lib

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

commit d028be89eb3a4f75927da9bce22d56d7e6b38a1c
parent 74735443d1437b946fd541216b6a646c91237d2c
Author: triesap <tyson@radroots.org>
Date:   Wed, 29 Apr 2026 21:41:33 +0000

trade: add active fulfillment event codec

Diffstat:
Mcrates/events/src/kinds.rs | 26+++++++++++++++++++-------
Mcrates/events/src/trade.rs | 113++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/events_codec/src/trade/decode.rs | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/events_codec/src/trade/encode.rs | 26++++++++++++++++++++++++--
Mcrates/events_codec/src/trade/mod.rs | 9+++++----
5 files changed, 271 insertions(+), 22 deletions(-)

diff --git a/crates/events/src/kinds.rs b/crates/events/src/kinds.rs @@ -125,14 +125,18 @@ 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; 2] = - [KIND_TRADE_ORDER_REQUEST, KIND_TRADE_ORDER_DECISION]; +pub const ACTIVE_TRADE_PUBLIC_KINDS: [u32; 3] = [ + KIND_TRADE_ORDER_REQUEST, + KIND_TRADE_ORDER_DECISION, + KIND_TRADE_FULFILLMENT_UPDATE, +]; -pub const ACTIVE_TRADE_KINDS: [u32; 4] = [ +pub const ACTIVE_TRADE_KINDS: [u32; 5] = [ KIND_LISTING, KIND_LISTING_DRAFT, KIND_TRADE_ORDER_REQUEST, KIND_TRADE_ORDER_DECISION, + KIND_TRADE_FULFILLMENT_UPDATE, ]; pub const KIND_JOB_REQUEST_MIN: u32 = 5000; @@ -193,7 +197,10 @@ pub const fn is_active_trade_listing_kind(kind: u32) -> bool { #[inline] pub const fn is_active_trade_public_kind(kind: u32) -> bool { - matches!(kind, KIND_TRADE_ORDER_REQUEST | KIND_TRADE_ORDER_DECISION) + matches!( + kind, + KIND_TRADE_ORDER_REQUEST | KIND_TRADE_ORDER_DECISION | KIND_TRADE_FULFILLMENT_UPDATE + ) } #[inline] @@ -721,14 +728,18 @@ mod kinds_constants_tests { } #[test] - fn active_trade_kind_set_contains_only_listing_request_and_decision() { + fn active_trade_kind_set_contains_listing_order_decision_and_fulfillment() { assert_eq!( ACTIVE_TRADE_LISTING_KINDS, [KIND_LISTING, KIND_LISTING_DRAFT] ); assert_eq!( ACTIVE_TRADE_PUBLIC_KINDS, - [KIND_TRADE_ORDER_REQUEST, KIND_TRADE_ORDER_DECISION] + [ + KIND_TRADE_ORDER_REQUEST, + KIND_TRADE_ORDER_DECISION, + KIND_TRADE_FULFILLMENT_UPDATE, + ] ); assert_eq!( ACTIVE_TRADE_KINDS, @@ -737,6 +748,7 @@ mod kinds_constants_tests { KIND_LISTING_DRAFT, KIND_TRADE_ORDER_REQUEST, KIND_TRADE_ORDER_DECISION, + KIND_TRADE_FULFILLMENT_UPDATE, ] ); @@ -744,6 +756,7 @@ 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_FULFILLMENT_UPDATE)); assert!(!is_active_trade_public_kind( KIND_TRADE_LISTING_VALIDATE_REQ )); @@ -758,7 +771,6 @@ mod kinds_constants_tests { 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_FULFILLMENT_UPDATE)); assert!(!is_active_trade_public_kind(KIND_TRADE_RECEIPT)); } } diff --git a/crates/events/src/trade.rs b/crates/events/src/trade.rs @@ -267,6 +267,53 @@ impl RadrootsTradeOrderDecisionEvent { #[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))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadrootsActiveTradeFulfillmentState { + AcceptedNotFulfilled, + Preparing, + ReadyForPickup, + OutForDelivery, + Delivered, + SellerCancelled, +} + +impl RadrootsActiveTradeFulfillmentState { + #[inline] + pub const fn is_publishable_update(self) -> bool { + !matches!(self, Self::AcceptedNotFulfilled) + } +} + +#[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 RadrootsTradeFulfillmentUpdated { + pub order_id: String, + pub listing_addr: String, + pub buyer_pubkey: String, + pub seller_pubkey: String, + pub status: RadrootsActiveTradeFulfillmentState, +} + +impl RadrootsTradeFulfillmentUpdated { + 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.status.is_publishable_update() { + Ok(()) + } else { + Err(RadrootsActiveTradePayloadError::InvalidFulfillmentStatus) + } + } +} + +#[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, @@ -429,6 +476,8 @@ pub enum RadrootsActiveTradeMessageType { TradeOrderRequested, #[cfg_attr(feature = "serde", serde(rename = "TradeOrderDecision"))] TradeOrderDecision, + #[cfg_attr(feature = "serde", serde(rename = "TradeFulfillmentUpdated"))] + TradeFulfillmentUpdated, } impl RadrootsActiveTradeMessageType { @@ -437,6 +486,7 @@ impl RadrootsActiveTradeMessageType { match kind { KIND_TRADE_ORDER_REQUEST => Some(Self::TradeOrderRequested), KIND_TRADE_ORDER_DECISION => Some(Self::TradeOrderDecision), + KIND_TRADE_FULFILLMENT_UPDATE => Some(Self::TradeFulfillmentUpdated), _ => None, } } @@ -446,6 +496,7 @@ impl RadrootsActiveTradeMessageType { match self { Self::TradeOrderRequested => KIND_TRADE_ORDER_REQUEST, Self::TradeOrderDecision => KIND_TRADE_ORDER_DECISION, + Self::TradeFulfillmentUpdated => KIND_TRADE_FULFILLMENT_UPDATE, } } @@ -454,6 +505,7 @@ impl RadrootsActiveTradeMessageType { match self { Self::TradeOrderRequested => "TradeOrderRequested", Self::TradeOrderDecision => "TradeOrderDecision", + Self::TradeFulfillmentUpdated => "TradeFulfillmentUpdated", } } @@ -464,7 +516,10 @@ impl RadrootsActiveTradeMessageType { #[inline] pub const fn requires_trade_chain(self) -> bool { - matches!(self, Self::TradeOrderDecision) + matches!( + self, + Self::TradeOrderDecision | Self::TradeFulfillmentUpdated + ) } } @@ -748,6 +803,7 @@ pub enum RadrootsActiveTradePayloadError { InvalidItemBinCount { index: usize }, MissingInventoryCommitments, InvalidInventoryCommitmentCount { index: usize }, + InvalidFulfillmentStatus, } impl core::fmt::Display for RadrootsActiveTradePayloadError { @@ -768,6 +824,9 @@ impl core::fmt::Display for RadrootsActiveTradePayloadError { f, "inventory_commitments[{index}].bin_count must be greater than zero" ), + Self::InvalidFulfillmentStatus => { + write!(f, "fulfillment status is not publishable") + } } } } @@ -963,6 +1022,16 @@ mod tests { } } + fn sample_active_fulfillment_update() -> RadrootsTradeFulfillmentUpdated { + RadrootsTradeFulfillmentUpdated { + order_id: "order-1".into(), + listing_addr: sample_listing_addr(), + buyer_pubkey: "buyer".into(), + seller_pubkey: "seller".into(), + status: RadrootsActiveTradeFulfillmentState::ReadyForPickup, + } + } + fn sample_order_revision() -> RadrootsTradeOrderRevision { RadrootsTradeOrderRevision { revision_id: "rev-1".into(), @@ -1038,6 +1107,10 @@ mod tests { RadrootsActiveTradeMessageType::from_kind(KIND_TRADE_ORDER_DECISION), Some(RadrootsActiveTradeMessageType::TradeOrderDecision) ); + assert_eq!( + RadrootsActiveTradeMessageType::from_kind(KIND_TRADE_FULFILLMENT_UPDATE), + Some(RadrootsActiveTradeMessageType::TradeFulfillmentUpdated) + ); assert_eq!(RadrootsActiveTradeMessageType::from_kind(3431), None); assert_eq!( RadrootsActiveTradeMessageType::TradeOrderRequested.kind(), @@ -1048,6 +1121,10 @@ mod tests { KIND_TRADE_ORDER_DECISION ); assert_eq!( + RadrootsActiveTradeMessageType::TradeFulfillmentUpdated.kind(), + KIND_TRADE_FULFILLMENT_UPDATE + ); + assert_eq!( RadrootsActiveTradeMessageType::TradeOrderRequested.name(), "TradeOrderRequested" ); @@ -1055,15 +1132,26 @@ mod tests { RadrootsActiveTradeMessageType::TradeOrderDecision.name(), "TradeOrderDecision" ); + assert_eq!( + RadrootsActiveTradeMessageType::TradeFulfillmentUpdated.name(), + "TradeFulfillmentUpdated" + ); assert!(RadrootsActiveTradeMessageType::TradeOrderRequested.requires_listing_snapshot()); assert!(RadrootsActiveTradeMessageType::TradeOrderDecision.requires_trade_chain()); + assert!(RadrootsActiveTradeMessageType::TradeFulfillmentUpdated.requires_trade_chain()); let request_name = serde_json::to_value(RadrootsActiveTradeMessageType::TradeOrderRequested).unwrap(); let decision_name = serde_json::to_value(RadrootsActiveTradeMessageType::TradeOrderDecision).unwrap(); + let fulfillment_name = + serde_json::to_value(RadrootsActiveTradeMessageType::TradeFulfillmentUpdated).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") + ); } #[test] @@ -1147,6 +1235,29 @@ mod tests { } #[test] + fn active_fulfillment_update_validation_rejects_derived_state() { + assert_eq!(sample_active_fulfillment_update().validate(), Ok(())); + + let derived = RadrootsTradeFulfillmentUpdated { + status: RadrootsActiveTradeFulfillmentState::AcceptedNotFulfilled, + ..sample_active_fulfillment_update() + }; + assert_eq!( + derived.validate().unwrap_err(), + RadrootsActiveTradePayloadError::InvalidFulfillmentStatus + ); + + let missing_seller = RadrootsTradeFulfillmentUpdated { + seller_pubkey: " ".into(), + ..sample_active_fulfillment_update() + }; + assert_eq!( + missing_seller.validate().unwrap_err(), + RadrootsActiveTradePayloadError::EmptyField("seller_pubkey") + ); + } + + #[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 @@ -9,8 +9,8 @@ use radroots_events::{ trade::{ RadrootsActiveTradeEnvelope, RadrootsActiveTradeEnvelopeError, RadrootsActiveTradeMessageType, RadrootsActiveTradePayloadError, RadrootsTradeEnvelope, - RadrootsTradeEnvelopeError, RadrootsTradeMessageType, RadrootsTradeOrderDecisionEvent, - RadrootsTradeOrderRequested, + RadrootsTradeEnvelopeError, RadrootsTradeFulfillmentUpdated, RadrootsTradeMessageType, + RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderRequested, }, }; #[cfg(feature = "serde_json")] @@ -380,6 +380,37 @@ pub fn active_trade_order_decision_from_event( } #[cfg(feature = "serde_json")] +pub fn active_trade_fulfillment_update_from_event( + event: &RadrootsNostrEvent, +) -> Result< + RadrootsActiveTradeEnvelope<RadrootsTradeFulfillmentUpdated>, + RadrootsActiveTradeEnvelopeParseError, +> { + let envelope = active_trade_envelope_from_event::<RadrootsTradeFulfillmentUpdated>(event)?; + if envelope.message_type != RadrootsActiveTradeMessageType::TradeFulfillmentUpdated { + 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.seller_pubkey, + &envelope.payload.buyer_pubkey, + )?; + Ok(envelope) +} + +#[cfg(feature = "serde_json")] pub fn trade_event_context_from_tags( message_type: RadrootsTradeMessageType, tags: &[Vec<String>], @@ -547,20 +578,24 @@ mod tests { use super::{ RadrootsActiveTradeEnvelopeParseError, RadrootsTradeEnvelopeParseError, RadrootsTradeListingAddress, active_trade_envelope_from_event, - active_trade_order_decision_from_event, active_trade_order_request_from_event, - trade_envelope_from_event, trade_event_context_from_tags, + active_trade_fulfillment_update_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_order_decision_event_build, active_trade_order_request_event_build, - trade_envelope_event_build, + active_trade_fulfillment_update_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_ORDER_DECISION, KIND_TRADE_ORDER_REQUEST}, + kinds::{ + KIND_TRADE_FULFILLMENT_UPDATE, KIND_TRADE_ORDER_DECISION, KIND_TRADE_ORDER_REQUEST, + }, tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT}, trade::{ - RadrootsActiveTradeEnvelope, RadrootsActiveTradeMessageType, RadrootsTradeEnvelope, + RadrootsActiveTradeEnvelope, RadrootsActiveTradeFulfillmentState, + RadrootsActiveTradeMessageType, RadrootsTradeEnvelope, RadrootsTradeFulfillmentUpdated, RadrootsTradeInventoryCommitment, RadrootsTradeMessagePayload, RadrootsTradeMessageType, RadrootsTradeOrder, RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderItem, RadrootsTradeOrderRequested, @@ -609,6 +644,16 @@ mod tests { } } + fn active_fulfillment_update() -> RadrootsTradeFulfillmentUpdated { + RadrootsTradeFulfillmentUpdated { + order_id: "order-1".into(), + listing_addr: "30402:seller:AAAAAAAAAAAAAAAAAAAAAg".into(), + buyer_pubkey: "buyer".into(), + seller_pubkey: "seller".into(), + status: RadrootsActiveTradeFulfillmentState::ReadyForPickup, + } + } + fn listing_event_ptr() -> RadrootsNostrEventPtr { RadrootsNostrEventPtr { id: "listing-snapshot".into(), @@ -730,6 +775,40 @@ mod tests { } #[test] + fn active_fulfillment_update_builder_emits_canonical_chain_shape() { + let payload = active_fulfillment_update(); + let built = + active_trade_fulfillment_update_event_build("root-event", "prev-event", &payload) + .unwrap(); + let envelope: RadrootsActiveTradeEnvelope<RadrootsTradeFulfillmentUpdated> = + serde_json::from_str(&built.content).unwrap(); + + assert_eq!(built.kind, KIND_TRADE_FULFILLMENT_UPDATE); + assert_eq!( + envelope.message_type, + RadrootsActiveTradeMessageType::TradeFulfillmentUpdated + ); + assert_eq!(envelope.payload.status, payload.status); + assert_eq!(built.tags[0], vec!["p".to_string(), "buyer".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(); @@ -775,6 +854,30 @@ mod tests { } #[test] + fn active_fulfillment_update_parse_roundtrips_and_validates_chain_tags() { + let payload = active_fulfillment_update(); + let built = + active_trade_fulfillment_update_event_build("root-event", "prev-event", &payload) + .unwrap(); + let event = RadrootsNostrEvent { + id: "event-id".into(), + author: "seller".into(), + created_at: 1, + kind: built.kind, + tags: built.tags, + content: built.content, + sig: "sig".into(), + }; + let envelope = active_trade_fulfillment_update_from_event(&event).unwrap(); + + assert_eq!(envelope.payload, payload); + assert_eq!( + envelope.message_type, + RadrootsActiveTradeMessageType::TradeFulfillmentUpdated + ); + } + + #[test] fn active_parse_rejects_forbidden_kind() { let event = RadrootsNostrEvent { id: "event-id".into(), diff --git a/crates/events_codec/src/trade/encode.rs b/crates/events_codec/src/trade/encode.rs @@ -7,8 +7,8 @@ use radroots_events::{ trade::{ RadrootsActiveTradeEnvelope, RadrootsActiveTradeEnvelopeError, RadrootsActiveTradeMessageType, RadrootsActiveTradePayloadError, RadrootsTradeEnvelope, - RadrootsTradeEnvelopeError, RadrootsTradeMessagePayload, RadrootsTradeMessageType, - RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderRequested, + RadrootsTradeEnvelopeError, RadrootsTradeFulfillmentUpdated, RadrootsTradeMessagePayload, + RadrootsTradeMessageType, RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderRequested, }, }; @@ -63,6 +63,9 @@ fn map_active_payload_error(error: RadrootsActiveTradePayloadError) -> EventEnco RadrootsActiveTradePayloadError::InvalidInventoryCommitmentCount { .. } => { EventEncodeError::InvalidField("inventory_commitments.bin_count") } + RadrootsActiveTradePayloadError::InvalidFulfillmentStatus => { + EventEncodeError::InvalidField("fulfillment.status") + } } } @@ -193,3 +196,22 @@ pub fn active_trade_order_decision_event_build( payload, ) } + +#[cfg(feature = "serde_json")] +pub fn active_trade_fulfillment_update_event_build( + root_event_id: &str, + prev_event_id: &str, + payload: &RadrootsTradeFulfillmentUpdated, +) -> Result<WireEventParts, EventEncodeError> { + payload.validate().map_err(map_active_payload_error)?; + active_trade_envelope_event_build( + &payload.buyer_pubkey, + RadrootsActiveTradeMessageType::TradeFulfillmentUpdated, + &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 @@ -7,13 +7,14 @@ pub use decode::{ RadrootsActiveTradeEnvelopeParseError, RadrootsTradeEnvelopeParseError, RadrootsTradeEventContext, RadrootsTradeListingAddress, RadrootsTradeListingAddressError, active_trade_envelope_from_event, active_trade_event_context_from_tags, - active_trade_order_decision_from_event, active_trade_order_request_from_event, - trade_envelope_from_event, trade_event_context_from_tags, + active_trade_fulfillment_update_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_order_decision_event_build, active_trade_order_request_event_build, - trade_envelope_event_build, + active_trade_fulfillment_update_event_build, active_trade_order_decision_event_build, + active_trade_order_request_event_build, trade_envelope_event_build, }; pub use tags::{ TAG_LISTING_EVENT, parse_trade_counterparty_tag, parse_trade_listing_event_tag,