Skip to content

Add yTranche indexing and REST support#445

Draft
murderteeth wants to merge 11 commits into
mainfrom
tronche
Draft

Add yTranche indexing and REST support#445
murderteeth wants to merge 11 commits into
mainfrom
tronche

Conversation

@murderteeth

Copy link
Copy Markdown
Contributor

Summary

Indexes the yTranche deployment: Kong discovers tranches through the controller,
retains controller state, enriches tranche snapshots with Hook state, generates
controller-backed PPS and performance history, and exposes it over REST.

The controller is the authoritative source for a tranche's assets and price per
share — it holds what the tranche has accrued and what losses it has absorbed,
where the tranche's own totalAssets() reports its ERC-4626 balance. So tvl-c,
pps and every APY observation resolve through one shared reader:
trancheController.liveAssets(vault) for tranches, totalAssets() and
pricePerShare() for ordinary vaults, which keep the paths they already had.

Closes #444

How to review

Read the commits in order — each is one self-contained step, and the messages carry
the reasoning that doesn't fit in code comments.

Start with docs/ytranche.md: it covers the accounting semantics that are easy to
get wrong (pendingExcess is assigned profit awaiting a report, coverage is an
accounting measure while capacity is what the Hook will deliver, rate limits are
fixed windows that expire at windowStart + rateLimitWindow) and the hook vs
hookState snapshot distinction.

Then the three places where a wrong call would be expensive:

  • packages/ingest/abis/yearn/lib/assets.ts — the shared asset/PPS readers, and
    the refactor of lib/tvl.ts, lib/apy.ts and the v2/v3 pps hook onto them.
    This is the only change that reaches non-tranche vaults.
  • .../tranche/controller/snapshot/hook.ts — discovery and the ordered tranches
    accounting array, all read at the automatic snapshot's block.
  • packages/web/app/api/rest/tranche/db.ts — payload units and derivations.

The two generated abi.ts files are etherscan-verified sources, one line each —
skim or skip.

Two deliberate choices worth a second opinion:

  • Tranches carry vault and strategy labels so they reach Kong's existing vault
    surfaces, and a tranche = true filter on the generic yearn/3/vault and
    yearn/3/strategy paths gives each tranche exactly one snapshot owner.
  • Tranche TVL is emitted as tvl-c from controller-backed liveAssets — a claim
    on the main vault's backing, which the legacy tvl label continues to report at
    the vault itself, so one number covers those assets.

Test plan

  • Automated: bun --filter ingest test, bun --filter web test, bun --filter lib test.
    20 new tests. Ingest carries 10 failures that also fail on main
    (archive-RPC-dependent specs) — the same set before and after this branch.
  • Manual: full local run against mainnet, controller-only config, discovery
    through to REST. Three tranches found in priority order; 9 things with correct
    base/locked types; hookState on each tranche alongside the raw hook
    address; five output labels populated with the legacy tvl label appearing
    for the main vault alone; both REST routes 200 with matching payloads; unknown
    controller 404, bad chainId 400.
  • Verified two tranche-accounting / tranche-system observations against
    direct eth_call at their stored block numbers — exact to the wei, confirming
    each observation is a point-in-time read.

Risk / impact

Runs on the existing schema, reusing the thing, snapshot and output tables.

The one change with shared blast radius routes every yearn vault's tvl-c and pps
through the new readers. Ordinary vaults issue the same totalAssets() and
pricePerShare() calls they did before — the branch is keyed on a
trancheController default that tranches alone carry — and the existing tvl and
pps specs pass unchanged. Rollback is a straight revert; every write lands in
existing tables and columns.

Also adds a refresh-cache workflow step for the tranche cache and widens the
timeseries refresh to controller-addressed series. Both are additive; the tranche
endpoints begin serving once their first refresh runs.

🤖 Generated with Claude Code

murderteeth and others added 11 commits July 26, 2026 17:34
Adds the Ethereum yTranche deployment to the indexer's configuration.

- `yearn/3/tranche/controller` — the TrancheController ABI, registered as a
  static chain-1 source at its creation block. Its zero-argument state
  (VAULT, ASSET, reserveVault, tranchesLength, totalClaims, vaultAssets,
  reserveAssets, backingAssets, ...) is picked up by the automatic snapshot,
  and its events index through automatic event extraction.
- `yearn/3/tranche/vault` — the tranche ABI: the TokenizedStrategy interface
  unioned with the base and locked tranche implementations, so one ABI covers
  both deployed variants. Registered as `tranche` things.
- `yearn/3/tranche/hook` — a read-only interface for the Hook contract. The
  Hook owns no thing and no snapshot; the tranche snapshot hook reads it
  through this interface.

Tranches also carry `vault` and `strategy` labels, which the generic
`yearn/3/vault` and `yearn/3/strategy` paths would otherwise process. Both
filters now exclude things marked `tranche`, so every tranche address has
exactly one snapshot owner.

The controller and tranche paths are siblings rather than nested under a
shared `yearn/3/tranche` hook path: kong's hook resolver applies a parent
path's hooks to all child paths, which would cross-wire the two.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…ting

The controller snapshot hook walks `tranchesByPriority(index)` for
`tranchesLength()` slots, so tranches are found through the controller with no
hardcoded addresses and each entry keeps its priority. For every tranche it
reads `tranches()`, `liveAssets()` and `trancheCoverage()` and appends an
ordered `tranches` array to the snapshot's hook data. All appended reads use
the automatic snapshot's block, so the stored row describes one point in time.

Discovery also projects the things the rest of the pipeline needs. Each tranche
gets `tranche`, `vault` and `strategy` things sharing one set of defaults:
controller, priority, type, asset, main vault, inception and the erc4626 / v3 /
yearn flags. `trancheType` records the implementation — base or locked, decided
by whether the tranche exposes cooldown configuration — rather than the A/B/E
deployment nicknames, which are configuration and not protocol types.

Four defaults go beyond the discovery set because existing shared code requires
them: `decimals` and `apiVersion` (the tvl and apy calculations parse them off
the thing), and `name`/`symbol`, which keep the vault list cache from seeing a
null name in the window between a tranche's first thing and its first snapshot.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Hook state is parameterized by tranche, so the automatic snapshot can't reach
it — it only captures zero-argument reads, which for a tranche means the raw
`hook` address. This hook reads that Hook at the same block with the tranche as
target and appends `hookState`: the global gate and rate-limit window, the
tranche's deposit limit, its fixed-window deposit and withdrawal counters, and
the derived deposit and withdrawal caps.

`hook` stays the address and `hookState` stays separate, so a reader can always
tell which Hook produced the state. Counters are stored exactly as reported;
whether a window has expired depends on the timestamp a consumer is answering
for, so that derivation belongs to the consumer. The Hook gets no thing and no
snapshot of its own, and no account-scoped state is read.

A tranche pointing at a hook that doesn't implement the interface logs and
skips enrichment rather than failing the snapshot — the raw address is stored
either way, and that's what a reader needs to investigate.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A vault's asset balance has one authoritative source, and for a tranche it is
not the vault: the controller holds the accounting. A tranche's own
totalAssets() reports its ERC-4626 balance, while the controller knows what it
has accrued and what losses it has absorbed. New `abis/yearn/lib/assets`
resolves both cases from a vault thing and a block — totalAssets() normally,
`trancheController.liveAssets(vault)` when the thing carries a controller — so
tvl, pps and apy can no longer disagree about how much a vault holds.

The same module owns the shared pps reader. Ordinary vaults keep reading
pricePerShare(); tranches are priced as authoritative assets × share scale ÷
totalSupply, falling back to the share scale when supply is zero. tvl-c,
`pps` and every apy observation (current, weekly, monthly, inception) now route
through these two functions, so a tranche and an ordinary vault are measured by
the same rule at the same blocks.

Tranches get `pps`, `apy-bwd-delta-pps` and `tvl-c` hooks and no `tvl` hook:
tranche tvl is a claim on the main vault's backing rather than additional
protocol assets, and the legacy label is the one naive aggregates sum. Labels,
components, pricing, sampling, annualization, compounding and storage are all
unchanged, and non-tranche vaults follow exactly the paths they did before —
`liveAssets` already excludes pendingExcess, so no pending profit enters pps or
tvl.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The tranche accounting reads decode to fixed tuples that viem types as a union
including address[], so the direct cast is not provable. Route both through
unknown, as the codebase does elsewhere for multicall tuple decodes.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two daily series make the controller's accounting historical rather than
only current.

`tranche-accounting`, one series per tranche: baseline assets, pending excess,
live assets, claim, covered, coverage ratio, target rate, excess share and
whether accrual is paused. Everything comes from the controller, because the
controller — not the tranche's own ERC-4626 view — knows what a tranche has
accrued and how much of its claim is actually covered.

`tranche-system`, one series at the controller: total claims against vault
assets, reserve assets and backing assets, plus their ratio. That ratio is
accounting coverage and nothing more — what can be withdrawn right now is a
separate question, bounded by hook limits and deliverable vault liquidity.

Each observation reads at a single historical block. Blocks before a tranche's
registration read as an unregistered tranche rather than reverting, so those
early zeros are real; a failed read emits nothing instead, so rpc trouble can't
be mistaken for empty accounting. Coverage ratio is left null rather than 1 when
there is no claim to cover — no claim is not evidence of full coverage.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`GET /api/rest/tranche/:chainId` returns the deployment as one document:
controller, asset, main vault, reserve vault, system accounting and the tranches
in priority order. Each tranche carries its metadata, its raw `hook` address,
`hookState`, the controller's accounting for it, coverage, current
controller-backed price per share, and deliverable deposit and withdrawal
capacity.

Amounts are normalized to the asset's decimals, with `asset.decimals` in the
payload so raw units can be recovered, and price per share keeps both raw and
humanized forms to match the `pps` output. Everything is stamped with the block
it came from: system fields from the controller snapshot's block, each tranche
from its own, since the two are snapshotted independently.

Capacity is reported as the Hook derives it rather than re-derived here, plus
effective rate-limit usage: a fixed window has expired once the snapshot's
timestamp passes windowStart + rateLimitWindow, at which point nothing is used.
That is deliverable capacity, and it is deliberately separate from the coverage
ratio, which is an accounting measure.

Served from Redis like the other REST resources, with a refresh script wired
into the existing refresh-cache workflow. An unset reserve vault reads as the
zero address on chain and is reported as absent; a chain with more than one
controller keeps the larger deployment and logs the rest rather than dropping it
silently.

`tranche-accounting` and `tranche-system` join `pps` and `apy-historical` in the
timeseries mechanism. Both are narrower than "every vault", so labels now
declare their address scope and the refresh scripts give the narrow ones their
own pass instead of adding a query per vault. No GraphQL changes.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Fixed-block specs against the deployed Ethereum system, so expected values are
reproducible rather than moving targets:

- authoritative assets pick totalAssets() for an ordinary vault and
  liveAssets(vault) for a tranche, each checked against a direct read of the
  deployed contracts
- tranche pps equals authoritative assets × scale ÷ supply, ordinary pps still
  equals pricePerShare(), and zero supply returns the share scale
- apy's current, weekly, monthly and inception observations each equal the shared
  reader's value at the block apy sampled — apy has no pps path of its own
- tvl-c consumes controller-backed assets while keeping its five components, and
  the tranche path exposes no legacy `tvl` label
- controller discovery returns the three deployed tranches in priority order,
  with their accounting and base/locked implementation type
- tranche snapshot enrichment appends hookState with representative limit data,
  leaves the raw `hook` address alone, and skips enrichment for a missing hook or
  one that doesn't implement the interface
- both accounting series emit their components normalized to asset decimals
- the REST payload's units, priority ordering, effective rate-limit usage and
  pre-snapshot fallbacks, unit-tested off captured row shapes

Verifying apy's observations by equality against the reader rather than by
mocking it is deliberate: specs share one module registry, so a module mock
either leaks or gets bypassed depending on file order.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
New docs/ytranche.md covers what the code can't say on its own: the deployment,
why the controller and tranche abi paths are siblings, why a tranche carries
three things and what the `tranche` default is protecting, the `hook` vs
`hookState` snapshot distinction, and the accounting semantics that are easy to
get wrong — pendingExcess is not live NAV, coverage is not withdrawal capacity,
target rate is not realized yield, rate limits are fixed windows rather than
sliding lookbacks, and tranche claims are not additional protocol TVL.

docs/rest.md gains the tranche endpoint with a full response and notes on units,
per-tranche block stamping and the capacity-vs-coverage distinction, plus the two
new timeseries segments. docs/outputs.md gains both new labels with their
components and the tranche pps formula, and its coverage table now records that
tranches deliberately emit no legacy `tvl`.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A tranche controller is bound to one asset class by construction: its ASSET and
VAULT are constructor arguments with no setters, so it can never be repointed. A
chain therefore holds one controller per asset class — USD, BTC, ETH — and the
previous single document per chain could only ever describe one of them. The
refresh script papered over this by keeping the controller with the most tranches
and logging the rest, which would have silently dropped a whole deployment from
REST the moment a second one shipped.

Replaces that with a collection and a member:

  GET /api/rest/tranche/:chainId/controllers   every system on the chain
  GET /api/rest/tranche/:chainId/:controller   one system, same shape as before

`controllers` is a static route segment, so Next resolves it ahead of the sibling
member route and it can never be mistaken for an address — controllers are hex, so
the reverse collision is impossible too. The member route accepts either address
casing; the cache key lowercases. `/api/rest/tranche/:chainId` is now an unrouted
prefix rather than a second URL for the same payload.

The dedupe branch is gone: every controller gets its own cache entry and each chain
gets a collection entry holding all of them. Nothing else needed changing —
discovery, the tranche-system series and the timeseries refresh were already keyed
by controller address.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The controller is a configured source rather than a discovered address, so
nothing created a thing for it and it was absent from every thing-shaped surface
— including the dashboard's label counts, where the tranches show up but the
deployment they belong to did not.

It now gets a `trancheController` thing carrying asset, main vault, inception and
the v3 / yearn flags. No `reserveVault` default: that one is settable, so its
current value belongs to the snapshot rather than to defaults, where it would go
stale. The thing is created whether or not any tranches are registered, since a
freshly deployed controller with an empty registry still exists.

That also removes a duplicated derivation. Both controller lookups in web were
reconstructing the set of controllers from the `trancheController` default on the
tranches pointing at them, which needed at least one registered tranche to find a
deployment at all. They now read the label directly.

No abi path claims the `trancheController` label, so the new thing is not fanned
out and the controller keeps exactly one snapshot owner. Label counts on the
dashboard are grouped straight from the thing table, so it appears with no web
changes.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@vercel

vercel Bot commented Jul 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
kong Ready Ready Preview, Comment Jul 26, 2026 9:32pm

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add yTranche indexing and REST support

1 participant