Skip to content
Bogdan Umaniuk
WritingWorkCV
© 2026 Bogdan UmaniukLinkedInTelegram channelEmailRSS

26 Oct 2026, 10 min read

Why 0.1 + 0.2 breaks wallets: money math in JavaScript and every other language

Real rounding bugs from a crypto exchange app, why floats can't hold money, which way to round fees and balances, and when to use BigNumber or BigInt.

I work on the mobile app of a crypto exchange, in React Native and native Android. Most of the money bugs I've run into came from small rounding decisions in the client that looked harmless in code review and broke on real balances. The most common one involves the Max button.

The Max button that says "insufficient funds"

A user has 10.4567 USDT. The screen shows two decimals, so somebody writes:

const shown = balance.toFixed(2) // "10.46"

The user taps Max, the field fills with 10.46, and the server answers "insufficient funds". They only have 10.4567.

toFixed rounds to the nearest value, and .4567 is closer to .46 than to .45. For anything a user can spend, that's the wrong direction. You need to round down:

import BigNumber from 'bignumber.js'
 
new BigNumber('10.4567').decimalPlaces(2, BigNumber.ROUND_DOWN).toFixed(2) // "10.45"

Even with the right rounding direction, the number you round is often already off, because of how floats store decimals.

Why 0.1 + 0.2 isn't 0.3

0.1 + 0.2   // 0.30000000000000004
0.07 * 100  // 7.000000000000001

Computers store numbers in base 2. In base 2 you can only write fractions exactly when the denominator is a power of two: 0.5, 0.25, 0.125. Values like 0.1, 0.07 or 0.001 become infinite fractions, the same way 1/3 is 0.3333... in base 10. The machine cuts them off at 53 bits and keeps the nearest value it can store.

This is what actually sits in memory:

0.001 → 0.001000000000000000020816682...
0.07  → 0.07000000000000000666133815...

JavaScript prints 0.07 because that's the shortest decimal that maps back to the same stored value. The tail is still there. Multiply by 100 and the tail grows to 7.000000000000000666.... Near 7, a double can only represent values about 0.00000000000000089 apart, and the nearest one to that result is 7.000000000000000888.... That gets printed as 7.000000000000001.

In money code that tail shows up in a few predictable places.

Five ways floats turn into money bugs

Converting to cents loses a cent

1.13 * 100              // 112.99999999999999
Math.floor(1.13 * 100)  // 112

The user typed 1.13 and the app sent 1.12. Or a "minimum amount is 1.13" check fails on exactly 1.13.

toFixed rounds the stored value, not the one you see

(1.005).toFixed(2) // "1.00"

You'd expect 1.01. But 1.005 is stored as 1.00499999..., and toFixed rounds what's stored. The app shows one value and the backend, which uses decimals, calculates another.

Sums drift

let total = 0
for (let i = 0; i < 10; i++) total += 0.1
total        // 0.9999999999999999
total === 1  // false

Ten fees of 0.1 don't add up to 1. A transaction history total stops matching the balance, and any check like "parts must equal the whole" fails.

Crypto amounts don't fit at all

ETH has 18 decimals. One ether is 10^18 wei, which is already past the range where a double can hold whole numbers exactly:

Number.isSafeInteger(1e18)           // false
parseFloat('1.123456789012345678')   // 1.1234567890123457

parseFloat drops the last digits without any error. For token amounts that means the number you display, or worse, the number you send, is quietly different from the real one.

JSON drops precision in transit

If an API returns { "amount": 0.1234567890123456789 }, the JSON parser on the client usually turns it into a double and cuts it. The backend kept the precision and the wire threw it away. This is why financial APIs send amounts as strings.

A real one: a Max button that offers more than you have

This one came from a P2P trading screen in an app I work on. The user sells crypto for fiat. The Max button worked like this:

  1. Take the crypto balance, multiply it by the price, round to 2 decimals. That's the max fiat amount.
  2. Take that fiat amount, divide it by the price, round to the crypto precision. That's the crypto amount.

The code already used BigNumber. The problem was its default rounding mode, ROUND_HALF_UP: step 1 sometimes rounds the fiat amount up, and step 2 converts that inflated amount back into crypto.

With a balance of 896.117400 and a price of 86.16:

896.1174 × 86.16  = 77209.475184
rounded           = 77209.48     (up)
77209.48 / 86.16  = 896.117456   (more than the balance)

I ran it on 20,000 random balances and prices:

Crypto precisionMax is above the balance
2 decimals0%
4 decimals~6%
6 decimals~50%
8 decimals~50%

For coins with 6 or 8 decimals, the button picked a number larger than the balance about half the time. The form only checked that the crypto amount was greater than zero, so nothing on the client caught it. The amount was then sent with Number(values.amount), which turns it into a double right before the request. What happens next depends on the backend. Most likely the user gets "insufficient funds" when they submit the deal.

Which way to round

For money, rounding should always land on the side that can't create a deficit.

Anything the user can spend or receive rounds down: available balance, Max, the payout amount. Anything the user pays rounds up: fees, the amount to charge.

Fees are where the second rule matters. Take a 0.1% fee and a currency with 2 decimals:

transfer 0.5 USDT → fee = 0.0005
round down → 0.00

That transfer is free. Split 1000 USDT into 2000 transfers of 0.5 and nobody pays anything. This is a known abuse pattern, and even without bad intent, losing a cent on millions of operations adds up.

Rounding up has its own problem: the fee on 0.5 becomes 0.01, which is 2%. That's why exchanges also set a minimum operation amount or a minimum fee.

Max depends on where the fee comes from

Same balance, 10.4567, 2 decimals, 0.1% fee.

If the fee is taken out of the amount, Max is the balance rounded down, 10.45. The fee is 0.01045, rounded up to 0.02, and the recipient gets 10.43.

If the fee is charged on top, Max has to satisfy amount + fee ≤ balance:

AmountFee (2 decimals, up)TotalFits in 10.4567?
10.450.0210.47no
10.440.0210.46no
10.430.0210.45yes

If the fee is kept with 8 decimals, as exchanges usually do:

AmountFee (8 decimals)TotalFits?
10.450.0104510.46045no
10.440.0104410.45044yes

So the same balance gives four different Max values: 10.46 (the bug), 10.45, 10.44 or 10.43. Which one is right depends on rules the client doesn't own. The backend defines the fee model, the precision of amounts and the precision of fees. The client either mirrors those rules exactly or asks the backend for the max amount directly. I prefer the second option.

Rounding up on a double has its own bug

Rounding fees up sounds simple:

const ceil2 = (x) => Math.ceil(x * 100) / 100

But the fee on 70 at 0.1% should be exactly 0.07:

70 * 0.001        // 0.07
0.07 * 100        // 7.000000000000001
Math.ceil(7.000000000000001) / 100 // 0.08
AmountExpected feeMath.ceil on a double
700.070.08
1400.140.15
2800.280.29
11001.101.11
13001.301.30

The last row is fine because 1.3 * 100 happens to land exactly on 130. So the bug depends on the amount, which makes it hard to reproduce. Math.ceil does nothing wrong here; it rounds the tail it was given. The fix is to never put money into a double:

const fee = (amount, rate) =>
  new BigNumber(amount)
    .times(rate)
    .decimalPlaces(2, BigNumber.ROUND_UP)
    .toFixed(2)
 
fee('70', '0.001')    // "0.07"
fee('70.01', '0.001') // "0.08"

It's a double problem, in every language

JavaScript's number is an IEEE 754 double, the same format as the default float in almost every mainstream language:

# Python
0.1 + 0.2    # 0.30000000000000004
0.07 * 100   # 7.000000000000001
# Ruby
0.1 + 0.2    # 0.30000000000000004
0.07 * 100   # 7.000000000000001
// Swift
0.1 + 0.2    // 0.30000000000000004
0.07 * 100   // 7.000000000000001

Java and Kotlin Double, Go float64 and C# double use the same format. A backend in any of them will charge 0.08 instead of 0.07 if it rounds fees up on a double.

Mature financial backends avoid this by doing money math in decimal types:

Language or layerType for money
Java / KotlinBigDecimal
RubyBigDecimal
Pythondecimal.Decimal
C#decimal
Goshopspring/decimal, or integers in minor units
SQLNUMERIC / DECIMAL columns
JavaScript / TypeScriptbignumber.js, decimal.js, big.js, or BigInt in minor units

Java's BigDecimal is the standard tool for money in the language a lot of banking software is written in. So the server is often the safest part of the system. Precision usually gets lost at the edges: when the client does its own math, when amounts travel as JSON numbers, and when a form value goes through Number() right before a request.

BigNumber or BigInt?

Who uses decimal libraries

bignumber.js, decimal.js and big.js were all written by the same developer, Michael Mclaughlin. In the week of October 16–22, 2026, npm counted about 65 million downloads for decimal.js, 42 million for bignumber.js and 20 million for big.js.

Several crypto SDKs depend on them. xrpl.js, the XRP Ledger SDK, and @stellar/stellar-base depend on bignumber.js. Aave's @aave/math-utils expects it as a peer dependency, and @0x/utils ships with it. Uniswap's @uniswap/sdk-core uses big.js together with JSBI. On the banking side, the same job is done by BigDecimal in Java and Kotlin backends, and fintech front ends commonly reach for one of these libraries once rates or percentages are involved.

They're used because they do decimal arithmetic with a precision and a rounding mode you set yourself. Fees and exchange rates need division, and division needs a rounding rule.

Why BigInt alone doesn't fit

BigInt is built into the language and handles arbitrarily large integers exactly, but only integers:

BigInt(0.07)    // RangeError: not an integer
BigInt('0.07')  // SyntaxError

To use it for money you have to work in minor units: 70.00 becomes 7000n cents, 1.5 ETH becomes 1500000000000000000n wei. That's a valid approach, and it brings some work with it:

7n / 2n                     // 3n, division truncates, there's no rounding mode
1n + 1                      // TypeError: Cannot mix BigInt and other types
JSON.stringify({ a: 1n })   // TypeError: Do not know how to serialize a BigInt

You track the scale of every currency yourself (2 decimals for USD, 8 for BTC, 18 for ETH), write your own rounding, convert percentages and rates into fixed-point integers, and handle serialization. The conversion into minor units also has to start from a string. BigInt(Math.round(0.07 * 100)) puts a float right back into the chain.

A minimal version looks like this:

// "70.00" -> 7000n, parsed from the string, never from a float
const toMinor = (s, decimals) => {
  const [int, frac = ''] = s.split('.')
  if (frac.length > decimals) throw new Error('too many decimals')
  return BigInt(int + frac.padEnd(decimals, '0'))
}
 
// division that rounds up
const ceilDiv = (n, d) => (n + d - 1n) / d
 
const amount = toMinor('70.00', 2)          // 7000n
const fee = ceilDiv(amount * 10n, 10000n)   // 0.1% = 10 basis points -> 7n, i.e. 0.07

BigInt is a good fit for on-chain values, which are integers by definition. ethers v6 dropped its own BigNumber class in favor of native BigInt for wei amounts, which are whole numbers. Once you show those amounts to a person, convert currencies or apply a fee rate, you're back to decimals with rounding rules.

In practice most teams combine the two. Amounts are stored and sent as strings or integers in minor units, and calculations go through a decimal library with explicit rounding.

Checklist

  1. Money is never a number (or float, or double). Use a decimal type or integers in minor units.
  2. Amounts travel as strings in JSON, in both directions.
  3. Never call Number() or parseFloat() on an amount right before a request.
  4. Always pass a rounding mode. bignumber.js defaults to ROUND_HALF_UP, which is wrong for both balances and fees.
  5. Round spendable amounts down and fees up.
  6. Take the precision of amounts and fees from the backend, per currency. Don't hardcode toFixed(2).
  7. For Max, ask the backend or mirror its fee model exactly, and validate amount + fee ≤ balance on the client too.

The 10.4567 from the first example is a good test case. If your Max button shows 10.46 for it, you have the bug.

New posts go to my Telegram channel first: t.me/umaniuk_dev.

I'm Bogdan Umaniuk, a senior mobile engineer working on fintech and crypto apps, open to senior roles. You can write to me directly at @umaniukbogdan.

More writing