Skip to content

Stellar support - #336

Merged
KirillPamPam merged 3 commits into
mainfrom
stellar_support
Aug 17, 2026
Merged

Stellar support#336
KirillPamPam merged 3 commits into
mainfrom
stellar_support

Conversation

@KirillPamPam

@KirillPamPam KirillPamPam commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Stellar family support (stellar-rpc + Horizon)

Summary

Adds the Stellar chain family (BlockchainType = "stellar"), mainnet and testnet, served through
both of Stellar's self-contained APIs:

  • stellar-rpc (formerly soroban-rpc) over the json-rpc connector — the complete 12-method API.
  • Horizon over the rest connector — the public REST surface, 50 path templates.

Either API can drive an upstream on its own, or both can sit on one upstream. Head tracking, chain
validation, health validation, client labels and lower bounds are implemented per API, so a
standalone Horizon upstream is fully accounted for from Horizon's own endpoints.

Four small fixes in shared REST code came along, because Horizon is the first upstream we route that
needed them (details in Generic REST fixes). Three are latent bugs that
affect every REST family, not just Stellar.

The method surface and the recorded live API shapes come from the unmerged
#295, which is credited in the design doc. This is a
fresh implementation against today's tree rather than a rebase of it, and it deviates deliberately in
four places, each marked [delta] below.

Motivation

Stellar is the last chain family our fleet runs that nodecore could not serve. There is no dshackle
surface to inherit — stellar was never actually served by dshackle — so the method surface is simply
the complete public API of each of the two front-ends.

Horizon is deprecated by SDF in favour of RPC, but it has no shutdown date, is still on maintenance
releases, and much of its query surface (trades, order books, path finding, effects, liquidity pools)
has no RPC equivalent — so serving RPC alone would not cover real traffic.

The chain registry needed nothing: pkg/chains/public/chains.yaml already carries the stellar
protocol (mainnet/testnet, chain-ids = network passphrases, grpcIds 1174/10208,
expected-block-time: 5s, validate-peers: false) and the generated chains_data.go already has
STELLAR / STELLAR_TESTNET. No submodule bump, no regeneration.

What changed

Chain type plumbing

pkg/chains/chains.go: Stellar BlockchainType = "stellar", accepted in IsValidBlockchainType,
and case Stellar: return "stellar" in getMethodSpecName. upstream_factory.go gains the
chains.Stellar case.

Flavor selection (internal/upstreams/chains_specific/stellar_specific)

NewStellarChainSpecificObject dispatches on the primary connector's type, the way
cosmos_specific.NewCosmosSpecific does: RestConnectorStellarHorizonChainSpecificObject,
anything else → StellarRpcChainSpecificObject. A shared stellarBaseChainSpecificObject carries
what does not differ: CapDetectors → nil (no ws transport; Horizon streams SSE, not ws),
MethodsProcessor → nil (neither API can be asked what it implements), poll-only head, and the
generic block processor with safe-block detection off (SCP has no "safe" ledger).

The primary connector is connectorsInfo.internalRequestConnector, which the factory derives from
conf.GetBestConnector(config.DefaultMode)DefaultMode is hardcoded at that call site — so it
is always the lowest ApiConnectorType ordinal. With JsonRpcConnector < RestConnector, a combined
upstream is always driven by stellar-rpc, in default and strict mode alike. That is correct here and
no warning is logged for it: unlike TON's v2 API and v3 indexer, which can front different
backends, stellar-rpc and Horizon are two front-ends of the same node, so combining them is a normal
deployment.

Head tracking [delta]

Poll-based, with synthetic block hashes derived from the ledger sequence:

head source height
stellar-rpc getHealth latestLedger
Horizon GET / root document history_latest_ledger

getLatestLedger is deliberately not used internally: it carries the ledger header XDR, is far
heavier than a head poll needs, and exposes no parent hash anyway, so it buys nothing over the small
getHealth document the bounds detector already reads. It stays in the spec for clients. Likewise
Horizon's head comes from the root document rather than GET /ledgers?order=desc&limit=1, so head,
passphrase, version and history boundary all read one document.

Both flavors compute hash = f(sequence), parentHash = f(sequence-1) through one shared helper, so
a chain supervisor holding a mix of rpc and Horizon upstreams sees consistent, parent-linkable head
hashes for the same ledger. GetFinalizedBlock == GetLatestBlock on both: SCP closes ledgers final,
no reorgs. Sequence 0 is rejected as a parse error.

Consequence worth naming: when stellar-rpc trips its own >30s staleness check it answers getHealth
with -32603, so the head stops advancing rather than reporting a stale ledger. The health validator
reads the same signal and marks the upstream Unavailable, so it leaves the pool either way.

Chain and health validation

chain validation health
stellar-rpc getNetwork.passphrase vs chain.ChainId, EqualFold getHealth: status=="healthy" → Available; error containing not initialized → Syncing; anything else (incl. the node's own staleness rejection) → Unavailable
Horizon network_passphrase from the root document, same rules GET /health booleans; core_synced=false → Syncing; db/core down → Unavailable

The registry loader lowercases every chain-id, so the passphrase compare has to be case-insensitive;
the two passphrases differ in far more than case, so nothing is lost. Mismatch (an empty passphrase
included, since it fails the same compare) → FatalSettingError; fetch failure → SettingsError.

Horizon answers /health with 503 plus the booleans while unhealthy, so that body is parsed
before falling back to the transport error — that is what keeps "captive core still syncing"
distinguishable from "Horizon is down".

Health validators are gated on ValidateSyncing, settings validators on DisableChainValidation,
matching CosmosRestSpecific; the factory already applies the DisableValidation /
Disable*Validation master switches.

Labels

client_version + client_type, published every ValidationInterval * 5. rpc reads
getVersionInfo.version, Horizon reads horizon_version from the root document; both cut at the
first - (27.1.1-<commit>27.1.1). Types are constants — stellar-rpc and horizon are the
only implementations of either API.

Lower bounds [delta]

One detector per flavor, period 2 minutes, publishing StateBound only — rpc from
getHealth.oldestLedger, Horizon from history_elder_ledger. Zero or absent is treated as "the node
did not report its boundary", not as full history: the detector returns an error, so the processor
logs, skips the tick, and the previously published bound stands. Each fetch is wrapped in the usual
3-attempt / 500ms failsafe retry.

Rationale for STATE only: nothing in nodecore's own routing consults stellar bounds (no tag-parsers
means no matcher asks), so the bound exists to be republished over the gRPC stream, where
emerald.lowerBoundTypeToApi maps StateBoundLOWER_BOUND_STATE. Adding BlockBound / TxBound
later needs no wire change.

No DecreasingBoundDetector [delta]. Horizon's history_elder_ledger genuinely can move down
horizon db reingest range backfills older ledgers — but the shared monotonic filter keeps the
shallower value, so nodecore under-claims history: it routes away from an upstream that could have
served, never towards one that cannot. The filter is in-memory, so a restart republishes the truth.
That is an acceptable trade for leaving shared bound-processing code untouched.

Shared node-document helpers

Every document more than one package reads lives in specific_helpers/stellar.go, next to the
cosmos/polkadot/tendermint helpers and following their FetchX / ParseX split:

Type Helpers Read by
StellarHealth FetchStellarHealth, ParseStellarHealth rpc head, rpc health validator, rpc bounds
StellarHorizonRoot FetchStellarHorizonRoot, ParseStellarHorizonRoot Horizon head, chain validator, labels, bounds
StellarHorizonHealth FetchStellarHorizonHealth Horizon health validator

stellar_validations holds validators and nothing else — stellar_bounds and stellar_labels do not
import a validations package at all. (Aptos exports FetchLedgerInfo from aptos_validations and
consumes it from aptos_bounds / aptos_labels; that is the older shape and not copied here.) The
getNetwork passphrase fetch stays private to StellarChainValidator, its only reader.

Splitting Parse* from Fetch* also removed duplication: both ParseBlock implementations had been
re-doing the same sonic.Unmarshal the helpers already knew, and now call the helper — which also
gives ParseBlock the empty-body guard it lacked.

specific_helpers.SyntheticHashes

solana_specific.SyntheticHashes moves to specific_helpers byte-identically (big-endian uint64
in bytes [0:8] of a 32-byte id), so the hashes solana publishes keep their exact current values —
pinned by solana's own pre-existing tests plus a new test on the helper. Stellar uses it.
aptos_specific keeps its local heightToHashId (right-aligned encoding); unifying that would
change no behaviour but is out of scope.

Method specs

  • stellar-json-rpc.jsongetHealth, getNetwork, getVersionInfo, getLatestLedger,
    getLedgers, getLedgerEntries, getEvents, getTransaction, getTransactions, getFeeStats,
    sendTransaction, simulateTransaction.
  • stellar-horizon.json — 50 templates: root, health, fee_stats, accounts (+ data/offers/
    transactions/operations/payments/effects/trades), ledgers (+ subs), transactions (+ subs,
    POST#/transactions, POST#/transactions_async), operations, payments, effects, offers (+ trades),
    order_book, trades, trade_aggregations, assets, claimable_balances (+ subs), liquidity_pools
    (+ subs), paths (strict-receive / strict-send + the legacy GET#/paths alias).
  • stellar.json — the bundle importing both.

Everything cacheable: false, default dispatch everywhere, no tag-parsers, aliases, bans or
translations.

Generic REST fixes

Four changes to shared code. Only the last is Stellar-specific in origin; the first three are latent
bugs that any REST family can hit.

  1. http_server/handlers.go — non-JSON request bodies were rejected before dispatch.
    NewRestHandler ran sonic.Valid on every non-empty body. Horizon's POST /transactions takes
    application/x-www-form-urlencoded (tx=<base64 XDR>), so nodecore answered "no valid json" and
    never contacted the node. JSON validity is now enforced only when the client's Content-Type is
    absent or contains json; other types pass through opaquely.

  2. connectors/http_connector.go — a client Content-Type stacked behind the connector default.
    applyConfigHeaders unconditionally sets Content-Type: application/json, then
    applyClientHeaders used Header.Add, so a client-declared type became a second value and Go's
    transport wrote two Content-Type lines. Content-Type is a singleton field and servers
    resolve it with Header.Get (first value), so Horizon saw application/json for a form body,
    ParseForm declined it, and the submission came back 400 transaction_malformed. The client value
    now replaces the default (Set, not Add). Config-pinned headers still win over the client, and
    every other header still stacks. REST-only: applyClientHeaders has one call site, sendRest.

  3. connectors/http_connector.gojoinEndpointAndPath produced a double slash. It concatenates
    blindly, so a connector URL with a trailing slash plus a path template starting with / sent
    GET // (or //health) upstream, and // is a different path than /. Found by running the
    binary against a stellar config: the Horizon root probe — which drives head, passphrase, version
    and the history boundary — went to http://host//. A trailing slash on the base is now collapsed
    against the leading slash of the path; base paths (/api + /health) are preserved.

  4. http_server/http_server.go — an empty rest path was always JSON-RPC. reqType was
    Ternary(len(restPath) > 0, Rest, JsonRpc), so /queries/stellar/ parsed as JSON-RPC and Horizon's
    root was reachable only through the double-slash /queries/stellar//. An empty rest path with an
    HTTP GET is now Rest: a JSON-RPC call is always a POST, so nothing legitimate is reclassified.
    The visible difference is that a stray GET /queries/eth now returns a REST-shaped
    "method not supported" error instead of a JSON-RPC parse error.

Notes / impact

  • Explicitly deferred: application errors inside a successful response. getTransaction
    NOT_FOUND, sendTransaction status ERROR|TRY_AGAIN_LATER|DUPLICATE, and a failed
    simulateTransaction all pass through as successful results. This is a deliberate call for this
    pass, not an oversight; the general fix (splitting response shape from response verdict) is tracked
    separately.
  • Horizon's RFC-7807 problem+json bodies pass through byte-exact, but a 4xx still becomes a
    ResponseError and counts against the upstream in dimensions and rating even when the client's
    request was simply wrong. That is pre-existing generic REST behaviour shared with cosmos-rest and
    TON, and is left alone here.
  • In a combined upstream the published StateBound comes from stellar-rpc's oldestLedger only,
    while its Horizon connector serves a different window (history_elder_ledger, its own ingest DB).
    Whether combined upstreams should reconcile the two, and which should win, is an open question
    recorded in the design doc rather than decided here.
  • Horizon SSE streaming is out of scope. Accept: text/event-stream is a per-request opt-in;
    without it every endpoint serves plain JSON, so the REST connector works untouched. An SSE request
    through nodecore will hang until the connector timeout.
  • Also out of scope: friendbot (a testnet redirect to an external service), Horizon's admin endpoints
    (separate listener), cache policies, broadcast sendTransaction with DUPLICATE reconciliation, and
    retention-aware retry of NOT_FOUND / -32600 / before_history 410 in mixed-depth pools.

Docs

  • README.md — both chain-family lists.
  • docs/nodecore/11-method-specs.md — bundle table and the json-rpc / rest plain-spec rows.
  • docs/nodecore/05-upstream-config.mdvalidate-syncing / validate-peers prose plus one row
    each in the chain-validator, health-validator, lower-bound and client-label tables.
  • docs/superpowers/specs/2026-08-14-stellar-support-design.md — the design of record, including the
    verified API shapes and every deferred decision.

@KirillPamPam
KirillPamPam merged commit c93c2d1 into main Aug 17, 2026
5 checks passed
@KirillPamPam
KirillPamPam deleted the stellar_support branch August 17, 2026 13:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants