Skip to content

fix: convert a human amount to raw units without exponent notation - #708

Open
ipezygj wants to merge 2 commits into
hummingbot:developmentfrom
ipezygj:fix/raw-amount-without-exponent
Open

ipezygj wants to merge 2 commits into
hummingbot:developmentfrom
ipezygj:fix/raw-amount-without-exponent

Conversation

@ipezygj

@ipezygj ipezygj commented Sep 21, 2026 •

Copy link
Copy Markdown

Before submitting this PR, please make sure:

  • Your code builds clean without any errors or warnings
  • You are using approved title ("feat/", "fix/", "docs/", "refactor/")

A description of the changes proposed in the pull request:

Fixes #622.

Math.floor(amount * Math.pow(10, decimals)).toString() prints "1e+21" once the raw amount reaches 1e21 — a thousand tokens with 18 decimals — and JSBI.BigInt and BigNumber.from both refuse that string, so a Uniswap CLMM open-position or add-liquidity of that size failed before it reached the chain, as the report describes. The float multiplication had also stopped being exact long before, from 2^53 raw units (about 9 tokens at 18 decimals).

The expression appeared 27 times across uniswap, pancakeswap, dflow, okx and titan, plus twice written with ** instead of Math.pow: in approximateBuyViaSellLeg, which every router BUY without ExactOut support goes through, and in Jupiter's quote. pancakeswap's CLMM add-liquidity had already moved to parseUnits, its siblings had not. All of them now go through one helper, toRawAmount(amount, decimals) in src/services/raw-amount.ts:

  • the digits the caller typed are kept as text (String(n), the shortest round-trip form — toFixed was not used because it prints the binary expansion, (0.1).toFixed(18) ends in …006),
  • expanded from exponent form on either side (1e-8, 1.5e21),
  • cut to the token's decimals, which is the floor the old code applied, rather than the parseUnits(amount.toString()) throw on extra digits,
  • and parseUnits does exact arithmetic on the string.

Behaviour for amounts the old code handled correctly is unchanged: the result is the same integer string.

Tests performed by the developer:

  • New test/services/raw-amount.test.ts: 1000 at 18 decimals is 1000000000000000000000 and both big-int constructors accept it (the replaced expression yields 1e+21, asserted alongside); 0.1 and 1.1 at 18 decimals are exact; extra digits are cut, not refused; 1e-8 and 1.5e21 are expanded.
  • New case in test/connectors/router-utils.test.ts: a BUY of 1000 of an 18-decimal token hands the sell leg 1000000000000000000000, not 1e+21.
  • test/connectors/{uniswap,pancakeswap,dflow,okx,titan,jupiter} and router-utils: 165 passed. pnpm typecheck, eslint and prettier clean on the changed files.

Tips for QA testing:

Repeat the report: gateway lp uniswap/clmm add-liquidity (or POST /trading/clmm/open-position) with an amount whose raw value exceeds 1e21, e.g. 1000 of an 18-decimal token. Before: Cannot convert 1e+21 to a BigInt. After: the position opens with the intended amount. A small swap quote through any of the five connectors should return the same raw amount as before.

Math.floor(amount * 10 ** decimals).toString() prints "1e+21" from a
thousand tokens with 18 decimals, and JSBI.BigInt and BigNumber.from both
refuse that, so a Uniswap CLMM open-position or add-liquidity of that size
failed before reaching the chain. The float multiplication had also lost
exact digits from 2^53 raw units.

One helper, toRawAmount, replaces the 27 sites of that expression across
uniswap, pancakeswap, dflow, okx and titan: the typed digits are kept as
text, expanded from exponent form on either side, cut to the token's
decimals as the floor was, and parseUnits does exact arithmetic on the
string. pancakeswap's CLMM add-liquidity had already moved to parseUnits;
its siblings had not.

Fixes hummingbot#622.
@greptile-apps

greptile-apps Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR appears safe to merge; the prior BUY approximation defect is fixed and no new actionable failures remain.

Findings

  1. P1 BUY conversion remains broken ▶

Summary

This PR centralizes conversion of human-readable token amounts into exact raw-unit integer strings and applies it across router, AMM, and CLMM connectors.

  • Expands exponent-form numbers before converting them to raw units.
  • Avoids floating-point multiplication and exponent notation that downstream big-integer parsers reject.
  • Updates shared BUY approximation so DFlow, OKX, Titan, and Jupiter fallback routes use the corrected conversion.
  • Adds focused tests for large, fractional, exponent-form, and excess-precision amounts.
  • The previously reported BUY conversion gap is fully fixed by routing approximateBuyViaSellLeg through toRawAmount.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
    A[Human token amount] --> B[toRawAmount]
    B --> C[Expand exponent notation]
    C --> D[Truncate to token decimals]
    D --> E[parseUnits exact conversion]
    E --> F[Raw integer string]
    F --> G[Router, AMM, or CLMM SDK]
Loading

Reviews (2) · Last reviewed commit: "fix: the BUY approximation and the Jupit..."


if (side === 'SELL') {
const amountRaw = Math.floor(amount * Math.pow(10, baseTokenInfo.decimals)).toString();
const amountRaw = toRawAmount(amount, baseTokenInfo.decimals);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 BUY conversion remains broken

This conversion only covers the SELL branch. DFlow BUY requests use approximateBuyViaSellLeg, which still converts the amount with Math.floor(baseAmount * 10 ** decimals).toString(). A BUY of 1000 units of an 18-decimal token therefore produces "1e+21" instead of a raw integer string and can fail before returning a route. Titan BUY requests have the same problem, as does OKX when ExactOut falls back to approximation.

Knowledge Base Used:

…ount path

approximateBuyViaSellLeg, which every router BUY without ExactOut goes
through, and Jupiter's quote built the raw amount with the same
expression, written with ** instead of Math.pow, so the first sweep did
not find them.
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.

1 participant