Smart contracts and Solidity · Intermediate

Checks, effects, interactions and reentrancy

4 min read

Why it matters

Contract bugs are public, permanent and often worth real money. Reentrancy, where an external call lets another contract call back in before your state is updated, has caused some of the largest losses in the ecosystem. The defence is well known; what matters is applying it every time and proving it with tests.

The concept

When a function sends ether or calls another contract, that contract runs code and can call back into yours. If your function has not yet updated its state, the callback sees stale values.

  • Checks, effects, interactions: first validate inputs and permissions, then update your own state, and only then make external calls.

  • Reentrancy guard: a lock that makes the function revert if it is entered again before it finishes (OpenZeppelin's ReentrancyGuard in production code).

  • Pull over push: let users withdraw their own funds instead of sending to many addresses in a loop.

  • Solidity 0.8 checks arithmetic overflow by default; unchecked blocks turn that off, so use them only when you have proved the maths is safe.

Worked example: a savings contract

src/Savings.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

/// @title Per-user savings with the checks-effects-interactions pattern and a reentrancy lock.
contract Savings {
    mapping(address => uint256) public balanceOf;
    bool private locked;

    error InsufficientBalance();
    error Reentrancy();
    error TransferFailed();

    modifier nonReentrant() {
        if (locked) revert Reentrancy();
        locked = true;
        _;
        locked = false;
    }

    function deposit() external payable {
        balanceOf[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) external nonReentrant {
        // Checks
        if (balanceOf[msg.sender] < amount) revert InsufficientBalance();
        // Effects: update state BEFORE talking to any other contract
        balanceOf[msg.sender] -= amount;
        // Interactions
        (bool ok, ) = msg.sender.call{value: amount}("");
        if (!ok) revert TransferFailed();
    }
}

The test uses a receiver contract that tries to call withdraw again from inside its receive function, simulating a malicious caller, and checks that other savers' 5 ether is untouched:

test/Savings.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Test} from "forge-std/Test.sol";
import {Savings} from "../src/Savings.sol";

/// A receiver that tries to call withdraw again while the first withdrawal is still running.
contract ReenteringReceiver {
    Savings public immutable savings;
    uint256 public reentryAttempts;

    constructor(Savings s) {
        savings = s;
    }

    function depositAndWithdraw() external payable {
        savings.deposit{value: msg.value}();
        savings.withdraw(msg.value);
    }

    receive() external payable {
        if (reentryAttempts == 0) {
            reentryAttempts++;
            savings.withdraw(msg.value); // must not succeed
        }
    }
}

contract SavingsTest is Test {
    Savings savings;

    function setUp() public {
        savings = new Savings();
        // Other savers' money is in the contract.
        address asha = makeAddr("asha");
        vm.deal(asha, 5 ether);
        vm.prank(asha);
        savings.deposit{value: 5 ether}();
    }

    function test_DepositAndWithdraw() public {
        address ravi = makeAddr("ravi");
        vm.deal(ravi, 1 ether);
        vm.startPrank(ravi);
        savings.deposit{value: 1 ether}();
        savings.withdraw(1 ether);
        vm.stopPrank();
        assertEq(ravi.balance, 1 ether);
        assertEq(savings.balanceOf(ravi), 0);
    }

    function test_RevertWhen_WithdrawingMoreThanBalance() public {
        vm.expectRevert(Savings.InsufficientBalance.selector);
        savings.withdraw(1);
    }

    function test_ReentrantWithdrawCannotTakeOthersFunds() public {
        ReenteringReceiver receiver = new ReenteringReceiver(savings);
        vm.expectRevert(Savings.TransferFailed.selector);
        receiver.depositAndWithdraw{value: 1 ether}();
        // The whole transaction reverted: other savers' funds are untouched.
        assertEq(address(savings).balance, 5 ether);
    }
}
shellsession
$ forge test --match-contract SavingsTest
[PASS] test_DepositAndWithdraw() (gas: 75155)
[PASS] test_ReentrantWithdrawCannotTakeOthersFunds() (gas: 334233)
[PASS] test_RevertWhen_WithdrawingMoreThanBalance() (gas: 33426)
Suite result: ok. 3 passed; 0 failed; 0 skipped

The nested call is rejected, the outer transfer fails, and the whole transaction reverts, so the contract still holds the other savers' funds. To check that the test really protects you, we moved the balance update after the external call and removed the lock. The same test then failed:

shellsession
[FAIL: next call did not revert as expected] test_ReentrantWithdrawCannotTakeOthersFunds() (gas: 336301)

A security test that still passes when the protection is removed proves nothing. Checking that it fails without the fix is part of writing it.

Common mistakes

  • Updating state after an external call. Always effects before interactions.

  • Assuming only ether transfers are risky. Token callbacks (ERC-777, ERC-721 onERC721Received) and any call to an unknown contract can re-enter.

  • Missing access control on functions that change critical settings. Test every privileged function with an unprivileged caller.

  • Shipping without review. Use static analysers (Slither), fuzzing, and an independent audit before real value is at stake.

Interview angle

"What is reentrancy and how do you prevent it?" "Explain checks-effects-interactions." "How would you test that a protection works?" (write the attack as a test, and confirm the test fails without the protection). "What changed with overflow checks in Solidity 0.8?"

Practice task

  1. Replace the hand-written lock with OpenZeppelin's ReentrancyGuard and keep all tests passing.

  2. Run Slither on the contracts and explain each finding.

Further reading