commit a8b7622b840d8be34f2a4873ff3ef0bfeefe05e0
parent c4b37da68337ec0701b605d5e6c979bdd8659b1c
Author: triesap <tyson@radroots.org>
Date: Sun, 10 May 2026 03:13:53 +0000
order: add explicit draft rebind
- add order rebind parser and operation contract
- preview and write bound buyer actor changes
- mint order ids when buyer pubkeys change
- refuse relay-visible published order requests
Diffstat:
9 files changed, 751 insertions(+), 16 deletions(-)
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -1338,6 +1338,46 @@ impl OrderSubmitView {
}
#[derive(Debug, Clone, Serialize)]
+pub struct OrderRebindView {
+ pub state: String,
+ pub source: String,
+ pub lookup: String,
+ pub file: String,
+ pub dry_run: bool,
+ pub from_order_id: String,
+ pub to_order_id: String,
+ pub order_id_changed: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub from_buyer_account_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub from_buyer_pubkey: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub from_buyer_actor_source: Option<String>,
+ pub to_buyer_account_id: String,
+ pub to_buyer_pubkey: String,
+ pub to_buyer_actor_source: String,
+ pub buyer_pubkey_changed: bool,
+ pub existing_request_check: String,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub existing_request_event_ids: Vec<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub reason: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub actions: Vec<String>,
+}
+
+impl OrderRebindView {
+ pub fn disposition(&self) -> CommandDisposition {
+ match self.state.as_str() {
+ "missing" => CommandDisposition::NotFound,
+ "invalid" => CommandDisposition::ValidationFailed,
+ "error" => CommandDisposition::InternalError,
+ _ => CommandDisposition::Success,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
pub struct OrderDecisionView {
pub state: String,
pub source: String,
diff --git a/src/main.rs b/src/main.rs
@@ -288,6 +288,9 @@ fn execute_request(
TargetOperationRequest::OrderList(request) => {
execute_with(OrderOperationService::new(config), request)
}
+ TargetOperationRequest::OrderRebind(request) => {
+ execute_with(OrderOperationService::new(config), request)
+ }
TargetOperationRequest::OrderAccept(request) => {
execute_with(OrderOperationService::new(config), request)
}
diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs
@@ -1455,6 +1455,10 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati
insert_string(&mut input, "order_id", &args.order_id);
}
OrderCommand::Get(args) => insert_string(&mut input, "order_id", &args.order_id),
+ OrderCommand::Rebind(args) => {
+ insert_string(&mut input, "order_id", &args.order_id);
+ insert_string(&mut input, "selector", &args.selector);
+ }
OrderCommand::Accept(args) => insert_string(&mut input, "order_id", &args.order_id),
OrderCommand::Decline(args) => {
insert_string(&mut input, "order_id", &args.order_id);
@@ -1643,6 +1647,7 @@ target_operation_contracts! {
OrderSubmit => (OrderSubmitRequest, OrderSubmitResult, "order.submit"),
OrderGet => (OrderGetRequest, OrderGetResult, "order.get"),
OrderList => (OrderListRequest, OrderListResult, "order.list"),
+ OrderRebind => (OrderRebindRequest, OrderRebindResult, "order.rebind"),
OrderAccept => (OrderAcceptRequest, OrderAcceptResult, "order.accept"),
OrderDecline => (OrderDeclineRequest, OrderDeclineResult, "order.decline"),
OrderCancel => (OrderCancelRequest, OrderCancelResult, "order.cancel"),
@@ -1866,6 +1871,37 @@ mod tests {
}
#[test]
+ fn adapter_maps_order_rebind_inputs() {
+ let parsed =
+ TargetCliArgs::try_parse_from(["radroots", "order", "rebind", "ord_test", "acct_test"])
+ .expect("target args parse");
+
+ let request = TargetOperationRequest::from_target_args(&parsed)
+ .expect("operation request from target args");
+ let TargetOperationRequest::OrderRebind(request) = request else {
+ panic!("expected order rebind request")
+ };
+
+ assert_eq!(request.operation_id(), "order.rebind");
+ assert_eq!(
+ request
+ .payload
+ .input
+ .get("order_id")
+ .and_then(Value::as_str),
+ Some("ord_test")
+ );
+ assert_eq!(
+ request
+ .payload
+ .input
+ .get("selector")
+ .and_then(Value::as_str),
+ Some("acct_test")
+ );
+ }
+
+ #[test]
fn adapter_maps_order_fulfillment_update_input() {
let parsed = TargetCliArgs::try_parse_from([
"radroots",
diff --git a/src/operation_order.rs b/src/operation_order.rs
@@ -4,8 +4,8 @@ use serde_json::{Value, json};
use crate::deferred_payment::deferred_payment_message;
use crate::domain::runtime::{
CommandDisposition, OrderCancellationView, OrderDecisionView, OrderFulfillmentView,
- OrderReceiptView, OrderRevisionDecisionView, OrderRevisionProposalView, OrderStatusView,
- OrderSubmitView,
+ OrderRebindView, OrderReceiptView, OrderRevisionDecisionView, OrderRevisionProposalView,
+ OrderStatusView, OrderSubmitView,
};
use crate::operation_adapter::{
OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload,
@@ -14,18 +14,19 @@ use crate::operation_adapter::{
OrderEventListRequest, OrderEventListResult, OrderEventWatchRequest, OrderEventWatchResult,
OrderFulfillmentUpdateRequest, OrderFulfillmentUpdateResult, OrderGetRequest, OrderGetResult,
OrderListRequest, OrderListResult, OrderPaymentRecordRequest, OrderPaymentRecordResult,
- OrderReceiptRecordRequest, OrderReceiptRecordResult, OrderRevisionAcceptRequest,
- OrderRevisionAcceptResult, OrderRevisionDeclineRequest, OrderRevisionDeclineResult,
- OrderRevisionProposeRequest, OrderRevisionProposeResult, OrderSettlementAcceptRequest,
- OrderSettlementAcceptResult, OrderSettlementRejectRequest, OrderSettlementRejectResult,
- OrderStatusGetRequest, OrderStatusGetResult, OrderSubmitRequest, OrderSubmitResult,
+ OrderRebindRequest, OrderRebindResult, OrderReceiptRecordRequest, OrderReceiptRecordResult,
+ OrderRevisionAcceptRequest, OrderRevisionAcceptResult, OrderRevisionDeclineRequest,
+ OrderRevisionDeclineResult, OrderRevisionProposeRequest, OrderRevisionProposeResult,
+ OrderSettlementAcceptRequest, OrderSettlementAcceptResult, OrderSettlementRejectRequest,
+ OrderSettlementRejectResult, OrderStatusGetRequest, OrderStatusGetResult, OrderSubmitRequest,
+ OrderSubmitResult,
};
use crate::runtime::RuntimeError;
use crate::runtime::config::RuntimeConfig;
use crate::runtime_args::{
- OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderFulfillmentArgs, OrderReceiptArgs,
- OrderRevisionDecisionArg, OrderRevisionDecisionArgs, OrderRevisionProposeArgs, OrderStatusArgs,
- OrderSubmitArgs, RecordLookupArgs,
+ OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderFulfillmentArgs, OrderRebindArgs,
+ OrderReceiptArgs, OrderRevisionDecisionArg, OrderRevisionDecisionArgs,
+ OrderRevisionProposeArgs, OrderStatusArgs, OrderSubmitArgs, RecordLookupArgs,
};
const ORDER_EVENT_WATCH_DEFERRED_REASON: &str = "relay-backed order event watch is not implemented";
@@ -100,6 +101,37 @@ impl OperationService<OrderListRequest> for OrderOperationService<'_> {
}
}
+impl OperationService<OrderRebindRequest> for OrderOperationService<'_> {
+ type Result = OrderRebindResult;
+
+ fn execute(
+ &self,
+ request: OperationRequest<OrderRebindRequest>,
+ ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
+ let args = OrderRebindArgs {
+ key: required_order_key(&request)?,
+ selector: required_string_input(&request, "selector")?,
+ };
+ if request.context.dry_run {
+ let view =
+ crate::runtime::order::rebind_preflight(self.config, &args).map_err(|error| {
+ OperationAdapterError::runtime_failure(request.operation_id(), error)
+ })?;
+ return order_rebind_result::<OrderRebindResult>(request.operation_id(), &view);
+ }
+ if request.context.requires_approval_token() {
+ return Err(OperationAdapterError::approval_required(
+ request.operation_id(),
+ ));
+ }
+
+ let view = crate::runtime::order::rebind(self.config, &args).map_err(|error| {
+ OperationAdapterError::runtime_failure(request.operation_id(), error)
+ })?;
+ order_rebind_result::<OrderRebindResult>(request.operation_id(), &view)
+ }
+}
+
impl OperationService<OrderAcceptRequest> for OrderOperationService<'_> {
type Result = OrderAcceptResult;
@@ -1218,6 +1250,64 @@ where
}
}
+fn order_rebind_result<R>(
+ operation_id: &str,
+ view: &OrderRebindView,
+) -> Result<OperationResult<R>, OperationAdapterError>
+where
+ R: OperationResultData,
+{
+ match view.disposition() {
+ CommandDisposition::Success => serialized_target_result::<R, _>(view),
+ CommandDisposition::NotFound => Err(OperationAdapterError::not_found_with_detail(
+ operation_id,
+ view.reason
+ .clone()
+ .unwrap_or_else(|| format!("order draft `{}` was not found", view.lookup)),
+ order_rebind_error_detail(view),
+ )),
+ CommandDisposition::ValidationFailed => {
+ Err(OperationAdapterError::validation_failed_with_detail(
+ operation_id,
+ view.reason.clone().unwrap_or_else(|| {
+ format!("order rebind finished with state `{}`", view.state)
+ }),
+ order_rebind_error_detail(view),
+ ))
+ }
+ disposition => Err(OperationAdapterError::from_command_disposition(
+ operation_id,
+ disposition,
+ view.reason
+ .clone()
+ .unwrap_or_else(|| format!("order rebind finished with state `{}`", view.state)),
+ )),
+ }
+}
+
+fn order_rebind_error_detail(view: &OrderRebindView) -> Value {
+ json!({
+ "state": &view.state,
+ "source": &view.source,
+ "lookup": &view.lookup,
+ "file": &view.file,
+ "dry_run": view.dry_run,
+ "from_order_id": &view.from_order_id,
+ "to_order_id": &view.to_order_id,
+ "order_id_changed": view.order_id_changed,
+ "from_buyer_account_id": &view.from_buyer_account_id,
+ "from_buyer_pubkey": &view.from_buyer_pubkey,
+ "from_buyer_actor_source": &view.from_buyer_actor_source,
+ "to_buyer_account_id": &view.to_buyer_account_id,
+ "to_buyer_pubkey": &view.to_buyer_pubkey,
+ "to_buyer_actor_source": &view.to_buyer_actor_source,
+ "buyer_pubkey_changed": view.buyer_pubkey_changed,
+ "existing_request_check": &view.existing_request_check,
+ "existing_request_event_ids": &view.existing_request_event_ids,
+ "actions": &view.actions,
+ })
+}
+
fn order_submit_error_detail(view: &OrderSubmitView) -> Value {
json!({
"state": &view.state,
diff --git a/src/operation_registry.rs b/src/operation_registry.rs
@@ -923,6 +923,21 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[
false
),
operation!(
+ "order.rebind",
+ "radroots order rebind",
+ "order",
+ "order_rebind",
+ "OrderRebindRequest",
+ "OrderRebindResult",
+ "Rebind a local order draft to an explicit buyer actor.",
+ Buyer,
+ true,
+ Required,
+ High,
+ false,
+ true
+ ),
+ operation!(
"order.accept",
"radroots order accept",
"order",
@@ -1273,6 +1288,7 @@ mod tests {
"order.submit",
"order.get",
"order.list",
+ "order.rebind",
"order.accept",
"order.decline",
"order.cancel",
@@ -1321,6 +1337,7 @@ mod tests {
"basket.adjustment.remove",
"basket.quote.create",
"order.submit",
+ "order.rebind",
"order.accept",
"order.decline",
"order.cancel",
@@ -1392,6 +1409,7 @@ mod tests {
"listing.publish",
"listing.archive",
"order.submit",
+ "order.rebind",
"order.accept",
"order.decline",
"order.cancel",
diff --git a/src/runtime/order.rs b/src/runtime/order.rs
@@ -84,8 +84,8 @@ use crate::domain::runtime::{
OrderCancellationView, OrderDecisionView, OrderDraftItemView, OrderEventListEntryView,
OrderEventListView, OrderFulfillmentView, OrderGetView, OrderInventoryBinView,
OrderInventoryView, OrderIssueView, OrderListView, OrderNewView, OrderPaymentView,
- OrderReceiptView, OrderRevisionDecisionView, OrderRevisionProposalView, OrderSettlementView,
- OrderStatusFulfillmentView, OrderStatusLifecycleCancellationView,
+ OrderRebindView, OrderReceiptView, OrderRevisionDecisionView, OrderRevisionProposalView,
+ OrderSettlementView, OrderStatusFulfillmentView, OrderStatusLifecycleCancellationView,
OrderStatusLifecycleReceiptView, OrderStatusLifecycleView, OrderStatusPaymentView,
OrderStatusRevisionView, OrderStatusView, OrderSubmitView, OrderSummaryView, RelayFailureView,
};
@@ -102,9 +102,10 @@ use crate::runtime::sync::{
};
use crate::runtime_args::{
OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderDraftCreateArgs,
- OrderFulfillmentArgs, OrderPaymentArgs, OrderReceiptArgs, OrderRevisionDecisionArg,
- OrderRevisionDecisionArgs, OrderRevisionProposeArgs, OrderSettlementArgs,
- OrderSettlementDecisionArg, OrderStatusArgs, OrderSubmitArgs, RecordLookupArgs,
+ OrderFulfillmentArgs, OrderPaymentArgs, OrderRebindArgs, OrderReceiptArgs,
+ OrderRevisionDecisionArg, OrderRevisionDecisionArgs, OrderRevisionProposeArgs,
+ OrderSettlementArgs, OrderSettlementDecisionArg, OrderStatusArgs, OrderSubmitArgs,
+ RecordLookupArgs,
};
const ORDER_DRAFT_KIND: &str = "order_draft_v1";
@@ -274,6 +275,12 @@ struct OrderDecisionInventoryPreflight {
}
#[derive(Debug, Clone)]
+struct OrderRebindExistingRequestCheck {
+ state: String,
+ event_ids: Vec<String>,
+}
+
+#[derive(Debug, Clone)]
struct SellerOrderRequestResolution {
target_relays: Vec<String>,
connected_relays: Vec<String>,
@@ -735,6 +742,168 @@ pub fn submit(
}
}
+pub fn rebind(
+ config: &RuntimeConfig,
+ args: &OrderRebindArgs,
+) -> Result<OrderRebindView, RuntimeError> {
+ rebind_inner(config, args, false)
+}
+
+pub fn rebind_preflight(
+ config: &RuntimeConfig,
+ args: &OrderRebindArgs,
+) -> Result<OrderRebindView, RuntimeError> {
+ rebind_inner(config, args, true)
+}
+
+fn rebind_inner(
+ config: &RuntimeConfig,
+ args: &OrderRebindArgs,
+ dry_run: bool,
+) -> Result<OrderRebindView, RuntimeError> {
+ let file = draft_lookup_path(config, args.key.as_str());
+ if !file.exists() {
+ return Ok(OrderRebindView {
+ state: "missing".to_owned(),
+ source: ORDER_SOURCE.to_owned(),
+ lookup: args.key.clone(),
+ file: file.display().to_string(),
+ dry_run,
+ from_order_id: args.key.clone(),
+ to_order_id: args.key.clone(),
+ order_id_changed: false,
+ from_buyer_account_id: None,
+ from_buyer_pubkey: None,
+ from_buyer_actor_source: None,
+ to_buyer_account_id: args.selector.clone(),
+ to_buyer_pubkey: String::new(),
+ to_buyer_actor_source: ORDER_BUYER_ACTOR_SOURCE_REBIND.to_owned(),
+ buyer_pubkey_changed: false,
+ existing_request_check: "not_checked".to_owned(),
+ existing_request_event_ids: Vec::new(),
+ reason: Some(format!("order draft `{}` was not found", args.key)),
+ actions: vec![
+ "radroots order list".to_owned(),
+ "radroots basket create".to_owned(),
+ ],
+ });
+ }
+
+ let loaded = load_draft(file.as_path()).map_err(RuntimeError::Config)?;
+ let target_account = accounts::resolve_account_selector(config, args.selector.as_str())
+ .map_err(|error| order_rebind_selector_error(args.selector.as_str(), error))?;
+ let existing_request = order_rebind_existing_request_check(config, &loaded)?;
+ let from_order_id = loaded.document.order.order_id.clone();
+ let from_buyer_account_id = buyer_account_id(&loaded.document);
+ let from_buyer_pubkey = non_empty_string(loaded.document.buyer_actor.pubkey.clone());
+ let from_buyer_actor_source = buyer_actor_source(&loaded.document);
+ let target_account_id = target_account.record.account_id.to_string();
+ let target_pubkey = target_account.record.public_identity.public_key_hex.clone();
+ let current_buyer_pubkey = from_buyer_pubkey
+ .clone()
+ .or_else(|| non_empty_string(loaded.document.order.buyer_pubkey.clone()));
+ let buyer_pubkey_changed = current_buyer_pubkey
+ .as_deref()
+ .is_none_or(|pubkey| !pubkey.eq_ignore_ascii_case(target_pubkey.as_str()));
+
+ if !existing_request.event_ids.is_empty() {
+ return Ok(OrderRebindView {
+ state: "invalid".to_owned(),
+ source: ORDER_SOURCE.to_owned(),
+ lookup: args.key.clone(),
+ file: loaded.file.display().to_string(),
+ dry_run,
+ from_order_id: from_order_id.clone(),
+ to_order_id: from_order_id.clone(),
+ order_id_changed: false,
+ from_buyer_account_id,
+ from_buyer_pubkey,
+ from_buyer_actor_source,
+ to_buyer_account_id: target_account_id,
+ to_buyer_pubkey: target_pubkey,
+ to_buyer_actor_source: ORDER_BUYER_ACTOR_SOURCE_REBIND.to_owned(),
+ buyer_pubkey_changed,
+ existing_request_check: existing_request.state,
+ existing_request_event_ids: existing_request.event_ids,
+ reason: Some(
+ "order rebind refused because a valid order request is already visible for this order id"
+ .to_owned(),
+ ),
+ actions: vec![
+ format!("radroots order status get {from_order_id}"),
+ "radroots basket quote create <basket-id>".to_owned(),
+ ],
+ });
+ }
+
+ let mut document = loaded.document.clone();
+ let to_order_id = if buyer_pubkey_changed {
+ next_order_id()
+ } else {
+ from_order_id.clone()
+ };
+ let order_id_changed = to_order_id != from_order_id;
+ document.order.order_id = to_order_id.clone();
+ document.order.buyer_pubkey = target_pubkey.clone();
+ document.buyer_actor.account_id = target_account_id.clone();
+ document.buyer_actor.pubkey = target_pubkey.clone();
+ document.buyer_actor.source = ORDER_BUYER_ACTOR_SOURCE_REBIND.to_owned();
+ if order_id_changed && let Some(economics) = document.order.economics.as_mut() {
+ economics.quote_id = format!("quote_{to_order_id}");
+ }
+
+ let output_file = if order_id_changed {
+ drafts_dir(config).join(format!("{to_order_id}.toml"))
+ } else {
+ loaded.file.clone()
+ };
+ if !dry_run {
+ if order_id_changed && output_file.exists() {
+ return Err(RuntimeError::Config(format!(
+ "order rebind target file {} already exists",
+ output_file.display()
+ )));
+ }
+ save_draft(output_file.as_path(), &document)?;
+ if order_id_changed && output_file != loaded.file {
+ fs::remove_file(loaded.file.as_path())?;
+ }
+ }
+
+ Ok(OrderRebindView {
+ state: if dry_run { "dry_run" } else { "rebound" }.to_owned(),
+ source: ORDER_SOURCE.to_owned(),
+ lookup: args.key.clone(),
+ file: output_file.display().to_string(),
+ dry_run,
+ from_order_id: from_order_id.clone(),
+ to_order_id: to_order_id.clone(),
+ order_id_changed,
+ from_buyer_account_id,
+ from_buyer_pubkey,
+ from_buyer_actor_source,
+ to_buyer_account_id: target_account_id,
+ to_buyer_pubkey: target_pubkey,
+ to_buyer_actor_source: ORDER_BUYER_ACTOR_SOURCE_REBIND.to_owned(),
+ buyer_pubkey_changed,
+ existing_request_check: existing_request.state,
+ existing_request_event_ids: Vec::new(),
+ reason: Some(if dry_run {
+ "dry run requested; order buyer actor binding was not written".to_owned()
+ } else {
+ "order buyer actor binding updated".to_owned()
+ }),
+ actions: if dry_run {
+ vec![format!(
+ "radroots --approval-token approve order rebind {} {}",
+ args.key, args.selector
+ )]
+ } else {
+ vec![format!("radroots order get {to_order_id}")]
+ },
+ })
+}
+
pub fn event_list(
config: &RuntimeConfig,
order_id: Option<&str>,
@@ -9196,6 +9365,65 @@ fn actions_for_document(
deduped
}
+fn order_rebind_selector_error(selector: &str, error: RuntimeError) -> RuntimeError {
+ match error {
+ RuntimeError::Accounts(_) | RuntimeError::Account(_) => {
+ accounts::AccountRuntimeFailure::unresolved_with_detail(
+ format!("order rebind target selector `{selector}` did not resolve"),
+ json!({
+ "selector": selector,
+ "actions": [
+ "radroots account list",
+ "radroots account import <path>",
+ "radroots account create",
+ ],
+ }),
+ )
+ .into()
+ }
+ other => other,
+ }
+}
+
+fn order_rebind_existing_request_check(
+ config: &RuntimeConfig,
+ loaded: &LoadedOrderDraft,
+) -> Result<OrderRebindExistingRequestCheck, RuntimeError> {
+ if config.relay.urls.is_empty() {
+ return Ok(OrderRebindExistingRequestCheck {
+ state: "skipped_no_relays".to_owned(),
+ event_ids: Vec::new(),
+ });
+ }
+
+ let filter = order_request_filter(
+ loaded.document.order.seller_pubkey.as_str(),
+ Some(loaded.document.order.order_id.as_str()),
+ )?;
+ let receipt = fetch_events_from_relays(&config.relay.urls, filter)
+ .map_err(|error| RuntimeError::Network(error.to_string()))?;
+ let mut event_ids = receipt
+ .events
+ .iter()
+ .filter_map(|event| {
+ order_submit_request_from_event(event, loaded)
+ .ok()
+ .map(|request| request.request_event_id)
+ })
+ .collect::<Vec<_>>();
+ event_ids.sort();
+ event_ids.dedup();
+
+ Ok(OrderRebindExistingRequestCheck {
+ state: if event_ids.is_empty() {
+ "clear".to_owned()
+ } else {
+ "blocked_existing_request".to_owned()
+ },
+ event_ids,
+ })
+}
+
fn resolve_initial_buyer_actor(
config: &RuntimeConfig,
) -> Result<OrderDraftBuyerActor, RuntimeError> {
diff --git a/src/runtime_args.rs b/src/runtime_args.rs
@@ -206,6 +206,12 @@ pub struct OrderSubmitArgs {
pub idempotency_key: Option<String>,
}
+#[derive(Debug, Clone)]
+pub struct OrderRebindArgs {
+ pub key: String,
+ pub selector: String,
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OrderDecisionArg {
Accept,
diff --git a/src/target_cli.rs b/src/target_cli.rs
@@ -220,6 +220,7 @@ impl TargetCommand {
OrderCommand::Submit(_) => "order.submit",
OrderCommand::Get(_) => "order.get",
OrderCommand::List => "order.list",
+ OrderCommand::Rebind(_) => "order.rebind",
OrderCommand::Accept(_) => "order.accept",
OrderCommand::Decline(_) => "order.decline",
OrderCommand::Cancel(_) => "order.cancel",
@@ -821,6 +822,7 @@ pub enum OrderCommand {
Submit(OrderSubmitArgs),
Get(OrderKeyArgs),
List,
+ Rebind(OrderRebindArgs),
Accept(OrderKeyArgs),
Decline(OrderDeclineArgs),
Cancel(OrderCancelArgs),
@@ -844,6 +846,12 @@ pub struct OrderKeyArgs {
}
#[derive(Debug, Clone, Args)]
+pub struct OrderRebindArgs {
+ pub order_id: Option<String>,
+ pub selector: Option<String>,
+}
+
+#[derive(Debug, Clone, Args)]
pub struct OrderDeclineArgs {
pub order_id: Option<String>,
#[arg(long)]
@@ -1218,6 +1226,23 @@ mod tests {
}
#[test]
+ fn target_parser_accepts_order_rebind_inputs() {
+ let parsed =
+ TargetCliArgs::try_parse_from(["radroots", "order", "rebind", "ord_test", "acct_test"])
+ .expect("target args parse");
+
+ assert_eq!(parsed.command.operation_id(), "order.rebind");
+ let crate::target_cli::TargetCommand::Order(order) = parsed.command else {
+ panic!("expected order command")
+ };
+ let OrderCommand::Rebind(args) = order.command else {
+ panic!("expected order rebind command")
+ };
+ assert_eq!(args.order_id.as_deref(), Some("ord_test"));
+ assert_eq!(args.selector.as_deref(), Some("acct_test"));
+ }
+
+ #[test]
fn target_parser_accepts_order_fulfillment_update_state() {
let parsed = TargetCliArgs::try_parse_from([
"radroots",
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -8,7 +8,13 @@ use std::sync::mpsc::{self, Receiver};
use std::thread::{self, JoinHandle};
use std::time::Duration;
+use radroots_events::RadrootsNostrEventPtr;
use radroots_events::kinds::{KIND_FARM, KIND_PROFILE};
+use radroots_events::trade::{
+ RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, RadrootsTradeOrderRequested,
+};
+use radroots_events_codec::trade::active_trade_order_request_event_build;
+use radroots_nostr::prelude::{RadrootsNostrEvent, radroots_nostr_build_event};
use radroots_replica_db::{farm, farm_member_claim, migrations};
use radroots_replica_db_schema::farm::IFarmFields;
use radroots_replica_db_schema::farm_member_claim::IFarmMemberClaimFields;
@@ -21,7 +27,7 @@ use serde_json::json;
use support::{
RadrootsCliSandbox, assert_contains, assert_no_daemon_runtime_reference,
- assert_no_removed_command_reference, create_listing_draft, identity_public,
+ assert_no_removed_command_reference, create_listing_draft, identity_public, identity_secret,
make_listing_publishable, make_listing_publishable_with_seller, ndjson_from_stdout, radroots,
remove_orderable_listing, replace_latest_listing_event_id, seed_orderable_listing, toml_string,
write_public_identity_profile,
@@ -258,6 +264,66 @@ impl RelayPublishServer {
}
}
+struct RelayFetchServer {
+ endpoint: String,
+ handle: JoinHandle<()>,
+}
+
+impl RelayFetchServer {
+ fn with_events(events: Vec<RadrootsNostrEvent>) -> Self {
+ let listener = TcpListener::bind("127.0.0.1:0").expect("bind relay fetch");
+ let endpoint = format!("ws://{}", listener.local_addr().expect("relay fetch addr"));
+ let handle = thread::spawn(move || {
+ let (stream, _) = listener.accept().expect("accept relay fetch connection");
+ handle_relay_fetch_connection(stream, events);
+ });
+ Self { endpoint, handle }
+ }
+
+ fn endpoint(&self) -> &str {
+ self.endpoint.as_str()
+ }
+
+ fn join(self) {
+ self.handle.join().expect("relay fetch server join");
+ }
+}
+
+fn handle_relay_fetch_connection(stream: TcpStream, events: Vec<RadrootsNostrEvent>) {
+ let mut websocket = tungstenite::accept(stream).expect("accept fetch websocket");
+ let subscription_id = read_relay_req_subscription_id(&mut websocket);
+ for event in events {
+ websocket
+ .send(tungstenite::Message::Text(
+ json!(["EVENT", subscription_id, event]).to_string().into(),
+ ))
+ .expect("relay event send");
+ }
+ websocket
+ .send(tungstenite::Message::Text(
+ json!(["EOSE", subscription_id]).to_string().into(),
+ ))
+ .expect("relay eose send");
+}
+
+fn read_relay_req_subscription_id(websocket: &mut tungstenite::WebSocket<TcpStream>) -> String {
+ loop {
+ let message = websocket.read().expect("relay req message");
+ if !message.is_text() {
+ continue;
+ }
+ let value: Value =
+ serde_json::from_str(message.to_text().expect("relay req text")).expect("relay json");
+ if value.get(0).and_then(Value::as_str) == Some("REQ") {
+ return value
+ .get(1)
+ .and_then(Value::as_str)
+ .expect("subscription id")
+ .to_owned();
+ }
+ }
+}
+
fn handle_relay_publish_connection(
stream: TcpStream,
accepted: bool,
@@ -3639,6 +3705,11 @@ fn required_approval_token_rejects_absent_empty_and_whitespace_values() {
&["--relay", "ws://127.0.0.1:9", "sync", "push"],
);
assert_required_approval_token_rejected(&sandbox, "order.submit", &["order", "submit"]);
+ assert_required_approval_token_rejected(
+ &sandbox,
+ "order.rebind",
+ &["order", "rebind", "ord_missing", "acct_missing"],
+ );
assert_required_approval_token_rejected(&sandbox, "order.accept", &["order", "accept"]);
assert_required_approval_token_rejected(
&sandbox,
@@ -3852,6 +3923,39 @@ fn line_indent(line: &str) -> &str {
&line[..line.len() - trimmed.len()]
}
+fn signed_order_request_event_for_quote(
+ buyer: &radroots_identity::RadrootsIdentity,
+ order_id: &str,
+ listing_event_id: &str,
+ economics: RadrootsTradeOrderEconomics,
+) -> RadrootsNostrEvent {
+ let buyer_pubkey = buyer.public_key_hex();
+ let seller_pubkey = "1".repeat(64);
+ let payload = RadrootsTradeOrderRequested {
+ order_id: order_id.to_owned(),
+ listing_addr: LISTING_ADDR.to_owned(),
+ buyer_pubkey,
+ seller_pubkey,
+ items: vec![RadrootsTradeOrderItem {
+ bin_id: "bin-1".to_owned(),
+ bin_count: 2,
+ }],
+ economics,
+ };
+ let parts = active_trade_order_request_event_build(
+ &RadrootsNostrEventPtr {
+ id: listing_event_id.to_owned(),
+ relays: None,
+ },
+ &payload,
+ )
+ .expect("order request parts");
+ radroots_nostr_build_event(parts.kind, parts.content, parts.tags)
+ .expect("nostr event builder")
+ .sign_with_keys(buyer.keys())
+ .expect("signed order request")
+}
+
#[test]
fn buyer_target_flow_acceptance_uses_target_operations() {
let sandbox = RadrootsCliSandbox::new();
@@ -4257,6 +4361,191 @@ fn order_get_marks_bound_buyer_pubkey_mismatch_unready() {
}
#[test]
+fn order_rebind_previews_and_writes_bound_buyer_actor_updates() {
+ let sandbox = RadrootsCliSandbox::new();
+ let order_id = create_ready_order(&sandbox, "order_rebind");
+ let order_file = sandbox
+ .root()
+ .join("data/apps/cli/orders/drafts")
+ .join(format!("{order_id}.toml"));
+ let before = fs::read_to_string(&order_file).expect("order before rebind");
+ let second = sandbox.json_success(&["--format", "json", "account", "create"]);
+ let second_account_id = second["result"]["account"]["id"]
+ .as_str()
+ .expect("second account id");
+
+ let dry_run = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "order",
+ "rebind",
+ order_id.as_str(),
+ second_account_id,
+ ]);
+ assert_eq!(dry_run["operation_id"], "order.rebind");
+ assert_eq!(dry_run["result"]["state"], "dry_run");
+ assert_eq!(dry_run["result"]["from_order_id"], order_id);
+ assert_eq!(dry_run["result"]["order_id_changed"], true);
+ assert_eq!(dry_run["result"]["buyer_pubkey_changed"], true);
+ assert_eq!(dry_run["result"]["to_buyer_account_id"], second_account_id);
+ assert_eq!(
+ dry_run["result"]["existing_request_check"],
+ "skipped_no_relays"
+ );
+ assert_eq!(
+ fs::read_to_string(&order_file).expect("order after dry-run rebind"),
+ before
+ );
+
+ let (unapproved_output, unapproved) = sandbox.json_output(&[
+ "--format",
+ "json",
+ "order",
+ "rebind",
+ order_id.as_str(),
+ second_account_id,
+ ]);
+ assert!(!unapproved_output.status.success());
+ assert_eq!(unapproved["operation_id"], "order.rebind");
+ assert_eq!(unapproved["errors"][0]["code"], "approval_required");
+
+ let rebound = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--approval-token",
+ "approve",
+ "order",
+ "rebind",
+ order_id.as_str(),
+ second_account_id,
+ ]);
+ assert_eq!(rebound["operation_id"], "order.rebind");
+ assert_eq!(rebound["result"]["state"], "rebound");
+ assert_eq!(rebound["result"]["from_order_id"], order_id);
+ assert_eq!(rebound["result"]["order_id_changed"], true);
+ let rebound_order_id = rebound["result"]["to_order_id"]
+ .as_str()
+ .expect("rebound order id");
+ assert_ne!(rebound_order_id, order_id);
+ let rebound_file = rebound["result"]["file"].as_str().expect("rebound file");
+ assert!(!order_file.exists());
+ let after = fs::read_to_string(rebound_file).expect("order after rebind");
+ assert!(after.contains("[buyer_actor]"));
+ assert!(after.contains("source = \"order_rebind\""));
+ assert!(after.contains(format!("order_id = \"{rebound_order_id}\"").as_str()));
+ assert!(after.contains(format!("quote_id = \"quote_{rebound_order_id}\"").as_str()));
+
+ let get = sandbox.json_success(&["--format", "json", "order", "get", rebound_order_id]);
+ assert_eq!(get["result"]["state"], "ready");
+ assert_eq!(get["result"]["buyer_account_id"], second_account_id);
+ assert_eq!(get["result"]["buyer_actor_source"], "order_rebind");
+
+ let same = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--approval-token",
+ "approve",
+ "order",
+ "rebind",
+ rebound_order_id,
+ second_account_id,
+ ]);
+ assert_eq!(same["result"]["state"], "rebound");
+ assert_eq!(same["result"]["order_id_changed"], false);
+ assert_eq!(same["result"]["to_order_id"], rebound_order_id);
+}
+
+#[test]
+fn order_rebind_refuses_visible_published_request() {
+ let sandbox = RadrootsCliSandbox::new();
+ let buyer = identity_secret(94);
+ let buyer_public = buyer.to_public();
+ let buyer_public_file =
+ write_public_identity_profile(&sandbox, "rebind-visible-buyer", &buyer_public);
+ sandbox.json_success(&[
+ "--format",
+ "json",
+ "--approval-token",
+ "approve",
+ "account",
+ "import",
+ "--default",
+ buyer_public_file.to_string_lossy().as_ref(),
+ ]);
+ let listing_event_id = seed_orderable_listing(&sandbox, LISTING_ADDR);
+ sandbox.json_success(&["--format", "json", "basket", "create", "visible_rebind"]);
+ sandbox.json_success(&[
+ "--format",
+ "json",
+ "basket",
+ "item",
+ "add",
+ "visible_rebind",
+ "--listing-addr",
+ LISTING_ADDR,
+ "--bin-id",
+ "bin-1",
+ "--quantity",
+ "2",
+ ]);
+ let quote = sandbox.json_success(&[
+ "--format",
+ "json",
+ "basket",
+ "quote",
+ "create",
+ "visible_rebind",
+ ]);
+ let order_id = quote["result"]["quote"]["order_id"]
+ .as_str()
+ .expect("order id");
+ let economics: RadrootsTradeOrderEconomics =
+ serde_json::from_value(quote["result"]["quote"]["economics"].clone())
+ .expect("quote economics");
+ let event = signed_order_request_event_for_quote(
+ &buyer,
+ order_id,
+ listing_event_id.as_str(),
+ economics,
+ );
+ let target = sandbox.json_success(&["--format", "json", "account", "create"]);
+ let target_account_id = target["result"]["account"]["id"]
+ .as_str()
+ .expect("target account id");
+ let relay = RelayFetchServer::with_events(vec![event]);
+
+ let (output, value) = sandbox.json_output(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "--relay",
+ relay.endpoint(),
+ "order",
+ "rebind",
+ order_id,
+ target_account_id,
+ ]);
+ relay.join();
+
+ assert!(!output.status.success());
+ assert_eq!(output.status.code(), Some(10));
+ assert_eq!(value["operation_id"], "order.rebind");
+ assert_eq!(value["errors"][0]["code"], "validation_failed");
+ assert_eq!(
+ value["errors"][0]["detail"]["existing_request_check"],
+ "blocked_existing_request"
+ );
+ assert_eq!(
+ value["errors"][0]["detail"]["existing_request_event_ids"]
+ .as_array()
+ .expect("existing request ids")
+ .len(),
+ 1
+ );
+}
+
+#[test]
fn order_submit_requires_local_replica_freshness_before_signing() {
let sandbox = RadrootsCliSandbox::new();
let order_id = create_ready_order(&sandbox, "freshness_missing_db");