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); + } + } } }