Skip to content

built-with-deviations Built, with deviations. This is a decision record, not documentation; the body is annotated where the build diverged.

What is still current: The base-asset rule is the live coverage policy and src/lib/portfolio/buckets.ts cites this page for it. The BTC book it grants was retired in August 2026; bitcoin assets are shown outside the yield book.

Landed: Tranche 1 in PR #376 (v0.7.0); Tranches 2 to 5 folded into portfolio-taxonomy-coverage-plan.md; the BTC book retired by migration 077 (v0.36.0)

Header updated 2026-09-14. The body below is frozen history. All plans.

Portfolio coverage expansion plan ​

Settled with Fred 2026-07-12, following a full audit of the portfolio view against the three position surfaces (Repo markets, Carry trades, Asset profiles). This document is the program of record; each tranche ships as its own PR into staging.

The policy ​

  1. Base-asset rule. A position leg charts into a PnL book iff its accounting asset is BTC-, ETH-, or USD-based. A Pendle PT resolves through its underlying's base (PT-reUSD → reUSD → USD). Anything else (EURC is EUR, XAUt/PAXG are gold, governance tokens, equity/RWA trackers) is valued in "Outside the yield book" with a reason — never hidden, never blended into PnL.
  2. Every covered asset profile shows, with yield in the PnL history — including a bare wallet holding of the profiled token (not just when it is deployed into a venue). Extends to the Managed Strategies vaults.
  3. Any Morpho curator vault position is covered — the target state is every MetaMorpho vault (factory-discovered), not only the allowlisted curator-fund registry. Includes the Morpho Vaults V2 gap (issue #68).
  4. Any USD/BTC/ETH supply to Aave v3, SparkLend, or Fluid Lending is covered — including supplies invisible to the user-config bitmask (collateral toggle off, LTV-0 reserves, isolation mode) and all based fTokens (not only fUSDC/fUSDT).
  5. Any direct lend into a Morpho Blue market with a based loan asset is covered — the market universe must become exhaustive, not the curated registry subset.
  6. Supplies always chart (per-leg carve-out): a based supply charts into its own book even when the same Aave/Spark account also holds an excluded debt group. Only debt groups are held to the all-or-nothing rules (e-mode gate, same-book).

Confirmed out of scope: Pendle YT and LP tokens, rewards/points, changes to the debt-side carry rules, EUR/gold assets.

Book-map maintenance rule ​

An asset enters ACCOUNTING_ASSET_BOOKS (src/lib/portfolio/buckets.ts) only when (a) its base denomination is verifiable from the contract or issuer (an ERC-4626 over a mapped asset, a recognised stable/LST/LRT/BTC wrapper) and (b) it is reachable on a covered venue. Never classify by symbol pattern-matching: the exotic tail (mHYPER, STRCx, the USDat/NUSD families, structured Pendle underlyings) stays unmapped — such a leg is valued in Outside and fires the WS8 unknown-asset Telegram alert (dust-floored at $1 so 1-wei aToken grief transfers cannot spam it), which is the queue for deliberate classification.

Adding a map entry re-books stored history. Classification is read-time from each key's latest row, but stored snapshot values carry the unit they were written under (EXCLUDED = MARKET-only USD). On every map addition, check portfolio_position_snapshots for wallets already holding the asset and run the destructive per-wallet backfill repair for them; without it, an ETH/BTC re-booking mixes USD-valued history into the new book's curve at the deploy boundary. The same applies to legs newly visible for the first time (e.g. supplies the old bitmask reader missed): they appear with no in-interval flow, so their book shows a one-time unexplained equity step until the wallet's history is repaired.

Tranches ​

Tranche 1 — coverage correctness on existing venues (this PR) ​

  • Book-map completion. DEFERRED to issue #385; NOT in v0.7.0. The intent is to add every verifiable based asset reachable today: Aave/Spark reserve underlyings (sDAI, crvUSD, FRAX, LUSD, mUSD, USDG, eUSDe; cbETH, ETHx, rsETH; tBTC, FBTC, BTC.b) and Fluid Liquidity Layer tokens (iUSD, USD0, RLP, BOLD, csUSDL, jrUSDe, fxUSD, USDai, deUSD, wstUSR; mETH, weETHs), plus pufETH (Pendle underlying). Addresses sourced from onchain_credit.lending_reserves / fluid_ll_apy (what the readers actually emit) and symbol-verified on-chain 2026-07-12. The entries were written and then reverted before the release: a book entry WITHOUT a redemption-rate path does not chart the leg, it deletes it. redemptionRateKind() classifies anything in a real book that is neither PAR_ACCOUNTING_ASSETS nor UNRATED_YIELD_ACCOUNTING_ASSETS as rate-source, and with no JIT_RATE_GETTERS entry and no token_yield_apy.share_rate row (both absent for all 26 of them) resolveBookUnitRate throws RateUnavailableError, so snapshot.ts skips the leg (M9). The position then vanishes: not stored, not in the outside API group, and not WS8-alerted (that alert keys off book='EXCLUDED', which a mapped leg no longer is). Unmapped is strictly the safer state. #385 lands the par classifications and the rate sources in the same change as the map entries.

    A book entry alone is not enough, and shipping one alone is worse than shipping nothing: bookForAccountingAsset only picks the book, and redemptionRateKind() then sends anything that is neither par nor explicitly unrated down the 'rate-source' path, which needs a JIT_RATE_GETTERS entry or a token_yield_apy.share_rate row. None of these 26 has either, so resolveBookUnitRate throws RateUnavailableError and snapshot.ts skips the leg (M9: skip, never zero). The position then DISAPPEARS: not stored, not in the outside group of GET /api/portfolio/positions, and not WS8-alerted (that alert keys off book='EXCLUDED', which the leg no longer is). Unmapped, the same leg is stored as EXCLUDED, market-valued, and alerted. So each asset is mapped only in the SAME change that gives it a rate path: the par ones (tBTC/FBTC/BTC.b as BTC-par under M5, plus the par stables) go into PAR_ACCOUNTING_ASSETS, and the yield-bearing ones (sDAI, cbETH, rsETH, ETHx, mETH, weETHs, pufETH, eUSDe, csUSDL, wstUSR, jrUSDe, iUSD, RLP) need a real rate source wired first.

  • Aave/SparkLend full-sweep reader. readers/aave-family.ts enumerated legs from the getUserConfiguration bitmask, which has no "has supply" bit — a supply with the collateral flag off (manual toggle, LTV-0 reserve, isolation mode) was invisible. Replace with a scaledBalanceOf sweep over every reserve's aToken + variableDebtToken; getUserEMode stays.

  • M1 per-book carve-out. classifyLegs (pnl.ts) treated an Aave/Spark account as one all-or-nothing group, so a USDC supply beside an ETH e-mode carry excluded BOTH (cross-book). New rule: partition the account's legs by book; a debt-bearing book sub-group is gated on e-mode as before; a supply-only book sub-group charts as debt-free; unknown-asset legs go Outside individually. Morpho Blue: the pure lend leg (morpho:market:<id>:supply) charts per-leg; the collateral+debt carry keeps the existing group rules.

Tranche 2 — Fluid Lending (fToken) expansion ​

Enumerate the fToken registry on-chain and add every based fToken (fWETH, fwstETH, fGHO, fUSDe, …) to src/data/fluid-ftokens.ts with on-chain-verified fields, extending the same erc4626 reader/flow path that fUSDC/fUSDT ride today.

Tranche 3 — exhaustive Morpho Blue market universe ​

Widen morpho_market_registry ingestion from the curated subset (~200 rows) to every mainnet market (CreateMarket log scan or Blue API), with token metadata so the portfolio loader qualifies them. Loan assets get book-mapped per the maintenance rule; the reader already scales (position() is multicalled per market).

Tranche 4 — every MetaMorpho vault ​

Factory-driven vault discovery (MetaMorpho v1.0/v1.1 factories, Vaults V2) replacing the 61-vault allowlist as the PORTFOLIO universe only — the Repo markets page keeps its curated registry. V2 vaults are ERC-4626, so the existing reader applies; the work is discovery, metadata, and flows/backfill.

Tranche 5 — wallet-holdings venue ​

New wallet venue (SQL venue CHECK migration): bare ERC-20 balances of the asset-profile registry tokens plus the Managed Strategies vaults (yoETH, earnETH, iETHv2, yoUSD, yvUSD, fLiteUSD). Transfer-event flow scanning and history backfill per token; /portfolio page copy updated (the current "no token balances" promise no longer holds once this ships).

Verification bar (every tranche) ​

npx tsc --noEmit + npm test clean; on-chain verification of any new/changed reader against live mainnet data (real wallets, and where a case cannot be found organically, a Tenderly VNet fork constructing it); docs (docs/metrics.md, page docs) updated in the same PR; review posted on the PR before merge into staging.

Addendum, 2026-08-10 — the R6 re-booking rule runs BOTH ways ​

The R6 ops rule in this plan (adding a buckets.ts entry re-books stored history, so the per-wallet backfill re-derivation must run for every affected wallet) was written for the direction where an asset GAINS a book. Retiring the BTC book exercised the other direction, and the rule is the same.

Rows written under book='BTC' carry BTC-UNIT values. The EXCLUDED contract is USD market value only, so relabelling them by SQL would manufacture rows whose unit lies, and nulling their values instead would erase honestly recorded data. The sanctioned repair is this plan's own per-wallet re-derivation, run with the 6h cron drained after migration 077 and before the code deploy, verified with:

sql
SELECT count(*) FROM onchain_credit.portfolio_position_snapshots WHERE book='BTC';
-- must read 0

Two belts were added beside it, because a missed wallet used to be a 500 rather than a degradation. The read path folds any unrecognised stored book string to EXCLUDED at one boundary (parseBook, src/lib/portfolio/buckets.ts) and drops that row's stored magnitudes, since they are denominated in the retired book's unit and the EXCLUDED contract cannot honour them: the leg still lists, priced as unreadable, so its group withholds a net rather than printing a bitcoin quantity behind a dollar sign. And the book CHECK vocabularies are narrowed by a follow-up migration that RAISES rather than running while a 'BTC' row survives.

The deferred CHECK narrowing (release N+1) ​

Not in the retirement release, and this is load-bearing rather than tidiness.scripts/ops/migrate.sh applies every pending file in one invocation under set -euo pipefail, so a guard that RAISES on leftover book='BTC' rows would abort the same run that applies 077 — and the gated prod sequence (077, then the repair, then the deploy) could never be executed as written. Even once the rows are gone, narrowing while the PREVIOUS release still serves would turn a degraded registry read there (empty bookMap, static map still saying BTC) from a self-healing blip into a failed snapshot INSERT, i.e. a lost tick.

So it ships as scripts/sql/078-narrow-portfolio-book-checks.sql in the release AFTER the code deploy, untagged, self-guarding:

sql
DO $$
DECLARE
  stale_snapshots bigint;
  stale_tokens    bigint;
BEGIN
  SELECT count(*) INTO stale_snapshots
    FROM onchain_credit.portfolio_position_snapshots WHERE book = 'BTC';
  SELECT count(*) INTO stale_tokens
    FROM onchain_credit.portfolio_tokens WHERE book = 'BTC';
  IF stale_snapshots > 0 OR stale_tokens > 0 THEN
    RAISE EXCEPTION
      'BTC book rows still present (% snapshot rows, % registry rows). Apply 077 and run the per-wallet backfill re-derivation for every affected wallet before narrowing the CHECK.',
      stale_snapshots, stale_tokens;
  END IF;
END
$$;

-- BOTH names, and that is not belt-and-braces. `043` created the snapshot CHECK as
-- portfolio_pos_book_chk; `067` rebuilt the table partitioned and named its copy
-- portfolio_pos_book_chk2. `067` is DESTRUCTIVE-tagged, so it has run on prod and NOT
-- on any database built from the ledger unattended (the e2e fixture is one). Dropping
-- only the `2` name would leave the wide constraint standing wherever `067` never ran.
ALTER TABLE onchain_credit.portfolio_position_snapshots
  DROP CONSTRAINT IF EXISTS portfolio_pos_book_chk;
ALTER TABLE onchain_credit.portfolio_position_snapshots
  DROP CONSTRAINT IF EXISTS portfolio_pos_book_chk2;
ALTER TABLE onchain_credit.portfolio_position_snapshots
  ADD CONSTRAINT portfolio_pos_book_chk2 CHECK (book IN ('USD','ETH','EXCLUDED'));

ALTER TABLE onchain_credit.portfolio_tokens
  DROP CONSTRAINT IF EXISTS portfolio_tokens_book_chk;
ALTER TABLE onchain_credit.portfolio_tokens
  ADD CONSTRAINT portfolio_tokens_book_chk CHECK (book IS NULL OR book IN ('USD','ETH'));

Expand/contract still holds after it lands: the narrowed vocabulary is a strict subset of what the previous release wrote, and that release cannot write 'BTC' either (its static map no longer carries the wrappers), so a code rollback does not need the constraint widened again.

See Portfolio -> Retired: the BTC book.

Private documentation. creddit.xyz