cli

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

commit 6b51c5964ee79ab4e016f6194eb7da7814c27765
parent 623d1c0e8d8490daf436edf6d1ec8cd56eae81cb
Author: triesap <tyson@radroots.org>
Date:   Thu, 30 Apr 2026 08:03:15 +0000

order: add revision decisions

Diffstat:
Msrc/domain/runtime.rs | 80++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/main.rs | 6++++++
Msrc/operation_adapter.rs | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/operation_order.rs | 265+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/operation_registry.rs | 38+++++++++++++++++++++++++++++++++++++-
Msrc/runtime/order.rs | 1980++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Msrc/runtime/signer.rs | 41+++++++++++++++++++++++++++++++++++++++--
Msrc/runtime_args.rs | 31+++++++++++++++++++++++++++++++
Msrc/target_cli.rs | 78+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/target_cli.rs | 26++++++++++++++++++++++++++
10 files changed, 2309 insertions(+), 319 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -1547,7 +1547,83 @@ impl OrderRevisionProposalView { pub fn disposition(&self) -> CommandDisposition { match self.state.as_str() { "missing" => CommandDisposition::NotFound, - "invalid" | "requested" | "declined" | "fulfilled" | "terminal" | "forked" => { + "invalid" | "requested" | "order_declined" | "fulfilled" | "terminal" | "forked" => { + CommandDisposition::ValidationFailed + } + "unconfigured" => CommandDisposition::Unconfigured, + "unavailable" => CommandDisposition::ExternalUnavailable, + "error" => CommandDisposition::InternalError, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct OrderRevisionDecisionView { + pub state: String, + pub source: String, + pub order_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub revision_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub decision: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub listing_addr: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub buyer_pubkey: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub seller_pubkey: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub decision_event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub agreement_event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub root_event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub prev_event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_kind: Option<u32>, + #[serde(skip_serializing_if = "Option::is_none")] + pub economics: Option<RadrootsTradeOrderEconomics>, + #[serde(skip_serializing_if = "Option::is_none")] + pub inventory: Option<OrderInventoryView>, + #[serde(default)] + pub dry_run: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub target_relays: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub connected_relays: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub acknowledged_relays: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub failed_relays: Vec<RelayFailureView>, + #[serde(default)] + pub fetched_count: usize, + #[serde(default)] + pub decoded_count: usize, + #[serde(default)] + pub skipped_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub signer_mode: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub issues: Vec<OrderIssueView>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl OrderRevisionDecisionView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "missing" => CommandDisposition::NotFound, + "invalid" | "requested" | "order_declined" | "fulfilled" | "terminal" | "forked" => { CommandDisposition::ValidationFailed } "unconfigured" => CommandDisposition::Unconfigured, @@ -1568,6 +1644,8 @@ pub struct OrderStatusView { #[serde(skip_serializing_if = "Option::is_none")] pub decision_event_id: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] + pub agreement_event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] pub listing_event_id: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub listing_addr: Option<String>, diff --git a/src/main.rs b/src/main.rs @@ -278,6 +278,12 @@ fn execute_request( TargetOperationRequest::OrderRevisionPropose(request) => { execute_with(OrderOperationService::new(config), request) } + TargetOperationRequest::OrderRevisionAccept(request) => { + execute_with(OrderOperationService::new(config), request) + } + TargetOperationRequest::OrderRevisionDecline(request) => { + execute_with(OrderOperationService::new(config), request) + } TargetOperationRequest::OrderFulfillmentUpdate(request) => { execute_with(OrderOperationService::new(config), request) } diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -1230,6 +1230,15 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati insert_string(&mut input, "adjustment_currency", &args.adjustment_currency); insert_string(&mut input, "adjustment_reason", &args.adjustment_reason); } + OrderRevisionCommand::Accept(args) => { + insert_string(&mut input, "order_id", &args.order_id); + insert_string(&mut input, "revision_id", &args.revision_id); + } + OrderRevisionCommand::Decline(args) => { + insert_string(&mut input, "order_id", &args.order_id); + insert_string(&mut input, "revision_id", &args.revision_id); + insert_string(&mut input, "reason", &args.reason); + } }, OrderCommand::Fulfillment(fulfillment) => match &fulfillment.command { OrderFulfillmentCommand::Update(args) => { @@ -1358,6 +1367,8 @@ target_operation_contracts! { OrderDecline => (OrderDeclineRequest, OrderDeclineResult, "order.decline"), OrderCancel => (OrderCancelRequest, OrderCancelResult, "order.cancel"), OrderRevisionPropose => (OrderRevisionProposeRequest, OrderRevisionProposeResult, "order.revision.propose"), + OrderRevisionAccept => (OrderRevisionAcceptRequest, OrderRevisionAcceptResult, "order.revision.accept"), + OrderRevisionDecline => (OrderRevisionDeclineRequest, OrderRevisionDeclineResult, "order.revision.decline"), OrderFulfillmentUpdate => (OrderFulfillmentUpdateRequest, OrderFulfillmentUpdateResult, "order.fulfillment.update"), OrderReceiptRecord => (OrderReceiptRecordRequest, OrderReceiptRecordResult, "order.receipt.record"), OrderStatusGet => (OrderStatusGetRequest, OrderStatusGetResult, "order.status.get"), @@ -1596,6 +1607,78 @@ mod tests { Some("weather delay") ); + let revision_accept = TargetCliArgs::try_parse_from([ + "radroots", + "order", + "revision", + "accept", + "ord_test", + "--revision-id", + "rev_test", + ]) + .expect("target args parse"); + let request = + TargetOperationRequest::from_target_args(&revision_accept).expect("operation request"); + let TargetOperationRequest::OrderRevisionAccept(request) = request else { + panic!("expected order revision accept request") + }; + assert_eq!(request.operation_id(), "order.revision.accept"); + assert_eq!( + request + .payload + .input + .get("order_id") + .and_then(Value::as_str), + Some("ord_test") + ); + assert_eq!( + request + .payload + .input + .get("revision_id") + .and_then(Value::as_str), + Some("rev_test") + ); + + let revision_decline = TargetCliArgs::try_parse_from([ + "radroots", + "order", + "revision", + "decline", + "ord_test", + "--revision-id", + "rev_test", + "--reason", + "keep original order", + ]) + .expect("target args parse"); + let request = + TargetOperationRequest::from_target_args(&revision_decline).expect("operation request"); + let TargetOperationRequest::OrderRevisionDecline(request) = request else { + panic!("expected order revision decline request") + }; + assert_eq!(request.operation_id(), "order.revision.decline"); + assert_eq!( + request + .payload + .input + .get("order_id") + .and_then(Value::as_str), + Some("ord_test") + ); + assert_eq!( + request + .payload + .input + .get("revision_id") + .and_then(Value::as_str), + Some("rev_test") + ); + assert_eq!( + request.payload.input.get("reason").and_then(Value::as_str), + Some("keep original order") + ); + let cancel = TargetCliArgs::try_parse_from([ "radroots", "order", diff --git a/src/operation_order.rs b/src/operation_order.rs @@ -3,7 +3,8 @@ use serde_json::{Value, json}; use crate::domain::runtime::{ CommandDisposition, OrderCancellationView, OrderDecisionView, OrderFulfillmentView, - OrderReceiptView, OrderRevisionProposalView, OrderStatusView, OrderSubmitView, + OrderReceiptView, OrderRevisionDecisionView, OrderRevisionProposalView, OrderStatusView, + OrderSubmitView, }; use crate::operation_adapter::{ OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload, @@ -12,14 +13,16 @@ use crate::operation_adapter::{ OrderEventListRequest, OrderEventListResult, OrderEventWatchRequest, OrderEventWatchResult, OrderFulfillmentUpdateRequest, OrderFulfillmentUpdateResult, OrderGetRequest, OrderGetResult, OrderListRequest, OrderListResult, OrderReceiptRecordRequest, OrderReceiptRecordResult, - OrderRevisionProposeRequest, OrderRevisionProposeResult, OrderStatusGetRequest, - OrderStatusGetResult, OrderSubmitRequest, OrderSubmitResult, + OrderRevisionAcceptRequest, OrderRevisionAcceptResult, OrderRevisionDeclineRequest, + OrderRevisionDeclineResult, OrderRevisionProposeRequest, OrderRevisionProposeResult, + OrderStatusGetRequest, OrderStatusGetResult, OrderSubmitRequest, OrderSubmitResult, }; use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; use crate::runtime_args::{ OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderFulfillmentArgs, OrderReceiptArgs, - OrderRevisionProposeArgs, OrderStatusArgs, OrderSubmitArgs, OrderWatchArgs, RecordLookupArgs, + OrderRevisionDecisionArg, OrderRevisionDecisionArgs, OrderRevisionProposeArgs, OrderStatusArgs, + OrderSubmitArgs, OrderWatchArgs, RecordLookupArgs, }; pub struct OrderOperationService<'a> { @@ -260,6 +263,85 @@ impl OperationService<OrderRevisionProposeRequest> for OrderOperationService<'_> } } +impl OperationService<OrderRevisionAcceptRequest> for OrderOperationService<'_> { + type Result = OrderRevisionAcceptResult; + + fn execute( + &self, + request: OperationRequest<OrderRevisionAcceptRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let args = OrderRevisionDecisionArgs { + key: required_order_key(&request)?, + revision_id: required_string_input(&request, "revision_id")?, + decision: OrderRevisionDecisionArg::Accept, + reason: None, + idempotency_key: request + .context + .idempotency_key + .clone() + .or_else(|| string_input(&request, "idempotency_key")), + }; + if request.context.requires_approval_token() { + return Err(OperationAdapterError::approval_required( + request.operation_id(), + )); + } + + let mut config = self.config.clone(); + if request.context.dry_run { + config.output.dry_run = true; + } + let view = crate::runtime::order::revision_decide(&config, &args).map_err(|error| { + OperationAdapterError::runtime_failure(request.operation_id(), error) + })?; + revision_decision_result::<OrderRevisionAcceptResult>(request.operation_id(), &view) + } +} + +impl OperationService<OrderRevisionDeclineRequest> for OrderOperationService<'_> { + type Result = OrderRevisionDeclineResult; + + fn execute( + &self, + request: OperationRequest<OrderRevisionDeclineRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + 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(), + ) + })?; + let args = OrderRevisionDecisionArgs { + key: required_order_key(&request)?, + revision_id: required_string_input(&request, "revision_id")?, + decision: OrderRevisionDecisionArg::Decline, + reason: Some(reason), + idempotency_key: request + .context + .idempotency_key + .clone() + .or_else(|| string_input(&request, "idempotency_key")), + }; + if request.context.requires_approval_token() { + return Err(OperationAdapterError::approval_required( + request.operation_id(), + )); + } + + let mut config = self.config.clone(); + if request.context.dry_run { + config.output.dry_run = true; + } + let view = crate::runtime::order::revision_decide(&config, &args).map_err(|error| { + OperationAdapterError::runtime_failure(request.operation_id(), error) + })?; + revision_decision_result::<OrderRevisionDeclineResult>(request.operation_id(), &view) + } +} + impl OperationService<OrderFulfillmentUpdateRequest> for OrderOperationService<'_> { type Result = OrderFulfillmentUpdateResult; @@ -754,6 +836,102 @@ fn order_revision_proposal_error_detail(view: &OrderRevisionProposalView) -> Val }) } +fn revision_decision_result<R>( + operation_id: &str, + view: &OrderRevisionDecisionView, +) -> Result<OperationResult<R>, OperationAdapterError> +where + R: OperationResultData, +{ + match view.disposition() { + CommandDisposition::Success => serialized_target_result::<R, _>(view), + CommandDisposition::ValidationFailed => { + let message = view.reason.clone().unwrap_or_else(|| { + format!( + "order revision {} failed validation with state `{}`", + view.decision.as_deref().unwrap_or("decision"), + view.state + ) + }); + Err(OperationAdapterError::validation_failed_with_detail( + operation_id, + message, + order_revision_decision_error_detail(view), + )) + } + disposition => { + let message = view.reason.clone().unwrap_or_else(|| { + format!( + "order revision {} finished with state `{}`", + view.decision.as_deref().unwrap_or("decision"), + view.state + ) + }); + if disposition == CommandDisposition::ExternalUnavailable { + let detail = order_revision_decision_error_detail(view); + if !view.failed_relays.is_empty() && view.connected_relays.is_empty() { + Err(OperationAdapterError::network_unavailable_with_detail( + operation_id, + message, + detail, + )) + } else { + Err(OperationAdapterError::operation_unavailable_with_detail( + operation_id, + message, + detail, + )) + } + } else if disposition == CommandDisposition::Unconfigured { + Err(OperationAdapterError::operation_unavailable_with_detail( + operation_id, + message, + order_revision_decision_error_detail(view), + )) + } else { + Err(OperationAdapterError::from_command_disposition( + operation_id, + disposition, + message, + )) + } + } + } +} + +fn order_revision_decision_error_detail(view: &OrderRevisionDecisionView) -> Value { + json!({ + "state": &view.state, + "order_id": &view.order_id, + "revision_id": &view.revision_id, + "decision": &view.decision, + "listing_addr": &view.listing_addr, + "request_event_id": &view.request_event_id, + "decision_event_id": &view.decision_event_id, + "agreement_event_id": &view.agreement_event_id, + "root_event_id": &view.root_event_id, + "prev_event_id": &view.prev_event_id, + "event_id": &view.event_id, + "event_kind": view.event_kind, + "economics": &view.economics, + "inventory": &view.inventory, + "buyer_pubkey": &view.buyer_pubkey, + "seller_pubkey": &view.seller_pubkey, + "dry_run": view.dry_run, + "target_relays": &view.target_relays, + "connected_relays": &view.connected_relays, + "acknowledged_relays": &view.acknowledged_relays, + "failed_relays": &view.failed_relays, + "fetched_count": view.fetched_count, + "decoded_count": view.decoded_count, + "skipped_count": view.skipped_count, + "idempotency_key": &view.idempotency_key, + "signer_mode": &view.signer_mode, + "issues": &view.issues, + "actions": &view.actions, + }) +} + fn receipt_result<R>( operation_id: &str, view: &OrderReceiptView, @@ -981,6 +1159,24 @@ where }) } +fn required_string_input<P>( + request: &OperationRequest<P>, + key: &str, +) -> Result<String, OperationAdapterError> +where + P: OperationRequestPayload + OperationRequestData, +{ + string_input(request, key) + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + invalid_input( + request.operation_id(), + format!("missing required `{key}` input"), + ) + }) +} + fn string_input<P>(request: &OperationRequest<P>, key: &str) -> Option<String> where P: OperationRequestPayload + OperationRequestData, @@ -1057,8 +1253,8 @@ mod tests { OperationAdapter, OperationContext, OperationData, OperationRequest, OrderAcceptRequest, OrderAcceptResult, OrderCancelRequest, OrderDeclineRequest, OrderDeclineResult, OrderEventListRequest, OrderEventWatchRequest, OrderGetRequest, OrderListRequest, - OrderReceiptRecordRequest, OrderRevisionProposeRequest, OrderStatusGetRequest, - OrderSubmitRequest, + OrderReceiptRecordRequest, OrderRevisionAcceptRequest, OrderRevisionDeclineRequest, + OrderRevisionProposeRequest, OrderStatusGetRequest, OrderSubmitRequest, }; use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, @@ -1307,6 +1503,63 @@ mod tests { } #[test] + fn order_revision_accept_requires_approval_token() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(OrderOperationService::new(&config)); + let revision = OperationRequest::new( + OperationContext::default(), + OrderRevisionAcceptRequest::from_data(data(&[ + ("order_id", "ord_pending"), + ("revision_id", "rev_pending"), + ])), + ) + .expect("order revision accept request"); + let error = service.execute(revision).expect_err("approval required"); + + assert_eq!(error.to_output_error().code, "approval_required"); + } + + #[test] + fn order_revision_decline_requires_reason_before_approval() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(OrderOperationService::new(&config)); + let revision = OperationRequest::new( + OperationContext::default(), + OrderRevisionDeclineRequest::from_data(data(&[ + ("order_id", "ord_pending"), + ("revision_id", "rev_pending"), + ])), + ) + .expect("order revision decline request"); + let error = service.execute(revision).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_revision_decline_requires_approval_token() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(OrderOperationService::new(&config)); + let revision = OperationRequest::new( + OperationContext::default(), + OrderRevisionDeclineRequest::from_data(data(&[ + ("order_id", "ord_pending"), + ("revision_id", "rev_pending"), + ("reason", "keep original order"), + ])), + ) + .expect("order revision decline request"); + let error = service.execute(revision).expect_err("approval required"); + + assert_eq!(error.to_output_error().code, "approval_required"); + } + + #[test] fn order_receipt_record_requires_outcome_before_approval() { let dir = tempdir().expect("tempdir"); let config = sample_config(dir.path()); diff --git a/src/operation_registry.rs b/src/operation_registry.rs @@ -932,6 +932,36 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ true ), operation!( + "order.revision.accept", + "radroots order revision accept", + "order", + "order_revision_accept", + "OrderRevisionAcceptRequest", + "OrderRevisionAcceptResult", + "Accept a seller-authored order revision.", + Buyer, + true, + Required, + High, + false, + true + ), + operation!( + "order.revision.decline", + "radroots order revision decline", + "order", + "order_revision_decline", + "OrderRevisionDeclineRequest", + "OrderRevisionDeclineResult", + "Decline a seller-authored order revision.", + Buyer, + true, + Required, + High, + false, + true + ), + operation!( "order.fulfillment.update", "radroots order fulfillment update", "order", @@ -1087,6 +1117,8 @@ mod tests { "order.decline", "order.cancel", "order.revision.propose", + "order.revision.accept", + "order.revision.decline", "order.fulfillment.update", "order.receipt.record", "order.status.get", @@ -1127,6 +1159,8 @@ mod tests { "order.decline", "order.cancel", "order.revision.propose", + "order.revision.accept", + "order.revision.decline", "order.fulfillment.update", "order.receipt.record", ]; @@ -1141,7 +1175,7 @@ mod tests { .copied() .collect::<BTreeSet<_>>(); assert_eq!(actual, expected); - assert_eq!(OPERATION_REGISTRY.len(), 62); + assert_eq!(OPERATION_REGISTRY.len(), 64); } #[test] @@ -1192,6 +1226,8 @@ mod tests { "order.decline", "order.cancel", "order.revision.propose", + "order.revision.accept", + "order.revision.decline", "order.fulfillment.update", "order.receipt.record", ] diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -11,7 +11,8 @@ use radroots_core::{ use radroots_events::RadrootsNostrEventPtr; use radroots_events::kinds::{ KIND_LISTING, KIND_TRADE_CANCEL, KIND_TRADE_FULFILLMENT_UPDATE, KIND_TRADE_ORDER_DECISION, - KIND_TRADE_ORDER_REQUEST, KIND_TRADE_ORDER_REVISION, KIND_TRADE_RECEIPT, + KIND_TRADE_ORDER_REQUEST, KIND_TRADE_ORDER_REVISION, KIND_TRADE_ORDER_REVISION_RESPONSE, + KIND_TRADE_RECEIPT, }; use radroots_events::listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingStatus, @@ -22,7 +23,9 @@ use radroots_events::trade::{ RadrootsTradeFulfillmentUpdated, RadrootsTradeInventoryCommitment, RadrootsTradeOrderCancelled, RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderEconomicItem, RadrootsTradeOrderEconomicLine, RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, - RadrootsTradeOrderRequested, RadrootsTradeOrderRevisionProposed, RadrootsTradePricingBasis, + RadrootsTradeOrderRequested, RadrootsTradeOrderRevisionDecision, + RadrootsTradeOrderRevisionDecisionEvent, RadrootsTradeOrderRevisionProposed, + RadrootsTradePricingBasis, }; use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_events_codec::listing::decode::listing_from_event; @@ -33,6 +36,8 @@ use radroots_events_codec::trade::{ 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, + active_trade_order_revision_decision_event_build, + active_trade_order_revision_decision_from_event, active_trade_order_revision_proposal_event_build, active_trade_order_revision_proposal_from_event, }; @@ -54,11 +59,12 @@ use radroots_sql_core::SqliteExecutor; use radroots_trade::order::{ RadrootsActiveOrderCancellationRecord, RadrootsActiveOrderDecisionRecord, RadrootsActiveOrderFulfillmentRecord, RadrootsActiveOrderReceiptRecord, - RadrootsActiveOrderReducerIssue, RadrootsActiveOrderRequestRecord, RadrootsActiveOrderStatus, - RadrootsListingInventoryAccountingIssue, RadrootsListingInventoryAccountingProjection, - RadrootsListingInventoryBinAvailability, canonicalize_active_order_decision_for_signer, - canonicalize_active_order_request_for_signer, reduce_active_order_events, - reduce_listing_inventory_accounting, + RadrootsActiveOrderReducerIssue, RadrootsActiveOrderRequestRecord, + RadrootsActiveOrderRevisionDecisionRecord, RadrootsActiveOrderRevisionProposalRecord, + RadrootsActiveOrderStatus, RadrootsListingInventoryAccountingIssue, + RadrootsListingInventoryAccountingProjection, RadrootsListingInventoryBinAvailability, + canonicalize_active_order_decision_for_signer, canonicalize_active_order_request_for_signer, + reduce_active_order_events, reduce_listing_inventory_accounting, }; use serde::{Deserialize, Serialize}; @@ -66,9 +72,10 @@ use crate::domain::runtime::{ OrderCancellationView, OrderDecisionView, OrderDraftItemView, OrderFulfillmentView, OrderGetView, OrderHistoryEntryView, OrderHistoryView, OrderInventoryBinView, OrderInventoryView, OrderIssueView, OrderListView, OrderNewView, OrderReceiptView, - OrderRevisionProposalView, OrderStatusFulfillmentView, OrderStatusLifecycleCancellationView, - OrderStatusLifecycleReceiptView, OrderStatusLifecycleView, OrderStatusView, OrderSubmitView, - OrderSummaryView, OrderWatchView, RelayFailureView, + OrderRevisionDecisionView, OrderRevisionProposalView, OrderStatusFulfillmentView, + OrderStatusLifecycleCancellationView, OrderStatusLifecycleReceiptView, + OrderStatusLifecycleView, OrderStatusView, OrderSubmitView, OrderSummaryView, OrderWatchView, + RelayFailureView, }; use crate::runtime::RuntimeError; use crate::runtime::accounts; @@ -80,8 +87,8 @@ use crate::runtime::direct_relay::{ use crate::runtime::signer::ActorWriteBindingError; use crate::runtime_args::{ OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderDraftCreateArgs, - OrderFulfillmentArgs, OrderReceiptArgs, OrderRevisionProposeArgs, OrderStatusArgs, - OrderSubmitArgs, OrderWatchArgs, RecordLookupArgs, + OrderFulfillmentArgs, OrderReceiptArgs, OrderRevisionDecisionArg, OrderRevisionDecisionArgs, + OrderRevisionProposeArgs, OrderStatusArgs, OrderSubmitArgs, OrderWatchArgs, RecordLookupArgs, }; const ORDER_DRAFT_KIND: &str = "order_draft_v1"; @@ -90,6 +97,8 @@ const ORDER_SUBMIT_SOURCE: &str = "direct Nostr relay publish · local key"; const ORDER_DECISION_SOURCE: &str = "direct Nostr relay decision publish · local key"; const ORDER_REVISION_PROPOSAL_SOURCE: &str = "direct Nostr relay revision proposal publish · local key"; +const ORDER_REVISION_DECISION_SOURCE: &str = + "direct Nostr relay revision decision publish · local key"; const ORDER_FULFILLMENT_SOURCE: &str = "direct Nostr relay fulfillment publish · local key"; const ORDER_CANCELLATION_SOURCE: &str = "direct Nostr relay cancellation publish · local key"; const ORDER_RECEIPT_SOURCE: &str = "direct Nostr relay receipt publish · local key"; @@ -1034,6 +1043,153 @@ pub fn revision_propose( publish_order_revision(config, args, status_view, signing, payload) } +pub fn revision_decide( + config: &RuntimeConfig, + args: &OrderRevisionDecisionArgs, +) -> Result<OrderRevisionDecisionView, RuntimeError> { + if let Some(view) = order_revision_decision_args_preflight_view(config, args) { + return Ok(view); + } + if config.relay.urls.is_empty() { + let mut view = + order_revision_decision_base_view(config, args, "unconfigured", config.output.dry_run); + view.reason = + Some("order revision decision requires at least one configured relay".to_owned()); + return Ok(view); + } + + let buyer = match accounts::resolve_account(config)? { + Some(account) => account, + None => { + let mut view = order_revision_decision_base_view( + config, + args, + "unconfigured", + config.output.dry_run, + ); + view.reason = + Some("order revision decision requires a selected buyer account".to_owned()); + view.actions = vec!["radroots account create".to_owned()]; + return Ok(view); + } + }; + let selected_pubkey = buyer.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_revision_decision_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 revision_candidates = + order_revision_proposals_from_events(args.key.as_str(), receipt.events.as_slice()); + 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 mut status_view = reduction.view; + enrich_order_status_inventory(config, &mut status_view)?; + if let Some(view) = order_revision_decision_preflight_view_from_status( + config, + args, + &status_view, + selected_pubkey.as_str(), + &revision_candidates, + ) { + return Ok(view); + } + + let proposal = pending_revision_proposal_candidate(&status_view, &revision_candidates) + .ok_or_else(|| { + RuntimeError::Config("accepted order is missing pending revision proposal".to_owned()) + })?; + if proposal.payload.revision_id != args.revision_id.trim() { + let mut view = order_revision_decision_invalid_view( + config, + args, + &status_view, + format!( + "order revision {} refused because revision `{}` is not the latest pending proposal", + args.decision.command(), + args.revision_id.trim() + ), + vec![issue_with_events( + "revision_id_not_pending", + "revision_id", + format!( + "latest pending revision is `{}`", + proposal.payload.revision_id + ), + vec![proposal.event_id.clone()], + )], + ); + apply_order_revision_decision_proposal(&mut view, proposal); + return Ok(view); + } + + let buyer_pubkey = status_view + .buyer_pubkey + .as_deref() + .ok_or_else(|| RuntimeError::Config("accepted order is missing buyer_pubkey".to_owned()))?; + let signing = + match resolve_local_order_revision_decision_signing_identity(config, buyer_pubkey, args) { + Ok(signing) => signing, + Err(error) => { + return Ok(order_revision_decision_binding_error_view( + config, + args, + &status_view, + error, + )); + } + }; + if args.decision == OrderRevisionDecisionArg::Accept { + let issues = order_revision_inventory_issues(&status_view, &proposal.payload); + if !issues.is_empty() { + let mut view = order_revision_decision_invalid_view( + config, + args, + &status_view, + "order revision accept refused because visible inventory is unavailable for the revised items", + issues, + ); + apply_order_revision_decision_proposal(&mut view, proposal); + return Ok(view); + } + } + let payload = order_revision_decision_payload_from_proposal(args, proposal)?; + let _ = order_revision_decision_event_parts(&payload)?; + if config.output.dry_run { + return Ok(order_revision_decision_dry_run_view( + config, + args, + &status_view, + proposal, + &payload, + )); + } + publish_order_revision_decision(config, args, status_view, proposal, signing, payload) +} + pub fn fulfillment_update( config: &RuntimeConfig, args: &OrderFulfillmentArgs, @@ -1334,6 +1490,7 @@ pub fn status( order_id: args.key.clone(), request_event_id: None, decision_event_id: None, + agreement_event_id: None, listing_event_id: None, listing_addr: None, buyer_pubkey: None, @@ -1369,6 +1526,7 @@ pub fn status( order_id: args.key.clone(), request_event_id: None, decision_event_id: None, + agreement_event_id: None, listing_event_id: None, listing_addr: None, buyer_pubkey: None, @@ -1414,16 +1572,14 @@ enum OrderStatusRecord { }, Decision(RadrootsActiveOrderDecisionRecord), RevisionProposal(OrderRevisionProposalRecord), + RevisionDecision(OrderRevisionDecisionRecord), Fulfillment(RadrootsActiveOrderFulfillmentRecord), Cancellation(RadrootsActiveOrderCancellationRecord), Receipt(RadrootsActiveOrderReceiptRecord), } -#[derive(Debug, Clone)] -struct OrderRevisionProposalRecord { - event_id: String, - payload: RadrootsTradeOrderRevisionProposed, -} +type OrderRevisionProposalRecord = RadrootsActiveOrderRevisionProposalRecord; +type OrderRevisionDecisionRecord = RadrootsActiveOrderRevisionDecisionRecord; #[derive(Debug, Clone)] struct OrderRevisionProposalCandidates { @@ -1482,6 +1638,8 @@ fn order_status_reduction_from_receipt_with_context( let mut skipped_count = 0usize; let mut requests = Vec::new(); let mut decisions = Vec::new(); + let mut revision_proposals = Vec::new(); + let mut revision_decisions = Vec::new(); let mut fulfillments = Vec::new(); let mut cancellations = Vec::new(); let mut receipts = Vec::new(); @@ -1506,8 +1664,13 @@ fn order_status_reduction_from_receipt_with_context( decoded_count += 1; decisions.push(record); } - Ok(OrderStatusRecord::RevisionProposal(_record)) => { + Ok(OrderStatusRecord::RevisionProposal(record)) => { + decoded_count += 1; + revision_proposals.push(record); + } + Ok(OrderStatusRecord::RevisionDecision(record)) => { decoded_count += 1; + revision_decisions.push(record); } Ok(OrderStatusRecord::Fulfillment(record)) => { decoded_count += 1; @@ -1551,6 +1714,8 @@ fn order_status_reduction_from_receipt_with_context( order_id, requests, decisions.clone(), + revision_proposals, + revision_decisions, fulfillments, cancellations, receipts, @@ -1670,6 +1835,7 @@ fn order_status_reduction_from_receipt_with_context( order_id: projection.order_id, request_event_id: projection.request_event_id, decision_event_id: projection.decision_event_id, + agreement_event_id: projection.agreement_event_id, listing_event_id, listing_addr: projection.listing_addr, buyer_pubkey: projection.buyer_pubkey, @@ -1751,6 +1917,16 @@ fn enrich_order_status_inventory( .into_iter() .filter(|record| request_order_ids.contains(&record.payload.order_id)) .collect::<Vec<_>>(); + let revision_proposals = + fetch_listing_accounting_revision_proposals_for_status(config, listing_addr.as_str())? + .into_iter() + .filter(|record| request_order_ids.contains(&record.payload.order_id)) + .collect::<Vec<_>>(); + let revision_decisions = + fetch_listing_accounting_revision_decisions_for_status(config, listing_addr.as_str())? + .into_iter() + .filter(|record| request_order_ids.contains(&record.payload.order_id)) + .collect::<Vec<_>>(); let fulfillments = fetch_listing_accounting_fulfillments_for_status(config, listing_addr.as_str())? .into_iter() @@ -1767,10 +1943,18 @@ fn enrich_order_status_inventory( listing.bins, requests, decisions, + revision_proposals, + revision_decisions, fulfillments, cancellations, Vec::<RadrootsActiveOrderReceiptRecord>::new(), ); + let mut relevant_event_ids = Vec::new(); + relevant_event_ids.push(decision_event_id); + relevant_event_ids.extend(view.agreement_event_id.clone()); + relevant_event_ids.extend(view.last_event_id.clone()); + relevant_event_ids.sort(); + relevant_event_ids.dedup(); let relevant_issues = projection .issues .iter() @@ -1778,7 +1962,7 @@ fn enrich_order_status_inventory( listing_inventory_issue_involves_order( issue, view.order_id.as_str(), - decision_event_id.as_str(), + relevant_event_ids.as_slice(), ) }) .cloned() @@ -1882,6 +2066,52 @@ fn fetch_listing_accounting_decisions_for_status( Ok(records) } +fn fetch_listing_accounting_revision_proposals_for_status( + config: &RuntimeConfig, + listing_addr: &str, +) -> Result<Vec<RadrootsActiveOrderRevisionProposalRecord>, RuntimeError> { + let filter = order_listing_revision_proposal_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_ORDER_REVISION + || !event_matches_tag_value(&event, "a", listing_addr) + { + continue; + } + if let Ok(OrderStatusRecord::RevisionProposal(record)) = + order_status_record_from_event(&event) + { + records.push(record); + } + } + Ok(records) +} + +fn fetch_listing_accounting_revision_decisions_for_status( + config: &RuntimeConfig, + listing_addr: &str, +) -> Result<Vec<RadrootsActiveOrderRevisionDecisionRecord>, RuntimeError> { + let filter = order_listing_revision_decision_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_ORDER_REVISION_RESPONSE + || !event_matches_tag_value(&event, "a", listing_addr) + { + continue; + } + if let Ok(OrderStatusRecord::RevisionDecision(record)) = + order_status_record_from_event(&event) + { + records.push(record); + } + } + Ok(records) +} + fn fetch_listing_accounting_fulfillments_for_status( config: &RuntimeConfig, listing_addr: &str, @@ -1928,18 +2158,25 @@ fn fetch_listing_accounting_cancellations_for_status( fn listing_inventory_issue_involves_order( issue: &RadrootsListingInventoryAccountingIssue, order_id: &str, - decision_event_id: &str, + event_ids: &[String], ) -> bool { match issue { RadrootsListingInventoryAccountingIssue::InvalidActiveOrder { order_id: issue_order_id, - event_ids, - } => issue_order_id == order_id || event_ids.iter().any(|id| id == decision_event_id), - RadrootsListingInventoryAccountingIssue::ArithmeticOverflow { event_ids, .. } - | RadrootsListingInventoryAccountingIssue::UnknownInventoryBin { event_ids, .. } - | RadrootsListingInventoryAccountingIssue::OverReserved { event_ids, .. } => { - event_ids.iter().any(|id| id == decision_event_id) + event_ids: issue_event_ids, + } => issue_order_id == order_id || issue_event_ids.iter().any(|id| event_ids.contains(id)), + RadrootsListingInventoryAccountingIssue::ArithmeticOverflow { + event_ids: issue_event_ids, + .. } + | RadrootsListingInventoryAccountingIssue::UnknownInventoryBin { + event_ids: issue_event_ids, + .. + } + | RadrootsListingInventoryAccountingIssue::OverReserved { + event_ids: issue_event_ids, + .. + } => issue_event_ids.iter().any(|id| event_ids.contains(id)), } } @@ -2056,9 +2293,50 @@ fn order_status_record_from_event( "decode active order revision proposal event: {error}" )) })?; + let context = active_trade_event_context_from_tags( + RadrootsActiveTradeMessageType::TradeOrderRevisionProposed, + &event.tags, + ) + .map_err(|error| { + RuntimeError::Config(format!( + "decode active order revision proposal tags: {error}" + )) + })?; Ok(OrderStatusRecord::RevisionProposal( - OrderRevisionProposalRecord { + RadrootsActiveOrderRevisionProposalRecord { + 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, + }, + )) + } + KIND_TRADE_ORDER_REVISION_RESPONSE => { + let event = radroots_event_from_nostr(event); + let envelope = + active_trade_order_revision_decision_from_event(&event).map_err(|error| { + RuntimeError::Config(format!( + "decode active order revision decision event: {error}" + )) + })?; + let context = active_trade_event_context_from_tags( + RadrootsActiveTradeMessageType::TradeOrderRevisionDecision, + &event.tags, + ) + .map_err(|error| { + RuntimeError::Config(format!( + "decode active order revision decision tags: {error}" + )) + })?; + Ok(OrderStatusRecord::RevisionDecision( + RadrootsActiveOrderRevisionDecisionRecord { 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, }, )) @@ -2617,151 +2895,335 @@ fn active_order_reducer_issue_view(issue_value: RadrootsActiveOrderReducerIssue) "active order reducer reported conflicting decisions", event_ids, ), - RadrootsActiveOrderReducerIssue::FulfillmentWithoutAcceptedDecision { event_id } => { + RadrootsActiveOrderReducerIssue::RevisionProposalWithoutAcceptedDecision { event_id } => { issue_with_events( - "fulfillment_without_accepted_decision", - "fulfillment_event_id", - "active order reducer reported fulfillment without accepted decision", + "revision_proposal_without_accepted_decision", + "revision_event_id", + "active order reducer reported revision proposal without accepted decision", vec![event_id], ) } - RadrootsActiveOrderReducerIssue::FulfillmentPayloadInvalid { event_id } => { + RadrootsActiveOrderReducerIssue::RevisionProposalPayloadInvalid { event_id } => { issue_with_events( - "invalid_fulfillment_payload", - "fulfillment_payload", - "active order reducer reported invalid fulfillment payload", + "invalid_revision_proposal_payload", + "revision_payload", + "active order reducer reported invalid revision proposal payload", vec![event_id], ) } - RadrootsActiveOrderReducerIssue::FulfillmentOrderIdMismatch { event_id } => { + RadrootsActiveOrderReducerIssue::RevisionProposalOrderIdMismatch { event_id } => { issue_with_events( - "fulfillment_order_id_mismatch", + "revision_proposal_order_id_mismatch", "order_id", - "active order reducer reported fulfillment order id mismatch", + "active order reducer reported revision proposal order id mismatch", vec![event_id], ) } - RadrootsActiveOrderReducerIssue::FulfillmentAuthorMismatch { event_id } => { + RadrootsActiveOrderReducerIssue::RevisionProposalAuthorMismatch { event_id } => { issue_with_events( - "fulfillment_author_mismatch", + "revision_proposal_author_mismatch", "seller_pubkey", - "active order reducer reported fulfillment author mismatch", + "active order reducer reported revision proposal author mismatch", vec![event_id], ) } - RadrootsActiveOrderReducerIssue::FulfillmentCounterpartyMismatch { event_id } => { + RadrootsActiveOrderReducerIssue::RevisionProposalCounterpartyMismatch { event_id } => { issue_with_events( - "fulfillment_counterparty_mismatch", + "revision_proposal_counterparty_mismatch", "buyer_pubkey", - "active order reducer reported fulfillment counterparty mismatch", + "active order reducer reported revision proposal counterparty mismatch", vec![event_id], ) } - RadrootsActiveOrderReducerIssue::FulfillmentBuyerMismatch { event_id } => { + RadrootsActiveOrderReducerIssue::RevisionProposalBuyerMismatch { event_id } => { issue_with_events( - "fulfillment_buyer_mismatch", + "revision_proposal_buyer_mismatch", "buyer_pubkey", - "active order reducer reported fulfillment buyer mismatch", + "active order reducer reported revision proposal buyer mismatch", vec![event_id], ) } - RadrootsActiveOrderReducerIssue::FulfillmentSellerMismatch { event_id } => { + RadrootsActiveOrderReducerIssue::RevisionProposalSellerMismatch { event_id } => { issue_with_events( - "fulfillment_seller_mismatch", + "revision_proposal_seller_mismatch", "seller_pubkey", - "active order reducer reported fulfillment seller mismatch", + "active order reducer reported revision proposal seller mismatch", vec![event_id], ) } - RadrootsActiveOrderReducerIssue::FulfillmentListingAddressInvalid { event_id } => { + RadrootsActiveOrderReducerIssue::RevisionProposalListingAddressInvalid { event_id } => { issue_with_events( - "invalid_fulfillment_listing_address", + "invalid_revision_proposal_listing_address", "listing_addr", - "active order reducer reported invalid fulfillment listing address", + "active order reducer reported invalid revision proposal listing address", vec![event_id], ) } - RadrootsActiveOrderReducerIssue::FulfillmentListingMismatch { event_id } => { + RadrootsActiveOrderReducerIssue::RevisionProposalListingMismatch { event_id } => { issue_with_events( - "fulfillment_listing_mismatch", + "revision_proposal_listing_mismatch", "listing_addr", - "active order reducer reported fulfillment listing mismatch", - vec![event_id], - ) - } - RadrootsActiveOrderReducerIssue::FulfillmentRootMismatch { event_id } => issue_with_events( - "fulfillment_root_mismatch", - "root_event_id", - "active order reducer reported fulfillment root mismatch", - vec![event_id], - ), - RadrootsActiveOrderReducerIssue::FulfillmentPreviousMismatch { event_id } => { - issue_with_events( - "fulfillment_previous_mismatch", - "prev_event_id", - "active order reducer reported fulfillment previous mismatch", + "active order reducer reported revision proposal listing mismatch", vec![event_id], ) } - RadrootsActiveOrderReducerIssue::FulfillmentStatusNotPublishable { event_id } => { + RadrootsActiveOrderReducerIssue::RevisionProposalRootMismatch { event_id } => { issue_with_events( - "fulfillment_status_not_publishable", - "fulfillment_state", - "active order reducer reported non-publishable fulfillment status", + "revision_proposal_root_mismatch", + "root_event_id", + "active order reducer reported revision proposal root mismatch", vec![event_id], ) } - RadrootsActiveOrderReducerIssue::FulfillmentUnsupportedTransition { event_id } => { + RadrootsActiveOrderReducerIssue::RevisionProposalPreviousMismatch { event_id } => { issue_with_events( - "fulfillment_unsupported_transition", - "fulfillment_state", - "active order reducer reported unsupported fulfillment transition", + "revision_proposal_previous_mismatch", + "prev_event_id", + "active order reducer reported revision proposal previous mismatch", vec![event_id], ) } - RadrootsActiveOrderReducerIssue::ForkedFulfillments { event_ids } => issue_with_events( - "forked_fulfillments", - "fulfillment_event_id", - "active order reducer reported forked fulfillment updates", - event_ids, - ), - RadrootsActiveOrderReducerIssue::CancellationWithoutCancellableOrder { event_id } => { + RadrootsActiveOrderReducerIssue::RevisionDecisionWithoutProposal { event_id } => { issue_with_events( - "cancellation_without_cancellable_order", - "cancellation_event_id", - "active order reducer reported cancellation without cancellable order", + "revision_decision_without_proposal", + "revision_decision_event_id", + "active order reducer reported revision decision without proposal", vec![event_id], ) } - RadrootsActiveOrderReducerIssue::CancellationPayloadInvalid { event_id } => { + RadrootsActiveOrderReducerIssue::RevisionDecisionPayloadInvalid { event_id } => { issue_with_events( - "invalid_cancellation_payload", - "cancellation_payload", - "active order reducer reported invalid cancellation payload", + "invalid_revision_decision_payload", + "revision_decision_payload", + "active order reducer reported invalid revision decision payload", vec![event_id], ) } - RadrootsActiveOrderReducerIssue::CancellationOrderIdMismatch { event_id } => { + RadrootsActiveOrderReducerIssue::RevisionDecisionOrderIdMismatch { event_id } => { issue_with_events( - "cancellation_order_id_mismatch", + "revision_decision_order_id_mismatch", "order_id", - "active order reducer reported cancellation order id mismatch", + "active order reducer reported revision decision order id mismatch", vec![event_id], ) } - RadrootsActiveOrderReducerIssue::CancellationAuthorMismatch { event_id } => { + RadrootsActiveOrderReducerIssue::RevisionDecisionAuthorMismatch { event_id } => { issue_with_events( - "cancellation_author_mismatch", + "revision_decision_author_mismatch", "buyer_pubkey", - "active order reducer reported cancellation author mismatch", + "active order reducer reported revision decision author mismatch", vec![event_id], ) } - RadrootsActiveOrderReducerIssue::CancellationCounterpartyMismatch { event_id } => { + RadrootsActiveOrderReducerIssue::RevisionDecisionCounterpartyMismatch { event_id } => { issue_with_events( - "cancellation_counterparty_mismatch", + "revision_decision_counterparty_mismatch", "seller_pubkey", - "active order reducer reported cancellation counterparty mismatch", + "active order reducer reported revision decision counterparty mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::RevisionDecisionBuyerMismatch { event_id } => { + issue_with_events( + "revision_decision_buyer_mismatch", + "buyer_pubkey", + "active order reducer reported revision decision buyer mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::RevisionDecisionSellerMismatch { event_id } => { + issue_with_events( + "revision_decision_seller_mismatch", + "seller_pubkey", + "active order reducer reported revision decision seller mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::RevisionDecisionListingAddressInvalid { event_id } => { + issue_with_events( + "invalid_revision_decision_listing_address", + "listing_addr", + "active order reducer reported invalid revision decision listing address", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::RevisionDecisionListingMismatch { event_id } => { + issue_with_events( + "revision_decision_listing_mismatch", + "listing_addr", + "active order reducer reported revision decision listing mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::RevisionDecisionRootMismatch { event_id } => { + issue_with_events( + "revision_decision_root_mismatch", + "root_event_id", + "active order reducer reported revision decision root mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::RevisionDecisionPreviousMismatch { event_id } => { + issue_with_events( + "revision_decision_previous_mismatch", + "prev_event_id", + "active order reducer reported revision decision previous mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::RevisionDecisionRevisionIdMismatch { event_id } => { + issue_with_events( + "revision_decision_revision_id_mismatch", + "revision_id", + "active order reducer reported revision decision revision id mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::FulfillmentWithoutAcceptedDecision { event_id } => { + issue_with_events( + "fulfillment_without_accepted_decision", + "fulfillment_event_id", + "active order reducer reported fulfillment without accepted decision", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::FulfillmentPayloadInvalid { event_id } => { + issue_with_events( + "invalid_fulfillment_payload", + "fulfillment_payload", + "active order reducer reported invalid fulfillment payload", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::FulfillmentOrderIdMismatch { event_id } => { + issue_with_events( + "fulfillment_order_id_mismatch", + "order_id", + "active order reducer reported fulfillment order id mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::FulfillmentAuthorMismatch { event_id } => { + issue_with_events( + "fulfillment_author_mismatch", + "seller_pubkey", + "active order reducer reported fulfillment author mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::FulfillmentCounterpartyMismatch { event_id } => { + issue_with_events( + "fulfillment_counterparty_mismatch", + "buyer_pubkey", + "active order reducer reported fulfillment counterparty mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::FulfillmentBuyerMismatch { event_id } => { + issue_with_events( + "fulfillment_buyer_mismatch", + "buyer_pubkey", + "active order reducer reported fulfillment buyer mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::FulfillmentSellerMismatch { event_id } => { + issue_with_events( + "fulfillment_seller_mismatch", + "seller_pubkey", + "active order reducer reported fulfillment seller mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::FulfillmentListingAddressInvalid { event_id } => { + issue_with_events( + "invalid_fulfillment_listing_address", + "listing_addr", + "active order reducer reported invalid fulfillment listing address", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::FulfillmentListingMismatch { event_id } => { + issue_with_events( + "fulfillment_listing_mismatch", + "listing_addr", + "active order reducer reported fulfillment listing mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::FulfillmentRootMismatch { event_id } => issue_with_events( + "fulfillment_root_mismatch", + "root_event_id", + "active order reducer reported fulfillment root mismatch", + vec![event_id], + ), + RadrootsActiveOrderReducerIssue::FulfillmentPreviousMismatch { event_id } => { + issue_with_events( + "fulfillment_previous_mismatch", + "prev_event_id", + "active order reducer reported fulfillment previous mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::FulfillmentStatusNotPublishable { event_id } => { + issue_with_events( + "fulfillment_status_not_publishable", + "fulfillment_state", + "active order reducer reported non-publishable fulfillment status", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::FulfillmentUnsupportedTransition { event_id } => { + issue_with_events( + "fulfillment_unsupported_transition", + "fulfillment_state", + "active order reducer reported unsupported fulfillment transition", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::ForkedFulfillments { event_ids } => issue_with_events( + "forked_fulfillments", + "fulfillment_event_id", + "active order reducer reported forked fulfillment updates", + event_ids, + ), + RadrootsActiveOrderReducerIssue::CancellationWithoutCancellableOrder { event_id } => { + issue_with_events( + "cancellation_without_cancellable_order", + "cancellation_event_id", + "active order reducer reported cancellation without cancellable order", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::CancellationPayloadInvalid { event_id } => { + issue_with_events( + "invalid_cancellation_payload", + "cancellation_payload", + "active order reducer reported invalid cancellation payload", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::CancellationOrderIdMismatch { event_id } => { + issue_with_events( + "cancellation_order_id_mismatch", + "order_id", + "active order reducer reported cancellation order id mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::CancellationAuthorMismatch { event_id } => { + issue_with_events( + "cancellation_author_mismatch", + "buyer_pubkey", + "active order reducer reported cancellation author mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::CancellationCounterpartyMismatch { event_id } => { + issue_with_events( + "cancellation_counterparty_mismatch", + "seller_pubkey", + "active order reducer reported cancellation counterparty mismatch", vec![event_id], ) } @@ -3086,6 +3548,46 @@ fn order_revision_base_view( } } +fn order_revision_decision_base_view( + config: &RuntimeConfig, + args: &OrderRevisionDecisionArgs, + state: &str, + dry_run: bool, +) -> OrderRevisionDecisionView { + OrderRevisionDecisionView { + state: state.to_owned(), + source: ORDER_REVISION_DECISION_SOURCE.to_owned(), + order_id: args.key.clone(), + revision_id: Some(args.revision_id.trim().to_owned()).filter(|value| !value.is_empty()), + decision: Some(args.decision.as_str().to_owned()), + listing_addr: None, + buyer_pubkey: None, + seller_pubkey: None, + request_event_id: None, + decision_event_id: None, + agreement_event_id: None, + root_event_id: None, + prev_event_id: None, + event_id: None, + event_kind: None, + economics: None, + inventory: None, + 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: args.reason.as_ref().map(|reason| reason.trim().to_owned()), + issues: Vec::new(), + actions: Vec::new(), + } +} + fn order_fulfillment_base_view( config: &RuntimeConfig, args: &OrderFulfillmentArgs, @@ -3269,7 +3771,10 @@ fn order_receipt_prev_event_id(status: &OrderStatusView) -> Option<String> { 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(), + "accepted" => status + .last_event_id + .clone() + .or(status.decision_event_id.clone()), _ => status.last_event_id.clone(), } } @@ -3651,7 +4156,31 @@ fn apply_order_revision_status(view: &mut OrderRevisionProposalView, status: &Or 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 = status.decision_event_id.clone(); + view.prev_event_id = status.last_event_id.clone(); + view.economics = status.economics.clone(); + view.inventory = status.inventory.clone(); + 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 apply_order_revision_decision_status( + view: &mut OrderRevisionDecisionView, + 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.agreement_event_id = status.agreement_event_id.clone(); + view.root_event_id = status.request_event_id.clone(); + view.prev_event_id = status.last_event_id.clone(); view.economics = status.economics.clone(); view.inventory = status.inventory.clone(); view.target_relays = status.target_relays.clone(); @@ -3774,29 +4303,71 @@ fn order_revision_args_preflight_view( Some(view) } -fn order_revision_preflight_view_from_status( +fn order_revision_decision_args_preflight_view( config: &RuntimeConfig, - args: &OrderRevisionProposeArgs, - status: &OrderStatusView, - selected_pubkey: &str, - candidates: &OrderRevisionProposalCandidates, -) -> Option<OrderRevisionProposalView> { - let seller_matches = status - .seller_pubkey - .as_deref() - .is_some_and(|seller| seller.eq_ignore_ascii_case(selected_pubkey)); - let state = match status.state.as_str() { - "accepted" - if seller_matches - && status - .fulfillment - .as_ref() - .and_then(|fulfillment| fulfillment.event_id.as_ref()) - .is_none() - && candidates.issues.is_empty() - && candidates.records.is_empty() => - { - return None; + args: &OrderRevisionDecisionArgs, +) -> Option<OrderRevisionDecisionView> { + let mut issues = Vec::new(); + if args.revision_id.trim().is_empty() { + issues.push(issue_with_code( + "revision_id_required", + "revision_id", + "order revision decision requires --revision-id", + )); + } + if args.decision == OrderRevisionDecisionArg::Decline + && args + .reason + .as_deref() + .map(str::trim) + .filter(|reason| !reason.is_empty()) + .is_none() + { + issues.push(issue_with_code( + "revision_decline_reason_required", + "reason", + "order revision decline requires a non-empty reason", + )); + } + + if issues.is_empty() { + return None; + } + let mut view = + order_revision_decision_base_view(config, args, "invalid", config.output.dry_run); + view.reason = Some(format!( + "order revision {} inputs for `{}` failed validation", + args.decision.command(), + args.key + )); + view.issues = issues; + Some(view) +} + +fn order_revision_preflight_view_from_status( + config: &RuntimeConfig, + args: &OrderRevisionProposeArgs, + status: &OrderStatusView, + selected_pubkey: &str, + candidates: &OrderRevisionProposalCandidates, +) -> Option<OrderRevisionProposalView> { + let pending_revision = pending_revision_proposal_candidate(status, candidates); + let seller_matches = status + .seller_pubkey + .as_deref() + .is_some_and(|seller| seller.eq_ignore_ascii_case(selected_pubkey)); + let state = match status.state.as_str() { + "accepted" + if seller_matches + && status + .fulfillment + .as_ref() + .and_then(|fulfillment| fulfillment.event_id.as_ref()) + .is_none() + && candidates.issues.is_empty() + && pending_revision.is_none() => + { + return None; } "accepted" if !seller_matches => "invalid", "accepted" @@ -3809,7 +4380,7 @@ fn order_revision_preflight_view_from_status( "fulfilled" } "accepted" if !candidates.issues.is_empty() => "invalid", - "accepted" if !candidates.records.is_empty() => "forked", + "accepted" if pending_revision.is_some() => "forked", "cancelled" | "completed" | "disputed" => "terminal", "missing" | "requested" | "declined" | "invalid" | "unavailable" | "unconfigured" => { status.state.as_str() @@ -3818,7 +4389,7 @@ fn order_revision_preflight_view_from_status( }; let mut view = order_revision_base_view(config, args, state, config.output.dry_run); apply_order_revision_status(&mut view, status); - if let Some(record) = candidates.records.first() { + if let Some(record) = pending_revision { view.event_id = Some(record.event_id.clone()); view.event_kind = Some(KIND_TRADE_ORDER_REVISION); view.revision_id = Some(record.payload.revision_id.clone()); @@ -3874,6 +4445,7 @@ fn order_revision_preflight_view_from_status( candidates .records .iter() + .filter(|record| Some(record.event_id.as_str()) == status.last_event_id.as_deref()) .map(|record| record.event_id.clone()) .collect(), )); @@ -3883,6 +4455,125 @@ fn order_revision_preflight_view_from_status( Some(view) } +fn order_revision_decision_preflight_view_from_status( + config: &RuntimeConfig, + args: &OrderRevisionDecisionArgs, + status: &OrderStatusView, + selected_pubkey: &str, + candidates: &OrderRevisionProposalCandidates, +) -> Option<OrderRevisionDecisionView> { + let pending_revision = pending_revision_proposal_candidate(status, candidates); + 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() { + "accepted" + if buyer_matches + && status + .fulfillment + .as_ref() + .and_then(|fulfillment| fulfillment.event_id.as_ref()) + .is_none() + && candidates.issues.is_empty() + && pending_revision.is_some() => + { + return None; + } + "accepted" if !buyer_matches => "invalid", + "accepted" + if status + .fulfillment + .as_ref() + .and_then(|fulfillment| fulfillment.event_id.as_ref()) + .is_some() => + { + "fulfilled" + } + "accepted" if !candidates.issues.is_empty() => "invalid", + "accepted" => "missing", + "cancelled" | "completed" | "disputed" => "terminal", + "declined" => "order_declined", + "missing" | "requested" | "invalid" | "unavailable" | "unconfigured" => { + status.state.as_str() + } + _ => "invalid", + }; + let mut view = order_revision_decision_base_view(config, args, state, config.output.dry_run); + apply_order_revision_decision_status(&mut view, status); + if let Some(record) = pending_revision { + apply_order_revision_decision_proposal(&mut view, record); + view.event_id = Some(record.event_id.clone()); + view.event_kind = Some(KIND_TRADE_ORDER_REVISION); + } + view.reason = Some(match state { + "missing" if status.state == "accepted" => format!( + "order revision {} refused because order `{}` has no pending revision proposal", + args.decision.command(), + args.key + ), + "missing" => format!("no active order events matched `{}`", args.key), + "requested" => format!( + "order revision {} refused because order `{}` has no accepted seller decision", + args.decision.command(), + args.key + ), + "order_declined" => format!( + "order revision {} refused because order `{}` was declined", + args.decision.command(), + args.key + ), + "terminal" => format!( + "order revision {} refused because order `{}` is already terminal", + args.decision.command(), + args.key + ), + "fulfilled" => format!( + "order revision {} refused because order `{}` already has seller fulfillment", + args.decision.command(), + args.key + ), + "invalid" if !buyer_matches && status.buyer_pubkey.is_some() => format!( + "order revision {} refused because selected account is not buyer for order `{}`", + args.decision.command(), + args.key + ), + "invalid" if !candidates.issues.is_empty() => format!( + "order revision {} refused because revision proposal candidates for `{}` are invalid", + args.decision.command(), + args.key + ), + "invalid" => status.reason.clone().unwrap_or_else(|| { + format!( + "order revision {} refused because active order events for `{}` are invalid", + args.decision.command(), + args.key + ) + }), + _ => status.reason.clone().unwrap_or_else(|| { + format!( + "order revision {} status preflight failed with state `{}`", + args.decision.command(), + status.state + ) + }), + }); + view.issues.extend(candidates.issues.clone()); + view.actions = vec![format!("radroots order status get {}", args.key)]; + Some(view) +} + +fn pending_revision_proposal_candidate<'a>( + status: &OrderStatusView, + candidates: &'a OrderRevisionProposalCandidates, +) -> Option<&'a OrderRevisionProposalRecord> { + let last_event_id = status.last_event_id.as_deref()?; + candidates + .records + .iter() + .find(|record| record.event_id == last_event_id) +} + fn order_accept_inventory_preflight_view( config: &RuntimeConfig, args: &OrderDecisionArgs, @@ -3967,6 +4658,20 @@ fn order_accept_inventory_preflight_view( .filter(|record| request_order_ids.contains(&record.payload.order_id)) .collect::<Vec<_>>(); decisions.push(proposed_accept_decision_record(request)?); + let revision_proposals = fetch_listing_accounting_revision_proposals_for_status( + config, + request.listing_addr.as_str(), + )? + .into_iter() + .filter(|record| request_order_ids.contains(&record.payload.order_id)) + .collect::<Vec<_>>(); + let revision_decisions = fetch_listing_accounting_revision_decisions_for_status( + config, + request.listing_addr.as_str(), + )? + .into_iter() + .filter(|record| request_order_ids.contains(&record.payload.order_id)) + .collect::<Vec<_>>(); let fulfillments = fetch_listing_accounting_fulfillments(config, request)? .into_iter() .filter(|record| request_order_ids.contains(&record.payload.order_id)) @@ -3982,6 +4687,8 @@ fn order_accept_inventory_preflight_view( listing.bins, requests, decisions, + revision_proposals, + revision_decisions, fulfillments, cancellations, Vec::<RadrootsActiveOrderReceiptRecord>::new(), @@ -4466,6 +5173,22 @@ fn order_revision_invalid_view( view } +fn order_revision_decision_invalid_view( + config: &RuntimeConfig, + args: &OrderRevisionDecisionArgs, + status: &OrderStatusView, + reason: impl Into<String>, + issues: Vec<OrderIssueView>, +) -> OrderRevisionDecisionView { + let mut view = + order_revision_decision_base_view(config, args, "invalid", config.output.dry_run); + apply_order_revision_decision_status(&mut view, status); + view.reason = Some(reason.into()); + view.issues.extend(issues); + view.actions = vec![format!("radroots order status get {}", args.key)]; + view +} + fn order_revision_dry_run_view( config: &RuntimeConfig, args: &OrderRevisionProposeArgs, @@ -4481,6 +5204,24 @@ fn order_revision_dry_run_view( view } +fn order_revision_decision_dry_run_view( + config: &RuntimeConfig, + args: &OrderRevisionDecisionArgs, + status: &OrderStatusView, + proposal: &OrderRevisionProposalRecord, + payload: &RadrootsTradeOrderRevisionDecisionEvent, +) -> OrderRevisionDecisionView { + let mut view = order_revision_decision_base_view(config, args, "dry_run", true); + apply_order_revision_decision_status(&mut view, status); + apply_order_revision_decision_payload(&mut view, proposal, payload); + view.reason = Some(format!( + "dry run requested; buyer revision {} publication skipped", + args.decision.command() + )); + view.actions = vec![format!("radroots order status get {}", status.order_id)]; + view +} + fn order_fulfillment_dry_run_view( config: &RuntimeConfig, args: &OrderFulfillmentArgs, @@ -4528,9 +5269,13 @@ fn order_revision_payload_from_status( root_event_id: status.request_event_id.clone().ok_or_else(|| { RuntimeError::Config("accepted order is missing request_event_id".to_owned()) })?, - prev_event_id: status.decision_event_id.clone().ok_or_else(|| { - RuntimeError::Config("accepted order is missing accepted decision event id".to_owned()) - })?, + prev_event_id: status + .last_event_id + .clone() + .or(status.decision_event_id.clone()) + .ok_or_else(|| { + RuntimeError::Config("accepted order is missing previous event id".to_owned()) + })?, items, economics, reason: args.reason.trim().to_owned(), @@ -4673,9 +5418,18 @@ fn order_revision_event_parts( let root_event_id = status.request_event_id.as_deref().ok_or_else(|| { RuntimeError::Config("accepted order is missing request_event_id".to_owned()) })?; - let prev_event_id = status.decision_event_id.as_deref().ok_or_else(|| { - RuntimeError::Config("accepted order is missing accepted decision event id".to_owned()) - })?; + let prev_event_id = status + .last_event_id + .as_deref() + .or(status.decision_event_id.as_deref()) + .ok_or_else(|| { + RuntimeError::Config("accepted order is missing previous event id".to_owned()) + })?; + if payload.root_event_id != root_event_id || payload.prev_event_id != prev_event_id { + return Err(RuntimeError::Config( + "order revision proposal payload chain does not match order status".to_owned(), + )); + } active_trade_order_revision_proposal_event_build(root_event_id, prev_event_id, payload).map_err( |error| RuntimeError::Config(format!("encode order revision proposal event: {error}")), ) @@ -4687,18 +5441,31 @@ fn order_revision_inventory_preflight_view( status: &OrderStatusView, payload: &RadrootsTradeOrderRevisionProposed, ) -> Option<OrderRevisionProposalView> { + let issues = order_revision_inventory_issues(status, payload); + if issues.is_empty() { + return None; + } + let mut view = order_revision_invalid_view( + config, + args, + status, + "order revision propose refused because visible inventory is unavailable for the revised items", + issues, + ); + apply_order_revision_payload(&mut view, payload); + Some(view) +} + +fn order_revision_inventory_issues( + status: &OrderStatusView, + payload: &RadrootsTradeOrderRevisionProposed, +) -> Vec<OrderIssueView> { let Some(current) = status.economics.as_ref() else { - return Some(order_revision_invalid_view( - config, - args, - status, - "order revision propose refused because current economics are missing", - vec![issue_with_code( - "revision_current_economics_missing", - "economics", - "current agreement economics are required before revision proposal", - )], - )); + return vec![issue_with_code( + "revision_current_economics_missing", + "economics", + "current agreement economics are required before revision proposal", + )]; }; let current_counts = current @@ -4756,18 +5523,7 @@ fn order_revision_inventory_preflight_view( } } - if issues.is_empty() { - return None; - } - let mut view = order_revision_invalid_view( - config, - args, - status, - "order revision propose refused because visible inventory is unavailable for the revised items", - issues, - ); - apply_order_revision_payload(&mut view, payload); - Some(view) + issues } fn apply_order_revision_payload( @@ -4788,6 +5544,89 @@ fn apply_order_revision_payload( view.economics = Some(payload.economics.clone()); } +fn apply_order_revision_decision_proposal( + view: &mut OrderRevisionDecisionView, + proposal: &OrderRevisionProposalRecord, +) { + view.revision_id = Some(proposal.payload.revision_id.clone()); + view.root_event_id = Some(proposal.payload.root_event_id.clone()); + view.prev_event_id = Some(proposal.event_id.clone()); + view.event_id = Some(proposal.event_id.clone()); + view.event_kind = Some(KIND_TRADE_ORDER_REVISION); + if view.decision.as_deref() == Some("accepted") { + view.economics = Some(proposal.payload.economics.clone()); + } +} + +fn apply_order_revision_decision_payload( + view: &mut OrderRevisionDecisionView, + proposal: &OrderRevisionProposalRecord, + payload: &RadrootsTradeOrderRevisionDecisionEvent, +) { + view.revision_id = Some(payload.revision_id.clone()); + view.root_event_id = Some(payload.root_event_id.clone()); + view.prev_event_id = Some(payload.prev_event_id.clone()); + view.decision = Some( + match &payload.decision { + RadrootsTradeOrderRevisionDecision::Accepted => "accepted", + RadrootsTradeOrderRevisionDecision::Declined { .. } => "declined", + } + .to_owned(), + ); + if matches!( + payload.decision, + RadrootsTradeOrderRevisionDecision::Accepted + ) { + view.agreement_event_id = view.event_id.clone(); + view.economics = Some(proposal.payload.economics.clone()); + } +} + +fn order_revision_decision_payload_from_proposal( + args: &OrderRevisionDecisionArgs, + proposal: &OrderRevisionProposalRecord, +) -> Result<RadrootsTradeOrderRevisionDecisionEvent, RuntimeError> { + let decision = match args.decision { + OrderRevisionDecisionArg::Accept => RadrootsTradeOrderRevisionDecision::Accepted, + OrderRevisionDecisionArg::Decline => { + let reason = args + .reason + .as_deref() + .map(str::trim) + .filter(|reason| !reason.is_empty()) + .ok_or_else(|| { + RuntimeError::Config( + "order revision decline requires a non-empty reason".to_owned(), + ) + })?; + RadrootsTradeOrderRevisionDecision::Declined { + reason: reason.to_owned(), + } + } + }; + Ok(RadrootsTradeOrderRevisionDecisionEvent { + revision_id: proposal.payload.revision_id.clone(), + order_id: proposal.payload.order_id.clone(), + listing_addr: proposal.payload.listing_addr.clone(), + buyer_pubkey: proposal.payload.buyer_pubkey.clone(), + seller_pubkey: proposal.payload.seller_pubkey.clone(), + root_event_id: proposal.payload.root_event_id.clone(), + prev_event_id: proposal.event_id.clone(), + decision, + }) +} + +fn order_revision_decision_event_parts( + payload: &RadrootsTradeOrderRevisionDecisionEvent, +) -> Result<WireEventParts, RuntimeError> { + active_trade_order_revision_decision_event_build( + payload.root_event_id.as_str(), + payload.prev_event_id.as_str(), + payload, + ) + .map_err(|error| RuntimeError::Config(format!("encode order revision decision event: {error}"))) +} + fn order_fulfillment_payload_from_status( status: &OrderStatusView, fulfillment_state: RadrootsActiveTradeFulfillmentState, @@ -4955,6 +5794,23 @@ fn publish_order_revision( )) } +fn publish_order_revision_decision( + config: &RuntimeConfig, + args: &OrderRevisionDecisionArgs, + status: OrderStatusView, + proposal: &OrderRevisionProposalRecord, + signing: accounts::AccountSigningIdentity, + payload: RadrootsTradeOrderRevisionDecisionEvent, +) -> Result<OrderRevisionDecisionView, RuntimeError> { + let parts = order_revision_decision_event_parts(&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_revision_decision_view( + config, args, &status, proposal, &payload, event_kind, receipt, + )) +} + fn published_order_revision_view( config: &RuntimeConfig, args: &OrderRevisionProposeArgs, @@ -4982,16 +5838,57 @@ fn published_order_revision_view( view } -fn publish_order_fulfillment( +fn published_order_revision_decision_view( 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) + args: &OrderRevisionDecisionArgs, + status: &OrderStatusView, + proposal: &OrderRevisionProposalRecord, + payload: &RadrootsTradeOrderRevisionDecisionEvent, + event_kind: u32, + receipt: DirectRelayPublishReceipt, +) -> OrderRevisionDecisionView { + let DirectRelayPublishReceipt { + event_id, + created_at: _, + signature: _, + target_relays, + acknowledged_relays, + failed_relays, + } = receipt; + let state = match payload.decision { + RadrootsTradeOrderRevisionDecision::Accepted => "accepted", + RadrootsTradeOrderRevisionDecision::Declined { .. } => "declined", + }; + let mut view = order_revision_decision_base_view(config, args, state, false); + apply_order_revision_decision_status(&mut view, status); + apply_order_revision_decision_payload(&mut view, proposal, payload); + view.revision_id = Some(payload.revision_id.clone()); + view.root_event_id = Some(payload.root_event_id.clone()); + view.prev_event_id = Some(payload.prev_event_id.clone()); + view.event_id = Some(event_id.clone()); + view.event_kind = Some(event_kind); + if matches!( + payload.decision, + RadrootsTradeOrderRevisionDecision::Accepted + ) { + view.agreement_event_id = Some(event_id); + } + view.target_relays = target_relays; + view.acknowledged_relays = acknowledged_relays; + view.failed_relays = relay_failures(failed_relays); + 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, @@ -5162,6 +6059,27 @@ fn order_revision_binding_error_view( view } +fn order_revision_decision_binding_error_view( + config: &RuntimeConfig, + args: &OrderRevisionDecisionArgs, + status: &OrderStatusView, + error: ActorWriteBindingError, +) -> OrderRevisionDecisionView { + 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_revision_decision_base_view(config, args, state.as_str(), config.output.dry_run); + apply_order_revision_decision_status(&mut view, status); + view.reason = Some(reason); + view.actions = actions; + view +} + fn order_cancellation_binding_error_view( config: &RuntimeConfig, args: &OrderCancelArgs, @@ -5571,6 +6489,28 @@ fn order_listing_decision_filter(listing_addr: &str) -> Result<RadrootsNostrFilt .map_err(|error| RuntimeError::Config(format!("build order decision filter: {error}"))) } +fn order_listing_revision_proposal_filter( + listing_addr: &str, +) -> Result<RadrootsNostrFilter, RuntimeError> { + let filter = RadrootsNostrFilter::new() + .kind(radroots_nostr_kind(KIND_TRADE_ORDER_REVISION as u16)) + .limit(1_000); + radroots_nostr_filter_tag(filter, "a", vec![listing_addr.to_owned()]) + .map_err(|error| RuntimeError::Config(format!("build revision proposal filter: {error}"))) +} + +fn order_listing_revision_decision_filter( + listing_addr: &str, +) -> Result<RadrootsNostrFilter, RuntimeError> { + let filter = RadrootsNostrFilter::new() + .kind(radroots_nostr_kind( + KIND_TRADE_ORDER_REVISION_RESPONSE as u16, + )) + .limit(1_000); + radroots_nostr_filter_tag(filter, "a", vec![listing_addr.to_owned()]) + .map_err(|error| RuntimeError::Config(format!("build revision decision filter: {error}"))) +} + fn order_listing_fulfillment_filter( listing_addr: &str, ) -> Result<RadrootsNostrFilter, RuntimeError> { @@ -5597,6 +6537,7 @@ fn order_status_filter(order_id: &str) -> Result<RadrootsNostrFilter, RuntimeErr radroots_nostr_kind(KIND_TRADE_ORDER_REQUEST as u16), radroots_nostr_kind(KIND_TRADE_ORDER_DECISION as u16), radroots_nostr_kind(KIND_TRADE_ORDER_REVISION as u16), + radroots_nostr_kind(KIND_TRADE_ORDER_REVISION_RESPONSE as u16), radroots_nostr_kind(KIND_TRADE_FULFILLMENT_UPDATE as u16), radroots_nostr_kind(KIND_TRADE_CANCEL as u16), radroots_nostr_kind(KIND_TRADE_RECEIPT as u16), @@ -7267,6 +8208,33 @@ fn resolve_local_order_receipt_signing_identity( Ok(signing) } +fn resolve_local_order_revision_decision_signing_identity( + config: &RuntimeConfig, + buyer_pubkey: &str, + args: &OrderRevisionDecisionArgs, +) -> Result<accounts::AccountSigningIdentity, ActorWriteBindingError> { + if !matches!(config.signer.backend, SignerBackend::Local) { + return Err(ActorWriteBindingError::Unconfigured(format!( + "order revision {} requires signer mode `local`", + args.decision.command() + ))); + } + 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), @@ -7551,7 +8519,7 @@ mod tests { use radroots_events::RadrootsNostrEventPtr; use radroots_events::kinds::{ KIND_TRADE_CANCEL, KIND_TRADE_FULFILLMENT_UPDATE, KIND_TRADE_ORDER_DECISION, - KIND_TRADE_ORDER_REVISION, KIND_TRADE_RECEIPT, + KIND_TRADE_ORDER_REVISION, KIND_TRADE_ORDER_REVISION_RESPONSE, KIND_TRADE_RECEIPT, }; use radroots_events::trade::{ RadrootsActiveTradeFulfillmentState, RadrootsActiveTradeMessageType, @@ -7559,13 +8527,15 @@ mod tests { RadrootsTradeInventoryCommitment, RadrootsTradeOrderCancelled, RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderEconomicItem, RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, RadrootsTradeOrderRequested, + RadrootsTradeOrderRevisionDecision, RadrootsTradeOrderRevisionDecisionEvent, RadrootsTradeOrderRevisionProposed, RadrootsTradePricingBasis, }; use radroots_events_codec::trade::{ active_trade_buyer_receipt_event_build, active_trade_event_context_from_tags, active_trade_fulfillment_update_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, active_trade_order_revision_proposal_event_build, + active_trade_order_request_event_build, active_trade_order_revision_decision_event_build, + active_trade_order_revision_proposal_event_build, }; use radroots_identity::RadrootsIdentity; use radroots_nostr::prelude::{radroots_event_from_nostr, radroots_nostr_build_event}; @@ -7574,6 +8544,7 @@ mod tests { use radroots_trade::order::{ RadrootsActiveOrderCancellationRecord, RadrootsActiveOrderDecisionRecord, RadrootsActiveOrderFulfillmentRecord, RadrootsActiveOrderReceiptRecord, + RadrootsActiveOrderRevisionDecisionRecord, RadrootsActiveOrderRevisionProposalRecord, RadrootsListingInventoryBinAvailability, canonicalize_active_order_decision_for_signer, reduce_listing_inventory_accounting, }; @@ -7594,12 +8565,15 @@ mod tests { order_fulfillment_preflight_view_from_status, order_history_entry_from_event, order_history_from_receipt, order_receipt_dry_run_view, order_receipt_event_parts, order_receipt_payload_from_status, order_receipt_preflight_view_from_status, - order_request_filter, order_revision_event_parts, order_revision_inventory_preflight_view, - order_revision_payload_from_status, order_revision_preflight_view_from_status, - order_revision_proposals_from_events, order_status_filter, order_status_from_receipt, - order_status_from_receipt_with_context, order_status_reduction_from_receipt_with_context, - order_submit_dry_run_view, order_submit_existing_request_view_from_receipt, - proposed_accept_decision_record, resolve_local_order_fulfillment_signing_identity, + order_request_filter, order_revision_decision_event_parts, + order_revision_decision_payload_from_proposal, + order_revision_decision_preflight_view_from_status, order_revision_event_parts, + order_revision_inventory_preflight_view, order_revision_payload_from_status, + order_revision_preflight_view_from_status, order_revision_proposals_from_events, + order_status_filter, order_status_from_receipt, order_status_from_receipt_with_context, + order_status_reduction_from_receipt_with_context, order_submit_dry_run_view, + order_submit_existing_request_view_from_receipt, proposed_accept_decision_record, + resolve_local_order_fulfillment_signing_identity, seller_order_request_resolution_from_receipt, }; use crate::runtime::accounts; @@ -7613,7 +8587,8 @@ mod tests { use crate::runtime::signer::ActorWriteBindingError; use crate::runtime_args::{ OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderDraftAdjustmentArgs, - OrderFulfillmentArgs, OrderReceiptArgs, OrderRevisionProposeArgs, OrderSubmitArgs, + OrderFulfillmentArgs, OrderReceiptArgs, OrderRevisionDecisionArg, + OrderRevisionDecisionArgs, OrderRevisionProposeArgs, OrderSubmitArgs, }; #[test] @@ -7711,134 +8686,453 @@ mod tests { economics.subtotal, RadrootsCoreMoney::new(RadrootsCoreDecimal::from(20), RadrootsCoreCurrency::USD) ); - assert_eq!(economics.discounts.len(), 1); - assert_eq!( - economics.discounts[0].amount, - RadrootsCoreMoney::new(RadrootsCoreDecimal::from(2), RadrootsCoreCurrency::USD) + assert_eq!(economics.discounts.len(), 1); + assert_eq!( + economics.discounts[0].amount, + RadrootsCoreMoney::new(RadrootsCoreDecimal::from(2), RadrootsCoreCurrency::USD) + ); + assert_eq!(economics.adjustments.len(), 1); + assert_eq!(economics.adjustments[0].id, "adj_delivery"); + assert_eq!( + economics.adjustments[0].amount, + RadrootsCoreMoney::new(RadrootsCoreDecimal::from(2), RadrootsCoreCurrency::USD) + ); + assert_eq!( + economics.total, + RadrootsCoreMoney::new(RadrootsCoreDecimal::from(20), RadrootsCoreCurrency::USD) + ); + } + + #[test] + fn order_draft_requires_listing_event_id_for_submit_readiness() { + let document = OrderDraftDocument { + version: 1, + kind: ORDER_DRAFT_KIND.to_owned(), + order: OrderDraft { + order_id: "ord_AAAAAAAAAAAAAAAAAAAAAg".to_owned(), + listing_addr: "30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg".to_owned(), + listing_event_id: String::new(), + buyer_pubkey: "a".repeat(64), + seller_pubkey: "deadbeef".to_owned(), + items: vec![OrderDraftItem { + bin_id: "bin-1".to_owned(), + bin_count: 2, + }], + economics: Some(sample_order_economics( + "ord_AAAAAAAAAAAAAAAAAAAAAg", + "bin-1", + 2, + )), + }, + listing_lookup: Some("fresh-eggs".to_owned()), + buyer_account_id: Some("acct_demo".to_owned()), + }; + + let inspection = inspect_document(&document); + assert_eq!(inspection.state, "draft"); + assert!(!inspection.ready_for_submit); + assert!( + collect_issues(&document) + .iter() + .any(|issue| issue.field == "order.listing_event_id") + ); + } + + #[test] + fn order_request_event_decodes_to_history_entry() { + let buyer = RadrootsIdentity::generate(); + let seller = RadrootsIdentity::generate(); + let buyer_pubkey = buyer.public_key_hex(); + let seller_pubkey = seller.public_key_hex(); + let listing_addr = format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg"); + let listing_event_id = "1".repeat(64); + let payload = RadrootsTradeOrderRequested { + order_id: "ord_AAAAAAAAAAAAAAAAAAAAAg".to_owned(), + listing_addr: listing_addr.clone(), + buyer_pubkey: buyer_pubkey.clone(), + seller_pubkey: seller_pubkey.clone(), + items: vec![RadrootsTradeOrderItem { + bin_id: "bin-1".to_owned(), + bin_count: 2, + }], + economics: sample_order_economics("ord_AAAAAAAAAAAAAAAAAAAAAg", "bin-1", 2), + }; + let parts = active_trade_order_request_event_build( + &RadrootsNostrEventPtr { + id: listing_event_id.clone(), + relays: None, + }, + &payload, + ) + .expect("order request parts"); + let event = radroots_nostr_build_event(parts.kind, parts.content, parts.tags) + .expect("nostr event builder") + .sign_with_keys(buyer.keys()) + .expect("signed order request"); + + let entry = + order_history_entry_from_event(&event, seller_pubkey.as_str()).expect("history entry"); + + assert_eq!(entry.id, "ord_AAAAAAAAAAAAAAAAAAAAAg"); + assert_eq!(entry.state, "requested"); + assert_eq!(entry.event_kind, Some(3422)); + assert_eq!(entry.listing_addr.as_deref(), Some(listing_addr.as_str())); + assert_eq!( + entry.listing_event_id.as_deref(), + Some(listing_event_id.as_str()) + ); + assert_eq!(entry.buyer_pubkey.as_deref(), Some(buyer_pubkey.as_str())); + assert_eq!(entry.seller_pubkey.as_deref(), Some(seller_pubkey.as_str())); + assert_eq!(entry.item_count, Some(1)); + } + + #[test] + fn order_request_filter_includes_order_id_d_tag_when_provided() { + let filter = order_request_filter("a", Some("ord_AAAAAAAAAAAAAAAAAAAAAg")) + .expect("order request filter"); + let value = serde_json::to_value(filter).expect("filter json"); + + assert_eq!(value["kinds"][0], 3422); + assert_eq!(value["#p"][0], "a"); + assert_eq!(value["#d"][0], "ord_AAAAAAAAAAAAAAAAAAAAAg"); + } + + #[test] + fn order_status_filter_includes_active_lifecycle_kinds() { + let filter = order_status_filter("ord_AAAAAAAAAAAAAAAAAAAAAg").expect("status filter"); + let value = serde_json::to_value(filter).expect("filter json"); + let kinds = value["kinds"].as_array().expect("kinds array"); + + assert!(kinds.contains(&serde_json::json!(3422))); + assert!(kinds.contains(&serde_json::json!(3423))); + assert!(kinds.contains(&serde_json::json!(3424))); + assert!(kinds.contains(&serde_json::json!(3425))); + assert!(kinds.contains(&serde_json::json!(3433))); + assert!(kinds.contains(&serde_json::json!(3432))); + assert!(kinds.contains(&serde_json::json!(3434))); + assert_eq!(value["#d"][0], "ord_AAAAAAAAAAAAAAAAAAAAAg"); + } + + #[test] + fn order_revision_payload_updates_items_and_economics() { + 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 = revision_args_for_fixture(&fixture, 3); + + let payload = + order_revision_payload_from_status(&args, &status_view).expect("revision payload"); + let parts = + order_revision_event_parts(&status_view, &payload).expect("revision event parts"); + let context = active_trade_event_context_from_tags( + RadrootsActiveTradeMessageType::TradeOrderRevisionProposed, + &parts.tags, + ) + .expect("revision context"); + let request_event_id = fixture.request_event.id.to_string(); + let decision_event_id = decision_event.id.to_string(); + + assert_eq!(payload.items[0].bin_id, "bin-1"); + assert_eq!(payload.items[0].bin_count, 3); + assert_eq!(payload.economics.items[0].bin_count, 3); + assert_eq!(payload.economics.quote_version, 2); + assert!(payload.economics.quote_id.starts_with("revision_rev_")); + assert_eq!(payload.reason, "update count"); + assert_eq!(parts.kind, KIND_TRADE_ORDER_REVISION); + assert_eq!( + context.root_event_id.as_deref(), + Some(request_event_id.as_str()) + ); + assert_eq!( + context.prev_event_id.as_deref(), + Some(decision_event_id.as_str()) + ); + } + + #[test] + fn order_revision_decision_payload_uses_pending_proposal_chain() { + 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 revision_event = signed_order_revision_proposal_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(), + 3, + ); + let revision_event_id = revision_event.id.to_string(); + let candidates = + order_revision_proposals_from_events(fixture.order_id.as_str(), &[revision_event]); + let proposal = candidates.records.first().expect("revision proposal"); + let args = revision_decision_args_for_fixture( + &fixture, + proposal.payload.revision_id.as_str(), + OrderRevisionDecisionArg::Accept, + ); + + let payload = order_revision_decision_payload_from_proposal(&args, proposal) + .expect("revision decision payload"); + let parts = + order_revision_decision_event_parts(&payload).expect("revision decision event parts"); + let context = active_trade_event_context_from_tags( + RadrootsActiveTradeMessageType::TradeOrderRevisionDecision, + &parts.tags, + ) + .expect("revision decision context"); + + assert_eq!(payload.revision_id, proposal.payload.revision_id); + assert_eq!(payload.prev_event_id, revision_event_id); + assert_eq!(parts.kind, KIND_TRADE_ORDER_REVISION_RESPONSE); + let request_event_id = fixture.request_event.id.to_string(); + assert_eq!( + context.root_event_id.as_deref(), + Some(request_event_id.as_str()) + ); + assert_eq!( + context.prev_event_id.as_deref(), + Some(revision_event_id.as_str()) + ); + } + + #[test] + fn order_revision_decision_preflight_allows_selected_buyer_pending_proposal() { + 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 revision_event = signed_order_revision_proposal_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(), + 3, + ); + 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, + revision_event.clone(), + ], + }, + ); + let candidates = + order_revision_proposals_from_events(fixture.order_id.as_str(), &[revision_event]); + let args = revision_decision_args_for_fixture( + &fixture, + "rev_test", + OrderRevisionDecisionArg::Accept, + ); + + let view = order_revision_decision_preflight_view_from_status( + &config, + &args, + &status_view, + fixture.buyer_pubkey.as_str(), + &candidates, + ); + + assert!(view.is_none()); + } + + #[test] + fn order_revision_decision_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 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 revision_event = signed_order_revision_proposal_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(), + 3, ); - assert_eq!(economics.adjustments.len(), 1); - assert_eq!(economics.adjustments[0].id, "adj_delivery"); - assert_eq!( - economics.adjustments[0].amount, - RadrootsCoreMoney::new(RadrootsCoreDecimal::from(2), RadrootsCoreCurrency::USD) + 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, + revision_event.clone(), + ], + }, ); - assert_eq!( - economics.total, - RadrootsCoreMoney::new(RadrootsCoreDecimal::from(20), RadrootsCoreCurrency::USD) + let candidates = + order_revision_proposals_from_events(fixture.order_id.as_str(), &[revision_event]); + let args = revision_decision_args_for_fixture( + &fixture, + "rev_test", + OrderRevisionDecisionArg::Accept, + ); + + let view = order_revision_decision_preflight_view_from_status( + &config, + &args, + &status_view, + fixture.seller_pubkey.as_str(), + &candidates, + ) + .expect("non buyer revision decision preflight"); + + assert_eq!(view.state, "invalid"); + assert!( + view.reason + .as_deref() + .expect("reason") + .contains("selected account is not buyer") ); } #[test] - fn order_draft_requires_listing_event_id_for_submit_readiness() { - let document = OrderDraftDocument { - version: 1, - kind: ORDER_DRAFT_KIND.to_owned(), - order: OrderDraft { - order_id: "ord_AAAAAAAAAAAAAAAAAAAAAg".to_owned(), - listing_addr: "30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg".to_owned(), - listing_event_id: String::new(), - buyer_pubkey: "a".repeat(64), - seller_pubkey: "deadbeef".to_owned(), - items: vec![OrderDraftItem { + fn order_status_from_receipt_applies_accepted_revision_decision() { + 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, }], - economics: Some(sample_order_economics( - "ord_AAAAAAAAAAAAAAAAAAAAAg", - "bin-1", - 2, - )), }, - listing_lookup: Some("fresh-eggs".to_owned()), - buyer_account_id: Some("acct_demo".to_owned()), - }; - - let inspection = inspect_document(&document); - assert_eq!(inspection.state, "draft"); - assert!(!inspection.ready_for_submit); - assert!( - collect_issues(&document) - .iter() - .any(|issue| issue.field == "order.listing_event_id") ); - } + let revision_event = signed_order_revision_proposal_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(), + 3, + ); + let revision_decision_event = signed_order_revision_decision_event( + &fixture.buyer, + &revision_event, + RadrootsTradeOrderRevisionDecision::Accepted, + ); + let revision_decision_event_id = revision_decision_event.id.to_string(); - #[test] - fn order_request_event_decodes_to_history_entry() { - let buyer = RadrootsIdentity::generate(); - let seller = RadrootsIdentity::generate(); - let buyer_pubkey = buyer.public_key_hex(); - let seller_pubkey = seller.public_key_hex(); - let listing_addr = format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg"); - let listing_event_id = "1".repeat(64); - let payload = RadrootsTradeOrderRequested { - order_id: "ord_AAAAAAAAAAAAAAAAAAAAAg".to_owned(), - listing_addr: listing_addr.clone(), - buyer_pubkey: buyer_pubkey.clone(), - seller_pubkey: seller_pubkey.clone(), - items: vec![RadrootsTradeOrderItem { - bin_id: "bin-1".to_owned(), - bin_count: 2, - }], - economics: sample_order_economics("ord_AAAAAAAAAAAAAAAAAAAAAg", "bin-1", 2), - }; - let parts = active_trade_order_request_event_build( - &RadrootsNostrEventPtr { - id: listing_event_id.clone(), - relays: None, + 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, + revision_event, + revision_decision_event, + ], }, - &payload, - ) - .expect("order request parts"); - let event = radroots_nostr_build_event(parts.kind, parts.content, parts.tags) - .expect("nostr event builder") - .sign_with_keys(buyer.keys()) - .expect("signed order request"); - - let entry = - order_history_entry_from_event(&event, seller_pubkey.as_str()).expect("history entry"); + ); - assert_eq!(entry.id, "ord_AAAAAAAAAAAAAAAAAAAAAg"); - assert_eq!(entry.state, "requested"); - assert_eq!(entry.event_kind, Some(3422)); - assert_eq!(entry.listing_addr.as_deref(), Some(listing_addr.as_str())); + assert_eq!(status_view.state, "accepted"); assert_eq!( - entry.listing_event_id.as_deref(), - Some(listing_event_id.as_str()) + status_view.last_event_id.as_deref(), + Some(revision_decision_event_id.as_str()) + ); + assert_eq!( + status_view.agreement_event_id.as_deref(), + Some(revision_decision_event_id.as_str()) + ); + assert_eq!( + status_view + .economics + .as_ref() + .expect("current economics") + .items[0] + .bin_count, + 3 ); - assert_eq!(entry.buyer_pubkey.as_deref(), Some(buyer_pubkey.as_str())); - assert_eq!(entry.seller_pubkey.as_deref(), Some(seller_pubkey.as_str())); - assert_eq!(entry.item_count, Some(1)); - } - - #[test] - fn order_request_filter_includes_order_id_d_tag_when_provided() { - let filter = order_request_filter("a", Some("ord_AAAAAAAAAAAAAAAAAAAAAg")) - .expect("order request filter"); - let value = serde_json::to_value(filter).expect("filter json"); - - assert_eq!(value["kinds"][0], 3422); - assert_eq!(value["#p"][0], "a"); - assert_eq!(value["#d"][0], "ord_AAAAAAAAAAAAAAAAAAAAAg"); - } - - #[test] - fn order_status_filter_includes_active_lifecycle_kinds() { - let filter = order_status_filter("ord_AAAAAAAAAAAAAAAAAAAAAg").expect("status filter"); - let value = serde_json::to_value(filter).expect("filter json"); - let kinds = value["kinds"].as_array().expect("kinds array"); - - assert!(kinds.contains(&serde_json::json!(3422))); - assert!(kinds.contains(&serde_json::json!(3423))); - assert!(kinds.contains(&serde_json::json!(3424))); - assert!(kinds.contains(&serde_json::json!(3433))); - assert!(kinds.contains(&serde_json::json!(3432))); - assert!(kinds.contains(&serde_json::json!(3434))); - assert_eq!(value["#d"][0], "ord_AAAAAAAAAAAAAAAAAAAAAg"); } #[test] - fn order_revision_payload_updates_items_and_economics() { + fn order_status_from_receipt_preserves_agreement_after_declined_revision_decision() { let fixture = order_status_fixture(); let decision_event = signed_order_decision_event( &fixture.seller, @@ -7854,44 +9148,59 @@ mod tests { }], }, ); + let decision_event_id = decision_event.id.to_string(); + let revision_event = signed_order_revision_proposal_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(), + 3, + ); + let revision_decision_event = signed_order_revision_decision_event( + &fixture.buyer, + &revision_event, + RadrootsTradeOrderRevisionDecision::Declined { + reason: "keep original order".to_owned(), + }, + ); + let revision_decision_event_id = revision_decision_event.id.to_string(); + 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()], + events: vec![ + fixture.request_event.clone(), + decision_event, + revision_event, + revision_decision_event, + ], }, ); - let args = revision_args_for_fixture(&fixture, 3); - - let payload = - order_revision_payload_from_status(&args, &status_view).expect("revision payload"); - let parts = - order_revision_event_parts(&status_view, &payload).expect("revision event parts"); - let context = active_trade_event_context_from_tags( - RadrootsActiveTradeMessageType::TradeOrderRevisionProposed, - &parts.tags, - ) - .expect("revision context"); - let request_event_id = fixture.request_event.id.to_string(); - let decision_event_id = decision_event.id.to_string(); - assert_eq!(payload.items[0].bin_id, "bin-1"); - assert_eq!(payload.items[0].bin_count, 3); - assert_eq!(payload.economics.items[0].bin_count, 3); - assert_eq!(payload.economics.quote_version, 2); - assert!(payload.economics.quote_id.starts_with("revision_rev_")); - assert_eq!(payload.reason, "update count"); - assert_eq!(parts.kind, KIND_TRADE_ORDER_REVISION); + assert_eq!(status_view.state, "accepted"); assert_eq!( - context.root_event_id.as_deref(), - Some(request_event_id.as_str()) + status_view.last_event_id.as_deref(), + Some(revision_decision_event_id.as_str()) ); assert_eq!( - context.prev_event_id.as_deref(), + status_view.agreement_event_id.as_deref(), Some(decision_event_id.as_str()) ); + assert_eq!( + status_view + .economics + .as_ref() + .expect("current economics") + .items[0] + .bin_count, + 2 + ); } #[test] @@ -10891,7 +12200,9 @@ mod tests { }, proposed_accept_decision_record(&request).expect("proposed accept decision"), ], - [], + Vec::<RadrootsActiveOrderRevisionProposalRecord>::new(), + Vec::<RadrootsActiveOrderRevisionDecisionRecord>::new(), + Vec::<RadrootsActiveOrderFulfillmentRecord>::new(), Vec::<RadrootsActiveOrderCancellationRecord>::new(), Vec::<RadrootsActiveOrderReceiptRecord>::new(), ); @@ -10988,6 +12299,8 @@ mod tests { }, proposed_accept_decision_record(&request).expect("proposed accept decision"), ], + Vec::<RadrootsActiveOrderRevisionProposalRecord>::new(), + Vec::<RadrootsActiveOrderRevisionDecisionRecord>::new(), vec![RadrootsActiveOrderFulfillmentRecord { event_id: "existing_fulfillment".to_owned(), author_pubkey: fixture.seller_pubkey.clone(), @@ -11620,6 +12933,24 @@ mod tests { } } + fn revision_decision_args_for_fixture( + fixture: &OrderStatusFixture, + revision_id: &str, + decision: OrderRevisionDecisionArg, + ) -> OrderRevisionDecisionArgs { + OrderRevisionDecisionArgs { + key: fixture.order_id.clone(), + revision_id: revision_id.to_owned(), + decision, + reason: if decision == OrderRevisionDecisionArg::Decline { + Some("keep original order".to_owned()) + } else { + None + }, + idempotency_key: None, + } + } + fn sample_config(root: &Path) -> RuntimeConfig { let data = root.join("data"); let logs = root.join("logs"); @@ -11813,6 +13144,39 @@ mod tests { .expect("signed order revision proposal") } + fn signed_order_revision_decision_event( + buyer: &RadrootsIdentity, + proposal_event: &radroots_nostr::prelude::RadrootsNostrEvent, + decision: RadrootsTradeOrderRevisionDecision, + ) -> radroots_nostr::prelude::RadrootsNostrEvent { + let proposal = radroots_event_from_nostr(proposal_event); + let envelope = + radroots_events_codec::trade::active_trade_order_revision_proposal_from_event( + &proposal, + ) + .expect("decoded revision proposal"); + let payload = RadrootsTradeOrderRevisionDecisionEvent { + revision_id: envelope.payload.revision_id.clone(), + order_id: envelope.payload.order_id.clone(), + listing_addr: envelope.payload.listing_addr.clone(), + buyer_pubkey: envelope.payload.buyer_pubkey.clone(), + seller_pubkey: envelope.payload.seller_pubkey.clone(), + root_event_id: envelope.payload.root_event_id.clone(), + prev_event_id: proposal_event.id.to_string(), + decision, + }; + let parts = active_trade_order_revision_decision_event_build( + payload.root_event_id.as_str(), + payload.prev_event_id.as_str(), + &payload, + ) + .expect("revision decision parts"); + radroots_nostr_build_event(parts.kind, parts.content, parts.tags) + .expect("nostr event builder") + .sign_with_keys(buyer.keys()) + .expect("signed order revision decision") + } + fn signed_fulfillment_update_event( seller: &RadrootsIdentity, request_event: &radroots_nostr::prelude::RadrootsNostrEvent, diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs @@ -6,6 +6,7 @@ use crate::runtime::accounts::{SHARED_ACCOUNT_STORE_SOURCE, empty_account_resolu use crate::runtime::config::{RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY, SignerBackend}; use radroots_events::kinds::{ KIND_FARM, KIND_LISTING, KIND_PROFILE, KIND_TRADE_ORDER_DECISION, KIND_TRADE_ORDER_REQUEST, + KIND_TRADE_ORDER_REVISION, KIND_TRADE_ORDER_REVISION_RESPONSE, }; use radroots_nostr_accounts::prelude::RadrootsNostrAccountStatus; use radroots_nostr_signer::prelude::{ @@ -274,7 +275,7 @@ fn deferred_myc_binding_status() -> SignerBindingStatusView { } } -fn cli_write_kinds() -> [CliWriteKind; 6] { +fn cli_write_kinds() -> [CliWriteKind; 9] { [ CliWriteKind { command: "farm profile publish", @@ -300,6 +301,18 @@ fn cli_write_kinds() -> [CliWriteKind; 6] { command: "order decline", event_kind: KIND_TRADE_ORDER_DECISION, }, + CliWriteKind { + command: "order revision propose", + event_kind: KIND_TRADE_ORDER_REVISION, + }, + CliWriteKind { + command: "order revision accept", + event_kind: KIND_TRADE_ORDER_REVISION_RESPONSE, + }, + CliWriteKind { + command: "order revision decline", + event_kind: KIND_TRADE_ORDER_REVISION_RESPONSE, + }, ] } @@ -343,7 +356,10 @@ fn local_availability(value: RadrootsNostrLocalSignerAvailability) -> &'static s mod tests { use radroots_events::kinds::KIND_TRADE_FORBIDDEN_3431; - use super::{KIND_TRADE_ORDER_DECISION, KIND_TRADE_ORDER_REQUEST, cli_write_kinds}; + use super::{ + KIND_TRADE_ORDER_DECISION, KIND_TRADE_ORDER_REQUEST, KIND_TRADE_ORDER_REVISION, + KIND_TRADE_ORDER_REVISION_RESPONSE, cli_write_kinds, + }; #[test] fn order_submit_readiness_uses_active_order_request_kind() { @@ -368,4 +384,25 @@ mod tests { assert_ne!(write_kind.event_kind, KIND_TRADE_FORBIDDEN_3431); } } + + #[test] + fn order_revision_readiness_uses_active_revision_kinds() { + let proposal = cli_write_kinds() + .into_iter() + .find(|kind| kind.command == "order revision propose") + .expect("order revision propose readiness"); + + assert_eq!(proposal.event_kind, KIND_TRADE_ORDER_REVISION); + assert_ne!(proposal.event_kind, KIND_TRADE_FORBIDDEN_3431); + + for command in ["order revision accept", "order revision decline"] { + let write_kind = cli_write_kinds() + .into_iter() + .find(|kind| kind.command == command) + .expect("order revision decision readiness"); + + assert_eq!(write_kind.event_kind, KIND_TRADE_ORDER_REVISION_RESPONSE); + assert_ne!(write_kind.event_kind, KIND_TRADE_FORBIDDEN_3431); + } + } } diff --git a/src/runtime_args.rs b/src/runtime_args.rs @@ -258,6 +258,37 @@ pub struct OrderRevisionProposeArgs { pub idempotency_key: Option<String>, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OrderRevisionDecisionArg { + Accept, + Decline, +} + +impl OrderRevisionDecisionArg { + pub fn as_str(self) -> &'static str { + match self { + Self::Accept => "accepted", + Self::Decline => "declined", + } + } + + pub fn command(self) -> &'static str { + match self { + Self::Accept => "accept", + Self::Decline => "decline", + } + } +} + +#[derive(Debug, Clone)] +pub struct OrderRevisionDecisionArgs { + pub key: String, + pub revision_id: String, + pub decision: OrderRevisionDecisionArg, + pub reason: Option<String>, + pub idempotency_key: Option<String>, +} + #[derive(Debug, Clone)] pub struct OrderStatusArgs { pub key: String, diff --git a/src/target_cli.rs b/src/target_cli.rs @@ -181,6 +181,8 @@ impl TargetCommand { OrderCommand::Cancel(_) => "order.cancel", OrderCommand::Revision(revision) => match &revision.command { OrderRevisionCommand::Propose(_) => "order.revision.propose", + OrderRevisionCommand::Accept(_) => "order.revision.accept", + OrderRevisionCommand::Decline(_) => "order.revision.decline", }, OrderCommand::Fulfillment(fulfillment) => match &fulfillment.command { OrderFulfillmentCommand::Update(_) => "order.fulfillment.update", @@ -787,6 +789,8 @@ pub struct OrderRevisionArgs { #[derive(Debug, Clone, Subcommand)] pub enum OrderRevisionCommand { Propose(OrderRevisionProposeArgs), + Accept(OrderRevisionDecisionArgs), + Decline(OrderRevisionDeclineArgs), } #[derive(Debug, Clone, Args)] @@ -811,6 +815,22 @@ pub struct OrderRevisionProposeArgs { } #[derive(Debug, Clone, Args)] +pub struct OrderRevisionDecisionArgs { + pub order_id: Option<String>, + #[arg(long)] + pub revision_id: Option<String>, +} + +#[derive(Debug, Clone, Args)] +pub struct OrderRevisionDeclineArgs { + pub order_id: Option<String>, + #[arg(long)] + pub revision_id: Option<String>, + #[arg(long)] + pub reason: Option<String>, +} + +#[derive(Debug, Clone, Args)] pub struct OrderFulfillmentArgs { #[command(subcommand)] pub command: OrderFulfillmentCommand, @@ -1077,7 +1097,9 @@ mod tests { let OrderCommand::Revision(revision) = order.command else { panic!("expected order revision command") }; - let OrderRevisionCommand::Propose(args) = revision.command; + let OrderRevisionCommand::Propose(args) = revision.command else { + panic!("expected order revision propose command") + }; assert_eq!(args.order_id.as_deref(), Some("ord_test")); assert_eq!(args.reason.as_deref(), Some("update count")); assert_eq!(args.bin_id.as_deref(), Some("bin-1")); @@ -1087,6 +1109,60 @@ mod tests { } #[test] + fn target_parser_accepts_order_revision_decision_inputs() { + let accepted = TargetCliArgs::try_parse_from([ + "radroots", + "order", + "revision", + "accept", + "ord_test", + "--revision-id", + "rev_test", + ]) + .expect("target args parse"); + + assert_eq!(accepted.command.operation_id(), "order.revision.accept"); + let crate::target_cli::TargetCommand::Order(order) = accepted.command else { + panic!("expected order command") + }; + let OrderCommand::Revision(revision) = order.command else { + panic!("expected order revision command") + }; + let OrderRevisionCommand::Accept(args) = revision.command else { + panic!("expected order revision accept command") + }; + assert_eq!(args.order_id.as_deref(), Some("ord_test")); + assert_eq!(args.revision_id.as_deref(), Some("rev_test")); + + let declined = TargetCliArgs::try_parse_from([ + "radroots", + "order", + "revision", + "decline", + "ord_test", + "--revision-id", + "rev_test", + "--reason", + "keep original order", + ]) + .expect("target args parse"); + + assert_eq!(declined.command.operation_id(), "order.revision.decline"); + let crate::target_cli::TargetCommand::Order(order) = declined.command else { + panic!("expected order command") + }; + let OrderCommand::Revision(revision) = order.command else { + panic!("expected order revision command") + }; + let OrderRevisionCommand::Decline(args) = revision.command else { + panic!("expected order revision decline command") + }; + assert_eq!(args.order_id.as_deref(), Some("ord_test")); + assert_eq!(args.revision_id.as_deref(), Some("rev_test")); + assert_eq!(args.reason.as_deref(), Some("keep original order")); + } + + #[test] fn target_parser_accepts_order_receipt_record_outcomes() { let received = TargetCliArgs::try_parse_from([ "radroots", diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -1267,6 +1267,32 @@ fn required_approval_token_rejects_absent_empty_and_whitespace_values() { ); assert_required_approval_token_rejected( &sandbox, + "order.revision.accept", + &[ + "order", + "revision", + "accept", + "ord_pending", + "--revision-id", + "rev_pending", + ], + ); + assert_required_approval_token_rejected( + &sandbox, + "order.revision.decline", + &[ + "order", + "revision", + "decline", + "ord_pending", + "--revision-id", + "rev_pending", + "--reason", + "keep original order", + ], + ); + assert_required_approval_token_rejected( + &sandbox, "order.fulfillment.update", &[ "order",