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