commit dae8880dd03a6d2be057b4e8083f881d926fba7c
parent 42ee62c68a81efabeefac9b08ceaac0ced52fa56
Author: triesap <tyson@radroots.org>
Date: Thu, 30 Apr 2026 07:07:35 +0000
events: add order revision proposals
Diffstat:
4 files changed, 197 insertions(+), 8 deletions(-)
diff --git a/crates/events/src/trade.rs b/crates/events/src/trade.rs
@@ -443,6 +443,39 @@ impl RadrootsTradeOrderRequested {
#[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 RadrootsTradeOrderRevisionProposed {
+ pub revision_id: String,
+ pub order_id: String,
+ pub listing_addr: String,
+ pub buyer_pubkey: String,
+ pub seller_pubkey: String,
+ pub root_event_id: String,
+ pub prev_event_id: String,
+ pub items: Vec<RadrootsTradeOrderItem>,
+ pub economics: RadrootsTradeOrderEconomics,
+ pub reason: String,
+}
+
+impl RadrootsTradeOrderRevisionProposed {
+ pub fn validate(&self) -> Result<(), RadrootsActiveTradePayloadError> {
+ validate_required_field(&self.revision_id, "revision_id")?;
+ 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.root_event_id, "root_event_id")?;
+ validate_required_field(&self.prev_event_id, "prev_event_id")?;
+ validate_required_field(&self.reason, "reason")?;
+ validate_order_items(&self.items)?;
+ self.economics.validate()?;
+ validate_order_economics_binding(&self.items, &self.economics)
+ }
+}
+
+#[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 RadrootsTradeInventoryCommitment {
pub bin_id: String,
pub bin_count: u32,
diff --git a/crates/events_codec/src/trade/decode.rs b/crates/events_codec/src/trade/decode.rs
@@ -11,7 +11,7 @@ use radroots_events::{
RadrootsActiveTradeMessageType, RadrootsActiveTradePayloadError, RadrootsTradeBuyerReceipt,
RadrootsTradeEnvelope, RadrootsTradeEnvelopeError, RadrootsTradeFulfillmentUpdated,
RadrootsTradeMessageType, RadrootsTradeOrderCancelled, RadrootsTradeOrderDecisionEvent,
- RadrootsTradeOrderRequested,
+ RadrootsTradeOrderRequested, RadrootsTradeOrderRevisionProposed,
},
};
#[cfg(feature = "serde_json")]
@@ -381,6 +381,44 @@ pub fn active_trade_order_decision_from_event(
}
#[cfg(feature = "serde_json")]
+pub fn active_trade_order_revision_proposal_from_event(
+ event: &RadrootsNostrEvent,
+) -> Result<
+ RadrootsActiveTradeEnvelope<RadrootsTradeOrderRevisionProposed>,
+ RadrootsActiveTradeEnvelopeParseError,
+> {
+ let envelope = active_trade_envelope_from_event::<RadrootsTradeOrderRevisionProposed>(event)?;
+ if envelope.message_type != RadrootsActiveTradeMessageType::TradeOrderRevisionProposed {
+ 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,
+ )?;
+ let context = active_trade_event_context_from_tags(envelope.message_type, &event.tags)?;
+ if context.root_event_id.as_deref() != Some(envelope.payload.root_event_id.as_str()) {
+ return Err(RadrootsActiveTradeEnvelopeParseError::PayloadBindingMismatch("root_event_id"));
+ }
+ if context.prev_event_id.as_deref() != Some(envelope.payload.prev_event_id.as_str()) {
+ return Err(RadrootsActiveTradeEnvelopeParseError::PayloadBindingMismatch("prev_event_id"));
+ }
+ Ok(envelope)
+}
+
+#[cfg(feature = "serde_json")]
pub fn active_trade_fulfillment_update_from_event(
event: &RadrootsNostrEvent,
) -> Result<
@@ -643,13 +681,14 @@ mod tests {
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,
+ active_trade_order_request_from_event, active_trade_order_revision_proposal_from_event,
+ trade_envelope_from_event, trade_event_context_from_tags,
};
use crate::trade::encode::{
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,
+ active_trade_order_request_event_build, active_trade_order_revision_proposal_event_build,
+ trade_envelope_event_build,
};
use crate::trade::tags::TAG_LISTING_EVENT;
use radroots_core::{
@@ -672,7 +711,7 @@ mod tests {
RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent,
RadrootsTradeOrderEconomicItem, RadrootsTradeOrderEconomicLine,
RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, RadrootsTradeOrderRequested,
- RadrootsTradePricingBasis,
+ RadrootsTradeOrderRevisionProposed, RadrootsTradePricingBasis,
},
};
@@ -751,6 +790,32 @@ mod tests {
}
}
+ fn active_order_revision_proposal() -> RadrootsTradeOrderRevisionProposed {
+ let mut economics = request_economics();
+ economics.quote_id = "revision-quote-1".into();
+ economics.quote_version = 2;
+ economics.items[0].bin_count = 4;
+ economics.items[0].line_subtotal = usd("20");
+ economics.subtotal = usd("20");
+ economics.total = usd("20");
+ economics.canonicalize();
+ RadrootsTradeOrderRevisionProposed {
+ revision_id: "rev-1".into(),
+ order_id: "order-1".into(),
+ listing_addr: "30402:seller:AAAAAAAAAAAAAAAAAAAAAg".into(),
+ buyer_pubkey: "buyer".into(),
+ seller_pubkey: "seller".into(),
+ root_event_id: "root-event".into(),
+ prev_event_id: "decision-event".into(),
+ items: vec![RadrootsTradeOrderItem {
+ bin_id: "lb".into(),
+ bin_count: 4,
+ }],
+ economics,
+ reason: "update count".into(),
+ }
+ }
+
fn active_fulfillment_update() -> RadrootsTradeFulfillmentUpdated {
RadrootsTradeFulfillmentUpdated {
order_id: "order-1".into(),
@@ -906,6 +971,44 @@ mod tests {
}
#[test]
+ fn active_order_revision_proposal_builder_emits_canonical_chain_shape() {
+ let payload = active_order_revision_proposal();
+ let built = active_trade_order_revision_proposal_event_build(
+ payload.root_event_id.as_str(),
+ payload.prev_event_id.as_str(),
+ &payload,
+ )
+ .unwrap();
+ let envelope: RadrootsActiveTradeEnvelope<RadrootsTradeOrderRevisionProposed> =
+ serde_json::from_str(&built.content).unwrap();
+
+ assert_eq!(built.kind, KIND_TRADE_ORDER_REVISION);
+ assert_eq!(
+ envelope.message_type,
+ RadrootsActiveTradeMessageType::TradeOrderRevisionProposed
+ );
+ 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_eq!(envelope.payload.revision_id, "rev-1");
+ assert_eq!(envelope.payload.economics.quote_version, 2);
+ 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(), "decision-event".to_string()])
+ );
+ }
+
+ #[test]
fn active_fulfillment_update_builder_emits_canonical_chain_shape() {
let payload = active_fulfillment_update();
let built =
@@ -1153,6 +1256,32 @@ mod tests {
}
#[test]
+ fn active_order_revision_proposal_parse_validates_actor_counterparty_and_chain_payload() {
+ let payload = active_order_revision_proposal();
+ let built = active_trade_order_revision_proposal_event_build(
+ payload.root_event_id.as_str(),
+ payload.prev_event_id.as_str(),
+ &payload,
+ )
+ .unwrap();
+ let mut 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_order_revision_proposal_from_event(&event).unwrap();
+ assert_eq!(envelope.payload, payload);
+
+ event.author = "buyer".into();
+ let err = active_trade_order_revision_proposal_from_event(&event).unwrap_err();
+ assert_eq!(err, RadrootsActiveTradeEnvelopeParseError::AuthorMismatch);
+ }
+
+ #[test]
fn active_revision_kinds_parse_with_chain_tags() {
for (kind, message_type) in [
(
diff --git a/crates/events_codec/src/trade/encode.rs b/crates/events_codec/src/trade/encode.rs
@@ -10,6 +10,7 @@ use radroots_events::{
RadrootsTradeEnvelope, RadrootsTradeEnvelopeError, RadrootsTradeFulfillmentUpdated,
RadrootsTradeMessagePayload, RadrootsTradeMessageType, RadrootsTradeOrderCancelled,
RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderRequested,
+ RadrootsTradeOrderRevisionProposed,
},
};
@@ -232,6 +233,31 @@ pub fn active_trade_order_decision_event_build(
}
#[cfg(feature = "serde_json")]
+pub fn active_trade_order_revision_proposal_event_build(
+ root_event_id: &str,
+ prev_event_id: &str,
+ payload: &RadrootsTradeOrderRevisionProposed,
+) -> Result<WireEventParts, EventEncodeError> {
+ payload.validate().map_err(map_active_payload_error)?;
+ if payload.root_event_id != root_event_id {
+ return Err(EventEncodeError::InvalidField("root_event_id"));
+ }
+ if payload.prev_event_id != prev_event_id {
+ return Err(EventEncodeError::InvalidField("prev_event_id"));
+ }
+ active_trade_envelope_event_build(
+ &payload.buyer_pubkey,
+ RadrootsActiveTradeMessageType::TradeOrderRevisionProposed,
+ &payload.listing_addr,
+ &payload.order_id,
+ None,
+ Some(root_event_id),
+ Some(prev_event_id),
+ payload,
+ )
+}
+
+#[cfg(feature = "serde_json")]
pub fn active_trade_fulfillment_update_event_build(
root_event_id: &str,
prev_event_id: &str,
diff --git a/crates/events_codec/src/trade/mod.rs b/crates/events_codec/src/trade/mod.rs
@@ -9,14 +9,15 @@ pub use decode::{
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,
+ active_trade_order_request_from_event, active_trade_order_revision_proposal_from_event,
+ trade_envelope_from_event, trade_event_context_from_tags,
};
#[cfg(feature = "serde_json")]
pub use encode::{
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,
+ active_trade_order_request_event_build, active_trade_order_revision_proposal_event_build,
+ trade_envelope_event_build,
};
pub use tags::{
TAG_LISTING_EVENT, parse_trade_counterparty_tag, parse_trade_listing_event_tag,