Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .beads/interactions.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,7 @@
{"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."}}
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,32 @@ 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
Expand Down
112 changes: 112 additions & 0 deletions apps/web/e2e/transient.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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');
});

test('a transient write is named in the runtime write log, not left unresolved', async ({
page,
}) => {
// Regression: useExecutionSession must pass the transient layout into
// resolveAccesses, or every tload/tstore stays unresolved in the real app
// even though the semantics layer can name them.
await compile(page, TRANSIENT_SOURCE);
await page.getByRole('button', { name: 'Deploy locally' }).click();

const runtime = page.getByRole('region', { name: 'Runtime storage' });
await expect(runtime).toBeVisible({ timeout: 60_000 });
await expect(runtime).toContainText('lock');
await expect(runtime).toContainText('who');
await expect(runtime).not.toContainText('unresolved slot');
});

test('a transient write is never given a persistent write from the same slot as its previous value', async ({
page,
}) => {
// Regression: previousValueHex must be keyed by address space, not slot
// alone. `persistent = 1` then `lock = 9` write the same slot NUMBER (0) in
// different spaces; lock's write must not inherit persistent's word as its
// "previous" value — that would show as a false byte-diff instead of the
// honest "first touch" state for transient slot 0's only access this run.
await compile(page, TRANSIENT_SOURCE);
await page.getByRole('button', { name: 'Deploy locally' }).click();
await page.getByRole('tab', { name: 'Trace' }).click();
await page.getByRole('tab', { name: 'Accesses' }).click();

const observations = page.locator('[data-testid="trace-observations"]');
await expect(observations).toBeVisible({ timeout: 60_000 });
const lockRow = observations.locator('li.ss-obs', { hasText: 'lock' }).first();
await lockRow.getByText('why').click();
await expect(lockRow.locator('[data-testid="byte-diff"]')).toContainText(
'first touch in this trace',
);
});
1 change: 1 addition & 0 deletions apps/web/src/execute/useExecutionSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export function useExecutionSession({
);
return resolveAccesses(trace.observations, artifact.storageLayout, {
resolvableSteps: outerSteps,
transientLayout: artifact.transientStorageLayout,
});
}, [trace, artifact]);

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
23 changes: 21 additions & 2 deletions apps/web/src/storage/StorageGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ export interface StorageGridProps {
ownerSuffixByContract?: ReadonlyMap<string, string>;
/** What the machine has observed about these slots; null before any run. */
runtime?: StorageRuntimeView | null;
/**
* Which address space this grid shows. Transient storage numbers from 0 in
* its own space and is discarded when the transaction ends, so it needs its
* own copy — and it has no derived children, because Solidity has no
* transient reference types.
*/
space?: 'persistent' | 'transient';
}

const OWNER_CLASSES = ['owner-a', 'owner-b', 'owner-c'];
Expand Down Expand Up @@ -81,6 +88,7 @@ export function StorageGrid({
declaringContracts,
ownerSuffixByContract,
runtime = null,
space = 'persistent',
}: StorageGridProps) {
const gridRef = useRef<HTMLDivElement | null>(null);
const [focusPos, setFocusPos] = useState<[number, number]>([0, 0]);
Expand Down Expand Up @@ -128,14 +136,17 @@ export function StorageGrid({
// Children attach here, not in buildRows: the gapless invariant is about
// DECLARED slots, and a keccak-derived slot is not one of them.
const derived = useMemo(() => {
if (decoded === null) return { byParentSlot: new Map<string, DerivedChild[]>(), orphans: [] };
// Transient storage has no reference types, so it has no derived slots at
// all. Reconstructing against persistent keccaks could only invent a link.
if (decoded === null || space === 'transient')
return { byParentSlot: new Map<string, DerivedChild[]>(), orphans: [] };
const declared = new Set(decoded.rows.map((row) => row.slot.toString()));
return buildDerivedChildren(
runtime?.observations ?? null,
runtime?.resolutions ?? [],
declared,
);
}, [decoded, runtime]);
}, [decoded, runtime, space]);

// Roving focus indexes a flat list, so a child row is reachable by arrow keys
// exactly like a slot row. One entry per navigable row, holding its cell count.
Expand Down Expand Up @@ -530,6 +541,14 @@ export function StorageGrid({
)}
</div>

{space === 'transient' && (
<p className="storage-caption" data-testid="transient-note">
{runtime !== null && runtime.wordsBySlot.size > 0
? 'observed during the transaction · discarded when it ended — transient storage does not survive the call'
: 'transient storage is empty outside a transaction — run a call to see these slots hold anything'}
</p>
)}

<p data-testid="byte-direction-legend" className="storage-caption">
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
Expand Down
27 changes: 27 additions & 0 deletions apps/web/src/storage/runtimeView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,30 @@ describe('buildRuntimeView', () => {
expect(view.derivedCountByBase.has('raw')).toBe(false);
});
});

describe('address-space filter (v1.5)', () => {
it('reads only the kinds belonging to the requested space', () => {
const trace = {
observations: {
keccak: [],
storage: [
access(0, 'sstore', SLOT_1, `${'0'.repeat(63)}1`),
access(1, 'tstore', SLOT_1, `${'0'.repeat(63)}2`),
],
},
};
// Both spaces number from 0, so mixing kinds would put one space's word on
// the other space's slot.
expect(buildRuntimeView(trace, [], 9).wordsBySlot.get('1')).toBe(`${'0'.repeat(63)}1`);
expect(buildRuntimeView(trace, [], 9, 'transient').wordsBySlot.get('1')).toBe(
`${'0'.repeat(63)}2`,
);
});

it('leaves the transient view empty when only persistent slots were touched', () => {
const trace = {
observations: { keccak: [], storage: [access(0, 'sstore', SLOT_1, `${'0'.repeat(63)}1`)] },
};
expect(buildRuntimeView(trace, [], 9, 'transient').wordsBySlot.size).toBe(0);
});
});
9 changes: 8 additions & 1 deletion apps/web/src/storage/runtimeView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,21 @@ export function buildRuntimeView(
trace: { observations: TraceObservations | null } | null,
resolutions: readonly SemanticResolution[],
cursor: number,
/**
* Which address space to read. Transient storage is numbered from 0 in its
* own space, so mixing the kinds would put one space's words on the other's
* slots.
*/
space: 'persistent' | 'transient' = 'persistent',
): StorageRuntimeView {
if (trace === null && resolutions.length === 0) return EMPTY;

const wordsBySlot = new Map<string, string>();
const observations = trace?.observations ?? null;
if (observations !== null) {
const wanted = space === 'transient' ? ['tload', 'tstore'] : ['sload', 'sstore'];
for (const access of observations.storage) {
if (access.kind === 'tload' || access.kind === 'tstore') continue;
if (!wanted.includes(access.kind)) continue;
if (access.stepIndex > cursor) continue;
if (access.valueHex === null) continue;
wordsBySlot.set(BigInt(`0x${access.slotHex}`).toString(), access.valueHex);
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/testing/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function makeArtifact(overrides: Partial<ContractArtifact>): ContractArti
instructions: [],
relations: [],
storageLayout: null,
transientStorageLayout: null,
...overrides,
};
}
Expand Down
Loading