Why special call handlers matter

Most Solidity functions are invoked by matching a function selector in the calldata. But not every external interaction fits that model. Examples include:

  • Sending Ether with no calldata from a wallet
  • Calling a contract with an unknown function signature
  • Forwarding calls through a proxy contract
  • Receiving Ether from another contract using transfer, send, or low-level call

Without explicit handling, these cases can revert or behave unexpectedly. Solidity’s special functions let you define a clear policy for these situations.

receive() vs fallback()

Solidity introduced receive() to separate plain Ether transfers from unknown function calls. The distinction is subtle but important.

FunctionTrigger conditionTypical use
receive()Empty calldata and Ether is sentAccepting plain ETH transfers
fallback()No matching function, or empty calldata when receive() is absentHandling unknown calls, proxy forwarding, rejecting unsupported input

Key rules

  • receive() must be declared as external payable.
  • fallback() can be external or external payable.
  • If both exist, receive() is used for empty calldata with Ether.
  • If calldata is non-empty and no function matches, fallback() is used.
  • If only fallback() exists and it is payable, it can also receive plain Ether.

A useful mental model is:

  1. Does the calldata match a function?
  • Yes: call that function.
  • No: continue.
  1. Is calldata empty and receive() present?
  • Yes: call receive().
  • No: call fallback() if available.
  1. Otherwise: revert.

A minimal example

The following contract accepts plain Ether, logs it, and rejects unsupported function calls.

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

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

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

    fallback() external payable {
        emit UnknownCall(msg.sender, msg.data, msg.value);
        revert("Unsupported function");
    }

    function balance() external view returns (uint256) {
        return address(this).balance;
    }
}

What happens here

  • A wallet sending ETH with no data triggers receive().
  • A call to an undefined function triggers fallback().
  • The fallback emits an event and then reverts, which is useful if you want diagnostics but do not want to accept unknown calls.

This pattern is common when you want to support deposits but avoid accidental or malicious interaction through arbitrary calldata.

When to use receive()

Use receive() when your contract should accept Ether sent without calldata. Typical cases include:

  • Donation or tip contracts
  • Treasury contracts that accept direct deposits
  • Contracts that need to support wallet “send ETH” actions
  • Proxy implementations that must accept ETH transfers

Best practices for receive()

Keep receive() simple and predictable:

  • Avoid heavy logic
  • Avoid external calls if possible
  • Emit an event for observability
  • Update internal accounting before any interaction with other contracts

A good receive() function should be easy to reason about. If receiving Ether requires complex business logic, consider exposing a regular payable function instead, such as deposit().

Example: tracked deposits

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

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

    event Received(address indexed from, uint256 amount);

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

This contract records who sent Ether directly. It is simple, but it has an important limitation: it only captures transfers with empty calldata. If a user sends ETH through a function call, receive() will not run.

When to use fallback()

Use fallback() when your contract must respond to calls that do not match any declared function. Common scenarios include:

  • Proxy contracts that forward calls to an implementation contract
  • Compatibility layers for older interfaces
  • Defensive contracts that log and reject unsupported calls
  • Contracts that need to accept arbitrary calldata for routing

Proxy forwarding pattern

One of the most important uses of fallback() is in proxy contracts. The proxy does not implement the business logic itself. Instead, it forwards the call data to another contract using delegatecall.

// 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
            )

            returndatacopy(0, 0, returndatasize())

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

This is a low-level pattern and should be used carefully. The proxy must preserve storage layout compatibility with the implementation contract, and the implementation must be trusted or upgrade-controlled.

Choosing between receive() and fallback()

The following table summarizes practical decision-making:

RequirementRecommended handler
Accept plain ETH transfers onlyreceive()
Handle unknown function callsfallback()
Build a proxyfallback()
Log direct depositsreceive()
Reject unsupported calldatafallback() with revert
Accept both plain ETH and arbitrary calldataBoth, with clear behavior

A contract can define both functions, but they should have distinct responsibilities. Do not duplicate logic unnecessarily.

Payability and Ether flow

Whether a special function can accept Ether depends on payable.

  • receive() must be payable.
  • fallback() must be payable if it should accept Ether.
  • A non-payable fallback() can still handle unknown calls, but any Ether sent with the call will revert.

Example: rejecting Ether in fallback

fallback() external {
    revert("No fallback calls accepted");
}

This is appropriate when your contract should never receive ETH through unknown calls. It is safer than silently accepting funds without accounting for them.

Example: accepting Ether in fallback

fallback() external payable {
    // Accept ETH and maybe route or log it
}

This is useful for compatibility layers, but be explicit about what happens to the funds. If Ether is accepted, it should be accounted for or intentionally forwarded.

Common pitfalls

1. Assuming receive() handles all ETH transfers

It does not. If calldata is non-empty and no function matches, fallback() is used instead. If you only define receive(), unknown function calls will revert.

2. Putting complex logic in special functions

Special handlers are often invoked unexpectedly. Keep them small and deterministic. Heavy computation, loops over large arrays, or external calls can make them fragile and expensive.

3. Forgetting event emission

Direct transfers are otherwise hard to trace. Emitting events in receive() or fallback() improves debugging and off-chain indexing.

4. Confusing msg.data and msg.value

  • msg.data contains the raw calldata.
  • msg.value contains the Ether sent with the call.

In receive(), msg.data is empty. In fallback(), it may contain arbitrary bytes.

5. Accepting Ether without a withdrawal plan

If your contract can receive funds, it should also define how funds are later withdrawn or used. Otherwise, Ether may become trapped.

A practical deposit-and-withdraw pattern

For most applications, a dedicated deposit() function is better than relying only on receive(). It can validate intent, support metadata, and still allow direct transfers as a convenience path.

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

contract DepositVault {
    mapping(address => uint256) public balances;

    event Deposited(address indexed account, uint256 amount);
    event Withdrawn(address indexed account, uint256 amount);

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

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

    function withdraw(uint256 amount) external {
        require(balances[msg.sender] >= amount, "Insufficient balance");
        balances[msg.sender] -= amount;

        (bool ok, ) = payable(msg.sender).call{value: amount}("");
        require(ok, "Transfer failed");

        emit Withdrawn(msg.sender, amount);
    }
}

Why this pattern is useful

  • deposit() gives users an explicit interface.
  • receive() supports direct wallet transfers.
  • withdraw() uses the checks-effects-interactions pattern.
  • The contract maintains internal accounting instead of relying on raw balance alone.

This design is more robust than a contract that merely accepts Ether and stores no metadata.

Security considerations

Special functions are frequently involved in value transfer, so they deserve careful review.

Reentrancy risk

If receive() or fallback() makes external calls, the contract may become vulnerable to reentrancy. Prefer to:

  1. Update state first
  2. Avoid external calls in the handler
  3. Use a reentrancy guard if external interaction is unavoidable

Unexpected execution paths

Because these functions can be triggered by wallets, routers, or other contracts, they may execute in contexts you did not anticipate. Keep authorization checks explicit and avoid assuming a trusted caller.

Gas constraints

Historically, transfer() and send() forwarded a fixed gas stipend, which made some fallback logic fail unexpectedly. Modern Solidity code should generally prefer low-level call for Ether transfers, but only with proper error handling.

Development tips

  • Test both empty and non-empty calldata cases.
  • Verify behavior when Ether is sent with and without a matching function.
  • Confirm that unsupported calls revert with a useful reason.
  • If building a proxy, test storage layout and delegatecall behavior thoroughly.
  • Use events to make direct transfers visible in block explorers and analytics tools.

A simple checklist for production readiness:

  • receive() present if plain ETH should be accepted
  • fallback() present if unknown calls must be handled
  • Both functions are minimal
  • Ether is either accounted for or intentionally rejected
  • Withdrawal logic is defined if funds are stored

Summary

receive() and fallback() are small language features with outsized importance. They define how your contract behaves when it receives Ether or unexpected calldata, which is critical for wallets, proxies, and payment flows. Use receive() for plain transfers, fallback() for unmatched calls, and keep both functions minimal, explicit, and well-tested.

Learn more with useful resources