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:
- 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.
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
- 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.
- 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.
- 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.
Summary
examples/oft/layerzero.config.ts#L22-L28ships: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:
lzReceivegas 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.EndpointV2verification cost rises permanently and unrefundably. Every inbound message creates oneinboundPayloadHashslot during verification: +87,920 gas per message, ~2,700 messages/day into Ethereum, with no state-gas refund whenlzReceivelater 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:
EIP-8038 reprices the execution side:
Because the access component is unchanged, the delta is identical warm or cold:
That single invariant drives everything below.
Part 1 — enforced
lzReceivegas optionsThe line that makes the gas limit a hard cap
Executor.sol#L131-L140:gas: _executionParams.gasLimitis an explicit stipend. It is notgasleft(), 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:OAppOptionsType3.sol#L16— and it is persisted on-chain, on the source chain: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#L125adds the per-destination base gas that covers endpoint overhead:Measured: the declared option is the budget
I traced 98 real
PacketDelivereddeliveries on Ethereum. Of 35 distinct gas values observed at the OApp'slzReceiveframe, 25 are exactly a round number + 2,081:lzReceiveBaseGascleanly absorbs theEndpointV2overhead and the EIP-150 63/64 loss, so the number an integrator writes inaddExecutorLzReceiveOptionis, 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 */):Call frame from
trace_replayTransaction, identical in 7 of 7 samples:The payout path is
OFTAdapter._credit#L96-L105:If
_tohas never held the token on Ethereum,balances[_to]goes0 → nonzero. That is a state creation, and it is the ordinary path, not an edge case.Measured on tx
0x029e8bae77ec7c0c035ecbe7cd0b47b1880486d82787e86e2369ae890893b5da:Post-repricing:
Others measured the same way
CreditMessaging is safe because credit accounting only ever updates slots that already exist. The exposure rule is simply: an OApp is affected iff
lzReceivecan 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,000to roughly170,000— one created slot is the entire difference.Why
eth_estimateGasdoes not rescue thisEIP-8037 requires
eth_estimateGasimplementations to account for the new state costs. That is sufficient for user-submitted transactions, but irrelevant here: nobody callseth_estimateGason 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_reservoirbefore touchinggas_left. But:TX_MAX_GAS_LIMITis EIP-7825's 16,777,216. The reservoir is only the overflow above that cap. The measured delivery transaction hadtx.gas = 231,279, so: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 satisfyingtx.gas <= state_gas_availableagainst the block's state-gas budget.Failure mode (important, and not catastrophic)
EndpointV2.sol#L179-L182:An OOG inside the receiver reverts the whole frame, so
_clearPayloadis 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
EndpointV2This one is independent of any OApp's configuration and cannot be fixed by re-tuning options.
MessagingChannel.sol#L19and#L37-L46: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 one0 → nonzeroslot in0x1a44…728c, 77,937 gas used.The later
_clearPayloadinlzReceivedoes 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.Reproduction
Any public RPC with
trace_*support (I usedhttps://eth.drpc.org).Executor selector
0xcfc32570=execute302((address,(uint32,bytes32,uint64),bytes32,bytes,bytes,uint256)), verified by keccak; thegasLimitis calldata word 8.Endpoint IDs referenced:
30101Ethereum,30102BSC,30106Avalanche,30109Polygon,30110Arbitrum,30111Optimism,30184Base.Suggested actions
80,000to ~170,000. More durably, the comment could say to profile and then add headroom per storage slot_lzReceivemay create, since that is the term a repricing moves.lz:oapp:config:audit-gasthat, for each configured Ethereum-destinationenforcedOptions, decodes theLZ_RECEIVEgas and flags anything below a post-fork floor. Every OApp with an Ethereum peer needs asetEnforcedOptionscall executed on 60+ source chains, ideally before the fork, and right now there is no way to find out which ones.EndpointV2verification 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 onLayerZero-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.