Skip to main content
Sigvex

Locked Ether

Detects contracts that can receive ETH but have no code path that sends it out, permanently trapping funds.

Locked Ether

Overview

Remediation Guide: Locked Ether Remediation

The locked ether detector identifies contracts that accept ETH — through payable functions, receive(), a payable fallback(), or a payable constructor — but contain no instruction capable of sending ETH out: no value-bearing CALL, no transfer/send, no selfdestruct. Any ETH such a contract receives stays there forever unless an external rescue mechanism exists.

The common causes are mundane: a constructor marked payable for deployment convenience with no withdrawal function behind it, a receive() added to silence failed transfers, or a withdrawal function that was planned and never written.

Why This Is an Issue

There is no undo. ETH sent to a contract without an exit path cannot be recovered by the sender, the deployer, or anyone else — the EVM offers no override. The problem also cannot be fully prevented at the intake side: a contract can be force-funded via selfdestruct or as a block reward recipient even if it has no payable functions at all. What matters is the exit path, and that is what this detector checks. Loss events here are usually self-inflicted (accidental sends, over-funded deployments) but they are total and permanent.

How to Resolve

Add an access-controlled withdrawal, or remove payability the contract does not need:

// Before: accepts ETH, no way out
contract Collector {
    receive() external payable {}
}

// After: accepts ETH with an owner-guarded exit
contract Collector {
    address public immutable owner = msg.sender;

    receive() external payable {}

    function withdraw(address payable to) external {
        require(msg.sender == owner, "not owner");
        (bool ok, ) = to.call{value: address(this).balance}("");
        require(ok, "send failed");
    }
}

If the contract should never hold ETH, remove payable from the constructor and functions and omit receive() — the default reverting fallback then refuses direct sends (force-funding via selfdestruct remains possible but harmless if the balance is never relied on).

Examples

Sample Sigvex Output

{
  "detector_id": "locked-ether",
  "severity": "high",
  "confidence": 0.6,
  "description": "Locked Ether in receive(): the contract can receive ETH but has no mechanism to withdraw it (no CALL with value, no transfer/send, no selfdestruct). Funds will be permanently locked unless an external rescue mechanism exists.",
  "location": { "function": "receive()", "offset": 0 }
}

Detection Methodology

The detector scans the decompiled IR in two passes. First, intake: it finds functions that accept nonzero CALLVALUE (payable entry points, receive, payable fallback, payable constructor). Second, egress: it scans every function for value-bearing external calls and selfdestruct. A contract with intake and no egress produces one finding per accepting entry point. Confidence is then calibrated by contract classification: token contracts (ERC-20/ERC-721) that rarely need ETH withdrawal are reported around 0.40; vault, staking, escrow, bridge, and WETH-like contracts that hold ETH by design around 0.45; everything else at 0.60. Proxy shells are further discounted, since the withdrawal path usually lives in the implementation.

Limitations

  • Proxies: intake in the proxy with egress in the implementation looks locked when the proxy is scanned alone; classification-based discounting reduces but does not eliminate these findings.
  • Contracts that intentionally burn ETH (sink contracts) are indistinguishable from accidental locks at the bytecode level.
  • An egress path that exists but is unreachable in practice (permanently failing guard) counts as an exit and suppresses the finding.

References