DEV Community

Cover image for Precision Loss and Rounding Exploits in Financial Smart Contracts
Hiren Kava for Stable Naira

Posted on

Precision Loss and Rounding Exploits in Financial Smart Contracts

A smart contract does not need an overflow, reentrancy bug, or broken access-control check to lose money.

Sometimes, the exploit is hidden inside an ordinary division:

uint256 result = amount * rate / SCALE;
Enter fullscreen mode Exit fullscreen mode

The expression looks harmless. It may even produce the expected answer in every unit test.

But financial smart contracts operate with integer arithmetic. Fractions are discarded, rounding direction changes who receives value, and an error of one unit can be repeated across thousands of transactions.

In a financial protocol, rounding is not merely a mathematical implementation detail.

Rounding is a value-transfer policy.

Every division should therefore answer three questions:

  1. Which direction does the calculation round?
  2. Which party benefits from that direction?
  3. Can the rounding advantage be repeated or amplified?

This article examines the most dangerous precision problems in Solidity and the engineering patterns used to prevent them.


Solidity Does Not Have Native Fixed-Point Arithmetic

Most financial formulas use fractions:

interest = principal × rate × time
fee = amount × fee percentage
shares = assets × total shares ÷ total assets
collateral value = token amount × oracle price
Enter fullscreen mode Exit fullscreen mode

Solidity primarily performs these calculations with integers.

For unsigned integers:

uint256 result = 5 / 2;
Enter fullscreen mode Exit fullscreen mode

The result is:

2
Enter fullscreen mode Exit fullscreen mode

The fractional component is discarded.

For positive values, this behaves like rounding down:

2.5 → 2
Enter fullscreen mode Exit fullscreen mode

This appears insignificant until the result represents:

  • vault shares;
  • debt;
  • collateral;
  • protocol fees;
  • interest;
  • rewards;
  • liquidation bonuses;
  • exchange rates;
  • token prices.

The lost fraction does not disappear economically. One party receives less value, while another party retains the remainder.


Precision Loss Is Not Always Small

Consider a protocol calculating a percentage:

function calculateFee(
    uint256 amount,
    uint256 feeBps
) public pure returns (uint256) {
    return amount * feeBps / 10_000;
}
Enter fullscreen mode Exit fullscreen mode

For a 0.3% fee:

amount = 100
feeBps = 30

fee = 100 × 30 ÷ 10,000
fee = 0
Enter fullscreen mode Exit fullscreen mode

The mathematically correct result is 0.3, but the smallest representable integer result is zero.

If the protocol permits small operations, a user may split one large transaction into many smaller transactions and avoid fees entirely.

Suppose:

One transaction:
10,000 × 30 ÷ 10,000 = 30 fee units
Enter fullscreen mode Exit fullscreen mode

Now split it into 100 transactions:

100 × 30 ÷ 10,000 = 0 fee units
Enter fullscreen mode Exit fullscreen mode

The total economic operation is identical, but the protocol receives no fee.

This is a rounding amplification attack.

The important issue is not the amount lost in one calculation. It is whether the calculation can be repeated under attacker control.


Division Before Multiplication Destroys Precision

One of the most common implementation errors is dividing too early.

Vulnerable calculation

function calculateReward(
    uint256 amount,
    uint256 rewardRate
) public pure returns (uint256) {
    return (amount / 1e18) * rewardRate;
}
Enter fullscreen mode Exit fullscreen mode

For:

amount = 1.5e18
rewardRate = 100
Enter fullscreen mode Exit fullscreen mode

The intermediate division produces:

1.5e18 / 1e18 = 1
Enter fullscreen mode Exit fullscreen mode

The result becomes:

1 × 100 = 100
Enter fullscreen mode Exit fullscreen mode

The expected value was:

150
Enter fullscreen mode Exit fullscreen mode

One-third of the reward was lost before the multiplication occurred.

Better operation ordering

function calculateReward(
    uint256 amount,
    uint256 rewardRate
) public pure returns (uint256) {
    return amount * rewardRate / 1e18;
}
Enter fullscreen mode Exit fullscreen mode

Multiplying first retains more precision:

1.5e18 × 100 ÷ 1e18 = 150
Enter fullscreen mode Exit fullscreen mode

The general rule is:

multiply before dividing
Enter fullscreen mode Exit fullscreen mode

But that rule introduces another problem: the intermediate multiplication may overflow even when the final result fits inside uint256.


Multiply First Without Creating Intermediate Overflow

Consider:

uint256 result = x * y / denominator;
Enter fullscreen mode Exit fullscreen mode

The product x * y can exceed type(uint256).max, even when:

(x × y) ÷ denominator
Enter fullscreen mode Exit fullscreen mode

would fit inside uint256.

Solidity's checked arithmetic will revert before performing the division.

A full-precision multiplication-and-division operation avoids this intermediate overflow:

import {Math} from
    "@openzeppelin/contracts/utils/math/Math.sol";

function calculate(
    uint256 x,
    uint256 y,
    uint256 denominator
) public pure returns (uint256) {
    return Math.mulDiv(x, y, denominator);
}
Enter fullscreen mode Exit fullscreen mode

mulDiv computes:

floor(x × y ÷ denominator)
Enter fullscreen mode Exit fullscreen mode

with full intermediate precision.

For calculations that must round upward:

function calculateUp(
    uint256 x,
    uint256 y,
    uint256 denominator
) public pure returns (uint256) {
    return Math.mulDiv(
        x,
        y,
        denominator,
        Math.Rounding.Ceil
    );
}
Enter fullscreen mode Exit fullscreen mode

Using a reviewed math library is safer than implementing custom 512-bit arithmetic.


Rounding Direction Is an Economic Decision

Suppose a lending protocol calculates interest owed by a borrower:

interest = principal × rate ÷ scale
Enter fullscreen mode Exit fullscreen mode

If the result is rounded down, the borrower pays slightly less.

If it is rounded up, the borrower pays slightly more.

Now consider calculating collateral credited to the borrower:

collateral value = collateral amount × price ÷ scale
Enter fullscreen mode Exit fullscreen mode

If this calculation rounds up, the protocol may credit collateral that does not economically exist.

A conservative lending protocol generally follows this principle:

  • round debt upward;
  • round required payments upward;
  • round collateral value downward;
  • round assets paid to users downward;
  • round shares charged to users upward.

This is not a universal rule. The correct direction depends on the operation.

The broader security principle is:

When an exact result is not representable, round against the party attempting to extract value from the protocol.

Each public operation should document its intended beneficiary.

For example:

/// @notice Calculates debt including accrued interest.
/// @dev Rounds upward so debt is never understated.
function currentDebt(
    uint256 principal,
    uint256 index
) public pure returns (uint256) {
    return Math.mulDiv(
        principal,
        index,
        1e18,
        Math.Rounding.Ceil
    );
}
Enter fullscreen mode Exit fullscreen mode

A calculation without a documented rounding policy should be treated as incomplete financial logic.


Implementing Ceiling Division Safely

Developers sometimes implement ceiling division like this:

uint256 result = (x + denominator - 1) / denominator;
Enter fullscreen mode Exit fullscreen mode

Mathematically, this is valid for positive integers.

In Solidity, however, x + denominator - 1 may overflow.

A safer implementation is:

function ceilDiv(
    uint256 x,
    uint256 denominator
) public pure returns (uint256) {
    if (x == 0) return 0;

    return (x - 1) / denominator + 1;
}
Enter fullscreen mode Exit fullscreen mode

Or use a reviewed library implementation:

uint256 result = Math.ceilDiv(x, denominator);
Enter fullscreen mode Exit fullscreen mode

Ceiling division is especially important for:

  • shares required to withdraw an exact asset amount;
  • assets required to mint an exact number of shares;
  • debt repayment requirements;
  • protocol fee collection;
  • auction payment calculations.

Repeated Rounding Can Become an Extraction Strategy

A one-unit discrepancy may appear harmless during review.

But attackers optimize transaction structure around deterministic arithmetic.

Suppose rewards are calculated independently for every claim:

reward = userWeight * rewardPool / totalWeight;
Enter fullscreen mode Exit fullscreen mode

Each calculation rounds down.

If unclaimed dust remains in the contract, that may be acceptable.

But suppose a protocol recalculates a user's balance after many small actions and accidentally rounds in the user's favor each time.

An attacker may:

  1. split one operation into many small operations;
  2. receive the favorable rounding remainder repeatedly;
  3. merge the resulting position;
  4. withdraw more value than a single equivalent operation would provide.

A useful invariant is:

performing an operation in N parts must not produce more value
than performing the same operation once
Enter fullscreen mode Exit fullscreen mode

This property is called path independence.

For a fee calculation:

fee(a) + fee(b) should not be materially lower than fee(a + b)
Enter fullscreen mode Exit fullscreen mode

For share minting:

shares(a) + shares(b) should not exceed shares(a + b)
when splitting deposits should not be advantageous
Enter fullscreen mode Exit fullscreen mode

Exact equality is not always possible with integer arithmetic. However, the difference must be bounded and must not create an attacker-controlled profit.


Fixed-Point Scales Must Be Explicit

A common Solidity convention represents decimal values using a scale.

For example:

uint256 constant WAD = 1e18;
Enter fullscreen mode Exit fullscreen mode

Then:

1.0 = 1e18
0.5 = 5e17
2.25 = 2.25e18
Enter fullscreen mode Exit fullscreen mode

A multiplication between two WAD values requires rescaling:

function wadMul(
    uint256 x,
    uint256 y
) public pure returns (uint256) {
    return Math.mulDiv(x, y, WAD);
}
Enter fullscreen mode Exit fullscreen mode

Division requires scaling the numerator:

function wadDiv(
    uint256 x,
    uint256 y
) public pure returns (uint256) {
    return Math.mulDiv(x, WAD, y);
}
Enter fullscreen mode Exit fullscreen mode

The dangerous part is not only precision loss. It is mixing values with different units.

Consider:

uint256 collateralAmount; // 6 decimals
uint256 oraclePrice;      // 8 decimals
uint256 debtAmount;       // 18 decimals
Enter fullscreen mode Exit fullscreen mode

These values cannot be safely compared without normalization.

A line such as:

require(
    collateralAmount * oraclePrice >= debtAmount,
    "Undercollateralized"
);
Enter fullscreen mode Exit fullscreen mode

has no meaningful financial interpretation unless all three units are known and normalized.

Senior-level financial code should make units visible:

uint256 collateralAmount6;
uint256 oraclePrice8;
uint256 debtValue18;
Enter fullscreen mode Exit fullscreen mode

Better still, isolate unit conversion in dedicated functions.


Decimal Normalization Can Introduce Both Truncation and Overflow

Suppose a token uses six decimals and the internal accounting system uses 18 decimals.

Normalization upward:

uint256 normalized = amount * 1e12;
Enter fullscreen mode Exit fullscreen mode

Normalization downward:

uint256 tokenAmount = normalized / 1e12;
Enter fullscreen mode Exit fullscreen mode

The downward conversion loses all values below 1e12 internal units.

For example:

normalized = 999,999,999,999

normalized / 1e12 = 0
Enter fullscreen mode Exit fullscreen mode

If a protocol reduces a user's internal balance by 999,999,999,999 but transfers zero tokens, the user loses value.

If it transfers one token unit but reduces the balance by less than 1e12, the protocol loses value.

The conversion must specify:

  • whether the operation rounds up or down;
  • whether dust remains credited;
  • whether zero-output operations revert;
  • whether the caller can repeat the operation;
  • whether normalized values can overflow.

A safe withdrawal flow often rejects non-zero inputs that produce zero output:

error ZeroOutput();

function denormalizeDown(
    uint256 amount18
) public pure returns (uint256 amount6) {
    amount6 = amount18 / 1e12;

    if (amount18 != 0 && amount6 == 0) {
        revert ZeroOutput();
    }
}
Enter fullscreen mode Exit fullscreen mode

Whether reverting is correct depends on the protocol's dust policy.


Fee Calculations Need Different Formulas for Inclusive and Exclusive Fees

There is an important difference between adding a fee to an amount and extracting a fee from an amount that already includes it.

Fee added on top

Suppose amount excludes the fee:

gross amount = amount + amount × fee rate
Enter fullscreen mode Exit fullscreen mode

The fee can be calculated as:

function feeOnRaw(
    uint256 amount,
    uint256 feeBps
) public pure returns (uint256) {
    return Math.mulDiv(
        amount,
        feeBps,
        10_000,
        Math.Rounding.Ceil
    );
}
Enter fullscreen mode Exit fullscreen mode

Fee included in the total

Suppose total already includes the fee.

The fee is not:

total * feeBps / 10_000
Enter fullscreen mode Exit fullscreen mode

Instead:

fee = total × fee rate ÷ (1 + fee rate)
Enter fullscreen mode Exit fullscreen mode

In basis points:

function feeOnTotal(
    uint256 total,
    uint256 feeBps
) public pure returns (uint256) {
    return Math.mulDiv(
        total,
        feeBps,
        10_000 + feeBps,
        Math.Rounding.Ceil
    );
}
Enter fullscreen mode Exit fullscreen mode

Confusing these formulas causes previews, deposits, accounting records, and emitted events to disagree.


Share Accounting Is Especially Sensitive to Rounding

Vaults commonly calculate shares as:

shares = assets × total supply ÷ total assets
Enter fullscreen mode Exit fullscreen mode

And assets as:

assets = shares × total assets ÷ total supply
Enter fullscreen mode Exit fullscreen mode

A naïve implementation might be:

function convertToShares(
    uint256 assets
) public view returns (uint256) {
    return assets * totalSupply / totalAssets;
}
Enter fullscreen mode Exit fullscreen mode

This contains several risks:

  • division by zero when the vault is empty;
  • intermediate multiplication overflow;
  • deposits returning zero shares;
  • manipulable exchange rates;
  • inconsistent rounding between deposit and withdrawal paths;
  • direct token donations changing totalAssets;
  • small deposits losing most or all of their value.

The zero-share deposit problem

Assume:

total assets = 1,000,000
total shares = 1
Enter fullscreen mode Exit fullscreen mode

A user deposits 999,999 asset units:

shares = 999,999 × 1 ÷ 1,000,000
shares = 0
Enter fullscreen mode Exit fullscreen mode

The user transfers assets but receives no shares.

If the existing shareholder owns all outstanding shares, the new deposit effectively becomes a donation to that shareholder.

At minimum, deposits that calculate zero shares should revert:

error ZeroShares();

function deposit(
    uint256 assets
) external returns (uint256 shares) {
    shares = previewDeposit(assets);

    if (shares == 0) revert ZeroShares();

    // Transfer assets and mint shares.
}
Enter fullscreen mode Exit fullscreen mode

But this check alone does not prevent exchange-rate manipulation.


ERC-4626 Inflation Attacks

An empty or nearly empty tokenized vault may be vulnerable to a donation-based inflation attack.

A simplified attack works as follows:

  1. The attacker deposits a minimal amount and receives the initial shares.
  2. The attacker transfers assets directly to the vault.
  3. The donation increases totalAssets without increasing totalSupply.
  4. The share price increases dramatically.
  5. A victim deposits assets.
  6. The victim's calculated share amount rounds down to zero.
  7. The attacker redeems the existing shares and captures the victim's deposit.

Example:

Attacker deposits: 1 asset
Attacker receives: 1 share

Attacker donates: 100 assets

Vault state:
totalAssets = 101
totalSupply = 1

Victim deposits: 100 assets

Victim shares:
100 × 1 ÷ 101 = 0
Enter fullscreen mode Exit fullscreen mode

After the victim's transfer:

totalAssets = 201
totalSupply = 1
Enter fullscreen mode Exit fullscreen mode

The attacker owns the only share and may redeem almost all the assets.

The vulnerability combines:

  • exchange-rate manipulation;
  • direct donations;
  • low initial liquidity;
  • floor rounding;
  • missing slippage protection.

It is therefore inaccurate to describe this only as a rounding bug.

Rounding is the mechanism that converts manipulated accounting into captured value.


Virtual Assets, Virtual Shares, and Decimal Offsets

One mitigation is to include virtual values in the conversion formula.

Conceptually:

shares =
    assets × (totalSupply + virtualShares)
    ÷ (totalAssets + virtualAssets)
Enter fullscreen mode Exit fullscreen mode

Virtual assets and shares establish an initial exchange rate and reduce the attacker's ability to manipulate an empty vault.

A decimal offset can also give shares greater precision than the underlying asset.

A simplified conversion function may look like:

function _convertToShares(
    uint256 assets,
    Math.Rounding rounding
) internal view returns (uint256) {
    return Math.mulDiv(
        assets,
        totalSupply() + 10 ** decimalsOffset,
        totalAssets() + 1,
        rounding
    );
}
Enter fullscreen mode Exit fullscreen mode

This approach can:

  • reduce loss from low-share precision;
  • make zero-share deposits less likely;
  • capture part of an attacker's donation for the vault;
  • make inflation attacks economically unprofitable.

The exact parameters still require protocol-specific analysis. A virtual offset is not a replacement for user-provided slippage limits.


ERC-4626 Operations Require Different Rounding Directions

A compliant tokenized vault cannot apply the same rounding direction to every conversion.

The economic intention is:

Operation User specifies Protocol calculates Conservative rounding
Deposit Exact assets Shares received Down
Mint Exact shares Assets required Up
Withdraw Exact assets Shares burned Up
Redeem Exact shares Assets received Down

This protects the vault from giving away unbacked value.

Deposit

The user provides an exact amount of assets.

The vault calculates shares to mint:

shares = Math.mulDiv(
    assets,
    totalSupply,
    totalAssets,
    Math.Rounding.Floor
);
Enter fullscreen mode Exit fullscreen mode

Rounding down prevents the vault from minting more shares than the assets support.

Mint

The user requests an exact number of shares.

The vault calculates the assets required:

assets = Math.mulDiv(
    shares,
    totalAssets,
    totalSupply,
    Math.Rounding.Ceil
);
Enter fullscreen mode Exit fullscreen mode

Rounding up prevents the user from receiving exact shares while paying slightly too few assets.

Withdraw

The user requests an exact amount of assets.

The vault calculates shares to burn:

shares = Math.mulDiv(
    assets,
    totalSupply,
    totalAssets,
    Math.Rounding.Ceil
);
Enter fullscreen mode Exit fullscreen mode

Rounding up prevents users from withdrawing exact assets while burning insufficient shares.

Redeem

The user provides an exact number of shares.

The vault calculates assets returned:

assets = Math.mulDiv(
    shares,
    totalAssets,
    totalSupply,
    Math.Rounding.Floor
);
Enter fullscreen mode Exit fullscreen mode

Rounding down prevents the vault from returning more assets than those shares support.

This asymmetry is intentional.

Using floor rounding everywhere may allow users to underpay for minted shares or burn too few shares during withdrawal.


Preview Functions Do Not Replace Slippage Protection

A user may call:

uint256 expectedShares = vault.previewDeposit(assets);
Enter fullscreen mode Exit fullscreen mode

Then submit:

vault.deposit(assets, receiver);
Enter fullscreen mode Exit fullscreen mode

The exchange rate can change between simulation and transaction execution.

An attacker may manipulate the vault before the user's transaction is included.

A safer router or vault extension accepts a minimum output:

function deposit(
    uint256 assets,
    address receiver,
    uint256 minShares
) external returns (uint256 shares) {
    shares = previewDeposit(assets);

    if (shares < minShares) {
        revert InsufficientSharesReceived(
            shares,
            minShares
        );
    }

    _deposit(msg.sender, receiver, assets, shares);
}
Enter fullscreen mode Exit fullscreen mode

Equivalent protections include:

  • minShares for deposits;
  • maxAssets for mints;
  • maxShares for withdrawals;
  • minAssets for redemptions;
  • transaction deadlines.

Rounding safety and slippage safety solve related but different problems.


Interest Accrual Can Leak Value Over Time

Consider a lending protocol that accrues interest using:

interest =
    principal *
    annualRate *
    elapsed /
    YEAR /
    1e18;
Enter fullscreen mode Exit fullscreen mode

Several problems may arise:

  • precision is lost at multiple division points;
  • multiplication may overflow;
  • frequent accrual may produce different results than infrequent accrual;
  • small interest amounts may repeatedly round to zero;
  • debt may be understated;
  • the protocol's global debt may diverge from user-level debt.

Suppose a borrower's interest for one block rounds to zero.

If anyone can trigger accrual every block and the protocol resets the accrual timestamp after each call, interest may remain zero indefinitely.

This creates a frequency-dependent rounding exploit.

The protocol should preserve fractional interest using an index or high-precision accumulator rather than discarding it on every update.

A common model stores a global borrow index:

new index =
    previous index × accumulated rate
Enter fullscreen mode Exit fullscreen mode

A user's debt is derived from:

debt =
    normalized debt × current index
Enter fullscreen mode Exit fullscreen mode

The index should use sufficient precision, and the final debt calculation should generally avoid understating the borrower's obligation.


Reward Distribution Needs Remainder Accounting

A reward-per-share system often uses:

rewardPerShare +=
    rewards * ACC_PRECISION / totalStaked;
Enter fullscreen mode Exit fullscreen mode

The division may leave a remainder.

If that remainder is discarded during every distribution, some rewards become permanently unclaimable.

A more accurate system can carry the remainder forward:

uint256 public undistributedRemainder;

function distribute(
    uint256 newRewards
) internal {
    uint256 rewards =
        newRewards + undistributedRemainder;

    uint256 increment = Math.mulDiv(
        rewards,
        ACC_PRECISION,
        totalStaked
    );

    uint256 distributed = Math.mulDiv(
        increment,
        totalStaked,
        ACC_PRECISION
    );

    undistributedRemainder = rewards - distributed;
    rewardPerShare += increment;
}
Enter fullscreen mode Exit fullscreen mode

The correct implementation depends on the reward model, but the key accounting property is:

distributed rewards + retained remainder
must equal funded rewards
Enter fullscreen mode Exit fullscreen mode

Untracked dust is an accounting error even when no individual transaction loses a large amount.


Liquidation Thresholds Must Fail Conservatively

Assume a protocol checks:

collateral value × liquidation threshold >= debt
Enter fullscreen mode Exit fullscreen mode

An unsafe implementation may round collateral value upward:

uint256 collateralValue = Math.mulDiv(
    collateralAmount,
    oraclePrice,
    PRICE_SCALE,
    Math.Rounding.Ceil
);
Enter fullscreen mode Exit fullscreen mode

That can make an insolvent position appear healthy.

The safer direction is normally downward:

uint256 collateralValue = Math.mulDiv(
    collateralAmount,
    oraclePrice,
    PRICE_SCALE,
    Math.Rounding.Floor
);
Enter fullscreen mode Exit fullscreen mode

Debt calculations should normally avoid rounding downward:

uint256 debtValue = Math.mulDiv(
    normalizedDebt,
    borrowIndex,
    INDEX_SCALE,
    Math.Rounding.Ceil
);
Enter fullscreen mode Exit fullscreen mode

Then:

bool healthy =
    Math.mulDiv(
        collateralValue,
        liquidationThresholdBps,
        10_000,
        Math.Rounding.Floor
    ) >= debtValue;
Enter fullscreen mode Exit fullscreen mode

The exact boundary behavior must be specified.

Ask:

  • Is equality healthy or liquidatable?
  • Does one unit of rounding alter liquidation eligibility?
  • Can an attacker oscillate around the boundary?
  • Do the preview and execution paths use identical calculations?
  • Can oracle decimal changes break normalization?

Never Use Rounding to Hide an Accounting Mismatch

Developers sometimes resolve a failing invariant by adding or subtracting one:

if (calculatedAmount < expectedAmount) {
    calculatedAmount += 1;
}
Enter fullscreen mode Exit fullscreen mode

This may silence a test while creating an unexplained transfer of value.

A one-unit correction is appropriate only when it implements an explicit rounding rule.

The code should be expressible mathematically:

floor(x × y ÷ denominator)
Enter fullscreen mode Exit fullscreen mode

or:

ceil(x × y ÷ denominator)
Enter fullscreen mode Exit fullscreen mode

It should not be:

approximately calculate the result and adjust it
until the test passes
Enter fullscreen mode Exit fullscreen mode

Financial arithmetic should be derived before it is implemented.


Testing Precision and Rounding Properties

Example-based tests are not enough.

A unit test such as:

function testFeeCalculation() public {
    assertEq(calculateFee(1_000 ether, 30), 3 ether);
}
Enter fullscreen mode Exit fullscreen mode

tests a value that divides cleanly.

Attackers search for values that do not divide cleanly.

Test boundary values

Always include:

0
1
denominator - 1
denominator
denominator + 1
type(uint256).max
Enter fullscreen mode Exit fullscreen mode

Also test values around:

  • one share;
  • one asset unit;
  • minimum deposit;
  • liquidation threshold;
  • fee boundaries;
  • decimal conversion boundaries;
  • empty and nearly empty vault states.

Test rounding inequalities

For floor rounding:

result × denominator <= x × y
Enter fullscreen mode Exit fullscreen mode

For ceiling rounding:

result × denominator >= x × y
Enter fullscreen mode Exit fullscreen mode

The exact invariant may need full-precision test arithmetic to avoid overflow.

Test split-operation behavior

function testFuzz_splitDepositDoesNotCreateValue(
    uint256 assetsA,
    uint256 assetsB
) public {
    // Compare depositing assetsA + assetsB once
    // against depositing assetsA and assetsB separately.
}
Enter fullscreen mode Exit fullscreen mode

Investigate any case in which splitting creates more redeemable assets.

Test round-trip conversions

For a conservative vault:

assets
→ shares rounded down
→ assets rounded down
Enter fullscreen mode Exit fullscreen mode

must not return more assets than the initial amount.

function invariant_roundTripCannotCreateAssets()
    external
    view
{
    uint256 assets = handler.sampleAssets();

    uint256 shares = vault.convertToShares(assets);
    uint256 returnedAssets =
        vault.convertToAssets(shares);

    assertLe(returnedAssets, assets);
}
Enter fullscreen mode Exit fullscreen mode

Test conservation

Useful protocol invariants include:

total user claims <= recoverable assets
Enter fullscreen mode Exit fullscreen mode
total collected fees + unpaid fee remainder
= total generated fees
Enter fullscreen mode Exit fullscreen mode
total distributed rewards + retained rewards
= total funded rewards
Enter fullscreen mode Exit fullscreen mode
a sequence of conversions cannot create value
Enter fullscreen mode Exit fullscreen mode
no non-zero deposit succeeds while minting zero shares
Enter fullscreen mode Exit fullscreen mode

Stateful fuzzing is especially valuable because many rounding exploits require carefully ordered sequences rather than one isolated call.


Precision-Security Review Checklist

Before deploying financial arithmetic, verify the following.

Formula design

  • Is the mathematical formula documented?
  • Are all variables labeled with units and decimal scales?
  • Is multiplication performed before division?
  • Can intermediate multiplication overflow?
  • Is full-precision mulDiv required?

Rounding policy

  • Is the rounding direction explicit?
  • Which party benefits from the remainder?
  • Is the direction conservative for the protocol?
  • Do preview and execution functions use the same policy?
  • Can users repeat the favorable rounding?

Token decimals

  • Are token and oracle decimals validated?
  • Are all values normalized before comparison?
  • Can downscaling produce zero?
  • Is dust retained, refunded, or rejected?
  • Can a token's unusual decimal count cause overflow?

Vault accounting

  • Can a deposit mint zero shares?
  • Can a withdrawal burn zero shares?
  • Can direct donations manipulate the exchange rate?
  • Is the empty-vault state protected?
  • Are virtual shares or assets appropriate?
  • Do users have slippage limits?

Fees and interest

  • Are fees inclusive or exclusive?
  • Should fees round upward or downward?
  • Can transaction splitting avoid fees?
  • Can frequent accrual suppress interest?
  • Are fractional remainders carried forward?

Testing

  • Are boundary values covered?
  • Are floor and ceiling inequalities tested?
  • Are split operations compared with combined operations?
  • Are round-trip conversions tested?
  • Are conservation invariants enforced?
  • Are empty and low-liquidity states fuzzed?

Final Principle

Precision loss becomes exploitable when four conditions meet:

  1. a financial formula produces a fractional result;
  2. the implementation silently chooses a rounding direction;
  3. that direction benefits an external actor;
  4. the actor can repeat, amplify, or manipulate the calculation.

The defense is not simply “use more decimals.”

Secure financial arithmetic requires:

  • explicit units;
  • full-precision multiplication and division;
  • operation-specific rounding;
  • conservative accounting;
  • remainder tracking;
  • slippage protection;
  • invariant and fuzz testing.

When reviewing a division operation, do not ask only:

Is this calculation mathematically close enough?

Ask:

Where does the discarded value go, who receives it, and can they trigger this calculation repeatedly?

That is the difference between ordinary integer arithmetic and production-grade financial engineering.

Top comments (0)