diff --git a/adapters/aquarius/fetch.test.ts b/adapters/aquarius/fetch.test.ts new file mode 100644 index 0000000..d617660 --- /dev/null +++ b/adapters/aquarius/fetch.test.ts @@ -0,0 +1,294 @@ +// Tests for the Aquarius read-side decoders. +// +// WHAT THESE COVER, AND WHY THESE AND NOT OTHERS. Every case below is one live +// data cannot reach: all seven roles are present on every Aquarius contract +// today, no pool has ever reported an unknown `pool_type()`, and no contract has +// ever carried a pending upgrade. Those are exactly the branches whose +// behaviour is a rulebook decision — `methodology/dex.md` sends a short role map +// to the unsafe end, and claims nothing about an unrecognised pool type — so +// they are pinned here rather than left to be discovered the day Aquarius +// changes. +// +// The fetch itself is not tested: it is RPC and Horizon, covered by the frozen +// fixtures in adapters/fixtures/, which are real captured mainnet state. +// +// Run with: pnpm --filter @stenion/adapters test + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { xdr } from '@stellar/stellar-sdk'; + +import { + AQUARIUS_POOL_TYPES, + AQUARIUS_ROLES, + STELLAR_ASSET_EXECUTABLE, + asPoolType, + decodeRoles, + instanceKeyName, + parseAssetName, + unrecognisedPoolType, +} from './index.ts'; +import { aquariusConcentratedMainnet } from '../fixtures/aquarius/concentrated-mainnet.ts'; +import { aquariusConstantProductMainnet } from '../fixtures/aquarius/constant-product-mainnet.ts'; +import { aquariusStableMainnet } from '../fixtures/aquarius/stable-mainnet.ts'; +import { aquariusWasmTokenMainnet } from '../fixtures/aquarius/wasm-token-mainnet.ts'; + +describe('asPoolType — the three types the router can deploy', () => { + it('accepts exactly the three the chain reports', () => { + // `constant_product`, NOT `standard`. Aquarius's documentation and issues + // #100/#101 all say "standard"; the deployed contract returns + // `constant_product`, read live from a pool of each type. This assertion is + // the one that would fail if someone "corrected" the code to match the prose. + assert.deepEqual([...AQUARIUS_POOL_TYPES], ['constant_product', 'stable', 'concentrated']); + for (const t of AQUARIUS_POOL_TYPES) assert.equal(asPoolType(t), t); + }); + + it('rejects the documentation spelling rather than silently accepting it', () => { + assert.equal(asPoolType('standard'), null); + }); + + it('rejects anything else, claiming nothing', () => { + for (const value of [null, undefined, 42, '', 'CONSTANT_PRODUCT', {}, ['stable']]) { + assert.equal(asPoolType(value), null, `${JSON.stringify(value)} must not be a pool type`); + } + }); + + it('names the pool and the value it actually saw in the failure message', () => { + // The message is what the indexer records on a failed run, so it has to be + // enough to diagnose from alone — a bare "unrecognised pool" would send a + // reader back to the chain to find out which pool and what it said. + const message = unrecognisedPoolType('CPOOL', 'something_else'); + assert.match(message, /CPOOL/); + assert.match(message, /something_else/); + assert.match(message, /constant_product, stable, concentrated/); + }); +}); + +describe('decodeRoles — the role map, and what a short one means', () => { + const full = () => Object.fromEntries(AQUARIUS_ROLES.map((r) => [r, [`G${r.toUpperCase()}`]])); + + /** Drop roles from a map, to build the short-map cases live data cannot produce. */ + const omit = (map: Record, ...drop: string[]) => + Object.fromEntries(Object.entries(map).filter(([k]) => !drop.includes(k))); + + it('reads a full seven-role map', () => { + const decoded = decodeRoles(full()); + assert.equal(decoded.status, 'read'); + assert.equal(decoded.status === 'read' && decoded.roles.length, 7); + }); + + it('keeps a role holding SEVERAL addresses, rather than taking the first', () => { + // `get_privileged_addrs()` returns Map> — confirmed + // live. Every role holds exactly one address today, but the contract's own + // type permits more, and flattening to [0] would silently drop a co-holder + // the day one is added. That is a change in who controls the pool going + // unreported, which is the failure adminKeySafety exists to catch. + const decoded = decodeRoles({ ...full(), Admin: ['GONE', 'GTWO', 'GTHREE'] }); + assert.equal(decoded.status, 'read'); + const admin = decoded.status === 'read' && decoded.roles.find((r) => r.role === 'Admin'); + assert.deepEqual(admin && admin.addresses, ['GONE', 'GTWO', 'GTHREE']); + }); + + it('reports a SHORT map as short, naming exactly what is missing', () => { + // methodology/dex.md: a map missing a role is either an unexpected contract + // version or a role we cannot see, and grading the roles that DID come back + // would publish a posture assessment of an admin set we know is incomplete. + // Scoring sends this to 0; the fetch layer's job is to make it detectable + // and attributable, which means naming the missing roles rather than a count. + const short = omit(full(), 'PauseAdmin', 'RewardsAdmin'); + const decoded = decodeRoles(short); + assert.equal(decoded.status, 'short'); + assert.deepEqual(decoded.status === 'short' && [...decoded.missing].sort(), [ + 'PauseAdmin', + 'RewardsAdmin', + ]); + // The roles that DID come back are still carried — the reading is not + // discarded, it is labelled. + assert.equal(decoded.status === 'short' && decoded.roles.length, 5); + }); + + it('detects a short map even when the contract returns extra roles', () => { + // An eighth role does not compensate for a missing one. Counting keys + // against 7 would pass this; comparing against the expected SET catches it. + const decoded = decodeRoles({ ...omit(full(), 'Admin'), SomeNewRole: ['GNEW'] }); + assert.equal(decoded.status, 'short'); + assert.deepEqual(decoded.status === 'short' && decoded.missing, ['Admin']); + }); + + it('fails a map that is not a map at all', () => { + for (const value of [null, 'Admin', 42, ['Admin']]) { + const decoded = decodeRoles(value); + assert.equal(decoded.status, 'failed', `${JSON.stringify(value)} is not a role map`); + } + }); + + it('tolerates a bare address where a vec was expected, rather than dropping the role', () => { + // Losing a role silently is the one outcome worse than reporting an + // unexpected shape: it would turn into a "short map" verdict about the + // contract when the real problem was our decode. + const decoded = decodeRoles({ ...full(), Admin: 'GBARE' }); + assert.equal(decoded.status, 'read'); + const admin = decoded.status === 'read' && decoded.roles.find((r) => r.role === 'Admin'); + assert.deepEqual(admin && admin.addresses, ['GBARE']); + }); +}); + +describe('parseAssetName — SAC metadata, and the native counter-example', () => { + it('reads native XLM as a code with NO issuer', () => { + // THE ONE THIS FUNCTION EXISTS FOR. Native XLM is a SAC whose metadata name + // is the bare string `native`, not CODE:ISSUER — and it is the single most + // common token in the registry. "No issuer account" is a POSITIVE fact + // (nobody can freeze or claw back XLM), not an absent reading, so it must + // not come back null and get routed like an unparseable name. + assert.deepEqual(parseAssetName('native'), { code: 'XLM', issuer: null }); + }); + + it('splits an ordinary CODE:ISSUER name', () => { + assert.deepEqual( + parseAssetName('AQUA:GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA'), + { + code: 'AQUA', + issuer: 'GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA', + }, + ); + }); + + it('rejects a name whose issuer half is not an account', () => { + // A contract address after the colon is not an issuer, and treating it as + // one would send a Horizon lookup for an address that cannot have flags. + assert.equal(parseAssetName('WRAP:CCONTRACTADDRESS'), null); + assert.equal(parseAssetName('NOCOLON'), null); + assert.equal(parseAssetName(':GISSUER'), null); + }); +}); + +describe('the SAC discriminant', () => { + it('is the executable type, pinned as a literal', () => { + // The whole SAC/wasm split turns on this one comparison — 196 of 205 pool + // tokens take one branch and 9 take the other. A typo would route every SAC + // down the wasm disclosure path, publishing "the read does not apply" for + // tokens whose issuer flags are exactly what assetControlSafety grades. + assert.equal(STELLAR_ASSET_EXECUTABLE, 'contractExecutableStellarAsset'); + }); +}); + +describe('instanceKeyName — the decode whose bug hid a scored signal', () => { + // THE REGRESSION GUARD FOR A REAL DEFECT. This function previously kept an + // entry only `if (typeof name === 'string')`. Aquarius keys instance storage + // with vec-wrapped enum variants, so that filter discarded EVERY entry on + // EVERY contract — 31 on the router, 33-40 per pool — and made + // `UpgradeDeadline` look unreadable when it is sitting there at 0n. It cost + // half of `adminKeySafety`'s Gate 0 argument until it was caught. + // + // The failure mode is why this is pinned: an over-strict filter returns an + // EMPTY MAP rather than throwing, so it is indistinguishable from a contract + // that stores nothing. Nothing else in the adapter would have noticed. + + it('reads a vec-wrapped unit variant — the shape Aquarius actually uses', () => { + const key = xdr.ScVal.scvVec([xdr.ScVal.scvSymbol('UpgradeDeadline')]); + assert.equal(instanceKeyName(key), 'UpgradeDeadline'); + }); + + it('still reads a bare symbol key', () => { + assert.equal(instanceKeyName(xdr.ScVal.scvSymbol('Admin')), 'Admin'); + }); + + it('joins a compound key rather than dropping it', () => { + // `get_reserves()` reads ["Balance",
]. Collapsing it to "Balance" + // would make two reserves collide on one map entry. + const key = xdr.ScVal.scvVec([xdr.ScVal.scvSymbol('Balance'), xdr.ScVal.scvSymbol('CTOKEN')]); + assert.equal(instanceKeyName(key), 'Balance:CTOKEN'); + }); + + it('returns null only for a key with no string head', () => { + assert.equal(instanceKeyName(xdr.ScVal.scvU32(7)), null); + assert.equal(instanceKeyName(xdr.ScVal.scvVec([xdr.ScVal.scvU32(7)])), null); + }); +}); + +// --------------------------------------------------------------------------- + +describe('the frozen fixtures — the decode bug, guarded at the data level', () => { + // WHY THIS EXISTS SEPARATELY FROM THE UNIT TESTS ABOVE. `instanceKeyName` is + // pinned against synthetic ScVals, which proves the function is right but not + // that the adapter WIRED it to the real contracts correctly. The original + // defect produced an empty instance-storage map and therefore a plausible + // all-nulls `upgrade` block — every unit test still passed, and the fixtures + // were typechecked by `satisfies` but asserted by nothing, so the false + // negative rode all the way into a published finding. + // + // These assert against real captured mainnet state. If the key decode + // regresses, `deadline` goes null here and this fails loudly. + const FIXTURES = [ + ['constant_product', aquariusConstantProductMainnet], + ['stable', aquariusStableMainnet], + ['concentrated', aquariusConcentratedMainnet], + ['wasm-token', aquariusWasmTokenMainnet], + ] as const; + + it('reads UpgradeDeadline on every pool AND its router — never null', () => { + for (const [label, fx] of FIXTURES) { + for (const [where, up] of [ + ['pool', fx.upgrade], + ['router', fx.router.upgrade], + ] as const) { + // `null` is the signature of the bug: it means "no such entry", which is + // what an emptied instance map looks like. `0n` is the real reading. + assert.notEqual( + up.deadline, + null, + `${label}/${where}: UpgradeDeadline read as null — the instance-storage key ` + + `decode has regressed (see instanceKeyName)`, + ); + assert.equal(up.deadline, 0n, `${label}/${where}: expected no upgrade scheduled`); + assert.equal(up.pending, false); + } + } + }); + + it('reads FutureWASM as each contract’s own running hash — nothing staged', () => { + // Presence is not the signal; DIFFERENCE is. Every contract read on + // 2026-08-29 staged code identical to its live code. + for (const [label, fx] of FIXTURES) { + for (const [where, up] of [ + ['pool', fx.upgrade], + ['router', fx.router.upgrade], + ] as const) { + assert.match(String(up.futureWasm), /^[0-9a-f]{64}$/, `${label}/${where}: no FutureWASM`); + assert.equal(up.futureWasm, up.runningWasm, `${label}/${where}: staged code differs`); + assert.equal(up.stagedDiffers, false); + } + } + }); + + it('decodes every reserve token, and routes each issuer read to the right arm', () => { + // The other half of what flows through readInstance: SAC metadata lives + // under a BARE symbol key (`METADATA`), so this path was never broken — but + // it shares the decoder, and nothing else asserts it against real data. + const seen = new Set(); + for (const [label, fx] of FIXTURES) { + assert.equal(fx.reserveTokens.length, fx.tokens.length, `${label}: token count mismatch`); + for (const t of fx.reserveTokens) { + assert.ok(t.decimals !== null, `${label}: ${t.address} has no decimals`); + seen.add(t.issuer.status); + } + } + // All four arms of AquariusIssuerRead that live data can reach. `failed` is + // deliberately absent — no captured issuer lookup failed, and a fixture + // cannot manufacture one. + assert.deepEqual([...seen].sort(), ['noIssuer', 'notApplicable', 'read']); + }); + + it('pins the three pool types, by the chain’s spelling', () => { + assert.deepEqual( + FIXTURES.map(([, fx]) => fx.poolType), + ['constant_product', 'stable', 'concentrated', 'stable'], + ); + }); + + it('carries a three-token pool, so arity is exercised and not just asserted', () => { + assert.equal(aquariusStableMainnet.tokens.length, 3); + assert.equal(aquariusStableMainnet.reserves.length, 3); + }); +}); diff --git a/adapters/aquarius/fetch.ts b/adapters/aquarius/fetch.ts new file mode 100644 index 0000000..3d9f4e2 --- /dev/null +++ b/adapters/aquarius/fetch.ts @@ -0,0 +1,635 @@ +// Everything that reads the chain for Aquarius: Soroban RPC + Horizon, and the +// decoders that turn ScVal into this adapter's raw shape. +// +// Nothing here scores. `fetchAquariusRawData` is the whole of what the +// adapter's `fetchRawData` does — it takes the target explicitly rather than +// reading it off an instance, so the pool it reads is always the pool it was +// handed. +// +// THE FAILURE POLICY, because it is the part that is easy to get subtly wrong. +// Adapters throw and the indexer records a failed run — that is the rule for a +// WHOLE-ENDPOINT outage: RPC unreachable, Horizon down, nothing decodes. But +// `methodology/dex.md` also requires that a LOCALIZED failure (one role, one +// issuer, one call that reverted) reach scoring as a reading that resolves to +// the unsafe end, not as a thrown cycle. So those are captured into tagged +// unions on the raw shape — `AquariusRolesRead`, `AquariusIssuerRead` — with +// the reason attached, and nothing is swallowed into a generic "fetch failed". +// The distinction is what stops a blip in our own network path publishing +// "dangerous admin control" across every pool at once. + +import { + Account, + Address, + BASE_FEE, + Contract, + Keypair, + TransactionBuilder, + scValToNative, + xdr, +} from '@stellar/stellar-sdk'; +import { rpc } from '@stellar/stellar-sdk'; + +import { + AQUARIUS_POOL_TYPES, + AQUARIUS_ROLES, + AQUARIUS_ROUTER_ID, + NETWORK_PASSPHRASE, +} from './types.ts'; +import type { + AquariusIssuerRead, + AquariusKillFlagsRaw, + AquariusPoolType, + AquariusRawData, + AquariusRoleRaw, + AquariusRolesRead, + AquariusRouterRaw, + AquariusTokenRaw, + AquariusUpgradeRaw, + HorizonAccount, + HorizonOps, +} from './types.ts'; + +// --------------------------------------------------------------------------- +// RPC helpers +// --------------------------------------------------------------------------- + +/** Simulate a read-only contract call and return the decoded native result. */ +async function readContract( + server: rpc.Server, + contractId: string, + method: string, + ...args: xdr.ScVal[] +): Promise { + // Simulation is side-effect-free and unsigned; a throwaway source account is fine. + const source = new Account(Keypair.random().publicKey(), '0'); + const tx = new TransactionBuilder(source, { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation(new Contract(contractId).call(method, ...args)) + .setTimeout(30) + .build(); + + const sim = await server.simulateTransaction(tx); + if (rpc.Api.isSimulationError(sim)) { + throw new Error(`Aquarius: simulation of ${method} on ${contractId} failed: ${sim.error}`); + } + const retval = sim.result?.retval; + if (!retval) throw new Error(`Aquarius: ${method} on ${contractId} returned no value`); + return scValToNative(retval); +} + +/** + * A contract's instance entry: its executable and its instance storage. + * + * Returns null when the contract has no instance entry at all, which is a real + * reading about the address rather than an error — the caller decides what it + * means, because it means different things for a pool and for a token. + */ +async function readInstance( + server: rpc.Server, + contractId: string, +): Promise<{ + executableType: string; + runningWasm: string | null; + storage: Map; +} | null> { + const key = xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: new Address(contractId).toScAddress(), + key: xdr.ScVal.scvLedgerKeyContractInstance(), + durability: xdr.ContractDataDurability.persistent(), + }), + ); + const resp = await server.getLedgerEntries(key); + if (resp.entries.length === 0) return null; + + const instance = resp.entries[0].val.contractData().val().instance(); + const storage = new Map(); + for (const entry of instance.storage() ?? []) { + const name = instanceKeyName(entry.key()); + if (name !== null) storage.set(name, entry.val()); + } + + const executable = instance.executable(); + let runningWasm: string | null = null; + try { + runningWasm = Buffer.from(executable.wasmHash()).toString('hex'); + } catch { + // A Stellar Asset Contract has no wasm hash — a real reading, not an error. + runningWasm = null; + } + return { executableType: executable.switch().name, runningWasm, storage }; +} + +/** + * The name an instance-storage entry is filed under. + * + * **AQUARIUS KEYS INSTANCE STORAGE WITH VEC-WRAPPED ENUM VARIANTS, NOT BARE + * SYMBOLS**, and getting this wrong is not a decode nit — it silently empties + * the map. A soroban `#[contracttype] enum DataKey` serializes a unit variant + * `UpgradeDeadline` as `scvVec([scvSymbol("UpgradeDeadline")])`, which + * `scValToNative` hands back as the ARRAY `["UpgradeDeadline"]`. An earlier + * version of this function kept an entry only `if (typeof name === 'string')` + * and therefore discarded **every** entry on **every** Aquarius contract — + * reading 31 router entries and 33–40 pool entries as zero, and reporting + * `UpgradeDeadline` as unreadable when it is sitting right there at `0n`. + * + * The failure mode is what makes this comment long: an over-strict filter here + * produces an EMPTY map rather than an error, so it looks exactly like a + * contract that stores nothing. Both shapes are accepted, and a compound key + * (`["Balance", address]`, which `get_reserves` reads) is joined with `:` so it + * is addressable rather than colliding with its own variant name. + */ +export function instanceKeyName(key: xdr.ScVal): string | null { + const native = scValToNative(key); + if (typeof native === 'string') return native; + if (Array.isArray(native) && typeof native[0] === 'string') { + return native.length === 1 ? native[0] : native.map((p) => String(p)).join(':'); + } + return null; +} + +/** + * The executable discriminant that identifies a Stellar Asset Contract. + * + * Exported so `fetch.test.ts` pins the exact string rather than restating it — + * the whole SAC/wasm split turns on this one comparison, and a typo in it would + * silently route all 196 SAC tokens down the wasm disclosure path. + */ +export const STELLAR_ASSET_EXECUTABLE = 'contractExecutableStellarAsset'; + +// --------------------------------------------------------------------------- +// Pending upgrades +// --------------------------------------------------------------------------- + +/** + * Read a contract's pending-upgrade state out of instance storage. + * + * Both keys live in the contract's INSTANCE storage under vec-wrapped enum + * variants — `scvVec(["UpgradeDeadline"])` — which is why `instanceKeyName` + * exists and why getting that decode wrong made this whole signal look absent. + * Confirmed live on 2026-08-29 across the router and pools of all three types; + * a simulation footprint for `get_privileged_addrs()` reads *only* the instance + * entry, which is independent proof the data is there rather than under a key + * of its own. + * + * WHAT PENDING MEANS. `commit_upgrade` writes a deadline and `apply_upgrade` + * refuses until it passes, so a non-zero `UpgradeDeadline` is the open reaction + * window — exactly how long an LP has to withdraw before the code under their + * money changes, anchored with no Stenion constant in it. A `0n` deadline is a + * READ VALUE meaning no upgrade is scheduled, not a missing entry, and the two + * are kept distinguishable: `deadline: null` is "no such entry", `0n` is "the + * contract says none pending". + * + * `stagedDiffers` is carried beside it because the two are not the same + * question. Every contract read on 2026-08-29 carried a `FutureWASM` equal to + * its OWN running hash — staged code identical to live code, i.e. nothing + * staged — so a `FutureWASM` naming anything else is the signal, not its mere + * presence. + * + * The DURATION of the window remains unreadable: `ADMIN_ACTIONS_DELAY` is a + * compile-time constant confirmed absent from all four deployed wasms, and + * there is no `get_upgrade_deadline`/`get_future_wasm` getter. That stays a + * route-(a) `value: null` disclosure — see methodology/dex.md. + */ +function readUpgrade( + instance: { runningWasm: string | null; storage: Map } | null, +): AquariusUpgradeRaw { + const storage = instance?.storage ?? new Map(); + + const deadlineScv = storage.get('UpgradeDeadline'); + const deadline = + deadlineScv === undefined + ? null + : BigInt(scValToNative(deadlineScv) as string | number | bigint); + + const wasmScv = storage.get('FutureWASM'); + const futureWasm = wasmScv === undefined ? null : describeWasmHash(scValToNative(wasmScv)); + const runningWasm = instance?.runningWasm ?? null; + + return { + deadline, + futureWasm, + runningWasm, + pending: deadline !== null && deadline > 0n, + stagedDiffers: futureWasm !== null && runningWasm !== null && futureWasm !== runningWasm, + }; +} + +/** A staged wasm hash arrives as bytes; render it hex, or null when it is not a hash. */ +function describeWasmHash(value: unknown): string | null { + if (value instanceof Uint8Array) return Buffer.from(value).toString('hex'); + if (typeof value === 'string') return value; + return null; +} + +// --------------------------------------------------------------------------- +// Roles +// --------------------------------------------------------------------------- + +/** + * Decode `get_privileged_addrs()` into the role list. + * + * The contract returns `Map>` — a role maps to a LIST of + * addresses, not to one. Every role currently holds exactly one, but the + * contract's own type permits several, and flattening to `[0]` would silently + * drop a co-holder the day one is added. + * + * Exported for `fetch.test.ts`: this decode has to survive a short map and a + * malformed one, and neither case can be produced from live data. + */ +export function decodeRoles(native: unknown): AquariusRolesRead { + if (native === null || typeof native !== 'object' || Array.isArray(native)) { + return { + status: 'failed', + reason: `get_privileged_addrs() returned ${native === null ? 'null' : typeof native}, not a role map`, + }; + } + + const entries = Object.entries(native as Record); + const roles: AquariusRoleRaw[] = []; + for (const [role, value] of entries) { + // A single Address decodes to a bare string; tolerate it rather than + // dropping the role, because losing a role silently is the one outcome + // worse than reporting an unexpected shape. + const addresses = Array.isArray(value) + ? value.filter((a): a is string => typeof a === 'string') + : typeof value === 'string' + ? [value] + : []; + roles.push({ role, addresses, accounts: [] }); + } + + const seen = new Set(roles.map((r) => r.role)); + const missing = AQUARIUS_ROLES.filter((r) => !seen.has(r)); + if (missing.length > 0) return { status: 'short', roles, missing: [...missing] }; + return { status: 'read', roles }; +} + +/** Read one privileged account's Horizon posture. */ +async function readRoleAccount( + horizonUrl: string, + address: string, +): Promise { + // A contract-held role has no Horizon account entry to introspect. Recorded + // honestly rather than fabricated — see AquariusRoleRaw.accounts. + if (address.startsWith('C')) return { status: 'contract', address }; + + const windowDays = 30; + try { + const acctResp = await fetch(`${horizonUrl}/accounts/${address}`); + if (!acctResp.ok) { + return { + status: 'failed', + address, + reason: `Horizon account fetch returned ${acctResp.status}`, + }; + } + const acct = (await acctResp.json()) as HorizonAccount; + + const opsResp = await fetch( + `${horizonUrl}/accounts/${address}/operations?order=desc&limit=200`, + ); + if (!opsResp.ok) { + return { status: 'failed', address, reason: `Horizon ops fetch returned ${opsResp.status}` }; + } + const opsBody = (await opsResp.json()) as HorizonOps; + const records = opsBody?._embedded?.records ?? []; + const cutoff = Date.now() - windowDays * 24 * 60 * 60 * 1000; + const recentOps = records.filter((r) => { + const t = Date.parse(r.created_at); + return Number.isFinite(t) && t >= cutoff; + }).length; + + return { + status: 'read', + address, + account: { + highThreshold: Number(acct.thresholds?.high_threshold ?? 0), + signerCount: Array.isArray(acct.signers) ? acct.signers.length : 0, + recentOps, + activityWindowDays: windowDays, + }, + }; + } catch (error) { + return { + status: 'failed', + address, + reason: error instanceof Error ? error.message : String(error), + }; + } +} + +/** Read the role map from one contract, then fill in each holder's Horizon posture. */ +async function readRoles( + server: rpc.Server, + horizonUrl: string, + contractId: string, + accountCache: Map, +): Promise { + let decoded: AquariusRolesRead; + try { + decoded = decodeRoles(await readContract(server, contractId, 'get_privileged_addrs')); + } catch (error) { + // A revert here is a READING about the pool — "we could not read who + // controls this" — which methodology/dex.md sends to the unsafe end. It is + // not a run failure, so it is captured rather than rethrown. + return { + status: 'failed', + reason: `get_privileged_addrs() on ${contractId} failed: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + if (decoded.status === 'failed') return decoded; + + for (const role of decoded.roles) { + for (const address of role.addresses) { + // Cached across the router and the pool within one fetch: the two report + // the same seven accounts today, and reading each one twice would double + // the Horizon cost of every pool to re-learn the same answer. The CACHE is + // per-fetch and per-address — it never assumes the router's roles ARE the + // pool's, which is the assumption AquariusRawData.roles exists to avoid. + const cached = accountCache.get(address); + const account = cached ?? (await readRoleAccount(horizonUrl, address)); + accountCache.set(address, account); + role.accounts.push(account); + } + } + return decoded; +} + +// --------------------------------------------------------------------------- +// Reserve tokens +// --------------------------------------------------------------------------- + +/** + * Split a SAC's metadata name into asset code and issuer. + * + * Native XLM's name is the bare string `native` — it is a SAC with NO issuer + * account, which is a different fact from "not a SAC" and from "we could not + * read the issuer". Everything else is `CODE:ISSUER`. + * + * Exported for `fetch.test.ts`, which pins the `native` case: it is the single + * most common token in the registry and the one a naive `CODE:ISSUER` split + * gets wrong. + */ +export function parseAssetName(name: string): { code: string; issuer: string | null } | null { + if (name === 'native') return { code: 'XLM', issuer: null }; + const colon = name.indexOf(':'); + if (colon <= 0) return null; + const code = name.slice(0, colon); + const issuer = name.slice(colon + 1); + if (!issuer.startsWith('G')) return null; + return { code, issuer }; +} + +/** Read an issuing account's flags from Horizon. */ +async function readIssuerFlags(horizonUrl: string, issuer: string): Promise { + try { + const resp = await fetch(`${horizonUrl}/accounts/${issuer}`); + if (!resp.ok) { + // The token IS a SAC, so the read APPLIES and did not happen — the unsafe + // end, never the wasm route-(a) disclosure. Conflating the two would + // silently upgrade an unknown into an exemption. + return { + status: 'failed', + issuer, + reason: `Horizon issuer fetch returned ${resp.status}`, + }; + } + const acct = (await resp.json()) as HorizonAccount; + return { + status: 'read', + issuer, + flags: { + authRequired: acct.flags?.auth_required === true, + authRevocable: acct.flags?.auth_revocable === true, + authImmutable: acct.flags?.auth_immutable === true, + authClawbackEnabled: acct.flags?.auth_clawback_enabled === true, + }, + }; + } catch (error) { + return { + status: 'failed', + issuer, + reason: error instanceof Error ? error.message : String(error), + }; + } +} + +/** Read one reserve token: what kind of contract it is, and who can control it. */ +async function readToken( + server: rpc.Server, + horizonUrl: string, + address: string, +): Promise { + const instance = await readInstance(server, address); + const isStellarAsset = instance?.executableType === STELLAR_ASSET_EXECUTABLE; + + let code: string | null = null; + let symbol: string | null = null; + let decimals: number | null = null; + let issuer: AquariusIssuerRead = { + status: 'failed', + issuer: null, + reason: `no contract instance entry for token ${address}`, + }; + + const metadataScv = instance?.storage.get('METADATA'); + const metadata = + metadataScv === undefined + ? null + : (scValToNative(metadataScv) as { name?: string; symbol?: string; decimal?: number }); + if (metadata) { + symbol = typeof metadata.symbol === 'string' ? metadata.symbol : null; + decimals = typeof metadata.decimal === 'number' ? metadata.decimal : null; + } + + if (!isStellarAsset) { + // A wasm token has no issuer-flag equivalent. Route (a) — a disclosure, not + // a zero and not a pass. Nine of the 205 distinct pool tokens are these. + issuer = { status: 'notApplicable', reason: 'wasm-contract' }; + code = symbol; + } else if (metadata && typeof metadata.name === 'string') { + const parsed = parseAssetName(metadata.name); + if (parsed === null) { + issuer = { + status: 'failed', + issuer: null, + reason: `SAC metadata name ${JSON.stringify(metadata.name)} is neither 'native' nor CODE:ISSUER`, + }; + } else if (parsed.issuer === null) { + // Native XLM: a SAC with no issuer account, so nothing can freeze or claw + // it back. A POSITIVE fact, not an absent reading — see AquariusIssuerRead. + code = parsed.code; + issuer = { status: 'noIssuer', asset: 'native' }; + } else { + code = parsed.code; + issuer = await readIssuerFlags(horizonUrl, parsed.issuer); + } + } + + return { address, isStellarAsset, code, decimals, symbol, issuer }; +} + +// --------------------------------------------------------------------------- +// Pool type +// --------------------------------------------------------------------------- + +/** + * Narrow `pool_type()` to one of the three the router can deploy. + * + * Returns null for anything else rather than guessing. Aquarius's router can + * only deploy the three wasm hashes it declares, so reaching that branch means + * the pool is running code this adapter was never read against — the same + * situation `BlendAdapter` handles for a status outside 0–6, and the same + * response: claim nothing about it. + * + * Exported for `fetch.test.ts`; the rejecting branch cannot be produced live. + */ +export function asPoolType(value: unknown): AquariusPoolType | null { + return typeof value === 'string' && (AQUARIUS_POOL_TYPES as readonly string[]).includes(value) + ? (value as AquariusPoolType) + : null; +} + +/** The run-failure message for a pool whose type this adapter does not know. */ +export function unrecognisedPoolType(poolId: string, value: unknown): string { + return ( + `Aquarius: pool ${poolId} reports pool_type() = ${JSON.stringify(value)}, which is none of ` + + `${AQUARIUS_POOL_TYPES.join(', ')}. This adapter was read against those three wasm hashes ` + + 'only, so nothing is claimed about this pool rather than guessing which curve it runs.' + ); +} + +// --------------------------------------------------------------------------- +// The fetch itself +// --------------------------------------------------------------------------- + +/** Read the protocol-wide router state shared by every pool. */ +async function fetchRouter( + server: rpc.Server, + horizonUrl: string, + accountCache: Map, +): Promise { + const instance = await readInstance(server, AQUARIUS_ROUTER_ID); + const [contractName, version, emergencyMode] = await Promise.all([ + readContract(server, AQUARIUS_ROUTER_ID, 'contract_name'), + readContract(server, AQUARIUS_ROUTER_ID, 'version'), + readContract(server, AQUARIUS_ROUTER_ID, 'get_emergency_mode'), + ]); + + return { + routerId: AQUARIUS_ROUTER_ID, + contractName: String(contractName), + version: Number(version), + emergencyMode: emergencyMode === true, + roles: await readRoles(server, horizonUrl, AQUARIUS_ROUTER_ID, accountCache), + upgrade: readUpgrade(instance), + }; +} + +/** + * Read one Aquarius pool into `AquariusRawData`. + * + * Target is a parameter, not instance state: `poolId` is the only pool this + * reads, and the caller that supplies it is the same one that publishes the + * identity built from it, so the two cannot drift. + */ +export async function fetchAquariusRawData(target: { + rpcUrl: string; + horizonUrl: string; + poolId: string; +}): Promise { + const { rpcUrl, horizonUrl, poolId } = target; + const server = new rpc.Server(rpcUrl); + + // Shared across the router and this pool for the duration of ONE fetch. See + // readRoles for why this is safe and what it deliberately does not assume. + const accountCache = new Map(); + + // Pool type first: everything after it is interpreted through it, and a pool + // running unknown code should fail before any of it is read rather than after. + const poolTypeNative = await readContract(server, poolId, 'pool_type'); + const poolType = asPoolType(poolTypeNative); + if (poolType === null) throw new Error(unrecognisedPoolType(poolId, poolTypeNative)); + + const instance = await readInstance(server, poolId); + + // Kill flags and emergency mode go through the GETTERS, never instance + // storage — see AquariusKillFlagsRaw for the three reasons. + const [ + tokensNative, + reservesNative, + totalSharesNative, + feeNative, + protocolFeeNative, + killedSwap, + killedDeposit, + killedClaim, + emergencyMode, + infoNative, + shareIdNative, + versionNative, + ] = await Promise.all([ + readContract(server, poolId, 'get_tokens'), + readContract(server, poolId, 'get_reserves'), + readContract(server, poolId, 'get_total_shares'), + readContract(server, poolId, 'get_fee_fraction'), + readContract(server, poolId, 'get_protocol_fee_fraction'), + readContract(server, poolId, 'get_is_killed_swap'), + readContract(server, poolId, 'get_is_killed_deposit'), + readContract(server, poolId, 'get_is_killed_claim'), + readContract(server, poolId, 'get_emergency_mode'), + readContract(server, poolId, 'get_info'), + readContract(server, poolId, 'share_id'), + readContract(server, poolId, 'version'), + ]); + + const tokens = Array.isArray(tokensNative) + ? tokensNative.filter((t): t is string => typeof t === 'string') + : []; + if (tokens.length === 0) { + throw new Error(`Aquarius: pool ${poolId} returned an empty token list`); + } + // Arity comes from get_tokens(), never from an assumed pair — three of the + // 304 token sets have three members. See AquariusRawData.tokens. + const reserves = (Array.isArray(reservesNative) ? reservesNative : []).map((r) => BigInt(r)); + + const killed: AquariusKillFlagsRaw = { + swap: killedSwap === true, + deposit: killedDeposit === true, + claim: killedClaim === true, + }; + + const reserveTokens: AquariusTokenRaw[] = []; + for (const address of tokens) { + reserveTokens.push(await readToken(server, horizonUrl, address)); + } + + const roles = await readRoles(server, horizonUrl, poolId, accountCache); + const upgrade = readUpgrade(instance); + const router = await fetchRouter(server, horizonUrl, accountCache); + + return { + poolId, + poolType, + tokens, + reserves, + totalShares: BigInt(totalSharesNative as string | number | bigint), + feeFraction: Number(feeNative), + protocolFeeFraction: Number(protocolFeeNative), + info: infoNative as Record, + shareId: String(shareIdNative), + version: Number(versionNative), + killed, + emergencyMode: emergencyMode === true, + roles, + upgrade, + reserveTokens, + router, + fetchedAt: Math.floor(Date.now() / 1000), + }; +} diff --git a/adapters/aquarius/index.ts b/adapters/aquarius/index.ts new file mode 100644 index 0000000..1277a94 --- /dev/null +++ b/adapters/aquarius/index.ts @@ -0,0 +1,162 @@ +// The AquariusAdapter itself: identity, and the Adapter interface wired to the +// modules beside it. This file is the adapter's whole public surface — +// `./types.ts` and `./fetch.ts` export more than this re-exports, and that extra +// is internal wiring rather than API. +// +// THIS ADAPTER FETCHES AND DOES NOT SCORE, DELIBERATELY (#101). `dex` ships with +// two factors (`adminKeySafety`, `assetControlSafety`) and NO weight table — the +// weights are their own review (#102) and the scoring implementation is #103. So +// `computeRiskFactors`, `score` and `operationalState` throw rather than +// returning something plausible. An adapter that returned a made-up factor map +// to satisfy the interface would be indistinguishable from a working one at the +// call site, which is precisely the failure the two-step admission exists to +// prevent. +// +// There is no `score.ts` in this folder yet, and that is the same fact stated in +// the file layout. CLAUDE.md's four-file adapter shape is types/fetch/score/ +// index; `score.ts` is where the factors and `operationalState` live, and it +// arrives with #103 rather than existing now as a file full of throws. + +import type { + Adapter, + OperationalState, + ProtocolMetadata, + RiskFactorMap, + RiskScoreResult, +} from '@stenion/core'; + +import { fetchAquariusRawData } from './fetch.ts'; +import { DEFAULT_HORIZON_URL, DEFAULT_RPC_URL } from './types.ts'; +import type { AquariusAdapterOptions, AquariusRawData } from './types.ts'; + +export { + AQUARIUS_POOL_TYPES, + AQUARIUS_ROLES, + AQUARIUS_ROUTER_ID, + DEFAULT_HORIZON_URL, + DEFAULT_RPC_URL, +} from './types.ts'; +export type { + AquariusAdapterOptions, + AquariusIssuerFlagsRaw, + AquariusIssuerRead, + AquariusKillFlagsRaw, + AquariusPool, + AquariusPoolType, + AquariusRawData, + AquariusRole, + AquariusRoleAccountRaw, + AquariusRoleRaw, + AquariusRolesRead, + AquariusRouterRaw, + AquariusTokenRaw, + AquariusUpgradeRaw, +} from './types.ts'; +export { + STELLAR_ASSET_EXECUTABLE, + asPoolType, + decodeRoles, + instanceKeyName, + fetchAquariusRawData, + parseAssetName, + unrecognisedPoolType, +} from './fetch.ts'; + +/** + * The message every unimplemented scoring method throws. + * + * A STRING LITERAL, never built from a runtime identifier — the workspace is + * bundled and minified into the dashboard's serverless functions, so a name + * taken from `this.constructor.name` would be right in every test and wrong in + * production. Same rule as `ProtocolMetadata.adapterRef`. + */ +function notScorable(method: string): Error { + return new Error( + `AquariusAdapter.${method} is not implemented: the dex rulebook publishes no weight table ` + + 'yet (methodology/dex.md, "Factor weights"), so nothing can be scored under it. This ' + + 'adapter reads the chain and stops there by design — see issue #101. Scoring is #103.', + ); +} + +export class AquariusAdapter implements Adapter { + /** + * Built in the constructor rather than as a field initialiser because every + * identity field has to describe the pool THIS INSTANCE reads. `contractId` + * is the sharp one: an adapter pointed at a second pool that published an + * explorer link to the first would attach a wrong reading to a real address. + * It is set from `this.poolId`, the same value `fetchRawData` reads, so the + * two cannot drift. + */ + readonly metadata: ProtocolMetadata<'dex'>; + + private readonly rpcUrl: string; + private readonly horizonUrl: string; + private readonly poolId: string; + + constructor(opts: AquariusAdapterOptions) { + this.rpcUrl = opts.rpcUrl ?? DEFAULT_RPC_URL; + this.horizonUrl = opts.horizonUrl ?? DEFAULT_HORIZON_URL; + this.poolId = opts.pool.poolId; + + this.metadata = { + id: opts.pool.id, + name: opts.pool.name, + chain: 'stellar', + // The first adapter of a category other than lending. Declared per + // instance like the rest of the identity — ADAPTER_INTERFACE_VERSION 3 + // makes this required precisely so no adapter is silently filed under + // lending, and this is the first time that has mattered. + category: 'dex', + // Literal, not this.constructor.name — see ProtocolMetadata.adapterRef. + adapterRef: 'AquariusAdapter', + contractId: this.poolId, + ...(opts.pool.logo === undefined ? {} : { logo: opts.pool.logo }), + ...(opts.pool.links === undefined ? {} : { links: opts.pool.links }), + ...(opts.pool.deployedOn === undefined ? {} : { deployedOn: opts.pool.deployedOn }), + }; + } + + // Reads live on ./fetch.ts, which takes the target explicitly — this method + // is where instance state becomes that argument, and nowhere else. + async fetchRawData(): Promise { + return fetchAquariusRawData({ + rpcUrl: this.rpcUrl, + horizonUrl: this.horizonUrl, + poolId: this.poolId, + }); + } + + /** + * Not implemented — see the file header. Throws rather than returning a + * partial or placeholder factor map: `dex` has no weight table, so there is + * no honest number to put in one. + */ + async computeRiskFactors(_raw: AquariusRawData): Promise { + throw notScorable('computeRiskFactors'); + } + + /** + * Not implemented — #103. + * + * The raw shape already carries everything this needs: `killed` (swap, + * deposit, claim — read via getters) and `emergencyMode`, on the pool and on + * the router. What is missing is the mapping onto `dex`'s vocabulary and the + * `swapDisabled` rung, which is scoring-adjacent logic and belongs in + * `score.ts` beside the factors rather than being written here first. + */ + operationalState(_raw: AquariusRawData): OperationalState<'dex'> { + throw notScorable('operationalState'); + } + + /** + * Not implemented — #103. + * + * Deliberately NOT `scoreFactors(factors)`. The shared weighted mean would + * happily average an empty or hand-built map and return a confident number, + * and `dex` has no weights for it to average. Delegating here would make an + * unscorable category look scorable at the one call site the indexer uses. + */ + score(_factors: RiskFactorMap): RiskScoreResult { + throw notScorable('score'); + } +} diff --git a/adapters/aquarius/types.ts b/adapters/aquarius/types.ts new file mode 100644 index 0000000..2ba1d97 --- /dev/null +++ b/adapters/aquarius/types.ts @@ -0,0 +1,479 @@ +// Mainnet wiring, the raw on-chain shape, and the adapter's options — the leaf +// of this adapter's module graph. Everything here is either a type or a +// constant; nothing reads the chain and nothing scores. +// +// SCOPE OF THIS FILE'S ADAPTER (#101). This is the FETCH half only. `dex` ships +// with two factors — `adminKeySafety` and `assetControlSafety` — and no weights +// yet (#102), so nothing here is read in order to produce a number today. The +// raw shape is nonetheless the place every pool-type divergence and every +// unreadable quantity has to be resolved, and those decisions outlive whichever +// formula eventually reads them. +// +// THERE IS NO DEPTH READ IN THIS ADAPTER, ANYWHERE. `estimate_swap` is not +// called, not stubbed and not flagged off. `depthSafety` was deferred by +// question A in `methodology/dex.md` — Aquarius publishes no unit of value to +// denominate a trade size in — and dormant code for a deferred factor is how a +// deferral quietly becomes an implementation. If depth is revisited, that is a +// fresh issue reopening option 4, not a commented-out call sitting here. + +import { Networks } from '@stellar/stellar-sdk'; +import type { ProtocolDeployment, ProtocolLinks } from '@stenion/core'; + +// --------------------------------------------------------------------------- +// Mainnet wiring +// +// Every address and every interface claim below was read from the deployed wasm +// and from ledger state, never from documentation — and for this protocol that +// is forced rather than preferred: `github.com/AquaToken/soroban-amm`, the +// repository Aquarius's own audit scope links to, returns 404. There is no +// source to check against, which is exactly the situation TAXONOMY.md Gate 8 +// describes when it says to confirm reads against the contract. +// +// ONE WASM PER POOL TYPE, verified against the router's own declared hashes and +// re-confirmed live on 2026-08-29 (ledger 64,176,303): +// +// constant_product ae0da5a84b15805c5c7931ac567a8d1b34be3f26b483993d9ff80cb2c3de9852 +// stable f1077e0b77da5e62d596e13aeae4160104cad99e7ef7f3183a6c9b6ec9e747cd +// concentrated 12fca5a7a96577273b6d4184cf9c984036cda0e8f0594747e7b2933dced37ee6 +// +// So a second Aquarius market is a config entry and no new scoring code, the +// same rule BLEND_POOLS runs under. Nothing on `AquariusPool` may be a +// threshold, weight or formula. +// --------------------------------------------------------------------------- + +export const NETWORK_PASSPHRASE = Networks.PUBLIC; + +/** Public, key-less Soroban mainnet RPC. Overridable for self-hosting. */ +export const DEFAULT_RPC_URL = 'https://mainnet.sorobanrpc.com'; + +/** Public Horizon — admin signer/activity and issuer flags, which Soroban RPC does not expose. */ +export const DEFAULT_HORIZON_URL = 'https://horizon.stellar.org'; + +/** + * Aquarius's AMM router. Read for protocol-wide identity and for the role set + * as the protocol declares it globally. + * + * `contract_name() = "AMMRouter"`, `version() = 200`, confirmed live. + */ +export const AQUARIUS_ROUTER_ID = 'CBQDHNBFBZYE4MKPWBSJOPIYLW4SFSXAXUTSXJN76GNKYVYPCKWC6QUK'; + +/** + * The seven privileged roles `get_privileged_addrs()` returns, in the order this + * adapter publishes them. + * + * THE COUNT IS LOAD-BEARING, and it is why this is a constant rather than + * "whatever keys came back". `methodology/dex.md` sends `adminKeySafety` to the + * unsafe end when the role map is SHORT — a map missing a role is either an + * unexpected contract version or a role we cannot see, and grading the roles + * that did come back would publish a posture assessment of an admin set we know + * is incomplete. Comparing against a fixed expected set is what makes "short" + * detectable at all; counting the keys the chain returned against itself always + * succeeds. + * + * All seven were present on the router and on every pool sampled on 2026-08-29. + */ +export const AQUARIUS_ROLES = [ + 'Admin', + 'EmergencyAdmin', + 'EmergencyPauseAdmin', + 'PauseAdmin', + 'OperationsAdmin', + 'RewardsAdmin', + 'SystemFeeAdmin', +] as const; +export type AquariusRole = (typeof AQUARIUS_ROLES)[number]; + +/** + * The three pool types the router can deploy, as `pool_type()` reports them. + * + * **`constant_product`, NOT `standard`.** Issue #100's rulebook prose and #101 + * both call the constant-product type "standard", which is what Aquarius's own + * documentation calls it. The deployed contract returns the symbol + * `constant_product` — read live on 2026-08-29 from a pool of each type. The + * chain's spelling is the one in the code, because the code compares against + * what the chain says; the prose is a naming discrepancy recorded in + * `methodology/dex.md` rather than silently reconciled here. + */ +export const AQUARIUS_POOL_TYPES = ['constant_product', 'stable', 'concentrated'] as const; +export type AquariusPoolType = (typeof AQUARIUS_POOL_TYPES)[number]; + +/** + * One Aquarius market this adapter can be pointed at. + * + * Everything here is IDENTITY — slug, display name, pool contract, mark, links. + * Deliberately nothing here is a threshold, a weight or a formula: a field that + * changed how a factor is computed would be a per-pool rulebook, which + * methodology ground rule 1 forbids. Adding a pool must stay a data change. + * + * Shaped like `BlendPool` on purpose — the two adapters solve the same + * targeting problem and there is no reason for them to solve it differently. + */ +export interface AquariusPool { + /** registry slug — `protocols.id`, the public URL, and the API path segment */ + id: string; + /** display name */ + name: string; + /** the pool contract this entry is scored from */ + poolId: string; + /** self-hosted mark, or omitted when the market publishes none */ + logo?: string; + links?: ProtocolLinks; + /** + * Every Aquarius pool is a market on Aquarius's contracts rather than an + * independent protocol, so an entry that is not Aquarius's own flagship + * carries this — the same rule the YieldBlox pool on Blend carries it under. + */ + deployedOn?: ProtocolDeployment; +} + +/** + * NO POOL IS REGISTERED YET, and there is deliberately no `AQUARIUS_POOLS` + * constant here. + * + * Which pools to register is a reviewed decision (#104) that depends on a size + * census, and #101's non-goals put registration out of scope. Shipping an empty + * or speculative registry array would invite `buildTargets` to iterate it. The + * fixture-capture script names its pools directly, which is what that script is + * for. + */ + +// --------------------------------------------------------------------------- +// The raw shape +// --------------------------------------------------------------------------- + +/** Classic-asset issuer flags, exactly as Horizon reports them. */ +export interface AquariusIssuerFlagsRaw { + authRequired: boolean; + authRevocable: boolean; + authImmutable: boolean; + authClawbackEnabled: boolean; +} + +/** + * What we learned about one reserve token's issuer-level control, as a tagged + * union rather than a nullable field. + * + * THE FOUR ARMS ARE FOUR DIFFERENT FACTS AND MUST NOT COLLAPSE. This is the + * single most dangerous confusion available in `assetControlSafety` + * (`methodology/dex.md`), so the type refuses to express it: + * + * - `read` — the token is a Stellar Asset Contract, its issuer was found, and + * the flags are the thing the factor grades. + * - `noIssuer` — the token is the native asset (XLM). It is a SAC, and it has + * no issuer account at all, so no third party can freeze or claw it back. + * **This is a positive fact, not an absent reading**, and it must never be + * routed like `notApplicable`: "nobody can seize this" and "we could not + * check whether anybody can seize this" are opposite statements. + * - `notApplicable` — the token is a wasm contract, not a SAC, so there is no + * issuer-flag equivalent to read. Route (a): a `value: null` disclosure. Nine + * of the 205 distinct pool tokens are these. + * - `failed` — the token IS a SAC and the Horizon lookup for its issuer did not + * succeed. The read applies and did not happen, which `methodology/dex.md` + * sends to the unsafe end. Never routed as `notApplicable`; doing so would + * silently upgrade an unknown into an exemption. + * + * `reason` is carried on `failed` so a run failure is attributable to a + * specific read rather than a generic "fetch failed". + */ +export type AquariusIssuerRead = + | { status: 'read'; issuer: string; flags: AquariusIssuerFlagsRaw } + | { status: 'noIssuer'; asset: 'native' } + | { status: 'notApplicable'; reason: 'wasm-contract' } + | { status: 'failed'; issuer: string | null; reason: string }; + +/** One of a pool's reserve tokens, and what could be established about it. */ +export interface AquariusTokenRaw { + /** the token contract address */ + address: string; + /** + * True when the contract's executable is `contractExecutableStellarAsset`. + * + * DETECTED FROM THE EXECUTABLE, NEVER FROM THE SHAPE OF `name()`. Native XLM + * (`CAS3J7GY…`) is a SAC whose metadata `name` is the bare string `native` + * rather than `CODE:ISSUER` — confirmed live — so a regex over the name + * misclassifies the single most common token in the registry as a wasm + * contract, and would hand XLM the wasm route-(a) disclosure. The executable + * discriminant has no such failure mode. + */ + isStellarAsset: boolean; + /** asset code from contract metadata, or null when it could not be read */ + code: string | null; + /** token decimals from contract metadata, or null when it could not be read */ + decimals: number | null; + /** token symbol from contract metadata, or null when it could not be read */ + symbol: string | null; + /** see AquariusIssuerRead — the four arms are four different facts */ + issuer: AquariusIssuerRead; +} + +/** One privileged account's Horizon-side posture. */ +export interface AquariusRoleAccountRaw { + highThreshold: number; + signerCount: number; + recentOps: number; + activityWindowDays: number; +} + +/** + * One role, its declared holders, and what could be read about each. + * + * `addresses` is an ARRAY because `get_privileged_addrs()` returns one — the map + * is `role -> Vec
`, not `role -> Address`. Confirmed live on + * 2026-08-29; every role currently holds exactly one address, but the contract's + * own type permits several and flattening to `[0]` would silently drop + * co-holders the day one is added. + */ +export interface AquariusRoleRaw { + role: string; + addresses: string[]; + /** + * Per address, in `addresses` order. + * + * - `read` — a classic `G…` account whose signers and thresholds were read. + * - `contract` — a `C…` address: Horizon has no account entry to introspect, + * recorded honestly rather than fabricated. `methodology/dex.md` sends this + * to the unsafe end for `dex` rather than inheriting lending's neutral 60, + * and records why. + * - `failed` — the lookup did not succeed; attributable, with its reason. + */ + accounts: ( + | { status: 'read'; address: string; account: AquariusRoleAccountRaw } + | { status: 'contract'; address: string } + | { status: 'failed'; address: string; reason: string } + )[]; +} + +/** + * The role map as a whole, tagged so a failed or short read is a READING rather + * than an exception. + * + * `short` is its own arm rather than a flag on `read`: `methodology/dex.md` + * sends both a revert and a short map to the unsafe end, and keeping them + * distinct means a reviewer can tell "the contract refused" from "the contract + * answered with less than we expect" without re-reading the reason string. + */ +export type AquariusRolesRead = + | { status: 'read'; roles: AquariusRoleRaw[] } + | { status: 'short'; roles: AquariusRoleRaw[]; missing: string[] } + | { status: 'failed'; reason: string }; + +/** + * A pending code upgrade, or the read that says there is none. + * + * `commit_upgrade` writes an upgrade deadline and `apply_upgrade` refuses until + * it passes, so a non-zero deadline is exactly how long an LP has to withdraw + * before the code under their money changes — the anchor `adminKeySafety` + * grades, with no Stenion constant in it. + * + * BOTH KEYS LIVE IN CONTRACT **INSTANCE** STORAGE, under vec-wrapped enum + * variants (`scvVec(["UpgradeDeadline"])`). Read live on 2026-08-29 from the + * router and pools of all three types: `UpgradeDeadline = 0n` on every one, and + * `FutureWASM` equal to that contract's own running hash — i.e. no upgrade + * scheduled anywhere, which is a READ VALUE rather than an absence. + * + * > **A correction is recorded here on purpose.** An earlier version of this + * > adapter reported these keys as unreadable "under any encoding or + * > durability", and that finding was WRONG — caused by `readInstance` keeping + * > only string-typed keys and so discarding every vec-wrapped entry on every + * > contract. The bug presented as an empty map rather than an error, which is + * > exactly why it read as a property of Aquarius instead of a defect here. The + * > upgrade-reaction-window half of `adminKeySafety` is fully readable, and + * > `methodology/dex.md`'s Gate 0 argument for it stands unchanged. + * + * WHAT GENUINELY IS NOT READABLE, and this part was always right: the + * **duration** of the window. `ADMIN_ACTIONS_DELAY` is a compile-time constant, + * confirmed absent from all four deployed wasms by byte-searching them, and + * there is no `get_upgrade_deadline` or `get_future_wasm` getter to ask. So the + * factor can grade the REMAINING window when one is open and state that none + * is, and cannot state how long a window would be. That stays a route-(a) + * `value: null` disclosure. + */ +export interface AquariusUpgradeRaw { + /** + * `UpgradeDeadline` as read: a unix timestamp, or `0n` for none scheduled. + * + * `null` means the contract carries no such entry AT ALL, which is a + * different statement from `0n` and must not be collapsed into it — one says + * the contract answered "nothing pending", the other says this contract does + * not keep that field. + */ + deadline: bigint | null; + /** `FutureWASM` as hex, or null when the contract carries no such entry */ + futureWasm: string | null; + /** the wasm hash actually running, from the instance executable */ + runningWasm: string | null; + /** true only when a deadline is set and non-zero — the reaction window is open */ + pending: boolean; + /** + * True when `FutureWASM` names code other than what is running. + * + * Carried separately because presence is not the signal: every contract read + * on 2026-08-29 had a `FutureWASM` equal to its own running hash, so staged + * code identical to live code is the quiescent state, not a staged upgrade. + */ + stagedDiffers: boolean; +} + +/** Protocol-wide reads from the router, shared by every pool. */ +export interface AquariusRouterRaw { + routerId: string; + contractName: string; + version: number; + /** router-wide emergency mode — live ungraded state, never a factor input */ + emergencyMode: boolean; + roles: AquariusRolesRead; + upgrade: AquariusUpgradeRaw; +} + +/** + * The kill switches, read through the GETTERS. + * + * WHY NOT INSTANCE STORAGE, which is the obvious cheaper read. The three pool + * types disagree about storage key names — `constant_product` writes + * `IsKilledClaim`, `concentrated` writes `ClaimKilled`/`IsKilledSwap`/ + * `EmergencyMode` — and, decisively, **a flag that has never been toggled has + * no storage key at all**. Live confirmation on 2026-08-29: contract instance + * storage is EMPTY on pools of all three types, so a storage-first reader would + * see nothing and have to guess whether that meant `false` or "unread". The + * getters normalise every one of those cases and return a real boolean. + * + * THERE IS NO `kill_withdraw`, and its absence is the strongest single fact + * about an Aquarius LP's exit risk. The pool wasms export `kill_swap`, + * `kill_deposit`, `kill_claim`, `kill_gauges_claim` and their `unkill_` + * counterparts and no withdraw equivalent, so no Aquarius role can stop a + * withdrawal. There is deliberately no `withdraw` member on this type: adding + * one would imply a switch exists. + */ +export interface AquariusKillFlagsRaw { + swap: boolean; + deposit: boolean; + claim: boolean; +} + +/** + * One Aquarius pool, read. + * + * Nothing in here is scored yet — `dex`'s two factors are `adminKeySafety` and + * `assetControlSafety`, and the scoring implementation is #103. Several fields + * (`reserves`, `totalShares`, `feeFraction`) feed no factor today and are read + * anyway, because they are what identifies and provenances a pool, and because + * re-deriving the pool-type divergence handling later would mean re-doing the + * work this file exists to do once. + */ +export interface AquariusRawData { + poolId: string; + /** see AQUARIUS_POOL_TYPES — the chain's spelling, not the documentation's */ + poolType: AquariusPoolType; + /** + * The pool's reserve token addresses, in the index order the contract uses. + * + * **THE LENGTH IS THE SOURCE OF TRUTH FOR ARITY — never assume two.** Three of + * the 304 token sets have three members, confirmed live, and all three are + * `stable` pools (`get_info().n_tokens = 3`). Nothing anywhere in this adapter + * may index `[0]`/`[1]` as though a pool were always a pair. + */ + tokens: string[]; + /** + * Raw reserves, in `tokens` order. + * + * **THIS MEANS SOMETHING DIFFERENT FOR A CONCENTRATED POOL, and that is why + * `poolType` is recorded beside it rather than being derivable later.** For + * `constant_product` and `stable` it is the tradable balance. For + * `concentrated` it is the total across all tick ranges, of which only the + * active range is available at the current price — the tradable part is + * described by `get_active_liquidity()` and `Slot0`, which this adapter + * deliberately does not read (concentrated-specific reads are out of scope + * per #100). Nothing downstream may treat the two as the same quantity, and + * the pairing of these two fields is what stops it doing so by accident. + */ + reserves: bigint[]; + totalShares: bigint; + /** + * Swap fee in BASIS POINTS, as the contract reports it. + * + * Read, never assumed from a tier: Aquarius's documentation describes three + * fee tiers, and the chain disagrees — `constant_product` pools use 10/30/100, + * while `stable` pools were found at 1, 5, 10, 15, 22, 25, 30 and 50. Not a + * factor input: a higher fee is worse execution, not a failure mode, and + * grading it would dress a pricing preference as a risk measurement + * (`methodology/dex.md`, "Fee tier as a factor"). + */ + feeFraction: number; + /** the protocol's cut of the fee, in basis points */ + protocolFeeFraction: number; + /** + * `get_info()`, verbatim. + * + * ITS KEYS **AND** ITS VALUE TYPES DIFFER PER POOL TYPE, which is why this is + * an open record rather than a struct. Read live: + * + * constant_product { fee: 100, pool_type: 'constant_product' } + * stable { a: 1500n, fee: 15, n_tokens: 3, pool_type: 'stable' } + * concentrated { fee: 30, pool_type: 'concentrated', tick_spacing: 60 } + * + * `bigint` is in the union because of `a`, the stableswap amplification + * coefficient, which decodes as u128. That was not in #101's description of + * this read and was caught by a captured fixture failing to satisfy the + * narrower type — which is the entire reason fixtures are checked with + * `satisfies` rather than cast. + */ + info: Record; + /** the LP share token, for provenance */ + shareId: string; + version: number; + /** see AquariusKillFlagsRaw — read via getters, and there is no withdraw switch */ + killed: AquariusKillFlagsRaw; + /** pool-level emergency mode — live ungraded state, never a factor input */ + emergencyMode: boolean; + /** + * The pool's OWN role map, read per pool rather than inherited from the + * router. The seven roles are identical across the router and every pool + * sampled — but a per-pool `set_privileged_addrs` exists, so that uniformity + * is a current reading and not an invariant. Assuming it would make the + * adapter unable to report the day it stops being true. + */ + roles: AquariusRolesRead; + /** the pool's own pending-upgrade state — see AquariusUpgradeRaw */ + upgrade: AquariusUpgradeRaw; + /** one entry per address in `tokens`, same order */ + reserveTokens: AquariusTokenRaw[]; + /** protocol-wide reads, shared by every pool */ + router: AquariusRouterRaw; + fetchedAt: number; // unix seconds +} + +export interface AquariusAdapterOptions { + rpcUrl?: string; + horizonUrl?: string; + /** + * Which market to read. Required — there is no default pool, deliberately. + * + * `BlendAdapter` defaults to Blend's flagship because Blend has one. Aquarius + * has 340 pools, none registered and none reviewed as the flagship (#104), so + * a default here would be this adapter picking the protocol's public face by + * accident. A whole `AquariusPool` rather than a bare id, for the reason + * `BlendAdapterOptions.pool` gives: target and identity must move together. + */ + pool: AquariusPool; +} + +// --------------------------------------------------------------------------- +// Shapes as they come back from the chain / Horizon +// --------------------------------------------------------------------------- + +/** Horizon `/accounts/{G…}` — the subset read. */ +export interface HorizonAccount { + thresholds?: { high_threshold?: number }; + signers?: unknown[]; + flags?: { + auth_required?: boolean; + auth_revocable?: boolean; + auth_immutable?: boolean; + auth_clawback_enabled?: boolean; + }; +} + +/** Horizon `/accounts/{G…}/operations` — the subset read. */ +export interface HorizonOps { + _embedded?: { records?: { created_at: string }[] }; +} diff --git a/adapters/fixtures/aquarius/concentrated-mainnet.ts b/adapters/fixtures/aquarius/concentrated-mainnet.ts new file mode 100644 index 0000000..64236fc --- /dev/null +++ b/adapters/fixtures/aquarius/concentrated-mainnet.ts @@ -0,0 +1,243 @@ +// Frozen mainnet snapshot — generated by scripts/capture-fixture.mjs. +// +// DO NOT HAND-EDIT. Regenerate with `pnpm capture:fixture aquarius-concentrated`, then re-derive +// the expected values in adapters/snapshot.test.ts. If a factor moved, find out +// why before committing — that is the entire point of this file. +// +// Captured: 2026-08-29T09:36:02.586Z +// Not scored at capture time: this category publishes no weight table yet, so the adapter scoring methods throw by design. This fixture freezes the RAW SHAPE. +// +// `satisfies` is load-bearing: if AquariusRawData gains a required field, this file +// stops compiling rather than quietly feeding the adapter a stale shape. + +import type { AquariusRawData } from '../../aquarius/index.ts'; + +export const aquariusConcentratedMainnet = { + poolId: 'CA4HTZNY2RBZWEQE5GBMNREZMFRPAZSVJ6OGPC7T3VM7NHRJYFAVID2S', + poolType: 'concentrated', + tokens: [ + 'CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', + 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', + ], + reserves: [4272366505631n, 3524317165205583n], + totalShares: 11430750066584469n, + feeFraction: 30, + protocolFeeFraction: 5000, + info: { fee: 30, pool_type: 'concentrated', tick_spacing: 60 }, + shareId: 'CA4HTZNY2RBZWEQE5GBMNREZMFRPAZSVJ6OGPC7T3VM7NHRJYFAVID2S', + version: 200, + killed: { swap: false, deposit: false, claim: false }, + emergencyMode: false, + roles: { + status: 'read', + roles: [ + { + role: 'Admin', + addresses: ['GAV5FBMKD2ZF4X2MGWDNQYUP7KFL7MRM6HZBY7HKQLB4BRHSCCX5J6VS'], + accounts: [ + { + status: 'read', + address: 'GAV5FBMKD2ZF4X2MGWDNQYUP7KFL7MRM6HZBY7HKQLB4BRHSCCX5J6VS', + account: { highThreshold: 2, signerCount: 3, recentOps: 96, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'EmergencyAdmin', + addresses: ['GCGZ6E5RBUKLNB4VZ5RC65C4QMBSBJ3COVRRJCWAMCXJC36LB7YYWEKM'], + accounts: [ + { + status: 'read', + address: 'GCGZ6E5RBUKLNB4VZ5RC65C4QMBSBJ3COVRRJCWAMCXJC36LB7YYWEKM', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'EmergencyPauseAdmin', + addresses: ['GA6MVTGQDCJPP27IAMG6PSDTWOJYTD3NUTLR2W54ADBCBY7OID5YUDSI'], + accounts: [ + { + status: 'read', + address: 'GA6MVTGQDCJPP27IAMG6PSDTWOJYTD3NUTLR2W54ADBCBY7OID5YUDSI', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'OperationsAdmin', + addresses: ['GBVQPX2LQ55HLRMLIWBEYVVQL3SZ5RFPRKYLRSLZU4XRIWAXW2KQIMMD'], + accounts: [ + { + status: 'read', + address: 'GBVQPX2LQ55HLRMLIWBEYVVQL3SZ5RFPRKYLRSLZU4XRIWAXW2KQIMMD', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'PauseAdmin', + addresses: ['GA6MA665XVKHTQUZVSMUKUPGT7OREJNCLAZ5ZEH5CXPKYTWFJKZ3YSEK'], + accounts: [ + { + status: 'read', + address: 'GA6MA665XVKHTQUZVSMUKUPGT7OREJNCLAZ5ZEH5CXPKYTWFJKZ3YSEK', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'RewardsAdmin', + addresses: ['GCXYKA3BM574WC6TWESEDUGUJTNQ5SVCFMHWLQ634H5FTE7FYPV3JH3X'], + accounts: [ + { + status: 'read', + address: 'GCXYKA3BM574WC6TWESEDUGUJTNQ5SVCFMHWLQ634H5FTE7FYPV3JH3X', + account: { highThreshold: 0, signerCount: 1, recentOps: 200, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'SystemFeeAdmin', + addresses: ['GB57YDVGLL2BAVOXHPXYCZR77J4MLPLMGJKFMTUKMHFI2AEGS4SGGW7N'], + accounts: [ + { + status: 'read', + address: 'GB57YDVGLL2BAVOXHPXYCZR77J4MLPLMGJKFMTUKMHFI2AEGS4SGGW7N', + account: { highThreshold: 0, signerCount: 1, recentOps: 200, activityWindowDays: 30 }, + }, + ], + }, + ], + }, + upgrade: { + deadline: 0n, + futureWasm: '12fca5a7a96577273b6d4184cf9c984036cda0e8f0594747e7b2933dced37ee6', + runningWasm: '12fca5a7a96577273b6d4184cf9c984036cda0e8f0594747e7b2933dced37ee6', + pending: false, + stagedDiffers: false, + }, + reserveTokens: [ + { + address: 'CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', + isStellarAsset: true, + code: 'XLM', + decimals: 7, + symbol: 'native', + issuer: { status: 'noIssuer', asset: 'native' }, + }, + { + address: 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', + isStellarAsset: true, + code: 'AQUA', + decimals: 7, + symbol: 'AQUA', + issuer: { + status: 'read', + issuer: 'GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA', + flags: { + authRequired: false, + authRevocable: false, + authImmutable: false, + authClawbackEnabled: false, + }, + }, + }, + ], + router: { + routerId: 'CBQDHNBFBZYE4MKPWBSJOPIYLW4SFSXAXUTSXJN76GNKYVYPCKWC6QUK', + contractName: 'AMMRouter', + version: 200, + emergencyMode: false, + roles: { + status: 'read', + roles: [ + { + role: 'Admin', + addresses: ['GAV5FBMKD2ZF4X2MGWDNQYUP7KFL7MRM6HZBY7HKQLB4BRHSCCX5J6VS'], + accounts: [ + { + status: 'read', + address: 'GAV5FBMKD2ZF4X2MGWDNQYUP7KFL7MRM6HZBY7HKQLB4BRHSCCX5J6VS', + account: { highThreshold: 2, signerCount: 3, recentOps: 96, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'EmergencyAdmin', + addresses: ['GCGZ6E5RBUKLNB4VZ5RC65C4QMBSBJ3COVRRJCWAMCXJC36LB7YYWEKM'], + accounts: [ + { + status: 'read', + address: 'GCGZ6E5RBUKLNB4VZ5RC65C4QMBSBJ3COVRRJCWAMCXJC36LB7YYWEKM', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'EmergencyPauseAdmin', + addresses: ['GA6MVTGQDCJPP27IAMG6PSDTWOJYTD3NUTLR2W54ADBCBY7OID5YUDSI'], + accounts: [ + { + status: 'read', + address: 'GA6MVTGQDCJPP27IAMG6PSDTWOJYTD3NUTLR2W54ADBCBY7OID5YUDSI', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'OperationsAdmin', + addresses: ['GBVQPX2LQ55HLRMLIWBEYVVQL3SZ5RFPRKYLRSLZU4XRIWAXW2KQIMMD'], + accounts: [ + { + status: 'read', + address: 'GBVQPX2LQ55HLRMLIWBEYVVQL3SZ5RFPRKYLRSLZU4XRIWAXW2KQIMMD', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'PauseAdmin', + addresses: ['GA6MA665XVKHTQUZVSMUKUPGT7OREJNCLAZ5ZEH5CXPKYTWFJKZ3YSEK'], + accounts: [ + { + status: 'read', + address: 'GA6MA665XVKHTQUZVSMUKUPGT7OREJNCLAZ5ZEH5CXPKYTWFJKZ3YSEK', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'RewardsAdmin', + addresses: ['GCXYKA3BM574WC6TWESEDUGUJTNQ5SVCFMHWLQ634H5FTE7FYPV3JH3X'], + accounts: [ + { + status: 'read', + address: 'GCXYKA3BM574WC6TWESEDUGUJTNQ5SVCFMHWLQ634H5FTE7FYPV3JH3X', + account: { highThreshold: 0, signerCount: 1, recentOps: 200, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'SystemFeeAdmin', + addresses: ['GB57YDVGLL2BAVOXHPXYCZR77J4MLPLMGJKFMTUKMHFI2AEGS4SGGW7N'], + accounts: [ + { + status: 'read', + address: 'GB57YDVGLL2BAVOXHPXYCZR77J4MLPLMGJKFMTUKMHFI2AEGS4SGGW7N', + account: { highThreshold: 0, signerCount: 1, recentOps: 200, activityWindowDays: 30 }, + }, + ], + }, + ], + }, + upgrade: { + deadline: 0n, + futureWasm: '06f4207b0c9ef78cc595e075ded8fa40e73fdb8346e5f9281068a2d4ba1e5037', + runningWasm: '06f4207b0c9ef78cc595e075ded8fa40e73fdb8346e5f9281068a2d4ba1e5037', + pending: false, + stagedDiffers: false, + }, + }, + fetchedAt: 1787996162, +} satisfies AquariusRawData; diff --git a/adapters/fixtures/aquarius/constant-product-mainnet.ts b/adapters/fixtures/aquarius/constant-product-mainnet.ts new file mode 100644 index 0000000..16ac18d --- /dev/null +++ b/adapters/fixtures/aquarius/constant-product-mainnet.ts @@ -0,0 +1,243 @@ +// Frozen mainnet snapshot — generated by scripts/capture-fixture.mjs. +// +// DO NOT HAND-EDIT. Regenerate with `pnpm capture:fixture aquarius-constant-product`, then re-derive +// the expected values in adapters/snapshot.test.ts. If a factor moved, find out +// why before committing — that is the entire point of this file. +// +// Captured: 2026-08-29T09:35:43.263Z +// Not scored at capture time: this category publishes no weight table yet, so the adapter scoring methods throw by design. This fixture freezes the RAW SHAPE. +// +// `satisfies` is load-bearing: if AquariusRawData gains a required field, this file +// stops compiling rather than quietly feeding the adapter a stale shape. + +import type { AquariusRawData } from '../../aquarius/index.ts'; + +export const aquariusConstantProductMainnet = { + poolId: 'CCSY43EHJAHT3NQDYKAMJXRFBEEH7OXDL3J3VNGO33UUSEXWNN27GBIZ', + poolType: 'constant_product', + tokens: [ + 'CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', + 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', + ], + reserves: [10239677249n, 5334469300021n], + totalShares: 226434700221n, + feeFraction: 100, + protocolFeeFraction: 5000, + info: { fee: 100, pool_type: 'constant_product' }, + shareId: 'CC4BPROIXISEFC7UKTB2HYBLNSNP27WNCR7YNZOHXLTPTGDKFMKYQ2YN', + version: 200, + killed: { swap: false, deposit: false, claim: false }, + emergencyMode: false, + roles: { + status: 'read', + roles: [ + { + role: 'Admin', + addresses: ['GAV5FBMKD2ZF4X2MGWDNQYUP7KFL7MRM6HZBY7HKQLB4BRHSCCX5J6VS'], + accounts: [ + { + status: 'read', + address: 'GAV5FBMKD2ZF4X2MGWDNQYUP7KFL7MRM6HZBY7HKQLB4BRHSCCX5J6VS', + account: { highThreshold: 2, signerCount: 3, recentOps: 96, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'EmergencyAdmin', + addresses: ['GCGZ6E5RBUKLNB4VZ5RC65C4QMBSBJ3COVRRJCWAMCXJC36LB7YYWEKM'], + accounts: [ + { + status: 'read', + address: 'GCGZ6E5RBUKLNB4VZ5RC65C4QMBSBJ3COVRRJCWAMCXJC36LB7YYWEKM', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'EmergencyPauseAdmin', + addresses: ['GA6MVTGQDCJPP27IAMG6PSDTWOJYTD3NUTLR2W54ADBCBY7OID5YUDSI'], + accounts: [ + { + status: 'read', + address: 'GA6MVTGQDCJPP27IAMG6PSDTWOJYTD3NUTLR2W54ADBCBY7OID5YUDSI', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'OperationsAdmin', + addresses: ['GBVQPX2LQ55HLRMLIWBEYVVQL3SZ5RFPRKYLRSLZU4XRIWAXW2KQIMMD'], + accounts: [ + { + status: 'read', + address: 'GBVQPX2LQ55HLRMLIWBEYVVQL3SZ5RFPRKYLRSLZU4XRIWAXW2KQIMMD', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'PauseAdmin', + addresses: ['GA6MA665XVKHTQUZVSMUKUPGT7OREJNCLAZ5ZEH5CXPKYTWFJKZ3YSEK'], + accounts: [ + { + status: 'read', + address: 'GA6MA665XVKHTQUZVSMUKUPGT7OREJNCLAZ5ZEH5CXPKYTWFJKZ3YSEK', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'RewardsAdmin', + addresses: ['GCXYKA3BM574WC6TWESEDUGUJTNQ5SVCFMHWLQ634H5FTE7FYPV3JH3X'], + accounts: [ + { + status: 'read', + address: 'GCXYKA3BM574WC6TWESEDUGUJTNQ5SVCFMHWLQ634H5FTE7FYPV3JH3X', + account: { highThreshold: 0, signerCount: 1, recentOps: 200, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'SystemFeeAdmin', + addresses: ['GB57YDVGLL2BAVOXHPXYCZR77J4MLPLMGJKFMTUKMHFI2AEGS4SGGW7N'], + accounts: [ + { + status: 'read', + address: 'GB57YDVGLL2BAVOXHPXYCZR77J4MLPLMGJKFMTUKMHFI2AEGS4SGGW7N', + account: { highThreshold: 0, signerCount: 1, recentOps: 200, activityWindowDays: 30 }, + }, + ], + }, + ], + }, + upgrade: { + deadline: 0n, + futureWasm: 'ae0da5a84b15805c5c7931ac567a8d1b34be3f26b483993d9ff80cb2c3de9852', + runningWasm: 'ae0da5a84b15805c5c7931ac567a8d1b34be3f26b483993d9ff80cb2c3de9852', + pending: false, + stagedDiffers: false, + }, + reserveTokens: [ + { + address: 'CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', + isStellarAsset: true, + code: 'XLM', + decimals: 7, + symbol: 'native', + issuer: { status: 'noIssuer', asset: 'native' }, + }, + { + address: 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', + isStellarAsset: true, + code: 'AQUA', + decimals: 7, + symbol: 'AQUA', + issuer: { + status: 'read', + issuer: 'GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA', + flags: { + authRequired: false, + authRevocable: false, + authImmutable: false, + authClawbackEnabled: false, + }, + }, + }, + ], + router: { + routerId: 'CBQDHNBFBZYE4MKPWBSJOPIYLW4SFSXAXUTSXJN76GNKYVYPCKWC6QUK', + contractName: 'AMMRouter', + version: 200, + emergencyMode: false, + roles: { + status: 'read', + roles: [ + { + role: 'Admin', + addresses: ['GAV5FBMKD2ZF4X2MGWDNQYUP7KFL7MRM6HZBY7HKQLB4BRHSCCX5J6VS'], + accounts: [ + { + status: 'read', + address: 'GAV5FBMKD2ZF4X2MGWDNQYUP7KFL7MRM6HZBY7HKQLB4BRHSCCX5J6VS', + account: { highThreshold: 2, signerCount: 3, recentOps: 96, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'EmergencyAdmin', + addresses: ['GCGZ6E5RBUKLNB4VZ5RC65C4QMBSBJ3COVRRJCWAMCXJC36LB7YYWEKM'], + accounts: [ + { + status: 'read', + address: 'GCGZ6E5RBUKLNB4VZ5RC65C4QMBSBJ3COVRRJCWAMCXJC36LB7YYWEKM', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'EmergencyPauseAdmin', + addresses: ['GA6MVTGQDCJPP27IAMG6PSDTWOJYTD3NUTLR2W54ADBCBY7OID5YUDSI'], + accounts: [ + { + status: 'read', + address: 'GA6MVTGQDCJPP27IAMG6PSDTWOJYTD3NUTLR2W54ADBCBY7OID5YUDSI', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'OperationsAdmin', + addresses: ['GBVQPX2LQ55HLRMLIWBEYVVQL3SZ5RFPRKYLRSLZU4XRIWAXW2KQIMMD'], + accounts: [ + { + status: 'read', + address: 'GBVQPX2LQ55HLRMLIWBEYVVQL3SZ5RFPRKYLRSLZU4XRIWAXW2KQIMMD', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'PauseAdmin', + addresses: ['GA6MA665XVKHTQUZVSMUKUPGT7OREJNCLAZ5ZEH5CXPKYTWFJKZ3YSEK'], + accounts: [ + { + status: 'read', + address: 'GA6MA665XVKHTQUZVSMUKUPGT7OREJNCLAZ5ZEH5CXPKYTWFJKZ3YSEK', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'RewardsAdmin', + addresses: ['GCXYKA3BM574WC6TWESEDUGUJTNQ5SVCFMHWLQ634H5FTE7FYPV3JH3X'], + accounts: [ + { + status: 'read', + address: 'GCXYKA3BM574WC6TWESEDUGUJTNQ5SVCFMHWLQ634H5FTE7FYPV3JH3X', + account: { highThreshold: 0, signerCount: 1, recentOps: 200, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'SystemFeeAdmin', + addresses: ['GB57YDVGLL2BAVOXHPXYCZR77J4MLPLMGJKFMTUKMHFI2AEGS4SGGW7N'], + accounts: [ + { + status: 'read', + address: 'GB57YDVGLL2BAVOXHPXYCZR77J4MLPLMGJKFMTUKMHFI2AEGS4SGGW7N', + account: { highThreshold: 0, signerCount: 1, recentOps: 200, activityWindowDays: 30 }, + }, + ], + }, + ], + }, + upgrade: { + deadline: 0n, + futureWasm: '06f4207b0c9ef78cc595e075ded8fa40e73fdb8346e5f9281068a2d4ba1e5037', + runningWasm: '06f4207b0c9ef78cc595e075ded8fa40e73fdb8346e5f9281068a2d4ba1e5037', + pending: false, + stagedDiffers: false, + }, + }, + fetchedAt: 1787996143, +} satisfies AquariusRawData; diff --git a/adapters/fixtures/aquarius/stable-mainnet.ts b/adapters/fixtures/aquarius/stable-mainnet.ts new file mode 100644 index 0000000..26825da --- /dev/null +++ b/adapters/fixtures/aquarius/stable-mainnet.ts @@ -0,0 +1,270 @@ +// Frozen mainnet snapshot — generated by scripts/capture-fixture.mjs. +// +// DO NOT HAND-EDIT. Regenerate with `pnpm capture:fixture aquarius-stable`, then re-derive +// the expected values in adapters/snapshot.test.ts. If a factor moved, find out +// why before committing — that is the entire point of this file. +// +// Captured: 2026-08-29T09:35:54.164Z +// Not scored at capture time: this category publishes no weight table yet, so the adapter scoring methods throw by design. This fixture freezes the RAW SHAPE. +// +// `satisfies` is load-bearing: if AquariusRawData gains a required field, this file +// stops compiling rather than quietly feeding the adapter a stale shape. + +import type { AquariusRawData } from '../../aquarius/index.ts'; + +export const aquariusStableMainnet = { + poolId: 'CD6VHCKSUPGQVQPEQUI6EAEO6Z4PXMFTPHW3UTAOF7W4UF7TH7ZSKZBG', + poolType: 'stable', + tokens: [ + 'CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', + 'CDIKURWHYS4FFTR5KOQK6MBFZA2K3E26WGBQI6PXBYWZ4XIOPJHDFJKP', + 'CDOFW7HNKLUZRLFZST4EW7V3AV4JI5IHMT6BPXXSY2IEFZ4NE5TWU2P4', + ], + reserves: [985712114n, 3915160093n, 1158031301n], + totalShares: 3903527931n, + feeFraction: 15, + protocolFeeFraction: 5000, + info: { a: 1500n, fee: 15, n_tokens: 3, pool_type: 'stable' }, + shareId: 'CDXRIV6XHJWJXCFCP7CGPPQCLQSVMJ64UOOJY2XLCFAOMF6OUFEH2VDD', + version: 200, + killed: { swap: false, deposit: false, claim: false }, + emergencyMode: false, + roles: { + status: 'read', + roles: [ + { + role: 'Admin', + addresses: ['GAV5FBMKD2ZF4X2MGWDNQYUP7KFL7MRM6HZBY7HKQLB4BRHSCCX5J6VS'], + accounts: [ + { + status: 'read', + address: 'GAV5FBMKD2ZF4X2MGWDNQYUP7KFL7MRM6HZBY7HKQLB4BRHSCCX5J6VS', + account: { highThreshold: 2, signerCount: 3, recentOps: 96, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'EmergencyAdmin', + addresses: ['GCGZ6E5RBUKLNB4VZ5RC65C4QMBSBJ3COVRRJCWAMCXJC36LB7YYWEKM'], + accounts: [ + { + status: 'read', + address: 'GCGZ6E5RBUKLNB4VZ5RC65C4QMBSBJ3COVRRJCWAMCXJC36LB7YYWEKM', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'EmergencyPauseAdmin', + addresses: ['GA6MVTGQDCJPP27IAMG6PSDTWOJYTD3NUTLR2W54ADBCBY7OID5YUDSI'], + accounts: [ + { + status: 'read', + address: 'GA6MVTGQDCJPP27IAMG6PSDTWOJYTD3NUTLR2W54ADBCBY7OID5YUDSI', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'OperationsAdmin', + addresses: ['GBVQPX2LQ55HLRMLIWBEYVVQL3SZ5RFPRKYLRSLZU4XRIWAXW2KQIMMD'], + accounts: [ + { + status: 'read', + address: 'GBVQPX2LQ55HLRMLIWBEYVVQL3SZ5RFPRKYLRSLZU4XRIWAXW2KQIMMD', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'PauseAdmin', + addresses: ['GA6MA665XVKHTQUZVSMUKUPGT7OREJNCLAZ5ZEH5CXPKYTWFJKZ3YSEK'], + accounts: [ + { + status: 'read', + address: 'GA6MA665XVKHTQUZVSMUKUPGT7OREJNCLAZ5ZEH5CXPKYTWFJKZ3YSEK', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'RewardsAdmin', + addresses: ['GCXYKA3BM574WC6TWESEDUGUJTNQ5SVCFMHWLQ634H5FTE7FYPV3JH3X'], + accounts: [ + { + status: 'read', + address: 'GCXYKA3BM574WC6TWESEDUGUJTNQ5SVCFMHWLQ634H5FTE7FYPV3JH3X', + account: { highThreshold: 0, signerCount: 1, recentOps: 200, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'SystemFeeAdmin', + addresses: ['GB57YDVGLL2BAVOXHPXYCZR77J4MLPLMGJKFMTUKMHFI2AEGS4SGGW7N'], + accounts: [ + { + status: 'read', + address: 'GB57YDVGLL2BAVOXHPXYCZR77J4MLPLMGJKFMTUKMHFI2AEGS4SGGW7N', + account: { highThreshold: 0, signerCount: 1, recentOps: 200, activityWindowDays: 30 }, + }, + ], + }, + ], + }, + upgrade: { + deadline: 0n, + futureWasm: 'f1077e0b77da5e62d596e13aeae4160104cad99e7ef7f3183a6c9b6ec9e747cd', + runningWasm: 'f1077e0b77da5e62d596e13aeae4160104cad99e7ef7f3183a6c9b6ec9e747cd', + pending: false, + stagedDiffers: false, + }, + reserveTokens: [ + { + address: 'CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', + isStellarAsset: true, + code: 'USDC', + decimals: 7, + symbol: 'USDC', + issuer: { + status: 'read', + issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + flags: { + authRequired: false, + authRevocable: true, + authImmutable: false, + authClawbackEnabled: false, + }, + }, + }, + { + address: 'CDIKURWHYS4FFTR5KOQK6MBFZA2K3E26WGBQI6PXBYWZ4XIOPJHDFJKP', + isStellarAsset: true, + code: 'USDx', + decimals: 7, + symbol: 'USDx', + issuer: { + status: 'read', + issuer: 'GAVH5ZWACAY2PHPUG4FL3LHHJIYIHOFPSIUGM2KHK25CJWXHAV6QKDMN', + flags: { + authRequired: false, + authRevocable: false, + authImmutable: false, + authClawbackEnabled: false, + }, + }, + }, + { + address: 'CDOFW7HNKLUZRLFZST4EW7V3AV4JI5IHMT6BPXXSY2IEFZ4NE5TWU2P4', + isStellarAsset: true, + code: 'yUSDC', + decimals: 7, + symbol: 'yUSDC', + issuer: { + status: 'read', + issuer: 'GDGTVWSM4MGS4T7Z6W4RPWOCHE2I6RDFCIFZGS3DOA63LWQTRNZNTTFF', + flags: { + authRequired: false, + authRevocable: false, + authImmutable: false, + authClawbackEnabled: false, + }, + }, + }, + ], + router: { + routerId: 'CBQDHNBFBZYE4MKPWBSJOPIYLW4SFSXAXUTSXJN76GNKYVYPCKWC6QUK', + contractName: 'AMMRouter', + version: 200, + emergencyMode: false, + roles: { + status: 'read', + roles: [ + { + role: 'Admin', + addresses: ['GAV5FBMKD2ZF4X2MGWDNQYUP7KFL7MRM6HZBY7HKQLB4BRHSCCX5J6VS'], + accounts: [ + { + status: 'read', + address: 'GAV5FBMKD2ZF4X2MGWDNQYUP7KFL7MRM6HZBY7HKQLB4BRHSCCX5J6VS', + account: { highThreshold: 2, signerCount: 3, recentOps: 96, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'EmergencyAdmin', + addresses: ['GCGZ6E5RBUKLNB4VZ5RC65C4QMBSBJ3COVRRJCWAMCXJC36LB7YYWEKM'], + accounts: [ + { + status: 'read', + address: 'GCGZ6E5RBUKLNB4VZ5RC65C4QMBSBJ3COVRRJCWAMCXJC36LB7YYWEKM', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'EmergencyPauseAdmin', + addresses: ['GA6MVTGQDCJPP27IAMG6PSDTWOJYTD3NUTLR2W54ADBCBY7OID5YUDSI'], + accounts: [ + { + status: 'read', + address: 'GA6MVTGQDCJPP27IAMG6PSDTWOJYTD3NUTLR2W54ADBCBY7OID5YUDSI', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'OperationsAdmin', + addresses: ['GBVQPX2LQ55HLRMLIWBEYVVQL3SZ5RFPRKYLRSLZU4XRIWAXW2KQIMMD'], + accounts: [ + { + status: 'read', + address: 'GBVQPX2LQ55HLRMLIWBEYVVQL3SZ5RFPRKYLRSLZU4XRIWAXW2KQIMMD', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'PauseAdmin', + addresses: ['GA6MA665XVKHTQUZVSMUKUPGT7OREJNCLAZ5ZEH5CXPKYTWFJKZ3YSEK'], + accounts: [ + { + status: 'read', + address: 'GA6MA665XVKHTQUZVSMUKUPGT7OREJNCLAZ5ZEH5CXPKYTWFJKZ3YSEK', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'RewardsAdmin', + addresses: ['GCXYKA3BM574WC6TWESEDUGUJTNQ5SVCFMHWLQ634H5FTE7FYPV3JH3X'], + accounts: [ + { + status: 'read', + address: 'GCXYKA3BM574WC6TWESEDUGUJTNQ5SVCFMHWLQ634H5FTE7FYPV3JH3X', + account: { highThreshold: 0, signerCount: 1, recentOps: 200, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'SystemFeeAdmin', + addresses: ['GB57YDVGLL2BAVOXHPXYCZR77J4MLPLMGJKFMTUKMHFI2AEGS4SGGW7N'], + accounts: [ + { + status: 'read', + address: 'GB57YDVGLL2BAVOXHPXYCZR77J4MLPLMGJKFMTUKMHFI2AEGS4SGGW7N', + account: { highThreshold: 0, signerCount: 1, recentOps: 200, activityWindowDays: 30 }, + }, + ], + }, + ], + }, + upgrade: { + deadline: 0n, + futureWasm: '06f4207b0c9ef78cc595e075ded8fa40e73fdb8346e5f9281068a2d4ba1e5037', + runningWasm: '06f4207b0c9ef78cc595e075ded8fa40e73fdb8346e5f9281068a2d4ba1e5037', + pending: false, + stagedDiffers: false, + }, + }, + fetchedAt: 1787996154, +} satisfies AquariusRawData; diff --git a/adapters/fixtures/aquarius/wasm-token-mainnet.ts b/adapters/fixtures/aquarius/wasm-token-mainnet.ts new file mode 100644 index 0000000..ad40a6c --- /dev/null +++ b/adapters/fixtures/aquarius/wasm-token-mainnet.ts @@ -0,0 +1,243 @@ +// Frozen mainnet snapshot — generated by scripts/capture-fixture.mjs. +// +// DO NOT HAND-EDIT. Regenerate with `pnpm capture:fixture aquarius-wasm-token`, then re-derive +// the expected values in adapters/snapshot.test.ts. If a factor moved, find out +// why before committing — that is the entire point of this file. +// +// Captured: 2026-08-29T09:36:10.809Z +// Not scored at capture time: this category publishes no weight table yet, so the adapter scoring methods throw by design. This fixture freezes the RAW SHAPE. +// +// `satisfies` is load-bearing: if AquariusRawData gains a required field, this file +// stops compiling rather than quietly feeding the adapter a stale shape. + +import type { AquariusRawData } from '../../aquarius/index.ts'; + +export const aquariusWasmTokenMainnet = { + poolId: 'CA262ONRV6P2IZPFVCTQNIU5XZIPZE4RLZNSOVJUNFUWDQR6MBNKS3IB', + poolType: 'stable', + tokens: [ + 'CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', + 'CDPV3H7C3MR2R4Y4GAEJN4AXXY4LBITRRVE74VSMVCSBWISIU3Q4QTMW', + ], + reserves: [4072255n, 407328n], + totalShares: 8144723n, + feeFraction: 10, + protocolFeeFraction: 5000, + info: { a: 1500n, fee: 10, n_tokens: 2, pool_type: 'stable' }, + shareId: 'CCICZJOZMAKZOPAQ5W4BQE4557WF43XF4YI4BRTR6GCWRLURSABTMVA6', + version: 200, + killed: { swap: false, deposit: false, claim: false }, + emergencyMode: false, + roles: { + status: 'read', + roles: [ + { + role: 'Admin', + addresses: ['GAV5FBMKD2ZF4X2MGWDNQYUP7KFL7MRM6HZBY7HKQLB4BRHSCCX5J6VS'], + accounts: [ + { + status: 'read', + address: 'GAV5FBMKD2ZF4X2MGWDNQYUP7KFL7MRM6HZBY7HKQLB4BRHSCCX5J6VS', + account: { highThreshold: 2, signerCount: 3, recentOps: 96, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'EmergencyAdmin', + addresses: ['GCGZ6E5RBUKLNB4VZ5RC65C4QMBSBJ3COVRRJCWAMCXJC36LB7YYWEKM'], + accounts: [ + { + status: 'read', + address: 'GCGZ6E5RBUKLNB4VZ5RC65C4QMBSBJ3COVRRJCWAMCXJC36LB7YYWEKM', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'EmergencyPauseAdmin', + addresses: ['GA6MVTGQDCJPP27IAMG6PSDTWOJYTD3NUTLR2W54ADBCBY7OID5YUDSI'], + accounts: [ + { + status: 'read', + address: 'GA6MVTGQDCJPP27IAMG6PSDTWOJYTD3NUTLR2W54ADBCBY7OID5YUDSI', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'OperationsAdmin', + addresses: ['GBVQPX2LQ55HLRMLIWBEYVVQL3SZ5RFPRKYLRSLZU4XRIWAXW2KQIMMD'], + accounts: [ + { + status: 'read', + address: 'GBVQPX2LQ55HLRMLIWBEYVVQL3SZ5RFPRKYLRSLZU4XRIWAXW2KQIMMD', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'PauseAdmin', + addresses: ['GA6MA665XVKHTQUZVSMUKUPGT7OREJNCLAZ5ZEH5CXPKYTWFJKZ3YSEK'], + accounts: [ + { + status: 'read', + address: 'GA6MA665XVKHTQUZVSMUKUPGT7OREJNCLAZ5ZEH5CXPKYTWFJKZ3YSEK', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'RewardsAdmin', + addresses: ['GCXYKA3BM574WC6TWESEDUGUJTNQ5SVCFMHWLQ634H5FTE7FYPV3JH3X'], + accounts: [ + { + status: 'read', + address: 'GCXYKA3BM574WC6TWESEDUGUJTNQ5SVCFMHWLQ634H5FTE7FYPV3JH3X', + account: { highThreshold: 0, signerCount: 1, recentOps: 200, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'SystemFeeAdmin', + addresses: ['GB57YDVGLL2BAVOXHPXYCZR77J4MLPLMGJKFMTUKMHFI2AEGS4SGGW7N'], + accounts: [ + { + status: 'read', + address: 'GB57YDVGLL2BAVOXHPXYCZR77J4MLPLMGJKFMTUKMHFI2AEGS4SGGW7N', + account: { highThreshold: 0, signerCount: 1, recentOps: 200, activityWindowDays: 30 }, + }, + ], + }, + ], + }, + upgrade: { + deadline: 0n, + futureWasm: 'f1077e0b77da5e62d596e13aeae4160104cad99e7ef7f3183a6c9b6ec9e747cd', + runningWasm: 'f1077e0b77da5e62d596e13aeae4160104cad99e7ef7f3183a6c9b6ec9e747cd', + pending: false, + stagedDiffers: false, + }, + reserveTokens: [ + { + address: 'CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', + isStellarAsset: true, + code: 'USDC', + decimals: 7, + symbol: 'USDC', + issuer: { + status: 'read', + issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + flags: { + authRequired: false, + authRevocable: true, + authImmutable: false, + authClawbackEnabled: false, + }, + }, + }, + { + address: 'CDPV3H7C3MR2R4Y4GAEJN4AXXY4LBITRRVE74VSMVCSBWISIU3Q4QTMW', + isStellarAsset: false, + code: 'USDC', + decimals: 6, + symbol: 'USDC', + issuer: { status: 'notApplicable', reason: 'wasm-contract' }, + }, + ], + router: { + routerId: 'CBQDHNBFBZYE4MKPWBSJOPIYLW4SFSXAXUTSXJN76GNKYVYPCKWC6QUK', + contractName: 'AMMRouter', + version: 200, + emergencyMode: false, + roles: { + status: 'read', + roles: [ + { + role: 'Admin', + addresses: ['GAV5FBMKD2ZF4X2MGWDNQYUP7KFL7MRM6HZBY7HKQLB4BRHSCCX5J6VS'], + accounts: [ + { + status: 'read', + address: 'GAV5FBMKD2ZF4X2MGWDNQYUP7KFL7MRM6HZBY7HKQLB4BRHSCCX5J6VS', + account: { highThreshold: 2, signerCount: 3, recentOps: 96, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'EmergencyAdmin', + addresses: ['GCGZ6E5RBUKLNB4VZ5RC65C4QMBSBJ3COVRRJCWAMCXJC36LB7YYWEKM'], + accounts: [ + { + status: 'read', + address: 'GCGZ6E5RBUKLNB4VZ5RC65C4QMBSBJ3COVRRJCWAMCXJC36LB7YYWEKM', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'EmergencyPauseAdmin', + addresses: ['GA6MVTGQDCJPP27IAMG6PSDTWOJYTD3NUTLR2W54ADBCBY7OID5YUDSI'], + accounts: [ + { + status: 'read', + address: 'GA6MVTGQDCJPP27IAMG6PSDTWOJYTD3NUTLR2W54ADBCBY7OID5YUDSI', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'OperationsAdmin', + addresses: ['GBVQPX2LQ55HLRMLIWBEYVVQL3SZ5RFPRKYLRSLZU4XRIWAXW2KQIMMD'], + accounts: [ + { + status: 'read', + address: 'GBVQPX2LQ55HLRMLIWBEYVVQL3SZ5RFPRKYLRSLZU4XRIWAXW2KQIMMD', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'PauseAdmin', + addresses: ['GA6MA665XVKHTQUZVSMUKUPGT7OREJNCLAZ5ZEH5CXPKYTWFJKZ3YSEK'], + accounts: [ + { + status: 'read', + address: 'GA6MA665XVKHTQUZVSMUKUPGT7OREJNCLAZ5ZEH5CXPKYTWFJKZ3YSEK', + account: { highThreshold: 0, signerCount: 1, recentOps: 0, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'RewardsAdmin', + addresses: ['GCXYKA3BM574WC6TWESEDUGUJTNQ5SVCFMHWLQ634H5FTE7FYPV3JH3X'], + accounts: [ + { + status: 'read', + address: 'GCXYKA3BM574WC6TWESEDUGUJTNQ5SVCFMHWLQ634H5FTE7FYPV3JH3X', + account: { highThreshold: 0, signerCount: 1, recentOps: 200, activityWindowDays: 30 }, + }, + ], + }, + { + role: 'SystemFeeAdmin', + addresses: ['GB57YDVGLL2BAVOXHPXYCZR77J4MLPLMGJKFMTUKMHFI2AEGS4SGGW7N'], + accounts: [ + { + status: 'read', + address: 'GB57YDVGLL2BAVOXHPXYCZR77J4MLPLMGJKFMTUKMHFI2AEGS4SGGW7N', + account: { highThreshold: 0, signerCount: 1, recentOps: 200, activityWindowDays: 30 }, + }, + ], + }, + ], + }, + upgrade: { + deadline: 0n, + futureWasm: '06f4207b0c9ef78cc595e075ded8fa40e73fdb8346e5f9281068a2d4ba1e5037', + runningWasm: '06f4207b0c9ef78cc595e075ded8fa40e73fdb8346e5f9281068a2d4ba1e5037', + pending: false, + stagedDiffers: false, + }, + }, + fetchedAt: 1787996170, +} satisfies AquariusRawData; diff --git a/adapters/index.ts b/adapters/index.ts index c44081f..d3177a7 100644 --- a/adapters/index.ts +++ b/adapters/index.ts @@ -1,6 +1,12 @@ -// Protocol adapters live here, one FOLDER per protocol (blend/, kinetic/), -// each implementing the Adapter interface from @stenion/core. A folder's -// index.ts is its whole public surface; index/fetch/score/types beside it are -// internal wiring. +// Protocol adapters live here, one FOLDER per protocol (blend/, kinetic/, +// aquarius/), each implementing the Adapter interface from @stenion/core. A +// folder's index.ts is its whole public surface; index/fetch/score/types beside +// it are internal wiring. +// +// `aquarius/` is the first `dex` adapter and it FETCHES ONLY — its scoring +// methods throw, because the dex rulebook publishes no weight table yet. It is +// exported so the fixture-capture script can reach it; nothing registers it as +// an indexer target. See adapters/aquarius/index.ts and issue #101. export * from './blend/index.ts'; export * from './kinetic/index.ts'; +export * from './aquarius/index.ts'; diff --git a/architecture/deploy-architecture.md b/architecture/deploy-architecture.md index 38035f0..b5e4489 100644 --- a/architecture/deploy-architecture.md +++ b/architecture/deploy-architecture.md @@ -154,12 +154,76 @@ The worked examples: **Environment variables** (all on the one Vercel project, Production + Preview): `DATABASE_URL` (Neon pooled), `STENION_RPC_URL`, `STENION_HORIZON_URL`, `CRON_SECRET`, and optionally `STENION_ALERT_WEBHOOK_URL` (failure/recovery alerts; unset = alerting off) and -`STENION_CYCLE_CONCURRENCY` (targets in flight at once; default 2). The retry and +`STENION_CYCLE_CONCURRENCY` (targets in flight at once; **default 1** — it shipped at 2 and was +reverted the same day when the free shared public RPC started returning `429`). The retry and threshold knobs — `STENION_RETRY_ATTEMPTS`, `STENION_RETRY_BASE_DELAY_MS`, `STENION_ATTEMPT_TIMEOUT_MS`, `STENION_CYCLE_BUDGET_MS`, `STENION_ALERT_THRESHOLD` — all have defaults and only need setting to override them; every one is documented in `.env.example`. Locally, every package reads these from a single repo-root `.env` via a walk-up loader. +### RPC cost per target, measured + +**Why this section exists.** `STENION_CYCLE_CONCURRENCY` is at 1 because an estimate computed from +developer-machine timings got the request _rate_ wrong and drew `429`s from the free shared public +RPC on the day it shipped at 2. So a new adapter's cost is counted in **requests**, which is +machine-independent, before anything is said about seconds. + +Counted by instrumenting `globalThis.fetch` around one `fetchRawData()` per adapter, 2026-08-29: + +| Target | Soroban RPC | Horizon | Total requests | +| ----------------------------- | --------------------------------------- | ------- | -------------- | +| Blend Fixed V2 | 14 | 2 | **16** | +| Kinetic (K2) | 27 | 0 | **27** | +| Aquarius, 2-token pool | 22 (18 simulate + 4 `getLedgerEntries`) | 15 | **37** | +| Aquarius, 3-token stable pool | 23 (18 simulate + 5 `getLedgerEntries`) | 17 | **40** | + +**An Aquarius pool costs roughly 2.3x a Blend pool in requests, and the shape is not what was +predicted.** Issue #101 estimated "~25 simulate calls and ~9 Horizon requests", where the simulate +count was dominated by `estimate_swap` probes. The simulate count landed at 18 with **no depth +simulation at all** — `depthSafety` was deferred by question A in `methodology/dex.md`, so +`estimate_swap` is never called — and the cost moved to **Horizon instead**, which the estimate had +low by a factor of ~1.7. + +**The `getLedgerEntries` count is low because one read answers a lot.** A contract's _instance_ +entry carries 31 (router) to 40 (concentrated pool) storage entries in a single ledger read — admin +roles, fee, reserves, `UpgradeDeadline`, `FutureWASM` — so the adapter reads it once per contract +and takes everything from it rather than fetching keys individually. An earlier version additionally +probed eight speculative ledger keys per contract for the upgrade fields; those were removed once +the fields were found where they had been all along, which is worth **2 RPC calls per pool**. + +**The Horizon cost is where it is because of the seven roles, and it is two requests each, not +one.** `adminKeySafety` reads every privileged account's thresholds/signers _and_ its recent +operations — `/accounts/{G…}` plus `/accounts/{G…}/operations` — so seven roles is 14 requests +before a single token is looked at. The adapter already caches accounts by address for the duration +of one fetch, which is what stops the router's roles and the pool's roles being read twice; without +it the figure would be 28. + +> **The registry is already at the feasibility ceiling, and one Aquarius pool breaks it.** +> `cycleFeasibility()` checks `ceil(targets / concurrency) * ATTEMPT_TIMEOUT_MS <= CYCLE_BUDGET_MS`. +> At today's defaults (`ATTEMPT_TIMEOUT_MS` 10,000, `CYCLE_BUDGET_MS` 42,000, concurrency **1**) the +> four current targets need `4 x 10,000 = 40,000ms` against a 42,000ms budget — **2 seconds of +> headroom**. A fifth target needs 50,000ms and the cycle is infeasible: +> +> | Targets | Concurrency | Waves | Required | Verdict | +> | --------------- | ----------- | ----- | -------- | ---------------- | +> | 4 (today) | 1 | 4 | 40,000ms | feasible, barely | +> | 5 (+1 Aquarius) | 1 | 5 | 50,000ms | **infeasible** | +> | 5 (+1 Aquarius) | 2 | 3 | 30,000ms | feasible | +> | 8 (+4 Aquarius) | 1 | 8 | 80,000ms | **infeasible** | +> | 8 (+4 Aquarius) | 2 | 4 | 40,000ms | feasible | +> +> So registering **any** Aquarius pool requires either raising concurrency — which is the change +> that drew `429`s and was reverted — or lowering `ATTEMPT_TIMEOUT_MS`, or raising the budget +> against the 60s `maxDuration` ceiling. **That is a deployment decision, not an adapter one**, and +> it is why no Aquarius pool is registered. It has to be settled before registration, not +> discovered by a cycle that fails. + +**Wall-clock is recorded but is NOT the claim.** The same run measured 15.4s / 12.5s per Aquarius +pool against 6.6s for Blend and 9.2s for Kinetic — from a developer machine in Nigeria against the +public endpoint, which is exactly the measurement CLAUDE.md forbids making an RPC-load claim from. +The _ratio_ between adapters on one machine in one session is the usable part; the absolute numbers +are not, and a deployed per-target `durationMs` is still owed before registration. + ### Caching and rate limits The public API had neither, deliberately, until it was deployed and about to be pitched to wallet diff --git a/architecture/monorepo-layout.md b/architecture/monorepo-layout.md index 7633e3e..66e8882 100644 --- a/architecture/monorepo-layout.md +++ b/architecture/monorepo-layout.md @@ -5,7 +5,7 @@ package; the adapters import `@stenion/core`'s `Adapter` interface as a real typ ``` /core — @stenion/core Adapter interface + RiskFactorType taxonomy + shared types -/adapters — @stenion/adapters one folder per protocol (blend/, kinetic/), each an Adapter +/adapters — @stenion/adapters one folder per protocol (blend/, kinetic/, aquarius/), each an Adapter /db — @stenion/db Postgres layer: pg pool, typed Store, raw-SQL migrations /indexer — @stenion/indexer scheduler that runs adapters on an interval, writes to Postgres /api — @stenion/api standalone REST server (legacy — see "Why @stenion/api exists") @@ -72,13 +72,22 @@ read `LENDING_FACTORS`, `core/src/scoring.test.ts` pins that declaration against published weight table, and both adapter suites pin themselves against the declaration — so the chain runs adapter → core → the public rulebook with no hand-written copy in it. -**`@stenion/adapters`** — one folder per protocol (`blend/`, `kinetic/`), each holding an -`index.ts` that exports the `Adapter` class and is the folder's whole public surface, plus the +**`@stenion/adapters`** — one folder per protocol (`blend/`, `kinetic/`, `aquarius/`), each holding +an `index.ts` that exports the `Adapter` class and is the folder's whole public surface, plus the `fetch.ts` / `score.ts` / `types.ts` it is assembled from. An adapter -reads a protocol's on-chain state (Soroban RPC + Horizon), reduces it into the five `*Safety` -factors using the formulas in `methodology/lending.md`, and produces a weighted `safetyScore`. -Currently -`BlendAdapter` and `KineticAdapter`. Adapters throw on failure; they never swallow errors. +reads a protocol's on-chain state (Soroban RPC + Horizon), reduces it into its category's `*Safety` +factors using the formulas in that category's `methodology/` file, and produces a weighted +`safetyScore`. Adapters throw on failure; they never swallow errors. + +**`AquariusAdapter` is the exception that proves the shape, and it is deliberate.** It is the first +`dex` adapter and it **fetches without scoring**: `computeRiskFactors`, `score` and +`operationalState` throw, because the `dex` rulebook publishes no weight table yet +(`methodology/dex.md`, "Factor weights"). It has no `score.ts` — that file arrives with the scoring +implementation rather than existing now as a file full of throws — and **no pool is registered to +any target list**, so nothing in the indexer can reach it. It is exported so the fixture-capture +script can. Returning a plausible factor map to satisfy the interface would have made an unscorable +category indistinguishable from a working one at the indexer's call site, which is the failure the +two-step category admission exists to prevent. **One adapter can serve several markets.** `BlendAdapter` takes a `BlendPool` — slug, display name, pool contract, mark, links, deployment label — and the module exports `BLEND_POOLS`, the list the diff --git a/scripts/capture-fixture.mjs b/scripts/capture-fixture.mjs index 5826e14..768ac7e 100644 --- a/scripts/capture-fixture.mjs +++ b/scripts/capture-fixture.mjs @@ -2,7 +2,7 @@ /* global console, process, URL */ // // Capture a live mainnet snapshot of an adapter's raw on-chain state, for use as -// a frozen regression fixture (adapters/fixtures/*.json). +// a frozen regression fixture (adapters/fixtures/**/*.ts). // // This is a MANUAL tool. It hits Soroban RPC and Horizon, so it is never run by // CI and is not part of `pnpm test` — the fixtures it produces are committed and @@ -20,6 +20,7 @@ // pnpm capture:fixture kinetic // pnpm capture:fixture yieldblox // pnpm capture:fixture etherfuse +// pnpm capture:fixture aquarius (all four Aquarius pools) // pnpm capture:fixture all // // `blend`, `yieldblox` and `etherfuse` are three POOLS behind one adapter, not @@ -30,6 +31,13 @@ // a decode regression that the three tidy Fixed reserves happen to survive shows // up in YieldBlox's eight. // +// NOT EVERY ADAPTER SCORES. AquariusAdapter fetches and stops there — the dex +// rulebook publishes no weight table yet (#101/#102), so its computeRiskFactors +// and score throw by design. Targets carry `scorable`, and an unscorable one +// captures the raw shape and skips the factor summary rather than being excluded +// from fixtures entirely: the raw shape is exactly what needs freezing while the +// decode work is fresh, and it is what #103 will score against. +// // Requires the workspace to be built (`pnpm --filter @stenion/adapters build`) // and STENION_RPC_URL / STENION_HORIZON_URL in the repo-root .env or the shell. @@ -91,8 +99,9 @@ function toLiteral(value) { async function main() { const which = (process.argv[2] ?? '').toLowerCase(); - if (!['blend', 'kinetic', 'yieldblox', 'etherfuse', 'all'].includes(which)) { - console.error('Usage: pnpm capture:fixture '); + const VALID = ['blend', 'kinetic', 'yieldblox', 'etherfuse', 'aquarius', 'all']; + if (!VALID.includes(which)) { + console.error(`Usage: pnpm capture:fixture <${VALID.join('|')}>`); process.exitCode = 2; return; } @@ -120,25 +129,116 @@ async function main() { blend: { type: 'BlendRawData', module: 'blend', + scorable: true, make: () => new mod.BlendAdapter({ ...opts, pool: mod.BLEND_FIXED_V2 }), }, kinetic: { type: 'KineticRawData', module: 'kinetic', + scorable: true, make: () => new mod.KineticAdapter(opts), }, yieldblox: { type: 'BlendRawData', module: 'blend', + scorable: true, make: () => new mod.BlendAdapter({ ...opts, pool: mod.BLEND_YIELDBLOX_V2 }), }, etherfuse: { type: 'BlendRawData', module: 'blend', + scorable: true, make: () => new mod.BlendAdapter({ ...opts, pool: mod.BLEND_ETHERFUSE_V2 }), }, + + // ---- Aquarius (dex) ---------------------------------------------------- + // + // FOUR POOLS BEHIND ONE ADAPTER, chosen to exercise every branch the fetch + // layer has, because live Aquarius state is otherwise monotonous — all 340 + // pools share one admin posture, so a single fixture would freeze almost + // nothing. `scorable: false` on all four: the dex rulebook has no weight + // table, so AquariusAdapter's scoring methods throw by design (#101). + // + // constant-product two SAC tokens, real reserves, 100 bps + // stable THREE tokens — the no-hardcoded-pair rule, exercised + // rather than asserted (3 of 304 token sets are these) + // concentrated where get_reserves() means the total across all tick + // ranges rather than the tradable balance + // wasm-token holds a non-SAC token, the only branch that produces a + // route-(a) `notApplicable` issuer disclosure + 'aquarius-constant-product': { + type: 'AquariusRawData', + module: 'aquarius', + dir: 'aquarius', + scorable: false, + make: () => + new mod.AquariusAdapter({ + ...opts, + pool: { + id: 'aquarius-xlm-aqua', + name: 'Aquarius XLM/AQUA', + poolId: 'CCSY43EHJAHT3NQDYKAMJXRFBEEH7OXDL3J3VNGO33UUSEXWNN27GBIZ', + }, + }), + }, + 'aquarius-stable': { + type: 'AquariusRawData', + module: 'aquarius', + dir: 'aquarius', + scorable: false, + make: () => + new mod.AquariusAdapter({ + ...opts, + pool: { + id: 'aquarius-stable-3', + name: 'Aquarius 3-token stable', + poolId: 'CD6VHCKSUPGQVQPEQUI6EAEO6Z4PXMFTPHW3UTAOF7W4UF7TH7ZSKZBG', + }, + }), + }, + 'aquarius-concentrated': { + type: 'AquariusRawData', + module: 'aquarius', + dir: 'aquarius', + scorable: false, + make: () => + new mod.AquariusAdapter({ + ...opts, + pool: { + id: 'aquarius-concentrated', + name: 'Aquarius XLM/AQUA concentrated', + poolId: 'CA4HTZNY2RBZWEQE5GBMNREZMFRPAZSVJ6OGPC7T3VM7NHRJYFAVID2S', + }, + }), + }, + 'aquarius-wasm-token': { + type: 'AquariusRawData', + module: 'aquarius', + dir: 'aquarius', + scorable: false, + make: () => + new mod.AquariusAdapter({ + ...opts, + pool: { + id: 'aquarius-wasm-token', + name: 'Aquarius pool holding a wasm token', + poolId: 'CA262ONRV6P2IZPFVCTQNIU5XZIPZE4RLZNSOVJUNFUWDQR6MBNKS3IB', + }, + }), + }, }; - const names = which === 'all' ? ['blend', 'kinetic', 'yieldblox', 'etherfuse'] : [which]; + const AQUARIUS = [ + 'aquarius-constant-product', + 'aquarius-stable', + 'aquarius-concentrated', + 'aquarius-wasm-token', + ]; + const names = + which === 'all' + ? ['blend', 'kinetic', 'yieldblox', 'etherfuse', ...AQUARIUS] + : which === 'aquarius' + ? AQUARIUS + : [which]; mkdirSync(FIXTURE_DIR, { recursive: true }); @@ -149,16 +249,31 @@ async function main() { `Capturing ${name} (${adapter.metadata.contractId}) from ` + `${opts.rpcUrl ?? '(adapter default RPC)'} …`, ); + const started = Date.now(); const raw = await adapter.fetchRawData(); + const fetchMs = Date.now() - started; - const factors = await adapter.computeRiskFactors(raw); - const score = adapter.score(factors).score; + // An adapter with no rulebook behind it cannot be asked for a number. See + // the header: `scorable: false` is a property of the CATEGORY's rulebook, + // not of this pool. + const factors = target.scorable ? await adapter.computeRiskFactors(raw) : null; + const score = factors === null ? null : adapter.score(factors).score; // The captured-at stamp is metadata only — nothing reads it at test time. // `fetchedAt` *inside* the raw data is what price ages are measured // against, and freezing that is what keeps the freshness score stable. const typeName = target.type; - const constName = `${name}Mainnet`; + const constName = `${name.replace(/-([a-z])/g, (_, c) => c.toUpperCase())}Mainnet`; + // A target may nest its fixtures in a subfolder — `aquarius/` holds four, + // which is enough to be worth grouping. The folder already says which + // adapter they belong to, so the filename drops the redundant prefix while + // the EXPORTED CONST keeps it: `aquariusStableMainnet` has to stay + // unambiguous at an import site, where the folder is not visible. + const outDir = target.dir ? resolve(FIXTURE_DIR, target.dir) : FIXTURE_DIR; + const fileBase = + target.dir && name.startsWith(`${target.dir}-`) ? name.slice(target.dir.length + 1) : name; + // ../ per level from the fixture back up to adapters/. + const upToAdapters = target.dir ? '../../' : '../'; const source = `// Frozen mainnet snapshot — generated by scripts/capture-fixture.mjs. // // DO NOT HAND-EDIT. Regenerate with \`pnpm capture:fixture ${name}\`, then re-derive @@ -166,24 +281,34 @@ async function main() { // why before committing — that is the entire point of this file. // // Captured: ${new Date().toISOString()} -// At capture time this scored: safetyScore ${score} (${Object.entries(factors) - .map(([k, f]) => `${k} ${f === null ? 'null' : f.value}`) - .join(', ')}) +// ${ + factors === null + ? 'Not scored at capture time: this category publishes no weight table yet, so ' + + 'the adapter scoring methods throw by design. This fixture freezes the RAW SHAPE.' + : `At capture time this scored: safetyScore ${score} (${Object.entries(factors) + .map(([k, f]) => `${k} ${f === null ? 'null' : f.value}`) + .join(', ')})` + } // // \`satisfies\` is load-bearing: if ${typeName} gains a required field, this file // stops compiling rather than quietly feeding the adapter a stale shape. -import type { ${typeName} } from '../${target.module}.ts'; +import type { ${typeName} } from '${upToAdapters}${target.module}/index.ts'; export const ${constName} = ${toLiteral(raw)} satisfies ${typeName}; `; - const file = resolve(FIXTURE_DIR, `${name}-mainnet.ts`); + mkdirSync(outDir, { recursive: true }); + const file = resolve(outDir, `${fileBase}-mainnet.ts`); writeFileSync(file, source, 'utf8'); console.log(` → ${file}`); - console.log(` reserves: ${raw.reserves.length}, safetyScore: ${score}`); - for (const [key, f] of Object.entries(factors)) { + // `reserves` is Blend/Kinetic's word for it; Aquarius calls the same thing + // reserves too, but nothing here may assume a field that a future adapter + // has no equivalent of. + const size = Array.isArray(raw.reserves) ? `reserves: ${raw.reserves.length}, ` : ''; + console.log(` ${size}fetch: ${fetchMs}ms, safetyScore: ${score ?? 'n/a (not scorable)'}`); + for (const [key, f] of Object.entries(factors ?? {})) { console.log(` ${key.padEnd(19)} ${f === null ? 'null' : f.value}`); } console.log(' (run `pnpm format` to normalize the generated file)');