From babda3ed52ecb6f817e46c9567c918e17310697a Mon Sep 17 00:00:00 2001 From: Matias Date: Thu, 8 Jan 2026 13:35:46 -0300 Subject: [PATCH] feat: add HTTP support --- pnpm-lock.yaml | 10 + .../packages/ampersend-sdk/package.json | 7 + .../ampersend-sdk/src/x402/http/README.md | 64 +++ .../ampersend-sdk/src/x402/http/adapter.ts | 148 ++++++ .../ampersend-sdk/src/x402/http/index.ts | 1 + .../packages/ampersend-sdk/src/x402/index.ts | 3 + .../tests/x402/http/adapter.test.ts | 421 ++++++++++++++++++ 7 files changed, 654 insertions(+) create mode 100644 typescript/packages/ampersend-sdk/src/x402/http/README.md create mode 100644 typescript/packages/ampersend-sdk/src/x402/http/adapter.ts create mode 100644 typescript/packages/ampersend-sdk/src/x402/http/index.ts create mode 100644 typescript/packages/ampersend-sdk/tests/x402/http/adapter.test.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e8ba66..0c1913d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: specifier: ^3.24.2 version: 3.25.76 devDependencies: + '@x402/core': + specifier: ^2.1.0 + version: 2.1.0 fastmcp: specifier: github:edgeandnode/fastmcp#598d18f version: https://codeload.github.com/edgeandnode/fastmcp/tar.gz/598d18f(effect@3.19.13) @@ -1841,6 +1844,9 @@ packages: '@walletconnect/window-metadata@1.0.1': resolution: {integrity: sha512-9koTqyGrM2cqFRW517BPY/iEtUDx2r1+Pwwu5m7sJ7ka79wi3EyqhqcICk/yDmv6jAS1rjKgTKXlEhanYjijcA==} + '@x402/core@2.1.0': + resolution: {integrity: sha512-r4T0yKqU6G6fVY0cTep/KWyg4qTVkAOTgxLafXrnYgFPX001lC6ZYJTHaeLS5gGQNHblkKOZWPxCl0M+IC5xhQ==} + abitype@1.0.6: resolution: {integrity: sha512-MMSqYh4+C/aVqI2RQaWqbvI4Kxo5cQV40WQ4QFtDnNzCkqChm8MuENhElmynZlO0qUy/ObkEUaXtKqYnx1Kp3A==} peerDependencies: @@ -6921,6 +6927,10 @@ snapshots: '@walletconnect/window-getters': 1.0.1 tslib: 1.14.1 + '@x402/core@2.1.0': + dependencies: + zod: 3.25.76 + abitype@1.0.6(typescript@5.9.3)(zod@3.25.76): optionalDependencies: typescript: 5.9.3 diff --git a/typescript/packages/ampersend-sdk/package.json b/typescript/packages/ampersend-sdk/package.json index bc018b5..f0a4bb2 100644 --- a/typescript/packages/ampersend-sdk/package.json +++ b/typescript/packages/ampersend-sdk/package.json @@ -71,9 +71,16 @@ "zod": "^3.24.2" }, "peerDependencies": { + "@x402/core": "^2.1.0", "fastmcp": "^3.17.0" }, + "peerDependenciesMeta": { + "@x402/core": { + "optional": true + } + }, "devDependencies": { + "@x402/core": "^2.1.0", "fastmcp": "github:edgeandnode/fastmcp#598d18f", "tsx": "^4.21.0" } diff --git a/typescript/packages/ampersend-sdk/src/x402/http/README.md b/typescript/packages/ampersend-sdk/src/x402/http/README.md new file mode 100644 index 0000000..a600548 --- /dev/null +++ b/typescript/packages/ampersend-sdk/src/x402/http/README.md @@ -0,0 +1,64 @@ +# HTTP x402 Client Adapter + +Wraps x402 v2 SDK clients to use Ampersend treasurers for payment decisions. + +## Overview + +Integrates ampersend-sdk treasurers with Coinbase's x402 v2 SDK (`@x402/fetch`), enabling sophisticated payment authorization logic (budgets, policies, approvals) with standard x402 HTTP clients. + +**→ [Complete Documentation](../../../../README.md)** + +## Quick Start + +```typescript +import { AccountWallet, NaiveTreasurer } from "@ampersend_ai/ampersend-sdk" +import { wrapWithAmpersend } from "@ampersend_ai/ampersend-sdk/x402" +import { wrapFetchWithPayment, x402Client } from "@x402/fetch" + +const wallet = AccountWallet.fromPrivateKey("0x...") +const treasurer = new NaiveTreasurer(wallet) + +const client = new x402Client() +wrapWithAmpersend(client, treasurer, ["base", "base-sepolia"]) + +const fetchWithPayment = wrapFetchWithPayment(fetch, client) +const response = await fetchWithPayment("https://paid-api.example.com/resource") +``` + +## API Reference + +### wrapWithAmpersend + +```typescript +function wrapWithAmpersend(client: x402Client, treasurer: X402Treasurer, networks: Array): x402Client +``` + +Configures an x402Client to use an Ampersend treasurer for payment authorization. + +**Parameters:** + +- `client` - The x402Client instance to wrap +- `treasurer` - The X402Treasurer that handles payment authorization decisions +- `networks` - Array of v1 network names to register (e.g., `"base"`, `"base-sepolia"`) + +**Returns:** The configured x402Client instance (same instance, mutated) + +## Features + +- **Transparent Integration**: Drop-in replacement for `registerExactEvmScheme` +- **Treasurer Pattern**: Payment decisions via `X402Treasurer.onPaymentRequired()` +- **Payment Lifecycle**: Tracks payment status (sending, success, error) via `onStatus()` +- **v1 Protocol Support**: Works with EVM networks using v1 payment payloads + +## How It Works + +1. Wraps the x402Client with treasurer-based payment hooks +2. On 402 response, calls `treasurer.onPaymentRequired()` for authorization +3. If approved, creates payment using the treasurer's wallet +4. Notifies treasurer of payment status via `onStatus()` + +## Learn More + +- [TypeScript SDK Guide](../../../../README.md) +- [Treasurer Documentation](../../../../README.md#x402treasurer) +- [x402-http-client Example](https://github.com/edgeandnode/ampersend-examples/tree/main/typescript/examples/x402-http-client) diff --git a/typescript/packages/ampersend-sdk/src/x402/http/adapter.ts b/typescript/packages/ampersend-sdk/src/x402/http/adapter.ts new file mode 100644 index 0000000..59a2158 --- /dev/null +++ b/typescript/packages/ampersend-sdk/src/x402/http/adapter.ts @@ -0,0 +1,148 @@ +import type { + PaymentCreatedContext, + PaymentCreationContext, + PaymentCreationFailureContext, + x402Client, +} from "@x402/core/client" +import type { PaymentRequirements } from "x402/types" + +import type { Authorization, X402Treasurer } from "../treasurer.ts" + +/** + * Scheme client that retrieves payments from the treasurer via a shared WeakMap. + * Compatible with @x402/core's SchemeNetworkClient interface for v1 protocol. + * + * Note: We don't implement SchemeNetworkClient directly because @x402/core + * exports v2 types, but registerV1() passes v1 types at runtime. + */ +class TreasurerSchemeClient { + readonly scheme = "exact" + + constructor(private readonly paymentStore: WeakMap) {} + + async createPaymentPayload( + x402Version: number, + requirements: PaymentRequirements, + ): Promise<{ x402Version: number; payload: Record }> { + const authorization = this.paymentStore.get(requirements) + if (!authorization) { + throw new Error("No payment authorization found for requirements") + } + + // Clean up after retrieval + this.paymentStore.delete(requirements) + + return { + x402Version, + payload: authorization.payment.payload, + } + } +} + +/** + * Wraps an x402Client to use an ampersend-sdk treasurer for payment decisions. + * + * This adapter integrates ampersend-sdk treasurers with Coinbase's x402 v2 SDK, + * allowing you to use sophisticated payment authorization logic (budgets, policies, + * approvals) with the standard x402 HTTP client ecosystem. + * + * Note: This adapter registers for v1 protocol because the underlying wallets + * (AccountWallet, SmartAccountWallet) produce v1 payment payloads. + * + * @param client - The x402Client instance to wrap + * @param treasurer - The X402Treasurer that handles payment authorization + * @param networks - Array of v1 network names to register (e.g., 'base', 'base-sepolia') + * @returns The configured x402Client instance (same instance, mutated) + * + * @example + * ```typescript + * import { x402Client } from '@x402/core/client' + * import { wrapFetchWithPayment } from '@x402/fetch' + * import { wrapWithAmpersend, NaiveTreasurer, AccountWallet } from '@ampersend_ai/ampersend-sdk' + * + * const wallet = AccountWallet.fromPrivateKey('0x...') + * const treasurer = new NaiveTreasurer(wallet) + * + * const client = wrapWithAmpersend( + * new x402Client(), + * treasurer, + * ['base', 'base-sepolia'] + * ) + * + * const fetchWithPay = wrapFetchWithPayment(fetch, client) + * const response = await fetchWithPay('https://paid-api.com/endpoint') + * ``` + */ +export function wrapWithAmpersend(client: x402Client, treasurer: X402Treasurer, networks: Array): x402Client { + // Shared store for correlating payments between hooks and scheme client + const paymentStore = new WeakMap() + + // Register TreasurerSchemeClient for v1 protocol on each network + // Using registerV1 because our wallets produce v1 payment payloads + // Cast to any because @x402/core types are v2, but registerV1 accepts v1 at runtime + const schemeClient = new TreasurerSchemeClient(paymentStore) + for (const network of networks) { + client.registerV1(network, schemeClient as any) + } + + // Track authorization for status updates + const authorizationByRequirements = new WeakMap() + + // beforePaymentCreation: Consult treasurer for payment authorization + client.onBeforePaymentCreation(async (context: PaymentCreationContext) => { + // v1 requirements are passed directly to treasurer (no conversion needed) + const requirements = context.selectedRequirements as unknown as PaymentRequirements + + const authorization = await treasurer.onPaymentRequired([requirements], { + method: "http", + params: { + resource: context.paymentRequired.resource, + }, + }) + + if (!authorization) { + return { abort: true, reason: "Payment declined by treasurer" } + } + + // Store for scheme client to retrieve + paymentStore.set(requirements, authorization) + // Store for status tracking + authorizationByRequirements.set(requirements, authorization) + + return + }) + + // afterPaymentCreation: Notify treasurer payment is being sent + client.onAfterPaymentCreation(async (context: PaymentCreatedContext) => { + const requirements = context.selectedRequirements as unknown as PaymentRequirements + const authorization = authorizationByRequirements.get(requirements) + if (authorization) { + await treasurer.onStatus("sending", authorization, { + method: "http", + params: { + resource: context.paymentRequired.resource, + }, + }) + } + }) + + // onPaymentCreationFailure: Notify treasurer of error + client.onPaymentCreationFailure(async (context: PaymentCreationFailureContext) => { + const requirements = context.selectedRequirements as unknown as PaymentRequirements + const authorization = authorizationByRequirements.get(requirements) + if (authorization) { + await treasurer.onStatus("error", authorization, { + method: "http", + params: { + resource: context.paymentRequired.resource, + error: context.error.message, + }, + }) + } + + // Don't recover - let the error propagate + return + }) + + return client +} diff --git a/typescript/packages/ampersend-sdk/src/x402/http/index.ts b/typescript/packages/ampersend-sdk/src/x402/http/index.ts new file mode 100644 index 0000000..965dadf --- /dev/null +++ b/typescript/packages/ampersend-sdk/src/x402/http/index.ts @@ -0,0 +1 @@ +export { wrapWithAmpersend } from "./adapter.ts" diff --git a/typescript/packages/ampersend-sdk/src/x402/index.ts b/typescript/packages/ampersend-sdk/src/x402/index.ts index 93b5046..50f5f9e 100644 --- a/typescript/packages/ampersend-sdk/src/x402/index.ts +++ b/typescript/packages/ampersend-sdk/src/x402/index.ts @@ -9,3 +9,6 @@ export { NaiveTreasurer, createNaiveTreasurer } from "./treasurers/index.ts" // X402Wallet implementations export { AccountWallet, SmartAccountWallet, createWalletFromConfig } from "./wallets/index.ts" export type { SmartAccountConfig, WalletConfig, EOAWalletConfig, SmartAccountWalletConfig } from "./wallets/index.ts" + +// HTTP adapter for x402 v2 SDK +export { wrapWithAmpersend } from "./http/index.ts" diff --git a/typescript/packages/ampersend-sdk/tests/x402/http/adapter.test.ts b/typescript/packages/ampersend-sdk/tests/x402/http/adapter.test.ts new file mode 100644 index 0000000..b882907 --- /dev/null +++ b/typescript/packages/ampersend-sdk/tests/x402/http/adapter.test.ts @@ -0,0 +1,421 @@ +import { wrapWithAmpersend } from "@/x402/http/adapter.ts" +import type { Authorization, PaymentContext, X402Treasurer } from "@/x402/treasurer.ts" +import type { + PaymentCreatedContext, + PaymentCreationContext, + PaymentCreationFailureContext, + x402Client, +} from "@x402/core/client" +import { beforeEach, describe, expect, it, vi } from "vitest" +import type { PaymentPayload, PaymentRequirements } from "x402/types" + +function createMockRequirements(): PaymentRequirements { + return { + scheme: "exact", + network: "base-sepolia", + maxAmountRequired: "1000000", + resource: "https://api.example.com/resource", + description: "Test payment", + mimeType: "application/json", + payTo: "0x1234567890123456789012345678901234567890", + maxTimeoutSeconds: 300, + asset: "USDC", + } +} + +function createMockPaymentPayload(): PaymentPayload { + return { + x402Version: 1, + scheme: "exact", + network: "base-sepolia", + payload: { + signature: "0xmocksignature", + authorization: { + from: "0xfrom", + to: "0xto", + value: "1000000", + validAfter: "0", + validBefore: "999999999999", + nonce: "0xnonce", + }, + }, + } +} + +function createMockAuthorization(payment: PaymentPayload): Authorization { + return { + payment, + authorizationId: "test-auth-id", + } +} + +describe("wrapWithAmpersend", () => { + let mockClient: x402Client & { + _beforeHooks: Array<(ctx: PaymentCreationContext) => Promise<{ abort: true; reason: string } | void>> + _afterHooks: Array<(ctx: PaymentCreatedContext) => Promise> + _failureHooks: Array<(ctx: PaymentCreationFailureContext) => Promise<{ recovered: true; payload: any } | void>> + _registeredSchemes: Map + } + let mockTreasurer: X402Treasurer & { + onPaymentRequired: ReturnType + onStatus: ReturnType + } + + beforeEach(() => { + // Create mock client that captures hooks + const beforeHooks: typeof mockClient._beforeHooks = [] + const afterHooks: typeof mockClient._afterHooks = [] + const failureHooks: typeof mockClient._failureHooks = [] + const registeredSchemes = new Map() + + mockClient = { + _beforeHooks: beforeHooks, + _afterHooks: afterHooks, + _failureHooks: failureHooks, + _registeredSchemes: registeredSchemes, + registerV1: vi.fn((network: string, schemeClient: any) => { + registeredSchemes.set(network, schemeClient) + return mockClient + }), + onBeforePaymentCreation: vi.fn((hook) => { + beforeHooks.push(hook) + return mockClient + }), + onAfterPaymentCreation: vi.fn((hook) => { + afterHooks.push(hook) + return mockClient + }), + onPaymentCreationFailure: vi.fn((hook) => { + failureHooks.push(hook) + return mockClient + }), + } as any + + mockTreasurer = { + onPaymentRequired: vi.fn(), + onStatus: vi.fn(), + } + }) + + describe("registration", () => { + it("registers scheme client on all specified networks", () => { + wrapWithAmpersend(mockClient, mockTreasurer, ["base", "base-sepolia"]) + + expect(mockClient.registerV1).toHaveBeenCalledTimes(2) + expect(mockClient._registeredSchemes.has("base")).toBe(true) + expect(mockClient._registeredSchemes.has("base-sepolia")).toBe(true) + }) + + it("uses same scheme client instance for all networks", () => { + wrapWithAmpersend(mockClient, mockTreasurer, ["base", "base-sepolia"]) + + const baseClient = mockClient._registeredSchemes.get("base") + const sepoliaClient = mockClient._registeredSchemes.get("base-sepolia") + expect(baseClient).toBe(sepoliaClient) + }) + + it("scheme client has correct scheme property", () => { + wrapWithAmpersend(mockClient, mockTreasurer, ["base"]) + + const schemeClient = mockClient._registeredSchemes.get("base") + expect(schemeClient.scheme).toBe("exact") + }) + }) + + describe("happy path - payment approved", () => { + it("calls treasurer with requirements and context", async () => { + const requirements = createMockRequirements() + const payment = createMockPaymentPayload() + const authorization = createMockAuthorization(payment) + + mockTreasurer.onPaymentRequired.mockResolvedValue(authorization) + + wrapWithAmpersend(mockClient, mockTreasurer, ["base-sepolia"]) + + const context: PaymentCreationContext = { + paymentRequired: { + x402Version: 1, + accepts: [requirements], + resource: "https://api.example.com/resource", + }, + selectedRequirements: requirements, + } as any + + await mockClient._beforeHooks[0](context) + + expect(mockTreasurer.onPaymentRequired).toHaveBeenCalledWith([requirements], { + method: "http", + params: { + resource: "https://api.example.com/resource", + }, + }) + }) + + it("scheme client retrieves payment payload from store", async () => { + const requirements = createMockRequirements() + const payment = createMockPaymentPayload() + const authorization = createMockAuthorization(payment) + + mockTreasurer.onPaymentRequired.mockResolvedValue(authorization) + + wrapWithAmpersend(mockClient, mockTreasurer, ["base-sepolia"]) + + const context: PaymentCreationContext = { + paymentRequired: { + x402Version: 1, + accepts: [requirements], + resource: "https://api.example.com/resource", + }, + selectedRequirements: requirements, + } as any + + // Store authorization via hook + await mockClient._beforeHooks[0](context) + + // Retrieve via scheme client + const schemeClient = mockClient._registeredSchemes.get("base-sepolia") + const result = await schemeClient.createPaymentPayload(1, requirements) + + expect(result).toEqual({ + x402Version: 1, + payload: payment.payload, + }) + }) + + it("calls onStatus with sending after payment created", async () => { + const requirements = createMockRequirements() + const payment = createMockPaymentPayload() + const authorization = createMockAuthorization(payment) + + mockTreasurer.onPaymentRequired.mockResolvedValue(authorization) + + wrapWithAmpersend(mockClient, mockTreasurer, ["base-sepolia"]) + + const beforeContext: PaymentCreationContext = { + paymentRequired: { + x402Version: 1, + accepts: [requirements], + resource: "https://api.example.com/resource", + }, + selectedRequirements: requirements, + } as any + + // Trigger before hook to store authorization + await mockClient._beforeHooks[0](beforeContext) + + const afterContext: PaymentCreatedContext = { + paymentRequired: { + x402Version: 1, + accepts: [requirements], + resource: "https://api.example.com/resource", + }, + selectedRequirements: requirements, + paymentPayload: payment, + } as any + + await mockClient._afterHooks[0](afterContext) + + expect(mockTreasurer.onStatus).toHaveBeenCalledWith("sending", authorization, { + method: "http", + params: { + resource: "https://api.example.com/resource", + }, + }) + }) + }) + + describe("treasurer declines", () => { + it("returns abort when treasurer returns null", async () => { + const requirements = createMockRequirements() + + mockTreasurer.onPaymentRequired.mockResolvedValue(null) + + wrapWithAmpersend(mockClient, mockTreasurer, ["base-sepolia"]) + + const context: PaymentCreationContext = { + paymentRequired: { + x402Version: 1, + accepts: [requirements], + resource: "https://api.example.com/resource", + }, + selectedRequirements: requirements, + } as any + + const result = await mockClient._beforeHooks[0](context) + + expect(result).toEqual({ + abort: true, + reason: "Payment declined by treasurer", + }) + }) + + it("does not call onStatus when declined", async () => { + const requirements = createMockRequirements() + + mockTreasurer.onPaymentRequired.mockResolvedValue(null) + + wrapWithAmpersend(mockClient, mockTreasurer, ["base-sepolia"]) + + const context: PaymentCreationContext = { + paymentRequired: { + x402Version: 1, + accepts: [requirements], + resource: "https://api.example.com/resource", + }, + selectedRequirements: requirements, + } as any + + await mockClient._beforeHooks[0](context) + + expect(mockTreasurer.onStatus).not.toHaveBeenCalled() + }) + }) + + describe("treasurer throws", () => { + it("propagates error from treasurer", async () => { + const requirements = createMockRequirements() + const error = new Error("Treasurer error") + + mockTreasurer.onPaymentRequired.mockRejectedValue(error) + + wrapWithAmpersend(mockClient, mockTreasurer, ["base-sepolia"]) + + const context: PaymentCreationContext = { + paymentRequired: { + x402Version: 1, + accepts: [requirements], + resource: "https://api.example.com/resource", + }, + selectedRequirements: requirements, + } as any + + await expect(mockClient._beforeHooks[0](context)).rejects.toThrow("Treasurer error") + }) + }) + + describe("payment creation failure", () => { + it("calls onStatus with error status", async () => { + const requirements = createMockRequirements() + const payment = createMockPaymentPayload() + const authorization = createMockAuthorization(payment) + + mockTreasurer.onPaymentRequired.mockResolvedValue(authorization) + + wrapWithAmpersend(mockClient, mockTreasurer, ["base-sepolia"]) + + // First store authorization via before hook + const beforeContext: PaymentCreationContext = { + paymentRequired: { + x402Version: 1, + accepts: [requirements], + resource: "https://api.example.com/resource", + }, + selectedRequirements: requirements, + } as any + + await mockClient._beforeHooks[0](beforeContext) + + // Then trigger failure + const failureContext: PaymentCreationFailureContext = { + paymentRequired: { + x402Version: 1, + accepts: [requirements], + resource: "https://api.example.com/resource", + }, + selectedRequirements: requirements, + error: new Error("Payment failed"), + } as any + + await mockClient._failureHooks[0](failureContext) + + expect(mockTreasurer.onStatus).toHaveBeenCalledWith("error", authorization, { + method: "http", + params: { + resource: "https://api.example.com/resource", + error: "Payment failed", + }, + }) + }) + + it("does not recover from failure", async () => { + const requirements = createMockRequirements() + const payment = createMockPaymentPayload() + const authorization = createMockAuthorization(payment) + + mockTreasurer.onPaymentRequired.mockResolvedValue(authorization) + + wrapWithAmpersend(mockClient, mockTreasurer, ["base-sepolia"]) + + // Store authorization + const beforeContext: PaymentCreationContext = { + paymentRequired: { + x402Version: 1, + accepts: [requirements], + resource: "https://api.example.com/resource", + }, + selectedRequirements: requirements, + } as any + + await mockClient._beforeHooks[0](beforeContext) + + const failureContext: PaymentCreationFailureContext = { + paymentRequired: { + x402Version: 1, + accepts: [requirements], + resource: "https://api.example.com/resource", + }, + selectedRequirements: requirements, + error: new Error("Payment failed"), + } as any + + const result = await mockClient._failureHooks[0](failureContext) + + // Should return undefined (no recovery) + expect(result).toBeUndefined() + }) + }) + + describe("missing authorization in store", () => { + it("throws when authorization not found", async () => { + const requirements = createMockRequirements() + + wrapWithAmpersend(mockClient, mockTreasurer, ["base-sepolia"]) + + const schemeClient = mockClient._registeredSchemes.get("base-sepolia") + + // Try to get payload without storing authorization first + await expect(schemeClient.createPaymentPayload(1, requirements)).rejects.toThrow( + "No payment authorization found for requirements", + ) + }) + }) + + describe("context shape", () => { + it("passes correct context structure to treasurer", async () => { + const requirements = createMockRequirements() + const payment = createMockPaymentPayload() + const authorization = createMockAuthorization(payment) + + mockTreasurer.onPaymentRequired.mockResolvedValue(authorization) + + wrapWithAmpersend(mockClient, mockTreasurer, ["base-sepolia"]) + + const context: PaymentCreationContext = { + paymentRequired: { + x402Version: 1, + accepts: [requirements], + resource: "https://api.example.com/resource", + }, + selectedRequirements: requirements, + } as any + + await mockClient._beforeHooks[0](context) + + const calledContext = mockTreasurer.onPaymentRequired.mock.calls[0][1] as PaymentContext + expect(calledContext.method).toBe("http") + expect(calledContext.params).toEqual({ + resource: "https://api.example.com/resource", + }) + }) + }) +})