diff --git a/.changeset/router-balance-input.md b/.changeset/router-balance-input.md new file mode 100644 index 000000000..e41489239 --- /dev/null +++ b/.changeset/router-balance-input.md @@ -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. diff --git a/sdks/universal-router-sdk/src/entities/actions/uniswap.ts b/sdks/universal-router-sdk/src/entities/actions/uniswap.ts index 609baf814..b06ff3339 100644 --- a/sdks/universal-router-sdk/src/entities/actions/uniswap.ts +++ b/sdks/universal-router-sdk/src/entities/actions/uniswap.ts @@ -35,6 +35,7 @@ import { CONTRACT_BALANCE, ETH_ADDRESS, UniversalRouterVersion, + UNIVERSAL_ROUTER_ADDRESS, isAtLeastV2_1_1, } from '../../utils/constants' import { getCurrencyAddress } from '../../utils/getCurrencyAddress' @@ -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 & { useRouterBalance?: boolean /** @@ -67,6 +81,23 @@ export type SwapOptions = Omit & { * 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 @@ -77,6 +108,13 @@ export type SwapOptions = Omit & { 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 { route: IRoute inputAmount: CurrencyAmount @@ -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( @@ -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 { @@ -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 @@ -426,7 +511,7 @@ function addV2Swap( 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), @@ -475,7 +560,7 @@ function addV3Swap( 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, @@ -525,8 +610,31 @@ function addV4Swap( 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 @@ -649,7 +757,11 @@ function addMixedSwap( 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, [ @@ -690,7 +802,7 @@ function addMixedSwap( } 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 @@ -700,7 +812,7 @@ function addMixedSwap( } 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, diff --git a/sdks/universal-router-sdk/src/swapRouter.ts b/sdks/universal-router-sdk/src/swapRouter.ts index d2084e0f0..65b13d9f9 100644 --- a/sdks/universal-router-sdk/src/swapRouter.ts +++ b/sdks/universal-router-sdk/src/swapRouter.ts @@ -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' @@ -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) } @@ -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) } diff --git a/sdks/universal-router-sdk/src/types/encodeSwaps.ts b/sdks/universal-router-sdk/src/types/encodeSwaps.ts index b85863a5a..950f56472 100644 --- a/sdks/universal-router-sdk/src/types/encodeSwaps.ts +++ b/sdks/universal-router-sdk/src/types/encodeSwaps.ts @@ -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' @@ -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 diff --git a/sdks/universal-router-sdk/src/utils/routerBalanceSteps.ts b/sdks/universal-router-sdk/src/utils/routerBalanceSteps.ts new file mode 100644 index 000000000..9fdba225f --- /dev/null +++ b/sdks/universal-router-sdk/src/utils/routerBalanceSteps.ts @@ -0,0 +1,96 @@ +import invariant from 'tiny-invariant' +import { CONTRACT_BALANCE } from './constants' +import { SwapStep, V4Action } from '../types/encodeSwaps' + +function v4ActionSpendsToken(action: V4Action, tokenAddress: string): boolean { + switch (action.action) { + case 'SETTLE': + case 'SETTLE_ALL': + return action.currency.toLowerCase() === tokenAddress + case 'SWAP_EXACT_IN': + return action.currencyIn.toLowerCase() === tokenAddress + case 'SWAP_EXACT_IN_SINGLE': { + const spent = action.zeroForOne ? action.poolKey.currency0 : action.poolKey.currency1 + return spent.toLowerCase() === tokenAddress + } + default: + return false + } +} + +// Whether a step draws the trade's input token, i.e. is a candidate first hop. +export function stepSpendsToken(step: SwapStep, inputTokenAddress: string): boolean { + const tokenAddress = inputTokenAddress.toLowerCase() + switch (step.type) { + case 'V2_SWAP_EXACT_IN': + return step.path[0]?.toLowerCase() === tokenAddress + case 'V3_SWAP_EXACT_IN': + // v3 exact-in paths are encoded input-first: the first 20 bytes are the input token + return step.path.slice(0, 42).toLowerCase() === tokenAddress + case 'V4_SWAP': + return step.v4Actions.some((action) => v4ActionSpendsToken(action, tokenAddress)) + default: + return false + } +} + +function applyToV4Actions(actions: V4Action[], tokenAddress: string): V4Action[] { + const hasInputSettle = actions.some( + (action) => action.action === 'SETTLE' && action.currency.toLowerCase() === tokenAddress + ) + + const transformed: V4Action[] = actions.map((action) => { + // The settle that funds the swap now takes the router's whole balance. + if (action.action === 'SETTLE' && action.currency.toLowerCase() === tokenAddress) { + return { ...action, amount: CONTRACT_BALANCE.toString(), payerIsUser: false } + } + // 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 } + } + if (action.action === 'SWAP_EXACT_IN_SINGLE' && v4ActionSpendsToken(action, tokenAddress)) { + return { ...action, amountIn: 0 } + } + return action + }) + + if (hasInputSettle) { + return transformed + } + // No settle in the plan (addTrade-style shapes): fund the open delta explicitly. + return [ + { action: 'SETTLE', currency: tokenAddress, amount: CONTRACT_BALANCE.toString(), payerIsUser: false }, + ...transformed, + ] +} + +/** + * Rewrites the first hop of a step plan to spend the router's entire input-token balance: + * v2/v3 exact-in amounts become the CONTRACT_BALANCE sentinel; a v4 first step settles + * CONTRACT_BALANCE and swaps the resulting open delta. Later hops already chain through + * CONTRACT_BALANCE / open deltas, so only the input-spending step changes. + * + * `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[] { + const tokenAddress = inputTokenAddress.toLowerCase() + const first = swapSteps[0] + invariant(first !== undefined && stepSpendsToken(first, tokenAddress), 'ROUTER_BALANCE_INPUT_FIRST_STEP') + + let transformed: SwapStep + switch (first.type) { + case 'V2_SWAP_EXACT_IN': + case 'V3_SWAP_EXACT_IN': + transformed = { ...first, amountIn: CONTRACT_BALANCE.toString() } + break + case 'V4_SWAP': + transformed = { ...first, v4Actions: applyToV4Actions(first.v4Actions, tokenAddress) } + break + default: + // validateEncodeSwaps refuses exact-out and native shapes before this runs + invariant(false, 'ROUTER_BALANCE_INPUT_UNSUPPORTED_STEP') + } + + return [transformed, ...swapSteps.slice(1)] +} diff --git a/sdks/universal-router-sdk/src/utils/validateEncodeSwaps.ts b/sdks/universal-router-sdk/src/utils/validateEncodeSwaps.ts index 5fe073b4f..28892a9a2 100644 --- a/sdks/universal-router-sdk/src/utils/validateEncodeSwaps.ts +++ b/sdks/universal-router-sdk/src/utils/validateEncodeSwaps.ts @@ -13,6 +13,7 @@ import { NormalizedSwapSpecification, SwapStep, V4Action } from '../types/encode import { getCurrencyAddress } from './getCurrencyAddress' import { getV3HopCount, hasUserPaidFlag, stepUserPaidPulls } from './directTransfers' import { computeEncodeSwapsAmounts } from './computeEncodeSwapsAmounts' +import { stepSpendsToken } from './routerBalanceSteps' function hasV4MinHopPriceX36(action: V4Action): boolean { switch (action.action) { @@ -132,6 +133,41 @@ export function validateEncodeSwaps(spec: NormalizedSwapSpecification, swapSteps invariant(spec.routing.inputToken.decimals <= 18, 'NATIVE_ERC20_INPUT_DECIMALS') } + // router-balance funding: no ingress, first hop spends CONTRACT_BALANCE. Mirrors the + // SwapOptions.routerBalanceInput guards; anything refused here would silently encode a + // fixed-amount wallet-funded swap instead. + if (spec.routerBalanceInput) { + invariant(spec.tradeType === TradeType.EXACT_INPUT, 'ROUTER_BALANCE_INPUT_EXACT_INPUT_ONLY') + invariant(!spec.routing.inputToken.isNative, 'ROUTER_BALANCE_INPUT_NATIVE_INPUT') + invariant(!spec.nativeErc20Input, 'ROUTER_BALANCE_INPUT_NATIVE_ERC20_CONFLICT') + invariant(!spec.permit, 'ROUTER_BALANCE_INPUT_PERMIT_CONFLICT') + invariant(spec.tokenTransferMode !== TokenTransferMode.ApproveProxy, 'ROUTER_BALANCE_INPUT_PROXY_CONFLICT') + invariant(!spec.allowDirectTransfers, 'ROUTER_BALANCE_INPUT_DIRECT_TRANSFERS_CONFLICT') + // SENDER_AS_RECIPIENT resolves to the caller of execute(), who in this flow is the + // funder (a bridge filler), not the swapper. + invariant(spec.recipient !== SENDER_AS_RECIPIENT, 'ROUTER_BALANCE_INPUT_EXPLICIT_RECIPIENT_REQUIRED') + if (spec.routerBalanceInput.minimumAmount !== undefined) { + // BALANCE_CHECK_ERC20 reads `owner` verbatim, so the router's real address is needed + invariant(!!spec.chainId, 'ROUTER_BALANCE_INPUT_MINIMUM_REQUIRES_CHAIN_ID') + } + + // One CONTRACT_BALANCE cannot address two legs of the same currency: the first drains it + // and the second resolves to zero. Exactly one step may spend the input token, and it + // must be the first, so the transform has an unambiguous hop 0. + const balanceInputTokenAddress = getCurrencyAddress(spec.routing.inputToken) + const spenderIndexes = swapSteps + .map((step, index) => (stepSpendsToken(step, balanceInputTokenAddress) ? index : -1)) + .filter((index) => index >= 0) + invariant(spenderIndexes.length === 1 && spenderIndexes[0] === 0, 'ROUTER_BALANCE_INPUT_SPLIT_ROUTE') + for (const step of swapSteps) { + invariant( + 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') + } + } + // portion fees pair with exact-input (% of variable output); flat fees pair with exact-output (fixed deduction from the target) invariant( !(spec.fee?.kind === 'portion' && spec.tradeType !== TradeType.EXACT_INPUT), diff --git a/sdks/universal-router-sdk/test/unit/encodeSwaps.test.ts b/sdks/universal-router-sdk/test/unit/encodeSwaps.test.ts index 3215b35f4..824f59776 100644 --- a/sdks/universal-router-sdk/test/unit/encodeSwaps.test.ts +++ b/sdks/universal-router-sdk/test/unit/encodeSwaps.test.ts @@ -1867,4 +1867,202 @@ describe('encodeSwaps', () => { expect(nextSettlement[2].toString()).to.equal(legacySettlement[2].toString()) }) }) + + describe('routerBalanceInput', () => { + const balanceSpec = (overrides: Partial = {}) => + buildSpec({ routerBalanceInput: {}, ...overrides }) + + it('rejects the sender-as-recipient sentinel', () => { + expect(() => + validateEncodeSwaps(balanceSpec({ recipient: SENDER_AS_RECIPIENT }), [buildV3ExactInStep()]) + ).to.throw('ROUTER_BALANCE_INPUT_EXPLICIT_RECIPIENT_REQUIRED') + }) + + it('rejects exact-output trades', () => { + expect(() => + validateEncodeSwaps( + buildSpec( + { routerBalanceInput: {}, tradeType: TradeType.EXACT_OUTPUT }, + { + amount: CurrencyAmount.fromRawAmount(WETH, '500000000000000000'), + quote: CurrencyAmount.fromRawAmount(USDC, '1000000'), + } + ), + [buildV3ExactOutStep()] + ) + ).to.throw('ROUTER_BALANCE_INPUT_EXACT_INPUT_ONLY') + }) + + it('rejects native input', () => { + expect(() => + validateEncodeSwaps( + buildSpec( + { routerBalanceInput: {} }, + { + inputToken: ETH, + amount: CurrencyAmount.fromRawAmount(ETH, '1000000000000000000'), + quote: CurrencyAmount.fromRawAmount(WETH, '500000000000000000'), + } + ), + [buildV3ExactInStep({ amountIn: '1000000000000000000' }, [WETH, WETH])] + ) + ).to.throw('ROUTER_BALANCE_INPUT_NATIVE_INPUT') + }) + + it('rejects permits', () => { + expect(() => validateEncodeSwaps(balanceSpec({ permit: TEST_PERMIT }), [buildV3ExactInStep()])).to.throw( + 'ROUTER_BALANCE_INPUT_PERMIT_CONFLICT' + ) + }) + + it('rejects ApproveProxy', () => { + expect(() => + validateEncodeSwaps(balanceSpec({ tokenTransferMode: TokenTransferMode.ApproveProxy, chainId: 1 }), [ + buildV3ExactInStep(), + ]) + ).to.throw('ROUTER_BALANCE_INPUT_PROXY_CONFLICT') + }) + + it('rejects allowDirectTransfers', () => { + expect(() => validateEncodeSwaps(balanceSpec({ allowDirectTransfers: true }), [buildV3ExactInStep()])).to.throw( + 'ROUTER_BALANCE_INPUT_DIRECT_TRANSFERS_CONFLICT' + ) + }) + + it('rejects a minimumAmount without chainId', () => { + expect(() => + validateEncodeSwaps(balanceSpec({ routerBalanceInput: { minimumAmount: '1000000' } }), [buildV3ExactInStep()]) + ).to.throw('ROUTER_BALANCE_INPUT_MINIMUM_REQUIRES_CHAIN_ID') + }) + + it('rejects split routes: two steps spending the input token', () => { + expect(() => + validateEncodeSwaps(balanceSpec(), [ + buildV3ExactInStep({ amountIn: '100000' }), + buildV3ExactInStep({ amountIn: '900000' }, [USDC, DAI, WETH], [500, 3000]), + ]) + ).to.throw('ROUTER_BALANCE_INPUT_SPLIT_ROUTE') + }) + + it('rejects plans whose input-spending step is not first', () => { + expect(() => + validateEncodeSwaps(balanceSpec(), [ + buildV3ExactInStep({ amountIn: '0' }, [DAI, WETH]), + buildV3ExactInStep({}, [USDC, DAI]), + ]) + ).to.throw('ROUTER_BALANCE_INPUT_SPLIT_ROUTE') + }) + + it('encodes a v3 balance swap with no ingress and CONTRACT_BALANCE on hop 0', () => { + const result = SwapRouter.encodeSwaps(balanceSpec(), [buildV3ExactInStep()]) + const { inputs } = decodeExecute(result.calldata) + const { commandTypes } = parseCommands(result.calldata) + + expect(result.value).to.equal('0x00') + expect(commandTypes).to.deep.equal([CommandType.V3_SWAP_EXACT_IN, CommandType.SWEEP]) + + const swap = defaultAbiCoder.decode(['address', 'uint256', 'uint256', 'bytes', 'bool'], inputs[0]) + expect(swap[0]).to.equal(ROUTER_AS_RECIPIENT) + expect(swap[1].toString()).to.equal(CONTRACT_BALANCE.toString()) + expect(swap[4]).to.equal(false) + + const sweep = defaultAbiCoder.decode(['address', 'address', 'uint256'], inputs[1]) + const expectedGrossMin = exactInputGrossMin(BigNumber.from('500000000000000000'), new Percent(5, 100)) + expect(sweep[0].toLowerCase()).to.equal(WETH.address.toLowerCase()) + expect(sweep[1].toLowerCase()).to.equal(TEST_RECIPIENT.toLowerCase()) + expect(sweep[2].toString()).to.equal(expectedGrossMin.toString()) + }) + + it('leads with BALANCE_CHECK_ERC20 when minimumAmount is set', () => { + const result = SwapRouter.encodeSwaps( + balanceSpec({ routerBalanceInput: { minimumAmount: '999000' }, chainId: 1 }), + [buildV3ExactInStep()] + ) + const { inputs } = decodeExecute(result.calldata) + const { commandTypes } = parseCommands(result.calldata) + + expect(commandTypes).to.deep.equal([ + CommandType.BALANCE_CHECK_ERC20, + CommandType.V3_SWAP_EXACT_IN, + CommandType.SWEEP, + ]) + + const check = defaultAbiCoder.decode(['address', 'address', 'uint256'], inputs[0]) + expect(check[0].toLowerCase()).to.equal(UNIVERSAL_ROUTER_ADDRESS(UniversalRouterVersion.V2_0, 1).toLowerCase()) + expect(check[1].toLowerCase()).to.equal(USDC.address.toLowerCase()) + expect(check[2].toString()).to.equal('999000') + }) + + it('encodes a pure-v4 balance swap as SETTLE(CONTRACT_BALANCE) + open-delta swap', () => { + const step: V4Swap = { + type: 'V4_SWAP', + v4Actions: [ + { + action: 'SWAP_EXACT_IN', + currencyIn: USDC.address, + path: [ + { + intermediateCurrency: WETH.address, + fee: 500, + tickSpacing: 10, + hooks: ETH_ADDRESS, + hookData: '0x', + }, + ], + amountIn: '1000000', + amountOutMinimum: '0', + }, + { action: 'TAKE_ALL', currency: WETH.address, minAmount: '0' }, + ], + } + // TAKE_ALL is refused outside direct transfers; keep the plan router-custody + const routerCustodyStep: V4Swap = { ...step, v4Actions: [step.v4Actions[0]] } + + const result = SwapRouter.encodeSwaps(balanceSpec(), [routerCustodyStep]) + const { inputs } = decodeExecute(result.calldata) + const { commandTypes } = parseCommands(result.calldata) + + expect(commandTypes).to.deep.equal([CommandType.V4_SWAP, CommandType.SWEEP]) + + const parsed = V4BaseActionsParser.parseCalldata(inputs[0], URVersion.V2_0) + expect(parsed.actions[0].actionName).to.equal('SETTLE') + expect((parsed.actions[0].params[1].value as BigNumber).toString()).to.equal(CONTRACT_BALANCE.toString()) + expect(parsed.actions[0].params[2].value).to.equal(false) + expect(parsed.actions[1].actionName).to.equal('SWAP_EXACT_IN') + const swapParams = parsed.actions[1].params[0].value as any + expect(swapParams.amountIn.toString()).to.equal('0') + }) + + it('rewrites an existing v4 SETTLE of the input token instead of adding a second', () => { + const step: V4Swap = { + type: 'V4_SWAP', + v4Actions: [ + { action: 'SETTLE', currency: USDC.address, amount: '1000000', payerIsUser: false }, + { + action: 'SWAP_EXACT_IN', + currencyIn: USDC.address, + path: [ + { + intermediateCurrency: WETH.address, + fee: 500, + tickSpacing: 10, + hooks: ETH_ADDRESS, + hookData: '0x', + }, + ], + amountIn: '0', + amountOutMinimum: '0', + }, + ], + } + + const result = SwapRouter.encodeSwaps(balanceSpec(), [step]) + const { inputs } = decodeExecute(result.calldata) + + const parsed = V4BaseActionsParser.parseCalldata(inputs[0], URVersion.V2_0) + expect(parsed.actions.length).to.equal(2) + expect(parsed.actions[0].actionName).to.equal('SETTLE') + expect((parsed.actions[0].params[1].value as BigNumber).toString()).to.equal(CONTRACT_BALANCE.toString()) + }) + }) }) diff --git a/sdks/universal-router-sdk/test/unit/routerBalanceInput.test.ts b/sdks/universal-router-sdk/test/unit/routerBalanceInput.test.ts new file mode 100644 index 000000000..14c3d84ae --- /dev/null +++ b/sdks/universal-router-sdk/test/unit/routerBalanceInput.test.ts @@ -0,0 +1,281 @@ +import { expect } from 'chai' +import { BigNumber } from 'ethers' +import { defaultAbiCoder } from '@ethersproject/abi' +import { Trade as V3Trade, Pool as V3Pool, Route as V3Route } from '@uniswap/v3-sdk' +import { Pool as V4Pool, Route as V4Route, Trade as V4Trade } from '@uniswap/v4-sdk' +import { Trade as V2Trade, Route as V2Route, Pair } from '@uniswap/v2-sdk' +import { CurrencyAmount, Token, TradeType, Percent } from '@uniswap/sdk-core' +import { Trade as RouterTrade } from '@uniswap/router-sdk' +import { SwapRouter } from '../../src/swapRouter' +import { UniswapTrade, SwapOptions, TokenTransferMode } from '../../src/entities/actions/uniswap' +import { CommandType } from '../../src/utils/routerCommands' +import { + CONTRACT_BALANCE, + SENDER_AS_RECIPIENT, + UNIVERSAL_ROUTER_ADDRESS, + UniversalRouterVersion, +} from '../../src/utils/constants' +import { ETHER, WETH, USDC, DAI, makeV3Pool, makeV4Pool, parseCommands } from '../utils/uniswapData' + +const TEST_RECIPIENT = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' +const V4_ACTION_SWAP_EXACT_IN = 0x07 +const V4_ACTION_SETTLE = 0x0b +const MAINNET = 1 + +function buildV3Trade( + pool: V3Pool, + inputCurrency: Token, + outputCurrency: Token, + inputAmount: string, + outputAmount: string, + tradeType: TradeType = TradeType.EXACT_INPUT +): RouterTrade { + const route = new V3Route([pool], inputCurrency, outputCurrency) + const trade = V3Trade.createUncheckedTrade({ + route, + inputAmount: CurrencyAmount.fromRawAmount(inputCurrency, inputAmount), + outputAmount: CurrencyAmount.fromRawAmount(outputCurrency, outputAmount), + tradeType, + }) + return new RouterTrade({ + v2Routes: [], + v3Routes: [{ routev3: trade.route, inputAmount: trade.inputAmount, outputAmount: trade.outputAmount }], + v4Routes: [], + mixedRoutes: [], + tradeType, + }) +} + +function balanceInputOptions(overrides: Partial = {}): SwapOptions { + return { + slippageTolerance: new Percent(50, 10_000), + recipient: TEST_RECIPIENT, + chainId: MAINNET, + routerBalanceInput: {}, + ...overrides, + } +} + +describe('routerBalanceInput', () => { + const usdcWethPool = makeV3Pool(USDC, WETH) + const usdcTrade = () => buildV3Trade(usdcWethPool, USDC, WETH, '1000000000', '500000000000000000') + + describe('UniswapTrade validation', () => { + it('forces payerIsUser to false', () => { + const uniswapTrade = new UniswapTrade(usdcTrade(), balanceInputOptions()) + expect(uniswapTrade.payerIsUser).to.equal(false) + }) + + it('throws without an explicit recipient', () => { + expect(() => new UniswapTrade(usdcTrade(), balanceInputOptions({ recipient: undefined }))).to.throw( + /Explicit recipient address required with routerBalanceInput/ + ) + }) + + it('throws when the recipient is the msg.sender sentinel', () => { + expect(() => new UniswapTrade(usdcTrade(), balanceInputOptions({ recipient: SENDER_AS_RECIPIENT }))).to.throw( + /Explicit recipient address required with routerBalanceInput/ + ) + }) + + it('throws on a native input currency', () => { + const ethTrade = buildV3Trade(makeV3Pool(WETH, USDC), WETH, USDC, '1000000000000000000', '2000000000') + // an ETH-input trade wraps first; routerBalanceInput is ERC20-in only + const nativeTrade = new RouterTrade({ + v2Routes: [], + v3Routes: [ + { + routev3: new V3Route([makeV3Pool(WETH, USDC)], ETHER, USDC), + inputAmount: CurrencyAmount.fromRawAmount(ETHER, '1000000000000000000'), + outputAmount: CurrencyAmount.fromRawAmount(USDC, '2000000000'), + }, + ], + v4Routes: [], + mixedRoutes: [], + tradeType: TradeType.EXACT_INPUT, + }) + expect(ethTrade).to.not.equal(undefined) + expect(() => new UniswapTrade(nativeTrade, balanceInputOptions())).to.throw( + /routerBalanceInput requires an ERC20 input token/ + ) + }) + + it('throws on EXACT_OUTPUT', () => { + const exactOut = buildV3Trade( + usdcWethPool, + USDC, + WETH, + '1000000000', + '500000000000000000', + TradeType.EXACT_OUTPUT + ) + expect(() => new UniswapTrade(exactOut, balanceInputOptions())).to.throw( + /routerBalanceInput requires TradeType.EXACT_INPUT/ + ) + }) + + it('throws on split routes, which cannot share one CONTRACT_BALANCE', () => { + const v3 = V3Trade.createUncheckedTrade({ + route: new V3Route([usdcWethPool], USDC, WETH), + inputAmount: CurrencyAmount.fromRawAmount(USDC, '500000000'), + outputAmount: CurrencyAmount.fromRawAmount(WETH, '250000000000000000'), + tradeType: TradeType.EXACT_INPUT, + }) + const pair = new Pair( + CurrencyAmount.fromRawAmount(USDC, '1000000000000'), + CurrencyAmount.fromRawAmount(WETH, '1000000000000000000') + ) + const v2 = new V2Trade( + new V2Route([pair], USDC, WETH), + CurrencyAmount.fromRawAmount(USDC, '500000000'), + TradeType.EXACT_INPUT + ) + const split = new RouterTrade({ + v2Routes: [{ routev2: v2.route, inputAmount: v2.inputAmount, outputAmount: v2.outputAmount }], + v3Routes: [{ routev3: v3.route, inputAmount: v3.inputAmount, outputAmount: v3.outputAmount }], + v4Routes: [], + mixedRoutes: [], + tradeType: TradeType.EXACT_INPUT, + }) + expect(() => new UniswapTrade(split, balanceInputOptions())).to.throw( + /routerBalanceInput does not support split routes/ + ) + }) + + it('throws when an inputTokenPermit is provided', () => { + const opts = balanceInputOptions({ inputTokenPermit: {} as any }) + expect(() => new UniswapTrade(usdcTrade(), opts)).to.throw(/does not use Permit2/) + }) + + it('throws with ApproveProxy token transfer mode', () => { + const opts = balanceInputOptions({ tokenTransferMode: TokenTransferMode.ApproveProxy }) + expect(() => new UniswapTrade(usdcTrade(), opts)).to.throw(/not supported with ApproveProxy/) + }) + + it('throws when a minimumAmount is set without a chainId', () => { + const opts = balanceInputOptions({ chainId: undefined, routerBalanceInput: { minimumAmount: '1' } }) + expect(() => new UniswapTrade(usdcTrade(), opts)).to.throw(/requires chainId/) + }) + }) + + describe('SwapRouter.swapCallParameters', () => { + it('encodes the first hop as CONTRACT_BALANCE for a V3 swap', () => { + const { calldata, value } = SwapRouter.swapCallParameters(usdcTrade(), balanceInputOptions()) + expect(value).to.equal('0x00') + + const { commandTypes, inputs } = parseCommands(calldata) + expect(commandTypes).to.deep.equal([CommandType.V3_SWAP_EXACT_IN]) + + const [recipient, amountIn, , , payerIsUser] = defaultAbiCoder.decode( + ['address', 'uint256', 'uint256', 'bytes', 'bool'], + inputs[0] + ) + expect(BigNumber.from(amountIn).eq(CONTRACT_BALANCE)).to.equal(true) + expect(payerIsUser).to.equal(false) + expect(recipient.toLowerCase()).to.equal(TEST_RECIPIENT) + }) + + it('keeps the quoted amountIn when routerBalanceInput is absent', () => { + const opts = balanceInputOptions() + delete opts.routerBalanceInput + const { calldata } = SwapRouter.swapCallParameters(usdcTrade(), opts) + const { inputs } = parseCommands(calldata) + const [, amountIn, , , payerIsUser] = defaultAbiCoder.decode( + ['address', 'uint256', 'uint256', 'bytes', 'bool'], + inputs[0] + ) + expect(BigNumber.from(amountIn).eq(CONTRACT_BALANCE)).to.equal(false) + expect(payerIsUser).to.equal(true) + }) + + it('still enforces the trade-level minimum output', () => { + const trade = usdcTrade() + const { calldata } = SwapRouter.swapCallParameters(trade, balanceInputOptions()) + const { inputs } = parseCommands(calldata) + const [, , amountOutMin] = defaultAbiCoder.decode(['address', 'uint256', 'uint256', 'bytes', 'bool'], inputs[0]) + const expected = trade.minimumAmountOut(new Percent(50, 10_000)).quotient.toString() + expect(BigNumber.from(amountOutMin).toString()).to.equal(expected) + expect(BigNumber.from(amountOutMin).gt(0)).to.equal(true) + }) + + it('prepends BALANCE_CHECK_ERC20 against the real router address when a minimum is set', () => { + const opts = balanceInputOptions({ routerBalanceInput: { minimumAmount: '999000000' } }) + const { calldata } = SwapRouter.swapCallParameters(usdcTrade(), opts) + + const { commandTypes, inputs } = parseCommands(calldata) + expect(commandTypes[0]).to.equal(CommandType.BALANCE_CHECK_ERC20) + + const [owner, token, minBalance] = defaultAbiCoder.decode(['address', 'address', 'uint256'], inputs[0]) + // owner is read verbatim by the router, so it must not be a sentinel + expect(owner.toLowerCase()).to.equal(UNIVERSAL_ROUTER_ADDRESS(UniversalRouterVersion.V2_0, MAINNET).toLowerCase()) + expect(token.toLowerCase()).to.equal(USDC.address.toLowerCase()) + expect(BigNumber.from(minBalance).toString()).to.equal('999000000') + }) + + it('omits the balance check when no minimum is set', () => { + const { calldata } = SwapRouter.swapCallParameters(usdcTrade(), balanceInputOptions()) + const { commandTypes } = parseCommands(calldata) + expect(commandTypes).to.not.include(CommandType.BALANCE_CHECK_ERC20) + }) + + it('settles CONTRACT_BALANCE and swaps the open delta for a pure V4 route', () => { + const pool = makeV4Pool(USDC, WETH) + const v4 = V4Trade.createUncheckedTrade({ + route: new V4Route([pool], USDC, WETH), + inputAmount: CurrencyAmount.fromRawAmount(USDC, '1000000000'), + outputAmount: CurrencyAmount.fromRawAmount(WETH, '500000000000000000'), + tradeType: TradeType.EXACT_INPUT, + }) + const trade = new RouterTrade({ + v2Routes: [], + v3Routes: [], + v4Routes: [{ routev4: v4.route, inputAmount: v4.inputAmount, outputAmount: v4.outputAmount }], + mixedRoutes: [], + tradeType: TradeType.EXACT_INPUT, + }) + + const { calldata } = SwapRouter.swapCallParameters(trade, balanceInputOptions()) + const { commandTypes, inputs } = parseCommands(calldata) + expect(commandTypes).to.deep.equal([CommandType.V4_SWAP]) + + const [actions, params] = defaultAbiCoder.decode(['bytes', 'bytes[]'], inputs[0]) + const actionIds = Array.from(Buffer.from(actions.slice(2), 'hex')) + // SETTLE must precede the swap: the swap consumes the delta the settle opened + expect(actionIds[0]).to.equal(V4_ACTION_SETTLE) + expect(actionIds[1]).to.equal(V4_ACTION_SWAP_EXACT_IN) + + const [, settleAmount, settlePayerIsUser] = defaultAbiCoder.decode(['address', 'uint256', 'bool'], params[0]) + expect(BigNumber.from(settleAmount).eq(CONTRACT_BALANCE)).to.equal(true) + expect(settlePayerIsUser).to.equal(false) + }) + + it('encodes the first hop as CONTRACT_BALANCE for a V2 swap', () => { + const pair = new Pair( + CurrencyAmount.fromRawAmount(USDC, '1000000000000'), + CurrencyAmount.fromRawAmount(DAI, '1000000000000000000000000') + ) + const v2 = new V2Trade( + new V2Route([pair], USDC, DAI), + CurrencyAmount.fromRawAmount(USDC, '1000000000'), + TradeType.EXACT_INPUT + ) + const trade = new RouterTrade({ + v2Routes: [{ routev2: v2.route, inputAmount: v2.inputAmount, outputAmount: v2.outputAmount }], + v3Routes: [], + v4Routes: [], + mixedRoutes: [], + tradeType: TradeType.EXACT_INPUT, + }) + const { calldata } = SwapRouter.swapCallParameters(trade, balanceInputOptions()) + const { commandTypes, inputs } = parseCommands(calldata) + expect(commandTypes).to.deep.equal([CommandType.V2_SWAP_EXACT_IN]) + + const [, amountIn, , , payerIsUser] = defaultAbiCoder.decode( + ['address', 'uint256', 'uint256', 'address[]', 'bool'], + inputs[0] + ) + expect(BigNumber.from(amountIn).eq(CONTRACT_BALANCE)).to.equal(true) + expect(payerIsUser).to.equal(false) + }) + }) +})