blockwatcher-stellar
blockwatcher-stellar is the Stellar chain family: one
source, stellar-rpc, which polls
stellar-rpc for closed ledgers inside the serving node’s history window,
and the stellar decoder that
source feeds (crates/blockwatcher-stellar/src/lib.rs). It is a
module crate, built on
blockwatcher-rpc’s endpoint pool and the stellar-xdr / stellar-strkey
SDKs.
Stellar documents this family’s selector keys and source config at an operator’s level. This page maps the same facts onto the crate’s file layout: which file owns ingestion, which owns compile-once/decode-many, and the registry that wires both into the catalog.
Key takeaways
blockwatcher-stellaris the Stellar chain family: one source,stellar-rpc(closed ledgers inside the node’s RPC history window), plus the onestellardecoder it feeds.- Closed ledgers are not rewound. Event interest spends
getEvents; operation or function interest spendsgetTransactions. On the first successfulgetHealth,getNetworkpassphrases must agree across the pool or the run refuses by endpoint name. - A classic spec payload is
{"catalog":"classic"}. A Stellar Asset Contract spec is{"catalog":"stellar-asset"}. A Soroban spec is a WASM blob or XDR-JSON ofScSpecEntry. Selector keys areevents,functions, andoperations. - A classic
Paymentand a CAP-67transferevent on the same ledger are two decoded occurrences when both are selected; the decoder does not merge them.
Responsibilities
stellar-rpc(source::StellarRpcSource,source/): poll a pool of stellar-rpc endpoints for closed ledgers, refuse a configuredstart_ledgerbelowoldestLedgeron the firstgetHealth, clamp a checkpoint (or a subsequent retention floor) to the window with an explicit gap signal, and emit oneRawEventper matching occurrence (source/run.rs,source/fetch.rs,source/emit.rs).- Choose RPC methods from decoder interest: event interest spends
getEvents(packed at five filters of five contract IDs; empty IDs send{type: contract}); operation or function interest spendsgetTransactions. Catch-up uses 10,000-ledger event chunks and narrows transaction windows to 512 ledgers (source/run.rsrustdoc,registry.rs). On first successfulgetHealth,probe_healthcallsgetNetwork; disagreeing passphrases areSourceError::InvalidConfignaming endpoint names (source/fetch.rs). - Pack a ledger-primary cursor with a plane-and-index secondary so
invocations, operations, and events in one ledger stay ordered
(
source/cursor.rs,source/emit.rs). - Translate StrKey addresses at the door into prefixed canonical bytes
(
decoder/address.rs); encode those bytes as0xhex on the value tree. - Compile
{ "catalog": "classic" }into one schema perOperationTypeplus named host functionsUploadContractWasm,CreateContract, andCreateContractV2(decoder/classic.rs).InvokeHostFunctionis afunction_call; every other classic type is anoperation. - Compile
{ "catalog": "stellar-asset" }from the pinned SEP-48 JSON through the Soroban compiler (decoder/stellar_asset.rs). - Compile a Soroban contract spec from WASM (
{"wasm":"<base64>"}, reading thecontractspecv0custom section) or from XDR-JSON of anScSpecEntryobject or array (decoder/soroban.rs). - Materialize a spec payload from already-fetched
getLedgerEntriesJSON (spec_from_chain.rs); the decoder does not fetch. - Fetch a spec payload at write time from stellar-rpc
getLedgerEntries(fetch_spec_from_rpc,spec_from_rpc.rs): a Stellar Asset Contract instance becomes{ "catalog": "stellar-asset" }; a WASM contract becomes{ "wasm": "<base64>" }thatcompile_specaccepts. A G-address isInvalid. An RPC failure isUnavailableand names endpoint names, never a URL.StellarSpecImporteris theSpecImporteradapter: it requiressource.moduleto bestellar-rpc, builds that source’s pool fromsource.config, and callsfetch_spec_from_rpc. - Compile selector bodies that admit
events,functions,operations, andaddresses(decoder/selector.rs). Naming any of the three kind keys selects only what it names; naming none selects every declaration the referenced spec exposes. - Decode a source-owned JSON envelope whose
xdrfield is base64 XDR (decoder/envelope.rs,decoder/decode.rs).block.timestampisenvelope.close_time.tx.statusisenvelope.status. Malformed XDR isundecodable; a foreign compiled selector or a non-stellarchain isno_match.
Not this crate’s job: deciding which module a network or spec names
(blockwatcher-embed’s catalog fold, the binary’s config); serving HTTP or
metrics (blockwatcher-api, blockwatcher-metrics); evaluating predicates
(blockwatcher-expr); knowing EVM types (blockwatcher-evm).
Key types and traits
| Name | Kind | Role |
|---|---|---|
StellarRpcSource | struct | The Source implementation: closed-ledger polling over Pool<StellarEndpoint> (source/run.rs) |
StellarRpcConfig | struct | Boot-time config: endpoints, url_secret, selection, timeouts, start cursor (source/config.rs) |
StellarEndpoint | struct | One stellar-rpc JSON-RPC target the pool drives (source/jsonrpc.rs) |
StellarDecoder | struct | The Decoder implementation: claims chain stellar (decoder/mod.rs) |
materialize_spec | fn | Library writer: ledger-entry JSON → {wasm} or {catalog: stellar-asset} (spec_from_chain.rs) |
fetch_spec_from_rpc | fn | Write-time fetch: C-strkey → {wasm} or {catalog: stellar-asset} via getLedgerEntries; G-addresses are Invalid; RPC failure is Unavailable without echoing http (spec_from_rpc.rs) |
StellarSpecImporter | struct | SpecImporter adapter: requires stellar-rpc, builds the pool from source.config, calls fetch_spec_from_rpc (spec_from_rpc.rs) |
spec_importers::get_all | fn | The family’s spec-importer table, keyed by chain; embed folds it the same way it folds sources and decoders (spec_from_rpc.rs) |
Envelope, Plane | struct/enum | Source-owned JSON wrapper around base64 XDR plus status; plane is event / operation / invocation (decoder/envelope.rs) |
StellarInterest | struct | Kind flags and addresses the source downcasts from InterestSet::chain_specific (decoder/interest.rs) |
StellarRpcRegistry, StellarDecoderRegistry | struct | ModuleRegistry entries named "stellar-rpc" and "stellar" (registry.rs) |
How data flows through it
flowchart LR
health["getHealth<br/>retention window"] --> fetch["fetch_window"]
fetch -->|"event interest"| events["getEvents<br/>sharded"]
fetch -->|"operation or function interest"| txs["getTransactions"]
events --> emit["emit_window<br/>Envelope + cursor"]
txs --> emit
emit --> decode["StellarDecoder::decode<br/>base64 XDR"]
decode --> value["canonical DecodedEvent"]
A configured start_ledger below the node’s retained ledgers on the first
health read is refused, naming both bounds. A checkpoint below that window
clamps to the new oldest ledger, increments a retention gap counter, and
continues — the same path a mid-run floor advance takes. If oldestLedger
advances past the next ledger after that read, the run warns with the skipped range, increments a retention
gap counter, publishes Degraded, and continues at the new oldest ledger. An
inconsistent page publishes Degraded and retries the same window; it does
not start at ledger 1. Invalidated is never returned for confirmation
depth: closed ledgers are not rewound (source/run.rs). Resume trusts
cursor.primary because a closed ledger is final: there is no hash-walk
against live headers.
stellar-rpc
Trade-off: this module walks closed ledgers inside the serving node’s RPC
history window. It misses nothing the node still retains, and spends RPC
quota on getEvents / getTransactions proportional to interest and shard
count. Ledgers the node has already dropped are unreachable. A restart
resumes from a ledger the node still has; it cannot reconstruct history the
node no longer serves.
scan and the live loop both walk wide ranges without truncation in bounded
chunks. Event-only catch-up uses at most 10,000 ledgers per chunk. Any chunk
that fetches transactions uses at most 512 ledgers. Pagination stops on an
empty page, an item beyond the inclusive window end, or a page shorter than
the requested limit. getTransactions does not send endLedger; the local
inclusive bound still stops pagination. FAILED envelopes are emitted with
Envelope.status copied from getTransactions. stellar-rpc does not
implement confirmed_tip (the Source port default is Unsupported), so
skip { "to": "tip" } is 422 invalid_resource with "this source cannot report a confirmed tip", the same as evm-mempool. Skip to a ledger/block
number still works while paused.
The stellar decoder
compile_spec accepts these payload shapes (decoder/mod.rs):
{"catalog":"classic"}— one schema per classicOperationType, plusUploadContractWasm,CreateContract, andCreateContractV2.{"catalog":"stellar-asset"}— baked Stellar Asset Contract spec (TransferandTransferMuxedshare thetransfertopic).{"wasm":"<base64>"}— Sorobancontractspecv0.- XDR-JSON of an
ScSpecEntryobject or array.
An unknown catalog string is SpecError::Invalid.
compile requires a spec and admits only events, functions,
operations, and addresses. InvokeHostFunction and the named host
functions are selected through functions, never through operations. An
explicit empty array for a kind list is SelectorError::Invalid.
decode matches envelope.plane, reads the base64 XDR, and looks up the
compiled tables. EventPlan stores data_format. A selected classic
Payment and a selected CAP-67 transfer event on the same ledger are two
DecodedEvents; the decoder does not fold them into one occurrence. Two
spec events that share the on-chain topic transfer (scalar vs muxed data)
compile as distinct schema names; selecting transfer includes both plans
(decoder/decode.rs, decoder/soroban.rs). materialize_spec walks
LedgerEntryData XDR from fetched JSON (spec_from_chain.rs). Write-time
import is fetch_spec_from_rpc plus StellarSpecImporter
(spec_from_rpc.rs); the decoder still does not fetch. A cargo-fuzz
target lives in crates/blockwatcher-stellar/fuzz/ (scval_to_value).
Addresses in selector bodies are StrKey at the door
(decoder/address.rs): account, contract, and muxed flavors become
prefixed canonical bytes so they cannot collide on the value tree.
The registry
Two ModuleRegistry implementations (registry.rs):
| Registry | NAME | Factory builds |
|---|---|---|
StellarRpcRegistry | "stellar-rpc" | Deserializes StellarRpcConfig, validates it, builds Pool<StellarEndpoint>, constructs StellarRpcSource::new(pool, config) |
StellarDecoderRegistry | "stellar" | Accepts null or empty-object config and returns Arc::new(StellarDecoder) |
sources::get_all() returns Vec<SourceModule>
(SourceModule::of::<StellarRpcRegistry>()); decoders::get_all() stays
(name, factory) (registry.rs, re-exported at the crate root). Those
are what blockwatcher-embed’s build_catalog folds under
#[cfg(feature = "stellar")].
Neighbours
blockwatcher-stellar depends on, in production:
blockwatcher-types,blockwatcher-ports,blockwatcher-rpcstellar-xdr,stellar-strkey,wasmparser,base64reqwest,url,serde,serde_json,thiserrorasync-trait,tokio,tokio-util,metrics,tracing,num-bigint
and, in [dev-dependencies] only:
- blockwatcher-stellar-testkit: a scripted JSON-RPC mock node
blockwatcher-testkit,tokio
blockwatcher-embed depends on this crate when the stellar feature is on.
Nothing in the core ring does.
Reading the source
lib.rs: crate doc,decoder/registry/source, re-exports.registry.rs: the twoModuleRegistryimpls and thesources/decodersget_all()tables.source/config.rs,source/jsonrpc.rs,source/fetch.rs,source/emit.rs,source/run.rs.decoder/envelope.rs,decoder/address.rs,decoder/classic.rs,decoder/soroban.rs,decoder/selector.rs,decoder/decode.rs.spec_from_chain.rs(materialize_spec),spec_from_rpc.rs(fetch_spec_from_rpc,StellarSpecImporter,spec_importers::get_all).