new·The score now tells you which way it movedA brain's exam only ever grows: its own material writes questions, and so does every question a real caller asked and did not get answered. The score is a percentage over that growing set, so a brain that learned more could post a smaller number — and this week three did. One of them answered two MORE questions than the week before and showed eighteen points less. Printed as a single percentage, that reads as decline to a reader and as punishment to anyone who contributes material.all news →
mozg.beta
Sign in

Smart Contract Auditor · all subjects

Arithmetic and rounding

5 notes, read out of this brain and free to use. Each one was extracted from a source and is re-checked against its exam.

Where does integer division rounding actually lose money?

Anywhere a division decides a payment. Rules: (1) Round AGAINST the user and FOR the protocol/vault — `shares = assets * totalSupply / totalAssets` must floor on deposit, and withdrawal asset amounts must floor too; a ceil in the wrong place is a slow drain. (2) Multiply before dividing: `a * b / c`, never `a / c * b`, or precision collapses to zero for small `a`. (3) Pick one fee convention: `amount * feeBps / 10000` subtracted differs by a wei from `amount * (10000 - feeBps) / 10000`. (4) First-depositor paths (`totalSupply == 0`) need explicit branches. (5) Solidity 0.8's checked arithmetic stops overflow but NOT precision loss — most real "arithmetic" findings post-0.8 are rounding direction and scale mismatches (mixing 6-decimal USDC with 18-decimal math), not overflow. Detection signal: grep divisions that feed `transfer` amounts or share mints; trace rounding direction.

Why does decimals() handling break cross-token vaults?

Protocols hardcoding 18 decimals (or assuming `price * amount` is normalized) break the moment USDC/USDT (6 decimals), WBTC (8), or a weird-decimal token enters. Symptoms: a vault that values 1 USDC at 10^12x its worth, or a borrow cap that's meaningless per-asset. Fix pattern: normalize every amount to a canonical precision at the boundary: `normalized = amount * 10**(18 - tokenDecimals)` (cache `10**IERC20Metadata(token).decimals()` at registration), and scale oracle prices by the feed's own `decimals()` — Chainlink USD feeds are usually 8, ETH feeds 18, and mixing them silently is a classic. Detection signals: constants like `1e18` applied to token amounts; missing `decimals()` calls entirely; `try token.decimals()` fallbacks defaulting to 18 without justification. Also: `decimals()` is OPTIONAL in the ERC-20 spec; decide and document the fallback. Audit test: run the whole foundry suite with a 6-decimal mock token.

Is unchecked math ever safe to leave in the code?

Yes — deliberately, in two spots: loop counters (`for (uint i; i < n; ) { ...; unchecked { ++i; } }`, standard since 0.8 to save gas) and provably-bounded subtraction (after `require(a >= b)`, `unchecked { c = a - b; }`). Don't report gas-optimized `unchecked` blocks as vulnerabilities; instead verify the bound that justifies each one. What IS reportable: `unchecked` wrapping user-controlled values (token amounts, timestamps added to deadlines) where overflow wraps small and bypasses a later check — pre-0.8 the classic was `balances[msg.sender] -= amount` underflowing to a huge balance (the 2018 batchOverflow/BeautyChain-style bugs were this class in multiplication). Detection signal: `unchecked` blocks containing user inputs or external-call-adjacent arithmetic; also `type(uint256).max` sentinel allowances reused in arithmetic (many protocols treat max allowance as infinite — reuse poisons accounting).

How do fee-on-transfer tokens break deposit() accounting?

If `deposit(uint amount)` does `token.transferFrom(msg.sender, address(this), amount)` and then credits the user `amount` shares, any deflationary token (Safemoon-style, or USDT if its fee switch ever flips) delivers LESS than `amount` — the vault credits shares it never received assets for, and the last withdrawers eat the shortfall. Fix: measure actual received: `uint before = token.balanceOf(address(this)); token.safeTransferFrom(...); uint received = token.balanceOf(address(this)) - before;` and account with `received`. Detection signal: `transferFrom` followed by accounting that trusts the calldata `amount`. Same class: rebasing tokens (stETH, AMPL) make stored balances stale — either wrap them (wstETH pattern) or disallow them with an explicit token allowlist. Also check the inverse on withdrawals: tokens that take a fee on `transfer` make `withdraw(x)` deliver less than x. A good audit deliverable includes an explicit "supported token assumptions" section listing these constraints.

Where does integer division rounding actually lose money — the precise mechanic?

Solidity division always truncates toward zero — a / b drops the remainder every time. Concrete loss pattern: a fee of 3% computed as amount * 3 / 100 is fine (multiply first), but amount / 100 * 3 or fee = amount * rate / BPS where amount * rate < BPS yields 0 — a 10-wei liquidation payout at (10 * 3) / 40 = 0 wei means the liquidator works for free and stops calling, or a small depositor's fee rounds to zero and they trade fee-free while large depositors pay. The mirror image: vault share calculations that round DOWN on mint and UP on redeem let an attacker drain via repeated small deposit/redeem cycles — the basis of the ERC-4626 inflation attack. Rules: always multiply before dividing; round in the protocol's favor (against the user-facing value being paid out); add a minimum-amount guard so truncated-to-zero operations revert instead of silently succeeding.

Give your agent this brain