Releases: wighawag/webevm
Release list
webevm@0.5.0
Minor Changes
-
c391f68: Renamed the package from
embedded-eth-nodetowebevm.This is a rename, not a rewrite: the api, the exports and the four subpaths are unchanged, so migrating is
npm i webevm, removingembedded-eth-node, and rewriting the import specifier:-import {createNode} from 'embedded-eth-node'; -import {createRevmEngine} from 'embedded-eth-node/revm'; +import {createNode} from 'webevm'; +import {createRevmEngine} from 'webevm/revm';
/revm,/worker-entry,/worker-hostand/worker-clientmove with it under the new name.createNode()and every other exported symbol keep their names.embedded-eth-nodeis deprecated on npm at 0.4.0 and receives no further releases. Why the name changed isdocs/adr/0011-the-package-is-named-webevm.md.
embedded-eth-node@0.4.0
Minor Changes
-
fe793e0:
eth_estimateGasnow returns the smallest gas LIMIT at which a transaction succeeds, found by re-executing it, instead of the gas it CONSUMES. The old answer (executionGasUsed+ intrinsic gas + the request's access list) was exact and was the wrong question: a client turns this number into the transaction's gas limit, and under EIP-150's 63/64 rule aCALLorCREATEis forwarded at most 63/64 of the gas remaining at that point, so a limit equal to total consumption starves the sub-call by the 1/64 the outer frame keeps.The failure it fixes, as reported. Deploying through the standard Arachnid CREATE2 factory (
0x4e59b448...) with a limit taken from this node's owneth_estimateGas: the funding transfer mined, the factory itself deployed (140 bytes of code), and the deployment THROUGH the factory came backstatus: 0x0with no contract created. The caller then pointed a proxy at the address that was never deployed, and every call to it returned0xrather than failing — so the receipt that named the problem was three transactions upstream of the symptom. The node's own suite had already recorded the same shape from the other side, in the comment ontest/helpers/post-state.tsexplaining why every transaction there carries an explicit gas limit.What the method does now, which is what geth has always done:
- one run at the UPPER BOUND — the request's
gasif it named one, capped at the block gas limit, since a limit above that is refused at submit and the node must never recommend a number it will not accept. If the request fails there it fails everywhere, and the method throws; - one probe at the MEASURED CONSUMPTION. A request that makes no sub-call and no create succeeds at exactly what it consumes, so a value transfer is still 21000, a plain deployment is still intrinsic + execution, and the common case costs ONE extra execution and stops;
- otherwise a bounded search above it, bracketing from below at the scale of the 63/64 rule before bisecting, so a window that starts 30,000,000 gas wide is never walked down from the top. The answer is the MINIMUM: one gas less fails.
Where "the minimum" is exact, and the two places it is an over-estimate instead. For a request carrying no access list the search is exact to the gas, which is what the battery asserts by mining at
estimate - 1and requiringstatus: 0x0. Two cases sit deliberately above the true minimum, both erring in the safe direction (unused gas is not charged; an under-estimate is a transaction that runs out of gas): a request that names an EIP-2930 access list, because the charge is added while the probe underneath still prices those entries cold — the pre-existing skew documented onaccessListGas, unchanged by this work — and a gas-sensitive contract that readsGASand spends what it finds, which can exhaust the probe budget and get the smallest limit the search has proven to work.The cost, measured at the seam. A request that succeeds at what it consumes costs exactly TWO engine calls; a realistic single-level 63/64 shortfall (the 3,099 gas the CREATE2 case measures) costs 15. Both are pinned as assertions against stub engines rather than described, the second by reproducing the shortfall with arithmetic so the number is deterministic.
A request that cannot succeed at any limit gets an error, never a plausible-looking number — and the error says WHICH problem it is. A REVERT keeps the leading clause
execution revertedwith the callee's bytes ondata(that pair is what viem decodes), and the message now adds what only this method knows: that no gas limit would have helped, the revert reason decoded fromError(string)when there is one, and the engine's own words. A request that is simply too big for the allowance is-32000"gas required exceeds allowance" instead, geth's vocabulary and the same code this node already answers a transaction whose gas limit it refuses: nothing reverted, so a client reading a revert there would hunt for return data that does not exist, and a user would be told their contract failed when their gas allowance did. Both shapes of it are covered — an allowance below the intrinsic floor, where nothing executes, and one that starts the transaction and cannot finish it. The two are told apart STRUCTURALLY (did the request spend everything it was given and return nothing?), never by matching on an engine's words for running out of gas, which are not the same words on the two engines.Identical on both engines, asserted directly: same estimates (21000 / 266748 / 270826 for the three shapes above), same receipt, same
-32000for an out-of-gas at the allowance and the same code-3-with-data for a revert, on@ethereumjs/evmand revm alike.eth_fillTransactionfills itsgasfrom the same search, for the same reason: what it fills is a limit. It still deliberately does not charge the request's access list, because the transaction it returns carries none.What did NOT change: the intrinsic-gas arithmetic (base, calldata, the EIP-3860 initcode term) and the EIP-2930 access-list charge, which are now the FLOOR of the search rather than the answer; every estimate is still at least that, so the intrinsic-gas refusal still points callers here safely. Gas CONSUMED is still verified equal to the reference
runTx'stotalGasSpent— the conformance battery's two estimate steps now assert that against the node's own receiptgasUsed, which is the value that has that property, and hold the estimate to being a usable limit.The default entry point's bundle baseline is re-pinned 422.5 -> 424.0 KB raw / 127.6 -> 128.2 KB gzip. The 1.5 KB is the search itself, the
Error(string)revert-reason decoder that puts the reason into the failure, and the prose of the two refusals this method can now throw. It is paid by every consumer including the JS-only one, and it is the feature: an estimate a transaction does not survive is what this change exists to remove.Covered by a new battery (
test/estimate-gas.spec.ts) built on the real CREATE2 factory, deployed by its own keyless presigned transaction: the deployment through it mines at the estimate and fails one gas below, a transfer is 21000 and a deployment equals its owngasUsed, a revert produces an error carryingError("boom"), and one estimate against a stub engine costs exactly two engine calls. The engine-seam battery's call count moves from 3 to 5 for the same reason. - one run at the UPPER BOUND — the request's
-
eb1d5e0: New subpath
embedded-eth-node/worker-host: host a node in a Worker that builds its OWN engine, without hand-copying theSlimNodeproxy. Additive:embedded-eth-node/worker-entrystill exposes the node at import time, still exportsworkerApi, and nothing on the main thread changes.// my-worker.ts: the whole module import {exposeNode} from 'embedded-eth-node/worker-host'; import {createRevmEngine} from 'embedded-eth-node/revm'; import wasm from 'revm-wasm/revm.wasm'; exposeNode({createEngine: () => createRevmEngine({wasm})});
Why it exists: an engine cannot cross a thread boundary (
createWorkerNode({engine})is refused, since the options are structured-cloned and anEngineis a function-bearing object holding thread-bound live state), andworker-entrydeliberately builds no engine for you, because that would mean the core naming engines by string and importing them, which ADR 0006 refuses (a JS-only consumer would pay for revm). So a consumer had to write their own worker module, andworker-entrycalls comlink'sexpose()at MODULE SCOPE, so it could not be imported to reuse the proxy: importing it would have exposed the wrong api on that thread. Everybody copied the proxy block instead.worker-hostisworker-entrywithout the side effect, andworker-entryis now that module plus its one line.createEngineis a FACTORY, called once percreateNode(), because building an engine is async and because one engine instance serves one node (connect()binds it, so an engine value would work for the first node and throw for the second). Passing a built engine there is refused with a message naming both forms. The main thread's options (chainId,miningConfig, and the rest) still travel throughcreateWorkerNode()unchanged; only the engine is the worker's.The proxy now exists in exactly ONE place, and staying complete is the compiler's job rather than anyone's memory: it is a literal typed
SlimNode, so a field added toSlimNodelater fails the build there, andworker-client'sas anycasts (which are what hidsenderModewhen it was silently dropped from that block for a month) are gone. The Worker test additionally compares a Worker-backed node against a main-thread one field by field, naming no field, so the same class of gap is caught at runtime for any future one too.The README's revm-in-a-Worker recipe is now shown INLINE (it is four lines), and its pointer at this repository's executed example says plainly that those are repository files rather than something in your
node_modules. The publishedfileslist is unchanged.src/index.tsis untouched, so the default entry point's bundle is unmoved and the benchmark baseline is not re-pinned. Decisions taken while building this, including the naming and the two rejected alternatives:docs/spikes/make-the-worker-node-proxy-reusable-instead-of-hand-copied/decisions.md.
Patch Changes
-
ce91700: A misused
exposeNode({createEngine})now REJECTS the main thread'screateWorkerNode()instead of hanging it forever.The refusal added with
embedded-eth-node/worker-host(passing an engine, or the promise of one, where the factory belongs) threw while the worker module was still EVALUATING. That is before comlink's ...
embedded-eth-node@0.3.0
Minor Changes
-
cf377cd: The block gas limit is ENFORCED, on both engines, and
blockGasLimitis what lifts it (behaviour change on the default engine). A transaction whose gas limit exceeds the block's is now REFUSED. It used to be mined on the default@ethereumjs/evmengine, because the node passed@ethereumjs/vm'sskipBlockGasLimitValidationtorunTx, and REJECTED on revm, which expresses the same relaxation as a simulation switch it refuses to combine with committing. Same node, same transaction, two answers depending on which EVM was installed. That flag is gone.If you want enormous gas limits, ask for them:
createNode({blockGasLimit: 100_000_000n})(default still30_000_000n, so nothing changes for a node that never asked for more). The permissiveness stops being a hidden per-transaction exemption that one engine cannot honour and becomes a visible property of the block that BOTH honour by construction, because both are handed the same block. It is also more honest: the transaction is no longer accepted against a limit the block does not have, andGASLIMITreports the configured number to a contract, as doeseth_getBlockByNumber.The refusal names what was exceeded and what raises it, identically on every engine, because the NODE answers it at submit rather than each EVM at execution: the block is the node's half of the seam on any engine, and neither EVM's own words carry the numbers or know that
blockGasLimitexists (@ethereumjs/vmsays "tx has a higher gas limit than the block", revm saysTransaction(CallerGasLimitMoreThanBlock)). It is anRpcErrorwith code-32000(the range geth uses for a transaction its pool refuses), thrown by theeth_sendRawTransaction*call that submitted it, so an over-limit transaction never enters the pending queue and cannot take a latermine()batch down with it. Both engines still enforce the same rule underneath as the backstop.The default read budget is deliberately NOT tied to
blockGasLimit. Aneth_callthat names nogasstill gets a fixed 30,000,000, so raising the block gas limit does not silently buy every unbudgeted read a proportionally longer runaway before it halts (and the revm engine's Osaka refusal quotes that budget as a fixed number). Passgason the call when you want a bigger one. The reasoning is recorded at the code site insrc/node.ts.This supersedes the "one asymmetry stated rather than worked around" note in the revm-transactions entry of this same release: the asymmetry is not shipped, it is removed.
The default entry's bundle-size baseline is re-pinned 417.2 -> 417.8 KB raw / 125.7 -> 126.0 KB gzip. The 0.6 KB is the refusal's prose in the core bundle, paid by every consumer including the JS-only one, and it is the feature: an error that does not say which limit was exceeded or which knob raises it is the thing this change exists to remove.
Covered by the differential conformance battery on BOTH engines and both state modes (
block gas limit refuses an over-limit tx; blockGasLimit lifts it), asserting the NODE's own answer rather than the reference's, because that battery's referencerunTxpassesskipBlockGasLimitValidationitself and is therefore blind to exactly this bug. Reference gas is unchanged (number()2446,sumTo(2000)498689,keccakLoop(2000)1107052 ->0x26812edce879c319b6c7baf99bf3c2f65aa4b81b023d72cd6dfc7ac31caafe5a). -
085aa37: BREAKING (no alias): the engine seam now covers TRANSACTIONS as well as reads, so
ReadEngineisEngineandnode.readEngineisnode.engine. No behaviour changes.The node had ONE seam for reads and a HARDCODED path for writes: an injected engine
answeredeth_call, while transactions bypassed it and went straight to
@ethereumjs/vm'srunTx. The seam is now ONE interface with TWO operations —
call(read-only) andtransact(executes and commits) — the default
@ethereumjs/evmengine implements both, and the node's mining path executes
through the engine rather than callingrunTxitself.Renamed on the public surface, with no deprecation alias, because a shim would
have left two words for one concept from the day it landed:ReadEngine→Engine(and it gainedtransact)ReadEngineContext→EngineContextReadEngineInfo→EngineInfoSlimNode.readEngine→SlimNode.engine(same{id}value, over comlink too)ReadCallRequest/ReadCallResultkeep their names: they are the READ
operation's request and result, and that is still what they are.
New, and the point of the change:
TransactionRequest(the signed transaction the
node parsed, plus the block it is mined in) andTransactionResult— what a
RECEIPT needs from an EVM and nothing else:status,gasUsed(net of refunds),
effectiveGasPrice,logsin emission order (TransactionLog: address, topics,
data as raw bytes),logsBloom, andcreatedAddress.runTx'samountSpent,
gasRefund,minerValue,accessListandexecResultare deliberately absent:
no receipt reads them, and a field that exists only because one engine returns it
is what makes two engines incomparable.effectiveGasPricenow comes from the
engine that executed the transaction (the node's legacy-safe computation moved
behind the default engine), so the fee arithmetic has one implementation per engine
and none in the node.What did NOT move, on any engine: block construction,
cumulativeGasUsed, receipt
assembly, the RPC layer, transaction parsing and sender recovery are still the
node's.@ethereumjs/vm'sskipBlockGasLimitValidation/skipHardForkValidation
stayed INSIDE the default engine rather than becoming neutral request fields — they
are one EVM's vocabulary, andrevm-wasmrefuses to combine its equivalent
relaxation with committing, so a neutral field would have been a promise another
engine could only throw at. The reasoning is at the code site insrc/engine.ts.
(Later in this same release,skipBlockGasLimitValidationwas DROPPED rather than
relocated. See the block-gas-limit entry: a relaxation only one engine could honour
was the divergence, wherever it lived.skipHardForkValidationstill lives there.)transactwas OPTIONAL, transitionally: an engine that omitted it left transactions
on the node's own@ethereumjs/vm, which is exactly what every non-default engine
did before this change, andcreateRevmEngine()fromembedded-eth-node/revmwas
in that state — it served the seam's read half only, so a node with it installed
still mined on@ethereumjs/vmand a receipt could not be attributed to
node.engine.id. That state did not survive the release: the sibling entry for
revm-executes-the-first-transaction-with-commitmakestransactREQUIRED, deletes
that fallback and gives the revm engine its write half, so no published version ever
shipped the optional marker (written in the past tense for that reason — the two
entries land under one version heading). Atransactthat is present but is not a
function is refused at construction, next to the existing engine refusals, because a
half-built engine silently mining somewhere else is the same class of lie those
refusals exist to prevent.No behaviour change anywhere: reference gas is identical (
number()2446,
sumTo(2000)498689,keccakLoop(2000)1107052 →
0x26812edce879c319b6c7baf99bf3c2f65aa4b81b023d72cd6dfc7ac31caafe5a), and the
differential conformance battery (both state modes, and again with the revm engine
installed), the GeneralStateTests, trusted-sender, persistence, worker and
viem-surface suites all pass unchanged.test/engine-seam.spec.tsgained the bar
for the new half: an engine whosetransactreturns values no EVM would produce
for a 21000-gas transfer, so the receipt proves the ENGINE executed the transaction
rather thanrunTxhaving been called anyway. The default entry's bundle-size
baseline is re-pinned 416.3 → 417.1 KB raw / 125.4 → 125.7 KB gzip (the result
mapping plus one more refusal string; still zero bytes ofrevm-wasm).docs/adr/0006-the-engine-is-an-injected-object-not-a-named-string.mdcarries a
dated amendment: the injected-object decision is unchanged, its scope widened. -
59f2df2: A replayed or invalid transaction is REFUSED by the NODE, in one vocabulary, on every engine. A transaction whose nonce the sender has already used, whose nonce this node will never reach, whose sender cannot cover
value + gasLimit * maxFeePerGas, or whose gas limit is below its intrinsic gas is now refused above the engine seam, with anRpcErrorcode-32000and nodata, before any EVM sees it.It used to be whichever EVM was installed that answered, and the two have nothing in common: revm rejected a replay with
Transaction(NonceTooLow { tx: 0, state: 1 })— Rust's debug rendering of an enum variant, arriving where a client expects prose — and@ethereumjs/vmwiththe tx doesn't have the correct nonce. account has nonce of: 1 tx has nonce of: 0followed by a dump of the whole block and transaction. Neither carried a JSON-RPC code at all. This is the transaction-path twin of the divergence removed from the read path in the same release (revm's validation text arriving aseth_callreturn data), and it is fixed the same way: the engine-specific artifact stops reaching a surface that is meant to be engine-independent.The words are geth's, so a client already knows them (viem maps these phrases onto typed errors):
nonce too low: address 0x…, tx: 0, state: 1,nonce too high: …,insufficient funds for gas * price + value: address 0x… have … want …,intrinsic gas too low: have 20999, want 21000. Each is followed by this node's own half — what happened and what to do about it — including the thing a real node would not have to say: there is NO MEMPOOL here, so a too-...
embedded-eth-node@0.2.0
Minor Changes
-
654bb91:
intrinsicGas()gates EIP-3860 by fork, and the revm read engine admitsberlin,londonandparisagain.revm-wasm@0.3.1fixes the upstream bug this repo filed as
wighawag/revm-wasm#4:CallExecutor::newnow rebuilds the gas-parameter table
for the requested spec instead of leaving it pinned at theContext::mainnet()
default, so revm no longer charges EIP-3860's initcode word cost on forks that
predate Shanghai. That INVERTS the previous release's remedy. The two engines
used to agree on a wrong number there; with revm fixed, the node was the only
party still charging the term, and a CREATE-shapedeth_estimateGasdiffered by
engine (default 53302 vs revm 53298 for a 64-byte initcode, where the protocol
charges 53298). The fork gate that was the wrong fix against0.3.0is the
required one against0.3.1.So
src/intrinsic-gas.tsnow takes the node'sCommonand charges the initcode
word cost only wherecommon.isActivatedEIP(3860)says the protocol does. The
parameter is thatCommonITSELF, not a hardfork name:node.tshands the
engine the very same instance throughReadEngineContext.common, so the caller
that ADDS the intrinsic gas and the caller that SUBTRACTS it cannot name
different forks — which is the drift that shared file exists to prevent. It is
also the table@ethereumjs/vm'srunTxconsults, so a deployment estimated on
the read path is charged what this node's own transaction path spends on it.Observable changes.
eth_estimateGasfor a CREATE is unchanged on the fork
the node runs (Cancun) and on Shanghai.REVM_SPEC_BY_HARDFORKis now
{berlin, london, paris, shanghai, cancun}andREVM_REFUSED_HARDFORKSis
{prague, osaka}; code reading either table sees the new contents, and the
PRE_EIP_3860refusal text is gone.revm-wasmmoves to^0.3.1. The revm
engine now throws ifcall()is reached beforeconnect()bound it to a node
(it has no hardfork to cost against) — unreachable throughcreateNode().Still refused, unchanged:
pragueandosaka. Their refusal never depended
on the upstream bug — revm enforces EIP-7623's calldata floor and EIP-7825's gas
limit cap, neither of which this node's arithmetic implements — and both were
re-measured on0.3.1rejecting the node's estimate and read budget exactly as
before.ADR 0008 gains a second amendment recording the reversal, the evidence it rests
on, and thatprague/osakaare untouched. See
docs/adr/0008-the-revm-engine-admits-only-hardforks-it-can-cost.mdand §6-§7
ofdocs/spikes/intrinsic-gas-charges-eip-3860-on-forks-that-predate-it/measurements.md.
embedded-eth-node@0.1.0
Minor Changes
-
6694ffc: Make the EVM behind the READ path swappable:
createNode({engine}).eth_call,eth_estimateGasandeth_fillTransaction's gas estimation now run
on an ENGINE rather than reaching@ethereumjs/evmdirectly. Supplying none
keeps exactly today's behaviour — the default engine wraps the node's own
@ethereumjs/evm, including the pure-read checkpoint/revert and the EIP-2929
warm/access reset that keeps a repeated estimate for a warm SSTORE from coming
back ~2000 gas too low.An engine is an INJECTED OBJECT (
ReadEngine), never a name the core resolves,
so the core imports no engine a consumer did not and a JS-only consumer pays
nothing for one they never use. See
docs/adr/0006-the-engine-is-an-injected-object-not-a-named-string.md.Everything an engine needs to keep a read pure is the ENGINE's business: the
default engine checkpoints and resets warmth because@ethereumjs/evmrequires
it, and an engine that is structurally incapable of committing pays for neither.Scope, deliberately narrow: only READS are routed through the engine.
Transactions still run on@ethereumjs/vm, which is why the active engine reads
asnode.readEngine({id: '@ethereumjs/evm'}by default) rather than
node.engine— a receipt can never be attributed to it.New exported types:
ReadEngine,ReadEngineContext,ReadEngineInfo,
ReadCallRequest,ReadCallResult. No new runtime dependency. -
172d1ad: The revm read engine now runs reads against the node's REAL block environment,
onrevm-wasm@^0.3.0.BASEFEEinside aneth_callused to read0onembedded-eth-node/revmand
the block's real value on the default@ethereumjs/evmengine: the zeroed base
fee was the only way to keep a read from an unfunded address (fromdefaults to
the zero address) from failing revm's transaction validation.revm-wasmnow
exposes the switches every real client uses to serveeth_call, so the engine
passes the node's own base fee andprevRandaoand turns the VALIDITY RULES off
instead:disableBaseFee,disableBlockGasLimit,disableEip3607.Observable consequences, all of them removing a divergence between the two
engines:BASEFEEandPREVRANDAOinside a read now report the node's block on revm,
as they always did on the default engine (COINBASE,NUMBER,TIMESTAMP
andGASLIMITalready did).eth_call/eth_estimateGaswithfromset to an address that HOLDS CODE
now succeeds on revm (EIP-3607 is a rule about sending a transaction;
@ethereumjs/evm'srunCallnever enforced it). Smart-account, ERC-4337 and
multicall-aggregator previews work on either engine.- A read's gas budget is no longer capped at the block gas limit on revm, so a
call needing within intrinsic gas of the whole block limit no longer runs out
of gas on one engine and completes on the other. - A read from an address holding no ether keeps working, which is what the
zeroed base fee was buying.
What is relaxed is a transaction's VALIDITY, never the VALUE TRANSFER: revm's
disableBalanceCheckis deliberately left off, so aneth_callcarrying more
ether than the sender holds still fails on either engine, as it does on geth
(ErrInsufficientBalance). A read never invents funds it can then report.The differential conformance battery grew two steps for the two divergences no
gas bar can see: one that reads the block-environment opcodes THROUGH A CONTRACT
and diffs them (gas is identical either way), and one that pins whether a
value-bearing read succeeds or fails per sender (a rejected read charges no gas
at all). Both run on both engines: in both state modes on the default engine, and
instateMode:'none'on revm, which refuses'trie'at construction. -
52d03c6: The revm read engine refuses
berlin,londonandparistoo: it now admitsshanghaiandcancunonly.src/intrinsic-gas.tsadds EIP-3860's initcode word cost (ceil(len/32) * 2)
to every CREATE with no hardfork gate, and EIP-3860 arrived in Shanghai. That
was previously judged harmless becauserevm-wasmover-charges identically on
the earlier forks, so the two engines agree and no cross-engine divergence
reaches an estimate. Measured against the shipped artifact, the agreement is
real and the conclusion was not: for a 64-byte initcode both sides charge 53296
where the protocol charges 53292, soeth_estimateGasfor a deployment on those
forks over-charges by 2 gas per initcode word (3072 for a maximum-size initcode)
against what this node's own@ethereumjs/vmtransaction path spends. The node
disagreed with itself, and an invariant that compares the node with revm could
not see it.Gating the term would not have fixed it: the engine subtracts the node's
intrinsic gas from what revm spent and the node adds the same number back, so a
gate moves the default engine's estimate and cannot move revm's, turning an
agreed wrong number into a cross-backend gas divergence. So the three forks are
refused at construction instead, naming EIP-3860 and where the measurements are,
andintrinsicGas()keeps its unconditional term — now true at every fork any
part of this node can run.Nothing a consumer can reach changes: the node runs Cancun and exposes no
hardfork option, so this is a guard that fires the day that moves.
REVM_SPEC_BY_HARDFORKis now{shanghai, cancun}andREVM_REFUSED_HARDFORKS
gainsberlin,londonandparis; code that reads either table sees the new
contents.ADR 0008's admission rule is amended with it: agreement between the node and
revm is necessary and NOT sufficient, because they share one intrinsic-gas
answer by construction, so admission now also requires the protocol's agreement,
judged by a witness that is neither of them (@ethereumjs/common's EIP
activation table, asserted per admitted fork in the test suite).See
docs/adr/0008-the-revm-engine-admits-only-hardforks-it-can-cost.mdand the
measurements in
docs/spikes/intrinsic-gas-charges-eip-3860-on-forks-that-predate-it/. -
f145bb5: Add
embedded-eth-node/revm: a revm-wasm engine behind the node's READ path.import {createNode} from 'embedded-eth-node'; import {createRevmEngine} from 'embedded-eth-node/revm'; const node = await createNode({engine: await createRevmEngine({wasm})});
eth_call,eth_estimateGasandeth_fillTransaction's estimation then run on
revm, returning the SAME results and the SAME gas as the default
@ethereumjs/evmengine (number()2446 execution gas,sumTo(2000)498689,
keccakLoop(2000)1107052 — asserted, not asserted-about). Transactions are
unchanged: they still run on@ethereumjs/vm, so a node with this engine runs
two EVMs andnode.readEnginesays which one produced a read.The engine reads the node's OWN state, which stays authoritative — nothing is
copied across, and a value written by a transaction is visible to the next
eth_callwith no sync step. It does that throughSimpleStateManager's public
checkpoint stacks, the only synchronous view of the node's state that exists, so
it servesstateMode:'none'ONLY and REFUSESstateMode:'trie'at construction
with an error naming the reason (see
docs/adr/0005-revm-reads-the-nodes-state-through-simplestatemanagers-stacks.md).
Aneth_callon it cannot mutate state:Revm#callcannot commit, and every
write method on the state adapter throws.The wasm is whatever you have — bytes, a
URL, aResponseor a compiled
WebAssembly.Module— passed straight through torevm-wasm, so a
bundler-resolved asset and a runtime-fetched URL are the same code path. In
Node, note thatrevm-wasm/wasm-urlis afile:URL and Node'sfetchcannot
resolve that scheme: read the bytes and pass those.One engine instance serves ONE node. Handing an already-connected engine to a
secondcreateNode()is refused, because rebinding it would silently re-point
the FIRST node's reads at the second node's state. Running several nodes means
callingcreateRevmEngine()per node — pass each the same compiled
WebAssembly.Moduleto compile the wasm only once.revm-wasmis a plaindependencyrather than an optional peer, because a
missing optional peer fails worse than the install costs. A JS-only consumer
pays install bytes and ZERO bundle bytes: the core entry point never imports
the subpath, andpackages/benchmarksnow ASSERTS the default entry's bundle
size against a pinned baseline and thatrevm-wasmis absent from its dependency
graph. (The default entry moved 412.3 KB -> 412.4 KB raw: that 0.1 KB is the new
getBlockHashaccessor in the node itself, not revm.)ReadEngineContextgains agetBlockHash(blockNumber)accessor (additive), so
an engine can answerBLOCKHASHfrom the node's real blocks instead of
silently answering zero. -
322097e: Add
senderMode: 'recover' | 'trusted'to skip ecrecover on a local chain.ecrecoveris a fixed ~2ms per transaction and dominates small ones (~80% of a
21k-gas transfer; EVM execution only overtakes it at ~33k gas of execution). A
client that signed a tx already knows the sender, so re-deriving it on a local
chain is pure waste.senderMode: 'trusted'(opt-in; default stays'recover') enables
evm_sendRawTransactionAs/evm_sendRawTransactionSyncAs, which take
[raw, from]and pin the sender instead of recovering it. Measured ~13x on
runTxin isolation, ~2.3x end-to-end through a viem-style client, and ~3.9x
when the caller also skips signing (fabricated signature). Gas, status, logs,
receipts and post-state are byte-identical to'recover', asserted field by
field in a new differential test.The primitive is just "execute as this sender, do not recover". It serves both an
ordinar...