Skip to content

built Built. This is a decision record, not documentation.

What is still current: Superseded in part by the cross-currency taxonomy plan (2026-09-18), which added an eighth category (Smart repo lending), broadened Cross-currency borrowing to every borrow no single currency runs through, and replaced the catch-all band with Not covered. portfolio_tokens remains the source of truth for the taxonomy (src/lib/portfolio/types.ts). The BTC category was retired, which is what the addendum at the foot of the page records.

Landed: migration 049 (v0.10.0); the BTC-book addendum of 2026-08-10 by migration 077 (v0.36.0)

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

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
1Repo lendingAny 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).
2Carry tradesAny 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).
3Money market fundsCurated ERC-4626 vault deposits: MetaMorpho / Euler curator funds (eventually the full MetaMorpho factory universe).
4Managed strategy fundsThe managed-strategy vaults (yoETH, earnETH, iETHv2, yoUSD, yvUSD, fLiteUSD).
5Fixed rate assetsBare Pendle PT holdings (underlying based). A PT pledged as same-book collateral stays a carry trade.
6Variable rate assetsBare 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).
7Idle assetsUnproductive 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 the ComposedIndex / 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_out kinds 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:

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:

CategoryRule
repo_lendingAave/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_tradeAave/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_funderc4626 leg with role: 'curator'.
managed_strategy_funderc4626 leg with role: 'managed'.
fixed_rate_assetpendle-venue leg (bare PT).
variable_rate_assetwallet-venue leg whose registry row has class = 'variable_rate'.
idlewallet-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:

sql
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.ts entry (source = 'buckets-seed', wallet_tracked = false), the 13 asset-profile tokens (class = 'variable_rate', wallet_tracked = true; the assets table 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.ts becomes a resolver over the runtime-loaded registry (via loadRegistries) 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 (the sync-carries.ts--approve pattern): 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 every variable_rate row resolves a rate_source. Ranking drift and gaps alert (WS8 extension) rather than silently mutating.
  • Coverage extensions later = registry rows (or a flipped wallet_tracked flag), not code changes.

3.4 The wallet venue ​

  • Venue union + the SQL venue CHECKs (snapshots + flows tables) gain 'wallet' (additive migration).
  • Reader readers/wallet.ts: multicall balanceOf over the wallet_tracked registry rows plus eth_getBalance for the native sentinel. Emits PositionRead { 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_rate tokens the composed index is venue index 1 × wrapperRate — the existing valuation-sources.ts path (token_yield_apy.share_rate history, on-chain getter fallback). This is the same mechanism that already attributes wstETH-collateral appreciation on Aave, applied to a bare balance. par tokens 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 (the chain_scan_cursors pattern 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 archive eth_getBalance anchors (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 in fluid-ftokens.ts is obsolete now that all three books chart.
  • Morpho Blue exhaustive (repo lending + carries): widen morpho_market_registry ingestion 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 lowercase uniqueKey_in). Loan/collateral tokens book-map via portfolio_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 (convertToAssets monotone, 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 / Borrow with onBehalf = 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: loadRegistries intersects each big universe with the wallet index (the restrictRegistries / 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 ​

  • PositionRow gains category: Category and charted: boolean; SummaryResponse gains 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 via scripts/ops/migrate.sh before 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_rate token 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 assets table has no address column; the registry owns addresses. A follow-up could backfill addresses into assets for 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 par idle 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.

Private documentation. creddit.xyz