You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
CEP-8 defines capability pricing and a payment flow over ContextVM. Servers advertise reference prices (cap tags) and payment methods (pmi tags); peers negotiate a session payment_interaction mode, either transparent (default) or explicit_gating. Payment is a transport concern: a middleware gates a priced request before it reaches the rmcp handler, which stays unaware. Transparent mode uses notifications/payment_required|accepted|rejected correlated by the outer event id. Explicit-gating returns JSON-RPC errors -32042 Payment Required and -32043 Payment Pending, correlated by client pubkey plus a canonical invocation hash, and consumes a paid authorization on retry.
No new Nostr kinds (reuses 25910 and the CEP-6 announcement kinds). Depends on CEP-4 (encryption), CEP-6 (announcements), and CEP-35 (negotiation), all done. Built on a new general-purpose public inbound-middleware seam (PR 0) that mirrors the ts-sdk addInboundMiddleware seam; payments is its only first-party tenant, and CEP-22/41 stay as the separate pre-chain interceptor. Real Lightning rails are Phase B, behind the PaymentProcessor and PaymentHandler traits. This tracker ships the protocol core plus deterministic fakes, testable end to end without Lightning.
Key notes:
Canonical invocation identity is SHA-256(RFC-8785-JCS({method, params minus top-level _meta})) via the json-canon crate (ECMAScript number formatting). ts-sdk pins no vectors, so we generate goldens from a pinned JS run.
AuthorizationStore uses a std::sync::Mutex across check-and-mutate for double-spend safety under multi-threaded tokio (ts-sdk relies on the single-threaded event loop).
rs-sdk does not rewrite the request id to the event id in the transport (that happens in the worker), so the middleware ctx carries request_event_id explicitly.
FFI error contract: Error::Payment and its matching contextvm-ffi/src/error.rs arm land together in PR 1 (one touch).
Spec:contextvm-docs/src/content/docs/reference/ceps/cep-8.md TS SDK reference:sdk/src/payments/ and sdk/src/transport/nostr-server/inbound-coordinator.ts
InboundContext { client_pubkey, request_event_id, is_encrypted, client_pmis: Option<Vec<String>>, payment_interaction: Option<PaymentInteractionMode> } (last two None until PR 4)
Owned Next { chain: Arc<[Arc<dyn InboundMiddleware>]>, index, ctx: Arc<InboundContext>, tx } with run(self, msg) -> bool (terminal step is tx.send(IncomingRequest))
PaymentInteractionMode { Transparent, ExplicitGating } (serde snake_case) in src/core/types.rs
Detached chain runner (tokio::spawn) with an empty-chain inline fast-path that reproduces today's server/mod.rs:1984tx.send byte-for-byte when no middleware is registered
Dual-entry: shared runner invoked from the normal request path (server/mod.rs:1984) and the oversized re-inject (:2137); handle_oversized_frame gains a middlewares arg
Vec<Arc<dyn InboundMiddleware>> field captured by value into event_loop; add_inbound_middleware(&mut self) plus builder option, registered before start()
Drop-cleanup pops the reserved correlation route on !forwarded and on middleware error; errors route to the existing transport onerror path
Declare the module in src/transport/server/mod.rs
Tests: empty-chain equivalence (existing CEP-22/41 e2e suite stays green); forward / drop / transform unit; drop pops route; error pops route and hits onerror
Sync ops with no .await inside the lock: grant; claim (get, check expiry, delete, return true, single-use); try_set_pending (CAS); update_pending_ttl (no-op if not active); get_pending_remaining; clear_pending. Lazy expiry, no background sweeper, Instant clock, consume-on-claim
Tests: grant then claim single-use (second claim false); claim fails after grant TTL; try_set_pending blocks a concurrent dup; re-pending after clear_pending and after TTL expiry; grant clears pending; independent LRU eviction for grants vs pending; update_pending_ttl extends only an active entry
Multi-thread double-spend tests (#[tokio::test(flavor = "multi_thread")]): N concurrent claim on one grant yields exactly one true; N concurrent try_set_pending yields exactly one true
ClientSession (core/types.rs) gains requested_payment_interaction, effective_payment_interaction, has_disclosed_payment_interaction; mirror onto SessionSnapshot and both snapshot mappers (session_store.rs)
negotiate_payment_interaction(session, &inner_tags, method, policy) hooked right after learn_peer_capabilities (server/mod.rs:1834) inside the same sessions_w scope; initialize resets payment fields only, leaving CEP-22/41 flags OR-learned
-32602 inline emission following the -32000 Unauthorized precedent (server/mod.rs:1748-1777): carries the original inner id, data { requested, supported: ["transparent"] }, then skips dispatch
Server payment_interaction policy config (Optional or Transparent, default Optional); populate IncomingRequest.payment_interaction (#[non_exhaustive]) to feed the middleware ctx
Availability tags (pmi* plus payment_interaction=explicit_gating when Optional) via set_announcement_extra_tags (server/mod.rs:1303)
First-response effective-mode disclosure in send_response when requested is non-transparent and not yet disclosed
cap on live list responses: wire pricing_tags into CommonTagsSnapshot plus append_pricing_tags gated to tools/list, resources/list, prompts/list
Tests: tests/conformance_cep8_wire_format.rs (pin pmi/payment_interaction/cap tuples, notif param shapes, -32602 data); negotiation upsert unit (transparent, gating-accepted, gating-rejected, initialize-reset, mid-session upsert); reset does not perturb CEP-22/41 flags
PR 5: Client negotiation emission + latch
get_pending_negotiation_tags() on the client, emitting pmi* plus payment_interaction even for transparent (to distinguish a downgrade from no preference)
Wire it as the third (negotiation) arg of compose_outbound_tags (client/mod.rs:680, currently &[])
last_sent_payment_interaction: Arc<Mutex<Option<String>>> latch-until-changed (re-send on mode change, not the one-shot has_sent_discovery_tags latch)
Client payment_interaction config field; client records the server's effective mode only when it itself requested explicit_gating
Tests: negotiation e2e over MockRelayPool covering tags emitted, server negotiates, effective mode disclosed on the first response, re-send on mode change
PR 6: Route-bypassing targeted-response sender
send_targeted_response(client_pubkey_hex, event_id, response) on the server transport, publishing a JSON-RPC error without popping event_routes (id from the caller-held request id, tags via create_response_tags, wrap-kind via select_outbound_gift_wrap_kind and request_wrap_kinds, publish via base.send_mcp_message). Modeled on publish_open_stream_deferred_response (server/mod.rs:1055-1090)
Injected-closure form for use inside the spawned middleware (no &self)
Tests: MockRelayPool asserting e-tag correlation and that the route is not mutated (still present after send)
src/payments/server_payments.rs - create_server_payments_middleware(opts, correlated_sender); self-gates on not-request, mode not transparent, or unpriced, forwarding in each case
Atomic pending-dedup keyed by ctx.request_event_id (Arc<tokio::Mutex<LruCache<PendingEntry { expires_at, in_flight: Shared }>>>; entry set before the first await; duplicates await the shared in_flight; keep until TTL on success, delete on failure)
Lifecycle: match_priced_capability, then resolve/initiate (reject sends payment_rejected and drops; waive forwards; otherwise continue), emit payment_required (reuse send_notification(.., Some(event_id)) at server/mod.rs:1206), verify_payment under timeout and CancellationToken, emit payment_accepted, forward
Sweep-survival: capture a RouteSnapshot when payment_required is first emitted, and add a payments arm to the send_response defer-decision (server/mod.rs:744-761) that delivers the result from the snapshot on route-miss, surviving the 60 s request_timeout sweep
Tests: happy-path emission order (create, required, verify, accepted, forward); reject; waive; verify-timeout (not forwarded, no accepted); duplicate-event idempotency (create called once); TTL over 60 s survives the sweep
src/payments/server_explicit_gating.rs - create_explicit_gating_middleware(opts, targeted_sender, auth_store); self-gates; registered only when policy is Optional
Lifecycle: compute_canonical_invocation_identity, then claim (a grant forwards), try_set_pending (already pending returns -32043 with retry_after = min(2, max(1, ceil(remaining_ms/1000))) via the targeted sender and drops), resolve (reject returns -32000, clears, drops; waive clears and forwards; otherwise set the grant TTL and return -32042 with payment_options[>=1] via the targeted sender, then drop)
Detached background verify_payment (not awaited): grant on success, clear_pending on failure or timeout
Tests: -32042 first; -32043 while pending; pre-grant claim forwards single-use; reject gives -32000; waive forwards; grant then retry forwards; concurrency yields a single forward; canonical-identity isolation across args, client, id, and _meta; a _meta-regenerated progressToken still claims the grant
PR 9: with_server_payments registration + config + server e2e
with_server_payments(&mut transport, opts): set announcement pmi/cap/payment_interaction tags, record supported_payment_interaction, add_inbound_middleware(transparent) then (if Optional) add_inbound_middleware(explicit_gating) with a fresh AuthorizationStore
Gateway payment_options wiring
Tests: tests/payments_server_e2e.rs over MockRelayPool with a fake-handler test client: both lifecycles engage; a CEP-22-reassembled priced tools/call gets gated; a long payment survives the sweep; PMI selection (client preference wins, fallback to processors[0])
Transparent auto-pay: on payment_required, dedup by pay_req, apply payment_policy and can_handle gates, call handler.handle(...); a synthetic-progress heartbeat (interval synthetic_progress_interval_ms) keeps the MCP request alive past its timeout, stopping on accepted or rejected
Explicit-gating interception: -32042 (on_payment_required, pay, retry same method and params) and -32043 (retry with exp-backoff, capped by max_pending_retries); a raw_request_cache; never surface -32042/-32043 to the MCP consumer except on the give-up paths
Tests: full bilateral e2e of with_client_payments against with_server_payments over MockRelayPool: both lifecycles, negotiation, oversized-gating, sweep-survival, -32043 backoff, and a user-declined payment surfacing -32042 to the caller; TS parity target payments-flow.test.ts
Lightning BOLT11 via NWC / NIP-47 (PaymentProcessor make and lookup invoice, PaymentHandler pay)
Lightning via LNURL / NIP-57 zaps (zap-request 9734 to invoice, verify via 9735 receipt)
Lightning via LNbits REST
Out of scope (parity with ts-sdk):direct_payment / change bearer-asset tags (type-only, never produced); multi-PMI payment_required fan-out (ts-sdk sends exactly one).
Summary
CEP-8 defines capability pricing and a payment flow over ContextVM. Servers advertise reference prices (
captags) and payment methods (pmitags); peers negotiate a sessionpayment_interactionmode, eithertransparent(default) orexplicit_gating. Payment is a transport concern: a middleware gates a priced request before it reaches the rmcp handler, which stays unaware. Transparent mode usesnotifications/payment_required|accepted|rejectedcorrelated by the outer event id. Explicit-gating returns JSON-RPC errors-32042 Payment Requiredand-32043 Payment Pending, correlated by client pubkey plus a canonical invocation hash, and consumes a paid authorization on retry.No new Nostr kinds (reuses 25910 and the CEP-6 announcement kinds). Depends on CEP-4 (encryption), CEP-6 (announcements), and CEP-35 (negotiation), all done. Built on a new general-purpose public inbound-middleware seam (PR 0) that mirrors the ts-sdk
addInboundMiddlewareseam; payments is its only first-party tenant, and CEP-22/41 stay as the separate pre-chain interceptor. Real Lightning rails are Phase B, behind thePaymentProcessorandPaymentHandlertraits. This tracker ships the protocol core plus deterministic fakes, testable end to end without Lightning.Key notes:
SHA-256(RFC-8785-JCS({method, params minus top-level _meta}))via thejson-canoncrate (ECMAScript number formatting). ts-sdk pins no vectors, so we generate goldens from a pinned JS run.AuthorizationStoreuses astd::sync::Mutexacross check-and-mutate for double-spend safety under multi-threaded tokio (ts-sdk relies on the single-threaded event loop).ctxcarriesrequest_event_idexplicitly.Error::Paymentand its matchingcontextvm-ffi/src/error.rsarm land together in PR 1 (one touch).Spec:
contextvm-docs/src/content/docs/reference/ceps/cep-8.mdTS SDK reference:
sdk/src/payments/andsdk/src/transport/nostr-server/inbound-coordinator.tsPR 0: Inbound middleware seam (general-purpose, public, behavior-preserving)
src/transport/server/middleware.rs-InboundMiddlewaretrait (#[async_trait],async fn handle(&self, msg, ctx: &InboundContext, next: Next) -> bool)InboundContext { client_pubkey, request_event_id, is_encrypted, client_pmis: Option<Vec<String>>, payment_interaction: Option<PaymentInteractionMode> }(last twoNoneuntil PR 4)Next { chain: Arc<[Arc<dyn InboundMiddleware>]>, index, ctx: Arc<InboundContext>, tx }withrun(self, msg) -> bool(terminal step istx.send(IncomingRequest))PaymentInteractionMode { Transparent, ExplicitGating }(serde snake_case) insrc/core/types.rstokio::spawn) with an empty-chain inline fast-path that reproduces today'sserver/mod.rs:1984tx.sendbyte-for-byte when no middleware is registeredserver/mod.rs:1984) and the oversized re-inject (:2137);handle_oversized_framegains amiddlewaresargVec<Arc<dyn InboundMiddleware>>field captured by value intoevent_loop;add_inbound_middleware(&mut self)plus builder option, registered beforestart()!forwardedand on middleware error; errors route to the existing transportonerrorpathsrc/transport/server/mod.rsonerrorPR 1: Payment primitives - constants, tags, types, traits, fakes (pure)
src/payments/module (pub mod payments;inlib.rs)constants.rs-PAYMENT_REQUIRED/ACCEPTED/REJECTED_METHOD, error codes-32042/-32043/-32602,PMI_BITCOIN_LIGHTNING_BOLT11,DEFAULT_PAYMENT_TTL_MS=300_000,DEFAULT_SYNTHETIC_PROGRESS_INTERVAL_MS=30_000,AUTH_STORE_MAX_ENTRIES=5000src/core/constants.rs::tagswithPMI="pmi",PAYMENT_INTERACTION="payment_interaction",DIRECT_PAYMENT,CHANGEtags.rs-capbuilder (tool:/prompt:/resource:prefix map,min-maxrange, omit on missing-name or unsupported-method),pmibuilder (server-preference order),payment_interactionbuilder, plus parserstypes.rs-PaymentRequiredParams/PaymentAcceptedParams/PaymentRejectedParams,PaymentOption,PaymentRequiredErrorData/PaymentPendingErrorData,PaymentInteractionPolicy {Optional, Transparent},PricedCapability; every optional uses#[serde(skip_serializing_if = "Option::is_none")]traits.rs-PaymentProcessor(pmi,create_payment_required,verify_paymentwithCancellationToken),PaymentHandler(pmi,can_handle,handle),ResolvePricefakes.rsbehindtest-utils:FakePaymentProcessor,FakePaymentHandlerPaymentErrorenum,Error::Payment(#[from] PaymentError)insrc/core/error.rs, and the matching arm incontextvm-ffi/src/error.rs(E0004 gate)cap-tag formatting (tool:addgives['cap','tool:add','1','sats'], range100-1000, skip missing-name and unsupported); serde wire-shape byte tests (Option::is_nonedrops keys, matchingJSON.stringify)PR 2: Canonical invocation identity + golden vectors (pure)
json-canon = "0.1"toCargo.toml(sha2andhexalready present)src/payments/canonical.rs-compute_canonical_invocation_hash(method, params: Option<&Value>) -> Result<String>: shallow top-level_metastrip;Noneomits theparamskey whileValue::Nullkeeps it;json_canon::to_string, thensha2::Sha256, then lowercasehexcompute_canonical_invocation_identity(...) -> CanonicalInvocationIdentity { client_pubkey, invocation_hash }PaymentError::Canonicalize { method }whoseDisplayreproduces the exact ts-sdk failure messagetools/gen-canonical-vectors/: Node generator (pinnedcanonicalize@2.1.0and@noble/hashes) writingtests/fixtures/canonical_vectors.json_metastripped gives the same hash; nested_metaNOT stripped gives a different hash;undefinedvsnullparams differ; arrays/primitives pass through; non-serializable throws the exact message; number edge vectors (1e21,1e-7,-0, non-BMP keys)PR 3: Authorization store + concurrency tests (pure)
src/payments/authorization_store.rs-AuthorizationStorebacked bystd::sync::Mutex<Inner { authorizations: LruCache, pending: LruCache }>(cap 5000 each, key"{pubkey}:{hash}").awaitinside the lock:grant;claim(get, check expiry, delete, return true, single-use);try_set_pending(CAS);update_pending_ttl(no-op if not active);get_pending_remaining;clear_pending. Lazy expiry, no background sweeper,Instantclock, consume-on-claimtry_set_pendingblocks a concurrent dup; re-pending afterclear_pendingand after TTL expiry;grantclears pending; independent LRU eviction for grants vs pending;update_pending_ttlextends only an active entry#[tokio::test(flavor = "multi_thread")]): N concurrentclaimon one grant yields exactly onetrue; N concurrenttry_set_pendingyields exactly onetruePR 4: Server negotiation + advertisement +
-32602discovery_tags.rs-extract_pmis(&[Tag]) -> Vec<String>andextract_payment_interaction(&[Tag]) -> Option<String>(multi-valued;PeerCapabilitiesstays boolean-only)ClientSession(core/types.rs) gainsrequested_payment_interaction,effective_payment_interaction,has_disclosed_payment_interaction; mirror ontoSessionSnapshotand both snapshot mappers (session_store.rs)negotiate_payment_interaction(session, &inner_tags, method, policy)hooked right afterlearn_peer_capabilities(server/mod.rs:1834) inside the samesessions_wscope;initializeresets payment fields only, leaving CEP-22/41 flags OR-learned-32602inline emission following the-32000Unauthorized precedent (server/mod.rs:1748-1777): carries the original inner id,data { requested, supported: ["transparent"] }, then skips dispatchpayment_interactionpolicy config (OptionalorTransparent, defaultOptional); populateIncomingRequest.payment_interaction(#[non_exhaustive]) to feed the middlewarectxpmi* pluspayment_interaction=explicit_gatingwhen Optional) viaset_announcement_extra_tags(server/mod.rs:1303)send_responsewhen requested is non-transparent and not yet disclosedcapon live list responses: wirepricing_tagsintoCommonTagsSnapshotplusappend_pricing_tagsgated totools/list,resources/list,prompts/listtests/conformance_cep8_wire_format.rs(pinpmi/payment_interaction/captuples, notif param shapes,-32602data); negotiation upsert unit (transparent, gating-accepted, gating-rejected, initialize-reset, mid-session upsert); reset does not perturb CEP-22/41 flagsPR 5: Client negotiation emission + latch
get_pending_negotiation_tags()on the client, emittingpmi* pluspayment_interactioneven fortransparent(to distinguish a downgrade from no preference)compose_outbound_tags(client/mod.rs:680, currently&[])last_sent_payment_interaction: Arc<Mutex<Option<String>>>latch-until-changed (re-send on mode change, not the one-shothas_sent_discovery_tagslatch)payment_interactionconfig field; client records the server's effective mode only when it itself requestedexplicit_gatingMockRelayPoolcovering tags emitted, server negotiates, effective mode disclosed on the first response, re-send on mode changePR 6: Route-bypassing targeted-response sender
send_targeted_response(client_pubkey_hex, event_id, response)on the server transport, publishing a JSON-RPC error without poppingevent_routes(id from the caller-held request id, tags viacreate_response_tags, wrap-kind viaselect_outbound_gift_wrap_kindandrequest_wrap_kinds, publish viabase.send_mcp_message). Modeled onpublish_open_stream_deferred_response(server/mod.rs:1055-1090)&self)MockRelayPoolassertinge-tag correlation and that the route is not mutated (still present after send)PR 7: Transparent payment middleware (+ long-payment route survival)
src/payments/server_payments.rs-create_server_payments_middleware(opts, correlated_sender); self-gates on not-request, mode not transparent, or unpriced, forwarding in each casectx.request_event_id(Arc<tokio::Mutex<LruCache<PendingEntry { expires_at, in_flight: Shared }>>>; entry set before the first await; duplicates await the sharedin_flight; keep until TTL on success, delete on failure)match_priced_capability, then resolve/initiate (reject sendspayment_rejectedand drops; waive forwards; otherwise continue), emitpayment_required(reusesend_notification(.., Some(event_id))atserver/mod.rs:1206),verify_paymentundertimeoutandCancellationToken, emitpayment_accepted, forwardRouteSnapshotwhenpayment_requiredis first emitted, and add a payments arm to thesend_responsedefer-decision (server/mod.rs:744-761) that delivers the result from the snapshot on route-miss, surviving the 60 srequest_timeoutsweepPR 8: Explicit-gating payment middleware (
-32042/-32043)src/payments/server_explicit_gating.rs-create_explicit_gating_middleware(opts, targeted_sender, auth_store); self-gates; registered only when policy isOptionalcompute_canonical_invocation_identity, thenclaim(a grant forwards),try_set_pending(already pending returns-32043withretry_after = min(2, max(1, ceil(remaining_ms/1000)))via the targeted sender and drops), resolve (reject returns-32000, clears, drops; waive clears and forwards; otherwise set the grant TTL and return-32042withpayment_options[>=1]via the targeted sender, then drop)verify_payment(not awaited):granton success,clear_pendingon failure or timeout-32042first;-32043while pending; pre-grant claim forwards single-use; reject gives-32000; waive forwards; grant then retry forwards; concurrency yields a single forward; canonical-identity isolation across args, client, id, and_meta; a_meta-regenerated progressToken still claims the grantPR 9:
with_server_paymentsregistration + config + server e2esrc/payments/server_transport_payments.rs-ServerPaymentsOptions { processors, priced_capabilities, resolve_price?, payment_ttl, max_pending_payments, payment_interaction: Policy }with_server_payments(&mut transport, opts): set announcementpmi/cap/payment_interactiontags, recordsupported_payment_interaction,add_inbound_middleware(transparent)then (if Optional)add_inbound_middleware(explicit_gating)with a freshAuthorizationStorepayment_optionswiringtests/payments_server_e2e.rsoverMockRelayPoolwith a fake-handler test client: both lifecycles engage; a CEP-22-reassembled pricedtools/callgets gated; a long payment survives the sweep; PMI selection (client preference wins, fallback toprocessors[0])PR 10:
with_client_payments(client peer) + bilateral e2e + docssrc/payments/client_payments.rs-with_client_payments(transport, opts)(wrapping transport, mirroring ts-sdkwithClientPayments);ClientPaymentsOptionspayment_required, dedup bypay_req, applypayment_policyandcan_handlegates, callhandler.handle(...); a synthetic-progress heartbeat (intervalsynthetic_progress_interval_ms) keeps the MCP request alive past its timeout, stopping onacceptedorrejected-32042(on_payment_required, pay, retry same method and params) and-32043(retry with exp-backoff, capped bymax_pending_retries); araw_request_cache; never surface-32042/-32043to the MCP consumer except on the give-up pathspayment_optionswiringdocs/payments.md(# Payments Guide (CEP-8)); CHANGELOG; README CEP matrix CEP-8 marked Donewith_client_paymentsagainstwith_server_paymentsoverMockRelayPool: both lifecycles, negotiation, oversized-gating, sweep-survival,-32043backoff, and a user-declined payment surfacing-32042to the caller; TS parity targetpayments-flow.test.tsPhase B: Payment rails (deferred, behind the Phase-A PaymentProcessor / PaymentHandler traits)
PaymentProcessormake and lookup invoice,PaymentHandlerpay)Out of scope (parity with ts-sdk):
direct_payment/changebearer-asset tags (type-only, never produced); multi-PMIpayment_requiredfan-out (ts-sdk sends exactly one).