commit dc1f751d8f27dd831f9630082c2c23c9783f22f5
parent 7d69fecc7afe5542e266b2c9df3aeba427ae4eff
Author: triesap <tyson@radroots.org>
Date: Wed, 29 Apr 2026 17:32:43 +0000
order: dedupe submitted requests
- add submit preflight detection for visible same-order request events
- return deduplicated submit output for an identical existing request
- reject changed or invalid visible request candidates before publish
- align request candidate validation across status and seller decision paths
Diffstat:
2 files changed, 482 insertions(+), 25 deletions(-)
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -1197,6 +1197,7 @@ impl OrderSubmitView {
pub fn disposition(&self) -> CommandDisposition {
match self.state.as_str() {
"missing" => CommandDisposition::NotFound,
+ "invalid" => CommandDisposition::ValidationFailed,
"unconfigured" => CommandDisposition::Unconfigured,
"unavailable" => CommandDisposition::ExternalUnavailable,
"error" => CommandDisposition::InternalError,
diff --git a/src/runtime/order.rs b/src/runtime/order.rs
@@ -124,6 +124,13 @@ struct ResolvedSellerOrderRequest {
}
#[derive(Debug, Clone)]
+struct ResolvedOrderSubmitRequest {
+ request_event_id: String,
+ listing_event_id: Option<String>,
+ payload: RadrootsTradeOrderRequested,
+}
+
+#[derive(Debug, Clone)]
struct SellerOrderRequestResolution {
target_relays: Vec<String>,
connected_relays: Vec<String>,
@@ -559,8 +566,23 @@ pub fn submit(
Ok(signing) => signing,
Err(error) => return Ok(order_binding_error_view(config, &loaded, args, error)),
};
+ let payload = canonical_order_request_payload_from_loaded(
+ &loaded,
+ signing
+ .account
+ .record
+ .public_identity
+ .public_key_hex
+ .as_str(),
+ )?;
+
+ if let Some(view) =
+ order_submit_existing_request_preflight_view(config, &loaded, args, &payload)?
+ {
+ return Ok(view);
+ }
- match publish_order_request(config, &loaded, args, signing) {
+ match publish_order_request(config, &loaded, args, signing, payload) {
Ok(view) => Ok(view),
Err(error) => Err(error),
}
@@ -851,6 +873,12 @@ enum OrderStatusRecord {
Decision(RadrootsActiveOrderDecisionRecord),
}
+#[derive(Debug, Clone, Copy)]
+struct OrderRequestCandidateContext<'a> {
+ order_id: &'a str,
+ seller_pubkey: Option<&'a str>,
+}
+
fn order_status_from_receipt(order_id: &str, receipt: DirectRelayFetchReceipt) -> OrderStatusView {
let DirectRelayFetchReceipt {
target_relays,
@@ -936,8 +964,27 @@ fn order_status_from_receipt(order_id: &str, receipt: DirectRelayFetchReceipt) -
}
fn order_status_request_candidate(event: &RadrootsNostrEvent, order_id: &str) -> bool {
- event_kind_u32(event) == KIND_TRADE_ORDER_REQUEST
- && event_matches_tag_value(event, "d", order_id)
+ order_request_candidate_matches(
+ event,
+ OrderRequestCandidateContext {
+ order_id,
+ seller_pubkey: None,
+ },
+ )
+}
+
+fn order_request_candidate_matches(
+ event: &RadrootsNostrEvent,
+ context: OrderRequestCandidateContext<'_>,
+) -> bool {
+ if event_kind_u32(event) != KIND_TRADE_ORDER_REQUEST
+ || !event_matches_tag_value(event, "d", context.order_id)
+ {
+ return false;
+ }
+ context
+ .seller_pubkey
+ .is_none_or(|seller_pubkey| event_matches_tag_value(event, "p", seller_pubkey))
}
fn order_status_record_from_event(
@@ -955,13 +1002,29 @@ fn order_status_record_from_event(
"active order request event used the wrong message type".to_owned(),
));
}
- active_trade_event_context_from_tags(
+ let context = active_trade_event_context_from_tags(
RadrootsActiveTradeMessageType::TradeOrderRequested,
&event.tags,
)
.map_err(|error| {
RuntimeError::Config(format!("decode active order request tags: {error}"))
})?;
+ if context.counterparty_pubkey != envelope.payload.seller_pubkey {
+ return Err(RuntimeError::Config(
+ "active order request p tag does not match seller_pubkey".to_owned(),
+ ));
+ }
+ let listing_addr =
+ parse_listing_addr(envelope.payload.listing_addr.as_str()).map_err(|error| {
+ RuntimeError::Config(format!(
+ "active order request listing_addr is invalid: {error}"
+ ))
+ })?;
+ if listing_addr.seller_pubkey != envelope.payload.seller_pubkey {
+ return Err(RuntimeError::Config(
+ "active order request listing_addr is outside seller authority".to_owned(),
+ ));
+ }
Ok(OrderStatusRecord::Request(
RadrootsActiveOrderRequestRecord {
event_id: event.id,
@@ -1143,6 +1206,14 @@ fn active_order_reducer_issue_view(issue_value: RadrootsActiveOrderReducerIssue)
vec![event_id],
)
}
+ RadrootsActiveOrderReducerIssue::DecisionInventoryCommitmentMismatch { event_id } => {
+ issue_with_events(
+ "decision_inventory_commitment_mismatch",
+ "inventory_commitments",
+ "active order reducer reported decision inventory commitment mismatch",
+ vec![event_id],
+ )
+ }
RadrootsActiveOrderReducerIssue::DecisionMissingReason { event_id } => issue_with_events(
"missing_decision_decline_reason",
"reason",
@@ -1495,15 +1566,13 @@ fn seller_order_request_resolution_from_receipt(
let mut decoded_count = 0usize;
let mut requests = Vec::new();
let mut candidate_issues = Vec::new();
+ let candidate_context = OrderRequestCandidateContext {
+ order_id,
+ seller_pubkey: Some(seller_pubkey),
+ };
for event in events {
- if event_kind_u32(&event) != KIND_TRADE_ORDER_REQUEST {
- skipped_count += 1;
- continue;
- }
- if !event_matches_tag_value(&event, "d", order_id)
- || !event_matches_tag_value(&event, "p", seller_pubkey)
- {
+ if !order_request_candidate_matches(&event, candidate_context) {
skipped_count += 1;
continue;
}
@@ -2344,18 +2413,286 @@ fn order_submit_unconfigured_view(
}
}
-fn publish_order_request(
+fn order_submit_existing_request_preflight_view(
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();
+ payload: &RadrootsTradeOrderRequested,
+) -> Result<Option<OrderSubmitView>, RuntimeError> {
+ let filter = order_request_filter(
+ loaded.document.order.seller_pubkey.as_str(),
+ Some(loaded.document.order.order_id.as_str()),
+ )?;
+ let receipt = match fetch_events_from_relays(&config.relay.urls, filter) {
+ Ok(receipt) => receipt,
+ Err(DirectRelayFetchError::Connect {
+ reason,
+ target_relays: _,
+ failed_relays: _,
+ }) => {
+ return Err(RuntimeError::Network(format!(
+ "direct relay connection failed during submit preflight: {reason}"
+ )));
+ }
+ Err(error) => return Err(RuntimeError::Network(error.to_string())),
+ };
+
+ order_submit_existing_request_view_from_receipt(config, loaded, args, payload, receipt)
+}
+
+fn order_submit_existing_request_view_from_receipt(
+ config: &RuntimeConfig,
+ loaded: &LoadedOrderDraft,
+ args: &OrderSubmitArgs,
+ payload: &RadrootsTradeOrderRequested,
+ receipt: DirectRelayFetchReceipt,
+) -> Result<Option<OrderSubmitView>, RuntimeError> {
+ let DirectRelayFetchReceipt {
+ target_relays,
+ connected_relays,
+ failed_relays,
+ events,
+ } = receipt;
+ let mut requests = Vec::new();
+ let mut candidate_issues = Vec::new();
+ let candidate_context = OrderRequestCandidateContext {
+ order_id: loaded.document.order.order_id.as_str(),
+ seller_pubkey: Some(loaded.document.order.seller_pubkey.as_str()),
+ };
+
+ for event in events {
+ if !order_request_candidate_matches(&event, candidate_context) {
+ continue;
+ }
+ let event_id = event.id.to_string();
+ match order_submit_request_from_event(&event, loaded) {
+ Ok(request) => requests.push(request),
+ Err(error) => candidate_issues.push(issue_with_events(
+ "invalid_request_candidate",
+ "request_event_id",
+ format!("request event `{event_id}` failed order submit preflight: {error}"),
+ vec![event_id],
+ )),
+ }
+ }
+
+ requests.sort_by(|left, right| left.request_event_id.cmp(&right.request_event_id));
+ candidate_issues.sort_by(|left, right| {
+ left.event_ids
+ .cmp(&right.event_ids)
+ .then_with(|| left.message.cmp(&right.message))
+ });
+ if !candidate_issues.is_empty() {
+ return Ok(Some(order_submit_invalid_existing_request_view(
+ config,
+ loaded,
+ args,
+ "visible order request candidates failed submit preflight validation",
+ candidate_issues,
+ target_relays,
+ failed_relays,
+ )));
+ }
+
+ let request_event_ids = requests
+ .iter()
+ .map(|request| request.request_event_id.clone())
+ .collect::<Vec<_>>();
+
+ match requests.as_slice() {
+ [] => Ok(None),
+ [request] if order_submit_request_matches_draft(request, loaded, payload) => {
+ Ok(Some(order_submit_deduplicated_view(
+ config,
+ loaded,
+ args,
+ request,
+ target_relays,
+ connected_relays,
+ failed_relays,
+ )))
+ }
+ [request] => Ok(Some(order_submit_invalid_existing_request_view(
+ config,
+ loaded,
+ args,
+ "visible order request event conflicts with the local order draft; refusing to publish a second request for the same order id",
+ vec![issue_with_events(
+ "existing_request_conflict",
+ "request_event_id",
+ format!(
+ "request event `{}` does not match the local order draft",
+ request.request_event_id
+ ),
+ vec![request.request_event_id.clone()],
+ )],
+ target_relays,
+ failed_relays,
+ ))),
+ _ => Ok(Some(order_submit_invalid_existing_request_view(
+ config,
+ loaded,
+ args,
+ "multiple visible order request events matched the local order id; refusing to publish another request",
+ vec![issue_with_events(
+ "multiple_request_candidates",
+ "request_event_id",
+ format!(
+ "matched {} request events for the same order id",
+ requests.len()
+ ),
+ request_event_ids,
+ )],
+ target_relays,
+ failed_relays,
+ ))),
+ }
+}
+
+fn order_submit_request_from_event(
+ event: &RadrootsNostrEvent,
+ loaded: &LoadedOrderDraft,
+) -> Result<ResolvedOrderSubmitRequest, RuntimeError> {
+ let event = radroots_event_from_nostr(event);
+ let envelope = active_trade_order_request_from_event(&event)
+ .map_err(|error| RuntimeError::Config(format!("decode order request event: {error}")))?;
+ let context = active_trade_event_context_from_tags(
+ RadrootsActiveTradeMessageType::TradeOrderRequested,
+ &event.tags,
+ )
+ .map_err(|error| RuntimeError::Config(format!("decode order request tags: {error}")))?;
+
+ if envelope.order_id != loaded.document.order.order_id
+ || envelope.payload.order_id != loaded.document.order.order_id
+ {
+ return Err(RuntimeError::Config(
+ "order request does not match local order id".to_owned(),
+ ));
+ }
+ if context.counterparty_pubkey != envelope.payload.seller_pubkey {
+ return Err(RuntimeError::Config(
+ "order request p tag does not match seller_pubkey".to_owned(),
+ ));
+ }
+ let listing_addr =
+ parse_listing_addr(envelope.payload.listing_addr.as_str()).map_err(|error| {
+ RuntimeError::Config(format!("order request listing_addr is invalid: {error}"))
+ })?;
+ if listing_addr.seller_pubkey != envelope.payload.seller_pubkey {
+ return Err(RuntimeError::Config(
+ "order request listing address is outside seller authority".to_owned(),
+ ));
+ }
+ let payload =
+ canonicalize_active_order_request_for_signer(envelope.payload, event.author.as_str())
+ .map_err(|error| {
+ RuntimeError::Config(format!("canonicalize order request: {error}"))
+ })?;
+ let listing_event_id = context.listing_event.as_ref().map(|event| event.id.clone());
+
+ Ok(ResolvedOrderSubmitRequest {
+ request_event_id: event.id,
+ listing_event_id,
+ payload,
+ })
+}
+
+fn order_submit_request_matches_draft(
+ request: &ResolvedOrderSubmitRequest,
+ loaded: &LoadedOrderDraft,
+ payload: &RadrootsTradeOrderRequested,
+) -> bool {
+ request.payload == *payload
+ && request.listing_event_id.as_deref()
+ == Some(loaded.document.order.listing_event_id.as_str())
+}
+
+fn order_submit_deduplicated_view(
+ config: &RuntimeConfig,
+ loaded: &LoadedOrderDraft,
+ args: &OrderSubmitArgs,
+ request: &ResolvedOrderSubmitRequest,
+ target_relays: Vec<String>,
+ connected_relays: Vec<String>,
+ failed_relays: Vec<DirectRelayFailure>,
+) -> OrderSubmitView {
+ OrderSubmitView {
+ 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(),
+ listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()),
+ listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()),
+ 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(request.request_event_id.clone()),
+ event_kind: Some(KIND_TRADE_ORDER_REQUEST),
+ dry_run: false,
+ deduplicated: true,
+ target_relays,
+ acknowledged_relays: connected_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(
+ "an identical order request is already visible on the configured relays; publish skipped"
+ .to_owned(),
+ ),
+ job: None,
+ issues: Vec::new(),
+ actions: Vec::new(),
+ }
+}
+
+fn order_submit_invalid_existing_request_view(
+ config: &RuntimeConfig,
+ loaded: &LoadedOrderDraft,
+ args: &OrderSubmitArgs,
+ reason: impl Into<String>,
+ issues: Vec<OrderIssueView>,
+ target_relays: Vec<String>,
+ failed_relays: Vec<DirectRelayFailure>,
+) -> OrderSubmitView {
+ OrderSubmitView {
+ state: "invalid".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(),
+ listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()),
+ listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()),
+ 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: Some(KIND_TRADE_ORDER_REQUEST),
+ dry_run: config.output.dry_run,
+ deduplicated: false,
+ target_relays,
+ acknowledged_relays: Vec::new(),
+ 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(reason.into()),
+ job: None,
+ issues,
+ actions: vec![format!(
+ "radroots order status get {}",
+ loaded.document.order.order_id
+ )],
+ }
+}
+
+fn canonical_order_request_payload_from_loaded(
+ loaded: &LoadedOrderDraft,
+ signer_pubkey: &str,
+) -> Result<RadrootsTradeOrderRequested, RuntimeError> {
let payload = RadrootsTradeOrderRequested {
order_id: loaded.document.order.order_id.clone(),
listing_addr: loaded.document.order.listing_addr.clone(),
@@ -2372,8 +2709,17 @@ fn publish_order_request(
})
.collect(),
};
- let payload = canonicalize_active_order_request_for_signer(payload, signer_pubkey)
- .map_err(|error| RuntimeError::Config(format!("canonicalize order request: {error}")))?;
+ canonicalize_active_order_request_for_signer(payload, signer_pubkey)
+ .map_err(|error| RuntimeError::Config(format!("canonicalize order request: {error}")))
+}
+
+fn publish_order_request(
+ config: &RuntimeConfig,
+ loaded: &LoadedOrderDraft,
+ args: &OrderSubmitArgs,
+ signing: accounts::AccountSigningIdentity,
+ payload: RadrootsTradeOrderRequested,
+) -> Result<OrderSubmitView, RuntimeError> {
let listing_event = RadrootsNostrEventPtr {
id: loaded.document.order.listing_event_id.clone(),
relays: None,
@@ -2786,12 +3132,14 @@ mod tests {
use tempfile::tempdir;
use super::{
- ORDER_DRAFT_KIND, OrderDraft, OrderDraftDocument, OrderDraftItem,
- SellerOrderRequestResolution, accepted_order_decision_payload_from_request, collect_issues,
+ LoadedOrderDraft, ORDER_DRAFT_KIND, OrderDraft, OrderDraftDocument, OrderDraftItem,
+ SellerOrderRequestResolution, accepted_order_decision_payload_from_request,
+ canonical_order_request_payload_from_loaded, collect_issues,
declined_order_decision_payload_from_request, inspect_document, next_order_id,
order_decision_dry_run_view, order_decision_preflight_view_from_status,
order_decision_view_from_resolution, order_history_entry_from_event,
order_history_from_receipt, order_request_filter, order_status_from_receipt,
+ order_submit_existing_request_view_from_receipt,
seller_order_request_resolution_from_receipt,
};
use crate::runtime::config::{
@@ -2801,7 +3149,7 @@ mod tests {
SignerBackend, SignerConfig, Verbosity,
};
use crate::runtime::direct_relay::DirectRelayFetchReceipt;
- use crate::runtime_args::{OrderDecisionArg, OrderDecisionArgs};
+ use crate::runtime_args::{OrderDecisionArg, OrderDecisionArgs, OrderSubmitArgs};
#[test]
fn generated_order_id_uses_stable_prefix() {
@@ -2925,6 +3273,90 @@ mod tests {
}
#[test]
+ fn order_submit_existing_request_preflight_deduplicates_identical_request() {
+ 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 loaded = loaded_order_draft_for_fixture(&fixture);
+ let payload =
+ canonical_order_request_payload_from_loaded(&loaded, fixture.buyer_pubkey.as_str())
+ .expect("canonical order request payload");
+ let event_id = fixture.request_event.id.to_string();
+ let args = OrderSubmitArgs {
+ key: fixture.order_id.clone(),
+ idempotency_key: Some("idem-submit".to_owned()),
+ };
+ let receipt = DirectRelayFetchReceipt {
+ target_relays: vec!["ws://relay.test".to_owned()],
+ connected_relays: vec!["ws://relay.test".to_owned()],
+ failed_relays: Vec::new(),
+ events: vec![fixture.request_event.clone()],
+ };
+
+ let view = order_submit_existing_request_view_from_receipt(
+ &config, &loaded, &args, &payload, receipt,
+ )
+ .expect("submit existing request preflight")
+ .expect("deduplicated view");
+
+ assert_eq!(view.state, "submitted");
+ assert_eq!(view.deduplicated, true);
+ assert_eq!(view.event_id.as_deref(), Some(event_id.as_str()));
+ assert_eq!(view.event_kind, Some(3422));
+ assert_eq!(view.target_relays, vec!["ws://relay.test"]);
+ assert_eq!(view.acknowledged_relays, vec!["ws://relay.test"]);
+ assert_eq!(view.idempotency_key.as_deref(), Some("idem-submit"));
+ }
+
+ #[test]
+ fn order_submit_existing_request_preflight_rejects_changed_request() {
+ 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 loaded = loaded_order_draft_for_fixture(&fixture);
+ let payload =
+ canonical_order_request_payload_from_loaded(&loaded, fixture.buyer_pubkey.as_str())
+ .expect("canonical order request payload");
+ let changed_event = signed_order_request_event(
+ &fixture.buyer,
+ fixture.order_id.as_str(),
+ fixture.listing_addr.as_str(),
+ fixture.buyer_pubkey.as_str(),
+ fixture.seller_pubkey.as_str(),
+ "2".repeat(64).as_str(),
+ );
+ let changed_event_id = changed_event.id.to_string();
+ let args = OrderSubmitArgs {
+ key: fixture.order_id.clone(),
+ idempotency_key: None,
+ };
+ let receipt = DirectRelayFetchReceipt {
+ target_relays: vec!["ws://relay.test".to_owned()],
+ connected_relays: vec!["ws://relay.test".to_owned()],
+ failed_relays: Vec::new(),
+ events: vec![changed_event],
+ };
+
+ let view = order_submit_existing_request_view_from_receipt(
+ &config, &loaded, &args, &payload, receipt,
+ )
+ .expect("submit existing request preflight")
+ .expect("invalid view");
+
+ assert_eq!(view.state, "invalid");
+ assert_eq!(view.deduplicated, false);
+ assert_eq!(view.issues.len(), 1);
+ assert_eq!(view.issues[0].code, "existing_request_conflict");
+ assert_eq!(view.issues[0].event_ids, vec![changed_event_id]);
+ assert_eq!(
+ view.actions,
+ vec![format!("radroots order status get {}", fixture.order_id)]
+ );
+ }
+
+ #[test]
fn order_history_counts_decoded_before_order_id_narrowing() {
let seller = RadrootsIdentity::generate();
let other_seller = RadrootsIdentity::generate();
@@ -3948,6 +4380,30 @@ mod tests {
}
}
+ fn loaded_order_draft_for_fixture(fixture: &OrderStatusFixture) -> LoadedOrderDraft {
+ LoadedOrderDraft {
+ file: PathBuf::from(format!("{}.toml", fixture.order_id)),
+ updated_at_unix: 0,
+ document: OrderDraftDocument {
+ version: 1,
+ kind: ORDER_DRAFT_KIND.to_owned(),
+ order: OrderDraft {
+ order_id: fixture.order_id.clone(),
+ listing_addr: fixture.listing_addr.clone(),
+ listing_event_id: "1".repeat(64),
+ buyer_pubkey: fixture.buyer_pubkey.clone(),
+ seller_pubkey: fixture.seller_pubkey.clone(),
+ items: vec![OrderDraftItem {
+ bin_id: "bin-1".to_owned(),
+ bin_count: 2,
+ }],
+ },
+ listing_lookup: Some("test-listing".to_owned()),
+ buyer_account_id: Some("acct_test".to_owned()),
+ },
+ }
+ }
+
fn request_resolution_for_fixture(
fixture: &OrderStatusFixture,
) -> SellerOrderRequestResolution {