Skip to content

Commit

Permalink
fix(universal-router-sdk): Supports Mixed Routes with V4 (#145)
Browse files Browse the repository at this point in the history
  • Loading branch information
ewilz authored Oct 8, 2024
1 parent 1ec1644 commit 680324d
Show file tree
Hide file tree
Showing 9 changed files with 359 additions and 86 deletions.
2 changes: 1 addition & 1 deletion sdks/universal-router-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"dependencies": {
"@openzeppelin/contracts": "4.7.0",
"@uniswap/permit2-sdk": "^1.3.0",
"@uniswap/router-sdk": "^1.14.0",
"@uniswap/router-sdk": "^1.14.2",
"@uniswap/sdk-core": "^5.8.0",
"@uniswap/universal-router": "2.0.0-beta.1",
"@uniswap/v2-core": "^1.0.1",
Expand Down
109 changes: 69 additions & 40 deletions sdks/universal-router-sdk/src/entities/actions/uniswap.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { RoutePlanner, CommandType } from '../../utils/routerCommands'
import { Trade as V2Trade, Pair } from '@uniswap/v2-sdk'
import { Trade as V3Trade, Pool as V3Pool, encodeRouteToPath } from '@uniswap/v3-sdk'
import { Trade as V4Trade, V4Planner } from '@uniswap/v4-sdk'
import {
Route as V4Route,
Trade as V4Trade,
Pool as V4Pool,
V4Planner,
encodeRouteToPath as encodeV4RouteToPath,
Actions,
} from '@uniswap/v4-sdk'
import {
Trade as RouterTrade,
MixedRouteTrade,
Expand All @@ -18,6 +25,7 @@ import {
partitionMixedRouteByProtocol,
} from '@uniswap/router-sdk'
import { Permit2Permit } from '../../utils/inputTokens'
import { getPathCurrency } from '../../utils/pathCurrency'
import { Currency, TradeType, 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'
Expand Down Expand Up @@ -118,15 +126,7 @@ export class UniswapTrade implements Command {
addV3Swap(planner, swap, this.trade.tradeType, this.options, this.payerIsUser, routerMustCustody)
break
case Protocol.V4:
addV4Swap(
planner,
swap,
this.trade.tradeType,
this.options,
this.payerIsUser,
routerMustCustody,
performAggregatedSlippageCheck
)
addV4Swap(planner, swap, this.trade.tradeType, this.options, this.payerIsUser, routerMustCustody)
break
case Protocol.MIXED:
addMixedSwap(planner, swap, this.trade.tradeType, this.options, this.payerIsUser, routerMustCustody)
Expand Down Expand Up @@ -279,16 +279,16 @@ function addV4Swap<TInput extends Currency, TOutput extends Currency>(
tradeType: TradeType,
options: SwapOptions,
payerIsUser: boolean,
routerMustCustody: boolean,
performAggregatedSlippageCheck: boolean
routerMustCustody: boolean
): void {
const trade = V4Trade.createUncheckedTrade({
route: route as RouteV4<TInput, TOutput>,
inputAmount,
outputAmount,
tradeType,
})
const slippageToleranceOnSwap = performAggregatedSlippageCheck ? undefined : options.slippageTolerance
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')
Expand All @@ -312,12 +312,16 @@ function addMixedSwap<TInput extends Currency, TOutput extends Currency>(
payerIsUser: boolean,
routerMustCustody: boolean
): void {
const { route, inputAmount, outputAmount } = swap
const tradeRecipient = routerMustCustody ? ROUTER_AS_RECIPIENT : options.recipient
const route = swap.route as MixedRoute<TInput, TOutput>
const inputAmount = swap.inputAmount
const outputAmount = swap.outputAmount
const tradeRecipient = routerMustCustody ? ROUTER_AS_RECIPIENT : options.recipient ?? SENDER_AS_RECIPIENT

// single hop, so it can be reduced to plain v2 or v3 swap logic
// single hop, so it can be reduced to plain swap logic for one protocol version
if (route.pools.length === 1) {
if (route.pools[0] instanceof V3Pool) {
if (route.pools[0] instanceof V4Pool) {
return addV4Swap(planner, swap, tradeType, options, payerIsUser, routerMustCustody)
} else if (route.pools[0] instanceof V3Pool) {
return addV3Swap(planner, swap, tradeType, options, payerIsUser, routerMustCustody)
} else if (route.pools[0] instanceof Pair) {
return addV2Swap(planner, swap, tradeType, options, payerIsUser, routerMustCustody)
Expand Down Expand Up @@ -345,49 +349,74 @@ function addMixedSwap<TInput extends Currency, TOutput extends Currency>(
return i === sections.length - 1
}

let outputToken
let inputToken = route.input.wrapped
let inputToken = route.pathInput

for (let i = 0; i < sections.length; i++) {
const section = sections[i]
/// Now, we get output of this section
outputToken = getOutputOfPools(section, inputToken)
const routePool = section[0]
const outputToken = getOutputOfPools(section, inputToken)
const subRoute = new MixedRoute(new MixedRouteSDK([...section], inputToken, outputToken))

const newRouteOriginal = new MixedRouteSDK(
[...section],
section[0].token0.equals(inputToken) ? section[0].token0 : section[0].token1,
outputToken
)
const newRoute = new MixedRoute(newRouteOriginal)
let nextInputToken
let swapRecipient

/// Previous output is now input
inputToken = outputToken.wrapped
if (isLastSectionInRoute(i)) {
nextInputToken = outputToken
swapRecipient = tradeRecipient
} else {
const nextPool = sections[i + 1][0]
nextInputToken = getPathCurrency(outputToken, nextPool)

const mixedRouteIsAllV3 = (route: MixedRouteSDK<Currency, Currency>) => {
return route.pools.every((pool) => pool instanceof V3Pool)
const v2PoolIsSwapRecipient = nextPool instanceof Pair && outputToken.equals(nextInputToken)
swapRecipient = v2PoolIsSwapRecipient ? (nextPool as Pair).liquidityToken.address : ROUTER_AS_RECIPIENT
}

if (mixedRouteIsAllV3(newRoute)) {
const path: string = encodeMixedRouteToPath(newRoute)
if (routePool instanceof V4Pool) {
const v4Planner = new V4Planner()
const v4SubRoute = new V4Route(section as V4Pool[], subRoute.input, subRoute.output)

v4Planner.addSettle(inputToken, payerIsUser && i === 0, (i == 0 ? amountIn : CONTRACT_BALANCE) as BigNumber)
v4Planner.addAction(Actions.SWAP_EXACT_IN, [
{
currencyIn: inputToken.isNative ? ETH_ADDRESS : inputToken.address,
path: encodeV4RouteToPath(v4SubRoute),
amountIn: 0, // denotes open delta, amount set in v4Planner.addSettle()
amountOutMinimum: !isLastSectionInRoute(i) ? 0 : amountOut,
},
])
v4Planner.addTake(outputToken, swapRecipient)

planner.addCommand(CommandType.V4_SWAP, [v4Planner.finalize()])
} else if (routePool instanceof V3Pool) {
planner.addCommand(CommandType.V3_SWAP_EXACT_IN, [
// if not last section: send tokens directly to the first v2 pair of the next section
// note: because of the partitioning function we can be sure that the next section is v2
isLastSectionInRoute(i) ? tradeRecipient : (sections[i + 1][0] as Pair).liquidityToken.address,
swapRecipient, // recipient
i == 0 ? amountIn : CONTRACT_BALANCE, // amountIn
!isLastSectionInRoute(i) ? 0 : amountOut, // amountOut
path, // path
encodeMixedRouteToPath(subRoute), // path
payerIsUser && i === 0, // payerIsUser
])
} else {
} else if (routePool instanceof Pair) {
planner.addCommand(CommandType.V2_SWAP_EXACT_IN, [
isLastSectionInRoute(i) ? tradeRecipient : ROUTER_AS_RECIPIENT, // recipient
swapRecipient, // recipient
i === 0 ? amountIn : CONTRACT_BALANCE, // amountIn
!isLastSectionInRoute(i) ? 0 : amountOut, // amountOutMin
newRoute.path.map((token) => token.wrapped.address), // path
subRoute.path.map((token) => token.wrapped.address), // path
payerIsUser && i === 0,
])
} else {
throw new Error('Unexpected Pool Type')
}

// perform a token transition (wrap/unwrap if necessary)
if (!isLastSectionInRoute(i)) {
if (outputToken.isNative && !nextInputToken.isNative) {
planner.addCommand(CommandType.WRAP_ETH, [ROUTER_AS_RECIPIENT, CONTRACT_BALANCE])
} else if (!outputToken.isNative && nextInputToken.isNative) {
planner.addCommand(CommandType.UNWRAP_WETH, [ROUTER_AS_RECIPIENT, 0])
}
}

inputToken = nextInputToken
}
}

Expand Down
28 changes: 28 additions & 0 deletions sdks/universal-router-sdk/src/utils/pathCurrency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Currency, Token } from '@uniswap/sdk-core'
import { Pool as V4Pool } from '@uniswap/v4-sdk'
import { TPool } from '@uniswap/router-sdk/dist/utils/TPool'

export function getPathCurrency(currency: Currency, pool: TPool): Currency {
// return currency if the currency matches a currency of the pool
if (pool.involvesToken(currency as Token)) {
return currency

// return if currency.wrapped if pool involves wrapped currency
} else if (pool.involvesToken(currency.wrapped as Token)) {
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 {
throw new Error(`Expected currency ${currency.symbol} to be either ${pool.token0.symbol} or ${pool.token1.symbol}`)
}

return currency // this line needed for typescript to compile
}
1 change: 1 addition & 0 deletions sdks/universal-router-sdk/src/utils/routerTradeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type TokenInRoute = {
export enum PoolType {
V2Pool = 'v2-pool',
V3Pool = 'v3-pool',
V4Pool = 'v4-pool',
}

export type V2Reserve = {
Expand Down
132 changes: 129 additions & 3 deletions sdks/universal-router-sdk/test/forge/SwapERC20CallParameters.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -546,9 +546,51 @@ contract SwapERC20CallParametersTest is Test, Interop, DeployRouter {
assertGt(USDC.balanceOf(RECIPIENT), 2000 * ONE_USDC);
}

// v4-sdk 1.6.3 allows this
// function testV4ExactInputEthWithWrap() public {
// MethodParameters memory params = readFixture(json, "._UNISWAP_V4_1_ETH_FOR_USDC_WITH_WRAP");
function testV4ExactInputEthWithWrap() public {
MethodParameters memory params = readFixture(json, "._UNISWAP_V4_1_ETH_FOR_USDC_WITH_WRAP");
assertEq(from.balance, BALANCE);
assertEq(USDC.balanceOf(RECIPIENT), 0);
assertEq(params.value, 1e18);

(bool success,) = address(router).call{value: params.value}(params.data);
require(success, "call failed");

assertLe(from.balance, BALANCE - params.value);
assertGt(USDC.balanceOf(RECIPIENT), 2000 * ONE_USDC);
}

// TODO: Logic for giving WETH fee with an ETH output
// function testV4ExactInputDAIForETHwithWEthFee() public {
// MethodParameters memory params = readFixture(json, "._UNISWAP_V4_USDC_FOR_1_ETH_2_HOP_WITH_WETH_FEE");
// deal(address(USDC), from, BALANCE);
// USDC.approve(address(permit2), BALANCE);
// permit2.approve(address(USDC), address(router), uint160(BALANCE), uint48(block.timestamp + 1000));
//
// assertEq(USDC.balanceOf(from), BALANCE);
// uint256 startingRecipientBalance = RECIPIENT.balance;
// uint256 startingFeeRecipientBalance = FEE_RECIPIENT.balance;
// assertEq(WETH.balanceOf(FEE_RECIPIENT), 0);
//
// (bool success,) = address(router).call{value: params.value}(params.data);
// require(success, "call failed");
// assertLe(USDC.balanceOf(from), BALANCE - 1000 ether);
//
// uint256 recipientOutETH = RECIPIENT.balance - startingRecipientBalance;
// uint256 feeRecipientOutETH = FEE_RECIPIENT.balance - startingFeeRecipientBalance;
// uint256 feeRecipientOutWETH = WETH.balanceOf(FEE_RECIPIENT);
//
// uint256 totalOut = recipientOutETH + feeRecipientOutWETH;
// uint256 expectedFee = totalOut * 500 / 10000;
//
// // Recipient should get ETH, and fee recipient should get WETH (and no ETH)
// assertEq(feeRecipientOutWETH, expectedFee);
// assertEq(feeRecipientOutETH, 0);
// assertEq(recipientOutETH, totalOut - expectedFee);
// assertGt(totalOut, 0.1 ether);
//
// // Nothing left in the router!
// assertEq(WETH.balanceOf(address(router)), 0);
// assertEq(address(router).balance, 0);
// }

function testV4ExactInWithFee() public {
Expand Down Expand Up @@ -714,4 +756,88 @@ contract SwapERC20CallParametersTest is Test, Interop, DeployRouter {
assertEq(USDC.balanceOf(address(router)), 0);
assertEq(address(router).balance, 0);
}

function testMixedV3ToV4UnwrapWETH() public {
MethodParameters memory params = readFixture(json, "._UNISWAP_MIXED_USDC_DAI_UNWRAP_WETH_V3_TO_V4");

uint256 usdcAmount = 1000000000;
deal(address(USDC), from, usdcAmount);
USDC.approve(address(permit2), usdcAmount);
permit2.approve(address(USDC), address(router), uint160(usdcAmount), uint48(block.timestamp + 1000));

assertEq(USDC.balanceOf(from), usdcAmount);
assertEq(DAI.balanceOf(RECIPIENT), 0);

(bool success,) = address(router).call{value: params.value}(params.data);
require(success, "call failed");

assertLe(from.balance, BALANCE - params.value);
assertEq(USDC.balanceOf(from), 0);
assertEq(DAI.balanceOf(address(router)), 0);
assertGt(DAI.balanceOf(RECIPIENT), 0);
assertEq(address(router).balance, 0);
}

function testMixedV2ToV4UnwrapWETH() public {
MethodParameters memory params = readFixture(json, "._UNISWAP_MIXED_USDC_DAI_UNWRAP_WETH_V2_TO_V4");

uint256 usdcAmount = 1000000000;
deal(address(USDC), from, usdcAmount);
USDC.approve(address(permit2), usdcAmount);
permit2.approve(address(USDC), address(router), uint160(usdcAmount), uint48(block.timestamp + 1000));

assertEq(USDC.balanceOf(from), usdcAmount);
assertEq(DAI.balanceOf(RECIPIENT), 0);

(bool success,) = address(router).call{value: params.value}(params.data);
require(success, "call failed");

assertLe(from.balance, BALANCE - params.value);
assertEq(USDC.balanceOf(from), 0);
assertEq(DAI.balanceOf(address(router)), 0);
assertGt(DAI.balanceOf(RECIPIENT), 0);
assertEq(address(router).balance, 0);
}

function testMixedV4ToV3WrapETH() public {
MethodParameters memory params = readFixture(json, "._UNISWAP_MIXED_DAI_USDC_WRAP_ETH_V4_TO_V3");

uint256 daiAmount = 1000 ether;
deal(address(DAI), from, daiAmount);
DAI.approve(address(permit2), daiAmount);
permit2.approve(address(DAI), address(router), uint160(daiAmount), uint48(block.timestamp + 1000));

assertEq(DAI.balanceOf(from), daiAmount);
assertEq(USDC.balanceOf(RECIPIENT), 0);

(bool success,) = address(router).call{value: params.value}(params.data);
require(success, "call failed");

assertLe(from.balance, BALANCE - params.value);
assertEq(DAI.balanceOf(from), 0);
assertEq(USDC.balanceOf(address(router)), 0);
assertGt(USDC.balanceOf(RECIPIENT), 0);
assertEq(address(router).balance, 0);
}

function testMixedV4ToV2WrapETH() public {
MethodParameters memory params = readFixture(json, "._UNISWAP_MIXED_DAI_USDC_WRAP_ETH_V4_TO_V2");

uint256 daiAmount = 1000 ether;
deal(address(DAI), from, daiAmount);
DAI.approve(address(permit2), daiAmount);
permit2.approve(address(DAI), address(router), uint160(daiAmount), uint48(block.timestamp + 1000));

assertEq(DAI.balanceOf(from), daiAmount);
assertEq(USDC.balanceOf(RECIPIENT), 0);

(bool success,) = address(router).call{value: params.value}(params.data);
require(success, "call failed");

assertLe(from.balance, BALANCE - params.value);
assertEq(DAI.balanceOf(from), 0);
assertEq(USDC.balanceOf(address(router)), 0);
assertGt(USDC.balanceOf(RECIPIENT), 0);
assertEq(address(router).balance, 0);
}
}
Loading

0 comments on commit 680324d

Please sign in to comment.