radrootsd

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

commit 8912fddd6a3e1c2fd1b102c16b80e63fe2709fee
parent 132310dfe4295570d9d821d37dc716a70e785eac
Author: triesap <tyson@radroots.org>
Date:   Fri, 12 Jun 2026 20:11:53 -0700

radrootsd: align bridge order contracts

Diffstat:
MCargo.lock | 66------------------------------------------------------------------
MCargo.toml | 2+-
Msrc/transport/jsonrpc/methods/bridge/listing_publish.rs | 1+
Msrc/transport/jsonrpc/methods/bridge/mod.rs | 2--
Msrc/transport/jsonrpc/methods/bridge/order_request.rs | 38++++++++++++++++++--------------------
Dsrc/transport/jsonrpc/methods/bridge/public_trade.rs | 743-------------------------------------------------------------------------------
Msrc/transport/jsonrpc/methods/bridge/shared.rs | 121++++++++++++++++---------------------------------------------------------------
Msrc/transport/jsonrpc/methods/mod.rs | 13-------------
8 files changed, 44 insertions(+), 942 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1731,7 +1731,6 @@ dependencies = [ "rust_decimal", "rust_decimal_macros", "serde", - "typeshare", ] [[package]] @@ -1740,8 +1739,6 @@ version = "0.1.0-alpha.2" dependencies = [ "radroots_core", "serde", - "ts-rs", - "typeshare", ] [[package]] @@ -1886,7 +1883,6 @@ dependencies = [ "serde_json", "sha2", "thiserror 1.0.69", - "ts-rs", ] [[package]] @@ -2449,15 +2445,6 @@ dependencies = [ ] [[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - -[[package]] name = "thiserror" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2829,28 +2816,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] -name = "ts-rs" -version = "11.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4994acea2522cd2b3b85c1d9529a55991e3ad5e25cdcd3de9d505972c4379424" -dependencies = [ - "thiserror 2.0.18", - "ts-rs-macros", -] - -[[package]] -name = "ts-rs-macros" -version = "11.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6ff59666c9cbaec3533964505d39154dc4e0a56151fdea30a09ed0301f62e2" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "termcolor", -] - -[[package]] name = "tungstenite" version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2876,28 +2841,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] -name = "typeshare" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da1bf9fe204f358ffea7f8f779b53923a20278b3ab8e8d97962c5e1b3a54edb7" -dependencies = [ - "chrono", - "serde", - "serde_json", - "typeshare-annotation", -] - -[[package]] -name = "typeshare-annotation" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "621963e302416b389a1ec177397e9e62de849a78bd8205d428608553def75350" -dependencies = [ - "quote", - "syn", -] - -[[package]] name = "ucd-trie" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3165,15 +3108,6 @@ dependencies = [ ] [[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] name = "windows-core" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -24,7 +24,7 @@ radroots_trade = { path = "../lib/crates/trade" } unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } [dependencies] -radroots_core = { workspace = true, features = ["std", "serde", "typeshare"] } +radroots_core = { workspace = true, features = ["std", "serde"] } radroots_events = { workspace = true, features = ["serde"] } radroots_events_codec = { workspace = true, features = ["nostr", "serde_json"] } radroots_identity = { workspace = true } diff --git a/src/transport/jsonrpc/methods/bridge/listing_publish.rs b/src/transport/jsonrpc/methods/bridge/listing_publish.rs @@ -458,6 +458,7 @@ mod tests { geohash: None, }), images: None, + published_at: None, } } } diff --git a/src/transport/jsonrpc/methods/bridge/mod.rs b/src/transport/jsonrpc/methods/bridge/mod.rs @@ -9,7 +9,6 @@ mod job_status; mod listing_publish; mod order_request; mod profile_publish; -mod public_trade; mod shared; mod status; @@ -22,6 +21,5 @@ pub fn module(ctx: RpcContext, registry: MethodRegistry) -> Result<RpcModule<Rpc farm_publish::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/order_request.rs b/src/transport/jsonrpc/methods/bridge/order_request.rs @@ -1,11 +1,11 @@ use anyhow::Result; use jsonrpsee::server::RpcModule; use radroots_events::RadrootsNostrEventPtr; -use radroots_events::kinds::KIND_TRADE_ORDER_REQUEST; -use radroots_events::trade::RadrootsTradeOrderRequested as TradeOrder; -use radroots_events_codec::trade::active_trade_order_request_event_build; +use radroots_events::kinds::KIND_ORDER_REQUEST; +use radroots_events::order::RadrootsOrderRequest as TradeOrder; +use radroots_events_codec::order::order_request_event_build; use radroots_nostr::prelude::{radroots_nostr_build_event, radroots_nostr_parse_pubkey}; -use radroots_trade::order::canonicalize_active_order_request_for_signer; +use radroots_trade::order::canonicalize_order_request_for_signer; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -67,13 +67,13 @@ async fn publish_order_request( &ctx, params.signer_session_id.as_deref(), params.signer_authority.as_ref(), - KIND_TRADE_ORDER_REQUEST, + KIND_ORDER_REQUEST, "bridge.order.request", ) .await?; let signer_pubkey = signer.signer_pubkey_hex(); let listing_event = params.listing_event; - let order = canonicalize_active_order_request_for_signer(params.order, signer_pubkey.as_str()) + let order = canonicalize_order_request_for_signer(params.order, signer_pubkey.as_str()) .map_err(|error| RpcError::InvalidParams(error.to_string()))?; radroots_nostr_parse_pubkey(&order.buyer_pubkey) .map_err(|error| RpcError::InvalidParams(format!("invalid order.buyer_pubkey: {error}")))?; @@ -88,10 +88,9 @@ async fn publish_order_request( listing_event: &listing_event, }, )?; - let built = - active_trade_order_request_event_build(&listing_event, &order).map_err(|error| { - RpcError::Other(format!("failed to build order request event: {error}")) - })?; + let built = order_request_event_build(&listing_event, &order).map_err(|error| { + RpcError::Other(format!("failed to build order request event: {error}")) + })?; let builder = radroots_nostr_build_event(built.kind, built.content, built.tags).map_err(|error| { RpcError::Other(format!("failed to build order request event: {error}")) @@ -103,7 +102,7 @@ async fn publish_order_request( Uuid::new_v4().to_string(), idempotency_key, signer.signer_mode(), - KIND_TRADE_ORDER_REQUEST, + KIND_ORDER_REQUEST, None, order.listing_addr.clone(), ctx.state.bridge_config.delivery_policy, @@ -156,12 +155,11 @@ mod tests { RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, }; use radroots_events::RadrootsNostrEventPtr; - use radroots_events::trade::{ - RadrootsTradeOrderEconomicItem as TradeOrderEconomicItem, - RadrootsTradeOrderEconomicLine as TradeOrderEconomicLine, - RadrootsTradeOrderEconomics as TradeOrderEconomics, - RadrootsTradeOrderItem as TradeOrderItem, RadrootsTradeOrderRequested as TradeOrder, - RadrootsTradePricingBasis as TradePricingBasis, + use radroots_events::order::{ + RadrootsOrderEconomicItem as TradeOrderEconomicItem, + RadrootsOrderEconomicLine as TradeOrderEconomicLine, + RadrootsOrderEconomics as TradeOrderEconomics, RadrootsOrderItem as TradeOrderItem, + RadrootsOrderPricingBasis as TradePricingBasis, RadrootsOrderRequest as TradeOrder, }; use radroots_identity::RadrootsIdentity; use radroots_nostr::prelude::{ @@ -173,13 +171,13 @@ mod tests { use crate::core::Radrootsd; use crate::core::nip46::session::Nip46Session; use crate::transport::jsonrpc::{MethodRegistry, RpcContext}; - use radroots_trade::order::canonicalize_active_order_request_for_signer; + use radroots_trade::order::canonicalize_order_request_for_signer; use super::{BridgeOrderRequestParams, publish_order_request}; #[test] fn canonicalize_order_request_sets_missing_buyer_and_seller_pubkeys() { - let order = canonicalize_active_order_request_for_signer( + let order = canonicalize_order_request_for_signer( base_order("", ""), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ) @@ -202,7 +200,7 @@ mod tests { "", ); order.items[0].bin_count = 0; - let err = canonicalize_active_order_request_for_signer( + let err = canonicalize_order_request_for_signer( order, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ) diff --git a/src/transport/jsonrpc/methods/bridge/public_trade.rs b/src/transport/jsonrpc/methods/bridge/public_trade.rs @@ -1,743 +0,0 @@ -use anyhow::Result; -use jsonrpsee::server::RpcModule; -use radroots_events::RadrootsNostrEventPtr; -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 radroots_trade::public_trade::canonicalize_public_trade_context; -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, -} - -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 context = canonicalize_public_trade_context( - params.listing_addr, - params.order_id, - params.counterparty_pubkey, - signer_pubkey, - message_type, - ) - .map_err(|error| RpcError::InvalidParams(error.to_string()))?; - radroots_nostr_parse_pubkey(&context.counterparty_pubkey).map_err(|error| { - RpcError::InvalidParams(format!("invalid counterparty_pubkey: {error}")) - })?; - let parsed_listing_addr = TradeListingAddress::parse(&context.listing_addr) - .map_err(|error| RpcError::InvalidParams(format!("invalid listing_addr: {error}")))?; - - 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: context.listing_addr, - order_id: context.order_id, - counterparty_pubkey: context.counterparty_pubkey, - listing_event, - root_event_id, - prev_event_id, - payload: params.payload, - }, - parsed_listing_addr, - )) -} - -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::kinds::KIND_LISTING; - 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, seller_pubkey) = signer_ctx().expect("ctx"); - let err = publish_public_trade( - ctx, - "bridge.order.revision.accept", - TradeListingMessageType::OrderRevisionAccept, - 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: 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> { - signer_ctx().map(|(ctx, _)| ctx) - } - - fn signer_ctx() -> Result<(RpcContext, String), RpcError> { - let identity = RadrootsIdentity::generate(); - let signer_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(), - ) - .map_err(|error| RpcError::Other(format!("build state: {error}")))?; - Ok(( - RpcContext::new(state, MethodRegistry::default()), - signer_pubkey, - )) - } - - 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/bridge/shared.rs b/src/transport/jsonrpc/methods/bridge/shared.rs @@ -1,7 +1,6 @@ use anyhow::Result; use nostr::Event; use radroots_nostr::prelude::RadrootsNostrEventBuilder; -use radroots_nostr_signer::prelude::RadrootsNostrSignerBackend; use serde::Serialize; use sha2::{Digest, Sha256}; @@ -101,9 +100,6 @@ pub(super) fn ensure_bridge_enabled(ctx: &RpcContext) -> Result<(), RpcError> { #[derive(Clone)] pub(super) enum BridgeSignerSelection { - EmbeddedServiceIdentity { - signer_pubkey_hex: String, - }, Nip46Session { session_id: String, session: crate::core::nip46::session::Nip46Session, @@ -113,52 +109,17 @@ pub(super) enum BridgeSignerSelection { impl BridgeSignerSelection { pub(super) fn signer_pubkey_hex(&self) -> String { match self { - Self::EmbeddedServiceIdentity { signer_pubkey_hex } => signer_pubkey_hex.clone(), Self::Nip46Session { session, .. } => session.remote_signer_pubkey.to_hex(), } } pub(super) fn signer_mode(&self) -> String { match self { - Self::EmbeddedServiceIdentity { .. } => "embedded_service_identity".to_string(), Self::Nip46Session { session_id, .. } => format!("nip46_session:{session_id}"), } } } -pub(super) fn bridge_signer_pubkey_hex(ctx: &RpcContext) -> Result<String, RpcError> { - Ok(ctx - .state - .bridge_signer - .signer_identity() - .map_err(|error| RpcError::Other(format!("bridge signer unavailable: {error}")))? - .ok_or_else(|| RpcError::Other("bridge signer identity is missing".to_string()))? - .public_key_hex) -} - -pub(super) async fn resolve_bridge_signer( - ctx: &RpcContext, - signer_session_id: Option<&str>, - event_kind: u32, -) -> Result<BridgeSignerSelection, RpcError> { - match signer_session_id - .map(str::trim) - .filter(|value| !value.is_empty()) - { - Some(session_id) => { - let session = nip46_session::get_session(ctx, session_id).await?; - nip46_session::require_sign_event_permission(&session, event_kind)?; - Ok(BridgeSignerSelection::Nip46Session { - session_id: session_id.to_string(), - session, - }) - } - None => Ok(BridgeSignerSelection::EmbeddedServiceIdentity { - signer_pubkey_hex: bridge_signer_pubkey_hex(ctx)?, - }), - } -} - pub(super) async fn resolve_actor_bridge_signer( ctx: &RpcContext, signer_session_id: Option<&str>, @@ -240,18 +201,12 @@ fn require_signer_authority( } pub(super) async fn sign_bridge_event_builder( - ctx: &RpcContext, + _ctx: &RpcContext, signer: &BridgeSignerSelection, builder: RadrootsNostrEventBuilder, label: &str, ) -> Result<Event, RpcError> { match signer { - BridgeSignerSelection::EmbeddedServiceIdentity { .. } => ctx - .state - .bridge_signer - .sign_event_builder(builder) - .map(|signed| signed.event) - .map_err(|error| RpcError::Other(format!("failed to sign {label} event: {error}"))), BridgeSignerSelection::Nip46Session { session, .. } => match session.role() { Nip46SessionRole::InboundLocalSigner => builder .sign_with_keys(&session.client_keys) @@ -329,7 +284,7 @@ mod tests { use super::{ BridgeJobView, fingerprint_bridge_request, normalize_idempotency_key, - resolve_actor_bridge_signer, resolve_bridge_signer, + resolve_actor_bridge_signer, }; use std::time::Instant; @@ -340,53 +295,6 @@ mod tests { } #[tokio::test] - async fn resolve_bridge_signer_prefers_requested_nip46_session() { - let identity = RadrootsIdentity::generate(); - let metadata: RadrootsNostrMetadata = - serde_json::from_str(r#"{"name":"radrootsd-test"}"#).expect("metadata"); - let state = Radrootsd::new( - identity.clone(), - metadata, - BridgeConfig::default(), - Nip46Config::default(), - ) - .expect("state"); - let session_keys = RadrootsNostrKeys::generate(); - state - .nip46_sessions - .insert(Nip46Session { - id: "session-1".to_string(), - client: RadrootsNostrClient::new(session_keys.clone()), - client_keys: session_keys.clone(), - client_pubkey: session_keys.public_key(), - remote_signer_pubkey: session_keys.public_key(), - user_pubkey: None, - relays: vec!["wss://relay.example.com".to_string()], - perms: vec!["sign_event".to_string()], - name: None, - url: None, - image: None, - expires_at: Some(Instant::now() + std::time::Duration::from_secs(60)), - auth_required: false, - authorized: true, - auth_url: None, - pending_request: None, - signer_authority: None, - }) - .await; - let ctx = RpcContext::new(state, MethodRegistry::default()); - - let signer = resolve_bridge_signer(&ctx, Some("session-1"), 30402) - .await - .expect("session signer"); - assert_eq!( - signer.signer_pubkey_hex(), - session_keys.public_key().to_hex() - ); - assert_eq!(signer.signer_mode(), "nip46_session:session-1"); - } - - #[tokio::test] async fn resolve_actor_bridge_signer_rejects_missing_session_id() { let identity = RadrootsIdentity::generate(); let metadata: RadrootsNostrMetadata = @@ -526,9 +434,28 @@ mod tests { #[test] fn fingerprint_bridge_request_changes_when_request_changes() { - let signer = super::BridgeSignerSelection::EmbeddedServiceIdentity { - signer_pubkey_hex: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - .to_string(), + let session_keys = RadrootsNostrKeys::generate(); + let signer = super::BridgeSignerSelection::Nip46Session { + session_id: "session-1".to_string(), + session: Nip46Session { + id: "session-1".to_string(), + client: RadrootsNostrClient::new(session_keys.clone()), + client_keys: session_keys.clone(), + client_pubkey: session_keys.public_key(), + remote_signer_pubkey: session_keys.public_key(), + user_pubkey: None, + relays: vec!["wss://relay.example.com".to_string()], + perms: vec!["sign_event".to_string()], + name: None, + url: None, + image: None, + expires_at: None, + auth_required: false, + authorized: true, + auth_url: None, + pending_request: None, + signer_authority: None, + }, }; let first = fingerprint_bridge_request( "bridge.order.request", diff --git a/src/transport/jsonrpc/methods/mod.rs b/src/transport/jsonrpc/methods/mod.rs @@ -64,19 +64,6 @@ mod tests { assert!(root.method("bridge.farm.publish").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()); }