From 223f4930ffae1bdc1a1995df3d66a173f6370db5 Mon Sep 17 00:00:00 2001 From: dubemoyibe-star Date: Tue, 18 Aug 2026 17:27:20 +0100 Subject: [PATCH] Implemented tie accounting in protocols and streenghtned project --- METHODOLOGY.md | 49 +++++++- ROADMAP.md | 18 --- adapters/blend.test.ts | 74 +++++++++++- adapters/blend.ts | 50 +++++--- adapters/kinetic.test.ts | 103 +++++++++++++++- adapters/kinetic.ts | 47 +++++--- adapters/snapshot.test.ts | 27 ++++- core/src/scoring.test.ts | 174 ++++++++++++++++++++++++++++ core/src/scoring.ts | 160 ++++++++++++++++++++++++- core/src/types.ts | 2 +- dashboard/app/lib/contract.ts | 2 +- dashboard/app/lib/protocol-notes.ts | 71 ++++++++++-- dashboard/components/factor-bar.tsx | 2 +- 13 files changed, 709 insertions(+), 70 deletions(-) diff --git a/METHODOLOGY.md b/METHODOLOGY.md index d464dfb..3603f69 100644 --- a/METHODOLOGY.md +++ b/METHODOLOGY.md @@ -120,7 +120,7 @@ A factor may publish a **`components`** breakdown — the sub-signals behind its Components with a numeric `value` are what the factor was computed from; components with a `null` value are **disclosures**: real, readable on-chain quantities we publish but deliberately do not grade, because scoring them would invent comparability the data does -not support (see §2c). A null component is never missing data. +not support (see §2c and §2d). A null component is never missing data. ### Methodology versions @@ -289,6 +289,14 @@ the binding constraint is the single weakest reserve, and averaging would hide i are published in the factor's `components` array so the composite is never an opaque number. +**Every reserve at the binding value is named, not one of them.** When several reserves tie +on a sub-signal the `detail` lists all of them; when _all_ of them tie it says so rather than +singling one out. This is reporting only — the published value is the same minimum either way +— but it is load-bearing for reading a score honestly. Blend prices its whole pool from one +aggregator publish round, so its reserves carry identical ages and **always** tie on freshness. +Naming one of them would make an iteration-order artifact read as a diagnosis, and a reserve +name that is really a tie-break is worse than no name at all. + #### 2a. `priceFreshness` — how stale the worst price is **Raw on-chain data (Soroban RPC):** per reserve, the price's publish `timestamp` from @@ -363,7 +371,26 @@ disclosed. (Whether such a peg _holds_ is a real risk — but it is a collateral question, not an oracle-robustness one, and inventing a number for it here would be the kind of fabrication ground rule 4 forbids.) -#### 2c. Bound tightness is disclosed, never scored +#### 2c. Per-feed price ages are disclosed, never scored + +`priceFreshness` grades the **worst** reserve, which is the right thing to score but hides the +**spread** — and on real data the spread is the informative part. A factor value of 0 reads as +a general condition of the oracle. "Two feeds have not updated in hours while two others update +every few seconds, through one contract and one source" is a specific, checkable statement +about which feeds are being maintained, and it is the one a depositor can act on. + +So every reserve's price age is published as a **disclosure-only component** (`priceAges`, +`value: null`), ordered oldest-first, alongside a count of how many exceed **the protocol's own +declared staleness limit** — Blend's aggregator `max_age`, K2's `price_staleness_threshold`. +The count is therefore a statement about the protocol's own rules, not about a Stenion line. A +reserve with no usable price at all sorts as the oldest rather than the freshest. + +It is not scored, because it would double-count: these are the same ages `priceFreshness` was +computed from, republished so that the grading can be checked rather than taken on faith. It is +published on healthy pools as well as unhealthy ones — a disclosure that appears only where +trouble is expected gives a reader no baseline to compare against. + +#### 2d. Bound tightness is disclosed, never scored The raw bound is published as a **disclosure-only component** (`value: null`) — visible, never graded. Grading it would invent comparability the underlying data does not support: @@ -389,6 +416,22 @@ the manipulated price from a legitimate one **at the time**, since a signal that sophisticated but would not have caught the actual attack is worse than none: it manufactures confidence. +- **Filtering `oracleSafety` by reserve size, the way §4/§5 are filtered.** + **Rejected on principle, not on impact** — and the distinction it turns on is the reason + §4/§5 may be size-filtered while this factor may not: + + > **§4 and §5 measure current state. §2 measures a vulnerability.** How drained a reserve is + > right now means little when the reserve holds $4, because the exposure is capped by what is + > actually in there. Whether a price can be trusted is not capped that way, because the + > attacker's move is to _grow_ a position against the mispriced asset. **A dust reserve with a + > stale price is an open door, not a small room.** Its balance today says nothing about what + > can be borrowed against it tomorrow. + + And it would blind the factor to the exact scenario it exists for: a newly-listed thin asset + with a bad price is the shape the February 2026 YieldBlox incident ran through, which §2b + already names. A filter that removes thin assets from an oracle-trust factor removes the + attack it was built to catch. + - **A Stenion-computed deviation from the oracle's price history** (calling Reflector's `prices(asset, N)` ourselves and comparing the latest price to a trailing mean). **Rejected — this would have made the platform actively worse.** It is a _coincident_ @@ -618,7 +661,7 @@ data: **Excluded reserves are disclosed, never silently dropped.** Each affected factor publishes an `excludedReserves` component with a `null` value — the same "measured, shown, deliberately not -graded" form as §2c — naming each excluded reserve, its supplied USD, its share of the pool, +graded" form as §2c/§2d — naming each excluded reserve, its supplied USD, its share of the pool, and **the score it would have contributed**. A reader can therefore see the number the filter suppressed and disagree with the exclusion, instead of never learning of it. diff --git a/ROADMAP.md b/ROADMAP.md index 28cad5d..a2ba6e7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -69,24 +69,6 @@ Roughly in priority order, but not committed to dates: breaking API change (a `v2` under the versioning policy in [`ARCHITECTURE.md`](ARCHITECTURE.md)); changing only the display `name` is free and additive. Almost certainly the latter, but it should be a decision rather than a drift. -- **Should a dust reserve be able to zero `oracleSafety`?** ([#45](https://github.com/stenion-lab/stenion/issues/45)) Open, and deliberately not resolved - alongside the §4/§5 minimum-size filter that prompted it. That filter stops a near-empty reserve - binding `liquiditySafety`/`utilizationSafety`; `oracleSafety` selects the worst reserve too and - was left untouched, so the same reserve can still set it. - - **What was found (2026-08-18, live mainnet).** K2's `oracleSafety` of **0** traces to its PYUSD - reserve — **$4.00**, 0.26% of a ~$1,535 pool — carrying a price **29,584s** old against a - 3,600s dead anchor. That reserve is below the minimum-size filter's line and is excluded from - §4/§5, yet it alone sets the factor carrying the heaviest weight (0.25). - - **Why it is not obviously the same bug.** A stale feed is plausibly an operational signal about - the _oracle_ rather than about the reserve: an oracle that has stopped updating one asset may - well be one you cannot trust on the others, regardless of how much value sits behind the stale - one. On that reading the current behaviour is correct and no filter belongs here. The opposite - reading — that a $4.00 reserve should not set a protocol-wide factor — is equally arguable. - - **Why it needs its own design pass.** Applying §4's filter here would be a one-line change and - the wrong way to decide it: it moves K2's heaviest factor and the anchor question ("is - freshness a per-reserve or a per-oracle property?") is genuinely unsettled. It gets the same - threshold-agreed-before-code treatment §4's filter got, not a fold-in at the end of that work. - - **Per-factor history.** `risk_scores` stores the full factor map on every row, so the data is 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 diff --git a/adapters/blend.test.ts b/adapters/blend.test.ts index 001ca52..a49bb6a 100644 --- a/adapters/blend.test.ts +++ b/adapters/blend.test.ts @@ -281,7 +281,8 @@ describe('oracleSafety — base assets are excluded, not scored 0 (§2b)', () => reserves: [reserve({ asset: BASE }), reserve({ asset: 'CGRADED…' })], }), ); - assert.match(f.oracleSafety!.components![2].detail, /1 base asset\(s\) excluded/); + const tightness = f.oracleSafety!.components!.find((c) => c.id === 'deviationTightness')!; + assert.match(tightness.detail, /1 base asset\(s\) excluded/); }); it('scores 0 when every reserve is a base asset — nothing left to grade', async () => { @@ -291,7 +292,7 @@ describe('oracleSafety — base assets are excluded, not scored 0 (§2b)', () => }); }); -describe('oracleSafety — bound tightness is disclosed, never scored (§2c)', () => { +describe('oracleSafety — bound tightness is disclosed, never scored (§2d)', () => { it('publishes the raw max_dev as a null-valued disclosure component', async () => { // A null component value means "measured, shown, deliberately not graded" — // never missing data. Grading tightness would invent comparability between @@ -577,7 +578,8 @@ describe('minimum-size filter — leg A, the pool’s own min_collateral (§4/§ // side by side with the test above, this is exactly what leg A buys. const f = await factors(lopsided(0)); assert.equal(f.liquiditySafety!.value, 100); - assert.match(f.liquiditySafety!.components![0].detail, /CSMALL/); + const excluded = f.liquiditySafety!.components!.find((c) => c.id === 'excludedReserves')!; + assert.match(excluded.detail, /CSMALL/); }); }); @@ -685,3 +687,69 @@ describe('minimum-size filter — excluding everything still cannot publish 100' assert.equal(f.liquiditySafety!.value, 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. +// --------------------------------------------------------------------------- + +describe('oracleSafety — identical reserves are reported as identical', () => { + it('does not single out a reserve when every one scores the same', async () => { + const f = await factors( + makeRaw({ + reserves: [ + reserve({ asset: 'CAAAAAAAA', ageSeconds: 233 }), + reserve({ asset: 'CBBBBBBBB', ageSeconds: 233 }), + reserve({ asset: 'CCCCCCCCC', ageSeconds: 233 }), + ], + }), + ); + const freshness = f.oracleSafety!.components!.find((c) => c.id === 'priceFreshness')!; + assert.match(freshness.detail, /all 3 reserves score the same/); + assert.doesNotMatch(freshness.detail, /worst reserve/); + }); + + it('still isolates a genuinely worse reserve', async () => { + // The all-tie wording must not swallow a real outlier. + const f = await factors( + makeRaw({ + reserves: [ + reserve({ asset: 'CFRESHAAA', ageSeconds: 100 }), + reserve({ asset: 'CFRESHBBB', ageSeconds: 100 }), + reserve({ asset: 'CSTALEAAA', ageSeconds: 880 }), + ], + }), + ); + const freshness = f.oracleSafety!.components!.find((c) => c.id === 'priceFreshness')!; + assert.match(freshness.detail, /^worst reserve \(CSTALE…\)/); + }); +}); + +describe('oracleSafety — per-feed price ages are disclosed (#47/#48)', () => { + 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 + // appears where trouble is expected gives a reader no healthy baseline. + const f = await factors( + makeRaw({ + reserves: [ + reserve({ asset: 'CAAAAAAAA', ageSeconds: 233 }), + reserve({ asset: 'CBBBBBBBB', ageSeconds: 233 }), + ], + }), + ); + const ages = f.oracleSafety!.components!.find((c) => c.id === 'priceAges')!; + assert.equal(ages.value, null); + assert.match(ages.detail, /all 2 within the protocol's own 900s staleness limit/); + }); + + it('reports ages against the aggregator’s own max_age', async () => { + const f = await factors( + makeRaw({ maxAge: 900, reserves: [reserve({ asset: 'CSTALEAAA', ageSeconds: 1_200 })] }), + ); + const ages = f.oracleSafety!.components!.find((c) => c.id === 'priceAges')!; + assert.match(ages.detail, /1 of 1 past the protocol's own 900s staleness limit/); + }); +}); diff --git a/adapters/blend.ts b/adapters/blend.ts index 65cbb8a..1ec7794 100644 --- a/adapters/blend.ts +++ b/adapters/blend.ts @@ -18,15 +18,19 @@ import { rpc } from '@stellar/stellar-sdk'; // `import type`. import { RiskFactorType, + describePriceAges, + describeWorst, excludedComponent, freshnessWindow, scoreFactors, sizeReserves, + worstReserves, } from '@stenion/core'; import type { Adapter, ExcludedReserve, ProtocolMetadata, + WorstReserves, RiskFactor, RiskFactorMap, RiskScoreResult, @@ -529,21 +533,20 @@ const deviationBounded = (maxDevPercent: number): boolean => maxDevPercent > 0 && maxDevPercent < 100; /** - * Score every reserve on one sub-signal and keep the worst. + * Score every reserve on one sub-signal and keep the worst — and every reserve + * tied with it. The selection rule and the phrasing both live in core + * (`worstReserves`/`describeWorst`) so the two adapters cannot drift into + * describing the same situation differently. * - * Worst-reserve selection is the house convention across factors: the binding - * constraint is the single weakest reserve, and averaging would hide it. + * Blend is the clearest case for reporting ties: all reserves are priced from a + * single aggregator publish round, so their ages are identical and they always + * tie. Naming one of them was pure iteration order. */ function worstBy( reserves: BlendReserveRaw[], score: (r: BlendReserveRaw) => { score: number; note: string }, -): { score: number; note: string; asset: string } { - let worst = { score: Number.POSITIVE_INFINITY, note: 'no reserves', asset: '' }; - for (const r of reserves) { - const s = score(r); - if (s.score <= worst.score) worst = { ...s, asset: r.asset }; - } - return Number.isFinite(worst.score) ? worst : { score: 0, note: 'no reserves', asset: '' }; +): WorstReserves { + return worstReserves(reserves.map((r) => ({ asset: r.asset, ...score(r) }))); } /** USD value of supplied liquidity for a reserve, or null if no price. */ @@ -765,20 +768,39 @@ export class BlendAdapter implements Adapter { weight, detail: worstBound.score === 0 - ? `worst reserve (${shortAsset(worstBound.asset)}) ${worstBound.note}` - : `worst reserve (${shortAsset(worstFresh.asset)}) ${worstFresh.note}; all reserves have a deviation bound`, + ? describeWorst(worstBound) + : `${describeWorst(worstFresh)}; all reserves have a deviation bound`, components: [ { id: 'priceFreshness', label: 'Price freshness', value: Math.round(worstFresh.score), - detail: `worst reserve (${shortAsset(worstFresh.asset)}) ${worstFresh.note}; anchored to the aggregator's own resolution and max_age (${raw.oracleConfig.maxAge}s)`, + detail: `${describeWorst(worstFresh)}; anchored to the aggregator's own resolution and max_age (${raw.oracleConfig.maxAge}s)`, }, { id: 'deviationBound', label: 'Deviation bound', value: Math.round(worstBound.score), - detail: `worst reserve (${shortAsset(worstBound.asset)}) ${worstBound.note}`, + detail: describeWorst(worstBound), + }, + { + id: 'priceAges', + label: 'Price age by feed (not scored)', + value: null, + // Blend's reserves are priced in one aggregator round, so these ages + // are normally identical and this disclosure is unremarkable. It is + // published anyway, on the same rule as every protocol: the value of + // showing per-feed ages is that a divergence becomes visible when one + // appears, and a disclosure that only exists where we already expect + // trouble is one nobody can check against a healthy baseline. + detail: describePriceAges( + graded.map((r) => ({ + asset: r.asset, + feed: r.priceConfig?.upstreamAsset ?? null, + ageSeconds: r.price ? Math.max(0, raw.fetchedAt - r.price.timestamp) : null, + })), + raw.oracleConfig.maxAge, + ), }, { id: 'deviationTightness', diff --git a/adapters/kinetic.test.ts b/adapters/kinetic.test.ts index 50f436a..27efeac 100644 --- a/adapters/kinetic.test.ts +++ b/adapters/kinetic.test.ts @@ -267,7 +267,7 @@ describe('oracleSafety — composite and disclosures', () => { const f = await factors(makeRaw({ maxPriceChangeBps: 2_000 })); const tightness = f.oracleSafety!.components!.find((c) => c.id === 'deviationTightness'); assert.ok(tightness); - assert.equal(tightness.value, null, 'tightness is disclosed, never scored (§2c)'); + assert.equal(tightness.value, null, 'tightness is disclosed, never scored (§2d)'); assert.match(tightness.detail, /2000 \(20%\)/); assert.match(tightness.detail, /not comparable|not graded/); }); @@ -498,3 +498,104 @@ describe('minimum-size filter — excluding everything still cannot publish 100' } }); }); + +// --------------------------------------------------------------------------- +// oracleSafety reports every stale reserve, not one tie-break winner. +// +// 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. +// oracleSafety is deliberately NOT size-filtered (METHODOLOGY.md §2), so the +// only fix owed here is an honest explanation. +// --------------------------------------------------------------------------- + +describe('oracleSafety — a tie names every reserve in it', () => { + /** K2's live shape: two fresh feeds, two dead ones of very different ages. */ + const mixedStaleness = makeRaw({ + reserves: [ + reserve({ asset: 'CUSDCAAAA', supplied: 54, ageSeconds: 19_599 }), + reserve({ asset: 'CXLMAAAAA', supplied: 1_450, ageSeconds: 167 }), + reserve({ asset: 'CPYUSDAAA', supplied: 4, ageSeconds: 39_955 }), + reserve({ asset: 'CSOLVAAAA', supplied: 37, ageSeconds: 17 }), + ], + }); + + it('names both dead feeds, not just the worse one', async () => { + const f = await factors(mixedStaleness); + assert.equal(f.oracleSafety!.value, 0); + assert.match(f.oracleSafety!.detail, /2 of 4 reserves tied at the worst score/); + assert.match(f.oracleSafety!.detail, /CUSDCA…/, 'the larger dead reserve must appear'); + assert.match(f.oracleSafety!.detail, /CPYUSD…/, 'the dust dead reserve must appear too'); + }); + + it('does not mention the reserves that are actually fresh', async () => { + const f = await factors(mixedStaleness); + assert.doesNotMatch(f.oracleSafety!.detail, /CXLMAA…/); + assert.doesNotMatch(f.oracleSafety!.detail, /CSOLVA…/); + }); + + it('still names a single reserve when only one is worst', async () => { + // The common case must not get more verbose to serve the tie case. + const f = await factors( + makeRaw({ + reserves: [ + reserve({ asset: 'CFRESHAAA', ageSeconds: 10 }), + reserve({ asset: 'CSTALEAAA', ageSeconds: 40_000 }), + ], + }), + ); + assert.match(f.oracleSafety!.detail, /^worst reserve \(CSTALE…\)/); + }); + + 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 + // 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 + // blind it to the newly-listed-thin-asset shape the YieldBlox incident ran + // through, which is the exact scenario this factor exists to catch. + const dustOnlyStale = makeRaw({ + reserves: [ + reserve({ asset: 'CBIGAAAAA', supplied: 1_000_000, ageSeconds: 10 }), + reserve({ asset: 'CDUSTAAAA', supplied: 1, ageSeconds: 40_000 }), + ], + }); + const f = await factors(dustOnlyStale); + assert.equal(f.oracleSafety!.value, 0, 'a dust reserve must still be able to sink this factor'); + assert.match(f.oracleSafety!.detail, /CDUSTA…/); + // And it is genuinely excluded from the factors that DO filter by size. + assert.equal(sub(f.liquiditySafety!, 'excludedReserves'), null); + }); +}); + +describe('oracleSafety — per-feed price ages are disclosed (#47/#48)', () => { + 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 + // oracleSafety 0 could not previously tell those apart from a dead oracle. + const f = await factors( + makeRaw({ + reserves: [ + reserve({ asset: 'CUSDCAAAA', ageSeconds: 21_421 }), + reserve({ asset: 'CXLMAAAAA', ageSeconds: 177 }), + reserve({ asset: 'CPYUSDAAA', ageSeconds: 41_777 }), + reserve({ asset: 'CSOLVAAAA', ageSeconds: 27 }), + ], + }), + ); + const ages = f.oracleSafety!.components!.find((c) => c.id === 'priceAges')!; + assert.equal(ages.value, null, 'a disclosure is never graded'); + assert.match(ages.detail, /41777s/); + assert.match(ages.detail, /27s/, 'the fresh end must be visible too'); + assert.match(ages.detail, /2 of 4 past the protocol's own 3600s staleness limit/); + }); + + it('does not let the disclosure move the score', async () => { + // It republishes inputs priceFreshness already used. If it ever gained a + // numeric value it would double-count the same staleness. + const f = await factors(makeRaw({ reserves: [reserve({ ageSeconds: 41_777 })] })); + const scored = f.oracleSafety!.components!.filter((c) => c.value !== null).map((c) => c.id); + assert.deepEqual(scored, ['priceFreshness', 'deviationBound']); + }); +}); diff --git a/adapters/kinetic.ts b/adapters/kinetic.ts index a02304b..597e315 100644 --- a/adapters/kinetic.ts +++ b/adapters/kinetic.ts @@ -15,15 +15,19 @@ import { rpc } from '@stellar/stellar-sdk'; // survive into the running module and fail against core's CommonJS output. import { RiskFactorType, + describePriceAges, + describeWorst, excludedComponent, freshnessWindow, scoreFactors, sizeReserves, + worstReserves, } from '@stenion/core'; import type { Adapter, ExcludedReserve, ProtocolMetadata, + WorstReserves, RiskFactor, RiskFactorMap, RiskScoreResult, @@ -485,19 +489,18 @@ function lerp01(v: number, a: number, b: number): number { const shortAsset = (a: string): string => `${a.slice(0, 6)}…`; /** - * Score every reserve on one sub-signal and keep the worst. Same convention as - * every other factor: the binding constraint is the single weakest reserve. + * Score every reserve on one sub-signal and keep the worst — and every reserve + * tied with it. Selection and phrasing both live in core, identical to Blend. + * + * K2 is why this matters for more than tidiness: with USDC and PYUSD both pinned + * at freshness 0, naming only one of them made `oracleSafety` look like it hinged + * on a $4.00 dust reserve when a reserve thirteen times larger was equally dead. */ function worstBy( reserves: KineticReserveRaw[], score: (r: KineticReserveRaw) => { score: number; note: string }, -): { score: number; note: string; asset: string } { - let worst = { score: Number.POSITIVE_INFINITY, note: 'no reserves', asset: '' }; - for (const r of reserves) { - const s = score(r); - if (s.score <= worst.score) worst = { ...s, asset: r.asset }; - } - return Number.isFinite(worst.score) ? worst : { score: 0, note: 'no reserves', asset: '' }; +): WorstReserves { + return worstReserves(reserves.map((r) => ({ asset: r.asset, ...score(r) }))); } /** Underlying supplied/borrowed for a reserve, in human units (asset decimals applied). */ @@ -753,20 +756,38 @@ export class KineticAdapter implements Adapter { weight, detail: worstBound.score === 0 - ? `worst reserve (${shortAsset(worstBound.asset)}) ${worstBound.note}` - : `worst reserve (${shortAsset(worstFresh.asset)}) ${worstFresh.note}; circuit breaker armed on all reserves`, + ? describeWorst(worstBound) + : `${describeWorst(worstFresh)}; circuit breaker armed on all reserves`, components: [ { id: 'priceFreshness', label: 'Price freshness', value: Math.round(worstFresh.score), - detail: `worst reserve (${shortAsset(worstFresh.asset)}) ${worstFresh.note}; anchored to K2's own cache TTL (${priceCacheTtl}s) and staleness threshold (${priceStalenessThreshold}s)`, + detail: `${describeWorst(worstFresh)}; anchored to K2's own cache TTL (${priceCacheTtl}s) and staleness threshold (${priceStalenessThreshold}s)`, }, { id: 'deviationBound', label: 'Deviation bound', value: Math.round(worstBound.score), - detail: `worst reserve (${shortAsset(worstBound.asset)}) ${worstBound.note}`, + detail: describeWorst(worstBound), + }, + { + id: 'priceAges', + label: 'Price age by feed (not scored)', + value: null, + // K2 is the case this disclosure exists for: one oracle and one + // batchAdapter source serve some assets seconds-fresh and leave others + // untouched for hours. `priceFreshness` grades only the worst of them, + // so without this a reader sees a 0 and cannot tell whether the oracle + // is down or two specific feeds are unmaintained. + detail: describePriceAges( + raw.reserves.map((r) => ({ + asset: r.asset, + feed: r.priceConfig?.feedId ?? null, + ageSeconds: r.price ? Math.max(0, raw.fetchedAt - r.price.timestamp) : null, + })), + priceStalenessThreshold, + ), }, { id: 'deviationTightness', diff --git a/adapters/snapshot.test.ts b/adapters/snapshot.test.ts index c415ea6..490d516 100644 --- a/adapters/snapshot.test.ts +++ b/adapters/snapshot.test.ts @@ -76,6 +76,19 @@ describe('Blend — frozen mainnet snapshot', () => { assert.equal(sub(factors.oracleSafety!, 'deviationTightness'), null); }); + it('reports its reserves as the tie they actually are', async () => { + // All three captured reserves carry the SAME publish timestamp (1787055300) + // because one aggregator round prices the whole pool. On real data, then, + // Blend's freshness signal never has a distinguished worst reserve — and the + // detail must not imply otherwise, as it did for ~1,459 stored runs. + const stamps = new Set(blendMainnet.reserves.map((r) => r.price?.timestamp)); + assert.equal(stamps.size, 1, 'fixture should capture one shared publish round'); + + const factors = await new BlendAdapter().computeRiskFactors(blendMainnet); + assert.match(factors.oracleSafety!.detail, /all 3 reserves score the same/); + assert.doesNotMatch(factors.oracleSafety!.detail, /worst reserve/); + }); + it('still exercises non-unit interest rates — the reason this fixture exists', async () => { // A guard on the fixture rather than on the adapter. If a regenerated // snapshot ever landed with every rate at exactly SCALAR_12, this file would @@ -135,17 +148,27 @@ describe('Kinetic — frozen mainnet snapshot', () => { } // The suppressed numbers are exactly the ones this fixture used to publish. assert.match( - factors.liquiditySafety!.components![0].detail, + factors.liquiditySafety!.components!.find((c) => c.id === 'excludedReserves')!.detail, /would have scored 34/, 'the old liquiditySafety value must still be readable', ); assert.match( - factors.utilizationSafety!.components![0].detail, + factors.utilizationSafety!.components!.find((c) => c.id === 'excludedReserves')!.detail, /would have scored 18/, 'the old utilizationSafety value must still be readable', ); }); + 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 + // worst one here. + const factors = await new KineticAdapter().computeRiskFactors(kineticMainnet); + assert.match(factors.oracleSafety!.detail, /^worst reserve \(CAS3J7…\)/); + assert.doesNotMatch(factors.oracleSafety!.detail, /CCCRWH/); + }); + it('scores oracleSafety 0 on a genuinely stale price, with the breaker armed', async () => { // This is K2's ordinary state, not an unlucky capture: across the // development-era history (since discarded — see METHODOLOGY.md, "Current diff --git a/core/src/scoring.test.ts b/core/src/scoring.test.ts index 180dea5..288cc16 100644 --- a/core/src/scoring.test.ts +++ b/core/src/scoring.test.ts @@ -29,10 +29,13 @@ import type { RiskFactor, RiskFactorMap } from './types.ts'; import { MIN_RESERVE_POOL_SHARE, STALE_CEILING_SECONDS, + describePriceAges, + describeWorst, excludedComponent, freshnessWindow, scoreFactors, sizeReserves, + worstReserves, } from './scoring.ts'; // --------------------------------------------------------------------------- @@ -473,3 +476,174 @@ describe('excludedComponent — the disclosure for what the filter set aside', ( assert.doesNotMatch(components![0].detail, /would have scored \d/); }); }); + +// --------------------------------------------------------------------------- +// worstReserves / describeWorst — every reserve at the binding value, not one. +// +// WHY THESE EXIST: the previous selection kept a single reserve and, comparing +// with `<=`, kept whichever came LAST in iteration order. That produced a +// detail string that reads like a diagnosis but is really a tie-break, and it +// caused a real misdiagnosis — issue #45 was filed claiming K2's oracleSafety +// of 0 traced to a $4.00 dust reserve, when a reserve thirteen times larger was +// equally dead and simply never named. The score was always right; only the +// explanation was wrong. These pin the explanation. +// --------------------------------------------------------------------------- + +describe('worstReserves — keeps every reserve at the binding score', () => { + const r = (asset: string, score: number, note = `note-${asset}`) => ({ asset, score, note }); + + it('reports the minimum, which is what the sub-signal publishes', () => { + assert.equal(worstReserves([r('CA', 90), r('CB', 12), r('CC', 40)]).score, 12); + }); + + it('keeps all reserves tied at the minimum, not the last one seen', () => { + // The old behaviour kept exactly one of these — CC, purely because it came + // last. Both are equally the binding constraint. + const w = worstReserves([r('CA', 90), r('CB', 0), r('CC', 0)]); + assert.deepEqual( + w.tied.map((t) => t.asset), + ['CB', 'CC'], + ); + assert.equal(w.total, 3); + }); + + it('preserves input order in the tied set', () => { + // So the disclosure reads in reserve order rather than in whatever order a + // filter happened to produce. + const w = worstReserves([r('CA', 0), r('CB', 5), r('CC', 0), r('CD', 0)]); + assert.deepEqual( + w.tied.map((t) => t.asset), + ['CA', 'CC', 'CD'], + ); + }); + + it('ties on the exact score, not the published rounded one', () => { + // 12.4 and 12.6 both publish as 12 and 13 respectively but are genuinely + // different scores; calling them tied would swap one misleading claim for + // another. Real ties sit at the clamped ends, where equality is exact. + const w = worstReserves([r('CA', 12.4), r('CB', 12.6)]); + assert.deepEqual( + w.tied.map((t) => t.asset), + ['CA'], + ); + }); + + it('handles an empty set without inventing a reserve', () => { + const w = worstReserves([]); + assert.deepEqual(w, { score: 0, tied: [], total: 0 }); + assert.equal(describeWorst(w), 'no reserves'); + }); +}); + +describe('describeWorst — a tie is described as a tie', () => { + const r = (asset: string, score: number, note: string) => ({ asset, score, note }); + + it('names the reserve when exactly one is worst', () => { + const text = describeWorst(worstReserves([r('CAAAAAAA', 0, '900s old'), r('CB', 90, 'fine')])); + assert.equal(text, 'worst reserve (CAAAAA…) 900s old'); + }); + + it('does not say "worst" when every reserve scores the same', () => { + // Blend's permanent state on freshness: one aggregator publish round prices + // every reserve, so all ages are identical. Calling one of them the worst + // reads as a finding about that reserve when there is no finding at all. + const text = describeWorst( + worstReserves([r('CA', 100, '233s old'), r('CB', 100, '233s old'), r('CC', 100, '233s old')]), + ); + assert.equal(text, 'all 3 reserves score the same — 233s old'); + assert.doesNotMatch(text, /worst/); + }); + + it('says "worst" when only some tie, because the rest really are better', () => { + // K2's case, and the one that produced the misdiagnosis. BOTH dead feeds + // must appear — naming only the second is what made a $4.00 reserve look + // like the sole cause. + const text = describeWorst( + worstReserves([ + r('CUSDCAAA', 0, '19599s old'), + r('CXLMAAAA', 96, '167s old'), + r('CPYUSDAA', 0, '39955s old'), + r('CSOLVAAA', 100, '17s old'), + ]), + ); + assert.equal( + text, + '2 of 4 reserves tied at the worst score — CUSDCA… 19599s old; CPYUSD… 39955s old', + ); + }); + + it('prints a shared note once and differing notes per reserve', () => { + const shared = describeWorst(worstReserves([r('CA', 0, 'same'), r('CB', 0, 'same')])); + assert.equal(shared, 'all 2 reserves score the same — same'); + + const differing = describeWorst(worstReserves([r('CA', 0, 'one'), r('CB', 0, 'two')])); + assert.match(differing, /CA… one; CB… two$/); + }); +}); + +// --------------------------------------------------------------------------- +// describePriceAges — the per-feed staleness disclosure. +// +// WHY IT EXISTS: `priceFreshness` grades the worst reserve, so a reader sees a +// single number and cannot tell the SPREAD. "oracleSafety 0" reads as a general +// condition of the oracle; "two feeds untouched for hours while two others +// update every few seconds, through one contract and one source" is a specific +// checkable claim about which feeds are maintained. Only the second is +// actionable, and it was previously unreadable from anything published. +// --------------------------------------------------------------------------- + +describe('describePriceAges — per-feed ages, disclosed not graded', () => { + const age = (feed: string | null, ageSeconds: number | null, asset = `C${feed}AAAAAA`) => ({ + asset, + feed, + ageSeconds, + }); + + it('surfaces the spread that a single worst-reserve score hides', () => { + // K2's live shape. The point is that both ends are visible at once. + const text = describePriceAges( + [age('USDC', 21421), age('XLM', 177), age('PYUSD', 41777), age('SolvBTC', 27)], + 3600, + ); + assert.match(text, /PYUSD 41777s, USDC 21421s, XLM 177s, SolvBTC 27s/, 'oldest first'); + assert.match(text, /2 of 4 past the protocol's own 3600s staleness limit \(USDC, PYUSD\)/); + }); + + it('orders oldest-first, because the stale end is the point', () => { + const text = describePriceAges([age('A', 1), age('B', 900), age('C', 30)], 3600); + assert.match(text, /B 900s, C 30s, A 1s/); + }); + + it('sorts an unusable price as the oldest, not the freshest', () => { + // No price at all is not fresher than a merely old one; ranking it by a + // missing number would put the worst case at the top of the list. + const text = describePriceAges([age('FRESH', 10), age('MISSING', null)], 3600); + assert.match(text, /MISSING no usable price, FRESH 10s/); + assert.match(text, /1 of 2 past/, 'an unusable price counts as stale'); + }); + + it('says so plainly when every feed is inside the limit', () => { + // Blend's normal state. A disclosure that only appears when something is + // wrong gives a reader no healthy baseline to compare against. + const text = describePriceAges([age('X', 233), age('Y', 233)], 900); + assert.match(text, /all 2 within the protocol's own 900s staleness limit/); + assert.doesNotMatch(text, /past the protocol/); + }); + + it('counts against the protocol’s own limit, never a Stenion one', () => { + // The same ages under two different protocol-declared limits give different + // counts — the claim is "how many the protocol itself would call stale". + const ages = [age('A', 1000), age('B', 100)]; + assert.match(describePriceAges(ages, 900), /1 of 2 past/); + assert.match(describePriceAges(ages, 3600), /all 2 within/); + }); + + it('falls back to the address when a protocol publishes no feed label', () => { + const text = describePriceAges([{ asset: 'CABCDEFGHIJ', feed: null, ageSeconds: 5 }], 900); + assert.match(text, /CABCDE… 5s/); + }); + + it('handles a pool with nothing to report', () => { + assert.equal(describePriceAges([], 3600), 'no reserves to report a price age for'); + }); +}); diff --git a/core/src/scoring.ts b/core/src/scoring.ts index 8847436..34d2ca1 100644 --- a/core/src/scoring.ts +++ b/core/src/scoring.ts @@ -196,7 +196,7 @@ export interface ExcludedReserve extends ReserveSize { * Excluding a reserve from scoring is not the same as it not existing, so the * excluded set is published rather than silently dropped — as a `value: null` * component, the established form for "measured, shown, deliberately not graded" - * (METHODOLOGY.md §2c). It names each reserve, its supplied USD, its share of + * (METHODOLOGY.md §2c/§2d). It names each reserve, its supplied USD, its share of * the pool, and crucially **the score it would have contributed**, so a reader * can see the number we suppressed and disagree with us about it. * @@ -235,3 +235,161 @@ export function excludedComponent( function formatUsd(value: number): string { return value >= 1000 ? Math.round(value).toLocaleString('en-US') : value.toFixed(2); } + +/** One reserve's result on a single sub-signal, before the worst is picked. */ +export interface ScoredReserve { + /** the reserve's asset contract address */ + asset: string; + /** 0-100, higher = safer, same convention as everything else */ + score: number; + /** what produced that score, e.g. "38694s old (fresh<30s, dead>3600s)" */ + note: string; +} + +/** The binding score on a sub-signal, and **every** reserve sitting at it. */ +export interface WorstReserves { + /** the minimum score across the reserves — what the sub-signal publishes */ + score: number; + /** every reserve tied at `score`, in input order. Empty only when there were no reserves. */ + tied: ScoredReserve[]; + /** how many reserves were considered, so "2 of 4" can be said */ + total: number; +} + +/** + * Take the worst reserve on a sub-signal — and keep **all** of them when several + * tie, rather than one arbitrary winner. + * + * Worst-reserve selection is the house convention across factors: the binding + * constraint is the single weakest reserve, and averaging would hide it. What + * changed here is only the *reporting*, never the score — `score` is the same + * minimum it always was. + * + * **Why keeping the whole tied set matters.** The previous version kept one + * reserve, and because it compared with `<=`, the one it kept was whichever + * happened to come last in iteration order. That is not a diagnosis, but it + * reads exactly like one: + * + * - On Blend, all reserves share a single aggregator publish round, so their + * ages are *identical* and they always tie. The reserve named across ~1,459 + * stored runs was therefore pure iteration order, carrying no information at + * all while looking like a specific finding. + * - On K2 it caused a real misdiagnosis. With USDC and PYUSD both pinned at 0, + * the detail named only PYUSD — a $4.00 dust reserve — which made + * `oracleSafety` look like it hinged on a reserve too small to matter. It did + * not: USDC, thirteen times larger, was equally dead. An issue was filed on + * that wrong premise and had to be corrected from chain data. + * + * A name that is really a tie-break is worse than no name, so ties are now + * disclosed as ties. + * + * Ties are compared on the **exact** score, not a rounded one. Two reserves that + * round to the same published integer from genuinely different scores are not + * tied, and saying they are would trade one misleading claim for another. In + * practice the real ties sit at the clamped ends (0 and 100), where equality is + * exact. + */ +export function worstReserves(scored: readonly ScoredReserve[]): WorstReserves { + if (scored.length === 0) return { score: 0, tied: [], total: 0 }; + let min = Number.POSITIVE_INFINITY; + for (const r of scored) if (r.score < min) min = r.score; + return { score: min, tied: scored.filter((r) => r.score === min), total: scored.length }; +} + +/** + * The human-readable phrase for a `worstReserves` result, shared so two adapters + * cannot describe the same situation two different ways. + * + * Shapes, because genuinely different things can be true: + * + * - one reserve is worst -> names it, as before + * - EVERY reserve ties -> "all N reserves score the same". Deliberately not + * worded as "worst": when nothing is worse than anything, calling the shared + * value the worst reads as a finding about one reserve, which is the exact + * misreading this function exists to stop. Blend sits here permanently on + * freshness, since one publish round prices every reserve. + * - SOME reserves tie -> "k of n tied at the worst score", where "worst" is + * accurate because reserves outside the tie really are better. + * + * In each multi-reserve case the note is printed once if every tied reserve + * shares it (repeating one identical age three times is noise), and per-reserve + * otherwise — K2's two dead feeds have the same score and very different ages, + * and both numbers matter. + */ +export function describeWorst(worst: WorstReserves): string { + const { tied, total } = worst; + if (tied.length === 0) return 'no reserves'; + if (tied.length === 1) return `worst reserve (${shortAsset(tied[0].asset)}) ${tied[0].note}`; + + const uniform = tied.every((r) => r.note === tied[0].note); + const scope = + tied.length === total + ? `all ${total} reserves score the same` + : `${tied.length} of ${total} reserves tied at the worst score`; + return uniform + ? `${scope} — ${tied[0].note}` + : `${scope} — ${tied.map((r) => `${shortAsset(r.asset)} ${r.note}`).join('; ')}`; +} + +/** First 6 characters of a contract address, the shared convention in detail strings. */ +function shortAsset(asset: string): string { + return `${asset.slice(0, 6)}\u2026`; +} + +/** One reserve's price age, for the per-feed staleness disclosure. */ +export interface ReserveAge { + /** the reserve's asset contract address */ + asset: string; + /** + * The protocol's own label for the upstream feed this asset is priced from — + * K2's `feedId` ("USDC"), Blend's aggregator `upstreamAsset` ("Other:XLM"). + * Null when the protocol publishes no label; the address is used instead. + */ + feed: string | null; + /** seconds since the price was published, or null when there is no usable price */ + ageSeconds: number | null; +} + +/** + * Per-feed price ages, published as a disclosure and never graded. + * + * `priceFreshness` grades the worst reserve, and its detail names every reserve + * tied at that worst value. Neither tells a reader what the SPREAD looks like — + * and the spread is the informative part. A factor value of 0 reads as a + * general condition of the oracle; "two feeds have not updated in hours while + * the other two update every few seconds, through one contract and one source" + * is a specific, checkable statement about which feeds are maintained. The + * second is what a depositor can act on, and it was previously unreadable from + * anything we publish. + * + * Disclosure, not a score (`value: null`, per METHODOLOGY.md §2c): these are the + * raw inputs `priceFreshness` was computed from, republished so the grading can + * be checked rather than taken on faith. Grading the spread separately would + * double-count the same staleness the factor already measures. + * + * Ordered oldest-first, because the disclosure exists to surface the stale end. + * `staleAfterSeconds` is the protocol's OWN declared limit, never a Stenion + * constant — the count it produces is "how many feeds the protocol itself would + * consider stale", which is a claim about the protocol's own rules. + */ +export function describePriceAges(ages: readonly ReserveAge[], staleAfterSeconds: number): string { + if (ages.length === 0) return 'no reserves to report a price age for'; + + const label = (a: ReserveAge) => a.feed ?? shortAsset(a.asset); + // Unpriced sorts oldest: a feed with no usable price at all is not fresher + // than one that is merely old. + const rank = (a: ReserveAge) => (a.ageSeconds === null ? Number.POSITIVE_INFINITY : a.ageSeconds); + const sorted = [...ages].sort((x, y) => rank(y) - rank(x)); + + const listed = sorted + .map((a) => `${label(a)} ${a.ageSeconds === null ? 'no usable price' : `${a.ageSeconds}s`}`) + .join(', '); + const stale = ages.filter((a) => a.ageSeconds === null || a.ageSeconds > staleAfterSeconds); + + const summary = + stale.length === 0 + ? `all ${ages.length} within the protocol's own ${staleAfterSeconds}s staleness limit` + : `${stale.length} of ${ages.length} past the protocol's own ${staleAfterSeconds}s staleness limit (${stale.map(label).join(', ')})`; + + return `${listed} — ${summary}. Reported, not graded: priceFreshness already scores the worst of these.`; +} diff --git a/core/src/types.ts b/core/src/types.ts index fb1fd5f..d7141d8 100644 --- a/core/src/types.ts +++ b/core/src/types.ts @@ -132,7 +132,7 @@ export enum RiskFactorType { * - **Disclosure** (`value` null): a real, readable on-chain quantity we * publish but deliberately do *not* score, because scoring it would invent * comparability the underlying data doesn't support. `detail` carries the raw - * figure. See METHODOLOGY.md §2 on why deviation-bound *tightness* is + * figure. See METHODOLOGY.md §2d on why deviation-bound *tightness* is * disclosed rather than graded. * * A null `value` is therefore never "missing data" — it means "measured, shown, diff --git a/dashboard/app/lib/contract.ts b/dashboard/app/lib/contract.ts index e1802a1..e9ada5e 100644 --- a/dashboard/app/lib/contract.ts +++ b/dashboard/app/lib/contract.ts @@ -29,7 +29,7 @@ export interface LeaderboardEntry { * A named sub-signal inside a factor. A numeric `value` is a scored component * that fed the parent factor; `value: null` is a DISCLOSURE — a real on-chain * quantity published deliberately without a score, because grading it would - * invent comparability the data doesn't support (METHODOLOGY.md §2c). Null is + * invent comparability the data doesn't support (METHODOLOGY.md §2c/§2d). Null is * never "missing data" here. */ export interface RiskFactorComponent { diff --git a/dashboard/app/lib/protocol-notes.ts b/dashboard/app/lib/protocol-notes.ts index aa4fa1c..43bf202 100644 --- a/dashboard/app/lib/protocol-notes.ts +++ b/dashboard/app/lib/protocol-notes.ts @@ -28,24 +28,25 @@ export const PROTOCOL_NOTES: Record = { { title: "The price feed is older than K2's own staleness threshold in most observed runs", body: [ - 'Between 2026-08-11 18:16 and 2026-08-14 11:45 UTC, Stenion recorded 267 scored ' + - "runs against K2. In 243 of them — 91% — the worst-priced reserve's price was " + - 'older than 3600 seconds, which is price_staleness_threshold: K2’s own ' + - 'on-chain limit, not a Stenion constant. The oldest price observed in that ' + - 'window was 43,147 seconds (11h 59m), within a minute of the per-asset max_age ' + - 'of 43,200 seconds.', + 'Between 2026-08-11 18:16 and 2026-08-18 15:55 UTC, Stenion recorded 1,469 scored ' + + 'runs against K2. In 1,370 of them — 93% — oracleSafety was 0. The distribution ' + + 'is close to all-or-nothing: 1,370 runs at 0 against 3 runs at 100, rather than ' + + 'spread across the range. In every run of that window where the sub-signals ' + + 'were recorded separately, the deviation-bound signal was 100 — so the zeroes ' + + 'are price age, not a missing circuit breaker.', + 'The threshold behind that is 3600 seconds: price_staleness_threshold, K2’s own ' + + 'on-chain limit, not a Stenion constant. The oldest single price observed in ' + + 'the window was 41,777 seconds (11h 36m).', 'It is not a continuous outage. The feed refreshes to an age of a few hundred ' + 'seconds, climbs back past the threshold over roughly an hour, and then sits ' + 'there for hours before refreshing again. That cycle is what makes the overall ' + 'score oscillate, and it is visible as a repeating sawtooth in the score ' + 'history above — the oscillation is the price ageing out and being renewed, ' + 'not the pool’s risk genuinely changing every few minutes.', - 'The runs behind this were recorded during development, and that stored history ' + - 'has since been discarded — Stenion’s score history now starts fresh under the ' + - 'published methodology, so these counts cannot be re-derived from the API. What ' + - 'is stated here is an observation that was made, not a claim about rows you can ' + - 'still fetch. The condition itself is checkable on-chain today by anyone, by the ' + - 'steps below.', + 'These counts come from the live scored history the API serves, over the closed ' + + 'window stated above — they are a snapshot of that window and do not update as ' + + 'new runs land. The condition itself is checkable on-chain directly, by the ' + + 'steps below, without taking our history on trust.', 'What is not being claimed: the circuit breaker was scored 100 throughout, so ' + 'the bound on a single-step price move is armed — this is purely about ' + 'freshness. And a price past a staleness threshold does not by itself mean the ' + @@ -67,6 +68,52 @@ export const PROTOCOL_NOTES: Record = { 'close time. Read the thresholds from the same contract: get_oracle_config for ' + 'price_staleness_threshold, and get_asset_config per asset for max_age.', }, + { + title: 'Individual price feeds go stale for hours while others update in seconds', + body: [ + 'All four of K2’s reserves are priced by the same oracle contract ' + + '(CCHRZE2K…) through the same batchAdapter source. Their prices do not age ' + + 'together. In one reading on 2026-08-18, SolvBTC was 27 seconds old and XLM 177 ' + + 'seconds old, while USDC was 21,421 seconds old (5h 57m) and PYUSD 41,777 ' + + 'seconds old (11h 36m). Repeated readings minutes apart showed the same split, ' + + 'with the two stale ages advancing in step with wall-clock time — meaning those ' + + 'entries were not being refreshed at all during the observation, rather than ' + + 'being sampled at an unlucky moment.', + 'For scale against K2’s own limits: price_staleness_threshold is 3600 seconds, so ' + + 'the USDC reading exceeded it roughly sixfold and the PYUSD reading elevenfold. ' + + 'The per-asset max_age values are looser — 43,200 seconds for XLM and SolvBTC, ' + + '86,400 for USDC and PYUSD — and neither stale reading had reached those.', + 'What this changes about the picture above: the staleness is not the whole oracle ' + + 'going quiet and coming back. The oracle is demonstrably alive and serving some ' + + 'assets within seconds while others sit untouched for hours, through one ' + + 'contract and one source. Freshness here is a property of the individual feed ' + + 'entry, not of the oracle as a whole.', + 'USDC is the reading worth noting rather than PYUSD. PYUSD held about $4 of ' + + 'supplied value at the time and USDC about $54 — both small in absolute terms, ' + + 'but USDC is well above the size line Stenion uses elsewhere, so this is not a ' + + 'question of an abandoned dust market. A lending protocol valuing a stablecoin ' + + 'position off a six-hour-old reading is the observable condition.', + 'What is not being claimed: nothing here says the protocol acted on a stale ' + + 'price. K2’s own code may reject it and revert whatever operation depended on ' + + 'it — that is the same limit noted above, and it applies equally here. What is ' + + 'observable from outside is the age of the price the oracle serves. Nor is any ' + + 'cause implied: an upstream feed pausing, a per-asset configuration, and a ' + + 'deliberate choice all look identical from here.', + 'Every reserve’s price age is now published on each run in the oracleSafety ' + + 'factor’s priceAges component, alongside a count of how many exceed K2’s own ' + + 'threshold. It is reported, not scored — the freshness score already grades the ' + + 'worst of them, and grading the spread again would count the same staleness ' + + 'twice.', + ], + verify: + 'Call get_asset_prices_vec_fresh on CCHRZE2K…5BNOMQRMU for the four assets in ' + + 'get_reserves_list on the router (CCTUJZLY…AWNXOJIV6J7) and compare each ' + + 'PriceData.timestamp against the current ledger close time — read them together ' + + 'in one call so the ages are directly comparable. Repeat a few minutes later: an ' + + 'age that grows by the elapsed wall-clock time is an entry that is not being ' + + 'refreshed. Read get_asset_config per asset for source and max_age, and ' + + 'get_oracle_config for price_staleness_threshold.', + }, { title: 'The deployed price oracle is a superset of its audited version', body: [ diff --git a/dashboard/components/factor-bar.tsx b/dashboard/components/factor-bar.tsx index 52ed340..e52738b 100644 --- a/dashboard/components/factor-bar.tsx +++ b/dashboard/components/factor-bar.tsx @@ -80,7 +80,7 @@ export function FactorCard({ } /** - * A factor's sub-signals, split the way METHODOLOGY.md §2c splits them: the + * A factor's sub-signals, split the way METHODOLOGY.md §2c/§2d split them: the * scored components the value was computed from, then the disclosures * (`value: null`) that are published but deliberately not graded. Rendering * them as one flat list made a disclosure look like a component that had simply