fix(units): scale amounts with exact integer arithmetic - #208
fix(units): scale amounts with exact integer arithmetic#208mehmetkr-31 wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e3ea34f284
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Good catch — fixed in 6ce85aa. Measured on the flagged input,
One correction to the premise, since it changes what the regression was. On |
|
The red Cause. The reusable workflow is pinned at install-scripts:
- python3 -m pip install -q -r requirements.txt
+ uv sync --locked --python 3.12with every Makefile target now going through The last green vector step ran at 2026-08-08T00:27 under the same pin; the uv migration landed later that day. Fix. Bumping both pins in Worth considering separately: a pinned workflow driving a Also, on fork PRs generally: |
`parse_units` scaled with `d * (10**decimals)`, which is evaluated in the
active decimal context. That context defaults to 28 significant digits, so
an amount longer than that was rounded to a different value before the
integrality check ran. Because the rounded value is itself integral, the
check passed and a silently wrong base-unit amount was returned:
parse_units("999999999999999999999999999999", 0)
# -> 1000000000000000000000000000000 (off by 1)
parse_units("12345678901234567890.123456789012345678", 18)
# -> ...790000000000 (off by 987654322 base units)
Scale from the decimal's own digit tuple instead, so the result is exact at
any length. The fractional-base-unit error and every other documented
behavior are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review feedback: scaling a value such as "1e-10000000" produced a divisor
with ten million digits before the fractional-base-unit check could reject
it — about 9.4s and ~4MB for an amount that never scales cleanly.
Decide divisibility from the exponent first. `unscaled` has exactly
`len(digits)` digits, so a divisor carrying at least that many zeros cannot
divide it, and the divisor is only built when it can. The pathological
inputs now fail in well under a millisecond.
Also return early for a zero significand so "0e-10000000" stays 0 rather
than being rejected by that check.
Note for the record: on main these inputs did not raise at all. The product
underflowed below the context Emin and compared equal to its own int, so
`parse_units("1e-10000000", 6)` returned 0 — another silently wrong amount
that this branch already fixes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6ce85aa to
e50f148
Compare
|
Correction to my previous comment: the pin bump had already landed before I posted it. #210 moved both pins to The rest of the diagnosis stands, and it explains why this PR was still red: it was based on The two structural notes are unaffected: a workflow pinned by SHA while |
`decimals` was never validated. A negative value divides the amount rather
than scaling it, and only reports the loss when the division leaves a
remainder:
parse_units("100", -2) # -> 1 an amount charged 100x too low
parse_units("1000", -3) # -> 1
parse_units("7", -1) # -> ValueError
That inconsistency is the dangerous part: the raising case makes it look
like the input is validated. mpp-go rejects negative decimals outright in
tempoxyz/mpp-go#87; do the same here.
`transform_units` guarded the type with `isinstance(decimals, int)`, which
accepts `bool`, so a request carrying `"decimals": true` scaled the amount
by 10 instead of being rejected. Exclude `bool` explicitly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Pushed
>>> parse_units("100", -2)
1 # an amount charged 100x too low
>>> parse_units("1000", -3)
1
>>> parse_units("7", -1)
ValueError: ... produces fractional base unitsThe inconsistency is what makes it harmful: the raising case makes the input look validated. Reached through This is already fixed on the Go side — tempoxyz/mpp-go#87 rejects negative Also excluded 810 passed, 41 skipped; ruff clean. One thing I left alone. A very large So |
Problem
parse_unitsscales withd * (10**decimals). That multiplication is evaluated in the active decimal context, whose default precision is 28 significant digits, so an amount longer than that is rounded before the integrality check runs. The rounded value is itself integral, soresult != int(result)does not fire and a silently wrong base-unit amount is returned.The second case overstates the amount by 987,654,322 base units. No exception is raised in either case, so a caller cannot detect it.
The threshold is 28 significant digits in total, not 28 decimal places, so it is reachable from ordinary inputs: an 18-decimal token needs only 10 integer digits before the value crosses the limit.
Fix
Scale from the decimal's own digit tuple with integer arithmetic, which is exact at any length.
10**decimalsis still applied, but never through the rounding context.Everything else is unchanged on purpose:
fractional base unitserror keeps its condition and message"1e5") keeps its current behavior — see the question belowTests
Two cases added to
TestParseUnitsEdgeCases, both verified to fail onmainand pass here:uv run pytest→ 802 passed, 41 skipped.ruff checkandruff format --checkclean.One question, deliberately left out of this PR
parse_units("1e5", 6)currently returns100000000000. The module docstring says the function matches "the parseUnits behavior in the TypeScript SDK (viem's parseUnits)", and viem validates its input against a plain decimal pattern that rejects exponent notation. If that divergence is real, it is a separate cross-SDK conformance question and I did not want to change two behaviors in one PR — happy to open a follow-up if you want it aligned.Disclosure: I used an AI assistant while investigating and preparing this change; the analysis and conclusions are my own to defend.
🤖 Generated with Claude Code