🌳 Advanced ⏱️ 50 min

Logic & Business Logic Bugs

Logic bugs are the hardest category of smart contract vulnerabilities to find. Unlike reentrancy or overflow — where a detector can mechanically flag suspicious patterns — logic bugs require understanding what the protocol is supposed to do and verifying that the code does exactly that. The code compiles, deploys, and runs without errors. It just does the wrong thing. These bugs consistently cause the largest financial losses because no static analysis tool catches them, and they often survive multiple audits.

🚨 Critical

Logic bugs are responsible for the largest DeFi losses that weren't immediately attributable to known vulnerability classes. Compound's governance bug distributed $80M in unexpected COMP rewards. Yearn's yDAI accounting error lost millions. These bugs are invisible to automated tools — finding them requires deep protocol understanding.

What Logic Bugs Look Like

Logic bugs manifest as: the contract does what it was written to do, but what it was written to do is wrong. The code faithfully executes an incorrect specification. There are five main subcategories: incorrect accounting, state machine errors, rounding direction errors, missing invariant checks, and incompatible token handling.

Type 1: Incorrect Accounting

⚠️ Incorrect Accounting — Fee Not Deducted from Protocol Balance
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract BrokenFeeAccounting { uint256 public totalDeposits; uint256 public protocolFees; mapping(address => uint256) public userDeposits; uint256 public constant FEE_RATE = 100; // 1% (basis points) function deposit() external payable { uint256 fee = msg.value / FEE_RATE; uint256 netDeposit = msg.value - fee; // ❌ BUG: totalDeposits tracks msg.value (GROSS) not netDeposit // But only netDeposit is credited to user // Protocol: total tracked = 100 ETH, actual user claims = 99 ETH // After 100 users: tracked = 10000 ETH, fees collected = 100 ETH // Users can withdraw 9900 ETH but totalDeposits says 10000 ETH // Any report/invariant using totalDeposits is wrong totalDeposits += msg.value; // ❌ Should be += netDeposit protocolFees += fee; userDeposits[msg.sender] += netDeposit; } // ✅ FIXED: Track net deposits consistently function depositFixed() external payable { uint256 fee = msg.value / FEE_RATE; uint256 netDeposit = msg.value - fee; totalDeposits += netDeposit; // ✅ Tracks net, not gross protocolFees += fee; userDeposits[msg.sender] += netDeposit; } function withdraw(uint256 amount) external { require(userDeposits[msg.sender] >= amount, "Insufficient balance"); userDeposits[msg.sender] -= amount; totalDeposits -= amount; (bool ok,) = msg.sender.call{value: amount}(""); require(ok); } }

Type 2: State Machine Bugs

⚠️ State Machine — Invalid Transitions
contract BrokenAuction { enum State { Open, Closed, Settled } State public state; uint256 public endTime; address public highestBidder; uint256 public highestBid; function bid() external payable { // ❌ Bug 1: Does not check state is Open // Users can bid on a Closed or Settled auction require(msg.value > highestBid, "Bid too low"); highestBidder = msg.sender; highestBid = msg.value; } function close() external { // ❌ Bug 2: Can be called before endTime // No check that auction period has elapsed state = State.Closed; } function settle() external { // ❌ Bug 3: Can settle before closing // State machine allows Open → Settled (skips Closed) require(state != State.Settled, "Already settled"); state = State.Settled; (bool ok,) = highestBidder.call{value: address(this).balance}(""); require(ok); } // ✅ Fixed state machine — enforces correct transitions function bidFixed() external payable { require(state == State.Open, "Auction not open"); require(block.timestamp < endTime, "Auction ended"); require(msg.value > highestBid, "Bid too low"); highestBidder = msg.sender; highestBid = msg.value; } function closeFixed() external { require(state == State.Open, "Not open"); require(block.timestamp >= endTime, "Not ended yet"); state = State.Closed; } function settleFixed() external { require(state == State.Closed, "Must be closed first"); state = State.Settled; (bool ok,) = highestBidder.call{value: address(this).balance}(""); require(ok); } }

Type 3: Rounding Direction Errors

⚠️ Wrong Rounding Direction — Systematic Insolvency
contract LendingPool { uint256 public totalAssets; uint256 public totalShares; // Converting assets to shares (deposit) function deposit(uint256 assets) external returns (uint256 shares) { // ❌ Rounds UP when minting shares to depositor // Each depositor gets slightly more shares than they deserve // Over time: totalShares inflates faster than totalAssets // Each share is worth less than it should be → insolvency risk shares = (assets * totalShares + totalAssets - 1) / totalAssets; // ❌ Round UP totalShares += shares; totalAssets += assets; } // ✅ CORRECT: Round DOWN when minting shares (favors protocol) function depositFixed(uint256 assets) external returns (uint256 shares) { shares = (assets * totalShares) / totalAssets; // ✅ Round DOWN totalShares += shares; totalAssets += assets; } // Rounding direction rules for lending protocols: // Deposit (mint shares): Round DOWN → depositor gets fewer shares → safer // Withdraw (burn shares): Round UP → user burns more shares → safer // Borrow (debt tracking): Round UP → user owes slightly more → safer // Collateral value: Round DOWN → collateral worth less → safer }

Type 4: ERC4626 Share Price Manipulation (Inflation Attack)

⚠️ ERC4626 Inflation Attack — First Depositor Manipulation
// The classic ERC4626 share price inflation attack: // SETUP: New vault deployed, totalShares = 0, totalAssets = 0 // STEP 1: Attacker deposits 1 wei // shares minted = 1 wei (1:1 ratio when vault is empty) // Vault: totalShares = 1, totalAssets = 1 // STEP 2: Attacker DIRECTLY transfers 1000 ETH to vault (not via deposit) // This inflates totalAssets WITHOUT minting shares // Vault: totalShares = 1, totalAssets = 1000 ETH + 1 wei // Share price: 1000 ETH per share! // STEP 3: Victim deposits 1999 ETH // shares = 1999 ETH * 1 / (1000 ETH + 1 wei) = 1 (integer division!) // Victim gets 1 share, attacker has 1 share // Each share is now worth (1999 + 1000 ETH) / 2 = 1499.5 ETH // STEP 4: Attacker redeems their 1 share for 1499.5 ETH // Attacker spent: 1 wei deposit + 1000 ETH donation = 1000 ETH // Attacker received: 1499.5 ETH → profit of ~499.5 ETH // Victim deposited 1999 ETH and gets back 1499.5 ETH → lost 499.5 ETH // ✅ FIX 1: Virtual shares (OpenZeppelin ERC4626 uses this) // Initialize with virtual offset: totalShares = 1, totalAssets = 1 // Makes manipulation cost-prohibitive without affecting real users // ✅ FIX 2: Minimum deposit amount — prevents 1-wei initial deposits function deposit(uint256 assets) external returns (uint256 shares) { require(assets >= 1000, "Minimum deposit required"); // ... }

Type 5: Fee-on-Transfer Token Incompatibility

⚠️ Fee-on-Transfer Token Bug
contract FoTTokenVulnerable { mapping(address => uint256) public depositedAmount; IERC20 public token; // ❌ Assumes amount received == amount specified // Fee-on-transfer tokens deduct a fee during transfer // Example: SAFEMOON has a 10% transfer tax // User deposits 100 SAFEMOON → protocol receives 90 SAFEMOON // But depositedAmount[user] = 100 → user can withdraw 100 → protocol is insolvent function deposit(uint256 amount) external { token.transferFrom(msg.sender, address(this), amount); depositedAmount[msg.sender] += amount; // ❌ Records gross, not net } // ✅ FIXED: Measure actual received amount using balance change function depositFixed(uint256 amount) external { uint256 balanceBefore = token.balanceOf(address(this)); token.transferFrom(msg.sender, address(this), amount); uint256 balanceAfter = token.balanceOf(address(this)); // ✅ Only credit what was actually received uint256 actualReceived = balanceAfter - balanceBefore; depositedAmount[msg.sender] += actualReceived; } }

Type 6: Rebasing Token Issues

⚠️ Rebasing Token — Balance Changes Without Transfers
// Rebasing tokens (stETH, AMPL) change all holder balances automatically // based on an external factor (staking rewards, target price) // Protocol cannot track these balance changes if it only stores deposit amounts contract RebasingTokenVulnerable { mapping(address => uint256) public deposited; // Tracks at deposit time function deposit(uint256 amount) external { stETH.transferFrom(msg.sender, address(this), amount); deposited[msg.sender] = amount; // ❌ Stale after rebase } function withdraw() external { uint256 amount = deposited[msg.sender]; // After a positive rebase: contract holds MORE than sum of deposits // First withdrawers get exact amount; last withdrawers get less (or nothing) stETH.transfer(msg.sender, amount); } // ✅ FIX: Track shares in the rebasing token, not absolute amounts // stETH uses shares — convert to shares on deposit, back to tokens on withdraw mapping(address => uint256) public shares; function depositFixed(uint256 amount) external { uint256 sharesReceived = stETH.getSharesByPooledEth(amount); stETH.transferFrom(msg.sender, address(this), amount); shares[msg.sender] += sharesReceived; // ✅ Track shares — immune to rebase } IStETH stETH = IStETH(address(0)); }

How to Find Logic Bugs: Invariant Testing

🧪 Foundry Invariant Test — Properties That Must Always Hold
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "forge-std/Test.sol"; import "./LendingPool.sol"; // Handler contract: provides inputs to the fuzzer contract LendingPoolHandler { LendingPool public pool; address[] public actors; function deposit(uint256 actorIndex, uint256 amount) external { address actor = actors[actorIndex % actors.length]; amount = bound(amount, 1, 1000 ether); vm.prank(actor); pool.deposit{value: amount}(); } function withdraw(uint256 actorIndex, uint256 amount) external { address actor = actors[actorIndex % actors.length]; amount = bound(amount, 1, pool.userDeposits(actor)); vm.prank(actor); pool.withdraw(amount); } } contract LendingPoolInvariantTest is Test { LendingPool public pool; LendingPoolHandler public handler; function setUp() public { pool = new LendingPool(); handler = new LendingPoolHandler(pool); targetContract(address(handler)); } /// @notice Invariant 1: Sum of user deposits must equal total tracked deposits function invariant_userDepositsSumEqualsTotal() public { uint256 sumOfDeposits = 0; address[] memory actors = handler.getActors(); for (uint256 i = 0; i < actors.length; ) { sumOfDeposits += pool.userDeposits(actors[i]); unchecked { ++i; } } assertEq(sumOfDeposits, pool.totalDeposits(), "Deposit sum mismatch"); } /// @notice Invariant 2: Total deposits must not exceed actual ETH balance function invariant_totalDepositsCoveredByBalance() public { assertLe( pool.totalDeposits(), address(pool).balance, "Protocol insolvent: tracking more than it holds" ); } /// @notice Invariant 3: Protocol fees should be non-negative function invariant_feesNonNegative() public { assertGe(pool.protocolFees(), 0, "Negative fees impossible"); } }
💡 Tip

The most effective way to find logic bugs is to write down all invariants before you read the code — properties that must be true for the protocol to be solvent and correct. Then check whether the code enforces each one. Invariants for a lending protocol: totalDebt never exceeds totalAssets, no user's withdrawable balance exceeds their deposit, fee accounting is exact. If the code doesn't enforce these, you have a bug.

How to Audit Logic Bugs: A Process

🔍 Logic Bug Audit Checklist
// STEP 1: Read the protocol documentation and whitepaper FIRST // Understand the intended behavior before reading the code // Any deviation between spec and code is a candidate logic bug // STEP 2: List all state variables and their intended invariants // totalDebt: the total amount currently borrowed across all users // totalAssets: the total value locked in the protocol // Invariant: totalDebt <= totalAssets at all times (solvency) // STEP 3: Trace every path that changes critical state variables // For each variable: when does it increase? When does it decrease? // Are the increases and decreases symmetric? // Is there any path that changes one without the other? // STEP 4: Look for rounding asymmetries // When you deposit: does rounding favor user or protocol? // When you withdraw: does rounding favor user or protocol? // If BOTH favor the user: protocol gradually becomes insolvent // STEP 5: Identify special tokens that might break assumptions // Fee-on-transfer: SAFEMOON, PAXG (older version) // Rebasing: stETH, AMPL, OHM // Non-standard decimals: USDC (6), WBTC (8) vs typical 18 // Tokens that can pause transfers: many centralized stablecoins // STEP 6: Write invariant tests and let the fuzzer find violations // forge test --match-contract InvariantTest // Let it run for thousands of sequences and check invariants hold

Real-World Logic Bug Exploits

ProtocolYearLossBug TypeRoot Cause
Compound COMP2021$80MIncorrect accountingCOMP distribution formula had a bug; users received far more than intended
Yearn yDAI2021$11MState machineAccounting error in harvest() allowed users to withdraw more than deposited
CREAM Finance2021$18.8MReentrancy + accountingERC77 callback allowed account balances to be read while mid-transfer
Rari Capital2022$80MLogicReentrancy bug in Fuse pool withdraw allowed more than deposited
Nomad Bridge2022$190MLogicInitialization set trusted root to 0x00; any message with 0 as previous root was auto-approved

Key Takeaways

  • Logic bugs require understanding protocol intent. No tool flags them automatically. You must read the specification, understand the invariants, and verify the code maintains them in every possible execution path.
  • Write invariant tests before auditing code. List every property that must hold for the protocol to be correct and solvent. These become Foundry invariant tests that the fuzzer can break automatically.
  • Rounding direction matters profoundly in DeFi. Every division must round in the direction that protects the protocol, not the user. Consistent wrong rounding causes gradual insolvency that may not be detected until the protocol is drained.
  • Assume all tokens behave non-standardly unless proven otherwise. Fee-on-transfer and rebasing tokens break the most common DeFi accounting assumptions. Always measure actual balances received, not amounts specified.
  • State machines must have explicit transition checks on every function. Validate that the current state is correct before allowing any state transition. A function that can be called in the wrong state is a logic bug.
  • The ERC4626 inflation attack affects any vault with unbounded first deposit. Use virtual shares or minimum deposit requirements to prevent the first depositor from manipulating the share price.

Business Logic Assumptions That Break at Scale

Logic bugs often emerge not from incorrect code but from correct code that fails under conditions the developer never tested. Assumptions that hold in unit tests with small numbers silently break with real user volumes, large amounts, or adversarial inputs.

Percentage Calculation That Breaks Near Zero
// ❌ VULNERABLE: Percentage fee rounds to zero for small amounts contract FeeVault { uint256 public constant FEE_BPS = 30; // 0.3% function deposit(uint256 amount) external { uint256 fee = (amount * FEE_BPS) / 10000; // BUG: If amount < 334, fee = 0 due to integer division // Attacker deposits 333 wei 1000 times → zero fee each time _collectFee(fee); _credit(msg.sender, amount - fee); } } // ✅ FIXED: Enforce minimum fee or minimum deposit amount contract FeeVaultFixed { uint256 public constant FEE_BPS = 30; uint256 public constant MIN_FEE = 1; uint256 public constant MIN_DEPOSIT = 1000; function deposit(uint256 amount) external { require(amount >= MIN_DEPOSIT, "Deposit too small"); uint256 fee = (amount * FEE_BPS) / 10000; fee = fee < MIN_FEE ? MIN_FEE : fee; // ✅ Enforce minimum require(amount > fee, "Fee exceeds amount"); _collectFee(fee); _credit(msg.sender, amount - fee); } }

Off-by-One Errors in Time Windows

Time-based logic is particularly prone to off-by-one boundary errors. Whether the boundary condition is inclusive or exclusive matters enormously for security properties like vesting cliff periods, voting deadlines, and cooldown timers.

Vesting Cliff Off-by-One Vulnerability
contract VestingContract { uint256 public vestStart; uint256 public constant CLIFF = 365 days; uint256 public constant DURATION = 1460 days; // 4 years // ❌ VULNERABLE: Uses > instead of >= for cliff check // At exactly vestStart + CLIFF, the cliff check fails function vestedAmount() public view returns (uint256) { if (block.timestamp > vestStart + CLIFF) { // BUG: should be >= uint256 elapsed = block.timestamp - vestStart; return (totalAllocation * elapsed) / DURATION; } return 0; } // ✅ FIXED: Inclusive boundary — at exactly cliff time, cliff is passed function vestedAmountFixed() public view returns (uint256) { if (block.timestamp >= vestStart + CLIFF) { // ✅ Inclusive uint256 elapsed = block.timestamp - vestStart; uint256 capped = elapsed > DURATION ? DURATION : elapsed; return (totalAllocation * capped) / DURATION; } return 0; } }

Logic Bug Detection Strategies

Bug TypeDetection TechniqueTool
Accounting errorsInvariant: totalDebt == sum(userDebts)Foundry invariant tests
Rounding directionFuzz test: deposit then withdraw → never profitFoundry fuzz tests
State machine bugsEnumerate all state transitions, check invalid ones revertFormal verification (Certora)
Fee-on-transfer tokensUse weird-erc20 test tokens in integration testsFoundry fork tests
Off-by-one in timeTest at exactly the boundary timestamp with vm.warpFoundry vm.warp
ERC4626 inflationInvariant: first depositor cannot profit at others' expenseFoundry invariant tests
Share/balance divergenceTrack expected vs actual amounts for all actionsEchidna property tests

Key Takeaways

  • Logic bugs are the hardest vulnerability class to detect with automated tools — they require understanding of intended behavior, not just code correctness.
  • Accounting invariants (total assets equal sum of individual claims) should be tested continuously with Foundry's invariant test framework.
  • Rounding direction is a security property: round in favor of the protocol (up for debts, down for withdrawals) to prevent accumulation of extractable value.
  • The ERC4626 share price inflation attack is mitigated by virtual shares (e.g., OpenZeppelin's default 1000 virtual shares) — always use standard implementations.
  • Fee-on-transfer tokens, rebasing tokens, and tokens with blacklists are integration hazards — explicitly document which token types your protocol supports.
  • State machines should be formally specified before implementation — every transition should be deliberate, and transitions not in the spec should be impossible.