Portfolio taxonomy & full-coverage plan
Settled with Fred 2026-07-15. Supersedes Tranches 2–5 of portfolio-coverage-expansion-plan.md (fTokens, exhaustive Morpho, every MetaMorpho, wallet-holdings venue), which are folded into this plan's tranches. Tranche 1 of that plan (coverage correctness, PR #376) shipped and is unchanged.
1. Product requirement (Fred, 2026-07-15)
The /portfolio view is a seven-category statement of a wallet's BTC/ETH/USD yield book. Every category below must be covered; anything outside them stays hidden from the UI (kept in the API + WS8 alert, per the settled PR #377 rule).
| # | Category (display label) | What belongs in it |
|---|---|---|
| 1 | Repo lending | Any based lending position with no same-book borrow against it: Aave v3 / SparkLend supplies, direct Morpho Blue market supply, Fluid Lending fTokens, debt-free Fluid vault positions (collateral parked in the Liquidity Layer). |
| 2 | Carry trades | Any same-denominated (same-book) collateral+debt loop on Aave, SparkLend, Morpho Blue, or Fluid: stETH/ETH, sUSDe/USDe, PT-collateral loops, smart pools. E-mode no longer gates inclusion (settled today: any same-book loop is a carry, e-mode or not). |
| 3 | Money market funds | Curated ERC-4626 vault deposits: MetaMorpho / Euler curator funds (eventually the full MetaMorpho factory universe). |
| 4 | Managed strategy funds | The managed-strategy vaults (yoETH, earnETH, iETHv2, yoUSD, yvUSD, fLiteUSD). |
| 5 | Fixed rate assets | Bare Pendle PT holdings (underlying based). A PT pledged as same-book collateral stays a carry trade. |
| 6 | Variable rate assets | Bare wallet balances of every yield-bearing asset with an asset profile (sUSDe, sUSDf, syrupUSDC, syrupUSDT, reUSD, weETH, wstETH, rETH, ezETH, osETH, sUSDS, PST, sUSDai — and every future profile automatically). |
| 7 | Idle assets | Unproductive bare balances: at least the top-20 stablecoins by TVL (including non-USD fiat stables like EURC), native ETH + WETH, and the BTC wrappers. |
The based rule is unchanged: a leg charts iff its accounting asset is BTC/ETH/USD-based; a yield accruer on a base (reUSD) is based; a PT resolves via its underlying's base. Non-based fiat stables (EURC) are the one carve-out: they appear in Idle assets valued in USD but never charted into book PnL (FX moves are not yield; see §3.8).
Settled in today's Q&A: curator vaults = Money market funds; strategy vaults = Managed strategy funds; carry = any same-book loop (drop the e-mode gate); the excluded bucket stays hidden; bare PTs = Fixed rate assets (and the old "yield assets" name becomes Variable rate assets).
Out of scope (unchanged from the 2026-07-12 policy): Pendle YT/LP, rewards and points, borrow-side rule changes, non-mainnet chains (schema stays chain_id-keyed so chains can be added without refactoring).
2. What stays (and why this is an extension, not a rewrite)
The core engine survives contact with every requirement above, so we extend it:
- The reader contract (
PositionRead[]per venue,types.ts): the new wallet venue is just a seventh reader returning the same shape. - The index-ratio PnL engine + composed index (
pnl.ts,valuation.ts): a bare variable-rate holding is exactly the case theComposedIndex/ wrapperRate machinery already handles for wrapper collateral on Aave (venue index ≡ 1, wrapper share rate carries the yield). No engine change. - Book accounting (M1): books remain the accounting/PnL partition. The new category dimension is a second, product-facing classification computed the same way (at read time, from current structure) and never blended across.
- Snapshot/flow tables, backfill state, advisory lock, WS8 alerts: the wallet venue rides the same rails (
portfolio_position_snapshots,portfolio_flow_events—transfer_in/transfer_outkinds already exist).
What gets rebuilt (deliberately, once): the hard-coded token/book map (buckets.ts) and the per-venue static universes become DB registries with sync scripts, and per-wallet discovery replaces read-the-whole-universe. Those are the two pieces that would otherwise force a refactor with every coverage expansion.
3. Design
3.1 The category dimension
New type in types.ts:
export type Category =
| "repo_lending"
| "carry_trade"
| "money_market_fund"
| "managed_strategy_fund"
| "fixed_rate_asset"
| "variable_rate_asset"
| "idle";Category is derived per group (the M1 grouping) at classification time in classifyLegs — same "classify at read time" semantics as books: when an account's structure changes (a supply-only book sub-group takes on a borrow), its category changes from repo_lending to carry_trade from that read onward, and history stays valid.
Derivation rules:
| Category | Rule |
|---|---|
| repo_lending | Aave/Spark book sub-group with supplies only; Morpho Blue supply carve-out leg; erc4626 leg with universe role: 'ftoken'; Fluid NFT group with no debt legs. |
| carry_trade | Aave/Spark book sub-group with supply+debt (any e-mode state); Morpho Blue same-book collateral+debt group; Fluid NFT group with debt (M14 same-book rule unchanged). |
| money_market_fund | erc4626 leg with role: 'curator'. |
| managed_strategy_fund | erc4626 leg with role: 'managed'. |
| fixed_rate_asset | pendle-venue leg (bare PT). |
| variable_rate_asset | wallet-venue leg whose registry row has class = 'variable_rate'. |
| idle | wallet-venue leg whose registry row has class = 'par'. |
| (hidden) | Everything included: false today: cross-book, bare debt, unknown asset. Unchanged. |
Erc4626Vault gains a role: 'ftoken' | 'curator' | 'managed' field (today the split is inferable from platform, but role is the taxonomy key and decouples it from where the vault happens to run).
3.2 Classification change: drop the e-mode carry gate
classifyLegs M1(b) changes: an Aave/Spark debt-bearing book sub-group with same-book supply is included as a carry regardless of e-mode. The leveraged-no-emode inclusion reason is retired; emode-carry-single-book becomes same-book-carry. Everything else holds: bare-debt sub-groups stay excluded as cross-book (R2 liquidation phantom-gain guard), unknown assets go outside individually, the per-book carve-out is untouched.
Because reads were never skipped by e-mode ("Reads are NEVER skipped by e-mode or book"), the snapshots and flows for previously-hidden no-e-mode loops are already in the DB — this is a pure classifier change and their full history appears at once. emodeCategory stays stored and becomes display metadata (an "e-mode" chip on the position row).
3.3 portfolio_tokens — the token registry (single source of truth)
New table replacing the hard-coded ACCOUNTING_ASSET_BOOKS map and defining the wallet-venue sweep universe:
CREATE TABLE onchain_credit.portfolio_tokens (
chain_id INT NOT NULL,
address TEXT NOT NULL, -- lower-cased; native ETH sentinel 0xeeee…eeee
symbol TEXT NOT NULL,
name TEXT,
decimals INT NOT NULL,
book TEXT CHECK (book IN ('USD','ETH','BTC')), -- NULL = valued-only (non-based fiat)
class TEXT NOT NULL CHECK (class IN ('par','variable_rate')),
wallet_tracked BOOLEAN NOT NULL DEFAULT false, -- swept by the wallet venue
rate_source TEXT, -- valuation-sources key; required when class='variable_rate'
source TEXT NOT NULL, -- 'buckets-seed' | 'asset-profile' | 'stablecoin-top20' | 'manual'
status TEXT NOT NULL DEFAULT 'active',
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (chain_id, address)
);- Seed migration: every current
buckets.tsentry (source = 'buckets-seed',wallet_tracked = false), the 13 asset-profile tokens (class = 'variable_rate',wallet_tracked = true; theassetstable is ticker-keyed with no address column, so the registry is where addresses live), the initial top-20 stablecoin set + native ETH + WETH + BTC wrappers (class = 'par',wallet_tracked = true). buckets.tsbecomes a resolver over the runtime-loaded registry (vialoadRegistries) plus the existing PT-promotion and EXCLUDED-fallback logic. The static map is deleted after the seed ships.- Sync script
scripts/sync-portfolio-tokens.ts(thesync-carries.ts--approvepattern): pulls the top-20 stablecoins by TVL (DefiLlama stablecoins API, mainnet addresses), diffs against the registry, proposes additions; also checks every asset-profile token has a row and everyvariable_raterow resolves arate_source. Ranking drift and gaps alert (WS8 extension) rather than silently mutating. - Coverage extensions later = registry rows (or a flipped
wallet_trackedflag), not code changes.
3.4 The wallet venue
Venueunion + the SQL venue CHECKs (snapshots + flows tables) gain'wallet'(additive migration).- Reader
readers/wallet.ts: multicallbalanceOfover thewallet_trackedregistry rows pluseth_getBalancefor the native sentinel. EmitsPositionRead { venue:'wallet', positionKey: 'wallet:token:{addr}', qtyRaw: balance, indexRaw: null, accountingAsset: {token} }. M9 honesty unchanged (skip failed reads, skip true zeros); display keeps the WS8 $1 dust convention. - Yield: for
variable_ratetokens the composed index is venue index 1 × wrapperRate — the existingvaluation-sources.tspath (token_yield_apy.share_ratehistory, on-chain getter fallback). This is the same mechanism that already attributes wstETH-collateral appreciation on Aave, applied to a bare balance.partokens have yield ≡ 0 by construction. - Flows: ERC-20 Transfer log scans per (wallet, token), both directions, as
transfer_in/transfer_out, with incremental block cursors (thechain_scan_cursorspattern from 026). Existing self-wallet handling (self-wallet.ts) applies to transfers between one uid's tracked wallets. - Native ETH flows: no Transfer logs exist and internal txs are invisible to log scans, so flows are derived as balance diffs at snapshot/backfill anchors. This is exact, not an approximation: for a par asset yield ≡ 0, so Δvalue = net flow by the yield invariant (gas spend naturally lands as transfer_out). Document the invariant next to the derivation.
- Backfill: ERC-20 = full Transfer replay from the wallet's first activity (WS5 open-only replay +
portfolio_backfill_state); ETH = daily archiveeth_getBalanceanchors (1 read/wallet/day), diffs as flows.
3.5 Universe completion
- fTokens (repo lending): enumerate the Fluid Liquidity Layer fToken list on-chain and add every based-underlying fToken (fWETH, fwstETH, fGHO, fUSDe, …) with
role: 'ftoken'. The old USD-only restriction influid-ftokens.tsis obsolete now that all three books chart. - Morpho Blue exhaustive (repo lending + carries): widen
morpho_market_registryingestion from ~200 curated rows to every mainnet market (CreateMarket log scan since Blue deployment; if the Blue API is used instead, remember its address filters are checksum-sensitive — use lowercaseuniqueKey_in). Loan/collateral tokens book-map viaportfolio_tokens; an unmapped loan asset lands EXCLUDED and alerts, never mis-charts. - MetaMorpho factory universe (money market funds): factory-driven vault discovery (MetaMorpho v1.0/v1.1 factories, Vaults V2) as the PORTFOLIO universe only; the Repo markets page keeps its curated registry. Euler vaults stay from the current curator registry. Morpho V2 indexing gap = issue #68.
- Managed strategy vaults: add yoETH, earnETH, iETHv2, yoUSD, yvUSD, fLiteUSD to the erc4626 universe with
role: 'managed'. Verify each is a conformant ERC-4626 (convertToAssetsmonotone, share Transfer events on deposit/withdraw) before enabling — fLiteUSD (Fluid Lite) needs an explicit check.
3.6 Discovery layer — how this scales
The scaling problem: exhaustive universes (1000+ Morpho markets, hundreds of vaults, dozens of tokens) × wallets × the 6h cron + JIT page loads. Reading every wallet against every universe key does not survive Tranche 4.
Design: per-wallet position discovery, so read cost is proportional to a wallet's own activity, not the universe.
- New table
portfolio_wallet_index (uid, wallet, chain_id, venue, universe_key, first_seen_block, last_scanned_block). - Populated by event scans per wallet: Morpho
Supply/SupplyCollateral/BorrowwithonBehalf = wallet; ERC-4626 share Transfer to/from the wallet; ERC-20 Transfer for the wallet venue. Incremental cursors advance each cron run; a new wallet gets a bounded historical discovery scan at enrollment (same trigger as the backfill floor). - The readers keep the WS3 universe-injected contract unchanged; only the loader changes:
loadRegistriesintersects each big universe with the wallet index (therestrictRegistries/ open-set mechanism already does exactly this shape for backfill replay). Small bounded universes stay full-sweep: Aave/Spark (~85 reserves), Pendle registry markets, Fluid NFTs (owner-enumerated on-chain, self-describing). - Safety net: a low-cadence (weekly) full-universe reconciliation sweep per wallet; any position found that the index missed alerts and is added. A discovery bug degrades to "found a week late + alert", never "silently wrong PnL".
3.7 API and UI
PositionRowgainscategory: Categoryandcharted: boolean;SummaryResponsegains per-category aggregates (value, net yield, realised APY where the span qualifies; Idle assets report value only).- Dashboard: the positions area becomes seven category sections in the fixed order of §1, each with a subtotal header; empty sections do not render. Idle assets render muted with no APY column (they are the "unproductive" callout). Book PnL charts are unchanged — books stay the accounting dimension, category is presentation.
- Landing copy: replace the "This is not a complete wallet tracker" paragraph with the new coverage promise (based positions across covered venues plus wallet balances: variable and fixed rate assets, idle stablecoins, ETH and BTC). No em-dashes in user-facing copy.
3.8 Idle assets and non-USD fiat
Idle par tokens in a based book (USDC, WETH, WBTC…) chart into their book's equity curve with zero yield — flat lines that make idle capital visible next to productive positions. Non-based fiat stables (EURC and any other top-20 fiat coin) get book = NULL: they render in the Idle assets section with a USD valuation but are never blended into any book curve — an FX move is not yield, and charting it would violate the book-isolation invariant. If a EUR book is ever wanted, it is a registry + prices addition, not a redesign.
4. Migrations & ops
- One additive migration: venue CHECK extensions,
portfolio_tokens(+ seed),portfolio_wallet_index, cursor rows — with GRANTs to the app role (repeated past gotcha) — applied viascripts/ops/migrate.shbefore the release merge so prerendered/ISR pages never race a missing relation (the v0.9.0 lesson; any prerendered reader of new relations gets an information_schema guard). - WS8 alert extensions: unmapped accounting asset above the $1 floor, top-20 stablecoin drift,
variable_ratetoken without a resolvable rate source, discovery reconciliation drift. - Backfill ops follow the existing v0.4.0 runbook (minutely drain cron, open-only replay, writer advisory lock); repair re-runs remain destructive full re-derivations and are treated as such.
5. Tranches
Each tranche is one PR into staging (feature branch → PR → review posted on the PR → merge), docs updated in the same PR, and live-mainnet verification before merge. Order chosen so user-visible wins ship first and each tranche stands alone.
T1 — Taxonomy over existing data (no migration).Category type + derivation in classifyLegs; e-mode gate removal (same-book-carry); role on Erc4626Vault; API fields; the seven UI sections. Ships: every existing position re-homed into its category, and previously-hidden no-e-mode loops appear with full history. Verify: a real no-e-mode same-book loop charts as a carry with sane history; category counts reconcile against the old venue grouping.
T2 — Token registry + wallet venue (forward-only).portfolio_tokens migration + seed; buckets.ts → registry resolver; readers/wallet.ts (variable-rate profile tokens, idle par set, native ETH); ERC-20 Transfer flow scanning forward from enrollment; managed-strategy vaults into the erc4626 universe (role: 'managed', after 4626 conformance checks); landing copy update. Ships: reUSD (and every profile asset) appears as a Variable rate asset; idle stables/ETH/BTC appear. Verify: the reUSD test wallet from Fred's report; a par asset shows exactly zero yield; EURC shows valued-but-not-charted; yield-invariant residual clean on a wallet with mid-window transfers.
T3 — Wallet venue history backfill. Transfer replay from first activity; native-ETH daily anchors; integration with portfolio_backfill_state and the open-only replay. Verify: backfilled curve matches an independently computed balance/share-rate series for one wallet; no flow double-count at the live/backfill boundary.
T4 — Discovery layer + exhaustive Morpho + fToken expansion.portfolio_wallet_index + event-scan cursors; loader intersection; morpho_market_registry widened to all mainnet markets; full based-fToken set. Verify: a wallet with an obscure (previously unregistered) Morpho lend position is discovered and charted; snapshot-cron wall time stays flat as the universe grows (measure before/after).
T5 — MetaMorpho factory universe + reconciliation sweep. Factory discovery for money market funds; weekly full-universe reconciliation
- drift alert. Verify: a deposit into a brand-new (unregistered) MetaMorpho vault is discovered.
T6 — Registry sync automation + alert wiring.sync-portfolio-tokens.ts (top-20 stablecoins, profile-asset completeness), WS8 alert extensions, ops docs. Verify: simulated top-20 drift raises the alert; a new asset profile lands in the portfolio universe with no code change.
6. Verification bar (every tranche)
npx tsc --noEmit + npm test clean; on-chain verification of any new or changed reader against live mainnet data (real wallets; a Tenderly VNet fork where a case cannot be found organically); yield-invariant residual checks on every new leg type; docs (docs/metrics.md, portfolio page docs) updated in the same PR; review posted on the PR before merge into staging.
7. Decision log & open items
Settled 2026-07-15 (Fred):
- Curator vaults = Money market funds; strategy vaults = Managed strategy funds (own categories).
- Carry = any same-book loop; e-mode gate dropped.
- Out-of-taxonomy positions stay hidden (API/alert only); non-USD fiat stables ARE covered, as Idle assets.
- Bare PTs = Fixed rate assets; "yield assets" renamed Variable rate assets.
Taken in this plan (flag if wrong):
- Debt-free Fluid vault NFTs classify as Repo lending (collateral parked in the Liquidity Layer earns supply yield; nothing is borrowed against it).
- Non-based fiat idle assets are valued in USD but never charted (§3.8).
- Native-ETH flows derived from balance diffs (exact for a zero-yield asset).
Open:
- fLiteUSD ERC-4626 conformance (verify before enabling in T2).
- The
assetstable has no address column; the registry owns addresses. A follow-up could backfill addresses intoassetsfor joinability. - srUSDe share-rate deviation note (pre-existing) still open; it becomes more visible once srUSDe-family tokens are wallet-tracked.
Addendum, 2026-08-10 — the BTC book was retired
This plan's coverage rule spoke of THREE books. There are now two, USD and ETH.
Bitcoin and its wrapped forms (WBTC, cbBTC, LBTC, eBTC) moved from "mapped, so charted" to declared exclusions in portfolio_tokens (migration 077: book NULL, wallet_tracked false), joining EURC, XAUt, apxUSD and the governance tokens in the permanently-out set. They are valued at market in USD under "Other -> Holdings outside coverage" and never charted; bare balances surface through the disclosed-holdings path rather than the Idle wallet sweep, so a bitcoin-priced figure never renders inside the USD statement.
Two of this plan's settled points read differently in that light:
- "Out-of-taxonomy positions stay hidden (API/alert only)" is unchanged, but the BTC rows are DECLARED, so the WS8 unknown-asset alert stays quiet for them by design (the same treatment 049 gave EURC).
- A
paridle token is no longer "a stablecoin, ETH, a BTC wrapper". The BTC wrappers left that set with the book.
The coverage POLICY itself is otherwise intact: an asset enters a book only when its base denomination is verifiable AND creddit can state a return in that denomination. The retirement is the first time the second half of that sentence removed a book rather than an asset. Full rationale and end-state: Portfolio -> Retired: the BTC book.