diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..d6b7ac3
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,36 @@
+# Connection string for PostgreSQL. The indexer and API both read from this.
+DATABASE_URL=postgresql://trident:password@localhost:5432/trident
+
+# Connection string for Redis. Used by the indexer to publish events and by
+# the API to consume them for WebSocket delivery.
+REDIS_URL=redis://localhost:6379
+
+# Soroban RPC endpoint. For testnet use https://soroban-testnet.stellar.org
+# For mainnet use https://horizon.stellar.org/soroban/rpc or a private node.
+STELLAR_RPC_URL=https://soroban-testnet.stellar.org
+
+# Network identifier passed through to the indexer for validation and labelling.
+# One of: mainnet | testnet | futurenet
+NETWORK=testnet
+
+# How often the indexer polls the RPC node for new ledgers, in milliseconds.
+POLL_INTERVAL_MS=5000
+
+# Set to true to store diagnostic events (emitted only in debug mode by Soroban).
+# Leave false for production — diagnostic events are high-volume and rarely useful.
+INDEX_DIAGNOSTIC=false
+
+# Log verbosity level. One of: error | warn | info | debug | trace
+LOG_LEVEL=info
+
+# Port the Go API server listens on.
+PORT=3000
+
+# Random secret used to salt API key hashes. Must be changed before deployment.
+# Generate with: openssl rand -hex 32
+API_KEY_SALT=change-this-to-a-random-string
+
+# PostgreSQL credentials (used by docker-compose.yml in production).
+POSTGRES_USER=trident
+POSTGRES_PASSWORD=password
+POSTGRES_DB=trident
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..6d0c2d5
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,86 @@
+name: CI
+
+on:
+ push:
+ branches: [dev, main]
+ pull_request:
+ branches: [dev, main]
+
+jobs:
+ # ---------------------------------------------------------------------------
+ # Rust — format, lint, test
+ # ---------------------------------------------------------------------------
+ rust:
+ name: Rust
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ components: rustfmt, clippy
+
+ - name: Cache Rust build artefacts
+ uses: Swatinem/rust-cache@v2
+
+ - name: Check formatting
+ run: cargo fmt --all -- --check
+
+ - name: Clippy (deny warnings)
+ run: cargo clippy --all-targets --all-features -- -D warnings
+
+ - name: Tests
+ run: cargo test --all
+
+ # ---------------------------------------------------------------------------
+ # Go — vet and lint
+ # ---------------------------------------------------------------------------
+ go:
+ name: Go
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: services/api
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: "1.22"
+ cache-dependency-path: services/api/go.sum
+
+ - name: go vet
+ run: go vet ./...
+
+ - name: golangci-lint
+ uses: golangci/golangci-lint-action@v6
+ with:
+ version: latest
+ working-directory: services/api
+
+ # ---------------------------------------------------------------------------
+ # TypeScript — type check
+ # ---------------------------------------------------------------------------
+ typescript:
+ name: TypeScript
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: sdk/typescript
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: npm
+ cache-dependency-path: sdk/typescript/package-lock.json
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Type check
+ run: npm run lint
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..85546bc
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,33 @@
+# Rust
+/target
+**/target/
+Cargo.lock
+**/*.rs.bk
+
+# Go
+services/api/bin/
+*.exe
+*.out
+
+# Node / TypeScript
+node_modules/
+sdk/typescript/dist/
+sdk/typescript/.turbo/
+*.tsbuildinfo
+
+# Environment
+.env
+.env.local
+.env.*.local
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+.DS_Store
+Thumbs.db
+
+# Misc
+*.log
+dist/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..250298d
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,111 @@
+# Contributing to Trident
+
+Trident is infrastructure — the kind of system other developers will build products on top of without thinking about it. That means the bar for what gets merged is higher than it would be for an application. Code here needs to be correct, readable, and maintainable by someone who didn't write it. Those three things, in that order.
+
+---
+
+## Before the Codebase Is Public
+
+The most useful thing you can do right now is engage with the design while it can still change. Read the full specification at [`docs/SPECIFICATION.md`](./docs/SPECIFICATION.md) and open an issue if something looks wrong, underspecified, or like a decision that hasn't been thought through. Open a [Discussion](https://github.com/trident-build/trident/discussions) and describe what you're building on Stellar and what you'd need from an indexer. If you've built event pipelines or indexing infrastructure before, your architectural critique matters more right now than it will once the code exists.
+
+---
+
+## Getting Set Up
+
+You'll need Rust via [rustup](https://rustup.rs), Node.js 20 LTS for the TypeScript SDK, and Docker with Compose v2. Once those are in place, getting a local environment running should take under ten minutes.
+
+```bash
+git clone https://github.com/trident-build/trident.git
+cd trident
+cp .env.example .env
+docker compose -f docker/docker-compose.dev.yml up -d
+cargo build
+```
+
+The dev Compose file starts PostgreSQL and Redis only. You run the indexer and API locally so changes reflect immediately without rebuilding containers. To work with real data, set `STELLAR_RPC_URL` and `NETWORK=testnet` in your `.env`. Full setup details will live in [`docs/development.md`](./docs/development.md) once the repo is scaffolded.
+
+---
+
+## How the Repo Is Structured
+
+```
+trident/
+├── crates/
+│ ├── indexer/ # Rust core — streamer, XDR parser, cursor management
+│ ├── api/ # Rust gRPC server
+│ └── common/ # Shared types, errors, config
+├── services/
+│ └── api/ # Go front office — REST, GraphQL, WebSocket, Redis consumer
+├── sdk/
+│ └── typescript/ # TypeScript SDK (@trident-indexer/sdk)
+├── database/
+│ ├── schema.sql # Canonical PostgreSQL schema
+│ └── migrations/ # Versioned, append-only migration files
+├── docker/
+└── docs/
+```
+
+The Rust crates own everything from the chain to storage. The Go service owns everything from storage to the developer. The TypeScript SDK is a pure HTTP and WebSocket client with no knowledge of the database and no direct connection to any internal service.
+
+---
+
+## Workflow
+
+Before writing code for anything non-trivial, open an issue first and comment to claim it. This prevents duplicated effort and makes sure your approach aligns with where the project is heading before you invest time in it.
+
+All branches come off `dev` — `main` is for tagged releases only. Name branches as `type/issue-number-short-description`, for example `feature/42-graphql-subscriptions` or `fix/87-cursor-recovery`.
+
+Commit messages follow [Conventional Commits](https://www.conventionalcommits.org) with type, scope, and an imperative description:
+
+```
+feat(indexer): add cursor recovery on restart
+fix(api): return 404 when event id not found
+docs(sdk): add subscribeToContract example
+chore(deps): update tokio to 1.35
+```
+
+Valid types are `feat`, `fix`, `docs`, `test`, `refactor`, `perf`, and `chore`. Valid scopes are `indexer`, `api`, `sdk`, `db`, `docker`, and `docs`. Reference the issue in the commit footer with `Closes #NNN`.
+
+---
+
+## Pull Requests
+
+A PR that moves through review quickly explains *why* the change exists, not just what it does. It has tests covering the new behaviour — for bug fixes specifically, a regression test that would have caught the original bug. CI is green before review is requested. It changes one thing.
+
+A PR that comes back for revision is one that mixes concerns, doesn't explain the reasoning, or changes a public interface without prior discussion. One concern per PR is a firm rule regardless of how small the changes are.
+
+---
+
+## Code Standards
+
+On the Rust side, `cargo clippy` must pass clean and `cargo fmt` must produce no diff — both enforced in CI with no exceptions. `.unwrap()` is not allowed in non-test code because a panic in the streaming loop means missed events, and missed events are the one thing Trident cannot tolerate. New error variants go in `crates/common`. Public functions and types get doc comments.
+
+On the Go side, `golangci-lint` must pass. Errors are returned and never ignored. Every function doing I/O takes a `context.Context` as its first argument. Error messages returned to developers need to be genuinely useful — not "internal server error" but something that tells them exactly what was wrong and how to fix it.
+
+On the TypeScript side, strict mode is on and `any` is not allowed. The SDK's public interface deserves particular care because renaming anything after v1 is a breaking change requiring a major version bump and migration docs. ESLint and Prettier must be clean.
+
+SQL migrations are append-only — a migration committed to `main` is never edited, only superseded by a new numbered one. Index names follow `idx_
_`. Production queries always name columns explicitly.
+
+---
+
+## Good First Issues
+
+Once development starts, we'll tag approachable issues with `good first issue`. These tend to be things like adding a missing filter parameter to an endpoint, writing tests for an existing parser function, improving an error message with more context, or documenting an undocumented function. They're scoped to help you understand the system without needing to know all of it upfront.
+
+For more substantial work — changes to the indexer core, query performance improvements, new API capabilities — open a Discussion with a concrete use case before writing any code. The indexer's streaming and cursor logic in particular warrants a conversation before anyone touches it.
+
+---
+
+## Security
+
+Security issues must not be filed as public GitHub issues. Send them to `security@trident.build` and expect a response within 48 hours.
+
+---
+
+## Getting Help
+
+[GitHub Discussions](https://github.com/trident-build/trident/discussions) is the right place for questions, ideas, and design conversations before an issue is opened. [GitHub Issues](https://github.com/trident-build/trident/issues) is for confirmed bugs and concrete feature requests. For anything that shouldn't be public, reach out at `contributors@trident.build`.
+
+---
+
+*Trident is infrastructure for the whole Stellar ecosystem. Getting it right matters. Thanks for helping.*
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..60d9e22
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,7 @@
+[workspace]
+members = [
+ "crates/indexer",
+ "crates/api",
+ "crates/common",
+]
+resolver = "2"
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..e8b09ba
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 Depo-dev
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 026bd1a..9c7d4a3 100644
--- a/README.md
+++ b/README.md
@@ -72,11 +72,39 @@ Full historical event storage with no enforced retention limit, so a query again
- [x] Architecture defined
- [x] Full specification written — [`docs/SPECIFICATION.md`](./docs/SPECIFICATION.md)
-- [ ] Repository scaffolding
+- [x] Repository scaffolded
+- [x] CI pipeline active
- [ ] Phase 1 development begins
---
+## Contributing
+
+All branches come off `dev`. Before opening a pull request, make sure all three CI checks pass locally — the pipeline will block merge if any of them fail.
+
+**Rust**
+```bash
+cargo fmt --all # format — CI runs --check, so this must be clean
+cargo clippy --all-targets --all-features -- -D warnings
+cargo test --all
+```
+
+**Go** (`services/api`)
+```bash
+go vet ./...
+golangci-lint run # install: https://golangci-lint.run/usage/install/
+```
+
+**TypeScript** (`sdk/typescript`)
+```bash
+npm ci
+npm run lint # runs tsc --noEmit in strict mode
+```
+
+Running these before pushing means CI passes on the first try. See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for branching conventions, commit message format, and code standards.
+
+---
+
*Build on Stellar. Query everything.*
diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml
new file mode 100644
index 0000000..e3d7bf4
--- /dev/null
+++ b/crates/api/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "trident-api"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+trident-common = { path = "../common" }
+tokio = { version = "1", features = ["full"] }
+tonic = "0.11"
+prost = "0.12"
+tokio-stream = "0.1"
+tracing = "0.1"
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+
+[build-dependencies]
+tonic-build = "0.11"
+protoc-bin-vendored = "3"
diff --git a/crates/api/build.rs b/crates/api/build.rs
new file mode 100644
index 0000000..47fae43
--- /dev/null
+++ b/crates/api/build.rs
@@ -0,0 +1,12 @@
+fn main() -> Result<(), Box> {
+ // Use the vendored protoc binary so neither CI nor local dev need a system install.
+ let protoc_path = protoc_bin_vendored::protoc_bin_path()?;
+ std::env::set_var("PROTOC", protoc_path);
+
+ tonic_build::configure()
+ .build_server(true)
+ .build_client(false)
+ .compile(&["../../proto/trident.proto"], &["../../proto"])?;
+
+ Ok(())
+}
diff --git a/crates/api/src/main.rs b/crates/api/src/main.rs
new file mode 100644
index 0000000..9064a5e
--- /dev/null
+++ b/crates/api/src/main.rs
@@ -0,0 +1,30 @@
+use std::net::SocketAddr;
+use tracing_subscriber::EnvFilter;
+
+pub mod trident {
+ tonic::include_proto!("trident");
+}
+
+mod services;
+
+#[tokio::main]
+async fn main() -> Result<(), Box> {
+ tracing_subscriber::fmt()
+ .with_env_filter(EnvFilter::from_default_env())
+ .init();
+
+ let addr: SocketAddr = std::env::var("GRPC_ADDR")
+ .unwrap_or_else(|_| "0.0.0.0:50051".into())
+ .parse()?;
+
+ tracing::info!(%addr, "Trident gRPC server listening");
+
+ let events_service = services::events::EventsServiceImpl::new();
+
+ tonic::transport::Server::builder()
+ .add_service(trident::events_server::EventsServer::new(events_service))
+ .serve(addr)
+ .await?;
+
+ Ok(())
+}
diff --git a/crates/api/src/services/events.rs b/crates/api/src/services/events.rs
new file mode 100644
index 0000000..8619779
--- /dev/null
+++ b/crates/api/src/services/events.rs
@@ -0,0 +1,59 @@
+use tonic::{Request, Response, Status};
+
+use crate::trident::{
+ events_server::Events, Event, GetEventRequest, ListEventsRequest, ListEventsResponse,
+ StreamEventsRequest,
+};
+
+pub struct EventsServiceImpl {
+ // TODO: db: sqlx::PgPool
+ // TODO: redis: redis::aio::MultiplexedConnection (for StreamEvents)
+}
+
+impl EventsServiceImpl {
+ pub fn new() -> Self {
+ Self {}
+ }
+}
+
+#[tonic::async_trait]
+impl Events for EventsServiceImpl {
+ /// Return a paginated list of historical events matching the filter.
+ async fn list_events(
+ &self,
+ request: Request,
+ ) -> Result, Status> {
+ let _req = request.into_inner();
+ // TODO: build WHERE clause from filter fields
+ // TODO: execute paginated query against soroban_events
+ // TODO: serialise rows to proto Event messages
+ // TODO: compute next_cursor from the last row's id
+ Err(Status::unimplemented("list_events not yet implemented"))
+ }
+
+ /// Return a single event by UUID.
+ async fn get_event(
+ &self,
+ request: Request,
+ ) -> Result, Status> {
+ let _req = request.into_inner();
+ // TODO: SELECT * FROM soroban_events WHERE id = $1
+ // TODO: return Status::not_found if no row
+ Err(Status::unimplemented("get_event not yet implemented"))
+ }
+
+ type StreamEventsStream = tokio_stream::wrappers::ReceiverStream>;
+
+ /// Stream real-time events for a contract from Redis Streams.
+ async fn stream_events(
+ &self,
+ request: Request,
+ ) -> Result, Status> {
+ let _req = request.into_inner();
+ // TODO: create a tokio mpsc channel
+ // TODO: spawn a task that reads from the Redis stream via XREAD BLOCK
+ // and sends matching events down the channel
+ // TODO: return ReceiverStream wrapping the receiver
+ Err(Status::unimplemented("stream_events not yet implemented"))
+ }
+}
diff --git a/crates/api/src/services/mod.rs b/crates/api/src/services/mod.rs
new file mode 100644
index 0000000..a9970c2
--- /dev/null
+++ b/crates/api/src/services/mod.rs
@@ -0,0 +1 @@
+pub mod events;
diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml
new file mode 100644
index 0000000..95b5496
--- /dev/null
+++ b/crates/common/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "trident-common"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+thiserror = "1"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
diff --git a/crates/common/src/errors.rs b/crates/common/src/errors.rs
new file mode 100644
index 0000000..114e221
--- /dev/null
+++ b/crates/common/src/errors.rs
@@ -0,0 +1,20 @@
+use thiserror::Error;
+
+#[derive(Debug, Error)]
+pub enum TridentError {
+ /// Failure communicating with or parsing a response from Stellar RPC.
+ #[error("RPC error: {0}")]
+ RpcError(String),
+
+ /// Failure decoding or normalising raw XDR event data.
+ #[error("Parse error: {0}")]
+ ParseError(String),
+
+ /// Failure reading from or writing to PostgreSQL or Redis.
+ #[error("Storage error: {0}")]
+ StorageError(String),
+
+ /// Missing or invalid configuration value.
+ #[error("Config error: {0}")]
+ ConfigError(String),
+}
diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs
new file mode 100644
index 0000000..d1bd738
--- /dev/null
+++ b/crates/common/src/lib.rs
@@ -0,0 +1,5 @@
+pub mod errors;
+pub mod types;
+
+pub use errors::TridentError;
+pub use types::{EventType, SorobanEvent};
diff --git a/crates/common/src/types.rs b/crates/common/src/types.rs
new file mode 100644
index 0000000..2974719
--- /dev/null
+++ b/crates/common/src/types.rs
@@ -0,0 +1,36 @@
+use serde::{Deserialize, Serialize};
+
+/// Distinguishes the three event categories emitted by the Soroban runtime.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum EventType {
+ /// Emitted explicitly by contract code via `env.events().publish(...)`.
+ Contract,
+ /// Emitted by the Soroban host itself (e.g. fee events).
+ System,
+ /// Emitted only when diagnostic mode is enabled; never stored by default.
+ Diagnostic,
+}
+
+/// Normalised representation of a single Soroban event as stored in PostgreSQL
+/// and published onto Redis Streams.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct SorobanEvent {
+ /// Strkey-encoded contract address (C...).
+ pub contract_id: String,
+ /// Ordered list of topic values, XDR-decoded to their string representations.
+ pub topics: Vec,
+ /// Decoded event body. Scalar XDR types are coerced to JSON primitives;
+ /// map/vec types become JSON objects/arrays.
+ pub data: serde_json::Value,
+ /// Ledger sequence number in which this event was emitted.
+ pub ledger_sequence: u64,
+ /// ISO 8601 UTC timestamp of the ledger close.
+ pub ledger_timestamp: String,
+ /// Hash of the transaction that emitted this event.
+ pub transaction_hash: String,
+ /// Zero-based index of this event within its transaction.
+ pub event_index: u32,
+ /// Category of event as reported by the Soroban host.
+ pub event_type: EventType,
+}
diff --git a/crates/indexer/Cargo.toml b/crates/indexer/Cargo.toml
new file mode 100644
index 0000000..599090d
--- /dev/null
+++ b/crates/indexer/Cargo.toml
@@ -0,0 +1,49 @@
+[package]
+name = "trident-indexer"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+trident-common = { path = "../common" }
+
+# Async runtime
+tokio = { version = "1", features = ["full"] }
+
+# Logging
+tracing = "0.1"
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+
+# HTTP client for Stellar RPC JSON-RPC calls
+reqwest = { version = "0.11", features = ["json"] }
+
+# Database
+sqlx = { version = "0.7", features = ["postgres", "runtime-tokio-native-tls", "uuid", "chrono", "json"] }
+
+# Redis
+redis = { version = "0.24", features = ["tokio-comp"] }
+
+# Stellar XDR decoding — pin to a specific protocol version via the "curr" feature
+# If the network upgrades to a new protocol, update this version accordingly.
+stellar-xdr = { version = "26.0.1", features = ["curr"] }
+
+# Strkey encoding for contract and account addresses
+stellar-strkey = "0.0.16"
+
+# Base64 decoding for XDR payloads
+base64 = "0.22"
+
+# Serialisation
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+
+# UUIDs for event primary keys
+uuid = { version = "1", features = ["v4", "serde"] }
+
+# Timestamps
+chrono = { version = "0.4", features = ["serde"] }
+
+# Hex encoding for bytes values
+hex = "0.4"
+
+# Retry logic with exponential backoff
+tokio-retry = "0.3"
diff --git a/crates/indexer/src/config.rs b/crates/indexer/src/config.rs
new file mode 100644
index 0000000..901e93e
--- /dev/null
+++ b/crates/indexer/src/config.rs
@@ -0,0 +1,36 @@
+use std::time::Duration;
+use trident_common::TridentError;
+
+pub struct Config {
+ pub database_url: String,
+ pub redis_url: String,
+ pub stellar_rpc_url: String,
+ pub network: String,
+ pub poll_interval: Duration,
+ pub index_diagnostic: bool,
+}
+
+impl Config {
+ pub fn from_env() -> Result {
+ Ok(Self {
+ database_url: require_env("DATABASE_URL")?,
+ redis_url: require_env("REDIS_URL")?,
+ stellar_rpc_url: require_env("STELLAR_RPC_URL")?,
+ network: std::env::var("NETWORK").unwrap_or_else(|_| "testnet".into()),
+ poll_interval: Duration::from_millis(
+ std::env::var("POLL_INTERVAL_MS")
+ .ok()
+ .and_then(|s| s.parse().ok())
+ .unwrap_or(5000),
+ ),
+ index_diagnostic: std::env::var("INDEX_DIAGNOSTIC")
+ .map(|v| v.eq_ignore_ascii_case("true"))
+ .unwrap_or(false),
+ })
+ }
+}
+
+fn require_env(key: &str) -> Result {
+ std::env::var(key)
+ .map_err(|_| TridentError::ConfigError(format!("{key} is required but not set")))
+}
diff --git a/crates/indexer/src/db/mod.rs b/crates/indexer/src/db/mod.rs
new file mode 100644
index 0000000..13a3057
--- /dev/null
+++ b/crates/indexer/src/db/mod.rs
@@ -0,0 +1,102 @@
+use chrono::{DateTime, Utc};
+use sqlx::PgPool;
+use trident_common::{EventType, SorobanEvent, TridentError};
+use uuid::Uuid;
+
+/// Insert a normalised event. Silently ignores duplicates (same tx_hash + event_index)
+/// because the streamer may replay events during cursor recovery.
+pub async fn insert_event(pool: &PgPool, event: &SorobanEvent) -> Result<(), TridentError> {
+ let id = Uuid::new_v4();
+ let event_type = match event.event_type {
+ EventType::Contract => "contract",
+ EventType::System => "system",
+ EventType::Diagnostic => "diagnostic",
+ };
+ let topics = serde_json::to_value(&event.topics)
+ .map_err(|e| TridentError::StorageError(format!("topics serialise: {e}")))?;
+ let ledger_ts: DateTime = event
+ .ledger_timestamp
+ .parse()
+ .map_err(|e| TridentError::StorageError(format!("ledger timestamp parse: {e}")))?;
+
+ sqlx::query(
+ r#"
+ INSERT INTO soroban_events
+ (id, contract_id, ledger_sequence, ledger_timestamp, transaction_hash,
+ event_index, event_type, topics, data)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
+ ON CONFLICT (transaction_hash, event_index) DO NOTHING
+ "#,
+ )
+ .bind(id)
+ .bind(&event.contract_id)
+ .bind(event.ledger_sequence as i64)
+ .bind(ledger_ts)
+ .bind(&event.transaction_hash)
+ .bind(event.event_index as i32)
+ .bind(event_type)
+ .bind(&topics)
+ .bind(&event.data)
+ .execute(pool)
+ .await
+ .map_err(|e| TridentError::StorageError(format!("insert_event: {e}")))?;
+
+ Ok(())
+}
+
+/// Read the latest processed ledger cursor from system_state.
+pub async fn get_cursor(pool: &PgPool) -> Result {
+ let row: (String,) =
+ sqlx::query_as("SELECT value FROM system_state WHERE key = 'latest_ledger_cursor'")
+ .fetch_one(pool)
+ .await
+ .map_err(|e| TridentError::StorageError(format!("get_cursor: {e}")))?;
+
+ row.0
+ .parse::()
+ .map_err(|e| TridentError::StorageError(format!("cursor parse: {e}")))
+}
+
+/// Persist the latest processed ledger sequence so the streamer can resume
+/// from the correct position after a restart.
+pub async fn set_cursor(pool: &PgPool, ledger: u64) -> Result<(), TridentError> {
+ sqlx::query(
+ "UPDATE system_state SET value = $1, updated_at = NOW() WHERE key = 'latest_ledger_cursor'",
+ )
+ .bind(ledger.to_string())
+ .execute(pool)
+ .await
+ .map_err(|e| TridentError::StorageError(format!("set_cursor: {e}")))?;
+
+ Ok(())
+}
+
+/// Record a processed ledger in ledger_metadata for gap detection.
+pub async fn insert_ledger_metadata(
+ pool: &PgPool,
+ ledger_sequence: u64,
+ ledger_hash: &str,
+ ledger_timestamp: &str,
+ event_count: i32,
+) -> Result<(), TridentError> {
+ let ts: DateTime = ledger_timestamp
+ .parse()
+ .map_err(|e| TridentError::StorageError(format!("ledger timestamp parse: {e}")))?;
+
+ sqlx::query(
+ r#"
+ INSERT INTO ledger_metadata (ledger_sequence, ledger_hash, ledger_timestamp, event_count)
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT (ledger_sequence) DO NOTHING
+ "#,
+ )
+ .bind(ledger_sequence as i64)
+ .bind(ledger_hash)
+ .bind(ts)
+ .bind(event_count)
+ .execute(pool)
+ .await
+ .map_err(|e| TridentError::StorageError(format!("insert_ledger_metadata: {e}")))?;
+
+ Ok(())
+}
diff --git a/crates/indexer/src/main.rs b/crates/indexer/src/main.rs
new file mode 100644
index 0000000..3f34838
--- /dev/null
+++ b/crates/indexer/src/main.rs
@@ -0,0 +1,31 @@
+use tracing_subscriber::EnvFilter;
+
+mod config;
+mod db;
+mod parser;
+mod redis_stream;
+mod rpc;
+mod streamer;
+
+#[tokio::main]
+async fn main() -> Result<(), Box> {
+ tracing_subscriber::fmt()
+ .with_env_filter(EnvFilter::from_default_env())
+ .init();
+
+ tracing::info!("Trident indexer starting");
+
+ let cfg = config::Config::from_env()?;
+
+ let db_pool = sqlx::PgPool::connect(&cfg.database_url).await?;
+ tracing::info!("Database connected");
+
+ let redis_client = redis::Client::open(cfg.redis_url.as_str())?;
+ let redis_conn = redis_client.get_multiplexed_async_connection().await?;
+ tracing::info!("Redis connected");
+
+ let mut s = streamer::Streamer::new(cfg, db_pool, redis_conn);
+ s.run().await?;
+
+ Ok(())
+}
diff --git a/crates/indexer/src/parser/mod.rs b/crates/indexer/src/parser/mod.rs
new file mode 100644
index 0000000..45d6b05
--- /dev/null
+++ b/crates/indexer/src/parser/mod.rs
@@ -0,0 +1,203 @@
+//! # Parser
+//!
+//! Owns XDR decoding and event normalisation. Responsibilities:
+//!
+//! - Decoding raw base64-encoded XDR strings as returned by the Soroban RPC
+//! `getEvents` method into typed Rust values via the `stellar-xdr` crate.
+//! - Normalising decoded `ScVal` topics into human-readable string representations
+//! and the event body into a `serde_json::Value` for storage and forwarding.
+//! - Type coercion: Symbol/String → plain string, Address → strkey, I128/U128 →
+//! decimal string, Bool → "true"/"false", Bytes → hex, Map/Vec → JSON object/array.
+//! - Returning `TridentError::ParseError` for any input that cannot be decoded so
+//! the caller (Streamer) can decide whether to skip or halt.
+
+use base64::{engine::general_purpose::STANDARD, Engine};
+use serde_json::Value as Json;
+use stellar_strkey::{ed25519, Contract};
+use stellar_xdr::curr::{
+ AccountId, ContractId, Limited, Limits, PublicKey, ReadXdr, ScAddress, ScVal,
+};
+use trident_common::{EventType, SorobanEvent, TridentError};
+
+use crate::rpc::RawEvent;
+
+pub struct Parser {
+ pub index_diagnostic: bool,
+}
+
+impl Parser {
+ pub fn new(index_diagnostic: bool) -> Self {
+ Self { index_diagnostic }
+ }
+
+ /// Decode a raw RPC event into a normalised `SorobanEvent`.
+ ///
+ /// Returns `None` if the event type is `diagnostic` and `index_diagnostic`
+ /// is false — the caller should silently skip `None` returns.
+ pub fn parse_event(&self, raw: &RawEvent) -> Result