feat(universal-router-sdk): routerBalanceInput for swaps funded from the router's balance - #708
david-uniswap wants to merge 5 commits into
Conversation
…the router's balance Adds a SwapOptions option that funds a swap from the Universal Router's own balance of the input token, spending whatever it holds at execution time rather than pulling a fixed amount from a payer. The first hop encodes CONTRACT_BALANCE and payerIsUser is false; later hops already chain through CONTRACT_BALANCE. An optional minimumAmount emits a BALANCE_CHECK_ERC20 before any swap so an under-funded router reverts up front, which is a different guarantee from slippageTolerance: the trade-level minimum output only catches a shortfall large enough to breach it. Distinct from useRouterBalance, which keeps the fixed quoted amountIn. Guards: explicit recipient required (msg.sender is the funder, not the beneficiary), ERC20 input, EXACT_INPUT, single non-split route (one CONTRACT_BALANCE cannot address two legs of the same currency), and no Permit2 / nativeErc20Input / ApproveProxy. Pure v4 routes throw for now since V4Planner.addTrade bakes in the quoted amountIn. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Build the SETTLE(CONTRACT_BALANCE) + SWAP_EXACT_IN(open delta) pair explicitly instead of V4Planner.addTrade, which bakes in the quoted amountIn. Same shape the mixed-route encoder already uses for its v4 sections, so no v4-sdk change is needed and pure v4 routes are no longer refused. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The swapSteps transport had no way to express a router-balance-funded swap: SwapSpecification lacked the option, so a caller passing balance-swap intent through encodeSwaps silently got a fixed-amount Permit2-funded plan. encodeSwaps now accepts spec.routerBalanceInput with the same guards as swapCallParameters (explicit recipient, ERC20 input, EXACT_INPUT, single input-spending step, no permit/proxy/direct-transfer modes): the ingress pull is skipped, hop 0 is rewritten to CONTRACT_BALANCE (v4: SETTLE the whole balance and swap the open delta), and an optional minimumAmount leads the plan with BALANCE_CHECK_ERC20. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
● Reviewed · against Note Approved. Adds a AssessmentThe guard set is the load-bearing part and it holds: explicit-recipient required (blocks silently paying the funder via The Datadog lint check is failing on formatting — run Prettier before merge. Tip Teach the reviewer. React 👍 on findings that helped, 👎 on false positives. Reply to push back or add context — we aggregate this weekly to tune the bot. Comment |
Graphite Automations"Request reviewers once CI passes on sdks monorepo" took an action on this PR • (09/10/26)3 reviewers were added and 1 assignee was added to this PR based on Siyu Jiang (See-You John)'s automation. |
There was a problem hiding this comment.
Note
✅ Approved — see full review in the sticky comment ↑
scorzeth
left a comment
There was a problem hiding this comment.
Overall I'm concerned with the rewriting of the SwapSteps plans (in this and #710). The intention of this move (to encodeSwaps/SwapSteps) was to allow the routers to control their steps, and for the SDK to handle the safety envelope around it: ingress, permits, fees, the sweep and its floors, and validation.
| step.type !== 'V2_SWAP_EXACT_OUT' && step.type !== 'V3_SWAP_EXACT_OUT', | ||
| 'ROUTER_BALANCE_INPUT_EXACT_INPUT_ONLY' | ||
| ) | ||
| invariant(step.type !== 'WRAP_ETH', 'ROUTER_BALANCE_INPUT_NATIVE_INPUT') |
There was a problem hiding this comment.
Its possible for wraps to happen mid-route, when a v4 leg pays out native ETH and a later leg or the output needs WETH, a router will insert a WRAP_ETH partway through the plan, already sized CONTRACT_BALANCE.
There was a problem hiding this comment.
Yep, fixed. WRAP_ETH is only refused at hop 0 in ERC20 balance mode now. Mid-route wraps pass through untouched.
| * `validateEncodeSwaps` guarantees exactly one step spends the input token and that it is | ||
| * the first step, so this only ever rewrites `steps[0]`. | ||
| */ | ||
| export function applyRouterBalanceInputToSteps(swapSteps: SwapStep[], inputTokenAddress: string): SwapStep[] { |
There was a problem hiding this comment.
I'm worried that having the SDK rewrite steps could get a bit hairy, maybe instead we should make the routers produce the correct steps for this situation instead?
There was a problem hiding this comment.
Fair worry, and the payerIsUser bug proves the rewrite is fragile. But pushing it into routers means GuideStar, UniRoute and Quickroute each reimplement balance mode and we end up with three versions of the same edge cases. Fixed the hairy bits instead: every spender leg gets payerIsUser cleared, v4 splits are supported, the largest leg goes last, and a v4 plan that swaps before settling is now refused. Tests for each.
| } | ||
| // With the settle sized by CONTRACT_BALANCE, the swap consumes the open delta. | ||
| if (action.action === 'SWAP_EXACT_IN' && action.currencyIn.toLowerCase() === tokenAddress) { | ||
| return { ...action, amountIn: 0 } |
There was a problem hiding this comment.
This sets amountIn to 0 on every swap that spends the input token. That works when the step has one such swap. But the router's usual v4 first hop is one SETTLE that deposits the input, followed by several swaps that each take a fixed slice of it. After this rewrite the first swap takes the whole deposit and the second swap has nothing left. A zero-amount swap is rejected by the pool manager, and that swap's own minimum output could not be met anyway, so the transaction reverts.
There was a problem hiding this comment.
Good catch, that was wrong. Now only the largest input swap becomes open-delta, and it's moved last so the fixed slices run first. Covered in encodeSwaps.test.ts (multi-swap v4 hop case).
|
On the bigger point about routers controlling their steps: agree on the split, and I'd argue balance mode is part of the envelope, not the route. It's ingress: who pays and where the input comes from. Cody's take is the same: routers should only know what they need to produce a path, and TAPI owns execution mode so it stays unified across routers. So v1 keeps the rewrite in the SDK (called from TAPI), and long term we want routers emitting a plainer route shape with no payer or custody fields at all. Would love your help spec'ing that. Also addressed from the TDD thread: v4 in splits is supported (both shapes tested), payerIsUser is forced false on every spender leg, and native ETH into a raw-native v4 leg is refused with a typed error since native mode needs a wrap step to check the balance floor as WETH. |
Description
Adds a
SwapOptions.routerBalanceInputoption that funds a swap from the Universal Router's own balance of the input token, spending whatever it holds at execution time instead of pulling a fixed amount from a payer.The motivating flow is a bridge filler that deposits into the router and swaps atomically in the same transaction: the delivered amount isn't known when the calldata is built, so the first hop has to resolve at execution rather than encode a quoted number.
useRouterBalancealready exists and setspayerIsUser = false, but it keeps the fixed quotedamountIn, so it means "the router holds a known amount". This is the other case.What it does
CONTRACT_BALANCEandpayerIsUser = false. Later hops already chain throughCONTRACT_BALANCE, so only hop 0 changes.minimumAmountemits aBALANCE_CHECK_ERC20before any swap, so an under-funded router reverts up front. This is a different guarantee fromslippageTolerance: the trade-level minimum output only catches a shortfall large enough to breach it, so a wide tolerance can let an under-delivery through. Bounding the input directly closes that.Guards
All of these throw in the
UniswapTradeconstructor rather than silently encoding something wrong:recipientrequired.SENDER_AS_RECIPIENTresolves tomsg.sender, who in this flow is the funder, not the beneficiary. Silently paying the filler is the worst failure mode here, and it mirrors the existingApproveProxyguard.EXACT_INPUT, and a single non-split route. OneCONTRACT_BALANCEcannot address two legs of the same currency: the first drains it and the second resolves to zero.inputTokenPermit,nativeErc20Input, andApproveProxy.minimumAmountrequireschainId, becauseBALANCE_CHECK_ERC20reads itsownerparam verbatim (no sentinel resolution), so it needs the router's real address. PassingROUTER_AS_RECIPIENTthere would check the balance of0x…02.Route coverage
v2, v3, v4 and mixed routes are all supported.
Pure v4 needed one extra step:
V4Planner.addTradebakes the quotedamountIninto the swap action, so for this mode the encoder builds theSETTLE(CONTRACT_BALANCE)+SWAP_EXACT_IN(open delta)pair explicitly instead. That's the same shape the mixed-route encoder already uses for its v4 sections, so no v4-sdk change was needed. The settle deliberately precedes the swap, since the swap consumes the delta the settle opens.How Has This Been Tested?
test/unit/routerBalanceInput.test.ts, 16 cases: every guard above, plus decoded-calldata assertions that the first hop isCONTRACT_BALANCEwithpayerIsUser = falseon v2 and v3, that the quoted amount is untouched when the option is absent, that the trade-level minimum output is still enforced, and thatBALANCE_CHECK_ERC20is emitted against the resolved router address with the right token and floor, and that a pure v4 route settlesCONTRACT_BALANCEbefore swapping the open delta.Full unit suite: 492 passing. The one failing test is a pre-existing fork-dependent
before allhook (476 passing / 1 failing onmainwithout this change).Are there any breaking changes?
No. New optional field; existing behaviour is unchanged when it's absent, which the "keeps the quoted amountIn" test pins.
(Optional) Feedback Focus
The option shape. I made it an object so a minimum can't be expressed without the mode, but a flat
routerBalanceInput?: booleanplus a separate field would be more consistent with the other flatSwapOptionsentries. Happy either way.