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 sent
  • fallback() 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:

ScenarioExpected behaviorWhy it matters
Empty calldata + ETHreceive() executesStandard ETH transfer path
Unknown selector + no ETHfallback() executesHandles typos or proxy routing
Unknown selector + ETHfallback() executes if payableCommon in forwarding contracts
Known function callNeither special function executesConfirms normal dispatch
Non-payable fallback/receiveCall reverts on ETHPrevents 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:

  1. Empty calldata + ETH → receive() if present and payable
  2. Non-empty calldata + unknown selector → fallback()
  3. Empty calldata + no receive()fallback() if payable, otherwise revert

Dispatch matrix

CalldataETH valueFunction chosen
""0receive() if present, otherwise fallback() if present
""> 0receive() if present and payable
unknown selector0fallback()
unknown selector> 0fallback() 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 accepted
  • fallback() 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.

Learn more with useful resources