Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .beads/interactions.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,10 @@
{"id":"int-51cd7e3fd904dfa823a0a73a0fb41156","kind":"field_change","created_at":"2026-07-30T10:36:21.003339065Z","actor":"halaprix","issue_id":"slotscope-zvd.5","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixture gains balances/history/note at slots 5-7 plus a constructor that fills the array and string, so the default document demonstrates reserved rows and their measured values on first compile and deploy. Declared after positionValue: the editor virtualizes lines and relations.spec.ts clicks one inside that function, so keeping earlier lines in place was required (caught by the full browser suite). Goldens regenerated via the sanctioned --update; verified every existing variable kept its exact slot and offset. sourceHash, expectedLayoutHash and expectedRelationHash moved; expectedInstructionHash did not."}}
{"id":"int-eaddbbe40fad713f478dda7fcb0c3278","kind":"field_change","created_at":"2026-07-30T10:36:21.314921434Z","actor":"halaprix","issue_id":"slotscope-zvd","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"All five tasks complete and verified: format+lint+typecheck clean, 235/235 unit (incl. Forge cross-check and a 300-run fast-check gaplessness property), fixtures:verify no drift, 61/61 Playwright, build+build:check (224 KB main entry), benchmark p95 225ms action-to-paint / 657ms cold start PASS. Not committed: conservative profile, awaiting owner approval."}}
{"id":"int-d4e8aa7f76623df676964dd1a4c2a317","kind":"field_change","created_at":"2026-07-30T14:22:32.928557923Z","actor":"halaprix","issue_id":"slotscope-02a","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"buildStaticIndex now indexes nested reference heads from the decoded model's collapsed spans. Verified end to end: a write to a mapping inside a struct resolves exact as nest.inner[0xaa..] where it previously reported unresolved (test added to chains.test.ts, confirmed failing first). 236/236 unit, 61/61 e2e, fixtures no drift, release:verify passed. Committed on fix/nested-reference-heads; PR held until #4 merges, since the fix reads CollapsedStorageSpan.encoding which #4 introduces."}}
{"id":"int-50421840975e9832e8db08d0887f4aa2","kind":"field_change","created_at":"2026-07-30T14:55:20.061275583Z","actor":"halaprix","issue_id":"slotscope-xu8.1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Derived entries render as child rows under their reserved row. Parent linkage is slot arithmetic over recorded keccak preimages, not name matching, so nested heads attach with no special case. Rail shows the derivation (keccak(0xaa.., 1), nested hops shown as keccak(k2, keccak(k1, 2)), array elements as keccak(3)+n). Roving focus generalized to a flat navigable-row list. 241 unit + 10 storage e2e green, axe clean."}}
{"id":"int-19379715946fac12b29eab1bcb3b26ce","kind":"field_change","created_at":"2026-07-30T14:58:46.581879605Z","actor":"halaprix","issue_id":"slotscope-xu8.2","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"RuntimeStorage is now a chronological write log with step numbers, keeping the status pill, migration badge and constructor-origin heading. Grid is the only per-variable map. Verified: 241/241 unit, 62/62 e2e, fixtures no drift, release:verify passed, bundle 225 KB, benchmark p95 250ms/732ms PASS."}}
{"id":"int-ee68c253d1c8e02909fed8c2dddde679","kind":"field_change","created_at":"2026-07-30T14:58:46.737131043Z","actor":"halaprix","issue_id":"slotscope-xu8","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"RuntimeStorage is now a chronological write log with step numbers, keeping the status pill, migration badge and constructor-origin heading. Grid is the only per-variable map. Verified: 241/241 unit, 62/62 e2e, fixtures no drift, release:verify passed, bundle 225 KB, benchmark p95 250ms/732ms PASS."}}
{"id":"int-2b75e456da452e649a6063ab2412858d","kind":"field_change","created_at":"2026-07-30T20:20:41.794857306Z","actor":"halaprix","issue_id":"slotscope-syp.1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"outputSelection requests transientStorageLayout; artifact carries it as RawStorageLayout|null. Locked Standard JSON snapshot updated deliberately; fixtures:verify still reports no drift, proving the added selection changes what solc REPORTS, not what it compiles. 2 new tests, 241/241 unit."}}
{"id":"int-2280dbebba63bf0efe051e306400dd78","kind":"field_change","created_at":"2026-07-30T20:22:27.655504206Z","actor":"halaprix","issue_id":"slotscope-syp.2","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"resolveAccesses takes an optional transientLayout and routes by access kind; tload/tstore resolve against the transient index only, sload/sstore against the persistent index only. No keccak grounding for transient (Solidity has no transient reference types). Verified on a real contract where both spaces have a slot 0: transient writes name lock/who while the persistent write keeps 'persistent'. Limitations entry updated."}}
{"id":"int-618c71e9fa9c862aa1d615ce7f2df607","kind":"field_change","created_at":"2026-07-30T20:32:45.193240663Z","actor":"halaprix","issue_id":"slotscope-syp.3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Transient tab renders StorageGrid with the transient layout and a transient runtime view; present only when the contract declares transient vars. 247/247 unit, 65/65 e2e (3 new transient specs incl. axe and a both-spaces-have-slot-0 case), fixtures no drift, release:verify passed, benchmark p95 218ms/689ms PASS."}}
{"id":"int-072ea33cb4955c6fe3daf40dfbc72ece","kind":"field_change","created_at":"2026-07-30T20:32:45.331940596Z","actor":"halaprix","issue_id":"slotscope-syp","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Transient tab renders StorageGrid with the transient layout and a transient runtime view; present only when the contract declares transient vars. 247/247 unit, 65/65 e2e (3 new transient specs incl. axe and a both-spaces-have-slot-0 case), fixtures no drift, release:verify passed, benchmark p95 218ms/689ms PASS."}}
55 changes: 55 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,61 @@ 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.5.0] — unreleased

### Added

- **Transient storage is a first-class address space.** Contracts that declare
`transient` variables get their own tab with their own grid. It is never merged
with persistent storage, because both spaces number from 0 and slot 0 means two
different things depending on which one you are in — keeping them apart is the
lesson. The declared slots are visible before any run, since the layout is
compiler truth even when nothing has executed; after a call the observed values
stay on screen carrying the fact that transient storage was discarded when the
transaction ended.
- Transient accesses are now **named**. `tload`/`tstore` resolve against the
contract's own `transientStorageLayout`, so a transient write shows its variable
instead of a bare slot.

### Changed

- `ContractArtifact` carries `transientStorageLayout`, and the Standard JSON input
requests it. The locked-input snapshot moved deliberately; fixture goldens verify
unchanged, confirming the added selection changes what solc reports rather than
what it compiles.
- `docs/limitations-v1.0.md` no longer says transient accesses cannot resolve. They
resolve against the transient layout, and only a contract that declares none
leaves them honestly unresolved.

## [1.4.0] — unreleased

### Added

- **Keccak-derived slots are visible, written as derivations.** A mapping entry or
array element the run touched now appears as a child row under its reserved
slot, and its address is written the way the EVM computes it —
`keccak(0xaa…, 1)`, or `keccak(0xbb…, keccak(0xaa…, 2))` for a nested hop, or
`keccak(3)+2` for an array element. The raw 32-byte slot is a hover detail; the
derivation is the identity, because that is the thing worth learning.
Entries that were only read are marked read-only — a fact no surface showed
before. Parent linkage is slot arithmetic over recorded keccak preimages, never
name matching, so a head nested inside a struct attaches with no special case.

### Changed

- **`RuntimeStorage` is now a write log, not a second map.** It lists every write
in the order it happened, with step numbers. The grid above is the map; two maps
of the same state, ordered differently, only made a reader decide which to
believe.
- Roving focus in the storage grid indexes a flat list of navigable rows, so arrow
keys reach a derived child exactly like a declared slot.

### Fixed

- A mapping, dynamic array or `bytes`/`string` declared inside a struct or array
element resolved as `unresolved`: the semantics index knew only top-level heads,
so `keccak(key ‖ slot)` could not be grounded for a nested one.

## [1.2.0] — unreleased

### Added
Expand Down
8 changes: 6 additions & 2 deletions apps/web/e2e/semantics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,15 @@ test('nested mappings show the collapsed KeccakFlow and the runtime storage grou
await expect(flow.locator('[data-depth="2"]').first()).toBeVisible();
await expect(write.locator('[data-testid="keccak-flow-collapsed"]')).toContainText('collapsed');

// The runtime group lives in the probe pane's Storage mode.
// The write log lives in the probe pane's Storage mode. It is a sequence, not
// a second map: the grid above owns the per-variable view.
await page.getByRole('tab', { name: 'Storage' }).click();
const runtime = page.getByRole('region', { name: 'Runtime storage' });
await expect(runtime).toBeVisible();
await expect(runtime.locator('[data-testid="runtime-group"]')).toContainText('allowance');
await expect(runtime).toContainText('Writes in order');
const entry = runtime.locator('[data-testid="runtime-write"]').first();
await expect(entry).toContainText('allowance');
await expect(entry).toContainText('step ');
await expect(runtime).toContainText('= 55');
await expect(runtime.locator('.ss-status-pill.is-exact')).toBeVisible();
});
32 changes: 32 additions & 0 deletions apps/web/e2e/storage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,3 +203,35 @@ test('the default source shows its reserved slots, then their values on deploy',
// solc never writes a mapping head, so it stays blank.
await expect(reserved.nth(0)).not.toContainText('0x');
});

test('a mapping entry written by the constructor appears as a keccak-derived child', async ({
page,
}) => {
await page.goto('/');
await expect(page.locator('[role="status"]')).toHaveAttribute('data-status', 'ready', {
timeout: 60_000,
});
await page
.locator('.cm-content')
.fill(
'pragma solidity 0.8.36;\ncontract M {\n mapping(address => uint256) internal balances;\n constructor() {\n balances[address(0x1)] = 5;\n }\n}',
);
await page.getByRole('button', { name: 'Compile' }).click();
const grid = page.getByRole('grid', { name: 'Storage layout' });
await expect(grid).toBeVisible({ timeout: 60_000 });

// Before any run the head is reserved and has no children.
await expect(grid.locator('[data-testid="storage-derived"]')).toHaveCount(0);

await page.getByRole('button', { name: 'Deploy locally' }).click();
const derived = grid.locator('[data-testid="storage-derived"]');
await expect(derived).toHaveCount(1, { timeout: 60_000 });
// The address is the derivation, written the way the EVM computes it.
await expect(derived).toContainText('keccak(');
await expect(derived).toContainText('balances[');
await expect(derived).toContainText('5');
await expect(derived).toContainText('not the next slot');

const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
74 changes: 74 additions & 0 deletions apps/web/e2e/transient.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test, type Page } from '@playwright/test';

const TRANSIENT_SOURCE =
'pragma solidity 0.8.36;\n' +
'contract T {\n' +
' uint256 internal persistent;\n' +
' uint256 internal transient lock;\n' +
' address internal transient who;\n' +
' constructor() {\n' +
' persistent = 1;\n' +
' lock = 9;\n' +
' who = address(0x1234);\n' +
' }\n' +
'}';

async function compile(page: Page, source: string) {
await page.goto('/');
await expect(page.locator('[role="status"]')).toHaveAttribute('data-status', 'ready', {
timeout: 60_000,
});
await page.locator('.cm-content').fill(source);
await page.getByRole('button', { name: 'Compile' }).click();
await expect(page.getByRole('grid', { name: 'Storage layout' })).toBeVisible({ timeout: 60_000 });
}

test('the transient tab appears only when the contract declares transient variables', async ({
page,
}) => {
await compile(page, 'pragma solidity 0.8.36;\ncontract P { uint256 internal a; }');
await expect(page.getByRole('tab', { name: 'Transient' })).toHaveCount(0);

await compile(page, TRANSIENT_SOURCE);
await expect(page.getByRole('tab', { name: 'Transient' })).toBeVisible();
});

test('transient slots are declared before a run and measured after, then marked discarded', async ({
page,
}) => {
await compile(page, TRANSIENT_SOURCE);
await page.getByRole('tab', { name: 'Transient' }).click();

const grid = page.getByRole('grid', { name: 'Storage layout' });
// The declared layout is compiler truth even with nothing in it yet.
await expect(grid.getByText('slot 0', { exact: true })).toBeVisible();
await expect(grid.getByText('slot 1', { exact: true })).toBeVisible();
await expect(grid).toContainText('lock');
await expect(grid).toContainText('who');
await expect(page.locator('[data-testid="transient-note"]')).toContainText(
'empty outside a transaction',
);

await page.getByRole('button', { name: 'Deploy locally' }).click();
await expect(page.locator('[data-testid="transient-note"]')).toContainText('discarded', {
timeout: 60_000,
});

const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});

test('the two address spaces stay separate: both have a slot 0', async ({ page }) => {
await compile(page, TRANSIENT_SOURCE);

// Persistent slot 0 is `persistent`; transient slot 0 is `lock`. Neither
// grid may show the other's name.
const grid = page.getByRole('grid', { name: 'Storage layout' });
await expect(grid).toContainText('persistent');
await expect(grid).not.toContainText('lock');

await page.getByRole('tab', { name: 'Transient' }).click();
await expect(grid).toContainText('lock');
await expect(grid).not.toContainText('persistent');
});
61 changes: 46 additions & 15 deletions apps/web/src/app/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -1294,6 +1294,44 @@ input[readonly] {
color: var(--ss-color-fg-muted);
}

/* Derived children: keccak addresses, deliberately set apart from the declared
sequence so the indent and the leader say "not the next slot" before the
words do. */
.storage-derived {
margin-left: 1.25rem;
border-left: 1px dashed var(--ss-color-border-strong);
padding-left: 0.5rem;
}

.storage-derived-note {
margin: 0.25rem 0;
color: var(--ss-color-fg-muted);
font-size: var(--ss-font-size-micro);
letter-spacing: var(--ss-tracking-label);
text-transform: uppercase;
}

.storage-slot-label.is-derived-rail .storage-slot-number {
font-size: var(--ss-font-size-micro);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.storage-cell.is-derived {
display: flex;
align-items: center;
gap: 0.5rem;
overflow: hidden;
padding-inline: 0.5rem;
border: 1px solid var(--ss-color-border-strong);
background: var(--ss-color-chip-bg);
}

.storage-cell.is-derived.is-read-only {
border-style: dashed;
}

.storage-record-note {
color: var(--ss-color-fg-muted);
}
Expand Down Expand Up @@ -1730,21 +1768,7 @@ input[readonly] {
color: var(--ss-color-label);
}

.ss-runtime-group {
border: var(--ss-border);
border-radius: var(--ss-radius);
padding: var(--ss-space-1) var(--ss-space-2);
margin-bottom: var(--ss-space-2);
}

.ss-runtime-var {
font-family: var(--ss-font-mono);
font-size: var(--ss-font-size-micro);
color: var(--ss-color-label);
letter-spacing: var(--ss-tracking-label);
}

.ss-runtime-group ul {
.ss-runtime-log {
list-style: none;
margin: 4px 0 0;
padding: 0;
Expand All @@ -1753,6 +1777,13 @@ input[readonly] {
gap: 3px;
}

.ss-runtime-step {
font-family: var(--ss-font-mono);
font-size: var(--ss-font-size-micro);
color: var(--ss-color-fg-muted);
font-variant-numeric: tabular-nums;
}

/* Long semantic labels wrap inside the pane instead of overflowing. */
.ss-obs {
overflow: hidden;
Expand Down
37 changes: 35 additions & 2 deletions apps/web/src/probe/ProbePane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { ObservationList } from '../trace/ObservationList';
import { ForensicsView } from './ForensicsView';
import { LiveStorageStrip } from './LiveStorageStrip';

export type ProbeMode = 'storage' | 'trace';
export type ProbeMode = 'storage' | 'transient' | 'trace';
export type ProbeTab = 'accesses' | 'state' | 'events' | 'forensics';

/**
Expand Down Expand Up @@ -44,7 +44,19 @@ export function ProbePane({
ownerChipText: (contract: string) => string;
}) {
const { trace, traceOrigin, cursor, setCursor, resolutions, outcome, callCount } = session;
const effectiveMode: ProbeMode = trace === null ? 'storage' : mode;
// Transient storage is a separate address space: its own layout, its own
// runtime view, never merged with the persistent grid.
const transientLayout = artifact?.transientStorageLayout ?? null;
const hasTransient = (transientLayout?.storage.length ?? 0) > 0;
const transientArtifact = useMemo(
() =>
artifact === null || transientLayout === null
? null
: { ...artifact, storageLayout: transientLayout },
[artifact, transientLayout],
);
const effectiveMode: ProbeMode =
mode === 'transient' && hasTransient ? mode : trace === null ? 'storage' : mode;
const boundedCursor =
trace === null ? 0 : Math.min(Math.max(cursor, 0), trace.encoded.stepCount - 1);

Expand All @@ -53,6 +65,11 @@ export function ProbePane({
[trace, boundedCursor],
);

const transientRuntime = useMemo(
() => buildRuntimeView(trace, resolutions, boundedCursor, 'transient'),
[trace, resolutions, boundedCursor],
);

const storageRuntime = useMemo(
() => buildRuntimeView(trace, resolutions, boundedCursor),
[trace, resolutions, boundedCursor],
Expand Down Expand Up @@ -91,6 +108,9 @@ export function ProbePane({
<div className="ss-pane-head">
<div role="tablist" aria-label="Probe pane mode" className="ss-probe-modes">
{modeButton('storage', 'Storage')}
{/* Present only when the contract declares transient variables, so the
control itself is compiler truth rather than a UI guess. */}
{hasTransient && modeButton('transient', 'Transient')}
{modeButton('trace', 'Trace', trace === null)}
</div>
{outcome !== null && (
Expand All @@ -116,6 +136,19 @@ export function ProbePane({
</div>
)}

{effectiveMode === 'transient' && transientArtifact !== null && (
<div className="ss-probe-body">
<StorageGrid
key={`tra-${result.jobId}`}
artifact={transientArtifact}
declaringContracts={result.declaringContracts}
ownerSuffixByContract={ownerSuffixByContract}
runtime={transientRuntime}
space="transient"
/>
</div>
)}

{effectiveMode === 'trace' && trace !== null && artifact !== null && (
<div className="ss-probe-trace" data-testid="trace-pane">
{trace.encoded.truncatedStepCount > 0 && (
Expand Down
Loading