Skip to content

EIP-8037/8038 (Glamsterdam) invalidates the profiled gas: 80000 lzReceive default — measured on mainnet, plus a suggested config-audit task #1952

Description

@dlebee-agent

Summary

examples/oft/layerzero.config.ts#L22-L28 ships:

// For production, you should ensure `gas` is set to the correct value through profiling
// the gas usage of calling OFT._lzReceive(...) on the destination chain
    msgType: 1,
    optionType: ExecutorOptionType.LZ_RECEIVE,
    gas: 80000,

The guidance is right, but "profile it" yields a value pinned to the gas schedule at profiling time. The Glamsterdam-candidate EIPs EIP-8037 (state gas) and EIP-8038 (access/write repricing) raise the cost of creating one new storage slot by +87,920 gas.

This has two independent consequences for LayerZero V2 on Ethereum, both of which I measured against mainnet rather than estimated:

  1. Enforced lzReceive gas options become too small to execute. A live OFT adapter handling ~315 messages/day is granted exactly 82,081 gas, and its normal path needs ~146,700 after the repricing. The delivery transaction reverts OOG.
  2. EndpointV2 verification cost rises permanently and unrefundably. Every inbound message creates one inboundPayloadHash slot during verification: +87,920 gas per message, ~2,700 messages/day into Ethereum, with no state-gas refund when lzReceive later clears it.

Nothing here is exploitable today — this is a forward-looking compatibility report so the numbers are on the record before the fork, not a vulnerability disclosure. Everything below is reproducible from public RPC; commands are at the bottom.

Measured at Ethereum mainnet block ~25,837,891. Source permalinks pinned to 9c741e7.


What the EIPs change

EIP-8037 introduces a second gas dimension, state gas:

CPSB                        = 1,530  gas per byte of new state
STATE_BYTES_PER_STORAGE_SET =    64  bytes
GAS_STORAGE_SET:  20,000  →  64 × 1,530  =  97,920 state gas

EIP-8038 reprices the execution side:

STORAGE_WRITE          2,800 → 10,000   (+257%)
COLD_STORAGE_ACCESS    2,100 →  2,100   (+0%)
WARM_ACCESS              100 →    100   (+0%)

Because the access component is unchanged, the delta is identical warm or cold:

Δ = +87,920 gas per newly created storage slot   (20,000 → 10,000 + 97,920)
Δ = +7,200  gas per write to an already-existing slot

That single invariant drives everything below.


Part 1 — enforced lzReceive gas options

The line that makes the gas limit a hard cap

Executor.sol#L131-L140:

function execute302(ExecutionParams calldata _executionParams) external payable onlyRole(ADMIN_ROLE) nonReentrant {
    try
        ILayerZeroEndpointV2(endpoint).lzReceive{ value: msg.value, gas: _executionParams.gasLimit }(

gas: _executionParams.gasLimit is an explicit stipend. It is not gasleft(), and it is not derived from the destination chain's current gas schedule — it is a number carried in the message and computed off-chain from the options.

Where that number originates

OptionsBuilder.sol#L45-L60 — the OApp author writes it:

/**
 * @param _gas The gasLimit used on the lzReceive() function in the OApp.
 */
function addExecutorLzReceiveOption(bytes memory _options, uint128 _gas, uint128 _value)

OAppOptionsType3.sol#L16 — and it is persisted on-chain, on the source chain:

mapping(uint32 eid => mapping(uint16 msgType => bytes enforcedOption)) public enforcedOptions;

This is the crux. The gas assumption for Ethereum execution is a constant frozen in storage on Polygon, Arbitrum, Optimism, BSC, and 60+ other chains. It was profiled against Ethereum's pre-fork gas schedule and there is no mechanism by which an Ethereum hard fork updates it.

ExecutorFeeLib.sol#L125 adds the per-destination base gas that covers endpoint overhead:

totalGas = _lzReceiveBaseGas + aggOptions.totalGas + _lzComposeBaseGas * aggOptions.numLzCompose;

Measured: the declared option is the budget

I traced 98 real PacketDelivered deliveries on Ethereum. Of 35 distinct gas values observed at the OApp's lzReceive frame, 25 are exactly a round number + 2,081:

 77,081 =  75,000 + 2,081        282,081 = 280,000 + 2,081
 82,081 =  80,000 + 2,081  (x20) 302,081 = 300,000 + 2,081  (x7)
102,081 = 100,000 + 2,081  (x10) 382,081 = 380,000 + 2,081
147,081 = 145,000 + 2,081        462,081 = 460,000 + 2,081
152,081 = 150,000 + 2,081        582,081 = 580,000 + 2,081
172,081 = 170,000 + 2,081  (x5)  882,081 = 880,000 + 2,081
202,081 = 200,000 + 2,081  (x6)  902,081 = 900,000 + 2,081

lzReceiveBaseGas cleanly absorbs the EndpointV2 overhead and the EIP-150 63/64 loss, so the number an integrator writes in addExecutorLzReceiveOption is, to within ~2k, the gas actually available inside _lzReceive. This is good design, and it is also exactly why a repricing bites so precisely.

A concrete live example

USDT OFT Adapter 0x6c96de32cea08842dcc4058c14d3aaad7fa41dee (token()0xdAC17F958D2ee523a2206206994597C13D831ec7), 315 deliveries in 24h from 15 source chains.

Enforced options on live peers, enforcedOptions(30101 /* Ethereum */, 1 /* SEND */):

Polygon   peer 0x6ba10300f0dc58b7a1e4c0e41f5dabb7d7829e13
Optimism  peer 0xf03b4d9ac1d5d1e7c4cef54c2a313b9fe051a0ad
both →  0x00030100110100000000000000000000000000013880
                                            ^^^^^ 0x13880 = 80,000

Call frame from trace_replayTransaction, identical in 7 of 7 samples:

Executor    0x1732…3059                 gas 207,243
 └ EndpointV2 0x1a44…728c               gas 100,269   ← execute302 gasLimit
    └ OFT Adapter 0x6c96…1dee           gas  82,081   ← _lzReceive budget
       └ USDT 0xdAC1…1ec7               gas  65,329

The payout path is OFTAdapter._credit#L96-L105:

// @dev Unlock the tokens and transfer to the recipient.
innerToken.safeTransfer(_to, _amountLD);

If _to has never held the token on Ethereum, balances[_to] goes 0 → nonzero. That is a state creation, and it is the ordinary path, not an edge case.

Measured on tx 0x029e8bae77ec7c0c035ecbe7cd0b47b1880486d82787e86e2369ae890893b5da:

lzReceive        granted 82,081   used 58,783
  USDT.transfer  used 41,601      (vs 24,501 to an existing holder)
  delta          17,100  ==  SSTORE_SET(20,000) − SSTORE_RESET(2,900)
  stateDiff      USDT balances[to]: 0 → nonzero        ← state creation

Post-repricing:

needed   = 58,783 + 87,920                     = 146,703
           (+ the sibling balances[from] write) ≈ 153,800
granted  =                                        82,081
──────────────────────────────────────────────────────────
deficit  ≥ 64,622                              → out of gas

Others measured the same way

receiver                                 msgs/24h  granted     used  new slot  post-fork  verdict
0x6c96de32…  USDT OFT Adapter (USDT)         315    82,081   58,783  yes        146,703  breaks
0x211cc4dd…  sUSDe OFT Adapter                42    82,081   46,723  6 of 7     134,643  breaks
0x5d3a1ff2…  USDe OFT Adapter                 56    82,081   40,396  yes        128,316  breaks
0x6d6620ef…  Stargate V2 TokenMessaging      390   100,063+ 110,818  1–2       ~294,000  marginal
0x9b4d17b4…  Stargate V2 CreditMessaging     850    82,098   27,896  0 of 7      49,496  safe

CreditMessaging is safe because credit accounting only ever updates slots that already exist. The exposure rule is simply: an OApp is affected iff lzReceive can create a storage slot that did not previously exist.

Back to the example default

For a plain OFT on Ethereum, the safe floor moves from 80,000 to roughly 170,000 — one created slot is the entire difference.

Why eth_estimateGas does not rescue this

EIP-8037 requires eth_estimateGas implementations to account for the new state costs. That is sufficient for user-submitted transactions, but irrelevant here: nobody calls eth_estimateGas on this path. The number was written into a mapping on another chain months earlier.

Why the state-gas reservoir does not rescue it either

EIP-8037 lets state charges draw from a state_gas_reservoir before touching gas_left. But:

evm_gas             = tx.gas − intrinsic_gas
gas_left            = min(TX_MAX_GAS_LIMIT − intrinsic_gas, evm_gas)
state_gas_reservoir = evm_gas − gas_left

TX_MAX_GAS_LIMIT is EIP-7825's 16,777,216. The reservoir is only the overflow above that cap. The measured delivery transaction had tx.gas = 231,279, so:

state_gas_reservoir = max(0, 231,279 − 16,777,216) = 0

Structurally zero. All 97,920 state gas comes straight out of the frame's gas_left. For the Executor to fund a reservoir it would have to submit >16.7M-gas transactions to deliver 230k-gas messages, while also satisfying tx.gas <= state_gas_available against the block's state-gas budget.

Failure mode (important, and not catastrophic)

EndpointV2.sol#L179-L182:

// clear the payload first to prevent reentrancy, and then execute the message
_clearPayload(_receiver, _origin.srcEid, _origin.sender, _origin.nonce, abi.encodePacked(_guid, _message));
ILayerZeroReceiver(_receiver).lzReceive{ value: msg.value }(_origin, _guid, _message, msg.sender, _extraData);

An OOG inside the receiver reverts the whole frame, so _clearPayload is rolled back. The packet stays verified and remains re-executable by anyone willing to supply more gas. So this is a liveness and relayer-economics problem — messages stall until manually re-executed — not a loss-of-funds problem. Flagging that explicitly so severity is not overstated.


Part 2 — verification-side state creation on EndpointV2

This one is independent of any OApp's configuration and cannot be fixed by re-tuning options.

MessagingChannel.sol#L19 and #L37-L46:

mapping(address receiver => mapping(uint32 srcEid => mapping(bytes32 sender => mapping(uint64 inboundNonce => bytes32 payloadHash))))
    public inboundPayloadHash;

function _inbound(..., bytes32 _payloadHash) internal {
    if (_payloadHash == EMPTY_PAYLOAD_HASH) revert Errors.LZ_InvalidPayloadHash();
    inboundPayloadHash[_receiver][_srcEid][_sender][_nonce] = _payloadHash;
}

Keyed by _nonce, so every message writes a slot that has never existed — an unconditional state creation, in the verification transaction, paid by the DVN/Executor.

Confirmed on tx 0xb99ab82bbd7129c4a3828f6435a43dbdf4d403d3466dba38910247a4c30194de: exactly one 0 → nonzero slot in 0x1a44…728c, 77,937 gas used.

The later _clearPayload in lzReceive does not give the state gas back. EIP-8037's SSTORE table specifies that for a slot which was non-zero at transaction start, clearing produces "no state-gas adjustments". The refill rule only applies to reverts within the same transaction, and creation/clearing happen in different transactions here.

+87,920 gas per message, permanently, on protocol-operated infrastructure
~2,700 inbound messages/day observed  →  ~237M gas/day of new cost

Reproduction

Any public RPC with trace_* support (I used https://eth.drpc.org).

# 1. Every message received on Ethereum
#    PacketDelivered(Origin,address) topic0 =
#    0x3cd5e48f9730b129dc7550f0fcea9c767b7be37837cd10e55eb35f734f4bca04
eth_getLogs({"address": "0x1a44076050125825900e736c501f859c50fE728c",
             "topics": ["0x3cd5e48f…"], "fromBlock": …, "toBlock": …})
#    data layout: [srcEid(32) | sender(32) | nonce(32) | receiver(32)]

# 2. Gas actually granted to lzReceive, and slots created
trace_replayTransaction(txhash, ["trace", "stateDiff"])
#    frame where action.to == receiver  → action.gas
#    stateDiff entries {"*": {"from": 0x0, "to": nonzero}} → state creations

# 3. The configured floor, read on the SOURCE chain
peers(uint32)                   selector 0xbb0b6a53   # on the Ethereum OApp
enforcedOptions(uint32,uint16)  selector 0x5535d461   # on that peer, (30101, 1)
#    decode: 0x0003 | worker(1) | size(2) | optType(1) | gas(u128) | value(u128)

Executor selector 0xcfc32570 = execute302((address,(uint32,bytes32,uint64),bytes32,bytes,bytes,uint256)), verified by keccak; the gasLimit is calldata word 8.

Endpoint IDs referenced: 30101 Ethereum, 30102 BSC, 30106 Avalanche, 30109 Polygon, 30110 Arbitrum, 30111 Optimism, 30184 Base.


Suggested actions

  1. Raise the example default and revise the profiling guidance. For a plain OFT on Ethereum the floor moves from 80,000 to ~170,000. More durably, the comment could say to profile and then add headroom per storage slot _lzReceive may create, since that is the term a repricing moves.
  2. A config-audit task would help a lot. Something like lz:oapp:config:audit-gas that, for each configured Ethereum-destination enforcedOptions, decodes the LZ_RECEIVE gas and flags anything below a post-fork floor. Every OApp with an Ethereum peer needs a setEnforcedOptions call executed on 60+ source chains, ideally before the fork, and right now there is no way to find out which ones.
  3. The EndpointV2 verification cost in Part 2 is a protocol-layer matter, not a tooling one — flagging it here only because it is the same root cause and I could not open an issue on LayerZero-v2. Happy for it to be moved.

Happy to share the full 98-transaction dataset, or re-run the survey over a wider block range or a specific OApp set, if that is useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions