commit 6b51c5964ee79ab4e016f6194eb7da7814c27765
parent 623d1c0e8d8490daf436edf6d1ec8cd56eae81cb
Author: triesap <tyson@radroots.org>
Date: Thu, 30 Apr 2026 08:03:15 +0000
order: add revision decisions
Diffstat:
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",