diff --git a/src/__tests__/forks.spec.ts b/src/__tests__/forks.spec.ts index 8531d9e..7750a78 100644 --- a/src/__tests__/forks.spec.ts +++ b/src/__tests__/forks.spec.ts @@ -228,6 +228,9 @@ describe('fork registry & resolve', () => { 'Increase access list cost', ) expect(caps.eipIntroductions.find((e) => e.eip === 7981)?.coverage).toBe('supported') + expect(caps.eipIntroductions.find((e) => e.eip === 7981)?.observableTools).toEqual([ + 'run_transaction', + ]) expect(caps.eipIntroductions.find((e) => e.eip === 7997)?.name).toBe( 'Deterministic factory contract', ) @@ -283,8 +286,9 @@ describe('fork registry & resolve', () => { expect(twinRows.every((row) => row.coverage === 'twin')).toBe(true) const supported = EIP_INTRODUCTIONS.filter((row) => row.coverage === 'supported') expect(supported.map((row) => row.eip)).toEqual([7981, 8246]) + expect(supported.find((row) => row.eip === 7981)?.observableShapes).toEqual(['transaction']) + expect(supported.find((row) => row.eip === 8246)?.observableShapes).toBeUndefined() for (const row of supported) { - expect(row.observableShapes).toBeUndefined() expect(EIP_MODULES.some((module) => module.eip === row.eip)).toBe(false) expect(caps.eips.some((entry) => entry.eip === row.eip)).toBe(false) } diff --git a/src/__tests__/runTransaction.accessList.spec.ts b/src/__tests__/runTransaction.accessList.spec.ts new file mode 100644 index 0000000..0edfe8e --- /dev/null +++ b/src/__tests__/runTransaction.accessList.spec.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' + +import { runTransaction } from '../transaction/runTransaction.js' +import { FUNDED_RECIPIENT, STATE_GAS_CALLER } from './fixtures/eip8037.js' + +const FUNDED = [{ address: FUNDED_RECIPIENT, balance: '1000000000000000000' }] +const ZERO_SLOT = `0x${'00'.repeat(32)}` + +describe('runTransaction accessList (EIP-7981)', () => { + it('rejects accessList together with authorizationList', async () => { + await expect( + runTransaction({ + from: STATE_GAS_CALLER, + to: FUNDED_RECIPIENT, + accessList: [{ address: FUNDED_RECIPIENT, storageKeys: [ZERO_SLOT] }], + authorizationList: [ + { + chainId: '0x01', + address: FUNDED_RECIPIENT, + nonce: '0x00', + yParity: '0x00', + r: '0x01', + s: '0x02', + }, + ], + accounts: FUNDED, + fork: { baseHardfork: 'glamsterdam' }, + }), + ).rejects.toThrow(/mutually exclusive/) + }) + + it('charges access-list bytes at the floor on Glamsterdam only', async () => { + const amsterdam = await runTransaction({ + from: STATE_GAS_CALLER, + to: FUNDED_RECIPIENT, + value: '0', + accessList: [{ address: FUNDED_RECIPIENT, storageKeys: [ZERO_SLOT] }], + accounts: FUNDED, + fork: { baseHardfork: 'glamsterdam' }, + }) + const fusaka = await runTransaction({ + from: STATE_GAS_CALLER, + to: FUNDED_RECIPIENT, + value: '0', + accessList: [{ address: FUNDED_RECIPIENT, storageKeys: [ZERO_SLOT] }], + accounts: FUNDED, + fork: { baseHardfork: 'fusaka' }, + }) + + expect(amsterdam.success).toBe(true) + expect(fusaka.success).toBe(true) + expect(amsterdam.gasUsed).toBe('23228') + expect(fusaka.gasUsed).toBe('25300') + }) +}) diff --git a/src/forks/introductions.ts b/src/forks/introductions.ts index 8ad416a..7a948d0 100644 --- a/src/forks/introductions.ts +++ b/src/forks/introductions.ts @@ -365,10 +365,11 @@ export const EIP_INTRODUCTIONS: EipIntroduction[] = [ eip: 7981, name: 'Increase access list cost', summary: - 'Active on Glamsterdam. Access-list bytes pay 64 gas each so they cannot dodge the calldata floor. Run a transaction on that fork. Leave eips empty.', + 'Active on Glamsterdam. Access-list bytes pay 64 gas each so they cannot dodge the calldata floor. Pass accessList on run_transaction. Leave eips empty.', keywords: ['7981', 'access list', 'calldata floor'], introducedAt: 'glamsterdam', coverage: 'supported', + observableShapes: ['transaction'], }, { eip: 7997, diff --git a/src/modules/eip-7976/index.ts b/src/modules/eip-7976/index.ts index 6417207..666372b 100644 --- a/src/modules/eip-7976/index.ts +++ b/src/modules/eip-7976/index.ts @@ -4,8 +4,8 @@ * canonicalSource: website/src/explorations/eip-7976/canonical.ts * * Callers supply calldata. Catalog describes the floor; it does not ship demo transactions. - * EIP-7981 (access-list bytes at the same rate) is coverage `supported` — this verb - * does not take an access list. + * EIP-7981 (access-list bytes at the same rate) is coverage `supported` — pass + * `accessList` on this verb; leave 7981 out of `eips`. */ import type { EipCapability } from '../../types/index.js' import { @@ -30,5 +30,5 @@ export const EIP_7976_MODULE: EipCapability = { testReleaseUrl: GLAMSTERDAM_DEVNET_TEST_RELEASE_URL, testReleaseName: GLAMSTERDAM_DEVNET_TEST_RELEASE_NAME, notes: - 'gasUsed is the observation. The floor binds when the call does little besides carrying data. EIP-7981 prices access-list bytes at the same 64 gas rate and is already active on Glamsterdam; leave 7981 out of eips. This verb does not accept an access list.', + 'gasUsed is the observation. The floor binds when the call does little besides carrying data. EIP-7981 prices access-list bytes at the same 64 gas rate on Glamsterdam — use accessList on run_transaction; leave 7981 out of eips.', } diff --git a/src/transaction/lab.ts b/src/transaction/lab.ts index c38488c..480ea8b 100644 --- a/src/transaction/lab.ts +++ b/src/transaction/lab.ts @@ -1,4 +1,7 @@ import { + type AccessList2930Tx, + type AccessList2930TxData, + createAccessList2930Tx, createEOACode7702Tx, createLegacyTx, type EOACode7702Tx, @@ -38,7 +41,10 @@ export const LAB_DEFAULT_TIMESTAMP = 1n * receive the impersonated sender). Recompute the EIP-2780 extras with `from` * so a self-send is 12,000 and a zero-value call is 15,000. */ -function bindIntrinsicGasToSender(tx: LegacyTx | EOACode7702Tx, from: Address): void { +function bindIntrinsicGasToSender( + tx: LegacyTx | EOACode7702Tx | AccessList2930Tx, + from: Address, +): void { const intrinsic = tx.getIntrinsicGas.bind(tx) tx.getIntrinsicGas = () => { const base = intrinsic() @@ -73,6 +79,34 @@ export function createImpersonatedTx(opts: { return tx } +export function createImpersonated2930Tx(opts: { + common: ReturnType['common'] + from: Address + to?: Address + value: bigint + data: Uint8Array + gasLimit: bigint + accessList: AccessList2930TxData['accessList'] + nonce?: bigint +}): AccessList2930Tx { + const tx = createAccessList2930Tx( + { + chainId: opts.common.chainId(), + nonce: opts.nonce ?? 0n, + gasLimit: opts.gasLimit, + gasPrice: LAB_GAS_PRICE, + to: opts.to, + value: opts.value, + data: opts.data, + accessList: opts.accessList, + }, + { common: opts.common, freeze: false }, + ) + tx.getSenderAddress = () => opts.from + bindIntrinsicGasToSender(tx, opts.from) + return tx +} + export function createImpersonated7702Tx(opts: { common: ReturnType['common'] from: Address diff --git a/src/transaction/parseAccessList.ts b/src/transaction/parseAccessList.ts new file mode 100644 index 0000000..12988be --- /dev/null +++ b/src/transaction/parseAccessList.ts @@ -0,0 +1,21 @@ +import { + type AccessList as TxAccessList, + type AccessList2930TxData, + accessListJSONToBytes, +} from '@ethereumjs/tx' + +import type { RunTransactionAccessListItem } from '../types/index.js' +import { EngineError } from '../types/index.js' + +export function accessListJsonToBytes( + items: RunTransactionAccessListItem[], +): AccessList2930TxData['accessList'] { + if (items.length === 0) { + throw new EngineError('accessList must not be empty', 'invalid_input') + } + const json = items.map((entry) => ({ + address: entry.address, + storageKeys: entry.storageKeys ?? [], + })) + return accessListJSONToBytes(json as TxAccessList) +} diff --git a/src/transaction/runTransaction.ts b/src/transaction/runTransaction.ts index d983b44..802ccce 100644 --- a/src/transaction/runTransaction.ts +++ b/src/transaction/runTransaction.ts @@ -17,6 +17,7 @@ import { EngineError } from '../types/index.js' import { applyPrefundAccounts, applyPrefundStorage, + createImpersonated2930Tx, createImpersonated7702Tx, createImpersonatedTx, emptyTransactionResult, @@ -27,6 +28,7 @@ import { senderUpfrontCost, transactionResultFromRunTx, } from './lab.js' +import { accessListJsonToBytes } from './parseAccessList.js' export async function runTransaction(input: RunTransactionInput): Promise { if (input.from === undefined || input.from.trim() === '') { @@ -43,6 +45,12 @@ export async function runTransaction(input: RunTransactionInput): Promise