The Core Problem: ETH You Did Not Ask For
Solidity gives you three canonical ways to guard against unsolicited ETH: omit a receive() function, omit a fallback() function, or revert inside them. Most developers believe those three options close the door entirely. They do not.
selfdestruct permanently destroys the calling contract, sends all remaining ETH to a specified recipient, and critically bypasses the recipient’s receive() and fallback() functions entirely — it can be used to force ETH into contracts that are not designed to hold it, breaking invariants that rely on address(this).balance == 0.
This is not a fringe edge case. The selfdestruct() function is powerful — it is one of the only ways to force a contract to receive ETH it does not want, and in doing so exists as an attack vector for any protocol not prepared for it.
The consequence is stark: any invariant your contract relies on that includes address(this).balance can be broken by an external party at will. This article walks through every known attack path and the defensive patterns that actually work.
1. selfdestruct — Forced ETH Delivery
How It Works
selfdestruct is a low-level Solidity function that permanently deletes a smart contract from the blockchain and transfers its remaining Ether balance to a specified address. The critical property from a security standpoint is that it sends the funds even if the contract does not have a receive() function defined, and any restriction that could exist on a receive() function is simply not applied.
An attacker does not need sophisticated tooling. The attacker can create a contract with a selfdestruct() function, send ether to it, call selfdestruct(target), and force ether to be sent to a target.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @notice Attacker deploys this, funds it, calls attack().
contract ForceEth {
constructor() payable {}
/// @dev Sends all ETH to `target`, bypassing its receive/fallback.
function attack(address payable target) external {
selfdestruct(target);
}
}
EIP-6780 and the Cancun Change
Since the Cancun hard fork, calling selfdestruct on an existing contract in a separate transaction only transfers the contract’s ETH balance to the specified address — the contract’s code, storage, and account are not deleted. The only exception is when selfdestruct is called within the same transaction that created the contract, in which case full classic deletion still applies.
This matters for the attack surface: one fundamental attack vector — force-sending ETH — completely survived EIP-6780. The ETH transfer bypass behaviour is preserved regardless of whether the sending contract is destroyed.
Since Solidity 0.8.18, the compiler emits a deprecation warning per EIP-6049. The keyword still compiles and executes — the warning signals that future EVM upgrades may remove it entirely. Any use in newly deployed contracts is strongly discouraged.
A Classic Victim: The Ether Game
The archetypal vulnerable contract uses address(this).balance as a game-state sentinel:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @notice VULNERABLE — do not deploy.
contract EtherGame {
uint256 public constant TARGET = 7 ether;
address public winner;
function deposit() external payable {
// Intended invariant: only accepts 1 ETH increments.
require(msg.value == 1 ether, "Send exactly 1 ETH");
// Balance check uses address(this).balance — attacker-controlled.
require(address(this).balance <= TARGET, "Game over");
if (address(this).balance == TARGET) {
winner = msg.sender;
}
}
}
If six players have already deposited one ETH into the contract, an attacker can use the selfdestruct function to force an additional ETH into the contract — this allows bypassing the EtherGame.deposit function entirely, resulting in a permanent accounting error. The balance skips over TARGET, winner is never set, and the contract is permanently bricked.
2. Mining / Validator Block Rewards as Forced ETH
Before Ethereum’s transition to proof-of-stake, miners could set their coinbase address to any arbitrary address — including a smart contract. Block rewards (base issuance plus transaction fees) would be credited directly to that address without triggering any EVM code execution. The receiving contract had no opportunity to revert.
Under proof-of-stake the same mechanic applies to validators: the fee_recipient field in a block proposal directs execution-layer tips and MEV payments. A validator can freely nominate a contract address as their fee_recipient. The EVM-level credit bypasses all contract logic identically to a selfdestruct delivery.
This means that even a contract that has successfully defended against selfdestruct by deploying with no receive() function can accumulate ETH through validator rewards if anyone with validator access targets it.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @notice VULNERABLE — balance check can be inflated by coinbase credit.
contract StakingVault {
uint256 public deposited;
function deposit() external payable {
deposited += msg.value;
}
/// @dev BUG: address(this).balance may exceed `deposited` due to
/// coinbase rewards, selfdestruct, or any forced-ETH path.
function shareOf(address user, uint256 userBalance)
external
view
returns (uint256)
{
return (userBalance * 1e18) / address(this).balance; // ← unsafe
}
}
The fix in both the mining-reward and selfdestruct cases is identical: track internal state rather than trusting address(this).balance.
3. Why address(this).balance in Calculations Is Dangerous
Donation attacks create a critical mismatch between the contract’s actual balance and its internal state tracking. When attackers send ETH directly or force it through selfdestruct, the contract’s balance increases while its state variables remain unchanged — this discrepancy causes critical functions to fail, as they rely on consistent accounting between the actual balance and tracked amounts.
The pattern appears deceptively reasonable to developers: “the balance is the canonical truth.” The problem is that it is canonical on-chain but not canonical within the contract’s intended invariant space. An attacker with a modest amount of ETH can shift the balance arbitrarily, making ratio calculations, thresholds, and caps all unreliable.
Categories of Breakage
| Pattern | How the attack manifests |
|---|---|
balance == threshold | Balance is pushed above threshold; the condition never triggers |
balance <= cap | Cap is bypassed; subsequent deposits are locked out |
shares = deposit * totalSupply / balance | Denominator is inflated; new depositors receive fewer shares |
collateral = balance * price | Artificially inflated balance makes positions appear overcollateralised |
if (balance == 0) init() | Contract never initialises if attacker pre-seeds it with 1 wei |
The vulnerability in external accounting is that anyone can send tokens directly to a contract, regardless of intended logic. If your contract uses token.balanceOf(address(this)) (or address(this).balance) to calculate shares, withdrawals, or any critical value, an attacker can donate tokens, irrevocably compromising the system and potentially altering the intended outcome.
4. The Share Inflation Attack in ERC-4626 Vaults
The Share-Price Formula
The share-to-asset conversion is the heart of ERC-4626. The standard specifies it as proportional: shares represent a pro-rata claim on the vault’s assets. For a deposit, the vault calculates shares = assets * totalSupply / totalAssets. For a redeem: assets = shares * totalAssets / totalSupply.
The attack targets totalAssets(). A naive implementation reads it directly from the underlying token balance:
/// @notice VULNERABLE totalAssets implementation.
function totalAssets() public view override returns (uint256) {
return asset.balanceOf(address(this)); // Anyone can inflate this
}
Anyone can send tokens directly to the vault contract, regardless of intended logic — if the contract uses token.balanceOf(address(this)) to calculate shares, an attacker can donate tokens, irrevocably compromising the system.
Step-by-Step Attack
In an inflation attack, an attacker manipulates the exchange rate to benefit themselves at the expense of other users. By exploiting the rounding down of shares during deposit, the attacker can dilute the value of other users’ deposits. New vaults are at the greatest risk of inflation attacks.
The precise sequence is:
A hacker back-runs the transaction of an ERC-4626 pool creation, then mints one share for themselves via deposit(1). At this point, totalAsset() == 1 and totalSupply() == 1.
The hacker then front-runs the deposit of a victim who wants to deposit 20,000 tokens, inflating the denominator right in front of the victim by calling asset.transfer(20_000e6) directly. Now totalAsset() == 20_000e6 + 1, totalSupply() == 1.
When the victim’s transaction executes, the victim receives 1 * 20_000e6 / (20_000e6 + 1) == 0 shares — zero. The hacker then burns their single share and takes all the money.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @notice VULNERABLE ERC-4626 vault — naive totalAssets.
contract NaiveVault is ERC4626 {
constructor(IERC20 _asset)
ERC20("Vault Share", "vSHARE")
ERC4626(_asset)
{}
// Inherits totalAssets() = asset.balanceOf(address(this))
// ← directly manipulable via direct transfer or selfdestruct
}
Defences
1. OpenZeppelin virtual offset (recommended)
A virtual offset restricts the attacker’s ability to manipulate the exchange rate effectively by including both virtual shares and virtual assets in the exchange rate computation. The virtual assets enforce the conversion rate when the vault is empty.
Analysis shows that the default offset (0) makes the attack non-profitable even if an attacker is able to capture value from multiple user deposits; with a larger offset, the attack becomes orders of magnitude more expensive than it is profitable.
/// @notice Protected vault — override _decimalsOffset.
contract SecureVault is ERC4626 {
constructor(IERC20 _asset)
ERC20("Vault Share", "vSHARE")
ERC4626(_asset)
{}
/// @dev Offset of 6 makes the attack ~1,000,000× more expensive.
function _decimalsOffset() internal pure override returns (uint8) {
return 6;
}
}
2. Internal accounting
The second strategy aims to negate the effect of direct transfers by keeping track of the assets held by the vault internally. This means that donated tokens are not accounted for, which effectively eliminates the risk of inflation attacks.
/// @notice Internal-accounting vault — ignores direct transfers.
contract InternalAccountingVault is ERC4626 {
uint256 private _totalManagedAssets;
constructor(IERC20 _asset)
ERC20("Vault Share", "vSHARE")
ERC4626(_asset)
{}
function totalAssets() public view override returns (uint256) {
return _totalManagedAssets; // Never reads balanceOf
}
function _deposit(
address caller,
address receiver,
uint256 assets,
uint256 shares
) internal override {
_totalManagedAssets += assets;
super._deposit(caller, receiver, assets, shares);
}
function _withdraw(
address caller,
address receiver,
address owner,
uint256 assets,
uint256 shares
) internal override {
_totalManagedAssets -= assets;
super._withdraw(caller, receiver, owner, assets, shares);
}
}
3. Dead shares on first deposit
Drawing inspiration from Uniswap V2, which generated inactive LP shares upon initial liquidity deposits, a comparable strategy can be adopted within ERC-4626 vaults — this approach involves minting inactive shares during the first deposit or minting process, establishing a foundation for share valuation and mitigating susceptibility to inflation attacks.
5. Donation Attacks in Lending Protocols
Lending protocols are particularly sensitive because balance manipulations translate directly into collateral valuations — and collateral valuations determine solvency.
The donation attack vector is a documented weakness in Compound-forked lending protocols, where direct token transfers to interest-bearing markets can distort the internal accounting that governs collateral valuation and supply cap enforcement.
A prominent example: the primary reason for the Hundred Finance hack was the manipulation of the exchange rate in the hWBTC contract by donating a large amount of WBTC, combined with a rounding error in the redeemUnderlying function.
The mechanics mirror the vault inflation attack, but the consequence is not just a victim losing deposit precision — it is under-collateralised borrowing or cascading liquidations across all users.
The core vulnerability was in how a price oracle calculated the price of collateral. The attacker manipulated the price by increasing the total assets via a donate function, which pushed the price higher and allowed the attacker to borrow more than the true value of their collateral.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC20 {
function balanceOf(address) external view returns (uint256);
function transferFrom(address, address, uint256) external returns (bool);
function transfer(address, uint256) external returns (bool);
}
/// @notice VULNERABLE lending market — exchange rate reads raw balance.
contract VulnerableCToken {
IERC20 public immutable underlying;
uint256 public totalBorrows;
uint256 public totalSupply; // cToken shares
constructor(IERC20 _underlying) {
underlying = _underlying;
}
/// @dev DANGEROUS: exchangeRate uses underlying.balanceOf.
/// A donation inflates the numerator, pumping cToken price.
function exchangeRate() public view returns (uint256) {
if (totalSupply == 0) return 1e18;
uint256 cash = underlying.balanceOf(address(this)); // ← exploitable
return (cash + totalBorrows) * 1e18 / totalSupply;
}
function mint(uint256 underlyingAmount) external returns (uint256 shares) {
shares = underlyingAmount * 1e18 / exchangeRate();
underlying.transferFrom(msg.sender, address(this), underlyingAmount);
totalSupply += shares;
}
function redeemUnderlying(uint256 shares) external {
uint256 amount = shares * exchangeRate() / 1e18;
totalSupply -= shares;
underlying.transfer(msg.sender, amount);
}
}
/// @notice SECURE lending market — exchange rate uses tracked cash.
contract SecureCToken {
IERC20 public immutable underlying;
uint256 public totalBorrows;
uint256 public totalSupply;
uint256 public totalCash; // Internal; not read from balanceOf
constructor(IERC20 _underlying) {
underlying = _underlying;
}
function exchangeRate() public view returns (uint256) {
if (totalSupply == 0) return 1e18;
return (totalCash + totalBorrows) * 1e18 / totalSupply;
}
function mint(uint256 underlyingAmount) external returns (uint256 shares) {
shares = underlyingAmount * 1e18 / exchangeRate();
underlying.transferFrom(msg.sender, address(this), underlyingAmount);
totalCash += underlyingAmount; // Track internally
totalSupply += shares;
}
function redeemUnderlying(uint256 shares) external {
uint256 amount = shares * exchangeRate() / 1e18;
totalSupply -= shares;
totalCash -= amount; // Track internally
underlying.transfer(msg.sender, amount);
}
}
When a vault token is used as collateral in a lending protocol, you are implicitly trusting the entire pricing chain. An attacker does not need to break the vault itself — they just need to wiggle the exchange rate enough to trigger liquidations on innocent users.
6. The CREATE2 Pre-Funding Attack Vector
CREATE2 allows anyone to compute a contract’s deployment address before it exists. This is enormously useful for counterfactual instantiation, state channels, and cross-chain deployments, but it opens a specific forced-ETH attack window.
This feature is significant because it allows interactions with the address and facilitates the transfer of ETH to it even before the smart contract has been deployed onto it. The CREATE2 opcode possesses the capability to forecast the deployment address of a contract without actual deployment and provides numerous opportunities for enhancing user onboarding and scalability.
Attackers can send ETH to precomputed addresses (even if no contract is deployed there) to change assumptions, grief users, or create unexpected states. This is low-cost and often overlooked — protocols that implicitly assume zero balance or an unused address can be tripped by simple pre-funding.
Attack Scenario
Consider a factory that deploys minimal proxy vaults and runs an initialisation check:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @notice VULNERABLE factory — constructor checks balance.
contract VaultFactory {
function deploy(bytes32 salt) external returns (address vault) {
vault = address(new Vault{salt: salt}());
// Vault constructor relies on starting with 0 ETH.
}
function predictAddress(bytes32 salt) external view returns (address) {
bytes32 hash = keccak256(
abi.encodePacked(
bytes1(0xff),
address(this),
salt,
keccak256(type(Vault).creationCode)
)
);
return address(uint160(uint256(hash)));
}
}
/// @notice VULNERABLE vault — constructor assumes zero initial balance.
contract Vault {
bool public initialised;
uint256 public trackedBalance;
constructor() {
// BUG: attacker pre-funded this address via CREATE2 prediction.
// address(this).balance is already > 0 before constructor runs.
require(address(this).balance == 0, "Pre-funded: abort");
// ↑ This revert prevents legitimate deployment entirely.
initialised = true;
}
receive() external payable {
trackedBalance += msg.value;
}
}
An attacker calls predictAddress(salt), sends 1 wei to the result, and the constructor reverts every time the factory tries to deploy with that salt. This is a griefing attack: sending ETH to the future contract address would block a CREATE2 deployment since the address is now an account — this is a form of griefing known as “address poisoning” to prevent someone from using CREATE2 to that spot.
The deeper variant targets post-deployment logic that assumes an empty starting balance for ratio initialisation. If the vault’s first-deposit share price is computed against address(this).balance, a pre-funder has already manipulated the denominator before the very first legitimate user interacts.
Safe CREATE2-Based Factory Pattern
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SafeVaultFactory {
event VaultDeployed(address indexed vault, bytes32 salt);
function deploy(bytes32 salt) external returns (address vault) {
vault = address(new SafeVault{salt: salt}());
emit VaultDeployed(vault, salt);
}
function predictAddress(bytes32 salt) external view returns (address) {
bytes32 hash = keccak256(
abi.encodePacked(
bytes1(0xff),
address(this),
salt,
keccak256(type(SafeVault).creationCode)
)
);
return address(uint160(uint256(hash)));
}
}
contract SafeVault {
uint256 public trackedDeposits; // Never reads address(this).balance
bool private _initialised;
modifier notInitialised() {
require(!_initialised, "Already initialised");
_;
}
/// @dev Initialisation does NOT rely on balance being zero.
function initialise() external notInitialised {
_initialised = true;
// Any ETH already present (via pre-fund) is treated as a donation
// and never enters trackedDeposits. It cannot affect share math.
}
function deposit() external payable {
require(_initialised, "Not initialised");
trackedDeposits += msg.value;
}
function withdraw(uint256 amount) external {
require(trackedDeposits >= amount, "Insufficient");
trackedDeposits -= amount;
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "Transfer failed");
}
/// @dev Any ETH forced into the contract (selfdestruct, pre-fund, coinbase)
/// simply sits in the contract and is not attributed to any depositor.
receive() external payable {}
}
7. Writing ETH-Balance-Robust Contracts
The overarching principle is simple: replace every address(this).balance that participates in business logic with a tracked internal variable. The real balance can diverge from your tracked balance — and you should treat that divergence as an expected event, not an invariant violation.
7.1 Internal Accounting Pattern
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @notice Robust ETH accumulator — immune to forced ETH.
contract RobustAccumulator {
mapping(address => uint256) public contributions;
uint256 public totalContributed; // Canonical balance; not address(this).balance
event Contributed(address indexed contributor, uint256 amount);
event Withdrawn(address indexed contributor, uint256 amount);
function contribute() external payable {
require(msg.value > 0, "Zero contribution");
contributions[msg.sender] += msg.value;
totalContributed += msg.value;
emit Contributed(msg.sender, msg.value);
}
function withdraw(uint256 amount) external {
require(contributions[msg.sender] >= amount, "Insufficient balance");
contributions[msg.sender] -= amount;
totalContributed -= amount;
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "ETH transfer failed");
emit Withdrawn(msg.sender, amount);
}
/// @notice Forced ETH (selfdestruct, coinbase) accumulates here silently.
/// It is intentionally NOT credited to any depositor.
receive() external payable {}
/// @notice Governance can recover forced ETH that has no owner.
function forcedEthBalance() external view returns (uint256) {
return address(this).balance - totalContributed;
}
}
7.2 Guarding a Threshold Game
Replace address(this).balance with a counter updated exclusively through your deposit pathway:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @notice Robust game — threshold check uses tracked variable.
contract RobustEtherGame {
uint256 public constant TARGET = 7 ether;
uint256 public trackedBalance; // Only incremented via deposit()
address public winner;
event Winner(address indexed winner, uint256 balance);
function deposit() external payable {
require(msg.value == 1 ether, "Exactly 1 ETH");
require(trackedBalance < TARGET, "Game over");
trackedBalance += msg.value;
if (trackedBalance == TARGET) {
winner = msg.sender;
emit Winner(msg.sender, trackedBalance);
}
}
/// @dev Forced ETH enters here but cannot affect game state.
receive() external payable {}
}
7.3 Ratio and Share Calculations
For any ratio x / totalBalance, ask: “What happens if totalBalance is doubled by an attacker?” If the answer is “the ratio halves unexpectedly and that is exploitable,” you must switch to an internal tracker.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @notice Robust ETH vault share calculation.
contract RobustEthVault {
uint256 public totalShares;
uint256 public totalEth; // Internal accounting; never address(this).balance
mapping(address => uint256) public sharesOf;
function deposit() external payable {
uint256 shares;
if (totalShares == 0 || totalEth == 0) {
shares = msg.value; // 1:1 initial price
} else {
// Denominator is totalEth — immune to forced ETH inflation.
shares = (msg.value * totalShares) / totalEth;
}
require(shares > 0, "Zero shares");
totalEth += msg.value;
totalShares += shares;
sharesOf[msg.sender] += shares;
}
function withdraw(uint256 shares) external {
require(sharesOf[msg.sender] >= shares, "Insufficient shares");
uint256 ethOut = (shares * totalEth) / totalShares;
sharesOf[msg.sender] -= shares;
totalShares -= shares;
totalEth -= ethOut;
(bool ok,) = msg.sender.call{value: ethOut}("");
require(ok, "Transfer failed");
}
receive() external payable {}
}
7.4 Overflow from Forced ETH
In arithmetic-heavy contracts, if address(this).balance feeds into an unchecked subtraction (e.g., address(this).balance - expectedBalance), a forced top-up can cause an underflow even in Solidity 0.8 if the balance exceeds the internal tracker. Always structure subtractions so the internal tracker is the minuend:
// SAFE: subtraction uses internal tracker, never raw balance.
uint256 excessEth = address(this).balance - totalEth;
// Works correctly if totalEth <= address(this).balance.
// An attacker increasing address(this).balance only increases excessEth.
7.5 Slippage Protection for Vault Depositors
Even with internal accounting, always let callers specify a minimum shares out:
function deposit(uint256 minSharesOut) external payable returns (uint256 shares) {
// ... compute shares ...
require(shares >= minSharesOut, "Slippage: too few shares");
}
Users can protect against unexpected slippage in general by verifying the amount received is as expected, using a wrapper that performs these checks such as the ERC4626Router.
8. Quick Reference: The Defensive Checklist
To mitigate this risk, we need to avoid relying on the contract balance, but rather verify internally with a variable this operation. The following checklist encapsulates every defensive measure discussed in this article.
Forced ETH Audit Checklist
Use this checklist during code review and audits. Every “No” answer is a finding.
A. selfdestruct / Forced-ETH Receipt
- A1 — Does the contract avoid using
address(this).balancein any ratio, threshold, cap, or share-price calculation? - A2 — If
address(this).balanceis used for informational purposes only (e.g., a view function), is it clearly documented that the value may be inflated and is not an invariant? - A3 — Does the contract’s
receive()orfallback()function revert, or is the forced-ETH path explicitly acknowledged in comments/docs? - A4 — Are there any
require(address(this).balance == X)assertions? (These must be removed; use internal trackers instead.)
B. Block Reward / Coinbase Credit
- B1 — Is the contract ever set (intentionally or accidentally) as a validator
fee_recipient? If so, is the accrued ETH handled safely? - B2 — Does the contract’s deployment documentation warn operators never to point block rewards at it if it uses balance-dependent logic?
C. Internal Accounting
- C1 — Is there a dedicated storage variable (e.g.,
totalDeposited,totalManagedAssets) that is the canonical source of truth for ETH held on behalf of users? - C2 — Is that variable incremented only when actual deposits occur — never when tokens are transferred directly to the contract?
- C3 — Does the protocol use
token.balanceOf(address(this))astotalAssets()? If yes, flag as vulnerable to donation manipulation. - C4 — Is there a first-depositor path where
totalShares == 0? If yes, verify that virtual shares or a dead-shares seed prevent the inflation attack.
D. Rounding Direction
- D1 — On deposit: shares issued round down (fewer shares, not more).
- D2 — On withdrawal/redeem: assets returned round down (user gets less, not more).
- D3 — On withdrawal: shares burned round up (user burns more, protecting the vault).
- D4 — All division goes through
Math.mulDivor equivalent to prevent intermediate overflow.
E. Monitoring and Response
- E1 — Is there a circuit breaker that pauses deposits when share price moves anomalously between blocks?
- E2 — Does on-chain monitoring alert on direct token transfers to the vault contract (donation signals)?
- E3 — Is there a recovery path if the share price is artificially inflated — e.g., the ability to reset the virtual balance or pause deposits?