You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
implement CEP-8 Phase B Lightning rails (NWC and zaps) #116
Phase B of CEP-8 (#100) replaces the deterministic fakes with real Lightning rails behind the PaymentProcessor and PaymentHandler traits Phase A froze. No trait, middleware or registration-entry-point changes: a rail is a plug-in.
Two rails, in this order. NWC (NIP-47), both sides of the wire: a processor that issues a BOLT11 invoice (make_invoice) and verifies settlement (lookup_invoice polling or payment_received notifications), and a handler that pays (pay_invoice). NIP-57 zaps, server side only: an invoice from an LNURL-pay callback carrying a kind 9734 zap request, verified against a kind 9735 receipt. LNbits, the third rail listed in #100, is dropped.
Settled before planning:
CI does not drive a real wallet. Relay-level (NWC) and HTTP-level (LNURL) mocks are the merge gate; ts-sdk gates nothing on a wallet either. A mock wallet service over MockRelayPool drives the real client code rather than stubbing it. Real-wallet tests ship #[ignore]d and environment-gated, run by hand before each rail merges.
No zap handler. NIP-57 obliges the payer only to pay a BOLT11 invoice, and the provider publishes the receipt. Both rails issue pmi: bitcoin-lightning-bolt11 and handlers are keyed by PMI, so a zap offer already routes to the NWC handler, as ts-sdk's zap-integration.test.ts does.
Key notes:
nostr 0.44.4 ships nips::nip47 and nips::nip57 behind flags nostr-sdk already forwards, so no NIP-47 crypto or zap-request construction is written here. The nwc crate is not used: its relay pool is private, so nothing on top of it is testable without a live wallet.
RelayPoolTrait has no unsubscribe, so ts-sdk's subscription-per-request would leak one live REQ per payment. Each rail client holds ONE subscription and correlates in process by request event id.
Wallet responses are parsed permissively, ts field for field, not through nostr's strict response structs. On a money path a missing optional field must not turn a paid invoice into a failed verification.
Bounds are the rail's job: create_payment_required runs in the middleware's unbounded, cancellation-blind initiation phase; verify_payment must observe params.cancel or its poller outlives the timeout; the client engine gives handle() neither a timeout nor a cancellation token.
Two safe-direction divergences from ts-sdk, invisible on the wire: NIP-47 notifications are decrypted by kind (23196 is NIP-04, 23197 is NIP-44), and notification-mode verification falls back to polling instead of throwing when no payment_hash is cached.
Features off by default: nwc = ["nostr-sdk/nip47"] adds no new crate; zaps = ["nostr-sdk/nip57", "dep:reqwest"] adds the only new dependency in Phase B, because a zap fetches its invoice over HTTP.
Spec:contextvm-docs/src/content/docs/reference/ceps/cep-8.md. It is rail-agnostic; the only normative rail text anywhere is CEP-21's line that pay_req is a BOLT11 invoice string. TS SDK reference:sdk/src/payments/nip47/, nip57/, processors/, handlers/ Phase A:#100 (11 PRs, #101 to #108 and #111 to #114, all merged)
Cargo.toml - nwc = ["nostr-sdk/nip47"], off by default, no new crate
src/payments/nip47/uri.rs - parse_nwc_uri, accepting the pathname form NostrWalletConnectURI::parse rejects and ts-sdk accepts
src/payments/nip47/types.rs - permissive {result_type, error, result} envelope plus all-optional per-method results mirroring ts nip47/types.ts; sats_to_msats with checked_mul and a non-positive-amount rejection
src/payments/nip47/client.rs - NwcClient over Arc<dyn RelayPoolTrait>: one subscription, one reader task, waiter map keyed by request event id, response_timeout per call, Lagged handling, notification decode by kind, fetch_info_notification_types from kind 13194
src/payments/nip47/mock_wallet.rs behind test-utils - scripted wallet over a linked MockRelayPool (answer, error, silence, delay, malformed) plus a settle trigger publishing payment_received
Tests: round trip for the three methods; uncorrelated and stranger-signed responses ignored; silence times out; a wallet error is not an Ok; a minimal settled result and an unknown error code both parse; both notification kinds decode; Lagged loses nothing; concurrent requests correlate independently; URI parsing both forms
PR B2: NWC payment processor (server side of rail 1)
src/payments/processors/ln_bolt11_nwc.rs - LnBolt11NwcPaymentProcessor for bitcoin-lightning-bolt11
create_payment_required - make_invoice in msats with expiry, invoice to payment_hash LRU, configured ttl (the wallet's expires_at is ignored, ts parity), bounded by response_timeout
verify_payment - in-flight dedup by pay_req (shared future over Arc<Result<..>>, since PaymentError is not Clone); ts backoff schedule floored at poll_interval with jitter; NOT_FOUND pending, other wallet errors fatal; expired and failed fatal; settled on state == settled or settled_at > 0; receipt is payment_hash or ts's settled:<event_id>[:<settled_at>] fallback
Notification mode - lazy OnceCell auto-detection from the info event, polling fallback when no payment_hash is cached
Cancellation - select! on params.cancel in every wait; a cancelled verify returns Err, never an empty Ok
LnBolt11NwcPaymentProcessorOptions, non-exhaustive, ts defaults (300 s ttl, 1500 ms poll, 60 s timeout, 5000 in-flight, 10000 hash cache), injectable pool and client
Tests: the four ts unit tests ported; cancelled verify stops the poller; a hung wallet fails create_payment_required inside response_timeout; terminal states fatal and NOT_FOUND not; the fallback receipt; notification mode polls instead of erroring; overflow and non-positive amounts rejected before any wallet call; no log line contains the invoice
src/payments/handlers/ln_bolt11_nwc.rs - LnBolt11NwcPaymentHandler, pay_invoice bounded by its own response_timeout; the spending decision stays with payment_policy
tests/payments_nwc_e2e.rs - real client and server transports over one MockRelayPool group, a mock wallet on each side, both lifecycles
tests/payments_nwc_real_wallet.rs - #[ignore]d, environment-gated, mirroring ts nwc-integration.test.ts and nwc-paid-capability-e2e.test.ts
docs/payments.md - the shipped NWC rail, its options, and its operational notes (a late settlement is money out with no execution; the wallet is a trust boundary; rate limiting is still upstream's job); correct the paragraph listing LNbits as planned
README.md, Cargo.toml test entries, CHANGELOG.md
Tests: both lifecycles end to end including -32043 while verifying; a declining payment_policy stops the wallet call and synthesizes -32000; a never-settling wallet ends at the TTL with no acceptance; a pay_invoice error leaves the request pending and synthesizes nothing; the handler's own timeout bounds a silent wallet
Real-wallet run executed and recorded in the PR, invoice redacted
Cargo.toml - zaps = ["nostr-sdk/nip57", "dep:reqwest"]; reqwest optional, default-features = false, features = ["rustls-tls", "json"] (rustls is already in the tree; no OpenSSL). MSRV 1.88 verified across its tree as a gate item
src/payments/nip57/lnurl.rs - parse_ln_address (LUD-16) and an injectable LnurlPayClient trait so the rail is testable without HTTP, with a reqwest-backed default that is https-only, timed out, body-capped and does not follow cross-scheme redirects (the callback URL is remote-controlled input)
src/payments/nip57/zap.rs - kind 9734 zap request via ZapRequestData plus anonymous_zap_request, and bolt11_from_zap_receipt
Tests: LUD-16 parsing including ts's four rejections; the well-known URL built as ts builds it; the callback carries amount and nostr and returns pr; a reason-only response errors with the reason; non-https and oversized bodies rejected; zap request tags in ts's order; receipt bolt11 extraction
src/payments/processors/ln_bolt11_zap.rs - LnBolt11ZapPaymentProcessor for bitcoin-lightning-bolt11
create_payment_required - fetch pay params, reject an endpoint without zap support, enforce minSendable and maxSendable, sign the zap request with the processor's ephemeral key, request the invoice, record {expected_zapper_pubkey, since, amount_msats} keyed by the invoice, return _meta: {"rail": "nip57"}
verify_payment - one subscription for kind 9735 from the expected zapper since the recorded timestamp, resolve on a bolt11 tag equal to pay_req, return _meta: {"zap_receipt_event_id": ..}; in-flight dedup by pay_req; stateless fallback re-fetches pay params; cancellation and HTTP bounds as in B2
tests/payments_zap_e2e.rs and tests/payments_zap_real_wallet.rs (the real-wallet zap test pays with the NWC handler)
docs/payments.md - the zap rail, its options, and three non-obvious facts: the provider publishes the receipt to the relays named in the zap request, so the configured relay set must be one it will publish to; the provider's nostrPubkey authorizes receipts, so it is a trust boundary; there is no client-side zap handler, and why
README.md, Cargo.toml test entries, CHANGELOG.md
Tests: issue and verify with the receipt published onto the mock relay; a receipt for another invoice or from another pubkey does not settle ours; out-of-range amounts fail before the callback; allowsNostr: false fails with ts's message; cancellation returns Err; the stateless fallback verifies; full lifecycle with the NWC handler paying the zap invoice
Real-wallet run executed and recorded in the PR, invoice redacted
Exit criteria
Both rails shipped and feature-gated; --all-features and --no-default-features green, MSRV 1.88
docs/payments.md documents both rails and no longer describes Phase B as deferred
Both real-wallet test pairs executed and recorded
CHANGELOG.md carries a rails entry per PR
Out of scope: LNbits (listed in #100, deliberately dropped); any third rail; a client-side zap handler; changes to the Phase A traits, middlewares or entry points; multi-PMI payment_required fan-out and direct_payment / change, which stay out for ts-sdk parity as in #100.
Not Phase B's, but load-bearing once real money moves: rate limiting. Every successful offer still spawns one unbounded detached verification task, and with a real rail each holds a wallet connection and polls a wallet. max_pending_payments and the authorization store caps bound state, not tasks. Worth its own issue.
Summary
Phase B of CEP-8 (#100) replaces the deterministic fakes with real Lightning rails behind the
PaymentProcessorandPaymentHandlertraits Phase A froze. No trait, middleware or registration-entry-point changes: a rail is a plug-in.Two rails, in this order. NWC (NIP-47), both sides of the wire: a processor that issues a BOLT11 invoice (
make_invoice) and verifies settlement (lookup_invoicepolling orpayment_receivednotifications), and a handler that pays (pay_invoice). NIP-57 zaps, server side only: an invoice from an LNURL-pay callback carrying a kind 9734 zap request, verified against a kind 9735 receipt. LNbits, the third rail listed in #100, is dropped.Settled before planning:
MockRelayPooldrives the real client code rather than stubbing it. Real-wallet tests ship#[ignore]d and environment-gated, run by hand before each rail merges.pmi: bitcoin-lightning-bolt11and handlers are keyed by PMI, so a zap offer already routes to the NWC handler, as ts-sdk'szap-integration.test.tsdoes.Key notes:
nostr0.44.4 shipsnips::nip47andnips::nip57behind flagsnostr-sdkalready forwards, so no NIP-47 crypto or zap-request construction is written here. Thenwccrate is not used: its relay pool is private, so nothing on top of it is testable without a live wallet.RelayPoolTraithas no unsubscribe, so ts-sdk's subscription-per-request would leak one live REQ per payment. Each rail client holds ONE subscription and correlates in process by request event id.nostr's strict response structs. On a money path a missing optional field must not turn a paid invoice into a failed verification.create_payment_requiredruns in the middleware's unbounded, cancellation-blind initiation phase;verify_paymentmust observeparams.cancelor its poller outlives the timeout; the client engine giveshandle()neither a timeout nor a cancellation token.payment_hashis cached.nwc = ["nostr-sdk/nip47"]adds no new crate;zaps = ["nostr-sdk/nip57", "dep:reqwest"]adds the only new dependency in Phase B, because a zap fetches its invoice over HTTP.Spec:
contextvm-docs/src/content/docs/reference/ceps/cep-8.md. It is rail-agnostic; the only normative rail text anywhere is CEP-21's line thatpay_reqis a BOLT11 invoice string.TS SDK reference:
sdk/src/payments/nip47/,nip57/,processors/,handlers/Phase A: #100 (11 PRs, #101 to #108 and #111 to #114, all merged)
PR B1: NIP-47 client core + mock wallet (rail infrastructure)
Cargo.toml-nwc = ["nostr-sdk/nip47"], off by default, no new cratesrc/payments/nip47/uri.rs-parse_nwc_uri, accepting the pathname formNostrWalletConnectURI::parserejects and ts-sdk acceptssrc/payments/nip47/types.rs- permissive{result_type, error, result}envelope plus all-optional per-method results mirroring tsnip47/types.ts;sats_to_msatswithchecked_muland a non-positive-amount rejectionsrc/payments/nip47/client.rs-NwcClientoverArc<dyn RelayPoolTrait>: one subscription, one reader task, waiter map keyed by request event id,response_timeoutper call,Laggedhandling, notification decode by kind,fetch_info_notification_typesfrom kind 13194src/payments/nip47/mock_wallet.rsbehindtest-utils- scripted wallet over a linkedMockRelayPool(answer, error, silence, delay, malformed) plus a settle trigger publishingpayment_receivedOk; a minimal settled result and an unknown error code both parse; both notification kinds decode;Laggedloses nothing; concurrent requests correlate independently; URI parsing both formsPR B2: NWC payment processor (server side of rail 1)
src/payments/processors/ln_bolt11_nwc.rs-LnBolt11NwcPaymentProcessorforbitcoin-lightning-bolt11create_payment_required-make_invoicein msats withexpiry, invoice topayment_hashLRU, configuredttl(the wallet'sexpires_atis ignored, ts parity), bounded byresponse_timeoutverify_payment- in-flight dedup bypay_req(shared future overArc<Result<..>>, sincePaymentErroris notClone); ts backoff schedule floored atpoll_intervalwith jitter;NOT_FOUNDpending, other wallet errors fatal;expiredandfailedfatal; settled onstate == settledorsettled_at > 0; receipt ispayment_hashor ts'ssettled:<event_id>[:<settled_at>]fallbackOnceCellauto-detection from the info event, polling fallback when nopayment_hashis cachedselect!onparams.cancelin every wait; a cancelled verify returnsErr, never an emptyOkLnBolt11NwcPaymentProcessorOptions, non-exhaustive, ts defaults (300 s ttl, 1500 ms poll, 60 s timeout, 5000 in-flight, 10000 hash cache), injectable pool and clientcreate_payment_requiredinsideresponse_timeout; terminal states fatal andNOT_FOUNDnot; the fallback receipt; notification mode polls instead of erroring; overflow and non-positive amounts rejected before any wallet call; no log line contains the invoiceNOT_FOUNDbranch, dedup claim)PR B3: NWC payment handler + wiring + docs + e2e (rail 1 capstone)
src/payments/handlers/ln_bolt11_nwc.rs-LnBolt11NwcPaymentHandler,pay_invoicebounded by its ownresponse_timeout; the spending decision stays withpayment_policytests/payments_nwc_e2e.rs- real client and server transports over oneMockRelayPoolgroup, a mock wallet on each side, both lifecyclestests/payments_nwc_real_wallet.rs-#[ignore]d, environment-gated, mirroring tsnwc-integration.test.tsandnwc-paid-capability-e2e.test.tsdocs/payments.md- the shipped NWC rail, its options, and its operational notes (a late settlement is money out with no execution; the wallet is a trust boundary; rate limiting is still upstream's job); correct the paragraph listing LNbits as plannedREADME.md,Cargo.tomltest entries,CHANGELOG.md-32043while verifying; a decliningpayment_policystops the wallet call and synthesizes-32000; a never-settling wallet ends at the TTL with no acceptance; apay_invoiceerror leaves the request pending and synthesizes nothing; the handler's own timeout bounds a silent walletPR B4: LNURL-pay client + zap event helpers (zap infrastructure)
Cargo.toml-zaps = ["nostr-sdk/nip57", "dep:reqwest"];reqwestoptional,default-features = false, features = ["rustls-tls", "json"](rustls is already in the tree; no OpenSSL). MSRV 1.88 verified across its tree as a gate itemsrc/payments/nip57/lnurl.rs-parse_ln_address(LUD-16) and an injectableLnurlPayClienttrait so the rail is testable without HTTP, with a reqwest-backed default that is https-only, timed out, body-capped and does not follow cross-scheme redirects (the callback URL is remote-controlled input)src/payments/nip57/zap.rs- kind 9734 zap request viaZapRequestDataplusanonymous_zap_request, andbolt11_from_zap_receiptsrc/payments/nip57/mock_lnurl.rsbehindtest-utils- scripted pay params, invoice, error,allowsNostr: false, out-of-range amountsamountandnostrand returnspr; areason-only response errors with the reason; non-https and oversized bodies rejected; zap request tags in ts's order; receiptbolt11extractionPR B5: Zap payment processor + wiring + docs + e2e (rail 2 capstone)
src/payments/processors/ln_bolt11_zap.rs-LnBolt11ZapPaymentProcessorforbitcoin-lightning-bolt11create_payment_required- fetch pay params, reject an endpoint without zap support, enforceminSendableandmaxSendable, sign the zap request with the processor's ephemeral key, request the invoice, record{expected_zapper_pubkey, since, amount_msats}keyed by the invoice, return_meta: {"rail": "nip57"}verify_payment- one subscription for kind 9735 from the expected zapper since the recorded timestamp, resolve on abolt11tag equal topay_req, return_meta: {"zap_receipt_event_id": ..}; in-flight dedup bypay_req; stateless fallback re-fetches pay params; cancellation and HTTP bounds as in B2tests/payments_zap_e2e.rsandtests/payments_zap_real_wallet.rs(the real-wallet zap test pays with the NWC handler)docs/payments.md- the zap rail, its options, and three non-obvious facts: the provider publishes the receipt to the relays named in the zap request, so the configured relay set must be one it will publish to; the provider'snostrPubkeyauthorizes receipts, so it is a trust boundary; there is no client-side zap handler, and whyREADME.md,Cargo.tomltest entries,CHANGELOG.mdallowsNostr: falsefails with ts's message; cancellation returnsErr; the stateless fallback verifies; full lifecycle with the NWC handler paying the zap invoiceExit criteria
--all-featuresand--no-default-featuresgreen, MSRV 1.88docs/payments.mddocuments both rails and no longer describes Phase B as deferredCHANGELOG.mdcarries a rails entry per PROut of scope: LNbits (listed in #100, deliberately dropped); any third rail; a client-side zap handler; changes to the Phase A traits, middlewares or entry points; multi-PMI
payment_requiredfan-out anddirect_payment/change, which stay out for ts-sdk parity as in #100.Not Phase B's, but load-bearing once real money moves: rate limiting. Every successful offer still spawns one unbounded detached verification task, and with a real rail each holds a wallet connection and polls a wallet.
max_pending_paymentsand the authorization store caps bound state, not tasks. Worth its own issue.