Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: uniswap v3 exact output support + UniversalSwapAndBridge #1268

Merged
merged 12 commits into from
Nov 10, 2024
Merged
Changes from 1 commit
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
Next Next commit
feat: add exact output trade type support uniswap
dohaki committed Nov 8, 2024
commit e2065b5b7a95df9c5155d5b556e0a5316e6c61e3
25 changes: 11 additions & 14 deletions api/_dexes/1inch.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,24 @@
import axios from "axios";

import { AcrossSwap, SwapQuoteAndCalldata } from "./types";
import { Swap, OriginSwapQuoteAndCalldata } from "./types";
import { getSwapAndBridgeAddress } from "./utils";

export async function get1inchQuoteAndCalldata(
swap: AcrossSwap
): Promise<SwapQuoteAndCalldata> {
const swapAndBridgeAddress = getSwapAndBridgeAddress(
"1inch",
swap.swapToken.chainId
);
const apiBaseUrl = `https://api.1inch.dev/swap/v6.0/${swap.swapToken.chainId}`;
export async function get1inchQuoteForOriginSwapExactInput(
swap: Omit<Swap, "recipient">
): Promise<OriginSwapQuoteAndCalldata> {
const swapAndBridgeAddress = getSwapAndBridgeAddress("1inch", swap.chainId);
const apiBaseUrl = `https://api.1inch.dev/swap/v6.0/${swap.chainId}`;
const apiHeaders = {
Authorization: `Bearer ${process.env.ONEINCH_API_KEY}`,
accept: "application/json",
};

const swapParams = {
src: swap.swapToken.address,
dst: swap.acrossInputToken.address,
amount: swap.swapTokenAmount,
src: swap.tokenIn.address,
dst: swap.tokenOut.address,
amount: swap.amount,
from: swapAndBridgeAddress,
slippage: swap.slippage,
slippage: swap.slippageTolerance,
disableEstimate: true,
allowPartialFill: false,
receiver: swapAndBridgeAddress,
@@ -47,6 +44,6 @@ export async function get1inchQuoteAndCalldata(
value: response.data.tx.value,
swapAndBridgeAddress,
dex: "1inch",
slippage: swap.slippage,
slippage: swap.slippageTolerance,
};
}
21 changes: 9 additions & 12 deletions api/_dexes/types.ts
Original file line number Diff line number Diff line change
@@ -5,22 +5,19 @@ export type Token = {
chainId: number;
};

/**
* @property `swapToken` - Address of the token that will be swapped for acrossInputToken.
* @property `acrossInputToken` - Address of the token that will be bridged via Across as the inputToken.
* @property `swapTokenAmount` - The amount of swapToken to be swapped for acrossInputToken.
* @property `slippage` - The slippage tolerance for the swap in decimals, e.g. 1 for 1%.
*/
export type AcrossSwap = {
swapToken: Token;
acrossInputToken: Token;
swapTokenAmount: string;
slippage: number;
export type Swap = {
chainId: number;
tokenIn: Token;
tokenOut: Token;
amount: string;
recipient: string;
slippageTolerance: number;
type: "EXACT_INPUT" | "MIN_OUTPUT";
};

export type SupportedDex = "1inch" | "uniswap";

export type SwapQuoteAndCalldata = {
export type OriginSwapQuoteAndCalldata = {
minExpectedInputTokenAmount: string;
routerCalldata: string;
value: string;
289 changes: 104 additions & 185 deletions api/_dexes/uniswap.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,22 @@
import { ethers } from "ethers";
import { CurrencyAmount, Percent, Token, TradeType } from "@uniswap/sdk-core";
import {
FeeAmount,
Pool,
Route,
SwapOptions,
SwapQuoter,
SwapRouter,
Trade,
computePoolAddress,
} from "@uniswap/v3-sdk";
import {
Currency,
CurrencyAmount,
Percent,
Token,
TradeType,
} from "@uniswap/sdk-core";
import JSBI from "jsbi";
import IUniswapV3PoolABI from "@uniswap/v3-core/artifacts/contracts/interfaces/IUniswapV3Pool.sol/IUniswapV3Pool.json";
AlphaRouter,
SwapOptionsSwapRouter02,
SwapType,
} from "@uniswap/smart-order-router";
import { CHAIN_IDs } from "@across-protocol/constants";
import { utils } from "@across-protocol/sdk";

import { callViaMulticall3, getProvider } from "../_utils";
import { getProvider } from "../_utils";
import { TOKEN_SYMBOLS_MAP } from "../_constants";
import {
AcrossSwap,
SwapQuoteAndCalldata,
OriginSwapQuoteAndCalldata,
Token as AcrossToken,
Swap,
} from "./types";
import { getSwapAndBridgeAddress } from "./utils";

// https://docs.uniswap.org/contracts/v3/reference/deployments/
const POOL_FACTORY_CONTRACT_ADDRESS = {
[CHAIN_IDs.MAINNET]: "0x1F98431c8aD98523631AE4a59f267346ea31F984",
[CHAIN_IDs.OPTIMISM]: "0x1F98431c8aD98523631AE4a59f267346ea31F984",
[CHAIN_IDs.ARBITRUM]: "0x1F98431c8aD98523631AE4a59f267346ea31F984",
[CHAIN_IDs.BASE]: "0x33128a8fC17869897dcE68Ed026d694621f6FDfD",
[CHAIN_IDs.POLYGON]: "0x1F98431c8aD98523631AE4a59f267346ea31F984",
};
const QUOTER_CONTRACT_ADDRESS = {
[CHAIN_IDs.MAINNET]: "0x61fFE014bA17989E743c5F6cB21bF9697530B21e",
[CHAIN_IDs.OPTIMISM]: "0x61fFE014bA17989E743c5F6cB21bF9697530B21e",
[CHAIN_IDs.ARBITRUM]: "0x61fFE014bA17989E743c5F6cB21bF9697530B21e",
[CHAIN_IDs.BASE]: "0x3d4e44Eb1374240CE5F1B871ab261CD16335B76a",
[CHAIN_IDs.POLYGON]: "0x61fFE014bA17989E743c5F6cB21bF9697530B21e",
};

// Maps testnet chain IDs to their main counterparts. Used to get the mainnet token
// info for testnet tokens.
const TESTNET_TO_MAINNET = {
@@ -55,186 +26,120 @@ const TESTNET_TO_MAINNET = {
[CHAIN_IDs.ARBITRUM_SEPOLIA]: CHAIN_IDs.ARBITRUM,
};

export async function getUniswapQuoteAndCalldata(
swap: AcrossSwap
): Promise<SwapQuoteAndCalldata> {
const swapAndBridgeAddress = getSwapAndBridgeAddress(
"uniswap",
swap.swapToken.chainId
);
export async function getUniswapQuoteForOriginSwapExactInput(
swap: Omit<Swap, "recipient">
): Promise<OriginSwapQuoteAndCalldata> {
const swapAndBridgeAddress = getSwapAndBridgeAddress("uniswap", swap.chainId);

const initialSwapToken = { ...swap.swapToken };
const initialAcrossInputToken = { ...swap.acrossInputToken };
const initialTokenIn = { ...swap.tokenIn };
const initialTokenOut = { ...swap.tokenOut };
// Always use mainnet tokens for retrieving quote, so that we can get equivalent quotes
// for testnet tokens.
swap.swapToken = getMainnetToken(swap.swapToken);
swap.acrossInputToken = getMainnetToken(swap.acrossInputToken);

const poolInfo = await getPoolInfo(swap);
const tokenA = new Token(
swap.swapToken.chainId,
swap.swapToken.address,
swap.swapToken.decimals
);
const tokenB = new Token(
swap.acrossInputToken.chainId,
swap.acrossInputToken.address,
swap.acrossInputToken.decimals
);
const pool = new Pool(
tokenA,
tokenB,
FeeAmount.LOW,
poolInfo.sqrtPriceX96.toString(),
poolInfo.liquidity.toString(),
poolInfo.tick
);

const swapRoute = new Route([pool], tokenA, tokenB);

const amountOut = await getOutputQuote(swap, swapRoute);
swap.tokenIn = getMainnetToken(swap.tokenIn);
swap.tokenOut = getMainnetToken(swap.tokenOut);

const uncheckedTrade = Trade.createUncheckedTrade({
route: swapRoute,
inputAmount: CurrencyAmount.fromRawAmount(tokenA, swap.swapTokenAmount),
outputAmount: CurrencyAmount.fromRawAmount(tokenB, JSBI.BigInt(amountOut)),
tradeType: TradeType.EXACT_INPUT,
});

const options: SwapOptions = {
slippageTolerance: new Percent(
// max. slippage decimals is 2
Number(swap.slippage.toFixed(2)) * 100,
10_000
),
// 20 minutes from the current Unix time
deadline: utils.getCurrentTime() + 60 * 20,
const { route, options } = await getUniswapQuote({
...swap,
recipient: swapAndBridgeAddress,
};
});

const methodParameters = SwapRouter.swapCallParameters(
[uncheckedTrade],
options
);
if (!route.methodParameters) {
throw new NoUniswapRouteError({
tokenInSymbol: swap.tokenIn.symbol,
tokenOutSymbol: swap.tokenOut.symbol,
chainId: swap.chainId,
swapType: "EXACT_INPUT",
});
}

// replace mainnet token addresses with initial token addresses in calldata
methodParameters.calldata = methodParameters.calldata.replace(
swap.swapToken.address.toLowerCase().slice(2),
initialSwapToken.address.toLowerCase().slice(2)
route.methodParameters.calldata = route.methodParameters.calldata.replace(
swap.tokenIn.address.toLowerCase().slice(2),
initialTokenIn.address.toLowerCase().slice(2)
);
methodParameters.calldata = methodParameters.calldata.replace(
swap.acrossInputToken.address.toLowerCase().slice(2),
initialAcrossInputToken.address.toLowerCase().slice(2)
route.methodParameters.calldata = route.methodParameters.calldata.replace(
swap.tokenOut.address.toLowerCase().slice(2),
initialTokenOut.address.toLowerCase().slice(2)
);

return {
minExpectedInputTokenAmount: ethers.utils
.parseUnits(
uncheckedTrade.minimumAmountOut(options.slippageTolerance).toExact(),
swap.acrossInputToken.decimals
route.trade.minimumAmountOut(options.slippageTolerance).toExact(),
swap.tokenIn.decimals
)
.toString(),
routerCalldata: methodParameters.calldata,
value: ethers.BigNumber.from(methodParameters.value).toString(),
routerCalldata: route.methodParameters.calldata,
value: ethers.BigNumber.from(route.methodParameters.value).toString(),
swapAndBridgeAddress,
dex: "uniswap",
slippage: swap.slippage,
slippage: swap.slippageTolerance,
};
}

async function getOutputQuote(
swap: AcrossSwap,
route: Route<Currency, Currency>
) {
const provider = getProvider(route.chainId);
export async function getUniswapQuote(swap: Swap) {
const { router, options } = getSwapRouterAndOptions(swap);

const { calldata } = SwapQuoter.quoteCallParameters(
route,
const amountCurrency =
swap.type === "EXACT_INPUT" ? swap.tokenIn : swap.tokenOut;
const quoteCurrency =
swap.type === "EXACT_INPUT" ? swap.tokenOut : swap.tokenIn;

const route = await router.route(
CurrencyAmount.fromRawAmount(
new Token(
swap.swapToken.chainId,
swap.swapToken.address,
swap.swapToken.decimals
amountCurrency.chainId,
amountCurrency.address,
amountCurrency.decimals
),
swap.swapTokenAmount
swap.amount
),
new Token(
quoteCurrency.chainId,
quoteCurrency.address,
quoteCurrency.decimals
),
TradeType.EXACT_INPUT,
{ useQuoterV2: true }
swap.type === "EXACT_INPUT"
? TradeType.EXACT_INPUT
: TradeType.EXACT_OUTPUT,
options
);

const quoteCallReturnData = await provider.call({
to: QUOTER_CONTRACT_ADDRESS[swap.swapToken.chainId],
data: calldata,
});
if (!route || !route.methodParameters) {
throw new NoUniswapRouteError({
tokenInSymbol: swap.tokenIn.symbol,
tokenOutSymbol: swap.tokenOut.symbol,
chainId: swap.chainId,
swapType: "EXACT_INPUT",
});
}

return ethers.utils.defaultAbiCoder.decode(["uint256"], quoteCallReturnData);
return { route, options };
}

async function getPoolInfo({
swapToken,
acrossInputToken,
}: AcrossSwap): Promise<{
token0: string;
token1: string;
fee: number;
tickSpacing: number;
sqrtPriceX96: ethers.BigNumber;
liquidity: ethers.BigNumber;
tick: number;
}> {
const provider = getProvider(swapToken.chainId);
const poolContract = new ethers.Contract(
computePoolAddress({
factoryAddress: POOL_FACTORY_CONTRACT_ADDRESS[swapToken.chainId],
tokenA: new Token(
swapToken.chainId,
swapToken.address,
swapToken.decimals
),
tokenB: new Token(
acrossInputToken.chainId,
acrossInputToken.address,
acrossInputToken.decimals
),
fee: FeeAmount.LOW,
}),
IUniswapV3PoolABI.abi,
provider
);

const poolMethods = [
"token0",
"token1",
"fee",
"tickSpacing",
"liquidity",
"slot0",
];
const [token0, token1, fee, tickSpacing, liquidity, slot0] =
await callViaMulticall3(
provider,
poolMethods.map((method) => ({
contract: poolContract,
functionName: method,
}))
);

function getSwapRouterAndOptions(params: {
chainId: number;
recipient: string;
slippageTolerance: number;
}) {
const provider = getProvider(params.chainId);
const router = new AlphaRouter({
chainId: params.chainId,
provider,
});
const options: SwapOptionsSwapRouter02 = {
recipient: params.recipient,
deadline: utils.getCurrentTime() + 30 * 60, // 30 minutes from now
type: SwapType.SWAP_ROUTER_02,
slippageTolerance: new Percent(
// max. slippage decimals is 2
Number(params.slippageTolerance.toFixed(2)) * 100,
10_000
),
};
return {
token0,
token1,
fee,
tickSpacing,
liquidity,
sqrtPriceX96: slot0[0],
tick: slot0[1],
} as unknown as {
token0: string;
token1: string;
fee: number;
tickSpacing: number;
sqrtPriceX96: ethers.BigNumber;
liquidity: ethers.BigNumber;
tick: number;
router,
options,
};
}

@@ -257,3 +162,17 @@ function getMainnetToken(token: AcrossToken) {
address: mainnetTokenAddress,
};
}

class NoUniswapRouteError extends Error {
constructor(args: {
tokenInSymbol: string;
tokenOutSymbol: string;
chainId: number;
swapType: string;
}) {
super(
`No Uniswap swap route found for '${args.swapType}' ${args.tokenInSymbol} to ${args.tokenOutSymbol} on chain ${args.chainId}`
);
this.name = "NoSwapRouteError";
}
}
20 changes: 11 additions & 9 deletions api/swap-quote.ts
Original file line number Diff line number Diff line change
@@ -13,8 +13,8 @@ import {
validateChainAndTokenParams,
isSwapRouteEnabled,
} from "./_utils";
import { getUniswapQuoteAndCalldata } from "./_dexes/uniswap";
import { get1inchQuoteAndCalldata } from "./_dexes/1inch";
import { getUniswapQuoteForOriginSwapExactInput } from "./_dexes/uniswap";
import { get1inchQuoteForOriginSwapExactInput } from "./_dexes/1inch";
import { InvalidParamError } from "./_errors";

const SwapQuoteQueryParamsSchema = type({
@@ -97,18 +97,20 @@ const handler = async (
}

const swap = {
swapToken,
acrossInputToken: {
chainId: originChainId,
tokenIn: swapToken,
tokenOut: {
...acrossInputToken,
chainId: originChainId,
},
swapTokenAmount,
slippage: parseFloat(swapSlippage),
};
amount: swapTokenAmount,
slippageTolerance: parseFloat(swapSlippage),
type: "EXACT_INPUT",
} as const;

const quoteResults = await Promise.allSettled([
getUniswapQuoteAndCalldata(swap),
get1inchQuoteAndCalldata(swap),
getUniswapQuoteForOriginSwapExactInput(swap),
get1inchQuoteForOriginSwapExactInput(swap),
]);

const settledResults = quoteResults.flatMap((result) =>
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -24,6 +24,7 @@
"@tanstack/react-query": "v4",
"@tanstack/react-query-devtools": "v4",
"@uniswap/sdk-core": "^4.2.0",
"@uniswap/smart-order-router": "^4.7.8",
"@uniswap/v3-sdk": "^3.11.0",
"@vercel/kv": "^2.0.0",
"@web3-onboard/coinbase": "^2.4.1",
324 changes: 309 additions & 15 deletions yarn.lock

Large diffs are not rendered by default.