EIP.tools

EIP.tools

⚠️ DraftStandards Track: Core

EIP-8429: Escalating gas for repeated calls

The k-th call into a self-enrolled address within a block costs G_REF * k^2 extra gas; the counter is client-side, not state.

Authors
Created2026-09-28
Discussion Linkhttps://ethereum-magicians.org/t/eip-8429-escalating-gas-for-repeated-calls/29798
Requires

Markdown

https://raw.githubusercontent.com/staccDOTsol/YIPs...

EIP-GPT summary

Contents
AbstractMotivationThe problem this pricesWhy price it in the protocolWhy gasWhy self-enrollmentSpecificationConstantsBlock sequenceDefinitionsBlock-scoped counterWindow-scoped counterRegistry snapshotRegistry contractCounted operationsGas accountingSurcharge tracking and distributionStake vaults and the sinkReverts and exceptional haltsBlock-level access listsJSON-RPCActivationRationaleBlock scope rather than transaction scopeTwo ratchetsGlobal rather than per senderQuadratic rather than linear or exponentialCounting calls rather than storage writesCounting reverted referencesWhat a single operation costs depends on the venueThe capExcluding STATICCALLExcluding DELEGATECALLThe registry snapshotStaked, not burned, and not paid to the producerStake vaults as the only permitted beneficiaryOpting in and opting outWhat this does not priceLeaving with notice rather than neverCounters in the access list, not in stateWhy not extend EIP-2929 directlyWhy a system contract and not an account flagBackwards CompatibilityTest CasesReference ImplementationRegistryClient hookEnd-of-transaction distributionToken-level formStake vaultSecurity ConsiderationsGriefing an enrolled accountGriefing where gas is cheapBystanders in busy blocksCross-block repetitionBuilder and proposer behaviorEnrollment as a weaponInteraction with account abstractionInteraction with EIP-7702State growthClient complexity and consensus riskPredictability of victimsCopyright

Abstract

This EIP introduces a block-scoped reference counter, maintained by the execution client outside of world state, that records how many message calls have been made into each enrolled account during the current block. An account enrolls itself by calling a system registry contract, and can leave only with at least a full window's notice. After activation, every message call whose target is an enrolled account is charged, in addition to all existing costs, a surcharge of

surcharge(k) = min(G_REF * k * k, G_REF_MAX)      for k > K_FREE
surcharge(k) = 0                                   for k <= K_FREE

where k is the ordinal of that call among all calls into that account in the block, counted across every transaction and every caller. A second counter, per originator and per window of blocks, prices the same sender returning to the same account; a call pays the larger of the two. The surcharge is accounted separately from other gas. Nothing of it is burned and none of it reaches the block producer. At the end of each transaction its value in wei is split in two: one half is credited to a canonical no-owner sink that can only stake, the other half to a beneficiary the enrolled account named at enrollment, which must itself be a stake vault. All of it ends up as validator stake.

The intent is to make repetition of references to a consenting contract within a block superlinearly expensive while leaving single references unchanged. The first K_FREE references to an account in a block pay nothing, so a transfer or a swap in a quiet block costs what it costs today. A crew filling from a dozen wallets in one block, a route that crosses the same token many times, or a wallet that returns to it dozens of times in a day pays quadratically in gas that the actor cannot recover.

Motivation

The problem this prices

Sequencer-ordered chains with private submission are immune to the mempool sandwich. A 2026 study of six chains over three years (arXiv:2609.28115) finds zero sandwiches against protected order flow on Arbitrum and Monad. The same chains are not immune to a second class of extraction that needs no mempool at all. On Robinhood Chain on 2026-09-26 a token was farmed within thirty-two minutes of its launch by:

  • fifty-nine permissionless pools initialized on the token, most with fee tiers between 70% and 98%, so that any route through them loses most of its input to fees and reports it as slippage;
  • a price ladder walked to seventeen times the reference price on a sibling pool with no trades, by initializing pool after pool at rising quotes with no tokens deposited;
  • liquidity added and removed inside the same block around each fill;
  • a fixed toll taken on every fill, a machine constant rather than a market outcome;
  • one helper contract at the same address on eight chains, and one operator that touched 532 tokens in under a month with a net position per token of approximately zero.

The victim of that machine is not one trader; it is everyone routed through the surface the machine built, and it runs against every launch on the chain regardless of who launched it. Encrypted mempools, private relays, batch auctions and threshold decryption do not address it, because the machine is not reacting to any transaction. It is reacting to the existence of the token.

None of this is an argument about memecoins. A token launch is a market; the technology is neutral and this EIP does not judge what is launched. What it prices is a practice: running the same extraction machine against every launch on a chain, whoever launched it, because the chain makes repetition free. This EIP does not price every step of that machine. Initializing a pool never calls the token, so the first two items above are invisible to a counter of calls into the token and are out of scope (see What this does not price). What it prices is the part that pays the machine, the fills and the return visits, each of which is the same primitive: reference the same token again.

Why price it in the protocol

The token can price only part of it, and at a cost. A token can count its own transfers, and one deployed form does (see Token-level form). But a counter in contract storage rolls back when the call reverts, so probing is free; it costs every caller a storage write; and the token can charge only in itself, which breaks every venue that checks exact balances. Neither the token nor this EIP sees pool initialization, which makes no call into the token, or a sequence that a flash-accounting venue (the Uniswap v4 pattern) nets into one settlement.

One AMM cannot price it. An AMM that charges liquidity operations the same as swaps and escalates repeated references defends only its own pools. The machine initializes its own pools, on the same singleton or on a fresh deployment of it, with a hook of its own choosing.

Ordering cannot price it. Fair ordering, batch auctions and single-sequencer FCFS constrain the position of a transaction relative to others. Repeated fills and return visits are not order-sensitive; they are count-sensitive.

The only layer that observes every reference to an account, across all callers and all transactions in a block, is the execution client. It already keeps per-transaction access sets for EIP-2929. This EIP asks it to keep a per-block counter for accounts that ask for one.

Why gas

The surcharge could have been specified as a fee in the referenced token. It is specified as gas for three reasons. A fee in the token is captured by whoever controls the venue, which is the party being priced. Gas is paid to the protocol and cannot be recaptured by the actor. And gas is the only unit in which the client can charge a message call without knowing anything about the callee's semantics. Because it is gas, it is ETH, and ETH can be staked: the surcharge is not burned, it is turned into validator stake that nobody can withdraw.

Why self-enrollment

A universal surcharge would price organic volume on every popular contract and would be a hard fork of the gas schedule for all existing code. Enrollment restricts the rule to accounts that have chosen it, makes the choice visible on chain, and leaves every unenrolled contract's gas exactly as it was. Leaving takes effect only after at least one full window, announced on chain when it is requested, so that a contract cannot enroll for a launch and quietly sell the surface back.

Specification

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.

Constants

NameValueDescription
FORK_BLOCKTBDfirst block at which this EIP is active
REGISTRY_ADDRESS0x000000000000000000000000000000000000E5CAplaceholder; assigned at deployment
SINK_ADDRESS0x000000000000000000000000000000000000E5CBplaceholder; the canonical StakeVault whose withdrawal address is itself
STAKE_VAULT_CODEHASHTBDkeccak256 of the canonical StakeVault runtime code
DEPOSIT_CONTRACT0x00000000219ab540356cBB839Cbe05303d7705Fabeacon chain deposit contract
G_REF2000base surcharge, in gas
G_REF_MAX5000000surcharge cap per call, in gas
K_FREE2references per block per enrolled account that carry no fast surcharge
W_SLOW50400length, in blocks, of the slow ratchet's window (a week at 12 s)
G_REF_SLOW400base surcharge of the slow ratchet, in gas
G_REF_SLOW_MAX5000000slow surcharge cap per call, in gas
K_FREE_SLOW16references per window per originator per enrolled account that carry no slow surcharge
BOND1 etheroperator bond per validator in a stake vault, held inside the validator
TOP_UP31 etherwhat a stake vault adds to a proven validator
MAX_PROOF_AGE3600seconds a validator proof stays acceptable

Chains other than Ethereum mainnet MAY adopt different values. For a single-sequencer rollup with sub-second blocks the RECOMMENDED values are G_REF = 20000, G_REF_MAX = 30000000, K_FREE = 2, W_SLOW = 2419200 (a week at 250 ms), K_FREE_SLOW = 16.

Block sequence

Everywhere in this specification, "block" means a block of the chain executing the transaction. refs is initialized and discarded on that chain's block boundaries, and windows are computed from that chain's block height. A chain whose NUMBER opcode reports its parent chain's height MUST key both counters on its own block sequence and MUST NOT key them on NUMBER. An implementation that keys on NUMBER on such a chain fails silently: it produces a well-formed schedule at the wrong ordinal, because every child block that shares a parent block shares one counter.

Definitions

  • Enrolled account. An account A for which REGISTRY.isEnrolled(A) returned true in the registry snapshot taken at the start of the current block (see Registry snapshot).
  • Reference. A message call whose target is an enrolled account, as enumerated under Counted operations.
  • Reference ordinal k. For a reference to account A, the value refs[A] after incrementing, where refs is the block-scoped counter defined below. The first reference to A in a block has k = 1.
  • Fast surcharge. min(G_REF * k * k, G_REF_MAX) for k > K_FREE, else 0. It is charged to whoever makes the reference.
  • Window. Block numbers n with the same value of n / W_SLOW belong to the same window.
  • Originator. The ORIGIN of the transaction making the reference.
  • Slow ordinal m. For a reference to account A by originator o, the value wrefs[(A, o)] after incrementing, where wrefs is the window-scoped counter defined below.
  • Slow surcharge. min(G_REF_SLOW * m * m, G_REF_SLOW_MAX) for m > K_FREE_SLOW, else 0.
  • Surcharge. The larger of the fast and the slow surcharge for the reference.

Block-scoped counter

At the beginning of the execution of every block with number >= FORK_BLOCK, the client MUST initialize an empty map

refs: address -> uint64

The map MUST persist across all transactions of the block, MUST be discarded when the block's execution completes, and MUST NOT be included in the state root or receipts root. Where EIP-7928 is active its values are recorded in the block access list (see Block-level access lists); no other structure commits to them. Its contents are fully determined by the ordered execution of the block's transactions, so all conforming clients compute identical values.

The map is not rolled back on transaction failure or on frame revert (see Reverts).

Window-scoped counter

The client MUST additionally maintain a map

wrefs: (address, address) -> uint64

keyed by enrolled account and originator, that counts references across every block of the current window. At the first block of a new window the map MUST be discarded and re-initialized empty. Like refs it is not part of state, is recorded only in the block access list where EIP-7928 is active, is fully determined by ordered execution, and is not rolled back on failure or revert.

The two counters price two things. refs is global and prices repetition within a block, whoever it comes from, so splitting a bundle across senders changes nothing. wrefs follows one originator across blocks and prices the same wallet coming back over hours and days. A reference pays the larger of the two surcharges, never both.

These shapes come from replays, not a prior. Two days of one extraction operator's activity on Robinhood Chain (65 launches, 45,728 transfers, 198 serial or heavy wallets against 2,911 others) showed the machine returning to a token 6 to 37 times over about twelve hours while other wallets touched it 3 to 8 times over about seven minutes; the slow ratchet is sized to that. In that sample the machine's same-block repetition came from one wallet at a time, and an earlier draft therefore charged the fast surcharge only to an originator already repeating in the block. On a later launch carrying the token-level form, a crew buying from thirteen wallets in the same block paid nothing under that gate. With the gate removed, over that token's first two hours (409 trades, 250 ms blocks) the crew paid 19 basis points of its volume and every other wallet 0.6. The gate is not in this specification (see Bystanders in busy blocks).

Registry snapshot

At the beginning of the execution of every block with number >= FORK_BLOCK, before executing the block's first transaction, the client MUST read the enrollment set from the registry contract's storage as it stands at the end of the previous block, and use that set for every reference decision in the block. An account A is in the set for block N if its beneficiary slot is non-zero and its exit slot is either zero or greater than N.

Enrollment during block N therefore takes effect at block N + 1, and an exit takes effect at the block recorded by scheduleExit, which is at least W_SLOW blocks later. This is required so that a contract cannot toggle its own cost mid-block and so that clients can consult a fixed set for the whole block.

Clients MAY implement the snapshot lazily (reading the registry's storage slots for an address on first reference and caching them for the block) provided the values observed are the end-of-previous-block values. A scheduleExit made earlier in the current block cannot change the current block's answer, since its exit block is at least W_SLOW blocks away; an enroll made earlier in the current block, including a re-enrollment after an exit, MUST be ignored until the next block.

Registry contract

At FORK_BLOCK the client MUST set the code of REGISTRY_ADDRESS to the canonical registry runtime code (see Reference Implementation). The registry has the following external interface:

interface IReferenceRegistry {
    event Enrolled(address indexed account, address indexed beneficiary);
    event ExitScheduled(address indexed account, uint256 exitBlock);

    /// Enroll msg.sender with SINK_ADDRESS as beneficiary.
    function enroll() external;

    /// Enroll msg.sender with a chosen beneficiary.
    /// Reverts unless beneficiary.codehash == STAKE_VAULT_CODEHASH.
    function enroll(address beneficiary) external;

    /// Schedule msg.sender's exit for the first block of the window after next.
    /// Cannot be cancelled.
    function scheduleExit() external;

    function isEnrolled(address account) external view returns (bool);
    function beneficiaryOf(address account) external view returns (address);
    /// The block at which the account stops being enrolled, or 0 if no exit is scheduled.
    function exitBlockOf(address account) external view returns (uint256);
}

Storage layout, which clients read directly for the snapshot:

  • slot keccak256(abi.encode(account, uint256(0))) holds the beneficiary address for account, or zero if it has never enrolled;
  • slot keccak256(abi.encode(account, uint256(1))) holds the block at which account's enrollment ends, or zero if no exit is scheduled.

Rules:

  1. enroll and enroll(address) MUST revert if msg.sender is enrolled at the current block, including while an exit is scheduled and has not yet taken effect, and MUST revert while the current block is below the exit block plus W_SLOW. After that, enroll writes the new beneficiary and clears the exit slot.
  2. enroll(address) MUST revert if EXTCODEHASH(beneficiary) != STAKE_VAULT_CODEHASH.
  3. Enrollment and exit are by the account itself. There is no function that enrolls a third party, and no function that removes another account's enrollment.
  4. scheduleExit MUST revert unless msg.sender is enrolled and has no exit scheduled. It sets the exit slot to (n / W_SLOW + 2) * W_SLOW, where n is the current block, and emits ExitScheduled. The exit therefore always falls on a window boundary, at least W_SLOW and fewer than 2 * W_SLOW blocks after the request, and a scheduled exit cannot be cancelled or brought forward.
  5. The registry is a plain contract with no owner and no upgrade path. Its address is reserved and its code MUST NOT be replaced by any later mechanism except a subsequent EIP.
  6. Enrolling REGISTRY_ADDRESS, SINK_ADDRESS, any precompile, or the system contracts of EIP-4788 and similar EIPs is not possible because those accounts never call enroll; clients MUST NOT special-case them.
  7. "The current block" and n mean the chain's own block height, per Block sequence. On a chain whose NUMBER reports the parent chain's height, the canonical registry code for that chain MUST read its own block height from the chain's native source (on Arbitrum-family chains, ArbSys.arbBlockNumber()).

Counted operations

A reference occurs when a message call is made whose target account is enrolled and has non-empty code, in exactly these cases:

  1. The top-level transaction, when tx.to is enrolled. This applies to legacy, EIP-2930, EIP-1559, EIP-4844 and EIP-7702 transaction types. Contract creation transactions (tx.to == null) never count.
  2. CALL and CALLCODE opcodes, when the target address is enrolled.

The following are never references, regardless of enrollment:

  • STATICCALL. A read cannot change the state of the account it reads, so it cannot be a leg of anything this EIP prices (see Excluding STATICCALL). It does not increment either counter.
  • DELEGATECALL. The code of the target runs in the caller's storage context. The account whose state is affected is the caller, which is counted when it is itself called.
  • CREATE and CREATE2. A freshly created account cannot be enrolled in the block of its creation (snapshot rule), so the question does not arise; in later blocks creation is not a call.
  • SELFDESTRUCT beneficiaries, value transfers to enrolled accounts through SELFDESTRUCT, and block reward or withdrawal credits.
  • Calls to an enrolled account that has empty code at the time of the call. An enrolled account whose code was removed cannot be referenced in the counted sense; if code is later placed there through CREATE2 at the same address, enrollment persists and counting resumes.
  • Calls to precompiled contracts.
  • The system calls a client makes to system contracts at the start or end of block processing (for example the EIP-4788 beacon root update), which are not EVM message calls from a transaction.

A call that is counted is counted whether or not it succeeds, whether or not the target frame executes any code, and whether or not value is transferred. The count is taken at the moment the call is issued, before the call's own gas is deducted and before the target frame begins.

Gas accounting

For a counted call with reference ordinal k and slow ordinal m:

fast = min(G_REF * k * k, G_REF_MAX)                if k > K_FREE       else 0
slow = min(G_REF_SLOW * m * m, G_REF_SLOW_MAX)      if m > K_FREE_SLOW  else 0
surcharge = max(fast, slow)

The surcharge MUST be charged to the calling frame, in addition to every other cost of the call as specified by the Yellow Paper, EIP-150, EIP-2929 and later EIPs, and BEFORE the 63/64 gas forwarding computation of EIP-150 is applied. That is, the surcharge reduces the gas available to be forwarded, exactly as the cold-access cost of EIP-2929 does.

Order of charges for a CALL-family opcode into an enrolled target:

  1. static opcode cost;
  2. EIP-2929 cold or warm access cost for the target;
  3. the surcharge for the reference;
  4. value transfer cost and new-account cost, if applicable;
  5. memory expansion;
  6. compute the forwardable gas per EIP-150 from what remains.

For the top-level transaction, the surcharge for tx.to is added to the intrinsic gas of the transaction. A transaction whose gas limit cannot cover intrinsic gas including the surcharge is invalid and MUST NOT be included in a block. Because the surcharge depends on the block-scoped counter, transaction validity for inclusion is evaluated at the transaction's position in the block, the same way balance sufficiency is.

If the calling frame does not have enough gas for the surcharge, the calling frame fails with an out-of-gas exception. The reference has still been counted.

The surcharge participates in the ordinary gas mechanics of the frame: it is part of gas used, it is never refunded, and the EIP-3529 refund cap applies to the transaction's total gas used including surcharges.

Surcharge tracking and distribution

Each transaction carries an accumulator surchargeGas, initialized to zero, to which every surcharge charged in any frame of the transaction is added. Surcharges charged in frames that later revert remain in the accumulator: the gas was consumed and the reference was counted.

At the end of the transaction, after gas refunds are applied and the sender is charged for gas used, the client MUST compute

S        = surchargeGas * effectiveGasPrice
sinkHalf = S / 2           (integer division)
beneHalf = S - sinkHalf

and:

  • treat the surchargeGas portion of gas used as carrying neither base fee nor priority fee under EIP-1559 accounting: the coinbase receives priorityFeePerGas * (gasUsed - surchargeGas), the base fee burn is computed on gasUsed - surchargeGas, and the surcharge's full value S is distributed as follows. No part of the surcharge is burned and no part reaches the coinbase.
  • credit sinkHalf wei to SINK_ADDRESS.
  • credit beneHalf wei to the beneficiary recorded in the registry snapshot for the enrolled account referenced, if the transaction contains references to exactly one enrolled account; otherwise credit each referenced account's beneficiary in proportion to the surcharge gas attributable to that account. Clients MUST therefore track surcharge gas per referenced account within the transaction. Rounding remainders go to SINK_ADDRESS. For an account that enrolled with enroll(), the beneficiary is SINK_ADDRESS and the whole of S goes to the sink.

A beneficiary credit is a balance increase with no message call, identical in mechanism to a withdrawal credit or a coinbase payment. It does not invoke the beneficiary's code and cannot fail. Since every permitted beneficiary is a StakeVault, every wei of surcharge ends as validator stake.

On a chain without a beacon deposit path (a rollup), the same split applies and the vaults bridge to their counterparts on the parent chain, per Stake vaults and the sink.

Stake vaults and the sink

The canonical StakeVault is a contract with a single constructor parameter withdrawalAddress, held in storage and never written again, so that every instance has the same runtime code and STAKE_VAULT_CODEHASH identifies them all. Its interface:

interface IStakeVault {
    struct ValidatorFields {
        bytes32 withdrawalCredentials;
        uint64 effectiveBalance;
        bool slashed;
        uint64 activationEligibilityEpoch;
        uint64 activationEpoch;
        uint64 exitEpoch;
        uint64 withdrawableEpoch;
    }

    /// A validator proven against the parent beacon block root of the block at `timestamp`
    struct ValidatorProof {
        uint64 timestamp;
        uint40 validatorIndex;
        ValidatorFields fields;
        bytes32[] branch;
    }

    event Registered(bytes pubkey, address indexed operator);
    event Staked(bytes pubkey, address indexed operator);
    event Ejected(bytes pubkey, address indexed by);
    event Exited(bytes pubkey, address indexed operator, uint256 bondReturned);

    function withdrawalAddress() external view returns (address);
    function register(bytes calldata pubkey) external;
    function stake(bytes calldata pubkey, ValidatorProof calldata proof) external;
    function eject(bytes calldata pubkey, ValidatorProof calldata proof) external payable;
    function exit(bytes calldata pubkey, ValidatorProof calldata proof) external;
}

A vault never creates a validator. The deposit contract does not check signatures, and the consensus layer ignores the withdrawal credentials of a deposit to a public key it already knows. A vault that made the first deposit for a key could therefore be front-run by a deposit for the same key with other credentials, after which the vault's 32 ETH would fund a validator that pays somebody else; and it could lose 32 ETH outright to a signature that does not verify. So the operator makes the first deposit, and the vault tops up only what it can prove.

Rules:

  1. An operator calls register(pubkey) and then deposits at least 1 ETH for pubkey to DEPOSIT_CONTRACT themselves, with withdrawal credentials 0x01 || 0x00 * 11 || withdrawalAddress. That deposit is the operator's bond. Whoever registered a key is its operator of record; a key can be registered once.
  2. stake(pubkey, proof) MAY be called by anyone. It MUST revert unless pubkey is registered and not yet staked, and proof shows a validator with that public key in the beacon state whose withdrawal credentials are 0x01 or 0x02 followed by eleven zero bytes and withdrawalAddress, which is not slashed and has no exit epoch. If the credentials are 0x01, the validator's effective balance MUST NOT exceed 1 ETH: a balance above 32 ETH on such a validator is swept to the withdrawal address, and a top-up of a funded one would turn stake into balance.
  3. stake MUST revert unless address(this).balance >= bondsOwed + BOND + TOP_UP. It then adds BOND to bondsOwed and deposits exactly TOP_UP to DEPOSIT_CONTRACT for pubkey, computing the deposit data root itself. The caller supplies no credentials, no signature and no root.
  4. A proof is a Merkle branch from the validator to the beacon block root returned by the EIP-4788 contract for proof.timestamp, which MUST NOT be older than MAX_PROOF_AGE. The path is the validator's index in validators, the list's length mix-in, field 11 of a state tree of depth 6, and field 3 of the block header: 50 siblings. A fork that takes the beacon state past 64 fields changes the depth, and with it STAKE_VAULT_CODEHASH.
  5. exit(pubkey, proof) MAY be called by anyone. It MUST revert unless proof shows a withdrawable epoch that has passed. It subtracts BOND from bondsOwed and pays BOND to the operator of record, unless the validator was slashed, in which case the bond stays in the vault.
  6. eject(pubkey, proof) MAY be called by anyone, on a vault whose withdrawalAddress is itself, for an active validator whose effective balance has fallen below 32 ETH. It forwards msg.value to the EIP-7002 contract as the fee of a full exit request.
  7. The vault has no other function that transfers ETH. It has no owner.
  8. SINK_ADDRESS is the StakeVault instance whose withdrawalAddress is SINK_ADDRESS itself. Every reward and every exited balance returns to it and can only be staked again.
  9. The operator runs the validator. Consensus-layer rewards accrue to the withdrawal address, not to the operator. The operator's compensation is whatever the execution layer pays the fee recipient the operator sets, which this EIP does not constrain.

On a chain without a beacon deposit path (a rollup), the vault holds what it receives until it is bridged to the vault with the same withdrawalAddress on the parent chain, where it is staked as above. The bridging contract is outside this EIP.

An account MAY name any StakeVault instance as its beneficiary at enrollment, including one whose withdrawalAddress is the account's own treasury. That treasury receives consensus rewards as they are swept, and the principal only when a validator exits, which the exit queue delays. The share is the enroller's, as stake; the sink's share is nobody's.

Reverts and exceptional halts

The counter is monotone within a block. It MUST NOT be decremented or restored on:

  • REVERT in the called frame or any ancestor;
  • an exceptional halt in the called frame or any ancestor;
  • a failed top-level transaction (out of gas, revert, invalid opcode);
  • a call that fails before the target frame starts, for example because the caller cannot afford value transfer or the call depth limit is reached. In the depth-limit case the reference is still counted, since the client has already resolved the target.

The surchargeGas accumulator is likewise monotone within a transaction.

Block-level access lists

Where EIP-7928 is active, the surcharge on a transaction depends on references made by the transactions before it in the block, and on references its originator made in earlier blocks of the window. Neither is state, so neither would otherwise appear in the block access list, and a client could execute a transaction in parallel only after executing everything before it. The counters MUST therefore be recorded in the block access list.

AccountChanges gains a seventh element, reference_changes, after code_changes:

# ReferenceChange: [block_access_index, refs_after, window_after]
# for one transaction that referenced this account; the originator is that transaction's sender
ReferenceChange = [BlockAccessIndex, uint64, uint64]

# WindowCarry: [originator, window_before]
# wrefs[(account, originator)] at the start of the block
WindowCarry = [Address, uint64]

ReferenceChanges = [List[ReferenceChange], List[WindowCarry]]

AccountChanges = [
    Address,
    List[SlotChanges],      # storage_changes
    List[StorageKey],       # storage_reads
    List[BalanceChange],    # balance_changes
    List[NonceChange],      # nonce_changes
    List[CodeChange],       # code_changes
    ReferenceChanges        # reference_changes
]

Rules:

  1. For every account A enrolled in the block's snapshot and every transaction i that made at least one reference to A, A's reference_changes MUST contain exactly one ReferenceChange at index i, holding refs[A] and wrefs[(A, o)] after transaction i, where o is the transaction's sender.
  2. For every originator o that made a reference to A in the block and for which wrefs[(A, o)] was non-zero at the start of the block, A's reference_changes MUST contain exactly one WindowCarry with that value. In the first block of a window this list is empty.
  3. Every other account carries [[], []]. ReferenceChange entries are ordered by index, WindowCarry entries lexicographically by originator.
  4. Each ReferenceChange and each WindowCarry counts as one item toward bal_items. Every ReferenceChange already pays for at least one cold account access to A within its transaction (2600 gas, or 2400 through an access list), so the limit binds no earlier than it does for the access itself.
  5. A block whose reference_changes do not match execution is invalid, as with any other block access list mismatch.

With these entries a client can price transaction i without executing the transactions before it: refs[A] before i is refs_after at the largest index below i, or zero; wrefs before i is window_after at the largest index below i whose sender is o, else the WindowCarry for o, else zero. A node that joins in the middle of a window can execute the next block from that block's access list alone, and can rebuild the full wrefs map, which it needs to build blocks, by taking the last window_after for each pair from the access lists of the window's earlier blocks, without executing them. Clients that build or validate blocks MUST therefore keep either the wrefs map or the reference_changes of the current window, and a validating client MUST check every WindowCarry against that map: a carry is the one entry a block's own execution cannot confirm, and an inflated carry raises the slow surcharge of the originator it names. On Ethereum mainnet EIP-7928 already retains access lists for the weak subjectivity period, which is longer than W_SLOW; a chain that sets W_SLOW longer than its access list retention MUST retain them for W_SLOW blocks.

On a chain where EIP-7928 is not active, the counters remain client memory as specified above.

JSON-RPC

  • eth_estimateGas and eth_call MUST execute against the pending block's counter as it stands after all transactions preceding the estimated one in the pending block, or, when no pending block is being built, against an empty counter. Implementations SHOULD document which.
  • Clients SHOULD expose the surcharge separately in debug_traceTransaction output as a per-call field referenceSurcharge.
  • A new optional method eth_getReferenceCount(address, blockTag) MAY be provided, returning the counter value for the address in the specified block (zero for unenrolled or unreferenced accounts).

Activation

This EIP activates at FORK_BLOCK. At activation the client MUST:

  1. set the code of REGISTRY_ADDRESS to the canonical registry code with empty storage;
  2. set the code of SINK_ADDRESS to the canonical StakeVault code with withdrawalAddress == SINK_ADDRESS;
  3. begin maintaining the counter and registry snapshot from the first block at or after FORK_BLOCK.

Nothing can be enrolled before FORK_BLOCK, so the first block after activation runs with an empty enrollment set and the gas schedule is unchanged until an account enrolls.

Rationale

Block scope rather than transaction scope

Transaction scope, which could be implemented today in application code with transient storage, is defeated by splitting a machine across a bundle of transactions in one block. On sequencer chains that is how same-block add and remove is actually executed. Block scope prices the bundle as one thing. Transaction-scoped escalation remains available to applications and composes with this EIP.

Two ratchets

An earlier draft had one global per-block counter, and a later one paired it with a per-originator counter over a few minutes. It prices a bundle correctly and cannot be evaded with fresh wallets, but it cannot see the same wallet coming back in the next block, and on chains where gas is nearly free it can be griefed cheaply (see Security Considerations). A counter keyed by originator alone cannot be griefed and sees the returning wallet, but a machine that splits its legs across two originators in one block pays nothing. Keeping both, and charging the larger, closes each one's hole with the other: the fast global ratchet catches the split bundle, the slow originator ratchet catches the return visit. The slow ratchet is sized to the ninetieth percentile of ordinary use, sixteen references per wallet per account per week, so a person who trades a token a dozen times over a week never pays it, while a machine that returns thirty times does. K_FREE = 2 on the fast ratchet spares a single swap in a quiet block, which is two references on a v4 route. Nothing spares a bystander in a busy one (see Bystanders in busy blocks).

Global rather than per sender

A counter keyed by tx.origin, by the calling contract, or by any sender-side identity is defeated by fresh wallets and fresh helper contracts at no cost. A global counter has an honest cost: an enrolled contract pays for its own organic volume once it exceeds K_FREE references per block. Two things bound that cost. Enrollment is a choice, made by the account itself, with consequences that are stated below and that cannot be shed in less than a window. And the surcharge is the only cost that scales: at mainnet constants the 10th reference in a block costs 200,000 gas extra, the 30th costs 1,800,000, and the cap binds from the 50th. A contract that expects hundreds of organic references per block should not enroll; a token in its launch week, whose organic activity is tens of references per block and whose adversary's activity is hundreds, should.

Quadratic rather than linear or exponential

A linear surcharge is a flat per-reference tax that a machine with positive expected value per reference absorbs and passes to its victims. Quadratic growth means the marginal cost of the machine's next reference grows with the machine's own size, so the largest surfaces (many pools, many rungs) are priced out first while a router that hops a token through two or three venues pays a few multiples of G_REF. Exponential growth was considered and rejected because it would make even a three-hop route prohibitive at any base constant large enough to matter for the machine.

Counting calls rather than storage writes

Storage writes to an enrolled account occur inside frames executing that account's code, so counting them would require per-account accounting inside the interpreter loop. Counting calls happens once per frame boundary, where EIP-2929 accounting already occurs, and captures every way an external actor can reference an account.

Counting reverted references

A reverted call leaves no state behind, but it is not free and it is not meaningless. It reads the state it was sent against (a price, a reserve, whether a pool exists, whether a launch is live) and its revert is the decision not to act on what it read. That is how the machine this EIP prices works: it sends many attempts into the same account in a block, each guarded by a condition, and reverts every one except the one that wins. Within a transaction it does the same thing one frame down, trying references in subframes and reverting the losers. If the counter rolled back with the frame, every losing attempt would be free and only the winner would be counted, so the surcharge would fall on one reference per block no matter how many were sent. The repetition this EIP prices would be invisible exactly where it is heaviest.

The counter is also not EVM state. No opcode reads it, and a frame that reverts leaves nothing an EVM program can observe; the only effect is on the gas of later references. That is the same kind of effect a reverted transaction already has: its gas is used and paid, its nonce is consumed, and its gas counts toward the block's fullness and so toward the next block's base fee under EIP-1559. EIP-2929's access sets do roll back on revert, and the difference is deliberate. Warmth describes what a frame has loaded, which a revert discards. The counter describes what has been attempted, which a revert cannot undo.

What a single operation costs depends on the venue

The unit that is counted is the reference, not the user's intent. A swap that reaches the enrolled account once is one reference; the same swap through a venue that reaches it four times is four. "A single swap pays nothing" therefore holds when the venue's route makes at most K_FREE references and nothing else has referenced the account in the block. It does not hold for every venue or every block. A replay of the token-level form over one token's full history found the largest payers to be the pool manager and ordinary routers, not dedicated extraction contracts. That is the mechanism pricing repetition wherever it occurs, and it is the reason K_FREE is 2 and not 1. A venue that needs more references per operation than the free allowance passes a cost to its users that a venue with a tighter route does not.

The cap

G_REF_MAX binds from the 50th reference in a block at mainnet constants. A contract that legitimately needs more than about fifty references per block is priced out, and that is intended: such a contract should not enroll. The slow ratchet does not carry that case. It is keyed by originator and exists to price the returning wallet, not to relieve the global counter.

Excluding STATICCALL

An earlier draft counted reads. A replay settled it. Every transaction that moved any of 24 tokens on Robinhood Chain was traced, 8,019 transactions from 2,734 originators, and each call into the token was recorded by type. Of 62,168 calls, 57% were STATICCALL, and 98% of those were balanceOf: routers and pools checking balances before and after a transfer. Priced at the rollup constants, counting reads charged 80% of ordinary wallets on their first transaction on a token. Exempting reads brought that to 31% and cut the surcharge on ordinary wallets elevenfold, against eightfold for wallets matching the machine's shape. Reads carry no extraction, since a static frame cannot change the state it reads, and they are the larger part of what a bystander's venue does on the bystander's behalf.

The figures in this section were measured under the earlier draft's rule, which charged the fast surcharge only from an originator's own third reference in a block. Without that gate more ordinary wallets are charged, and the call-level replay has not been rerun; the comparison between counting and exempting reads does not depend on it.

With reads exempt, the replay leaves 31% of ordinary wallets charged on their first transaction on a token, and the constants are kept as they are. Those wallets are not people who approve and then swap: that is two transactions in two blocks, the fast counter resets between them, and the pair costs nothing. Of the approvals seen inside ordinary wallets' transactions, 95% were issued by a router or aggregator in the middle of the swap, not by the wallet. What is charged is a route that makes three or more state-changing calls into the token for one operation. That cost belongs to the venue that chose the route, and a venue with a tighter route does not pass it on. Raising K_FREE to fit the loosest common route would spare those venues and the machine alike: at K_FREE = 8 the share of machine-shaped transactions charged falls from 66% to 16%. Exempting approvals by selector was also considered and rejected for the protocol form, because the client cannot know what a selector means and any contract may name an extractive function approve; STATICCALL is exempt because the client itself guarantees a static frame changes nothing.

Excluding DELEGATECALL

Counting DELEGATECALL targets would count on the implementation contract shared by every proxy that uses it, so one enrolled implementation would make every one of its proxies expensive collectively. The account whose storage a machine manipulates is the proxy; the proxy is what enrolls and what is counted.

The registry snapshot

Without the snapshot rule, a contract could enroll itself in the middle of a block after its own references were free and before a competitor's, or a client would have to re-read registry storage on every call. Taking the end-of-previous-block set once per block gives every client a fixed input and makes enrollment a visible, one-block-delayed event.

Staked, not burned, and not paid to the producer

Burning the surcharge would benefit every holder of ETH pro rata, including the actor being priced. Paying it to the block producer would give builders a reason to induce references. Staking it does neither: the ETH stays in the system as security budget, it belongs to nobody, and it cannot be withdrawn. Half goes to a sink with no owner, no governance and no exit, so the practice being priced funds the network's validators forever. The other half goes to a stake vault of the enroller's choosing, so an issuer that enrolls has skin in the same game and receives its share only as consensus rewards over time. No foundation, no research organization, no general partner is in the path.

Stake vaults as the only permitted beneficiary

If an enroller could name an arbitrary beneficiary, enrollment would become a way to charge callers a private toll in ETH, which is the practice this EIP exists to price. Requiring the beneficiary to be a stake vault means the enroller's half is locked into validator duty and reaches the enroller only as consensus rewards and eventual exits, at the pace of the beacon chain's withdrawal queue. The code-hash check is cheap, needs no registry of vaults, and lets anyone deploy a vault with the withdrawal address of their choice.

Opting in and opting out

Enrollment is a choice with consequences on both sides, and they are stated here so nobody enrolls, or declines to, by accident.

A token that enrolls gets this and only this: the second and later calls into it within a block cost the caller superlinear gas that the caller cannot recapture. Repeated fills, separately settled adds and removes, and multi-venue routing through the token all pay. Pool initialization, and a sequence a venue nets into one settlement, do not (see What this does not price). So does the token's own organic volume once it exceeds K_FREE references in a block, which is the price of the choice and the reason the constants are per chain. Enrollment does not stop ordering attacks: a sandwich is two references, not two hundred, and is priced elsewhere. Leaving takes at least a full window and is announced when it is requested, so a token cannot switch the surcharge off for a block, a bundle or a buyer. And the issuer never receives the surcharge as spendable ETH: half is staked in the no-owner sink and half in a stake vault the issuer named at enrollment, or all of it in the sink if the issuer named none. Enrolling cannot be turned into the issuer's own toll.

A token that does not enroll changes nothing for itself. Every call costs what it costs today, every machine that walks launches keeps walking it, and every router prices it the same as before the fork. The chain does not force the choice and does not need to: wallets, explorers and launchpads can read isEnrolled and show it. A launchpad that leaves its tokens unenrolled is stating, in a way any interface can display, that it is fine with its users being order flow for the machine. That is a legitimate position; this EIP only makes it visible.

For extractors nothing is banned. A bot that touches hundreds of tokens a month is unaffected on every one that did not enroll, and on an enrolled token it pays a price that grows with the size of its own surface. Extraction at the scale of one arbitrage is still cheap; extraction as a practice, the same machine on every launch, is what becomes expensive.

For the chain the surcharge is gas, so it becomes validator stake, never a payment to an intermediary and never producer revenue. Block builders can reorder enrolled-token transactions to shift who pays a surcharge, as they can reorder to shift EIP-2929 warmth today.

What this does not price

A reference is a call into the enrolled account. When a token enrolls, three things the motivating machine does are therefore outside this EIP:

  • Pool initialization. Initializing a pool on a singleton venue calls the venue, not the token, and moves no balance. Pool spam, and a price ladder built by initialization alone, are invisible to the token and to this counter. Pricing them needs the venue.
  • Netted sequences. A flash-accounting venue settles any sequence of adds, swaps and removes inside one unlock with at most one net transfer per token, so the sequence is one or two references however long it is.
  • Anything that needs only a few calls. A sandwich is two references. A searcher that simulates off chain and lands one transaction makes one.

What remains is what has to be on chain to be worth anything: each fill, each separately settled liquidity move, each return visit. The surcharge is a price on those, in proportion to how much of a block one account's traffic takes. It is not a deterrent and is not sized to be one.

Leaving with notice rather than never

An earlier draft made enrollment irrevocable, so that a token could not enroll for its launch and then sell the surface back. That shut the door on a contract whose circumstances change (a token that outgrows the free allowance, a venue that migrates) and gave the contract no way to undo a decision the protocol lets it make. What irrevocability actually protected was narrower: a caller should never find the surcharge gone for the one block, bundle or counterparty where removing it was worth something to the issuer.

scheduleExit keeps that. The exit is public from the moment it is requested, takes effect only at the start of the window after next, so never sooner than W_SLOW blocks, and cannot be cancelled or moved. It always lands on a window boundary, where wrefs is discarded anyway, so no originator's slow count is cut off part way. A contract that has left cannot enroll again until a full window after its exit, so it cannot schedule an exit and re-enroll inside the exit block to open a one-block hole a window in advance: any gap it opens lasts more than a window and is announced a window ahead. What a contract still cannot do is decide per call or per caller whether the surcharge applies: that discretion would let it waive the surcharge for its own bundles and charge everyone else, which is the toll this EIP is written to prevent.

Counters in the access list, not in state

The cross-transaction dependency that block-scoped storage proposals create for EIP-7928 exists here too: the surcharge on transaction i depends on what came before it. Putting the counters in state would make them visible to the access list for free, but refs would then be written and cleared for every referenced account in every block, and wrefs, keyed by originator, would grow state with every new wallet and could not be cleared at the end of a window without iterating it. Recording them in the access list gives a parallel executor and a node joining mid-window everything they need, commits to them through block_access_list_hash, and leaves state exactly as it was. The cost is a few bytes per referencing transaction and three bytes per other account.

Why not extend EIP-2929 directly

EIP-2929's sets are per transaction, are reset at every transaction boundary, and price the first access higher than later ones. This EIP's map is per block and prices later accesses higher than the first. Reusing the same data structure would conflate two signals with opposite shapes. The implementation is nevertheless the same kind of object, consulted at the same point in the call path.

Why a system contract and not an account flag

A per-account flag in the state trie needs an account format change and a migration. A system contract whose storage the client reads once per block needs neither, has precedent in EIP-4788, and keeps the enrollment set queryable by ordinary contracts through isEnrolled.

Backwards Compatibility

No account is enrolled at activation, so no existing transaction, contract or tool changes behavior at the fork. Changes appear only for accounts that enroll afterwards, and only for callers of those accounts:

  • Gas cost of a call into an enrolled account becomes dependent on block context. Tooling that assumes a call's gas is a function of state alone (fixed-gas relayers, some bundle simulators, some meta-transaction forwarders) will under-estimate for enrolled targets and MUST consult isEnrolled or the pending counter.
  • EIP-1559 fee accounting changes for transactions that include surcharges: both the base fee burn and the coinbase's priority fee are computed on gas used net of surcharge gas, and the surcharge's value follows the split above. Transactions with no surcharges are accounted exactly as before.
  • Contracts that enroll and are also DELEGATECALL implementations are not counted through that path, as specified.
  • The intrinsic gas of a transaction whose tx.to is enrolled varies with position in the block. Transaction pools SHOULD treat the maximum possible surcharge (G_REF_MAX) as the requirement for admission and let block builders evaluate the actual value at inclusion.

This EIP requires a hard fork.

Test Cases

Notation: A is an enrolled account with non-empty code; U is an unenrolled account; P is a proxy that DELEGATECALLs into A; costs are stated as gas above the pre-fork cost of the same operation. Mainnet constants.

  1. Unenrolled is unchanged. 100 CALLs into U in one block, across any number of transactions: surcharge 0 on every call.
  2. Enrollment delay. A calls enroll() in block N. In block N, 5 calls into A after the enrollment transaction: surcharge 0 on every call. In block N+1, the same 5 calls: surcharges 0, 0, 18000, 32000, 50000.
  3. Cross-transaction counting. Block with transactions 1, 2 and 3 each calling A once, from three different senders: transactions 1 and 2 pay 0, transaction 3 pays 18000.
  4. Reverted call counts. Transactions 1 and 2 each call A and revert. Transaction 3 calls A: pays 18000. The surchargeGas of transactions 1 and 2 is 0 (ordinals 1 and 2); their gas used includes no surcharge.
  5. Reverted frame keeps surcharge. One transaction: CALL A twice (free), then a subcall that does CALL A (ordinal 3, 18000) and reverts. The transaction's surchargeGas is 18000 and its gas used includes it.
  6. Cap. 60 calls into A in one block: the surcharge for ordinal 50 is min(2000 * 2500, 5000000) = 5000000; for ordinal 51 it is 5000000.
  7. DELEGATECALL excluded. CALL P, where P is unenrolled and P DELEGATECALLs A: surcharge 0. If P is enrolled: CALL P is counted on P, and the DELEGATECALL into A is not counted on A.
  8. STATICCALL not counted. 100 STATICCALLs into A in one transaction: surcharge 0 on every one, and refs[A] and wrefs are unchanged afterwards.
  9. Top-level target counted. Transaction with tx.to == A, after two prior calls into A in the block: intrinsic gas is increased by 18000; a gas limit that covers the pre-fork intrinsic gas but not the increase makes the transaction invalid for inclusion at that position.
  10. Out-of-gas on surcharge. A frame with 5,000 gas remaining performs CALL A at ordinal 3 (surcharge 18000): the frame halts with out-of-gas; the counter for A is now 3.
  11. Empty code. A is enrolled but has no code: calls into A are not counted and pay no surcharge.
  12. Distribution, single account. Transaction with surchargeGas = 50000 (ordinals 3 and 4), effective gas price 100 gwei: S = 5,000,000 gwei; 2,500,000 gwei credited to SINK_ADDRESS; 2,500,000 gwei credited to beneficiaryOf(A); base fee burn and coinbase priority fee both computed on gasUsed - 50000. If A enrolled with enroll(), SINK_ADDRESS receives the full 5,000,000 gwei.
  13. Distribution, two accounts. Transaction references enrolled A (surcharge 18000) and enrolled B (surcharge 32000): SINK_ADDRESS receives S / 2; of the other half A's beneficiary receives 18/50 and B's receives 32/50; the wei remainder goes to SINK_ADDRESS.
  14. Registry rejects bad beneficiary. enroll(U) where U.codehash != STAKE_VAULT_CODEHASH reverts; enroll(V) where V is a StakeVault succeeds and beneficiaryOf(msg.sender) == V.
  15. Registry rejects re-enrollment while enrolled. Second enroll() from the same account reverts, and so does enroll() after scheduleExit() until W_SLOW blocks after the exit block.
  16. Vault stakes only to the deposit contract. V.stake(...) with msg.value != BOND reverts; with a vault balance below 32 ether + totalBonds reverts; a successful call emits a deposit with credentials 0x01 || 0 * 11 || V.withdrawalAddress().
  17. eth_estimateGas. After 9 references to A in the pending block, estimating a transaction that calls A once returns the base estimate plus 200000.
  18. Leaving takes a window. A calls scheduleExit() in block N = 3 * W_SLOW + 10: exitBlockOf(A) == 5 * W_SLOW. In block 5 * W_SLOW - 1, 5 calls into A from one originator pay 0, 0, 18000, 32000, 50000. In block 5 * W_SLOW, the same 5 calls pay 0 and refs[A] stays 0. A second scheduleExit() before the exit reverts.
  19. Re-enrollment after exit. A exited at block E. enroll(V) from A reverts in block E and in block E + W_SLOW - 1. In block E + W_SLOW it succeeds: calls into A in that block are not counted; from block E + W_SLOW + 1 they are, beneficiaryOf(A) == V and exitBlockOf(A) == 0.
  20. Access list entries. With EIP-7928 active, a block whose transactions 1 and 3 each call A once from the same sender o, which made 4 references to A in earlier blocks of the window: A's reference_changes is [[[1, 1, 5], [3, 2, 6]], [[o, 4]]]. Every account without references carries [[], []].
  21. Slow ratchet. Originator o has made 16 references to A in earlier blocks of the window. Its next reference, the first to A in its block: slow ordinal 17, surcharge 400 * 17 * 17 = 115600. The same call from an originator with no earlier references in the window: 0.
  22. Larger of the two. The same originator o makes that reference as the 10th to A in its block: fast 200000, slow 115600, surcharge 200000.

Reference Implementation

Registry

// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.26;

contract ReferenceRegistry {
    bytes32 constant STAKE_VAULT_CODEHASH = 0x0; // set at deployment
    address constant SINK = 0x000000000000000000000000000000000000e5CB;

    uint256 constant W_SLOW = 50400;

    mapping(address => address) private _beneficiary; // slot 0
    mapping(address => uint256) private _exitBlock;   // slot 1

    event Enrolled(address indexed account, address indexed beneficiary);
    event ExitScheduled(address indexed account, uint256 exitBlock);

    function enroll() external { _enroll(SINK); }

    function enroll(address beneficiary) external {
        require(beneficiary.codehash == STAKE_VAULT_CODEHASH, "not a stake vault");
        _enroll(beneficiary);
    }

    function scheduleExit() external {
        require(_enrolled(msg.sender) && _exitBlock[msg.sender] == 0, "no exit to schedule");
        uint256 exitBlock = (_height() / W_SLOW + 2) * W_SLOW;
        _exitBlock[msg.sender] = exitBlock;
        emit ExitScheduled(msg.sender, exitBlock);
    }

    function _enroll(address beneficiary) internal {
        require(!_enrolled(msg.sender), "enrolled");
        uint256 e = _exitBlock[msg.sender];
        require(e == 0 || _height() >= e + W_SLOW, "cooling down");
        _beneficiary[msg.sender] = beneficiary;
        _exitBlock[msg.sender] = 0;
        emit Enrolled(msg.sender, beneficiary);
    }

    function _enrolled(address a) internal view returns (bool) {
        uint256 e = _exitBlock[a];
        return _beneficiary[a] != address(0) && (e == 0 || _height() < e);
    }

    /// The chain's own block height (see Block sequence); NUMBER on Ethereum.
    function _height() internal view returns (uint256) { return block.number; }

    function isEnrolled(address a) external view returns (bool) { return _enrolled(a); }
    function beneficiaryOf(address a) external view returns (address) { return _enrolled(a) ? _beneficiary[a] : address(0); }
    function exitBlockOf(address a) external view returns (uint256) { return _exitBlock[a]; }
}

Client hook

In the shape of go-ethereum's core/vm:

// enrolled and refs are built once per block, enrolled from the registry's
// storage at the parent state. wrefs is kept for the whole window.
type refState struct {
    enrolled map[common.Address]common.Address // account -> beneficiary
    refs     map[common.Address]uint64
    wrefs    map[[2]common.Address]uint64 // (account, originator)
}

// Per transaction.
type surchargeLedger struct {
    total   uint64
    perAcct map[common.Address]uint64
}

func (evm *EVM) chargeReference(caller *Contract, target common.Address) error {
    if _, ok := evm.refState.enrolled[target]; !ok { return nil }
    if evm.StateDB.GetCodeSize(target) == 0 { return nil }
    k := evm.refState.refs[target] + 1
    evm.refState.refs[target] = k
    key := [2]common.Address{target, evm.TxContext.Origin}
    m := evm.refState.wrefs[key] + 1
    evm.refState.wrefs[key] = m
    var s uint64
    if k > params.KFree { s = min(params.GRef*k*k, params.GRefMax) }
    if m > params.KFreeSlow { s = max(s, min(params.GRefSlow*m*m, params.GRefSlowMax)) }
    if s == 0 { return nil }
    if !caller.UseGas(s) { return ErrOutOfGas }
    evm.ledger.total += s
    evm.ledger.perAcct[target] += s
    return nil
}

chargeReference is called from opCall and opCallCode after the target address is popped and after EIP-2929 access accounting, and once from the transaction entry point for tx.to. For the top-level call the caller is the transaction's intrinsic gas accounting.

End-of-transaction distribution

func finalizeSurcharge(st *StateTransition, ledger *surchargeLedger) {
    if ledger.total == 0 { return }
    price := st.effectiveGasPrice
    S := new(big.Int).Mul(big.NewInt(int64(ledger.total)), price)
    sinkHalf := new(big.Int).Div(S, big.NewInt(2))
    beneHalf := new(big.Int).Sub(S, sinkHalf)
    // base fee burn and coinbase tip are computed elsewhere on (gasUsed - ledger.total)
    remainder := new(big.Int).Set(beneHalf)
    for acct, g := range ledger.perAcct {
        share := new(big.Int).Div(new(big.Int).Mul(beneHalf, big.NewInt(int64(g))), big.NewInt(int64(ledger.total)))
        st.state.AddBalance(evm.refState.enrolled[acct], share) // a StakeVault, or the sink
        remainder.Sub(remainder, share)
    }
    st.state.AddBalance(params.SinkAddress, new(big.Int).Add(sinkHalf, remainder))
}

Token-level form

Where this EIP is not active, the same rule can be carried by the token itself. An ERC-20 token counts its own transfers as references, keeps both counters in storage, keys "block" on the chain's own block (on Arbitrum-family chains via the ArbSys precompile, since NUMBER reports the parent chain), and takes the surcharge in kind because a transfer cannot take ETH from its sender. Everything it takes is paid to a settler with no owner; anyone may settle, which sells the tokens for ETH through the token's own market and splits the ETH exactly as the protocol form does: half to the chain's sink, an ownerless contract that can only stake (on a rollup with no validator set, only hold), half wrapped to the stake pool the deployer fixed, which must be a pool and not a wallet. Nothing is burned. A settler's own sales are not references. Flash-accounted venues net a sequence into one transfer at this layer, and into one call at the protocol layer too; the token form is what can ship on any EVM chain today, and it has run on Robinhood Chain since 2026-09-28. The contracts are the token, the settler, the sink and the venue interface, with a README.

Stake vault

The stake vault implements the rules above. Its deposit data root is tested against the mainnet deposit contract, which accepts the top-up. Its proof verification is tested against proofs taken from a finalized mainnet state under Fulu: the state was decoded, its root was checked against the block header the chain reports, and the branches were read off the tree for an active validator with 0x01 credentials, one with 0x02 credentials and one that has exited. STAKE_VAULT_CODEHASH is the keccak256 of its runtime code compiled as the asset states.

Security Considerations

Griefing an enrolled account

Anyone may spend gas to raise an enrolled account's counter early in a block, making every later caller in that block pay more. The griefer pays the same escalating schedule and cannot recover any of it; what it pays becomes stake in the sink and in the target's own vault. At mainnet constants, taking A from ordinal 1 to the cap costs the griefer sum_{k=3}^{50} min(2000 k², 5,000,000) = 2000 * (sum_{k=3}^{50} k²) = 2000 * 42,920 = 85,840,000 gas, or roughly three full mainnet blocks of gas at the 30M target, per block, forever. Keeping A at the cap afterwards costs 5,000,000 gas per additional reference. This is a strictly more expensive form of the spam that is already possible against any contract by filling blocks, and it is bounded the same way: by the block gas limit and by the griefer's willingness to burn ETH into a sink that benefits the network.

Griefing where gas is cheap

The bound above is a gas bound. On a chain where a full block of gas costs a few cents, filling refs[A] with dust references every block is affordable, and every bystander then pays the cap for the price of one reference. The token-level form saw exactly this exposure, made worse by a NUMBER that reported the parent chain and so stretched "one block" to twelve seconds. The mitigations are the ones specified here: key on the chain's own block so the griefer must pay every 250 ms rather than every 12 s; set K_FREE = 2 so a single collision is free; and price the returning wallet with the slow ratchet, which nobody can inflate for anyone else, so a chain that finds the fast ratchet too exposed can raise K_FREE without giving up on repetition altogether.

Bystanders in busy blocks

The fast surcharge is charged to whoever makes the k-th reference, so an ordinary user whose call lands third or later in a block pays for the block's busyness. An earlier draft exempted an originator until its own third reference in the block. That exemption is free to evade: a crew that spreads its references over many originators never reaches its own third, and in the token-level deployment one did, thirteen wallets in the same block, and paid nothing. Any allowance keyed on the sender has this property, so this specification has none.

The cost to bystanders is bounded by K_FREE, by the account's choice to enroll, and by block time. In a replay of the token-level form over one token's first two hours, wallets outside the crew paid 0.6 basis points of their volume on 250 ms blocks, and 21 when the same trades were grouped into 12-second windows. A chain with long blocks, or an account with heavy organic traffic, should expect the second figure and weigh enrollment accordingly.

Cross-block repetition

The slow ratchet prices one originator across a window. A machine can still evade it with a fresh originator per leg; what it cannot do is add liquidity from one wallet and remove it from another without a transfer of the position in between, which is itself a reference at the venue. The window is a cost-of-defense choice, not a guarantee, and the numbers here are the ones the token-level form has run with.

Builder and proposer behavior

The counter depends on transaction order, so a builder can order a block to determine which caller of an enrolled account pays the higher ordinals. This is the same power a builder has today over EIP-2929 warmth and over every ordering-dependent cost, and the surcharge cannot be redirected to the builder: it goes to the sink and to a stake vault of the enroller's choice, never to the coinbase. A builder's only lever is to shift cost between users, not to capture it.

Enrollment as a weapon

Only an account can enroll itself. A contract can enroll and then call itself repeatedly to burn its callers' gas, but any contract can already burn its callers' gas with a loop, and the surcharge portion is worse for the contract than a loop because the contract's callers will simply route elsewhere. A malicious implementation behind a proxy cannot enroll the proxy; only the proxy can.

Interaction with account abstraction

An ERC-4337-style entry point that calls an enrolled account on behalf of many user operations in one bundle pays escalating surcharges on those calls, and bundlers MUST model this in their gas estimation. Entry points and paymasters SHOULD NOT enroll themselves; the intent of this EIP is that tokens and venues enroll, not routers.

Interaction with EIP-7702

An EOA with a delegation designator has code and may enroll by executing a call to the registry under its delegated code. It is then counted like any contract. Calls that the delegated code makes to other enrolled accounts are counted normally.

State growth

The registry stores one word per enrolled account. The counter is transient client memory bounded by the number of distinct enrolled accounts referenced in a block. Neither grows with the number of references.

Client complexity and consensus risk

The rule touches the call path of the interpreter and the end-of-transaction fee accounting. Both are already fork-dependent. A divergence between clients in whether a given call is counted (for example in the depth-limit or empty-code edge cases) would be a consensus failure, which is why those cases are specified explicitly above and enumerated in the test cases. Implementations MUST match on every test case.

Predictability of victims

The referenced study closes by noting that even encrypted mempools may not suffice "if the victim is too predictable". A token launch is the most predictable trade there is. This EIP does not make a launch less predictable; it makes the machine that exploits predictability pay in proportion to its own size, in a currency it cannot get back, and that currency becomes the stake securing the chain it is running on. That is the strongest guarantee available at the protocol layer without constraining what anyone is allowed to deploy.

Copyright and related rights waived via CC0.

EIP.tools

EIP.tools

Search, read, and map Ethereum improvement proposals, ERCs, RIPs, and CAIPs from one focused interface.

Farcaster
by @apoorveth