diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 3e3bd8b..4116a4c 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -88,3 +88,6 @@ {"id":"int-51cd7e3fd904dfa823a0a73a0fb41156","kind":"field_change","created_at":"2026-07-30T10:36:21.003339065Z","actor":"halaprix","issue_id":"slotscope-zvd.5","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixture gains balances/history/note at slots 5-7 plus a constructor that fills the array and string, so the default document demonstrates reserved rows and their measured values on first compile and deploy. Declared after positionValue: the editor virtualizes lines and relations.spec.ts clicks one inside that function, so keeping earlier lines in place was required (caught by the full browser suite). Goldens regenerated via the sanctioned --update; verified every existing variable kept its exact slot and offset. sourceHash, expectedLayoutHash and expectedRelationHash moved; expectedInstructionHash did not."}} {"id":"int-eaddbbe40fad713f478dda7fcb0c3278","kind":"field_change","created_at":"2026-07-30T10:36:21.314921434Z","actor":"halaprix","issue_id":"slotscope-zvd","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"All five tasks complete and verified: format+lint+typecheck clean, 235/235 unit (incl. Forge cross-check and a 300-run fast-check gaplessness property), fixtures:verify no drift, 61/61 Playwright, build+build:check (224 KB main entry), benchmark p95 225ms action-to-paint / 657ms cold start PASS. Not committed: conservative profile, awaiting owner approval."}} {"id":"int-d4e8aa7f76623df676964dd1a4c2a317","kind":"field_change","created_at":"2026-07-30T14:22:32.928557923Z","actor":"halaprix","issue_id":"slotscope-02a","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"buildStaticIndex now indexes nested reference heads from the decoded model's collapsed spans. Verified end to end: a write to a mapping inside a struct resolves exact as nest.inner[0xaa..] where it previously reported unresolved (test added to chains.test.ts, confirmed failing first). 236/236 unit, 61/61 e2e, fixtures no drift, release:verify passed. Committed on fix/nested-reference-heads; PR held until #4 merges, since the fix reads CollapsedStorageSpan.encoding which #4 introduces."}} +{"id":"int-50421840975e9832e8db08d0887f4aa2","kind":"field_change","created_at":"2026-07-30T14:55:20.061275583Z","actor":"halaprix","issue_id":"slotscope-xu8.1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Derived entries render as child rows under their reserved row. Parent linkage is slot arithmetic over recorded keccak preimages, not name matching, so nested heads attach with no special case. Rail shows the derivation (keccak(0xaa.., 1), nested hops shown as keccak(k2, keccak(k1, 2)), array elements as keccak(3)+n). Roving focus generalized to a flat navigable-row list. 241 unit + 10 storage e2e green, axe clean."}} +{"id":"int-19379715946fac12b29eab1bcb3b26ce","kind":"field_change","created_at":"2026-07-30T14:58:46.581879605Z","actor":"halaprix","issue_id":"slotscope-xu8.2","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"RuntimeStorage is now a chronological write log with step numbers, keeping the status pill, migration badge and constructor-origin heading. Grid is the only per-variable map. Verified: 241/241 unit, 62/62 e2e, fixtures no drift, release:verify passed, bundle 225 KB, benchmark p95 250ms/732ms PASS."}} +{"id":"int-ee68c253d1c8e02909fed8c2dddde679","kind":"field_change","created_at":"2026-07-30T14:58:46.737131043Z","actor":"halaprix","issue_id":"slotscope-xu8","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"RuntimeStorage is now a chronological write log with step numbers, keeping the status pill, migration badge and constructor-origin heading. Grid is the only per-variable map. Verified: 241/241 unit, 62/62 e2e, fixtures no drift, release:verify passed, bundle 225 KB, benchmark p95 250ms/732ms PASS."}} diff --git a/CHANGELOG.md b/CHANGELOG.md index 794a10e..c527a5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,35 @@ All notable changes to SlotScope. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow the [roadmap](.resources/slotscope-final-scope-roadmap-v2.0.md) release plan. +## [1.4.0] — unreleased + +### Added + +- **Keccak-derived slots are visible, written as derivations.** A mapping entry or + array element the run touched now appears as a child row under its reserved + slot, and its address is written the way the EVM computes it — + `keccak(0xaa…, 1)`, or `keccak(0xbb…, keccak(0xaa…, 2))` for a nested hop, or + `keccak(3)+2` for an array element. The raw 32-byte slot is a hover detail; the + derivation is the identity, because that is the thing worth learning. + Entries that were only read are marked read-only — a fact no surface showed + before. Parent linkage is slot arithmetic over recorded keccak preimages, never + name matching, so a head nested inside a struct attaches with no special case. + +### Changed + +- **`RuntimeStorage` is now a write log, not a second map.** It lists every write + in the order it happened, with step numbers. The grid above is the map; two maps + of the same state, ordered differently, only made a reader decide which to + believe. +- Roving focus in the storage grid indexes a flat list of navigable rows, so arrow + keys reach a derived child exactly like a declared slot. + +### Fixed + +- A mapping, dynamic array or `bytes`/`string` declared inside a struct or array + element resolved as `unresolved`: the semantics index knew only top-level heads, + so `keccak(key ‖ slot)` could not be grounded for a nested one. + ## [1.2.0] — unreleased ### Added diff --git a/apps/web/e2e/semantics.spec.ts b/apps/web/e2e/semantics.spec.ts index 2537489..71de991 100644 --- a/apps/web/e2e/semantics.spec.ts +++ b/apps/web/e2e/semantics.spec.ts @@ -101,11 +101,15 @@ test('nested mappings show the collapsed KeccakFlow and the runtime storage grou await expect(flow.locator('[data-depth="2"]').first()).toBeVisible(); await expect(write.locator('[data-testid="keccak-flow-collapsed"]')).toContainText('collapsed'); - // The runtime group lives in the probe pane's Storage mode. + // The write log lives in the probe pane's Storage mode. It is a sequence, not + // a second map: the grid above owns the per-variable view. await page.getByRole('tab', { name: 'Storage' }).click(); const runtime = page.getByRole('region', { name: 'Runtime storage' }); await expect(runtime).toBeVisible(); - await expect(runtime.locator('[data-testid="runtime-group"]')).toContainText('allowance'); + await expect(runtime).toContainText('Writes in order'); + const entry = runtime.locator('[data-testid="runtime-write"]').first(); + await expect(entry).toContainText('allowance'); + await expect(entry).toContainText('step '); await expect(runtime).toContainText('= 55'); await expect(runtime.locator('.ss-status-pill.is-exact')).toBeVisible(); }); diff --git a/apps/web/e2e/storage.spec.ts b/apps/web/e2e/storage.spec.ts index d0fdb9c..e315b19 100644 --- a/apps/web/e2e/storage.spec.ts +++ b/apps/web/e2e/storage.spec.ts @@ -203,3 +203,35 @@ test('the default source shows its reserved slots, then their values on deploy', // solc never writes a mapping head, so it stays blank. await expect(reserved.nth(0)).not.toContainText('0x'); }); + +test('a mapping entry written by the constructor appears as a keccak-derived child', async ({ + page, +}) => { + await page.goto('/'); + await expect(page.locator('[role="status"]')).toHaveAttribute('data-status', 'ready', { + timeout: 60_000, + }); + await page + .locator('.cm-content') + .fill( + 'pragma solidity 0.8.36;\ncontract M {\n mapping(address => uint256) internal balances;\n constructor() {\n balances[address(0x1)] = 5;\n }\n}', + ); + await page.getByRole('button', { name: 'Compile' }).click(); + const grid = page.getByRole('grid', { name: 'Storage layout' }); + await expect(grid).toBeVisible({ timeout: 60_000 }); + + // Before any run the head is reserved and has no children. + await expect(grid.locator('[data-testid="storage-derived"]')).toHaveCount(0); + + await page.getByRole('button', { name: 'Deploy locally' }).click(); + const derived = grid.locator('[data-testid="storage-derived"]'); + await expect(derived).toHaveCount(1, { timeout: 60_000 }); + // The address is the derivation, written the way the EVM computes it. + await expect(derived).toContainText('keccak('); + await expect(derived).toContainText('balances['); + await expect(derived).toContainText('5'); + await expect(derived).toContainText('not the next slot'); + + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations).toEqual([]); +}); diff --git a/apps/web/src/app/app.css b/apps/web/src/app/app.css index 5d6a53e..d9fda61 100644 --- a/apps/web/src/app/app.css +++ b/apps/web/src/app/app.css @@ -1294,6 +1294,44 @@ input[readonly] { color: var(--ss-color-fg-muted); } +/* Derived children: keccak addresses, deliberately set apart from the declared + sequence so the indent and the leader say "not the next slot" before the + words do. */ +.storage-derived { + margin-left: 1.25rem; + border-left: 1px dashed var(--ss-color-border-strong); + padding-left: 0.5rem; +} + +.storage-derived-note { + margin: 0.25rem 0; + color: var(--ss-color-fg-muted); + font-size: var(--ss-font-size-micro); + letter-spacing: var(--ss-tracking-label); + text-transform: uppercase; +} + +.storage-slot-label.is-derived-rail .storage-slot-number { + font-size: var(--ss-font-size-micro); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.storage-cell.is-derived { + display: flex; + align-items: center; + gap: 0.5rem; + overflow: hidden; + padding-inline: 0.5rem; + border: 1px solid var(--ss-color-border-strong); + background: var(--ss-color-chip-bg); +} + +.storage-cell.is-derived.is-read-only { + border-style: dashed; +} + .storage-record-note { color: var(--ss-color-fg-muted); } @@ -1730,21 +1768,7 @@ input[readonly] { color: var(--ss-color-label); } -.ss-runtime-group { - border: var(--ss-border); - border-radius: var(--ss-radius); - padding: var(--ss-space-1) var(--ss-space-2); - margin-bottom: var(--ss-space-2); -} - -.ss-runtime-var { - font-family: var(--ss-font-mono); - font-size: var(--ss-font-size-micro); - color: var(--ss-color-label); - letter-spacing: var(--ss-tracking-label); -} - -.ss-runtime-group ul { +.ss-runtime-log { list-style: none; margin: 4px 0 0; padding: 0; @@ -1753,6 +1777,13 @@ input[readonly] { gap: 3px; } +.ss-runtime-step { + font-family: var(--ss-font-mono); + font-size: var(--ss-font-size-micro); + color: var(--ss-color-fg-muted); + font-variant-numeric: tabular-nums; +} + /* Long semantic labels wrap inside the pane instead of overflowing. */ .ss-obs { overflow: hidden; diff --git a/apps/web/src/storage/RuntimeStorage.tsx b/apps/web/src/storage/RuntimeStorage.tsx index 2507781..9bf5d8a 100644 --- a/apps/web/src/storage/RuntimeStorage.tsx +++ b/apps/web/src/storage/RuntimeStorage.tsx @@ -2,12 +2,19 @@ import type { SemanticResolution } from '@slotscope/domain'; import { MigrationBadge, StatusPill } from '@slotscope/ui'; /** - * Runtime storage grouping (S5): the slots the last traced execution - * actually wrote, grouped under the static variable they derive from and - * carrying their resolved labels — the runtime companion to the static - * grid. Also how initializer values become visible: a constructor is an - * execution like any other, so its writes appear here immediately after - * deploy — never parsed from source, only measured. + * The write log (S5): every persistent or transient write the last traced + * execution performed, in the order it performed them. + * + * It deliberately does NOT group by variable. The storage grid above is the + * map — declared slots with their reserved heads and keccak-derived children — + * and two maps of the same state, ordered differently, make a reader work out + * which one to believe. This answers the other question: what happened, and in + * what order. A repeated write to one slot appears once per write, because the + * sequence is the point. + * + * Also how initializer values become visible: a constructor is an execution + * like any other, so its writes appear here immediately after deploy — never + * parsed from source, only measured. */ export function RuntimeStorage({ resolutions, @@ -20,46 +27,34 @@ export function RuntimeStorage({ const writes = resolutions.filter((r) => r.kind === 'sstore' || r.kind === 'tstore'); if (writes.length === 0) return null; - const groups = new Map>(); - for (const resolution of writes) { - const groupKey = resolution.baseVariable ?? '(unresolved)'; - const labelKey = resolution.label ?? `raw ${resolution.accessId}`; - const group = groups.get(groupKey) ?? new Map(); - group.set(labelKey, resolution); // later writes replace earlier: last value wins - groups.set(groupKey, group); - } - return (

- Runtime storage ({origin === 'constructor' ? 'from the constructor' : 'last traced call'}) + Writes in order ({origin === 'constructor' ? 'from the constructor' : 'last traced call'})

- {[...groups.entries()].map(([groupName, byLabel]) => ( -
- {groupName} -
    - {[...byLabel.values()].map((resolution) => ( -
  • - {' '} - - {resolution.label ?? 'unresolved slot'} - {resolution.decodedValues.length > 0 && ( - <> - {' = '} - - {resolution.decodedValues.map((value) => value.display).join(' · ')} - - - )} - - {resolution.migration !== null && ( - - )} -
  • - ))} -
-
- ))} +
    + {writes.map((resolution) => ( +
  1. + step {resolution.stepIndex}{' '} + {' '} + + {resolution.label ?? 'unresolved slot'} + {resolution.kind === 'tstore' && ( + · transient + )} + {resolution.decodedValues.length > 0 && ( + <> + {' = '} + + {resolution.decodedValues.map((value) => value.display).join(' · ')} + + + )} + + {resolution.migration !== null && } +
  2. + ))} +
); } diff --git a/apps/web/src/storage/StorageGrid.tsx b/apps/web/src/storage/StorageGrid.tsx index 0f6244c..d851711 100644 --- a/apps/web/src/storage/StorageGrid.tsx +++ b/apps/web/src/storage/StorageGrid.tsx @@ -3,6 +3,7 @@ import type { CollapsedStorageSpan, ContractArtifact } from '@slotscope/domain'; import { LayoutDecodeError, decodeStorageLayout } from '@slotscope/layout'; import { buildRows, type RowSegment } from './buildRows'; import { ownerChipText } from './ownerChip'; +import { buildDerivedChildren, type DerivedChild } from './derivedRows'; import { readReference, type ReferenceReading } from './referenceValue'; import type { StorageRuntimeView } from './runtimeView'; @@ -124,6 +125,34 @@ export function StorageGrid({ return owners; }, [decoded, ownerSuffixByContract]); + // Children attach here, not in buildRows: the gapless invariant is about + // DECLARED slots, and a keccak-derived slot is not one of them. + const derived = useMemo(() => { + if (decoded === null) return { byParentSlot: new Map(), orphans: [] }; + const declared = new Set(decoded.rows.map((row) => row.slot.toString())); + return buildDerivedChildren( + runtime?.observations ?? null, + runtime?.resolutions ?? [], + declared, + ); + }, [decoded, runtime]); + + // Roving focus indexes a flat list, so a child row is reachable by arrow keys + // exactly like a slot row. One entry per navigable row, holding its cell count. + const focusGeometry = useMemo(() => { + const cellCounts: number[] = []; + const flatIndexByRow: number[] = []; + if (decoded !== null) { + for (const row of decoded.rows) { + flatIndexByRow.push(cellCounts.length); + cellCounts.push(row.segments.length); + const kids = derived.byParentSlot.get(row.slot.toString()) ?? []; + for (let i = 0; i < kids.length; i += 1) cellCounts.push(1); + } + } + return { cellCounts, flatIndexByRow }; + }, [decoded, derived]); + if (artifact === null || artifact.storageLayout === null) { return (
@@ -145,11 +174,12 @@ export function StorageGrid({ function moveFocus(rowDelta: number, cellDelta: number, absolute?: 'home' | 'end') { if (decoded === null) return; let [row, cellIndex] = focusPos; - row = Math.min(Math.max(row + rowDelta, 0), decoded.rows.length - 1); - const segments = decoded.rows[row]?.segments ?? []; + const counts = focusGeometry.cellCounts; + row = Math.min(Math.max(row + rowDelta, 0), counts.length - 1); + const cells = counts[row] ?? 1; if (absolute === 'home') cellIndex = 0; - else if (absolute === 'end') cellIndex = segments.length - 1; - else cellIndex = Math.min(Math.max(cellIndex + cellDelta, 0), segments.length - 1); + else if (absolute === 'end') cellIndex = cells - 1; + else cellIndex = Math.min(Math.max(cellIndex + cellDelta, 0), cells - 1); setFocusPos([row, cellIndex]); gridRef.current?.querySelector(`[data-grid-pos="${row}:${cellIndex}"]`)?.focus(); } @@ -203,7 +233,7 @@ export function StorageGrid({
- {decoded.rows.map((row, rowIndex) => { + {decoded.rows.map((row, declaredIndex) => { + const rowIndex = focusGeometry.flatIndexByRow[declaredIndex] ?? declaredIndex; + const children = derived.byParentSlot.get(row.slot.toString()) ?? []; const isSlotPinned = pinnedSlot === row.slot.toString(); // MSB-first: the record reads in the same direction as the band. // A reserved row has exactly one segment; its record replaces the @@ -419,9 +451,83 @@ export function StorageGrid({
)} + {children.length > 0 && ( +
+

+ derived · not the next slot — these live at keccak-derived addresses, shown as + the EVM computes them +

+ {children.map((child, childIndex) => { + const flatIndex = rowIndex + 1 + childIndex; + const isFocused = focusPos[0] === flatIndex && focusPos[1] === 0; + const aria = `${child.rail}: ${child.title}${ + child.valueDisplay !== null ? `, last observed ${child.valueDisplay}` : '' + }${child.readOnly ? ', read only in this run' : ''}, ${child.status}`; + return ( +
+
+ {child.rail} +
+
{ + setFocusPos([flatIndex, 0]); + setDetails(aria); + }} + onClick={() => { + setFocusPos([flatIndex, 0]); + setDetails(aria); + }} + > + + {child.title} + {child.readOnly && ( + read only + )} + {child.valueDisplay !== null && ( + {child.valueDisplay} + )} + +
+
+ ); + })} +
+ )} ); })} + {derived.orphans.length > 0 && ( +
+

+ touched, but not traceable to a declared slot — the run reached these by means the + layout cannot explain +

+ {derived.orphans.map((child) => ( +
+
+ {child.title} +
+
+ no derivation reconstructed +
+
+ ))} +
+ )}

diff --git a/apps/web/src/storage/derivedRows.test.ts b/apps/web/src/storage/derivedRows.test.ts new file mode 100644 index 0000000..c35532a --- /dev/null +++ b/apps/web/src/storage/derivedRows.test.ts @@ -0,0 +1,86 @@ +import { encodeCall } from '@slotscope/executor'; +import { deployContract, fn } from '@slotscope/executor/testing'; +import { resolveAccesses } from '@slotscope/semantics'; +import { describe, expect, it } from 'vitest'; +import { buildDerivedChildren } from './derivedRows'; + +/** Slots 0..3 are declared; every entry the calls touch is derived. */ +const SOURCE = `// SPDX-License-Identifier: MIT +pragma solidity 0.8.36; + +contract Derived { + uint256 internal counter; // slot 0 + mapping(address => uint256) internal balances; // slot 1 + mapping(address => mapping(address => uint256)) internal allowance; // slot 2 + uint256[] internal history; // slot 3 + + function bump() external { counter += 1; } + function set(address who, uint256 v) external { balances[who] = v; } + function read(address who) external returns (uint256) { counter = balances[who]; return counter; } + function approve(address o, address s, uint256 v) external { allowance[o][s] = v; } + function push(uint256 v) external { history.push(v); } +} +`; + +const WHO = `0x${'aa'.repeat(20)}`; +const OTHER = `0x${'bb'.repeat(20)}`; +const DECLARED = new Set(['0', '1', '2', '3']); + +async function run(name: string, types: string[], args: string[]) { + const { executor, address, layout } = await deployContract(SOURCE, 'Derived'); + const result = await executor.call( + { toHex: address, dataHex: encodeCall(fn(name, types), args) }, + { captureTrace: true }, + ); + expect(result.success).toBe(true); + const resolutions = resolveAccesses(result.observations!, layout); + return buildDerivedChildren(result.observations!, resolutions, DECLARED); +} + +describe('buildDerivedChildren', () => { + it('writes a mapping entry as keccak(key, base) under its declared head', async () => { + const { byParentSlot, orphans } = await run('set', ['address', 'uint256'], [WHO, '42']); + expect(orphans).toEqual([]); + const children = byParentSlot.get('1'); + expect(children).toHaveLength(1); + expect(children![0]!.rail).toBe('keccak(0xaaaaaa…, 1)'); + expect(children![0]!.title).toBe(`balances[${WHO}]`); + expect(children![0]!.valueDisplay).toBe('42'); + expect(children![0]!.status).toBe('exact'); + expect(children![0]!.readOnly).toBe(false); + }); + + it('never treats a declared slot as a derived child', async () => { + const { byParentSlot, orphans } = await run('bump', [], []); + expect(byParentSlot.size).toBe(0); + expect(orphans).toEqual([]); + }); + + it('shows a nested mapping hop as a nested derivation', async () => { + const { byParentSlot } = await run( + 'approve', + ['address', 'address', 'uint256'], + [WHO, OTHER, '7'], + ); + const children = byParentSlot.get('2'); + expect(children).toHaveLength(1); + // The inner hash is itself hashed: the rail shows both hops. + expect(children![0]!.rail).toBe('keccak(0xbbbbbb…, keccak(0xaaaaaa…, 2))'); + expect(children![0]!.title).toBe(`allowance[${WHO}][${OTHER}]`); + }); + + it('attaches an array element to its length head', async () => { + const { byParentSlot } = await run('push', ['uint256'], ['9']); + const children = byParentSlot.get('3'); + expect(children).toHaveLength(1); + expect(children![0]!.rail).toBe('keccak(3)'); + expect(children![0]!.valueDisplay).toBe('9'); + }); + + it('marks an entry that was only read', async () => { + const { byParentSlot } = await run('read', ['address'], [WHO]); + const children = byParentSlot.get('1'); + expect(children).toHaveLength(1); + expect(children![0]!.readOnly).toBe(true); + }); +}); diff --git a/apps/web/src/storage/derivedRows.ts b/apps/web/src/storage/derivedRows.ts new file mode 100644 index 0000000..2975bbe --- /dev/null +++ b/apps/web/src/storage/derivedRows.ts @@ -0,0 +1,192 @@ +import type { SemanticResolution, TraceObservations } from '@slotscope/domain'; + +export interface DerivedChild { + /** Decimal slot, for value lookup and keying. */ + readonly slot: string; + /** The derivation, e.g. `keccak(0xaa…, 5)` or `keccak(6)+2`. */ + readonly rail: string; + /** Resolved name when semantics grounded it, else the short slot hex. */ + readonly title: string; + readonly valueDisplay: string | null; + readonly status: 'exact' | 'inferred' | 'unresolved'; + /** First step that touched this slot: the ordering key. */ + readonly firstStep: number; + /** Only ever read in this run, never written. */ + readonly readOnly: boolean; +} + +export interface DerivedChildren { + /** Declared base slot (decimal) → its derived entries, first-touch order. */ + readonly byParentSlot: ReadonlyMap; + /** Touched slots that are neither declared nor traceable to a head. */ + readonly orphans: readonly DerivedChild[]; +} + +const EMPTY: DerivedChildren = { byParentSlot: new Map(), orphans: [] }; + +/** How far past a hash we will look for an array element or data word. */ +const MAX_DELTA = 4096n; + +function shortHex(hex: string): string { + const trimmed = hex.replace(/^0+(?=.)/, ''); + return trimmed.length <= 10 ? `0x${trimmed}` : `0x${trimmed.slice(0, 6)}…`; +} + +interface Chain { + /** Declared slot the chain ultimately rests on. */ + readonly baseSlot: bigint; + /** The derivation, written the way the EVM computes it. */ + readonly rail: string; +} + +interface HashFact { + readonly hash: bigint; + readonly input: string; +} + +/** + * Reconstruct how the EVM reached a slot, from recorded hashes alone. + * + * A mapping preimage is `key ‖ base` (64 bytes), so both halves are exact. An + * array element or long-string word sits at `keccak(base) + delta`. A nested hop + * hashes `key ‖ innerHash`, where the tail is itself a recorded hash — so the + * tail is followed until it is a plain slot. Nothing here parses a label. + */ +function reconstruct( + slot: bigint, + inputByHash: ReadonlyMap, + hashes: readonly HashFact[], + depth = 0, +): Chain | null { + if (depth > 8) return null; + + const exact = inputByHash.get(slot.toString()); + if (exact !== undefined) return chainOf(exact, inputByHash, hashes, depth); + + for (const { hash, input } of hashes) { + if (slot <= hash) continue; + const delta = slot - hash; + if (delta > MAX_DELTA) continue; + const inner = chainOf(input, inputByHash, hashes, depth); + if (inner === null) continue; + return { baseSlot: inner.baseSlot, rail: `${inner.rail}+${delta}` }; + } + return null; +} + +function chainOf( + inputHex: string, + inputByHash: ReadonlyMap, + hashes: readonly HashFact[], + depth: number, +): Chain | null { + // 32 bytes: keccak(baseSlot) — an array or bytes/string data region. + if (inputHex.length === 64) { + const base = BigInt(`0x${inputHex}`); + return { baseSlot: base, rail: `keccak(${base})` }; + } + // 64 bytes: keccak(key ‖ base), where base may itself be a hash (nested hop). + if (inputHex.length === 128) { + const key = inputHex.slice(0, 64); + const tail = BigInt(`0x${inputHex.slice(64)}`); + const nested = reconstruct(tail, inputByHash, hashes, depth + 1); + if (nested !== null) { + return { baseSlot: nested.baseSlot, rail: `keccak(${shortHex(key)}, ${nested.rail})` }; + } + return { baseSlot: tail, rail: `keccak(${shortHex(key)}, ${tail})` }; + } + return null; +} + +interface SlotFacts { + firstStep: number; + readOnly: boolean; + title: string | null; + status: DerivedChild['status']; + valueDisplay: string | null; +} + +/** + * Every keccak-derived slot the run touched, attached to the declared slot it + * descends from. + * + * The child set spans the whole run rather than stopping at the cursor: roving + * focus is index-based, so a row appearing mid-scrub would move focus under the + * reader. The cursor changes values, never row positions. + * + * `declaredSlots` separates the two kinds of unreconstructable slot: one the + * compiler declared (already a grid row, not a child) from one the run reached by + * means we cannot show, which becomes an honest orphan rather than a silent drop. + */ +export function buildDerivedChildren( + observations: TraceObservations | null, + resolutions: readonly SemanticResolution[], + declaredSlots: ReadonlySet, +): DerivedChildren { + if (observations === null || observations.storage.length === 0) return EMPTY; + + const inputByHash = new Map(); + const hashes: HashFact[] = []; + for (const observation of observations.keccak) { + const hash = BigInt(`0x${observation.hashHex}`); + inputByHash.set(hash.toString(), observation.inputHex); + hashes.push({ hash, input: observation.inputHex }); + } + + const byStep = new Map(); + for (const resolution of resolutions) byStep.set(resolution.stepIndex, resolution); + + // One pass: per-slot facts in first-touch order. + const facts = new Map(); + const order: string[] = []; + for (const access of observations.storage) { + if (access.kind === 'tload' || access.kind === 'tstore') continue; + const slot = BigInt(`0x${access.slotHex}`).toString(); + let entry = facts.get(slot); + if (entry === undefined) { + entry = { + firstStep: access.stepIndex, + readOnly: true, + title: null, + status: 'unresolved', + valueDisplay: null, + }; + facts.set(slot, entry); + order.push(slot); + } + if (access.kind === 'sstore') entry.readOnly = false; + const resolution = byStep.get(access.stepIndex); + if (resolution !== undefined) { + if (resolution.label !== null) entry.title = resolution.label; + entry.status = resolution.status; + const decoded = resolution.decodedValues[0]?.display; + if (decoded !== undefined) entry.valueDisplay = decoded; + } + } + + const byParentSlot = new Map(); + const orphans: DerivedChild[] = []; + for (const slot of order) { + if (declaredSlots.has(slot)) continue; + const entry = facts.get(slot)!; + const chain = hashes.length === 0 ? null : reconstruct(BigInt(slot), inputByHash, hashes); + const child: DerivedChild = { + slot, + rail: chain?.rail ?? 'underivable', + title: entry.title ?? shortHex(BigInt(slot).toString(16)), + valueDisplay: entry.valueDisplay, + status: entry.status, + firstStep: entry.firstStep, + readOnly: entry.readOnly, + }; + if (chain === null) { + orphans.push(child); + continue; + } + const parent = chain.baseSlot.toString(); + const bucket = byParentSlot.get(parent); + if (bucket === undefined) byParentSlot.set(parent, [child]); + else bucket.push(child); + } + return { byParentSlot, orphans }; +} diff --git a/apps/web/src/storage/runtimeView.ts b/apps/web/src/storage/runtimeView.ts index d66cb96..bf8031e 100644 --- a/apps/web/src/storage/runtimeView.ts +++ b/apps/web/src/storage/runtimeView.ts @@ -5,11 +5,17 @@ export interface StorageRuntimeView { readonly wordsBySlot: ReadonlyMap; /** Base variable → how many distinct derived entries this run has named. */ readonly derivedCountByBase: ReadonlyMap; + /** Raw Layer-1 facts, so the grid can reconstruct derived slots itself. It + * needs the declared-slot set to do that, which only the grid knows. */ + readonly observations: TraceObservations | null; + readonly resolutions: readonly SemanticResolution[]; } const EMPTY: StorageRuntimeView = { wordsBySlot: new Map(), derivedCountByBase: new Map(), + observations: null, + resolutions: [], }; /** @@ -54,5 +60,5 @@ export function buildRuntimeView( const derivedCountByBase = new Map(); for (const [base, labels] of derivedLabels) derivedCountByBase.set(base, labels.size); - return { wordsBySlot, derivedCountByBase }; + return { wordsBySlot, derivedCountByBase, observations, resolutions }; } diff --git a/docs/design/derived-slots-v1.4.md b/docs/design/derived-slots-v1.4.md new file mode 100644 index 0000000..f0d4cb8 --- /dev/null +++ b/docs/design/derived-slots-v1.4.md @@ -0,0 +1,108 @@ +# SlotScope Derived Slots — design spec (phase v1.4) + +**Status:** approved (owner greenlight, 2026-07-30) +**Beads:** `slotscope-xu8` (+ `.1`, `.2`) +**Depends on:** `slotscope-zvd` (reserved rows) and `slotscope-02a` (nested heads), +both merged. +**Gates:** unchanged. Identity law from `docs/design/instrument-identity-v0.15.md` +applies verbatim. + +## Thesis + +`slotscope-zvd` gave every reserved slot a row. It could not show what actually +lives in a mapping, because those entries sit at `keccak(key ‖ slot)` — a number +near 2^256, nowhere near the declared sequence. + +**The teaching payload is the derivation, not the number.** So a derived entry's +address is written the way the EVM computes it: `keccak(0xaa…, 5)`. That string +goes in the slot rail, which is the address column everywhere in this instrument +(line numbers, PCs, slot numbers). The raw 32-byte slot is a hover detail, not the +identity. + +## Where a child comes from (machine facts only) + +Every derived slot is reconstructed from Layer-1 observations, never from parsing a +display label: + +- `KeccakObservation` carries `inputHex` (the exact hashed bytes) and `hashHex`. +- A mapping entry's preimage is 64 bytes: `key ‖ baseSlot`. Split it and you have + both halves exactly. +- An array element or long-string data word is `keccak(baseSlot) + delta`, so a + bounded scan over recorded hashes finds the parent and the offset. +- A nested mapping hop (`allowance[a][b]`) has `key_b ‖ innerHash` as its preimage, + where `innerHash` is itself a recorded hash. Recurse until the tail is a declared + slot. + +**This means parent linkage is slot arithmetic, not name matching.** The base slot +that falls out of the preimage is compared against the reserved row's own slot. A +mapping nested inside a struct therefore attaches correctly with no special case, +and the `derivedCountByBase` name-matching gap from v1.2 disappears rather than +being patched. + +## Architecture + +### 1. `apps/web/src/storage/derivedRows.ts` (`xu8.1`) + +A new pure module. `buildRows` stays compiler-only — the moment it reads runtime +data the gapless invariant stops meaning anything — so children attach at the view +layer: + +```ts +export interface DerivedChild { + /** Decimal slot, for value lookup. */ + readonly slot: string; + /** The derivation, e.g. `keccak(0xaa…, 5)` or `keccak(6)+2`. */ + readonly rail: string; + /** Resolved name when semantics grounded it, else the short slot hex. */ + readonly title: string; + readonly valueDisplay: string | null; + readonly status: 'exact' | 'inferred' | 'unresolved'; + /** First step that touched it: the ordering key. */ + readonly firstStep: number; + readonly readOnly: boolean; +} + +export function buildDerivedChildren( + observations: TraceObservations | null, + resolutions: readonly SemanticResolution[], + /** Slots the compiler declared, so a plain slot is never mistaken for an orphan. */ + declaredSlots: ReadonlySet, +): DerivedChildren; +``` + +Decisions baked in: + +- **Frozen child set.** Children come from the whole run, not from `stepIndex <= +cursor`. `focusPos` is `[rowIndex, cellIndex]`, so a row appearing mid-scrub would + move focus under the reader. The cursor changes _values_, never row positions. +- **Reads included.** `sload`-only entries appear, marked read-only. Nothing in the + product surfaces them today, and "this was read but never written" is a fact + worth teaching. +- **First-touch order**, matching `LiveStorageStrip` and preserving replay + determinism. +- **Orphans are never dropped.** A touched slot whose parent cannot be reconstructed + is returned separately and rendered under its own honest heading. + +### 2. `StorageGrid` rendering (`xu8.1`) + +Children render inside their parent's rowgroup, after the reserved band, with an +explicit break so nobody reads one as the next slot: an indent, a dashed leader, +and the words `derived · not the next slot` on the group. + +The roving-focus model generalizes from "rows" to a flat list of navigable rows, so +arrow keys reach a child and `Enter` opens its details. A child contributes one +full-width cell. `aria-label` states the derivation, the name, and the value. + +### 3. `RuntimeStorage` becomes a write log (`xu8.2`) + +Once the grid owns the per-variable map, `RuntimeStorage` must stop being a second +one. It becomes writes in observation order with step numbers, keeping the three +things that have no other home: the resolution status pill, the migration badge, +and the constructor-origin heading. + +## Out of scope + +- Mapping-key probing (entering a key to compute a slot without executing). +- Any change to `buildRows`, the collapse policy, or the keccak grounding in + `packages/semantics`. +- Retiring `ObservationList` or `LiveStorageStrip`.