Solidity Contract-to-Contract Interactions Guide

A practical guide to Solidity contract-to-contract interactions. Learn how contracts call each other, handle errors, and manage security. Includes code patterns, pitfalls, and best practices.

🕐 6 min read

TL;DR: Secure Contract-to-Contract Interactions in Solidity

  • Smart contracts rarely operate alone: secure, reliable contract-to-contract calls are the backbone of composable dApps.
  • Direct calls are safest for known interfaces; low-level calls (call, delegatecall, staticcall) offer flexibility but require extra caution.
  • ERC20 approve/transferFrom is the standard for delegated token transfers between contracts. Check this guide for more information: ERC20 Approve/TransferFrom Pattern: How Token Allowances Work in Solidity
  • Security best practices: Always check return values, use the Checks-Effects-Interactions (CEI) pattern, and protect against reentrancy with guards like OpenZeppelin's ReentrancyGuard.
  • Treat all external contracts as untrusted: defensive coding is essential.

1. Why Contract-to-Contract Interactions Matter

Solidity smart contracts are the building blocks of Ethereum dApps. But the real power of Web3 comes from how these contracts interact, enabling everything from DeFi protocols to NFT marketplaces and token vendors. Understanding secure contract-to-contract communication is essential for any developer aiming to build robust, composable, and secure decentralized applications.

Smart contracts are composable, but every contract you interact with is a potential risk.

  • When your contract calls another, you trust its code to behave as expected. If it's buggy or malicious, your contract (and users) can be at risk.
  • Always treat external contracts (including standard tokens) as untrusted unless you've audited them yourself.

2. Core Patterns for Contract Interactions in Solidity

Direct calls are the most common and safest way for one contract to interact with another when the interface is known at compile time.

Example:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface ICounter {
    function increment() external;
    function getCount() external view returns (uint256);
}

contract Caller {
    ICounter public counter;
    constructor(address counterAddress) {
        counter = ICounter(counterAddress);
    }
    function callIncrement() external {
        counter.increment();
    }
    function readCount() external view returns (uint256) {
        return counter.getCount();
    }
}
  • Pros: Easy to read, automatic error propagation, type safety.
  • Cons: Requires interface at compile time.

2.2 Low-Level Calls: call, delegatecall, staticcall

Low-level calls are powerful but riskier. Use them only when you need dynamic interactions or proxy patterns.

  • call: Generic external call, can send Ether, must check return value.
  • delegatecall: Runs code from another contract in the caller's context (used for upgradeable proxies).
  • staticcall: Like call, but enforces read-only (view/pure) execution.

Example:

(bool success, bytes memory data) = targetAddress.call(abi.encodeWithSignature("doSomething(uint256)", 123));
require(success, "External call failed");

Security Tip: Always check the success flag when using low-level calls. Never assume the call succeeded!

Quick Comparison Table: Low-Level Calls

Featurecall()delegatecall()staticcall()
Execution ContextCalleeCaller (callee code)Callee
msg.senderCaller contractOriginal senderCaller contract
Storage AccessCalleeCallerCallee (read-only)
State ModificationAllowedAllowed (in caller)Disallowed
Use CaseGeneric callsProxies, upgradesSafe data reads
Key RiskReentrancy, unchecked errorsStorage collision, unchecked errorsUnchecked errors

Storage collision warning: If you use delegatecall for upgradeable contracts, your storage layout must match exactly between proxy and logic contracts, or you risk corrupting your contract's state.


3. ERC20 Token Interactions: The Approve/TransferFrom Pattern

Most dApps need to move tokens between users and contracts. The ERC20 approve/transferFrom workflow is the standard for delegated transfers.

How it works:

  1. User calls approve(spender, amount) on the token contract, allowing the contract (e.g., a vendor) to spend tokens on their behalf.
  2. Contract calls transferFrom(user, contract, amount) to pull tokens from the user.

Example:

IERC20 public paymentToken;

function buy(uint256 amount) external {
    // User must have approved this contract for at least `amount`
    bool success = paymentToken.transferFrom(msg.sender, address(this), amount);
    require(success, "Token transfer failed");
    // ... business logic ...
}
  • Always check the return value of transferFrom and transfer.
  • Never assume all ERC20s behave identically. Defensive coding is a must.

3.1 Infinite Approvals & ERC20 Trust

  • Infinite approvals (users approving max uint256) are common for convenience, but dangerous if the contract is ever compromised.
  • Not all ERC20s behave identically. Some have non-standard logic or bugs. Always check return values and code defensively.

We'll cover this pattern in more detail in the ERC20 Approve Pattern guide.


4. Security Best Practices for Contract Interactions

4.1 Checks-Effects-Interactions (CEI) Pattern

Update your contract's state before making any external calls. This prevents reentrancy attacks.

Example:

function withdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount, "Insufficient balance");
    balances[msg.sender] -= amount; // Effect
    (bool sent, ) = msg.sender.call{value: amount}(""); // Interaction
    require(sent, "Withdraw failed");
}

4.2 Use Reentrancy Guards

Add OpenZeppelin's ReentrancyGuard and the nonReentrant modifier to functions that make external calls and change state.

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract SafeBank is ReentrancyGuard {
    mapping(address => uint256) public balances;
    function withdraw(uint256 amount) external nonReentrant {
        require(balances[msg.sender] >= amount, "Insufficient");
        balances[msg.sender] -= amount;
        (bool sent, ) = msg.sender.call{value: amount}("");
        require(sent, "Withdraw failed");
    }
}

4.3 Treat All External Contracts as Untrusted

  • Never assume another contract is safe, even if it's a standard like ERC20.
  • Always check return values and handle errors gracefully.
  • Avoid state changes after external calls.

4.4 Types of Reentrancy (Know Your Enemy)

  • Single-function reentrancy: Attacker re-enters the same function.
  • Cross-function reentrancy: Attacker re-enters a different function in the same contract.
  • Cross-contract reentrancy: Attacker re-enters via another contract.

Always use the Checks-Effects-Interactions (CEI) pattern and a reentrancy guard (like OpenZeppelin's) for any function that makes external calls and changes state.

4.5 Error Handling & Gas Management

  • Low-level calls (call, delegatecall, staticcall) do NOT revert on failure. Always check the success flag.
  • When using low-level calls, decode return data carefully with abi.decode.
  • Be aware of gas usage: external calls can consume unpredictable gas, especially with non-standard tokens.

5. Practical Example: Secure Token Vendor Contract

A token vendor is a classic example of contract-to-contract interaction. It must securely accept payment tokens and dispense another token.

Key features:

  • Uses approve/transferFrom for payments
  • Dispenses tokens only after successful payment
  • Follows CEI and uses ReentrancyGuard

Example:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract TokenVendor is ReentrancyGuard {
    IERC20 public immutable paymentToken;
    IERC20 public immutable vendedToken;
    uint256 public price;
    address public owner;

    event TokensPurchased(address indexed buyer, uint256 amount, uint256 cost);

    constructor(address _paymentToken, address _vendedToken, uint256 _price) {
        paymentToken = IERC20(_paymentToken);
        vendedToken = IERC20(_vendedToken);
        price = _price;
        owner = msg.sender;
    }

    function buyTokens(uint256 amount) external nonReentrant {
        require(amount > 0, "Amount must be positive");
        uint256 cost = amount * price;
        require(vendedToken.balanceOf(address(this)) >= amount, "Not enough tokens");
        bool paid = paymentToken.transferFrom(msg.sender, address(this), cost);
        require(paid, "Payment failed");
        bool sent = vendedToken.transfer(msg.sender, amount);
        require(sent, "Token transfer failed");
        emit TokensPurchased(msg.sender, amount, cost);
    }
}

6. Build Secure, Composable dApps

Mastering contract-to-contract interactions is essential for any Solidity developer. Use direct calls for known interfaces, low-level calls with caution, and always follow security best practices. Defensive coding, thorough testing, and regular audits are your best tools for building secure, reliable, and composable smart contracts.

6.1 Testing, Audits & Continuous Security

Security is never "done."

  • Always write thorough tests, including edge cases and attack scenarios.
  • Get independent audits for any contract that will hold real value.
  • Stay up-to-date with new vulnerabilities and best practices.

Ready to put these patterns into practice? Try the Token Vendor Challenge on Speedrun Ethereum!

Frequently Asked Questions

How does one smart contract call another in Solidity?

When you know the interface at compile time, use a direct call: declare an interface for the target, cast its address to that interface, and call the function. You get type safety and errors propagate automatically. Low-level calls exist for cases where the interface is not known ahead of time, but they trade that safety away.

When should I use call, delegatecall or staticcall?

call is a generic external call that can send Ether and executes in the callee's context. delegatecall runs the callee's code in the caller's context using the caller's storage, which is how upgradeable proxies work and also why the storage layout of proxy and logic contracts must match exactly. staticcall behaves like call but enforces read-only execution, making it the safe choice for reading data. All three are riskier than a direct interface call.

Why did my low-level call fail without reverting?

Low-level calls do not revert on failure. They return a success boolean, and if you ignore it execution continues as though the call worked. Always check the success flag and require on it, and decode any return data carefully with abi.decode.

How do I stop a contract-to-contract call from being reentered?

Use the Checks-Effects-Interactions pattern: validate inputs, update your own state, and only then make the external call. Add OpenZeppelin's ReentrancyGuard with the nonReentrant modifier to any function that both makes an external call and changes state. Reentrancy comes in three shapes worth knowing: single-function, cross-function within the same contract, and cross-contract.

What is the safest way for my contract to pull ERC20 tokens from a user?

Have the user approve your contract first, then call transferFrom and check the return value. Never assume a transfer succeeded. Not all ERC20 implementations behave identically, and some return false rather than reverting. Treat every external contract as untrusted, including standard tokens, unless you have audited it yourself.