Skip to content

Commit

Permalink
feat: unified swap endpoint (#1353)
Browse files Browse the repository at this point in the history
* feat: unified swap endpoint

* refactor: change DEFAULT_PERMIT_DEADLINE to a constant in PermitSwapQueryParamsSchema

* fixup
  • Loading branch information
dohaki authored Jan 6, 2025
1 parent 87a0f44 commit 73b19f8
Show file tree
Hide file tree
Showing 3 changed files with 226 additions and 86 deletions.
128 changes: 78 additions & 50 deletions api/_permit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@ import { BigNumberish, ethers } from "ethers";
import { getProvider } from "./_utils";
import { ERC20_PERMIT_ABI } from "./_abis";

export class PermitNotSupportedError extends Error {
constructor(tokenAddress: string, cause?: Error) {
super(`ERC-20 contract ${tokenAddress} does not support permit`, {
cause,
});
}
}

export class PermitDomainSeparatorMismatchError extends Error {
constructor(tokenAddress: string) {
super(`Permit EIP712 domain separator mismatch for ${tokenAddress}`);
}
}

export async function getPermitTypedData(params: {
tokenAddress: string;
chainId: number;
Expand All @@ -11,6 +25,62 @@ export async function getPermitTypedData(params: {
value: BigNumberish;
deadline: number;
eip712DomainVersion?: number;
}) {
const { name, domainSeparator, eip712DomainVersion, nonce } =
await getPermitArgsFromContract(params);

return {
domainSeparator,
eip712: {
types: {
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),
},
},
};
}

export async function getPermitArgsFromContract(params: {
tokenAddress: string;
chainId: number;
ownerAddress: string;
spenderAddress: string;
value: BigNumberish;
eip712DomainVersion?: number;
}) {
const provider = getProvider(params.chainId);
const erc20Permit = new ethers.Contract(
Expand Down Expand Up @@ -44,21 +114,16 @@ export async function getPermitTypedData(params: {
: domainSeparatorResult.status === "rejected"
? domainSeparatorResult.reason
: new Error("Unknown error");
throw new Error(
`ERC-20 contract ${params.tokenAddress} does not support permit`,
{
cause: error,
}
);
throw new PermitNotSupportedError(params.tokenAddress, error);
}

const name = nameResult.value;
const name: string = nameResult.value;
const versionFromContract =
versionFromContractResult.status === "fulfilled"
? versionFromContractResult.value
: undefined;
const nonce = nonceResult.value;
const domainSeparator = domainSeparatorResult.value;
const nonce: BigNumberish = nonceResult.value;
const domainSeparator: string = domainSeparatorResult.value;

const eip712DomainVersion = [1, 2, "1", "2"].includes(versionFromContract)
? Number(versionFromContract)
Expand All @@ -80,50 +145,13 @@ export async function getPermitTypedData(params: {
);

if (domainSeparator !== domainSeparatorHash) {
throw new Error("EIP712 domain separator mismatch");
throw new PermitDomainSeparatorMismatchError(params.tokenAddress);
}

return {
name,
domainSeparator,
eip712: {
types: {
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),
},
},
eip712DomainVersion,
nonce,
};
}
104 changes: 68 additions & 36 deletions api/_transfer-with-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,25 @@ import { getProvider } from "./_utils";
import { ERC_TRANSFER_WITH_AUTH_ABI } from "./_abis";
import { utils } from "ethers";

export class TransferWithAuthNotSupportedError extends Error {
constructor(tokenAddress: string, cause?: Error) {
super(
`ERC-20 contract ${tokenAddress} does not support transfer with authorization`,
{
cause,
}
);
}
}

export class TransferWithAuthDomainSeparatorMismatchError extends Error {
constructor(tokenAddress: string) {
super(
`Transfer with authorization EIP712 domain separator mismatch for ${tokenAddress}`
);
}
}

export function hashDomainSeparator(params: {
name: string;
version: string | number;
Expand Down Expand Up @@ -35,6 +54,49 @@ export async function getReceiveWithAuthTypedData(params: {
nonce: string;
validAfter?: number;
eip712DomainVersion?: number;
}) {
const { name, domainSeparator, eip712DomainVersion } =
await getReceiveWithAuthArgsFromContract(params);

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
},
},
};
}

export async function getReceiveWithAuthArgsFromContract(params: {
tokenAddress: string;
chainId: number;
ownerAddress: string;
spenderAddress: string;
value: BigNumberish;
eip712DomainVersion?: number;
}) {
const provider = getProvider(params.chainId);

Expand All @@ -61,20 +123,15 @@ export async function getReceiveWithAuthTypedData(params: {
: domainSeparatorResult.status === "rejected"
? domainSeparatorResult.reason
: new Error("Unknown error");
throw new Error(
`Contract ${params.tokenAddress} does not support transfer with authorization`,
{
cause: error,
}
);
throw new TransferWithAuthNotSupportedError(params.tokenAddress, error);
}

const name = nameResult.value;
const name: string = nameResult.value;
const versionFromContract =
versionFromContractResult.status === "fulfilled"
? versionFromContractResult.value
: undefined;
const domainSeparator = domainSeparatorResult.value;
const domainSeparator: string = domainSeparatorResult.value;

const eip712DomainVersion = [1, 2, "1", "2"].includes(versionFromContract)
? Number(versionFromContract)
Expand All @@ -88,37 +145,12 @@ export async function getReceiveWithAuthTypedData(params: {
});

if (domainSeparator !== domainSeparatorHash) {
throw new Error("EIP712 domain separator mismatch");
throw new TransferWithAuthDomainSeparatorMismatchError(params.tokenAddress);
}

return {
name,
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
},
},
eip712DomainVersion,
};
}
80 changes: 80 additions & 0 deletions api/swap/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { VercelResponse } from "@vercel/node";

import { TypedVercelRequest } from "../_types";
import {
getLogger,
handleErrorCondition,
resolveVercelEndpoint,
} from "../_utils";
import { handleBaseSwapQueryParams, BaseSwapQueryParams } from "./_utils";
import { getPermitArgsFromContract } from "../_permit";
import { getReceiveWithAuthArgsFromContract } from "../_transfer-with-auth";
import axios from "axios";

type SwapFlowType = "permit" | "transfer-with-auth" | "approval";

function makeSwapHandler(path: string) {
return (params: unknown) =>
axios.get(`${resolveVercelEndpoint()}/${path}`, { params });
}
const swapFlowTypeToHandler = {
permit: makeSwapHandler("permit"),
"transfer-with-auth": makeSwapHandler("auth"),
approval: makeSwapHandler("approval"),
};

export default async function handler(
request: TypedVercelRequest<BaseSwapQueryParams>,
response: VercelResponse
) {
const logger = getLogger();
logger.debug({
at: "Swap",
message: "Query data",
query: request.query,
});
try {
// `/swap` only validate shared base params
const { inputToken, amount, recipient, depositor } =
await handleBaseSwapQueryParams(request.query);

// Determine swap flow by checking if required args and methods are supported
let swapFlowType: SwapFlowType;
const args = {
tokenAddress: inputToken.address,
chainId: inputToken.chainId,
ownerAddress: depositor,
spenderAddress: recipient || depositor,
value: amount,
};
const [permitArgsResult, transferWithAuthArgsResult] =
await Promise.allSettled([
getPermitArgsFromContract(args),
getReceiveWithAuthArgsFromContract(args),
]);

if (permitArgsResult.status === "fulfilled") {
swapFlowType = "permit";
} else if (transferWithAuthArgsResult.status === "fulfilled") {
swapFlowType = "transfer-with-auth";
} else {
swapFlowType = "approval";
}

const handler = swapFlowTypeToHandler[swapFlowType];
const responseJson = await handler(request.query);
const enrichedResponseJson = {
...responseJson,
swapFlowType,
};

logger.debug({
at: "Swap",
message: "Response data",
responseJson: enrichedResponseJson,
});
response.status(200).json(enrichedResponseJson);
} catch (error: unknown) {
return handleErrorCondition("swap", response, logger, error);
}
}

0 comments on commit 73b19f8

Please sign in to comment.