Crypto Training

Compositional Rounding Attacks: When Safe Math Becomes Unsafe

Rounding bugs are usually not single-line bugs. They emerge when multiple safe-looking operations compose into a leak loop. This post shows how to model, test, and patch that class of failures.

Crypto Training2025-12-274 min read

Rounding bugs in production protocols usually look harmless in code review.

One line rounds down. Another line rounds up. Both lines are “defensible.”

The loss appears when these lines are chained across user flows that can be repeated under attacker control.

flowchart LR A[Deposit Path\nround down] --> B[Internal Accounting\nround half-up] B --> C[Withdrawal Path\nround up] C --> D[Net Dust Transfer\nUser gains drift] D --> E[Loop with bots] E --> F[Material leakage]

Why this class is hard to catch#

Teams often review each function locally.

Attackers execute the system globally.

Review lensTypical conclusionReality in incidents
Single operation“Dust is tiny”Dust compounds over loops
Single contract“No direct theft path”Composition creates theft path
Single block“Gas blocks abuse”Bundles + private order flow reduce cost

The right question is not “is this rounding choice reasonable?”

The right question is “who controls repetition count and path ordering?”

Threat model for compositional precision attacks#

sequenceDiagram actor Bot participant V as Vault participant R as Router participant P as Pool Bot->>V: deposit(min viable amount) V->>R: rebalance / swap R->>P: execute trade P-->>R: output with floor rounding R-->>V: settled amount V-->>Bot: shares with ceil rounding Bot->>V: withdraw via alternate path V-->>Bot: assets + accumulated drift

Attack prerequisites are usually:

  1. At least two rounding boundaries with different direction.
  2. A user-controlled loop surface.
  3. No hard lower bound on effective trade or share delta.

A minimal Solidity example of compositional leakage#

SOLIDITY
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

library MathX {
    function mulDivDown(uint256 x, uint256 y, uint256 d) internal pure returns (uint256) {
        return (x * y) / d;
    }

    function mulDivUp(uint256 x, uint256 y, uint256 d) internal pure returns (uint256) {
        if (x == 0) return 0;
        return ((x * y) - 1) / d + 1;
    }
}

contract DriftVault {
    using MathX for uint256;

    uint256 public totalAssets;
    uint256 public totalShares;
    mapping(address => uint256) public shareOf;

    function deposit(uint256 assets) external {
        uint256 shares = totalShares == 0
            ? assets
            : assets.mulDivUp(totalShares, totalAssets); // attacker-favorable here

        totalAssets += assets;
        totalShares += shares;
        shareOf[msg.sender] += shares;
    }

    function withdraw(uint256 shares) external {
        require(shareOf[msg.sender] >= shares, "insufficient");

        uint256 assets = shares.mulDivDown(totalAssets, totalShares); // opposite direction

        shareOf[msg.sender] -= shares;
        totalShares -= shares;
        totalAssets -= assets;

        // transfer(assets)
    }
}

This alone is not always fatal. It becomes exploitable when the attacker can route through other operations that move totals in favorable micro-steps.

Design rules that survive adversarial composition#

1) Choose and document a global rounding policy#

Do not let each engineer choose per function.

flowchart TD P[Policy file] --> D[deposit conversions] P --> W[withdraw conversions] P --> F[fee minting] P --> L[liquidation math] D --> T[Shared math library] W --> T F --> T L --> T

2) Normalize before division and keep domains consistent#

  • Scale to a common precision first.
  • Avoid division before multiplication unless you can prove no loss sensitivity.

3) Add minimum effective delta checks#

Reject operations where computed effect is zero or one-unit drift-prone.

SOLIDITY
uint256 shares = assets.mulDivDown(totalShares, totalAssets);
require(shares >= MIN_SHARES_OUT, "dust-op");

4) Treat composition as an invariant problem#

Test n-step sequences, not one-shot calls.

Invariants that catch this bug class#

SOLIDITY
function invariant_totalValueNotLeaking() public {
    uint256 before = model.systemValue();
    vm.roll(block.number + 1);
    fuzzedActorSequence();
    uint256 after_ = model.systemValue();

    // bounded for fees, unbounded leak is forbidden
    assertGe(after_ + model.accountedFees(), before);
}

Useful invariant families:

InvariantPurpose
Value conservation (net of explicit fees)Detects hidden drift transfer
Path equivalence (A→B vs direct)Detects compositional mismatch
Split/merge equivalenceDetects loopable dust extraction

Patch pattern#

  1. Replace scattered arithmetic with a single library.
  2. Encode direction per operation type.
  3. Add guardrails on minimum effective operation size.
  4. Add sequence fuzzing with attacker-controlled repetition count.

Video explainer#

Audit checklist#

  • Does any user path have opposite rounding direction from its inverse path?
  • Can the user repeat the path cheaply?
  • Can the user select inputs near precision boundaries?
  • Are there cross-contract conversions with mismatched decimal assumptions?

Further reading#