Why Ether entry points need careful design

A contract can receive ETH in several ways:

  • A direct transfer with empty calldata
  • A call to a non-existent function selector
  • A contract-to-contract transfer through call
  • A forced transfer via selfdestruct from another contract

If your contract does not explicitly handle these cases, you may get unexpected reverts or silent behavior that is hard to debug. In production systems, this can break deposits, block integrations, or create confusing user experiences.

Solidity provides two special functions for this purpose:

  • receive() for plain ETH transfers with empty calldata
  • fallback() for unknown function calls, and optionally for ETH reception if receive() is absent

The execution rules

The dispatch logic is straightforward:

SituationFunction invoked
Empty calldata, ETH sentreceive() if present; otherwise fallback() if payable
Non-empty calldata, no matching functionfallback()
Matching function existsThat function runs instead
ETH sent to non-payable entry pointTransaction reverts

A few details matter in practice:

  • receive() must be declared external payable
  • fallback() may be external or external payable
  • If both exist, receive() handles empty calldata and fallback() handles unknown selectors
  • If fallback() is not payable, it cannot accept ETH

This separation lets you distinguish between “someone sent ETH” and “someone called something I do not recognize.”


A minimal example

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Vault {
    event Deposited(address indexed sender, uint256 amount);
    event UnknownCall(address indexed sender, bytes data, uint256 value);

    uint256 public totalReceived;

    receive() external payable {
        totalReceived += msg.value;
        emit Deposited(msg.sender, msg.value);
    }

    fallback() external payable {
        if (msg.value > 0) {
            totalReceived += msg.value;
        }
        emit UnknownCall(msg.sender, msg.data, msg.value);
    }
}

This contract accepts ETH in both entry points, but it treats them differently:

  • receive() is used for plain deposits
  • fallback() logs unknown calls and still accepts ETH if sent

That said, this pattern is not always ideal. Logging unknown calls can be useful during development, but in production it may hide integration errors that should instead revert.


When to use receive()

Use receive() when your contract is intended to accept plain ETH transfers with no calldata. Common examples include:

  • Donation contracts
  • Simple vaults
  • Payment escrows
  • Contracts that must accept ETH from wallets using transfer() or send()

A good receive() function should usually be small and predictable. It should avoid complex logic, external calls, and state changes that depend on external contracts.

Best practices for receive()

  • Keep it minimal
  • Emit an event if deposits need to be indexed
  • Update only essential accounting state
  • Avoid external calls
  • Consider reverting if plain ETH transfers should not be accepted

Example:

receive() external payable {
    require(msg.value > 0, "No ETH sent");
    balanceOf[msg.sender] += msg.value;
    emit Deposited(msg.sender, msg.value);
}

This is appropriate for a deposit contract where every incoming ETH transfer should be tracked.


When to use fallback()

Use fallback() when your contract needs to handle unknown function selectors. This is common in a few advanced scenarios:

  • Proxy contracts that delegate calls to an implementation
  • Compatibility layers for legacy interfaces
  • Contracts that intentionally accept arbitrary calldata
  • Debug or metering contracts that inspect unknown calls

Because fallback() is invoked when no function matches, it is often the backbone of proxy patterns. In that case, it typically forwards the call using delegatecall.

Example: proxy-style fallback

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract SimpleProxy {
    address public implementation;

    constructor(address _implementation) {
        implementation = _implementation;
    }

    fallback() external payable {
        address impl = implementation;
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
            let size := returndatasize()
            returndatacopy(0, 0, size)

            switch result
            case 0 { revert(0, size) }
            default { return(0, size) }
        }
    }
}

This is a classic advanced use case: the proxy does not implement application logic itself. Instead, it forwards all unknown calls to another contract.


Choosing between receive() and fallback()

A practical way to decide is to ask what behavior you want for each kind of incoming transaction.

GoalRecommended design
Accept plain ETH onlyImplement receive() and keep fallback() non-payable or absent
Accept ETH and inspect unknown callsImplement both, with clear separation
Build a proxyUse fallback() to forward calls; receive() may also forward or revert depending on design
Reject accidental transfersRevert in receive() and fallback()
Preserve compatibility with old integrationsUse fallback() carefully, with strict validation

If your contract is not meant to be a proxy or compatibility shim, a permissive fallback() is often a liability. It can make bugs harder to detect because malformed calls no longer fail loudly.


Common pitfalls

1. Assuming transfer() is always safe

Historically, developers relied on transfer() to send ETH because it forwarded a fixed gas stipend. That assumption is fragile. If the recipient’s receive() or fallback() needs more gas, the transfer can fail.

Modern Solidity development generally prefers low-level call{value: ...}("") with explicit success handling.

2. Putting too much logic in receive()

A receive() function should not become a full business-logic endpoint. If you need validation, pricing, or cross-contract coordination, expose a named payable function such as deposit() instead. That gives callers a clearer API and makes failures easier to diagnose.

3. Making fallback() silently accept mistakes

If a user calls a misspelled function and your fallback() accepts it, the transaction may appear successful while doing nothing useful. In many applications, that is worse than a revert.

4. Forgetting calldata differences

receive() only handles empty calldata. A transaction that sends ETH and includes any calldata will not hit receive(). If you expect both patterns, you need fallback() too.


Designing explicit deposit APIs

Even when a contract can receive ETH through receive(), it is often better to provide a named deposit function for application-level interactions.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract DepositBox {
    mapping(address => uint256) public deposits;

    event Deposited(address indexed sender, uint256 amount);

    function deposit() external payable {
        require(msg.value >= 0.01 ether, "Minimum deposit not met");
        deposits[msg.sender] += msg.value;
        emit Deposited(msg.sender, msg.value);
    }

    receive() external payable {
        deposits[msg.sender] += msg.value;
        emit Deposited(msg.sender, msg.value);
    }
}

This contract supports both explicit deposits and plain ETH transfers. But in many systems, you would choose only one of these paths to reduce ambiguity.

Why explicit functions are often better

  • They document intent
  • They support parameterized validation
  • They are easier to integrate with frontends and SDKs
  • They reduce accidental deposits from unrelated transfers

A good rule: use receive() for compatibility, but use named functions for primary business flows.


Security and upgrade considerations

receive() and fallback() are often part of the contract’s external attack surface. Treat them as critical entry points.

Security guidelines

  • Do not call untrusted contracts from receive()
  • Avoid state transitions that depend on external return values
  • Revert on unexpected calldata unless compatibility is required
  • If using delegatecall in fallback(), strictly control the implementation address
  • Be careful with access control in proxy admin flows

Upgrade-safe design

For upgradeable systems, fallback() is frequently used to route calls to an implementation contract. In that case:

  • Keep proxy storage layout separate from implementation storage
  • Ensure the implementation address is validated
  • Consider an admin-only upgrade function
  • Avoid mixing business logic into the proxy itself

A proxy’s fallback() should be boring: copy calldata, delegate, return or revert. Anything more increases risk.


Testing strategies

You should test both entry points explicitly.

Test cases to include

  • Sending ETH with empty calldata
  • Sending ETH with non-empty calldata
  • Calling an unknown function selector
  • Calling a known payable function
  • Sending ETH to a non-payable fallback()
  • Verifying event emission and accounting updates

Example test scenarios:

  • address(contract).call{value: 1 ether}("") should hit receive()
  • address(contract).call{value: 1 ether}(abi.encodeWithSignature("nope()")) should hit fallback()
  • contract.someMissingFunction() should revert at compile time, but low-level calls should be tested for runtime behavior

When testing proxies, also verify that storage writes happen in the expected contract context after delegatecall.


Practical recommendations

For most contracts, the safest approach is:

  1. Implement receive() only if plain ETH transfers are expected.
  2. Keep receive() minimal and deterministic.
  3. Use fallback() only when you truly need unknown-call handling.
  4. Revert in fallback() unless compatibility or proxy behavior is required.
  5. Prefer explicit payable functions for user-facing deposits.
  6. Test all call paths, including malformed calldata and zero-length calldata.

A concise decision matrix:

PatternUse caseRisk level
receive() onlySimple ETH acceptanceLow
receive() + reverting fallback()Accept ETH, reject unknown callsLow
receive() + permissive fallback()Compatibility or diagnosticsMedium
fallback() proxy forwardingUpgradeable architectureHigh
fallback() with business logicRarely justifiedHigh

Conclusion

receive() and fallback() are small functions with outsized impact. They define how your contract behaves at the boundary between expected and unexpected input. Used well, they make ETH handling explicit, compatible, and safe. Used carelessly, they can hide bugs, complicate integrations, or open the door to brittle proxy behavior.

For most applications, the best design is simple: accept ETH intentionally, reject everything else by default, and reserve fallback() for cases where unknown-call handling is genuinely required.

Learn more with useful resources