Skip to content

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

What is still current: The window rule it settles is live: a wallet that held a curve-starting position at its own history floor charts from that floor, with the position as an opening balance, and only a wallet that held nothing there has its start raised to its first curve-starting activity (heldCurveStartingAt + computeBackfillWindow in src/lib/portfolio/backfill.ts). The nine-wallet table in section 1 is a snapshot of the defect on 2026-09-22, not a standing list; those wallets are rebuilt by the gated release step, which is where their before/after is recorded. One line of section 5 is superseded by the shipped build: no injectable reader seam was built, as it decided, but the floor read's wiring and the single production call site are NOT left to the staging run and review alone. They are pinned at the source in backfill.test.ts, after round-1 review showed that hard-coding heldAtFloor: false at that call site, a complete revert of this fix, left all 6403 tests green.

Landed: PR #931 (fix/held-at-floor-window)

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

The window start when the wallet already holds positions at its floor ​

Decision record for the fix Fred asked for on 2026-09-22, after wallet 0xea88…7974 (added 2026-09-21 18:29Z) came up with one week of history instead of thirty days.

1. The rule, in Fred's words, and what the build does instead ​

The settled rule (issue #852, settled 2026-09-11, restated by Fred on 2026-09-22):

Any wallet that holds a position today gets up to 30 days of history, as long as that position has that long a history or more. A position opened before the window opens at its value on the window start; a newer one opens when it was opened.

The build honours the "newer one" half and breaks the "opened before" half. Its window computation (computeBackfillWindow, src/lib/portfolio/backfill.ts) takes the stored floor and raises the start to the wallet's first curve-starting activity inside the window (probe.firstActivityBlock → firstActivitySec). That raise was written for one case only, a wallet that held nothing at the floor, so its chart does not open on a flat-zero lead-in. It never asks whether the wallet already held something at the floor, so:

  • a wallet holding positions opened before the window, whose first in-window move is a NEW position, has everything before that move dropped;
  • a wallet holding positions opened before the window, whose first in-window move is the EXIT of one of them, has its chart start on the exit day (the exit itself is a curve-starting flow in firstCurveActivityBlock).

Both are "a position opened before the window", and both should open at the floor.

Evidence (prod, 2026-09-22, verified by hand, see the drain log and archive reads) ​

0xea88d13e17637182a0bc7b336f9829c276c87974: stored floor 2026-08-22 00:00Z / block 25806959; first curve activity block 25988644 (Aave USDC supply, 2026-09-16 07:48Z); replay ran [2026-09-16 .. 2026-09-21T18:00], 7 grid points, tracked since 2026-09-16. Archive balanceOf at the floor block: PT-reUSD-10DEC2026 (0xecfa…3957) 16,327,111,891 raw and sGHO (0xe175…ca1d) 13,290.38 shares, both IDENTICAL to the 09-16 grid point (~$29.4k). 25 days of held-through history were dropped. Its 09-15 pre-wipe build was cut the same way (to 09-03, the day of a Morpho collateral withdrawal).

Every prod wallet whose chart start (portfolio_backfill_state.floor_ts) sits above its stored floor (accounts.history_floor_ts) as of 2026-09-22, with USD value at the chart start:

walletaddedstored floorchart startdays cutheld at chart start
0xef08c6a4…07c509-1708-1809-1629idle USDC + ETH only (the raise's own case)
0xea88d13e…797409-2108-2209-1625PT + sGHO, ~$29k
0x64172ea6…079409-2108-2209-1423wstETH + another ETH-book token + an Aave supply, ~55 ETH
0xb8b827ea…8e8409-1808-1909-0618~$10.4M
0xe1590894…6b0b09-1708-1808-246Morpho collateral 21.3 ETH against 10.2 ETH debt
0x5d09098f…ca1709-1808-1908-245~$20.4M
0xddcd0d17…cdd109-1808-1908-212~$9.9M
0x4c5a6116…8c3109-1808-1908-212~$5.1M
0x7c7565ad…34de09-1908-2008-222~$201k

0xef08… is the case the raise exists for and stays as it is. The other eight are the defect. Whether each of them held a curve-starting position AT THE FLOOR (not only at the chart start) is decided by the rebuild itself (§6), not by this table.

2. Root cause, precisely ​

probeWallet (backfill.ts ~L750-945) reads the wallet's CURRENT positions at nowBlock, runs ledger discovery for the groups it held earlier in the window, builds the restricted replay universe (openSet, restrictedReg), then sweeps the window for the first curve-starting flow (foldFirstActivity / firstActivityFromFold). backfillWallet (~L2800-2845) converts that block to seconds and calls computeBackfillWindow, whose start is max(historyFloorSec, firstActivitySec). Nothing reads the wallet at the floor block, so "held through the floor" and "held nothing until day N" are indistinguishable to it whenever the wallet has ANY in-window curve-starting flow. The held-through branch in the comment ("no activity in the window, positions present") only covers the silent wallet.

Everything else is already right and stays untouched: the ledger v2 range opens at the stored floor block regardless of the raise (ledger-v2 … [25806959, tip] in the same log), the gap patch never revisits the window, the empty verdict is a question about today, and ledger discovery already admits positions opened-and-closed or only-closed inside the window.

3. The fix ​

D1 — one strict read at the floor. In probeWallet, after openSet / restrictedReg are built (after restrictRegistries, before the first-activity sweep), read the wallet at from (= Math.min(candidateStartBlock, nowBlock), the stored floor block resolved once by storedProbeStartBlock) over the RESTRICTED replay universe, mirroring readReplayGridPoint exactly so the floor read and grid[0] are the same read:

ts
const floorHex = "0x" + from.toString(16);
const floorReads = filterReadsToOpenSet(
  await readGridPointOrThrow(
    () => readAllPositionsSettled([walletLc], floorHex, restrictedReg, { strictNative: true }),
    { iso: "probe(floor)", block: from, log },
  ),
  openSet,
);

Strict on purpose: a failed venue read aborts the run (retried by the drain) rather than deciding the window off a partial read, the same M9 rule the current-read follows. Cost: one archive multicall round per registration, over the restricted universe (smaller than the probe's full-universe now-read).

D2 — the predicate, pure and exported. heldCurveStartingAt(reads, parWalletTokens) in backfill.ts, next to isParWalletFlow, returns true iff some read has qtyRaw > 0n and is curve-starting: any venue other than wallet, or a wallet leg whose token (third : part of positionKey, lower-cased, exactly as isParWalletFlow parses it) is NOT in the par set. The par set is the one the probe already builds from the FULL registry (reg.walletTokens.filter(t => t.tokenClass === "par")), so ETH (the native sentinel is a registry row with class par), WETH, USDC, GHO and every other idle token never count, and sGHO / wstETH / sUSDS (class variable_rate) do. Nothing about valuation or a dollar threshold: a leg is held or it is not.

D3 — the window. computeBackfillWindow gains a required heldAtFloor: boolean. When true, rangeStartSec = historyFloorSec and firstActivitySec is ignored; when false the existing raise applies unchanged. Update the D1 comment block and the function's doc comment to state the rule in three lines: held at the floor → the floor; held nothing at the floor → raised to the first curve-starting activity; nothing now → empty.

D4 — plumbing. ProbeResult gains heldAtFloor: boolean (and, for the log, floorLegs: number); the empty-wallet early return sets false/0. backfillWallet passes probe.heldAtFloor to computeBackfillWindow. Keep the first-activity sweep exactly as it is (it is cheap and its answer still goes into the log and BackfillResult.firstActivityBlock); do not add a skip branch. Extend the probe log line to probe: has current positions, held at floor: yes (N curve-starting leg(s) @block B) | no, first activity block X (R rpc calls).

D5 — unchanged by design. The empty verdict, the gap-patch path, storedProbeStartBlock, the v2 ledger range, floor_ts semantics (still = grid[0]), and the par rule for the idle-only wallet. MAX_GRID_POINTS still bounds the grid.

4. Decisions taken here (Fred can flip each with a one-line change) ​

  1. Idle-only at the floor counts as "held nothing", so the raise still applies to a wallet whose only floor holdings are par tokens (USDC, ETH, …). Consistent with the settled par rule ("an old stablecoin top-up cannot pin the chart's start") and with 0xef08… above. Flipping it = heldCurveStartingAt counts every leg with qtyRaw > 0n.
  2. Dust counts. Any non-zero curve-starting leg at the floor opens the window at the floor; a 1-wei leftover therefore yields a near-zero lead-in, which is honest and rare. No value threshold in the build path.
  3. The floor read decides the window only; it does not widen the replay universe. A group held at the floor that discovery could not certify is not read here either, so the two answers stay consistent. Widening from the floor read is a possible follow-up (propose it in the PR comment; do NOT open an issue).

5. Tests (mutation-tested: each cell must fail on a revert of the line it pins) ​

In src/lib/portfolio/backfill.test.ts (already in the npm test manifest; a NEW test file must be added to the test script list or scripts/test-manifest.test.ts goes red):

  • computeBackfillWindow: heldAtFloor: true with firstActivitySec = ROLL_FLOOR + 11 * DAY → rangeStartSec === ROLL_FLOOR, floorSec === ROLL_FLOOR, 30 + 1 + 1 grid points. Assert notEqual(w.floorSec, floorDay(firstActivity)) so the old raise fails the cell.
  • heldAtFloor: false keeps the raise (update the existing "first activity RAISES…" cell to pass the flag explicitly; its assertions stay).
  • The "NEVER OPENS BELOW THE WALLET'S OWN FLOOR" property sweep and the "swept range and the replayed range are the SAME number" cell run with both flag values.
  • heldCurveStartingAt: venue leg (aave, pendle, fluid, erc4626, escrow) → true; only par wallet legs (USDC + the native sentinel 0xeeee…eeee) → false; a variable_rate wallet leg (sGHO) → true; a curve-starting leg with qtyRaw === 0n → false; positionKey casing never defeats the par match (mirror the existing casing cell); empty reads → false.
  • The probe has no injectable reader seam today; do not build one for this PR. The floor read's wiring (block, universe, filter) is pinned by the staging run in §7 and by review.

6. Docs and records (same PR) ​

  • docs/data-pipeline.md §"Registration backfill (WS5)": the "Replay: from max(the wallet's own history floor, first activity)" sentence and the probe paragraph → state the three-line rule and the floor read.
  • docs/portfolio.md (~L2040-2050, the cumulative-yield tooltip's derivation note): the day_floor(max(...)) formula → the new rule.
  • docs/processes.md §D (~L993-996): "raised to the wallet's own first activity" → "raised to its first activity only when it held nothing curve-starting at the floor".
  • src/lib/portfolio/history-window.ts header ("It is a FLOOR, not a start…") and the backfill.ts file header (its "REPLAY: from max(history floor 2026-01-01, first activity, coverage floor 2025-05-21)" line is stale twice over; write the current rule).
  • src/components/portfolio/building-labels.ts L20 comment: keep it true ("a floor, raised only for a wallet that held nothing at it").
  • This plan: set status: built, landed: "PR #<n> (fix/held-at-floor-window)", current: one sentence, updated; then cd docs && npm run plans:index and commit index.md.
  • docs/ops/release-steps.md § "One-time repairs and rebuilds (gated)": add ### GATED STEP: rebuild the wallets whose window was cut at their first move {#held-at-floor-rebuild} with belongs to: this PR / the next release and executed: pending, in the shape of the existing gated steps (scope query, before figures, command, after checks, rollback). Content in §8. scripts/runbook-split.test.ts checks the executed: line.
  • npm --prefix docs run build is the dead-link check; run it.
  • PR body: what/why (one paragraph, Fred's rule quoted), docs impact (the pages above), server steps (§8, verbatim command), rollback (code-only revert; a wallet rebuilt under the fix keeps its longer history, which is correct data, so no data rollback), and the §7 evidence.

7. Verification before the PR is marked ready (staging only, never prod) ​

Unit + types + docs: npm test, npx tsc --noEmit, npm --prefix docs run build, all green, under Node 20 (export PATH=~/.nvm/versions/node/v20.20.1/bin:$PATH; the Mac default node is 14).

End-to-end on the server against STAGING, from a temporary worktree of the PR branch so the staging checkout (/opt/onchain-credit-staging, auto-deployed from staging) and prod (/opt/onchain-credit, DB creddit) are never touched:

bash
ssh -i ~/.ssh/hetzner_ed25519 root@dexhq.io
cd /opt/onchain-credit-staging && git fetch origin fix/held-at-floor-window
git worktree add /tmp/oc-verify-held-at-floor origin/fix/held-at-floor-window
ln -s /opt/onchain-credit-staging/node_modules /tmp/oc-verify-held-at-floor/node_modules
# the wallet from §1, with its prod account facts (staging's nightly reseed scrubs accounts)
sudo -u postgres psql -d creddit_staging <<'SQL'
INSERT INTO onchain_credit.accounts (uid, created_at, created_block, history_floor_ts, history_floor_block)
VALUES ('0xea88d13e17637182a0bc7b336f9829c276c87974', '2026-09-21 18:29:26+00', 26027676,
        '2026-08-22 00:00:00+00', 25806959) ON CONFLICT (uid) DO NOTHING;
INSERT INTO onchain_credit.account_wallets (account_uid, wallet)
VALUES ('0xea88d13e17637182a0bc7b336f9829c276c87974', '0xea88d13e17637182a0bc7b336f9829c276c87974')
ON CONFLICT DO NOTHING;
SQL
cd /tmp/oc-verify-held-at-floor
set -a; source /opt/onchain-credit-staging/.env.local; set +a   # staging DB + archive RPC; never echo it
node_modules/.bin/tsx scripts/backfill-portfolio-wallet.ts --uid 0xea88d13e17637182a0bc7b336f9829c276c87974 --fresh 2>&1 | tail -60

Expected: the probe line says held at floor: yes with 2 curve-starting legs at block 25806959 (the PT and sGHO), the replay line reads replay 32 grid points (daily + seam) [2026-08-22T00:00:00.000Z .. <today>], and done: … tracked since 2026-08-22T00:00:00.000Z. Then, read-only:

sql
SELECT min(snapshot_ts), max(snapshot_ts), count(*) FROM onchain_credit.portfolio_position_snapshots
 WHERE wallet = '0xea88d13e17637182a0bc7b336f9829c276c87974';
SELECT snapshot_ts, venue, position_key, qty_raw FROM onchain_credit.portfolio_position_snapshots
 WHERE wallet = '0xea88d13e17637182a0bc7b336f9829c276c87974' AND snapshot_ts = '2026-08-22 00:00:00+00' ORDER BY 2, 3;

Expected: min = 2026-08-22 00:00:00+00; the 08-22 point holds pendle:pt:0xecfa…3957 (16327111891) and wallet:token:0xe175…ca1d (13290380416871041233685). Paste both outputs into the PR body. The v2 ledger merge will report DEFERRED on staging (the scrub removed the wallet's bare-token coverage row); that is expected and not part of this check. Clean up afterwards: git worktree remove --force /tmp/oc-verify-held-at-floor from /opt/onchain-credit-staging; leave the staging rows, the nightly reseed drops them. The staging drain cron runs the OLD code every minute; it only picks up queued rows, and this run leaves the row done.

If the floor read ever reports held at floor: no for this wallet, the fix is wrong (the PT is a pendle venue leg and sGHO a variable_rate wallet token, both held at 25806959); stop and find out why before anything else.

8. The gated prod step (runs once, after the release that carries this PR) ​

Nothing here runs before Fred's go. The PR body and docs/ops/release-steps.md carry it.

  1. Scope, read-only, on prod (sudo -u postgres psql -d creddit): every tracked wallet whose chart start sits above its stored floor — SELECT a.uid, a.history_floor_ts, b.floor_ts FROM onchain_credit.accounts a JOIN onchain_credit.portfolio_backfill_state b USING (uid) WHERE b.status = 'done' AND b.floor_ts > a.history_floor_ts ORDER BY 1; — expected to list the nine wallets of §1 (plus any added since). Record the rows.
  2. For each, one at a time, cd /opt/onchain-credit && scripts/run-cron.sh backfill-portfolio-wallet.ts --uid <uid> --fresh (the history writers serialise on one advisory lock; the drain keeps running, it only touches queued rows). Output lands in /tmp/onchain-credit-cron/onchain-credit-backfill-portfolio-wallet.log (keyed by checkout, like the lock, so it is prod's own file). --fresh is DESTRUCTIVE by design: it replaces the wallet's whole history, thins the 6h live rows since the wallet was added to daily points, and re-earns the coverage anchor at done.
  3. Re-run the scope query. A wallet that held a curve-starting position at its floor now has floor_ts = history_floor_ts; 0xef08… (idle-only at its floor) is expected to stay at 2026-09-16. Record before/after per wallet under the step's executed: line and set the date.
  4. The v2 ledger merge runs inside each rebuild (the wallets' bare-token coverage is certified down to their floors); the log's ledger-v2 … [floor, tip] line should not say DEFERRED. If one does, the drain re-checks every tick and derives once the enrolment lands.

9. Out of scope ​

Widening the replay universe from the floor read (§4.3); deepening an existing wallet's window (#861); any change to HISTORY_WINDOW_DAYS, the par rule, or the empty verdict.

Private documentation. creddit.xyz