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
- 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.
- 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.
- 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).
- 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).
- 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.
- 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 neitherPAR_ACCOUNTING_ASSETSnorUNRATED_YIELD_ACCOUNTING_ASSETSasrate-source, and with noJIT_RATE_GETTERSentry and notoken_yield_apy.share_raterow (both absent for all 26 of them)resolveBookUnitRatethrowsRateUnavailableError, sosnapshot.tsskips the leg (M9). The position then vanishes: not stored, not in theoutsideAPI group, and not WS8-alerted (that alert keys offbook='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:
bookForAccountingAssetonly picks the book, andredemptionRateKind()then sends anything that is neither par nor explicitly unrated down the'rate-source'path, which needs aJIT_RATE_GETTERSentry or atoken_yield_apy.share_raterow. None of these 26 has either, soresolveBookUnitRatethrowsRateUnavailableErrorandsnapshot.tsskips the leg (M9: skip, never zero). The position then DISAPPEARS: not stored, not in theoutsidegroup ofGET /api/portfolio/positions, and not WS8-alerted (that alert keys offbook='EXCLUDED', which the leg no longer is). Unmapped, the same leg is stored asEXCLUDED, 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 intoPAR_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.tsenumerated legs from thegetUserConfigurationbitmask, which has no "has supply" bit — a supply with the collateral flag off (manual toggle, LTV-0 reserve, isolation mode) was invisible. Replace with ascaledBalanceOfsweep over every reserve's aToken + variableDebtToken;getUserEModestays.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:
SELECT count(*) FROM onchain_credit.portfolio_position_snapshots WHERE book='BTC';
-- must read 0Two 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:
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.