Skip to main content
Back to research
ADR-0011Accepted2026-04-27

Three-domain supply algorithm — XLM hard-coded, classic from ledger entries, SEP-41 from event sums

View source on GitHub
Amendment (2026-07-08, BACKLOG #59). The "SAC-wrapped classics — both algorithms must agree" §'s "Cross-check: alert when they disagree by more than 1 stroop" is a true invariant ONLY for a classic asset whose entire economic supply is represented through its SAC (a genuinely SAC-issued token). It does NOT hold for the common case — a classic asset that merely HAS a SAC wrapper but is mostly held classically — where Algorithm 2's total and Algorithm 3's total legitimately diverge by ~the whole non-wrapped supply (e.g. AQUA: Algorithm 2 ≈ 86.4B, Algorithm 3 ≈ 0). Applying the equality compare unconditionally produced 8 standing stellarindex_supply_cross_check_divergence false positives — a monitoring category error, not indexer corruption; served supply was always correct. internal/supply.WrapClass now selects the equality compare only for an operator-attested WrapClassFull pair ([supply].fully_wrapped_sacs; none configured as of 2026-07-08); every other pair defaults to WrapClassPartial, which checks the true subset-bound invariant instead: a SAC's total_supply can never exceed its classic asset's total_supply (SACWrapped is one of Algorithm 2's own non-negative addends), so only sac_total > classic_total fires. The real subset compare (Algorithm 2's SACWrapped component vs Algorithm 3's total, which per this ADR's own math IS a true equality) needs new plumbing not yet built — see internal/supply/crosscheck.go and docs/operations/runbooks/supply-cross-check-divergence.md for the full account. The decision below is preserved as the original record per the immutability rule.
Reality note (2026-06-12, F-1354 / D2-03; resolved 2026-07-05, launch-todo P4-2). For ~3 weeks the SEP-1 max_supply precedence step described below was dead code (supply.Overlay had zero callers). As of 2026-07-05 it is wired into the `/v1/assets/{id}` serving path (internal/api/v1/assets_f2.go): when the supply snapshot carries no operator-override max, the handler overlays the issuer's stellar.toml [[CURRENCIES]] max_number (falling back to fixed_number; blocked by an explicit is_unlimited = true), scaled from display units to raw units by the asset's decimals. The self_declared: true flag described below shipped in a different shape: the wire carries the declaration verbatim as max_number / fixed_number / is_unlimited metadata fields, and an applied overlay labels supply_basis: "sep1_declared_max" — a stronger per-source signal than a boolean. The overlay applies at serving time only; asset_supply_history rows are never rewritten with declared values. The decision below is preserved as the original record.
Amendment (2026-07-24, audit-2026-07-23 wave5 DOC-01/DAT-14). The "API + schema" section's hypertable note below — "Append-only; latest row per asset_key is the queryable current state" — and migration 0005's table COMMENT ("append-only") are no longer accurate. Migration 0109 (the INV-3 re-derive-trap fix) added a generation-guarded ON CONFLICT ... DO UPDATE to the asset_supply_history writer (internal/storage/timescale/supply.go): a re-derive at the same (asset_key, ledger_sequence) now overwrites the existing row in-place when derive_generation is >= the stored value, rather than being silently discarded by DO NOTHING against stale data. The table is therefore idempotent-corrective (in-place UPDATE guarded by generation, not pure append), not append-only. A follow-up migration to update the migration-0005 COMMENT ON TABLE text accordingly is tracked separately (DAT-14) and not made here.

>

Separately, DAT-14 also flags that this invariant — like the "single-writer by construction" claims elsewhere in the codebase — is enforced by application code only, with no corresponding Postgres-role REVOKE/GRANT split restricting UPDATE/DELETE on asset_supply_history (or trades, oracle_updates, soroban_events) to a distinct migration/ops role. That hardening is infrastructure + migration work, out of scope for this doc pass.

Context

The V2 spec (requirement F2.4 in docs/architecture/coverage-matrix.md) requires the API to publish total_supply / circulating_supply / max_supply for every asset we index. The numbers feed market-cap, FDV, and supply-pct fields on the asset-detail endpoint and the historical supply chart.

Stellar's asset model has three structurally different domains that need three different algorithms:

  1. Native XLM — fixed: 50 B genesis lumens + ~1.8 B inflation

pool, frozen by network vote in October 2019. Total supply doesn't move; only the SDF-reserve exclusion changes circulating.

  1. Classic credit assets (CODE:ISSUER) — issuer-authoritative.

Total supply is the sum of every unit the issuer has emitted that hasn't been burned, observable as the inverse of the issuer's balance + the trustline / claimable / LP / SAC-wrapped balances downstream. Reconstructed from ledger meta.

  1. SEP-41 Soroban tokens — event-defined per the SEP-41 spec:

Σ mint − Σ burn − Σ clawback. Indexed off contract events.

These can't share a single materialisation pipeline. Per ADR-0003 all amounts are *big.Int / NUMERIC end-to-end (i128 safety) and strings on the wire.

This ADR is the immutable commitment to the supply-derivation policy.

Decision

Adopt three domain-specific supply algorithms with a shared schema, plus an operator-configurable locked-set policy for the circulating-supply derivation.

Algorithm 1 — Native XLM

  • total_supply = hard-coded constant 50_001_806_812 * 10^7

stroops (50 B genesis + inflation pool, frozen 2019-10).

  • max_supply = total_supply.
  • circulating_supply = `total_supply − Σ(SDF reserve account

balances)`. Reserve account list is config, not on-chain derivable. SDF publishes the list; we maintain a YAML version- controlled in the deployment repo and refresh it when SDF publishes changes.

No event-stream tracking; the numbers don't move except for reserve-account balance changes (which our trustline-delta indexer already observes).

Algorithm 2 — Classic credit assets

  • total_supply = `Σ trustline balances + Σ claimable balances +

Σ LP-reserve pro-rata + Σ SAC-wrapped contract balances for the asset. Reconstructed from Galexie ledger meta — we observe every TrustLineEntry / ClaimableBalanceEntry / LiquidityPoolEntry / SAC-contract-data delta and maintain a per-(asset, ledger) running total in the asset_supply_history` hypertable.

  • max_supply = null by default (classic issuers can always

issue more). Two override paths: 1. SEP-1 [[CURRENCIES]].max_supply from the issuer's stellar.toml — respected as a display value but flagged self_declared: true in the API response (not on-chain enforced). 2. Operator override in the supply policy YAML.

  • circulating_supply = total_supply − Σ locked_set. Default

locked set: just the issuer's own balance. Operator may extend via YAML to include known reserve / treasury multisigs and vesting contracts. LP-reserve balances are NOT excluded — the underlying asset is still circulating; LP-token holders own it pro-rata.

Algorithm 3 — SEP-41 Soroban tokens

  • total_supply = `Σ mint.amount − Σ burn.amount − Σ

clawback.amount over the contract's lifetime, per SEP-41 semantics. Indexed off the contract's events; running per-token total in asset_supply_history`.

  • max_supply — no canonical on-chain source. Sources, in order:

1. SEP-1 [[CURRENCIES]].max_supply from the token's stellar.toml. 2. Operator override. 3. null.

  • circulating_supply = total_supply − Σ locked_set. Default

locked set: the token's admin account / contract balance (when an admin exists). Operator extends per-token.

SAC-wrapped classics — both algorithms must agree

A SAC-wrapped classic asset (e.g. CAS3…OWMA for native XLM, or the SAC contract address for USDC:GA5Z…) is simultaneously a classic asset (Algorithm 2) and emits SEP-41 events (Algorithm 3). We compute both. Cross-check: alert when they disagree by more than 1 stroop.

API + schema

  • All supply fields are strings on the wire (i128 safety per

ADR-0003).

  • supply_basis field on the response identifies which policy

produced the numbers ("xlm_sdf_reserve_exclusion", "issuer_exclusion", "admin_exclusion", "override", "no_metadata").

  • null for any field where we don't have a defensible value;

document the convention as "we don't fabricate."

  • Hypertable shape:

``sql CREATE TABLE asset_supply_history ( time TIMESTAMPTZ NOT NULL, asset_key TEXT NOT NULL, -- "XLM" | "CODE:G…" | "C…" total_supply NUMERIC NOT NULL, circulating_supply NUMERIC NOT NULL, max_supply NUMERIC, -- NULL when uncapped basis TEXT NOT NULL, ledger_sequence BIGINT NOT NULL ); SELECT create_hypertable('asset_supply_history', 'time'); CREATE UNIQUE INDEX ON asset_supply_history (asset_key, ledger_sequence); ` Append-only; latest row per asset_key` is the queryable current state. Time-bucketed for historical queries.

Consequences

  • Positive — covers F2.4 (Freighter V2 market-cap fields) end-

to-end without inventing a new ingest path. Every domain-specific data source we need is already captured per the discovery audit (Galexie ledger entries for classic, SEP-41 events for Soroban, configured constants for XLM).

  • Positive — the no-fabrication policy makes degradation honest.

When we don't have a defensible max_supply (uncapped issuer + no stellar.toml + no operator override), we publish null rather than guess. Consumers handle null explicitly.

  • **Positive — operator-configurable locked-set lets each

deployment match its compliance posture.** A deployment focused on Freighter end users may include only the issuer-balance exclusion; a deployment serving institutional customers may exclude treasury multisigs + vesting contracts per the asset's formal disclosure. Same code path; just YAML.

  • **Negative — three algorithms means three test surfaces and

three bug classes.** Partially mitigated by the SAC-wrapped cross-check: when the same asset is observable both ways, the two observations must satisfy a pair of INEQUALITIES. A breach triggers an alert.

Amendment (2026-07-26, C6-056 — the original text said "the sums must match within 1 stroop", which the implementation has never done and cannot do). A partially-wrapped asset legitimately has classic_total > sac_total: the classic ledger-entry sum counts every holder, while the SAC event-derived total counts only what was wrapped. Equality would false-alarm on every normal asset. What internal/supply.CrossCheckSubsetBound actually asserts is two one-directional bounds:

1. over-mintsac.TotalSupply ≤ classic.TotalSupply; 2. escrow-exceeds-mintedclassic.SACWrappedStroops ≤ sac.TotalSupply (every escrowed unit got there by a mint).

DivergenceStroops = max(over_mint_excess, escrow_excess), so the single existing gauge and alert fire on either breach.

Consequences of it being a bound rather than a reconciliation — each is a real blind spot, not a hypothetical:

- Only the OVER-report direction is detected. An over-reported SAC total (a mint the indexer double-counted, a burn it missed) breaches leg 1; an UNDER-counted SACWrappedStroops sits *below* sac_total and passes leg 2 silently — the documented BLND/EURC/KALE/PHO dormant-pool-balance case in WrapClassPartial's KNOWN LIMITATION, cured by supply seed-sac-balances -full-history. Leg 2 is an upper bound on escrow, not proof that escrow was fully observed. - The non-SAC half of Algorithm 2 is unchecked. Trustline / claimable-balance / LP-reserve balances have no independent second observation, so an undercount there merely widens the benign classic > sac gap and is invisible to this check. - Leg 2 is CS-087-gated. When classic.SACWrappedStroops is nil the leg is not evaluated and SubsetBoundChecked stays false; it is never defaulted to zero, because 0 ≤ sac_total holds vacuously and a zero default would publish a green check that verified nothing. A caller reading `WithinTolerance` MUST read `SubsetBoundChecked` alongside it.

  • Negative — the locked-set YAML is operationally fiddly. Every

asset-of-interest needs a curated entry to get a meaningful circulating-supply. Without curation, we default to issuer-only exclusion and document the policy in the API response so consumers know not to trust the absolute number.

  • **Operational impact — adds asset_supply_history hypertable +

per-source supply-update emitters.** Storage is small (a few thousand assets × a few writes/day = MB-scale). The ingest hot path is unchanged; supply derivation is a downstream consumer of the trustline / events streams.

  • **Downstream design impact — market-cap / FDV / supply-pct fields

in the API depend on this hypertable.** Aggregation policy (combining supply with VWAP price) is straightforward but documented in aggregation-plan.md once this lands.

Alternatives considered

  1. Single unified algorithm — rejected. The three domains have

incompatible truth sources (constant vs ledger entries vs events); a unified path would need to special-case each anyway, so make the structure explicit.

  1. **Trust upstream aggregators (CoinGecko / CMC) for circulating

supply** — rejected. We're being graded on independence per the spec; importing a third-party number is what aggregators are for, and we're explicitly NOT one. Plus the third parties' policies for "locked" are opaque and inconsistent across assets.

  1. Hard-code the locked-set per asset (no YAML) — rejected.

Treasury multisigs + vesting contracts move; a code change per update is too brittle for production. YAML in the deployment repo is the right grain.

  1. Don't publish `max_supply` at all — rejected. The spec requires

it for FDV; consumers have to display "unknown" somehow, and null is a clearer signal than "0" or omitting the field.

  1. Compute max_supply from on-chain auth flags (e.g.

auth_immutable + auth_revocable + known burn-signer patterns) — considered as an enhancement but rejected for v1 because the heuristic is brittle and produces false positives. Operator override + SEP-1 declaration are sufficient signals; automatic derivation is a v2 feature gated on a discovery PR that audits the heuristic across all classic issuers on pubnet.

References

— the supply pipeline this ADR's policy drives.

§F2.4 — the requirement this ADR closes.

every amount in this ADR.

fiat asset representation (out of scope here; off-chain currencies don't have a "supply" we publish).

  • SEP-1 §[[CURRENCIES]] — the max_supply declaration we honour

for the self-declared overlay.

  • SEP-41 — the Soroban token-contract spec defining

mint/burn/clawback event semantics.