AMM constant-product math runs on native u128 with no 256-bit widening — 18-dec pools overflow-revert at ~18 tokens/side #464
Labels
No labels
agent:fix_bugfix
agent:fix_conflicts
agent:fix_security
agent:gap_analysis
agent:implement
agent:implement
agent:implement
agent:open_issues
agent:ready
agent:research
agent:security_audit
agent:verify
architecture
backend
blocker:hybrid
blocker:launch
blocker:limit-orders
blocker:v2
block:log_only
block:security
bug
ci
contracts
correctness
deploy
dev
devops
docs
documentation
duplicate
e2e
enhancement
epic
feature
frontend
functional-completion
gas
good first issue
governance
help wanted
high-risk
hooks
hybrid
indexer
infra
infrastructure
integrators
invalid
launch-blocker
limit-orders
localnet
localterra
low priority
missing-implementation
needs-design
ops
performance
priority
high
priority
medium
product
qa
QA
question
ready
ready
research
scripts
security
security-hardening
smartcontracts
tech-debt
testing
ux
UX
v2
verification
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
code/cl8y-dex-terraclassic#464
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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_bin nativeUint128, 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— au128 * u128— with noUint256widening anywhere.grep -rc Uint256 smartcontracts/contracts/pair/src/returns nothing; the pair contract has zero 256-bit math.The
kproducts 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— samekproduct in the swap simulation pathhybrid_reverse.rs:35— samekin the hybrid net-output helperAnd 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)thenisqrtcontract.rs:1587 / :1589— subsequent mint,amount * total_supplycontract.rs:1753 / :1755— withdraw numerators,lp_amount * reserveWhy it happens (mechanism)
Uint128::checked_mulis a straightu128 * u128.u128::MAX ~= 3.40e38. The momentreserve_a * reserve_bexceeds that it returnsErr, and the?at 1088 turns it intoContractError::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:
Overflowatcontract.rs:1088. The sim (:2366) reverts too, so quotes die as well.Even seeding straight to that size in a single
provide_liquidityreverts at:1570(amount_a * amount_bbefore theisqrt), 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) arelp * 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
Uint256and narrow back at the end — the Astroport / Uniswap-V2 pattern.k, the LP mint (isqrtover aUint256product), the spread numerator, and the mint/withdraw numerators all widen cleanly; CosmWasm shipsUint256withisqrtandtry_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_BOOTSTRAPwell 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.
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.
mentioned in issue #465
mentioned in merge request !1002
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.mentioned in commit
fcdcbd87f2Verification — #464 already fixed on
mainThe Uint256 widening fix landed in commit
4811caf9via !1002 (qa/464-465-amm-256bit-widening). No additional implementation was required on currentmain(fcdcbd87).Acceptance criteria
k, LP mint/withdraw, spread, hybrid scale, sim,hybrid_reverse) widened toUint256with narrow-back toUint128grep -rc Uint256 smartcontracts/contracts/pair/src/→ 23 hits incontract.rs+hybrid_reverse.rs; swap path usesu256(input_reserve).checked_mul(u256(output_reserve))atcontract.rs:1118cargo test -p cl8y-dex-pair pool_net_output_survives_18decmake test-contracts— 393 integration + 44 pair unit + 21 dex-common = 459/0cargo test -p cl8y-dex-tests test_swap_reserves_near_u128_maxcargo test -p cl8y-dex-pair oracle_overflow— 2/2Commands run
Third-party re-check
git pullonmain(≥4811caf9).make test-contracts— expect 459/0.cargo test -p cl8y-dex-pair pool_net_output_survives_18dec— must pass (pre-fix reverts onreserve_a * reserve_bat 2e19/side).Follow-up (non-blocking)
MAX_PAIR_ASSET_DECIMALS_BOOTSTRAPdoc comment inpackages/dex-common/src/pair.rs:62-64still citesUint128overflow 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.