3.11.2 Cross-Contract Composability

DeFi's defining property is that protocols compose. A lending market integrates with a DEX which integrates with an oracle which integrates with a stablecoin which is held in a vault built on top of the lending market. Any transaction can chain calls across all of them. This is the source of DeFi's power — protocols can be combined in ways their authors didn't anticipate, producing new functionality without permission. It is also the source of DeFi's most expensive class of bugs.

Section 3.10.3 (bZx) showed what happens when a protocol's design fails to account for a flash-loan-equipped composability adversary. Section 3.10.8 (Euler) showed the same pattern applied to a logic bug. Section 3.10.4 (Poly Network) showed it at the cross-contract architecture level inside a single bridge. In each case, the failure was not a contract bug in isolation — it was a failure to reason about the contract as part of a larger composition.

This subsection covers the design question: when your contract is one of many in a transaction, what assumptions hold and which ones fail? When you call into a contract you don't control, what risks do you accept? When external callers can compose your contract in ways you didn't anticipate, how do you defend? The bugs that emerge from composability are subtle, varied, and rarely caught by single-contract review. The defenses are about what your contract assumes — and the most important habit is making those assumptions explicit.

Two Directions of Composability

Composability runs in two directions, and each presents a different security problem.

Outbound composability: your contract calls another contract. You're trusting the callee to behave correctly. If the callee misbehaves, your contract is exposed.

Inbound composability: another contract calls your contract. The caller is trusting your contract to behave correctly. If your contract makes assumptions about the caller that the caller violates, your contract may misbehave in ways that harm itself or downstream users.

The bugs in both directions look similar from a distance but require different defenses. A protocol designer needs to think about both.

Outbound: Calling Untrusted Contracts

When your contract calls into a contract whose code you didn't write — an oracle, a token, a router, a callback handler — you've extended the trust boundary of your contract to include the callee. Anything the callee does happens within the context of your call, including:

  • Reverting (your transaction may be rolled back)
  • Consuming all forwarded gas (out-of-gas elsewhere)
  • Re-entering your contract before your call completes
  • Calling other contracts that re-enter you
  • Returning malformed data
  • Returning intentionally misleading data

The mitigations depend on the specific risk. The general principle: assume the callee is adversarial, then evaluate what damage they can cause.

Inbound: Being Called by Untrusted Contracts

When another contract calls your contract, your contract's assumptions about state, ordering, and intent are now in the hands of the caller. The caller can:

  • Call your functions in an order you didn't anticipate
  • Pass parameters at extreme values
  • Re-enter you in the middle of a different operation
  • Be a contract whose behavior is itself controlled by yet another adversary
  • Be a flash loan recipient executing inside the loan callback

The mitigations: defensive coding that does not depend on the caller's specific identity, type, or behavior. Section 3.7 covers many of these patterns; this section ties them together for the composability case.

The Modern Composability Threat Model

In 2026, a realistic threat model for any DeFi protocol assumes:

  1. The attacker has flash-loan-level capital. Tens of millions of dollars are available for a single transaction at near-zero cost. Section 3.11.4 covers this in depth.

  2. The attacker can compose your contract with any other contract on chain. They can deploy new contracts as helpers in seconds. They can call any public function in any state. They can sandwich your operations between manipulations of other protocols.

  3. The attacker can read all your state. "Storage layout privacy" does not exist. Internal variables, modifier guards, and timing-sensitive state are all visible to off-chain analysis.

  4. The attacker can simulate any transaction before executing it. They will not submit speculative attacks; they will simulate the exact attack until it works, then submit it. Probabilistic defenses ("the attacker probably won't realize they can do this") have no protection value.

  5. The attacker may have visibility into pending transactions. Even on chains with private mempools, MEV searchers see substantial pre-execution information. Front-running and back-running attacks against your protocol should be assumed possible.

Designing under this threat model produces different code than designing under "honest users." The differences are mostly in what assumptions your code makes. The bugs in Section 3.10 emerge largely from designing under weaker threat models than the actual one.

Patterns for Outbound Composability

Treat External Calls as Boundaries

Any line that calls an external contract is a boundary where control leaves your code. The boundaries are special:

  • All state updates that should happen before the call must be complete before the call
  • All state updates that should happen after the call must be valid if the call reverts and is retried later
  • Any value passed to the call must be valid even if the callee misuses it
  • Any value returned from the call must be sanity-checked before use
// Bad: state update after external call (reentrancy hazard)
function withdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount);
    payable(msg.sender).transfer(amount);  // external call
    balances[msg.sender] -= amount;        // state update AFTER call
}

// Good: state update before external call (CEI compliance)
function withdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount);
    balances[msg.sender] -= amount;        // state update BEFORE call
    payable(msg.sender).transfer(amount);  // external call
}

This is the Checks-Effects-Interactions pattern from Section 3.7.1. Composability adds an additional dimension: even if you've followed CEI, the callee can call back into other functions on your contract that depend on the state you haven't yet updated. This is read-only reentrancy and cross-function reentrancy (Section 3.8.2). The defense is reentrancy guards on all related functions, not just the one making the external call.

Validate Return Data

External calls in Solidity can return data that the calling contract must parse. The parsing is a place where assumptions break:

// Bad: assumes the returned bool means what you think it means
(bool ok, bytes memory data) = token.call(
    abi.encodeWithSelector(IERC20.transfer.selector, recipient, amount)
);
require(ok, "transfer failed");

// Better: handle the multiple cases ERC-20 implementations can produce
(bool ok, bytes memory data) = token.call(
    abi.encodeWithSelector(IERC20.transfer.selector, recipient, amount)
);
require(ok, "transfer call failed");
if (data.length > 0) {
    require(abi.decode(data, (bool)), "transfer returned false");
}

The standard library SafeERC20 from OpenZeppelin implements this correctly:

import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

contract Vault {
    using SafeERC20 for IERC20;

    function deposit(IERC20 token, uint256 amount) external {
        token.safeTransferFrom(msg.sender, address(this), amount);
        // SafeERC20 handles tokens that return false, return nothing, or revert
    }
}

SafeERC20 exists because the ERC-20 standard says transfer should return a bool but real-world tokens variously return bool, return nothing, or revert on failure. A contract that assumes one specific behavior breaks against tokens with another. SafeERC20 normalizes all three cases. This pattern — a library that adapts to multiple real-world implementations of a standard — recurs throughout Solidity. Use the standard libraries instead of writing your own interpretation of what the spec says.

Be Defensive About Gas

External calls can consume all the gas they're given. The default Solidity call forwards all remaining gas; this allows the callee to do arbitrarily much work, including blocking the caller's subsequent operations.

// Risky: callee can consume all gas
(bool ok, ) = recipient.call{value: amount}("");
require(ok);

// Defensive: bound the gas if the callee shouldn't need much
(bool ok, ) = recipient.call{value: amount, gas: 50_000}("");
require(ok);

The right gas budget depends on what the callee is supposed to do. For a simple ETH transfer to an EOA, 2,300 gas is the historical default (the stipend that transfer() and send() provide). For a call to a contract that might need to update its own state, 50,000-100,000 gas is more reasonable. For arbitrary callback handlers, no gas limit may be appropriate.

After EIP-2929 (Berlin upgrade, 2021) and EIP-3529 (London, 2021), the costs of opcodes changed enough that the 2,300-gas stipend is no longer reliably sufficient for many recipients. Section 3.7.7 covers this anti-pattern. The modern guidance: use call instead of transfer/send, and either forward all gas (accepting the gas-griefing risk) or set an explicit gas budget appropriate to the callee.

Avoid Sequential Untrusted Calls

A function that makes multiple external calls in sequence introduces multiple boundaries. Each one is a place where a callee can re-enter, where gas can be consumed unpredictably, and where state can shift between calls.

// Risky: state may shift between calls
function complexOp(IERC20 tokenA, IERC20 tokenB) external {
    uint256 priceA = oracleA.getPrice();  // call 1 (may revert/reenter)
    uint256 priceB = oracleB.getPrice();  // call 2 (may revert/reenter)
    uint256 amountIn = tokenA.balanceOf(address(this));  // call 3 (returns may change)

    // ... computations based on values that may already be stale
}

Where possible, batch external calls into a single multicall, or read all required state once and trust the snapshot. Where not possible, recognize that each external call is a chance for the world to change underneath you, and design accordingly.

Patterns for Inbound Composability

Don't Assume Caller Identity

A common mistake: assuming msg.sender is a particular kind of caller. The mistake takes several forms:

// Bad: assumes msg.sender is an EOA (a wallet user)
function userOnlyFunction() external {
    require(msg.sender == tx.origin, "no contracts");
}

The tx.origin == msg.sender check tries to prevent contracts from calling the function. This was a common pattern circa 2018; it has several problems:

  • It breaks composability — contracts can't compose this protocol into multi-step transactions
  • It breaks account abstraction (EIP-4337 wallets are contracts, not EOAs)
  • It can be circumvented in some edge cases via flash loans

Modern guidance: do not check tx.origin. If you need to gate operations, gate them on permissions (AccessControl, signature checks, etc.), not on caller type. Section 3.11.7 covers account abstraction in depth.

Don't Assume Caller Trustworthiness Based on Code

A related pattern: assuming a contract caller is safe because "it's the official router" or "it has the right interface":

// Bad: trusts any contract that returns the expected interface ID
function deposit(IERC4626 vault, uint256 amount) external {
    require(vault.asset() == address(asset), "wrong asset");
    asset.transferFrom(msg.sender, address(vault), amount);
    // ... but `vault` might be a malicious contract pretending to be ERC4626
}

// Better: trust only explicitly approved contracts
mapping(address => bool) public approvedVaults;

function deposit(IERC4626 vault, uint256 amount) external {
    require(approvedVaults[address(vault)], "vault not approved");
    asset.transferFrom(msg.sender, address(vault), amount);
}

Section 3.10.7 (Wormhole) is the canonical case: a contract trusted a user-supplied account address as if it were the legitimate system entity. A contract should only trust other contracts whose addresses are explicitly set during deployment or governance, not contracts whose addresses are supplied at call time by untrusted callers.

Defend Against State-Change Sandwiches

When your contract is one step in a multi-step composition, the caller may have manipulated state before calling you, and may manipulate it again after. This is the bZx pattern (Section 3.10.3) generalized: the attacker compounds your protocol's operations with manipulations of related state.

Defenses:

1. Read-only checks against expected state. If your function depends on an external state (oracle price, AMM reserves, token total supply), the value at the time of the call may not reflect "the market." Use TWAPs (Section 3.11.1), require multi-block confirmation, or read aggregate state instead of spot state.

2. Per-block rate limits on state changes. Limit how much your contract's state can change in a single block. If the function would cause a 50% reserve change in one block, revert and require the user to spread the operation across blocks. This prevents single-block manipulation chains.

3. Time-weighted state. Where possible, derive decisions from time-weighted state rather than instantaneous state. This raises the cost of manipulation linearly with the time window.

Don't Leak Predictable Information

In some cases your contract emits information (events, return values, observable state changes) that subsequent attackers can use. The most common case: predictable randomness derived from block data.

// Bad: predictable randomness; an attacker can pre-compute the outcome
function rollDice() external returns (uint256) {
    uint256 roll = uint256(keccak256(abi.encodePacked(
        block.timestamp, block.prevrandao, msg.sender
    ))) % 6;
    if (roll == 5) {
        payable(msg.sender).transfer(prize);
    }
    return roll;
}

The attacker can compute the same hash off-chain, predict the result, and only submit the transaction when the result is favorable. Section 3.8.7 (front-running) and Section 3.11.3 (MEV) cover this in more depth. The defense: do not derive randomness from on-chain data alone. Use a commit-reveal scheme, an external randomness oracle (Chainlink VRF, drand), or design the game so that the outcome doesn't matter to a specific party.

Test Adversarial Compositions

The single best defensive practice for inbound composability is testing with adversarial counterparts. The Foundry pattern from Section 3.8.2:

contract Attacker {
    Target public target;
    bool public reentering;

    function attack() external {
        reentering = true;
        target.someFunction();
    }

    fallback() external payable {
        // Re-enter the target while we have control
        if (reentering) {
            target.someFunction();
        }
    }
}

function test_targetSurvivesReentrancy() public {
    Attacker attacker = new Attacker(target);
    vm.expectRevert();
    attacker.attack();
}

This kind of test is rare in many codebases and is one of the highest-value tests a protocol can write. The discipline: for every external-call-making function, write at least one test where the recipient is an adversarial contract that misbehaves in some specific way.

Composability with Specific Patterns

Token Standards

ERC-20 is the most-composed-with standard. Several known gotchas:

Tokens that don't return bool: some old tokens (USDT historically, others) don't return a value from transfer. Use SafeERC20.

Tokens with transfer fees: some tokens deduct a fee on transfer, so balanceOf(recipient) after transfer(amount) is less than amount. If your contract assumes the full amount arrived, accounting breaks. Check actual received amounts: uint256 received = token.balanceOf(address(this)) - balanceBefore;.

Tokens with rebasing: some tokens (stETH, AMPL) change balanceOf over time without transfers. If your contract tracks balances internally and relies on balanceOf to match, drift occurs.

Tokens with callbacks: ERC-777 tokens can call into the recipient and sender on transfer. This is reentrancy by design. Use the same defenses as for any reentrant call.

Tokens with non-standard decimals: USDC has 6 decimals; ETH has 18; some have 8. Always read decimals() rather than hardcoding.

Tokens with allowance reset requirements: some tokens (USDT historically) revert if you call approve(spender, newAmount) when the existing allowance is nonzero. Use forceApprove from SafeERC20 or set allowance to 0 first.

ERC-721 and ERC-1155 have their own composability gotchas, particularly around the onERC721Received / onERC1155Received callback. These callbacks can re-enter your contract before the transfer is "complete" from your perspective.

Callback Patterns

Many protocols are designed around callbacks: Uniswap V3's swap callback, Aave's flash loan callback, ERC-3156 flash loans, etc. The pattern:

  1. Caller calls into the protocol's function
  2. Protocol transfers tokens to caller / does work
  3. Protocol calls a specified callback on the caller
  4. Inside the callback, the caller does work
  5. Control returns to the protocol; protocol validates final state

Callbacks are reentrancy by design. The protocol must:

  • Treat the callback as an external untrusted call
  • Validate the caller's final-state contributions strictly
  • Not allow the caller to escape the validation

The classic flash loan pattern:

function flashLoan(address recipient, uint256 amount) external {
    uint256 balanceBefore = asset.balanceOf(address(this));

    asset.transfer(recipient, amount);
    IFlashLoanReceiver(recipient).onFlashLoan(amount);

    uint256 balanceAfter = asset.balanceOf(address(this));
    require(balanceAfter >= balanceBefore + fee, "loan not repaid");
}

The validation is at the end. The receiver can do anything in the callback — re-enter the protocol, swap, manipulate prices — but if the loan isn't repaid, the call reverts. Section 3.11.4 covers flash loans in more depth.

Multi-Step Operations

For protocols that involve multi-step operations across blocks (auctions, time-locked operations, cross-chain bridges), the composability surface is temporal. The attacker doesn't compose across contracts within a transaction; they compose across blocks.

The defense: explicit state machines with constrained transitions.

enum AuctionState { Created, Bidding, Settling, Completed, Cancelled }

contract Auction {
    AuctionState public state;

    modifier inState(AuctionState expected) {
        require(state == expected, "wrong state");
        _;
    }

    function bid(uint256 amount) external inState(AuctionState.Bidding) {
        // ... only allowed when state is Bidding
    }

    function settle() external inState(AuctionState.Settling) {
        // ... only allowed when state is Settling
    }

    function cancel() external {
        require(state != AuctionState.Completed, "cannot cancel after completion");
        state = AuctionState.Cancelled;
    }
}

Each transition is explicit. The contract cannot be in an unexpected state. This is much harder to compose adversarially than a free-form contract.

Composability Anti-Patterns

Specific patterns that look reasonable but create composability vulnerabilities:

1. "Just call back to msg.sender to check approval." If your contract's permission check calls back into the caller, you've created a reentrancy hazard and given the caller full control over your authorization.

2. "Hash the entire calldata to track replays." Calldata can be padded or reformatted to produce different hashes for semantically-identical calls. Track replay using semantic identifiers (nonces, message hashes derived from canonical encodings), not raw calldata.

3. "Use deterministic addresses (CREATE2) and trust them." An attacker can deploy a different contract at a CREATE2 address than you expect if they control the salt or the init code. CREATE2 addresses are not implicitly trustworthy; the trust must come from the address being baked in at deployment.

4. "Assume the price won't move between two reads in the same transaction." Even within one transaction, an attacker can move prices between your two reads (via callbacks, reentrancy, or interleaved external calls). Read prices once and use the snapshot.

5. "Assume gas will be available for cleanup." A caller can pass just enough gas for the main operation and force out-of-gas in cleanup logic. If cleanup is essential, do it first, not last.

6. "Trust any contract that emits the right events." Events are not capabilities. Anyone can emit any event. Permissions must be enforced by storage/state, not by emitted events.

Practical Checklist

For a protocol designing for safe composability:

  • Every function that makes external calls follows CEI ordering
  • Every function that makes external calls is reentrancy-guarded (where state could be corrupted by re-entry)
  • Related functions (those reading state the external-call function modifies) are also reentrancy-guarded
  • All ERC-20 interactions use SafeERC20 (or equivalent)
  • Token integrations have been tested with fee-on-transfer, rebasing, ERC-777, and non-standard-decimal tokens (where relevant)
  • No tx.origin == msg.sender checks (compatible with account abstraction)
  • All trusted external contracts (oracles, routers, etc.) are stored as immutable or governance-set, not user-supplied
  • Gas budgets for external calls are explicit where the recipient is untrusted
  • State changes that depend on external state (prices, balances) read the external state once per transaction, not multiple times
  • State machine transitions are explicit; functions revert when called from wrong state
  • Tests cover adversarial counterpart contracts (revert, consume gas, reenter, return bad data)
  • Tests cover composition with flash loan recipients
  • No predictable randomness from block data
  • Replay protection uses canonical message hashes, not raw calldata

Cross-References

  • Reentrancy — Section 3.8.2 covers the full reentrancy family, the most common composability bug
  • Access control failures — Section 3.8.4 covers the trust-user-supplied-references pattern that Wormhole exhibited
  • Anti-patterns — Section 3.7.7 covers tx.origin and other deprecated compatibility patterns
  • Defensive patterns — Section 3.7.5 covers reentrancy guards, rate limits, and pause mechanisms
  • Oracles — Section 3.11.1 covers oracle composability specifically
  • Flash loans — Section 3.11.4 covers flash loans as the dominant composability stress test
  • MEV — Section 3.11.3 covers ordering-based attacks that compose with state-change sandwiches
  • Account abstraction — Section 3.11.7 covers EIP-4337 and why tx.origin should be avoided
  • L2 considerations — Section 3.11.8 covers cross-chain composability and its additional failure modes