receive() or a Named Payable Function? A Solidity Ether-Handling Decision Guide
Should your contract accept Ether through receive() or a named payable function? A decision guide with a comparison table, trade-offs, and a validatable implementation pattern.
18 Jul 2025, 08:43 UTC

Any contract that accepts Ether needs an entry point for it, and Solidity gives you two practical choices: the special receive() function, or a named payable function such as deposit(). Picking the wrong one leads to reverted transfers, accidental locked funds, or an open door for unwanted payments. This guide lays out the decision, compares the options, and shows a concrete pattern you can validate yourself.
The decision and its constraints
The core question is: should anyone be able to send this contract plain Ether with no calldata, and do you need to know anything about the payment when it arrives?
Three constraints drive the answer:
- Calldata.
receive()only runs when a transaction carries Ether and empty calldata. It cannot accept arguments. If a payment must carry context (an invoice ID, a user reference),receive()cannot express that. - Caller intent. A named function makes the deposit an explicit, auditable action.
receive()makes any plain transfer succeed, including mistaken ones. - Forced Ether. Neither option can block Ether entirely. Ether can arrive via
selfdestructfrom another contract or as validator rewards without any function running. Never useaddress(this).balanceas an exact accounting invariant.
Option comparison
| Property | receive() external payable | Named payable function (e.g., deposit()) |
|---|---|---|
| Trigger | Ether transfer with empty calldata | Explicit call with the function selector |
| Accepts arguments/calldata | No | Yes |
| Access control | Possible but awkward | Natural (modifiers like onlyOwner) |
| Plain wallet transfer (EOA) | Works (subject to gas stipend) | Fails (no selector match) |
| Gas | Slightly cheaper | Slightly higher dispatch cost |
| Accidental payments | Accepted silently unless reverted | Rejected by default if no fallback exists |
| Best for | Wallets, WETH wrappers, tip jars | Staking, payments with references |
Trade-offs that matter in practice
The 2300 gas caveat. If callers use Solidity's .transfer() or .send(), those forward only 2300 gas. Your receive() must fit in that budget—an event emit plus a storage write can exceed it. Modern guidance is to use .call{value: amount}(""), which forwards all remaining gas, but you do not control what third parties use. Keep receive() minimal.
Silent acceptance is a liability. With receive() present, a user who pastes your contract address into their wallet and hits send will succeed. If your contract's accounting depends on knowing why Ether arrived, silent acceptance creates funds you cannot attribute. The conservative default for business-logic contracts is: no receive(), no payable fallback(), only named payable functions.
fallback() is not a deposit mechanism. A fallback() external payable catches calls with unmatched selectors or non-empty calldata when no receive() applies. Using it to accept payments hides mistakes—a user who mistypes a function name would have their Ether accepted by fallback instead of getting a revert.
Concrete implementation
A common, defensible pattern: a named payable function for real deposits, plus a receive() that either forwards to the same logic or reverts based on a configuration.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Treasury {
event Deposit(address indexed from, uint256 amount, bytes32 reference);
event PlainTransfer(address indexed from, uint256 amount);
error PlainTransfersDisabled();
bool public acceptPlainTransfers;
constructor(bool _acceptPlain) {
acceptPlainTransfers = _acceptPlain;
}
/// Explicit deposits carry a reference for accounting.
function deposit(bytes32 reference) external payable {
require(msg.value > 0, "no value");
emit Deposit(msg.sender, msg.value, reference);
}
/// Runs only for Ether transfers with empty calldata.
receive() external payable {
if (!acceptPlainTransfers) revert PlainTransfersDisabled();
// Keep this cheap: event only, no heavy storage writes
emit PlainTransfer(msg.sender, msg.value);
}
// No payable fallback: calls with bad calldata revert.
}Key properties of this shape:
- Deposits that matter go through
deposit(bytes32)and are attributable. - The behavior of plain transfers is an explicit constructor decision.
- There is no payable fallback, so mistyped calldata reverts.
How to validate the behavior
Compile with solc 0.8.x. In a test framework such as Foundry or Hardhat:
- Empty calldata transfer: send Ether with
call{value: 1 ether}(""). ExpectPlainTransferwhen enabled, or a revert withPlainTransfersDisabledwhen not. - Named call with data: call
deposit(bytes32)with value. ConfirmDepositis emitted andreceive()was not invoked. - Unknown selector: send Ether with garbage calldata, e.g.
call{value: 1 ether}(hex"deadbeef"). Expect a revert, proving no payable fallback is swallowing mistakes. - Gas check: if supporting
.transfer(), measurereceive()'s gas and confirm it stays under 2300.
Limitations
No code pattern prevents forced Ether via selfdestruct or coinbase payments; reconcile accounting against tracked totals, not raw balance. Gas costs of event emission vary by compiler version and state. If your contract is behind a proxy, the receive/fallback dispatch happens on the proxy, not the implementation.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.