Skip to content

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

What is still current: The four-column statement and the semantic action vocabulary are what the portfolio Activity feed renders today, in src/lib/portfolio/v2/activity.ts. The engine and the flow ledger this document computes them from were replaced.

Landed: v0.32.0 (rounds 1 and 2); rebuilt on the current engine in v0.43.0

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

Activity: semantic actions, four-column statement, and the same-direction double-count fix ​

Scope: ONE PR against staging. Owner plan doc. Implementation agents follow this document; deviations must be argued in the PR body, not silently made.

0. Why ​

Two problems, one surface (/portfolio → wallet card → Activity face):

  1. A real valuation bug. A transaction that moves money between two covered places records BOTH sides as legs — a vault deposit paid from tracked USDC is a deposit leg AND a wallet transfer_out leg, same tx, same $99,413. Both legs point the same capital direction ('out'), so lineValue (which only refuses a total when directions MIX) sums them: the line states ~$199k for a $99k event. Live prod evidence (2026-08-04): tx 0x27cc3393… states $198,826 for a $99,413 deposit. Four pair shapes double this way: {deposit, transfer_out}, {withdraw, transfer_in}, {borrow, transfer_in}, {repay, transfer_out} — ~1,300 transactions in the prod ledger today.

  2. The feed reads as a movement log, not a statement. One human action (deposit into Aave; open a leveraged carry) renders as verb soup ("Supplied and transferred out") with a legs-count subtitle. Fred's spec: one row = one INTENT, mechanics in the expansion.

1. Approved product spec (Fred's decisions, 2026-08-04) ​

  • Columns, in order: (1) Action, (2) Amount with token icon + ticker, (3) Date with time, (4) Etherscan link. Month and day grouping STAYS; the columns live inside the day groups. (Superseded by §1a.3: the date leads.)
  • Action = app-level intent. "A deposit into Aave is one action; anything that relates to it (the funding transfer etc.) shows only when the item is expanded."
  • A plain transfer of a covered token is Transfer out / Transfer in and has NO expansion (single leg: the row is the detail).
  • An atomic leveraged carry in one transaction reads "Open carry trade"; expanded it shows the deposit, the borrow, the transfers.
  • Amount = the action's primary amount (deposit → deposited token; carry open → collateral; liquidation → what was seized; swap → incoming token). The USD (book-unit) value AT EVENT TIME stays as a small secondary line under the token amount. (Superseded by §1a.1: the secondary line is dropped from collapsed lines.)
  • Fallback rule (agreed): any transaction that does not cleanly match a pattern keeps honest generic treatment — grouped, all legs, no invented label, and NO single figure when one would not mean anything.
  • Carry recognition is PATTERN-based (collateral supplied + borrow in one tx), not carries-registry-based: it must work for any leveraged position.

1a. Round 2 (Fred, 2026-08-05, on the shipped round-1 row) ​

Five approved changes to the row above, made after seeing it. They SUPERSEDE the lines they contradict in §1 and §3.4; everything else in this document stands.

  1. No book-unit figure on a collapsed line. The amber secondary under the token amount is dropped. Expansion LEG rows keep their per-leg figures, and the withheld-amount tooltip vocabulary stays on the quantity dash. Because a single-movement line never opens, the line's own figure (or the sentence refusing it) rides in the amount cell's hover — a figure a reader can reach nowhere is a figure the view has dropped, M9's admission with it.
  2. Amount cell order: signed quantity, coin, ticker ("99,413.00 [coin] USDC"), right-aligned, and the same order on the expansion's leg rows.
  3. The date leads the line: [Date · time] [Action + venue] [Amount] [link]. The date cell is a proper first column ("29 Dec · 18:45", title = full ISO), not the small right-edge stamp. Month and day headings stay exactly as they were.
  4. The venue line carries the platform mark, from the shipped registry (platform-brand.tsx, relocated out of PortfolioDashboard so both readers share one module and the feed closes no import cycle). An erc4626 leg also resolves its NAME from the vault address the way platformOf does, and keeps the word "vault" with the brand ("Morpho vault"): the venue pill filters by VENUE and offers "Morpho" and "Vault" as two different options, so a bare brand on the line would name a choice that cannot return it. A line spanning several venues keeps "2 venues" with no mark, counted over the RESOLVED names (two vaults run by two protocols are two venues under one venue word).
  5. A sign in front of the quantity: "+" when the action's direction is 'in', "−" (U+2212) when 'out'; none when the quantity is withheld, when the direction is not single, or when the number rounds to zero. Leg rows sign from their own kind's direction. The signs are MONOCHROME: colour on this dashboard means gain and loss.

Column tracks as shipped: 108px minmax(0,1.4fr) minmax(120px,1fr) beside the 30px link column (the date floor clears "07 Sept · 18:45", en-GB's longest stamp).

2. Current architecture (read this before touching anything) ​

  • src/lib/portfolio/activity.ts — PURE shared model (client + server import it). Wire shapes ActivityLeg / ActivityTx / ActivityFacets; VERB, KIND_ORDER, KIND_DIRECTION, verbForKinds, valueLegs (liquidation states what was seized), lineValue (magnitude sum + four refusal notes: mark/books/unreported/directions/confirming), groupActivityTxs, takeWholeTxPage, keyset cursor (block:txhash), groupActivityByDay (month > day > txs), synthetic native-ETH exclusion (isSyntheticNativeEthLeg, syntheticSql/excludeSyntheticSql — SQL and TS pinned to each other by test).
  • src/lib/portfolio/api-data.ts getActivity — two-arm read: a tx-picking CTE (whole transactions, one MORE than the page as the has-more signal, keyset by (block_number, tx_hash) DESC) + a leg read joining it. Facets (venues, kinds, venue×kind pairs) computed server-side on the first unfiltered page. Single-wallet equality only (HASH partition pruning, migration 072).
  • src/app/api/portfolio/activity/route.ts — session-gated; strict validation of wallet/cursor/position/venue/kind (unknown → 400).
  • src/components/portfolio/ActivityFeed.tsx — the client feed: month kickers, day labels, one role="listitem" per tx; line grid [disclosure+verb+time·detail | venue | value] + a sibling Etherscan link column (30px); expansion renders LegRows; two FilterPills (Venue, Action) driven by facets with pair-pruning (impossible combos greyed, never dropped); PositionPill for the cross-link; loading/error/empty states with exact copy.
  • Hosted by PortfolioDashboard.tsx (~line 2476) behind the Positions/Activity Seg face pill; deep links ?view=activity[&position=…].
  • Formatting: fmtQty (4dp, "–" on null), fmtValue (USD/ETH/BTC), symbols via tokenSymbol(address) (covers every book asset, hex fallback). Icons exist in src/components/icons/token-marks.tsx: TokenMark symbol=<ticker> renders the registry mark and FALLS BACK to TokenMonogram itself — use it directly; do NOT hand-draw any asset (house rule), do NOT import assets/TokenIcon (that one falls back to issuer marks, and the feed has no issuer context).
  • Tests: src/lib/portfolio/activity.test.ts (pure model), tests/e2e/portfolio.spec.ts lines ~1459–1996 (18 specs, the ACTIVITY constant pins counts/labels/figures), fixture ledger in scripts/fixture/seed.sql (spec_pf_activity, 9 shown txs + 1 excluded synthetic row).
  • Fixture invariant (load-bearing): every activity fixture row sits BEFORE the snapshot grid (2026-01-01 00:00 UTC / block 23,000,000) with value_market = value_redemption, so the ledger block moves NO figure on any chart/PnL spec. Every row you add must keep both properties.
  • Known pre-existing e2e nit: expect(text).toMatch(/Mon 29 Dec/i) fails on containers whose en-GB CLDR renders "Mon, 29 Dec". Fix the assertion to accept the comma (or assert via the app's own formatter), in this PR.

3. Design ​

3.1 The action classifier (new, pure, one source of truth) ​

New module src/lib/portfolio/activity-actions.ts (pure; imported by activity.ts consumers, the SQL builder, and the UI). It classifies ONE transaction's legs:

ts
export type ActivityAction =
  | "deposit"        // "Deposit"
  | "withdrawal"     // "Withdrawal"
  | "borrow"         // "Borrow"
  | "repay"          // "Repay"
  | "open_carry"     // "Open carry trade"
  | "close_carry"    // "Close carry trade"
  | "swap"           // "Swap"
  | "transfer_in"    // "Transfer in"
  | "transfer_out"   // "Transfer out"
  | "liquidation"    // "Liquidated"
  | "other";         // fallback: today's honest treatment

export type ClassifiedTx = {
  action: ActivityAction;
  // The legs the row's amount + figure are drawn from (never empty for a
  // non-'other' action).
  primaryLegs: ActivityLeg[];
  // The legs the expansion shows (== all legs, always; primary is a VIEW, not a
  // filter — nothing is hidden).
};

Classification rules, in precedence order, over the tx's DISTINCT kinds (venue-aware where stated). W = the set of wallet-venue legs; V = non-wallet (venue) legs:

  1. any liquidation leg → liquidation; primary = the liquidation legs (EXACTLY today's valueLegs behavior, absorbed here).
  2. V-kinds == {deposit} AND V-kinds ∪ W-kinds ⊆ {deposit, transfer_out} → deposit; primary = the deposit legs. (The wallet transfer_out is the funding side of the same money.)
  3. V-kinds == {withdraw} AND W-kinds ⊆ {transfer_in} → withdrawal; primary = the withdraw legs.
  4. V-kinds == {borrow} AND W-kinds ⊆ {transfer_in} → borrow; primary = the borrow legs.
  5. V-kinds == {repay} AND W-kinds ⊆ {transfer_out} → repay; primary = the repay legs.
  6. V-kinds ⊇ {deposit, borrow} AND V-kinds ⊆ {deposit, borrow} AND W-kinds ⊆ {transfer_in, transfer_out} → open_carry; primary = the deposit (collateral) legs.
  7. V-kinds ⊇ {repay, withdraw} AND V-kinds ⊆ {repay, withdraw} AND W-kinds ⊆ {transfer_in, transfer_out} → close_carry; primary = the withdraw legs (the collateral coming back to the reader).
  8. V empty AND W-kinds == {transfer_in, transfer_out} → swap; primary = the transfer_in legs (the incoming token).
  9. V empty AND W-kinds == {transfer_in} → transfer_in; primary = those legs. Same for {transfer_out} → transfer_out.
  10. anything else → other; primary = all legs.

Notes the implementer must honor:

  • The classifier consumes leg (venue, kind) pairs ONLY — never position keys, never assets — so the SQL twin (3.3) can mirror it exactly.
  • Rule 2 vs rule 6: a deposit-only tx with a wallet transfer_in present (money arriving AND a deposit) does NOT match rule 2 (W ⊆ {transfer_out} fails) and falls through toward other — deliberate: that shape is ambiguous (a swap-and-deposit zap); honesty over a guessed label. Same conservatism everywhere: the subset conditions are exact, not fuzzy.
  • Rules 2–5 also match the pure single-leg venue tx (W empty): a lone borrow IS a "Borrow" action.
  • close_carry when the reader repays and withdraws in one tx. A repay-only or withdraw-only tx stays "Repay"/"Withdrawal" — do not over-claim a carry that may never have existed.
  • Verb strings live in ONE map ACTION_LABEL: Record<ActivityAction, string> ("Deposit", "Withdrawal", "Borrow", "Repay", "Open carry trade", "Close carry trade", "Swap", "Transfer in", "Transfer out", "Liquidated", "Multiple actions"). The old VERB map stays for LEG rows in the expansion (a leg still reads "Supplied 100,000.0000 DAI").

3.2 The figure (bug fix falls out) ​

lineValue changes callers, not semantics: the tx headline figure is lineValue(primaryLegs, mark) instead of lineValue(allLegs, mark).

  • For every non-other action, primary legs are same-direction by construction (deposit legs only, withdraw legs only, …), so the mirror-pair doubling is structurally impossible. The $99,413 deposit states $99,413.
  • For other: KEEP the existing refusal ladder over all legs (directions note etc.) — but ADD one refusal: when the tx contains BOTH venue legs and wallet legs pointing the same direction (the un-classified mirror shape), state no total with a new note "related" ("These movements include the same capital recorded on both sides, so they do not add to one figure."). This is the belt-and-braces for mirror pairs that arrive inside an other tx.
  • valueLegs' liquidation special-case moves INTO the classifier (rule 1 primary); valueLegs itself is deleted. The seized-only figure behavior and its tests carry over unchanged.
  • Multi-asset primary (Fluid smart-pool T3/T4 deposits carry TWO collateral legs): amount cell rules in 3.4; the USD secondary is the primary legs' sum (same book, same direction — legitimate).

3.3 Server: filtering and facets by ACTION ​

The action filter must filter by CLASSIFIED action (a carry open must NOT surface under "Borrow" — it IS the carry action). The tx-picking CTE in getActivity already aggregates per transaction; extend it to compute the action per tx in SQL:

  • New pure builder actionSql(kindsExpr, hasWalletExpr, …) in activity-actions.ts GENERATING the SQL CASE expression FROM the same rule table the TS classifier runs — one source of truth emitting both, exactly the syntheticSql precedent. The CTE aggregates array_agg(DISTINCT kind) FILTER (WHERE venue <> 'wallet') and array_agg(DISTINCT kind) FILTER (WHERE venue = 'wallet') per tx and the CASE maps those two arrays to an action string.
  • ?action=<ActivityAction> replaces ?kind= in the route (strict-validated, unknown → 400; kind is REMOVED, not aliased — the app is the only consumer; update the position-row cross-link and any deep-link tests).
  • Facets: kinds → actions (distinct classified actions in the wallet's ledger) and pairs → venue×action pairs, via the same CASE. The pair-pruning UX (grey, never drop) is unchanged. NOTE: for the pairs, a tx's venue set may be plural; a (venue, action) pair exists when a tx with that action touches that venue (wallet venue excluded from the venue pill as today — check: today the venue pill CAN offer "Wallet"; keep offering it, a plain transfer's venue IS the wallet).
  • A unit test MUST pin TS classifier ≡ SQL CASE: enumerate every subset of (venue-kinds × wallet-kinds) up to the full kind set (7×7 → the powerset is small: 2^7 × 2^7 combos filtered to non-empty = ~16k; fine for one test) and assert the generated CASE, evaluated by a tiny TS array-semantics interpreter of the CASE conditions, matches classify(). (No DB in unit tests; the interpreter walks the same generated AST/conditions the SQL string is built from — the string and the interpreter must share the generator so drift is a compile error, not a hope.)
  • e2e then covers the REAL SQL against the fixture ledger for every action the fixture holds (which after 3.6 is all of them but close_carry optional — seed it too, keep it complete).

3.4 The row (client) ​

Grid per tx row inside a day group (replaces today's inner grid). Round 2 reorders this to [date·time] [action] [amount] [link] and drops the amount's secondary figure line — see §1a; the rest of this section stands as written.

[action] [amount] [date·time] [link]
  • Action cell: ACTION_LABEL[action], weight 600, + the venue in the muted style beneath or beside it ("Deposit" / "Aave v3"); a tx touching several venues names the count ("2 venues") as today; wallet venue reads "Wallet". Confirming tag rides here as today. Disclosure chevron renders ONLY when legs.length > 1 (single-leg rows: no chevron, no button semantics for expansion — the row is not expandable, and the a11y tree must not promise it).
  • Amount cell: token mark (TokenMark, size 16) + fmtQty(amount) + ticker (tokenSymbol(asset)), from the primary legs:
    • one asset → one icon + summed amount + ticker;
    • two assets (Fluid smart pools) → two stacked marks + "USDC + USDT" style label, amounts in the title attr; NO invented single number;
    • three+ assets or a null amount → "n assets" and let the expansion speak.
    • Secondary line under it: the USD/book figure at event time (lineValue(primaryLegs)), muted mono 10.5, dash-with-title on refusal, exactly the current dash vocabulary (NOTE_TITLE + new related note).
  • Date·time cell: "29 Dec · 18:45" (UTC, en-GB, no seconds), muted mono, title attr = full ISO. The day heading stays; the cell repeats the date by Fred's explicit choice. Weekday only in the day heading. Build BOTH from Intl.DateTimeFormat with explicit parts (formatToParts) so no locale comma can drift the copy or the tests again.
  • Link cell: unchanged Etherscan affordance (sibling column, quiet).
  • Expansion (legs.length > 1 only): today's LegRows (verb + qty + ticker + venue + per-leg value) — now ALSO with the token mark before the qty for visual continuity. All legs, execution order, nothing hidden.
  • Column tracks (desktop): minmax(0,1.4fr) minmax(150px,1fr) 96px 30px; at the 900 container the date·time cell may drop its date half (day heading carries it) — verify no horizontal overflow at 900/1140/1360 (existing e2e asserts stay).
  • Filter pills: Venue unchanged; Action pill lists ACTION_LABEL values present in facets. Copy for the greyed why strings switches to action labels ("No Aave v3 deposit in this wallet's history.").

3.5 What does NOT change ​

Session gating, single-wallet rule, keyset pagination + whole-tx pages, the synthetic native-ETH exclusion + excludedLegs truth-telling, the position cross-link (still filters by position_key; a narrowed feed classifies identically), provisional/confirming semantics, month/day grouping, error/empty states and their copy, the "Includes movements outside the X view" note, UTC everywhere, no em-dashes, no help cursor, mono tabular numerals.

3.6 Fixture additions (scripts/fixture/seed.sql) ​

Add to spec_pf_activity, ALL before block 23,000,000 / 2026-01-01, all with value_market = value_redemption, distinct tx hashes in the 0x…0a0aaN series, on the primary signed-in wallet unless said otherwise:

  • Mirror-pair deposit (THE BUG REGRESSION): one tx: erc4626 deposit 99,413 USDC + wallet transfer_out 99,413 USDC. Must read: action "Deposit", amount "99,413.0000 USDC", figure $99,413.00, expandable (2 legs), and the string "$198,826" must appear NOWHERE.
  • Carry open: one tx: aave deposit 100,000 DAI + aave borrow 60,000 USDT + wallet transfer_in 60,000 USDT. Reads "Open carry trade", amount 100,000 DAI, figure $100,000; expanded shows all three legs in log order. (NOTE: the EXISTING November two-leg tx {deposit,borrow} also becomes "Open carry trade" — the spec constants change accordingly; keep both txs, they cover the with-and-without wallet-leg variants of rule 6.)
  • Carry close: one tx: aave repay 60,000 USDT + aave withdraw 100,000 DAI. Reads "Close carry trade", amount 100,000 DAI.
  • Swap: one tx: wallet transfer_out 4,000 USDC + wallet transfer_in 3,990 USDS. Reads "Swap", amount 3,990 USDS (incoming), figure $3,990.
  • Plain transfer, no expansion: one tx: wallet transfer_out 1,000 USDC, single leg. Reads "Transfer out"; NO disclosure chevron; row not expandable.
  • An other fallback: one tx with deposit + wallet transfer_in (the ambiguous zap shape rule 2 refuses): reads "Multiple actions", states NO total (note related or directions as applicable), all legs in expansion.

Keep the existing rows untouched (chart borrows become "Borrow" actions — assert). Update the ACTIVITY spec constant block: txs count, actions list, months (new rows land in existing Dec 2025/Nov 2025 months where possible to keep the month list stable — place them in Dec 2025 days), figures.

3.7 Tests (the bar) ​

Unit (activity-actions.test.ts + updates to activity.test.ts):

  • Table-driven classification: every rule, every fallback edge in §3.1, INCLUDING: liquidation precedence over everything; deposit+transfer_in → other; three-venue tx → other; swap; single-leg venue actions; carry with and without wallet legs; repay+withdraw+borrow → other.
  • TS ≡ SQL pin per §3.3.
  • lineValue-over-primary: the four mirror pairs each state the single-leg figure; other with same-direction venue+wallet legs states none with note related; two same-direction venue deposits still SUM (the existing twoSupplies test survives against primary legs).
  • Amount-cell resolution: one asset, two assets, n>2, null amount.
  • Use magnitude-unique fixture numbers (house rule: no vacuous regex asserts).
  • Mutation-test the load-bearing predicates (flip a classifier condition, a direction entry, the SQL generator's rule order — each must fail a test).

e2e (rewrite the Activity describe block):

  • Every existing behavior that survives (one line per tx, month/day order, confirming tag, dash-not-zero, liquidation seized-only, reorg pair no-double, explorer links, venue pill, position cross-link + per-card scoping, financed wallet reachability, empty-feed truths, failed-load pills, no-empty-combos, outside-view note, no horizontal overflow) re-asserted against the NEW column structure and action vocabulary.
  • New: the §3.6 shapes each render their action label, amount+ticker (icon presence asserted via img/svg role or data attr, not pixels), figure; the mirror-pair regression (absence of the doubled string); single-leg rows carry no expansion affordance; action filter offers the new vocabulary and narrows correctly (carry opens do NOT appear under "Borrow"); date·time cell format.
  • Fix the day-heading assertion to be CLDR-proof (formatToParts or /Mon,? 29/).
  • Keep expectNoHorizontalOverflow at all three project widths + reduced-motion project passes.

Docs (same PR, house rule): docs/portfolio.md Activity section — action vocabulary table (user-facing words only), the amount/figure semantics, the fallback honesty rule, the no-expansion rule; npm run docs:build (vitepress) must pass (dead-link check).

3.8 Explicitly OUT of scope ​

  • No DB migration, no refresher/flow-scan change (the LEDGER is right; the read is what changes). KIND_DIRECTION, flow detection, PnL netting, chart flow markers: untouched.
  • No aggregate all-wallets feed, no CSV export, no new filters beyond the vocabulary swap.
  • No re-labeling of the ledger's kind column anywhere server-side.

4. Verification bar (before the PR opens) ​

  • npx tsc --noEmit clean; full unit suite green; e2e green on an ISOLATED stack (unique E2E_PORT + FIXTURE_PORT — never the shared 55432; read the skipped count, 0 expected, not just "passed").
  • Browser feel-check at 1360/1140/900 + dark/light + prefers-reduced-motion on the REAL page (screenshots in the PR), not isolated cells.
  • Scans: no em-dash in any user-facing string added; no cursor: help; no internal enum/key strings reaching the screen (the existing e2e assert extends to the new labels).
  • git status reviewed; stage by explicit path; never git add -A.

5. Review protocol (after implementation, before the PR) ​

Two INDEPENDENT reviewers (Opus 5, max effort), different lenses, then an adversarial verify pass on every finding:

  • Lens A — financial soundness + correctness: every figure the new view can print, traced 1st/2nd/3rd-order (classifier wrong-label risk, primary-leg figure vs legs, refusal notes, facets/filter consistency with SQL, pagination under filters, the reorg pair, liquidation).
  • Lens B — product/UX/consistency: column semantics at all widths, a11y (expansion affordance truthfulness, list semantics, filter pills), copy (TradFi vocabulary, no internal words), theme/motion house rules, docs accuracy.
  • Findings → fixed in the same branch → suites re-run → findings + resolutions posted as a PR comment (house rule), PR body carries what/why + "no server steps".

Private documentation. creddit.xyz