feat(payments): with_client_payments, the client payment engine, and the bilateral e2e suite - #114
feat(payments): with_client_payments, the client payment engine, and the bilateral e2e suite#114harsh04044 wants to merge 3 commits into
with_client_payments, the client payment engine, and the bilateral e2e suite#114Conversation
…and the correlation fixes paid delivery rides on
…ilaterally over MockRelayPool
…try point and correlation fix
1f6edc1 to
206d57e
Compare
let is_pending = pending.contains(correlated_id).await; // gate
... parse, touch ...
let entry = pending.peek(correlated_id).await; // engine hookTwo awaited calls apart. If a duplicate payment_required interleaves with its request's terminal response in that window (contains → true, response consumed, peek → None), the engine still runs the full transparent pipeline for the dead request: dedup claimed, touch loop started, handler chain engaged → the wallet pays. The rustdoc threat model says "a replayed or forged invoice for a request that is not in flight never reaches a handler" — this interleave technically violates that invariant. Honest severity: low. It needs an exact interleave of a duplicate already-settled offer with its own response, the payment_policy gate still runs first (an operator with a policy is protected), and real BOLT11 invoices are single-use so real rails self-heal. But the fix is one line — if entry.is_none() { return; } at the top of on_payment_required_notification — and entry=None has no legitimate producer (the gate guarantees it). |
part of #100
Stacked on #113 (which is stacked on #112 and #111). Diff base
a37dc27. I'll rebase onto main once the parents merge, same as the earlier PRs in this stack.This adds
with_client_payments, the client peer of #113's entry point, and with it Phase A is complete on both sides: the new bilateral suite runs awith_client_paymentsclient against awith_server_paymentsserver in one process and asserts delivery at the client's own channel.contextvm_sdk::payments::with_client_payments(&mut transport, options)registers once, beforestart(), and installs a payment engine into the transport's inbound path (not a wrapping transport: the proxy owns the transport by value, and the consumer channel strips the event ids a wrapper would need). The engine drives both lifecycles:pay_reqand claimed synchronously before the handler chain spawns, apayment_policygate ahead of every wallet action, synthesized-32000errors for declines and server rejections on the original request id, and a synthetic-progress heartbeat carrying the request's original progress-token JSON value (rmcp's watcher is keyed by exact JSON type)-32042answered through anon_payment_requiredcallback with a byte-for-byte retry of the cached original request under a fresh outer event,-32043retried withretry_after * 1.5^nbackoff capped at 10 s, and exactly seven enumerated ways either code can reach the consumer; everything else is handled internallyThe options struct, callback signatures, and entry point freeze on merge as the API the Phase B rails build on. The proxy gets
payment_optionsonProxyConfig(plus a builder), used in bothNostrMCPProxy::newandserve_client_handler, mirroring the gateway from #113. The payments guide (docs/payments.md) covers both lifecycles end to end.The main thing to review: this changes shipped transport behavior
No rs client could ever receive a paid result, so this PR does surgery on 0.2.x client-transport code:
payment_requiredabove all) consumed the entry and the real response was then dropped as "response for unknown request". Now only a response or error response consumes the entry. This is parity, not new behavior: the ts client classifies by JSON-RPC type and only deletes on the response path. I strengthened the older suites' asserts to check client-channel delivery, watched them fail on the unfixed tree, then watched them pass with the fix.config.timeout(30 s default), so a multi-minute payment would lose its entry mid-settlement even with the fix. The engine refreshes it on a cadence ofmin(synthetic_progress_interval, timeout / 2)for the payment's lifetime. The half-timeout bound is not decoration: a naive touch-on-heartbeat design (30 s cadence against the 30 s retention TTL) lost the entry in 40 of 40 simulated trials at the defaults.&self, sosend()'s body moved onto apub(crate)parts struct of cloned handles, the same refactor the server side did forsend_notification. One non-mechanical bit: the one-shot discovery flag becameArc-shared, since a forked copy would re-send discovery tags on every retry. Behavior-preserving: every moved body was diffed against the original, and all prior suites are unchanged at their base counts.Deliberate divergences from ts
start(), afterclose(), second registration), each a hard error before anything is mutated. ts has no guards and callingwithClientPaymentstwice there can double-pay: each wrapper holds its own in-flight dedup set, so both run the pipeline for one offer. Same reasoning as feat(payments): add the server payments registration entry point and gateway payment options #113's server-side guards.retry_after: 0; a byte-identical same-second retry mints the same Nostr event id, which relays and the server's ingestion dedup swallow, so the ts consumer just times out. The floor closes that.Optionwhere ts always wraps, the policy callback takes the request by value, durations areDurationfields, and the cache keys keep numeric and string ids distinct. All wire-invisible.One interop note: gating identity resolves through the correlation entry, never the arriving wire id, so this client works against both server flavors (rs servers answer gating errors with the inner id; ts servers answer with the rewritten event id, already on the upstream pile).
Known quirks (same as ts, documented, not changed)
payment_policyis the gate for that. Treat it as the spending limit.-32042cycles: a failed verification mints a fresh invoice and the callback is asked again each round. The callback is the budget.Note
The ts double-wrap double-pay (plus its silent loss of the context path) should be reported to the ts-sdk, alongside the earlier findings (the server double-registration double charge, the wire id on gating errors, the
ttl: 0grant, the negotiation latch question).