Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 46 additions & 3 deletions METHODOLOGY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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_
Expand Down Expand Up @@ -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.

Expand Down
18 changes: 0 additions & 18 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 71 additions & 3 deletions adapters/blend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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
Expand Down Expand Up @@ -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/);
});
});

Expand Down Expand Up @@ -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/);
});
});
50 changes: 36 additions & 14 deletions adapters/blend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -765,20 +768,39 @@ export class BlendAdapter implements Adapter<BlendRawData> {
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',
Expand Down
Loading
Loading