Skip to content

implement CEP-8 capability pricing and payment #100

Description

@harsh04044

Summary

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


PR 0: Inbound middleware seam (general-purpose, public, behavior-preserving)

  • src/transport/server/middleware.rs - InboundMiddleware trait (#[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 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:1984 tx.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

PR 1: Payment primitives - constants, tags, types, traits, fakes (pure)

  • src/payments/ module (pub mod payments; in lib.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=5000
  • Extend src/core/constants.rs::tags with PMI="pmi", PAYMENT_INTERACTION="payment_interaction", DIRECT_PAYMENT, CHANGE
  • tags.rs - cap builder (tool:/prompt:/resource: prefix map, min-max range, omit on missing-name or unsupported-method), pmi builder (server-preference order), payment_interaction builder, plus parsers
  • types.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_payment with CancellationToken), PaymentHandler (pmi, can_handle, handle), ResolvePrice
  • fakes.rs behind test-utils: FakePaymentProcessor, FakePaymentHandler
  • PaymentError enum, Error::Payment(#[from] PaymentError) in src/core/error.rs, and the matching arm in contextvm-ffi/src/error.rs (E0004 gate)
  • Tests: constant values vs ts-sdk; cap-tag formatting (tool:add gives ['cap','tool:add','1','sats'], range 100-1000, skip missing-name and unsupported); serde wire-shape byte tests (Option::is_none drops keys, matching JSON.stringify)

PR 2: Canonical invocation identity + golden vectors (pure)

  • Add json-canon = "0.1" to Cargo.toml (sha2 and hex already present)
  • src/payments/canonical.rs - compute_canonical_invocation_hash(method, params: Option<&Value>) -> Result<String>: shallow top-level _meta strip; None omits the params key while Value::Null keeps it; json_canon::to_string, then sha2::Sha256, then lowercase hex
  • compute_canonical_invocation_identity(...) -> CanonicalInvocationIdentity { client_pubkey, invocation_hash }
  • PaymentError::Canonicalize { method } whose Display reproduces the exact ts-sdk failure message
  • tools/gen-canonical-vectors/: Node generator (pinned canonicalize@2.1.0 and @noble/hashes) writing tests/fixtures/canonical_vectors.json
  • Tests: golden-vector conformance; key-order independence; top-level _meta stripped gives the same hash; nested _meta NOT stripped gives a different hash; undefined vs null params 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 - AuthorizationStore backed by std::sync::Mutex<Inner { authorizations: LruCache, pending: LruCache }> (cap 5000 each, key "{pubkey}:{hash}")
  • 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

PR 4: Server negotiation + advertisement + -32602

  • discovery_tags.rs - extract_pmis(&[Tag]) -> Vec<String> and extract_payment_interaction(&[Tag]) -> Option<String> (multi-valued; PeerCapabilities stays boolean-only)
  • 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)

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 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

PR 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 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

  • src/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 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])

PR 10: with_client_payments (client peer) + bilateral e2e + docs

  • src/payments/client_payments.rs - with_client_payments(transport, opts) (wrapping transport, mirroring ts-sdk withClientPayments); ClientPaymentsOptions
  • 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
  • Proxy payment_options wiring
  • docs/payments.md (# Payments Guide (CEP-8)); CHANGELOG; README CEP matrix CEP-8 marked Done
  • 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

Phase B: Payment rails (deferred, behind the Phase-A PaymentProcessor / PaymentHandler traits)

  • 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions