Skip to content

fix(numbers): convert amounts to base units with BigNumber instead of float math - #519

Open
maymuneth wants to merge 3 commits into
0xMiden:mainfrom
maymuneth:fix/amount-precision-bignumber
Open

fix(numbers): convert amounts to base units with BigNumber instead of float math#519
maymuneth wants to merge 3 commits into
0xMiden:mainfrom
maymuneth:fix/amount-precision-bignumber

Conversation

@maymuneth

@maymuneth maymuneth commented Aug 9, 2026

Copy link
Copy Markdown

What

stringToBigInt converts a user-entered amount string into base units via parseFloat(str) * 10 ** decimals, then Math.round. Doubles carry ~15-16 significant decimal digits, so the result drifts from the true value once the amount plus its decimal scale exceeds that.

Observed:

Input decimals Expected Current
"1.1" 18 1100000000000000000 1100000000000000128
"99999999.99999999" 8 9999999999999999 9999999999999998
"12345678901.12345678" 8 1234567890112345678 1234567890112345600

User impact

This is the only path that turns the amount a user types into the base-unit figure that gets signed and broadcast. It is called from the send review (ReviewTransaction.tsx, SendManager.tsx), the swap flow (SwapManager.tsx), and the earn deposit review (EarnDepositReview.tsx). The wallet already handles 18-decimal tokens (BRIDGEABLE_EVM_OUTPUT_TOKEN_DECIMALS = 18), which is where the drift is largest.

Change

Convert through bignumber.js — already imported at the top of the file — using shiftedBy(decimals) and ROUND_HALF_UP, which matches the rounding the previous Math.round applied. Behaviour on empty input is preserved: stringToBigInt('') still throws, which SwapManager.tsx:193 relies on.

Testing

I wasn't able to run the workspace suite locally, so this is verified by evaluating the two expressions directly rather than through the test runner:

for (const [s, d] of [["1.1", 18], ["99999999.99999999", 8], ["12345678901.12345678", 8]]) {
  let n = parseFloat(s); n *= Math.pow(10, d); n = Math.round(n);
  console.log(s, d, BigInt(n).toString());
}

That reproduces the three "Current" values in the table above exactly. The "Expected" column is the digit-shift of the same input string, which is what BigNumber.shiftedBy produces.

Rounding mode is preserved: the old code used Math.round (half away from zero for positives), and ROUND_HALF_UP matches it on this input domain, which is non-negative amounts.

Empty input still throws, so the guard SwapManager.tsx relies on is unaffected: parseFloat('') gave NaNBigInt(NaN)RangeError, and new BigNumber('') gives NaNBigInt('NaN')SyntaxError. Both throw, though the error type changes. Say the word if you'd rather I add a unit test with the table cases.

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

👋 Thanks for contributing to Miden, @maymuneth!

We really appreciate you taking the time to open this pull request. Miden is building an edge-first, zero-knowledge blockchain, and thoughtful contributions from the community are a big part of how it gets better. A maintainer will review your changes as soon as they can — in the meantime, please make sure the CI checks are green and that your change follows the repository's contribution guidelines.

We're genuinely excited to have you here. 🧡


One thing we want to be transparent about up front: contributing to this repository will not make you eligible for any token airdrop, allocation, or other reward — not now, and not at any point in the future.

Thanks again for being part of the community — we're glad you chose to contribute. 🚀

@canoplos

Copy link
Copy Markdown

The core change is right and this is a real correctness bug worth fixing — parseFloat(str) * 10 ** decimals goes through IEEE-754 doubles, so anything past ~15–17 significant digits silently shifts the amount that gets signed and broadcast. Moving to BigNumber(str).shiftedBy(decimals).integerValue(ROUND_HALF_UP) does the conversion in exact decimal, and ROUND_HALF_UP preserves the old Math.round semantics for the non-negative amounts this handles. Good catch.

Two things I'd want to see before this merges, though:

  1. No tests. The PR description gives concrete failing cases ("1.1" at 18 decimals, "99999999.99999999" at 8), and those are exactly the regression tests this should add — asserting the exact base-unit result for each. Right now there's nothing locking in the fix, so it could silently regress later.

  2. Locale / malformed input behavior. stringToBigInt lives in i18n/numbers.ts and is called with user-entered amounts from the swap and earn flows. new BigNumber("1,5") yields NaNBigInt("NaN") throws, whereas the old parseFloat("1,5") returned 1. Throwing is arguably the safer behavior (better than silently sending the wrong amount), but it's a behavior change — is the input already normalized to a .-decimal before it reaches here, or could a comma-decimal locale hit this path? A test around invalid/locale input would make the intended contract explicit.

Core logic LGTM; I'd just like the regression tests and a note on the input contract.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants