diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 895e16e..666d18f 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -80,3 +80,10 @@ {"id":"int-78d92a08ad438bc04c0142e555c53902","kind":"field_change","created_at":"2026-07-29T07:12:36.373056725Z","actor":"halaprix","issue_id":"slotscope-tkd","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"v2 identity applied (band+record, resolution treatment on runtime surfaces, contrast promotions, chassis lock restored); inventory doc written for the next design round; 209 unit + 57 e2e green."}} {"id":"int-508bcad5d22f7e38c6348ba64ec7e735","kind":"field_change","created_at":"2026-07-29T09:17:25.835199235Z","actor":"halaprix","issue_id":"slotscope-bnq","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"PR #1 merged into master (83fd429); Variant B cockpit-bar workbench live, review fixes (reserved-hue violations, call index) landed before merge."}} {"id":"int-5f6e4259a9ea3d7c490343f6a09f79cf","kind":"field_change","created_at":"2026-07-29T09:27:34.871060006Z","actor":"halaprix","issue_id":"slotscope-p1t","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented: deploy captures constructor trace via captureTrace:true, resolveAccesses runs over it identically to a call trace, RuntimeStorage labels the group 'from the constructor'. New e2e test covers non-zero + zero-valued initializers. All gates green (209 unit, 58 e2e, lint/format/typecheck/build/fixtures/deps-policy/token-discipline)."}} +{"id":"int-74ceaeac7b18e1d6d73229cf26040dce","kind":"field_change","created_at":"2026-07-30T10:09:27.063987887Z","actor":"halaprix","issue_id":"slotscope-73y","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed via EditorView.theme({...}, {dark: true}) in SourceEditor.tsx. Root cause: CodeMirror injects base styles as a StyleModule, so a plain .cm-tooltip rule in app.css lost the cascade at equal specificity and the widget kept its stock #f5f5f5 background under light text. A CSS-only fix was tried first and verifiably failed (received rgb(245,245,245)); the theme extension is the supported mechanism. Verified: new axe-gated e2e hovers the lint marker and asserts rgb(35,32,32); 59/59 e2e, 209/209 unit, lint and typecheck all pass. Commit pending owner approval."}} +{"id":"int-ad8880584f11af1a0add2fe779245102","kind":"field_change","created_at":"2026-07-30T10:23:23.09280563Z","actor":"halaprix","issue_id":"slotscope-zvd.1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"StorageEncoding type + CollapsedStorageSpan.encoding populated at all five collapse sites; unknown encodings now throw LayoutDecodeError instead of being guessed. 16/16 layout tests, 212/212 unit, typecheck clean."}} +{"id":"int-cb5e2ee25b4426acfdea6a11226cad36","kind":"field_change","created_at":"2026-07-30T10:26:02.145821017Z","actor":"halaprix","issue_id":"slotscope-zvd.2","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"buildRows now walks a sorted claim list: reference heads get their own row, multi-slot inplace collapses become one elided range, unclaimed runs become an honest empty elided range. Gapless by construction, proved by a fast-check property suite (300 runs x2). 9/9 storage tests, 219/219 unit, lint+typecheck clean."}} +{"id":"int-ed1713e43409e336e1468796d1ae102a","kind":"field_change","created_at":"2026-07-30T10:31:04.596194724Z","actor":"halaprix","issue_id":"slotscope-zvd.3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"runtimeView folds observed words (reads included, cursor-bounded, transient excluded) plus distinct derived-entry counts; referenceValue turns (span, word) into per-encoding copy and one measured value; StorageGrid renders reserved and elided rows with amber only on measured words, retires the 'Not expanded' list, and keeps roving focus/pin/zoom/axe green. 234/234 unit, 8/8 storage e2e, format+lint+typecheck clean."}} +{"id":"int-b276681d8d66b2c92c0ead9c38ffd11d","kind":"field_change","created_at":"2026-07-30T10:31:04.75317577Z","actor":"halaprix","issue_id":"slotscope-zvd.4","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"runtimeView folds observed words (reads included, cursor-bounded, transient excluded) plus distinct derived-entry counts; referenceValue turns (span, word) into per-encoding copy and one measured value; StorageGrid renders reserved and elided rows with amber only on measured words, retires the 'Not expanded' list, and keeps roving focus/pin/zoom/axe green. 234/234 unit, 8/8 storage e2e, format+lint+typecheck clean."}} +{"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."}} diff --git a/CHANGELOG.md b/CHANGELOG.md index f8d1670..794a10e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ 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.2.0] — unreleased + +### Added + +- **Reserved reference slots are visible.** The slot a mapping, dynamic array, + `bytes` or `string` reserves now gets its own labeled row instead of leaving a + silent hole in the slot sequence, and the row shows what the machine was + observed to put there: an array or long-string length, a short string's inline + data, and nothing at all for a mapping head — which solc never writes, so a + value there is flagged as an anomaly. Rows are gapless by construction; an + unexpanded multi-slot span becomes one honest elided range instead of thousands + of rows. + +### Changed + +- `CollapsedStorageSpan` carries solc's `encoding`, so the UI classifies spans by + compiler output instead of by parsing display labels. An encoding solc does not + emit now fails loudly rather than being treated as one of the four. +- The "Not expanded" prose list is retired; its teaching copy moved onto the rows + and the per-slot record. + +### Fixed + +- CodeMirror tooltips, diagnostics and the search panel were rendering with the + library's light-theme defaults under the app's light text, which made them + effectively unreadable. They are themed through an `EditorView.theme` marked + `dark`, so the cascade is correct by construction rather than by specificity. + ## [1.1.0] — 2026-07-29 · Cockpit workbench A post-launch UX pass: the full measure loop — compile → deploy → call → diff --git a/apps/web/e2e/editor.spec.ts b/apps/web/e2e/editor.spec.ts index 755a49f..a735cfd 100644 --- a/apps/web/e2e/editor.spec.ts +++ b/apps/web/e2e/editor.spec.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import AxeBuilder from '@axe-core/playwright'; import { expect, test } from '@playwright/test'; const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); @@ -107,3 +108,23 @@ test('an abstract artifact is visibly unavailable, never empty-successful', asyn 'no deployed bytecode', ); }); + +test('a diagnostic tooltip is readable on the dark chassis and axe-clean', async ({ page }) => { + await page.goto('/'); + await waitReady(page); + await page.locator('.cm-content').fill(invalidSource); + await page.getByRole('button', { name: 'Compile' }).click(); + + const marker = page.locator('.cm-lint-marker-error'); + await expect(marker).toHaveCount(1, { timeout: 60_000 }); + await marker.hover(); + + const tooltip = page.locator('.cm-tooltip-lint'); + await expect(tooltip).toBeVisible(); + // CodeMirror's own tooltip background is light (#f5f5f5). The instrument theme + // must win, or the tooltip renders light text on a light surface. + await expect(tooltip).toHaveCSS('background-color', 'rgb(35, 32, 32)'); + + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations).toEqual([]); +}); diff --git a/apps/web/e2e/storage.spec.ts b/apps/web/e2e/storage.spec.ts index c421295..d0fdb9c 100644 --- a/apps/web/e2e/storage.spec.ts +++ b/apps/web/e2e/storage.spec.ts @@ -128,9 +128,41 @@ test('axe scan passes in both motion modes', async ({ page }) => { expect(reduced.violations).toEqual([]); }); -test('a mapping base slot explains itself honestly, with no stale version claim', async ({ - page, -}) => { +test('every reserved reference slot gets its own row, with no skipped slot', 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 uint256 a;\n mapping(address => uint256) internal balances;\n uint256 b;\n uint256[] internal history;\n string internal note;\n}', + ); + await page.getByRole('button', { name: 'Compile' }).click(); + const grid = page.getByRole('grid', { name: 'Storage layout' }); + await expect(grid).toBeVisible({ timeout: 60_000 }); + + // Slots 0..4 are all present: nothing is skipped. + for (const slot of [0, 1, 2, 3, 4]) { + await expect(grid.getByText(`slot ${slot}`, { exact: true })).toBeVisible(); + } + + const reserved = grid.locator('[data-testid="storage-reference"]'); + await expect(reserved).toHaveCount(3); + await expect(reserved.nth(0)).toHaveAttribute('aria-label', /balances.*reserved base slot/); + await expect(reserved.nth(0)).toContainText('RESERVED · mapping'); + await expect(reserved.nth(1)).toContainText('LENGTH · array'); + await expect(reserved.nth(2)).toContainText('note'); + + // The old prose list is gone; the rows carry the teaching copy now. + await expect(page.locator('[data-testid="collapsed-spans"]')).toHaveCount(0); + await expect(grid).toContainText('keccak-derived slots that exist only at runtime'); + + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations).toEqual([]); +}); + +test('a reserved slot shows the value the constructor put in it', async ({ page }) => { await page.goto('/'); await expect(page.locator('[role="status"]')).toHaveAttribute('data-status', 'ready', { timeout: 60_000, @@ -138,12 +170,36 @@ test('a mapping base slot explains itself honestly, with no stale version claim' await page .locator('.cm-content') .fill( - 'pragma solidity 0.8.36;\ncontract M { uint256 a; mapping(bytes32 => address) internal klop; }', + 'pragma solidity 0.8.36;\ncontract M {\n uint256[] internal history;\n string internal note;\n constructor() {\n history.push(7);\n note = "hi";\n }\n}', ); await page.getByRole('button', { name: 'Compile' }).click(); - const collapsed = page.locator('[data-testid="collapsed-spans"]'); - await expect(collapsed).toBeVisible({ timeout: 60_000 }); - await expect(collapsed).toContainText('klop (mapping(bytes32 => address)): base slot 1'); - await expect(collapsed).toContainText('keccak-derived slots that exist only at runtime'); - await expect(collapsed).not.toContainText('v0.1'); + const grid = page.getByRole('grid', { name: 'Storage layout' }); + await expect(grid).toBeVisible({ timeout: 60_000 }); + await page.getByRole('button', { name: 'Deploy locally' }).click(); + + const reserved = grid.locator('[data-testid="storage-reference"]'); + await expect(reserved.nth(0)).toContainText('length = 1', { timeout: 60_000 }); + await expect(reserved.nth(1)).toContainText('"hi"'); +}); + +test('the default source shows its reserved slots, then their values on deploy', async ({ + page, +}) => { + await compileFixture(page); + const grid = page.getByRole('grid', { name: 'Storage layout' }); + + // Slots 0..4 keep their existing meaning; 5..7 are the new reference heads. + const reserved = grid.locator('[data-testid="storage-reference"]'); + await expect(reserved).toHaveCount(3); + await expect(reserved.nth(0)).toContainText('balances'); + await expect(reserved.nth(0)).toContainText('RESERVED · mapping'); + await expect(reserved.nth(1)).toContainText('history'); + await expect(reserved.nth(2)).toContainText('note'); + await expect(grid.getByText('slot 7', { exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'Deploy locally' }).click(); + await expect(reserved.nth(1)).toContainText('length = 1', { timeout: 60_000 }); + await expect(reserved.nth(2)).toContainText('"slotscope"'); + // solc never writes a mapping head, so it stays blank. + await expect(reserved.nth(0)).not.toContainText('0x'); }); diff --git a/apps/web/package.json b/apps/web/package.json index 7672ed0..a5f6041 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -39,6 +39,7 @@ "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "6.0.4", "axe-core": "4.12.1", + "fast-check": "4.9.0", "typescript": "6.0.3", "vite": "8.1.5", "vitest": "4.1.10" diff --git a/apps/web/src/app/app.css b/apps/web/src/app/app.css index b007bcf..5d6a53e 100644 --- a/apps/web/src/app/app.css +++ b/apps/web/src/app/app.css @@ -1260,6 +1260,44 @@ input[readonly] { ); } +/* A reserved slot is not padding, so it never takes the hatch. Graphite while + only the compiler has spoken; the amber inside it is the measured word. */ +.storage-cell.is-reference { + 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-reference-eyebrow { + color: var(--ss-color-fg-muted); + font-size: var(--ss-font-size-micro); + letter-spacing: var(--ss-tracking-label); + text-transform: uppercase; + white-space: nowrap; +} + +/* solc never writes a mapping head: a value here is a finding, not a reading. */ +.storage-cell.is-reference.is-anomaly { + border-color: var(--ss-color-warning); +} + +.storage-cell.is-elided { + display: flex; + align-items: center; + padding-inline: 0.5rem; + border: 1px dashed var(--ss-color-border-strong); + background: transparent; + color: var(--ss-color-fg-muted); +} + +.storage-record-note { + color: var(--ss-color-fg-muted); +} + .owner-a { background: var(--ss-color-owner-a-surface); border-color: var(--ss-color-owner-a-border); diff --git a/apps/web/src/editor/SourceEditor.tsx b/apps/web/src/editor/SourceEditor.tsx index c376095..1c72744 100644 --- a/apps/web/src/editor/SourceEditor.tsx +++ b/apps/web/src/editor/SourceEditor.tsx @@ -17,6 +17,68 @@ import { solidity } from '@replit/codemirror-lang-solidity'; import { SOURCE_PATH } from '@slotscope/compiler'; import { Utf8IndexMap, type CompilerDiagnostic } from '@slotscope/domain'; +/** + * Dark theming for CodeMirror's own floating widgets. + * + * CodeMirror injects its base styles as a StyleModule, so a plain `.cm-tooltip` + * rule in app.css loses the cascade at equal specificity and the widget keeps + * its stock light background under our light text. A theme extension is the + * supported fix: CodeMirror injects it after the base sheet, and `dark: true` + * makes it pick dark defaults for anything not named here. + */ +const instrumentWidgets = EditorView.theme( + { + '.cm-tooltip': { + background: 'var(--ss-color-raised)', + border: '1px solid var(--ss-color-border-strong)', + borderRadius: '2px', + color: 'var(--ss-color-fg)', + }, + '.cm-tooltip .cm-tooltip-arrow:before': { + borderTopColor: 'var(--ss-color-border-strong)', + borderBottomColor: 'var(--ss-color-border-strong)', + }, + '.cm-tooltip .cm-tooltip-arrow:after': { + borderTopColor: 'var(--ss-color-raised)', + borderBottomColor: 'var(--ss-color-raised)', + }, + '.cm-tooltip-lint': { fontSize: 'var(--ss-font-size-micro)', maxWidth: '32rem', padding: '0' }, + '.cm-diagnostic': { + borderLeft: '3px solid var(--ss-color-border-strong)', + color: 'var(--ss-color-fg)', + padding: '0.35rem 0.6rem', + }, + '.cm-diagnostic-error': { borderLeftColor: 'var(--ss-color-error)' }, + '.cm-diagnostic-warning': { borderLeftColor: 'var(--ss-color-warning)' }, + '.cm-diagnostic-info': { borderLeftColor: 'var(--ss-color-label)' }, + '.cm-diagnosticAction': { + background: 'var(--ss-color-surface)', + border: '1px solid var(--ss-color-border-strong)', + color: 'var(--ss-color-fg)', + }, + // The Ctrl-F panel and its controls. + '.cm-panels': { + background: 'var(--ss-color-raised)', + borderColor: 'var(--ss-color-border-strong)', + color: 'var(--ss-color-fg)', + }, + '.cm-panels-bottom': { borderTop: '1px solid var(--ss-color-border-strong)' }, + '.cm-panels-top': { borderBottom: '1px solid var(--ss-color-border-strong)' }, + '.cm-textfield': { + background: 'var(--ss-color-surface)', + border: '1px solid var(--ss-color-border-strong)', + color: 'var(--ss-color-fg)', + }, + '.cm-button': { + background: 'var(--ss-color-raised)', + backgroundImage: 'none', + border: '1px solid var(--ss-color-border-strong)', + color: 'var(--ss-color-fg)', + }, + }, + { dark: true }, +); + /** * Presentation-only lexical highlighting in the instrument palette. * The lezer grammar colors tokens and is NEVER a source of facts — solc @@ -198,6 +260,7 @@ export function SourceEditor({ history(), lintGutter(), solidity, + instrumentWidgets, syntaxHighlighting(instrumentHighlight), highlightField, spineField, diff --git a/apps/web/src/probe/ProbePane.tsx b/apps/web/src/probe/ProbePane.tsx index af1901b..8a15187 100644 --- a/apps/web/src/probe/ProbePane.tsx +++ b/apps/web/src/probe/ProbePane.tsx @@ -5,6 +5,7 @@ import type { useExecutionSession } from '../execute/useExecutionSession'; import { EventsPanel } from '../execute/EventsPanel'; import { RuntimeStorage } from '../storage/RuntimeStorage'; import { StorageGrid } from '../storage/StorageGrid'; +import { buildRuntimeView } from '../storage/runtimeView'; import { MachineStatePanes } from '../trace/MachineStatePanes'; import { ObservationList } from '../trace/ObservationList'; import { ForensicsView } from './ForensicsView'; @@ -52,6 +53,11 @@ export function ProbePane({ [trace, boundedCursor], ); + const storageRuntime = useMemo( + () => buildRuntimeView(trace, resolutions, boundedCursor), + [trace, resolutions, boundedCursor], + ); + const accessCount = trace?.observations?.storage.length ?? 0; const eventCount = trace?.logs.length ?? 0; @@ -104,6 +110,7 @@ export function ProbePane({ artifact={artifact} declaringContracts={result.declaringContracts} ownerSuffixByContract={ownerSuffixByContract} + runtime={storageRuntime} /> diff --git a/apps/web/src/storage/StorageGrid.tsx b/apps/web/src/storage/StorageGrid.tsx index 52f234d..0f6244c 100644 --- a/apps/web/src/storage/StorageGrid.tsx +++ b/apps/web/src/storage/StorageGrid.tsx @@ -3,20 +3,37 @@ import type { CollapsedStorageSpan, ContractArtifact } from '@slotscope/domain'; import { LayoutDecodeError, decodeStorageLayout } from '@slotscope/layout'; import { buildRows, type RowSegment } from './buildRows'; import { ownerChipText } from './ownerChip'; +import { readReference, type ReferenceReading } from './referenceValue'; +import type { StorageRuntimeView } from './runtimeView'; export interface StorageGridProps { artifact: ContractArtifact | null; declaringContracts: Readonly>; /** contract name → owner suffix ('a'|'b'|'c'), shared with the editor spine. */ ownerSuffixByContract?: ReadonlyMap; + /** What the machine has observed about these slots; null before any run. */ + runtime?: StorageRuntimeView | null; } const OWNER_CLASSES = ['owner-a', 'owner-b', 'owner-c']; -function segmentAria(segment: RowSegment, slot: bigint): string { +function segmentAria(segment: RowSegment, slot: bigint, reading: ReferenceReading | null): string { if (segment.kind === 'gap') { return `Slot ${slot}, bytes ${segment.fromByte}–${segment.toByte}: unused`; } + if (segment.kind === 'reference') { + const measured = + reading !== null && reading.measured !== null ? `; last observed: ${reading.measured}` : ''; + return `Slot ${slot}, bytes 31–0: ${segment.span.path}, type ${segment.span.typeLabel}, ${ + reading?.sentence ?? 'reserved base slot' + }${measured}`; + } + if (segment.kind === 'elided') { + const reasons = segment.spans.map((span) => `${span.path}: ${collapsedReason(span)}`).join(' '); + return segment.spans.length === 0 + ? `Slots ${slot} onward: no declared variable` + : `Slots ${slot} onward: not expanded — ${reasons}`; + } const cell = segment.cell; const end = cell.byteOffset + cell.byteLength - 1; return `Slot ${cell.slot}, bytes ${cell.byteOffset}–${end}: ${cell.path}, type ${cell.typeLabel}${ @@ -62,6 +79,7 @@ export function StorageGrid({ artifact, declaringContracts, ownerSuffixByContract, + runtime = null, }: StorageGridProps) { const gridRef = useRef(null); const [focusPos, setFocusPos] = useState<[number, number]>([0, 0]); @@ -165,8 +183,8 @@ export function StorageGrid({ } } - function openDetails(segment: RowSegment, slot: bigint) { - setDetails(segmentAria(segment, slot)); + function openDetails(segment: RowSegment, slot: bigint, reading: ReferenceReading | null) { + setDetails(segmentAria(segment, slot, reading)); } return ( @@ -211,6 +229,12 @@ export function StorageGrid({ {decoded.rows.map((row, rowIndex) => { 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 + // prose that used to live under "Not expanded". + const head = row.segments[0]; + const referenceSpan = head?.kind === 'reference' ? head.span : null; + const derivedSeen = + referenceSpan === null ? 0 : (runtime?.derivedCountByBase.get(referenceSpan.path) ?? 0); const recordCells = row.segments .filter((segment) => segment.kind === 'value') .map((segment) => (segment as Extract).cell) @@ -219,7 +243,11 @@ export function StorageGrid({
- slot {row.slot.toString()} + + {row.endSlot === row.slot + ? `slot ${row.slot.toString()}` + : `slots ${row.slot.toString()}–${row.endSlot.toString()}`} +
)} + {referenceSpan !== null && ( + + )}
); })} @@ -338,22 +427,14 @@ export function StorageGrid({

Byte direction: offset 0 is the least-significant (rightmost) byte of each slot; byte 31 is leftmost. Fields pack upward from the least significant byte, so a row reads exactly as the - word does in the EVM — hatched cells are unused padding the compiler left behind. + word does in the EVM — hatched cells are unused padding the compiler left behind. A + full-width outlined row is a slot a reference type reserves; amber inside it is a value the + machine was observed to put there.

- {decoded.collapsed.length > 0 && ( -
-

Not expanded

-
    - {decoded.collapsed.map((span) => ( -
  • - {span.path} ({span.typeLabel}): base slot {span.startSlot.toString()} —{' '} - {collapsedReason(span)} -
  • - ))} -
-
- )} + {/* No "Not expanded" list: every collapsed span now has a row of its own — + a reference head or an elided range — and the record under each band + carries the sentence a reader used to find down here. */}
{details ?? 'Focus a cell to see its details.'} diff --git a/apps/web/src/storage/buildRows.property.test.ts b/apps/web/src/storage/buildRows.property.test.ts new file mode 100644 index 0000000..0c23db0 --- /dev/null +++ b/apps/web/src/storage/buildRows.property.test.ts @@ -0,0 +1,108 @@ +import type { CollapsedStorageSpan, StaticLayoutItem } from '@slotscope/domain'; +import fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { buildRows } from './buildRows'; + +type Declaration = + | { kind: 'value'; slots: number } + | { kind: 'reference'; encoding: 'mapping' | 'dynamic_array' | 'bytes' } + | { kind: 'inplaceCollapse'; slots: number }; + +const declaration = fc.oneof( + fc.record({ kind: fc.constant('value' as const), slots: fc.integer({ min: 1, max: 4 }) }), + fc.record({ + kind: fc.constant('reference' as const), + encoding: fc.constantFrom('mapping' as const, 'dynamic_array' as const, 'bytes' as const), + }), + fc.record({ + kind: fc.constant('inplaceCollapse' as const), + slots: fc.integer({ min: 1, max: 1000 }), + }), +); + +/** Lay declarations out sequentially, exactly as solc allocates top-level slots. */ +function layout(declarations: readonly Declaration[]): StaticLayoutItem[] { + const items: StaticLayoutItem[] = []; + let slot = 0n; + declarations.forEach((declaration, index) => { + if (declaration.kind === 'value') { + for (let i = 0; i < declaration.slots; i += 1) { + items.push({ + kind: 'value', + path: `v${index}_${i}`, + typeId: 't_uint256', + typeLabel: 'uint256', + slot: slot + BigInt(i), + byteOffset: 0, + byteLength: 32, + declaringContract: null, + astId: null, + }); + } + slot += BigInt(declaration.slots); + return; + } + const span: CollapsedStorageSpan = { + kind: 'collapsed', + path: `c${index}`, + typeId: `t_c${index}`, + typeLabel: 'collapsed', + startSlot: slot, + byteOffset: 0, + numberOfBytes: declaration.kind === 'reference' ? 32n : BigInt(declaration.slots) * 32n, + encoding: declaration.kind === 'reference' ? declaration.encoding : 'inplace', + reason: declaration.kind === 'reference' ? 'unsupported-encoding' : 'materialization-limit', + declaringContract: null, + astId: null, + }; + items.push(span); + slot += declaration.kind === 'reference' ? 1n : BigInt(declaration.slots); + }); + return items; +} + +describe('buildRows invariants', () => { + it('covers every slot from the first to the last with no hole and no overlap', () => { + fc.assert( + fc.property(fc.array(declaration, { minLength: 1, maxLength: 12 }), (declarations) => { + const items = layout(declarations); + const { rows } = buildRows({ + items, + materializedCellCount: 0, + materializedSlotCount: 0, + }); + expect(rows.length).toBeGreaterThan(0); + expect(rows[0]?.slot).toBe(0n); + for (const row of rows) { + expect(row.endSlot).toBeGreaterThanOrEqual(row.slot); + } + for (let i = 1; i < rows.length; i += 1) { + expect(rows[i]?.slot).toBe((rows[i - 1]?.endSlot ?? -1n) + 1n); + } + }), + { numRuns: 300 }, + ); + }); + + it('accounts for every collapsed span in a reference or elided row', () => { + fc.assert( + fc.property(fc.array(declaration, { minLength: 1, maxLength: 12 }), (declarations) => { + const items = layout(declarations); + const { rows, collapsed } = buildRows({ + items, + materializedCellCount: 0, + materializedSlotCount: 0, + }); + const shown = new Set(); + for (const row of rows) { + for (const segment of row.segments) { + if (segment.kind === 'reference') shown.add(segment.span.path); + if (segment.kind === 'elided') for (const span of segment.spans) shown.add(span.path); + } + } + expect([...shown].sort()).toEqual(collapsed.map((span) => span.path).sort()); + }), + { numRuns: 300 }, + ); + }); +}); diff --git a/apps/web/src/storage/buildRows.test.ts b/apps/web/src/storage/buildRows.test.ts index 8a36718..d9bad8f 100644 --- a/apps/web/src/storage/buildRows.test.ts +++ b/apps/web/src/storage/buildRows.test.ts @@ -1,4 +1,8 @@ -import type { StaticStorageLayoutModel, StorageValueCell } from '@slotscope/domain'; +import type { + CollapsedStorageSpan, + StaticStorageLayoutModel, + StorageValueCell, +} from '@slotscope/domain'; import { describe, expect, it } from 'vitest'; import { buildRows } from './buildRows'; @@ -21,6 +25,29 @@ function cell( }; } +function span( + path: string, + startSlot: bigint, + encoding: CollapsedStorageSpan['encoding'], + numberOfBytes: bigint, + reason: CollapsedStorageSpan['reason'] = 'unsupported-encoding', + typeLabel = 'x', +): CollapsedStorageSpan { + return { + kind: 'collapsed', + path, + typeId: `t_${path}`, + typeLabel, + startSlot, + byteOffset: 0, + numberOfBytes, + encoding, + reason, + declaringContract: null, + astId: null, + }; +} + function model(items: StaticStorageLayoutModel['items']): StaticStorageLayoutModel { return { items, materializedCellCount: items.length, materializedSlotCount: 0 }; } @@ -31,6 +58,7 @@ describe('buildRows', () => { model([cell('b', 1n, 0, 32), cell('a1', 0n, 0, 16), cell('a2', 0n, 24, 4)]), ); expect(rows.map((r) => r.slot)).toEqual([0n, 1n]); + expect(rows.map((r) => r.endSlot)).toEqual([0n, 1n]); expect(rows[0]?.segments).toEqual([ { kind: 'value', cell: expect.objectContaining({ path: 'a1' }) }, { kind: 'gap', fromByte: 16, toByte: 23 }, @@ -42,26 +70,75 @@ describe('buildRows', () => { ]); }); - it('returns collapsed spans separately, never dropping them', () => { - const { rows, collapsed } = buildRows( + it('gives a mapping between two variables its own reserved row', () => { + const balances = span( + 'balances', + 1n, + 'mapping', + 32n, + 'unsupported-encoding', + 'mapping(address => uint256)', + ); + const { rows } = buildRows(model([cell('a', 0n, 0, 32), balances, cell('b', 2n, 0, 32)])); + expect(rows.map((r) => r.slot)).toEqual([0n, 1n, 2n]); + expect(rows[1]?.segments).toEqual([{ kind: 'reference', span: balances }]); + }); + + it('gives each reference encoding a reserved row', () => { + const items = [ + span('history', 0n, 'dynamic_array', 32n), + span('blob', 1n, 'bytes', 32n), + span('note', 2n, 'bytes', 32n, 'unsupported-encoding', 'string'), + ]; + const { rows } = buildRows(model(items)); + expect(rows.map((r) => r.slot)).toEqual([0n, 1n, 2n]); + expect(rows.every((r) => r.segments[0]?.kind === 'reference')).toBe(true); + }); + + it('gives a static array of mappings one reserved row per element', () => { + const { rows } = buildRows( model([ - cell('a', 0n, 0, 32), - { - kind: 'collapsed', - path: 'big', - typeId: 't_big', - typeLabel: 'big[1000]', - startSlot: 1n, - byteOffset: 0, - numberOfBytes: 32000n, - reason: 'materialization-limit', - declaringContract: null, - astId: null, - }, + span('m[0]', 0n, 'mapping', 32n), + span('m[1]', 1n, 'mapping', 32n), + span('m[2]', 2n, 'mapping', 32n), ]), ); - expect(rows).toHaveLength(1); + expect(rows.map((r) => r.slot)).toEqual([0n, 1n, 2n]); + }); + + it('collapses a multi-slot inplace span into one elided range row', () => { + const big = span('big', 1n, 'inplace', 32000n, 'materialization-limit', 'uint256[1000]'); + const { rows, collapsed } = buildRows(model([cell('a', 0n, 0, 32), big])); + expect(rows).toHaveLength(2); + expect(rows[1]).toEqual({ + slot: 1n, + endSlot: 1000n, + segments: [{ kind: 'elided', spans: [big] }], + }); expect(collapsed).toHaveLength(1); - expect(collapsed[0]?.reason).toBe('materialization-limit'); + }); + + it('reports an unclaimed slot run as an elided range with no spans', () => { + const { rows } = buildRows(model([cell('a', 0n, 0, 32), cell('z', 4n, 0, 32)])); + expect(rows.map((r) => [r.slot, r.endSlot])).toEqual([ + [0n, 0n], + [1n, 3n], + [4n, 4n], + ]); + expect(rows[1]?.segments).toEqual([{ kind: 'elided', spans: [] }]); + }); + + it('never leaves a hole between the first and last slot', () => { + const { rows } = buildRows( + model([ + cell('a', 0n, 0, 32), + span('m', 1n, 'mapping', 32n), + span('big', 2n, 'inplace', 320n, 'materialization-limit'), + cell('tail', 12n, 0, 32), + ]), + ); + for (let i = 1; i < rows.length; i += 1) { + expect(rows[i]?.slot).toBe((rows[i - 1]?.endSlot ?? -1n) + 1n); + } }); }); diff --git a/apps/web/src/storage/buildRows.ts b/apps/web/src/storage/buildRows.ts index d1c4a11..5b4d77e 100644 --- a/apps/web/src/storage/buildRows.ts +++ b/apps/web/src/storage/buildRows.ts @@ -5,20 +5,65 @@ import type { StorageValueCell, } from '@slotscope/domain'; +const SLOT_BYTES = 32n; + export type RowSegment = - { kind: 'value'; cell: StorageValueCell } | { kind: 'gap'; fromByte: number; toByte: number }; + | { kind: 'value'; cell: StorageValueCell } + | { kind: 'gap'; fromByte: number; toByte: number } + | { kind: 'reference'; span: CollapsedStorageSpan } + | { kind: 'elided'; spans: readonly CollapsedStorageSpan[] }; export interface GridRow { slot: bigint; + /** Last slot covered; equals `slot` for every row but an elided range. */ + endSlot: bigint; /** Segments ordered by ascending byte offset; gaps fill unused bytes. */ segments: RowSegment[]; } +/** How many whole slots a byte extent occupies (at least one). */ +function slotSpan(numberOfBytes: bigint): bigint { + if (numberOfBytes <= SLOT_BYTES) return 1n; + const whole = numberOfBytes / SLOT_BYTES; + return numberOfBytes % SLOT_BYTES === 0n ? whole : whole + 1n; +} + +type Claim = + | { start: bigint; end: bigint; kind: 'cells'; cells: StorageValueCell[] } + | { start: bigint; end: bigint; kind: 'reference'; span: CollapsedStorageSpan } + | { start: bigint; end: bigint; kind: 'elided'; spans: CollapsedStorageSpan[] }; + +function byStart(a: Claim, b: Claim): number { + return a.start < b.start ? -1 : a.start > b.start ? 1 : 0; +} + +/** Bytes 0..31 of one slot, with explicit gaps for what no cell covers. */ +function cellSegments(cells: StorageValueCell[]): RowSegment[] { + const segments: RowSegment[] = []; + let cursor = 0; + for (const cell of [...cells].sort((a, b) => a.byteOffset - b.byteOffset)) { + if (cell.byteOffset > cursor) { + segments.push({ kind: 'gap', fromByte: cursor, toByte: cell.byteOffset - 1 }); + } + segments.push({ kind: 'value', cell }); + cursor = cell.byteOffset + cell.byteLength; + } + if (cursor < 32) segments.push({ kind: 'gap', fromByte: cursor, toByte: 31 }); + return segments; +} + /** - * Groups materialized value cells into one row per slot (ascending) and - * fills unused bytes with explicit gap segments so every row covers exactly - * bytes 0..31. Collapsed spans are returned separately — they are announced, - * never silently dropped. + * One row per slot the compiler accounted for, in ascending slot order, with + * no hole between the first and last row. + * + * A reference type (mapping, dynamic array, bytes/string) reserves exactly one + * slot it may never fill, so it earns its own row rather than vanishing from + * the sequence. An inplace span the decoder did not expand can cover thousands + * of slots, so it becomes a single elided range row instead of thousands of + * rows — and any run of slots nothing claims becomes an elided range with no + * spans, which is honest about the hole rather than hiding it. + * + * Collapsed spans are still returned in full: announced, never dropped. */ export function buildRows(model: StaticStorageLayoutModel): { rows: GridRow[]; @@ -29,23 +74,69 @@ export function buildRows(model: StaticStorageLayoutModel): { (item): item is CollapsedStorageSpan => item.kind === 'collapsed', ); + const claims: Claim[] = []; + for (const [slotKey, cells] of bySlot) { + const slot = BigInt(slotKey); + claims.push({ start: slot, end: slot, kind: 'cells', cells: [...cells] }); + } + for (const span of collapsed) { + // A reference head is exactly one word and never shares its slot with a + // materialized cell; anything else keeps its exact compiler-reported extent. + if (span.encoding !== 'inplace' && !bySlot.has(span.startSlot.toString())) { + claims.push({ start: span.startSlot, end: span.startSlot, kind: 'reference', span }); + continue; + } + const end = span.startSlot + slotSpan(span.numberOfBytes) - 1n; + claims.push({ start: span.startSlot, end, kind: 'elided', spans: [span] }); + } + if (claims.length === 0) return { rows: [], collapsed }; + + claims.sort(byStart); + const rows: GridRow[] = []; - const slots = [...bySlot.keys()] - .map((s) => BigInt(s)) - .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); - for (const slot of slots) { - const cells = (bySlot.get(slot.toString()) ?? []).sort((a, b) => a.byteOffset - b.byteOffset); - const segments: RowSegment[] = []; - let cursor = 0; - for (const cell of cells) { - if (cell.byteOffset > cursor) { - segments.push({ kind: 'gap', fromByte: cursor, toByte: cell.byteOffset - 1 }); + let cursor = claims[0]!.start; + let index = 0; + while (index < claims.length) { + const claim = claims[index]!; + if (claim.end < cursor) { + // Already covered by a wider elided range emitted before it. + index += 1; + continue; + } + if (claim.start > cursor) { + rows.push({ + slot: cursor, + endSlot: claim.start - 1n, + segments: [{ kind: 'elided', spans: [] }], + }); + cursor = claim.start; + continue; + } + if (claim.kind === 'cells') { + rows.push({ slot: claim.start, endSlot: claim.start, segments: cellSegments(claim.cells) }); + } else if (claim.kind === 'reference') { + rows.push({ + slot: claim.start, + endSlot: claim.start, + segments: [{ kind: 'reference', span: claim.span }], + }); + } else { + // Merge every elided claim that overlaps this one into a single range. + const spans = [...claim.spans]; + let end = claim.end; + let next = index + 1; + while (next < claims.length) { + const other = claims[next]!; + if (other.kind !== 'elided' || other.start > end) break; + spans.push(...other.spans); + if (other.end > end) end = other.end; + next += 1; } - segments.push({ kind: 'value', cell }); - cursor = cell.byteOffset + cell.byteLength; + rows.push({ slot: claim.start, endSlot: end, segments: [{ kind: 'elided', spans }] }); + index = next - 1; } - if (cursor < 32) segments.push({ kind: 'gap', fromByte: cursor, toByte: 31 }); - rows.push({ slot, segments }); + cursor = rows[rows.length - 1]!.endSlot + 1n; + index += 1; } return { rows, collapsed }; } diff --git a/apps/web/src/storage/referenceValue.test.ts b/apps/web/src/storage/referenceValue.test.ts new file mode 100644 index 0000000..ebb18c2 --- /dev/null +++ b/apps/web/src/storage/referenceValue.test.ts @@ -0,0 +1,81 @@ +import type { CollapsedStorageSpan } from '@slotscope/domain'; +import { describe, expect, it } from 'vitest'; +import { readReference } from './referenceValue'; + +function span(encoding: CollapsedStorageSpan['encoding'], typeLabel: string): CollapsedStorageSpan { + return { + kind: 'collapsed', + path: 'v', + typeId: 't_v', + typeLabel, + startSlot: 1n, + byteOffset: 0, + numberOfBytes: 32n, + encoding, + reason: 'unsupported-encoding', + declaringContract: null, + astId: null, + }; +} + +const word = (value: bigint) => value.toString(16).padStart(64, '0'); + +describe('readReference', () => { + it('says a mapping head is reserved and has no value of its own', () => { + const reading = readReference(span('mapping', 'mapping(address => uint256)'), null); + expect(reading.eyebrow).toBe('RESERVED · mapping'); + expect(reading.measured).toBeNull(); + expect(reading.anomaly).toBe(false); + expect(reading.sentence).toContain('keccak'); + }); + + it('treats an all-zero mapping head as the expected reserved state', () => { + expect( + readReference(span('mapping', 'mapping(address => uint256)'), word(0n)).measured, + ).toBeNull(); + }); + + it('flags a nonzero mapping head as an anomaly rather than a value', () => { + const reading = readReference(span('mapping', 'mapping(address => uint256)'), word(5n)); + expect(reading.anomaly).toBe(true); + expect(reading.measured).toContain('0x'); + expect(reading.sentence).toContain('not written by the compiler'); + }); + + it('reads a dynamic array head as its length', () => { + const reading = readReference(span('dynamic_array', 'uint256[]'), word(3n)); + expect(reading.eyebrow).toBe('LENGTH · array'); + expect(reading.measured).toBe('length = 3'); + expect(reading.anomaly).toBe(false); + }); + + it('reads a short string from the head, where the data actually lives', () => { + // 'hi' left-aligned, 2·len = 4 in the low byte. + const short = `${Buffer.from('hi').toString('hex').padEnd(62, '0')}04`; + const reading = readReference(span('bytes', 'string'), short); + expect(reading.eyebrow).toBe('LENGTH + DATA · string'); + expect(reading.measured).toBe('"hi" · 2 bytes'); + }); + + it('reads a long string head as a length marker only', () => { + const reading = readReference(span('bytes', 'string'), word(97n)); + expect(reading.eyebrow).toBe('LENGTH · string'); + expect(reading.measured).toBe('48 bytes at keccak(slot)'); + }); + + it('reads short bytes without pretending they are text', () => { + const reading = readReference(span('bytes', 'bytes'), `${'ab'.repeat(2)}${'0'.repeat(58)}04`); + expect(reading.eyebrow).toBe('LENGTH + DATA · bytes'); + expect(reading.measured).toBe('2 bytes in this slot'); + }); + + it('says an invalid string encoding is invalid instead of guessing', () => { + const reading = readReference(span('bytes', 'string'), word(3n)); + expect(reading.anomaly).toBe(true); + expect(reading.measured).toContain('non-canonical'); + }); + + it('has no measured value before anything is observed', () => { + expect(readReference(span('dynamic_array', 'uint256[]'), null).measured).toBeNull(); + }); +}); diff --git a/apps/web/src/storage/referenceValue.ts b/apps/web/src/storage/referenceValue.ts new file mode 100644 index 0000000..0f25266 --- /dev/null +++ b/apps/web/src/storage/referenceValue.ts @@ -0,0 +1,101 @@ +import type { CollapsedStorageSpan } from '@slotscope/domain'; +import { decodeStringSlot, displayTypedValue } from '@slotscope/semantics'; + +export interface ReferenceReading { + /** Uppercase eyebrow naming what the slot holds. */ + readonly eyebrow: string; + /** The honest sentence, for the aria label and the record list. */ + readonly sentence: string; + /** Measured text for the amber span, or null when nothing was observed. */ + readonly measured: string | null; + /** The observed word contradicts what this encoding can canonically hold. */ + readonly anomaly: boolean; +} + +const MAPPING_SENTENCE = + 'reserved base slot — entries live at keccak-derived slots that exist only at runtime; ' + + 'deploy and call a function that touches it, and the storage accesses under the trace will name each one.'; +const ARRAY_SENTENCE = + 'the base slot holds the length; the elements live at keccak-derived slots — run a call that touches it and the trace names them.'; + +function isZeroWord(word: string): boolean { + return /^0*$/.test(word); +} + +function shortWord(word: string): string { + return `0x${word.replace(/^0+(?=.)/, '')}`; +} + +/** + * What a reference head reserves, and what the machine has been seen to put + * there. Three encodings, three different truths — a mapping head is never + * written, an array head holds a length, and a short bytes/string head holds + * the data itself, so one shared sentence would be false for two of them. + */ +export function readReference(span: CollapsedStorageSpan, word: string | null): ReferenceReading { + if (span.encoding === 'mapping') { + if (word === null || isZeroWord(word)) { + return { + eyebrow: 'RESERVED · mapping', + sentence: MAPPING_SENTENCE, + measured: null, + anomaly: false, + }; + } + return { + eyebrow: 'RESERVED · mapping', + sentence: `${MAPPING_SENTENCE} The word observed here was not written by the compiler — only inline assembly or a slot collision can put a value in a mapping head.`, + measured: shortWord(word), + anomaly: true, + }; + } + + if (span.encoding === 'dynamic_array') { + return { + eyebrow: 'LENGTH · array', + sentence: ARRAY_SENTENCE, + measured: word === null ? null : `length = ${displayTypedValue('uint256', word)}`, + anomaly: false, + }; + } + + // bytes / string. `typeLabel` decides only which decoder renders the word — + // the same display-only distinction the resolver makes (resolve.ts:232). + const isString = span.typeLabel.startsWith('string'); + const noun = isString ? 'string' : 'bytes'; + if (word === null) { + return { + eyebrow: `LENGTH · ${noun}`, + sentence: + 'the base slot holds the length, and under 32 bytes the data itself; longer values live at keccak-derived slots.', + measured: null, + anomaly: false, + }; + } + const form = decodeStringSlot(word, isString); + if (form.form === 'invalid') { + return { + eyebrow: `LENGTH · ${noun}`, + sentence: `the observed word is a non-canonical ${noun} encoding — Solidity did not write it.`, + measured: `non-canonical ${noun} word ${shortWord(word)}`, + anomaly: true, + }; + } + if (form.form === 'long') { + return { + eyebrow: `LENGTH · ${noun}`, + sentence: 'long form: the head holds 2·length+1 and the data lives at keccak(slot).', + measured: `${form.byteLength} bytes at keccak(slot)`, + anomaly: false, + }; + } + return { + eyebrow: `LENGTH + DATA · ${noun}`, + sentence: 'short form: the data is in this slot, left-aligned, with 2·length in the low byte.', + measured: + form.text !== null + ? `"${form.text}" · ${form.byteLength} bytes` + : `${form.byteLength} bytes in this slot`, + anomaly: false, + }; +} diff --git a/apps/web/src/storage/runtimeView.test.ts b/apps/web/src/storage/runtimeView.test.ts new file mode 100644 index 0000000..1faa78b --- /dev/null +++ b/apps/web/src/storage/runtimeView.test.ts @@ -0,0 +1,97 @@ +import type { SemanticResolution, StorageAccessObservation } from '@slotscope/domain'; +import { describe, expect, it } from 'vitest'; +import { buildRuntimeView } from './runtimeView'; + +const SLOT_1 = '1'.padStart(64, '0'); + +function access( + stepIndex: number, + kind: StorageAccessObservation['kind'], + slotHex: string, + valueHex: string | null, +): StorageAccessObservation { + return { stepIndex, kind, slotHex, valueHex, cold: false, slotExpr: { kind: 'unknown' } }; +} + +function trace(storage: StorageAccessObservation[]) { + return { observations: { keccak: [], storage } }; +} + +function resolution(partial: Partial): SemanticResolution { + return { + accessId: 'step-0', + stepIndex: 0, + kind: 'sstore', + status: 'exact', + label: null, + decodedValues: [], + reasoning: [], + baseVariable: null, + accessCostNote: null, + migration: null, + previousValueHex: null, + ...partial, + }; +} + +describe('buildRuntimeView', () => { + it('is empty without a trace', () => { + const view = buildRuntimeView(null, [], 0); + expect(view.wordsBySlot.size).toBe(0); + expect(view.derivedCountByBase.size).toBe(0); + }); + + it('keys words by decimal slot and lets the last observation win', () => { + const view = buildRuntimeView( + trace([ + access(0, 'sstore', SLOT_1, `${'0'.repeat(63)}1`), + access(1, 'sstore', SLOT_1, `${'0'.repeat(63)}7`), + ]), + [], + 5, + ); + expect(view.wordsBySlot.get('1')).toBe(`${'0'.repeat(63)}7`); + }); + + it('takes a value from a read, so pre-existing state is visible', () => { + const view = buildRuntimeView(trace([access(0, 'sload', SLOT_1, `${'0'.repeat(63)}9`)]), [], 5); + expect(view.wordsBySlot.get('1')).toBe(`${'0'.repeat(63)}9`); + }); + + it('ignores observations past the cursor and transient storage', () => { + const view = buildRuntimeView( + trace([ + access(0, 'sstore', SLOT_1, `${'0'.repeat(63)}1`), + access(3, 'sstore', SLOT_1, `${'0'.repeat(63)}2`), + access(1, 'tstore', SLOT_1, `${'0'.repeat(63)}3`), + ]), + [], + 2, + ); + expect(view.wordsBySlot.get('1')).toBe(`${'0'.repeat(63)}1`); + }); + + it('skips observations with no derivable value', () => { + const view = buildRuntimeView( + trace([access(0, 'sstore', SLOT_1, `${'0'.repeat(63)}4`), access(1, 'sload', SLOT_1, null)]), + [], + 9, + ); + expect(view.wordsBySlot.get('1')).toBe(`${'0'.repeat(63)}4`); + }); + + it('counts distinct derived entries per base variable', () => { + const view = buildRuntimeView( + trace([]), + [ + resolution({ baseVariable: 'balances', label: 'balances[0x01]' }), + resolution({ baseVariable: 'balances', label: 'balances[0x01]' }), + resolution({ baseVariable: 'balances', label: 'balances[0x02]' }), + resolution({ baseVariable: null, label: 'raw' }), + ], + 9, + ); + expect(view.derivedCountByBase.get('balances')).toBe(2); + expect(view.derivedCountByBase.has('raw')).toBe(false); + }); +}); diff --git a/apps/web/src/storage/runtimeView.ts b/apps/web/src/storage/runtimeView.ts new file mode 100644 index 0000000..d66cb96 --- /dev/null +++ b/apps/web/src/storage/runtimeView.ts @@ -0,0 +1,58 @@ +import type { SemanticResolution, TraceObservations } from '@slotscope/domain'; + +export interface StorageRuntimeView { + /** Decimal slot → the last 32-byte word observed at or before the cursor. */ + readonly wordsBySlot: ReadonlyMap; + /** Base variable → how many distinct derived entries this run has named. */ + readonly derivedCountByBase: ReadonlyMap; +} + +const EMPTY: StorageRuntimeView = { + wordsBySlot: new Map(), + derivedCountByBase: new Map(), +}; + +/** + * What the machine has said about persistent slots so far, folded for the + * static grid. + * + * Observations rather than a state replay, deliberately: a slot that was only + * READ still yields a value here, which is the only way pre-existing state + * becomes visible. Reads and writes are both "last one at or before the cursor + * wins", matching the bookkeeping the semantics resolver already performs. + * + * Honest limit: these are last-OBSERVED words, not post-revert final state. A + * write inside a frame that later reverted still appears, so callers must say + * "observed", never "final". + */ +export function buildRuntimeView( + trace: { observations: TraceObservations | null } | null, + resolutions: readonly SemanticResolution[], + cursor: number, +): StorageRuntimeView { + if (trace === null && resolutions.length === 0) return EMPTY; + + const wordsBySlot = new Map(); + const observations = trace?.observations ?? null; + if (observations !== null) { + for (const access of observations.storage) { + if (access.kind === 'tload' || access.kind === 'tstore') continue; + if (access.stepIndex > cursor) continue; + if (access.valueHex === null) continue; + wordsBySlot.set(BigInt(`0x${access.slotHex}`).toString(), access.valueHex); + } + } + + const derivedLabels = new Map>(); + for (const resolution of resolutions) { + const base = resolution.baseVariable; + if (base === null) continue; + const labels = derivedLabels.get(base) ?? new Set(); + labels.add(resolution.label ?? resolution.accessId); + derivedLabels.set(base, labels); + } + const derivedCountByBase = new Map(); + for (const [base, labels] of derivedLabels) derivedCountByBase.set(base, labels.size); + + return { wordsBySlot, derivedCountByBase }; +} diff --git a/docs/design/reserved-slots-v1.2.md b/docs/design/reserved-slots-v1.2.md new file mode 100644 index 0000000..a455b23 --- /dev/null +++ b/docs/design/reserved-slots-v1.2.md @@ -0,0 +1,246 @@ +# SlotScope Reserved Reference Slots — design spec (phase v1.2) + +**Status:** proposed (awaiting owner review, 2026-07-30) +**Beads:** `slotscope-zvd` (+ `.1`–`.5`). Runtime keccak-derived slots are deferred +to v1.4 as `slotscope-xu8`. The unreadable-tooltip fix `slotscope-73y` rides along +in the same branch. +**Gates:** unchanged from v1.0/v1.1. Identity law from +`docs/design/instrument-identity-v0.15.md` applies verbatim. + +## The defect + +`uint256 a; mapping(address => uint256) balances; uint256 b;` renders as two rows — +slot 0 and slot 2. Slot 1 is not shown, not marked, not explained. Read the +grid top to bottom and the compiler appears to have skipped an address. + +The cause is a single omission. `decodeStorageLayout` turns every non-`inplace` +encoding into a `CollapsedStorageSpan` (`packages/layout/src/decode.ts:113-125`), +`groupCellsBySlot` keeps only `kind: 'value'` items +(`packages/layout/src/group.ts:9`), and `buildRows` derives its slot list purely +from that map (`apps/web/src/storage/buildRows.ts:27-35`). Nothing anywhere +iterates a slot range or checks continuity: "gap" in this codebase means unused +_bytes inside_ one word, never a missing word. The base slot survives only as +prose under the "Not expanded" heading (`StorageGrid.tsx:344-356`). + +This is the one place the instrument is silent instead of honest, and it is +silent about the single most-taught fact in Solidity storage: a mapping consumes +a slot that it then never writes, because its entries live at +`keccak(key ‖ slot)`. + +## Thesis + +**A reserved slot is a fact the compiler reported, so it gets a row.** The grid's +slot sequence becomes gapless by construction, and every slot in it says what +occupies it — a value, padding, a reference head, or an honestly elided range. + +**A reserved slot's contents are a fact only the machine can report,** so the +current word appears only once measured, and only in amber. Reservation is +structural and stays graphite; the word inside it is phosphor. + +## What occupies a reference head (the reason one label will not do) + +| Encoding | Base slot holds | Honest row copy | +| ---------------- | --------------------------------------------------- | ---------------------------------------------- | +| `mapping` | nothing, ever — solc never writes the head | reserved · entries at `keccak(key ‖ slot)` | +| `dynamic_array` | the length | `name.length` · measured length | +| `bytes`/`string` | the length **and, under 32 bytes, the data itself** | short form: value inline; long form: `2·len+1` | + +The third row is why "reserved — nothing lives here" cannot be the single +sentence: for a short string the base slot is where the characters actually are. +`packages/semantics/src/strings.ts` already distinguishes the two forms +(`decodeStringSlot`, which needs one new line in the package's index to be +reachable), and `chains.ts:315-347` already names array heads `${name}.length` +while returning no value for mappings. This spec surfaces decisions the semantics +layer has already made; it invents nothing. + +`string` versus `bytes` is decided by `typeLabel.startsWith('string')`, copying +`resolve.ts:232` verbatim. That is a _display_ choice — which decoder renders the +word — not a placement decision, so it stays inside the convention that forbids +label parsing for facts about where data lives. + +## Architecture + +Four changes, each in the layer that owns the fact. + +### 1. `packages/domain` + `packages/layout` — carry the encoding (`zvd.1`) + +`CollapsedStorageSpan` gains one field: + +```ts +/** solc-reported encoding of the collapsed type. */ +readonly encoding: 'inplace' | 'mapping' | 'dynamic_array' | 'bytes'; +``` + +populated in `decode.ts` from `raw.types[typeId].encoding`, which is already in +hand at the collapse branch. This replaces the current classification by +`typeLabel.startsWith('mapping')` (`StorageGrid.tsx:44-59`) — a heuristic over a +display string, which the project's own convention forbids. `numberOfBytes` +already carries the extent, so no slot-span field is added; the span is derived. + +### 2. `apps/web/src/storage/buildRows.ts` — gapless rows (`zvd.2`) + +The row shape stays one shape; only the segment vocabulary grows: + +```ts +export interface GridRow { + slot: bigint; + /** Last slot covered; equals `slot` for every row but an elided range. */ + endSlot: bigint; + segments: RowSegment[]; +} + +export type RowSegment = + | { kind: 'value'; cell: StorageValueCell } + | { kind: 'gap'; fromByte: number; toByte: number } + | { kind: 'reference'; span: CollapsedStorageSpan } + | { kind: 'elided'; spans: readonly CollapsedStorageSpan[] }; +``` + +A discriminated _row_ union was the obvious alternative and is the wrong trade: +roving focus, pinning, React keys and `aria-rowcount` all index rows through +`row.slot` and `row.segments` (`StorageGrid.tsx:130-136,212,219,228`). Adding +`endSlot` plus two full-width segment kinds leaves every one of those untouched, +because `gridColumn(0, 32)` already spans a whole row. + +The split is decided by encoding, and it is provably non-overlapping: + +- **`mapping` | `dynamic_array` | `bytes`** — always exactly one 32-byte word, + always alone in its slot. Each becomes a row whose single segment is a + full-width `reference` covering bytes 31–0. +- **`inplace`** collapses (oversized leaf at `decode.ts:152-164`, + `unsupported-length`, `materialization-limit`) — extent is arbitrary and may + be thousands of slots, so these become one `elided` banner row over + `startSlot … startSlot + ceil(numberOfBytes / 32) - 1`. One row, never one per + slot. + +A static array of mappings expands to one reference row per element, because +`decode.ts` already recurses through static arrays before reaching the encoding +check. + +**Gapless by construction, not by assertion.** `buildRows` walks a sorted list of +slot _claims_ (never the integers between them — a materialization-limit span can +cover 100 000 slots) and emits an `elided` row with `spans: []` for any run of +slots nothing claims. So an unforeseen layout produces an honest +`slots N–M · no declared variable` banner instead of either a thrown error or a +silent hole. The property suite then asserts the invariant holds for every +generated layout. + +### 3. `apps/web/src/storage/runtimeView.ts` — the measured word (`zvd.3`) + +`StorageGrid` is presently a pure function of the compiled layout and takes no +trace data — but `ProbePane` already holds `trace`, `resolutions` and `cursor` in +scope at the exact mount point (`ProbePane.tsx:45,102`), so this needs one new +prop and no lifting. + +A new pure module, unit-tested in isolation: + +```ts +export interface StorageRuntimeView { + /** decimal slot → last observed 32-byte word at or before the cursor. */ + readonly wordsBySlot: ReadonlyMap; + /** base variable name → count of distinct derived entries observed this run. */ + readonly derivedCountByBase: ReadonlyMap; +} + +export function buildRuntimeView( + trace: TraceBundle | null, + resolutions: readonly SemanticResolution[], + cursor: number, +): StorageRuntimeView; +``` + +`wordsBySlot` is one pass over `trace.observations.storage`: persistent kinds +only (`tload`/`tstore` excluded), `stepIndex <= cursor`, last non-null `valueHex` +wins. Using observations rather than `replayToCursor` means a slot that was only +**read** also yields a value — which is exactly what makes pre-existing state +visible — and it reuses the `lastValueBySlot` semantics `resolve.ts` already +applies internally. Keys are decimal slot strings, matching `groupCellsBySlot`, +so hex normalization happens once at the boundary. + +`derivedCountByBase` counts distinct `label`s per `baseVariable` (so repeated +writes to one mapping entry count once), giving the mapping +row a `N entries seen` chip — the visible link between the reserved slot and the +keccak-derived slots listed in `RuntimeStorage` directly below it. + +**Honest limit, to be documented in `docs/limitations-v1.0.md`:** these are the +last words _observed_, not post-revert final state. An SSTORE inside a frame +that later reverted still appears. `LiveStorageStrip` already has this property; +the row label says "observed", never "final". + +### 4. `StorageGrid` + `app.css` — the face (`zvd.4`) + +- **Never the hatch.** `.storage-gap`'s hatch means "padding the compiler left + behind" (caption at `StorageGrid.tsx:338-342`). A reserved slot is not padding, + so the reference band gets its own class, `.storage-cell.is-reference`, + borrowing the vocabulary the live strip already established for derived slots: + `--ss-color-border-strong` on `--ss-color-chip-bg` (cf. + `.ss-live-cell.is-derived`, `app.css:756`). +- **Graphite until measured.** The band shows the variable name in `ink` and an + eyebrow `RESERVED · mapping` in `dim`. When `wordsBySlot` has the slot, the + decoded value renders in `.ss-measured` amber — the only amber added, and only + on a machine-measured number. Identity law intact. +- **A nonzero mapping head is an anomaly, not a value.** solc never writes it, so + if a word is observed there it is assembly or a collision, and the row says so + in `warning` rather than presenting it as normal contents. Cheap to implement, + and the kind of fact the instrument exists to expose. +- **The elided banner** is dim throughout: rail reads `slots N–M`, one cell + carries the existing `collapsedReason` sentence. No amber. +- **"Not expanded" retires.** Every collapsed span is now either a reference row + or an elided banner, so the list is redundant. Its teaching copy + (`collapsedReason`, `StorageGrid.tsx:44-59`) is not lost — it moves onto the + rows and into the per-slot record list, which is where a reader already looks + for the sentence version of a band. +- **A11y:** `segmentAria` extends to + `Slot 1, bytes 31–0: balances, mapping(address => uint256), reserved base slot; +entries live at keccak-derived slots` plus `; last observed word 0x…` when + measured. An elided row's header names a range (`slots 1–1000`) and its single + `gridcell` carries every collapsed reason it covers. No `aria-colspan` is + declared: the grid sets no `aria-colcount`, and existing full-width cells span + visually through `gridColumn`, so a colspan claim would be unanchored. Both new + row kinds join the roving-focus model unchanged, and `.ss-emphasis` already + supplies the forced-colors border via `[role='gridcell']` + (`packages/ui/src/tokens.css:155-160`). + +## Testing + +| Level | Where | Covers | +| -------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| unit | `packages/layout/src/decode.test.ts` | exact `encoding` for mapping, dynamic array, `bytes`, `string`, oversized inplace leaf | +| unit | `apps/web/src/storage/buildRows.test.ts` | mapping between two values; each reference encoding; static array of mappings; materialization-limit range | +| property | `apps/web/src/storage/buildRows.property.test.ts` | no hole between min and max slot for any sequentially-allocated layout (adds `fast-check@4.9.0`, already the pinned version in `packages/compiler` and `packages/session`) | +| unit | `apps/web/src/storage/runtimeView.test.ts` | last-write-wins, sload baseline, transient excluded, cursor bound | +| unit | `apps/web/src/storage/referenceValue.test.ts` | per-encoding copy and value: zero vs nonzero mapping head, array length, short/long/invalid string and bytes forms | +| e2e | `apps/web/e2e/storage.spec.ts` | rows 0–4 for `uint256 a; mapping balances; uint256 b; uint256[] history; string note;`; reserved labels; axe; 200% zoom; roving focus | +| e2e | `apps/web/e2e/execute.spec.ts` | constructor sets array length + short string → measured values on the reserved rows | + +`storage.spec.ts:131-149` currently asserts the mapping appears **only** under +`[data-testid="collapsed-spans"]`. That assertion encodes the defect and is +replaced deliberately with this spec, as the identity spec's gate clause requires. + +**No new golden fixture, but one deliberate change to the existing one.** Adding a +fixture would mean a new contract, manifest, golden extract, registration in +`fixtures.test.ts:90` and a Forge cross-check — cost with no coverage this change +needs. Instead `zvd.5` appends the three reference types plus a small constructor +to `PackedInheritance.sol`, because the editor seeds its document from that file +(`App.tsx:16`) and a first-time visitor would otherwise never see a reserved row. +The append is safe by position: every existing fixture and storage assertion covers +slots 0–4, and the new variables take slots 5–7. It goes through the sanctioned +`node scripts/verify-fixtures.ts --update` path in the same reviewed commit, and +only `sourceHash` and `expectedLayoutHash` may move. + +## Out of scope + +- **Decoded current values on ordinary value cells.** The `runtime` prop is + shaped to serve it (slot → word is all it needs), and it is the obvious next + step, but it changes the character of every row in the grid and deserves its + own decision. Separate bead when wanted. +- **Keccak-derived slots as grid rows — deferred to v1.4 (`slotscope-xu8`).** A + mapping entry sits at `keccak(key ‖ slot)`, a number near 2^256. A row for it + drawn after slot 7 would claim an adjacency it does not have, and the gapless + walk would have to emit an elided range roughly 10^76 slots wide to reach it. So + `buildRows` takes its claims only from the compiled layout and never reads the + runtime view. Until v1.4, `RuntimeStorage` and `LiveStorageStrip` keep naming + those slots, and the reserved row links to them by count. +- Mapping-key probing. +- Any change to `decodeStorageLayout`'s collapse policy, limits, or the + keccak-grounding in `semantics`. diff --git a/docs/limitations-v1.0.md b/docs/limitations-v1.0.md index 3a85d85..bcc575d 100644 --- a/docs/limitations-v1.0.md +++ b/docs/limitations-v1.0.md @@ -32,6 +32,11 @@ exactly this: outer layout — other contracts' storage is never named with this contract's variables. - Transient storage accesses never resolve against the persistent layout. +- Reserved-slot values in the storage grid are the last words _observed_ in + the current trace, not post-revert final state: a write inside a frame that + later reverted still shows. The row says "last observed", never "final". +- A keccak-derived slot is never a grid row (it would claim an adjacency near + 2^256 that it does not have); the runtime panels name those slots instead. ## Semantics diff --git a/docs/plans/2026-07-30-reserved-reference-slots.md b/docs/plans/2026-07-30-reserved-reference-slots.md new file mode 100644 index 0000000..e855570 --- /dev/null +++ b/docs/plans/2026-07-30-reserved-reference-slots.md @@ -0,0 +1,2140 @@ +# Reserved Reference Slots Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A mapping, a dynamic array, `bytes` and `string` each reserve one slot. +Show that slot as its own labeled row in the storage grid. Show the word the +machine put in that slot, if a run measured one. The grid must not skip a slot +number. + +**Architecture:** Each layer keeps its own fact. + +1. `packages/domain` gets one new field, `CollapsedStorageSpan.encoding`. The UI + then classifies a span by compiler output instead of by display label. +2. `apps/web/src/storage/buildRows.ts` gets two full-width segment kinds, + `reference` and `elided`. It walks a sorted list of slot claims. The row list + therefore has no hole in it. +3. A new pure module `runtimeView.ts` folds trace observations into `slot → word`. +4. A new pure module `referenceValue.ts` converts `(span, word)` into the row's + copy and its one measured value. +5. `StorageGrid` shows these rows. Do not change the component's focus model, its + pin model, or its React keys. + +**Tech Stack:** TypeScript, React 19, Vite, Vitest, fast-check 4.9.0, Playwright, +pnpm workspace on Node 24.18.0 / pnpm 11.17.0. + +**Spec:** `docs/design/reserved-slots-v1.2.md` + +**Beads:** Tasks 1 to 5 are `slotscope-zvd.1` to `slotscope-zvd.5`. Task 6 is +`slotscope-73y`, a separate bug that rides along in this branch. Runtime +keccak-derived slots are **out of scope**: bead `slotscope-xu8` holds them for +v1.4. + +## Global Constraints + +- **Beads, not TODOs.** `bd update --claim` before starting a task, + `bd close ` when its gate passes. Never TodoWrite/markdown checklists. +- **Do not commit. Do not push.** The owner must approve each commit first. The + repository profile is conservative. Branch protection guards `master`: a pull + request must pass all five CI jobs. The commit steps below use the branch + `feat/reserved-reference-slots`. +- **Use exact dependency versions.** The `deps:policy` gate rejects a range and it + rejects a tag. Add `fast-check` as exactly `"4.9.0"`. +- **Compiler output is the only truth.** Do not parse Solidity source. Do not + infer placement from a display label. `typeLabel` can decide only _display_ — + which decoder renders a word. `typeLabel` must not decide placement. + `typeLabel` must not decide classification. +- **Use amber only for measured values** + (`docs/design/instrument-identity-v0.15.md`). Show the structure of a reserved + slot in graphite: `--ss-color-border-strong` on `--ss-color-chip-bg`. Only an + observed word can use the `.ss-measured` class. +- **Do not use the padding hatch** (`.storage-gap`) for a reserved slot. The grid + caption defines the hatch as "unused padding the compiler left behind". +- **Give every animated state a permanent equivalent**: a border, an icon, or + text. The keyboard e2e suite and the reduced-motion e2e suite must pass. +- **Change the goldens in Task 5 only.** Task 5 edits the fixture contract, so it + must regenerate the goldens with `node scripts/verify-fixtures.ts --update` and + review the semantic diff that command prints. No other task may change a file in + `packages/fixtures/`. +- **Byte convention:** offset 0 is the least-significant byte. It is the rightmost + byte. `gridColumn` puts byte 31 at the left. + +Quality gate for every task unless stated otherwise: + +```bash +pnpm format:check && pnpm lint && pnpm typecheck && pnpm test:unit +``` + +--- + +### Task 1: Carry the solc encoding on collapsed spans + +**Bead:** `slotscope-zvd.1` + +**Files:** + +- Modify: `packages/domain/src/static-layout.ts:26-38` (add field + exported type) +- Modify: `packages/domain/src/index.ts` (export the new type) +- Modify: `packages/layout/src/decode.ts:72-88, 113-125, 152-164, 200-211, 222-231, 261-273` +- Modify: `packages/layout/src/decode.test.ts:244-264` (assert the new field) +- Modify: `apps/web/src/storage/buildRows.test.ts:49-60` (literal needs the field) + +**Interfaces:** + +- Consumes: nothing from earlier tasks. +- Produces: `StorageEncoding = 'inplace' | 'mapping' | 'dynamic_array' | 'bytes'` + exported from `@slotscope/domain`, and `CollapsedStorageSpan.encoding: +StorageEncoding`. Task 2 branches on it; Task 3 and Task 4 read it. + +- [ ] **Step 1: Claim the bead** + +```bash +bd update slotscope-zvd.1 --claim +``` + +- [ ] **Step 2: Write the failing test** + +Do these two things in `packages/layout/src/decode.test.ts`: + +1. Add `encoding: 'mapping'` to the assertion at line 258. +2. Add the two new tests after it. + +The other tests in this file use `toMatchObject`. A new field therefore fails no +other test. + +```ts +it('collapses mappings as unsupported encoding with their exact extent', () => { + const raw: RawStorageLayout = { + storage: [ + { astId: 1, contract: 'C', label: 'balances', offset: 0, slot: '7', type: 't_mapping' }, + ], + types: { + t_mapping: { + encoding: 'mapping', + label: 'mapping(address => uint256)', + numberOfBytes: '32', + }, + }, + }; + const model = decodeStorageLayout(raw, () => null); + expect(model.items[0]).toMatchObject({ + kind: 'collapsed', + reason: 'unsupported-encoding', + encoding: 'mapping', + startSlot: 7n, + numberOfBytes: 32n, + }); +}); + +it('reports the compiler encoding for dynamic arrays, bytes and string', () => { + const raw: RawStorageLayout = { + storage: [ + { astId: 1, contract: 'C', label: 'arr', offset: 0, slot: '0', type: 't_arr' }, + { astId: 2, contract: 'C', label: 'blob', offset: 0, slot: '1', type: 't_bytes' }, + { astId: 3, contract: 'C', label: 'name', offset: 0, slot: '2', type: 't_string' }, + ], + types: { + t_arr: { encoding: 'dynamic_array', label: 'uint256[]', numberOfBytes: '32' }, + t_bytes: { encoding: 'bytes', label: 'bytes', numberOfBytes: '32' }, + t_string: { encoding: 'bytes', label: 'string', numberOfBytes: '32' }, + }, + }; + const model = decodeStorageLayout(raw, () => null); + expect(model.items.map((item) => item.kind === 'collapsed' && item.encoding)).toEqual([ + 'dynamic_array', + 'bytes', + 'bytes', + ]); +}); + +it('marks an oversized inplace leaf as inplace, not as a reference type', () => { + const raw: RawStorageLayout = { + storage: [{ astId: 1, contract: 'C', label: 'wide', offset: 0, slot: '0', type: 't_wide' }], + types: { t_wide: { encoding: 'inplace', label: 'ufixed', numberOfBytes: '64' } }, + }; + const model = decodeStorageLayout(raw, () => null); + expect(model.items[0]).toMatchObject({ + kind: 'collapsed', + reason: 'unsupported-encoding', + encoding: 'inplace', + numberOfBytes: 64n, + }); +}); +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `pnpm --filter @slotscope/layout exec vitest run src/decode.test.ts` +Expected: FAIL — the three new/changed assertions report `encoding` missing from +the received object (and `tsc` would reject `item.encoding`). + +- [ ] **Step 4: Add the field to the domain model** + +In `packages/domain/src/static-layout.ts`, add the exported type above +`CollapsedStorageSpan` and the field inside it: + +```ts +/** + * solc's `storageLayout.types[*].encoding`, verbatim. Carried on collapsed + * spans so consumers classify by compiler output instead of by display label. + */ +export type StorageEncoding = 'inplace' | 'mapping' | 'dynamic_array' | 'bytes'; +``` + +```ts +export interface CollapsedStorageSpan { + readonly kind: 'collapsed'; + readonly path: string; + readonly typeId: string; + readonly typeLabel: string; + readonly startSlot: bigint; + readonly byteOffset: number; + /** Exact compiler-reported byte extent of the whole region. */ + readonly numberOfBytes: bigint; + /** solc-reported encoding of the collapsed type. */ + readonly encoding: StorageEncoding; + readonly reason: 'unsupported-encoding' | 'unsupported-length' | 'materialization-limit'; + readonly declaringContract: string | null; + readonly astId: number | null; +} +``` + +Then export the type. `packages/domain/src/index.ts` already has one +`export type { ... } from './static-layout.ts';` block. Add `StorageEncoding` to +that block, in alphabetical order: + +```ts +export type { + CollapsedStorageSpan, + StaticLayoutItem, + StaticStorageLayoutModel, + StorageEncoding, + StorageValueCell, +} from './static-layout.ts'; +``` + +- [ ] **Step 5: Populate it at all five collapse sites in the decoder** + +In `packages/layout/src/decode.ts`, add the field to `CollapsedSite` (line 72): + +```ts +interface CollapsedSite { + path: string; + typeId: string; + typeLabel: string; + startSlot: bigint; + numberOfBytes: bigint; + encoding: StorageEncoding; + declaringContract: string | null; + astId: number | null; + byteOffset?: number; +} +``` + +Import the type at the top of the file (add `StorageEncoding` to the existing +`import type { ... } from '@slotscope/domain';`). + +Then pass `encoding` at each `pushCollapsed` call: + +- Line ~113 is the non-inplace branch. The local variable `encoding` is already in + scope there, but its type is `string`. Narrow the type once, at line 110, where + the function computes the value: + +```ts +const encoding = normalizeEncoding(type.encoding); +``` + +and add this module-level helper next to `parseSlot` at the bottom of the file: + +```ts +/** + * solc emits exactly four encodings. A fifth would mean the compiler shape + * changed under us, and every placement decision downstream keys off this + * value — so it fails loudly here rather than being silently treated as one of + * the four, which is how the decoder already handles a missing type or a cycle. + */ +function normalizeEncoding(raw: string | undefined): StorageEncoding { + if (raw === undefined) return 'inplace'; + if (raw === 'inplace' || raw === 'mapping' || raw === 'dynamic_array' || raw === 'bytes') { + return raw; + } + throw new LayoutDecodeError('malformed', `unknown storage encoding "${raw}"`); +} +``` + +The `undefined` branch keeps the previous default of +`type.encoding ?? 'inplace'`. + +- Line ~114, `unsupported-encoding`, the non-inplace branch: add `encoding,`. +- Line ~153, the oversized inplace leaf: add `encoding,`. The local value is + always `'inplace'` here. Execution reaches this branch only after the inplace + check passes. +- Line ~201, `unsupported-length`: add `encoding: 'inplace',`. Only an inplace + static array reaches `expandStaticArray`. +- Line ~222, `materialization-limit` in `expandStaticArray`: add + `encoding: 'inplace',`. +- Line ~262, `materialization-limit` in `pushCell`: add `encoding: 'inplace',`. + A cell exists only for an inplace leaf. + +- [ ] **Step 6: Fix the one broken test literal** + +`apps/web/src/storage/buildRows.test.ts:49-60` builds a `CollapsedStorageSpan` +literal that now misses a required field. Add it: + +```ts + { + kind: 'collapsed', + path: 'big', + typeId: 't_big', + typeLabel: 'big[1000]', + startSlot: 1n, + byteOffset: 0, + numberOfBytes: 32000n, + encoding: 'inplace', + reason: 'materialization-limit', + declaringContract: null, + astId: null, + }, +``` + +- [ ] **Step 7: Run the gate** + +Run: `pnpm format:check && pnpm lint && pnpm typecheck && pnpm test:unit` +Expected: PASS. `typecheck` can flag another `CollapsedStorageSpan` literal. Add +`encoding` to that literal in the same way. Such an error is correct behavior: the +compiler found one more consumer of the type. + +- [ ] **Step 8: Commit (only with the owner's approval — see Global Constraints)** + +```bash +git add packages/domain/src/static-layout.ts packages/domain/src/index.ts \ + packages/layout/src/decode.ts packages/layout/src/decode.test.ts \ + apps/web/src/storage/buildRows.test.ts +git commit -m "feat(layout): carry solc encoding on collapsed storage spans" +bd close slotscope-zvd.1 +``` + +--- + +### Task 2: Gapless rows — reference heads and elided ranges + +**Bead:** `slotscope-zvd.2` + +**Files:** + +- Rewrite: `apps/web/src/storage/buildRows.ts` +- Modify: `apps/web/src/storage/buildRows.test.ts` (existing two tests + new cases) +- Create: `apps/web/src/storage/buildRows.property.test.ts` +- Modify: `apps/web/package.json` (add `fast-check` devDependency) +- Modify: `pnpm-lock.yaml` (regenerated by `pnpm install`) + +**Interfaces:** + +- Consumes: `CollapsedStorageSpan.encoding` from Task 1. +- Produces: + +```ts +export interface GridRow { + slot: bigint; + endSlot: bigint; + segments: RowSegment[]; +} +export type RowSegment = + | { kind: 'value'; cell: StorageValueCell } + | { kind: 'gap'; fromByte: number; toByte: number } + | { kind: 'reference'; span: CollapsedStorageSpan } + | { kind: 'elided'; spans: readonly CollapsedStorageSpan[] }; +export function buildRows(model: StaticStorageLayoutModel): { + rows: GridRow[]; + collapsed: CollapsedStorageSpan[]; +}; +``` + +`buildRows` keeps returning `collapsed` (every span, unfiltered) so Task 4 can +retire the "Not expanded" list without changing this signature again. Tasks 3 +and 4 consume `RowSegment` and `GridRow`. + +- [ ] **Step 1: Claim the bead** + +```bash +bd update slotscope-zvd.2 --claim +``` + +- [ ] **Step 2: Add fast-check to the web workspace** + +`fast-check@4.9.0` is already the pinned version in `packages/compiler` and +`packages/session`; the `deps:policy` gate requires the exact same form. Add it to +the `devDependencies` block of `apps/web/package.json`, keeping the block's +alphabetical order: + +```json + "fast-check": "4.9.0", +``` + +Then: + +```bash +pnpm install +``` + +- [ ] **Step 3: Write the failing unit tests** + +Do these three things in `apps/web/src/storage/buildRows.test.ts`, in this order: + +1. Add `CollapsedStorageSpan` to the existing `import type { ... } from +'@slotscope/domain';` statement. +2. Add the `span` helper below, next to the existing `cell` and `model` helpers. +3. Replace the body of `describe('buildRows', ...)` with the seven tests below. + The first test is the existing test plus one `endSlot` assertion. The other six + tests are new. + +The `span` helper: + +```ts +function span( + path: string, + startSlot: bigint, + encoding: 'inplace' | 'mapping' | 'dynamic_array' | 'bytes', + numberOfBytes: bigint, + reason: CollapsedStorageSpan['reason'] = 'unsupported-encoding', + typeLabel = 'x', +): CollapsedStorageSpan { + return { + kind: 'collapsed', + path, + typeId: `t_${path}`, + typeLabel, + startSlot, + byteOffset: 0, + numberOfBytes, + encoding, + reason, + declaringContract: null, + astId: null, + }; +} +``` + +The seven tests: + +```ts +it('groups by slot ascending and fills gaps to cover bytes 0..31', () => { + const { rows } = buildRows( + model([cell('b', 1n, 0, 32), cell('a1', 0n, 0, 16), cell('a2', 0n, 24, 4)]), + ); + expect(rows.map((r) => r.slot)).toEqual([0n, 1n]); + expect(rows.map((r) => r.endSlot)).toEqual([0n, 1n]); + expect(rows[0]?.segments).toEqual([ + { kind: 'value', cell: expect.objectContaining({ path: 'a1' }) }, + { kind: 'gap', fromByte: 16, toByte: 23 }, + { kind: 'value', cell: expect.objectContaining({ path: 'a2' }) }, + { kind: 'gap', fromByte: 28, toByte: 31 }, + ]); +}); + +it('gives a mapping between two variables its own reserved row', () => { + const balances = span( + 'balances', + 1n, + 'mapping', + 32n, + 'unsupported-encoding', + 'mapping(address => uint256)', + ); + const { rows } = buildRows(model([cell('a', 0n, 0, 32), balances, cell('b', 2n, 0, 32)])); + expect(rows.map((r) => r.slot)).toEqual([0n, 1n, 2n]); + expect(rows[1]?.segments).toEqual([{ kind: 'reference', span: balances }]); +}); + +it('gives each reference encoding a reserved row', () => { + const items = [ + span('arr', 0n, 'dynamic_array', 32n), + span('blob', 1n, 'bytes', 32n), + span('name', 2n, 'bytes', 32n, 'unsupported-encoding', 'string'), + ]; + const { rows } = buildRows(model(items)); + expect(rows.map((r) => r.slot)).toEqual([0n, 1n, 2n]); + expect(rows.every((r) => r.segments[0]?.kind === 'reference')).toBe(true); +}); + +it('gives a static array of mappings one reserved row per element', () => { + const { rows } = buildRows( + model([ + span('m[0]', 0n, 'mapping', 32n), + span('m[1]', 1n, 'mapping', 32n), + span('m[2]', 2n, 'mapping', 32n), + ]), + ); + expect(rows.map((r) => r.slot)).toEqual([0n, 1n, 2n]); +}); + +it('collapses a multi-slot inplace span into one elided range row', () => { + const big = span('big', 1n, 'inplace', 32000n, 'materialization-limit', 'uint256[1000]'); + const { rows, collapsed } = buildRows(model([cell('a', 0n, 0, 32), big])); + expect(rows).toHaveLength(2); + expect(rows[1]).toEqual({ + slot: 1n, + endSlot: 1000n, + segments: [{ kind: 'elided', spans: [big] }], + }); + expect(collapsed).toHaveLength(1); +}); + +it('reports an unclaimed slot run as an elided range with no spans', () => { + const { rows } = buildRows(model([cell('a', 0n, 0, 32), cell('z', 4n, 0, 32)])); + expect(rows.map((r) => [r.slot, r.endSlot])).toEqual([ + [0n, 0n], + [1n, 3n], + [4n, 4n], + ]); + expect(rows[1]?.segments).toEqual([{ kind: 'elided', spans: [] }]); +}); + +it('never leaves a hole between the first and last slot', () => { + const { rows } = buildRows( + model([ + cell('a', 0n, 0, 32), + span('m', 1n, 'mapping', 32n), + span('big', 2n, 'inplace', 320n, 'materialization-limit'), + cell('tail', 12n, 0, 32), + ]), + ); + for (let i = 1; i < rows.length; i += 1) { + expect(rows[i]?.slot).toBe((rows[i - 1]?.endSlot ?? -1n) + 1n); + } +}); +``` + +- [ ] **Step 4: Run the tests to verify they fail** + +Run: `pnpm --filter @slotscope/web exec vitest run src/storage/buildRows.test.ts` +Expected: FAIL — `endSlot` is missing and the reference/elided rows are not built. + +- [ ] **Step 5: Rewrite `buildRows`** + +Replace the whole of `apps/web/src/storage/buildRows.ts`: + +```ts +import { groupCellsBySlot } from '@slotscope/layout'; +import type { + CollapsedStorageSpan, + StaticStorageLayoutModel, + StorageValueCell, +} from '@slotscope/domain'; + +const SLOT_BYTES = 32n; + +export type RowSegment = + | { kind: 'value'; cell: StorageValueCell } + | { kind: 'gap'; fromByte: number; toByte: number } + | { kind: 'reference'; span: CollapsedStorageSpan } + | { kind: 'elided'; spans: readonly CollapsedStorageSpan[] }; + +export interface GridRow { + slot: bigint; + /** Last slot covered; equals `slot` for every row but an elided range. */ + endSlot: bigint; + /** Segments ordered by ascending byte offset; gaps fill unused bytes. */ + segments: RowSegment[]; +} + +/** How many whole slots a byte extent occupies (at least one). */ +function slotSpan(numberOfBytes: bigint): bigint { + if (numberOfBytes <= SLOT_BYTES) return 1n; + const whole = numberOfBytes / SLOT_BYTES; + return numberOfBytes % SLOT_BYTES === 0n ? whole : whole + 1n; +} + +type Claim = + | { start: bigint; end: bigint; kind: 'cells'; cells: StorageValueCell[] } + | { start: bigint; end: bigint; kind: 'reference'; span: CollapsedStorageSpan } + | { start: bigint; end: bigint; kind: 'elided'; spans: CollapsedStorageSpan[] }; + +function byStart(a: Claim, b: Claim): number { + return a.start < b.start ? -1 : a.start > b.start ? 1 : 0; +} + +/** Bytes 0..31 of one slot, with explicit gaps for what no cell covers. */ +function cellSegments(cells: StorageValueCell[]): RowSegment[] { + const segments: RowSegment[] = []; + let cursor = 0; + for (const cell of [...cells].sort((a, b) => a.byteOffset - b.byteOffset)) { + if (cell.byteOffset > cursor) { + segments.push({ kind: 'gap', fromByte: cursor, toByte: cell.byteOffset - 1 }); + } + segments.push({ kind: 'value', cell }); + cursor = cell.byteOffset + cell.byteLength; + } + if (cursor < 32) segments.push({ kind: 'gap', fromByte: cursor, toByte: 31 }); + return segments; +} + +/** + * One row per slot the compiler accounted for, in ascending slot order, with + * no hole between the first and last row. + * + * A reference type (mapping, dynamic array, bytes/string) reserves exactly one + * slot it may never fill, so it earns its own row rather than vanishing from + * the sequence. An inplace span the decoder did not expand can cover thousands + * of slots, so it becomes a single elided range row instead of thousands of + * rows — and any run of slots nothing claims becomes an elided range with no + * spans, which is honest about the hole rather than hiding it. + * + * Collapsed spans are still returned in full: announced, never dropped. + */ +export function buildRows(model: StaticStorageLayoutModel): { + rows: GridRow[]; + collapsed: CollapsedStorageSpan[]; +} { + const bySlot = groupCellsBySlot(model.items); + const collapsed = model.items.filter( + (item): item is CollapsedStorageSpan => item.kind === 'collapsed', + ); + + const claims: Claim[] = []; + for (const [slotKey, cells] of bySlot) { + const slot = BigInt(slotKey); + claims.push({ start: slot, end: slot, kind: 'cells', cells: [...cells] }); + } + for (const span of collapsed) { + // A reference head is exactly one word and never shares its slot with a + // materialized cell; anything else keeps its exact compiler-reported extent. + if (span.encoding !== 'inplace' && !bySlot.has(span.startSlot.toString())) { + claims.push({ start: span.startSlot, end: span.startSlot, kind: 'reference', span }); + continue; + } + const end = span.startSlot + slotSpan(span.numberOfBytes) - 1n; + claims.push({ start: span.startSlot, end, kind: 'elided', spans: [span] }); + } + if (claims.length === 0) return { rows: [], collapsed }; + + claims.sort(byStart); + + const rows: GridRow[] = []; + let cursor = claims[0]!.start; + let index = 0; + while (index < claims.length) { + const claim = claims[index]!; + if (claim.end < cursor) { + // Already covered by a wider elided range emitted before it. + index += 1; + continue; + } + if (claim.start > cursor) { + rows.push({ + slot: cursor, + endSlot: claim.start - 1n, + segments: [{ kind: 'elided', spans: [] }], + }); + cursor = claim.start; + continue; + } + if (claim.kind === 'cells') { + rows.push({ slot: claim.start, endSlot: claim.start, segments: cellSegments(claim.cells) }); + } else if (claim.kind === 'reference') { + rows.push({ + slot: claim.start, + endSlot: claim.start, + segments: [{ kind: 'reference', span: claim.span }], + }); + } else { + // Merge every elided claim that overlaps this one into a single range. + const spans = [...claim.spans]; + let end = claim.end; + let next = index + 1; + while (next < claims.length) { + const other = claims[next]!; + if (other.kind !== 'elided' || other.start > end) break; + spans.push(...other.spans); + if (other.end > end) end = other.end; + next += 1; + } + rows.push({ slot: claim.start, endSlot: end, segments: [{ kind: 'elided', spans }] }); + index = next - 1; + } + cursor = rows[rows.length - 1]!.endSlot + 1n; + index += 1; + } + return { rows, collapsed }; +} +``` + +- [ ] **Step 6: Run the unit tests to verify they pass** + +Run: `pnpm --filter @slotscope/web exec vitest run src/storage/buildRows.test.ts` +Expected: PASS, all seven tests. + +- [ ] **Step 7: Write the property test** + +Create `apps/web/src/storage/buildRows.property.test.ts`. The generator mirrors +how solc allocates: a sequence of declarations, each consuming whole slots from a +running cursor, so it never produces an impossible overlap. + +```ts +import type { CollapsedStorageSpan, StaticLayoutItem } from '@slotscope/domain'; +import fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { buildRows } from './buildRows'; + +type Declaration = + | { kind: 'value'; slots: number } + | { kind: 'reference'; encoding: 'mapping' | 'dynamic_array' | 'bytes' } + | { kind: 'inplaceCollapse'; slots: number }; + +const declaration = fc.oneof( + fc.record({ kind: fc.constant('value' as const), slots: fc.integer({ min: 1, max: 4 }) }), + fc.record({ + kind: fc.constant('reference' as const), + encoding: fc.constantFrom('mapping' as const, 'dynamic_array' as const, 'bytes' as const), + }), + fc.record({ + kind: fc.constant('inplaceCollapse' as const), + slots: fc.integer({ min: 1, max: 1000 }), + }), +); + +/** Lay declarations out sequentially, exactly as solc allocates top-level slots. */ +function layout(declarations: readonly Declaration[]): StaticLayoutItem[] { + const items: StaticLayoutItem[] = []; + let slot = 0n; + declarations.forEach((declaration, index) => { + if (declaration.kind === 'value') { + for (let i = 0; i < declaration.slots; i += 1) { + items.push({ + kind: 'value', + path: `v${index}_${i}`, + typeId: 't_uint256', + typeLabel: 'uint256', + slot: slot + BigInt(i), + byteOffset: 0, + byteLength: 32, + declaringContract: null, + astId: null, + }); + } + slot += BigInt(declaration.slots); + return; + } + const span: CollapsedStorageSpan = { + kind: 'collapsed', + path: `c${index}`, + typeId: `t_c${index}`, + typeLabel: 'collapsed', + startSlot: slot, + byteOffset: 0, + numberOfBytes: declaration.kind === 'reference' ? 32n : BigInt(declaration.slots) * 32n, + encoding: declaration.kind === 'reference' ? declaration.encoding : 'inplace', + reason: declaration.kind === 'reference' ? 'unsupported-encoding' : 'materialization-limit', + declaringContract: null, + astId: null, + }; + items.push(span); + slot += declaration.kind === 'reference' ? 1n : BigInt(declaration.slots); + }); + return items; +} + +describe('buildRows invariants', () => { + it('covers every slot from the first to the last with no hole and no overlap', () => { + fc.assert( + fc.property(fc.array(declaration, { minLength: 1, maxLength: 12 }), (declarations) => { + const items = layout(declarations); + const { rows } = buildRows({ + items, + materializedCellCount: 0, + materializedSlotCount: 0, + }); + expect(rows.length).toBeGreaterThan(0); + expect(rows[0]?.slot).toBe(0n); + for (const row of rows) { + expect(row.endSlot).toBeGreaterThanOrEqual(row.slot); + } + for (let i = 1; i < rows.length; i += 1) { + expect(rows[i]?.slot).toBe((rows[i - 1]?.endSlot ?? -1n) + 1n); + } + }), + { numRuns: 300 }, + ); + }); + + it('accounts for every collapsed span in a reference or elided row', () => { + fc.assert( + fc.property(fc.array(declaration, { minLength: 1, maxLength: 12 }), (declarations) => { + const items = layout(declarations); + const { rows, collapsed } = buildRows({ + items, + materializedCellCount: 0, + materializedSlotCount: 0, + }); + const shown = new Set(); + for (const row of rows) { + for (const segment of row.segments) { + if (segment.kind === 'reference') shown.add(segment.span.path); + if (segment.kind === 'elided') for (const span of segment.spans) shown.add(span.path); + } + } + expect([...shown].sort()).toEqual(collapsed.map((span) => span.path).sort()); + }), + { numRuns: 300 }, + ); + }); +}); +``` + +- [ ] **Step 8: Run the property test** + +Run: `pnpm --filter @slotscope/web exec vitest run src/storage/buildRows.property.test.ts` +Expected: PASS. A fast-check counterexample is a real hole in the row list. Fix +`buildRows`. Do not weaken the generator. + +- [ ] **Step 9: Make the app compile again** + +`StorageGrid.tsx` now fails `typecheck`. The `row.segments.map` callback receives +two segment kinds that its ternary expressions do not handle. Add a placeholder +branch for each one, so that the gate passes. Task 4 replaces these placeholders +with the real rendering. + +Treat both new kinds as one full-width cell. Change the `style` ternary at line +~237 and the `cls` ternary at line ~250: + +```tsx +const style = + segment.kind === 'value' + ? { + gridColumn: gridColumn(segment.cell.byteOffset, segment.cell.byteLength), + gridRow: 1, + } + : segment.kind === 'gap' + ? { + gridColumn: gridColumn(segment.fromByte, segment.toByte - segment.fromByte + 1), + gridRow: 1, + } + : { gridColumn: gridColumn(0, 32), gridRow: 1 }; +``` + +```tsx +const cls = + segment.kind === 'value' + ? `storage-cell ss-emphasis ${ + segment.cell.declaringContract !== null + ? (ownerClass.get(segment.cell.declaringContract) ?? '') + : '' + }` + : segment.kind === 'gap' + ? 'storage-cell storage-gap' + : 'storage-cell'; +``` + +In `segmentAria` (line 16), add the two branches: + +```ts +function segmentAria(segment: RowSegment, slot: bigint): string { + if (segment.kind === 'gap') { + return `Slot ${slot}, bytes ${segment.fromByte}–${segment.toByte}: unused`; + } + if (segment.kind === 'reference') { + return `Slot ${slot}, bytes 31–0: ${segment.span.path}, type ${segment.span.typeLabel}`; + } + if (segment.kind === 'elided') { + return `Slot ${slot}: not expanded`; + } + const cell = segment.cell; + const end = cell.byteOffset + cell.byteLength - 1; + return `Slot ${cell.slot}, bytes ${cell.byteOffset}–${end}: ${cell.path}, type ${cell.typeLabel}${ + cell.declaringContract !== null ? `, declared in ${cell.declaringContract}` : '' + }`; +} +``` + +Two more places use these segments: + +- The value-cell body at line ~283 and the record filter at line ~214 already test + `segment.kind === 'value'`. Do not change them. +- Change the gap body at line ~291 to `segment.kind === 'gap' ? (...) : null`. + TypeScript then narrows the type of `segment.toByte`. + +- [ ] **Step 10: Run the full gate** + +Run: `pnpm format:check && pnpm lint && pnpm typecheck && pnpm test:unit` +Expected: PASS. + +- [ ] **Step 11: Commit (only with the owner's approval)** + +```bash +git add apps/web/package.json pnpm-lock.yaml apps/web/src/storage/ \ + && git commit -m "feat(web): gapless storage rows with reference and elided ranges" +bd close slotscope-zvd.2 +``` + +--- + +### Task 3: The measured word for a reserved slot + +**Bead:** `slotscope-zvd.3` + +**Files:** + +- Create: `apps/web/src/storage/runtimeView.ts` +- Create: `apps/web/src/storage/runtimeView.test.ts` +- Create: `apps/web/src/storage/referenceValue.ts` +- Create: `apps/web/src/storage/referenceValue.test.ts` +- Modify: `packages/semantics/src/index.ts` (export `decodeStringSlot`) +- Modify: `apps/web/src/storage/StorageGrid.tsx:7-12` (accept the `runtime` prop) +- Modify: `apps/web/src/probe/ProbePane.tsx:102-107` (pass it) + +**Interfaces:** + +- Consumes: `RowSegment`/`GridRow` from Task 2; `CollapsedStorageSpan.encoding` + from Task 1. +- Produces: + +```ts +export interface StorageRuntimeView { + readonly wordsBySlot: ReadonlyMap; // decimal slot → 64-char hex + readonly derivedCountByBase: ReadonlyMap; +} +export function buildRuntimeView( + trace: { observations: TraceObservations | null } | null, + resolutions: readonly SemanticResolution[], + cursor: number, +): StorageRuntimeView; + +export interface ReferenceReading { + readonly eyebrow: string; // e.g. 'RESERVED · mapping' + readonly sentence: string; // full honest sentence for aria + record + readonly measured: string | null; // amber text, or null when unobserved + readonly anomaly: boolean; // observed word contradicts the encoding +} +export function readReference(span: CollapsedStorageSpan, word: string | null): ReferenceReading; +``` + +Task 4 renders `ReferenceReading` and consumes `StorageGridProps.runtime`. + +- [ ] **Step 1: Claim the bead** + +```bash +bd update slotscope-zvd.3 --claim +``` + +- [ ] **Step 2: Write the failing runtimeView test** + +Create `apps/web/src/storage/runtimeView.test.ts`: + +```ts +import type { SemanticResolution, StorageAccessObservation } from '@slotscope/domain'; +import { describe, expect, it } from 'vitest'; +import { buildRuntimeView } from './runtimeView'; + +const SLOT_1 = '1'.padStart(64, '0'); + +function access( + stepIndex: number, + kind: StorageAccessObservation['kind'], + slotHex: string, + valueHex: string | null, +): StorageAccessObservation { + return { stepIndex, kind, slotHex, valueHex, cold: false, slotExpr: { kind: 'unknown' } }; +} + +function trace(storage: StorageAccessObservation[]) { + return { observations: { keccak: [], storage } }; +} + +function resolution(partial: Partial): SemanticResolution { + return { + accessId: 'step-0', + stepIndex: 0, + kind: 'sstore', + status: 'exact', + label: null, + decodedValues: [], + reasoning: [], + baseVariable: null, + accessCostNote: null, + migration: null, + previousValueHex: null, + ...partial, + }; +} + +describe('buildRuntimeView', () => { + it('is empty without a trace', () => { + const view = buildRuntimeView(null, [], 0); + expect(view.wordsBySlot.size).toBe(0); + expect(view.derivedCountByBase.size).toBe(0); + }); + + it('keys words by decimal slot and lets the last observation win', () => { + const view = buildRuntimeView( + trace([ + access(0, 'sstore', SLOT_1, `${'0'.repeat(63)}1`), + access(1, 'sstore', SLOT_1, `${'0'.repeat(63)}7`), + ]), + [], + 5, + ); + expect(view.wordsBySlot.get('1')).toBe(`${'0'.repeat(63)}7`); + }); + + it('takes a value from a read, so pre-existing state is visible', () => { + const view = buildRuntimeView(trace([access(0, 'sload', SLOT_1, `${'0'.repeat(63)}9`)]), [], 5); + expect(view.wordsBySlot.get('1')).toBe(`${'0'.repeat(63)}9`); + }); + + it('ignores observations past the cursor and transient storage', () => { + const view = buildRuntimeView( + trace([ + access(0, 'sstore', SLOT_1, `${'0'.repeat(63)}1`), + access(3, 'sstore', SLOT_1, `${'0'.repeat(63)}2`), + access(1, 'tstore', SLOT_1, `${'0'.repeat(63)}3`), + ]), + [], + 2, + ); + expect(view.wordsBySlot.get('1')).toBe(`${'0'.repeat(63)}1`); + }); + + it('skips observations with no derivable value', () => { + const view = buildRuntimeView( + trace([access(0, 'sstore', SLOT_1, `${'0'.repeat(63)}4`), access(1, 'sload', SLOT_1, null)]), + [], + 9, + ); + expect(view.wordsBySlot.get('1')).toBe(`${'0'.repeat(63)}4`); + }); + + it('counts distinct derived entries per base variable', () => { + const view = buildRuntimeView( + trace([]), + [ + resolution({ baseVariable: 'balances', label: 'balances[0x01]' }), + resolution({ baseVariable: 'balances', label: 'balances[0x01]' }), + resolution({ baseVariable: 'balances', label: 'balances[0x02]' }), + resolution({ baseVariable: null, label: 'raw' }), + ], + 9, + ); + expect(view.derivedCountByBase.get('balances')).toBe(2); + expect(view.derivedCountByBase.has('raw')).toBe(false); + }); +}); +``` + +- [ ] **Step 3: Run it to verify it fails** + +Run: `pnpm --filter @slotscope/web exec vitest run src/storage/runtimeView.test.ts` +Expected: FAIL — `Failed to resolve import "./runtimeView"`. + +- [ ] **Step 4: Implement `runtimeView.ts`** + +Create `apps/web/src/storage/runtimeView.ts`: + +```ts +import type { SemanticResolution, TraceObservations } from '@slotscope/domain'; + +export interface StorageRuntimeView { + /** Decimal slot → the last 32-byte word observed at or before the cursor. */ + readonly wordsBySlot: ReadonlyMap; + /** Base variable → how many distinct derived entries this run has named. */ + readonly derivedCountByBase: ReadonlyMap; +} + +const EMPTY: StorageRuntimeView = { + wordsBySlot: new Map(), + derivedCountByBase: new Map(), +}; + +/** + * What the machine has said about persistent slots so far, folded for the + * static grid. + * + * Observations rather than a state replay, deliberately: a slot that was only + * READ still yields a value here, which is the only way pre-existing state + * becomes visible. Reads and writes are both "last one at or before the cursor + * wins", matching the bookkeeping the semantics resolver already performs. + * + * Honest limit: these are last-OBSERVED words, not post-revert final state. A + * write inside a frame that later reverted still appears, so callers must say + * "observed", never "final". + */ +export function buildRuntimeView( + trace: { observations: TraceObservations | null } | null, + resolutions: readonly SemanticResolution[], + cursor: number, +): StorageRuntimeView { + if (trace === null && resolutions.length === 0) return EMPTY; + + const wordsBySlot = new Map(); + const observations = trace?.observations ?? null; + if (observations !== null) { + for (const access of observations.storage) { + if (access.kind === 'tload' || access.kind === 'tstore') continue; + if (access.stepIndex > cursor) continue; + if (access.valueHex === null) continue; + wordsBySlot.set(BigInt(`0x${access.slotHex}`).toString(), access.valueHex); + } + } + + const derivedLabels = new Map>(); + for (const resolution of resolutions) { + const base = resolution.baseVariable; + if (base === null) continue; + const labels = derivedLabels.get(base) ?? new Set(); + labels.add(resolution.label ?? resolution.accessId); + derivedLabels.set(base, labels); + } + const derivedCountByBase = new Map(); + for (const [base, labels] of derivedLabels) derivedCountByBase.set(base, labels.size); + + return { wordsBySlot, derivedCountByBase }; +} +``` + +- [ ] **Step 5: Run it to verify it passes** + +Run: `pnpm --filter @slotscope/web exec vitest run src/storage/runtimeView.test.ts` +Expected: PASS, six tests. + +- [ ] **Step 6: Export `decodeStringSlot` from semantics** + +In `packages/semantics/src/index.ts`, add the export block (keep the file's +existing style — one `export {...} from './.ts';` per module): + +```ts +export { decodeStringSlot, type StringForm } from './strings.ts'; +``` + +- [ ] **Step 7: Write the failing referenceValue test** + +Create `apps/web/src/storage/referenceValue.test.ts`: + +```ts +import type { CollapsedStorageSpan } from '@slotscope/domain'; +import { describe, expect, it } from 'vitest'; +import { readReference } from './referenceValue'; + +function span(encoding: CollapsedStorageSpan['encoding'], typeLabel: string): CollapsedStorageSpan { + return { + kind: 'collapsed', + path: 'v', + typeId: 't_v', + typeLabel, + startSlot: 1n, + byteOffset: 0, + numberOfBytes: 32n, + encoding, + reason: 'unsupported-encoding', + declaringContract: null, + astId: null, + }; +} + +const word = (value: bigint) => value.toString(16).padStart(64, '0'); + +describe('readReference', () => { + it('says a mapping head is reserved and has no value of its own', () => { + const reading = readReference(span('mapping', 'mapping(address => uint256)'), null); + expect(reading.eyebrow).toBe('RESERVED · mapping'); + expect(reading.measured).toBeNull(); + expect(reading.anomaly).toBe(false); + expect(reading.sentence).toContain('keccak'); + }); + + it('treats an all-zero mapping head as the expected reserved state', () => { + expect( + readReference(span('mapping', 'mapping(address => uint256)'), word(0n)).measured, + ).toBeNull(); + }); + + it('flags a nonzero mapping head as an anomaly rather than a value', () => { + const reading = readReference(span('mapping', 'mapping(address => uint256)'), word(5n)); + expect(reading.anomaly).toBe(true); + expect(reading.measured).toContain('0x'); + expect(reading.sentence).toContain('not written by the compiler'); + }); + + it('reads a dynamic array head as its length', () => { + const reading = readReference(span('dynamic_array', 'uint256[]'), word(3n)); + expect(reading.eyebrow).toBe('LENGTH · array'); + expect(reading.measured).toBe('length = 3'); + expect(reading.anomaly).toBe(false); + }); + + it('reads a short string from the head, where the data actually lives', () => { + // 'hi' left-aligned, 2·len = 4 in the low byte. + const short = `${Buffer.from('hi').toString('hex').padEnd(62, '0')}04`; + const reading = readReference(span('bytes', 'string'), short); + expect(reading.eyebrow).toBe('LENGTH + DATA · string'); + expect(reading.measured).toBe('"hi" · 2 bytes'); + }); + + it('reads a long string head as a length marker only', () => { + const reading = readReference(span('bytes', 'string'), word(97n)); + expect(reading.eyebrow).toBe('LENGTH · string'); + expect(reading.measured).toBe('48 bytes at keccak(slot)'); + }); + + it('reads short bytes without pretending they are text', () => { + const reading = readReference(span('bytes', 'bytes'), `${'ab'.repeat(2)}${'0'.repeat(58)}04`); + expect(reading.eyebrow).toBe('LENGTH + DATA · bytes'); + expect(reading.measured).toBe('2 bytes in this slot'); + }); + + it('says an invalid string encoding is invalid instead of guessing', () => { + const reading = readReference(span('bytes', 'string'), word(3n)); + expect(reading.anomaly).toBe(true); + expect(reading.measured).toContain('non-canonical'); + }); + + it('has no measured value before anything is observed', () => { + expect(readReference(span('dynamic_array', 'uint256[]'), null).measured).toBeNull(); + }); +}); +``` + +- [ ] **Step 8: Run it to verify it fails** + +Run: `pnpm --filter @slotscope/web exec vitest run src/storage/referenceValue.test.ts` +Expected: FAIL — `Failed to resolve import "./referenceValue"`. + +- [ ] **Step 9: Implement `referenceValue.ts`** + +Create `apps/web/src/storage/referenceValue.ts`: + +```ts +import type { CollapsedStorageSpan } from '@slotscope/domain'; +import { decodeStringSlot, displayTypedValue } from '@slotscope/semantics'; + +export interface ReferenceReading { + /** Uppercase eyebrow naming what the slot holds. */ + readonly eyebrow: string; + /** The honest sentence, for the aria label and the record list. */ + readonly sentence: string; + /** Measured text for the amber span, or null when nothing was observed. */ + readonly measured: string | null; + /** The observed word contradicts what this encoding can canonically hold. */ + readonly anomaly: boolean; +} + +const MAPPING_SENTENCE = + 'reserved base slot — entries live at keccak-derived slots that exist only at runtime; ' + + 'deploy and call a function that touches it, and the storage accesses under the trace will name each one.'; +const ARRAY_SENTENCE = + 'the base slot holds the length; the elements live at keccak-derived slots — run a call that touches it and the trace names them.'; + +function isZeroWord(word: string): boolean { + return /^0*$/.test(word); +} + +function shortWord(word: string): string { + return `0x${word.replace(/^0+(?=.)/, '')}`; +} + +/** + * What a reference head reserves, and what the machine has been seen to put + * there. Three encodings, three different truths — a mapping head is never + * written, an array head holds a length, and a short bytes/string head holds + * the data itself, so one shared sentence would be false for two of them. + */ +export function readReference(span: CollapsedStorageSpan, word: string | null): ReferenceReading { + if (span.encoding === 'mapping') { + if (word === null || isZeroWord(word)) { + return { + eyebrow: 'RESERVED · mapping', + sentence: MAPPING_SENTENCE, + measured: null, + anomaly: false, + }; + } + return { + eyebrow: 'RESERVED · mapping', + sentence: `${MAPPING_SENTENCE} The word observed here was not written by the compiler — only inline assembly or a slot collision can put a value in a mapping head.`, + measured: shortWord(word), + anomaly: true, + }; + } + + if (span.encoding === 'dynamic_array') { + return { + eyebrow: 'LENGTH · array', + sentence: ARRAY_SENTENCE, + measured: word === null ? null : `length = ${displayTypedValue('uint256', word)}`, + anomaly: false, + }; + } + + // bytes / string. `typeLabel` decides only which decoder renders the word — + // the same display-only distinction the resolver makes (resolve.ts:232). + const isString = span.typeLabel.startsWith('string'); + const noun = isString ? 'string' : 'bytes'; + if (word === null) { + return { + eyebrow: `LENGTH · ${noun}`, + sentence: `the base slot holds the length, and under 32 bytes the data itself; longer values live at keccak-derived slots.`, + measured: null, + anomaly: false, + }; + } + const form = decodeStringSlot(word, isString); + if (form.form === 'invalid') { + return { + eyebrow: `LENGTH · ${noun}`, + sentence: `the observed word is a non-canonical ${noun} encoding — Solidity did not write it.`, + measured: `non-canonical ${noun} word ${shortWord(word)}`, + anomaly: true, + }; + } + if (form.form === 'long') { + return { + eyebrow: `LENGTH · ${noun}`, + sentence: `long form: the head holds 2·length+1 and the data lives at keccak(slot).`, + measured: `${form.byteLength} bytes at keccak(slot)`, + anomaly: false, + }; + } + return { + eyebrow: `LENGTH + DATA · ${noun}`, + sentence: `short form: the data is in this slot, left-aligned, with 2·length in the low byte.`, + measured: + form.text !== null + ? `"${form.text}" · ${form.byteLength} bytes` + : `${form.byteLength} bytes in this slot`, + anomaly: false, + }; +} +``` + +- [ ] **Step 10: Run it to verify it passes** + +Run: `pnpm --filter @slotscope/web exec vitest run src/storage/referenceValue.test.ts` +Expected: PASS, nine tests. + +- [ ] **Step 11: Accept the prop in `StorageGrid` and pass it from `ProbePane`** + +Change two places in `apps/web/src/storage/StorageGrid.tsx`. First add +`import type { StorageRuntimeView } from './runtimeView';` to the file. Then +extend the props interface at line 7 and the destructuring at line 61: + +```ts +export interface StorageGridProps { + artifact: ContractArtifact | null; + declaringContracts: Readonly>; + /** contract name → owner suffix ('a'|'b'|'c'), shared with the editor spine. */ + ownerSuffixByContract?: ReadonlyMap; + /** What the machine has observed about these slots; null before any run. */ + runtime?: StorageRuntimeView | null; +} +``` + +```tsx +export function StorageGrid({ + artifact, + declaringContracts, + ownerSuffixByContract, + runtime = null, +}: StorageGridProps) { +``` + +with `import type { StorageRuntimeView } from './runtimeView';` added. + +Now change `apps/web/src/probe/ProbePane.tsx`. Line 45 already destructures +`trace`, `resolutions` and `cursor`, so this file needs no new state. Add one memo +next to the existing memos. Then pass the result to `StorageGrid` at line 102: + +```tsx +const storageRuntime = useMemo( + () => buildRuntimeView(trace, resolutions, boundedCursor), + [trace, resolutions, boundedCursor], +); +``` + +```tsx + +``` + +Add `import { buildRuntimeView } from '../storage/runtimeView';` to the file. The +new memo must come after the declaration of `boundedCursor`. Do not move the +existing memos. + +- [ ] **Step 12: Run the full gate** + +Run: `pnpm format:check && pnpm lint && pnpm typecheck && pnpm test:unit` +Expected: PASS. `runtime` is accepted but not yet rendered — that is Task 4. + +- [ ] **Step 13: Commit (only with the owner's approval)** + +```bash +git add packages/semantics/src/index.ts apps/web/src/storage/ apps/web/src/probe/ProbePane.tsx +git commit -m "feat(web): fold observed storage words into a grid runtime view" +bd close slotscope-zvd.3 +``` + +--- + +### Task 4: The face — visuals, a11y, and the e2e gates + +**Bead:** `slotscope-zvd.4` + +**Files:** + +- Modify: `apps/web/src/storage/StorageGrid.tsx` (render both new segment kinds; retire "Not expanded") +- Modify: `apps/web/src/app/app.css` (new classes near `.storage-gap`, ~line 1256) +- Modify: `apps/web/e2e/storage.spec.ts:131-149` (replace the assertion that encodes the defect) +- Modify: `apps/web/e2e/execute.spec.ts` (add the measured-value case) +- Modify: `docs/limitations-v1.0.md` (observed vs final state) +- Modify: `CHANGELOG.md` (1.2.0 entry) + +**Interfaces:** + +- Consumes: `GridRow`/`RowSegment` (Task 2), `StorageRuntimeView` and + `readReference`/`ReferenceReading` (Task 3), `CollapsedStorageSpan.encoding` + (Task 1). +- Produces: no new module API. DOM contracts the e2e suites rely on: + `[data-testid="storage-reference"]` on a reserved row's cell, + `[data-testid="storage-elided"]` on an elided range's cell, and + `[data-testid="collapsed-spans"]` removed. + +- [ ] **Step 1: Claim the bead** + +```bash +bd update slotscope-zvd.4 --claim +``` + +- [ ] **Step 2: Write the failing e2e test** + +Replace the whole `'a mapping base slot explains itself honestly, with no stale +version claim'` test in `apps/web/e2e/storage.spec.ts:131-149`. That test asserts +that the mapping appears **only** under `collapsed-spans`. That assertion states +the defect as the expected behavior. The spec replaces it deliberately. + +```ts +test('every reserved reference slot gets its own row, with no skipped slot', 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 uint256 a;\n mapping(address => uint256) internal balances;\n uint256 b;\n uint256[] internal history;\n string internal note;\n}', + ); + await page.getByRole('button', { name: 'Compile' }).click(); + const grid = page.getByRole('grid', { name: 'Storage layout' }); + await expect(grid).toBeVisible({ timeout: 60_000 }); + + // Slots 0..4 are all present: nothing is skipped. + for (const slot of [0, 1, 2, 3, 4]) { + await expect(grid.getByText(`slot ${slot}`, { exact: true })).toBeVisible(); + } + + const reserved = grid.locator('[data-testid="storage-reference"]'); + await expect(reserved).toHaveCount(3); + await expect(reserved.nth(0)).toHaveAttribute('aria-label', /balances.*reserved base slot/); + await expect(reserved.nth(0)).toContainText('RESERVED · mapping'); + await expect(reserved.nth(1)).toContainText('LENGTH · array'); + await expect(reserved.nth(2)).toContainText('note'); + + // The old prose list is gone; the rows carry the teaching copy now. + await expect(page.locator('[data-testid="collapsed-spans"]')).toHaveCount(0); + await expect(grid).toContainText('keccak-derived slots that exist only at runtime'); + + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations).toEqual([]); +}); + +test('a reserved slot shows the value the constructor put in it', 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 uint256[] internal history;\n string internal note;\n constructor() {\n history.push(7);\n note = "hi";\n }\n}', + ); + await page.getByRole('button', { name: 'Compile' }).click(); + const grid = page.getByRole('grid', { name: 'Storage layout' }); + await expect(grid).toBeVisible({ timeout: 60_000 }); + await page.getByRole('button', { name: 'Deploy locally' }).click(); + + const reserved = grid.locator('[data-testid="storage-reference"]'); + await expect(reserved.nth(0)).toContainText('length = 1', { timeout: 60_000 }); + await expect(reserved.nth(1)).toContainText('"hi"'); +}); +``` + +`'Deploy locally'` is the control's real accessible name — it is what every +existing test in `apps/web/e2e/execute.spec.ts` clicks (lines 18, 37, 65, 83, 103). + +- [ ] **Step 3: Run it to verify it fails** + +Run: `pnpm --filter @slotscope/web exec playwright test e2e/storage.spec.ts -g "reserved"` +Expected: FAIL — no `[data-testid="storage-reference"]` element exists. + +- [ ] **Step 4: Render the reference row** + +In `apps/web/src/storage/StorageGrid.tsx`, replace the placeholder branches from +Task 2 Step 9 with the real rendering. + +Import at the top: + +```ts +import { readReference, type ReferenceReading } from './referenceValue'; +``` + +Compute the reading once per reference segment, inside the `row.segments.map` +callback, above the `style` ternary: + +```tsx +const reading: ReferenceReading | null = + segment.kind === 'reference' + ? readReference(segment.span, runtime?.wordsBySlot.get(row.slot.toString()) ?? null) + : null; +``` + +Give `segmentAria` the honest sentence by passing the reading through it — replace +the `reference`/`elided` branches added in Task 2 Step 9: + +```ts +function segmentAria(segment: RowSegment, slot: bigint, reading: ReferenceReading | null): string { + if (segment.kind === 'gap') { + return `Slot ${slot}, bytes ${segment.fromByte}–${segment.toByte}: unused`; + } + if (segment.kind === 'reference') { + const measured = + reading?.measured !== null && reading !== null ? `; last observed: ${reading.measured}` : ''; + return `Slot ${slot}, bytes 31–0: ${segment.span.path}, type ${segment.span.typeLabel}, ${ + reading?.sentence ?? 'reserved base slot' + }${measured}`; + } + if (segment.kind === 'elided') { + const reasons = segment.spans.map((span) => `${span.path}: ${collapsedReason(span)}`).join(' '); + return segment.spans.length === 0 + ? `Slots ${slot} onward: no declared variable` + : `Slots ${slot} onward: not expanded — ${reasons}`; + } + const cell = segment.cell; + const end = cell.byteOffset + cell.byteLength - 1; + return `Slot ${cell.slot}, bytes ${cell.byteOffset}–${end}: ${cell.path}, type ${cell.typeLabel}${ + cell.declaringContract !== null ? `, declared in ${cell.declaringContract}` : '' + }`; +} +``` + +Three call sites pass arguments to `segmentAria`: the `title` attribute, the +`aria-label` attribute, and the `openDetails` function. Change each call from +`segmentAria(segment, row.slot)` to `segmentAria(segment, row.slot, reading)`. Give +`openDetails` a third parameter for the reading, and pass the reading to it. + +Class names: + +```tsx +const cls = + segment.kind === 'value' + ? `storage-cell ss-emphasis ${ + segment.cell.declaringContract !== null + ? (ownerClass.get(segment.cell.declaringContract) ?? '') + : '' + }` + : segment.kind === 'gap' + ? 'storage-cell storage-gap' + : segment.kind === 'reference' + ? `storage-cell ss-emphasis is-reference${reading?.anomaly === true ? ' is-anomaly' : ''}` + : 'storage-cell is-elided'; +``` + +Cell body — add these two branches to the existing `segment.kind === 'value' ? …` +chain: + +```tsx +{ + segment.kind === 'reference' && reading !== null && ( + + {segment.span.path} + {reading.eyebrow} + {reading.measured !== null && {reading.measured}} + + ); +} +{ + segment.kind === 'elided' && ( + + ); +} +``` + +Add the test ids on the `role="gridcell"` div: + +```tsx + data-testid={ + segment.kind === 'reference' + ? 'storage-reference' + : segment.kind === 'elided' + ? 'storage-elided' + : undefined + } +``` + +The row header must name a range for an elided row (line ~222), and the pin label +follows it: + +```tsx + + {row.endSlot === row.slot + ? `slot ${row.slot.toString()}` + : `slots ${row.slot.toString()}–${row.endSlot.toString()}`} + +``` + +- [ ] **Step 5: Move the teaching copy onto the record, and delete "Not expanded"** + +A reader looks in the per-slot record list for the sentence form of a band. The +sentence for a reserved row therefore belongs in that list. Do these three things: + +1. Add the block below after the existing record block at line ~309. It renders + the record for a reference row. +2. Delete the whole `{decoded.collapsed.length > 0 && ( … )}` block at lines + 344-356. This removes the "Not expanded" list. +3. Keep the `collapsedReason` function. `segmentAria` still calls it for an elided + row. Do not change the `decoded` object either: `decoded.collapsed` becomes an + unused property, which is not an error. + +```tsx +{ + row.segments[0]?.kind === 'reference' && ( + + ); +} +``` + +Also update the grid caption (line ~338) so the hatch sentence stays accurate and +the new row kinds are explained: + +```tsx +

+ Byte direction: offset 0 is the least-significant (rightmost) byte of each slot; byte 31 is + leftmost. Fields pack upward from the least significant byte, so a row reads exactly as the word + does in the EVM — hatched cells are unused padding the compiler left behind. A full-width outlined + row is a slot a reference type reserves; amber in it is a value the machine was observed to put + there. +

+``` + +- [ ] **Step 6: Style the new rows** + +In `apps/web/src/app/app.css`, after the `.storage-gap` rule (~line 1261): + +```css +/* A reserved slot is not padding, so it never takes the hatch. Graphite while + only the compiler has spoken; the amber inside it is the measured word. */ +.storage-cell.is-reference { + border: 1px solid var(--ss-color-border-strong); + background: var(--ss-color-chip-bg); + display: flex; + align-items: center; + gap: 0.5rem; + padding-inline: 0.5rem; + overflow: hidden; +} + +.storage-cell.is-reference .storage-reference-eyebrow { + color: var(--ss-color-fg-muted); + font-size: var(--ss-font-size-micro); + letter-spacing: var(--ss-tracking-label); + text-transform: uppercase; + white-space: nowrap; +} + +/* solc never writes a mapping head: a value here is a finding, not a reading. */ +.storage-cell.is-reference.is-anomaly { + border-color: var(--ss-color-warning); +} + +.storage-cell.is-elided { + border: 1px dashed var(--ss-color-border-strong); + background: transparent; + color: var(--ss-color-fg-muted); + display: flex; + align-items: center; + padding-inline: 0.5rem; +} + +.storage-record-note { + color: var(--ss-color-fg-muted); +} +``` + +Every token used above already exists in `packages/ui/src/tokens.css`: +`--ss-color-border-strong` (line 32), `--ss-color-chip-bg` (67), +`--ss-color-fg-muted` (27 — the caption/rail ink, measured at 4.99:1), +`--ss-color-warning` (48), `--ss-font-size-micro` (100), +`--ss-tracking-label` (98). Add no new tokens for this feature. + +- [ ] **Step 7: Run the storage e2e suite** + +Run: `pnpm --filter @slotscope/web exec playwright test e2e/storage.spec.ts` +Expected: PASS. This includes the existing roving-focus, ruler, pin, 200%-zoom and +axe tests. A roving-focus failure means the arrow keys cannot reach the new +full-width cells. Fix the rendering. Do not change the focus assertions. + +- [ ] **Step 8: Run the execute e2e suite** + +Run: `pnpm --filter @slotscope/web exec playwright test e2e/execute.spec.ts` +Expected: PASS. + +- [ ] **Step 9: Document the honest limit and the release note** + +In `docs/limitations-v1.0.md`, next to the existing storage bullets, add: + +```markdown +- Reserved-slot values in the storage grid are the last words _observed_ in the + current trace, not post-revert final state: a write inside a frame that later + reverted still shows. The row says "last observed", never "final". +``` + +In `CHANGELOG.md`, add a `## [1.2.0] — unreleased` section above `## [1.1.0]`: + +```markdown +## [1.2.0] — unreleased + +### Added + +- **Reserved reference slots are visible.** The slot a mapping, dynamic array, + `bytes` or `string` reserves now gets its own labeled row instead of leaving a + silent hole in the slot sequence, and the row shows what the machine was + observed to put there: an array or long-string length, a short string's inline + data, and nothing at all for a mapping head — which solc never writes, so a + value there is flagged as an anomaly. Rows are gapless by construction; an + unexpanded multi-slot span becomes one honest elided range instead of thousands + of rows. + +### Changed + +- `CollapsedStorageSpan` carries solc's `encoding`, so the UI classifies spans by + compiler output instead of by parsing display labels. +- The "Not expanded" prose list is retired; its teaching copy moved onto the rows + and the per-slot record. +``` + +- [ ] **Step 10: Run the delivery gate** + +```bash +pnpm format:check && pnpm lint && pnpm typecheck && pnpm test:unit && pnpm fixtures:verify && pnpm build && pnpm build:check +``` + +Expected: PASS. `fixtures:verify` must report no drift. This feature changes no +golden file. Drift therefore means that an earlier step changed the fixture corpus +by mistake. Find that change and revert it. + +- [ ] **Step 11: Commit and open the PR (only with the owner's approval)** + +```bash +git add apps/web/src/storage/StorageGrid.tsx apps/web/src/app/app.css \ + apps/web/e2e/storage.spec.ts apps/web/e2e/execute.spec.ts \ + docs/limitations-v1.0.md CHANGELOG.md +git commit -m "feat(web): render reserved reference slots with their observed values" +bd close slotscope-zvd.4 slotscope-zvd +git push -u origin feat/reserved-reference-slots +gh pr create --title "Reserved reference slots in the storage grid" --body "$(cat <<'BODY' +Implements docs/design/reserved-slots-v1.2.md (beads slotscope-zvd.1-.4). + +The grid no longer skips the slot a mapping, dynamic array, bytes or string +reserves: each gets its own labeled row, rows are gapless by construction, and a +reserved row shows the word the machine was observed to put there. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +BODY +)" +``` + +`master` is protected: the PR must pass all five CI jobs (quality, unit-fixtures, +build, browser, release-verify) before merge. + +--- + +### Task 5: Show reference types in the default editor source + +**Bead:** `slotscope-zvd.5` + +The editor seeds its document from the golden fixture: `App.tsx:16` imports +`@slotscope/fixtures/contracts/PackedInheritance.sol?raw`. That contract declares +no mapping, no dynamic array and no string. A first-time visitor therefore sees no +reserved row until they write their own contract. Add the three reference types to +the fixture, and add a constructor that fills two of them. + +This is the one deliberate golden change in this feature. Appending variables after +`derivedTail` is safe: every existing assertion in +`packages/fixtures/src/fixtures.test.ts` and `apps/web/e2e/storage.spec.ts` covers +slots 0 to 4 only, and the new variables take slots 5, 6 and 7. + +**Files:** + +- Modify: `packages/fixtures/contracts/PackedInheritance.sol` (append to the tail) +- Regenerate: `packages/fixtures/manifests/PackedInheritance.json` +- Regenerate: `packages/fixtures/expected/PackedInheritance.extract.json` +- Modify: `apps/web/e2e/storage.spec.ts` (one new test) + +**Interfaces:** + +- Consumes: the rendering from Task 4. No new module API. +- Produces: a default document that shows three reserved rows on first compile. + +- [ ] **Step 1: Claim the bead** + +```bash +bd update slotscope-zvd.5 --claim +``` + +- [ ] **Step 2: Write the failing test** + +Add this test to `apps/web/e2e/storage.spec.ts`. It reuses the existing +`compileFixture` helper, which loads `/` and compiles the default document. + +```ts +test('the default source shows its reserved slots, then their values on deploy', async ({ + page, +}) => { + await compileFixture(page); + const grid = page.getByRole('grid', { name: 'Storage layout' }); + + // Slots 0..4 keep their existing meaning; 5..7 are the new reference heads. + const reserved = grid.locator('[data-testid="storage-reference"]'); + await expect(reserved).toHaveCount(3); + await expect(reserved.nth(0)).toContainText('balances'); + await expect(reserved.nth(0)).toContainText('RESERVED · mapping'); + await expect(reserved.nth(1)).toContainText('history'); + await expect(reserved.nth(2)).toContainText('note'); + await expect(grid.getByText('slot 7', { exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'Deploy locally' }).click(); + await expect(reserved.nth(1)).toContainText('length = 1', { timeout: 60_000 }); + await expect(reserved.nth(2)).toContainText('"slotscope"'); + // solc never writes a mapping head, so it stays blank. + await expect(reserved.nth(0)).not.toContainText('0x'); +}); +``` + +- [ ] **Step 3: Run it to verify it fails** + +Run: `pnpm --filter @slotscope/web exec playwright test e2e/storage.spec.ts -g "default source"` +Expected: FAIL — the default contract declares no reference type, so the grid shows +zero reserved rows. + +- [ ] **Step 4: Add the three reference types and a constructor** + +In `packages/fixtures/contracts/PackedInheritance.sol`, add these lines after +`uint32 internal derivedTail; // slot 4, bytes 0..3`: + +```solidity + mapping(address => uint256) internal balances; // reference head: slot 5 + uint256[] internal history; // length in slot 6, elements at keccak(6) + string internal note; // short form: length AND data in slot 7 + + /// @notice Writes the two heads that can hold a value, so a deploy shows + /// them measured. A mapping head is left alone: solc never writes one. + constructor() { + history.push(7); + note = "slotscope"; + } +``` + +Keep the existing `positionValue` function unchanged. + +- [ ] **Step 5: Regenerate the goldens and read the diff** + +```bash +node scripts/verify-fixtures.ts --update +``` + +The command prints a semantic diff. Read it. Two hashes must change: +`sourceHash` and `expectedLayoutHash`. The layout must gain exactly three entries: +`balances` at slot 5, `history` at slot 6, and `note` at slot 7. Stop and report if +the diff shows any other change — for example a moved slot for an existing +variable, or a changed `expectedInstructionHash`. A moved slot means the append +landed in the wrong place. + +- [ ] **Step 6: Run the fixture gates** + +Run: `pnpm test:unit && pnpm fixtures:verify` +Expected: PASS. `pnpm test:unit` includes the Forge 1.5.1 cross-check, which must +still agree with solc on every storage position. + +- [ ] **Step 7: Run the whole browser suite** + +Run: `pnpm test:e2e` +Expected: PASS. Every spec that compiles the default document is in scope here: +`storage.spec.ts`, `compile.spec.ts`, `editor.spec.ts`, `relations.spec.ts`, +`palette.spec.ts`, `sharing.spec.ts` and `execute.spec.ts`. A failure that names a +slot number or a contract name means the append changed something it should not +have. + +- [ ] **Step 8: Commit (only with the owner's approval)** + +```bash +git add packages/fixtures/contracts/PackedInheritance.sol \ + packages/fixtures/manifests/PackedInheritance.json \ + packages/fixtures/expected/PackedInheritance.extract.json \ + apps/web/e2e/storage.spec.ts +git commit -m "feat(fixtures): show reference types in the default editor source" +bd close slotscope-zvd.5 +``` + +--- + +### Task 6: Make the editor tooltips readable + +**Bead:** `slotscope-73y` + +`apps/web/src/app/app.css` themes `.cm-editor`, `.cm-gutters` and `.cm-line`. It +has no rule for `.cm-tooltip`, `.cm-diagnostic` or `.cm-panels`. `SourceEditor.tsx` +enables `lintGutter()` and `lintKeymap` at lines 199 and 204, which produce +diagnostic tooltips. It also enables `searchKeymap`, which produces the Ctrl-F +panel. Those widgets therefore keep CodeMirror's built-in light background while +they inherit the app's light text. The result is bright text on a bright +background. + +This task is independent of Tasks 1 to 5. Run it in any order. + +**Files:** + +- Modify: `apps/web/src/app/app.css` (add rules after the `.cm-line` rules, ~line 952) +- Modify: `apps/web/e2e/editor.spec.ts` (one new test) + +**Interfaces:** + +- Consumes: nothing. Produces: nothing. This task changes only styles and one test. + +- [ ] **Step 1: Claim the bead** + +```bash +bd update slotscope-73y --claim +``` + +- [ ] **Step 2: Write the failing test** + +Add this test to `apps/web/e2e/editor.spec.ts`: + +```ts +test('a diagnostic tooltip is readable and axe-clean', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('[role="status"]')).toHaveAttribute('data-status', 'ready', { + timeout: 60_000, + }); + // A missing semicolon gives solc something to complain about. + await page.locator('.cm-content').fill('pragma solidity 0.8.36;\ncontract Bad { uint256 x = 1 }'); + await page.getByRole('button', { name: 'Compile' }).click(); + + const marker = page.locator('.cm-lint-marker-error').first(); + await expect(marker).toBeVisible({ timeout: 60_000 }); + await marker.hover(); + + const tooltip = page.locator('.cm-tooltip-lint'); + await expect(tooltip).toBeVisible(); + // The theme must apply: a themed tooltip is never on CodeMirror's default light + // background (#f5f5f5 / rgb(245, 245, 245)). + await expect(tooltip).toHaveCSS('background-color', 'rgb(35, 32, 32)'); + + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations).toEqual([]); +}); +``` + +`AxeBuilder` is already imported at the top of this spec file. If it is not, add +`import AxeBuilder from '@axe-core/playwright';`. + +- [ ] **Step 3: Run it to verify it fails** + +Run: `pnpm --filter @slotscope/web exec playwright test e2e/editor.spec.ts -g "diagnostic tooltip"` +Expected: FAIL on the `background-color` assertion. The received value is +CodeMirror's default light background. An axe `color-contrast` violation may also +appear. + +- [ ] **Step 4: Theme every floating CodeMirror widget** + +Add these rules to `apps/web/src/app/app.css`, after the `.cm-line.ss-spine-c` +rule at ~line 952: + +```css +/* CodeMirror ships light-theme widgets. This app is dark, so an unthemed widget + renders bright text on a bright background. Theme all of them, not only the + ones currently reachable: enabling one more extension must not reintroduce + the bug. */ +.cm-tooltip { + background: var(--ss-color-raised); + border: 1px solid var(--ss-color-border-strong); + border-radius: 2px; + box-shadow: none; + color: var(--ss-color-fg); +} + +.cm-tooltip .cm-tooltip-arrow::before { + border-top-color: var(--ss-color-border-strong); + border-bottom-color: var(--ss-color-border-strong); +} + +.cm-tooltip .cm-tooltip-arrow::after { + border-top-color: var(--ss-color-raised); + border-bottom-color: var(--ss-color-raised); +} + +.cm-tooltip-lint { + font-size: var(--ss-font-size-micro); + max-width: 32rem; + padding: 0; +} + +.cm-diagnostic { + border-left: 3px solid var(--ss-color-border-strong); + color: var(--ss-color-fg); + padding: 0.35rem 0.6rem; +} + +.cm-diagnostic-error { + border-left-color: var(--ss-color-error); +} + +.cm-diagnostic-warning { + border-left-color: var(--ss-color-warning); +} + +.cm-diagnostic-info { + border-left-color: var(--ss-color-label); +} + +.cm-diagnosticAction { + background: var(--ss-color-surface); + border: 1px solid var(--ss-color-border-strong); + color: var(--ss-color-fg); +} + +/* The Ctrl-F panel and its controls. */ +.cm-panels { + background: var(--ss-color-raised); + border-color: var(--ss-color-border-strong); + color: var(--ss-color-fg); +} + +.cm-panels-bottom { + border-top: 1px solid var(--ss-color-border-strong); +} + +.cm-panels-top { + border-bottom: 1px solid var(--ss-color-border-strong); +} + +.cm-textfield { + background: var(--ss-color-surface); + border: 1px solid var(--ss-color-border-strong); + color: var(--ss-color-fg); +} + +.cm-button { + background: var(--ss-color-raised); + background-image: none; + border: 1px solid var(--ss-color-border-strong); + color: var(--ss-color-fg); +} + +@media (forced-colors: active) { + .cm-tooltip, + .cm-panels, + .cm-textfield, + .cm-button { + border: 1px solid CanvasText; + } +} +``` + +Every token above already exists in `packages/ui/src/tokens.css`: +`--ss-color-raised` (22), `--ss-color-surface` (20), `--ss-color-fg` (25), +`--ss-color-label` (26), `--ss-color-error` (46), `--ss-color-warning` (48), +`--ss-color-border-strong` (32), `--ss-font-size-micro` (100). + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `pnpm --filter @slotscope/web exec playwright test e2e/editor.spec.ts` +Expected: PASS, including the existing editor tests. + +- [ ] **Step 6: Check the search panel by hand** + +The axe gate covers the tooltip. Check the panel yourself, because no test opens +it. Run `pnpm dev`, press Ctrl-F in the editor, and confirm three things: the panel +background is dark, the input text is readable, and the buttons have a visible +border. + +- [ ] **Step 7: Run the full gate** + +Run: `pnpm format:check && pnpm lint && pnpm typecheck && pnpm test:unit` +Expected: PASS. + +- [ ] **Step 8: Commit (only with the owner's approval)** + +```bash +git add apps/web/src/app/app.css apps/web/e2e/editor.spec.ts +git commit -m "fix(web): theme CodeMirror tooltips and panels for the dark instrument" +bd close slotscope-73y +``` + +--- + +## Notes for the implementer + +- **Where the facts live.** `packages/layout` decodes the layout. + `packages/semantics` names slots and decodes values. `apps/web` composes the two. + Solidity knowledge in `apps/web` usually belongs in `semantics`. A display + choice is the exception: put it in `referenceValue.ts`. +- **A mapping head always holds zero.** solc writes no value to it. The row + therefore shows a nonzero word as an anomaly, not as a value. A normal value + display would teach the reader something false. +- **`slot` and `endSlot` are bigint.** Do not convert them with `Number()` for + arithmetic. Do not convert them with `Number()` for comparison. A + materialization-limit span can exceed `Number.MAX_SAFE_INTEGER`. +- **A keccak-derived slot must never become a grid row.** `buildRows` takes its + claims only from the compiled layout: the value cells and the collapsed spans. + It never reads `runtimeView`. Keep it that way. A mapping entry sits at + `keccak(key ‖ slot)`. That number is near 2^256. A row for it would therefore + claim a position next to slot 4 that it does not have. It would also force an + elided range about 10^76 slots wide. The runtime panels below the grid already + name these slots: `RuntimeStorage` names each entry, and `LiveStorageStrip` + shows one band for each touched slot. +- **The `N entries seen` chip needs matching names.** The chip reads + `derivedCountByBase.get(span.path)`. `baseVariable` holds the layout label of a + top-level variable. The two names match for `balances`. They do not match for a + mapping inside a struct, such as `s.m`. The chip is therefore absent for a + struct member. Do not repair this by matching a name prefix: this codebase + forbids that kind of guess. Bead `slotscope-xu8` covers the correct fix. +- **Do not change these four things:** the collapse policy of + `decodeStorageLayout`, the limits of `decodeStorageLayout`, the keccak grounding + in `packages/semantics/src/chains.ts`, and any file in + `packages/fixtures/expected/`. diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index c6747ee..8919324 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -34,6 +34,7 @@ export type { CollapsedStorageSpan, StaticLayoutItem, StaticStorageLayoutModel, + StorageEncoding, StorageValueCell, } from './static-layout.ts'; export type { LogObservation } from './executor.ts'; diff --git a/packages/domain/src/static-layout.ts b/packages/domain/src/static-layout.ts index c8911ea..a2bfdf2 100644 --- a/packages/domain/src/static-layout.ts +++ b/packages/domain/src/static-layout.ts @@ -18,6 +18,12 @@ export interface StorageValueCell { readonly astId: number | null; } +/** + * solc's `storageLayout.types[*].encoding`, verbatim. Carried on collapsed + * spans so consumers classify by compiler output instead of by display label. + */ +export type StorageEncoding = 'inplace' | 'mapping' | 'dynamic_array' | 'bytes'; + /** * An honest, non-expanded span: the exact compiler-reported extent of a * region whose contents v0.1 does not expand (unsupported encoding, an @@ -32,6 +38,8 @@ export interface CollapsedStorageSpan { readonly byteOffset: number; /** Exact compiler-reported byte extent of the whole region. */ readonly numberOfBytes: bigint; + /** solc-reported encoding of the collapsed type. */ + readonly encoding: StorageEncoding; readonly reason: 'unsupported-encoding' | 'unsupported-length' | 'materialization-limit'; readonly declaringContract: string | null; readonly astId: number | null; diff --git a/packages/fixtures/contracts/PackedInheritance.sol b/packages/fixtures/contracts/PackedInheritance.sol index e25b7df..bc0677d 100644 --- a/packages/fixtures/contracts/PackedInheritance.sol +++ b/packages/fixtures/contracts/PackedInheritance.sol @@ -36,4 +36,16 @@ contract PackedInheritance is BaseA, BaseB { } return uint256(baseAValue) + uint256(baseBCounter); } + + // Reference types: each reserves a slot the grid must show. Declared last so + // the lines above keep their positions; declaration order still puts these + // after derivedTail, so they take slots 5, 6 and 7. + mapping(address => uint256) internal balances; // head: slot 5, never written + uint256[] internal history; // length in slot 6, elements at keccak(6) + string internal note; // short form: length AND data in slot 7 + + constructor() { + history.push(7); + note = "slotscope"; + } } diff --git a/packages/fixtures/expected/PackedInheritance.extract.json b/packages/fixtures/expected/PackedInheritance.extract.json index 8178d76..81d5cc5 100644 --- a/packages/fixtures/expected/PackedInheritance.extract.json +++ b/packages/fixtures/expected/PackedInheritance.extract.json @@ -1,11 +1,11 @@ { - "astSha256": "sha256:c56066a5f65070f52bcdc96d1ea948ff1b1346a10d831bbed5caa125cd753184", + "astSha256": "sha256:1c96a5127bcea33429b4fb8d2c641aaa614e4d950d0b31b98ffe4702437d3b88", "contracts": { "Main.sol:BaseA": { "deployable": true, "deployedBytecode": { - "object": "60806040525f5ffdfea26469706673582212208551293014d0d32040cb4a6dd2b4d093eddca200632f7e5ea171b9e9b15e5c8164736f6c63430008240033", - "opcodes": "PUSH1 0x80 PUSH1 0x40 MSTORE PUSH0 PUSH0 REVERT INVALID LOG2 PUSH5 0x6970667358 0x22 SLT KECCAK256 DUP6 MLOAD 0x29 ADDRESS EQ 0xD0 0xD3 KECCAK256 BLOCKHASH 0xCB BLOBBASEFEE PUSH14 0xD2B4D093EDDCA200632F7E5EA171 0xB9 0xE9 0xB1 MCOPY TLOAD DUP2 PUSH5 0x736F6C6343 STOP ADDMOD 0x24 STOP CALLER ", + "object": "60806040525f5ffdfea26469706673582212200987840ce1fc7241b6f12d236343f620765e109192e67f550af156821dc5d25264736f6c63430008240033", + "opcodes": "PUSH1 0x80 PUSH1 0x40 MSTORE PUSH0 PUSH0 REVERT INVALID LOG2 PUSH5 0x6970667358 0x22 SLT KECCAK256 MULMOD DUP8 DUP5 0xC 0xE1 0xFC PUSH19 0x41B6F12D236343F620765E109192E67F550AF1 JUMP DUP3 SAR 0xC5 0xD2 MSTORE PUSH5 0x736F6C6343 STOP ADDMOD 0x24 STOP CALLER ", "sourceMap": "189:130:0:-:0;;;;;" }, "storageLayout": { @@ -44,8 +44,8 @@ "Main.sol:BaseB": { "deployable": true, "deployedBytecode": { - "object": "60806040525f5ffdfea26469706673582212207aa9b0b3003388c2ed2aa126690d76599a59340dcd24b5fb051d77b5b2515e9164736f6c63430008240033", - "opcodes": "PUSH1 0x80 PUSH1 0x40 MSTORE PUSH0 PUSH0 REVERT INVALID LOG2 PUSH5 0x6970667358 0x22 SLT KECCAK256 PUSH27 0xA9B0B3003388C2ED2AA126690D76599A59340DCD24B5FB051D77B5 0xB2 MLOAD MCOPY SWAP2 PUSH5 0x736F6C6343 STOP ADDMOD 0x24 STOP CALLER ", + "object": "60806040525f5ffdfea26469706673582212205f3e7aa685ef931845bc25aa269fda4de8c824b202d00fd540721a86a668f90c64736f6c63430008240033", + "opcodes": "PUSH1 0x80 PUSH1 0x40 MSTORE PUSH0 PUSH0 REVERT INVALID LOG2 PUSH5 0x6970667358 0x22 SLT KECCAK256 PUSH0 RETURNDATACOPY PUSH27 0xA685EF931845BC25AA269FDA4DE8C824B202D00FD540721A86A668 0xF9 0xC PUSH5 0x736F6C6343 STOP ADDMOD 0x24 STOP CALLER ", "sourceMap": "452:156:0:-:0;;;;;" }, "storageLayout": { @@ -84,9 +84,9 @@ "Main.sol:PackedInheritance": { "deployable": true, "deployedBytecode": { - "object": "608060405234801561000f575f5ffd5b5060043610610029575f3560e01c8063be4f7ab11461002d575b5f5ffd5b6100476004803603810190610042919061016d565b61005d565b60405161005491906101b0565b60405180910390f35b5f60015f0160189054906101000a900460ff16156100cb57816fffffffffffffffffffffffffffffffff1660015f015f9054906101000a90046fffffffffffffffffffffffffffffffff166fffffffffffffffffffffffffffffffff166100c491906101f6565b905061011f565b5f60189054906101000a900463ffffffff1663ffffffff165f5f9054906101000a90046fffffffffffffffffffffffffffffffff166fffffffffffffffffffffffffffffffff1661011c91906101f6565b90505b919050565b5f5ffd5b5f6fffffffffffffffffffffffffffffffff82169050919050565b61014c81610128565b8114610156575f5ffd5b50565b5f8135905061016781610143565b92915050565b5f6020828403121561018257610181610124565b5b5f61018f84828501610159565b91505092915050565b5f819050919050565b6101aa81610198565b82525050565b5f6020820190506101c35f8301846101a1565b92915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f61020082610198565b915061020b83610198565b9250828201905080821115610223576102226101c9565b5b9291505056fea2646970667358221220fdfa62f3597073b4fb1ff94ffe4d2ad2fc5f9674b3f42b5e7d6e840182d749ac64736f6c63430008240033", - "opcodes": "PUSH1 0x80 PUSH1 0x40 MSTORE CALLVALUE DUP1 ISZERO PUSH2 0xF JUMPI PUSH0 PUSH0 REVERT JUMPDEST POP PUSH1 0x4 CALLDATASIZE LT PUSH2 0x29 JUMPI PUSH0 CALLDATALOAD PUSH1 0xE0 SHR DUP1 PUSH4 0xBE4F7AB1 EQ PUSH2 0x2D JUMPI JUMPDEST PUSH0 PUSH0 REVERT JUMPDEST PUSH2 0x47 PUSH1 0x4 DUP1 CALLDATASIZE SUB DUP2 ADD SWAP1 PUSH2 0x42 SWAP2 SWAP1 PUSH2 0x16D JUMP JUMPDEST PUSH2 0x5D JUMP JUMPDEST PUSH1 0x40 MLOAD PUSH2 0x54 SWAP2 SWAP1 PUSH2 0x1B0 JUMP JUMPDEST PUSH1 0x40 MLOAD DUP1 SWAP2 SUB SWAP1 RETURN JUMPDEST PUSH0 PUSH1 0x1 PUSH0 ADD PUSH1 0x18 SWAP1 SLOAD SWAP1 PUSH2 0x100 EXP SWAP1 DIV PUSH1 0xFF AND ISZERO PUSH2 0xCB JUMPI DUP2 PUSH16 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF AND PUSH1 0x1 PUSH0 ADD PUSH0 SWAP1 SLOAD SWAP1 PUSH2 0x100 EXP SWAP1 DIV PUSH16 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF AND PUSH16 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF AND PUSH2 0xC4 SWAP2 SWAP1 PUSH2 0x1F6 JUMP JUMPDEST SWAP1 POP PUSH2 0x11F JUMP JUMPDEST PUSH0 PUSH1 0x18 SWAP1 SLOAD SWAP1 PUSH2 0x100 EXP SWAP1 DIV PUSH4 0xFFFFFFFF AND PUSH4 0xFFFFFFFF AND PUSH0 PUSH0 SWAP1 SLOAD SWAP1 PUSH2 0x100 EXP SWAP1 DIV PUSH16 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF AND PUSH16 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF AND PUSH2 0x11C SWAP2 SWAP1 PUSH2 0x1F6 JUMP JUMPDEST SWAP1 POP JUMPDEST SWAP2 SWAP1 POP JUMP JUMPDEST PUSH0 PUSH0 REVERT JUMPDEST PUSH0 PUSH16 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF DUP3 AND SWAP1 POP SWAP2 SWAP1 POP JUMP JUMPDEST PUSH2 0x14C DUP2 PUSH2 0x128 JUMP JUMPDEST DUP2 EQ PUSH2 0x156 JUMPI PUSH0 PUSH0 REVERT JUMPDEST POP JUMP JUMPDEST PUSH0 DUP2 CALLDATALOAD SWAP1 POP PUSH2 0x167 DUP2 PUSH2 0x143 JUMP JUMPDEST SWAP3 SWAP2 POP POP JUMP JUMPDEST PUSH0 PUSH1 0x20 DUP3 DUP5 SUB SLT ISZERO PUSH2 0x182 JUMPI PUSH2 0x181 PUSH2 0x124 JUMP JUMPDEST JUMPDEST PUSH0 PUSH2 0x18F DUP5 DUP3 DUP6 ADD PUSH2 0x159 JUMP JUMPDEST SWAP2 POP POP SWAP3 SWAP2 POP POP JUMP JUMPDEST PUSH0 DUP2 SWAP1 POP SWAP2 SWAP1 POP JUMP JUMPDEST PUSH2 0x1AA DUP2 PUSH2 0x198 JUMP JUMPDEST DUP3 MSTORE POP POP JUMP JUMPDEST PUSH0 PUSH1 0x20 DUP3 ADD SWAP1 POP PUSH2 0x1C3 PUSH0 DUP4 ADD DUP5 PUSH2 0x1A1 JUMP JUMPDEST SWAP3 SWAP2 POP POP JUMP JUMPDEST PUSH32 0x4E487B7100000000000000000000000000000000000000000000000000000000 PUSH0 MSTORE PUSH1 0x11 PUSH1 0x4 MSTORE PUSH1 0x24 PUSH0 REVERT JUMPDEST PUSH0 PUSH2 0x200 DUP3 PUSH2 0x198 JUMP JUMPDEST SWAP2 POP PUSH2 0x20B DUP4 PUSH2 0x198 JUMP JUMPDEST SWAP3 POP DUP3 DUP3 ADD SWAP1 POP DUP1 DUP3 GT ISZERO PUSH2 0x223 JUMPI PUSH2 0x222 PUSH2 0x1C9 JUMP JUMPDEST JUMPDEST SWAP3 SWAP2 POP POP JUMP INVALID LOG2 PUSH5 0x6970667358 0x22 SLT KECCAK256 REVERT STATICCALL PUSH3 0xF35970 PUSH20 0xB4FB1FF94FFE4D2AD2FC5F9674B3F42B5E7D6E84 ADD DUP3 0xD7 BLOBHASH 0xAC PUSH5 0x736F6C6343 STOP ADDMOD 0x24 STOP CALLER ", - "sourceMap": "758:801:0:-:0;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;1309:248;;;;;;;;;;;;;:::i;:::-;;:::i;:::-;;;;;;;:::i;:::-;;;;;;;;;1374:7;1397:8;:15;;;;;;;;;;;;1393:98;;;1470:9;1462:18;;1443:8;:15;;;;;;;;;;;;1435:24;;:45;;;;:::i;:::-;1428:52;;;;1393:98;1537:12;;;;;;;;;;;1529:21;;1515:10;;;;;;;;;;;1507:19;;:43;;;;:::i;:::-;1500:50;;1309:248;;;;:::o;88:117:1:-;197:1;194;187:12;334:118;371:7;411:34;404:5;400:46;389:57;;334:118;;;:::o;458:122::-;531:24;549:5;531:24;:::i;:::-;524:5;521:35;511:63;;570:1;567;560:12;511:63;458:122;:::o;586:139::-;632:5;670:6;657:20;648:29;;686:33;713:5;686:33;:::i;:::-;586:139;;;;:::o;731:329::-;790:6;839:2;827:9;818:7;814:23;810:32;807:119;;;845:79;;:::i;:::-;807:119;965:1;990:53;1035:7;1026:6;1015:9;1011:22;990:53;:::i;:::-;980:63;;936:117;731:329;;;;:::o;1066:77::-;1103:7;1132:5;1121:16;;1066:77;;;:::o;1149:118::-;1236:24;1254:5;1236:24;:::i;:::-;1231:3;1224:37;1149:118;;:::o;1273:222::-;1366:4;1404:2;1393:9;1389:18;1381:26;;1417:71;1485:1;1474:9;1470:17;1461:6;1417:71;:::i;:::-;1273:222;;;;:::o;1501:180::-;1549:77;1546:1;1539:88;1646:4;1643:1;1636:15;1670:4;1667:1;1660:15;1687:191;1727:3;1746:20;1764:1;1746:20;:::i;:::-;1741:25;;1780:20;1798:1;1780:20;:::i;:::-;1775:25;;1823:1;1820;1816:9;1809:16;;1844:3;1841:1;1838:10;1835:36;;;1851:18;;:::i;:::-;1835:36;1687:191;;;;:::o" + "object": "608060405234801561000f575f5ffd5b5060043610610029575f3560e01c8063be4f7ab11461002d575b5f5ffd5b6100476004803603810190610042919061016d565b61005d565b60405161005491906101b0565b60405180910390f35b5f60015f0160189054906101000a900460ff16156100cb57816fffffffffffffffffffffffffffffffff1660015f015f9054906101000a90046fffffffffffffffffffffffffffffffff166fffffffffffffffffffffffffffffffff166100c491906101f6565b905061011f565b5f60189054906101000a900463ffffffff1663ffffffff165f5f9054906101000a90046fffffffffffffffffffffffffffffffff166fffffffffffffffffffffffffffffffff1661011c91906101f6565b90505b919050565b5f5ffd5b5f6fffffffffffffffffffffffffffffffff82169050919050565b61014c81610128565b8114610156575f5ffd5b50565b5f8135905061016781610143565b92915050565b5f6020828403121561018257610181610124565b5b5f61018f84828501610159565b91505092915050565b5f819050919050565b6101aa81610198565b82525050565b5f6020820190506101c35f8301846101a1565b92915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f61020082610198565b915061020b83610198565b9250828201905080821115610223576102226101c9565b5b9291505056fea2646970667358221220caed5a545b383cf71b263c766439ff561283e31a6483cab5bb97cd7c078be9ec64736f6c63430008240033", + "opcodes": "PUSH1 0x80 PUSH1 0x40 MSTORE CALLVALUE DUP1 ISZERO PUSH2 0xF JUMPI PUSH0 PUSH0 REVERT JUMPDEST POP PUSH1 0x4 CALLDATASIZE LT PUSH2 0x29 JUMPI PUSH0 CALLDATALOAD PUSH1 0xE0 SHR DUP1 PUSH4 0xBE4F7AB1 EQ PUSH2 0x2D JUMPI JUMPDEST PUSH0 PUSH0 REVERT JUMPDEST PUSH2 0x47 PUSH1 0x4 DUP1 CALLDATASIZE SUB DUP2 ADD SWAP1 PUSH2 0x42 SWAP2 SWAP1 PUSH2 0x16D JUMP JUMPDEST PUSH2 0x5D JUMP JUMPDEST PUSH1 0x40 MLOAD PUSH2 0x54 SWAP2 SWAP1 PUSH2 0x1B0 JUMP JUMPDEST PUSH1 0x40 MLOAD DUP1 SWAP2 SUB SWAP1 RETURN JUMPDEST PUSH0 PUSH1 0x1 PUSH0 ADD PUSH1 0x18 SWAP1 SLOAD SWAP1 PUSH2 0x100 EXP SWAP1 DIV PUSH1 0xFF AND ISZERO PUSH2 0xCB JUMPI DUP2 PUSH16 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF AND PUSH1 0x1 PUSH0 ADD PUSH0 SWAP1 SLOAD SWAP1 PUSH2 0x100 EXP SWAP1 DIV PUSH16 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF AND PUSH16 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF AND PUSH2 0xC4 SWAP2 SWAP1 PUSH2 0x1F6 JUMP JUMPDEST SWAP1 POP PUSH2 0x11F JUMP JUMPDEST PUSH0 PUSH1 0x18 SWAP1 SLOAD SWAP1 PUSH2 0x100 EXP SWAP1 DIV PUSH4 0xFFFFFFFF AND PUSH4 0xFFFFFFFF AND PUSH0 PUSH0 SWAP1 SLOAD SWAP1 PUSH2 0x100 EXP SWAP1 DIV PUSH16 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF AND PUSH16 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF AND PUSH2 0x11C SWAP2 SWAP1 PUSH2 0x1F6 JUMP JUMPDEST SWAP1 POP JUMPDEST SWAP2 SWAP1 POP JUMP JUMPDEST PUSH0 PUSH0 REVERT JUMPDEST PUSH0 PUSH16 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF DUP3 AND SWAP1 POP SWAP2 SWAP1 POP JUMP JUMPDEST PUSH2 0x14C DUP2 PUSH2 0x128 JUMP JUMPDEST DUP2 EQ PUSH2 0x156 JUMPI PUSH0 PUSH0 REVERT JUMPDEST POP JUMP JUMPDEST PUSH0 DUP2 CALLDATALOAD SWAP1 POP PUSH2 0x167 DUP2 PUSH2 0x143 JUMP JUMPDEST SWAP3 SWAP2 POP POP JUMP JUMPDEST PUSH0 PUSH1 0x20 DUP3 DUP5 SUB SLT ISZERO PUSH2 0x182 JUMPI PUSH2 0x181 PUSH2 0x124 JUMP JUMPDEST JUMPDEST PUSH0 PUSH2 0x18F DUP5 DUP3 DUP6 ADD PUSH2 0x159 JUMP JUMPDEST SWAP2 POP POP SWAP3 SWAP2 POP POP JUMP JUMPDEST PUSH0 DUP2 SWAP1 POP SWAP2 SWAP1 POP JUMP JUMPDEST PUSH2 0x1AA DUP2 PUSH2 0x198 JUMP JUMPDEST DUP3 MSTORE POP POP JUMP JUMPDEST PUSH0 PUSH1 0x20 DUP3 ADD SWAP1 POP PUSH2 0x1C3 PUSH0 DUP4 ADD DUP5 PUSH2 0x1A1 JUMP JUMPDEST SWAP3 SWAP2 POP POP JUMP JUMPDEST PUSH32 0x4E487B7100000000000000000000000000000000000000000000000000000000 PUSH0 MSTORE PUSH1 0x11 PUSH1 0x4 MSTORE PUSH1 0x24 PUSH0 REVERT JUMPDEST PUSH0 PUSH2 0x200 DUP3 PUSH2 0x198 JUMP JUMPDEST SWAP2 POP PUSH2 0x20B DUP4 PUSH2 0x198 JUMP JUMPDEST SWAP3 POP DUP3 DUP3 ADD SWAP1 POP DUP1 DUP3 GT ISZERO PUSH2 0x223 JUMPI PUSH2 0x222 PUSH2 0x1C9 JUMP JUMPDEST JUMPDEST SWAP3 SWAP2 POP POP JUMP INVALID LOG2 PUSH5 0x6970667358 0x22 SLT KECCAK256 0xCA 0xED GAS SLOAD JUMPDEST CODESIZE EXTCODECOPY 0xF7 SHL 0x26 EXTCODECOPY PUSH23 0x6439FF561283E31A6483CAB5BB97CD7C078BE9EC64736F PUSH13 0x63430008240033000000000000 ", + "sourceMap": "758:1325:0:-:0;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;1309:248;;;;;;;;;;;;;:::i;:::-;;:::i;:::-;;;;;;;:::i;:::-;;;;;;;;;1374:7;1397:8;:15;;;;;;;;;;;;1393:98;;;1470:9;1462:18;;1443:8;:15;;;;;;;;;;;;1435:24;;:45;;;;:::i;:::-;1428:52;;;;1393:98;1537:12;;;;;;;;;;;1529:21;;1515:10;;;;;;;;;;;1507:19;;:43;;;;:::i;:::-;1500:50;;1309:248;;;;:::o;88:117:1:-;197:1;194;187:12;334:118;371:7;411:34;404:5;400:46;389:57;;334:118;;;:::o;458:122::-;531:24;549:5;531:24;:::i;:::-;524:5;521:35;511:63;;570:1;567;560:12;511:63;458:122;:::o;586:139::-;632:5;670:6;657:20;648:29;;686:33;713:5;686:33;:::i;:::-;586:139;;;;:::o;731:329::-;790:6;839:2;827:9;818:7;814:23;810:32;807:119;;;845:79;;:::i;:::-;807:119;965:1;990:53;1035:7;1026:6;1015:9;1011:22;990:53;:::i;:::-;980:63;;936:117;731:329;;;;:::o;1066:77::-;1103:7;1132:5;1121:16;;1066:77;;;:::o;1149:118::-;1236:24;1254:5;1236:24;:::i;:::-;1231:3;1224:37;1149:118;;:::o;1273:222::-;1366:4;1404:2;1393:9;1389:18;1381:26;;1417:71;1485:1;1474:9;1470:17;1461:6;1417:71;:::i;:::-;1273:222;;;;:::o;1501:180::-;1549:77;1546:1;1539:88;1646:4;1643:1;1636:15;1670:4;1667:1;1660:15;1687:191;1727:3;1746:20;1764:1;1746:20;:::i;:::-;1741:25;;1780:20;1798:1;1780:20;:::i;:::-;1775:25;;1823:1;1820;1816:9;1809:16;;1844:3;1841:1;1838:10;1835:36;;;1851:18;;:::i;:::-;1835:36;1687:191;;;;:::o" }, "storageLayout": { "storage": [ @@ -145,20 +145,67 @@ "offset": 0, "slot": "4", "type": "t_uint32" + }, + { + "astId": 72, + "contract": "Main.sol:PackedInheritance", + "label": "balances", + "offset": 0, + "slot": "5", + "type": "t_mapping(t_address,t_uint256)" + }, + { + "astId": 75, + "contract": "Main.sol:PackedInheritance", + "label": "history", + "offset": 0, + "slot": "6", + "type": "t_array(t_uint256)dyn_storage" + }, + { + "astId": 77, + "contract": "Main.sol:PackedInheritance", + "label": "note", + "offset": 0, + "slot": "7", + "type": "t_string_storage" } ], "types": { + "t_address": { + "encoding": "inplace", + "label": "address", + "numberOfBytes": "20" + }, "t_array(t_uint128)3_storage": { "base": "t_uint128", "encoding": "inplace", "label": "uint128[3]", "numberOfBytes": "64" }, + "t_array(t_uint256)dyn_storage": { + "base": "t_uint256", + "encoding": "dynamic_array", + "label": "uint256[]", + "numberOfBytes": "32" + }, "t_bool": { "encoding": "inplace", "label": "bool", "numberOfBytes": "1" }, + "t_mapping(t_address,t_uint256)": { + "encoding": "mapping", + "key": "t_address", + "label": "mapping(address => uint256)", + "numberOfBytes": "32", + "value": "t_uint256" + }, + "t_string_storage": { + "encoding": "bytes", + "label": "string", + "numberOfBytes": "32" + }, "t_struct(Position)25_storage": { "encoding": "inplace", "label": "struct PackedInheritance.Position", @@ -195,6 +242,11 @@ "label": "uint128", "numberOfBytes": "16" }, + "t_uint256": { + "encoding": "inplace", + "label": "uint256", + "numberOfBytes": "32" + }, "t_uint32": { "encoding": "inplace", "label": "uint32", diff --git a/packages/fixtures/manifests/PackedInheritance.json b/packages/fixtures/manifests/PackedInheritance.json index 168dcac..62272ec 100644 --- a/packages/fixtures/manifests/PackedInheritance.json +++ b/packages/fixtures/manifests/PackedInheritance.json @@ -2,15 +2,15 @@ "evmVersion": "cancun", "expectedDiagnosticsHash": "sha256:4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "expectedInstructionHash": "sha256:ac1e6d8501b75d66f39a2bd80b0ec82ac95b96882ea8e9c3b081ca1733bf7e65", - "expectedLayoutHash": "sha256:93a87d2914c277e65291508ecefb54c70ad8f39dd0781f8c701fca9f3c7a90f9", - "expectedRelationHash": "sha256:50b2d8d46bc4a5f60446e7e0587c1363cba8941c9d6f6e8bb0257bd06e38cf77", + "expectedLayoutHash": "sha256:d3d5153bab886a3376b7ba812d5e7bbb67709b1c4b80fee51d169165035e8409", + "expectedRelationHash": "sha256:d4d8460e16cd96a2c43a2c9eed59f22c996aefcebd5a700f0dea5306ce2a01cb", "expectedStatuses": [], "fixture": "PackedInheritance", "optimizer": { "enabled": false }, "solcVersion": "0.8.36+commit.8a079791.Emscripten.clang", - "sourceHash": "sha256:68db49e4e841669533e61e70a4f54d5c312824722e618cb12b606ec48bb940f0", + "sourceHash": "sha256:34b0e85b27e4d8f8c267470fb10a7abc766fa573450b783d69f72bbf2dbe8bdb", "sourcePath": "Main.sol", "viaIR": false } diff --git a/packages/layout/src/decode.test.ts b/packages/layout/src/decode.test.ts index 4af0bd8..31517d6 100644 --- a/packages/layout/src/decode.test.ts +++ b/packages/layout/src/decode.test.ts @@ -47,11 +47,22 @@ describe('public fixture ground truth (blocking cell facts)', () => { } it('materializes exactly the expected cells and slots', () => { - expect(model.items.every((item) => item.kind === 'value')).toBe(true); expect(model.materializedCellCount).toBe(11); expect(model.materializedSlotCount).toBe(5); }); + it('collapses the fixture reference types with their compiler encodings', () => { + // The fixture's mapping, dynamic array and string reserve slots 5..7. They + // are collapsed, never materialized, so the cell and slot counts above are + // unchanged by their presence. + const collapsed = model.items.filter((item) => item.kind === 'collapsed'); + expect(collapsed.map((span) => [span.path, span.startSlot, span.encoding])).toEqual([ + ['balances', 5n, 'mapping'], + ['history', 6n, 'dynamic_array'], + ['note', 7n, 'bytes'], + ]); + }); + it('packs BaseA and BaseB into slot 0 with byte-exact offsets', () => { expect(cell('baseAValue')).toMatchObject({ slot: 0n, @@ -258,11 +269,55 @@ describe('failure modes and honest collapses', () => { expect(model.items[0]).toMatchObject({ kind: 'collapsed', reason: 'unsupported-encoding', + encoding: 'mapping', startSlot: 7n, numberOfBytes: 32n, }); }); + it('reports the compiler encoding for dynamic arrays, bytes and string', () => { + const raw: RawStorageLayout = { + storage: [ + { astId: 1, contract: 'C', label: 'arr', offset: 0, slot: '0', type: 't_arr' }, + { astId: 2, contract: 'C', label: 'blob', offset: 0, slot: '1', type: 't_bytes' }, + { astId: 3, contract: 'C', label: 'name', offset: 0, slot: '2', type: 't_string' }, + ], + types: { + t_arr: { encoding: 'dynamic_array', label: 'uint256[]', numberOfBytes: '32' }, + t_bytes: { encoding: 'bytes', label: 'bytes', numberOfBytes: '32' }, + t_string: { encoding: 'bytes', label: 'string', numberOfBytes: '32' }, + }, + }; + const model = decodeStorageLayout(raw, () => null); + expect(model.items.map((item) => item.kind === 'collapsed' && item.encoding)).toEqual([ + 'dynamic_array', + 'bytes', + 'bytes', + ]); + }); + + it('marks an oversized inplace leaf as inplace, not as a reference type', () => { + const raw: RawStorageLayout = { + storage: [{ astId: 1, contract: 'C', label: 'wide', offset: 0, slot: '0', type: 't_wide' }], + types: { t_wide: { encoding: 'inplace', label: 'ufixed', numberOfBytes: '64' } }, + }; + const model = decodeStorageLayout(raw, () => null); + expect(model.items[0]).toMatchObject({ + kind: 'collapsed', + reason: 'unsupported-encoding', + encoding: 'inplace', + numberOfBytes: 64n, + }); + }); + + it('rejects an encoding solc does not emit instead of guessing', () => { + const raw: RawStorageLayout = { + storage: [{ astId: 1, contract: 'C', label: 'odd', offset: 0, slot: '0', type: 't_odd' }], + types: { t_odd: { encoding: 'quantum', label: 'odd', numberOfBytes: '32' } }, + }; + expect(() => decodeStorageLayout(raw, () => null)).toThrow(LayoutDecodeError); + }); + it('collapses an unparsable static-array length honestly', () => { const raw: RawStorageLayout = { storage: [ diff --git a/packages/layout/src/decode.ts b/packages/layout/src/decode.ts index f986fd8..f2e9a58 100644 --- a/packages/layout/src/decode.ts +++ b/packages/layout/src/decode.ts @@ -2,6 +2,7 @@ import type { RawStorageLayout, StaticLayoutItem, StaticStorageLayoutModel, + StorageEncoding, StorageValueCell, } from '@slotscope/domain'; @@ -75,6 +76,7 @@ export function decodeStorageLayout( typeLabel: string; startSlot: bigint; numberOfBytes: bigint; + encoding: StorageEncoding; declaringContract: string | null; astId: number | null; byteOffset?: number; @@ -107,7 +109,7 @@ export function decodeStorageLayout( throw new LayoutDecodeError('cycle', `${path}: cyclic type reference through "${typeId}"`); } const numberOfBytes = parseBigint(type.numberOfBytes, path); - const encoding = type.encoding ?? 'inplace'; + const encoding = normalizeEncoding(type.encoding); const typeLabel = type.label ?? typeId; if (encoding !== 'inplace') { @@ -118,6 +120,7 @@ export function decodeStorageLayout( startSlot: slot, byteOffset, numberOfBytes, + encoding, declaringContract: declaring, astId, }); @@ -157,6 +160,7 @@ export function decodeStorageLayout( startSlot: slot, byteOffset, numberOfBytes, + encoding, declaringContract: declaring, astId, }); @@ -204,6 +208,8 @@ export function decodeStorageLayout( typeLabel, startSlot, numberOfBytes, + // Only an inplace static array reaches expandStaticArray. + encoding: 'inplace', declaringContract: declaring, astId, }); @@ -225,6 +231,7 @@ export function decodeStorageLayout( typeLabel, startSlot, numberOfBytes, + encoding: 'inplace', declaringContract: declaring, astId, }); @@ -266,6 +273,8 @@ export function decodeStorageLayout( startSlot: cell.slot, byteOffset: cell.byteOffset, numberOfBytes: BigInt(cell.byteLength), + // A cell exists only for an inplace leaf. + encoding: 'inplace', declaringContract: cell.declaringContract, astId: cell.astId, }); @@ -281,6 +290,20 @@ export function decodeStorageLayout( }; } +/** + * solc emits exactly four encodings. A fifth would mean the compiler shape + * changed under us, and every placement decision downstream keys off this + * value — so fail loudly rather than silently treat it as one of the four, + * which is how this decoder already handles a missing type or a cycle. + */ +function normalizeEncoding(raw: string | undefined): StorageEncoding { + if (raw === undefined) return 'inplace'; + if (raw === 'inplace' || raw === 'mapping' || raw === 'dynamic_array' || raw === 'bytes') { + return raw; + } + throw new LayoutDecodeError('malformed', `unknown storage encoding "${raw}"`); +} + function parseSlot(slot: string, path: string): bigint { if (!/^\d+$/.test(slot)) { throw new LayoutDecodeError('malformed', `${path}: slot "${slot}" is not a decimal integer`); diff --git a/packages/semantics/src/index.ts b/packages/semantics/src/index.ts index f1706ce..8d6c444 100644 --- a/packages/semantics/src/index.ts +++ b/packages/semantics/src/index.ts @@ -13,3 +13,4 @@ export { resolveDerivedSlot, resolveReferenceBaseSlot, } from './chains.ts'; +export { decodeStringSlot, type StringForm } from './strings.ts'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b54eae5..816a57c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,6 +132,9 @@ importers: axe-core: specifier: 4.12.1 version: 4.12.1 + fast-check: + specifier: 4.9.0 + version: 4.9.0 typescript: specifier: 6.0.3 version: 6.0.3