Opcode Summary
| Property | Value |
|---|---|
| Opcode | 0x5A |
| Mnemonic | GAS |
| Gas | 2 |
| Stack Input | (none) |
| Stack Output | gasRemaining (uint256) |
| Behavior | Pushes the amount of remaining gas onto the stack. The value returned is the gas available after the 2-gas cost of the GAS opcode itself has been deducted. This is the same value exposed as gasleft() in Solidity. The returned value is context-dependent: it differs between eth_call simulation and actual on-chain execution, varies based on how much gas the transaction was given, and changes after every opcode execution. GAS is purely informational — it reads from the EVM’s internal gas counter and does not modify state. |
Threat Surface
GAS exposes the EVM’s internal gas counter to smart contract code. While this seems innocuous, it creates a fragile coupling between contract logic and an execution-environment value that is inherently non-deterministic, externally controllable, and unstable across protocol upgrades.
The threat surface centers on four properties:
-
GAS returns a caller-controlled value. The
gasleft()value depends on how much gas the transaction sender supplied and how much has been consumed by prior opcodes. An external caller (EOA or relayer) can manipulate the gas provided to a transaction or sub-call, directly controlling what the GAS opcode returns. Any contract logic that branches ongasleft()is effectively trusting the caller to provide a specific gas amount — a trust assumption that is almost never justified. -
GAS values differ between simulation and execution. When users or frontends call
eth_calloreth_estimateGasto simulate a transaction, the gas environment differs from actual on-chain execution.eth_callruns with an effectively unlimited gas limit by default, state may change between simulation and block inclusion, and preceding transactions in the block may alter gas consumption. Contracts that usegasleft()to make decisions (e.g., forwarding gas to sub-calls) may behave differently in simulation than on-chain, causing gas estimation failures and unexpected reverts. -
GAS values break across hard forks. Opcode repricing (EIP-150, EIP-2929, EIP-7904) changes gas costs for common operations, shifting the
gasleft()value at every execution point. Contracts with hardcoded gas thresholds or gas-dependent conditional logic silently break when the protocol changes gas costs. The EIP-150 Tangerine Whistle fork (2016) broke contracts by increasing SLOAD from 50 to 200 gas. The Berlin fork (2021) introduced cold/warm access pricing, making gas consumption non-deterministic within a single transaction. -
The 63/64 rule makes gas forwarding lossy. EIP-150 mandates that CALL-family opcodes can forward at most 63/64 of the remaining gas, retaining 1/64 for post-call operations. This means
gasleft()before a CALL does not equal the gas available to the callee. Contracts that computegasleft()and pass it to a sub-call as a gas limit lose ~1.56% per call depth, compounding across nested calls. This creates exploitable gaps where a caller provides just enough gas for the outer function to succeed while the inner call silently fails.
Smart Contract Threats
T1: Gas-Dependent Logic Breaking Across Hard Forks (High)
Contracts that branch on gasleft() values or use hardcoded gas thresholds create brittle dependencies on current gas pricing. When the protocol reprices opcodes, these thresholds silently become incorrect:
-
Hardcoded gas checks. A contract that checks
require(gasleft() > 100000)before performing a series of SSTORE operations may work under current gas pricing but fail after a repricing fork increases SSTORE costs. The check passes (gas is above the threshold) but the subsequent operations consume more gas than the contract author expected, causing a revert. -
Forwarding fixed gas amounts. Patterns like
target.call{gas: 50000}(data)freeze gas assumptions at deployment time. When EIP-2929 increased cold SLOAD from 800 to 2100 gas, sub-calls that previously succeeded within 50,000 gas began failing because the callee’s storage access costs tripled. -
The
.transfer()/.send()2300 gas stipend. The 2300 gas stipend was designed to be enough for a simple ETH receive. After EIP-2929 introduced cold/warm account access pricing, the first access to a recipient’s storage in a transaction costs 2600 gas, exceeding the 2300 stipend. This broke Gnosis Safe wallets and other contracts with non-trivialreceive()functions after the Berlin hard fork. -
Cold/warm access non-determinism. EIP-2929 makes gas consumption dependent on whether an address or storage slot was previously accessed in the same transaction. A function that costs X gas when called first costs Y < X when called second.
gasleft()checks calibrated for one scenario fail in the other.
Why it matters: Gas costs have been repriced in five major hard forks (Tangerine Whistle, Spurious Dragon, Istanbul, Berlin, Shanghai). Contracts with gas-dependent logic are effectively on a countdown to their next breakage.
T2: Using GAS for Access Control or Conditional Logic (Medium)
Some contracts use gasleft() as a proxy for detecting execution context — attempting to distinguish between normal calls and recursive calls, or between external callers and internal sub-calls:
-
Anti-reentrancy heuristic. A contract might check
if (gasleft() < threshold) revert()as a cheap reentrancy guard, assuming that re-entrant calls have less gas remaining. This is trivially bypassed by an attacker who provides excess gas to the initial call. -
Execution context detection. Contracts that check
gasleft()to determine whether they are being called frometh_call(high gas) vs. an on-chain transaction (limited gas) can be fooled by a caller who provides a high gas limit. -
Gas-metered access tiers. Any scheme that uses
gasleft()to gate functionality (e.g., “only execute this branch if we have enough gas”) creates a caller-controlled gate. The attacker simply adjusts the gas limit in the transaction to pass or fail the check at will.
Why it matters: GAS returns a caller-controlled value. Using it for any form of access control or authorization is equivalent to trusting the attacker’s input.
T3: Insufficient Gas Forwarding to Sub-Calls via 63/64 Retention (High)
When a contract uses CALL, DELEGATECALL, or STATICCALL, EIP-150 mandates that at most 63/64 of remaining gas is forwarded to the callee. The calling contract retains 1/64 for post-call execution. This creates a systematic gas deficit:
-
Compounding loss at depth. At call depth N, the callee receives
gas * (63/64)^Nof the original gas. At depth 5, ~7.6% of gas has been retained across intermediate frames. Contracts that rely on precise gas availability at inner call depths will fail unpredictably. -
Gas check / gas forward gap. A common pattern is to check
require(gasleft() >= requiredGas)before making an external call. But the call only forwardsgasleft() * 63/64, notgasleft(). IfrequiredGasis close to the callee’s actual need, the 1/64 retention causes the callee to receive less gas than the check suggests, and the callee reverts while the caller’s check passed. -
Relayer exploitation. In meta-transaction systems, relayers call a contract on behalf of users. A malicious relayer can provide just enough gas for the relay contract’s logic to execute, but the 63/64 rule means the inner call (the user’s actual transaction) receives insufficient gas. The relay contract marks the transaction as “executed” (consuming the user’s nonce), but the inner call silently fails. The user’s funds or state change are lost.
Why it matters: The Optimism Portal vulnerability (2023) was exactly this pattern — a 5,122-gas gap between the gas check and the actual gas forwarded to the withdrawal execution, allowing attackers to permanently lock user funds.
T4: Gas Estimation Discrepancy Between eth_call and On-Chain Execution (Medium)
The GAS opcode returns different values during eth_call simulation and actual block execution:
-
Unlimited gas in simulation. By default,
eth_callruns with a gas limit of2^63 - 1or the block gas limit. This meansgasleft()returns a very large value during simulation. If contract logic usesgasleft()to compute how much gas to forward to a sub-call, the simulation shows the sub-call receiving abundant gas, while the actual transaction (with a realistic gas limit) forwards much less. -
State changes between simulation and inclusion. If a pending transaction modifies storage that the target contract reads, the gas cost changes (cold vs. warm access). A function that costs 30,000 gas in
eth_estimateGasmight cost 45,000 gas on-chain because a preceding transaction in the same block made certain storage slots cold again (new transaction context) or changed storage values that alter execution paths. -
Block-level variable differences.
gasleft()at any point in execution depends on all preceding opcodes in the call. Ifblock.basefee,block.number, orblock.timestampvalues differ between simulation and actual execution (they will), and the contract’s execution path depends on these values, gas consumption diverges.
Why it matters: Gas estimation is foundational to user experience and relayer economics. Contracts that use gasleft() in their logic make gas estimation unreliable, leading to failed transactions and wasted ETH on reverted gas.
T5: Griefing via Precisely Calibrated Gas Supply (High)
An attacker can supply a precise gas amount to a transaction such that top-level checks pass but sub-calls fail, leaving the contract in an inconsistent state:
-
Pass the check, fail the call. Consider:
require(gasleft() > MIN_GAS); (bool ok,) = target.call{gas: gasleft() - BUFFER}(data). An attacker provides gas such thatgasleft() > MIN_GASis barely true, but after the 63/64 retention,targetreceives insufficient gas. If the contract does not checkok, the sub-call silently fails. -
Nonce/replay consumption without execution. In relay systems, the outer function may consume the user’s nonce before making the sub-call. If the attacker (acting as relayer) provides insufficient gas for the sub-call, the nonce is consumed but the user’s intended action never executes. The user cannot replay the transaction.
-
Withdrawal grief. In bridge contracts, a finalizer calls a function that marks a withdrawal as “finalized” and then sends the ETH/tokens to the user. If the attacker provides just enough gas for the state update but not for the token transfer, the withdrawal is marked complete but the user never receives funds — permanently locked.
Why it matters: Gas griefing is found in approximately 20% of smart contract audits. Unlike direct theft, it is subtle — the contract’s own logic executes “correctly” from the EVM’s perspective, but the economic outcome is adversarial.
Protocol-Level Threats
P1: Gas Repricing Across Hard Forks (Medium)
Ethereum has repriced opcodes in every major hard fork aimed at mitigating DoS attacks:
| Hard Fork | Year | Key Gas Changes | Impact on gasleft() |
|---|---|---|---|
| Tangerine Whistle (EIP-150) | 2016 | SLOAD: 50→200, BALANCE: 20→400, CALL: 40→700, EXTCODESIZE: 20→700 | Contracts with hardcoded gas thresholds broke. Introduced the 63/64 forwarding rule. |
| Spurious Dragon (EIP-160) | 2016 | EXP repricing | Minor impact on gas-dependent logic. |
| Istanbul (EIP-1884) | 2019 | SLOAD: 200→800, BALANCE: 400→700 | .transfer() patterns began failing for contracts with non-trivial fallback functions. |
| Berlin (EIP-2929) | 2021 | Cold SLOAD: 2100, warm SLOAD: 100, cold account access: 2600 | Non-deterministic gas costs within a transaction. Broke Gnosis Safe .transfer() patterns. |
| Shanghai (EIP-3651) | 2023 | COINBASE address made warm | Reduced cost for COINBASE access; minor gasleft() impact. |
Each repricing event shifts gasleft() values at every execution point. Contracts cannot anticipate future repricing and have no mechanism to adapt.
P2: EIP-150 63/64 Gas Forwarding Rule (Medium)
The 63/64 rule (EIP-150) was introduced to prevent call-stack attacks where a contract could recursively CALL itself 1024 times to exhaust gas. The rule ensures the caller always retains 1/64 of gas. While this prevents stack-depth attacks, it introduces a systematic gap between gasleft() and the gas actually available to callees:
- At call depth 1: callee gets
gasleft() * 63/64(~98.4%) - At call depth 5: callee gets
gasleft() * (63/64)^5(~92.4%) - At call depth 10: callee gets
gasleft() * (63/64)^10(~85.4%)
This compounding loss is invisible to contracts that check gasleft() and assume that much gas is available to the callee. The loss is especially dangerous in cross-contract protocol interactions (DeFi composability) where call depth is not controlled by a single contract author.
Edge Cases
| Edge Case | Behavior | Security Implication |
|---|---|---|
| GAS at the very start of execution | Returns txGasLimit - intrinsicGas - 2 (the 2 is the GAS opcode cost) | Maximum possible gasleft() value; contracts using this to detect “fresh” calls are trivially fooled by high-gas-limit transactions. |
| GAS after expensive operations (SSTORE, CREATE) | Returns significantly reduced value depending on prior operations | Gas-dependent branching after storage writes is sensitive to cold/warm access pricing — same code path, different gasleft() values depending on prior access patterns. |
| GAS in DELEGATECALL context | Returns remaining gas in the current frame (same gas pool as the delegating contract) | No change in semantics; gasleft() reflects the shared gas pool. Gas consumption in the delegatee reduces the delegator’s available gas. |
| GAS in STATICCALL context | Returns remaining gas; behaves identically to other call contexts | No special behavior, but state-modifying operations that would consume gas cannot execute, so gasleft() decreases more slowly in static contexts. |
GAS with explicit gas stipend (call{gas: N}) | Inside the sub-call, GAS returns remaining gas from the stipend N, not the parent’s gas | Callee sees N - opcodesCost as gasleft(), not the parent’s remaining gas. Gas-dependent logic in the callee is controlled by the caller’s stipend choice. |
| GAS when remaining gas < 2 | Execution reverts with out-of-gas before GAS can execute | The GAS opcode itself costs 2 gas; if fewer than 2 remain, the opcode never runs. No return value is pushed. |
| GAS in constructor (CREATE/CREATE2) | Returns remaining gas in the creation context | Gas available during construction differs from runtime. Gas-dependent initialization logic may behave differently than runtime logic. |
| GAS across L1 vs L2 | L2s (Optimism, Arbitrum) may have different gas schedules and block gas limits | gasleft() values are chain-specific. Cross-chain contracts with gas thresholds must account for different gas pricing. |
Real-World Exploits
Exploit 1: Optimism Portal — Gas Griefing Permanently Locks Withdrawal Funds (January 2023)
Root cause: A 5,122-gas discrepancy between the gas validation check and the actual gas forwarded to the withdrawal execution call, compounded by EIP-150’s 63/64 rule.
Details: Optimism’s OptimismPortal.finalizeWithdrawalTransaction() function allowed anyone to finalize pending L2→L1 withdrawals. The function checked require(gasleft() >= _tx.gasLimit + FINALIZE_GAS_BUFFER) to ensure sufficient gas was available, then performed storage writes (setting the l2Sender variable, costing ~2,900 gas), and finally executed the external call. The gap between the check and the call consumed 5,122 gas that was not accounted for.
An attacker could call finalizeWithdrawalTransaction() with precisely calibrated gas: enough to pass the gasleft() check, but after the 5,122 gas was consumed by intermediate operations, the external call received gasLimit - 5,122 gas instead of the intended gasLimit. For withdrawals where the actual gas requirement was within 5,122 of the specified gasLimit, the execution would fail.
Critically, the function marked the withdrawal as finalized before making the external call. A failed external call meant the withdrawal was marked complete but the user’s ETH was never sent. With no replay mechanism, the funds were permanently locked in the portal contract.
A secondary issue involved EIP-150’s 63/64 rule: for withdrawals with very large gasLimit values (>1,265,280 gas), the implicit 1/64 retention further reduced gas available to the callee below the specified limit.
GAS opcode’s role: The vulnerability directly stems from the gap between what gasleft() reports and what CALL actually forwards. The gasleft() check passed, but the CALL received less gas due to intermediate consumption and the 63/64 rule.
Impact: Critical severity finding in Sherlock audit. All migrated L2 withdrawals requiring more than 135,175 gas were at risk of permanent fund loss. Fixed via PR #4954 with a GAS_CHECK_BUFFER accounting for the intermediate gas consumption.
References:
Exploit 2: King of the Ether Throne — Insufficient Gas Breaks Payment Forwarding (February 2016)
Root cause: The contract used .send() (which forwards only 2,300 gas) to pay previous kings. When the recipient was a contract-based wallet, 2,300 gas was insufficient for the wallet’s receive() function, and the failed send was not checked.
Details: The King of the Ether Throne game required each new “king” to pay more than the previous king. The contract forwarded the payment to the previous king using previousKing.send(compensation). During the “Turbulent Age” (February 6-8, 2016), users with Ethereum Mist contract-based wallets could not receive payments because .send() only forwarded 2,300 gas — insufficient for the wallet contract’s fallback function.
The contract did not check the return value of .send(), so failed payments were silently ignored. The previous king lost their compensation, and the contract continued operating as if the payment succeeded.
GAS opcode’s role: The .send() function internally uses CALL with a gas stipend of 2,300. This fixed stipend assumes the recipient needs minimal gas. After EIP-150 increased gas costs for IO operations and subsequent hard forks further inflated costs, the 2,300 stipend became insufficient for an increasing number of recipient contracts. The gasleft() in the recipient’s context was only 2,300 minus overhead, far too little for storage operations.
Impact: Multiple users lost compensation payments. All ETH was eventually recovered manually by the contract developer. Established the pattern of “gas stipend fragility” that would recur with every subsequent gas repricing hard fork.
References:
Exploit 3: Gnosis Safe .transfer() Breakage After Berlin Hard Fork (April 2021)
Root cause: EIP-2929’s cold account access repricing increased the gas cost of the first interaction with an address to 2,600 gas, exceeding the 2,300 gas stipend provided by .transfer() and .send().
Details: Before the Berlin hard fork (April 2021), sending ETH to a Gnosis Safe multisig wallet via .transfer() worked reliably because the Safe’s fallback function fit within the 2,300 gas stipend. After Berlin, EIP-2929 introduced cold/warm account access pricing: the first time a transaction touches a storage slot costs 2,100 gas (cold SLOAD), and the first time it interacts with an account costs 2,600 gas (cold account access).
When a contract called gnosisSafe.transfer(amount), the 2,300 gas stipend was insufficient to cover the cold account access cost. The transfer reverted, and any contract using .transfer() to send ETH to a Gnosis Safe stopped working. This affected numerous DeFi protocols that used .transfer() for ETH payouts to users with Smart Contract Wallets.
The fix was EIP-2930 access lists, which allowed transactions to pre-declare which addresses they would touch, making them “warm” before execution and reducing costs below 2,300 gas.
GAS opcode’s role: Inside the .transfer() call frame, gasleft() started at 2,300. After EIP-2929, the cold account access consumed 2,600 gas — more than the entire stipend. The recipient’s code never executed because it ran out of gas before its first opcode.
Impact: Broke ETH transfers to Gnosis Safe wallets across all DeFi protocols using .transfer() or .send(). Required protocol-level workaround (EIP-2930 access lists) and prompted the industry to abandon .transfer() in favor of .call{value: amount}("").
References:
Exploit 4: Fomo3D — Gas Manipulation to Block Competitors (August 2018)
Root cause: A player used gas manipulation to fill blocks with gas-consuming transactions, preventing other players’ transactions from being included and winning the game’s jackpot.
Details: Fomo3D was a “last key buyer wins” game. The attacker deployed smart contracts that conditionally consumed massive amounts of gas (up to 4.2M gas via assert() failures) to fill blocks. The contracts used gasleft() and block.coinbase checks to coordinate the attack — activating the gas-consuming behavior only when conditions were favorable.
The attacker held the last key purchase while flooding blocks with high-gas-price transactions that consumed the entire block gas limit. For approximately 11 consecutive blocks, no other player’s key purchase could be included. The timer expired with the attacker as the last buyer.
GAS opcode’s role: The attack contracts used gasleft() as part of their conditional logic to determine how much gas to consume and when to activate. By monitoring gasleft(), the contracts could calibrate their gas consumption to fill blocks without exceeding limits, maximizing the blocking effect.
Impact: 10,469.66 ETH (~$3M) won by a single player through gas manipulation. Demonstrated that gasleft() can be used offensively to calibrate gas consumption for block-stuffing attacks.
References:
Attack Scenarios
Scenario A: Gas Griefing via Precise Gas Supply to a Relay Contract
contract VulnerableRelay {
mapping(bytes32 => bool) public executed;
function relay(
address target,
bytes calldata data,
uint256 gasLimit,
bytes32 nonce
) external {
require(!executed[nonce], "already executed");
// Gas check passes -- caller provided just enough
require(gasleft() >= gasLimit + 40000, "insufficient gas");
// Nonce consumed BEFORE the external call
executed[nonce] = true;
// 63/64 rule: target receives gasleft() * 63/64, NOT gasleft()
// If gasLimit is close to actual requirement, the 1/64 retention
// causes target to receive less gas than gasLimit
(bool success,) = target.call{gas: gasLimit}(data);
// If success is not checked, or if the contract proceeds regardless,
// the nonce is consumed but the user's action never executed.
// The user cannot replay -- their nonce is burned.
}
}
// Attack: Malicious relayer calls relay() with tx gas = gasLimit + 40001.
// gasleft() check passes. But intermediate operations (SSTORE for nonce)
// consume gas before the CALL. The call forwards less than gasLimit.
// Target reverts. Nonce is consumed. User's funds are stuck.Scenario B: Gas-Dependent Logic Bypassed by Attacker-Controlled Gas
contract VulnerableGasGate {
uint256 public constant EXPENSIVE_THRESHOLD = 200000;
function process(uint256 amount) external {
if (gasleft() > EXPENSIVE_THRESHOLD) {
// "Expensive" path: full validation, oracle checks, etc.
_fullValidation(amount);
_executeTransfer(amount);
} else {
// "Cheap" path: minimal checks, intended for low-gas situations
// A developer might assume this path only runs during gas-constrained
// internal calls, but an attacker can force this path by supplying
// a transaction with just enough gas to reach this point with
// gasleft() < EXPENSIVE_THRESHOLD
_executeTransfer(amount);
}
}
// Attack: Send tx with gas limit calibrated so gasleft() at the
// if-check is just below EXPENSIVE_THRESHOLD.
// Bypasses _fullValidation(), directly executes transfer.
}Scenario C: 63/64 Rule Exploitation in Bridge Withdrawal
contract VulnerableBridge {
struct Withdrawal {
address recipient;
uint256 amount;
uint256 gasLimit;
bool finalized;
}
mapping(bytes32 => Withdrawal) public withdrawals;
function finalizeWithdrawal(bytes32 id) external {
Withdrawal storage w = withdrawals[id];
require(!w.finalized, "already finalized");
require(gasleft() >= w.gasLimit + 50000, "not enough gas");
// State update BEFORE external call
w.finalized = true; // SSTORE: ~5,000-20,000 gas consumed
// By now, gasleft() has decreased. The CALL forwards 63/64 of
// whatever remains, minus the gas consumed by the CALL setup.
// If w.gasLimit was tight, the recipient gets less than needed.
(bool success,) = w.recipient.call{value: w.amount, gas: w.gasLimit}("");
// Even if success == false, withdrawal is marked finalized.
// Funds are permanently locked.
if (!success) {
// Too late -- w.finalized is already true.
// No replay mechanism exists.
}
}
// Attack: Anyone calls finalizeWithdrawal() with precise gas so that
// gasleft() >= w.gasLimit + 50000 passes, but after the SSTORE and
// CALL overhead, recipient receives w.gasLimit - 5122 gas.
// If the recipient needs exactly w.gasLimit, the call fails.
}Scenario D: Gas Estimation Divergence Causing Transaction Failure
contract GasEstimationTrap {
uint256 public counter;
function complexOperation() external {
// During eth_call simulation: gasleft() returns ~2^63
// During actual execution: gasleft() returns txGasLimit - consumed
uint256 gasForSubCall = gasleft() / 2;
// In simulation: gasForSubCall is enormous, sub-call always succeeds
// On-chain: gasForSubCall depends on the user's tx gas limit
(bool ok,) = address(this).call{gas: gasForSubCall}(
abi.encodeCall(this.expensiveWork, ())
);
require(ok, "sub-call failed");
counter++;
}
function expensiveWork() external {
// Consumes ~100,000 gas
for (uint i = 0; i < 50; i++) {
assembly { sstore(add(0x100, i), add(i, 1)) }
}
}
// Problem: eth_estimateGas sees complexOperation() succeed with
// gasForSubCall = huge_number. It returns ~120,000 as the estimate.
// User sends tx with 130,000 gas. On-chain, gasForSubCall = ~55,000.
// expensiveWork() needs 100,000. Sub-call reverts. Transaction fails.
// User wasted gas on a transaction that worked in simulation.
}Mitigations
| Threat | Mitigation | Implementation |
|---|---|---|
| T1: Gas-dependent logic across forks | Never use hardcoded gas thresholds | Remove all gasleft() > CONSTANT checks. Use external gas configuration if gas-awareness is unavoidable. |
T1: .transfer() / .send() stipend fragility | Replace .transfer() with .call{value:}("") | (bool ok,) = recipient.call{value: amount}(""); require(ok); — forwards all available gas instead of 2,300. Add reentrancy guards. |
| T2: GAS for access control | Never branch security-critical logic on gasleft() | Use msg.sender, signatures, or on-chain state for access control. gasleft() is caller-controlled. |
| T3: 63/64 rule gas loss | Account for the 63/64 retention in gas checks | Check gasleft() >= (requiredGas * 64 / 63) + buffer before external calls. Include buffer for intermediate opcodes between check and CALL. |
| T3: Nonce consumed before sub-call | Move state updates after successful external calls | Follow Checks-Effects-Interactions: validate, make external call, then update state. Or verify success before committing state changes. |
| T4: Estimation discrepancy | Do not use gasleft() to compute gas for sub-calls | Forward all gas to sub-calls: target.call(data) without explicit gas. Let the EVM handle gas forwarding naturally. |
| T5: Griefing via precise gas supply | Validate sub-call success and revert on failure | Always check return value: (bool ok,) = target.call(data); require(ok, "call failed"); — revert the entire transaction if the sub-call fails. |
| T5: Bridge/relay permanent fund loss | Implement replay mechanisms | If a sub-call fails, do not mark the action as finalized. Allow the user (or anyone) to retry with more gas. |
| General: Gas estimation safety | Add gas buffers to estimates | Frontends should add 20-30% buffer to eth_estimateGas results for contracts that use gasleft(). |
Compiler/EIP-Based Protections
- EIP-150 (Tangerine Whistle, 2016): Introduced the 63/64 gas forwarding rule. While it prevents call-stack depth attacks, contract authors must account for the 1/64 gas retention when checking
gasleft()before external calls. - EIP-2929 (Berlin, 2021): Introduced cold/warm access pricing. Contracts should not assume fixed gas costs for storage and account access. Using EIP-2930 access lists can pre-warm addresses and slots, making gas costs predictable.
- EIP-2930 (Berlin, 2021): Allows transactions to declare access lists, pre-warming addresses and storage slots. Mitigates the non-determinism introduced by EIP-2929 for gas-sensitive operations.
- Solidity >= 0.8.0:
gasleft()is a built-in function. The compiler does not insert any implicit gas checks, but the language no longer provides.gas()modifier on external calls in high-level syntax, encouraging.call{gas: N}()patterns with explicit gas amounts. - EIP-1153 (Transient Storage, Dencun, 2024): Provides cheap transient storage (100 gas for TLOAD/TSTORE) that can replace gas-expensive patterns using SSTORE, reducing the impact of gas-dependent logic that interacts with storage.
Severity Summary
| Threat ID | Category | Severity | Likelihood | Real-World Precedent |
|---|---|---|---|---|
| T1 | Smart Contract | High | High | Gnosis Safe .transfer() breakage (Berlin 2021), KotET (2016) |
| T2 | Smart Contract | Medium | Low | No major exploit, but trivially exploitable by adjusting tx gas |
| T3 | Smart Contract | High | High | Optimism Portal gas griefing (2023, critical finding) |
| T4 | Smart Contract | Medium | Medium | Ongoing gas estimation failures across DeFi frontends |
| T5 | Smart Contract | High | High | Optimism Portal fund locking, relay griefing patterns (~20% of audits) |
| P1 | Protocol | Medium | High | Five major repricing events (EIP-150, EIP-1884, EIP-2929, etc.) |
| P2 | Protocol | Medium | Medium | 63/64 rule gas loss compounds across DeFi composability layers |
Related Opcodes
| Opcode | Relationship |
|---|---|
| GASLIMIT (0x45) | Returns the block’s gas limit. Unlike GAS (which returns remaining gas in the current execution), GASLIMIT returns the maximum gas allowed per block. Contracts sometimes confuse the two. |
| GASPRICE (0x3A) | Returns the gas price of the current transaction (tx.gasprice). Combined with GAS, can be used to compute the ETH cost of remaining execution — but this is fragile and changes with gas market dynamics. |
| CALL (0xF1) | Creates a new call frame with forwarded gas. GAS is typically checked before CALL to ensure sufficient gas for the sub-call. The 63/64 rule means CALL forwards less gas than gasleft() reports. |
| STATICCALL (0xFA) | Read-only variant of CALL. Same gas forwarding semantics (63/64 rule) apply. gasleft() inside a STATICCALL reflects the gas allocated to that frame. |
| DELEGATECALL (0xF4) | Executes callee code in the caller’s context. Shares the same gas pool — gasleft() in the delegatee reflects the caller’s remaining gas. |
| CALLCODE (0xF2) | Deprecated precursor to DELEGATECALL. Same gas forwarding behavior applies. |