diff --git a/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.test.ts b/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.test.ts new file mode 100644 index 000000000..a8b758466 --- /dev/null +++ b/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.test.ts @@ -0,0 +1,770 @@ +import { basePayActionProvider } from "./basepayActionProvider"; +import { + SendUsdcSchema, + SendUsdcGaslessSchema, + BatchPayUsdcSchema, + CreateEscrowSchema, + SubscribeSchema, +} from "./schemas"; +import { EvmWalletProvider } from "../../wallet-providers"; + +const MOCK_ADDRESS = "0xe6b2af36b3bb8d47206a129ff11d5a2de2a63c83"; +const MOCK_RECIPIENT = "0xaabbccddee112233445566778899001122334455"; +const MOCK_TX_HASH = + "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab" as `0x${string}`; +// 65-byte signature (r + s + v) in hex +const MOCK_SIG = ("0x" + "ab".repeat(32) + "cd".repeat(32) + "1b") as `0x${string}`; + +// ── Schema tests ────────────────────────────────────────────────────────────── + +describe("SendUsdcSchema", () => { + it("parses valid input", () => { + expect(SendUsdcSchema.safeParse({ to: MOCK_RECIPIENT, amount: "10.5" }).success).toBe(true); + }); + it("rejects an invalid address", () => { + expect(SendUsdcSchema.safeParse({ to: "not-an-address", amount: "10" }).success).toBe(false); + }); + it("rejects missing amount", () => { + expect(SendUsdcSchema.safeParse({ to: MOCK_RECIPIENT }).success).toBe(false); + }); +}); + +describe("SendUsdcGaslessSchema", () => { + it("parses valid input", () => { + expect(SendUsdcGaslessSchema.safeParse({ to: MOCK_RECIPIENT, amount: "5" }).success).toBe(true); + }); + it("rejects invalid address", () => { + expect(SendUsdcGaslessSchema.safeParse({ to: "bad", amount: "5" }).success).toBe(false); + }); +}); + +describe("BatchPayUsdcSchema", () => { + it("parses valid recipients", () => { + const result = BatchPayUsdcSchema.safeParse({ + recipients: [{ address: MOCK_RECIPIENT, amount: "5.0" }], + }); + expect(result.success).toBe(true); + }); + it("rejects empty recipients array", () => { + expect(BatchPayUsdcSchema.safeParse({ recipients: [] }).success).toBe(false); + }); + it("rejects more than 200 recipients", () => { + const recipients = Array.from({ length: 201 }, () => ({ + address: MOCK_RECIPIENT, + amount: "1", + })); + expect(BatchPayUsdcSchema.safeParse({ recipients }).success).toBe(false); + }); + it("rejects invalid recipient address", () => { + expect( + BatchPayUsdcSchema.safeParse({ recipients: [{ address: "bad", amount: "1" }] }).success, + ).toBe(false); + }); +}); + +describe("CreateEscrowSchema", () => { + it("parses valid input", () => { + expect( + CreateEscrowSchema.safeParse({ + payee: MOCK_RECIPIENT, + amount: "100", + unlockAfterSeconds: 86400, + }).success, + ).toBe(true); + }); + it("rejects unlock period below minimum (60s)", () => { + expect( + CreateEscrowSchema.safeParse({ payee: MOCK_RECIPIENT, amount: "100", unlockAfterSeconds: 30 }) + .success, + ).toBe(false); + }); + it("rejects invalid payee address", () => { + expect( + CreateEscrowSchema.safeParse({ payee: "bad", amount: "100", unlockAfterSeconds: 86400 }) + .success, + ).toBe(false); + }); +}); + +describe("SubscribeSchema", () => { + it("parses valid monthly subscription", () => { + expect( + SubscribeSchema.safeParse({ payee: MOCK_RECIPIENT, amount: "9.99", intervalSeconds: 2592000 }) + .success, + ).toBe(true); + }); + it("rejects interval below minimum (3600s)", () => { + expect( + SubscribeSchema.safeParse({ payee: MOCK_RECIPIENT, amount: "9.99", intervalSeconds: 60 }) + .success, + ).toBe(false); + }); +}); + +// ── Action tests ────────────────────────────────────────────────────────────── + +describe("BasePay Action Provider", () => { + let mockWallet: jest.Mocked; + const provider = basePayActionProvider(); + + beforeEach(() => { + mockWallet = { + getAddress: jest.fn().mockReturnValue(MOCK_ADDRESS), + getNetwork: jest.fn().mockReturnValue({ chainId: "8453" }), + sendTransaction: jest.fn().mockResolvedValue(MOCK_TX_HASH), + waitForTransactionReceipt: jest.fn().mockResolvedValue({ logs: [] }), + readContract: jest.fn().mockResolvedValue(0n), + signTypedData: jest.fn().mockResolvedValue(MOCK_SIG), + } as unknown as jest.Mocked; + }); + + // ── supportsNetwork ── + describe("supportsNetwork", () => { + it("supports Base Mainnet (chainId 8453)", () => { + expect(provider.supportsNetwork({ chainId: "8453", protocolFamily: "evm" })).toBe(true); + }); + it("does not support Ethereum mainnet", () => { + expect(provider.supportsNetwork({ chainId: "1", protocolFamily: "evm" })).toBe(false); + }); + }); + + // ── sendUsdc ── + describe("sendUsdc", () => { + it("sends USDC and returns a success message with Basescan link", async () => { + const result = await provider.sendUsdc(mockWallet, { to: MOCK_RECIPIENT, amount: "10" }); + expect(mockWallet.sendTransaction).toHaveBeenCalledTimes(1); + expect(mockWallet.waitForTransactionReceipt).toHaveBeenCalledWith(MOCK_TX_HASH); + expect(result).toContain("10 USDC"); + expect(result).toContain(MOCK_RECIPIENT); + expect(result).toContain("basescan.org/tx"); + }); + + it("returns an error message when the transaction fails", async () => { + mockWallet.sendTransaction.mockRejectedValue(new Error("insufficient funds")); + const result = await provider.sendUsdc(mockWallet, { to: MOCK_RECIPIENT, amount: "10" }); + expect(result).toContain("Error sending USDC"); + expect(result).toContain("insufficient funds"); + }); + }); + + // ── batchPayUsdc ── + describe("batchPayUsdc", () => { + const twoRecipients = [ + { address: MOCK_RECIPIENT, amount: "5" }, + { address: "0x1234567890123456789012345678901234567890", amount: "3" }, + ]; + + it("approves then batch-sends when allowance is zero", async () => { + const result = await provider.batchPayUsdc(mockWallet, { + recipients: twoRecipients, + memo: "", + }); + // approve tx + batchSend tx + expect(mockWallet.sendTransaction).toHaveBeenCalledTimes(2); + expect(result).toContain("2 recipients"); + expect(result).toContain("basescan.org/tx"); + }); + + it("skips approve when allowance is already sufficient", async () => { + mockWallet.readContract.mockResolvedValue(BigInt(10_000_000)); // 10 USDC atomic + const result = await provider.batchPayUsdc(mockWallet, { + recipients: [{ address: MOCK_RECIPIENT, amount: "5" }], + memo: "", + }); + // only batchSend — no approve needed + expect(mockWallet.sendTransaction).toHaveBeenCalledTimes(1); + expect(result).toContain("1 recipients"); + }); + + it("returns an error message on contract revert", async () => { + mockWallet.sendTransaction.mockRejectedValue(new Error("execution reverted")); + const result = await provider.batchPayUsdc(mockWallet, { + recipients: twoRecipients, + memo: "", + }); + expect(result).toContain("Error in batch payment"); + expect(result).toContain("execution reverted"); + }); + }); + + // ── createEscrow ── + describe("createEscrow", () => { + const escrowArgs = { + payee: MOCK_RECIPIENT, + amount: "100", + unlockAfterSeconds: 86400, + memo: "test", + }; + + it("approves then creates escrow, returning details", async () => { + const result = await provider.createEscrow(mockWallet, escrowArgs); + expect(mockWallet.sendTransaction).toHaveBeenCalledTimes(2); + expect(result).toContain("Escrow created"); + expect(result).toContain(MOCK_RECIPIENT); + expect(result).toContain("basescan.org/tx"); + }); + + it("includes unlock duration in days", async () => { + const result = await provider.createEscrow(mockWallet, escrowArgs); + expect(result).toContain("1.0 days"); + }); + + it("returns an error message on failure", async () => { + mockWallet.sendTransaction.mockRejectedValue(new Error("revert: paused")); + const result = await provider.createEscrow(mockWallet, escrowArgs); + expect(result).toContain("Error creating escrow"); + }); + }); + + // ── subscribe ── + describe("subscribe", () => { + const subArgs = { payee: MOCK_RECIPIENT, amount: "9.99", intervalSeconds: 2592000, memo: "" }; + + it("approves 24x then subscribes, labelling interval as monthly", async () => { + const result = await provider.subscribe(mockWallet, subArgs); + expect(mockWallet.sendTransaction).toHaveBeenCalledTimes(2); + expect(result).toContain("Subscription created"); + expect(result).toContain("monthly"); + }); + + it("labels weekly interval correctly", async () => { + const result = await provider.subscribe(mockWallet, { ...subArgs, intervalSeconds: 604800 }); + expect(result).toContain("weekly"); + }); + + it("returns an error message on failure", async () => { + mockWallet.sendTransaction.mockRejectedValue(new Error("revert")); + const result = await provider.subscribe(mockWallet, subArgs); + expect(result).toContain("Error creating subscription"); + }); + }); + + // ── sendUsdcGasless ── + describe("sendUsdcGasless", () => { + beforeEach(() => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ txHash: MOCK_TX_HASH }), + } as unknown as Response); + }); + + it("signs EIP-3009 typed data and calls the relay, returning success", async () => { + const result = await provider.sendUsdcGasless(mockWallet, { + to: MOCK_RECIPIENT, + amount: "5", + }); + expect(mockWallet.signTypedData).toHaveBeenCalledTimes(1); + const callArgs = (mockWallet.signTypedData as jest.Mock).mock.calls[0][0]; + expect(callArgs.primaryType).toBe("TransferWithAuthorization"); + expect(result).toContain("5 USDC"); + expect(result).toContain(MOCK_RECIPIENT); + expect(result).toContain("basescan.org/tx"); + }); + + it("returns an error when wallet does not support signTypedData", async () => { + const walletNoSign = { + ...mockWallet, + signTypedData: undefined, + } as unknown as EvmWalletProvider; + const result = await provider.sendUsdcGasless(walletNoSign, { + to: MOCK_RECIPIENT, + amount: "5", + }); + expect(result).toContain("does not support signTypedData"); + }); + + it("returns an error when the relay responds with an error", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + statusText: "Bad Request", + json: async () => ({ error: "invalid signature" }), + } as unknown as Response); + const result = await provider.sendUsdcGasless(mockWallet, { + to: MOCK_RECIPIENT, + amount: "5", + }); + expect(result).toContain("Relay error"); + expect(result).toContain("invalid signature"); + }); + + it("returns an error when the relay fetch throws", async () => { + global.fetch = jest.fn().mockRejectedValue(new Error("network timeout")); + const result = await provider.sendUsdcGasless(mockWallet, { + to: MOCK_RECIPIENT, + amount: "5", + }); + expect(result).toContain("Error calling BasePay relay"); + expect(result).toContain("network timeout"); + }); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// Policy hook: two-layer execution-boundary matrix (Fix 7) +// ══════════════════════════════════════════════════════════════════════════════ +// +// Layer 1 — Authority gate: assertions that fire BEFORE the first irreversible +// operation. Tests assert the operation was NOT called on policy failure. +// +// Layer 2 — Settlement outcome: assertions that fire AFTER the chain/relay +// result. Tests assert the correct outcome tag ([executed], [failed], +// [relay_confirmed]) appears in the return string. +// ══════════════════════════════════════════════════════════════════════════════ + +import { actionContextHash, recipientAllocationHash } from "../../policy/utils"; +import type { PolicyDecision } from "../../policy/interfaces"; + +jest.mock("../../policy/utils", () => ({ + actionContextHash: jest.fn(), + recipientAllocationHash: jest.fn(), +})); + +const MOCK_HASH = "a".repeat(64); + +function decision(overrides?: Partial): PolicyDecision { + return { + allowed: true, + policy_version: "1", + action_context_hash: MOCK_HASH, + decision_ref: "ref-" + Math.random().toString(36).slice(2, 10), + issued_at_ms: Date.now(), + expires_at_ms: Date.now() + 60_000, + ...overrides, + }; +} + +describe("Policy hook — Layer 1: authority gate", () => { + let mockWallet: jest.Mocked; + let mockEvaluate: jest.Mock; + let mockRecord: jest.Mock; + + beforeEach(() => { + (actionContextHash as jest.Mock).mockResolvedValue(MOCK_HASH); + (recipientAllocationHash as jest.Mock).mockResolvedValue(MOCK_HASH); + + mockEvaluate = jest.fn().mockResolvedValue(decision()); + mockRecord = jest.fn().mockResolvedValue(undefined); + mockWallet = { + getAddress: jest.fn().mockReturnValue(MOCK_ADDRESS), + getNetwork: jest.fn().mockReturnValue({ chainId: "8453" }), + sendTransaction: jest.fn().mockResolvedValue(MOCK_TX_HASH), + waitForTransactionReceipt: jest.fn().mockResolvedValue({ status: "success", logs: [] }), + readContract: jest.fn().mockResolvedValue(0n), + signTypedData: jest.fn().mockResolvedValue(MOCK_SIG), + } as unknown as jest.Mocked; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ txHash: MOCK_TX_HASH }), + } as unknown as Response); + }); + + function providerWith() { + return basePayActionProvider({ + policyProvider: { evaluate: mockEvaluate, record: mockRecord }, + }); + } + + // ── sendUsdc: first authority step = sendTransaction ────────────────────── + + describe("sendUsdc — first authority step: sendTransaction", () => { + it("policy_denied blocks before sendTransaction", async () => { + mockEvaluate.mockResolvedValue(decision({ allowed: false, reason_codes: ["spend_limit"] })); + const result = await providerWith().sendUsdc(mockWallet, { + to: MOCK_RECIPIENT, + amount: "10", + }); + expect(result).toContain("policy_denied"); + expect(mockWallet.sendTransaction).not.toHaveBeenCalled(); + expect(mockRecord).toHaveBeenCalledWith( + expect.objectContaining({ + outcome: "denied", + error: "policy_denied: spend_limit", + }), + ); + }); + + it("unbound_execution (missing decision_ref) blocks before sendTransaction", async () => { + mockEvaluate.mockResolvedValue(decision({ decision_ref: "" })); + const result = await providerWith().sendUsdc(mockWallet, { + to: MOCK_RECIPIENT, + amount: "10", + }); + expect(result).toContain("unbound_execution: missing decision_ref"); + expect(mockWallet.sendTransaction).not.toHaveBeenCalled(); + }); + + it("policy_unverifiable (expired TTL) blocks before sendTransaction", async () => { + mockEvaluate.mockResolvedValue(decision({ expires_at_ms: Date.now() - 1000 })); + const result = await providerWith().sendUsdc(mockWallet, { + to: MOCK_RECIPIENT, + amount: "10", + }); + expect(result).toContain("policy_unverifiable"); + expect(mockWallet.sendTransaction).not.toHaveBeenCalled(); + }); + + it("context_drift (hash mismatch) blocks before sendTransaction", async () => { + mockEvaluate.mockResolvedValue(decision({ action_context_hash: "wrong-hash" })); + const result = await providerWith().sendUsdc(mockWallet, { + to: MOCK_RECIPIENT, + amount: "10", + }); + expect(result).toContain("context_drift"); + expect(mockWallet.sendTransaction).not.toHaveBeenCalled(); + }); + + it("duplicate decision_ref blocks second call before sendTransaction", async () => { + const ref = "fixed-ref-abc"; + mockEvaluate.mockResolvedValue(decision({ decision_ref: ref })); + const p = providerWith(); + // First call: succeeds + await p.sendUsdc(mockWallet, { to: MOCK_RECIPIENT, amount: "1" }); + // Second call with same ref: consumed — must not reach sendTransaction again + const sendCallsBefore = (mockWallet.sendTransaction as jest.Mock).mock.calls.length; + const result = await p.sendUsdc(mockWallet, { to: MOCK_RECIPIENT, amount: "1" }); + expect(result).toContain("unbound_execution: duplicate decision_ref"); + expect((mockWallet.sendTransaction as jest.Mock).mock.calls.length).toBe(sendCallsBefore); + }); + }); + + // ── sendUsdcGasless: first authority step = signTypedData ───────────────── + + describe("sendUsdcGasless — first authority step: signTypedData", () => { + it("policy_denied blocks before signTypedData", async () => { + mockEvaluate.mockResolvedValue(decision({ allowed: false })); + const result = await providerWith().sendUsdcGasless(mockWallet, { + to: MOCK_RECIPIENT, + amount: "5", + }); + expect(result).toContain("policy_denied"); + expect(mockWallet.signTypedData).not.toHaveBeenCalled(); + }); + + it("duplicate decision_ref blocks second call before signTypedData", async () => { + const ref = "gasless-fixed-ref"; + mockEvaluate.mockResolvedValue(decision({ decision_ref: ref })); + const p = providerWith(); + await p.sendUsdcGasless(mockWallet, { to: MOCK_RECIPIENT, amount: "5" }); + const signCallsBefore = (mockWallet.signTypedData as jest.Mock).mock.calls.length; + const result = await p.sendUsdcGasless(mockWallet, { to: MOCK_RECIPIENT, amount: "5" }); + expect(result).toContain("unbound_execution: duplicate decision_ref"); + expect((mockWallet.signTypedData as jest.Mock).mock.calls.length).toBe(signCallsBefore); + }); + }); + + // ── batchPayUsdc: first authority step = ensureAllowance ───────────────── + + describe("batchPayUsdc — first authority step: ensureAllowance", () => { + const recipients = [{ address: MOCK_RECIPIENT, amount: "5" }]; + + it("policy_denied blocks before ensureAllowance (readContract not called)", async () => { + mockEvaluate.mockResolvedValue(decision({ allowed: false })); + const result = await providerWith().batchPayUsdc(mockWallet, { recipients, memo: "" }); + expect(result).toContain("policy_denied"); + expect(mockWallet.readContract).not.toHaveBeenCalled(); + }); + + it("duplicate decision_ref blocks second call before ensureAllowance", async () => { + const ref = "batch-fixed-ref"; + mockEvaluate.mockResolvedValue(decision({ decision_ref: ref })); + const p = providerWith(); + await p.batchPayUsdc(mockWallet, { recipients, memo: "" }); + const readCallsBefore = (mockWallet.readContract as jest.Mock).mock.calls.length; + const result = await p.batchPayUsdc(mockWallet, { recipients, memo: "" }); + expect(result).toContain("unbound_execution: duplicate decision_ref"); + expect((mockWallet.readContract as jest.Mock).mock.calls.length).toBe(readCallsBefore); + }); + + it("context_drift: changed recipient_allocation_hash at execution boundary blocks before ensureAllowance", async () => { + // First call to recipientAllocationHash (ctx build) returns MOCK_HASH — matches decision. + // Second call (execution-time re-derivation) returns a different hash — triggers context_drift. + let callCount = 0; + (recipientAllocationHash as jest.Mock).mockImplementation(async () => { + callCount++; + return callCount === 1 ? MOCK_HASH : "execution-hash-differs-" + "b".repeat(44); + }); + const result = await providerWith().batchPayUsdc(mockWallet, { recipients, memo: "" }); + expect(result).toContain("context_drift"); + expect(mockWallet.readContract).not.toHaveBeenCalled(); + expect(mockRecord).toHaveBeenLastCalledWith( + expect.objectContaining({ + outcome: "context_drift", + error: "context_drift", + }), + ); + }); + }); + + // ── createEscrow: first authority step = ensureAllowance ───────────────── + + describe("createEscrow — first authority step: ensureAllowance", () => { + const escrowArgs = { + payee: MOCK_RECIPIENT, + amount: "100", + unlockAfterSeconds: 86400, + memo: "", + }; + + it("policy_denied blocks before ensureAllowance", async () => { + mockEvaluate.mockResolvedValue(decision({ allowed: false })); + const result = await providerWith().createEscrow(mockWallet, escrowArgs); + expect(result).toContain("policy_denied"); + expect(mockWallet.readContract).not.toHaveBeenCalled(); + }); + + it("duplicate decision_ref blocks second call before ensureAllowance", async () => { + const ref = "escrow-fixed-ref"; + mockEvaluate.mockResolvedValue(decision({ decision_ref: ref })); + const p = providerWith(); + await p.createEscrow(mockWallet, escrowArgs); + const readCallsBefore = (mockWallet.readContract as jest.Mock).mock.calls.length; + const result = await p.createEscrow(mockWallet, escrowArgs); + expect(result).toContain("unbound_execution: duplicate decision_ref"); + expect((mockWallet.readContract as jest.Mock).mock.calls.length).toBe(readCallsBefore); + }); + }); + + // ── subscribe (creation): first authority step = ensureAllowance ────────── + + describe("subscribe (creation) — first authority step: ensureAllowance", () => { + const subArgs = { payee: MOCK_RECIPIENT, amount: "9.99", intervalSeconds: 2592000, memo: "" }; + + it("policy_denied blocks before ensureAllowance", async () => { + mockEvaluate.mockResolvedValue(decision({ allowed: false })); + const result = await providerWith().subscribe(mockWallet, subArgs); + expect(result).toContain("policy_denied"); + expect(mockWallet.readContract).not.toHaveBeenCalled(); + }); + + it("duplicate decision_ref blocks second creation before ensureAllowance", async () => { + const ref = "sub-fixed-ref"; + mockEvaluate.mockResolvedValue(decision({ decision_ref: ref })); + const p = providerWith(); + await p.subscribe(mockWallet, subArgs); + const readCallsBefore = (mockWallet.readContract as jest.Mock).mock.calls.length; + const result = await p.subscribe(mockWallet, subArgs); + expect(result).toContain("unbound_execution: duplicate decision_ref"); + expect((mockWallet.readContract as jest.Mock).mock.calls.length).toBe(readCallsBefore); + }); + + it("subscribe without policyProvider succeeds — charge() is a separate authority plane", async () => { + // No policyProvider injected: subscribe proceeds without any policy evaluation. + // This asserts that charge() calls (which happen via on-chain interaction, not + // through this provider) are not governed by the creation decision_ref. + const p = basePayActionProvider(); // no policyProvider + const result = await p.subscribe(mockWallet, subArgs); + expect(mockEvaluate).not.toHaveBeenCalled(); + expect(result).toContain("[executed]"); + }); + }); +}); + +// ── Layer 2: Settlement outcome assertions ──────────────────────────────────── + +describe("Policy hook — Layer 2: settlement outcomes", () => { + let mockWallet: jest.Mocked; + let mockEvaluate: jest.Mock; + let mockRecord: jest.Mock; + + beforeEach(() => { + (actionContextHash as jest.Mock).mockResolvedValue(MOCK_HASH); + (recipientAllocationHash as jest.Mock).mockResolvedValue(MOCK_HASH); + mockEvaluate = jest.fn().mockResolvedValue(decision()); + mockRecord = jest.fn().mockResolvedValue(undefined); + + mockWallet = { + getAddress: jest.fn().mockReturnValue(MOCK_ADDRESS), + getNetwork: jest.fn().mockReturnValue({ chainId: "8453" }), + sendTransaction: jest.fn().mockResolvedValue(MOCK_TX_HASH), + waitForTransactionReceipt: jest.fn().mockResolvedValue({ status: "success", logs: [] }), + readContract: jest.fn().mockResolvedValue(BigInt(999_999_999)), + signTypedData: jest.fn().mockResolvedValue(MOCK_SIG), + } as unknown as jest.Mocked; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ txHash: MOCK_TX_HASH }), + } as unknown as Response); + }); + + const noPolicy = () => basePayActionProvider(); + const withRecordingPolicy = () => + basePayActionProvider({ policyProvider: { evaluate: mockEvaluate, record: mockRecord } }); + + it("sendUsdc: on-chain revert → [failed], not [executed]", async () => { + mockWallet.waitForTransactionReceipt = jest + .fn() + .mockResolvedValue({ status: "reverted", logs: [] }); + const result = await noPolicy().sendUsdc(mockWallet, { to: MOCK_RECIPIENT, amount: "10" }); + expect(result).toContain("[failed]"); + expect(result).not.toContain("[executed]"); + }); + + it("sendUsdc: on-chain success → [executed], not [failed]", async () => { + const result = await noPolicy().sendUsdc(mockWallet, { to: MOCK_RECIPIENT, amount: "10" }); + expect(result).toContain("[executed]"); + expect(result).not.toContain("[failed]"); + }); + + it("sendUsdcGasless: relay HTTP 200 → [relay_confirmed], not [executed]", async () => { + const result = await noPolicy().sendUsdcGasless(mockWallet, { + to: MOCK_RECIPIENT, + amount: "5", + }); + expect(result).toContain("[relay_confirmed]"); + expect(result).not.toContain("[executed]"); + }); + + it("sendUsdc success records executed with tx hash", async () => { + const result = await withRecordingPolicy().sendUsdc(mockWallet, { + to: MOCK_RECIPIENT, + amount: "10", + }); + expect(result).toContain("[executed]"); + expect(mockRecord).toHaveBeenCalledWith( + expect.objectContaining({ + outcome: "executed", + tx_hash: MOCK_TX_HASH, + }), + ); + }); + + it("sendUsdc revert records failed with tx hash", async () => { + mockWallet.waitForTransactionReceipt = jest + .fn() + .mockResolvedValue({ status: "reverted", logs: [] }); + const result = await withRecordingPolicy().sendUsdc(mockWallet, { + to: MOCK_RECIPIENT, + amount: "10", + }); + expect(result).toContain("[failed]"); + expect(mockRecord).toHaveBeenCalledWith( + expect.objectContaining({ + outcome: "failed", + tx_hash: MOCK_TX_HASH, + }), + ); + }); + + it("sendUsdcGasless relay acceptance records relay_confirmed", async () => { + const result = await withRecordingPolicy().sendUsdcGasless(mockWallet, { + to: MOCK_RECIPIENT, + amount: "5", + }); + expect(result).toContain("[relay_confirmed]"); + expect(mockRecord).toHaveBeenCalledWith( + expect.objectContaining({ + outcome: "relay_confirmed", + tx_hash: MOCK_TX_HASH, + }), + ); + }); + + it("batchPayUsdc: on-chain revert → [failed], not [executed]", async () => { + mockWallet.waitForTransactionReceipt = jest + .fn() + .mockResolvedValue({ status: "reverted", logs: [] }); + const result = await noPolicy().batchPayUsdc(mockWallet, { + recipients: [{ address: MOCK_RECIPIENT, amount: "5" }], + memo: "", + }); + expect(result).toContain("[failed]"); + expect(result).not.toContain("[executed]"); + }); + + it("batchPayUsdc: on-chain success → [executed]", async () => { + const result = await noPolicy().batchPayUsdc(mockWallet, { + recipients: [{ address: MOCK_RECIPIENT, amount: "5" }], + memo: "", + }); + expect(result).toContain("[executed]"); + }); + + it("createEscrow: on-chain revert → [failed], not [executed]", async () => { + mockWallet.waitForTransactionReceipt = jest + .fn() + .mockResolvedValue({ status: "reverted", logs: [] }); + const result = await noPolicy().createEscrow(mockWallet, { + payee: MOCK_RECIPIENT, + amount: "100", + unlockAfterSeconds: 86400, + memo: "", + }); + expect(result).toContain("[failed]"); + expect(result).not.toContain("[executed]"); + }); + + it("createEscrow: on-chain success → [executed]", async () => { + const result = await noPolicy().createEscrow(mockWallet, { + payee: MOCK_RECIPIENT, + amount: "100", + unlockAfterSeconds: 86400, + memo: "", + }); + expect(result).toContain("[executed]"); + }); + + it("subscribe: on-chain revert → [failed], not [executed]", async () => { + mockWallet.waitForTransactionReceipt = jest + .fn() + .mockResolvedValue({ status: "reverted", logs: [] }); + const result = await noPolicy().subscribe(mockWallet, { + payee: MOCK_RECIPIENT, + amount: "9.99", + intervalSeconds: 2592000, + memo: "", + }); + expect(result).toContain("[failed]"); + expect(result).not.toContain("[executed]"); + }); + + it("subscribe: on-chain success → [executed]", async () => { + const result = await noPolicy().subscribe(mockWallet, { + payee: MOCK_RECIPIENT, + amount: "9.99", + intervalSeconds: 2592000, + memo: "", + }); + expect(result).toContain("[executed]"); + }); + + describe("policy outcomes are distinct from chain/relay errors", () => { + let mockEvaluate: jest.Mock; + let p: ReturnType; + + beforeEach(() => { + (actionContextHash as jest.Mock).mockResolvedValue(MOCK_HASH); + mockEvaluate = jest.fn(); + p = basePayActionProvider({ policyProvider: { evaluate: mockEvaluate } }); + }); + + it("policy_denied is in the result string — not a sendTransaction error", async () => { + mockEvaluate.mockResolvedValue(decision({ allowed: false })); + const result = await p.sendUsdc(mockWallet, { to: MOCK_RECIPIENT, amount: "1" }); + expect(result).toContain("policy_denied"); + expect(mockWallet.sendTransaction).not.toHaveBeenCalled(); + }); + + it("unbound_execution is in the result string — not a wallet error", async () => { + mockEvaluate.mockResolvedValue(decision({ decision_ref: "" })); + const result = await p.sendUsdc(mockWallet, { to: MOCK_RECIPIENT, amount: "1" }); + expect(result).toContain("unbound_execution: missing decision_ref"); + expect(mockWallet.sendTransaction).not.toHaveBeenCalled(); + }); + + it("policy_unverifiable is in the result string — not a chain error", async () => { + mockEvaluate.mockResolvedValue(decision({ expires_at_ms: Date.now() - 1 })); + const result = await p.sendUsdc(mockWallet, { to: MOCK_RECIPIENT, amount: "1" }); + expect(result).toContain("policy_unverifiable"); + expect(mockWallet.sendTransaction).not.toHaveBeenCalled(); + }); + + it("context_drift is in the result string — not a wallet/relay/chain error", async () => { + mockEvaluate.mockResolvedValue(decision({ action_context_hash: "mismatch" })); + const result = await p.sendUsdc(mockWallet, { to: MOCK_RECIPIENT, amount: "1" }); + expect(result).toContain("context_drift"); + expect(mockWallet.sendTransaction).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts b/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts new file mode 100644 index 000000000..9fdb7263d --- /dev/null +++ b/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts @@ -0,0 +1,687 @@ +import { z } from "zod"; +import { ActionProvider } from "../actionProvider"; +import { Network } from "../../network"; +import { CreateAction } from "../actionDecorator"; +import { + SendUsdcSchema, + SendUsdcGaslessSchema, + BatchPayUsdcSchema, + CreateEscrowSchema, + SubscribeSchema, +} from "./schemas"; +import { encodeFunctionData, parseUnits, formatUnits, type Hex } from "viem"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { + PolicyProvider, + ActionContext, + PolicyDecision, + PolicyOutcome, +} from "../../policy/interfaces"; +import { actionContextHash, recipientAllocationHash } from "../../policy/utils"; + +const BASE_CHAIN_ID = "8453"; +const BASESCAN = "https://basescan.org/tx"; +const DEFAULT_RELAY_URL = "https://base-pay.replit.app"; + +const USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as const; +const USDC_DECIMALS = 6; +const BATCH_PAY = "0xe40d2292c050566d16cecda74627b70778806c68" as const; +const ESCROW_V2 = "0x1eb2b1e8dda64fc4ccb0537574f2a2ca9f307499" as const; +const SUBSCRIPTION_MANAGER = "0x101918a252b3852ac4b50b7bbf2525d3084d5421" as const; + +const ERC20_ABI = [ + { + name: "transfer", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { name: "to", type: "address" }, + { name: "amount", type: "uint256" }, + ], + outputs: [{ name: "", type: "bool" }], + }, + { + name: "approve", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { name: "spender", type: "address" }, + { name: "amount", type: "uint256" }, + ], + outputs: [{ name: "", type: "bool" }], + }, + { + name: "allowance", + type: "function", + stateMutability: "view", + inputs: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + ], + outputs: [{ name: "", type: "uint256" }], + }, +] as const; + +const BATCH_PAY_ABI = [ + { + name: "batchSend", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { name: "token", type: "address" }, + { name: "recipients", type: "address[]" }, + { name: "amounts", type: "uint256[]" }, + { name: "memo", type: "string" }, + ], + outputs: [], + }, +] as const; + +const ESCROW_ABI = [ + { + name: "create", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { name: "token", type: "address" }, + { name: "payee", type: "address" }, + { name: "amount", type: "uint256" }, + { name: "ttl", type: "uint256" }, + { name: "memo", type: "string" }, + ], + outputs: [{ name: "id", type: "uint256" }], + }, +] as const; + +const SUBSCRIPTION_ABI = [ + { + name: "subscribe", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { name: "token", type: "address" }, + { name: "payee", type: "address" }, + { name: "amount", type: "uint256" }, + { name: "interval", type: "uint256" }, + { name: "memo", type: "string" }, + ], + outputs: [{ name: "id", type: "uint256" }], + }, +] as const; + +function toAtomic(human: string): bigint { + return parseUnits(human, USDC_DECIMALS); +} + +function txLink(hash: Hex): string { + return `${BASESCAN}/${hash}`; +} + +async function ensureAllowance( + walletProvider: EvmWalletProvider, + spender: string, + required: bigint, +): Promise { + const owner = walletProvider.getAddress(); + const current = await walletProvider.readContract({ + address: USDC, + abi: ERC20_ABI, + functionName: "allowance", + args: [owner as Hex, spender as Hex], + }); + if (typeof current === "bigint" && current >= required) return null; + + const approveTx = await walletProvider.sendTransaction({ + to: USDC, + data: encodeFunctionData({ + abi: ERC20_ABI, + functionName: "approve", + args: [spender as Hex, required], + }), + }); + await walletProvider.waitForTransactionReceipt(approveTx); + return approveTx; +} + +export interface BasePayConfig { + relayUrl?: string; + policyProvider?: PolicyProvider; +} + +/** + * BasePayActionProvider provides AI agents with USDC payment primitives on Base Mainnet: + * gasless EIP-3009 transfers, batch payments, time-locked escrow, and on-chain subscriptions. + * + * Contracts: https://github.com/osr21/basepay/blob/main/contracts/addresses.json + * BasePay dApp: https://base-pay.replit.app + */ +export class BasePayActionProvider extends ActionProvider { + private readonly relayUrl: string; + private readonly policyProvider?: PolicyProvider; + private readonly pending = new Set(); + private readonly consumed = new Set(); + + constructor(config?: BasePayConfig) { + super("basepay", []); + this.relayUrl = config?.relayUrl ?? DEFAULT_RELAY_URL; + this.policyProvider = config?.policyProvider; + } + + /** + * checkPolicy evaluates the action context against the policy provider. + * + * Fix 1: pending.add(ref) is done here, synchronously, before returning — + * so concurrent calls with the same decision_ref are blocked before any + * async work in the caller, closing the race window in the two-set pattern. + */ + private async recordPolicyOutcome( + decision: PolicyDecision | null, + outcome: PolicyOutcome, + extras: { tx_hash?: Hex; error?: string } = {}, + ): Promise { + if (!decision || !this.policyProvider?.record) return; + + try { + await this.policyProvider.record({ + decision, + outcome, + tx_hash: extras.tx_hash, + error: extras.error, + issued_at_ms: Date.now(), + }); + } catch { + // Receipt recording is best-effort and must not block settlement. + } + } + + private classifyPolicyError(error: unknown): PolicyOutcome { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("policy_denied")) return "denied"; + if (message.includes("policy_unverifiable")) return "expired"; + if (message.includes("context_drift")) return "context_drift"; + if (message.includes("unbound_execution: duplicate")) return "denied"; + if (message.includes("unbound_execution")) return "unauditable_outcome"; + return "failed"; + } + + private async checkPolicy(ctx: ActionContext): Promise { + if (!this.policyProvider) return null; + + const decision = await this.policyProvider.evaluate(ctx); + if (!decision.allowed) { + await this.recordPolicyOutcome(decision, "denied", { + error: `policy_denied: ${decision.reason_codes?.join(", ") || "no reason"}`, + }); + throw new Error(`policy_denied: ${decision.reason_codes?.join(", ") || "no reason"}`); + } + if (!decision.decision_ref) { + await this.recordPolicyOutcome(decision, "unauditable_outcome", { + error: "unbound_execution: missing decision_ref", + }); + throw new Error("unbound_execution: missing decision_ref"); + } + if (Date.now() > decision.expires_at_ms) { + await this.recordPolicyOutcome(decision, "expired", { error: "policy_unverifiable" }); + throw new Error("policy_unverifiable"); + } + + const expectedHash = await actionContextHash(ctx); + if (decision.action_context_hash !== expectedHash) { + await this.recordPolicyOutcome(decision, "context_drift", { error: "context_drift" }); + throw new Error("context_drift"); + } + + if (this.pending.has(decision.decision_ref) || this.consumed.has(decision.decision_ref)) { + await this.recordPolicyOutcome(decision, "denied", { + error: "unbound_execution: duplicate decision_ref", + }); + throw new Error("unbound_execution: duplicate decision_ref"); + } + + // Fix 1: add to pending inside checkPolicy before returning. + // This ensures the concurrent-duplicate guard fires before any caller + // async work, not after checkPolicy returns. + this.pending.add(decision.decision_ref); + return decision; + } + + @CreateAction({ + name: "basepay_send_usdc", + description: ` + Send USDC to any address on Base Mainnet. The agent wallet pays ETH gas. + + Inputs: + - to: recipient Ethereum address (0x…) + - amount: USDC amount as a decimal string (e.g. "10.5" for 10.5 USDC) + + Requirements: agent wallet must hold USDC and ETH for gas (~0.0002 ETH typical). + Returns: transaction hash and Basescan link. + `.trim(), + schema: SendUsdcSchema, + }) + async sendUsdc( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + const ctx: ActionContext = { + action: "basepay_send_usdc", + to: args.to, + amount_usdc: args.amount, + transfer_mechanism: "direct", + }; + + let decision: PolicyDecision | null = null; + let ref = ""; + try { + decision = await this.checkPolicy(ctx); + ref = decision?.decision_ref ?? ""; + // Fix 1 (caller): pending.add is now inside checkPolicy; only consumed.add here. + if (ref) this.consumed.add(ref); + + const hash = await walletProvider.sendTransaction({ + to: USDC, + data: encodeFunctionData({ + abi: ERC20_ABI, + functionName: "transfer", + args: [args.to as Hex, toAtomic(args.amount)], + }), + }); + // Fix 5: classify on-chain revert as [failed], not [executed]. + const receipt = await walletProvider.waitForTransactionReceipt(hash); + if ((receipt as { status?: string }).status === "reverted") { + await this.recordPolicyOutcome(decision, "failed", { tx_hash: hash }); + return `Error: transaction reverted on-chain. [failed]\nTransaction: ${txLink(hash)}`; + } + await this.recordPolicyOutcome(decision, "executed", { tx_hash: hash }); + return `Sent ${args.amount} USDC to ${args.to} [executed]\nTransaction: ${txLink(hash)}`; + } catch (e: unknown) { + await this.recordPolicyOutcome(decision, this.classifyPolicyError(e), { + error: e instanceof Error ? e.message : String(e), + }); + return `Error sending USDC: ${e instanceof Error ? e.message : String(e)}`; + } finally { + if (ref) this.pending.delete(ref); + } + } + + @CreateAction({ + name: "basepay_send_usdc_gasless", + description: ` + Send USDC gaslessly via the BasePay EIP-3009 relay — the relay pays ETH gas, the agent needs NO ETH. + + How it works: + 1. Agent signs a TransferWithAuthorization EIP-712 typed message (no on-chain tx) + 2. BasePay relay submits the authorization to USDC.transferWithAuthorization() + 3. USDC moves directly from agent wallet to recipient + + Inputs: + - to: recipient Ethereum address (0x…) + - amount: USDC decimal (e.g. "5"). Max 1,000,000 USDC. + + Requirements: wallet must support signTypedData (ViemWalletProvider, CdpEvmWalletProvider). + Returns: relay transaction hash and Basescan link. + `.trim(), + schema: SendUsdcGaslessSchema, + }) + async sendUsdcGasless( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + const ctx: ActionContext = { + action: "basepay_send_usdc_gasless", + to: args.to, + amount_usdc: args.amount, + transfer_mechanism: "eip3009", + }; + + let decision: PolicyDecision | null = null; + let ref = ""; + try { + decision = await this.checkPolicy(ctx); + ref = decision?.decision_ref ?? ""; + // Fix 1 (caller): pending.add is inside checkPolicy. + // Fix 2: consumed.add is here, before signTypedData — signing is the first + // irreversible authority step (a signed EIP-3009 auth is spend-capable even + // if the relay is never called). The post-relay consumed.add has been removed. + if (ref) this.consumed.add(ref); + + const wp = walletProvider as EvmWalletProvider & { + signTypedData?: (p: Record) => Promise; + }; + if (typeof wp.signTypedData !== "function") { + await this.recordPolicyOutcome(decision, "failed", { + error: "wallet provider does not support signTypedData", + }); + return ( + "Error: wallet provider does not support signTypedData. " + + "Use ViemWalletProvider or CdpEvmWalletProvider for gasless transfers." + ); + } + + const from = walletProvider.getAddress(); + const value = toAtomic(args.amount); + const validAfter = "0"; + const validBefore = String(Math.floor(Date.now() / 1000) + 3600); + const randomBytes = new Uint8Array(32); + crypto.getRandomValues(randomBytes); + const nonce = ("0x" + + Array.from(randomBytes) + .map(b => b.toString(16).padStart(2, "0")) + .join("")) as Hex; + + let signature: Hex; + try { + signature = await wp.signTypedData({ + domain: { name: "USD Coin", version: "2", chainId: 8453, verifyingContract: USDC }, + types: { + TransferWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + ], + }, + primaryType: "TransferWithAuthorization", + message: { + from, + to: args.to, + value, + validAfter: BigInt(validAfter), + validBefore: BigInt(validBefore), + nonce, + }, + }); + } catch (e: unknown) { + await this.recordPolicyOutcome(decision, "failed", { + error: e instanceof Error ? e.message : String(e), + }); + return `Error signing EIP-3009 authorization: ${e instanceof Error ? e.message : String(e)}`; + } + + const sigHex = signature.slice(2); + const r = ("0x" + sigHex.slice(0, 64)) as Hex; + const s = ("0x" + sigHex.slice(64, 128)) as Hex; + const vByte = parseInt(sigHex.slice(128, 130), 16); + const v = vByte < 27 ? vByte + 27 : vByte; + + try { + const resp = await fetch(`${this.relayUrl}/api/gasless/relay`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + from, + to: args.to, + value: value.toString(), + validAfter, + validBefore, + nonce, + v, + r, + s, + }), + }); + const data = (await resp.json()) as { txHash?: string; error?: string }; + if (!resp.ok || data.error) { + await this.recordPolicyOutcome(decision, "failed", { + error: `Relay error: ${data.error ?? resp.statusText}`, + }); + return `Relay error: ${data.error ?? resp.statusText}`; + } + await this.recordPolicyOutcome(decision, "relay_confirmed", { + tx_hash: data.txHash as Hex, + }); + // Fix 6: relay accepted the authorization and returned a tx hash, but chain + // confirmation is not awaited. Outcome is relay_confirmed, not executed. + return `Gaslessly sent ${args.amount} USDC to ${args.to} (relay paid gas) [relay_confirmed]\nTransaction: ${txLink(data.txHash as Hex)}`; + } catch (e: unknown) { + await this.recordPolicyOutcome(decision, "failed", { + error: e instanceof Error ? e.message : String(e), + }); + return `Error calling BasePay relay: ${e instanceof Error ? e.message : String(e)}`; + } + } finally { + if (ref) this.pending.delete(ref); + } + } + + @CreateAction({ + name: "basepay_batch_pay_usdc", + description: ` + Pay up to 200 recipients USDC atomically in one transaction. Auto-approves allowance if needed. + + Inputs: + - recipients: array of { address, amount } (max 200). address is a 0x Ethereum address; amount is USDC decimal. + - memo: optional string recorded on-chain (max 64 chars) + + Returns: recipient count, total USDC, and Basescan link. + `.trim(), + schema: BatchPayUsdcSchema, + }) + async batchPayUsdc( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + const amounts = args.recipients.map(r => toAtomic(r.amount)); + const total = amounts.reduce((a, b) => a + b, 0n); + + const ctx: ActionContext = { + action: "basepay_batch_pay_usdc", + recipient_allocation_hash: await recipientAllocationHash( + args.recipients.map(r => ({ address: r.address, amount: toAtomic(r.amount) })), + ), + recipient_count: args.recipients.length, + aggregate_usdc: formatUnits(total, USDC_DECIMALS), + transfer_mechanism: "direct", + }; + + let decision: PolicyDecision | null = null; + let ref = ""; + try { + decision = await this.checkPolicy(ctx); + ref = decision?.decision_ref ?? ""; + // Fix 1 (caller): pending.add is inside checkPolicy. + if (ref) this.consumed.add(ref); + + // Fix 3: re-derive recipient_allocation_hash at the execution boundary, + // before any allowance change, to close the TOCTOU window between + // policy evaluation and execution. + if (ctx.recipient_allocation_hash !== undefined) { + const execHash = await recipientAllocationHash( + args.recipients.map(r => ({ address: r.address, amount: toAtomic(r.amount) })), + ); + if (execHash !== ctx.recipient_allocation_hash) { + throw new Error("context_drift"); + } + } + + const approveTx = await ensureAllowance(walletProvider, BATCH_PAY, total); + const hash = await walletProvider.sendTransaction({ + to: BATCH_PAY, + data: encodeFunctionData({ + abi: BATCH_PAY_ABI, + functionName: "batchSend", + args: [USDC, args.recipients.map(r => r.address as Hex), amounts, args.memo], + }), + }); + // Fix 5: classify on-chain revert as [failed]. + const receipt = await walletProvider.waitForTransactionReceipt(hash); + if ((receipt as { status?: string }).status === "reverted") { + await this.recordPolicyOutcome(decision, "failed", { tx_hash: hash }); + return `Error: batch payment reverted on-chain. [failed]\nTransaction: ${txLink(hash)}`; + } + await this.recordPolicyOutcome(decision, "executed", { tx_hash: hash }); + return [ + `Batch payment: ${args.recipients.length} recipients, ${formatUnits(total, USDC_DECIMALS)} USDC [executed]`, + ...(approveTx ? [`Approve: ${txLink(approveTx)}`] : []), + `Batch tx: ${txLink(hash)}`, + ].join("\n"); + } catch (e: unknown) { + await this.recordPolicyOutcome(decision, this.classifyPolicyError(e), { + error: e instanceof Error ? e.message : String(e), + }); + return `Error in batch payment: ${e instanceof Error ? e.message : String(e)}`; + } finally { + if (ref) this.pending.delete(ref); + } + } + + @CreateAction({ + name: "basepay_create_escrow", + description: ` + Lock USDC in a time-locked escrow. The payee can claim after the unlock period; the payer can refund before it. + + Inputs: + - payee: address that can claim USDC after unlock (0x…) + - amount: USDC to lock (e.g. "100") + - unlockAfterSeconds: seconds until the payee can claim (min 60). Example: 86400 = 1 day. + - memo: optional on-chain label (max 64 chars) + + Returns: escrow ID, unlock time, and Basescan links. + `.trim(), + schema: CreateEscrowSchema, + }) + async createEscrow( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + const amount = toAtomic(args.amount); + + const ctx: ActionContext = { + action: "basepay_create_escrow", + to: args.payee, + amount_usdc: args.amount, + transfer_mechanism: "direct", + creates_commitment: true, + }; + + let decision: PolicyDecision | null = null; + let ref = ""; + try { + decision = await this.checkPolicy(ctx); + ref = decision?.decision_ref ?? ""; + // Fix 1 (caller): pending.add is inside checkPolicy. + if (ref) this.consumed.add(ref); + + const approveTx = await ensureAllowance(walletProvider, ESCROW_V2, amount); + const hash = await walletProvider.sendTransaction({ + to: ESCROW_V2, + data: encodeFunctionData({ + abi: ESCROW_ABI, + functionName: "create", + args: [USDC, args.payee as Hex, amount, BigInt(args.unlockAfterSeconds), args.memo], + }), + }); + // Fix 5: classify on-chain revert as [failed]. + const receipt = await walletProvider.waitForTransactionReceipt(hash); + if ((receipt as { status?: string }).status === "reverted") { + await this.recordPolicyOutcome(decision, "failed", { tx_hash: hash }); + return `Error: escrow creation reverted on-chain. [failed]\nTransaction: ${txLink(hash)}`; + } + const escrowId = + (receipt as { logs?: { topics?: string[] }[] })?.logs?.[0]?.topics?.[1] ?? "see tx"; + await this.recordPolicyOutcome(decision, "executed", { tx_hash: hash }); + return [ + `Escrow created: ${args.amount} USDC for ${args.payee} [executed]`, + `Unlock in: ${(args.unlockAfterSeconds / 86400).toFixed(1)} days`, + `Escrow ID: ${escrowId}`, + ...(approveTx ? [`Approve: ${txLink(approveTx)}`] : []), + `Create tx: ${txLink(hash)}`, + ].join("\n"); + } catch (e: unknown) { + await this.recordPolicyOutcome(decision, this.classifyPolicyError(e), { + error: e instanceof Error ? e.message : String(e), + }); + return `Error creating escrow: ${e instanceof Error ? e.message : String(e)}`; + } finally { + if (ref) this.pending.delete(ref); + } + } + + @CreateAction({ + name: "basepay_subscribe", + description: ` + Create a recurring on-chain USDC subscription. Anyone can call charge() once per interval. + + Inputs: + - payee: address that receives USDC each period (0x…) + - amount: USDC per interval (e.g. "9.99") + - intervalSeconds: seconds between charges (e.g. 604800 weekly, 2592000 monthly) + - memo: optional on-chain label (max 64 chars) + + Auto-approves SubscriptionManager for 24× the per-period amount (24 billing cycles). + Returns: subscription ID, Basescan link. + `.trim(), + schema: SubscribeSchema, + }) + async subscribe( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + const amount = toAtomic(args.amount); + + const ctx: ActionContext = { + action: "basepay_subscribe", + to: args.payee, + amount_usdc: args.amount, + transfer_mechanism: "direct", + creates_recurring_obligation: true, + }; + + let decision: PolicyDecision | null = null; + let ref = ""; + try { + decision = await this.checkPolicy(ctx); + ref = decision?.decision_ref ?? ""; + // Fix 1 (caller): pending.add is inside checkPolicy. + // decision_ref scopes to subscription creation only; subsequent charge() + // calls are a separate authority plane and do not inherit this ref. + if (ref) this.consumed.add(ref); + + const approveTx = await ensureAllowance(walletProvider, SUBSCRIPTION_MANAGER, amount * 24n); + const hash = await walletProvider.sendTransaction({ + to: SUBSCRIPTION_MANAGER, + data: encodeFunctionData({ + abi: SUBSCRIPTION_ABI, + functionName: "subscribe", + args: [USDC, args.payee as Hex, amount, BigInt(args.intervalSeconds), args.memo], + }), + }); + // Fix 5: classify on-chain revert as [failed]. + const receipt = await walletProvider.waitForTransactionReceipt(hash); + if ((receipt as { status?: string }).status === "reverted") { + await this.recordPolicyOutcome(decision, "failed", { tx_hash: hash }); + return `Error: subscription creation reverted on-chain. [failed]\nTransaction: ${txLink(hash)}`; + } + const period = + args.intervalSeconds === 604800 + ? "weekly" + : args.intervalSeconds === 2592000 + ? "monthly" + : `every ${args.intervalSeconds}s`; + await this.recordPolicyOutcome(decision, "executed", { tx_hash: hash }); + return [ + `Subscription created: ${args.amount} USDC ${period} to ${args.payee} [executed]`, + `Anyone can call charge() once per interval`, + ...(approveTx ? [`Approve: ${txLink(approveTx)}`] : []), + `Subscribe tx: ${txLink(hash)}`, + ].join("\n"); + } catch (e: unknown) { + await this.recordPolicyOutcome(decision, this.classifyPolicyError(e), { + error: e instanceof Error ? e.message : String(e), + }); + return `Error creating subscription: ${e instanceof Error ? e.message : String(e)}`; + } finally { + if (ref) this.pending.delete(ref); + } + } + + supportsNetwork(network: Network): boolean { + return network.chainId === BASE_CHAIN_ID; + } +} + +export function basePayActionProvider(config?: BasePayConfig): BasePayActionProvider { + return new BasePayActionProvider(config); +} diff --git a/typescript/agentkit/src/action-providers/basepay/index.ts b/typescript/agentkit/src/action-providers/basepay/index.ts new file mode 100644 index 000000000..6fd268e3c --- /dev/null +++ b/typescript/agentkit/src/action-providers/basepay/index.ts @@ -0,0 +1,9 @@ +export { BasePayActionProvider, basePayActionProvider } from "./basepayActionProvider"; +export type { BasePayConfig } from "./basepayActionProvider"; +export { + SendUsdcSchema, + SendUsdcGaslessSchema, + BatchPayUsdcSchema, + CreateEscrowSchema, + SubscribeSchema, +} from "./schemas"; diff --git a/typescript/agentkit/src/action-providers/basepay/schemas.ts b/typescript/agentkit/src/action-providers/basepay/schemas.ts new file mode 100644 index 000000000..fb8eddce4 --- /dev/null +++ b/typescript/agentkit/src/action-providers/basepay/schemas.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; + +const ethAddress = z.string().regex(/^0x[0-9a-fA-F]{40}$/, "Must be a valid 0x Ethereum address"); + +export const SendUsdcSchema = z.object({ + to: ethAddress.describe("Recipient address on Base Mainnet"), + amount: z + .string() + .describe('Amount of USDC to send, as a human-readable decimal (e.g. "10.5" for 10.5 USDC)'), +}); + +export const SendUsdcGaslessSchema = z.object({ + to: ethAddress.describe("Recipient address on Base Mainnet"), + amount: z + .string() + .describe( + 'Amount of USDC to send gaslessly via EIP-3009, as a decimal (e.g. "5" for 5 USDC). ' + + "The BasePay relay pays the ETH gas — the agent wallet needs no ETH for this action.", + ), +}); + +export const BatchPayUsdcSchema = z.object({ + recipients: z + .array( + z.object({ + address: ethAddress.describe("Recipient wallet address"), + amount: z.string().describe('USDC amount for this recipient (e.g. "10.5")'), + }), + ) + .min(1) + .max(200) + .describe("List of recipient address and USDC amount pairs (max 200 entries)."), + memo: z + .string() + .max(64) + .default("") + .describe("Optional note recorded on-chain with the batch payment"), +}); + +export const CreateEscrowSchema = z.object({ + payee: ethAddress.describe( + "Address of the escrow beneficiary who can claim the USDC after the lock period expires", + ), + amount: z.string().describe('Amount of USDC to lock in escrow (e.g. "100" for 100 USDC)'), + unlockAfterSeconds: z + .number() + .int() + .min(60) + .describe( + "Seconds until the payee can claim, or the payer can reclaim. " + + "Examples: 86400 = 1 day, 604800 = 1 week, 2592000 = 30 days", + ), + memo: z.string().max(64).default("").describe("Optional note recorded on-chain with the escrow"), +}); + +export const SubscribeSchema = z.object({ + payee: ethAddress.describe("Address that receives USDC at each billing interval"), + amount: z + .string() + .describe('USDC amount charged per interval (e.g. "9.99" for $9.99 per period)'), + intervalSeconds: z + .number() + .int() + .min(3600) + .describe( + "Seconds between each recurring charge. " + + "Examples: 604800 = weekly, 2592000 = monthly, 31536000 = yearly", + ), + memo: z + .string() + .max(64) + .default("") + .describe("Optional description of the subscription recorded on-chain"), +}); diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 9f7164086..b9f8c2d2b 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -41,3 +41,4 @@ export * from "./zerion"; export * from "./zerodev"; export * from "./zeroX"; export * from "./zora"; +export * from "./basepay"; diff --git a/typescript/agentkit/src/index.ts b/typescript/agentkit/src/index.ts index 5017789c4..57b1ffe10 100644 --- a/typescript/agentkit/src/index.ts +++ b/typescript/agentkit/src/index.ts @@ -2,3 +2,4 @@ export * from "./agentkit"; export * from "./wallet-providers"; export * from "./action-providers"; export * from "./network"; +export * from "./policy"; diff --git a/typescript/agentkit/src/policy/index.ts b/typescript/agentkit/src/policy/index.ts new file mode 100644 index 000000000..795622a6e --- /dev/null +++ b/typescript/agentkit/src/policy/index.ts @@ -0,0 +1,2 @@ +export * from "./interfaces"; +export * from "./utils"; diff --git a/typescript/agentkit/src/policy/interfaces.ts b/typescript/agentkit/src/policy/interfaces.ts new file mode 100644 index 000000000..b6f7ec9c7 --- /dev/null +++ b/typescript/agentkit/src/policy/interfaces.ts @@ -0,0 +1,46 @@ +export interface ActionContext { + action: string; + to?: string; + amount_usdc?: string; + aggregate_usdc?: string; + recipient_count?: number; + recipient_allocation_hash?: string; + per_recipient_max?: string; + transfer_mechanism?: "direct" | "eip3009" | "permit" | "x402"; + creates_recurring_obligation?: boolean; + creates_commitment?: boolean; +} + +export interface PolicyDecision { + allowed: boolean; + reason_codes?: string[]; + signal_refs?: Record; + policy_version: string; + action_context_hash: string; + decision_ref: string; + issued_at_ms: number; + expires_at_ms: number; + signature?: string; +} + +export type PolicyOutcome = + | "executed" + | "relay_confirmed" + | "failed" + | "denied" + | "expired" + | "context_drift" + | "unauditable_outcome"; + +export interface PolicyReceipt { + decision: PolicyDecision; + outcome: PolicyOutcome; + tx_hash?: string; + error?: string; + issued_at_ms: number; +} + +export interface PolicyProvider { + evaluate(ctx: ActionContext): Promise; + record?(receipt: PolicyReceipt): Promise; +} diff --git a/typescript/agentkit/src/policy/utils.ts b/typescript/agentkit/src/policy/utils.ts new file mode 100644 index 000000000..480845eec --- /dev/null +++ b/typescript/agentkit/src/policy/utils.ts @@ -0,0 +1,33 @@ +import canonicalize from "canonicalize"; +import { ActionContext } from "./interfaces"; + +/** + * SHA-256 hash of a string, using the Web Crypto API. + */ +export async function sha256(text: string): Promise { + const msgUint8 = new TextEncoder().encode(text); + const hashBuffer = await crypto.subtle.digest("SHA-256", msgUint8); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map(b => b.toString(16).padStart(2, "0")).join(""); +} + +/** + * recipientAllocationHash: RFC 8785 JCS hash over sorted address+amount pairs. + * Catches address substitution, amount redistribution, and silent reordering. + */ +export async function recipientAllocationHash( + recipients: Array<{ address: string; amount: bigint }>, +): Promise { + const normalized = recipients + .map(r => ({ to: r.address.toLowerCase(), amount_atomic: r.amount.toString() })) + .sort((a, b) => a.to.localeCompare(b.to) || a.amount_atomic.localeCompare(b.amount_atomic)); + return sha256(canonicalize(normalized) ?? "{}"); +} + +/** + * actionContextHash: RFC 8785 JCS hash of an ActionContext. + * Policy-independent content identifier for cross-implementation join keys. + */ +export async function actionContextHash(ctx: ActionContext): Promise { + return sha256(canonicalize(ctx) ?? "{}"); +}