Stellar support - #336
Merged
Merged
Conversation
tonatoz
approved these changes
Aug 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stellar family support (stellar-rpc + Horizon)
Summary
Adds the Stellar chain family (
BlockchainType = "stellar"), mainnet and testnet, served throughboth of Stellar's self-contained APIs:
json-rpcconnector — the complete 12-method API.restconnector — 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.yamlalready carries thestellarprotocol (mainnet/testnet, chain-ids = network passphrases, grpcIds 1174/10208,
expected-block-time: 5s,validate-peers: false) and the generatedchains_data.goalready hasSTELLAR/STELLAR_TESTNET. No submodule bump, no regeneration.What changed
Chain type plumbing
pkg/chains/chains.go:Stellar BlockchainType = "stellar", accepted inIsValidBlockchainType,and
case Stellar: return "stellar"ingetMethodSpecName.upstream_factory.gogains thechains.Stellarcase.Flavor selection (
internal/upstreams/chains_specific/stellar_specific)NewStellarChainSpecificObjectdispatches on the primary connector's type, the waycosmos_specific.NewCosmosSpecificdoes:RestConnector→StellarHorizonChainSpecificObject,anything else →
StellarRpcChainSpecificObject. A sharedstellarBaseChainSpecificObjectcarrieswhat 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 thegeneric block processor with safe-block detection off (SCP has no "safe" ledger).
The primary connector is
connectorsInfo.internalRequestConnector, which the factory derives fromconf.GetBestConnector(config.DefaultMode)— DefaultMode is hardcoded at that call site — so itis always the lowest
ApiConnectorTypeordinal. WithJsonRpcConnector < RestConnector, a combinedupstream 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:
getHealthlatestLedgerGET /root documenthistory_latest_ledgergetLatestLedgeris deliberately not used internally: it carries the ledger header XDR, is farheavier than a head poll needs, and exposes no parent hash anyway, so it buys nothing over the small
getHealthdocument the bounds detector already reads. It stays in the spec for clients. LikewiseHorizon'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, soa chain supervisor holding a mix of rpc and Horizon upstreams sees consistent, parent-linkable head
hashes for the same ledger.
GetFinalizedBlock==GetLatestBlockon both: SCP closes ledgers final,no reorgs. Sequence
0is rejected as a parse error.Consequence worth naming: when stellar-rpc trips its own >30s staleness check it answers
getHealthwith
-32603, so the head stops advancing rather than reporting a stale ledger. The health validatorreads the same signal and marks the upstream
Unavailable, so it leaves the pool either way.Chain and health validation
getNetwork.passphrasevschain.ChainId,EqualFoldgetHealth:status=="healthy"→ Available; error containingnot initialized→ Syncing; anything else (incl. the node's own staleness rejection) → Unavailablenetwork_passphrasefrom the root document, same rulesGET /healthbooleans;core_synced=false→ Syncing; db/core down → UnavailableThe 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
/healthwith 503 plus the booleans while unhealthy, so that body is parsedbefore 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 onDisableChainValidation,matching
CosmosRestSpecific; the factory already applies theDisableValidation/Disable*Validationmaster switches.Labels
client_version+client_type, published everyValidationInterval * 5. rpc readsgetVersionInfo.version, Horizon readshorizon_versionfrom the root document; both cut at thefirst
-(27.1.1-<commit>→27.1.1). Types are constants —stellar-rpcandhorizonare theonly implementations of either API.
Lower bounds [delta]
One detector per flavor, period 2 minutes, publishing
StateBoundonly — rpc fromgetHealth.oldestLedger, Horizon fromhistory_elder_ledger. Zero or absent is treated as "the nodedid 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.lowerBoundTypeToApimapsStateBound→LOWER_BOUND_STATE. AddingBlockBound/TxBoundlater needs no wire change.
No
DecreasingBoundDetector[delta]. Horizon'shistory_elder_ledgergenuinely can move down —horizon db reingest rangebackfills older ledgers — but the shared monotonic filter keeps theshallower 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 thecosmos/polkadot/tendermint helpers and following their
FetchX/ParseXsplit:StellarHealthFetchStellarHealth,ParseStellarHealthStellarHorizonRootFetchStellarHorizonRoot,ParseStellarHorizonRootStellarHorizonHealthFetchStellarHorizonHealthstellar_validationsholds validators and nothing else —stellar_boundsandstellar_labelsdo notimport a validations package at all. (Aptos exports
FetchLedgerInfofromaptos_validationsandconsumes it from
aptos_bounds/aptos_labels; that is the older shape and not copied here.) ThegetNetworkpassphrase fetch stays private toStellarChainValidator, its only reader.Splitting
Parse*fromFetch*also removed duplication: bothParseBlockimplementations had beenre-doing the same
sonic.Unmarshalthe helpers already knew, and now call the helper — which alsogives
ParseBlockthe empty-body guard it lacked.specific_helpers.SyntheticHashessolana_specific.SyntheticHashesmoves tospecific_helpersbyte-identically (big-endian uint64in 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_specifickeeps its localheightToHashId(right-aligned encoding); unifying that wouldchange no behaviour but is out of scope.
Method specs
stellar-json-rpc.json—getHealth,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#/pathsalias).stellar.json— the bundle importing both.Everything
cacheable: false, default dispatch everywhere, no tag-parsers, aliases, bans ortranslations.
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.
http_server/handlers.go— non-JSON request bodies were rejected before dispatch.NewRestHandlerransonic.Validon every non-empty body. Horizon'sPOST /transactionstakesapplication/x-www-form-urlencoded(tx=<base64 XDR>), so nodecore answered "no valid json" andnever contacted the node. JSON validity is now enforced only when the client's
Content-Typeisabsent or contains
json; other types pass through opaquely.connectors/http_connector.go— a clientContent-Typestacked behind the connector default.applyConfigHeadersunconditionally setsContent-Type: application/json, thenapplyClientHeadersusedHeader.Add, so a client-declared type became a second value and Go'stransport wrote two
Content-Typelines.Content-Typeis a singleton field and serversresolve it with
Header.Get(first value), so Horizon sawapplication/jsonfor a form body,ParseFormdeclined it, and the submission came back400 transaction_malformed. The client valuenow replaces the default (
Set, notAdd). Config-pinned headers still win over the client, andevery other header still stacks. REST-only:
applyClientHeadershas one call site,sendRest.connectors/http_connector.go—joinEndpointAndPathproduced a double slash. It concatenatesblindly, so a connector URL with a trailing slash plus a path template starting with
/sentGET //(or//health) upstream, and//is a different path than/. Found by running thebinary 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 collapsedagainst the leading slash of the path; base paths (
/api+/health) are preserved.http_server/http_server.go— an empty rest path was always JSON-RPC.reqTypewasTernary(len(restPath) > 0, Rest, JsonRpc), so/queries/stellar/parsed as JSON-RPC and Horizon'sroot was reachable only through the double-slash
/queries/stellar//. An empty rest path with anHTTP
GETis nowRest: a JSON-RPC call is always a POST, so nothing legitimate is reclassified.The visible difference is that a stray
GET /queries/ethnow returns a REST-shaped"method not supported" error instead of a JSON-RPC parse error.
Notes / impact
getTransactionNOT_FOUND,sendTransactionstatus ERROR|TRY_AGAIN_LATER|DUPLICATE, and a failedsimulateTransactionall pass through as successful results. This is a deliberate call for thispass, not an oversight; the general fix (splitting response shape from response verdict) is tracked
separately.
problem+jsonbodies pass through byte-exact, but a 4xx still becomes aResponseErrorand counts against the upstream in dimensions and rating even when the client'srequest was simply wrong. That is pre-existing generic REST behaviour shared with cosmos-rest and
TON, and is left alone here.
StateBoundcomes from stellar-rpc'soldestLedgeronly,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.
Accept: text/event-streamis 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.
(separate listener), cache policies, broadcast
sendTransactionwith DUPLICATE reconciliation, andretention-aware retry of
NOT_FOUND/-32600/before_history410 in mixed-depth pools.Docs
README.md— both chain-family lists.docs/nodecore/11-method-specs.md— bundle table and thejson-rpc/restplain-spec rows.docs/nodecore/05-upstream-config.md—validate-syncing/validate-peersprose plus one roweach 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 theverified API shapes and every deferred decision.