From 4ff0ce4fd08ccdb363a25ea3280026c74b0e8ae1 Mon Sep 17 00:00:00 2001 From: Gerhard Steenkamp Date: Fri, 20 Dec 2024 16:11:18 +0200 Subject: [PATCH] support transfer with auth --- api/_abis.ts | 30 ++++++++ api/_transfer-with-auth.ts | 137 +++++++++++++++++++++++++++++++++++ api/swap/auth/_utils.ts | 133 ++++++++++++++++++++++++++++++++++ api/swap/auth/index.ts | 112 ++++++++++++++++++++++++++++ scripts/tests/_swap-utils.ts | 2 +- scripts/tests/swap-auth.ts | 15 ++++ 6 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 api/_transfer-with-auth.ts create mode 100644 api/swap/auth/_utils.ts create mode 100644 api/swap/auth/index.ts create mode 100644 scripts/tests/swap-auth.ts diff --git a/api/_abis.ts b/api/_abis.ts index 3dd4fc41f..8fe7a37ff 100644 --- a/api/_abis.ts +++ b/api/_abis.ts @@ -126,3 +126,33 @@ export const ERC20_PERMIT_ABI = [ type: "function", }, ]; + +export const ERC_TRANSFER_WITH_AUTH_ABI = [ + { + inputs: [], + stateMutability: "view", + type: "function", + name: "name", + outputs: [ + { + internalType: "string", + name: "", + type: "string", + }, + ], + }, + { + 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/_transfer-with-auth.ts b/api/_transfer-with-auth.ts new file mode 100644 index 000000000..27c39d972 --- /dev/null +++ b/api/_transfer-with-auth.ts @@ -0,0 +1,137 @@ +import { BigNumberish, ethers } from "ethers"; +import { getProvider } from "./_utils"; +import { ERC_TRANSFER_WITH_AUTH_ABI } from "./_abis"; +import { utils } from "ethers"; + +export function hashDomainSeparator(params: { + name: string; + version: string | number; + chainId: number; + verifyingContract: string; +}): string { + return utils.keccak256( + utils.defaultAbiCoder.encode( + ["bytes32", "bytes32", "bytes32", "uint256", "address"], + [ + utils.id( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ), + utils.id(params.name), + utils.id(params.version.toString()), + params.chainId, + params.verifyingContract, + ] + ) + ); +} + +export async function getTransferWithAuthTypedData(params: { + tokenAddress: string; + chainId: number; + ownerAddress: string; + spenderAddress: string; + value: BigNumberish; + validBefore: number; + nonce: string; + validAfter?: number; + eip712DomainVersion?: number; +}) { + const provider = getProvider(params.chainId); + + const erc20Permit = new ethers.Contract( + params.tokenAddress, + ERC_TRANSFER_WITH_AUTH_ABI, + provider + ); + + const [nameResult, versionFromContractResult, domainSeparatorResult] = + await Promise.allSettled([ + erc20Permit.name(), + erc20Permit.version(), + erc20Permit.DOMAIN_SEPARATOR(), + ]); + + if ( + nameResult.status === "rejected" || + domainSeparatorResult.status === "rejected" + ) { + const error = + nameResult.status === "rejected" + ? nameResult.reason + : domainSeparatorResult.status === "rejected" + ? domainSeparatorResult.reason + : new Error("Unknown error"); + throw new Error( + `Contract ${params.tokenAddress} does not support transfer with authorization`, + { + cause: error, + } + ); + } + + const name = nameResult.value; + const versionFromContract = + versionFromContractResult.status === "fulfilled" + ? versionFromContractResult.value + : undefined; + const domainSeparator = domainSeparatorResult.value; + + const eip712DomainVersion = [1, 2, "1", "2"].includes(versionFromContract) + ? Number(versionFromContract) + : params.eip712DomainVersion || 1; + + const domainSeparatorHash = hashDomainSeparator({ + name, + version: eip712DomainVersion, + chainId: params.chainId, + verifyingContract: 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" }, + ], + TransferWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + ], + }, + domain: { + name, + version: eip712DomainVersion.toString(), + chainId: params.chainId, + verifyingContract: params.tokenAddress, + }, + primaryType: "TransferWithAuthorization", + message: { + from: params.ownerAddress, + to: params.spenderAddress, + value: String(params.value), + validAfter: params?.validAfter + ? convertMaybeMillisecondsToSeconds(params.validAfter) + : 0, + validBefore: convertMaybeMillisecondsToSeconds(params.validBefore), + nonce: params.nonce, // non-sequential nonce, random 32 byte hex string + }, + }, + }; +} + +export function convertMaybeMillisecondsToSeconds(timestamp: number): number { + const isMilliseconds = timestamp > 1_000_000_000; // rough approximation + return isMilliseconds ? Math.floor(timestamp / 1000) : Math.floor(timestamp); +} diff --git a/api/swap/auth/_utils.ts b/api/swap/auth/_utils.ts new file mode 100644 index 000000000..bffaca856 --- /dev/null +++ b/api/swap/auth/_utils.ts @@ -0,0 +1,133 @@ +import { + CrossSwapQuotes, + DepositEntryPointContract, + OriginSwapEntryPointContract, +} from "../../_dexes/types"; +import { getTransferWithAuthTypedData } from "../../_transfer-with-auth"; +import { + getDepositTypedData, + getSwapAndDepositTypedData, + TransferType, +} from "../../_spoke-pool-periphery"; +import { extractDepositDataStruct } from "../../_dexes/utils"; +import { BigNumber, utils } from "ethers"; + +export async function buildAuthTxPayload( + crossSwapQuotes: CrossSwapQuotes, + authDeadline: number, // maybe milliseconds + authStart = 0 // maybe milliseconds +) { + const { originSwapQuote, bridgeQuote, crossSwap, contracts } = + crossSwapQuotes; + const originChainId = crossSwap.inputToken.chainId; + const { originSwapEntryPoint, depositEntryPoint, originRouter } = contracts; + + const baseDepositData = await extractDepositDataStruct(crossSwapQuotes); + + let entryPointContract: + | DepositEntryPointContract + | OriginSwapEntryPointContract; + let getDepositTypedDataPromise: + | ReturnType + | ReturnType; + let methodName: string; + + if (originSwapQuote) { + if (!originSwapEntryPoint) { + throw new Error( + `'originSwapEntryPoint' needs to be defined for origin swap quotes` + ); + } + // Only SpokePoolPeriphery supports transfer with auth + if (originSwapEntryPoint.name !== "SpokePoolPeriphery") { + throw new Error( + `Transfer with auth is not supported for origin swap entry point contract '${originSwapEntryPoint.name}'` + ); + } + + if (!originRouter) { + throw new Error( + `'originRouter' needs to be defined for origin swap quotes` + ); + } + + entryPointContract = originSwapEntryPoint; + getDepositTypedDataPromise = getSwapAndDepositTypedData({ + swapAndDepositData: { + // TODO: Make this dynamic + submissionFees: { + amount: BigNumber.from(0), + recipient: crossSwapQuotes.crossSwap.depositor, + }, + depositData: baseDepositData, + swapToken: originSwapQuote.tokenIn.address, + swapTokenAmount: originSwapQuote.maximumAmountIn, + minExpectedInputTokenAmount: originSwapQuote.minAmountOut, + routerCalldata: originSwapQuote.swapTx.data, + exchange: originRouter.address, + transferType: + originRouter.name === "UniswapV3UniversalRouter" + ? TransferType.Transfer + : TransferType.Approval, + }, + chainId: originChainId, + }); + methodName = "swapAndBridgeWithAuthorization"; + } else { + if (!depositEntryPoint) { + throw new Error( + `'depositEntryPoint' needs to be defined for bridge quotes` + ); + } + + if (depositEntryPoint.name !== "SpokePoolPeriphery") { + throw new Error( + `auth is not supported for deposit entry point contract '${depositEntryPoint.name}'` + ); + } + + entryPointContract = depositEntryPoint; + getDepositTypedDataPromise = getDepositTypedData({ + depositData: { + // TODO: Make this dynamic + submissionFees: { + amount: BigNumber.from(0), + recipient: crossSwap.depositor, + }, + baseDepositData, + inputAmount: BigNumber.from(bridgeQuote.inputAmount), + }, + chainId: originChainId, + }); + methodName = "depositWithAuthorization"; + } + + // random non-sequesntial nonce + const nonce = utils.hexlify(utils.randomBytes(32)); + + const [authTypedData, depositTypedData] = await Promise.all([ + getTransferWithAuthTypedData({ + tokenAddress: + originSwapQuote?.tokenIn.address || bridgeQuote.inputToken.address, + chainId: originChainId, + ownerAddress: crossSwap.depositor, + spenderAddress: entryPointContract.address, + value: originSwapQuote?.maximumAmountIn || bridgeQuote.inputAmount, + nonce, + validAfter: authStart, + validBefore: authDeadline, + }), + getDepositTypedDataPromise, + ]); + return { + eip712: { + transferWithAuthorization: authTypedData.eip712, + deposit: depositTypedData.eip712, + }, + swapTx: { + chainId: originChainId, + to: entryPointContract.address, + methodName, + }, + }; +} diff --git a/api/swap/auth/index.ts b/api/swap/auth/index.ts new file mode 100644 index 000000000..6cc971746 --- /dev/null +++ b/api/swap/auth/index.ts @@ -0,0 +1,112 @@ +import { VercelResponse } from "@vercel/node"; +import { assert, Infer, optional, type } from "superstruct"; + +import { TypedVercelRequest } from "../../_types"; +import { getLogger, handleErrorCondition, positiveIntStr } from "../../_utils"; +import { getCrossSwapQuotes } from "../../_dexes/cross-swap-service"; +import { handleBaseSwapQueryParams, BaseSwapQueryParams } from "../_utils"; +import { getSwapRouter02Strategy } from "../../_dexes/uniswap/swap-router-02"; +import { InvalidParamError } from "../../_errors"; +import { QuoteFetchStrategies } from "../../_dexes/utils"; +import { buildAuthTxPayload } from "./_utils"; + +export const authSwapQueryParamsSchema = type({ + authDeadline: optional(positiveIntStr()), +}); + +export type authSwapQueryParams = Infer; + +const DEFAULT_AUTH_DEADLINE = + Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365; // 1 year + +// For auth-based flows, we have to use the `SpokePoolPeriphery` as an entry point +const quoteFetchStrategies: QuoteFetchStrategies = { + default: getSwapRouter02Strategy("SpokePoolPeriphery"), +}; + +const handler = async ( + request: TypedVercelRequest, + response: VercelResponse +) => { + const logger = getLogger(); + logger.debug({ + at: "Swap/auth", + message: "Query data", + query: request.query, + }); + try { + // `/swap/auth` specific params validation + const { + authDeadline: _authDeadline, + authStart: _authStart, + ...restQuery + } = request.query; + assert( + { + authDeadline: _authDeadline, + }, + authSwapQueryParamsSchema + ); + const authDeadline = Number(_authDeadline ?? DEFAULT_AUTH_DEADLINE); + const authStart = Number(_authStart ?? Date.now()); + + if (authDeadline < Math.floor(Date.now() / 1000)) { + throw new InvalidParamError({ + message: + "auth deadline must be a UNIX timestamp (seconds) in the future", + param: "authDeadline", + }); + } + + // `/swap` specific params validation + quote generation + const { + isInputNative, + isOutputNative, + inputToken, + outputToken, + amount, + amountType, + refundOnOrigin, + refundAddress, + recipient, + depositor, + slippageTolerance, + } = await handleBaseSwapQueryParams(restQuery); + + const crossSwapQuotes = await getCrossSwapQuotes( + { + amount, + inputToken, + outputToken, + depositor, + recipient: recipient || depositor, + slippageTolerance: Number(slippageTolerance), + type: amountType, + refundOnOrigin, + refundAddress, + isInputNative, + isOutputNative, + }, + quoteFetchStrategies + ); + // Build tx for auth + const crossSwapTxForAuth = await buildAuthTxPayload( + crossSwapQuotes, + authDeadline, + authStart + ); + + const responseJson = crossSwapTxForAuth; + + logger.debug({ + at: "Swap/auth", + message: "Response data", + responseJson, + }); + response.status(200).json(responseJson); + } catch (error: unknown) { + return handleErrorCondition("swap/auth", response, logger, error); + } +}; + +export default handler; diff --git a/scripts/tests/_swap-utils.ts b/scripts/tests/_swap-utils.ts index 48d0fa14f..80d2fed66 100644 --- a/scripts/tests/_swap-utils.ts +++ b/scripts/tests/_swap-utils.ts @@ -170,7 +170,7 @@ export function filterTestCases( return filteredTestCases; } -export async function swap(slug: "approval" | "permit") { +export async function swap(slug: "approval" | "permit" | "auth") { const filterString = process.argv[2]; const testCases = [...MIN_OUTPUT_CASES, ...EXACT_OUTPUT_CASES]; const filteredTestCases = filterTestCases(testCases, filterString); diff --git a/scripts/tests/swap-auth.ts b/scripts/tests/swap-auth.ts new file mode 100644 index 000000000..ae676ba54 --- /dev/null +++ b/scripts/tests/swap-auth.ts @@ -0,0 +1,15 @@ +import { swap } from "./_swap-utils"; + +async function swapWithAuthorization() { + console.log("Swapping with authorization..."); + await swap("auth"); +} + +swapWithAuthorization() + .then(() => console.log("Done")) + .catch((e) => { + console.error(e); + if (e.response?.data) { + console.log("Tx for debug sim:", e.response.data.transaction); + } + });