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