lib

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

commit 0053a148e787eb7811c53960b437c904137d9a22
parent 6af9759b576f24f8e6d3a7d3022f2281d01bf54a
Author: triesap <tyson@radroots.org>
Date:   Tue,  5 May 2026 18:10:15 +0000

events: add payment settlement contracts

- activate trade kinds 3435 and 3436
- add payment and settlement payload contracts
- add codec builders and parsers for new events
- cover payment settlement event roundtrips

Diffstat:
Mcrates/events/src/kinds.rs | 30+++++++++++++++++++++++++-----
Mcrates/events/src/trade.rs | 191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events_codec/src/trade/decode.rs | 273+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/events_codec/src/trade/encode.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events_codec/src/trade/mod.rs | 6++++--
5 files changed, 548 insertions(+), 12 deletions(-)

diff --git a/crates/events/src/kinds.rs b/crates/events/src/kinds.rs @@ -68,6 +68,8 @@ pub const KIND_TRADE_DISCOUNT_DECLINE: u32 = KIND_TRADE_FORBIDDEN_3431; pub const KIND_TRADE_CANCEL: u32 = 3432; pub const KIND_TRADE_FULFILLMENT_UPDATE: u32 = 3433; pub const KIND_TRADE_RECEIPT: u32 = 3434; +pub const KIND_TRADE_PAYMENT_RECORDED: u32 = 3435; +pub const KIND_TRADE_SETTLEMENT_DECISION: u32 = 3436; pub const KIND_TRADE_LISTING_ORDER_REQ: u32 = KIND_TRADE_ORDER_REQUEST; pub const KIND_TRADE_LISTING_ORDER_RES: u32 = KIND_TRADE_ORDER_RESPONSE; @@ -88,7 +90,7 @@ pub const TRADE_SERVICE_KINDS: [u32; 2] = [ KIND_TRADE_LISTING_VALIDATE_RES, ]; -pub const TRADE_PUBLIC_KINDS: [u32; 12] = [ +pub const TRADE_PUBLIC_KINDS: [u32; 14] = [ KIND_TRADE_ORDER_REQUEST, KIND_TRADE_ORDER_RESPONSE, KIND_TRADE_ORDER_REVISION, @@ -101,9 +103,11 @@ pub const TRADE_PUBLIC_KINDS: [u32; 12] = [ KIND_TRADE_CANCEL, KIND_TRADE_FULFILLMENT_UPDATE, KIND_TRADE_RECEIPT, + KIND_TRADE_PAYMENT_RECORDED, + KIND_TRADE_SETTLEMENT_DECISION, ]; -pub const TRADE_KINDS: [u32; 14] = [ +pub const TRADE_KINDS: [u32; 16] = [ KIND_TRADE_LISTING_VALIDATE_REQ, KIND_TRADE_LISTING_VALIDATE_RES, KIND_TRADE_ORDER_REQUEST, @@ -118,13 +122,15 @@ pub const TRADE_KINDS: [u32; 14] = [ KIND_TRADE_CANCEL, KIND_TRADE_FULFILLMENT_UPDATE, KIND_TRADE_RECEIPT, + KIND_TRADE_PAYMENT_RECORDED, + KIND_TRADE_SETTLEMENT_DECISION, ]; -pub const TRADE_LISTING_KINDS: [u32; 14] = TRADE_KINDS; +pub const TRADE_LISTING_KINDS: [u32; 16] = TRADE_KINDS; pub const ACTIVE_TRADE_LISTING_KINDS: [u32; 2] = [KIND_LISTING, KIND_LISTING_DRAFT]; -pub const ACTIVE_TRADE_PUBLIC_KINDS: [u32; 7] = [ +pub const ACTIVE_TRADE_PUBLIC_KINDS: [u32; 9] = [ KIND_TRADE_ORDER_REQUEST, KIND_TRADE_ORDER_DECISION, KIND_TRADE_ORDER_REVISION, @@ -132,9 +138,11 @@ pub const ACTIVE_TRADE_PUBLIC_KINDS: [u32; 7] = [ KIND_TRADE_CANCEL, KIND_TRADE_FULFILLMENT_UPDATE, KIND_TRADE_RECEIPT, + KIND_TRADE_PAYMENT_RECORDED, + KIND_TRADE_SETTLEMENT_DECISION, ]; -pub const ACTIVE_TRADE_KINDS: [u32; 9] = [ +pub const ACTIVE_TRADE_KINDS: [u32; 11] = [ KIND_LISTING, KIND_LISTING_DRAFT, KIND_TRADE_ORDER_REQUEST, @@ -144,6 +152,8 @@ pub const ACTIVE_TRADE_KINDS: [u32; 9] = [ KIND_TRADE_CANCEL, KIND_TRADE_FULFILLMENT_UPDATE, KIND_TRADE_RECEIPT, + KIND_TRADE_PAYMENT_RECORDED, + KIND_TRADE_SETTLEMENT_DECISION, ]; pub const KIND_JOB_REQUEST_MIN: u32 = 5000; @@ -188,6 +198,8 @@ pub const fn is_trade_public_kind(kind: u32) -> bool { | KIND_TRADE_CANCEL | KIND_TRADE_FULFILLMENT_UPDATE | KIND_TRADE_RECEIPT + | KIND_TRADE_PAYMENT_RECORDED + | KIND_TRADE_SETTLEMENT_DECISION ) } @@ -212,6 +224,8 @@ pub const fn is_active_trade_public_kind(kind: u32) -> bool { | KIND_TRADE_CANCEL | KIND_TRADE_FULFILLMENT_UPDATE | KIND_TRADE_RECEIPT + | KIND_TRADE_PAYMENT_RECORDED + | KIND_TRADE_SETTLEMENT_DECISION ) } @@ -751,6 +765,8 @@ mod kinds_constants_tests { KIND_TRADE_CANCEL, KIND_TRADE_FULFILLMENT_UPDATE, KIND_TRADE_RECEIPT, + KIND_TRADE_PAYMENT_RECORDED, + KIND_TRADE_SETTLEMENT_DECISION, ] ); assert_eq!( @@ -765,6 +781,8 @@ mod kinds_constants_tests { KIND_TRADE_CANCEL, KIND_TRADE_FULFILLMENT_UPDATE, KIND_TRADE_RECEIPT, + KIND_TRADE_PAYMENT_RECORDED, + KIND_TRADE_SETTLEMENT_DECISION, ] ); @@ -779,6 +797,8 @@ mod kinds_constants_tests { 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_PAYMENT_RECORDED)); + assert!(is_active_trade_public_kind(KIND_TRADE_SETTLEMENT_DECISION)); assert!(!is_active_trade_public_kind( KIND_TRADE_LISTING_VALIDATE_REQ )); diff --git a/crates/events/src/trade.rs b/crates/events/src/trade.rs @@ -682,6 +682,135 @@ impl RadrootsTradeBuyerReceipt { #[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 RadrootsTradePaymentMethod { + Cash, + ManualTransfer, + Other, +} + +#[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 RadrootsTradePaymentRecorded { + pub order_id: String, + pub listing_addr: String, + pub buyer_pubkey: String, + pub seller_pubkey: String, + pub root_event_id: String, + pub previous_event_id: String, + pub agreement_event_id: String, + pub quote_id: String, + pub quote_version: u32, + pub economics_digest: String, + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreDecimal"))] + pub amount: RadrootsCoreDecimal, + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreCurrency"))] + pub currency: RadrootsCoreCurrency, + pub method: RadrootsTradePaymentMethod, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub reference: Option<String>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "number | null"))] + pub paid_at: Option<u64>, +} + +impl RadrootsTradePaymentRecorded { + 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.root_event_id, "root_event_id")?; + validate_required_field(&self.previous_event_id, "previous_event_id")?; + validate_required_field(&self.agreement_event_id, "agreement_event_id")?; + validate_required_field(&self.quote_id, "quote_id")?; + validate_required_field(&self.economics_digest, "economics_digest")?; + if self.quote_version == 0 { + return Err(RadrootsActiveTradePayloadError::InvalidQuoteVersion); + } + if self.amount.is_zero() || self.amount.is_sign_negative() { + return Err(RadrootsActiveTradePayloadError::InvalidPaymentAmount); + } + if let Some(reference) = self.reference.as_deref() { + validate_required_field(reference, "reference")?; + } + 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))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadrootsTradeSettlementDecision { + Accepted, + Rejected, +} + +#[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 RadrootsTradeSettlementDecisionEvent { + pub order_id: String, + pub listing_addr: String, + pub seller_pubkey: String, + pub buyer_pubkey: String, + pub root_event_id: String, + pub previous_event_id: String, + pub agreement_event_id: String, + pub payment_event_id: String, + pub quote_id: String, + pub quote_version: u32, + pub economics_digest: String, + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreDecimal"))] + pub amount: RadrootsCoreDecimal, + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreCurrency"))] + pub currency: RadrootsCoreCurrency, + pub decision: RadrootsTradeSettlementDecision, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub reason: Option<String>, +} + +impl RadrootsTradeSettlementDecisionEvent { + 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.seller_pubkey, "seller_pubkey")?; + validate_required_field(&self.buyer_pubkey, "buyer_pubkey")?; + validate_required_field(&self.root_event_id, "root_event_id")?; + validate_required_field(&self.previous_event_id, "previous_event_id")?; + validate_required_field(&self.agreement_event_id, "agreement_event_id")?; + validate_required_field(&self.payment_event_id, "payment_event_id")?; + validate_required_field(&self.quote_id, "quote_id")?; + validate_required_field(&self.economics_digest, "economics_digest")?; + if self.quote_version == 0 { + return Err(RadrootsActiveTradePayloadError::InvalidQuoteVersion); + } + if self.amount.is_zero() || self.amount.is_sign_negative() { + return Err(RadrootsActiveTradePayloadError::InvalidPaymentAmount); + } + match self.decision { + RadrootsTradeSettlementDecision::Accepted => { + if self.reason.is_some() { + return Err(RadrootsActiveTradePayloadError::UnexpectedSettlementReason); + } + } + RadrootsTradeSettlementDecision::Rejected => match self.reason.as_deref() { + Some(reason) => validate_required_field(reason, "reason")?, + None => return Err(RadrootsActiveTradePayloadError::MissingSettlementReason), + }, + } + 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, @@ -854,6 +983,10 @@ pub enum RadrootsActiveTradeMessageType { TradeFulfillmentUpdated, #[cfg_attr(feature = "serde", serde(rename = "TradeBuyerReceipt"))] TradeBuyerReceipt, + #[cfg_attr(feature = "serde", serde(rename = "TradePaymentRecorded"))] + TradePaymentRecorded, + #[cfg_attr(feature = "serde", serde(rename = "TradeSettlementDecision"))] + TradeSettlementDecision, } impl RadrootsActiveTradeMessageType { @@ -867,6 +1000,8 @@ impl RadrootsActiveTradeMessageType { KIND_TRADE_CANCEL => Some(Self::TradeOrderCancelled), KIND_TRADE_FULFILLMENT_UPDATE => Some(Self::TradeFulfillmentUpdated), KIND_TRADE_RECEIPT => Some(Self::TradeBuyerReceipt), + KIND_TRADE_PAYMENT_RECORDED => Some(Self::TradePaymentRecorded), + KIND_TRADE_SETTLEMENT_DECISION => Some(Self::TradeSettlementDecision), _ => None, } } @@ -881,6 +1016,8 @@ impl RadrootsActiveTradeMessageType { Self::TradeOrderCancelled => KIND_TRADE_CANCEL, Self::TradeFulfillmentUpdated => KIND_TRADE_FULFILLMENT_UPDATE, Self::TradeBuyerReceipt => KIND_TRADE_RECEIPT, + Self::TradePaymentRecorded => KIND_TRADE_PAYMENT_RECORDED, + Self::TradeSettlementDecision => KIND_TRADE_SETTLEMENT_DECISION, } } @@ -894,6 +1031,8 @@ impl RadrootsActiveTradeMessageType { Self::TradeOrderCancelled => "TradeOrderCancelled", Self::TradeFulfillmentUpdated => "TradeFulfillmentUpdated", Self::TradeBuyerReceipt => "TradeBuyerReceipt", + Self::TradePaymentRecorded => "TradePaymentRecorded", + Self::TradeSettlementDecision => "TradeSettlementDecision", } } @@ -912,6 +1051,8 @@ impl RadrootsActiveTradeMessageType { | Self::TradeOrderCancelled | Self::TradeFulfillmentUpdated | Self::TradeBuyerReceipt + | Self::TradePaymentRecorded + | Self::TradeSettlementDecision ) } } @@ -1211,6 +1352,9 @@ pub enum RadrootsActiveTradePayloadError { InvalidFulfillmentStatus, MissingReceiptIssue, UnexpectedReceiptIssue, + InvalidPaymentAmount, + MissingSettlementReason, + UnexpectedSettlementReason, } impl core::fmt::Display for RadrootsActiveTradePayloadError { @@ -1285,6 +1429,18 @@ impl core::fmt::Display for RadrootsActiveTradePayloadError { Self::UnexpectedReceiptIssue => { write!(f, "receipt issue must be absent when received is true") } + Self::InvalidPaymentAmount => { + write!(f, "payment amount must be greater than zero") + } + Self::MissingSettlementReason => { + write!(f, "settlement reason is required when decision is rejected") + } + Self::UnexpectedSettlementReason => { + write!( + f, + "settlement reason must be absent when decision is accepted" + ) + } } } } @@ -1919,6 +2075,14 @@ mod tests { RadrootsActiveTradeMessageType::from_kind(KIND_TRADE_RECEIPT), Some(RadrootsActiveTradeMessageType::TradeBuyerReceipt) ); + assert_eq!( + RadrootsActiveTradeMessageType::from_kind(KIND_TRADE_PAYMENT_RECORDED), + Some(RadrootsActiveTradeMessageType::TradePaymentRecorded) + ); + assert_eq!( + RadrootsActiveTradeMessageType::from_kind(KIND_TRADE_SETTLEMENT_DECISION), + Some(RadrootsActiveTradeMessageType::TradeSettlementDecision) + ); assert_eq!(RadrootsActiveTradeMessageType::from_kind(3431), None); assert_eq!( RadrootsActiveTradeMessageType::TradeOrderRequested.kind(), @@ -1949,6 +2113,14 @@ mod tests { KIND_TRADE_RECEIPT ); assert_eq!( + RadrootsActiveTradeMessageType::TradePaymentRecorded.kind(), + KIND_TRADE_PAYMENT_RECORDED + ); + assert_eq!( + RadrootsActiveTradeMessageType::TradeSettlementDecision.kind(), + KIND_TRADE_SETTLEMENT_DECISION + ); + assert_eq!( RadrootsActiveTradeMessageType::TradeOrderRequested.name(), "TradeOrderRequested" ); @@ -1976,6 +2148,14 @@ mod tests { RadrootsActiveTradeMessageType::TradeBuyerReceipt.name(), "TradeBuyerReceipt" ); + assert_eq!( + RadrootsActiveTradeMessageType::TradePaymentRecorded.name(), + "TradePaymentRecorded" + ); + assert_eq!( + RadrootsActiveTradeMessageType::TradeSettlementDecision.name(), + "TradeSettlementDecision" + ); assert!(RadrootsActiveTradeMessageType::TradeOrderRequested.requires_listing_snapshot()); assert!(RadrootsActiveTradeMessageType::TradeOrderDecision.requires_trade_chain()); assert!(RadrootsActiveTradeMessageType::TradeOrderRevisionProposed.requires_trade_chain()); @@ -1983,6 +2163,8 @@ mod tests { assert!(RadrootsActiveTradeMessageType::TradeFulfillmentUpdated.requires_trade_chain()); assert!(RadrootsActiveTradeMessageType::TradeOrderCancelled.requires_trade_chain()); assert!(RadrootsActiveTradeMessageType::TradeBuyerReceipt.requires_trade_chain()); + assert!(RadrootsActiveTradeMessageType::TradePaymentRecorded.requires_trade_chain()); + assert!(RadrootsActiveTradeMessageType::TradeSettlementDecision.requires_trade_chain()); let request_name = serde_json::to_value(RadrootsActiveTradeMessageType::TradeOrderRequested).unwrap(); @@ -2000,6 +2182,10 @@ mod tests { serde_json::to_value(RadrootsActiveTradeMessageType::TradeOrderCancelled).unwrap(); let receipt_name = serde_json::to_value(RadrootsActiveTradeMessageType::TradeBuyerReceipt).unwrap(); + let payment_name = + serde_json::to_value(RadrootsActiveTradeMessageType::TradePaymentRecorded).unwrap(); + let settlement_name = + serde_json::to_value(RadrootsActiveTradeMessageType::TradeSettlementDecision).unwrap(); assert_eq!(request_name, serde_json::json!("TradeOrderRequested")); assert_eq!(decision_name, serde_json::json!("TradeOrderDecision")); assert_eq!( @@ -2016,6 +2202,11 @@ mod tests { ); assert_eq!(cancellation_name, serde_json::json!("TradeOrderCancelled")); assert_eq!(receipt_name, serde_json::json!("TradeBuyerReceipt")); + assert_eq!(payment_name, serde_json::json!("TradePaymentRecorded")); + assert_eq!( + settlement_name, + serde_json::json!("TradeSettlementDecision") + ); } #[test] diff --git a/crates/events_codec/src/trade/decode.rs b/crates/events_codec/src/trade/decode.rs @@ -12,7 +12,8 @@ use radroots_events::{ RadrootsTradeEnvelope, RadrootsTradeEnvelopeError, RadrootsTradeFulfillmentUpdated, RadrootsTradeMessageType, RadrootsTradeOrderCancelled, RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderRequested, RadrootsTradeOrderRevisionDecisionEvent, - RadrootsTradeOrderRevisionProposed, + RadrootsTradeOrderRevisionProposed, RadrootsTradePaymentRecorded, + RadrootsTradeSettlementDecisionEvent, }, }; #[cfg(feature = "serde_json")] @@ -552,6 +553,86 @@ pub fn active_trade_buyer_receipt_from_event( } #[cfg(feature = "serde_json")] +pub fn active_trade_payment_recorded_from_event( + event: &RadrootsNostrEvent, +) -> Result< + RadrootsActiveTradeEnvelope<RadrootsTradePaymentRecorded>, + RadrootsActiveTradeEnvelopeParseError, +> { + let envelope = active_trade_envelope_from_event::<RadrootsTradePaymentRecorded>(event)?; + if envelope.message_type != RadrootsActiveTradeMessageType::TradePaymentRecorded { + 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, + )?; + 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.previous_event_id.as_str()) { + return Err( + RadrootsActiveTradeEnvelopeParseError::PayloadBindingMismatch("previous_event_id"), + ); + } + Ok(envelope) +} + +#[cfg(feature = "serde_json")] +pub fn active_trade_settlement_decision_from_event( + event: &RadrootsNostrEvent, +) -> Result< + RadrootsActiveTradeEnvelope<RadrootsTradeSettlementDecisionEvent>, + RadrootsActiveTradeEnvelopeParseError, +> { + let envelope = active_trade_envelope_from_event::<RadrootsTradeSettlementDecisionEvent>(event)?; + if envelope.message_type != RadrootsActiveTradeMessageType::TradeSettlementDecision { + 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.previous_event_id.as_str()) { + return Err( + RadrootsActiveTradeEnvelopeParseError::PayloadBindingMismatch("previous_event_id"), + ); + } + Ok(envelope) +} + +#[cfg(feature = "serde_json")] pub fn trade_event_context_from_tags( message_type: RadrootsTradeMessageType, tags: &[Vec<String>], @@ -722,14 +803,17 @@ mod tests { 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, active_trade_order_revision_decision_from_event, - active_trade_order_revision_proposal_from_event, trade_envelope_from_event, + active_trade_order_revision_proposal_from_event, active_trade_payment_recorded_from_event, + active_trade_settlement_decision_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, active_trade_order_revision_decision_event_build, - active_trade_order_revision_proposal_event_build, trade_envelope_event_build, + active_trade_order_revision_proposal_event_build, + active_trade_payment_recorded_event_build, active_trade_settlement_decision_event_build, + trade_envelope_event_build, }; use crate::trade::tags::TAG_LISTING_EVENT; use radroots_core::{ @@ -740,7 +824,8 @@ mod tests { kinds::{ KIND_TRADE_CANCEL, KIND_TRADE_FULFILLMENT_UPDATE, KIND_TRADE_ORDER_DECISION, KIND_TRADE_ORDER_REQUEST, KIND_TRADE_ORDER_REVISION, - KIND_TRADE_ORDER_REVISION_RESPONSE, KIND_TRADE_RECEIPT, + KIND_TRADE_ORDER_REVISION_RESPONSE, KIND_TRADE_PAYMENT_RECORDED, KIND_TRADE_RECEIPT, + KIND_TRADE_SETTLEMENT_DECISION, }, tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT}, trade::{ @@ -753,7 +838,9 @@ mod tests { RadrootsTradeOrderEconomicItem, RadrootsTradeOrderEconomicLine, RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, RadrootsTradeOrderRequested, RadrootsTradeOrderRevisionDecision, RadrootsTradeOrderRevisionDecisionEvent, - RadrootsTradeOrderRevisionProposed, RadrootsTradePricingBasis, + RadrootsTradeOrderRevisionProposed, RadrootsTradePaymentMethod, + RadrootsTradePaymentRecorded, RadrootsTradePricingBasis, + RadrootsTradeSettlementDecision, RadrootsTradeSettlementDecisionEvent, }, }; @@ -905,6 +992,49 @@ mod tests { } } + fn active_payment_recorded() -> RadrootsTradePaymentRecorded { + RadrootsTradePaymentRecorded { + 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(), + previous_event_id: "agreement-event".into(), + agreement_event_id: "agreement-event".into(), + quote_id: "quote-1".into(), + quote_version: 1, + economics_digest: "digest-1".into(), + amount: decimal("15"), + currency: RadrootsCoreCurrency::USD, + method: RadrootsTradePaymentMethod::Cash, + reference: Some("cash drawer".into()), + paid_at: Some(1_777_665_600), + } + } + + fn active_settlement_decision( + decision: RadrootsTradeSettlementDecision, + ) -> RadrootsTradeSettlementDecisionEvent { + RadrootsTradeSettlementDecisionEvent { + order_id: "order-1".into(), + listing_addr: "30402:seller:AAAAAAAAAAAAAAAAAAAAAg".into(), + seller_pubkey: "seller".into(), + buyer_pubkey: "buyer".into(), + root_event_id: "root-event".into(), + previous_event_id: "payment-event".into(), + agreement_event_id: "agreement-event".into(), + payment_event_id: "payment-event".into(), + quote_id: "quote-1".into(), + quote_version: 1, + economics_digest: "digest-1".into(), + amount: decimal("15"), + currency: RadrootsCoreCurrency::USD, + decision, + reason: (decision == RadrootsTradeSettlementDecision::Rejected) + .then(|| "reference mismatch".into()), + } + } + fn listing_event_ptr() -> RadrootsNostrEventPtr { RadrootsNostrEventPtr { id: "listing-snapshot".into(), @@ -1204,6 +1334,85 @@ mod tests { } #[test] + fn active_payment_recorded_builder_emits_canonical_buyer_chain_shape() { + let payload = active_payment_recorded(); + let built = active_trade_payment_recorded_event_build( + payload.root_event_id.as_str(), + payload.previous_event_id.as_str(), + &payload, + ) + .unwrap(); + let envelope: RadrootsActiveTradeEnvelope<RadrootsTradePaymentRecorded> = + serde_json::from_str(&built.content).unwrap(); + + assert_eq!(built.kind, KIND_TRADE_PAYMENT_RECORDED); + assert_eq!( + envelope.message_type, + RadrootsActiveTradeMessageType::TradePaymentRecorded + ); + assert_eq!(envelope.payload.amount, decimal("15")); + assert_eq!(envelope.payload.method, RadrootsTradePaymentMethod::Cash); + 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(), "agreement-event".to_string()]) + ); + } + + #[test] + fn active_settlement_decision_builder_emits_canonical_seller_chain_shape() { + let payload = active_settlement_decision(RadrootsTradeSettlementDecision::Accepted); + let built = active_trade_settlement_decision_event_build( + payload.root_event_id.as_str(), + payload.previous_event_id.as_str(), + &payload, + ) + .unwrap(); + let envelope: RadrootsActiveTradeEnvelope<RadrootsTradeSettlementDecisionEvent> = + serde_json::from_str(&built.content).unwrap(); + + assert_eq!(built.kind, KIND_TRADE_SETTLEMENT_DECISION); + assert_eq!( + envelope.message_type, + RadrootsActiveTradeMessageType::TradeSettlementDecision + ); + assert_eq!( + envelope.payload.decision, + RadrootsTradeSettlementDecision::Accepted + ); + assert_eq!(envelope.payload.reason, None); + 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(), "payment-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(); @@ -1350,6 +1559,60 @@ mod tests { } #[test] + fn active_payment_recorded_parse_roundtrips_and_validates_buyer_actor() { + let payload = active_payment_recorded(); + let built = active_trade_payment_recorded_event_build( + payload.root_event_id.as_str(), + payload.previous_event_id.as_str(), + &payload, + ) + .unwrap(); + let event = RadrootsNostrEvent { + id: "payment-event".into(), + author: "buyer".into(), + created_at: 1, + kind: built.kind, + tags: built.tags, + content: built.content, + sig: "sig".into(), + }; + let envelope = active_trade_payment_recorded_from_event(&event).unwrap(); + + assert_eq!(envelope.payload, payload); + assert_eq!( + envelope.message_type, + RadrootsActiveTradeMessageType::TradePaymentRecorded + ); + } + + #[test] + fn active_settlement_decision_parse_roundtrips_and_validates_seller_actor() { + let payload = active_settlement_decision(RadrootsTradeSettlementDecision::Rejected); + let built = active_trade_settlement_decision_event_build( + payload.root_event_id.as_str(), + payload.previous_event_id.as_str(), + &payload, + ) + .unwrap(); + let event = RadrootsNostrEvent { + id: "settlement-event".into(), + author: "seller".into(), + created_at: 1, + kind: built.kind, + tags: built.tags, + content: built.content, + sig: "sig".into(), + }; + let envelope = active_trade_settlement_decision_from_event(&event).unwrap(); + + assert_eq!(envelope.payload, payload); + assert_eq!( + envelope.message_type, + RadrootsActiveTradeMessageType::TradeSettlementDecision + ); + } + + #[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( diff --git a/crates/events_codec/src/trade/encode.rs b/crates/events_codec/src/trade/encode.rs @@ -11,6 +11,7 @@ use radroots_events::{ RadrootsTradeMessagePayload, RadrootsTradeMessageType, RadrootsTradeOrderCancelled, RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderRequested, RadrootsTradeOrderRevisionDecisionEvent, RadrootsTradeOrderRevisionProposed, + RadrootsTradePaymentRecorded, RadrootsTradeSettlementDecisionEvent, }, }; @@ -101,6 +102,15 @@ fn map_active_payload_error(error: RadrootsActiveTradePayloadError) -> EventEnco RadrootsActiveTradePayloadError::UnexpectedReceiptIssue => { EventEncodeError::InvalidField("receipt.issue") } + RadrootsActiveTradePayloadError::InvalidPaymentAmount => { + EventEncodeError::InvalidField("payment.amount") + } + RadrootsActiveTradePayloadError::MissingSettlementReason => { + EventEncodeError::EmptyRequiredField("settlement.reason") + } + RadrootsActiveTradePayloadError::UnexpectedSettlementReason => { + EventEncodeError::InvalidField("settlement.reason") + } } } @@ -338,3 +348,53 @@ pub fn active_trade_buyer_receipt_event_build( payload, ) } + +#[cfg(feature = "serde_json")] +pub fn active_trade_payment_recorded_event_build( + root_event_id: &str, + prev_event_id: &str, + payload: &RadrootsTradePaymentRecorded, +) -> 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.previous_event_id != prev_event_id { + return Err(EventEncodeError::InvalidField("previous_event_id")); + } + active_trade_envelope_event_build( + &payload.seller_pubkey, + RadrootsActiveTradeMessageType::TradePaymentRecorded, + &payload.listing_addr, + &payload.order_id, + None, + Some(root_event_id), + Some(prev_event_id), + payload, + ) +} + +#[cfg(feature = "serde_json")] +pub fn active_trade_settlement_decision_event_build( + root_event_id: &str, + prev_event_id: &str, + payload: &RadrootsTradeSettlementDecisionEvent, +) -> 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.previous_event_id != prev_event_id { + return Err(EventEncodeError::InvalidField("previous_event_id")); + } + active_trade_envelope_event_build( + &payload.buyer_pubkey, + RadrootsActiveTradeMessageType::TradeSettlementDecision, + &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 @@ -10,7 +10,8 @@ pub use decode::{ 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, active_trade_order_revision_decision_from_event, - active_trade_order_revision_proposal_from_event, trade_envelope_from_event, + active_trade_order_revision_proposal_from_event, active_trade_payment_recorded_from_event, + active_trade_settlement_decision_from_event, trade_envelope_from_event, trade_event_context_from_tags, }; #[cfg(feature = "serde_json")] @@ -18,7 +19,8 @@ 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, active_trade_order_revision_decision_event_build, - active_trade_order_revision_proposal_event_build, trade_envelope_event_build, + active_trade_order_revision_proposal_event_build, active_trade_payment_recorded_event_build, + active_trade_settlement_decision_event_build, trade_envelope_event_build, }; pub use tags::{ TAG_LISTING_EVENT, parse_trade_counterparty_tag, parse_trade_listing_event_tag,