diff --git a/.env.example b/.env.example index 42d529d..9355af9 100644 --- a/.env.example +++ b/.env.example @@ -69,8 +69,9 @@ CRON_SECRET=replace-with-a-long-random-string # can leave one protocol scored and another neither scored nor recorded as failed. # Must leave room for cold start, pool connect, upserts, streak queries and the alert # POST. Raise it only against observed cycle durations, never by arithmetic alone. -# 50s, raised from 42s in #104 against three curl'ed deployed cycles (totalMs 15.5-16.8s, -# HTTP wall 17.3-17.7s, so ~1.0-1.7s of overhead beyond the cycle). That makes the +# 50s, raised from 42s to fit the fifth target, against three curl'ed deployed +# cycles (totalMs 15.5-16.8s, HTTP wall 17.3-17.7s, so ~1.0-1.7s of overhead beyond +# the cycle). That makes the # five-target registry feasible — 5 x 10s attempts = 50s <= 50s — while the worst-case # function wall stays ~52s against the 60s ceiling. A SIXTH target would need 60s, which # IS the ceiling, so the budget cannot buy one: that needs a lower attempt timeout or diff --git a/CLAUDE.md b/CLAUDE.md index d7f0d03..69be23e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,8 @@ These override any default behavior and are enforced in code and review: static Findings (hand-written, reviewed in a PR); and **live ungraded state** — measured every cycle, published as a typed field, never graded. The third exists because pause/frozen state is real, changing, on-chain data that changes how a score should be read while nothing on chain - lets us grade it (`OperationalState`, `core/src/operational-state.ts`, decided in #15). Its rule: + lets us grade it (`OperationalState`, `core/src/operational-state.ts`; the reasoning is in + `methodology/publishing-rules.md`). Its rule: a live ungraded field must be **published beside the score wherever the score appears**, or the decision not to grade it becomes a decision to hide it. Nothing in that category may be reachable from `scoreFactors` — the adapters' `computeRiskFactors` must produce a byte-identical factor map @@ -160,8 +161,8 @@ These override any default behavior and are enforced in code and review: moment anyone reads it. `weights.test.ts` asserts the absence at runtime and `scoring.test.ts` asserts such a category publishes **no** weight table in `methodology/`, so the two cannot drift into agreeing on a number nobody reviewed. It is a state to leave, not to live in — `dex` passed - through it (#100 → #102) and **no category is in it today**; it is kept for the next one admitted - the same way. + through it — admitted as a factor set, weighted in a later review — and **no category is in it + today**; it is kept for the next one admitted the same way. - **An adapter is a FOLDER of four files, and only `index.ts` is API.** `adapters//` holds `types.ts` (mainnet wiring, constants, raw on-chain shape, options), `fetch.ts` (everything touching RPC/Horizon, plus decoders, behind one `fetch*` entry point), `score.ts` (the five @@ -186,22 +187,22 @@ These override any default behavior and are enforced in code and review: runs adapters through the `toTarget()` wrapper (see [`indexer/src/index.ts`](indexer/src/index.ts)) so a heterogeneous adapter list shares one typed run loop. `core/src/adapter.ts` carries `ADAPTER_INTERFACE_VERSION` — bump it for breaking interface changes rather than rewriting - every adapter at once. It is at **4**: v2 added the required `operationalState(raw)` method - (#15), v3 the required `metadata.category` field and the `TCategory` parameter that scopes - `operationalState`'s vocabulary to it (#76), v4 made the factor map a function of that same - parameter (#104). All required rather than optional, deliberately — an optional member is one + every adapter at once. It is at **4**: v2 added the required `operationalState(raw)` method, + v3 the required `metadata.category` field and the `TCategory` parameter that scopes + `operationalState`'s vocabulary to it, and v4 made the factor map a function of that same + parameter. All required rather than optional, deliberately — an optional member is one every future adapter can skip, which is the retrofit debt the constant exists to make visible. - **An adapter declares its category; it does NOT declare its factor map.** `computeRiskFactors`/`score` speak `FactorMapFor`, derived from `CATEGORY_FACTORS`, so an adapter that declares `'dex'` owes exactly the factors `methodology/dex.md` publishes and cannot return lending's five or a key no rulebook has. This replaces the defaulted `TFactors` - parameter #103 added under deadline and flagged as unreviewed: the review (#104) found the + parameter added under deadline and flagged as unreviewed: the review found the parameter was never tied to `TCategory`, so `Adapter` compiled. It is - now a settled decision with the alternatives and the two corrected #103 claims recorded in + now a settled decision, with the alternatives and the two claims it corrects recorded in [`architecture/monorepo-layout.md`](architecture/monorepo-layout.md), and guarded by `@ts-expect-error` probes in `core/src/weights.test.ts`. - **Precision at the adapter boundary, one open `FactorMap` from the indexer onward.** That split is - the answer #103 left open. `IndexTarget.run`, `RunRecord`, `HistoryEntry` and `ProtocolDetail` all + the answer that review left open. `IndexTarget.run`, `RunRecord`, `HistoryEntry` and `ProtocolDetail` all carry `FactorMap` — storage and transport enforce no rulebook and must not claim a key set they are not the source of. `getProtocolDetail`'s read-side cast asserts only the non-null the `risk_scores_shape` CHECK guarantees; it used to assert lending's five keys, which was a lie about @@ -271,7 +272,7 @@ computed per response from the body rather than being a constant. > bounded worker pool (`STENION_CYCLE_CONCURRENCY`, default 2) and each gets the end of the budget > less one full attempt reserved per queued wave (`targetDeadline`). A rule where a target's > deadline shrinks as the registry grows can fail protocols that already work — that is what -> `budgetMs / targetCount` did, and #68 removed it. The replacement's ceiling is the explicit +> `budgetMs / targetCount` did, and it was removed. The replacement's ceiling is the explicit > condition `ceil(targets / concurrency) * attemptTimeoutMs <= budgetMs`, checked by > `cycleFeasibility()` and warned about every cycle, never discovered by adding a pool. > diff --git a/ROADMAP.md b/ROADMAP.md index ba42e3c..45f5bba 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -25,7 +25,7 @@ commitment — priorities shift as protocols launch and as the project finds fun unregistered for want of a target slot, and say so on the registry. See "Beyond lending" below. Same label, same adapter, third pool; a config entry and no new scoring code. - **Four scored markets, not five.** The Blend V2 pool investigation (#65) found five unregistered + **Four scored markets, not five.** The Blend V2 pool investigation found five unregistered pools clearing the market-size floor and registered exactly one of them. The other four are published as **assessed-and-not-scored** coverage entries because their oracles fail the oracle-legibility precondition — see "Four Blend V2 markets" under _Protocols investigated and @@ -33,7 +33,7 @@ commitment — priorities shift as protocols launch and as the project finds fun - **Operational state, published beside every score and deliberately not scored.** Both adapters had always read a pause/frozen signal (Blend's `PoolConfig.status`, K2's `router.is_paused()`) - and neither had ever used it. Resolved in #15 as a decision **not** to grade it: nothing on chain + and neither had ever used it. Resolved as a decision **not** to grade it: nothing on chain separates an admin freezing a pool to contain a threat from an admin abandoning it, and the two protocols' restricted states are not even the same shape — Blend never blocks a withdrawal at any of its seven statuses, while K2's pause blocks withdrawals, repayments and liquidations alike. So @@ -134,8 +134,8 @@ commitment — priorities shift as protocols launch and as the project finds fun that work was the footnote at the bottom of this file, which no visitor reads: absence told a reader nothing, so someone searching for a protocol learned only that it wasn't there. Four entries at launch — Templar, K2's two sub-floor markets, and Nectar — each backed by an - investigation actually recorded in this repo. **Eight today**: #69 added the four Blend V2 markets - whose oracles cannot be graded (Orbit, Forex, Spectra PTs, Solv), under a status of their own + investigation actually recorded in this repo. **Eight today**: the oracle-legibility precondition + added the four Blend V2 markets whose oracles cannot be graded (Orbit, Forex, Spectra PTs, Solv), under a status of their own rather than folded into the size floor, because their exclusion has nothing to do with their size. Three properties are load-bearing. **Nothing in the section renders a numeral**, so "not scored" @@ -250,7 +250,7 @@ adds a market**, so it is first in fact and not only in presentation. | Dial | Where it is | Why it cannot move | | ---------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | - | `STENION_CYCLE_BUDGET_MS` | 50,000 | Raised from 42,000 in #104 against three `curl`ed deployed cycles. 60,000 is the `maxDuration` ceiling itself, so the next step up is the cliff. | + | `STENION_CYCLE_BUDGET_MS` | 50,000 | Raised from 42,000 against three `curl`ed deployed cycles. 60,000 is the `maxDuration` ceiling itself, so the next step up is the cliff. | | `STENION_CYCLE_CONCURRENCY` | 1 | Shipped at 2 and reverted the same day when `mainnet.sorobanrpc.com` — free, shared, keyless — started refusing the target behind the burst. Raising it again needs a **deployed** RPC-tolerance measurement, never an estimate. | | `STENION_ATTEMPT_TIMEOUT_MS` | 10,000 | Already lowered 15s → 10s to buy the fourth target. The registry's most expensive attempt (an Aquarius pool: 37 requests against Blend's 16) sits at an estimated 5.5–8.7s deployed, so a lower cap would time out a healthy target on a slow day. | @@ -371,7 +371,7 @@ adds a market**, so it is first in fact and not only in presentation. already there, but the API exposes only `safetyScore` per history point. Charting a single factor over time — watching `oracleSafety` sawtooth on its own axis — is deliberately deferred until the window question above is settled, because it multiplies the same payload by five. -- **Concurrent targets within a cycle — DONE (#68).** The indexer no longer runs targets +- **Concurrent targets within a cycle — DONE.** The indexer no longer runs targets sequentially and no longer divides one budget between them. `STENION_CYCLE_CONCURRENCY` targets (default 2) run through a bounded worker pool, and each target's deadline is the end of `STENION_CYCLE_BUDGET_MS` minus one full attempt reserved for each wave still queued behind it. @@ -401,8 +401,8 @@ adds a market**, so it is first in fact and not only in presentation. than discovered by someone adding a pool. Past it, behaviour degrades to whole attempts first-come with the tail failing cleanly and visibly on `/api/v1/health`, not a squeeze for everyone. - **That ceiling has since been reached.** #104 registered the fifth target and raised the budget - 42s → 50s to fit it, which was the last raise available under the 60s `maxDuration`. The registry + **That ceiling has since been reached.** Registering the fifth target — the first dex market — + raised the budget 42s → 50s to fit it, which was the last raise available under the 60s `maxDuration`. The registry is now full — see **the blocker at the top of this section**, which is where the exit from it is tracked. @@ -504,8 +504,8 @@ adds a market**, so it is first in fact and not only in presentation. withdrawals don't mean anything for an AMM. Scoring other categories means designing a taxonomy that fits how each one actually fails, not stretching the lending model over them: - **DEXs / AMMs** (Soroswap, Phoenix, Aquarius) — **SHIPPED for Aquarius: rulebook complete - (#100 admitted the factor set, #102 reviewed the weight table) and one market registered and - scored (#104).** See [`methodology/dex.md`](methodology/dex.md). The `dex` category is + (one review admitted the factor set, a second reviewed the weight table) and one market + registered and scored.** See [`methodology/dex.md`](methodology/dex.md). The `dex` category is versioned at 1 on its own counter and published in full — **two** factors: `adminKeySafety` at **0.55** (seven named roles plus the two-step upgrade deadline, sharing lending's factor key deliberately) and `assetControlSafety` at **0.45** (whether a SAC issuer can freeze or claw @@ -517,8 +517,7 @@ adds a market**, so it is first in fact and not only in presentation. pool (`CA6PUJLB…`), scoring **24** — `adminKeySafety` 10, `assetControlSafety` 40 — and carrying `category: 'dex'` and `methodology_version: 1` on every run. It renders as its own ranked block on the registry, numbered 01 of dex, because a position numeral is scoped to one - rulebook (#78) and this is the first time that rule has had two categories to enforce it - against. + rulebook, and this is the first time that rule has had two categories to enforce it against. **What is NOT covered: the other 339 Aquarius pools, and every other DEX.** The census, re-read at ledger 64,182,824 on 2026-08-29, is 340 pools across 304 token sets — 272 constant-product, @@ -538,15 +537,15 @@ adds a market**, so it is first in fact and not only in presentation. against are in [`architecture/deploy-architecture.md`](architecture/deploy-architecture.md). - **The `Adapter` interface change #103 left unreviewed has been reviewed, and revised.** #103 + **The `Adapter` interface change that shipped unreviewed has been reviewed, and revised.** It added a third type parameter, `TFactors`, to make the first non-lending adapter compile at all, - and flagged the decision as unreviewed. #104 found the parameter was never tied to `TCategory` + and flagged the decision as unreviewed. The review found the parameter was never tied to `TCategory` — `Adapter` compiled, so a dex adapter could publish lending's five factors, and so could one with invented keys. The factor map is now **derived** from the category, `FactorMapFor`, read straight out of `CATEGORY_FACTORS`, so an adapter cannot disagree with its own rulebook. `ADAPTER_INTERFACE_VERSION` is **4**. The four frozen lending snapshots and both lending adapters are byte-identical either side of it. Decision - record, with the two #103 claims it corrects, in + record, with the two claims it corrects, in [`architecture/monorepo-layout.md`](architecture/monorepo-layout.md). **`@stenion/db` now carries any category's factor map.** `RunRecord.factors`, @@ -663,9 +662,8 @@ part of the discipline, not a failure. Four notable cases: change here:** an oracle that starts publishing the two parameters makes its pool scorable under the existing rulebook, and the work is a `BLEND_POOLS` entry plus a deleted coverage entry in one PR. Etherfuse (`CDMAVJPF…`) was the fifth of the five and is _not_ on this list — it runs an - aggregator, so it is scored, and [#65](https://github.com/stenion-lab/stenion/issues/65) - **registered it**: it is a ranked entry above, and the pool investigation is closed at one market - registered and four published here. + aggregator, so it is scored, and it **is registered**: it is a ranked entry above, and the pool + investigation is closed at one market registered and four published here. **The curation question that investigation raised is still open, and this pass did not close it.** Three of the five are tiny — read on 2026-08-26, Solv held $175.69, Spectra PTs $9.88, and Forex diff --git a/TAXONOMY.md b/TAXONOMY.md index edc2a22..decf193 100644 --- a/TAXONOMY.md +++ b/TAXONOMY.md @@ -138,7 +138,8 @@ the top.** A minimum over an empty set is `0`, not `100`. [market-size floor](methodology/lending.md#the-market-size-floor), as `below-size-floor`. Neither renders a numeral. - **(c)** `operationalState` ([`core/src/operational-state.ts`](core/src/operational-state.ts), - decided in #15): which user operations a market's contracts are currently refusing, published + the decision recorded in `methodology/publishing-rules.md`): which user operations a market's + contracts are currently refusing, published beside the name and score everywhere either appears. Both adapter suites assert a **byte-identical factor map** across every restricted state their protocol can be in, so route (c) cannot leak into a score. @@ -257,7 +258,7 @@ is comparable only with one produced by the same rulebook — each category is s factors under its own weights, so the same numeral in two categories is two different measurements wearing the same digits. -**Lending as the worked example — the enforcement is structural, not a rendering habit** (#78): +**Lending as the worked example — the enforcement is structural, not a rendering habit:** - [`dashboard/app/lib/registry-query.ts`](dashboard/app/lib/registry-query.ts)'s `buildRegistryView` publishes **`RankedCategoryGroup[]` and no flat ranked array**. There is nowhere for a @@ -294,7 +295,7 @@ second table, or a dashboard constant. **Lending as the worked example.** The chain runs adapter → `CATEGORY_FACTORS` → the published rulebook, with no hand-written copy in it. Adapters read `LENDING_FACTORS..weight` and never -a literal (#77). `scoring.test.ts` then parses `methodology/.md` rather than restating +a literal. `scoring.test.ts` then parses `methodology/.md` rather than restating it: it locates a category's section by `## ${CATEGORY_FACTORS[category].label}` — the heading text comes from the code, so the document and the code cannot disagree about what the section is called — and pulls the @@ -333,7 +334,7 @@ stored rows under new rules. **Lending as the worked example.** `METHODOLOGY_VERSIONS` in [`core/src/category.ts`](core/src/category.ts) is a `Record` — a category -added to `PROTOCOL_CATEGORIES` is a compile error until it has an entry (#76), the same shape +added to `PROTOCOL_CATEGORIES` is a compile error until it has an entry, the same shape `CATEGORY_FACTORS` and `CATEGORY_OPERATIONS` use. Counters are independent and each starts at 1, so **the integer alone does not identify a rulebook**: the indexer stamps `risk_scores.category` beside `risk_scores.methodology_version`, resolved from the target's own category, and an adapter has no say diff --git a/adapters/aquarius/fetch.test.ts b/adapters/aquarius/fetch.test.ts index d617660..c7593aa 100644 --- a/adapters/aquarius/fetch.test.ts +++ b/adapters/aquarius/fetch.test.ts @@ -36,9 +36,9 @@ import { aquariusWasmTokenMainnet } from '../fixtures/aquarius/wasm-token-mainne 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 + // `constant_product`, NOT `standard`. Aquarius's documentation and this + // adapter's own design notes 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); diff --git a/adapters/aquarius/index.ts b/adapters/aquarius/index.ts index fca0013..a7f9036 100644 --- a/adapters/aquarius/index.ts +++ b/adapters/aquarius/index.ts @@ -4,14 +4,14 @@ // and that extra is internal wiring rather than API. // // THE FIRST NON-LENDING ADAPTER. It implements `Adapter` -// — the same two parameters both lending adapters take, because since #104 the -// factor map is DERIVED from the category rather than named here. Declaring +// — the same two parameters both lending adapters take, because the factor map +// is DERIVED from the category rather than named here. Declaring // `'dex'` is what makes `computeRiskFactors` owe exactly `adminKeySafety` and // `assetControlSafety`; there is no third parameter to get wrong, which is the // whole point of the revision (see `Adapter` in `core/src/adapter.ts`). // -// REGISTERED SINCE #104, through `AQUARIUS_POOLS` in ./types.ts — one entry, the -// XLM/USDC constant-product pool. The indexer iterates that list exactly as it +// REGISTERED, through `AQUARIUS_POOLS` in ./types.ts — one entry, the XLM/USDC +// constant-product pool. The indexer iterates that list exactly as it // iterates `BLEND_POOLS`; this class still scores whichever pool it is handed, // and the list is the only place a market is named. @@ -131,7 +131,7 @@ export class AquariusAdapter implements Adapter { /** * The pool's live restrictions — published beside the score and never in it. * - * The byte-identical-factor-map invariant this rests on (#15) is asserted in + * The byte-identical-factor-map invariant this rests on is asserted in * ./score.test.ts: no kill switch and no emergency-mode flag may move any * value `computeRiskFactors` produces. */ diff --git a/adapters/aquarius/score.test.ts b/adapters/aquarius/score.test.ts index 62736a2..2b0fff7 100644 --- a/adapters/aquarius/score.test.ts +++ b/adapters/aquarius/score.test.ts @@ -299,7 +299,7 @@ describe('adminKeySafety — role posture', () => { // `get_privileged_addrs()` returns `role -> Vec
`. Every role holds // exactly one address today and the contract's own type permits several, so // taking `[0]` is correct on live data and wrong the day a co-holder is - // added — the same class of bug #101 caught on the fetch side. The healthy + // added — the same class of bug the fetch side caught. The healthy // holder is deliberately FIRST, so `[0]` would score 90 and pass. const coheld = roles({ Admin: { @@ -677,7 +677,7 @@ describe('operationalState', () => { assert.equal(s.origin, 'indeterminate'); }); - it('classifies a killed swap as swapDisabled — the rung #100 question C added', async () => { + it('classifies a killed swap as swapDisabled — the rung dex added', async () => { // Under the ladder as it stood this would have read `active`: true about // exit, wrong about the market. `blocked` carried the fact and `level`, the // field a reader scans, did not. @@ -745,10 +745,10 @@ describe('operationalState', () => { }); it('publishes emergency mode without claiming which operations it gates', async () => { - // Neither #100's census nor #101's reads established what - // `get_emergency_mode()` refuses. Listing operations it MIGHT refuse would - // put a guess into the one field defined as "every operation the protocol's - // own gating logic currently refuses". + // Neither the rulebook's census of the contracts nor this adapter's own + // reads established what `get_emergency_mode()` refuses. Listing operations + // it MIGHT refuse would put a guess into the one field defined as "every + // operation the protocol's own gating logic currently refuses". const s = state({ emergencyMode: true }); assert.deepEqual(s.blocked, []); assert.equal(s.level, OperationalLevel.Active); @@ -774,7 +774,7 @@ describe('operationalState', () => { }); // --------------------------------------------------------------------------- -// The #15 invariant, re-grounded +// The published-never-scored invariant, re-grounded // --------------------------------------------------------------------------- describe('operationalState never reaches a score', () => { @@ -796,18 +796,18 @@ describe('operationalState never reaches a score', () => { }; it('produces a byte-identical factor map across every operational state', async () => { - // THE CORRECTNESS CHECK FOR THE WHOLE ROUTE-(C) DECISION (#15). If any kill - // switch or emergency flag ever moves any factor, the rulebook has silently - // acquired a third signal and the category version is wrong. + // THE CORRECTNESS CHECK FOR THE WHOLE PUBLISH-NEVER-SCORE DECISION. If any + // kill switch or emergency flag ever moves any factor, the rulebook has + // silently acquired a third signal and the category version is wrong. // - // RE-GROUNDED FROM WHAT #103 ORIGINALLY DESCRIBED. That issue built this - // test around `estimate_swap` reverting on a swap-killed pool, which was the - // one place the kill switches could have leaked into a number. There is no + // RE-GROUNDED FROM WHAT THIS TEST ORIGINALLY DESCRIBED. It was first built + // around `estimate_swap` reverting on a swap-killed pool, which was the one + // place the kill switches could have leaked into a number. There is no // `estimate_swap` call anywhere in this adapter — `depthSafety` is deferred // — so that failure mode does not exist. What remains is the invariant // itself, and it holds structurally: neither factor reads `killed` or // `emergencyMode` at all. This asserts it rather than trusting it, over all - // 32 states rather than the one the issue named. + // 32 states rather than the one `estimate_swap` would have covered. const baseline = await factors(makeRaw()); for (const raw of everyState()) { assert.deepEqual( @@ -876,7 +876,7 @@ describe('metadata', () => { // `contractId` must name the contract the score was derived from, or a wrong // reading gets attached to a real address in an explorer link. There is no // default pool at all — Aquarius has 340 and none is reviewed as the - // flagship (#104). + // flagship. const a = new AquariusAdapter({ pool: { id: 'p1', name: 'One', poolId: 'CONE' } }); const b = new AquariusAdapter({ pool: { id: 'p2', name: 'Two', poolId: 'CTWO' } }); assert.equal(a.metadata.contractId, 'CONE'); diff --git a/adapters/aquarius/score.ts b/adapters/aquarius/score.ts index 53e3396..a1fcb3c 100644 --- a/adapters/aquarius/score.ts +++ b/adapters/aquarius/score.ts @@ -133,8 +133,8 @@ function accountScore(entry: AquariusRoleRaw['accounts'][number]): { * returns `role -> Vec
`, not `role -> Address` — every role holds * exactly one today, and the contract's own type permits several. Reading the * first would silently drop a co-holder the day one is added, which is the same - * class of mistake #101 caught and fixed on the fetch side; the scoring side must - * not reintroduce it. + * class of mistake the fetch side caught and fixed when it decoded the same + * map; the scoring side must not reintroduce it. * * A role holding no readable address at all is `0` on `worst`'s empty rule — * "we could not read who controls this pool" is a statement about the pool, and @@ -512,8 +512,9 @@ function assetControlSafety(raw: AquariusRawData): RiskFactor { * function could not report one if it tried. * * EMERGENCY MODE BLOCKS NOTHING HERE, AND THAT IS A REFUSAL TO INVENT RATHER - * THAN AN OVERSIGHT. Neither #100's census nor #101's reads established which - * user operations `get_emergency_mode()` gates; the flag is read from a getter + * THAN AN OVERSIGHT. Neither the rulebook's census of the contracts nor this + * adapter's own reads established which user operations `get_emergency_mode()` + * gates; the flag is read from a getter * and its authorisation effect was never confirmed against the wasm. Listing * operations it *might* refuse would put a guess into the one field defined as * "every operation the protocol's own gating logic currently refuses", so the diff --git a/adapters/aquarius/types.ts b/adapters/aquarius/types.ts index 43f2fcc..40f6266 100644 --- a/adapters/aquarius/types.ts +++ b/adapters/aquarius/types.ts @@ -2,9 +2,9 @@ // 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 +// SCOPE OF THIS FILE. This is the FETCH half only. `dex` ships with two factors +// — `adminKeySafety` and `assetControlSafety` — and this file reads nothing in +// order to produce a number: it is either a type or a constant throughout. 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. @@ -86,9 +86,9 @@ 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`, NOT `standard`.** The rulebook prose and this adapter's + * own design notes both called 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 @@ -127,7 +127,7 @@ export interface AquariusPool { } /** - * The XLM/USDC constant-product pool — the one Aquarius market registered (#104). + * The XLM/USDC constant-product pool — the one Aquarius market registered. * * WHY ONE, AND WHY THIS ONE. Not because the rulebook excludes the other 339: * `dex` has **no size floor and no pending one** (`methodology/dex.md`, @@ -151,8 +151,8 @@ export interface AquariusPool { * whose reserves are **both** gradable. Both tokens are Stellar Asset Contracts, * so nothing in `assetControlSafety` is excluded as a route-(a) disclosure — the * larger XLM/SolvBTC pool (21.4M XLM) holds a wasm token and would publish a - * number computed from one of its two legs. Verified per pool against #104's - * checklist at ledger 64,182,918 on 2026-08-29: + * number computed from one of its two legs. Verified per pool against the + * registration checklist at ledger 64,182,918 on 2026-08-29: * * pool_type() constant_product * running wasm ae0da5a8…de9852 — equals the router's own @@ -194,7 +194,7 @@ export const AQUARIUS_XLM_USDC: AquariusPool = { // redrawing a wordmark to fit a tile are forbidden (ProtocolMetadata.logo). // The dashboard draws an initials tile, which is the supported answer. // - // NO `deployedOn`, DECIDED RATHER THAN OMITTED (#104). The field means "this + // NO `deployedOn`, DECIDED RATHER THAN OMITTED. The field means "this // entry is a market running ANOTHER protocol's contracts" — YieldBlox on // Blend. An Aquarius pool runs Aquarius's contracts, so setting it would be a // category error, and it is the same reading under which Blend's own Fixed @@ -444,7 +444,8 @@ export interface AquariusKillFlagsRaw { * 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 + * `assetControlSafety`, and the scoring implementation lives in ./score.ts. + * 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 @@ -472,8 +473,9 @@ export interface AquariusRawData { * `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 + * deliberately does not read (concentrated-specific reads are out of scope for + * the `dex` rulebook). 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[]; @@ -502,9 +504,9 @@ export interface AquariusRawData { * 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 + * coefficient, which decodes as u128. That was not in the original + * 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; @@ -539,9 +541,9 @@ export interface AquariusAdapterOptions { * 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 + * has 340 pools and none of them is reviewed as the flagship, 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; diff --git a/adapters/blend/fetch.test.ts b/adapters/blend/fetch.test.ts index 5efdcce..3c9a404 100644 --- a/adapters/blend/fetch.test.ts +++ b/adapters/blend/fetch.test.ts @@ -27,7 +27,7 @@ describe('the oracle-legibility precondition', () => { // upgrade that changes the answer shows up as a changed hash rather than as a // mysteriously flipped assertion. // - // WHAT THEY SHOW, and it is the finding that decided issue #69: the four + // WHAT THEY SHOW, and it is the finding that decided the precondition: the four // non-aggregator pools do NOT share one "plain SEP-40" shape. They are four // different contracts with four different wasm hashes doing four different // things — a bridge, a proxy, a deterministic bond pricer, a feed registry. @@ -75,7 +75,7 @@ describe('the oracle-legibility precondition', () => { { pool: 'Etherfuse (scored)', wasm: '65300c006c7b0151043be891f1f99872a25b20529d929db38fdfed96af966ff2', - note: 'a third aggregator build — registered by #65 on the strength of this row', + note: 'a third aggregator build — registered on the strength of this row', methods: [ '__constructor', 'add_asset', @@ -229,7 +229,8 @@ describe('isMissingContractFunction separates a verdict from a run failure', () it('does not treat a transient RPC failure as a scorability verdict', () => { // THE ASYMMETRY THAT MATTERS. A 429 from the shared public RPC is the exact - // failure #68's revert was caused by, and it arrives on these same calls. If + // failure the concurrency revert was caused by, and it arrives on these same + // calls. If // it were read as "this oracle publishes no max_age", one bad five-minute // cycle would declare a working pool permanently ungradable. for (const message of [ diff --git a/adapters/blend/fetch.ts b/adapters/blend/fetch.ts index c9fbdfc..bda7b41 100644 --- a/adapters/blend/fetch.ts +++ b/adapters/blend/fetch.ts @@ -208,8 +208,8 @@ async function readOraclePrice( // they are not in SEP-40, which defines no staleness tolerance and no deviation // bound at all. // -// Not every Blend V2 pool runs an aggregator. Four live ones do not (issue #69, -// probed 2026-08-26 by reading each oracle's contract spec out of its wasm): +// Not every Blend V2 pool runs an aggregator. Four live ones do not (probed +// 2026-08-26 by reading each oracle's contract spec out of its wasm): // Orbit's bridge oracle, Forex's proxy, Spectra PTs' deterministic zero-coupon // pricer and Solv's SEP-40 feed registry. They are four DIFFERENT contracts with // four different wasm hashes — not one "non-aggregator shape" — and they agree diff --git a/adapters/blend/score.test.ts b/adapters/blend/score.test.ts index 4b9f199..dcfbe55 100644 --- a/adapters/blend/score.test.ts +++ b/adapters/blend/score.test.ts @@ -721,7 +721,7 @@ describe('minimum-size filter — excluding everything still cannot publish 100' // oracleSafety reporting: Blend's reserves are priced in one publish round, so // their ages are identical and they ALWAYS tie on freshness. Naming one of them // "worst" was pure iteration order across the whole stored history — a string -// that reads as a finding while carrying no information. See #45. +// that reads as a finding while carrying no information. // --------------------------------------------------------------------------- describe('oracleSafety — identical reserves are reported as identical', () => { @@ -756,7 +756,7 @@ describe('oracleSafety — identical reserves are reported as identical', () => }); }); -describe('oracleSafety — per-feed price ages are disclosed (#47/#48)', () => { +describe('oracleSafety — per-feed price ages are disclosed', () => { it('publishes the disclosure on a healthy pool too, not only a stale one', async () => { // Blend prices every reserve in one aggregator round, so this is normally // unremarkable. Publishing it anyway is the point: a disclosure that only @@ -784,7 +784,7 @@ describe('oracleSafety — per-feed price ages are disclosed (#47/#48)', () => { }); // --------------------------------------------------------------------------- -// Operational state (issue #15) — published, never scored +// Operational state — published, never scored // --------------------------------------------------------------------------- describe('operationalState — the seven Blend pool statuses', () => { @@ -872,8 +872,8 @@ describe('operationalState — the seven Blend pool statuses', () => { describe('operationalState never reaches a score', () => { it('produces a byte-identical factor map across all seven statuses', async () => { - // THE CORRECTNESS CHECK FOR THIS WHOLE DECISION. Issue #15 chose to publish - // pause state rather than grade it, and this is what makes that a property + // THE CORRECTNESS CHECK FOR THIS WHOLE DECISION. Pause state is published + // rather than graded, and this is what makes that a property // of the code rather than an intention: if any status ever moves any factor, // the rulebook has silently acquired a sixth signal and the category version // is wrong. A before/after score table could not prove this — every live diff --git a/adapters/blend/score.ts b/adapters/blend/score.ts index fcb450b..0785ca8 100644 --- a/adapters/blend/score.ts +++ b/adapters/blend/score.ts @@ -70,7 +70,7 @@ import type { BlendRawData, BlendReserveRaw } from './types.ts'; // is timelocked (`config.rs` requires a timelock only when `status != 6`). It // supersedes everything: the permissionless update path panics rather than // moving a Setup pool. Every Setup pool found in the 2026-08-22 factory survey -// (issue #65) held exactly $0.00 and is excluded by the market-size floor +// held exactly $0.00 and is excluded by the market-size floor // regardless — this row exists so that if one is ever pointed at, it reads as // "never opened" rather than as a market that restricted its users. interface BlendStatusMeaning { diff --git a/adapters/fixtures/kinetic-mainnet.ts b/adapters/fixtures/kinetic-mainnet.ts index e3cefc9..d8152f4 100644 --- a/adapters/fixtures/kinetic-mainnet.ts +++ b/adapters/fixtures/kinetic-mainnet.ts @@ -5,7 +5,7 @@ // why before committing — that is the entire point of this file. // // ONE HAND-EDITED FIELD, and this is the record of it. `KineticReserveRaw` gained -// a required `flags` block on 2026-08-25 (issue #15, per-reserve gating state). +// a required `flags` block on 2026-08-25, carrying per-reserve gating state. // This fixture was NOT regenerated to absorb it — snapshot.test.ts explains why // at length: it is the only captured state in which the pre-minimum-size-filter // defect is visible, and a re-capture would have destroyed that evidence for an diff --git a/adapters/index.ts b/adapters/index.ts index 510d0d6..b5d8538 100644 --- a/adapters/index.ts +++ b/adapters/index.ts @@ -4,9 +4,9 @@ // it are internal wiring. // // `aquarius/` is the first `dex` adapter: it fetches, scores against -// methodology/dex.md's two factors, and publishes `operationalState` (#101, -// #103). One market is registered to it — `AQUARIUS_POOLS`, iterated by the -// indexer's buildTargets the same way BLEND_POOLS is (#104). +// methodology/dex.md's two factors, and publishes `operationalState`. One +// market is registered to it — `AQUARIUS_POOLS`, iterated by the indexer's +// buildTargets the same way BLEND_POOLS is. export * from './blend/index.ts'; export * from './kinetic/index.ts'; export * from './aquarius/index.ts'; diff --git a/adapters/kinetic/score.test.ts b/adapters/kinetic/score.test.ts index cb8c374..543b60c 100644 --- a/adapters/kinetic/score.test.ts +++ b/adapters/kinetic/score.test.ts @@ -543,7 +543,7 @@ describe('minimum-size filter — excluding everything still cannot publish 100' // The score was never wrong; the explanation was. With two feeds both pinned at // freshness 0, the old detail named whichever came last in iteration order — so // a $4.00 dust reserve was reported as the cause while a reserve thirteen times -// larger, equally dead, went unmentioned. That is what issue #45 was filed on. +// larger, equally dead, went unmentioned. That is what the bug report was filed on. // oracleSafety is deliberately NOT size-filtered (METHODOLOGY.md §2), so the // only fix owed here is an honest explanation. // --------------------------------------------------------------------------- @@ -587,7 +587,8 @@ describe('oracleSafety — a tie names every reserve in it', () => { }); it('scores the dust reserve exactly as it scores any other (§2 is not size-filtered)', async () => { - // Pinning the decision from #45: a reserve too small for §4/§5 still counts + // Pinning the decision that came out of it: a reserve too small for §4/§5 + // still counts // in full here. §2 measures a vulnerability, and a stale price on a dust // reserve is an open door rather than a small room — nothing stops an // attacker supplying into it at the stale price. Size-filtering §2 would @@ -607,7 +608,7 @@ describe('oracleSafety — a tie names every reserve in it', () => { }); }); -describe('oracleSafety — per-feed price ages are disclosed (#47/#48)', () => { +describe('oracleSafety — per-feed price ages are disclosed', () => { it('shows both ends of the spread, not just the graded worst', async () => { // The finding this exists for: one oracle, one batchAdapter source, some // feeds seconds-fresh and others untouched for hours. A reader seeing @@ -639,7 +640,7 @@ describe('oracleSafety — per-feed price ages are disclosed (#47/#48)', () => { }); // --------------------------------------------------------------------------- -// Operational state (issue #15) — published, never scored +// Operational state — published, never scored // --------------------------------------------------------------------------- describe('operationalState — router pause and per-reserve flags', () => { diff --git a/adapters/snapshot.test.ts b/adapters/snapshot.test.ts index b4d2eb2..bd6ba4f 100644 --- a/adapters/snapshot.test.ts +++ b/adapters/snapshot.test.ts @@ -30,7 +30,7 @@ // oracle reports 14 decimals where both others report 7, so every fixed-point // divide that reads `oracleDecimals` is only genuinely tested here. // -// EIGHT FIXTURES, THREE ADAPTERS, TWO CATEGORIES since #103. The four Aquarius +// EIGHT FIXTURES, THREE ADAPTERS, TWO CATEGORIES. The four Aquarius // pools are scored under the `dex` rulebook — two factors, not lending's five — // and they are here for the same reason the lending four are: the synthetic // suite in `aquarius/score.test.ts` picks convenient issuer flags and identical @@ -68,8 +68,8 @@ import { aquariusWasmTokenMainnet } from './fixtures/aquarius/wasm-token-mainnet import { DEX_FACTORS } from '@stenion/core'; import type { FactorMap, RiskFactor } from '@stenion/core'; -// Takes `FactorMap`, not `RiskFactorMap`: since #103 this file reads two -// categories' maps and lending's five-key type cannot describe the dex one. +// Takes `FactorMap`, not `RiskFactorMap`: this file reads two categories' maps +// and lending's five-key type cannot describe the dex one. // `RiskFactorMap` is assignable to it, so every lending call site is unchanged. const values = (f: FactorMap) => Object.fromEntries(Object.entries(f).map(([k, v]) => [k, v === null ? null : v.value])); @@ -204,8 +204,8 @@ describe('Kinetic — frozen mainnet snapshot', () => { it('names the one genuinely worst reserve when there is no tie', async () => { // At capture, XLM — 94% of the pool — was the sole reserve at freshness 0, - // with PYUSD at 31. This is the fixture that disproves #45's premise that - // oracleSafety traced to the dust reserve: the dust reserve was not even the + // with PYUSD at 31. This is the fixture that disproves the bug report's + // premise that oracleSafety traced to the dust reserve: the dust reserve was not even the // worst one here. const factors = await new KineticAdapter().computeRiskFactors(kineticMainnet); assert.match(factors.oracleSafety!.detail, /^worst reserve \(CAS3J7…\)/); @@ -473,10 +473,11 @@ describe('Etherfuse — frozen mainnet snapshot (same adapter, third pool)', () }); it('publishes operationalState active without touching the factor map', async () => { - // The #15 rule checked on the newest entry. Both adapter suites already - // assert the byte-identical-factor-map half; what this pins is that a market - // registered AFTER #15 shipped actually populates the field from its own - // PoolConfig.status rather than falling back to a default. + // The published-never-scored rule checked on the newest entry. Both adapter + // suites already assert the byte-identical-factor-map half; what this pins is + // that a market registered after `operationalState` shipped actually + // populates the field from its own PoolConfig.status rather than falling + // back to a default. const state = adapter().operationalState(etherfuseMainnet); assert.equal(state.level, 'active'); assert.equal(state.source, 'PoolConfig.status = 1'); @@ -503,7 +504,7 @@ describe('all three Blend pools — one engine, three markets', () => { }); it('declare the lending category, on every pool', async () => { - // Required as of ADAPTER_INTERFACE_VERSION 3 (#76). Asserted per pool rather + // Required as of ADAPTER_INTERFACE_VERSION 3. Asserted per pool rather // than once on the class, for the same reason `contractId` is: metadata is // built per instance from the pool it was handed, so "the class sets it" and // "every instance has it" are different claims. @@ -563,7 +564,7 @@ describe('all three Blend pools — one engine, three markets', () => { * would be handed. * * THE FIRST ENTRY IS THE REGISTERED MARKET and its identity is IMPORTED from - * `AQUARIUS_POOLS` (#104), not written out here — a snapshot that pinned a + * `AQUARIUS_POOLS`, not written out here — a snapshot that pinned a * hand-typed pool would keep passing after the registry moved to a different * one, which is the failure this file exists to catch. The other four are branch * fixtures for markets nothing publishes a number about, so they declare the @@ -639,7 +640,7 @@ const AQUARIUS_SNAPSHOTS = [ * BY LABEL, NOT BY INDEX. The assertions below each need a SPECIFIC pool — the * three-token stable one, the one holding a wasm token — and reading them out of * the array positionally meant that inserting the registered market at the front - * (#104) silently repointed four tests at different pools, three of which then + * silently repointed four tests at different pools, three of which then * failed and one of which would have kept passing while covering nothing. A name * cannot be shifted by an insertion. */ @@ -795,7 +796,8 @@ describe('Aquarius — frozen mainnet snapshots, every captured pool', () => { }); it('publishes operationalState active on every one without touching the factor map', async () => { - // The #15 rule on the newest category. None of the captured pools has a + // The published-never-scored rule on the newest category. None of the + // captured pools has a // kill switch set, so `active` here is a reading rather than a default; the // synthetic suite is what walks all 32 states. for (const snap of AQUARIUS_SNAPSHOTS) { diff --git a/api-docs/conventions.md b/api-docs/conventions.md index dbd8a4d..7353c1a 100644 --- a/api-docs/conventions.md +++ b/api-docs/conventions.md @@ -218,7 +218,7 @@ Stated so you do not go looking: paging, that is an additive change and would arrive on `v1` with a documented default. - **No historical range query.** `history` is the most recent 50 runs, fixed. There is no `?from=` or `?limit=`. -- **No historical factor DIFF.** History rows do carry their own `factors` (added in #82), so you +- **No historical factor DIFF.** History rows do carry their own `factors`, so you can see which factor moved a score. What there is not is a server-side comparison endpoint: diff two rows yourself, and only when their `methodologyVersion` matches — factors from two rulebooks are no more comparable than the scores are. diff --git a/api-docs/index.md b/api-docs/index.md index 60d83d9..3f9beeb 100644 --- a/api-docs/index.md +++ b/api-docs/index.md @@ -7,27 +7,17 @@ wallet, an aggregator, or a dashboard and you want a live safety number for a pr are about to interact with, this is the whole surface area. The scored examples below were captured from the live production API, not written from the type -definitions. Their responses are verbatim bodies from a snapshot taken at -**2026-08-25T18:05Z**; the numbers move every ~5 minutes, the shapes do not. - -> **One field is documented below but not yet in these captures: `category`.** It ships with the -> per-category rulebook change (#76) and was added to the field tables when the code was, while the -> example bodies still predate the deploy that publishes it. They are re-`curl`ed from production -> rather than hand-edited, because a body written from the types reproduces the type instead of the -> truth — which is the entire reason these are captures. Until that recapture, read the tables as -> current and the bodies as one deploy behind on this one field. - -> **A second `category` is now scored, and no example body here shows one yet.** Registering the -> first `dex` market (#104) means `/v1/protocols` returns entries whose `category` is `dex`, and -> whose `factors` object has **two** keys rather than lending's five — -> `adminKeySafety` and `assetControlSafety`. The field tables say so and -> [the `factors` section](protocol-by-id.md#the-factors-object) explains how to read a factor map -> whose keys you do not recognise. The bodies do not, because they are captures and the change is -> not deployed at the time of writing; the dex entry's shape was verified end-to-end over HTTP -> against a real indexer cycle, but not against production, and a local capture is not a production -> capture. **Re-`curl` `/v1/protocols` and `/v1/protocol/aquarius-xlm-usdc` from production once -> this deploys**, and delete this note. Until then, read the tables as current and every example -> body as lending-only. +definitions. They are verbatim bodies: `/v1/protocols` and `/v1/protocol/aquarius-xlm-usdc` from +**2026-08-30T08:35Z**, `/v1/protocol/blend` from **2026-08-28T12:25Z**. The numbers move every +~5 minutes, the shapes do not. + +> **Both categories appear in the examples.** `/v1/protocols` returns `"lending"` and `"dex"` +> entries side by side, and a `dex` protocol's `factors` object has **two** keys rather than +> lending's five — `adminKeySafety` and `assetControlSafety`, at their own weights. +> [The `factors` section](protocol-by-id.md#the-factors-object) explains how to read a factor map +> whose keys you do not recognise, and carries a full `dex` response beside the `lending` one. +> **Scores are comparable only within one `category`**, and so are the keys and weights of +> `factors`. The coverage example was `curl`ed from production on **2026-08-25T18:20Z**. diff --git a/api-docs/protocol-by-id.md b/api-docs/protocol-by-id.md index 9c6a528..2415243 100644 --- a/api-docs/protocol-by-id.md +++ b/api-docs/protocol-by-id.md @@ -193,6 +193,161 @@ rules") from entirely different on-chain data on each side. **That is not a comp A `dex` `adminKeySafety` of 60 and a `lending` one of 60 were produced by different rules from different quantities, exactly as the two overall scores were. +#### A `dex` response in full + +The same endpoint, on the one `dex` market: `category` is `"dex"`, `factors` has **two** keys, and +their weights sum to 1 exactly as lending's five do. `history` is truncated to one entry as above; +everything else is verbatim. + +```bash +curl https://stenion.vercel.app/api/v1/protocol/aquarius-xlm-usdc +``` + +```json +{ + "id": "aquarius-xlm-usdc", + "name": "Aquarius XLM/USDC", + "chain": "stellar", + "category": "dex", + "adapter": "AquariusAdapter", + "logo": null, + "contractId": "CA6PUJLBYKZKUEKLZJMKBZLEKP2OTHANDEOWSFF44FTSYLKQPIICCJBE", + "site": "https://aqua.network", + "docs": "https://docs.aqua.network", + "deployedOn": null, + "safetyScore": 24, + "computedAt": "2026-08-30T08:30:12.798Z", + "factors": { + "adminKeySafety": { + "value": 10, + "detail": "role posture binds at 10 — pool: worst of 7 roles — RewardsAdmin single-key GCXYKA… (1 signer(s), high-threshold 0), 200 op(s) in 30d; SystemFeeAdmin single-key GB57YD… (1 signer(s), high-threshold 0), 200 op(s) in 30d | router: worst of 7 roles — RewardsAdmin single-key GCXYKA… (1 signer(s), high-threshold 0), 200 op(s) in 30d; SystemFeeAdmin single-key GB57YD… (1 signer(s), high-threshold 0), 200 op(s) in 30d", + "weight": 0.55, + "components": [ + { + "id": "rolePosture", + "label": "Privileged role posture", + "value": 10, + "detail": "pool: worst of 7 roles — RewardsAdmin single-key GCXYKA… (1 signer(s), high-threshold 0), 200 op(s) in 30d; SystemFeeAdmin single-key GB57YD… (1 signer(s), high-threshold 0), 200 op(s) in 30d | router: worst of 7 roles — RewardsAdmin single-key GCXYKA… (1 signer(s), high-threshold 0), 200 op(s) in 30d; SystemFeeAdmin single-key GB57YD… (1 signer(s), high-threshold 0), 200 op(s) in 30d" + }, + { + "id": "upgradeWindow", + "label": "Upgrade reaction window", + "value": 100, + "detail": "pool UpgradeDeadline = 0 — no code change scheduled (FutureWASM ae0da5a8… matches the running code) | router UpgradeDeadline = 0 — no code change scheduled (FutureWASM 06f4207b… matches the running code)" + }, + { + "id": "timelockDuration", + "label": "Timelock duration", + "value": null, + "detail": "ADMIN_ACTIONS_DELAY is a compile-time constant with no getter, confirmed absent from all four deployed wasms, so the LENGTH of the upgrade window is not readable from the contract. The deadline and the current time are, which is why the window is graded as a state and never as a fraction remaining." + }, + { + "id": "emergencyBypass", + "label": "Emergency Admin bypass", + "value": null, + "detail": "Aquarius's own response to Certora H-01: \"In the case of system vulnerability fixes, delay may be bypassed by the Emergency Admin role.\" So the reaction window is conditional on one single-signer key choosing not to skip it. Disclosed rather than folded in, because \"a window exists\" and \"the window is unconditional\" are different claims and only the first is true." + } + ] + }, + "assetControlSafety": { + "value": 40, + "detail": "worst of 2 gradable reserve(s): USDC CCW67T…: issuer GA5ZSE… has auth_revocable — can freeze the pool's balance", + "weight": 0.45, + "components": [ + { + "id": "reserve:CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", + "label": "XLM", + "value": 100, + "detail": "XLM CAS3J7…: native XLM — a SAC with no issuer account to act from" + }, + { + "id": "reserve:CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", + "label": "USDC", + "value": 40, + "detail": "USDC CCW67T…: issuer GA5ZSE… has auth_revocable — can freeze the pool's balance" + } + ] + } + }, + "operationalState": { + "asOf": "2026-08-30T08:30:12.000Z", + "level": "active", + "detail": "the AMM router CBQDHN… is not in emergency mode", + "origin": "indeterminate", + "source": "router.get_emergency_mode() = false", + "blocked": [] + }, + "methodologyVersion": 1, + "lastRunAt": "2026-08-30T08:30:10.864Z", + "lastRunStatus": "ok", + "history": [ + { + "status": "ok", + "safetyScore": 24, + "methodologyVersion": 1, + "factors": { + "adminKeySafety": { + "value": 10, + "detail": "role posture binds at 10 — pool: worst of 7 roles — RewardsAdmin single-key GCXYKA… (1 signer(s), high-threshold 0), 200 op(s) in 30d; SystemFeeAdmin single-key GB57YD… (1 signer(s), high-threshold 0), 200 op(s) in 30d | router: worst of 7 roles — RewardsAdmin single-key GCXYKA… (1 signer(s), high-threshold 0), 200 op(s) in 30d; SystemFeeAdmin single-key GB57YD… (1 signer(s), high-threshold 0), 200 op(s) in 30d", + "weight": 0.55, + "components": [ + { + "id": "rolePosture", + "label": "Privileged role posture", + "value": 10, + "detail": "pool: worst of 7 roles — RewardsAdmin single-key GCXYKA… (1 signer(s), high-threshold 0), 200 op(s) in 30d; SystemFeeAdmin single-key GB57YD… (1 signer(s), high-threshold 0), 200 op(s) in 30d | router: worst of 7 roles — RewardsAdmin single-key GCXYKA… (1 signer(s), high-threshold 0), 200 op(s) in 30d; SystemFeeAdmin single-key GB57YD… (1 signer(s), high-threshold 0), 200 op(s) in 30d" + }, + { + "id": "upgradeWindow", + "label": "Upgrade reaction window", + "value": 100, + "detail": "pool UpgradeDeadline = 0 — no code change scheduled (FutureWASM ae0da5a8… matches the running code) | router UpgradeDeadline = 0 — no code change scheduled (FutureWASM 06f4207b… matches the running code)" + }, + { + "id": "timelockDuration", + "label": "Timelock duration", + "value": null, + "detail": "ADMIN_ACTIONS_DELAY is a compile-time constant with no getter, confirmed absent from all four deployed wasms, so the LENGTH of the upgrade window is not readable from the contract. The deadline and the current time are, which is why the window is graded as a state and never as a fraction remaining." + }, + { + "id": "emergencyBypass", + "label": "Emergency Admin bypass", + "value": null, + "detail": "Aquarius's own response to Certora H-01: \"In the case of system vulnerability fixes, delay may be bypassed by the Emergency Admin role.\" So the reaction window is conditional on one single-signer key choosing not to skip it. Disclosed rather than folded in, because \"a window exists\" and \"the window is unconditional\" are different claims and only the first is true." + } + ] + }, + "assetControlSafety": { + "value": 40, + "detail": "worst of 2 gradable reserve(s): USDC CCW67T…: issuer GA5ZSE… has auth_revocable — can freeze the pool's balance", + "weight": 0.45, + "components": [ + { + "id": "reserve:CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", + "label": "XLM", + "value": 100, + "detail": "XLM CAS3J7…: native XLM — a SAC with no issuer account to act from" + }, + { + "id": "reserve:CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", + "label": "USDC", + "value": 40, + "detail": "USDC CCW67T…: issuer GA5ZSE… has auth_revocable — can freeze the pool's balance" + } + ] + } + }, + "computedAt": "2026-08-30T08:30:12.798Z", + "runAt": "2026-08-30T08:30:10.864Z" + } + ] +} +``` + +Note `adminKeySafety` carrying a **different weight** here (`0.55`) than on a `lending` entry +(`0.20`). Weights belong to a category's rulebook, so reading one off a response and applying it to +another category's factor of the same name is the same mistake as comparing the two values. + Each value is either a factor object or `null` (the factor genuinely does not apply to that protocol — render "N/A", do not treat it as zero). diff --git a/api-docs/protocols.md b/api-docs/protocols.md index c7fb592..83169ea 100644 --- a/api-docs/protocols.md +++ b/api-docs/protocols.md @@ -25,86 +25,110 @@ curl https://stenion.vercel.app/api/v1/protocols ```json { "protocols": [ - { - "id": "blend", - "name": "Blend", - "chain": "stellar", - "logo": "/assets/protocols/blend.svg", - "deployedOn": null, - "safetyScore": 52, - "computedAt": "2026-08-26T10:01:30.847Z", - "operationalState": { - "asOf": "2026-08-26T10:01:30.000Z", - "level": "active", - "detail": "pool status 1 (Active) — all operations available.", - "origin": "protocol", - "source": "PoolConfig.status = 1", - "blocked": [] - }, - "lastRunAt": "2026-08-26T10:01:25.334Z", - "lastRunStatus": "ok" - }, { "id": "etherfuse", "name": "Etherfuse", "chain": "stellar", + "category": "lending", "logo": null, "deployedOn": { "host": "Blend", "label": "Blend V2 pool" }, - "safetyScore": 50, - "computedAt": "2026-08-26T10:01:08.281Z", + "safetyScore": 65, + "computedAt": "2026-08-30T08:30:10.636Z", + "operationalState": { + "asOf": "2026-08-30T08:30:10.000Z", + "level": "entryDisabled", + "detail": "pool status 4 (Admin Frozen) — borrowing and supplying are disabled; withdrawals and repayments still work.", + "origin": "admin", + "source": "PoolConfig.status = 4", + "blocked": ["supply", "borrow"] + }, + "lastRunAt": "2026-08-30T08:30:07.381Z", + "lastRunStatus": "ok" + }, + { + "id": "blend", + "name": "Blend", + "chain": "stellar", + "category": "lending", + "logo": "/assets/protocols/blend.svg", + "deployedOn": null, + "safetyScore": 49, + "computedAt": "2026-08-30T07:45:25.080Z", "operationalState": { - "asOf": "2026-08-26T10:01:08.000Z", + "asOf": "2026-08-30T07:45:25.000Z", "level": "active", "detail": "pool status 1 (Active) — all operations available.", "origin": "protocol", "source": "PoolConfig.status = 1", "blocked": [] }, - "lastRunAt": "2026-08-26T10:00:59.824Z", - "lastRunStatus": "ok" + "lastRunAt": "2026-08-30T08:30:23.669Z", + "lastRunStatus": "failed" }, { "id": "kinetic", "name": "Kinetic", "chain": "stellar", + "category": "lending", "logo": "/assets/protocols/kinetic.png", "deployedOn": null, "safetyScore": 27, - "computedAt": "2026-08-26T10:01:17.379Z", + "computedAt": "2026-08-30T08:30:18.555Z", "operationalState": { - "asOf": "2026-08-26T10:01:13.000Z", + "asOf": "2026-08-30T08:30:16.000Z", "level": "active", "detail": "the router is not paused", "origin": "indeterminate", "source": "router.is_paused() = false", "blocked": [] }, - "lastRunAt": "2026-08-26T10:01:08.636Z", + "lastRunAt": "2026-08-30T08:30:12.987Z", "lastRunStatus": "ok" }, { "id": "yieldblox", "name": "YieldBlox", "chain": "stellar", + "category": "lending", "logo": "/assets/protocols/yieldblox.png", "deployedOn": { "host": "Blend", "label": "Blend V2 pool" }, - "safetyScore": 25, - "computedAt": "2026-08-26T10:01:24.961Z", + "safetyScore": 27, + "computedAt": "2026-08-30T07:45:21.408Z", "operationalState": { - "asOf": "2026-08-26T10:01:24.000Z", + "asOf": "2026-08-30T07:45:21.000Z", "level": "active", "detail": "pool status 0 (Admin Active) — all operations available.", "origin": "admin", "source": "PoolConfig.status = 0", "blocked": [] }, - "lastRunAt": "2026-08-26T10:01:17.757Z", + "lastRunAt": "2026-08-30T08:30:18.749Z", + "lastRunStatus": "failed" + }, + { + "id": "aquarius-xlm-usdc", + "name": "Aquarius XLM/USDC", + "chain": "stellar", + "category": "dex", + "logo": null, + "deployedOn": null, + "safetyScore": 24, + "computedAt": "2026-08-30T08:30:12.798Z", + "operationalState": { + "asOf": "2026-08-30T08:30:12.000Z", + "level": "active", + "detail": "the AMM router CBQDHN… is not in emergency mode", + "origin": "indeterminate", + "source": "router.get_emergency_mode() = false", + "blocked": [] + }, + "lastRunAt": "2026-08-30T08:30:10.864Z", "lastRunStatus": "ok" } ] diff --git a/architecture/deploy-architecture.md b/architecture/deploy-architecture.md index 4a79ac8..15801e0 100644 --- a/architecture/deploy-architecture.md +++ b/architecture/deploy-architecture.md @@ -40,7 +40,7 @@ runtime require, not bundled) and pins `outputFileTracingRoot` to the repo root tracing is correct. On Vercel: Root Directory = `dashboard`, Build Command = `pnpm run build`. > **`@stellar/stellar-sdk` must NOT be a `serverExternalPackage`** — it was, and that is what took -> the indexer down on the Next 15 → 16 upgrade (#96). `@stenion/adapters` compiles to CommonJS, so +> the indexer down on the Next 15 → 16 upgrade. `@stenion/adapters` compiles to CommonJS, so > it reaches the SDK through the `require` condition (`lib/cjs/*`), and the SDK's CJS build does > `require('@noble/hashes/sha2.js')` — ESM-only since noble v2. That require works **only** on a > runtime with `require(esm)` (Node >= 22.12 / >= 20.19); anywhere else it throws `ERR_REQUIRE_ESM` @@ -178,7 +178,8 @@ Counted by instrumenting `globalThis.fetch` around one `fetchRawData()` per adap | 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 +predicted.** The adapter's design estimate was "~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 @@ -202,8 +203,9 @@ it the figure would be 28. > `cycleFeasibility()` checks `ceil(targets / concurrency) * ATTEMPT_TIMEOUT_MS <= CYCLE_BUDGET_MS`. > At concurrency **1** and `ATTEMPT_TIMEOUT_MS` 10,000, the old 42,000ms budget allowed exactly > **four** targets — the four lending markets that were already registered — so registering **any** -> dex market at all made the cycle infeasible, whichever market it was. That was #101's finding and -> it was #104's blocker, independent of which pools the census turned up. +> dex market at all made the cycle infeasible, whichever market it was. That was the finding of the +> request-count measurement above, and it blocked registering any Aquarius pool, independent of +> which pools the census turned up. > > | Targets | Concurrency | Waves | Required | Against 42,000ms | Against 50,000ms | > | ---------------- | ----------- | ----- | -------- | ---------------- | ----------------- | @@ -211,13 +213,13 @@ it the figure would be 28. > | 5 (+1 Aquarius) | 1 | 5 | 50,000ms | **infeasible** | feasible, exactly | > | 6 (+2 Aquarius) | 1 | 6 | 60,000ms | **infeasible** | **infeasible** | > -> **Resolved in #104 by raising `STENION_CYCLE_BUDGET_MS` from 42,000 to 50,000, and by nothing +> **Resolved by raising `STENION_CYCLE_BUDGET_MS` from 42,000 to 50,000, and by nothing > else.** Concurrency stays at 1 and `ATTEMPT_TIMEOUT_MS` stays at 10,000. The three levers were > weighed and two were rejected on evidence: > > - **Concurrency 2** would make five targets fit in three waves, and is the change that drew > sustained `429`s from the free shared public RPC and was reverted the same day. Raising it needs -> its own deployed RPC-tolerance measurement, which #104 did not do. (Incidentally reproduced +> its own deployed RPC-tolerance measurement, which has not been done. (Incidentally reproduced > while running the pool census: four concurrent `simulateTransaction` streams against > `mainnet.sorobanrpc.com` drew `429` on 28 of 340 reads. Not a substitute for a deployed > measurement, but not encouraging either.) diff --git a/architecture/monorepo-layout.md b/architecture/monorepo-layout.md index ef65191..a562431 100644 --- a/architecture/monorepo-layout.md +++ b/architecture/monorepo-layout.md @@ -54,8 +54,8 @@ small and stable. Carries `ADAPTER_INTERFACE_VERSION` as a seam for future break `Adapter`'s `TCategory` is **defaulted** to the whole `ProtocolCategory` union, which is what lets the indexer keep one heterogeneous `Adapter[]` run loop across categories. It scopes three -things to one rulebook: `metadata.category`, `operationalState`'s operation vocabulary, and — since -#104 — the factor map, through `FactorMapFor`. **The factor map is derived from the +things to one rulebook: `metadata.category`, `operationalState`'s operation vocabulary, and — as of +interface version 4 — the factor map, through `FactorMapFor`. **The factor map is derived from the category, never named by the adapter.** Which factors a category scores is declared once in `CATEGORY_FACTORS`, so an adapter that declares `'dex'` owes exactly `adminKeySafety` and `assetControlSafety`; a map of lending's five, or of invented keys, is a compile error. The decision @@ -96,27 +96,29 @@ lending adapters take — scores the `dex` rulebook's two factors (`adminKeySafe `swapDisabled` rung. It is otherwise an ordinary four-file adapter: nothing about the pipeline, the run loop or the storage schema needed a `dex` special case. -**One market is registered to it** (#104): `AQUARIUS_POOLS` in `adapters/aquarius/types.ts`, holding +**One market is registered to it:** `AQUARIUS_POOLS` in `adapters/aquarius/types.ts`, holding the XLM/USDC constant-product pool, iterated by `buildTargets` exactly as `BLEND_POOLS` is. One, because the registry's ceiling is the cycle budget rather than the census — 340 Aquarius pools are scorable and five targets fit inside the deploy's 60s ceiling, of which four were already lending. See `deploy-architecture.md`. The other 339 pools say so on the registry through `coverage.ts`'s `awaiting-capacity` status. -### Decision record: the factor map is derived from the category (#103 → reviewed in #104) +### Decision record: the factor map is derived from the category -**Status: reviewed and revised.** #103 added a third `Adapter` parameter, `TFactors`, because -nothing in that issue compiled without it, and recorded itself as unreviewed so that #104 would -inherit a decision it could see and reopen. #104 reopened it, checked the claims, and **revised** -rather than affirmed. What ships is `FactorMapFor`; the parameter is gone. +**Status: reviewed and revised.** The change that built the first `dex` adapter's scoring half +added a third `Adapter` parameter, `TFactors`, because nothing in it compiled without one, and +recorded itself as unreviewed so that whoever came next would inherit a decision they could see and +reopen. That review happened, checked the claims, and **revised** rather than affirmed. What ships +is `FactorMapFor`; the parameter is gone. -**What #103 did, and why it was unavoidable there.** `RiskFactorMap` is **lending's** map — +**What `TFactors` did, and why it was unavoidable then.** `RiskFactorMap` is **lending's** map — `Record`, its five keys required. `dex` scores `adminKeySafety` and `assetControlSafety`, so `AquariusAdapter` could not implement `Adapter` at all: a hard compile -failure with no local workaround that is not a lie. That was the gap #77 left — it widened -`scoreFactors` to `` and parameterized `ScoreResult` so the weighted mean -could never acquire a per-category variant, and stopped one file short of the interface those two -members are declared on. #103 opened the interface with a defaulted third parameter and moved on. +failure with no local workaround that is not a lie. That was the gap left when the scoring engine +was generalized — it widened `scoreFactors` to `` and parameterized +`ScoreResult` so the weighted mean could never acquire a per-category variant, and stopped one +file short of the interface those two members are declared on. The interface was opened with a +defaulted third parameter and the work moved on. **What the review found, by checking rather than reasoning.** The parameter was never related to `TCategory`, so both of these compiled: @@ -149,7 +151,7 @@ export interface Adapter< } ``` -`ADAPTER_INTERFACE_VERSION` goes to **4**. #103's addition deliberately did not bump it — a +`ADAPTER_INTERFACE_VERSION` goes to **4**. Adding `TFactors` deliberately did not bump it — a defaulted parameter is not something an implementor must react to — and removing one that an implementor did name is. Exactly one adapter named it. @@ -157,16 +159,16 @@ Guarded rather than left to review: `core/src/weights.test.ts` carries three `@t interface probes — a `dex` adapter returning lending's map, one returning an invented key, and the correct shape with no directive — checked by `pnpm typecheck` alongside the sources. -**Two claims in the #103 record were wrong, and are corrected here rather than left standing.** Both +**Two claims in the earlier record were wrong, and are corrected here rather than left standing.** Both were argued rather than compiled, which is worth noting in a section about a decision that turned on compiling things. -| #103 claimed | Actually | +| The earlier record claimed | Actually | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `FactorMapFor` "would resolve the default `TCategory = ProtocolCategory` to a map requiring **every** category's keys at once, breaking the bare `Adapter`" | Only if written non-distributively — which is the same trap `OperationFor` in `operational-state.ts` records being written carefully to survive. Written as a mapped type indexed by `C`, the default resolves to the **union** of every category's map, `Adapter` types, and the indexer's run loop is unaffected. Checked, not argued. | -| Spelling the members against the open `FactorMap` would "ripple straight into the indexer and the store — a much larger change than the one it avoids" | The ripple is real and #104 made it deliberately: `RunRecord`/`ProtocolDetail`/`HistoryEntry` are `FactorMap` now (below). That is the right place for an open type — storage enforces no rulebook — and the wrong place is the adapter boundary, which is exactly where the rulebook IS enforced. | +| Spelling the members against the open `FactorMap` would "ripple straight into the indexer and the store — a much larger change than the one it avoids" | The ripple is real and this change made it deliberately: `RunRecord`/`ProtocolDetail`/`HistoryEntry` are `FactorMap` now (below). That is the right place for an open type — storage enforces no rulebook — and the wrong place is the adapter boundary, which is exactly where the rulebook IS enforced. | -**The open question #103 left for a reviewer is now answered.** It asked whether the right end state +**The open question that record left for a reviewer is now answered.** It asked whether the right end state is per-category factor-map types everywhere, or one open `FactorMap` from the adapter all the way through storage. Neither, and the split is the answer: **derived per-category types at the adapter boundary, one open `FactorMap` from the indexer's `IndexTarget` through storage and out to the API.** @@ -176,7 +178,7 @@ storage and transport layers are category-agnostic by design — `recordRun` ins are not in a position to make, and `toHistoryEntry`'s old `row.factors as RiskFactorMap` was that claim being made and being wrong. -#### `@stenion/db` carries any category's factor map, as of #104 +#### `@stenion/db` carries any category's factor map **The storage always round-tripped; the types did not, and now they do.** @@ -198,7 +200,7 @@ claim being made and being wrong. gained a `dex` case that writes a two-key map through `insertRunRecord` and reads it back through `getProtocolDetail`, asserting the top-level `factors` and the history row each come back with exactly `adminKeySafety` and `assetControlSafety`, and that no lending key appears. It is still - skipped unless `STENION_TEST_DATABASE_URL` is set, so CI needs no credentials; it was run for #104 + skipped unless `STENION_TEST_DATABASE_URL` is set, so CI needs no credentials; it was run against a throwaway Postgres 16 with all eight migrations applied. **One adapter can serve several markets.** `BlendAdapter` takes a `BlendPool` — slug, display name, @@ -422,7 +424,7 @@ still recorded as `failed` — a protocol that is genuinely down still shows as `STENION_RPC_URL` is `mainnet.sorobanrpc.com`: the free, shared, keyless public endpoint, whose rate limit is unpublished and not ours to raise. - **What happened.** #68 shipped the worker pool at concurrency 2. Within one cycle, Blend — the + **What happened.** The worker pool shipped at concurrency 2. Within one cycle, Blend — the target that runs _behind_ the concurrent pair — began failing with `Request failed with status code 429`, recorded only after all three retry attempts were exhausted. Measured from `risk_scores` via `/api/v1/protocol/:id`: @@ -449,15 +451,15 @@ still recorded as `failed` — a protocol that is genuinely down still shows as **The fix, applied.** Both halves together, not either alone: - `STENION_CYCLE_CONCURRENCY` default **2 → 1**. Removes the burst entirely. It does **not** - reinstate the bug #68 fixed: budget division is gone independently of concurrency, so at 1 each + reinstate the budget-division bug: budget division is gone independently of concurrency, so at 1 each target still gets the budget less a reservation rather than a shrinking even share. - `STENION_ATTEMPT_TIMEOUT_MS` default **15s → 10s**. Needed _because_ of the first: at 1 worker a 15s timeout makes `cycleFeasibility` infeasible at three targets (`3 × 15s = 45s > 42s`) and warn on every cycle. 10s is justified by the measurement rather than guessed — nothing healthy exceeds 6.1s deployed — and made a sequential cycle feasible to **four** targets - (`4 × 10s = 40s ≤ 42s`), so #65's Etherfuse needed no further config change. + (`4 × 10s = 40s ≤ 42s`), so registering Etherfuse needed no further config change. - **Now standing at five, and the budget moved to get there (#104).** Registering the first dex + **Now standing at five, and the budget moved to get there.** Registering the first dex market was the registration that tripped the ceiling, exactly as predicted: a fifth target needs 50,000ms of attempts, which the 42s budget did not have. `STENION_CYCLE_BUDGET_MS` is now **50,000**, raised against three `curl`ed deployed cycles rather than against arithmetic — see @@ -499,14 +501,14 @@ still recorded as `failed` — a protocol that is genuinely down still shows as **These are two to three times faster than the developer-machine figures they replace** (which were Blend 6.0–7.5s, Kinetic 7.7–10.5s, YieldBlox 8.1–12.5s, 24.5–26.9s sequential, measured - 2026-08-19). Vercel's path to the RPC is simply not a laptop's, which is why the issue insisted on - measuring from the deployed function rather than trusting arithmetic over local timings. Two + 2026-08-19). Vercel's path to the RPC is simply not a laptop's, which is why these figures must + come from the deployed function rather than from arithmetic over local timings. Two consequences worth stating: the whole cycle uses under a fifth of its 42s budget, and the 15s attempt timeout is now roughly 2.5x the slowest healthy fetch rather than barely above it. **The 4-target case is now measured; the 5-target case is not.** Three deployed cycles were `curl`ed from the cron route on 2026-08-29 at four targets — `totalMs` 15,482 / 15,627 / 16,759, - per-target 2,412–6,363ms — and those are the numbers #104 raised the budget against. The fifth + per-target 2,412–6,363ms — and those are the numbers the budget was raised against. The fifth target, `aquarius-xlm-usdc`, has **no deployed measurement yet**: it cannot have one until it is deployed, since the cron route reports `durationMs` per registered target. Its local wall-clock is 8,929ms against Blend's 5,545ms on the same machine in the same cycle, and its request count is @@ -516,7 +518,8 @@ still recorded as `failed` — a protocol that is genuinely down still shows as **One thing to watch on the first deployed cycles.** Etherfuse's `fetchRawData` was observed locally at 8.0s and 12.2s in two consecutive runs against the shared public RPC — the second past - the 10s attempt timeout. Local timings are exactly what the #68 incident says never to reason from, + the 10s attempt timeout. Local timings are exactly what the 429 incident above says never to + reason from, so this is written down as the thing to check in the cron route's per-target `durationMs`, not as a claim about production or as grounds to move a knob. diff --git a/core/src/adapter.ts b/core/src/adapter.ts index 9813194..613105b 100644 --- a/core/src/adapter.ts +++ b/core/src/adapter.ts @@ -8,34 +8,35 @@ import type { FactorMapFor } from './weights'; * to — a required method, a changed signature, a new error model. * * 1 — fetchRawData / computeRiskFactors / score. - * 2 — adds the required `operationalState(raw)` method (issue #15). Required, + * 2 — adds the required `operationalState(raw)` method. Required, * not optional, deliberately: an optional method is one every future adapter * can quietly skip, which is precisely the retrofit debt that decision was * made to stop accumulating. This constant exists so that forcing every * implementor to update is a labelled event rather than a silent break, and * this is the first time it has been used for one. * 3 — adds the required `metadata.category` field and a `TCategory` parameter - * that scopes `operationalState`'s vocabulary to it (issue #76). Required + * that scopes `operationalState`'s vocabulary to it. Required * for the same reason `operationalState` was: a protocol with no category is * not a protocol we know how to score, and an optional field would default * the first adapter of every future category into lending's rulebook. Every * implementor must name its category; nothing infers one. * * 4 — the factor map is DERIVED from `TCategory` rather than named by the - * adapter (issue #104). #103 had added a third parameter, `TFactors`, - * defaulted to `RiskFactorMap`, to make the first `dex` adapter compile — - * and recorded itself as unreviewed. This is that review, and it revised - * rather than affirmed: nothing tied `TFactors` to `TCategory`, so + * adapter. The version before it had a third parameter, `TFactors`, + * defaulted to `RiskFactorMap`, to make the first `dex` adapter compile, + * and it was left explicitly unreviewed. The review revised rather than + * affirmed it: nothing tied `TFactors` to `TCategory`, so * `Adapter` compiled (a dex adapter scored on * lending's five) and so did a map of invented keys. Both were checked, not * supposed. `computeRiskFactors`/`score` now speak `FactorMapFor` * — the key set `CATEGORY_FACTORS` declares for that category — so an * adapter cannot publish factors its own rulebook does not name. * - * BUMPED, where #103's addition deliberately was not, because the bar is - * "a change adapters must react to" and this is one: an adapter that spelled - * three parameters must drop the third. A defaulted parameter nobody had to - * name was not; a removed one that someone did name is. + * BUMPED, where the defaulted `TFactors` parameter deliberately was not, + * because the bar is "a change adapters must react to" and this is one: an + * adapter that spelled three parameters must drop the third. A defaulted + * parameter nobody had to name was not; a removed one that someone did name + * is. */ export const ADAPTER_INTERFACE_VERSION = 4 as const; @@ -74,13 +75,13 @@ export const ADAPTER_INTERFACE_VERSION = 4 as const; * returning lending's five is a compile error, and so is inventing a key no * rulebook has. * - * That is the #104 revision of #103's `TFactors` parameter, and the reason for + * That is the revision of the earlier `TFactors` parameter, and the reason for * it is that the parameter did not connect to anything. `RiskFactorMap` is * **lending's** map — `Record`, its five keys fixed — and * `dex` scores `adminKeySafety` and `assetControlSafety`, so the first - * non-lending adapter could not implement this interface at all; #103 opened it - * up with a defaulted parameter to get that adapter compiling and flagged the - * decision as unreviewed. The review found what the parameter allowed: + * non-lending adapter could not implement this interface at all; `TFactors` + * opened it up with a defaulted parameter to get that adapter compiling, and the + * decision was flagged as unreviewed. The review found what the parameter allowed: * `Adapter` and `Adapter` * both compiled, because nothing related the two parameters. Deriving relates * them. diff --git a/core/src/category.test.ts b/core/src/category.test.ts index d7e8389..bfaab76 100644 --- a/core/src/category.test.ts +++ b/core/src/category.test.ts @@ -81,12 +81,12 @@ describe('the category registry', () => { // category a deliberate act with a failing test attached, rather than a // one-word edit that silently widens what the platform claims to score. // Adding a member here without a `methodology/.md` section for it - // is the failure mode TAXONOMY.md (#79) exists to prevent — and it is + // is the failure mode TAXONOMY.md exists to prevent — and it is // `scoring.test.ts`'s "publishes a rulebook section for every category" // that actually catches it. // // ORDER IS THE ORDER THEY WERE ADMITTED, not alphabetical: `lending` first - // because every scored market runs under it, `dex` second (#100). + // because every scored market runs under it, `dex` second. assert.deepEqual([...PROTOCOL_CATEGORIES], ['lending', 'dex']); }); diff --git a/core/src/category.ts b/core/src/category.ts index eada456..7b8c1d3 100644 --- a/core/src/category.ts +++ b/core/src/category.ts @@ -22,9 +22,11 @@ * The protocol categories Stenion publishes a rulebook for. * * TWO MEMBERS: `lending`, the rulebook every scored market runs under today, and - * `dex`, admitted by the gate-checked submission in #100 as the first use of the - * machinery #76–#79 built. `lending` was always the unstated default, and naming - * it is what made a second category expressible rather than assumed. + * `dex`, admitted by a gate-checked submission as the first use of the category + * machinery — `ProtocolCategory`, `METHODOLOGY_VERSIONS`, `CATEGORY_FACTORS`, + * `CATEGORY_OPERATIONS` and TAXONOMY.md. `lending` was always the unstated + * default, and naming it is what made a second category expressible rather than + * assumed. * * A CLOSED UNION, NOT AN OPEN STRING. Adding a category is never a data-only * change: it needs a taxonomy, a weight table, a `methodology/.md` @@ -36,12 +38,11 @@ * as the three places it had to be declared, which is the design working. * * `dex` IS REGISTERED AND WEIGHTED, AND STILL SCORES NOTHING. Its factor set was - * admitted in #100 with `status: 'pendingWeights'` — a different type from a - * published one, carrying no `weight` on any factor — and #102 reviewed the - * weight table and flipped it to `status: 'published'`. What stops a `dex` row - * reaching `risk_scores` today is no longer the type system but the plain - * absence of the rest: the adapter's scoring half is #103 and no market is - * registered to a target list until #104. + * admitted with `status: 'pendingWeights'` — a different type from a published + * one, carrying no `weight` on any factor — and a later review of the weight + * table flipped it to `status: 'published'`. What stops a `dex` row reaching + * `risk_scores` today is no longer the type system but the plain absence of the + * rest: the adapter's scoring half, and a market registered to a target list. */ export const PROTOCOL_CATEGORIES = ['lending', 'dex'] as const; export type ProtocolCategory = (typeof PROTOCOL_CATEGORIES)[number]; @@ -74,17 +75,17 @@ export type ProtocolCategory = (typeof PROTOCOL_CATEGORIES)[number]; * history stamped 2 while a briefly-live v2 was in this constant before the * rulebook was flattened back. See METHODOLOGY.md, "Current version". * - * `dex: 1` — the two-factor AMM rulebook admitted in #100 - * (`methodology/dex.md`). **Its own counter, with no relation to lending's.** - * Both read 1 today and that is a coincidence of both being new, not a - * statement that they are the same rulebook or that a `dex` v1 score is + * `dex: 1` — the two-factor AMM rulebook in `methodology/dex.md`. **Its own + * counter, with no relation to lending's.** Both read 1 today and that is a + * coincidence of both being new, not a statement that they are the same + * rulebook or that a `dex` v1 score is * comparable to a `lending` v1 score — it is exactly the ambiguity * `risk_scores.category` is stamped beside this number to remove. It starts * at 1 rather than continuing lending's count for the same reason: a * category's first published rulebook is its version 1, always. * * It is a version for a rulebook nothing has yet been scored under, and it - * stayed at 1 when #102 published the weight table: a rulebook that could + * stayed at 1 when the weight table was published: a rulebook that could * not compute a score cannot have produced one that a weight made * non-comparable, so there was no discontinuity to label. It is declared at * all because TAXONOMY.md Gate 7 requires a category to arrive at 1 rather diff --git a/core/src/operational-state.test.ts b/core/src/operational-state.test.ts index f1eb1bc..cceff6f 100644 --- a/core/src/operational-state.test.ts +++ b/core/src/operational-state.test.ts @@ -203,7 +203,7 @@ describe('mostRestrictive — reducing several readings to one', () => { }); // --------------------------------------------------------------------------- -// The category-scoped operation vocabulary (#76) +// The category-scoped operation vocabulary // --------------------------------------------------------------------------- describe('CATEGORY_OPERATIONS — vocabulary per category, ladder shared', () => { @@ -220,8 +220,8 @@ describe('CATEGORY_OPERATIONS — vocabulary per category, ladder shared', () => }); it("leaves lending's five operations exactly as they were", () => { - // The whole of what #76 changed here is the TYPE — which category's names - // these are. Not one member was added, removed or renamed, because renaming + // The whole of what scoping the vocabulary changed here is the TYPE — which + // category's names these are. Not one member was added, removed or renamed, because renaming // one would change `blocked` in every stored operational_state jsonb and in // every API response, for a refactor that was supposed to move no data. assert.deepEqual(Object.values(CATEGORY_OPERATIONS.lending).sort(), [ @@ -246,7 +246,7 @@ describe('CATEGORY_OPERATIONS — vocabulary per category, ladder shared', () => // CATEGORY_OPERATIONS-style per-category tables, the shared representation // this module exists for is gone — see its header. // - // `swapDisabled` arrived with `dex` (#100) and did NOT fork the ladder: it + // `swapDisabled` arrived with `dex` and did NOT fork the ladder: it // is a sixth rung on the one shared ladder, which is the whole point of // adding it here rather than giving dex a ladder of its own. assert.deepEqual(Object.values(OperationalLevel).sort(), [ @@ -290,9 +290,9 @@ describe('toDexOperationalState — the dex ladder', () => { it('calls a swap-killed pool swapDisabled, NOT active — open question C', () => { // THE ASSERTION THIS RUNG EXISTS FOR. Aquarius's `kill_swap` halts the - // market while both LP paths stay open. Under the pre-#100 ladder this - // classified `active` — true about exit, and wrong about the market — and - // `level` is the field a reader scans. + // market while both LP paths stay open. Under the ladder as it stood before + // `dex` was admitted this classified `active` — true about exit, and wrong + // about the market — and `level` is the field a reader scans. const state = toDexOperationalState(dexReading({ blocked: [DexOperation.Swap] })); assert.equal(state.level, OperationalLevel.SwapDisabled); assert.deepEqual(state.blocked, ['swap']); diff --git a/core/src/operational-state.ts b/core/src/operational-state.ts index ed69aab..df46e37 100644 --- a/core/src/operational-state.ts +++ b/core/src/operational-state.ts @@ -4,8 +4,8 @@ * This is the third category of published data, alongside scored factors and * the static Findings notes (see CLAUDE.md). It exists because pause/frozen * state is a real, changing, on-chain reading that changes how a score should - * be read, but which nothing on chain lets us grade. Issue #15 resolved that - * tension by publishing it rather than scoring it; the full reasoning is in + * be read, but which nothing on chain lets us grade. That tension was resolved + * by publishing it rather than scoring it; the full reasoning is in * METHODOLOGY.md, "Operational state is published, never scored". * * WHY THIS FILE IS NOT IN scoring.ts. `scoring.ts` is the shared *rulebook* — @@ -108,7 +108,7 @@ export type DexOperation = (typeof DexOperation)[keyof typeof DexOperation]; * words or skip the method, and both are worse than saying which words belong to * which rulebook. * - * TWO ENTRIES. `dex` (#100) is the second, and it registered exactly what this + * TWO ENTRIES. `dex` is the second, and it registered exactly what this * design said it would: its own operation names here, its own canonical ordering * and its own classifier beside the lending ones below. **Not one lending * operation was renamed, added or removed** — renaming one would rewrite @@ -190,10 +190,10 @@ export const OperationalLevel = { /** * Cannot trade; depositing and withdrawing both work. * - * ADDED FOR `dex` (#100, open question C), and the reason it is a new rung - * rather than a reused one. Aquarius's `kill_swap` halts the market while - * leaving both LP paths open — there is no `kill_withdraw` in any of the three - * pool wasms — so a swap-killed pool is genuinely open on entry and on exit. + * ADDED FOR `dex`, and the reason it is a new rung rather than a reused one. + * Aquarius's `kill_swap` halts the market while leaving both LP paths open — + * there is no `kill_withdraw` in any of the three pool wasms — so a swap-killed + * pool is genuinely open on entry and on exit. * Under the ladder as it stood it would have classified `Active`: true about * exit, and wrong about the market. `blocked: ['swap']` carried the fact, but * `level` is the field a reader scans, and a dead market reading "active" is diff --git a/core/src/scoring.test.ts b/core/src/scoring.test.ts index 30e190e..43f7932 100644 --- a/core/src/scoring.test.ts +++ b/core/src/scoring.test.ts @@ -64,7 +64,7 @@ function repoFile(name: string): string { /** * One category's rulebook file. * - * METHODOLOGY.md is a FOLDER now — #77's per-category sections became + * METHODOLOGY.md is a FOLDER now — its per-category sections became * per-category files — and a category's file is named for the category: * `lending` lives in `methodology/lending.md`. Reading only that file rather * than the whole folder is the same scoping the section parser below already @@ -195,8 +195,8 @@ const factor = (value: number, weight: number): RiskFactor => ({ * The assertions below iterate this rather than naming `lending`, because every * one of them is a statement about *a category's* rulebook and nothing in any of * them is lending-specific. Naming one category was correct while one was - * published; it stopped being correct in #102, when `dex` gained a table that - * would otherwise have been pinned against nothing. + * published; it stopped being correct when `dex` gained a table that would + * otherwise have been pinned against nothing. */ const publishedCategories = () => PROTOCOL_CATEGORIES.filter((c) => CATEGORY_FACTORS[c].status === 'published'); @@ -232,7 +232,7 @@ describe("scoreFactors — agreement with each published category's rulebook", ( // // THEY ITERATE THE PUBLISHED CATEGORIES RATHER THAN NAMING `lending`. Nothing // in any of them was ever lending-specific; naming one category was simply - // correct while one had a weight table. `dex` gained one in #102, and a table + // correct while one had a weight table. `dex` gained one, and a table // nobody pins against `CATEGORY_FACTORS` is exactly the drift these tests // exist to catch — so the loop is what stops the next category being published // in the document and never checked against the code. @@ -349,8 +349,8 @@ describe("scoreFactors — agreement with each published category's rulebook", ( // for. The `##