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:
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,