cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

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:
Msrc/operation_order.rs | 32++++++++++++++++++++++++++------
Msrc/runtime/order.rs | 167+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
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();