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..bd72c03 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,10 @@ 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 usageText: string creatorName?: 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 new file mode 100644 index 0000000..2f7f0ec --- /dev/null +++ b/src/explorations/eip-7928/BalExplorerPanel.vue @@ -0,0 +1,60 @@ + + + diff --git a/src/explorations/eip-7928/BalJsonView.vue b/src/explorations/eip-7928/BalJsonView.vue new file mode 100644 index 0000000..bc5dc6c --- /dev/null +++ b/src/explorations/eip-7928/BalJsonView.vue @@ -0,0 +1,234 @@ + + + diff --git a/src/explorations/eip-7928/MyC.vue b/src/explorations/eip-7928/MyC.vue new file mode 100644 index 0000000..e0ba4e5 --- /dev/null +++ b/src/explorations/eip-7928/MyC.vue @@ -0,0 +1,148 @@ + + + diff --git a/src/explorations/eip-7928/ScenarioBriefView.vue b/src/explorations/eip-7928/ScenarioBriefView.vue new file mode 100644 index 0000000..b7781d7 --- /dev/null +++ b/src/explorations/eip-7928/ScenarioBriefView.vue @@ -0,0 +1,145 @@ + + + diff --git a/src/explorations/eip-7928/TriggerGroupsView.vue b/src/explorations/eip-7928/TriggerGroupsView.vue new file mode 100644 index 0000000..0084cb1 --- /dev/null +++ b/src/explorations/eip-7928/TriggerGroupsView.vue @@ -0,0 +1,87 @@ + + + diff --git a/src/explorations/eip-7928/examples.ts b/src/explorations/eip-7928/examples.ts new file mode 100644 index 0000000..6bd6e91 --- /dev/null +++ b/src/explorations/eip-7928/examples.ts @@ -0,0 +1,36 @@ +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/image.webp b/src/explorations/eip-7928/image.webp new file mode 100644 index 0000000..ad4af9f Binary files /dev/null and b/src/explorations/eip-7928/image.webp differ diff --git a/src/explorations/eip-7928/info.ts b/src/explorations/eip-7928/info.ts new file mode 100644 index 0000000..fefd497 --- /dev/null +++ b/src/explorations/eip-7928/info.ts @@ -0,0 +1,29 @@ +import type { Exploration } from '@/explorations/REGISTRY' +import { Tag } from '@/explorations/TAGS' + +import image from './image.webp' + +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: 'scaling', + timeline: 'glamsterdam', + tags: [Tag.BAL, Tag.EVM], + image, + imageBoxHeight: '19rem', + 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..af50169 --- /dev/null +++ b/src/explorations/eip-7928/run.ts @@ -0,0 +1,38 @@ +import { Common, Hardfork, Mainnet } from '@ethereumjs/common' +import { bytesToHex } from '@ethereumjs/util' +import { createVM, runBlock } from '@ethereumjs/vm' + +import { getScenario } from './scenarios' +import { applyPreState, buildAmsterdamBlock } from './scenarios/helpers' +import type { 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..d19f728 --- /dev/null +++ b/src/explorations/eip-7928/scenarioBrief.ts @@ -0,0 +1,149 @@ +import type { BalExampleMeta } from './examples' +import type { + BalHighlightField, + BalScenarioDefinition, + PreStateAccount, + ScenarioRunResult, +} from './scenarios/types' +import { getGroupByField } from './taxonomy' +import { formatEth } from './transitions' + +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 recipient account', + detail: 'Legacy transfer · 21,000 gas · fees go to coinbase', + } + } + 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', + } + } + if (scenario.id === '04-create-deploy') { + return { + headline: 'Sender deploys a new contract via CREATE', + detail: 'Init code returns runtime bytecode · new account at deterministic address', + } + } + if (scenario.id === '05-two-transfers') { + return { + headline: 'Sender sends two transfers in one block', + detail: 'tx 1: 1 wei · tx 2: 2 wei to the same recipient · sequential nonces', + } + } + if (scenario.id === '06-sstore-revert') { + return { + headline: 'Sender calls a contract that reverts after SSTORE', + detail: 'Write is rolled back · access list records a read, not a write', + } + } + if (scenario.id === '07-cross-contract-call') { + return { + headline: 'Sender calls contract A, which CALLs contract B', + detail: 'B reads slot 0 and returns · both contracts in one access list', + } + } + + 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..bf336f3 --- /dev/null +++ b/src/explorations/eip-7928/scenarios/01-plain-transfer.ts @@ -0,0 +1,53 @@ +import { createLegacyTx } from '@ethereumjs/tx' +import { createAddressFromString } from '@ethereumjs/util' + +import { + DEFAULT_GAS_PRICE, + DEFAULT_SENDER_BALANCE, + RECIPIENT_ADDRESS, + SENDER_ADDRESS, + SENDER_PRIVATE_KEY, +} 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, + }, + { + label: 'recipient', + address: RECIPIENT_ADDRESS, + }, + ], + 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: createAddressFromString(RECIPIENT_ADDRESS), + }, + { 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..da9a54d --- /dev/null +++ b/src/explorations/eip-7928/scenarios/02-contract-sload.ts @@ -0,0 +1,64 @@ +import { createLegacyTx } from '@ethereumjs/tx' + +import { + CONTRACT_ADDRESS, + contractAddress, + DEFAULT_GAS_PRICE, + DEFAULT_SENDER_BALANCE, + RETRIEVE_BYTECODE, + SENDER_ADDRESS, + SENDER_PRIVATE_KEY, + SLOT_0, + VALUE_42, +} from './constants' +import type { BalScenarioDefinition } from './types' + +export const contractSloadScenario: BalScenarioDefinition = { + id: '02-contract-sload', + title: '2. Contract read (SLOAD)', + lesson: + 'The contract is already deployed with slot 0 set to 42. This call only reads that slot — ' + + 'the access list records which slot was read, with no storage writes.', + 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..f1ed8e8 --- /dev/null +++ b/src/explorations/eip-7928/scenarios/03-sstore-write.ts @@ -0,0 +1,61 @@ +import { createLegacyTx } from '@ethereumjs/tx' + +import { + CONTRACT_ADDRESS, + contractAddress, + DEFAULT_GAS_PRICE, + DEFAULT_SENDER_BALANCE, + SENDER_ADDRESS, + SENDER_PRIVATE_KEY, + SSTORE_42_BYTECODE, +} from './constants' +import type { BalScenarioDefinition } from './types' + +export const sstoreWriteScenario: BalScenarioDefinition = { + id: '03-sstore-write', + title: '3. Storage write (SSTORE)', + lesson: + 'The contract starts with empty storage. This call writes 42 into slot 0 — ' + + 'the access list records that as a storage write. If the same slot was read earlier in ' + + 'the transaction, only the write is listed.', + 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/04-create-deploy.ts b/src/explorations/eip-7928/scenarios/04-create-deploy.ts new file mode 100644 index 0000000..ed1a89c --- /dev/null +++ b/src/explorations/eip-7928/scenarios/04-create-deploy.ts @@ -0,0 +1,55 @@ +import { createLegacyTx } from '@ethereumjs/tx' +import { hexToBytes } from '@ethereumjs/util' + +import { + CREATE_DEPLOY_INIT_BYTECODE, + CREATE_DEPLOYED_ADDRESS, + DEFAULT_GAS_PRICE, + DEFAULT_SENDER_BALANCE, + SENDER_ADDRESS, + SENDER_PRIVATE_KEY, +} from './constants' +import type { BalScenarioDefinition } from './types' + +export const createDeployScenario: BalScenarioDefinition = { + id: '04-create-deploy', + title: '4. Contract deploy (CREATE)', + lesson: + 'The sender broadcasts a contract-creation transaction with no recipient address. ' + + 'Init code runs once, returns runtime bytecode, and a new account appears at a ' + + 'deterministic address — the access list records the deployed code.', + step: 4, + adjustable: false, + highlightFields: ['codeChanges'], + preState: [ + { + label: 'sender', + address: SENDER_ADDRESS, + balance: DEFAULT_SENDER_BALANCE, + nonce: 0n, + }, + ], + txSummary: [ + { + label: 'tx 1', + detail: `legacy CREATE, init code → ${CREATE_DEPLOYED_ADDRESS}, gasLimit 500000`, + }, + ], + bytecodeSteps: [ + { opcode: 'PUSH1 size / PUSH1 offset / CODECOPY', comment: 'init: copy runtime into memory' }, + { opcode: 'PUSH1 size / PUSH1 0 / RETURN', comment: 'init: return runtime as contract code' }, + { opcode: 'PUSH1 0x2a / PUSH1 0x00 / SSTORE / STOP', comment: 'runtime deployed on-chain' }, + ], + buildTransactions(common) { + return [ + createLegacyTx( + { + data: hexToBytes(CREATE_DEPLOY_INIT_BYTECODE), + gasLimit: 500_000n, + gasPrice: DEFAULT_GAS_PRICE, + }, + { common }, + ).sign(SENDER_PRIVATE_KEY), + ] + }, +} diff --git a/src/explorations/eip-7928/scenarios/05-two-transfers.ts b/src/explorations/eip-7928/scenarios/05-two-transfers.ts new file mode 100644 index 0000000..cdaa996 --- /dev/null +++ b/src/explorations/eip-7928/scenarios/05-two-transfers.ts @@ -0,0 +1,60 @@ +import { createLegacyTx } from '@ethereumjs/tx' +import { createAddressFromString } from '@ethereumjs/util' + +import { + DEFAULT_GAS_PRICE, + DEFAULT_SENDER_BALANCE, + RECIPIENT_ADDRESS, + SENDER_ADDRESS, + SENDER_PRIVATE_KEY, +} from './constants' +import type { BalScenarioDefinition } from './types' + +export const twoTransfersScenario: BalScenarioDefinition = { + id: '05-two-transfers', + title: '5. Two transactions in one block', + lesson: + 'This block runs two transfers back-to-back from the same sender. Each access-list ' + + 'entry is tagged with which transaction caused it — tx 1 or tx 2 — so effects from ' + + 'different transactions stay distinguishable.', + step: 5, + adjustable: false, + highlightFields: ['balanceChanges', 'nonceChanges'], + preState: [ + { + label: 'sender', + address: SENDER_ADDRESS, + balance: DEFAULT_SENDER_BALANCE, + nonce: 0n, + }, + { + label: 'recipient', + address: RECIPIENT_ADDRESS, + }, + ], + txSummary: [ + { + label: 'tx 1', + detail: `legacy transfer: 1 wei → ${RECIPIENT_ADDRESS}, gasLimit 21000`, + }, + { + label: 'tx 2', + detail: `legacy transfer: 2 wei → ${RECIPIENT_ADDRESS}, gasLimit 21000`, + }, + ], + buildTransactions(common) { + const to = createAddressFromString(RECIPIENT_ADDRESS) + return [0n, 1n].map((nonce, i) => + createLegacyTx( + { + nonce, + gasLimit: 21_000n, + gasPrice: DEFAULT_GAS_PRICE, + value: BigInt(i + 1), + to, + }, + { common }, + ).sign(SENDER_PRIVATE_KEY), + ) + }, +} diff --git a/src/explorations/eip-7928/scenarios/06-sstore-revert.ts b/src/explorations/eip-7928/scenarios/06-sstore-revert.ts new file mode 100644 index 0000000..a64511c --- /dev/null +++ b/src/explorations/eip-7928/scenarios/06-sstore-revert.ts @@ -0,0 +1,60 @@ +import { createLegacyTx } from '@ethereumjs/tx' + +import { + CONTRACT_ADDRESS, + contractAddress, + DEFAULT_GAS_PRICE, + DEFAULT_SENDER_BALANCE, + SENDER_ADDRESS, + SENDER_PRIVATE_KEY, + SSTORE_REVERT_BYTECODE, +} from './constants' +import type { BalScenarioDefinition } from './types' + +export const sstoreRevertScenario: BalScenarioDefinition = { + id: '06-sstore-revert', + title: '6. Reverted storage write', + lesson: + 'This contract runs SSTORE and then REVERT — the write never commits. The access list ' + + 'still records that slot 0 was touched, but as a read only, not a write.', + step: 6, + adjustable: false, + highlightFields: ['storageReads'], + preState: [ + { + label: 'sender', + address: SENDER_ADDRESS, + balance: DEFAULT_SENDER_BALANCE, + nonce: 0n, + }, + { + label: 'contract', + address: CONTRACT_ADDRESS, + code: SSTORE_REVERT_BYTECODE, + }, + ], + txSummary: [ + { + label: 'tx 1', + detail: `legacy call → ${CONTRACT_ADDRESS}, SSTORE then REVERT, gasLimit 200000`, + }, + ], + bytecodeSteps: [ + { opcode: 'PUSH1 0x2a', comment: 'value 42' }, + { opcode: 'PUSH1 0x00', comment: 'storage slot 0' }, + { opcode: 'SSTORE', comment: 'attempted write (reverted)' }, + { opcode: 'PUSH1 0 / PUSH1 0 / REVERT', comment: 'undo all state changes' }, + ], + 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/07-cross-contract-call.ts b/src/explorations/eip-7928/scenarios/07-cross-contract-call.ts new file mode 100644 index 0000000..973a73c --- /dev/null +++ b/src/explorations/eip-7928/scenarios/07-cross-contract-call.ts @@ -0,0 +1,70 @@ +import { createLegacyTx } from '@ethereumjs/tx' + +import { + CALL_FORWARD_BYTECODE, + CALLER_ADDRESS, + callerAddress, + CONTRACT_ADDRESS, + DEFAULT_GAS_PRICE, + DEFAULT_SENDER_BALANCE, + RETRIEVE_BYTECODE, + SENDER_ADDRESS, + SENDER_PRIVATE_KEY, + SLOT_0, + VALUE_42, +} from './constants' +import type { BalScenarioDefinition } from './types' + +export const crossContractCallScenario: BalScenarioDefinition = { + id: '07-cross-contract-call', + title: '7. Cross-contract CALL', + lesson: + 'The sender calls contract A, which CALLs contract B. B reads slot 0 and returns — ' + + 'both contracts appear in the same access list because all state touches in a ' + + 'transaction roll up together, across call frames.', + step: 7, + adjustable: false, + highlightFields: ['storageReads'], + preState: [ + { + label: 'sender', + address: SENDER_ADDRESS, + balance: DEFAULT_SENDER_BALANCE, + nonce: 0n, + }, + { + label: 'caller', + address: CALLER_ADDRESS, + code: CALL_FORWARD_BYTECODE, + }, + { + label: 'callee', + address: CONTRACT_ADDRESS, + code: RETRIEVE_BYTECODE, + storage: [[SLOT_0, VALUE_42]], + }, + ], + txSummary: [ + { + label: 'tx 1', + detail: `legacy call → ${CALLER_ADDRESS}, forwards CALL to ${CONTRACT_ADDRESS}, gasLimit 300000`, + }, + ], + bytecodeSteps: [ + { opcode: 'CALL → callee', comment: 'caller forwards with empty calldata' }, + { opcode: 'PUSH1 0x00 / SLOAD', comment: 'callee reads slot 0' }, + { opcode: 'MSTORE / RETURN', comment: 'callee returns 32-byte word to caller' }, + ], + buildTransactions(common) { + return [ + createLegacyTx( + { + to: callerAddress, + gasLimit: 300_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..b94c782 --- /dev/null +++ b/src/explorations/eip-7928/scenarios/constants.ts @@ -0,0 +1,60 @@ +import type { PrefixedHexString } from '@ethereumjs/util' +import { + createAddressFromPrivateKey, + createAddressFromString, + createContractAddress, + hexToBytes, +} from '@ethereumjs/util' + +export const SENDER_PRIVATE_KEY = hexToBytes(`0x${'20'.repeat(32)}`) +export const SENDER_ADDRESS = createAddressFromPrivateKey(SENDER_PRIVATE_KEY).toString() +export const RECIPIENT_PRIVATE_KEY = hexToBytes(`0x${'71'.repeat(32)}`) +export const RECIPIENT_ADDRESS = createAddressFromPrivateKey( + RECIPIENT_PRIVATE_KEY, +).toString() as PrefixedHexString +export const CONTRACT_PRIVATE_KEY = hexToBytes(`0x${'42'.repeat(32)}`) +export const CONTRACT_ADDRESS = createAddressFromPrivateKey( + CONTRACT_PRIVATE_KEY, +).toString() as PrefixedHexString +export const CALLER_PRIVATE_KEY = hexToBytes(`0x${'43'.repeat(32)}`) +export const CALLER_ADDRESS = createAddressFromPrivateKey( + CALLER_PRIVATE_KEY, +).toString() as PrefixedHexString +/** Block fee recipient — kept distinct from other addresses so fee flow reads cleanly. */ +export const COINBASE_ADDRESS = '0x00000000000000000000000000000000000000c1' 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 + +/** PUSH1 42; PUSH1 0; SSTORE; PUSH1 0; PUSH1 0; REVERT */ +export const SSTORE_REVERT_BYTECODE = `0x602a60005560006000fd` as PrefixedHexString + +/** + * Caller runtime: CALL callee with empty calldata, copy 32-byte return to memory, STOP. + * Callee address is embedded as a PUSH20 immediate ({@link CONTRACT_ADDRESS}). + */ +export const CALL_FORWARD_BYTECODE = + `0x6020600060006000600073${CONTRACT_ADDRESS.slice(2).toLowerCase()}620186a0f100` as PrefixedHexString + +/** Init code: CODECOPY runtime into memory, RETURN — deploys {@link SSTORE_42_BYTECODE}. */ +export const CREATE_DEPLOY_INIT_BYTECODE = + `0x6006600c60003960066000f3${SSTORE_42_BYTECODE.slice(2)}` as PrefixedHexString + +/** Address of the contract created when the sender (nonce 0) runs a CREATE tx. */ +export const CREATE_DEPLOYED_ADDRESS = createContractAddress( + createAddressFromString(SENDER_ADDRESS), + 0n, +).toString() 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) +export const callerAddress = createAddressFromString(CALLER_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..d80bc5e --- /dev/null +++ b/src/explorations/eip-7928/scenarios/helpers.ts @@ -0,0 +1,89 @@ +import type { Block } from '@ethereumjs/block' +import { createBlock } 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 { COINBASE_ADDRESS, 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, + coinbase: createAddressFromString(COINBASE_ADDRESS), + }, + 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..43666e3 --- /dev/null +++ b/src/explorations/eip-7928/scenarios/index.ts @@ -0,0 +1,46 @@ +import { plainTransferScenario } from './01-plain-transfer' +import { contractSloadScenario } from './02-contract-sload' +import { sstoreWriteScenario } from './03-sstore-write' +import { createDeployScenario } from './04-create-deploy' +import { twoTransfersScenario } from './05-two-transfers' +import { sstoreRevertScenario } from './06-sstore-revert' +import { crossContractCallScenario } from './07-cross-contract-call' +import type { BalScenarioDefinition } from './types' + +export const SCENARIOS: Record = { + [plainTransferScenario.id]: plainTransferScenario, + [contractSloadScenario.id]: contractSloadScenario, + [sstoreWriteScenario.id]: sstoreWriteScenario, + [createDeployScenario.id]: createDeployScenario, + [twoTransfersScenario.id]: twoTransfersScenario, + [sstoreRevertScenario.id]: sstoreRevertScenario, + [crossContractCallScenario.id]: crossContractCallScenario, +} + +/** Curriculum order for prev/next navigation in the UI. */ +export const SCENARIO_ORDER = [ + plainTransferScenario.id, + contractSloadScenario.id, + sstoreWriteScenario.id, + createDeployScenario.id, + twoTransfersScenario.id, + sstoreRevertScenario.id, + crossContractCallScenario.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..5bc85f0 --- /dev/null +++ b/src/explorations/eip-7928/taxonomy.ts @@ -0,0 +1,167 @@ +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..b131443 --- /dev/null +++ b/src/explorations/eip-7928/tests.spec.ts @@ -0,0 +1,324 @@ +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 { + CALLER_ADDRESS, + COINBASE_ADDRESS, + CONTRACT_ADDRESS, + CREATE_DEPLOYED_ADDRESS, + DEFAULT_GAS_PRICE, + RECIPIENT_ADDRESS, + SENDER_ADDRESS, + SSTORE_42_BYTECODE, +} from './scenarios/constants' +import { getGroupByField, TRIGGER_GROUPS } from './taxonomy' +import { + buildTriggerGroups, + formatBalanceTransition, + formatEth, + formatIndexBadge, + formatSlotValue, +} from './transitions' + +const LEGACY_TRANSFER_GAS = 21_000n +const LEGACY_TRANSFER_FEE = LEGACY_TRANSFER_GAS * DEFAULT_GAS_PRICE +const LEGACY_PRIORITY_FEE = LEGACY_TRANSFER_GAS * (DEFAULT_GAS_PRICE - 1n) + +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('scaling') + 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)).toBe('1 ETH') + expect(formatEth(1n)).toBe('1 wei') + expect(formatIndexBadge('0x01')).toBe('tx 1') + expect(formatIndexBadge('0x02')).toBe('tx 2') + expect(formatIndexBadge('0x00')).toBe('system') + expect(formatSlotValue('0x2a')).toBe('42') + }) + + it('shows sub-ETH balances with enough precision for gas deductions', () => { + const postSender = 1_000_000_000_000_000_000n - LEGACY_TRANSFER_FEE - 1n + expect(formatEth(postSender)).toContain('0.999999') + expect(formatEth(postSender)).not.toBe('1 ETH') + expect(formatBalanceTransition(1_000_000_000_000_000_000n, postSender)).toBe( + `1 ETH → ${formatEth(postSender)}`, + ) + }) + }) + + 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).toBe(2) + 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 1 wei on the recipient and fees on coinbase for plain transfer', async () => { + const result = await runScenario('01-plain-transfer') + + const recipient = result.balJson.find( + (a) => a.address.toLowerCase() === RECIPIENT_ADDRESS.toLowerCase(), + ) + expect(recipient?.balanceChanges).toHaveLength(1) + expect(BigInt(recipient!.balanceChanges[0]!.postBalance)).toBe(1n) + + const coinbase = result.balJson.find( + (a) => a.address.toLowerCase() === COINBASE_ADDRESS.toLowerCase(), + ) + expect(coinbase?.balanceChanges).toHaveLength(1) + expect(BigInt(coinbase!.balanceChanges[0]!.postBalance)).toBe(LEGACY_PRIORITY_FEE) + + const sender = result.balJson.find( + (a) => a.address.toLowerCase() === SENDER_ADDRESS.toLowerCase(), + ) + expect(BigInt(sender!.balanceChanges[0]!.postBalance)).toBe( + 1_000_000_000_000_000_000n - LEGACY_TRANSFER_FEE - 1n, + ) + }) + + 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) + }) + + it('records codeChanges on CREATE deploy', async () => { + const result = await runScenario('04-create-deploy') + const deployed = result.balJson.find( + (a) => a.address.toLowerCase() === CREATE_DEPLOYED_ADDRESS.toLowerCase(), + ) + expect(deployed).toBeDefined() + expect(deployed!.codeChanges).toHaveLength(1) + expect(deployed!.codeChanges[0]!.newCode.toLowerCase()).toBe(SSTORE_42_BYTECODE.toLowerCase()) + expect(deployed!.codeChanges[0]!.blockAccessIndex).toBe('0x01') + }) + + it('tags each change with the transaction index in a two-tx block', async () => { + const result = await runScenario('05-two-transfers') + expect(result.txCount).toBe(2) + + const sender = result.balJson.find( + (a) => a.address.toLowerCase() === SENDER_ADDRESS.toLowerCase(), + ) + expect(sender?.balanceChanges.map((c) => c.blockAccessIndex)).toEqual(['0x01', '0x02']) + expect(sender?.nonceChanges.map((c) => c.blockAccessIndex)).toEqual(['0x01', '0x02']) + + const recipient = result.balJson.find( + (a) => a.address.toLowerCase() === RECIPIENT_ADDRESS.toLowerCase(), + ) + expect(recipient?.balanceChanges).toHaveLength(2) + expect(recipient?.balanceChanges[0]!.blockAccessIndex).toBe('0x01') + expect(BigInt(recipient!.balanceChanges[0]!.postBalance)).toBe(1n) + expect(recipient?.balanceChanges[1]!.blockAccessIndex).toBe('0x02') + expect(BigInt(recipient!.balanceChanges[1]!.postBalance)).toBe(3n) + }) + + it('records storageReads but not storageChanges when SSTORE reverts', async () => { + const result = await runScenario('06-sstore-revert') + const contract = result.balJson.find( + (a) => + a.address.toLowerCase() === + SCENARIOS['06-sstore-revert'].preState[1]!.address.toLowerCase(), + ) + expect(contract).toBeDefined() + expect(contract!.storageReads.length).toBeGreaterThan(0) + expect(contract!.storageChanges.length).toBe(0) + }) + + it('records callee storageReads for nested CALL in one transaction', async () => { + const result = await runScenario('07-cross-contract-call') + expect(result.txCount).toBe(1) + + const caller = result.balJson.find( + (a) => a.address.toLowerCase() === CALLER_ADDRESS.toLowerCase(), + ) + const callee = result.balJson.find( + (a) => a.address.toLowerCase() === CONTRACT_ADDRESS.toLowerCase(), + ) + expect(caller).toBeDefined() + expect(callee).toBeDefined() + expect(callee!.storageReads.length).toBeGreaterThan(0) + expect(callee!.storageChanges.length).toBe(0) + }) + }) + + describe('buildTriggerGroups', () => { + it('describes plain transfer value flow matching the scenario', async () => { + const result = await runScenario('01-plain-transfer') + const groups = buildTriggerGroups(result.balJson, result.preState) + const valueFlow = groups.find((g) => g.group.id === 'valueFlow')! + const byLabel = Object.fromEntries(valueFlow.items.map((item) => [item.addressLabel, item])) + + expect(byLabel.sender?.summary).toBe( + formatBalanceTransition( + 1_000_000_000_000_000_000n, + 1_000_000_000_000_000_000n - LEGACY_TRANSFER_FEE - 1n, + ), + ) + expect(byLabel.recipient?.summary).toBe('0 ETH → 1 wei') + expect(byLabel.coinbase?.summary).toBe(formatBalanceTransition(0n, LEGACY_PRIORITY_FEE)) + expect(byLabel.sender?.indexBadge).toBe('tx 1') + }) + + 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]!.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/) + expect(peeks!.items[0]!.addressLabel).toBe('contract') + }) + + 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(/→/) + expect(imprints!.items[0]!.addressLabel).toBe('contract') + }) + + it('includes contract births for CREATE deploy', async () => { + const result = await runScenario('04-create-deploy') + const groups = buildTriggerGroups(result.balJson, result.preState) + const births = groups.find((g) => g.group.id === 'contractBirths')! + expect(births.items).toHaveLength(1) + expect(births.items[0]!.summary).toBe('deployed (6 bytes)') + expect(births.items[0]!.addressLabel).toBe('deployed contract') + expect(births.items[0]!.indexBadge).toBe('tx 1') + }) + + it('labels each transfer with tx 1 and tx 2 in a two-tx block', async () => { + const result = await runScenario('05-two-transfers') + const groups = buildTriggerGroups(result.balJson, result.preState) + const valueFlow = groups.find((g) => g.group.id === 'valueFlow')! + const recipientItems = valueFlow.items.filter((i) => i.addressLabel === 'recipient') + expect(recipientItems).toHaveLength(2) + expect(recipientItems[0]!.indexBadge).toBe('tx 1') + expect(recipientItems[0]!.summary).toBe('0 ETH → 1 wei') + expect(recipientItems[1]!.indexBadge).toBe('tx 2') + expect(recipientItems[1]!.summary).toBe('1 wei → 3 wei') + + const ticks = groups.find((g) => g.group.id === 'counterTicks')! + expect(ticks.items).toHaveLength(2) + expect(ticks.items[0]!.indexBadge).toBe('tx 1') + expect(ticks.items[0]!.summary).toBe('nonce 0 → 1') + expect(ticks.items[1]!.indexBadge).toBe('tx 2') + expect(ticks.items[1]!.summary).toBe('nonce 1 → 2') + }) + + it('includes state peeks but no imprints when SSTORE reverts', async () => { + const result = await runScenario('06-sstore-revert') + const groups = buildTriggerGroups(result.balJson, result.preState) + const peeks = groups.find((g) => g.group.id === 'statePeeks')! + const imprints = groups.find((g) => g.group.id === 'stateImprints')! + expect(peeks.items.length).toBeGreaterThan(0) + expect(peeks.items[0]!.addressLabel).toBe('contract') + expect(peeks.items[0]!.summary).toMatch(/read slot/) + expect(imprints.items).toHaveLength(0) + }) + + it('attributes nested CALL storage read to the callee contract', async () => { + const result = await runScenario('07-cross-contract-call') + const groups = buildTriggerGroups(result.balJson, result.preState) + const peeks = groups.find((g) => g.group.id === 'statePeeks')! + expect(peeks.items).toHaveLength(1) + expect(peeks.items[0]!.addressLabel).toBe('callee') + expect(peeks.items[0]!.summary).toMatch(/read slot/) + }) + + it('labels counter ticks with the sender on plain transfer', async () => { + const result = await runScenario('01-plain-transfer') + const groups = buildTriggerGroups(result.balJson, result.preState) + const ticks = groups.find((g) => g.group.id === 'counterTicks')! + expect(ticks.items).toHaveLength(1) + expect(ticks.items[0]!.summary).toBe('nonce 0 → 1') + expect(ticks.items[0]!.addressLabel).toBe('sender') + }) + + 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..bdc0479 --- /dev/null +++ b/src/explorations/eip-7928/transitions.ts @@ -0,0 +1,287 @@ +import type { BALJSONBlockAccessList } from '@ethereumjs/util' + +import { COINBASE_ADDRESS, CREATE_DEPLOYED_ADDRESS, RECIPIENT_ADDRESS } from './scenarios/constants' +import type { PreStateAccount } from './scenarios/types' +import { + balPathFor, + getGroupByField, + TRIGGER_GROUPS, + type TriggerGroupDefinition, +} from './taxonomy' + +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) +} + +const ONE_ETH = 1_000_000_000_000_000_000n +const MIN_ETH_DISPLAY = 1_000_000_000_000_000n // 0.001 ETH + +export function formatEth(wei: bigint): string { + if (wei === 0n) return '0 ETH' + if (wei % ONE_ETH === 0n) return `${wei / ONE_ETH} ETH` + if (wei >= MIN_ETH_DISPLAY) { + const whole = wei / ONE_ETH + const fracWei = wei % ONE_ETH + const fracDigits = ((fracWei * 1_000_000_000n) / ONE_ETH) + .toString() + .padStart(9, '0') + .replace(/0+$/, '') + if (whole === 0n) { + return fracDigits.length > 0 ? `0.${fracDigits} ETH` : `${wei.toLocaleString()} wei` + } + return fracDigits.length > 0 ? `${whole}.${fracDigits} ETH` : `${whole} ETH` + } + return `${wei.toLocaleString()} wei` +} + +export function formatBalanceTransition(pre: bigint, post: bigint): string { + if (pre === post) return `${formatEth(pre)} (unchanged)` + return `${formatEth(pre)} → ${formatEth(post)}` +} + +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 getAddressLabel(preStateMap: Map, address: string): string { + const account = preStateMap.get(normalizeAddress(address)) + if (account !== undefined) return account.label + const normalized = normalizeAddress(address) + if (normalized === normalizeAddress(RECIPIENT_ADDRESS)) return 'recipient' + if (normalized === normalizeAddress(COINBASE_ADDRESS)) return 'coinbase' + if (normalized === normalizeAddress(CREATE_DEPLOYED_ADDRESS)) return 'deployed contract' + return shortAddress(address) +} + +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 = formatBalanceTransition(previous, post) + previous = post + return { + summary, + address: account.address, + addressLabel: getAddressLabel(preStateMap, 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: getAddressLabel(preStateMap, account.address), + balPath: balPathFor(account.address, 'nonceChanges', String(i)), + indexBadge: formatIndexBadge(change.blockAccessIndex), + groupId: group.id, + } + }) +} + +function buildCodeItems( + account: BALJSONBlockAccessList[number], + preStateMap: Map, +): 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: getAddressLabel(preStateMap, 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: getAddressLabel(preStateMap, 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], + preStateMap: Map, +): TransitionItem[] { + const group = getGroupByField('storageReads') + return account.storageReads.map((slot) => ({ + summary: `read slot ${formatShortSlot(slot)}`, + address: account.address, + addressLabel: getAddressLabel(preStateMap, 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() + + 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, preStateMap)) + itemsByField.get('storageChanges')!.push(...buildStorageChangeItems(account, preStateMap)) + itemsByField.get('storageReads')!.push(...buildStorageReadItems(account, preStateMap)) + } + + 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..742245e 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') @@ -38,10 +39,20 @@ const ExplorationComponent = defineAsyncComponent( +
+ +
+
diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index 49ce77a..3333732 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -10,7 +10,7 @@ import TopicIntroView from './TopicIntroView.vue' const allExplorationIds = Object.keys(EXPLORATIONS) -const featured = ['eip-8024', 'eip-7883', 'eip-7594', 'eip-7951'] +const featured = ['eip-7928', 'eip-8024', 'eip-7883', 'eip-7594', 'eip-7951'] const activeTopicIds = Object.keys(TOPICS).filter((id) => TOPICS[id].explorations.length > 0) diff --git a/src/views/TopicIntroView.vue b/src/views/TopicIntroView.vue index aea6dd7..18ee8bc 100644 --- a/src/views/TopicIntroView.vue +++ b/src/views/TopicIntroView.vue @@ -1,4 +1,6 @@