cli

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

commit 217c58fa35200118d49443407cb9d6668d8a4139
parent 079170d50a8a36cb9a801858bedeea8658a31cf3
Author: triesap <tyson@radroots.org>
Date:   Thu, 30 Apr 2026 02:11:14 +0000

order: implement buyer cancellation runtime

Diffstat:
Msrc/runtime/order.rs | 990++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
1 file changed, 929 insertions(+), 61 deletions(-)

diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -5,7 +5,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use radroots_events::RadrootsNostrEventPtr; use radroots_events::kinds::{ - KIND_LISTING, KIND_TRADE_FULFILLMENT_UPDATE, KIND_TRADE_ORDER_DECISION, + KIND_LISTING, KIND_TRADE_CANCEL, KIND_TRADE_FULFILLMENT_UPDATE, KIND_TRADE_ORDER_DECISION, KIND_TRADE_ORDER_REQUEST, }; use radroots_events::listing::{ @@ -13,15 +13,17 @@ use radroots_events::listing::{ }; use radroots_events::trade::{ RadrootsActiveTradeFulfillmentState, RadrootsActiveTradeMessageType, - RadrootsTradeFulfillmentUpdated, RadrootsTradeInventoryCommitment, RadrootsTradeOrderDecision, - RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderItem, RadrootsTradeOrderRequested, + RadrootsTradeFulfillmentUpdated, RadrootsTradeInventoryCommitment, RadrootsTradeOrderCancelled, + RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderItem, + RadrootsTradeOrderRequested, }; use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_events_codec::listing::decode::listing_from_event; use radroots_events_codec::trade::{ RadrootsTradeListingAddress, active_trade_envelope_from_event, active_trade_event_context_from_tags, active_trade_fulfillment_update_event_build, - active_trade_fulfillment_update_from_event, active_trade_order_decision_event_build, + active_trade_fulfillment_update_from_event, active_trade_order_cancel_event_build, + active_trade_order_cancel_from_event, active_trade_order_decision_event_build, active_trade_order_request_event_build, active_trade_order_request_from_event, }; use radroots_events_codec::wire::WireEventParts; @@ -957,34 +959,81 @@ pub fn cancel( config: &RuntimeConfig, args: &OrderCancelArgs, ) -> Result<OrderCancellationView, RuntimeError> { - Ok(OrderCancellationView { - state: "unavailable".to_owned(), - source: ORDER_CANCELLATION_SOURCE.to_owned(), - order_id: args.key.clone(), - listing_addr: None, - buyer_pubkey: None, - seller_pubkey: None, - request_event_id: None, - decision_event_id: None, - root_event_id: None, - prev_event_id: None, - event_id: None, - event_kind: None, - cancellation_reason: Some(args.reason.clone()), - dry_run: config.output.dry_run, - target_relays: config.relay.urls.clone(), - connected_relays: Vec::new(), - acknowledged_relays: Vec::new(), - failed_relays: Vec::new(), - fetched_count: 0, - decoded_count: 0, - skipped_count: 0, - idempotency_key: args.idempotency_key.clone(), - signer_mode: Some(config.signer.backend.as_str().to_owned()), - reason: Some("order cancel runtime is pending lifecycle preflight wiring".to_owned()), - issues: Vec::new(), - actions: vec![format!("radroots order status get {}", args.key)], - }) + if config.relay.urls.is_empty() { + let mut view = + order_cancellation_base_view(config, args, "unconfigured", config.output.dry_run); + view.reason = Some("order cancel requires at least one configured relay".to_owned()); + return Ok(view); + } + + let selected_account = match accounts::resolve_account(config)? { + Some(account) => account, + None => { + let mut view = + order_cancellation_base_view(config, args, "unconfigured", config.output.dry_run); + view.reason = Some("order cancel requires a selected buyer account".to_owned()); + view.actions = vec!["radroots account create".to_owned()]; + return Ok(view); + } + }; + let selected_pubkey = selected_account.record.public_identity.public_key_hex; + let filter = order_status_filter(args.key.as_str())?; + let receipt = match fetch_events_from_relays(&config.relay.urls, filter) { + Ok(receipt) => receipt, + Err(DirectRelayFetchError::Connect { + reason, + target_relays, + failed_relays, + }) => { + let mut view = + order_cancellation_base_view(config, args, "unavailable", config.output.dry_run); + view.buyer_pubkey = Some(selected_pubkey); + view.target_relays = target_relays; + view.failed_relays = relay_failures(failed_relays); + view.reason = Some(format!("direct relay connection failed: {reason}")); + return Ok(view); + } + Err(error) => return Err(RuntimeError::Network(error.to_string())), + }; + + let reduction = order_status_reduction_from_receipt_with_context( + OrderStatusContext { + order_id: args.key.as_str(), + selected_account_pubkey: Some(selected_pubkey.as_str()), + }, + receipt, + ); + let status_view = reduction.view; + if let Some(view) = order_cancellation_preflight_view_from_status( + config, + args, + &status_view, + selected_pubkey.as_str(), + ) { + return Ok(view); + } + + let buyer_pubkey = status_view + .buyer_pubkey + .as_deref() + .ok_or_else(|| RuntimeError::Config("order is missing buyer_pubkey".to_owned()))?; + let signing = match resolve_local_order_cancellation_signing_identity(config, buyer_pubkey) { + Ok(signing) => signing, + Err(error) => { + return Ok(order_cancellation_binding_error_view( + config, + args, + &status_view, + error, + )); + } + }; + let payload = order_cancellation_payload_from_status(args, &status_view)?; + let _ = order_cancellation_event_parts(&status_view, &payload)?; + if config.output.dry_run { + return Ok(order_cancellation_dry_run_view(config, args, &status_view)); + } + publish_order_cancellation(config, args, status_view, signing, payload) } pub fn receipt_record( @@ -1113,6 +1162,7 @@ enum OrderStatusRecord { }, Decision(RadrootsActiveOrderDecisionRecord), Fulfillment(RadrootsActiveOrderFulfillmentRecord), + Cancellation(RadrootsActiveOrderCancellationRecord), } #[derive(Debug, Clone)] @@ -1167,6 +1217,7 @@ fn order_status_reduction_from_receipt_with_context( let mut requests = Vec::new(); let mut decisions = Vec::new(); let mut fulfillments = Vec::new(); + let mut cancellations = Vec::new(); let mut request_listing_events = Vec::new(); let mut candidate_issues = Vec::new(); @@ -1192,6 +1243,10 @@ fn order_status_reduction_from_receipt_with_context( decoded_count += 1; fulfillments.push(record); } + Ok(OrderStatusRecord::Cancellation(record)) => { + decoded_count += 1; + cancellations.push(record); + } Err(error) => { skipped_count += 1; if order_status_request_candidate(&event, context) { @@ -1216,12 +1271,13 @@ fn order_status_reduction_from_receipt_with_context( let order_id = context.order_id; let fulfillment_records = fulfillments.clone(); + let cancellation_records = cancellations.clone(); let projection = reduce_active_order_events( order_id, requests, decisions.clone(), fulfillments, - Vec::<RadrootsActiveOrderCancellationRecord>::new(), + cancellations, Vec::<RadrootsActiveOrderReceiptRecord>::new(), ); let fulfillment_event_id = projection.fulfillment_event_id.clone(); @@ -1238,6 +1294,26 @@ fn order_status_reduction_from_receipt_with_context( .find(|record| &record.event_id == event_id) .map(|record| record.prev_event_id.clone()) }); + let cancellation_root_event_id = + projection + .cancellation_event_id + .as_ref() + .and_then(|event_id| { + cancellation_records + .iter() + .find(|record| &record.event_id == event_id) + .map(|record| record.root_event_id.clone()) + }); + let cancellation_prev_event_id = + projection + .cancellation_event_id + .as_ref() + .and_then(|event_id| { + cancellation_records + .iter() + .find(|record| &record.event_id == event_id) + .map(|record| record.prev_event_id.clone()) + }); let listing_event_id = projection .request_event_id .as_ref() @@ -1284,8 +1360,8 @@ fn order_status_reduction_from_receipt_with_context( projection.last_event_id.clone(), projection.fulfillment_status, projection.cancellation_event_id.clone(), - None, - None, + cancellation_root_event_id, + cancellation_prev_event_id, projection.settlement_pending, projection.settlement_reason.clone(), None, @@ -1386,6 +1462,11 @@ fn enrich_order_status_inventory( .into_iter() .filter(|record| request_order_ids.contains(&record.payload.order_id)) .collect::<Vec<_>>(); + let cancellations = + fetch_listing_accounting_cancellations_for_status(config, listing_addr.as_str())? + .into_iter() + .filter(|record| request_order_ids.contains(&record.payload.order_id)) + .collect::<Vec<_>>(); let projection = reduce_listing_inventory_accounting( listing_addr.as_str(), listing.event_id.as_str(), @@ -1393,7 +1474,7 @@ fn enrich_order_status_inventory( requests, decisions, fulfillments, - Vec::<RadrootsActiveOrderCancellationRecord>::new(), + cancellations, Vec::<RadrootsActiveOrderReceiptRecord>::new(), ); let relevant_issues = projection @@ -1528,6 +1609,28 @@ fn fetch_listing_accounting_fulfillments_for_status( Ok(records) } +fn fetch_listing_accounting_cancellations_for_status( + config: &RuntimeConfig, + listing_addr: &str, +) -> Result<Vec<RadrootsActiveOrderCancellationRecord>, RuntimeError> { + let filter = order_listing_cancellation_filter(listing_addr)?; + let receipt = fetch_events_from_relays(&config.relay.urls, filter) + .map_err(|error| RuntimeError::Network(error.to_string()))?; + let mut records = Vec::new(); + for event in receipt.events { + if event_kind_u32(&event) != KIND_TRADE_CANCEL + || !event_matches_tag_value(&event, "a", listing_addr) + { + continue; + } + if let Ok(OrderStatusRecord::Cancellation(record)) = order_status_record_from_event(&event) + { + records.push(record); + } + } + Ok(records) +} + fn listing_inventory_issue_involves_order( issue: &RadrootsListingInventoryAccountingIssue, order_id: &str, @@ -1674,6 +1777,29 @@ fn order_status_record_from_event( }, )) } + KIND_TRADE_CANCEL => { + let event = radroots_event_from_nostr(event); + let envelope = active_trade_order_cancel_from_event(&event).map_err(|error| { + RuntimeError::Config(format!("decode active order cancellation event: {error}")) + })?; + let context = active_trade_event_context_from_tags( + RadrootsActiveTradeMessageType::TradeOrderCancelled, + &event.tags, + ) + .map_err(|error| { + RuntimeError::Config(format!("decode active order cancellation tags: {error}")) + })?; + Ok(OrderStatusRecord::Cancellation( + RadrootsActiveOrderCancellationRecord { + event_id: event.id, + author_pubkey: event.author, + counterparty_pubkey: context.counterparty_pubkey, + root_event_id: context.root_event_id.unwrap_or_default(), + prev_event_id: context.prev_event_id.unwrap_or_default(), + payload: envelope.payload, + }, + )) + } event_kind => Err(RuntimeError::Config(format!( "order status received unexpected kind `{event_kind}`" ))), @@ -2582,6 +2708,42 @@ fn order_fulfillment_base_view( } } +fn order_cancellation_base_view( + config: &RuntimeConfig, + args: &OrderCancelArgs, + state: &str, + dry_run: bool, +) -> OrderCancellationView { + OrderCancellationView { + state: state.to_owned(), + source: ORDER_CANCELLATION_SOURCE.to_owned(), + order_id: args.key.clone(), + listing_addr: None, + buyer_pubkey: None, + seller_pubkey: None, + request_event_id: None, + decision_event_id: None, + root_event_id: None, + prev_event_id: None, + event_id: None, + event_kind: None, + cancellation_reason: Some(args.reason.clone()), + dry_run, + target_relays: config.relay.urls.clone(), + connected_relays: Vec::new(), + acknowledged_relays: Vec::new(), + failed_relays: Vec::new(), + fetched_count: 0, + decoded_count: 0, + skipped_count: 0, + idempotency_key: args.idempotency_key.clone(), + signer_mode: Some(config.signer.backend.as_str().to_owned()), + reason: None, + issues: Vec::new(), + actions: Vec::new(), + } +} + fn apply_order_fulfillment_status(view: &mut OrderFulfillmentView, status: &OrderStatusView) { view.order_id = status.order_id.clone(); view.listing_addr = status.listing_addr.clone(); @@ -2600,6 +2762,105 @@ fn apply_order_fulfillment_status(view: &mut OrderFulfillmentView, status: &Orde view.issues = status.reducer_issues.clone(); } +fn apply_order_cancellation_status(view: &mut OrderCancellationView, status: &OrderStatusView) { + view.order_id = status.order_id.clone(); + view.listing_addr = status.listing_addr.clone(); + view.buyer_pubkey = status.buyer_pubkey.clone(); + view.seller_pubkey = status.seller_pubkey.clone(); + view.request_event_id = status.request_event_id.clone(); + view.decision_event_id = status.decision_event_id.clone(); + view.root_event_id = status.request_event_id.clone(); + view.prev_event_id = order_cancellation_prev_event_id(status); + view.target_relays = status.target_relays.clone(); + view.connected_relays = status.connected_relays.clone(); + view.failed_relays = status.failed_relays.clone(); + view.fetched_count = status.fetched_count; + view.decoded_count = status.decoded_count; + view.skipped_count = status.skipped_count; + view.issues = status.reducer_issues.clone(); +} + +fn order_cancellation_prev_event_id(status: &OrderStatusView) -> Option<String> { + match status.state.as_str() { + "requested" => status.request_event_id.clone(), + "accepted" => status.decision_event_id.clone(), + _ => status.last_event_id.clone(), + } +} + +fn order_cancellation_preflight_view_from_status( + config: &RuntimeConfig, + args: &OrderCancelArgs, + status: &OrderStatusView, + selected_pubkey: &str, +) -> Option<OrderCancellationView> { + let buyer_matches = status + .buyer_pubkey + .as_deref() + .is_some_and(|buyer| buyer.eq_ignore_ascii_case(selected_pubkey)); + let state = match status.state.as_str() { + "requested" if buyer_matches => return None, + "accepted" + if buyer_matches + && status + .fulfillment + .as_ref() + .and_then(|fulfillment| fulfillment.event_id.as_ref()) + .is_none() => + { + return None; + } + "accepted" if buyer_matches => "fulfilled", + "missing" | "declined" | "cancelled" | "completed" | "disputed" | "invalid" + | "unavailable" | "unconfigured" => status.state.as_str(), + _ => "invalid", + }; + let mut view = order_cancellation_base_view(config, args, state, config.output.dry_run); + apply_order_cancellation_status(&mut view, status); + if status.state == "cancelled" { + view.event_id = status + .lifecycle + .as_ref() + .and_then(|lifecycle| lifecycle.event_id.clone()); + view.event_kind = Some(KIND_TRADE_CANCEL); + } + view.reason = Some(match state { + "missing" => format!("no active order events matched `{}`", args.key), + "declined" => format!( + "order cancel refused because order `{}` was declined", + args.key + ), + "cancelled" | "completed" | "disputed" => { + format!( + "order cancel refused because order `{}` is already terminal", + args.key + ) + } + "fulfilled" => format!( + "order cancel refused because order `{}` already has seller fulfillment", + args.key + ), + "invalid" if !buyer_matches && status.buyer_pubkey.is_some() => format!( + "order cancel refused because selected account is not buyer for order `{}`", + args.key + ), + "invalid" => status.reason.clone().unwrap_or_else(|| { + format!( + "order cancel refused because active order events for `{}` are invalid", + args.key + ) + }), + _ => status.reason.clone().unwrap_or_else(|| { + format!( + "order cancel status preflight failed with state `{}`", + status.state + ) + }), + }); + view.actions = vec![format!("radroots order status get {}", args.key)]; + Some(view) +} + fn order_fulfillment_preflight_view_from_status( config: &RuntimeConfig, args: &OrderFulfillmentArgs, @@ -2926,6 +3187,10 @@ fn order_accept_inventory_preflight_view( .into_iter() .filter(|record| request_order_ids.contains(&record.payload.order_id)) .collect::<Vec<_>>(); + let cancellations = fetch_listing_accounting_cancellations(config, request)? + .into_iter() + .filter(|record| request_order_ids.contains(&record.payload.order_id)) + .collect::<Vec<_>>(); let projection = reduce_listing_inventory_accounting( request.listing_addr.as_str(), @@ -2934,7 +3199,7 @@ fn order_accept_inventory_preflight_view( requests, decisions, fulfillments, - Vec::<RadrootsActiveOrderCancellationRecord>::new(), + cancellations, Vec::<RadrootsActiveOrderReceiptRecord>::new(), ); Ok(order_accept_inventory_preflight_view_from_projection( @@ -3233,6 +3498,28 @@ fn fetch_listing_accounting_fulfillments( Ok(records) } +fn fetch_listing_accounting_cancellations( + config: &RuntimeConfig, + request: &ResolvedSellerOrderRequest, +) -> Result<Vec<RadrootsActiveOrderCancellationRecord>, RuntimeError> { + let filter = order_listing_cancellation_filter(request.listing_addr.as_str())?; + let receipt = fetch_events_from_relays(&config.relay.urls, filter) + .map_err(|error| RuntimeError::Network(error.to_string()))?; + let mut records = Vec::new(); + for event in receipt.events { + if event_kind_u32(&event) != KIND_TRADE_CANCEL + || !event_matches_tag_value(&event, "a", request.listing_addr.as_str()) + { + continue; + } + if let Ok(OrderStatusRecord::Cancellation(record)) = order_status_record_from_event(&event) + { + records.push(record); + } + } + Ok(records) +} + fn listing_accounting_request_from_event( event: &RadrootsNostrEvent, ) -> Result<ResolvedAccountingRequest, RuntimeError> { @@ -3431,24 +3718,86 @@ fn order_fulfillment_event_parts( .map_err(|error| RuntimeError::Config(format!("encode fulfillment update event: {error}"))) } -fn publish_order_fulfillment( - config: &RuntimeConfig, - args: &OrderFulfillmentArgs, - status: OrderStatusView, - signing: accounts::AccountSigningIdentity, - payload: RadrootsTradeFulfillmentUpdated, -) -> Result<OrderFulfillmentView, RuntimeError> { - let parts = order_fulfillment_event_parts(&status, &payload)?; - let event_kind = parts.kind; - let receipt = publish_parts_with_identity(&signing.identity, &config.relay.urls, parts) - .map_err(|error| RuntimeError::Network(error.to_string()))?; - Ok(published_order_fulfillment_view( - config, - args, - &status, - payload.status, - event_kind, - receipt, +fn order_cancellation_payload_from_status( + args: &OrderCancelArgs, + status: &OrderStatusView, +) -> Result<RadrootsTradeOrderCancelled, RuntimeError> { + Ok(RadrootsTradeOrderCancelled { + order_id: status.order_id.clone(), + listing_addr: status.listing_addr.clone().ok_or_else(|| { + RuntimeError::Config("cancellable order is missing listing_addr".to_owned()) + })?, + buyer_pubkey: status.buyer_pubkey.clone().ok_or_else(|| { + RuntimeError::Config("cancellable order is missing buyer_pubkey".to_owned()) + })?, + seller_pubkey: status.seller_pubkey.clone().ok_or_else(|| { + RuntimeError::Config("cancellable order is missing seller_pubkey".to_owned()) + })?, + reason: args.reason.trim().to_owned(), + }) +} + +fn order_cancellation_event_parts( + status: &OrderStatusView, + payload: &RadrootsTradeOrderCancelled, +) -> Result<WireEventParts, RuntimeError> { + let root_event_id = status.request_event_id.as_deref().ok_or_else(|| { + RuntimeError::Config("cancellable order is missing request_event_id".to_owned()) + })?; + let prev_event_id = order_cancellation_prev_event_id(status).ok_or_else(|| { + RuntimeError::Config("cancellable order is missing previous event id".to_owned()) + })?; + active_trade_order_cancel_event_build(root_event_id, prev_event_id.as_str(), payload) + .map_err(|error| RuntimeError::Config(format!("encode order cancellation event: {error}"))) +} + +fn order_cancellation_dry_run_view( + config: &RuntimeConfig, + args: &OrderCancelArgs, + status: &OrderStatusView, +) -> OrderCancellationView { + let mut view = order_cancellation_base_view(config, args, "dry_run", true); + apply_order_cancellation_status(&mut view, status); + view.reason = + Some("dry run requested; buyer order cancellation publication skipped".to_owned()); + view.actions = vec![format!("radroots order status get {}", status.order_id)]; + view +} + +fn publish_order_fulfillment( + config: &RuntimeConfig, + args: &OrderFulfillmentArgs, + status: OrderStatusView, + signing: accounts::AccountSigningIdentity, + payload: RadrootsTradeFulfillmentUpdated, +) -> Result<OrderFulfillmentView, RuntimeError> { + let parts = order_fulfillment_event_parts(&status, &payload)?; + let event_kind = parts.kind; + let receipt = publish_parts_with_identity(&signing.identity, &config.relay.urls, parts) + .map_err(|error| RuntimeError::Network(error.to_string()))?; + Ok(published_order_fulfillment_view( + config, + args, + &status, + payload.status, + event_kind, + receipt, + )) +} + +fn publish_order_cancellation( + config: &RuntimeConfig, + args: &OrderCancelArgs, + status: OrderStatusView, + signing: accounts::AccountSigningIdentity, + payload: RadrootsTradeOrderCancelled, +) -> Result<OrderCancellationView, RuntimeError> { + let parts = order_cancellation_event_parts(&status, &payload)?; + let event_kind = parts.kind; + let receipt = publish_parts_with_identity(&signing.identity, &config.relay.urls, parts) + .map_err(|error| RuntimeError::Network(error.to_string()))?; + Ok(published_order_cancellation_view( + config, args, &status, event_kind, receipt, )) } @@ -3480,6 +3829,31 @@ fn published_order_fulfillment_view( view } +fn published_order_cancellation_view( + config: &RuntimeConfig, + args: &OrderCancelArgs, + status: &OrderStatusView, + event_kind: u32, + receipt: DirectRelayPublishReceipt, +) -> OrderCancellationView { + let DirectRelayPublishReceipt { + event_id, + created_at: _, + signature: _, + target_relays, + acknowledged_relays, + failed_relays, + } = receipt; + let mut view = order_cancellation_base_view(config, args, "cancelled", false); + apply_order_cancellation_status(&mut view, status); + view.event_id = Some(event_id); + view.event_kind = Some(event_kind); + view.target_relays = target_relays; + view.acknowledged_relays = acknowledged_relays; + view.failed_relays = relay_failures(failed_relays); + view +} + fn order_fulfillment_binding_error_view( config: &RuntimeConfig, args: &OrderFulfillmentArgs, @@ -3500,6 +3874,27 @@ fn order_fulfillment_binding_error_view( view } +fn order_cancellation_binding_error_view( + config: &RuntimeConfig, + args: &OrderCancelArgs, + status: &OrderStatusView, + error: ActorWriteBindingError, +) -> OrderCancellationView { + let (state, reason, actions) = match error { + ActorWriteBindingError::Unconfigured(reason) => ( + "unconfigured".to_owned(), + reason, + vec!["run radroots signer status get".to_owned()], + ), + }; + let mut view = + order_cancellation_base_view(config, args, state.as_str(), config.output.dry_run); + apply_order_cancellation_status(&mut view, status); + view.reason = Some(reason); + view.actions = actions; + view +} + fn seller_order_request_resolution_from_receipt( seller_pubkey: &str, order_id: &str, @@ -3877,12 +4272,23 @@ fn order_listing_fulfillment_filter( .map_err(|error| RuntimeError::Config(format!("build fulfillment filter: {error}"))) } +fn order_listing_cancellation_filter( + listing_addr: &str, +) -> Result<RadrootsNostrFilter, RuntimeError> { + let filter = RadrootsNostrFilter::new() + .kind(radroots_nostr_kind(KIND_TRADE_CANCEL as u16)) + .limit(1_000); + radroots_nostr_filter_tag(filter, "a", vec![listing_addr.to_owned()]) + .map_err(|error| RuntimeError::Config(format!("build cancellation filter: {error}"))) +} + fn order_status_filter(order_id: &str) -> Result<RadrootsNostrFilter, RuntimeError> { let filter = RadrootsNostrFilter::new() .kinds([ radroots_nostr_kind(KIND_TRADE_ORDER_REQUEST as u16), radroots_nostr_kind(KIND_TRADE_ORDER_DECISION as u16), radroots_nostr_kind(KIND_TRADE_FULFILLMENT_UPDATE as u16), + radroots_nostr_kind(KIND_TRADE_CANCEL as u16), ]) .limit(1_000); radroots_nostr_filter_tag(filter, "d", vec![order_id.to_owned()]) @@ -5142,6 +5548,31 @@ fn resolve_local_order_fulfillment_signing_identity( Ok(signing) } +fn resolve_local_order_cancellation_signing_identity( + config: &RuntimeConfig, + buyer_pubkey: &str, +) -> Result<accounts::AccountSigningIdentity, ActorWriteBindingError> { + if !matches!(config.signer.backend, SignerBackend::Local) { + return Err(ActorWriteBindingError::Unconfigured( + "order cancel requires signer mode `local`".to_owned(), + )); + } + let signing = accounts::resolve_local_signing_identity(config) + .map_err(|error| ActorWriteBindingError::Unconfigured(error.to_string()))?; + let selected_pubkey = signing + .account + .record + .public_identity + .public_key_hex + .as_str(); + if !selected_pubkey.eq_ignore_ascii_case(buyer_pubkey) { + return Err(ActorWriteBindingError::Unconfigured(format!( + "selected local account pubkey `{selected_pubkey}` cannot sign order buyer_pubkey `{buyer_pubkey}`" + ))); + } + Ok(signing) +} + fn parse_fulfillment_state(state: &str) -> Result<RadrootsActiveTradeFulfillmentState, String> { match state.trim() { "accepted_not_fulfilled" => Ok(RadrootsActiveTradeFulfillmentState::AcceptedNotFulfilled), @@ -5408,17 +5839,19 @@ mod tests { use std::path::{Path, PathBuf}; use radroots_events::RadrootsNostrEventPtr; - use radroots_events::kinds::{KIND_TRADE_FULFILLMENT_UPDATE, KIND_TRADE_ORDER_DECISION}; + use radroots_events::kinds::{ + KIND_TRADE_CANCEL, KIND_TRADE_FULFILLMENT_UPDATE, KIND_TRADE_ORDER_DECISION, + }; use radroots_events::trade::{ RadrootsActiveTradeFulfillmentState, RadrootsActiveTradeMessageType, RadrootsTradeFulfillmentUpdated, RadrootsTradeInventoryCommitment, - RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderItem, - RadrootsTradeOrderRequested, + RadrootsTradeOrderCancelled, RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent, + RadrootsTradeOrderItem, RadrootsTradeOrderRequested, }; use radroots_events_codec::trade::{ active_trade_event_context_from_tags, active_trade_fulfillment_update_event_build, - active_trade_order_decision_event_build, active_trade_order_decision_from_event, - active_trade_order_request_event_build, + active_trade_order_cancel_event_build, active_trade_order_decision_event_build, + active_trade_order_decision_from_event, active_trade_order_request_event_build, }; use radroots_identity::RadrootsIdentity; use radroots_nostr::prelude::{radroots_event_from_nostr, radroots_nostr_build_event}; @@ -5439,6 +5872,8 @@ mod tests { active_request_record_from_resolved, canonical_order_request_payload_from_loaded, collect_issues, declined_order_decision_payload_from_request, inspect_document, next_order_id, order_accept_inventory_preflight_view_from_projection, + order_cancellation_dry_run_view, order_cancellation_event_parts, + order_cancellation_payload_from_status, order_cancellation_preflight_view_from_status, order_decision_dry_run_view, order_decision_preflight_view_from_status, order_decision_view_from_resolution, order_fulfillment_dry_run_view, order_fulfillment_preflight_view_from_status, order_history_entry_from_event, @@ -5458,7 +5893,7 @@ mod tests { use crate::runtime::direct_relay::DirectRelayFetchReceipt; use crate::runtime::signer::ActorWriteBindingError; use crate::runtime_args::{ - OrderDecisionArg, OrderDecisionArgs, OrderFulfillmentArgs, OrderSubmitArgs, + OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderFulfillmentArgs, OrderSubmitArgs, }; #[test] @@ -6478,6 +6913,400 @@ mod tests { } #[test] + fn order_status_from_receipt_reports_requested_cancellation() { + let fixture = order_status_fixture(); + let cancellation_event = signed_order_cancellation_event( + &fixture.buyer, + &fixture.request_event, + &fixture.request_event, + fixture.order_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + "buyer cancelled", + ); + 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![fixture.request_event.clone(), cancellation_event.clone()], + }; + + let view = order_status_from_receipt(fixture.order_id.as_str(), receipt); + let request_event_id = fixture.request_event.id.to_string(); + let cancellation_event_id = cancellation_event.id.to_string(); + let lifecycle = view.lifecycle.as_ref().expect("lifecycle view"); + let cancellation = lifecycle.cancellation.as_ref().expect("cancellation view"); + + assert_eq!( + u32::from(cancellation_event.kind.as_u16()), + KIND_TRADE_CANCEL + ); + assert_eq!(view.state, "cancelled"); + assert_eq!( + view.last_event_id.as_deref(), + Some(cancellation_event_id.as_str()) + ); + assert_eq!(lifecycle.phase, "cancelled"); + assert_eq!(lifecycle.terminal, true); + assert_eq!( + lifecycle.event_id.as_deref(), + Some(cancellation_event_id.as_str()) + ); + assert_eq!( + lifecycle.root_event_id.as_deref(), + Some(request_event_id.as_str()) + ); + assert_eq!( + lifecycle.prev_event_id.as_deref(), + Some(request_event_id.as_str()) + ); + assert_eq!(lifecycle.settlement_required, true); + assert_eq!( + lifecycle.settlement_reason.as_deref(), + Some("buyer cancelled") + ); + assert_eq!(cancellation.event_id, cancellation_event_id); + assert_eq!( + cancellation.root_event_id.as_deref(), + Some(request_event_id.as_str()) + ); + assert_eq!( + cancellation.prev_event_id.as_deref(), + Some(request_event_id.as_str()) + ); + assert_eq!(cancellation.reason.as_deref(), Some("buyer cancelled")); + assert!(view.reducer_issues.is_empty()); + } + + #[test] + fn order_status_from_receipt_reports_accepted_cancellation() { + let fixture = order_status_fixture(); + let decision_event = signed_order_decision_event( + &fixture.seller, + &fixture.request_event, + fixture.order_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + RadrootsTradeOrderDecision::Accepted { + inventory_commitments: vec![RadrootsTradeInventoryCommitment { + bin_id: "bin-1".to_owned(), + bin_count: 2, + }], + }, + ); + let cancellation_event = signed_order_cancellation_event( + &fixture.buyer, + &fixture.request_event, + &decision_event, + fixture.order_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + "buyer cannot collect", + ); + 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![ + fixture.request_event.clone(), + decision_event.clone(), + cancellation_event.clone(), + ], + }; + + let view = order_status_from_receipt(fixture.order_id.as_str(), receipt); + let decision_event_id = decision_event.id.to_string(); + let cancellation_event_id = cancellation_event.id.to_string(); + let lifecycle = view.lifecycle.as_ref().expect("lifecycle view"); + let inventory = view.inventory.as_ref().expect("inventory view"); + + assert_eq!(view.state, "cancelled"); + assert_eq!( + view.decision_event_id.as_deref(), + Some(decision_event_id.as_str()) + ); + assert_eq!(inventory.state, "released"); + assert_eq!(inventory.commitment_valid, true); + assert_eq!(lifecycle.phase, "cancelled"); + assert_eq!(lifecycle.terminal, true); + assert_eq!( + lifecycle.event_id.as_deref(), + Some(cancellation_event_id.as_str()) + ); + assert_eq!( + lifecycle.prev_event_id.as_deref(), + Some(decision_event_id.as_str()) + ); + assert_eq!(lifecycle.settlement_required, true); + assert_eq!( + lifecycle.settlement_reason.as_deref(), + Some("buyer cannot collect") + ); + assert!(view.reducer_issues.is_empty()); + } + + #[test] + fn order_cancellation_event_parts_chain_from_request_or_decision() { + let fixture = order_status_fixture(); + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let args = cancel_args_for_fixture(&fixture, "buyer cancelled"); + let requested_status = order_status_from_receipt( + fixture.order_id.as_str(), + DirectRelayFetchReceipt { + target_relays: vec!["ws://relay.test".to_owned()], + connected_relays: vec!["ws://relay.test".to_owned()], + failed_relays: Vec::new(), + events: vec![fixture.request_event.clone()], + }, + ); + + assert!( + order_cancellation_preflight_view_from_status( + &config, + &args, + &requested_status, + fixture.buyer_pubkey.as_str() + ) + .is_none() + ); + let requested_payload = order_cancellation_payload_from_status(&args, &requested_status) + .expect("requested cancellation payload"); + let requested_parts = order_cancellation_event_parts(&requested_status, &requested_payload) + .expect("requested cancellation parts"); + let request_event_id = fixture.request_event.id.to_string(); + let requested_context = active_trade_event_context_from_tags( + RadrootsActiveTradeMessageType::TradeOrderCancelled, + &requested_parts.tags, + ) + .expect("requested cancellation context"); + + assert_eq!(requested_parts.kind, KIND_TRADE_CANCEL); + assert_eq!( + requested_context.root_event_id.as_deref(), + Some(request_event_id.as_str()) + ); + assert_eq!( + requested_context.prev_event_id.as_deref(), + Some(request_event_id.as_str()) + ); + + let decision_event = signed_order_decision_event( + &fixture.seller, + &fixture.request_event, + fixture.order_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + RadrootsTradeOrderDecision::Accepted { + inventory_commitments: vec![RadrootsTradeInventoryCommitment { + bin_id: "bin-1".to_owned(), + bin_count: 2, + }], + }, + ); + let accepted_status = order_status_from_receipt( + fixture.order_id.as_str(), + DirectRelayFetchReceipt { + target_relays: vec!["ws://relay.test".to_owned()], + connected_relays: vec!["ws://relay.test".to_owned()], + failed_relays: Vec::new(), + events: vec![fixture.request_event.clone(), decision_event.clone()], + }, + ); + + assert!( + order_cancellation_preflight_view_from_status( + &config, + &args, + &accepted_status, + fixture.buyer_pubkey.as_str() + ) + .is_none() + ); + let accepted_payload = order_cancellation_payload_from_status(&args, &accepted_status) + .expect("accepted cancellation payload"); + let accepted_parts = order_cancellation_event_parts(&accepted_status, &accepted_payload) + .expect("accepted cancellation parts"); + let decision_event_id = decision_event.id.to_string(); + let accepted_context = active_trade_event_context_from_tags( + RadrootsActiveTradeMessageType::TradeOrderCancelled, + &accepted_parts.tags, + ) + .expect("accepted cancellation context"); + + assert_eq!(accepted_parts.kind, KIND_TRADE_CANCEL); + assert_eq!( + accepted_context.root_event_id.as_deref(), + Some(request_event_id.as_str()) + ); + assert_eq!( + accepted_context.prev_event_id.as_deref(), + Some(decision_event_id.as_str()) + ); + } + + #[test] + fn order_cancellation_dry_run_view_preserves_preflight_without_publish_fields() { + let dir = tempdir().expect("tempdir"); + let mut config = sample_config(dir.path()); + config.output.dry_run = true; + config.relay.urls = vec!["ws://relay.test".to_owned()]; + let fixture = order_status_fixture(); + let decision_event = signed_order_decision_event( + &fixture.seller, + &fixture.request_event, + fixture.order_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + RadrootsTradeOrderDecision::Accepted { + inventory_commitments: vec![RadrootsTradeInventoryCommitment { + bin_id: "bin-1".to_owned(), + bin_count: 2, + }], + }, + ); + let status_view = order_status_from_receipt( + fixture.order_id.as_str(), + DirectRelayFetchReceipt { + target_relays: vec!["ws://relay.test".to_owned()], + connected_relays: vec!["ws://relay.test".to_owned()], + failed_relays: Vec::new(), + events: vec![fixture.request_event.clone(), decision_event.clone()], + }, + ); + let args = OrderCancelArgs { + key: fixture.order_id.clone(), + reason: "buyer cancelled".to_owned(), + idempotency_key: Some("idem_cancel".to_owned()), + }; + + let view = order_cancellation_dry_run_view(&config, &args, &status_view); + let request_event_id = fixture.request_event.id.to_string(); + let decision_event_id = decision_event.id.to_string(); + + assert_eq!(view.state, "dry_run"); + assert_eq!(view.dry_run, true); + assert_eq!( + view.root_event_id.as_deref(), + Some(request_event_id.as_str()) + ); + assert_eq!( + view.prev_event_id.as_deref(), + Some(decision_event_id.as_str()) + ); + assert_eq!(view.event_id, None); + assert_eq!(view.event_kind, None); + assert_eq!(view.target_relays, vec!["ws://relay.test"]); + assert_eq!(view.connected_relays, vec!["ws://relay.test"]); + assert_eq!(view.fetched_count, 2); + assert_eq!(view.decoded_count, 2); + assert_eq!(view.idempotency_key.as_deref(), Some("idem_cancel")); + } + + #[test] + fn order_cancellation_preflight_rejects_fulfilled_order() { + let dir = tempdir().expect("tempdir"); + let mut config = sample_config(dir.path()); + config.relay.urls = vec!["ws://relay.test".to_owned()]; + let fixture = order_status_fixture(); + let decision_event = signed_order_decision_event( + &fixture.seller, + &fixture.request_event, + fixture.order_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + RadrootsTradeOrderDecision::Accepted { + inventory_commitments: vec![RadrootsTradeInventoryCommitment { + bin_id: "bin-1".to_owned(), + bin_count: 2, + }], + }, + ); + let fulfillment_event = signed_fulfillment_update_event( + &fixture.seller, + &fixture.request_event, + &decision_event, + fixture.order_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + RadrootsActiveTradeFulfillmentState::ReadyForPickup, + ); + let status_view = order_status_from_receipt( + fixture.order_id.as_str(), + DirectRelayFetchReceipt { + target_relays: vec!["ws://relay.test".to_owned()], + connected_relays: vec!["ws://relay.test".to_owned()], + failed_relays: Vec::new(), + events: vec![ + fixture.request_event.clone(), + decision_event, + fulfillment_event.clone(), + ], + }, + ); + let args = cancel_args_for_fixture(&fixture, "buyer cancelled"); + + let view = order_cancellation_preflight_view_from_status( + &config, + &args, + &status_view, + fixture.buyer_pubkey.as_str(), + ) + .expect("fulfilled cancellation preflight"); + + assert_eq!(view.state, "fulfilled"); + assert_eq!(view.event_id, None); + assert!( + view.reason + .as_deref() + .expect("reason") + .contains("already has seller fulfillment") + ); + } + + #[test] + fn order_cancellation_preflight_rejects_selected_non_buyer_account() { + let dir = tempdir().expect("tempdir"); + let mut config = sample_config(dir.path()); + config.relay.urls = vec!["ws://relay.test".to_owned()]; + let fixture = order_status_fixture(); + let status_view = order_status_from_receipt( + fixture.order_id.as_str(), + DirectRelayFetchReceipt { + target_relays: vec!["ws://relay.test".to_owned()], + connected_relays: vec!["ws://relay.test".to_owned()], + failed_relays: Vec::new(), + events: vec![fixture.request_event.clone()], + }, + ); + let args = cancel_args_for_fixture(&fixture, "buyer cancelled"); + + let view = order_cancellation_preflight_view_from_status( + &config, + &args, + &status_view, + fixture.seller_pubkey.as_str(), + ) + .expect("non buyer cancellation preflight"); + + assert_eq!(view.state, "invalid"); + assert!( + view.reason + .as_deref() + .expect("reason") + .contains("selected account is not buyer") + ); + assert!(view.event_id.is_none()); + } + + #[test] fn order_status_from_receipt_reports_latest_fulfillment_as_last_event() { let fixture = order_status_fixture(); let decision_event = signed_order_decision_event( @@ -7837,6 +8666,14 @@ mod tests { } } + fn cancel_args_for_fixture(fixture: &OrderStatusFixture, reason: &str) -> OrderCancelArgs { + OrderCancelArgs { + key: fixture.order_id.clone(), + reason: reason.to_owned(), + idempotency_key: None, + } + } + fn sample_config(root: &Path) -> RuntimeConfig { let data = root.join("data"); let logs = root.join("logs"); @@ -8020,6 +8857,37 @@ mod tests { .expect("signed fulfillment update") } + fn signed_order_cancellation_event( + buyer: &RadrootsIdentity, + request_event: &radroots_nostr::prelude::RadrootsNostrEvent, + prev_event: &radroots_nostr::prelude::RadrootsNostrEvent, + order_id: &str, + listing_addr: &str, + buyer_pubkey: &str, + seller_pubkey: &str, + reason: &str, + ) -> radroots_nostr::prelude::RadrootsNostrEvent { + let payload = RadrootsTradeOrderCancelled { + order_id: order_id.to_owned(), + listing_addr: listing_addr.to_owned(), + buyer_pubkey: buyer_pubkey.to_owned(), + seller_pubkey: seller_pubkey.to_owned(), + reason: reason.to_owned(), + }; + let request_event_id = request_event.id.to_string(); + let prev_event_id = prev_event.id.to_string(); + let parts = active_trade_order_cancel_event_build( + request_event_id.as_str(), + prev_event_id.as_str(), + &payload, + ) + .expect("order cancellation parts"); + radroots_nostr_build_event(parts.kind, parts.content, parts.tags) + .expect("nostr event builder") + .sign_with_keys(buyer.keys()) + .expect("signed order cancellation") + } + fn signed_malformed_order_request_event( buyer: &RadrootsIdentity, order_id: &str,