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
119 changes: 117 additions & 2 deletions METHODOLOGY.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@ If the code and this document ever disagree, that is a bug — open an issue (se
## Current version

**Methodology v1** — the rulebook described by this document, in full, including `oracleSafety`
scoring both price freshness and manipulation resistance (§2), and the minimum-size filter §4
and §5 select reserves through ([The minimum-size filter](#the-minimum-size-filter)).
scoring both price freshness and manipulation resistance (§2), the minimum-size filter §4
and §5 select reserves through ([The minimum-size filter](#the-minimum-size-filter)), and the
[market-size floor](#the-market-size-floor) that decides whether a market is scorable at all.
The floor is a precondition rather than a formula — it moves no number and did not bump this
version.

**Versioning begins here.** `methodology_version = 1` is the only version this rulebook
defines, and the only one any stored row will carry. A version 2 was briefly live in the code
Expand Down Expand Up @@ -736,6 +739,118 @@ anchor.

---

### The market-size floor

The [minimum-size filter](#the-minimum-size-filter) one level up. That filter asks whether a
_reserve_ is big enough for its number to mean anything; this asks the same of a whole
**market**. They are two halves of one idea — a size below which a published number stops
carrying information — and they are written together so neither looks like an afterthought.

**The problem it solves.** K2 deploys its markets as separate router contracts running
identical code, the same way Blend's factory deploys pools. Three are live on mainnet as of
2026-08-20, and two of them are empty:

| Market | Reserves | Total priced supplied value |
| ------------------------------------- | ------------------------- | --------------------------- |
| K2 primary (`CCTUJZLY…`) | USDC, XLM, PYUSD, SolvBTC | **$1,781** |
| K2 SolvBTC/xSolvBTC iso (`CCGXGXIL…`) | SolvBTC, xSolvBTC | **$3.62** |
| K2 Earn / earnUSDC (`CDWPVHKB…`) | USDC, earnUSDC | **$0.00** |

Point the shipped rulebook at either of the bottom two and it does not fail — it returns a
**score**. Every factor falls to its can't-assess branch, and every one of those branches is
**0**. So a market holding nothing publishes 0, in the danger band, reading "this is
dangerous" to anyone scanning the registry when what is true is "there is nothing in here."
That is the same misleading-number failure §4 and §5 were filtered for, one level up, and it
is worse at this level: a filtered reserve still leaves a scored market with a disclosure
beside it, whereas this is the market's entire published number.

**The rule.** A market is scorable only if it can hold **at least one position the protocol
itself considers viable**:

| Leg | Test | Anchor |
| ----- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| **A** | total priced supplied USD `≥` the protocol's own declared minimum viable position | the protocol's own on-chain parameter, where it declares one |
| **B** | **none — no relative leg exists at this scale.** See below; this is a real gap, not an omission | — |

| Protocol / market | Leg A source | Value |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
| **Blend** (both pools) | `PoolConfig.min_collateral`, read per pool | `50000000` = **$5.00** |
| **Kinetic (K2)** | **none declared on chain** — the $5.00 above is borrowed as an analogue, and is a flagged judgment call for K2, not an anchor | **$5.00** |

This is the same parameter, and the same reasoning, that §4/§5's leg A already uses:
`min_collateral` is the protocol's own statement of the smallest collateral a position may
hold and still borrow. A market whose **entire** supplied value sits below it cannot host even
one position the protocol itself would let borrow. There is nothing there to assess, and the
number is not a measurement of risk — it is a measurement of absence.

Against the table above: K2 Earn fails on total supplied value of exactly zero. The
SolvBTC/xSolvBTC market fails at $3.62. K2's primary market clears by three orders of
magnitude, as do both Blend pools.

**Why there is no relative leg, unlike §4/§5.** The reserve filter has two legs because each
covers a failure the other has. No such second leg exists here. Relative to the market's own
reserves is what §4 and §5 already do. Relative to the other markets in the registry would
make one market's listing depend on **another market's** size — a market could become
unlistable because a different one grew, while nothing about its own on-chain state changed.
A rule about a market's own data must not have that property. So this floor is absolute-only,
which is precisely the shape §4/§5 rejected as insufficient on its own, and that limitation is
the reason it is set low rather than at a number that sounds meaningful.

> **The direction of error is deliberately toward keeping markets in.** Two reasons, and the
> asymmetry is not the same one §4/§5 reasoned about:
>
> - **Raising it buys nothing against the failure it exists for.** A score computed from no
> data is fully prevented at $5. Every dollar above that excludes markets that genuinely
> _can_ be assessed, in exchange for nothing.
> - **Excluding a market is a much stronger action than excluding a reserve.** A filtered
> reserve leaves a scored market and a published disclosure naming what was suppressed. An
> excluded market has no entry at all — no score, no factors, no disclosure, nothing for a
> reader to disagree with. §4/§5 already call hiding a small-but-real reserve "strictly
> worse" than leaving a dust one in; at market scale that error hides everything at once.

**What this floor guarantees — and what it does not.** It guarantees only that a published
number was computed from **something rather than nothing**. It is emphatically **not** a
quality bar: a market holding $50 clears it, and its score would still be close to
meaningless. Saying so plainly matters more than the threshold does, because a floor that
sounds like a meaningfulness test while being a scorability test is worse than no floor.

> **⚠️ A separate question this deliberately does NOT answer: is a scorable market worth
> listing?** K2's primary market is a registered, ranked entry holding **$1,781**. It clears
> this floor by three orders of magnitude and is still small enough that a reasonable person
> could ask whether ranking it beside a $185M pool conveys what the ranking appears to convey.
>
> That is a **curation** question — what belongs in the registry — not a question about whether
> a number can be computed, and answering it with a threshold in this document would dress an
> editorial judgment as a measurement. It also has a consequence a scoring threshold does not:
> any such bar set above $1,781 would **delist a live entry**, breaking a public URL
> (`/protocol/kinetic`) and orphaning a published history. Flagged here, resolved nowhere yet.

> **⚠️ An excluded market has NO score. It does not have a score of zero.** This is the
> market-level form of the warning under §4/§5 about "cannot assess" quietly becoming a number
> again, and it is the whole reason the floor is written down.
>
> - **It must mean:** the market is not registered. If a registered market later falls below
> the floor, its published `safetyScore` must become **`null`** — the never-scored
> representation the API already defines and the dashboard already renders as an em dash.
> - **It must never mean:** a score of **0** (which renders in the danger band and says the
> opposite of what is true), a score of **100**, or — the live hazard — **registering the
> market and letting the five factors fall to their can't-assess branches**, which is exactly
> what the shipped code does today and exactly how an empty market publishes a 0.
>
> **Enforcement, stated honestly: this floor is currently enforced only by the decision not to
> register such a market.** No code path implements it. Nothing in an adapter, the indexer or
> the store can express "this market is not scorable" as distinct from "this market scored 0",
> so a registered market that drained below the floor would keep publishing a number today.
> Closing that needs a distinct not-scorable outcome through `Adapter` and `RunRecord`; it is
> filed in [`ROADMAP.md`](ROADMAP.md) rather than implied to exist here.

**This does not bump `METHODOLOGY_VERSION`.** It moves no published number: every market
Stenion currently scores — both Blend pools and K2's primary market — clears the floor, so no
stored score is computed differently and none becomes non-comparable. It documents a
precondition on what gets scored at all, which is additive.

---

### 5. `utilizationSafety` — headroom below the configured cap (weight 0.20)

**What it measures:** how close live utilization is to the protocol's own on-chain
Expand Down
59 changes: 59 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ commitment — priorities shift as protocols launch and as the project finds fun
- **The five-factor `*Safety` model** — collateral concentration, oracle trustworthiness, admin-key
control, liquidity depth, utilization headroom — with a fully public, challengeable rulebook in
[`METHODOLOGY.md`](METHODOLOGY.md).
- **Two size floors, both published.** The reserve-level
[minimum-size filter](METHODOLOGY.md#the-minimum-size-filter) decides which reserves may set
§4/§5's number; the [market-size floor](METHODOLOGY.md#the-market-size-floor) decides whether a
market is scorable at all. They are the same idea at two scales — a size below which a number
stops carrying information — and the second exists because an empty market does not fail, it
publishes **0**, in the danger band, meaning the opposite of what is true. Neither moved a
published score; the floor is a precondition, not a formula.
- **Oracle robustness.** `oracleSafety` scores price freshness _and_ manipulation
resistance: whether the pool's own price path bounds how far a single update can move, read from
the protocol's own on-chain config. Freshness is anchored to each oracle's real resolution and
Expand Down Expand Up @@ -105,6 +112,58 @@ Roughly in priority order, but not committed to dates:
Wanted, but it is a payload-size and query-cost decision on free tiers, not a UI tweak — likely a
separate downsampled endpoint or a `?window=` parameter rather than simply raising the cap, since
the detail response is already the largest thing the API serves.
- **K2 multi-market targeting — available, deliberately unused.** K2 deploys markets the way
Blend's factory deploys pools: separate router contracts running byte-identical code. Three are
live on mainnet (all wasm `df2831cf…`, sharing one oracle, one `PADMIN` and one treasury, each
with its own configurator):

| Router | What | Reserves | Supplied |
| ----------- | ------------------------------------------------------ | ------------------------- | -------- |
| `CCTUJZLY…` | K2's primary pooled market — **the `kinetic` entry** | USDC, XLM, PYUSD, SolvBTC | ~$1,781 |
| `CCGXGXIL…` | SolvBTC / xSolvBTC isolated market, K2-listed | SolvBTC, xSolvBTC | $3.62 |
| `CDWPVHKB…` | Earn (earnUSDC/USDC), third-party, run by Gami/Upshift | USDC, earnUSDC | $0.00 |

**The refactor is not needed to register them — the floor is.** `KineticAdapter` already takes a
`routerId`, exactly as `BlendAdapter` took a `poolId` before the multi-pool change, so
generalising it to a `KineticMarket` config would be the same shape of work and is available the
day a market qualifies. It is **not built**, because neither additional market clears the
[market-size floor](METHODOLOGY.md#the-market-size-floor) and building it now would be dead code
guarding an empty list.

**What K2's own market types turned out to be**, since the docs and the chain do not agree on
this and it is the question that started the survey:

- **Pooled vs isolated is Aave-V3 configuration** — the router wasm carries `IsolationModeData`,
`isolation_mode_enabled`, `UserInIsolationMode` and `get_reserve_debt_ceiling`, all per-reserve
settings inside one pool. **K2 does not use it that way:** every reserve on all three routers
reports `debt_ceiling = 0`, and the isolated market was given its own router instead.
- **Third-party markets are genuinely separate contracts**, and K2 says so: "Isolation is enforced
at the contract level: collateral and debt in a third-party market cannot be combined with
positions in K2's primary market."
- **"Gated" — not found, and not asserted absent.** No market type by that name appears in the
router's exported interface or in any documentation page reachable from
`docs.k2lend.com/llms.txt`, which calls that section **Third Party Markets**. The nearest
on-chain machinery is per-reserve allow/deny lists (`RWLMAP`/`RBLMAP`,
`get_reserve_whitelist`, `is_whitelisted_for_reserve`) — router configuration, not a separate
contract. If the term is current somewhere not reachable from that index, this is a gap in what
was read, not a finding that no such thing exists.

- **A not-scorable run outcome, distinct from a score of 0.** The
[market-size floor](METHODOLOGY.md#the-market-size-floor) is currently enforced **only by the
decision not to register a market below it** — nothing in `Adapter`, the indexer or `RunRecord`
can express "this market is not scorable" as distinct from "this market scored 0". So a
registered market that drained below the floor would keep publishing a number computed from five
can't-assess branches, which is the exact failure the floor is written against.

The representation already exists at the far end: `safetyScore: null` is the never-scored state,
documented in [`API.md`](API.md) and rendered as an em dash rather than a zero. What is missing is
a path to reach it deliberately. That is a third `RunRecord` status alongside `ok`/`failed` — and
a `failed` row is the wrong home for it, because a market that is empty has not failed: the
adapter read it perfectly and the answer is that there is nothing to score. A new status touches
the DB CHECK constraint, the `ok`/`failed` union every API consumer parses (a **breaking** change,
so `v2` under the versioning policy), and the score chart's break rendering. Filed rather than
slipped in.

- **The Kinetic / K2 naming mismatch.** The protocol rebranded to **K2** (k2lend.com). Stenion still
displays `name: 'Kinetic'`, and now shows it beside the K2 mark — the logo work made an existing
inconsistency visible rather than creating it. Renaming isn't cosmetic: `id: 'kinetic'` is the
Expand Down
Loading
Loading