commit 06cabe28e7f045d360c01ce3cb07371452182498
parent 39bb2c54aa7bd0dda369702cdbca5007b17eca0e
Author: triesap <tyson@radroots.org>
Date: Tue, 28 Apr 2026 00:45:43 +0000
order: publish requests through relays
Diffstat:
5 files changed, 197 insertions(+), 24 deletions(-)
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -1159,10 +1159,20 @@ pub struct OrderSubmitView {
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 event_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub event_kind: Option<u32>,
#[serde(default)]
pub dry_run: bool,
#[serde(default)]
pub deduplicated: bool,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub target_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(skip_serializing_if = "Option::is_none")]
pub idempotency_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
diff --git a/src/operation_order.rs b/src/operation_order.rs
@@ -48,7 +48,9 @@ impl OperationService<OrderSubmitRequest> for OrderOperationService<'_> {
if request.context.dry_run {
config.output.dry_run = true;
}
- let view = map_runtime(crate::runtime::order::submit(&config, &args))?;
+ let view = crate::runtime::order::submit(&config, &args).map_err(|error| {
+ OperationAdapterError::runtime_failure(request.operation_id(), error)
+ })?;
if request.context.dry_run && view.state == "unconfigured" && !view.issues.is_empty() {
serialized_target_result::<OrderSubmitResult, _>(&view)
} else {
diff --git a/src/runtime/order.rs b/src/runtime/order.rs
@@ -3,24 +3,33 @@ use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
+use radroots_events::RadrootsNostrEventPtr;
use radroots_events::kinds::KIND_LISTING;
+use radroots_events::trade::{RadrootsTradeOrderItem, RadrootsTradeOrderRequested};
use radroots_events_codec::d_tag::is_d_tag_base64url;
-use radroots_events_codec::trade::RadrootsTradeListingAddress;
+use radroots_events_codec::trade::{
+ RadrootsTradeListingAddress, active_trade_order_request_event_build,
+};
use radroots_replica_db::{ReplicaSql, nostr_event_state, trade_product};
use radroots_replica_db_schema::nostr_event_state::{
INostrEventStateFindOne, INostrEventStateFindOneArgs, NostrEventStateQueryBindValues,
};
use radroots_replica_db_schema::trade_product::{ITradeProductFieldsFilter, ITradeProductFindMany};
use radroots_sql_core::SqliteExecutor;
+use radroots_trade::order::canonicalize_active_order_request_for_signer;
use serde::{Deserialize, Serialize};
use crate::domain::runtime::{
OrderCancelView, OrderDraftItemView, OrderGetView, OrderHistoryView, OrderIssueView,
OrderListView, OrderNewView, OrderSubmitView, OrderSummaryView, OrderWatchView,
+ RelayFailureView,
};
use crate::runtime::RuntimeError;
use crate::runtime::accounts;
use crate::runtime::config::{RuntimeConfig, SignerBackend};
+use crate::runtime::direct_relay::{
+ DirectRelayFailure, DirectRelayPublishReceipt, publish_parts_with_identity,
+};
use crate::runtime::signer::ActorWriteBindingError;
use crate::runtime_args::{
OrderDraftCreateArgs, OrderSubmitArgs, OrderWatchArgs, RecordLookupArgs,
@@ -28,8 +37,7 @@ use crate::runtime_args::{
const ORDER_DRAFT_KIND: &str = "order_draft_v1";
const ORDER_SOURCE: &str = "local order drafts · local first";
-const ORDER_SUBMIT_UNAVAILABLE_REASON: &str =
- "direct Nostr relay order publication is not implemented";
+const ORDER_SUBMIT_SOURCE: &str = "direct Nostr relay publish · local key";
const ORDER_EVENT_STATE_UNAVAILABLE_REASON: &str =
"relay-backed order event state is not implemented";
const ORDERS_DIR: &str = "orders/drafts";
@@ -363,8 +371,13 @@ pub fn submit(
buyer_account_id: None,
buyer_pubkey: None,
seller_pubkey: None,
+ event_id: None,
+ event_kind: None,
dry_run: config.output.dry_run,
deduplicated: false,
+ target_relays: Vec::new(),
+ acknowledged_relays: Vec::new(),
+ failed_relays: Vec::new(),
idempotency_key: args.idempotency_key.clone(),
signer_mode: None,
signer_session_id: None,
@@ -393,8 +406,13 @@ pub fn submit(
buyer_account_id: None,
buyer_pubkey: None,
seller_pubkey: None,
+ event_id: None,
+ event_kind: None,
dry_run: config.output.dry_run,
deduplicated: false,
+ target_relays: Vec::new(),
+ acknowledged_relays: Vec::new(),
+ failed_relays: Vec::new(),
idempotency_key: args.idempotency_key.clone(),
signer_mode: None,
signer_session_id: None,
@@ -425,8 +443,13 @@ pub fn submit(
buyer_account_id: loaded.document.buyer_account_id.clone(),
buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()),
seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()),
+ event_id: None,
+ event_kind: None,
dry_run: config.output.dry_run,
deduplicated: false,
+ target_relays: Vec::new(),
+ acknowledged_relays: Vec::new(),
+ failed_relays: Vec::new(),
idempotency_key: args.idempotency_key.clone(),
signer_mode: None,
signer_session_id: None,
@@ -439,7 +462,7 @@ pub fn submit(
}
if config.output.dry_run {
- if let Err(error) = validate_local_order_write_authority(
+ if let Err(error) = resolve_local_order_signing_identity(
config,
loaded.document.order.buyer_pubkey.as_str(),
) {
@@ -456,8 +479,13 @@ pub fn submit(
buyer_account_id: loaded.document.buyer_account_id.clone(),
buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()),
seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()),
+ event_id: None,
+ event_kind: None,
dry_run: true,
deduplicated: false,
+ target_relays: Vec::new(),
+ acknowledged_relays: Vec::new(),
+ failed_relays: Vec::new(),
idempotency_key: args.idempotency_key.clone(),
signer_mode: None,
signer_session_id: None,
@@ -472,15 +500,24 @@ pub fn submit(
});
}
- if let Err(error) =
- validate_local_order_write_authority(config, loaded.document.order.buyer_pubkey.as_str())
- {
- return Ok(order_binding_error_view(config, &loaded, args, error));
+ if config.relay.urls.is_empty() {
+ return Err(RuntimeError::Network(
+ "order submit requires at least one configured relay before signing".to_owned(),
+ ));
}
- Ok(direct_relay_unavailable_order_submit_view(
- config, &loaded, args,
- ))
+ let signing = match resolve_local_order_signing_identity(
+ config,
+ loaded.document.order.buyer_pubkey.as_str(),
+ ) {
+ Ok(signing) => signing,
+ Err(error) => return Ok(order_binding_error_view(config, &loaded, args, error)),
+ };
+
+ match publish_order_request(config, &loaded, args, signing) {
+ Ok(view) => Ok(view),
+ Err(error) => Err(error),
+ }
}
pub fn watch(
@@ -1052,14 +1089,70 @@ fn actions_for_document(
actions
}
-fn direct_relay_unavailable_order_submit_view(
+fn publish_order_request(
config: &RuntimeConfig,
loaded: &LoadedOrderDraft,
args: &OrderSubmitArgs,
+ signing: accounts::AccountSigningIdentity,
+) -> Result<OrderSubmitView, RuntimeError> {
+ let signer_pubkey = signing
+ .account
+ .record
+ .public_identity
+ .public_key_hex
+ .as_str();
+ let payload = RadrootsTradeOrderRequested {
+ order_id: loaded.document.order.order_id.clone(),
+ listing_addr: loaded.document.order.listing_addr.clone(),
+ buyer_pubkey: loaded.document.order.buyer_pubkey.clone(),
+ seller_pubkey: loaded.document.order.seller_pubkey.clone(),
+ items: loaded
+ .document
+ .order
+ .items
+ .iter()
+ .map(|item| RadrootsTradeOrderItem {
+ bin_id: item.bin_id.clone(),
+ bin_count: item.bin_count,
+ })
+ .collect(),
+ };
+ let payload = canonicalize_active_order_request_for_signer(payload, signer_pubkey)
+ .map_err(|error| RuntimeError::Config(format!("canonicalize order request: {error}")))?;
+ let listing_event = RadrootsNostrEventPtr {
+ id: loaded.document.order.listing_event_id.clone(),
+ relays: None,
+ };
+ let parts = active_trade_order_request_event_build(&listing_event, &payload)
+ .map_err(|error| RuntimeError::Config(format!("encode order request event: {error}")))?;
+ 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_submit_view(
+ config, loaded, args, event_kind, receipt,
+ ))
+}
+
+fn published_order_submit_view(
+ config: &RuntimeConfig,
+ loaded: &LoadedOrderDraft,
+ args: &OrderSubmitArgs,
+ event_kind: u32,
+ receipt: DirectRelayPublishReceipt,
) -> OrderSubmitView {
+ let DirectRelayPublishReceipt {
+ event_id,
+ created_at: _,
+ signature: _,
+ target_relays,
+ acknowledged_relays,
+ failed_relays,
+ } = receipt;
+
OrderSubmitView {
- state: "unavailable".to_owned(),
- source: ORDER_SOURCE.to_owned(),
+ state: "submitted".to_owned(),
+ source: ORDER_SUBMIT_SOURCE.to_owned(),
order_id: loaded.document.order.order_id.clone(),
file: loaded.file.display().to_string(),
listing_lookup: loaded.document.listing_lookup.clone(),
@@ -1068,13 +1161,18 @@ fn direct_relay_unavailable_order_submit_view(
buyer_account_id: loaded.document.buyer_account_id.clone(),
buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()),
seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()),
+ event_id: Some(event_id),
+ event_kind: Some(event_kind),
dry_run: false,
deduplicated: false,
+ target_relays,
+ acknowledged_relays,
+ failed_relays: relay_failures(failed_relays),
idempotency_key: args.idempotency_key.clone(),
signer_mode: Some(config.signer.backend.as_str().to_owned()),
signer_session_id: None,
requested_signer_session_id: None,
- reason: Some(ORDER_SUBMIT_UNAVAILABLE_REASON.to_owned()),
+ reason: None,
job: None,
issues: Vec::new(),
actions: Vec::new(),
@@ -1112,8 +1210,13 @@ fn order_binding_error_view(
buyer_account_id: loaded.document.buyer_account_id.clone(),
buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()),
seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()),
+ event_id: None,
+ event_kind: None,
dry_run: config.output.dry_run,
deduplicated: false,
+ target_relays: Vec::new(),
+ acknowledged_relays: Vec::new(),
+ failed_relays: Vec::new(),
idempotency_key: args.idempotency_key.clone(),
signer_mode: Some(config.signer.backend.as_str().to_owned()),
signer_session_id: None,
@@ -1125,12 +1228,14 @@ fn order_binding_error_view(
}
}
-fn validate_local_order_write_authority(
+fn resolve_local_order_signing_identity(
config: &RuntimeConfig,
buyer_pubkey: &str,
-) -> Result<(), ActorWriteBindingError> {
+) -> Result<accounts::AccountSigningIdentity, ActorWriteBindingError> {
if !matches!(config.signer.backend, SignerBackend::Local) {
- return Ok(());
+ return Err(ActorWriteBindingError::Unconfigured(
+ "order submit requires signer mode `local`".to_owned(),
+ ));
}
let signing = accounts::resolve_local_signing_identity(config)
.map_err(|error| ActorWriteBindingError::Unconfigured(error.to_string()))?;
@@ -1145,7 +1250,17 @@ fn validate_local_order_write_authority(
"selected local account pubkey `{selected_pubkey}` cannot sign order buyer_pubkey `{buyer_pubkey}`"
)));
}
- Ok(())
+ Ok(signing)
+}
+
+fn relay_failures(failures: Vec<DirectRelayFailure>) -> Vec<RelayFailureView> {
+ failures
+ .into_iter()
+ .map(|failure| RelayFailureView {
+ relay: failure.relay,
+ reason: failure.reason,
+ })
+ .collect()
}
fn load_draft(path: &Path) -> Result<LoadedOrderDraft, String> {
diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs
@@ -5,9 +5,13 @@ use std::path::Path;
use support::{
RadrootsCliSandbox, assert_contains, assert_no_daemon_runtime_reference,
assert_no_removed_command_reference, create_listing_draft, identity_public,
- make_listing_publishable, shell_single_quoted, toml_string, write_public_identity_profile,
+ make_listing_publishable, seed_orderable_listing, shell_single_quoted, toml_string,
+ write_public_identity_profile,
};
+const LISTING_ADDR: &str =
+ "30402:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg";
+
#[test]
fn local_signer_status_reports_unconfigured_without_account() {
let sandbox = RadrootsCliSandbox::new();
@@ -715,6 +719,47 @@ fn local_seller_publish_commands_attempt_configured_direct_relay() {
"listing.archive",
&["listing", "archive"],
);
+
+ seed_orderable_listing(&sandbox, LISTING_ADDR);
+ sandbox.json_success(&["--format", "json", "basket", "create", "direct_order"]);
+ sandbox.json_success(&[
+ "--format",
+ "json",
+ "basket",
+ "item",
+ "add",
+ "direct_order",
+ "--listing-addr",
+ LISTING_ADDR,
+ "--bin-id",
+ "bin-1",
+ "--quantity",
+ "1",
+ ]);
+ let quote = sandbox.json_success(&[
+ "--format",
+ "json",
+ "basket",
+ "quote",
+ "create",
+ "direct_order",
+ ]);
+ let order_id = quote["result"]["quote"]["order_id"]
+ .as_str()
+ .expect("order id");
+ let (order_output, order_value) = sandbox.json_output(&[
+ "--format",
+ "json",
+ "--relay",
+ relay,
+ "--approval-token",
+ "approve",
+ "order",
+ "submit",
+ order_id,
+ ]);
+ assert!(!order_output.status.success());
+ assert_direct_relay_connection_failure(&order_value, "order.submit", &["order", "submit"]);
}
#[test]
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -1060,21 +1060,22 @@ fn buyer_target_flow_acceptance_uses_target_operations() {
order_id,
]);
assert!(!output.status.success());
+ assert_eq!(output.status.code(), Some(8));
assert_eq!(unavailable_submit["operation_id"], "order.submit");
assert_eq!(unavailable_submit["result"], Value::Null);
assert_eq!(
unavailable_submit["errors"][0]["code"],
- "operation_unavailable"
+ "network_unavailable"
);
assert_eq!(
unavailable_submit["errors"][0]["detail"]["class"],
- "operation"
+ "network"
);
assert!(
unavailable_submit["errors"][0]["message"]
.as_str()
.expect("message")
- .contains("direct Nostr relay order publication is not implemented")
+ .contains("order submit requires at least one configured relay before signing")
);
assert_no_removed_command_reference(&unavailable_submit, &["order", "submit"]);
assert_no_daemon_runtime_reference(&unavailable_submit, &["order", "submit"]);