Skip to content

fix(frontend): scale token amounts with string arithmetic, not floats - #443

Merged
zachyo merged 1 commit into
soropad:masterfrom
DSOTec:fix/issue-395-tobaseunits-string-scaling
Sep 2, 2026
Merged

fix(frontend): scale token amounts with string arithmetic, not floats#443
zachyo merged 1 commit into
soropad:masterfrom
DSOTec:fix/issue-395-tobaseunits-string-scaling

Conversation

@DSOTec

@DSOTec DSOTec commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Problem

The plumbing from #320 is correct — supply is typed as a string in the schema,
stays a string through the form, and every caller routes through one shared
helper. toBaseUnits then threw the guarantee away on its first line:

const amount = typeof display === "string" ? Number(display) : display;
return BigInt(Math.round(amount * (10 ** decimals)));

Number() puts the value back into an IEEE-754 double before the multiply,
which is exactly what #253 and then #320 were raised to stop. Wrapping the
result in BigInt cannot recover precision that is already gone.

The multiply is exact only while supply * 5 ** decimals < 2 ** 53, so the
safe ceiling collapses as decimals rise — about 115 billion at 7 decimals, but
2,361 at 18. Reproduced against the old helper:

Input Produced Expected Short by
1,000,000 @ 18 999999999999999983222784 1000000000000000000000000 16,777,216
123,456,789,012,345 @ 7 1234567890123449958400 1234567890123450000000 41,600

Worth noting the blast radius is wider than deploy: the helper has eleven
call sites
across transfer, burn, mint, mint_batch and vesting, so this
reached every amount the app sends.

Solution

Scale by moving the decimal point through string surgery: split on ., reject
a fraction longer than the token's decimals, right-pad, concatenate, and
parse once with BigInt. A string amount never passes through a Number.

number is still accepted for the callers that pass literals, but is expanded
out of exponent notation first — String(1e21) is "1e+21", which no string
surgery survives. The doc comment now says plainly that a number beyond
~15 significant digits has already lost precision before the call, and to pass
a string.

Two edges worth calling out explicitly:

  • An absent or unparseable amount still yields 0n. Preview and preflight
    paths call this against half-filled forms, so throwing there would break the
    deploy wizard's fee estimate.
  • More fraction digits than the token supports now throws rather than
    silently rounding. toBaseUnits("1.12345678", 7) used to quietly become
    1.1234568 — sending an amount the user did not ask for, which is the same
    class of bug this function exists to prevent. I checked all eleven call
    sites: every one already runs inside a try/catch that surfaces the message
    as a toast or an error panel, so this becomes a visible, actionable error
    rather than a crash.

Testing

The reason the bug survived review is the existing test file: it covered
1,000,000 at 7 decimals and 0.1 at 7 decimals — small, round values that
survive the float exactly — so every assertion passed against the buggy
helper
, including the ScVal case #319 asked for.

The added regression tests include both failing cases from the issue. I checked
they are real coverage rather than more false confidence — five of the six
new assertions fail against the old implementation
:

FAIL (catches the bug)   1000000 @ 18
FAIL (catches the bug)   123456789012345 @ 7
FAIL (catches the bug)   38 nines @ 7
FAIL (catches the bug)   1.234567890123456789 @ 18
FAIL (catches the bug)   reject 1.12345678 @ 7
PASS (would NOT have caught)  exponent 1e21 @ 0

Also added: exactness across every decimals value 0–18, amounts past
Number.MAX_SAFE_INTEGER up to the 38-digit schema limit, fraction padding,
sign preservation, exponent-notation input, invalid decimals, and an exact
fromBaseUnits round-trip at 18 decimals.

lib/__tests__/utils.test.ts: 25 passed. Full suite unchanged from master
apart from the additions — same 5 pre-existing failing suites before and after,
180 → 192 passing. eslint clean on both changed files; tsc reports no new
errors.

Scope

Two files, no behaviour change to any caller beyond correctness. I deliberately
did not touch app/deploy/DeployForm.tsx or app/hooks/useDeployToken.ts:
the schema there already constrains supply to /^[0-9]+$/, so the fix in the
helper is sufficient and the call sites need no change.

Two pre-existing problems found along the way

Neither is addressed here — flagging them because both are worth their own
issue.

1. useDeployToken.ts is structurally broken. npm run type-check fails on
master with four syntax errors, three of them in this file. The cause is a bad
merge: the entire const deployToken = useCallback(async (params) => {
declaration was dropped, leaving an orphaned body, dependency array and );.
app/claim/ClaimVesting.tsx:37 has a matching break — useState lost its
opening <.

I repaired both, and it revealed worse damage behind them: initializeContract
(live, called at line 241) splices a typed-TokenClient construction into a raw
TransactionBuilder chain, and references adminScVal, decimalScVal,
nameScVal and others that are never defined. That is a reimplementation of the
deploy-initialize step, not a repair, and it is a maintainer's call. I reverted
my two fixes rather than leave this PR reporting eight type errors instead of
four while still not being green.

2. .github/workflows/ci.yml is still an invalid workflow file. As noted in
#437, components: is a YAML sequence where Actions requires a scalar, so the
file fails validation — every run is 0s, failure, zero jobs, listed under
its file path rather than name: CI. It is still failing this way on master
as of dfe5db4. The fix is components: rustfmt, clippy. Until that lands, the
Lint / Type Check / Jest steps this PR would be measured against do not execute
at all, which is why the two breakages above went unnoticed.

Closes #395

soropad#320 got the plumbing right — supply is typed as a string in the schema,
stays a string through the form, and every caller routes through one
shared helper. toBaseUnits then threw the guarantee away on its first
line:

    const amount = typeof display === "string" ? Number(display) : display;
    return BigInt(Math.round(amount * (10 ** decimals)));

Number() puts the value back into an IEEE-754 double before the multiply,
which is what soropad#253 and soropad#320 were raised to stop, and wrapping the result
in BigInt cannot recover precision already gone. The multiply is exact
only while supply * 5^decimals < 2^53, so the safe ceiling collapses as
decimals rise: about 115 billion at 7 decimals, but 2,361 at 18.

Measured against the old helper:
- 1,000,000 at 18 decimals minted 999999999999999983222784 rather than
  1000000000000000000000000 — 16,777,216 base units short.
- 123,456,789,012,345 at 7 decimals was 41,600 base units short.

This reached every amount the app sends, not just deploy: the helper has
eleven call sites across transfer, burn, mint, mint_batch and vesting.

Scale by moving the decimal point through string surgery instead —
split on ".", reject a fraction longer than the token's decimals,
right-pad, concatenate, and parse once with BigInt. A string amount now
never passes through a Number. Numbers are still accepted for the
callers that pass literals, and are expanded from exponent notation
first, since String(1e21) is "1e+21" and no string surgery survives that.

Two edges worth naming:
- An absent or unparseable amount still yields 0n, because preview and
  preflight paths call this against half-filled forms.
- A well-formed amount with more fraction digits than the token supports
  now throws rather than silently rounding. Rounding would send an
  amount the user did not ask for, which is the same class of bug this
  function exists to prevent. Every call site already runs inside a
  try/catch that surfaces the message, so this shows up as a visible
  error rather than a wrong transfer.

The existing tests covered 1,000,000 at 7 decimals and 0.1 at 7
decimals — small round values that survive the float exactly — so every
assertion passed against the buggy helper. The added regression tests
include both failing cases above; five of the six new assertions fail
against the old implementation.

Closes soropad#395
@drips-wave

drips-wave Bot commented Aug 31, 2026

Copy link
Copy Markdown

@DSOTec Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@zachyo
zachyo merged commit 4448605 into soropad:master Sep 2, 2026
1 check failed
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.

Frontend: toBaseUnits is floating point, so high-decimal tokens mint the wrong supply (regression of #320)

2 participants