Opcode Summary

PropertyValue
Opcode0x31
MnemonicBALANCE
Gas100 (warm) / 2600 (cold)
Stack Inputaddr
Stack Outputaddr.balance
BehaviorReturns the ETH balance (in wei) of the given 20-byte address. Costs 2600 gas on first access to a cold address within a transaction, and 100 gas on subsequent warm accesses (EIP-2929).

Threat Surface

BALANCE reads the ETH balance of any account on-chain. While it appears read-only and harmless, its threat surface is surprisingly broad and has caused real-world losses. The risks center on three properties:

  1. Balance is externally manipulable: ETH can be forced into any contract via SELFDESTRUCT (which bypasses the target’s receive/fallback functions), via coinbase rewards (block proposer tips), and via pre-funding via CREATE2 address prediction. Any contract that uses address(this).balance as a source of truth assumes nobody can inject or remove ETH outside its own logic — an assumption that is false.

  2. Warm/cold access pricing creates gas unpredictability: A single BALANCE call costs either 100 or 2600 gas depending on whether the target address has been accessed earlier in the same transaction. This 26x variance breaks hardcoded gas estimates, causes unexpected reverts in contracts that forward fixed gas amounts, and can be weaponized for gas griefing by forcing cold lookups.

  3. Using balance for logic is inherently fragile: Strict equality checks (require(address(this).balance == expectedAmount)) are permanently breakable by forcing even 1 wei into the contract. Balance-based authentication, state tracking, or game logic can all be manipulated by third parties without any interaction with the target contract’s code.

These properties combine to make BALANCE one of the most deceptively dangerous read-only opcodes in the EVM.


Smart Contract Threats

T1: Forced ETH via SELFDESTRUCT Breaking Balance-Dependent Logic (Critical)

SELFDESTRUCT transfers the calling contract’s entire ETH balance to a target address without invoking any code on the recipient — no receive(), no fallback(), no revert possible. This means any contract’s ETH balance can be increased by an external party at any time.

Contracts that rely on address(this).balance for logic are vulnerable:

contract VulnerableVault {
    uint256 public targetBalance = 10 ether;
 
    function isGoalReached() public view returns (bool) {
        return address(this).balance >= targetBalance;
    }
 
    function withdraw() external {
        require(isGoalReached(), "Goal not reached");
        // Attacker forces ETH via SELFDESTRUCT to trigger early withdrawal
        payable(msg.sender).transfer(address(this).balance);
    }
}

Post-Dencun (EIP-6780), SELFDESTRUCT no longer deletes pre-existing contracts but still transfers ETH to the target. Additionally, contracts created and destroyed in the same transaction retain full legacy SELFDESTRUCT behavior, so the forced-ETH vector remains fully operational.

Other forced-ETH vectors that survive EIP-6780:

  • Coinbase rewards: A block proposer can direct transaction priority fees to any address
  • CREATE2 pre-funding: Compute a contract’s deployment address via CREATE2, send ETH to it before deployment; the constructor sees a non-zero starting balance

T2: Strict Balance Equality Checks — Permanent DoS (High)

Using == instead of >= or <= on address(this).balance creates a trivially breakable invariant:

contract Lockdrop {
    function lock() external payable {
        address lockAddr = createLockContract();
        // Bug: assumes newly created contract has zero balance
        assert(address(lockAddr).balance == msg.value);
    }
}

An attacker pre-computes lockAddr (deterministic via CREATE or CREATE2) and sends 1 wei to it before lock() is called. The assertion permanently fails. No legitimate user can ever call lock() again.

This is not theoretical — see the Gridlock bug (Exploit 1 below), which threatened $900M in locked ETH.

T3: Gas Griefing via Cold Access (Medium)

BALANCE costs 2600 gas for a cold address vs 100 for a warm address. A contract that calls BALANCE on an address the attacker controls can be griefed:

contract GasVulnerable {
    function checkBalances(address[] calldata addrs) external view returns (uint256 total) {
        for (uint i = 0; i < addrs.length; i++) {
            total += addrs[i].balance;  // 2600 gas each if cold
        }
    }
}

An attacker supplies a list of never-before-accessed addresses. Each BALANCE call costs 2600 gas. With 100 addresses, that is 260,000 gas just for balance reads. Contracts that forward a fixed gas stipend (e.g., call{gas: 50000}) to functions containing BALANCE on cold addresses will revert unexpectedly.

T4: Balance as Authentication or State Proxy (High)

Some contracts use balance as a proxy for state, assuming that balance changes only through their own deposit/withdraw logic:

contract NaiveStaking {
    mapping(address => uint256) public deposits;
 
    function totalDeposited() public view returns (uint256) {
        // Wrong: uses balance instead of internal accounting
        return address(this).balance;
    }
 
    function getShare(address user) external view returns (uint256) {
        // Share calculation corrupted if balance != sum(deposits)
        return (deposits[user] * 1e18) / totalDeposited();
    }
}

Forced ETH inflates totalDeposited(), diluting all users’ shares. Alternatively, if a user’s share calculation depends on balance equaling the sum of deposits, injecting ETH breaks the invariant and can be used to extract value.

T5: Front-Running Balance Checks (Medium)

Transactions that read address(x).balance and make decisions based on the result are vulnerable to front-running. An attacker who sees a pending transaction can manipulate the target’s balance before the victim’s transaction executes:

function liquidate(address borrower) external {
    // Front-runnable: attacker can send ETH to borrower to prevent liquidation
    if (borrower.balance >= minimumCollateral) {
        revert("Borrower is solvent");
    }
    // ... liquidation logic
}

A borrower (or colluding party) front-runs the liquidation by sending ETH to the borrower’s address, making them appear solvent. After the liquidation attempt fails, the ETH can be recovered.


Protocol-Level Threats

P1: Cold Access Gas Cost — State I/O Amplification (High)

BALANCE requires reading account state from the state trie. For cold addresses, this involves disk I/O (trie traversal). The 2016 Shanghai DoS attacks exploited underpriced state access opcodes (then including BALANCE at 20 gas, later 400, then 700) to force nodes into heavy disk I/O with minimal gas expenditure. Blocks took 20–80 seconds to validate instead of milliseconds.

EIP-2929 (Berlin hard fork, April 2021) raised the cold access cost to 2600 gas. This reduced the worst-case block processing time from ~80 seconds to ~27 seconds. The warm cost of 100 gas applies when the address has already been accessed in the same transaction and is tracked in the accessed_addresses set.

P2: EIP-2929 Access Lists and Gas Estimation (Medium)

EIP-2930 introduced optional access lists that allow transactions to pre-declare which addresses they will access, paying the cold cost upfront at a slight discount. Contracts that hardcode gas estimates for BALANCE without accounting for warm/cold status will behave inconsistently:

  • A BALANCE call in a simple transaction costs 2600 gas (cold)
  • The same call inside a transaction that already touched the address (e.g., via CALL) costs 100 gas (warm)
  • A transaction with an EIP-2930 access list pre-warms the address

This inconsistency breaks:

  • Gas estimation in wallets and relayers
  • Hardcoded gasleft() checks
  • Fixed-gas call forwarding patterns
  • ERC-4337 account abstraction validation, which explicitly bans BALANCE in the validation phase to prevent gas-dependent behavior

Edge Cases

Edge CaseBehaviorSecurity Implication
Balance of non-existent accountReturns 0No error; contract cannot distinguish “empty account” from “account with 0 ETH”
Balance of precompile address (0x01–0x09)Returns whatever balance exists (usually 0)Precompiles are regular accounts at the state level; balance can be forced into them
Balance after SELFDESTRUCT in same txReturns 0 for self-destructed contract (EIP-6780: only if created in same tx)Pre-Dencun: balance zeroed after SELFDESTRUCT. Post-Dencun: balance transferred but contract persists with 0 balance only if created same tx
Balance during constructoraddress(this).balance includes any ETH sent with deployment (msg.value) and any pre-funded ETH at the CREATE2 addressConstructor cannot assume starting balance is msg.value
Self-balance vs SELFBALANCE (0x47)SELFBALANCE costs 5 gas; BALANCE(address(this)) costs 100/2600 gasSolidity automatically optimizes address(this).balance to SELFBALANCE since Istanbul. Assembly code using balance(address()) does NOT get this optimization
Balance of address(0)Returns the balance of the zero address (which accumulates burned ETH on some chains)On Ethereum mainnet, address(0) holds ETH from erroneous sends; reading it is valid
Balance query with dirty upper bits in addressUpper 96 bits are ignored; address is masked to 20 bytesCannot use dirty bits to bypass address-based access controls on BALANCE

Real-World Exploits

Exploit 1: Gridlock Bug — $900M Lockdrop DoS (July 2019)

Root cause: Strict balance equality check in Edgeware’s Lockdrop contract assumed newly created lock contracts would have zero balance.

Details: Edgeware’s Lockdrop contract allowed users to lock ETH in individual lock contracts to earn EDG tokens at Edgeware’s launch. The lock() function created a new contract and validated its balance:

assert(address(lockAddr).balance == msg.value);

Because Ethereum allows sending ETH to an address before any contract is deployed there, an attacker could pre-compute the lock contract’s address (deterministic via CREATE) and pre-fund it with any amount of ETH. This would cause the strict equality assertion to permanently fail, bricking the entire Lockdrop mechanism.

At the time of discovery, the contract held over $900 million in locked ETH. The bug was discovered by community researcher Neil M and confirmed by Runtime Verification’s formal verification team before it could be exploited.

BALANCE’s role: The address(lockAddr).balance read returned the pre-funded balance plus msg.value, breaking the == check. Had the contract used internal state tracking instead of BALANCE, the attack vector would not exist.

References:


Exploit 2: Shanghai DoS Attacks — Network-Wide Degradation (September 2016)

Root cause: Underpriced state-access opcodes (BALANCE, EXTCODESIZE, CALL) allowed attackers to force massive disk I/O at minimal gas cost.

Details: In September 2016, attackers crafted transactions that called state-access opcodes (including BALANCE) approximately 50,000 times per block. Each call required nodes to traverse the state trie on disk. With BALANCE priced at only 20 gas at the time, an attacker could force ~50,000 disk reads per block for roughly 1 million gas (a fraction of the block gas limit).

Blocks took 20–80 seconds to validate, compared to milliseconds under normal conditions. The Ethereum network experienced a 2–3x reduction in block production rate. No consensus failure occurred, but the degraded performance persisted for weeks.

Response: EIP-150 (Tangerine Whistle, October 2016) raised BALANCE from 20 to 400 gas. EIP-1884 (Istanbul, December 2019) raised it to 700 gas. EIP-2929 (Berlin, April 2021) introduced the warm/cold model: 2600 gas for cold, 100 gas for warm.

BALANCE’s role: BALANCE was one of several state-access opcodes used in the attack. Its low gas cost relative to its disk I/O requirement made it an efficient DoS vector.

References:


Exploit 3: EtherGame-Class Forced ETH Attacks (Recurring Pattern)

Root cause: Contracts that gate functionality on address(this).balance reaching a threshold, combined with SELFDESTRUCT forced-ETH injection.

Details: A widely documented vulnerability pattern where game or crowdfunding contracts use address(this).balance to determine when a goal is reached:

contract EtherGame {
    uint256 public constant TARGET = 7 ether;
 
    function deposit() external payable {
        require(msg.value == 1 ether, "Must send exactly 1 ETH");
        require(address(this).balance <= TARGET, "Game over");
        if (address(this).balance == TARGET) {
            // Winner logic: pay out to last depositor
        }
    }
}

An attacker deploys a contract containing 5+ ETH, calls SELFDESTRUCT targeting the game contract, and inflates its balance past TARGET. The game becomes permanently stuck: the address(this).balance <= TARGET check prevents new deposits, and no legitimate player can win.

This pattern appears in OpenZeppelin’s Ethernaut CTF (Level 7: Force), Capture the Ether (Retirement Fund), and Damn Vulnerable DeFi as a teaching exercise, but has been found in production contracts on mainnet.

BALANCE’s role: address(this).balance reads the manipulated balance, and the contract has no way to distinguish “legitimate deposits” from “forced ETH.”

References:


Attack Scenarios

Scenario A: Crowdfunding DoS via Forced ETH

contract VulnerableCrowdfund {
    uint256 public goal = 100 ether;
    uint256 public deadline;
    mapping(address => uint256) public contributions;
 
    function contribute() external payable {
        require(block.timestamp < deadline, "Ended");
        contributions[msg.sender] += msg.value;
    }
 
    function finalize() external {
        require(block.timestamp >= deadline, "Not ended");
        // Bug: uses balance instead of tracked contributions
        if (address(this).balance >= goal) {
            // Release funds to project
        } else {
            // Enable refunds
        }
    }
}

Attack: Attacker sends 1 wei via SELFDESTRUCT before deadline. Now address(this).balance includes untracked ETH. If contributions are exactly at the threshold, the forced ETH could tip finalize() into releasing funds to the project when contributors expected a refund. Conversely, the project team could force ETH to reach the goal.

Scenario B: Share Dilution via Forced ETH

contract VulnerablePool {
    mapping(address => uint256) public shares;
    uint256 public totalShares;
 
    function deposit() external payable {
        uint256 newShares;
        if (totalShares == 0) {
            newShares = msg.value;
        } else {
            // Shares based on balance ratio — manipulable
            newShares = (msg.value * totalShares) / address(this).balance;
        }
        shares[msg.sender] += newShares;
        totalShares += newShares;
    }
}

Attack: Before any deposits, attacker forces 1000 ETH into the contract via SELFDESTRUCT. First legitimate depositor sends 1 ETH: newShares = (1 ether * 0) / 1001 ether — the totalShares == 0 branch fires, giving them 1e18 shares. But address(this).balance is now 1001 ETH. On withdrawal, they can claim a proportional share of the inflated balance. The reverse also works: forcing ETH after deposits dilutes existing users’ share value.

Scenario C: Cold-Access Gas Griefing on a Relay

contract VulnerableRelay {
    function batchCheck(address[] calldata accounts) external view returns (bool) {
        for (uint i = 0; i < accounts.length; i++) {
            // Each cold BALANCE costs 2600 gas
            if (accounts[i].balance < 0.01 ether) {
                return false;
            }
        }
        return true;
    }
}

Attack: An attacker supplies 1000 unique, never-accessed addresses. The function consumes at minimum 2,600,000 gas just for BALANCE reads. If called internally with a gas limit, it reverts. If called as a view function in a gas-limited context (e.g., eth_call with a gas cap), it fails silently.

Scenario D: Front-Running a Balance-Based Liquidation Guard

contract VulnerableLending {
    function liquidate(address borrower) external {
        uint256 collateral = borrower.balance;
        uint256 debt = debts[borrower];
        require(collateral < debt, "Borrower is solvent");
        // ... liquidation logic
    }
}

Attack: Borrower monitors the mempool. When they see a liquidate() call targeting them, they front-run it by sending themselves enough ETH (from another account) to appear solvent. The liquidation reverts. After the block, the borrower moves the ETH back. This is repeatable indefinitely.


Mitigations

ThreatMitigationImplementation
T1: Forced ETH via SELFDESTRUCTTrack deposits with internal state variables; never rely on address(this).balance for logicmapping(address => uint256) deposits; uint256 totalDeposited; — update on every deposit/withdrawal
T2: Strict equality DoSNever use == on balance; use >= or internal accountingReplace assert(addr.balance == amount) with tracked state variables
T3: Gas griefing via cold accessCap loop iterations; use access lists (EIP-2930); avoid unbounded BALANCE readsrequire(addrs.length <= MAX_BATCH) and pre-warm addresses in access lists
T4: Balance as state proxyMaintain explicit internal accounting for all ETH flowsUse receive() and fallback() to track incoming ETH; use withdrawal pattern with internal balances
T5: Front-running balance checksUse commit-reveal schemes; perform balance checks atomically with state changes; use internal accountingMove to pull-based withdrawal patterns; use Flashbots for MEV protection
P1: Cold access DoSAlready mitigated by EIP-2929 gas repricingEnsure gas estimates account for 2600 gas per cold BALANCE
P2: Gas estimation inconsistencyUse EIP-2930 access lists to pre-declare addressesWallets and relayers should simulate transactions to get accurate gas estimates

Key Design Principle

Never use address(this).balance for business logic. Maintain internal accounting:

contract SafeVault {
    uint256 private _totalDeposited;
    mapping(address => uint256) private _deposits;
 
    function deposit() external payable {
        _deposits[msg.sender] += msg.value;
        _totalDeposited += msg.value;
    }
 
    function totalDeposited() public view returns (uint256) {
        return _totalDeposited;  // Not address(this).balance
    }
}

Use address(this).balance only for informational/display purposes, emergency recovery, or as a sanity check (e.g., assert(address(this).balance >= _totalDeposited)).


Severity Summary

Threat IDCategorySeverityLikelihoodReal-World Precedent
T1Smart ContractCriticalHighEtherGame-class exploits; forced ETH in production contracts
T2Smart ContractHighMediumGridlock bug ($900M at risk)
T3Smart ContractMediumMediumGas griefing in batch operations
T4Smart ContractHighMediumShare dilution in DeFi pools
T5Smart ContractMediumMediumMEV/front-running balance-gated functions
P1ProtocolHighLow (post-EIP-2929)Shanghai DoS attacks (2016)
P2ProtocolMediumMediumGas estimation failures in wallets/relayers

OpcodeRelationship
SELFBALANCE (0x47)Returns balance of the executing contract at 5 gas (vs 100/2600 for BALANCE). Solidity compiles address(this).balance to SELFBALANCE since Istanbul. Immune to warm/cold pricing but still reflects forced ETH.
CALL (0xF1)Transfers ETH to an address, changing its balance. CALL’s value field is the primary legitimate mechanism for balance changes.
SELFDESTRUCT (0xFF)Forcibly sends ETH to any address without triggering recipient code. Primary vector for balance manipulation attacks. Post-EIP-6780: still transfers ETH even though contract deletion is restricted.
CALLVALUE (0x34)Returns the ETH sent with the current call. Use msg.value (CALLVALUE) for tracking incoming deposits instead of address(this).balance (BALANCE).
CREATE2 (0xF5)Deterministic address computation allows pre-funding contracts before deployment, breaking balance assumptions in constructors.
SLOAD (0x54)Same warm/cold gas model (100/2100). Both are state-access opcodes governed by EIP-2929’s accessed_addresses and accessed_storage_keys sets.