@nomicfoundation/ethereumjs-vm
Version:
An Ethereum VM implementation
759 lines (692 loc) • 23.7 kB
text/typescript
import { Block } from '@nomicfoundation/ethereumjs-block'
import { ConsensusType, Hardfork } from '@nomicfoundation/ethereumjs-common'
import { BlobEIP4844Transaction, Capability, isBlobEIP4844Tx } from '@nomicfoundation/ethereumjs-tx'
import {
Account,
Address,
BIGINT_0,
KECCAK256_NULL,
bytesToHex,
bytesToUnprefixedHex,
equalsBytes,
hexToBytes,
short,
} from '@nomicfoundation/ethereumjs-util'
import debugDefault from 'debug'
import { Bloom } from './bloom/index.js'
import type {
AfterTxEvent,
BaseTxReceipt,
EIP4844BlobTxReceipt,
PostByzantiumTxReceipt,
PreByzantiumTxReceipt,
RunTxOpts,
RunTxResult,
TxReceipt,
} from './types.js'
import type { VM } from './vm.js'
import type { AccessList, AccessListItem, Common } from '@nomicfoundation/ethereumjs-common'
import type { EVM } from '@nomicfoundation/ethereumjs-evm'
import type {
AccessListEIP2930Transaction,
FeeMarketEIP1559Transaction,
LegacyTransaction,
TypedTransaction,
} from '@nomicfoundation/ethereumjs-tx'
const { debug: createDebugLogger } = debugDefault
const debug = createDebugLogger('vm:tx')
const debugGas = createDebugLogger('vm:tx:gas')
let enableProfiler = false
const initLabel = 'EVM journal init, address/slot warming, fee validation'
const balanceNonceLabel = 'Balance/Nonce checks and update'
const logsGasBalanceLabel = 'Logs, gas usage, account/miner balances'
const cleanupAndReceiptsLabel = 'Accounts clean up, access list, journal/cache cleanup, receipts'
const entireTxLabel = 'Entire tx'
/**
* Returns the hardfork excluding the merge hf which has
* no effect on the vm execution capabilities.
*
* This is particularly useful in executing/evaluating the transaction
* when chain td is not available at many places to correctly set the
* hardfork in for e.g. vm or txs or when the chain is not fully synced yet.
*
* @returns Hardfork name
*/
function execHardfork(
hardfork: Hardfork | string,
preMergeHf: Hardfork | string
): string | Hardfork {
return hardfork !== Hardfork.Paris ? hardfork : preMergeHf
}
/**
* @ignore
*/
export async function runTx(this: VM, opts: RunTxOpts): Promise<RunTxResult> {
if (this._opts.profilerOpts?.reportAfterTx === true) {
enableProfiler = true
}
if (enableProfiler) {
const title = `Profiler run - Tx ${bytesToHex(opts.tx.hash())}`
// eslint-disable-next-line no-console
console.log(title)
// eslint-disable-next-line no-console
console.time(initLabel)
// eslint-disable-next-line no-console
console.time(entireTxLabel)
}
// create a reasonable default if no block is given
opts.block = opts.block ?? Block.fromBlockData({}, { common: this.common })
if (opts.skipHardForkValidation !== true) {
// Find and set preMerge hf for easy access later
const hfs = this.common.hardforks()
const preMergeIndex = hfs.findIndex((hf) => hf.ttd !== null && hf.ttd !== undefined) - 1
// If no pre merge hf found, set it to first hf even if its merge
const preMergeHf = preMergeIndex >= 0 ? hfs[preMergeIndex].name : hfs[0].name
// If block and tx don't have a same hardfork, set tx hardfork to block
if (
execHardfork(opts.tx.common.hardfork(), preMergeHf) !==
execHardfork(opts.block.common.hardfork(), preMergeHf)
) {
opts.tx.common.setHardfork(opts.block.common.hardfork())
}
if (
execHardfork(opts.block.common.hardfork(), preMergeHf) !==
execHardfork(this.common.hardfork(), preMergeHf)
) {
// Block and VM's hardfork should match as well
const msg = _errorMsg('block has a different hardfork than the vm', this, opts.block, opts.tx)
throw new Error(msg)
}
}
if (opts.skipBlockGasLimitValidation !== true && opts.block.header.gasLimit < opts.tx.gasLimit) {
const msg = _errorMsg('tx has a higher gas limit than the block', this, opts.block, opts.tx)
throw new Error(msg)
}
// Ensure we start with a clear warmed accounts Map
await this.evm.journal.cleanup()
if (opts.reportAccessList === true) {
this.evm.journal.startReportingAccessList()
}
await this.evm.journal.checkpoint()
if (this.DEBUG) {
debug('-'.repeat(100))
debug(`tx checkpoint`)
}
// Typed transaction specific setup tasks
if (
opts.tx.supports(Capability.EIP2718TypedTransaction) &&
this.common.isActivatedEIP(2718) === true
) {
// Is it an Access List transaction?
if (this.common.isActivatedEIP(2930) === false) {
await this.evm.journal.revert()
const msg = _errorMsg(
'Cannot run transaction: EIP 2930 is not activated.',
this,
opts.block,
opts.tx
)
throw new Error(msg)
}
if (
opts.tx.supports(Capability.EIP1559FeeMarket) &&
this.common.isActivatedEIP(1559) === false
) {
await this.evm.journal.revert()
const msg = _errorMsg(
'Cannot run transaction: EIP 1559 is not activated.',
this,
opts.block,
opts.tx
)
throw new Error(msg)
}
const castedTx = <AccessListEIP2930Transaction>opts.tx
for (const accessListItem of castedTx.AccessListJSON) {
this.evm.journal.addAlwaysWarmAddress(accessListItem.address, true)
for (const storageKey of accessListItem.storageKeys) {
this.evm.journal.addAlwaysWarmSlot(accessListItem.address, storageKey, true)
}
}
}
try {
const result = await _runTx.bind(this)(opts)
await this.evm.journal.commit()
if (this.DEBUG) {
debug(`tx checkpoint committed`)
}
return result
} catch (e: any) {
await this.evm.journal.revert()
if (this.DEBUG) {
debug(`tx checkpoint reverted`)
}
throw e
} finally {
if (this.common.isActivatedEIP(2929) === true) {
this.evm.journal.cleanJournal()
}
this.evm.stateManager.originalStorageCache.clear()
if (enableProfiler) {
// eslint-disable-next-line no-console
console.timeEnd(entireTxLabel)
const logs = (<EVM>this.evm).getPerformanceLogs()
if (logs.precompiles.length === 0 && logs.opcodes.length === 0) {
// eslint-disable-next-line no-console
console.log('No precompile or opcode execution.')
}
this.emitEVMProfile(logs.precompiles, 'Precompile performance')
this.emitEVMProfile(logs.opcodes, 'Opcodes performance')
;(<EVM>this.evm).clearPerformanceLogs()
}
}
}
async function _runTx(this: VM, opts: RunTxOpts): Promise<RunTxResult> {
const state = this.stateManager
const { tx, block } = opts
if (!block) {
throw new Error('block required')
}
/**
* The `beforeTx` event
*
* @event Event: beforeTx
* @type {Object}
* @property {Transaction} tx emits the Transaction that is about to be processed
*/
await this._emit('beforeTx', tx)
const caller = tx.getSenderAddress()
if (this.DEBUG) {
debug(
`New tx run hash=${
opts.tx.isSigned() ? bytesToHex(opts.tx.hash()) : 'unsigned'
} sender=${caller}`
)
}
if (this.common.isActivatedEIP(2929) === true) {
// Add origin and precompiles to warm addresses
const activePrecompiles = this.evm.precompiles
for (const [addressStr] of activePrecompiles.entries()) {
this.evm.journal.addAlwaysWarmAddress(addressStr)
}
this.evm.journal.addAlwaysWarmAddress(caller.toString())
if (tx.to !== undefined) {
// Note: in case we create a contract, we do this in EVMs `_executeCreate` (this is also correct in inner calls, per the EIP)
this.evm.journal.addAlwaysWarmAddress(bytesToUnprefixedHex(tx.to.bytes))
}
if (this.common.isActivatedEIP(3651) === true) {
this.evm.journal.addAlwaysWarmAddress(bytesToUnprefixedHex(block.header.coinbase.bytes))
}
}
// Validate gas limit against tx base fee (DataFee + TxFee + Creation Fee)
const txBaseFee = tx.getBaseFee()
let gasLimit = tx.gasLimit
if (gasLimit < txBaseFee) {
const msg = _errorMsg(
`tx gas limit ${Number(gasLimit)} is lower than the minimum gas limit of ${Number(
txBaseFee
)}`,
this,
block,
tx
)
throw new Error(msg)
}
gasLimit -= txBaseFee
if (this.DEBUG) {
debugGas(`Subtracting base fee (${txBaseFee}) from gasLimit (-> ${gasLimit})`)
}
if (this.common.isActivatedEIP(1559) === true) {
// EIP-1559 spec:
// Ensure that the user was willing to at least pay the base fee
// assert transaction.max_fee_per_gas >= block.base_fee_per_gas
const maxFeePerGas = 'maxFeePerGas' in tx ? tx.maxFeePerGas : tx.gasPrice
const baseFeePerGas = block.header.baseFeePerGas!
if (maxFeePerGas < baseFeePerGas) {
const msg = _errorMsg(
`Transaction's ${
'maxFeePerGas' in tx ? 'maxFeePerGas' : 'gasPrice'
} (${maxFeePerGas}) is less than the block's baseFeePerGas (${baseFeePerGas})`,
this,
block,
tx
)
throw new Error(msg)
}
}
if (enableProfiler) {
// eslint-disable-next-line no-console
console.timeEnd(initLabel)
// eslint-disable-next-line no-console
console.time(balanceNonceLabel)
}
// Check from account's balance and nonce
let fromAccount = await state.getAccount(caller)
if (fromAccount === undefined) {
fromAccount = new Account()
}
const { nonce, balance } = fromAccount
if (this.DEBUG) {
debug(`Sender's pre-tx balance is ${balance}`)
}
// EIP-3607: Reject transactions from senders with deployed code
if (
this.common.isActivatedEIP(3607) === true &&
!equalsBytes(fromAccount.codeHash, KECCAK256_NULL)
) {
const msg = _errorMsg('invalid sender address, address is not EOA (EIP-3607)', this, block, tx)
throw new Error(msg)
}
// Check balance against upfront tx cost
const upFrontCost = tx.getUpfrontCost(block.header.baseFeePerGas)
if (balance < upFrontCost) {
if (opts.skipBalance === true && fromAccount.balance < upFrontCost) {
if (tx.supports(Capability.EIP1559FeeMarket) === false) {
// if skipBalance and not EIP1559 transaction, ensure caller balance is enough to run transaction
fromAccount.balance = upFrontCost
await this.evm.journal.putAccount(caller, fromAccount)
}
} else {
const msg = _errorMsg(
`sender doesn't have enough funds to send tx. The upfront cost is: ${upFrontCost} and the sender's account (${caller}) only has: ${balance}`,
this,
block,
tx
)
throw new Error(msg)
}
}
// Check balance against max potential cost (for EIP 1559 and 4844)
let maxCost = tx.value
let blobGasPrice = BIGINT_0
let totalblobGas = BIGINT_0
if (tx.supports(Capability.EIP1559FeeMarket)) {
// EIP-1559 spec:
// The signer must be able to afford the transaction
// `assert balance >= gas_limit * max_fee_per_gas`
maxCost += tx.gasLimit * (tx as FeeMarketEIP1559Transaction).maxFeePerGas
}
if (tx instanceof BlobEIP4844Transaction) {
if (!this.common.isActivatedEIP(4844)) {
const msg = _errorMsg('blob transactions are only valid with EIP4844 active', this, block, tx)
throw new Error(msg)
}
// EIP-4844 spec
// the signer must be able to afford the transaction
// assert signer(tx).balance >= tx.message.gas * tx.message.max_fee_per_gas + get_total_data_gas(tx) * tx.message.max_fee_per_data_gas
const castTx = tx as BlobEIP4844Transaction
totalblobGas = castTx.common.param('gasConfig', 'blobGasPerBlob') * BigInt(castTx.numBlobs())
maxCost += totalblobGas * castTx.maxFeePerBlobGas
// 4844 minimum blobGas price check
if (opts.block === undefined) {
const msg = _errorMsg(
`Block option must be supplied to compute blob gas price`,
this,
block,
tx
)
throw new Error(msg)
}
blobGasPrice = opts.block.header.getBlobGasPrice()
if (castTx.maxFeePerBlobGas < blobGasPrice) {
const msg = _errorMsg(
`Transaction's maxFeePerBlobGas ${castTx.maxFeePerBlobGas}) is less than block blobGasPrice (${blobGasPrice}).`,
this,
block,
tx
)
throw new Error(msg)
}
}
if (fromAccount.balance < maxCost) {
if (opts.skipBalance === true && fromAccount.balance < maxCost) {
// if skipBalance, ensure caller balance is enough to run transaction
fromAccount.balance = maxCost
await this.evm.journal.putAccount(caller, fromAccount)
} else {
const msg = _errorMsg(
`sender doesn't have enough funds to send tx. The max cost is: ${maxCost} and the sender's account (${caller}) only has: ${balance}`,
this,
block,
tx
)
throw new Error(msg)
}
}
if (opts.skipNonce !== true) {
if (nonce !== tx.nonce) {
const msg = _errorMsg(
`the tx doesn't have the correct nonce. account has nonce of: ${nonce} tx has nonce of: ${tx.nonce}`,
this,
block,
tx
)
throw new Error(msg)
}
}
let gasPrice: bigint
let inclusionFeePerGas: bigint
// EIP-1559 tx
if (tx.supports(Capability.EIP1559FeeMarket)) {
const baseFee = block.header.baseFeePerGas!
inclusionFeePerGas =
(tx as FeeMarketEIP1559Transaction).maxPriorityFeePerGas <
(tx as FeeMarketEIP1559Transaction).maxFeePerGas - baseFee
? (tx as FeeMarketEIP1559Transaction).maxPriorityFeePerGas
: (tx as FeeMarketEIP1559Transaction).maxFeePerGas - baseFee
gasPrice = inclusionFeePerGas + baseFee
} else {
// Have to cast as legacy tx since EIP1559 tx does not have gas price
gasPrice = (<LegacyTransaction>tx).gasPrice
if (this.common.isActivatedEIP(1559) === true) {
const baseFee = block.header.baseFeePerGas!
inclusionFeePerGas = (<LegacyTransaction>tx).gasPrice - baseFee
}
}
// EIP-4844 tx
let blobVersionedHashes
if (tx instanceof BlobEIP4844Transaction) {
blobVersionedHashes = (tx as BlobEIP4844Transaction).blobVersionedHashes
}
// Update from account's balance
const txCost = tx.gasLimit * gasPrice
const blobGasCost = totalblobGas * blobGasPrice
fromAccount.balance -= txCost
fromAccount.balance -= blobGasCost
if (opts.skipBalance === true && fromAccount.balance < BIGINT_0) {
fromAccount.balance = BIGINT_0
}
await this.evm.journal.putAccount(caller, fromAccount)
if (this.DEBUG) {
debug(`Update fromAccount (caller) balance (-> ${fromAccount.balance}))`)
}
if (enableProfiler) {
// eslint-disable-next-line no-console
console.timeEnd(balanceNonceLabel)
// eslint-disable-next-line no-console
console.log('[ For execution see detailed table output ]')
}
/*
* Execute message
*/
const { value, data, to } = tx
if (this.DEBUG) {
debug(
`Running tx=${
tx.isSigned() ? bytesToHex(tx.hash()) : 'unsigned'
} with caller=${caller} gasLimit=${gasLimit} to=${
to?.toString() ?? 'none'
} value=${value} data=${short(data)}`
)
}
const results = (await this.evm.runCall({
block,
gasPrice,
caller,
gasLimit,
to,
value,
data,
blobVersionedHashes,
})) as RunTxResult
if (enableProfiler) {
// eslint-disable-next-line no-console
console.time(logsGasBalanceLabel)
}
if (this.DEBUG) {
debug(`Update fromAccount (caller) nonce (-> ${fromAccount.nonce})`)
}
if (this.DEBUG) {
const { executionGasUsed, exceptionError, returnValue } = results.execResult
debug('-'.repeat(100))
debug(
`Received tx execResult: [ executionGasUsed=${executionGasUsed} exceptionError=${
exceptionError !== undefined ? `'${exceptionError.error}'` : 'none'
} returnValue=${short(returnValue)} gasRefund=${results.gasRefund ?? 0} ]`
)
}
/*
* Parse results
*/
// Generate the bloom for the tx
results.bloom = txLogsBloom(results.execResult.logs, this.common)
if (this.DEBUG) {
debug(`Generated tx bloom with logs=${results.execResult.logs?.length}`)
}
// Calculate the total gas used
results.totalGasSpent = results.execResult.executionGasUsed + txBaseFee
if (this.DEBUG) {
debugGas(`tx add baseFee ${txBaseFee} to totalGasSpent (-> ${results.totalGasSpent})`)
}
// Add blob gas used to result
if (isBlobEIP4844Tx(tx)) {
results.blobGasUsed = totalblobGas
}
// Process any gas refund
let gasRefund = results.execResult.gasRefund ?? BIGINT_0
results.gasRefund = gasRefund
const maxRefundQuotient = this.common.param('gasConfig', 'maxRefundQuotient')
if (gasRefund !== BIGINT_0) {
const maxRefund = results.totalGasSpent / maxRefundQuotient
gasRefund = gasRefund < maxRefund ? gasRefund : maxRefund
results.totalGasSpent -= gasRefund
if (this.DEBUG) {
debug(`Subtract tx gasRefund (${gasRefund}) from totalGasSpent (-> ${results.totalGasSpent})`)
}
} else {
if (this.DEBUG) {
debug(`No tx gasRefund`)
}
}
results.amountSpent = results.totalGasSpent * gasPrice
// Update sender's balance
fromAccount = await state.getAccount(caller)
if (fromAccount === undefined) {
fromAccount = new Account()
}
const actualTxCost = results.totalGasSpent * gasPrice
const txCostDiff = txCost - actualTxCost
fromAccount.balance += txCostDiff
await this.evm.journal.putAccount(caller, fromAccount)
if (this.DEBUG) {
debug(
`Refunded txCostDiff (${txCostDiff}) to fromAccount (caller) balance (-> ${fromAccount.balance})`
)
}
// Update miner's balance
let miner
if (this.common.consensusType() === ConsensusType.ProofOfAuthority) {
miner = block.header.cliqueSigner()
} else {
miner = block.header.coinbase
}
let minerAccount = await state.getAccount(miner)
if (minerAccount === undefined) {
minerAccount = new Account()
}
// add the amount spent on gas to the miner's account
results.minerValue =
this.common.isActivatedEIP(1559) === true
? results.totalGasSpent * inclusionFeePerGas!
: results.amountSpent
minerAccount.balance += results.minerValue
// Put the miner account into the state. If the balance of the miner account remains zero, note that
// the state.putAccount function puts this into the "touched" accounts. This will thus be removed when
// we clean the touched accounts below in case we are in a fork >= SpuriousDragon
await this.evm.journal.putAccount(miner, minerAccount)
if (this.DEBUG) {
debug(`tx update miner account (${miner}) balance (-> ${minerAccount.balance})`)
}
if (enableProfiler) {
// eslint-disable-next-line no-console
console.timeEnd(logsGasBalanceLabel)
// eslint-disable-next-line no-console
console.time(cleanupAndReceiptsLabel)
}
/*
* Cleanup accounts
*/
if (results.execResult.selfdestruct !== undefined) {
for (const addressToSelfdestructHex of results.execResult.selfdestruct) {
const address = new Address(hexToBytes(addressToSelfdestructHex))
if (this.common.isActivatedEIP(6780)) {
// skip cleanup of addresses not in createdAddresses
if (!results.execResult.createdAddresses!.has(address.toString())) {
continue
}
}
await this.evm.journal.deleteAccount(address)
if (this.DEBUG) {
debug(`tx selfdestruct on address=${address}`)
}
}
}
if (opts.reportAccessList === true && this.common.isActivatedEIP(2930)) {
// Convert the Map to the desired type
const accessList: AccessList = []
for (const [address, set] of this.evm.journal.accessList!) {
const addressPrefixed = '0x' + address
const item: AccessListItem = {
address: addressPrefixed,
storageKeys: [],
}
for (const slot of set) {
const slotPrefixed = '0x' + slot
item.storageKeys.push(slotPrefixed)
}
accessList.push(item)
}
results.accessList = accessList
}
await this.evm.journal.cleanup()
state.originalStorageCache.clear()
// Generate the tx receipt
const gasUsed = opts.blockGasUsed !== undefined ? opts.blockGasUsed : block.header.gasUsed
const cumulativeGasUsed = gasUsed + results.totalGasSpent
results.receipt = await generateTxReceipt.bind(this)(
tx,
results,
cumulativeGasUsed,
totalblobGas,
blobGasPrice
)
if (enableProfiler) {
// eslint-disable-next-line no-console
console.timeEnd(cleanupAndReceiptsLabel)
}
/**
* The `afterTx` event
*
* @event Event: afterTx
* @type {Object}
* @property {Object} result result of the transaction
*/
const event: AfterTxEvent = { transaction: tx, ...results }
await this._emit('afterTx', event)
if (this.DEBUG) {
debug(
`tx run finished hash=${
opts.tx.isSigned() ? bytesToHex(opts.tx.hash()) : 'unsigned'
} sender=${caller}`
)
}
return results
}
/**
* @method txLogsBloom
* @private
*/
function txLogsBloom(logs?: any[], common?: Common): Bloom {
const bloom = new Bloom(undefined, common)
if (logs) {
for (let i = 0; i < logs.length; i++) {
const log = logs[i]
// add the address
bloom.add(log[0])
// add the topics
const topics = log[1]
for (let q = 0; q < topics.length; q++) {
bloom.add(topics[q])
}
}
}
return bloom
}
/**
* Returns the tx receipt.
* @param this The vm instance
* @param tx The transaction
* @param txResult The tx result
* @param cumulativeGasUsed The gas used in the block including this tx
* @param blobGasUsed The blob gas used in the tx
* @param blobGasPrice The blob gas price for the block including this tx
*/
export async function generateTxReceipt(
this: VM,
tx: TypedTransaction,
txResult: RunTxResult,
cumulativeGasUsed: bigint,
blobGasUsed?: bigint,
blobGasPrice?: bigint
): Promise<TxReceipt> {
const baseReceipt: BaseTxReceipt = {
cumulativeBlockGasUsed: cumulativeGasUsed,
bitvector: txResult.bloom.bitvector,
logs: txResult.execResult.logs ?? [],
}
let receipt
if (this.DEBUG) {
debug(
`Generate tx receipt transactionType=${
tx.type
} cumulativeBlockGasUsed=${cumulativeGasUsed} bitvector=${short(baseReceipt.bitvector)} (${
baseReceipt.bitvector.length
} bytes) logs=${baseReceipt.logs.length}`
)
}
if (!tx.supports(Capability.EIP2718TypedTransaction)) {
// Legacy transaction
if (this.common.gteHardfork(Hardfork.Byzantium) === true) {
// Post-Byzantium
receipt = {
status: txResult.execResult.exceptionError !== undefined ? 0 : 1, // Receipts have a 0 as status on error
...baseReceipt,
} as PostByzantiumTxReceipt
} else {
// Pre-Byzantium
const stateRoot = await this.stateManager.getStateRoot()
receipt = {
stateRoot,
...baseReceipt,
} as PreByzantiumTxReceipt
}
} else {
// Typed EIP-2718 Transaction
if (isBlobEIP4844Tx(tx)) {
receipt = {
blobGasUsed,
blobGasPrice,
status: txResult.execResult.exceptionError ? 0 : 1,
...baseReceipt,
} as EIP4844BlobTxReceipt
} else {
receipt = {
status: txResult.execResult.exceptionError ? 0 : 1,
...baseReceipt,
} as PostByzantiumTxReceipt
}
}
return receipt
}
/**
* Internal helper function to create an annotated error message
*
* @param msg Base error message
* @hidden
*/
function _errorMsg(msg: string, vm: VM, block: Block, tx: TypedTransaction) {
const blockErrorStr = 'errorStr' in block ? block.errorStr() : 'block'
const txErrorStr = 'errorStr' in tx ? tx.errorStr() : 'tx'
const errorMsg = `${msg} (${vm.errorStr()} -> ${blockErrorStr} -> ${txErrorStr})`
return errorMsg
}