From 57b5d6dfdc081989709de6f62eea4674a39dafac Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Thu, 14 Nov 2024 12:26:16 +0700 Subject: [PATCH 01/11] feat: build cross swap tx --- api/_dexes/cross-swap.ts | 99 ++++++++++++- api/_dexes/types.ts | 1 + api/_dexes/uniswap.ts | 310 ++++++++++++++++++++++----------------- api/_integrator-id.ts | 29 ++++ api/_utils.ts | 119 +++++++++------ api/swap.ts | 43 ++++-- scripts/tests/swap.ts | 51 +++++-- 7 files changed, 450 insertions(+), 202 deletions(-) create mode 100644 api/_integrator-id.ts diff --git a/api/_dexes/cross-swap.ts b/api/_dexes/cross-swap.ts index c435a20c3..462ccab5a 100644 --- a/api/_dexes/cross-swap.ts +++ b/api/_dexes/cross-swap.ts @@ -1,8 +1,12 @@ +import { SpokePool } from "@across-protocol/contracts/dist/typechain"; + import { isRouteEnabled, isInputTokenBridgeable, isOutputTokenBridgeable, getBridgeQuoteForMinOutput, + getSpokePool, + latestGasPriceCache, } from "../_utils"; import { getUniswapCrossSwapQuotesForMinOutputB2A, @@ -10,6 +14,10 @@ import { getBestUniswapCrossSwapQuotesForMinOutputA2A, } from "./uniswap"; import { CrossSwap, CrossSwapQuotes } from "./types"; +import { getSwapAndBridge } from "./utils"; +import { tagIntegratorId } from "../_integrator-id"; +import { PopulatedTransaction } from "ethers"; +import { getMultiCallHandlerAddress } from "../_multicall-handler"; export type CrossSwapType = (typeof CROSS_SWAP_TYPE)[keyof typeof CROSS_SWAP_TYPE]; @@ -139,4 +147,93 @@ export function getCrossSwapType(params: { return CROSS_SWAP_TYPE.ANY_TO_ANY; } -export function calcFees() {} +export async function buildCrossSwapTx( + crossSwapQuotes: CrossSwapQuotes, + integratorId?: string +) { + const originChainId = crossSwapQuotes.crossSwap.inputToken.chainId; + const destinationChainId = crossSwapQuotes.crossSwap.outputToken.chainId; + const spokePool = getSpokePool(originChainId); + const deposit = { + depositor: crossSwapQuotes.crossSwap.recipient, + recipient: crossSwapQuotes.destinationSwapQuote + ? getMultiCallHandlerAddress(destinationChainId) + : crossSwapQuotes.crossSwap.recipient, + inputToken: crossSwapQuotes.bridgeQuote.inputToken.address, + outputToken: crossSwapQuotes.bridgeQuote.outputToken.address, + inputAmount: crossSwapQuotes.bridgeQuote.inputAmount, + outputAmount: crossSwapQuotes.bridgeQuote.outputAmount, + destinationChainid: crossSwapQuotes.bridgeQuote.outputToken.chainId, + exclusiveRelayer: + crossSwapQuotes.bridgeQuote.suggestedFees.exclusiveRelayer, + quoteTimestamp: crossSwapQuotes.bridgeQuote.suggestedFees.timestamp, + fillDeadline: await getFillDeadline(spokePool), + exclusivityDeadline: + crossSwapQuotes.bridgeQuote.suggestedFees.exclusivityDeadline, + message: crossSwapQuotes.bridgeQuote?.message || "0x", + }; + + let tx: PopulatedTransaction; + let toAddress: string; + + if (crossSwapQuotes.originSwapQuote) { + const swapAndBridge = getSwapAndBridge("uniswap", originChainId); + tx = await swapAndBridge.populateTransaction.swapAndBridge( + crossSwapQuotes.originSwapQuote.tokenIn.address, + crossSwapQuotes.originSwapQuote.tokenOut.address, + crossSwapQuotes.originSwapQuote.swapTx.data, + crossSwapQuotes.originSwapQuote.maximumAmountIn, + crossSwapQuotes.originSwapQuote.minAmountOut, + deposit + ); + toAddress = swapAndBridge.address; + console.log(tx, toAddress); + } else { + const spokePool = getSpokePool( + crossSwapQuotes.crossSwap.inputToken.chainId + ); + tx = await spokePool.populateTransaction.depositV3( + deposit.depositor, + deposit.recipient, + deposit.inputToken, + deposit.outputToken, + deposit.inputAmount, + deposit.outputAmount, + deposit.destinationChainid, + deposit.exclusiveRelayer, + deposit.quoteTimestamp, + deposit.fillDeadline, + deposit.exclusivityDeadline, + deposit.message + ); + toAddress = spokePool.address; + } + + const [gas, gasPrice] = await Promise.all([ + spokePool.provider.estimateGas({ + from: crossSwapQuotes.crossSwap.depositor, + ...tx, + }), + latestGasPriceCache(originChainId).get(), + ]); + + return { + from: crossSwapQuotes.crossSwap.depositor, + to: toAddress, + data: integratorId ? tagIntegratorId(integratorId, tx.data!) : tx.data, + gas, + gasPrice, + value: tx.value, + }; +} + +async function getFillDeadline(spokePool: SpokePool): Promise { + const calls = [ + spokePool.interface.encodeFunctionData("getCurrentTime"), + spokePool.interface.encodeFunctionData("fillDeadlineBuffer"), + ]; + + const [currentTime, fillDeadlineBuffer] = + await spokePool.callStatic.multicall(calls); + return Number(currentTime) + Number(fillDeadlineBuffer); +} diff --git a/api/_dexes/types.ts b/api/_dexes/types.ts index da5e448c8..f6b85472b 100644 --- a/api/_dexes/types.ts +++ b/api/_dexes/types.ts @@ -25,6 +25,7 @@ export type CrossSwap = { amount: BigNumber; inputToken: Token; outputToken: Token; + depositor: string; recipient: string; slippageTolerance: number; type: AmountType; diff --git a/api/_dexes/uniswap.ts b/api/_dexes/uniswap.ts index cf2fc2c53..b9a82a148 100644 --- a/api/_dexes/uniswap.ts +++ b/api/_dexes/uniswap.ts @@ -27,7 +27,6 @@ import { Swap, CrossSwap, SwapQuote, - CrossSwapQuotes, } from "./types"; import { getSwapAndBridgeAddress, NoSwapRouteError } from "./utils"; import { AMOUNT_TYPE } from "./cross-swap"; @@ -70,10 +69,13 @@ export async function getUniswapQuoteForOriginSwapExactInput( swap.tokenIn = getMainnetToken(swap.tokenIn); swap.tokenOut = getMainnetToken(swap.tokenOut); - const { swapTx, minAmountOut } = await getUniswapQuote({ - ...swap, - recipient: swapAndBridgeAddress, - }); + const { swapTx, minAmountOut } = await getUniswapQuote( + { + ...swap, + recipient: swapAndBridgeAddress, + }, + TradeType.EXACT_INPUT + ); // replace mainnet token addresses with initial token addresses in calldata swapTx.data = swapTx.data.replace( @@ -136,15 +138,19 @@ export async function getUniswapCrossSwapQuotesForMinOutputB2A( }; // 1. Get destination swap quote for bridgeable output token -> any token - const destinationSwapQuote = await getUniswapQuote({ - chainId: destinationSwapChainId, - tokenIn: bridgeableOutputToken, - tokenOut: crossSwap.outputToken, - amount: crossSwap.amount.toString(), - recipient: crossSwap.recipient, - slippageTolerance: crossSwap.slippageTolerance, - type: AMOUNT_TYPE.MIN_OUTPUT, - }); + // with exact output amount. + const destinationSwapQuote = await getUniswapQuote( + { + chainId: destinationSwapChainId, + tokenIn: bridgeableOutputToken, + tokenOut: crossSwap.outputToken, + amount: crossSwap.amount.toString(), + recipient: crossSwap.recipient, + slippageTolerance: crossSwap.slippageTolerance, + type: AMOUNT_TYPE.MIN_OUTPUT, + }, + TradeType.EXACT_OUTPUT + ); // 2. Get bridge quote for bridgeable input token -> bridgeable output token const bridgeQuote = await getBridgeQuoteForMinOutput({ @@ -152,48 +158,17 @@ export async function getUniswapCrossSwapQuotesForMinOutputB2A( outputToken: bridgeableOutputToken, minOutputAmount: destinationSwapQuote.maximumAmountIn, recipient: getMultiCallHandlerAddress(destinationSwapChainId), - message: buildMulticallHandlerMessage({ - // @TODO: handle fallback recipient for params `refundOnOrigin` and `refundAddress` - fallbackRecipient: crossSwap.recipient, - actions: [ - { - target: destinationSwapQuote.swapTx.to, - callData: destinationSwapQuote.swapTx.data, - value: destinationSwapQuote.swapTx.value, - }, - ], + message: buildDestinationSwapCrossChainMessage({ + crossSwap, + destinationSwapQuote, + bridgeableOutputToken, }), }); - // 3. Re-fetch destination swap quote with updated input amount and EXACT_INPUT type. - // This prevents leftover tokens in the MulticallHandler contract. - const updatedDestinationSwapQuote = await getUniswapQuote({ - chainId: destinationSwapChainId, - tokenIn: bridgeableOutputToken, - tokenOut: crossSwap.outputToken, - amount: bridgeQuote.outputAmount.toString(), - recipient: crossSwap.recipient, - slippageTolerance: crossSwap.slippageTolerance, - type: AMOUNT_TYPE.EXACT_INPUT, - }); - - // 4. Rebuild message - bridgeQuote.message = buildMulticallHandlerMessage({ - // @TODO: handle fallback recipient for params `refundOnOrigin` and `refundAddress` - fallbackRecipient: crossSwap.recipient, - actions: [ - { - target: updatedDestinationSwapQuote.swapTx.to, - callData: updatedDestinationSwapQuote.swapTx.data, - value: updatedDestinationSwapQuote.swapTx.value, - }, - ], - }); - return { crossSwap, bridgeQuote, - destinationSwapQuote: updatedDestinationSwapQuote, + destinationSwapQuote, originSwapQuote: undefined, }; } @@ -246,22 +221,46 @@ export async function getUniswapCrossSwapQuotesForMinOutputA2B( // @TODO: handle ETH/WETH message generation }); - // 2. Get origin swap quote for any input token -> bridgeable input token - const originSwapQuote = await getUniswapQuote({ - chainId: originSwapChainId, - tokenIn: crossSwap.inputToken, - tokenOut: bridgeableInputToken, - amount: bridgeQuote.inputAmount.toString(), - recipient: getSwapAndBridgeAddress("uniswap", originSwapChainId), - slippageTolerance: crossSwap.slippageTolerance, - type: AMOUNT_TYPE.MIN_OUTPUT, - }); + // 2.1. Get origin swap quote for any input token -> bridgeable input token + const originSwapQuote = await getUniswapQuote( + { + chainId: originSwapChainId, + tokenIn: crossSwap.inputToken, + tokenOut: bridgeableInputToken, + amount: bridgeQuote.inputAmount.toString(), + recipient: getSwapAndBridgeAddress("uniswap", originSwapChainId), + slippageTolerance: crossSwap.slippageTolerance, + type: AMOUNT_TYPE.MIN_OUTPUT, + }, + TradeType.EXACT_OUTPUT + ); + // 2.2. Re-fetch origin swap quote with updated input amount and EXACT_INPUT type. + // This prevents leftover tokens in the SwapAndBridge contract. + const adjOriginSwapQuote = await getUniswapQuote( + { + chainId: originSwapChainId, + tokenIn: crossSwap.inputToken, + tokenOut: bridgeableInputToken, + amount: originSwapQuote.maximumAmountIn.toString(), + recipient: getSwapAndBridgeAddress("uniswap", originSwapChainId), + slippageTolerance: crossSwap.slippageTolerance, + type: AMOUNT_TYPE.EXACT_INPUT, + }, + TradeType.EXACT_INPUT + ); + + if (adjOriginSwapQuote.minAmountOut.lt(crossSwap.amount)) { + throw new Error( + `Origin swap quote min. output amount ${adjOriginSwapQuote.minAmountOut.toString()} ` + + `is less than targeted min. output amount ${crossSwap.amount.toString()}` + ); + } return { crossSwap, bridgeQuote, destinationSwapQuote: undefined, - originSwapQuote, + originSwapQuote: adjOriginSwapQuote, }; } @@ -293,8 +292,8 @@ export async function getBestUniswapCrossSwapQuotesForMinOutputA2A( ); } - const preferredBridgeRoutes = allBridgeRoutes.filter(({ fromTokenSymbol }) => - opts.preferredBridgeTokens.includes(fromTokenSymbol) + const preferredBridgeRoutes = allBridgeRoutes.filter(({ toTokenSymbol }) => + opts.preferredBridgeTokens.includes(toTokenSymbol) ); const bridgeRoutesToCompare = ( preferredBridgeRoutes.length > 0 ? preferredBridgeRoutes : allBridgeRoutes @@ -306,21 +305,11 @@ export async function getBestUniswapCrossSwapQuotesForMinOutputA2A( ); } - const crossSwapQuotesSettledResults = await Promise.allSettled( + const crossSwapQuotes = await Promise.all( bridgeRoutesToCompare.map((bridgeRoute) => getUniswapCrossSwapQuotesForMinOutputA2A(crossSwap, bridgeRoute) ) ); - const crossSwapQuotes = crossSwapQuotesSettledResults - .filter((res) => res.status === "fulfilled") - .map((res) => (res as PromiseFulfilledResult).value); - - if (crossSwapQuotes.length === 0) { - console.log("crossSwapQuotesSettledResults", crossSwapQuotesSettledResults); - throw new Error( - `No successful bridge quotes found for ${originSwapChainId} -> ${destinationSwapChainId}` - ); - } // Compare quotes by lowest input amount const bestCrossSwapQuote = crossSwapQuotes.reduce((prev, curr) => @@ -386,15 +375,19 @@ export async function getUniswapCrossSwapQuotesForMinOutputA2A( }; // 1. Get destination swap quote for bridgeable output token -> any token - const destinationSwapQuote = await getUniswapQuote({ - chainId: destinationSwapChainId, - tokenIn: bridgeableOutputToken, - tokenOut: crossSwap.outputToken, - amount: crossSwap.amount.toString(), - recipient: crossSwap.recipient, - slippageTolerance: crossSwap.slippageTolerance, - type: AMOUNT_TYPE.MIN_OUTPUT, - }); + // with exact output amount + const destinationSwapQuote = await getUniswapQuote( + { + chainId: destinationSwapChainId, + tokenIn: bridgeableOutputToken, + tokenOut: crossSwap.outputToken, + amount: crossSwap.amount.toString(), + recipient: crossSwap.recipient, + slippageTolerance: crossSwap.slippageTolerance, + type: AMOUNT_TYPE.MIN_OUTPUT, + }, + TradeType.EXACT_OUTPUT + ); // 2. Get bridge quote for bridgeable input token -> bridgeable output token const bridgeQuote = await getBridgeQuoteForMinOutput({ @@ -402,70 +395,59 @@ export async function getUniswapCrossSwapQuotesForMinOutputA2A( outputToken: bridgeableOutputToken, minOutputAmount: destinationSwapQuote.maximumAmountIn, recipient: getMultiCallHandlerAddress(destinationSwapChainId), - message: buildMulticallHandlerMessage({ - // @TODO: handle fallback recipient for params `refundOnOrigin` and `refundAddress` - fallbackRecipient: crossSwap.recipient, - actions: [ - { - target: destinationSwapQuote.swapTx.to, - callData: destinationSwapQuote.swapTx.data, - value: destinationSwapQuote.swapTx.value, - }, - ], + message: buildDestinationSwapCrossChainMessage({ + crossSwap, + destinationSwapQuote, + bridgeableOutputToken, }), }); - // 3. Re-fetch destination swap quote with updated input amount and EXACT_INPUT type. - // This prevents leftover tokens in the MulticallHandler contract. - const updatedDestinationSwapQuote = await getUniswapQuote({ - chainId: destinationSwapChainId, - tokenIn: bridgeableOutputToken, - tokenOut: crossSwap.outputToken, - amount: bridgeQuote.outputAmount.toString(), - recipient: crossSwap.recipient, - slippageTolerance: crossSwap.slippageTolerance, - type: AMOUNT_TYPE.EXACT_INPUT, - }); - - // 4. Rebuild message - bridgeQuote.message = buildMulticallHandlerMessage({ - // @TODO: handle fallback recipient for params `refundOnOrigin` and `refundAddress` - fallbackRecipient: crossSwap.recipient, - actions: [ - { - target: updatedDestinationSwapQuote.swapTx.to, - callData: updatedDestinationSwapQuote.swapTx.data, - value: updatedDestinationSwapQuote.swapTx.value, - }, - ], - }); - - // 3. Get origin swap quote for any input token -> bridgeable input token - const originSwapQuote = await getUniswapQuote({ - chainId: originSwapChainId, - tokenIn: crossSwap.inputToken, - tokenOut: bridgeableInputToken, - amount: bridgeQuote.inputAmount.toString(), - recipient: getSwapAndBridgeAddress("uniswap", originSwapChainId), - slippageTolerance: crossSwap.slippageTolerance, - type: AMOUNT_TYPE.MIN_OUTPUT, - }); + // 3.1. Get origin swap quote for any input token -> bridgeable input token + const originSwapQuote = await getUniswapQuote( + { + chainId: originSwapChainId, + tokenIn: crossSwap.inputToken, + tokenOut: bridgeableInputToken, + amount: bridgeQuote.inputAmount.toString(), + recipient: getSwapAndBridgeAddress("uniswap", originSwapChainId), + slippageTolerance: crossSwap.slippageTolerance, + type: AMOUNT_TYPE.MIN_OUTPUT, + }, + TradeType.EXACT_OUTPUT + ); + // 3.2. Re-fetch origin swap quote with updated input amount and EXACT_INPUT type. + // This prevents leftover tokens in the SwapAndBridge contract. + const adjOriginSwapQuote = await getUniswapQuote( + { + chainId: originSwapChainId, + tokenIn: crossSwap.inputToken, + tokenOut: bridgeableInputToken, + amount: originSwapQuote.maximumAmountIn.toString(), + recipient: getSwapAndBridgeAddress("uniswap", originSwapChainId), + slippageTolerance: crossSwap.slippageTolerance, + type: AMOUNT_TYPE.EXACT_INPUT, + }, + TradeType.EXACT_INPUT + ); return { crossSwap, - destinationSwapQuote: updatedDestinationSwapQuote, + destinationSwapQuote, bridgeQuote, - originSwapQuote, + originSwapQuote: adjOriginSwapQuote, }; } -export async function getUniswapQuote(swap: Swap): Promise { +export async function getUniswapQuote( + swap: Swap, + tradeType: TradeType +): Promise { const { router, options } = getSwapRouterAndOptions(swap); const amountCurrency = - swap.type === AMOUNT_TYPE.EXACT_INPUT ? swap.tokenIn : swap.tokenOut; + tradeType === TradeType.EXACT_INPUT ? swap.tokenIn : swap.tokenOut; const quoteCurrency = - swap.type === AMOUNT_TYPE.EXACT_INPUT ? swap.tokenOut : swap.tokenIn; + tradeType === TradeType.EXACT_INPUT ? swap.tokenOut : swap.tokenIn; const route = await router.route( CurrencyAmount.fromRawAmount( @@ -481,9 +463,7 @@ export async function getUniswapQuote(swap: Swap): Promise { quoteCurrency.address, quoteCurrency.decimals ), - swap.type === AMOUNT_TYPE.EXACT_INPUT - ? TradeType.EXACT_INPUT - : TradeType.EXACT_OUTPUT, + tradeType, options ); @@ -587,3 +567,61 @@ function getMainnetToken(token: AcrossToken) { address: mainnetTokenAddress, }; } + +function buildDestinationSwapCrossChainMessage({ + crossSwap, + destinationSwapQuote, + bridgeableOutputToken, +}: { + crossSwap: CrossSwap; + bridgeableOutputToken: AcrossToken; + destinationSwapQuote: SwapQuote; +}) { + const destinationSwapChainId = destinationSwapQuote.tokenOut.chainId; + return buildMulticallHandlerMessage({ + // @TODO: handle fallback recipient for params `refundOnOrigin` and `refundAddress` + fallbackRecipient: crossSwap.recipient, + actions: [ + // approve bridgeable input token + { + target: bridgeableOutputToken.address, + callData: encodeApproveCalldata( + SWAP_ROUTER_02_ADDRESS[destinationSwapChainId], + destinationSwapQuote.maximumAmountIn + ), + value: "0", + }, + // swap + { + target: destinationSwapQuote.swapTx.to, + callData: destinationSwapQuote.swapTx.data, + value: destinationSwapQuote.swapTx.value, + }, + // drain + { + target: getMultiCallHandlerAddress(destinationSwapChainId), + callData: encodeDrainCalldata( + bridgeableOutputToken.address, + crossSwap.recipient + ), + value: "0", + }, + ], + }); +} + +function encodeApproveCalldata(spender: string, value: ethers.BigNumber) { + const approveFunction = "function approve(address spender, uint256 value)"; + const erc20Interface = new ethers.utils.Interface([approveFunction]); + return erc20Interface.encodeFunctionData("approve", [spender, value]); +} + +function encodeDrainCalldata(token: string, destination: string) { + const drainFunction = + "function drainLeftoverTokens(address token, address payable destination)"; + const multicallHandlerInterface = new ethers.utils.Interface([drainFunction]); + return multicallHandlerInterface.encodeFunctionData("drainLeftoverTokens", [ + token, + destination, + ]); +} diff --git a/api/_integrator-id.ts b/api/_integrator-id.ts new file mode 100644 index 000000000..982971eeb --- /dev/null +++ b/api/_integrator-id.ts @@ -0,0 +1,29 @@ +import { utils } from "ethers"; + +export const DOMAIN_CALLDATA_DELIMITER = "0x1dc0de"; + +export function isValidIntegratorId(integratorId: string) { + return ( + utils.isHexString(integratorId) && + // "0x" + 2 bytes = 6 hex characters + integratorId.length === 6 + ); +} + +export function assertValidIntegratorId(integratorId: string) { + if (!isValidIntegratorId(integratorId)) { + throw new Error( + `Invalid integrator ID: ${integratorId}. Needs to be 2 bytes hex string.` + ); + } + + return true; +} + +export function tagIntegratorId(integratorId: string, txData: string) { + assertValidIntegratorId(integratorId); + + return utils.hexlify( + utils.concat([txData, DOMAIN_CALLDATA_DELIMITER, integratorId]) + ); +} diff --git a/api/_utils.ts b/api/_utils.ts index 0b50770f8..e1654fe7b 100644 --- a/api/_utils.ts +++ b/api/_utils.ts @@ -14,7 +14,7 @@ import { BalancerNetworkConfig, Multicall3, } from "@balancer-labs/sdk"; -import axios from "axios"; +import axios, { AxiosError } from "axios"; import { BigNumber, BigNumberish, @@ -64,6 +64,9 @@ import { MissingParamError, InvalidParamError, RouteNotEnabledError, + AcrossApiError, + HttpErrorToStatusCode, + AcrossErrorCode, } from "./_errors"; import { Token } from "./_dexes/types"; @@ -183,6 +186,7 @@ export const getLogger = (): LoggingUtility => { * @returns A valid URL of the current endpoint in vercel */ export const resolveVercelEndpoint = () => { + return "https://app.across.to"; const url = process.env.VERCEL_URL ?? "across.to"; const env = process.env.VERCEL_ENV ?? "development"; switch (env) { @@ -874,55 +878,84 @@ export async function getBridgeQuoteForMinOutput(params: { message: params.message, }; - // 1. Use the suggested fees to get an indicative quote with - // input amount equal to minOutputAmount - let tries = 0; - let adjustedInputAmount = params.minOutputAmount; - let indicativeQuote = await getSuggestedFees({ - ...baseParams, - amount: adjustedInputAmount.toString(), - }); - let adjustmentPct = indicativeQuote.totalRelayFee.pct; - let finalQuote: Awaited> | undefined = - undefined; - - // 2. Adjust input amount to meet minOutputAmount - while (tries < 3) { - adjustedInputAmount = adjustedInputAmount - .mul(utils.parseEther("1").add(adjustmentPct)) - .div(sdk.utils.fixedPointAdjustment); - const adjustedQuote = await getSuggestedFees({ + try { + // 1. Use the suggested fees to get an indicative quote with + // input amount equal to minOutputAmount + let tries = 0; + let adjustedInputAmount = params.minOutputAmount; + let indicativeQuote = await getSuggestedFees({ ...baseParams, amount: adjustedInputAmount.toString(), }); - const outputAmount = adjustedInputAmount.sub( - adjustedInputAmount - .mul(adjustedQuote.totalRelayFee.pct) - .div(sdk.utils.fixedPointAdjustment) - ); + let adjustmentPct = indicativeQuote.totalRelayFee.pct; + let finalQuote: Awaited> | undefined = + undefined; + + // 2. Adjust input amount to meet minOutputAmount + while (tries < 3) { + adjustedInputAmount = adjustedInputAmount + .mul(utils.parseEther("1").add(adjustmentPct)) + .div(sdk.utils.fixedPointAdjustment); + const adjustedQuote = await getSuggestedFees({ + ...baseParams, + amount: adjustedInputAmount.toString(), + }); + const outputAmount = adjustedInputAmount.sub( + adjustedInputAmount + .mul(adjustedQuote.totalRelayFee.pct) + .div(sdk.utils.fixedPointAdjustment) + ); - if (outputAmount.gte(params.minOutputAmount)) { - finalQuote = adjustedQuote; - break; - } else { - adjustmentPct = adjustedQuote.totalRelayFee.pct; - tries++; + if (outputAmount.gte(params.minOutputAmount)) { + finalQuote = adjustedQuote; + break; + } else { + adjustmentPct = adjustedQuote.totalRelayFee.pct; + tries++; + } } - } - if (!finalQuote) { - throw new Error("Failed to adjust input amount to meet minOutputAmount"); - } + if (!finalQuote) { + throw new Error("Failed to adjust input amount to meet minOutputAmount"); + } - return { - inputAmount: adjustedInputAmount, - outputAmount: adjustedInputAmount.sub(finalQuote.totalRelayFee.total), - minOutputAmount: params.minOutputAmount, - suggestedFees: finalQuote, - message: params.message, - inputToken: params.inputToken, - outputToken: params.outputToken, - }; + return { + inputAmount: adjustedInputAmount, + outputAmount: adjustedInputAmount.sub(finalQuote.totalRelayFee.total), + minOutputAmount: params.minOutputAmount, + suggestedFees: finalQuote, + message: params.message, + inputToken: params.inputToken, + outputToken: params.outputToken, + }; + } catch (err) { + if (err instanceof AxiosError) { + const { response = { data: {} } } = err; + // If upstream error is an AcrossApiError, we just return it + if (response?.data?.type === "AcrossApiError") { + throw new AcrossApiError( + { + message: response.data.message, + status: response.data.status, + code: response.data.code, + param: response.data.param, + }, + { cause: err } + ); + } else { + const message = `Upstream http request to ${err.request?.url} failed with ${err.status} ${err.message}`; + throw new AcrossApiError( + { + message, + status: HttpErrorToStatusCode.BAD_GATEWAY, + code: AcrossErrorCode.UPSTREAM_HTTP_ERROR, + }, + { cause: err } + ); + } + } + throw err; + } } export const providerCache: Record = {}; diff --git a/api/swap.ts b/api/swap.ts index 2dc0cc8a1..f28fc3ec9 100644 --- a/api/swap.ts +++ b/api/swap.ts @@ -12,9 +12,14 @@ import { boolStr, getTokenByAddress, } from "./_utils"; -import { AMOUNT_TYPE, getCrossSwapQuotes } from "./_dexes/cross-swap"; +import { + AMOUNT_TYPE, + buildCrossSwapTx, + getCrossSwapQuotes, +} from "./_dexes/cross-swap"; import { Token } from "./_dexes/types"; import { InvalidParamError, MissingParamError } from "./_errors"; +import { isValidIntegratorId } from "./_integrator-id"; const SwapQueryParamsSchema = type({ minOutputAmount: optional(positiveIntStr()), @@ -23,8 +28,9 @@ const SwapQueryParamsSchema = type({ outputToken: validAddress(), originChainId: positiveIntStr(), destinationChainId: positiveIntStr(), - recipient: validAddress(), - integratorId: string(), + depositor: validAddress(), + recipient: optional(validAddress()), + integratorId: optional(string()), refundAddress: optional(validAddress()), refundOnOrigin: optional(boolStr()), slippageTolerance: optional(positiveFloatStr(50)), // max. 50% slippage @@ -53,10 +59,11 @@ const handler = async ( originChainId: _originChainId, destinationChainId: _destinationChainId, recipient, + depositor, integratorId, refundAddress, refundOnOrigin: _refundOnOrigin = "true", - slippageTolerance = "0.5", // Default to 0.5% slippage + slippageTolerance = "1", // Default to 1% slippage } = query; const originChainId = Number(_originChainId); @@ -78,6 +85,13 @@ const handler = async ( }); } + if (integratorId && !isValidIntegratorId(integratorId)) { + throw new InvalidParamError({ + param: "integratorId", + message: "Invalid integrator ID. Needs to be 2 bytes hex string.", + }); + } + const amountType = _minOutputAmount ? AMOUNT_TYPE.MIN_OUTPUT : AMOUNT_TYPE.EXACT_INPUT; @@ -132,22 +146,33 @@ const handler = async ( amount, inputToken, outputToken, - recipient, + depositor, + recipient: recipient || depositor, slippageTolerance: Number(slippageTolerance), type: amountType, refundOnOrigin, refundAddress, }); - // 3. Build tx and return - // @TODO + // 3. Build cross swap tx + const crossSwapTx = await buildCrossSwapTx(crossSwapQuotes, integratorId); + + const responseJson = { + tx: { + to: crossSwapTx.to, + data: crossSwapTx.data, + value: crossSwapTx.value?.toString(), + gas: crossSwapTx.gas?.toString(), + gasPrice: crossSwapTx.gasPrice?.toString(), + }, + }; logger.debug({ at: "Swap", message: "Response data", - responseJson: crossSwapQuotes, + responseJson, }); - response.status(200).json(crossSwapQuotes); + response.status(200).json(responseJson); } catch (error: unknown) { return handleErrorCondition("swap", response, logger, error); } diff --git a/scripts/tests/swap.ts b/scripts/tests/swap.ts index c2d185abb..e3f40fd34 100644 --- a/scripts/tests/swap.ts +++ b/scripts/tests/swap.ts @@ -1,6 +1,9 @@ import axios from "axios"; import { CHAIN_IDs, TOKEN_SYMBOLS_MAP } from "@across-protocol/constants"; -import { ethers } from "ethers"; +import { ethers, Wallet } from "ethers"; +import dotenv from "dotenv"; +import { getProvider } from "../../api/_utils"; +dotenv.config(); /** * Manual test script for the swap API. Should be converted to a proper test suite. @@ -10,52 +13,74 @@ const depositor = "0x9A8f92a830A5cB89a3816e3D267CB7791c16b04D"; const MIN_OUTPUT_CASES = [ // B2B { - minOutputAmount: ethers.utils.parseUnits("100", 6).toString(), + label: "B2B", + minOutputAmount: ethers.utils.parseUnits("1", 6).toString(), inputToken: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.BASE], originChainId: CHAIN_IDs.BASE, outputToken: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.ARBITRUM], destinationChainId: CHAIN_IDs.ARBITRUM, - recipient: depositor, - integratorId: "test", + depositor, }, // B2A { - minOutputAmount: ethers.utils.parseUnits("0.001", 18).toString(), + label: "B2A", + minOutputAmount: ethers.utils.parseUnits("1", 18).toString(), inputToken: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.BASE], originChainId: CHAIN_IDs.BASE, - outputToken: TOKEN_SYMBOLS_MAP.WETH.addresses[CHAIN_IDs.ARBITRUM], + outputToken: "0x74885b4D524d497261259B38900f54e6dbAd2210", // APE Coin destinationChainId: CHAIN_IDs.ARBITRUM, - recipient: depositor, - integratorId: "test", + depositor, }, // A2B { - minOutputAmount: ethers.utils.parseUnits("10", 6).toString(), + label: "A2B", + minOutputAmount: ethers.utils.parseUnits("1", 6).toString(), inputToken: TOKEN_SYMBOLS_MAP.USDbC.addresses[CHAIN_IDs.BASE], originChainId: CHAIN_IDs.BASE, outputToken: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.ARBITRUM], destinationChainId: CHAIN_IDs.ARBITRUM, - recipient: depositor, - integratorId: "test", + depositor, }, // A2A { + label: "A2A", minOutputAmount: ethers.utils.parseUnits("1", 18).toString(), inputToken: TOKEN_SYMBOLS_MAP.USDbC.addresses[CHAIN_IDs.BASE], originChainId: CHAIN_IDs.BASE, outputToken: "0x74885b4D524d497261259B38900f54e6dbAd2210", // APE Coin destinationChainId: CHAIN_IDs.ARBITRUM, - recipient: depositor, - integratorId: "test", + depositor, + slippageTolerance: 1, }, ]; async function swap() { for (const testCase of MIN_OUTPUT_CASES) { + console.log("\nTest case:", testCase.label); const response = await axios.get(`http://localhost:3000/api/swap`, { params: testCase, }); console.log(response.data); + + if (process.env.DEV_WALLET_PK) { + const wallet = new Wallet(process.env.DEV_WALLET_PK!).connect( + getProvider(testCase.originChainId) + ); + try { + const tx = await wallet.sendTransaction({ + to: response.data.tx.to, + data: response.data.tx.data, + value: response.data.tx.value, + gasLimit: response.data.tx.gas, + gasPrice: response.data.tx.gasPrice, + }); + console.log("Tx hash: ", tx.hash); + await tx.wait(); + console.log("Tx mined"); + } catch (e) { + console.error("Tx reverted", e); + } + } } } From 3f23d874d91f9f2e24968fbfc9d8a7904e501c70 Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Thu, 14 Nov 2024 13:58:24 +0700 Subject: [PATCH 02/11] fixup --- api/_dexes/cross-swap.ts | 1 - api/_dexes/uniswap.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/api/_dexes/cross-swap.ts b/api/_dexes/cross-swap.ts index 462ccab5a..b7ca236c1 100644 --- a/api/_dexes/cross-swap.ts +++ b/api/_dexes/cross-swap.ts @@ -187,7 +187,6 @@ export async function buildCrossSwapTx( deposit ); toAddress = swapAndBridge.address; - console.log(tx, toAddress); } else { const spokePool = getSpokePool( crossSwapQuotes.crossSwap.inputToken.chainId diff --git a/api/_dexes/uniswap.ts b/api/_dexes/uniswap.ts index b9a82a148..531000575 100644 --- a/api/_dexes/uniswap.ts +++ b/api/_dexes/uniswap.ts @@ -249,10 +249,10 @@ export async function getUniswapCrossSwapQuotesForMinOutputA2B( TradeType.EXACT_INPUT ); - if (adjOriginSwapQuote.minAmountOut.lt(crossSwap.amount)) { + if (adjOriginSwapQuote.minAmountOut.lt(bridgeQuote.inputAmount)) { throw new Error( `Origin swap quote min. output amount ${adjOriginSwapQuote.minAmountOut.toString()} ` + - `is less than targeted min. output amount ${crossSwap.amount.toString()}` + `is less than required bridge input amount ${bridgeQuote.inputAmount.toString()}` ); } From 4ed90e337a1ed12fc4edbd7dcdffeb3b7884eaec Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Thu, 14 Nov 2024 14:22:42 +0700 Subject: [PATCH 03/11] fixup --- api/_dexes/uniswap.ts | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/api/_dexes/uniswap.ts b/api/_dexes/uniswap.ts index 531000575..1eca7ae3f 100644 --- a/api/_dexes/uniswap.ts +++ b/api/_dexes/uniswap.ts @@ -44,9 +44,9 @@ export const SWAP_ROUTER_02_ADDRESS = { [CHAIN_IDs.ZORA]: "0x7De04c96BE5159c3b5CeffC82aa176dc81281557", }; -// Maps testnet chain IDs to their main counterparts. Used to get the mainnet token +// Maps testnet chain IDs to their prod counterparts. Used to get the prod token // info for testnet tokens. -const TESTNET_TO_MAINNET = { +const TESTNET_TO_PROD = { [CHAIN_IDs.SEPOLIA]: CHAIN_IDs.MAINNET, [CHAIN_IDs.BASE_SEPOLIA]: CHAIN_IDs.BASE, [CHAIN_IDs.OPTIMISM_SEPOLIA]: CHAIN_IDs.OPTIMISM, @@ -66,8 +66,8 @@ export async function getUniswapQuoteForOriginSwapExactInput( const initialTokenOut = { ...swap.tokenOut }; // Always use mainnet tokens for retrieving quote, so that we can get equivalent quotes // for testnet tokens. - swap.tokenIn = getMainnetToken(swap.tokenIn); - swap.tokenOut = getMainnetToken(swap.tokenOut); + swap.tokenIn = getProdToken(swap.tokenIn); + swap.tokenOut = getProdToken(swap.tokenOut); const { swapTx, minAmountOut } = await getUniswapQuote( { @@ -548,23 +548,23 @@ function floatToPercent(value: number) { ); } -function getMainnetToken(token: AcrossToken) { - const mainnetChainId = TESTNET_TO_MAINNET[token.chainId] || token.chainId; +function getProdToken(token: AcrossToken) { + const prodChainId = TESTNET_TO_PROD[token.chainId] || token.chainId; - const mainnetToken = + const prodToken = TOKEN_SYMBOLS_MAP[token.symbol as keyof typeof TOKEN_SYMBOLS_MAP]; - const mainnetTokenAddress = mainnetToken?.addresses[mainnetChainId]; + const prodTokenAddress = prodToken?.addresses[prodChainId]; - if (!mainnetToken || !mainnetTokenAddress) { + if (!prodToken || !prodTokenAddress) { throw new Error( - `Mainnet token not found for ${token.symbol} on chain ${token.chainId}` + `Prod token not found for ${token.symbol} on chain ${token.chainId}` ); } return { - ...mainnetToken, - chainId: mainnetChainId, - address: mainnetTokenAddress, + ...prodToken, + chainId: prodChainId, + address: prodTokenAddress, }; } @@ -582,7 +582,7 @@ function buildDestinationSwapCrossChainMessage({ // @TODO: handle fallback recipient for params `refundOnOrigin` and `refundAddress` fallbackRecipient: crossSwap.recipient, actions: [ - // approve bridgeable input token + // approve bridgeable output token { target: bridgeableOutputToken.address, callData: encodeApproveCalldata( @@ -591,17 +591,18 @@ function buildDestinationSwapCrossChainMessage({ ), value: "0", }, - // swap + // swap bridgeable output token -> cross swap output token { target: destinationSwapQuote.swapTx.to, callData: destinationSwapQuote.swapTx.data, value: destinationSwapQuote.swapTx.value, }, - // drain + // drain remaining bridgeable output tokens from MulticallHandler contract { target: getMultiCallHandlerAddress(destinationSwapChainId), callData: encodeDrainCalldata( bridgeableOutputToken.address, + // @TODO: determine whether to use 'depositor' or 'recipient' crossSwap.recipient ), value: "0", From cb41cab1adcb980f5da8c969ab500f6549d73d0f Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Thu, 14 Nov 2024 14:37:37 +0700 Subject: [PATCH 04/11] refactor --- api/_dexes/uniswap.ts | 49 +++++++++++++++++++------------------------ 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/api/_dexes/uniswap.ts b/api/_dexes/uniswap.ts index 1eca7ae3f..a078ab4ea 100644 --- a/api/_dexes/uniswap.ts +++ b/api/_dexes/uniswap.ts @@ -147,7 +147,6 @@ export async function getUniswapCrossSwapQuotesForMinOutputB2A( amount: crossSwap.amount.toString(), recipient: crossSwap.recipient, slippageTolerance: crossSwap.slippageTolerance, - type: AMOUNT_TYPE.MIN_OUTPUT, }, TradeType.EXACT_OUTPUT ); @@ -221,16 +220,18 @@ export async function getUniswapCrossSwapQuotesForMinOutputA2B( // @TODO: handle ETH/WETH message generation }); + const originSwap = { + chainId: originSwapChainId, + tokenIn: crossSwap.inputToken, + tokenOut: bridgeableInputToken, + recipient: getSwapAndBridgeAddress("uniswap", originSwapChainId), + slippageTolerance: crossSwap.slippageTolerance, + }; // 2.1. Get origin swap quote for any input token -> bridgeable input token const originSwapQuote = await getUniswapQuote( { - chainId: originSwapChainId, - tokenIn: crossSwap.inputToken, - tokenOut: bridgeableInputToken, + ...originSwap, amount: bridgeQuote.inputAmount.toString(), - recipient: getSwapAndBridgeAddress("uniswap", originSwapChainId), - slippageTolerance: crossSwap.slippageTolerance, - type: AMOUNT_TYPE.MIN_OUTPUT, }, TradeType.EXACT_OUTPUT ); @@ -238,13 +239,8 @@ export async function getUniswapCrossSwapQuotesForMinOutputA2B( // This prevents leftover tokens in the SwapAndBridge contract. const adjOriginSwapQuote = await getUniswapQuote( { - chainId: originSwapChainId, - tokenIn: crossSwap.inputToken, - tokenOut: bridgeableInputToken, + ...originSwap, amount: originSwapQuote.maximumAmountIn.toString(), - recipient: getSwapAndBridgeAddress("uniswap", originSwapChainId), - slippageTolerance: crossSwap.slippageTolerance, - type: AMOUNT_TYPE.EXACT_INPUT, }, TradeType.EXACT_INPUT ); @@ -384,7 +380,6 @@ export async function getUniswapCrossSwapQuotesForMinOutputA2A( amount: crossSwap.amount.toString(), recipient: crossSwap.recipient, slippageTolerance: crossSwap.slippageTolerance, - type: AMOUNT_TYPE.MIN_OUTPUT, }, TradeType.EXACT_OUTPUT ); @@ -402,16 +397,18 @@ export async function getUniswapCrossSwapQuotesForMinOutputA2A( }), }); + const originSwap = { + chainId: originSwapChainId, + tokenIn: crossSwap.inputToken, + tokenOut: bridgeableInputToken, + recipient: getSwapAndBridgeAddress("uniswap", originSwapChainId), + slippageTolerance: crossSwap.slippageTolerance, + }; // 3.1. Get origin swap quote for any input token -> bridgeable input token const originSwapQuote = await getUniswapQuote( { - chainId: originSwapChainId, - tokenIn: crossSwap.inputToken, - tokenOut: bridgeableInputToken, + ...originSwap, amount: bridgeQuote.inputAmount.toString(), - recipient: getSwapAndBridgeAddress("uniswap", originSwapChainId), - slippageTolerance: crossSwap.slippageTolerance, - type: AMOUNT_TYPE.MIN_OUTPUT, }, TradeType.EXACT_OUTPUT ); @@ -419,13 +416,8 @@ export async function getUniswapCrossSwapQuotesForMinOutputA2A( // This prevents leftover tokens in the SwapAndBridge contract. const adjOriginSwapQuote = await getUniswapQuote( { - chainId: originSwapChainId, - tokenIn: crossSwap.inputToken, - tokenOut: bridgeableInputToken, + ...originSwap, amount: originSwapQuote.maximumAmountIn.toString(), - recipient: getSwapAndBridgeAddress("uniswap", originSwapChainId), - slippageTolerance: crossSwap.slippageTolerance, - type: AMOUNT_TYPE.EXACT_INPUT, }, TradeType.EXACT_INPUT ); @@ -439,7 +431,7 @@ export async function getUniswapCrossSwapQuotesForMinOutputA2A( } export async function getUniswapQuote( - swap: Swap, + swap: Omit, tradeType: TradeType ): Promise { const { router, options } = getSwapRouterAndOptions(swap); @@ -473,7 +465,8 @@ export async function getUniswapQuote( tokenInSymbol: swap.tokenIn.symbol, tokenOutSymbol: swap.tokenOut.symbol, chainId: swap.chainId, - swapType: swap.type, + swapType: + tradeType === TradeType.EXACT_INPUT ? "EXACT_INPUT" : "EXACT_OUTPUT", }); } From fb882e5c9bc30f53ea32ec19d6bca688f7804090 Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Thu, 14 Nov 2024 14:46:56 +0700 Subject: [PATCH 05/11] fix --- api/_dexes/uniswap.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/_dexes/uniswap.ts b/api/_dexes/uniswap.ts index a078ab4ea..d018277e7 100644 --- a/api/_dexes/uniswap.ts +++ b/api/_dexes/uniswap.ts @@ -498,7 +498,7 @@ export async function getUniswapQuote( }; console.log("swapQuote", { - type: swap.type, + type: tradeType === TradeType.EXACT_INPUT ? "EXACT_INPUT" : "EXACT_OUTPUT", tokenIn: swapQuote.tokenIn.symbol, tokenOut: swapQuote.tokenOut.symbol, chainId: swap.chainId, From 188ccfd156973c01cb6d86e118071d95081e8447 Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Fri, 15 Nov 2024 17:43:58 +0700 Subject: [PATCH 06/11] fixup --- api/_dexes/uniswap.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/api/_dexes/uniswap.ts b/api/_dexes/uniswap.ts index d018277e7..f5a0765e8 100644 --- a/api/_dexes/uniswap.ts +++ b/api/_dexes/uniswap.ts @@ -29,7 +29,6 @@ import { SwapQuote, } from "./types"; import { getSwapAndBridgeAddress, NoSwapRouteError } from "./utils"; -import { AMOUNT_TYPE } from "./cross-swap"; // Taken from here: https://docs.uniswap.org/contracts/v3/reference/deployments/ export const SWAP_ROUTER_02_ADDRESS = { From 589dc9837b9fa487446b2ded482e1077954bdfd7 Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Sat, 16 Nov 2024 14:55:01 +0700 Subject: [PATCH 07/11] fixup --- api/_dexes/uniswap.ts | 2 +- api/_utils.ts | 12 +++++----- api/swap.ts | 53 ++++++++++--------------------------------- 3 files changed, 19 insertions(+), 48 deletions(-) diff --git a/api/_dexes/uniswap.ts b/api/_dexes/uniswap.ts index f5a0765e8..f5a9663f1 100644 --- a/api/_dexes/uniswap.ts +++ b/api/_dexes/uniswap.ts @@ -387,7 +387,7 @@ export async function getUniswapCrossSwapQuotesForMinOutputA2A( const bridgeQuote = await getBridgeQuoteForMinOutput({ inputToken: bridgeableInputToken, outputToken: bridgeableOutputToken, - minOutputAmount: destinationSwapQuote.maximumAmountIn, + minOutputAmount: destinationSwapQuote.expectedAmountIn, recipient: getMultiCallHandlerAddress(destinationSwapChainId), message: buildDestinationSwapCrossChainMessage({ crossSwap, diff --git a/api/_utils.ts b/api/_utils.ts index 6858bc375..7b43e9b08 100644 --- a/api/_utils.ts +++ b/api/_utils.ts @@ -187,7 +187,6 @@ export const getLogger = (): LoggingUtility => { * @returns A valid URL of the current endpoint in vercel */ export const resolveVercelEndpoint = () => { - return "https://app.across.to"; const url = process.env.VERCEL_URL ?? "across.to"; const env = process.env.VERCEL_ENV ?? "development"; switch (env) { @@ -2221,11 +2220,10 @@ export async function getCachedTokenInfo(params: TokenOptions) { } // find decimals and symbol for any token address on any chain we support -export async function getTokenInfo({ - chainId, - address, -}: TokenOptions): Promise< - Pick +export async function getTokenInfo({ chainId, address }: TokenOptions): Promise< + Pick & { + chainId: number; + } > { try { if (!ethers.utils.isAddress(address)) { @@ -2255,6 +2253,7 @@ export async function getTokenInfo({ symbol: token.symbol, address: token.addresses[chainId], name: token.name, + chainId, }; } @@ -2291,6 +2290,7 @@ export async function getTokenInfo({ decimals, symbol, name, + chainId, }; } catch (error) { throw new TokenNotFoundError({ diff --git a/api/swap.ts b/api/swap.ts index f28fc3ec9..a59bc9387 100644 --- a/api/swap.ts +++ b/api/swap.ts @@ -10,14 +10,13 @@ import { positiveIntStr, validAddress, boolStr, - getTokenByAddress, + getCachedTokenInfo, } from "./_utils"; import { AMOUNT_TYPE, buildCrossSwapTx, getCrossSwapQuotes, } from "./_dexes/cross-swap"; -import { Token } from "./_dexes/types"; import { InvalidParamError, MissingParamError } from "./_errors"; import { isValidIntegratorId } from "./_integrator-id"; @@ -101,45 +100,17 @@ const handler = async ( : _minOutputAmount ); - // 1. Get auxiliary data - // - Token details - // - Token prices - const knownInputToken = getTokenByAddress( - _inputTokenAddress, - originChainId - ); - const inputToken: Token = knownInputToken - ? { - address: knownInputToken.addresses[originChainId]!, - decimals: knownInputToken.decimals, - symbol: knownInputToken.symbol, - chainId: originChainId, - } - : // @FIXME: fetch dynamic token details. using hardcoded values for now - { - address: _inputTokenAddress, - decimals: 18, - symbol: "UNKNOWN", - chainId: originChainId, - }; - const knownOutputToken = getTokenByAddress( - _outputTokenAddress, - destinationChainId - ); - const outputToken: Token = knownOutputToken - ? { - address: knownOutputToken.addresses[destinationChainId]!, - decimals: knownOutputToken.decimals, - symbol: knownOutputToken.symbol, - chainId: destinationChainId, - } - : // @FIXME: fetch dynamic token details. using hardcoded values for now - { - address: _outputTokenAddress, - decimals: 18, - symbol: "UNKNOWN", - chainId: destinationChainId, - }; + // 1. Get token details + const [inputToken, outputToken] = await Promise.all([ + getCachedTokenInfo({ + address: _inputTokenAddress, + chainId: originChainId, + }), + getCachedTokenInfo({ + address: _outputTokenAddress, + chainId: destinationChainId, + }), + ]); // 2. Get swap quotes and calldata based on the swap type const crossSwapQuotes = await getCrossSwapQuotes({ From 8ac1e952dee670245ffd52501e469f6b6b5e2c49 Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Mon, 18 Nov 2024 18:01:31 +0700 Subject: [PATCH 08/11] feat: add leftover handling type --- api/_dexes/cross-swap.ts | 7 +++ api/_dexes/types.ts | 4 +- api/_dexes/uniswap.ts | 105 ++++++++++++++++++++++++++++++--------- api/swap.ts | 2 + 4 files changed, 93 insertions(+), 25 deletions(-) diff --git a/api/_dexes/cross-swap.ts b/api/_dexes/cross-swap.ts index b7ca236c1..be903bba2 100644 --- a/api/_dexes/cross-swap.ts +++ b/api/_dexes/cross-swap.ts @@ -24,6 +24,8 @@ export type CrossSwapType = export type AmountType = (typeof AMOUNT_TYPE)[keyof typeof AMOUNT_TYPE]; +export type LeftoverType = (typeof LEFTOVER_TYPE)[keyof typeof LEFTOVER_TYPE]; + export const AMOUNT_TYPE = { EXACT_INPUT: "exactInput", MIN_OUTPUT: "minOutput", @@ -36,6 +38,11 @@ export const CROSS_SWAP_TYPE = { ANY_TO_ANY: "anyToAny", } as const; +export const LEFTOVER_TYPE = { + OUTPUT_TOKEN: "outputToken", + BRIDGEABLE_TOKEN: "bridgeableToken", +} as const; + export const PREFERRED_BRIDGE_TOKENS = ["WETH", "USDC"]; export async function getCrossSwapQuotes( diff --git a/api/_dexes/types.ts b/api/_dexes/types.ts index f6b85472b..6e00f04ce 100644 --- a/api/_dexes/types.ts +++ b/api/_dexes/types.ts @@ -1,6 +1,6 @@ import { BigNumber } from "ethers"; import { getSuggestedFees } from "../_utils"; -import { AmountType, CrossSwapType } from "./cross-swap"; +import { AmountType, CrossSwapType, LeftoverType } from "./cross-swap"; export type { AmountType, CrossSwapType }; @@ -19,6 +19,7 @@ export type Swap = { recipient: string; slippageTolerance: number; type: AmountType; + leftoverType?: LeftoverType; }; export type CrossSwap = { @@ -29,6 +30,7 @@ export type CrossSwap = { recipient: string; slippageTolerance: number; type: AmountType; + leftoverType?: LeftoverType; refundOnOrigin: boolean; refundAddress?: string; }; diff --git a/api/_dexes/uniswap.ts b/api/_dexes/uniswap.ts index f5a9663f1..530207eb1 100644 --- a/api/_dexes/uniswap.ts +++ b/api/_dexes/uniswap.ts @@ -29,6 +29,7 @@ import { SwapQuote, } from "./types"; import { getSwapAndBridgeAddress, NoSwapRouteError } from "./utils"; +import { LEFTOVER_TYPE } from "./cross-swap"; // Taken from here: https://docs.uniswap.org/contracts/v3/reference/deployments/ export const SWAP_ROUTER_02_ADDRESS = { @@ -136,19 +137,41 @@ export async function getUniswapCrossSwapQuotesForMinOutputB2A( chainId: destinationSwapChainId, }; - // 1. Get destination swap quote for bridgeable output token -> any token - // with exact output amount. - const destinationSwapQuote = await getUniswapQuote( + const destinationSwap = { + chainId: destinationSwapChainId, + tokenIn: bridgeableOutputToken, + tokenOut: crossSwap.outputToken, + recipient: crossSwap.recipient, + slippageTolerance: crossSwap.slippageTolerance, + }; + // 1.1. Get destination swap quote for bridgeable output token -> any token + // with exact output amount. + let destinationSwapQuote = await getUniswapQuote( { - chainId: destinationSwapChainId, - tokenIn: bridgeableOutputToken, - tokenOut: crossSwap.outputToken, + ...destinationSwap, amount: crossSwap.amount.toString(), - recipient: crossSwap.recipient, - slippageTolerance: crossSwap.slippageTolerance, }, TradeType.EXACT_OUTPUT ); + // 1.2. Re-fetch destination swap quote with exact input amount if leftover tokens + // should be sent as output tokens instead of bridgeable output tokens. + if (crossSwap.leftoverType === LEFTOVER_TYPE.OUTPUT_TOKEN) { + destinationSwapQuote = await getUniswapQuote( + { + ...destinationSwap, + amount: destinationSwapQuote.maximumAmountIn + .mul( + ethers.utils.parseEther( + (1 + Number(crossSwap.slippageTolerance) / 100).toString() + ) + ) + .div(utils.fixedPointAdjustment) + .toString(), + }, + TradeType.EXACT_INPUT + ); + assertMinOutputAmount(destinationSwapQuote.minAmountOut, crossSwap.amount); + } // 2. Get bridge quote for bridgeable input token -> bridgeable output token const bridgeQuote = await getBridgeQuoteForMinOutput({ @@ -368,20 +391,49 @@ export async function getUniswapCrossSwapQuotesForMinOutputA2A( symbol: _bridgeableOutputToken.symbol, chainId: bridgeRoute.toChain, }; + const originSwap = { + chainId: originSwapChainId, + tokenIn: crossSwap.inputToken, + tokenOut: bridgeableInputToken, + recipient: getSwapAndBridgeAddress("uniswap", originSwapChainId), + slippageTolerance: crossSwap.slippageTolerance, + }; + const destinationSwap = { + chainId: destinationSwapChainId, + tokenIn: bridgeableOutputToken, + tokenOut: crossSwap.outputToken, + recipient: crossSwap.recipient, + slippageTolerance: crossSwap.slippageTolerance, + }; - // 1. Get destination swap quote for bridgeable output token -> any token - // with exact output amount - const destinationSwapQuote = await getUniswapQuote( + // 1.1. Get destination swap quote for bridgeable output token -> any token + // with exact output amount + let destinationSwapQuote = await getUniswapQuote( { - chainId: destinationSwapChainId, - tokenIn: bridgeableOutputToken, - tokenOut: crossSwap.outputToken, + ...destinationSwap, amount: crossSwap.amount.toString(), - recipient: crossSwap.recipient, - slippageTolerance: crossSwap.slippageTolerance, }, TradeType.EXACT_OUTPUT ); + // 1.2. Re-fetch destination swap quote with exact input amount if leftover tokens + // should be sent as output tokens instead of bridgeable output tokens. + if (crossSwap.leftoverType === LEFTOVER_TYPE.OUTPUT_TOKEN) { + destinationSwapQuote = await getUniswapQuote( + { + ...destinationSwap, + amount: destinationSwapQuote.maximumAmountIn + .mul( + ethers.utils.parseEther( + (1 + Number(crossSwap.slippageTolerance) / 100).toString() + ) + ) + .div(utils.fixedPointAdjustment) + .toString(), + }, + TradeType.EXACT_INPUT + ); + assertMinOutputAmount(destinationSwapQuote.minAmountOut, crossSwap.amount); + } // 2. Get bridge quote for bridgeable input token -> bridgeable output token const bridgeQuote = await getBridgeQuoteForMinOutput({ @@ -396,13 +448,6 @@ export async function getUniswapCrossSwapQuotesForMinOutputA2A( }), }); - const originSwap = { - chainId: originSwapChainId, - tokenIn: crossSwap.inputToken, - tokenOut: bridgeableInputToken, - recipient: getSwapAndBridgeAddress("uniswap", originSwapChainId), - slippageTolerance: crossSwap.slippageTolerance, - }; // 3.1. Get origin swap quote for any input token -> bridgeable input token const originSwapQuote = await getUniswapQuote( { @@ -589,7 +634,7 @@ function buildDestinationSwapCrossChainMessage({ callData: destinationSwapQuote.swapTx.data, value: destinationSwapQuote.swapTx.value, }, - // drain remaining bridgeable output tokens from MulticallHandler contract + // drain remaining bridgeable output tokens from MultiCallHandler contract { target: getMultiCallHandlerAddress(destinationSwapChainId), callData: encodeDrainCalldata( @@ -618,3 +663,15 @@ function encodeDrainCalldata(token: string, destination: string) { destination, ]); } + +function assertMinOutputAmount( + amountOut: BigNumber, + expectedMinAmountOut: BigNumber +) { + if (amountOut.lt(expectedMinAmountOut)) { + throw new Error( + `Swap quote output amount ${amountOut.toString()} ` + + `is less than required min. output amount ${expectedMinAmountOut.toString()}` + ); + } +} diff --git a/api/swap.ts b/api/swap.ts index a59bc9387..d4e2ac1a7 100644 --- a/api/swap.ts +++ b/api/swap.ts @@ -123,6 +123,8 @@ const handler = async ( type: amountType, refundOnOrigin, refundAddress, + // @TODO: Make this configurable via env var or query param + leftoverType: "outputToken", }); // 3. Build cross swap tx From 8958fad868ae1236221b249618726d99a6553096 Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Mon, 18 Nov 2024 22:26:45 +0700 Subject: [PATCH 09/11] fixup --- api/_dexes/uniswap.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/api/_dexes/uniswap.ts b/api/_dexes/uniswap.ts index 530207eb1..7987d92eb 100644 --- a/api/_dexes/uniswap.ts +++ b/api/_dexes/uniswap.ts @@ -617,7 +617,7 @@ function buildDestinationSwapCrossChainMessage({ const destinationSwapChainId = destinationSwapQuote.tokenOut.chainId; return buildMulticallHandlerMessage({ // @TODO: handle fallback recipient for params `refundOnOrigin` and `refundAddress` - fallbackRecipient: crossSwap.recipient, + fallbackRecipient: crossSwap.depositor, actions: [ // approve bridgeable output token { @@ -639,8 +639,7 @@ function buildDestinationSwapCrossChainMessage({ target: getMultiCallHandlerAddress(destinationSwapChainId), callData: encodeDrainCalldata( bridgeableOutputToken.address, - // @TODO: determine whether to use 'depositor' or 'recipient' - crossSwap.recipient + crossSwap.depositor ), value: "0", }, From 30c38f56ff35d907d8260195a7ecedc89824b38f Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Tue, 19 Nov 2024 09:08:56 +0700 Subject: [PATCH 10/11] fixup --- api/swap.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/swap.ts b/api/swap.ts index d4e2ac1a7..93b98e949 100644 --- a/api/swap.ts +++ b/api/swap.ts @@ -124,7 +124,7 @@ const handler = async ( refundOnOrigin, refundAddress, // @TODO: Make this configurable via env var or query param - leftoverType: "outputToken", + leftoverType: "bridgeableToken", }); // 3. Build cross swap tx From 484314e987aa4b4c8667e0ac06221b30e839ae3f Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Wed, 20 Nov 2024 10:13:00 +0700 Subject: [PATCH 11/11] review requests --- api/_dexes/cross-swap.ts | 4 ++-- api/_dexes/uniswap.ts | 33 +++++++++++++++++---------------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/api/_dexes/cross-swap.ts b/api/_dexes/cross-swap.ts index be903bba2..8599baa3d 100644 --- a/api/_dexes/cross-swap.ts +++ b/api/_dexes/cross-swap.ts @@ -162,7 +162,7 @@ export async function buildCrossSwapTx( const destinationChainId = crossSwapQuotes.crossSwap.outputToken.chainId; const spokePool = getSpokePool(originChainId); const deposit = { - depositor: crossSwapQuotes.crossSwap.recipient, + depositor: crossSwapQuotes.crossSwap.depositor, recipient: crossSwapQuotes.destinationSwapQuote ? getMultiCallHandlerAddress(destinationChainId) : crossSwapQuotes.crossSwap.recipient, @@ -177,7 +177,7 @@ export async function buildCrossSwapTx( fillDeadline: await getFillDeadline(spokePool), exclusivityDeadline: crossSwapQuotes.bridgeQuote.suggestedFees.exclusivityDeadline, - message: crossSwapQuotes.bridgeQuote?.message || "0x", + message: crossSwapQuotes.bridgeQuote.message || "0x", }; let tx: PopulatedTransaction; diff --git a/api/_dexes/uniswap.ts b/api/_dexes/uniswap.ts index 7987d92eb..b658edfc8 100644 --- a/api/_dexes/uniswap.ts +++ b/api/_dexes/uniswap.ts @@ -159,14 +159,10 @@ export async function getUniswapCrossSwapQuotesForMinOutputB2A( destinationSwapQuote = await getUniswapQuote( { ...destinationSwap, - amount: destinationSwapQuote.maximumAmountIn - .mul( - ethers.utils.parseEther( - (1 + Number(crossSwap.slippageTolerance) / 100).toString() - ) - ) - .div(utils.fixedPointAdjustment) - .toString(), + amount: addSlippageToAmount( + destinationSwapQuote.maximumAmountIn, + crossSwap.slippageTolerance.toString() + ), }, TradeType.EXACT_INPUT ); @@ -421,14 +417,10 @@ export async function getUniswapCrossSwapQuotesForMinOutputA2A( destinationSwapQuote = await getUniswapQuote( { ...destinationSwap, - amount: destinationSwapQuote.maximumAmountIn - .mul( - ethers.utils.parseEther( - (1 + Number(crossSwap.slippageTolerance) / 100).toString() - ) - ) - .div(utils.fixedPointAdjustment) - .toString(), + amount: addSlippageToAmount( + destinationSwapQuote.maximumAmountIn, + crossSwap.slippageTolerance.toString() + ), }, TradeType.EXACT_INPUT ); @@ -674,3 +666,12 @@ function assertMinOutputAmount( ); } } + +function addSlippageToAmount(amount: BigNumber, slippageTolerance: string) { + return amount + .mul( + ethers.utils.parseEther((1 + Number(slippageTolerance) / 100).toString()) + ) + .div(utils.fixedPointAdjustment) + .toString(); +}