commit b0fc6c564d8d1a06a943df6de2de0a7bba6c4d03
parent a461afaa456d8c720b3e0193f3db0abffcf3161b
Author: triesap <tyson@radroots.org>
Date: Sun, 14 Jun 2026 01:56:23 -0700
trade: decode order records from events
- add order_event_record_from_event for every order lifecycle kind
- assemble unified records with context counterparty root and previous ids
- return typed decode errors for unsupported kinds id parsing and envelope failures
- validate with cargo fmt, check, and tests for radroots_trade
Diffstat:
1 file changed, 498 insertions(+), 11 deletions(-)
diff --git a/crates/trade/src/order.rs b/crates/trade/src/order.rs
@@ -7,11 +7,21 @@ use alloc::{
};
use radroots_core::{RadrootsCoreCurrency, RadrootsCoreDecimal};
+#[cfg(feature = "serde_json")]
+use radroots_events::RadrootsNostrEvent;
use radroots_events::ids::{
- RadrootsEconomicsDigest, RadrootsEventId, RadrootsInventoryBinId, RadrootsListingAddress,
- RadrootsOrderId, RadrootsOrderQuoteId, RadrootsPublicKey,
+ RadrootsEconomicsDigest, RadrootsEventId, RadrootsIdParseError, RadrootsInventoryBinId,
+ RadrootsListingAddress, RadrootsOrderId, RadrootsOrderQuoteId, RadrootsPublicKey,
};
use radroots_events::kinds::KIND_LISTING;
+#[cfg(feature = "serde_json")]
+use radroots_events::kinds::{
+ KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_FULFILLMENT_UPDATE,
+ KIND_ORDER_PAYMENT_RECORD, KIND_ORDER_RECEIPT, KIND_ORDER_REQUEST,
+ KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL, KIND_ORDER_SETTLEMENT_DECISION,
+};
+#[cfg(feature = "serde_json")]
+use radroots_events::order::RadrootsOrderEventType;
use radroots_events::order::{
RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome,
RadrootsOrderEconomics, RadrootsOrderFulfillmentState, RadrootsOrderFulfillmentUpdate,
@@ -21,6 +31,14 @@ use radroots_events::order::{
RadrootsOrderRevisionProposal, RadrootsOrderSettlementDecision, RadrootsOrderSettlementOutcome,
};
#[cfg(feature = "serde_json")]
+use radroots_events_codec::order::{
+ RadrootsOrderEnvelopeParseError, order_cancellation_from_event, order_decision_from_event,
+ order_event_context_from_tags, order_fulfillment_update_from_event,
+ order_payment_record_from_event, order_receipt_from_event, order_request_from_event,
+ order_revision_decision_from_event, order_revision_proposal_from_event,
+ order_settlement_decision_from_event,
+};
+#[cfg(feature = "serde_json")]
use sha2::{Digest, Sha256};
use thiserror::Error;
@@ -176,6 +194,174 @@ impl RadrootsOrderEventRecord {
}
}
+#[cfg(feature = "serde_json")]
+#[derive(Debug, Error)]
+pub enum RadrootsOrderEventDecodeError {
+ #[error("unsupported order event kind: {kind}")]
+ UnsupportedKind { kind: u32 },
+ #[error("invalid order event id: {0}")]
+ InvalidEventId(RadrootsIdParseError),
+ #[error("invalid order event author: {0}")]
+ InvalidAuthor(RadrootsIdParseError),
+ #[error("order event context is missing root event id")]
+ MissingRootEventId,
+ #[error("order event context is missing previous event id")]
+ MissingPreviousEventId,
+ #[error("{0}")]
+ Envelope(#[from] RadrootsOrderEnvelopeParseError),
+}
+
+#[cfg(feature = "serde_json")]
+pub fn order_event_record_from_event(
+ event: &RadrootsNostrEvent,
+) -> Result<RadrootsOrderEventRecord, RadrootsOrderEventDecodeError> {
+ let message_type = RadrootsOrderEventType::from_kind(event.kind)
+ .ok_or(RadrootsOrderEventDecodeError::UnsupportedKind { kind: event.kind })?;
+ let context = order_event_context_from_tags(message_type, &event.tags)?;
+ let event_id =
+ RadrootsEventId::parse(&event.id).map_err(RadrootsOrderEventDecodeError::InvalidEventId)?;
+ let author_pubkey = RadrootsPublicKey::parse(&event.author)
+ .map_err(RadrootsOrderEventDecodeError::InvalidAuthor)?;
+
+ match event.kind {
+ KIND_ORDER_REQUEST => {
+ let envelope = order_request_from_event(event)?;
+ Ok(RadrootsOrderEventRecord::Request(
+ RadrootsOrderRequestRecord {
+ event_id,
+ author_pubkey,
+ payload: envelope.payload,
+ },
+ ))
+ }
+ KIND_ORDER_DECISION => {
+ let envelope = order_decision_from_event(event)?;
+ Ok(RadrootsOrderEventRecord::Decision(
+ RadrootsOrderDecisionRecord {
+ event_id,
+ author_pubkey,
+ counterparty_pubkey: context.counterparty_pubkey.clone(),
+ root_event_id: require_context_root_event_id(&context)?,
+ prev_event_id: require_context_prev_event_id(&context)?,
+ payload: envelope.payload,
+ },
+ ))
+ }
+ KIND_ORDER_REVISION_PROPOSAL => {
+ let envelope = order_revision_proposal_from_event(event)?;
+ Ok(RadrootsOrderEventRecord::RevisionProposal(
+ RadrootsOrderRevisionProposalRecord {
+ event_id,
+ author_pubkey,
+ counterparty_pubkey: context.counterparty_pubkey.clone(),
+ root_event_id: require_context_root_event_id(&context)?,
+ prev_event_id: require_context_prev_event_id(&context)?,
+ payload: envelope.payload,
+ },
+ ))
+ }
+ KIND_ORDER_REVISION_DECISION => {
+ let envelope = order_revision_decision_from_event(event)?;
+ Ok(RadrootsOrderEventRecord::RevisionDecision(
+ RadrootsOrderRevisionDecisionRecord {
+ event_id,
+ author_pubkey,
+ counterparty_pubkey: context.counterparty_pubkey.clone(),
+ root_event_id: require_context_root_event_id(&context)?,
+ prev_event_id: require_context_prev_event_id(&context)?,
+ payload: envelope.payload,
+ },
+ ))
+ }
+ KIND_ORDER_FULFILLMENT_UPDATE => {
+ let envelope = order_fulfillment_update_from_event(event)?;
+ Ok(RadrootsOrderEventRecord::Fulfillment(
+ RadrootsOrderFulfillmentRecord {
+ event_id,
+ author_pubkey,
+ counterparty_pubkey: context.counterparty_pubkey.clone(),
+ root_event_id: require_context_root_event_id(&context)?,
+ prev_event_id: require_context_prev_event_id(&context)?,
+ payload: envelope.payload,
+ },
+ ))
+ }
+ KIND_ORDER_CANCELLATION => {
+ let envelope = order_cancellation_from_event(event)?;
+ Ok(RadrootsOrderEventRecord::Cancellation(
+ RadrootsOrderCancellationRecord {
+ event_id,
+ author_pubkey,
+ counterparty_pubkey: context.counterparty_pubkey.clone(),
+ root_event_id: require_context_root_event_id(&context)?,
+ prev_event_id: require_context_prev_event_id(&context)?,
+ payload: envelope.payload,
+ },
+ ))
+ }
+ KIND_ORDER_RECEIPT => {
+ let envelope = order_receipt_from_event(event)?;
+ Ok(RadrootsOrderEventRecord::Receipt(
+ RadrootsOrderReceiptRecord {
+ event_id,
+ author_pubkey,
+ counterparty_pubkey: context.counterparty_pubkey.clone(),
+ root_event_id: require_context_root_event_id(&context)?,
+ prev_event_id: require_context_prev_event_id(&context)?,
+ payload: envelope.payload,
+ },
+ ))
+ }
+ KIND_ORDER_PAYMENT_RECORD => {
+ let envelope = order_payment_record_from_event(event)?;
+ Ok(RadrootsOrderEventRecord::Payment(
+ RadrootsOrderPaymentEventRecord {
+ event_id,
+ author_pubkey,
+ counterparty_pubkey: context.counterparty_pubkey.clone(),
+ root_event_id: require_context_root_event_id(&context)?,
+ prev_event_id: require_context_prev_event_id(&context)?,
+ payload: envelope.payload,
+ },
+ ))
+ }
+ KIND_ORDER_SETTLEMENT_DECISION => {
+ let envelope = order_settlement_decision_from_event(event)?;
+ Ok(RadrootsOrderEventRecord::Settlement(
+ RadrootsOrderSettlementRecord {
+ event_id,
+ author_pubkey,
+ counterparty_pubkey: context.counterparty_pubkey.clone(),
+ root_event_id: require_context_root_event_id(&context)?,
+ prev_event_id: require_context_prev_event_id(&context)?,
+ payload: envelope.payload,
+ },
+ ))
+ }
+ _ => Err(RadrootsOrderEventDecodeError::UnsupportedKind { kind: event.kind }),
+ }
+}
+
+#[cfg(feature = "serde_json")]
+fn require_context_root_event_id(
+ context: &radroots_events_codec::order::RadrootsOrderEventContext,
+) -> Result<RadrootsEventId, RadrootsOrderEventDecodeError> {
+ context
+ .root_event_id
+ .clone()
+ .ok_or(RadrootsOrderEventDecodeError::MissingRootEventId)
+}
+
+#[cfg(feature = "serde_json")]
+fn require_context_prev_event_id(
+ context: &radroots_events_codec::order::RadrootsOrderEventContext,
+) -> Result<RadrootsEventId, RadrootsOrderEventDecodeError> {
+ context
+ .prev_event_id
+ .clone()
+ .ok_or(RadrootsOrderEventDecodeError::MissingPreviousEventId)
+}
+
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RadrootsOrderStatus {
Missing,
@@ -3562,20 +3748,31 @@ mod tests {
RadrootsOrderRevisionOutcome, RadrootsOrderRevisionProposal,
RadrootsOrderSettlementDecision, RadrootsOrderSettlementOutcome,
};
+ use radroots_events::tags::{TAG_E_PREV, TAG_E_ROOT};
+ use radroots_events::{RadrootsNostrEvent, RadrootsNostrEventPtr};
+ use radroots_events_codec::order::{
+ RadrootsOrderEnvelopeParseError, order_cancellation_event_build,
+ order_decision_event_build, order_fulfillment_update_event_build,
+ order_payment_record_event_build, order_receipt_event_build, order_request_event_build,
+ order_revision_decision_event_build, order_revision_proposal_event_build,
+ order_settlement_decision_event_build,
+ };
+ use radroots_events_codec::wire::WireEventParts;
use super::{
RadrootsListingInventoryAccountingIssue, RadrootsListingInventoryAccountingProjection,
RadrootsListingInventoryBinAccounting, RadrootsListingInventoryBinAvailability,
RadrootsListingInventoryOrderReservation, RadrootsOrderCancellationRecord,
- RadrootsOrderCanonicalizationError, RadrootsOrderDecisionRecord, RadrootsOrderEventRecord,
- RadrootsOrderFulfillmentRecord, RadrootsOrderIssue, RadrootsOrderPaymentEventRecord,
- RadrootsOrderPaymentProjection, RadrootsOrderPaymentState, RadrootsOrderProjection,
- RadrootsOrderReceiptRecord, RadrootsOrderRequestRecord,
- RadrootsOrderRevisionDecisionRecord, RadrootsOrderRevisionProposalRecord,
- RadrootsOrderSettlementRecord, RadrootsOrderSettlementState, RadrootsOrderStatus,
- add_inventory_reservation, canonicalize_order_decision_for_signer,
- canonicalize_order_request_for_signer, inventory_issue_event_ids, inventory_issue_id,
- inventory_issue_rank, inventory_issue_sort_key, projection_issue_event_ids,
+ RadrootsOrderCanonicalizationError, RadrootsOrderDecisionRecord,
+ RadrootsOrderEventDecodeError, RadrootsOrderEventRecord, RadrootsOrderFulfillmentRecord,
+ RadrootsOrderIssue, RadrootsOrderPaymentEventRecord, RadrootsOrderPaymentProjection,
+ RadrootsOrderPaymentState, RadrootsOrderProjection, RadrootsOrderReceiptRecord,
+ RadrootsOrderRequestRecord, RadrootsOrderRevisionDecisionRecord,
+ RadrootsOrderRevisionProposalRecord, RadrootsOrderSettlementRecord,
+ RadrootsOrderSettlementState, RadrootsOrderStatus, add_inventory_reservation,
+ canonicalize_order_decision_for_signer, canonicalize_order_request_for_signer,
+ inventory_issue_event_ids, inventory_issue_id, inventory_issue_rank,
+ inventory_issue_sort_key, order_event_record_from_event, projection_issue_event_ids,
radroots_order_economics_digest,
reduce_listing_inventory_accounting as reduce_listing_inventory_accounting_with_revisions,
reduce_order_events as reduce_order_events_with_revisions,
@@ -3706,6 +3903,29 @@ mod tests {
format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")
}
+ fn listing_event_ptr() -> RadrootsNostrEventPtr {
+ RadrootsNostrEventPtr {
+ id: test_event_id("listing-event").into_string(),
+ relays: Some("wss://relay.radroots.test".to_string()),
+ }
+ }
+
+ fn event_from_parts(
+ event_id: &RadrootsEventId,
+ author_pubkey: &RadrootsPublicKey,
+ parts: WireEventParts,
+ ) -> RadrootsNostrEvent {
+ RadrootsNostrEvent {
+ id: event_id.clone().into_string(),
+ author: author_pubkey.clone().into_string(),
+ created_at: 1,
+ kind: parts.kind,
+ tags: parts.tags,
+ content: parts.content,
+ sig: "sig".to_string(),
+ }
+ }
+
fn clean_request_payload() -> RadrootsOrderRequest {
RadrootsOrderRequest {
order_id: order_id("order-1"),
@@ -4066,6 +4286,273 @@ mod tests {
}
}
+ #[test]
+ fn order_event_record_from_event_decodes_all_variants() {
+ let request = request_record_with_event_id("decode-request");
+ let request_event = event_from_parts(
+ &request.event_id,
+ &request.author_pubkey,
+ order_request_event_build(&listing_event_ptr(), &request.payload).unwrap(),
+ );
+ assert_eq!(
+ order_event_record_from_event(&request_event).unwrap(),
+ RadrootsOrderEventRecord::Request(request)
+ );
+
+ let decision = accepted_decision_record("decode-decision");
+ let decision_event = event_from_parts(
+ &decision.event_id,
+ &decision.author_pubkey,
+ order_decision_event_build(
+ &decision.root_event_id,
+ &decision.prev_event_id,
+ &decision.payload,
+ )
+ .unwrap(),
+ );
+ assert_eq!(
+ order_event_record_from_event(&decision_event).unwrap(),
+ RadrootsOrderEventRecord::Decision(decision)
+ );
+
+ let proposal = revision_proposal_record(
+ "decode-revision-proposal",
+ "decode-decision",
+ "revision-1",
+ 3,
+ );
+ let proposal_event = event_from_parts(
+ &proposal.event_id,
+ &proposal.author_pubkey,
+ order_revision_proposal_event_build(
+ &proposal.root_event_id,
+ &proposal.prev_event_id,
+ &proposal.payload,
+ )
+ .unwrap(),
+ );
+ assert_eq!(
+ order_event_record_from_event(&proposal_event).unwrap(),
+ RadrootsOrderEventRecord::RevisionProposal(proposal)
+ );
+
+ let revision_decision = revision_decision_record(
+ "decode-revision-decision",
+ "decode-revision-proposal",
+ "revision-1",
+ RadrootsOrderRevisionOutcome::Accepted,
+ );
+ let revision_decision_event = event_from_parts(
+ &revision_decision.event_id,
+ &revision_decision.author_pubkey,
+ order_revision_decision_event_build(
+ &revision_decision.root_event_id,
+ &revision_decision.prev_event_id,
+ &revision_decision.payload,
+ )
+ .unwrap(),
+ );
+ assert_eq!(
+ order_event_record_from_event(&revision_decision_event).unwrap(),
+ RadrootsOrderEventRecord::RevisionDecision(revision_decision)
+ );
+
+ let fulfillment = fulfillment_record(
+ "decode-fulfillment",
+ "decode-revision-decision",
+ RadrootsOrderFulfillmentState::ReadyForPickup,
+ );
+ let fulfillment_event = event_from_parts(
+ &fulfillment.event_id,
+ &fulfillment.author_pubkey,
+ order_fulfillment_update_event_build(
+ &fulfillment.root_event_id,
+ &fulfillment.prev_event_id,
+ &fulfillment.payload,
+ )
+ .unwrap(),
+ );
+ assert_eq!(
+ order_event_record_from_event(&fulfillment_event).unwrap(),
+ RadrootsOrderEventRecord::Fulfillment(fulfillment)
+ );
+
+ let cancellation = cancellation_record("decode-cancellation", "decode-request");
+ let cancellation_event = event_from_parts(
+ &cancellation.event_id,
+ &cancellation.author_pubkey,
+ order_cancellation_event_build(
+ &cancellation.root_event_id,
+ &cancellation.prev_event_id,
+ &cancellation.payload,
+ )
+ .unwrap(),
+ );
+ assert_eq!(
+ order_event_record_from_event(&cancellation_event).unwrap(),
+ RadrootsOrderEventRecord::Cancellation(cancellation)
+ );
+
+ let receipt = receipt_record("decode-receipt", "decode-fulfillment", true);
+ let receipt_event = event_from_parts(
+ &receipt.event_id,
+ &receipt.author_pubkey,
+ order_receipt_event_build(
+ &receipt.root_event_id,
+ &receipt.prev_event_id,
+ &receipt.payload,
+ )
+ .unwrap(),
+ );
+ assert_eq!(
+ order_event_record_from_event(&receipt_event).unwrap(),
+ RadrootsOrderEventRecord::Receipt(receipt)
+ );
+
+ let payment = payment_record("decode-payment", "decode-decision");
+ let payment_event = event_from_parts(
+ &payment.event_id,
+ &payment.author_pubkey,
+ order_payment_record_event_build(
+ &payment.root_event_id,
+ &payment.prev_event_id,
+ &payment.payload,
+ )
+ .unwrap(),
+ );
+ assert_eq!(
+ order_event_record_from_event(&payment_event).unwrap(),
+ RadrootsOrderEventRecord::Payment(payment)
+ );
+
+ let settlement = settlement_record(
+ "decode-settlement",
+ "decode-payment",
+ RadrootsOrderSettlementOutcome::Accepted,
+ );
+ let settlement_event = event_from_parts(
+ &settlement.event_id,
+ &settlement.author_pubkey,
+ order_settlement_decision_event_build(
+ &settlement.root_event_id,
+ &settlement.prev_event_id,
+ &settlement.payload,
+ )
+ .unwrap(),
+ );
+ assert_eq!(
+ order_event_record_from_event(&settlement_event).unwrap(),
+ RadrootsOrderEventRecord::Settlement(settlement)
+ );
+ }
+
+ #[test]
+ fn order_event_record_from_event_rejects_wrong_kind() {
+ let request = request_record_with_event_id("decode-wrong-kind");
+ let mut event = event_from_parts(
+ &request.event_id,
+ &request.author_pubkey,
+ order_request_event_build(&listing_event_ptr(), &request.payload).unwrap(),
+ );
+ event.kind = KIND_LISTING;
+ assert!(matches!(
+ order_event_record_from_event(&event),
+ Err(RadrootsOrderEventDecodeError::UnsupportedKind { kind: KIND_LISTING })
+ ));
+ }
+
+ #[test]
+ fn order_event_record_from_event_rejects_wrong_envelope_type() {
+ let request = request_record_with_event_id("decode-request-content");
+ let request_parts =
+ order_request_event_build(&listing_event_ptr(), &request.payload).unwrap();
+ let decision = accepted_decision_record("decode-wrong-envelope");
+ let mut event = event_from_parts(
+ &decision.event_id,
+ &decision.author_pubkey,
+ order_decision_event_build(
+ &decision.root_event_id,
+ &decision.prev_event_id,
+ &decision.payload,
+ )
+ .unwrap(),
+ );
+ event.content = request_parts.content;
+ assert!(matches!(
+ order_event_record_from_event(&event),
+ Err(RadrootsOrderEventDecodeError::Envelope(_))
+ ));
+ }
+
+ #[test]
+ fn order_event_record_from_event_rejects_root_and_previous_mismatches() {
+ let proposal =
+ revision_proposal_record("decode-chain-mismatch", "decode-decision", "revision-1", 3);
+ let parts = order_revision_proposal_event_build(
+ &proposal.root_event_id,
+ &proposal.prev_event_id,
+ &proposal.payload,
+ )
+ .unwrap();
+ let mut root_event = event_from_parts(&proposal.event_id, &proposal.author_pubkey, parts);
+ root_event
+ .tags
+ .iter_mut()
+ .find(|tag| tag.first().map(String::as_str) == Some(TAG_E_ROOT))
+ .unwrap()[1] = test_event_id("wrong-root").into_string();
+ assert!(matches!(
+ order_event_record_from_event(&root_event),
+ Err(RadrootsOrderEventDecodeError::Envelope(
+ RadrootsOrderEnvelopeParseError::PayloadBindingMismatch("root_event_id")
+ ))
+ ));
+
+ let parts = order_revision_proposal_event_build(
+ &proposal.root_event_id,
+ &proposal.prev_event_id,
+ &proposal.payload,
+ )
+ .unwrap();
+ let mut prev_event = event_from_parts(&proposal.event_id, &proposal.author_pubkey, parts);
+ prev_event
+ .tags
+ .iter_mut()
+ .find(|tag| tag.first().map(String::as_str) == Some(TAG_E_PREV))
+ .unwrap()[1] = test_event_id("wrong-prev").into_string();
+ assert!(matches!(
+ order_event_record_from_event(&prev_event),
+ Err(RadrootsOrderEventDecodeError::Envelope(
+ RadrootsOrderEnvelopeParseError::PayloadBindingMismatch("prev_event_id")
+ ))
+ ));
+ }
+
+ #[test]
+ fn order_event_record_from_event_rejects_counterparty_mismatch() {
+ let decision = accepted_decision_record("decode-counterparty-mismatch");
+ let mut event = event_from_parts(
+ &decision.event_id,
+ &decision.author_pubkey,
+ order_decision_event_build(
+ &decision.root_event_id,
+ &decision.prev_event_id,
+ &decision.payload,
+ )
+ .unwrap(),
+ );
+ event
+ .tags
+ .iter_mut()
+ .find(|tag| tag.first().map(String::as_str) == Some("p"))
+ .unwrap()[1] = SELLER.to_string();
+ assert!(matches!(
+ order_event_record_from_event(&event),
+ Err(RadrootsOrderEventDecodeError::Envelope(
+ RadrootsOrderEnvelopeParseError::CounterpartyTagMismatch
+ ))
+ ));
+ }
+
fn reduce_order_events<I, J, K, L, M>(
order_id: &str,
requests: I,