AMM constant-product math runs on native u128 with no 256-bit widening — 18-dec pools overflow-revert at ~18 tokens/side #464

Closed
opened 2026-07-01 12:57:00 +00:00 by Brouie · 6 comments
Brouie commented 2026-07-01 12:57:00 +00:00 (Migrated from gitlab.com)

Came out of the pre-launch security sweep on the pair contract. This one's a core-liveness bug, not a rounding nit: the whole constant-product engine does k = reserve_a * reserve_b in native Uint128, and for any 18-decimal asset that product overflows at a laughably small TVL. Rolls up under the #381 hardening umbrella.

What / where

Every product in the AMM math is a plain Uint128::checked_mul — a u128 * u128 — with no Uint256 widening anywhere. grep -rc Uint256 smartcontracts/contracts/pair/src/ returns nothing; the pair contract has zero 256-bit math.

The k products specifically:

  • smartcontracts/contracts/pair/src/contract.rs:1088 — let k = input_reserve.checked_mul(output_reserve)? (main swap)
  • contract.rs:1093 — new_k = new_input_reserve.checked_mul(new_output_reserve)? (invariant recheck)
  • contract.rs:2366 — same k product in the swap simulation path
  • hybrid_reverse.rs:35 — same k in the hybrid net-output helper

And the sibling products that blow up on the same reserve magnitudes:

  • contract.rs:808 — spread calc, pool_input.checked_mul(output_reserve)
  • contract.rs:1570 — first-deposit LP mint, amount_a.checked_mul(amount_b) then isqrt
  • contract.rs:1587 / :1589 — subsequent mint, amount * total_supply
  • contract.rs:1753 / :1755 — withdraw numerators, lp_amount * reserve

Why it happens (mechanism)

Uint128::checked_mul is a straight u128 * u128. u128::MAX ~= 3.40e38. The moment reserve_a * reserve_b exceeds that it returns Err, and the ? at 1088 turns it into ContractError::Overflow — the tx reverts.

sqrt(u128::MAX) ~= 1.8446e19. So a roughly balanced pool where each side sits above ~1.8446e19 raw units overflows the product. Pool assets are allowed up to 18 decimals (MAX_PAIR_ASSET_DECIMALS_BOOTSTRAP = 18, packages/dex-common/src/pair.rs:65). For an 18-decimal token, 1 whole token = 1e18 raw, so 1.8446e19 raw ~= 18.4 whole tokens per side.

The kicker: the doc comment right above that constant (pair.rs:62-64) already knows this — "Higher decimals are rejected because realistic deposits can overflow Uint128 in amount_a * amount_b." But the cap is set at 18, which is exactly the decimals that overflow at ~18 tokens. The guard names the right failure mode and then picks the value that triggers it.

How to hit it

Nothing exotic, no attacker needed:

  1. Create an 18-dec / 18-dec pair (allowed by the factory).
  2. Provide liquidity to grow each reserve past ~1.85e19 raw — i.e. ~20 whole tokens a side.
  3. Every swap now reverts with Overflow at contract.rs:1088. The sim (:2366) reverts too, so quotes die as well.

Even seeding straight to that size in a single provide_liquidity reverts at :1570 (amount_a * amount_b before the isqrt), so you can't even open a normally-sized 18-dec pool in one shot.

Impact

DoS / core liveness. For 18-decimal assets the DEX is unusable at trivial TVL — ~20 tokens a side and swaps + quotes are bricked. That's most ERC20-style tokens, so it's a launch blocker for any 18-dec listing.

Not fund-loss: withdraws numerators (:1753/:1755) are lp * reserve (single reserve, not a product), and partial withdrawals keep each intermediate under the ceiling, so LPs can pull out in chunks. Funds are recoverable, the pool just can't trade.

Fix direction

Do the products in Uint256 and narrow back at the end — the Astroport / Uniswap-V2 pattern. k, the LP mint (isqrt over a Uint256 product), the spread numerator, and the mint/withdraw numerators all widen cleanly; CosmWasm ships Uint256 with isqrt and try_into::<Uint128>(). That lifts the practical ceiling out of reach and lets you actually raise or drop the decimals cap on its own merits instead of using it as an overflow guard that doesn't guard.

If widening everything is too big a lift pre-launch, the stopgap is capping reserve magnitude (or lowering MAX_PAIR_ASSET_DECIMALS_BOOTSTRAP well below 18), but that just trades a hard revert for an artificial TVL ceiling and breaks 18-dec support — the real fix is the 256-bit math.

Same root-cause family as the oracle-panic finding (unwidened native-width arithmetic on reserve products) — worth cross-linking so they get audited and patched together.

@PlasticDigits flagging this as launch-blocker — 18-dec pools are dead on arrival above ~20 tokens/side until the constant-product math is widened.

Came out of the pre-launch security sweep on the pair contract. This one's a core-liveness bug, not a rounding nit: the whole constant-product engine does `k = reserve_a * reserve_b` in native `Uint128`, and for any 18-decimal asset that product overflows at a laughably small TVL. Rolls up under the #381 hardening umbrella. ## What / where Every product in the AMM math is a plain `Uint128::checked_mul` — a `u128 * u128` — with no `Uint256` widening anywhere. `grep -rc Uint256 smartcontracts/contracts/pair/src/` returns nothing; the pair contract has zero 256-bit math. The `k` products specifically: - `smartcontracts/contracts/pair/src/contract.rs:1088` — `let k = input_reserve.checked_mul(output_reserve)?` (main swap) - `contract.rs:1093` — `new_k = new_input_reserve.checked_mul(new_output_reserve)?` (invariant recheck) - `contract.rs:2366` — same `k` product in the swap simulation path - `hybrid_reverse.rs:35` — same `k` in the hybrid net-output helper And the sibling products that blow up on the same reserve magnitudes: - `contract.rs:808` — spread calc, `pool_input.checked_mul(output_reserve)` - `contract.rs:1570` — first-deposit LP mint, `amount_a.checked_mul(amount_b)` then `isqrt` - `contract.rs:1587 / :1589` — subsequent mint, `amount * total_supply` - `contract.rs:1753 / :1755` — withdraw numerators, `lp_amount * reserve` ## Why it happens (mechanism) `Uint128::checked_mul` is a straight `u128 * u128`. `u128::MAX ~= 3.40e38`. The moment `reserve_a * reserve_b` exceeds that it returns `Err`, and the `?` at 1088 turns it into `ContractError::Overflow` — the tx reverts. `sqrt(u128::MAX) ~= 1.8446e19`. So a roughly balanced pool where each side sits above ~1.8446e19 raw units overflows the product. Pool assets are allowed up to 18 decimals (`MAX_PAIR_ASSET_DECIMALS_BOOTSTRAP = 18`, `packages/dex-common/src/pair.rs:65`). For an 18-decimal token, 1 whole token = 1e18 raw, so 1.8446e19 raw ~= **18.4 whole tokens per side**. The kicker: the doc comment right above that constant (`pair.rs:62-64`) already knows this — *"Higher decimals are rejected because realistic deposits can overflow Uint128 in amount_a \* amount_b."* But the cap is set **at** 18, which is exactly the decimals that overflow at ~18 tokens. The guard names the right failure mode and then picks the value that triggers it. ## How to hit it Nothing exotic, no attacker needed: 1. Create an 18-dec / 18-dec pair (allowed by the factory). 2. Provide liquidity to grow each reserve past ~1.85e19 raw — i.e. ~20 whole tokens a side. 3. Every swap now reverts with `Overflow` at `contract.rs:1088`. The sim (`:2366`) reverts too, so quotes die as well. Even seeding straight to that size in a single `provide_liquidity` reverts at `:1570` (`amount_a * amount_b` before the `isqrt`), so you can't even open a normally-sized 18-dec pool in one shot. ## Impact DoS / core liveness. For 18-decimal assets the DEX is unusable at trivial TVL — ~20 tokens a side and swaps + quotes are bricked. That's most ERC20-style tokens, so it's a launch blocker for any 18-dec listing. Not fund-loss: withdraws numerators (`:1753/:1755`) are `lp * reserve` (single reserve, not a product), and partial withdrawals keep each intermediate under the ceiling, so LPs can pull out in chunks. Funds are recoverable, the pool just can't trade. ## Fix direction Do the products in `Uint256` and narrow back at the end — the Astroport / Uniswap-V2 pattern. `k`, the LP mint (`isqrt` over a `Uint256` product), the spread numerator, and the mint/withdraw numerators all widen cleanly; CosmWasm ships `Uint256` with `isqrt` and `try_into::<Uint128>()`. That lifts the practical ceiling out of reach and lets you actually raise or drop the decimals cap on its own merits instead of using it as an overflow guard that doesn't guard. If widening everything is too big a lift pre-launch, the stopgap is capping reserve magnitude (or lowering `MAX_PAIR_ASSET_DECIMALS_BOOTSTRAP` well below 18), but that just trades a hard revert for an artificial TVL ceiling and breaks 18-dec support — the real fix is the 256-bit math. Same root-cause family as the oracle-panic finding (unwidened native-width arithmetic on reserve products) — worth cross-linking so they get audited and patched together. @PlasticDigits flagging this as launch-blocker — 18-dec pools are dead on arrival above ~20 tokens/side until the constant-product math is widened.
Brouie commented 2026-07-01 12:58:30 +00:00 (Migrated from gitlab.com)

Companion finding, same root cause (unwidened native-width reserve math): #465 (oracle_update from_ratio panic -> brick + fund lock). Both want the Uint256 widening / graceful-overflow fix, worth patching + auditing together.

Companion finding, same root cause (unwidened native-width reserve math): #465 (oracle_update from_ratio panic -> brick + fund lock). Both want the Uint256 widening / graceful-overflow fix, worth patching + auditing together.
Brouie commented 2026-07-01 12:58:31 +00:00 (Migrated from gitlab.com)

mentioned in issue #465

mentioned in issue #465
Brouie commented 2026-07-01 13:19:20 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1002

mentioned in merge request !1002
Brouie commented 2026-07-01 13:19:35 +00:00 (Migrated from gitlab.com)

Drafted the fix — MR !1002 (branch qa/464-465-amm-256bit-widening, commit 4811caf9). Widened every reserve product in the pair (swap k + invariant, LP mint/withdraw, spread, hybrid book scale, sim, and hybrid_reverse pool-net/seed) to Uint256 and narrow back to Uint128. Behaviour is identical for existing small-reserve cases — full contract suite 459/0 — plus a pool_net_output_survives_18dec_scale_reserves regression at 2e19/side that overflowed pre-fix. Handled together with #465 in the one MR (same file, same root cause). Needs review @PlasticDigits.

Drafted the fix — MR !1002 (branch qa/464-465-amm-256bit-widening, commit 4811caf9). Widened every reserve product in the pair (swap k + invariant, LP mint/withdraw, spread, hybrid book scale, sim, and hybrid_reverse pool-net/seed) to Uint256 and narrow back to Uint128. Behaviour is identical for existing small-reserve cases — full contract suite 459/0 — plus a pool_net_output_survives_18dec_scale_reserves regression at 2e19/side that overflowed pre-fix. Handled together with #465 in the one MR (same file, same root cause). Needs review @PlasticDigits.
PlasticDigits commented 2026-07-01 13:55:41 +00:00 (Migrated from gitlab.com)

mentioned in commit fcdcbd87f2

mentioned in commit fcdcbd87f20d7a74d7bd56bf50d77d0a97671ccc
PlasticDigits commented 2026-07-07 02:13:46 +00:00 (Migrated from gitlab.com)

Verification — #464 already fixed on main

The Uint256 widening fix landed in commit 4811caf9 via !1002 (qa/464-465-amm-256bit-widening). No additional implementation was required on current main (fcdcbd87).

Acceptance criteria

Criterion Verification Result
Reserve products (k, LP mint/withdraw, spread, hybrid scale, sim, hybrid_reverse) widened to Uint256 with narrow-back to Uint128 grep -rc Uint256 smartcontracts/contracts/pair/src/ → 23 hits in contract.rs + hybrid_reverse.rs; swap path uses u256(input_reserve).checked_mul(u256(output_reserve)) at contract.rs:1118 PASS
18-dec pools (~20 tokens/side, 2e19 raw) no longer overflow-revert on swap/quote math cargo test -p cl8y-dex-pair pool_net_output_survives_18dec PASS
Existing small-reserve behaviour unchanged Full suite make test-contracts — 393 integration + 44 pair unit + 21 dex-common = 459/0 PASS
Near-ceiling reserves still work cargo test -p cl8y-dex-tests test_swap_reserves_near_u128_max PASS
Oracle overflow handled (companion #465, same MR) cargo test -p cl8y-dex-pair oracle_overflow — 2/2 PASS

Commands run

make test-contracts                                          # 459/0
cargo test -p cl8y-dex-pair pool_net_output_survives_18dec # 1/1
cargo test -p cl8y-dex-pair oracle_overflow                # 2/2
cargo test -p cl8y-dex-tests test_swap_reserves_near_u128_max # 1/1
grep -rc Uint256 smartcontracts/contracts/pair/src/          # contract.rs:17, hybrid_reverse.rs:6

Third-party re-check

  1. git pull on main (≥ 4811caf9).
  2. make test-contracts — expect 459/0.
  3. cargo test -p cl8y-dex-pair pool_net_output_survives_18dec — must pass (pre-fix reverts on reserve_a * reserve_b at 2e19/side).

Follow-up (non-blocking)

MAX_PAIR_ASSET_DECIMALS_BOOTSTRAP doc comment in packages/dex-common/src/pair.rs:62-64 still cites Uint128 overflow as the rationale for the decimals cap; with #464 fixed the cap can be revisited on its own merits. No functional blocker.

Closing — launch-blocker resolved on main.

## Verification — #464 already fixed on `main` The Uint256 widening fix landed in commit `4811caf9` via [!1002](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/merge_requests/284) (`qa/464-465-amm-256bit-widening`). No additional implementation was required on current `main` (`fcdcbd87`). ### Acceptance criteria | Criterion | Verification | Result | |-----------|--------------|--------| | Reserve products (`k`, LP mint/withdraw, spread, hybrid scale, sim, `hybrid_reverse`) widened to `Uint256` with narrow-back to `Uint128` | `grep -rc Uint256 smartcontracts/contracts/pair/src/` → 23 hits in `contract.rs` + `hybrid_reverse.rs`; swap path uses `u256(input_reserve).checked_mul(u256(output_reserve))` at `contract.rs:1118` | **PASS** | | 18-dec pools (~20 tokens/side, 2e19 raw) no longer overflow-revert on swap/quote math | `cargo test -p cl8y-dex-pair pool_net_output_survives_18dec` | **PASS** | | Existing small-reserve behaviour unchanged | Full suite `make test-contracts` — 393 integration + 44 pair unit + 21 dex-common = **459/0** | **PASS** | | Near-ceiling reserves still work | `cargo test -p cl8y-dex-tests test_swap_reserves_near_u128_max` | **PASS** | | Oracle overflow handled (companion #465, same MR) | `cargo test -p cl8y-dex-pair oracle_overflow` — 2/2 | **PASS** | ### Commands run ```bash make test-contracts # 459/0 cargo test -p cl8y-dex-pair pool_net_output_survives_18dec # 1/1 cargo test -p cl8y-dex-pair oracle_overflow # 2/2 cargo test -p cl8y-dex-tests test_swap_reserves_near_u128_max # 1/1 grep -rc Uint256 smartcontracts/contracts/pair/src/ # contract.rs:17, hybrid_reverse.rs:6 ``` ### Third-party re-check 1. `git pull` on `main` (≥ `4811caf9`). 2. `make test-contracts` — expect 459/0. 3. `cargo test -p cl8y-dex-pair pool_net_output_survives_18dec` — must pass (pre-fix reverts on `reserve_a * reserve_b` at 2e19/side). ### Follow-up (non-blocking) `MAX_PAIR_ASSET_DECIMALS_BOOTSTRAP` doc comment in `packages/dex-common/src/pair.rs:62-64` still cites `Uint128` overflow as the rationale for the decimals cap; with #464 fixed the cap can be revisited on its own merits. No functional blocker. Closing — launch-blocker resolved on `main`.
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-07-07 02:13:51 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
code/cl8y-dex-terraclassic#464
No description provided.