← Back to Blog

Solidity Integer Division: Precision Loss, Truncation, and Rounding Vulnerabilities

2026-04-18 arithmetic precision loss truncation division rounding fixed-point solidity security

Solidity has no floating-point numbers. Every integer division truncates toward zero, silently dropping the remainder. 5 / 2 equals 2, not 2.5. 1 / 100 equals 0, not 0.01. Most developers know this — yet truncation remains one of the most persistent sources of financial bugs in DeFi.

The problem is that truncation errors compound in non-obvious ways. A rounding loss of a few wei per operation seems harmless. Accumulated across thousands of users, millions of blocks, and high-frequency reward distributions, those errors aggregate into significant losses. Some patterns go further: an attacker who understands the truncation threshold can bypass fees entirely or extract disproportionate value without breaking any protocol rule.

This post covers six truncation vulnerability patterns drawn from real audits and post-mortems. For each pattern you will see the vulnerable code, how the precision error manifests, the correct fix, and detection tips.


1. Division Before Multiplication

When a formula requires both division and multiplication, the order determines how much precision is lost. Dividing first and multiplying second introduces an avoidable rounding error.

// VULNERABLE: division before multiplication loses precision
function calculateShare(uint256 amount, uint256 rate, uint256 basis) external pure returns (uint256) {
    // If amount = 1000, rate = 3, basis = 10:
    // (1000 / 10) * 3 = 100 * 3 = 300  (correct)
    // But if amount = 15, rate = 3, basis = 10:
    // (15 / 10) * 3 = 1 * 3 = 3  (should be 4.5, truncated to 4 minimum)
    return (amount / basis) * rate;
}

The intermediate result amount / basis truncates before rate is applied, amplifying the rounding error by rate. For small amount values relative to basis, the result can be dramatically lower than the correct answer.

// FIXED: multiply first, then divide
function calculateShare(uint256 amount, uint256 rate, uint256 basis) external pure returns (uint256) {
    // For amount = 15, rate = 3, basis = 10:
    // (15 * 3) / 10 = 45 / 10 = 4  (one truncation, not amplified)
    return (amount * rate) / basis;
}

Multiplying first keeps full precision and applies truncation only once at the end. The caveat is overflow: amount * rate must not exceed type(uint256).max. For very large values, use Uniswap V3's FullMath.mulDiv which handles 512-bit intermediate arithmetic.

Detection tips: Search for any expression matching (x / y) * z where all three variables are integers. Flag every instance and verify that the multiplication cannot be performed before the division. Check whether overflow protection is needed if the order is reversed.


2. Fee Calculation Rounding to Zero

Protocols that charge fees on small transactions are vulnerable to complete fee bypass when the fee formula truncates to zero. An attacker who knows the threshold can structure transactions to pay no fees at all.

// VULNERABLE: fee truncates to zero for small amounts
contract FeeCollector {
    uint256 public constant FEE_BPS = 30; // 0.3%
    uint256 public constant BPS_DENOMINATOR = 10_000;

    function deposit(uint256 amount) external {
        uint256 fee = (amount * FEE_BPS) / BPS_DENOMINATOR;
        // For amount = 333: (333 * 30) / 10000 = 9990 / 10000 = 0
        // Attacker deposits 333 units repeatedly, paying zero fees
        uint256 netAmount = amount - fee;
        _processDeposit(msg.sender, netAmount);
    }
}

With FEE_BPS = 30, any amount below 334 produces a fee of zero. The bypass threshold is BPS_DENOMINATOR / FEE_BPS - 1 — here 333 — trivially computable by any attacker who splits a large deposit into many sub-threshold calls.

// FIXED: enforce minimum fee or reject sub-threshold amounts
contract FeeCollector {
    uint256 public constant FEE_BPS = 30;
    uint256 public constant BPS_DENOMINATOR = 10_000;
    uint256 public constant MIN_FEE = 1; // at least 1 unit of fee

    function deposit(uint256 amount) external {
        uint256 fee = (amount * FEE_BPS) / BPS_DENOMINATOR;
        fee = fee < MIN_FEE ? MIN_FEE : fee;
        require(amount > fee, "Amount too small");
        uint256 netAmount = amount - fee;
        _processDeposit(msg.sender, netAmount);
    }
}

Alternatively, enforce a minimum deposit size that guarantees a non-zero fee.

Detection tips: For every fee formula, compute the largest amount that produces a zero fee and check whether that amount is reachable in practice. Look for loops or batching patterns that could split a large operation into many sub-threshold calls.


3. Reward Per Token Precision Loss

Staking contracts track rewards via an accumulator: rewardPerTokenStored grows each block by rewardRate / totalSupply. When the reward rate is small relative to total supply, every update truncates to zero and the accumulator never grows — stakers receive nothing while the protocol believes rewards are being distributed.

// VULNERABLE: reward accumulator loses precision at scale
contract StakingRewards {
    uint256 public rewardPerTokenStored;
    uint256 public lastUpdateTime;
    uint256 public rewardRate; // tokens per second
    uint256 public totalSupply;

    function rewardPerToken() public view returns (uint256) {
        if (totalSupply == 0) return rewardPerTokenStored;
        return rewardPerTokenStored +
            ((block.timestamp - lastUpdateTime) * rewardRate) / totalSupply;
            // If rewardRate = 1e15 and totalSupply = 1e24:
            // 1 second update: (1 * 1e15) / 1e24 = 0
            // Accumulator never increases — all rewards lost
    }
}

Over millions of update cycles every step truncates to zero, the total reward is lost, and rewardPerTokenStored remains stale indefinitely.

// FIXED: scale the accumulator with a precision multiplier
contract StakingRewards {
    uint256 public constant PRECISION = 1e18;
    uint256 public rewardPerTokenStored; // stored scaled by PRECISION
    uint256 public lastUpdateTime;
    uint256 public rewardRate;
    uint256 public totalSupply;

    function rewardPerToken() public view returns (uint256) {
        if (totalSupply == 0) return rewardPerTokenStored;
        return rewardPerTokenStored +
            ((block.timestamp - lastUpdateTime) * rewardRate * PRECISION) / totalSupply;
    }

    function earned(address account) public view returns (uint256) {
        return (balanceOf[account] *
            (rewardPerToken() - userRewardPerTokenPaid[account])) / PRECISION;
        // Divide out the precision multiplier only at the final payout step
    }
}

Scaling by 1e18 before dividing preserves 18 decimal places in the accumulator. The multiplier is divided out only at the final payout step, keeping truncating divisions to a minimum.

Detection tips: Find every division that produces a per-unit accumulator value. Check the ratio of the numerator to the denominator under realistic mainnet conditions (high total supply, low reward rate). If the quotient can be zero for a meaningful time window, a precision multiplier is required.


4. Share Price Manipulation via Early Truncation

ERC-4626 vaults calculate share prices as assets / totalShares. Without a precision multiplier, an early depositor can donate assets directly to the vault, inflate the share price, and cause subsequent depositors to receive fewer shares than expected — sometimes rounding down to zero.

// VULNERABLE: share calculation without virtual offset or precision scaling
contract NaiveVault {
    uint256 public totalShares;
    uint256 public totalAssets;

    function deposit(uint256 assets) external returns (uint256 shares) {
        if (totalShares == 0) {
            shares = assets; // first depositor sets 1:1 ratio
        } else {
            shares = (assets * totalShares) / totalAssets;
            // Attacker deposits 1 wei first (totalShares = 1, totalAssets = 1)
            // Then donates 1e18 assets directly (totalAssets = 1e18 + 1)
            // Next user deposits 1e18 - 1 assets:
            // shares = ((1e18 - 1) * 1) / (1e18 + 1) = 0 — victim gets nothing
        }
        totalShares += shares;
        totalAssets += assets;
    }
}

The attacker controls totalAssets via a direct transfer, making legitimate share calculations truncate to zero, then redeems their single share for all vault assets.

// FIXED: virtual shares and virtual assets prevent the attack
contract SafeVault {
    uint256 public totalShares;
    uint256 public totalAssets;
    uint256 private constant VIRTUAL_SHARES = 1e3;
    uint256 private constant VIRTUAL_ASSETS = 1e3;

    function convertToShares(uint256 assets) public view returns (uint256) {
        return (assets * (totalShares + VIRTUAL_SHARES)) /
               (totalAssets + VIRTUAL_ASSETS);
    }

    function deposit(uint256 assets) external returns (uint256 shares) {
        shares = convertToShares(assets);
        require(shares > 0, "Zero shares");
        totalShares += shares;
        totalAssets += assets;
    }
}

The virtual offset approach, used in OpenZeppelin's ERC-4626 implementation, adds a constant to both numerator and denominator, making manipulation prohibitively expensive.

Detection tips: Audit every vault deposit function for share calculations that do not include virtual offsets or a precision multiplier of at least 1e3. Check whether the contract accepts direct token transfers that bypass deposit(), as these inflate totalAssets without minting shares.


5. Percentage Calculation Overflow Risk

The standard basis-point pattern amount * bps / 10_000 overflows uint256 for large amount values before the division reduces the product to a safe size. Solidity 0.8+ reverts on the overflow rather than producing a wrong result, but unchecked blocks or older compilers silently wrap.

// VULNERABLE: intermediate overflow for large amounts
contract Treasury {
    function applyFee(uint256 amount, uint256 feeBps) external pure returns (uint256) {
        // If amount = type(uint256).max / 2 and feeBps = 5000 (50%):
        // amount * feeBps overflows uint256 before / 10_000 reduces it
        uint256 fee = amount * feeBps / 10_000;
        return amount - fee;
    }
}

This reverts for any amount > type(uint256).max / feeBps. Protocols handling 18-decimal token amounts with large nominal values can hit this limit in production.

// FIXED: use mulDiv for overflow-safe percentage calculation
import "@openzeppelin/contracts/utils/math/Math.sol";

contract Treasury {
    function applyFee(uint256 amount, uint256 feeBps) external pure returns (uint256) {
        // Math.mulDiv computes (amount * feeBps) / 10_000 without intermediate overflow
        // using a 512-bit intermediate representation
        uint256 fee = Math.mulDiv(amount, feeBps, 10_000);
        return amount - fee;
    }
}

Both Math.mulDiv and Uniswap's FullMath.mulDiv use 512-bit intermediate arithmetic and are gas-efficient. Either should be the default for any multiplication-then-division where the product could approach type(uint256).max.

Detection tips: For every a * b / c pattern, compute the maximum realistic values of a and b and check whether their product fits in uint256. Pay special attention to fee and percentage calculations applied to unbounded token amounts. Flag any such pattern inside an unchecked block regardless of apparent safety.


6. Cumulative Rounding in Loops

A loop that divides inside each iteration compounds truncation across every step. The same computation performed as a single batch division truncates only once and produces a more accurate result.

// VULNERABLE: per-iteration division compounds rounding errors
contract RewardDistributor {
    function distributeRewards(
        address[] calldata recipients,
        uint256 totalReward
    ) external {
        uint256 count = recipients.length;
        for (uint256 i = 0; i < count; i++) {
            // Each iteration truncates independently
            uint256 share = totalReward / count;
            // For totalReward = 100, count = 3:
            // share = 33 each iteration, 1 wei lost per call
            // Over 1000 calls: 1000 wei of unclaimable reward accumulates
            _sendReward(recipients[i], share);
        }
    }
}

When totalReward = 100 and count = 3, each iteration yields 33, leaving 1 wei permanently locked. Across high-frequency distributions the trapped value grows, while protocol accounting shows the full total as distributed.

// FIXED: track remainder and assign it to the last recipient
contract RewardDistributor {
    function distributeRewards(
        address[] calldata recipients,
        uint256 totalReward
    ) external {
        uint256 count = recipients.length;
        require(count > 0, "No recipients");
        uint256 share = totalReward / count;
        uint256 remainder = totalReward - (share * count);

        for (uint256 i = 0; i < count; i++) {
            uint256 payout = (i == count - 1) ? share + remainder : share;
            _sendReward(recipients[i], payout);
        }
        // Total distributed = share * (count - 1) + share + remainder = totalReward
    }
}

Assigning the remainder to the last recipient ensures the full totalReward is always distributed with no wei stranded.

Detection tips: Search for division operations inside for or while loops where the divisor is a loop-invariant value. Verify that the sum of all loop iterations equals the intended total. Also look for staking contracts where per-block reward fractions accumulate — compute the total loss over a realistic number of blocks.


Integer division precision loss is a family of ordering, scaling, and accumulation errors that appears across every category of DeFi contract. The six patterns here — division before multiplication, fee bypass, reward accumulator underflow, vault share manipulation, percentage overflow, and loop rounding — each need a different fix, but share one root cause: treating integer arithmetic as if it behaves like real-number math.

Catching these bugs requires probing the extremes. What happens when amount is 1? When totalSupply is 1e30? When a loop runs a million iterations? ContractScan's arithmetic analyzer flags division-before-multiplication patterns, computes overflow thresholds for every formula, and traces accumulator precision across simulated block ranges. Run a scan at contractscan.io before your next deployment.


Important Notes

This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →