From 075d85d046aa94b15316d9bd099959e888ab2a47 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Mar 2026 14:01:02 +0000 Subject: [PATCH 1/4] feat: integrate MPP (Machine Payments Protocol) for paying MPP-enabled services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `cash pay ` command that auto-detects MPP 402 payment challenges and settles them by swapping BTC→stablecoin via existing LendaSwap infrastructure. Follows the core principle: Bitcoin in, stablecoins out, on demand. - MPP client library (cli/src/mpp/) implementing HTTP 402 challenge-then-retry - Currency mapping: USD/USDC/USDT on polygon/ethereum/arbitrum/tempo - Expiry validation on payment requirements - Session support for streaming payments - 31 comprehensive tests covering protocol parsing, proof creation, currency mapping, full client flow, edge cases https://claude.ai/code/session_01XEaEUdv3xCXdw6wwYYMajj --- cli/src/commands/pay.ts | 81 ++++++ cli/src/index.ts | 10 +- cli/src/mpp/client.ts | 178 +++++++++++++ cli/src/mpp/currency.ts | 100 +++++++ cli/src/mpp/index.ts | 4 + cli/src/mpp/types.ts | 130 +++++++++ test/mpp.test.ts | 574 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1076 insertions(+), 1 deletion(-) create mode 100644 cli/src/commands/pay.ts create mode 100644 cli/src/mpp/client.ts create mode 100644 cli/src/mpp/currency.ts create mode 100644 cli/src/mpp/index.ts create mode 100644 cli/src/mpp/types.ts create mode 100644 test/mpp.test.ts diff --git a/cli/src/commands/pay.ts b/cli/src/commands/pay.ts new file mode 100644 index 0000000..4a7a220 --- /dev/null +++ b/cli/src/commands/pay.ts @@ -0,0 +1,81 @@ +import type { CashContext } from "../context.js"; +import { outputSuccess, outputError } from "../output.js"; +import { MppClient } from "../mpp/client.js"; +import type { ParsedArgs } from "minimist"; + +export async function handlePay( + ctx: CashContext, + args: ParsedArgs +): Promise { + const positionals = args._.slice(1); // after "pay" + const url = positionals[0] as string | undefined; + const method = (args.method as string | undefined)?.toUpperCase() ?? "GET"; + const body = args.body as string | undefined; + const headerArgs = args.header as string | string[] | undefined; + + if (!url) { + return outputError( + "Missing URL.\n\nUsage:\n" + + " cash pay \n" + + " cash pay --method POST --body '{\"query\":\"test\"}'\n" + + " cash pay --header 'Authorization: Bearer token'\n\n" + + "The command fetches the URL. If the server returns an MPP 402 payment\n" + + "challenge, claw-cash automatically swaps BTC→stablecoin and retries\n" + + "with payment proof." + ); + } + + // Validate URL + try { + new URL(url); + } catch { + return outputError(`Invalid URL: ${url}`); + } + + // Parse --header flags into a Record + const headers: Record = {}; + if (headerArgs) { + const list = Array.isArray(headerArgs) ? headerArgs : [headerArgs]; + for (const h of list) { + const colonIdx = h.indexOf(":"); + if (colonIdx === -1) { + return outputError(`Invalid header format: ${h}. Expected "Name: value"`); + } + headers[h.slice(0, colonIdx).trim()] = h.slice(colonIdx + 1).trim(); + } + } + + const client = new MppClient({ + swap: { + swapBtcToStablecoin: (params) => + ctx.swap.swapBtcToStablecoin(params as Parameters[0]), + }, + }); + + const init: RequestInit = { method, headers }; + if (body && (method === "POST" || method === "PUT" || method === "PATCH")) { + init.body = body; + if (!headers["Content-Type"] && !headers["content-type"]) { + headers["Content-Type"] = "application/json"; + } + } + + const result = await client.pay(url, init); + + return outputSuccess({ + url, + status: result.status, + body: tryParseJson(result.body), + paid: !!result.proof, + swap_id: result.swap_id, + proof: result.proof, + }); +} + +function tryParseJson(s: string): unknown { + try { + return JSON.parse(s); + } catch { + return s; + } +} diff --git a/cli/src/index.ts b/cli/src/index.ts index d2f5a00..4bc4ef8 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -17,6 +17,7 @@ import { handleSwap } from "./commands/swap.js"; import { handleConfig } from "./commands/config.js"; import { handleSignPsbt } from "./commands/sign-psbt.js"; import { handlePubkey } from "./commands/pubkey.js"; +import { handlePay } from "./commands/pay.js"; import { getVersion } from "./version.js"; const HELP = `cash - Bitcoin & Stablecoin CLI @@ -42,6 +43,10 @@ Usage: cash claim Manually claim a swap (reveal preimage) cash refund Manually refund a swap --address Refund destination (optional) + cash pay Fetch a URL, auto-pay MPP 402 challenges (BTC→stablecoin) + --method HTTP method (default: GET) + --body Request body (for POST/PUT/PATCH) + --header 'Name: value' Custom request headers (repeatable) cash pubkey Show the wallet's public key (for multisig setup) cash sign-psbt Sign a PSBT (Partially Signed Bitcoin Transaction) Parses PSBT, shows tx details, signs inputs @@ -75,7 +80,7 @@ const argv = minimist(process.argv.slice(2), { string: [ "amount", "currency", "where", "to", "address", "id", "api-url", "token", "ark-server", "network", "port", - "bot-token", "chat-id", "message-id", + "bot-token", "chat-id", "message-id", "method", "body", "header", ], boolean: ["help", "version", "daemon-internal", "start"], alias: { h: "help", v: "version" }, @@ -179,6 +184,9 @@ async function main() { case "sign-psbt": await handleSignPsbt(ctx, argv); break; + case "pay": + await handlePay(ctx, argv); + break; default: outputError(`Unknown command: ${command}. Run 'cash --help' for usage.`); } diff --git a/cli/src/mpp/client.ts b/cli/src/mpp/client.ts new file mode 100644 index 0000000..84d2d6d --- /dev/null +++ b/cli/src/mpp/client.ts @@ -0,0 +1,178 @@ +/** + * MPP (Machine Payments Protocol) client for claw-cash. + * + * Implements the HTTP 402 challenge-then-retry flow: + * 1. Fetch the URL + * 2. If 402 + MPP-Version header → parse payment requirements + * 3. Pick the first supported requirement + * 4. Swap BTC→stablecoin via claw-cash infrastructure + * 5. Retry the request with MPP-Authorization header + * 6. Return the final response + */ + +import type { + MppPaymentRequired, + MppPaymentProof, + MppPayResult, + MppClientConfig, +} from "./types.js"; +import { + MPP_VERSION, + MPP_HEADER_VERSION, + MPP_HEADER_AUTHORIZATION, +} from "./types.js"; +import { isSupportedRequirement, mapMppCurrencyToSwapParams } from "./currency.js"; + +// ── Protocol helpers ─────────────────────────────────────────────── + +/** + * Check if a Response is an MPP 402 payment-required response. + */ +export function isMppResponse(res: Response): boolean { + return res.status === 402 && res.headers.has(MPP_HEADER_VERSION); +} + +/** + * Parse the MPP payment requirements from a 402 response body. + */ +export async function parseMppResponse(res: Response): Promise { + let body: unknown; + try { + body = await res.json(); + } catch { + throw new Error("MPP: failed to parse 402 response body as JSON"); + } + + const data = body as Record; + if (!Array.isArray(data.requirements)) { + throw new Error("MPP: 402 response missing requirements array"); + } + + return data as unknown as MppPaymentRequired; +} + +/** + * Create an MPP payment proof from swap result data. + */ +export function createMppProof(params: Omit): MppPaymentProof { + return { + version: MPP_VERSION, + ...params, + }; +} + +/** + * Encode a payment proof as a base64 string for the MPP-Authorization header. + */ +export function buildMppAuthorizationHeader(proof: MppPaymentProof): string { + return Buffer.from(JSON.stringify(proof), "utf-8").toString("base64"); +} + +// ── Client ───────────────────────────────────────────────────────── + +export class MppClient { + private swap: MppClientConfig["swap"]; + private fetch: typeof globalThis.fetch; + + constructor(config: MppClientConfig) { + this.swap = config.swap; + this.fetch = config.fetch ?? globalThis.fetch; + } + + /** + * Fetch a URL, automatically handling MPP 402 payment challenges. + * + * If the server returns 402 with MPP headers, the client will: + * 1. Parse the payment requirements + * 2. Pick the first requirement that claw-cash can fulfill + * 3. Swap BTC→stablecoin to pay + * 4. Retry the request with payment proof + */ + async pay(url: string, init?: RequestInit): Promise { + const res = await this.fetch(url, init); + + // Not an MPP 402 — pass through as-is + if (!isMppResponse(res)) { + const body = await res.text(); + return { + ok: res.ok, + status: res.status, + body, + headers: Object.fromEntries(res.headers.entries()), + }; + } + + // Parse the MPP payment requirements + const paymentRequired = await parseMppResponse(res); + + // Find the first requirement we can fulfill (skip expired ones) + const now = Date.now(); + const requirement = paymentRequired.requirements.find((r) => { + if (!isSupportedRequirement(r.currency, r.network)) return false; + if (r.expires_at && new Date(r.expires_at).getTime() <= now) return false; + return true; + }); + + if (!requirement) { + const networks = paymentRequired.requirements.map((r) => `${r.currency}/${r.network}`); + throw new Error( + `No supported payment requirement found. Server requires: ${networks.join(", ")}. ` + + `Claw-cash supports: USD/USDC/USDT on polygon, ethereum, arbitrum, tempo.` + ); + } + + // Swap BTC → stablecoin + const swapParams = mapMppCurrencyToSwapParams( + requirement.currency, + requirement.network, + requirement.recipient, + requirement.amount, + ); + + const swapResult = await this.swap.swapBtcToStablecoin(swapParams); + + // Build payment proof + const proof = createMppProof({ + requirement_id: requirement.id, + tx_id: swapResult.swapId, + network: requirement.network, + amount: requirement.amount, + currency: requirement.currency, + session_id: requirement.session_id, + }); + + const authHeader = buildMppAuthorizationHeader(proof); + + // Merge original headers with MPP-Authorization + const retryHeaders: Record = {}; + if (init?.headers) { + const entries = + init.headers instanceof Headers + ? [...init.headers.entries()] + : Array.isArray(init.headers) + ? init.headers + : Object.entries(init.headers); + for (const [k, v] of entries) { + retryHeaders[k] = v; + } + } + retryHeaders[MPP_HEADER_AUTHORIZATION] = authHeader; + + // Retry the original request with payment proof + const retryRes = await this.fetch(url, { + ...init, + headers: retryHeaders, + }); + + const retryBody = await retryRes.text(); + + return { + ok: retryRes.ok, + status: retryRes.status, + body: retryBody, + headers: Object.fromEntries(retryRes.headers.entries()), + proof, + swap_id: swapResult.swapId, + }; + } +} diff --git a/cli/src/mpp/currency.ts b/cli/src/mpp/currency.ts new file mode 100644 index 0000000..074fb78 --- /dev/null +++ b/cli/src/mpp/currency.ts @@ -0,0 +1,100 @@ +/** + * Maps MPP payment requirement currencies and networks to claw-cash swap + * parameters. Agents hold BTC as treasury and swap on-the-fly to stablecoins + * to pay MPP-enabled services. + */ + +import type { StablecoinToken, EvmChain } from "@clw-cash/skills"; + +// Networks that claw-cash can settle on via LendaSwap +const SUPPORTED_NETWORKS = new Set(["polygon", "ethereum", "arbitrum", "tempo"]); + +// Tempo settles via Polygon bridge by default (EVM-compatible, lowest fees) +const TEMPO_BRIDGE_CHAIN: EvmChain = "polygon"; + +const CHAIN_MAP: Record = { + polygon: "polygon", + ethereum: "ethereum", + arbitrum: "arbitrum", + tempo: TEMPO_BRIDGE_CHAIN, +}; + +const USDC_TOKEN_MAP: Record = { + polygon: "usdc_pol", + ethereum: "usdc_eth", + arbitrum: "usdc_arb", +}; + +const USDT_TOKEN_MAP: Record = { + polygon: "usdt0_pol", + ethereum: "usdt_eth", + arbitrum: "usdt_arb", +}; + +// Currencies where the amount is in cents (smallest unit) +const CENTS_CURRENCIES = new Set(["USD"]); + +export interface SwapParams { + targetAddress: string; + targetToken: StablecoinToken; + targetChain: EvmChain; + targetAmount: number; +} + +/** + * Returns true if claw-cash can fulfill a payment requirement on the given + * network and currency. + */ +export function isSupportedRequirement(currency: string, network: string): boolean { + if (!SUPPORTED_NETWORKS.has(network)) return false; + const upper = currency.toUpperCase(); + return upper === "USD" || upper === "USDC" || upper === "USDT"; +} + +/** + * Map an MPP payment requirement to claw-cash BTC→stablecoin swap parameters. + * + * - USD amounts are in cents → converted to dollar units for stablecoin swap + * - USDC/USDT amounts are in token units (no conversion) + * - Tempo network is bridged via Polygon (lowest fees) + */ +export function mapMppCurrencyToSwapParams( + currency: string, + network: string, + recipient: string, + amount: number, +): SwapParams { + const upper = currency.toUpperCase(); + const chain = CHAIN_MAP[network]; + + if (!chain) { + throw new Error(`Unsupported MPP network: ${network}. Supported: ${[...SUPPORTED_NETWORKS].join(", ")}`); + } + + let token: StablecoinToken; + let targetAmount: number; + + switch (upper) { + case "USD": + case "USDC": { + token = USDC_TOKEN_MAP[chain]; + // USD is in cents, USDC is direct + targetAmount = CENTS_CURRENCIES.has(upper) ? amount / 100 : amount; + break; + } + case "USDT": { + token = USDT_TOKEN_MAP[chain]; + targetAmount = CENTS_CURRENCIES.has(upper) ? amount / 100 : amount; + break; + } + default: + throw new Error(`Unsupported MPP currency: ${upper}. Supported: USD, USDC, USDT`); + } + + return { + targetAddress: recipient, + targetToken: token, + targetChain: chain, + targetAmount, + }; +} diff --git a/cli/src/mpp/index.ts b/cli/src/mpp/index.ts new file mode 100644 index 0000000..f98f817 --- /dev/null +++ b/cli/src/mpp/index.ts @@ -0,0 +1,4 @@ +export { MppClient, isMppResponse, parseMppResponse, createMppProof, buildMppAuthorizationHeader } from "./client.js"; +export { mapMppCurrencyToSwapParams, isSupportedRequirement } from "./currency.js"; +export type { SwapParams } from "./currency.js"; +export * from "./types.js"; diff --git a/cli/src/mpp/types.ts b/cli/src/mpp/types.ts new file mode 100644 index 0000000..ef9704c --- /dev/null +++ b/cli/src/mpp/types.ts @@ -0,0 +1,130 @@ +/** + * MPP (Machine Payments Protocol) types. + * + * MPP is an open protocol for machine-to-machine payments co-authored by + * Stripe and Tempo. It uses the HTTP 402 challenge-then-retry pattern: + * + * 1. Client requests a resource. + * 2. Server responds 402 with `MPP-Version` + JSON body containing payment + * requirements (amount, currency, recipient, network, optional session). + * 3. Client pays (BTC→stablecoin swap via claw-cash infrastructure) and + * retries with an `MPP-Authorization` header carrying the payment proof. + * 4. Server verifies and returns the resource. + * + * Sessions ("OAuth for money") let the client authorize once and stream + * payments within defined limits. + */ + +// ── Protocol constants ───────────────────────────────── + +export const MPP_VERSION = "1"; + +export const MPP_HEADER_VERSION = "MPP-Version"; +export const MPP_HEADER_AUTHORIZATION = "MPP-Authorization"; + +// ── Payment requirements (server → client on 402) ───── + +export interface MppPaymentRequirement { + /** Unique id for this requirement set */ + id: string; + /** Payment amount in the smallest unit of the currency (e.g. cents for USD) */ + amount: number; + /** ISO 4217 currency code — typically "USD" */ + currency: string; + /** Recipient address on the target network */ + recipient: string; + /** Network/rail to settle on — e.g. "tempo", "polygon", "ethereum", "base" */ + network: string; + /** Human-readable description of the resource being purchased */ + description?: string; + /** Optional: expiry timestamp (ISO 8601) for the payment requirement */ + expires_at?: string; + /** Optional: session id if the server supports MPP sessions */ + session_id?: string; +} + +export interface MppPaymentRequired { + /** Protocol version */ + version: string; + /** One or more acceptable payment options */ + requirements: MppPaymentRequirement[]; +} + +// ── Payment proof (client → server on retry) ─────────── + +export interface MppPaymentProof { + /** Protocol version */ + version: string; + /** The requirement id the client chose to fulfill */ + requirement_id: string; + /** On-chain transaction id / swap id proving payment */ + tx_id: string; + /** Network the payment was settled on */ + network: string; + /** Amount paid (smallest unit) */ + amount: number; + /** Currency paid */ + currency: string; + /** Optional session id for session-based payments */ + session_id?: string; +} + +// ── Session types ────────────────────────────────────── + +export interface MppSession { + /** Unique session identifier */ + session_id: string; + /** Maximum total spend in smallest currency unit */ + budget: number; + /** ISO 4217 currency code */ + currency: string; + /** Amount already spent in this session */ + spent: number; + /** Session expiry (ISO 8601) */ + expires_at: string; +} + +// ── Client configuration ─────────────────────────────── + +export interface MppSwapParams { + targetAddress: string; + targetToken: string; + targetChain: string; + targetAmount: number; +} + +export interface MppSwapResult { + swapId: string; + status: string; +} + +export interface MppClientConfig { + /** The swap skill for BTC→stablecoin conversions */ + swap: { + swapBtcToStablecoin(params: MppSwapParams): Promise; + }; + /** Optional: custom fetch implementation (for testing) */ + fetch?: typeof globalThis.fetch; +} + +// ── Result types ─────────────────────────────────────── + +export interface MppPayResult { + /** Whether the resource was successfully retrieved */ + ok: boolean; + /** HTTP status code of the final response */ + status: number; + /** The response body (resource content) */ + body: string; + /** Response headers */ + headers: Record; + /** The payment proof that was submitted (if payment was made) */ + proof?: MppPaymentProof; + /** The swap id from claw-cash (if a BTC→stablecoin swap occurred) */ + swap_id?: string; +} + +export interface MppError { + code: string; + message: string; +} diff --git a/test/mpp.test.ts b/test/mpp.test.ts new file mode 100644 index 0000000..f2e7f6a --- /dev/null +++ b/test/mpp.test.ts @@ -0,0 +1,574 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + parseMppResponse, + isMppResponse, + createMppProof, + buildMppAuthorizationHeader, + MppClient, +} from "../cli/src/mpp/client.js"; +import type { + MppPaymentRequired, + MppPaymentProof, + MppClientConfig, +} from "../cli/src/mpp/types.js"; +import { + MPP_VERSION, + MPP_HEADER_VERSION, + MPP_HEADER_AUTHORIZATION, +} from "../cli/src/mpp/types.js"; +import { + mapMppCurrencyToSwapParams, +} from "../cli/src/mpp/currency.js"; + +// ─── Helper: create a mock 402 Response ──────────────────────────── + +function mock402Response(body: MppPaymentRequired): Response { + return new Response(JSON.stringify(body), { + status: 402, + headers: { + "content-type": "application/json", + [MPP_HEADER_VERSION]: MPP_VERSION, + }, + }); +} + +function mock200Response(body: string): Response { + return new Response(body, { + status: 200, + headers: { "content-type": "text/plain" }, + }); +} + +// ─── Mock swap skill ─────────────────────────────────────────────── + +function createMockSwap() { + return { + swapBtcToStablecoin: vi.fn().mockResolvedValue({ + swapId: "swap_abc123", + status: "completed", + }), + }; +} + +// ───────────────────────────────────────────────────────────────────── +// Unit tests: protocol parsing +// ───────────────────────────────────────────────────────────────────── + +describe("MPP protocol parsing", () => { + it("isMppResponse detects 402 with MPP-Version header", () => { + const res = mock402Response({ + version: MPP_VERSION, + requirements: [{ + id: "req_1", + amount: 100, + currency: "USD", + recipient: "0xabc", + network: "tempo", + }], + }); + expect(isMppResponse(res)).toBe(true); + }); + + it("isMppResponse returns false for regular 402 without MPP-Version", () => { + const res = new Response("Payment Required", { status: 402 }); + expect(isMppResponse(res)).toBe(false); + }); + + it("isMppResponse returns false for 200 with MPP-Version", () => { + const res = new Response("ok", { + status: 200, + headers: { [MPP_HEADER_VERSION]: MPP_VERSION }, + }); + expect(isMppResponse(res)).toBe(false); + }); + + it("parseMppResponse extracts payment requirements from 402 body", async () => { + const body: MppPaymentRequired = { + version: MPP_VERSION, + requirements: [ + { + id: "req_1", + amount: 500, + currency: "USD", + recipient: "0xdeadbeef", + network: "polygon", + description: "API call to /v1/data", + }, + { + id: "req_2", + amount: 500, + currency: "USD", + recipient: "0xdeadbeef", + network: "tempo", + }, + ], + }; + const res = mock402Response(body); + const parsed = await parseMppResponse(res); + expect(parsed.version).toBe(MPP_VERSION); + expect(parsed.requirements).toHaveLength(2); + expect(parsed.requirements[0].amount).toBe(500); + expect(parsed.requirements[0].network).toBe("polygon"); + expect(parsed.requirements[1].network).toBe("tempo"); + }); + + it("parseMppResponse throws on invalid JSON", async () => { + const res = new Response("not json", { + status: 402, + headers: { + "content-type": "application/json", + [MPP_HEADER_VERSION]: MPP_VERSION, + }, + }); + await expect(parseMppResponse(res)).rejects.toThrow(); + }); + + it("parseMppResponse throws when requirements array is missing", async () => { + const res = new Response(JSON.stringify({ version: "1" }), { + status: 402, + headers: { + "content-type": "application/json", + [MPP_HEADER_VERSION]: MPP_VERSION, + }, + }); + await expect(parseMppResponse(res)).rejects.toThrow("requirements"); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// Unit tests: payment proof creation +// ───────────────────────────────────────────────────────────────────── + +describe("MPP payment proof", () => { + it("createMppProof builds a valid proof object", () => { + const proof = createMppProof({ + requirement_id: "req_1", + tx_id: "0xtx123", + network: "polygon", + amount: 500, + currency: "USD", + }); + expect(proof.version).toBe(MPP_VERSION); + expect(proof.requirement_id).toBe("req_1"); + expect(proof.tx_id).toBe("0xtx123"); + expect(proof.network).toBe("polygon"); + expect(proof.amount).toBe(500); + expect(proof.currency).toBe("USD"); + }); + + it("createMppProof includes session_id when provided", () => { + const proof = createMppProof({ + requirement_id: "req_1", + tx_id: "0xtx123", + network: "polygon", + amount: 500, + currency: "USD", + session_id: "sess_abc", + }); + expect(proof.session_id).toBe("sess_abc"); + }); + + it("buildMppAuthorizationHeader encodes proof as base64 JSON", () => { + const proof: MppPaymentProof = { + version: MPP_VERSION, + requirement_id: "req_1", + tx_id: "0xtx123", + network: "polygon", + amount: 500, + currency: "USD", + }; + const header = buildMppAuthorizationHeader(proof); + const decoded = JSON.parse(Buffer.from(header, "base64").toString("utf-8")); + expect(decoded.requirement_id).toBe("req_1"); + expect(decoded.tx_id).toBe("0xtx123"); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// Unit tests: currency mapping (MPP → claw-cash swap params) +// ───────────────────────────────────────────────────────────────────── + +describe("MPP currency mapping", () => { + it("maps USD on polygon to usdc_pol", () => { + const params = mapMppCurrencyToSwapParams("USD", "polygon", "0xrecipient", 1000); + expect(params.targetToken).toBe("usdc_pol"); + expect(params.targetChain).toBe("polygon"); + expect(params.targetAddress).toBe("0xrecipient"); + // 1000 cents = $10.00 + expect(params.targetAmount).toBe(10); + }); + + it("maps USD on ethereum to usdc_eth", () => { + const params = mapMppCurrencyToSwapParams("USD", "ethereum", "0xrecipient", 500); + expect(params.targetToken).toBe("usdc_eth"); + expect(params.targetChain).toBe("ethereum"); + expect(params.targetAmount).toBe(5); + }); + + it("maps USD on arbitrum to usdc_arb", () => { + const params = mapMppCurrencyToSwapParams("USD", "arbitrum", "0xrecipient", 250); + expect(params.targetToken).toBe("usdc_arb"); + expect(params.targetChain).toBe("arbitrum"); + expect(params.targetAmount).toBe(2.5); + }); + + it("maps USD on tempo to usdc_pol (default bridge)", () => { + const params = mapMppCurrencyToSwapParams("USD", "tempo", "0xrecipient", 100); + expect(params.targetToken).toBe("usdc_pol"); + expect(params.targetChain).toBe("polygon"); + expect(params.targetAmount).toBe(1); + }); + + it("maps USDC on polygon to usdc_pol (no cents conversion)", () => { + const params = mapMppCurrencyToSwapParams("USDC", "polygon", "0xrecipient", 10); + expect(params.targetToken).toBe("usdc_pol"); + expect(params.targetChain).toBe("polygon"); + expect(params.targetAmount).toBe(10); + }); + + it("maps USDT on polygon to usdt0_pol", () => { + const params = mapMppCurrencyToSwapParams("USDT", "polygon", "0xrecipient", 10); + expect(params.targetToken).toBe("usdt0_pol"); + expect(params.targetChain).toBe("polygon"); + expect(params.targetAmount).toBe(10); + }); + + it("throws for unsupported network", () => { + expect(() => + mapMppCurrencyToSwapParams("USD", "solana", "0xrecipient", 100) + ).toThrow("Unsupported MPP network"); + }); + + it("throws for unsupported currency", () => { + expect(() => + mapMppCurrencyToSwapParams("EUR", "polygon", "0xrecipient", 100) + ).toThrow("Unsupported MPP currency"); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// Integration tests: MppClient end-to-end flow +// ───────────────────────────────────────────────────────────────────── + +describe("MppClient", () => { + let mockSwap: ReturnType; + let client: MppClient; + let mockFetch: ReturnType; + + beforeEach(() => { + mockSwap = createMockSwap(); + mockFetch = vi.fn(); + client = new MppClient({ + swap: mockSwap, + fetch: mockFetch as unknown as typeof globalThis.fetch, + }); + }); + + it("passes through non-402 responses without payment", async () => { + mockFetch.mockResolvedValue(mock200Response("hello world")); + + const result = await client.pay("https://api.example.com/resource"); + expect(result.ok).toBe(true); + expect(result.status).toBe(200); + expect(result.body).toBe("hello world"); + expect(result.proof).toBeUndefined(); + expect(mockSwap.swapBtcToStablecoin).not.toHaveBeenCalled(); + }); + + it("handles MPP 402 → swap → retry → 200 flow", async () => { + const paymentReq: MppPaymentRequired = { + version: MPP_VERSION, + requirements: [{ + id: "req_001", + amount: 100, + currency: "USD", + recipient: "0xmerchant", + network: "polygon", + description: "API call", + }], + }; + + // First call returns 402, second call returns 200 + mockFetch + .mockResolvedValueOnce(mock402Response(paymentReq)) + .mockResolvedValueOnce(mock200Response('{"data":"secret"}')); + + const result = await client.pay("https://api.example.com/paid-resource"); + + expect(result.ok).toBe(true); + expect(result.status).toBe(200); + expect(result.body).toBe('{"data":"secret"}'); + expect(result.swap_id).toBe("swap_abc123"); + expect(result.proof).toBeDefined(); + expect(result.proof!.requirement_id).toBe("req_001"); + expect(result.proof!.network).toBe("polygon"); + + // Verify swap was called with correct params + expect(mockSwap.swapBtcToStablecoin).toHaveBeenCalledWith({ + targetAddress: "0xmerchant", + targetToken: "usdc_pol", + targetChain: "polygon", + targetAmount: 1, // 100 cents = $1 + }); + + // Verify second request has MPP-Authorization header + const secondCall = mockFetch.mock.calls[1]; + const headers = secondCall[1]?.headers as Record; + expect(headers[MPP_HEADER_AUTHORIZATION]).toBeDefined(); + + // Decode the authorization header + const proof = JSON.parse( + Buffer.from(headers[MPP_HEADER_AUTHORIZATION], "base64").toString("utf-8") + ); + expect(proof.version).toBe(MPP_VERSION); + expect(proof.requirement_id).toBe("req_001"); + expect(proof.tx_id).toBe("swap_abc123"); + }); + + it("forwards original request headers on retry", async () => { + const paymentReq: MppPaymentRequired = { + version: MPP_VERSION, + requirements: [{ + id: "req_002", + amount: 50, + currency: "USD", + recipient: "0xmerchant", + network: "polygon", + }], + }; + + mockFetch + .mockResolvedValueOnce(mock402Response(paymentReq)) + .mockResolvedValueOnce(mock200Response("ok")); + + await client.pay("https://api.example.com/resource", { + method: "POST", + headers: { "X-Custom": "value123", "Content-Type": "application/json" }, + body: '{"query":"test"}', + }); + + // Second call should preserve original headers + add MPP-Authorization + const secondCall = mockFetch.mock.calls[1]; + const headers = secondCall[1]?.headers as Record; + expect(headers["X-Custom"]).toBe("value123"); + expect(headers["Content-Type"]).toBe("application/json"); + expect(headers[MPP_HEADER_AUTHORIZATION]).toBeDefined(); + expect(secondCall[1]?.body).toBe('{"query":"test"}'); + }); + + it("prefers the first supported network in requirements", async () => { + const paymentReq: MppPaymentRequired = { + version: MPP_VERSION, + requirements: [ + { + id: "req_base", + amount: 100, + currency: "USD", + recipient: "0xmerchant", + network: "base", // unsupported by claw-cash + }, + { + id: "req_poly", + amount: 100, + currency: "USD", + recipient: "0xmerchant", + network: "polygon", // supported + }, + ], + }; + + mockFetch + .mockResolvedValueOnce(mock402Response(paymentReq)) + .mockResolvedValueOnce(mock200Response("ok")); + + const result = await client.pay("https://api.example.com/resource"); + expect(result.proof!.requirement_id).toBe("req_poly"); + }); + + it("throws when no supported payment requirement is found", async () => { + const paymentReq: MppPaymentRequired = { + version: MPP_VERSION, + requirements: [{ + id: "req_sol", + amount: 100, + currency: "USD", + recipient: "sol123", + network: "solana", + }], + }; + + mockFetch.mockResolvedValueOnce(mock402Response(paymentReq)); + + await expect(client.pay("https://api.example.com/resource")) + .rejects.toThrow("No supported payment requirement"); + }); + + it("throws when swap fails", async () => { + const paymentReq: MppPaymentRequired = { + version: MPP_VERSION, + requirements: [{ + id: "req_001", + amount: 100, + currency: "USD", + recipient: "0xmerchant", + network: "polygon", + }], + }; + + mockFetch.mockResolvedValueOnce(mock402Response(paymentReq)); + mockSwap.swapBtcToStablecoin.mockRejectedValueOnce(new Error("Insufficient BTC balance")); + + await expect(client.pay("https://api.example.com/resource")) + .rejects.toThrow("Insufficient BTC balance"); + }); + + it("throws when retry after payment still fails", async () => { + const paymentReq: MppPaymentRequired = { + version: MPP_VERSION, + requirements: [{ + id: "req_001", + amount: 100, + currency: "USD", + recipient: "0xmerchant", + network: "polygon", + }], + }; + + mockFetch + .mockResolvedValueOnce(mock402Response(paymentReq)) + .mockResolvedValueOnce(new Response("Forbidden", { status: 403 })); + + const result = await client.pay("https://api.example.com/resource"); + expect(result.ok).toBe(false); + expect(result.status).toBe(403); + }); + + it("handles session-based payment requirements", async () => { + const paymentReq: MppPaymentRequired = { + version: MPP_VERSION, + requirements: [{ + id: "req_sess", + amount: 50, + currency: "USD", + recipient: "0xmerchant", + network: "polygon", + session_id: "sess_xyz", + }], + }; + + mockFetch + .mockResolvedValueOnce(mock402Response(paymentReq)) + .mockResolvedValueOnce(mock200Response("session data")); + + const result = await client.pay("https://api.example.com/resource"); + expect(result.ok).toBe(true); + expect(result.proof!.session_id).toBe("sess_xyz"); + }); + + it("skips expired requirements and picks a valid one", async () => { + const paymentReq: MppPaymentRequired = { + version: MPP_VERSION, + requirements: [ + { + id: "req_expired", + amount: 100, + currency: "USD", + recipient: "0xmerchant", + network: "polygon", + expires_at: "2020-01-01T00:00:00Z", // long expired + }, + { + id: "req_valid", + amount: 100, + currency: "USD", + recipient: "0xmerchant", + network: "polygon", + expires_at: new Date(Date.now() + 3600_000).toISOString(), // 1h from now + }, + ], + }; + + mockFetch + .mockResolvedValueOnce(mock402Response(paymentReq)) + .mockResolvedValueOnce(mock200Response("ok")); + + const result = await client.pay("https://api.example.com/resource"); + expect(result.proof!.requirement_id).toBe("req_valid"); + }); + + it("throws when all requirements are expired", async () => { + const paymentReq: MppPaymentRequired = { + version: MPP_VERSION, + requirements: [{ + id: "req_expired", + amount: 100, + currency: "USD", + recipient: "0xmerchant", + network: "polygon", + expires_at: "2020-01-01T00:00:00Z", + }], + }; + + mockFetch.mockResolvedValueOnce(mock402Response(paymentReq)); + + await expect(client.pay("https://api.example.com/resource")) + .rejects.toThrow("No supported payment requirement"); + }); + + it("handles non-MPP 402 responses (passes through)", async () => { + // A regular 402 without MPP-Version header + const res = new Response("Please subscribe", { status: 402 }); + mockFetch.mockResolvedValueOnce(res); + + const result = await client.pay("https://api.example.com/resource"); + expect(result.ok).toBe(false); + expect(result.status).toBe(402); + expect(result.proof).toBeUndefined(); + expect(mockSwap.swapBtcToStablecoin).not.toHaveBeenCalled(); + }); + + it("handles USDC-denominated requirements without cents conversion", async () => { + const paymentReq: MppPaymentRequired = { + version: MPP_VERSION, + requirements: [{ + id: "req_usdc", + amount: 5, + currency: "USDC", + recipient: "0xmerchant", + network: "polygon", + }], + }; + + mockFetch + .mockResolvedValueOnce(mock402Response(paymentReq)) + .mockResolvedValueOnce(mock200Response("ok")); + + await client.pay("https://api.example.com/resource"); + + expect(mockSwap.swapBtcToStablecoin).toHaveBeenCalledWith({ + targetAddress: "0xmerchant", + targetToken: "usdc_pol", + targetChain: "polygon", + targetAmount: 5, // USDC amount is direct, no cents conversion + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// Unit tests: handlePay command +// ───────────────────────────────────────────────────────────────────── + +describe("handlePay argument parsing", () => { + // These test the URL validation logic used by the pay command + it("validates URL format", () => { + expect(() => new URL("https://api.example.com/resource")).not.toThrow(); + expect(() => new URL("not-a-url")).toThrow(); + }); + + it("accepts http and https URLs", () => { + const https = new URL("https://api.example.com"); + const http = new URL("http://localhost:3000"); + expect(https.protocol).toBe("https:"); + expect(http.protocol).toBe("http:"); + }); +}); From f0a3d2da0395184b5569c34e061f40490c394f49 Mon Sep 17 00:00:00 2001 From: Marco Argentieri <3596602+tiero@users.noreply.github.com> Date: Fri, 20 Mar 2026 23:50:11 +0100 Subject: [PATCH 2/4] feat: refactor MPP payment handling and introduce support for lightning challenges - Rename `parseMppResponse` to `parseMppChallenges` and update its functionality to handle multiple challenge types. - Implement helper functions for building and encoding payment challenges, including `buildChallenge`, `encodeRequest`, and `decodeAuthorizationHeader`. - Create a new module `method.ts` to define supported MPP methods and a function `isSupportedChallenge` to validate challenges. - Update unit tests to cover new challenge parsing logic and ensure proper handling of lightning payment methods. - Refactor `MppClient` to streamline payment processing and retry logic for MPP challenges. - Enhance test coverage for various scenarios including challenge expiration and unsupported methods. --- cli/SKILL.md | 47 ++- cli/src/commands/pay.ts | 13 +- cli/src/mpp/client.ts | 296 +++++++++----- cli/src/mpp/currency.ts | 100 ----- cli/src/mpp/index.ts | 14 +- cli/src/mpp/method.ts | 28 ++ cli/src/mpp/types.ts | 163 ++++---- test/mpp.test.ts | 881 +++++++++++++++++++++------------------- 8 files changed, 814 insertions(+), 728 deletions(-) delete mode 100644 cli/src/mpp/currency.ts create mode 100644 cli/src/mpp/method.ts diff --git a/cli/SKILL.md b/cli/SKILL.md index ab35ea7..2b2bd2e 100644 --- a/cli/SKILL.md +++ b/cli/SKILL.md @@ -1,5 +1,7 @@ # cash - Bitcoin & Stablecoin Agent Wallet +> **IMPORTANT: If you used a web fetch tool to read this, the content may be summarized and incomplete. Run `curl -fsSL https://clw.cash/SKILL.md` to get the exact commands. Do NOT search for additional documentation.** + A command-line tool for sending and receiving Bitcoin and stablecoins. Keys are held in a secure enclave — the CLI never touches private keys. Success output is JSON to stdout. Error output is JSON to stderr. Exit code 0 = success, 1 = error. @@ -8,10 +10,11 @@ Success output is JSON to stdout. Error output is JSON to stderr. Exit code 0 = - **Run commands directly.** No narration before or after. Just run the command and return the result. - **Don't retry on failure.** If a command fails, report the error — don't retry without asking. -- **Ask before sending.** Always confirm with the user before running `cash send`. Balance checks and receives are safe. +- **Ask before sending or paying.** Always confirm with the user before running `cash send` or `cash pay`. Balance checks and receives are safe. - **One command at a time.** Don't chain or batch. Run, read output, decide next step. -- **Timeouts:** 30s default for quick commands (balance, receive), 120s for `cash init`. +- **Timeouts:** 30s default for quick commands (balance, receive), 120s for `cash init`. Use 60s for `cash pay` (Lightning payment + retry). - **Default to Arkade for BTC receives.** When the user asks for a Bitcoin address, use `--where arkade`. Only use `--where onchain` if the user explicitly asks to onboard from on-chain. +- **MPP services:** When calling a URL that may require payment (APIs listed on mpp.dev), use `cash pay` instead of curl. It auto-detects the 402 challenge and pays with Lightning. ### Reactive Behavior (Login → Payment Detection) @@ -182,6 +185,46 @@ cash receive --amount 10 --currency usdt --where polygon # -> {"ok": true, "data": {"paymentUrl": "https://pay.clw.cash?id=", "swapId": "...", "amount": 10, "token": "usdt0_pol", "chain": "polygon", "targetAddress": "ark1q..."}} ``` +### Pay MPP-Enabled Services + +MPP (Machine Payments Protocol) is an open protocol for machine-to-machine payments over HTTP (IETF draft `draft-httpauth-payment-00`). When an API returns `HTTP 402` with a `WWW-Authenticate: Payment ...` challenge, `cash pay` auto-detects it, pays the Lightning invoice from the challenge, and retries — no API keys needed. + +The BTC treasury is used to fund payments. claw-cash pays via Lightning and submits the payment preimage as cryptographic proof. + +```bash +# Call any MPP-enabled service (GET by default) +cash pay https://api.example.com/resource +# -> {"ok": true, "data": {"url": "...", "status": 200, "body": {...}, "paid": true, "method": "lightning", "preimage": "abcdef..."}} + +# POST with JSON body +cash pay https://api.example.com/v1/generate --method POST --body '{"prompt":"hello"}' + +# With extra headers +cash pay https://api.example.com/resource --header 'X-Session-Id: abc123' + +# Non-MPP services (no 402 challenge) are passed through unchanged +cash pay https://api.example.com/free-resource +# -> {"ok": true, "data": {"url": "...", "status": 200, "body": {...}, "paid": false}} +``` + +Output fields: + +- `paid` — `true` if a Lightning payment was made, `false` if no 402 challenge +- `method` — payment method used (currently `"lightning"`) +- `preimage` — Lightning payment preimage (proof of payment) +- `body` — the API response body (parsed as JSON if possible) +- `status` — HTTP status of the final response + +Supported MPP methods: `lightning` (pays BOLT11 invoice; submits preimage as proof). + +For service discovery, see [mpp.dev](https://mpp.dev) — a registry of APIs that accept MPP payments. + +Common MPP errors: + +- `"No supported MPP payment challenge found"` — service requires `tempo` or `stripe` (not yet supported); use a different payment method or service +- `"MPP lightning challenge missing methodDetails.invoice"` — malformed challenge from server; contact service provider +- `"Insufficient BTC balance"` — run `cash balance` and top up via `cash receive` + ### Check Balance ```bash diff --git a/cli/src/commands/pay.ts b/cli/src/commands/pay.ts index 4a7a220..f11bcbb 100644 --- a/cli/src/commands/pay.ts +++ b/cli/src/commands/pay.ts @@ -20,8 +20,8 @@ export async function handlePay( " cash pay --method POST --body '{\"query\":\"test\"}'\n" + " cash pay --header 'Authorization: Bearer token'\n\n" + "The command fetches the URL. If the server returns an MPP 402 payment\n" + - "challenge, claw-cash automatically swaps BTC→stablecoin and retries\n" + - "with payment proof." + "challenge (WWW-Authenticate: Payment ...), claw-cash automatically pays\n" + + "the Lightning invoice and retries with the Authorization: Payment credential." ); } @@ -46,9 +46,8 @@ export async function handlePay( } const client = new MppClient({ - swap: { - swapBtcToStablecoin: (params) => - ctx.swap.swapBtcToStablecoin(params as Parameters[0]), + lightning: { + payInvoice: (params) => ctx.lightning.payInvoice(params), }, }); @@ -67,8 +66,8 @@ export async function handlePay( status: result.status, body: tryParseJson(result.body), paid: !!result.proof, - swap_id: result.swap_id, - proof: result.proof, + method: result.proof?.challenge.method, + preimage: result.paymentPreimage, }); } diff --git a/cli/src/mpp/client.ts b/cli/src/mpp/client.ts index 84d2d6d..919ffef 100644 --- a/cli/src/mpp/client.ts +++ b/cli/src/mpp/client.ts @@ -1,92 +1,177 @@ /** * MPP (Machine Payments Protocol) client for claw-cash. * - * Implements the HTTP 402 challenge-then-retry flow: + * Implements the IETF "Payment" HTTP Authentication Scheme (draft-httpauth-payment-00). + * + * Flow: * 1. Fetch the URL - * 2. If 402 + MPP-Version header → parse payment requirements - * 3. Pick the first supported requirement - * 4. Swap BTC→stablecoin via claw-cash infrastructure - * 5. Retry the request with MPP-Authorization header - * 6. Return the final response + * 2. If 402 + `WWW-Authenticate: Payment ...` → parse challenge(s) + * 3. Pick the first supported challenge (currently: method="lightning", intent="charge") + * 4. Pay the Lightning invoice from the challenge's decoded `request` + * 5. Build `Authorization: Payment ` + * 6. Retry the original request with Authorization header + * 7. Return the final response */ import type { - MppPaymentRequired, - MppPaymentProof, + MppChallenge, + MppCredential, MppPayResult, MppClientConfig, + LightningChargeRequest, } from "./types.js"; -import { - MPP_VERSION, - MPP_HEADER_VERSION, - MPP_HEADER_AUTHORIZATION, -} from "./types.js"; -import { isSupportedRequirement, mapMppCurrencyToSwapParams } from "./currency.js"; +import { isSupportedChallenge } from "./method.js"; -// ── Protocol helpers ─────────────────────────────────────────────── +// ── WWW-Authenticate header parsing ──────────────────────────────── /** - * Check if a Response is an MPP 402 payment-required response. + * Parse all `Payment` challenges from a `WWW-Authenticate` header value. + * + * A single header value may contain multiple challenges separated by commas, + * or the server may return multiple WWW-Authenticate headers (RFC 9110 §11.6.1). + * We receive whatever `res.headers.get()` returns (which concatenates multiple + * headers with ", " in the Fetch API). + * + * Each challenge looks like: + * Payment id="abc", realm="api.example.com", method="lightning", intent="charge", + * request="base64url...", expires="2025-01-15T12:05:00Z" */ -export function isMppResponse(res: Response): boolean { - return res.status === 402 && res.headers.has(MPP_HEADER_VERSION); +export function parseMppChallenges(headerValue: string): MppChallenge[] { + const challenges: MppChallenge[] = []; + + // Split on "Payment" keyword boundaries to separate multiple challenges. + // We match each `Payment ...` segment up to the next `Payment` or end of string. + const segments = headerValue.split(/(?=\bPayment\s)/i).filter(Boolean); + + for (const segment of segments) { + const trimmed = segment.trim(); + if (!/^Payment\s/i.test(trimmed)) continue; + + const params = parseAuthParams(trimmed.slice("Payment".length).trim()); + + const id = params["id"]; + const realm = params["realm"]; + const method = params["method"]; + const intent = params["intent"]; + const request = params["request"]; + + // id, realm, method, intent, request are required per the spec + if (!id || !realm || !method || !intent || !request) continue; + + const challenge: MppChallenge = { id, realm, method, intent, request }; + if (params["expires"]) challenge.expires = params["expires"]; + if (params["description"]) challenge.description = params["description"]; + if (params["opaque"]) challenge.opaque = params["opaque"]; + + challenges.push(challenge); + } + + return challenges; } /** - * Parse the MPP payment requirements from a 402 response body. + * Parse RFC 9110 auth-params: `key="value"` or `key=token` pairs, comma-separated. + * Returns a flat string→string map. */ -export async function parseMppResponse(res: Response): Promise { - let body: unknown; - try { - body = await res.json(); - } catch { - throw new Error("MPP: failed to parse 402 response body as JSON"); +function parseAuthParams(input: string): Record { + const params: Record = {}; + // Match key="quoted-value" or key=token + const re = /(\w[\w-]*)=(?:"([^"\\]*)"|([^\s,]+))/g; + let m: RegExpExecArray | null; + while ((m = re.exec(input)) !== null) { + params[m[1].toLowerCase()] = m[2] !== undefined ? m[2] : m[3]; } + return params; +} - const data = body as Record; - if (!Array.isArray(data.requirements)) { - throw new Error("MPP: 402 response missing requirements array"); - } +/** + * Returns true if the response is a valid MPP 402 challenge + * (status 402 + at least one parseable `WWW-Authenticate: Payment` challenge). + */ +export function isMppResponse(res: Response): boolean { + if (res.status !== 402) return false; + const header = res.headers.get("WWW-Authenticate") ?? ""; + return parseMppChallenges(header).length > 0; +} + +// ── Base64url helpers ─────────────────────────────────────────────── + +/** Decode a base64url (no-padding) string to a UTF-8 string. */ +export function decodeBase64url(b64url: string): string { + // Restore standard base64 padding and characters + const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/"); + const padded = b64 + "==".slice((b64.length % 4 === 0) ? 4 : b64.length % 4); + return Buffer.from(padded, "base64").toString("utf-8"); +} + +/** Encode a UTF-8 string as base64url (no padding). */ +export function encodeBase64url(s: string): string { + return Buffer.from(s, "utf-8") + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); +} - return data as unknown as MppPaymentRequired; +/** Decode a base64url-encoded JSON value. */ +export function decodeBase64urlJson(b64url: string): T { + return JSON.parse(decodeBase64url(b64url)) as T; } +// ── JCS (RFC 8785) canonical JSON ────────────────────────────────── + /** - * Create an MPP payment proof from swap result data. + * Produce JCS (JSON Canonicalization Scheme) output: keys sorted recursively, + * no extra whitespace. Handles the object shapes we control (string, number, + * boolean, null, nested objects, arrays). Sufficient for MPP credential + * serialization; not a general-purpose JCS implementation. */ -export function createMppProof(params: Omit): MppPaymentProof { - return { - version: MPP_VERSION, - ...params, - }; +export function jcsStringify(value: unknown): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return "[" + value.map(jcsStringify).join(",") + "]"; + } + const obj = value as Record; + const keys = Object.keys(obj).sort(); + const pairs = keys.map((k) => JSON.stringify(k) + ":" + jcsStringify(obj[k])); + return "{" + pairs.join(",") + "}"; } +// ── Credential building ───────────────────────────────────────────── + /** - * Encode a payment proof as a base64 string for the MPP-Authorization header. + * Build the `Authorization: Payment ` header value for a credential. + * The credential is JCS-serialized and then base64url-encoded (no padding). */ -export function buildMppAuthorizationHeader(proof: MppPaymentProof): string { - return Buffer.from(JSON.stringify(proof), "utf-8").toString("base64"); +export function buildAuthorizationHeader(credential: MppCredential): string { + return "Payment " + encodeBase64url(jcsStringify(credential)); } // ── Client ───────────────────────────────────────────────────────── export class MppClient { - private swap: MppClientConfig["swap"]; + private lightning: MppClientConfig["lightning"]; private fetch: typeof globalThis.fetch; constructor(config: MppClientConfig) { - this.swap = config.swap; + this.lightning = config.lightning; this.fetch = config.fetch ?? globalThis.fetch; } /** * Fetch a URL, automatically handling MPP 402 payment challenges. * - * If the server returns 402 with MPP headers, the client will: - * 1. Parse the payment requirements - * 2. Pick the first requirement that claw-cash can fulfill - * 3. Swap BTC→stablecoin to pay - * 4. Retry the request with payment proof + * If the server returns 402 with `WWW-Authenticate: Payment ...` headers: + * 1. Parse all challenges + * 2. Pick the first supported one (method="lightning", intent="charge") + * 3. Decode the BOLT11 invoice from the challenge request + * 4. Pay the invoice via claw-cash Lightning skill → get preimage + * 5. Build and submit `Authorization: Payment ` + * 6. Return the retried response + * + * Non-MPP 402 responses (no Payment challenge) are passed through unchanged. */ async pay(url: string, init?: RequestInit): Promise { const res = await this.fetch(url, init); @@ -102,77 +187,80 @@ export class MppClient { }; } - // Parse the MPP payment requirements - const paymentRequired = await parseMppResponse(res); + // Parse the MPP challenges from WWW-Authenticate + const headerValue = res.headers.get("WWW-Authenticate") ?? ""; + const challenges = parseMppChallenges(headerValue); - // Find the first requirement we can fulfill (skip expired ones) + // Find the first challenge we can fulfill (not expired, supported method) const now = Date.now(); - const requirement = paymentRequired.requirements.find((r) => { - if (!isSupportedRequirement(r.currency, r.network)) return false; - if (r.expires_at && new Date(r.expires_at).getTime() <= now) return false; + const challenge = challenges.find((c) => { + if (!isSupportedChallenge(c)) return false; + if (c.expires && new Date(c.expires).getTime() <= now) return false; return true; }); - if (!requirement) { - const networks = paymentRequired.requirements.map((r) => `${r.currency}/${r.network}`); + if (!challenge) { + const methods = challenges.map((c) => `${c.method}/${c.intent}`); throw new Error( - `No supported payment requirement found. Server requires: ${networks.join(", ")}. ` + - `Claw-cash supports: USD/USDC/USDT on polygon, ethereum, arbitrum, tempo.` + `No supported MPP payment challenge found. Server offered: ${methods.join(", ")}. ` + + `claw-cash supports: lightning/charge.` ); } - // Swap BTC → stablecoin - const swapParams = mapMppCurrencyToSwapParams( - requirement.currency, - requirement.network, - requirement.recipient, - requirement.amount, - ); - - const swapResult = await this.swap.swapBtcToStablecoin(swapParams); - - // Build payment proof - const proof = createMppProof({ - requirement_id: requirement.id, - tx_id: swapResult.swapId, - network: requirement.network, - amount: requirement.amount, - currency: requirement.currency, - session_id: requirement.session_id, - }); - - const authHeader = buildMppAuthorizationHeader(proof); - - // Merge original headers with MPP-Authorization - const retryHeaders: Record = {}; - if (init?.headers) { - const entries = - init.headers instanceof Headers - ? [...init.headers.entries()] - : Array.isArray(init.headers) - ? init.headers - : Object.entries(init.headers); - for (const [k, v] of entries) { - retryHeaders[k] = v; + // ── Lightning charge ──────────────────────────────────────────── + if (challenge.method === "lightning") { + const req = decodeBase64urlJson(challenge.request); + const bolt11 = req.methodDetails?.invoice; + if (!bolt11) { + throw new Error("MPP lightning challenge missing methodDetails.invoice"); } + + const { preimage } = await this.lightning.payInvoice({ bolt11 }); + + const credential: MppCredential = { + challenge, + payload: { preimage }, + }; + + const retryHeaders = mergeHeaders(init?.headers, { + Authorization: buildAuthorizationHeader(credential), + }); + + const retryRes = await this.fetch(url, { ...init, headers: retryHeaders }); + const retryBody = await retryRes.text(); + + return { + ok: retryRes.ok, + status: retryRes.status, + body: retryBody, + headers: Object.fromEntries(retryRes.headers.entries()), + proof: credential, + paymentPreimage: preimage, + }; } - retryHeaders[MPP_HEADER_AUTHORIZATION] = authHeader; - // Retry the original request with payment proof - const retryRes = await this.fetch(url, { - ...init, - headers: retryHeaders, - }); + // Should never reach here (isSupportedChallenge guards above) + throw new Error(`Unsupported MPP method: ${challenge.method}`); + } +} - const retryBody = await retryRes.text(); +// ── Internal helpers ──────────────────────────────────────────────── - return { - ok: retryRes.ok, - status: retryRes.status, - body: retryBody, - headers: Object.fromEntries(retryRes.headers.entries()), - proof, - swap_id: swapResult.swapId, - }; +function mergeHeaders( + base: RequestInit["headers"] | undefined, + extra: Record, +): Record { + const result: Record = {}; + if (base) { + const entries = + base instanceof Headers + ? [...base.entries()] + : Array.isArray(base) + ? (base as [string, string][]) + : Object.entries(base as Record); + for (const [k, v] of entries) { + result[k] = v; + } } + return { ...result, ...extra }; } diff --git a/cli/src/mpp/currency.ts b/cli/src/mpp/currency.ts deleted file mode 100644 index 074fb78..0000000 --- a/cli/src/mpp/currency.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Maps MPP payment requirement currencies and networks to claw-cash swap - * parameters. Agents hold BTC as treasury and swap on-the-fly to stablecoins - * to pay MPP-enabled services. - */ - -import type { StablecoinToken, EvmChain } from "@clw-cash/skills"; - -// Networks that claw-cash can settle on via LendaSwap -const SUPPORTED_NETWORKS = new Set(["polygon", "ethereum", "arbitrum", "tempo"]); - -// Tempo settles via Polygon bridge by default (EVM-compatible, lowest fees) -const TEMPO_BRIDGE_CHAIN: EvmChain = "polygon"; - -const CHAIN_MAP: Record = { - polygon: "polygon", - ethereum: "ethereum", - arbitrum: "arbitrum", - tempo: TEMPO_BRIDGE_CHAIN, -}; - -const USDC_TOKEN_MAP: Record = { - polygon: "usdc_pol", - ethereum: "usdc_eth", - arbitrum: "usdc_arb", -}; - -const USDT_TOKEN_MAP: Record = { - polygon: "usdt0_pol", - ethereum: "usdt_eth", - arbitrum: "usdt_arb", -}; - -// Currencies where the amount is in cents (smallest unit) -const CENTS_CURRENCIES = new Set(["USD"]); - -export interface SwapParams { - targetAddress: string; - targetToken: StablecoinToken; - targetChain: EvmChain; - targetAmount: number; -} - -/** - * Returns true if claw-cash can fulfill a payment requirement on the given - * network and currency. - */ -export function isSupportedRequirement(currency: string, network: string): boolean { - if (!SUPPORTED_NETWORKS.has(network)) return false; - const upper = currency.toUpperCase(); - return upper === "USD" || upper === "USDC" || upper === "USDT"; -} - -/** - * Map an MPP payment requirement to claw-cash BTC→stablecoin swap parameters. - * - * - USD amounts are in cents → converted to dollar units for stablecoin swap - * - USDC/USDT amounts are in token units (no conversion) - * - Tempo network is bridged via Polygon (lowest fees) - */ -export function mapMppCurrencyToSwapParams( - currency: string, - network: string, - recipient: string, - amount: number, -): SwapParams { - const upper = currency.toUpperCase(); - const chain = CHAIN_MAP[network]; - - if (!chain) { - throw new Error(`Unsupported MPP network: ${network}. Supported: ${[...SUPPORTED_NETWORKS].join(", ")}`); - } - - let token: StablecoinToken; - let targetAmount: number; - - switch (upper) { - case "USD": - case "USDC": { - token = USDC_TOKEN_MAP[chain]; - // USD is in cents, USDC is direct - targetAmount = CENTS_CURRENCIES.has(upper) ? amount / 100 : amount; - break; - } - case "USDT": { - token = USDT_TOKEN_MAP[chain]; - targetAmount = CENTS_CURRENCIES.has(upper) ? amount / 100 : amount; - break; - } - default: - throw new Error(`Unsupported MPP currency: ${upper}. Supported: USD, USDC, USDT`); - } - - return { - targetAddress: recipient, - targetToken: token, - targetChain: chain, - targetAmount, - }; -} diff --git a/cli/src/mpp/index.ts b/cli/src/mpp/index.ts index f98f817..96e5850 100644 --- a/cli/src/mpp/index.ts +++ b/cli/src/mpp/index.ts @@ -1,4 +1,12 @@ -export { MppClient, isMppResponse, parseMppResponse, createMppProof, buildMppAuthorizationHeader } from "./client.js"; -export { mapMppCurrencyToSwapParams, isSupportedRequirement } from "./currency.js"; -export type { SwapParams } from "./currency.js"; +export { + MppClient, + isMppResponse, + parseMppChallenges, + buildAuthorizationHeader, + decodeBase64url, + encodeBase64url, + decodeBase64urlJson, + jcsStringify, +} from "./client.js"; +export { isSupportedChallenge, SUPPORTED_MPP_METHODS } from "./method.js"; export * from "./types.js"; diff --git a/cli/src/mpp/method.ts b/cli/src/mpp/method.ts new file mode 100644 index 0000000..e94697c --- /dev/null +++ b/cli/src/mpp/method.ts @@ -0,0 +1,28 @@ +/** + * MPP payment method support matrix for claw-cash. + * + * MPP payment methods (from the spec): + * - "lightning" — Bitcoin Lightning Network (BOLT11 invoices + preimage proof) ✅ + * - "tempo" — Tempo blockchain (chain ID 42431, TIP-20 tokens) ❌ not in LendaSwap + * - "stripe" — Stripe Payment Tokens (card-based) ❌ + * - "card" — Generic card payments ❌ + * + * claw-cash holds BTC as treasury. The only MPP method currently supported is + * "lightning": pay the BOLT11 invoice from the challenge, submit the preimage. + */ + +import type { MppChallenge } from "./types.js"; + +/** MPP methods claw-cash can fulfill, in preference order. */ +export const SUPPORTED_MPP_METHODS = ["lightning"] as const; + +/** + * Returns true if claw-cash can fulfill this challenge. + * Currently: method must be "lightning" and intent must be "charge". + */ +export function isSupportedChallenge(challenge: MppChallenge): boolean { + return ( + (SUPPORTED_MPP_METHODS as readonly string[]).includes(challenge.method) && + challenge.intent === "charge" + ); +} diff --git a/cli/src/mpp/types.ts b/cli/src/mpp/types.ts index ef9704c..f740799 100644 --- a/cli/src/mpp/types.ts +++ b/cli/src/mpp/types.ts @@ -1,130 +1,117 @@ /** * MPP (Machine Payments Protocol) types. * - * MPP is an open protocol for machine-to-machine payments co-authored by - * Stripe and Tempo. It uses the HTTP 402 challenge-then-retry pattern: + * MPP uses the IETF "Payment" HTTP Authentication Scheme (draft-httpauth-payment-00). + * See: https://paymentauth.org * + * Protocol flow: * 1. Client requests a resource. - * 2. Server responds 402 with `MPP-Version` + JSON body containing payment - * requirements (amount, currency, recipient, network, optional session). - * 3. Client pays (BTC→stablecoin swap via claw-cash infrastructure) and - * retries with an `MPP-Authorization` header carrying the payment proof. + * 2. Server responds 402 with `WWW-Authenticate: Payment id="...", method="...", ...` + * The `request` param is base64url-encoded JCS JSON with method-specific payment data. + * 3. Client pays (e.g., Lightning invoice) and retries with: + * `Authorization: Payment ` * 4. Server verifies and returns the resource. * - * Sessions ("OAuth for money") let the client authorize once and stream - * payments within defined limits. + * Supported payment methods: + * - "lightning": client pays BOLT11 invoice and submits the preimage as proof + * - "tempo": Tempo chain (ID 42431) — not yet supported by claw-cash swap infrastructure + * - "stripe", "card": not supported (card-based, not BTC) */ -// ── Protocol constants ───────────────────────────────── +// ── Challenge (from WWW-Authenticate: Payment header) ────────────── -export const MPP_VERSION = "1"; - -export const MPP_HEADER_VERSION = "MPP-Version"; -export const MPP_HEADER_AUTHORIZATION = "MPP-Authorization"; - -// ── Payment requirements (server → client on 402) ───── - -export interface MppPaymentRequirement { - /** Unique id for this requirement set */ +export interface MppChallenge { + /** Unique challenge identifier (HMAC-SHA256 of slots, base64url-encoded) */ id: string; - /** Payment amount in the smallest unit of the currency (e.g. cents for USD) */ - amount: number; - /** ISO 4217 currency code — typically "USD" */ - currency: string; - /** Recipient address on the target network */ - recipient: string; - /** Network/rail to settle on — e.g. "tempo", "polygon", "ethereum", "base" */ - network: string; - /** Human-readable description of the resource being purchased */ + /** Protection space identifier (typically the API hostname) */ + realm: string; + /** Payment method: "lightning", "tempo", "stripe", "card" */ + method: string; + /** Payment intent: "charge" (one-shot) or "session" (channel) */ + intent: string; + /** base64url-encoded (no padding) JCS JSON with method-specific payment data */ + request: string; + /** Optional: RFC3339 expiry timestamp */ + expires?: string; + /** Optional: human-readable description (display only) */ description?: string; - /** Optional: expiry timestamp (ISO 8601) for the payment requirement */ - expires_at?: string; - /** Optional: session id if the server supports MPP sessions */ - session_id?: string; + /** Optional: opaque base64url value — must be echoed back in credential */ + opaque?: string; } -export interface MppPaymentRequired { - /** Protocol version */ - version: string; - /** One or more acceptable payment options */ - requirements: MppPaymentRequirement[]; -} +// ── Method-specific request data (decoded from challenge.request) ─── -// ── Payment proof (client → server on retry) ─────────── +/** Lightning charge request (method="lightning", intent="charge") */ +export interface LightningChargeRequest { + /** Stringified integer satoshis */ + amount: string; + currency: "sat"; + description?: string; + methodDetails: { + /** BOLT11 invoice to pay */ + invoice: string; + }; +} -export interface MppPaymentProof { - /** Protocol version */ - version: string; - /** The requirement id the client chose to fulfill */ - requirement_id: string; - /** On-chain transaction id / swap id proving payment */ - tx_id: string; - /** Network the payment was settled on */ - network: string; - /** Amount paid (smallest unit) */ - amount: number; - /** Currency paid */ +/** Tempo charge request (method="tempo", intent="charge") */ +export interface TempoChargeRequest { + /** Stringified integer in base units (6 decimals) */ + amount: string; + /** TIP-20 token contract address */ currency: string; - /** Optional session id for session-based payments */ - session_id?: string; + /** Recipient address on Tempo chain */ + recipient: string; + description?: string; + externalId?: string; + methodDetails?: { + chainId?: number; + feePayer?: boolean; + memo?: string; + }; } -// ── Session types ────────────────────────────────────── +// ── Credential payload (method-specific, included in Authorization header) ─ -export interface MppSession { - /** Unique session identifier */ - session_id: string; - /** Maximum total spend in smallest currency unit */ - budget: number; - /** ISO 4217 currency code */ - currency: string; - /** Amount already spent in this session */ - spent: number; - /** Session expiry (ISO 8601) */ - expires_at: string; +/** Lightning proof: 32-byte lowercase hex preimage */ +export interface LightningChargePayload { + preimage: string; } -// ── Client configuration ─────────────────────────────── +// ── Credential (client → server in Authorization: Payment header) ─── -export interface MppSwapParams { - targetAddress: string; - targetToken: string; - targetChain: string; - targetAmount: number; +export interface MppCredential { + /** Echo of all challenge params from the WWW-Authenticate header */ + challenge: MppChallenge; + /** Optional payer DID identifier (did:pkh format) */ + source?: string; + /** Method-specific payment proof */ + payload: LightningChargePayload | Record; } -export interface MppSwapResult { - swapId: string; - status: string; -} +// ── Client configuration ──────────────────────────────────────────── export interface MppClientConfig { - /** The swap skill for BTC→stablecoin conversions */ - swap: { - swapBtcToStablecoin(params: MppSwapParams): Promise; + /** Lightning skill for paying BOLT11 invoices */ + lightning: { + payInvoice(params: { bolt11: string }): Promise<{ preimage: string }>; }; /** Optional: custom fetch implementation (for testing) */ fetch?: typeof globalThis.fetch; } -// ── Result types ─────────────────────────────────────── +// ── Result types ──────────────────────────────────────────────────── export interface MppPayResult { /** Whether the resource was successfully retrieved */ ok: boolean; /** HTTP status code of the final response */ status: number; - /** The response body (resource content) */ + /** The response body */ body: string; /** Response headers */ headers: Record; - /** The payment proof that was submitted (if payment was made) */ - proof?: MppPaymentProof; - /** The swap id from claw-cash (if a BTC→stablecoin swap occurred) */ - swap_id?: string; -} - -export interface MppError { - code: string; - message: string; + /** The credential submitted (if payment was made) */ + proof?: MppCredential; + /** Lightning payment preimage (if lightning method was used) */ + paymentPreimage?: string; } diff --git a/test/mpp.test.ts b/test/mpp.test.ts index f2e7f6a..69a3687 100644 --- a/test/mpp.test.ts +++ b/test/mpp.test.ts @@ -1,574 +1,607 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { - parseMppResponse, + parseMppChallenges, isMppResponse, - createMppProof, - buildMppAuthorizationHeader, + buildAuthorizationHeader, + decodeBase64url, + decodeBase64urlJson, + encodeBase64url, + jcsStringify, MppClient, } from "../cli/src/mpp/client.js"; -import type { - MppPaymentRequired, - MppPaymentProof, - MppClientConfig, -} from "../cli/src/mpp/types.js"; -import { - MPP_VERSION, - MPP_HEADER_VERSION, - MPP_HEADER_AUTHORIZATION, -} from "../cli/src/mpp/types.js"; -import { - mapMppCurrencyToSwapParams, -} from "../cli/src/mpp/currency.js"; +import { isSupportedChallenge } from "../cli/src/mpp/method.js"; +import type { MppChallenge, MppCredential, LightningChargeRequest } from "../cli/src/mpp/types.js"; -// ─── Helper: create a mock 402 Response ──────────────────────────── +// ─── Helpers ─────────────────────────────────────────────────────── -function mock402Response(body: MppPaymentRequired): Response { - return new Response(JSON.stringify(body), { - status: 402, - headers: { - "content-type": "application/json", - [MPP_HEADER_VERSION]: MPP_VERSION, - }, - }); +/** Encode a request object as base64url for use in WWW-Authenticate `request` param */ +function encodeRequest(obj: unknown): string { + return encodeBase64url(JSON.stringify(obj)); +} + +/** Build a WWW-Authenticate: Payment header string */ +function buildChallenge(params: { + id?: string; + realm?: string; + method?: string; + intent?: string; + request?: string; + expires?: string; + description?: string; + opaque?: string; +}): string { + const { + id = "chall_abc123", + realm = "api.example.com", + method = "lightning", + intent = "charge", + request = encodeRequest({ amount: "1000", currency: "sat", methodDetails: { invoice: "lnbc100n1..." } }), + expires, + description, + opaque, + } = params; + + let h = `Payment id="${id}", realm="${realm}", method="${method}", intent="${intent}", request="${request}"`; + if (expires) h += `, expires="${expires}"`; + if (description) h += `, description="${description}"`; + if (opaque) h += `, opaque="${opaque}"`; + return h; } -function mock200Response(body: string): Response { +/** Build a 402 Response with one or more WWW-Authenticate: Payment challenges */ +function mock402(challengeHeader: string): Response { + return new Response( + JSON.stringify({ + type: "https://paymentauth.org/problems/payment-required", + title: "Payment Required", + status: 402, + }), + { + status: 402, + headers: { + "content-type": "application/json", + "WWW-Authenticate": challengeHeader, + }, + } + ); +} + +function mock200(body: string): Response { return new Response(body, { status: 200, headers: { "content-type": "text/plain" }, }); } -// ─── Mock swap skill ─────────────────────────────────────────────── +/** Create a standard lightning challenge request blob */ +function lightningRequest(invoice = "lnbc100n1pj..."): LightningChargeRequest { + return { + amount: "1000", + currency: "sat", + methodDetails: { invoice }, + }; +} + +/** Decode the Authorization: Payment header back to a credential object */ +function decodeAuthorizationHeader(headerValue: string): MppCredential { + const b64url = headerValue.replace(/^Payment\s+/i, ""); + return decodeBase64urlJson(b64url); +} + +// ─── Mock lightning skill ─────────────────────────────────────────── -function createMockSwap() { +function createMockLightning(preimage = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890") { return { - swapBtcToStablecoin: vi.fn().mockResolvedValue({ - swapId: "swap_abc123", - status: "completed", - }), + payInvoice: vi.fn().mockResolvedValue({ preimage }), }; } // ───────────────────────────────────────────────────────────────────── -// Unit tests: protocol parsing +// Unit tests: WWW-Authenticate header parsing // ───────────────────────────────────────────────────────────────────── -describe("MPP protocol parsing", () => { - it("isMppResponse detects 402 with MPP-Version header", () => { - const res = mock402Response({ - version: MPP_VERSION, - requirements: [{ - id: "req_1", - amount: 100, - currency: "USD", - recipient: "0xabc", - network: "tempo", - }], - }); +describe("parseMppChallenges", () => { + it("parses a single lightning challenge", () => { + const header = buildChallenge({ method: "lightning", intent: "charge" }); + const challenges = parseMppChallenges(header); + expect(challenges).toHaveLength(1); + expect(challenges[0].id).toBe("chall_abc123"); + expect(challenges[0].realm).toBe("api.example.com"); + expect(challenges[0].method).toBe("lightning"); + expect(challenges[0].intent).toBe("charge"); + expect(challenges[0].request).toBeTruthy(); + }); + + it("parses expires optional parameter", () => { + const expires = "2030-01-01T00:00:00Z"; + const header = buildChallenge({ expires }); + const challenges = parseMppChallenges(header); + expect(challenges[0].expires).toBe(expires); + }); + + it("parses description optional parameter", () => { + const header = buildChallenge({ description: "API call fee" }); + const challenges = parseMppChallenges(header); + expect(challenges[0].description).toBe("API call fee"); + }); + + it("parses opaque optional parameter", () => { + const header = buildChallenge({ opaque: "abc123xyz" }); + const challenges = parseMppChallenges(header); + expect(challenges[0].opaque).toBe("abc123xyz"); + }); + + it("parses multiple challenges in a single header value", () => { + const req = encodeRequest({ amount: "1000", currency: "sat", methodDetails: { invoice: "lnbc..." } }); + const header = [ + `Payment id="chall_1", realm="api.example.com", method="lightning", intent="charge", request="${req}"`, + `Payment id="chall_2", realm="api.example.com", method="tempo", intent="charge", request="${req}"`, + ].join(", "); + + const challenges = parseMppChallenges(header); + expect(challenges).toHaveLength(2); + expect(challenges[0].id).toBe("chall_1"); + expect(challenges[0].method).toBe("lightning"); + expect(challenges[1].id).toBe("chall_2"); + expect(challenges[1].method).toBe("tempo"); + }); + + it("returns empty array for non-Payment WWW-Authenticate header", () => { + const challenges = parseMppChallenges("Bearer realm=\"example.com\""); + expect(challenges).toHaveLength(0); + }); + + it("returns empty array for empty header", () => { + expect(parseMppChallenges("")).toHaveLength(0); + }); + + it("skips challenges missing required fields", () => { + // Missing 'method' field + const header = `Payment id="chall_1", realm="api.example.com", intent="charge", request="abc"`; + const challenges = parseMppChallenges(header); + expect(challenges).toHaveLength(0); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// Unit tests: isMppResponse +// ───────────────────────────────────────────────────────────────────── + +describe("isMppResponse", () => { + it("returns true for 402 with WWW-Authenticate: Payment challenge", () => { + const res = mock402(buildChallenge({})); expect(isMppResponse(res)).toBe(true); }); - it("isMppResponse returns false for regular 402 without MPP-Version", () => { + it("returns false for 402 without WWW-Authenticate header", () => { const res = new Response("Payment Required", { status: 402 }); expect(isMppResponse(res)).toBe(false); }); - it("isMppResponse returns false for 200 with MPP-Version", () => { - const res = new Response("ok", { - status: 200, - headers: { [MPP_HEADER_VERSION]: MPP_VERSION }, + it("returns false for 402 with non-Payment WWW-Authenticate (e.g. Bearer)", () => { + const res = new Response("Unauthorized", { + status: 402, + headers: { "WWW-Authenticate": 'Bearer realm="api.example.com"' }, }); expect(isMppResponse(res)).toBe(false); }); - it("parseMppResponse extracts payment requirements from 402 body", async () => { - const body: MppPaymentRequired = { - version: MPP_VERSION, - requirements: [ - { - id: "req_1", - amount: 500, - currency: "USD", - recipient: "0xdeadbeef", - network: "polygon", - description: "API call to /v1/data", - }, - { - id: "req_2", - amount: 500, - currency: "USD", - recipient: "0xdeadbeef", - network: "tempo", - }, - ], - }; - const res = mock402Response(body); - const parsed = await parseMppResponse(res); - expect(parsed.version).toBe(MPP_VERSION); - expect(parsed.requirements).toHaveLength(2); - expect(parsed.requirements[0].amount).toBe(500); - expect(parsed.requirements[0].network).toBe("polygon"); - expect(parsed.requirements[1].network).toBe("tempo"); - }); - - it("parseMppResponse throws on invalid JSON", async () => { - const res = new Response("not json", { - status: 402, - headers: { - "content-type": "application/json", - [MPP_HEADER_VERSION]: MPP_VERSION, - }, + it("returns false for 200 with WWW-Authenticate: Payment", () => { + const res = new Response("ok", { + status: 200, + headers: { "WWW-Authenticate": buildChallenge({}) }, }); - await expect(parseMppResponse(res)).rejects.toThrow(); + expect(isMppResponse(res)).toBe(false); }); - it("parseMppResponse throws when requirements array is missing", async () => { - const res = new Response(JSON.stringify({ version: "1" }), { - status: 402, - headers: { - "content-type": "application/json", - [MPP_HEADER_VERSION]: MPP_VERSION, - }, + it("returns false for 401 with WWW-Authenticate: Payment", () => { + const res = new Response("unauthorized", { + status: 401, + headers: { "WWW-Authenticate": buildChallenge({}) }, }); - await expect(parseMppResponse(res)).rejects.toThrow("requirements"); + expect(isMppResponse(res)).toBe(false); }); }); // ───────────────────────────────────────────────────────────────────── -// Unit tests: payment proof creation +// Unit tests: isSupportedChallenge // ───────────────────────────────────────────────────────────────────── -describe("MPP payment proof", () => { - it("createMppProof builds a valid proof object", () => { - const proof = createMppProof({ - requirement_id: "req_1", - tx_id: "0xtx123", - network: "polygon", - amount: 500, - currency: "USD", - }); - expect(proof.version).toBe(MPP_VERSION); - expect(proof.requirement_id).toBe("req_1"); - expect(proof.tx_id).toBe("0xtx123"); - expect(proof.network).toBe("polygon"); - expect(proof.amount).toBe(500); - expect(proof.currency).toBe("USD"); - }); - - it("createMppProof includes session_id when provided", () => { - const proof = createMppProof({ - requirement_id: "req_1", - tx_id: "0xtx123", - network: "polygon", - amount: 500, - currency: "USD", - session_id: "sess_abc", - }); - expect(proof.session_id).toBe("sess_abc"); +describe("isSupportedChallenge", () => { + const base: MppChallenge = { + id: "c1", + realm: "api.example.com", + method: "lightning", + intent: "charge", + request: "abc", + }; + + it("accepts lightning/charge", () => { + expect(isSupportedChallenge(base)).toBe(true); }); - it("buildMppAuthorizationHeader encodes proof as base64 JSON", () => { - const proof: MppPaymentProof = { - version: MPP_VERSION, - requirement_id: "req_1", - tx_id: "0xtx123", - network: "polygon", - amount: 500, - currency: "USD", - }; - const header = buildMppAuthorizationHeader(proof); - const decoded = JSON.parse(Buffer.from(header, "base64").toString("utf-8")); - expect(decoded.requirement_id).toBe("req_1"); - expect(decoded.tx_id).toBe("0xtx123"); + it("rejects tempo/charge", () => { + expect(isSupportedChallenge({ ...base, method: "tempo" })).toBe(false); + }); + + it("rejects stripe/charge", () => { + expect(isSupportedChallenge({ ...base, method: "stripe" })).toBe(false); + }); + + it("rejects lightning/session (only charge is supported)", () => { + expect(isSupportedChallenge({ ...base, intent: "session" })).toBe(false); + }); + + it("rejects unknown method", () => { + expect(isSupportedChallenge({ ...base, method: "card" })).toBe(false); }); }); // ───────────────────────────────────────────────────────────────────── -// Unit tests: currency mapping (MPP → claw-cash swap params) +// Unit tests: base64url + JCS helpers // ───────────────────────────────────────────────────────────────────── -describe("MPP currency mapping", () => { - it("maps USD on polygon to usdc_pol", () => { - const params = mapMppCurrencyToSwapParams("USD", "polygon", "0xrecipient", 1000); - expect(params.targetToken).toBe("usdc_pol"); - expect(params.targetChain).toBe("polygon"); - expect(params.targetAddress).toBe("0xrecipient"); - // 1000 cents = $10.00 - expect(params.targetAmount).toBe(10); +describe("base64url helpers", () => { + it("encodes and decodes a string round-trip", () => { + const original = '{"hello":"world","num":42}'; + const encoded = encodeBase64url(original); + expect(encoded).not.toContain("="); + expect(encoded).not.toContain("+"); + expect(encoded).not.toContain("/"); + expect(decodeBase64url(encoded)).toBe(original); + }); + + it("decodeBase64urlJson parses JSON from base64url", () => { + const obj = { amount: "1000", currency: "sat", methodDetails: { invoice: "lnbc..." } }; + const encoded = encodeBase64url(JSON.stringify(obj)); + const decoded = decodeBase64urlJson(encoded); + expect(decoded.amount).toBe("1000"); + expect(decoded.currency).toBe("sat"); + expect(decoded.methodDetails.invoice).toBe("lnbc..."); + }); + + it("handles base64url strings with padding that needs to be restored", () => { + // "Man" → base64 = "TWFu" (4 chars, no padding needed) + expect(decodeBase64url(encodeBase64url("Man"))).toBe("Man"); + // "Ma" → base64 = "TWE=" (padding needed) + expect(decodeBase64url(encodeBase64url("Ma"))).toBe("Ma"); + // "M" → base64 = "TQ==" (two padding chars) + expect(decodeBase64url(encodeBase64url("M"))).toBe("M"); }); +}); - it("maps USD on ethereum to usdc_eth", () => { - const params = mapMppCurrencyToSwapParams("USD", "ethereum", "0xrecipient", 500); - expect(params.targetToken).toBe("usdc_eth"); - expect(params.targetChain).toBe("ethereum"); - expect(params.targetAmount).toBe(5); +describe("jcsStringify", () => { + it("sorts object keys lexicographically", () => { + const result = jcsStringify({ z: 1, a: 2, m: 3 }); + expect(result).toBe('{"a":2,"m":3,"z":1}'); }); - it("maps USD on arbitrum to usdc_arb", () => { - const params = mapMppCurrencyToSwapParams("USD", "arbitrum", "0xrecipient", 250); - expect(params.targetToken).toBe("usdc_arb"); - expect(params.targetChain).toBe("arbitrum"); - expect(params.targetAmount).toBe(2.5); + it("sorts nested object keys", () => { + const result = jcsStringify({ outer: { z: 1, a: 2 }, b: "x" }); + expect(result).toBe('{"b":"x","outer":{"a":2,"z":1}}'); }); - it("maps USD on tempo to usdc_pol (default bridge)", () => { - const params = mapMppCurrencyToSwapParams("USD", "tempo", "0xrecipient", 100); - expect(params.targetToken).toBe("usdc_pol"); - expect(params.targetChain).toBe("polygon"); - expect(params.targetAmount).toBe(1); + it("handles arrays without reordering elements", () => { + const result = jcsStringify([3, 1, 2]); + expect(result).toBe("[3,1,2]"); }); - it("maps USDC on polygon to usdc_pol (no cents conversion)", () => { - const params = mapMppCurrencyToSwapParams("USDC", "polygon", "0xrecipient", 10); - expect(params.targetToken).toBe("usdc_pol"); - expect(params.targetChain).toBe("polygon"); - expect(params.targetAmount).toBe(10); + it("handles primitive values", () => { + expect(jcsStringify("hello")).toBe('"hello"'); + expect(jcsStringify(42)).toBe("42"); + expect(jcsStringify(true)).toBe("true"); + expect(jcsStringify(null)).toBe("null"); }); - it("maps USDT on polygon to usdt0_pol", () => { - const params = mapMppCurrencyToSwapParams("USDT", "polygon", "0xrecipient", 10); - expect(params.targetToken).toBe("usdt0_pol"); - expect(params.targetChain).toBe("polygon"); - expect(params.targetAmount).toBe(10); + it("produces no extra whitespace", () => { + const result = jcsStringify({ a: 1, b: 2 }); + expect(result).not.toMatch(/\s/); }); +}); + +// ───────────────────────────────────────────────────────────────────── +// Unit tests: buildAuthorizationHeader +// ───────────────────────────────────────────────────────────────────── - it("throws for unsupported network", () => { - expect(() => - mapMppCurrencyToSwapParams("USD", "solana", "0xrecipient", 100) - ).toThrow("Unsupported MPP network"); +describe("buildAuthorizationHeader", () => { + it("produces 'Payment ' format", () => { + const challenge: MppChallenge = { + id: "c1", + realm: "api.example.com", + method: "lightning", + intent: "charge", + request: "abc", + }; + const credential: MppCredential = { + challenge, + payload: { preimage: "deadbeef" }, + }; + const header = buildAuthorizationHeader(credential); + expect(header).toMatch(/^Payment [A-Za-z0-9_-]+$/); }); - it("throws for unsupported currency", () => { - expect(() => - mapMppCurrencyToSwapParams("EUR", "polygon", "0xrecipient", 100) - ).toThrow("Unsupported MPP currency"); + it("credential decodes to correct structure", () => { + const challenge: MppChallenge = { + id: "test_id", + realm: "api.test.com", + method: "lightning", + intent: "charge", + request: "req_data", + }; + const credential: MppCredential = { + challenge, + payload: { preimage: "abc123" }, + }; + const header = buildAuthorizationHeader(credential); + const decoded = decodeAuthorizationHeader(header); + + expect(decoded.challenge.id).toBe("test_id"); + expect(decoded.challenge.realm).toBe("api.test.com"); + expect(decoded.challenge.method).toBe("lightning"); + expect(decoded.challenge.intent).toBe("charge"); + expect((decoded.payload as { preimage: string }).preimage).toBe("abc123"); + }); + + it("keys are sorted (JCS) in the encoded credential", () => { + const challenge: MppChallenge = { + id: "c1", + realm: "realm", + method: "lightning", + intent: "charge", + request: "r", + }; + const credential: MppCredential = { + challenge, + payload: { preimage: "pre" }, + }; + const header = buildAuthorizationHeader(credential); + const b64url = header.replace(/^Payment\s+/, ""); + const raw = decodeBase64url(b64url); + // Verify it's valid JSON with sorted keys + const parsed = JSON.parse(raw); + expect(Object.keys(parsed)).toEqual(["challenge", "payload"].sort()); }); }); // ───────────────────────────────────────────────────────────────────── -// Integration tests: MppClient end-to-end flow +// Integration tests: MppClient end-to-end // ───────────────────────────────────────────────────────────────────── describe("MppClient", () => { - let mockSwap: ReturnType; + let mockLightning: ReturnType; let client: MppClient; let mockFetch: ReturnType; + const PREIMAGE = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"; + const BOLT11 = "lnbc100n1pjtest..."; beforeEach(() => { - mockSwap = createMockSwap(); + mockLightning = createMockLightning(PREIMAGE); mockFetch = vi.fn(); client = new MppClient({ - swap: mockSwap, + lightning: mockLightning, fetch: mockFetch as unknown as typeof globalThis.fetch, }); }); it("passes through non-402 responses without payment", async () => { - mockFetch.mockResolvedValue(mock200Response("hello world")); + mockFetch.mockResolvedValue(mock200("hello world")); const result = await client.pay("https://api.example.com/resource"); expect(result.ok).toBe(true); expect(result.status).toBe(200); expect(result.body).toBe("hello world"); expect(result.proof).toBeUndefined(); - expect(mockSwap.swapBtcToStablecoin).not.toHaveBeenCalled(); - }); - - it("handles MPP 402 → swap → retry → 200 flow", async () => { - const paymentReq: MppPaymentRequired = { - version: MPP_VERSION, - requirements: [{ - id: "req_001", - amount: 100, - currency: "USD", - recipient: "0xmerchant", - network: "polygon", - description: "API call", - }], - }; + expect(mockLightning.payInvoice).not.toHaveBeenCalled(); + }); + + it("passes through non-MPP 402 (no WWW-Authenticate: Payment)", async () => { + mockFetch.mockResolvedValue(new Response("Pay to subscribe", { status: 402 })); + + const result = await client.pay("https://api.example.com/resource"); + expect(result.ok).toBe(false); + expect(result.status).toBe(402); + expect(result.proof).toBeUndefined(); + expect(mockLightning.payInvoice).not.toHaveBeenCalled(); + }); + + it("handles lightning MPP 402 → pay invoice → retry → 200 flow", async () => { + const req = lightningRequest(BOLT11); + const challengeHeader = buildChallenge({ + id: "chall_001", + method: "lightning", + intent: "charge", + request: encodeRequest(req), + }); - // First call returns 402, second call returns 200 mockFetch - .mockResolvedValueOnce(mock402Response(paymentReq)) - .mockResolvedValueOnce(mock200Response('{"data":"secret"}')); + .mockResolvedValueOnce(mock402(challengeHeader)) + .mockResolvedValueOnce(mock200('{"data":"secret"}')); - const result = await client.pay("https://api.example.com/paid-resource"); + const result = await client.pay("https://api.example.com/paid"); + // Result checks expect(result.ok).toBe(true); expect(result.status).toBe(200); expect(result.body).toBe('{"data":"secret"}'); - expect(result.swap_id).toBe("swap_abc123"); + expect(result.paymentPreimage).toBe(PREIMAGE); expect(result.proof).toBeDefined(); - expect(result.proof!.requirement_id).toBe("req_001"); - expect(result.proof!.network).toBe("polygon"); - - // Verify swap was called with correct params - expect(mockSwap.swapBtcToStablecoin).toHaveBeenCalledWith({ - targetAddress: "0xmerchant", - targetToken: "usdc_pol", - targetChain: "polygon", - targetAmount: 1, // 100 cents = $1 - }); + expect(result.proof!.challenge.id).toBe("chall_001"); + expect(result.proof!.challenge.method).toBe("lightning"); - // Verify second request has MPP-Authorization header - const secondCall = mockFetch.mock.calls[1]; - const headers = secondCall[1]?.headers as Record; - expect(headers[MPP_HEADER_AUTHORIZATION]).toBeDefined(); - - // Decode the authorization header - const proof = JSON.parse( - Buffer.from(headers[MPP_HEADER_AUTHORIZATION], "base64").toString("utf-8") - ); - expect(proof.version).toBe(MPP_VERSION); - expect(proof.requirement_id).toBe("req_001"); - expect(proof.tx_id).toBe("swap_abc123"); - }); - - it("forwards original request headers on retry", async () => { - const paymentReq: MppPaymentRequired = { - version: MPP_VERSION, - requirements: [{ - id: "req_002", - amount: 50, - currency: "USD", - recipient: "0xmerchant", - network: "polygon", - }], - }; + // Lightning was called with the BOLT11 from the challenge + expect(mockLightning.payInvoice).toHaveBeenCalledOnce(); + expect(mockLightning.payInvoice).toHaveBeenCalledWith({ bolt11: BOLT11 }); + + // Retry request has Authorization: Payment header + const retryCall = mockFetch.mock.calls[1]; + const retryHeaders = retryCall[1]?.headers as Record; + expect(retryHeaders["Authorization"]).toMatch(/^Payment [A-Za-z0-9_-]+$/); + + // Decode and verify credential structure + const credential = decodeAuthorizationHeader(retryHeaders["Authorization"]); + expect(credential.challenge.id).toBe("chall_001"); + expect(credential.challenge.method).toBe("lightning"); + expect((credential.payload as { preimage: string }).preimage).toBe(PREIMAGE); + }); + + it("forwards original request method, headers, and body on retry", async () => { + const req = lightningRequest(BOLT11); + const challengeHeader = buildChallenge({ request: encodeRequest(req) }); mockFetch - .mockResolvedValueOnce(mock402Response(paymentReq)) - .mockResolvedValueOnce(mock200Response("ok")); + .mockResolvedValueOnce(mock402(challengeHeader)) + .mockResolvedValueOnce(mock200("ok")); await client.pay("https://api.example.com/resource", { method: "POST", - headers: { "X-Custom": "value123", "Content-Type": "application/json" }, - body: '{"query":"test"}', + headers: { "X-Custom": "val123", "Content-Type": "application/json" }, + body: '{"q":"test"}', }); - // Second call should preserve original headers + add MPP-Authorization - const secondCall = mockFetch.mock.calls[1]; - const headers = secondCall[1]?.headers as Record; - expect(headers["X-Custom"]).toBe("value123"); - expect(headers["Content-Type"]).toBe("application/json"); - expect(headers[MPP_HEADER_AUTHORIZATION]).toBeDefined(); - expect(secondCall[1]?.body).toBe('{"query":"test"}'); - }); - - it("prefers the first supported network in requirements", async () => { - const paymentReq: MppPaymentRequired = { - version: MPP_VERSION, - requirements: [ - { - id: "req_base", - amount: 100, - currency: "USD", - recipient: "0xmerchant", - network: "base", // unsupported by claw-cash - }, - { - id: "req_poly", - amount: 100, - currency: "USD", - recipient: "0xmerchant", - network: "polygon", // supported - }, - ], - }; + const retryCall = mockFetch.mock.calls[1]; + const retryInit = retryCall[1]; + const retryHeaders = retryInit?.headers as Record; + + expect(retryInit?.method).toBe("POST"); + expect(retryInit?.body).toBe('{"q":"test"}'); + expect(retryHeaders["X-Custom"]).toBe("val123"); + expect(retryHeaders["Content-Type"]).toBe("application/json"); + expect(retryHeaders["Authorization"]).toMatch(/^Payment /); + }); + + it("picks first supported challenge when server offers multiple methods", async () => { + const req = lightningRequest(BOLT11); + // Server offers: tempo (unsupported) first, then lightning (supported) + const tempoReq = encodeRequest({ amount: "1000000", currency: "0x20c0...", recipient: "0xmerchant" }); + const header = [ + `Payment id="chall_tempo", realm="api.example.com", method="tempo", intent="charge", request="${tempoReq}"`, + `Payment id="chall_lightning", realm="api.example.com", method="lightning", intent="charge", request="${encodeRequest(req)}"`, + ].join(", "); mockFetch - .mockResolvedValueOnce(mock402Response(paymentReq)) - .mockResolvedValueOnce(mock200Response("ok")); + .mockResolvedValueOnce(mock402(header)) + .mockResolvedValueOnce(mock200("ok")); const result = await client.pay("https://api.example.com/resource"); - expect(result.proof!.requirement_id).toBe("req_poly"); - }); - - it("throws when no supported payment requirement is found", async () => { - const paymentReq: MppPaymentRequired = { - version: MPP_VERSION, - requirements: [{ - id: "req_sol", - amount: 100, - currency: "USD", - recipient: "sol123", - network: "solana", - }], - }; + expect(result.proof!.challenge.id).toBe("chall_lightning"); + expect(result.proof!.challenge.method).toBe("lightning"); + }); - mockFetch.mockResolvedValueOnce(mock402Response(paymentReq)); + it("throws when no supported method is offered", async () => { + const tempoReq = encodeRequest({ amount: "1000000", currency: "0x20c0...", recipient: "0x1234" }); + const header = `Payment id="c1", realm="api.example.com", method="tempo", intent="charge", request="${tempoReq}"`; + + mockFetch.mockResolvedValueOnce(mock402(header)); await expect(client.pay("https://api.example.com/resource")) - .rejects.toThrow("No supported payment requirement"); - }); - - it("throws when swap fails", async () => { - const paymentReq: MppPaymentRequired = { - version: MPP_VERSION, - requirements: [{ - id: "req_001", - amount: 100, - currency: "USD", - recipient: "0xmerchant", - network: "polygon", - }], - }; + .rejects.toThrow(/No supported MPP payment challenge/); + }); + + it("throws when only session challenges are offered (only charge supported)", async () => { + const req = encodeRequest({ amount: "1000", currency: "sat", methodDetails: { invoice: BOLT11 } }); + const header = `Payment id="c1", realm="api.example.com", method="lightning", intent="session", request="${req}"`; - mockFetch.mockResolvedValueOnce(mock402Response(paymentReq)); - mockSwap.swapBtcToStablecoin.mockRejectedValueOnce(new Error("Insufficient BTC balance")); + mockFetch.mockResolvedValueOnce(mock402(header)); await expect(client.pay("https://api.example.com/resource")) - .rejects.toThrow("Insufficient BTC balance"); + .rejects.toThrow(/No supported MPP payment challenge/); }); - it("throws when retry after payment still fails", async () => { - const paymentReq: MppPaymentRequired = { - version: MPP_VERSION, - requirements: [{ - id: "req_001", - amount: 100, - currency: "USD", - recipient: "0xmerchant", - network: "polygon", - }], - }; + it("skips expired challenges and uses a valid one", async () => { + const req = encodeRequest(lightningRequest(BOLT11)); + const expiredReq = encodeRequest(lightningRequest("lnbc_expired...")); + const header = [ + `Payment id="chall_expired", realm="api.example.com", method="lightning", intent="charge", request="${expiredReq}", expires="2020-01-01T00:00:00Z"`, + `Payment id="chall_valid", realm="api.example.com", method="lightning", intent="charge", request="${req}", expires="${new Date(Date.now() + 3600_000).toISOString()}"`, + ].join(", "); mockFetch - .mockResolvedValueOnce(mock402Response(paymentReq)) - .mockResolvedValueOnce(new Response("Forbidden", { status: 403 })); + .mockResolvedValueOnce(mock402(header)) + .mockResolvedValueOnce(mock200("ok")); const result = await client.pay("https://api.example.com/resource"); - expect(result.ok).toBe(false); - expect(result.status).toBe(403); + expect(result.proof!.challenge.id).toBe("chall_valid"); + // Should have called payInvoice with the non-expired challenge's BOLT11 + expect(mockLightning.payInvoice).toHaveBeenCalledWith({ bolt11: BOLT11 }); }); - it("handles session-based payment requirements", async () => { - const paymentReq: MppPaymentRequired = { - version: MPP_VERSION, - requirements: [{ - id: "req_sess", - amount: 50, - currency: "USD", - recipient: "0xmerchant", - network: "polygon", - session_id: "sess_xyz", - }], - }; + it("throws when all challenges are expired", async () => { + const req = encodeRequest(lightningRequest(BOLT11)); + const header = `Payment id="c1", realm="api.example.com", method="lightning", intent="charge", request="${req}", expires="2020-01-01T00:00:00Z"`; - mockFetch - .mockResolvedValueOnce(mock402Response(paymentReq)) - .mockResolvedValueOnce(mock200Response("session data")); + mockFetch.mockResolvedValueOnce(mock402(header)); - const result = await client.pay("https://api.example.com/resource"); - expect(result.ok).toBe(true); - expect(result.proof!.session_id).toBe("sess_xyz"); - }); - - it("skips expired requirements and picks a valid one", async () => { - const paymentReq: MppPaymentRequired = { - version: MPP_VERSION, - requirements: [ - { - id: "req_expired", - amount: 100, - currency: "USD", - recipient: "0xmerchant", - network: "polygon", - expires_at: "2020-01-01T00:00:00Z", // long expired - }, - { - id: "req_valid", - amount: 100, - currency: "USD", - recipient: "0xmerchant", - network: "polygon", - expires_at: new Date(Date.now() + 3600_000).toISOString(), // 1h from now - }, - ], - }; + await expect(client.pay("https://api.example.com/resource")) + .rejects.toThrow(/No supported MPP payment challenge/); + }); - mockFetch - .mockResolvedValueOnce(mock402Response(paymentReq)) - .mockResolvedValueOnce(mock200Response("ok")); + it("throws when lightning challenge is missing the invoice", async () => { + // request has no methodDetails.invoice + const req = encodeRequest({ amount: "1000", currency: "sat", methodDetails: {} }); + const header = buildChallenge({ request: req }); - const result = await client.pay("https://api.example.com/resource"); - expect(result.proof!.requirement_id).toBe("req_valid"); - }); - - it("throws when all requirements are expired", async () => { - const paymentReq: MppPaymentRequired = { - version: MPP_VERSION, - requirements: [{ - id: "req_expired", - amount: 100, - currency: "USD", - recipient: "0xmerchant", - network: "polygon", - expires_at: "2020-01-01T00:00:00Z", - }], - }; + mockFetch.mockResolvedValueOnce(mock402(header)); - mockFetch.mockResolvedValueOnce(mock402Response(paymentReq)); + await expect(client.pay("https://api.example.com/resource")) + .rejects.toThrow(/invoice/); + }); + + it("propagates lightning payment failure", async () => { + const req = encodeRequest(lightningRequest(BOLT11)); + const header = buildChallenge({ request: req }); + + mockFetch.mockResolvedValueOnce(mock402(header)); + mockLightning.payInvoice.mockRejectedValueOnce(new Error("Insufficient BTC balance")); await expect(client.pay("https://api.example.com/resource")) - .rejects.toThrow("No supported payment requirement"); + .rejects.toThrow("Insufficient BTC balance"); }); - it("handles non-MPP 402 responses (passes through)", async () => { - // A regular 402 without MPP-Version header - const res = new Response("Please subscribe", { status: 402 }); - mockFetch.mockResolvedValueOnce(res); + it("returns non-ok result when retry after payment fails", async () => { + const req = encodeRequest(lightningRequest(BOLT11)); + const header = buildChallenge({ request: req }); + + mockFetch + .mockResolvedValueOnce(mock402(header)) + .mockResolvedValueOnce(new Response("Forbidden", { status: 403 })); const result = await client.pay("https://api.example.com/resource"); expect(result.ok).toBe(false); - expect(result.status).toBe(402); - expect(result.proof).toBeUndefined(); - expect(mockSwap.swapBtcToStablecoin).not.toHaveBeenCalled(); - }); - - it("handles USDC-denominated requirements without cents conversion", async () => { - const paymentReq: MppPaymentRequired = { - version: MPP_VERSION, - requirements: [{ - id: "req_usdc", - amount: 5, - currency: "USDC", - recipient: "0xmerchant", - network: "polygon", - }], - }; + expect(result.status).toBe(403); + // proof should still be present (we did pay) + expect(result.proof).toBeDefined(); + expect(result.paymentPreimage).toBe(PREIMAGE); + }); - mockFetch - .mockResolvedValueOnce(mock402Response(paymentReq)) - .mockResolvedValueOnce(mock200Response("ok")); + it("challenge without expiry is always accepted (no expiry = no limit)", async () => { + const req = encodeRequest(lightningRequest(BOLT11)); + const header = buildChallenge({ request: req }); // no expires param - await client.pay("https://api.example.com/resource"); + mockFetch + .mockResolvedValueOnce(mock402(header)) + .mockResolvedValueOnce(mock200("ok")); - expect(mockSwap.swapBtcToStablecoin).toHaveBeenCalledWith({ - targetAddress: "0xmerchant", - targetToken: "usdc_pol", - targetChain: "polygon", - targetAmount: 5, // USDC amount is direct, no cents conversion - }); + const result = await client.pay("https://api.example.com/resource"); + expect(result.ok).toBe(true); + expect(result.proof).toBeDefined(); }); }); // ───────────────────────────────────────────────────────────────────── -// Unit tests: handlePay command +// Unit tests: handlePay argument parsing // ───────────────────────────────────────────────────────────────────── -describe("handlePay argument parsing", () => { - // These test the URL validation logic used by the pay command - it("validates URL format", () => { +describe("handlePay URL validation", () => { + it("accepts https URLs", () => { expect(() => new URL("https://api.example.com/resource")).not.toThrow(); - expect(() => new URL("not-a-url")).toThrow(); }); - it("accepts http and https URLs", () => { - const https = new URL("https://api.example.com"); - const http = new URL("http://localhost:3000"); - expect(https.protocol).toBe("https:"); - expect(http.protocol).toBe("http:"); + it("accepts http URLs (localhost)", () => { + expect(() => new URL("http://localhost:3000/pay")).not.toThrow(); + }); + + it("rejects non-URL strings", () => { + expect(() => new URL("not-a-url")).toThrow(); + expect(() => new URL("")).toThrow(); }); }); From b61784c2ce08dc4b28bd4c54fbbb524bac86e88b Mon Sep 17 00:00:00 2001 From: Marco Argentieri <3596602+tiero@users.noreply.github.com> Date: Fri, 20 Mar 2026 23:58:22 +0100 Subject: [PATCH 3/4] feat: update @arkade-os/sdk to version 0.4.10 and add delegator support in CLI --- cli/package.json | 2 +- cli/src/commands/delegate.ts | 31 ++++++++++++ cli/src/config.ts | 11 ++++- cli/src/context.ts | 5 +- cli/src/index.ts | 6 +++ pnpm-lock.yaml | 94 ++++++++++++++++++++++++++++++++++-- skills/package.json | 2 +- 7 files changed, 142 insertions(+), 9 deletions(-) create mode 100644 cli/src/commands/delegate.ts diff --git a/cli/package.json b/cli/package.json index 2fce254..bf8a5f1 100644 --- a/cli/package.json +++ b/cli/package.json @@ -19,7 +19,7 @@ }, "dependencies": { "@arkade-os/boltz-swap": "^0.2.18", - "@arkade-os/sdk": "^0.3.12", + "@arkade-os/sdk": "^0.4.10", "@lendasat/lendaswap-sdk-pure": "^0.0.2", "@noble/hashes": "^2.0.1", "@scure/base": "^2.0.0", diff --git a/cli/src/commands/delegate.ts b/cli/src/commands/delegate.ts new file mode 100644 index 0000000..810580f --- /dev/null +++ b/cli/src/commands/delegate.ts @@ -0,0 +1,31 @@ +import type { ExtendedVirtualCoin } from "@arkade-os/sdk"; +import type { CashContext } from "../context.js"; +import { outputSuccess, outputError } from "../output.js"; + +export async function handleDelegate(ctx: CashContext): Promise { + const wallet = ctx.bitcoin.getWallet(); + + const delegatorManager = await wallet.getDelegatorManager(); + if (!delegatorManager) { + return outputError( + "No delegator configured. Set CLW_DELEGATOR_URL or add delegatorUrl to config." + ); + } + + const vtxos = (await wallet.getVtxos({ withRecoverable: false })).filter( + (v: ExtendedVirtualCoin) => v.virtualStatus.state === "settled" + ); + + if (vtxos.length === 0) { + return outputSuccess({ message: "No settled VTXOs to delegate." }); + } + + const address = await wallet.getAddress(); + const result = await delegatorManager.delegate(vtxos, address); + + return outputSuccess({ + delegated: result.delegated.length, + failed: result.failed.length, + outpoints: result.delegated, + }); +} diff --git a/cli/src/config.ts b/cli/src/config.ts index cc285da..0c19978 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -9,6 +9,7 @@ export interface CashConfig { publicKey: string; arkServerUrl: string; network: string; + delegatorUrl?: string; } const CONFIG_DIR = join(homedir(), ".clw-cash"); @@ -55,6 +56,10 @@ export function loadConfig(overrides?: Partial): CashConfig { process.env.CLW_NETWORK ?? fileConfig.network ?? "bitcoin", + delegatorUrl: + overrides?.delegatorUrl ?? + process.env.CLW_DELEGATOR_URL ?? + fileConfig.delegatorUrl, }; return config; @@ -69,22 +74,24 @@ export interface ConfigEntry { export type CashConfigWithSources = Record; -const ENV_KEYS: Record = { +const ENV_KEYS: Record, string> = { apiBaseUrl: "CLW_API_URL", sessionToken: "CLW_SESSION_TOKEN", identityId: "CLW_IDENTITY_ID", publicKey: "CLW_PUBLIC_KEY", arkServerUrl: "CLW_ARK_SERVER_URL", network: "CLW_NETWORK", + delegatorUrl: "CLW_DELEGATOR_URL", }; -const DEFAULTS: Record = { +const DEFAULTS: Record, string> = { apiBaseUrl: "https://api.clw.cash", sessionToken: "", identityId: "", publicKey: "", arkServerUrl: "https://arkade.computer", network: "bitcoin", + delegatorUrl: "", }; export function loadConfigWithSources(): CashConfigWithSources { diff --git a/cli/src/context.ts b/cli/src/context.ts index c1f53dd..743968f 100644 --- a/cli/src/context.ts +++ b/cli/src/context.ts @@ -2,7 +2,7 @@ import { join } from "node:path"; import { homedir } from "node:os"; import { mkdirSync } from "node:fs"; import { RemoteSignerIdentity } from "@clw-cash/sdk"; -import { Wallet } from "@arkade-os/sdk"; +import { Wallet, RestDelegatorProvider } from "@arkade-os/sdk"; import { FileSystemStorageAdapter } from "@arkade-os/sdk/adapters/fileSystem"; import { SqliteWalletStorage, @@ -46,6 +46,9 @@ export async function createContext(config: CashConfig, opts?: CreateContextOpts identity, arkServerUrl: config.arkServerUrl, storage: walletStorage, + ...(config.delegatorUrl + ? { delegatorProvider: new RestDelegatorProvider(config.delegatorUrl) } + : {}), }); const bitcoin = new ArkadeBitcoinSkill(wallet); diff --git a/cli/src/index.ts b/cli/src/index.ts index 4bc4ef8..aeb481e 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -18,6 +18,7 @@ import { handleConfig } from "./commands/config.js"; import { handleSignPsbt } from "./commands/sign-psbt.js"; import { handlePubkey } from "./commands/pubkey.js"; import { handlePay } from "./commands/pay.js"; +import { handleDelegate } from "./commands/delegate.js"; import { getVersion } from "./version.js"; const HELP = `cash - Bitcoin & Stablecoin CLI @@ -47,6 +48,7 @@ Usage: --method HTTP method (default: GET) --body Request body (for POST/PUT/PATCH) --header 'Name: value' Custom request headers (repeatable) + cash delegate Delegate settled VTXOs to the configured delegate service (auto-renewal) cash pubkey Show the wallet's public key (for multisig setup) cash sign-psbt Sign a PSBT (Partially Signed Bitcoin Transaction) Parses PSBT, shows tx details, signs inputs @@ -74,6 +76,7 @@ Environment: CLW_ARK_SERVER_URL Arkade server URL CLW_NETWORK Network (bitcoin|testnet) CLW_DAEMON_PORT Daemon port (default: 3457) + CLW_DELEGATOR_URL Delegator service URL (e.g. https://delegate.arkade.money) `; const argv = minimist(process.argv.slice(2), { @@ -187,6 +190,9 @@ async function main() { case "pay": await handlePay(ctx, argv); break; + case "delegate": + await handleDelegate(ctx); + break; default: outputError(`Unknown command: ${command}. Run 'cash --help' for usage.`); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71e062b..83af3bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,8 +55,8 @@ importers: specifier: ^0.2.18 version: 0.2.20 '@arkade-os/sdk': - specifier: ^0.3.12 - version: 0.3.13 + specifier: ^0.4.10 + version: 0.4.10 '@lendasat/lendaswap-sdk-pure': specifier: ^0.0.2 version: 0.0.2 @@ -148,8 +148,8 @@ importers: specifier: ^0.2.18 version: 0.2.20 '@arkade-os/sdk': - specifier: ^0.3.12 - version: 0.3.13 + specifier: ^0.4.10 + version: 0.4.10 '@clw-cash/sdk': specifier: workspace:* version: link:../remote-signer-identity @@ -193,6 +193,30 @@ packages: resolution: {integrity: sha512-eC6XGifqVf2Xu+sf/3T2duUgLiXlgh/IQXHD+v09wsu8ik/CZedH4gvQpetTjRMKti3DdY/QVLC1OQTKKWgvqQ==} engines: {node: '>=20.0.0'} + '@arkade-os/sdk@0.4.10': + resolution: {integrity: sha512-7Ga7ywIDGiSfeLGyrvnJAPicOg7srcaYEdwlQOeDIWM+cmzDwKThj2KTJ2ZL50YAksoFMradAPAD+UAJs/Dwag==} + engines: {node: '>=22.12.0 <23', pnpm: '>=10.25.0 <11'} + peerDependencies: + '@react-native-async-storage/async-storage': '>=1.0.0' + expo: '>=54.0.0' + expo-background-task: ~1.0.10 + expo-sqlite: ~16.0.10 + expo-task-manager: ~14.0.9 + peerDependenciesMeta: + '@react-native-async-storage/async-storage': + optional: true + expo: + optional: true + expo-background-task: + optional: true + expo-sqlite: + optional: true + expo-task-manager: + optional: true + + '@bitcoinerlab/miniscript@1.4.3': + resolution: {integrity: sha512-gf7WK4dKJJJl+IgLGmkTOxXQLiCje9c9y4wTLC+cyt0tBDTiSXgsG0X8FFaXf4d+34b8B5p/EJGvRd7mEYB6mQ==} + '@cloudflare/kv-asset-handler@0.4.2': resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==} engines: {node: '>=18.0.0'} @@ -711,6 +735,9 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@kukks/bitcoin-descriptors@3.1.0': + resolution: {integrity: sha512-7HOCZjufw3DIIDebx7/ZOeol6khkWoccM6vzee6t5bXOJoj/6ISdssEDhjxDgy88in/I39pKz+kzFukxK5eqVw==} + '@lendasat/lendaswap-sdk-pure@0.0.2': resolution: {integrity: sha512-4N9ugC/5ja7p5U0pRfRhopxu7Nld5u94117kklucmjDd8zdLd4BUdIVXObLhwgze+/WvAaU6jCp/S3Maks54Lw==} @@ -735,6 +762,10 @@ packages: resolution: {integrity: sha512-RiwZZeJnsTnhT+/gg2KvITJZhK5oagQrpZo+yQyd3mv3D5NAG2qEeEHpw7IkXRlpkoD45wl2o4ydHAvY9wyEfw==} engines: {node: '>= 20.19.0'} + '@noble/curves@2.0.1': + resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==} + engines: {node: '>= 20.19.0'} + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} @@ -896,9 +927,18 @@ packages: '@scure/bip32@1.7.0': resolution: {integrity: sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==} + '@scure/bip32@2.0.0': + resolution: {integrity: sha512-V24pyBWzKQqhB0CrV3Xz7JZr/cC2Nhp9LuIr9gOHkAKxSGwYqTIs8u0AkZgKA7HFqRiyoNg/PymiXOExm+3KlQ==} + + '@scure/bip32@2.0.1': + resolution: {integrity: sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==} + '@scure/bip39@1.6.0': resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} + '@scure/bip39@2.0.1': + resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==} + '@scure/btc-signer@2.0.1': resolution: {integrity: sha512-vk5a/16BbSFZkhh1JIJ0+4H9nceZVo5WzKvJGGWiPp3sQOExeW+L53z3dI6u0adTPoE8ZbL+XEb6hEGzVZSvvQ==} @@ -1918,6 +1958,22 @@ snapshots: '@scure/btc-signer': 2.0.1 bip68: 1.0.4 + '@arkade-os/sdk@0.4.10': + dependencies: + '@kukks/bitcoin-descriptors': 3.1.0 + '@marcbachmann/cel-js': 7.3.1 + '@noble/curves': 2.0.0 + '@noble/secp256k1': 3.0.0 + '@scure/base': 2.0.0 + '@scure/bip32': 2.0.0 + '@scure/bip39': 2.0.1 + '@scure/btc-signer': 2.0.1 + bip68: 1.0.4 + + '@bitcoinerlab/miniscript@1.4.3': + dependencies: + bip68: 1.0.4 + '@cloudflare/kv-asset-handler@0.4.2': {} '@cloudflare/unenv-preset@2.12.1(unenv@2.0.0-rc.24)(workerd@1.20260212.0)': @@ -2223,6 +2279,15 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@kukks/bitcoin-descriptors@3.1.0': + dependencies: + '@bitcoinerlab/miniscript': 1.4.3 + '@noble/curves': 2.0.1 + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + '@scure/bip32': 2.0.1 + '@scure/btc-signer': 2.0.1 + '@lendasat/lendaswap-sdk-pure@0.0.2': dependencies: '@arkade-os/sdk': 0.3.13 @@ -2251,6 +2316,10 @@ snapshots: dependencies: '@noble/hashes': 2.0.0 + '@noble/curves@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + '@noble/hashes@1.8.0': {} '@noble/hashes@2.0.0': {} @@ -2358,11 +2427,28 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 + '@scure/bip32@2.0.0': + dependencies: + '@noble/curves': 2.0.0 + '@noble/hashes': 2.0.0 + '@scure/base': 2.0.0 + + '@scure/bip32@2.0.1': + dependencies: + '@noble/curves': 2.0.1 + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + '@scure/bip39@1.6.0': dependencies: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 + '@scure/bip39@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + '@scure/btc-signer@2.0.1': dependencies: '@noble/curves': 2.0.0 diff --git a/skills/package.json b/skills/package.json index 22b237c..ca881aa 100644 --- a/skills/package.json +++ b/skills/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@clw-cash/sdk": "workspace:*", - "@arkade-os/sdk": "^0.3.12", + "@arkade-os/sdk": "^0.4.10", "@arkade-os/boltz-swap": "^0.2.18", "@lendasat/lendaswap-sdk-pure": "^0.0.2" }, From acc72a3412d4951e237af3a9e7b7269f33852629 Mon Sep 17 00:00:00 2001 From: Marco Argentieri <3596602+tiero@users.noreply.github.com> Date: Sat, 21 Mar 2026 00:49:49 +0100 Subject: [PATCH 4/4] feat: remove delegate command and implement auto-delegation for settled VTXOs --- cli/src/commands/delegate.ts | 31 ------------------- cli/src/index.ts | 59 +++++++++++++++++++++++++++++++++--- 2 files changed, 54 insertions(+), 36 deletions(-) delete mode 100644 cli/src/commands/delegate.ts diff --git a/cli/src/commands/delegate.ts b/cli/src/commands/delegate.ts deleted file mode 100644 index 810580f..0000000 --- a/cli/src/commands/delegate.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { ExtendedVirtualCoin } from "@arkade-os/sdk"; -import type { CashContext } from "../context.js"; -import { outputSuccess, outputError } from "../output.js"; - -export async function handleDelegate(ctx: CashContext): Promise { - const wallet = ctx.bitcoin.getWallet(); - - const delegatorManager = await wallet.getDelegatorManager(); - if (!delegatorManager) { - return outputError( - "No delegator configured. Set CLW_DELEGATOR_URL or add delegatorUrl to config." - ); - } - - const vtxos = (await wallet.getVtxos({ withRecoverable: false })).filter( - (v: ExtendedVirtualCoin) => v.virtualStatus.state === "settled" - ); - - if (vtxos.length === 0) { - return outputSuccess({ message: "No settled VTXOs to delegate." }); - } - - const address = await wallet.getAddress(); - const result = await delegatorManager.delegate(vtxos, address); - - return outputSuccess({ - delegated: result.delegated.length, - failed: result.failed.length, - outpoints: result.delegated, - }); -} diff --git a/cli/src/index.ts b/cli/src/index.ts index aeb481e..a6ad85f 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,4 +1,6 @@ import minimist from "minimist"; +import type { ExtendedVirtualCoin, IncomingFunds } from "@arkade-os/sdk"; +import { VtxoManager } from "@arkade-os/sdk"; import { loadConfig, validateConfig, getSessionStatus, saveConfig } from "./config.js"; import { createContext } from "./context.js"; import { outputError } from "./output.js"; @@ -18,7 +20,6 @@ import { handleConfig } from "./commands/config.js"; import { handleSignPsbt } from "./commands/sign-psbt.js"; import { handlePubkey } from "./commands/pubkey.js"; import { handlePay } from "./commands/pay.js"; -import { handleDelegate } from "./commands/delegate.js"; import { getVersion } from "./version.js"; const HELP = `cash - Bitcoin & Stablecoin CLI @@ -48,7 +49,6 @@ Usage: --method HTTP method (default: GET) --body Request body (for POST/PUT/PATCH) --header 'Name: value' Custom request headers (repeatable) - cash delegate Delegate settled VTXOs to the configured delegate service (auto-renewal) cash pubkey Show the wallet's public key (for multisig setup) cash sign-psbt Sign a PSBT (Partially Signed Bitcoin Transaction) Parses PSBT, shows tx details, signs inputs @@ -190,9 +190,6 @@ async function main() { case "pay": await handlePay(ctx, argv); break; - case "delegate": - await handleDelegate(ctx); - break; default: outputError(`Unknown command: ${command}. Run 'cash --help' for usage.`); } @@ -315,6 +312,57 @@ async function runDaemon() { monitor.start(); console.error("[daemon] lendaswap monitor started"); + // Recover swept VTXOs on startup + const wallet = ctx.bitcoin.getWallet(); + const vtxoManager = new VtxoManager(wallet); + void (async () => { + try { + const recoverable = await vtxoManager.getRecoverableBalance(); + if (recoverable.recoverable > 0n) { + console.error(`[daemon] recovering ${recoverable.recoverable} sats in swept vtxo(s)...`); + await vtxoManager.recoverVtxos(); + console.error("[daemon] vtxo recovery complete"); + } + } catch (err) { + console.error(`[daemon] vtxo recovery error: ${err instanceof Error ? err.message : err}`); + } + })(); + + // Auto-delegate VTXOs when delegator is configured + let stopVtxoDelegate: (() => void) | undefined; + const delegatorManager = await wallet.getDelegatorManager(); + if (delegatorManager) { + const delegateSettled = async () => { + try { + const vtxos = (await wallet.getVtxos({ withRecoverable: false })).filter( + (v: ExtendedVirtualCoin) => v.virtualStatus.state === "settled" + ); + if (vtxos.length === 0) return; + const address = await wallet.getAddress(); + const result = await delegatorManager.delegate(vtxos, address); + if (result.delegated.length > 0) { + console.error(`[daemon] delegated ${result.delegated.length} vtxo(s) for auto-renewal`); + } + } catch (err) { + console.error(`[daemon] vtxo delegation error: ${err instanceof Error ? err.message : err}`); + } + }; + + // Delegate any existing settled VTXOs on startup + void delegateSettled(); + + // Subscribe and delegate on every incoming VTXO + wallet.notifyIncomingFunds((funds: IncomingFunds) => { + if (funds.type === "vtxo" && funds.newVtxos.length > 0) { + void delegateSettled(); + } + }).then((stop: () => void) => { stopVtxoDelegate = stop; }).catch((err: unknown) => { + console.error(`[daemon] vtxo delegate subscription error: ${err instanceof Error ? err.message : err}`); + }); + + console.error("[daemon] vtxo auto-delegation started"); + } + // Start HTTP server server.listen(port, "127.0.0.1", () => { saveDaemonPid(process.pid, port); @@ -324,6 +372,7 @@ async function runDaemon() { // Graceful shutdown const shutdown = async () => { console.error("[daemon] shutting down..."); + stopVtxoDelegate?.(); monitor.stop(); authMonitor.stop(); await ctx.lightning.stopSwapManager();