The phrase “money legos” is charming until a load-bearing brick disintegrates mid-stack. DeFi’s composability—the ability to permissionlessly combine protocols into increasingly complex financial instruments—is the ecosystem’s most celebrated property and its most underappreciated attack surface.
DeFi protocols are designed to interact with each other, creating complex dependency chains that introduce emergent security properties. A protocol that is secure in isolation may become vulnerable when composed with other protocols in ways its developers did not anticipate. This is the uncomfortable truth that composability forces on every builder: your threat model does not end at your contract boundary.
A smart contract’s security is now dependent on the security of every protocol it integrates. A vulnerability or exploit in one underlying protocol can cascade through the entire stack, even if the integrating contract is perfectly coded. This creates a systemic risk where the failure of a single component can lead to widespread losses across multiple applications.
This article systematically dissects the security risks that live at integration boundaries—the places your auditor may not be looking.
1. Inherited Vulnerability Surface from Dependencies
When your protocol calls an external contract, you inherit that contract’s entire vulnerability surface. This is not metaphorical; it is mechanically precise. Every interface you call is an implicit trust declaration.
The composability that makes DeFi powerful also expands the attack surface. Protocols integrate with multiple external services: price oracles for asset valuations, liquidity pools for token swaps, governance contracts for parameter updates, and cross-chain bridges for multi-network operations.
Consider a straightforward vault that deposits user funds into a yield aggregator, which itself routes capital across three lending markets. Your vault has no reentrancy bug, no access-control flaw, and no arithmetic error. Yet if any one of those three lending markets contains a vulnerability, your users’ funds are at risk—and the exploit transaction may never touch a line of your code.
Evaluating DeFi security goes beyond merely assessing the internal contracts. Auditing the underlying infrastructure and inherited components is essential to ensure a comprehensive analysis.
This inherited surface manifests in several concrete ways:
Shared codebase lineage. Many protocols are forks of Aave, Compound, or Uniswap. This shared lineage was observed during exploits like the Reentrancy Bug 2.0, which affected multiple derivatives platforms due to inherited vulnerabilities in their Solidity contracts. A vulnerability discovered in a canonical implementation propagates instantly to every downstream fork that hasn’t patched it.
External call return-value trust. Protocols frequently accept the return values of external calls at face value without validating whether the dependency is in a sane state. The following pattern is dangerously common:
// VULNERABLE: unconditional trust of external state
interface ILendingPool {
function getCollateralValue(address user) external view returns (uint256);
function borrow(uint256 amount) external returns (bool);
}
contract VulnerableVault {
ILendingPool public lendingPool;
function leveragePosition(uint256 borrowAmount) external {
// We trust lendingPool implicitly - no staleness check,
// no circuit breaker, no validation of pool health.
uint256 collateral = lendingPool.getCollateralValue(msg.sender);
require(collateral >= borrowAmount * 2, "Undercollateralized");
lendingPool.borrow(borrowAmount);
}
}
A safer pattern explicitly validates the dependency’s state before trusting its output:
// SAFER: defensive integration with dependency health checks
interface ILendingPool {
function getCollateralValue(address user) external view returns (uint256);
function borrow(uint256 amount) external returns (bool);
function isPaused() external view returns (bool);
function lastUpdateTimestamp() external view returns (uint256);
}
contract DefensiveVault {
ILendingPool public lendingPool;
uint256 public constant MAX_STALENESS = 1 hours;
error DependencyPaused();
error StaleDepState();
error BorrowFailed();
function leveragePosition(uint256 borrowAmount) external {
// Check dependency health before trusting any of its state
if (lendingPool.isPaused()) revert DependencyPaused();
if (block.timestamp - lendingPool.lastUpdateTimestamp() > MAX_STALENESS)
revert StaleDepState();
uint256 collateral = lendingPool.getCollateralValue(msg.sender);
require(collateral >= borrowAmount * 2, "Undercollateralized");
bool success = lendingPool.borrow(borrowAmount);
if (!success) revert BorrowFailed();
}
}
Interface assumption mismatch. The TrueUSD (TUSD) token had an unusual implementation where its new version was deployed on a different address, but interactions with one address could trigger interactions with the other. At first glance, this behavior appeared consistent, but it could introduce a critical issue in the protocols relying on the token: if a protocol assumes token address verification is sufficient, this assumption breaks when a token has multiple entry points.
The lesson: an address is not a behavior contract. Interface compliance does not imply semantic correctness.
2. Oracle Cascades: When Protocol B Depends on Protocol A’s State
Oracle manipulation is DeFi’s most reliably recurring exploit class. Composability amplifies it from a single-protocol problem into a systemic one.
Lending platforms like Compound and Aave exhibited high dependency scores on the health of decentralized oracles. If these oracle feeds were manipulated or congested, upstream effects on liquidity, interest rate recalibration, and liquidation thresholds emerged across several yield farming protocols.
The cascade topology works as follows: Protocol A reports a price. Protocol B reads that price to size a position. Protocol C reads Protocol B’s position value to determine creditworthiness. A manipulation of Protocol A’s price propagates through B and C, triggering economically irrational behavior at each hop—each with its own liquidation engine, collateral factor, and fee mechanism.
A recent attack on the Harvest yield aggregation protocol was made possible due to its dependence on the prices reported by the Curve decentralized exchange protocol. By performing a $17M trade in Curve, the attacker could indirectly manipulate the price of tokens in Harvest, obtaining $24M of protocol funds.
By borrowing a massive sum of an asset, an attacker can manipulate the price on a DEX, tricking a vulnerable oracle into reporting a false price to a lending protocol. The attacker can then use this manipulated price to borrow a huge amount of funds against their collateral before repaying the flash loan, all in one single, atomic transaction.
The following illustrates a protocol that is vulnerable to oracle cascade because it reads spot price directly from an AMM:
// VULNERABLE: spot price oracle - trivially manipulable with flash loans
interface IUniswapV2Pair {
function getReserves() external view returns (
uint112 reserve0,
uint112 reserve1,
uint32 blockTimestampLast
);
}
contract VulnerableOracle {
IUniswapV2Pair public pair;
// Returns spot price - manipulable within a single transaction
function getPrice() external view returns (uint256) {
(uint112 reserve0, uint112 reserve1,) = pair.getReserves();
return (uint256(reserve1) * 1e18) / uint256(reserve0);
}
}
contract DownstreamProtocol {
VulnerableOracle public oracle;
// Anyone can manipulate the oracle's output via flash loan
// before calling this function in the same transaction
function borrow(uint256 collateralAmount) external {
uint256 price = oracle.getPrice(); // Spot - DANGEROUS
uint256 borrowLimit = (collateralAmount * price * 75) / (100 * 1e18);
_executeBorrow(msg.sender, borrowLimit);
}
function _executeBorrow(address user, uint256 amount) internal { /* ... */ }
}
A cascade-resistant design layers multiple oracle sources with a TWAP and divergence circuit breaker:
// SAFER: multi-source oracle with TWAP and divergence guard
interface IChainlinkAggregator {
function latestRoundData() external view returns (
uint80 roundId,
int256 answer,
uint256 startedAt,
uint256 updatedAt,
uint80 answeredInRound
);
}
interface IUniswapV3Pool {
function observe(uint32[] calldata secondsAgos) external view
returns (int56[] memory tickCumulatives, uint160[] memory);
}
contract ResilientOracle {
IChainlinkAggregator public chainlink;
IUniswapV3Pool public uniV3Pool;
uint256 public constant STALENESS_THRESHOLD = 1 hours;
uint256 public constant MAX_DIVERGENCE_BPS = 200; // 2%
uint32 public constant TWAP_PERIOD = 1800; // 30 minutes
error StaleChainlinkFeed(uint256 updatedAt);
error OracleDivergence(uint256 chainlinkPrice, uint256 twapPrice);
function getSafePrice() external view returns (uint256) {
// Source 1: Chainlink with staleness check
(, int256 clAnswer,, uint256 updatedAt,) = chainlink.latestRoundData();
if (block.timestamp - updatedAt > STALENESS_THRESHOLD)
revert StaleChainlinkFeed(updatedAt);
uint256 chainlinkPrice = uint256(clAnswer);
// Source 2: Uniswap V3 TWAP (manipulation-resistant over 30 min)
uint32[] memory secondsAgos = new uint32[](2);
secondsAgos[0] = TWAP_PERIOD;
secondsAgos[1] = 0;
(int56[] memory tickCumulatives,) = uniV3Pool.observe(secondsAgos);
int56 tickDelta = tickCumulatives[1] - tickCumulatives[0];
int24 meanTick = int24(tickDelta / int56(uint56(TWAP_PERIOD)));
uint256 twapPrice = _tickToPrice(meanTick);
// Circuit breaker: revert if sources diverge by >2%
uint256 divergence = chainlinkPrice > twapPrice
? ((chainlinkPrice - twapPrice) * 10000) / twapPrice
: ((twapPrice - chainlinkPrice) * 10000) / chainlinkPrice;
if (divergence > MAX_DIVERGENCE_BPS)
revert OracleDivergence(chainlinkPrice, twapPrice);
// Return the more conservative (lower) of the two prices
return chainlinkPrice < twapPrice ? chainlinkPrice : twapPrice;
}
function _tickToPrice(int24 tick) internal pure returns (uint256) {
// Simplified: in production use TickMath.getSqrtRatioAtTick
return uint256(uint24(tick)); // placeholder
}
}
Oracle inputs must be aggregated from multiple independent sources: using a single data source makes the system fragile and easy to manipulate.
3. Liquidity Dependency Risk
Protocols do not just depend on each other for data—they depend on each other for capital. This is perhaps the most underappreciated composability risk category.
Liquidity withdrawal from one protocol can quickly ripple through interconnected systems, initiating chain reactions that destabilize lending platforms and DEXs simultaneously.
When a strategy vault deposits into a DEX pool and then pledges those LP tokens as collateral in a lending market, three layers of liquidity assumption are stacked:
- The DEX pool has sufficient depth to execute entry and exit without catastrophic slippage.
- The LP token price is not susceptible to donation or donation-shadow attacks.
- The lending market’s liquidation engine can function even during simultaneous mass withdrawals.
Unlike traditional markets, where manual intervention was possible in case of concurrent defaults and manipulations, the algorithmic permissionless nature of DeFi does not allow to stop cascading crashes.
The below contract demonstrates the hidden liquidity assumption:
// VULNERABLE: hidden liquidity dependency—assumes pool depth is always adequate
interface IDEX {
function swap(
address tokenIn,
address tokenOut,
uint256 amountIn,
uint256 minAmountOut
) external returns (uint256 amountOut);
function getPoolDepth(address token) external view returns (uint256);
}
contract LiquidityDependentStrategy {
IDEX public dex;
address public tokenA;
address public tokenB;
// No check on pool depth before executing a large swap.
// If pool is thin (e.g., due to concurrent withdrawals elsewhere),
// slippage can be extreme and minAmountOut can be gamed.
function rebalance(uint256 amount, uint256 minOut) external {
dex.swap(tokenA, tokenB, amount, minOut);
}
}
// SAFER: enforce minimum liquidity thresholds before acting
contract LiquidityAwareStrategy {
IDEX public dex;
address public tokenA;
address public tokenB;
uint256 public constant MIN_POOL_DEPTH_MULTIPLE = 10; // position must be <10% of pool
error InsufficientPoolDepth(uint256 available, uint256 required);
function rebalance(uint256 amount, uint256 minOut) external {
uint256 poolDepth = dex.getPoolDepth(tokenB);
uint256 requiredDepth = amount * MIN_POOL_DEPTH_MULTIPLE;
if (poolDepth < requiredDepth)
revert InsufficientPoolDepth(poolDepth, requiredDepth);
dex.swap(tokenA, tokenB, amount, minOut);
}
}
Liquidity crises also interact with oracle cascades: thin pools are dramatically easier to manipulate with flash loans, linking the two risk categories into a compound attack surface. An attacker can exploit price discrepancies across multiple protocols in a single transaction. This can drain liquidity from multiple pools simultaneously, far exceeding the risk to any single protocol.
4. Building on Unaudited Protocols
Exponential found there was a 68% greater chance of negative events such as hacks in DeFi protocols that chose not to conduct a published audit. “In our backtest, the majority of protocols that resulted in actual user losses shared one trait: they were unaudited,” lead researcher David Kuang told DL News. Exponential highlighted a few case studies, including Sonne, a lending protocol that launched without an audit and was later exploited for $20 million.
When your protocol integrates with an unaudited dependency, you effectively absorb that protocol’s entire unknown bug surface. This is structurally similar to importing an open-source library with no test suite and unknown contributors into production financial infrastructure.
Since DeFi is still new, and often experimental, some platforms may not have strong security or reliable backup systems. Learning about these systems, checking for audits and being cautious with new or untested projects can help protect users from loss.
The appropriate engineering response is a dependency registry with formalized acceptance criteria:
// Dependency registry pattern: enforce audit requirements on-chain
contract DependencyRegistry {
struct DependencyRecord {
address contractAddress;
bool audited;
address[] auditors;
uint256 auditTimestamp;
uint256 tvlAtIntegration; // snapshot TVL as a maturity signal
bool approved;
}
mapping(address => DependencyRecord) public dependencies;
address public governance;
event DependencyApproved(address indexed dep, address[] auditors);
event DependencyRevoked(address indexed dep, string reason);
modifier onlyGov() {
require(msg.sender == governance, "Not governance");
_;
}
function approveDependency(
address dep,
address[] calldata auditors,
uint256 tvl
) external onlyGov {
require(auditors.length > 0, "Must have at least one auditor");
require(tvl > 0, "TVL must be non-zero");
dependencies[dep] = DependencyRecord({
contractAddress: dep,
audited: true,
auditors: auditors,
auditTimestamp: block.timestamp,
tvlAtIntegration: tvl,
approved: true
});
emit DependencyApproved(dep, auditors);
}
function revokeDependency(address dep, string calldata reason) external onlyGov {
dependencies[dep].approved = false;
emit DependencyRevoked(dep, reason);
}
function assertApproved(address dep) external view {
require(dependencies[dep].approved, "Dependency not approved");
require(dependencies[dep].audited, "Dependency not audited");
}
}
// Consumer that gates on dependency approval
contract SafeStrategy {
DependencyRegistry public registry;
address public lendingPool;
constructor(address _registry, address _lendingPool) {
registry = DependencyRegistry(_registry);
lendingPool = _lendingPool;
// Revert at construction if dependency is unregistered
registry.assertApproved(lendingPool);
}
}
5. Integration Assumptions That Break When a Dependency Upgrades
Upgradeable contracts are a double-edged sword. They enable bug fixes and feature additions, but they represent a trust assumption that is easy to miss: you are trusting not just the current implementation but every future implementation the dependency’s governance may deploy.
For an upgradeable protocol, the Protocol Operator could also be able to pause the protocol, change the software, withdraw funds, mint new tokens, set new operational parameters such as fees, or initiate emergency steps.
Consider a protocol that integrates with a lending market and assumes getInterestRate() always returns a value in basis points. The dependency upgrades and returns a value in ray units (10^27). Your protocol’s math silently overflows or returns economically irrational values. No exploit necessary—the upgrade itself broke the integration.
// VULNERABLE: hardcoded interpretation of return value format
interface ILendingMarketV1 {
// Returns APY in basis points (e.g., 500 = 5%)
function getInterestRate() external view returns (uint256);
}
contract IntegrationAssumptionVault {
ILendingMarketV1 public market;
uint256 public constant BPS_DENOMINATOR = 10_000;
function computeYield(uint256 principal, uint256 timeSeconds)
external
view
returns (uint256)
{
uint256 rateBps = market.getInterestRate();
// Silent breakage: if market upgrades to return in RAY (1e27),
// this calculation overflows or produces garbage output.
return (principal * rateBps * timeSeconds) / (BPS_DENOMINATOR * 365 days);
}
}
// SAFER: version-gated integration with explicit format validation
interface ILendingMarketV2 {
function getInterestRate() external view returns (uint256);
function getInterestRateDecimals() external view returns (uint8);
function implementationVersion() external view returns (uint256);
}
contract VersionAwareVault {
ILendingMarketV2 public market;
uint256 public expectedVersion;
uint8 public expectedDecimals;
error UnexpectedVersion(uint256 got, uint256 expected);
error UnexpectedDecimals(uint8 got, uint8 expected);
constructor(address _market, uint256 _version, uint8 _decimals) {
market = ILendingMarketV2(_market);
expectedVersion = _version;
expectedDecimals = _decimals;
}
function computeYield(uint256 principal, uint256 timeSeconds)
external
view
returns (uint256)
{
// Validate the dependency hasn't silently changed its ABI semantics
uint256 version = market.implementationVersion();
if (version != expectedVersion)
revert UnexpectedVersion(version, expectedVersion);
uint8 decimals = market.getInterestRateDecimals();
if (decimals != expectedDecimals)
revert UnexpectedDecimals(decimals, expectedDecimals);
uint256 rate = market.getInterestRate();
uint256 denominator = 10 ** uint256(decimals);
return (principal * rate * timeSeconds) / (denominator * 365 days);
}
}
This pattern pairs with a monitoring requirement: any dependency upgrade event should trigger an automated circuit breaker that pauses your protocol until the integration assumptions have been re-validated.
6. Composability-Specific Reentrancy Across Protocols
Classic reentrancy lives within a single contract. Composability reentrancy lives in the gap between contracts—and it is substantially harder to detect.
Known as “read-only external call reentrancy,” this vulnerability occurs when an external call is made to another contract that reads data and reenters the calling contract, potentially causing unexpected behavior.
Read-only reentrancy isn’t a Solidity bug. It’s a composability hazard—an emergent property of protocols reading each other’s intermediate states. No single protocol is “wrong.” The vulnerability exists in the gap between them.
The canonical real-world example: If contract Foo depends on the state of another contract Bar, and Bar does not produce the correct state values mid-transaction, then Foo can be tricked. In the Curve finance case, it wasn’t Curve that was exploited. It was contracts that depended on it.
The attacker deposits ether and other ERC20 tokens into Curve. Curve mints liquidity tokens to the attacker. The attacker withdraws liquidity by burning the liquidity tokens. Curve sends back ether before sending back the ERC20 tokens. When Curve sends back Ether, the attacker regains control and conducts a trade on another contract. The contract that depends on Curve asks Curve for the price ratio between the liquidity tokens, ether, and the other ERC20 tokens. At this mid-transaction moment, the price is in an inconsistent intermediate state—and the downstream protocol prices collateral based on that corrupted snapshot.
The following demonstrates this cross-protocol reentrancy vector:
// VULNERABLE: reads external state during a mid-transaction window
interface ICurvePool {
function get_virtual_price() external view returns (uint256);
function remove_liquidity(uint256 amount, uint256[2] calldata minAmounts) external;
}
// This is the "victim" protocol that trusts Curve's view function
contract VulnerableCollateralValuer {
ICurvePool public curvePool;
mapping(address => uint256) public lpBalances;
// Called by attacker mid-transaction after remove_liquidity sends ETH
// but before ERC20s are returned — virtual_price is in inconsistent state
function getCollateralValue(address user) external view returns (uint256) {
uint256 lpBal = lpBalances[user];
// get_virtual_price() returns incorrect value during Curve's withdrawal
uint256 virtualPrice = curvePool.get_virtual_price();
return (lpBal * virtualPrice) / 1e18;
}
function borrow(uint256 amount) external {
uint256 collateral = getCollateralValue(msg.sender);
require(collateral >= amount * 2, "Undercollateralized");
// Issue funds based on manipulated collateral value
_disburseFunds(msg.sender, amount);
}
function _disburseFunds(address to, uint256 amount) internal { /* ... */ }
}
// Attacker contract exploiting cross-protocol read-only reentrancy
contract CrossProtocolReentrancyAttacker {
ICurvePool public curve;
VulnerableCollateralValuer public victim;
// Step 1: attacker has LP tokens deposited in victim as collateral
// Step 2: call remove_liquidity on Curve
// Step 3: Curve sends ETH → triggers receive() below
// Step 4: inside receive(), virtual_price is inconsistent
// → borrow() gets inflated collateral valuation
function attack() external {
uint256[2] memory minAmounts = [uint256(0), uint256(0)];
curve.remove_liquidity(1000e18, minAmounts); // Step 2
}
receive() external payable {
// Mid-transaction: Curve has sent ETH but not ERC20s yet.
// virtual_price reports an incorrect (inflated or deflated) ratio.
victim.borrow(type(uint256).max); // Step 3 — drains victim
}
}
Mitigation patterns:
One is to make the reentrancy lock public or make the view functions non-reentrant also. If a dependency exposes a public reentrancy lock, downstream protocols can read it before trusting any view:
// SAFER: check upstream reentrancy lock before reading view functions
interface ICurvePoolGuarded {
function get_virtual_price() external view returns (uint256);
function reentrancyLocked() external view returns (bool); // public lock
}
contract GuardedCollateralValuer {
ICurvePoolGuarded public curvePool;
mapping(address => uint256) public lpBalances;
error UpstreamLocked();
function getCollateralValue(address user) external view returns (uint256) {
// Refuse to read state if upstream is mid-execution
if (curvePool.reentrancyLocked()) revert UpstreamLocked();
uint256 lpBal = lpBalances[user];
uint256 virtualPrice = curvePool.get_virtual_price();
return (lpBal * virtualPrice) / 1e18;
}
}
Cross-contract reentrancy typically happens when multiple contracts share the same state variable, and some contracts update that variable insecurely. This type of reentrancy might be considered a complicated issue since it is often challenging to discover. This is precisely why composability-specific reentrancy escapes standard automated tools—no single contract exhibits the vulnerable pattern in isolation.
7. How to Scope and Assess Composability Risk in an Audit
No DeFi protocol is an island; its security is often only as strong as its weakest external link. This step focuses on rigorously testing all external dependencies and integrations, which are frequent sources of catastrophic failure.
Standard audits are scoped to a specific set of contracts. Composability risk assessment requires explicitly expanding that scope to cover the integration surface. Here is how to approach it methodologically.
7.1 Construct the Dependency Graph
Before reading a line of code, map every external call:
- Which contracts does the in-scope code call?
- Which contracts call the in-scope code?
- What state does the in-scope code read from external contracts (even via view calls)?
- What external events or callbacks can re-enter the in-scope code?
Trust boundaries are the interfaces where data enters your protocol from external sources or where assets move between isolated components. These boundaries are the highest-risk areas because they represent points where assumptions from one component meet the reality of another. For example, when your vault accepts tokens from users (external → internal), or when your pricing logic queries an oracle (internal → external). Most critical vulnerabilities—like fee-on-transfer token issues, oracle manipulation, or cross-contract reentrancy—occur at these boundaries.
Each node in the dependency graph should be labeled with:
- Audit status (audited / unaudited / self-audited)
- Upgradeability (immutable / proxy / beacon)
- Oracle dependency (yes/no, and which feeds)
- Liquidity dependency (yes/no, and which pools)
- Admin key risk (multisig, timelock, EOA)
7.2 Define Invariants Across Protocol Boundaries
Security audits should explicitly model inter-protocol interactions and test the protocol’s behaviour when composed with common DeFi building blocks. Formal specification of protocol invariants should include properties that must hold across arbitrary external calls.
Cross-boundary invariants differ from standard invariants. Examples:
- “The sum of user balances must equal the underlying token balance held by the dependency, regardless of what state the dependency is in.”
- “No user can borrow more than their collateral value as reported by the oracle, even if the oracle is in a mid-update state.”
- “A paused dependency must prevent any state-mutating call in this protocol.”
7.3 Simulate Dependency Failure Modes
Protocol integration risks are evaluated, asking how the system behaves if an integrated oracle fails or a dependent lending protocol is compromised. This holistic view ensures the design itself is resilient before testing its economic model.
For each dependency, define and test the following failure scenarios:
// Test harness: mock dependency that simulates failure modes
contract MockLendingPoolFailure {
enum FailureMode { NONE, PAUSED, STALE_ORACLE, REENTRANT, RETURNS_ZERO }
FailureMode public mode;
function setMode(FailureMode _mode) external { mode = _mode; }
function getCollateralValue(address) external returns (uint256) {
if (mode == FailureMode.PAUSED) revert("Pool paused");
if (mode == FailureMode.STALE_ORACLE) return type(uint256).max; // overflow bait
if (mode == FailureMode.RETURNS_ZERO) return 0;
if (mode == FailureMode.REENTRANT) {
// Simulate mid-execution callback
ITarget(msg.sender).callback();
}
return 1e18; // nominal
}
}
7.4 Assess Upgrade Risk for Every Proxy Dependency
For every upgradeable dependency:
- Who controls the upgrade key? Is there a timelock?
- What is the minimum time between upgrade proposal and execution?
- Does your protocol have a kill switch or pause mechanism that can be activated if the dependency upgrades adversely?
- Are there any on-chain events emitted by the proxy that your monitoring system watches?
7.5 Check for Read-Only Reentrancy
When a dependency upgrades its implementation, your protocol’s assumptions about its behavior become stale. The answers to these questions determine how exposed you are and how quickly you can respond.
Composability Risk Audit Checklist
Dependency mapping
- Every external contract call is documented with the address, interface, and trust assumption
- The set of contracts that can affect your protocol’s solvency is explicitly enumerated
- Indirect dependencies (contracts called by your direct dependencies) are identified at least one level deep
Oracle and price feed dependencies
- No spot price from an AMM is used without a TWAP
- Chainlink feeds are validated for staleness, zero price, and L2 sequencer uptime
- Oracle failure modes are defined: does the protocol pause, use a fallback, or continue with stale data?
Upgrade exposure
- Every proxy dependency has an identified admin and timelock delay
- Your protocol has a plan to respond within the timelock window if a dependency upgrade is malicious
- Immutable contracts are preferred over upgradeable ones for high-trust dependencies
Reentrancy and callback exposure
- All external calls that could trigger callbacks into your protocol are identified
- Read-only reentrancy is checked: view functions consumed by third parties cannot return inconsistent state during your protocol’s external calls
-
nonReentrantguards are applied to all functions that make external calls and share state with other callable functions
Liquidity and market dependency
- The protocol’s solvency assumptions are stress-tested against low-liquidity scenarios
- Cascading liquidation risk from correlated collateral is modeled
- Circuit breakers exist to pause operations when external market conditions become pathological
Monitoring
- On-chain events from all dependencies are monitored in real time
- Alerts fire when dependency state changes unexpectedly (price deviation, admin change, upgrade proposal)
- An incident response runbook exists for each identified dependency failure mode