From 8a7722ce1d768594a4e82778ef8694c6321d3fc6 Mon Sep 17 00:00:00 2001 From: Sasha Mitchell Date: Sat, 8 Aug 2026 09:41:37 +0700 Subject: [PATCH] fix(baseAccount): wait for spend/revoke receipt before success Do not return success:true after sendTransaction alone. Wait for the receipt and require a mined success/complete status so agents do not retry a spend that later reverts (double-spend against remaining allowance). Same confirmation pattern as zeroX/sushi providers. --- .../baseAccountActionProvider.test.ts | 56 ++++++++++++++++++- .../baseAccount/baseAccountActionProvider.ts | 29 ++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/typescript/agentkit/src/action-providers/baseAccount/baseAccountActionProvider.test.ts b/typescript/agentkit/src/action-providers/baseAccount/baseAccountActionProvider.test.ts index 61f7b968f..604879978 100644 --- a/typescript/agentkit/src/action-providers/baseAccount/baseAccountActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/baseAccount/baseAccountActionProvider.test.ts @@ -189,7 +189,7 @@ describe("BaseAccountActionProvider", () => { networkId: "base-mainnet", }), sendTransaction: jest.fn().mockResolvedValue("0xmockTransactionHash"), - waitForTransactionReceipt: jest.fn(), + waitForTransactionReceipt: jest.fn().mockResolvedValue({ status: "success" }), } as unknown as jest.Mocked; // Reset mocks before each test @@ -322,6 +322,36 @@ describe("BaseAccountActionProvider", () => { data: "0xspendCallData", value: BigInt("0x0"), }); + expect(mockWallet.waitForTransactionReceipt).toHaveBeenCalledWith("0xmockTransactionHash"); + }); + + it("should not report success when spend receipt reverts", async () => { + mockFetchPermissions.mockResolvedValue([mockPermission]); + mockGetPermissionStatus.mockResolvedValue(mockPermissionStatus); + mockGetTokenDetails.mockResolvedValue(mockTokenDetails); + mockPrepareSpendCallData.mockResolvedValue([ + { + to: "0xSpendContract", + data: "0xspendCallData", + value: "0x0", + }, + ]); + mockWallet.waitForTransactionReceipt.mockResolvedValue({ status: "reverted" }); + + const args = { + baseAccount: MOCK_BASE_ACCOUNT, + amount: 10.5, + tokenAddress: null, + permissionIndex: null, + }; + + const response = await actionProvider.spendFromBaseAccountPermission(mockWallet, args); + const parsedResponse = JSON.parse(response); + + expect(parsedResponse.success).toBe(false); + expect(parsedResponse.error).toContain("failed or was reverted"); + expect(parsedResponse.transactionHash).toBe("0xmockTransactionHash"); + expect(mockWallet.waitForTransactionReceipt).toHaveBeenCalledWith("0xmockTransactionHash"); }); it("should handle insufficient allowance", async () => { @@ -395,6 +425,30 @@ describe("BaseAccountActionProvider", () => { data: "0xrevokeCallData", value: BigInt("0x0"), }); + expect(mockWallet.waitForTransactionReceipt).toHaveBeenCalledWith("0xmockTransactionHash"); + }); + + it("should not report success when revoke receipt reverts", async () => { + mockFetchPermissions.mockResolvedValue([mockPermission]); + mockPrepareRevokeCallData.mockResolvedValue({ + to: "0xRevokeContract", + data: "0xrevokeCallData", + value: "0x0", + }); + mockWallet.waitForTransactionReceipt.mockResolvedValue({ status: "reverted" }); + + const args = { + baseAccount: MOCK_BASE_ACCOUNT, + permissionIndex: 1, + }; + + const response = await actionProvider.revokeBaseAccountSpendPermission(mockWallet, args); + const parsedResponse = JSON.parse(response); + + expect(parsedResponse.success).toBe(false); + expect(parsedResponse.error).toContain("failed or was reverted"); + expect(parsedResponse.transactionHash).toBe("0xmockTransactionHash"); + expect(mockWallet.waitForTransactionReceipt).toHaveBeenCalledWith("0xmockTransactionHash"); }); it("should handle out of range permission index", async () => { diff --git a/typescript/agentkit/src/action-providers/baseAccount/baseAccountActionProvider.ts b/typescript/agentkit/src/action-providers/baseAccount/baseAccountActionProvider.ts index 9eeb27bef..095d70263 100644 --- a/typescript/agentkit/src/action-providers/baseAccount/baseAccountActionProvider.ts +++ b/typescript/agentkit/src/action-providers/baseAccount/baseAccountActionProvider.ts @@ -312,6 +312,23 @@ Important notes: value: BigInt(callData.value || "0x0"), }); + // Wait for confirmation before reporting success. Returning success on + // broadcast alone lets agents/frameworks retry on later revert, which can + // double-spend against remaining allowance (same class as sushi #1401). + const receipt = await walletProvider.waitForTransactionReceipt(txHash); + if (receipt.status !== "complete" && receipt.status !== "success") { + return JSON.stringify({ + success: false, + transactionHash: txHash, + error: "Spend transaction failed or was reverted", + baseAccount: baseAccount, + spender: spenderAddress, + tokenAddress: permissionTokenAddress, + tokenName: tokenDetails.name, + permissionIndex: permissionIndex, + }); + } + const amountSpentFormatted = formatUnits(amountInAtomicUnits, tokenDetails.decimals); return JSON.stringify({ @@ -417,6 +434,18 @@ Important notes: value: BigInt(revokeCall.value || "0x0"), }); + const receipt = await walletProvider.waitForTransactionReceipt(txHash); + if (receipt.status !== "complete" && receipt.status !== "success") { + return JSON.stringify({ + success: false, + transactionHash: txHash, + error: "Revoke transaction failed or was reverted", + revokedPermissionIndex: permissionIndex, + baseAccount: baseAccount, + spender: spenderAddress, + }); + } + return JSON.stringify({ success: true, transactionHash: txHash,