Skip to content

feat(universal-router-sdk): routerBalanceInput for swaps funded from the router's balance - #708

Open
david-uniswap wants to merge 5 commits into
mainfrom
david/ur-router-balance-input
Open

david-uniswap wants to merge 5 commits into
mainfrom
david/ur-router-balance-input

Conversation

@david-uniswap

@david-uniswap david-uniswap commented Aug 28, 2026

Copy link
Copy Markdown

Description

Adds a SwapOptions.routerBalanceInput option 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.

useRouterBalance already exists and sets payerIsUser = false, but it keeps the fixed quoted amountIn, so it means "the router holds a known amount". This is the other case.

What it does

  • First hop encodes CONTRACT_BALANCE and payerIsUser = false. Later hops already chain through CONTRACT_BALANCE, so only hop 0 changes.
  • Optional minimumAmount emits a BALANCE_CHECK_ERC20 before any swap, so an under-funded router reverts up front. This is a different guarantee from slippageTolerance: 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.
  • The trade-level minimum output is unchanged, so price protection still comes from the SDK rather than the caller.
SwapRouter.swapCallParameters(trade, {
  recipient,
  slippageTolerance,
  chainId,
  routerBalanceInput: { minimumAmount: '999000000' }, // minimumAmount optional
})

Guards

All of these throw in the UniswapTrade constructor rather than silently encoding something wrong:

  • Explicit recipient required. SENDER_AS_RECIPIENT resolves to msg.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 existing ApproveProxy guard.
  • ERC20 input, EXACT_INPUT, and a single non-split route. One CONTRACT_BALANCE cannot address two legs of the same currency: the first drains it and the second resolves to zero.
  • Incompatible with inputTokenPermit, nativeErc20Input, and ApproveProxy.
  • minimumAmount requires chainId, because BALANCE_CHECK_ERC20 reads its owner param verbatim (no sentinel resolution), so it needs the router's real address. Passing ROUTER_AS_RECIPIENT there would check the balance of 0x…02.

Route coverage

v2, v3, v4 and mixed routes are all supported.

Pure v4 needed one extra step: V4Planner.addTrade bakes the quoted amountIn into the swap action, so for this mode the encoder builds the SETTLE(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 is CONTRACT_BALANCE with payerIsUser = false on v2 and v3, that the quoted amount is untouched when the option is absent, that the trade-level minimum output is still enforced, and that BALANCE_CHECK_ERC20 is emitted against the resolved router address with the right token and floor, and that a pure v4 route settles CONTRACT_BALANCE before swapping the open delta.

Full unit suite: 492 passing. The one failing test is a pre-existing fork-dependent before all hook (476 passing / 1 failing on main without 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?: boolean plus a separate field would be more consistent with the other flat SwapOptions entries. Happy either way.

david-uniswap and others added 3 commits August 28, 2026 09:52
…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>
@datadog-official

This comment has been minimized.

david-uniswap and others added 2 commits September 3, 2026 11:29
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Sep 10, 2026

Copy link
Copy Markdown

ECO-731

@graphite-app
graphite-app Bot requested a review from a team September 10, 2026 17:19
@graphite-app
graphite-app Bot requested a review from a team September 10, 2026 17:19
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

● Reviewed · against 4d4c906 · 2026-09-10 17:25 UTC · view run ↗

Note

Approved.

Adds a routerBalanceInput swap option that funds the first hop from the Universal Router's own balance (CONTRACT_BALANCE, payerIsUser = false) with an optional up-front BALANCE_CHECK_ERC20 floor.

Assessment

The guard set is the load-bearing part and it holds: explicit-recipient required (blocks silently paying the funder via SENDER_AS_RECIPIENT), single non-split ERC20 exact-input route (prevents two legs draining then zeroing the same CONTRACT_BALANCE), and chainId required for minimumAmount (so BALANCE_CHECK_ERC20's verbatim owner resolves to the real router address). The v4 path builds SETTLE(CONTRACT_BALANCE) + SWAP_EXACT_IN on the open delta explicitly rather than through V4Planner.addTrade, matching the existing mixed-route shape. Price protection stays with the trade-level minimum output enforced at sweep, so omitting minimumAmount degrades to the existing defense rather than opening a gap.

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 @request-claude-review to re-run.

@graphite-app

graphite-app Bot commented Sep 10, 2026

Copy link
Copy Markdown

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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note

Approved — see full review in the sticky comment ↑

@scorzeth scorzeth left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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[] {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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 }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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).

@david-uniswap

Copy link
Copy Markdown
Author

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.

This branch has not been deployed

No deployments
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.

2 participants