From 57b5d6dfdc081989709de6f62eea4674a39dafac Mon Sep 17 00:00:00 2001
From: Dong-Ha Kim <dongha.kim210@gmail.com>
Date: Thu, 14 Nov 2024 12:26:16 +0700
Subject: [PATCH] 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<number> {
+  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<CrossSwapQuotes>).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<SwapQuote> {
+export async function getUniswapQuote(
+  swap: Swap,
+  tradeType: TradeType
+): Promise<SwapQuote> {
   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<SwapQuote> {
       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<ReturnType<typeof getSuggestedFees>> | 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<ReturnType<typeof getSuggestedFees>> | 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<string, StaticJsonRpcProvider> = {};
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);
+      }
+    }
   }
 }