diff --git a/config/cspell-md.json b/config/cspell-md.json index a00979bf8d..70a5cb1d33 100644 --- a/config/cspell-md.json +++ b/config/cspell-md.json @@ -2,6 +2,8 @@ "language": "en-US", "ignoreRegExpList": ["/0x[0-9A-Fa-f]+/"], "words": [ + "t8ntool", + "calldatasize", "Dencun", "Hardfork", "acolytec", diff --git a/config/cspell-ts.json b/config/cspell-ts.json index ad6c4d2ea8..79b934d298 100644 --- a/config/cspell-ts.json +++ b/config/cspell-ts.json @@ -12,6 +12,7 @@ } ], "words": [ + "t8ntool", "!Json", "!Rpc", "Hardfork", diff --git a/packages/vm/.gitignore b/packages/vm/.gitignore index 3ed4090b20..f7c4be695a 100644 --- a/packages/vm/.gitignore +++ b/packages/vm/.gitignore @@ -1,2 +1,4 @@ .cachedb benchmarks/*.js +test/t8n/testdata/output/allocTEST.json +test/t8n/testdata/output/resultTEST.json \ No newline at end of file diff --git a/packages/vm/src/buildBlock.ts b/packages/vm/src/buildBlock.ts index 30013c6704..119b3b144d 100644 --- a/packages/vm/src/buildBlock.ts +++ b/packages/vm/src/buildBlock.ts @@ -90,7 +90,7 @@ export class BlockBuilder { this.headerData = { ...opts.headerData, - parentHash: opts.parentBlock.hash(), + parentHash: opts.headerData?.parentHash ?? opts.parentBlock.hash(), number: opts.headerData?.number ?? opts.parentBlock.header.number + BIGINT_1, gasLimit: opts.headerData?.gasLimit ?? opts.parentBlock.header.gasLimit, timestamp: opts.headerData?.timestamp ?? Math.round(Date.now() / 1000), @@ -213,7 +213,10 @@ export class BlockBuilder { */ async addTransaction( tx: TypedTransaction, - { skipHardForkValidation }: { skipHardForkValidation?: boolean } = {}, + { + skipHardForkValidation, + allowNoBlobs, + }: { skipHardForkValidation?: boolean; allowNoBlobs?: boolean } = {}, ) { this.checkStatus() @@ -242,7 +245,11 @@ export class BlockBuilder { // Guard against the case if a tx came into the pool without blobs i.e. network wrapper payload if (blobTx.blobs === undefined) { - throw new Error('blobs missing for 4844 transaction') + // TODO: verify if we want this, do we want to allow the block builder to accept blob txs without the actual blobs? + // (these must have at least one `blobVersionedHashes`, this is verified at tx-level) + if (allowNoBlobs !== true) { + throw new Error('blobs missing for 4844 transaction') + } } if (this.blobGasUsed + BigInt(blobTx.numBlobs()) * blobGasPerBlob > blobGasLimit) { diff --git a/packages/vm/src/runBlock.ts b/packages/vm/src/runBlock.ts index bf88074812..97ff199044 100644 --- a/packages/vm/src/runBlock.ts +++ b/packages/vm/src/runBlock.ts @@ -494,14 +494,12 @@ export async function accumulateParentBlockHash( // getAccount with historyAddress will throw error as witnesses are not bundled // but we need to put account so as to query later for slot - try { - if ((await vm.stateManager.getAccount(historyAddress)) === undefined) { - const emptyHistoryAcc = new Account(BigInt(1)) - await vm.evm.journal.putAccount(historyAddress, emptyHistoryAcc) - } - } catch (_e) { - const emptyHistoryAcc = new Account(BigInt(1)) - await vm.evm.journal.putAccount(historyAddress, emptyHistoryAcc) + const code = await vm.stateManager.getCode(historyAddress) + + if (code.length === 0) { + // Exit early, system contract has no code so no storage is written + // TODO: verify with Gabriel that this is fine regarding verkle (should we put an empty account?) + return } async function putBlockHash(vm: VM, hash: Uint8Array, number: bigint) { @@ -536,24 +534,17 @@ export async function accumulateParentBeaconBlockRoot(vm: VM, root: Uint8Array, const timestampIndex = timestamp % historicalRootsLength const timestampExtended = timestampIndex + historicalRootsLength - /** - * Note: (by Jochem) - * If we don't do vm (put account if undefined / non-existent), block runner crashes because the beacon root address does not exist - * vm is hence (for me) again a reason why it should /not/ throw if the address does not exist - * All ethereum accounts have empty storage by default - */ - /** * Note: (by Gabriel) * Get account will throw an error in stateless execution b/c witnesses are not bundled * But we do need an account so we are able to put the storage */ - try { - if ((await vm.stateManager.getAccount(parentBeaconBlockRootAddress)) === undefined) { - await vm.evm.journal.putAccount(parentBeaconBlockRootAddress, new Account()) - } - } catch (_) { - await vm.evm.journal.putAccount(parentBeaconBlockRootAddress, new Account()) + const code = await vm.stateManager.getCode(parentBeaconBlockRootAddress) + + if (code.length === 0) { + // Exit early, system contract has no code so no storage is written + // TODO: verify with Gabriel that this is fine regarding verkle (should we put an empty account?) + return } await vm.stateManager.putStorage( diff --git a/packages/vm/test/api/EIPs/eip-2935-historical-block-hashes.spec.ts b/packages/vm/test/api/EIPs/eip-2935-historical-block-hashes.spec.ts index b95b7b58cf..7b97d3390f 100644 --- a/packages/vm/test/api/EIPs/eip-2935-historical-block-hashes.spec.ts +++ b/packages/vm/test/api/EIPs/eip-2935-historical-block-hashes.spec.ts @@ -178,6 +178,8 @@ describe('EIP 2935: historical block hashes', () => { validateConsensus: false, }) const vm = await createVM({ common: commonGenesis, blockchain }) + // Ensure 2935 system code exists + await vm.stateManager.putCode(historyAddress, contract2935Code) commonGenesis.setHardforkBy({ timestamp: 1, }) @@ -216,6 +218,8 @@ describe('EIP 2935: historical block hashes', () => { validateConsensus: false, }) const vm = await createVM({ common, blockchain }) + // Ensure 2935 system code exists + await vm.stateManager.putCode(historyAddress, contract2935Code) let lastBlock = (await vm.blockchain.getBlock(0)) as Block for (let i = 1; i <= blocksToBuild; i++) { lastBlock = await ( diff --git a/packages/vm/test/api/t8ntool/t8ntool.spec.ts b/packages/vm/test/api/t8ntool/t8ntool.spec.ts new file mode 100644 index 0000000000..9c34937aa7 --- /dev/null +++ b/packages/vm/test/api/t8ntool/t8ntool.spec.ts @@ -0,0 +1,44 @@ +import { readFileSync } from 'fs' +import { assert, describe, it } from 'vitest' + +import { TransitionTool } from '../../t8n/t8ntool.js' + +import type { T8NOptions } from '../../t8n/types.js' + +const t8nDir = 'test/t8n/testdata/' + +const args: T8NOptions = { + state: { + fork: 'shanghai', + reward: BigInt(0), + chainid: BigInt(1), + }, + input: { + alloc: `${t8nDir}input/alloc.json`, + txs: `${t8nDir}input/txs.json`, + env: `${t8nDir}input/env.json`, + }, + output: { + basedir: t8nDir, + result: `output/resultTEST.json`, + alloc: `output/allocTEST.json`, + }, + log: false, +} + +// This test is generated using `execution-spec-tests` commit 88cab2521322191b2ec7ef7d548740c0b0a264fc, running: +// fill -k test_push0_contracts[fork_Shanghai-blockchain_test-key_sstore] --fork Shanghai tests/shanghai/eip3855_push0 --evm-bin= + +// The test will run the TransitionTool using the inputs, and then compare if the output matches + +describe('test runner config tests', () => { + it('should run t8ntool with inputs and report the expected output', async () => { + await TransitionTool.run(args) + const expectedResult = JSON.parse(readFileSync(`${t8nDir}/output/result.json`).toString()) + const expectedAlloc = JSON.parse(readFileSync(`${t8nDir}/output/alloc.json`).toString()) + const reportedResult = JSON.parse(readFileSync(`${t8nDir}/output/resultTEST.json`).toString()) + const reportedAlloc = JSON.parse(readFileSync(`${t8nDir}/output/allocTEST.json`).toString()) + assert.deepStrictEqual(reportedResult, expectedResult, 'result matches expected result') + assert.deepStrictEqual(reportedAlloc, expectedAlloc, 'alloc matches expected alloc') + }) +}) diff --git a/packages/vm/test/t8n/README.md b/packages/vm/test/t8n/README.md new file mode 100644 index 0000000000..30ec38e9f3 --- /dev/null +++ b/packages/vm/test/t8n/README.md @@ -0,0 +1,40 @@ +# EVM T8NTool + +T8NTool, or Transition Tool, is a tool used to "fill tests" by test runners, for instance . These files take an input allocation (the pre-state), which contains the accounts (their balances, nonces, storage and code). It also provides an environment, which holds relevant data as current timestamp, previous block hashes, current gas limit, etc. Finally, it also provides a transactions file, which are the transactions to run on top of this pre-state and environment. It outputs the post-state and relevant other artifacts, such as tx receipts and their logs. Test fillers will take this output to generate relevant tests, such as Blockchain tests or State tests, which can then be directly ran in other clients, or using EthereumJS `npm run test:blockchain` or `npm run test:state`. + +## Using T8Ntool to fill `execution-spec-tests` + +To fill `execution-spec-tests` (or write own tests, and test those against the monorepo), follow these steps: + +1. Clone . +2. Follow the installation steps: . + +To fill tests, such as the EIP-1153 TSTORE/TLOAD tests, run: + +- `fill -vv -x --fork Cancun tests/cancun/eip1153_tstore/ --evm-bin=../ethereumjs-monorepo/packages/vm/test/t8n/ethereumjs-t8ntool.sh` + +Breaking down these arguments: + +- `-vv`: Verbose output +- `-x`: Fail early if any of the test fillers fails +- `--fork`: Fork to fill for +- `--evm-bin`: relative/absolute path to t8ns `ethereumjs-t8ntool.sh` + +Optionally, it is also possible to add the `-k ` option which will only fill this certain test. + +## Debugging T8NTool with `execution-spec-tests` + +Sometimes it is unclear why a test fails, and one wants more verbose output (from the EthereumJS side). To do so, raw output from `execution-spec-tests` can be dumped by adding the `evm-dump-dir=` flag to the `fill` command above. This will output `stdout`, `stderr`, the raw output allocation and the raw results (logs, receipts, etc.) to the `evm-dump-dir`. Additionally, if traces are wanted in `stdout`, add the `--log` flag to `ethereumjs-t8ntool.sh`, i.e. `tsx "$SCRIPT_DIR/launchT8N.ts" "$@" --log`. + +This will produce small EVM traces, like this: + +```typescript +Processing new transaction... +{ + gasLeft: '9976184', + stack: [], + opName: 'CALLDATASIZE', + depth: 0, + address: '0x0000000000000000000000000000000000001000' +} +``` diff --git a/packages/vm/test/t8n/ethereumjs-t8ntool.sh b/packages/vm/test/t8n/ethereumjs-t8ntool.sh new file mode 100755 index 0000000000..bde03526eb --- /dev/null +++ b/packages/vm/test/t8n/ethereumjs-t8ntool.sh @@ -0,0 +1,8 @@ +#!/bin/bash +if [[ "$1" == "--version" ]]; then + echo "ethereumjs t8n v1" + exit 0 +fi +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +export NODE_OPTIONS="--max-old-space-size=4096" +tsx "$SCRIPT_DIR/launchT8N.ts" "$@" \ No newline at end of file diff --git a/packages/vm/test/t8n/helpers.ts b/packages/vm/test/t8n/helpers.ts new file mode 100644 index 0000000000..54a8f697f8 --- /dev/null +++ b/packages/vm/test/t8n/helpers.ts @@ -0,0 +1,126 @@ +import yargs from 'yargs' +import { hideBin } from 'yargs/helpers' + +import type { T8NOptions } from './types.js' + +export function getArguments() { + const argsParsed = yargs(hideBin(process.argv)) + .parserConfiguration({ + 'dot-notation': false, + }) + .option('state.fork', { + describe: 'Fork to use', + type: 'string', + demandOption: true, + }) + .option('state.chainid', { + describe: 'ChainID to use', + type: 'string', + default: '1', + }) + .option('state.reward', { + describe: + 'Coinbase reward after running txs. If 0: coinbase account is touched and rewarded 0 wei. If -1, the coinbase account is not touched (default)', + type: 'string', + default: '-1', + }) + .option('input.alloc', { + describe: 'Initial state allocation', + type: 'string', + demandOption: true, + }) + .option('input.txs', { + describe: 'JSON input of txs to run on top of the initial state allocation', + type: 'string', + demandOption: true, + }) + .option('input.env', { + describe: 'Input environment (coinbase, difficulty, etc.)', + type: 'string', + demandOption: true, + }) + .option('output.basedir', { + describe: 'Base directory to write output to', + type: 'string', + demandOption: true, + }) + .option('output.result', { + describe: 'File to write output results to (relative to `output.basedir`)', + type: 'string', + demandOption: true, + }) + .option('output.alloc', { + describe: 'File to write output allocation to (after running the transactions)', + type: 'string', + demandOption: true, + }) + .option('output.body', { + deprecate: true, + description: 'File to write transaction RLPs to (currently unused)', + type: 'string', + }) + .option('log', { + describe: 'Optionally write light-trace logs to stdout', + type: 'boolean', + default: false, + }) + .strict() + .help().argv + + const args = argsParsed as any as T8NOptions + + args.input = { + alloc: (args)['input.alloc'], + txs: (args)['input.txs'], + env: (args)['input.env'], + } + args.output = { + basedir: (args)['output.basedir'], + result: (args)['output.result'], + alloc: (args)['output.alloc'], + } + args.state = { + fork: (args)['state.fork'], + reward: BigInt((args)['state.reward']), + chainid: BigInt((args)['state.chainid']), + } + + return args +} + +/** + * This function accepts an `inputs.env` which converts non-hex-prefixed numbers + * to a BigInt value, to avoid errors when converting non-prefixed hex strings to + * numbers + * @param input + * @returns converted input + */ +export function normalizeNumbers(input: any) { + const keys = [ + 'currentGasLimit', + 'currentNumber', + 'currentTimestamp', + 'currentRandom', + 'currentDifficulty', + 'currentBaseFee', + 'currentBlobGasUsed', + 'currentExcessBlobGas', + 'parentDifficulty', + 'parentTimestamp', + 'parentBaseFee', + 'parentGasUsed', + 'parentGasLimit', + 'parentBlobGasUsed', + 'parentExcessBlobGas', + ] + + for (const key of keys) { + const value = input[key] + if (value !== undefined) { + if (value.substring(0, 2) !== '0x') { + input[key] = BigInt(value) + } + } + } + return input +} diff --git a/packages/vm/test/t8n/launchT8N.ts b/packages/vm/test/t8n/launchT8N.ts new file mode 100644 index 0000000000..4a25543e2c --- /dev/null +++ b/packages/vm/test/t8n/launchT8N.ts @@ -0,0 +1,4 @@ +import { getArguments } from './helpers.js' +import { TransitionTool } from './t8ntool.js' + +await TransitionTool.run(getArguments()) diff --git a/packages/vm/test/t8n/stateTracker.ts b/packages/vm/test/t8n/stateTracker.ts new file mode 100644 index 0000000000..8e8376da1c --- /dev/null +++ b/packages/vm/test/t8n/stateTracker.ts @@ -0,0 +1,110 @@ +import { + bigIntToHex, + bytesToHex, + createAddressFromString, + hexToBytes, + setLengthLeft, + unpadBytes, +} from '@ethereumjs/util' + +import type { VM } from '../../src/vm.js' +import type { T8NAlloc } from './types.js' +import type { Account, Address, PrefixedHexString } from '@ethereumjs/util' + +export class StateTracker { + private allocTracker: { + // TODO these are all PrefixedHexString + [address: string]: { + storage: string[] + } + } = {} + + private alloc: T8NAlloc + + private vm: VM + + constructor(vm: VM, alloc: T8NAlloc) { + this.alloc = alloc + const originalPutAccount = vm.stateManager.putAccount + const originalPutCode = vm.stateManager.putCode + const originalPutStorage = vm.stateManager.putStorage + + this.vm = vm + + const self = this + + vm.stateManager.putAccount = async function (...args: [Address, Account?]) { + const address = args[0] + self.addAddress(address.toString()) + await originalPutAccount.apply(this, args) + } + + vm.stateManager.putCode = async function (...args: [Address, Uint8Array]) { + const address = args[0] + self.addAddress(address.toString()) + return originalPutCode.apply(this, args) + } + + vm.stateManager.putStorage = async function (...args: [Address, Uint8Array, Uint8Array]) { + const address = args[0] + const key = args[1] + self.addStorage(address.toString(), bytesToHex(key)) + return originalPutStorage.apply(this, args) + } + } + + private addAddress(address: PrefixedHexString) { + if (this.allocTracker[address] === undefined) { + this.allocTracker[address] = { storage: [] } + } + return this.allocTracker[address] + } + + private addStorage(address: PrefixedHexString, storage: PrefixedHexString) { + const storageList = this.addAddress(address).storage + if (!storageList.includes(storage)) { + storageList.push(storage) + } + } + + public async dumpAlloc() { + // Build output alloc + const outputAlloc = this.alloc + for (const addressString in this.allocTracker) { + const address = createAddressFromString(addressString) + const account = await this.vm.stateManager.getAccount(address) + if (account === undefined) { + delete outputAlloc[addressString] + continue + } + if (outputAlloc[addressString] === undefined) { + outputAlloc[addressString] = { + balance: '0x0', + } + } + outputAlloc[addressString].nonce = bigIntToHex(account.nonce) + outputAlloc[addressString].balance = bigIntToHex(account.balance) + outputAlloc[addressString].code = bytesToHex(await this.vm.stateManager.getCode(address)) + + const storage = this.allocTracker[addressString].storage + outputAlloc[addressString].storage = outputAlloc[addressString].storage ?? {} + + for (const key of storage) { + const keyBytes = hexToBytes(key) + let storageKeyTrimmed = bytesToHex(unpadBytes(keyBytes)) + if (storageKeyTrimmed === '0x') { + storageKeyTrimmed = '0x00' + } + const value = await this.vm.stateManager.getStorage(address, setLengthLeft(keyBytes, 32)) + if (value.length === 0) { + delete outputAlloc[addressString].storage![storageKeyTrimmed] + // To be sure, also delete any keys which are left-padded to 32 bytes + delete outputAlloc[addressString].storage![key] + continue + } + outputAlloc[addressString].storage![storageKeyTrimmed] = bytesToHex(value) + } + } + return outputAlloc + } +} diff --git a/packages/vm/test/t8n/t8ntool.ts b/packages/vm/test/t8n/t8ntool.ts new file mode 100644 index 0000000000..e8cdaf0fc3 --- /dev/null +++ b/packages/vm/test/t8n/t8ntool.ts @@ -0,0 +1,343 @@ +import { Block } from '@ethereumjs/block' +import { EVMMockBlockchain, NobleBLS } from '@ethereumjs/evm' +import { RLP } from '@ethereumjs/rlp' +import { createTxFromTxData } from '@ethereumjs/tx' +import { + CLRequestType, + bigIntToHex, + bytesToHex, + hexToBytes, + toBytes, + zeros, +} from '@ethereumjs/util' +import { keccak256 } from 'ethereum-cryptography/keccak' +import { readFileSync, writeFileSync } from 'fs' +import { loadKZG } from 'kzg-wasm' +import { join } from 'path' + +import { buildBlock, createVM } from '../../src/index.js' +import { rewardAccount } from '../../src/runBlock.js' +import { getCommon } from '../tester/config.js' +import { makeBlockFromEnv, setupPreConditions } from '../util.js' + +import { normalizeNumbers } from './helpers.js' +import { StateTracker } from './stateTracker.js' + +import type { PostByzantiumTxReceipt } from '../../dist/esm/types.js' +import type { BlockBuilder, VM } from '../../src/index.js' +import type { AfterTxEvent } from '../../src/types.js' +import type { T8NAlloc, T8NEnv, T8NOptions, T8NOutput, T8NReceipt, T8NRejectedTx } from './types.js' +import type { Common } from '@ethereumjs/common' +import type { Log } from '@ethereumjs/evm' +import type { TypedTxData } from '@ethereumjs/tx' +import type { + ConsolidationRequestV1, + DepositRequestV1, + PrefixedHexString, + WithdrawalRequestV1, +} from '@ethereumjs/util' + +/** + * This is the TransitionTool class to run transitions. The entire class is marked `private` since + * it is only intended to be used **once**. To use it, use the single public entrypoint TransitionTool.run(args) + */ +export class TransitionTool { + public options: T8NOptions + + public alloc: T8NAlloc + public txsData: TypedTxData[] + public inputEnv: T8NEnv + + public common!: Common + public vm!: VM + + public stateTracker!: StateTracker + + // Array of rejected txs + // These are rejected in case of: + // (1) The transaction is invalid (for instance, an Authorization List tx does not contain an authorization list) + // (2) The transaction is rejected by the block builder (for instance, if the sender does not have enough funds to pay) + public rejected: T8NRejectedTx[] + + // Logs tracker (for logsHash) + public logs: Log[] + + // Receipt tracker (in t8n format) + public receipts: T8NReceipt[] + + private constructor(args: T8NOptions) { + this.options = args + + this.alloc = JSON.parse(readFileSync(args.input.alloc).toString()) + this.txsData = normalizeTxData(JSON.parse(readFileSync(args.input.txs).toString())) + this.inputEnv = normalizeNumbers(JSON.parse(readFileSync(args.input.env).toString())) + + this.rejected = [] + this.logs = [] + this.receipts = [] + } + + static async run(args: T8NOptions) { + await new TransitionTool(args).run(args) + } + + private async run(args: T8NOptions) { + await this.setup(args) + + const block = makeBlockFromEnv(this.inputEnv, { common: this.common }) + + const headerData = block.header.toJSON() + headerData.difficulty = this.inputEnv.parentDifficulty + + const builder = await buildBlock(this.vm, { + parentBlock: new Block(), + headerData, + blockOpts: { putBlockIntoBlockchain: false }, + }) + + let index = 0 + + this.vm.events.on('afterTx', (event) => this.afterTx(event, index, builder)) + + for (const txData of this.txsData) { + try { + const tx = createTxFromTxData(txData, { common: this.common }) + // Set `allowNoBlobs` to `true`, since the test might not have the blob + // The 4844-tx at this should still be valid, since it has the `blobHashes` field + await builder.addTransaction(tx, { allowNoBlobs: true }) + } catch (e: any) { + this.rejected.push({ + index, + error: e.message, + }) + } + index++ + } + + // Reward miner + + if (args.state.reward !== BigInt(-1)) { + await rewardAccount(this.vm.evm, block.header.coinbase, args.state.reward, this.vm.common) + } + + const result = await builder.build() + + const convertedOutput = this.getOutput(result) + const alloc = await this.stateTracker.dumpAlloc() + + this.writeOutput(args, convertedOutput, alloc) + } + + private async setup(args: T8NOptions) { + this.common = getCommon(args.state.fork, await loadKZG()) + + const blockchain = getBlockchain(this.inputEnv) + + // Setup BLS + const evmOpts = { + bls: new NobleBLS(), + } + this.vm = await createVM({ common: this.common, blockchain, evmOpts }) + await setupPreConditions(this.vm.stateManager, { pre: this.alloc }) + + this.stateTracker = new StateTracker(this.vm, this.alloc) + + if (args.log === true) { + this.vm.events.on('beforeTx', () => { + // eslint-disable-next-line no-console + console.log('Processing new transaction...') + }) + this.vm.events.on('afterTx', () => { + // eslint-disable-next-line no-console + console.log('Done processing transaction (system operations might follow next)') + }) + this.vm.evm.events?.on('step', (e) => { + // eslint-disable-next-line no-console + console.log({ + gasLeft: e.gasLeft.toString(), + stack: e.stack.map((a) => bigIntToHex(a)), + opName: e.opcode.name, + depth: e.depth, + address: e.address.toString(), + }) + }) + } + } + + private afterTx(event: AfterTxEvent, txIndex: number, builder: BlockBuilder) { + const receipt = event.receipt as PostByzantiumTxReceipt + + const formattedLogs = [] + for (const log of receipt.logs) { + this.logs.push(log) + + const entry: any = { + address: bytesToHex(log[0]), + topics: log[1].map((e) => bytesToHex(e)), + data: bytesToHex(log[2]), + blockNumber: bytesToHex(toBytes(builder['headerData'].number)), + transactionHash: bytesToHex(event.transaction.hash()), + transactionIndex: bigIntToHex(BigInt(txIndex)), + blockHash: bytesToHex(zeros(32)), + logIndex: bigIntToHex(BigInt(formattedLogs.length)), + removed: 'false', + } + formattedLogs.push(entry) + } + + this.receipts.push({ + root: '0x', + status: receipt.status === 0 ? '0x0' : '0x1', + cumulativeGasUsed: '0x' + receipt.cumulativeBlockGasUsed.toString(16), + logsBloom: bytesToHex(receipt.bitvector), + logs: formattedLogs, + transactionHash: bytesToHex(event.transaction.hash()), + contractAddress: '0x0000000000000000000000000000000000000000', + gasUsed: '0x' + event.totalGasSpent.toString(16), + blockHash: '0x0000000000000000000000000000000000000000000000000000000000000000', + transactionIndex: bigIntToHex(BigInt(txIndex)), + }) + } + + private getOutput(block: Block): T8NOutput { + const output: T8NOutput = { + stateRoot: bytesToHex(block.header.stateRoot), + txRoot: bytesToHex(block.header.transactionsTrie), + receiptsRoot: bytesToHex(block.header.receiptTrie), + logsHash: bytesToHex(keccak256(RLP.encode(this.logs))), + logsBloom: bytesToHex(block.header.logsBloom), + receipts: this.receipts, + gasUsed: bigIntToHex(block.header.gasUsed), + } + + if (block.header.baseFeePerGas !== undefined) { + output.currentBaseFee = bigIntToHex(block.header.baseFeePerGas) + } + + if (block.header.withdrawalsRoot !== undefined) { + output.withdrawalsRoot = bytesToHex(block.header.withdrawalsRoot) + } + + if (block.header.blobGasUsed !== undefined) { + output.blobGasUsed = bigIntToHex(block.header.blobGasUsed) + } + + if (block.header.excessBlobGas !== undefined) { + output.currentExcessBlobGas = bigIntToHex(block.header.excessBlobGas) + } + + if (block.header.requestsRoot !== undefined) { + output.requestsRoot = bytesToHex(block.header.requestsRoot) + } + + if (block.requests !== undefined) { + if (this.common.isActivatedEIP(6110)) { + output.depositRequests = [] + } + + if (this.common.isActivatedEIP(7002)) { + output.withdrawalRequests = [] + } + + if (this.common.isActivatedEIP(7251)) { + output.consolidationRequests = [] + } + + for (const request of block.requests) { + if (request.type === CLRequestType.Deposit) { + output.depositRequests!.push(request.toJSON()) + } else if (request.type === CLRequestType.Withdrawal) { + output.withdrawalRequests!.push(request.toJSON()) + } else if (request.type === CLRequestType.Consolidation) { + output.consolidationRequests!.push(request.toJSON()) + } + } + } + + if (this.rejected.length > 0) { + output.rejected = this.rejected + } + + return output + } + + private writeOutput(args: T8NOptions, output: T8NOutput, outputAlloc: T8NAlloc) { + const outputResultFilePath = join(args.output.basedir, args.output.result) + const outputAllocFilePath = join(args.output.basedir, args.output.alloc) + + writeFileSync(outputResultFilePath, JSON.stringify(output)) + writeFileSync(outputAllocFilePath, JSON.stringify(outputAlloc)) + } +} + +// Helper methods + +/** + * Returns a blockchain with an overridden "getBlock" method to return the correct block hash + * @param inputEnv the T8NEnv input, which contains a `blockHashes` list containing the respective block hashes + * @returns + */ +function getBlockchain(inputEnv: T8NEnv) { + const blockchain = new EVMMockBlockchain() + + blockchain.getBlock = async function (number?: Number) { + for (const key in inputEnv.blockHashes) { + if (Number(key) === number) { + return { + hash() { + return hexToBytes(inputEnv.blockHashes[key]) + }, + } + } + } + return { + hash() { + return zeros(32) + }, + } + } + return blockchain +} + +/** + * Normalizes txData to use with EthereumJS keywords. For instance, 1559-txs have `v` fields on the inputs, where EthereumJS expects `yParity` + * @param txData Array of txData + * @returns Normalized array of txData + */ +function normalizeTxData(txData: TypedTxData[]) { + return txData.map((data: any) => { + if (data.v !== undefined) { + data.yParity = data.v + } + if (data.gas !== undefined) { + data.gasLimit = data.gas + } + + if (data.authorizationList !== undefined) { + data.authorizationList.map((e: any) => { + if (e.yParity === undefined) { + e.yParity = e.v + } + if (e.yParity === '0x0') { + e.yParity = '0x' + } + if (e.nonce === '0x0') { + e.nonce = '0x' + } + if (e.chainId === '0x0') { + e.chainId = '0x' + } + if (e.r === '0x0') { + e.r = '0x' + } + if (e.s === '0x0') { + e.s = '0x' + } + }) + } + if (data.input !== undefined) { + data.data = data.input + } + return data as TypedTxData + }) +} diff --git a/packages/vm/test/t8n/testdata/input/alloc.json b/packages/vm/test/t8n/testdata/input/alloc.json new file mode 100644 index 0000000000..5636d0fe76 --- /dev/null +++ b/packages/vm/test/t8n/testdata/input/alloc.json @@ -0,0 +1,14 @@ +{ + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "nonce": "0x00", + "balance": "0x3635c9adc5dea00000", + "code": "0x", + "storage": {} + }, + "0x0000000000000000000000000000000000001000": { + "nonce": "0x01", + "balance": "0x00", + "code": "0x60015f55", + "storage": {} + } +} diff --git a/packages/vm/test/t8n/testdata/input/env.json b/packages/vm/test/t8n/testdata/input/env.json new file mode 100644 index 0000000000..856a1bb845 --- /dev/null +++ b/packages/vm/test/t8n/testdata/input/env.json @@ -0,0 +1,20 @@ +{ + "currentCoinbase": "0x2adc25665018aa1fe0e6bc666dac8fc2697ff9ba", + "currentGasLimit": "100000000000000000", + "currentNumber": "1", + "currentTimestamp": "1000", + "currentRandom": "0", + "currentDifficulty": "0", + "parentDifficulty": "0", + "parentTimestamp": "0", + "parentBaseFee": "7", + "parentGasUsed": "0", + "parentGasLimit": "100000000000000000", + "parentUncleHash": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "blockHashes": { + "0": "0xb8e2278dcbf8c5a9a75efba398d65f11b8021b5729b52b7cb29ab2c911b2ad0e" + }, + "ommers": [], + "withdrawals": [], + "parentHash": "0xb8e2278dcbf8c5a9a75efba398d65f11b8021b5729b52b7cb29ab2c911b2ad0e" +} diff --git a/packages/vm/test/t8n/testdata/input/txs.json b/packages/vm/test/t8n/testdata/input/txs.json new file mode 100644 index 0000000000..25d90d019d --- /dev/null +++ b/packages/vm/test/t8n/testdata/input/txs.json @@ -0,0 +1,16 @@ +[ + { + "type": "0x0", + "chainId": "0x1", + "nonce": "0x0", + "gasPrice": "0xa", + "gas": "0x186a0", + "to": "0x0000000000000000000000000000000000001000", + "value": "0x0", + "input": "0x", + "v": "0x25", + "r": "0x9069fab60fe5c8a970860130c49d2295646da4fff858330a1fd5d260cd01e562", + "s": "0x7a31960780931801bb34d06f89f19a529357f833c6cfda63135440a743949717", + "sender": "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b" + } +] diff --git a/packages/vm/test/t8n/testdata/output/alloc.json b/packages/vm/test/t8n/testdata/output/alloc.json new file mode 100644 index 0000000000..978d91deef --- /dev/null +++ b/packages/vm/test/t8n/testdata/output/alloc.json @@ -0,0 +1,20 @@ +{ + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "nonce": "0x1", + "balance": "0x3635c9adc5de996c36", + "code": "0x", + "storage": {} + }, + "0x0000000000000000000000000000000000001000": { + "nonce": "0x1", + "balance": "0x0", + "code": "0x60015f55", + "storage": { "0x00": "0x01" } + }, + "0x2adc25665018aa1fe0e6bc666dac8fc2697ff9ba": { + "balance": "0x1f923", + "nonce": "0x0", + "code": "0x", + "storage": {} + } +} diff --git a/packages/vm/test/t8n/testdata/output/result.json b/packages/vm/test/t8n/testdata/output/result.json new file mode 100644 index 0000000000..0472f7077a --- /dev/null +++ b/packages/vm/test/t8n/testdata/output/result.json @@ -0,0 +1,24 @@ +{ + "stateRoot": "0x3fa1fe742a29d9b11867fae5d162645b828bf24c533a1f3c6483b5765a5fb517", + "txRoot": "0x73b4cec7dd450173155fcf22cd9a4bbac4e41c118b3d6e9dc22202a1966e9483", + "receiptsRoot": "0xc598f69a5674cae9337261b669970e24abc0b46e6d284372a239ec8ccbf20b0a", + "logsHash": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "receipts": [ + { + "root": "0x", + "status": "0x1", + "cumulativeGasUsed": "0xa861", + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "logs": [], + "transactionHash": "0xdec8f52ec4ec24aefca6b6a1fbd8bd36bd3670ac31dc7e4dd74a1cc490a57306", + "contractAddress": "0x0000000000000000000000000000000000000000", + "gasUsed": "0xa861", + "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "transactionIndex": "0x0" + } + ], + "gasUsed": "0xa861", + "currentBaseFee": "0x7", + "withdrawalsRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421" +} diff --git a/packages/vm/test/t8n/types.ts b/packages/vm/test/t8n/types.ts new file mode 100644 index 0000000000..527be50049 --- /dev/null +++ b/packages/vm/test/t8n/types.ts @@ -0,0 +1,106 @@ +import type { + ConsolidationRequestV1, + DepositRequestV1, + WithdrawalRequestV1, +} from '@ethereumjs/util' + +export type T8NOptions = { + state: { + fork: string + reward: bigint + chainid: bigint + } + input: { + alloc: string + txs: string + env: string + } + output: { + basedir: string + result: string + alloc: string + } + log: boolean +} + +export type T8NAlloc = { + // These are all PrefixedHexString, but TypeScript fails to coerce these in some places for some reason + [address: string]: { + nonce?: string + balance: string + code?: string + storage?: { + [key: string]: string + } + } +} + +export type T8NEnv = { + currentCoinbase: string + currentGasLimit: string + currentNumber: string + currentTimestamp: string + currentRandom?: string + currentDifficulty: string + parentDifficulty: string + parentTimestamp: string + parentBaseFee: string + parentGasUsed: string + parentGasLimit: string + parentUncleHash: string + parentBlobGasUsed?: string + parentExcessBlobGas?: string + parentBeaconBlockRoot?: string + blockHashes: { + [number: string]: string + } + ommers: string[] + withdrawals: string[] + parentHash: string +} + +export type T8NRejectedTx = { index: number; error: string } + +export type T8NOutput = { + stateRoot: string + txRoot: string + receiptsRoot: string + logsHash: string + logsBloom: string + receipts: T8NReceipt[] + gasUsed: string + currentBaseFee?: string + withdrawalsRoot?: string + blobGasUsed?: string + currentExcessBlobGas?: string + requestsRoot?: string + depositRequests?: DepositRequestV1[] + withdrawalRequests?: WithdrawalRequestV1[] + consolidationRequests?: ConsolidationRequestV1[] + rejected?: T8NRejectedTx[] +} + +type T8NLog = { + address: string + topics: string[] + data: string + blockNumber: string + transactionHash: string + transactionIndex: string + blockHash: string + logIndex: string + removed: 'false' +} + +export type T8NReceipt = { + root: string + status: string + cumulativeGasUsed: string + logsBloom: string + logs: T8NLog[] + transactionHash: string + contractAddress: string + gasUsed: string + blockHash: string + transactionIndex: string +} diff --git a/packages/vm/test/util.ts b/packages/vm/test/util.ts index e63774d9e3..c2b8497a2b 100644 --- a/packages/vm/test/util.ts +++ b/packages/vm/test/util.ts @@ -11,6 +11,7 @@ import { import { Account, Address, + TypeOutput, bigIntToBytes, bytesToBigInt, bytesToHex, @@ -21,6 +22,7 @@ import { isHexString, setLengthLeft, toBytes, + toType, unpadBytes, } from '@ethereumjs/util' import { keccak256 } from 'ethereum-cryptography/keccak' @@ -300,6 +302,7 @@ export function makeBlockHeader(data: any, opts?: BlockOptions) { currentTimestamp, currentGasLimit, previousHash, + parentHash, currentCoinbase, currentDifficulty, currentExcessBlobGas, @@ -313,7 +316,7 @@ export function makeBlockHeader(data: any, opts?: BlockOptions) { const headerData: any = { number: currentNumber, coinbase: currentCoinbase, - parentHash: previousHash, + parentHash: previousHash ?? parentHash, difficulty: currentDifficulty, gasLimit: currentGasLimit, timestamp: currentTimestamp, @@ -333,7 +336,10 @@ export function makeBlockHeader(data: any, opts?: BlockOptions) { } } if (opts?.common && opts.common.gteHardfork('paris')) { - headerData['mixHash'] = currentRandom + headerData['mixHash'] = setLengthLeft( + toType(currentRandom, TypeOutput.Uint8Array)!, + 32, + ) headerData['difficulty'] = 0 } if (opts?.common && opts.common.gteHardfork('cancun')) { diff --git a/packages/vm/vitest.config.browser.mts b/packages/vm/vitest.config.browser.mts index a3b19fe16a..73613ccbc2 100644 --- a/packages/vm/vitest.config.browser.mts +++ b/packages/vm/vitest.config.browser.mts @@ -11,7 +11,9 @@ export default mergeConfig( // path.resolve is not a function 'test/api/tester/tester.config.spec.ts', // Cannot read properties of undefined (reading 'pedersen_hash') - 'test/api/EIPs/eip-6800-verkle.spec.ts' + 'test/api/EIPs/eip-6800-verkle.spec.ts', + // Uses NodeJS builtins and we don't need to fill tests in browser anyway + 'test/api/t8ntool/t8ntool.spec.ts' ], }, }