diff --git a/API.md b/API.md index 0f348d0..a664b74 100644 --- a/API.md +++ b/API.md @@ -89,6 +89,14 @@ curl https://stenion.vercel.app/api/v1/protocols **Response** `200 OK` +> **⚠️ These captures predate `deployedOn` and the `yieldblox` entry.** Every example in this +> document is a verbatim `curl` against production, never written from the types — which means an +> example cannot show a field that has not deployed yet. Both captures below are real responses from +> 2026-08-19, before the multi-pool change shipped; they are otherwise accurate, and the field +> tables are authoritative in the meantime. **Re-capture both on the next deploy** +> (`curl https://stenion.vercel.app/api/v1/protocols` and `…/protocol/yieldblox`) rather than +> hand-adding the field here. + ```json { "protocols": [ @@ -116,20 +124,60 @@ curl https://stenion.vercel.app/api/v1/protocols } ``` -| Field | Type | Notes | -| --------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------- | -| `id` | string | Stable identifier, **case-sensitive**, used as the path segment on the detail endpoint. | -| `name` | string | Display name. | -| `chain` | string | Currently always `"stellar"`. | -| `logo` | string or null | Root-relative path to a mark **Stenion hosts** — prefix with the base host. `null` is a normal state, not a broken image. | -| `safetyScore` | number or null | 0–100, higher = safer. From the latest **`ok`** run. `null` means never successfully scored — not "zero", not "unsafe". | -| `computedAt` | string or null | ISO 8601 UTC. When that score was computed. `null` if and only if `safetyScore` is `null`. | -| `lastRunAt` | string or null | ISO 8601 UTC. The most recent run of **any** status. See [Staleness](#staleness-is-your-problem-too). | -| `lastRunStatus` | `"ok"`, `"failed"`, null | Status of that most recent run. `null` means the protocol has never been run at all. | +| Field | Type | Notes | +| --------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | string | Stable identifier, **case-sensitive**, used as the path segment on the detail endpoint. | +| `name` | string | Display name. | +| `chain` | string | Currently always `"stellar"`. | +| `logo` | string or null | Root-relative path to a mark **Stenion hosts** — prefix with the base host. `null` is a normal state, not a broken image. | +| `deployedOn` | object or null | **Present when this entry is not an independent protocol** — see [Not every entry is a protocol](#not-every-entry-is-a-protocol). `null` means it runs on its own contracts. | +| `safetyScore` | number or null | 0–100, higher = safer. From the latest **`ok`** run. `null` means never successfully scored — not "zero", not "unsafe". | +| `computedAt` | string or null | ISO 8601 UTC. When that score was computed. `null` if and only if `safetyScore` is `null`. | +| `lastRunAt` | string or null | ISO 8601 UTC. The most recent run of **any** status. See [Staleness](#staleness-is-your-problem-too). | +| `lastRunStatus` | `"ok"`, `"failed"`, null | Status of that most recent run. `null` means the protocol has never been run at all. | The board deliberately carries no `contractId`, `site`, or `docs` — those are verification detail nobody acts on from a list, and repeating them on every row of every fetch is waste. They live on -the detail response. +the detail response. `deployedOn` is the exception, and for the opposite reason: it is not detail +you look up after deciding to care, it is part of what the row _is_, and a reader who scans the +board and leaves has to have seen it. + +--- + +## Not every entry is a protocol + +Some entries are **individual markets running another protocol's contracts**, not protocols in their +own right. The YieldBlox entry (`yieldblox`) is one: it is a DAO-managed pool on Blend V2, running +Blend's pool contract byte-for-byte, and Stenion scores it with the same adapter it uses for Blend's +own pool. + +Such an entry carries a non-null `deployedOn` on **both** endpoints: + +```json +"deployedOn": { "host": "Blend", "label": "Blend V2 pool" } +``` + +| Field | Type | Notes | +| ------- | ------ | ----------------------------------------------------------------------------------- | +| `host` | string | The host protocol's **display name**, e.g. `"Blend"`. Not an `id`, and not a link. | +| `label` | string | Short label naming the deployment, e.g. `"Blend V2 pool"`. Safe to render verbatim. | + +`null` means the entry runs on its own contracts. It never means "unknown" — we do not register an +entry without knowing which. + +**If you display protocol names, display this beside them.** Not a style preference: without it your +users read a list of markets as a list of protocols, which is a claim about the ecosystem that isn't +true. Rendering `label` verbatim next to the name is enough. + +**`host` is deliberately not a protocol id and links to nothing.** Stenion's `blend` entry is itself +one Blend market, so pointing at it would say this pool runs on _that entry_ rather than on Blend's +contract. If you want the host's own entry, you are looking for a relationship this API does not +assert. + +**Each such entry is scored independently, on its own on-chain state.** Sharing contract code is not +sharing a score: `deployedOn` markets are ranked on their own reserves, oracle configuration and +admin like any other entry, and the two live Blend pools currently differ by 30 points. Do not infer +one entry's risk from its host's. --- @@ -248,6 +296,7 @@ curl https://stenion.vercel.app/api/v1/protocol/blend | `adapter` | string | Which Stenion adapter produced the score. Informational. | | `contractId` | string or null | The Soroban contract the score was derived from. A raw `C…` address, deliberately **not** an explorer URL — pick your own. | | `site`, `docs` | string or null | The protocol's own links. Listed as its properties, not as a recommendation. | +| `deployedOn` | object or null | Same as the leaderboard. See [Not every entry is a protocol](#not-every-entry-is-a-protocol). | | `safetyScore`, `computedAt` | | Latest **`ok`** run. Both `null` if never successfully scored. | | `factors` | object or null | The five-factor breakdown, or `null` if never scored. See below. | | `methodologyVersion` | number or null | Which rulebook version the current score was computed under. | diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5446cd4..d4ef64a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -68,21 +68,45 @@ reads a protocol's on-chain state (Soroban RPC + Horizon), reduces it into the f factors using the formulas in `METHODOLOGY.md`, and produces a weighted `safetyScore`. Currently `BlendAdapter` and `KineticAdapter`. Adapters throw on failure; they never swallow errors. +**One adapter can serve several markets.** `BlendAdapter` takes a `BlendPool` — slug, display name, +pool contract, mark, links, deployment label — and the module exports `BLEND_POOLS`, the list the +indexer iterates. Every Blend market runs the same pool wasm (both live pools report code hash +`a41fc53d…`, and the V2 pool factory's `is_pool` returns true for both), so a second market is a +config entry and no new scoring code. Nothing on `BlendPool` is a threshold, a weight or a formula — +a per-pool rulebook would break `METHODOLOGY.md` ground rule 1 — and the identity fields all come +from the pool the instance was given, so an adapter cannot publish one pool's `contractId` beside +another pool's numbers. **Targeting, not aggregation:** each pool is its own ranked entry, and the +two live Blend pools sit 30 points apart (54 and 24) on identical contract code. + +**A row in `protocols` is therefore not always a protocol.** Three targets, two protocols: Blend's +Fixed pool, Kinetic, and the YieldBlox pool on Blend V2. That distinction is carried in the data, +not left to the reader — see `deployment_host` / `deployment_label` below. + **`@stenion/db`** — the single, typed storage layer, shared by both the indexer (writes) and the dashboard/API (reads) so there's no duplicated connection logic. Exposes a lazy singleton `pg` `Pool` (`getPool`/`closePool`), a `createStore(pool)` factory with all read/write methods, env loading, and the persisted `RunRecord` type. Three tables — two that hold the product, and one that holds no product data at all: -- `protocols` — one row per protocol (slug PK, name, chain, adapter class name) plus its identity: - `logo` (a root-relative path into the dashboard's own `public/` tree — we host every mark, never - hotlink), `contract_id` (the raw Soroban address the score is derived from, so a reader can check - it in an explorer; the explorer itself is chosen in `dashboard/app/lib/explorer.ts`, not per - adapter), and `site_url` / `docs_url`. All four are nullable, because "publishes no mark" and - "publishes no docs" are real answers the UI renders deliberately rather than papering over with a - placeholder. Upserted at indexer startup from adapter metadata — and **overwritten every cycle**, - so these are maintainer-managed; a future protocol self-service flow needs separate - precedence-taking columns, not edits to these. +- `protocols` — one row per **scored market** (slug PK, name, chain, adapter class name) plus its + identity: `logo` (a root-relative path into the dashboard's own `public/` tree — we host every + mark, never hotlink), `contract_id` (the raw Soroban address the score is derived from, so a + reader can check it in an explorer; the explorer itself is chosen in + `dashboard/app/lib/explorer.ts`, not per adapter), `site_url` / `docs_url`, and the deployment + pair `deployment_host` / `deployment_label` (migration 0006). All are nullable, because + "publishes no mark", "publishes no docs" and "runs on its own contracts" are real answers the UI + renders deliberately rather than papering over with a placeholder. Upserted at indexer startup + from adapter metadata — and **overwritten every cycle**, so these are maintainer-managed; a future + protocol self-service flow needs separate precedence-taking columns, not edits to these. + + The deployment pair is written and read together and published as one `deployedOn` object + (`{ host, label }`, or `null`), on **both** the leaderboard and the detail response — the only + identity field besides `logo` that the board carries, because it is what a row _is_ rather than + verification detail a reader looks up afterwards. `deployment_host` is a display name, not a + `protocols.id`, and there is deliberately no foreign key: Stenion's `blend` row is itself one + Blend market, so a reference to it would claim the pool runs on that _entry_ rather than on the + host protocol's contract. A half-populated pair maps to `null` rather than to a partial object. + - `risk_scores` — append-only history. `safety_score` is promoted to its own `numeric` column (it's what the registry ranks on); the five factors live in one `jsonb` column (displayed, not ranked, and growing the taxonomy then needs no migration). `methodology_version` records which @@ -162,7 +186,16 @@ still recorded as `failed` — a protocol that is genuinely down still shows as left rather than a fixed split, so one protocol failing cannot spend the other's retries while a protocol that finishes early hands its slack on. Worst-case cycle ≈ 42s in the run loop plus cold start, pool connect, upserts, streak queries and a 3s-capped alert POST — comfortably inside 60s. - Measured live `fetchRawData` on 2026-08-19: Blend 6.0–7.5s, Kinetic 7.7–10.5s. + Measured live `fetchRawData` on 2026-08-19: Blend 6.0–7.5s, Kinetic 7.7–10.5s, YieldBlox + 8.1–12.5s; three sequential targets totalled 24.5–26.9s against the 42s budget. + + **At three targets the first share is 14s, below the 15s attempt timeout.** A first attempt that + runs to that cap leaves nothing for a retry, so slow-failure retries are gone for whichever target + runs first (fast failures — the common RPC 429/5xx case — still retry with ~12s left). Blend runs + first because it is the fastest and most likely to hand slack on. This is the ceiling + [`ROADMAP.md`](ROADMAP.md) describes, reached at three targets rather than the four it previously + predicted; the fix is concurrency, not a bigger budget, and it is not done here. + - **The attempt timeout is soft.** It races the attempt against a timer, abandoning the in-flight work rather than cancelling it. That bounds the observed attempt duration, which is what the budget needs, and is harmless under serverless where the socket dies with the invocation. True diff --git a/CLAUDE.md b/CLAUDE.md index f0183fb..e8a4d08 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,12 @@ These override any default behavior and are enforced in code and review: - **Findings are not scores.** Verifiable observations we can't or won't grade go in the protocol page's Findings section (`dashboard/app/lib/protocol-notes.ts`), never into a factor. Nothing there is read by any scoring path. +- **A registry entry is a market, not necessarily a protocol — and it must say which.** An entry + running another protocol's contracts (the YieldBlox pool on Blend V2) carries + `ProtocolMetadata.deployedOn`, published as `deployedOn` on both API responses and rendered + beside the name everywhere the name appears. Presenting such a market as an independent protocol + is the misrepresentation the standalone-YieldBlox-adapter decision refused; the label is the + condition on which the entry exists, not decoration. ## Score conventions & taxonomy @@ -82,6 +88,12 @@ These override any default behavior and are enforced in code and review: exception — it imports `./retry.ts` / `./alerts.ts` with explicit extensions, and `indexer/tsconfig.build.json` adds `rewriteRelativeImportExtensions` so tsc emits `.js`. Prefer the leaf shape; reach for the flag only when a tested module genuinely needs siblings. +- **One adapter may serve several markets; a market never gets its own adapter.** `BlendAdapter` + takes a `BlendPool` (slug, name, pool contract, mark, links, `deployedOn`) and the indexer + iterates `BLEND_POOLS` — every Blend market runs the same wasm, so a second pool is a config + entry and no new scoring code. Nothing on `BlendPool` may be a threshold, weight, or formula: + that would be a per-pool rulebook. Identity is built per instance from the pool given, so + `contractId` can never name a pool the numbers didn't come from. - **Error handling:** adapters throw on failure; the indexer wraps each run in try/catch and records a failed/stale run. Error handling lives in the indexer, not duplicated per adapter. The indexer runs adapters through the `toTarget()` wrapper (see [`indexer/src/index.ts`](indexer/src/index.ts)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 66f8ae8..9e00ee1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -252,8 +252,13 @@ Before writing scoring logic, confirm the protocol is an **independently-scoreab lending protocol**. Two real, significant protocols were investigated and _deliberately skipped_ because they aren't (details in [`ROADMAP.md`](ROADMAP.md)): -- **YieldBlox** — turned out to be a community-managed pool _on Blend V2_, not an independent - protocol. An adapter would just be `BlendAdapter` pointed at a different pool. +- **YieldBlox** — turned out to be a DAO-managed pool _on Blend V2_, not an independent protocol. + An adapter would just be `BlendAdapter` pointed at a different pool. **It is now scored anyway — + as a pool, not as a protocol:** a `BLEND_POOLS` entry carrying its own slug, contract, links and a + `deployedOn` label that says "Blend V2 pool" everywhere it appears. If your candidate turns out to + be a Blend market, that is the path — add a pool config, not an adapter. Writing a second adapter + for it would duplicate a rulebook that is already shared, which is the thing the taxonomy exists + to prevent. - **Templar** — its lending market state lives on **NEAR**, not Stellar; only its price oracle is native Soroban. Reading NEAR would break the trustless-Stellar rule. @@ -262,6 +267,24 @@ Horizon** from the protocol's _own_ contracts (not another chain, not another pr before you commit to an adapter. Confirming this from the contracts first — rather than assuming it mirrors Blend/K2 — is the whole point. +**Cheap way to tell a Blend market from a protocol:** read the pool's instance storage. A Blend pool +has a `Config` with an `oracle`/`status`/`min_collateral`, a `Name`, and a `Backstop` pointing at +Blend's own backstop contract — and the V2 pool factory (`CDSYOAVX…`) answers `is_pool(address)` +with `true`. Compare its wasm hash against a known Blend pool's while you are there; if they match +byte-for-byte, you are looking at a Blend market and the answer is a `BLEND_POOLS` entry. + +**Adding a Blend pool**, once you have confirmed that: + +1. Add a `BlendPool` to `BLEND_POOLS` in `adapters/blend.ts` — slug, name, pool contract, and a + `deployedOn` label. No scoring code, and nothing on that type may be a threshold or a weight. +2. `pnpm capture:fixture ` and add a block to `adapters/snapshot.test.ts` asserting the + captured factor map, the score, and that `metadata.contractId` is **this** pool. +3. Check the identity fields honestly: omit `logo` unless there is a mark you can self-host (never + borrow the host protocol's — that asserts the identity the label exists to deny), and omit `docs` + rather than pointing at the host's. +4. Check the indexer budget. Each added target narrows the first target's share of + `STENION_CYCLE_BUDGET_MS`; see the ceiling note in [`ROADMAP.md`](ROADMAP.md). + ## Local development setup **Prerequisites:** Node 22.18+ (24 recommended — see [`.nvmrc`](.nvmrc); the test runner needs diff --git a/METHODOLOGY.md b/METHODOLOGY.md index 2550be5..f3f3663 100644 --- a/METHODOLOGY.md +++ b/METHODOLOGY.md @@ -8,7 +8,8 @@ directly from the shipped code (currently [`adapters/blend.ts`](adapters/blend.t the rulebook the adapters must implement. **One formula, per-protocol data sources.** Every factor's formula, scale, and thresholds -are fixed here and identical across protocols. What legitimately differs per adapter is only +are fixed here and identical across protocols — and across _markets_: the two Blend pools +Stenion scores run one adapter and one rulebook, differing only in the pool each reads. What legitimately differs per adapter is only _where the raw inputs are read on-chain_ — e.g. Blend reads a per-reserve `max_util` cap, while Kinetic (K2), being Aave-V3-style, has no such cap and instead anchors the same utilization formula to its own `OPTIMAL_UTILIZATION_RATE` (see §5). The _anchoring pattern_ @@ -479,10 +480,23 @@ rulebook, no special-casing: | Pool | `priceFreshness` | `deviationBound` | `oracleSafety` | | ------------------------------------------- | ---------------- | --------------------------------------------------- | -------------- | | Blend Fixed V2 (`CAJJZSGM…`) | 100 | 100 — all reserves bounded (`max_dev` 60/20/20) | **100** | -| YieldBlox (`CCCCIQSD…`, the exploited pool) | 100 | 0 — XLM and AQUA carry `max_dev: 0`, check disabled | **0** | - -Both pools' prices are fresh, so an age-only factor scores both 100. This factor separates -them, and on the axis that actually failed. +| YieldBlox (`CCCCIQSD…`, the exploited pool) | 84 | 0 — XLM and AQUA carry `max_dev: 0`, check disabled | **0** | + +Both pools' prices are fresh, so an age-only factor scores both high — 100 and 84, the +latter being an ordinary mid-window price age, not a warning. This factor separates them +anyway, and on the axis that actually failed. + +> **This is no longer a demonstration run.** As of the multi-pool change, the YieldBlox pool +> is a **registered, continuously scored entry** in the public registry, and the row above is +> its live `oracleSafety`, published every five minutes like any other. Two consequences worth +> stating: the claim in this section is now checkable by anyone against +> `GET /api/v1/protocol/yieldblox` rather than reproducible only by running the adapter by +> hand; and the number will move, because it is live. The pairing that matters — a fresh price +> and a disabled bound — is a property of the pool's configuration, not of the moment it was +> sampled. +> +> The entry is labelled a **Blend V2 pool** wherever it appears (`deployedOn` on both API +> responses). It is not a third protocol, and the registry must not be read as saying so. > **⚠️ Two honest limits on that claim, stated rather than glossed:** > @@ -627,10 +641,16 @@ Leg A is per-protocol in exactly the sense §5's `cap` is: the _pattern_ ("grade parameter the protocol set itself") is the invariant, and which parameter it resolves to is a documented per-protocol fact. -| Protocol | Leg A source | Value | -| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | -| **Blend** | `PoolConfig.min_collateral`, read live from pool instance storage, denominated in the oracle's base asset (`Other:USD`, 7 decimals) | `50000000` = **$5.00** | -| **Kinetic (K2)** | **none — K2 declares no minimum-exposure parameter on chain.** Leg B alone applies. | n/a | +| Protocol / market | Leg A source | Value | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | +| **Blend — Fixed V2** | `PoolConfig.min_collateral`, read live from pool instance storage, denominated in the oracle's base asset (`Other:USD`, 7 decimals) | `50000000` = **$5.00** | +| **Blend — YieldBlox V2** | the same field, read live from **this pool's own** instance storage — read per pool, never inherited from the flagship | `50000000` = **$5.00** | +| **Kinetic (K2)** | **none — K2 declares no minimum-exposure parameter on chain.** Leg B alone applies. | n/a | + +Both live Blend pools happen to declare the same floor. That is a coincidence of their +configuration, not a property of the adapter: leg A is resolved from whichever pool an +adapter instance was pointed at, and a Blend pool declaring a different `min_collateral` +would be graded against its own. `min_collateral` is Blend's _own_ dust guard: the smallest collateral a position may hold and still borrow, set where liquidating a position stops being economically worthwhile. A reserve @@ -684,6 +704,28 @@ reserve always holds at least `1/n`, which clears 0.5% for any `n ≤ 200`. The branch above is therefore unreachable in practice — it is implemented and tested synthetically anyway, because that is precisely where a "cannot assess" could quietly become a 100 again. +> **⚠️ OPEN QUESTION, raised by the YieldBlox pool and deliberately not resolved here.** The two +> legs are OR'd, so leg A can override leg B — and on a small Blend pool it overrides it almost +> entirely. YieldBlox holds ~$1.28M, putting leg B's 0.5% line at ~$6,396; **six of its eight +> reserves fall below that line** ($39.47 to $4,243.73) and every one is scored anyway, because +> Blend's $5 `min_collateral` passes for all of them. The result is that `liquiditySafety` (10) +> and `utilizationSafety` (0) are both set by a reserve holding **$1,096.85 — 0.086% of the +> pool**. +> +> That is the shape of the problem this filter was added for. On Blend's Fixed pool it is +> invisible: leg A is a documented no-op there, because the smallest reserve holds $3.4M. On a +> pool three orders of magnitude smaller, the same $5 floor is doing all the work and leg B's +> guard never engages. +> +> **It is recorded, not fixed.** Changing it — sizing leg A relative to the pool, capping it, +> or making the legs AND rather than OR below some pool size — moves published numbers on a +> live entry, and is a threshold change under the same review bar as any other (see +> [Disputing or changing a threshold](#disputing-or-changing-a-threshold)). It is equally +> arguable that the current behaviour is correct: `min_collateral` is the pool's own statement +> of the smallest position worth liquidating, and a $1,097 reserve at 90% utilization is a real +> reserve with real depositors, not the $3.00 dust the filter was built to exclude. What is not +> defensible is leaving the tension undocumented, which is why it is written down here. + **Why this shape / this anchor:** `(supplied − borrowed) / supplied` is `1 − utilization`, i.e. the fraction of supplied value that is actually withdrawable _right now_. That is a direct on-chain quantity, not a modeled one — the anchor is the pool's own balances. Taking diff --git a/README.md b/README.md index dc3c25f..3db445a 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,11 @@ lending protocols ([Blend](https://blend.capital) and [Kinetic/K2](https://k2len reading their state **directly from the chain** and turning it into a single, comparable `safetyScore` (0–100, higher = safer), broken down into five risk factors. +Scoring is per **market**, not per brand: three entries across those two protocols today, the third +being the YieldBlox pool on Blend V2. A market running another protocol's contracts is labelled as +one everywhere it appears — the registry ranks what is actually deployed, and never lets a pool +pass for a protocol. + It is **not** a TVL tracker. [DefiLlama](https://defillama.com) already covers TVL for Stellar. The thing Stenion measures that a TVL dashboard and a one-time audit both miss is **risk that moves every block**: diff --git a/ROADMAP.md b/ROADMAP.md index ac7bfc9..6643a99 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,11 +7,35 @@ commitment — priorities shift as protocols launch and as the project finds fun - **Continuous risk scoring for Stellar/Soroban lending protocols**, with a public, free, ranked registry sorted purely on `safetyScore` — payment-blind, no exceptions. -- **Two protocols scored end-to-end from live mainnet data:** - - **[Blend](https://blend.capital)** — the flagship Fixed V2 pool. Reference implementation. +- **Three markets scored end-to-end from live mainnet data — two protocols, three entries:** + - **[Blend](https://blend.capital)** — the flagship Fixed V2 pool (`CAJJZSGM…`). Reference + implementation. - **[Kinetic / K2](https://k2lend.com)** — an Aave-V3-style single-pool-multi-asset protocol; the first adapter to exercise a genuinely different on-chain shape than Blend, validating the shared taxonomy against a non-Blend protocol. + - **YieldBlox** (`CCCCIQSD…`) — a DAO-managed pool **on Blend V2**, not an independent protocol. + Scored by `BlendAdapter` pointed at a second pool, and labelled as a Blend V2 pool everywhere it + appears. See "Multi-pool Blend targeting" below. + +- **Multi-pool Blend targeting.** `BlendAdapter` takes a `BlendPool` config — slug, display name, + pool contract, mark, links, deployment label — instead of hardcoding one pool, and the indexer + iterates `BLEND_POOLS`. Every Blend market runs the same pool wasm (both live pools report code + hash `a41fc53d…`, and the V2 factory's `is_pool` returns true for both), so a second market needs + **no new scoring code** — only a config entry. The same rule that moved `scoreFactors` into + `core`, applied to pool targeting. + + It is **targeting, not aggregation**: each pool is a separate ranked entry scored from its own + reserves, oracle and admin. The two live Blend pools sit 30 points apart on identical contract + code (54 and 24), which a single summed "Blend" number would have hidden. + + **A registry entry is therefore no longer the same thing as a protocol**, and the API says so: + `deployedOn` (`{ host, label }`, null for an independent entry) rides on both the leaderboard and + the detail response, and the dashboard renders it beside the name on the registry row, the + homepage card, the protocol hero and the page's own metadata description. That labelling is the + condition on which the entry exists — presenting a Blend market as an independent protocol is the + exact misrepresentation this project refused when it declined to build a standalone YieldBlox + adapter. + - **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). @@ -59,12 +83,19 @@ commitment — priorities shift as protocols launch and as the project finds fun Roughly in priority order, but not committed to dates: - **More protocol adapters.** The open contribution path (see [`CONTRIBUTING.md`](CONTRIBUTING.md)). - The bar: an _independently-scoreable native-Soroban lending protocol_ — not another Blend pool, - not a deployment whose lending state lives on another chain. + The bar for a new **adapter** is unchanged: an _independently-scoreable native-Soroban lending + protocol_ — not a deployment whose lending state lives on another chain, and not something that + turns out to be a Blend pool. + + What changed is what happens when it _is_ a Blend pool. That is no longer a dead end: it is a + `BLEND_POOLS` entry with a `deployedOn` label, which costs one config block and no scoring code. + The two paths must not be confused — a Blend market gets a pool entry, never an adapter of its + own, because an adapter would duplicate a rulebook that is already shared. - **Nectar Network — watching for mainnet.** Flagged as the next protocol to evaluate once it's live on Stellar mainnet. Not built yet, and won't be until we can confirm from its own contracts that it's an independently-scoreable native-Soroban lending protocol (reserves/utilization/oracle readable via Soroban RPC + Horizon) rather than another Blend pool or another-chain deployment. + - **A longer history window (raising the 50-row detail cap).** `GET /api/v1/protocol/:id` returns the newest 50 runs, which at the current 5-minute cadence is about four hours. That is enough to show _an_ event and not enough to show a _pattern_, and the difference is load-bearing: K2's @@ -86,19 +117,41 @@ Roughly in priority order, but not committed to dates: already there, but the API exposes only `safetyScore` per history point. Charting a single factor over time — watching `oracleSafety` sawtooth on its own axis — is deliberately deferred until the window question above is settled, because it multiplies the same payload by five. -- **Concurrent protocols within a cycle — and the protocol-count ceiling that forces it.** - **Trigger condition, stated plainly: this becomes necessary at four protocols.** The indexer runs - protocols sequentially and divides one wall-clock budget between them, so each protocol's share is - `STENION_CYCLE_BUDGET_MS / protocolCount`. At the 42s default that is 21s each for two protocols - and 14s each for three — but at four it is 10.5s, which is **below the 15s attempt timeout**, and - retries stop happening at all. Nothing breaks loudly when that happens: cycles still run, failures - are still recorded, and the retry that this whole feature exists for has simply, silently, stopped. - That is the failure mode worth writing down, because it degrades invisibly as the project grows. - - The fix is running protocols concurrently (each then gets the full budget), not a longer budget — - the 60s `maxDuration` ceiling on Vercel Hobby cannot be raised. It is deliberately not done yet: - concurrency doubles simultaneous load on a shared, rate-limited public RPC, which is itself a - source of the failures being retried. **Before adding a fourth adapter, check this.** +- **Concurrent targets within a cycle — the ceiling is here, one target earlier than this document + used to predict.** The indexer runs targets sequentially and divides one wall-clock budget between + them, so the **first** target's share is `STENION_CYCLE_BUDGET_MS / targetCount` (later ones + inherit whatever slack the earlier ones did not spend). At the 42s default that is 21s each for + two and **14s for three** — and 14s is already **below the 15s `STENION_ATTEMPT_TIMEOUT_MS`**. + + **Correction to what was written here before.** This entry used to name four targets as the + trigger, on the arithmetic that four gives 10.5s. The arithmetic was right and the conclusion was + off by one: the condition is "share < attempt timeout", and three targets already meets it. Adding + the YieldBlox pool crossed it. + + What that actually costs, stated precisely rather than as "retries stop": + + - The first target's first attempt is capped at **14s instead of 15s** (`withRetry` caps each + attempt at whatever is left). + - A first attempt that **runs to that cap** leaves 0ms, which is below + `baseDelayMs + minAttemptMs` (1s + 1s), so **no retry is started**. At two targets the same + timed-out attempt still left 6s and bought one (5s-capped) retry. + - A first attempt that **fails fast** — an RPC 429 or 5xx, which is the common transient case and + returns in well under a second — still retries normally, with ~12s left to do it in. + + So retries are not gone; they are gone for the slow-failure case on whichever target runs first. + Measured `fetchRawData` durations (2026-08-19, from a developer machine — Vercel's path to the RPC + is not this one): Blend 6.0–7.5s, Kinetic 10.2–11.4s, YieldBlox 8.1–12.5s; three sequential + targets totalled 24.5–26.9s against the 42s budget, so a healthy cycle has real headroom and it is + only the retry margin that is tight. Blend runs first because it is the fastest and so most likely + to pass slack on. + + The fix is running targets concurrently (each then gets the full budget), not a longer budget — + the 60s `maxDuration` ceiling on Vercel Hobby cannot be raised. Still deliberately not done: + concurrency triples simultaneous load on a shared, rate-limited public RPC, which is itself a + source of the failures being retried. The cheaper interim lever, if slow-failure retries matter + more than long attempts, is **lowering** `STENION_ATTEMPT_TIMEOUT_MS` (10s would restore a retry + inside a 14s share) — a config change, not code. **Before adding a fourth target, this stops being + optional.** - **An `AbortSignal` through `Adapter.fetchRawData`.** The per-attempt timeout is currently _soft_ — it races the attempt against a timer and abandons the loser rather than cancelling it, because no @@ -201,10 +254,13 @@ Roughly in priority order, but not committed to dates: Confirming a protocol is _not_ in scope from its own contracts — before writing scoring logic — is part of the discipline, not a failure. Two notable cases: -- **YieldBlox.** Not an independent Soroban lending protocol. The YieldBlox DAO adopted Blend as its - backbone; what exists today is a community-managed pool _on Blend V2_, using the identical Blend - interface. A "YieldBlox adapter" would just be `BlendAdapter` pointed at a different pool. Could - later be represented as a second Blend _pool_ via a small multi-pool refactor — tracked, not built. +- **YieldBlox — skipped as an adapter, then shipped as a pool.** Still not an independent Soroban + lending protocol: the YieldBlox DAO adopted Blend as its backbone, and what exists today is a + DAO-managed pool _on Blend V2_ running the identical Blend contract. So it never got an adapter — + a "YieldBlox adapter" would just be `BlendAdapter` pointed at a different pool. The multi-pool + refactor this entry anticipated has since landed, and the pool is now a registered entry + (`CCCCIQSD…`), scored by `BlendAdapter` and labelled a Blend V2 pool. The skip decision was never + reversed — it is the reason the entry is a pool and not a protocol. - **Templar.** A NEAR-based, chain-abstraction ("Cypher Lending") protocol. Its lending market state — reserves, supply/borrow, utilization, collateral positions — lives on **NEAR**, read via NEAR RPC. Stellar is only a wallet/collateral entry point via NEAR MPC. The only native-Soroban contract diff --git a/adapters/blend.ts b/adapters/blend.ts index 1ec7794..1701f58 100644 --- a/adapters/blend.ts +++ b/adapters/blend.ts @@ -29,6 +29,8 @@ import { import type { Adapter, ExcludedReserve, + ProtocolDeployment, + ProtocolLinks, ProtocolMetadata, WorstReserves, RiskFactor, @@ -41,9 +43,23 @@ import type { // // Addresses come from Blend's own deploy config (blend-utils/mainnet.contracts.json), // cross-checked against docs.blend.capital/mainnet-deployments — not a third-party -// indexer. We target the flagship "Fixed" V2 pool (XLM:USDC) only for v1: it holds -// the most liquidity and is the simplest to reason about. Blend's factory deploys -// one contract per market, so multi-pool aggregation is deliberately deferred. +// indexer. +// +// ONE ENGINE, MANY POOLS. Blend's factory deploys one contract per market, and +// every market runs the SAME pool wasm — verified rather than assumed: the Fixed +// V2 and YieldBlox V2 pools report the identical code hash +// (a41fc53d6753b6c04eb15b021c55052366a4c8e0e21bc72700f461264ec1350e), and the V2 +// pool factory's `is_pool` returns true for both. So the read interface, the +// instance-storage keys and the fixed-point scalars below hold for every pool, +// and a second Blend market needs no new scoring code — only a new BlendPool +// entry. Same rule that moved `scoreFactors` into @stenion/core, applied to pool +// targeting: nothing per-pool is allowed to be logic. +// +// This is multi-pool TARGETING, not aggregation. Each pool is scored and ranked +// as its own registry entry from its own reserves, oracle and admin; pools are +// never summed into a single Blend number. Summing would hide exactly the +// per-market differences the score exists to show — the two live pools sit 30 +// points apart on the same contract code. // --------------------------------------------------------------------------- const NETWORK_PASSPHRASE = Networks.PUBLIC; @@ -54,8 +70,89 @@ const DEFAULT_RPC_URL = 'https://mainnet.sorobanrpc.com'; /** Public Horizon — needed for admin-account signer/activity data, which Soroban RPC does not expose. */ const DEFAULT_HORIZON_URL = 'https://horizon.stellar.org'; -/** Blend V2 "Fixed" pool (XLM:USDC). */ -const FIXED_POOL_V2 = 'CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD'; +/** + * One Blend market this adapter can be pointed at. + * + * Everything here is IDENTITY — the slug, the display name, the pool contract, + * the mark and the links. Deliberately nothing here is a threshold, a weight or + * a formula: a field on this type that changed how a factor is computed would be + * a per-pool rulebook, which METHODOLOGY.md ground rule 1 forbids. Adding a pool + * must stay a data change. + */ +export interface BlendPool { + /** registry slug — `protocols.id`, the public URL, and the API path segment */ + id: string; + /** display name */ + name: string; + /** the pool contract this entry is scored from */ + poolId: string; + /** self-hosted mark, or omitted when the market publishes none (see ProtocolMetadata.logo) */ + logo?: string; + links?: ProtocolLinks; + /** + * Set on every pool that is not the protocol's own flagship entry, so a reader + * can tell a community market running Blend's contracts from Blend itself. + * This is the field that stops a second Blend pool reading as a second + * protocol — see ProtocolDeployment. + */ + deployedOn?: ProtocolDeployment; +} + +/** + * Blend V2 "Fixed" pool (XLM:USDC:EURC) — Blend's flagship market and this + * adapter's default target. Its on-chain pool `Name` is "Fixed"; the entry is + * called "Blend" because it is the reference deployment of the protocol itself. + */ +export const BLEND_FIXED_V2: BlendPool = { + id: 'blend', + name: 'Blend', + poolId: 'CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD', + // Self-hosted copy of Blend's own mark, never a hotlink to their CDN. + logo: '/assets/protocols/blend.svg', + links: { + site: 'https://www.blend.capital', + docs: 'https://docs.blend.capital', + }, +}; + +/** + * The YieldBlox pool on Blend V2 — a DAO-managed market, NOT an independent + * protocol. Its on-chain pool `Name` is "YieldBlox", its admin is a Soroban + * Governor contract rather than a keypair, and YieldBlox's own site describes it + * as "a DAO-managed money market on the Stellar Network, using Blend Protocol + * and Soroban Governor". It is scored as its own entry because its reserves, + * oracle aggregator and admin are all its own — and carries `deployedOn` because + * its contract code is not. + * + * No `logo`: YieldBlox publishes no mark we can self-host, so the initials tile + * applies (ProtocolMetadata.logo). Borrowing Blend's mark would be the worst + * option on the list — it would assert precisely the identity this entry exists + * to deny. + * + * No `docs`: their documentation is referred to from a pre-launch page but + * publishes no reachable URL, and a dead link is worse than an absent one. + */ +export const BLEND_YIELDBLOX_V2: BlendPool = { + id: 'yieldblox', + name: 'YieldBlox', + poolId: 'CCCCIQSDILITHMM7PBSLVDT5MISSY7R26MNZXCX4H7J5JQ5FPIYOGYFS', + links: { + site: 'https://yieldblox.finance', + }, + deployedOn: { + host: 'Blend', + label: 'Blend V2 pool', + }, +}; + +/** + * Every Blend market Stenion scores, in registration order. + * + * The indexer iterates this rather than naming pools one by one, so adding a + * market is one entry here and nothing else — there is no second target list to + * keep in step, which is how such lists come apart. + */ +export const BLEND_POOLS: readonly BlendPool[] = [BLEND_FIXED_V2, BLEND_YIELDBLOX_V2]; // Fixed-point scalars from blend-contracts-v2/pool/src/constants.rs. const SCALAR_7 = 10n ** 7n; // c_factor, l_factor, util, max_util @@ -564,16 +661,31 @@ function suppliedUsd(r: BlendReserveRaw, oracleDecimals: number): number | null export interface BlendAdapterOptions { rpcUrl?: string; horizonUrl?: string; - poolId?: string; + /** + * Which market to score. Defaults to Blend's flagship Fixed V2 pool. + * + * A whole BlendPool rather than a bare `poolId`, deliberately: target and + * identity have to move together. A lone pool-id knob lets an instance read + * one pool while publishing another pool's slug, name and links — the same + * class of bug as `adapter: "w"`, and harder to catch, because every field + * involved stays individually plausible. + */ + pool?: BlendPool; } export class BlendAdapter implements Adapter { /** - * Built in the constructor rather than as a field initialiser because - * `contractId` must be the pool THIS INSTANCE scores, not the module default. - * An adapter constructed with `{ poolId }` would otherwise publish an explorer - * link to a pool it never read — a wrong number attached to a real address, - * which is worse than no link at all. Everything else here is a literal. + * Built in the constructor rather than as a field initialiser because every + * identity field has to describe the pool THIS INSTANCE scores, not a module + * default. `contractId` is the sharp one: an adapter pointed at a second pool + * that published an explorer link to the first would attach a wrong number to + * a real address, which is worse than no link at all. It is set from + * `this.poolId`, the same value `fetchRawData` reads, so the two cannot drift. + * + * `adapterRef` is the one field every pool shares, and that is correct rather + * than a gap: both entries genuinely are produced by this class, so both rows + * point a reader at this file. It stays a string literal — never + * `this.constructor.name`; see ProtocolMetadata.adapterRef. */ readonly metadata: ProtocolMetadata; @@ -584,21 +696,23 @@ export class BlendAdapter implements Adapter { constructor(opts: BlendAdapterOptions = {}) { this.rpcUrl = opts.rpcUrl ?? DEFAULT_RPC_URL; this.horizonUrl = opts.horizonUrl ?? DEFAULT_HORIZON_URL; - this.poolId = opts.poolId ?? FIXED_POOL_V2; + const pool = opts.pool ?? BLEND_FIXED_V2; + this.poolId = pool.poolId; this.metadata = { - id: 'blend', - name: 'Blend', + id: pool.id, + name: pool.name, chain: 'stellar', // Literal, not this.constructor.name — see ProtocolMetadata.adapterRef. adapterRef: 'BlendAdapter', - // Self-hosted copy of Blend's own mark, never a hotlink to their CDN. - logo: '/assets/protocols/blend.svg', contractId: this.poolId, - links: { - site: 'https://www.blend.capital', - docs: 'https://docs.blend.capital', - }, + // Spread, so a pool with no mark / no links / no deployment note leaves the + // key ABSENT rather than set to undefined. Identical to TypeScript, not to + // a reader of a serialized metadata object — and `upsertProtocol` maps + // either to NULL, so nothing downstream is asked to tell them apart. + ...(pool.logo === undefined ? {} : { logo: pool.logo }), + ...(pool.links === undefined ? {} : { links: pool.links }), + ...(pool.deployedOn === undefined ? {} : { deployedOn: pool.deployedOn }), }; } @@ -994,4 +1108,5 @@ export class BlendAdapter implements Adapter { } } +/** Blend's flagship Fixed V2 pool, ready to use. */ export const blendAdapter = new BlendAdapter(); diff --git a/adapters/fixtures/yieldblox-mainnet.ts b/adapters/fixtures/yieldblox-mainnet.ts new file mode 100644 index 0000000..6dd8f74 --- /dev/null +++ b/adapters/fixtures/yieldblox-mainnet.ts @@ -0,0 +1,217 @@ +// Frozen mainnet snapshot — generated by scripts/capture-fixture.mjs. +// +// DO NOT HAND-EDIT. Regenerate with `pnpm capture:fixture yieldblox`, then re-derive +// the expected values in adapters/snapshot.test.ts. If a factor moved, find out +// why before committing — that is the entire point of this file. +// +// Captured: 2026-08-20T09:36:36.539Z +// At capture time this scored: safetyScore 24 (collateralSafety 52, oracleSafety 0, adminKeySafety 60, liquiditySafety 10, utilizationSafety 0) +// +// `satisfies` is load-bearing: if BlendRawData gains a required field, this file +// stops compiling rather than quietly feeding the adapter a stale shape. + +import type { BlendRawData } from '../blend.ts'; + +export const yieldbloxMainnet = { + poolId: 'CCCCIQSDILITHMM7PBSLVDT5MISSY7R26MNZXCX4H7J5JQ5FPIYOGYFS', + oracleId: 'CD74A3C54EKUVEGUC6WNTUPOTHB624WFKXN3IYTFJGX3EHXDXHCYMXXR', + oracleDecimals: 7, + status: 0, + minCollateral: 50000000n, + admin: { + address: 'CANSYFVMIP7JVYEZQ463Y2I2VLEVNLDJJ4QNZTDBGLOOGKURPTW4A6FQ', + isContract: true, + account: null, + }, + oracleConfig: { + maxAge: 900, + baseAssets: [ + 'CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', + 'CB226ZOEYXTBPD3QEGABTJYSKZVBP2PASEISLG3SBMTN5CE4QZUVZ3CE', + 'CCCRWH6Q3FNP3I2I57BDLM5AFAT7O6OF6GKQOC6SSJNDAVRZ57SPHGU2', + ], + oracles: [ + { + index: 0, + address: 'CALI2BYU2JE6WVRUFYTS6MSBNEHGJ35P4AVCZYF3B6QOE3QKOB2PLE6M', + resolution: 300, + decimals: 14, + }, + ], + }, + reserves: [ + { + asset: 'CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', + config: { + decimals: 7, + cFactor: 7500000n, + lFactor: 7500000n, + util: 5000000n, + maxUtil: 7000000n, + enabled: true, + }, + data: { + dRate: 1004429084635n, + bRate: 475605876856n, + bSupply: 51110737759183n, + dSupply: 5858753007136n, + }, + price: { value: 1760719n, timestamp: 1787218200 }, + priceConfig: { + upstreamAsset: 'Stellar:CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', + oracleIndex: 0, + maxDev: 0, + }, + }, + { + asset: 'CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', + config: { + decimals: 7, + cFactor: 9500000n, + lFactor: 9500000n, + util: 8000000n, + maxUtil: 9500000n, + enabled: true, + }, + data: { + dRate: 1196832774372n, + bRate: 969039001613n, + bSupply: 8686540961571n, + dSupply: 5781278559824n, + }, + price: { value: 10000000n, timestamp: 1787218580 }, + priceConfig: null, + }, + { + asset: 'CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', + config: { + decimals: 7, + cFactor: 0n, + lFactor: 9000000n, + util: 7500000n, + maxUtil: 9000000n, + enabled: true, + }, + data: { + dRate: 1152500798118n, + bRate: 1058112475161n, + bSupply: 8898454621n, + dSupply: 7360523121n, + }, + price: { value: 11649341n, timestamp: 1787218200 }, + priceConfig: { + upstreamAsset: 'Stellar:CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', + oracleIndex: 0, + maxDev: 10, + }, + }, + { + asset: 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', + config: { + decimals: 7, + cFactor: 0n, + lFactor: 5000000n, + util: 4000000n, + maxUtil: 7000000n, + enabled: true, + }, + data: { + dRate: 1032054252218n, + bRate: 1011145309365n, + bSupply: 120015695491709n, + dSupply: 58299241322983n, + }, + price: { value: 3497n, timestamp: 1787218200 }, + priceConfig: { + upstreamAsset: 'Stellar:CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', + oracleIndex: 0, + maxDev: 0, + }, + }, + { + asset: 'CB226ZOEYXTBPD3QEGABTJYSKZVBP2PASEISLG3SBMTN5CE4QZUVZ3CE', + config: { + decimals: 7, + cFactor: 0n, + lFactor: 9000000n, + util: 7500000n, + maxUtil: 9000000n, + enabled: true, + }, + data: { + dRate: 1309580113386n, + bRate: 1211897040106n, + bSupply: 5603729634n, + dSupply: 4667162378n, + }, + price: { value: 10000000n, timestamp: 1787218586 }, + priceConfig: null, + }, + { + asset: 'CBLV4ATSIWU67CFSQU2NVRKINQIKUZ2ODSZBUJTJ43VJVRSBTZYOPNUR', + config: { + decimals: 7, + cFactor: 0n, + lFactor: 9000000n, + util: 5000000n, + maxUtil: 8000000n, + enabled: true, + }, + data: { + dRate: 1057380715248n, + bRate: 1026286205049n, + bSupply: 358328674n, + dSupply: 2618491n, + }, + price: { value: 10732658n, timestamp: 1787218200 }, + priceConfig: { + upstreamAsset: 'Stellar:CBLV4ATSIWU67CFSQU2NVRKINQIKUZ2ODSZBUJTJ43VJVRSBTZYOPNUR', + oracleIndex: 0, + maxDev: 10, + }, + }, + { + asset: 'CAL6ER2TI6CTRAY6BFXWNWA7WTYXUXTQCHUBCIBU5O6KM3HJFG6Z6VXV', + config: { + decimals: 7, + cFactor: 0n, + lFactor: 8000000n, + util: 5000000n, + maxUtil: 8000000n, + enabled: true, + }, + data: { + dRate: 1359978178850n, + bRate: 1163123190625n, + bSupply: 272693796906n, + dSupply: 77226619947n, + }, + price: { value: 693273n, timestamp: 1787218200 }, + priceConfig: { + upstreamAsset: 'Stellar:CAL6ER2TI6CTRAY6BFXWNWA7WTYXUXTQCHUBCIBU5O6KM3HJFG6Z6VXV', + oracleIndex: 0, + maxDev: 10, + }, + }, + { + asset: 'CCCRWH6Q3FNP3I2I57BDLM5AFAT7O6OF6GKQOC6SSJNDAVRZ57SPHGU2', + config: { + decimals: 7, + cFactor: 9000000n, + lFactor: 9000000n, + util: 7500000n, + maxUtil: 9000000n, + enabled: true, + }, + data: { + dRate: 1044984359381n, + bRate: 1031064277660n, + bSupply: 10599432633n, + dSupply: 8334726554n, + }, + price: { value: 10000000n, timestamp: 1787218591 }, + priceConfig: null, + }, + ], + fetchedAt: 1787218596, +} satisfies BlendRawData; diff --git a/adapters/snapshot.test.ts b/adapters/snapshot.test.ts index 490d516..1ae1070 100644 --- a/adapters/snapshot.test.ts +++ b/adapters/snapshot.test.ts @@ -19,21 +19,31 @@ // (re-derive them deliberately) or something regressed. Do not update a number // here without knowing which. // +// THREE FIXTURES, TWO ADAPTERS. `blend` and `yieldblox` are the same +// BlendAdapter pointed at two different pools, so between them they answer a +// question neither could alone: whether the multi-pool refactor left the engine +// alone. Blend's numbers must not move (they are the before/after control), and +// YieldBlox's must be produced by the identical code path with nothing +// special-cased for it. +// // Run with: pnpm --filter @stenion/adapters test import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { BlendAdapter } from './blend.ts'; +import { BLEND_FIXED_V2, BLEND_POOLS, BLEND_YIELDBLOX_V2, BlendAdapter } from './blend.ts'; import { KineticAdapter } from './kinetic.ts'; import { blendMainnet } from './fixtures/blend-mainnet.ts'; import { kineticMainnet } from './fixtures/kinetic-mainnet.ts'; +import { yieldbloxMainnet } from './fixtures/yieldblox-mainnet.ts'; import type { RiskFactor, RiskFactorMap } from '@stenion/core'; const values = (f: RiskFactorMap) => Object.fromEntries(Object.entries(f).map(([k, v]) => [k, v === null ? null : v.value])); const sub = (f: RiskFactor, id: string) => f.components?.find((c) => c.id === id)?.value; +/** A component's detail string — for the disclosure components, whose `value` is always null. */ +const sub2 = (f: RiskFactor, id: string) => f.components?.find((c) => c.id === id)?.detail ?? ''; describe('Blend — frozen mainnet snapshot', () => { it('produces exactly the factor map captured with it', async () => { @@ -197,11 +207,187 @@ describe('Kinetic — frozen mainnet snapshot', () => { }); }); +describe('YieldBlox — frozen mainnet snapshot (same adapter, second pool)', () => { + const adapter = () => new BlendAdapter({ pool: BLEND_YIELDBLOX_V2 }); + + it('produces exactly the factor map captured with it', async () => { + const factors = await adapter().computeRiskFactors(yieldbloxMainnet); + assert.deepEqual(values(factors), { + collateralSafety: 52, + oracleSafety: 0, + adminKeySafety: 60, + liquiditySafety: 10, + utilizationSafety: 0, + }); + }); + + it('scores 24 — the weighted mean of those five', async () => { + // 52×0.20 + 0×0.25 + 60×0.20 + 10×0.15 + 0×0.20 = 23.9. + const a = adapter(); + const factors = await a.computeRiskFactors(yieldbloxMainnet); + assert.equal(a.score(factors).score, 24); + }); + + it('publishes the pool it actually read, not the module default', async () => { + // THE `adapter: "w"` CLASS OF BUG, in its pool-shaped form. Every number on + // this entry is derived from CCCCIQSD…, so an explorer link to Blend's + // CAJJZSGM… would attach a real address to a score computed from a different + // contract — worse than publishing no link, because it invites a reader to + // "verify" against state that never fed the number. + assert.equal(adapter().metadata.contractId, BLEND_YIELDBLOX_V2.poolId); + assert.equal(adapter().metadata.contractId, yieldbloxMainnet.poolId); + assert.notEqual(adapter().metadata.contractId, BLEND_FIXED_V2.poolId); + }); + + it("reads its own oracle and its own admin, not Blend's", async () => { + // The two pools share contract CODE and nothing else. If these ever matched, + // the fixture would have been captured from the wrong pool — which is the + // failure mode a shared engine makes easy and a shared assertion catches. + assert.notEqual(yieldbloxMainnet.oracleId, blendMainnet.oracleId); + assert.notEqual(yieldbloxMainnet.admin.address, blendMainnet.admin.address); + assert.equal(yieldbloxMainnet.admin.isContract, true, 'DAO governor, not a keypair'); + }); + + it('scores oracleSafety 0 on a disabled deviation bound, with the price fresh', async () => { + // This is the §2 composite doing the exact job METHODOLOGY.md §2b says it + // exists for, on the pool the February 2026 incident ran through. An + // age-only oracle factor would publish 84 here and call it healthy; two of + // the five graded reserves carry max_dev 0, so the aggregator's deviation + // check is off for them and a single update can move their price arbitrarily + // far. METHODOLOGY.md's "what this factor would have said on 2026-02-22" + // table predicts precisely this pairing — it is now produced by a scored, + // published entry rather than by a one-off run. + const factors = await adapter().computeRiskFactors(yieldbloxMainnet); + assert.ok(sub(factors.oracleSafety!, 'priceFreshness')! > 50, 'the price is fresh'); + assert.equal(sub(factors.oracleSafety!, 'deviationBound'), 0); + assert.equal(factors.oracleSafety!.value, 0, 'the binding constraint is the missing bound'); + assert.match(factors.oracleSafety!.detail, /deviation check disabled/); + }); + + it('excludes the aggregator base assets from oracleSafety, and says how many', async () => { + // Blend's Fixed pool has none, so this branch is unexercised by the other + // fixture. YieldBlox's aggregator declares three assets it prices 1:1 as its + // unit of account, and `lastprice` short-circuits those without consulting a + // feed — there is no oracle price to grade, so scoring them 0 for "no + // deviation bound" would be measuring the absence of a mechanism that does + // not apply to them. + assert.equal(yieldbloxMainnet.oracleConfig.baseAssets.length, 3); + const factors = await adapter().computeRiskFactors(yieldbloxMainnet); + assert.match(sub2(factors.oracleSafety!, 'deviationTightness'), /3 base asset\(s\) excluded/); + assert.match(factors.oracleSafety!.detail, /of 5 reserves/, 'grades 5 of the 8 reserves'); + }); + + it('reads a live min_collateral of $5.00 from this pool, not from the other one', async () => { + // The §4/§5 filter's leg A is per-pool: it is read from THIS pool's own + // PoolConfig instance storage. Both live Blend pools happen to declare the + // same 50000000 at 7 oracle decimals, and that coincidence is worth pinning + // — if a future pool declares a different floor, this test is what makes the + // difference visible instead of assumed. + assert.equal(yieldbloxMainnet.minCollateral, 50_000_000n); + assert.equal(yieldbloxMainnet.oracleDecimals, 7); + }); + + it('keeps every reserve — and six of the eight survive on leg A alone', async () => { + // WORTH READING TWICE, because it is the first pool where the two legs of + // the minimum-size filter genuinely disagree. The pool holds ~$1.28M, so leg + // B's 0.5% line sits at ~$6,396 and SIX reserves fall under it ($39 to + // $4,244). All six are scored anyway, because leg A — Blend's own $5 + // min_collateral — passes for every one of them, and the legs are OR'd. + // + // The consequence is visible in the numbers above: liquiditySafety 10 and + // utilizationSafety 0 are both set by a $1,097 reserve holding 0.086% of the + // pool. On Blend's Fixed pool leg A is a documented no-op (its smallest + // reserve is $3.4M); here it is doing all the work, and pulling leg B's + // guard out from under the factors it was added to protect. + // + // This test pins the BEHAVIOUR, not an endorsement of it. Whether a $5 floor + // is the right leg A for a pool three orders of magnitude smaller than the + // one it was validated against is a METHODOLOGY.md question, flagged rather + // than answered here — changing it moves published numbers and is a + // threshold change under the same review bar as any other. + const factors = await adapter().computeRiskFactors(yieldbloxMainnet); + for (const factor of [factors.liquiditySafety!, factors.utilizationSafety!]) { + assert.equal( + factor.components?.find((c) => c.id === 'excludedReserves'), + undefined, + 'leg A rescues every reserve, so nothing is excluded', + ); + } + assert.match(factors.liquiditySafety!.detail, /CDTKPW…/); + assert.match(factors.utilizationSafety!.detail, /CDTKPW…/); + }); + + it('exercises decode paths the Fixed pool cannot reach', async () => { + // The reason this fixture earns its place next to blend-mainnet rather than + // duplicating it: eight reserves against three, a reserve at $39 against a + // floor of $3.4M, cFactor 0 entries (borrow-only, no collateral value), and + // reserves the aggregator has no entry for at all. A decode or scaling + // regression that the three tidy Fixed reserves survive has somewhere to + // show up here. + assert.equal(yieldbloxMainnet.reserves.length, 8); + assert.ok( + yieldbloxMainnet.reserves.some((r) => r.config.cFactor === 0n), + 'expected at least one borrow-only reserve', + ); + assert.ok( + yieldbloxMainnet.reserves.some((r) => r.priceConfig === null), + 'expected at least one reserve with no aggregator entry', + ); + }); +}); + +describe('both Blend pools — one engine, two markets', () => { + it('produce different scores from identical code', async () => { + // The whole argument for multi-pool targeting rather than aggregation. Same + // class, same rulebook, same contract wasm on chain — and 30 points apart, + // because the reserves, the oracle configuration and the admin differ. A + // single summed "Blend" number would hide exactly this. + const fixed = new BlendAdapter({ pool: BLEND_FIXED_V2 }); + const ybx = new BlendAdapter({ pool: BLEND_YIELDBLOX_V2 }); + const fixedScore = fixed.score(await fixed.computeRiskFactors(blendMainnet)).score; + const ybxScore = ybx.score(await ybx.computeRiskFactors(yieldbloxMainnet)).score; + assert.equal(fixedScore, 54); + assert.equal(ybxScore, 24); + }); + + it('are scored by the same adapter, and say so', async () => { + // `adapterRef` is deliberately shared: both rows genuinely come from + // BlendAdapter, and a reader following the provenance label lands in the + // right file. It is the one identity field that does NOT vary per pool, and + // it must stay a literal — see ProtocolMetadata.adapterRef. + for (const pool of [BLEND_FIXED_V2, BLEND_YIELDBLOX_V2]) { + assert.equal(new BlendAdapter({ pool }).metadata.adapterRef, 'BlendAdapter'); + } + }); + + it('label the non-flagship pool as a deployment and leave the flagship unlabelled', async () => { + // The condition on which the second entry is allowed to exist at all. If + // this ever inverted or went missing, the registry would present a Blend + // market as an independent protocol — the exact misrepresentation Stenion + // refused when it declined to build a standalone YieldBlox adapter. + assert.equal(new BlendAdapter({ pool: BLEND_FIXED_V2 }).metadata.deployedOn, undefined); + assert.deepEqual(new BlendAdapter({ pool: BLEND_YIELDBLOX_V2 }).metadata.deployedOn, { + host: 'Blend', + label: 'Blend V2 pool', + }); + }); + + it('never share a slug or a contract', async () => { + // `id` is a primary key and a public URL; two pools colliding on it would + // make one silently overwrite the other's row on every indexer cycle. + const ids = BLEND_POOLS.map((p) => p.id); + const contracts = BLEND_POOLS.map((p) => p.poolId); + assert.equal(new Set(ids).size, ids.length, 'pool slugs must be unique'); + assert.equal(new Set(contracts).size, contracts.length, 'pool contracts must be unique'); + }); +}); + describe('both snapshots', () => { it('populate all five factors with real detail strings', async () => { for (const [factors] of [ [await new BlendAdapter().computeRiskFactors(blendMainnet)], [await new KineticAdapter().computeRiskFactors(kineticMainnet)], + [await new BlendAdapter({ pool: BLEND_YIELDBLOX_V2 }).computeRiskFactors(yieldbloxMainnet)], ]) { for (const [key, factor] of Object.entries(factors)) { assert.ok(factor, `${key} should be populated on live data`); diff --git a/core/src/types.ts b/core/src/types.ts index c37cc17..acffb58 100644 --- a/core/src/types.ts +++ b/core/src/types.ts @@ -48,6 +48,37 @@ export interface ProtocolLinks { docs?: string; } +/** + * Set when an entry is a market running on ANOTHER protocol's contracts rather + * than on its own — e.g. the YieldBlox pool, which is a community-managed pool + * deployed on Blend V2 and running Blend's pool contract byte-for-byte. + * + * This exists because such an entry is otherwise indistinguishable from an + * independent protocol, and presenting it as one would misrepresent the + * ecosystem: a reader scanning the registry would count two protocols where the + * chain has one codebase and two markets. Every consumer that renders a + * protocol's identity MUST render this alongside it when present — that is the + * whole reason the field is in the metadata rather than in a frontend lookup. + * + * It is deliberately NOT a link between registry entries. `host` is a display + * name, not an id: Stenion's `blend` entry is itself one pool, so pointing at it + * would claim "this runs on that entry", which is not what is true. What is true + * is that both run the host protocol's contract, and that is what this says. + * + * Absent (undefined) is the normal case and means exactly "this protocol runs on + * its own contracts" — never "we didn't check". + */ +export interface ProtocolDeployment { + /** the host protocol's display name, e.g. "Blend" */ + host: string; + /** + * Short label naming the exact deployment, scannable in a registry row and + * complete enough to stand alone, e.g. "Blend V2 pool". Written to be read at + * a glance next to the protocol's name — not a sentence. + */ + label: string; +} + export interface ProtocolMetadata { /** unique slug used as the primary key across storage and the API, e.g. "blend" */ id: string; @@ -85,6 +116,11 @@ export interface ProtocolMetadata { contractId?: string; /** the protocol's own site/docs — see ProtocolLinks for the endorsement caveat */ links?: ProtocolLinks; + /** + * Present only when this entry is a market on another protocol's contracts — + * see ProtocolDeployment. Optional because independence is the normal case. + */ + deployedOn?: ProtocolDeployment; /** * Which adapter produced this protocol's scores, e.g. "BlendAdapter". * Persisted to `protocols.adapter` and published on GET /api/v1/protocol/:id diff --git a/dashboard/app/(home)/page.tsx b/dashboard/app/(home)/page.tsx index d181668..4a9caf7 100644 --- a/dashboard/app/(home)/page.tsx +++ b/dashboard/app/(home)/page.tsx @@ -17,6 +17,7 @@ import { getProtocols, type LeaderboardEntry } from '../lib/api'; import { GITHUB_URL } from '../lib/site'; import { ScoreRing } from '../../components/score-ring'; import { MarkAttribution, ProtocolLogo } from '../../components/protocol-logo'; +import { DeploymentBadge } from '../../components/deployment-badge'; import { StatusPill } from '../../components/status-pill'; import { Reveal, RevealGroup, RevealItem } from '../../components/reveal'; @@ -137,6 +138,11 @@ export default async function HomePage() {
{p.chain}
+ {/* The strip shows the top three scores, so a market on + another protocol's contracts can surface here without + the reader ever reaching the registry. It carries the + same label there as everywhere else. */} +
draws a deliberate initials tile. */ logo: string | null; + /** see ProtocolDeployment — null for an entry on its own contracts */ + deployedOn: ProtocolDeployment | null; safetyScore: number | null; computedAt: string | null; lastRunAt: string | null; @@ -85,6 +109,8 @@ export interface ProtocolDetail { */ site: string | null; docs: string | null; + /** see ProtocolDeployment — null for an entry on its own contracts */ + deployedOn: ProtocolDeployment | null; safetyScore: number | null; computedAt: string | null; factors: RiskFactorMap | null; diff --git a/dashboard/app/protocol/[id]/page.tsx b/dashboard/app/protocol/[id]/page.tsx index 1da1b7d..d86505f 100644 --- a/dashboard/app/protocol/[id]/page.tsx +++ b/dashboard/app/protocol/[id]/page.tsx @@ -22,6 +22,7 @@ import { import { formatTimestamp, freshness } from '../../lib/format'; import { contractExplorerUrl, shortenContractId } from '../../lib/explorer'; import { MarkAttribution, ProtocolLogo } from '../../../components/protocol-logo'; +import { DeploymentBadge, DeploymentNotice } from '../../../components/deployment-badge'; import { ScoreRing } from '../../../components/score-ring'; import { StatusPill } from '../../../components/status-pill'; import { FactorCard } from '../../../components/factor-bar'; @@ -38,9 +39,16 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin // here for a missing protocol is discarded, so don't pretend to set one. if (!detail) return {}; const score = detail.safetyScore ?? '—'; + // The deployment note belongs in the description, not only on the page: this + // is the text a shared link renders, and a preview card reading "YieldBlox — + // safety 24" with nothing else is the exact misreading the label exists to + // prevent, travelling further than the page itself. + const deployment = detail.deployedOn + ? ` A ${detail.deployedOn.label}, not an independent protocol.` + : ''; return { title: `${detail.name} — safety ${score}`, - description: `Live Stenion safety score and factor breakdown for ${detail.name} on ${detail.chain}.`, + description: `Live Stenion safety score and factor breakdown for ${detail.name} on ${detail.chain}.${deployment}`, }; } @@ -68,6 +76,19 @@ export default async function ProtocolDetailPage({ params }: { params: Promise<{ + {/* What this entry IS comes before how fresh our reading of it is, and + well before any number: a score is uninterpretable until you know + whether you are looking at a protocol or at one market on another + protocol's contracts. + Guarded out here as well as inside DeploymentNotice, because the + wrapper carries the `mt-4` — rendering it around a null child + would leave a stray gap on every independent protocol's page. */} + {detail.deployedOn && ( + + + + )} + @@ -216,6 +237,11 @@ function Hero({ detail }: { detail: ProtocolDetail }) { {detail.adapter} + {/* Repeated here as well as in the notice below because the hero is + what a screenshot or a shared card captures, and the adapter name + beside it (BlendAdapter on a page titled YieldBlox) otherwise + reads as a mistake rather than as the point. */} +

diff --git a/dashboard/app/registry/page.tsx b/dashboard/app/registry/page.tsx index 7322d87..418839b 100644 --- a/dashboard/app/registry/page.tsx +++ b/dashboard/app/registry/page.tsx @@ -6,6 +6,7 @@ import { bandColor, bandTextClass, formatTimestamp, freshness, scoreBand } from import { cn } from '../lib/cn'; import { FreshnessTooltip, StatusPill } from '../../components/status-pill'; import { MarkAttribution, ProtocolLogo } from '../../components/protocol-logo'; +import { DeploymentBadge } from '../../components/deployment-badge'; import { Reveal, RevealGroup, RevealItem } from '../../components/reveal'; export const dynamic = 'force-dynamic'; @@ -25,6 +26,20 @@ export default async function RegistryPage() { errored = true; } + // Derived from what was actually fetched, never assumed. The note below + // explains a category of row, so it may only appear when the board really + // contains one — otherwise it describes members that aren't there, which is a + // worse failure than saying nothing: a reader looks for the labelled row, + // doesn't find it, and learns the copy can't be trusted. + // + // It matters because the two can genuinely come apart. The registry renders + // from the database, and a `deployedOn` entry only lands there once an indexer + // cycle has upserted it — so between deploying a new pool config and the first + // cycle that runs it, the code knows about a market the board does not. Same + // for a pool later removed from BLEND_POOLS, or one whose row is present but + // never scored. + const hasDeployedEntries = (protocols ?? []).some((p) => p.deployedOn !== null); + return (
@@ -39,6 +54,18 @@ export default async function RegistryPage() { is derived purely from on-chain data; no protocol can pay to move up. Open a protocol to see the full factor breakdown behind its number.

+ {/* Said once at the top rather than only per-row: a reader who scans the + list and leaves should know that a row is not necessarily a distinct + protocol, even if they never hover the badge that says which. + Conditional, because a standing claim about "some entries" with no + such entry on the board is a promise the page doesn't keep. */} + {hasDeployedEntries && ( +

+ Some entries are individual markets running another protocol’s contracts rather + than protocols in their own right. Those are labelled on the row, and scored on their + own reserves, oracle and admin like any other entry. +

+ )}
{errored ? ( @@ -108,8 +135,16 @@ function ProtocolRow({ entry, rank }: { entry: LeaderboardEntry; rank: number }) size={36} className="mr-1 transition-transform duration-200 ease-out motion-safe:group-hover:-rotate-6" /> - {entry.name} - + {/* Name and deployment label stack, so the label sits with the name it + qualifies rather than trailing off to the right where a narrow + viewport would wrap it away from its subject. */} + + + {entry.name} + + + +
diff --git a/dashboard/components/deployment-badge.tsx b/dashboard/components/deployment-badge.tsx new file mode 100644 index 0000000..b82dd86 --- /dev/null +++ b/dashboard/components/deployment-badge.tsx @@ -0,0 +1,112 @@ +// "This entry is a market on someone else's contracts." +// +// WHY IT EXISTS. Every entry in the registry used to be an independent protocol, +// so a row's name was the whole story. The YieldBlox pool breaks that: it runs +// Blend's V2 pool contract byte-for-byte (identical wasm hash to Blend's own +// Fixed pool), and listing it unqualified beside Blend and Kinetic would tell a +// reader the ecosystem has three independent lending protocols when it has two +// protocols and three markets. Stenion refused to build a standalone YieldBlox +// adapter for exactly that reason, so this label is not decoration — it is the +// condition on which the entry is allowed to exist at all. +// +// COLOUR IS DELIBERATELY NEUTRAL. Not a score band, because this says nothing +// about risk: a pool on Blend's contracts is neither safer nor more dangerous +// for being one, and a green or amber pill would assert otherwise. Not accent +// either — the dashboard already spends accent on freshness ("our data is old"), +// and a second accent-toned marker on the same row would blur a distinction that +// took work to draw. Identity gets its own quiet register: hairline border, +// muted text, no fill that competes with the score. +// +// It renders nothing when `deployedOn` is null, which is the normal case. There +// is no "independent" badge to go with it: labelling the ordinary state would +// turn the exception into noise, and absence already means the entry runs on its +// own contracts (never "unknown" — see ProtocolDeployment). + +import { Layers } from 'lucide-react'; + +import { cn } from '../app/lib/cn'; +import type { ProtocolDeployment } from '../app/lib/contract'; + +export interface DeploymentBadgeProps { + deployedOn: ProtocolDeployment | null; + className?: string; +} + +/** + * The compact form: one pill, scannable in a registry row or a card. + * + * `label` is rendered verbatim rather than composed here ("Pool on " + host) + * because the phrasing is a per-market fact the adapter owns — "Blend V2 pool" + * is right for YieldBlox and would be wrong for a deployment that isn't a pool. + * The UI's job is to make sure it is SEEN, not to write it. + */ +export function DeploymentBadge({ deployedOn, className }: DeploymentBadgeProps) { + if (deployedOn === null) return null; + return ( + + + ); +} + +/** + * The full form for a protocol's own page, where there is room to say the whole + * thing once rather than compress it into a pill. + * + * Placed directly under the hero, above the factor breakdown, because a reader + * has to know WHAT they are looking at before any number means anything: a + * `contractId` that resolves to a Blend pool is only checkable if you already + * know it is supposed to. + * + * What it deliberately does NOT say: anything evaluative. It doesn't call the + * arrangement good or bad, and it doesn't imply the host protocol endorses or is + * responsible for this market. Both of those would be claims we can't make from + * the chain — what we can read is whose code it runs, and that is all it states. + */ +export function DeploymentNotice({ + deployedOn, + name, + className, +}: { + deployedOn: ProtocolDeployment | null; + /** the market's own display name, e.g. "YieldBlox" */ + name: string; + className?: string; +}) { + if (deployedOn === null) return null; + return ( +
+
+ ); +} diff --git a/db/migrations/0006_protocol_deployment.sql b/db/migrations/0006_protocol_deployment.sql new file mode 100644 index 0000000..be96cf8 --- /dev/null +++ b/db/migrations/0006_protocol_deployment.sql @@ -0,0 +1,48 @@ +-- Deployment provenance: when a registry entry is a market running on ANOTHER +-- protocol's contracts rather than on its own. +-- +-- Why this exists at all. Until now every entry was an independent protocol, so +-- "a row in `protocols`" and "a protocol" were the same thing and nothing had to +-- say otherwise. Adding the YieldBlox pool breaks that: it is a DAO-managed +-- market on Blend V2, running Blend's pool contract byte-for-byte (identical +-- wasm hash to Blend's own Fixed pool, and the V2 pool factory's `is_pool` +-- returns true for both). Listing it beside Blend and Kinetic with no further +-- qualification would tell a reader the Stellar ecosystem has three independent +-- lending protocols when it has two protocols and three markets. That is a +-- misrepresentation the registry must not make, which is why this is a column +-- and not a frontend footnote — it travels with the row, into the public API, +-- and every consumer sees it. +-- +-- Same route as every other identity column (ProtocolMetadata -> upsertProtocol, +-- overwritten from adapter metadata on every cycle) and the same reasoning as +-- 0003: adapter metadata is the one source, so there is no dashboard lookup +-- table keyed by slug to go stale when an adapter is added. +-- +-- BOTH NULLABLE, and NULL is the normal case: it means "this entry runs on its +-- own contracts", which is true of Blend and Kinetic and will be true of most +-- future entries. It never means "we did not check". Nullable is also required +-- for the live-writer hazard 0002 and 0003 both document at length — this +-- migration runs against the one shared Neon database while `main` may still be +-- running an indexer that upserts without these columns. +-- +-- They are written and read as a PAIR (the API publishes them as a single +-- `deployedOn` object, present only when both are set). A row with one and not +-- the other is meaningless, and no writer can produce one: `ProtocolDeployment` +-- makes both required, so the adapter either supplies the object or omits it. +-- Deliberately NOT enforced with a CHECK, for the same live-writer reason — a +-- constraint added here binds a deploy that has not happened yet. +-- +-- `deployment_host` is a DISPLAY NAME ("Blend"), not a `protocols.id`, and there +-- is deliberately no foreign key. Stenion's `blend` row is itself one Blend +-- market, so a reference to it would assert "this pool runs on that entry", +-- which is not what is true; what is true is that both run the host protocol's +-- contract. A FK would also make the host's presence in the registry a +-- precondition for naming it, and a market can outlive — or precede — its +-- backbone having an entry of its own. +-- +-- Two text columns rather than one `jsonb`, on 0003's reasoning: this is a short +-- fixed set whose members each mean something specific and are published as +-- typed fields on the public API, not an open taxonomy that grows per adapter. +ALTER TABLE protocols + ADD COLUMN IF NOT EXISTS deployment_host text, + ADD COLUMN IF NOT EXISTS deployment_label text; diff --git a/db/src/store.test.ts b/db/src/store.test.ts index a23abae..10e5e86 100644 --- a/db/src/store.test.ts +++ b/db/src/store.test.ts @@ -19,6 +19,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { + toDeployedOn, toHistoryEntry, toLeaderboardEntry, toProtocolDetail, @@ -65,6 +66,10 @@ const detailRow = (over: Partial = {}): ProtocolDetailRow => contract_id: 'CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD', site_url: 'https://www.blend.capital', docs_url: 'https://docs.blend.capital', + // NULL is the normal case — Blend runs on its own contracts. The YieldBlox + // pool is the row that sets these; see the deployment suite below. + deployment_host: null, + deployment_label: null, safety_score: '53', computed_at: COMPUTED_AT, factors: FACTORS, @@ -283,6 +288,8 @@ describe('toLeaderboardEntry', () => { name: 'Blend', chain: 'stellar', logo: '/assets/protocols/blend.svg', + deployment_host: null, + deployment_label: null, safety_score: '53', computed_at: COMPUTED_AT, last_run_at: RUN_AT, @@ -296,6 +303,7 @@ describe('toLeaderboardEntry', () => { name: 'Blend', chain: 'stellar', logo: '/assets/protocols/blend.svg', + deployedOn: null, safetyScore: 53, computedAt: COMPUTED_AT.toISOString(), lastRunAt: RUN_AT.toISOString(), @@ -404,6 +412,8 @@ describe('an empty risk_scores table', () => { name: 'Blend', chain: 'stellar', logo: '/assets/protocols/blend.svg', + deployment_host: null, + deployment_label: null, safety_score: null, computed_at: null, last_run_at: null, @@ -415,3 +425,107 @@ describe('an empty risk_scores table', () => { assert.equal(entry.name, 'Blend'); }); }); + +// --------------------------------------------------------------------------- +// `deployedOn` — the label that stops one protocol's pool reading as a second +// protocol. +// +// WHY IT IS TESTED HERE and not left to the UI: this is the field on which +// registering the YieldBlox pool was made conditional. If it silently mapped to +// null, the registry would list a Blend market beside Blend and Kinetic with +// nothing to distinguish it, which is precisely the misrepresentation Stenion +// refused to publish when it declined to build a standalone YieldBlox adapter. +// A wrong number is visibly wrong; a missing label just reads as a third +// protocol. +// --------------------------------------------------------------------------- + +describe('toDeployedOn — the two deployment columns as one published object', () => { + it('maps a complete pair to the object the API publishes', () => { + assert.deepEqual(toDeployedOn('Blend', 'Blend V2 pool'), { + host: 'Blend', + label: 'Blend V2 pool', + }); + }); + + it('maps both-null to null — an entry on its own contracts', () => { + // The common case, and it must stay cheap and unambiguous: null here means + // "independent", never "we did not check". + assert.equal(toDeployedOn(null, null), null); + }); + + it('refuses a half-populated pair rather than publishing a partial claim', () => { + // Unreachable from the adapter — ProtocolDeployment makes both fields + // required, so a writer supplies the object or omits it — but reachable from + // a hand-edited row or a half-applied migration. Emitting + // `{ host: null, label: 'Blend V2 pool' }` would be a shape no consumer was + // promised, built from data we already know is broken. + assert.equal(toDeployedOn(null, 'Blend V2 pool'), null); + assert.equal(toDeployedOn('Blend', null), null); + }); +}); + +describe('the deployment label on both public responses', () => { + const yieldbloxDetail = detailRow({ + id: 'yieldblox', + name: 'YieldBlox', + // Same adapter as Blend, deliberately: one engine, two pools. This pairing — + // a BlendAdapter row that is not named Blend — is exactly why the label has + // to be present, so `adapter: 'BlendAdapter'` reads as the point rather than + // as a bug. + adapter: 'BlendAdapter', + logo: null, + contract_id: 'CCCCIQSDILITHMM7PBSLVDT5MISSY7R26MNZXCX4H7J5JQ5FPIYOGYFS', + site_url: 'https://yieldblox.finance', + docs_url: null, + deployment_host: 'Blend', + deployment_label: 'Blend V2 pool', + }); + + it('publishes it on the detail response', () => { + const detail = toProtocolDetail(yieldbloxDetail, [okRow()]); + assert.deepEqual(detail.deployedOn, { host: 'Blend', label: 'Blend V2 pool' }); + }); + + it('publishes it on the leaderboard too, not only on the detail call', () => { + // The board is where the misreading happens: a reader scanning three rows + // and leaving never makes the detail request. Unlike contractId/site/docs, + // this one has to survive the trip to every row. + const entry = toLeaderboardEntry({ + id: 'yieldblox', + name: 'YieldBlox', + chain: 'stellar', + logo: null, + deployment_host: 'Blend', + deployment_label: 'Blend V2 pool', + safety_score: '24', + computed_at: COMPUTED_AT, + last_run_at: RUN_AT, + last_run_status: 'ok', + }); + assert.deepEqual(entry.deployedOn, { host: 'Blend', label: 'Blend V2 pool' }); + }); + + it('leaves an independent protocol null on both', () => { + assert.equal(toProtocolDetail(detailRow(), [okRow()]).deployedOn, null); + }); + + it('survives a protocol with no score at all', () => { + // Identity is not score-derived, so a market that has never scored — or + // whose history was wiped — must still say what it is. This is the window in + // which a reader is MOST likely to be looking at an unfamiliar entry. + const detail = toProtocolDetail( + detailRow({ + ...yieldbloxDetail, + safety_score: null, + computed_at: null, + factors: null, + methodology_version: null, + last_run_at: null, + last_run_status: null, + }), + [], + ); + assert.equal(detail.safetyScore, null); + assert.deepEqual(detail.deployedOn, { host: 'Blend', label: 'Blend V2 pool' }); + }); +}); diff --git a/db/src/store.ts b/db/src/store.ts index 38f495d..2edbe68 100644 --- a/db/src/store.ts +++ b/db/src/store.ts @@ -3,7 +3,7 @@ // not redesigned. This package now owns the persisted contract so both the // indexer (writing) and the API (reading, step 6) agree on one definition. -import type { ProtocolMetadata, RiskFactorMap } from '@stenion/core'; +import type { ProtocolDeployment, ProtocolMetadata, RiskFactorMap } from '@stenion/core'; import type { Pool } from 'pg'; /** @@ -55,6 +55,20 @@ export interface LeaderboardEntry { * across every row of every leaderboard fetch. */ logo: string | null; + /** + * Set when this entry is a market on another protocol's contracts (see + * ProtocolDeployment), null when it runs on its own — which is the normal + * case, and never means "unknown". + * + * This one DOES belong on the board, unlike contractId/site/docs above, and + * for the reason those don't: it is not verification detail a reader looks up + * after deciding to care, it is part of what the row IS. A row that reads + * "YieldBlox · 24" next to "Blend · 54" tells a scanner there are two + * protocols here; the whole point of the label is that it is legible at scan + * time, which means it has to travel with every row of every leaderboard + * fetch rather than waiting on the detail call. + */ + deployedOn: ProtocolDeployment | null; safetyScore: number | null; computedAt: string | null; lastRunAt: string | null; @@ -110,6 +124,8 @@ export interface ProtocolDetail { */ site: string | null; docs: string | null; + /** see LeaderboardEntry.deployedOn — same value, same null-means-independent contract */ + deployedOn: ProtocolDeployment | null; safetyScore: number | null; computedAt: string | null; factors: RiskFactorMap | null; @@ -164,6 +180,20 @@ export interface Store { listRecentRuns(protocolId: string, limit: number): Promise; } +/** + * The two deployment columns -> the public `deployedOn` object, or null. + * + * Both must be present. They are written as a pair from a `ProtocolDeployment` + * (where both fields are required), so a half-populated row can only come from + * a hand-edit or a partially-applied migration — and in that case null is the + * honest answer. A `{ host: null, label: 'Blend V2 pool' }` on the public API + * would be a shape no consumer was promised, published from data we know is + * broken; omitting the claim is strictly better than half-making it. + */ +export function toDeployedOn(host: string | null, label: string | null): ProtocolDeployment | null { + return host === null || label === null ? null : { host, label }; +} + /** timestamptz comes back from pg as a Date; the API contract is ISO strings. */ function toIso(value: Date | null): string | null { return value === null ? null : value.toISOString(); @@ -230,6 +260,8 @@ export interface ProtocolDetailRow { contract_id: string | null; site_url: string | null; docs_url: string | null; + deployment_host: string | null; + deployment_label: string | null; safety_score: string | null; computed_at: Date | null; factors: RiskFactorMap | null; @@ -263,6 +295,7 @@ export function toProtocolDetail( contractId: row.contract_id, site: row.site_url, docs: row.docs_url, + deployedOn: toDeployedOn(row.deployment_host, row.deployment_label), safetyScore: toNumber(row.safety_score), computedAt: toIso(row.computed_at), factors: row.factors, @@ -279,6 +312,8 @@ export interface LeaderboardRow { name: string; chain: string; logo: string | null; + deployment_host: string | null; + deployment_label: string | null; safety_score: string | null; computed_at: Date | null; last_run_at: Date | null; @@ -292,6 +327,7 @@ export function toLeaderboardEntry(row: LeaderboardRow): LeaderboardEntry { name: row.name, chain: row.chain, logo: row.logo, + deployedOn: toDeployedOn(row.deployment_host, row.deployment_label), safetyScore: toNumber(row.safety_score), computedAt: toIso(row.computed_at), lastRunAt: toIso(row.last_run_at), @@ -317,8 +353,10 @@ export function createStore(pool: Pool): Store { // protocol supplying a mark that flatters, next to a score it dislikes). // See ProtocolMetadata.logo and CONTRIBUTING.md. await pool.query( - `INSERT INTO protocols (id, name, chain, adapter, logo, contract_id, site_url, docs_url) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `INSERT INTO protocols + (id, name, chain, adapter, logo, contract_id, site_url, docs_url, + deployment_host, deployment_label) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, chain = EXCLUDED.chain, @@ -327,6 +365,8 @@ export function createStore(pool: Pool): Store { contract_id = EXCLUDED.contract_id, site_url = EXCLUDED.site_url, docs_url = EXCLUDED.docs_url, + deployment_host = EXCLUDED.deployment_host, + deployment_label = EXCLUDED.deployment_label, updated_at = now()`, [ metadata.id, @@ -340,6 +380,13 @@ export function createStore(pool: Pool): Store { metadata.contractId ?? null, metadata.links?.site ?? null, metadata.links?.docs ?? null, + // Overwritten like every other identity column, including back to NULL: + // if a market is ever migrated off a host protocol's contracts, the + // adapter dropping `deployedOn` must clear the claim rather than leave + // a stale one standing. That is the same reason this statement doesn't + // COALESCE anything. + metadata.deployedOn?.host ?? null, + metadata.deployedOn?.label ?? null, ], ); }, @@ -377,6 +424,7 @@ export function createStore(pool: Pool): Store { // flag. Rank by score desc, never-scored protocols (null score) last. const { rows } = await pool.query( `SELECT p.id, p.name, p.chain, p.logo, + p.deployment_host, p.deployment_label, ok.safety_score, ok.computed_at, latest.run_at AS last_run_at, latest.status AS last_run_status FROM protocols p @@ -406,6 +454,7 @@ export function createStore(pool: Pool): Store { const { rows } = await pool.query( `SELECT p.id, p.name, p.chain, p.adapter, p.logo, p.contract_id, p.site_url, p.docs_url, + p.deployment_host, p.deployment_label, ok.safety_score, ok.computed_at, ok.factors, ok.methodology_version, latest.run_at AS last_run_at, latest.status AS last_run_status FROM protocols p diff --git a/indexer/src/index.ts b/indexer/src/index.ts index d6dc4cc..20b5a10 100644 --- a/indexer/src/index.ts +++ b/indexer/src/index.ts @@ -18,7 +18,7 @@ // failing never aborts the cycle or process; a DB write failure is likewise // caught and logged so it can't kill the loop. -import { BlendAdapter, KineticAdapter } from '@stenion/adapters'; +import { BLEND_POOLS, BlendAdapter, KineticAdapter } from '@stenion/adapters'; import { closePool, createStore, getPool, type Store } from '@stenion/db'; import { webhookNotifier } from './alerts'; @@ -35,21 +35,44 @@ export type { StreakAlert } from './alerts'; import type { CycleSummary } from './cycle'; function buildTargets(config: IndexerConfig): IndexTarget[] { - // poolId is deliberately not configured here — it's a Blend constant the - // adapter owns (FIXED_POOL_V2), not environment config. Override via the - // BlendAdapter constructor if a test/testnet pool is ever needed. - const blend = new BlendAdapter({ - rpcUrl: config.rpcUrl, - horizonUrl: config.horizonUrl, - }); - // Kinetic (K2) — second, genuinely-independent protocol. Same run loop via - // the toTarget wrapper (its TRawData differs from Blend's, so the list - // can't be typed Adapter[] directly — that's what the wrapper is for). + // Which pools exist is deliberately NOT environment config — it is the + // adapter's own BLEND_POOLS registry, reviewed in a PR alongside the pool's + // identity metadata. Point an instance at a test/testnet pool by passing a + // BlendPool to the constructor; there is no env var that can silently change + // what the public registry is scoring. + // + // One BlendAdapter instance PER POOL, all running the same engine: the shared + // scoring code is the class, and what differs is only the pool it was handed. + // Iterating BLEND_POOLS rather than naming pools here means adding a market + // touches one list, in adapters, and nothing in the indexer. + const blend = BLEND_POOLS.map((pool) => + toTarget( + new BlendAdapter({ + rpcUrl: config.rpcUrl, + horizonUrl: config.horizonUrl, + pool, + }), + ), + ); + // Kinetic (K2) — the genuinely-independent protocol, not a Blend pool. Same + // run loop via the toTarget wrapper (its TRawData differs from Blend's, so + // the list can't be typed Adapter[] directly — that's what the + // wrapper is for). const kinetic = new KineticAdapter({ rpcUrl: config.rpcUrl, horizonUrl: config.horizonUrl, }); - return [toTarget(blend), toTarget(kinetic)]; + // NOTE ON ORDER, because it decides who gets squeezed. runCycle divides the + // budget REMAINING by the number of targets LEFT, so the first target gets + // exactly budgetMs/N and each later one inherits whatever slack the earlier + // ones did not spend. At three targets that first share is 14s against a 15s + // attempt timeout, so a first attempt that runs to its cap leaves nothing for + // a retry — the ceiling ROADMAP.md describes, reached one protocol earlier + // than it predicts. Blend leads because it has the fastest observed fetch + // (~6-7s against Kinetic's ~10-11s) and so is likeliest to hand its slack on + // rather than consume the tightest share; Kinetic sits last, where the + // remainder is largest. This is an ordering, not a fix — see ROADMAP.md. + return [...blend, toTarget(kinetic)]; } /** diff --git a/scripts/capture-fixture.mjs b/scripts/capture-fixture.mjs index 8ac856d..ea0643e 100644 --- a/scripts/capture-fixture.mjs +++ b/scripts/capture-fixture.mjs @@ -18,8 +18,16 @@ // Usage: // pnpm capture:fixture blend // pnpm capture:fixture kinetic +// pnpm capture:fixture yieldblox // pnpm capture:fixture all // +// `blend` and `yieldblox` are two POOLS behind one adapter, not two adapters — +// BlendAdapter pointed at BLEND_FIXED_V2 and at BLEND_YIELDBLOX_V2. They get +// separate fixtures because they are separate on-chain state: different oracle +// aggregator, different admin, different reserve set. The shared engine is +// exactly what makes both fixtures worth having — a decode regression that the +// three tidy Fixed reserves happen to survive shows up in YieldBlox's eight. +// // Requires the workspace to be built (`pnpm --filter @stenion/adapters build`) // and STENION_RPC_URL / STENION_HORIZON_URL in the repo-root .env or the shell. @@ -81,8 +89,8 @@ function toLiteral(value) { async function main() { const which = (process.argv[2] ?? '').toLowerCase(); - if (!['blend', 'kinetic', 'all'].includes(which)) { - console.error('Usage: pnpm capture:fixture '); + if (!['blend', 'kinetic', 'yieldblox', 'all'].includes(which)) { + console.error('Usage: pnpm capture:fixture '); process.exitCode = 2; return; } @@ -102,17 +110,38 @@ async function main() { rpcUrl: process.env.STENION_RPC_URL, horizonUrl: process.env.STENION_HORIZON_URL, }; + // Each entry names the adapter instance AND the raw type its fixture is + // checked against, because those no longer line up one-to-one: two of these + // are BlendAdapter on different pools, so the type is shared while the state + // is not. const targets = { - blend: () => new mod.BlendAdapter(opts), - kinetic: () => new mod.KineticAdapter(opts), + blend: { + type: 'BlendRawData', + module: 'blend', + make: () => new mod.BlendAdapter({ ...opts, pool: mod.BLEND_FIXED_V2 }), + }, + kinetic: { + type: 'KineticRawData', + module: 'kinetic', + make: () => new mod.KineticAdapter(opts), + }, + yieldblox: { + type: 'BlendRawData', + module: 'blend', + make: () => new mod.BlendAdapter({ ...opts, pool: mod.BLEND_YIELDBLOX_V2 }), + }, }; - const names = which === 'all' ? ['blend', 'kinetic'] : [which]; + const names = which === 'all' ? ['blend', 'kinetic', 'yieldblox'] : [which]; mkdirSync(FIXTURE_DIR, { recursive: true }); for (const name of names) { - const adapter = targets[name](); - console.log(`Capturing ${name} from ${opts.rpcUrl ?? '(adapter default RPC)'} …`); + const target = targets[name]; + const adapter = target.make(); + console.log( + `Capturing ${name} (${adapter.metadata.contractId}) from ` + + `${opts.rpcUrl ?? '(adapter default RPC)'} …`, + ); const raw = await adapter.fetchRawData(); const factors = await adapter.computeRiskFactors(raw); @@ -121,7 +150,7 @@ async function main() { // The captured-at stamp is metadata only — nothing reads it at test time. // `fetchedAt` *inside* the raw data is what price ages are measured // against, and freezing that is what keeps the freshness score stable. - const typeName = name === 'blend' ? 'BlendRawData' : 'KineticRawData'; + const typeName = target.type; const constName = `${name}Mainnet`; const source = `// Frozen mainnet snapshot — generated by scripts/capture-fixture.mjs. // @@ -137,7 +166,7 @@ async function main() { // \`satisfies\` is load-bearing: if ${typeName} gains a required field, this file // stops compiling rather than quietly feeding the adapter a stale shape. -import type { ${typeName} } from '../${name}.ts'; +import type { ${typeName} } from '../${target.module}.ts'; export const ${constName} = ${toLiteral(raw)} satisfies ${typeName}; `;