
Designing Safe and Predictable Solidity Receive Functions
What receive() actually does
The receive() function is a special Solidity entry point that runs when a contract receives Ether with empty calldata.
Typical trigger:
- A plain ETH transfer using
.transfer(),.send(), or low-level.call{value: ...}("") - A transaction sent directly to the contract address with no calldata
A valid receive() function must be:
externalpayable- declared without arguments
- declared without a return value
Example:
receive() external payable {
// Accept Ether here
}If the contract does not define receive(), Solidity may route the call to fallback() instead, if one exists. If neither exists, the transfer reverts.
Why this matters
Many contracts do not need to accept Ether at all. In those cases, omitting receive() is often the safest choice. If your contract does accept Ether, the function should do as little as possible and make the acceptance rules obvious.
receive() vs fallback()
The distinction between receive() and fallback() is one of the most common sources of confusion.
| Situation | receive() | fallback() |
|---|---|---|
| Empty calldata, Ether sent | Yes | Only if receive() is absent |
| Non-empty calldata, no matching function | No | Yes |
| Ether sent with unknown function selector | No | Yes, if payable |
| Intended for plain deposits | Yes | Usually no |
A good mental model:
- Use
receive()for plain Ether deposits. - Use
fallback()for unknown function calls or proxy dispatch logic.
If both are present, Solidity chooses based on calldata:
- Empty calldata →
receive() - Non-empty calldata with no match →
fallback()
Practical implication
If you add a payable fallback() but forget that it also accepts Ether, you may accidentally allow deposits through unexpected paths. That can be acceptable in a proxy contract, but it is usually undesirable in application contracts.
When you should define receive()
Define receive() only when your contract has a clear reason to accept plain Ether transfers.
Common cases include:
- A treasury or vault contract that holds ETH
- A payment contract that accepts direct deposits
- A wrapped asset contract that needs to receive ETH during minting or redemption
- A proxy or router that must preserve compatibility with incoming ETH
If your contract does not need to hold ETH, do not define receive(). Let transfers revert by default.
Good rule of thumb
Ask two questions:
- Should this contract ever accept ETH sent without calldata?
- If yes, what exact state changes should happen?
If you cannot answer both clearly, you probably should not implement receive() yet.
Keep receive() minimal
A receive() function should be short and deterministic. It should not contain complex business logic, external calls, or expensive state transitions unless absolutely necessary.
Prefer these patterns
- Record a deposit amount
- Emit an event
- Update a balance mapping
- Enforce a simple acceptance rule
Avoid these patterns
- Calling untrusted external contracts
- Performing token swaps
- Triggering complex accounting branches
- Depending on mutable global assumptions
- Reentering other contract functions
A minimal design reduces the chance of unexpected failures and makes the contract easier to reason about.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract SimpleVault {
mapping(address => uint256) public deposits;
event Deposited(address indexed sender, uint256 amount);
receive() external payable {
deposits[msg.sender] += msg.value;
emit Deposited(msg.sender, msg.value);
}
}This example is simple, but it already captures the main design principles:
- The deposit path is explicit
- State updates happen before any external interaction
- The event makes off-chain tracking easy
Decide whether deposits should be unconditional
A common mistake is to accept Ether without checking whether the sender is allowed to deposit or whether the amount is valid.
Sometimes unconditional acceptance is fine. For example, a donation address may accept any amount from any sender.
In other cases, you should enforce rules such as:
- Only accept deposits during a specific phase
- Only accept deposits from approved addresses
- Only accept exact amounts
- Only accept deposits when a related contract state is active
Example with validation
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract TimedDepositBox {
address public owner;
uint256 public deadline;
mapping(address => uint256) public deposits;
error DepositsClosed();
error ZeroDeposit();
constructor(uint256 _deadline) {
owner = msg.sender;
deadline = _deadline;
}
receive() external payable {
if (block.timestamp > deadline) revert DepositsClosed();
if (msg.value == 0) revert ZeroDeposit();
deposits[msg.sender] += msg.value;
}
}This pattern is useful when deposits are meaningful only within a defined window. It prevents silent acceptance of funds after the contract is no longer supposed to receive them.
Be explicit about what happens to unexpected Ether
If a contract should not accept Ether, make that behavior obvious. The safest approach is often to omit receive() entirely and avoid a payable fallback() unless you truly need one.
If you want to reject unexpected Ether while still supporting other functionality, use a non-payable fallback or a reverting receive().
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract NoEthAllowed {
receive() external payable {
revert("ETH not accepted");
}
fallback() external payable {
revert("ETH not accepted");
}
}This is more explicit than relying on accidental reverts elsewhere in the codebase. It also makes the contract’s intent clear to integrators and auditors.
When explicit rejection is useful
- Governance contracts that should never hold ETH
- ERC-20-only systems
- Contracts that rely on off-chain payment settlement
- Proxies that should not be funded directly
Consider how receive() interacts with accounting
If your contract tracks balances internally, receive() should update those balances immediately and consistently. Do not rely on address(this).balance as your only source of truth unless the contract is intentionally balance-based.
Balance-based vs ledger-based design
| Approach | Strengths | Weaknesses |
|---|---|---|
address(this).balance | Simple, reflects actual ETH held | Harder to attribute deposits per user |
| Internal ledger mapping | Clear attribution, supports withdrawals | Requires careful accounting discipline |
For most application contracts, a ledger-based design is better because it supports user-specific accounting and controlled withdrawals.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract LedgerVault {
mapping(address => uint256) public balances;
event Received(address indexed from, uint256 amount);
receive() external payable {
balances[msg.sender] += msg.value;
emit Received(msg.sender, msg.value);
}
}This pattern is especially useful when deposits later drive withdrawals, rewards, or access rights.
Avoid hidden assumptions about calldata
A contract can receive ETH in ways that do not obviously call receive(). For example, a low-level call with empty calldata may trigger it, but a call with malformed calldata may route to fallback() instead.
That means your design should not assume that all ETH transfers arrive through the same path.
Best practices
- Keep
receive()andfallback()behavior distinct - Document which entry point is intended for deposits
- Avoid duplicating logic in both functions unless necessary
- If both functions accept ETH, ensure they preserve the same accounting invariants
If you use both, consider factoring shared logic into an internal function.
function _deposit() internal {
balances[msg.sender] += msg.value;
emit Deposited(msg.sender, msg.value);
}
receive() external payable {
_deposit();
}
fallback() external payable {
_deposit();
}This can be appropriate when both empty and non-empty calldata should be treated as deposits, but it should be a deliberate choice, not an accident.
Be careful with external calls from receive()
One of the most important safety rules is to avoid external calls inside receive() unless you have a strong reason and understand the reentrancy implications.
External calls can:
- Reenter your contract
- Fail unexpectedly
- Increase gas usage
- Create dependency on another contract’s behavior
If you must call out, follow checks-effects-interactions and consider a reentrancy guard. But in most cases, the better design is to keep receive() self-contained.
Prefer this
- Update internal state
- Emit event
- Return
Avoid this
- Calling a price oracle
- Triggering a token mint in another contract
- Performing a swap on a DEX
- Sending ETH onward immediately
If Ether must be forwarded elsewhere, a separate function is usually safer and easier to test.
Document the contract’s Ether policy
A good receive() function is not just technically correct; it is also understandable to integrators.
Document:
- Whether the contract accepts ETH
- Whether deposits are unconditional or restricted
- What state changes occur on receipt
- Whether deposits are refundable
- Whether
fallback()also accepts ETH
This documentation belongs in the contract comments and external developer docs. Clear Ether policy reduces integration mistakes and helps front-end teams build correct transaction flows.
Example comment style
/// @notice Accepts plain ETH deposits during the active sale window.
/// @dev Updates the sender's deposit balance and emits Deposited.
/// Reverts after the sale deadline.
receive() external payable { ... }Checklist for a safe receive() design
Use this checklist before shipping:
- [ ] The contract actually needs to accept plain ETH
- [ ]
receive()isexternalandpayable - [ ] The function is minimal and deterministic
- [ ] State updates happen before any external interaction
- [ ] Unexpected ETH is rejected intentionally if not supported
- [ ] Deposit rules are enforced clearly
- [ ] Events are emitted when deposits matter off-chain
- [ ]
fallback()behavior is separately reviewed - [ ] The Ether policy is documented for integrators
If several of these boxes are hard to justify, simplify the design.
Common mistakes to avoid
1. Accepting ETH without a purpose
If the contract does not need ETH, do not add receive() “just in case.”
2. Mixing deposit logic with unrelated business logic
Keep receipt handling separate from complex workflows.
3. Forgetting that fallback() may also be payable
A payable fallback can unintentionally accept ETH.
4. Using receive() as a catch-all
It is not a general-purpose entry point. It is specifically for empty-calldata ETH transfers.
5. Hiding acceptance rules
If deposits are limited by time, role, or amount, enforce that directly in code.
Conclusion
A well-designed receive() function is small, explicit, and easy to audit. In many contracts, the safest choice is not to implement one at all. When you do need to accept Ether, make the acceptance path intentional, keep the logic minimal, and define clear rules for how deposits affect contract state.
The goal is not merely to receive ETH successfully. The goal is to make every incoming transfer predictable, reviewable, and aligned with the contract’s actual purpose.
