
Solidity Receive vs Fallback: Designing Safe Ether Entry Points
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
selfdestructfrom 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 calldatafallback()for unknown function calls, and optionally for ETH reception ifreceive()is absent
The execution rules
The dispatch logic is straightforward:
| Situation | Function invoked |
|---|---|
| Empty calldata, ETH sent | receive() if present; otherwise fallback() if payable |
| Non-empty calldata, no matching function | fallback() |
| Matching function exists | That function runs instead |
| ETH sent to non-payable entry point | Transaction reverts |
A few details matter in practice:
receive()must be declaredexternal payablefallback()may beexternalorexternal payable- If both exist,
receive()handles empty calldata andfallback()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 depositsfallback()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()orsend()
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.
| Goal | Recommended design |
|---|---|
| Accept plain ETH only | Implement receive() and keep fallback() non-payable or absent |
| Accept ETH and inspect unknown calls | Implement both, with clear separation |
| Build a proxy | Use fallback() to forward calls; receive() may also forward or revert depending on design |
| Reject accidental transfers | Revert in receive() and fallback() |
| Preserve compatibility with old integrations | Use 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
delegatecallinfallback(), 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 hitreceive()address(contract).call{value: 1 ether}(abi.encodeWithSignature("nope()"))should hitfallback()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:
- Implement
receive()only if plain ETH transfers are expected. - Keep
receive()minimal and deterministic. - Use
fallback()only when you truly need unknown-call handling. - Revert in
fallback()unless compatibility or proxy behavior is required. - Prefer explicit payable functions for user-facing deposits.
- Test all call paths, including malformed calldata and zero-length calldata.
A concise decision matrix:
| Pattern | Use case | Risk level |
|---|---|---|
receive() only | Simple ETH acceptance | Low |
receive() + reverting fallback() | Accept ETH, reject unknown calls | Low |
receive() + permissive fallback() | Compatibility or diagnostics | Medium |
fallback() proxy forwarding | Upgradeable architecture | High |
fallback() with business logic | Rarely justified | High |
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.
