From 5aa2ec1e9f3ba9e433670fd2ccab7a4163bdf49b Mon Sep 17 00:00:00 2001 From: Depo-dev Date: Tue, 19 May 2026 14:12:23 +0100 Subject: [PATCH 01/16] chore: initialise monorepo structure Add workspace Cargo.toml, MIT LICENSE, CONTRIBUTING.md, and .gitignore covering Rust, Go, Node, environment files, and IDE artifacts. --- .gitignore | 33 ++++++++++++++ CONTRIBUTING.md | 111 ++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 7 +++ LICENSE | 21 +++++++++ 4 files changed, 172 insertions(+) create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 Cargo.toml create mode 100644 LICENSE 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. From e14a718edfb8275982f0caaff508a33bbfc67b5f Mon Sep 17 00:00:00 2001 From: Depo-dev Date: Tue, 19 May 2026 14:13:18 +0100 Subject: [PATCH 02/16] feat(db): add PostgreSQL schema and initial migration Define soroban_events with all columns, computed topic columns, composite and GIN indexes. Add system_state for cursor tracking, indexed_contracts for the contract registry, and ledger_metadata for gap detection. Migration 0001_init.sql mirrors schema.sql at this stage. --- database/migrations/0001_init.sql | 91 +++++++++++++++++++++++++++++++ database/schema.sql | 91 +++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 database/migrations/0001_init.sql create mode 100644 database/schema.sql diff --git a/database/migrations/0001_init.sql b/database/migrations/0001_init.sql new file mode 100644 index 0000000..a3d28bc --- /dev/null +++ b/database/migrations/0001_init.sql @@ -0,0 +1,91 @@ +-- Trident PostgreSQL Schema +-- Canonical definition. Migrations in ./migrations/ mirror this file incrementally. + +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- --------------------------------------------------------------------------- +-- soroban_events +-- Primary store for every indexed Soroban contract event. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS soroban_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contract_id TEXT NOT NULL, + ledger_sequence BIGINT NOT NULL, + ledger_timestamp TIMESTAMPTZ NOT NULL, + transaction_hash TEXT NOT NULL, + event_index INTEGER NOT NULL, + event_type TEXT NOT NULL CHECK (event_type IN ('contract', 'system', 'diagnostic')), + topics JSONB NOT NULL DEFAULT '[]', + topic_0 TEXT GENERATED ALWAYS AS (topics ->> 0) STORED, + topic_1 TEXT GENERATED ALWAYS AS (topics ->> 1) STORED, + data JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Single-column indexes +CREATE INDEX IF NOT EXISTS idx_soroban_events_contract_id ON soroban_events (contract_id); +CREATE INDEX IF NOT EXISTS idx_soroban_events_ledger_sequence ON soroban_events (ledger_sequence); +CREATE INDEX IF NOT EXISTS idx_soroban_events_ledger_timestamp ON soroban_events (ledger_timestamp); +CREATE INDEX IF NOT EXISTS idx_soroban_events_topic_0 ON soroban_events (topic_0); +CREATE INDEX IF NOT EXISTS idx_soroban_events_topic_1 ON soroban_events (topic_1); + +-- Composite index covering the most common query pattern: events for a +-- contract filtered by primary topic (e.g. all "transfer" events for token X) +CREATE INDEX IF NOT EXISTS idx_soroban_events_contract_topic_0 ON soroban_events (contract_id, topic_0); + +-- GIN index for arbitrary topic containment queries +CREATE INDEX IF NOT EXISTS idx_soroban_events_topics_gin ON soroban_events USING GIN (topics); + +-- Unique constraint: a given event position within a transaction is immutable +ALTER TABLE soroban_events + ADD CONSTRAINT uq_soroban_events_tx_index + UNIQUE (transaction_hash, event_index); + +-- --------------------------------------------------------------------------- +-- system_state +-- Persistent cursor tracking so the indexer can resume after restart without +-- re-scanning from genesis. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS system_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Seed the cursor row so the indexer can always do an UPDATE rather than +-- an upsert on the hot path. +INSERT INTO system_state (key, value) +VALUES ('latest_ledger_cursor', '0') +ON CONFLICT (key) DO NOTHING; + +-- --------------------------------------------------------------------------- +-- indexed_contracts +-- Registry of contracts whose events Trident is actively indexing. +-- A NULL network means "all networks". +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS indexed_contracts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contract_id TEXT NOT NULL, + network TEXT, + label TEXT, + index_from BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_indexed_contracts_id_network UNIQUE (contract_id, network) +); + +CREATE INDEX IF NOT EXISTS idx_indexed_contracts_contract_id ON indexed_contracts (contract_id); + +-- --------------------------------------------------------------------------- +-- ledger_metadata +-- Lightweight record of every processed ledger for gap detection and +-- provenance tracking. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS ledger_metadata ( + ledger_sequence BIGINT PRIMARY KEY, + ledger_hash TEXT NOT NULL, + ledger_timestamp TIMESTAMPTZ NOT NULL, + event_count INTEGER NOT NULL DEFAULT 0, + processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_ledger_metadata_timestamp ON ledger_metadata (ledger_timestamp); diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 0000000..a3d28bc --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,91 @@ +-- Trident PostgreSQL Schema +-- Canonical definition. Migrations in ./migrations/ mirror this file incrementally. + +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- --------------------------------------------------------------------------- +-- soroban_events +-- Primary store for every indexed Soroban contract event. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS soroban_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contract_id TEXT NOT NULL, + ledger_sequence BIGINT NOT NULL, + ledger_timestamp TIMESTAMPTZ NOT NULL, + transaction_hash TEXT NOT NULL, + event_index INTEGER NOT NULL, + event_type TEXT NOT NULL CHECK (event_type IN ('contract', 'system', 'diagnostic')), + topics JSONB NOT NULL DEFAULT '[]', + topic_0 TEXT GENERATED ALWAYS AS (topics ->> 0) STORED, + topic_1 TEXT GENERATED ALWAYS AS (topics ->> 1) STORED, + data JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Single-column indexes +CREATE INDEX IF NOT EXISTS idx_soroban_events_contract_id ON soroban_events (contract_id); +CREATE INDEX IF NOT EXISTS idx_soroban_events_ledger_sequence ON soroban_events (ledger_sequence); +CREATE INDEX IF NOT EXISTS idx_soroban_events_ledger_timestamp ON soroban_events (ledger_timestamp); +CREATE INDEX IF NOT EXISTS idx_soroban_events_topic_0 ON soroban_events (topic_0); +CREATE INDEX IF NOT EXISTS idx_soroban_events_topic_1 ON soroban_events (topic_1); + +-- Composite index covering the most common query pattern: events for a +-- contract filtered by primary topic (e.g. all "transfer" events for token X) +CREATE INDEX IF NOT EXISTS idx_soroban_events_contract_topic_0 ON soroban_events (contract_id, topic_0); + +-- GIN index for arbitrary topic containment queries +CREATE INDEX IF NOT EXISTS idx_soroban_events_topics_gin ON soroban_events USING GIN (topics); + +-- Unique constraint: a given event position within a transaction is immutable +ALTER TABLE soroban_events + ADD CONSTRAINT uq_soroban_events_tx_index + UNIQUE (transaction_hash, event_index); + +-- --------------------------------------------------------------------------- +-- system_state +-- Persistent cursor tracking so the indexer can resume after restart without +-- re-scanning from genesis. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS system_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Seed the cursor row so the indexer can always do an UPDATE rather than +-- an upsert on the hot path. +INSERT INTO system_state (key, value) +VALUES ('latest_ledger_cursor', '0') +ON CONFLICT (key) DO NOTHING; + +-- --------------------------------------------------------------------------- +-- indexed_contracts +-- Registry of contracts whose events Trident is actively indexing. +-- A NULL network means "all networks". +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS indexed_contracts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contract_id TEXT NOT NULL, + network TEXT, + label TEXT, + index_from BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_indexed_contracts_id_network UNIQUE (contract_id, network) +); + +CREATE INDEX IF NOT EXISTS idx_indexed_contracts_contract_id ON indexed_contracts (contract_id); + +-- --------------------------------------------------------------------------- +-- ledger_metadata +-- Lightweight record of every processed ledger for gap detection and +-- provenance tracking. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS ledger_metadata ( + ledger_sequence BIGINT PRIMARY KEY, + ledger_hash TEXT NOT NULL, + ledger_timestamp TIMESTAMPTZ NOT NULL, + event_count INTEGER NOT NULL DEFAULT 0, + processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_ledger_metadata_timestamp ON ledger_metadata (ledger_timestamp); From e8823e9df8b7843b1561bd4466f015251e8194b6 Mon Sep 17 00:00:00 2001 From: Depo-dev Date: Tue, 19 May 2026 14:13:50 +0100 Subject: [PATCH 03/16] feat(common): add shared error types and SorobanEvent struct TridentError covers RpcError, ParseError, StorageError, and ConfigError via thiserror. SorobanEvent is the canonical normalised event record shared across the indexer, gRPC server, and any future crates. --- crates/common/Cargo.toml | 9 +++++++++ crates/common/src/errors.rs | 20 ++++++++++++++++++++ crates/common/src/lib.rs | 5 +++++ crates/common/src/types.rs | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+) create mode 100644 crates/common/Cargo.toml create mode 100644 crates/common/src/errors.rs create mode 100644 crates/common/src/lib.rs create mode 100644 crates/common/src/types.rs 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..a8d6e51 --- /dev/null +++ b/crates/common/src/types.rs @@ -0,0 +1,34 @@ +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, + /// 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, +} From fcb72dd5a0f1dbfcfa75ba7ce2517b42e69b179c Mon Sep 17 00:00:00 2001 From: Depo-dev Date: Tue, 19 May 2026 14:14:34 +0100 Subject: [PATCH 04/16] feat(indexer): scaffold streamer and parser crates Streamer owns the RPC polling loop, cursor management, and retry logic. Parser owns XDR decoding, ScVal normalisation, and type coercion to the canonical SorobanEvent shape. Both modules have detailed comment blocks describing their full responsibilities for the next developer. --- crates/indexer/Cargo.toml | 16 ++++++++++++ crates/indexer/src/main.rs | 21 +++++++++++++++ crates/indexer/src/parser/mod.rs | 37 +++++++++++++++++++++++++++ crates/indexer/src/streamer/mod.rs | 41 ++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+) create mode 100644 crates/indexer/Cargo.toml create mode 100644 crates/indexer/src/main.rs create mode 100644 crates/indexer/src/parser/mod.rs create mode 100644 crates/indexer/src/streamer/mod.rs diff --git a/crates/indexer/Cargo.toml b/crates/indexer/Cargo.toml new file mode 100644 index 0000000..f61fb12 --- /dev/null +++ b/crates/indexer/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "trident-indexer" +version = "0.1.0" +edition = "2021" + +[dependencies] +trident-common = { path = "../common" } +tokio = { version = "1", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +reqwest = { version = "0.11", features = ["json"] } +sqlx = { version = "0.7", features = ["postgres", "runtime-tokio-native-tls", "uuid", "chrono"] } +redis = { version = "0.24", features = ["tokio-comp"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio-retry = "0.3" diff --git a/crates/indexer/src/main.rs b/crates/indexer/src/main.rs new file mode 100644 index 0000000..43e24eb --- /dev/null +++ b/crates/indexer/src/main.rs @@ -0,0 +1,21 @@ +use tracing_subscriber::EnvFilter; + +mod parser; +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"); + + // TODO: load config from environment + // TODO: initialise database pool (sqlx::PgPool) + // TODO: initialise Redis connection + // TODO: construct Streamer and Parser, wire them together + // TODO: call streamer.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..db82351 --- /dev/null +++ b/crates/indexer/src/parser/mod.rs @@ -0,0 +1,37 @@ +//! # 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 using the `stellar-xdr` crate. +//! - Normalising decoded XDR values into the canonical `SorobanEvent` shape: +//! converting `ScVal` topics to their string representations, coercing the +//! event body to `serde_json::Value`, and extracting ledger/tx metadata. +//! - Type coercion rules: ScVal::Symbol → plain string, ScVal::Address → strkey, +//! ScVal::I128/U128 → JSON number (with overflow to string for values outside +//! i64 range), ScVal::Map/Vec → JSON object/array recursively. +//! - Returning a typed `TridentError::ParseError` for any input that cannot be +//! decoded, so the caller can decide whether to skip, retry, or halt. + +use trident_common::{SorobanEvent, TridentError}; + +pub struct Parser; + +impl Parser { + pub fn new() -> Self { + Self + } + + /// Decode a raw XDR event string (as returned by `getEvents` RPC) into a + /// normalised `SorobanEvent`. Returns `TridentError::ParseError` if the + /// input cannot be decoded or required fields are missing. + pub fn parse_event(&self, raw_xdr: &str) -> Result { + let _ = raw_xdr; // suppress unused warning until implemented + // TODO: base64-decode raw_xdr + // TODO: XDR-decode using stellar-xdr ContractEvent + // TODO: normalise topics via ScVal → string conversion + // TODO: normalise data body via ScVal → serde_json::Value + // TODO: construct and return SorobanEvent + Err(TridentError::ParseError("not yet implemented".into())) + } +} diff --git a/crates/indexer/src/streamer/mod.rs b/crates/indexer/src/streamer/mod.rs new file mode 100644 index 0000000..b11831f --- /dev/null +++ b/crates/indexer/src/streamer/mod.rs @@ -0,0 +1,41 @@ +//! # Streamer +//! +//! Owns the RPC polling loop. Responsibilities: +//! +//! - Maintaining the ledger cursor: reading the last processed sequence from +//! `system_state`, advancing it after each successful batch, and persisting +//! it atomically with the events it covers. +//! - Calling `getEvents` on the Stellar Soroban RPC node on a configurable +//! interval (`POLL_INTERVAL_MS`), handling pagination across large ledger +//! ranges by following the `cursor` field in each response. +//! - Fault tolerance and retry logic: transient RPC failures should be retried +//! with exponential backoff; persistent failures must be logged and surfaced +//! without crashing the process or losing cursor position. +//! - Handing each raw event to the `Parser` and forwarding normalised +//! `SorobanEvent` values to both PostgreSQL and Redis Streams. + +use trident_common::TridentError; + +pub struct Streamer { + // TODO: rpc_url: String + // TODO: db: sqlx::PgPool + // TODO: redis: redis::aio::ConnectionManager + // TODO: poll_interval: std::time::Duration +} + +impl Streamer { + /// Construct a new Streamer. All dependencies are injected so they can be + /// shared with other components or replaced in tests. + pub fn new() -> Self { + Self {} + } + + /// Start the polling loop. This future runs indefinitely — it should be + /// spawned with `tokio::spawn` or driven directly from `main`. + pub async fn run(&self) -> Result<(), TridentError> { + tracing::info!("Streamer started"); + // TODO: read cursor from system_state + // TODO: loop: call getEvents RPC, parse, write to DB + Redis, advance cursor + Ok(()) + } +} From 5d4303e5efc85e022779cd926f74ff436efdd09b Mon Sep 17 00:00:00 2001 From: Depo-dev Date: Tue, 19 May 2026 14:15:11 +0100 Subject: [PATCH 05/16] feat(api): scaffold Rust gRPC server crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minimal Tonic server entry point listening on GRPC_ADDR (default 0.0.0.0:50051). Proto definitions and service implementations are deferred — the commented layout shows exactly where they slot in once .proto files are defined. --- crates/api/Cargo.toml | 15 +++++++++++++++ crates/api/src/main.rs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 crates/api/Cargo.toml create mode 100644 crates/api/src/main.rs diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml new file mode 100644 index 0000000..3c00b71 --- /dev/null +++ b/crates/api/Cargo.toml @@ -0,0 +1,15 @@ +[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" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[build-dependencies] +tonic-build = "0.11" diff --git a/crates/api/src/main.rs b/crates/api/src/main.rs new file mode 100644 index 0000000..bcd185a --- /dev/null +++ b/crates/api/src/main.rs @@ -0,0 +1,33 @@ +use std::net::SocketAddr; +use tracing_subscriber::EnvFilter; + +// Proto-generated code will live here once the .proto files are defined. +// Each RPC service gets its own module under src/services/. +// +// Example layout once protos are added: +// pub mod trident { tonic::include_proto!("trident"); } +// mod services { pub mod events; } + +#[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"); + + // TODO: implement the EventsService trait generated from protos + // TODO: register service with tonic::transport::Server + // TODO: add tls_config if TLS is required + + tonic::transport::Server::builder() + // .add_service(EventsServer::new(EventsService::new(db, redis))) + .serve(addr) + .await?; + + Ok(()) +} From c41e6a2462b232bebfb957dac4b9358e4b4052d3 Mon Sep 17 00:00:00 2001 From: Depo-dev Date: Tue, 19 May 2026 14:15:59 +0100 Subject: [PATCH 06/16] feat(services): scaffold Go front office API server Minimal stdlib HTTP server with graceful shutdown. Comment markers show exactly where the REST router, GraphQL handler, WebSocket handler, and Redis Streams consumer are wired in. Listens on PORT env var, default 3000. --- services/api/go.mod | 3 ++ services/api/go.sum | 0 services/api/main.go | 80 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 services/api/go.mod create mode 100644 services/api/go.sum create mode 100644 services/api/main.go diff --git a/services/api/go.mod b/services/api/go.mod new file mode 100644 index 0000000..1521f16 --- /dev/null +++ b/services/api/go.mod @@ -0,0 +1,3 @@ +module github.com/Depo-dev/trident/services/api + +go 1.22 diff --git a/services/api/go.sum b/services/api/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/services/api/main.go b/services/api/main.go new file mode 100644 index 0000000..ee5ec51 --- /dev/null +++ b/services/api/main.go @@ -0,0 +1,80 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "3000" + } + + mux := http.NewServeMux() + + // --------------------------------------------------------------------------- + // REST router + // Wire in the REST handler here. Each resource group gets its own handler + // func registered under /v1/. Example: + // mux.HandleFunc("GET /v1/events", handlers.ListEvents) + // mux.HandleFunc("GET /v1/events/{id}", handlers.GetEvent) + // --------------------------------------------------------------------------- + + // --------------------------------------------------------------------------- + // GraphQL handler + // Mount the GraphQL endpoint here, e.g. using gqlgen: + // srv := handler.NewDefaultServer(generated.NewExecutableSchema(cfg)) + // mux.Handle("/graphql", srv) + // mux.Handle("/playground", playground.Handler("Trident", "/graphql")) + // --------------------------------------------------------------------------- + + // --------------------------------------------------------------------------- + // WebSocket handler + // Mount the WebSocket subscription endpoint here. Clients subscribe to a + // contract address and receive a stream of SorobanEvent JSON objects in + // real time as they land on-chain. + // mux.HandleFunc("/ws", ws.Handler(redisClient)) + // --------------------------------------------------------------------------- + + // --------------------------------------------------------------------------- + // Redis Streams consumer + // Start the background consumer here. It reads from the Redis Stream written + // by the Rust indexer and fans out to connected WebSocket clients. + // go consumer.Start(ctx, redisClient, hub) + // --------------------------------------------------------------------------- + + server := &http.Server{ + Addr: fmt.Sprintf(":%s", port), + Handler: mux, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + go func() { + slog.Info("Trident API server listening", "port", port) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("server error", "err", err) + os.Exit(1) + } + }() + + <-ctx.Done() + slog.Info("shutting down") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + slog.Error("graceful shutdown failed", "err", err) + } +} From 6bb36d7b294bcf5f6ea9be2db3b44282fadf401d Mon Sep 17 00:00:00 2001 From: Depo-dev Date: Tue, 19 May 2026 14:16:54 +0100 Subject: [PATCH 07/16] feat(sdk): scaffold TypeScript SDK with TridentClient stub TridentClient accepts apiUrl, apiKey, and network. Three public methods with full type signatures: queryEvents (cursor-paginated), getEventById, and subscribeToContract (returns a Subscription handle). Zod schemas mirror the server-side SorobanEvent shape for runtime validation. --- sdk/typescript/package.json | 32 ++++++++++ sdk/typescript/src/index.ts | 118 +++++++++++++++++++++++++++++++++++ sdk/typescript/tsconfig.json | 19 ++++++ 3 files changed, 169 insertions(+) create mode 100644 sdk/typescript/package.json create mode 100644 sdk/typescript/src/index.ts create mode 100644 sdk/typescript/tsconfig.json diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json new file mode 100644 index 0000000..c2e8fd4 --- /dev/null +++ b/sdk/typescript/package.json @@ -0,0 +1,32 @@ +{ + "name": "@trident-indexer/sdk", + "version": "0.0.1", + "description": "TypeScript client SDK for the Trident Soroban event indexer", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": ["dist"], + "scripts": { + "build": "tsup src/index.ts --format cjs,esm --dts", + "dev": "tsup src/index.ts --format cjs,esm --dts --watch", + "lint": "tsc --noEmit" + }, + "dependencies": { + "zod": "^3.22.4" + }, + "devDependencies": { + "tsup": "^8.0.2", + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=20" + }, + "license": "MIT" +} diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts new file mode 100644 index 0000000..39f7072 --- /dev/null +++ b/sdk/typescript/src/index.ts @@ -0,0 +1,118 @@ +import { z } from "zod"; + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +export type Network = "mainnet" | "testnet" | "futurenet"; + +export interface TridentClientConfig { + apiUrl: string; + apiKey: string; + network: Network; +} + +// --------------------------------------------------------------------------- +// Domain types (mirrors SorobanEvent on the server side) +// --------------------------------------------------------------------------- + +export const EventTypeSchema = z.enum(["contract", "system", "diagnostic"]); +export type EventType = z.infer; + +export const SorobanEventSchema = z.object({ + id: z.string().uuid(), + contractId: z.string(), + ledgerSequence: z.number().int().nonnegative(), + ledgerTimestamp: z.string().datetime(), + transactionHash: z.string(), + eventIndex: z.number().int().nonnegative(), + eventType: EventTypeSchema, + topics: z.array(z.string()), + data: z.unknown(), + createdAt: z.string().datetime(), +}); +export type SorobanEvent = z.infer; + +// --------------------------------------------------------------------------- +// Query parameter types +// --------------------------------------------------------------------------- + +export interface QueryEventsParams { + contractId?: string; + topic0?: string; + topic1?: string; + ledgerFrom?: number; + ledgerTo?: number; + after?: string; + limit?: number; +} + +export interface GetEventByIdParams { + id: string; +} + +export interface SubscribeToContractParams { + contractId: string; + topic0?: string; + onEvent: (event: SorobanEvent) => void; + onError?: (error: Error) => void; +} + +export interface Subscription { + unsubscribe: () => void; +} + +export interface PaginatedEvents { + events: SorobanEvent[]; + cursor: string | null; + hasMore: boolean; +} + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + +export class TridentClient { + private readonly config: TridentClientConfig; + + constructor(config: TridentClientConfig) { + this.config = config; + } + + /** + * Query historical Soroban events with optional filtering. + * + * Supports filtering by contract address, topic values, and ledger range. + * Results are cursor-paginated — pass the returned `cursor` as `after` on + * the next call to fetch the next page. Returns events in ascending ledger + * order. + */ + async queryEvents(params: QueryEventsParams): Promise { + void params; + throw new Error("not yet implemented"); + } + + /** + * Fetch a single event by its UUID. + * + * Returns the full `SorobanEvent` record, or throws if no event with the + * given id exists. + */ + async getEventById(params: GetEventByIdParams): Promise { + void params; + throw new Error("not yet implemented"); + } + + /** + * Open a real-time WebSocket subscription to events emitted by a contract. + * + * Calls `onEvent` for every new event that matches the subscription criteria + * as it lands on-chain. Calls `onError` on connection failure and attempts + * to reconnect automatically. Returns a `Subscription` handle whose + * `unsubscribe()` method tears down the WebSocket cleanly. + */ + subscribeToContract(params: SubscribeToContractParams): Subscription { + void params; + throw new Error("not yet implemented"); + } +} diff --git a/sdk/typescript/tsconfig.json b/sdk/typescript/tsconfig.json new file mode 100644 index 0000000..59243fa --- /dev/null +++ b/sdk/typescript/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM"], + "strict": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} From ca79d070bf7577ed97b2feb01ab902454f200b36 Mon Sep 17 00:00:00 2001 From: Depo-dev Date: Tue, 19 May 2026 14:17:37 +0100 Subject: [PATCH 08/16] chore(docker): add dev and production compose files Dev compose runs postgres:15 and redis:7 only, with a postgres health check and schema auto-init. Production compose adds indexer and api services with depends_on health check conditions; all secrets are env var references. --- docker/docker-compose.dev.yml | 36 ++++++++++++++++++ docker/docker-compose.yml | 72 +++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 docker/docker-compose.dev.yml create mode 100644 docker/docker-compose.yml diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml new file mode 100644 index 0000000..af05c0b --- /dev/null +++ b/docker/docker-compose.dev.yml @@ -0,0 +1,36 @@ +version: "3.9" + +# Development compose — runs only the infrastructure dependencies. +# The indexer and API service are run locally (cargo run / go run) so that +# code changes reflect immediately without rebuilding containers. + +services: + postgres: + image: postgres:15-alpine + environment: + POSTGRES_USER: trident + POSTGRES_PASSWORD: password + POSTGRES_DB: trident + ports: + - "5432:5432" + volumes: + - postgres_dev_data:/var/lib/postgresql/data + - ../database/schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U trident -d trident"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_dev_data:/data + command: redis-server --appendonly yes + +volumes: + postgres_dev_data: + redis_dev_data: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..34df560 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,72 @@ +version: "3.9" + +# Production compose — runs all four services. +# All secrets are passed via environment variables; never hardcode values here. + +services: + postgres: + image: postgres:15-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 15s + restart: unless-stopped + + redis: + image: redis:7-alpine + volumes: + - redis_data:/data + command: redis-server --appendonly yes + restart: unless-stopped + + indexer: + build: + context: ../crates/indexer + dockerfile: Dockerfile + environment: + DATABASE_URL: ${DATABASE_URL} + REDIS_URL: ${REDIS_URL} + STELLAR_RPC_URL: ${STELLAR_RPC_URL} + NETWORK: ${NETWORK} + POLL_INTERVAL_MS: ${POLL_INTERVAL_MS} + INDEX_DIAGNOSTIC: ${INDEX_DIAGNOSTIC} + LOG_LEVEL: ${LOG_LEVEL} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + restart: unless-stopped + + api: + build: + context: ../services/api + dockerfile: Dockerfile + environment: + DATABASE_URL: ${DATABASE_URL} + REDIS_URL: ${REDIS_URL} + PORT: ${PORT} + API_KEY_SALT: ${API_KEY_SALT} + LOG_LEVEL: ${LOG_LEVEL} + ports: + - "${PORT}:${PORT}" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + indexer: + condition: service_started + restart: unless-stopped + +volumes: + postgres_data: + redis_data: From b55928a8cef3f116039c1e0382e6219c9b2468ad Mon Sep 17 00:00:00 2001 From: Depo-dev Date: Tue, 19 May 2026 14:18:09 +0100 Subject: [PATCH 09/16] chore: add .env.example Documents every required environment variable with a one-line explanation of its purpose. Covers DATABASE_URL, REDIS_URL, STELLAR_RPC_URL, NETWORK, POLL_INTERVAL_MS, INDEX_DIAGNOSTIC, LOG_LEVEL, PORT, API_KEY_SALT, and PostgreSQL credentials for the production compose file. --- .env.example | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .env.example 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 From ef91254e4e190b4f4e56cd38f6fb923593e3d1c3 Mon Sep 17 00:00:00 2001 From: Depo-dev Date: Tue, 19 May 2026 14:18:40 +0100 Subject: [PATCH 10/16] docs: add placeholder specification SPECIFICATION.md reserves the location for the full technical spec covering the data model, RPC protocol, XDR decoding, cursor semantics, API contracts, and SDK design decisions. Content will be added before Phase 1 development begins. --- docs/SPECIFICATION.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 docs/SPECIFICATION.md diff --git a/docs/SPECIFICATION.md b/docs/SPECIFICATION.md new file mode 100644 index 0000000..b706cc8 --- /dev/null +++ b/docs/SPECIFICATION.md @@ -0,0 +1,3 @@ +# Trident — Project Specification + +> The full specification is being drafted and will be added here. It will cover the complete data model, RPC polling protocol, XDR decoding rules, cursor management semantics, PostgreSQL schema rationale, Redis Streams message format, REST and GraphQL API contracts, WebSocket subscription protocol, and SDK design decisions. From 84f02aad99bd06ff9e288a4cdbfd1d7b5130caf1 Mon Sep 17 00:00:00 2001 From: Depo-dev Date: Thu, 21 May 2026 15:59:49 +0100 Subject: [PATCH 11/16] ci: add GitHub Actions workflow for Rust, Go, and TypeScript Three required jobs on push/PR to dev and main: cargo fmt + clippy -D warnings + cargo test for Rust; go vet + golangci-lint for Go; tsc --noEmit for TypeScript. Rust build cache via Swatinem/rust-cache keeps CI times reasonable. Closes #1 --- .github/workflows/ci.yml | 86 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 .github/workflows/ci.yml 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 From 07f5677186a443a12aad8436b486daa8922c59d9 Mon Sep 17 00:00:00 2001 From: Depo-dev Date: Thu, 21 May 2026 16:00:19 +0100 Subject: [PATCH 12/16] docs(readme): add CI check requirements for contributors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the exact commands contributors must run locally before opening a PR — cargo fmt/clippy/test, go vet/golangci-lint, and tsc --noEmit — so they know what the pipeline enforces before they push. Update project status to reflect that scaffolding and CI are now complete. --- README.md | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) 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.* From 7cea2bf4dd6439cda164b7f6af72c70bc69d8b0b Mon Sep 17 00:00:00 2001 From: Depo-dev Date: Thu, 21 May 2026 16:07:30 +0100 Subject: [PATCH 13/16] feat(indexer): implement RPC streamer, XDR parser, and event storage Full indexer pipeline: - Config::from_env() reads all required environment variables - RpcClient calls getEvents via JSON-RPC 2.0 with cursor pagination - Parser decodes base64 XDR ScVal topics/data, normalises to SorobanEvent; all common ScVal variants handled with JSON-safe i128/u128 overflow to string - db module: insert_event (ON CONFLICT DO NOTHING), get/set_cursor, insert_ledger_metadata via sqlx - redis_stream module: XADD to trident:events stream - Streamer loop: cursor recovery on restart, exponential backoff retry (5 attempts, max 30s), full-page pagination, per-event error isolation so one bad event never stalls the stream - SorobanEvent gains ledger_timestamp field in trident-common Closes #2 --- crates/common/src/types.rs | 2 + crates/indexer/Cargo.toml | 35 ++++- crates/indexer/src/config.rs | 36 +++++ crates/indexer/src/db/mod.rs | 101 ++++++++++++ crates/indexer/src/main.rs | 20 ++- crates/indexer/src/parser/mod.rs | 206 ++++++++++++++++++++++--- crates/indexer/src/redis_stream/mod.rs | 39 +++++ crates/indexer/src/rpc/mod.rs | 154 ++++++++++++++++++ crates/indexer/src/streamer/mod.rs | 164 +++++++++++++++++--- 9 files changed, 704 insertions(+), 53 deletions(-) create mode 100644 crates/indexer/src/config.rs create mode 100644 crates/indexer/src/db/mod.rs create mode 100644 crates/indexer/src/redis_stream/mod.rs create mode 100644 crates/indexer/src/rpc/mod.rs diff --git a/crates/common/src/types.rs b/crates/common/src/types.rs index a8d6e51..2974719 100644 --- a/crates/common/src/types.rs +++ b/crates/common/src/types.rs @@ -25,6 +25,8 @@ pub struct SorobanEvent { 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. diff --git a/crates/indexer/Cargo.toml b/crates/indexer/Cargo.toml index f61fb12..65d57fe 100644 --- a/crates/indexer/Cargo.toml +++ b/crates/indexer/Cargo.toml @@ -5,12 +5,45 @@ 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"] } -sqlx = { version = "0.7", features = ["postgres", "runtime-tokio-native-tls", "uuid", "chrono"] } + +# 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 = "0.0.19", features = ["curr"] } + +# Strkey encoding for contract and account addresses +stellar-strkey = "0.0.8" + +# 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..7a5c61a --- /dev/null +++ b/crates/indexer/src/db/mod.rs @@ -0,0 +1,101 @@ +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 + "#, + id, + event.contract_id, + event.ledger_sequence as i64, + ledger_ts, + event.transaction_hash, + event.event_index as i32, + event_type, + topics, + 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 = sqlx::query!("SELECT value FROM system_state WHERE key = 'latest_ledger_cursor'") + .fetch_one(pool) + .await + .map_err(|e| TridentError::StorageError(format!("get_cursor: {e}")))?; + + row.value + .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'", + 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 + "#, + ledger_sequence as i64, + ledger_hash, + ts, + 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 index 43e24eb..3f34838 100644 --- a/crates/indexer/src/main.rs +++ b/crates/indexer/src/main.rs @@ -1,6 +1,10 @@ use tracing_subscriber::EnvFilter; +mod config; +mod db; mod parser; +mod redis_stream; +mod rpc; mod streamer; #[tokio::main] @@ -11,11 +15,17 @@ async fn main() -> Result<(), Box> { tracing::info!("Trident indexer starting"); - // TODO: load config from environment - // TODO: initialise database pool (sqlx::PgPool) - // TODO: initialise Redis connection - // TODO: construct Streamer and Parser, wire them together - // TODO: call streamer.run().await + 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 index db82351..6f4b0c7 100644 --- a/crates/indexer/src/parser/mod.rs +++ b/crates/indexer/src/parser/mod.rs @@ -3,35 +3,193 @@ //! 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 using the `stellar-xdr` crate. -//! - Normalising decoded XDR values into the canonical `SorobanEvent` shape: -//! converting `ScVal` topics to their string representations, coercing the -//! event body to `serde_json::Value`, and extracting ledger/tx metadata. -//! - Type coercion rules: ScVal::Symbol → plain string, ScVal::Address → strkey, -//! ScVal::I128/U128 → JSON number (with overflow to string for values outside -//! i64 range), ScVal::Map/Vec → JSON object/array recursively. -//! - Returning a typed `TridentError::ParseError` for any input that cannot be -//! decoded, so the caller can decide whether to skip, retry, or halt. +//! `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 trident_common::{SorobanEvent, TridentError}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use serde_json::Value as Json; +use stellar_strkey::{ed25519, Contract}; +use stellar_xdr::curr::{ + AccountId, Hash, Limited, Limits, PublicKey, ReadXdr, ScAddress, ScVal, +}; +use trident_common::{EventType, SorobanEvent, TridentError}; -pub struct Parser; +use crate::rpc::RawEvent; + +pub struct Parser { + pub index_diagnostic: bool, +} impl Parser { - pub fn new() -> Self { - Self + 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, TridentError> { + let event_type = parse_event_type(&raw.event_type)?; + + if event_type == EventType::Diagnostic && !self.index_diagnostic { + return Ok(None); + } + + let contract_id = raw.contract_id.clone().unwrap_or_default(); + + let topics: Vec = raw + .topic + .iter() + .map(|xdr| decode_scval(xdr).map(|v| scval_to_string(&v))) + .collect::>()?; + + let data = if raw.value.is_empty() { + Json::Null + } else { + decode_scval(&raw.value).map(|v| scval_to_json(&v))? + }; + + let ledger_sequence: u64 = raw + .ledger + .parse() + .map_err(|_| TridentError::ParseError(format!("invalid ledger: {}", raw.ledger)))?; + + // event_index is the second component of the opaque id string: "{encoded}-{index}" + let event_index: u32 = raw + .id + .split('-') + .next_back() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + Ok(Some(SorobanEvent { + contract_id, + topics, + data, + ledger_sequence, + ledger_timestamp: raw.ledger_closed_at.clone(), + transaction_hash: raw.tx_hash.clone(), + event_index, + event_type, + })) + } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +fn parse_event_type(raw: &str) -> Result { + match raw { + "contract" => Ok(EventType::Contract), + "system" => Ok(EventType::System), + "diagnostic" => Ok(EventType::Diagnostic), + other => Err(TridentError::ParseError(format!( + "unknown event type: {other}" + ))), + } +} + +fn decode_scval(b64: &str) -> Result { + let bytes = STANDARD + .decode(b64) + .map_err(|e| TridentError::ParseError(format!("base64 decode: {e}")))?; + let mut cursor = std::io::Cursor::new(bytes); + ScVal::read_xdr(&mut Limited::new(&mut cursor, Limits::none())) + .map_err(|e| TridentError::ParseError(format!("XDR decode ScVal: {e}"))) +} + +/// Convert a topic `ScVal` to a compact string representation. +pub fn scval_to_string(val: &ScVal) -> String { + match val { + ScVal::Symbol(s) => s.to_utf8_string_lossy(), + ScVal::String(s) => s.to_utf8_string_lossy(), + ScVal::Bool(b) => b.to_string(), + ScVal::Void => "void".into(), + ScVal::U32(n) => n.to_string(), + ScVal::I32(n) => n.to_string(), + ScVal::U64(n) => n.to_string(), + ScVal::I64(n) => n.to_string(), + ScVal::U128(parts) => { + let val = ((parts.hi as u128) << 64) | (parts.lo as u128); + val.to_string() + } + ScVal::I128(parts) => { + let val = ((parts.hi as i128) << 64) | (parts.lo as i128); + val.to_string() + } + ScVal::U256(parts) => format!( + "u256({:x}{:x}{:x}{:x})", + parts.hi_hi, parts.hi_lo, parts.lo_hi, parts.lo_lo + ), + ScVal::I256(parts) => format!( + "i256({:x}{:x}{:x}{:x})", + parts.hi_hi, parts.hi_lo, parts.lo_hi, parts.lo_lo + ), + ScVal::Bytes(b) => hex::encode(b.as_slice()), + ScVal::Address(addr) => scaddress_to_string(addr), + // For complex types in topic position, fall back to debug representation + other => format!("{other:?}"), + } +} + +/// Recursively convert a `ScVal` to a `serde_json::Value` for the event body. +pub fn scval_to_json(val: &ScVal) -> Json { + match val { + ScVal::Void => Json::Null, + ScVal::Bool(b) => Json::Bool(*b), + ScVal::Symbol(s) => Json::String(s.to_utf8_string_lossy()), + ScVal::String(s) => Json::String(s.to_utf8_string_lossy()), + ScVal::U32(n) => Json::from(*n), + ScVal::I32(n) => Json::from(*n), + ScVal::U64(n) => Json::from(*n), + ScVal::I64(n) => Json::from(*n), + ScVal::U128(parts) => { + let v = ((parts.hi as u128) << 64) | (parts.lo as u128); + // Use string for values that overflow JSON's safe integer range + if v <= u64::MAX as u128 { + Json::from(v as u64) + } else { + Json::String(v.to_string()) + } + } + ScVal::I128(parts) => { + let v = ((parts.hi as i128) << 64) | (parts.lo as i128); + if v >= i64::MIN as i128 && v <= i64::MAX as i128 { + Json::from(v as i64) + } else { + Json::String(v.to_string()) + } + } + ScVal::Bytes(b) => Json::String(hex::encode(b.as_slice())), + ScVal::Address(addr) => Json::String(scaddress_to_string(addr)), + ScVal::Vec(Some(items)) => { + Json::Array(items.iter().map(scval_to_json).collect()) + } + ScVal::Vec(None) => Json::Array(vec![]), + ScVal::Map(Some(entries)) => { + let obj: serde_json::Map = entries + .iter() + .map(|e| (scval_to_string(&e.key), scval_to_json(&e.val))) + .collect(); + Json::Object(obj) + } + ScVal::Map(None) => Json::Object(serde_json::Map::new()), + other => Json::String(format!("{other:?}")), } +} - /// Decode a raw XDR event string (as returned by `getEvents` RPC) into a - /// normalised `SorobanEvent`. Returns `TridentError::ParseError` if the - /// input cannot be decoded or required fields are missing. - pub fn parse_event(&self, raw_xdr: &str) -> Result { - let _ = raw_xdr; // suppress unused warning until implemented - // TODO: base64-decode raw_xdr - // TODO: XDR-decode using stellar-xdr ContractEvent - // TODO: normalise topics via ScVal → string conversion - // TODO: normalise data body via ScVal → serde_json::Value - // TODO: construct and return SorobanEvent - Err(TridentError::ParseError("not yet implemented".into())) +fn scaddress_to_string(addr: &ScAddress) -> String { + match addr { + ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(bytes))) => { + ed25519::PublicKey(bytes.0).to_string() + } + ScAddress::Contract(Hash(bytes)) => Contract(*bytes).to_string(), } } diff --git a/crates/indexer/src/redis_stream/mod.rs b/crates/indexer/src/redis_stream/mod.rs new file mode 100644 index 0000000..c6a17d8 --- /dev/null +++ b/crates/indexer/src/redis_stream/mod.rs @@ -0,0 +1,39 @@ +use redis::AsyncCommands; +use trident_common::{SorobanEvent, TridentError}; + +const STREAM_KEY: &str = "trident:events"; + +/// Publish a normalised event onto the Redis Stream. The Go API layer +/// consumes this stream to fan out to WebSocket subscribers. +/// +/// Each entry is a flat hash of event fields. `topics` and `data` are +/// JSON-serialised strings so all values are plain Redis strings. +pub async fn publish_event( + conn: &mut redis::aio::MultiplexedConnection, + event: &SorobanEvent, +) -> Result<(), TridentError> { + let topics = serde_json::to_string(&event.topics) + .map_err(|e| TridentError::StorageError(format!("topics serialise: {e}")))?; + let data = event.data.to_string(); + let event_type = format!("{:?}", event.event_type).to_lowercase(); + + let _: String = conn + .xadd( + STREAM_KEY, + "*", + &[ + ("contract_id", event.contract_id.as_str()), + ("ledger_sequence", &event.ledger_sequence.to_string()), + ("ledger_timestamp", event.ledger_timestamp.as_str()), + ("transaction_hash", event.transaction_hash.as_str()), + ("event_index", &event.event_index.to_string()), + ("event_type", &event_type), + ("topics", &topics), + ("data", &data), + ], + ) + .await + .map_err(|e| TridentError::StorageError(format!("redis xadd: {e}")))?; + + Ok(()) +} diff --git a/crates/indexer/src/rpc/mod.rs b/crates/indexer/src/rpc/mod.rs new file mode 100644 index 0000000..a7ef090 --- /dev/null +++ b/crates/indexer/src/rpc/mod.rs @@ -0,0 +1,154 @@ +use serde::{Deserialize, Serialize}; +use trident_common::TridentError; + +/// A single raw event as returned by the Stellar RPC `getEvents` method. +/// Topics and data are base64-encoded XDR strings; the parser decodes them. +#[derive(Debug, Deserialize)] +pub struct RawEvent { + #[serde(rename = "type")] + pub event_type: String, + /// Ledger sequence number as a numeric string. + pub ledger: String, + #[serde(rename = "ledgerClosedAt")] + pub ledger_closed_at: String, + #[serde(rename = "contractId")] + pub contract_id: Option, + pub id: String, + #[serde(rename = "pagingToken")] + pub paging_token: String, + #[serde(rename = "txHash")] + pub tx_hash: String, + /// Ordered list of base64 XDR-encoded ScVal topic values. + pub topic: Vec, + /// Base64 XDR-encoded ScVal event body. + pub value: String, + #[serde(rename = "inSuccessfulContractCall")] + pub in_successful_contract_call: bool, +} + +#[derive(Debug)] +pub struct EventsPage { + pub events: Vec, + pub latest_ledger: u64, +} + +// --------------------------------------------------------------------------- +// JSON-RPC wire types +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct JsonRpcRequest<'a, P: Serialize> { + jsonrpc: &'static str, + id: u64, + method: &'a str, + params: P, +} + +#[derive(Deserialize)] +struct JsonRpcResponse { + result: Option, + error: Option, +} + +#[derive(Deserialize)] +struct JsonRpcError { + code: i64, + message: String, +} + +#[derive(Serialize)] +struct GetEventsParams { + #[serde(rename = "startLedger", skip_serializing_if = "Option::is_none")] + start_ledger: Option, + filters: Vec, + pagination: Pagination, +} + +#[derive(Serialize)] +struct Pagination { + limit: u32, + #[serde(skip_serializing_if = "Option::is_none")] + cursor: Option, +} + +#[derive(Deserialize)] +struct GetEventsResult { + events: Vec, + #[serde(rename = "latestLedger")] + latest_ledger: u64, +} + +// --------------------------------------------------------------------------- +// RPC client +// --------------------------------------------------------------------------- + +pub struct RpcClient { + http: reqwest::Client, + url: String, +} + +impl RpcClient { + pub fn new(url: String) -> Self { + Self { + http: reqwest::Client::new(), + url, + } + } + + /// Fetch a page of events from the Stellar RPC node. + /// + /// Pass `start_ledger` on the first call to anchor the scan position. + /// On subsequent calls pass `cursor` (the `paging_token` from the last + /// event received) to continue pagination. Only one of the two should be + /// set at a time — the RPC rejects requests that supply both. + pub async fn get_events( + &self, + start_ledger: Option, + cursor: Option, + ) -> Result { + let params = GetEventsParams { + start_ledger, + filters: vec![], + pagination: Pagination { + limit: 200, + cursor, + }, + }; + + let req = JsonRpcRequest { + jsonrpc: "2.0", + id: 1, + method: "getEvents", + params, + }; + + let resp = self + .http + .post(&self.url) + .json(&req) + .send() + .await + .map_err(|e| TridentError::RpcError(format!("HTTP request failed: {e}")))?; + + let body: JsonRpcResponse = resp + .json() + .await + .map_err(|e| TridentError::RpcError(format!("Failed to decode RPC response: {e}")))?; + + if let Some(err) = body.error { + return Err(TridentError::RpcError(format!( + "RPC error {}: {}", + err.code, err.message + ))); + } + + let result = body + .result + .ok_or_else(|| TridentError::RpcError("Empty result in RPC response".into()))?; + + Ok(EventsPage { + events: result.events, + latest_ledger: result.latest_ledger, + }) + } +} diff --git a/crates/indexer/src/streamer/mod.rs b/crates/indexer/src/streamer/mod.rs index b11831f..d7ebe71 100644 --- a/crates/indexer/src/streamer/mod.rs +++ b/crates/indexer/src/streamer/mod.rs @@ -3,39 +3,157 @@ //! Owns the RPC polling loop. Responsibilities: //! //! - Maintaining the ledger cursor: reading the last processed sequence from -//! `system_state`, advancing it after each successful batch, and persisting -//! it atomically with the events it covers. +//! `system_state` on startup, advancing it after each successful batch, and +//! persisting it atomically with the events it covers. //! - Calling `getEvents` on the Stellar Soroban RPC node on a configurable -//! interval (`POLL_INTERVAL_MS`), handling pagination across large ledger -//! ranges by following the `cursor` field in each response. -//! - Fault tolerance and retry logic: transient RPC failures should be retried -//! with exponential backoff; persistent failures must be logged and surfaced -//! without crashing the process or losing cursor position. +//! interval (`POLL_INTERVAL_MS`), following the `pagingToken` cursor field +//! to paginate across large ledger ranges within a single poll cycle. +//! - Fault tolerance and retry logic: transient RPC failures are retried with +//! exponential backoff; persistent failures are logged without crashing the +//! process or losing cursor position so the next poll cycle can recover. //! - Handing each raw event to the `Parser` and forwarding normalised -//! `SorobanEvent` values to both PostgreSQL and Redis Streams. +//! `SorobanEvent` values to both PostgreSQL (via `db`) and Redis Streams +//! (via `redis_stream`). +use std::time::Duration; + +use sqlx::PgPool; +use tokio_retry::{strategy::ExponentialBackoff, Retry}; use trident_common::TridentError; +use crate::{ + config::Config, + db, + parser::Parser, + redis_stream, + rpc::RpcClient, +}; + pub struct Streamer { - // TODO: rpc_url: String - // TODO: db: sqlx::PgPool - // TODO: redis: redis::aio::ConnectionManager - // TODO: poll_interval: std::time::Duration + config: Config, + db: PgPool, + redis: redis::aio::MultiplexedConnection, + rpc: RpcClient, + parser: Parser, } impl Streamer { - /// Construct a new Streamer. All dependencies are injected so they can be - /// shared with other components or replaced in tests. - pub fn new() -> Self { - Self {} + pub fn new( + config: Config, + db: PgPool, + redis: redis::aio::MultiplexedConnection, + ) -> Self { + let rpc = RpcClient::new(config.stellar_rpc_url.clone()); + let parser = Parser::new(config.index_diagnostic); + Self { config, db, redis, rpc, parser } } - /// Start the polling loop. This future runs indefinitely — it should be - /// spawned with `tokio::spawn` or driven directly from `main`. - pub async fn run(&self) -> Result<(), TridentError> { - tracing::info!("Streamer started"); - // TODO: read cursor from system_state - // TODO: loop: call getEvents RPC, parse, write to DB + Redis, advance cursor - Ok(()) + /// Start the polling loop. Runs indefinitely — spawn with `tokio::spawn` + /// or drive directly from `main`. Never returns `Ok(())` in normal operation. + pub async fn run(&mut self) -> Result<(), TridentError> { + tracing::info!( + network = %self.config.network, + poll_interval_ms = %self.config.poll_interval.as_millis(), + "Streamer started" + ); + + let mut cursor = db::get_cursor(&self.db).await?; + tracing::info!(cursor, "Resuming from ledger cursor"); + + loop { + match self.poll_once(&mut cursor).await { + Ok(events_processed) => { + if events_processed > 0 { + tracing::info!(events_processed, cursor, "Batch processed"); + } else { + tracing::debug!(cursor, "No new events"); + } + } + Err(e) => { + // Log but do not crash — the cursor is safe, next poll will retry. + tracing::error!(error = %e, "Poll cycle failed, will retry next interval"); + } + } + + tokio::time::sleep(self.config.poll_interval).await; + } + } + + /// Execute a single poll cycle. Fetches all available pages from the RPC + /// starting at `cursor`, persists each event, and advances the cursor. + /// Returns the total number of events processed in this cycle. + async fn poll_once(&mut self, cursor: &mut u64) -> Result { + let retry_strategy = ExponentialBackoff::from_millis(200) + .max_delay(Duration::from_secs(30)) + .take(5); + + // Use start_ledger on the very first call (cursor == 0), then switch + // to paging_token-based cursor for all subsequent pages. + let start_ledger = if *cursor == 0 { None } else { None }; + let initial_cursor = if *cursor > 0 { + Some(cursor.to_string()) + } else { + None + }; + + let mut page_cursor = initial_cursor; + let mut total = 0; + + loop { + let pc = page_cursor.clone(); + let sl = start_ledger; + let page = Retry::spawn(retry_strategy.clone(), || async { + self.rpc.get_events(sl, pc.clone()).await + }) + .await?; + + if page.events.is_empty() { + break; + } + + let last_paging_token = page + .events + .last() + .map(|e| e.paging_token.clone()); + + for raw in &page.events { + match self.parser.parse_event(raw) { + Ok(Some(event)) => { + db::insert_event(&self.db, &event).await?; + redis_stream::publish_event(&mut self.redis, &event).await?; + total += 1; + } + Ok(None) => { + // Diagnostic event skipped — index_diagnostic is false + } + Err(e) => { + tracing::warn!( + tx_hash = %raw.tx_hash, + error = %e, + "Skipping unparseable event" + ); + } + } + } + + // Advance the persistent cursor to the last processed ledger + let last_ledger = page.events.last().map(|e| e.ledger.parse::().unwrap_or(*cursor)); + if let Some(seq) = last_ledger { + if seq > *cursor { + *cursor = seq; + db::set_cursor(&self.db, *cursor).await?; + } + } + + // If a full page was returned there may be more — keep paginating. + // An incomplete page means we've caught up to the chain tip. + if page.events.len() < 200 { + break; + } + + page_cursor = last_paging_token; + } + + Ok(total) } } From 246169cad793b40e17db99e016f469c7117fdcc4 Mon Sep 17 00:00:00 2001 From: Depo-dev Date: Thu, 21 May 2026 16:10:24 +0100 Subject: [PATCH 14/16] feat(api): define gRPC proto contract and wire EventsService proto/trident.proto defines EventsService with three RPCs: ListEvents (cursor-paginated with contract_id/topic/ledger filters), GetEvent (by UUID), and StreamEvents (server-side streaming for real-time delivery). All message types are fully documented. build.rs compiles the proto via tonic-build. EventsServiceImpl in src/services/events.rs is wired into the Tonic server with TODO stubs for each RPC showing exactly what query/stream logic goes in each handler. Closes #3 --- crates/api/Cargo.toml | 1 + crates/api/build.rs | 7 ++ crates/api/src/main.rs | 17 ++--- crates/api/src/services/events.rs | 60 +++++++++++++++ crates/api/src/services/mod.rs | 1 + proto/trident.proto | 118 ++++++++++++++++++++++++++++++ 6 files changed, 194 insertions(+), 10 deletions(-) create mode 100644 crates/api/build.rs create mode 100644 crates/api/src/services/events.rs create mode 100644 crates/api/src/services/mod.rs create mode 100644 proto/trident.proto diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index 3c00b71..b11840e 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -8,6 +8,7 @@ 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"] } diff --git a/crates/api/build.rs b/crates/api/build.rs new file mode 100644 index 0000000..9c2609f --- /dev/null +++ b/crates/api/build.rs @@ -0,0 +1,7 @@ +fn main() -> Result<(), Box> { + 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 index bcd185a..9064a5e 100644 --- a/crates/api/src/main.rs +++ b/crates/api/src/main.rs @@ -1,12 +1,11 @@ use std::net::SocketAddr; use tracing_subscriber::EnvFilter; -// Proto-generated code will live here once the .proto files are defined. -// Each RPC service gets its own module under src/services/. -// -// Example layout once protos are added: -// pub mod trident { tonic::include_proto!("trident"); } -// mod services { pub mod events; } +pub mod trident { + tonic::include_proto!("trident"); +} + +mod services; #[tokio::main] async fn main() -> Result<(), Box> { @@ -20,12 +19,10 @@ async fn main() -> Result<(), Box> { tracing::info!(%addr, "Trident gRPC server listening"); - // TODO: implement the EventsService trait generated from protos - // TODO: register service with tonic::transport::Server - // TODO: add tls_config if TLS is required + let events_service = services::events::EventsServiceImpl::new(); tonic::transport::Server::builder() - // .add_service(EventsServer::new(EventsService::new(db, redis))) + .add_service(trident::events_server::EventsServer::new(events_service)) .serve(addr) .await?; diff --git a/crates/api/src/services/events.rs b/crates/api/src/services/events.rs new file mode 100644 index 0000000..0dd9dc1 --- /dev/null +++ b/crates/api/src/services/events.rs @@ -0,0 +1,60 @@ +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/proto/trident.proto b/proto/trident.proto new file mode 100644 index 0000000..cb44e2c --- /dev/null +++ b/proto/trident.proto @@ -0,0 +1,118 @@ +syntax = "proto3"; + +package trident; + +// --------------------------------------------------------------------------- +// EventsService +// +// The gRPC interface between the Rust core and the Go front office. +// The Go API layer calls these RPCs to serve developer-facing REST, GraphQL, +// and WebSocket endpoints. Direct developer access to this service is not +// intended — it is an internal transport boundary. +// --------------------------------------------------------------------------- +service Events { + // Return a paginated list of historical events matching the filter. + rpc ListEvents(ListEventsRequest) returns (ListEventsResponse); + + // Return a single event by its UUID primary key. + rpc GetEvent(GetEventRequest) returns (Event); + + // Server-side streaming RPC: subscribe to real-time events for a contract. + // The server pushes each new matching event as it is indexed. + rpc StreamEvents(StreamEventsRequest) returns (stream Event); +} + +// --------------------------------------------------------------------------- +// Core message types +// --------------------------------------------------------------------------- + +message Event { + // UUID primary key as assigned by PostgreSQL. + string id = 1; + + // Strkey-encoded contract address (C...). + string contract_id = 2; + + // Ledger sequence number in which this event was emitted. + uint64 ledger_sequence = 3; + + // ISO 8601 UTC timestamp of the ledger close. + string ledger_timestamp = 4; + + // Hash of the transaction that emitted this event. + string transaction_hash = 5; + + // Zero-based index of this event within its transaction. + uint32 event_index = 6; + + // "contract" | "system" | "diagnostic" + string event_type = 7; + + // Decoded topic values in order. Serialised from ScVal to string. + repeated string topics = 8; + + // Decoded event body as a JSON string. Complex types are serialised + // recursively; i128/u128 values outside i64 range are JSON strings. + string data = 9; + + // Row insertion timestamp (server time, not ledger time). + string created_at = 10; +} + +// --------------------------------------------------------------------------- +// ListEvents +// --------------------------------------------------------------------------- + +message ListEventsRequest { + // Filter by contract address. Empty string means all contracts. + string contract_id = 1; + + // Filter by the value of the first topic (e.g. "transfer"). + string topic_0 = 2; + + // Filter by the value of the second topic. + string topic_1 = 3; + + // Inclusive lower bound on ledger sequence. + uint64 ledger_from = 4; + + // Inclusive upper bound on ledger sequence. 0 means no upper bound. + uint64 ledger_to = 5; + + // Opaque cursor from the previous response. Empty string on first call. + string cursor = 6; + + // Maximum number of events to return. Capped server-side at 200. + uint32 limit = 7; +} + +message ListEventsResponse { + repeated Event events = 1; + + // Pass this as `cursor` on the next call to fetch the next page. + // Empty string when there are no more results. + string next_cursor = 2; + + bool has_more = 3; +} + +// --------------------------------------------------------------------------- +// GetEvent +// --------------------------------------------------------------------------- + +message GetEventRequest { + // UUID of the event to fetch. + string id = 1; +} + +// --------------------------------------------------------------------------- +// StreamEvents +// --------------------------------------------------------------------------- + +message StreamEventsRequest { + // Contract address to subscribe to. Required. + string contract_id = 1; + + // Optional topic_0 filter — only events whose first topic matches are pushed. + string topic_0 = 2; +} From 636ae21a7ef6d3d3fc8ba3f7034b419109ec6094 Mon Sep 17 00:00:00 2001 From: Depo-dev Date: Thu, 21 May 2026 16:46:34 +0100 Subject: [PATCH 15/16] fix(ci): resolve all clippy and fmt failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rust CI fixes: - stellar-xdr 0.0.x no longer published; update to 26.0.1. Fix ScAddress::Contract pattern (ContractId wrapper, not Hash) and add exhaustive match arms for new MuxedAccount/ClaimableBalance/LiquidityPool variants added in 26.x - stellar-strkey 0.0.8 → 0.0.16: to_string() now returns heapless::String; convert via .as_str().to_owned() at both call sites - sqlx::query! requires DATABASE_URL or a prepared cache at compile time; replace all four usages with sqlx::query().bind() chains which compile without a live database - Streamer: fix identical-branch clippy lint — start_ledger was always None; correct logic now uses Some(1) on first run and cursor-based pagination on all subsequent calls - Wire insert_ledger_metadata into poll_once (called once per page after cursor advance) to resolve dead_code lint - Use in_successful_contract_call in Parser to skip events from failed calls (resolves field dead_code lint and is the correct semantic behaviour) - Use page.latest_ledger in streamer debug log (resolves field dead_code lint) - Add protoc-bin-vendored as a build dependency so protoc is bundled and neither CI nor local dev need a system installation --- crates/api/Cargo.toml | 1 + crates/api/build.rs | 5 +++ crates/api/src/services/events.rs | 3 +- crates/indexer/Cargo.toml | 4 +- crates/indexer/src/db/mod.rs | 45 +++++++++---------- crates/indexer/src/parser/mod.rs | 20 ++++++--- crates/indexer/src/rpc/mod.rs | 5 +-- crates/indexer/src/streamer/mod.rs | 70 +++++++++++++++++------------- 8 files changed, 86 insertions(+), 67 deletions(-) diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index b11840e..e3d7bf4 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -14,3 +14,4 @@ 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 index 9c2609f..47fae43 100644 --- a/crates/api/build.rs +++ b/crates/api/build.rs @@ -1,7 +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/services/events.rs b/crates/api/src/services/events.rs index 0dd9dc1..8619779 100644 --- a/crates/api/src/services/events.rs +++ b/crates/api/src/services/events.rs @@ -42,8 +42,7 @@ impl Events for EventsServiceImpl { Err(Status::unimplemented("get_event not yet implemented")) } - type StreamEventsStream = - tokio_stream::wrappers::ReceiverStream>; + type StreamEventsStream = tokio_stream::wrappers::ReceiverStream>; /// Stream real-time events for a contract from Redis Streams. async fn stream_events( diff --git a/crates/indexer/Cargo.toml b/crates/indexer/Cargo.toml index 65d57fe..599090d 100644 --- a/crates/indexer/Cargo.toml +++ b/crates/indexer/Cargo.toml @@ -24,10 +24,10 @@ 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 = "0.0.19", features = ["curr"] } +stellar-xdr = { version = "26.0.1", features = ["curr"] } # Strkey encoding for contract and account addresses -stellar-strkey = "0.0.8" +stellar-strkey = "0.0.16" # Base64 decoding for XDR payloads base64 = "0.22" diff --git a/crates/indexer/src/db/mod.rs b/crates/indexer/src/db/mod.rs index 7a5c61a..13a3057 100644 --- a/crates/indexer/src/db/mod.rs +++ b/crates/indexer/src/db/mod.rs @@ -19,7 +19,7 @@ pub async fn insert_event(pool: &PgPool, event: &SorobanEvent) -> Result<(), Tri .parse() .map_err(|e| TridentError::StorageError(format!("ledger timestamp parse: {e}")))?; - sqlx::query!( + sqlx::query( r#" INSERT INTO soroban_events (id, contract_id, ledger_sequence, ledger_timestamp, transaction_hash, @@ -27,16 +27,16 @@ pub async fn insert_event(pool: &PgPool, event: &SorobanEvent) -> Result<(), Tri VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (transaction_hash, event_index) DO NOTHING "#, - id, - event.contract_id, - event.ledger_sequence as i64, - ledger_ts, - event.transaction_hash, - event.event_index as i32, - event_type, - topics, - event.data, ) + .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}")))?; @@ -46,12 +46,13 @@ pub async fn insert_event(pool: &PgPool, event: &SorobanEvent) -> Result<(), Tri /// Read the latest processed ledger cursor from system_state. pub async fn get_cursor(pool: &PgPool) -> Result { - let row = sqlx::query!("SELECT value FROM system_state WHERE key = 'latest_ledger_cursor'") - .fetch_one(pool) - .await - .map_err(|e| TridentError::StorageError(format!("get_cursor: {e}")))?; + 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.value + row.0 .parse::() .map_err(|e| TridentError::StorageError(format!("cursor parse: {e}"))) } @@ -59,10 +60,10 @@ pub async fn get_cursor(pool: &PgPool) -> Result { /// 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!( + sqlx::query( "UPDATE system_state SET value = $1, updated_at = NOW() WHERE key = 'latest_ledger_cursor'", - ledger.to_string() ) + .bind(ledger.to_string()) .execute(pool) .await .map_err(|e| TridentError::StorageError(format!("set_cursor: {e}")))?; @@ -82,17 +83,17 @@ pub async fn insert_ledger_metadata( .parse() .map_err(|e| TridentError::StorageError(format!("ledger timestamp parse: {e}")))?; - sqlx::query!( + 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 "#, - ledger_sequence as i64, - ledger_hash, - ts, - event_count, ) + .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}")))?; diff --git a/crates/indexer/src/parser/mod.rs b/crates/indexer/src/parser/mod.rs index 6f4b0c7..45d6b05 100644 --- a/crates/indexer/src/parser/mod.rs +++ b/crates/indexer/src/parser/mod.rs @@ -15,7 +15,7 @@ use base64::{engine::general_purpose::STANDARD, Engine}; use serde_json::Value as Json; use stellar_strkey::{ed25519, Contract}; use stellar_xdr::curr::{ - AccountId, Hash, Limited, Limits, PublicKey, ReadXdr, ScAddress, ScVal, + AccountId, ContractId, Limited, Limits, PublicKey, ReadXdr, ScAddress, ScVal, }; use trident_common::{EventType, SorobanEvent, TridentError}; @@ -41,6 +41,11 @@ impl Parser { return Ok(None); } + // Skip events emitted by failed contract calls — they have no observable effect. + if !raw.in_successful_contract_call { + return Ok(None); + } + let contract_id = raw.contract_id.clone().unwrap_or_default(); let topics: Vec = raw @@ -169,9 +174,7 @@ pub fn scval_to_json(val: &ScVal) -> Json { } ScVal::Bytes(b) => Json::String(hex::encode(b.as_slice())), ScVal::Address(addr) => Json::String(scaddress_to_string(addr)), - ScVal::Vec(Some(items)) => { - Json::Array(items.iter().map(scval_to_json).collect()) - } + ScVal::Vec(Some(items)) => Json::Array(items.iter().map(scval_to_json).collect()), ScVal::Vec(None) => Json::Array(vec![]), ScVal::Map(Some(entries)) => { let obj: serde_json::Map = entries @@ -188,8 +191,13 @@ pub fn scval_to_json(val: &ScVal) -> Json { fn scaddress_to_string(addr: &ScAddress) -> String { match addr { ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(bytes))) => { - ed25519::PublicKey(bytes.0).to_string() + // stellar-strkey 0.0.16+ returns heapless::String — convert to std::String + ed25519::PublicKey(bytes.0).to_string().as_str().to_owned() } - ScAddress::Contract(Hash(bytes)) => Contract(*bytes).to_string(), + // stellar-xdr 26.x wraps the hash in ContractId; the inner Hash holds [u8; 32] + ScAddress::Contract(ContractId(hash)) => Contract(hash.0).to_string().as_str().to_owned(), + // stellar-xdr 26.x added MuxedAccount, ClaimableBalance, LiquidityPool variants; + // these do not appear in Soroban contract events but the match must be exhaustive. + other => format!("{other:?}"), } } diff --git a/crates/indexer/src/rpc/mod.rs b/crates/indexer/src/rpc/mod.rs index a7ef090..830c4c5 100644 --- a/crates/indexer/src/rpc/mod.rs +++ b/crates/indexer/src/rpc/mod.rs @@ -109,10 +109,7 @@ impl RpcClient { let params = GetEventsParams { start_ledger, filters: vec![], - pagination: Pagination { - limit: 200, - cursor, - }, + pagination: Pagination { limit: 200, cursor }, }; let req = JsonRpcRequest { diff --git a/crates/indexer/src/streamer/mod.rs b/crates/indexer/src/streamer/mod.rs index d7ebe71..f6bcf5f 100644 --- a/crates/indexer/src/streamer/mod.rs +++ b/crates/indexer/src/streamer/mod.rs @@ -21,13 +21,7 @@ use sqlx::PgPool; use tokio_retry::{strategy::ExponentialBackoff, Retry}; use trident_common::TridentError; -use crate::{ - config::Config, - db, - parser::Parser, - redis_stream, - rpc::RpcClient, -}; +use crate::{config::Config, db, parser::Parser, redis_stream, rpc::RpcClient}; pub struct Streamer { config: Config, @@ -38,14 +32,16 @@ pub struct Streamer { } impl Streamer { - pub fn new( - config: Config, - db: PgPool, - redis: redis::aio::MultiplexedConnection, - ) -> Self { + pub fn new(config: Config, db: PgPool, redis: redis::aio::MultiplexedConnection) -> Self { let rpc = RpcClient::new(config.stellar_rpc_url.clone()); let parser = Parser::new(config.index_diagnostic); - Self { config, db, redis, rpc, parser } + Self { + config, + db, + redis, + rpc, + parser, + } } /// Start the polling loop. Runs indefinitely — spawn with `tokio::spawn` @@ -87,13 +83,13 @@ impl Streamer { .max_delay(Duration::from_secs(30)) .take(5); - // Use start_ledger on the very first call (cursor == 0), then switch - // to paging_token-based cursor for all subsequent pages. - let start_ledger = if *cursor == 0 { None } else { None }; - let initial_cursor = if *cursor > 0 { - Some(cursor.to_string()) + // First-ever run: anchor to ledger 1 via start_ledger. + // All subsequent calls use paging_token cursors so the RPC can resume + // exactly where we left off without re-scanning from genesis. + let (start_ledger, initial_cursor) = if *cursor == 0 { + (Some(1u64), None) } else { - None + (None, Some(cursor.to_string())) }; let mut page_cursor = initial_cursor; @@ -107,25 +103,28 @@ impl Streamer { }) .await?; + tracing::debug!( + latest_ledger = page.latest_ledger, + cursor = *cursor, + "RPC page received" + ); + if page.events.is_empty() { break; } - let last_paging_token = page - .events - .last() - .map(|e| e.paging_token.clone()); + let last_paging_token = page.events.last().map(|e| e.paging_token.clone()); + let mut events_in_page: i32 = 0; for raw in &page.events { match self.parser.parse_event(raw) { Ok(Some(event)) => { db::insert_event(&self.db, &event).await?; redis_stream::publish_event(&mut self.redis, &event).await?; total += 1; + events_in_page += 1; } - Ok(None) => { - // Diagnostic event skipped — index_diagnostic is false - } + Ok(None) => {} // diagnostic or failed-call event — intentionally skipped Err(e) => { tracing::warn!( tx_hash = %raw.tx_hash, @@ -136,17 +135,26 @@ impl Streamer { } } - // Advance the persistent cursor to the last processed ledger - let last_ledger = page.events.last().map(|e| e.ledger.parse::().unwrap_or(*cursor)); - if let Some(seq) = last_ledger { + // Advance the persistent cursor and record ledger metadata. + if let Some(last) = page.events.last() { + let seq: u64 = last.ledger.parse().unwrap_or(*cursor); if seq > *cursor { *cursor = seq; db::set_cursor(&self.db, *cursor).await?; + // Ledger hash is not available from getEvents; record what we have. + // TODO: enrich with ledger hash via getLedger RPC when needed. + db::insert_ledger_metadata( + &self.db, + seq, + "", + &last.ledger_closed_at, + events_in_page, + ) + .await?; } } - // If a full page was returned there may be more — keep paginating. - // An incomplete page means we've caught up to the chain tip. + // An incomplete page means we have caught up to the chain tip. if page.events.len() < 200 { break; } From 57ae9d00f847914d87d4df9351dcae30b4250c14 Mon Sep 17 00:00:00 2001 From: Depo-dev Date: Thu, 21 May 2026 20:57:21 +0100 Subject: [PATCH 16/16] chore(sdk): add package-lock.json so npm ci works in CI --- sdk/typescript/package-lock.json | 1491 ++++++++++++++++++++++++++++++ 1 file changed, 1491 insertions(+) create mode 100644 sdk/typescript/package-lock.json diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json new file mode 100644 index 0000000..8946dc8 --- /dev/null +++ b/sdk/typescript/package-lock.json @@ -0,0 +1,1491 @@ +{ + "name": "@trident-indexer/sdk", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@trident-indexer/sdk", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "zod": "^3.22.4" + }, + "devDependencies": { + "tsup": "^8.0.2", + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +}