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
8 changes: 7 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,13 @@ CRON_SECRET=replace-with-a-long-random-string
# can leave one protocol scored and another neither scored nor recorded as failed.
# Must leave room for cold start, pool connect, upserts, streak queries and the alert
# POST. Raise it only against observed cycle durations, never by arithmetic alone.
#STENION_CYCLE_BUDGET_MS=42000
# 50s, raised from 42s in #104 against three curl'ed deployed cycles (totalMs 15.5-16.8s,
# HTTP wall 17.3-17.7s, so ~1.0-1.7s of overhead beyond the cycle). That makes the
# five-target registry feasible — 5 x 10s attempts = 50s <= 50s — while the worst-case
# function wall stays ~52s against the 60s ceiling. A SIXTH target would need 60s, which
# IS the ceiling, so the budget cannot buy one: that needs a lower attempt timeout or
# higher concurrency, each with its own measurement.
#STENION_CYCLE_BUDGET_MS=50000

# How many protocols are scored at once. This IS the peak simultaneous load on the
# shared public RPC: both adapters are strictly sequential internally, so one target in
Expand Down
30 changes: 19 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,18 +186,26 @@ These override any default behavior and are enforced in code and review:
runs adapters through the `toTarget<T>()` wrapper (see [`indexer/src/index.ts`](indexer/src/index.ts))
so a heterogeneous adapter list shares one typed run loop. `core/src/adapter.ts` carries
`ADAPTER_INTERFACE_VERSION` — bump it for breaking interface changes rather than rewriting
every adapter at once. It is at **3**: v2 added the required `operationalState(raw)` method
every adapter at once. It is at **4**: v2 added the required `operationalState(raw)` method
(#15), v3 the required `metadata.category` field and the `TCategory` parameter that scopes
`operationalState`'s vocabulary to it (#76). Both required rather than optional, deliberately —
an optional member is one every future adapter can skip, which is the retrofit debt the constant
exists to make visible. **`Adapter`'s third parameter, `TFactors`, did NOT bump it** (#103):
it is defaulted to lending's `RiskFactorMap`, so no implementor has to react, and the bar the
constant states is a change adapters must react to. A non-lending adapter names all three; a
lending one names two and is unchanged. **That parameter is not yet reviewed** — it was resolved
inside #103 to make the first `dex` adapter compile at all, and the decision record, the
alternatives, and the `@stenion/db` typing gap it leaves for #104 are in
[`architecture/monorepo-layout.md`](architecture/monorepo-layout.md). Read it before building on
it; do not treat it as settled convention.
`operationalState`'s vocabulary to it (#76), v4 made the factor map a function of that same
parameter (#104). All required rather than optional, deliberately — an optional member is one
every future adapter can skip, which is the retrofit debt the constant exists to make visible.
- **An adapter declares its category; it does NOT declare its factor map.**
`computeRiskFactors`/`score` speak `FactorMapFor<TCategory>`, derived from `CATEGORY_FACTORS`, so
an adapter that declares `'dex'` owes exactly the factors `methodology/dex.md` publishes and
cannot return lending's five or a key no rulebook has. This replaces the defaulted `TFactors`
parameter #103 added under deadline and flagged as unreviewed: the review (#104) found the
parameter was never tied to `TCategory`, so `Adapter<Raw, 'dex', RiskFactorMap>` compiled. It is
now a settled decision with the alternatives and the two corrected #103 claims recorded in
[`architecture/monorepo-layout.md`](architecture/monorepo-layout.md), and guarded by
`@ts-expect-error` probes in `core/src/weights.test.ts`.
- **Precision at the adapter boundary, one open `FactorMap` from the indexer onward.** That split is
the answer #103 left open. `IndexTarget.run`, `RunRecord`, `HistoryEntry` and `ProtocolDetail` all
carry `FactorMap` — storage and transport enforce no rulebook and must not claim a key set they
are not the source of. `getProtocolDetail`'s read-side cast asserts only the non-null the
`risk_scores_shape` CHECK guarantees; it used to assert lending's five keys, which was a lie about
any `dex` row.
- **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
Expand Down
36 changes: 21 additions & 15 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,24 +35,24 @@ An adapter that breaks any of these will not be merged, regardless of how good t

## The `Adapter` interface

Every adapter implements `Adapter<TRawData, TCategory, TFactors>` from `@stenion/core`
Every adapter implements `Adapter<TRawData, TCategory>` from `@stenion/core`
([`core/src/adapter.ts`](core/src/adapter.ts)). `TRawData` is your protocol's own raw shape — it has
nothing in common with another protocol's, so it stays internal to your adapter.

```ts
export interface Adapter<
TRawData = unknown,
TCategory extends ProtocolCategory = ProtocolCategory,
TFactors extends FactorMap = RiskFactorMap,
> {
// identity: { id: slug, name, chain, category, adapterRef, logo?, contractId?, links? }
readonly metadata: ProtocolMetadata<TCategory>;

fetchRawData(): Promise<TRawData>; // pull raw on-chain state (RPC + Horizon)

computeRiskFactors(rawData: TRawData): Promise<TFactors>; // → your category's *Safety factors
// → exactly the *Safety factors YOUR category's rulebook declares
computeRiskFactors(rawData: TRawData): Promise<FactorMapFor<TCategory>>;

score(factors: TFactors): ScoreResult<TFactors>; // → weighted safetyScore
score(factors: FactorMapFor<TCategory>): ScoreResult<FactorMapFor<TCategory>>; // → safetyScore

operationalState(rawData: TRawData): OperationalState<TCategory>; // → what the market refuses
}
Expand All @@ -61,21 +61,27 @@ export interface Adapter<
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.

**Both trailing parameters are defaulted, so a lending adapter names only the first two.**
`BlendAdapter implements Adapter<BlendRawData, 'lending'>` and picks up lending's five-key
`RiskFactorMap` for free. An adapter in any other category names all three —
`AquariusAdapter implements Adapter<AquariusRawData, 'dex', DexFactorMap>` — because `RiskFactorMap`
_is_ lending's map (`Record<RiskFactorType, …>`, its five keys fixed) and `dex` scores two different
ones. Your category's map is a one-line derivation beside its weight table in
[`core/src/weights.ts`](core/src/weights.ts), never a hand-written key list:
**Name your category and the factor map follows — you do not get to choose it.**
`BlendAdapter implements Adapter<BlendRawData, 'lending'>` owes lending's five;
`AquariusAdapter implements Adapter<AquariusRawData, 'dex'>` owes `dex`'s two. `FactorMapFor<C>`
reads the key set straight out of `CATEGORY_FACTORS` in
[`core/src/weights.ts`](core/src/weights.ts), so **which factors your category scores is declared in
exactly one place and your adapter cannot disagree with it.** Returning another category's map, or a
key no rulebook has, is a compile error rather than a review note — `core/src/weights.test.ts` pins
that with `@ts-expect-error` probes.

`TCategory` is defaulted to the whole union so the indexer can hold a heterogeneous
`Adapter<unknown>[]`; your adapter should always name it.

A convenience alias for your category's map lives beside its weight table, for your own code to
refer to. It is a name, not a second definition:

```ts
export type DexFactorMap = Record<keyof typeof DEX_FACTORS, RiskFactor | null>;
export type DexFactorMap = FactorMapFor<'dex'>;
```

Deriving it is the point — a factor added to or removed from `CATEGORY_FACTORS` then breaks every
adapter in that category at compile time, instead of letting one publish a map its rulebook does not
describe.
Adding or removing a factor in `CATEGORY_FACTORS` therefore breaks every adapter in that category at
compile time, instead of letting one publish a map its rulebook does not describe.

### `operationalState` — required, and it must not touch a factor

Expand Down
34 changes: 24 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,23 @@

## What Stenion is

Stenion continuously scores the safety of Stellar/Soroban DeFi protocols — starting with
lending protocols ([Blend](https://blend.capital) and [Kinetic/K2](https://k2lend.com)) — by
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: four entries across those two protocols today — Blend's
own Fixed pool, Kinetic, and the YieldBlox and Etherfuse pools 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.
Stenion continuously scores the safety of Stellar/Soroban DeFi protocols — lending
([Blend](https://blend.capital), [Kinetic/K2](https://k2lend.com)) and now AMMs
([Aquarius](https://aqua.network)) — by reading their state **directly from the chain** and turning
it into a single, comparable `safetyScore` (0–100, higher = safer), broken down into that
category's risk factors.

Scoring is per **market**, not per brand: five entries across those three protocols today — Blend's
own Fixed pool, Kinetic, the YieldBlox and Etherfuse pools on Blend V2, and Aquarius's XLM/USDC
pool. 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.

**Each category has its own rulebook, and scores are only comparable inside one.** Lending is
scored on five factors; the `dex` rulebook scores two, because utilization against a borrow cap and
free-liquidity depth mean nothing for an AMM and reusing them would publish a number computed from
nothing. So the registry ranks each category in its own block, numbered from 01 within itself —
there is no cross-category ordering anywhere, because two scores produced by different rules are
not comparable.

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
Expand All @@ -29,8 +37,14 @@ moves every block**:
- **Liquidity depth** — how much could be withdrawn before a reserve is drained?
- **Utilization headroom** — how close is borrowing to the protocol's own stress line?

Those five are lending's. An AMM fails differently, so the `dex` rulebook asks two different
questions instead — **who can change the rules** (Aquarius's seven privileged roles and the
two-step upgrade deadline that says how long an LP has to react) and **who can seize what the pool
holds** (whether a reserve's issuer has `auth_revocable` or `auth_clawback_enabled` set). Both
rulebooks are in [`methodology/`](methodology/index.md).

A static audit is a snapshot of one moment. TVL tells you how much is at stake, not how safe it
is. Stenion re-derives all five factors from on-chain data on a short interval, so the number you
is. Stenion re-derives every factor from on-chain data on a short interval, so the number you
see reflects the protocol **now**, not at audit time.

## The rules that make the number trustworthy
Expand Down
Loading
Loading