Skip to content
7 changes: 7 additions & 0 deletions .changeset/router-balance-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@uniswap/universal-router-sdk': minor
---

`SwapOptions.routerBalanceInput` on both encode paths (`SwapRouter.swapCallParameters` and `encodeSwaps`): the swap spends whatever input-token balance the Universal Router already holds instead of pulling it from the swapper via Permit2. The first hop's amount is encoded as `CONTRACT_BALANCE`, and an optional `routerBalanceInput.minimumAmount` is asserted up front with `BALANCE_CHECK_ERC20` against the router's own address, so an under-funded router reverts before any swap runs. Split routes are supported: fixed legs are encoded first at their quoted amounts and the largest leg last as `CONTRACT_BALANCE`, with one aggregate sweep floor.

Intended for bridged or relayer-delivered funds that land in the router and are swapped in the same transaction. `routerBalanceInput` requires `TradeType.EXACT_INPUT`, an explicit recipient (not `SENDER_AS_RECIPIENT`, since `msg.sender` is the funder rather than the swapper), and is mutually exclusive with `inputTokenPermit`, `nativeErc20Input` and `TokenTransferMode.ApproveProxy`. Callers that don't set the option get byte-identical calldata to the previous release.
128 changes: 120 additions & 8 deletions sdks/universal-router-sdk/src/entities/actions/uniswap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
CONTRACT_BALANCE,
ETH_ADDRESS,
UniversalRouterVersion,
UNIVERSAL_ROUTER_ADDRESS,
isAtLeastV2_1_1,
} from '../../utils/constants'
import { getCurrencyAddress } from '../../utils/getCurrencyAddress'
Expand All @@ -56,6 +57,19 @@ export enum TokenTransferMode {
ApproveProxy = 'ApproveProxy',
}

export type RouterBalanceInput = {
/**
* Optional floor on the router's input-token balance, enforced by a `BALANCE_CHECK_ERC20`
* command before any swap runs, so an under-funded router reverts up front rather than
* swapping a short amount. Use when the funding amount is guaranteed by the caller.
*
* This is a distinct 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. This bounds the input side directly.
*/
minimumAmount?: BigNumberish
}

export type SwapOptions = Omit<RouterSwapOptions, 'inputTokenPermit'> & {
useRouterBalance?: boolean
/**
Expand All @@ -67,6 +81,23 @@ export type SwapOptions = Omit<RouterSwapOptions, 'inputTokenPermit'> & {
* Incompatible with native input, inputTokenPermit, and TokenTransferMode.ApproveProxy.
*/
nativeErc20Input?: boolean
/**
* Fund the 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
* (`payerIsUser = false`, first hop encoded as `CONTRACT_BALANCE`).
*
* This is for flows where the router is funded by a third party in the same transaction
* and the delivered amount is not known when the calldata is built, e.g. a bridge filler
* that deposits into the router and swaps atomically. Distinct from `useRouterBalance`,
* which keeps the fixed quoted `amountIn`.
*
* Requires an explicit `recipient` (the caller of `execute()` is not the beneficiary),
* an ERC20 input, and `TradeType.EXACT_INPUT` on a single (non-split) route. Supported
* for v2, v3, v4 and mixed routes.
* Incompatible with native input, `inputTokenPermit`, `nativeErc20Input`, and
* `TokenTransferMode.ApproveProxy`.
*/
routerBalanceInput?: RouterBalanceInput
inputTokenPermit?: Permit2Permit
flatFee?: FlatFeeOptions
safeMode?: boolean
Expand All @@ -77,6 +108,13 @@ export type SwapOptions = Omit<RouterSwapOptions, 'inputTokenPermit'> & {

const REFUND_ETH_PRICE_IMPACT_THRESHOLD = new Percent(50, 100)

// The amount encoded for a route's FIRST hop. With routerBalanceInput the router spends
// whatever it holds at execution time, so the quoted amount is replaced by the
// CONTRACT_BALANCE sentinel; later hops already use it to chain the intermediate token.
function firstHopInputAmount(options: SwapOptions, quotedAmountIn: string): BigNumberish {
return options.routerBalanceInput ? CONTRACT_BALANCE : quotedAmountIn
}

interface Swap<TInput extends Currency, TOutput extends Currency> {
route: IRoute<TInput, TOutput, TPool>
inputAmount: CurrencyAmount<TInput>
Expand Down Expand Up @@ -108,6 +146,40 @@ export class UniswapTrade implements Command {
}
}

if (options.routerBalanceInput) {
// The router spends a balance a third party funded in the same transaction, so the
// amount is unknown at encode time and msg.sender is the funder, not the beneficiary.
if (!options.recipient || options.recipient === SENDER_AS_RECIPIENT) {
throw new Error(
'Explicit recipient address required with routerBalanceInput (SENDER_AS_RECIPIENT resolves to the caller, who is not the swapper)'
)
}
if (this.trade.inputAmount.currency.isNative) {
throw new Error('routerBalanceInput requires an ERC20 input token')
}
if (this.trade.tradeType !== TradeType.EXACT_INPUT) {
throw new Error('routerBalanceInput requires TradeType.EXACT_INPUT')
}
// CONTRACT_BALANCE resolves to the router's whole balance at execution, so it cannot
// address two legs of the same currency: the first would drain it and the second
// would resolve to zero.
if (this.trade.swaps.length > 1) {
throw new Error('routerBalanceInput does not support split routes')
}
if (options.inputTokenPermit) {
throw new Error('routerBalanceInput does not use Permit2; remove inputTokenPermit')
}
if (options.nativeErc20Input) {
throw new Error('routerBalanceInput is not supported with nativeErc20Input')
}
if (options.tokenTransferMode === TokenTransferMode.ApproveProxy) {
throw new Error('routerBalanceInput is not supported with ApproveProxy')
}
if (options.routerBalanceInput.minimumAmount !== undefined && !options.chainId) {
throw new Error('routerBalanceInput.minimumAmount requires chainId to resolve the router address')
}
}

if (options.tokenTransferMode === TokenTransferMode.ApproveProxy) {
if (!options.recipient || options.recipient === SENDER_AS_RECIPIENT) {
throw new Error(
Expand All @@ -119,7 +191,8 @@ export class UniswapTrade implements Command {
this.inputRequiresWrap ||
this.inputRequiresUnwrap ||
this.options.useRouterBalance ||
this.options.nativeErc20Input
this.options.nativeErc20Input ||
this.options.routerBalanceInput
) {
this.payerIsUser = false
} else {
Expand Down Expand Up @@ -240,6 +313,18 @@ export class UniswapTrade implements Command {
}

encode(planner: RoutePlanner, _config: TradeConfig): void {
// Input floor first, so an under-funded router reverts before any swap runs.
const minimumRouterBalance = this.options.routerBalanceInput?.minimumAmount
if (minimumRouterBalance !== undefined) {
// BALANCE_CHECK_ERC20 reads `owner` verbatim, without the sentinel resolution the
// recipient params get, so this must be the router's real address.
planner.addCommand(CommandType.BALANCE_CHECK_ERC20, [
UNIVERSAL_ROUTER_ADDRESS(this.options.urVersion ?? UniversalRouterVersion.V2_0, this.options.chainId!),
(this.trade.inputAmount.currency as Token).address,
minimumRouterBalance,
])
}

// If the input currency is the native currency, we need to wrap it with the router as the recipient
if (this.inputRequiresWrap) {
// TODO: optimize if only one v2 pool we can directly send this to the pool
Expand Down Expand Up @@ -426,7 +511,7 @@ function addV2Swap<TInput extends Currency, TOutput extends Currency>(
const params: any[] = [
// if native, we have to unwrap so keep in the router for now
routerMustCustody ? ROUTER_AS_RECIPIENT : options.recipient,
trade.maximumAmountIn(options.slippageTolerance).quotient.toString(),
firstHopInputAmount(options, trade.maximumAmountIn(options.slippageTolerance).quotient.toString()),
// if router will custody funds, we do aggregated slippage check from router
routerMustCustody ? 0 : trade.minimumAmountOut(options.slippageTolerance).quotient.toString(),
route.path.map((token) => token.wrapped.address),
Expand Down Expand Up @@ -475,7 +560,7 @@ function addV3Swap<TInput extends Currency, TOutput extends Currency>(
if (tradeType == TradeType.EXACT_INPUT) {
const params: any[] = [
routerMustCustody ? ROUTER_AS_RECIPIENT : options.recipient,
trade.maximumAmountIn(options.slippageTolerance).quotient.toString(),
firstHopInputAmount(options, trade.maximumAmountIn(options.slippageTolerance).quotient.toString()),
routerMustCustody ? 0 : trade.minimumAmountOut(options.slippageTolerance).quotient.toString(),
path,
payerIsUser,
Expand Down Expand Up @@ -525,8 +610,31 @@ function addV4Swap<TInput extends Currency, TOutput extends Currency>(
const perHopSlippage = minHopPriceX36?.map((s) => BigNumber.from(s)) ?? []

const v4Planner = new V4Planner()
v4Planner.addTrade(trade, slippageToleranceOnSwap, perHopSlippage, toV4URVersion(options.urVersion))
v4Planner.addSettle(trade.route.pathInput, payerIsUser)
if (options.routerBalanceInput) {
// V4Planner.addTrade would bake the quoted amountIn into the swap action, so build the
// pair explicitly instead: SETTLE the router's whole balance, then swap the resulting
// open delta. Same shape the mixed-route encoder uses for its v4 sections.
const pathInput = trade.route.pathInput
v4Planner.addSettle(pathInput, false, CONTRACT_BALANCE)
v4Planner.addAction(
Actions.SWAP_EXACT_IN,
[
{
currencyIn: pathInput.isNative ? ETH_ADDRESS : pathInput.wrapped.address,
path: encodeV4RouteToPath(v4Route),
minHopPriceX36: perHopSlippage,
amountIn: 0, // open delta: the amount settled above
amountOutMinimum: slippageToleranceOnSwap
? trade.minimumAmountOut(slippageToleranceOnSwap).quotient.toString()
: 0,
},
],
toV4URVersion(options.urVersion)
)
} else {
v4Planner.addTrade(trade, slippageToleranceOnSwap, perHopSlippage, toV4URVersion(options.urVersion))
v4Planner.addSettle(trade.route.pathInput, payerIsUser)
}

// Handle split route output consistency:
// - If output is ETH and some routes output WETH: force all to output WETH, then unwrap
Expand Down Expand Up @@ -649,7 +757,11 @@ function addMixedSwap<TInput extends Currency, TOutput extends Currency>(
const v4SubRoute = new V4Route(section as V4Pool[], subRoute.input, subRoute.output)
const v4SectionSlippage: BigNumber[] = sectionHopSlippage?.map((s) => BigNumber.from(s)) ?? []

v4Planner.addSettle(inputToken, payerIsUser && i === 0, (i == 0 ? amountIn : CONTRACT_BALANCE) as BigNumber)
v4Planner.addSettle(
inputToken,
payerIsUser && i === 0,
(i == 0 ? firstHopInputAmount(options, amountIn) : CONTRACT_BALANCE) as BigNumber
)
v4Planner.addAction(
Actions.SWAP_EXACT_IN,
[
Expand Down Expand Up @@ -690,7 +802,7 @@ function addMixedSwap<TInput extends Currency, TOutput extends Currency>(
} else if (routePool instanceof V3Pool) {
const v3Params: any[] = [
swapRecipient, // recipient
i == 0 ? amountIn : CONTRACT_BALANCE, // amountIn
i == 0 ? firstHopInputAmount(options, amountIn) : CONTRACT_BALANCE, // amountIn
!isLastSectionInRoute(i) ? 0 : amountOut, // amountOut
encodeMixedRouteToPath(subRoute), // path
payerIsUser && i === 0, // payerIsUser
Expand All @@ -700,7 +812,7 @@ function addMixedSwap<TInput extends Currency, TOutput extends Currency>(
} else if (routePool instanceof Pair) {
const v2Params: any[] = [
swapRecipient, // recipient
i === 0 ? amountIn : CONTRACT_BALANCE, // amountIn
i === 0 ? firstHopInputAmount(options, amountIn) : CONTRACT_BALANCE, // amountIn
!isLastSectionInRoute(i) ? 0 : amountOut, // amountOutMin
subRoute.path.map((token) => token.wrapped.address), // path
payerIsUser && i === 0,
Expand Down
32 changes: 29 additions & 3 deletions sdks/universal-router-sdk/src/swapRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
import { getCurrencyAddress } from './utils/getCurrencyAddress'
import { encodeFee1e18, encodeFeeBips } from './utils/numbers'
import { encodeSwapStep } from './utils/encodeSwapStep'
import { applyRouterBalanceInputToSteps } from './utils/routerBalanceSteps'
import { computeEncodeSwapsAmounts } from './utils/computeEncodeSwapsAmounts'
import { normalizeEncodeSwapsSpec } from './utils/normalizeEncodeSwapsSpec'
import { validateEncodeSwaps } from './utils/validateEncodeSwaps'
Expand Down Expand Up @@ -180,10 +181,28 @@ export abstract class SwapRouter {
routing: { inputToken, outputToken },
} = normalizedSpec

// Router-balance funding: assert the floor before anything else runs, so an
// under-funded router reverts up front rather than swapping a short amount.
if (normalizedSpec.routerBalanceInput?.minimumAmount !== undefined) {
planner.addCommand(
CommandType.BALANCE_CHECK_ERC20,
[
// BALANCE_CHECK_ERC20 reads `owner` verbatim (no sentinel resolution), so it
// needs the router's real address; validateEncodeSwaps requires chainId.
UNIVERSAL_ROUTER_ADDRESS(normalizedSpec.urVersion, normalizedSpec.chainId!),
getCurrencyAddress(inputToken),
normalizedSpec.routerBalanceInput.minimumAmount,
],
false,
normalizedSpec.urVersion
)
}

// Ingress: pull funds into the router. Native input is paid as msg.value at the bottom
// instead of via Permit2 — as is a native-ERC20 gas-token input (nativeErc20Input);
// ApproveProxy ingress is handled by the outer wrapper at the end.
if (normalizedSpec.tokenTransferMode === TokenTransferMode.Permit2) {
// ApproveProxy ingress is handled by the outer wrapper at the end. A router-balance
// swap is funded by a third party in the same transaction, so it has no ingress at all.
if (normalizedSpec.tokenTransferMode === TokenTransferMode.Permit2 && !normalizedSpec.routerBalanceInput) {
if (normalizedSpec.permit) {
encodePermit(planner, normalizedSpec.permit)
}
Expand All @@ -202,7 +221,14 @@ export abstract class SwapRouter {
}
}

for (const step of swapSteps) {
// With router-balance funding the delivered amount is unknown at encode time, so the
// input-spending first hop is rewritten to the CONTRACT_BALANCE sentinel (v4: settle
// the whole balance, swap the open delta).
const stepsToEncode = normalizedSpec.routerBalanceInput
? applyRouterBalanceInputToSteps(swapSteps, getCurrencyAddress(inputToken))
: swapSteps

for (const step of stepsToEncode) {
encodeSwapStep(planner, step, normalizedSpec.urVersion)
}

Expand Down
11 changes: 10 additions & 1 deletion sdks/universal-router-sdk/src/types/encodeSwaps.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { BigNumberish } from 'ethers'
import { Currency, CurrencyAmount, Percent, TradeType } from '@uniswap/sdk-core'
import { type PathKey, type PoolKey } from '@uniswap/v4-sdk'
import { TokenTransferMode } from '../entities/actions/uniswap'
import { RouterBalanceInput, TokenTransferMode } from '../entities/actions/uniswap'
import { Permit2Permit } from '../utils/inputTokens'
import { UniversalRouterVersion } from '../utils/constants'

Expand Down Expand Up @@ -45,6 +45,15 @@ export type SwapSpecification = {
* See `SwapRouter.encodeSwaps`.
*/
allowDirectTransfers?: boolean
/**
* Fund the swap from the Universal Router's own balance of the input token: no Permit2
* ingress is emitted, the first hop spends the CONTRACT_BALANCE sentinel, and an optional
* `minimumAmount` emits a BALANCE_CHECK_ERC20 up front (requires `chainId` to resolve the
* router address). Same semantics and guards as `SwapOptions.routerBalanceInput`: explicit
* `recipient`, ERC20 input, EXACT_INPUT, exactly one input-spending step (no splits);
* incompatible with `permit`, `nativeErc20Input`, `allowDirectTransfers`, and ApproveProxy.
*/
routerBalanceInput?: RouterBalanceInput
}

// Output of `normalizeEncodeSwapsSpec`: the five fields below are guaranteed
Expand Down
Loading
Loading