diff --git a/src/agent-fetch.ts b/src/agent-fetch.ts index 84cf078..25ea7eb 100644 --- a/src/agent-fetch.ts +++ b/src/agent-fetch.ts @@ -82,7 +82,7 @@ export async function agentFetch( ); } - let paid: PaidChallenge = await options.pay(challenge); + let paid: PaidChallenge = await options.pay(challenge, { url }); if (!paid.preimage) { const paymentId = paid.paymentId; diff --git a/src/index.ts b/src/index.ts index 7eb1d1f..94b68cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,13 @@ export { agentFetch } from "./agent-fetch.js"; export { requestChallenge, payChallenge, fetchWithProof } from "./challenge.js"; export { FileTokenCache } from "./file-token-cache.js"; +export { zbdPayL402Invoice } from "./zbd-payment.js"; +export type { ZbdL402PaymentOptions } from "./zbd-payment.js"; export type { AgentFetchOptions, ChallengeScheme, + PaymentContext, PaidChallenge, PaymentSettlement, PaymentChallenge, diff --git a/src/types.ts b/src/types.ts index c6cff70..45c90a1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -34,6 +34,10 @@ export interface TokenCache { delete(url: string): Promise; } +export interface PaymentContext { + url: string; +} + export interface AgentFetchOptions { fetchImpl?: typeof fetch; tokenCache?: TokenCache; @@ -43,6 +47,6 @@ export interface AgentFetchOptions { paymentPollIntervalMs?: number; now?: () => number; sleep?: (ms: number) => Promise; - pay: (challenge: PaymentChallenge) => Promise; + pay: (challenge: PaymentChallenge, context?: PaymentContext) => Promise; waitForPayment?: (paymentId: string) => Promise; } diff --git a/src/zbd-payment.ts b/src/zbd-payment.ts new file mode 100644 index 0000000..aa73190 --- /dev/null +++ b/src/zbd-payment.ts @@ -0,0 +1,215 @@ +import { randomUUID } from "node:crypto"; + +import type { PaidChallenge, PaymentChallenge, PaymentContext } from "./types.js"; + +const DEFAULT_ZBD_API_BASE_URL = "https://api.zbdpay.com"; + +function parseBooleanEnv(value: string | undefined): boolean | undefined { + if (value === undefined) { + return undefined; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") { + return true; + } + + if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") { + return false; + } + + return undefined; +} + +function resolveShieldEnabled(explicitValue: boolean | undefined, zbdAiBaseUrl: string | undefined): boolean { + if (explicitValue !== undefined) { + return explicitValue; + } + + const envValue = parseBooleanEnv(process.env.ZBD_SHIELD_ENABLED); + if (envValue !== undefined) { + return envValue; + } + + return typeof zbdAiBaseUrl === "string" && zbdAiBaseUrl.length > 0; +} + +function isNetworkFailure(error: unknown): boolean { + return error instanceof TypeError; +} + +interface PaymentResponse { + preimage?: string; + paymentId?: string; + amountPaidSats?: number; +} + +async function parseResponseJson(response: Response): Promise> { + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.includes("application/json")) { + return {}; + } + + try { + return (await response.json()) as Record; + } catch { + return {}; + } +} + +function toPaymentResponse(body: Record, fallbackAmountSats: number): PaymentResponse { + const nested = + body.data && typeof body.data === "object" ? (body.data as Record) : undefined; + + const paymentId = + (typeof body.id === "string" ? body.id : undefined) ?? + (typeof body.payment_id === "string" ? body.payment_id : undefined) ?? + (nested && typeof nested.id === "string" ? nested.id : undefined) ?? + (nested && typeof nested.payment_id === "string" ? nested.payment_id : undefined); + + const preimage = + (typeof body.preimage === "string" ? body.preimage : undefined) ?? + (nested && typeof nested.preimage === "string" ? nested.preimage : undefined); + + const rawAmount = + (typeof body.amount_sats === "number" ? body.amount_sats : undefined) ?? + (typeof body.amountSats === "number" ? body.amountSats : undefined) ?? + (nested && typeof nested.amount_sats === "number" ? nested.amount_sats : undefined) ?? + (nested && typeof nested.amountSats === "number" ? nested.amountSats : undefined) ?? + fallbackAmountSats; + + const result: PaymentResponse = { + amountPaidSats: Number(rawAmount), + }; + if (preimage !== undefined) { + result.preimage = preimage; + } + if (paymentId !== undefined) { + result.paymentId = paymentId; + } + return result; +} + +function toPaidChallenge(parsed: PaymentResponse): PaidChallenge { + const result: PaidChallenge = { + preimage: parsed.preimage ?? "", + }; + if (parsed.paymentId !== undefined) { + result.paymentId = parsed.paymentId; + } + if (parsed.amountPaidSats !== undefined) { + result.amountPaidSats = parsed.amountPaidSats; + } + return result; +} + +export interface ZbdL402PaymentOptions { + apiKey?: string; + zbdApiBaseUrl?: string; + zbdAiBaseUrl?: string; + shieldEnabled?: boolean; + fetchImpl?: typeof fetch; + warningLogger?: (message: string) => void; + idempotencyKeyFactory?: () => string; +} + +async function zbdPayInvoiceDirect( + invoice: string, + amountSats: number, + options: ZbdL402PaymentOptions, +): Promise { + const apiKey = options.apiKey ?? process.env.ZBD_API_KEY; + if (!apiKey) { + throw new Error("Missing ZBD_API_KEY for direct L402 payment"); + } + + const fetchImpl = options.fetchImpl ?? fetch; + const zbdApiBaseUrl = options.zbdApiBaseUrl ?? process.env.ZBD_API_BASE_URL ?? DEFAULT_ZBD_API_BASE_URL; + + const response = await fetchImpl(`${zbdApiBaseUrl}/v0/payments`, { + method: "POST", + headers: { + apikey: apiKey, + "content-type": "application/json", + }, + body: JSON.stringify({ + invoice, + amount: amountSats, + }), + }); + + const body = await parseResponseJson(response); + if (!response.ok) { + throw new Error(`Direct L402 payment failed: ${response.status} ${JSON.stringify(body)}`); + } + + return toPaidChallenge(toPaymentResponse(body, amountSats)); +} + +export async function zbdPayL402Invoice( + challenge: PaymentChallenge, + context: PaymentContext, + options: ZbdL402PaymentOptions = {}, +): Promise { + const zbdAiBaseUrl = options.zbdAiBaseUrl ?? process.env.ZBD_AI_BASE_URL; + const shieldEnabled = resolveShieldEnabled(options.shieldEnabled, zbdAiBaseUrl); + const idempotencyKeyFactory = options.idempotencyKeyFactory ?? (() => `agent-fetch-l402-${randomUUID()}`); + + if (shieldEnabled && zbdAiBaseUrl) { + const apiKey = options.apiKey ?? process.env.ZBD_API_KEY; + if (!apiKey) { + throw new Error("Missing ZBD_API_KEY for shield L402 payment"); + } + + const fetchImpl = options.fetchImpl ?? fetch; + + try { + const response = await fetchImpl(`${zbdAiBaseUrl}/api/shield/l402`, { + method: "POST", + headers: { + apikey: apiKey, + "x-api-key": apiKey, + "content-type": "application/json", + }, + body: JSON.stringify({ + invoice: challenge.invoice, + amount_sats: challenge.amountSats, + url: context.url, + idempotency_key: idempotencyKeyFactory(), + }), + }); + + const body = await parseResponseJson(response); + + if (response.status === 403) { + const reason = typeof body.reason === "string" ? body.reason : "allowance_exceeded"; + const approvalId = typeof body.approval_id === "string" ? body.approval_id : undefined; + throw new Error( + approvalId + ? `Shield blocked L402 payment: ${reason} (approval_id=${approvalId})` + : `Shield blocked L402 payment: ${reason}`, + ); + } + + if (response.status === 202) { + const approvalId = typeof body.approval_id === "string" ? body.approval_id : "unknown"; + throw new Error(`Shield approval pending for L402 payment (approval_id=${approvalId})`); + } + + if (!response.ok) { + throw new Error(`Shield L402 payment failed: ${response.status} ${JSON.stringify(body)}`); + } + + return toPaidChallenge(toPaymentResponse(body, challenge.amountSats)); + } catch (error) { + if (!isNetworkFailure(error)) { + throw error; + } + + const warningLogger = options.warningLogger ?? ((message: string) => console.warn(message)); + warningLogger("Shield unreachable for L402 payment, falling back to direct payment"); + } + } + + return zbdPayInvoiceDirect(challenge.invoice, challenge.amountSats, options); +} diff --git a/test/zbd-payment.test.ts b/test/zbd-payment.test.ts new file mode 100644 index 0000000..0eb5580 --- /dev/null +++ b/test/zbd-payment.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { PaymentChallenge } from "../src/types.js"; +import { zbdPayL402Invoice } from "../src/zbd-payment.js"; + +const challenge: PaymentChallenge = { + scheme: "L402", + macaroon: "m", + invoice: "lnbc1mock", + paymentHash: "h", + amountSats: 21, +}; + +afterEach(() => { + delete process.env.ZBD_SHIELD_ENABLED; + delete process.env.ZBD_AI_BASE_URL; + delete process.env.ZBD_API_KEY; +}); + +describe("zbdPayL402Invoice", () => { + it("sends shield payload with invoice amount url and idempotency key", async () => { + process.env.ZBD_API_KEY = "api-key"; + process.env.ZBD_AI_BASE_URL = "https://zbd-ai.local"; + + const fetchImpl = vi.fn(async () => { + return new Response( + JSON.stringify({ + preimage: "pre", + payment_id: "pay-1", + amount_sats: 21, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ); + }); + + const paid = await zbdPayL402Invoice(challenge, { url: "https://service.local/protected" }, { + fetchImpl, + idempotencyKeyFactory: () => "idem-1", + }); + + expect(paid.preimage).toBe("pre"); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [url, init] = fetchImpl.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://zbd-ai.local/api/shield/l402"); + expect(JSON.parse(String(init.body))).toEqual({ + invoice: "lnbc1mock", + amount_sats: 21, + url: "https://service.local/protected", + idempotency_key: "idem-1", + }); + }); + + it("throws descriptive allowance error on shield 403", async () => { + process.env.ZBD_API_KEY = "api-key"; + process.env.ZBD_AI_BASE_URL = "https://zbd-ai.local"; + + const fetchImpl = vi.fn(async () => { + return new Response( + JSON.stringify({ + reason: "budget_exhausted", + approval_id: "appr-123", + }), + { + status: 403, + headers: { "content-type": "application/json" }, + }, + ); + }); + + await expect( + zbdPayL402Invoice(challenge, { url: "https://service.local/protected" }, { fetchImpl }), + ).rejects.toThrow("Shield blocked L402 payment: budget_exhausted (approval_id=appr-123)"); + }); + + it("throws pending approval error on shield 202", async () => { + process.env.ZBD_API_KEY = "api-key"; + process.env.ZBD_AI_BASE_URL = "https://zbd-ai.local"; + + const fetchImpl = vi.fn(async () => { + return new Response( + JSON.stringify({ + approval_id: "appr-456", + status: "pending_approval", + }), + { + status: 202, + headers: { "content-type": "application/json" }, + }, + ); + }); + + await expect( + zbdPayL402Invoice(challenge, { url: "https://service.local/protected" }, { fetchImpl }), + ).rejects.toThrow("Shield approval pending for L402 payment (approval_id=appr-456)"); + }); + + it("falls back to direct payment when shield network call fails", async () => { + process.env.ZBD_API_KEY = "api-key"; + process.env.ZBD_AI_BASE_URL = "https://zbd-ai.local"; + + const fetchImpl = vi + .fn() + .mockRejectedValueOnce(new TypeError("fetch failed")) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + preimage: "direct-pre", + payment_id: "pay-direct", + amount_sats: 21, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ); + + const warningLogger = vi.fn(); + + const paid = await zbdPayL402Invoice(challenge, { url: "https://service.local/protected" }, { + fetchImpl, + warningLogger, + }); + + expect(paid.preimage).toBe("direct-pre"); + expect(warningLogger).toHaveBeenCalledWith( + "Shield unreachable for L402 payment, falling back to direct payment", + ); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(fetchImpl.mock.calls[1]?.[0]).toBe("https://api.zbdpay.com/v0/payments"); + }); +});