Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/agent-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
6 changes: 5 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export interface TokenCache {
delete(url: string): Promise<void>;
}

export interface PaymentContext {
url: string;
}

export interface AgentFetchOptions {
fetchImpl?: typeof fetch;
tokenCache?: TokenCache;
Expand All @@ -43,6 +47,6 @@ export interface AgentFetchOptions {
paymentPollIntervalMs?: number;
now?: () => number;
sleep?: (ms: number) => Promise<void>;
pay: (challenge: PaymentChallenge) => Promise<PaidChallenge>;
pay: (challenge: PaymentChallenge, context?: PaymentContext) => Promise<PaidChallenge>;
waitForPayment?: (paymentId: string) => Promise<PaymentSettlement>;
}
215 changes: 215 additions & 0 deletions src/zbd-payment.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> {
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
return {};
}

try {
return (await response.json()) as Record<string, unknown>;
} catch {
return {};
}
}

function toPaymentResponse(body: Record<string, unknown>, fallbackAmountSats: number): PaymentResponse {
const nested =
body.data && typeof body.data === "object" ? (body.data as Record<string, unknown>) : 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<PaidChallenge> {
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<PaidChallenge> {
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);
}
135 changes: 135 additions & 0 deletions test/zbd-payment.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fetch>()
.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");
});
});