From cd2dd499c9fbe09996fe3c955d9552be39054174 Mon Sep 17 00:00:00 2001 From: Holger Drewes <931137+holgerd77@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:42:33 +0200 Subject: [PATCH 1/9] BAL exploration, first round --- package-lock.json | 63 ++++- package.json | 3 + src/explorations/REGISTRY.ts | 4 + .../eip-7928/BalExplorerPanel.vue | 69 +++++ src/explorations/eip-7928/BalJsonView.vue | 225 +++++++++++++++ src/explorations/eip-7928/MyC.vue | 149 ++++++++++ .../eip-7928/ScenarioBriefView.vue | 133 +++++++++ .../eip-7928/TriggerGroupsView.vue | 86 ++++++ src/explorations/eip-7928/examples.ts | 37 +++ src/explorations/eip-7928/info.ts | 25 ++ src/explorations/eip-7928/run.ts | 39 +++ src/explorations/eip-7928/scenarioBrief.ts | 126 +++++++++ .../eip-7928/scenarios/01-plain-transfer.ts | 50 ++++ .../eip-7928/scenarios/02-contract-sload.ts | 65 +++++ .../eip-7928/scenarios/03-sstore-write.ts | 61 ++++ .../eip-7928/scenarios/constants.ts | 23 ++ .../eip-7928/scenarios/helpers.ts | 84 ++++++ src/explorations/eip-7928/scenarios/index.ts | 35 +++ src/explorations/eip-7928/scenarios/types.ts | 61 ++++ src/explorations/eip-7928/taxonomy.ts | 171 ++++++++++++ src/explorations/eip-7928/tests.spec.ts | 140 ++++++++++ src/explorations/eip-7928/transitions.ts | 262 ++++++++++++++++++ src/views/ExplorationView.vue | 6 +- 23 files changed, 1913 insertions(+), 4 deletions(-) create mode 100644 src/explorations/eip-7928/BalExplorerPanel.vue create mode 100644 src/explorations/eip-7928/BalJsonView.vue create mode 100644 src/explorations/eip-7928/MyC.vue create mode 100644 src/explorations/eip-7928/ScenarioBriefView.vue create mode 100644 src/explorations/eip-7928/TriggerGroupsView.vue create mode 100644 src/explorations/eip-7928/examples.ts create mode 100644 src/explorations/eip-7928/info.ts create mode 100644 src/explorations/eip-7928/run.ts create mode 100644 src/explorations/eip-7928/scenarioBrief.ts create mode 100644 src/explorations/eip-7928/scenarios/01-plain-transfer.ts create mode 100644 src/explorations/eip-7928/scenarios/02-contract-sload.ts create mode 100644 src/explorations/eip-7928/scenarios/03-sstore-write.ts create mode 100644 src/explorations/eip-7928/scenarios/constants.ts create mode 100644 src/explorations/eip-7928/scenarios/helpers.ts create mode 100644 src/explorations/eip-7928/scenarios/index.ts create mode 100644 src/explorations/eip-7928/scenarios/types.ts create mode 100644 src/explorations/eip-7928/taxonomy.ts create mode 100644 src/explorations/eip-7928/tests.spec.ts create mode 100644 src/explorations/eip-7928/transitions.ts diff --git a/package-lock.json b/package-lock.json index e9d6e96..fc77fa1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,19 @@ { "name": "feelyourprotocol.org", - "version": "0.0.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "feelyourprotocol.org", - "version": "0.0.0", + "version": "0.1.1", "dependencies": { + "@ethereumjs/block": "^10.1.2", "@ethereumjs/common": "^10.1.2", "@ethereumjs/evm": "^10.1.2", + "@ethereumjs/tx": "^10.1.2", "@ethereumjs/util": "^10.1.2", + "@ethereumjs/vm": "^10.1.2", "@headlessui/vue": "^1.7.23", "@heroicons/vue": "^2.2.0", "@paulmillr/trusted-setups": "^0.2.0", @@ -1364,6 +1367,24 @@ "node": "20 || >=22" } }, + "node_modules/@ethereumjs/block": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@ethereumjs/block/-/block-10.1.2.tgz", + "integrity": "sha512-VNH6sRKsHacsOFiizeY98L6eQhMw7bRavbacUUwcFIXe6IgeKLT0y3QCcS55dJGJDGtb/TsJnas1dt411K2Vzw==", + "license": "MPL-2.0", + "dependencies": { + "@ethereumjs/common": "^10.1.2", + "@ethereumjs/mpt": "^10.1.2", + "@ethereumjs/rlp": "^10.1.2", + "@ethereumjs/tx": "^10.1.2", + "@ethereumjs/util": "^10.1.2", + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@ethereumjs/common": { "version": "10.1.2", "resolved": "https://registry.npmjs.org/@ethereumjs/common/-/common-10.1.2.tgz", @@ -1456,6 +1477,22 @@ "node": "20 || >=22" } }, + "node_modules/@ethereumjs/tx": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@ethereumjs/tx/-/tx-10.1.2.tgz", + "integrity": "sha512-bAYK3YaYkk+auzxGfZSRVDbLHvboJNTx8/tV6jaqgPVlrA1QKEEADDEp/EGz+KI4NQmTGxEtXZ8tV/WjniRNww==", + "license": "MPL-2.0", + "dependencies": { + "@ethereumjs/common": "^10.1.2", + "@ethereumjs/rlp": "^10.1.2", + "@ethereumjs/util": "^10.1.2", + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@ethereumjs/util": { "version": "10.1.2", "resolved": "https://registry.npmjs.org/@ethereumjs/util/-/util-10.1.2.tgz", @@ -1470,6 +1507,28 @@ "node": ">=20" } }, + "node_modules/@ethereumjs/vm": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@ethereumjs/vm/-/vm-10.1.2.tgz", + "integrity": "sha512-7GwDX2Sq8szLDwb1Y8zDFNBJrekdVMuS/8VYcxf1DCAPO2yp677vIoip8+Ge/J5uK3dq0eTGb8gTPvZvOdRZ/Q==", + "license": "MPL-2.0", + "dependencies": { + "@ethereumjs/block": "^10.1.2", + "@ethereumjs/common": "^10.1.2", + "@ethereumjs/evm": "^10.1.2", + "@ethereumjs/mpt": "^10.1.2", + "@ethereumjs/rlp": "^10.1.2", + "@ethereumjs/statemanager": "^10.1.2", + "@ethereumjs/tx": "^10.1.2", + "@ethereumjs/util": "^10.1.2", + "@noble/hashes": "^2.0.1", + "debug": "^4.4.0", + "eventemitter3": "^5.0.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", diff --git a/package.json b/package.json index 4a352a3..7371c98 100644 --- a/package.json +++ b/package.json @@ -33,9 +33,12 @@ "format": "prettier --write src/ cypress/e2e/ community-token/" }, "dependencies": { + "@ethereumjs/block": "^10.1.2", "@ethereumjs/common": "^10.1.2", "@ethereumjs/evm": "^10.1.2", + "@ethereumjs/tx": "^10.1.2", "@ethereumjs/util": "^10.1.2", + "@ethereumjs/vm": "^10.1.2", "@headlessui/vue": "^1.7.23", "@heroicons/vue": "^2.2.0", "@paulmillr/trusted-setups": "^0.2.0", diff --git a/src/explorations/REGISTRY.ts b/src/explorations/REGISTRY.ts index 13e4164..a7a3ca4 100644 --- a/src/explorations/REGISTRY.ts +++ b/src/explorations/REGISTRY.ts @@ -1,5 +1,6 @@ import { INFO as eip7594 } from './eip-7594/info' import { INFO as eip7883 } from './eip-7883/info' +import { INFO as eip7928 } from './eip-7928/info' import { INFO as eip7951 } from './eip-7951/info' import { INFO as eip8024 } from './eip-8024/info' import type { Tag } from './TAGS' @@ -7,6 +8,7 @@ import type { Tag } from './TAGS' export const EXPLORATIONS: Explorations = { [eip7594.id]: eip7594, [eip7883.id]: eip7883, + [eip7928.id]: eip7928, [eip7951.id]: eip7951, [eip8024.id]: eip8024, } @@ -32,6 +34,8 @@ export interface Exploration { timeline: string tags: Tag[] image?: string + /** When set, exploration content may Teleport into `#exploration-right-panel`. */ + rightPanel?: boolean introText: string usageText: string creatorName?: string diff --git a/src/explorations/eip-7928/BalExplorerPanel.vue b/src/explorations/eip-7928/BalExplorerPanel.vue new file mode 100644 index 0000000..82fa2fc --- /dev/null +++ b/src/explorations/eip-7928/BalExplorerPanel.vue @@ -0,0 +1,69 @@ + + + diff --git a/src/explorations/eip-7928/BalJsonView.vue b/src/explorations/eip-7928/BalJsonView.vue new file mode 100644 index 0000000..b7f57e3 --- /dev/null +++ b/src/explorations/eip-7928/BalJsonView.vue @@ -0,0 +1,225 @@ + + + diff --git a/src/explorations/eip-7928/MyC.vue b/src/explorations/eip-7928/MyC.vue new file mode 100644 index 0000000..5dc9751 --- /dev/null +++ b/src/explorations/eip-7928/MyC.vue @@ -0,0 +1,149 @@ + + + diff --git a/src/explorations/eip-7928/ScenarioBriefView.vue b/src/explorations/eip-7928/ScenarioBriefView.vue new file mode 100644 index 0000000..0d5bea6 --- /dev/null +++ b/src/explorations/eip-7928/ScenarioBriefView.vue @@ -0,0 +1,133 @@ + + + diff --git a/src/explorations/eip-7928/TriggerGroupsView.vue b/src/explorations/eip-7928/TriggerGroupsView.vue new file mode 100644 index 0000000..908b6d7 --- /dev/null +++ b/src/explorations/eip-7928/TriggerGroupsView.vue @@ -0,0 +1,86 @@ + + + diff --git a/src/explorations/eip-7928/examples.ts b/src/explorations/eip-7928/examples.ts new file mode 100644 index 0000000..09c47e5 --- /dev/null +++ b/src/explorations/eip-7928/examples.ts @@ -0,0 +1,37 @@ +import type { Examples } from '@/explorations/REGISTRY' + +import { SCENARIO_ORDER, SCENARIOS } from './scenarios' + +import type { BalHighlightField } from './scenarios/types' + +/** Extended metadata keyed by scenario id (values[0] in {@link examples}). */ +export interface BalExampleMeta { + title: string + lesson: string + step: number + adjustable: boolean + highlightFields: BalHighlightField[] +} + +export const exampleMeta: Record = Object.fromEntries( + SCENARIO_ORDER.map((id) => { + const scenario = SCENARIOS[id] + return [ + id, + { + title: scenario.title, + lesson: scenario.lesson, + step: scenario.step, + adjustable: scenario.adjustable, + highlightFields: scenario.highlightFields, + }, + ] + }), +) + +/** ExamplesUIC-compatible presets; values[0] is the scenario id passed to {@link runScenario}. */ +export const examples: Examples = Object.fromEntries( + SCENARIO_ORDER.map((id) => [id, { title: SCENARIOS[id].title, values: [id] }]), +) + +export const DEFAULT_SCENARIO_ID = SCENARIO_ORDER[0] diff --git a/src/explorations/eip-7928/info.ts b/src/explorations/eip-7928/info.ts new file mode 100644 index 0000000..2e50259 --- /dev/null +++ b/src/explorations/eip-7928/info.ts @@ -0,0 +1,25 @@ +import type { Exploration } from '@/explorations/REGISTRY' +import { Tag } from '@/explorations/TAGS' + +export const INFO: Exploration = { + id: 'eip-7928', + path: '/eip-7928-block-level-access-lists', + title: 'EIP-7928 Block Level Access Lists', + infoURL: 'https://eips.ethereum.org/EIPS/eip-7928', + topic: 'robustness', + timeline: 'glamsterdam', + tags: [Tag.EVM], + rightPanel: true, + introText: + 'What does the block commit to besides state root? ' + + 'EIP-7928 adds a block-level access list (BAL): a structured record of balance, nonce, code, ' + + 'and storage touches during block execution, hashed into blockAccessListHash. ' + + 'This exploration runs real Amsterdam blocks via EthereumJS and inspects the generated BAL step by step.', + usageText: + 'Pick a curriculum step, read the scenario summary, then press Run block. ' + + 'The highlighted panel shows what changed in the BAL compared to the previous step. ' + + 'Expand the full JSON for details. Work through the examples in order for the guided tour.', + creatorName: 'HolgerD77', + creatorURL: 'https://x.com/HolgerD77', + poweredBy: [{ name: 'EthereumJS', href: 'https://github.com/ethereumjs/ethereumjs-monorepo' }], +} diff --git a/src/explorations/eip-7928/run.ts b/src/explorations/eip-7928/run.ts new file mode 100644 index 0000000..9982a3d --- /dev/null +++ b/src/explorations/eip-7928/run.ts @@ -0,0 +1,39 @@ +import { Common, Hardfork, Mainnet } from '@ethereumjs/common' +import { bytesToHex } from '@ethereumjs/util' +import { createVM, runBlock } from '@ethereumjs/vm' + +import { applyPreState, buildAmsterdamBlock } from './scenarios/helpers' +import { getScenario } from './scenarios' + +import type { PreStateAccount, ScenarioRunResult } from './scenarios/types' + +export async function runScenario(scenarioId: string): Promise { + const scenario = getScenario(scenarioId) + const common = new Common({ chain: Mainnet, hardfork: Hardfork.Amsterdam }) + const vm = await createVM({ common }) + + await applyPreState(vm, scenario.preState) + + const transactions = scenario.buildTransactions(common) + const { block } = buildAmsterdamBlock(common, transactions) + + const result = await runBlock(vm, { + block, + generate: true, + skipBlockValidation: true, + }) + + const bal = result.blockLevelAccessList + if (bal === undefined) { + throw new Error('EIP-7928 active but no blockLevelAccessList on RunBlockResult') + } + + return { + scenarioId, + preState: scenario.preState, + balJson: bal.toJSON(), + balHash: bytesToHex(bal.hash()), + gasUsed: result.gasUsed, + txCount: transactions.length, + } +} diff --git a/src/explorations/eip-7928/scenarioBrief.ts b/src/explorations/eip-7928/scenarioBrief.ts new file mode 100644 index 0000000..d0f5dc0 --- /dev/null +++ b/src/explorations/eip-7928/scenarioBrief.ts @@ -0,0 +1,126 @@ +import type { BalExampleMeta } from './examples' +import { getGroupByField } from './taxonomy' +import { formatEth } from './transitions' + +import type { + BalHighlightField, + BalScenarioDefinition, + PreStateAccount, + ScenarioRunResult, +} from './scenarios/types' + +export interface ActorBrief { + label: string + shortAddress: string + lines: string[] +} + +export interface TxActionBrief { + headline: string + detail: string +} + +export interface ScenarioBriefModel { + title: string + lesson: string + actors: ActorBrief[] + action: TxActionBrief + bytecodeSteps?: Array<{ opcode: string; comment?: string }> + watchFor: string[] + blockFooter?: { + gasUsed: string + accountCount: number + hashShort: string + } +} + +function shortAddress(address: string): string { + return `${address.slice(0, 6)}…${address.slice(-4)}` +} + +function actorLines(account: PreStateAccount): string[] { + const lines: string[] = [] + if (account.balance !== undefined) { + lines.push(formatEth(account.balance)) + } + if (account.nonce !== undefined) { + lines.push(`nonce ${account.nonce}`) + } + if (account.code !== undefined) { + lines.push('contract code loaded') + } + if (account.storage !== undefined && account.storage.length > 0) { + lines.push(`${account.storage.length} storage slot(s) set`) + } + if (lines.length === 0) { + lines.push('empty account') + } + return lines +} + +function highlightToGroupName(field: BalHighlightField): string { + return getGroupByField(field).name +} + +function buildAction(scenario: BalScenarioDefinition): TxActionBrief { + const tx = scenario.txSummary[0] + if (tx === undefined) { + return { headline: 'Block execution', detail: '' } + } + + if (scenario.id === '01-plain-transfer') { + return { + headline: 'Sender pays 1 wei to the zero address', + detail: 'Standard legacy transfer · 21,000 gas', + } + } + if (scenario.id === '02-contract-sload') { + return { + headline: 'Sender calls the contract with empty calldata', + detail: 'Contract reads slot 0 and returns 42 · no storage write', + } + } + if (scenario.id === '03-sstore-write') { + return { + headline: 'Sender calls the contract to run SSTORE', + detail: 'Slot 0 written with value 42 · 200,000 gas', + } + } + + return { + headline: tx.label, + detail: tx.detail, + } +} + +export function buildScenarioBrief( + scenario: BalScenarioDefinition, + meta: BalExampleMeta, + result?: ScenarioRunResult | null, +): ScenarioBriefModel { + const brief: ScenarioBriefModel = { + title: meta.title, + lesson: meta.lesson, + actors: scenario.preState.map((account) => ({ + label: account.label.charAt(0).toUpperCase() + account.label.slice(1), + shortAddress: shortAddress(account.address), + lines: actorLines(account), + })), + action: buildAction(scenario), + watchFor: meta.highlightFields.map(highlightToGroupName), + } + + if (scenario.bytecodeSteps !== undefined) { + brief.bytecodeSteps = scenario.bytecodeSteps + } + + if (result !== undefined && result !== null) { + brief.blockFooter = { + gasUsed: result.gasUsed.toLocaleString(), + accountCount: result.balJson.length, + hashShort: `${result.balHash.slice(0, 10)}…${result.balHash.slice(-6)}`, + } + } + + return brief +} diff --git a/src/explorations/eip-7928/scenarios/01-plain-transfer.ts b/src/explorations/eip-7928/scenarios/01-plain-transfer.ts new file mode 100644 index 0000000..df9bbc5 --- /dev/null +++ b/src/explorations/eip-7928/scenarios/01-plain-transfer.ts @@ -0,0 +1,50 @@ +import { createLegacyTx } from '@ethereumjs/tx' +import { createZeroAddress } from '@ethereumjs/util' + +import { + DEFAULT_GAS_PRICE, + DEFAULT_SENDER_BALANCE, + RECIPIENT_ADDRESS, + SENDER_PRIVATE_KEY, + SENDER_ADDRESS, +} from './constants' + +import type { BalScenarioDefinition } from './types' + +export const plainTransferScenario: BalScenarioDefinition = { + id: '01-plain-transfer', + title: '1. Plain ETH transfer', + lesson: + 'A simple value transfer touches sender and recipient balances and bumps the sender nonce. ' + + 'No contract code or storage is involved.', + step: 1, + adjustable: false, + highlightFields: ['balanceChanges', 'nonceChanges'], + preState: [ + { + label: 'sender', + address: SENDER_ADDRESS, + balance: DEFAULT_SENDER_BALANCE, + nonce: 0n, + }, + ], + txSummary: [ + { + label: 'tx 1', + detail: `legacy transfer: 1 wei → ${RECIPIENT_ADDRESS}, gasLimit 21000`, + }, + ], + buildTransactions(common) { + return [ + createLegacyTx( + { + gasLimit: 21000n, + gasPrice: DEFAULT_GAS_PRICE, + value: 1n, + to: createZeroAddress(), + }, + { common }, + ).sign(SENDER_PRIVATE_KEY), + ] + }, +} diff --git a/src/explorations/eip-7928/scenarios/02-contract-sload.ts b/src/explorations/eip-7928/scenarios/02-contract-sload.ts new file mode 100644 index 0000000..3914f7b --- /dev/null +++ b/src/explorations/eip-7928/scenarios/02-contract-sload.ts @@ -0,0 +1,65 @@ +import { createLegacyTx } from '@ethereumjs/tx' + +import { + CONTRACT_ADDRESS, + DEFAULT_GAS_PRICE, + DEFAULT_SENDER_BALANCE, + RETRIEVE_BYTECODE, + SENDER_PRIVATE_KEY, + SENDER_ADDRESS, + SLOT_0, + VALUE_42, + contractAddress, +} from './constants' + +import type { BalScenarioDefinition } from './types' + +export const contractSloadScenario: BalScenarioDefinition = { + id: '02-contract-sload', + title: '2. Contract read (SLOAD)', + lesson: + 'Calling a pre-deployed contract that only reads storage adds the contract to the BAL with ' + + 'storageReads — not storageChanges.', + step: 2, + adjustable: false, + highlightFields: ['storageReads'], + preState: [ + { + label: 'sender', + address: SENDER_ADDRESS, + balance: DEFAULT_SENDER_BALANCE, + nonce: 0n, + }, + { + label: 'contract', + address: CONTRACT_ADDRESS, + code: RETRIEVE_BYTECODE, + storage: [[SLOT_0, VALUE_42]], + }, + ], + txSummary: [ + { + label: 'tx 1', + detail: `legacy call → ${CONTRACT_ADDRESS}, empty calldata, gasLimit 100000`, + }, + ], + bytecodeSteps: [ + { opcode: 'PUSH1 0x00', comment: 'storage slot 0' }, + { opcode: 'SLOAD', comment: 'read slot → stack' }, + { opcode: 'PUSH1 0x00', comment: 'memory offset' }, + { opcode: 'MSTORE', comment: 'write 32-byte word to memory' }, + { opcode: 'PUSH1 0x20 / PUSH1 0x00 / RETURN', comment: 'return memory[0:32]' }, + ], + buildTransactions(common) { + return [ + createLegacyTx( + { + to: contractAddress, + gasLimit: 100_000n, + gasPrice: DEFAULT_GAS_PRICE, + }, + { common }, + ).sign(SENDER_PRIVATE_KEY), + ] + }, +} diff --git a/src/explorations/eip-7928/scenarios/03-sstore-write.ts b/src/explorations/eip-7928/scenarios/03-sstore-write.ts new file mode 100644 index 0000000..73eb61b --- /dev/null +++ b/src/explorations/eip-7928/scenarios/03-sstore-write.ts @@ -0,0 +1,61 @@ +import { createLegacyTx } from '@ethereumjs/tx' + +import { + CONTRACT_ADDRESS, + DEFAULT_GAS_PRICE, + DEFAULT_SENDER_BALANCE, + SENDER_PRIVATE_KEY, + SENDER_ADDRESS, + SSTORE_42_BYTECODE, + contractAddress, +} from './constants' + +import type { BalScenarioDefinition } from './types' + +export const sstoreWriteScenario: BalScenarioDefinition = { + id: '03-sstore-write', + title: '3. Storage write (SSTORE)', + lesson: + 'When the contract writes slot 0, the BAL records storageChanges instead of storageReads. ' + + 'A successful write subsumes any read of the same slot in that transaction.', + step: 3, + adjustable: false, + highlightFields: ['storageChanges'], + preState: [ + { + label: 'sender', + address: SENDER_ADDRESS, + balance: DEFAULT_SENDER_BALANCE, + nonce: 0n, + }, + { + label: 'contract', + address: CONTRACT_ADDRESS, + code: SSTORE_42_BYTECODE, + }, + ], + txSummary: [ + { + label: 'tx 1', + detail: `legacy call → ${CONTRACT_ADDRESS}, executes SSTORE(0, 42), gasLimit 200000`, + }, + ], + bytecodeSteps: [ + { opcode: 'PUSH1 0x2a', comment: 'value 42' }, + { opcode: 'PUSH1 0x00', comment: 'storage slot 0' }, + { opcode: 'SSTORE', comment: 'persist to storage' }, + { opcode: 'STOP' }, + ], + buildTransactions(common) { + return [ + createLegacyTx( + { + to: contractAddress, + gasLimit: 200_000n, + gasPrice: DEFAULT_GAS_PRICE, + }, + { common }, + ).sign(SENDER_PRIVATE_KEY), + ] + }, +} diff --git a/src/explorations/eip-7928/scenarios/constants.ts b/src/explorations/eip-7928/scenarios/constants.ts new file mode 100644 index 0000000..f072bd9 --- /dev/null +++ b/src/explorations/eip-7928/scenarios/constants.ts @@ -0,0 +1,23 @@ +import { createAddressFromPrivateKey, createAddressFromString, hexToBytes } from '@ethereumjs/util' + +import type { PrefixedHexString } from '@ethereumjs/util' + +export const SENDER_PRIVATE_KEY = hexToBytes(`0x${'20'.repeat(32)}`) +export const SENDER_ADDRESS = createAddressFromPrivateKey(SENDER_PRIVATE_KEY).toString() +export const CONTRACT_ADDRESS = '0x00000000000000000000000000000000000000c0' as PrefixedHexString +export const RECIPIENT_ADDRESS = '0x0000000000000000000000000000000000000000' as PrefixedHexString + +export const SLOT_0 = `0x${'00'.repeat(32)}` as PrefixedHexString +export const VALUE_42 = `0x${'00'.repeat(31)}2a` as PrefixedHexString + +/** SLOAD slot 0 → MSTORE → RETURN (see runTxWithContractState.ts). */ +export const RETRIEVE_BYTECODE = `0x60005460005260206000f3` as PrefixedHexString + +/** PUSH1 42; PUSH1 0; SSTORE; STOP */ +export const SSTORE_42_BYTECODE = `0x602a60005500` as PrefixedHexString + +export const DEFAULT_SENDER_BALANCE = BigInt(1e18) +export const DEFAULT_GAS_PRICE = 10n +export const DEFAULT_BLOCK_GAS_LIMIT = 30_000_000n + +export const contractAddress = createAddressFromString(CONTRACT_ADDRESS) diff --git a/src/explorations/eip-7928/scenarios/helpers.ts b/src/explorations/eip-7928/scenarios/helpers.ts new file mode 100644 index 0000000..1dbc489 --- /dev/null +++ b/src/explorations/eip-7928/scenarios/helpers.ts @@ -0,0 +1,84 @@ +import { createBlock } from '@ethereumjs/block' +import type { Block } from '@ethereumjs/block' +import type { Common } from '@ethereumjs/common' +import type { TypedTransaction } from '@ethereumjs/tx' +import { Account, createAccount, createAddressFromString, hexToBytes } from '@ethereumjs/util' +import type { VM } from '@ethereumjs/vm' + +import { DEFAULT_BLOCK_GAS_LIMIT } from './constants' +import type { BalScenarioDefinition, PreStateAccount } from './types' + +export async function applyPreState(vm: VM, accounts: PreStateAccount[]): Promise { + for (const account of accounts) { + const address = createAddressFromString(account.address) + const acct = + account.balance !== undefined || account.nonce !== undefined + ? createAccount({ + nonce: account.nonce ?? 0n, + balance: account.balance ?? 0n, + }) + : new Account() + await vm.stateManager.putAccount(address, acct) + + if (account.code !== undefined) { + await vm.stateManager.putCode(address, hexToBytes(account.code)) + } + + if (account.storage !== undefined) { + for (const [slot, value] of account.storage) { + await vm.stateManager.putStorage(address, hexToBytes(slot), hexToBytes(value)) + } + } + } +} + +export function buildAmsterdamBlock( + common: Common, + transactions: TypedTransaction[], +): { block: Block; parentBlock: Block } { + const parentBlock = createBlock( + { header: { number: 1n } }, + { common, skipConsensusFormatValidation: true }, + ) + + const block = createBlock( + { + header: { number: 2n, gasLimit: DEFAULT_BLOCK_GAS_LIMIT, baseFeePerGas: 1n }, + transactions, + }, + { + common, + skipConsensusFormatValidation: true, + calcDifficultyFromHeader: parentBlock.header, + }, + ) + + return { block, parentBlock } +} + +export function formatPreStateChips(accounts: PreStateAccount[]): string[] { + return accounts.map((account) => { + const parts: string[] = [account.label] + if (account.balance !== undefined) { + parts.push(`${Number(account.balance) / 1e18} ETH`) + } + if (account.code !== undefined) { + parts.push('code') + } + if (account.storage !== undefined && account.storage.length > 0) { + parts.push(`${account.storage.length} storage slot(s)`) + } + return parts.join(' · ') + }) +} + +export function getScenarioById( + scenarios: Record, + id: string, +): BalScenarioDefinition { + const scenario = scenarios[id] + if (scenario === undefined) { + throw new Error(`Unknown BAL scenario: ${id}`) + } + return scenario +} diff --git a/src/explorations/eip-7928/scenarios/index.ts b/src/explorations/eip-7928/scenarios/index.ts new file mode 100644 index 0000000..9c928fd --- /dev/null +++ b/src/explorations/eip-7928/scenarios/index.ts @@ -0,0 +1,35 @@ +import { plainTransferScenario } from './01-plain-transfer' +import { contractSloadScenario } from './02-contract-sload' +import { sstoreWriteScenario } from './03-sstore-write' + +import type { BalScenarioDefinition } from './types' + +export const SCENARIOS: Record = { + [plainTransferScenario.id]: plainTransferScenario, + [contractSloadScenario.id]: contractSloadScenario, + [sstoreWriteScenario.id]: sstoreWriteScenario, +} + +/** Curriculum order for prev/next navigation in the UI. */ +export const SCENARIO_ORDER = [ + plainTransferScenario.id, + contractSloadScenario.id, + sstoreWriteScenario.id, +] as const + +export type ScenarioId = (typeof SCENARIO_ORDER)[number] + +export function getScenario(id: string): BalScenarioDefinition { + const scenario = SCENARIOS[id] + if (scenario === undefined) { + throw new Error(`Unknown BAL scenario: ${id}`) + } + return scenario +} + +export function getAdjacentScenarioId(id: string, direction: -1 | 1): string | undefined { + const index = SCENARIO_ORDER.indexOf(id as ScenarioId) + if (index === -1) return undefined + const next = SCENARIO_ORDER[index + direction] + return next +} diff --git a/src/explorations/eip-7928/scenarios/types.ts b/src/explorations/eip-7928/scenarios/types.ts new file mode 100644 index 0000000..5ecefc7 --- /dev/null +++ b/src/explorations/eip-7928/scenarios/types.ts @@ -0,0 +1,61 @@ +import type { Common } from '@ethereumjs/common' +import type { TypedTransaction } from '@ethereumjs/tx' +import type { BALJSONBlockAccessList } from '@ethereumjs/util' +import type { PrefixedHexString } from '@ethereumjs/util' + +/** BAL account fields the UI can spotlight per curriculum step. */ +export type BalHighlightField = + | 'balanceChanges' + | 'nonceChanges' + | 'codeChanges' + | 'storageChanges' + | 'storageReads' + +/** Account pre-state applied via the state manager before block execution. */ +export interface PreStateAccount { + /** Short label for UI chips (e.g. "sender", "contract"). */ + label: string + address: PrefixedHexString + balance?: bigint + nonce?: bigint + code?: PrefixedHexString + storage?: Array<[slot: PrefixedHexString, value: PrefixedHexString]> +} + +/** Human-readable tx line for the condensed scenario panel. */ +export interface TxSummaryLine { + label: string + detail: string +} + +/** Annotated opcode line for optional bytecode strip in the UI. */ +export interface BytecodeStep { + opcode: string + comment?: string +} + +/** + * One curriculum scenario: pre-state, txs, and metadata for the exploration UI. + * Execution always goes through {@link runScenario} → `runBlock()` on Amsterdam. + */ +export interface BalScenarioDefinition { + id: string + title: string + lesson: string + step: number + adjustable: boolean + highlightFields: BalHighlightField[] + preState: PreStateAccount[] + txSummary: TxSummaryLine[] + bytecodeSteps?: BytecodeStep[] + buildTransactions: (common: Common) => TypedTransaction[] +} + +export interface ScenarioRunResult { + scenarioId: string + preState: PreStateAccount[] + balJson: BALJSONBlockAccessList + balHash: PrefixedHexString + gasUsed: bigint + txCount: number +} diff --git a/src/explorations/eip-7928/taxonomy.ts b/src/explorations/eip-7928/taxonomy.ts new file mode 100644 index 0000000..6ab3165 --- /dev/null +++ b/src/explorations/eip-7928/taxonomy.ts @@ -0,0 +1,171 @@ +import type { Component } from 'vue' +import { + BanknotesIcon, + CubeIcon, + HashtagIcon, + MagnifyingGlassIcon, + PencilSquareIcon, +} from '@heroicons/vue/24/outline' + +/** BAL JSON field each trigger group maps from (internal only — never shown in UI). */ +export type BalSourceField = + | 'balanceChanges' + | 'nonceChanges' + | 'codeChanges' + | 'storageChanges' + | 'storageReads' + +export type TriggerGroupId = + | 'valueFlow' + | 'counterTicks' + | 'contractBirths' + | 'stateImprints' + | 'statePeeks' + +/** Literal Tailwind classes (scanner-visible) for one trigger group. */ +export interface TriggerGroupClasses { + bgItem: string + bg: string + border: string + borderCard: string + text: string + accent: string + ring: string + jsonTint: string + jsonActive: string +} + +export interface TriggerGroupDefinition { + id: TriggerGroupId + name: string + triggerLabel: string + sourceField: BalSourceField + icon: Component + classes: TriggerGroupClasses +} + +export const TRIGGER_GROUPS: TriggerGroupDefinition[] = [ + { + id: 'valueFlow', + name: 'Value Flow', + triggerLabel: 'value transfer', + sourceField: 'balanceChanges', + icon: BanknotesIcon, + classes: { + bgItem: 'bg-green-50', + bg: 'bg-green-100', + border: 'border-green-200', + borderCard: 'border-green-400', + text: 'text-green-800', + accent: 'text-green-600', + ring: 'ring-green-400', + jsonTint: 'bg-green-50/80 border-green-200', + jsonActive: 'bg-green-100 ring-2 ring-green-400 border-green-400', + }, + }, + { + id: 'counterTicks', + name: 'Counter Ticks', + triggerLabel: 'nonce bump', + sourceField: 'nonceChanges', + icon: HashtagIcon, + classes: { + bgItem: 'bg-blue-50', + bg: 'bg-blue-100', + border: 'border-blue-200', + borderCard: 'border-blue-300', + text: 'text-blue-800', + accent: 'text-blue-600', + ring: 'ring-blue-400', + jsonTint: 'bg-blue-50/80 border-blue-200', + jsonActive: 'bg-blue-100 ring-2 ring-blue-400 border-blue-400', + }, + }, + { + id: 'contractBirths', + name: 'Contract Births', + triggerLabel: 'CREATE / deploy', + sourceField: 'codeChanges', + icon: CubeIcon, + classes: { + bgItem: 'bg-purple-50', + bg: 'bg-purple-100', + border: 'border-purple-200', + borderCard: 'border-purple-400', + text: 'text-purple-800', + accent: 'text-purple-600', + ring: 'ring-purple-400', + jsonTint: 'bg-purple-50/80 border-purple-200', + jsonActive: 'bg-purple-100 ring-2 ring-purple-400 border-purple-400', + }, + }, + { + id: 'stateImprints', + name: 'State Imprints', + triggerLabel: 'SSTORE Opcode', + sourceField: 'storageChanges', + icon: PencilSquareIcon, + classes: { + bgItem: 'bg-orange-50', + bg: 'bg-orange-100', + border: 'border-orange-200', + borderCard: 'border-orange-400', + text: 'text-orange-800', + accent: 'text-orange-600', + ring: 'ring-orange-400', + jsonTint: 'bg-orange-50/80 border-orange-200', + jsonActive: 'bg-orange-100 ring-2 ring-orange-400 border-orange-400', + }, + }, + { + id: 'statePeeks', + name: 'State Peeks', + triggerLabel: 'SLOAD Opcode', + sourceField: 'storageReads', + icon: MagnifyingGlassIcon, + classes: { + bgItem: 'bg-yellow-50', + bg: 'bg-yellow-100', + border: 'border-yellow-200', + borderCard: 'border-yellow-500', + text: 'text-yellow-800', + accent: 'text-yellow-600', + ring: 'ring-yellow-400', + jsonTint: 'bg-yellow-50/80 border-yellow-200', + jsonActive: 'bg-yellow-100 ring-2 ring-yellow-400 border-yellow-500', + }, + }, +] + +const GROUP_BY_FIELD = new Map( + TRIGGER_GROUPS.map((g) => [g.sourceField, g]), +) + +const GROUP_BY_ID = new Map( + TRIGGER_GROUPS.map((g) => [g.id, g]), +) + +export function getGroupByField(field: BalSourceField): TriggerGroupDefinition { + const group = GROUP_BY_FIELD.get(field) + if (group === undefined) { + throw new Error(`No trigger group for field: ${field}`) + } + return group +} + +export function getGroupById(id: TriggerGroupId): TriggerGroupDefinition { + const group = GROUP_BY_ID.get(id) + if (group === undefined) { + throw new Error(`No trigger group: ${id}`) + } + return group +} + +/** Stable path prefix for cross-highlight between cards and JSON view. */ +export function balPathFor( + address: string, + field: BalSourceField, + suffix: string, +): string { + return `${address.toLowerCase()}/${field}/${suffix}` +} diff --git a/src/explorations/eip-7928/tests.spec.ts b/src/explorations/eip-7928/tests.spec.ts new file mode 100644 index 0000000..915947f --- /dev/null +++ b/src/explorations/eip-7928/tests.spec.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest' + +import { DEFAULT_SCENARIO_ID, exampleMeta, examples } from './examples' +import { INFO } from './info' +import { runScenario } from './run' +import { SCENARIO_ORDER, SCENARIOS } from './scenarios' +import { TRIGGER_GROUPS, getGroupByField } from './taxonomy' +import { + buildTriggerGroups, + formatEth, + formatIndexBadge, + formatSlotValue, +} from './transitions' + +describe('EIP-7928 BAL Exploration', () => { + describe('info', () => { + it('has correct metadata', () => { + expect(INFO.id).toBe('eip-7928') + expect(INFO.path).toContain('eip-7928') + expect(INFO.topic).toBe('robustness') + expect(INFO.timeline).toBe('glamsterdam') + expect(INFO.poweredBy.length).toBeGreaterThan(0) + }) + }) + + describe('taxonomy', () => { + it('defines five trigger groups with literal color classes', () => { + expect(TRIGGER_GROUPS).toHaveLength(5) + for (const group of TRIGGER_GROUPS) { + expect(group.name.length).toBeGreaterThan(0) + expect(group.triggerLabel.length).toBeGreaterThan(0) + expect(group.classes.bg).toMatch(/^bg-\w+-\d+$/) + expect(group.classes.jsonTint).toContain('border') + } + }) + + it('maps each BAL source field to a group', () => { + expect(getGroupByField('balanceChanges').id).toBe('valueFlow') + expect(getGroupByField('storageReads').id).toBe('statePeeks') + }) + }) + + describe('examples', () => { + it('lists curriculum scenarios in order', () => { + expect(Object.keys(examples)).toEqual([...SCENARIO_ORDER]) + expect(DEFAULT_SCENARIO_ID).toBe('01-plain-transfer') + }) + + it('maps each example value to a scenario id', () => { + for (const [key, ex] of Object.entries(examples)) { + expect(ex.values).toEqual([key]) + expect(SCENARIOS[key]).toBeDefined() + expect(exampleMeta[key]?.lesson.length).toBeGreaterThan(0) + } + }) + }) + + describe('transitions', () => { + it('formats ETH and index badges readably', () => { + expect(formatEth(1_000_000_000_000_000_000n)).toContain('ETH') + expect(formatIndexBadge('0x01')).toBe('tx 1') + expect(formatIndexBadge('0x00')).toBe('system') + expect(formatSlotValue('0x2a')).toBe('42') + }) + }) + + describe('runScenario', () => { + it('runs plain transfer with balance and nonce BAL entries', async () => { + const result = await runScenario('01-plain-transfer') + expect(result.balHash).toMatch(/^0x[0-9a-f]+$/i) + expect(result.preState.length).toBeGreaterThan(0) + expect(result.txCount).toBe(1) + expect(result.balJson.length).toBeGreaterThan(0) + + const hasBalance = result.balJson.some((a) => a.balanceChanges.length > 0) + const hasNonce = result.balJson.some((a) => a.nonceChanges.length > 0) + expect(hasBalance).toBe(true) + expect(hasNonce).toBe(true) + }) + + it('records storageReads on contract SLOAD', async () => { + const result = await runScenario('02-contract-sload') + const contract = result.balJson.find( + (a) => + a.address.toLowerCase() === + SCENARIOS['02-contract-sload'].preState[1]!.address.toLowerCase(), + ) + expect(contract).toBeDefined() + expect(contract!.storageReads.length).toBeGreaterThan(0) + expect(contract!.storageChanges.length).toBe(0) + }) + + it('records storageChanges on SSTORE', async () => { + const result = await runScenario('03-sstore-write') + const contract = result.balJson.find( + (a) => + a.address.toLowerCase() === + SCENARIOS['03-sstore-write'].preState[1]!.address.toLowerCase(), + ) + expect(contract).toBeDefined() + expect(contract!.storageChanges.length).toBeGreaterThan(0) + }) + }) + + describe('buildTriggerGroups', () => { + it('produces human-readable value flow transitions', async () => { + const result = await runScenario('01-plain-transfer') + const groups = buildTriggerGroups(result.balJson, result.preState) + const valueFlow = groups.find((g) => g.group.id === 'valueFlow') + expect(valueFlow).toBeDefined() + expect(valueFlow!.items.length).toBeGreaterThan(0) + expect(valueFlow!.items[0]!.summary).toMatch(/→/) + expect(valueFlow!.items[0]!.summary).toContain('ETH') + expect(valueFlow!.items[0]!.balPath).toContain('balanceChanges') + }) + + it('includes state peeks for SLOAD scenario', async () => { + const result = await runScenario('02-contract-sload') + const groups = buildTriggerGroups(result.balJson, result.preState) + const peeks = groups.find((g) => g.group.id === 'statePeeks') + expect(peeks!.items.length).toBeGreaterThan(0) + expect(peeks!.items[0]!.summary).toMatch(/read slot/) + }) + + it('includes state imprints for SSTORE scenario', async () => { + const result = await runScenario('03-sstore-write') + const groups = buildTriggerGroups(result.balJson, result.preState) + const imprints = groups.find((g) => g.group.id === 'stateImprints') + expect(imprints!.items.length).toBeGreaterThan(0) + expect(imprints!.items[0]!.summary).toMatch(/→/) + }) + + it('assigns stable balPath keys for cross-highlight', async () => { + const result = await runScenario('01-plain-transfer') + const groups = buildTriggerGroups(result.balJson, result.preState) + const paths = groups.flatMap((g) => g.items.map((i) => i.balPath)) + expect(new Set(paths).size).toBe(paths.length) + }) + }) +}) diff --git a/src/explorations/eip-7928/transitions.ts b/src/explorations/eip-7928/transitions.ts new file mode 100644 index 0000000..ccca93f --- /dev/null +++ b/src/explorations/eip-7928/transitions.ts @@ -0,0 +1,262 @@ +import type { BALJSONBlockAccessList } from '@ethereumjs/util' + +import { + balPathFor, + getGroupByField, + TRIGGER_GROUPS, + type TriggerGroupDefinition, +} from './taxonomy' + +import type { PreStateAccount } from './scenarios/types' + +export interface TransitionItem { + summary: string + address: string + addressLabel: string + balPath: string + indexBadge: string + groupId: TriggerGroupDefinition['id'] +} + +export interface TriggerGroupViewModel { + group: TriggerGroupDefinition + items: TransitionItem[] +} + +function normalizeAddress(address: string): string { + return address.toLowerCase() +} + +function parseAccessIndex(hex: string): number { + return Number(BigInt(hex)) +} + +export function formatIndexBadge(blockAccessIndex: string): string { + const n = parseAccessIndex(blockAccessIndex) + if (n === 0) return 'system' + return `tx ${n}` +} + +function hexToBigInt(hex: string): bigint { + if (hex === '0x' || hex === '') return 0n + return BigInt(hex) +} + +export function formatEth(wei: bigint): string { + if (wei === 0n) return '0 ETH' + const eth = Number(wei) / 1e18 + if (eth >= 0.0001) { + const formatted = eth.toLocaleString(undefined, { maximumFractionDigits: 6 }) + return `${formatted} ETH` + } + return `${wei.toLocaleString()} wei` +} + +export function formatSlotValue(hex: string): string { + if (hex === '0x' || hex === '0x0') return '0' + try { + const value = BigInt(hex) + if (value <= 999_999_999_999n) return value.toString() + return `0x…${hex.slice(-4)}` + } catch { + return hex + } +} + +export function formatShortSlot(slot: string): string { + if (slot === '0x' || slot === '0x0') return '0x00…00' + const trimmed = slot.replace(/^0x0+/, '0x') || '0x0' + if (trimmed.length <= 10) return trimmed + return `${trimmed.slice(0, 6)}…${trimmed.slice(-4)}` +} + +function shortAddress(address: string): string { + return `${address.slice(0, 6)}…${address.slice(-4)}` +} + +function preStateByAddress( + preState: PreStateAccount[], +): Map { + return new Map(preState.map((a) => [normalizeAddress(a.address), a])) +} + +function getPreBalance(preStateMap: Map, address: string): bigint { + return preStateMap.get(normalizeAddress(address))?.balance ?? 0n +} + +function getPreNonce(preStateMap: Map, address: string): bigint { + return preStateMap.get(normalizeAddress(address))?.nonce ?? 0n +} + +function getPreStorage( + preStateMap: Map, + address: string, + slot: string, +): bigint { + const account = preStateMap.get(normalizeAddress(address)) + if (account?.storage === undefined) return 0n + const normalizedSlot = slot.toLowerCase() + for (const [s, v] of account.storage) { + if (s.toLowerCase() === normalizedSlot || normalizeSlotKey(s) === normalizeSlotKey(slot)) { + return hexToBigInt(v) + } + } + return 0n +} + +export function normalizeSlotKey(slot: string): string { + if (slot === '0x') return '0x0' + try { + return `0x${BigInt(slot).toString(16)}` + } catch { + return slot.toLowerCase() + } +} + +function buildBalanceItems( + account: BALJSONBlockAccessList[number], + preStateMap: Map, +): TransitionItem[] { + const group = getGroupByField('balanceChanges') + const sorted = [...account.balanceChanges].sort( + (a, b) => parseAccessIndex(a.blockAccessIndex) - parseAccessIndex(b.blockAccessIndex), + ) + let previous = getPreBalance(preStateMap, account.address) + + return sorted.map((change, i) => { + const post = hexToBigInt(change.postBalance) + const summary = `${formatEth(previous)} → ${formatEth(post)}` + previous = post + return { + summary, + address: account.address, + addressLabel: shortAddress(account.address), + balPath: balPathFor(account.address, 'balanceChanges', String(i)), + indexBadge: formatIndexBadge(change.blockAccessIndex), + groupId: group.id, + } + }) +} + +function buildNonceItems( + account: BALJSONBlockAccessList[number], + preStateMap: Map, +): TransitionItem[] { + const group = getGroupByField('nonceChanges') + const sorted = [...account.nonceChanges].sort( + (a, b) => parseAccessIndex(a.blockAccessIndex) - parseAccessIndex(b.blockAccessIndex), + ) + let previous = getPreNonce(preStateMap, account.address) + + return sorted.map((change, i) => { + const post = hexToBigInt(change.postNonce) + const summary = `nonce ${previous} → ${post}` + previous = post + return { + summary, + address: account.address, + addressLabel: shortAddress(account.address), + balPath: balPathFor(account.address, 'nonceChanges', String(i)), + indexBadge: formatIndexBadge(change.blockAccessIndex), + groupId: group.id, + } + }) +} + +function buildCodeItems(account: BALJSONBlockAccessList[number]): TransitionItem[] { + const group = getGroupByField('codeChanges') + return account.codeChanges.map((change, i) => { + const byteLen = change.newCode === '0x' ? 0 : (change.newCode.length - 2) / 2 + return { + summary: `deployed (${byteLen} bytes)`, + address: account.address, + addressLabel: shortAddress(account.address), + balPath: balPathFor(account.address, 'codeChanges', String(i)), + indexBadge: formatIndexBadge(change.blockAccessIndex), + groupId: group.id, + } + }) +} + +function buildStorageChangeItems( + account: BALJSONBlockAccessList[number], + preStateMap: Map, +): TransitionItem[] { + const group = getGroupByField('storageChanges') + const items: TransitionItem[] = [] + let pathIndex = 0 + + for (const slotEntry of account.storageChanges) { + const sorted = [...slotEntry.slotChanges].sort( + (a, b) => parseAccessIndex(a.blockAccessIndex) - parseAccessIndex(b.blockAccessIndex), + ) + let previous = getPreStorage(preStateMap, account.address, slotEntry.slot) + + for (const change of sorted) { + const post = hexToBigInt(change.postValue) + const summary = `slot ${formatShortSlot(slotEntry.slot)}: ${formatSlotValue(`0x${previous.toString(16)}`)} → ${formatSlotValue(change.postValue)}` + previous = post + items.push({ + summary, + address: account.address, + addressLabel: shortAddress(account.address), + balPath: balPathFor( + account.address, + 'storageChanges', + `${normalizeSlotKey(slotEntry.slot)}/${pathIndex}`, + ), + indexBadge: formatIndexBadge(change.blockAccessIndex), + groupId: group.id, + }) + pathIndex++ + } + } + + return items +} + +function buildStorageReadItems(account: BALJSONBlockAccessList[number]): TransitionItem[] { + const group = getGroupByField('storageReads') + return account.storageReads.map((slot, i) => ({ + summary: `read slot ${formatShortSlot(slot)}`, + address: account.address, + addressLabel: shortAddress(account.address), + balPath: balPathFor(account.address, 'storageReads', normalizeSlotKey(slot)), + indexBadge: '', + groupId: group.id, + })) +} + +/** + * Build trigger-group view models from BAL JSON and scenario pre-state. + * Groups with no items are included but marked empty (UI collapses them). + */ +export function buildTriggerGroups( + balJson: BALJSONBlockAccessList, + preState: PreStateAccount[], +): TriggerGroupViewModel[] { + const preStateMap = preStateByAddress(preState) + const itemsByField = new Map< + TriggerGroupDefinition['sourceField'], + TransitionItem[] + >() + + for (const field of TRIGGER_GROUPS.map((g) => g.sourceField)) { + itemsByField.set(field, []) + } + + for (const account of balJson) { + itemsByField.get('balanceChanges')!.push(...buildBalanceItems(account, preStateMap)) + itemsByField.get('nonceChanges')!.push(...buildNonceItems(account, preStateMap)) + itemsByField.get('codeChanges')!.push(...buildCodeItems(account)) + itemsByField.get('storageChanges')!.push( + ...buildStorageChangeItems(account, preStateMap), + ) + itemsByField.get('storageReads')!.push(...buildStorageReadItems(account)) + } + + return TRIGGER_GROUPS.map((group) => ({ + group, + items: itemsByField.get(group.sourceField) ?? [], + })) +} diff --git a/src/views/ExplorationView.vue b/src/views/ExplorationView.vue index 3a937d6..32f44a7 100644 --- a/src/views/ExplorationView.vue +++ b/src/views/ExplorationView.vue @@ -12,7 +12,8 @@ import TopicIntroView from './TopicIntroView.vue' const route = useRoute() const explorationId = route.name as string const exploration = EXPLORATIONS[explorationId] -const cc = TOPIC_COLORS[TOPICS[exploration.topic].color].classes +const topic = TOPICS[exploration.topic] +const cc = TOPIC_COLORS[topic.color].classes const breadcrumbs = getBreadcrumbsForPath(route.path) const componentModules = import.meta.glob('../explorations/*/MyC.vue') @@ -40,8 +41,9 @@ const ExplorationComponent = defineAsyncComponent( +
From b95b74b4b778aac60a8ffffa11530169b5a3b1e8 Mon Sep 17 00:00:00 2001 From: Holger Drewes <931137+holgerd77@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:15:17 +0200 Subject: [PATCH 2/9] BAL clean-up (topic, tag, latest), new image, scenario review + fixes --- src/explorations/REGISTRY.ts | 2 + src/explorations/TAGS.ts | 1 + .../eip-7928/BalExplorerPanel.vue | 5 +- src/explorations/eip-7928/BalJsonView.vue | 18 ++-- src/explorations/eip-7928/MyC.vue | 3 +- .../eip-7928/ScenarioBriefView.vue | 10 +-- src/explorations/eip-7928/examples.ts | 1 - src/explorations/eip-7928/image.webp | Bin 0 -> 172520 bytes src/explorations/eip-7928/info.ts | 8 +- src/explorations/eip-7928/run.ts | 5 +- src/explorations/eip-7928/scenarioBrief.ts | 9 +- .../eip-7928/scenarios/01-plain-transfer.ts | 11 ++- .../eip-7928/scenarios/02-contract-sload.ts | 5 +- .../eip-7928/scenarios/03-sstore-write.ts | 5 +- .../eip-7928/scenarios/constants.ts | 8 +- .../eip-7928/scenarios/helpers.ts | 11 ++- src/explorations/eip-7928/scenarios/index.ts | 1 - src/explorations/eip-7928/tests.spec.ts | 83 ++++++++++++++++-- src/explorations/eip-7928/transitions.ts | 62 +++++++++---- src/views/ExplorationView.vue | 13 ++- src/views/HomeView.vue | 2 +- src/views/TopicIntroView.vue | 24 ++++- src/views/__tests__/HomeView.spec.ts | 2 +- 23 files changed, 216 insertions(+), 73 deletions(-) create mode 100644 src/explorations/eip-7928/image.webp diff --git a/src/explorations/REGISTRY.ts b/src/explorations/REGISTRY.ts index a7a3ca4..bd72c03 100644 --- a/src/explorations/REGISTRY.ts +++ b/src/explorations/REGISTRY.ts @@ -34,6 +34,8 @@ export interface Exploration { timeline: string tags: Tag[] image?: string + /** Optional max height for the cover image in the exploration sidebar (CSS length, e.g. `12rem`). */ + imageBoxHeight?: string /** When set, exploration content may Teleport into `#exploration-right-panel`. */ rightPanel?: boolean introText: string diff --git a/src/explorations/TAGS.ts b/src/explorations/TAGS.ts index 9fb62e8..9757b59 100644 --- a/src/explorations/TAGS.ts +++ b/src/explorations/TAGS.ts @@ -20,6 +20,7 @@ * - Short form preferred (e.g. "EVM" not "Ethereum Virtual Machine"). */ export enum Tag { + BAL = 'BAL', EVM = 'EVM', GasCosts = 'Gas Costs', PeerDAS = 'PeerDAS', diff --git a/src/explorations/eip-7928/BalExplorerPanel.vue b/src/explorations/eip-7928/BalExplorerPanel.vue index 82fa2fc..05db1a8 100644 --- a/src/explorations/eip-7928/BalExplorerPanel.vue +++ b/src/explorations/eip-7928/BalExplorerPanel.vue @@ -1,12 +1,11 @@