From 152e3d7e54a8acdcebd95736c83c3e8a9e4329c0 Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Thu, 21 Nov 2024 19:26:41 +0700 Subject: [PATCH] feat: support permit swap --- api/_abis.ts | 49 ++++++++ api/_dexes/cross-swap.ts | 58 ++++++++++ api/_permit.ts | 144 ++++++++++++++++++++++++ api/_utils.ts | 1 + api/swap/permit.ts | 41 +++++++ scripts/tests/swap-permit.ts | 212 +++++++++++++++++++++++++++++++++++ 6 files changed, 505 insertions(+) create mode 100644 api/_permit.ts create mode 100644 api/swap/permit.ts create mode 100644 scripts/tests/swap-permit.ts diff --git a/api/_abis.ts b/api/_abis.ts index da0bf6884..3dd4fc41f 100644 --- a/api/_abis.ts +++ b/api/_abis.ts @@ -77,3 +77,52 @@ export const MINIMAL_MULTICALL3_ABI = [ type: "function", }, ]; + +export const ERC20_PERMIT_ABI = [ + { + inputs: [], + stateMutability: "view", + type: "function", + name: "name", + outputs: [ + { + internalType: "string", + name: "", + type: "string", + }, + ], + }, + { + inputs: [ + { + internalType: "address", + name: "owner", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + name: "nonces", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + }, + { + inputs: [], + name: "version", + outputs: [{ internalType: "string", name: "", type: "string" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "DOMAIN_SEPARATOR", + outputs: [{ internalType: "bytes32", name: "", type: "bytes32" }], + stateMutability: "view", + type: "function", + }, +]; diff --git a/api/_dexes/cross-swap.ts b/api/_dexes/cross-swap.ts index c6ecf9c59..408792a99 100644 --- a/api/_dexes/cross-swap.ts +++ b/api/_dexes/cross-swap.ts @@ -22,6 +22,7 @@ import { } from "./utils"; import { tagIntegratorId } from "../_integrator-id"; import { getMultiCallHandlerAddress } from "../_multicall-handler"; +import { getPermitTypedData } from "../_permit"; export type CrossSwapType = (typeof CROSS_SWAP_TYPE)[keyof typeof CROSS_SWAP_TYPE]; @@ -248,6 +249,63 @@ export async function buildCrossSwapTxForAllowanceHolder( value: tx.value, }; } + +export async function getCrossSwapTxForPermit( + crossSwapQuotes: CrossSwapQuotes, + permitDeadline: number +) { + const originChainId = crossSwapQuotes.crossSwap.inputToken.chainId; + const swapAndBridge = getSwapAndBridge("uniswap", originChainId); + const deposit = await extractDepositDataStruct(crossSwapQuotes); + + let methodName: string; + let argsWithoutSignature: Record; + if (crossSwapQuotes.originSwapQuote) { + methodName = "swapAndBridgeWithPermit"; + argsWithoutSignature = { + swapToken: crossSwapQuotes.originSwapQuote.tokenIn.address, + acrossInputToken: crossSwapQuotes.originSwapQuote.tokenOut.address, + routerCalldata: crossSwapQuotes.originSwapQuote.swapTx.data, + swapTokenAmount: crossSwapQuotes.originSwapQuote.maximumAmountIn, + minExpectedInputTokenAmount: crossSwapQuotes.originSwapQuote.minAmountOut, + depositData: deposit, + deadline: permitDeadline, + }; + } else { + methodName = "depositWithPermit"; + argsWithoutSignature = { + acrossInputToken: crossSwapQuotes.bridgeQuote.inputToken.address, + acrossInputAmount: crossSwapQuotes.bridgeQuote.inputAmount, + depositData: deposit, + deadline: permitDeadline, + }; + } + + const permitTypedData = await getPermitTypedData({ + tokenAddress: + crossSwapQuotes.originSwapQuote?.tokenIn.address || + crossSwapQuotes.bridgeQuote.inputToken.address, + chainId: originChainId, + ownerAddress: crossSwapQuotes.crossSwap.depositor, + spenderAddress: swapAndBridge.address, + value: + crossSwapQuotes.originSwapQuote?.maximumAmountIn || + crossSwapQuotes.bridgeQuote.inputAmount, + deadline: permitDeadline, + }); + return { + permit: { + eip712: permitTypedData.eip712, + }, + tx: { + chainId: originChainId, + to: swapAndBridge.address, + methodName, + argsWithoutSignature, + }, + }; +} + async function extractDepositDataStruct(crossSwapQuotes: CrossSwapQuotes) { const originChainId = crossSwapQuotes.crossSwap.inputToken.chainId; const destinationChainId = crossSwapQuotes.crossSwap.outputToken.chainId; diff --git a/api/_permit.ts b/api/_permit.ts new file mode 100644 index 000000000..90e7211ad --- /dev/null +++ b/api/_permit.ts @@ -0,0 +1,144 @@ +import { BigNumberish, ethers } from "ethers"; + +import { getProvider } from "./_utils"; +import { ERC20_PERMIT_ABI } from "./_abis"; + +export async function getPermitTypedData(params: { + tokenAddress: string; + chainId: number; + ownerAddress: string; + spenderAddress: string; + value: BigNumberish; + deadline: number; + eip712DomainVersion?: number; +}) { + const provider = getProvider(params.chainId); + const erc20Permit = new ethers.Contract( + params.tokenAddress, + ERC20_PERMIT_ABI, + provider + ); + + const [ + nameResult, + versionFromContractResult, + nonceResult, + domainSeparatorResult, + ] = await Promise.allSettled([ + erc20Permit.name(), + erc20Permit.version(), + erc20Permit.nonces(params.ownerAddress), + erc20Permit.DOMAIN_SEPARATOR(), + ]); + + if ( + nameResult.status === "rejected" || + nonceResult.status === "rejected" || + domainSeparatorResult.status === "rejected" + ) { + const error = + nameResult.status === "rejected" + ? nameResult.reason + : nonceResult.status === "rejected" + ? nonceResult.reason + : domainSeparatorResult.status === "rejected" + ? domainSeparatorResult.reason + : new Error("Unknown error"); + throw new Error(`Contract ${params.tokenAddress} does not support permit`, { + cause: error, + }); + } + + const name = nameResult.value; + const versionFromContract = + versionFromContractResult.status === "fulfilled" + ? versionFromContractResult.value + : undefined; + const nonce = nonceResult.value; + const domainSeparator = domainSeparatorResult.value; + + const eip712DomainVersion = [1, 2, "1", "2"].includes(versionFromContract) + ? Number(versionFromContract) + : params.eip712DomainVersion || 1; + + const domainSeparatorHash = ethers.utils.keccak256( + ethers.utils.defaultAbiCoder.encode( + ["bytes32", "bytes32", "bytes32", "uint256", "address"], + [ + ethers.utils.id( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ), + ethers.utils.id(name), + ethers.utils.id(eip712DomainVersion.toString()), + params.chainId, + params.tokenAddress, + ] + ) + ); + + if (domainSeparator !== domainSeparatorHash) { + throw new Error("EIP712 domain separator mismatch"); + } + + return { + domainSeparator, + eip712: { + types: { + EIP712Domain: [ + { + name: "name", + type: "string", + }, + { + name: "version", + type: "string", + }, + { + name: "chainId", + type: "uint256", + }, + { + name: "verifyingContract", + type: "address", + }, + ], + Permit: [ + { + name: "owner", + type: "address", + }, + { + name: "spender", + type: "address", + }, + { + name: "value", + type: "uint256", + }, + { + name: "nonce", + type: "uint256", + }, + { + name: "deadline", + type: "uint256", + }, + ], + }, + primaryType: "Permit", + domain: { + name, + version: eip712DomainVersion.toString(), + chainId: params.chainId, + verifyingContract: params.tokenAddress, + }, + message: { + owner: params.ownerAddress, + spender: params.spenderAddress, + value: String(params.value), + nonce: String(nonce), + deadline: String(params.deadline), + }, + }, + }; +} diff --git a/api/_utils.ts b/api/_utils.ts index a695b88af..21f984645 100644 --- a/api/_utils.ts +++ b/api/_utils.ts @@ -190,6 +190,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) { diff --git a/api/swap/permit.ts b/api/swap/permit.ts new file mode 100644 index 000000000..6d65b8b6c --- /dev/null +++ b/api/swap/permit.ts @@ -0,0 +1,41 @@ +import { VercelResponse } from "@vercel/node"; + +import { TypedVercelRequest } from "../_types"; +import { getLogger, handleErrorCondition } from "../_utils"; +import { getCrossSwapTxForPermit } from "../_dexes/cross-swap"; +import { handleBaseSwapQueryParams, BaseSwapQueryParams } from "./_utils"; + +const handler = async ( + request: TypedVercelRequest, + response: VercelResponse +) => { + const logger = getLogger(); + logger.debug({ + at: "Swap/permit", + message: "Query data", + query: request.query, + }); + try { + const { crossSwapQuotes } = await handleBaseSwapQueryParams(request); + + const permitDeadline = Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365; // 1 year + + const crossSwapTxForPermit = await getCrossSwapTxForPermit( + crossSwapQuotes, + permitDeadline + ); + + const responseJson = crossSwapTxForPermit; + + logger.debug({ + at: "Swap/allowance", + message: "Response data", + responseJson, + }); + response.status(200).json(responseJson); + } catch (error: unknown) { + return handleErrorCondition("swap/allowance", response, logger, error); + } +}; + +export default handler; diff --git a/scripts/tests/swap-permit.ts b/scripts/tests/swap-permit.ts new file mode 100644 index 000000000..c22053f54 --- /dev/null +++ b/scripts/tests/swap-permit.ts @@ -0,0 +1,212 @@ +import axios from "axios"; +import { CHAIN_IDs, TOKEN_SYMBOLS_MAP } from "@across-protocol/constants"; +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. + */ + +const depositor = "0x9A8f92a830A5cB89a3816e3D267CB7791c16b04D"; +const MIN_OUTPUT_CASES = [ + // B2B + { + labels: ["B2B", "MIN_OUTPUT", "Base USDC - Arbitrum USDC"], + params: { + 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, + depositor, + }, + }, + { + labels: ["B2B", "MIN_OUTPUT", "Base USDC - Arbitrum ETH"], + params: { + minOutputAmount: ethers.utils.parseUnits("0.001", 18).toString(), + inputToken: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.BASE], + originChainId: CHAIN_IDs.BASE, + outputToken: ethers.constants.AddressZero, + destinationChainId: CHAIN_IDs.ARBITRUM, + depositor, + }, + }, + { + labels: ["B2B", "MIN_OUTPUT", "Arbitrum ETH - Base USDC"], + params: { + minOutputAmount: ethers.utils.parseUnits("3", 6).toString(), + inputToken: ethers.constants.AddressZero, + originChainId: CHAIN_IDs.ARBITRUM, + outputToken: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.BASE], + destinationChainId: CHAIN_IDs.BASE, + depositor, + }, + }, + // B2A + { + labels: ["B2A", "MIN_OUTPUT", "Base USDC", "Arbitrum WETH"], + params: { + minOutputAmount: ethers.utils.parseUnits("0.001", 18).toString(), + inputToken: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.BASE], + originChainId: CHAIN_IDs.BASE, + outputToken: TOKEN_SYMBOLS_MAP.WETH.addresses[CHAIN_IDs.ARBITRUM], + destinationChainId: CHAIN_IDs.ARBITRUM, + depositor, + }, + }, + { + labels: ["B2A", "MIN_OUTPUT", "Base USDC", "Arbitrum ETH"], + params: { + minOutputAmount: ethers.utils.parseUnits("0.001", 18).toString(), + inputToken: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.BASE], + originChainId: CHAIN_IDs.BASE, + outputToken: ethers.constants.AddressZero, + destinationChainId: CHAIN_IDs.ARBITRUM, + depositor, + }, + }, + // A2B + { + labels: ["A2B", "MIN_OUTPUT", "Base USDbC", "Arbitrum USDC"], + params: { + 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, + depositor, + }, + }, + // A2A + { + labels: ["A2A", "MIN_OUTPUT", "Base USDbC", "Arbitrum APE"], + params: { + 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, + depositor, + }, + }, +]; +const EXACT_OUTPUT_CASES = [ + // B2B + { + labels: ["B2B", "EXACT_OUTPUT", "Base USDC", "Arbitrum USDC"], + params: { + exactOutputAmount: 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, + depositor, + }, + }, + { + labels: ["B2B", "EXACT_OUTPUT", "Base USDC", "Arbitrum ETH"], + params: { + exactOutputAmount: ethers.utils.parseUnits("0.001", 18).toString(), + inputToken: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.BASE], + originChainId: CHAIN_IDs.BASE, + outputToken: ethers.constants.AddressZero, + destinationChainId: CHAIN_IDs.ARBITRUM, + depositor, + }, + }, + // B2A + { + labels: ["B2A", "EXACT_OUTPUT", "Base USDC", "Arbitrum WETH"], + params: { + exactOutputAmount: ethers.utils.parseUnits("0.001", 18).toString(), + inputToken: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.BASE], + originChainId: CHAIN_IDs.BASE, + outputToken: TOKEN_SYMBOLS_MAP.WETH.addresses[CHAIN_IDs.ARBITRUM], + destinationChainId: CHAIN_IDs.ARBITRUM, + depositor, + }, + }, + { + labels: ["B2A", "EXACT_OUTPUT", "Base USDC", "Arbitrum ETH"], + params: { + exactOutputAmount: ethers.utils.parseUnits("0.001", 18).toString(), + inputToken: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.BASE], + originChainId: CHAIN_IDs.BASE, + outputToken: ethers.constants.AddressZero, + destinationChainId: CHAIN_IDs.ARBITRUM, + depositor, + }, + }, + // A2B + { + labels: ["A2B", "EXACT_OUTPUT", "Base USDbC", "Arbitrum USDC"], + params: { + 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, + depositor, + }, + }, + // A2A + { + labels: ["A2A", "EXACT_OUTPUT", "Base USDbC", "Arbitrum APE"], + params: { + 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, + depositor, + }, + }, +]; + +async function swap() { + const filterString = process.argv[2]; + const testCases = [...MIN_OUTPUT_CASES, ...EXACT_OUTPUT_CASES]; + const labelsToFilter = filterString ? filterString.split(",") : []; + const filteredTestCases = testCases.filter((testCase) => { + const matches = labelsToFilter.filter((label) => + testCase.labels + .map((label) => label.toLowerCase()) + .includes(label.toLowerCase()) + ); + return matches.length === labelsToFilter.length; + }); + for (const testCase of filteredTestCases) { + console.log("\nTest case:", testCase.labels.join(" ")); + const response = await axios.get(`http://localhost:3000/api/swap/permit`, { + params: testCase.params, + }); + console.log(response.data); + + if (process.env.DEV_WALLET_PK) { + const wallet = new Wallet(process.env.DEV_WALLET_PK!).connect( + getProvider(testCase.params.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); + } + } + } +} + +swap() + .then(() => console.log("Done")) + .catch(console.error);