commit b3dd0fe7f9f14407b9e97fe9e2e58ddb469f24ef
parent 88b7931c576cf37730aca3400b2d4599d8306221
Author: triesap <tyson@radroots.org>
Date: Tue, 28 Apr 2026 17:08:02 +0000
cli: publish seller order declines
- route decline decisions through resolved seller order requests
- build canonical declined kind 3423 payloads with request chain tags
- reject blank decline reasons before approval or signing
- cover declined payload and chain-tag construction in tests
Diffstat:
2 files changed, 186 insertions(+), 13 deletions(-)
diff --git a/src/operation_order.rs b/src/operation_order.rs
@@ -134,12 +134,15 @@ impl OperationService<OrderDeclineRequest> for OrderOperationService<'_> {
&self,
request: OperationRequest<OrderDeclineRequest>,
) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
- let reason = string_input(&request, "reason").ok_or_else(|| {
- invalid_input(
- request.operation_id(),
- "missing required `reason` input".to_owned(),
- )
- })?;
+ let reason = string_input(&request, "reason")
+ .map(|reason| reason.trim().to_owned())
+ .filter(|reason| !reason.is_empty())
+ .ok_or_else(|| {
+ invalid_input(
+ request.operation_id(),
+ "missing required `reason` input".to_owned(),
+ )
+ })?;
if request.context.requires_approval_token() {
return Err(OperationAdapterError::approval_required(
request.operation_id(),
@@ -567,6 +570,23 @@ mod tests {
}
#[test]
+ fn order_decline_rejects_blank_reason_before_approval() {
+ let dir = tempdir().expect("tempdir");
+ let config = sample_config(dir.path());
+ let service = OperationAdapter::new(OrderOperationService::new(&config));
+ let decline = OperationRequest::new(
+ OperationContext::default(),
+ OrderDeclineRequest::from_data(data(&[("order_id", "ord_pending"), ("reason", " ")])),
+ )
+ .expect("order decline request");
+ let error = service.execute(decline).expect_err("reason required");
+ let output_error = error.to_output_error();
+
+ assert_eq!(output_error.code, "invalid_input");
+ assert!(output_error.message.contains("reason"));
+ }
+
+ #[test]
fn order_status_get_requires_relay_configuration() {
let dir = tempdir().expect("tempdir");
let config = sample_config(dir.path());
diff --git a/src/runtime/order.rs b/src/runtime/order.rs
@@ -730,7 +730,7 @@ pub fn decide(
args.key.as_str(),
receipt,
)?;
- if args.decision == OrderDecisionArg::Accept && resolution.requests.len() == 1 {
+ if resolution.requests.len() == 1 {
let request = resolution.requests[0].clone();
let signing = match resolve_local_order_decision_signing_identity(
config,
@@ -744,7 +744,7 @@ pub fn decide(
));
}
};
- return publish_order_accept_decision(config, args, request, resolution, signing);
+ return publish_order_decision(config, args, request, resolution, signing);
}
Ok(order_decision_view_from_resolution(
config,
@@ -1179,7 +1179,7 @@ fn seller_order_request_from_event(
})
}
-fn publish_order_accept_decision(
+fn publish_order_decision(
config: &RuntimeConfig,
args: &OrderDecisionArgs,
request: ResolvedSellerOrderRequest,
@@ -1192,7 +1192,7 @@ fn publish_order_accept_decision(
.public_identity
.public_key_hex
.as_str();
- let payload = accepted_order_decision_payload_from_request(&request);
+ let payload = order_decision_payload_from_request(args, &request)?;
let payload = canonicalize_active_order_decision_for_signer(payload, signer_pubkey)
.map_err(|error| RuntimeError::Config(format!("canonicalize order decision: {error}")))?;
let parts = active_trade_order_decision_event_build(
@@ -1210,6 +1210,28 @@ fn publish_order_accept_decision(
))
}
+fn order_decision_payload_from_request(
+ args: &OrderDecisionArgs,
+ request: &ResolvedSellerOrderRequest,
+) -> Result<RadrootsTradeOrderDecisionEvent, RuntimeError> {
+ match args.decision {
+ OrderDecisionArg::Accept => Ok(accepted_order_decision_payload_from_request(request)),
+ OrderDecisionArg::Decline => {
+ let reason = args
+ .reason
+ .as_deref()
+ .map(str::trim)
+ .filter(|reason| !reason.is_empty())
+ .ok_or_else(|| {
+ RuntimeError::Config("order decline requires a non-empty reason".to_owned())
+ })?;
+ Ok(declined_order_decision_payload_from_request(
+ request, reason,
+ ))
+ }
+ }
+}
+
fn accepted_order_decision_payload_from_request(
request: &ResolvedSellerOrderRequest,
) -> RadrootsTradeOrderDecisionEvent {
@@ -1231,6 +1253,21 @@ fn accepted_order_decision_payload_from_request(
}
}
+fn declined_order_decision_payload_from_request(
+ request: &ResolvedSellerOrderRequest,
+ reason: &str,
+) -> RadrootsTradeOrderDecisionEvent {
+ RadrootsTradeOrderDecisionEvent {
+ order_id: request.order_id.clone(),
+ listing_addr: request.listing_addr.clone(),
+ buyer_pubkey: request.buyer_pubkey.clone(),
+ seller_pubkey: request.seller_pubkey.clone(),
+ decision: RadrootsTradeOrderDecision::Declined {
+ reason: reason.to_owned(),
+ },
+ }
+}
+
fn published_order_decision_view(
config: &RuntimeConfig,
args: &OrderDecisionArgs,
@@ -2263,9 +2300,10 @@ mod tests {
use super::{
ORDER_DRAFT_KIND, OrderDraft, OrderDraftDocument, OrderDraftItem,
- accepted_order_decision_payload_from_request, collect_issues, inspect_document,
- next_order_id, order_history_entry_from_event, order_history_from_receipt,
- order_request_filter, seller_order_request_resolution_from_receipt,
+ accepted_order_decision_payload_from_request, collect_issues,
+ declined_order_decision_payload_from_request, inspect_document, next_order_id,
+ order_history_entry_from_event, order_history_from_receipt, order_request_filter,
+ seller_order_request_resolution_from_receipt,
};
use crate::runtime::direct_relay::DirectRelayFetchReceipt;
@@ -2607,6 +2645,121 @@ mod tests {
}
#[test]
+ fn declined_order_decision_payload_uses_decline_reason() {
+ let seller = RadrootsIdentity::generate();
+ let buyer = RadrootsIdentity::generate();
+ let seller_pubkey = seller.public_key_hex();
+ let buyer_pubkey = buyer.public_key_hex();
+ let order_id = "ord_AAAAAAAAAAAAAAAAAAAAAg";
+ let listing_event_id = "1".repeat(64);
+ let listing_addr = format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg");
+ let receipt = DirectRelayFetchReceipt {
+ target_relays: vec!["ws://relay.test".to_owned()],
+ connected_relays: vec!["ws://relay.test".to_owned()],
+ failed_relays: Vec::new(),
+ events: vec![signed_order_request_event(
+ &buyer,
+ order_id,
+ listing_addr.as_str(),
+ buyer_pubkey.as_str(),
+ seller_pubkey.as_str(),
+ listing_event_id.as_str(),
+ )],
+ };
+ let resolution =
+ seller_order_request_resolution_from_receipt(seller_pubkey.as_str(), order_id, receipt)
+ .expect("seller order request resolution");
+ let request = resolution
+ .requests
+ .first()
+ .expect("resolved request")
+ .clone();
+
+ let payload = declined_order_decision_payload_from_request(&request, "out of stock");
+
+ assert_eq!(payload.order_id, order_id);
+ assert_eq!(payload.listing_addr, listing_addr);
+ assert_eq!(payload.buyer_pubkey, buyer_pubkey);
+ assert_eq!(payload.seller_pubkey, seller_pubkey);
+ let RadrootsTradeOrderDecision::Declined { reason } = payload.decision else {
+ panic!("expected declined decision");
+ };
+ assert_eq!(reason, "out of stock");
+ }
+
+ #[test]
+ fn declined_order_decision_event_uses_request_chain_tags() {
+ let seller = RadrootsIdentity::generate();
+ let buyer = RadrootsIdentity::generate();
+ let seller_pubkey = seller.public_key_hex();
+ let buyer_pubkey = buyer.public_key_hex();
+ let order_id = "ord_AAAAAAAAAAAAAAAAAAAAAg";
+ let listing_event_id = "1".repeat(64);
+ let listing_addr = format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg");
+ let receipt = DirectRelayFetchReceipt {
+ target_relays: vec!["ws://relay.test".to_owned()],
+ connected_relays: vec!["ws://relay.test".to_owned()],
+ failed_relays: Vec::new(),
+ events: vec![signed_order_request_event(
+ &buyer,
+ order_id,
+ listing_addr.as_str(),
+ buyer_pubkey.as_str(),
+ seller_pubkey.as_str(),
+ listing_event_id.as_str(),
+ )],
+ };
+ let resolution =
+ seller_order_request_resolution_from_receipt(seller_pubkey.as_str(), order_id, receipt)
+ .expect("seller order request resolution");
+ let request = resolution
+ .requests
+ .first()
+ .expect("resolved request")
+ .clone();
+ let payload = declined_order_decision_payload_from_request(&request, " out of stock ");
+ let payload =
+ canonicalize_active_order_decision_for_signer(payload, seller_pubkey.as_str())
+ .expect("canonical decision payload");
+ let parts = active_trade_order_decision_event_build(
+ request.request_event_id.as_str(),
+ request.request_event_id.as_str(),
+ &payload,
+ )
+ .expect("decision event parts");
+
+ assert_eq!(parts.kind, KIND_TRADE_ORDER_DECISION);
+ let event = radroots_nostr_build_event(parts.kind, parts.content, parts.tags)
+ .expect("nostr event builder")
+ .sign_with_keys(seller.keys())
+ .expect("signed order decision");
+ let event = radroots_event_from_nostr(&event);
+ let envelope =
+ active_trade_order_decision_from_event(&event).expect("decoded decision event");
+ let context = active_trade_event_context_from_tags(
+ RadrootsActiveTradeMessageType::TradeOrderDecision,
+ &event.tags,
+ )
+ .expect("decision event context");
+
+ assert_eq!(envelope.order_id, order_id);
+ assert_eq!(envelope.payload.seller_pubkey, seller_pubkey);
+ assert_eq!(envelope.payload.buyer_pubkey, buyer_pubkey);
+ let RadrootsTradeOrderDecision::Declined { reason } = envelope.payload.decision else {
+ panic!("expected declined decision");
+ };
+ assert_eq!(reason, "out of stock");
+ assert_eq!(
+ context.root_event_id.as_deref(),
+ Some(request.request_event_id.as_str())
+ );
+ assert_eq!(
+ context.prev_event_id.as_deref(),
+ Some(request.request_event_id.as_str())
+ );
+ }
+
+ #[test]
fn seller_order_request_resolution_skips_wrong_seller_request() {
let selected_seller = RadrootsIdentity::generate();
let other_seller = RadrootsIdentity::generate();