Skip to content

Commit

Permalink
fix(universal-router-sdk): Add V4 Exact-In Swaps (#111)
Browse files Browse the repository at this point in the history
Co-authored-by: gretzke <[email protected]>
Co-authored-by: Cody Born <[email protected]>
Co-authored-by: Alice Henshaw <[email protected]>
Co-authored-by: Mark Toda <[email protected]>
  • Loading branch information
5 people authored Sep 30, 2024
1 parent 64f4751 commit 8848f72
Show file tree
Hide file tree
Showing 9 changed files with 510 additions and 53 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.11.0",
"@uniswap/router-sdk": "^1.12.1",
"@uniswap/sdk-core": "^5.3.1",
"@uniswap/universal-router": "2.0.0-beta.1",
"@uniswap/v2-core": "^1.0.1",
"@uniswap/v2-sdk": "^4.4.1",
"@uniswap/v3-core": "1.0.0",
"@uniswap/v3-sdk": "^3.13.1",
"@uniswap/v4-sdk": "^1.0.0",
"@uniswap/v4-sdk": "^1.6.3",
"bignumber.js": "^9.0.2",
"ethers": "^5.7.0"
},
Expand Down
77 changes: 71 additions & 6 deletions sdks/universal-router-sdk/src/entities/actions/uniswap.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
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 { Pool as V4Pool } from '@uniswap/v4-sdk'
import { Trade as V4Trade, V4Planner } from '@uniswap/v4-sdk'
import {
Trade as RouterTrade,
MixedRouteTrade,
Protocol,
IRoute,
RouteV2,
RouteV3,
RouteV4,
MixedRouteSDK,
MixedRoute,
SwapOptions as RouterSwapOptions,
Expand All @@ -22,6 +23,7 @@ import { Command, RouterActionType, TradeConfig } from '../Command'
import { SENDER_AS_RECIPIENT, ROUTER_AS_RECIPIENT, CONTRACT_BALANCE, ETH_ADDRESS } from '../../utils/constants'
import { encodeFeeBips } from '../../utils/numbers'
import { BigNumber, BigNumberish } from 'ethers'
import { TPool } from '@uniswap/router-sdk/dist/utils/TPool'

export type FlatFeeOptions = {
amount: BigNumberish
Expand All @@ -42,7 +44,7 @@ export type SwapOptions = Omit<RouterSwapOptions, 'inputTokenPermit'> & {
const REFUND_ETH_PRICE_IMPACT_THRESHOLD = new Percent(50, 100)

interface Swap<TInput extends Currency, TOutput extends Currency> {
route: IRoute<TInput, TOutput, Pair | V3Pool | V4Pool>
route: IRoute<TInput, TOutput, TPool>
inputAmount: CurrencyAmount<TInput>
outputAmount: CurrencyAmount<TOutput>
}
Expand All @@ -60,8 +62,31 @@ export class UniswapTrade implements Command {
else this.payerIsUser = true
}

get isAllV4(): boolean {
let result = true
for (const swap of this.trade.swaps) {
result = result && swap.route.protocol == Protocol.V4
}
return result
}

get inputRequiresWrap(): boolean {
return this.trade.inputAmount.currency.isNative
if (!this.isAllV4) {
return this.trade.inputAmount.currency.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
}
}

get outputRequiresUnwrap(): boolean {
if (!this.isAllV4) {
return this.trade.outputAmount.currency.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
}
}

encode(planner: RoutePlanner, _config: TradeConfig): void {
Expand All @@ -82,8 +107,7 @@ 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 outputIsNative = this.trade.outputAmount.currency.isNative
const routerMustCustody = performAggregatedSlippageCheck || outputIsNative || hasFeeOption(this.options)
const routerMustCustody = performAggregatedSlippageCheck || this.outputRequiresUnwrap || hasFeeOption(this.options)

for (const swap of this.trade.swaps) {
switch (swap.route.protocol) {
Expand All @@ -93,6 +117,17 @@ export class UniswapTrade implements Command {
case Protocol.V3:
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
)
break
case Protocol.MIXED:
addMixedSwap(planner, swap, this.trade.tradeType, this.options, this.payerIsUser, routerMustCustody)
break
Expand Down Expand Up @@ -145,7 +180,7 @@ export class UniswapTrade implements Command {

// The remaining tokens that need to be sent to the user after the fee is taken will be caught
// by this if-else clause.
if (outputIsNative) {
if (this.outputRequiresUnwrap) {
planner.addCommand(CommandType.UNWRAP_WETH, [this.options.recipient, minimumAmountOut])
} else {
planner.addCommand(CommandType.SWEEP, [
Expand Down Expand Up @@ -238,6 +273,36 @@ function addV3Swap<TInput extends Currency, TOutput extends Currency>(
}
}

function addV4Swap<TInput extends Currency, TOutput extends Currency>(
planner: RoutePlanner,
{ route, inputAmount, outputAmount }: Swap<TInput, TOutput>,
tradeType: TradeType,
options: SwapOptions,
payerIsUser: boolean,
routerMustCustody: boolean,
performAggregatedSlippageCheck: boolean
): void {
const trade = V4Trade.createUncheckedTrade({
route: route as RouteV4<TInput, TOutput>,
inputAmount,
outputAmount,
tradeType,
})
const slippageToleranceOnSwap = performAggregatedSlippageCheck ? 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)

planner.addCommand(CommandType.V4_SWAP, [v4Planner.finalize()])
}

// encode a mixed route swap, i.e. including both v2 and v3 pools
function addMixedSwap<TInput extends Currency, TOutput extends Currency>(
planner: RoutePlanner,
Expand Down
5 changes: 5 additions & 0 deletions sdks/universal-router-sdk/src/utils/routerCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export enum CommandType {
PERMIT2_TRANSFER_FROM_BATCH = 0x0d,
BALANCE_CHECK_ERC20 = 0x0e,

V4_SWAP = 0x10,
V3_POSITION_MANAGER_PERMIT = 0x11,
V3_POSITION_MANAGER_CALL = 0x12,
V4_POSITION_CALL = 0x13,
Expand Down Expand Up @@ -56,6 +57,7 @@ const ABI_DEFINITION: { [key in CommandType]: string[] } = {
[CommandType.V3_SWAP_EXACT_OUT]: ['address', 'uint256', 'uint256', 'bytes', 'bool'],
[CommandType.V2_SWAP_EXACT_IN]: ['address', 'uint256', 'uint256', 'address[]', 'bool'],
[CommandType.V2_SWAP_EXACT_OUT]: ['address', 'uint256', 'uint256', 'address[]', 'bool'],
[CommandType.V4_SWAP]: ['bytes'],

// Token Actions and Checks
[CommandType.WRAP_ETH]: ['address', 'uint256'],
Expand Down Expand Up @@ -104,6 +106,9 @@ export type RouterCommand = {
}

export function createCommand(type: CommandType, parameters: any[]): RouterCommand {
if (type === CommandType.V4_SWAP) {
return { type, encodedInput: parameters[0] }
}
const encodedInput = defaultAbiCoder.encode(ABI_DEFINITION[type], parameters)
return { type, encodedInput }
}
92 changes: 92 additions & 0 deletions sdks/universal-router-sdk/test/forge/SwapERC20CallParameters.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ contract SwapERC20CallParametersTest is Test, Interop, DeployRouter {
json = vm.readFile(string.concat(root, "/test/forge/interop.json"));

vm.createSelectFork(vm.envString("FORK_URL"), 16075500);
deployV4Contracts();
initializeV4Pools(WETH, USDC, DAI);
vm.startPrank(from);
deployRouterAndPermit2();
vm.deal(from, BALANCE);
Expand Down Expand Up @@ -531,6 +533,96 @@ contract SwapERC20CallParametersTest is Test, Interop, DeployRouter {
assertEq(address(router).balance, 0);
}

function testV4ExactInputETH() public {
MethodParameters memory params = readFixture(json, "._UNISWAP_V4_1_ETH_FOR_USDC");
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);
}

// v4-sdk 1.6.3 allows this
// function testV4ExactInputEthWithWrap() public {
// MethodParameters memory params = readFixture(json, "._UNISWAP_V4_1_ETH_FOR_USDC_WITH_WRAP");
// }

function testV4ExactInWithFee() public {
MethodParameters memory params = readFixture(json, "._UNISWAP_V4_1_ETH_FOR_USDC_WITH_FEE");

assertEq(from.balance, BALANCE);
assertEq(USDC.balanceOf(RECIPIENT), 0);
assertEq(USDC.balanceOf(FEE_RECIPIENT), 0);

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

uint256 recipientBalance = USDC.balanceOf(RECIPIENT);
uint256 feeRecipientBalance = USDC.balanceOf(FEE_RECIPIENT);
uint256 totalOut = recipientBalance + feeRecipientBalance;
uint256 expectedFee = totalOut * 500 / 10000;
assertEq(feeRecipientBalance, expectedFee, "Fee received");
assertEq(recipientBalance, totalOut - expectedFee, "User output");
assertGt(totalOut, 1000 * ONE_USDC, "Slippage");
}

function testV4ExactInWithFlatFee() public {
MethodParameters memory params = readFixture(json, "._UNISWAP_V4_1_ETH_FOR_USDC_WITH_FLAT_FEE");
assertEq(from.balance, BALANCE);
assertEq(USDC.balanceOf(RECIPIENT), 0);
assertEq(USDC.balanceOf(FEE_RECIPIENT), 0);

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

uint256 recipientBalance = USDC.balanceOf(RECIPIENT);
uint256 feeRecipientBalance = USDC.balanceOf(FEE_RECIPIENT);
uint256 totalOut = recipientBalance + feeRecipientBalance;
uint256 expectedFee = 50 * ONE_USDC;
assertEq(feeRecipientBalance, expectedFee);
assertEq(recipientBalance, totalOut - expectedFee);
assertGt(totalOut, 1000 * ONE_USDC);
}

function testV4ExactInNativeOutput() public {
MethodParameters memory params = readFixture(json, "._UNISWAP_V4_1000_USDC_FOR_ETH");

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 = from.balance;

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

assertEq(USDC.balanceOf(from), BALANCE - 1000 * ONE_USDC);
assertGe(from.balance, startingRecipientBalance);
}

// v4-sdk 1.6.3 allows this
// function testV4ExactInNativeOutputWithUnwrap() public {
// MethodParameters memory params = readFixture(json, "._UNISWAP_V4_1000_USDC_FOR_ETH_WITH_UNWRAP");
// }

function testV4ExactInMultiHop() public {
MethodParameters memory params = readFixture(json, "._UNISWAP_V4_ETH_FOR_DAI");

assertEq(from.balance, BALANCE);
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);
assertGt(DAI.balanceOf(RECIPIENT), 9 * ONE_DAI / 10);
}

function testMixedExactInputNative() public {
MethodParameters memory params = readFixture(json, "._UNISWAP_MIXED_1_ETH_FOR_DAI");

Expand Down
Loading

0 comments on commit 8848f72

Please sign in to comment.