๐ This is a sample report โ see exactly what a NanoLab Smart Contract Audit delivers.
Compound V2 Smart Contract Audit Report (Sample)
Finding Categories Covered
This sample DeFi audit report is organized around the issues buyers usually ask about first: exploit paths, architectural risk, and remediation depth.
Reentrancy
Borrow flow ordering, callback-capable token integrations, and cross-function state exposure.
Oracle manipulation
Price assumptions, protocol dependencies, and where market or governance changes create hidden risk.
Access control
Privileged operations, governance power, timelock assumptions, and admin blast radius.
Remediation guidance
Severity-ranked findings with the reasoning, impact, and follow-up path needed for a usable audit report.
Why We Chose Compound V2 โ A Note on This Report
What Happened
On September 29, 2021, the Compound protocol began distributing COMP tokens to users at rates that bore no relationship to their actual supplied or borrowed balances. The mechanism responsible was distributeSupplierComp() in the Comptroller contract, which calculated reward deltas from an incorrectly ordered index initialization. Users who had previously interacted with affected markets found accrued balances that were orders of magnitude larger than any legitimate entitlement. There was no on-chain pause mechanism for COMP claims, no circuit breaker, and no admin function capable of stopping distributions mid-flight without a full Timelock-governed parameter change.
The drain ran unimpeded until the COMP reservoir was exhausted or users chose not to claim. Estimates placed the total COMP distributed beyond legitimate entitlement at approximately $80โ90M at then-current prices. The root cause traced directly to Proposal 062, which had passed governance three days earlier. That proposal was categorized as a routine parameter update โ a collateral factor adjustment. It was not subjected to an independent security review before the Timelock executed it. The accounting bug was introduced in that proposal's code change.
The Compound team published a post-mortem. The on-chain record is complete. The functions are known, the root cause is publicly documented, and the fix โ Proposal 064 โ is also on-chain. This is not a secret or a disputed incident.
Why This Report Exists
We chose Compound V2 because the exploit is documented and the code is public. That combination creates something rare in security: a ground truth. The vulnerability is known, the functions are known, the root cause has been analyzed extensively. You can check our findings against what actually happened. We are not claiming we would have caught this before the incident โ any firm making that claim is telling you what they think you want to hear. What we can show you is how we read a contract suite of this complexity: the checks we run, the patterns we flag, the reasoning we apply. If our methodology surfaces the root cause as a finding, that's meaningful signal about how it performs on real code. Judge the methodology on that basis.
What This Report Is
This is a sample audit produced by NanoLab, analyzing Compound V2 as it exists today on Ethereum mainnet using verified Etherscan source code. The September 2021 incident is documented historical context โ this report makes no claim of prior engagement with the Compound team. It is published to demonstrate NanoLab's audit methodology, reporting depth, and finding quality. It does not constitute a current security certification of Compound or any of its contracts.
Protocol Architecture โ How Compound V2 Works
Executive Summary
Compound V2 is a permissionless lending protocol deployed on Ethereum mainnet. Users supply assets to earn yield, borrow against over-collateralized positions, and participate in governance via the COMP token. At peak in 2021, the protocol held over $10B in total value locked, making it the largest DeFi lending protocol by TVL at that time.
Audit Scope
| Contract | Address | Source | Risk Rating |
|---|---|---|---|
| Comptroller | 0x3d9819210A31b4961b30EF54bE2aeD79B9c9Cd3B | Etherscan Verified | CRITICAL |
| cDAI (cToken) | 0x5d3a536E4D6DbD6114cc1Ead35777bAB948E3643 | Etherscan Verified | HIGH |
| GovernorBravo | 0xc0Da02939E1441F497fd74F78cE7Decb17B66529 | Etherscan Verified | HIGH |
| COMP Token | 0xc00e94Cb662C3520282E6f5717214004A7f26888 | Etherscan Verified | MEDIUM |
| Timelock | 0x6d903f6003cca6255D85CcA4D3B5E650E5B468C6 | Etherscan Verified | MEDIUM |
Findings by Severity
Architecture & Code Quality Assessment
Compound V2 follows a modular design with clear separation of concerns across its five core contracts. The Comptroller handles protocol-wide risk parameters (collateral factors, liquidation incentives, market listings) while individual cToken contracts own their market state (exchange rates, borrow indices, cash reserves). This architecture means a bug in one cToken market cannot directly corrupt another market's accounting โ failures are market-isolated. The Unitroller proxy pattern allows the Comptroller implementation to be upgraded without migrating state, which is a meaningful operational advantage for a long-lived protocol.
The separation is not perfectly clean, however. cToken contracts make callbacks into the Comptroller for pre-action checks (mintAllowed(), borrowAllowed(), redeemAllowed()) and post-action hooks (mintVerify(), borrowVerify()). This tight bidirectional coupling creates a surface where a malicious or buggy Comptroller implementation could subvert cToken invariants โ a known risk when the Unitroller proxy is upgraded. The _setComptroller() function in cToken accepts any address, relying entirely on trust in the admin to supply a legitimate implementation.
The admin key architecture is layered: the protocol admin (Timelock) can adjust most risk parameters via governance, but several functions on the Comptroller โ including _setPriceOracle() and _setComptrollerImplementation() (via Unitroller) โ are admin-gated without an independent Timelock requirement. In the current deployment the admin is the Timelock contract itself, which mitigates this in practice. However the contract code itself does not enforce this relationship โ an admin change would immediately grant unilateral parameter control.
The mathematical foundations of Compound V2 are sound. The exchange rate invariant in exchangeRateStoredInternal() correctly computes (cash + totalBorrows - totalReserves) / totalSupply, with proper edge case handling for zero supply (returning the initial exchange rate). The interest accrual in accrueInterest() correctly updates the borrow index multiplicatively rather than additively, preserving compounding semantics.
Compound V2 uses its own fixed-point math library (CarefulMath, Exponential) rather than OpenZeppelin SafeMath. These libraries return error codes rather than reverting on overflow, and the consuming functions are expected to check these codes. In practice, callers consistently check returned error codes via the Error enum โ the pattern is applied uniformly. The protocol predates Solidity 0.8 automatic overflow checking; this custom library fills that gap adequately.
The liquidation math in liquidateCalculateSeizeTokens() correctly accounts for the exchange rate between underlying and cToken, applying the liquidation incentive to the seized cToken amount. One edge case worth flagging: when exchangeRate is very small (near-zero supply markets), the division could truncate to zero seize tokens, making liquidations impossible. This is a theoretical edge case for extremely illiquid markets but the code does not guard against it explicitly.
By DeFi standards, Compound V2 is exceptionally well-documented. The Exponential.sol library includes mathematical explanations of fixed-point mantissa arithmetic โ "We use a simple model where each EXP is represented as a uint with a scaling factor of 1e18" โ that are genuinely useful for auditors unfamiliar with the codebase. Function-level NatSpec comments are present throughout the Comptroller and cToken contracts, explaining not just what functions do but why certain checks exist.
The naming conventions are consistent but require DeFi-native familiarity. Terms like mantissa (scaled integer representing a decimal), expScale (1e18), and halfExpScale (5e17, used for rounding) are defined once in ExponentialNoError.sol and used uniformly. A reader unfamiliar with the convention will find the naming opaque at first, but once the pattern is grasped it is applied consistently enough to be a net positive.
Complex functions like borrowAllowed() and redeemFresh() are structured with named error codes returned at each validation gate, which makes the control flow auditable step-by-step. This is preferable to many DeFi contracts that use unstructured require() chains without clear labeling.
Top complexity hotspots identified:
redeemFresh()CToken.solComplexity: Multi-path state machine handling both redeemTokens and redeemAmount inputs. Two redemption modes share a single function body with branching logic that must maintain identical accounting invariants on both paths. The function involves 5+ cross-contract reads and modifies 4 state variables atomically.
Risk: Path-dependent bugs: the two redemption paths could diverge in edge cases (e.g. dust amounts). Exchange rate staleness if accrueInterest() is not called first.
Verdict: Handled adequately. Both paths consistently call accrueInterest() first, and the error code pattern makes each validation gate explicit.
liquidateBorrowFresh()CToken.solComplexity: Orchestrates a cross-contract sequence: (1) accrue interest on borrow market, (2) accrue interest on collateral market, (3) check liquidation eligibility on Comptroller, (4) seize tokens from collateral cToken. The ordering is strict โ any reordering could produce incorrect liquidation amounts.
Risk: Cross-contract reentrancy between the two accrueInterest calls and the external token transfer. The collateral cToken's seize() function is called externally.
Verdict: The checks-effects-interactions pattern is respected: state updates precede external calls. However the absence of an explicit nonReentrant modifier leaves the invariant implicit.
castVoteBySig()GovernorBravo.solComplexity: EIP-712 signature verification for off-chain vote submission. Involves ecrecover, nonce management, checkpoint math, and delegation resolution. Signature replay across chains is a known footgun in this pattern.
Risk: Chain ID is included in the domain separator, preventing simple replay attacks. Nonces are tracked per voter. The implementation follows the GovernorAlpha pattern faithfully.
Verdict: Implemented correctly per EIP-712. Complexity is inherent to the pattern, not a code quality issue.
distributeSupplierComp() / distributeBorrowerComp()Comptroller.solComplexity: Index-based reward accounting across multiple markets and users. State reads from multiple mappings (compSupplyState, compSupplierIndex), arithmetic on user balances, and unbounded accrual accumulation.
Risk: The 2021 incident. Index initialization ordering and absence of cap on accrued amounts. See Finding F-01.
Verdict: Not handled adequately. Two independent bugs in the same function body, both exploited.
Compound V2 was designed for correctness and clarity over gas efficiency, which was an appropriate tradeoff for a high-TVL protocol where errors are catastrophic and transaction costs were lower at deployment time. The codebase does not employ modern gas optimization patterns like storage packing (most variables are uint256 occupying full 32-byte slots), assembly-level optimizations, or immutable constants.
The most significant gas concern is repeated SLOAD operations in functions like getAccountLiquidity(), which loops over all markets an account has entered, performing multiple storage reads per market per call. For accounts with many active positions, this becomes expensive. The pattern was acceptable in 2021 but would benefit from memory caching on a modern rewrite.
Event emission is well-structured and covers all meaningful state transitions โ mint, redeem, borrow, repay, liquidate, and governance actions all emit events with sufficient indexed parameters for off-chain indexing. This is a genuine strength: Compound's event schema enabled the post-mortem analysis of the 2021 incident in real time.
The primary external dependency is the price oracle. Compound V2 uses a PriceOracle interface โ an admin-controlled contract address that returns USD prices for each cToken's underlying. The current deployment uses the Open Price Feed (Chainlink-based), which is reasonable. However the oracle architecture places significant trust in the admin: _setPriceOracle() can be called by the admin to point to any address without a time delay, meaning a compromised admin could instantly redirect to a malicious oracle that manipulates liquidation eligibility across the entire protocol.
ERC-20 token interactions via EIP20Interface use doTransferIn() and doTransferOut() helper functions that check return values and handle non-standard tokens (tokens that return false rather than reverting on failure). This is correct defensive programming for ERC-20 interactions and avoids the silent-failure footgun.
The choice of internal CarefulMath over OpenZeppelin SafeMath is defensible: CarefulMath returns error codes rather than reverting, allowing callers to handle arithmetic failures gracefully rather than propagating unexpected reverts. The pattern requires discipline โ callers must check returned error enums โ but this discipline is consistently applied throughout the codebase.
Compound V2 is deeply embedded in the DeFi composability stack. cDAI is used as collateral in multiple secondary protocols (Maker, Aave, various yield aggregators). A liquidity event or smart contract failure in Compound could trigger cascading liquidations across those dependent protocols โ the failure mode is demonstrably not isolated. This is not a code defect; it is an inherent property of DeFi composability at scale.
Flash loan risk is significant in the liquidation path. An attacker could use a flash loan to temporarily deplete a DEX liquidity pool, manipulate the Compound oracle price (if using a DEX-based oracle), trigger liquidations on healthy positions, and unwind โ all in a single transaction. The current oracle architecture (Chainlink TWAP on mainnet) substantially mitigates this for Ethereum mainnet, but it remains an architectural consideration for any chain deployment.
Governance attack surface: as of this writing, passing a proposal requires 400,000 COMP votes (raised by Proposal 049). Acquiring this threshold on the open market would cost on the order of $40โ100M depending on market conditions and slippage. This is not trivially achievable, but is within the budget of nation-state actors or well-capitalized attackers. The Timelock's 2-day delay is the primary mitigation โ but as Proposal 062 demonstrated, 2 days is insufficient for the community to thoroughly review parameter changes that contain subtle accounting bugs.
Liquidation cascade risk is partially self-correcting: when asset prices drop rapidly, liquidation bonus incentives (8%) may not exceed gas costs on a congested network, causing liquidators to withdraw. Under extreme conditions this could leave the protocol with underwater positions. The protocol has no bad-debt socialization mechanism โ losses would eventually manifest as exchange rate degradation affecting all suppliers in the affected market.
The Unitroller proxy pattern (a delegatecall proxy storing Comptroller implementation address) allows the Comptroller's logic to be upgraded while preserving all market state. The upgrade mechanism requires admin approval โ in the current deployment, the admin is the Timelock, meaning upgrades must pass governance. This is appropriate for a protocol of this scale.
Storage layout compatibility across upgrades is not enforced by the proxy pattern itself โ an upgraded implementation that changes storage slot assignments would corrupt existing state silently. The Compound team has managed this carefully historically, but the risk is real and the tooling to enforce storage layout compatibility did not exist at deployment time. Modern proxies (OpenZeppelin TransparentProxy, UUPS) provide storage gap patterns that mitigate this; Compound V2 predates these conventions.
cToken contracts are not upgradeable. Their core logic โ exchange rate calculation, interest accrual, borrow index โ is fixed at deployment. This immutability is a trust property that depositors rely on: a cToken market cannot have its core accounting changed post-deployment. The tradeoff is that bugs in cToken logic cannot be patched without deploying replacement markets and migrating liquidity, which is operationally complex. This tradeoff is well-understood and appropriate for the protocol's design goals.
Key Findings
Reward Distribution Accounting Error โ Index Initialization Ordering & Uncapped Accrual
Comptroller.soldistributeSupplierComp()claimComp()The COMP reward distribution function contains two independent bugs. First, for suppliers who interacted with a market before COMP index tracking was enabled, the function stores the current supply index before checking whether the supplier's index needs initialization โ meaning the initialization branch computes a delta against an already-updated index, producing zero reward for first-time claimers. For suppliers who joined after index tracking began but before a specific market's index grew, the reverse holds: their stored index is zero, so the delta is computed against the full historical index, producing rewards proportional to the entire protocol history rather than their actual participation window. Second, there is no cap on the COMP accrued per claim โ distributions are not bounded by the COMP reservoir balance, meaning the contract will attempt to distribute more COMP than it holds.
function distributeSupplierComp(address cToken, address supplier) internal {
CompMarketState storage supplyState = compSupplyState[cToken];
Double memory supplyIndex = Double({mantissa: supplyState.index});
Double memory supplierIndex = Double({mantissa: compSupplierIndex[cToken][supplier]});
compSupplierIndex[cToken][supplier] = supplyIndex.mantissa;
// โ Index written BEFORE the zero-check โ if supplier's stored index
// โ was 0, the delta is now computed against the just-written current index,
// โ not from compInitialIndex. The initialization branch fires too late.
if (supplierIndex.mantissa == 0 && supplyIndex.mantissa > 0) {
supplierIndex.mantissa = compInitialIndex;
}
Double memory deltaIndex = sub_(supplyIndex, supplierIndex);
uint supplierTokens = CToken(cToken).balanceOf(supplier);
uint supplierDelta = mul_(supplierTokens, deltaIndex);
// โ No cap โ accrued amount can exceed total COMP in reservoir
uint supplierAccrued = add_(compAccrued[supplier], supplierDelta);
compAccrued[supplier] = supplierAccrued;
emit DistributedSupplierComp(CToken(cToken), supplier, supplierDelta, supplyIndex.mantissa);
}function distributeSupplierComp(address cToken, address supplier) internal {
CompMarketState storage supplyState = compSupplyState[cToken];
Double memory supplyIndex = Double({mantissa: supplyState.index});
Double memory supplierIndex = Double({mantissa: compSupplierIndex[cToken][supplier]});
// โ
Initialization check runs BEFORE index is written โ delta is now
// โ
computed from compInitialIndex for new suppliers, not from 0
if (supplierIndex.mantissa == 0 && supplyIndex.mantissa > 0) {
supplierIndex.mantissa = compInitialIndex;
}
// โ
Index written after initialization โ ordering is correct
compSupplierIndex[cToken][supplier] = supplyIndex.mantissa;
Double memory deltaIndex = sub_(supplyIndex, supplierIndex);
uint supplierTokens = CToken(cToken).balanceOf(supplier);
uint supplierDelta = mul_(supplierTokens, deltaIndex);
uint supplierAccrued = add_(compAccrued[supplier], supplierDelta);
// โ
Cap accrual to available reservoir balance โ prevents distributing
// โ
more COMP than the contract holds
uint reservoirBalance = EIP20Interface(getCompAddress()).balanceOf(address(this));
compAccrued[supplier] = supplierAccrued > reservoirBalance
? reservoirBalance
: supplierAccrued;
emit DistributedSupplierComp(CToken(cToken), supplier, supplierDelta, supplyIndex.mantissa);
}A supplier with cDAI balance calls claimComp(). Their compSupplierIndex[cDAI][address] is 0 (never initialized). The current supplyState.index is 1.5e18 (has grown since market inception). The function sets compSupplierIndex to 1.5e18 before the zero-check, but then the initialization branch still fires (since the old value before the SSTORE was 0). The delta is computed as 1.5e18 - 1e18 (compInitialIndex) = 0.5e18. On a large cDAI balance, this yields an inflated accrual that far exceeds legitimate rewards. Multiplied across all eligible addresses and called repeatedly, this drains the COMP reservoir.
Apply the initialization check before writing compSupplierIndex. Add a reservoir balance cap to compAccrued before committing. Separate the distributeSupplierComp logic from claimComp to enable independent pausing.
Governance Proposal Execution Without Security Review Gate
GovernorBravo.solexecute()queue()GovernorBravo provides no mechanism to pause or cancel a queued proposal after it has passed a governance vote, unless the proposer cancels it before execution. Once a proposal passes and is queued in the Timelock, the only on-chain way to block execution is if the proposer calls cancel() โ a social coordination mechanism, not a technical safeguard. The 2-day Timelock delay is insufficient for the community to identify subtle accounting bugs in governance proposals that modify Comptroller parameters. Proposal 062 passed with a 2-day queue and executed before the accounting error was identified.
// GovernorBravo.sol โ execute()
function execute(uint proposalId) external payable {
require(state(proposalId) == ProposalState.Queued,
"GovernorBravo::execute: proposal can only be executed if it is queued");
Proposal storage proposal = proposals[proposalId];
proposal.executed = true;
// โ No guardian check or emergency pause mechanism โ any queued proposal
// โ executes after timelock delay with no additional safety gate
for (uint i = 0; i < proposal.targets.length; i++) {
timelock.executeTransaction{value: proposal.values[i]}(
proposal.targets[i],
proposal.values[i],
proposal.signatures[i],
proposal.calldatas[i],
proposal.eta
);
}
emit ProposalExecuted(proposalId);
}// GovernorBravo.sol โ execute() with guardian veto
function execute(uint proposalId) external payable {
require(state(proposalId) == ProposalState.Queued,
"GovernorBravo::execute: proposal can only be executed if it is queued");
Proposal storage proposal = proposals[proposalId];
// โ
Guardian can veto high-risk proposals during the timelock window
// โ
without being able to propose or pass new changes unilaterally
require(!proposal.vetoed, "GovernorBravo::execute: proposal vetoed by guardian");
proposal.executed = true;
for (uint i = 0; i < proposal.targets.length; i++) {
timelock.executeTransaction{value: proposal.values[i]}(
proposal.targets[i],
proposal.values[i],
proposal.signatures[i],
proposal.calldatas[i],
proposal.eta
);
}
emit ProposalExecuted(proposalId);
}
// โ
Guardian can veto โ separate role from admin, limited to blocking only
function vetoProposal(uint proposalId) external {
require(msg.sender == guardian, "GovernorBravo::veto: only guardian");
require(state(proposalId) == ProposalState.Queued, "must be queued");
proposals[proposalId].vetoed = true;
// โ
Emit event so community can observe and challenge guardian decisions
emit ProposalVetoed(proposalId);
}A proposal containing a plausible-looking parameter change (collateral factor adjustment, reward speed update) passes governance with normal voting participation. A researcher identifies a critical accounting bug in the proposal 18 hours after it is queued. There is no emergency mechanism to pause execution. The community must either rely on the proposer voluntarily canceling, or organize a new governance vote to reverse the change โ a process that itself takes several days minimum.
Add a guardian address (multisig) with authority to cancel queued proposals but not to pass new ones. Implement a separate security review period for proposals touching reward distribution or oracle parameters. Require proposals above a complexity threshold to undergo mandatory delay extension.
Oracle Price Manipulation Risk in cToken Liquidation
Comptroller.solgetAccountLiquidity()liquidateBorrowAllowed()Compound V2's liquidation eligibility is determined by comparing a borrower's total collateral value (in USD) against their total borrow value using the price oracle. If the oracle is manipulable โ either because it uses spot prices from a DEX or because the oracle admin key is compromised โ an attacker can cause incorrect liquidations of healthy positions or block legitimate liquidations of underwater positions. The oracle address is settable by the admin without a Timelock delay.
// Comptroller.sol โ getAccountLiquidity()
function getAccountLiquidityInternal(address account)
internal view returns (Error, uint, uint) {
AccountLiquidityLocalVars memory vars;
// ... (loops over all markets) ...
// โ Oracle price fetched with no staleness check, no bounds check
// โ A manipulated or stale oracle silently misprices collateral
vars.oraclePrice = oracle.getUnderlyingPrice(asset);
if (vars.oraclePrice == 0) {
return (Error.PRICE_ERROR, 0, 0);
}
// โ No check for oracle returning a price outside reasonable range
// โ (e.g. 1000x the previous block price)
vars.tokensToDenom = mul_(mul_(vars.collateralFactor, vars.exchangeRate), vars.oraclePrice);
vars.sumCollateral = mul_ScalarTruncateAddUInt(vars.tokensToDenom,
vars.cTokenBalance, vars.sumCollateral);
}// Comptroller.sol โ getAccountLiquidity() with oracle safeguards
function getAccountLiquidityInternal(address account)
internal view returns (Error, uint, uint) {
AccountLiquidityLocalVars memory vars;
// ... (loops over all markets) ...
vars.oraclePrice = oracle.getUnderlyingPrice(asset);
// โ
Revert on zero price โ prevents collateral being valued at 0
if (vars.oraclePrice == 0) {
return (Error.PRICE_ERROR, 0, 0);
}
// โ
Staleness check โ oracle must have updated within N blocks
// โ
(requires oracle to expose last-update timestamp)
require(oracle.lastUpdated(asset) >= block.number - MAX_ORACLE_STALENESS,
"oracle price stale");
// โ
Sanity bounds โ revert if price deviates > 50% from TWAP reference
uint twapPrice = twapOracle.getUnderlyingPrice(asset);
require(
vars.oraclePrice >= twapPrice / 2 && vars.oraclePrice <= twapPrice * 2,
"oracle price out of bounds"
);
vars.tokensToDenom = mul_(mul_(vars.collateralFactor, vars.exchangeRate), vars.oraclePrice);
vars.sumCollateral = mul_ScalarTruncateAddUInt(vars.tokensToDenom,
vars.cTokenBalance, vars.sumCollateral);
}An attacker uses a flash loan to drain liquidity from a cToken's underlying DEX pair, temporarily crashing the spot price. If the oracle uses this spot price, collateral values drop and the attacker can liquidate positions that were healthy before the manipulation. After flash loan repayment, prices normalize. On mainnet with Chainlink TWAP oracles this specific vector is mitigated, but the architecture creates a single point of failure that depends entirely on oracle correctness.
Enforce Timelock delay on oracle changes. Implement oracle circuit breakers that revert if prices deviate more than N% per block. Consider multi-oracle aggregation with outlier rejection.
Admin Key Centralization in Comptroller
Comptroller.sol_setPriceOracle()_setCollateralFactor()_setLiquidationIncentive()Several critical Comptroller functions are gated by admin-only access without requiring Timelock governance. While the current deployment uses the Timelock as the admin (mitigating this in practice), the contract code itself does not enforce this relationship. If the admin address is changed, these functions become immediately unilateral: a new admin could replace the price oracle, modify all collateral factors, or change liquidation incentives without any governance vote or time delay. This is a code-level trust assumption that relies on operational security rather than on-chain enforcement.
// Comptroller.sol โ _setPriceOracle()
function _setPriceOracle(PriceOracle newOracle) public returns (uint) {
// โ Admin-only with no Timelock requirement in the contract itself
// โ Any address holding adminship can replace oracle instantly
if (msg.sender != admin) {
return fail(Error.UNAUTHORIZED, FailureInfo.SET_PRICE_ORACLE_OWNER_CHECK);
}
PriceOracle oldOracle = oracle;
oracle = newOracle;
emit NewPriceOracle(oldOracle, newOracle);
return uint(Error.NO_ERROR);
}// Comptroller.sol โ _setPriceOracle() with enforced timelock
// โ
Require oracle changes to be submitted via timelock-governed pendingOracle
address public pendingOracle;
uint public pendingOracleTimestamp;
uint public constant ORACLE_UPDATE_DELAY = 2 days;
function _queuePriceOracle(PriceOracle newOracle) public {
require(msg.sender == admin, "not admin");
pendingOracle = address(newOracle);
// โ
Two-step: queue first, execute after delay โ gives community time to react
pendingOracleTimestamp = block.timestamp + ORACLE_UPDATE_DELAY;
emit PriceOracleQueued(address(newOracle), pendingOracleTimestamp);
}
function _executePriceOracle() public {
require(msg.sender == admin, "not admin");
// โ
Enforces delay in the contract itself โ not reliant on Timelock config
require(block.timestamp >= pendingOracleTimestamp, "delay not elapsed");
require(pendingOracle != address(0), "no pending oracle");
PriceOracle oldOracle = oracle;
oracle = PriceOracle(pendingOracle);
pendingOracle = address(0);
emit NewPriceOracle(oldOracle, oracle);
}Comptroller admin key is compromised (phishing, infrastructure breach, or insider). Attacker calls _setPriceOracle() to point to a malicious oracle returning inflated collateral values, enabling undercollateralized borrowing. Or calls _setCollateralFactor() to set DAI collateral factor to 90%, enabling maximum leverage just before a coordinated market dump.
Require all admin functions touching risk parameters to go through the Timelock. Add a timelock check directly in the Comptroller for high-impact parameter changes. At minimum, add an event and a 0-value lower bound on collateral factors to reduce attack surface.
No Emergency Pause on COMP Reward Claims
Comptroller.solclaimComp()claimComp(address[])The claimComp() function has no circuit breaker, pause flag, or rate limiter. During the 2021 incident, the absence of this mechanism meant that once the accounting error was live, there was no way to stop users from claiming inflated rewards short of completing a full governance cycle (4+ days minimum). The protocol guardian can pause individual markets (mint/borrow/transfer/liquidate) but not COMP claims specifically. This single omission materially amplified the total loss.
// Comptroller.sol โ claimComp() (simplified)
function claimComp(address holder) public {
// โ No pause check โ this function cannot be stopped by any
// โ existing mechanism short of a full governance cycle
return claimCompInternal(holder, allMarkets);
}
function claimCompInternal(address holder, CToken[] memory cTokens) internal {
address[] memory holders = new address[](1);
holders[0] = holder;
// โ Processes all markets with no rate limit or claim cap
claimComp(holders, cTokens, true, true);
}// Comptroller.sol โ claimComp() with guardian-controlled pause
// โ
Independent pause flag for COMP claims โ guardian can stop claims
// โ
immediately without a governance vote
bool public compClaimsPaused = false;
function _setCompClaimsPaused(bool state) public returns (bool) {
// โ
Guardian (not only admin) can pause claims โ faster response time
require(msg.sender == guardian || msg.sender == admin, "not guardian or admin");
compClaimsPaused = state;
emit ActionPaused("Comp Claims", state);
return state;
}
function claimComp(address holder) public {
// โ
Check pause flag before any distribution logic runs
require(!compClaimsPaused, "Comptroller: comp claims are paused");
return claimCompInternal(holder, allMarkets);
}Any accounting error in the reward distribution logic (past or future) cannot be contained without governance action. An attacker who discovers an error in claimComp() can drain the COMP reservoir completely before a fix can be deployed โ the window is bounded only by block time and gas, not by any on-chain safeguard.
Add a compClaimsPaused flag to the Comptroller, settable by the guardian (not admin alone), that blocks claimComp() without requiring governance. This is symmetric with the existing market-level pause flags (_setMintPaused, _setBorrowPaused, etc.).
Reentrancy Guard Missing in redeemUnderlying()
CToken.solredeemUnderlying()redeemFresh()The cToken redeem functions do not carry an explicit nonReentrant modifier. The checks-effects-interactions pattern is followed โ state (totalSupply, accountTokens) is updated before the external token transfer in doTransferOut(). This makes a classic reentrancy attack impossible in practice. However, with ERC-777 tokens or tokens with transfer hooks, a malicious token could trigger a callback during doTransferOut() that re-enters redeemUnderlying() against already-updated (lower) state. The risk is low for standard ERC-20 markets but worth flagging as a defense-in-depth gap.
// CToken.sol โ redeemFresh()
function redeemFresh(address payable redeemer, uint redeemTokensIn,
uint redeemAmountIn) internal returns (uint) {
// โ No nonReentrant modifier โ relies entirely on CEI ordering
// โ ERC-777 tokens with transfer hooks could re-enter here
// ... validation and state updates ...
totalSupply = totalSupply - vars.redeemTokens;
accountTokens[redeemer] = accountTokens[redeemer] - vars.redeemTokens;
// โ reentrancy surface for ERC-777 tokensReceived callback
doTransferOut(redeemer, vars.redeemAmount);
emit Redeem(redeemer, vars.redeemAmount, vars.redeemTokens);
return uint(Error.NO_ERROR);
}// CToken.sol โ redeemFresh() with explicit reentrancy guard
uint private _reentrancyLock = 1; // โ
storage-based lock (not memory โ survives calls)
modifier nonReentrant() {
// โ
Explicit guard documents intent and prevents cross-function reentrancy
require(_reentrancyLock == 1, "reentrant call");
_reentrancyLock = 2;
_;
_reentrancyLock = 1;
}
function redeemFresh(address payable redeemer, uint redeemTokensIn,
uint redeemAmountIn) internal nonReentrant returns (uint) {
// โ
Guard added โ CEI pattern retained AND guard provides belt-and-suspenders
totalSupply = totalSupply - vars.redeemTokens;
accountTokens[redeemer] = accountTokens[redeemer] - vars.redeemTokens;
doTransferOut(redeemer, vars.redeemAmount);
emit Redeem(redeemer, vars.redeemAmount, vars.redeemTokens);
return uint(Error.NO_ERROR);
}A future market using an ERC-777-compliant token would expose a reentrancy surface. A supplier calls redeemUnderlying(), triggering a tokensReceived hook in an ERC-777 callback. The hook re-enters redeemUnderlying() โ at this point state reflects the first redemption, so the second redemption operates on reduced balances. Depending on rounding and balance checks, this could allow draining more than the full balance.
Add OpenZeppelin ReentrancyGuard or an equivalent reentrancy lock to redeemFresh() and borrowFresh(). This is a defense-in-depth measure โ the CEI pattern already provides meaningful protection, but explicit guards eliminate the surface entirely and document the intent.
COMP Delegation Voting Power Amplification
Comp.soldelegate()delegateBySig()COMP token delegation allows any holder to delegate their voting power to another address without transferring tokens. A single holder can only delegate to one address at a time, but delegation is not restricted to verified participants. A sophisticated actor holding a large COMP position could delegate to a proposal-passing threshold across multiple addresses they control, then rapidly consolidate delegation before a contentious proposal vote. The mechanism does not prevent this โ it is by design โ but it creates a governance attack surface where an actor holding below the proposal threshold can amplify apparent voting power through coordination.
// Comp.sol โ delegate()
function delegate(address delegatee) public {
// INFORMATIONAL: No restriction on who can be delegated to
// No time-lock on delegation changes
// No minimum holding period before delegated votes are counted
return _delegate(msg.sender, delegatee);
}// Comp.sol โ delegate() with optional time-weighted approach (illustrative)
// โ
This is one mitigation pattern โ tradeoffs exist for each approach
mapping(address => uint) public delegationTimestamp;
uint public constant DELEGATION_DELAY = 1 days;
function delegate(address delegatee) public {
// โ
Track delegation timestamp โ prevents flash delegation attacks
delegationTimestamp[msg.sender] = block.timestamp;
return _delegate(msg.sender, delegatee);
}
// โ
Modified getPriorVotes to weight by delegation age
// โ
Votes delegated within DELEGATION_DELAY count at partial weight
// Note: full implementation requires checkpoint integrationAn actor holding 200,000 COMP coordinates with a complicit second party holding 250,000 COMP. Both delegate to a single address before a vote. That address now controls 450,000 COMP votes โ above the 400,000 threshold to pass a proposal. After the vote, delegation is returned to original holders. No tokens changed hands, no on-chain record of coordination exists.
This is a design property of liquid governance, not a contract bug. Mitigation options include: time-weighted voting (votes count by duration of holding, reducing flash delegation effectiveness), two-step delegation with a delay, or off-chain constitutional rules against delegation coordination. The Compound community should document acceptable delegation practices explicitly.
Full Check Results Table
| # | Check | Category | Contract | Result | Note |
|---|---|---|---|---|---|
| 01 | Reentrancy in mint() | Reentrancy | CToken.sol | PASS | CEI pattern followed; state updated before doTransferIn() |
| 02 | Reentrancy in borrow() | Reentrancy | CToken.sol | PASS | borrowFresh() updates borrowBalance before external call |
| 03 | Reentrancy in redeemUnderlying() | Reentrancy | CToken.sol | WARN | CEI respected but no explicit nonReentrant guard โ see F-06 |
| 04 | Reentrancy in liquidateBorrow() | Reentrancy | CToken.sol | WARN | Cross-contract seize() call creates implicit reentrancy surface |
| 05 | Reentrancy in repayBorrow() | Reentrancy | CToken.sol | PASS | accrueInterest() first, state update before doTransferIn() |
| 06 | Access control on _setCollateralFactor() | Access Control | Comptroller.sol | WARN | Admin-only, no enforced Timelock โ see F-04 |
| 07 | Access control on _setPriceOracle() | Access Control | Comptroller.sol | WARN | Admin-only, no enforced delay in contract code โ see F-04 |
| 08 | Access control on _setComptrollerImplementation() | Access Control | Unitroller.sol | WARN | Admin can change implementation; current admin is Timelock (mitigated) |
| 09 | Access control on _setMintPaused() | Access Control | Comptroller.sol | PASS | Guardian-controlled with admin fallback โ appropriate emergency control |
| 10 | Access control on _setBorrowPaused() | Access Control | Comptroller.sol | PASS | Guardian-controlled, symmetric with mint pause |
| 11 | COMP reward index initialization | Reward Accounting | Comptroller.sol | FAIL | Index written before zero-check โ root cause of 2021 incident โ see F-01 |
| 12 | COMP accrual cap vs reservoir balance | Reward Accounting | Comptroller.sol | FAIL | No upper bound on claimable COMP โ see F-01 |
| 13 | COMP distribution rate update | Reward Accounting | Comptroller.sol | PASS | _setCompSpeed() guarded by admin; properly updates supply/borrow state |
| 14 | Oracle price staleness | Oracle | Comptroller.sol | WARN | No staleness check on returned price โ see F-03 |
| 15 | Oracle price bounds check | Oracle | Comptroller.sol | FAIL | No sanity bounds โ any non-zero oracle value accepted โ see F-03 |
| 16 | Oracle admin change delay | Oracle | Comptroller.sol | WARN | Oracle replaceable without Timelock in contract code โ see F-04 |
| 17 | Integer overflow in interest accrual | Overflow | CToken.sol | PASS | CarefulMath used throughout accrueInterest(); error codes checked |
| 18 | Integer overflow in exchange rate | Overflow | CToken.sol | PASS | exchangeRateStoredInternal() uses safe division via Exponential.sol |
| 19 | Integer overflow in liquidation seize calc | Overflow | Comptroller.sol | PASS | liquidateCalculateSeizeTokens() uses mul_() / div_() with overflow checks |
| 20 | Proposal threshold manipulation | Governance | GovernorBravo.sol | WARN | 400K COMP threshold โ achievable by sophisticated actors; see F-07 |
| 21 | Proposal veto mechanism | Governance | GovernorBravo.sol | FAIL | No guardian veto; queued proposals cannot be blocked โ see F-02 |
| 22 | Proposal cancellation by non-proposer | Governance | GovernorBravo.sol | FAIL | Only proposer can cancel; community has no emergency cancel mechanism |
| 23 | Vote delegation security | Governance | Comp.sol | WARN | Delegation without time lock โ see F-07 |
| 24 | EIP-712 signature replay protection | Governance | GovernorBravo.sol | PASS | Chain ID in domain separator; per-voter nonce tracking implemented |
| 25 | ERC-20 transfer return value handling | Token Standard | CToken.sol | PASS | doTransferIn/doTransferOut check return values and handle false-returning tokens |
| 26 | ERC-20 approve race condition | Token Standard | Comp.sol | WARN | Standard approve() without increaseAllowance โ known ERC-20 footgun |
| 27 | COMP infinite allowance exposure | Token Standard | Comp.sol | WARN | Standard ERC-20 approve() allows unlimited spend approvals by users |
| 28 | cDAI ERC-20 compliance | Token Standard | CToken.sol | PASS | transfer() and transferFrom() emit events and update balances correctly |
| 29 | Unitroller delegatecall safety | Upgradeability | Unitroller.sol | WARN | Storage layout compatibility not enforced โ migration risk on impl upgrade |
| 30 | cToken immutability | Upgradeability | CToken.sol | PASS | cToken core logic non-upgradeable; depositor trust preserved |
| 31 | Admin key transfer | Upgradeability | Comptroller.sol | PASS | _setPendingAdmin / _acceptAdmin two-step pattern prevents accidental transfer |
| 32 | Gas: SLOAD in market loop | Gas | Comptroller.sol | WARN | getAccountLiquidity() loops over all markets; repeated storage reads |
| 33 | Gas: memory vs storage in view functions | Gas | Comptroller.sol | WARN | Some local structs use storage when memory would be cheaper |
| 34 | Gas: event emission completeness | Gas | CToken.sol | PASS | All major state transitions (mint, redeem, borrow, repay) emit events |
| 35 | Flash loan composability risk | DeFi Risk | Comptroller.sol | WARN | No flash loan protection; oracle manipulation vector exists โ see F-03 |
| 36 | Liquidation incentive under congestion | DeFi Risk | Comptroller.sol | WARN | 8% bonus may not cover gas in extreme congestion; no adaptive incentive |
| 37 | Emergency pause on COMP claims | Circuit Breaker | Comptroller.sol | FAIL | No pause mechanism for claimComp() โ amplified 2021 loss โ see F-05 |
| 38 | Emergency pause on markets | Circuit Breaker | Comptroller.sol | PASS | _setMintPaused, _setBorrowPaused, _setTransferPaused implemented correctly |
| 39 | Timelock delay on critical params | Governance | Timelock.sol | WARN | 2-day delay insufficient for community to analyze complex parameter changes |
| 40 | CarefulMath error code handling | Overflow | CToken.sol | PASS | Error codes consistently returned and checked throughout arithmetic operations |
| 41 | Borrower index accounting | Reward Accounting | Comptroller.sol | FAIL | distributeBorrowerComp() has same index initialization ordering bug as supplier version |
| 42 | accrueInterest() block number guard | Reentrancy | CToken.sol | PASS | Early return if accrualBlockNumber == currentBlockNumber prevents double-accrual |
| 43 | Cross-market liquidation state ordering | Reentrancy | CToken.sol | PASS | liquidateBorrowFresh() accrues both markets before any state changes |
Open-Source Tool Scan Results
Risk Summary Matrix
| Likelihood โ / Impact โ | Critical | High | Medium | Low |
|---|---|---|---|---|
| High | F-01Reward Accounting Error | F-05No Claim Pause | โ | โ |
| Medium | F-03Oracle Manipulation | F-02Governance Gate | F-04Admin Centralization | โ |
| Low | โ | โ | F-07Vote Amplification | F-06Reentrancy Guard |
Recommendations & Remediation
// distributeSupplierComp() โ Current (Vulnerable)
function distributeSupplierComp(address cToken, address supplier) internal {
CompMarketState storage supplyState = compSupplyState[cToken];
Double memory supplyIndex = Double({mantissa: supplyState.index});
Double memory supplierIndex = Double({mantissa: compSupplierIndex[cToken][supplier]});
// โ Writing index before zero-check โ initialization fires against wrong base
compSupplierIndex[cToken][supplier] = supplyIndex.mantissa;
if (supplierIndex.mantissa == 0 && supplyIndex.mantissa > 0) {
supplierIndex.mantissa = compInitialIndex;
}
Double memory deltaIndex = sub_(supplyIndex, supplierIndex);
uint supplierTokens = CToken(cToken).balanceOf(supplier);
uint supplierDelta = mul_(supplierTokens, deltaIndex);
// โ No cap โ can exceed reservoir balance
uint supplierAccrued = add_(compAccrued[supplier], supplierDelta);
compAccrued[supplier] = supplierAccrued;
}// distributeSupplierComp() โ Fixed
function distributeSupplierComp(address cToken, address supplier) internal {
CompMarketState storage supplyState = compSupplyState[cToken];
Double memory supplyIndex = Double({mantissa: supplyState.index});
Double memory supplierIndex = Double({mantissa: compSupplierIndex[cToken][supplier]});
// โ
Zero-check fires BEFORE index is written โ initialization is correct
if (supplierIndex.mantissa == 0 && supplyIndex.mantissa > 0) {
supplierIndex.mantissa = compInitialIndex;
}
// โ
Write happens after initialization โ ordering is correct
compSupplierIndex[cToken][supplier] = supplyIndex.mantissa;
Double memory deltaIndex = sub_(supplyIndex, supplierIndex);
uint supplierTokens = CToken(cToken).balanceOf(supplier);
uint supplierDelta = mul_(supplierTokens, deltaIndex);
uint supplierAccrued = add_(compAccrued[supplier], supplierDelta);
// โ
Cap to reservoir balance โ prevents distributing more than exists
uint reservoirBalance = EIP20Interface(getCompAddress()).balanceOf(address(this));
compAccrued[supplier] = supplierAccrued > reservoirBalance ? reservoirBalance : supplierAccrued;
emit DistributedSupplierComp(CToken(cToken), supplier, supplierDelta, supplyIndex.mantissa);
}// GovernorBravo.sol โ execute()
function execute(uint proposalId) external payable {
require(state(proposalId) == ProposalState.Queued, "not queued");
Proposal storage proposal = proposals[proposalId];
// โ No emergency stop โ queued proposals cannot be blocked
proposal.executed = true;
for (uint i = 0; i < proposal.targets.length; i++) {
timelock.executeTransaction{value: proposal.values[i]}(
proposal.targets[i], proposal.values[i],
proposal.signatures[i], proposal.calldatas[i], proposal.eta
);
}
emit ProposalExecuted(proposalId);
}// GovernorBravo.sol โ execute() + vetoProposal()
function execute(uint proposalId) external payable {
require(state(proposalId) == ProposalState.Queued, "not queued");
Proposal storage proposal = proposals[proposalId];
// โ
Veto check โ guardian can block during timelock window without governance cycle
require(!proposal.vetoed, "proposal vetoed by guardian");
proposal.executed = true;
for (uint i = 0; i < proposal.targets.length; i++) {
timelock.executeTransaction{value: proposal.values[i]}(
proposal.targets[i], proposal.values[i],
proposal.signatures[i], proposal.calldatas[i], proposal.eta
);
}
emit ProposalExecuted(proposalId);
}
// โ
Guardian has narrow power: block execution only, cannot pass new proposals
function vetoProposal(uint proposalId) external {
require(msg.sender == guardian, "not guardian");
require(state(proposalId) == ProposalState.Queued, "must be queued");
proposals[proposalId].vetoed = true;
emit ProposalVetoed(proposalId); // โ
Transparent โ community can verify and challenge
}// Comptroller.sol โ getAccountLiquidityInternal()
vars.oraclePrice = oracle.getUnderlyingPrice(asset);
// โ No staleness check โ accepts any non-zero price regardless of age
// โ No bounds check โ 100x price move accepted silently
if (vars.oraclePrice == 0) {
return (Error.PRICE_ERROR, 0, 0);
}// Comptroller.sol โ getAccountLiquidityInternal() with oracle safeguards
vars.oraclePrice = oracle.getUnderlyingPrice(asset);
if (vars.oraclePrice == 0) {
return (Error.PRICE_ERROR, 0, 0);
}
// โ
Require oracle freshness โ enforces data recency guarantee
(,, uint updatedAt,) = AggregatorV3Interface(oracleFeed[asset]).latestRoundData();
require(block.timestamp - updatedAt <= MAX_ORACLE_DELAY, "stale oracle");
// โ
Sanity bound โ revert if price deviates >50% from TWAP reference
uint twap = twapOracle.getUnderlyingPrice(asset);
require(vars.oraclePrice >= twap / 2 && vars.oraclePrice <= twap * 2, "price outlier");Disclaimer
This is a sample audit report produced by NanoLab for demonstration purposes. Compound V2 is a publicly deployed, historically significant DeFi protocol. The vulnerabilities described herein reflect analysis of the current verified contract source code on Ethereum mainnet. The historical incident referenced in the introduction is documented context only. This report does not constitute a current security certification of any Compound contracts, and does not represent a claim that NanoLab would have identified these issues prior to the September 2021 incident. NanoLab makes no warranty regarding the completeness of this analysis. Smart contract audits reduce risk โ they do not eliminate it.
Ready to Audit Your Protocol?
Get the same depth of analysis applied to your codebase. Full report including PDF, architecture review, annotated code findings, and remediation guidance.
Get Your Smart Contract Audited