DEV Community

member_fb93637b
member_fb93637b

Posted on

I fuzzed 50,000 dollar amounts across my Python JS ports and found a silent 1.2% rounding bug

I maintain ~10 small zero-dependency CLI tools for freelancer admin (invoicing, late fees, currency conversion, tax estimates) — some in Python, some ported to Node so people can npx them without installing anything. Porting money-handling code between the two languages taught me a lesson the hard way: Math.round(n * 100) / 100 and Python's round(n, 2) do not agree 1.2% of the time, and the disagreement is silent — no crash, no NaN, just a wrong cent that only shows up on specific inputs.

The setup

Every port had the same shape: read a dollar/ruble amount, do arithmetic (apply a rate, a fee, a tax bracket), round to 2 decimals, print it. I'd hand-write 5-10 test cases per port, compare output against the Python original byte-for-byte, and ship once they matched. That caught obvious bugs. It did not catch this one, because none of my hand-picked test cases happened to land exactly on a rounding boundary.

Why they disagree

Python's round() uses round-half-to-even (banker's rounding) and operates on the exact binary value of the float. JavaScript has no built-in decimal round — the common pattern is:

Math.round(n * 100) / 100
Enter fullscreen mode Exit fullscreen mode

This rounds half-up, but more importantly, n * 100 is itself a new floating-point multiplication that can land on a different side of .5 than the original value, because n * 100 isn't the same double as "the true value times 100" — it's the double closest to that product.

A real one that shipped and sat live for days before I caught it — converting USDT to RUB at a P2P rate (85900 USDT at a rate of 117.1):

Python (ground truth):   9993507.21
JS (Math.round, shipped): 9993507.22
Enter fullscreen mode Exit fullscreen mode

One cent off, on a 7-figure number, with zero errors or warnings anywhere.

How I actually found it

Not by reading the code harder — I'd already read it. I wrote a fuzzer: generate 50,000 random amounts, rates, and fees, run both the Python original and the JS port on each, diff the outputs.

50,000 random inputs → 1.2% disagreed by exactly 1 cent
Enter fullscreen mode Exit fullscreen mode

1.2% doesn't sound like much until you remember every one of those is a real dollar amount a real invoice would show to a real client.

The fix

Round-half-to-even on the exact decimal expansion of the double, not on an intermediate n * 100 multiplication:

function round2(n) {
  if (n === 0) return 0;
  const neg = n < 0;
  const s = Math.abs(n).toFixed(20);          // exact decimal expansion of the double
  const [intPart, fracPart] = s.split(".");
  const keep = fracPart.slice(0, 2);
  const rest = fracPart.slice(2);
  let roundUp;
  if (rest[0] > "5") roundUp = true;
  else if (rest[0] < "5") roundUp = false;
  else {
    const restAfter = rest.slice(1).replace(/0+$/, "");
    roundUp = restAfter.length > 0 || parseInt(keep[1] || "0", 10) % 2 === 1;
  }
  let keepNum = BigInt(intPart + keep.padEnd(2, "0"));
  if (roundUp) keepNum += 1n;
  const result = Number(keepNum) / 100;
  return neg ? -result : result;
}
Enter fullscreen mode Exit fullscreen mode

It's uglier than one line of Math.round, but it agrees with Python on all 50,000 fuzzed inputs — and on every input since.

What I'd actually recommend

If you're porting money logic between languages (or even just adding decimal rounding to a JS app that touches money), don't trust hand-picked test cases to catch rounding-boundary bugs — they're specifically the inputs you're least likely to think of by hand. A quick fuzz-and-diff against a reference implementation (or against toFixed + exact decimal math, if you don't have one) takes a few minutes to write and will catch the 1-in-80 case your test suite won't.

The affected tools (now fixed) are open source, zero-dependency, MIT-licensed:

The rest of these money-math tools ship together in the Freelancer Starter Kit — invoicing, contracts, NDAs, timesheets, all the same zero-dependency philosophy.

Top comments (0)