Skip to content
Draft
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
50 changes: 50 additions & 0 deletions typescript/packages/mechanisms/bip122/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
49 changes: 49 additions & 0 deletions typescript/packages/mechanisms/bip122/src/exact/client/scheme.ts
Original file line number Diff line number Diff line change
@@ -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 } };
}
}
25 changes: 25 additions & 0 deletions typescript/packages/mechanisms/bip122/src/exact/constants.ts
Original file line number Diff line number Diff line change
@@ -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";
205 changes: 205 additions & 0 deletions typescript/packages/mechanisms/bip122/src/exact/facilitator/scheme.ts
Original file line number Diff line number Diff line change
@@ -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<VerifyResult> {
// 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<SettleResult> {
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 };
}
7 changes: 7 additions & 0 deletions typescript/packages/mechanisms/bip122/src/exact/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading
Loading