diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl
index 4116a4c..40f4e02 100644
--- a/.beads/interactions.jsonl
+++ b/.beads/interactions.jsonl
@@ -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."}}
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..5a3edff
--- /dev/null
+++ b/apps/web/e2e/transient.spec.ts
@@ -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',
+ );
+});
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/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/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/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..932b2d3 100644
--- a/packages/semantics/src/chains.test.ts
+++ b/packages/semantics/src/chains.test.ts
@@ -164,3 +164,76 @@ 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('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(
+ { 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..d16640d 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,7 +185,17 @@ export function resolveAccesses(
options?: ResolveOptions,
): SemanticResolution[] {
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.
@@ -195,7 +211,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) ??
@@ -208,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;
});
}