Skip to content

feat(payments): NWC payment processor, the server side of the NWC rail - #118

Open
nisargpatel7042lva wants to merge 9 commits into
ContextVM:mainfrom
nisargpatel7042lva:feat/cep8-nwc-processor-b2
Open

nisargpatel7042lva wants to merge 9 commits into
ContextVM:mainfrom
nisargpatel7042lva:feat/cep8-nwc-processor-b2

Conversation

@nisargpatel7042lva

@nisargpatel7042lva nisargpatel7042lva commented Oct 1, 2026 •

Copy link
Copy Markdown

part of #116

Second of the three NWC sub-PRs (B2 of B1-B3). Stacked on #117; diff base 408a37f. I'll rebase onto main once the parent merges.

Adds LnBolt11NwcPaymentProcessor for PMI bitcoin-lightning-bolt11, behind the same off-by-default nwc feature. It plugs into the PaymentProcessor trait Phase A froze: no trait, middleware or registration entry point changes.

create_payment_required mints a BOLT11 invoice with make_invoice in msats with an expiry, caches the invoice to payment_hash mapping, and advertises the configured TTL rather than the wallet's own expires_at, which providers return in non-standard forms. verify_payment then waits for settlement, either by polling lookup_invoice on the ts backoff schedule or by waiting on a payment_received notification.

The main thing to review: bounds and cancellation, because this is where money moves

Three separate bounds, each doing a different job, and it is worth checking I put them in the right places:

  1. create_payment_required has only the client's response_timeout. It runs in the middleware's unbounded, cancellation-blind initiation phase, so nothing else can stop a hung wallet from wedging it. a_hung_wallet_fails_creation_inside_the_response_timeout pins that.
  2. verify_payment selects on params.cancel at every wait — before each poll, around the wallet call itself, around the sleep, and around the notification wait. A poller that only checked the token between polls would sit inside a hung lookup_invoice well past the payment TTL, still holding a wallet connection. a_cancelled_verify_errors_and_stops_polling asserts the wallet stops being called after cancellation, not just that the call returns.
  3. A cancelled verify always returns Err. Ok(VerifyOutcome::default()) would be read by the middleware as "paid" and would forward an unpaid invocation. Every cancellation path in the file goes through one cancelled() helper so there is a single place to audit.

The in-flight dedup is a Shared future keyed by pay_req, so duplicate delivery of one request cannot multiply wallet and relay load (dedupes_concurrent_verifies_for_the_same_invoice asserts three concurrent verifies make exactly one wallet call). PaymentError is not Clone, so the shared output is Arc<Result<..>> and joiners re-wrap the error by message. The slot is released once the future settles, so a later retry of the same invoice re-runs rather than replaying a stale terminal answer.

One wart inherited from ts and left as-is: callers deduplicated onto one verification share the first caller's cancellation token, so if that one is cancelled the joiners see an Err too. That is a liveness wart, not a safety one — the invoice stays payable, the dedup entry keeps its TTL, and the middleware reads a non-settlement. Worth a look if you would rather it behaved differently.

Settlement classification

The predicate is deliberately generous in one direction and strict in the other:

  • Settled on state == "settled" or a positive settled_at. Wallets populate one or the other; requiring both would strand real payments. settles_on_settled_at_without_a_state covers the second path.
  • Terminal on expired or failed.
  • Pending on NOT_FOUND, because a lagging wallet that has not yet seen its own invoice has not refused it. Every other wallet error code is fatal. not_found_is_pending_and_other_codes_are_fatal checks both halves.

The receipt prefers payment_hash, a stable identifier that is not the preimage, and falls back to ts's settled:<request_event_id>[:<settled_at>] for wallets that omit it on lookup.

Notification mode

Auto-detected once from the wallet's info event through a OnceCell, with an explicit Some(true) / Some(false) short-circuiting the probe entirely. A probe failure means polling, never a hard error.

The subscription installs exactly once, via a second OnceCell. RelayPoolTrait has no unsubscribe, so two verifications racing here would each install a sink and the extra one would live for the process lifetime. concurrent_verifies_subscribe_for_notifications_exactly_once drives that race.

Deliberate divergences from ts

  1. No payment_hash cached means fall back to polling, where ts throws NWC notification verification requires payment_hash. A payable invoice should not fail verification because the wallet declined to return a hash on make_invoice. notification_mode_without_a_cached_hash_polls_instead_of_erroring pins the new behavior.
  2. Jitter comes from the clock, not a RNG. This crate has no direct rand dependency and I did not want to add one for de-stampeding, which needs spread, not unpredictability. If you would rather have a real RNG, say so and I will add the dependency.
  3. Amounts are validated before any wallet call (sats_to_msats rejects non-positive and uses checked_mul), where ts does a bare multiply. rejects_non_positive_and_overflowing_amounts_before_calling_the_wallet also asserts the wallet call count stays at zero.

Log hygiene

An invoice is a bearer payment request, so no_tracing_call_carries_the_invoice parses whole tracing:: macro invocations out of the implementation and asserts none of them binds pay_req, invoice, preimage or secret as a field. It scans whole invocations rather than single lines because a tracing::debug! spans several and its fields are never on the first one, and it is boundary-aware so that has_preimage = ...is_some() — a boolean, which is exactly what we do want logged — is not mistaken for preimage = ....

I got that test wrong three times before it was right, each time with correct code and a broken check. Flagging it because the final version is doing something slightly clever and deserves a read.

Verification

cargo fmt --check clean, cargo clippy --all-features --all-targets clean, 1107 tests pass under --all-features with 0 failures, --no-default-features and default builds green, MSRV 1.88 compiles. 23 tests in this module, covering every case listed for B2 in #116.

One gap remains open. Issue #116 asks for mutation testing of the money paths (the cancellation arms, the settlement predicate, the NOT_FOUND branch, the dedup claim). Those arms all have direct tests, but I have not run a mutation tool over them. Happy to do that before merge if you want it as a gate rather than a follow-up.

Not in this PR

B3: the client-side handler, the e2e suite over one MockRelayPool group, the #[ignore]d real-wallet test, the Cargo.toml test entries, and the docs. docs/payments.md still describes Phase B as deferred and still lists LNbits as planned; both are B3's to correct.

First of the three NWC sub-PRs in ContextVM#116. Rail infrastructure only: no
processor, no handler, and no change to the Phase A traits, middlewares
or registration entry points.

Behind the off-by-default `nwc` feature, which only switches on
`nostr-sdk/nip47` and adds no new crate to the dependency tree.

- `parse_nwc_uri` reads a connection string in either shape wallets emit.
  `NostrWalletConnectURI::parse` takes the pubkey from the URI host only,
  so it rejects the `nostr+walletconnect:<pubkey>?...` pathname form the
  ts-sdk accepts; a connection string that works there must not fail
  here. A test pins the upstream rejection as a precondition.
- `NwcClient` speaks request/response and notifications over an injected
  `RelayPoolTrait`, so the same code path runs against a scripted mock
  wallet in CI and a real wallet in production. The `nwc` crate is not
  used: its relay pool is private, so nothing built on it is testable
  without a live wallet.
- One subscription and one reader task, correlating by request event id.
  `RelayPoolTrait` has no unsubscribe, so the ts-sdk
  subscription-per-request shape would leak one live REQ per payment.
- Wallet responses are parsed permissively, field for field with ts
  `nip47/types.ts`, rather than through the strict upstream response
  structs. On a money path a missing or unknown optional field must not
  turn a paid invoice into a failed verification.
- Notifications are decrypted by kind (23196 NIP-04, 23197 NIP-44). ts
  always decrypts as NIP-04 and so silently drops the NIP-44 form. Kind
  23197 has no named constant in `nostr` 0.44, hence `Kind::from`.
- `sats_to_msats` rejects a non-positive amount and uses `checked_mul`,
  both before any wallet call, where ts does a bare multiply.
- `MockWallet` behind `test-utils` runs the real wire protocol over a
  linked `MockRelayPool`, scripting answer, error, silence, delay and
  malformed replies, and can publish a `payment_received` notification.

A wallet-reported error is returned as `Ok(envelope)` with `error` set
rather than `Err`, matching ts: the processor in B2 has to treat
`NOT_FOUND` as pending but other codes as fatal, which needs the code at
the call site. `into_result()` collapses the envelope for callers that
do not classify, so a failed payment still cannot read as a settled one.

Verified: fmt and clippy clean, 1084 tests pass under `--all-features`,
`--no-default-features` and default builds green, MSRV 1.88 compiles.

Refs ContextVM#116
Addresses the review on ContextVM#117. The design held; what did not was
robustness against a wallet that misbehaves, and the money-path bounds.
A mock written alongside the client agreed with the client wherever the
client was wrong, so most of this was invisible to the old suite.

Critical

- One panic on the reader killed the rail for the process, silently,
  while requests kept publishing. Per-event handling now runs under
  catch_unwind, the reader marks the client closed and drains its
  waiters on exit, and `nostr` is floored at 0.44.7 (0.44.5 panics on a
  malformed NIP-04 payload, and "0.44" permitted it).
- One off-type optional field threw out the whole response.
  `#[serde(default)]` covers absent, not wrong-typed, so a string
  `settled_at` lost a result that said `"state":"settled"` and the payer
  would have paid for nothing. Every optional field now deserializes
  off-type values to `None`.

Major

- The response filter widens itself once, dropping `#p`, while nothing
  has ever correlated, so a wallet that omits the tag is not invisible.
- One deadline now covers start-up, publish and the wait, where publish,
  connect and subscribe used to sit outside it.
- A dropped request future no longer orphans its waiter: an RAII guard
  removes it. The processor's `select!` drops one per cancelled verify.
- `close()` is fail-fast. It used to leave `pay_invoice` publishing, and
  the wallet still paid.
- Errors are typed. `NwcError::Timeout` carries `published`, so a caller
  can tell "the wallet may have acted" from "it certainly did not",
  which is what a non-idempotent `pay_invoice` needs, and the trait
  boundary maps to Processor or Handler explicitly.
- Error text can no longer carry a BOLT11 invoice into operator logs:
  wallet free text is redacted and bounded, and a parse failure reports
  its category and position instead of the offending bytes.
- `MockWallet` can now break the client's assumptions: omit the `p` tag,
  answer NOT_IMPLEMENTED, mislabel `result_type`, drop the `e` tag,
  publish both notification kinds, and answer out of order. It serves
  each request on its own task, so a fast answer really can overtake a
  slow one.
- The public API is typed: `make_invoice` / `lookup_invoice` /
  `pay_invoice`, `request` crate-internal, wire types
  `#[non_exhaustive]`, and the options builder lands here.
- `NwcClient::connect` builds the client its own pool. `with_pool` stays
  for tests, documenting why sharing a transport's pool is a trap.

Minor

- Corrected the "adds no new crate" claim, which was wrong: the feature
  pulls in NIP-04 and so adds `aes`, `cbc` and `cipher`. Measured.
- Notifications decrypt by ciphertext shape, not event kind, so NIP-04
  content on kind 23197 (ts's own fixture) decodes.
- The subscription carries `since`, so a retaining relay does not replay
  this client's whole history into the sinks.
- The legacy `nostrwalletconnect://` scheme parses.
- A zero `response_timeout` is refused at construction.
- `pay_invoice` takes an optional NIP-40 expiration.
- Rustdoc corrected where it no longer matched behavior, including the
  claim about handing pieces to the upstream URI type, which it never did.

Non-blocking: a mismatched `result_type` is tolerated and warned about,
since correlation by event id already proves which request an answer
belongs to; duplicate `secret` is documented as last-wins; the `e` tag
parity note is recorded; the two quiet side effects of
`fetch_info_notification_types` are documented.

Tests go 38 to 61, and now bite: a five-mutant sweep over the author
check, the waiter guard, the closed flag, decrypt-by-shape and the
off-type leniency kills all five. The author check needed
`MockRelayPool::publish_event_unfiltered`, which models a relay that
ignores `authors`; without it the guard is unreachable, which is why
deleting it used to survive the suite.

Verified: fmt and clippy clean, 1104 tests pass under `--all-features`,
feature matrix green, MSRV 1.88 compiles.

Refs ContextVM#116
@nisargpatel7042lva
nisargpatel7042lva force-pushed the feat/cep8-nwc-processor-b2 branch from ac80072 to a5b48fe Compare October 9, 2026 13:48
The quirks struct was reachable only by its full module path, so a
downstream test could not use it, and the module doc still described the
client as taking an injected pool after `connect` made it own one.
Rust 1.99 deprecates `Atomic::fetch_update` in favour of `try_update`,
and CI lints with `-D warnings`, so `cargo clippy --all-targets` fails
on main. `try_update` is only stable from 1.95, above the declared MSRV
of 1.88, so this spells the same decrement-while-non-zero out as a
compare-exchange loop, which compiles warning-free on both.
@nisargpatel7042lva
nisargpatel7042lva force-pushed the feat/cep8-nwc-processor-b2 branch from a5b48fe to dd25207 Compare October 11, 2026 04:31
…led widening

Addresses the five blocking items from the second review of the NIP-47
client.

(8a) A deadline that fires inside the publish is not a non-delivery. The
pool writes the EVENT before it waits on the relay's OK, so the wallet
may have the request. `Delivery` replaces the `published` bool with
`NotSent`, `Unknown` and `Sent`, and `may_have_reached_wallet` treats
`Unknown` as "may have", which is the safe direction for `pay_invoice`.

(24) `Closed` carries the same `Delivery`, so a `close()` landing after
the request went out no longer reports a safe retry. `request` also
re-checks the closed flag after start-up: `close()` returns as soon as
it has set the flag, so one arriving during a slow connect used to be
followed by a publish.

(25) `sanitize_wallet_text` walks back to a character boundary before
truncating. `truncate` panics mid character, and the text is
wallet-controlled, so a long non-Latin message was a remote panic on the
pay path. `floor_char_boundary` is the obvious fix and is stable only
from 1.95, above the declared MSRV.

(26) Three remaining paths for wallet text to reach a log unredacted:
the error code was copied raw and unbounded, a mislabelled `result_type`
was logged raw, and the BOLT11 prefix test was `starts_with` on
whitespace-split words, which `lightning:lnbc...`, `(lnbc...)` and
quoted forms all passed while carrying the full payment hash. The prefix
is now matched anywhere in the token.

(3) Widening now requires evidence that the narrow filter has failed: a
request that reached a relay has waited out a full deadline, and nothing
has ever correlated. It is latched, since the pool has no unsubscribe
and a silent wallet would otherwise accumulate one subscription per
call; it widens the response kind only, leaving `#p` on both
notification kinds; and it logs at warn.

Tests: a nine-mutant sweep over these fixes, each killed. Two of them
survived the first pass because the delivery states were asserted in
isolation without anything proving `request` produces them, so both now
have tests that drive the real path. Also adds a response correlated to
a different request, which the existing no-`e`-tag test cannot see,
since that one is dropped before the correlation lookup is reached.
@nisargpatel7042lva
nisargpatel7042lva force-pushed the feat/cep8-nwc-processor-b2 branch from dd25207 to b5605f5 Compare October 15, 2026 14:34
The review measured 17 of 35 mutants living through the suite and named
the three on money paths. This kills those and five more, so every guard
these PRs added now has a test that fails when the guard is removed.

The three named ones:

- the `Lagged` arm breaking the reader instead of continuing, which
  would close the client and fail every later request while requests
  already in flight could still have paid;
- registering the waiter after the publish rather than before, which
  loses an answer that arrives inside that window;
- bypassing the waiter guard, which leaks an `EventId` and a
  `oneshot::Sender` on every cancelled verification.

And five that were untested: `since` at subscribe, the NIP-40
expiration tag, the reader-exit drain, and the payload-free parse
error, whose existing test built its serde failure from a string with
no payload in it and so passed even when the whole serde message was
leaked.

Two needed infrastructure, so the test pool now owns the notification
stream. A test can overflow it synchronously, which on a current-thread
runtime guarantees the reader observes `Lagged` rather than racing for
it, and can close it to force a real reader exit. It can also park the
caller inside `publish_event` after the event is on the wire, which
makes the registration-order window deterministic.
@nisargpatel7042lva
nisargpatel7042lva force-pushed the feat/cep8-nwc-processor-b2 branch from b5605f5 to ac0a1ea Compare October 16, 2026 07:15
Three statements no longer matched the code, all of them on points the
review raised and I said were fixed.

The client no longer speaks "over an injected `RelayPoolTrait`": item
(12) made it build its own pool from the connection relays, with
injection kept for tests. The module doc was corrected at the time and
this was not.

"Decrypted by kind (23196 NIP-04, 23197 NIP-44)" describes exactly the
behaviour item (14) asked to change, and the review said so about this
line. It is decryption by ciphertext shape now.

"A missing or unknown optional field" understates item (2), which was
about a field present with the wrong JSON type, the case that lost a
settled response.

Also records the typed error and `Delivery`, which are public API a
caller needs in order to know whether retrying `pay_invoice` is safe.
Second of the three NWC sub-PRs in ContextVM#116. Adds
`LnBolt11NwcPaymentProcessor` for PMI `bitcoin-lightning-bolt11`, behind
the same off-by-default `nwc` feature. No trait, middleware or
registration entry point changes: it plugs into the `PaymentProcessor`
trait Phase A froze.

- `create_payment_required` mints a BOLT11 invoice with `make_invoice`
  in msats with an expiry, caches invoice to `payment_hash`, and
  advertises the configured TTL rather than the wallet's `expires_at`,
  which providers return in non-standard forms (ts parity). Amounts are
  validated before any wallet call.
- `verify_payment` polls `lookup_invoice` on the ts backoff schedule,
  floored at the configured poll interval and jittered, or waits on a
  `payment_received` notification when the wallet advertises one.
- In-flight dedup by `pay_req` over a shared future. `PaymentError` is
  not `Clone`, so the shared output is an `Arc<Result<..>>` and joiners
  re-wrap the error by message.
- `NOT_FOUND` is pending, every other wallet error is fatal, `expired`
  and `failed` are terminal, and settlement is either `state ==
  "settled"` or a positive `settled_at`, since wallets populate one or
  the other. The receipt prefers `payment_hash` and falls back to ts's
  `settled:<request_event_id>[:<settled_at>]`.
- Notification mode auto-detects once from the info event via a
  `OnceCell`, and the subscription installs exactly once: with no
  unsubscribe on `RelayPoolTrait`, a racing second subscribe would leak
  a sink for the process lifetime.
- Every wait selects on `params.cancel`. A cancelled verify returns
  `Err`, never an empty `Ok` the middleware would read as payment.

Two documented divergences from ts. Notification mode falls back to
polling when no `payment_hash` is cached, where ts throws and strands a
payable invoice. Jitter is derived from the clock rather than a RNG,
since this crate has no direct `rand` dependency and de-stampeding does
not need cryptographic randomness.

23 tests, including the ts unit tests ported, cancellation stopping the
poller, a hung wallet bounded by the response timeout, terminal states,
the fallback receipt, notification mode and its polling fallback,
amount rejection before any wallet call, and a check that no tracing
call binds the invoice as a field.

Verified: fmt and clippy clean, 1107 tests pass under `--all-features`,
feature matrix green, MSRV 1.88 compiles.

Refs ContextVM#116
"the Phase B Lightning rail" is internal planning vocabulary that a
changelog reader cannot resolve, which the review noted about the
client entry. The same wording was in this one.
@nisargpatel7042lva
nisargpatel7042lva force-pushed the feat/cep8-nwc-processor-b2 branch from ac0a1ea to a3237e5 Compare October 23, 2026 14:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant