radrootsd

JSON-RPC bridge for Radroots event publishing
git clone https://radroots.dev/git/radrootsd.git
Log | Files | Refs | README | LICENSE

commit 007cc6f4fe32e4571b8ddd8f7f953a7b99af5286
parent ea5f814de4cc3af9ea1e0456b2b38828dc0840cb
Author: triesap <tyson@radroots.org>
Date:   Thu,  2 Apr 2026 21:39:30 +0000

bridge: complete agricultural trade write plane

- add canonical bridge methods for public agricultural trade lifecycle events
- enforce listing snapshot and trade chain invariants before bridge signing
- allow draft listing publish and fingerprint listing kind in bridge requests
- cover the expanded bridge surface with local radrootsd nix validation

Diffstat:
Msrc/transport/jsonrpc/methods/bridge/listing_publish.rs | 86++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Msrc/transport/jsonrpc/methods/bridge/mod.rs | 2++
Asrc/transport/jsonrpc/methods/bridge/public_trade.rs | 809+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/transport/jsonrpc/methods/mod.rs | 13+++++++++++++
4 files changed, 901 insertions(+), 9 deletions(-)

diff --git a/src/transport/jsonrpc/methods/bridge/listing_publish.rs b/src/transport/jsonrpc/methods/bridge/listing_publish.rs @@ -1,12 +1,13 @@ use anyhow::Result; use jsonrpsee::server::RpcModule; use radroots_events::RadrootsNostrEvent; +use radroots_events::kinds::{KIND_LISTING, KIND_LISTING_DRAFT, is_listing_kind}; use radroots_events::listing::RadrootsListing; -use radroots_events_codec::listing::encode::to_wire_parts; +use radroots_events_codec::listing::encode::to_wire_parts_with_kind; use radroots_events_codec::wire::WireEventParts; use radroots_nostr::prelude::radroots_nostr_build_event; use radroots_trade::listing::validation::validate_listing_event; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::core::bridge::publish::{ @@ -25,11 +26,19 @@ use crate::transport::jsonrpc::{MethodRegistry, RpcContext, RpcError}; struct BridgeListingPublishParams { listing: RadrootsListing, #[serde(default)] + kind: Option<u32>, + #[serde(default)] signer_session_id: Option<String>, #[serde(default)] idempotency_key: Option<String>, } +#[derive(Debug, Clone, Serialize)] +struct CanonicalBridgeListingPublishRequest { + kind: u32, + listing: RadrootsListing, +} + pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { registry.track("bridge.listing.publish"); m.register_async_method( @@ -52,15 +61,20 @@ async fn publish_listing( ) -> Result<BridgePublishResponse, RpcError> { ensure_bridge_enabled(&ctx)?; let idempotency_key = normalize_idempotency_key(params.idempotency_key)?; - let signer = resolve_bridge_signer(&ctx, params.signer_session_id.as_deref(), 30402).await?; + let kind = resolve_listing_kind(params.kind)?; + let signer = resolve_bridge_signer(&ctx, params.signer_session_id.as_deref(), kind).await?; let signer_pubkey = signer.signer_pubkey_hex(); let listing = canonicalize_listing_for_signer(params.listing, signer_pubkey.as_str()); + let canonical = CanonicalBridgeListingPublishRequest { kind, listing }; let request_fingerprint = - fingerprint_bridge_request("bridge.listing.publish", &signer, &listing)?; - let parts = to_wire_parts(&listing) + fingerprint_bridge_request("bridge.listing.publish", &signer, &canonical)?; + let parts = to_wire_parts_with_kind(&canonical.listing, canonical.kind) .map_err(|error| RpcError::InvalidParams(format!("invalid listing contract: {error}")))?; - let validated = - validate_canonical_listing_contract_for_signer(&listing, signer_pubkey.as_str(), &parts)?; + let validated = validate_canonical_listing_contract_for_signer( + &canonical.listing, + signer_pubkey.as_str(), + &parts, + )?; let builder = radroots_nostr_build_event(parts.kind, parts.content, parts.tags) .map_err(|error| RpcError::Other(format!("failed to build listing event: {error}")))?; @@ -121,6 +135,16 @@ async fn publish_listing( }) } +fn resolve_listing_kind(kind: Option<u32>) -> Result<u32, RpcError> { + let kind = kind.unwrap_or(KIND_LISTING); + if !is_listing_kind(kind) { + return Err(RpcError::InvalidParams(format!( + "listing kind must be {KIND_LISTING} or {KIND_LISTING_DRAFT}" + ))); + } + Ok(kind) +} + fn canonicalize_listing_for_signer( mut listing: RadrootsListing, signer_pubkey: &str, @@ -157,12 +181,13 @@ mod tests { RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, }; + use radroots_events::kinds::{KIND_LISTING, KIND_LISTING_DRAFT}; use radroots_events::listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, RadrootsListingDeliveryMethod, RadrootsListingFarmRef, RadrootsListingLocation, RadrootsListingProduct, }; - use radroots_events_codec::listing::encode::to_wire_parts; + use radroots_events_codec::listing::encode::to_wire_parts_with_kind; use radroots_identity::RadrootsIdentity; use radroots_nostr::prelude::RadrootsNostrMetadata; @@ -186,7 +211,7 @@ mod tests { let listing = canonicalize_listing_for_signer(base_listing(), "abc123"); let mut invalid = listing.clone(); invalid.farm.pubkey = "other".to_string(); - let parts = to_wire_parts(&invalid).expect("wire parts"); + let parts = to_wire_parts_with_kind(&invalid, KIND_LISTING).expect("wire parts"); let err = validate_canonical_listing_contract_for_signer(&invalid, "abc123", &parts).unwrap_err(); assert!(err.to_string().contains("invalid listing contract")); @@ -211,6 +236,7 @@ mod tests { let ctx = RpcContext::new(state, MethodRegistry::default()); let params = BridgeListingPublishParams { listing: base_listing(), + kind: None, signer_session_id: None, idempotency_key: Some("same-key".to_string()), }; @@ -224,6 +250,7 @@ mod tests { ctx, BridgeListingPublishParams { listing: base_listing(), + kind: None, signer_session_id: None, idempotency_key: Some("same-key".to_string()), }, @@ -258,6 +285,7 @@ mod tests { ctx.clone(), BridgeListingPublishParams { listing, + kind: None, signer_session_id: None, idempotency_key: Some("bad-listing".to_string()), }, @@ -268,6 +296,46 @@ mod tests { assert_eq!(ctx.state.bridge_jobs.snapshot().retained_jobs, 0); } + #[tokio::test] + async fn publish_listing_allows_draft_kind() { + let identity = RadrootsIdentity::generate(); + let metadata: RadrootsNostrMetadata = + serde_json::from_str(r#"{"name":"radrootsd-test"}"#).expect("metadata"); + let state = Radrootsd::new( + identity, + metadata, + BridgeConfig { + enabled: true, + bearer_token: Some("secret".to_string()), + ..BridgeConfig::default() + }, + Nip46Config::default(), + ) + .expect("state"); + let ctx = RpcContext::new(state, MethodRegistry::default()); + + let response = publish_listing( + ctx, + BridgeListingPublishParams { + listing: base_listing(), + kind: Some(KIND_LISTING_DRAFT), + signer_session_id: None, + idempotency_key: Some("draft-kind".to_string()), + }, + ) + .await + .expect("draft listing"); + + assert_eq!(response.job.event_kind, KIND_LISTING_DRAFT); + assert!( + response + .job + .event_addr + .as_deref() + .is_some_and(|addr| addr.starts_with("30403:")) + ); + } + fn base_listing() -> RadrootsListing { RadrootsListing { d_tag: "AAAAAAAAAAAAAAAAAAAAAg".to_string(), diff --git a/src/transport/jsonrpc/methods/bridge/mod.rs b/src/transport/jsonrpc/methods/bridge/mod.rs @@ -6,6 +6,7 @@ use crate::transport::jsonrpc::{MethodRegistry, RpcContext}; mod job_status; mod listing_publish; mod order_request; +mod public_trade; mod shared; mod status; @@ -15,5 +16,6 @@ pub fn module(ctx: RpcContext, registry: MethodRegistry) -> Result<RpcModule<Rpc job_status::register(&mut m, &registry)?; listing_publish::register(&mut m, &registry)?; order_request::register(&mut m, &registry)?; + public_trade::register(&mut m, &registry)?; Ok(m) } diff --git a/src/transport/jsonrpc/methods/bridge/public_trade.rs b/src/transport/jsonrpc/methods/bridge/public_trade.rs @@ -0,0 +1,809 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use radroots_events::RadrootsNostrEventPtr; +use radroots_events::kinds::KIND_LISTING; +use radroots_events::trade::{ + RadrootsTradeDiscountDecision as TradeDiscountDecision, + RadrootsTradeMessagePayload as TradeListingMessagePayload, + RadrootsTradeMessageType as TradeListingMessageType, +}; +use radroots_events_codec::trade::{ + RadrootsTradeListingAddress as TradeListingAddress, + trade_envelope_event_build as trade_listing_envelope_event_build, +}; +use radroots_nostr::prelude::{ + radroots_event_from_nostr, radroots_nostr_build_event, radroots_nostr_fetch_event_by_id, + radroots_nostr_parse_pubkey, +}; +use radroots_trade::listing::validation::validate_listing_event; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use uuid::Uuid; + +use crate::core::bridge::publish::{ + BridgePublishSettings, connect_and_publish_event, failed_prepublish_execution, +}; +use crate::core::bridge::store::new_publish_job; +use crate::transport::jsonrpc::auth::require_bridge_auth; +use crate::transport::jsonrpc::methods::bridge::shared::{ + BridgePublishResponse, ensure_bridge_enabled, fingerprint_bridge_request, + normalize_idempotency_key, reserve_bridge_job, resolve_bridge_signer, + sign_bridge_event_builder, +}; +use crate::transport::jsonrpc::{MethodRegistry, RpcContext, RpcError}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct BridgePublicTradeParams<T> { + listing_addr: String, + order_id: String, + counterparty_pubkey: String, + #[serde(default)] + listing_event: Option<RadrootsNostrEventPtr>, + #[serde(default)] + root_event_id: Option<String>, + #[serde(default)] + prev_event_id: Option<String>, + payload: T, + #[serde(default)] + signer_session_id: Option<String>, + #[serde(default)] + idempotency_key: Option<String>, +} + +#[derive(Debug, Clone, Serialize)] +struct CanonicalBridgePublicTradeRequest<T> { + listing_addr: String, + order_id: String, + counterparty_pubkey: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + listing_event: Option<RadrootsNostrEventPtr>, + #[serde(default, skip_serializing_if = "Option::is_none")] + root_event_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + prev_event_id: Option<String>, + payload: T, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ExpectedPublicTradeAuthor { + Buyer, + Seller, + Either, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + register_public_trade_method( + m, + registry, + "bridge.order.response", + TradeListingMessageType::OrderResponse, + TradeListingMessagePayload::OrderResponse, + )?; + register_public_trade_method( + m, + registry, + "bridge.order.revision", + TradeListingMessageType::OrderRevision, + TradeListingMessagePayload::OrderRevision, + )?; + register_public_trade_method( + m, + registry, + "bridge.order.revision.accept", + TradeListingMessageType::OrderRevisionAccept, + TradeListingMessagePayload::OrderRevisionAccept, + )?; + register_public_trade_method( + m, + registry, + "bridge.order.revision.decline", + TradeListingMessageType::OrderRevisionDecline, + TradeListingMessagePayload::OrderRevisionDecline, + )?; + register_public_trade_method( + m, + registry, + "bridge.order.question", + TradeListingMessageType::Question, + TradeListingMessagePayload::Question, + )?; + register_public_trade_method( + m, + registry, + "bridge.order.answer", + TradeListingMessageType::Answer, + TradeListingMessagePayload::Answer, + )?; + register_public_trade_method( + m, + registry, + "bridge.order.discount.request", + TradeListingMessageType::DiscountRequest, + TradeListingMessagePayload::DiscountRequest, + )?; + register_public_trade_method( + m, + registry, + "bridge.order.discount.offer", + TradeListingMessageType::DiscountOffer, + TradeListingMessagePayload::DiscountOffer, + )?; + register_public_trade_method( + m, + registry, + "bridge.order.discount.accept", + TradeListingMessageType::DiscountAccept, + TradeListingMessagePayload::DiscountAccept, + )?; + register_public_trade_method( + m, + registry, + "bridge.order.discount.decline", + TradeListingMessageType::DiscountDecline, + TradeListingMessagePayload::DiscountDecline, + )?; + register_public_trade_method( + m, + registry, + "bridge.order.cancel", + TradeListingMessageType::Cancel, + TradeListingMessagePayload::Cancel, + )?; + register_public_trade_method( + m, + registry, + "bridge.order.fulfillment.update", + TradeListingMessageType::FulfillmentUpdate, + TradeListingMessagePayload::FulfillmentUpdate, + )?; + register_public_trade_method( + m, + registry, + "bridge.order.receipt", + TradeListingMessageType::Receipt, + TradeListingMessagePayload::Receipt, + )?; + Ok(()) +} + +fn register_public_trade_method<T>( + m: &mut RpcModule<RpcContext>, + registry: &MethodRegistry, + method_name: &'static str, + message_type: TradeListingMessageType, + payload_into: fn(T) -> TradeListingMessagePayload, +) -> Result<()> +where + T: DeserializeOwned + Serialize + Clone + Send + Sync + 'static, +{ + registry.track(method_name); + m.register_async_method(method_name, move |params, ctx, extensions| async move { + require_bridge_auth(&extensions)?; + let params: BridgePublicTradeParams<T> = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + let response = publish_public_trade( + ctx.as_ref().clone(), + method_name, + message_type, + params, + payload_into, + ) + .await?; + Ok::<BridgePublishResponse, RpcError>(response) + })?; + Ok(()) +} + +async fn publish_public_trade<T>( + ctx: RpcContext, + command: &'static str, + message_type: TradeListingMessageType, + params: BridgePublicTradeParams<T>, + payload_into: fn(T) -> TradeListingMessagePayload, +) -> Result<BridgePublishResponse, RpcError> +where + T: Serialize + Clone, +{ + ensure_bridge_enabled(&ctx)?; + + let idempotency_key = normalize_idempotency_key(params.idempotency_key.clone())?; + let signer = resolve_bridge_signer( + &ctx, + params.signer_session_id.as_deref(), + message_type.kind(), + ) + .await?; + let signer_pubkey = signer.signer_pubkey_hex(); + let (mut canonical, listing_addr) = + canonicalize_public_trade_params(params, signer_pubkey.as_str(), message_type)?; + canonical.listing_event = + resolve_listing_snapshot(&ctx, &listing_addr, message_type, canonical.listing_event) + .await?; + + let request_fingerprint = fingerprint_bridge_request(command, &signer, &canonical)?; + let payload = payload_into(canonical.payload.clone()); + validate_payload_for_message_type(&payload, message_type)?; + let built = trade_listing_envelope_event_build( + canonical.counterparty_pubkey.clone(), + message_type, + canonical.listing_addr.clone(), + Some(canonical.order_id.clone()), + canonical.listing_event.as_ref(), + canonical.root_event_id.as_deref(), + canonical.prev_event_id.as_deref(), + &payload, + ) + .map_err(|error| RpcError::InvalidParams(format!("invalid {command} envelope: {error}")))?; + let builder = radroots_nostr_build_event(built.kind, built.content, built.tags) + .map_err(|error| RpcError::Other(format!("failed to build {command} event: {error}")))?; + + let reserved = reserve_bridge_job( + &ctx, + new_publish_job( + command, + Uuid::new_v4().to_string(), + idempotency_key, + signer.signer_mode(), + message_type.kind(), + None, + Some(canonical.listing_addr.clone()), + ctx.state.bridge_config.delivery_policy, + ctx.state.bridge_config.delivery_quorum, + ), + request_fingerprint, + command, + )?; + let job = match reserved { + crate::core::bridge::store::BridgeJobReservation::Accepted(job) => job, + crate::core::bridge::store::BridgeJobReservation::Duplicate(existing) => { + return Ok(BridgePublishResponse { + deduplicated: true, + job: existing.into(), + }); + } + }; + + let publish_settings = BridgePublishSettings::from_config(&ctx.state.bridge_config); + let event = match sign_bridge_event_builder(&ctx, &signer, builder, command).await { + Ok(event) => event, + Err(error) => { + let _ = ctx.state.bridge_jobs.complete( + &job.job_id, + None, + failed_prepublish_execution(&publish_settings, error.to_string()), + ); + return Err(error); + } + }; + + let execution = connect_and_publish_event(&ctx.state.client, &publish_settings, &event).await; + let job = ctx + .state + .bridge_jobs + .complete(&job.job_id, Some(event.id.to_hex()), execution) + .map_err(|error| RpcError::Other(format!("failed to persist {command} job: {error}")))? + .ok_or_else(|| RpcError::Other("bridge job disappeared during completion".to_string()))?; + + Ok(BridgePublishResponse { + deduplicated: false, + job: job.into(), + }) +} + +fn canonicalize_public_trade_params<T>( + params: BridgePublicTradeParams<T>, + signer_pubkey: &str, + message_type: TradeListingMessageType, +) -> Result<(CanonicalBridgePublicTradeRequest<T>, TradeListingAddress), RpcError> { + let listing_addr = normalized_required_string(params.listing_addr, "listing_addr")?; + let parsed_listing_addr = TradeListingAddress::parse(&listing_addr) + .map_err(|error| RpcError::InvalidParams(format!("invalid listing_addr: {error}")))?; + if u32::from(parsed_listing_addr.kind) != KIND_LISTING { + return Err(RpcError::InvalidParams( + "listing_addr must reference a public NIP-99 listing".to_string(), + )); + } + + let order_id = normalized_required_string(params.order_id, "order_id")?; + let counterparty_pubkey = + normalized_required_string(params.counterparty_pubkey, "counterparty_pubkey")?; + radroots_nostr_parse_pubkey(&counterparty_pubkey).map_err(|error| { + RpcError::InvalidParams(format!("invalid counterparty_pubkey: {error}")) + })?; + + if counterparty_pubkey == signer_pubkey { + return Err(RpcError::InvalidParams( + "counterparty_pubkey must not match the requested bridge signer identity".to_string(), + )); + } + + validate_expected_author( + &parsed_listing_addr, + message_type, + signer_pubkey, + &counterparty_pubkey, + )?; + + let listing_event = if message_type.requires_listing_snapshot() { + Some(normalize_listing_event_ptr( + params.listing_event.ok_or_else(|| { + RpcError::InvalidParams( + "listing_event is required for this trade message".to_string(), + ) + })?, + )?) + } else { + None + }; + + let (root_event_id, prev_event_id) = if message_type.requires_trade_chain() { + ( + Some(normalized_required_string( + params.root_event_id.unwrap_or_default(), + "root_event_id", + )?), + Some(normalized_required_string( + params.prev_event_id.unwrap_or_default(), + "prev_event_id", + )?), + ) + } else { + (None, None) + }; + + Ok(( + CanonicalBridgePublicTradeRequest { + listing_addr, + order_id, + counterparty_pubkey, + listing_event, + root_event_id, + prev_event_id, + payload: params.payload, + }, + parsed_listing_addr, + )) +} + +fn validate_expected_author( + listing_addr: &TradeListingAddress, + message_type: TradeListingMessageType, + signer_pubkey: &str, + counterparty_pubkey: &str, +) -> Result<(), RpcError> { + match expected_author(message_type) { + ExpectedPublicTradeAuthor::Seller => { + if signer_pubkey != listing_addr.seller_pubkey { + return Err(RpcError::InvalidParams(format!( + "{message_type:?} must be authored by the listing seller" + ))); + } + } + ExpectedPublicTradeAuthor::Buyer => { + if signer_pubkey == listing_addr.seller_pubkey { + return Err(RpcError::InvalidParams(format!( + "{message_type:?} must be authored by the buyer, not the listing seller" + ))); + } + if counterparty_pubkey != listing_addr.seller_pubkey { + return Err(RpcError::InvalidParams( + "counterparty_pubkey must match the listing seller for buyer-authored trade messages" + .to_string(), + )); + } + } + ExpectedPublicTradeAuthor::Either => {} + } + Ok(()) +} + +fn expected_author(message_type: TradeListingMessageType) -> ExpectedPublicTradeAuthor { + match message_type { + TradeListingMessageType::OrderResponse + | TradeListingMessageType::OrderRevision + | TradeListingMessageType::Answer + | TradeListingMessageType::DiscountOffer + | TradeListingMessageType::FulfillmentUpdate => ExpectedPublicTradeAuthor::Seller, + TradeListingMessageType::OrderRequest + | TradeListingMessageType::OrderRevisionAccept + | TradeListingMessageType::OrderRevisionDecline + | TradeListingMessageType::Question + | TradeListingMessageType::DiscountRequest + | TradeListingMessageType::DiscountAccept + | TradeListingMessageType::DiscountDecline + | TradeListingMessageType::Receipt => ExpectedPublicTradeAuthor::Buyer, + TradeListingMessageType::Cancel => ExpectedPublicTradeAuthor::Either, + TradeListingMessageType::ListingValidateRequest + | TradeListingMessageType::ListingValidateResult => ExpectedPublicTradeAuthor::Either, + } +} + +fn normalize_listing_event_ptr( + ptr: RadrootsNostrEventPtr, +) -> Result<RadrootsNostrEventPtr, RpcError> { + if ptr.id.trim().is_empty() { + return Err(RpcError::InvalidParams( + "listing_event.id cannot be empty".to_string(), + )); + } + if ptr + .relays + .as_ref() + .is_some_and(|relay| relay.trim().is_empty()) + { + return Err(RpcError::InvalidParams( + "listing_event.relays cannot be empty".to_string(), + )); + } + Ok(ptr) +} + +async fn resolve_listing_snapshot( + ctx: &RpcContext, + listing_addr: &TradeListingAddress, + message_type: TradeListingMessageType, + listing_event: Option<RadrootsNostrEventPtr>, +) -> Result<Option<RadrootsNostrEventPtr>, RpcError> { + if !message_type.requires_listing_snapshot() { + return Ok(None); + } + let Some(listing_event) = listing_event else { + return Err(RpcError::InvalidParams( + "listing_event is required for this trade message".to_string(), + )); + }; + if ctx.state.client.relays().await.is_empty() { + return Ok(Some(listing_event)); + } + let event = radroots_nostr_fetch_event_by_id(&ctx.state.client, &listing_event.id) + .await + .map_err(|error| { + RpcError::Other(format!( + "failed to fetch listing_event `{}`: {error}", + listing_event.id + )) + })?; + let validated = validate_listing_event(&radroots_event_from_nostr(&event)) + .map_err(|error| RpcError::InvalidParams(format!("invalid listing_event: {error}")))?; + if validated.listing_addr != listing_addr.as_str() { + return Err(RpcError::InvalidParams( + "listing_event must match listing_addr".to_string(), + )); + } + Ok(Some(listing_event)) +} + +fn validate_payload_for_message_type( + payload: &TradeListingMessagePayload, + message_type: TradeListingMessageType, +) -> Result<(), RpcError> { + match (message_type, payload) { + ( + TradeListingMessageType::OrderRevisionAccept, + TradeListingMessagePayload::OrderRevisionAccept(response), + ) => { + if !response.accepted { + return Err(RpcError::InvalidParams( + "bridge.order.revision.accept payload.accepted must be true".to_string(), + )); + } + } + ( + TradeListingMessageType::OrderRevisionDecline, + TradeListingMessagePayload::OrderRevisionDecline(response), + ) => { + if response.accepted { + return Err(RpcError::InvalidParams( + "bridge.order.revision.decline payload.accepted must be false".to_string(), + )); + } + } + ( + TradeListingMessageType::DiscountAccept, + TradeListingMessagePayload::DiscountAccept(TradeDiscountDecision::Accept { .. }), + ) + | ( + TradeListingMessageType::DiscountDecline, + TradeListingMessagePayload::DiscountDecline(TradeDiscountDecision::Decline { .. }), + ) => {} + (TradeListingMessageType::DiscountAccept, _) => { + return Err(RpcError::InvalidParams( + "bridge.order.discount.accept payload must be an accept decision".to_string(), + )); + } + (TradeListingMessageType::DiscountDecline, _) => { + return Err(RpcError::InvalidParams( + "bridge.order.discount.decline payload must be a decline decision".to_string(), + )); + } + _ => {} + } + Ok(()) +} + +fn normalized_required_string(value: String, field: &str) -> Result<String, RpcError> { + let value = value.trim().to_string(); + if value.is_empty() { + return Err(RpcError::InvalidParams(format!("{field} cannot be empty"))); + } + Ok(value) +} + +#[cfg(test)] +mod tests { + use radroots_core::{RadrootsCoreDecimal, RadrootsCoreDiscountValue, RadrootsCorePercent}; + use radroots_events::trade::{ + RadrootsTradeDiscountRequest as TradeDiscountRequest, + RadrootsTradeOrderResponse as TradeOrderResponse, + RadrootsTradeOrderRevisionResponse as TradeOrderRevisionResponse, + RadrootsTradeQuestion as TradeQuestion, + }; + use radroots_identity::RadrootsIdentity; + use radroots_nostr::prelude::RadrootsNostrMetadata; + + use crate::app::config::{BridgeConfig, Nip46Config}; + use crate::core::Radrootsd; + use crate::transport::jsonrpc::{MethodRegistry, RpcContext}; + + use super::*; + + #[tokio::test] + async fn publish_order_response_is_job_backed_and_idempotent() { + let identity = RadrootsIdentity::generate(); + let seller_pubkey = identity.public_key_hex(); + let metadata: RadrootsNostrMetadata = + serde_json::from_str(r#"{"name":"radrootsd-test"}"#).expect("metadata"); + let state = Radrootsd::new( + identity, + metadata, + BridgeConfig { + enabled: true, + bearer_token: Some("secret".to_string()), + ..BridgeConfig::default() + }, + Nip46Config::default(), + ) + .expect("state"); + let ctx = RpcContext::new(state, MethodRegistry::default()); + let params = BridgePublicTradeParams { + listing_addr: base_listing_addr(&seller_pubkey), + order_id: "order-1".to_string(), + counterparty_pubkey: base_buyer_pubkey().to_string(), + listing_event: None, + root_event_id: Some("order-request-event".to_string()), + prev_event_id: Some("order-request-event".to_string()), + payload: TradeOrderResponse { + accepted: true, + reason: None, + }, + signer_session_id: None, + idempotency_key: Some("same-key".to_string()), + }; + + let first = publish_public_trade( + ctx.clone(), + "bridge.order.response", + TradeListingMessageType::OrderResponse, + params.clone(), + TradeListingMessagePayload::OrderResponse, + ) + .await + .expect("first"); + assert!(!first.deduplicated); + assert_eq!(first.job.command, "bridge.order.response"); + assert_eq!( + first.job.event_kind, + TradeListingMessageType::OrderResponse.kind() + ); + assert_eq!( + first.job.event_addr.as_deref(), + Some(base_listing_addr(&seller_pubkey).as_str()) + ); + assert_eq!(first.job.signer_mode, "embedded_service_identity"); + + let second = publish_public_trade( + ctx, + "bridge.order.response", + TradeListingMessageType::OrderResponse, + params, + TradeListingMessagePayload::OrderResponse, + ) + .await + .expect("second"); + assert!(second.deduplicated); + assert_eq!(second.job.job_id, first.job.job_id); + } + + #[tokio::test] + async fn publish_snapshot_message_requires_listing_event() { + let ctx = buyer_ctx().expect("ctx"); + let err = publish_public_trade( + ctx, + "bridge.order.discount.request", + TradeListingMessageType::DiscountRequest, + BridgePublicTradeParams { + listing_addr: base_listing_addr(base_seller_pubkey()), + order_id: "order-1".to_string(), + counterparty_pubkey: base_seller_pubkey().to_string(), + listing_event: None, + root_event_id: Some("root".to_string()), + prev_event_id: Some("prev".to_string()), + payload: TradeDiscountRequest { + discount_id: "discount-1".to_string(), + value: RadrootsCoreDiscountValue::Percent(RadrootsCorePercent::new( + RadrootsCoreDecimal::from(5u32), + )), + }, + signer_session_id: None, + idempotency_key: None, + }, + TradeListingMessagePayload::DiscountRequest, + ) + .await + .expect_err("missing listing_event"); + assert!(err.to_string().contains("listing_event")); + } + + #[tokio::test] + async fn publish_chain_message_requires_root_and_prev() { + let identity = RadrootsIdentity::generate(); + let seller_pubkey = identity.public_key_hex(); + let metadata: RadrootsNostrMetadata = + serde_json::from_str(r#"{"name":"radrootsd-test"}"#).expect("metadata"); + let state = Radrootsd::new( + identity, + metadata, + BridgeConfig { + enabled: true, + bearer_token: Some("secret".to_string()), + ..BridgeConfig::default() + }, + Nip46Config::default(), + ) + .expect("state"); + let ctx = RpcContext::new(state, MethodRegistry::default()); + let err = publish_public_trade( + ctx, + "bridge.order.response", + TradeListingMessageType::OrderResponse, + BridgePublicTradeParams { + listing_addr: base_listing_addr(&seller_pubkey), + order_id: "order-1".to_string(), + counterparty_pubkey: base_buyer_pubkey().to_string(), + listing_event: None, + root_event_id: None, + prev_event_id: Some("prev".to_string()), + payload: TradeOrderResponse { + accepted: true, + reason: None, + }, + signer_session_id: None, + idempotency_key: None, + }, + TradeListingMessagePayload::OrderResponse, + ) + .await + .expect_err("missing root_event_id"); + assert!(err.to_string().contains("root_event_id")); + } + + #[tokio::test] + async fn publish_buyer_message_rejects_listing_seller_signer() { + let identity = RadrootsIdentity::generate(); + let seller_pubkey = identity.public_key_hex(); + let metadata: RadrootsNostrMetadata = + serde_json::from_str(r#"{"name":"radrootsd-test"}"#).expect("metadata"); + let state = Radrootsd::new( + identity, + metadata, + BridgeConfig { + enabled: true, + bearer_token: Some("secret".to_string()), + ..BridgeConfig::default() + }, + Nip46Config::default(), + ) + .expect("state"); + let ctx = RpcContext::new(state, MethodRegistry::default()); + let err = publish_public_trade( + ctx, + "bridge.order.question", + TradeListingMessageType::Question, + BridgePublicTradeParams { + listing_addr: base_listing_addr(&seller_pubkey), + order_id: "order-1".to_string(), + counterparty_pubkey: base_buyer_pubkey().to_string(), + listing_event: None, + root_event_id: Some("root".to_string()), + prev_event_id: Some("prev".to_string()), + payload: TradeQuestion { + question_id: "q-1".to_string(), + }, + signer_session_id: None, + idempotency_key: None, + }, + TradeListingMessagePayload::Question, + ) + .await + .expect_err("seller signed buyer message"); + assert!(err.to_string().contains("buyer")); + } + + #[tokio::test] + async fn publish_revision_accept_rejects_decline_payload() { + let ctx = buyer_ctx().expect("ctx"); + let err = publish_public_trade( + ctx, + "bridge.order.revision.accept", + TradeListingMessageType::OrderRevisionAccept, + BridgePublicTradeParams { + listing_addr: base_listing_addr(base_seller_pubkey()), + order_id: "order-1".to_string(), + counterparty_pubkey: base_seller_pubkey().to_string(), + listing_event: None, + root_event_id: Some("root".to_string()), + prev_event_id: Some("prev".to_string()), + payload: TradeOrderRevisionResponse { + accepted: false, + reason: Some("no".to_string()), + }, + signer_session_id: None, + idempotency_key: None, + }, + TradeListingMessagePayload::OrderRevisionAccept, + ) + .await + .expect_err("decline payload"); + assert!(err.to_string().contains("payload.accepted")); + } + + fn buyer_ctx() -> Result<RpcContext, RpcError> { + let identity = RadrootsIdentity::generate(); + let metadata: RadrootsNostrMetadata = + serde_json::from_str(r#"{"name":"radrootsd-test"}"#).expect("metadata"); + let state = Radrootsd::new( + identity, + metadata, + BridgeConfig { + enabled: true, + bearer_token: Some("secret".to_string()), + ..BridgeConfig::default() + }, + Nip46Config::default(), + ) + .map_err(|error| RpcError::Other(format!("build state: {error}")))?; + Ok(RpcContext::new(state, MethodRegistry::default())) + } + + fn base_seller_pubkey() -> &'static str { + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + + fn base_buyer_pubkey() -> &'static str { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + + fn base_listing_addr(seller_pubkey: &str) -> String { + format!("{KIND_LISTING}:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg") + } + + #[test] + fn validate_discount_decline_payload_shape() { + let payload = TradeListingMessagePayload::DiscountDecline(TradeDiscountDecision::Decline { + reason: Some("no".to_string()), + }); + validate_payload_for_message_type(&payload, TradeListingMessageType::DiscountDecline) + .expect("decline"); + + let err = validate_payload_for_message_type( + &TradeListingMessagePayload::DiscountDecline(TradeDiscountDecision::Accept { + value: RadrootsCoreDiscountValue::Percent(RadrootsCorePercent::new( + RadrootsCoreDecimal::from(5u32), + )), + }), + TradeListingMessageType::DiscountDecline, + ) + .expect_err("accept"); + assert!(err.to_string().contains("decline")); + } +} diff --git a/src/transport/jsonrpc/methods/mod.rs b/src/transport/jsonrpc/methods/mod.rs @@ -61,6 +61,19 @@ mod tests { assert!(root.method("bridge.job.status").is_some()); assert!(root.method("bridge.listing.publish").is_some()); assert!(root.method("bridge.order.request").is_some()); + assert!(root.method("bridge.order.response").is_some()); + assert!(root.method("bridge.order.revision").is_some()); + assert!(root.method("bridge.order.revision.accept").is_some()); + assert!(root.method("bridge.order.revision.decline").is_some()); + assert!(root.method("bridge.order.question").is_some()); + assert!(root.method("bridge.order.answer").is_some()); + assert!(root.method("bridge.order.discount.request").is_some()); + assert!(root.method("bridge.order.discount.offer").is_some()); + assert!(root.method("bridge.order.discount.accept").is_some()); + assert!(root.method("bridge.order.discount.decline").is_some()); + assert!(root.method("bridge.order.cancel").is_some()); + assert!(root.method("bridge.order.fulfillment.update").is_some()); + assert!(root.method("bridge.order.receipt").is_some()); assert!(root.method("nip46.connect").is_none()); }