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

Filter by extension

Filter by extension

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

---
Expand Down Expand Up @@ -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": [
Expand All @@ -105,25 +97,45 @@ 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"
},
{
"id": "kinetic",
"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. |
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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": {
Expand All @@ -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"
}
]
}
Expand Down
17 changes: 12 additions & 5 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 23 additions & 11 deletions adapters/blend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
40 changes: 32 additions & 8 deletions dashboard/app/(home)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,14 @@ export default async function HomePage() {
) : (
<RevealGroup className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{scored.map((p, i) => (
<RevealItem key={p.id}>
// `h-full` on both the cell and the card. The grid stretches the
// cell to the tallest in the row, but the <Link> 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.
<RevealItem key={p.id} className="h-full">
<Link
href={`/protocol/${p.id}`}
// `border-sheen` (globals.css) is scoped to these cards only
Expand All @@ -125,24 +132,41 @@ export default async function HomePage() {
// instead of firing them in unison; it starts each card
// mid-animation rather than waiting, so nothing is idle.
style={{ '--sheen-delay': `${i * -3}s` } as React.CSSProperties}
className="border-sheen group flex items-center gap-5 rounded-xl border border-line surface-lit p-5 transition-all hover:-translate-y-0.5 hover:border-accent"
className="border-sheen group flex h-full items-center gap-5 rounded-xl border border-line surface-lit p-5 transition-all hover:-translate-y-0.5 hover:border-accent"
>
<ScoreRing score={p.safetyScore} size={104} stroke={8} label={null} />
{/* 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. */}
<ScoreRing
score={p.safetyScore}
size={104}
stroke={8}
label={null}
className="shrink-0"
/>
<div className="min-w-0">
<div className="flex items-center gap-2">
<ProtocolLogo name={p.name} logo={p.logo} size={24} />
<div className="truncate font-display text-lg font-semibold text-ink">
{p.name}
</div>
</div>
<div className="mt-0.5 text-xs uppercase tracking-wider text-faint">
{p.chain}
</div>
{/* 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. */}
<DeploymentBadge deployedOn={p.deployedOn} className="mt-1.5" />
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-xs uppercase tracking-wider text-faint">{p.chain}</span>
<DeploymentBadge deployedOn={p.deployedOn} />
</div>
<div className="mt-3">
<StatusPill
lastRunStatus={p.lastRunStatus}
Expand Down
8 changes: 5 additions & 3 deletions dashboard/app/api/cron/run-indexer/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ import { timingSafeEqual } from 'node:crypto';
import { loadEnv } from '@stenion/db';
import { runIndexerCycle } from '@stenion/indexer';

// pg + Soroban/Horizon I/O need the Node.js runtime. A full cycle (2 protocols)
// makes several on-chain sim calls, so allow up to 60s (Vercel Hobby max) rather
// than the 10s default.
// pg + Soroban/Horizon I/O need the Node.js runtime. A full cycle (3 targets —
// two Blend pools and Kinetic) makes several on-chain sim calls each, so allow up
// to 60s (Vercel Hobby max) rather than the 10s default. The cycle's own
// wall-clock budget (STENION_CYCLE_BUDGET_MS, 42s) is what keeps it inside this
// ceiling; see ARCHITECTURE.md on why a budget rather than a fixed schedule.
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 60;
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 6 additions & 2 deletions db/src/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,9 +473,13 @@ describe('the deployment label on both public responses', () => {
// 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',
Expand Down
Loading
Loading