lib

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

commit 3f2c36b058f3b550bce35980b4c382d208982cfe
parent 4660b3a050bcd7d5dc6cbb4fcfc1f3997a0e7ff9
Author: triesap <tyson@radroots.org>
Date:   Thu, 30 Apr 2026 01:25:04 +0000

trade: add active cancellation receipt events

Diffstat:
Mcrates/events/src/kinds.rs | 25+++++++++++++++++++------
Mcrates/events/src/trade.rs | 190++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/events_codec/src/trade/decode.rs | 260++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/events_codec/src/trade/encode.rs | 51++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/events_codec/src/trade/mod.rs | 8+++++---
5 files changed, 511 insertions(+), 23 deletions(-)

diff --git a/crates/events/src/kinds.rs b/crates/events/src/kinds.rs @@ -125,18 +125,22 @@ pub const TRADE_LISTING_KINDS: [u32; 15] = TRADE_KINDS; pub const ACTIVE_TRADE_LISTING_KINDS: [u32; 2] = [KIND_LISTING, KIND_LISTING_DRAFT]; -pub const ACTIVE_TRADE_PUBLIC_KINDS: [u32; 3] = [ +pub const ACTIVE_TRADE_PUBLIC_KINDS: [u32; 5] = [ KIND_TRADE_ORDER_REQUEST, KIND_TRADE_ORDER_DECISION, + KIND_TRADE_CANCEL, KIND_TRADE_FULFILLMENT_UPDATE, + KIND_TRADE_RECEIPT, ]; -pub const ACTIVE_TRADE_KINDS: [u32; 5] = [ +pub const ACTIVE_TRADE_KINDS: [u32; 7] = [ KIND_LISTING, KIND_LISTING_DRAFT, KIND_TRADE_ORDER_REQUEST, KIND_TRADE_ORDER_DECISION, + KIND_TRADE_CANCEL, KIND_TRADE_FULFILLMENT_UPDATE, + KIND_TRADE_RECEIPT, ]; pub const KIND_JOB_REQUEST_MIN: u32 = 5000; @@ -199,7 +203,11 @@ pub const fn is_active_trade_listing_kind(kind: u32) -> bool { pub const fn is_active_trade_public_kind(kind: u32) -> bool { matches!( kind, - KIND_TRADE_ORDER_REQUEST | KIND_TRADE_ORDER_DECISION | KIND_TRADE_FULFILLMENT_UPDATE + KIND_TRADE_ORDER_REQUEST + | KIND_TRADE_ORDER_DECISION + | KIND_TRADE_CANCEL + | KIND_TRADE_FULFILLMENT_UPDATE + | KIND_TRADE_RECEIPT ) } @@ -728,7 +736,8 @@ mod kinds_constants_tests { } #[test] - fn active_trade_kind_set_contains_listing_order_decision_and_fulfillment() { + fn active_trade_kind_set_contains_listing_order_decision_fulfillment_cancellation_and_receipt() + { assert_eq!( ACTIVE_TRADE_LISTING_KINDS, [KIND_LISTING, KIND_LISTING_DRAFT] @@ -738,7 +747,9 @@ mod kinds_constants_tests { [ KIND_TRADE_ORDER_REQUEST, KIND_TRADE_ORDER_DECISION, + KIND_TRADE_CANCEL, KIND_TRADE_FULFILLMENT_UPDATE, + KIND_TRADE_RECEIPT, ] ); assert_eq!( @@ -748,7 +759,9 @@ mod kinds_constants_tests { KIND_LISTING_DRAFT, KIND_TRADE_ORDER_REQUEST, KIND_TRADE_ORDER_DECISION, + KIND_TRADE_CANCEL, KIND_TRADE_FULFILLMENT_UPDATE, + KIND_TRADE_RECEIPT, ] ); @@ -756,7 +769,9 @@ mod kinds_constants_tests { assert!(is_active_trade_kind(KIND_LISTING_DRAFT)); assert!(is_active_trade_public_kind(KIND_TRADE_ORDER_REQUEST)); assert!(is_active_trade_public_kind(KIND_TRADE_ORDER_DECISION)); + assert!(is_active_trade_public_kind(KIND_TRADE_CANCEL)); assert!(is_active_trade_public_kind(KIND_TRADE_FULFILLMENT_UPDATE)); + assert!(is_active_trade_public_kind(KIND_TRADE_RECEIPT)); assert!(!is_active_trade_public_kind( KIND_TRADE_LISTING_VALIDATE_REQ )); @@ -770,7 +785,5 @@ mod kinds_constants_tests { assert!(!is_active_trade_public_kind(KIND_TRADE_DISCOUNT_OFFER)); assert!(!is_active_trade_public_kind(KIND_TRADE_DISCOUNT_ACCEPT)); assert!(!is_active_trade_public_kind(3431)); - assert!(!is_active_trade_public_kind(KIND_TRADE_CANCEL)); - assert!(!is_active_trade_public_kind(KIND_TRADE_RECEIPT)); } } diff --git a/crates/events/src/trade.rs b/crates/events/src/trade.rs @@ -315,6 +315,63 @@ impl RadrootsTradeFulfillmentUpdated { #[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsTradeOrderCancelled { + pub order_id: String, + pub listing_addr: String, + pub buyer_pubkey: String, + pub seller_pubkey: String, + pub reason: String, +} + +impl RadrootsTradeOrderCancelled { + pub fn validate(&self) -> Result<(), RadrootsActiveTradePayloadError> { + validate_required_field(&self.order_id, "order_id")?; + validate_required_field(&self.listing_addr, "listing_addr")?; + validate_required_field(&self.buyer_pubkey, "buyer_pubkey")?; + validate_required_field(&self.seller_pubkey, "seller_pubkey")?; + validate_required_field(&self.reason, "reason") + } +} + +#[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))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsTradeBuyerReceipt { + pub order_id: String, + pub listing_addr: String, + pub buyer_pubkey: String, + pub seller_pubkey: String, + pub received: bool, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub issue: Option<String>, + pub received_at: u64, +} + +impl RadrootsTradeBuyerReceipt { + pub fn validate(&self) -> Result<(), RadrootsActiveTradePayloadError> { + validate_required_field(&self.order_id, "order_id")?; + validate_required_field(&self.listing_addr, "listing_addr")?; + validate_required_field(&self.buyer_pubkey, "buyer_pubkey")?; + validate_required_field(&self.seller_pubkey, "seller_pubkey")?; + if self.received { + if self.issue.is_some() { + return Err(RadrootsActiveTradePayloadError::UnexpectedReceiptIssue); + } + } else { + match self.issue.as_deref() { + Some(issue) => validate_required_field(issue, "issue")?, + None => return Err(RadrootsActiveTradePayloadError::MissingReceiptIssue), + } + } + Ok(()) + } +} + +#[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))] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsTradeQuestion { pub question_id: String, } @@ -476,8 +533,12 @@ pub enum RadrootsActiveTradeMessageType { TradeOrderRequested, #[cfg_attr(feature = "serde", serde(rename = "TradeOrderDecision"))] TradeOrderDecision, + #[cfg_attr(feature = "serde", serde(rename = "TradeOrderCancelled"))] + TradeOrderCancelled, #[cfg_attr(feature = "serde", serde(rename = "TradeFulfillmentUpdated"))] TradeFulfillmentUpdated, + #[cfg_attr(feature = "serde", serde(rename = "TradeBuyerReceipt"))] + TradeBuyerReceipt, } impl RadrootsActiveTradeMessageType { @@ -486,7 +547,9 @@ impl RadrootsActiveTradeMessageType { match kind { KIND_TRADE_ORDER_REQUEST => Some(Self::TradeOrderRequested), KIND_TRADE_ORDER_DECISION => Some(Self::TradeOrderDecision), + KIND_TRADE_CANCEL => Some(Self::TradeOrderCancelled), KIND_TRADE_FULFILLMENT_UPDATE => Some(Self::TradeFulfillmentUpdated), + KIND_TRADE_RECEIPT => Some(Self::TradeBuyerReceipt), _ => None, } } @@ -496,7 +559,9 @@ impl RadrootsActiveTradeMessageType { match self { Self::TradeOrderRequested => KIND_TRADE_ORDER_REQUEST, Self::TradeOrderDecision => KIND_TRADE_ORDER_DECISION, + Self::TradeOrderCancelled => KIND_TRADE_CANCEL, Self::TradeFulfillmentUpdated => KIND_TRADE_FULFILLMENT_UPDATE, + Self::TradeBuyerReceipt => KIND_TRADE_RECEIPT, } } @@ -505,7 +570,9 @@ impl RadrootsActiveTradeMessageType { match self { Self::TradeOrderRequested => "TradeOrderRequested", Self::TradeOrderDecision => "TradeOrderDecision", + Self::TradeOrderCancelled => "TradeOrderCancelled", Self::TradeFulfillmentUpdated => "TradeFulfillmentUpdated", + Self::TradeBuyerReceipt => "TradeBuyerReceipt", } } @@ -518,7 +585,10 @@ impl RadrootsActiveTradeMessageType { pub const fn requires_trade_chain(self) -> bool { matches!( self, - Self::TradeOrderDecision | Self::TradeFulfillmentUpdated + Self::TradeOrderDecision + | Self::TradeOrderCancelled + | Self::TradeFulfillmentUpdated + | Self::TradeBuyerReceipt ) } } @@ -804,6 +874,8 @@ pub enum RadrootsActiveTradePayloadError { MissingInventoryCommitments, InvalidInventoryCommitmentCount { index: usize }, InvalidFulfillmentStatus, + MissingReceiptIssue, + UnexpectedReceiptIssue, } impl core::fmt::Display for RadrootsActiveTradePayloadError { @@ -827,6 +899,12 @@ impl core::fmt::Display for RadrootsActiveTradePayloadError { Self::InvalidFulfillmentStatus => { write!(f, "fulfillment status is not publishable") } + Self::MissingReceiptIssue => { + write!(f, "receipt issue is required when received is false") + } + Self::UnexpectedReceiptIssue => { + write!(f, "receipt issue must be absent when received is true") + } } } } @@ -1032,6 +1110,28 @@ mod tests { } } + fn sample_active_order_cancelled() -> RadrootsTradeOrderCancelled { + RadrootsTradeOrderCancelled { + order_id: "order-1".into(), + listing_addr: sample_listing_addr(), + buyer_pubkey: "buyer".into(), + seller_pubkey: "seller".into(), + reason: "changed plans".into(), + } + } + + fn sample_active_buyer_receipt(received: bool) -> RadrootsTradeBuyerReceipt { + RadrootsTradeBuyerReceipt { + order_id: "order-1".into(), + listing_addr: sample_listing_addr(), + buyer_pubkey: "buyer".into(), + seller_pubkey: "seller".into(), + received, + issue: (!received).then(|| "damaged items".into()), + received_at: 1_777_665_600, + } + } + fn sample_order_revision() -> RadrootsTradeOrderRevision { RadrootsTradeOrderRevision { revision_id: "rev-1".into(), @@ -1111,6 +1211,14 @@ mod tests { RadrootsActiveTradeMessageType::from_kind(KIND_TRADE_FULFILLMENT_UPDATE), Some(RadrootsActiveTradeMessageType::TradeFulfillmentUpdated) ); + assert_eq!( + RadrootsActiveTradeMessageType::from_kind(KIND_TRADE_CANCEL), + Some(RadrootsActiveTradeMessageType::TradeOrderCancelled) + ); + assert_eq!( + RadrootsActiveTradeMessageType::from_kind(KIND_TRADE_RECEIPT), + Some(RadrootsActiveTradeMessageType::TradeBuyerReceipt) + ); assert_eq!(RadrootsActiveTradeMessageType::from_kind(3431), None); assert_eq!( RadrootsActiveTradeMessageType::TradeOrderRequested.kind(), @@ -1125,6 +1233,14 @@ mod tests { KIND_TRADE_FULFILLMENT_UPDATE ); assert_eq!( + RadrootsActiveTradeMessageType::TradeOrderCancelled.kind(), + KIND_TRADE_CANCEL + ); + assert_eq!( + RadrootsActiveTradeMessageType::TradeBuyerReceipt.kind(), + KIND_TRADE_RECEIPT + ); + assert_eq!( RadrootsActiveTradeMessageType::TradeOrderRequested.name(), "TradeOrderRequested" ); @@ -1136,9 +1252,19 @@ mod tests { RadrootsActiveTradeMessageType::TradeFulfillmentUpdated.name(), "TradeFulfillmentUpdated" ); + assert_eq!( + RadrootsActiveTradeMessageType::TradeOrderCancelled.name(), + "TradeOrderCancelled" + ); + assert_eq!( + RadrootsActiveTradeMessageType::TradeBuyerReceipt.name(), + "TradeBuyerReceipt" + ); assert!(RadrootsActiveTradeMessageType::TradeOrderRequested.requires_listing_snapshot()); assert!(RadrootsActiveTradeMessageType::TradeOrderDecision.requires_trade_chain()); assert!(RadrootsActiveTradeMessageType::TradeFulfillmentUpdated.requires_trade_chain()); + assert!(RadrootsActiveTradeMessageType::TradeOrderCancelled.requires_trade_chain()); + assert!(RadrootsActiveTradeMessageType::TradeBuyerReceipt.requires_trade_chain()); let request_name = serde_json::to_value(RadrootsActiveTradeMessageType::TradeOrderRequested).unwrap(); @@ -1146,12 +1272,18 @@ mod tests { serde_json::to_value(RadrootsActiveTradeMessageType::TradeOrderDecision).unwrap(); let fulfillment_name = serde_json::to_value(RadrootsActiveTradeMessageType::TradeFulfillmentUpdated).unwrap(); + let cancellation_name = + serde_json::to_value(RadrootsActiveTradeMessageType::TradeOrderCancelled).unwrap(); + let receipt_name = + serde_json::to_value(RadrootsActiveTradeMessageType::TradeBuyerReceipt).unwrap(); assert_eq!(request_name, serde_json::json!("TradeOrderRequested")); assert_eq!(decision_name, serde_json::json!("TradeOrderDecision")); assert_eq!( fulfillment_name, serde_json::json!("TradeFulfillmentUpdated") ); + assert_eq!(cancellation_name, serde_json::json!("TradeOrderCancelled")); + assert_eq!(receipt_name, serde_json::json!("TradeBuyerReceipt")); } #[test] @@ -1258,6 +1390,62 @@ mod tests { } #[test] + fn active_cancellation_validation_requires_buyer_bindings_and_reason() { + assert_eq!(sample_active_order_cancelled().validate(), Ok(())); + + let missing_reason = RadrootsTradeOrderCancelled { + reason: " ".into(), + ..sample_active_order_cancelled() + }; + assert_eq!( + missing_reason.validate().unwrap_err(), + RadrootsActiveTradePayloadError::EmptyField("reason") + ); + + let missing_buyer = RadrootsTradeOrderCancelled { + buyer_pubkey: " ".into(), + ..sample_active_order_cancelled() + }; + assert_eq!( + missing_buyer.validate().unwrap_err(), + RadrootsActiveTradePayloadError::EmptyField("buyer_pubkey") + ); + } + + #[test] + fn active_buyer_receipt_validation_requires_consistent_received_and_issue() { + assert_eq!(sample_active_buyer_receipt(true).validate(), Ok(())); + assert_eq!(sample_active_buyer_receipt(false).validate(), Ok(())); + + let received_with_issue = RadrootsTradeBuyerReceipt { + issue: Some("damaged".into()), + ..sample_active_buyer_receipt(true) + }; + assert_eq!( + received_with_issue.validate().unwrap_err(), + RadrootsActiveTradePayloadError::UnexpectedReceiptIssue + ); + + let not_received_without_issue = RadrootsTradeBuyerReceipt { + issue: None, + ..sample_active_buyer_receipt(false) + }; + assert_eq!( + not_received_without_issue.validate().unwrap_err(), + RadrootsActiveTradePayloadError::MissingReceiptIssue + ); + + let not_received_blank_issue = RadrootsTradeBuyerReceipt { + issue: Some(" ".into()), + ..sample_active_buyer_receipt(false) + }; + assert_eq!( + not_received_blank_issue.validate().unwrap_err(), + RadrootsActiveTradePayloadError::EmptyField("issue") + ); + } + + #[test] fn active_envelope_serializes_canonical_type_name() { let envelope = RadrootsActiveTradeEnvelope::new( RadrootsActiveTradeMessageType::TradeOrderRequested, diff --git a/crates/events_codec/src/trade/decode.rs b/crates/events_codec/src/trade/decode.rs @@ -8,9 +8,10 @@ use radroots_events::{ tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT}, trade::{ RadrootsActiveTradeEnvelope, RadrootsActiveTradeEnvelopeError, - RadrootsActiveTradeMessageType, RadrootsActiveTradePayloadError, RadrootsTradeEnvelope, - RadrootsTradeEnvelopeError, RadrootsTradeFulfillmentUpdated, RadrootsTradeMessageType, - RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderRequested, + RadrootsActiveTradeMessageType, RadrootsActiveTradePayloadError, RadrootsTradeBuyerReceipt, + RadrootsTradeEnvelope, RadrootsTradeEnvelopeError, RadrootsTradeFulfillmentUpdated, + RadrootsTradeMessageType, RadrootsTradeOrderCancelled, RadrootsTradeOrderDecisionEvent, + RadrootsTradeOrderRequested, }, }; #[cfg(feature = "serde_json")] @@ -411,6 +412,68 @@ pub fn active_trade_fulfillment_update_from_event( } #[cfg(feature = "serde_json")] +pub fn active_trade_order_cancel_from_event( + event: &RadrootsNostrEvent, +) -> Result< + RadrootsActiveTradeEnvelope<RadrootsTradeOrderCancelled>, + RadrootsActiveTradeEnvelopeParseError, +> { + let envelope = active_trade_envelope_from_event::<RadrootsTradeOrderCancelled>(event)?; + if envelope.message_type != RadrootsActiveTradeMessageType::TradeOrderCancelled { + return Err( + RadrootsActiveTradeEnvelopeParseError::MessageTypeKindMismatch { + event_kind: event.kind, + message_type: envelope.message_type, + }, + ); + } + envelope + .payload + .validate() + .map_err(RadrootsActiveTradeEnvelopeParseError::InvalidPayload)?; + validate_active_order_binding( + event, + &envelope, + &envelope.payload.order_id, + &envelope.payload.listing_addr, + &envelope.payload.buyer_pubkey, + &envelope.payload.seller_pubkey, + )?; + Ok(envelope) +} + +#[cfg(feature = "serde_json")] +pub fn active_trade_buyer_receipt_from_event( + event: &RadrootsNostrEvent, +) -> Result< + RadrootsActiveTradeEnvelope<RadrootsTradeBuyerReceipt>, + RadrootsActiveTradeEnvelopeParseError, +> { + let envelope = active_trade_envelope_from_event::<RadrootsTradeBuyerReceipt>(event)?; + if envelope.message_type != RadrootsActiveTradeMessageType::TradeBuyerReceipt { + return Err( + RadrootsActiveTradeEnvelopeParseError::MessageTypeKindMismatch { + event_kind: event.kind, + message_type: envelope.message_type, + }, + ); + } + envelope + .payload + .validate() + .map_err(RadrootsActiveTradeEnvelopeParseError::InvalidPayload)?; + validate_active_order_binding( + event, + &envelope, + &envelope.payload.order_id, + &envelope.payload.listing_addr, + &envelope.payload.buyer_pubkey, + &envelope.payload.seller_pubkey, + )?; + Ok(envelope) +} + +#[cfg(feature = "serde_json")] pub fn trade_event_context_from_tags( message_type: RadrootsTradeMessageType, tags: &[Vec<String>], @@ -577,27 +640,31 @@ fn validate_active_order_binding<T>( mod tests { use super::{ RadrootsActiveTradeEnvelopeParseError, RadrootsTradeEnvelopeParseError, - RadrootsTradeListingAddress, active_trade_envelope_from_event, - active_trade_fulfillment_update_from_event, active_trade_order_decision_from_event, + RadrootsTradeListingAddress, active_trade_buyer_receipt_from_event, + active_trade_envelope_from_event, active_trade_fulfillment_update_from_event, + active_trade_order_cancel_from_event, active_trade_order_decision_from_event, active_trade_order_request_from_event, trade_envelope_from_event, trade_event_context_from_tags, }; use crate::trade::encode::{ - active_trade_fulfillment_update_event_build, active_trade_order_decision_event_build, + active_trade_buyer_receipt_event_build, active_trade_fulfillment_update_event_build, + active_trade_order_cancel_event_build, active_trade_order_decision_event_build, active_trade_order_request_event_build, trade_envelope_event_build, }; use crate::trade::tags::TAG_LISTING_EVENT; use radroots_events::{ RadrootsNostrEvent, RadrootsNostrEventPtr, kinds::{ - KIND_TRADE_FULFILLMENT_UPDATE, KIND_TRADE_ORDER_DECISION, KIND_TRADE_ORDER_REQUEST, + KIND_TRADE_CANCEL, KIND_TRADE_FULFILLMENT_UPDATE, KIND_TRADE_ORDER_DECISION, + KIND_TRADE_ORDER_REQUEST, KIND_TRADE_RECEIPT, }, tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT}, trade::{ RadrootsActiveTradeEnvelope, RadrootsActiveTradeFulfillmentState, - RadrootsActiveTradeMessageType, RadrootsTradeEnvelope, RadrootsTradeFulfillmentUpdated, - RadrootsTradeInventoryCommitment, RadrootsTradeMessagePayload, - RadrootsTradeMessageType, RadrootsTradeOrder, RadrootsTradeOrderDecision, + RadrootsActiveTradeMessageType, RadrootsTradeBuyerReceipt, RadrootsTradeEnvelope, + RadrootsTradeFulfillmentUpdated, RadrootsTradeInventoryCommitment, + RadrootsTradeMessagePayload, RadrootsTradeMessageType, RadrootsTradeOrder, + RadrootsTradeOrderCancelled, RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderItem, RadrootsTradeOrderRequested, }, }; @@ -654,6 +721,28 @@ mod tests { } } + fn active_order_cancelled() -> RadrootsTradeOrderCancelled { + RadrootsTradeOrderCancelled { + order_id: "order-1".into(), + listing_addr: "30402:seller:AAAAAAAAAAAAAAAAAAAAAg".into(), + buyer_pubkey: "buyer".into(), + seller_pubkey: "seller".into(), + reason: "changed plans".into(), + } + } + + fn active_buyer_receipt(received: bool) -> RadrootsTradeBuyerReceipt { + RadrootsTradeBuyerReceipt { + order_id: "order-1".into(), + listing_addr: "30402:seller:AAAAAAAAAAAAAAAAAAAAAg".into(), + buyer_pubkey: "buyer".into(), + seller_pubkey: "seller".into(), + received, + issue: (!received).then(|| "damaged items".into()), + received_at: 1_777_665_600, + } + } + fn listing_event_ptr() -> RadrootsNostrEventPtr { RadrootsNostrEventPtr { id: "listing-snapshot".into(), @@ -809,6 +898,73 @@ mod tests { } #[test] + fn active_order_cancel_builder_emits_canonical_buyer_chain_shape() { + let payload = active_order_cancelled(); + let built = + active_trade_order_cancel_event_build("root-event", "prev-event", &payload).unwrap(); + let envelope: RadrootsActiveTradeEnvelope<RadrootsTradeOrderCancelled> = + serde_json::from_str(&built.content).unwrap(); + + assert_eq!(built.kind, KIND_TRADE_CANCEL); + assert_eq!( + envelope.message_type, + RadrootsActiveTradeMessageType::TradeOrderCancelled + ); + assert_eq!(envelope.payload.reason, payload.reason); + assert_eq!(built.tags[0], vec!["p".to_string(), "seller".to_string()]); + assert_eq!( + built.tags[2], + vec![TAG_D.to_string(), "order-1".to_string()] + ); + assert!( + built + .tags + .iter() + .any(|tag| tag == &vec![TAG_E_ROOT.to_string(), "root-event".to_string()]) + ); + assert!( + built + .tags + .iter() + .any(|tag| tag == &vec![TAG_E_PREV.to_string(), "prev-event".to_string()]) + ); + } + + #[test] + fn active_buyer_receipt_builder_emits_canonical_buyer_chain_shape() { + let payload = active_buyer_receipt(false); + let built = + active_trade_buyer_receipt_event_build("root-event", "prev-event", &payload).unwrap(); + let envelope: RadrootsActiveTradeEnvelope<RadrootsTradeBuyerReceipt> = + serde_json::from_str(&built.content).unwrap(); + + assert_eq!(built.kind, KIND_TRADE_RECEIPT); + assert_eq!( + envelope.message_type, + RadrootsActiveTradeMessageType::TradeBuyerReceipt + ); + assert_eq!(envelope.payload.received, false); + assert_eq!(envelope.payload.issue.as_deref(), Some("damaged items")); + assert_eq!(built.tags[0], vec!["p".to_string(), "seller".to_string()]); + assert_eq!( + built.tags[2], + vec![TAG_D.to_string(), "order-1".to_string()] + ); + assert!( + built + .tags + .iter() + .any(|tag| tag == &vec![TAG_E_ROOT.to_string(), "root-event".to_string()]) + ); + assert!( + built + .tags + .iter() + .any(|tag| tag == &vec![TAG_E_PREV.to_string(), "prev-event".to_string()]) + ); + } + + #[test] fn active_order_request_parse_roundtrips_and_validates_tags() { let payload = active_order_request(); let built = active_trade_order_request_event_build(&listing_event_ptr(), &payload).unwrap(); @@ -878,6 +1034,52 @@ mod tests { } #[test] + fn active_order_cancel_parse_roundtrips_and_validates_buyer_actor() { + let payload = active_order_cancelled(); + let built = + active_trade_order_cancel_event_build("root-event", "prev-event", &payload).unwrap(); + let event = RadrootsNostrEvent { + id: "event-id".into(), + author: "buyer".into(), + created_at: 1, + kind: built.kind, + tags: built.tags, + content: built.content, + sig: "sig".into(), + }; + let envelope = active_trade_order_cancel_from_event(&event).unwrap(); + + assert_eq!(envelope.payload, payload); + assert_eq!( + envelope.message_type, + RadrootsActiveTradeMessageType::TradeOrderCancelled + ); + } + + #[test] + fn active_buyer_receipt_parse_roundtrips_and_validates_buyer_actor() { + let payload = active_buyer_receipt(true); + let built = + active_trade_buyer_receipt_event_build("root-event", "prev-event", &payload).unwrap(); + let event = RadrootsNostrEvent { + id: "event-id".into(), + author: "buyer".into(), + created_at: 1, + kind: built.kind, + tags: built.tags, + content: built.content, + sig: "sig".into(), + }; + let envelope = active_trade_buyer_receipt_from_event(&event).unwrap(); + + assert_eq!(envelope.payload, payload); + assert_eq!( + envelope.message_type, + RadrootsActiveTradeMessageType::TradeBuyerReceipt + ); + } + + #[test] fn active_parse_rejects_forbidden_kind() { let event = RadrootsNostrEvent { id: "event-id".into(), @@ -946,6 +1148,44 @@ mod tests { } #[test] + fn active_buyer_lifecycle_parse_rejects_wrong_actor_or_counterparty() { + let cancellation = active_order_cancelled(); + let cancellation_parts = + active_trade_order_cancel_event_build("root-event", "prev-event", &cancellation) + .unwrap(); + let cancellation_event = RadrootsNostrEvent { + id: "event-id".into(), + author: "seller".into(), + created_at: 1, + kind: cancellation_parts.kind, + tags: cancellation_parts.tags, + content: cancellation_parts.content, + sig: "sig".into(), + }; + let err = active_trade_order_cancel_from_event(&cancellation_event).unwrap_err(); + assert_eq!(err, RadrootsActiveTradeEnvelopeParseError::AuthorMismatch); + + let receipt = active_buyer_receipt(true); + let receipt_parts = + active_trade_buyer_receipt_event_build("root-event", "prev-event", &receipt).unwrap(); + let mut receipt_event = RadrootsNostrEvent { + id: "event-id".into(), + author: "buyer".into(), + created_at: 1, + kind: receipt_parts.kind, + tags: receipt_parts.tags, + content: receipt_parts.content, + sig: "sig".into(), + }; + receipt_event.tags[0] = vec!["p".into(), "other-seller".into()]; + let err = active_trade_buyer_receipt_from_event(&receipt_event).unwrap_err(); + assert_eq!( + err, + RadrootsActiveTradeEnvelopeParseError::CounterpartyTagMismatch + ); + } + + #[test] fn parse_rejects_listing_addr_mismatch() { let payload = RadrootsTradeMessagePayload::OrderRequest(base_order()); let built = trade_envelope_event_build( diff --git a/crates/events_codec/src/trade/encode.rs b/crates/events_codec/src/trade/encode.rs @@ -6,9 +6,10 @@ use radroots_events::{ RadrootsNostrEventPtr, trade::{ RadrootsActiveTradeEnvelope, RadrootsActiveTradeEnvelopeError, - RadrootsActiveTradeMessageType, RadrootsActiveTradePayloadError, RadrootsTradeEnvelope, - RadrootsTradeEnvelopeError, RadrootsTradeFulfillmentUpdated, RadrootsTradeMessagePayload, - RadrootsTradeMessageType, RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderRequested, + RadrootsActiveTradeMessageType, RadrootsActiveTradePayloadError, RadrootsTradeBuyerReceipt, + RadrootsTradeEnvelope, RadrootsTradeEnvelopeError, RadrootsTradeFulfillmentUpdated, + RadrootsTradeMessagePayload, RadrootsTradeMessageType, RadrootsTradeOrderCancelled, + RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderRequested, }, }; @@ -66,6 +67,12 @@ fn map_active_payload_error(error: RadrootsActiveTradePayloadError) -> EventEnco RadrootsActiveTradePayloadError::InvalidFulfillmentStatus => { EventEncodeError::InvalidField("fulfillment.status") } + RadrootsActiveTradePayloadError::MissingReceiptIssue => { + EventEncodeError::EmptyRequiredField("receipt.issue") + } + RadrootsActiveTradePayloadError::UnexpectedReceiptIssue => { + EventEncodeError::InvalidField("receipt.issue") + } } } @@ -215,3 +222,41 @@ pub fn active_trade_fulfillment_update_event_build( payload, ) } + +#[cfg(feature = "serde_json")] +pub fn active_trade_order_cancel_event_build( + root_event_id: &str, + prev_event_id: &str, + payload: &RadrootsTradeOrderCancelled, +) -> Result<WireEventParts, EventEncodeError> { + payload.validate().map_err(map_active_payload_error)?; + active_trade_envelope_event_build( + &payload.seller_pubkey, + RadrootsActiveTradeMessageType::TradeOrderCancelled, + &payload.listing_addr, + &payload.order_id, + None, + Some(root_event_id), + Some(prev_event_id), + payload, + ) +} + +#[cfg(feature = "serde_json")] +pub fn active_trade_buyer_receipt_event_build( + root_event_id: &str, + prev_event_id: &str, + payload: &RadrootsTradeBuyerReceipt, +) -> Result<WireEventParts, EventEncodeError> { + payload.validate().map_err(map_active_payload_error)?; + active_trade_envelope_event_build( + &payload.seller_pubkey, + RadrootsActiveTradeMessageType::TradeBuyerReceipt, + &payload.listing_addr, + &payload.order_id, + None, + Some(root_event_id), + Some(prev_event_id), + payload, + ) +} diff --git a/crates/events_codec/src/trade/mod.rs b/crates/events_codec/src/trade/mod.rs @@ -6,14 +6,16 @@ pub mod tags; pub use decode::{ RadrootsActiveTradeEnvelopeParseError, RadrootsTradeEnvelopeParseError, RadrootsTradeEventContext, RadrootsTradeListingAddress, RadrootsTradeListingAddressError, - active_trade_envelope_from_event, active_trade_event_context_from_tags, - active_trade_fulfillment_update_from_event, active_trade_order_decision_from_event, + active_trade_buyer_receipt_from_event, active_trade_envelope_from_event, + active_trade_event_context_from_tags, active_trade_fulfillment_update_from_event, + active_trade_order_cancel_from_event, active_trade_order_decision_from_event, active_trade_order_request_from_event, trade_envelope_from_event, trade_event_context_from_tags, }; #[cfg(feature = "serde_json")] pub use encode::{ - active_trade_fulfillment_update_event_build, active_trade_order_decision_event_build, + active_trade_buyer_receipt_event_build, active_trade_fulfillment_update_event_build, + active_trade_order_cancel_event_build, active_trade_order_decision_event_build, active_trade_order_request_event_build, trade_envelope_event_build, }; pub use tags::{