Skip to main content

Type a word from a question or an article title.

3 · Under the hood

Integer math and rounding in smart contracts

Why Solidity has no fractions, how basis points work, the order of multiplication and division, overflow checks, mulDiv and calculating shares by subtraction.

5 min readUpdated:
In this article

In an ordinary program 0.1 + 0.2 puzzles you and percentages come out "as they come". In a smart contract every unit of precision is real money, so all calculations are done in integers and rounding is a deliberate decision. This article shows how not to lose a cent. For context on numbers in Ethereum see accounts and state.

No fractions: everything in integers

The EVM has no float or double types. Money is stored in the smallest units of a token. If a token has decimals = 6 (like USDC), 1.5 USDC is 1500000. For ETH, 1 ETH = 10^18 wei (about tokens).

Percentages are set in basis points (bps): 1 bps = 0.01%, 10,000 bps = 100%. A 1% rate is 100 bps, 2.5% is 250 bps. Here is how it looks in the escrow contract:

solidity
uint16 public constant BPS = 10_000;
uint16 public constant PLATFORM_FEE_BPS = 100; // 1%

Division rounds down

Integer division drops the remainder: 7 / 2 == 3, 1 / 3 == 0. Calculating a fee:

solidity
platformFee = (amount * PLATFORM_FEE_BPS) / BPS;

For an amount of 99 units: 99 * 100 / 10000 = 0 (a fee of 0.99 rounds to 0). This is not a bug but a consequence of discreteness. For a token with 6 decimals losses in the smallest unit are immaterial, but you must be aware of them.

Multiply first, divide later

The order of operations is critical. Take an amount of 99,999 and a rate of 150 bps (1.5%):

solidity
// bad: division first loses precision
uint256 bad = (amount / BPS) * fee;   // (99999 / 10000) = 9;  9 * 150 = 1350

// good: multiplication keeps precision
uint256 good = (amount * fee) / BPS;  // 99999 * 150 = 14999850;  / 10000 = 1499

The gap is 149 units out of 1,499, that is about 10% of the fee lost to premature division. The rule: multiplication first, division second.

Overflow

Since Solidity 0.8 arithmetic is checked: if the result does not fit the type, the transaction reverts with Panic(0x11). This protects against a class of vulnerabilities where a number "wrapped around zero" (as in versions before 0.8). An unchecked { ... } block turns the checks off to save gas and fits only if you have proven overflow is impossible.

But there is an exception: explicit type conversion is not checked.

solidity
uint256 big = 2**100;
uint96 small = uint96(big); // silently truncated, no error

In the escrow contract the amount is stored as uint96 together with an address in one storage slot. So before writing, the amount is explicitly checked, and if it is exceeded the call is rejected with the error AmountTooLarge. Without this check a seller could create an "offer" with a truncated amount.

Division by zero

Division by zero causes Panic(0x12). Watch denominators, especially if they come from external data (for example a pool's balance).

Scaled multiplication: mulDiv

The expression a * b / c can overflow at the intermediate step a * b even if the result is small. The library function mulDiv(a, b, c) computes with a 512-bit intermediate value and avoids the problem. It is used in DeFi protocols for exact calculations.

Rounding direction

When you split an amount, dividing into parts loses a remainder. Where it goes is for the developer to decide, and it is a matter of security and fairness. A general rule: round in favour of the protocol, so that "dust" cannot be squeezed out by repeated operations.

The escrow uses a different, more robust approach - subtraction:

solidity
platformFee  = (amount * PLATFORM_FEE_BPS) / BPS;
uint256 net  = amount - platformFee;

arbiterFee   = (net * arbiterFeeBps) / BPS;
uint256 pot  = net - arbiterFee;

buyerAmount  = (pot * buyerShareBps) / BPS;
// The remainder is derived by subtraction: rounding down cannot create a shortfall.
sellerAmount = pot - buyerAmount;

Only the fees and the buyer's share are computed by division. The seller's share is the remainder. So the equality is guaranteed:

text
platformFee + arbiterFee + buyerAmount + sellerAmount == amount

Not a single token unit is lost or created. If both shares were computed by pot * share / BPS, together they could come to one unit less than pot, and that "dust" would be stuck in the contract. Calculations on concrete numbers: fees by example.

Checking invariants by fuzzing

How do you make sure the sums always match? Write a fuzz test: feed in thousands of random values and check the invariant "the parts equal the whole" (how contracts are tested, Foundry tutorial). Such tests find errors in edge cases: an amount of 1, the maximum possible amount, shares of 0 and 10,000.

A developer's checklist

  1. Money - only uint256 in smallest units.
  2. Percentages - in bps, multiplication before division.
  3. Explicit type conversions - only after a range check (or SafeCast).
  4. For complex formulas - mulDiv.
  5. The remainder - by subtraction.
  6. The "sum of parts = whole" invariant - by fuzzing.
  7. Remember different decimals across tokens (special tokens).

Learn how data is packed into slots and why uint96 ended up next to an address: the storage layout. Or how to spend less gas: gas optimization.

Mentioned in

Ready to try it?

Create your first deal in a couple of minutes. The money is locked in a smart contract and paid out by rules you can see in advance.