diff --git a/contextvm-ffi/.gitignore b/contextvm-ffi/.gitignore index ca32551..3dccc99 100644 --- a/contextvm-ffi/.gitignore +++ b/contextvm-ffi/.gitignore @@ -2,5 +2,6 @@ __pycache__/ *.py[cod] -# C test binary built by c-tests/Makefile +# C test binary + debug bundle built by c-tests/Makefile c-tests/test_ffi +c-tests/test_ffi.dSYM/ diff --git a/contextvm-ffi/c-tests/test_ffi.c b/contextvm-ffi/c-tests/test_ffi.c index 6f4654b..4e023e4 100644 --- a/contextvm-ffi/c-tests/test_ffi.c +++ b/contextvm-ffi/c-tests/test_ffi.c @@ -155,6 +155,7 @@ TEST(constant_values) { ASSERT(CVM_VALIDATION == 5); ASSERT(CVM_UNAUTHORIZED == 6); ASSERT(CVM_SERIALIZATION == 7); + ASSERT(CVM_PAYMENT == 8); ASSERT(CVM_OTHER == 99); ASSERT(CVM_ENCRYPTION_DISABLED == 2); diff --git a/contextvm-ffi/headers/contextvm.h b/contextvm-ffi/headers/contextvm.h index de247cf..02522d1 100644 --- a/contextvm-ffi/headers/contextvm.h +++ b/contextvm-ffi/headers/contextvm.h @@ -83,6 +83,7 @@ typedef enum { CVM_VALIDATION = 5, CVM_UNAUTHORIZED = 6, CVM_SERIALIZATION = 7, + CVM_PAYMENT = 8, CVM_OTHER = 99, } CvmErrorCode; diff --git a/contextvm-ffi/src/error.rs b/contextvm-ffi/src/error.rs index d2f477b..be17861 100644 --- a/contextvm-ffi/src/error.rs +++ b/contextvm-ffi/src/error.rs @@ -22,6 +22,8 @@ pub enum ErrorCode { Unauthorized = 6, /// Serialization/deserialization error. Serialization = 7, + /// Payment (CEP-8) error. + Payment = 8, /// Generic / unknown error. Other = 99, } @@ -43,6 +45,11 @@ impl From<&contextvm_sdk::Error> for ErrorCode { // transport-layer, so they surface as CVM_TRANSPORT. The C header // exposes no dedicated code; mirrors `OversizedTransfer`. contextvm_sdk::Error::OpenStream(_) => ErrorCode::Transport, + // CEP-8 payment failures get a dedicated FFI code so foreign callers can + // branch on "payment required/failed" rather than a generic error. The + // wildcard on the inner PaymentError keeps every later payment error off + // this map. + contextvm_sdk::Error::Payment(_) => ErrorCode::Payment, contextvm_sdk::Error::Other(_) => ErrorCode::Other, } } diff --git a/src/core/constants.rs b/src/core/constants.rs index e06af99..445b2ee 100644 --- a/src/core/constants.rs +++ b/src/core/constants.rs @@ -70,6 +70,18 @@ pub mod tags { /// Support CEP-41 open-ended streaming via notifications/progress framing pub const SUPPORT_OPEN_STREAM: &str = "support_open_stream"; + + /// CEP-8 payment method identifier advertisement/negotiation tag. + pub const PMI: &str = "pmi"; + + /// CEP-8 session payment-interaction negotiation tag. + pub const PAYMENT_INTERACTION: &str = "payment_interaction"; + + /// CEP-8 optional bearer-asset direct-payment tag (type/name only; never produced by this SDK). + pub const DIRECT_PAYMENT: &str = "direct_payment"; + + /// CEP-8 optional bearer-asset change tag (type/name only; never produced by this SDK). + pub const CHANGE: &str = "change"; } /// Maximum message size (1MB) @@ -181,6 +193,11 @@ mod tests { tags::SUPPORT_OVERSIZED_TRANSFER, "support_oversized_transfer" ); + assert_eq!(tags::SUPPORT_OPEN_STREAM, "support_open_stream"); + assert_eq!(tags::PMI, "pmi"); + assert_eq!(tags::PAYMENT_INTERACTION, "payment_interaction"); + assert_eq!(tags::DIRECT_PAYMENT, "direct_payment"); + assert_eq!(tags::CHANGE, "change"); } #[test] diff --git a/src/core/error.rs b/src/core/error.rs index 5a70a81..b48eb4e 100644 --- a/src/core/error.rs +++ b/src/core/error.rs @@ -42,6 +42,10 @@ pub enum Error { #[error("Open stream error: {0}")] OpenStream(#[from] crate::transport::open_stream::OpenStreamError), + /// CEP-8 payment error (pricing, issuance, verification, or settlement). + #[error("Payment error: {0}")] + Payment(#[from] crate::payments::PaymentError), + /// Generic error #[error("{0}")] Other(String), diff --git a/src/lib.rs b/src/lib.rs index 78ea09b..6c421b0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,6 +45,8 @@ pub mod discovery; pub mod encryption; /// Gateway bridging a local MCP server to Nostr pub mod gateway; +/// CEP-8 capability pricing and payment primitives +pub mod payments; /// Proxy connecting to a remote MCP server via Nostr pub mod proxy; /// Nostr relay pool management @@ -88,6 +90,20 @@ pub use transport::server::{ SessionSnapshot, SessionStore, }; +// ── Payments (CEP-8) ───────────────────────────────────────────────── +#[cfg(feature = "test-utils")] +pub use payments::{ + FakePaymentHandler, FakePaymentHandlerOptions, FakePaymentProcessor, + FakePaymentProcessorOptions, +}; +pub use payments::{ + Meta, PaymentAcceptedParams, PaymentError, PaymentHandler, PaymentHandlerRequest, + PaymentInteractionPolicy, PaymentOption, PaymentPendingErrorData, PaymentProcessor, + PaymentProcessorCreateParams, PaymentProcessorVerifyParams, PaymentRejectedParams, + PaymentRequiredErrorData, PaymentRequiredParams, PricedCapability, ResolvePrice, + ResolvePriceParams, ResolvePriceResult, VerifyOutcome, +}; + // ── rmcp re-export ────────────────────────────────────────────────── #[cfg(feature = "rmcp")] pub use rmcp; diff --git a/src/payments/constants.rs b/src/payments/constants.rs new file mode 100644 index 0000000..3dff6e5 --- /dev/null +++ b/src/payments/constants.rs @@ -0,0 +1,57 @@ +//! CEP-8 payment protocol constants (methods, JSON-RPC error codes, PMIs, TTLs). + +/// `notifications/payment_required`: server requests payment (transparent lifecycle). +pub const PAYMENT_REQUIRED_METHOD: &str = "notifications/payment_required"; +/// `notifications/payment_accepted`: server observed settlement. +pub const PAYMENT_ACCEPTED_METHOD: &str = "notifications/payment_accepted"; +/// `notifications/payment_rejected`: server refused or rejected payment. +pub const PAYMENT_REJECTED_METHOD: &str = "notifications/payment_rejected"; + +/// Explicit-gating JSON-RPC error: payment required (`error.data` = [`PaymentRequiredErrorData`](crate::payments::PaymentRequiredErrorData)). +pub const PAYMENT_REQUIRED_ERROR_CODE: i64 = -32042; +/// Explicit-gating JSON-RPC error: payment pending (`error.data` = [`PaymentPendingErrorData`](crate::payments::PaymentPendingErrorData)). +pub const PAYMENT_PENDING_ERROR_CODE: i64 = -32043; +/// Unsupported `payment_interaction` negotiation: reuses JSON-RPC Invalid Params per CEP-8. +pub const UNSUPPORTED_PAYMENT_INTERACTION_ERROR_CODE: i64 = -32602; + +/// The only Phase-A payment method identifier. +pub const PMI_BITCOIN_LIGHTNING_BOLT11: &str = "bitcoin-lightning-bolt11"; + +/// Default payment TTL when `payment_required` carries no `ttl` (ms). Mirrors the server default. +pub const DEFAULT_PAYMENT_TTL_MS: u64 = 300_000; +/// Default synthetic-progress heartbeat interval (ms). Half the 60 s MCP request timeout. +pub const DEFAULT_SYNTHETIC_PROGRESS_INTERVAL_MS: u64 = 30_000; +/// Per-map cap for the `AuthorizationStore` LRUs. Equals [`core::constants::DEFAULT_LRU_SIZE`](crate::core::constants::DEFAULT_LRU_SIZE). +pub const AUTH_STORE_MAX_ENTRIES: usize = 5000; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn methods_match_ts_sdk() { + assert_eq!(PAYMENT_REQUIRED_METHOD, "notifications/payment_required"); + assert_eq!(PAYMENT_ACCEPTED_METHOD, "notifications/payment_accepted"); + assert_eq!(PAYMENT_REJECTED_METHOD, "notifications/payment_rejected"); + } + + #[test] + fn error_codes_match_ts_sdk() { + assert_eq!(PAYMENT_REQUIRED_ERROR_CODE, -32042); + assert_eq!(PAYMENT_PENDING_ERROR_CODE, -32043); + assert_eq!(UNSUPPORTED_PAYMENT_INTERACTION_ERROR_CODE, -32602); + } + + #[test] + fn pmi_and_ttls_match_ts_sdk() { + assert_eq!(PMI_BITCOIN_LIGHTNING_BOLT11, "bitcoin-lightning-bolt11"); + assert_eq!(DEFAULT_PAYMENT_TTL_MS, 300_000); + assert_eq!(DEFAULT_SYNTHETIC_PROGRESS_INTERVAL_MS, 30_000); + assert_eq!(AUTH_STORE_MAX_ENTRIES, 5000); + // Mirrors the core LRU default; keep the two in lockstep. + assert_eq!( + AUTH_STORE_MAX_ENTRIES, + crate::core::constants::DEFAULT_LRU_SIZE + ); + } +} diff --git a/src/payments/errors.rs b/src/payments/errors.rs new file mode 100644 index 0000000..a10e63a --- /dev/null +++ b/src/payments/errors.rs @@ -0,0 +1,69 @@ +//! Error taxonomy for CEP-8 payments. +//! +//! Mirrors the sibling per-module error enums +//! (`transport/open_stream/errors.rs`, `transport/oversized_transfer/errors.rs`) +//! so failure classification is consistent across the crate. Surfaced through +//! the crate-level [`crate::Error`] via a `#[from]` conversion on +//! `Error::Payment`. +//! +//! It is `#[non_exhaustive]` because it grows as later CEP-8 work adds variants +//! (canonicalization, payment selection and verification). +//! Unlike the all-`String` siblings it deliberately does not derive +//! `Clone`/`PartialEq`/`Eq`: it carries a [`serde_json::Error`], which is not `Clone`. + +/// Errors raised while pricing, issuing, verifying, or paying a CEP-8 invocation. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum PaymentError { + /// A server-side [`PaymentProcessor`](crate::payments::PaymentProcessor) + /// failed to create or verify a payment. + #[error("Payment processor error: {0}")] + Processor(String), + + /// A client-side [`PaymentHandler`](crate::payments::PaymentHandler) + /// failed to execute a payment. + #[error("Payment handler error: {0}")] + Handler(String), + + /// (De)serialization of a payment payload failed. + #[error("Payment serialization error: {0}")] + Serialization(#[from] serde_json::Error), +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Error; + + #[test] + fn folds_into_crate_error_payment_arm() { + let e: Error = PaymentError::Processor("x".to_string()).into(); + assert!(matches!(e, Error::Payment(_))); + } + + #[test] + fn display_nests_through_crate_error() { + let e: Error = PaymentError::Processor("x".to_string()).into(); + assert_eq!(e.to_string(), "Payment error: Payment processor error: x"); + } + + #[test] + fn handler_variant_display() { + assert_eq!( + PaymentError::Handler("boom".to_string()).to_string(), + "Payment handler error: boom" + ); + } + + #[test] + fn serde_json_error_converts_via_question_mark() { + fn parse() -> Result<(), PaymentError> { + let _v: serde_json::Value = serde_json::from_str("{ not json")?; + Ok(()) + } + assert!(matches!( + parse().unwrap_err(), + PaymentError::Serialization(_) + )); + } +} diff --git a/src/payments/fakes.rs b/src/payments/fakes.rs new file mode 100644 index 0000000..fd6d9e2 --- /dev/null +++ b/src/payments/fakes.rs @@ -0,0 +1,267 @@ +//! Deterministic in-memory fakes for the CEP-8 payment traits, behind the +//! `test-utils` feature. Behavior mirrors ts-sdk's `fake-payment-processor.ts` +//! / `fake-payment-handler.ts` on the happy path, with one enrichment: +//! [`FakePaymentProcessor::verify_payment`] selects on the cancellation token +//! and returns `Err` when cancelled, so verify-timeout and shutdown tests are +//! deterministic AND a cancelled verify can never be read as a +//! settlement (ts relies on the caller's `withTimeout` to reject instead). + +use std::time::Duration; + +use async_trait::async_trait; + +use crate::payments::errors::PaymentError; +use crate::payments::traits::{PaymentHandler, PaymentProcessor}; +use crate::payments::types::{ + Meta, PaymentHandlerRequest, PaymentProcessorCreateParams, PaymentProcessorVerifyParams, + PaymentRequiredParams, VerifyOutcome, +}; + +/// Construction options for [`FakePaymentProcessor`] (mirrors ts `FakePaymentProcessorOptions`). +#[derive(Debug, Clone)] +pub struct FakePaymentProcessorOptions { + /// The PMI this processor issues. Default `"fake"`. + pub pmi: String, + /// Artificial delay (ms) for settlement verification. Default `50`. + pub verify_delay_ms: u64, + /// Artificial delay (ms) for request creation. Default `0`. + pub create_delay_ms: u64, + /// Optional TTL (seconds) to include in `payment_required`. Default `None`. + pub ttl: Option, +} + +impl Default for FakePaymentProcessorOptions { + fn default() -> Self { + Self { + pmi: "fake".to_string(), + verify_delay_ms: 50, + create_delay_ms: 0, + ttl: None, + } + } +} + +/// Fake processor: issues a deterministic `pay_req` and "settles" after a delay. +pub struct FakePaymentProcessor { + pmi: String, + verify_delay_ms: u64, + create_delay_ms: u64, + ttl: Option, +} + +impl FakePaymentProcessor { + /// Construct with defaults (pmi `"fake"`, verify 50 ms, create 0 ms, no ttl). + pub fn new() -> Self { + Self::with_options(FakePaymentProcessorOptions::default()) + } + + /// Construct from explicit options. + pub fn with_options(opts: FakePaymentProcessorOptions) -> Self { + Self { + pmi: opts.pmi, + verify_delay_ms: opts.verify_delay_ms, + create_delay_ms: opts.create_delay_ms, + ttl: opts.ttl, + } + } +} + +impl Default for FakePaymentProcessor { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl PaymentProcessor for FakePaymentProcessor { + fn pmi(&self) -> &str { + &self.pmi + } + + async fn create_payment_required( + &self, + p: PaymentProcessorCreateParams, + ) -> Result { + if self.create_delay_ms > 0 { + tokio::time::sleep(Duration::from_millis(self.create_delay_ms)).await; + } + Ok(PaymentRequiredParams { + amount: p.amount, + pay_req: format!( + "fake:{}:{}:{}", + p.request_event_id, p.client_pubkey, p.amount + ), + pmi: self.pmi.clone(), + description: p.description, + ttl: self.ttl, + meta: None, + }) + } + + async fn verify_payment( + &self, + p: PaymentProcessorVerifyParams, + ) -> Result { + // Honor cancellation so timeout/shutdown tests are deterministic. + // A cancelled verify is a FAILURE, not an empty settlement: return Err so + // it can never be read as settled (mirrors ts's `withTimeout` rejection). + tokio::select! { + _ = p.cancel.cancelled() => Err(PaymentError::Processor( + "verify_payment cancelled".to_string(), + )), + _ = tokio::time::sleep(Duration::from_millis(self.verify_delay_ms)) => { + let mut meta = Meta::new(); + meta.insert("settled".to_string(), serde_json::Value::Bool(true)); + Ok(VerifyOutcome { meta: Some(meta) }) + } + } + } +} + +/// Construction options for [`FakePaymentHandler`] (mirrors ts `FakePaymentHandlerOptions`). +#[derive(Debug, Clone)] +pub struct FakePaymentHandlerOptions { + /// The PMI this handler advertises. Default `"fake"`. + pub pmi: String, + /// Artificial delay (ms) simulating a wallet action. Default `50`. + pub delay_ms: u64, +} + +impl Default for FakePaymentHandlerOptions { + fn default() -> Self { + Self { + pmi: "fake".to_string(), + delay_ms: 50, + } + } +} + +/// Fake handler: simulates a wallet action with a delay. Publishes no protocol messages. +pub struct FakePaymentHandler { + pmi: String, + delay_ms: u64, +} + +impl FakePaymentHandler { + /// Construct with defaults (pmi `"fake"`, 50 ms delay). + pub fn new() -> Self { + Self::with_options(FakePaymentHandlerOptions::default()) + } + + /// Construct from explicit options. + pub fn with_options(opts: FakePaymentHandlerOptions) -> Self { + Self { + pmi: opts.pmi, + delay_ms: opts.delay_ms, + } + } +} + +impl Default for FakePaymentHandler { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl PaymentHandler for FakePaymentHandler { + fn pmi(&self) -> &str { + &self.pmi + } + + async fn handle(&self, _req: PaymentHandlerRequest) -> Result<(), PaymentError> { + tokio::time::sleep(Duration::from_millis(self.delay_ms)).await; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio_util::sync::CancellationToken; + + fn create_params(amount: i64) -> PaymentProcessorCreateParams { + PaymentProcessorCreateParams { + amount, + description: Some("d".to_string()), + request_event_id: "evt".to_string(), + client_pubkey: "pk".to_string(), + } + } + + fn verify_params(cancel: CancellationToken) -> PaymentProcessorVerifyParams { + PaymentProcessorVerifyParams { + pay_req: "r".to_string(), + request_event_id: "evt".to_string(), + client_pubkey: "pk".to_string(), + cancel, + } + } + + #[tokio::test] + async fn processor_create_payment_required_builds_pay_req_and_passes_ttl() { + let proc = FakePaymentProcessor::with_options(FakePaymentProcessorOptions { + ttl: Some(600), + create_delay_ms: 0, + ..Default::default() + }); + let out = proc + .create_payment_required(create_params(42)) + .await + .unwrap(); + assert_eq!(out.pay_req, "fake:evt:pk:42"); + assert_eq!(out.amount, 42); + assert_eq!(out.pmi, "fake"); + assert_eq!(out.ttl, Some(600)); + assert_eq!(out.description.as_deref(), Some("d")); + assert!(out.meta.is_none()); + } + + #[tokio::test] + async fn processor_verify_payment_returns_settled_meta() { + let proc = FakePaymentProcessor::with_options(FakePaymentProcessorOptions { + verify_delay_ms: 0, + ..Default::default() + }); + let out = proc + .verify_payment(verify_params(CancellationToken::new())) + .await + .unwrap(); + let meta = out.meta.expect("settled meta present"); + assert_eq!(meta.get("settled"), Some(&serde_json::Value::Bool(true))); + } + + #[tokio::test] + async fn processor_verify_payment_errors_on_pre_cancelled_token() { + // Default 50 ms verify delay; a pre-cancelled token wins the select. A + // cancelled verify is a failure, never a (empty) settlement. + let proc = FakePaymentProcessor::new(); + let cancel = CancellationToken::new(); + cancel.cancel(); + let err = proc + .verify_payment(verify_params(cancel)) + .await + .expect_err("a cancelled verify must not settle"); + assert!(matches!(err, PaymentError::Processor(_))); + } + + #[tokio::test] + async fn handler_handles_and_default_can_handle_is_true() { + let handler = FakePaymentHandler::with_options(FakePaymentHandlerOptions { + delay_ms: 0, + ..Default::default() + }); + let req = PaymentHandlerRequest { + amount: 1, + pay_req: "r".to_string(), + pmi: "fake".to_string(), + description: None, + ttl: None, + meta: None, + request_event_id: "evt".to_string(), + }; + assert_eq!(handler.pmi(), "fake"); + assert!(handler.can_handle(&req).await); + handler.handle(req).await.unwrap(); + } +} diff --git a/src/payments/mod.rs b/src/payments/mod.rs new file mode 100644 index 0000000..b7b8bc5 --- /dev/null +++ b/src/payments/mod.rs @@ -0,0 +1,38 @@ +//! CEP-8 capability pricing and payment primitives. +//! +//! This is the pure foundation consumed by the later CEP-8 PRs: protocol +//! constants, `cap` / `pmi` / `payment_interaction` tag builders and parsers, +//! the wire notification params and explicit-gating error `data` types, the +//! [`PaymentProcessor`] / [`PaymentHandler`] / [`ResolvePrice`] traits, the +//! [`PaymentError`] taxonomy, and deterministic fakes behind the `test-utils` +//! feature. It carries no transport wiring, no network, no canonicalization, +//! and no authorization store (those arrive in later PRs). +//! +//! Constants and tag builders stay reachable under their module paths +//! ([`crate::payments::constants`] / [`crate::payments::tags`]); the wire/config +//! types, traits, and error are also re-exported at this module root for +//! ergonomic crate-level access. + +pub mod constants; +pub mod errors; +pub mod tags; +pub mod traits; +pub mod types; + +#[cfg(feature = "test-utils")] +pub mod fakes; + +pub use errors::PaymentError; +pub use traits::{PaymentHandler, PaymentProcessor, ResolvePrice}; +pub use types::{ + Meta, PaymentAcceptedParams, PaymentHandlerRequest, PaymentInteractionPolicy, PaymentOption, + PaymentPendingErrorData, PaymentProcessorCreateParams, PaymentProcessorVerifyParams, + PaymentRejectedParams, PaymentRequiredErrorData, PaymentRequiredParams, PricedCapability, + ResolvePriceParams, ResolvePriceResult, VerifyOutcome, +}; + +#[cfg(feature = "test-utils")] +pub use fakes::{ + FakePaymentHandler, FakePaymentHandlerOptions, FakePaymentProcessor, + FakePaymentProcessorOptions, +}; diff --git a/src/payments/tags.rs b/src/payments/tags.rs new file mode 100644 index 0000000..52c0426 --- /dev/null +++ b/src/payments/tags.rs @@ -0,0 +1,247 @@ +//! CEP-8 tag builders and single-tag parsers (`cap`, `pmi`, `payment_interaction`). +//! +//! Builders follow the repo idiom `Tag::custom(TagKind::Custom(name.into()), ..)` +//! and parsers the `tag.clone().to_vec()` idiom (see +//! `src/transport/discovery_tags.rs`). The multi-value `extract_pmis` / +//! `extract_payment_interaction` collectors that live in `discovery_tags.rs` +//! build on these single-tag primitives. +//! +//! There is deliberately **no `cap` parser** (ts-sdk parity): `cap-tags.ts` is +//! builder-only and a client reads price from `payment_required.amount` / +//! `payment_options`, never from the advertised `cap` tag. + +use nostr_sdk::prelude::*; + +use crate::core::constants::tags; +use crate::core::types::PaymentInteractionMode; +use crate::payments::types::PricedCapability; + +/// CEP-8 `cap` tags from priced capabilities (order preserved). Omits a capability whose `name` is `None` +/// or whose `method` is not one of `tools/call` / `prompts/get` / `resources/read`. +pub fn cap_tags_from_priced_capabilities(caps: &[PricedCapability]) -> Vec { + caps.iter() + .filter_map(|cap| { + let identifier = cep8_capability_identifier(cap)?; + let price = match cap.max_amount { + Some(max) => format!("{}-{}", cap.amount, max), + None => cap.amount.to_string(), + }; + Some(Tag::custom( + TagKind::Custom(tags::CAPABILITY.into()), + vec![identifier, price, cap.currency_unit.clone()], + )) + }) + .collect() +} + +/// Maps a priced capability to its CEP-8 `cap` identifier (`tool:` / `prompt:` / `resource:` prefix), +/// or `None` for an unnamed capability or an unsupported method. An empty-string name is treated as +/// unnamed (mirrors ts `if (!cap.name)`, where `""` is falsy). +fn cep8_capability_identifier(cap: &PricedCapability) -> Option { + let name = cap.name.as_deref().filter(|n| !n.is_empty())?; + match cap.method.as_str() { + "tools/call" => Some(format!("tool:{name}")), + "prompts/get" => Some(format!("prompt:{name}")), + "resources/read" => Some(format!("resource:{name}")), + _ => None, + } +} + +/// A single `pmi` tag. +pub fn pmi_tag(pmi: &str) -> Tag { + Tag::custom(TagKind::Custom(tags::PMI.into()), vec![pmi.to_string()]) +} + +/// `pmi` tags in server-preference order. Callers pass each processor's `pmi` as `&[String]`. +pub fn pmi_tags(pmis: &[String]) -> Vec { + pmis.iter().map(|p| pmi_tag(p)).collect() +} + +/// A `payment_interaction` tag for the given mode. +pub fn payment_interaction_tag(mode: PaymentInteractionMode) -> Tag { + let value = match mode { + PaymentInteractionMode::Transparent => "transparent", + PaymentInteractionMode::ExplicitGating => "explicit_gating", + }; + Tag::custom( + TagKind::Custom(tags::PAYMENT_INTERACTION.into()), + vec![value.to_string()], + ) +} + +/// Parse a `pmi` tag's value (index 1) if `tag` is a `pmi` tag. +pub fn parse_pmi_tag(tag: &Tag) -> Option { + let parts = tag.clone().to_vec(); + match (parts.first().map(String::as_str), parts.get(1)) { + (Some(tags::PMI), Some(v)) => Some(v.clone()), + _ => None, + } +} + +/// Parse a `payment_interaction` tag into a mode; unknown values yield `None`. +pub fn parse_payment_interaction_tag(tag: &Tag) -> Option { + let parts = tag.clone().to_vec(); + if parts.first().map(String::as_str) != Some(tags::PAYMENT_INTERACTION) { + return None; + } + match parts.get(1).map(String::as_str) { + Some("transparent") => Some(PaymentInteractionMode::Transparent), + Some("explicit_gating") => Some(PaymentInteractionMode::ExplicitGating), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn priced( + method: &str, + name: Option<&str>, + amount: i64, + max_amount: Option, + unit: &str, + ) -> PricedCapability { + PricedCapability { + method: method.to_string(), + name: name.map(String::from), + amount, + max_amount, + currency_unit: unit.to_string(), + description: None, + } + } + + fn parts(tag: &Tag) -> Vec { + tag.clone().to_vec() + } + + fn all_parts(tags: &[Tag]) -> Vec> { + tags.iter().map(parts).collect() + } + + // ── cap builder (exact cap-tags.test.ts vectors) ──────────────── + + #[test] + fn cap_tags_build_tool_prompt_resource_in_order() { + let caps = vec![ + priced("tools/call", Some("add"), 1, None, "sats"), + priced("prompts/get", Some("welcome"), 2, None, "sats"), + priced("resources/read", Some("greeting://alice"), 3, None, "sats"), + ]; + assert_eq!( + all_parts(&cap_tags_from_priced_capabilities(&caps)), + vec![ + vec!["cap", "tool:add", "1", "sats"], + vec!["cap", "prompt:welcome", "2", "sats"], + vec!["cap", "resource:greeting://alice", "3", "sats"], + ] + ); + } + + #[test] + fn cap_tags_use_range_when_max_amount_present() { + let caps = vec![priced("tools/call", Some("add"), 100, Some(1000), "sats")]; + assert_eq!( + all_parts(&cap_tags_from_priced_capabilities(&caps)), + vec![vec!["cap", "tool:add", "100-1000", "sats"]] + ); + } + + #[test] + fn cap_tags_skip_unnamed_empty_name_and_unsupported_methods() { + let caps = vec![ + priced("tools/call", None, 1, None, "sats"), // no name + priced("tools/call", Some(""), 1, None, "sats"), // empty name (ts `!cap.name`) + priced("resources/list", Some("ignored"), 1, None, "sats"), // unsupported method + ]; + assert!(cap_tags_from_priced_capabilities(&caps).is_empty()); + } + + #[test] + fn cap_tags_preserve_order_across_skips() { + let caps = vec![ + priced("tools/call", Some("a"), 1, None, "sats"), + priced("tools/call", None, 9, None, "sats"), // skipped (unnamed) + priced("prompts/get", Some("b"), 2, None, "sats"), + ]; + assert_eq!( + all_parts(&cap_tags_from_priced_capabilities(&caps)), + vec![ + vec!["cap", "tool:a", "1", "sats"], + vec!["cap", "prompt:b", "2", "sats"], + ] + ); + } + + // ── pmi / payment_interaction builders ────────────────────────── + + #[test] + fn pmi_tags_preserve_input_order() { + let pmis = vec!["one".to_string(), "two".to_string()]; + assert_eq!( + all_parts(&pmi_tags(&pmis)), + vec![vec!["pmi", "one"], vec!["pmi", "two"]] + ); + } + + #[test] + fn payment_interaction_tag_renders_both_modes() { + assert_eq!( + parts(&payment_interaction_tag( + PaymentInteractionMode::Transparent + )), + vec!["payment_interaction", "transparent"] + ); + assert_eq!( + parts(&payment_interaction_tag( + PaymentInteractionMode::ExplicitGating + )), + vec!["payment_interaction", "explicit_gating"] + ); + } + + // ── parsers ───────────────────────────────────────────────────── + + #[test] + fn parse_pmi_tag_roundtrips_builder() { + let tag = pmi_tag("bitcoin-lightning-bolt11"); + assert_eq!( + parse_pmi_tag(&tag), + Some("bitcoin-lightning-bolt11".to_string()) + ); + } + + #[test] + fn parse_pmi_tag_rejects_non_pmi_tag() { + let tag = payment_interaction_tag(PaymentInteractionMode::Transparent); + assert_eq!(parse_pmi_tag(&tag), None); + } + + #[test] + fn parse_payment_interaction_tag_roundtrips_both_modes() { + for mode in [ + PaymentInteractionMode::Transparent, + PaymentInteractionMode::ExplicitGating, + ] { + assert_eq!( + parse_payment_interaction_tag(&payment_interaction_tag(mode)), + Some(mode) + ); + } + } + + #[test] + fn parse_payment_interaction_tag_unknown_value_is_none() { + let tag = Tag::custom( + TagKind::Custom(tags::PAYMENT_INTERACTION.into()), + vec!["bogus".to_string()], + ); + assert_eq!(parse_payment_interaction_tag(&tag), None); + } + + #[test] + fn parse_payment_interaction_tag_rejects_non_matching_tag() { + assert_eq!(parse_payment_interaction_tag(&pmi_tag("x")), None); + } +} diff --git a/src/payments/traits.rs b/src/payments/traits.rs new file mode 100644 index 0000000..d4ac035 --- /dev/null +++ b/src/payments/traits.rs @@ -0,0 +1,65 @@ +//! CEP-8 payment traits: [`PaymentProcessor`] (server-side issue/verify), +//! [`PaymentHandler`] (client-side wallet), and [`ResolvePrice`] (server-side +//! dynamic pricing). +//! +//! All three are `Send + Sync` under `#[async_trait]` and object-safe (used as +//! `Arc` on a detached async task, which requires +//! `Send + Sync + 'static`). Fallible methods return `Result<_, PaymentError>`. + +use async_trait::async_trait; + +use crate::payments::errors::PaymentError; +use crate::payments::types::{ + PaymentHandlerRequest, PaymentProcessorCreateParams, PaymentProcessorVerifyParams, + PaymentRequiredParams, ResolvePriceParams, ResolvePriceResult, VerifyOutcome, +}; + +/// Server-side module that issues and verifies payments for one PMI. +#[async_trait] +pub trait PaymentProcessor: Send + Sync { + /// The PMI this processor issues/verifies. + fn pmi(&self) -> &str; + + /// Create a payment request for a specific invocation. + async fn create_payment_required( + &self, + params: PaymentProcessorCreateParams, + ) -> Result; + + /// Wait for / verify settlement for a previously issued `pay_req`. + async fn verify_payment( + &self, + params: PaymentProcessorVerifyParams, + ) -> Result; +} + +/// Client-side module that pays a single PMI in-band (wallet handler). +#[async_trait] +pub trait PaymentHandler: Send + Sync { + /// The PMI this handler supports. + fn pmi(&self) -> &str; + + /// Optional policy gate; the default accepts every request. (TS `canHandle?`.) + async fn can_handle(&self, _req: &PaymentHandlerRequest) -> bool { + true + } + + /// Execute the payment (wallet action). + async fn handle(&self, req: PaymentHandlerRequest) -> Result<(), PaymentError>; +} + +/// Server-side dynamic-pricing callback. `cap` tags are discovery; this decides the final quote. +#[async_trait] +pub trait ResolvePrice: Send + Sync { + /// Resolve the price (or reject/waive) for a priced invocation. + /// + /// Fallible so a resolver doing I/O (a price lookup, a balance check) can + /// surface an operational failure via `Err`, kept distinct from a business + /// decision to refuse service ([`ResolvePriceResult::Reject`]). This mirrors + /// ts `ResolvePriceFn`, whose `Promise` may reject; the payment middleware + /// treats an `Err` as a server-side failure, not a client refusal. + async fn resolve_price( + &self, + params: ResolvePriceParams, + ) -> Result; +} diff --git a/src/payments/types.rs b/src/payments/types.rs new file mode 100644 index 0000000..7b7e7c0 --- /dev/null +++ b/src/payments/types.rs @@ -0,0 +1,532 @@ +//! CEP-8 payment types: wire notification params, explicit-gating error `data`, +//! server-side pricing config, and the trait param / return structs. +//! +//! Wire structs derive `Serialize`/`Deserialize`; every optional carries +//! `#[serde(default, skip_serializing_if = "Option::is_none")]` so a `None` +//! renders byte-identically to TS dropping an `undefined` key. `_meta` is a +//! permissive object and no wire type uses `deny_unknown_fields` (CEP-8 MUST: +//! unknown `_meta`/params keys are ignored). +//! +//! **Struct field order is load-bearing.** serde emits keys in declaration +//! order and these payloads are plain JSON (not JCS-canonicalized), so each +//! struct matches the ts-sdk runtime emitter's object-construction order. Note +//! the deliberate difference between [`PaymentRequiredParams`] (`amount`, +//! **`pay_req`, `pmi`**) and [`PaymentOption`] (`amount`, **`pmi`, `pay_req`**); +//! both mirror their respective ts emitters exactly. + +use serde::{Deserialize, Serialize}; +use tokio_util::sync::CancellationToken; + +use crate::core::types::JsonRpcRequest; + +/// Permissive transparency metadata (`_meta`), a JSON object of arbitrary keys. +/// +/// This is `serde_json::Map`. The crate does not enable `serde_json/preserve_order`, +/// so multi-key `_meta` serializes in **sorted** key order (a `BTreeMap`), which +/// can differ from a JS peer's insertion order. This is cosmetic only: `_meta` is +/// opaque pass-through and never enters a cross-checked hash (the canonical +/// invocation hash strips top-level `params._meta` and JCS re-sorts regardless; +/// notifications are never canonicalized). If strict multi-key `_meta` byte-parity +/// with ts is ever required, enable `serde_json/preserve_order`. +pub type Meta = serde_json::Map; + +// ── Config / pricing ──────────────────────────────────────────────── + +/// Server-side pricing metadata for one capability pattern (config, not a wire type). +/// +/// The `cap` tag is this struct's wire form (see [`crate::payments::tags`]), so +/// it carries no serde/field-order contract. +#[derive(Debug, Clone)] +pub struct PricedCapability { + /// JSON-RPC method, e.g. `"tools/call"`. Unknown methods yield no `cap` tag. + pub method: String, + /// Capability name (tool/prompt name, resource uri). `None` yields no `cap` tag. + pub name: Option, + /// Amount for this invocation. + pub amount: i64, + /// Optional upper bound; when set the `cap` price is `"-"`. + pub max_amount: Option, + /// Currency/unit label for display and the `cap` tag (e.g. `"sats"`). + pub currency_unit: String, + /// Optional human-readable description for the payment request. + pub description: Option, +} + +/// Server policy for which payment lifecycles it accepts (config; mirrors the OPTIONAL enc/giftwrap pattern). +/// +/// Wire values are `"optional"` / `"transparent"`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PaymentInteractionPolicy { + /// Accept both lifecycles; mirror the client's requested mode (default). + #[default] + Optional, + /// Transparent-only; reject `explicit_gating` with `-32602`. + Transparent, +} + +// ── Transparent-lifecycle notification params ─────────────────────── + +/// `notifications/payment_required` params. Emitter key order: amount, pay_req, pmi, description, ttl, _meta. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PaymentRequiredParams { + /// Amount to pay, in the capability's currency unit. + pub amount: i64, + /// Opaque payment request (e.g. a BOLT11 invoice) the client settles. + pub pay_req: String, + /// Payment method identifier this request is denominated in. + pub pmi: String, + /// Optional human-readable description of the charge. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Optional time-to-live in seconds before the request expires. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ttl: Option, + /// Optional transparency metadata (`_meta`), passed through opaquely. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// `notifications/payment_accepted` params. Emitter key order: amount, pmi, _meta. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PaymentAcceptedParams { + /// Amount that was settled. + pub amount: i64, + /// Payment method identifier that settled. + pub pmi: String, + /// Optional settlement metadata (`_meta`) from `verify_payment`. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// `notifications/payment_rejected` params. Emitter key order: pmi, amount, message. (No `_meta` in TS.) +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PaymentRejectedParams { + /// Payment method identifier that was rejected. + pub pmi: String, + /// Optional amount that was rejected. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub amount: Option, + /// Optional human-readable rejection reason. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +// ── Explicit-gating error `data` ──────────────────────────────────── + +/// One entry in a `-32042` `payment_options`. Emitter key order: amount, pmi, pay_req, description, ttl, _meta. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PaymentOption { + /// Amount to pay for this option. + pub amount: i64, + /// Payment method identifier for this option. + pub pmi: String, + /// Opaque payment request the client settles for this option. + pub pay_req: String, + /// Optional human-readable description of this option. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Optional time-to-live in seconds for this option. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ttl: Option, + /// Optional transparency metadata (`_meta`) for this option. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// `error.data` for `-32042 Payment Required`. Emitter key order: instructions, payment_options. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PaymentRequiredErrorData { + /// Optional human-readable payment instructions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instructions: Option, + /// One or more concrete payment options (at least one per CEP-8). + pub payment_options: Vec, +} + +/// `error.data` for `-32043 Payment Pending`. Emitter key order: instructions, retry_after. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PaymentPendingErrorData { + /// Optional human-readable pending instructions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instructions: Option, + /// Optional seconds the client SHOULD wait before retrying. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retry_after: Option, +} + +// ── Trait param / return structs (internal; not wire types) ───────── + +/// Input to [`PaymentProcessor::create_payment_required`](crate::payments::PaymentProcessor::create_payment_required). +#[derive(Debug, Clone)] +pub struct PaymentProcessorCreateParams { + /// Amount to charge for this invocation. + pub amount: i64, + /// Optional description to carry into `payment_required`. + pub description: Option, + /// Correlating Nostr event id of the gated request. + pub request_event_id: String, + /// Caller (client) pubkey, hex. + pub client_pubkey: String, +} + +/// Input to [`PaymentProcessor::verify_payment`](crate::payments::PaymentProcessor::verify_payment). +/// `cancel` is a per-verify child token (timeout / shutdown). +#[derive(Debug, Clone)] +pub struct PaymentProcessorVerifyParams { + /// The `pay_req` previously issued for this invocation. + pub pay_req: String, + /// Correlating Nostr event id of the gated request. + pub request_event_id: String, + /// Caller (client) pubkey, hex. + pub client_pubkey: String, + /// Per-verify cancellation (timeout / shutdown); a child of the transport token. + pub cancel: CancellationToken, +} + +/// Outcome of [`PaymentProcessor::verify_payment`](crate::payments::PaymentProcessor::verify_payment); +/// `meta` flows into `payment_accepted._meta`. +#[derive(Debug, Clone, Default)] +pub struct VerifyOutcome { + /// Optional settlement metadata attached to `payment_accepted`. + pub meta: Option, +} + +/// Input to [`PaymentHandler`](crate::payments::PaymentHandler) (client-side wallet action). +/// Mirrors `payment_required` plus the event id. +#[derive(Debug, Clone)] +pub struct PaymentHandlerRequest { + /// Amount to pay. + pub amount: i64, + /// Opaque payment request to settle. + pub pay_req: String, + /// Payment method identifier. + pub pmi: String, + /// Optional description from the server. + pub description: Option, + /// Optional time-to-live in seconds from the server. + pub ttl: Option, + /// Optional transparency metadata from the server's `payment_required._meta`. + pub meta: Option, + /// Correlating Nostr event id of the request being paid. + pub request_event_id: String, +} + +/// Input to [`ResolvePrice::resolve_price`](crate::payments::ResolvePrice::resolve_price). +#[derive(Debug, Clone)] +pub struct ResolvePriceParams { + /// The matched priced-capability config. + pub capability: PricedCapability, + /// The full inbound request being priced. + pub request: JsonRpcRequest, + /// Caller (client) pubkey, hex. + pub client_pubkey: String, + /// Correlating Nostr event id. + pub request_event_id: String, +} + +/// The dynamic-pricing decision. Modeling this as an enum (not a `reject: true` / +/// `waive: true` union) removes the TS discriminant-typo footgun that +/// `rejectPrice` / `waivePrice` guard against. Never (de)serialized (internal +/// callback return). +#[derive(Debug, Clone)] +pub enum ResolvePriceResult { + /// Charge this invocation. `meta` rides `payment_required.params._meta`. + Quote { + /// Final amount to charge. + amount: i64, + /// Optional description override. + description: Option, + /// Optional transparency metadata. + meta: Option, + }, + /// Reject without asking for payment (emits `payment_rejected` / `-32000`). + Reject { + /// Optional human-readable rejection reason. + message: Option, + }, + /// Waive/cover; forward immediately. `meta` rides `payment_accepted._meta` if emitted. + Waive { + /// Optional transparency metadata (e.g. remaining balance). + meta: Option, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn one_key_meta() -> Meta { + let mut m = Meta::new(); + m.insert("k".to_string(), serde_json::Value::String("v".to_string())); + m + } + + fn to_json(v: &T) -> String { + serde_json::to_string(v).unwrap() + } + + // ── PaymentRequiredParams: amount, pay_req, pmi, description, ttl, _meta ── + + #[test] + fn payment_required_params_drops_none_optionals() { + let p = PaymentRequiredParams { + amount: 1, + pay_req: "inv".to_string(), + pmi: "bitcoin-lightning-bolt11".to_string(), + description: None, + ttl: None, + meta: None, + }; + assert_eq!( + to_json(&p), + r#"{"amount":1,"pay_req":"inv","pmi":"bitcoin-lightning-bolt11"}"# + ); + } + + #[test] + fn payment_required_params_full_shape_and_field_order() { + let p = PaymentRequiredParams { + amount: 1, + pay_req: "inv".to_string(), + pmi: "x".to_string(), + description: Some("d".to_string()), + ttl: Some(600), + meta: Some(one_key_meta()), + }; + assert_eq!( + to_json(&p), + r#"{"amount":1,"pay_req":"inv","pmi":"x","description":"d","ttl":600,"_meta":{"k":"v"}}"# + ); + } + + #[test] + fn payment_required_params_roundtrips_and_ignores_unknown_keys() { + let json = r#"{"amount":7,"pay_req":"r","pmi":"m","ttl":1,"unknown":"ignored"}"#; + let p: PaymentRequiredParams = serde_json::from_str(json).unwrap(); + assert_eq!(p.amount, 7); + assert_eq!(p.pay_req, "r"); + assert_eq!(p.pmi, "m"); + assert_eq!(p.ttl, Some(1)); + assert!(p.description.is_none()); + let back: PaymentRequiredParams = serde_json::from_str(&to_json(&p)).unwrap(); + assert_eq!(p, back); + } + + // ── PaymentAcceptedParams: amount, pmi, _meta ─────────────────── + + #[test] + fn payment_accepted_params_shapes() { + let none = PaymentAcceptedParams { + amount: 5, + pmi: "x".to_string(), + meta: None, + }; + assert_eq!(to_json(&none), r#"{"amount":5,"pmi":"x"}"#); + + let mut meta = Meta::new(); + meta.insert("settled".to_string(), serde_json::Value::Bool(true)); + let full = PaymentAcceptedParams { + amount: 5, + pmi: "x".to_string(), + meta: Some(meta), + }; + assert_eq!( + to_json(&full), + r#"{"amount":5,"pmi":"x","_meta":{"settled":true}}"# + ); + } + + // ── PaymentRejectedParams: pmi, amount, message ───────────────── + + #[test] + fn payment_rejected_params_shapes_and_order() { + let min = PaymentRejectedParams { + pmi: "x".to_string(), + amount: None, + message: None, + }; + assert_eq!(to_json(&min), r#"{"pmi":"x"}"#); + + let full = PaymentRejectedParams { + pmi: "x".to_string(), + amount: Some(5), + message: Some("nope".to_string()), + }; + assert_eq!(to_json(&full), r#"{"pmi":"x","amount":5,"message":"nope"}"#); + } + + // ── PaymentOption: amount, pmi, pay_req (the deliberate flip) ──── + + #[test] + fn payment_option_field_order_flips_pmi_before_pay_req() { + let min = PaymentOption { + amount: 1, + pmi: "x".to_string(), + pay_req: "r".to_string(), + description: None, + ttl: None, + meta: None, + }; + assert_eq!(to_json(&min), r#"{"amount":1,"pmi":"x","pay_req":"r"}"#); + + let full = PaymentOption { + amount: 1, + pmi: "x".to_string(), + pay_req: "r".to_string(), + description: Some("d".to_string()), + ttl: Some(600), + meta: Some(one_key_meta()), + }; + assert_eq!( + to_json(&full), + r#"{"amount":1,"pmi":"x","pay_req":"r","description":"d","ttl":600,"_meta":{"k":"v"}}"# + ); + } + + // ── error data ────────────────────────────────────────────────── + + #[test] + fn payment_required_error_data_shapes() { + let opt = PaymentOption { + amount: 1, + pmi: "x".to_string(), + pay_req: "r".to_string(), + description: None, + ttl: None, + meta: None, + }; + let no_instructions = PaymentRequiredErrorData { + instructions: None, + payment_options: vec![opt.clone()], + }; + assert_eq!( + to_json(&no_instructions), + r#"{"payment_options":[{"amount":1,"pmi":"x","pay_req":"r"}]}"# + ); + + let with_instructions = PaymentRequiredErrorData { + instructions: Some("pay".to_string()), + payment_options: vec![opt], + }; + assert_eq!( + to_json(&with_instructions), + r#"{"instructions":"pay","payment_options":[{"amount":1,"pmi":"x","pay_req":"r"}]}"# + ); + } + + #[test] + fn payment_pending_error_data_shapes() { + let empty = PaymentPendingErrorData { + instructions: None, + retry_after: None, + }; + assert_eq!(to_json(&empty), r#"{}"#); + + let full = PaymentPendingErrorData { + instructions: Some("wait".to_string()), + retry_after: Some(2), + }; + assert_eq!(to_json(&full), r#"{"instructions":"wait","retry_after":2}"#); + } + + // ── PaymentInteractionPolicy: "optional" / "transparent" ──────── + + #[test] + fn payment_interaction_policy_roundtrips() { + assert_eq!( + to_json(&PaymentInteractionPolicy::Optional), + r#""optional""# + ); + assert_eq!( + to_json(&PaymentInteractionPolicy::Transparent), + r#""transparent""# + ); + assert_eq!( + PaymentInteractionPolicy::default(), + PaymentInteractionPolicy::Optional + ); + let parsed: PaymentInteractionPolicy = serde_json::from_str(r#""transparent""#).unwrap(); + assert_eq!(parsed, PaymentInteractionPolicy::Transparent); + } + + // ── u64 fail-closed reject tests (deliberate ts divergence) ── + // + // ts types `ttl`/`retry_after` as `number`, so a peer MAY send `-1` or + // `1.5`; rs `u64` rejects both at deserialize. The client-inbound path + // must handle this `Err` rather than unwrap. `amount` (i64) shares the + // fail-closed property for fractional values. + + #[test] + fn ttl_negative_fails_to_deserialize() { + let json = r#"{"amount":1,"pay_req":"r","pmi":"x","ttl":-1}"#; + assert!(serde_json::from_str::(json).is_err()); + } + + #[test] + fn ttl_fractional_fails_to_deserialize() { + let json = r#"{"amount":1,"pay_req":"r","pmi":"x","ttl":1.5}"#; + assert!(serde_json::from_str::(json).is_err()); + } + + #[test] + fn retry_after_negative_fails_to_deserialize() { + assert!(serde_json::from_str::(r#"{"retry_after":-1}"#).is_err()); + } + + #[test] + fn retry_after_fractional_fails_to_deserialize() { + assert!(serde_json::from_str::(r#"{"retry_after":1.5}"#).is_err()); + } + + #[test] + fn amount_fractional_fails_to_deserialize() { + // `amount` is i64, so a fractional amount fails closed at + // deserialize just like a fractional `ttl`. (A negative amount is a + // valid i64 and is intentionally NOT rejected.) + let json = r#"{"amount":1.5,"pay_req":"r","pmi":"x"}"#; + assert!(serde_json::from_str::(json).is_err()); + } + + // ── _meta ordering (pinned; see the `Meta` doc) ───────────────── + + #[test] + fn multi_key_meta_serializes_in_sorted_order() { + // `preserve_order` is off, so `Meta` is a BTreeMap: keys emit sorted, + // not in insertion order. This is cosmetic (never enters a hash) but is + // pinned so a future change to insertion order is a conscious decision. + let mut meta = Meta::new(); + meta.insert("zeta".to_string(), serde_json::Value::Bool(true)); + meta.insert("alpha".to_string(), serde_json::Value::from(1)); + let p = PaymentAcceptedParams { + amount: 5, + pmi: "x".to_string(), + meta: Some(meta), + }; + assert_eq!( + to_json(&p), + r#"{"amount":5,"pmi":"x","_meta":{"alpha":1,"zeta":true}}"# + ); + } + + // ── ResolvePriceResult (never on the wire; smoke-test the variants) ── + + #[test] + fn resolve_price_result_variants_construct_and_match() { + let quote = ResolvePriceResult::Quote { + amount: 42, + description: Some("d".to_string()), + meta: None, + }; + let reject = ResolvePriceResult::Reject { + message: Some("no".to_string()), + }; + let waive = ResolvePriceResult::Waive { meta: None }; + assert!(matches!( + quote, + ResolvePriceResult::Quote { amount: 42, .. } + )); + assert!(matches!(reject, ResolvePriceResult::Reject { .. })); + assert!(matches!(waive, ResolvePriceResult::Waive { meta: None })); + } +}