
Testing Solidity Fallback and Receive Functions
Why fallback and receive testing matters
In Solidity, external calls can reach a contract in several ways:
- A transaction sends ETH with empty calldata
- A call targets a non-existent function selector
- A proxy forwards unknown selectors to an implementation
- A contract receives ETH during a refund or settlement flow
If these paths are not tested, subtle bugs can slip into production. Common failures include:
- ETH transfers reverting unexpectedly
- Funds being trapped because
receive()is missing fallback()swallowing calls that should fail- Proxy forwarding logic breaking when calldata is malformed
- Access checks not being applied to low-level entry points
A good test suite should prove that the contract accepts only the intended interactions and rejects everything else.
Fallback and receive in Solidity
Solidity provides two special functions:
receive() external payable: called when calldata is empty and ETH is sentfallback() external [payable]: called when no function selector matches, or when calldata is non-empty and no function exists
A contract may define either one or both.
Minimal example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Vault {
event Deposited(address indexed sender, uint256 amount, bytes data);
event UnknownCall(address indexed sender, bytes data);
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);
}
}This contract accepts plain ETH transfers via receive() and records unknown calls via fallback(). In practice, you should test both paths independently because they are triggered by different conditions.
What to test
A solid test plan usually covers these scenarios:
| Scenario | Expected behavior | Why it matters |
|---|---|---|
| Empty calldata + ETH | receive() executes | Standard ETH transfer path |
| Unknown selector + no ETH | fallback() executes | Handles typos or proxy routing |
| Unknown selector + ETH | fallback() executes if payable | Common in forwarding contracts |
| Known function call | Neither special function executes | Confirms normal dispatch |
| Non-payable fallback/receive | Call reverts on ETH | Prevents accidental value acceptance |
The key is to verify not just success, but also the exact route taken.
Testing receive() with empty calldata
The receive() function is triggered when a transaction sends ETH without calldata. This is the simplest case, but it is also the easiest to miss in tests because many frameworks default to calling named functions.
Example test
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
contract VaultTest is Test {
Vault vault;
function setUp() public {
vault = new Vault();
}
function testReceiveAcceptsPlainEtherTransfer() public {
address sender = address(0xBEEF);
vm.deal(sender, 1 ether);
vm.prank(sender);
(bool ok, ) = address(vault).call{value: 0.25 ether}("");
assertTrue(ok);
assertEq(vault.totalReceived(), 0.25 ether);
assertEq(address(vault).balance, 0.25 ether);
}
}What this test proves
- The call uses empty calldata
- ETH is accepted
- State changes occur as expected
- The contract balance increases correctly
Best practice
Always assert both the return value and the resulting state. A successful low-level call does not guarantee the contract handled the transfer correctly.
Testing fallback() with unknown selectors
A fallback function is triggered when calldata does not match any function signature. This is especially important for contracts that act as routers, proxies, or compatibility layers.
Example test
function testFallbackHandlesUnknownSelector() public {
address sender = address(0xCAFE);
vm.deal(sender, 1 ether);
bytes memory data = hex"12345678deadbeef";
vm.prank(sender);
(bool ok, ) = address(vault).call{value: 0}(data);
assertTrue(ok);
}This test sends arbitrary calldata that does not match any function selector. The important detail is that the calldata is non-empty, so fallback() is the expected entry point.
Verifying emitted data
If your fallback emits an event, assert that the raw calldata was captured correctly. This is useful when the contract forwards unknown calls to another target or logs them for diagnostics.
function testFallbackEmitsUnknownCallEvent() public {
bytes memory data = hex"ffffffff11223344";
vm.expectEmit(true, false, false, true);
emit Vault.UnknownCall(address(this), data);
(bool ok, ) = address(vault).call(data);
assertTrue(ok);
}Testing payable versus non-payable behavior
A frequent source of bugs is assuming that a contract can accept ETH when it cannot. receive() and fallback() are only payable if explicitly marked so.
Non-payable receive example
contract StrictVault {
fallback() external {}
}This contract cannot accept plain ETH transfers through receive(), and its fallback is not payable.
Test the revert path
function testPlainEtherTransferRevertsWhenNotPayable() public {
address sender = address(0xD00D);
vm.deal(sender, 1 ether);
vm.prank(sender);
(bool ok, ) = address(new StrictVault()).call{value: 1 wei}("");
assertFalse(ok);
}Why this matters
Many production incidents happen because a contract is assumed to be ETH-compatible when it is not. Testing the revert path makes that assumption explicit.
Distinguishing receive() from fallback()
The dispatch rules are simple, but tests should prove them:
- Empty calldata + ETH →
receive()if present and payable - Non-empty calldata + unknown selector →
fallback() - Empty calldata + no
receive()→fallback()if payable, otherwise revert
Dispatch matrix
| Calldata | ETH value | Function chosen |
|---|---|---|
"" | 0 | receive() if present, otherwise fallback() if present |
"" | > 0 | receive() if present and payable |
| unknown selector | 0 | fallback() |
| unknown selector | > 0 | fallback() if payable |
In tests, use explicit calldata values so the route is unambiguous. Avoid relying on helper methods that hide the underlying call shape.
Testing proxy-style forwarding
One of the most important real-world uses of fallback() is proxy forwarding. A proxy contract often receives a selector it does not implement, then forwards the calldata to an implementation contract.
Simplified proxy example
contract SimpleProxy {
address public implementation;
constructor(address impl) {
implementation = impl;
}
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()) }
}
}
}What to test in a proxy
- Unknown selector reaches the implementation
- Return data is preserved
- Reverts are bubbled up
- ETH handling matches the implementation’s expectations
- Storage writes occur in the proxy context when using
delegatecall
Practical test idea
Deploy an implementation with a function like setValue(uint256). Then call that function through the proxy and verify that the proxy storage changes, not the implementation storage. This confirms the fallback forwarding path is correct.
Common edge cases
1. Empty calldata with no receive()
If a contract has only a non-payable fallback(), a plain ETH transfer should revert. Test this explicitly because many wallets and integrations use empty-calldata transfers.
2. Selector collision assumptions
A call may appear to target a function but actually hit fallback() if the selector is wrong. Tests should include malformed selectors to ensure the contract does not silently accept invalid calls.
3. Unexpected ETH in fallback
If fallback() is payable, it may receive ETH during a low-level call. Verify whether that is intended. If not, assert that the call reverts.
4. Contract-to-contract transfers
When another contract sends ETH using .call{value: ...}(""), the target’s receive() is used. This is common in refund logic and should be covered by integration tests, not just unit tests.
Recommended testing patterns
Use low-level calls intentionally
To test special entry points, prefer address(contract).call(...) over named function calls. This gives you full control over calldata and value.
Assert both success and side effects
A passing call is not enough. Check:
- balance changes
- state variables
- emitted events
- returndata when relevant
Separate positive and negative tests
Write one test for the intended path and another for the rejection path. This makes the behavior easier to audit.
Keep calldata explicit
Use raw bytes for unknown selectors. Do not rely on helper abstractions that may accidentally encode a valid function signature.
Test with and without ETH
A fallback path may behave differently depending on msg.value. Cover both cases if the contract is payable.
A compact test checklist
Before shipping a contract that uses receive() or fallback(), verify the following:
- Plain ETH transfer succeeds or reverts as intended
- Unknown selector calls route to
fallback() receive()is payable if ETH must be acceptedfallback()is payable only if value-bearing unknown calls are allowed- Proxy forwarding preserves return data and reverts
- Empty calldata and non-empty calldata are both covered
- State changes match the intended entry point
Conclusion
Testing receive() and fallback() is not optional for contracts that handle ETH, proxies, or low-level interactions. These functions define how your contract behaves when callers do not follow the happy path, which is exactly where many production failures occur.
By writing explicit tests for calldata shape, value transfer, and revert behavior, you can validate dispatch logic with confidence and prevent subtle integration bugs before deployment.
