Skip to content

feat(0374): soroban AMM pools — aquarius end to end (Refs #405) - #438

Closed
karolko9 wants to merge 78 commits into
developfrom
feat/0374_lp-native-leg-and-soroban-amm-completeness
Closed

karolko9 wants to merge 78 commits into
developfrom
feat/0374_lp-native-leg-and-soroban-amm-completeness

Conversation

@karolko9

Copy link
Copy Markdown
Collaborator

Summary

  • Index router-family (Aquarius) AMM pools end to end: shape-driven add_pool discovery into the liquidity_pools union (pool_kind = 1), verified on the full mainnet population (497/497 registrations decode, 0 false positives on an all-signatures negative corpus). Refs improvement suggestion: add other protocols to the explorer #405
  • Reserve history from ledger STATE at the chain's grain — new pool_state_changes table fed by two on-chain layouts (plane PoolData for fungible, instance Reserve0/1 for concentrated) plus the pool_share_tokens side table from the instance TokenShare key; verified by raw-ledger corpora, a staging e2e and a bidirectional anti-test against update_reserves (0 missing, values 17/17)
  • Read path serves both worlds: legs[] / pool_kind / protocol (verified-operator label only) / pool_type on list+detail, filter[pool_kind], C-strkey pool ids, soroban participants from share-token balances, explicit 400s for the classic-only chart/activity feeds instead of confidently empty series; frontend renders 2–4 legs through one leg-view model
  • Audit closures with measurements: the XLM filter matches all 11,734 native-leg pools (verified on prod), and share_percentage is confirmed correct (denominator chain-exact via raw getLedgerEntries; the holder-coverage gap is filed separately as task 0523)
  • ADR 0058 + database-schema / indexing-pipeline / xdr-parsing overviews updated per ADR 0032; OpenAPI types regenerated

The map is regenerated per session and is not shared state, so it does
not belong in history.
Reserves are read from the pool-plane ContractData entry rather than
reconstructed from event arithmetic; the arithmetic route failed its
oracle test (6 of 49 cases exact) and is kept only as evidence.

Refs #405
…type

Two of three adversarial challenges were refuted by re-measuring: the
router registries have zero orphan pools, and the concentrated share is
an adoption curve rather than a sampling artefact. The router counts were
low and are corrected.

Also corrects the deposit_liquidity data vector, recorded here backwards:
shares is element 0, not the last element. A parser reading it from the
wrong end would fail the share-token derivation everywhere.

Refs #405
…_update

List returns positions, header counts holders; constant pools are the
degenerate one-position case. Open positions only; raw L until the
token-amount conversion passes an on-chain check.

Refs #405
One decoder, no stitch at 57573730. update_reserves demoted to a
monitored cross-check; volume stays event-sourced. Grounded in the gap
pilot: 80/80 router-A pools had PoolData in their first trade ledger,
and router B writes to its own plane, so discovery keys on the entry
shape, never a hard-coded address.

Refs #405
…overy

Discovery keys on the event shape, never on a contract address: ten
contracts emit add_pool and the vendor documents one, so an address list
silently drops about 6% of live pools. Measured on all history, 497 of 497
events match the decoder's structural checks.

Anchored on the vendor's own emitting code, with the limits of that anchor
recorded: their repository is now unreachable, so the snippet comes from an
archived capture, and the deployed contract's spec — which carries no event
definitions at all — corroborates only the parts it covers.

The third data field is the router's pool lookup key, not a WASM hash;
naming it after a hash would have invited a join that matches the wrong
contract. Pool type keeps both a normalised and a raw spelling because
three vocabularies for it exist at once, and an uncatalogued shape yields
None rather than a plausible wrong value.

Refs #405
… real data

An adversarial review and four production measurements found the decoder
asserting things the chain contradicts.

init_args was parsed into a numeric list, which flattened three different
vocabularies and, worse, dropped any element it could not parse and shifted
the rest left - turning a three-argument stable pool into the exact shape of
a two-argument concentrated one. Kept as raw text now: position 1 is a tick
spacing in one pool type and an amplification factor in another, and u128
does not fit i64.

The salt was documented as identifying a pool. It does not: 497 registrations
carry 81 distinct salts, and 47 slots were registered more than once - one of
them seven times, each naming a different pool contract, because a slot is
re-pointed when a pool is redeployed.

Legs were documented as two or three. Four-leg pools exist.

Option collapsed 'this is another event' with 'this is a registration I could
not read', so the counting the docs asked for was unwritable. Now a typed
rejection reason.

Two corpus tests replace assertion with measurement. Every one of the 497
registrations decodes through the compiled decoder, not through a query
imitating it; and a cross-section of 307 real events over 30 signatures -
including the family's own trade, update_reserves and pool_state - yields not
one false positive.

Refs #405
Nine columns with DEFAULTs turn liquidity_pools into the registry for
every pool; classic rows read unchanged as pool_kind=0. The pair-shaped
asset_a_*/asset_b_* columns are marked legacy in place - 3- and 4-leg
stable pools exist and do not fit a pair - and stay until the ~612
pair-shaped call sites migrate to legs.

Refs #405
…n prod

Depth-first cut: the earlier wide mirror never reached the database; the
ALTER that did carries exactly pool_kind, legs, deployment_id,
pool_type_raw, share_token_id. Registration provenance (salt, raw
init_args) is not materialised - the add_pool event sits complete in
soroban_events; extract on demand, never copy.

Refs #405
Discovery follows the house detector idiom: the semantic decode lives in
xdr_parser::pool_router::detect_pool_registrations (shaped like
detect_nft_events), and the liquidity_pools staging section only maps
registrations to rows - the event serialization loop stays free of
interpretation. An unreadable registration is warned about with its
reject reason, never dropped silently.

A registration records only what the event itself says: share_token_id
stays 0 (the share token is unknowable until the first deposit, T6) and
no venue label is stored anywhere - labels resolve from deployment_id at
read time. pool_id carries the raw 32-byte contract payload (reversible
to the C... address), while legs and deployment_id carry the Int64
surrogates that join the rest of the schema.

Adds stellar-strkey to db-clickhouse for the payload decode.

Refs #405
An unparseable init_args[0] used to become a plausible fee of 0 - the
misleading-fallback class. Every mainnet shape carries a u32 there
(497/497, pinned by the corpus test), so a failure is a vocabulary
nobody has seen: the registration is now refused with tracing::error
and a named reason, the raw event still lands, and the ledger does not
fail. Karol's call: error, not warn-and-default.

Also ships the one-off backfill generator (same ids/decoder as the live
writer, so backfilled and live rows are identical by construction) and
records in the task file why filling share_token_id later must go
through a side table rather than a partial RMT row.

Refs #405
A single-use tool is not a maintained surface. The final-phase catch-up
resurrects it verbatim from history (pointer recorded in the task file),
so nothing is lost and nothing rots in src/bin meanwhile.

Refs #405
One id-IN resolver in the 0344/0345 canon shape: sorted+deduped id list
formatted into the query, every dimension subquery bounded by it, and the
soroban_contracts hop picked with LIMIT 1 BY id - the strkey is immutable
across RMT versions. Two arms by construction: a leg is always a token
contract, so a classic asset appears only through its deployed SAC
(asset_sac facet, 96% of legs) and a bespoke token through its own
type-3 row. 1005/1005 leg occurrences resolve on production.

Decimals are the protocol constant 7 for the classic family and on-chain
metadata for bespoke tokens - an unpublished scale stays None and renders
as an explicit marker, never a guessed default (an 18-decimal leg with
late metadata is live in a stable pool). Bespoke legs also carry their
SEP-41 symbol/name, resolved exactly as the assets page does.

Also corrects a lying comment: legs hold token-contract surrogates, which
equal assets.id only for bespoke tokens - the doc claimed assets.id in
general while 96% of legs are SACs whose classic asset has a different id.

Refs #405
… 15)

The relation lives in a SIDE table (pool_share_tokens), the asset_sac
pattern: the deposit path knows only (pool, token), and a partial row in
the whole-row-replace registry would wipe legs, type and deployment on
merge. Versioned by sighting ledger, so the 13 measured share-token
migrations converge on the newest - matching share_id() on chain.

The detector implements the T6 rule and is proven the way add_pool was:
85,586 real deposit transactions through the COMPILED code, 16/16 exact
against on-chain share_id(), and 394 resolved pools reconciled
independently via chq as precisely the fungible-pools-with-deposits count
(328 constant + 63 stable + 3 elastic; 37 concentrated yield nothing by
construction; 66 deposit-less pools are unreachable by events at all).

Anchored at source where a source exists: the vendor's own capture spells
the deposit body [stake_amount, amounts...] - shares first. Task notes
record the fundamental correction this commit anticipates: the chain
stores TokenShare as instance STATE in the registration tx, so step 7
swaps the primary source to state and this detector becomes the
monitored cross-check (update_reserves' fate in T4).

Refs #405
The ContractInstance arm serialized only the executable and silently
dropped the instance's storage map. Nothing read that section before;
pool state does — TokenShare (the share-token relation's primary
source), Reserve0/1 (the ONLY reserve source for concentrated pools)
and the Plane/Router shape gate all live in instance storage. Same
defect class that hid contract metadata (chain-refuted "off-ledger"
verdict in 0156/0283/0297).
Two on-chain layouts feed one reserve source (T4): fungible pools
write PoolData[pool] on the deployment's shared plane (vector kept
VERBATIM — per-tick tails exist, readers slice by leg count);
concentrated pools write Reserve0/Reserve1 on their own instance,
where TokenShare (share-token relation) and the Plane/Router shape
gate also live. Extraction mirrors the token-balance extractors:
state images from created/updated/restored changes only.

Fixtures are verbatim house-dialect JSON from mainnet ledgers
63,893,403 and 64,134,576.
Env-gated like the other prod-harvest corpora (skip cleanly in CI).
pool_state_real_ledger runs one raw registration ledger end to end;
pool_state_real_corpus sweeps 90 raw ledgers (registration eras, hot
event eras, pre-family dead era) through the COMPILED extractors with
the invariant: PoolData-shaped-but-unparseable count is zero — a
vendor payload change surfaces here as a failure, not as pools
quietly losing state.
New fact table at the chain's grain (pool, ledger, tx, change_index)
with reserves Array(Int128) VERBATIM. The full key matters:
(pool, ledger) alone collapses 23.5% of rows (12 writes/ledger
measured). Named without a family prefix on purpose — it is the
target state-fact shape; classic history joins INTO it if the
snapshot models ever unify (ADR 0058).

Staging arms: plane PoolData rows for fungible pools; instance rows
for concentrated (their reserves ride Reserve0/1 on the instance —
plane only at registration; anti-test discovery). The instance's
TokenShare key becomes the PRIMARY pool_share_tokens writer; the
event-rule detector arm is removed from staging and survives only as
a cross-check. Unparseable reserves refuse the row loudly, never a
plausible default.

persist_ledger_clickhouse grows two inputs — the indexer call sites
and both e2e drivers ride along (workspace-atomic signature change).
Env-gated e2e: the raw registration ledger (63,893,403) decompressed,
extracted and staged through prepare_with_sac_overrides, asserting the
exact pool_state_changes and pool_share_tokens rows — values,
change_index and plane_id to the digit. Covers all three transaction
meta eras (V0/V1/V2 — the batch era is CAP-67 V2, which the first cut
missed and read zero rows).
A soroban pool's 32 id bytes are a CONTRACT address payload — an
L-strkey render of them would be well-formed and WRONG, so the API
never mints one and must accept the C-form on pool paths and in the
list's free-text box (pasting a pool's own id has to find it, task
0470's lesson). Adds contract_hex_to_strkey for the response side
(consumed by the next commit — transient dead-code warning until
then).
List and detail publish pool_kind / protocol / pool_type / legs[];
the pair fields go null on soroban rows (their storage defaults would
render every pool as native/native). Legs resolve through the
asset_sac facet or the bespoke-token assets row — an unresolvable leg
surfaces as family "unresolved", never a plausible empty asset.
Protocol labels are attribution resolved at read time from the
registering deployment against a verified-operator list; the second
live router (same WASM, disjoint admin roles) stays unlabelled.

Values with the wrong population's truth go ABSENT, not zero:
participant_count is null on soroban rows. Participants branch to
share-token holders in balances (asset-direction full scan measured
121.5M rows / 71 ms; skip index on asset_id is the upgrade path);
shares display scaled by the token's on-chain decimals or null when
unpublished — the cursor rides the raw value. Classic-only feeds
(USD chart, lp_operation_amounts activity) refuse soroban pools with
an explanatory 400 instead of a confidently empty series — the
activity surrogates would otherwise hash to the NATIVE asset and
answer with unrelated XLM traffic. pool_exists retired: every gate
needs the kind now.

OpenAPI + generated types ride along (CI gate: API types freshness).
One poolLegViews expands a classic pair or a soroban legs[] into the
same render shape, so labelling / linking / scaling rules live in one
place. Soroban raw reserves scale by the leg's on-chain decimals with
exact string surgery (18-decimal tokens are precisely what a double
would corrupt); an unknown scale renders as absent, never a raw
integer posing as scaled. An unresolved leg keeps an explicit '?'.
Every pool surface consumes poolLegViews, so soroban rows render
their 2-4 legs (avatars, reserve dots, KPI cells, summary rows chunk
two per row) next to classic pairs. Protocol chip appears only for a
verified operator. Detail accepts C-strkey ids and does not mount the
classic-only sections (USD chart, activity) for soroban pools — the
API refuses them explicitly, so no doomed queries. Nullable fields
render as em-dash, never as zero (participant_count, unknown-scale
shares, missing first_deposit_ledger).
ADR 0058 records the durable decisions: shape-driven discovery with
labels as read-time attribution; one pool dimension, two id worlds;
reserves as ledger state at the chain's grain (pool_state_changes as
the target state-fact shape); the share-token relation as a side
table; explicit API refusals over misleading zeros. The three
architecture overviews (database-schema, indexing-pipeline,
xdr-parsing) gain the matching sections per ADR 0032.
…raming, steps 16-20

Deep-testing record (90-ledger corpus, staging e2e, bidirectional
anti-test with the concentrated-instance discovery), the snapshot
one-vs-two-tables investigation corrected by a devil's-advocate pass
(two fact tables greenfield: state + trades; no in-band sentinels;
parallel model not 'legacy'), the pool_state_changes rename
rationale, and the read-path/frontend/docs completion state.
filter[pool_kind] = classic | soroban narrows the union list to one
world; omitted keeps both. Validated in the handler so a bad value
gets the envelope with the allowed list (the filter[event] shape);
the SQL inlines the validated 0/1 discriminant. Regenerated OpenAPI
types ride along (CI gate: API types freshness).
URL key 'kind' through the existing cursor-pagination filter
mechanism (cursor resets on change); empty value = both worlds.
The step-7 StageInputs growth missed backfill-runner (outside that
commit's test sweep): sink.rs and the pilot example no longer built.
Wired to the ParsedLedger fields rather than stubbed empty, so the
historical S3 re-parse emits pool_state_changes / pool_share_tokens
for free — the same shared-path property token balances already ride.
The pass the 0463 balances seed deliberately deferred. K4-6 measured
and chain-validated the gap: holders whose pool-share trustline
predates the ingest floor have no lp_positions row (2,681 pools know
<50% of their shares' owners, 1,164 live), and pre-floor pools with
no post-floor entry change have no liquidity_pools row at all.

Seeds missing positions (versioned on each entry's OWN
lastModifiedLedgerSeq — the 0492 rule; first_deposit_ledger = 0 is
the documented predates-our-history sentinel), self-heals newer
differing pairs while restating their real first deposit (whole-row
RMT), stubs missing pools + one snapshot row from the entry itself,
and stubs missing holder accounts. Ghosts are REPORTED to ghosts.tsv,
never corrected — the balances seed earned zeroing rights through a
100/100 RPC probe; this pass has no such evidence yet.

The summary carries a protocol-identity decode check:
sum(pool_shares_trust_line_count) over live pools must equal the
decoded live pool-share count; mismatch = records dropped, do not
--execute. Decoder extended with LiquidityPoolEntry (NetPool,
first-wins), unit-tested on a constructed XDR entry.
Both per-pool feeds now branch per kind behind one URL instead of
refusing soroban pools with a 400. Activity reads the pool's own
trade/deposit_liquidity/withdraw_liquidity events out of soroban_events
(per-leg signed raw amounts in leg_amounts; the actor is the tx source —
the trader topic names the router on routed trades). The chart folds
pool_state_changes reserves + trade events against the same prices
series the classic chart joins (bespoke tokens via asset_kind =
'contract'); inputs are pre-aggregated in ClickHouse and the output is
sparse calendar buckets on the classic grain, so both arms share one
wire contract. An unpriceable trade nulls its bucket, never a partial
sum.

Shared mechanisms extracted rather than copied: one tx-enrichment seek
(fetch_activity_txs), one interval-to-prices-view mapping (the soroban
path had named a daily view that only existed in a local stub — prod's
is prices.price_usd_series), the tuple keyset idiom, PriceLeg reuse via
ChartPriceId, and a PoolFeed enum in place of the Option-pair dispatch.
application_order is nullable on the wire now: soroban rows carry no
per-op anchor, and the 0 sentinel built dangling #op-0 links and
collided row keys. Unit tests for the module files move to sibling
*_tests.rs files (the mod declarations tie them to this commit).

Refs #405
Drop the detail page's soroban unmount gates: charts and activity mount
for both kinds now that the API serves them. poolAmountLegs collapses
to one arm — rows normalize to (leg index, signed raw amount) and the
unified PoolLegView (now carrying decimals; classic legs are stroops,
7) supplies label, link, icon and scale for both worlds, so the pair
fields and leg_amounts render through the same tail. tradeRate divides
scaled values (raw units of legs with different decimals are not
comparable), the local scaleRawAmount duplicate gives way to the
library's validated scaleByDecimals, and rows without an
application_order link plain /transactions/<hash> and key by page
index.

Refs #405
The real-data spec's soroban detail test flips from asserting the
sections are absent to asserting the chart metric tabs render and the
activity table shows a Trade row with the full swap phrase (via the
amount stack's aria-label — the visible text interleaves linked codes).

Refs #405
ADR 0058 §5 rewritten — the chart/activity refusals are replaced by the
per-kind branches; the one refusal left is participants without a known
share token. Task 0374 gains the build record and the simplify-pass
record, including the prod-breaking stub-only prices view name the
cross-check caught and the local-rig notes (vite base URL, stub daily
view name).
…n crate

The deposit-mint correlation rule (detect_share_tokens + its shape tests)
is a verification oracle, not the live share-token source — TokenShare in
pool instance storage is. It now lives with its 85k-transaction corpus in
tests/share_token_real_corpus.rs, so nothing verification-only ships in
the production module (pool_router.rs 802 -> 543 lines); a doc pointer
remains at the old site.
…nit_args

PlanePoolData decoded pool_type_raw + init_args, but staging consumes
only pool/plane/reserves — the two fields were decoded and dropped on
every plane write. The LIVE source of the pool type and fee is the
add_pool registration event; the plane's copy fed nothing. Tests updated
to the slimmer shape (the 5-init-arg stable case still pins that extra
map keys cannot confuse the decode).
…idence rule

The verified-operator mapping moves out of queries.rs into
protocol_labels.rs — one place for the dictionary, the rule that earns
an entry (operator verified at the vendor's own publications; code
identity is not enough), the one-sided failure direction (incomplete is
allowed, wrong is not — unlisted deployments render no chip), and the
per-entry evidence links (aqua.network docs publish the router address;
re-checked live 2026-08-31, archived captures in lore research 0003/0008).
PoolSummary chunked reserve cells into pairs itself because SummaryRow's
contract is 'one row = up to two cells'. The pairing now lives beside
that contract as a SummaryRows sibling (flat cell list laid out
two-per-row), so any caller with a variable number of cells — a 3/4-leg
soroban pool's reserves — reuses it instead of re-implementing the
chunking. Rendering unchanged (verified on the 3-leg demo pool).
plane_id stays as provenance insurance (a removal was built and reverted
same day on the owner's correction — recorded so the exercise is not
repeated); the reserve-cell pairing moved into SummaryRows; duplicate
leg codes stay label-ambiguous with the disambiguation options on file.
The 0374 sequence lived only in an init.sql comment, while CLAUDE.md names
deployment.md and backfills.md as the mandatory pre-deploy and pre-backfill
reading. Deploying the indexer against an un-migrated schema is an ingest
outage, not a degradation: the clickhouse-rs client refuses an insert when a
table is missing or still carries a no-DEFAULT column the row struct dropped,
and the soroban arm runs unconditionally. That is the 0310 outage class, and
production is mid-migration today, so the window is open.

deployment.md gains the DDL-before-indexer gotcha with the exact statements;
backfills.md gains the three catch-up passes and the mandatory window-closure
check that proves no registration slipped between the backfill and the live
writer.
ADR 0032 requires the architecture docs to move with any change to the shape
of the system, and this branch changed it: the share-token side table now also
carries the plane a pool declares for itself, and reserve provenance became a
read-time predicate rather than a stored column nobody consulted.

Three overviews updated (database-schema, indexing-pipeline, xdr-parsing), and
ADR 0058 decision 4 amended with why one table carries both facts instead of a
second side table beside it: they come from one authenticated source, read in
one pass, on one version clock.

Two claims that had already gone stale are corrected while here: the reserve
grain was described as (pool, ledger, tx, change_index) after the 2026-08-30
collapse to (pool, ledger), and the deposit-mint share-token rule was described
as production code after it moved to tests. The parser overview also now states
the asymmetry the storage contract rests on — an instance keys on its owner, so
a pool can only ever describe itself, while a plane entry names its pool in a
payload.
…ainer

Soroban emits a diagnostic copy of a transaction's events even when the
transaction FAILED, so the diagnostic container is the failed-tx exclusion for
the event path — there is no separate success flag on events. Every sibling
detector filters it, and the module right beside this one documents why, but
`detect_pool_registrations` did not: a transaction that merely ATTEMPTED to
register a pool was indexed as a real registration.

One guard, matching the four siblings. The regression test supplies a fully
corroborated registration so the event source is the only thing keeping it out,
and it fails without the guard.
…tance

An add_pool event names its pool in the DATA payload, which the emitter
chooses freely, and discovery is shape-driven by design — emitting the event
IS what makes a contract a router. Nothing checked that the named pool agreed.
Since liquidity_pools is a ReplacingMergeTree keyed on pool_id and versioned by
ledger, a contract could name a REAL pool at a later ledger and replace its
registry row wholesale: protocol label stripped, legs, fee and type replaced.
Inventing unlimited pools was the milder half.

The corroborating fact was already in hand and thrown away. A pool's instance
storage records its own Router, and the pool contract is the ledger-authenticated
owner of that entry, so a third party cannot write it. The instance is written in
the SAME transaction as add_pool, so it is always in the same parse output. A
registration now becomes a row only when the named pool declares that emitter.

The refusal is a warn, not an error: unlike a malformed payload it is not
evidence of a pool going missing.

Also fixes a test that would have started passing for the wrong reason —
prepare_refuses_a_registration_with_an_unparseable_fee no longer reached the fee
check at all, since the new guard refuses it earlier. It now supplies a
corroborating instance, as mainnet does.
Two arms feed pool_state_change_rows — the plane arm for fungible pools and
the instance arm for concentrated ones — and they collide on a pool's
registration ledger. The parser already folds each SOURCE independently, but
neither fold can see the other, so both rows survived into one insert.

pool_state_changes is a version-less ReplacingMergeTree, and per backfills.md
rule 4 that is safe for re-parsing (a later parse lands last and wins) while
the real hazard is exactly this: two rows for one key inside a single insert,
where the survivor is arbitrary. That is the defect class task 0463 measured
on balances, at 1.24M keys.

Folding at stage time is what the classic twin already does
(dedup_final_pool_snapshots, lore 0356): it makes the stored row a
deterministic function of the ledger. Deliberately NOT a version column — any
version keyed on something batch-local would let a narrow re-parse stamp a
lower value than the original wide parse and lose to the stale row, inverting
the guarantee every backfill depends on.

The regression test emits both arms for one (pool, ledger) and fails without
the fold, with two rows where one is expected.
…ying about soroban pools

Closes the review of PR #438. Four defects share one root and are fixed
together, because the fix spans schema, writer and reader.

IDENTITY AND PROVENANCE. A plane entry names its pool in a KEY payload the
writing contract chooses freely, so any contract can publish reserves under a
victim pool's id — and argMax by ledger then serves them, deterministically,
since a later ledger always wins. The authority already existed and was thrown
away: a pool's own instance storage declares its Plane, and the pool contract
is the ledger-authenticated owner of that entry.

pool_share_tokens is therefore reshaped into pool_instance_state, carrying
plane_id beside share_token_id — one table, because both facts come from one
authenticated source, read in one pass, on one version clock. All three
reserve reads (KPI, chart, list activity) now keep only rows whose plane_id
matches what the pool declares. Free to do: nothing has deployed.

XLM/XLM POISONING. Soroban registry rows carry the legacy pair columns at
defaults, and asset_a_type = 0 reads as native there, so every soroban pool
matched an XLM filter — 497 false positives against 754 real ones, measured on
production. The shared predicate is gated to pool_kind = 0, which is its actual
domain: it reads only the legacy pair columns, which only classic rows fill.
Global search becomes kind-aware too, and stops minting a well-formed WRONG
L-strkey for a contract address; a soroban pool is now named from its resolved
legs, the same identities the list and detail render.

FALSE UI STATE ON EVERY SOROBAN POOL. latest_snapshot_at is null for the whole
kind (soroban never writes the classic snapshot table), so staleness derived
from it captioned live reserves "no recent snapshot" — and that caption
replaced the asset link. isPoolStale is kind-aware and the caption keeps its
link. Participants for a pool with no share token rendered a generic "try
again" for a DELIBERATE 400 that can never succeed; it now explains itself.

LIST AND CHART. The list ordered soroban pools by registration while classic
pools order by last trade, burying live Aquarius pools under ~26.5k classic
ones; it now orders by activity, itself provenance-gated. The chart opened on
"no activity" beside a full trades table because the guard caught all-null
TVL but not zero rows; it widens its own window until the user picks a range.

API types regenerated for the four endpoint descriptions that claimed L-only
ids while accepting and returning contract ids.
Records what the seven-agent review found and how each finding was fixed, the
sweep that bounded the root class to this branch's own code (the one
pre-existing relative lives in 0410), and the decisions a future session would
otherwise re-litigate: why pool_state_changes stays version-less, why the
pool_kind guard belongs in the predicate, and why a write_order column was
built and then removed.

Also records the facts verified outside our own code — Stellar's docs plus a
production measurement showing failed soroban transactions carry only fee
events — and the measured cardinality of routers, pools and share tokens,
including why 39 pools structurally have no share token.
karolko9 added a commit that referenced this pull request Sep 1, 2026
The end state — `legs` as the only leg source in both worlds, the six legacy
pair columns dropped — was decided inside 0374 while the soroban work was in
flight, with four ordered steps and their verifiers. It never got a task, so
the schema's "do not add new readers" rule had no owner.

The review of PR #438 showed the cost of the columns surviving: a soroban
registry row writes asset_a_type = 0, which every classic reader takes to mean
native XLM, so the shared asset-code predicate rendered all 497 soroban pools
as XLM/XLM on production. That was fixed with a pool_kind = 0 guard, which is
correct as an interim and is meant to be deleted by step 3 of this task rather
than preserved.
Read from chain 2026-09-01, one pool per deployment: five of the ten live
deployments run an OLDER contract whose instance storage carries Plane,
TokenShare and reserves but no Router key at all — 23 real pools, exactly the
five dead deployments this task already counted.

Two things had generalised a measurement taken on live creations into a rule
about the whole population, and both were wrong:

parse_pool_instance required BOTH Router and Plane, so those pools decoded to
nothing — no pool_instance_state row, no plane_id, and therefore no reserves at
all once reads filter on provenance. That is a regression the provenance work
introduced. Plane alone now decides, which is safe because this decode is keyed
on the entry's OWNER: a foreign contract writing a Plane key describes only
itself, and nothing reads an instance row for a pool that never reached the
registry.

The corroboration guard then refused their registrations. It now ACCEPTS them
with a distinct warn, because a missing key is an older contract version, not a
forgery, and dropping a real pool is the failure the reject taxonomy exists to
prevent. The residual is stated rather than hidden, in the code and in the ADR:
the registry row of a pool that declares no router is forgeable. Every such
pool measured is dead, with no flow event ever.

No source settles the question, which is itself the finding: the protocol docs
say nothing about trusting entry or event contents, the vendor's docs describe
the roles without promising the key, and the vendor's source is unreachable
(404, re-checked). The chain is the only authority available, so the code says
what was measured and when.

Regression tests pin both shapes — an older pool decodes and registers, an
instance with no Plane is still not a pool.
The function maps every leg and joins them, so a 3- or 4-leg pool comes back
as "XLM / AQUA / USDx" — which the test already pinned. Only the name still
said pair, and that is precisely the assumption this model exists to remove:
491 pools on mainnet carry two legs, 7 carry three and 2 carry four.

Renamed to poolLegsLabel, matching poolLegViews, which it is built on. The
local binding in the detail header was also called pair and is now legs.
Mechanical across three consumers; the doc comment carries the measurement so
the next reader does not have to re-derive why the old name was wrong.
isPoolStale measured pool IDLENESS and presented it as data unreliability. A
classic pool writes a snapshot on every mutating change, so an old snapshot
means nothing has happened — the reserves beside it are exactly current. The
caption said the opposite, and it fired on the majority: 60% of classic pools
(31,420 of 52,677) last changed over a week ago, the median 35 days.

The function had grown a kind branch to stop it lying about soroban pools,
which only moved the lie: soroban returned "not stale", which reads as
"fresh", where the truth is that snapshot freshness does not apply to a kind
whose reserves come from live ledger state. A boolean cannot carry
fresh/stale/not-applicable, and the two "unknown" cases were answered
oppositely depending on kind.

Removed rather than replaced. Nothing takes its place: the age would be a fact
worth showing, but it is not one this endpoint carries, and inventing a field
to justify keeping the cell is how the original judgement got here. Net −85
lines.

Also corrects the doc comment it rested on, which claimed stale pools come
back with null reserves — true of the Postgres path, false on ClickHouse,
where the module notes the freshness window is NOT applied to the detail pick.

Separately: the pool list ordered its page by activity_ledger and its cursor by
activity_ledger, then re-sorted the result by last_updated_ledger — the right
pools in the wrong order, and a cursor keyed on a column the display did not
follow. Introduced with the activity ordering; fixed here.
…urning null

first_deposit_ledger was null for every soroban participant, on the grounds
that `balances` records current state rather than a first sighting. That is
true of `balances` — it carries no such column — but it was never the only
source. The share token emits an event whenever a position begins, and we
store them: 76,508 mints and 2,031 transfers across the LP tokens on
production. The first mint or incoming transfer naming a holder IS the ledger
their position began.

Measured on the busiest share token: 655 of 655 current holders resolve, the
four contract holders included. Cheap by construction — `soroban_events` is
ordered on `contract_id` first, so this is a primary-key prefix seek over one
token's events, and it runs alongside the three lookups the path already
issues in parallel.

One trap, verified before relying on it: this family's `mint` carries
[sym, pool, to] — three topics, not the two a bare SEP-41 mint has. Reading
the recipient at the SEP-41 index returns the POOL and dates almost nobody (3
of 655 on the first attempt). Checked across every LP share token: 78,539
acquisition events, all three topics, the third always an address.

`shares` stays optional, now for a measured reason rather than an assumed
one: it is absent only when the token published no decimals, and all 483 LP
share tokens publish 7.

Alongside, three reductions from a fresh-eyes pass over the PR:

- `reserveDotColor` deleted — zero callers.
- `family_label` defers to `AssetFamily::as_str` instead of repeating the
  discriminant mapping, which is the drift task 0496 recorded. Only the
  "unresolved" fallback stays local, because it is not a family.
- The house typed-JSON READERS get a canonical home beside the encoder in
  `scval.rs`. `pool_state` and `pool_router` had each grown their own set in
  one PR — the same duplication `map_get` was consolidated to fix in 0393,
  recurring because only the encode side had a home.

The dedup folds keep their (pool, ledger) key. It never varies today, since
`ParseOutput` is built per ledger, but dropping it would turn dead weight
into a silent trap for a future batching caller; the invariant is now stated
in the docs instead.
Making it nullable was the PR conceding a gap it did not have. The coverage is
STRUCTURAL, not lucky: every LP share token was deployed after our event floor
— the family's first mint is L50,639,009 against a floor of L50,457,424 — so
the mint or transfer that gave a current holder their tokens is always an event
we hold. Measured 655/655 on the busiest token, the four contract holders
included.

So the field goes back to a plain i64 for every consumer, including the classic
ones that never lost the ability to provide it. A soroban holder we cannot date
is now treated as a defect rather than an absence: dropped with an error log,
the same contract this path already applies to a holder that resolves to no
account or contract. The frontend loses its em-dash branch with it.

`shares` deliberately stays nullable, and the reason is now recorded next to
it. Its coverage rests on a different kind of fact: not on our index, but on
the vendor's contract publishing `decimals`. All 483 LP share tokens on
production do, and all say 7 — but this same PR found five deployments running
an older pool contract that publishes no `Router` key at all. A measurement
over the instances we have met is not a guarantee about a version we have not.
Every soroban pool plotted a TVL curve on its chart and showed an empty TVL
figure above it. Both numbers came from the same reserves and the same price
views, computed a few hundred lines apart.

The cause was not a missing capability. `fetch_pool_usd_analytics` values a
pool from `leg_a`/`leg_b` — the CLASSIC pair — and `priceable_legs` reads
exactly those two, ignoring the `legs` the context already carries. A soroban
row holds storage defaults in the pair columns, so both legs filtered out as
unpriceable and TVL, volume and fee revenue came back null for all 500 of
them. The detail handler made it explicit, passing `legs: Vec::new()` under
the note that only the chart branch reads them.

TVL now sums over a soroban pool's 2-4 legs the way its chart sums each
point: reserve × last hourly close, each leg scaled by its own decimals,
SAC legs priced by classic identity and bespoke tokens under
`asset_kind = 'contract'` — the second identity shape needed the sibling of
`fetch_last_closes`, which did not exist because nothing had asked for it.
A leg that cannot be identified, scaled or priced nulls the whole figure,
never a partial sum over the legs that happened to resolve, which is the rule
the classic path and the chart already apply.

Two price queries for a whole page, not per row, matching how the classic
list prices its page; the detail is a page of one. Errors degrade the figure
to null rather than failing the page, as the classic analytics do.

Still absent for soroban, and deliberately: 24h volume and fee revenue read
`liquidity_pool_snapshots.gross_volume_a`, which soroban never writes. Their
source is the pool's own trade events, a different aggregation from this one.
The last gap of the same shape as the TVL one: a soroban pool's chart plotted
volume and fee revenue per bucket while its detail showed neither. The classic
path sums `liquidity_pool_snapshots.gross_volume_a`, a column soroban never
writes — so the figure was absent, not because the data is missing, but
because the only source wired up was the one that does not apply.

Soroban volume now comes from the pool's own `trade` events, the source its
chart already aggregates: sum the in-token amounts over 24h, price each hop by
its in-token's leg at that leg's last hourly close, scaled by that leg's own
decimals. Fee revenue follows from it through the same `fee_revenue_usd` the
classic branch uses.

The chart's rules come with it rather than being re-invented: a hop we cannot
price or parse nulls the whole figure instead of contributing a partial sum,
and the token→leg match goes by SURROGATE against the registry's own legs,
never through the contracts dimension. A pool with no trades in the window is
a genuine zero, not an unknown.

Detail only, matching the classic contract exactly — both read a per-pool
source that has no place in a list page. One query, on a primary-key prefix
(`soroban_events` is ordered on `contract_id`), reusing the legs and prices
already resolved for TVL.
karolko9 added a commit that referenced this pull request Sep 2, 2026
The task file's session record lived on the PR #438 branch while the code
moved to develop — the record follows the code. Brings over the full 0374
history: research, verification passes, review records, the split
decision, the created-gate + total_shares hardenings, the e2e re-run with
the production cross-check, and the read-half-last roadmap.
@karolko9 karolko9 closed this Sep 15, 2026
@karolko9
karolko9 deleted the feat/0374_lp-native-leg-and-soroban-amm-completeness branch September 15, 2026 15:17
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.

1 participant