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
71 changes: 60 additions & 11 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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.

---

Expand Down Expand Up @@ -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. |
Expand Down
53 changes: 43 additions & 10 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<T>()` wrapper (see [`indexer/src/index.ts`](indexer/src/index.ts))
Expand Down
27 changes: 25 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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 <slug>` 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
Expand Down
Loading
Loading