radrootsd

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

commit ea5f814de4cc3af9ea1e0456b2b38828dc0840cb
parent 87a90cf6d71cbb27dfe2d64e4c2f093c2510b776
Author: triesap <tyson@radroots.org>
Date:   Thu,  2 Apr 2026 20:30:00 +0000

bridge: require listing snapshots for order requests

Diffstat:
Msrc/transport/jsonrpc/methods/bridge/order_request.rs | 125++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
1 file changed, 76 insertions(+), 49 deletions(-)

diff --git a/src/transport/jsonrpc/methods/bridge/order_request.rs b/src/transport/jsonrpc/methods/bridge/order_request.rs @@ -1,17 +1,22 @@ use anyhow::Result; use jsonrpsee::server::RpcModule; +use radroots_events::RadrootsNostrEventPtr; use radroots_events::kinds::KIND_LISTING; use radroots_events::trade::{ RadrootsTradeEnvelope as TradeListingEnvelope, + RadrootsTradeMessagePayload as TradeListingMessagePayload, RadrootsTradeMessageType as TradeListingMessageType, RadrootsTradeOrder as TradeOrder, - RadrootsTradeOrderStatus as TradeOrderStatus, }; use radroots_events_codec::trade::{ RadrootsTradeListingAddress as TradeListingAddress, trade_envelope_event_build as trade_listing_envelope_event_build, }; -use radroots_nostr::prelude::{radroots_nostr_build_event, radroots_nostr_parse_pubkey}; +use radroots_nostr::prelude::{ + RadrootsNostrFilter, RadrootsNostrKind, radroots_event_ptr_from_nostr, + radroots_nostr_build_event, radroots_nostr_filter_tag, radroots_nostr_parse_pubkey, +}; use serde::Deserialize; +use std::time::Duration; use uuid::Uuid; use crate::core::bridge::publish::{ @@ -76,12 +81,16 @@ async fn publish_order_request( envelope.validate().map_err(|error| { RpcError::InvalidParams(format!("invalid order request envelope: {error}")) })?; + let listing_snapshot = fetch_listing_snapshot(&ctx, &order.listing_addr).await?; let built = trade_listing_envelope_event_build( order.seller_pubkey.clone(), TradeListingMessageType::OrderRequest, order.listing_addr.clone(), Some(order.order_id.clone()), - &order, + Some(&listing_snapshot), + None, + None, + &TradeListingMessagePayload::OrderRequest(order.clone()), ) .map_err(|error| RpcError::Other(format!("failed to build order request event: {error}")))?; let builder = radroots_nostr_build_event(u32::from(built.kind), built.content, built.tags) @@ -142,6 +151,52 @@ async fn publish_order_request( }) } +async fn fetch_listing_snapshot( + ctx: &RpcContext, + listing_addr: &str, +) -> Result<RadrootsNostrEventPtr, RpcError> { + let listing_addr = TradeListingAddress::parse(listing_addr) + .map_err(|error| RpcError::InvalidParams(format!("invalid order.listing_addr: {error}")))?; + if ctx.state.client.relays().await.is_empty() { + return Ok(synthetic_listing_snapshot(&listing_addr)); + } + let filter = RadrootsNostrFilter::new() + .author( + radroots_nostr_parse_pubkey(&listing_addr.seller_pubkey).map_err(|error| { + RpcError::InvalidParams(format!("invalid order.seller_pubkey: {error}")) + })?, + ) + .kind(RadrootsNostrKind::Custom(KIND_LISTING as u16)); + let filter = radroots_nostr_filter_tag(filter, "d", vec![listing_addr.listing_id.clone()]) + .map_err(|error| { + RpcError::Other(format!("failed to build listing snapshot filter: {error}")) + })?; + let mut events = ctx + .state + .client + .fetch_events(filter, Duration::from_secs(10)) + .await + .map_err(|error| { + RpcError::Other(format!( + "failed to fetch listing snapshot for bridge.order.request: {error}" + )) + })?; + events.sort_by_key(|event| event.created_at); + let event = events.pop().ok_or_else(|| { + RpcError::InvalidParams( + "order.listing_addr must reference an existing public NIP-99 listing".to_string(), + ) + })?; + Ok(radroots_event_ptr_from_nostr(&event)) +} + +fn synthetic_listing_snapshot(listing_addr: &TradeListingAddress) -> RadrootsNostrEventPtr { + RadrootsNostrEventPtr { + id: format!("listing:{}", listing_addr.as_str()), + relays: None, + } +} + fn canonicalize_order_request_for_signer( mut order: TradeOrder, signer_pubkey: &str, @@ -208,17 +263,10 @@ fn canonicalize_order_request_for_signer( } } - if order.status != TradeOrderStatus::Requested { - return Err(RpcError::InvalidParams( - "order.status must be requested for bridge.order.request".to_string(), - )); - } - order.order_id = order_id; order.listing_addr = listing_addr.as_str(); order.buyer_pubkey = buyer_pubkey; order.seller_pubkey = seller_pubkey; - order.notes = normalize_optional_string(order.notes); if order.discounts.as_ref().is_some_and(Vec::is_empty) { order.discounts = None; } @@ -233,19 +281,11 @@ fn normalized_required_string(value: String, field: &str) -> Result<String, RpcE Ok(value) } -fn normalize_optional_string(value: Option<String>) -> Option<String> { - value.and_then(|value| { - let value = value.trim().to_string(); - if value.is_empty() { None } else { Some(value) } - }) -} - #[cfg(test)] mod tests { use radroots_core::RadrootsCoreDiscountValue; use radroots_events::trade::{ RadrootsTradeOrder as TradeOrder, RadrootsTradeOrderItem as TradeOrderItem, - RadrootsTradeOrderStatus as TradeOrderStatus, }; use radroots_identity::RadrootsIdentity; use radroots_nostr::prelude::RadrootsNostrMetadata; @@ -255,14 +295,13 @@ mod tests { use crate::transport::jsonrpc::{MethodRegistry, RpcContext}; use super::{ - BridgeOrderRequestParams, canonicalize_order_request_for_signer, normalize_optional_string, - publish_order_request, + BridgeOrderRequestParams, canonicalize_order_request_for_signer, publish_order_request, }; #[test] fn canonicalize_order_request_sets_missing_buyer_and_seller_pubkeys() { let order = canonicalize_order_request_for_signer( - base_order("", "", TradeOrderStatus::Requested), + base_order("", ""), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ) .expect("canonicalize"); @@ -278,25 +317,10 @@ mod tests { } #[test] - fn canonicalize_order_request_rejects_non_requested_status() { - let err = canonicalize_order_request_for_signer( - base_order( - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "", - TradeOrderStatus::Draft, - ), - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ) - .expect_err("status should fail"); - assert!(err.to_string().contains("order.status")); - } - - #[test] fn canonicalize_order_request_rejects_items_with_zero_bin_count() { let mut order = base_order( "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "", - TradeOrderStatus::Requested, ); order.items[0].bin_count = 0; let err = canonicalize_order_request_for_signer( @@ -308,12 +332,17 @@ mod tests { } #[test] - fn normalize_optional_string_trims_blank_values() { - assert_eq!(normalize_optional_string(Some(" ".to_string())), None); - assert_eq!( - normalize_optional_string(Some(" note ".to_string())), - Some("note".to_string()) - ); + fn canonicalize_order_request_drops_empty_discounts() { + let order = canonicalize_order_request_for_signer( + base_order( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "", + ), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + .expect("canonicalize"); + + assert_eq!(order.discounts, None); } #[tokio::test] @@ -334,7 +363,7 @@ mod tests { .expect("state"); let ctx = RpcContext::new(state, MethodRegistry::default()); let params = BridgeOrderRequestParams { - order: base_order("", "", TradeOrderStatus::Requested), + order: base_order("", ""), signer_session_id: None, idempotency_key: Some("same-key".to_string()), }; @@ -349,7 +378,7 @@ mod tests { let second = publish_order_request( ctx, BridgeOrderRequestParams { - order: base_order("", "", TradeOrderStatus::Requested), + order: base_order("", ""), signer_session_id: None, idempotency_key: Some("same-key".to_string()), }, @@ -380,7 +409,7 @@ mod tests { publish_order_request( ctx.clone(), BridgeOrderRequestParams { - order: base_order("", "", TradeOrderStatus::Requested), + order: base_order("", ""), signer_session_id: None, idempotency_key: Some("same-key".to_string()), }, @@ -388,7 +417,7 @@ mod tests { .await .expect("first"); - let mut conflicting = base_order("", "", TradeOrderStatus::Requested); + let mut conflicting = base_order("", ""); conflicting.order_id = "order-2".to_string(); let err = publish_order_request( ctx, @@ -407,7 +436,7 @@ mod tests { "30402:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:AAAAAAAAAAAAAAAAAAAAAg" } - fn base_order(buyer_pubkey: &str, seller_pubkey: &str, status: TradeOrderStatus) -> TradeOrder { + fn base_order(buyer_pubkey: &str, seller_pubkey: &str) -> TradeOrder { TradeOrder { order_id: "order-1".to_string(), listing_addr: base_listing_addr().to_string(), @@ -418,8 +447,6 @@ mod tests { bin_count: 2, }], discounts: Some(Vec::<RadrootsCoreDiscountValue>::new()), - notes: Some(" note ".to_string()), - status, } } }