Let’s take a break from the white-knuckled world of DeFi rug pulls, and instead, talk about something equally dangerous and deceptively interesting: integer division in Solidity.
If you thought math couldn’t betray you, you’ve never written uint tokens = msg.value / 1e18 * 10;
and watched a whale get scammed out of free tokens one gwei at a time.
contract FunWithNumbers {
uint constant public tokensPerEth = 10;
uint constant public weiPerEth = 1e18;
mapping(address => uint) public balances;
function buyTokens() public payable {
// Seems simple, right?
uint tokens = msg.value / weiPerEth * tokensPerEth;
balances[msg.sender] += tokens;
}
function sellTokens(uint tokens) public {
require(balances[msg.sender] >= tokens);
uint eth = tokens / tokensPerEth;
balances[msg.sender] -= tokens;
payable(msg.sender).transfer(eth * weiPerEth);
}
}
It looks like it belongs in a high school textbook. But this cute contract has a dark secret: integer division in Solidity rounds down. ALWAYS. No exceptions. No decimals. No warning signs.
Let’s say you send exactly 1 wei to buyTokens()
. What happens?
uint tokens = msg.value / 1e18 * 10;
// tokens = 1 / 1e18 * 10 = 0 * 10 = 0
Congratulations! You just gave the contract money and got NOTHING. Zero. Nada. Your wallet is lighter, and you don’t even get a thank-you note.
Worse still, it’s not symmetric. That token you didn’t get might somehow still be used to claim real ETH if someone messes with rounding in the reverse direction.
Smart contracts are about math with money. You want that math to be precise. If users start noticing they’re being short-changed due to rounding, your app’s going to be rated 1-star on DappRadar faster than you can say “TruncationError”.
uint tokens = msg.value * tokensPerEth / weiPerEth;
Boom. Just switched the math order and saved your protocol. Why?
Because now:
tokens = (1 * 10) / 1e18 = 10 / 1e18 = 0 // still 0 😅
Okay, bad example. But at least this avoids underflows when bigger numbers are involved. Order of operations in integer math is everything.
SafeMath
or Custom Fixed-Point LibrariesFixed-point math libraries like ABDKMath64x64
or PRBMath
offer decimal support (ish) and let you work with fractions safely:
import "prb-math/contracts/PRBMathUD60x18.sol";
uint tokens = PRBMathUD60x18.mul(msg.value, tokensPerEth) / weiPerEth;
Now you’re doing math like a grown-up protocol.
Wrap your functions with invariant checks:
modifier checkInvariant {
uint beforeBalance = balances[msg.sender];
uint beforeContract = address(this).balance;
_;
if (address(this).balance > beforeContract && balances[msg.sender] <= beforeBalance) {
emit AssertionFailed("Where'd the ETH go, buddy?");
}
}
Add paranoia to your logic — the good kind.
require(tokens > 0, "You can't buy zero tokens, Scrooge.");
Unless you're launching "ZeroProtocol: The Token for Nothing", this requirement is essential.
"One Wei to Rule Them All" – A testnet user minted 0.000000000000000001% of a DAO by sending 1 wei to a misordered division function.
"The Richest Poor Man" – A rounding bug gave someone 0.999999999 tokens per transaction. They did it a million times. Guess what? That’s a lot of near-zero.
Solidity’s integer division always rounds down.
x / y * z
is not the same as x * z / y
.
Be deliberate about math order, especially in financial logic.
Use assertions, modifiers, and fixed-point libraries when stakes are high.
Assume users will try to break your contract with weird edge cases. They probably have a Discord channel about it.
Solidity integer math doesn’t give warnings. It doesn’t blink. It just silently truncates your future. So next time you write a / b * c
, remember: the order of operations may just order your bankruptcy.
Round responsibly.