Stellar Architecture (Stages 6, 8, 9, 10)
Scope: account topology, authority, trustlines, authorization, issuance/burn mapping, sequence management, the transaction lifecycle, RPC, indexing, supply reconciliation and the validator decision. Decisions referenced: Q-01–Q-04, D-S01, D-E01–D-E05 (../architecture/DECISIONS.md). Protocol facts: ../discovery/STELLAR_TECHNOLOGY_BASELINE.md.
Protocol assumptions marked [verified] were tested on Quickstart (P30): ../testing/evidence/STELLAR_ASSUMPTIONS_SPIKE_20261029.md.
1. Account topology
flowchart TB
subgraph Authority["Issuer account (per asset family; master weight 0)"]
ISS["ISSUER G…<br/>flags: AUTH_REQUIRED, AUTH_REVOCABLE, AUTH_CLAWBACK_ENABLED"]
end
H["Issuer signers H1…Hn<br/>(human keys, weight 10 each; launch: one)"] -. sign .-> ISS
AUTH["Authorization signer<br/>(online key, weight 1)"] -. sign low only .-> ISS
ISS -- "ISSUE = Payment from issuer" --> COLD["TREASURY_COLD<br/>(human multisig)"]
COLD -- "refill (four-eyes, multisig)" --> HOT["TREASURY_HOT<br/>(online key)"]
HOT -- "sweep above ceiling" --> COLD
COLD -- "BURN = Payment to issuer" --> ISS
HOT -- "BUY settlement" --> C1["Customer account A<br/>(signer: custody key)"]
C1 -- "SELL settlement" --> HOT
C1 -- "TRANSFER" --> C2["Customer account B"]
SP["RESERVE_SPONSOR<br/>(pays customer reserves)"] -. sponsors .-> C1
SP -. sponsors .-> C2
FUND["FUNDER<br/>(tops up channels)"] --> CH["Channel accounts CH1…CHn<br/>(tx source: sequence + fee)"]
| Account | Purpose | Holds asset? | Signers (MVP) | Created by |
|---|---|---|---|---|
ISSUER | Asset identity; issuance, authorization, clawback | Never (issuer balances do not exist) | H1…Hn (weight 10; launch: one signer), AUTH (weight 1); master 0 | Asset ceremony |
TREASURY_COLD | Receives issuance; source of burns; reserve inventory | Yes | Human treasury signers (multisig); master 0 | Ceremony |
TREASURY_HOT | Operational float for BUY; receives SELL | Yes | HOT online key + human signers for high ops; master 0 | Ceremony |
RESERVE_SPONSOR | Sponsors customer account + trustline + signer reserves | No | SPONSOR online key; master 0 | Ceremony |
FUNDER | Holds operational XLM; tops up channels | No | FUNDER online key | Ceremony |
CHANNEL_n | Transaction source: sequence numbers and fees | No | CHANNEL online key (shared); master 0 | Bootstrap by FUNDER |
GOV_CHANNEL | Transaction source for human-signed proposals only | No | CHANNEL key | Bootstrap |
| Customer account | Custodial holding account, one per customer | Yes | CUSTODY online key (shared across customers, weight 1); master 0 | Onboarding (§4) |
Rules:
- The issuer, Treasury and customer accounts are never transaction sources. They appear only as operation sources. Their sequence numbers stay frozen, so (a) a pending multisig proposal can never be invalidated by routine activity, and (b) concurrency is handled entirely in the channel pool (§6).
- One issuer per asset. The issuer's flags and signers then apply to exactly one asset, and one asset's incident cannot affect another. Cost: one more ceremony per asset.
- Hot/cold split:
TREASURY_HOTholds at most a configured float (Q-08); everything else stays inTREASURY_COLD. This bounds the loss if the online hot key is compromised (brief §95). For reconciliation, "Treasury inventory" = HOT + COLD. - Customer accounts hold no secret keys at rest. Each is created with a throwaway keypair whose only use is to sign the creation transaction; the account's master weight is set to 0 there, and the shared
CUSTODYkey is added as the only signer. A production HSM therefore holds one customer key, not one per customer (same reasoning as the siblings' D-018).
2. Authority (summary; full matrix in ../security/AUTHORITY_MATRIX.md)
Issuer thresholds follow from the number of human signers (ceremony.Quorum, ../security/AUTHORITY_MATRIX.md §1):
launch, one signer: low = 1, medium = 10, high = 10; reference design, four signers: 1 / 20 / 30. Each human
signer has weight 10, AUTH weight 1, so AUTH never reaches the medium threshold.
| Action | Stellar operation | Class | Who can reach it (launch: one signer · four signers) |
|---|---|---|---|
| Authorize / revoke / restore a customer trustline | SetTrustLineFlags | Low (1) | AUTH alone (online), or any human |
| Issue | Payment issuer → TREASURY_COLD | Medium | The signer · any 2 of 4. AUTH alone: cannot (TestSingleHumanSigner) |
| Clawback | Clawback | Medium | The signer · any 2 |
| Home domain, flags | SetOptions (no signer change) | Medium | The signer · any 2 |
| Change signers or thresholds | SetOptions (signers/thresholds) | High | The signer · any 3 of 4 (one lost key still leaves 3). With one signer a lost key cannot be repaired |
A compromised AUTH key can authorize a stranger's trustline (useless: only our Treasury and customers send the asset, and their keys are separate) or revoke customers' authorization (denial of service, reversible by AUTH/humans). It cannot create, move or destroy tokens.
3. Asset identity and configuration
- Identity =
asset_code+ISSUER+ network passphrase. The backend stores all three and refuses to start if the configured issuer, Treasury and channel accounts or the passphrase disagree with the environment manifest and withgetNetwork(brief §70). - Codes: GOLD (alphanum4) at launch (Q-13, decided 2026-10-31). Later assets (for example SILVER) follow the same model.
- Precision: 7 decimals, fixed by Stellar. The platform stores amounts as integer stroops (
int64on-chain,NUMERIC(20,0)in PostgreSQL). - Flags are set by the ceremony before any trustline exists; the ceremony verifies
TRUSTLINE_CLAWBACK_ENABLEDon the first trustline (ASSET_CONFIGURATION.md). - Metadata (SEP-1). On every non-local network the issuer's
home_domainpoints to our/.well-known/stellar.toml, published before activation. It has one[[CURRENCIES]]entry per asset:code,issuer,display_decimals(= the asset's display precision),name,desc,is_asset_anchored = true,anchor_asset_type = "commodity",anchor_asset(the unit definition, e.g. "1 g of 750 gold"),attestation_of_reserve(the latest published attestation),redemption_instructions, and a plain statement of the issuer's freeze and clawback powers. Wallets and explorers identify the asset by this file. Its fields must match the database and the chain: activation and the scanner check this. We do not setregulated = true: that flag announces a SEP-8 approval server, which a custodial platform does not need (see Q-09).
4. Customer onboarding (trustline as a product concept)
Triggered when Compliance approves KYC. One transaction, source CHANNEL_k [verified]:
| # | Operation | Op source | Signed by |
|---|---|---|---|
| 1 | BeginSponsoringFutureReserves(new) | RESERVE_SPONSOR | SPONSOR |
| 2 | CreateAccount(new, 0 XLM) | RESERVE_SPONSOR | SPONSOR |
| 3 | ChangeTrust(GOLD, limit = max) per enabled asset | new | throwaway key |
| 4 | SetOptions(signer CUSTODY weight 1, master 0, thresholds 1/1/1) | new | throwaway key |
| 5 | EndSponsoringFutureReserves | new | throwaway key (signatures are checked against the state before the transaction applies) |
| 6 | SetTrustLineFlags(new, GOLD, set AUTHORIZED) per asset | ISSUER | AUTH |
Signature set: channel, SPONSOR, throwaway, AUTH, and nothing else. An unused signature (for example CUSTODY) makes the envelope invalid (txBAD_AUTH_EXTRA, found in the Stage 14 integration tests). The throwaway key is derived in the signer from the customer ID (onboard:<uuid>), so an expired onboarding is re-signed with the same key; the persisted envelope is enough for rebroadcast. The wallet shows only "Activating your GOLD wallet…", then "Ready". Before any payment to an account, the gateway checks (error names from brief §23): ACCOUNT_NOT_FOUND, TRUSTLINE_REQUIRED, TRUSTLINE_NOT_AUTHORIZED, TRUSTLINE_LIMIT_EXCEEDED, INSUFFICIENT_RESERVE.
Enabling a later asset for existing customers is the same transaction without ops 2 and 4 (sponsored ChangeTrust + authorize).
5. Authorization state machine
| Business state | Stellar trustline flags | Can send | Can receive | Entered by |
|---|---|---|---|---|
PENDING | no trustline yet, or unauthorized | — | — | Account created |
ACTIVE | AUTHORIZED | yes | yes | KYC approval |
FROZEN | AUTHORIZED_TO_MAINTAIN_LIABILITIES* or unauthorized | no | no | Compliance freeze (four-eyes: no; single Compliance officer, audited) |
ACTIVE (restored) | AUTHORIZED | yes | yes | Unfreeze (four-eyes) |
CLOSED | trustline removed after a zero balance | — | — | Account closure (future) |
* We use fully unauthorized for FROZEN, since we have no offers; AUTHORIZED_TO_MAINTAIN_LIABILITIES only matters for DEX offers. The platform freeze also blocks quotes and orders off-chain, so the on-chain freeze is defense in depth.
SUSPENDED (an off-chain trading block with no chain effect) and REVOKED (KYC failure, terminal unless re-approved) map to FROZEN on-chain.
6. Sequence management: the channel pool
Constraints: one transaction per source account per ledger; a failed transaction consumes its sequence number (and fee); txBAD_SEQ consumes nothing; an expired transaction can never apply once a closed ledger's close time passes its maxTime.
Design:
channel_accountstable: account,next_seq(persisted),state(FREE/LEASED/QUARANTINED),leased_by(blockchain transaction id),lease_expires_at.- Lease:
SELECT … FOR UPDATE SKIP LOCKEDpicks aFREEchannel, assignsseq = next_seq, setsnext_seq = seq + 1, and persists the signed envelope + hash, in the same DB transaction. One in-flight transaction per channel at a time. - Release: when the transaction is in a closed ledger (success or failure; the sequence is consumed either way), the channel returns to
FREE. - Expiry: if
getTransaction(hash)isNOT_FOUNDand the latest closed ledger's close time >maxTime, the envelope can never apply. The business intent gets a new attempt (new channel lease, new envelope), and the channel'snext_seqis re-read from the chain before the channel is freed. txBAD_SEQ(another writer used the channel, e.g. a mis-configured second environment): the channel isQUARANTINED, an incident opens, and the intent moves to a fresh channel. Nothing ever re-signs a transaction with a guessed sequence number.- Crash recovery: on start, every
LEASEDchannel is resolved by its persisted hash before reuse. There are no in-memory sequence counters. - Multiple workers: leases are row locks, so any number of worker replicas is safe. The MVP still runs one active submitter (advisory lock, as in the siblings) for simpler reasoning, and the pool gives the parallelism.
- Throughput: n channels → up to n transactions per ledger, each up to 100 operations. Pool size (default 8) and batching are tuned in Stage 25 from measurements.
Every signature on an envelope must be consumed by the ledger's signature check, which stops at the threshold. A human proposal therefore carries a minimal sufficient subset of the uploaded signatures (chosen by descending weight), never all of them. A third human signature on a 2-of-4 issuance would otherwise invalidate it (txBAD_AUTH_EXTRA). All uploads stay in proposal_signatures for the audit.
GOV_CHANNEL is used only for human multisig proposals (issuance, burn, refill, clawback, signer changes). Proposals are serialized: one pending proposal per GOV_CHANNEL, with a long but bounded maxTime (default 24 h; Q-07).
7. Transaction lifecycle (BlockchainOperation)
stateDiagram-v2 [*] --> CREATED: business tx commits intent (outbox) CREATED --> SIGNED: channel leased, envelope built with timebounds, signed, persisted with hash CREATED --> AWAITING_SIGNATURES: human multisig proposal AWAITING_SIGNATURES --> SIGNED: signature weight ≥ threshold (verified locally) AWAITING_SIGNATURES --> EXPIRED: maxTime passed unsigned SIGNED --> SUBMITTED: sendTransaction PENDING / DUPLICATE SIGNED --> UNKNOWN: timeout or transport error SUBMITTED --> UNKNOWN: no result yet UNKNOWN --> SUBMITTED: resubmit identical envelope SUBMITTED --> APPLIED: getTransaction SUCCESS SUBMITTED --> FAILED: getTransaction FAILED (in ledger; seq + fee consumed) UNKNOWN --> APPLIED UNKNOWN --> FAILED UNKNOWN --> EXPIRED: NOT_FOUND and closed ledger close time > maxTime (checked on ≥ 1 healthy node at a ledger ≥ maxTime) SIGNED --> REJECTED: sendTransaction ERROR (never entered the ledger; e.g. txBAD_SEQ, txINSUFFICIENT_FEE) EXPIRED --> [*]: intent gets a new attempt or fails per handler REJECTED --> RECONCILIATION_REQUIRED: txBAD_SEQ / unexpected APPLIED --> [*] FAILED --> [*]
- Business intent (
blockchain_operations) ↔ attempts (stellar_transactions, one per envelope). An intent has at most one non-terminal attempt. - Every envelope carries
MEMO_HASH = SHA-256("dara/op/v1" ‖ intent id). The indexer links on-chain effects to intents through this memo, even when our database lost the response. - Time bounds: automatic transactions
maxTime = now + 60 s(configurable); human proposals 24 h.maxTime = 0is refused by the builder (D-E05). NTP drift is monitored; the expiry decision uses ledger close time, never the local clock. - Handlers (by purpose, inside the same DB transaction as the state change):
OnSubmitted,OnApplied,OnFailed(reason),OnReconciliationRequired. There is no confirmed/finalized split: a closed ledger is final (D-E04). - Fees:
fee = min(max_fee_per_op, max(100, p70 of getFeeStats inclusion fee)) × ops. During surge, an expired attempt may be rebuilt with a higher fee, up to the cap. - Errors are decoded into the domain taxonomy (brief §90):
AccountMissing,TrustlineMissing,NotAuthorized,InsufficientBalance,InsufficientReserve,SequenceConflict,TransactionExpired,FeeTooLow,DestinationFull,OperationNotAllowed,NetworkUnavailable,UnknownSubmissionResult.
8. Flows mapped to Stellar
| Domain operation | Stellar | Supply effect |
|---|---|---|
IssueAsset(asset, q, TREASURY_COLD) | Payment op source ISSUER → TREASURY_COLD; human proposal | +q |
BurnAsset(asset, q) | Payment op source TREASURY_COLD → ISSUER; human proposal | −q |
| Refill hot | Payment COLD → HOT; human proposal | 0 |
| Sweep | Payment HOT → COLD; automatic above the ceiling | 0 |
| BUY settlement | Payment HOT → customer | 0 |
| SELL settlement | Payment customer → HOT (signed by CUSTODY) | 0 |
| TRANSFER | Payment customer → customer (signed by CUSTODY) | 0 |
| Freeze / unfreeze | SetTrustLineFlags (signed by AUTH) | 0 |
| Clawback | Clawback from customer; human proposal; re-issue to the rightful owner is a separate, approved issuance | −q (then +q if re-issued) |
BUY and SELL never touch the issuer, so INV "BUY/SELL/TRANSFER do not change supply" holds by construction and is also checked by reconciliation.
9. RPC architecture
- Two self-hosted
stellar-rpcv30 nodes (A, B), each with its own captive Core, on separate hosts (D-S01). Mainnet hardware: baseline §5. - Gateway health sample every 5 s per node:
getHealth(status, latest ledger, ledger close-time age),getNetwork(passphrase must match),getLatestLedger(sequence + hash), latency, and error rate. States:HEALTHY,DEGRADED(lagging > 2 ledgers or slow),UNHEALTHY,DISABLED(operator). - Reads: the healthiest node, with failover. Critical reads (reconciliation) read ledger entries and record
latestLedgerfrom the response; reconciliation uses one node per run and records which. - Writes: the persisted envelope is sent to every healthy node.
DUPLICATEcounts as success. A different envelope is never built for the same attempt. - Expiry proof (
EXPIRED) needs a healthy node whose latest ledger close time is pastmaxTimeand which returnsNOT_FOUND. With two healthy nodes, both must agree; otherwise the attempt staysUNKNOWNand the gate counts it. - No hosted RPC is configured. An optional read-only fallback is supported but disabled (independence).
10. Indexing and reconciliation
- Indexer: Go Ingest SDK reading
LedgerCloseMetafrom our RPC (getLedgers), ledger by ledger. The cursor is the ledger sequence, so gaps are impossible inside the RPC window. It records, for our assets and accounts: payments (decoded as issue/burn/transfer), clawbacks, trustline creation/flag changes, signer and threshold changes on our system accounts, and memo hashes. Idempotent on(ledger, tx index, op index). - Retention: RPC keeps 7 days by default. If the indexer falls further behind, it backfills the gap from a SEP-54 ledger data lake. The source is our own Galexie lake on local disk (Q-10, decided;
deploy/datalake/). The same Ingest SDK code reads it throughledgerbackend.BufferedStorageBackend(any SDKLedgerBackendplugs into the indexer'sBackendSource), so there is one decoder and the cursor (ledger sequence) is unchanged. With no backfill source configured, the indexer stops at the gap and opens a CRITICALINDEXER_GAPincident; it never skips a ledger. Reconciliation's own balance checks still work, because they read current ledger entries; itsindexer_currentcheck fails until the gap is filled. - Start and the ceremony: the indexer's first ledger is at or before the ceremony, so it sees every issuance. Signer/threshold/flag changes on an account in ledgers that closed before the platform registered (and verified) the account are its ceremony and open nothing; any later change without an intent that asks for it (customer onboarding sets the custody signer) is CRITICAL.
- Unexpected-event detection (CRITICAL incidents): issuance or clawback without an approved proposal; signer/threshold/flag change on
ISSUER, Treasury, sponsor or funder accounts without an approved proposal; any authorized trustline on an account we did not create; any payment that touches our accounts without a known memo; a claimable balance, liquidity pool or SAC contract balance in our asset; a change tohome_domain. Deployment of the asset's SAC by anyone is a WARNING: it grants no powers, but the scanner should show it. - Supply = Σ trustline balances + claimable balances + liquidity-pool reserves + SAC contract balances (baseline §10). The closed holder set (Q-03) means only the first term should be non-zero. The other three are checked to be zero, not assumed. Anyone can deploy the SAC for a Classic asset without permission. The indexer records every claimable balance, liquidity-pool reserve and SAC contract balance (decoded with the SDK's
ingest/sachelpers) in our assets, so reconciliation checks they are zero from observed state; it does not rely on the expectation that, underAUTH_REQUIRED, a contract balance starts unauthorized. (That expectation is still unverified on Quickstart; it only affects how likely a finding is, not whether it is detected.) Read withgetLedgerEntriesin batches of trustline keys for: HOT, COLD, every customer account. Cross-checks: (1) Σ on-chain = issued − burned − clawed back per indexer; (2) supply ≤ approved backing; (3) per-account on-chain balance = ledger position (minus in-flight attempts); (4) trustline count from the indexer = known accounts. - Implemented checks (
reconciliationpackage): ledger integrity (balanced, projection = entries, no negative restricted balance); per asset, from one node at one ledger L: each known account's balance = its ledger position, Σ known balances =TOKEN_SUPPLY, supply ≤ unexpired backing,TOKEN_SUPPLY= Σ holder accounts, business trustline state = on-chain authorization (and clawback enabled), issuer layout = ceremony layout; from the indexer: issued − burned − clawed back = every holding it has seen, nothing held outside the trustlines of known accounts, indexer within 60 ledgers of L. In-flight intents are resolved per attempt hash against L (applied ≤ L but not recorded: the chain has it; recorded but applied after L: the ledger has it); a node that cannot prove either makes the affected accountsSKIPPED_IN_FLIGHTfor that run, never a guess. - Details: the checks and what each failure means are in
../operations/RECONCILIATION_RUNBOOK.md; how ledger accounts map to on-chain balances is../finance/LEDGER_MODEL.md§4.
11. Validator decision
Option A (self-hosted RPC with captive Core, ×2, no validator) is selected (D-S01 (4); baseline §11). We do not participate in SCP and have no quorum set to configure. If Stellar Mainnet is unreachable, the trading gate closes (fail closed, D-S01 (3)); there are no PENDING_CHAIN_SETTLEMENT trades.
12. Environments and safety
| Env | Network | RPC | Signers |
|---|---|---|---|
| LOCAL / DEMO / TEST / CI | Quickstart --local (own passphrase per run) | Quickstart's RPC | Dev signer (deterministic seeds from a local file, never committed); refused unless the passphrase is a local one |
| TESTNET | Testnet | Own RPC on Testnet (SDF public RPC allowed only as a read fallback) | Testnet-only keys; human signers are test hardware keys |
| UAT | Testnet | Two own RPC nodes | Production signer adapter, test keys |
| MAINNET_CANARY / PRODUCTION | Mainnet | Two own RPC nodes | HSM/remote signer; human hardware keys |
Startup verification: passphrase (config = getNetwork = manifest), the issuer's flags and signer set, the Treasury, sponsor and channel signer sets, and that the signer backend is allowed for that passphrase. The dev signer and the demo seed command refuse the Mainnet and Testnet passphrases.