diff --git a/API.md b/API.md index a664b74..dee8287 100644 --- a/API.md +++ b/API.md @@ -7,7 +7,7 @@ wallet, an aggregator, or a dashboard and you want a live safety number for a pr are about to interact with, this is the whole surface area. Everything below was captured from the live production API, not written from the type definitions. -The example responses are verbatim bodies from a snapshot taken at **2026-08-19T13:20Z**; the +The example responses are verbatim bodies from a snapshot taken at **2026-08-20T11:05–11:10Z**; the numbers move every ~5 minutes, the shapes do not. --- @@ -89,14 +89,6 @@ 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": [ @@ -105,9 +97,10 @@ curl https://stenion.vercel.app/api/v1/protocols "name": "Blend", "chain": "stellar", "logo": "/assets/protocols/blend.svg", + "deployedOn": null, "safetyScore": 53, - "computedAt": "2026-08-19T13:20:06.991Z", - "lastRunAt": "2026-08-19T13:20:04.336Z", + "computedAt": "2026-08-20T11:05:05.600Z", + "lastRunAt": "2026-08-20T11:05:02.641Z", "lastRunStatus": "ok" }, { @@ -115,15 +108,34 @@ curl https://stenion.vercel.app/api/v1/protocols "name": "Kinetic", "chain": "stellar", "logo": "/assets/protocols/kinetic.png", + "deployedOn": null, "safetyScore": 27, - "computedAt": "2026-08-19T13:20:12.614Z", - "lastRunAt": "2026-08-19T13:20:07.182Z", + "computedAt": "2026-08-20T11:05:15.167Z", + "lastRunAt": "2026-08-20T11:05:10.083Z", + "lastRunStatus": "ok" + }, + { + "id": "yieldblox", + "name": "YieldBlox", + "chain": "stellar", + "logo": null, + "deployedOn": { + "host": "Blend", + "label": "Blend V2 pool" + }, + "safetyScore": 24, + "computedAt": "2026-08-20T11:05:09.897Z", + "lastRunAt": "2026-08-20T11:05:05.786Z", "lastRunStatus": "ok" } ] } ``` +> One value in this capture has since moved: `yieldblox` had no self-hosted mark when it was taken +> and now carries `"logo": "/assets/protocols/yieldblox.png"`. Re-`curl` this block on the next +> deploy rather than hand-editing the value in. + | Field | Type | Notes | | --------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | string | Stable identifier, **case-sensitive**, used as the path segment on the detail endpoint. | @@ -151,10 +163,14 @@ own right. The YieldBlox entry (`yieldblox`) is one: it is a DAO-managed pool on 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: +Such an entry carries a non-null `deployedOn` on **both** endpoints — verbatim from the +`yieldblox` entry in the leaderboard capture above: ```json -"deployedOn": { "host": "Blend", "label": "Blend V2 pool" } +"deployedOn": { + "host": "Blend", + "label": "Blend V2 pool" +} ``` | Field | Type | Notes | @@ -206,19 +222,20 @@ curl https://stenion.vercel.app/api/v1/protocol/blend "contractId": "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD", "site": "https://www.blend.capital", "docs": "https://docs.blend.capital", + "deployedOn": null, "safetyScore": 53, - "computedAt": "2026-08-19T13:20:06.991Z", + "computedAt": "2026-08-20T11:10:07.254Z", "factors": { "oracleSafety": { "value": 99, - "detail": "all 3 reserves score the same — 306s old (fresh<300s, dead>900s); all reserves have a deviation bound", + "detail": "all 3 reserves score the same — 307s old (fresh<300s, dead>900s); all reserves have a deviation bound", "weight": 0.25, "components": [ { "id": "priceFreshness", "label": "Price freshness", "value": 99, - "detail": "all 3 reserves score the same — 306s old (fresh<300s, dead>900s); anchored to the aggregator's own resolution and max_age (900s)" + "detail": "all 3 reserves score the same — 307s old (fresh<300s, dead>900s); anchored to the aggregator's own resolution and max_age (900s)" }, { "id": "deviationBound", @@ -230,7 +247,7 @@ curl https://stenion.vercel.app/api/v1/protocol/blend "id": "priceAges", "label": "Price age by feed (not scored)", "value": null, - "detail": "Other:XLM 306s, Other:USDC 306s, Other:EURC 306s — all 3 within the protocol's own 900s staleness limit. Reported, not graded: priceFreshness already scores the worst of these." + "detail": "Other:XLM 307s, Other:USDC 307s, Other:EURC 307s — all 3 within the protocol's own 900s staleness limit. Reported, not graded: priceFreshness already scores the worst of these." }, { "id": "deviationTightness", @@ -251,8 +268,8 @@ curl https://stenion.vercel.app/api/v1/protocol/blend "weight": 0.15 }, "collateralSafety": { - "value": 71, - "detail": "top reserve holds 64% of supplied value across 3 reserves (HHI 0.53)", + "value": 68, + "detail": "top reserve holds 67% of supplied value across 3 reserves (HHI 0.55)", "weight": 0.2 }, "utilizationSafety": { @@ -262,29 +279,29 @@ curl https://stenion.vercel.app/api/v1/protocol/blend } }, "methodologyVersion": 1, - "lastRunAt": "2026-08-19T13:20:04.336Z", + "lastRunAt": "2026-08-20T11:10:04.814Z", "lastRunStatus": "ok", "history": [ { "status": "ok", "safetyScore": 53, "methodologyVersion": 1, - "computedAt": "2026-08-19T13:20:06.991Z", - "runAt": "2026-08-19T13:20:04.336Z" + "computedAt": "2026-08-20T11:10:07.254Z", + "runAt": "2026-08-20T11:10:04.814Z" }, { "status": "ok", "safetyScore": 53, "methodologyVersion": 1, - "computedAt": "2026-08-19T13:15:07.978Z", - "runAt": "2026-08-19T13:15:05.118Z" + "computedAt": "2026-08-20T11:05:05.600Z", + "runAt": "2026-08-20T11:05:02.641Z" }, { "status": "ok", "safetyScore": 53, "methodologyVersion": 1, - "computedAt": "2026-08-19T13:10:06.937Z", - "runAt": "2026-08-19T13:10:04.295Z" + "computedAt": "2026-08-20T11:00:09.773Z", + "runAt": "2026-08-20T11:00:07.298Z" } ] } diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d4ef64a..6aa90a5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -220,11 +220,18 @@ still recorded as `failed` — a protocol that is genuinely down still shows as and four different ones usually mean "the protocol changed" versus "the RPC provider is flaky". - **The rendered message is capped at 2,000 characters** (`MAX_MESSAGE_CHARS`). Discord _rejects_ a longer `content` with a 400 rather than truncating it, and the case that reaches the limit is the - worst one available: an RPC-wide outage takes out every protocol, they all cross the threshold on - the same cycle, and their alerts batch into one POST — two protocols with four distinct Soroban - `HostError` messages each renders to ~2,500 characters. Without the cap, the alert for the biggest - possible outage is the one that silently never arrives. The structured `alerts` array is never - truncated, so nothing is lost for a machine consumer. + worst one available: an RPC-wide outage takes out every target, they all cross the threshold on + the same cycle, and their alerts batch into one POST. The render is one block per alert, so the + body scales linearly with target count — two protocols with four distinct Soroban `HostError` + messages each measured ~2,500 characters, and three of the same is ~3,700. Without the cap, the + alert for the biggest possible outage is the one that silently never arrives. The structured + `alerts` array is never truncated, so nothing is lost for a machine consumer. + + **The third target moved where truncation starts.** Measured with a moderate ~150-character error + message, the same four-distinct-errors scenario renders 1,958 characters across two targets — it + fit — and 2,944 across three. Outages that used to arrive whole now arrive marked truncated. The + cap is doing its job either way; what changed is how often a reader sees the marker. + - **Verifying delivery without waiting for a real outage:** `pnpm smoke:alert-webhook` drives the real path — a seeded failure streak through `runCycle`, `decideAlert`, `formatAlert` and the real `webhookNotifier` — at a live webhook URL, reporting the HTTP status and body that diff --git a/adapters/blend.ts b/adapters/blend.ts index 1701f58..5be6512 100644 --- a/adapters/blend.ts +++ b/adapters/blend.ts @@ -119,25 +119,37 @@ export const BLEND_FIXED_V2: BlendPool = { * 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. + * as "a community-run DeFi lending protocol on the Stellar network, built on + * Blend". 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. Their own wording says the same thing this entry's label does, which is + * the best evidence available that the label is not our interpretation. * - * 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. + * `logo` is a self-hosted copy of their own mark, taken from the 512x512 + * `icon-512.png` their web manifest publishes — never a hotlink. PNG rather than + * SVG because they ship no vector mark: the site is a SvelteKit build whose only + * inline SVGs are 24x24 `currentColor` UI glyphs, and its icon set is raster + * throughout. That is the documented fallback (CONTRIBUTING.md, "The logo + * asset"), same as Kinetic's. * - * 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. + * It clears the dark-tile check: the mark is a green glyph (#38af4a) on a fully + * transparent field — sampled corners are alpha 0 and its opaque pixels average + * luminance 147 — so nothing vanishes into the tile's #0a0816. Borrowing Blend's + * mark, had none been available, would have been the worst option on the list: + * it would assert precisely the identity this entry exists to deny. + * + * No `docs`: yieldblox.xyz publishes no documentation link, and a dead link is + * worse than an absent one. */ export const BLEND_YIELDBLOX_V2: BlendPool = { id: 'yieldblox', name: 'YieldBlox', poolId: 'CCCCIQSDILITHMM7PBSLVDT5MISSY7R26MNZXCX4H7J5JQ5FPIYOGYFS', + logo: '/assets/protocols/yieldblox.png', links: { - site: 'https://yieldblox.finance', + // Their canonical URL (rel=canonical resolves to the www host), not the + // yieldblox.finance placeholder this entry originally shipped with. + site: 'https://www.yieldblox.xyz', }, deployedOn: { host: 'Blend', diff --git a/dashboard/app/(home)/page.tsx b/dashboard/app/(home)/page.tsx index 4a9caf7..022712b 100644 --- a/dashboard/app/(home)/page.tsx +++ b/dashboard/app/(home)/page.tsx @@ -116,7 +116,14 @@ export default async function HomePage() { ) : ( {scored.map((p, i) => ( - + // `h-full` on both the cell and the card. The grid stretches the + // cell to the tallest in the row, but the inside only grew + // to its own content, so a card with one extra line was visibly + // taller than its neighbours rather than merely fuller. That went + // unnoticed while every card held identical content; the first + // card to carry a deployment label exposed it. Same fix the + // protocol page's factor grid already uses. + - + {/* shrink-0: the ring carries its size as an inline style, which + is a flex-basis, not a floor — a longer text column beside it + would otherwise squash the ring on the narrowest cards. */} +
@@ -135,14 +151,22 @@ export default async function HomePage() { {p.name}
-
- {p.chain} -
- {/* The strip shows the top three scores, so a market on + {/* Chain and deployment label share ONE line, so every card + has the same number of rows whether or not it carries a + label. The badge previously sat on a row of its own, + which is what made the heights diverge; `h-full` above + now equalises them, and keeping the row count equal is + what stops that equalising from leaving the other cards + looking padded out with dead space. + + 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. */} - +
+ {p.chain} + +
{ // to be present, so `adapter: 'BlendAdapter'` reads as the point rather than // as a bug. adapter: 'BlendAdapter', - logo: null, + // Mirrors the real row rather than a plausible-looking one, so a reader + // comparing this fixture against the live entry finds them agreeing. The + // null-logo path has its own coverage on the leaderboard mapping above and + // does not need to be smuggled in here. + logo: '/assets/protocols/yieldblox.png', contract_id: 'CCCCIQSDILITHMM7PBSLVDT5MISSY7R26MNZXCX4H7J5JQ5FPIYOGYFS', - site_url: 'https://yieldblox.finance', + site_url: 'https://www.yieldblox.xyz', docs_url: null, deployment_host: 'Blend', deployment_label: 'Blend V2 pool', diff --git a/indexer/src/alerts.ts b/indexer/src/alerts.ts index ce47b43..13604f3 100644 --- a/indexer/src/alerts.ts +++ b/indexer/src/alerts.ts @@ -217,13 +217,23 @@ export function formatAlert(alert: StreakAlert): string { * Discord's hard cap on `content`. A longer body is rejected outright with a * 400 — the message does not arrive truncated, it does not arrive at all. * - * This is not a theoretical limit. The case that reaches it is the worst one - * there is: an RPC-wide outage takes out every protocol, they all cross the - * threshold on the same cycle, and their alerts are batched into a single POST. - * Two protocols with four distinct Soroban `HostError` messages each render to - * ~2,500 characters, so the alert for the biggest possible outage would be the - * one Discord threw away. Slack's `text` limit is far higher (40,000), so this - * cap is Discord's, applied to both to keep the two keys identical. + * This is not a theoretical limit, and it got closer when the registry went from + * two targets to three. The case that reaches it is the worst one there is: an + * RPC-wide outage takes out every target, they all cross the threshold on the + * same cycle, and their alerts are batched into a single POST. The render is one + * block per alert, so the body scales linearly with target count — two protocols + * with four distinct Soroban `HostError` messages each measured ~2,500 + * characters, and three of the same is ~3,700. + * + * The sharper consequence is which cases now truncate at all. With a moderate + * ~150-character error message, the same four-distinct-errors scenario renders + * 1,958 characters across two targets — inside the cap — and 2,944 across three. + * The third target is what moves an ordinary outage from "arrives whole" to + * "arrives marked truncated". That is the cap working, not failing: without it + * the alert for the biggest possible outage is the one Discord throws away. The + * structured `alerts` array is never truncated, so nothing is lost for a machine + * consumer. Slack's `text` limit is far higher (40,000), so this cap is + * Discord's, applied to both to keep the two keys identical. */ export const MAX_MESSAGE_CHARS = 2000; @@ -260,7 +270,7 @@ export const WEBHOOK_TIMEOUT_MS = 3000; /** * POST every alert for one cycle to `url` as a single request — an RPC-wide - * outage that takes out both protocols should be one message, not two. + * outage that takes out every target should be one message, not one per target. * * Throws on a non-2xx or a network error; the caller logs and continues. * Alerting must never be able to fail a cycle. diff --git a/indexer/src/cycle.test.ts b/indexer/src/cycle.test.ts index 70ac5e0..6dac129 100644 --- a/indexer/src/cycle.test.ts +++ b/indexer/src/cycle.test.ts @@ -202,7 +202,8 @@ describe('runCycle — one failure never affects another protocol', () => { it('still scores later targets when the FIRST one throws', async () => { // Ordering matters: an early failure aborting the loop would silently stop - // every protocol behind it, and with two adapters that is half the registry. + // every target behind it — and with three registered, losing the first one + // costs two thirds of the registry. const { store, written } = fakeStore(); const summary = await runCycle([throwingTarget('blend'), okTarget('kinetic', 24)], store); diff --git a/indexer/src/retry.test.ts b/indexer/src/retry.test.ts index 555e647..04f65c2 100644 --- a/indexer/src/retry.test.ts +++ b/indexer/src/retry.test.ts @@ -171,10 +171,13 @@ describe('withRetry — retries never hide a real failure', () => { describe('withRetry — the deadline is the guarantee, the attempt count is the ceiling', () => { it('stops retrying when the remaining budget cannot cover backoff + an attempt', async () => { - // Blend's share of a 42s budget across two protocols is 21s. Each attempt - // here burns 15s (the attempt cap), so: attempt 1 → 6s left → 1s backoff - // leaves 5s, enough → attempt 2 (burns the remaining 5s) → 0s left, so the - // third attempt the policy allows is never started. + // A 21s deadline against a 15s attempt cap — the share the FIRST target got + // when the registry held two. (It holds three now, so the live first share is + // 14s and a timed-out first attempt buys no retry at all; that narrower case + // is the ceiling described in ROADMAP.md, not what this test pins.) The + // arithmetic under test is the same at any share: attempt 1 burns 15s → 6s + // left → 1s backoff leaves 5s, enough → attempt 2 burns the remaining 5s → + // 0s left, so the third attempt the policy allows is never started. const clock = fakeClock(); let calls = 0; await assert.rejects(