Skip to content

feat(payments): with_client_payments, the client payment engine, and the bilateral e2e suite - #114

Draft
harsh04044 wants to merge 10 commits into
ContextVM:mainfrom
harsh04044:feat/cep8-client-payments
Draft

feat(payments): with_client_payments, the client payment engine, and the bilateral e2e suite#114
harsh04044 wants to merge 10 commits into
ContextVM:mainfrom
harsh04044:feat/cep8-client-payments

Conversation

@harsh04044

Copy link
Copy Markdown

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 a with_client_payments client against a with_server_payments server in one process and asserts delivery at the client's own channel. contextvm_sdk::payments::with_client_payments(&mut transport, options) registers once, before start(), 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:

  • transparent auto-pay: an in-flight dedup keyed by pay_req and claimed synchronously before the handler chain spawns, a payment_policy gate ahead of every wallet action, synthesized -32000 errors 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)
  • explicit gating: -32042 answered through an on_payment_required callback with a byte-for-byte retry of the cached original request under a fresh outer event, -32043 retried with retry_after * 1.5^n backoff capped at 10 s, and exactly seven enumerated ways either code can reach the consumer; everything else is handled internally

The options struct, callback signatures, and entry point freeze on merge as the API the Phase B rails build on. The proxy gets payment_options on ProxyConfig (plus a builder), used in both NostrMCPProxy::new and serve_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:

  1. The correlation fix. The client deleted its response-correlation entry on the FIRST correlated inbound message of any kind, so a correlated notification (payment_required above 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.
  2. A per-payment touch loop. The client sweeps stale correlation entries after 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 of min(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.
  3. The send-path extraction. Engine retries must ride the real send path (negotiation tags, latches, encryption, oversized fragmentation, pending registration) and detached tasks can't hold &self, so send()'s body moved onto a pub(crate) parts struct of cloned handles, the same refactor the server side did for send_notification. One non-mechanical bit: the one-shot discovery flag became Arc-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

  1. Three registration guards (after start(), after close(), second registration), each a hard error before anything is mutated. ts has no guards and calling withClientPayments twice 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.
  2. A 1 s floor on the pending backoff. ts sends a zero-delay retry for 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.
  3. The retry counter lives inside the request-cache entry instead of a separate map, so the LRU bounds both together (ts leaks counters for requests that never see a terminal response).
  4. Smaller: the proxy field is an Option where ts always wraps, the policy callback takes the request by value, durations are Duration fields, 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)

  • The dedup is in-flight-only: a fresh offer for a still-pending request after the first payment settles is honored, and payment_policy is the gate for that. Treat it as the spending limit.
  • No cap on -32042 cycles: a failed verification mints a fresh invoice and the callback is asked again each round. The callback is the budget.
  • A session where the server accepted explicit gating still auto-pays transparent offers (policy-gated); only a refused gating request disables auto-pay.
  • The handlers' PMIs replace any config-seeded list at registration.
  • A fabricated heartbeat is indistinguishable from real server progress; TTL-bounded.
  • The proxy's consumption lines can't be driven over a mock today (real relay pool), same as the gateway's in feat(payments): add the server payments registration entry point and gateway payment options #113; recorded in the followups rather than papered over with a fake test.

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: 0 grant, the negotiation latch question).

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