Token Decimals and Human Units: Where DeFi Math Quietly Goes Wrong
The first DeFi swap UI I shipped looked correct in every test. Then a real user reported that swapping 100 USDC for ETH had shown a quote of "100,000,000,000,000 ETH" for a brief second before correcting itself. The fix turned out to be a single missing line in the amount formatter: USDC has 6 decimals and we were treating it as 18. Nothing about Solidity or the smart contract was wrong; the bug was entirely on our side, in the way we'd translated raw integer amounts into the numbers humans were supposed to read.
Decimal handling is the most boring failure mode in DeFi and also one of the most frequent. Token contracts speak in raw integer units. Humans want to see "1.50 USDC". The translation layer between those two worlds is where money quietly disappears, transactions revert, and front-ends show numbers that make support tickets write themselves.
The sections below cover what decimals actually means at the contract level, the cheat sheet across the major tokens, the JavaScript Number trap that bites every web team eventually, the Solidity-side rules that changed at 0.8, real-world failures worth knowing, and the validation patterns that keep the bug class out of your codebase.
What "decimals" means at the contract level
ERC-20 (EIP-20) does not store fractional balances. Every balance is an unsigned 256-bit integer (uint256). Decimal points are a presentation layer convention. The contract exposes a decimals() view function whose only job is to tell wallets and dapps how many zeros to imagine after the integer.
// USDC contract on Ethereum mainnet
function decimals() external pure returns (uint8) {
return 6;
}
// A user balance of 1,500,000 means 1.5 USDC for display.
// On-chain the number 1,500,000 is the truth. The decimal point is fiction.The pattern is identical on Solana: SPL token mints carry a decimals field in the mint account, and the raw balance on a token account is a u64 integer. Native SOL is similar: balances are stored in lamports, where 1 SOL = 109 lamports. If you have not read the dedicated Solana lamports guide yet, the math there is the same shape as everything in this post.
Once you internalise this, the rule becomes simple. On-chain math always uses the raw integer. Display only ever scales by 10decimals. The minute those two worlds touch in the same variable, you have a bug waiting to ship.
Decimals across the major tokens
There is no universal default. Most tokens follow either the 18-decimal Ethereum convention (because that's what the OpenZeppelin ERC20 constructor defaults to) or a smaller value that matches the precision the original asset actually needed. Hard-coding 18 anywhere in your codebase is the most reliable way to introduce a future incident.
| Token | Decimals | Smallest unit | Note |
|---|---|---|---|
| ETH (native) | 18 | 1 wei | 1 ETH = 1018 wei |
| DAI | 18 | 10-18 DAI | Matches ETH for ease of math |
| USDC, USDT | 6 | 1 micro-cent | Matches dollar accounting precision; same on all major chains |
| WBTC | 8 | 1 satoshi | Mirrors Bitcoin's native precision |
| SOL (native) | 9 | 1 lamport | 1 SOL = 109 lamports |
| USDC on Solana (SPL) | 6 | 1 micro-cent | Same as Ethereum USDC; consistency across bridges |
| UNI, LINK, AAVE, … | 18 | 10-18 | OpenZeppelin default |
| Gas (gwei) | 9 | 1 wei | gwei is a display unit, not a token; 1 gwei = 109 wei |
Two takeaways from the table. First, stablecoins almost always use 6 decimals because that matches the precision a dollar actually needs; the "match the underlying asset" rule beats the OpenZeppelin default. Second, anything you bridge between chains can keep its decimals or change them, depending on the bridge contract. Wrapped tokens sometimes get re-decimalled (rare but it has happened on smaller bridges), so always confirm with decimals() on the destination chain rather than assuming.
For a calculator that converts between raw units and human-readable amounts across these decimal scales, BeautiCode's Token Decimals Calculator runs client-side, and the Wei / Gwei / Ether converter handles the gas-cost arithmetic for Ethereum specifically.
The JavaScript Number trap
JavaScript's Number is an IEEE 754 double. It is safe to use for integers up to Number.MAX_SAFE_INTEGER (253 - 1, which is 9,007,199,254,740,991). For anything beyond that, the bit pattern starts losing precision in the lowest digits, silently.
> Number.MAX_SAFE_INTEGER
9007199254740991
> 10n ** 18n // 1 ETH in wei, as BigInt
1000000000000000000n
> Number(10n ** 18n) // Same value as a Number
1000000000000000000 // Looks fine!
> Number(10n ** 18n) + 1
1000000000000000000 // ...wait. We added 1 and nothing happened.
// The reason: 10^18 is past MAX_SAFE_INTEGER, so the +1 disappears
// into a representational gap.The implications are easy to miss in code review. Any time you fetch a balance from an RPC call, do arithmetic on it, and then convert to a number for display, you risk losing the last few digits. For a token with 18 decimals, "losing the last few digits" is the same as losing fractions of a cent. For a token with 6 decimals, it is the same as losing entire dollars or worse.
The rule is to keep everything in BigInt (or a big-number library like ethers.js's built-in bigint types) until the moment you format for a user, and even then only convert the final formatted string rather than the raw integer.
// Safe pattern — using ethers.js v6
import { formatUnits, parseUnits } from 'ethers';
// 1) Display: raw integer → human string
const rawBalance = 1_500_000n; // 1.5 USDC at 6 decimals
const display = formatUnits(rawBalance, 6); // "1.5"
// 2) Input: human string → raw integer
const userInput = '1.5';
const rawAmount = parseUnits(userInput, 6); // 1500000n
// 3) Math: do it entirely in BigInt
const fee = (rawAmount * 30n) / 10_000n; // 0.3% fee, still integer
const net = rawAmount - fee;
// 4) Only the final display gets re-formatted
console.log(formatUnits(net, 6)); // "1.4955"Two patterns to avoid in this code. First, parseFloat(userInput) * 1e6 works for small numbers and silently corrupts large ones; never use floating-point multiplication for token math. Second, rawAmount.toString() followed by manual decimal insertion is tempting and almost always introduces an off-by-one error around zero-padding for amounts smaller than the decimal scale. Use the library helper.
Solidity-side: SafeMath, checked math, and unchecked blocks
On the contract side, the rules changed at Solidity 0.8. Before 0.8, integer math wrapped on overflow by default, so production contracts pulled in OpenZeppelin's SafeMath library and called a.add(b) instead of a + b. The library wrapped each operation in an overflow check and reverted on failure. Failing to use it meant a deposit of type(uint256).max + 1 would silently become zero, which is exactly the bug class that drained several early DeFi pools.
Solidity 0.8 made checked arithmetic the default. Overflow now reverts the transaction automatically. SafeMath became redundant, and the OpenZeppelin team eventually deprecated it. If you read a contract written today, a + b already checks for overflow.
// Solidity 0.8+ — overflow check is on by default
function deposit(uint256 amount) public {
balance[msg.sender] += amount; // reverts if it overflows
totalSupply += amount; // same
}
// Opt out only inside an unchecked block (gas savings; rare)
function unsafeIncrement() public {
unchecked {
counter += 1; // wraps on overflow, no revert
}
}The catch is that unchecked blocks exist for a reason. They are used inside loops to save gas when the developer has already proven the value cannot overflow (for example, an array index that is bounded by the array length). Misusing them — putting financial arithmetic inside an unchecked block to save gas — is the modern equivalent of forgetting SafeMath in 2019. If you are reviewing contract code, treat any unchecked block as a place that needs an inline justification comment.
For pre-0.8 contracts that you still need to interact with (and yes, plenty are still live), assume nothing about overflow safety unless you can read the imports and see SafeMath. The age of the deployment matters more than the language version of your client.
Real-world failures worth knowing
Decimal bugs do not always surface in the contract. The more interesting failures sit at the boundary between the front-end and the chain. A few categories that have shown up repeatedly in incident reports:
Front-end rounding before submission
A user types 100.123456789 into an amount field for a token with 6 decimals. The display layer rounds to 6 decimals (which is correct), but the submission code uses parseFloat on the original string and multiplies by 1e6. The floating-point error nudges the value down by 1 unit, the transaction underpays by one micro-cent, and the receiving contract reverts with InsufficientAmount. The user thinks the dapp is broken; the dapp thinks the chain is broken; the bug is in the front-end formatter.
Cross-decimal pair math
A swap pair of USDC (6 decimals) and DAI (18 decimals) needs explicit scaling whenever the math touches both sides. A naive AMM-style calculation that multiplies USDC reserves by DAI reserves without scaling either to a common precision will produce a price that is off by 12 orders of magnitude. Production AMMs encode this scaling on the way in (Uniswap v3 uses Q64.96 fixed-point), but custom DeFi code regularly forgets.
Bridged token decimal mismatch
Most bridges preserve decimals across chains, but not all. If your aggregator pulls a balance from one chain and a quote from another, you are stitching two integer columns from different precision worlds together. Bridge token registries (the official ones from Polygon, Arbitrum, Optimism, etc.) usually publish per-chain decimal values; query them rather than trusting a single hard-coded number per symbol.
Rebasing tokens and balance shifts
Tokens like the original AMPL or older stETH rebase: the raw balance changes between blocks without any transfer event firing. A cached balance read from yesterday is no longer accurate today, even though no transaction touched it. Code that compares stored and live balances to detect transfers (a pattern used in some MEV searchers and tax tools) will see ghost movements. The fix is to either query the share-based view function (when the token exposes one) or refresh balances on every read.
Validation patterns when displaying balances
The defensive rules that prevent most of the bug classes above are not exotic. They are the same handful of checks repeated across every screen that involves a token amount:
- Never store an amount without its decimals. The minimum record is
{ chainId, tokenAddress, rawAmount: bigint, decimals: number }. Storing just{ symbol, amount: number }guarantees a future bug. - Query
decimals()once per token per chain, cache it, and never assume. Even if 99% of your tokens are 18 decimals, the 1% that is not will be your stablecoin pair. - Keep math in BigInt end-to-end. Convert to a display string only at the last possible moment, in the component that renders it.
- Validate user input before parsing.Reject more decimal places than the token supports rather than silently truncating; the silent truncation is what produces the "dapp ate 0.0001 of my balance" reports.
- Compare amounts as BigInt. Greater-than checks against
Numberwill pass for amounts the contract treats as different. - Log amounts in raw form during debugging.A console line that shows "1.5 USDC" tells you nothing about the actual transaction; a line that shows
1500000 (6 decimals)tells you the exact integer the chain will see.
Tip: When building any new dapp screen, the unit test that catches the most bugs is the one that submits the same human-readable amount through the parser, all the way through the contract simulation, and back out through the formatter, asserting that the round-trip is bit-for-bit identical for a token at 6 decimals, 8 decimals, 9 decimals, and 18 decimals. If that test passes, most of the bug class covered in this post cannot reach production.
Cross-chain: Solana and Cosmos do the same thing
The decimal pattern is not Ethereum-specific. Solana's SPL token program stores raw balances as u64 integers in the token account, with the decimals field read from the mint account. Cosmos chains use cosmossdk.io/math.Int (arbitrary-precision integers) for amounts, paired with a per-denom decimal scale defined in chain metadata. The numbers differ; the rule is identical: integers on chain, decimals only for display.
Where Solana adds an extra wrinkle is the u64 upper bound (18,446,744,073,709,551,615). For a token with 9 decimals, that maxes out at roughly 18.4 billion of the human unit, which is fine for SOL but tight for memecoins minted with trillions of supply at 6 decimals. If you ever see an Anchor program panic on a balance check, the u64 overflow is the first thing to suspect.
For working with Solana amounts specifically, BeautiCode's Solana lamports converter keeps the 109 scale explicit, and the crypto unit converter handles the broader gwei/ether/satoshi families when you are debugging cross-chain.
Wrap up
Decimal bugs are not about cryptography or consensus. They are about boring data plumbing, which is why they tend to hide in the parts of a codebase nobody reviews carefully. The fixes are equally boring: always store { rawAmount, decimals } together, never let a token amount touch a JavaScript Number, and query decimals() rather than guessing.
Treat any code path that converts between the raw integer and the human-readable string as the trust boundary where bugs live, and write the round-trip test for every token decimal value you support. That single test catches more incidents than any audit will.
Related Tools
Token Decimals Calculator
Convert between token raw units and human amounts respecting each token's decimals (USDC 6, ETH 18).
Wei / Gwei / Ether Converter
Convert between Wei, Gwei, and Ether with precision. Decimal math without floating-point errors.
Crypto Unit Converter
Convert between ETH, Wei, Gwei, BTC, and Satoshi units with precise calculations.
Solana Lamports Converter
Convert between lamports and SOL with precise arithmetic. Works with transaction fees and account rent.
Related Articles
How to Generate Secure Passwords in 2026: A Complete Guide
Credential attacks now lean on GPU clusters and ML pattern guessing. What entropy, length, and randomness actually buy you, plus the password manager picks that hold up in 2026.
2025-12-15 · 8 min readData FormatsJSON vs YAML: When to Use What — A Developer's Guide
JSON wins on APIs; YAML wins on configs. Side-by-side syntax, parser behaviour, and where each fits across Kubernetes manifests, REST payloads, and GitHub Actions.
2025-12-28 · 10 min read