From a72aebcdc7baba25eaedc51fb3301b8ecef1f77f Mon Sep 17 00:00:00 2001 From: SashaMIT Date: Tue, 4 Aug 2026 16:38:59 +0700 Subject: [PATCH 1/2] fix(zerox): exact Permit2 allowance and EIP-712 bind checks Stop granting maxUint256 to Permit2 and reject quote typed-data that does not match the local sell token, amount, chain, and Permit2 verifyingContract. --- .../zeroX/utils.permit2Bind.test.ts | 88 +++++++++++++++++ .../src/action-providers/zeroX/utils.ts | 95 ++++++++++++++++++- .../zeroX/zeroXActionProvider.ts | 23 +++-- 3 files changed, 197 insertions(+), 9 deletions(-) create mode 100644 typescript/agentkit/src/action-providers/zeroX/utils.permit2Bind.test.ts diff --git a/typescript/agentkit/src/action-providers/zeroX/utils.permit2Bind.test.ts b/typescript/agentkit/src/action-providers/zeroX/utils.permit2Bind.test.ts new file mode 100644 index 000000000..c253b9f9e --- /dev/null +++ b/typescript/agentkit/src/action-providers/zeroX/utils.permit2Bind.test.ts @@ -0,0 +1,88 @@ +import { PERMIT2_ADDRESS, assertPermit2Eip712MatchesSwap } from "./utils"; + +const sellToken = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const sellAmount = "1000000"; + +function baseEip712(overrides: Record = {}) { + return { + domain: { + name: "Permit2", + chainId: 8453, + verifyingContract: PERMIT2_ADDRESS, + }, + primaryType: "PermitWitnessTransferFrom", + message: { + permitted: { + token: sellToken, + amount: sellAmount, + }, + }, + ...overrides, + }; +} + +describe("assertPermit2Eip712MatchesSwap", () => { + it("accepts a matching Permit2 payload", () => { + expect(() => + assertPermit2Eip712MatchesSwap({ + eip712: baseEip712(), + chainId: 8453, + sellToken, + sellAmountBaseUnits: sellAmount, + }), + ).not.toThrow(); + }); + + it("rejects wrong verifyingContract", () => { + expect(() => + assertPermit2Eip712MatchesSwap({ + eip712: baseEip712({ + domain: { + name: "Permit2", + chainId: 8453, + verifyingContract: "0x0000000000000000000000000000000000000001", + }, + }), + chainId: 8453, + sellToken, + sellAmountBaseUnits: sellAmount, + }), + ).toThrow(/verifyingContract 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 too low/); + }); +}); diff --git a/typescript/agentkit/src/action-providers/zeroX/utils.ts b/typescript/agentkit/src/action-providers/zeroX/utils.ts index 4d9eee7d5..e3ed91713 100644 --- a/typescript/agentkit/src/action-providers/zeroX/utils.ts +++ b/typescript/agentkit/src/action-providers/zeroX/utils.ts @@ -1,9 +1,102 @@ -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; + 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 | undefined, +): { token?: string; amount?: bigint } { + if (!message) return {}; + + const permitted = message.permitted; + if (permitted && typeof permitted === "object") { + const p = permitted as Record; + 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; +}): void { + const { eip712, chainId, sellToken, sellAmountBaseUnits } = 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 domainChainId = eip712.domain?.chainId; + if (domainChainId !== undefined && 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); + // Allow equal or greater (some quotes pad), but never a smaller authorized sell. + if (amount < expected) { + throw new Error( + `Permit2 EIP-712 amount too low: got ${amount.toString()}, expected at least ${expected.toString()}`, + ); + } +} + /** * Checks if a token is native ETH. * diff --git a/typescript/agentkit/src/action-providers/zeroX/zeroXActionProvider.ts b/typescript/agentkit/src/action-providers/zeroX/zeroXActionProvider.ts index 026feca72..17eea9e36 100644 --- a/typescript/agentkit/src/action-providers/zeroX/zeroXActionProvider.ts +++ b/typescript/agentkit/src/action-providers/zeroX/zeroXActionProvider.ts @@ -8,14 +8,13 @@ import { erc20Abi, formatUnits, parseUnits, - maxUint256, encodeFunctionData, size, concat, Hex, numberToHex, } from "viem"; -import { getTokenDetails, PERMIT2_ADDRESS } from "./utils"; +import { assertPermit2Eip712MatchesSwap, getTokenDetails, PERMIT2_ADDRESS } from "./utils"; /** * Configuration for the ZeroXActionProvider. */ @@ -185,8 +184,8 @@ It takes the following inputs: Important notes: - The contract address for native ETH is "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" - This will execute an actual swap transaction that sends tokens from your wallet -- If needed, it will automatically approve the permit2 contract to spend the sell token -- The approval transaction is only needed once per token +- If needed, it will automatically approve the permit2 contract for this sell amount (not unlimited) +- Permit2 EIP-712 from the quote is checked against local sell token/amount/chain before signing - Ensure you have sufficient balance of the sell token before executing - The trade size might influence the excecution price depending on available liquidity - First fetch a price quote and only execute swap if you are happy with the indicated price @@ -269,8 +268,9 @@ Important notes: }); } - // Check if permit2 approval is needed for ERC20 tokens - // Only needed once per token per address + // Check if permit2 approval is needed for ERC20 tokens. + // Approve only the sell amount for this swap — never maxUint256 — so a + // compromised quote/API path cannot inherit an unlimited Permit2 allowance. let approvalTxHash: Hex | null = null; if (priceData.issues?.allowance) { try { @@ -279,7 +279,7 @@ Important notes: data: encodeFunctionData({ abi: erc20Abi, functionName: "approve", - args: [PERMIT2_ADDRESS, maxUint256], + args: [PERMIT2_ADDRESS, BigInt(sellAmount)], }), }); @@ -325,10 +325,17 @@ Important notes: const quoteData = await quoteResponse.json(); - // Sign Permit2.eip712 returned from quote + // Sign Permit2.eip712 returned from quote — only after binding checks let signature: Hex | undefined; if (quoteData.permit2?.eip712) { try { + assertPermit2Eip712MatchesSwap({ + eip712: quoteData.permit2.eip712, + chainId, + sellToken: args.sellToken, + sellAmountBaseUnits: sellAmount, + }); + const typedData = { domain: quoteData.permit2.eip712.domain, types: quoteData.permit2.eip712.types, From 42c2aee87766b7f2cc657af151b7bfec042de49f Mon Sep 17 00:00:00 2001 From: SashaMIT Date: Wed, 5 Aug 2026 15:02:53 +0700 Subject: [PATCH 2/2] fix(zerox): tighten Permit2 EIP-712 bind (amount/spender/deadline) Require domain.chainId, exact sell amount, optional spender==tx.to, and non-expired deadline before signTypedData. Co-authored-by: Cursor --- .../zeroX/utils.permit2Bind.test.ts | 102 +++++++++++++++++- .../src/action-providers/zeroX/utils.ts | 72 ++++++++++++- .../zeroX/zeroXActionProvider.ts | 4 + 3 files changed, 168 insertions(+), 10 deletions(-) diff --git a/typescript/agentkit/src/action-providers/zeroX/utils.permit2Bind.test.ts b/typescript/agentkit/src/action-providers/zeroX/utils.permit2Bind.test.ts index c253b9f9e..99e950a9e 100644 --- a/typescript/agentkit/src/action-providers/zeroX/utils.permit2Bind.test.ts +++ b/typescript/agentkit/src/action-providers/zeroX/utils.permit2Bind.test.ts @@ -2,9 +2,11 @@ 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 = {}) { - return { + const base = { domain: { name: "Permit2", chainId: 8453, @@ -16,8 +18,19 @@ function baseEip712(overrides: Record = {}) { token: sellToken, amount: sellAmount, }, + spender, + deadline: futureDeadline, }, - ...overrides, + }; + const { domain: domainOverride, message: messageOverride, ...rest } = overrides as { + domain?: Record; + message?: Record; + }; + return { + ...base, + ...rest, + domain: { ...base.domain, ...(domainOverride ?? {}) }, + message: { ...base.message, ...(messageOverride ?? {}) }, }; } @@ -29,6 +42,8 @@ describe("assertPermit2Eip712MatchesSwap", () => { chainId: 8453, sellToken, sellAmountBaseUnits: sellAmount, + expectedSpender: spender, + nowSeconds: 1_700_000_000, }), ).not.toThrow(); }); @@ -38,8 +53,6 @@ describe("assertPermit2Eip712MatchesSwap", () => { assertPermit2Eip712MatchesSwap({ eip712: baseEip712({ domain: { - name: "Permit2", - chainId: 8453, verifyingContract: "0x0000000000000000000000000000000000000001", }, }), @@ -50,6 +63,41 @@ describe("assertPermit2Eip712MatchesSwap", () => { ).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({ @@ -83,6 +131,50 @@ describe("assertPermit2Eip712MatchesSwap", () => { sellToken, sellAmountBaseUnits: sellAmount, }), - ).toThrow(/amount too low/); + ).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/); }); }); diff --git a/typescript/agentkit/src/action-providers/zeroX/utils.ts b/typescript/agentkit/src/action-providers/zeroX/utils.ts index e3ed91713..e340a2f0a 100644 --- a/typescript/agentkit/src/action-providers/zeroX/utils.ts +++ b/typescript/agentkit/src/action-providers/zeroX/utils.ts @@ -57,8 +57,22 @@ export function assertPermit2Eip712MatchesSwap(params: { 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 } = params; + 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"); @@ -69,8 +83,16 @@ export function assertPermit2Eip712MatchesSwap(params: { ); } + 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 && String(domainChainId) !== String(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}`, ); @@ -89,12 +111,52 @@ export function assertPermit2Eip712MatchesSwap(params: { throw new Error("Permit2 EIP-712 message missing amount"); } const expected = BigInt(sellAmountBaseUnits); - // Allow equal or greater (some quotes pad), but never a smaller authorized sell. - if (amount < expected) { + // 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 too low: got ${amount.toString()}, expected at least ${expected.toString()}`, + `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()}`, + ); + } + } } /** diff --git a/typescript/agentkit/src/action-providers/zeroX/zeroXActionProvider.ts b/typescript/agentkit/src/action-providers/zeroX/zeroXActionProvider.ts index 17eea9e36..4a86703ce 100644 --- a/typescript/agentkit/src/action-providers/zeroX/zeroXActionProvider.ts +++ b/typescript/agentkit/src/action-providers/zeroX/zeroXActionProvider.ts @@ -334,6 +334,10 @@ Important notes: chainId, sellToken: args.sellToken, sellAmountBaseUnits: sellAmount, + expectedSpender: + typeof quoteData.transaction?.to === "string" + ? quoteData.transaction.to + : undefined, }); const typedData = {