From 44b9024b78035c1fff835dfc06fbe5830c382c5b Mon Sep 17 00:00:00 2001 From: Sasha Mitchell Date: Sat, 8 Aug 2026 09:44:09 +0700 Subject: [PATCH] fix(flaunch): exact Permit2 amountIn and short deadline Permit2 AllowanceTransfer on sellCoin used maxUint160 with a ~10-year expiration/sigDeadline whenever allowance was low. Permit only this sell's amountIn for 30 minutes so leftover UniversalRouter rights cannot linger. Sibling hygiene to CDP exact Permit2 (#1409); distinct provider. --- .../flaunch/flaunchActionProvider.test.ts | 59 ++++++++++++++++++- .../flaunch/flaunchActionProvider.ts | 14 ++--- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/typescript/agentkit/src/action-providers/flaunch/flaunchActionProvider.test.ts b/typescript/agentkit/src/action-providers/flaunch/flaunchActionProvider.test.ts index 45c6c91b4..08b7ca84f 100644 --- a/typescript/agentkit/src/action-providers/flaunch/flaunchActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/flaunch/flaunchActionProvider.test.ts @@ -7,8 +7,7 @@ import { SellCoinSchema, } from "./schemas"; import { EvmWalletProvider } from "../../wallet-providers"; -import { Hex } from "viem"; -import { formatEther } from "viem"; +import { Hex, formatEther, maxUint160, parseEther } from "viem"; import * as swapUtils from "./swap_utils"; // Mock the actual contract calls with Jest @@ -433,6 +432,62 @@ describe("FlaunchActionProvider", () => { expect(mockWalletProvider.signTypedData).toHaveBeenCalled(); }); + it("should permit only amountIn with a short deadline (not maxUint160 / multi-year)", async () => { + const nowMs = 1_700_000_000_000; + const dateNowSpy = jest.spyOn(Date, "now").mockReturnValue(nowMs); + + const args = { + coinAddress: "0x1234567890123456789012345678901234567890", + amountIn: "1000", + slippagePercent: 3, + }; + + try { + await provider.sellCoin(mockWalletProvider, args); + + expect(mockWalletProvider.signTypedData).toHaveBeenCalled(); + const typedData = mockWalletProvider.signTypedData.mock.calls[0][0] as { + message: { + details: { amount: bigint; expiration: number }; + sigDeadline: bigint; + }; + }; + + const expectedAmount = parseEther(args.amountIn); + const expectedDeadline = BigInt(Math.floor(nowMs / 1000) + 30 * 60); + + expect(typedData.message.details.amount).toBe(expectedAmount); + expect(typedData.message.details.amount).not.toBe(maxUint160); + expect(typedData.message.details.expiration).toBe(Number(expectedDeadline)); + expect(typedData.message.sigDeadline).toBe(expectedDeadline); + // Guard against regressing to the prior ~10-year window. + expect(Number(typedData.message.sigDeadline) - Math.floor(nowMs / 1000)).toBeLessThan( + 60 * 60, + ); + } finally { + dateNowSpy.mockRestore(); + } + }); + + it("should skip permit2 signing when allowance already covers amountIn", async () => { + const covered = parseEther("1000"); + mockWalletProvider.readContract.mockImplementation(({ functionName }) => { + if (functionName === "symbol") return "TEST"; + if (functionName === "allowance") return [covered, BigInt(0)]; + return undefined; + }); + + const args = { + coinAddress: "0x1234567890123456789012345678901234567890", + amountIn: "1000", + slippagePercent: 3, + }; + + const result = await provider.sellCoin(mockWalletProvider, args); + expect(result).toContain("Sold"); + expect(mockWalletProvider.signTypedData).not.toHaveBeenCalled(); + }); + it("should handle errors in sellCoin", async () => { mockWalletProvider.sendTransaction.mockRejectedValueOnce(new Error("Transaction failed")); diff --git a/typescript/agentkit/src/action-providers/flaunch/flaunchActionProvider.ts b/typescript/agentkit/src/action-providers/flaunch/flaunchActionProvider.ts index 5bf2cdce7..d8252789f 100644 --- a/typescript/agentkit/src/action-providers/flaunch/flaunchActionProvider.ts +++ b/typescript/agentkit/src/action-providers/flaunch/flaunchActionProvider.ts @@ -9,7 +9,6 @@ import { zeroAddress, Address, formatEther, - maxUint160, Hex, zeroHash, parseUnits, @@ -405,10 +404,11 @@ It takes: let signature: Hex | undefined; let permitSingle: PermitSingle | undefined; - // approve + // Permit2 AllowanceTransfer: approve only this sell's amountIn for a short + // window. Never maxUint160 / multi-year deadlines (leftover spender rights). if (allowance < amountIn) { - // 10 years in seconds - const defaultDeadline = BigInt(Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365 * 10); + const PERMIT2_DEADLINE_SECONDS = 30 * 60; // 30 minutes + const permitDeadline = BigInt(Math.floor(Date.now() / 1000) + PERMIT2_DEADLINE_SECONDS); const domain = { name: "Permit2", @@ -419,12 +419,12 @@ It takes: const message = { details: { token: args.coinAddress as Address, - amount: maxUint160, - expiration: Number(defaultDeadline), + amount: amountIn, + expiration: Number(permitDeadline), nonce, }, spender: UniversalRouterAddress[chainId], - sigDeadline: defaultDeadline, + sigDeadline: permitDeadline, } as PermitSingle; const typedData = {