Skip to content

Commit

Permalink
fix(universal-router-sdk): handles all ETH/WETH transitions (#157)
Browse files Browse the repository at this point in the history
  • Loading branch information
ewilz authored Nov 19, 2024
1 parent 62d162a commit 5a00420
Show file tree
Hide file tree
Showing 8 changed files with 355 additions and 121 deletions.
4 changes: 2 additions & 2 deletions sdks/universal-router-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,14 @@
"dependencies": {
"@openzeppelin/contracts": "4.7.0",
"@uniswap/permit2-sdk": "^1.3.0",
"@uniswap/router-sdk": "^1.14.2",
"@uniswap/router-sdk": "^1.14.3",
"@uniswap/sdk-core": "^5.8.2",
"@uniswap/universal-router": "2.0.0-beta.2",
"@uniswap/v2-core": "^1.0.1",
"@uniswap/v2-sdk": "^4.6.0",
"@uniswap/v3-core": "1.0.0",
"@uniswap/v3-sdk": "^3.18.1",
"@uniswap/v4-sdk": "^1.10.0",
"@uniswap/v4-sdk": "^1.10.3",
"bignumber.js": "^9.0.2",
"ethers": "^5.7.0"
},
Expand Down
127 changes: 86 additions & 41 deletions sdks/universal-router-sdk/src/entities/actions/uniswap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
IRoute,
RouteV2,
RouteV3,
RouteV4,
MixedRouteSDK,
MixedRoute,
SwapOptions as RouterSwapOptions,
Expand All @@ -26,9 +25,10 @@ import {
} from '@uniswap/router-sdk'
import { Permit2Permit } from '../../utils/inputTokens'
import { getPathCurrency } from '../../utils/pathCurrency'
import { Currency, TradeType, CurrencyAmount, Percent } from '@uniswap/sdk-core'
import { Currency, TradeType, Token, CurrencyAmount, Percent } from '@uniswap/sdk-core'
import { Command, RouterActionType, TradeConfig } from '../Command'
import { SENDER_AS_RECIPIENT, ROUTER_AS_RECIPIENT, CONTRACT_BALANCE, ETH_ADDRESS } from '../../utils/constants'
import { getCurrencyAddress } from '../../utils/getCurrencyAddress'
import { encodeFeeBips } from '../../utils/numbers'
import { BigNumber, BigNumberish } from 'ethers'
import { TPool } from '@uniswap/router-sdk/dist/utils/TPool'
Expand Down Expand Up @@ -65,9 +65,12 @@ export class UniswapTrade implements Command {

constructor(public trade: RouterTrade<Currency, Currency, TradeType>, public options: SwapOptions) {
if (!!options.fee && !!options.flatFee) throw new Error('Only one fee option permitted')
if (this.inputRequiresWrap) this.payerIsUser = false
else if (this.options.useRouterBalance) this.payerIsUser = false
else this.payerIsUser = true

if (this.inputRequiresWrap || this.inputRequiresUnwrap || this.options.useRouterBalance) {
this.payerIsUser = false
} else {
this.payerIsUser = true
}
}

get isAllV4(): boolean {
Expand All @@ -79,24 +82,51 @@ export class UniswapTrade implements Command {
}

get inputRequiresWrap(): boolean {
if (!this.isAllV4) {
return this.trade.inputAmount.currency.isNative
if (this.isAllV4) {
return (
this.trade.inputAmount.currency.isNative &&
!(this.trade.swaps[0].route as unknown as V4Route<Currency, Currency>).pathInput.isNative
)
} else {
// We only support wrapping all ETH or no ETH currently. We cannot support splitting where half needs to be wrapped
// If the input currency is ETH and the input of the first path is not ETH it must be WETH that needs wrapping
return this.trade.inputAmount.currency.isNative && !this.trade.swaps[0].route.input.isNative
return this.trade.inputAmount.currency.isNative
}
}

get inputRequiresUnwrap(): boolean {
if (this.isAllV4) {
return (
!this.trade.inputAmount.currency.isNative &&
(this.trade.swaps[0].route as unknown as V4Route<Currency, Currency>).pathInput.isNative
)
}
return false
}

get outputRequiresWrap(): boolean {
if (this.isAllV4) {
return (
!this.trade.outputAmount.currency.isNative &&
(this.trade.swaps[0].route as unknown as V4Route<Currency, Currency>).pathOutput.isNative
)
}
return false
}

get outputRequiresUnwrap(): boolean {
if (!this.isAllV4) {
return this.trade.outputAmount.currency.isNative
if (this.isAllV4) {
return (
this.trade.outputAmount.currency.isNative &&
!(this.trade.swaps[0].route as unknown as V4Route<Currency, Currency>).pathOutput.isNative
)
} else {
// If the output currency is ETH and the output of the swap is not ETH it must be WETH that needs unwrapping
return this.trade.outputAmount.currency.isNative && !this.trade.swaps[0].route.output.isNative
return this.trade.outputAmount.currency.isNative
}
}

get outputRequiresTransition(): boolean {
return this.outputRequiresWrap || this.outputRequiresUnwrap
}

encode(planner: RoutePlanner, _config: TradeConfig): void {
// If the input currency is the native currency, we need to wrap it with the router as the recipient
if (this.inputRequiresWrap) {
Expand All @@ -105,6 +135,14 @@ export class UniswapTrade implements Command {
ROUTER_AS_RECIPIENT,
this.trade.maximumAmountIn(this.options.slippageTolerance).quotient.toString(),
])
} else if (this.inputRequiresUnwrap) {
// send wrapped token to router to unwrap
planner.addCommand(CommandType.PERMIT2_TRANSFER_FROM, [
(this.trade.inputAmount.currency as Token).address,
ROUTER_AS_RECIPIENT,
this.trade.maximumAmountIn(this.options.slippageTolerance).quotient.toString(),
])
planner.addCommand(CommandType.UNWRAP_WETH, [ROUTER_AS_RECIPIENT, 0])
}
// The overall recipient at the end of the trade, SENDER_AS_RECIPIENT uses the msg.sender
this.options.recipient = this.options.recipient ?? SENDER_AS_RECIPIENT
Expand All @@ -115,7 +153,8 @@ export class UniswapTrade implements Command {
// in that the reversion probability is lower
const performAggregatedSlippageCheck =
this.trade.tradeType === TradeType.EXACT_INPUT && this.trade.routes.length > 2
const routerMustCustody = performAggregatedSlippageCheck || this.outputRequiresUnwrap || hasFeeOption(this.options)
const routerMustCustody =
performAggregatedSlippageCheck || this.outputRequiresTransition || hasFeeOption(this.options)

for (const swap of this.trade.swaps) {
switch (swap.route.protocol) {
Expand All @@ -139,18 +178,18 @@ export class UniswapTrade implements Command {
let minimumAmountOut: BigNumber = BigNumber.from(
this.trade.minimumAmountOut(this.options.slippageTolerance).quotient.toString()
)

// The router custodies for 3 reasons: to unwrap, to take a fee, and/or to do a slippage check
if (routerMustCustody) {
const pools = this.trade.swaps[0].route.pools
const pathOutputCurrencyAddress = getCurrencyAddress(
getPathCurrency(this.trade.outputAmount.currency, pools[pools.length - 1])
)

// If there is a fee, that percentage is sent to the fee recipient
// In the case where ETH is the output currency, the fee is taken in WETH (for gas reasons)
if (!!this.options.fee) {
const feeBips = encodeFeeBips(this.options.fee.fee)
planner.addCommand(CommandType.PAY_PORTION, [
this.trade.outputAmount.currency.wrapped.address,
this.options.fee.recipient,
feeBips,
])
planner.addCommand(CommandType.PAY_PORTION, [pathOutputCurrencyAddress, this.options.fee.recipient, feeBips])

// If the trade is exact output, and a fee was taken, we must adjust the amount out to be the amount after the fee
// Otherwise we continue as expected with the trade's normal expected output
Expand All @@ -165,11 +204,7 @@ export class UniswapTrade implements Command {
const feeAmount = this.options.flatFee.amount
if (minimumAmountOut.lt(feeAmount)) throw new Error('Flat fee amount greater than minimumAmountOut')

planner.addCommand(CommandType.TRANSFER, [
this.trade.outputAmount.currency.wrapped.address,
this.options.flatFee.recipient,
feeAmount,
])
planner.addCommand(CommandType.TRANSFER, [pathOutputCurrencyAddress, this.options.flatFee.recipient, feeAmount])

// If the trade is exact output, and a fee was taken, we must adjust the amount out to be the amount after the fee
// Otherwise we continue as expected with the trade's normal expected output
Expand All @@ -182,19 +217,28 @@ export class UniswapTrade implements Command {
// by this if-else clause.
if (this.outputRequiresUnwrap) {
planner.addCommand(CommandType.UNWRAP_WETH, [this.options.recipient, minimumAmountOut])
} else if (this.outputRequiresWrap) {
planner.addCommand(CommandType.WRAP_ETH, [this.options.recipient, CONTRACT_BALANCE])
} else {
planner.addCommand(CommandType.SWEEP, [
this.trade.outputAmount.currency.wrapped.address,
getCurrencyAddress(this.trade.outputAmount.currency),
this.options.recipient,
minimumAmountOut,
])
}
}

if (this.inputRequiresWrap && (this.trade.tradeType === TradeType.EXACT_OUTPUT || riskOfPartialFill(this.trade))) {
// for exactOutput swaps that take native currency as input
// we need to send back the change to the user
planner.addCommand(CommandType.UNWRAP_WETH, [this.options.recipient, 0])
// for exactOutput swaps with native input or that perform an inputToken transition (wrap or unwrap)
// we need to send back the change to the user
if (this.trade.tradeType === TradeType.EXACT_OUTPUT || riskOfPartialFill(this.trade)) {
if (this.inputRequiresWrap) {
planner.addCommand(CommandType.UNWRAP_WETH, [this.options.recipient, 0])
} else if (this.inputRequiresUnwrap) {
planner.addCommand(CommandType.WRAP_ETH, [this.options.recipient, CONTRACT_BALANCE])
} else if (this.trade.inputAmount.currency.isNative) {
// must refund extra native currency sent along for native v4 trades (no input transition)
planner.addCommand(CommandType.SWEEP, [ETH_ADDRESS, this.options.recipient, 0])
}
}

if (this.options.safeMode) planner.addCommand(CommandType.SWEEP, [ETH_ADDRESS, this.options.recipient, 0])
Expand Down Expand Up @@ -275,31 +319,32 @@ function addV3Swap<TInput extends Currency, TOutput extends Currency>(

function addV4Swap<TInput extends Currency, TOutput extends Currency>(
planner: RoutePlanner,
{ route, inputAmount, outputAmount }: Swap<TInput, TOutput>,
{ inputAmount, outputAmount, route }: Swap<TInput, TOutput>,
tradeType: TradeType,
options: SwapOptions,
payerIsUser: boolean,
routerMustCustody: boolean
): void {
// create a deep copy of pools since v4Planner encoding tampers with array
const pools = route.pools.map((p) => p) as V4Pool[]
const v4Route = new V4Route(pools, inputAmount.currency, outputAmount.currency)
const trade = V4Trade.createUncheckedTrade({
route: route as RouteV4<TInput, TOutput>,
route: v4Route,
inputAmount,
outputAmount,
tradeType,
})

const slippageToleranceOnSwap =
routerMustCustody && tradeType == TradeType.EXACT_INPUT ? undefined : options.slippageTolerance

const inputWethFromRouter = inputAmount.currency.isNative && !route.input.isNative
if (inputWethFromRouter && !payerIsUser) throw new Error('Inconsistent payer')

const v4Planner = new V4Planner()
v4Planner.addTrade(trade, slippageToleranceOnSwap)
v4Planner.addSettle(inputWethFromRouter ? inputAmount.currency.wrapped : inputAmount.currency, payerIsUser)

options.recipient = options.recipient ?? SENDER_AS_RECIPIENT
v4Planner.addTake(outputAmount.currency, routerMustCustody ? ROUTER_AS_RECIPIENT : options.recipient)

v4Planner.addSettle(trade.route.pathInput, payerIsUser)
v4Planner.addTake(
trade.route.pathOutput,
routerMustCustody ? ROUTER_AS_RECIPIENT : options.recipient ?? SENDER_AS_RECIPIENT
)
planner.addCommand(CommandType.V4_SWAP, [v4Planner.finalize()])
}

Expand Down
6 changes: 6 additions & 0 deletions sdks/universal-router-sdk/src/utils/getCurrencyAddress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Currency } from '@uniswap/sdk-core'
import { ETH_ADDRESS } from './constants'

export function getCurrencyAddress(currency: Currency): string {
return currency.isNative ? ETH_ADDRESS : currency.wrapped.address
}
12 changes: 4 additions & 8 deletions sdks/universal-router-sdk/src/utils/pathCurrency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,10 @@ export function getPathCurrency(currency: Currency, pool: TPool): Currency {
return currency.wrapped

// return native currency if pool involves native version of wrapped currency (only applies to V4)
} else if (pool instanceof V4Pool) {
if (pool.token0.wrapped.equals(currency)) {
return pool.token0
} else if (pool.token1.wrapped.equals(currency)) {
return pool.token1
}

// otherwise the token is invalid
} else if (pool instanceof V4Pool && pool.token0.wrapped.equals(currency)) {
return pool.token0
} else if (pool instanceof V4Pool && pool.token1.wrapped.equals(currency)) {
return pool.token1
} else {
throw new Error(`Expected currency ${currency.symbol} to be either ${pool.token0.symbol} or ${pool.token1.symbol}`)
}
Expand Down
Loading

0 comments on commit 5a00420

Please sign in to comment.