--- name: reentrancy-audit description: > Audit Solidity smart contracts for reentrancy vulnerabilities. Use when reviewing contracts that transfer ETH, call external contracts, or interact with ERC-20/ERC-721 tokens. Finds classic reentrancy, cross-function reentrancy, cross-contract reentrancy, read-only reentrancy, and ERC-777/ERC-1155 callback exploits. Returns findings with exact line citations and PoC exploit traces. --- # Reentrancy Audit ## Core principle: Checks-Effects-Interactions (CEI) All state changes MUST happen before any external call or ETH transfer. ## Phase 1: Identify External Calls Flag every: - `address.call{value:}()` - `address.transfer()` / `address.send()` - `IERC20.transfer()` / `transferFrom()` - `IERC721.safeTransferFrom()` / `onERC721Received()` callback - `IERC1155.safeTransferFrom()` / `onERC1155Received()` callback - `IUniswapV2Pair.swap()` — callback to `uniswapV2Call()` - Any interface call to an untrusted address ## Phase 2: Classic Reentrancy ```solidity // 🚨 State update AFTER external call function withdraw(uint256 amount) external { require(balances[msg.sender] >= amount); (bool ok,) = msg.sender.call{value: amount}(""); // ← external call first require(ok); balances[msg.sender] -= amount; // ← state update after — reentrant! } // ✅ CEI order function withdraw(uint256 amount) external { require(balances[msg.sender] >= amount); balances[msg.sender] -= amount; // effects first (bool ok,) = msg.sender.call{value: amount}(""); // then interact require(ok); } ``` ## Phase 3: Cross-Function Reentrancy Check if the reentrant call can invoke a *different* function that reads stale state: ```solidity // 🚨 withdraw() and transfer() share balances — cross-function exploit function withdraw() external { uint256 bal = balances[msg.sender]; (bool ok,) = msg.sender.call{value: bal}(""); // attacker reenters transfer() balances[msg.sender] = 0; } function transfer(address to, uint256 amt) external { require(balances[msg.sender] >= amt); // ← stale balance still > 0 balances[to] += amt; balances[msg.sender] -= amt; } ``` **Fix:** Use `ReentrancyGuard` on all functions sharing mutable state. ## Phase 4: Read-Only Reentrancy Particularly common with Curve/Balancer/Compound price oracles: ```solidity // 🚨 Price read during reentrancy window (state partially updated) function getPrice() external view returns (uint256) { return pool.get_virtual_price(); // Curve price — reentrant during add_liquidity callback } function liquidate(address user) external { uint256 price = getPrice(); // price is stale/manipulated if called during callback ... } ``` **Check:** Does the contract read price/balance from external pools that have reentrancy windows? Wrap reads with Curve's `nonreentrant` view or use a commit-reveal pattern. ## Phase 5: ERC-777 / ERC-1155 Hooks ```solidity // 🚨 ERC-777 tokensReceived hook called before state update function deposit(IERC777 token, uint256 amount) external { token.transferFrom(msg.sender, address(this), amount); // calls tokensReceived on sender balances[msg.sender] += amount; // ← executed after hook, attacker has re-entered } ``` **Check every `transferFrom`:** Is the token trusted/ERC-20-only? If token address is user-supplied, assume ERC-777. ## Phase 6: Lock Verification If `ReentrancyGuard` is used, verify: ```solidity // Check all external-call functions have the modifier function riskyFn() external nonReentrant { ... } // ✅ function otherFn() external { ... } // ❓ — shares state with riskyFn? ``` Missing `nonReentrant` on one function while present on another sharing state = still vulnerable. ## Output Format ``` ## [R-01] Classic reentrancy in withdraw() — Critical **Contract:** Vault.sol:47 **Impact:** Full ETH drain. Attacker deploys contract with fallback that re-calls withdraw(). **PoC trace:** 1. Attacker calls withdraw(1 ETH) 2. ETH sent → attacker fallback triggered 3. Attacker re-calls withdraw(1 ETH) — balances[attacker] still = 1 ETH 4. Repeat until vault empty **Fix:** Move `balances[msg.sender] -= amount` to before the .call{}() ```