Skip to content

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

What is still current: Its M1 to M9 methodology is still normative and Metrics cites it by number. Everything else (the routes, the modules, the engine wiring, the two-reader framing) was rebuilt and is history.

Landed: migrations 042 and 043 (v0.3.0); deviations recorded in portfolio-execution-log.md

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

Portfolio (read-only) — implementation plan ​

Verified against the repo and on-chain interfaces (2026-07-09). Interface claims below (event signatures, topic positions, resolver functions) were checked against verified sources and live mainnet reads; repo claims were checked against the working tree.

Prereq reading for every executing agent: AGENTS.md (all of it), docs/database.md, docs/data-pipeline.md, docs/deployment.md.

This plan ships the read-only portfolio feature: a signed-in user sees their positions on creddit-covered venues and an honest, yield-only performance view. No execution, no automation. Each workstream below is one PR into staging.


1. Product definition ​

  • /portfolio becomes the first nav item, visually distinct; the three visible pages (Repo markets, Carry trades, Asset profiles) group under an EXPLORE section label; /strategies stays hidden as today. Brand chrome stays as settled: uppercase mono nav, uppercase CRE-DD-IT logo.
  • Sign-In with Ethereum creates the creddit account (wallet address = uid). The same account gates the AI assistant; chat usage limits stay keyed by uid (they already are). Signed-out /portfolio shows the SIWE prompt and explains that one account covers portfolio tracking and the assistant.
  • The portfolio shows only positions on venues creddit covers (Aave v3, SparkLend, Morpho Blue covered markets, Morpho/Euler curator vaults, Pendle PT markets; Fluid vaults follow in WS7). It is a fixed-income book monitor, not a wallet tracker: no token balances, no net-worth framing.
  • Performance is yield-only, partitioned into three switchable books by accounting asset: USD / ETH / BTC, each denominated in its own unit. Price moves of ETH/BTC against USD never appear inside a book.
  • Two switchable valuation marks: MARKET (secondary-market prices) and REDEMPTION (fair value from on-chain redemption/share rates). A wedge badge appears in both modes when the two marks diverge beyond a threshold on any held asset.
  • History: block-anchored 6h snapshots from registration onward, plus a 90-day archive-replay backfill at signup so accounts are not born with an empty chart (positions older than 90 days enter as an opening balance at the window boundary). Current positions always come from JIT RPC on page load ("6h stays, now = JIT RPC").

2. Methodology decisions (locked — do not re-litigate in PRs) ​

#DecisionRule
M1Book partitionEach leg maps to USD, ETH, or BTC via its accounting asset (see buckets.ts spec in WS3). The snapshot row's book column always stores the leg's native book; EXCLUDED is reserved for unknown assets. Inclusion is computed at read time per position group: Aave/Spark group = the whole account per protocol (pooled cross-collateral), Morpho Blue group = per market, vault shares and PTs are single-leg groups. Rules: (a) a group with no debt legs is included, per-leg, by each leg's book; (b) an Aave/Spark group with debt is included only if the account has e-mode enabled (emode_category != 0) AND all legs resolve to the same book — leveraged positions qualify only as e-mode carries, matching the /carries universe (e-mode restricts borrows, not supplies, so the same-book check is still required on top); (c) any other group with debt is included only if all legs share one book (Morpho Blue markets are same-book pairs in the covered registry). Everything else is shown in the "Outside the yield book" section (valued, not charted, never blended into PnL). Classification at read time keeps history valid when a user's account structure changes later.
M2Yield attributionYield between two snapshots = share/scaled quantity x compounding-index ratio, in the position's book unit. Never diff raw balances; never average per-snapshot APYs (annualizeRatio convention holds). The index must be the COMPOSED index composeIndex(venueIndex, rate) = venueIndex × (accounting-asset→book-unit redemption rate), both read at the same block, so a leg's book value is proportional to it and the invariant attributedYield == ΔbookValue − netFlow holds in every mark. For a book-unit accounting asset (WETH/USDC/WBTC) and an ERC-4626 vault share the rate is IDENTITY_RATE (1) and the composed index is the bare venue index (degenerate); for a yield-bearing wrapper held as an Aave/Spark/erc4626 leg (or owed as debt) it also carries the wrapper's staking/redemption appreciation — the collateral (or funding) yield the carry composition rule requires. Enforced in the types: LegSnapshot.indexRaw is a branded ComposedIndex producible only by composeIndex (src/lib/portfolio/valuation.ts), so a bare venue index is a compile error. Morpho RAW collateral (no venue index) stays accrual='value', attributed from the mark's value series.
M3FlowsExternal flows are detected from events at block precision, valued at their flow block, netted per tx hash (a leverage loop is not a deposit). Charts show cumulative yield net of flows; TWR is used for any percentage.
M4LiquidationsLiquidationCall (Aave/Spark) and Liquidate (Morpho Blue) are realized-loss events, marked on the chart, never classified as user withdrawals. Fluid has no position-level liquidation event (tick-level LogLiquidate carries no nftId); WS7 classifies liquidations by state diff (see WS7).
M5MarksMARKET = DeFiLlama market prices (token_basis series + JIT). REDEMPTION = on-chain rates (share rates, liquidity indices, exchange prices); BTC/ETH wrappers without an on-chain rate (WBTC, cbBTC) are par by convention in REDEMPTION mode. Protocol oracles are never used for PnL marks. Flows are valued in the same mode as the curve they adjust, in both modes.
M6Pendle PTREDEMPTION mode = pull-to-par accrual at the entry implied yield (entry fill from the acquisition flow event; if entry predates our data, synthetic basis = oracle rate at first snapshot, labeled). MARKET mode = pt_to_asset_rate TWAP. Never blended. At maturity the PT rolls to underlying at par.
M7Wedge badgeIf abs(MARKET - REDEMPTION)/REDEMPTION > 0.5% for stables or > 1.0% for ETH/BTC wrappers on any held asset, show the divergence badge in both modes. Constants in one place, tunable.
M8Exclusions (v1)Rewards and points (permanent UI label: "Base yield only. Rewards and points are not included."), gas costs, annualized figures under 30 days of observation, blended cross-book totals, positions held via proxies/Safes not signed in directly, multi-wallet accounts, MWR/IRR.
M9Data honestyFailed reads are skipped, never written as zero; empty (0x) returndata from an eth_call counts as a failed read (a codeless address "succeeds" with 0x). Gaps render as gaps, never interpolated. History never extends beyond the coverage floor (2025-05-21; PT realized yield only from rpc-basis rows, ~late June 2026).
M10UI copyNo em-dashes anywhere in user-facing copy. Numbers mono tabular-nums. Never the CSS help cursor.

3. Architecture ​

Hybrid, reusing the existing pipeline patterns:

  1. Valuation spine (cron, 6h): block-anchored multicall snapshots of every registered wallet's share/scaled quantities plus the same-block index, into an append-only history table. Pattern: scripts/refreshers/lending-positions.ts.
  2. Flow ledger (cron, same tick): getLogsChunked cursor scans of position token Transfer streams and protocol events, filtered to the registered wallet set, per-tx netted. Cursors live in onchain_credit.chain_scan_cursors (scopes portfolio:*). No rindexer changes.
  3. Freshness (JIT): on /portfolio load, a live multicall for the signed-in wallet plus a mini flow scan since the last snapshot. A position entered five minutes ago appears on next page load.
  4. History at signup (backfill): probe-first archive replay over the 6h grid back to the coverage floor, queued per account, concurrency-capped.
  5. Identity: the existing SIWE stack, factored out of the chat namespace, plus an accounts table.

RPC: ETHEREUM_RPC_URL (current state), ETHEREUM_ARCHIVE_RPC_URL (backfill, flow-block index reads). Batching through src/lib/data/rpc-batch.ts (multicall3, getLogsChunked, rpcRequest, ERC20_TRANSFER_TOPIC); block-pinned archive calls through src/lib/data/rpc.ts (ethCallAtRaw / rpcRequest, blockByTimestamp). Historical USD marks follow the batchHistorical pattern in scripts/refreshers/token-basis.ts / scripts/backfill-token-basis.ts (per-timestamp calls 429 at scale; never use them in loops).


4. Workstreams ​

Execution order: WS1 → (WS2 ∥ WS3) → WS4 → WS5 → WS6 → WS8. WS7 (Fluid) is a fast follow, explicitly outside the launch gate. Every PR updates the relevant docs/ pages in the same PR (AGENTS.md §6) and appends any new test file to the explicit "test" list in package.json. Concurrent agents: use your own git worktree per AGENTS.md §6.

WS1 — Account foundation (auth refactor + accounts table) ​

Goal: one neutral SIWE account layer serving portfolio and chat.

  • Create src/lib/auth/session.ts by moving the session machinery out of src/lib/agent/session.ts: issueNonce, consumeNonce, verifySiwe, expectedDomain, cookieValueFor, cookieOptions, verifyChatCookie (rename to verifySessionCookie). Keep chat-specific config (getChatConfig, model gating) in src/lib/agent/; it imports from src/lib/auth/.
  • Cookie: new name creddit_session. Read path accepts creddit_chat as a fallback during transition; write path issues only creddit_session. Secret: SESSION_SECRET, falling back to CHAT_SESSION_SECRET if unset (server already has the latter). SIWE statement becomes account-neutral copy (mention portfolio + assistant; no em-dashes).
  • Migration scripts/sql/042-accounts.sql:
    • onchain_credit.accounts(uid text PRIMARY KEY CHECK (uid = lower(uid)), created_at timestamptz NOT NULL DEFAULT now(), created_block bigint, last_seen_at timestamptz).
    • Seed from existing chat_profiles/chat_conversations uids (INSERT ... SELECT DISTINCT ... ON CONFLICT DO NOTHING).
    • GRANT SELECT, INSERT, UPDATE ON onchain_credit.accounts TO onchain_credit;
  • On successful SIWE verify: upsert accounts row (this is "account creation"; created_at is the tracking anchor; created_block = eth_blockNumber at verify time, best effort, nullable).
  • New routes under src/app/api/auth/: nonce (GET), verify (POST), me (GET, returns { address } from cookie or 401), signout (POST, clears cookie). Wire the existing chat sign-in client (src/components/chat/siwe.ts) to these routes and delete src/app/api/chat/session in the same PR (client and route ship atomically; the cookie fallback keeps live sessions valid). Chat API routes switch to verifySessionCookie.
  • src/lib/agent/wallet-positions.ts (the chat's oracle-based Aave getUserAccountData read) stays as-is for chat; it is a different lens (oracle USD totals) and is out of scope for portfolio. Do not unify.
  • Move/extend src/lib/agent/session.test.ts → keep green; add tests for cookie fallback and the accounts upsert logic (pure parts). Update the package.json test list.

Acceptance: npx tsc --noEmit clean; npm test green; on staging, sign in via the agent dock still works, accounts row appears, /api/auth/me returns the address, old creddit_chat cookies still authenticate. Docs: docs/architecture.md (auth section), docs/database.md (accounts).

WS2 — Nav restructure + portfolio shell + connect chrome ​

Goal: /portfolio exists with sign-in flow; nav communicates the new hierarchy. Can land before any portfolio data exists (shows JIT-only view later).

  • src/components/layout/AppSidebar.tsx + MobileNav.tsx: PORTFOLIO as the first item with a distinct treatment (amber accent tick or filled marker; keep uppercase mono, 1px borders, no gradients); an EXPLORE group label above the existing three items. /strategies stays hidden.
  • Global connect chrome in the sidebar foot region (above STATUS): signed-out CONNECT WALLET button; signed-in truncated address (0x12ab…34cd) + SIGN OUT. Uses /api/auth/me on mount. Mobile equivalent in MobileNav.
  • src/app/portfolio/page.tsx: prerendered public shell (title, one-paragraph explanation, methodology note) + a "use client" PortfolioClient component that probes /api/auth/me and renders either the SIWE prompt (copy: one account for portfolio tracking and the AI assistant) or the portfolio view (skeleton until WS6). Pattern: src/app/agent/page.tsx for the shell+client split, but do not copy its robots: { index: false }; the /portfolio public shell should be indexable. Per-user data only flows through authenticated APIs.

Acceptance: nav renders correctly at all shell zoom breakpoints (1280 / 1536 / 1920 widths; --shell-zoom in globals.css); sign-in from /portfolio works end to end on staging; signed-out and signed-in states both render. Docs: new docs/portfolio.md stub; docs/architecture.md nav section.

WS3 — Data model, venue readers, PnL engine (lib only, no cron) ​

Goal: all pure logic in place and unit-tested before any scheduling.

  • Migration scripts/sql/043-portfolio.sql (follow the 026 conventions: chain_id keys, lowercase addresses, block-anchored, basis column, GRANTs):
    • portfolio_position_snapshots(chain_id int, wallet text, venue text, position_key text, snapshot_ts timestamptz, block_number bigint, qty_raw numeric, index_raw numeric, qty_underlying numeric, accounting_asset text, book text CHECK (book IN ('USD','ETH','BTC','EXCLUDED')), emode_category int, value_market numeric, value_redemption numeric, basis text, PRIMARY KEY (chain_id, wallet, venue, position_key, snapshot_ts)). emode_category is the account-level getUserEMode result, denormalized onto Aave/Spark legs (NULL elsewhere); it feeds the M1(b) gate at read time. Append-only history; the only carve-out is the windowed delete+insert repair/backfill (WS5). snapshot_ts is ALWAYS the aligned 6h window (alignedWindowIso in scripts/refreshers/shared.ts); block_number is the anchor block actually read. position_key examples: aave:reserve:<asset>:supply, aave:reserve:<asset>:debt, morpho:market:<id>:supply, morpho:market:<id>:collateral, morpho:market:<id>:debt, vault:<address>, pendle:pt:<address>.
    • portfolio_flow_events(chain_id int, tx_hash text, log_index int, wallet text, venue text, position_key text, kind text CHECK (kind IN ('deposit','withdraw','borrow','repay','transfer_in','transfer_out','liquidation')), block_number bigint, ts timestamptz, asset text, amount_raw numeric, amount_underlying numeric, value_market numeric, value_redemption numeric, basis text, PRIMARY KEY (chain_id, tx_hash, log_index)) — append-only.
    • portfolio_backfill_state(uid text PRIMARY KEY REFERENCES onchain_credit.accounts(uid), status text CHECK (status IN ('queued','running','done','empty','error')), floor_ts timestamptz, error text, updated_at timestamptz).
  • src/lib/portfolio/buckets.ts: accounting-asset → book map with unit tests. There is no machine-readable accounting-asset column today; build the map from concrete sources: token_basis.numeraire ('USD'|'ETH'|'BTC', scripts/sql/020), the wrapper conventions in scripts/refreshers/token-yields.ts (BASE_YIELD_TOKENS) and scripts/refreshers/yield-token-assets.ts, and the asset field in src/data/curator-vaults.ts (note: curator-vaults.ts is USD-only today; the ETH-denominated strategy vaults live in the token-yields registry). Hand- maintain the remainder in buckets.ts. Unit test: every asset in lending_reserves, pendle_markets, and the curator-vault registry resolves to a book or is knowingly EXCLUDED. Unknown assets → EXCLUDED + the WS8 alert. ETH book: WETH and ETH-accounted wrappers (wstETH, weETH, rETH, osETH, ezETH, …). BTC book: WBTC, cbBTC, LBTC, eBTC. USD book: stables and USD-accounted wrappers (USDC, USDT, GHO, USDe, USDtb, sUSDe via USDe, PTs of USD underlyings, USD curator vaults).
  • Venue readers src/lib/portfolio/readers/{aave.ts,sparklend.ts,morpho-blue.ts,erc4626.ts,pendle.ts}, one shared signature: readPositions(wallets: string[], blockTag: string) → PositionRead[] where PositionRead = { wallet, venue, positionKey, qtyRaw, indexRaw, accountingAsset, decimals }. All reads via multicall3, all pinned to the caller's anchor block, failed inner reads skipped (never zero; empty 0x returndata = failed read):
    • aave/sparklend: universe from onchain_credit.lending_reserves (columns a_token, variable_debt_token). Per wallet: getUserConfiguration bitmask (2 bits per immutable reserve id, exactly as scripts/refreshers/lending-positions.ts decodes it) → for flagged reserves read scaledBalanceOf(wallet) on aToken and variableDebtToken, plus the Pool's getReserveNormalizedIncome(asset) / getReserveNormalizedVariableDebt(asset) at the same block (these compound to the queried block; do NOT use the data provider's stored liquidityIndex/variableBorrowIndex, which only update on interactions). Also read getUserEMode(wallet) (batch 1000, as lending-positions.ts does) and stamp it on the account's legs (emode_category); snapshot ALL legs regardless of e-mode or book — the M1 inclusion rules are applied at read time, never by skipping reads. E-mode PT collateral positions come through the same path.
    • morpho-blue: universe from onchain_credit.morpho_market_registry. position(marketId, wallet) → (supplyShares u256, borrowShares u128, collateral u128), plus market(marketId) totals at the same block. Share→asset conversion MUST use Morpho's virtual-offset math: assets = shares * (totalAssets + 1) / (totalShares + 1e6) (VIRTUAL_SHARES = 1e6, VIRTUAL_ASSETS = 1). market() totals accrue only to lastUpdate; accrue interest to the anchor block via the IRM's borrowRateView (Morpho's expectedMarketBalances pattern) so index-ratio yield math stays exact.
    • erc4626: universe from src/data/curator-vaults.ts. balanceOf(wallet)
      • convertToAssets(1e18) divided by 10^rateDivisorPow10 from the registry entry (the repo convention; shareDecimals != assetDecimals on several vaults, see scripts/backfill-usd-curator-vaults.ts). Skip vaults with totalSupply() == 0 (convertToAssets is garbage there). Before wiring, audit the registry for codeless addresses (at time of writing 'Euler Earn USDC' 0xe4783824593a50bfe9dc873204cec171ebc62de0 has no mainnet code) and drop/fix them via the sync script.
    • pendle: universe from onchain_credit.pendle_markets (active + recent matured). PT balanceOf(wallet). The rate helpers currently live in scripts/refreshers/pendle-markets.ts; src never imports from scripts (tsconfig excludes it), so lift the shared decode/rate helpers into src/lib/portfolio/pendle-rates.ts (or src/lib/data/) and have the refresher re-import from there.
  • src/lib/portfolio/pnl.ts: pure functions, heavily unit-tested with fixture series: window yield from (qty, index) pairs; flow adjustment and per-tx netting; TWR segmentation and geometric linking; dual-mark valuation; PT pull-to-par accrual; liquidation loss events; the M1 read-time inclusion rules (grouping, e-mode gate, same-book check); the wedge computation (M7); the 30-day annualization gate. No DB or RPC imports in this module.

Acceptance: npm test green with the new test files listed; a scratch script (not committed) reads a known whale wallet at a pinned block and returns plausible positions for every reader (verify against Etherscan/protocol UI, per the verify-before-reporting rule). Docs: docs/database.md (three tables), docs/metrics.md (full portfolio methodology section: M1–M9, formulas).

WS4 — Snapshot cron + flow scanner + JIT live reads ​

Goal: the 6h pipeline and the page-load freshness path.

  • Prerequisite inside this PR: widen getLogsChunked in src/lib/data/rpc-batch.ts — address: string | string[], topics: (string | string[] | null)[] (eth_getLogs OR-array semantics are standard and the function body already passes through), and widen its log type to include data, transactionHash, logIndex, address (the flow ledger PK and amounts need them). Type-level change only; verify no existing caller breaks.
  • scripts/refresh-portfolio.ts (entrypoint mirroring refresh-lending-positions.ts): pick one anchor block; snapshot registered wallets through every reader; then flow scan since last cursor. Wallet selection: accounts LEFT JOIN portfolio_backfill_state, snapshot when status IS NULL OR status NOT IN ('queued','running') (accounts seeded from chat uids have no state row and must not be silently skipped; running excluded so backfill and live writes never interleave for one wallet). snapshot_ts = aligned 6h window per WS3.
    • Transfer streams — the scan set is aTokens, variableDebtTokens, ERC-4626 share tokens, and PTs in the universes (variableDebtTokens DO emit standard ERC-20 Transfers: mint from 0x0 on borrow, burn to 0x0 on repay; without them borrow/repay flows are invisible and M3's loop netting breaks). For each token: getLogsChunked with ERC20_TRANSFER_TOPIC, the registered wallet set as a padded-address array at topic1 (out/burn) and once at topic2 (in/mint). Note a/debt-token Transfer amounts are balance units (scaled x index); classify from the event, value per M3 at the flow block.
    • Protocol events: Aave/Spark Pool LiquidationCall. Morpho Blue singleton (0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb) — the owner is indexed in all seven events but the topic position varies: wallet-set array at topic3 for Supply/Repay/SupplyCollateral (onBehalf) and for Liquidate (borrower); at topic2 for Withdraw/Borrow/WithdrawCollateral (onBehalf; receiver is topic3 and may also be scanned to catch withdrawals routed to the wallet).
    • Classification: group logs by tx hash, net per (wallet, position_key), classify kind per M3/M4; value at the flow block in both marks (ethCallAtRaw for the index; batchHistorical pattern for market price).
    • Cursors: chain_scan_cursors scopes portfolio:transfers:<token> and portfolio:events:<venue>, with a 64-block safety margin below the anchor block (replicate the private CURSOR_SAFETY_BLOCKS = 64 from lending-positions.ts or export it from a shared module).
  • JIT path src/lib/portfolio/live.ts: single-wallet current read (all readers pinned to one block) + mini flow scan from the wallet's last snapshot block. Used by the summary API (WS6). Rate-limit: one live refresh per wallet per 60s (in-memory, single process).
  • Ops (required before launch; alerting itself is WS8):
    • Per-script lock in scripts/run-cron.sh, keyed by checkout so the prod and staging working copies on the shared box never collide: flock on $LOG_DIR/$(basename "$DIR")-$(basename "$SCRIPT" .ts).lock, exit 0 with a log line if held.
    • Crontab addition (manual server step, listed in the PR body): 50 */6 * * * /opt/onchain-credit/scripts/run-cron.sh refresh-portfolio.ts.
  • Staging user-data handling: the nightly reseed drops and fully restores the staging DB (scripts/ops/reseed-staging.sh); there is no table-exclusion mechanism. Instead, add accounts, portfolio_position_snapshots, portfolio_flow_events, portfolio_backfill_state to the TRUNCATE block in scripts/ops/scrub-staging-pii.sql (chat-tables precedent) and extend the fail-closed leak check. For testing, add scripts/ops/seed-portfolio-fixtures.ts (registers 2–3 public whale wallets on staging); note it must be re-run after each nightly reseed, or touch /root/.reseed-paused during multi-day validation.

Acceptance: two consecutive manual runs against the staging DB produce consistent snapshots for fixture wallets. Flow-scan correctness is validated deterministically: pick a whale that demonstrably deposited (and one that borrowed) between two chosen anchor blocks and assert the scan over that range yields exactly the expected flow events with correct kind, tx hash, and block (a live test deposit is optional and Fred-assisted; agents cannot transact). JIT read returns within ~2s for a wallet with positions on 3 venues. Docs: docs/data-pipeline.md (new job, cadence, cursors, reseed/scrub rule), docs/deployment.md (crontab line, lock behavior).

WS5 — Registration backfill ​

Goal: a new account sees history to the floor, without letting free wallets mint archive load.

  • Enqueue (built here, not WS1): modify src/app/api/auth/verify so that on account creation, and on first login of a pre-existing account with no portfolio_backfill_state row, it inserts status='queued'.
  • scripts/backfill-portfolio-wallet.ts --uid 0x…:
    1. Probe (cheap, well-defined): one multicall round of current positions across all readers, plus ONE getLogsChunked sweep over the 90-day window across all universe tokens with the wallet as topic filter (cheap with address-array support from WS4). empty = no current positions AND zero transfers in the window (a held-then-closed wallet has transfers, so it is NOT empty and gets its history). First activity block = first log of that sweep.
    2. Replay: from max(signup − 90 days, first activity block, coverage floor 2025-05-21) to now on the aligned 6h grid (~360 grid points max) via archive multicalls (ethCallAtRaw), writing snapshots identical in shape to live ones (basis = backfill vs live); flow scan over the same range. floor_ts records the range start; it is the account's "tracked since" anchor. Positions that predate the range enter as the opening balance of the first snapshot, NOT as a flow.
    3. PT entries older than the flow range get the synthetic basis per M6.
  • Processing: the WS4 cron processes at most 2 queued backfills per tick (90d ≈ 360 grid points, order of a few hundred archive multicalls per active wallet). UI shows "History syncing" while status IN ('queued','running').
  • Repair path: the same script re-run over a window replaces that window's rows idempotently (delete+insert per wallet+range) — this is the recovery for a missed cron run (M9) and the append-only carve-out named in WS3.

Acceptance: backfilling a fixture whale reproduces, at the 6h grid points, values consistent with the live snapshots taken since (spot-check 3 timestamps); an empty wallet costs on the order of one multicall round plus one log sweep and ends empty. Docs: docs/data-pipeline.md (backfill semantics, caps, repair runbook), docs/processes.md.

WS6 — Portfolio APIs + full UI ​

Goal: the user-facing product.

  • API routes under src/app/api/portfolio/ (all force-dynamic, all gated by verifySessionCookie, wallet address only ever from the cookie, never from params): summary (JIT live merge + latest snapshot + backfill status), positions (per book, both marks), history?book=USD|ETH|BTC&mark=market|redemption (chart series: cumulative net yield in native units, flow markers, liquidation markers), events (paged flow ledger).
  • UI in src/components/portfolio/:
    • Book tabs USD | ETH | BTC and mark switch MARKET | REDEMPTION as mono pill groups (pattern: the LIN/LOG + timeframe pills in CarryChart).
    • Headline tiles per book: cumulative yield in native units (e.g. +1.2384 ETH), realized APY (only when ≥30d observed), current book value in the active mark.
    • The chart: Recharts, conventions from CarryChart/MoneyMarketRatesChart (daily-reduced points, uniform grid snapped to midnight, maxBarSize if bars are used — PR #275 lesson, mono tabular-nums, end-of-line labels). Cumulative yield line + flow tick marks + liquidation markers. Gaps render as gaps.
    • Positions table per book: venue, position, quantity, value (active mark), yield earned since tracking (native units), realized APY vs current quoted rate (the "earned vs advertised" column is the differentiator; quoted rate from the existing rate tables).
    • "Outside the yield book" section (M1) and the wedge badge (M7). The section's copy must say why a position sits there (cross-book legs, or leveraged Aave/Spark without e-mode); one-line rule for users: supply positions always count, leveraged positions count when they are e-mode carries.
    • Permanent methodology footer: "Base yield only. Rewards and points are not included. Tracked since <account floor_ts date>." (per-account date; final copy in PR; no em-dashes).
    • States: signed-out prompt, empty book, history-syncing, JIT-only (no snapshots yet).
  • ISR exception: /portfolio page shell stays prerendered; all personal data flows through the APIs (WS2 pattern).

Acceptance: full flow on staging with a fixture wallet: sign in → syncing → books render with correct native-unit yields cross-checked by hand for one Aave position and one vault position (index-ratio arithmetic in a scratch script); mark switch changes values; wedge badge triggers when threshold is artificially lowered; npx tsc --noEmit + npm test + npm run build clean. Docs: docs/portfolio.md (user-facing methodology), docs/metrics.md.

WS7 — Fluid vaults (fast follow, NOT in the launch gate) ​

Fluid positions are NFTs; nothing in the repo enumerates a user's NFTs today. Scope here: T1 single-token vaults only (normal collateral / normal debt). Smart collateral / smart debt legs (T2–T4) are explicitly v2 (fee attribution is only pool-share approximable; see the smart-debt depeg memory before touching them).

  • Reader src/lib/portfolio/readers/fluid.ts: FluidVaultResolver 0xA5C3E16523eeeDDcC34706b0E6bE88b4c6EA95cC positionsNftIdOfUser(address) → uint256[] nftIds; positionByNftId(uint256) → (UserPosition {nftId, owner, isLiquidated, isSupplyPosition, tick, tickId, beforeSupply, beforeBorrow, beforeDustBorrow, supply, borrow, dustBorrow}, VaultEntireData). Decode pattern precedent: scripts/refreshers/vault-capacity.ts (getVaultEntireData tuple decode). Filter to registry vaults; exclude wound-down vaults (borrowLimit < minimumBorrowing, from limitsAndAvailability).
  • Flows — Fluid's events are NOT wallet-filterable:
    • LogOperate(address user_, uint256 nftId_, int256 colAmt_, int256 debtAmt_, address to_) has zero indexed parameters, and user_ is msg.sender (often a wrapper/DSA), not the position owner. Scan per-vault logs on topic0 only, decode the data, and filter client-side by nftId against the account's NFT set (from positionsNftIdOfUser + factory Transfer history). Never filter on user_.
    • NFT in/out: the VaultFactory 0x324c5Dc1fC42c7a4D43d92dF1eBA58a54d13Bf2d is a standard ERC-721 (Transfer(address indexed, address indexed, uint256 indexed tokenId)); wallet arrays at topic1/topic2, tokenId at topic3.
    • Liquidations: LogLiquidate(address liquidator_, uint256 colAmt_, uint256 debtAmt_, address to_) carries no nftId (liquidation executes against ticks, aggregated). Classify per-position liquidation by state diff: collateral/debt drop between snapshots with no matching LogOperate for that nftId, plus positionByNftId.isLiquidated; use LogLiquidate only as a market-level timing hint. This is the M4 exception.
  • Yield via fluid_ll_apy exchange prices; buckets by vault token accounting asset.

Acceptance: a fixture wallet holding a known Fluid T1 position (use one of the team's) shows correct value vs the Fluid app; NFT transfer in/out registers as flow events; a historical tick liquidation in a covered vault is correctly classified as liquidation, not withdrawal.

WS8 — Alerting, docs completion, release ​

  • Cron failure alerting in scripts/run-cron.sh: capture the script's exit code via trap or rc=$? (the wrapper uses set -e; code after the failing line will not run without this) and POST to a Telegram bot on failure (env ALERT_TG_BOT_TOKEN, ALERT_TG_CHAT_ID; plain curl). The message must name the checkout/environment (prod vs staging share the box). This covers all existing jobs too.
  • Unknown-asset alert: exit-code alerting cannot see log lines, so the refresher POSTs to the same Telegram endpoint directly (shared helper) when the bucket map yields EXCLUDED for an asset with nonzero value, after committing its writes.
  • Docs sweep: database.md, data-pipeline.md, metrics.md, architecture.md, deployment.md, processes.md, portfolio.md complete; vitepress build passes (dead-link check).
  • Release: staging → main release PR per docs/deployment.md (version bump, minor). Prod migrations 042/043 are a gated manual migrate.sh step; the crontab line and alert env vars are manual server steps; all listed in the release PR body with exact commands (per the PR-body convention).

Launch gate: WS1–WS6 + WS8 complete, fixture-wallet validation on staging signed off by Fred. WS7 ships in the first post-launch release.


5. Explicitly out of scope (v1) ​

Rewards/points income; gas costs; WalletConnect and Safe sign-in (injected wallets only, ERC-1271 verification already works if a Safe injects); multi-wallet accounts and watched addresses; history beyond 90 days before signup (an on-demand "extend history" replay is a natural v2; the hard cap stays 2025-05-21); MWR/IRR; blended cross-book totals; Fluid T2–T4 smart legs; proxy/DSA position lookthrough; any execution. Each is a candidate v2 item, none blocks launch.

6. Decisions (resolved by Fred, 2026-07-09) ​

  1. Backfill range: last 90 days before signup (hard cap 2025-05-21). Older positions enter as an opening balance at the window boundary; on-demand history extension is a v2 candidate.
  2. Empty books: show the tab with a one-line empty state (discoverability of coverage).
  3. Alert channel: Telegram bot per WS8.
  4. Morpho interest accrual (still default, flag in PR if changed): WS3 specifies exact accrual to the anchor block via IRM borrowRateView. Fallback if it proves fiddly: accept lastUpdate-granularity accrual on active markets, documented in metrics.md.

Private documentation. creddit.xyz