From b26a2355888e38bc28b8c7a4bbc40e06b924548b Mon Sep 17 00:00:00 2001 From: osr21 Date: Sun, 21 Jun 2026 03:18:29 +0930 Subject: [PATCH 01/18] feat(basepay): add schemas for BasePay action provider --- .../src/action-providers/basepay/schemas.ts | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 typescript/agentkit/src/action-providers/basepay/schemas.ts 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..67b449086 --- /dev/null +++ b/typescript/agentkit/src/action-providers/basepay/schemas.ts @@ -0,0 +1,91 @@ +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"), + }); + \ No newline at end of file From 074d35a596645bcec54686bd4ab7cc9018332c02 Mon Sep 17 00:00:00 2001 From: osr21 Date: Sun, 21 Jun 2026 03:21:24 +0930 Subject: [PATCH 02/18] feat(basepay): add BasePayActionProvider with 5 USDC actions --- .../basepay/basepayActionProvider.ts | 383 ++++++++++++++++++ 1 file changed, 383 insertions(+) create mode 100644 typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts 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..a79baa381 --- /dev/null +++ b/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts @@ -0,0 +1,383 @@ +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"; + + const BASE_CHAIN_ID = "8453"; + const BASESCAN = "https://basescan.org/tx"; + const DEFAULT_RELAY_URL = "https://base-pay.replit.app"; + + // ── Contract addresses (Base Mainnet) ───────────────────────────────────────── + 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; + + // ── Minimal ABIs ────────────────────────────────────────────────────────────── + 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; + + // ── Helpers ─────────────────────────────────────────────────────────────────── + 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; + } + + /** + * 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; + + constructor(config?: BasePayConfig) { + super("basepay", []); + this.relayUrl = config?.relayUrl ?? DEFAULT_RELAY_URL; + } + + @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 { + try { + const hash = await walletProvider.sendTransaction({ + to: USDC, + data: encodeFunctionData({ + abi: ERC20_ABI, + functionName: "transfer", + args: [args.to as Hex, toAtomic(args.amount)], + }), + }); + await walletProvider.waitForTransactionReceipt(hash); + return `Sent ${args.amount} USDC to ${args.to}\nTransaction: ${txLink(hash)}`; + } catch (e: unknown) { + return `Error sending USDC: ${e instanceof Error ? e.message : String(e)}`; + } + } + + @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 wp = walletProvider as EvmWalletProvider & { + signTypedData?: (p: Record) => Promise; + }; + if (typeof wp.signTypedData !== "function") { + 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) { + 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) return `Relay error: ${data.error ?? resp.statusText}`; + return `Gaslessly sent ${args.amount} USDC to ${args.to} (relay paid gas)\nTransaction: ${txLink(data.txHash as Hex)}`; + } catch (e: unknown) { + return `Error calling BasePay relay: ${e instanceof Error ? e.message : String(e)}`; + } + } + + @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); + try { + 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], + }), + }); + await walletProvider.waitForTransactionReceipt(hash); + return [ + `Batch payment: ${args.recipients.length} recipients, ${formatUnits(total, USDC_DECIMALS)} USDC`, + ...(approveTx ? [`Approve: ${txLink(approveTx)}`] : []), + `Batch tx: ${txLink(hash)}`, + ].join("\n"); + } catch (e: unknown) { + return `Error in batch payment: ${e instanceof Error ? e.message : String(e)}`; + } + } + + @CreateAction({ + name: "basepay_create_escrow", + description: ` + Lock USDC in a time-locked escrow. Payee can claim after the unlock period; payer can reclaim after. + + Inputs: + - payee: beneficiary address (0x…) + - amount: USDC to lock (e.g. "100") + - unlockAfterSeconds: lock duration in seconds (e.g. 86400 = 1 day, 604800 = 1 week) + - memo: optional on-chain label (max 64 chars) + + Returns: escrow ID (needed for release/refund), Basescan link. + `.trim(), + schema: CreateEscrowSchema, + }) + async createEscrow( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + const amount = toAtomic(args.amount); + try { + 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], + }), + }); + const receipt = await walletProvider.waitForTransactionReceipt(hash); + const escrowId = (receipt as { logs?: { topics?: string[] }[] })?.logs?.[0]?.topics?.[1] ?? "see tx"; + return [ + `Escrow created: ${args.amount} USDC for ${args.payee}`, + `Unlock in: ${(args.unlockAfterSeconds / 86400).toFixed(1)} days`, + `Escrow ID: ${escrowId}`, + ...(approveTx ? [`Approve: ${txLink(approveTx)}`] : []), + `Create tx: ${txLink(hash)}`, + ].join("\n"); + } catch (e: unknown) { + return `Error creating escrow: ${e instanceof Error ? e.message : String(e)}`; + } + } + + @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); + try { + 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], + }), + }); + await walletProvider.waitForTransactionReceipt(hash); + const period = args.intervalSeconds === 604800 ? "weekly" + : args.intervalSeconds === 2592000 ? "monthly" + : `every ${args.intervalSeconds}s`; + return [ + `Subscription: ${args.amount} USDC ${period} to ${args.payee}`, + `Anyone can call charge() once per interval`, + ...(approveTx ? [`Approve: ${txLink(approveTx)}`] : []), + `Subscribe tx: ${txLink(hash)}`, + ].join("\n"); + } catch (e: unknown) { + return `Error creating subscription: ${e instanceof Error ? e.message : String(e)}`; + } + } + + supportsNetwork(network: Network): boolean { + return network.chainId === BASE_CHAIN_ID; + } + } + + export function basePayActionProvider(config?: BasePayConfig): BasePayActionProvider { + return new BasePayActionProvider(config); + } + \ No newline at end of file From fe7bd2baf3e0aed79bb152fc2feaf31825dbd735 Mon Sep 17 00:00:00 2001 From: osr21 Date: Sun, 21 Jun 2026 03:22:41 +0930 Subject: [PATCH 03/18] feat(basepay): add basepay index exports --- .../agentkit/src/action-providers/basepay/index.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 typescript/agentkit/src/action-providers/basepay/index.ts 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..60cbda2e5 --- /dev/null +++ b/typescript/agentkit/src/action-providers/basepay/index.ts @@ -0,0 +1,10 @@ +export { BasePayActionProvider, basePayActionProvider } from "./basepayActionProvider"; + export type { BasePayConfig } from "./basepayActionProvider"; + export { + SendUsdcSchema, + SendUsdcGaslessSchema, + BatchPayUsdcSchema, + CreateEscrowSchema, + SubscribeSchema, + } from "./schemas"; + \ No newline at end of file From 787cf925a7ab2517d865fef025116ad4187dad83 Mon Sep 17 00:00:00 2001 From: osr21 Date: Sun, 21 Jun 2026 03:22:42 +0930 Subject: [PATCH 04/18] feat(basepay): export basepay action provider from index --- typescript/agentkit/src/action-providers/index.ts | 1 + 1 file changed, 1 insertion(+) 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"; From 77a1f99475fdf6fa56fd5b771b3d5ce1c883fc25 Mon Sep 17 00:00:00 2001 From: osr21 Date: Sun, 28 Jun 2026 15:09:58 +0930 Subject: [PATCH 05/18] test: add BasePay action provider unit tests --- .../basepay/basepayActionProvider.test.ts | 263 ++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 typescript/agentkit/src/action-providers/basepay/basepayActionProvider.test.ts 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..91410c056 --- /dev/null +++ b/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.test.ts @@ -0,0 +1,263 @@ +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"); + }); + }); + }); + \ No newline at end of file From c156f72608804370a03ac7aba0702725bff1f01c Mon Sep 17 00:00:00 2001 From: Lumen Date: Mon, 29 Jun 2026 09:40:33 +0000 Subject: [PATCH 06/18] feat: implement policy hook scaffolding with two-set atomic gating --- .../basepay/basepayActionProvider.ts | 128 +++++++++++++++++- typescript/agentkit/src/index.ts | 1 + typescript/agentkit/src/policy/index.ts | 2 + typescript/agentkit/src/policy/interfaces.ts | 28 ++++ typescript/agentkit/src/policy/utils.ts | 45 ++++++ 5 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 typescript/agentkit/src/policy/index.ts create mode 100644 typescript/agentkit/src/policy/interfaces.ts create mode 100644 typescript/agentkit/src/policy/utils.ts diff --git a/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts b/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts index a79baa381..9741744da 100644 --- a/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts +++ b/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts @@ -11,6 +11,8 @@ import { z } from "zod"; } from "./schemas"; import { encodeFunctionData, parseUnits, formatUnits, type Hex } from "viem"; import { EvmWalletProvider } from "../../wallet-providers"; + import { PolicyProvider, ActionContext } from "../../policy/interfaces"; + import { actionContextHash, recipientAllocationHash } from "../../policy/utils"; const BASE_CHAIN_ID = "8453"; const BASESCAN = "https://basescan.org/tx"; @@ -99,6 +101,7 @@ import { z } from "zod"; export interface BasePayConfig { relayUrl?: string; + policyProvider?: PolicyProvider; } /** @@ -110,10 +113,32 @@ import { z } from "zod"; */ 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; + } + + private async checkPolicy(ctx: ActionContext): Promise { + if (!this.policyProvider) return ""; + + const decision = await this.policyProvider.evaluate(ctx); + if (!decision.allowed) throw new Error(`policy_denied: ${decision.reason_codes?.join(", ") || "no reason"}`); + if (!decision.decision_ref) throw new Error("unbound_execution"); + if (Date.now() > decision.expires_at_ms) throw new Error("policy_unverifiable"); + + const expectedHash = await actionContextHash(ctx); + if (decision.action_context_hash !== expectedHash) throw new Error("context_drift"); + + if (this.pending.has(decision.decision_ref) || this.consumed.has(decision.decision_ref)) { + throw new Error("unbound_execution"); + } + + return decision.decision_ref; } @CreateAction({ @@ -134,7 +159,21 @@ import { z } from "zod"; walletProvider: EvmWalletProvider, args: z.infer, ): Promise { + const ctx: ActionContext = { + action: "basepay_send_usdc", + to: args.to, + amount_usdc: args.amount, + transfer_mechanism: "direct", + }; + + let ref = ""; try { + ref = await this.checkPolicy(ctx); + if (ref) { + this.pending.add(ref); + this.consumed.add(ref); + } + const hash = await walletProvider.sendTransaction({ to: USDC, data: encodeFunctionData({ @@ -147,6 +186,8 @@ import { z } from "zod"; return `Sent ${args.amount} USDC to ${args.to}\nTransaction: ${txLink(hash)}`; } catch (e: unknown) { return `Error sending USDC: ${e instanceof Error ? e.message : String(e)}`; + } finally { + if (ref) this.pending.delete(ref); } } @@ -173,6 +214,21 @@ import { z } from "zod"; 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 ref = ""; + try { + ref = await this.checkPolicy(ctx); + if (ref) { + this.pending.add(ref); + this.consumed.add(ref); + } + const wp = walletProvider as EvmWalletProvider & { signTypedData?: (p: Record) => Promise; }; @@ -236,10 +292,14 @@ import { z } from "zod"; }); const data = (await resp.json()) as { txHash?: string; error?: string }; if (!resp.ok || data.error) return `Relay error: ${data.error ?? resp.statusText}`; + if (ref) this.consumed.add(ref); return `Gaslessly sent ${args.amount} USDC to ${args.to} (relay paid gas)\nTransaction: ${txLink(data.txHash as Hex)}`; } catch (e: unknown) { return `Error calling BasePay relay: ${e instanceof Error ? e.message : String(e)}`; } + } finally { + if (ref) this.pending.delete(ref); + } } @CreateAction({ @@ -261,7 +321,25 @@ import { z } from "zod"; ): 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 ref = ""; try { + ref = await this.checkPolicy(ctx); + if (ref) { + this.pending.add(ref); + this.consumed.add(ref); + } + const approveTx = await ensureAllowance(walletProvider, BATCH_PAY, total); const hash = await walletProvider.sendTransaction({ to: BATCH_PAY, @@ -279,6 +357,8 @@ import { z } from "zod"; ].join("\n"); } catch (e: unknown) { return `Error in batch payment: ${e instanceof Error ? e.message : String(e)}`; + } finally { + if (ref) this.pending.delete(ref); } } @@ -302,7 +382,23 @@ import { z } from "zod"; 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 ref = ""; try { + ref = await this.checkPolicy(ctx); + if (ref) { + this.pending.add(ref); + this.consumed.add(ref); + } + const approveTx = await ensureAllowance(walletProvider, ESCROW_V2, amount); const hash = await walletProvider.sendTransaction({ to: ESCROW_V2, @@ -313,7 +409,8 @@ import { z } from "zod"; }), }); const receipt = await walletProvider.waitForTransactionReceipt(hash); - const escrowId = (receipt as { logs?: { topics?: string[] }[] })?.logs?.[0]?.topics?.[1] ?? "see tx"; + const escrowId = + (receipt as { logs?: { topics?: string[] }[] })?.logs?.[0]?.topics?.[1] ?? "see tx"; return [ `Escrow created: ${args.amount} USDC for ${args.payee}`, `Unlock in: ${(args.unlockAfterSeconds / 86400).toFixed(1)} days`, @@ -323,6 +420,8 @@ import { z } from "zod"; ].join("\n"); } catch (e: unknown) { return `Error creating escrow: ${e instanceof Error ? e.message : String(e)}`; + } finally { + if (ref) this.pending.delete(ref); } } @@ -347,7 +446,23 @@ import { z } from "zod"; 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 ref = ""; try { + ref = await this.checkPolicy(ctx); + if (ref) { + this.pending.add(ref); + this.consumed.add(ref); + } + const approveTx = await ensureAllowance(walletProvider, SUBSCRIPTION_MANAGER, amount * 24n); const hash = await walletProvider.sendTransaction({ to: SUBSCRIPTION_MANAGER, @@ -358,9 +473,12 @@ import { z } from "zod"; }), }); await walletProvider.waitForTransactionReceipt(hash); - const period = args.intervalSeconds === 604800 ? "weekly" - : args.intervalSeconds === 2592000 ? "monthly" - : `every ${args.intervalSeconds}s`; + const period = + args.intervalSeconds === 604800 + ? "weekly" + : args.intervalSeconds === 2592000 + ? "monthly" + : `every ${args.intervalSeconds}s`; return [ `Subscription: ${args.amount} USDC ${period} to ${args.payee}`, `Anyone can call charge() once per interval`, @@ -369,6 +487,8 @@ import { z } from "zod"; ].join("\n"); } catch (e: unknown) { return `Error creating subscription: ${e instanceof Error ? e.message : String(e)}`; + } finally { + if (ref) this.pending.delete(ref); } } 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..e0b002ef8 --- /dev/null +++ b/typescript/agentkit/src/policy/interfaces.ts @@ -0,0 +1,28 @@ +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 interface PolicyProvider { + evaluate(ctx: ActionContext): Promise; +} diff --git a/typescript/agentkit/src/policy/utils.ts b/typescript/agentkit/src/policy/utils.ts new file mode 100644 index 000000000..eb337f9a1 --- /dev/null +++ b/typescript/agentkit/src/policy/utils.ts @@ -0,0 +1,45 @@ +import { ActionContext } from "./interfaces"; + +/** + * Minimal JCS-like canonicalization for ActionContext. + * Sorts keys and removes undefined values. + */ +export function canonicalize(obj: any): string { + if (obj === null || typeof obj !== "object") return JSON.stringify(obj); + if (Array.isArray(obj)) { + return "[" + obj.map((item) => canonicalize(item)).join(",") + "]"; + } + const keys = Object.keys(obj) + .filter((k) => obj[k] !== undefined) + .sort(); + return "{" + keys.map((k) => `"${k}":${canonicalize(obj[k])}`).join(",") + "}"; +} + +/** + * SHA-256 hash of a string. + */ +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 generates a hash over sorted recipient address+amount pairs. + */ +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 generates the hash of an ActionContext. + */ +export async function actionContextHash(ctx: ActionContext): Promise { + return sha256(canonicalize(ctx)); +} From 2143d4da0569a207f971d95e46d59b0e89603491 Mon Sep 17 00:00:00 2001 From: osr21 Date: Mon, 29 Jun 2026 20:47:47 +0930 Subject: [PATCH 07/18] =?UTF-8?q?fix(basepay):=20policy=20hook=20fixup=20?= =?UTF-8?q?=E2=80=94=20seven=20items?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 1: pending.add(ref) inside checkPolicy before returning; closes race window in two-set pattern where concurrent calls both passed the duplicate check before either had added to pending. Fix 2: remove duplicate consumed.add in sendUsdcGasless; the add before signTypedData (the authority boundary) is kept, the post-relay add removed. Fix 3: re-derive recipient_allocation_hash at execution boundary in batchPayUsdc before ensureAllowance; closes TOCTOU window between policy evaluation and execution. Fix 4: replace home-grown canonicalize with the canonicalize npm package (RFC 8785 JCS); action_context_hash values now compatible with the argenum-core conformance fixture. Fix 5: check receipt.status on all four waitForTransactionReceipt call sites; status: "reverted" produces [failed] not [executed]. Fix 6: sendUsdcGasless returns [relay_confirmed] after relay HTTP 200; relay-submitted is not the same as on-chain-confirmed. Fix 7: two-layer execution-boundary test matrix — Layer 1 (authority gate, before first irreversible op) and Layer 2 (settlement outcome, after chain/relay result) for all five actions plus subscription authority plane. --- .../basepay/basepayActionProvider.test.ts | 360 ++++++++++++++++++ 1 file changed, 360 insertions(+) diff --git a/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.test.ts b/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.test.ts index 91410c056..74ab6b760 100644 --- a/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.test.ts @@ -260,4 +260,364 @@ import { basePayActionProvider } from "./basepayActionProvider"; }); }); }); + + // ══════════════════════════════════════════════════════════════════════════════ + // 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; + + beforeEach(() => { + (actionContextHash as jest.Mock).mockResolvedValue(MOCK_HASH); + (recipientAllocationHash as jest.Mock).mockResolvedValue(MOCK_HASH); + + mockEvaluate = jest.fn().mockResolvedValue(decision()); + 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 } }); + } + + // ── 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(); + }); + + 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"); + 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"); + 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"); + 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"); + 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(); + }); + }); + + // ── 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"); + 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"); + 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; + + beforeEach(() => { + (actionContextHash as jest.Mock).mockResolvedValue(MOCK_HASH); + (recipientAllocationHash as jest.Mock).mockResolvedValue(MOCK_HASH); + + 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(); + + 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("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"); + 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(); + }); + }); + }); \ No newline at end of file From 92cf7eedae3aeaa0f0677202c2e677fe74fe820d Mon Sep 17 00:00:00 2001 From: osr21 Date: Mon, 29 Jun 2026 20:48:20 +0930 Subject: [PATCH 08/18] =?UTF-8?q?fix(basepay):=20policy=20hook=20fixup=20?= =?UTF-8?q?=E2=80=94=20seven=20items?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- typescript/agentkit/src/policy/interfaces.ts | 29 ++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 typescript/agentkit/src/policy/interfaces.ts diff --git a/typescript/agentkit/src/policy/interfaces.ts b/typescript/agentkit/src/policy/interfaces.ts new file mode 100644 index 000000000..8d21cf220 --- /dev/null +++ b/typescript/agentkit/src/policy/interfaces.ts @@ -0,0 +1,29 @@ +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 interface PolicyProvider { + evaluate(ctx: ActionContext): Promise; + } + \ No newline at end of file From 242ccaea017b5f507ac9794cd91781dfaddcb131 Mon Sep 17 00:00:00 2001 From: osr21 Date: Mon, 29 Jun 2026 20:48:21 +0930 Subject: [PATCH 09/18] =?UTF-8?q?fix(basepay):=20policy=20hook=20fixup=20?= =?UTF-8?q?=E2=80=94=20seven=20items?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- typescript/agentkit/src/policy/utils.ts | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 typescript/agentkit/src/policy/utils.ts diff --git a/typescript/agentkit/src/policy/utils.ts b/typescript/agentkit/src/policy/utils.ts new file mode 100644 index 000000000..217232004 --- /dev/null +++ b/typescript/agentkit/src/policy/utils.ts @@ -0,0 +1,34 @@ +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) ?? "{}"); + } + \ No newline at end of file From 7086b229dd848e7549ac8402f72031682d0ecf0a Mon Sep 17 00:00:00 2001 From: osr21 Date: Mon, 29 Jun 2026 20:48:22 +0930 Subject: [PATCH 10/18] =?UTF-8?q?fix(basepay):=20policy=20hook=20fixup=20?= =?UTF-8?q?=E2=80=94=20seven=20items?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- typescript/agentkit/src/policy/index.ts | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 typescript/agentkit/src/policy/index.ts diff --git a/typescript/agentkit/src/policy/index.ts b/typescript/agentkit/src/policy/index.ts new file mode 100644 index 000000000..beda9073d --- /dev/null +++ b/typescript/agentkit/src/policy/index.ts @@ -0,0 +1,3 @@ +export * from "./interfaces"; + export * from "./utils"; + \ No newline at end of file From 56dbbeaa115e070af4e94f4b0c3e870d9fd102fe Mon Sep 17 00:00:00 2001 From: osr21 Date: Mon, 29 Jun 2026 20:48:23 +0930 Subject: [PATCH 11/18] =?UTF-8?q?fix(basepay):=20policy=20hook=20fixup=20?= =?UTF-8?q?=E2=80=94=20seven=20items?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../basepay/basepayActionProvider.ts | 314 +++++++++++++----- 1 file changed, 233 insertions(+), 81 deletions(-) diff --git a/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts b/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts index a79baa381..bf29e5fb5 100644 --- a/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts +++ b/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts @@ -11,19 +11,19 @@ import { z } from "zod"; } from "./schemas"; import { encodeFunctionData, parseUnits, formatUnits, type Hex } from "viem"; import { EvmWalletProvider } from "../../wallet-providers"; + import { PolicyProvider, ActionContext } 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"; - // ── Contract addresses (Base Mainnet) ───────────────────────────────────────── 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; - // ── Minimal ABIs ────────────────────────────────────────────────────────────── const ERC20_ABI = [ { name: "transfer", type: "function", stateMutability: "nonpayable", inputs: [{ name: "to", type: "address" }, { name: "amount", type: "uint256" }], @@ -62,7 +62,6 @@ import { z } from "zod"; ], outputs: [{ name: "id", type: "uint256" }] }, ] as const; - // ── Helpers ─────────────────────────────────────────────────────────────────── function toAtomic(human: string): bigint { return parseUnits(human, USDC_DECIMALS); } @@ -99,6 +98,7 @@ import { z } from "zod"; export interface BasePayConfig { relayUrl?: string; + policyProvider?: PolicyProvider; } /** @@ -110,10 +110,43 @@ import { z } from "zod"; */ 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 checkPolicy(ctx: ActionContext): Promise { + if (!this.policyProvider) return ""; + + const decision = await this.policyProvider.evaluate(ctx); + if (!decision.allowed) throw new Error(`policy_denied: ${decision.reason_codes?.join(", ") || "no reason"}`); + if (!decision.decision_ref) throw new Error("unbound_execution"); + if (Date.now() > decision.expires_at_ms) throw new Error("policy_unverifiable"); + + const expectedHash = await actionContextHash(ctx); + if (decision.action_context_hash !== expectedHash) throw new Error("context_drift"); + + if (this.pending.has(decision.decision_ref) || this.consumed.has(decision.decision_ref)) { + throw new Error("unbound_execution"); + } + + // 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.decision_ref; } @CreateAction({ @@ -134,7 +167,19 @@ import { z } from "zod"; walletProvider: EvmWalletProvider, args: z.infer, ): Promise { + const ctx: ActionContext = { + action: "basepay_send_usdc", + to: args.to, + amount_usdc: args.amount, + transfer_mechanism: "direct", + }; + + let ref = ""; try { + ref = await this.checkPolicy(ctx); + // 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({ @@ -143,10 +188,16 @@ import { z } from "zod"; args: [args.to as Hex, toAtomic(args.amount)], }), }); - await walletProvider.waitForTransactionReceipt(hash); - return `Sent ${args.amount} USDC to ${args.to}\nTransaction: ${txLink(hash)}`; + // Fix 5: classify on-chain revert as [failed], not [executed]. + const receipt = await walletProvider.waitForTransactionReceipt(hash); + if ((receipt as { status?: string }).status === "reverted") { + return `Error: transaction reverted on-chain. [failed]\nTransaction: ${txLink(hash)}`; + } + return `Sent ${args.amount} USDC to ${args.to} [executed]\nTransaction: ${txLink(hash)}`; } catch (e: unknown) { return `Error sending USDC: ${e instanceof Error ? e.message : String(e)}`; + } finally { + if (ref) this.pending.delete(ref); } } @@ -173,72 +224,93 @@ import { z } from "zod"; walletProvider: EvmWalletProvider, args: z.infer, ): Promise { - const wp = walletProvider as EvmWalletProvider & { - signTypedData?: (p: Record) => Promise; + const ctx: ActionContext = { + action: "basepay_send_usdc_gasless", + to: args.to, + amount_usdc: args.amount, + transfer_mechanism: "eip3009", }; - if (typeof wp.signTypedData !== "function") { - 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; + let ref = ""; 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) { - 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) return `Relay error: ${data.error ?? resp.statusText}`; - return `Gaslessly sent ${args.amount} USDC to ${args.to} (relay paid gas)\nTransaction: ${txLink(data.txHash as Hex)}`; - } catch (e: unknown) { - return `Error calling BasePay relay: ${e instanceof Error ? e.message : String(e)}`; + ref = await this.checkPolicy(ctx); + // 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") { + 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) { + 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) return `Relay error: ${data.error ?? resp.statusText}`; + // 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) { + return `Error calling BasePay relay: ${e instanceof Error ? e.message : String(e)}`; + } + } finally { + if (ref) this.pending.delete(ref); } } @@ -261,7 +333,35 @@ import { z } from "zod"; ): 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 ref = ""; try { + ref = await this.checkPolicy(ctx); + // 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, @@ -271,29 +371,35 @@ import { z } from "zod"; args: [USDC, args.recipients.map((r) => r.address as Hex), amounts, args.memo], }), }); - await walletProvider.waitForTransactionReceipt(hash); + // Fix 5: classify on-chain revert as [failed]. + const receipt = await walletProvider.waitForTransactionReceipt(hash); + if ((receipt as { status?: string }).status === "reverted") { + return `Error: batch payment reverted on-chain. [failed]\nTransaction: ${txLink(hash)}`; + } return [ - `Batch payment: ${args.recipients.length} recipients, ${formatUnits(total, USDC_DECIMALS)} USDC`, + `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) { 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. Payee can claim after the unlock period; payer can reclaim after. + Lock USDC in a time-locked escrow. The payee can claim after the unlock period; the payer can refund before it. Inputs: - - payee: beneficiary address (0x…) + - payee: address that can claim USDC after unlock (0x…) - amount: USDC to lock (e.g. "100") - - unlockAfterSeconds: lock duration in seconds (e.g. 86400 = 1 day, 604800 = 1 week) + - unlockAfterSeconds: seconds until the payee can claim (min 60). Example: 86400 = 1 day. - memo: optional on-chain label (max 64 chars) - Returns: escrow ID (needed for release/refund), Basescan link. + Returns: escrow ID, unlock time, and Basescan links. `.trim(), schema: CreateEscrowSchema, }) @@ -302,7 +408,21 @@ import { z } from "zod"; 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 ref = ""; try { + ref = await this.checkPolicy(ctx); + // 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, @@ -312,10 +432,15 @@ import { z } from "zod"; 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); - const escrowId = (receipt as { logs?: { topics?: string[] }[] })?.logs?.[0]?.topics?.[1] ?? "see tx"; + if ((receipt as { status?: string }).status === "reverted") { + return `Error: escrow creation reverted on-chain. [failed]\nTransaction: ${txLink(hash)}`; + } + const escrowId = + (receipt as { logs?: { topics?: string[] }[] })?.logs?.[0]?.topics?.[1] ?? "see tx"; return [ - `Escrow created: ${args.amount} USDC for ${args.payee}`, + `Escrow created: ${args.amount} USDC for ${args.payee} [executed]`, `Unlock in: ${(args.unlockAfterSeconds / 86400).toFixed(1)} days`, `Escrow ID: ${escrowId}`, ...(approveTx ? [`Approve: ${txLink(approveTx)}`] : []), @@ -323,6 +448,8 @@ import { z } from "zod"; ].join("\n"); } catch (e: unknown) { return `Error creating escrow: ${e instanceof Error ? e.message : String(e)}`; + } finally { + if (ref) this.pending.delete(ref); } } @@ -347,7 +474,23 @@ import { z } from "zod"; 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 ref = ""; try { + ref = await this.checkPolicy(ctx); + // 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, @@ -357,18 +500,27 @@ import { z } from "zod"; args: [USDC, args.payee as Hex, amount, BigInt(args.intervalSeconds), args.memo], }), }); - await walletProvider.waitForTransactionReceipt(hash); - const period = args.intervalSeconds === 604800 ? "weekly" - : args.intervalSeconds === 2592000 ? "monthly" - : `every ${args.intervalSeconds}s`; + // Fix 5: classify on-chain revert as [failed]. + const receipt = await walletProvider.waitForTransactionReceipt(hash); + if ((receipt as { status?: string }).status === "reverted") { + 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`; return [ - `Subscription: ${args.amount} USDC ${period} to ${args.payee}`, + `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) { return `Error creating subscription: ${e instanceof Error ? e.message : String(e)}`; + } finally { + if (ref) this.pending.delete(ref); } } From f79f9346bb561f2a104d372861c35fcf59fa3f08 Mon Sep 17 00:00:00 2001 From: Lumen Date: Mon, 29 Jun 2026 11:40:11 +0000 Subject: [PATCH 12/18] feat(policy): add PolicyOutcome and PolicyReceipt interfaces --- typescript/agentkit/src/policy/interfaces.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/typescript/agentkit/src/policy/interfaces.ts b/typescript/agentkit/src/policy/interfaces.ts index e0b002ef8..953c4db48 100644 --- a/typescript/agentkit/src/policy/interfaces.ts +++ b/typescript/agentkit/src/policy/interfaces.ts @@ -23,6 +23,17 @@ export interface PolicyDecision { signature?: string; } +export type PolicyOutcome = 'executed' | '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; } From d95263146d7938e23880c9bfefe85c0d0ca02212 Mon Sep 17 00:00:00 2001 From: Lumen Date: Mon, 29 Jun 2026 16:39:47 +0000 Subject: [PATCH 13/18] fix(policy): add relay_confirmed outcome and fix indentation --- typescript/agentkit/src/policy/index.ts | 3 +- typescript/agentkit/src/policy/interfaces.ts | 11 +++- typescript/agentkit/src/policy/utils.ts | 59 ++++++++++---------- 3 files changed, 39 insertions(+), 34 deletions(-) diff --git a/typescript/agentkit/src/policy/index.ts b/typescript/agentkit/src/policy/index.ts index beda9073d..795622a6e 100644 --- a/typescript/agentkit/src/policy/index.ts +++ b/typescript/agentkit/src/policy/index.ts @@ -1,3 +1,2 @@ export * from "./interfaces"; - export * from "./utils"; - \ No newline at end of file +export * from "./utils"; diff --git a/typescript/agentkit/src/policy/interfaces.ts b/typescript/agentkit/src/policy/interfaces.ts index 953c4db48..b6f7ec9c7 100644 --- a/typescript/agentkit/src/policy/interfaces.ts +++ b/typescript/agentkit/src/policy/interfaces.ts @@ -6,7 +6,7 @@ export interface ActionContext { recipient_count?: number; recipient_allocation_hash?: string; per_recipient_max?: string; - transfer_mechanism?: 'direct' | 'eip3009' | 'permit' | 'x402'; + transfer_mechanism?: "direct" | "eip3009" | "permit" | "x402"; creates_recurring_obligation?: boolean; creates_commitment?: boolean; } @@ -23,7 +23,14 @@ export interface PolicyDecision { signature?: string; } -export type PolicyOutcome = 'executed' | 'failed' | 'denied' | 'expired' | 'context_drift' | 'unauditable_outcome'; +export type PolicyOutcome = + | "executed" + | "relay_confirmed" + | "failed" + | "denied" + | "expired" + | "context_drift" + | "unauditable_outcome"; export interface PolicyReceipt { decision: PolicyDecision; diff --git a/typescript/agentkit/src/policy/utils.ts b/typescript/agentkit/src/policy/utils.ts index 217232004..480845eec 100644 --- a/typescript/agentkit/src/policy/utils.ts +++ b/typescript/agentkit/src/policy/utils.ts @@ -1,34 +1,33 @@ import canonicalize from "canonicalize"; - import { ActionContext } from "./interfaces"; +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(""); - } +/** + * 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) ?? "{}"); - } +/** + * 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) ?? "{}"); - } - \ No newline at end of file +/** + * 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) ?? "{}"); +} From 4c29157b9c565e61abf03c53663da605f92ac5dd Mon Sep 17 00:00:00 2001 From: Lumen Date: Mon, 29 Jun 2026 20:40:52 +0000 Subject: [PATCH 14/18] style: apply prettier formatter pass to basepay action provider --- .../basepay/basepayActionProvider.test.ts | 1179 +++++++++-------- .../basepay/basepayActionProvider.ts | 1039 ++++++++------- .../src/action-providers/basepay/index.ts | 17 +- .../src/action-providers/basepay/schemas.ts | 147 +- 4 files changed, 1331 insertions(+), 1051 deletions(-) diff --git a/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.test.ts b/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.test.ts index 74ab6b760..247449ba7 100644 --- a/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.test.ts @@ -1,623 +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); - }); +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("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("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; }); - 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); + // ── supportsNetwork ── + describe("supportsNetwork", () => { + it("supports Base Mainnet (chainId 8453)", () => { + expect(provider.supportsNetwork({ chainId: "8453", protocolFamily: "evm" })).toBe(true); }); - it("rejects invalid payee address", () => { - expect( - CreateEscrowSchema.safeParse({ payee: "bad", amount: "100", unlockAfterSeconds: 86400 }).success, - ).toBe(false); + it("does not support Ethereum mainnet", () => { + expect(provider.supportsNetwork({ chainId: "1", protocolFamily: "evm" })).toBe(false); }); }); - describe("SubscribeSchema", () => { - it("parses valid monthly subscription", () => { - expect( - SubscribeSchema.safeParse({ payee: MOCK_RECIPIENT, amount: "9.99", intervalSeconds: 2592000 }).success, - ).toBe(true); + // ── 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("rejects interval below minimum (3600s)", () => { - expect( - SubscribeSchema.safeParse({ payee: MOCK_RECIPIENT, amount: "9.99", intervalSeconds: 60 }).success, - ).toBe(false); + + 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"); }); }); - // ── Action tests ────────────────────────────────────────────────────────────── - - describe("BasePay Action Provider", () => { - let mockWallet: jest.Mocked; - const provider = basePayActionProvider(); + // ── batchPayUsdc ── + describe("batchPayUsdc", () => { + const twoRecipients = [ + { address: MOCK_RECIPIENT, amount: "5" }, + { address: "0x1234567890123456789012345678901234567890", amount: "3" }, + ]; - 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); + 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"); }); - // ── 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"); + 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"); }); - // ── 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"); + 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"); - }); + // ── createEscrow ── + describe("createEscrow", () => { + const escrowArgs = { + payee: MOCK_RECIPIENT, + amount: "100", + unlockAfterSeconds: 86400, + memo: "test", + }; - 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"); - }); + 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"); }); - // ── 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"); - }); + it("includes unlock duration in days", async () => { + const result = await provider.createEscrow(mockWallet, escrowArgs); + expect(result).toContain("1.0 days"); }); - // ── sendUsdcGasless ── - describe("sendUsdcGasless", () => { - beforeEach(() => { - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - json: async () => ({ txHash: MOCK_TX_HASH }), - } as unknown as Response); - }); + 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"); + }); + }); - 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"); - }); + // ── subscribe ── + describe("subscribe", () => { + const subArgs = { payee: MOCK_RECIPIENT, amount: "9.99", intervalSeconds: 2592000, memo: "" }; - 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("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("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("labels weekly interval correctly", async () => { + const result = await provider.subscribe(mockWallet, { ...subArgs, intervalSeconds: 604800 }); + expect(result).toContain("weekly"); + }); - 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"); - }); + 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"); }); }); - // ══════════════════════════════════════════════════════════════════════════════ - // 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; - + // ── sendUsdcGasless ── + describe("sendUsdcGasless", () => { beforeEach(() => { - (actionContextHash as jest.Mock).mockResolvedValue(MOCK_HASH); - (recipientAllocationHash as jest.Mock).mockResolvedValue(MOCK_HASH); - - mockEvaluate = jest.fn().mockResolvedValue(decision()); - 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 } }); - } - - // ── 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(); - }); - - 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"); - expect(mockWallet.sendTransaction).not.toHaveBeenCalled(); + 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("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("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("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("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("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"); - expect((mockWallet.sendTransaction as jest.Mock).mock.calls.length).toBe(sendCallsBefore); + 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); + }); - // ── sendUsdcGasless: first authority step = signTypedData ───────────────── + function providerWith() { + return basePayActionProvider({ + policyProvider: { evaluate: mockEvaluate, record: mockRecord }, + }); + } - 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(); - }); + // ── sendUsdc: first authority step = sendTransaction ────────────────────── - 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"); - expect((mockWallet.signTypedData as jest.Mock).mock.calls.length).toBe(signCallsBefore); + 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", + }), + ); }); - // ── 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("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"); + expect(mockWallet.sendTransaction).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"); - expect((mockWallet.readContract as jest.Mock).mock.calls.length).toBe(readCallsBefore); + 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: 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(); + 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(); }); - // ── createEscrow: first authority step = ensureAllowance ───────────────── + 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"); + expect((mockWallet.sendTransaction as jest.Mock).mock.calls.length).toBe(sendCallsBefore); + }); + }); - describe("createEscrow — first authority step: ensureAllowance", () => { - const escrowArgs = { payee: MOCK_RECIPIENT, amount: "100", unlockAfterSeconds: 86400, memo: "" }; + // ── sendUsdcGasless: first authority step = signTypedData ───────────────── - 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(); + 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 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"); - expect((mockWallet.readContract as jest.Mock).mock.calls.length).toBe(readCallsBefore); - }); + 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"); + expect((mockWallet.signTypedData as jest.Mock).mock.calls.length).toBe(signCallsBefore); }); + }); - // ── subscribe (creation): first authority step = ensureAllowance ────────── + // ── 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"); + 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", + }), + ); + }); + }); - describe("subscribe (creation) — first authority step: ensureAllowance", () => { - const subArgs = { payee: MOCK_RECIPIENT, amount: "9.99", intervalSeconds: 2592000, memo: "" }; + // ── createEscrow: first authority step = ensureAllowance ───────────────── - 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(); - }); + describe("createEscrow — first authority step: ensureAllowance", () => { + const escrowArgs = { + payee: MOCK_RECIPIENT, + amount: "100", + unlockAfterSeconds: 86400, + memo: "", + }; - 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"); - expect((mockWallet.readContract as jest.Mock).mock.calls.length).toBe(readCallsBefore); - }); + 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("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]"); - }); + 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"); + expect((mockWallet.readContract as jest.Mock).mock.calls.length).toBe(readCallsBefore); }); }); - // ── Layer 2: Settlement outcome assertions ──────────────────────────────────── - - describe("Policy hook — Layer 2: settlement outcomes", () => { - let mockWallet: jest.Mocked; - - beforeEach(() => { - (actionContextHash as jest.Mock).mockResolvedValue(MOCK_HASH); - (recipientAllocationHash as jest.Mock).mockResolvedValue(MOCK_HASH); + // ── subscribe (creation): first authority step = ensureAllowance ────────── - 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; + describe("subscribe (creation) — first authority step: ensureAllowance", () => { + const subArgs = { payee: MOCK_RECIPIENT, amount: "9.99", intervalSeconds: 2592000, memo: "" }; - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - json: async () => ({ txHash: MOCK_TX_HASH }), - } as unknown as Response); + 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(); }); - const noPolicy = () => basePayActionProvider(); - - 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("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"); + expect((mockWallet.readContract as jest.Mock).mock.calls.length).toBe(readCallsBefore); }); - it("sendUsdc: on-chain success → [executed], not [failed]", async () => { - const result = await noPolicy().sendUsdc(mockWallet, { to: MOCK_RECIPIENT, amount: "10" }); + 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]"); - expect(result).not.toContain("[failed]"); }); + }); +}); + +// ── 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); + }); - 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]"); - }); + 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("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("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("batchPayUsdc: on-chain success → [executed]", async () => { - const result = await noPolicy().batchPayUsdc(mockWallet, { - recipients: [{ address: MOCK_RECIPIENT, amount: "5" }], - memo: "", - }); - expect(result).toContain("[executed]"); + 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("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("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 unauditable_outcome", async () => { + const result = await withRecordingPolicy().sendUsdcGasless(mockWallet, { + to: MOCK_RECIPIENT, + amount: "5", + }); + expect(result).toContain("[relay_confirmed]"); + expect(mockRecord).toHaveBeenCalledWith( + expect.objectContaining({ + outcome: "unauditable_outcome", + 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("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("batchPayUsdc: on-chain success → [executed]", async () => { + const result = await noPolicy().batchPayUsdc(mockWallet, { + recipients: [{ address: MOCK_RECIPIENT, amount: "5" }], + 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("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 success → [executed]", async () => { - const result = await noPolicy().subscribe(mockWallet, { - payee: MOCK_RECIPIENT, amount: "9.99", intervalSeconds: 2592000, 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; + 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 } }); - }); + 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("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"); - 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"); + 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("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(); - }); + 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(); }); }); - \ No newline at end of file +}); diff --git a/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts b/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts index bf29e5fb5..b876feb3a 100644 --- a/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts +++ b/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts @@ -1,157 +1,252 @@ 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 } 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); +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; } - function txLink(hash: Hex): string { - return `${BASESCAN}/${hash}`; + /** + * 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. + } } - 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; + 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")) return "unauditable_outcome"; + return "failed"; } - export interface BasePayConfig { - relayUrl?: string; - policyProvider?: PolicyProvider; - } + private async checkPolicy(ctx: ActionContext): Promise { + if (!this.policyProvider) return null; - /** - * 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; + 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", + }); + throw new Error("unbound_execution"); + } + if (Date.now() > decision.expires_at_ms) { + await this.recordPolicyOutcome(decision, "expired", { error: "policy_unverifiable" }); + throw new Error("policy_unverifiable"); } - /** - * 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 checkPolicy(ctx: ActionContext): Promise { - if (!this.policyProvider) return ""; - - const decision = await this.policyProvider.evaluate(ctx); - if (!decision.allowed) throw new Error(`policy_denied: ${decision.reason_codes?.join(", ") || "no reason"}`); - if (!decision.decision_ref) throw new Error("unbound_execution"); - if (Date.now() > decision.expires_at_ms) throw new Error("policy_unverifiable"); - - const expectedHash = await actionContextHash(ctx); - if (decision.action_context_hash !== expectedHash) throw new Error("context_drift"); - - if (this.pending.has(decision.decision_ref) || this.consumed.has(decision.decision_ref)) { - throw new Error("unbound_execution"); - } + 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"); + } - // 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.decision_ref; + if (this.pending.has(decision.decision_ref) || this.consumed.has(decision.decision_ref)) { + await this.recordPolicyOutcome(decision, "unauditable_outcome", { + error: "unbound_execution", + }); + throw new Error("unbound_execution"); } - @CreateAction({ - name: "basepay_send_usdc", - description: ` + // 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: @@ -161,49 +256,56 @@ import { z } from "zod"; 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 ref = ""; - try { - ref = await this.checkPolicy(ctx); - // 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") { - return `Error: transaction reverted on-chain. [failed]\nTransaction: ${txLink(hash)}`; - } - return `Sent ${args.amount} USDC to ${args.to} [executed]\nTransaction: ${txLink(hash)}`; - } catch (e: unknown) { - return `Error sending USDC: ${e instanceof Error ? e.message : String(e)}`; - } finally { - if (ref) this.pending.delete(ref); + 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: ` + @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: @@ -218,105 +320,134 @@ import { z } from "zod"; 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", + 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." + ); + } - let ref = ""; + 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 { - ref = await this.checkPolicy(ctx); - // 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") { - return ( - "Error: wallet provider does not support signTypedData. " + - "Use ViemWalletProvider or CdpEvmWalletProvider for gasless transfers." - ); - } + 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 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) { - 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; - 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 }), + 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}`, }); - const data = (await resp.json()) as { txHash?: string; error?: string }; - if (!resp.ok || data.error) return `Relay error: ${data.error ?? resp.statusText}`; - // 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) { - return `Error calling BasePay relay: ${e instanceof Error ? e.message : String(e)}`; + return `Relay error: ${data.error ?? resp.statusText}`; } - } finally { - if (ref) this.pending.delete(ref); + await this.recordPolicyOutcome(decision, "unauditable_outcome", { + 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: ` + @CreateAction({ + name: "basepay_batch_pay_usdc", + description: ` Pay up to 200 recipients USDC atomically in one transaction. Auto-approves allowance if needed. Inputs: @@ -325,72 +456,79 @@ import { z } from "zod"; 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 ref = ""; - try { - ref = await this.checkPolicy(ctx); - // 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"); - } + 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") { - return `Error: batch payment reverted on-chain. [failed]\nTransaction: ${txLink(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) { - return `Error in batch payment: ${e instanceof Error ? e.message : String(e)}`; - } finally { - if (ref) this.pending.delete(ref); + 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: ` + @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: @@ -401,61 +539,68 @@ import { z } from "zod"; 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 ref = ""; - try { - ref = await this.checkPolicy(ctx); - // 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") { - return `Error: escrow creation reverted on-chain. [failed]\nTransaction: ${txLink(hash)}`; - } - const escrowId = - (receipt as { logs?: { topics?: string[] }[] })?.logs?.[0]?.topics?.[1] ?? "see tx"; - 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) { - return `Error creating escrow: ${e instanceof Error ? e.message : String(e)}`; - } finally { - if (ref) this.pending.delete(ref); + 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: ` + @CreateAction({ + name: "basepay_subscribe", + description: ` Create a recurring on-chain USDC subscription. Anyone can call charge() once per interval. Inputs: @@ -467,69 +612,75 @@ import { z } from "zod"; 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 ref = ""; - try { - ref = await this.checkPolicy(ctx); - // 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") { - 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`; - 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) { - return `Error creating subscription: ${e instanceof Error ? e.message : String(e)}`; - } finally { - if (ref) this.pending.delete(ref); + 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)}`; } - } - - supportsNetwork(network: Network): boolean { - return network.chainId === BASE_CHAIN_ID; + 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); } } - export function basePayActionProvider(config?: BasePayConfig): BasePayActionProvider { - return new BasePayActionProvider(config); + supportsNetwork(network: Network): boolean { + return network.chainId === BASE_CHAIN_ID; } - \ No newline at end of file +} + +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 index 60cbda2e5..6fd268e3c 100644 --- a/typescript/agentkit/src/action-providers/basepay/index.ts +++ b/typescript/agentkit/src/action-providers/basepay/index.ts @@ -1,10 +1,9 @@ export { BasePayActionProvider, basePayActionProvider } from "./basepayActionProvider"; - export type { BasePayConfig } from "./basepayActionProvider"; - export { - SendUsdcSchema, - SendUsdcGaslessSchema, - BatchPayUsdcSchema, - CreateEscrowSchema, - SubscribeSchema, - } from "./schemas"; - \ No newline at end of file +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 index 67b449086..fb8eddce4 100644 --- a/typescript/agentkit/src/action-providers/basepay/schemas.ts +++ b/typescript/agentkit/src/action-providers/basepay/schemas.ts @@ -1,91 +1,74 @@ import { z } from "zod"; - const ethAddress = z - .string() - .regex(/^0x[0-9a-fA-F]{40}$/, "Must be a valid 0x Ethereum address"); +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 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 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 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", +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", ), - 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"), - }); + 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", +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", ), - 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"), - }); - \ No newline at end of file + memo: z + .string() + .max(64) + .default("") + .describe("Optional description of the subscription recorded on-chain"), +}); From ccb406246e99c66e4ec8442c3bc5824fa58a29ee Mon Sep 17 00:00:00 2001 From: Lumen Date: Mon, 29 Jun 2026 22:10:46 +0000 Subject: [PATCH 15/18] feat(basepay): record relay_confirmed outcome on successful relay acceptance --- .../action-providers/basepay/basepayActionProvider.test.ts | 4 ++-- .../src/action-providers/basepay/basepayActionProvider.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.test.ts b/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.test.ts index 247449ba7..275a68950 100644 --- a/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.test.ts @@ -647,7 +647,7 @@ describe("Policy hook — Layer 2: settlement outcomes", () => { ); }); - it("sendUsdcGasless relay acceptance records unauditable_outcome", async () => { + it("sendUsdcGasless relay acceptance records relay_confirmed", async () => { const result = await withRecordingPolicy().sendUsdcGasless(mockWallet, { to: MOCK_RECIPIENT, amount: "5", @@ -655,7 +655,7 @@ describe("Policy hook — Layer 2: settlement outcomes", () => { expect(result).toContain("[relay_confirmed]"); expect(mockRecord).toHaveBeenCalledWith( expect.objectContaining({ - outcome: "unauditable_outcome", + outcome: "relay_confirmed", tx_hash: MOCK_TX_HASH, }), ); diff --git a/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts b/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts index b876feb3a..c0c07c1b7 100644 --- a/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts +++ b/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts @@ -428,7 +428,7 @@ export class BasePayActionProvider extends ActionProvider { }); return `Relay error: ${data.error ?? resp.statusText}`; } - await this.recordPolicyOutcome(decision, "unauditable_outcome", { + 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 From e7cde2812f21788c7f2474b3aa51e2ec5f586990 Mon Sep 17 00:00:00 2001 From: Lumen Date: Tue, 30 Jun 2026 04:11:47 +0000 Subject: [PATCH 16/18] fix(basepay): classify duplicate decision_ref as denied outcome --- .../src/action-providers/basepay/basepayActionProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts b/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts index c0c07c1b7..9c2f7edca 100644 --- a/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts +++ b/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts @@ -231,7 +231,7 @@ export class BasePayActionProvider extends ActionProvider { } if (this.pending.has(decision.decision_ref) || this.consumed.has(decision.decision_ref)) { - await this.recordPolicyOutcome(decision, "unauditable_outcome", { + await this.recordPolicyOutcome(decision, "denied", { error: "unbound_execution", }); throw new Error("unbound_execution"); From 2dfc8bc6f88cdc7b7bfecba22881624900ce8585 Mon Sep 17 00:00:00 2001 From: Lumen Date: Tue, 30 Jun 2026 10:11:58 +0000 Subject: [PATCH 17/18] fix(basepay): split unbound_execution into missing and duplicate decision_ref --- .../action-providers/basepay/basepayActionProvider.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts b/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts index 9c2f7edca..9fdb7263d 100644 --- a/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts +++ b/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.ts @@ -199,6 +199,7 @@ export class BasePayActionProvider extends ActionProvider { 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"; } @@ -215,9 +216,9 @@ export class BasePayActionProvider extends ActionProvider { } if (!decision.decision_ref) { await this.recordPolicyOutcome(decision, "unauditable_outcome", { - error: "unbound_execution", + error: "unbound_execution: missing decision_ref", }); - throw new Error("unbound_execution"); + throw new Error("unbound_execution: missing decision_ref"); } if (Date.now() > decision.expires_at_ms) { await this.recordPolicyOutcome(decision, "expired", { error: "policy_unverifiable" }); @@ -232,9 +233,9 @@ export class BasePayActionProvider extends ActionProvider { if (this.pending.has(decision.decision_ref) || this.consumed.has(decision.decision_ref)) { await this.recordPolicyOutcome(decision, "denied", { - error: "unbound_execution", + error: "unbound_execution: duplicate decision_ref", }); - throw new Error("unbound_execution"); + throw new Error("unbound_execution: duplicate decision_ref"); } // Fix 1: add to pending inside checkPolicy before returning. From 8380c34b9769cc255ff50d78b4ad2bafdd3de354 Mon Sep 17 00:00:00 2001 From: Lumen Date: Tue, 30 Jun 2026 10:12:18 +0000 Subject: [PATCH 18/18] test(basepay): update tests for missing vs duplicate decision_ref --- .../basepay/basepayActionProvider.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.test.ts b/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.test.ts index 275a68950..a8b758466 100644 --- a/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/basepay/basepayActionProvider.test.ts @@ -390,7 +390,7 @@ describe("Policy hook — Layer 1: authority gate", () => { to: MOCK_RECIPIENT, amount: "10", }); - expect(result).toContain("unbound_execution"); + expect(result).toContain("unbound_execution: missing decision_ref"); expect(mockWallet.sendTransaction).not.toHaveBeenCalled(); }); @@ -423,7 +423,7 @@ describe("Policy hook — Layer 1: authority gate", () => { // 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"); + expect(result).toContain("unbound_execution: duplicate decision_ref"); expect((mockWallet.sendTransaction as jest.Mock).mock.calls.length).toBe(sendCallsBefore); }); }); @@ -448,7 +448,7 @@ describe("Policy hook — Layer 1: authority gate", () => { 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"); + expect(result).toContain("unbound_execution: duplicate decision_ref"); expect((mockWallet.signTypedData as jest.Mock).mock.calls.length).toBe(signCallsBefore); }); }); @@ -472,7 +472,7 @@ describe("Policy hook — Layer 1: authority gate", () => { 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"); + expect(result).toContain("unbound_execution: duplicate decision_ref"); expect((mockWallet.readContract as jest.Mock).mock.calls.length).toBe(readCallsBefore); }); @@ -520,7 +520,7 @@ describe("Policy hook — Layer 1: authority gate", () => { 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"); + expect(result).toContain("unbound_execution: duplicate decision_ref"); expect((mockWallet.readContract as jest.Mock).mock.calls.length).toBe(readCallsBefore); }); }); @@ -544,7 +544,7 @@ describe("Policy hook — Layer 1: authority gate", () => { 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"); + expect(result).toContain("unbound_execution: duplicate decision_ref"); expect((mockWallet.readContract as jest.Mock).mock.calls.length).toBe(readCallsBefore); }); @@ -749,7 +749,7 @@ describe("Policy hook — Layer 2: settlement outcomes", () => { 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"); + expect(result).toContain("unbound_execution: missing decision_ref"); expect(mockWallet.sendTransaction).not.toHaveBeenCalled(); });