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/_spoke-pool-periphery.ts b/api/_spoke-pool-periphery.ts index 38c7605ba..14807851f 100644 --- a/api/_spoke-pool-periphery.ts +++ b/api/_spoke-pool-periphery.ts @@ -260,3 +260,49 @@ export function encodeSwapAndBridgeWithPermitCalldata(args: { ] ); } + +export function encodeDepositWithAuthCalldata(args: { + signatureOwner: string; + depositData: SpokePoolV3PeripheryInterface.DepositDataStruct; + validAfter: number; + validBefore: number; + nonce: string; + receiveWithAuthSignature: string; + depositDataSignature: string; +}) { + return SpokePoolV3Periphery__factory.createInterface().encodeFunctionData( + "depositWithAuthorization", + [ + args.signatureOwner, + args.depositData, + args.validAfter, + args.validBefore, + args.nonce, + args.receiveWithAuthSignature, + args.depositDataSignature, + ] + ); +} + +export function encodeSwapAndBridgeWithAuthCalldata(args: { + signatureOwner: string; + swapAndDepositData: SpokePoolV3PeripheryInterface.SwapAndDepositDataStruct; + validAfter: number; + validBefore: number; + nonce: string; + receiveWithAuthSignature: string; + swapAndDepositDataSignature: string; +}) { + return SpokePoolV3Periphery__factory.createInterface().encodeFunctionData( + "swapAndBridgeWithAuthorization", + [ + args.signatureOwner, + args.swapAndDepositData, + args.validAfter, + args.validBefore, + args.nonce, + args.receiveWithAuthSignature, + args.swapAndDepositDataSignature, + ] + ); +} diff --git a/api/_transfer-with-auth.ts b/api/_transfer-with-auth.ts new file mode 100644 index 000000000..a14a3b6d2 --- /dev/null +++ b/api/_transfer-with-auth.ts @@ -0,0 +1,124 @@ +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 getReceiveWithAuthTypedData(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: { + ReceiveWithAuthorization: [ + { 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: "ReceiveWithAuthorization", + message: { + from: params.ownerAddress, + to: params.spenderAddress, + value: String(params.value), + validAfter: params.validAfter ?? 0, + validBefore: params.validBefore, + nonce: params.nonce, // non-sequential nonce, random 32 byte hex string + }, + }, + }; +} diff --git a/api/_utils.ts b/api/_utils.ts index 238c8687f..48992fb4f 100644 --- a/api/_utils.ts +++ b/api/_utils.ts @@ -34,6 +34,7 @@ import { Infer, integer, min, + size, string, Struct, } from "superstruct"; @@ -1509,6 +1510,10 @@ export function hexString() { }); } +export function bytes32() { + return size(hexString(), 66); // inclusive of "0x" +} + /** * Returns the cushion for a given token symbol and route. If no route is specified, the cushion for the token symbol * @param symbol The token symbol @@ -2250,14 +2255,16 @@ export async function getMaxFeePerGas(chainId: number): Promise { * const res = await axios.get(`${base_url}?${queryString}`) * ``` */ - export function buildSearchParams( - params: Record> + params: Record< + string, + number | string | boolean | Array + > ): string { const searchParams = new URLSearchParams(); for (const key in params) { const value = params[key]; - if (!value) continue; + if (value === undefined || value === null) continue; if (Array.isArray(value)) { value.forEach((val) => searchParams.append(key, String(val))); } else { diff --git a/api/relay/_types.ts b/api/relay/_types.ts index 839934d49..95ea1076c 100644 --- a/api/relay/_types.ts +++ b/api/relay/_types.ts @@ -7,7 +7,7 @@ export type RelayRequest = { to: string; methodNameAndArgs: ReturnType; signatures: { - permit: string; + permit: string; // use this for all auth signatures deposit: string; }; }; diff --git a/api/relay/_utils.ts b/api/relay/_utils.ts index 82e41ab49..de5465602 100644 --- a/api/relay/_utils.ts +++ b/api/relay/_utils.ts @@ -1,17 +1,20 @@ import { assert, Infer, type } from "superstruct"; import { utils } from "ethers"; -import { hexString, positiveIntStr, validAddress } from "../_utils"; +import { bytes32, hexString, positiveIntStr, validAddress } from "../_utils"; import { getPermitTypedData } from "../_permit"; import { InvalidParamError } from "../_errors"; import { + encodeDepositWithAuthCalldata, encodeDepositWithPermitCalldata, + encodeSwapAndBridgeWithAuthCalldata, encodeSwapAndBridgeWithPermitCalldata, getDepositTypedData, getSwapAndDepositTypedData, } from "../_spoke-pool-periphery"; import { RelayRequest } from "./_types"; import { redisCache } from "../_cache"; +import { getReceiveWithAuthTypedData } from "../_transfer-with-auth"; export const GAS_SPONSOR_ADDRESS = process.env.GAS_SPONSOR_ADDRESS ?? @@ -47,13 +50,15 @@ const SwapAndDepositDataSchema = type({ routerCalldata: hexString(), }); +const DepositDataSchema = type({ + submissionFees: SubmissionFeesSchema, + baseDepositData: BaseDepositDataSchema, + inputAmount: positiveIntStr(), +}); + export const DepositWithPermitArgsSchema = type({ signatureOwner: validAddress(), - depositData: type({ - submissionFees: SubmissionFeesSchema, - baseDepositData: BaseDepositDataSchema, - inputAmount: positiveIntStr(), - }), + depositData: DepositDataSchema, deadline: positiveIntStr(), }); @@ -63,12 +68,33 @@ export const SwapAndDepositWithPermitArgsSchema = type({ deadline: positiveIntStr(), }); +export const DepositWithAuthArgsSchema = type({ + signatureOwner: validAddress(), + depositData: DepositDataSchema, + validAfter: positiveIntStr(), + validBefore: positiveIntStr(), + nonce: bytes32(), +}); + +export const SwapAndDepositWithAuthArgsSchema = type({ + signatureOwner: validAddress(), + swapAndDepositData: SwapAndDepositDataSchema, + validAfter: positiveIntStr(), + validBefore: positiveIntStr(), + nonce: bytes32(), +}); + export const allowedMethodNames = [ "depositWithPermit", "swapAndBridgeWithPermit", -]; - -export function validateMethodArgs(methodName: string, args: any) { + "depositWithAuthorization", + "swapAndBridgeWithAuthorization", +] as const; + +export function validateMethodArgs( + methodName: (typeof allowedMethodNames)[number], + args: any +) { if (methodName === "depositWithPermit") { assert(args, DepositWithPermitArgsSchema); return { @@ -81,6 +107,18 @@ export function validateMethodArgs(methodName: string, args: any) { args: args as Infer, methodName, } as const; + } else if (methodName === "depositWithAuthorization") { + assert(args, DepositWithAuthArgsSchema); + return { + args: args as Infer, + methodName, + } as const; + } else if (methodName === "swapAndBridgeWithAuthorization") { + assert(args, SwapAndDepositWithAuthArgsSchema); + return { + args: args as Infer, + methodName, + } as const; } throw new Error(`Invalid method name: ${methodName}`); } @@ -94,10 +132,15 @@ export async function verifySignatures({ const { methodName, args } = methodNameAndArgs; let signatureOwner: string; - let getPermitTypedDataPromise: ReturnType; + let getPermitTypedDataPromise: + | ReturnType + | undefined; let getDepositTypedDataPromise: ReturnType< typeof getDepositTypedData | typeof getSwapAndDepositTypedData >; + let getReceiveWithAuthTypedDataPromise: + | ReturnType + | undefined; if (methodName === "depositWithPermit") { const { signatureOwner: _signatureOwner, deadline, depositData } = args; @@ -133,41 +176,123 @@ export async function verifySignatures({ chainId, swapAndDepositData, }); + } else if (methodName === "depositWithAuthorization") { + const { + signatureOwner: _signatureOwner, + validAfter, + validBefore, + nonce, + depositData, + } = args; + signatureOwner = _signatureOwner; + getReceiveWithAuthTypedDataPromise = getReceiveWithAuthTypedData({ + tokenAddress: depositData.baseDepositData.inputToken, + chainId, + ownerAddress: signatureOwner, + spenderAddress: to, // SpokePoolV3Periphery address + value: depositData.inputAmount, + validAfter: Number(validAfter), + validBefore: Number(validBefore), + nonce, + }); + getDepositTypedDataPromise = getDepositTypedData({ + chainId, + depositData, + }); + } else if (methodName === "swapAndBridgeWithAuthorization") { + const { + signatureOwner: _signatureOwner, + validAfter, + validBefore, + nonce, + swapAndDepositData, + } = args; + signatureOwner = _signatureOwner; + getReceiveWithAuthTypedDataPromise = getReceiveWithAuthTypedData({ + tokenAddress: swapAndDepositData.swapToken, + chainId, + ownerAddress: signatureOwner, + spenderAddress: to, // SpokePoolV3Periphery address + value: swapAndDepositData.swapTokenAmount, + validAfter: Number(validAfter), + validBefore: Number(validBefore), + nonce, + }); + getDepositTypedDataPromise = getSwapAndDepositTypedData({ + chainId, + swapAndDepositData, + }); } else { throw new Error( `Can not verify signatures for invalid method name: ${methodName}` ); } - const [permitTypedData, depositTypedData] = await Promise.all([ - getPermitTypedDataPromise, - getDepositTypedDataPromise, - ]); + if (getPermitTypedDataPromise) { + const [permitTypedData, depositTypedData] = await Promise.all([ + getPermitTypedDataPromise, + getDepositTypedDataPromise, + ]); + + const recoveredPermitSignerAddress = utils.verifyTypedData( + permitTypedData.eip712.domain, + permitTypedData.eip712.types, + permitTypedData.eip712.message, + signatures.permit + ); - const recoveredPermitSignerAddress = utils.verifyTypedData( - permitTypedData.eip712.domain, - permitTypedData.eip712.types, - permitTypedData.eip712.message, - signatures.permit - ); - if (recoveredPermitSignerAddress !== signatureOwner) { - throw new InvalidParamError({ - message: "Invalid permit signature", - param: "signatures.permit", - }); - } + if (recoveredPermitSignerAddress !== signatureOwner) { + throw new InvalidParamError({ + message: "Invalid permit signature", + param: "signatures.permit", + }); + } - const recoveredDepositSignerAddress = utils.verifyTypedData( - depositTypedData.eip712.domain, - depositTypedData.eip712.types, - depositTypedData.eip712.message, - signatures.deposit - ); - if (recoveredDepositSignerAddress !== signatureOwner) { - throw new InvalidParamError({ - message: "Invalid deposit signature", - param: "signatures.deposit", - }); + const recoveredDepositSignerAddress = utils.verifyTypedData( + depositTypedData.eip712.domain, + depositTypedData.eip712.types, + depositTypedData.eip712.message, + signatures.deposit + ); + if (recoveredDepositSignerAddress !== signatureOwner) { + throw new InvalidParamError({ + message: "Invalid deposit signature", + param: "signatures.deposit", + }); + } + } else if (getReceiveWithAuthTypedDataPromise) { + const [authTypedData, depositTypedData] = await Promise.all([ + getReceiveWithAuthTypedDataPromise, + getDepositTypedDataPromise, + ]); + + const recoveredAuthSignerAddress = utils.verifyTypedData( + authTypedData.eip712.domain, + authTypedData.eip712.types, + authTypedData.eip712.message, + signatures.permit + ); + + if (recoveredAuthSignerAddress !== signatureOwner) { + throw new InvalidParamError({ + message: "Invalid Authorization signature", + param: "signatures.permit", + }); + } + + const recoveredDepositSignerAddress = utils.verifyTypedData( + depositTypedData.eip712.domain, + depositTypedData.eip712.types, + depositTypedData.eip712.message, + signatures.deposit + ); + + if (recoveredDepositSignerAddress !== signatureOwner) { + throw new InvalidParamError({ + message: "Invalid deposit signature", + param: "signatures.deposit", + }); + } } } @@ -190,12 +315,33 @@ export function encodeCalldataForRelayRequest(request: RelayRequest) { swapAndDepositDataSignature: request.signatures.deposit, permitSignature: request.signatures.permit, }); + } else if ( + request.methodNameAndArgs.methodName === "depositWithAuthorization" + ) { + encodedCalldata = encodeDepositWithAuthCalldata({ + ...request.methodNameAndArgs.args, + validAfter: Number(request.methodNameAndArgs.args.validAfter), + validBefore: Number(request.methodNameAndArgs.args.validBefore), + nonce: request.methodNameAndArgs.args.nonce, + receiveWithAuthSignature: request.signatures.permit, + depositDataSignature: request.signatures.deposit, + }); + } else if ( + request.methodNameAndArgs.methodName === "swapAndBridgeWithAuthorization" + ) { + encodedCalldata = encodeSwapAndBridgeWithAuthCalldata({ + ...request.methodNameAndArgs.args, + validAfter: Number(request.methodNameAndArgs.args.validAfter), + validBefore: Number(request.methodNameAndArgs.args.validBefore), + nonce: request.methodNameAndArgs.args.nonce, + receiveWithAuthSignature: request.signatures.permit, + swapAndDepositDataSignature: request.signatures.deposit, + }); } - // TODO: Add cases for `withAuth` and `withPermit2` + // TODO: Add cases for `withPermit2` else { throw new Error(`Can not encode calldata for relay request`); } - return encodedCalldata; } diff --git a/api/swap/auth/_utils.ts b/api/swap/auth/_utils.ts new file mode 100644 index 000000000..323e7a43a --- /dev/null +++ b/api/swap/auth/_utils.ts @@ -0,0 +1,167 @@ +import { + CrossSwapQuotes, + DepositEntryPointContract, + OriginSwapEntryPointContract, +} from "../../_dexes/types"; +import { getReceiveWithAuthTypedData } from "../../_transfer-with-auth"; +import { + getDepositTypedData, + getSwapAndDepositTypedData, +} from "../../_spoke-pool-periphery"; +import { + extractDepositDataStruct, + extractSwapAndDepositDataStruct, +} from "../../_dexes/utils"; +import { BigNumberish, BytesLike, utils } from "ethers"; +import { SpokePoolV3PeripheryInterface } from "../../_typechain/SpokePoolV3Periphery"; + +export async function buildAuthTxPayload({ + crossSwapQuotes, + authDeadline, + authStart = 0, + submissionFees, +}: { + crossSwapQuotes: CrossSwapQuotes; + authDeadline: number; + authStart?: number; + submissionFees?: { + amount: BigNumberish; + recipient: string; + }; +}) { + const { originSwapQuote, bridgeQuote, crossSwap, contracts } = + crossSwapQuotes; + const originChainId = crossSwap.inputToken.chainId; + const { originSwapEntryPoint, depositEntryPoint, originRouter } = contracts; + + let entryPointContract: + | DepositEntryPointContract + | OriginSwapEntryPointContract; + let getDepositTypedDataPromise: + | ReturnType + | ReturnType; + let methodNameAndArgsWithoutSignatures: + | { + methodName: "depositWithAuthorization"; + argsWithoutSignatures: { + signatureOwner: string; + depositData: SpokePoolV3PeripheryInterface.DepositDataStruct; + validAfter: BigNumberish; + validBefore: BigNumberish; + nonce: BytesLike; + }; + } + | { + methodName: "swapAndBridgeWithAuthorization"; + argsWithoutSignatures: { + signatureOwner: string; + swapAndDepositData: SpokePoolV3PeripheryInterface.SwapAndDepositDataStruct; + validAfter: BigNumberish; + validBefore: BigNumberish; + nonce: BytesLike; + }; + }; + + // random non-sequesntial nonce + const nonce = utils.hexlify(utils.randomBytes(32)); + + const validAfter = authStart; + const validBefore = authDeadline; + + 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` + ); + } + const swapAndDepositData = + await extractSwapAndDepositDataStruct(crossSwapQuotes); + entryPointContract = originSwapEntryPoint; + + getDepositTypedDataPromise = getSwapAndDepositTypedData({ + swapAndDepositData: swapAndDepositData, + chainId: originChainId, + }); + methodNameAndArgsWithoutSignatures = { + methodName: "swapAndBridgeWithAuthorization", + argsWithoutSignatures: { + signatureOwner: crossSwap.depositor, + swapAndDepositData, + validAfter, + validBefore, + nonce, + }, + }; + } 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}'` + ); + } + const depositDataStruct = await extractDepositDataStruct( + crossSwapQuotes, + submissionFees + ); + entryPointContract = depositEntryPoint; + getDepositTypedDataPromise = getDepositTypedData({ + depositData: depositDataStruct, + chainId: originChainId, + }); + methodNameAndArgsWithoutSignatures = { + methodName: "depositWithAuthorization", + argsWithoutSignatures: { + signatureOwner: crossSwap.depositor, + depositData: depositDataStruct, + validAfter, + validBefore, + nonce, + }, + }; + } + + const [authTypedData, depositTypedData] = await Promise.all([ + getReceiveWithAuthTypedData({ + tokenAddress: + originSwapQuote?.tokenIn.address || bridgeQuote.inputToken.address, + chainId: originChainId, + ownerAddress: crossSwap.depositor, + spenderAddress: entryPointContract.address, + value: originSwapQuote?.maximumAmountIn || bridgeQuote.inputAmount, + nonce, + validAfter, + validBefore, + }), + getDepositTypedDataPromise, + ]); + + return { + eip712: { + receiveWithAuthorization: authTypedData.eip712, + deposit: depositTypedData.eip712, + }, + + swapTx: { + chainId: originChainId, + to: entryPointContract.address, + ...methodNameAndArgsWithoutSignatures, + }, + }; +} diff --git a/api/swap/auth/index.ts b/api/swap/auth/index.ts new file mode 100644 index 000000000..21b919b66 --- /dev/null +++ b/api/swap/auth/index.ts @@ -0,0 +1,122 @@ +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, + stringifyBigNumProps, +} from "../_utils"; +import { getSwapRouter02Strategy } from "../../_dexes/uniswap/swap-router-02"; +import { InvalidParamError } from "../../_errors"; +import { QuoteFetchStrategies } from "../../_dexes/utils"; +import { buildAuthTxPayload } from "./_utils"; +import { GAS_SPONSOR_ADDRESS } from "../../relay/_utils"; +import * as sdk from "@across-protocol/sdk"; + +export const authSwapQueryParamsSchema = type({ + authDeadline: optional(positiveIntStr()), +}); + +export type authSwapQueryParams = Infer; + +const DEFAULT_AUTH_DEADLINE = sdk.utils.getCurrentTime() + 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 ?? sdk.utils.getCurrentTime()); + + 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, + // FIXME: Calculate proper fees + submissionFees: { + amount: "0", + recipient: GAS_SPONSOR_ADDRESS, + }, + }); + + const responseJson = stringifyBigNumProps(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 a5598bfb8..6b4f52e4f 100644 --- a/scripts/tests/_swap-utils.ts +++ b/scripts/tests/_swap-utils.ts @@ -169,7 +169,7 @@ export function filterTestCases( return filteredTestCases; } -export async function fetchSwapQuote(slug: "approval" | "permit") { +export async function fetchSwapQuote(slug: "approval" | "permit" | "auth") { const filterString = process.argv[2]; const testCases = [...MIN_OUTPUT_CASES, ...EXACT_OUTPUT_CASES]; const filteredTestCases = filterTestCases(testCases, filterString); @@ -180,6 +180,6 @@ export async function fetchSwapQuote(slug: "approval" | "permit") { params: testCase.params, }); console.log(JSON.stringify(response.data, null, 2)); - return response.data; + return response.data as T; } } diff --git a/scripts/tests/swap-auth.ts b/scripts/tests/swap-auth.ts new file mode 100644 index 000000000..31a6e2467 --- /dev/null +++ b/scripts/tests/swap-auth.ts @@ -0,0 +1,69 @@ +import { Wallet } from "ethers"; + +import { getProvider } from "../../api/_utils"; +import { fetchSwapQuote, SWAP_API_BASE_URL } from "./_swap-utils"; +import { buildAuthTxPayload } from "../../api/swap/auth/_utils"; +import axios from "axios"; + +type AuthPayload = Awaited>; + +async function swapWithAuth() { + console.log("Swapping with auth..."); + const swapQuote = await fetchSwapQuote("auth"); + + if (!swapQuote) { + console.log("No Quote"); + return; + } + + if (process.env.DEV_WALLET_PK) { + const wallet = new Wallet(process.env.DEV_WALLET_PK!).connect( + getProvider(swapQuote.swapTx.chainId) + ); + + // sign permit + deposit + const permitSig = await wallet._signTypedData( + swapQuote.eip712.receiveWithAuthorization.domain, + swapQuote.eip712.receiveWithAuthorization.types, + swapQuote.eip712.receiveWithAuthorization.message + ); + console.log("Signed permit:", permitSig); + + const depositSig = await wallet._signTypedData( + swapQuote.eip712.deposit.domain, + swapQuote.eip712.deposit.types, + swapQuote.eip712.deposit.message + ); + console.log("Signed deposit:", depositSig); + + // relay + const relayResponse = await axios.post(`${SWAP_API_BASE_URL}/api/relay`, { + ...swapQuote.swapTx, + signatures: { permit: permitSig, deposit: depositSig }, + }); + console.log("Relay response:", relayResponse.data); + + // track relay + while (true) { + const relayStatusResponse = await axios.get( + `${SWAP_API_BASE_URL}/api/relay/status?requestHash=${relayResponse.data.requestHash}` + ); + console.log("Relay status response:", relayStatusResponse.data); + + if (relayStatusResponse.data.status === "success") { + break; + } + + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + } +} + +swapWithAuth() + .then(() => console.log("Done")) + .catch((e) => { + console.error(e); + if (e.response?.data) { + console.log("Tx for debug sim:", e.response.data.transaction); + } + }); diff --git a/scripts/tests/swap-permit.ts b/scripts/tests/swap-permit.ts index ab052e48e..ce3c4e409 100644 --- a/scripts/tests/swap-permit.ts +++ b/scripts/tests/swap-permit.ts @@ -2,11 +2,19 @@ import { Wallet } from "ethers"; import { getProvider } from "../../api/_utils"; import { fetchSwapQuote, SWAP_API_BASE_URL } from "./_swap-utils"; +import { buildPermitTxPayload } from "../../api/swap/permit/_utils"; import axios from "axios"; +type PermitPayload = Awaited>; + async function swapWithPermit() { console.log("Swapping with permit..."); - const swapQuote = await fetchSwapQuote("permit"); + const swapQuote = await fetchSwapQuote("permit"); + + if (!swapQuote) { + console.log("No Quote"); + return; + } if (process.env.DEV_WALLET_PK) { const wallet = new Wallet(process.env.DEV_WALLET_PK!).connect(