ChainScore Labs
All Guides

Cross-Contract Trust Boundaries and Composability Risk

LABS

Cross-Contract Trust Boundaries and Composability Risk

Chainscore © 2025

Core Concepts of Contract Interaction

Understanding the fundamental principles governing how smart contracts communicate and share state is critical for assessing security in a composable ecosystem.

External Calls & Fallback Functions

External calls transfer execution control to another contract's code. The receiving contract's fallback or receive function can execute arbitrary logic, potentially altering the expected flow. This creates a reentrancy vector if state changes occur after the call. Always follow the checks-effects-interactions pattern to mitigate this critical risk.

State Mutability & View/Pure Functions

State mutability declarations (view, pure) specify if a function reads or modifies contract storage. Relying on a view function's return value for critical logic is risky, as composable contracts may be manipulated to return incorrect data. These functions do not prevent state changes in called contracts, creating trust assumptions.

Delegatecall & Proxy Patterns

Delegatecall executes code from a logic contract within the context of the calling contract, sharing its storage. This is foundational for upgradeable proxies but introduces severe risks: a compromised logic contract can arbitrarily overwrite the proxy's storage. Users must trust both the proxy admin and the current implementation address.

Token Standards & Approval Scams

ERC-20 approve and setApprovalForAll functions grant spending allowances to other addresses. Malicious contracts can exploit these allowances indefinitely if granted. A common attack involves tricking users into approving a malicious contract disguised as a dApp, leading to asset theft. Always revoke unused allowances.

Cross-Contract State Dependencies

Contracts often depend on external state, like oracle prices or LP pool reserves. Dependencies create risk when the external state can change between transaction steps (e.g., in a multi-call). This can lead to MEV exploits, failed transactions, or incorrect settlement values due to front-running or stale data.

Gas Limits & Unbounded Operations

Gas limits per block constrain execution. Operations that loop over externally influenced data (e.g., user arrays) risk running out of gas if the data grows. A contract that fails mid-operation due to gas can leave the system in an inconsistent state, potentially freezing funds or breaking composability.

Analyzing a Contract's Trust Surface

A systematic process to identify and evaluate all external dependencies and trust assumptions of a smart contract.

1

Map External Dependencies

Identify all contracts, oracles, and EOAs the target contract interacts with.

Detailed Instructions

Begin by analyzing the contract's external call graph. Use a tool like Slither to generate a call graph or manually inspect the code for call, delegatecall, staticcall, and external function calls.

  • Sub-step 1: List all address state variables and constructor arguments. These are primary dependency injection points.
  • Sub-step 2: Trace all function arguments of type address and IERC20 to see where user-supplied addresses are used.
  • Sub-step 3: Identify hardcoded addresses, especially for oracles (e.g., Chainlink's 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419) or governance contracts.
solidity
// Example of a dependency mapping in a constructor constructor(address _token, address _oracle, address _governance) { token = IERC20(_token); // External token dependency oracle = IOracle(_oracle); // Oracle dependency governance = _governance; // Governance EOA/multisig }

Tip: Pay special attention to upgradeable proxies, as their implementation address is a critical, mutable dependency.

2

Categorize Trust Assumptions

Classify each dependency by the type of trust required and potential failure modes.

Detailed Instructions

For each dependency mapped, determine the trust model. Is it trust-minimized (e.g., a battle-tested DEX pool) or trust-maximized (e.g., an admin multisig)?

  • Sub-step 1: Code Trust: Does the contract trust the correctness of the external code? Audit status and complexity matter here.
  • Sub-step 2: Value Trust: Does the contract trust the integrity of data from the source? This is critical for oracles and price feeds.
  • Sub-step 3: Motivational Trust: Does the contract trust the incentives of an external actor? This applies to governance and keepers.

Consider failure modes: Can the dependency be rug-pulled (transferOwnership), have a logic bug, return stale data, or be censored?

Tip: A dependency on a decentralized, immutable, and widely used contract (like Uniswap V2 Factory) presents lower risk than a dependency on a new, upgradeable contract controlled by a small team.

3

Analyze Control Flows and Privileges

Examine how the contract's control flow changes based on external inputs and privileged roles.

Detailed Instructions

Identify all privileged functions (e.g., onlyOwner, onlyGovernance) and trace what external addresses they can affect. Then, analyze how user flows depend on external calls.

  • Sub-step 1: List all require statements checking msg.sender against an admin address. What systems can they modify?
  • Sub-step 2: For key user actions (e.g., deposit, swap), trace the path. Does a successful transaction require a callback to an untrusted contract? This introduces reentrancy risk.
  • Sub-step 3: Check for cross-function state dependencies. Can an admin change a fee parameter or pause the contract during a user's multi-step operation?
solidity
// Example of a privileged function altering a critical dependency function setNewOracle(address _newOracle) external onlyOwner { oracle = IOracle(_newOracle); // Owner can unilaterally change the data source }

Tip: Use a symbolic execution tool like Mythril to find unexpected stateful control flows that depend on external contract return values.

4

Evaluate Economic and Incentive Alignment

Assess the financial incentives of external actors and the contract's economic security.

Detailed Instructions

Determine if the system's economic security relies on the continued proper behavior of an external entity. Calculate the value at risk.

  • Sub-step 1: For oracles, check the deviation threshold and heartbeat. What is the maximum value that could be lost if the oracle is manipulated or fails? Use oracle.latestAnswer() to see precision.
  • Sub-step 2: For governance dependencies, analyze the proposal and timelock duration. Could a malicious proposal be executed before the community reacts?
  • Sub-step 3: For external liquidity pools (e.g., as collateral), assess the pool's depth and concentration. A sudden drop in liquidity due to a dependency's failure could cause insolvency.

Quantify the Maximum Extractable Value (MEV) or profit an attacker could gain by manipulating a dependency, compared to the cost of attack (e.g., oracle manipulation cost).

Tip: A contract holding $100M in TVL with a dependency on a single oracle secured by only $10M in stake is inherently risky.

5

Document and Score Risks

Create a formal risk matrix and mitigation plan for the identified trust surfaces.

Detailed Instructions

Synthesize findings into a risk register. For each trust surface, document its category, potential impact, likelihood, and any existing mitigations.

  • Sub-step 1: Assign a Impact Score (e.g., Low, Medium, High, Critical) based on potential fund loss or system failure.
  • Sub-step 2: Assign a Likelihood Score based on the dependency's attack surface and historical incidents.
  • Sub-step 3: Propose Mitigation Strategies. Can a trust-minimized alternative be used? Can circuit breakers (pause()), slippage limits, or multi-oracle designs be implemented?

Create a table in your report:

DependencyType (Code/Value/Motivational)ImpactLikelihoodMitigation
Oracle XValue TrustHighMediumAdd a 2nd oracle with medianizer

Tip: This documented analysis is crucial for audit reports, security postures, and informing users of the system's trust assumptions.

Common Composability Vulnerability Patterns

Comparison of vulnerability types, their root causes, and typical impacts in composable DeFi systems.

Vulnerability PatternPrimary CauseCommon ImpactExample Context

Reentrancy

Callback to untrusted contract before state update

Fund theft, state corruption

ERC-777 tokens, custom fallback functions

Read-Only Reentrancy

State inconsistency during external view calls

Oracle manipulation, incorrect pricing

Price oracle queries during flash loan execution

Improper Access Control

Missing or flawed permission checks on critical functions

Unauthorized fund withdrawal, parameter changes

Upgradeable proxy admin functions, owner-only minting

Unchecked Return Values

Assuming external low-level calls succeed without verification

Silent failure, loss of funds

send()/transfer() to contracts, delegatecall operations

Front-Running

Transaction ordering exploit via public mempool

Value extraction, transaction failure

DEX arbitrage, NFT minting with revealed metadata

Price Oracle Manipulation

Dependence on easily influenced price sources

Under-collateralized borrowing, protocol insolvency

Single DEX pool as oracle, TWAP with low liquidity

Flash Loan Price Attack

Temporary capital influx to distort on-chain metrics

Liquidation cascades, artificial arbitrage

Manipulating collateral ratios or DEX pool balances

Implementing Secure External Calls

Process for managing trust boundaries and mitigating composability risks when interacting with external contracts.

1

Define and Validate the Trust Boundary

Identify the external contract interface and establish validation checks before any interaction.

Detailed Instructions

First, explicitly define the trust boundary between your contract and the external dependency. This involves declaring the interface for the external function you intend to call. Before making the call, perform validation on the target address and the expected state.

  • Sub-step 1: Declare the interface using interface IExternalContract { function transfer(address to, uint amount) external; }.
  • Sub-step 2: Validate the target address is a contract using extcodesize(target) > 0 or OpenZeppelin's Address.isContract().
  • Sub-step 3: Check that the contract is not in a paused or compromised state, potentially by calling a known paused() view function if available.
solidity
interface IVault { function deposit(uint256 amount) external returns (bool); } function safeDeposit(address vaultAddress, uint256 amount) public { require(Address.isContract(vaultAddress), "Invalid contract address"); IVault vault = IVault(vaultAddress); // Additional state checks would go here vault.deposit(amount); }

Tip: Use established, audited interfaces from project repositories or Etherscan's contract verification tab to ensure accuracy.

2

Implement Checks-Effects-Interactions Pattern

Structure your function to perform all state changes before the external call to prevent reentrancy.

Detailed Instructions

Adhere strictly to the Checks-Effects-Interactions pattern. This is a primary defense against reentrancy attacks. Complete all validation (checks) and update your contract's internal state (effects) before making the external call (interactions).

  • Sub-step 1: Perform all condition checks, such as balance requirements or access control using require() statements.
  • Sub-step 2: Update all relevant state variables. For example, deduct a user's internal balance before sending tokens out.
  • Sub-step 3: Only after steps 1 and 2 are complete, make the low-level call or interface call to the external contract.
solidity
mapping(address => uint256) public balances; function withdraw(uint256 amount) public nonReentrant { // CHECK require(balances[msg.sender] >= amount, "Insufficient balance"); // EFFECTS balances[msg.sender] -= amount; // INTERACTION (bool success, ) = msg.sender.call{value: amount}(""); require(success, "Transfer failed"); }

Tip: The OpenZeppelin ReentrancyGuard modifier provides a robust safety net, but the pattern should still be followed as a primary design principle.

3

Use Specific Gas Limits and Handle Failures

Control gas stipends for external calls and implement explicit failure handling.

Detailed Instructions

Unbounded gas forwarding in external calls can lead to denial-of-service. Use .call{gas: gasLimit}() to specify a gas stipend. Always assume the external call can fail and handle both success and failure paths explicitly to avoid leaving your contract in an inconsistent state.

  • Sub-step 1: Define a reasonable gas limit for the specific operation, e.g., uint256 gasLimit = 30000; for a simple transfer, or more for complex logic.
  • Sub-step 2: Use a low-level call and capture the return boolean and data: (bool success, bytes memory data) = target.call{gas: gasLimit, value: msg.value}(callData);.
  • Sub-step 3: Check the success boolean. On failure, revert or implement a fallback logic; do not silently ignore.
solidity
function executeCall(address target, bytes memory data) external payable { uint256 gasLimit = 50000; // Context-specific limit (bool success, bytes memory returnData) = target.call{gas: gasLimit, value: msg.value}(data); if (!success) { // Option 1: Revert with custom error, bubbling up the reason revert CallFailed(returnData); // Option 2: Emit an event and update state to reflect failure } // Process returnData if successful }

Tip: For token transfers, prefer transfer() or send() which forward a fixed 2300 gas stipend, making reentrancy impossible.

4

Validate and Sanitize Return Data

Decode and verify the data returned from external calls to ensure expected behavior.

Detailed Instructions

External calls can return malicious or malformed data. Never trust return values implicitly. Decode them against expected types and ranges, and validate the data aligns with the protocol's rules before accepting it into your state.

  • Sub-step 1: Define the expected return type, e.g., returns (uint256 balance).
  • Sub-step 2: Use abi.decode on the bytes memory returnData from the low-level call to extract values: uint256 newBalance = abi.decode(returnData, (uint256));.
  • Sub-step 3: Apply validation logic. For example, check that a returned balance is non-zero or within a maximum cap before storing it.
solidity
function getExternalBalance(address token, address holder) public view returns (uint256) { // Encode the call to the standard `balanceOf` function bytes memory callData = abi.encodeWithSignature("balanceOf(address)", holder); (bool success, bytes memory data) = token.staticcall(callData); require(success, "Static call failed"); // Decode and validate the return data uint256 balance = abi.decode(data, (uint256)); // Sanitization: Ensure balance isn't impossibly large (e.g., type(uint256).max) require(balance < type(uint256).max / 2, "Suspicious balance value"); return balance; }

Tip: For view/pure functions, use staticcall to prevent state modifications during the validation process.

5

Implement Circuit Breakers and Rate Limiting

Add administrative controls to pause interactions or limit volumes in response to threats.

Detailed Instructions

Circuit breakers (pause functions) and rate limits are critical risk mitigation tools. They allow protocol guardians to stop interactions with a potentially compromised external contract or limit exposure during volatile events, providing time for investigation.

  • Sub-step 1: Implement a boolean paused state variable and a onlyOwner modifier to toggle it.
  • Sub-step 2: Wrap the external call logic in a require statement: require(!paused, "Function is paused");.
  • Sub-step 3: Add rate-limiting logic, such as a cap on the amount per transaction (amount <= maxPerTx) or a period-based limit using a timestamp map.
solidity
bool public paused; uint256 public maxPerTx = 1000 ether; mapping(address => uint256) public lastTxTime; uint256 public cooldownPeriod = 1 hours; function secureExternalTransfer(address to, uint256 amount) external { require(!paused, "Contract is paused"); require(amount <= maxPerTx, "Exceeds per-transaction limit"); require(block.timestamp >= lastTxTime[msg.sender] + cooldownPeriod, "In cooldown"); lastTxTime[msg.sender] = block.timestamp; // Proceed with checks-effects-interactions for the external call (bool success, ) = to.call{value: amount}(""); require(success, "Transfer failed"); }

Tip: Consider making the circuit breaker role a multi-signature wallet or a timelock contract to decentralize control and prevent a single point of failure.

Auditing for Composability Risks

Understanding the Risk Surface

Composability is the ability for smart contracts to interact and build upon each other, but it creates hidden trust boundaries. When you use a DeFi protocol, you are implicitly trusting not just its code, but the code of every contract it interacts with. This forms a dependency graph of risk.

Key Points

  • Unchecked External Calls: A protocol may call an external contract (e.g., an oracle or another DApp) without validating its state or return data, leading to unexpected behavior.
  • Reentrancy Across Contracts: Classic reentrancy can propagate through multiple contracts, not just the one you are auditing. A malicious token contract can re-enter a lending protocol during a callback.
  • Economic Assumption Violations: Protocols often assume other systems behave predictably. For example, a vault might assume a stablecoin maintains its peg; a depeg can cascade into insolvency.
  • Upgradeable Dependencies: Many protocols rely on upgradeable proxy contracts (e.g., from OpenZeppelin). An admin key compromise or a malicious upgrade in a dependency can break your system.

Example

When a yield aggregator like Yearn deposits funds into Compound, it trusts Compound's interest rate model and liquidation logic. If Compound's oracle is manipulated, it can cause incorrect liquidations within Yearn's vaults, affecting all depositors.

SECTION-FAQ

Frequently Asked Questions on Trust Boundaries

Ready to Start Building?

Let's bring your Web3 vision to life.

From concept to deployment, ChainScore helps you architect, build, and scale secure blockchain solutions.