Repository navigation
feat(payments): NWC payment processor, the server side of the NWC rail - #118
Open
nisargpatel7042lva wants to merge 9 commits into
Open
nisargpatel7042lva wants to merge 9 commits into
nisargpatel7042lva wants to merge 9 commits into
Conversation
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
This was referenced Oct 7, 2026
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
force-pushed
the
feat/cep8-nwc-processor-b2
branch
from
October 9, 2026 13:48
ac80072 to
a5b48fe
Compare
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
force-pushed
the
feat/cep8-nwc-processor-b2
branch
from
October 11, 2026 04:31
a5b48fe to
dd25207
Compare
…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
force-pushed
the
feat/cep8-nwc-processor-b2
branch
from
October 15, 2026 14:34
dd25207 to
b5605f5
Compare
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
force-pushed
the
feat/cep8-nwc-processor-b2
branch
from
October 16, 2026 07:15
b5605f5 to
ac0a1ea
Compare
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
force-pushed
the
feat/cep8-nwc-processor-b2
branch
from
October 23, 2026 14:37
ac0a1ea to
a3237e5
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
LnBolt11NwcPaymentProcessorfor PMIbitcoin-lightning-bolt11, behind the same off-by-defaultnwcfeature. It plugs into thePaymentProcessortrait Phase A froze: no trait, middleware or registration entry point changes.create_payment_requiredmints a BOLT11 invoice withmake_invoicein msats with an expiry, caches the invoice topayment_hashmapping, and advertises the configured TTL rather than the wallet's ownexpires_at, which providers return in non-standard forms.verify_paymentthen waits for settlement, either by pollinglookup_invoiceon the ts backoff schedule or by waiting on apayment_receivednotification.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:
create_payment_requiredhas only the client'sresponse_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_timeoutpins that.verify_paymentselects onparams.cancelat 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 hunglookup_invoicewell past the payment TTL, still holding a wallet connection.a_cancelled_verify_errors_and_stops_pollingasserts the wallet stops being called after cancellation, not just that the call 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 onecancelled()helper so there is a single place to audit.The in-flight dedup is a
Sharedfuture keyed bypay_req, so duplicate delivery of one request cannot multiply wallet and relay load (dedupes_concurrent_verifies_for_the_same_invoiceasserts three concurrent verifies make exactly one wallet call).PaymentErroris notClone, so the shared output isArc<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
Errtoo. 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:
state == "settled"or a positivesettled_at. Wallets populate one or the other; requiring both would strand real payments.settles_on_settled_at_without_a_statecovers the second path.expiredorfailed.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_fatalchecks both halves.The receipt prefers
payment_hash, a stable identifier that is not the preimage, and falls back to ts'ssettled:<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 explicitSome(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.RelayPoolTraithas 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_oncedrives that race.Deliberate divergences from ts
payment_hashcached means fall back to polling, where ts throwsNWC notification verification requires payment_hash. A payable invoice should not fail verification because the wallet declined to return a hash onmake_invoice.notification_mode_without_a_cached_hash_polls_instead_of_erroringpins the new behavior.randdependency 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.sats_to_msatsrejects non-positive and useschecked_mul), where ts does a bare multiply.rejects_non_positive_and_overflowing_amounts_before_calling_the_walletalso asserts the wallet call count stays at zero.Log hygiene
An invoice is a bearer payment request, so
no_tracing_call_carries_the_invoiceparses wholetracing::macro invocations out of the implementation and asserts none of them bindspay_req,invoice,preimageorsecretas a field. It scans whole invocations rather than single lines because atracing::debug!spans several and its fields are never on the first one, and it is boundary-aware so thathas_preimage = ...is_some()— a boolean, which is exactly what we do want logged — is not mistaken forpreimage = ....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 --checkclean,cargo clippy --all-features --all-targetsclean, 1107 tests pass under--all-featureswith 0 failures,--no-default-featuresand 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_FOUNDbranch, 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
MockRelayPoolgroup, the#[ignore]d real-wallet test, theCargo.tomltest entries, and the docs.docs/payments.mdstill describes Phase B as deferred and still lists LNbits as planned; both are B3's to correct.