From 8d25f30e7ae63c636a0162a7338fd399517d4dd3 Mon Sep 17 00:00:00 2001 From: dubemoyibe-star Date: Thu, 20 Aug 2026 11:48:46 +0100 Subject: [PATCH] Document a market-size floor alongside the reserve filter, and record K2's three live markets --- METHODOLOGY.md | 119 +++++++++++++++++++++++++++- ROADMAP.md | 59 ++++++++++++++ dashboard/app/lib/protocol-notes.ts | 55 +++++++++++++ 3 files changed, 231 insertions(+), 2 deletions(-) diff --git a/METHODOLOGY.md b/METHODOLOGY.md index f3f3663..de1fb3d 100644 --- a/METHODOLOGY.md +++ b/METHODOLOGY.md @@ -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 @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 6643a99..3644d83 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 @@ -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 diff --git a/dashboard/app/lib/protocol-notes.ts b/dashboard/app/lib/protocol-notes.ts index 8279ef0..f71f1b6 100644 --- a/dashboard/app/lib/protocol-notes.ts +++ b/dashboard/app/lib/protocol-notes.ts @@ -108,6 +108,16 @@ export const PROTOCOL_NOTES: Record = { '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.', + 'Still running as of 2026-08-20. A fresh reading two days after the one above found ' + + 'SolvBTC at 26 seconds, XLM at 71, USDC at 618 and PYUSD at 23,304 (6h 28m) — the ' + + 'same split, from the same oracle, in the same call. Two things are worth taking ' + + 'from the repeat rather than from either reading alone. The condition is a standing ' + + 'property of these feeds and not a moment we happened to catch. And WHICH feed is ' + + 'stale moves: USDC was 21,421 seconds old on 2026-08-18 and 618 seconds old on ' + + '2026-08-20, while PYUSD exceeded K2’s 3,600-second threshold on both. So a reader ' + + 'should not take the specific assets named here as the affected set — the pattern is ' + + 'that some entry is hours behind while others are seconds behind, not that it is ' + + 'always this one.', ], verify: 'Call get_asset_prices_vec_fresh on CCHRZE2K…5BNOMQRMU for the four assets in ' + @@ -149,6 +159,51 @@ export const PROTOCOL_NOTES: Record = { 'Soroban RPC getLedgerEntries and read the wasm export section; compare against ' + 'contracts/price-oracle/src/contract.rs in code-423n4/2026-04-k2.', }, + { + title: 'Two of K2’s live contracts are missing or out of date in its published contract list', + body: [ + 'K2 runs its markets as separate router contracts rather than as configurations inside ' + + 'one pool. Three are live on Stellar mainnet, all deployed from byte-identical code ' + + '(wasm df2831cf…), sharing one price oracle, one pool admin and one treasury, each ' + + 'with its own configurator: the primary pooled market (CCTUJZLY…), a SolvBTC/xSolvBTC ' + + 'isolated market (CCGXGXIL…), and an Earn market for earnUSDC/USDC (CDWPVHKB…) ' + + 'operated by a third party, Gami/Upshift.', + 'The isolated market’s router does not appear on K2’s contracts page. That page lists ' + + 'the xSolvBTC market as a set of reserve token addresses with no router among them, ' + + 'and repeats the primary market’s SolvBTC aToken and debt ledger beside them — which ' + + 'reads as though the market sits inside the primary pool. It does not. The router ' + + 'address above was not read from any documentation: it came from the pool_address ' + + 'field in the xSolvBTC aToken’s own instance storage, whose State also names it ' + + '"K2 Iso Interest Bearing SolvBTC" (kiSolvBTC). Calling get_reserve_data for xSolvBTC ' + + 'on the primary router returns Error(Contract, #24) — it is not a reserve there.', + 'The Earn market’s contract table is out of date in the other direction. It gives the ' + + 'earnUSDC aToken and debt ledger as "TBA" and says they "will be added once ' + + 'deployed". Both are deployed and wired: the Earn router’s get_reserves_list returns ' + + 'earnUSDC alongside USDC, and get_current_reserve_data resolves an aToken at ' + + 'CCOPG2ZQ… and a debt ledger at CBO4TOFT….', + 'What this changes for Stenion’s coverage, stated plainly: this entry scores the ' + + 'primary market only. It is not a score for K2 as a whole, and the two other markets ' + + 'are unscored rather than assessed and passed over. Neither holds enough to be ' + + 'scorable — the isolated market held $3.62 and the Earn market held nothing at all ' + + 'when read on 2026-08-20 — so they fall below the market-size floor in the ' + + 'methodology, which is why there is no entry for either.', + 'What is not being claimed: nothing here says the documentation is wrong about what ' + + 'the contracts do, that anything is hidden, or that any market is unsafe. ' + + 'Documentation lagging deployment is ordinary, and an empty market is an empty ' + + 'market rather than a defective one. What is reported is the divergence itself — ' + + 'a live market reachable only by reading contract storage, and a deployed pair ' + + 'described as pending — because a reader who takes the published list as complete ' + + 'gets a different picture of the protocol than the chain gives.', + ], + verify: + 'Read the instance storage of the xSolvBTC aToken (CBMGL7ZL…HGYJ6JALVY) via Soroban ' + + 'RPC getLedgerEntries and take pool_address from its State; call get_reserves_list and ' + + 'is_paused on the router it names. Compare against the tables at ' + + 'docs.k2lend.com/contracts and docs.k2lend.com/third-party-markets/contract-addresses. ' + + 'For the Earn market, call get_reserves_list on CDWPVHKB…KTPF6TZE and ' + + 'get_current_reserve_data for each asset, and compare the a_token_address it returns ' + + 'against the "TBA" row. Balances come from total_supply on each aToken and debt ledger.', + }, { title: 'get_price_with_protection provides no protection', body: [