diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e70a16c..0f5c3cf 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -195,6 +195,13 @@ as runtime requires, not webpack-bundled) and pins `outputFileTracingRoot` to th workspace-dep tracing is correct. On Vercel: Root Directory = `dashboard`, Build Command = `pnpm run build`. +The workspace packages themselves are **not** externalised — they are bundled into the serverless +functions, and therefore **minified**, which renames classes and functions. Nothing that is +persisted or published may be derived from a runtime identifier (`constructor.name`, `fn.name`): +those values are correct under `node --test` and `next dev` and wrong in the only environment that +writes the data. `ProtocolMetadata.adapterRef` is a hardcoded literal for exactly this reason — see +[`CONTRIBUTING.md`](CONTRIBUTING.md#adapterref-must-be-a-hardcoded-string-literal). + **Tests:** `pnpm test` at the root, fanning out to whichever packages define one, and **run by CI on every PR**. There is **no test framework dependency** — tests are `*.test.ts` files run by Node's built-in test runner (`node --test`) against native TypeScript stripping, which is why CI and diff --git a/CLAUDE.md b/CLAUDE.md index a5830a9..89fc12d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,6 +77,11 @@ These override any default behavior and are enforced in code and review: so a heterogeneous adapter list shares one typed run loop. `core/src/adapter.ts` carries `ADAPTER_INTERFACE_VERSION` — bump it for future breaking interface changes rather than rewriting every adapter at once. +- **Nothing persisted or published may come from a runtime identifier.** No `constructor.name`, + `fn.name`, or similar for a value that reaches the database or an API response — use a string + literal. The workspace packages are bundled and minified into the dashboard's serverless + functions, so identifiers are renamed there and _only_ there: such a value is correct in every + test and in local dev, and wrong in production. Full rationale in [`CONTRIBUTING.md`](CONTRIBUTING.md). - **Tests are `node --test`, zero dependencies.** `*.test.ts` files run on Node's built-in runner via native type stripping (`pnpm test`); test files import with an explicit `.ts` extension, app code doesn't. Test pure logic whose important cases live data can't reach — not for coverage. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 43cc8ca..d7a567e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,7 +41,8 @@ it stays internal to your adapter. ```ts export interface Adapter { - readonly metadata: ProtocolMetadata; // { id: slug, name, chain: 'stellar' } + // { id: slug, name, chain: 'stellar', adapterRef: 'YourAdapter' } + readonly metadata: ProtocolMetadata; fetchRawData(): Promise; // pull raw on-chain state (RPC + Horizon) @@ -54,6 +55,43 @@ export interface Adapter { Three separate methods (not one `run()`) so the indexer can inspect intermediate output and so `score()` can be unit-tested against fixed factor inputs without touching RPC. +### `adapterRef` must be a hardcoded string literal + +`metadata.adapterRef` names the class that produced the score (`'BlendAdapter'`). It is stored in +`protocols.adapter` and published on `GET /api/v1/protocol/:id`, where it is the provenance label a +reader follows to find your adapter in this repo. + +Write it out by hand: + +```ts +readonly metadata: ProtocolMetadata = { + id: 'yourprotocol', + name: 'YourProtocol', + chain: 'stellar', + adapterRef: 'YourProtocolAdapter', // literal — never this.constructor.name +}; +``` + +**Never derive it from the class name** — not `this.constructor.name`, not +`YourAdapter.name`, not anything else read off a runtime identifier. It looks like the obvious +DRY move and it is silently wrong in production: + +The indexer is not a standalone deployed process. It runs inside the dashboard's serverless cron +route (`dashboard/app/api/cron/run-indexer`), which imports `@stenion/indexer` and, through it, +your adapter class. Next bundles the workspace packages into that function and **minifies them**, +which renames classes — `class BlendAdapter` becomes `class w`. `constructor.name` then returns +`'w'`, and that is what gets written to the database. + +Nothing catches this before it ships. It is correct under `node --test`, correct under `next dev`, +and correct in every local run, because none of those minify. It is wrong only in the one +environment that actually writes the published data. This is not hypothetical: it is exactly how +every row in `protocols` came to have `adapter = 'w'` for both shipped protocols until it was +fixed. A string literal is a value the bundler has no license to rewrite; a class name is not. + +The same reasoning applies to anything else an adapter persists or publishes. If a value ends up in +the database or an API response, it must come from a literal or from on-chain data — never from a +JavaScript identifier. + ## The `*Safety` taxonomy — populate all five Every adapter must populate the same fixed five factors from `RiskFactorType` diff --git a/adapters/blend.ts b/adapters/blend.ts index c6f3e91..78ff4b5 100644 --- a/adapters/blend.ts +++ b/adapters/blend.ts @@ -546,6 +546,8 @@ export class BlendAdapter implements Adapter { id: 'blend', name: 'Blend', chain: 'stellar', + // Literal, not this.constructor.name — see ProtocolMetadata.adapterRef. + adapterRef: 'BlendAdapter', }; private readonly rpcUrl: string; diff --git a/adapters/kinetic.ts b/adapters/kinetic.ts index 9e87c27..09c66fc 100644 --- a/adapters/kinetic.ts +++ b/adapters/kinetic.ts @@ -538,6 +538,8 @@ export class KineticAdapter implements Adapter { id: 'kinetic', name: 'Kinetic', chain: 'stellar', + // Literal, not this.constructor.name — see ProtocolMetadata.adapterRef. + adapterRef: 'KineticAdapter', }; private readonly rpcUrl: string; diff --git a/core/src/types.ts b/core/src/types.ts index 89abfce..6645e82 100644 --- a/core/src/types.ts +++ b/core/src/types.ts @@ -25,6 +25,21 @@ export interface ProtocolMetadata { id: string; name: string; chain: Chain; + /** + * Which adapter produced this protocol's scores, e.g. "BlendAdapter". + * Persisted to `protocols.adapter` and published on GET /api/v1/protocol/:id + * as the provenance label a reader uses to find the adapter in the repo. + * + * MUST be a string literal. Never `this.constructor.name` or anything else + * derived from a runtime identifier: the workspace packages are bundled and + * minified into the dashboard's serverless functions, which renames the + * classes. Deriving it is how every row in `protocols` came to read `w` + * instead of `BlendAdapter`/`KineticAdapter` — correct in every test and in + * local dev, wrong in the only environment that actually writes the data. + * It sits here with id/name/chain because it is the same kind of value: + * a fixed string the build cannot rewrite. + */ + adapterRef: string; } /** diff --git a/dashboard/app/(home)/page.tsx b/dashboard/app/(home)/page.tsx index aa8952c..95714ec 100644 --- a/dashboard/app/(home)/page.tsx +++ b/dashboard/app/(home)/page.tsx @@ -9,7 +9,6 @@ import { Coins, Gauge, KeyRound, - Radio, ShieldOff, Timer, Waves, @@ -36,27 +35,22 @@ export default async function HomePage() { <> {/* ---------- Hero ---------- */}
+ {/* The dotted backdrop stays (white-alpha texture, no hue). The teal + radial glow that used to sit here is gone: an ambient wash of the + accent is the accent behaving as decoration, which is exactly what + this palette is built to avoid. */}
-
- - + Live risk intelligence for Stellar / Soroban DeFi

- Audits are a snapshot.{' '} - - Risk moves every block. - + Audits are a snapshot. Risk moves every block + .

@@ -72,14 +66,14 @@ export default async function HomePage() {
Explore the registry Read the methodology @@ -102,7 +96,7 @@ export default async function HomePage() {
All protocols @@ -112,7 +106,7 @@ export default async function HomePage() { {scored.length === 0 ? (
Live scores are warming up — the indexer hasn't published a run yet.{' '} - + Open the registry {' '} to check again. @@ -123,7 +117,7 @@ export default async function HomePage() {
@@ -180,8 +174,8 @@ export default async function HomePage() { ].map((c) => (
-
- +
+

{c.title}

{c.body}

@@ -208,7 +202,7 @@ export default async function HomePage() {
Full methodology @@ -225,7 +219,7 @@ export default async function HomePage() { ].map((f) => (
- +
{f.name}
{f.hint}
@@ -267,7 +261,7 @@ export default async function HomePage() { ].map((c) => (
- +

{c.title}

{c.body}

@@ -276,7 +270,7 @@ export default async function HomePage() { -
+

See where the protocols stand @@ -288,7 +282,7 @@ export default async function HomePage() {
Open the registry @@ -296,7 +290,7 @@ export default async function HomePage() { href={GITHUB_URL} target="_blank" rel="noreferrer" - className="inline-flex items-center gap-2 rounded-lg border border-line bg-surface/60 px-5 py-2.5 text-sm font-medium text-ink hover:border-accent/40" + className="inline-flex items-center gap-2 rounded-lg border border-line-strong px-5 py-2.5 text-sm font-medium text-ink transition-colors hover:border-accent" > Contribute an adapter diff --git a/dashboard/app/about/page.tsx b/dashboard/app/about/page.tsx index 12e7b66..00f019c 100644 --- a/dashboard/app/about/page.tsx +++ b/dashboard/app/about/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from 'next'; import Link from 'next/link'; -import { ArrowRight, Bot, GitPullRequest, Radar, ScanEye, ShieldOff } from 'lucide-react'; +import { ArrowRight, Bot, GitPullRequest, ScanEye, ShieldOff } from 'lucide-react'; import { GITHUB_URL } from '../lib/site'; import { Reveal, RevealGroup, RevealItem } from '../../components/reveal'; @@ -14,8 +14,7 @@ export default function AboutPage() { return (
- - + About Stenion

@@ -35,7 +34,7 @@ export default function AboutPage() {

  • - + Static audits confirm the code was sound on a particular date. They say nothing about the pool becoming 95% concentrated in one @@ -43,7 +42,7 @@ export default function AboutPage() {
  • - + TVL trackers (DeFiLlama already does this well for Stellar) measure how much is at stake — not how risky the position is. Size is not @@ -71,7 +70,7 @@ export default function AboutPage() { Adapters read data directly from Soroban RPC and Horizon — official, trustless Stellar infrastructure — so nothing is self-reported by the protocols being scored. The exact formulas are public in the{' '} - + methodology , and they're meant to be challenged. @@ -111,7 +110,7 @@ export default function AboutPage() { ].map((c) => (
    - +

    {c.title}

    {c.body}

    @@ -135,7 +134,7 @@ export default function AboutPage() {

-
+

See it working

@@ -145,7 +144,7 @@ export default function AboutPage() {

Open the registry @@ -153,7 +152,7 @@ export default function AboutPage() { href={GITHUB_URL} target="_blank" rel="noreferrer" - className="inline-flex items-center gap-2 rounded-lg border border-line bg-surface/60 px-5 py-2.5 text-sm font-medium text-ink hover:border-accent/40" + className="inline-flex items-center gap-2 rounded-lg border border-line-strong px-5 py-2.5 text-sm font-medium text-ink transition-colors hover:border-accent" > View on GitHub diff --git a/dashboard/app/globals.css b/dashboard/app/globals.css index 5e16681..e04c881 100644 --- a/dashboard/app/globals.css +++ b/dashboard/app/globals.css @@ -3,28 +3,58 @@ /* Stenion design tokens — dark-mode ONLY (no toggle, no light theme yet). - Deep navy surfaces + off-white ink, with a deliberately non-default accent - pair: cool aqua (`accent`) against a soft periwinkle (`accent-2`) for gradient - wordmarks/headings. The score scale (safe/warn/danger) is a separate semantic - ramp — it encodes data, not brand, so it must never be reused as decoration. + + ONE accent: violet, on a violet-cast dark neutral. The accent is a *marker*, + not a wash — it goes on the logo mark, links, active nav state, primary + buttons, and (at most) a couple of emphasised words in the hero. Everything + else is neutral ink or a hairline. There is deliberately no second accent and + no gradient: a gradient across two hues is decoration standing in for a colour + decision, and on the headline it cost readability for ornament. + + --- The two violets are NOT a redundant pair. Do not collapse them. --- + `accent` (#7c6bf5) is for FILLS — button backgrounds, the logo tile, the + active-nav underline, focus rings. Against `bg` it is 4.82:1, which is fine + behind dark text (the primary button uses `text-bg`, 4.82:1 — white on it + would be 3.98:1 and fail) but leaves no headroom when used AS text: on + `surface` it measures 4.55:1, i.e. AA by 0.05. + `accent-ink` (#9b8cff) is therefore the violet for TEXT — links, prose links, + emphasised headline words — at 6.93:1 on `bg` / 6.54:1 on `surface`. + Same hue, so this does not reintroduce a second accent. Merging them back + into one value silently drops every inline link to the 4.55:1 floor. + + Neutrals: `line` is a decorative hairline (1.25:1 on `bg`) and is fine as + such. `line-strong` exists because a control whose ONLY boundary is a + hairline is an invisible button — WCAG 1.4.11 wants 3:1 for control + boundaries, so interactive borders (secondary buttons) use `line-strong` + (3.32:1) while decorative dividers stay on `line`. + + The score scale (safe/warn/danger) is a separate semantic ramp — it encodes + data, not brand, so it must never be reused as decoration, and no violet + state is ever added to it. It is kept in a desaturated red/amber/green range + deliberately far from the accent in hue: `danger` is a true red (hue 4°), not + the pink-magenta (345°) it used to be, because that sat in the same + perceptual family as violet and read as brand colour on a risk numeral. + Every band clears AA on `bg`, `surface`, AND `surface-2`; `danger` is the + floor at 5.89 / 5.55 / 5.13. */ @theme { - --color-bg: #06090f; - --color-surface: #0b111b; - --color-surface-2: #111926; - --color-line: #1b2431; - --color-line-soft: #131b28; + --color-bg: #0f0e17; /* near-black with a violet cast, never pure black */ + --color-surface: #1a1826; + --color-surface-2: #1e1c2b; + --color-line: #252336; /* decorative hairlines only */ + --color-line-soft: #201e30; + --color-line-strong: #666090; /* interactive borders only — see above */ - --color-ink: #e9eef7; - --color-muted: #8b95a9; - --color-faint: #5b6577; + --color-ink: #edebf2; /* off-white, never pure white */ + --color-muted: #9a97ad; + --color-faint: #82809a; - --color-accent: #3fd0c9; /* custom aqua — not tailwind teal */ - --color-accent-2: #8b8ff5; /* periwinkle */ + --color-accent: #7c6bf5; /* fills only */ + --color-accent-ink: #9b8cff; /* accent-coloured TEXT only */ - --color-safe: #35d99a; - --color-warn: #f4b53f; - --color-danger: #f26a8d; + --color-safe: #56c08e; + --color-warn: #dfae55; + --color-danger: #e06b62; --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, sans-serif; --font-mono: var(--font-geist-mono), ui-monospace, 'SFMono-Regular', monospace; @@ -51,6 +81,16 @@ body { font-feature-settings: 'tnum' 1; } +/* A *score* — a measured value on the 0–100 safety scale, as opposed to a rank + or a timestamp (those stay on plain `.tnum` in the UI face). Mono is the + signal: this figure was computed, not written. Applies to the hero ring, the + registry column, factor values, and the chart's axis/tooltip readouts. */ +.score-num { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + font-feature-settings: 'tnum' 1; +} + /* Fine top-lit surface used on cards — subtle depth without a heavy shadow. */ .surface-lit { background: @@ -82,12 +122,12 @@ body { } /* Prose (methodology page) tuned to the dark palette instead of prose-invert's - default grays — headings in ink, links in accent, code on a real surface. */ + default grays — headings in ink, links in accent-ink, code on a real surface. */ .prose-stenion { --tw-prose-body: var(--color-muted); --tw-prose-headings: var(--color-ink); --tw-prose-bold: var(--color-ink); - --tw-prose-links: var(--color-accent); + --tw-prose-links: var(--color-accent-ink); --tw-prose-quotes: var(--color-ink); --tw-prose-quote-borders: var(--color-line); --tw-prose-code: var(--color-ink); diff --git a/dashboard/app/methodology/page.tsx b/dashboard/app/methodology/page.tsx index bb51495..b8a79c2 100644 --- a/dashboard/app/methodology/page.tsx +++ b/dashboard/app/methodology/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from 'next'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; -import { BookOpenText, ExternalLink, FileWarning } from 'lucide-react'; +import { ExternalLink, FileWarning } from 'lucide-react'; import { MarkdownDoc } from '../../components/markdown-doc'; import { METHODOLOGY_SOURCE_URL } from '../lib/site'; import { Reveal } from '../../components/reveal'; @@ -31,8 +31,7 @@ export default async function MethodologyPage() { return (
- - + The rulebook

@@ -47,7 +46,7 @@ export default async function MethodologyPage() { href={METHODOLOGY_SOURCE_URL} target="_blank" rel="noreferrer" - className="mt-5 inline-flex items-center gap-1.5 rounded-lg border border-line bg-surface/60 px-4 py-2 text-sm text-ink transition-colors hover:border-accent/40" + className="mt-5 inline-flex items-center gap-1.5 rounded-lg border border-line-strong px-4 py-2 text-sm text-ink transition-colors hover:border-accent" > View source on GitHub @@ -70,7 +69,7 @@ export default async function MethodologyPage() { href={METHODOLOGY_SOURCE_URL} target="_blank" rel="noreferrer" - className="mt-5 inline-flex items-center gap-1.5 text-sm text-accent hover:underline" + className="mt-5 inline-flex items-center gap-1.5 text-sm text-accent-ink hover:underline" > Open METHODOLOGY.md diff --git a/dashboard/app/not-found.tsx b/dashboard/app/not-found.tsx index cb8f61b..4b43f23 100644 --- a/dashboard/app/not-found.tsx +++ b/dashboard/app/not-found.tsx @@ -13,21 +13,21 @@ export const metadata: Metadata = { export default function NotFound() { return (
-
- +
+

404

That page — or protocol — isn't part of the registry.

Back home View the registry diff --git a/dashboard/app/protocol/[id]/page.tsx b/dashboard/app/protocol/[id]/page.tsx index 18dc3fd..a7094f7 100644 --- a/dashboard/app/protocol/[id]/page.tsx +++ b/dashboard/app/protocol/[id]/page.tsx @@ -60,7 +60,7 @@ export default async function ProtocolDetailPage({ params }: { params: Promise<{
- +

Factor breakdown

@@ -145,7 +145,7 @@ function Findings({ notes, name }: { notes: ProtocolNote[]; name: string }) {

- +

Findings

@@ -227,7 +227,7 @@ function ScoreHistory({ history }: { history: HistoryEntry[] }) {

- +

Score history

@@ -278,7 +278,7 @@ function History({ history }: { history: HistoryEntry[] }) { {formatTimestamp(h.runAt)} {h.status === 'ok' ? ( - scored {h.safetyScore} + scored {h.safetyScore} ) : ( failed — {h.error} diff --git a/dashboard/app/registry/page.tsx b/dashboard/app/registry/page.tsx index 44adc84..82be80f 100644 --- a/dashboard/app/registry/page.tsx +++ b/dashboard/app/registry/page.tsx @@ -26,8 +26,7 @@ export default async function RegistryPage() { return (

- - + Ranked by safety score · payment-blind

@@ -84,7 +83,7 @@ function ProtocolRow({ entry, rank }: { entry: LeaderboardEntry; rank: number }) {String(rank).padStart(2, '0')} {entry.name} - +

@@ -94,7 +93,7 @@ function ProtocolRow({ entry, rank }: { entry: LeaderboardEntry; rank: number })
- + {entry.safetyScore ?? '—'} / 100 diff --git a/dashboard/components/factor-bar.tsx b/dashboard/components/factor-bar.tsx index 8ce6e3f..52ed340 100644 --- a/dashboard/components/factor-bar.tsx +++ b/dashboard/components/factor-bar.tsx @@ -54,7 +54,9 @@ export function FactorCard({ weight {Math.round(factor.weight * 100)}% - {factor.value} + + {factor.value} +
@@ -140,7 +142,7 @@ function ComponentRow({ component: c }: { component: RiskFactorComponent }) { ) : ( {c.value} diff --git a/dashboard/components/nav.tsx b/dashboard/components/nav.tsx index 7d86647..73dd7e2 100644 --- a/dashboard/components/nav.tsx +++ b/dashboard/components/nav.tsx @@ -56,7 +56,7 @@ export function Nav() {
- + @@ -77,7 +77,7 @@ export function Nav() { > {link.label} {isActive(link.href) && ( - + )} ))} diff --git a/dashboard/components/score-history-chart.tsx b/dashboard/components/score-history-chart.tsx index 737465b..9bec42e 100644 --- a/dashboard/components/score-history-chart.tsx +++ b/dashboard/components/score-history-chart.tsx @@ -136,7 +136,7 @@ export function ScoreHistoryChart({ history }: { history: HistoryEntry[] }) { } }} onBlur={() => setActive(null)} - className="relative rounded-xl border border-line surface-lit p-2 outline-none focus-visible:border-accent/60 focus-visible:ring-1 focus-visible:ring-accent/40" + className="relative rounded-xl border border-line surface-lit p-2 outline-none focus-visible:border-accent focus-visible:ring-1 focus-visible:ring-accent/60" > @@ -251,8 +251,11 @@ export function ScoreHistoryChart({ history }: { history: HistoryEntry[] }) { /> ))} - {/* Methodology break: periwinkle, NOT a colour from the score ramp — - this annotates the rulebook, it is not a safety reading. */} + {/* Methodology break: the brand accent, and deliberately the ONLY + accent-coloured thing in this chart. It annotates the rulebook — + it is not a safety reading, so it must not come from the score + ramp. The score line itself is neutral for the same reason in + reverse: the accent marks, it never encodes a data value. */} {breaks .filter((b) => b.kinds.includes('methodology')) .map((b) => ( @@ -262,7 +265,7 @@ export function ScoreHistoryChart({ history }: { history: HistoryEntry[] }) { y1={Y0 - 4} x2={scaleX(b.at)} y2={Y1} - stroke="var(--color-accent-2)" + stroke="var(--color-accent)" strokeWidth={1.5} strokeDasharray="4 4" opacity={0.85} @@ -273,7 +276,7 @@ export function ScoreHistoryChart({ history }: { history: HistoryEntry[] }) { textAnchor="middle" fontSize={10} fontWeight={600} - fill="var(--color-accent-2)" + fill="var(--color-accent-ink)" > v{b.fromVersion}→v{b.toVersion} @@ -305,7 +308,7 @@ export function ScoreHistoryChart({ history }: { history: HistoryEntry[] }) { cx={scaleX(seg[0].t)} cy={scaleY(seg[0].score)} r={3.5} - fill="var(--color-accent)" + fill="var(--color-muted)" /> ) : ( `${j === 0 ? 'M' : 'L'}${scaleX(p.t)},${scaleY(p.score)}`) .join(' ')} fill="none" - stroke="var(--color-accent)" + stroke="var(--color-muted)" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" @@ -325,7 +328,9 @@ export function ScoreHistoryChart({ history }: { history: HistoryEntry[] }) { ), )} - {/* Latest point, in its band colour — the same reading as the hero ring. */} + {/* Latest point, in its band colour — the same reading as the hero + ring, and (with the band tints behind it) what actually carries + the score reading now that the line itself is neutral. */} {latest && ( @@ -445,7 +450,10 @@ function Tooltip({ opacity: pinned ? 0.85 : 1, }} > - + {point.score} {bandLabel(scoreBand(point.score))} @@ -469,7 +477,7 @@ function Legend({ return (
- safety score + safety score {hasFailures && ( @@ -483,10 +491,7 @@ function Legend({ )} {hasMethodologyBreak && ( - {' '} + {' '} methodology change — scores either side aren’t comparable )} diff --git a/dashboard/components/score-ring.tsx b/dashboard/components/score-ring.tsx index 08eae38..5e2581e 100644 --- a/dashboard/components/score-ring.tsx +++ b/dashboard/components/score-ring.tsx @@ -84,7 +84,7 @@ export function ScoreRing({
{score === null ? '—' : Math.round(display)} diff --git a/db/src/store.integration.test.ts b/db/src/store.integration.test.ts index b916de8..72718ba 100644 --- a/db/src/store.integration.test.ts +++ b/db/src/store.integration.test.ts @@ -88,7 +88,12 @@ describe('Store SQL (integration)', { skip }, () => { it('surfaces the last good score and flags it stale when the newest run failed', async () => { // The two LATERAL joins, which is the whole reason this needs real SQL. const p = id('stale'); - await store.upsertProtocol({ id: p, name: 'Stale', chain: 'stellar' }, 'FakeAdapter'); + await store.upsertProtocol({ + id: p, + name: 'Stale', + chain: 'stellar', + adapterRef: 'FakeAdapter', + }); await insertRun(p, { status: 'ok', score: 53, runAt: '2026-08-16T10:00:00Z' }); await insertRun(p, { status: 'failed', error: 'RPC down', runAt: '2026-08-16T10:05:00Z' }); @@ -106,7 +111,12 @@ describe('Store SQL (integration)', { skip }, () => { it('returns a protocol with no runs, rather than hiding it', async () => { const p = id('unscored'); - await store.upsertProtocol({ id: p, name: 'Unscored', chain: 'stellar' }, 'FakeAdapter'); + await store.upsertProtocol({ + id: p, + name: 'Unscored', + chain: 'stellar', + adapterRef: 'FakeAdapter', + }); const detail = await store.getProtocolDetail(p); assert.ok(detail, 'a registered protocol with no runs must still resolve'); @@ -117,7 +127,12 @@ describe('Store SQL (integration)', { skip }, () => { it('orders history newest-first', async () => { const p = id('history'); - await store.upsertProtocol({ id: p, name: 'History', chain: 'stellar' }, 'FakeAdapter'); + await store.upsertProtocol({ + id: p, + name: 'History', + chain: 'stellar', + adapterRef: 'FakeAdapter', + }); await insertRun(p, { status: 'ok', score: 40, runAt: '2026-08-16T10:00:00Z' }); await insertRun(p, { status: 'ok', score: 60, runAt: '2026-08-16T10:10:00Z' }); await insertRun(p, { status: 'failed', runAt: '2026-08-16T10:05:00Z' }); @@ -141,7 +156,7 @@ describe('Store SQL (integration)', { skip }, () => { [high, 'High'], [none, 'None'], ]) { - await store.upsertProtocol({ id: p, name, chain: 'stellar' }, 'FakeAdapter'); + await store.upsertProtocol({ id: p, name, chain: 'stellar', adapterRef: 'FakeAdapter' }); } await insertRun(low, { status: 'ok', score: 10, runAt: '2026-08-16T10:00:00Z' }); await insertRun(high, { status: 'ok', score: 90, runAt: '2026-08-16T10:00:00Z' }); @@ -163,7 +178,12 @@ describe('Store SQL (integration)', { skip }, () => { // RunRecord union: a failed row carrying a score should be impossible even // if application code regressed. const p = id('check'); - await store.upsertProtocol({ id: p, name: 'Check', chain: 'stellar' }, 'FakeAdapter'); + await store.upsertProtocol({ + id: p, + name: 'Check', + chain: 'stellar', + adapterRef: 'FakeAdapter', + }); await assert.rejects( () => pool.query( @@ -178,7 +198,12 @@ describe('Store SQL (integration)', { skip }, () => { it('round-trips a run written through insertRunRecord', async () => { const p = id('roundtrip'); - await store.upsertProtocol({ id: p, name: 'Round', chain: 'stellar' }, 'FakeAdapter'); + await store.upsertProtocol({ + id: p, + name: 'Round', + chain: 'stellar', + adapterRef: 'FakeAdapter', + }); await store.insertRunRecord({ protocolId: p, status: 'ok', @@ -206,8 +231,8 @@ describe('Store SQL (integration)', { skip }, () => { it('upsertProtocol is idempotent and refreshes metadata', async () => { const p = id('upsert'); - await store.upsertProtocol({ id: p, name: 'First', chain: 'stellar' }, 'AdapterA'); - await store.upsertProtocol({ id: p, name: 'Second', chain: 'stellar' }, 'AdapterB'); + await store.upsertProtocol({ id: p, name: 'First', chain: 'stellar', adapterRef: 'AdapterA' }); + await store.upsertProtocol({ id: p, name: 'Second', chain: 'stellar', adapterRef: 'AdapterB' }); const detail = await store.getProtocolDetail(p); assert.equal(detail!.name, 'Second'); diff --git a/db/src/store.ts b/db/src/store.ts index c7e6113..0b1dd61 100644 --- a/db/src/store.ts +++ b/db/src/store.ts @@ -97,8 +97,14 @@ export interface ProtocolDetail { export const DETAIL_HISTORY_LIMIT = 50; export interface Store { - /** Insert-or-update the protocol row from adapter metadata. Idempotent. */ - upsertProtocol(metadata: ProtocolMetadata, adapterRef: string): Promise; + /** + * Insert-or-update the protocol row from adapter metadata. Idempotent. + * + * The adapter reference comes from `metadata.adapterRef` rather than a + * separate argument so there is exactly one source for it — see + * ProtocolMetadata.adapterRef for why it must be a literal. + */ + upsertProtocol(metadata: ProtocolMetadata): Promise; /** Append one run outcome to risk_scores. */ insertRunRecord(record: RunRecord): Promise; /** Every protocol with its latest-ok score, ranked by score desc (nulls last). */ @@ -231,7 +237,7 @@ export function toLeaderboardEntry(row: LeaderboardRow): LeaderboardEntry { export function createStore(pool: Pool): Store { return { - async upsertProtocol(metadata, adapterRef) { + async upsertProtocol(metadata) { await pool.query( `INSERT INTO protocols (id, name, chain, adapter) VALUES ($1, $2, $3, $4) @@ -240,7 +246,7 @@ export function createStore(pool: Pool): Store { chain = EXCLUDED.chain, adapter = EXCLUDED.adapter, updated_at = now()`, - [metadata.id, metadata.name, metadata.chain, adapterRef], + [metadata.id, metadata.name, metadata.chain, metadata.adapterRef], ); }, diff --git a/indexer/src/cycle.test.ts b/indexer/src/cycle.test.ts index 1df0a4d..f6293e1 100644 --- a/indexer/src/cycle.test.ts +++ b/indexer/src/cycle.test.ts @@ -55,8 +55,7 @@ const COMPUTED_AT = new Date('2026-08-16T10:00:00.000Z'); /** A target that succeeds with a fixed score. */ function okTarget(id: string, safetyScore = 53): IndexTarget { return { - metadata: { id, name: id, chain: 'stellar' }, - adapterRef: 'FakeAdapter', + metadata: { id, name: id, chain: 'stellar', adapterRef: 'FakeAdapter' }, run: async () => ({ safetyScore, factors: FACTORS, computedAt: COMPUTED_AT }), }; } @@ -64,8 +63,7 @@ function okTarget(id: string, safetyScore = 53): IndexTarget { /** A target whose adapter throws, the way a real one does on RPC failure. */ function throwingTarget(id: string, thrown: unknown = new Error('Soroban RPC unreachable')) { return { - metadata: { id, name: id, chain: 'stellar' as const }, - adapterRef: 'FakeAdapter', + metadata: { id, name: id, chain: 'stellar' as const, adapterRef: 'FakeAdapter' }, run: async () => { throw thrown; }, @@ -249,7 +247,7 @@ describe('toTarget — the adapter pipeline wrapper', () => { /** A minimal adapter with its own raw shape, to prove TRawData stays internal. */ function fakeAdapter(id: string, calls: string[]): Adapter<{ n: number }> { return { - metadata: { id, name: id, chain: 'stellar' } as ProtocolMetadata, + metadata: { id, name: id, chain: 'stellar', adapterRef: 'FakeAdapter' } as ProtocolMetadata, async fetchRawData() { calls.push('fetchRawData'); return { n: 1 }; @@ -275,10 +273,25 @@ describe('toTarget — the adapter pipeline wrapper', () => { assert.equal(out.computedAt, COMPUTED_AT); }); - it("takes adapterRef from the adapter's class name", async () => { + it('carries adapterRef through from metadata, independent of the class name', async () => { // This is what lands in the protocols table's `adapter` column. - class BlendAdapter implements Adapter<{ n: number }> { - metadata = { id: 'blend', name: 'Blend', chain: 'stellar' } as ProtocolMetadata; + // + // The class here is deliberately named something OTHER than its adapterRef. + // The previous version of this test read the value off `constructor.name` + // and asserted it equalled the class name — which passes under `node --test` + // and in dev, and passed the whole time production was writing `w` to every + // row, because minification renames classes in the bundled serverless build + // and nothing unminified ever exercised that path. Asserting on a value the + // build is free to rewrite is what made the test worthless. Naming the class + // `MinifiedToSomethingElse` means this can only pass if the literal on + // `metadata` is the thing being persisted. + class MinifiedToSomethingElse implements Adapter<{ n: number }> { + metadata = { + id: 'blend', + name: 'Blend', + chain: 'stellar', + adapterRef: 'BlendAdapter', + } as ProtocolMetadata; async fetchRawData() { return { n: 1 }; } @@ -289,7 +302,10 @@ describe('toTarget — the adapter pipeline wrapper', () => { return { score: 1, factors, computedAt: COMPUTED_AT }; } } - assert.equal(toTarget(new BlendAdapter()).adapterRef, 'BlendAdapter'); + + const target = toTarget(new MinifiedToSomethingElse()); + assert.equal(target.metadata.adapterRef, 'BlendAdapter'); + assert.notEqual(target.metadata.adapterRef, MinifiedToSomethingElse.name); }); it('lets a failure anywhere in the pipeline surface to runCycle', async () => { diff --git a/indexer/src/cycle.ts b/indexer/src/cycle.ts index d658559..fce17ef 100644 --- a/indexer/src/cycle.ts +++ b/indexer/src/cycle.ts @@ -21,19 +21,22 @@ import type { RunRecord, Store } from '@stenion/db'; * An adapter bound to its run pipeline. Wrapping each adapter this way keeps * its TRawData type internal (an `Adapter` is not assignable to * `Adapter` because computeRiskFactors is contravariant in TRawData), - * so a heterogeneous list of adapters can share one run loop. `adapterRef` is - * the concrete adapter class name, persisted as the protocol's adapter column. + * so a heterogeneous list of adapters can share one run loop. + * + * The adapter reference persisted to `protocols.adapter` rides on `metadata` + * (as `metadata.adapterRef`) and is deliberately NOT duplicated here. It used + * to be read off `adapter.constructor.name`, which is correct in dev and in + * every test but mangled by minification in the bundled serverless build — + * see ProtocolMetadata.adapterRef. One source, and it's a literal. */ export interface IndexTarget { metadata: ProtocolMetadata; - adapterRef: string; run(): Promise<{ safetyScore: number; factors: RiskFactorMap; computedAt: Date }>; } export function toTarget(adapter: Adapter): IndexTarget { return { metadata: adapter.metadata, - adapterRef: adapter.constructor.name, run: async () => { const raw = await adapter.fetchRawData(); const factors = await adapter.computeRiskFactors(raw); diff --git a/indexer/src/index.ts b/indexer/src/index.ts index 97e6a16..b582bd1 100644 --- a/indexer/src/index.ts +++ b/indexer/src/index.ts @@ -57,7 +57,7 @@ async function prepare(config: IndexerConfig): Promise<{ targets: IndexTarget[]; // Protocol metadata is static, so upsert once here; the run loop only appends // scores. for (const target of targets) { - await store.upsertProtocol(target.metadata, target.adapterRef); + await store.upsertProtocol(target.metadata); } return { targets, store }; }