Locked Ether Remediation
Overview
A contract that can receive ETH but never send it traps funds permanently — the EVM has no recovery mechanism. The remediation is one of two symmetrical moves: give the contract an exit path (an access-controlled withdrawal), or take away the intake (remove payable and receive() so direct sends revert). Which one is right depends on whether the contract has any business holding ETH.
Related Detector: Locked Ether
Recommended Fix
Before (Vulnerable)
contract FeeSink {
// Accepts ETH from anywhere...
receive() external payable {}
// ...and has no function that can send it out.
}
After (Fixed)
contract FeeSink {
address public immutable owner = msg.sender;
receive() external payable {}
function withdraw(address payable to, uint256 amount) external {
require(msg.sender == owner, "not owner");
(bool ok, ) = to.call{value: amount}("");
require(ok, "send failed");
}
}
The withdrawal must be guarded — an unguarded exit swaps a locked-funds bug for a theft bug. Use call{value: ...} with a success check rather than transfer, which forwards a fixed 2300 gas and fails against recipients with nontrivial receive logic.
Alternative Mitigations
- Remove payability instead: if the contract should never hold ETH, delete
receive(), droppayablefrom the constructor and all functions, and let the default fallback revert direct sends. Note ETH can still be force-pushed viaselfdestructor block rewards — that residue is unrecoverable but harmless as long as no logic depends onaddress(this).balance. - Sweep-to-treasury pattern: instead of per-owner withdrawal, a permissionless
sweep()that always sends the full balance to a fixed, immutable treasury address gives an exit path with no access-control surface. - Upgradeable contracts: if the locked contract is behind a proxy, add the withdrawal function in the next implementation upgrade; the trapped balance becomes reachable as soon as the upgrade lands.
Common Mistakes
- Adding an unprotected withdrawal.
function withdraw() external { msg.sender.call{value: address(this).balance}(""); }fixes the lock by letting anyone drain the contract. Every exit path needs access control. - Relying on
selfdestructas the rescue plan. Since EIP-6780 (Cancun),selfdestructonly transfers the balance when called in the same transaction as contract creation — it is no longer a viable recovery mechanism for deployed contracts. - Using
transfer()for the exit. The 2300-gas stipend breaks withdrawals to multisigs and smart-contract wallets; usecallwith a checked return. - Marking the constructor
payable“to save gas” with no withdrawal behind it. Any ETH sent at deployment is locked from block one.