diff --git a/typescript/packages/mechanisms/bip122/package.json b/typescript/packages/mechanisms/bip122/package.json new file mode 100644 index 0000000000..4ffb060e03 --- /dev/null +++ b/typescript/packages/mechanisms/bip122/package.json @@ -0,0 +1,50 @@ +{ + "name": "@x402/bip122", + "version": "0.1.0", + "description": "x402 Payment Protocol Bitcoin Lightning (bip122/exact) Implementation", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/cjs/index.d.ts", + "license": "Apache-2.0", + "author": "x402 Foundation", + "repository": "https://github.com/x402-foundation/x402", + "keywords": ["x402", "payment", "protocol", "bitcoin", "lightning", "bip122", "bolt11"], + "scripts": { + "build": "tsup", + "test": "vitest run", + "test:watch": "vitest", + "format": "prettier -c .prettierrc --write \"**/*.{ts,js,json,md}\"", + "lint": "eslint . --ext .ts --fix" + }, + "exports": { + ".": { + "import": { "types": "./dist/esm/index.d.mts", "default": "./dist/esm/index.mjs" }, + "require": { "types": "./dist/cjs/index.d.ts", "default": "./dist/cjs/index.js" } + }, + "./exact/client": { + "import": { "types": "./dist/esm/exact/client/scheme.d.mts", "default": "./dist/esm/exact/client/scheme.mjs" }, + "require": { "types": "./dist/cjs/exact/client/scheme.d.ts", "default": "./dist/cjs/exact/client/scheme.js" } + }, + "./exact/server": { + "import": { "types": "./dist/esm/exact/server/scheme.d.mts", "default": "./dist/esm/exact/server/scheme.mjs" }, + "require": { "types": "./dist/cjs/exact/server/scheme.d.ts", "default": "./dist/cjs/exact/server/scheme.js" } + }, + "./exact/facilitator": { + "import": { "types": "./dist/esm/exact/facilitator/scheme.d.mts", "default": "./dist/esm/exact/facilitator/scheme.mjs" }, + "require": { "types": "./dist/cjs/exact/facilitator/scheme.d.ts", "default": "./dist/cjs/exact/facilitator/scheme.js" } + } + }, + "files": ["dist", "src"], + "dependencies": { + "@x402/core": "workspace:~" + }, + "optionalDependencies": { + "light-bolt11-decoder": "^3.0.0" + }, + "devDependencies": { + "@types/node": "^22.13.4", + "tsup": "^8.4.0", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + } +} \ No newline at end of file diff --git a/typescript/packages/mechanisms/bip122/src/exact/client/scheme.ts b/typescript/packages/mechanisms/bip122/src/exact/client/scheme.ts new file mode 100644 index 0000000000..5d3660571b --- /dev/null +++ b/typescript/packages/mechanisms/bip122/src/exact/client/scheme.ts @@ -0,0 +1,49 @@ +import type { PaymentRequirements } from "@x402/core/types"; +import type { ExactBip122Payload, LightningPayer } from "../types"; +import { ERR_MISSING_INVOICE, ERR_INVALID_PAYMENT_METHOD, PAYMENT_METHOD_LIGHTNING } from "../constants"; + +/** + * x402 client-side scheme for Bitcoin Lightning (bip122/exact). + * + * Responsibilities: + * - Extract the BOLT11 invoice from PaymentRequirements.extra + * - Pay it via the provided LightningPayer + * - Return the payload (invoice string) for the Authorization header + * + * @example + * ```ts + * import { ExactBip122ClientScheme } from "@x402/bip122/exact/client"; + * + * const client = new ExactBip122ClientScheme({ + * payInvoice: async (invoice, network) => { + * await myWallet.pay(invoice); + * } + * }); + * + * const payload = await client.createPaymentPayload(2, requirements); + * ``` + */ +export class ExactBip122ClientScheme { + readonly scheme = "exact"; + + constructor(private readonly payer: LightningPayer) {} + + async createPaymentPayload( + _x402Version: number, + requirements: PaymentRequirements, + ): Promise<{ payload: ExactBip122Payload }> { + const paymentMethod = requirements.extra?.paymentMethod; + if (paymentMethod && paymentMethod !== PAYMENT_METHOD_LIGHTNING) { + throw new Error(`${ERR_INVALID_PAYMENT_METHOD}: expected lightning, got ${paymentMethod}`); + } + + const invoice = requirements.extra?.invoice; + if (!invoice || typeof invoice !== "string") { + throw new Error(`${ERR_MISSING_INVOICE}: PaymentRequirements.extra.invoice is required`); + } + + await this.payer.payInvoice(invoice, requirements.network); + + return { payload: { invoice } }; + } +} diff --git a/typescript/packages/mechanisms/bip122/src/exact/constants.ts b/typescript/packages/mechanisms/bip122/src/exact/constants.ts new file mode 100644 index 0000000000..518cf9a9e4 --- /dev/null +++ b/typescript/packages/mechanisms/bip122/src/exact/constants.ts @@ -0,0 +1,25 @@ +export const SCHEME = "exact" as const; +export const ASSET = "BTC" as const; +export const PAY_TO_ANONYMOUS = "anonymous" as const; +export const PAYMENT_METHOD_LIGHTNING = "lightning" as const; +export const DEFAULT_INVOICE_DESCRIPTION = "x402 payment" as const; +export const DEFAULT_INVOICE_EXPIRY_SECONDS = 3600; +export const SETTLEMENT_TTL_BUFFER_MS = 60_000; + +export const BTC_MAINNET_CAIP2 = "bip122:000000000019d6689c085ae165831e93" as const; +export const BTC_TESTNET_CAIP2 = "bip122:000000000933ea01ad0ee984209779ba" as const; + +export const SUPPORTED_NETWORKS = [BTC_MAINNET_CAIP2, BTC_TESTNET_CAIP2] as const; + +export const ERR_UNSUPPORTED_NETWORK = "unsupported_network"; +export const ERR_INVALID_ASSET = "invalid_asset"; +export const ERR_INVALID_PAY_TO = "invalid_pay_to"; +export const ERR_INVALID_PAYMENT_METHOD = "invalid_payment_method"; +export const ERR_MISSING_INVOICE = "missing_invoice"; +export const ERR_INVALID_INVOICE = "invalid_invoice"; +export const ERR_INVOICE_SUBSTITUTION = "invoice_substitution"; +export const ERR_INVOICE_EXPIRED = "invoice_expired"; +export const ERR_AMOUNT_MISMATCH = "amount_mismatch"; +export const ERR_INVOICE_IN_FLIGHT = "invoice_in_flight"; +export const ERR_INVOICE_NOT_PAID = "invoice_not_paid"; +export const ERR_DUPLICATE_SETTLEMENT = "duplicate_settlement"; diff --git a/typescript/packages/mechanisms/bip122/src/exact/facilitator/scheme.ts b/typescript/packages/mechanisms/bip122/src/exact/facilitator/scheme.ts new file mode 100644 index 0000000000..e07bf3dfad --- /dev/null +++ b/typescript/packages/mechanisms/bip122/src/exact/facilitator/scheme.ts @@ -0,0 +1,205 @@ +import type { PaymentRequirements } from "@x402/core/types"; +import { decodeBolt11 } from "../utils"; +import { createInMemorySettlementCache, type SettlementCache } from "../settlementCache"; +import type { ExactBip122Payload, LightningReceiver, DecodedBolt11 } from "../types"; +import { + SUPPORTED_NETWORKS, + ASSET, + PAY_TO_ANONYMOUS, + PAYMENT_METHOD_LIGHTNING, + SETTLEMENT_TTL_BUFFER_MS, + ERR_UNSUPPORTED_NETWORK, + ERR_INVALID_ASSET, + ERR_INVALID_PAY_TO, + ERR_INVALID_PAYMENT_METHOD, + ERR_MISSING_INVOICE, + ERR_INVOICE_SUBSTITUTION, + ERR_INVOICE_EXPIRED, + ERR_AMOUNT_MISMATCH, + ERR_DUPLICATE_SETTLEMENT, + ERR_INVOICE_NOT_PAID, + ERR_INVOICE_IN_FLIGHT, +} from "../constants"; + +export interface ExactBip122FacilitatorOptions { + receiver: LightningReceiver; + settlementCache?: SettlementCache; + /** Override BOLT11 decoder for testing. */ + decodeBolt11Fn?: (invoice: string) => DecodedBolt11; + /** Override clock for testing. Returns ms epoch. */ + nowFn?: () => number; +} + +export interface VerifyResult { + verified: boolean; + paymentHash?: string; + amountMsat?: number; + reason?: string; +} + +export interface SettleResult { + settled: boolean; + paymentHash?: string; + reason?: string; +} + +/** + * x402 facilitator-side scheme for Bitcoin Lightning (bip122/exact). + * + * Implements the 10-step verification defined in PR #1311: + * 1. Validate network (bip122:*) + * 2. Validate asset (BTC) + * 3. Validate payTo (anonymous) + * 4. Validate paymentMethod (lightning) + * 5. Check invoice presence in requirements + * 6. Invoice substitution guard — payload.invoice must match requirements.extra.invoice + * 7. Decode BOLT11 — extract payment_hash, amount_msat, expiresAt + * 8. Expiry check — reject expired invoices before hitting the node + * 9. Amount check — decoded msat must match requirements.amount exactly + * 10. Replay cache — reject already-settled payment_hash + * 11. Node check — call receiver.lookupInvoice, handle in-flight + * + * settle() marks the payment_hash in the cache with TTL = (expiresAt - now) + buffer. + * + * @example + * ```ts + * import { ExactBip122FacilitatorScheme } from "@x402/bip122/exact/facilitator"; + * + * const facilitator = new ExactBip122FacilitatorScheme({ + * receiver: myLightningReceiver, + * }); + * + * const result = await facilitator.verify(payload, requirements); + * ``` + */ +export class ExactBip122FacilitatorScheme { + readonly scheme = "exact"; + + private readonly receiver: LightningReceiver; + private readonly cache: SettlementCache; + private readonly decodeFn: (invoice: string) => DecodedBolt11; + private readonly nowFn: () => number; + + constructor(options: ExactBip122FacilitatorOptions) { + this.receiver = options.receiver; + this.cache = options.settlementCache ?? createInMemorySettlementCache(); + this.decodeFn = options.decodeBolt11Fn ?? decodeBolt11; + this.nowFn = options.nowFn ?? (() => Date.now()); + } + + async verify( + payload: ExactBip122Payload, + requirements: PaymentRequirements, + ): Promise { + // Step 1 — network + if (!SUPPORTED_NETWORKS.includes(requirements.network as (typeof SUPPORTED_NETWORKS)[number])) { + return fail(`${ERR_UNSUPPORTED_NETWORK}: ${requirements.network}`); + } + + // Step 2 — asset + if (requirements.asset && requirements.asset !== ASSET) { + return fail(`${ERR_INVALID_ASSET}: expected BTC, got ${requirements.asset}`); + } + + // Step 3 — payTo + if (requirements.payTo && requirements.payTo !== PAY_TO_ANONYMOUS) { + return fail(`${ERR_INVALID_PAY_TO}: expected anonymous`); + } + + // Step 4 — paymentMethod + const pm = requirements.extra?.paymentMethod; + if (pm && pm !== PAYMENT_METHOD_LIGHTNING) { + return fail(`${ERR_INVALID_PAYMENT_METHOD}: expected lightning, got ${pm}`); + } + + // Step 5 — invoice in requirements + const requiredInvoice = requirements.extra?.invoice; + if (!requiredInvoice || typeof requiredInvoice !== "string") { + return fail(ERR_MISSING_INVOICE); + } + + // Step 6 — invoice substitution guard (must check BEFORE decode) + // A misbehaving client could submit a different paid invoice from a prior request. + if (payload.invoice !== requiredInvoice) { + return fail(ERR_INVOICE_SUBSTITUTION); + } + + // Step 7 — decode BOLT11 + let decoded: DecodedBolt11; + try { + decoded = this.decodeFn(payload.invoice); + } catch (e) { + return fail(`invalid_invoice: ${(e as Error).message}`); + } + + // Step 8 — expiry check + // Note: most Lightning backends don't surface "expired" as a distinct status. + // We check explicitly: expiresAt is timestamp + expiry (seconds), compare to now (ms). + const nowSec = this.nowFn() / 1000; + if (decoded.expiresAt < nowSec) { + return fail(ERR_INVOICE_EXPIRED); + } + + // Step 9 — amount match (string compare to avoid float precision issues) + const requiredMsat = Number(requirements.amount); + if (decoded.amountMsat !== requiredMsat) { + return fail( + `${ERR_AMOUNT_MISMATCH}: invoice ${decoded.amountMsat} msat ≠ required ${requiredMsat} msat`, + ); + } + + // Step 10 — replay cache + if (this.cache.isSettled(decoded.paymentHash)) { + return fail(ERR_DUPLICATE_SETTLEMENT); + } + + // Step 11 — node check + const status = await this.receiver.lookupInvoice(payload.invoice, requirements.network); + + if (!status) { + return fail(ERR_INVOICE_NOT_PAID); + } + + if (status.status === "in_flight") { + return fail(ERR_INVOICE_IN_FLIGHT); + } + + if (status.status !== "paid") { + return fail(ERR_INVOICE_NOT_PAID); + } + + return { verified: true, paymentHash: decoded.paymentHash, amountMsat: decoded.amountMsat }; + } + + async settle( + payload: ExactBip122Payload, + requirements: PaymentRequirements, + ): Promise { + let decoded: DecodedBolt11; + try { + decoded = this.decodeFn(payload.invoice); + } catch (e) { + return { settled: false, reason: `invalid_invoice: ${(e as Error).message}` }; + } + + if (this.cache.isSettled(decoded.paymentHash)) { + return { settled: false, reason: ERR_DUPLICATE_SETTLEMENT }; + } + + const status = await this.receiver.lookupInvoice(payload.invoice, requirements.network); + if (!status || status.status !== "paid") { + return { settled: false, reason: ERR_INVOICE_NOT_PAID }; + } + + const nowMs = this.nowFn(); + const expiresAtMs = decoded.expiresAt * 1000; + const ttlMs = Math.max(expiresAtMs - nowMs, 0) + SETTLEMENT_TTL_BUFFER_MS; + this.cache.markSettled(decoded.paymentHash, ttlMs); + + return { settled: true, paymentHash: decoded.paymentHash }; + } +} + +function fail(reason: string): VerifyResult { + return { verified: false, reason }; +} diff --git a/typescript/packages/mechanisms/bip122/src/exact/index.ts b/typescript/packages/mechanisms/bip122/src/exact/index.ts new file mode 100644 index 0000000000..31e69efec7 --- /dev/null +++ b/typescript/packages/mechanisms/bip122/src/exact/index.ts @@ -0,0 +1,7 @@ +export { ExactBip122ClientScheme } from "./client/scheme"; +export { ExactBip122ServerScheme } from "./server/scheme"; +export { ExactBip122FacilitatorScheme } from "./facilitator/scheme"; +export { createInMemorySettlementCache } from "./settlementCache"; +export { decodeBolt11 } from "./utils"; +export * from "./types"; +export * from "./constants"; diff --git a/typescript/packages/mechanisms/bip122/src/exact/server/scheme.ts b/typescript/packages/mechanisms/bip122/src/exact/server/scheme.ts new file mode 100644 index 0000000000..d355fc01a5 --- /dev/null +++ b/typescript/packages/mechanisms/bip122/src/exact/server/scheme.ts @@ -0,0 +1,96 @@ +import type { PaymentRequirements, AssetAmount, Price } from "@x402/core/types"; +import { + ASSET, + DEFAULT_INVOICE_DESCRIPTION, + DEFAULT_INVOICE_EXPIRY_SECONDS, + PAY_TO_ANONYMOUS, + PAYMENT_METHOD_LIGHTNING, + SCHEME, + SUPPORTED_NETWORKS, + ERR_UNSUPPORTED_NETWORK, +} from "../constants"; +import type { LightningReceiver } from "../types"; + +/** + * x402 server-side scheme for Bitcoin Lightning (bip122/exact). + * + * Responsibilities: + * - Convert a price to millisatoshis + * - Create a BOLT11 invoice and inject it into PaymentRequirements.extra + * + * The server does NOT verify payments — that is the facilitator's role. + * For sovereign (no-facilitator) deployments, use ExactBip122FacilitatorScheme + * directly on the server. + * + * @example + * ```ts + * import { ExactBip122ServerScheme } from "@x402/bip122/exact/server"; + * import { myLightningReceiver } from "./my-receiver"; + * + * const server = new ExactBip122ServerScheme(myLightningReceiver); + * ``` + */ +export class ExactBip122ServerScheme { + readonly scheme = SCHEME; + + constructor( + private readonly receiver: LightningReceiver, + private readonly defaultDescription = DEFAULT_INVOICE_DESCRIPTION, + private readonly defaultExpirySeconds = DEFAULT_INVOICE_EXPIRY_SECONDS, + ) {} + + /** + * Parse a price into an AssetAmount (millisatoshis). + * Accepts a number (treated as satoshis) or a string like "1000" (sats) or "1000msat". + */ + async parsePrice(price: Price, network: string): Promise { + if (!SUPPORTED_NETWORKS.includes(network as (typeof SUPPORTED_NETWORKS)[number])) { + throw new Error(`${ERR_UNSUPPORTED_NETWORK}: ${network}`); + } + + if (typeof price === "object" && "amount" in price) { + return { amount: price.amount, asset: ASSET }; + } + + const raw = typeof price === "number" ? price : parseFloat(String(price).replace(/[^0-9.]/g, "")); + if (isNaN(raw) || raw <= 0) throw new Error(`Invalid price: ${price}`); + + // Treat bare numbers as satoshis → convert to msats + const amountMsat = Math.round(raw * 1000); + return { amount: String(amountMsat), asset: ASSET }; + } + + /** + * Create a BOLT11 invoice and inject it into PaymentRequirements.extra. + * Called by the x402 middleware before sending the 402 response. + */ + async enhancePaymentRequirements( + requirements: PaymentRequirements, + supportedKind: { network: string; extra?: Record }, + ): Promise { + const network = supportedKind.network ?? requirements.network; + + const amountMsat = Number(requirements.amount); + if (isNaN(amountMsat) || amountMsat <= 0) { + throw new Error(`Invalid amount in requirements: ${requirements.amount}`); + } + + const invoice = await this.receiver.createInvoice( + amountMsat, + this.defaultDescription, + this.defaultExpirySeconds, + network, + ); + + return { + ...requirements, + asset: ASSET, + payTo: PAY_TO_ANONYMOUS, + extra: { + ...requirements.extra, + paymentMethod: PAYMENT_METHOD_LIGHTNING, + invoice, + }, + }; + } +} diff --git a/typescript/packages/mechanisms/bip122/src/exact/settlementCache.ts b/typescript/packages/mechanisms/bip122/src/exact/settlementCache.ts new file mode 100644 index 0000000000..87544dcf62 --- /dev/null +++ b/typescript/packages/mechanisms/bip122/src/exact/settlementCache.ts @@ -0,0 +1,36 @@ +/** + * In-memory TTL cache for Lightning payment_hash deduplication. + * + * Prevents replay attacks where a client resubmits a previously-settled invoice. + * TTL is set to invoice expiry + SETTLEMENT_TTL_BUFFER_MS to cover the full + * window in which a duplicate could arrive. + * + * For production deployments with multiple server instances, replace this with + * a shared cache (Redis, etc.) implementing the same interface. + */ +export interface SettlementCache { + isSettled(paymentHash: string): boolean; + markSettled(paymentHash: string, ttlMs: number): void; +} + +export function createInMemorySettlementCache(): SettlementCache { + const store = new Map(); + + function purgeExpired() { + const now = Date.now(); + for (const [key, expiresAt] of store) { + if (now > expiresAt) store.delete(key); + } + } + + return { + isSettled(paymentHash: string): boolean { + purgeExpired(); + const expiresAt = store.get(paymentHash); + return expiresAt !== undefined && Date.now() <= expiresAt; + }, + markSettled(paymentHash: string, ttlMs: number): void { + store.set(paymentHash, Date.now() + ttlMs); + }, + }; +} diff --git a/typescript/packages/mechanisms/bip122/src/exact/types.ts b/typescript/packages/mechanisms/bip122/src/exact/types.ts new file mode 100644 index 0000000000..5bb69e7059 --- /dev/null +++ b/typescript/packages/mechanisms/bip122/src/exact/types.ts @@ -0,0 +1,55 @@ +export type LightningPaymentStatus = "unpaid" | "in_flight" | "paid"; + +export interface LightningInvoiceStatus { + invoice: string; + paymentHash: string; + amountMsat: number; + /** Unix timestamp (seconds) when the invoice expires: timestamp + expiry from BOLT11 */ + expiresAt: number; + status: LightningPaymentStatus; + payer?: string; + settledAt?: number; +} + +/** Minimal decoded BOLT11 fields required for verification. */ +export interface DecodedBolt11 { + paymentHash: string; + /** Amount in millisatoshis */ + amountMsat: number; + /** Unix timestamp (seconds) of invoice creation */ + timestamp: number; + /** Invoice expiry in seconds */ + expiry: number; + /** Absolute expiry: timestamp + expiry */ + expiresAt: number; +} + +/** x402 payload sent by the client after paying the invoice. */ +export interface ExactBip122Payload { + /** The BOLT11 invoice that was paid. */ + invoice: string; +} + +/** Protocol for objects that can pay Lightning invoices (client-side). */ +export interface LightningPayer { + /** Pay a BOLT11 invoice. Resolves when payment is complete. */ + payInvoice(invoice: string, network: string): Promise; +} + +/** Protocol for objects that can issue and look up Lightning invoices (server-side). */ +export interface LightningReceiver { + /** Create a BOLT11 invoice for the given amount. Returns the invoice string. */ + createInvoice( + amountMsat: number, + memo: string, + expirySeconds: number, + network: string, + ): Promise; + /** + * Look up the status of a previously-issued invoice. + * Returns null if the invoice is unknown. + * Note: "expired" is NOT a distinct status on most backends — callers must + * check expiresAt independently via the decoded BOLT11. + */ + lookupInvoice(invoice: string, network: string): Promise; +} diff --git a/typescript/packages/mechanisms/bip122/src/exact/utils.ts b/typescript/packages/mechanisms/bip122/src/exact/utils.ts new file mode 100644 index 0000000000..088d89103d --- /dev/null +++ b/typescript/packages/mechanisms/bip122/src/exact/utils.ts @@ -0,0 +1,69 @@ +import type { DecodedBolt11 } from "./types"; + +let _decode: ((invoice: string) => unknown) | null = null; + +function getDecoder(): (invoice: string) => unknown { + if (!_decode) { + // lazy-require so the package loads even if light-bolt11-decoder is absent + // (facilitator-only deployments that supply their own decodeBolt11Fn) + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const mod = require("light-bolt11-decoder"); + _decode = mod.decode ?? mod.default?.decode ?? mod; + } catch { + throw new Error( + "light-bolt11-decoder is required for BOLT11 decoding. " + + "Install it: npm add light-bolt11-decoder", + ); + } + } + return _decode!; +} + +/** + * Decode a BOLT11 invoice into the minimal fields needed for x402 verification. + * Uses light-bolt11-decoder (zero-deps, TypeScript-native, ~12kB). + * + * @throws if the invoice is malformed or missing required fields + */ +export function decodeBolt11(invoice: string): DecodedBolt11 { + const decode = getDecoder(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const decoded: any = decode(invoice); + + const getSectionValue = (name: string) => + decoded?.sections?.find((s: { name: string }) => s.name === name)?.value; + + const paymentHash: string | undefined = + decoded?.payment_hash ?? getSectionValue("payment_hash"); + const amountMsat: number | undefined = + decoded?.millisatoshis != null + ? Number(decoded.millisatoshis) + : decoded?.satoshis != null + ? Number(decoded.satoshis) * 1000 + : undefined; + const timestamp: number | undefined = decoded?.timestamp ?? getSectionValue("timestamp"); + const expiry: number | undefined = + decoded?.expiry ?? getSectionValue("expiry") ?? 3600; + + if (!paymentHash || typeof paymentHash !== "string") { + throw new Error("BOLT11 decode: missing payment_hash"); + } + if (amountMsat == null || isNaN(amountMsat) || amountMsat <= 0) { + throw new Error("BOLT11 decode: missing or invalid amount"); + } + if (timestamp == null || isNaN(Number(timestamp))) { + throw new Error("BOLT11 decode: missing timestamp"); + } + + const ts = Number(timestamp); + const exp = Number(expiry); + + return { + paymentHash, + amountMsat, + timestamp: ts, + expiry: exp, + expiresAt: ts + exp, + }; +} diff --git a/typescript/packages/mechanisms/bip122/src/index.ts b/typescript/packages/mechanisms/bip122/src/index.ts new file mode 100644 index 0000000000..fde0cc9763 --- /dev/null +++ b/typescript/packages/mechanisms/bip122/src/index.ts @@ -0,0 +1 @@ +export * from "./exact"; \ No newline at end of file diff --git a/typescript/packages/mechanisms/bip122/test/unit/facilitator.test.ts b/typescript/packages/mechanisms/bip122/test/unit/facilitator.test.ts new file mode 100644 index 0000000000..551ea3d854 --- /dev/null +++ b/typescript/packages/mechanisms/bip122/test/unit/facilitator.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ExactBip122FacilitatorScheme } from "../../src/exact/facilitator/scheme"; +import type { LightningReceiver, LightningInvoiceStatus, DecodedBolt11 } from "../../src/exact/types"; +import { BTC_MAINNET_CAIP2 } from "../../src/exact/constants"; + +const PAYMENT_HASH = "abc123def456abc123def456abc123def456abc123def456abc123def456abc1"; +const INVOICE = "lnbc1u1p3xyz_test_invoice_string"; +const AMOUNT_MSAT = 1_000_000; // 1000 sats +const NOW_SEC = 1_700_000_000; +const EXPIRES_AT = NOW_SEC + 3600; + +function makeDecoded(overrides?: Partial): DecodedBolt11 { + return { + paymentHash: PAYMENT_HASH, + amountMsat: AMOUNT_MSAT, + timestamp: NOW_SEC, + expiry: 3600, + expiresAt: EXPIRES_AT, + ...overrides, + }; +} + +function makeRequirements(extra?: Record) { + return { + scheme: "exact", + network: BTC_MAINNET_CAIP2, + amount: String(AMOUNT_MSAT), + asset: "BTC", + payTo: "anonymous", + maxTimeoutSeconds: 60, + extra: { + paymentMethod: "lightning", + invoice: INVOICE, + ...extra, + }, + } as Parameters[1]; +} + +function makeReceiver(status: LightningInvoiceStatus["status"] = "paid"): LightningReceiver { + const s: LightningInvoiceStatus = { + invoice: INVOICE, + paymentHash: PAYMENT_HASH, + amountMsat: AMOUNT_MSAT, + expiresAt: EXPIRES_AT, + status, + }; + return { + createInvoice: vi.fn(), + lookupInvoice: vi.fn().mockResolvedValue(s), + }; +} + +describe("ExactBip122FacilitatorScheme.verify", () => { + let facilitator: ExactBip122FacilitatorScheme; + let receiver: LightningReceiver; + + beforeEach(() => { + receiver = makeReceiver("paid"); + facilitator = new ExactBip122FacilitatorScheme({ + receiver, + decodeBolt11Fn: () => makeDecoded(), + nowFn: () => NOW_SEC * 1000, + }); + }); + + it("verifies a valid paid invoice", async () => { + const result = await facilitator.verify({ invoice: INVOICE }, makeRequirements()); + expect(result.verified).toBe(true); + expect(result.paymentHash).toBe(PAYMENT_HASH); + }); + + it("rejects unsupported network", async () => { + const req = { ...makeRequirements(), network: "eip155:1" }; + const result = await facilitator.verify({ invoice: INVOICE }, req as never); + expect(result.verified).toBe(false); + expect(result.reason).toContain("unsupported_network"); + }); + + it("rejects invoice substitution — payload.invoice !== requirements.extra.invoice", async () => { + const result = await facilitator.verify( + { invoice: "lnbc_different_invoice" }, + makeRequirements(), + ); + expect(result.verified).toBe(false); + expect(result.reason).toBe("invoice_substitution"); + }); + + it("rejects expired invoice", async () => { + facilitator = new ExactBip122FacilitatorScheme({ + receiver, + decodeBolt11Fn: () => makeDecoded({ expiresAt: NOW_SEC - 1 }), + nowFn: () => NOW_SEC * 1000, + }); + const result = await facilitator.verify({ invoice: INVOICE }, makeRequirements()); + expect(result.verified).toBe(false); + expect(result.reason).toBe("invoice_expired"); + }); + + it("rejects amount mismatch", async () => { + facilitator = new ExactBip122FacilitatorScheme({ + receiver, + decodeBolt11Fn: () => makeDecoded({ amountMsat: 999 }), + nowFn: () => NOW_SEC * 1000, + }); + const result = await facilitator.verify({ invoice: INVOICE }, makeRequirements()); + expect(result.verified).toBe(false); + expect(result.reason).toContain("amount_mismatch"); + }); + + it("rejects duplicate settlement (replay attack)", async () => { + await facilitator.settle({ invoice: INVOICE }, makeRequirements()); + const result = await facilitator.verify({ invoice: INVOICE }, makeRequirements()); + expect(result.verified).toBe(false); + expect(result.reason).toBe("duplicate_settlement"); + }); + + it("rejects unpaid invoice", async () => { + receiver = makeReceiver("unpaid"); + facilitator = new ExactBip122FacilitatorScheme({ + receiver, + decodeBolt11Fn: () => makeDecoded(), + nowFn: () => NOW_SEC * 1000, + }); + const result = await facilitator.verify({ invoice: INVOICE }, makeRequirements()); + expect(result.verified).toBe(false); + expect(result.reason).toBe("invoice_not_paid"); + }); + + it("rejects in-flight invoice", async () => { + receiver = makeReceiver("in_flight"); + facilitator = new ExactBip122FacilitatorScheme({ + receiver, + decodeBolt11Fn: () => makeDecoded(), + nowFn: () => NOW_SEC * 1000, + }); + const result = await facilitator.verify({ invoice: INVOICE }, makeRequirements()); + expect(result.verified).toBe(false); + expect(result.reason).toBe("invoice_in_flight"); + }); + + it("rejects missing invoice in requirements", async () => { + const result = await facilitator.verify({ invoice: INVOICE }, makeRequirements({ invoice: undefined })); + expect(result.verified).toBe(false); + expect(result.reason).toBe("missing_invoice"); + }); +}); + +describe("ExactBip122FacilitatorScheme.settle", () => { + it("settles a paid invoice and prevents replay", async () => { + const receiver = makeReceiver("paid"); + const facilitator = new ExactBip122FacilitatorScheme({ + receiver, + decodeBolt11Fn: () => makeDecoded(), + nowFn: () => NOW_SEC * 1000, + }); + + const settle1 = await facilitator.settle({ invoice: INVOICE }, makeRequirements()); + expect(settle1.settled).toBe(true); + expect(settle1.paymentHash).toBe(PAYMENT_HASH); + + const settle2 = await facilitator.settle({ invoice: INVOICE }, makeRequirements()); + expect(settle2.settled).toBe(false); + expect(settle2.reason).toBe("duplicate_settlement"); + }); +}); \ No newline at end of file diff --git a/typescript/packages/mechanisms/bip122/test/unit/settlementCache.test.ts b/typescript/packages/mechanisms/bip122/test/unit/settlementCache.test.ts new file mode 100644 index 0000000000..3f7b335ab3 --- /dev/null +++ b/typescript/packages/mechanisms/bip122/test/unit/settlementCache.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { createInMemorySettlementCache } from "../../src/exact/settlementCache"; + +describe("createInMemorySettlementCache", () => { + afterEach(() => vi.useRealTimers()); + + it("returns false for unknown hash", () => { + const cache = createInMemorySettlementCache(); + expect(cache.isSettled("unknown")).toBe(false); + }); + + it("returns true immediately after marking", () => { + const cache = createInMemorySettlementCache(); + cache.markSettled("hash1", 60_000); + expect(cache.isSettled("hash1")).toBe(true); + }); + + it("returns false after TTL expires", () => { + vi.useFakeTimers(); + const cache = createInMemorySettlementCache(); + cache.markSettled("hash2", 1000); + vi.advanceTimersByTime(1001); + expect(cache.isSettled("hash2")).toBe(false); + }); + + it("handles multiple independent hashes", () => { + const cache = createInMemorySettlementCache(); + cache.markSettled("a", 10_000); + cache.markSettled("b", 10_000); + expect(cache.isSettled("a")).toBe(true); + expect(cache.isSettled("b")).toBe(true); + expect(cache.isSettled("c")).toBe(false); + }); +}); \ No newline at end of file diff --git a/typescript/packages/mechanisms/bip122/tsconfig.json b/typescript/packages/mechanisms/bip122/tsconfig.json new file mode 100644 index 0000000000..a55de12359 --- /dev/null +++ b/typescript/packages/mechanisms/bip122/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2020"], + "allowJs": false, + "checkJs": false + }, + "include": ["src", "test"] +} \ No newline at end of file