Skip to content

Releases: wighawag/webevm

webevm@0.5.0

Choose a tag to compare

@github-actions github-actions released this 16 Aug 20:44
226470f

Minor Changes

  • c391f68: Renamed the package from embedded-eth-node to webevm.

    This is a rename, not a rewrite: the api, the exports and the four subpaths are unchanged, so migrating is npm i webevm, removing embedded-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-host and /worker-client move with it under the new name. createNode() and every other exported symbol keep their names.

    embedded-eth-node is deprecated on npm at 0.4.0 and receives no further releases. Why the name changed is docs/adr/0011-the-package-is-named-webevm.md.

embedded-eth-node@0.4.0

Choose a tag to compare

@github-actions github-actions released this 16 Aug 10:08
b1bf1b0

Minor Changes

  • fe793e0: eth_estimateGas now 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 a CALL or CREATE is 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 own eth_estimateGas: the funding transfer mined, the factory itself deployed (140 bytes of code), and the deployment THROUGH the factory came back status: 0x0 with no contract created. The caller then pointed a proxy at the address that was never deployed, and every call to it returned 0x rather 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 on test/helpers/post-state.ts explaining 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 gas if 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 - 1 and requiring status: 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 on accessListGas, unchanged by this work — and a gas-sensitive contract that reads GAS and 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 reverted with the callee's bytes on data (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 from Error(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 -32000 for an out-of-gas at the allowance and the same code-3-with-data for a revert, on @ethereumjs/evm and revm alike.

    eth_fillTransaction fills its gas from 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's totalGasSpent — the conformance battery's two estimate steps now assert that against the node's own receipt gasUsed, 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 own gasUsed, a revert produces an error carrying Error("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.

  • eb1d5e0: New subpath embedded-eth-node/worker-host: host a node in a Worker that builds its OWN engine, without hand-copying the SlimNode proxy. Additive: embedded-eth-node/worker-entry still exposes the node at import time, still exports workerApi, 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 an Engine is a function-bearing object holding thread-bound live state), and worker-entry deliberately 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, and worker-entry calls comlink's expose() 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-host is worker-entry without the side effect, and worker-entry is now that module plus its one line.

    createEngine is a FACTORY, called once per createNode(), 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 through createWorkerNode() 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 to SlimNode later fails the build there, and worker-client's as any casts (which are what hid senderMode when 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 published files list is unchanged.

    src/index.ts is 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's createWorkerNode() 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 ...

Read more

embedded-eth-node@0.3.0

Choose a tag to compare

@github-actions github-actions released this 11 Aug 06:19
ba17507

Minor Changes

  • cf377cd: The block gas limit is ENFORCED, on both engines, and blockGasLimit is 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/evm engine, because the node passed @ethereumjs/vm's skipBlockGasLimitValidation to runTx, 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 still 30_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, and GASLIMIT reports the configured number to a contract, as does eth_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 blockGasLimit exists (@ethereumjs/vm says "tx has a higher gas limit than the block", revm says Transaction(CallerGasLimitMoreThanBlock)). It is an RpcError with code -32000 (the range geth uses for a transaction its pool refuses), thrown by the eth_sendRawTransaction* call that submitted it, so an over-limit transaction never enters the pending queue and cannot take a later mine() 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. An eth_call that names no gas still 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). Pass gas on the call when you want a bigger one. The reasoning is recorded at the code site in src/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 reference runTx passes skipBlockGasLimitValidation itself 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 ReadEngine is Engine and node.readEngine is node.engine. No behaviour changes.

    The node had ONE seam for reads and a HARDCODED path for writes: an injected engine
    answered eth_call, while transactions bypassed it and went straight to
    @ethereumjs/vm's runTx. The seam is now ONE interface with TWO operations —
    call (read-only) and transact (executes and commits) — the default
    @ethereumjs/evm engine implements both, and the node's mining path executes
    through the engine rather than calling runTx itself.

    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:

    • ReadEngineEngine (and it gained transact)
    • ReadEngineContextEngineContext
    • ReadEngineInfoEngineInfo
    • SlimNode.readEngineSlimNode.engine (same {id} value, over comlink too)
    • ReadCallRequest / ReadCallResult keep 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) and TransactionResult — what a
    RECEIPT needs from an EVM and nothing else: status, gasUsed (net of refunds),
    effectiveGasPrice, logs in emission order (TransactionLog: address, topics,
    data as raw bytes), logsBloom, and createdAddress. runTx's amountSpent,
    gasRefund, minerValue, accessList and execResult are deliberately absent:
    no receipt reads them, and a field that exists only because one engine returns it
    is what makes two engines incomparable. effectiveGasPrice now 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's skipBlockGasLimitValidation / skipHardForkValidation
    stayed INSIDE the default engine rather than becoming neutral request fields — they
    are one EVM's vocabulary, and revm-wasm refuses 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 in src/engine.ts.
    (Later in this same release, skipBlockGasLimitValidation was DROPPED rather than
    relocated. See the block-gas-limit entry: a relaxation only one engine could honour
    was the divergence, wherever it lived. skipHardForkValidation still lives there.)

    transact was 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, and createRevmEngine() from embedded-eth-node/revm was
    in that state — it served the seam's read half only, so a node with it installed
    still mined on @ethereumjs/vm and 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-commit makes transact REQUIRED, 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). A transact that 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.ts gained the bar
    for the new half: an engine whose transact returns values no EVM would produce
    for a 21000-gas transfer, so the receipt proves the ENGINE executed the transaction
    rather than runTx having 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 of revm-wasm).

    docs/adr/0006-the-engine-is-an-injected-object-not-a-named-string.md carries 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 an RpcError code -32000 and no data, 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/vm with the tx doesn't have the correct nonce. account has nonce of: 1 tx has nonce of: 0 followed 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 as eth_call return 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-...

Read more

embedded-eth-node@0.2.0

Choose a tag to compare

@github-actions github-actions released this 02 Aug 15:05
a0a5270

Minor Changes

  • 654bb91: intrinsicGas() gates EIP-3860 by fork, and the revm read engine admits berlin, london and paris again.

    revm-wasm@0.3.1 fixes the upstream bug this repo filed as
    wighawag/revm-wasm#4: CallExecutor::new now rebuilds the gas-parameter table
    for the requested spec instead of leaving it pinned at the Context::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-shaped eth_estimateGas differed 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 against 0.3.0 is the
    required one against 0.3.1.

    So src/intrinsic-gas.ts now takes the node's Common and charges the initcode
    word cost only where common.isActivatedEIP(3860) says the protocol does. The
    parameter is that Common ITSELF, not a hardfork name: node.ts hands the
    engine the very same instance through ReadEngineContext.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's runTx consults, so a deployment estimated on
    the read path is charged what this node's own transaction path spends on it.

    Observable changes. eth_estimateGas for a CREATE is unchanged on the fork
    the node runs (Cancun) and on Shanghai. REVM_SPEC_BY_HARDFORK is now
    {berlin, london, paris, shanghai, cancun} and REVM_REFUSED_HARDFORKS is
    {prague, osaka}; code reading either table sees the new contents, and the
    PRE_EIP_3860 refusal text is gone. revm-wasm moves to ^0.3.1. The revm
    engine now throws if call() is reached before connect() bound it to a node
    (it has no hardfork to cost against) — unreachable through createNode().

    Still refused, unchanged: prague and osaka. 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 on 0.3.1 rejecting 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 that prague/osaka are untouched. See
    docs/adr/0008-the-revm-engine-admits-only-hardforks-it-can-cost.md and §6-§7
    of docs/spikes/intrinsic-gas-charges-eip-3860-on-forks-that-predate-it/measurements.md.

embedded-eth-node@0.1.0

Choose a tag to compare

@github-actions github-actions released this 02 Aug 10:33
cba6cd6

Minor Changes

  • 6694ffc: Make the EVM behind the READ path swappable: createNode({engine}).

    eth_call, eth_estimateGas and eth_fillTransaction's gas estimation now run
    on an ENGINE rather than reaching @ethereumjs/evm directly. 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/evm requires
    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
    as node.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,
    on revm-wasm@^0.3.0.

    BASEFEE inside an eth_call used to read 0 on embedded-eth-node/revm and
    the block's real value on the default @ethereumjs/evm engine: the zeroed base
    fee was the only way to keep a read from an unfunded address (from defaults to
    the zero address) from failing revm's transaction validation. revm-wasm now
    exposes the switches every real client uses to serve eth_call, so the engine
    passes the node's own base fee and prevRandao and turns the VALIDITY RULES off
    instead: disableBaseFee, disableBlockGasLimit, disableEip3607.

    Observable consequences, all of them removing a divergence between the two
    engines:

    • BASEFEE and PREVRANDAO inside a read now report the node's block on revm,
      as they always did on the default engine (COINBASE, NUMBER, TIMESTAMP
      and GASLIMIT already did).
    • eth_call / eth_estimateGas with from set to an address that HOLDS CODE
      now succeeds on revm (EIP-3607 is a rule about sending a transaction;
      @ethereumjs/evm's runCall never 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
    disableBalanceCheck is deliberately left off, so an eth_call carrying 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
    in stateMode:'none' on revm, which refuses 'trie' at construction.

  • 52d03c6: The revm read engine refuses berlin, london and paris too: it now admits shanghai and cancun only.

    src/intrinsic-gas.ts adds 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 because revm-wasm over-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, so eth_estimateGas for 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/vm transaction 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,
    and intrinsicGas() 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_HARDFORK is now {shanghai, cancun} and REVM_REFUSED_HARDFORKS
    gains berlin, london and paris; 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.md and 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_estimateGas and eth_fillTransaction's estimation then run on
    revm, returning the SAME results and the SAME gas as the default
    @ethereumjs/evm engine (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 and node.readEngine says 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_call with no sync step. It does that through SimpleStateManager's public
    checkpoint stacks, the only synchronous view of the node's state that exists, so
    it serves stateMode:'none' ONLY and REFUSES stateMode:'trie' at construction
    with an error naming the reason (see
    docs/adr/0005-revm-reads-the-nodes-state-through-simplestatemanagers-stacks.md).
    An eth_call on it cannot mutate state: Revm#call cannot commit, and every
    write method on the state adapter throws.

    The wasm is whatever you have — bytes, a URL, a Response or a compiled
    WebAssembly.Module — passed straight through to revm-wasm, so a
    bundler-resolved asset and a runtime-fetched URL are the same code path. In
    Node, note that revm-wasm/wasm-url is a file: URL and Node's fetch cannot
    resolve that scheme: read the bytes and pass those.

    One engine instance serves ONE node. Handing an already-connected engine to a
    second createNode() is refused, because rebinding it would silently re-point
    the FIRST node's reads at the second node's state. Running several nodes means
    calling createRevmEngine() per node — pass each the same compiled
    WebAssembly.Module to compile the wasm only once.

    revm-wasm is a plain dependency rather 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, and packages/benchmarks now ASSERTS the default entry's bundle
    size against a pinned baseline and that revm-wasm is absent from its dependency
    graph. (The default entry moved 412.3 KB -> 412.4 KB raw: that 0.1 KB is the new
    getBlockHash accessor in the node itself, not revm.)

    ReadEngineContext gains a getBlockHash(blockNumber) accessor (additive), so
    an engine can answer BLOCKHASH from the node's real blocks instead of
    silently answering zero.

  • 322097e: Add senderMode: 'recover' | 'trusted' to skip ecrecover on a local chain.

    ecrecover is 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
    runTx in 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...

Read more