Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { PERMIT2_ADDRESS, assertPermit2Eip712MatchesSwap } from "./utils";

const sellToken = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const sellAmount = "1000000";
const spender = "0x000000000022D473030F116dDEE9F6B43aC78BA3";
const futureDeadline = "4102444800"; // 2100-01-01

function baseEip712(overrides: Record<string, unknown> = {}) {
const base = {
domain: {
name: "Permit2",
chainId: 8453,
verifyingContract: PERMIT2_ADDRESS,
},
primaryType: "PermitWitnessTransferFrom",
message: {
permitted: {
token: sellToken,
amount: sellAmount,
},
spender,
deadline: futureDeadline,
},
};
const { domain: domainOverride, message: messageOverride, ...rest } = overrides as {
domain?: Record<string, unknown>;
message?: Record<string, unknown>;
};
return {
...base,
...rest,
domain: { ...base.domain, ...(domainOverride ?? {}) },
message: { ...base.message, ...(messageOverride ?? {}) },
};
}

describe("assertPermit2Eip712MatchesSwap", () => {
it("accepts a matching Permit2 payload", () => {
expect(() =>
assertPermit2Eip712MatchesSwap({
eip712: baseEip712(),
chainId: 8453,
sellToken,
sellAmountBaseUnits: sellAmount,
expectedSpender: spender,
nowSeconds: 1_700_000_000,
}),
).not.toThrow();
});

it("rejects wrong verifyingContract", () => {
expect(() =>
assertPermit2Eip712MatchesSwap({
eip712: baseEip712({
domain: {
verifyingContract: "0x0000000000000000000000000000000000000001",
},
}),
chainId: 8453,
sellToken,
sellAmountBaseUnits: sellAmount,
}),
).toThrow(/verifyingContract mismatch/);
});

it("rejects missing chainId", () => {
const eip712 = baseEip712();
delete (eip712.domain as { chainId?: number }).chainId;
expect(() =>
assertPermit2Eip712MatchesSwap({
eip712,
chainId: 8453,
sellToken,
sellAmountBaseUnits: sellAmount,
}),
).toThrow(/chainId missing/);
});

it("rejects chainId mismatch", () => {
expect(() =>
assertPermit2Eip712MatchesSwap({
eip712: baseEip712({ domain: { chainId: 1 } }),
chainId: 8453,
sellToken,
sellAmountBaseUnits: sellAmount,
}),
).toThrow(/chainId mismatch/);
});

it("rejects domain.name mismatch", () => {
expect(() =>
assertPermit2Eip712MatchesSwap({
eip712: baseEip712({ domain: { name: "NotPermit2" } }),
chainId: 8453,
sellToken,
sellAmountBaseUnits: sellAmount,
}),
).toThrow(/domain\.name mismatch/);
});

it("rejects token mismatch", () => {
expect(() =>
assertPermit2Eip712MatchesSwap({
eip712: baseEip712({
message: {
permitted: {
token: "0x0000000000000000000000000000000000000001",
amount: sellAmount,
},
},
}),
chainId: 8453,
sellToken,
sellAmountBaseUnits: sellAmount,
}),
).toThrow(/token mismatch/);
});

it("rejects amount below local sell amount", () => {
expect(() =>
assertPermit2Eip712MatchesSwap({
eip712: baseEip712({
message: {
permitted: {
token: sellToken,
amount: "1",
},
},
}),
chainId: 8453,
sellToken,
sellAmountBaseUnits: sellAmount,
}),
).toThrow(/amount mismatch/);
});

it("rejects amount above local sell amount", () => {
expect(() =>
assertPermit2Eip712MatchesSwap({
eip712: baseEip712({
message: {
permitted: {
token: sellToken,
amount: "999999999999",
},
},
}),
chainId: 8453,
sellToken,
sellAmountBaseUnits: sellAmount,
}),
).toThrow(/amount mismatch/);
});

it("rejects spender mismatch when expectedSpender is set", () => {
expect(() =>
assertPermit2Eip712MatchesSwap({
eip712: baseEip712(),
chainId: 8453,
sellToken,
sellAmountBaseUnits: sellAmount,
expectedSpender: "0x0000000000000000000000000000000000000001",
}),
).toThrow(/spender mismatch/);
});

it("rejects expired deadline", () => {
expect(() =>
assertPermit2Eip712MatchesSwap({
eip712: baseEip712({
message: { deadline: "100" },
}),
chainId: 8453,
sellToken,
sellAmountBaseUnits: sellAmount,
nowSeconds: 1_700_000_000,
}),
).toThrow(/deadline expired/);
});
});
157 changes: 156 additions & 1 deletion typescript/agentkit/src/action-providers/zeroX/utils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,164 @@
import { Hex, erc20Abi } from "viem";
import { Hex, erc20Abi, getAddress, isAddress } from "viem";
import { EvmWalletProvider } from "../../wallet-providers";

// Permit2 contract address is the same across all networks
export const PERMIT2_ADDRESS = "0x000000000022D473030F116dDEE9F6B43aC78BA3";

type Permit2Eip712 = {
domain?: {
verifyingContract?: string;
chainId?: number | string;
name?: string;
};
message?: Record<string, unknown>;
primaryType?: string;
};

/**
* Extracts ERC-20 token + amount from a Permit2-style EIP-712 message.
* Supports both flat and `permitted: { token, amount }` shapes used by 0x.
*/
function extractPermit2TokenAmount(
message: Record<string, unknown> | undefined,
): { token?: string; amount?: bigint } {
if (!message) return {};

const permitted = message.permitted;
if (permitted && typeof permitted === "object") {
const p = permitted as Record<string, unknown>;
const token = typeof p.token === "string" ? p.token : undefined;
const amountRaw = p.amount;
const amount =
typeof amountRaw === "bigint"
? amountRaw
: typeof amountRaw === "string" || typeof amountRaw === "number"
? BigInt(amountRaw)
: undefined;
return { token, amount };
}

const token = typeof message.token === "string" ? message.token : undefined;
const amountRaw = message.amount;
const amount =
typeof amountRaw === "bigint"
? amountRaw
: typeof amountRaw === "string" || typeof amountRaw === "number"
? BigInt(amountRaw)
: undefined;
return { token, amount };
}

/**
* Validates that a quote's Permit2 EIP-712 payload matches local swap intent.
* Rejects blind signing of attacker-controlled typed data from a compromised API path.
*/
export function assertPermit2Eip712MatchesSwap(params: {
eip712: Permit2Eip712;
chainId: string | number;
sellToken: string;
sellAmountBaseUnits: string;
/**
* When set (typically quote.transaction.to), require message.spender to match.
* 0x Permit2 witness transfers use the settlement/allowance-holder as spender.
*/
expectedSpender?: string;
/** Unix seconds; defaults to Date.now()/1000. Deadline must be strictly in the future. */
nowSeconds?: number;
}): void {
const {
eip712,
chainId,
sellToken,
sellAmountBaseUnits,
expectedSpender,
nowSeconds,
} = params;
const verifying = eip712.domain?.verifyingContract;
if (!verifying || !isAddress(verifying)) {
throw new Error("Invalid Permit2 EIP-712 domain.verifyingContract");
}
if (getAddress(verifying) !== getAddress(PERMIT2_ADDRESS)) {
throw new Error(
`Permit2 EIP-712 verifyingContract mismatch: got ${verifying}, expected ${PERMIT2_ADDRESS}`,
);
}

const domainName = eip712.domain?.name;
if (domainName !== undefined && domainName !== "Permit2") {
throw new Error(`Permit2 EIP-712 domain.name mismatch: got ${domainName}, expected Permit2`);
}

const domainChainId = eip712.domain?.chainId;
if (domainChainId === undefined || domainChainId === null || domainChainId === "") {
throw new Error("Permit2 EIP-712 domain.chainId missing");
}
if (String(domainChainId) !== String(chainId)) {
throw new Error(
`Permit2 EIP-712 chainId mismatch: got ${domainChainId}, expected ${chainId}`,
);
}

const { token, amount } = extractPermit2TokenAmount(eip712.message);
if (!token || !isAddress(token)) {
throw new Error("Permit2 EIP-712 message missing token");
}
if (getAddress(token) !== getAddress(sellToken)) {
throw new Error(
`Permit2 EIP-712 token mismatch: got ${token}, expected ${sellToken}`,
);
}
if (amount === undefined) {
throw new Error("Permit2 EIP-712 message missing amount");
}
const expected = BigInt(sellAmountBaseUnits);
// Exact match: padded-high amounts would authorize more than the local sell intent
// (and more than the exact ERC-20 approve above).
if (amount !== expected) {
throw new Error(
`Permit2 EIP-712 amount mismatch: got ${amount.toString()}, expected ${expected.toString()}`,
);
}

const message = eip712.message ?? {};
const spender = typeof message.spender === "string" ? message.spender : undefined;
if (expectedSpender !== undefined) {
if (!spender || !isAddress(spender)) {
throw new Error("Permit2 EIP-712 message missing spender");
}
if (getAddress(spender) !== getAddress(expectedSpender)) {
throw new Error(
`Permit2 EIP-712 spender mismatch: got ${spender}, expected ${expectedSpender}`,
);
}
} else if (spender !== undefined) {
if (!isAddress(spender)) {
throw new Error("Permit2 EIP-712 message has invalid spender");
}
}

const deadlineRaw = message.deadline;
if (deadlineRaw !== undefined && deadlineRaw !== null) {
let deadline: bigint;
try {
if (typeof deadlineRaw === "bigint") {
deadline = deadlineRaw;
} else if (typeof deadlineRaw === "string" || typeof deadlineRaw === "number") {
deadline = BigInt(deadlineRaw);
} else {
throw new Error("Permit2 EIP-712 message has invalid deadline");
}
} catch {
throw new Error("Permit2 EIP-712 message has invalid deadline");
}
const now = BigInt(nowSeconds ?? Math.floor(Date.now() / 1000));
if (deadline <= now) {
throw new Error(
`Permit2 EIP-712 deadline expired: got ${deadline.toString()}, now ${now.toString()}`,
);
}
}
}

/**
* Checks if a token is native ETH.
*
Expand Down
Loading
Loading