
Solidity Fallback and Receive Functions: Handling Ether and Unexpected Calls
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-levelcall
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.
| Function | Trigger condition | Typical use |
|---|---|---|
receive() | Empty calldata and Ether is sent | Accepting plain ETH transfers |
fallback() | No matching function, or empty calldata when receive() is absent | Handling unknown calls, proxy forwarding, rejecting unsupported input |
Key rules
receive()must be declared asexternal payable.fallback()can beexternalorexternal 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:
- Does the calldata match a function?
- Yes: call that function.
- No: continue.
- Is calldata empty and
receive()present?
- Yes: call
receive(). - No: call
fallback()if available.
- 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:
| Requirement | Recommended handler |
|---|---|
| Accept plain ETH transfers only | receive() |
| Handle unknown function calls | fallback() |
| Build a proxy | fallback() |
| Log direct deposits | receive() |
| Reject unsupported calldata | fallback() with revert |
| Accept both plain ETH and arbitrary calldata | Both, 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.datacontains the raw calldata.msg.valuecontains 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:
- Update state first
- Avoid external calls in the handler
- 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 acceptedfallback()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.
