Skip to content

refactor(swap): consolidate min-out/max-in slippage math into a shared helper (#715) - #829

Merged
szhygulin merged 1 commit into
mainfrom
refactor/715-shared-slippage-helper
Jul 26, 2026
Merged

refactor(swap): consolidate min-out/max-in slippage math into a shared helper (#715)#829
szhygulin merged 1 commit into
mainfrom
refactor/715-shared-slippage-helper

Conversation

@graciangabriel8

Copy link
Copy Markdown
Contributor

Closes #715

What changed and why

The exact-IN min-out floor (quotedOut * BigInt(10_000 - bps)) / 10_000n and the exact-OUT max-in ceiling (quotedIn * BigInt(10_000 + bps) + 9_999n) / 10_000n were copy-pasted across the swap modules. Both size a fund-safety bound that ends up baked into calldata the user signs on a Ledger, so a correction applied to one copy silently leaves the others wrong — the recurrence surface ARCHITECTURE.md §5.5 names, and the shape behind the #685-class arithmetic bug.

New src/modules/shared/slippage.ts exports applyMinOut(quotedOut, slippageBps) and applyMaxIn(quotedIn, slippageBps) with byte-identical arithmetic to the code they replace, including the deliberate asymmetric rounding (floor truncates DOWN via BigInt division; ceiling keeps the + 9_999n round-up bias so an approval cap is never one wei short).

Call sites converted:

File Site
src/modules/uniswap-swap/index.ts module-private applySlippageExactIn / applySlippageExactOut deleted; the two call sites now use the shared helper
src/modules/curve/actions.ts add_liquidity minLpOut, swap minDy
src/modules/swap/index.ts 1inch dstAmount min-out, LiFi exact-out approval cap
src/modules/tron/sunswap-swap.ts SunSwap V2 minOutBase — see "beyond the issue's scope" below

This is a pure refactor: no value changes, no rounding changes, no gate changes.

Beyond the issue's enumerated sites

The issue enumerated four files. A fifth verbatim copy of the exact-IN floor lives at src/modules/tron/sunswap-swap.ts (SunSwap V2), outside the acceptance criterion's {shared,swap,uniswap-swap,curve} grep scope — it postdates the recon the issue was written from. It is converted here too, since leaving a known copy of the same fund-safety formula defeats the point of the unit. Happy to split it out if you would rather keep this PR to the enumerated four.

Deliberately NOT added

The helper adds no slippageBps validation. Every call site already runs its own gate before reaching this arithmetic (assertSlippageOk in swap/, reused by uniswap-swap/; Curve's inline bps cap; LiFi's MAX_EFFECTIVE_SLIPPAGE). Adding a throw here would be a user-visible behaviour change, which a consolidation unit should not smuggle in. The precondition is documented in the helper's header instead. If you want a centralised gate, that is a follow-up with its own product call.

Out of scope, unchanged, per the issue: src/modules/lp/uniswap-v3/ computes its bounds via a separate position-math path.

Acceptance criterion

The issue's transcribed criterion now holds on this branch:

$ grep -rnE '10_?000 *[-+]' src/modules/{shared,swap,uniswap-swap,curve}
src/modules/shared/slippage.ts:32:  return (quotedOut * BigInt(10_000 - slippageBps)) / 10_000n;
src/modules/shared/slippage.ts:41:  return (quotedIn * BigInt(10_000 + slippageBps) + 9_999n) / 10_000n;

Exactly the two shared-helper definitions, both separator spellings and both operators covered, helper directory included. src/modules/tron is also clean.

The falsifier test

test/slippage-shared-helper-715.test.ts, two halves:

  1. BehaviourapplyMinOut / applyMaxIn are asserted equal to the pre-refactor inline formulas (kept in the test as the equivalence oracle) across 14 (amount, bps) pairs spanning zero, dust, exact multiples, 18-decimal amounts and 2^128-1; plus literal pinned values for the cases where the rounding direction is the whole point (applyMinOut(1n, 50) === 0n, applyMaxIn(1n, 1) === 2n, applyMaxIn(1n, 0) === 1n).
  2. Structure — the acceptance grep re-expressed as a real recursive file walk over src/modules/{shared,swap,uniswap-swap,curve,tron}: exactly two hits, both in slippage.ts, and each converted call site is asserted to import ../shared/slippage.js.

Why it is a falsifier, not a tautology: on unfixed code src/modules/shared/slippage.ts does not exist (half 1 fails at import) and the walk finds the seven pre-refactor arithmetic sites instead of two. It goes RED again if any future call site re-inlines either formula. The structural half also asserts the scan matches the helper's own two definitions, so a broken regex — which would make "no duplicates found" vacuously true — fails loudly rather than passing green.

Tests were NOT run locally: Node/npm are not installed on the authoring machine, so no build, lint or test run happened here. CI (Build & Test on Node 20 + 22) is the authoritative check. Nothing in this PR should be read as a claim of a green local run.

No existing test was weakened or updated. Behaviour is unchanged, so no existing assertion needed to move.

Blast radius

Every EVM/TRON swap-preparation path that derives a slippage bound: prepare_uniswap_swap, prepare_curve_add_liquidity, prepare_curve_swap, prepare_swap (1inch direct + LiFi exact-out approval sizing), prepare_tron_sunswap_swap. Read paths and quote paths are untouched. LiFi's own baked toAmountMin (and the #685 integrator-fee handling around it) is not this formula and is untouched.

Residual concerns / review asks

  • Not self-certified. This touches fund-safety arithmetic that flows into signed calldata. I am not certifying it correct or safe — it needs maintainer security review, specifically: (a) that the two helper bodies are character-for-character the arithmetic they replaced, and (b) that no call site's operand order or variable binding shifted in the conversion.
  • Adding a shared import to swap/, uniswap-swap/, curve/ and tron/ creates a new (leaf, dependency-free) shared module. Worth confirming it does not disturb the module-boundary intent of ARCHITECTURE.md §7 decision record 1 (swap/ and uniswap-swap/ kept distinct, assertSlippageOk defined once and imported) — this follows the same pattern rather than breaking it.
  • The tron/ conversion is the one judgement call in the diff; see above.

…d helper (#715)

The exact-IN min-out floor and the exact-OUT max-in ceiling were copy-pasted
across five call sites. Both size a fund-safety bound baked into calldata the
user signs, so a fix applied to one copy silently leaves the others wrong —
the recurrence surface ARCHITECTURE.md 5.5 calls out.

New src/modules/shared/slippage.ts exports applyMinOut / applyMaxIn with the
byte-identical arithmetic (floor truncates down; ceiling keeps the + 9_999n
round-up bias). Call sites now import it:

  - uniswap-swap/index.ts  (module-private applySlippageExactIn/Out deleted)
  - curve/actions.ts       (add_liquidity minLpOut, swap minDy)
  - swap/index.ts          (1inch dstAmount minOut, LiFi exact-out approval cap)
  - tron/sunswap-swap.ts   (SunSwap V2 minOut — a 5th copy the issue's
                            enumeration predates)

Pure refactor: no value, no rounding and no gate changes. The helper
deliberately adds no bps validation — every call site already runs its own
slippage gate before reaching here.

Closes #715
@szhygulin
szhygulin marked this pull request as ready for review July 26, 2026 08:06
@szhygulin
szhygulin merged commit b5ddd7f into main Jul 26, 2026
3 checks passed
@szhygulin
szhygulin deleted the refactor/715-shared-slippage-helper branch July 26, 2026 08:06
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 26, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

simplification: consolidate min-out/max-in slippage math into one shared helper

2 participants