Crypto Training

Reentrancy Beyond the Basics: Cross-Function, Cross-Contract, and Read-Only

Reentrancy is not just 'send ETH then update state'. It’s any time external code can observe or mutate your state mid-invariant.

Crypto Training2026-01-236 min read

Reentrancy graph illustration

Reentrancy is taught as a single bug pattern, but in practice it’s a family of bugs.

The best way to think about it is not “did we update state before sending ETH?” but:

Did we cross a trust boundary while an invariant was temporarily false?

If yes, reentrancy is on the table.

The reentrancy matrix#

VariantWhat re-entersWhat breaksTypical trigger
Classicsame functionbalances / accountingETH send, token transfer
Cross-functiondifferent function in same contractshared state invariantfallback -> other entry point
Cross-contractanother contract re-enters youmulti-contract invarianthooks, callbacks
Read-onlyview path is called mid-transitionprice/snapshot assumptionsoracle reads, share price

The last two are the ones that surprise teams.

The invariant lens (the only one that scales)#

When you do:

  1. read state
  2. compute something meaningful
  3. call out
  4. continue

You are implicitly assuming that state did not change between steps 1 and 4.

Reentrancy violates that assumption.

Even worse: you can violate it without the attacker “calling back into the same function”. Any reachable entry point that touches shared state is enough.

Example 1: classic reentrancy (tutorial shape)#

SOLIDITY
function withdraw(uint256 shares) external {
    uint256 assets = shares * totalAssets() / totalShares;

    // External call before state is committed.
    token.transfer(msg.sender, assets);

    _burn(msg.sender, shares);
}

What matters is not “transfer”. What matters is “external call while the share invariant is in-flight.”

Example 2: cross-function reentrancy (realistic)#

You add a guard on withdraw(), but forget another entry point reaches the same internal transition.

SOLIDITY
function withdraw(uint256 shares) external nonReentrant {
  _withdraw(shares);
}

function emergencyExit() external {
  // no guard
  _withdraw(balanceOf[msg.sender]);
}

Now the attacker re-enters through emergencyExit().

This is why “add nonReentrant” is not a complete fix unless you reason about all entry points that touch shared state.

Example 3: tokens and hooks (transfer is a callback)#

In 2026, you cannot assume token transfers are pure.

A token transfer can:

  • call arbitrary code (hooks)
  • consume a lot of gas
  • revert to halt progress

So this is always suspicious:

  • transferring inside accounting
  • transferring before updating state

A safer pattern is to commit state before transfer, or to split into phases.

Example 4: read-only reentrancy (composition hazard)#

Read-only reentrancy looks harmless because no state write happens.

The exploit is about observing a temporary state and using it elsewhere.

A plausible shape:

  • your vault updates a cached value mid-function
  • it calls an external module
  • attacker calls a view function during the window
  • a downstream protocol reads that view and uses it as an oracle

If other systems integrate your view as a price feed, your internal temporary state becomes their external truth.

This is how “my contract didn’t lose funds” can still become “my contract caused a liquidation cascade elsewhere.”

A concrete toy example#

Vault:

SOLIDITY
// Sketch only.
contract Vault {
    uint256 public cachedPrice;
    IERC20 public token;

    function withdraw(uint256 shares) external {
        cachedPrice = _expensivePrice();
        token.transfer(msg.sender, _assetsFor(shares, cachedPrice));
        _burn(msg.sender, shares);
        cachedPrice = 0;
    }

    function price() external view returns (uint256) {
        // Downstream protocols might treat this as an oracle.
        return cachedPrice == 0 ? _spotPrice() : cachedPrice;
    }
}

Attacker:

  • trigger withdraw() so cachedPrice is temporarily set
  • reenter via a token hook and call price()
  • use that price in a second protocol that trusts the view

The exploit isn’t “Vault lost funds directly”. The exploit is “Vault exposed a temporary internal value as an oracle.”

Defensive designs that actually work#

Design A: commit state, then interact#

If the invariant is “shares correspond to assets”, commit share changes before transfers.

Design B: split into phases#

  • phase 1: compute + commit
  • phase 2: interact with external contracts

This is often easier than sprinkling nonReentrant everywhere.

Design C: isolate callbacks#

If you provide hooks (like afterDeposit), treat them as optional:

  • call them in a best-effort manner
  • ensure failure does not revert the core accounting

If a hook can revert and freeze deposits, your hook is a veto.

Audit workflow: how I find reentrancy#

I don’t start with “where is nonReentrant?”

I do this:

  1. List every external call.
  2. Mark which invariants are in-flight around it.
  3. List all reachable entry points during that window.
  4. Try to construct a cycle in the call graph.

If there’s a cycle while invariants are false, you have a reentrancy candidate.

Defenses: what they cost you#

Reentrancy defenses are tradeoffs. The goal is to choose the tradeoff that matches your protocol.

DefenseStrengthCost / failure mode
nonReentrant mutexsimple, effectivecan block composability; easy to forget on other entry points
commit-then-interactpreserves invariantsrequires careful ordering and design
pull paymentsavoids push-revert DoSmore state, more UX steps
two-phase state machinesbounded progressmore complexity; needs careful replay protection

In audits, I prefer “commit-then-interact” and pull patterns because they fix the underlying invariant problem rather than just blocking a class of paths.

A subtle anti-pattern: external calls inside pricing#

If your pricing function calls out (oracle, module, hook) and is used inside a state transition, you have combined:

  • reentrancy risk
  • revert/liveness risk
  • gas griefing risk

This is a common shape in modular systems (fee modules, hook-based DEX designs, vault strategies).

Audit move: separate price computation from state transition, or snapshot the needed values before external calls.

Testing reentrancy in Foundry (minimal harness)#

When a path is plausible, I prefer to lock it down with a reproduction.

Pattern:

  1. write an attacker contract that implements the callback/hook you fear
  2. call the victim entry point
  3. reenter a second entry point (or a view) during the callback
  4. assert invariant break

Even a small harness makes the call graph concrete and avoids “theoretical” findings.

A practical checklist#

These questions catch most reentrancy issues:

  • Is there any external call between “compute” and “commit”?
  • Are there multiple entry points that reach the same state transition?
  • Are token transfers treated as external calls (they are)?
  • Are view functions used as oracles by other protocols?

Exercises (fast and valuable)#

If you want to sharpen intuition, do these drills:

  • Build a toy vault and an attacker that re-enters through a different entry point.
  • Add a view function that exposes a temporary state mid-withdraw; use it as an oracle in a second contract.
  • Reorder state updates until the exploit stops.

You’ll stop thinking of reentrancy as “that one bug” and start treating it as a control-flow property.

Further reading#