From 315981a3edd2773e0714afd169cbd67538353e26 Mon Sep 17 00:00:00 2001 From: halaprix <6533433+halaprix@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:32:44 +0200 Subject: [PATCH 1/3] feat: transient storage as its own address space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transient storage (EIP-1153) gets its own tab and its own grid. It is never merged with persistent storage, because both spaces number from 0 — in the probe that shaped this design, the persistent `persistent` is slot 0 and the transient `lock` is slot 0 — so a merged view would make slot 0 mean two things at once. Keeping them apart is the lesson, not an implementation detail. Probed against the locked solc 0.8.36 before designing anything: - transientStorageLayout exists with the same shape as storageLayout, so decodeStorageLayout and buildRows work on it verbatim; - packing is identical (two uint128 transient vars share slot 2 at 0 and 16); - transient is VALUE TYPES ONLY — mapping, struct and static array are rejected with UnimplementedFeatureError. That last point is why this phase is small: the transient grid needs none of the v1.2/v1.4 reference machinery. No reserved heads, no derived children, no elided ranges. StorageGrid is reused unchanged behind a `space` prop that only selects copy and switches derived-child reconstruction off, since reconstructing against persistent keccaks could only invent a link. Transient accesses are now named: resolveAccesses takes an optional transient layout and routes strictly by kind, so neither index is ever consulted for the other space's accesses. docs/limitations-v1.0.md is updated accordingly — that entry was true only while the persistent layout was the sole index. Honesty about lifetime is carried in permanent text, not styling. Before a run: "transient storage is empty outside a transaction". After one: "observed during the transaction, discarded when it ended". The tab itself appears only when the contract declares transient variables, so its absence is compiler truth rather than a UI accident. The locked Standard JSON snapshot moved deliberately to request the new output. Fixture goldens verify unchanged, which confirms the added selection changes what solc reports, not what it compiles. Co-Authored-By: Claude Opus 5 (1M context) --- .beads/interactions.jsonl | 2 + CHANGELOG.md | 26 ++++ apps/web/e2e/transient.spec.ts | 74 ++++++++++++ apps/web/src/probe/ProbePane.tsx | 37 +++++- apps/web/src/storage/StorageGrid.tsx | 23 +++- apps/web/src/storage/runtimeView.test.ts | 27 +++++ apps/web/src/storage/runtimeView.ts | 9 +- apps/web/src/testing/artifacts.ts | 1 + docs/design/transient-storage-v1.5.md | 127 ++++++++++++++++++++ docs/limitations-v1.0.md | 5 +- packages/compiler/src/normalize.test.ts | 52 ++++++++ packages/compiler/src/normalize.ts | 1 + packages/compiler/src/protocol.ts | 1 + packages/compiler/src/standard-json.test.ts | 1 + packages/compiler/src/standard-json.ts | 1 + packages/domain/src/models.ts | 6 + packages/executor/src/testing.ts | 17 +++ packages/semantics/src/chains.test.ts | 51 ++++++++ packages/semantics/src/resolve.ts | 18 ++- 19 files changed, 471 insertions(+), 8 deletions(-) create mode 100644 apps/web/e2e/transient.spec.ts create mode 100644 docs/design/transient-storage-v1.5.md diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 4116a4c..b63af30 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -91,3 +91,5 @@ {"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."}} diff --git a/CHANGELOG.md b/CHANGELOG.md index c527a5c..f15e961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/web/e2e/transient.spec.ts b/apps/web/e2e/transient.spec.ts new file mode 100644 index 0000000..641f559 --- /dev/null +++ b/apps/web/e2e/transient.spec.ts @@ -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'); +}); diff --git a/apps/web/src/probe/ProbePane.tsx b/apps/web/src/probe/ProbePane.tsx index 8a15187..1a52415 100644 --- a/apps/web/src/probe/ProbePane.tsx +++ b/apps/web/src/probe/ProbePane.tsx @@ -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'; /** @@ -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); @@ -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], @@ -91,6 +108,9 @@ export function ProbePane({
{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)}
{outcome !== null && ( @@ -116,6 +136,19 @@ export function ProbePane({
)} + {effectiveMode === 'transient' && transientArtifact !== null && ( +
+ +
+ )} + {effectiveMode === 'trace' && trace !== null && artifact !== null && (
{trace.encoded.truncatedStepCount > 0 && ( diff --git a/apps/web/src/storage/StorageGrid.tsx b/apps/web/src/storage/StorageGrid.tsx index d851711..a0e3663 100644 --- a/apps/web/src/storage/StorageGrid.tsx +++ b/apps/web/src/storage/StorageGrid.tsx @@ -14,6 +14,13 @@ export interface StorageGridProps { ownerSuffixByContract?: ReadonlyMap; /** 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']; @@ -81,6 +88,7 @@ export function StorageGrid({ declaringContracts, ownerSuffixByContract, runtime = null, + space = 'persistent', }: StorageGridProps) { const gridRef = useRef(null); const [focusPos, setFocusPos] = useState<[number, number]>([0, 0]); @@ -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(), 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(), 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. @@ -530,6 +541,14 @@ export function StorageGrid({ )}
+ {space === 'transient' && ( +

+ {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'} +

+ )} +

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 diff --git a/apps/web/src/storage/runtimeView.test.ts b/apps/web/src/storage/runtimeView.test.ts index 1faa78b..8d63e43 100644 --- a/apps/web/src/storage/runtimeView.test.ts +++ b/apps/web/src/storage/runtimeView.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/storage/runtimeView.ts b/apps/web/src/storage/runtimeView.ts index bf8031e..4c9987e 100644 --- a/apps/web/src/storage/runtimeView.ts +++ b/apps/web/src/storage/runtimeView.ts @@ -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(); 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); diff --git a/apps/web/src/testing/artifacts.ts b/apps/web/src/testing/artifacts.ts index 399c84d..1eb3cb8 100644 --- a/apps/web/src/testing/artifacts.ts +++ b/apps/web/src/testing/artifacts.ts @@ -18,6 +18,7 @@ export function makeArtifact(overrides: Partial): ContractArti instructions: [], relations: [], storageLayout: null, + transientStorageLayout: null, ...overrides, }; } diff --git a/docs/design/transient-storage-v1.5.md b/docs/design/transient-storage-v1.5.md new file mode 100644 index 0000000..1902f40 --- /dev/null +++ b/docs/design/transient-storage-v1.5.md @@ -0,0 +1,127 @@ +# SlotScope Transient Storage — design spec (phase v1.5) + +**Status:** approved (owner greenlight, 2026-07-30) +**Beads:** `slotscope-syp` (+ `.1`, `.2`, `.3`) +**Gates:** unchanged. Identity law from `docs/design/instrument-identity-v0.15.md` +applies verbatim. + +## What solc actually gives us + +Probed directly against the locked solc 0.8.36, because the whole design depends +on it: + +1. **`transientStorageLayout` exists** and has the same shape as `storageLayout` + (`storage[]` + `types{}`). `decodeStorageLayout` and `buildRows` therefore work + on it verbatim. It is simply not in our `outputSelection` today. +2. **Transient has its own slot space, starting at 0.** In the probe, the + persistent `persistent` is slot 0 _and_ the transient `lock` is slot 0. The two + spaces must never share a grid: the numbers collide, and keeping them apart is + itself the lesson. +3. **Packing is identical.** Two `uint128 transient` variables shared slot 2 at + offsets 0 and 16. +4. **Value types only.** `mapping`, structs and static arrays are rejected with + `UnimplementedFeatureError: Transient data location is only supported for value +types`. + +Point 4 is the reason this phase is small: the transient grid needs none of the +machinery v1.2 and v1.4 added. No reserved reference rows, no keccak-derived +children, no elided ranges — every transient row is a plain value row. If solc ever +widens the feature, the same decoder already handles it and the rows appear. + +## Thesis + +**Transient storage is a second address space, not a second copy of the first.** It +gets its own tab and its own grid, never a merged view, because slot 0 means two +different things depending on which space you are in. + +**Its values exist only inside a transaction.** Unlike persistent storage, they are +discarded when the transaction ends. So the tab never claims a "current" value: it +shows what was _observed_ during the run, says plainly that it no longer exists, and +lets the trace cursor walk through it. + +## Owner decisions + +- **The tab exists whenever the contract declares transient variables** — that is, + whenever `transientStorageLayout.storage` is non-empty. Before any run it shows + the declared slots with no values and one line explaining that transient storage + is empty outside a transaction. Its absence therefore means "this contract + declares none", which is a fact rather than a UI accident. +- **After the call ends the observed values stay on screen, labeled as discarded.** + The label carries the truth; blanking the grid would be strictly accurate and + useless for study, and would make the scrubber the only way to see anything. + +## Architecture + +### 1. `packages/compiler` — ask for the layout + +Add `transientStorageLayout` to `outputSelection` in `standard-json.ts`, and carry +it through `normalize.ts` onto the artifact: + +```ts +readonly transientStorageLayout: RawStorageLayout | null; +``` + +Same type as `storageLayout`, `null` when solc omits it. This is the only compiler +change; nothing about persistent output moves. + +### 2. `packages/semantics` — name transient accesses + +`docs/limitations-v1.0.md` records that transient accesses never resolve. That was +correct while the persistent layout was the only index available — resolving them +against it would have named the wrong variable. With the transient layout in hand +they can finally be named, and without this the tab could label nothing. + +`resolveAccesses` takes an optional second layout and routes by access kind: + +```ts +export function resolveAccesses( + observations: TraceObservations, + layout: RawStorageLayout | null, + options?: ResolveOptions & { transientLayout?: RawStorageLayout | null }, +): SemanticResolution[]; +``` + +`tload`/`tstore` resolve against the transient index, `sload`/`sstore` against the +persistent one. Neither index is ever consulted for the other kind — a transient +slot 0 must never be named with a persistent slot 0 variable. + +Keccak grounding is not extended: transient reference types do not exist, so there +are no derived transient slots to ground. A transient access either hits a declared +slot exactly or stays honestly unresolved. + +### 3. `apps/web` — the tab + +`ProbeMode` gains `'transient'` alongside `'storage'` and `'trace'`. The mode button +is present only when the artifact declares transient variables, so the control +itself is compiler truth. + +The pane reuses `StorageGrid` unchanged, passed the transient layout and a transient +runtime view. `buildRuntimeView` already folds observations into `slot → word`; it +gains a kind filter so the transient view reads `tload`/`tstore` and the persistent +view keeps reading `sload`/`sstore`. That is a one-parameter change to a pure module +that already has a test suite. + +Two pieces of copy carry the honesty, and both are permanent text rather than +styling: + +- before any run: `transient storage is empty outside a transaction`; +- after the run ends: `observed during the transaction · discarded when it ended`. + +## Testing + +| Level | Where | Covers | +| ----- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| unit | `packages/compiler` | the artifact carries `transientStorageLayout`; `null` when absent | +| unit | `packages/semantics` | `tstore` resolves against the transient layout; a transient slot 0 is never named with the persistent slot-0 variable | +| unit | `apps/web/src/storage/runtimeView.test.ts` | the kind filter selects transient vs persistent accesses | +| e2e | `apps/web/e2e/transient.spec.ts` | a contract with transient vars shows the tab before running; values appear after a call and carry the discarded label; a contract with none has no tab; axe clean | + +No fixture golden changes: transient behavior is exercised by inline e2e contracts +and by real compiled contracts in the semantics suite, exactly as the reference-type +work was. + +## Out of scope + +- Merging the two address spaces into one view, under any affordance. +- Transient reference types (Solidity does not have them). +- Any change to keccak grounding, the collapse policy, or `buildRows`. diff --git a/docs/limitations-v1.0.md b/docs/limitations-v1.0.md index bcc575d..fb46536 100644 --- a/docs/limitations-v1.0.md +++ b/docs/limitations-v1.0.md @@ -31,7 +31,10 @@ exactly this: - Cross-frame accesses (nested CALL frames) stay UNRESOLVED against the outer layout — other contracts' storage is never named with this contract's variables. -- Transient storage accesses never resolve against the persistent layout. +- Transient storage accesses resolve against the contract's own + `transientStorageLayout`, never against the persistent one: the two are + separate address spaces that both number from 0. A contract that declares no + transient variables leaves its transient accesses honestly unresolved. - 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". diff --git a/packages/compiler/src/normalize.test.ts b/packages/compiler/src/normalize.test.ts index 5cc1dc3..19cdbf9 100644 --- a/packages/compiler/src/normalize.test.ts +++ b/packages/compiler/src/normalize.test.ts @@ -259,3 +259,55 @@ describe('normalize failure modes', () => { expect(relations[5]?.mapped).toBe(true); }); }); + +describe('transient storage layout (v1.5)', () => { + it('carries solc transientStorageLayout onto the artifact, separate from storage', async () => { + const output: SolcOutput = { + contracts: { + 'Main.sol': { + T: { + storageLayout: { storage: [], types: {} }, + // Transient numbering is its OWN space: this slot 0 is not the + // persistent slot 0 and must never be merged with it. + transientStorageLayout: { + storage: [ + { + astId: 5, + contract: 'Main.sol:T', + label: 'lock', + offset: 0, + slot: '0', + type: 't_uint256', + }, + ], + types: { t_uint256: { encoding: 'inplace', label: 'uint256', numberOfBytes: '32' } }, + }, + evm: { deployedBytecode: { object: '00', opcodes: 'STOP', sourceMap: '0:1:0:-:0' } }, + }, + }, + }, + } as unknown as SolcOutput; + const result = await normalize('job-t', 'pragma solidity 0.8.36;\ncontract T {}\n', output); + const artifact = result.artifacts[0]!; + expect(artifact.transientStorageLayout?.storage[0]).toMatchObject({ + label: 'lock', + slot: '0', + }); + expect(artifact.storageLayout?.storage).toEqual([]); + }); + + it('is null when solc reports no transient layout', async () => { + const output: SolcOutput = { + contracts: { + 'Main.sol': { + P: { + storageLayout: { storage: [], types: {} }, + evm: { deployedBytecode: { object: '00', opcodes: 'STOP', sourceMap: '0:1:0:-:0' } }, + }, + }, + }, + } as unknown as SolcOutput; + const result = await normalize('job-p', 'pragma solidity 0.8.36;\ncontract P {}\n', output); + expect(result.artifacts[0]!.transientStorageLayout).toBeNull(); + }); +}); diff --git a/packages/compiler/src/normalize.ts b/packages/compiler/src/normalize.ts index 08e8569..d707275 100644 --- a/packages/compiler/src/normalize.ts +++ b/packages/compiler/src/normalize.ts @@ -193,6 +193,7 @@ export async function normalize( instructions, relations, storageLayout: (raw.storageLayout as RawStorageLayout | undefined) ?? null, + transientStorageLayout: (raw.transientStorageLayout as RawStorageLayout | undefined) ?? null, }); } diff --git a/packages/compiler/src/protocol.ts b/packages/compiler/src/protocol.ts index ed1d850..6308cd3 100644 --- a/packages/compiler/src/protocol.ts +++ b/packages/compiler/src/protocol.ts @@ -64,6 +64,7 @@ export interface SolcOutput { { abi?: unknown[]; storageLayout?: unknown; + transientStorageLayout?: unknown; evm?: { bytecode?: { object?: string }; deployedBytecode?: { object?: string; opcodes?: string; sourceMap?: string }; diff --git a/packages/compiler/src/standard-json.test.ts b/packages/compiler/src/standard-json.test.ts index dc5f66c..9276167 100644 --- a/packages/compiler/src/standard-json.test.ts +++ b/packages/compiler/src/standard-json.test.ts @@ -17,6 +17,7 @@ describe('fixed Standard JSON input', () => { '*': [ 'abi', 'storageLayout', + 'transientStorageLayout', 'evm.bytecode.object', 'evm.deployedBytecode.object', 'evm.deployedBytecode.opcodes', diff --git a/packages/compiler/src/standard-json.ts b/packages/compiler/src/standard-json.ts index c47c5e4..d8f1655 100644 --- a/packages/compiler/src/standard-json.ts +++ b/packages/compiler/src/standard-json.ts @@ -34,6 +34,7 @@ export function buildStandardJsonInput( '*': [ 'abi', 'storageLayout', + 'transientStorageLayout', 'evm.bytecode.object', 'evm.deployedBytecode.object', 'evm.deployedBytecode.opcodes', diff --git a/packages/domain/src/models.ts b/packages/domain/src/models.ts index b502b15..f1e2133 100644 --- a/packages/domain/src/models.ts +++ b/packages/domain/src/models.ts @@ -96,6 +96,12 @@ export interface ContractArtifact { readonly instructions: readonly AssemblyInstruction[]; readonly relations: readonly SourceAssemblyRelation[]; readonly storageLayout: RawStorageLayout | null; + /** + * solc `transientStorageLayout` (EIP-1153). A separate address space with + * its own slot numbering from 0 — never merge it with `storageLayout`. + * Value types only: Solidity rejects transient mappings, structs and arrays. + */ + readonly transientStorageLayout: RawStorageLayout | null; } export interface CompilationResult { diff --git a/packages/executor/src/testing.ts b/packages/executor/src/testing.ts index 410c79c..537d6cc 100644 --- a/packages/executor/src/testing.ts +++ b/packages/executor/src/testing.ts @@ -54,6 +54,21 @@ export function compileStorageLayout(sourceUtf8: string, contractName: string): ]).storageLayout; } +/** + * Transient storage layout (EIP-1153): a separate address space numbered from + * 0. Null when the contract declares no transient variables. + */ +export function compileTransientStorageLayout( + sourceUtf8: string, + contractName: string, +): RawStorageLayout | null { + return ( + compileSelection<{ transientStorageLayout?: RawStorageLayout }>(sourceUtf8, contractName, [ + 'transientStorageLayout', + ]).transientStorageLayout ?? null + ); +} + /** Minimal nonpayable AbiFunction with positional argument names. */ export function fn(name: string, types: string[] = []): AbiFunction { return { @@ -69,6 +84,7 @@ export interface DeployedContract { readonly executor: EthereumJsExecutor; readonly address: string; readonly layout: RawStorageLayout; + readonly transientLayout: RawStorageLayout | null; } /** Compile, deploy, and hand back the executor, address, and layout. */ @@ -87,6 +103,7 @@ export async function deployContract( executor, address: deployed.createdAddressHex, layout: compileStorageLayout(sourceUtf8, contractName), + transientLayout: compileTransientStorageLayout(sourceUtf8, contractName), }; } diff --git a/packages/semantics/src/chains.test.ts b/packages/semantics/src/chains.test.ts index cdc4d4c..f86eca8 100644 --- a/packages/semantics/src/chains.test.ts +++ b/packages/semantics/src/chains.test.ts @@ -164,3 +164,54 @@ describe('reference heads nested inside a struct', () => { ]); }); }); + +const TRANSIENT_SOURCE = `// SPDX-License-Identifier: MIT +pragma solidity 0.8.36; + +contract Tr { + uint256 internal persistent; // persistent slot 0 + uint256 internal transient lock; // transient slot 0 — a DIFFERENT space + address internal transient who; // transient slot 1 + + function run() external { + persistent = 1; + lock = 9; + who = msg.sender; + } +} +`; + +describe('transient storage is its own address space (v1.5)', () => { + it('names a transient write from the transient layout, never the persistent one', async () => { + const { executor, address, layout, transientLayout } = await deployContract( + TRANSIENT_SOURCE, + 'Tr', + ); + const result = await executor.call( + { toHex: address, dataHex: encodeCall(fn('run', []), []) }, + { captureTrace: true }, + ); + expect(result.success).toBe(true); + const resolutions = resolveAccesses(result.observations!, layout, { transientLayout }); + + const tstores = resolutions.filter((r) => r.kind === 'tstore'); + expect(tstores.map((r) => r.label)).toEqual(['lock', 'who']); + expect(tstores.every((r) => r.status === 'exact')).toBe(true); + + // Both spaces have a slot 0. The persistent write must keep its own name. + const sstore = resolutions.find((r) => r.kind === 'sstore')!; + expect(sstore.label).toBe('persistent'); + expect(tstores[0]!.label).not.toBe('persistent'); + }); + + it('leaves a transient access unresolved when no transient layout is supplied', async () => { + const { executor, address, layout } = await deployContract(TRANSIENT_SOURCE, 'Tr'); + const result = await executor.call( + { toHex: address, dataHex: encodeCall(fn('run', []), []) }, + { captureTrace: true }, + ); + const tstore = resolveAccesses(result.observations!, layout).find((r) => r.kind === 'tstore')!; + expect(tstore.status).toBe('unresolved'); + expect(tstore.label).toBeNull(); + }); +}); diff --git a/packages/semantics/src/resolve.ts b/packages/semantics/src/resolve.ts index 0ca3866..523aca6 100644 --- a/packages/semantics/src/resolve.ts +++ b/packages/semantics/src/resolve.ts @@ -146,6 +146,12 @@ export interface ResolveOptions { * Omit to resolve everything (single-frame traces). */ readonly resolvableSteps?: ReadonlySet; + /** + * solc `transientStorageLayout`. Transient storage is a SEPARATE address + * space numbered from 0, so it gets its own index and is never consulted for + * a persistent access, nor the persistent index for a transient one. + */ + readonly transientLayout?: RawStorageLayout | null; } /** Transient storage has its own layout, which v0.5 does not request. */ @@ -154,7 +160,7 @@ function transientUnresolved(access: StorageAccessObservation): SemanticResoluti ...unresolved(access), reasoning: [ { - fact: 'transient storage (TLOAD/TSTORE): the persistent storage layout does not apply, and the transient layout is not requested in v0.5', + fact: 'transient storage (TLOAD/TSTORE): the persistent layout does not apply, and this contract declares no transient layout to name it with', depth: 0, }, ], @@ -179,6 +185,8 @@ export function resolveAccesses( options?: ResolveOptions, ): SemanticResolution[] { const index = buildStaticIndex(layout); + const transientIndex = + options?.transientLayout != null ? buildStaticIndex(options.transientLayout) : null; const lastValueBySlot = new Map(); const resolvable = options?.resolvableSteps ?? null; // Only outer-frame keccaks may ground derivations: a child frame's hashes @@ -195,7 +203,13 @@ export function resolveAccesses( if (resolvable !== null && !resolvable.has(access.stepIndex)) { resolution = nestedFrameUnresolved(access); } else if (access.kind === 'tload' || access.kind === 'tstore') { - resolution = transientUnresolved(access); + // Transient reference types do not exist in Solidity, so a transient + // access either lands on a declared slot exactly or stays unresolved. + // No keccak grounding is attempted, and never the persistent index. + resolution = + transientIndex === null + ? transientUnresolved(access) + : (resolveDirectSlot(access, transientIndex) ?? transientUnresolved(access)); } else { resolution = resolveDirectSlot(access, index) ?? From 61f65b5ca77b2796289a92ed7141a616fd5c4dab Mon Sep 17 00:00:00 2001 From: halaprix <6533433+halaprix@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:32:53 +0200 Subject: [PATCH 2/3] chore(beads): record v1.5 closures Co-Authored-By: Claude Opus 5 (1M context) --- .beads/interactions.jsonl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index b63af30..40f4e02 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -93,3 +93,5 @@ {"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."}} From d2d6cdba2b5e769471f85f986490e25a24bff67d Mon Sep 17 00:00:00 2001 From: halaprix <6533433+halaprix@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:04:45 +0200 Subject: [PATCH 3/3] fix: wire transient resolution into the app and stop cross-space value leaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed by an independent Codex + Gemini 3.6 Flash review of this PR, then verified directly against source before fixing anything. - useExecutionSession.ts called resolveAccesses without transientLayout, so the transient naming this PR added was correct and unit-tested but unreachable from the actual app: every tload/tstore in the UI stayed unresolved. Both reviewers found this independently. Pass artifact.transientStorageLayout through. - resolve.ts's lastValueBySlot was one map keyed by slotHex alone, shared across persistent and transient accesses. Persistent and transient storage both number from 0, so a persistent write to slot 0 followed by a transient write to slot 0 — exactly what the existing test contract does (persistent = 1; lock = 9;) — handed the transient access the persistent word as its previousValueHex, producing a false byte-diff instead of the honest "first touch" state. Keyed the map by space + slot instead. Verified the new test fails without this fix (previousValueHex was the persistent word, not null) and passes with it. - packages/challenges' compileArtifact()/bankRunner() test helpers built ContractArtifact literals missing the now-required transientStorageLayout field. This package isn't in the root tsconfig's project references, so pnpm typecheck never touched it — confirmed by reading tsconfig.json directly and by running tsc --noEmit on the package in isolation, on both this branch and master. Not a currently-failing gate, but a real type error; set the field to null in both helpers. New coverage: a unit test in chains.test.ts asserting previousValueHex stays null on first touch across a space boundary, and two e2e tests confirming a transient write is actually named in the runtime write log (not "unresolved slot") and that its previous-value display shows "first touch" rather than a cross-space byte diff. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/e2e/transient.spec.ts | 38 +++++++++++++++++++++ apps/web/src/execute/useExecutionSession.ts | 1 + packages/challenges/src/content.test.ts | 1 + packages/challenges/src/engine.test.ts | 1 + packages/semantics/src/chains.test.ts | 22 ++++++++++++ packages/semantics/src/resolve.ts | 14 ++++++-- 6 files changed, 74 insertions(+), 3 deletions(-) diff --git a/apps/web/e2e/transient.spec.ts b/apps/web/e2e/transient.spec.ts index 641f559..5a3edff 100644 --- a/apps/web/e2e/transient.spec.ts +++ b/apps/web/e2e/transient.spec.ts @@ -72,3 +72,41 @@ test('the two address spaces stay separate: both have a slot 0', async ({ page } 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', + ); +}); diff --git a/apps/web/src/execute/useExecutionSession.ts b/apps/web/src/execute/useExecutionSession.ts index 2f6ac85..a390a20 100644 --- a/apps/web/src/execute/useExecutionSession.ts +++ b/apps/web/src/execute/useExecutionSession.ts @@ -117,6 +117,7 @@ export function useExecutionSession({ ); return resolveAccesses(trace.observations, artifact.storageLayout, { resolvableSteps: outerSteps, + transientLayout: artifact.transientStorageLayout, }); }, [trace, artifact]); diff --git a/packages/challenges/src/content.test.ts b/packages/challenges/src/content.test.ts index 00633ce..c0beb22 100644 --- a/packages/challenges/src/content.test.ts +++ b/packages/challenges/src/content.test.ts @@ -45,6 +45,7 @@ function compileArtifact(challenge: ChallengeDefinition): ContractArtifact { instructions: [], relations: [], storageLayout: raw.storageLayout, + transientStorageLayout: null, }; } diff --git a/packages/challenges/src/engine.test.ts b/packages/challenges/src/engine.test.ts index 8e1e210..0a6376a 100644 --- a/packages/challenges/src/engine.test.ts +++ b/packages/challenges/src/engine.test.ts @@ -61,6 +61,7 @@ async function bankRunner(): Promise<{ instructions: [], relations: [], storageLayout: layout, + transientStorageLayout: null, } as Parameters[1]; const run: RunCalls = async (calls) => { let last; diff --git a/packages/semantics/src/chains.test.ts b/packages/semantics/src/chains.test.ts index f86eca8..932b2d3 100644 --- a/packages/semantics/src/chains.test.ts +++ b/packages/semantics/src/chains.test.ts @@ -204,6 +204,28 @@ describe('transient storage is its own address space (v1.5)', () => { expect(tstores[0]!.label).not.toBe('persistent'); }); + it('never gives a transient write the persistent write from the same slot as its previous value', async () => { + // Regression: previousValueHex was tracked in one Map keyed by slotHex + // alone, shared across both spaces. persistent=1 (sstore, slot 0) runs + // before lock=9 (tstore, slot 0) — the same slot NUMBER in a DIFFERENT + // space — so a shared map would incorrectly hand lock the persistent + // word as its "previous" value instead of leaving it null (first touch). + const { executor, address, layout, transientLayout } = await deployContract( + TRANSIENT_SOURCE, + 'Tr', + ); + const result = await executor.call( + { toHex: address, dataHex: encodeCall(fn('run', []), []) }, + { captureTrace: true }, + ); + const resolutions = resolveAccesses(result.observations!, layout, { transientLayout }); + + const sstore = resolutions.find((r) => r.kind === 'sstore')!; + expect(sstore.previousValueHex).toBeNull(); + const lockWrite = resolutions.find((r) => r.kind === 'tstore' && r.label === 'lock')!; + expect(lockWrite.previousValueHex).toBeNull(); + }); + it('leaves a transient access unresolved when no transient layout is supplied', async () => { const { executor, address, layout } = await deployContract(TRANSIENT_SOURCE, 'Tr'); const result = await executor.call( diff --git a/packages/semantics/src/resolve.ts b/packages/semantics/src/resolve.ts index 523aca6..d16640d 100644 --- a/packages/semantics/src/resolve.ts +++ b/packages/semantics/src/resolve.ts @@ -187,7 +187,15 @@ export function resolveAccesses( const index = buildStaticIndex(layout); const transientIndex = options?.transientLayout != null ? buildStaticIndex(options.transientLayout) : null; + // Keyed by space + slot, never slot alone: persistent and transient storage + // are separate address spaces that both number from 0, so a bare slotHex + // key would let a persistent word leak in as a transient access's "previous + // value" (and vice versa) whenever the two spaces touch the same slot. const lastValueBySlot = new Map(); + function historyKey(access: StorageAccessObservation): string { + const space = access.kind === 'tload' || access.kind === 'tstore' ? 't' : 'p'; + return `${space}:${access.slotHex}`; + } const resolvable = options?.resolvableSteps ?? null; // Only outer-frame keccaks may ground derivations: a child frame's hashes // must never name this contract's slots. @@ -222,14 +230,14 @@ export function resolveAccesses( resolution, access, index, - lastValueBySlot.get(access.slotHex) ?? null, + lastValueBySlot.get(historyKey(access)) ?? null, ); resolution = { ...resolution, accessCostNote: accessCostNote(access.kind, access.cold), - previousValueHex: lastValueBySlot.get(access.slotHex) ?? null, + previousValueHex: lastValueBySlot.get(historyKey(access)) ?? null, }; - if (access.valueHex !== null) lastValueBySlot.set(access.slotHex, access.valueHex); + if (access.valueHex !== null) lastValueBySlot.set(historyKey(access), access.valueHex); return resolution; }); }