Skip to content

implement CEP-8 Phase B Lightning rails (NWC and zaps) #116

Description

@harsh04044

Summary

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)


PR B1: NIP-47 client core + mock wallet (rail infrastructure)

  • 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
  • Mutation-test the money paths (cancellation arms, settlement predicate, NOT_FOUND branch, dedup claim)

PR B3: NWC payment handler + wiring + docs + e2e (rail 1 capstone)

  • 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

PR B4: LNURL-pay client + zap event helpers (zap infrastructure)

  • 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
  • src/payments/nip57/mock_lnurl.rs behind test-utils - scripted pay params, invoice, error, allowsNostr: false, out-of-range amounts
  • 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

PR B5: Zap payment processor + wiring + docs + e2e (rail 2 capstone)

  • 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.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions