Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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": "^2.3.2",
"@uniswap/router-sdk": "^2.3.3",
"@uniswap/sdk-core": "^7.10.0",
"@uniswap/universal-router": "2.0.0-beta.2",
"@uniswap/v2-core": "^1.0.1",
"@uniswap/v2-sdk": "^4.17.0",
"@uniswap/v3-core": "1.0.0",
"@uniswap/v3-sdk": "^3.27.0",
"@uniswap/v4-sdk": "^1.25.1",
"@uniswap/v4-sdk": "^1.25.2",
"bignumber.js": "^9.0.2",
"ethers": "^5.7.0"
},
Expand Down
100 changes: 93 additions & 7 deletions sdks/universal-router-sdk/src/entities/actions/uniswap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,44 @@ export class UniswapTrade implements Command {
return result
}

/**
* Checks if any route in a split trade ends with an ETH-WETH pool.
*
* When a split trade has routes ending in ETH-WETH pools alongside routes ending in WETH,
* V4 swaps that are last in their route must TAKE WETH (not ETH) at the endfor consistency.
* This ensures all routes accumulate WETH, which can then be unwrapped together to ETH
* in a single operation at the end.
*/
get shouldForceV4UnwrapForSplitNativeOutput(): boolean {
// Only relevant if output is native and we have split routes
if (!this.trade.outputAmount.currency.isNative || this.trade.swaps.length <= 1) {
return false
}

for (const swap of this.trade.swaps) {
if (swap.route.protocol === Protocol.V4) {
const v4Route = swap.route as unknown as V4Route<Currency, Currency>
const lastPool = v4Route.pools[v4Route.pools.length - 1]
const isEthWethPool = lastPool.currency1.equals(lastPool.currency0.wrapped)
if (isEthWethPool) {
return true
}
} else if (swap.route.protocol === Protocol.MIXED) {
const mixedRoute = swap.route as MixedRoute<Currency, Currency>
const lastPool = mixedRoute.pools[mixedRoute.pools.length - 1]
// Check if the last pool in the mixed route is a V4 ETH-WETH pool
if (lastPool instanceof V4Pool) {
const isEthWethPool = lastPool.currency1.equals(lastPool.currency0.wrapped)
if (isEthWethPool) {
return true
}
}
}
}

return false
}

// this.trade.swaps is an array of swaps / trades.
// we are iterating over one swap (trade) at a time so length is 1
// route is either v2, v3, v4, or mixed
Expand Down Expand Up @@ -139,6 +177,12 @@ export class UniswapTrade implements Command {
}

get outputRequiresUnwrap(): boolean {
// If output is ETH and any V4 route has ETH-WETH last pool,
// we force ALL V4 routes to take WETH, so we need to unwrap at the end
if (this.shouldForceV4UnwrapForSplitNativeOutput) {
return true
}

const swap = this.trade.swaps[0]
const lastRoute = swap.route
const lastPool = lastRoute.pools[lastRoute.pools.length - 1]
Expand Down Expand Up @@ -197,10 +241,26 @@ 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)
addV4Swap(
planner,
swap,
this.trade.tradeType,
this.options,
this.payerIsUser,
routerMustCustody,
this.shouldForceV4UnwrapForSplitNativeOutput
)
break
case Protocol.MIXED:
addMixedSwap(planner, swap, this.trade.tradeType, this.options, this.payerIsUser, routerMustCustody)
addMixedSwap(
planner,
swap,
this.trade.tradeType,
this.options,
this.payerIsUser,
routerMustCustody,
this.shouldForceV4UnwrapForSplitNativeOutput
)
break
default:
throw new Error('UNSUPPORTED_TRADE_PROTOCOL')
Expand Down Expand Up @@ -355,7 +415,8 @@ function addV4Swap<TInput extends Currency, TOutput extends Currency>(
tradeType: TradeType,
options: SwapOptions,
payerIsUser: boolean,
routerMustCustody: boolean
routerMustCustody: boolean,
shouldForceV4UnwrapForSplitNativeOutput: boolean = false
): void {
// create a deep copy of pools since v4Planner encoding tampers with array
const pools = route.pools.map((p) => p) as V4Pool[]
Expand All @@ -373,8 +434,16 @@ function addV4Swap<TInput extends Currency, TOutput extends Currency>(
const v4Planner = new V4Planner()
v4Planner.addTrade(trade, slippageToleranceOnSwap)
v4Planner.addSettle(trade.route.pathInput, payerIsUser)

// If any V4 route in split trades has an ETH-WETH last pool and output is ETH,
// ALL V4 routes should use WETH for taking to ensure consistency and allow single unwrap
let pathOutputForTake = trade.route.pathOutput
if (shouldForceV4UnwrapForSplitNativeOutput) {
pathOutputForTake = pathOutputForTake.wrapped
}

v4Planner.addTake(
trade.route.pathOutput,
pathOutputForTake,
routerMustCustody ? ROUTER_AS_RECIPIENT : options.recipient ?? SENDER_AS_RECIPIENT
)
planner.addCommand(CommandType.V4_SWAP, [v4Planner.finalize()])
Expand All @@ -387,7 +456,8 @@ function addMixedSwap<TInput extends Currency, TOutput extends Currency>(
tradeType: TradeType,
options: SwapOptions,
payerIsUser: boolean,
routerMustCustody: boolean
routerMustCustody: boolean,
shouldForceV4UnwrapForSplitNativeOutput: boolean = false
): void {
const route = swap.route as MixedRoute<TInput, TOutput>
const inputAmount = swap.inputAmount
Expand All @@ -397,7 +467,15 @@ function addMixedSwap<TInput extends Currency, TOutput extends Currency>(
// 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 V4Pool) {
return addV4Swap(planner, swap, tradeType, options, payerIsUser, routerMustCustody)
return addV4Swap(
planner,
swap,
tradeType,
options,
payerIsUser,
routerMustCustody,
shouldForceV4UnwrapForSplitNativeOutput
)
} else if (route.pools[0] instanceof V3Pool) {
return addV3Swap(planner, swap, tradeType, options, payerIsUser, routerMustCustody)
} else if (route.pools[0] instanceof Pair) {
Expand Down Expand Up @@ -461,7 +539,15 @@ function addMixedSwap<TInput extends Currency, TOutput extends Currency>(
amountOutMinimum: !isLastSectionInRoute(i) ? 0 : amountOut,
},
])
v4Planner.addTake(outputToken, swapRecipient)

// If any V4 route has ETH-WETH last pool and this is last section outputting ETH,
// use WETH instead for consistency
let outputTokenForTake = outputToken
if (shouldForceV4UnwrapForSplitNativeOutput && isLastSectionInRoute(i)) {
outputTokenForTake = outputToken.wrapped
}

v4Planner.addTake(outputTokenForTake, swapRecipient)

planner.addCommand(CommandType.V4_SWAP, [v4Planner.finalize()])
} else if (routePool instanceof V3Pool) {
Expand Down
174 changes: 174 additions & 0 deletions sdks/universal-router-sdk/test/forge/SwapERC20CallParameters.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,180 @@ contract SwapERC20CallParametersTest is Test, Interop, DeployRouter {
assertGt(DAI.balanceOf(RECIPIENT), 9 * ONE_DAI / 10);
}

function testV4ExactInMultiHop2Routes1() public {
MethodParameters memory params = readFixture(json, "._UNISWAP_SPLIT_TWO_ROUTES_V4");

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 wethStartingBalance = WETH.balanceOf(RECIPIENT);

(bool success,) = address(router).call{value: params.value}(params.data);
require(success, "call failed");
assertEq(WETH.balanceOf(RECIPIENT), wethStartingBalance);
assertGt(RECIPIENT.balance, startingRecipientBalance);
assertEq(address(router).balance, 0);
assertEq(USDC.balanceOf(from), BALANCE - 2000 * ONE_USDC);
}

function testV4ExactInMultiHop2RoutesMixed1() public {
MethodParameters memory params = readFixture(json, "._UNISWAP_SPLIT_TWO_ROUTES_V4_MIXED");

uint256 daiAmount = 2000 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);
uint256 startingRecipientBalance = RECIPIENT.balance;

(bool success,) = address(router).call{value: params.value}(params.data);
require(success, "call failed");
assertEq(WETH.balanceOf(RECIPIENT), 0);
assertGt(RECIPIENT.balance, startingRecipientBalance);
assertEq(address(router).balance, 0);
}

function testV4ExactInMultiHop2RoutesMixedEth() public {
MethodParameters memory params = readFixture(json, "._UNISWAP_SPLIT_TWO_ROUTES_V4_MIXED_ENDING_WITH_ETH");

uint256 daiAmount = 2000 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);
uint256 startingRecipientBalance = RECIPIENT.balance;

(bool success,) = address(router).call{value: params.value}(params.data);
require(success, "call failed");
assertEq(WETH.balanceOf(RECIPIENT), 0);
assertGt(RECIPIENT.balance, startingRecipientBalance);
assertEq(address(router).balance, 0);
}

function testV4ExactInMultiHop2RoutesMixedWETH() public {
MethodParameters memory params = readFixture(json, "._UNISWAP_SPLIT_TWO_ROUTES_V4_MIXED_ENDING_WITH_WETH");

uint256 daiAmount = 2000 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);
uint256 startingRecipientBalance = RECIPIENT.balance;

(bool success,) = address(router).call{value: params.value}(params.data);
require(success, "call failed");
assertEq(WETH.balanceOf(RECIPIENT), 0);
assertGt(RECIPIENT.balance, startingRecipientBalance);
assertEq(address(router).balance, 0);
}

function testV4ExactInMultiHop2RoutesETHWETH() public {
MethodParameters memory params = readFixture(json, "._UNISWAP_SPLIT_TWO_ROUTES_V4_ETH_WETH");

uint256 usdcAmount = 1000 ether;
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);
uint256 startingRecipientBalance = RECIPIENT.balance;

(bool success,) = address(router).call{value: params.value}(params.data);
require(success, "call failed");
assertEq(WETH.balanceOf(RECIPIENT), 0);
assertGt(RECIPIENT.balance, startingRecipientBalance);
assertEq(address(router).balance, 0);
}

function testV4ExactInMultiHop2RoutesETHWETHMixed() public {
MethodParameters memory params = readFixture(json, "._UNISWAP_SPLIT_TWO_ROUTES_V4_ETH_WETH_MIXED");

uint256 daiAmount = 2000 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);
uint256 startingRecipientBalance = RECIPIENT.balance;

(bool success,) = address(router).call{value: params.value}(params.data);
require(success, "call failed");
assertEq(WETH.balanceOf(RECIPIENT), 0);
assertGt(RECIPIENT.balance, startingRecipientBalance);
assertEq(address(router).balance, 0);
}

function testV4ExactInMultiHop2RoutesWETHMixedETHWETH() public {
MethodParameters memory params = readFixture(json, "._UNISWAP_SPLIT_TWO_ROUTES_V4_WETH_MIXED_ETH_WETH");

uint256 daiAmount = 2000 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);
uint256 startingRecipientBalance = RECIPIENT.balance;

(bool success,) = address(router).call{value: params.value}(params.data);
require(success, "call failed");
assertEq(WETH.balanceOf(RECIPIENT), 0);
assertGt(RECIPIENT.balance, startingRecipientBalance);
assertEq(address(router).balance, 0);
}

function testV4ExactInMultiHop2Routesv4ETHWETHv3WETH() public {
MethodParameters memory params = readFixture(json, "._UNISWAP_SPLIT_TWO_ROUTES_V4_WETH");

uint256 usdcAmount = 2000 ether;
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);
uint256 startingRecipientWETHBalance = WETH.balanceOf(RECIPIENT);
uint256 startingRecipientBalance = RECIPIENT.balance;

(bool success,) = address(router).call{value: params.value}(params.data);
require(success, "call failed");
assertGt(WETH.balanceOf(RECIPIENT), startingRecipientWETHBalance);
assertEq(RECIPIENT.balance, startingRecipientBalance);
assertEq(address(router).balance, 0);
}

function testV4ExactInMultiHop2Routesv4WETHv4ETHWETH() public {
MethodParameters memory params = readFixture(json, "._UNISWAP_SPLIT_TWO_ROUTES_V4_WETH");

uint256 usdcAmount = 2000 ether;
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);
uint256 startingRecipientWETHBalance = WETH.balanceOf(RECIPIENT);
uint256 startingRecipientBalance = RECIPIENT.balance;

(bool success,) = address(router).call{value: params.value}(params.data);
require(success, "call failed");
assertGt(WETH.balanceOf(RECIPIENT), startingRecipientWETHBalance);
assertEq(RECIPIENT.balance, startingRecipientBalance);
assertEq(address(router).balance, 0);
}

function testV4ExactInMultiHop2Routesv4ETHv4ETH() public {
MethodParameters memory params = readFixture(json, "._UNISWAP_SPLIT_TWO_ROUTES_V4_ETH_V4_ETH");

uint256 usdcAmount = 2000 ether;
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);
uint256 startingRecipientWETHBalance = WETH.balanceOf(RECIPIENT);
uint256 startingRecipientBalance = RECIPIENT.balance;

(bool success,) = address(router).call{value: params.value}(params.data);
require(success, "call failed");
assertGt(WETH.balanceOf(RECIPIENT), startingRecipientWETHBalance);
assertEq(RECIPIENT.balance, startingRecipientBalance);
assertEq(address(router).balance, 0);
}

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

Expand Down
Loading
Loading