Skip to content

feat(ts): TypeScript Lightning (bip122/exact) mechanism - ExactBip122{Client,Server,Facilitator}Scheme#2262

Draft
ThiagoDataEngineer wants to merge 1 commit into
x402-foundation:mainfrom
ThiagoDataEngineer:feat/ts-bip122-exact-lightning
Draft

feat(ts): TypeScript Lightning (bip122/exact) mechanism - ExactBip122{Client,Server,Facilitator}Scheme#2262
ThiagoDataEngineer wants to merge 1 commit into
x402-foundation:mainfrom
ThiagoDataEngineer:feat/ts-bip122-exact-lightning

Conversation

@ThiagoDataEngineer
Copy link
Copy Markdown

Closes #2258.

Adds TypeScript Lightning (bip122/exact) mechanism implementation following the spec in PR #1311 and the Python reference in PR #1873.

What's here

13 files, 801 LOC in typescript/packages/mechanisms/bip122/:

File Role
src/exact/types.ts LightningPayer, LightningReceiver, ExactBip122Payload, LightningInvoiceStatus, DecodedBolt11
src/exact/constants.ts CAIP-2 network IDs, error codes, defaults
src/exact/utils.ts decodeBolt11() - lazy-requires light-bolt11-decoder
src/exact/settlementCache.ts In-memory TTL cache (replaces with Redis/Postgres via interface)
src/exact/client/scheme.ts ExactBip122ClientScheme - extracts invoice, calls LightningPayer.payInvoice
src/exact/server/scheme.ts ExactBip122ServerScheme - creates invoice via LightningReceiver, injects into extra
src/exact/facilitator/scheme.ts ExactBip122FacilitatorScheme - 10-step verify + settle
test/unit/facilitator.test.ts 10 unit tests: happy path + all rejection branches
test/unit/settlementCache.test.ts 4 unit tests: TTL, isolation, expiry

Verification (10 steps, per #1311)

`

  1. network in bip122:* (mainnet + testnet CAIP-2)
  2. asset === BTC
  3. payTo === anonymous
  4. extra.paymentMethod === lightning
  5. extra.invoice present in requirements
  6. invoice_substitution: payload.invoice === requirements.extra.invoice ? before BOLT11 decode
  7. BOLT11 decode ? payment_hash, amount_msat, expiresAt
  8. expiry: expiresAt (secs) < now - explicit because backends don't surface "expired"
  9. amount: decoded msat === requirements.amount (string-safe compare)
  10. replay cache: payment_hash not already settled
  11. node check: LightningReceiver.lookupInvoice ? paid / in_flight / unpaid
    `

Step 6 (invoice substitution) runs before BOLT11 decode per the test case in @powforge/mcp-pay-gate - prevents a stale paid invoice from a prior request cross-binding to new requirements.

Design choices

BOLT11 library: light-bolt11-decoder (optional peer dep, lazy-required via require()). Zero-deps, ~12kB, decode-only - the verifier never signs. Injecting decodeBolt11Fn as a constructor option keeps tests fast and deterministic.

expiresAt: timestamp + expiry from BOLT11 header, compared to Date.now() / 1000. Documented in LightningInvoiceStatus because LNBits (and Blink) don't return a distinct "expired" status - callers must check independently. The nowFn option is injectable for testing.

TTL formula: (expiresAt * 1000 - now) + 60_000 ms - mirrors the expiry + 60s used in production by @powforge/mcp-pay-gate.

No facilitator required: Lightning settles off-chain; the server verifies directly via its own node/wallet adapter. ExactBip122FacilitatorScheme can run co-located with the server.

LightningReceiver interface: the lookupInvoice slot maps to LNBits' checkPaidFn pattern. For Blink, makeBlinkCheckPaidFn follows the same shape - the facilitator core is adapter-agnostic.

Prior art

@powforge/mcp-pay-gate (MIT) - verifier shape and invoice-substitution test informed the lookupInvoice interface and the step-6 guard order. Credited in commit message.

What's NOT here yet

  • Go implementation (separate PR)
  • Alby facilitator adapter (follows Add Alby Bitcoin Facilitator #1590)
  • Integration tests against live LNBits/Blink (needs test infra decision)
  • @x402/bip122 published to npm (pending maintainer review)

Testing

�ash cd typescript/packages/mechanisms/bip122 npm install npm test

All 14 unit tests pass. decodeBolt11Fn and nowFn are injected in tests - no live Lightning node required.

cc @ocknamo (spec, #1311) @Bortlesboat (Python reference, #1873) @zekebuilds-lab (prior art, invoice-substitution test)

Implements ExactBip122ClientScheme, ExactBip122ServerScheme, and
ExactBip122FacilitatorScheme following the spec in PR x402-foundation#1311 and
the Python reference in PR x402-foundation#1873.

Key design decisions:
- light-bolt11-decoder (optional peer dep, lazy-required) for BOLT11 decode
- 10-step verification per spec: network/asset/payTo/paymentMethod/invoice
  presence/invoice-substitution/decode/expiry/amount/replay-cache/node-check
- Invoice substitution guard runs before BOLT11 decode (prevents cross-bind
  of a stale paid invoice from a prior request)
- expiresAt = timestamp + expiry; checked explicitly since most Lightning
  backends don't surface "expired" as a distinct status (LNBits, Blink)
- TTL = (expiresAt - now) + 60s buffer for settlement dedup cache
- Fire-and-forget safe: decodeBolt11Fn and nowFn are injectable for testing

Prior art: @powforge/mcp-pay-gate (MIT) — verifier shape and LNBits adapter
pattern informed the lookupInvoice interface and invoice-substitution test.

Closes x402-foundation#2258
@vercel
Copy link
Copy Markdown

vercel Bot commented May 11, 2026

@ThiagoDataEngineer is attempting to deploy a commit to the Coinbase Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions github-actions Bot added typescript sdk Changes to core v2 packages labels May 11, 2026
@zekebuilds-lab
Copy link
Copy Markdown

@ThiagoDataEngineer nice. The step-6-before-decode order is the load-bearing bit and you got the spec right. Glad to be cited; the credit reads cleanly.

One distinction worth pulling apart in the test matrix while the PR is still draft: the invoice-substitution guard (three-way string compare across payload.invoice / accepted.extra.invoice / requirements.extra.invoice) and a minted-invoice guard ("did THIS server actually issue this payment_hash") are two different checks. In our verifier they're step 5 and step 6 respectively. Step 5 catches a client mixing-and-matching strings inside one request. Step 6 catches a client attaching a paid invoice from a different server's L402 / Lightning surface entirely. Source: src/lib/x402-verify.js#L172-L207.

The minted-invoice check is optional in our impl (gated on mintedInvoices.has), since ops without a minted store can reasonably fall back to "any valid + paid invoice on our LNBits node is ours". But for a facilitator that fronts multiple receivers, or for operators wallet-sharing across services, the cross-server replay is a real surface and the spec test matrix probably wants an explicit case for it. Happy to write the test in TS mirroring our x402-verify.test.js shape if useful: cross_server_invoice rejection alongside the invoice_substitution one you've already got.

Also: the (timestamp + expiry) * 1000 comparison in your step 8 matches our impl byte-for-byte. Good landing.

@zekebuilds-lab
Copy link
Copy Markdown

@ThiagoDataEngineer — pulled the standalone verifier out into its own package in case it's useful here:

@powforge/l402-verify@0.1.0

Zero runtime dependencies. Exports verifyL402(authHeader, cfg), parseL402Header, verifyMacaroon, and checkInvoicePaid. The checkPaidFn is injectable so it isn't bound to LNBits — drop in LND lookupinvoice, Phoenix, Greenlight, hosted, whatever. Same 8-step verification order as the verifyX402Lightning you've got here, including the step-6-before-decode ordering.

35 unit tests. MIT.

No expectation either way — just figured I'd flag it before this PR lands in case it's easier to depend on a tagged surface than vendor the logic.

1 similar comment
@zekebuilds-lab
Copy link
Copy Markdown

@ThiagoDataEngineer — pulled the standalone verifier out into its own package in case it's useful here:

@powforge/l402-verify@0.1.0

Zero runtime dependencies. Exports verifyL402(authHeader, cfg), parseL402Header, verifyMacaroon, and checkInvoicePaid. The checkPaidFn is injectable so it isn't bound to LNBits — drop in LND lookupinvoice, Phoenix, Greenlight, hosted, whatever. Same 8-step verification order as the verifyX402Lightning you've got here, including the step-6-before-decode ordering.

35 unit tests. MIT.

No expectation either way — just figured I'd flag it before this PR lands in case it's easier to depend on a tagged surface than vendor the logic.

@zekebuilds-lab
Copy link
Copy Markdown

One thing worth surfacing while the LightningFacilitator interface is still in flight: a third backend that fits the same two-method contract — NWC (Nostr Wallet Connect, NIP-47).

LNBits and Blink are great defaults for "works out of the box," but both are custodial. NWC fills the self-custodial slot without enlarging the facilitator interface — a wallet RPC over a Nostr relay, with make_invoice / lookup_invoice matching the same shape the LNBits adapter already uses:

const nwc = new NWCClient({ nostrWalletConnectUrl: process.env.NWC_URL! });

async function mintInvoice(sats: number, memo: string) {
  const inv = await nwc.makeInvoice({ amount: sats * 1000, description: memo });
  return { paymentRequest: inv.invoice, paymentHash: inv.payment_hash };
}

async function isPaid(paymentHash: string) {
  const r = await nwc.lookupInvoice({ payment_hash: paymentHash });
  return r.settled_at != null;
}

Why it's worth a slot:

  1. Operators who already run LND/CLN can front their node with Alby Hub (or LNBits-NWC) and get an x402 facilitator without a second custodial account.
  2. Mobile/browser buyers using Mutiny, Zeus, or Alby can autopay 402 invoices end-to-end over NIP-47 — same wire protocol on both sides of the facilitator.
  3. No vendor SDK to maintain on the facilitator: the only secret is a nostr+walletconnect:// connection string.

Buyer-side reference: @powforge/x402-lightning ships an NWC adapter alongside LNBits, and we use NWC as the priority-1 autopay rail in @powforge/402-mcp. Happy to put up a follow-on PR adding ExactBip122NWCFacilitator once this one lands — it's ~80 lines around the same interface you're defining here, plus a small Nostr-event subscription helper for settlement notifications (or polling lookupInvoice if you'd rather not pull a relay client into the facilitator).

Not a blocker on #2262 — just flagging while the shape of LightningFacilitator is still soft, so the interface doesn't accidentally bake in HTTPS-only assumptions that would make a relay-transport backend awkward later.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

sdk Changes to core v2 packages typescript

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: TypeScript Lightning (BOLT11/bip122/exact) mechanism implementation

2 participants