lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 4801bd8e6392fe036bc4cfe40621045d34279918
parent da89a3dac1107db9b81fd749db912379e989d37f
Author: triesap <tyson@radroots.org>
Date:   Thu,  2 Apr 2026 18:51:40 +0000

trade: enforce public snapshot and thread invariants

Diffstat:
Mcrates/events-codec/src/trade/decode.rs | 187++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/events-codec/src/trade/encode.rs | 61++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/events-codec/src/trade/mod.rs | 10+++++++---
Mcrates/events-codec/src/trade/tags.rs | 230++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/events/src/trade.rs | 69+++++++++++++++++++++++++++++++++++++--------------------------------
Mcrates/trade/src/listing/dvm.rs | 288+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mcrates/trade/src/listing/order.rs | 30------------------------------
Mcrates/trade/src/listing/overlay.rs | 133++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/trade/src/listing/projection.rs | 400+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mcrates/trade/src/listing/tags.rs | 290+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
10 files changed, 1358 insertions(+), 340 deletions(-)

diff --git a/crates/events-codec/src/trade/decode.rs b/crates/events-codec/src/trade/decode.rs @@ -3,9 +3,9 @@ use alloc::{borrow::ToOwned, format, string::String, vec::Vec}; #[cfg(feature = "serde_json")] use radroots_events::{ - RadrootsNostrEvent, + RadrootsNostrEvent, RadrootsNostrEventPtr, kinds::{KIND_PROFILE, is_trade_kind}, - tags::TAG_D, + tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT}, trade::{RadrootsTradeEnvelope, RadrootsTradeEnvelopeError, RadrootsTradeMessageType}, }; #[cfg(feature = "serde_json")] @@ -13,6 +13,11 @@ use serde::de::DeserializeOwned; #[cfg(feature = "serde_json")] use crate::d_tag::is_d_tag_base64url; +#[cfg(feature = "serde_json")] +use crate::trade::tags::{ + TAG_LISTING_EVENT, parse_trade_counterparty_tag, parse_trade_listing_event_tag, + parse_trade_prev_tag, parse_trade_root_tag, +}; #[cfg(feature = "serde_json")] #[derive(Clone, Debug, PartialEq, Eq)] @@ -69,6 +74,15 @@ impl std::error::Error for RadrootsTradeEnvelopeParseError { #[cfg(feature = "serde_json")] #[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsTradeEventContext { + pub counterparty_pubkey: String, + pub listing_event: Option<RadrootsNostrEventPtr>, + pub root_event_id: Option<String>, + pub prev_event_id: Option<String>, +} + +#[cfg(feature = "serde_json")] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsTradeListingAddress { pub kind: u32, pub seller_pubkey: String, @@ -182,20 +196,81 @@ pub fn trade_envelope_from_event<T: DeserializeOwned>( } } + let message_type = envelope.message_type; + trade_event_context_from_tags(message_type, &event.tags)?; + Ok(envelope) } +#[cfg(feature = "serde_json")] +pub fn trade_event_context_from_tags( + message_type: RadrootsTradeMessageType, + tags: &[Vec<String>], +) -> Result<RadrootsTradeEventContext, RadrootsTradeEnvelopeParseError> { + let counterparty_pubkey = + parse_trade_counterparty_tag(tags).map_err(map_tag_parse_error_for_trade_envelope)?; + let listing_event = + parse_trade_listing_event_tag(tags).map_err(map_tag_parse_error_for_trade_envelope)?; + let root_event_id = + parse_trade_root_tag(tags).map_err(map_tag_parse_error_for_trade_envelope)?; + let prev_event_id = + parse_trade_prev_tag(tags).map_err(map_tag_parse_error_for_trade_envelope)?; + + if message_type.requires_listing_snapshot() && listing_event.is_none() { + return Err(RadrootsTradeEnvelopeParseError::MissingTag( + TAG_LISTING_EVENT, + )); + } + if message_type.requires_trade_chain() { + if root_event_id.is_none() { + return Err(RadrootsTradeEnvelopeParseError::MissingTag(TAG_E_ROOT)); + } + if prev_event_id.is_none() { + return Err(RadrootsTradeEnvelopeParseError::MissingTag(TAG_E_PREV)); + } + } + + Ok(RadrootsTradeEventContext { + counterparty_pubkey, + listing_event, + root_event_id, + prev_event_id, + }) +} + +#[cfg(feature = "serde_json")] +fn map_tag_parse_error_for_trade_envelope( + error: crate::error::EventParseError, +) -> RadrootsTradeEnvelopeParseError { + match error { + crate::error::EventParseError::MissingTag(tag) => { + RadrootsTradeEnvelopeParseError::MissingTag(tag) + } + crate::error::EventParseError::InvalidTag(tag) => { + RadrootsTradeEnvelopeParseError::InvalidTag(tag) + } + crate::error::EventParseError::InvalidKind { expected: _, got } => { + RadrootsTradeEnvelopeParseError::InvalidKind(got) + } + crate::error::EventParseError::InvalidNumber(tag, _) + | crate::error::EventParseError::InvalidJson(tag) => { + RadrootsTradeEnvelopeParseError::InvalidTag(tag) + } + } +} + #[cfg(all(test, feature = "serde_json"))] mod tests { use super::{ RadrootsTradeEnvelopeParseError, RadrootsTradeListingAddress, trade_envelope_from_event, + trade_event_context_from_tags, }; use crate::trade::encode::trade_envelope_event_build; use radroots_events::{ - RadrootsNostrEvent, + RadrootsNostrEvent, RadrootsNostrEventPtr, trade::{ RadrootsTradeEnvelope, RadrootsTradeMessagePayload, RadrootsTradeMessageType, - RadrootsTradeOrder, RadrootsTradeOrderItem, RadrootsTradeOrderStatus, + RadrootsTradeOrder, RadrootsTradeOrderItem, }, }; @@ -210,8 +285,6 @@ mod tests { bin_count: 3, }], discounts: None, - notes: None, - status: RadrootsTradeOrderStatus::Requested, } } @@ -230,6 +303,12 @@ mod tests { RadrootsTradeMessageType::OrderRequest, "30402:seller:AAAAAAAAAAAAAAAAAAAAAg", Some("order-1".into()), + Some(&RadrootsNostrEventPtr { + id: "listing-snapshot".into(), + relays: None, + }), + None, + None, &payload, ) .expect("build trade envelope"); @@ -259,6 +338,12 @@ mod tests { RadrootsTradeMessageType::OrderRequest, "30402:seller:AAAAAAAAAAAAAAAAAAAAAg", Some("order-1".into()), + Some(&RadrootsNostrEventPtr { + id: "listing-snapshot".into(), + relays: None, + }), + None, + None, &payload, ) .expect("build trade envelope"); @@ -277,4 +362,94 @@ mod tests { let err = trade_envelope_from_event::<serde_json::Value>(&event).unwrap_err(); assert_eq!(err, RadrootsTradeEnvelopeParseError::ListingAddrTagMismatch); } + + #[test] + fn parse_rejects_missing_public_snapshot_tag() { + let payload = RadrootsTradeMessagePayload::OrderRequest(base_order()); + let built = trade_envelope_event_build( + "seller", + RadrootsTradeMessageType::OrderRequest, + "30402:seller:AAAAAAAAAAAAAAAAAAAAAg", + Some("order-1".into()), + Some(&RadrootsNostrEventPtr { + id: "listing-snapshot".into(), + relays: None, + }), + None, + None, + &payload, + ) + .expect("build trade envelope"); + let mut event = RadrootsNostrEvent { + id: "id".into(), + author: "buyer".into(), + created_at: 1, + kind: built.kind, + tags: built.tags, + content: built.content, + sig: "sig".into(), + }; + event + .tags + .retain(|tag| tag.first().map(|value| value.as_str()) != Some(TAG_LISTING_EVENT)); + let err = trade_envelope_from_event::<RadrootsTradeMessagePayload>(&event).unwrap_err(); + assert_eq!( + err, + RadrootsTradeEnvelopeParseError::MissingTag(TAG_LISTING_EVENT) + ); + } + + #[test] + fn parse_rejects_missing_public_chain_tags_after_order_request() { + let payload = RadrootsTradeMessagePayload::OrderResponse( + radroots_events::trade::RadrootsTradeOrderResponse { + accepted: true, + reason: None, + }, + ); + let built = trade_envelope_event_build( + "buyer", + RadrootsTradeMessageType::OrderResponse, + "30402:seller:AAAAAAAAAAAAAAAAAAAAAg", + Some("order-1".into()), + None, + Some("root"), + Some("prev"), + &payload, + ) + .expect("build trade envelope"); + let mut event = RadrootsNostrEvent { + id: "id".into(), + author: "seller".into(), + created_at: 1, + kind: built.kind, + tags: built.tags, + content: built.content, + sig: "sig".into(), + }; + event + .tags + .retain(|tag| tag.first().map(|value| value.as_str()) != Some(TAG_E_PREV)); + let err = trade_envelope_from_event::<RadrootsTradeMessagePayload>(&event).unwrap_err(); + assert_eq!(err, RadrootsTradeEnvelopeParseError::MissingTag(TAG_E_PREV)); + } + + #[test] + fn parse_trade_event_context_extracts_public_refs() { + let context = trade_event_context_from_tags( + RadrootsTradeMessageType::OrderResponse, + &[ + vec!["p".into(), "buyer".into()], + vec!["a".into(), "30402:seller:AAAAAAAAAAAAAAAAAAAAAg".into()], + vec![TAG_D.into(), "order-1".into()], + vec![TAG_E_ROOT.into(), "root-id".into()], + vec![TAG_E_PREV.into(), "prev-id".into()], + ], + ) + .expect("event context"); + assert_eq!(context.counterparty_pubkey, "buyer"); + assert_eq!(context.root_event_id.as_deref(), Some("root-id")); + assert_eq!(context.prev_event_id.as_deref(), Some("prev-id")); + assert!(context.listing_event.is_none()); + } } diff --git a/crates/events-codec/src/trade/encode.rs b/crates/events-codec/src/trade/encode.rs @@ -2,19 +2,58 @@ use alloc::string::String; #[cfg(feature = "serde_json")] -use radroots_events::trade::{RadrootsTradeEnvelope, RadrootsTradeMessageType}; +use radroots_events::{ + RadrootsNostrEventPtr, + trade::{ + RadrootsTradeEnvelope, RadrootsTradeEnvelopeError, RadrootsTradeMessagePayload, + RadrootsTradeMessageType, + }, +}; #[cfg(feature = "serde_json")] -use crate::{trade::tags::trade_envelope_tags, wire::WireEventParts}; +use crate::{error::EventEncodeError, trade::tags::trade_envelope_tags, wire::WireEventParts}; #[cfg(feature = "serde_json")] -pub fn trade_envelope_event_build<T: serde::Serialize + Clone>( +fn map_envelope_error(error: RadrootsTradeEnvelopeError) -> EventEncodeError { + match error { + RadrootsTradeEnvelopeError::MissingOrderId => { + EventEncodeError::EmptyRequiredField("order_id") + } + RadrootsTradeEnvelopeError::MissingListingAddr => { + EventEncodeError::EmptyRequiredField("listing_addr") + } + RadrootsTradeEnvelopeError::InvalidVersion { .. } => { + EventEncodeError::InvalidField("version") + } + } +} + +#[cfg(feature = "serde_json")] +pub fn trade_envelope_event_build( recipient_pubkey: impl Into<String>, message_type: RadrootsTradeMessageType, listing_addr: impl Into<String>, order_id: Option<String>, - payload: &T, -) -> Result<WireEventParts, serde_json::Error> { + listing_event: Option<&RadrootsNostrEventPtr>, + root_event_id: Option<&str>, + prev_event_id: Option<&str>, + payload: &RadrootsTradeMessagePayload, +) -> Result<WireEventParts, EventEncodeError> { + if payload.message_type() != message_type { + return Err(EventEncodeError::InvalidField("payload")); + } + if message_type.requires_listing_snapshot() && listing_event.is_none() { + return Err(EventEncodeError::EmptyRequiredField("listing_event.id")); + } + if message_type.requires_trade_chain() { + if root_event_id.is_none() { + return Err(EventEncodeError::EmptyRequiredField("root_event_id")); + } + if prev_event_id.is_none() { + return Err(EventEncodeError::EmptyRequiredField("prev_event_id")); + } + } + let listing_addr = listing_addr.into(); let envelope = RadrootsTradeEnvelope::new( message_type, @@ -22,8 +61,16 @@ pub fn trade_envelope_event_build<T: serde::Serialize + Clone>( order_id.clone(), payload.clone(), ); - let content = serde_json::to_string(&envelope)?; - let tags = trade_envelope_tags(recipient_pubkey, &listing_addr, order_id.as_deref()); + envelope.validate().map_err(map_envelope_error)?; + let content = serde_json::to_string(&envelope).map_err(|_| EventEncodeError::Json)?; + let tags = trade_envelope_tags( + recipient_pubkey, + &listing_addr, + order_id.as_deref(), + listing_event, + root_event_id, + prev_event_id, + )?; Ok(WireEventParts { kind: message_type.kind(), content, diff --git a/crates/events-codec/src/trade/mod.rs b/crates/events-codec/src/trade/mod.rs @@ -4,9 +4,13 @@ pub mod tags; #[cfg(feature = "serde_json")] pub use decode::{ - RadrootsTradeEnvelopeParseError, RadrootsTradeListingAddress, RadrootsTradeListingAddressError, - trade_envelope_from_event, + RadrootsTradeEnvelopeParseError, RadrootsTradeEventContext, RadrootsTradeListingAddress, + RadrootsTradeListingAddressError, trade_envelope_from_event, trade_event_context_from_tags, }; #[cfg(feature = "serde_json")] pub use encode::trade_envelope_event_build; -pub use tags::{push_trade_chain_tags, trade_envelope_tags, validate_trade_chain}; +pub use tags::{ + TAG_LISTING_EVENT, parse_trade_counterparty_tag, parse_trade_listing_event_tag, + parse_trade_prev_tag, parse_trade_root_tag, push_trade_chain_tags, trade_envelope_tags, + validate_trade_chain, +}; diff --git a/crates/events-codec/src/trade/tags.rs b/crates/events-codec/src/trade/tags.rs @@ -1,9 +1,17 @@ #[cfg(not(feature = "std"))] use alloc::{borrow::ToOwned, string::String, vec::Vec}; -use radroots_events::tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT}; +use radroots_events::{ + RadrootsNostrEventPtr, + tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT}, +}; -use crate::job::error::JobParseError; +use crate::{ + error::{EventEncodeError, EventParseError}, + job::error::JobParseError, +}; + +pub const TAG_LISTING_EVENT: &str = "listing_event"; #[inline] fn push_tag(tags: &mut Vec<Vec<String>>, name: &'static str, value: impl Into<String>) { @@ -13,24 +21,158 @@ fn push_tag(tags: &mut Vec<Vec<String>>, name: &'static str, value: impl Into<St tags.push(tag); } +fn build_event_ptr_tag( + name: &'static str, + ptr: &RadrootsNostrEventPtr, + field_prefix: &'static str, +) -> Result<Vec<String>, EventEncodeError> { + if ptr.id.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField(field_prefix)); + } + let mut tag = Vec::with_capacity(3); + tag.push(name.to_owned()); + tag.push(ptr.id.clone()); + if let Some(relay) = &ptr.relays { + if relay.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("listing_event.relays")); + } + tag.push(relay.clone()); + } + Ok(tag) +} + +fn parse_event_ptr_tag( + tags: &[Vec<String>], + name: &'static str, +) -> Result<Option<RadrootsNostrEventPtr>, EventParseError> { + let Some(tag) = tags + .iter() + .find(|tag| tag.first().map(|value| value.as_str()) == Some(name)) + else { + return Ok(None); + }; + let id = tag.get(1).ok_or(EventParseError::InvalidTag(name))?; + if id.trim().is_empty() { + return Err(EventParseError::InvalidTag(name)); + } + let relay = match tag.get(2) { + Some(value) if value.trim().is_empty() => return Err(EventParseError::InvalidTag(name)), + Some(value) => Some(value.clone()), + None => None, + }; + Ok(Some(RadrootsNostrEventPtr { + id: id.clone(), + relays: relay, + })) +} + #[inline] pub fn trade_envelope_tags<P, A, D>( recipient_pubkey: P, listing_addr: A, order_id: Option<D>, -) -> Vec<Vec<String>> + listing_event: Option<&RadrootsNostrEventPtr>, + root_event_id: Option<&str>, + prev_event_id: Option<&str>, +) -> Result<Vec<Vec<String>>, EventEncodeError> where P: Into<String>, A: Into<String>, D: Into<String>, { - let mut tags = Vec::with_capacity(2 + usize::from(order_id.is_some())); + let recipient_pubkey = recipient_pubkey.into(); + if recipient_pubkey.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("recipient_pubkey")); + } + let listing_addr = listing_addr.into(); + if listing_addr.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("listing_addr")); + } + + let mut capacity = 2 + usize::from(order_id.is_some()) + usize::from(listing_event.is_some()); + capacity += usize::from(root_event_id.is_some()) + usize::from(prev_event_id.is_some()); + let mut tags = Vec::with_capacity(capacity); push_tag(&mut tags, "p", recipient_pubkey); push_tag(&mut tags, "a", listing_addr); if let Some(order_id) = order_id { + let order_id = order_id.into(); + if order_id.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("order_id")); + } push_tag(&mut tags, TAG_D, order_id); } - tags + if let Some(listing_event) = listing_event { + tags.push(build_event_ptr_tag( + TAG_LISTING_EVENT, + listing_event, + "listing_event.id", + )?); + } + if let Some(root_event_id) = root_event_id { + if root_event_id.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("root_event_id")); + } + push_tag(&mut tags, TAG_E_ROOT, root_event_id); + } + if let Some(prev_event_id) = prev_event_id { + if prev_event_id.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("prev_event_id")); + } + push_tag(&mut tags, TAG_E_PREV, prev_event_id); + } + Ok(tags) +} + +#[inline] +pub fn parse_trade_counterparty_tag(tags: &[Vec<String>]) -> Result<String, EventParseError> { + let tag = tags + .iter() + .find(|tag| tag.first().map(|value| value.as_str()) == Some("p")) + .ok_or(EventParseError::MissingTag("p"))?; + let value = tag.get(1).ok_or(EventParseError::InvalidTag("p"))?; + if value.trim().is_empty() { + return Err(EventParseError::InvalidTag("p")); + } + Ok(value.clone()) +} + +#[inline] +pub fn parse_trade_listing_event_tag( + tags: &[Vec<String>], +) -> Result<Option<RadrootsNostrEventPtr>, EventParseError> { + parse_event_ptr_tag(tags, TAG_LISTING_EVENT) +} + +#[inline] +pub fn parse_trade_root_tag(tags: &[Vec<String>]) -> Result<Option<String>, EventParseError> { + let tag = match tags + .iter() + .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_E_ROOT)) + { + Some(tag) => tag, + None => return Ok(None), + }; + let value = tag.get(1).ok_or(EventParseError::InvalidTag(TAG_E_ROOT))?; + if value.trim().is_empty() { + return Err(EventParseError::InvalidTag(TAG_E_ROOT)); + } + Ok(Some(value.clone())) +} + +#[inline] +pub fn parse_trade_prev_tag(tags: &[Vec<String>]) -> Result<Option<String>, EventParseError> { + let tag = match tags + .iter() + .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_E_PREV)) + { + Some(tag) => tag, + None => return Ok(None), + }; + let value = tag.get(1).ok_or(EventParseError::InvalidTag(TAG_E_PREV))?; + if value.trim().is_empty() { + return Err(EventParseError::InvalidTag(TAG_E_PREV)); + } + Ok(Some(value.clone())) } #[inline] @@ -93,8 +235,13 @@ pub fn validate_trade_chain(tags: &[Vec<String>]) -> Result<(), JobParseError> { #[cfg(test)] mod tests { - use super::{push_trade_chain_tags, trade_envelope_tags, validate_trade_chain}; + use super::{ + TAG_LISTING_EVENT, parse_trade_counterparty_tag, parse_trade_listing_event_tag, + parse_trade_prev_tag, parse_trade_root_tag, push_trade_chain_tags, trade_envelope_tags, + validate_trade_chain, + }; use radroots_events::{ + RadrootsNostrEventPtr, kinds::KIND_LISTING, tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT}, }; @@ -102,7 +249,8 @@ mod tests { #[test] fn trade_envelope_tags_build_expected_tags() { let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg"); - let tags = trade_envelope_tags("pubkey", &listing_addr, Some("order-1")); + let tags = trade_envelope_tags("pubkey", &listing_addr, Some("order-1"), None, None, None) + .expect("trade tags"); let expected: Vec<Vec<String>> = vec![ vec![String::from("p"), String::from("pubkey")], vec![String::from("a"), listing_addr], @@ -112,6 +260,74 @@ mod tests { } #[test] + fn trade_envelope_tags_include_snapshot_and_chain_refs() { + let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg"); + let tags = trade_envelope_tags( + "buyer", + &listing_addr, + Some("order-1"), + Some(&RadrootsNostrEventPtr { + id: "listing-snapshot".into(), + relays: Some("wss://relay.example".into()), + }), + Some("root-event"), + Some("prev-event"), + ) + .expect("trade tags"); + assert!(tags.iter().any(|tag| { + tag.as_slice() + == [ + TAG_LISTING_EVENT.to_string(), + "listing-snapshot".to_string(), + "wss://relay.example".to_string(), + ] + })); + assert!( + tags.iter().any(|tag| { + tag.as_slice() == [TAG_E_ROOT.to_string(), "root-event".to_string()] + }) + ); + assert!( + tags.iter().any(|tag| { + tag.as_slice() == [TAG_E_PREV.to_string(), "prev-event".to_string()] + }) + ); + } + + #[test] + fn trade_envelope_tag_parsers_cover_public_context() { + let tags = vec![ + vec!["p".into(), "counterparty".into()], + vec![ + TAG_LISTING_EVENT.into(), + "snapshot".into(), + "wss://relay".into(), + ], + vec![TAG_E_ROOT.into(), "root".into()], + vec![TAG_E_PREV.into(), "prev".into()], + ]; + assert_eq!( + parse_trade_counterparty_tag(&tags).expect("counterparty"), + "counterparty" + ); + assert_eq!( + parse_trade_listing_event_tag(&tags).expect("snapshot"), + Some(RadrootsNostrEventPtr { + id: "snapshot".into(), + relays: Some("wss://relay".into()), + }) + ); + assert_eq!( + parse_trade_root_tag(&tags).expect("root"), + Some("root".into()) + ); + assert_eq!( + parse_trade_prev_tag(&tags).expect("prev"), + Some("prev".into()) + ); + } + + #[test] fn push_trade_chain_tags_adds_root_prev_and_trade_id() { let mut tags = Vec::new(); push_trade_chain_tags(&mut tags, "root", Some("prev"), Some("trade")); diff --git a/crates/events/src/trade.rs b/crates/events/src/trade.rs @@ -146,10 +146,7 @@ pub enum RadrootsTradeOrderChange { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsTradeOrderRevision { pub revision_id: String, - pub order_id: String, pub changes: Vec<RadrootsTradeOrderChange>, - #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] - pub reason: Option<String>, } #[cfg_attr(feature = "ts-rs", derive(TS))] @@ -185,9 +182,6 @@ pub struct RadrootsTradeOrder { ts(optional, type = "RadrootsCoreDiscountValue[] | null") )] pub discounts: Option<Vec<RadrootsCoreDiscountValue>>, - #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] - pub notes: Option<String>, - pub status: RadrootsTradeOrderStatus, } #[cfg_attr(feature = "ts-rs", derive(TS))] @@ -196,11 +190,6 @@ pub struct RadrootsTradeOrder { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsTradeQuestion { pub question_id: String, - #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] - pub order_id: Option<String>, - #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] - pub listing_addr: Option<String>, - pub question_text: String, } #[cfg_attr(feature = "ts-rs", derive(TS))] @@ -209,11 +198,6 @@ pub struct RadrootsTradeQuestion { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsTradeAnswer { pub question_id: String, - #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] - pub order_id: Option<String>, - #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] - pub listing_addr: Option<String>, - pub answer_text: String, } #[cfg_attr(feature = "ts-rs", derive(TS))] @@ -222,11 +206,8 @@ pub struct RadrootsTradeAnswer { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsTradeDiscountRequest { pub discount_id: String, - pub order_id: String, #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreDiscountValue"))] pub value: RadrootsCoreDiscountValue, - #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] - pub conditions: Option<String>, } #[cfg_attr(feature = "ts-rs", derive(TS))] @@ -235,11 +216,8 @@ pub struct RadrootsTradeDiscountRequest { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsTradeDiscountOffer { pub discount_id: String, - pub order_id: String, #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreDiscountValue"))] pub value: RadrootsCoreDiscountValue, - #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] - pub conditions: Option<String>, } #[cfg_attr(feature = "ts-rs", derive(TS))] @@ -283,12 +261,6 @@ pub enum RadrootsTradeFulfillmentStatus { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsTradeFulfillmentUpdate { pub status: RadrootsTradeFulfillmentStatus, - #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] - pub tracking: Option<String>, - #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] - pub eta: Option<String>, - #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] - pub notes: Option<String>, } #[cfg_attr(feature = "ts-rs", derive(TS))] @@ -298,8 +270,6 @@ pub struct RadrootsTradeFulfillmentUpdate { pub struct RadrootsTradeReceipt { pub acknowledged: bool, pub at: u64, - #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] - pub note: Option<String>, } #[cfg_attr(feature = "ts-rs", derive(TS))] @@ -481,6 +451,19 @@ impl RadrootsTradeMessageType { } #[inline] + pub const fn requires_listing_snapshot(self) -> bool { + matches!( + self, + Self::OrderRequest | Self::OrderRevision | Self::DiscountRequest | Self::DiscountOffer + ) + } + + #[inline] + pub const fn requires_trade_chain(self) -> bool { + self.is_public() && !matches!(self, Self::OrderRequest) + } + + #[inline] pub const fn is_request(self) -> bool { matches!( self, @@ -608,6 +591,30 @@ pub enum RadrootsTradeMessagePayload { Receipt(RadrootsTradeReceipt), } +impl RadrootsTradeMessagePayload { + #[inline] + pub const fn message_type(&self) -> RadrootsTradeMessageType { + match self { + Self::ListingValidateRequest(_) => RadrootsTradeMessageType::ListingValidateRequest, + Self::ListingValidateResult(_) => RadrootsTradeMessageType::ListingValidateResult, + Self::OrderRequest(_) => RadrootsTradeMessageType::OrderRequest, + Self::OrderResponse(_) => RadrootsTradeMessageType::OrderResponse, + Self::OrderRevision(_) => RadrootsTradeMessageType::OrderRevision, + Self::OrderRevisionAccept(_) => RadrootsTradeMessageType::OrderRevisionAccept, + Self::OrderRevisionDecline(_) => RadrootsTradeMessageType::OrderRevisionDecline, + Self::Question(_) => RadrootsTradeMessageType::Question, + Self::Answer(_) => RadrootsTradeMessageType::Answer, + Self::DiscountRequest(_) => RadrootsTradeMessageType::DiscountRequest, + Self::DiscountOffer(_) => RadrootsTradeMessageType::DiscountOffer, + Self::DiscountAccept(_) => RadrootsTradeMessageType::DiscountAccept, + Self::DiscountDecline(_) => RadrootsTradeMessageType::DiscountDecline, + Self::Cancel(_) => RadrootsTradeMessageType::Cancel, + Self::FulfillmentUpdate(_) => RadrootsTradeMessageType::FulfillmentUpdate, + Self::Receipt(_) => RadrootsTradeMessageType::Receipt, + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -641,8 +648,6 @@ mod tests { seller_pubkey: "seller".into(), items: vec![], discounts: None, - notes: None, - status: RadrootsTradeOrderStatus::Requested, }), ); assert_eq!( diff --git a/crates/trade/src/listing/dvm.rs b/crates/trade/src/listing/dvm.rs @@ -8,6 +8,8 @@ use radroots_events::{RadrootsNostrEvent, tags::TAG_D}; use radroots_events::{RadrootsNostrEventPtr, kinds::KIND_PROFILE}; use radroots_events_codec::d_tag::is_d_tag_base64url; #[cfg(feature = "serde_json")] +use radroots_events_codec::error::{EventEncodeError, EventParseError}; +#[cfg(feature = "serde_json")] use serde::de::DeserializeOwned; #[cfg(feature = "ts-rs")] use ts_rs::TS; @@ -28,6 +30,11 @@ use crate::listing::order::{ }; #[cfg(feature = "serde_json")] use crate::listing::tags::trade_listing_dvm_tags; +#[cfg(feature = "serde_json")] +use crate::listing::tags::{ + TAG_LISTING_EVENT, parse_trade_listing_counterparty_tag, parse_trade_listing_event_tag, + parse_trade_listing_prev_tag, parse_trade_listing_root_tag, +}; use crate::listing::validation::TradeListingValidationError; pub const TRADE_LISTING_DOMAIN: &str = "trade:listing"; @@ -128,6 +135,27 @@ impl TradeListingMessageType { } #[inline] + pub const fn requires_listing_snapshot(self) -> bool { + matches!( + self, + TradeListingMessageType::OrderRequest + | TradeListingMessageType::OrderRevision + | TradeListingMessageType::DiscountRequest + | TradeListingMessageType::DiscountOffer + ) + } + + #[inline] + pub const fn requires_trade_chain(self) -> bool { + !matches!( + self, + TradeListingMessageType::ListingValidateRequest + | TradeListingMessageType::ListingValidateResult + | TradeListingMessageType::OrderRequest + ) + } + + #[inline] pub const fn is_request(self) -> bool { matches!( self, @@ -212,13 +240,45 @@ pub struct TradeListingEnvelopeEvent { } #[cfg(feature = "serde_json")] -pub fn trade_listing_envelope_event_build<T: serde::Serialize + Clone>( +fn map_envelope_error(error: TradeListingEnvelopeError) -> EventEncodeError { + match error { + TradeListingEnvelopeError::MissingOrderId => { + EventEncodeError::EmptyRequiredField("order_id") + } + TradeListingEnvelopeError::MissingListingAddr => { + EventEncodeError::EmptyRequiredField("listing_addr") + } + TradeListingEnvelopeError::InvalidVersion { .. } => { + EventEncodeError::InvalidField("version") + } + } +} + +#[cfg(feature = "serde_json")] +pub fn trade_listing_envelope_event_build( recipient_pubkey: impl Into<String>, message_type: TradeListingMessageType, listing_addr: impl Into<String>, order_id: Option<String>, - payload: &T, -) -> Result<TradeListingEnvelopeEvent, serde_json::Error> { + listing_event: Option<&RadrootsNostrEventPtr>, + root_event_id: Option<&str>, + prev_event_id: Option<&str>, + payload: &TradeListingMessagePayload, +) -> Result<TradeListingEnvelopeEvent, EventEncodeError> { + if payload.message_type() != message_type { + return Err(EventEncodeError::InvalidField("payload")); + } + if message_type.requires_listing_snapshot() && listing_event.is_none() { + return Err(EventEncodeError::EmptyRequiredField("listing_event.id")); + } + if message_type.requires_trade_chain() { + if root_event_id.is_none() { + return Err(EventEncodeError::EmptyRequiredField("root_event_id")); + } + if prev_event_id.is_none() { + return Err(EventEncodeError::EmptyRequiredField("prev_event_id")); + } + } let listing_addr = listing_addr.into(); let envelope = TradeListingEnvelope::new( message_type, @@ -226,8 +286,16 @@ pub fn trade_listing_envelope_event_build<T: serde::Serialize + Clone>( order_id.clone(), payload.clone(), ); - let content = serde_json::to_string(&envelope)?; - let tags = trade_listing_dvm_tags(recipient_pubkey, &listing_addr, order_id.as_deref()); + envelope.validate().map_err(map_envelope_error)?; + let content = serde_json::to_string(&envelope).map_err(|_| EventEncodeError::Json)?; + let tags = trade_listing_dvm_tags( + recipient_pubkey, + &listing_addr, + order_id.as_deref(), + listing_event, + root_event_id, + prev_event_id, + )?; Ok(TradeListingEnvelopeEvent { kind: message_type.kind(), content, @@ -282,6 +350,15 @@ pub enum TradeListingEnvelopeParseError { } #[cfg(feature = "serde_json")] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TradeListingEventContext { + pub counterparty_pubkey: String, + pub listing_event: Option<RadrootsNostrEventPtr>, + pub root_event_id: Option<String>, + pub prev_event_id: Option<String>, +} + +#[cfg(feature = "serde_json")] impl core::fmt::Display for TradeListingEnvelopeParseError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { @@ -445,10 +522,61 @@ where } } + trade_listing_event_context_from_tags(envelope.message_type, &event.tags)?; + Ok(envelope) } } +#[cfg(feature = "serde_json")] +pub fn trade_listing_event_context_from_tags( + message_type: TradeListingMessageType, + tags: &[Vec<String>], +) -> Result<TradeListingEventContext, TradeListingEnvelopeParseError> { + let counterparty_pubkey = + parse_trade_listing_counterparty_tag(tags).map_err(map_event_parse_error)?; + let listing_event = parse_trade_listing_event_tag(tags).map_err(map_event_parse_error)?; + let root_event_id = parse_trade_listing_root_tag(tags).map_err(map_event_parse_error)?; + let prev_event_id = parse_trade_listing_prev_tag(tags).map_err(map_event_parse_error)?; + if message_type.requires_listing_snapshot() && listing_event.is_none() { + return Err(TradeListingEnvelopeParseError::MissingTag( + TAG_LISTING_EVENT, + )); + } + if message_type.requires_trade_chain() { + if root_event_id.is_none() { + return Err(TradeListingEnvelopeParseError::MissingTag( + radroots_events::tags::TAG_E_ROOT, + )); + } + if prev_event_id.is_none() { + return Err(TradeListingEnvelopeParseError::MissingTag( + radroots_events::tags::TAG_E_PREV, + )); + } + } + Ok(TradeListingEventContext { + counterparty_pubkey, + listing_event, + root_event_id, + prev_event_id, + }) +} + +#[cfg(feature = "serde_json")] +fn map_event_parse_error(error: EventParseError) -> TradeListingEnvelopeParseError { + match error { + EventParseError::MissingTag(tag) => TradeListingEnvelopeParseError::MissingTag(tag), + EventParseError::InvalidTag(tag) => TradeListingEnvelopeParseError::InvalidTag(tag), + EventParseError::InvalidKind { expected: _, got } => { + TradeListingEnvelopeParseError::InvalidKind(got) + } + EventParseError::InvalidNumber(tag, _) | EventParseError::InvalidJson(tag) => { + TradeListingEnvelopeParseError::InvalidTag(tag) + } + } +} + #[cfg_attr(feature = "ts-rs", derive(TS))] #[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -524,19 +652,61 @@ pub enum TradeListingMessagePayload { Receipt(TradeReceipt), } +impl TradeListingMessagePayload { + pub const fn message_type(&self) -> TradeListingMessageType { + match self { + TradeListingMessagePayload::ListingValidateRequest(_) => { + TradeListingMessageType::ListingValidateRequest + } + TradeListingMessagePayload::ListingValidateResult(_) => { + TradeListingMessageType::ListingValidateResult + } + TradeListingMessagePayload::OrderRequest(_) => TradeListingMessageType::OrderRequest, + TradeListingMessagePayload::OrderResponse(_) => TradeListingMessageType::OrderResponse, + TradeListingMessagePayload::OrderRevision(_) => TradeListingMessageType::OrderRevision, + TradeListingMessagePayload::OrderRevisionAccept(_) => { + TradeListingMessageType::OrderRevisionAccept + } + TradeListingMessagePayload::OrderRevisionDecline(_) => { + TradeListingMessageType::OrderRevisionDecline + } + TradeListingMessagePayload::Question(_) => TradeListingMessageType::Question, + TradeListingMessagePayload::Answer(_) => TradeListingMessageType::Answer, + TradeListingMessagePayload::DiscountRequest(_) => { + TradeListingMessageType::DiscountRequest + } + TradeListingMessagePayload::DiscountOffer(_) => TradeListingMessageType::DiscountOffer, + TradeListingMessagePayload::DiscountAccept(_) => { + TradeListingMessageType::DiscountAccept + } + TradeListingMessagePayload::DiscountDecline(_) => { + TradeListingMessageType::DiscountDecline + } + TradeListingMessagePayload::Cancel(_) => TradeListingMessageType::Cancel, + TradeListingMessagePayload::FulfillmentUpdate(_) => { + TradeListingMessageType::FulfillmentUpdate + } + TradeListingMessagePayload::Receipt(_) => TradeListingMessageType::Receipt, + } + } +} + #[cfg(test)] mod tests { use super::{ TradeListingAddress, TradeListingAddressError, TradeListingEnvelope, TradeListingEnvelopeError, TradeListingEnvelopeParseError, TradeListingMessagePayload, - TradeListingMessageType, TradeListingValidateRequest, trade_listing_envelope_event_build, + TradeListingMessageType, TradeListingValidateRequest, TradeOrderResponse, + trade_listing_envelope_event_build, }; - #[cfg(feature = "serde_json")] - use radroots_events::RadrootsNostrEvent; use radroots_events::kinds::KIND_LISTING; + #[cfg(feature = "serde_json")] + use radroots_events::{RadrootsNostrEvent, RadrootsNostrEventPtr}; + #[cfg(feature = "serde_json")] + use radroots_events_codec::error::EventEncodeError; #[cfg(feature = "serde_json")] - use crate::listing::order::{TradeOrder, TradeOrderItem, TradeOrderStatus}; + use crate::listing::order::{TradeOrder, TradeOrderItem}; #[test] fn envelope_requires_listing_addr() { @@ -776,8 +946,14 @@ mod tests { bin_count: 2, }], discounts: None, - notes: Some("deliver friday".into()), - status: TradeOrderStatus::Draft, + } + } + + #[cfg(feature = "serde_json")] + fn listing_snapshot() -> RadrootsNostrEventPtr { + RadrootsNostrEventPtr { + id: "listing-event-id".into(), + relays: None, } } @@ -790,11 +966,18 @@ mod tests { order_id: Option<&str>, payload: &TradeListingMessagePayload, ) -> RadrootsNostrEvent { + let message_type = payload.message_type(); + let listing_event = message_type + .requires_listing_snapshot() + .then(listing_snapshot); let built = trade_listing_envelope_event_build( recipient_pubkey, message_type, listing_addr.to_string(), order_id.map(str::to_string), + listing_event.as_ref(), + None, + None, payload, ) .expect("canonical envelope event"); @@ -810,45 +993,18 @@ mod tests { } #[cfg(feature = "serde_json")] - #[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] - struct EnvelopePayload { - fail: bool, - } - - #[cfg(feature = "serde_json")] - impl EnvelopePayload { - fn ok() -> Self { - Self { fail: false } - } - - fn fail() -> Self { - Self { fail: true } - } - } - - #[cfg(feature = "serde_json")] - impl serde::Serialize for EnvelopePayload { - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> - where - S: serde::Serializer, - { - if self.fail { - return Err(serde::ser::Error::custom("intentional")); - } - serializer.serialize_str("ok") - } - } - - #[cfg(feature = "serde_json")] #[test] - fn envelope_event_build_includes_order_tag() { + fn envelope_event_build_includes_order_and_snapshot_tags() { let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg"); - let payload = EnvelopePayload::ok(); + let payload = TradeListingMessagePayload::OrderRequest(base_order()); let built = super::trade_listing_envelope_event_build( "pubkey", TradeListingMessageType::OrderRequest, listing_addr.clone(), Some(String::from("order-1")), + Some(&listing_snapshot()), + None, + None, &payload, ) .unwrap(); @@ -859,19 +1015,25 @@ mod tests { serde_json::from_str(&built.content).unwrap(); assert_eq!(envelope.listing_addr, listing_addr.clone()); assert_eq!(envelope.order_id.as_deref(), Some("order-1")); - assert_eq!(built.tags.len(), 3); + assert_eq!(built.tags.len(), 4); } #[cfg(feature = "serde_json")] #[test] fn envelope_event_build_omits_order_tag_when_missing() { let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg"); - let payload = EnvelopePayload::ok(); + let payload = + TradeListingMessagePayload::ListingValidateRequest(TradeListingValidateRequest { + listing_event: None, + }); let built = super::trade_listing_envelope_event_build( "pubkey", TradeListingMessageType::ListingValidateRequest, listing_addr.clone(), None, + None, + None, + None, &payload, ) .unwrap(); @@ -890,18 +1052,46 @@ mod tests { #[cfg(feature = "serde_json")] #[test] - fn envelope_event_build_propagates_payload_serialization_error() { + fn envelope_event_build_requires_snapshot_for_order_request() { let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg"); - let payload = EnvelopePayload::fail(); + let payload = TradeListingMessagePayload::OrderRequest(base_order()); let err = super::trade_listing_envelope_event_build( "pubkey", - TradeListingMessageType::ListingValidateRequest, + TradeListingMessageType::OrderRequest, + listing_addr, + Some(String::from("order-1")), + None, + None, + None, + &payload, + ) + .unwrap_err(); + assert_eq!( + err, + EventEncodeError::EmptyRequiredField("listing_event.id") + ); + } + + #[cfg(feature = "serde_json")] + #[test] + fn envelope_event_build_requires_chain_tags_for_order_response() { + let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg"); + let payload = TradeListingMessagePayload::OrderResponse(TradeOrderResponse { + accepted: true, + reason: None, + }); + let err = super::trade_listing_envelope_event_build( + "buyer-pubkey", + TradeListingMessageType::OrderResponse, listing_addr, + Some(String::from("order-1")), + None, + None, None, &payload, ) .unwrap_err(); - assert!(err.to_string().contains("intentional")); + assert_eq!(err, EventEncodeError::EmptyRequiredField("root_event_id")); } #[cfg(feature = "serde_json")] diff --git a/crates/trade/src/listing/order.rs b/crates/trade/src/listing/order.rs @@ -36,10 +36,7 @@ pub enum TradeOrderChange { #[derive(Clone, Debug, PartialEq, Eq)] pub struct TradeOrderRevision { pub revision_id: String, - pub order_id: String, pub changes: Vec<TradeOrderChange>, - #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] - pub reason: Option<String>, } #[cfg_attr(feature = "ts-rs", derive(TS))] @@ -57,9 +54,6 @@ pub struct TradeOrder { ts(optional, type = "RadrootsCoreDiscountValue[] | null") )] pub discounts: Option<Vec<RadrootsCoreDiscountValue>>, - #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] - pub notes: Option<String>, - pub status: TradeOrderStatus, } #[cfg_attr(feature = "ts-rs", derive(TS))] @@ -86,11 +80,6 @@ pub enum TradeOrderStatus { #[derive(Clone, Debug, PartialEq, Eq)] pub struct TradeQuestion { pub question_id: String, - #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] - pub order_id: Option<String>, - #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] - pub listing_addr: Option<String>, - pub question_text: String, } #[cfg_attr(feature = "ts-rs", derive(TS))] @@ -99,11 +88,6 @@ pub struct TradeQuestion { #[derive(Clone, Debug, PartialEq, Eq)] pub struct TradeAnswer { pub question_id: String, - #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] - pub order_id: Option<String>, - #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] - pub listing_addr: Option<String>, - pub answer_text: String, } #[cfg_attr(feature = "ts-rs", derive(TS))] @@ -112,11 +96,8 @@ pub struct TradeAnswer { #[derive(Clone, Debug, PartialEq, Eq)] pub struct TradeDiscountRequest { pub discount_id: String, - pub order_id: String, #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreDiscountValue"))] pub value: RadrootsCoreDiscountValue, - #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] - pub conditions: Option<String>, } #[cfg_attr(feature = "ts-rs", derive(TS))] @@ -125,11 +106,8 @@ pub struct TradeDiscountRequest { #[derive(Clone, Debug, PartialEq, Eq)] pub struct TradeDiscountOffer { pub discount_id: String, - pub order_id: String, #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreDiscountValue"))] pub value: RadrootsCoreDiscountValue, - #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] - pub conditions: Option<String>, } #[cfg_attr(feature = "ts-rs", derive(TS))] @@ -173,12 +151,6 @@ pub enum TradeFulfillmentStatus { #[derive(Clone, Debug, PartialEq, Eq)] pub struct TradeFulfillmentUpdate { pub status: TradeFulfillmentStatus, - #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] - pub tracking: Option<String>, - #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] - pub eta: Option<String>, - #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] - pub notes: Option<String>, } #[cfg_attr(feature = "ts-rs", derive(TS))] @@ -188,6 +160,4 @@ pub struct TradeFulfillmentUpdate { pub struct TradeReceipt { pub acknowledged: bool, pub at: u64, - #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] - pub note: Option<String>, } diff --git a/crates/trade/src/listing/overlay.rs b/crates/trade/src/listing/overlay.rs @@ -496,6 +496,8 @@ fn order_backoffice_matches_query( #[cfg(test)] mod tests { + use std::cell::RefCell; + use super::{ RadrootsTradeBackofficeOverlayError, RadrootsTradeBackofficeOverlayStore, RadrootsTradeFulfillmentException, RadrootsTradeFulfillmentExceptionSeverity, @@ -524,12 +526,128 @@ mod tests { RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCorePercent, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, }; + use radroots_events::RadrootsNostrEventPtr; use radroots_events::listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, RadrootsListingDeliveryMethod, RadrootsListingFarmRef, RadrootsListingLocation, RadrootsListingProduct, RadrootsListingStatus, }; + #[derive(Clone, Debug)] + struct TestWorkflowChain { + buyer_pubkey: String, + seller_pubkey: String, + root_event_id: String, + last_event_id: String, + next_sequence: u32, + } + + thread_local! { + static TEST_WORKFLOW_CHAINS: RefCell<std::collections::BTreeMap<String, TestWorkflowChain>> = + RefCell::new(std::collections::BTreeMap::new()); + } + + fn listing_snapshot(listing_addr: &str) -> RadrootsNostrEventPtr { + RadrootsNostrEventPtr { + id: format!("snapshot:{listing_addr}"), + relays: None, + } + } + + fn seller_pubkey_from_listing_addr(listing_addr: &str) -> String { + listing_addr + .split(':') + .nth(1) + .unwrap_or_default() + .to_string() + } + + fn workflow_refs( + actor_pubkey: &str, + listing_addr: &str, + order_id: Option<&str>, + payload: &TradeListingMessagePayload, + ) -> ( + String, + String, + Option<RadrootsNostrEventPtr>, + Option<String>, + Option<String>, + ) { + let message_type = payload.message_type(); + let listing_event = message_type + .requires_listing_snapshot() + .then(|| listing_snapshot(listing_addr)); + let default_seller = seller_pubkey_from_listing_addr(listing_addr); + + match (message_type, order_id) { + (_, None) => ( + format!("event:no-order:{}:{actor_pubkey}", message_type.kind()), + default_seller, + listing_event, + None, + None, + ), + (crate::listing::dvm::TradeListingMessageType::OrderRequest, Some(order_id)) => { + let order = match payload { + TradeListingMessagePayload::OrderRequest(order) => order, + _ => unreachable!("order request payload should match message type"), + }; + let event_id = format!("{order_id}:request"); + TEST_WORKFLOW_CHAINS.with(|chains| { + chains.borrow_mut().insert( + order_id.to_string(), + TestWorkflowChain { + buyer_pubkey: order.buyer_pubkey.clone(), + seller_pubkey: order.seller_pubkey.clone(), + root_event_id: event_id.clone(), + last_event_id: event_id.clone(), + next_sequence: 1, + }, + ); + }); + ( + event_id, + order.seller_pubkey.clone(), + listing_event, + None, + None, + ) + } + (_, Some(order_id)) => TEST_WORKFLOW_CHAINS.with(|chains| { + let mut chains = chains.borrow_mut(); + let chain = + chains + .entry(order_id.to_string()) + .or_insert_with(|| TestWorkflowChain { + buyer_pubkey: String::from("buyer-pubkey"), + seller_pubkey: default_seller.clone(), + root_event_id: format!("{order_id}:root"), + last_event_id: format!("{order_id}:root"), + next_sequence: 1, + }); + let event_id = + format!("{order_id}:{}:{}", message_type.kind(), chain.next_sequence); + chain.next_sequence += 1; + let counterparty_pubkey = if actor_pubkey == chain.seller_pubkey { + chain.buyer_pubkey.clone() + } else { + chain.seller_pubkey.clone() + }; + let prev_event_id = chain.last_event_id.clone(); + let root_event_id = chain.root_event_id.clone(); + chain.last_event_id = event_id.clone(); + ( + event_id, + counterparty_pubkey, + listing_event, + Some(root_event_id), + Some(prev_event_id), + ) + }), + } + } + fn base_listing() -> RadrootsListing { RadrootsListing { d_tag: "AAAAAAAAAAAAAAAAAAAAAg".into(), @@ -673,8 +791,6 @@ mod tests { discounts: Some(vec![radroots_core::RadrootsCoreDiscountValue::Percent( RadrootsCorePercent::new(RadrootsCoreDecimal::from(10u32)), )]), - notes: Some("deliver friday".into()), - status: TradeOrderStatus::Draft, } } @@ -689,8 +805,6 @@ mod tests { bin_count: 3, }], discounts: None, - notes: Some("expedite".into()), - status: TradeOrderStatus::Draft, } } @@ -700,10 +814,17 @@ mod tests { order_id: Option<&str>, payload: TradeListingMessagePayload, ) -> RadrootsTradeOrderWorkflowMessage { + let (event_id, counterparty_pubkey, listing_event, root_event_id, prev_event_id) = + workflow_refs(actor_pubkey, listing_addr, order_id, &payload); RadrootsTradeOrderWorkflowMessage { + event_id, actor_pubkey: actor_pubkey.into(), + counterparty_pubkey, listing_addr: listing_addr.into(), order_id: order_id.map(str::to_string), + listing_event, + root_event_id, + prev_event_id, payload, } } @@ -816,9 +937,6 @@ mod tests { Some("order-2"), TradeListingMessagePayload::FulfillmentUpdate(TradeFulfillmentUpdate { status: TradeFulfillmentStatus::Delivered, - tracking: Some("track-2".into()), - eta: None, - notes: Some("in transit".into()), }), )) .expect("fulfilled"); @@ -830,7 +948,6 @@ mod tests { TradeListingMessagePayload::Receipt(TradeReceipt { acknowledged: true, at: 1_700_000_020, - note: Some("all good".into()), }), )) .expect("completed"); diff --git a/crates/trade/src/listing/projection.rs b/crates/trade/src/listing/projection.rs @@ -9,7 +9,7 @@ use std::collections::BTreeMap; use radroots_core::{RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountValue}; use radroots_events::{ - RadrootsNostrEvent, + RadrootsNostrEvent, RadrootsNostrEventPtr, kinds::{KIND_LISTING, is_listing_kind}, listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, @@ -34,6 +34,8 @@ use crate::listing::{ }, price_ext::BinPricingExt, }; +#[cfg(feature = "serde_json")] +use radroots_events_codec::trade::trade_event_context_from_tags; #[cfg_attr(feature = "ts-rs", derive(TS))] #[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] @@ -116,9 +118,11 @@ pub struct RadrootsTradeOrderWorkflowProjection { ts(optional, type = "RadrootsCoreDiscountValue[] | null") )] pub requested_discounts: Option<Vec<RadrootsCoreDiscountValue>>, - #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] - pub notes: Option<String>, pub status: TradeOrderStatus, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "RadrootsNostrEventPtr | null"))] + pub listing_snapshot: Option<RadrootsNostrEventPtr>, + pub root_event_id: String, + pub last_event_id: String, #[cfg_attr( feature = "ts-rs", ts(optional, type = "RadrootsCoreDiscountValue | null") @@ -166,10 +170,18 @@ pub struct RadrootsTradeOrderWorkflowProjection { #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsTradeOrderWorkflowMessage { + pub event_id: String, pub actor_pubkey: String, + pub counterparty_pubkey: String, pub listing_addr: String, #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] pub order_id: Option<String>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "RadrootsNostrEventPtr | null"))] + pub listing_event: Option<RadrootsNostrEventPtr>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub root_event_id: Option<String>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub prev_event_id: Option<String>, #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsTradeMessagePayload"))] pub payload: TradeListingMessagePayload, } @@ -421,6 +433,12 @@ pub enum RadrootsTradeProjectionError { InvalidRevisionResponse, NonOrderWorkflowMessage(TradeListingMessageType), UnauthorizedActor, + CounterpartyMismatch, + MissingListingSnapshot, + MissingTradeRootEventId, + MissingTradePrevEventId, + TradeThreadRootMismatch, + TradeThreadPrevMismatch, #[cfg(feature = "serde_json")] InvalidWorkflowEvent { error: TradeListingEnvelopeParseError, @@ -463,6 +481,24 @@ impl core::fmt::Display for RadrootsTradeProjectionError { write!(f, "non-order workflow message: {message_type:?}") } RadrootsTradeProjectionError::UnauthorizedActor => write!(f, "unauthorized actor"), + RadrootsTradeProjectionError::CounterpartyMismatch => { + write!(f, "counterparty pubkey mismatch") + } + RadrootsTradeProjectionError::MissingListingSnapshot => { + write!(f, "missing listing snapshot") + } + RadrootsTradeProjectionError::MissingTradeRootEventId => { + write!(f, "missing trade root event id") + } + RadrootsTradeProjectionError::MissingTradePrevEventId => { + write!(f, "missing trade previous event id") + } + RadrootsTradeProjectionError::TradeThreadRootMismatch => { + write!(f, "trade thread root mismatch") + } + RadrootsTradeProjectionError::TradeThreadPrevMismatch => { + write!(f, "trade thread previous event mismatch") + } #[cfg(feature = "serde_json")] RadrootsTradeProjectionError::InvalidWorkflowEvent { error } => write!(f, "{error}"), } @@ -630,16 +666,22 @@ impl RadrootsTradeOrderWorkflowProjection { } } - fn from_order_request(order: &TradeOrder) -> Self { - Self { + fn from_order_request( + message: &RadrootsTradeOrderWorkflowMessage, + order: &TradeOrder, + ) -> Result<Self, RadrootsTradeProjectionError> { + let listing_snapshot = require_listing_snapshot(message)?; + Ok(Self { order_id: order.order_id.clone(), listing_addr: order.listing_addr.clone(), buyer_pubkey: order.buyer_pubkey.clone(), seller_pubkey: order.seller_pubkey.clone(), items: order.items.clone(), requested_discounts: order.discounts.clone(), - notes: order.notes.clone(), status: TradeOrderStatus::Requested, + listing_snapshot: Some(listing_snapshot), + root_event_id: message.event_id.clone(), + last_event_id: message.event_id.clone(), last_discount_request: None, last_discount_offer: None, accepted_discount: None, @@ -660,7 +702,7 @@ impl RadrootsTradeOrderWorkflowProjection { receipt_count: 0, last_message_type: TradeListingMessageType::OrderRequest, last_actor_pubkey: order.buyer_pubkey.clone(), - } + }) } } @@ -668,10 +710,16 @@ impl RadrootsTradeOrderWorkflowMessage { #[cfg(feature = "serde_json")] pub fn from_event(event: &RadrootsNostrEvent) -> Result<Self, TradeListingEnvelopeParseError> { let envelope = trade_listing_envelope_from_event::<TradeListingMessagePayload>(event)?; + let context = trade_event_context_from_tags(envelope.message_type, &event.tags)?; Ok(Self { + event_id: event.id.clone(), actor_pubkey: event.author.clone(), + counterparty_pubkey: context.counterparty_pubkey, listing_addr: envelope.listing_addr, order_id: envelope.order_id, + listing_event: context.listing_event, + root_event_id: context.root_event_id, + prev_event_id: context.prev_event_id, payload: envelope.payload, }) } @@ -915,6 +963,8 @@ impl RadrootsTradeReadIndex { let order_id = required_order_id(message)?; let order = self.order_mut_checked(order_id, &message.listing_addr)?; ensure_actor(&order.seller_pubkey, &message.actor_pubkey)?; + ensure_counterparty(&order.buyer_pubkey, &message.counterparty_pubkey)?; + ensure_trade_chain(order, message)?; let next_status = if response.accepted { TradeOrderStatus::Accepted } else { @@ -928,15 +978,15 @@ impl RadrootsTradeReadIndex { order.last_message_type = TradeListingMessageType::OrderResponse; order.last_actor_pubkey = message.actor_pubkey.clone(); order.last_reason = response.reason.clone(); + order.last_event_id = message.event_id.clone(); Ok(order_id.to_string()) } TradeListingMessagePayload::OrderRevision(revision) => { let order_id = required_order_id(message)?; - if revision.order_id != order_id { - return Err(RadrootsTradeProjectionError::OrderIdMismatch); - } let order = self.order_mut_checked(order_id, &message.listing_addr)?; ensure_actor(&order.seller_pubkey, &message.actor_pubkey)?; + ensure_counterparty(&order.buyer_pubkey, &message.counterparty_pubkey)?; + ensure_trade_chain(order, message)?; radroots_trade_order_status_ensure_transition( order.status.clone(), TradeOrderStatus::Revised, @@ -944,11 +994,13 @@ impl RadrootsTradeReadIndex { for change in &revision.changes { apply_order_change(&mut order.items, change)?; } + order.listing_snapshot = Some(require_listing_snapshot(message)?); order.status = TradeOrderStatus::Revised; order.revision_count = order.revision_count.saturating_add(1); order.last_message_type = TradeListingMessageType::OrderRevision; order.last_actor_pubkey = message.actor_pubkey.clone(); - order.last_reason = revision.reason.clone(); + order.last_reason = None; + order.last_event_id = message.event_id.clone(); Ok(order_id.to_string()) } TradeListingMessagePayload::OrderRevisionAccept(response) => { @@ -958,6 +1010,8 @@ impl RadrootsTradeReadIndex { } let order = self.order_mut_checked(order_id, &message.listing_addr)?; ensure_actor(&order.buyer_pubkey, &message.actor_pubkey)?; + ensure_counterparty(&order.seller_pubkey, &message.counterparty_pubkey)?; + ensure_trade_chain(order, message)?; radroots_trade_order_status_ensure_transition( order.status.clone(), TradeOrderStatus::Accepted, @@ -966,6 +1020,7 @@ impl RadrootsTradeReadIndex { order.last_message_type = TradeListingMessageType::OrderRevisionAccept; order.last_actor_pubkey = message.actor_pubkey.clone(); order.last_reason = response.reason.clone(); + order.last_event_id = message.event_id.clone(); Ok(order_id.to_string()) } TradeListingMessagePayload::OrderRevisionDecline(response) => { @@ -975,6 +1030,8 @@ impl RadrootsTradeReadIndex { } let order = self.order_mut_checked(order_id, &message.listing_addr)?; ensure_actor(&order.buyer_pubkey, &message.actor_pubkey)?; + ensure_counterparty(&order.seller_pubkey, &message.counterparty_pubkey)?; + ensure_trade_chain(order, message)?; radroots_trade_order_status_ensure_transition( order.status.clone(), TradeOrderStatus::Declined, @@ -983,19 +1040,15 @@ impl RadrootsTradeReadIndex { order.last_message_type = TradeListingMessageType::OrderRevisionDecline; order.last_actor_pubkey = message.actor_pubkey.clone(); order.last_reason = response.reason.clone(); + order.last_event_id = message.event_id.clone(); Ok(order_id.to_string()) } TradeListingMessagePayload::Question(question) => { let order_id = required_order_id(message)?; - if question - .order_id - .as_deref() - .is_some_and(|value| value != order_id) - { - return Err(RadrootsTradeProjectionError::OrderIdMismatch); - } let order = self.order_mut_checked(order_id, &message.listing_addr)?; ensure_actor(&order.buyer_pubkey, &message.actor_pubkey)?; + ensure_counterparty(&order.seller_pubkey, &message.counterparty_pubkey)?; + ensure_trade_chain(order, message)?; radroots_trade_order_status_ensure_transition( order.status.clone(), TradeOrderStatus::Questioned, @@ -1004,19 +1057,16 @@ impl RadrootsTradeReadIndex { order.question_count = order.question_count.saturating_add(1); order.last_message_type = TradeListingMessageType::Question; order.last_actor_pubkey = message.actor_pubkey.clone(); + order.last_reason = Some(question.question_id.clone()); + order.last_event_id = message.event_id.clone(); Ok(order_id.to_string()) } TradeListingMessagePayload::Answer(answer) => { let order_id = required_order_id(message)?; - if answer - .order_id - .as_deref() - .is_some_and(|value| value != order_id) - { - return Err(RadrootsTradeProjectionError::OrderIdMismatch); - } let order = self.order_mut_checked(order_id, &message.listing_addr)?; ensure_actor(&order.seller_pubkey, &message.actor_pubkey)?; + ensure_counterparty(&order.buyer_pubkey, &message.counterparty_pubkey)?; + ensure_trade_chain(order, message)?; radroots_trade_order_status_ensure_transition( order.status.clone(), TradeOrderStatus::Requested, @@ -1025,29 +1075,31 @@ impl RadrootsTradeReadIndex { order.answer_count = order.answer_count.saturating_add(1); order.last_message_type = TradeListingMessageType::Answer; order.last_actor_pubkey = message.actor_pubkey.clone(); + order.last_reason = Some(answer.question_id.clone()); + order.last_event_id = message.event_id.clone(); Ok(order_id.to_string()) } TradeListingMessagePayload::DiscountRequest(request) => { let order_id = required_order_id(message)?; - if request.order_id != order_id { - return Err(RadrootsTradeProjectionError::OrderIdMismatch); - } let order = self.order_mut_checked(order_id, &message.listing_addr)?; ensure_actor(&order.buyer_pubkey, &message.actor_pubkey)?; + ensure_counterparty(&order.seller_pubkey, &message.counterparty_pubkey)?; + ensure_trade_chain(order, message)?; order.discount_request_count = order.discount_request_count.saturating_add(1); order.last_discount_request = Some(request.value.clone()); + order.listing_snapshot = Some(require_listing_snapshot(message)?); order.last_message_type = TradeListingMessageType::DiscountRequest; order.last_actor_pubkey = message.actor_pubkey.clone(); - order.last_reason = request.conditions.clone(); + order.last_reason = None; + order.last_event_id = message.event_id.clone(); Ok(order_id.to_string()) } TradeListingMessagePayload::DiscountOffer(offer) => { let order_id = required_order_id(message)?; - if offer.order_id != order_id { - return Err(RadrootsTradeProjectionError::OrderIdMismatch); - } let order = self.order_mut_checked(order_id, &message.listing_addr)?; ensure_actor(&order.seller_pubkey, &message.actor_pubkey)?; + ensure_counterparty(&order.buyer_pubkey, &message.counterparty_pubkey)?; + ensure_trade_chain(order, message)?; radroots_trade_order_status_ensure_transition( order.status.clone(), TradeOrderStatus::Revised, @@ -1055,15 +1107,19 @@ impl RadrootsTradeReadIndex { order.status = TradeOrderStatus::Revised; order.discount_offer_count = order.discount_offer_count.saturating_add(1); order.last_discount_offer = Some(offer.value.clone()); + order.listing_snapshot = Some(require_listing_snapshot(message)?); order.last_message_type = TradeListingMessageType::DiscountOffer; order.last_actor_pubkey = message.actor_pubkey.clone(); - order.last_reason = offer.conditions.clone(); + order.last_reason = None; + order.last_event_id = message.event_id.clone(); Ok(order_id.to_string()) } TradeListingMessagePayload::DiscountAccept(decision) => { let order_id = required_order_id(message)?; let order = self.order_mut_checked(order_id, &message.listing_addr)?; ensure_actor(&order.buyer_pubkey, &message.actor_pubkey)?; + ensure_counterparty(&order.seller_pubkey, &message.counterparty_pubkey)?; + ensure_trade_chain(order, message)?; let TradeDiscountDecisionValue::Accepted(value) = trade_discount_decision_value(decision)? else { @@ -1080,12 +1136,15 @@ impl RadrootsTradeReadIndex { order.last_message_type = TradeListingMessageType::DiscountAccept; order.last_actor_pubkey = message.actor_pubkey.clone(); order.last_reason = None; + order.last_event_id = message.event_id.clone(); Ok(order_id.to_string()) } TradeListingMessagePayload::DiscountDecline(decision) => { let order_id = required_order_id(message)?; let order = self.order_mut_checked(order_id, &message.listing_addr)?; ensure_actor(&order.buyer_pubkey, &message.actor_pubkey)?; + ensure_counterparty(&order.seller_pubkey, &message.counterparty_pubkey)?; + ensure_trade_chain(order, message)?; let TradeDiscountDecisionValue::Declined(reason) = trade_discount_decision_value(decision)? else { @@ -1101,6 +1160,7 @@ impl RadrootsTradeReadIndex { order.last_message_type = TradeListingMessageType::DiscountDecline; order.last_actor_pubkey = message.actor_pubkey.clone(); order.last_reason = reason; + order.last_event_id = message.event_id.clone(); Ok(order_id.to_string()) } TradeListingMessagePayload::Cancel(cancel) => { @@ -1111,6 +1171,13 @@ impl RadrootsTradeReadIndex { { return Err(RadrootsTradeProjectionError::UnauthorizedActor); } + let expected_counterparty = if order.buyer_pubkey == message.actor_pubkey { + &order.seller_pubkey + } else { + &order.buyer_pubkey + }; + ensure_counterparty(expected_counterparty, &message.counterparty_pubkey)?; + ensure_trade_chain(order, message)?; radroots_trade_order_status_ensure_transition( order.status.clone(), TradeOrderStatus::Cancelled, @@ -1120,12 +1187,15 @@ impl RadrootsTradeReadIndex { order.last_message_type = TradeListingMessageType::Cancel; order.last_actor_pubkey = message.actor_pubkey.clone(); order.last_reason = cancel.reason.clone(); + order.last_event_id = message.event_id.clone(); Ok(order_id.to_string()) } TradeListingMessagePayload::FulfillmentUpdate(update) => { let order_id = required_order_id(message)?; let order = self.order_mut_checked(order_id, &message.listing_addr)?; ensure_actor(&order.seller_pubkey, &message.actor_pubkey)?; + ensure_counterparty(&order.buyer_pubkey, &message.counterparty_pubkey)?; + ensure_trade_chain(order, message)?; if let Some(next_status) = trade_order_status_for_fulfillment_update(&order.status, &update.status)? { @@ -1135,13 +1205,16 @@ impl RadrootsTradeReadIndex { order.last_fulfillment_status = Some(update.status.clone()); order.last_message_type = TradeListingMessageType::FulfillmentUpdate; order.last_actor_pubkey = message.actor_pubkey.clone(); - order.last_reason = update.notes.clone(); + order.last_reason = None; + order.last_event_id = message.event_id.clone(); Ok(order_id.to_string()) } TradeListingMessagePayload::Receipt(receipt) => { let order_id = required_order_id(message)?; let order = self.order_mut_checked(order_id, &message.listing_addr)?; ensure_actor(&order.buyer_pubkey, &message.actor_pubkey)?; + ensure_counterparty(&order.seller_pubkey, &message.counterparty_pubkey)?; + ensure_trade_chain(order, message)?; if let Some(next_status) = trade_order_status_for_receipt(&order.status, receipt.acknowledged)? { @@ -1152,7 +1225,8 @@ impl RadrootsTradeReadIndex { order.receipt_at = Some(receipt.at); order.last_message_type = TradeListingMessageType::Receipt; order.last_actor_pubkey = message.actor_pubkey.clone(); - order.last_reason = receipt.note.clone(); + order.last_reason = None; + order.last_event_id = message.event_id.clone(); Ok(order_id.to_string()) } } @@ -1174,8 +1248,9 @@ impl RadrootsTradeReadIndex { return Err(RadrootsTradeProjectionError::ListingAddrMismatch); } ensure_actor(&order.buyer_pubkey, &message.actor_pubkey)?; + ensure_counterparty(&order.seller_pubkey, &message.counterparty_pubkey)?; radroots_trade_order_status_ensure_transition( - order.status.clone(), + TradeOrderStatus::Draft, TradeOrderStatus::Requested, )?; @@ -1191,7 +1266,7 @@ impl RadrootsTradeReadIndex { self.orders.insert( order.order_id.clone(), - RadrootsTradeOrderWorkflowProjection::from_order_request(order), + RadrootsTradeOrderWorkflowProjection::from_order_request(message, order)?, ); Ok(order.order_id.clone()) } @@ -1366,6 +1441,15 @@ fn required_order_id( .ok_or(RadrootsTradeProjectionError::MissingOrderId) } +fn require_listing_snapshot( + message: &RadrootsTradeOrderWorkflowMessage, +) -> Result<RadrootsNostrEventPtr, RadrootsTradeProjectionError> { + message + .listing_event + .clone() + .ok_or(RadrootsTradeProjectionError::MissingListingSnapshot) +} + fn ensure_actor(expected: &str, actual: &str) -> Result<(), RadrootsTradeProjectionError> { if expected == actual { Ok(()) @@ -1374,6 +1458,35 @@ fn ensure_actor(expected: &str, actual: &str) -> Result<(), RadrootsTradeProject } } +fn ensure_counterparty(expected: &str, actual: &str) -> Result<(), RadrootsTradeProjectionError> { + if expected == actual { + Ok(()) + } else { + Err(RadrootsTradeProjectionError::CounterpartyMismatch) + } +} + +fn ensure_trade_chain( + order: &RadrootsTradeOrderWorkflowProjection, + message: &RadrootsTradeOrderWorkflowMessage, +) -> Result<(), RadrootsTradeProjectionError> { + let root_event_id = message + .root_event_id + .as_deref() + .ok_or(RadrootsTradeProjectionError::MissingTradeRootEventId)?; + if root_event_id != order.root_event_id { + return Err(RadrootsTradeProjectionError::TradeThreadRootMismatch); + } + let prev_event_id = message + .prev_event_id + .as_deref() + .ok_or(RadrootsTradeProjectionError::MissingTradePrevEventId)?; + if prev_event_id != order.last_event_id { + return Err(RadrootsTradeProjectionError::TradeThreadPrevMismatch); + } + Ok(()) +} + fn apply_order_change( items: &mut Vec<TradeOrderItem>, change: &TradeOrderChange, @@ -1663,6 +1776,8 @@ fn facet_counts_from_map(counts: BTreeMap<String, u32>) -> Vec<RadrootsTradeFace #[cfg(test)] mod tests { + use std::cell::RefCell; + use super::{ RadrootsTradeListingMarketStatus, RadrootsTradeListingQuery, RadrootsTradeListingSort, RadrootsTradeListingSortField, RadrootsTradeOrderQuery, RadrootsTradeOrderSort, @@ -1691,7 +1806,122 @@ mod tests { RadrootsListingDeliveryMethod, RadrootsListingFarmRef, RadrootsListingLocation, RadrootsListingProduct, RadrootsListingStatus, }; - use radroots_events::{RadrootsNostrEvent, kinds::KIND_LISTING}; + use radroots_events::{RadrootsNostrEvent, RadrootsNostrEventPtr, kinds::KIND_LISTING}; + + #[derive(Clone, Debug)] + struct TestWorkflowChain { + buyer_pubkey: String, + seller_pubkey: String, + root_event_id: String, + last_event_id: String, + next_sequence: u32, + } + + thread_local! { + static TEST_WORKFLOW_CHAINS: RefCell<std::collections::BTreeMap<String, TestWorkflowChain>> = + RefCell::new(std::collections::BTreeMap::new()); + } + + fn listing_snapshot(listing_addr: &str) -> RadrootsNostrEventPtr { + RadrootsNostrEventPtr { + id: format!("snapshot:{listing_addr}"), + relays: None, + } + } + + fn seller_pubkey_from_listing_addr(listing_addr: &str) -> String { + listing_addr + .split(':') + .nth(1) + .unwrap_or_default() + .to_string() + } + + fn workflow_refs( + actor_pubkey: &str, + listing_addr: &str, + order_id: Option<&str>, + payload: &TradeListingMessagePayload, + ) -> ( + String, + String, + Option<RadrootsNostrEventPtr>, + Option<String>, + Option<String>, + ) { + let message_type = payload.message_type(); + let listing_event = message_type + .requires_listing_snapshot() + .then(|| listing_snapshot(listing_addr)); + let default_seller = seller_pubkey_from_listing_addr(listing_addr); + + match (message_type, order_id) { + (_, None) => ( + format!("event:no-order:{}:{actor_pubkey}", message_type.kind()), + default_seller, + listing_event, + None, + None, + ), + (crate::listing::dvm::TradeListingMessageType::OrderRequest, Some(order_id)) => { + let order = match payload { + TradeListingMessagePayload::OrderRequest(order) => order, + _ => unreachable!("order request payload should match message type"), + }; + let event_id = format!("{order_id}:request"); + TEST_WORKFLOW_CHAINS.with(|chains| { + chains.borrow_mut().insert( + order_id.to_string(), + TestWorkflowChain { + buyer_pubkey: order.buyer_pubkey.clone(), + seller_pubkey: order.seller_pubkey.clone(), + root_event_id: event_id.clone(), + last_event_id: event_id.clone(), + next_sequence: 1, + }, + ); + }); + ( + event_id, + order.seller_pubkey.clone(), + listing_event, + None, + None, + ) + } + (_, Some(order_id)) => TEST_WORKFLOW_CHAINS.with(|chains| { + let mut chains = chains.borrow_mut(); + let chain = + chains + .entry(order_id.to_string()) + .or_insert_with(|| TestWorkflowChain { + buyer_pubkey: String::from("buyer-pubkey"), + seller_pubkey: default_seller.clone(), + root_event_id: format!("{order_id}:root"), + last_event_id: format!("{order_id}:root"), + next_sequence: 1, + }); + let event_id = + format!("{order_id}:{}:{}", message_type.kind(), chain.next_sequence); + chain.next_sequence += 1; + let counterparty_pubkey = if actor_pubkey == chain.seller_pubkey { + chain.buyer_pubkey.clone() + } else { + chain.seller_pubkey.clone() + }; + let prev_event_id = chain.last_event_id.clone(); + let root_event_id = chain.root_event_id.clone(); + chain.last_event_id = event_id.clone(); + ( + event_id, + counterparty_pubkey, + listing_event, + Some(root_event_id), + Some(prev_event_id), + ) + }), + } + } fn base_listing() -> RadrootsListing { RadrootsListing { @@ -1795,8 +2025,6 @@ mod tests { discounts: Some(vec![radroots_core::RadrootsCoreDiscountValue::Percent( RadrootsCorePercent::new(RadrootsCoreDecimal::from(10u32)), )]), - notes: Some("deliver friday".into()), - status: TradeOrderStatus::Requested, } } @@ -1883,8 +2111,6 @@ mod tests { }, ], discounts: None, - notes: Some("expedite".into()), - status: TradeOrderStatus::Requested, } } @@ -1894,10 +2120,17 @@ mod tests { order_id: Option<&str>, payload: TradeListingMessagePayload, ) -> RadrootsTradeOrderWorkflowMessage { + let (event_id, counterparty_pubkey, listing_event, root_event_id, prev_event_id) = + workflow_refs(actor_pubkey, listing_addr, order_id, &payload); RadrootsTradeOrderWorkflowMessage { + event_id, actor_pubkey: actor_pubkey.into(), + counterparty_pubkey, listing_addr: listing_addr.into(), order_id: order_id.map(str::to_string), + listing_event, + root_event_id, + prev_event_id, payload, } } @@ -1922,11 +2155,16 @@ mod tests { order_id: Option<&str>, payload: &TradeListingMessagePayload, ) -> RadrootsNostrEvent { + let (_, _, listing_event, root_event_id, prev_event_id) = + workflow_refs(actor_pubkey, listing_addr, order_id, payload); let built = trade_listing_envelope_event_build( recipient_pubkey, message_type, listing_addr.to_string(), order_id.map(str::to_string), + listing_event.as_ref(), + root_event_id.as_deref(), + prev_event_id.as_deref(), payload, ) .expect("trade workflow event"); @@ -2045,9 +2283,6 @@ mod tests { Some("order-1"), TradeListingMessagePayload::FulfillmentUpdate(TradeFulfillmentUpdate { status: TradeFulfillmentStatus::Delivered, - tracking: Some("track-1".into()), - eta: None, - notes: Some("left at dock".into()), }), )) .expect("fulfillment"); @@ -2066,7 +2301,6 @@ mod tests { TradeListingMessagePayload::Receipt(TradeReceipt { acknowledged: true, at: 1_700_000_000, - note: Some("received".into()), }), )) .expect("receipt"); @@ -2112,9 +2346,6 @@ mod tests { Some("order-1"), TradeListingMessagePayload::FulfillmentUpdate(TradeFulfillmentUpdate { status: TradeFulfillmentStatus::Shipped, - tracking: Some("track-1".into()), - eta: None, - notes: Some("left warehouse".into()), }), )) .expect("fulfillment update"); @@ -2164,9 +2395,6 @@ mod tests { Some("order-1"), TradeListingMessagePayload::FulfillmentUpdate(TradeFulfillmentUpdate { status: TradeFulfillmentStatus::Delivered, - tracking: Some("track-1".into()), - eta: None, - notes: Some("left at dock".into()), }), )) .expect("fulfilled"); @@ -2178,7 +2406,6 @@ mod tests { TradeListingMessagePayload::Receipt(TradeReceipt { acknowledged: false, at: 1_700_000_000, - note: Some("received pending inspection".into()), }), )) .expect("receipt"); @@ -2212,9 +2439,6 @@ mod tests { Some("order-1"), TradeListingMessagePayload::Question(TradeQuestion { question_id: "q-1".into(), - order_id: Some("order-1".into()), - listing_addr: None, - question_text: "can you pack separately?".into(), }), )) .expect("question"); @@ -2225,9 +2449,6 @@ mod tests { Some("order-1"), TradeListingMessagePayload::Answer(TradeAnswer { question_id: "q-1".into(), - order_id: Some("order-1".into()), - listing_addr: None, - answer_text: "yes".into(), }), )) .expect("answer"); @@ -2243,7 +2464,6 @@ mod tests { Some("order-1"), TradeListingMessagePayload::OrderRevision(TradeOrderRevision { revision_id: "rev-1".into(), - order_id: "order-1".into(), changes: vec![ TradeOrderChange::BinCount { item_index: 0, @@ -2256,7 +2476,6 @@ mod tests { }, }, ], - reason: Some("limited stock".into()), }), )) .expect("order revision"); @@ -2286,11 +2505,9 @@ mod tests { Some("order-1"), TradeListingMessagePayload::DiscountRequest(TradeDiscountRequest { discount_id: "disc-1".into(), - order_id: "order-1".into(), value: radroots_core::RadrootsCoreDiscountValue::Percent( RadrootsCorePercent::new(RadrootsCoreDecimal::from(15u32)), ), - conditions: Some("volume".into()), }), )) .expect("discount request"); @@ -2301,11 +2518,9 @@ mod tests { Some("order-1"), TradeListingMessagePayload::DiscountOffer(TradeDiscountOffer { discount_id: "disc-1".into(), - order_id: "order-1".into(), value: radroots_core::RadrootsCoreDiscountValue::Percent( RadrootsCorePercent::new(RadrootsCoreDecimal::from(12u32)), ), - conditions: Some("counter".into()), }), )) .expect("discount offer"); @@ -2373,9 +2588,6 @@ mod tests { None, TradeListingMessagePayload::Question(TradeQuestion { question_id: "q-1".into(), - order_id: Some("order-1".into()), - listing_addr: None, - question_text: "hello".into(), }), )) .expect_err("missing order id should fail"); @@ -2412,24 +2624,44 @@ mod tests { RadrootsTradeProjectionError::MissingPrimaryBin("missing".into()) ); - let err = index + let order = index .apply_workflow_message(&message( "buyer-pubkey", "30402:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAg", Some("order-1"), - TradeListingMessagePayload::OrderRequest(TradeOrder { - status: TradeOrderStatus::Accepted, - ..base_order() - }), + TradeListingMessagePayload::OrderRequest(base_order()), )) - .expect_err("non-requested order request should fail"); + .expect("order request"); assert_eq!( - err, - RadrootsTradeProjectionError::InvalidTransition { - from: TradeOrderStatus::Accepted, - to: TradeOrderStatus::Requested, - } + order.status, + TradeOrderStatus::Requested, + "canonical helper should still create a requested order" ); + + let err = index + .apply_workflow_message(&RadrootsTradeOrderWorkflowMessage { + event_id: "missing-snapshot".into(), + actor_pubkey: "buyer-pubkey".into(), + counterparty_pubkey: "seller-pubkey".into(), + listing_addr: "30402:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAg".into(), + order_id: Some("order-2".into()), + listing_event: None, + root_event_id: None, + prev_event_id: None, + payload: TradeListingMessagePayload::OrderRequest(TradeOrder { + order_id: "order-2".into(), + listing_addr: "30402:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAg".into(), + buyer_pubkey: "buyer-pubkey".into(), + seller_pubkey: "seller-pubkey".into(), + items: vec![TradeOrderItem { + bin_id: "bin-1".into(), + bin_count: 1, + }], + discounts: None, + }), + }) + .expect_err("order request without snapshot should fail"); + assert_eq!(err, RadrootsTradeProjectionError::MissingListingSnapshot); } #[test] @@ -2500,9 +2732,6 @@ mod tests { Some("order-2"), TradeListingMessagePayload::FulfillmentUpdate(TradeFulfillmentUpdate { status: TradeFulfillmentStatus::Delivered, - tracking: None, - eta: None, - notes: Some("shipped".into()), }), )) .expect("order fulfilled"); @@ -2514,7 +2743,6 @@ mod tests { TradeListingMessagePayload::Receipt(TradeReceipt { acknowledged: true, at: 1_700_000_010, - note: Some("received".into()), }), )) .expect("order receipt"); @@ -2613,9 +2841,6 @@ mod tests { Some("order-2"), TradeListingMessagePayload::FulfillmentUpdate(TradeFulfillmentUpdate { status: TradeFulfillmentStatus::Delivered, - tracking: Some("track-2".into()), - eta: None, - notes: Some("in transit".into()), }), )) .expect("fulfilled"); @@ -2627,7 +2852,6 @@ mod tests { TradeListingMessagePayload::Receipt(TradeReceipt { acknowledged: true, at: 1_700_000_020, - note: Some("all good".into()), }), )) .expect("completed"); @@ -2663,7 +2887,7 @@ mod tests { assert_eq!(summaries[0].item_count, 2); assert_eq!(summaries[0].total_bin_count, 4); assert!(!summaries[0].has_requested_discounts); - assert_eq!(summaries[0].last_reason.as_deref(), Some("all good")); + assert_eq!(summaries[0].last_reason, None); assert_eq!(summaries[1].order_id, "order-1"); assert!(summaries[1].has_requested_discounts); diff --git a/crates/trade/src/listing/tags.rs b/crates/trade/src/listing/tags.rs @@ -1,13 +1,21 @@ #[cfg(not(feature = "std"))] use alloc::{string::String, vec::Vec}; -use radroots_events::tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT}; -use radroots_events_codec::job::error::JobParseError; +use radroots_events::{ + RadrootsNostrEventPtr, + tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT}, +}; +use radroots_events_codec::{ + error::{EventEncodeError, EventParseError}, + job::error::JobParseError, +}; + +pub const TAG_LISTING_EVENT: &str = "listing_event"; #[inline] fn push_tag(tags: &mut Vec<Vec<String>>, name: &'static str, value: impl Into<String>) { let mut tag = Vec::with_capacity(2); - tag.push(name.to_owned()); + tag.push(name.to_string()); tag.push(value.into()); tags.push(tag); } @@ -17,19 +25,142 @@ pub fn trade_listing_dvm_tags<P, A, D>( recipient_pubkey: P, listing_addr: A, order_id: Option<D>, -) -> Vec<Vec<String>> + listing_event: Option<&RadrootsNostrEventPtr>, + root_event_id: Option<&str>, + prev_event_id: Option<&str>, +) -> Result<Vec<Vec<String>>, EventEncodeError> where P: Into<String>, A: Into<String>, D: Into<String>, { - let mut tags = Vec::with_capacity(2 + usize::from(order_id.is_some())); + let recipient_pubkey = recipient_pubkey.into(); + if recipient_pubkey.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("recipient_pubkey")); + } + let listing_addr = listing_addr.into(); + if listing_addr.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("listing_addr")); + } + let mut tags = Vec::with_capacity( + 2 + usize::from(order_id.is_some()) + + usize::from(listing_event.is_some()) + + usize::from(root_event_id.is_some()) + + usize::from(prev_event_id.is_some()), + ); push_tag(&mut tags, "p", recipient_pubkey); push_tag(&mut tags, "a", listing_addr); if let Some(order_id) = order_id { + let order_id = order_id.into(); + if order_id.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("order_id")); + } push_tag(&mut tags, TAG_D, order_id); } - tags + if let Some(listing_event) = listing_event { + if listing_event.id.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("listing_event.id")); + } + let mut tag = vec![TAG_LISTING_EVENT.to_string(), listing_event.id.clone()]; + if let Some(relay) = &listing_event.relays { + if relay.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("listing_event.relays")); + } + tag.push(relay.clone()); + } + tags.push(tag); + } + if let Some(root_event_id) = root_event_id { + if root_event_id.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("root_event_id")); + } + push_tag(&mut tags, TAG_E_ROOT, root_event_id); + } + if let Some(prev_event_id) = prev_event_id { + if prev_event_id.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("prev_event_id")); + } + push_tag(&mut tags, TAG_E_PREV, prev_event_id); + } + Ok(tags) +} + +#[inline] +pub fn parse_trade_listing_counterparty_tag( + tags: &[Vec<String>], +) -> Result<String, EventParseError> { + let tag = tags + .iter() + .find(|tag| tag.first().map(|value| value.as_str()) == Some("p")) + .ok_or(EventParseError::MissingTag("p"))?; + let value = tag.get(1).ok_or(EventParseError::InvalidTag("p"))?; + if value.trim().is_empty() { + return Err(EventParseError::InvalidTag("p")); + } + Ok(value.clone()) +} + +#[inline] +pub fn parse_trade_listing_event_tag( + tags: &[Vec<String>], +) -> Result<Option<RadrootsNostrEventPtr>, EventParseError> { + let Some(tag) = tags + .iter() + .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_LISTING_EVENT)) + else { + return Ok(None); + }; + let id = tag + .get(1) + .ok_or(EventParseError::InvalidTag(TAG_LISTING_EVENT))?; + if id.trim().is_empty() { + return Err(EventParseError::InvalidTag(TAG_LISTING_EVENT)); + } + let relays = match tag.get(2) { + Some(value) if value.trim().is_empty() => { + return Err(EventParseError::InvalidTag(TAG_LISTING_EVENT)); + } + Some(value) => Some(value.clone()), + None => None, + }; + Ok(Some(RadrootsNostrEventPtr { + id: id.clone(), + relays, + })) +} + +#[inline] +pub fn parse_trade_listing_root_tag( + tags: &[Vec<String>], +) -> Result<Option<String>, EventParseError> { + let Some(tag) = tags + .iter() + .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_E_ROOT)) + else { + return Ok(None); + }; + let value = tag.get(1).ok_or(EventParseError::InvalidTag(TAG_E_ROOT))?; + if value.trim().is_empty() { + return Err(EventParseError::InvalidTag(TAG_E_ROOT)); + } + Ok(Some(value.clone())) +} + +#[inline] +pub fn parse_trade_listing_prev_tag( + tags: &[Vec<String>], +) -> Result<Option<String>, EventParseError> { + let Some(tag) = tags + .iter() + .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_E_PREV)) + else { + return Ok(None); + }; + let value = tag.get(1).ok_or(EventParseError::InvalidTag(TAG_E_PREV))?; + if value.trim().is_empty() { + return Err(EventParseError::InvalidTag(TAG_E_PREV)); + } + Ok(Some(value.clone())) } #[inline] @@ -98,10 +229,15 @@ pub fn validate_trade_listing_chain(tags: &[Vec<String>]) -> Result<(), JobParse #[cfg(test)] mod tests { use super::{ - push_trade_listing_chain_tags, trade_listing_dvm_tags, validate_trade_listing_chain, + TAG_LISTING_EVENT, parse_trade_listing_counterparty_tag, parse_trade_listing_event_tag, + parse_trade_listing_prev_tag, parse_trade_listing_root_tag, push_trade_listing_chain_tags, + trade_listing_dvm_tags, validate_trade_listing_chain, + }; + use radroots_events::{ + RadrootsNostrEventPtr, + kinds::KIND_LISTING, + tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT}, }; - use radroots_events::kinds::KIND_LISTING; - use radroots_events::tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT}; #[test] fn validate_trade_listing_chain_ok() { @@ -123,105 +259,55 @@ mod tests { } #[test] - fn validate_trade_listing_chain_rejects_empty_root_value() { - let tags = vec![ - vec![TAG_E_ROOT.into(), " ".into()], - vec![TAG_D.into(), "trade".into()], - ]; - let err = validate_trade_listing_chain(&tags).unwrap_err(); - assert_eq!( - err.to_string(), - format!("invalid tag structure for '{TAG_E_ROOT}'") - ); + fn trade_listing_dvm_tags_builds_expected_tags() { + let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg"); + let tags = trade_listing_dvm_tags( + "pubkey", + &listing_addr, + Some("order-1"), + Some(&RadrootsNostrEventPtr { + id: "listing-snapshot".into(), + relays: None, + }), + Some("root"), + Some("prev"), + ) + .expect("trade listing tags"); + assert_eq!(tags[0], vec!["p".to_string(), "pubkey".to_string()]); + assert_eq!(tags[1], vec!["a".to_string(), listing_addr]); + assert!(tags.iter().any(|tag| tag[0] == TAG_LISTING_EVENT)); } #[test] - fn validate_trade_listing_chain_rejects_root_without_value() { - let tags = vec![vec![TAG_E_ROOT.into()], vec![TAG_D.into(), "trade".into()]]; - let err = validate_trade_listing_chain(&tags).unwrap_err(); + fn trade_listing_tag_parsers_extract_context() { + let tags = vec![ + vec!["p".into(), "counterparty".into()], + vec![TAG_LISTING_EVENT.into(), "snapshot".into()], + vec![TAG_E_ROOT.into(), "root".into()], + vec![TAG_E_PREV.into(), "prev".into()], + ]; assert_eq!( - err.to_string(), - format!("invalid tag structure for '{TAG_E_ROOT}'") + parse_trade_listing_counterparty_tag(&tags).expect("counterparty"), + "counterparty" ); - } - - #[test] - fn validate_trade_listing_chain_rejects_missing_trade_id() { - let tags = vec![vec![TAG_E_ROOT.into(), "root".into()]]; - let err = validate_trade_listing_chain(&tags).unwrap_err(); assert_eq!( - err.to_string(), - format!("missing required chain tag: {TAG_D}") + parse_trade_listing_event_tag(&tags).expect("snapshot"), + Some(RadrootsNostrEventPtr { + id: "snapshot".into(), + relays: None, + }) ); - } - - #[test] - fn validate_trade_listing_chain_rejects_empty_trade_id() { - let tags = vec![ - vec![TAG_E_ROOT.into(), "root".into()], - vec![TAG_D.into(), " ".into()], - ]; - let err = validate_trade_listing_chain(&tags).unwrap_err(); assert_eq!( - err.to_string(), - format!("invalid tag structure for '{TAG_D}'") + parse_trade_listing_root_tag(&tags).expect("root"), + Some("root".into()) ); - } - - #[test] - fn validate_trade_listing_chain_rejects_trade_id_without_value() { - let tags = vec![vec![TAG_E_ROOT.into(), "root".into()], vec![TAG_D.into()]]; - let err = validate_trade_listing_chain(&tags).unwrap_err(); assert_eq!( - err.to_string(), - format!("invalid tag structure for '{TAG_D}'") + parse_trade_listing_prev_tag(&tags).expect("prev"), + Some("prev".into()) ); } #[test] - fn validate_trade_listing_chain_accepts_trade_id_before_root() { - let tags = vec![ - vec![TAG_D.into(), "trade".into()], - vec!["x".into(), "ignore".into()], - vec![TAG_E_ROOT.into(), "root".into()], - ]; - assert!(validate_trade_listing_chain(&tags).is_ok()); - } - - #[test] - fn validate_trade_listing_chain_ignores_unknown_single_key_tag() { - let tags = vec![ - vec!["x".into()], - vec![TAG_E_ROOT.into(), "root".into()], - vec![TAG_D.into(), "trade".into()], - ]; - assert!(validate_trade_listing_chain(&tags).is_ok()); - } - - #[test] - fn trade_listing_dvm_tags_builds_expected_tags() { - let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg"); - let tags = trade_listing_dvm_tags("pubkey", &listing_addr, Some("order-1")); - let expected: Vec<Vec<String>> = vec![ - vec![String::from("p"), String::from("pubkey")], - vec![String::from("a"), listing_addr.clone()], - vec![String::from(TAG_D), String::from("order-1")], - ]; - assert_eq!(tags, expected); - } - - #[test] - fn trade_listing_dvm_tags_omit_order_id_when_missing() { - let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg"); - let tags = trade_listing_dvm_tags("pubkey", &listing_addr, None::<String>); - let expected: Vec<Vec<String>> = vec![ - vec![String::from("p"), String::from("pubkey")], - vec![String::from("a"), listing_addr.clone()], - ]; - assert_eq!(tags, expected); - } - - #[test] fn push_trade_listing_chain_tags_appends_optional_fields() { let mut tags = vec![vec![String::from("x"), String::from("seed")]]; push_trade_listing_chain_tags( @@ -241,20 +327,4 @@ mod tests { ] ); } - - #[test] - fn push_trade_listing_chain_tags_skips_missing_optional_fields() { - let mut tags = Vec::new(); - push_trade_listing_chain_tags( - &mut tags, - "root-id", - Option::<String>::None, - Option::<String>::None, - ); - - assert_eq!( - tags, - vec![vec![String::from(TAG_E_ROOT), String::from("root-id")]] - ); - } }