commit ca48754f8c7775dee8368d245f305ba3f892353a
parent 0516f2f7d4a493c73ae339b8a24d9f4a7d5d8fc4
Author: triesap <tyson@radroots.org>
Date: Mon, 25 May 2026 19:58:50 +0000
order: align app order signed evidence
- scan acknowledged signed order request records before exposing app local work
- mark matching app order evidence as submitted and duplicate-safe
- fail closed on conflicting signed request evidence for export and submit
- cover submitted and conflict app-order workflows in target cli tests
Diffstat:
3 files changed, 765 insertions(+), 30 deletions(-)
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -1509,7 +1509,7 @@ impl OrderAppRecordExportView {
pub fn disposition(&self) -> CommandDisposition {
match self.state.as_str() {
"missing" => CommandDisposition::NotFound,
- "conflict" | "invalid" | "stale" | "unsupported" => {
+ "already_submitted" | "conflict" | "invalid" | "stale" | "unsupported" => {
CommandDisposition::ValidationFailed
}
"error" => CommandDisposition::InternalError,
diff --git a/src/runtime/order.rs b/src/runtime/order.rs
@@ -49,8 +49,8 @@ use radroots_events_codec::trade::{
use radroots_events_codec::wire::WireEventParts;
use radroots_local_events::{
BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, LocalEventRecord, LocalRecordFamily,
- LocalRecordStatus, RelayDeliveryEvidence, RelayDeliveryState, SourceRuntime,
- normalize_relay_urls, validate_supported_buyer_order_request_local_work_payload,
+ LocalRecordStatus, PublishOutboxStatus, RelayDeliveryEvidence, RelayDeliveryState,
+ SourceRuntime, normalize_relay_urls, validate_supported_buyer_order_request_local_work_payload,
};
use radroots_nostr::prelude::{
RadrootsNostrEvent, RadrootsNostrFilter, radroots_event_from_nostr, radroots_nostr_filter_tag,
@@ -145,6 +145,8 @@ const ORDER_ACTOR_CONTEXT_ORDER_DRAFT: &str = "order_draft";
const ORDER_ACTOR_CONTEXT_RESOLVED_ACCOUNT: &str = "resolved_account";
const ORDER_ACTOR_CONTEXT_NETWORK_ONLY: &str = "network_only";
const ORDERS_DIR: &str = "orders/drafts";
+const APP_ORDER_ALREADY_SUBMITTED_ISSUE: &str = "app_order_already_submitted";
+const APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE: &str = "app_order_signed_evidence_conflict";
static ORDER_COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -637,10 +639,9 @@ pub fn list(config: &RuntimeConfig) -> Result<OrderListView, RuntimeError> {
let state = if orders.is_empty() {
"empty"
- } else if orders
- .iter()
- .any(|order| order.state == "error" || !order.ready_for_submit)
- {
+ } else if orders.iter().any(|order| {
+ order.state == "error" || (!order.ready_for_submit && order.state != "submitted")
+ }) {
"degraded"
} else {
"ready"
@@ -736,6 +737,7 @@ pub fn app_record_export(
let mut issues = source_and_document_issues(config, &app_order)?;
if !issues.is_empty() {
let state = app_order_export_failure_state(issues.as_slice());
+ let actions = app_order_export_failure_actions(&app_order.loaded.document, &issues);
return Ok(OrderAppRecordExportView {
state: state.to_owned(),
source: ORDER_APP_RECORD_SOURCE.to_owned(),
@@ -762,7 +764,7 @@ pub fn app_record_export(
"app-authored local order record `{}` is not ready as a CLI order draft",
args.record_id
)),
- actions: vec!["radroots order app list".to_owned()],
+ actions,
});
}
@@ -903,6 +905,9 @@ pub fn submit(
let mut issues = collect_issues(&loaded.document);
issues.extend(source_issues.clone());
+ if let Some(view) = order_submit_app_signed_evidence_view(config, &loaded, args, &issues) {
+ return Ok(view);
+ }
if !issues.is_empty() {
let mut actions = actions_for_document(&loaded.document, loaded.file.as_path(), &issues);
actions.push(format!(
@@ -9647,12 +9652,15 @@ fn load_app_order_record_from_record(
placeholder_app_order_document(&record)
}
};
+ let loaded = LoadedOrderDraft {
+ file: PathBuf::from(format!("shared-local-events/{}", record.record_id)),
+ updated_at_unix: u64::try_from(record.updated_at_ms / 1000).unwrap_or_default(),
+ document,
+ };
+ source_issues.extend(app_order_signed_evidence_issues(config, &loaded)?);
+
Ok(LoadedAppOrderRecord {
- loaded: LoadedOrderDraft {
- file: PathBuf::from(format!("shared-local-events/{}", record.record_id)),
- updated_at_unix: u64::try_from(record.updated_at_ms / 1000).unwrap_or_default(),
- document,
- },
+ loaded,
record,
source_issues,
})
@@ -9770,6 +9778,185 @@ fn app_order_record_source_issues(
Ok(issues)
}
+fn app_order_signed_evidence_issues(
+ config: &RuntimeConfig,
+ loaded: &LoadedOrderDraft,
+) -> Result<Vec<OrderIssueView>, RuntimeError> {
+ let order_id = loaded.document.order.order_id.as_str();
+ let candidate_records = visible_signed_order_request_records(config, order_id)?;
+ if candidate_records.is_empty() {
+ return Ok(Vec::new());
+ }
+
+ let expected_payload = match canonical_order_request_payload_from_loaded(
+ loaded,
+ loaded.document.order.buyer_pubkey.as_str(),
+ ) {
+ Ok(payload) => payload,
+ Err(error) => {
+ let event_ids = candidate_records
+ .iter()
+ .map(signed_record_event_id)
+ .collect::<Vec<_>>();
+ return Ok(vec![issue_with_events(
+ APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE,
+ "signed_event",
+ format!(
+ "signed order request evidence cannot be compared with local work: {error}"
+ ),
+ event_ids,
+ )]);
+ }
+ };
+
+ let mut submitted_event_ids = Vec::new();
+ let mut conflict_issues = Vec::new();
+ for record in candidate_records {
+ let event_id = signed_record_event_id(&record);
+ match signed_order_request_from_record(&record)
+ .and_then(|event| order_submit_request_from_event(&event, loaded))
+ {
+ Ok(request)
+ if order_submit_request_matches_draft(&request, loaded, &expected_payload) =>
+ {
+ submitted_event_ids.push(request.request_event_id);
+ }
+ Ok(request) => conflict_issues.push(issue_with_events(
+ APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE,
+ "signed_event",
+ format!(
+ "signed order request event `{}` conflicts with the app-authored local order",
+ request.request_event_id
+ ),
+ vec![request.request_event_id],
+ )),
+ Err(error) => conflict_issues.push(issue_with_events(
+ APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE,
+ "signed_event",
+ format!("signed order request event `{event_id}` cannot be validated: {error}"),
+ vec![event_id],
+ )),
+ }
+ }
+
+ conflict_issues.sort_by(|left, right| {
+ left.event_ids
+ .cmp(&right.event_ids)
+ .then_with(|| left.message.cmp(&right.message))
+ });
+ if !conflict_issues.is_empty() {
+ return Ok(conflict_issues);
+ }
+
+ submitted_event_ids.sort();
+ submitted_event_ids.dedup();
+ if submitted_event_ids.is_empty() {
+ Ok(Vec::new())
+ } else {
+ Ok(vec![issue_with_events(
+ APP_ORDER_ALREADY_SUBMITTED_ISSUE,
+ "signed_event",
+ "app-authored local order already has matching signed order request evidence",
+ submitted_event_ids,
+ )])
+ }
+}
+
+fn visible_signed_order_request_records(
+ config: &RuntimeConfig,
+ order_id: &str,
+) -> Result<Vec<LocalEventRecord>, RuntimeError> {
+ let mut records = Vec::new();
+ let mut before_cursor = None::<(i64, i64)>;
+ loop {
+ let shared_records = if let Some((before_change_seq, before_seq)) = before_cursor {
+ list_shared_records_before(
+ config,
+ before_change_seq,
+ before_seq,
+ ORDER_APP_RECORD_LIST_LIMIT,
+ )?
+ } else {
+ list_shared_records_latest(config, ORDER_APP_RECORD_LIST_LIMIT)?
+ };
+ let Some(next_cursor) = shared_records
+ .last()
+ .map(|record| (record.change_seq, record.seq))
+ else {
+ break;
+ };
+ let has_more = shared_records.len() == ORDER_APP_RECORD_LIST_LIMIT as usize;
+ records.extend(
+ shared_records
+ .into_iter()
+ .filter(|record| is_visible_signed_order_request_record(record, order_id)),
+ );
+ if !has_more {
+ break;
+ }
+ before_cursor = Some(next_cursor);
+ }
+ Ok(records)
+}
+
+fn is_visible_signed_order_request_record(record: &LocalEventRecord, order_id: &str) -> bool {
+ record.family == LocalRecordFamily::SignedEvent
+ && record.status == LocalRecordStatus::Published
+ && record.outbox_status == PublishOutboxStatus::Acknowledged
+ && record.event_kind == Some(i64::from(KIND_TRADE_ORDER_REQUEST))
+ && signed_record_tag_values(record, "d")
+ .iter()
+ .any(|value| value == order_id)
+}
+
+fn signed_order_request_from_record(
+ record: &LocalEventRecord,
+) -> Result<RadrootsNostrEvent, RuntimeError> {
+ let raw_event_json = record.raw_event_json.as_ref().ok_or_else(|| {
+ RuntimeError::Config(format!(
+ "signed event record `{}` is missing raw_event_json",
+ record.record_id
+ ))
+ })?;
+ serde_json::from_value::<RadrootsNostrEvent>(raw_event_json.clone()).map_err(|error| {
+ RuntimeError::Config(format!(
+ "signed event record `{}` raw_event_json cannot be decoded: {error}",
+ record.record_id
+ ))
+ })
+}
+
+fn signed_record_tag_values(record: &LocalEventRecord, key: &str) -> Vec<String> {
+ record
+ .event_tags_json
+ .as_ref()
+ .or(record
+ .raw_event_json
+ .as_ref()
+ .and_then(|event| event.get("tags")))
+ .and_then(Value::as_array)
+ .map(|tags| {
+ tags.iter()
+ .filter_map(Value::as_array)
+ .filter_map(|tag| {
+ if tag.first().and_then(Value::as_str) == Some(key) {
+ tag.get(1).and_then(Value::as_str).map(str::to_owned)
+ } else {
+ None
+ }
+ })
+ .collect::<Vec<_>>()
+ })
+ .unwrap_or_default()
+}
+
+fn signed_record_event_id(record: &LocalEventRecord) -> String {
+ record
+ .event_id
+ .clone()
+ .unwrap_or_else(|| record.record_id.clone())
+}
+
fn source_and_document_issues(
config: &RuntimeConfig,
app_order: &LoadedAppOrderRecord,
@@ -9793,13 +9980,42 @@ fn app_order_record_summary(
let exportable = issues.is_empty();
let reason = issues.first().map(|issue| issue.message.clone());
let document = &app_order.loaded.document;
+ let status = if app_order_issue_present(&issues, APP_ORDER_ALREADY_SUBMITTED_ISSUE) {
+ "submitted".to_owned()
+ } else if app_order_issue_present(&issues, APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE) {
+ "conflict".to_owned()
+ } else {
+ record.status.as_str().to_owned()
+ };
+ let actions = if exportable {
+ vec![
+ format!("radroots order get {}", document.order.order_id),
+ format!("radroots order app export {}", record.record_id),
+ format!(
+ "radroots --relay wss://relay.example.com order submit {}",
+ document.order.order_id
+ ),
+ ]
+ } else if app_order_issue_present(&issues, APP_ORDER_ALREADY_SUBMITTED_ISSUE) {
+ vec![format!(
+ "radroots order status get {}",
+ document.order.order_id
+ )]
+ } else if app_order_issue_present(&issues, APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE) {
+ vec![
+ format!("radroots order status get {}", document.order.order_id),
+ "radroots order app list".to_owned(),
+ ]
+ } else {
+ Vec::new()
+ };
Ok(OrderAppRecordSummaryView {
record_id: record.record_id.clone(),
seq: record.seq,
change_seq: record.change_seq,
superseded_count,
record_kind,
- status: record.status.as_str().to_owned(),
+ status,
source_runtime: record.source_runtime.as_str().to_owned(),
owner_account_id: record.owner_account_id.clone(),
owner_pubkey: record.owner_pubkey.clone(),
@@ -9816,18 +10032,7 @@ fn app_order_record_summary(
ready_for_submit: exportable,
exportable,
reason,
- actions: if exportable {
- vec![
- format!("radroots order get {}", document.order.order_id),
- format!("radroots order app export {}", record.record_id),
- format!(
- "radroots --relay wss://relay.example.com order submit {}",
- document.order.order_id
- ),
- ]
- } else {
- Vec::new()
- },
+ actions,
})
}
@@ -9873,8 +10078,12 @@ fn placeholder_app_order_document(record: &LocalEventRecord) -> OrderDraftDocume
fn app_order_export_failure_state(issues: &[OrderIssueView]) -> &'static str {
if issues
.iter()
- .any(|issue| issue.code == "app_order_conflict")
+ .any(|issue| issue.code == APP_ORDER_ALREADY_SUBMITTED_ISSUE)
{
+ "already_submitted"
+ } else if issues.iter().any(|issue| {
+ issue.code == "app_order_conflict" || issue.code == APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE
+ }) {
"conflict"
} else if issues.iter().any(|issue| issue.code == "app_order_stale") {
"stale"
@@ -9893,6 +10102,25 @@ fn app_order_export_failure_state(issues: &[OrderIssueView]) -> &'static str {
}
}
+fn app_order_export_failure_actions(
+ document: &OrderDraftDocument,
+ issues: &[OrderIssueView],
+) -> Vec<String> {
+ if app_order_issue_present(issues, APP_ORDER_ALREADY_SUBMITTED_ISSUE) {
+ vec![format!(
+ "radroots order status get {}",
+ document.order.order_id
+ )]
+ } else if app_order_issue_present(issues, APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE) {
+ vec![
+ format!("radroots order status get {}", document.order.order_id),
+ "radroots order app list".to_owned(),
+ ]
+ } else {
+ vec!["radroots order app list".to_owned()]
+ }
+}
+
fn order_export_output_path(
config: &RuntimeConfig,
output: Option<&PathBuf>,
@@ -9957,7 +10185,11 @@ fn inspect_document_with_source_issues(
issues.extend(buyer_readiness.issues);
issues.extend(source_issues.iter().cloned());
let ready_for_submit = issues.is_empty();
- let state = if ready_for_submit {
+ let state = if app_order_issue_present(&issues, APP_ORDER_ALREADY_SUBMITTED_ISSUE) {
+ "submitted".to_owned()
+ } else if app_order_issue_present(&issues, APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE) {
+ "conflict".to_owned()
+ } else if ready_for_submit {
"ready".to_owned()
} else {
"draft".to_owned()
@@ -10232,6 +10464,19 @@ fn actions_for_document(
file: &Path,
issues: &[OrderIssueView],
) -> Vec<String> {
+ if app_order_issue_present(issues, APP_ORDER_ALREADY_SUBMITTED_ISSUE) {
+ return vec![format!(
+ "radroots order status get {}",
+ document.order.order_id
+ )];
+ }
+ if app_order_issue_present(issues, APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE) {
+ return vec![
+ format!("radroots order status get {}", document.order.order_id),
+ "radroots order app list".to_owned(),
+ ];
+ }
+
let mut actions = Vec::new();
actions.push(format!(
"edit {} and fill the remaining draft fields",
@@ -10292,6 +10537,14 @@ fn actions_for_document(
deduped
}
+fn app_order_issue_present(issues: &[OrderIssueView], code: &str) -> bool {
+ issues.iter().any(|issue| issue.code == code)
+}
+
+fn app_order_issue<'a>(issues: &'a [OrderIssueView], code: &str) -> Option<&'a OrderIssueView> {
+ issues.iter().find(|issue| issue.code == code)
+}
+
fn order_rebind_selector_error(selector: &str, error: RuntimeError) -> RuntimeError {
match error {
RuntimeError::Accounts(_) | RuntimeError::Account(_) => {
@@ -10765,6 +11018,93 @@ fn order_submit_unconfigured_view(
}
}
+fn order_submit_app_signed_evidence_view(
+ config: &RuntimeConfig,
+ loaded: &LoadedOrderDraft,
+ args: &OrderSubmitArgs,
+ issues: &[OrderIssueView],
+) -> Option<OrderSubmitView> {
+ if let Some(issue) = app_order_issue(issues, APP_ORDER_ALREADY_SUBMITTED_ISSUE) {
+ return Some(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()),
+ listing_relays: order_listing_relays(&loaded.document),
+ buyer_account_id: buyer_account_id(&loaded.document),
+ buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()),
+ buyer_actor_source: buyer_actor_source(&loaded.document),
+ buyer_custody: None,
+ buyer_write_capable: None,
+ seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()),
+ event_id: issue.event_ids.first().cloned(),
+ event_kind: Some(KIND_TRADE_ORDER_REQUEST),
+ dry_run: config.output.dry_run,
+ deduplicated: true,
+ target_relays: Vec::new(),
+ connected_relays: Vec::new(),
+ acknowledged_relays: Vec::new(),
+ failed_relays: Vec::new(),
+ idempotency_key: args.idempotency_key.clone(),
+ signer_mode: None,
+ signer_session_id: None,
+ requested_signer_session_id: None,
+ reason: Some(
+ "matching signed order request evidence already exists; publish skipped".to_owned(),
+ ),
+ job: None,
+ issues: vec![issue.clone()],
+ actions: Vec::new(),
+ });
+ }
+
+ if app_order_issue_present(issues, APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE) {
+ return Some(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()),
+ listing_relays: order_listing_relays(&loaded.document),
+ buyer_account_id: buyer_account_id(&loaded.document),
+ buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()),
+ buyer_actor_source: buyer_actor_source(&loaded.document),
+ buyer_custody: None,
+ buyer_write_capable: None,
+ 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: Vec::new(),
+ connected_relays: Vec::new(),
+ acknowledged_relays: Vec::new(),
+ failed_relays: Vec::new(),
+ idempotency_key: args.idempotency_key.clone(),
+ signer_mode: None,
+ signer_session_id: None,
+ requested_signer_session_id: None,
+ reason: Some(
+ "signed order request evidence conflicts with the app-authored local order"
+ .to_owned(),
+ ),
+ job: None,
+ issues: issues.to_vec(),
+ actions: vec![format!(
+ "radroots order status get {}",
+ loaded.document.order.order_id
+ )],
+ });
+ }
+
+ None
+}
+
fn order_submit_invalid_quantity_view(
config: &RuntimeConfig,
loaded: &LoadedOrderDraft,
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -8,7 +8,7 @@ use std::thread::{self, JoinHandle};
use std::time::Duration;
use radroots_events::RadrootsNostrEventPtr;
-use radroots_events::kinds::{KIND_FARM, KIND_PROFILE};
+use radroots_events::kinds::{KIND_FARM, KIND_PROFILE, KIND_TRADE_ORDER_REQUEST};
use radroots_events::trade::{
RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, RadrootsTradeOrderRequested,
};
@@ -16,7 +16,7 @@ use radroots_events_codec::trade::active_trade_order_request_event_build;
use radroots_local_events::{
BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, CANONICAL_RELAY_SET_FINGERPRINT_VERSION,
LocalEventRecordInput, LocalEventsStore, LocalRecordFamily, LocalRecordStatus,
- PublishOutboxStatus, SourceRuntime, canonical_relay_set_fingerprint,
+ PublishOutboxStatus, RelayDeliveryEvidence, SourceRuntime, canonical_relay_set_fingerprint,
};
use radroots_nostr::prelude::{RadrootsNostrEvent, radroots_nostr_build_event};
use radroots_replica_db::{farm, farm_member_claim, migrations};
@@ -682,6 +682,143 @@ fn seed_app_order_record_variant_with_record_id(
record_id
}
+fn app_order_economics(order_id: &str, bin_count: u32) -> RadrootsTradeOrderEconomics {
+ let line_total = (bin_count * 6).to_string();
+ serde_json::from_value(json!({
+ "quote_id": format!("app-order:{order_id}"),
+ "quote_version": 1,
+ "pricing_basis": "listing_event",
+ "currency": "USD",
+ "items": [
+ {
+ "bin_id": "bin-1",
+ "bin_count": bin_count,
+ "quantity_amount": "1",
+ "quantity_unit": "each",
+ "unit_price_amount": "6",
+ "unit_price_currency": "USD",
+ "line_subtotal": {
+ "amount": line_total,
+ "currency": "USD",
+ },
+ }
+ ],
+ "discounts": [],
+ "adjustments": [],
+ "subtotal": {
+ "amount": line_total,
+ "currency": "USD",
+ },
+ "discount_total": {
+ "amount": "0",
+ "currency": "USD",
+ },
+ "adjustment_total": {
+ "amount": "0",
+ "currency": "USD",
+ },
+ "total": {
+ "amount": line_total,
+ "currency": "USD",
+ },
+ }))
+ .expect("app order economics")
+}
+
+fn signed_app_order_request_event(
+ buyer: &radroots_identity::RadrootsIdentity,
+ order_id: &str,
+ listing_addr: &str,
+ listing_event_id: &str,
+ seller_pubkey: &str,
+ bin_count: u32,
+) -> RadrootsNostrEvent {
+ let payload = RadrootsTradeOrderRequested {
+ order_id: order_id.to_owned(),
+ listing_addr: listing_addr.to_owned(),
+ buyer_pubkey: buyer.public_key_hex(),
+ seller_pubkey: seller_pubkey.to_owned(),
+ items: vec![RadrootsTradeOrderItem {
+ bin_id: "bin-1".to_owned(),
+ bin_count,
+ }],
+ economics: app_order_economics(order_id, bin_count),
+ };
+ let parts = active_trade_order_request_event_build(
+ &RadrootsNostrEventPtr {
+ id: listing_event_id.to_owned(),
+ relays: None,
+ },
+ &payload,
+ )
+ .expect("app order request parts");
+ radroots_nostr_build_event(parts.kind, parts.content, parts.tags)
+ .expect("nostr event builder")
+ .sign_with_keys(buyer.keys())
+ .expect("signed app order request")
+}
+
+fn append_app_signed_order_request_record(
+ sandbox: &RadrootsCliSandbox,
+ account_id: &str,
+ listing_addr: &str,
+ event: &RadrootsNostrEvent,
+) -> String {
+ let event_id = event.id.to_hex();
+ let event_tags = event
+ .tags
+ .iter()
+ .map(|tag| tag.as_slice().to_vec())
+ .collect::<Vec<_>>();
+ let delivery = RelayDeliveryEvidence::acknowledged(
+ [ORDERABLE_LISTING_RELAY],
+ [ORDERABLE_LISTING_RELAY],
+ [ORDERABLE_LISTING_RELAY],
+ Vec::new(),
+ )
+ .expect("order request delivery evidence");
+ let record_id = format!("app:signed_event:{event_id}");
+ append_app_local_record(
+ LocalEventRecordInput {
+ record_id: record_id.clone(),
+ family: LocalRecordFamily::SignedEvent,
+ status: LocalRecordStatus::Published,
+ source_runtime: SourceRuntime::App,
+ created_at_ms: i64::try_from(event.created_at.as_secs()).expect("event created_at")
+ * 1_000,
+ inserted_at_ms: 1_779_000_011_000,
+ owner_account_id: Some(account_id.to_owned()),
+ owner_pubkey: Some(event.pubkey.to_string()),
+ farm_id: Some("018f47a8-7b2c-7000-8000-0000000000f1".to_owned()),
+ listing_addr: Some(listing_addr.to_owned()),
+ local_work_json: None,
+ event_id: Some(event_id),
+ event_kind: Some(i64::from(KIND_TRADE_ORDER_REQUEST)),
+ event_pubkey: Some(event.pubkey.to_string()),
+ event_created_at: Some(
+ i64::try_from(event.created_at.as_secs()).expect("event created_at"),
+ ),
+ event_tags_json: Some(json!(event_tags.clone())),
+ event_content: Some(event.content.clone()),
+ event_sig: Some(event.sig.to_string()),
+ raw_event_json: Some(json!({
+ "id": event.id.to_hex(),
+ "pubkey": event.pubkey.to_string(),
+ "created_at": i64::try_from(event.created_at.as_secs()).expect("event created_at"),
+ "kind": u32::from(event.kind.as_u16()),
+ "tags": event_tags,
+ "content": event.content.clone(),
+ "sig": event.sig.to_string(),
+ })),
+ outbox_status: PublishOutboxStatus::Acknowledged,
+ relay_set_fingerprint: canonical_relay_set_fingerprint([ORDERABLE_LISTING_RELAY]),
+ relay_delivery_json: Some(delivery.to_json_value().expect("delivery json")),
+ },
+ sandbox,
+ );
+ record_id
+}
+
#[test]
fn root_help_exposes_only_target_namespaces() {
let output = radroots().arg("--help").output().expect("run root help");
@@ -4460,6 +4597,264 @@ fn order_app_records_list_export_get_and_submit_supported_app_order() {
}
#[test]
+fn order_app_records_treat_matching_signed_evidence_as_submitted() {
+ let sandbox = RadrootsCliSandbox::new();
+ let buyer = identity_secret(97);
+ let buyer_public_file =
+ write_public_identity_profile(&sandbox, "app-order-submitted-buyer", &buyer.to_public());
+ let imported = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--approval-token",
+ "approve",
+ "account",
+ "import",
+ "--default",
+ buyer_public_file.to_string_lossy().as_ref(),
+ ]);
+ let account_id = imported["result"]["account"]["id"]
+ .as_str()
+ .expect("account id");
+ let buyer_secret_file = write_secret_identity_profile(&sandbox, "app-order-submitted", &buyer);
+ sandbox.json_success(&[
+ "--format",
+ "json",
+ "--approval-token",
+ "approve",
+ "account",
+ "attach-secret",
+ account_id,
+ buyer_secret_file.to_string_lossy().as_ref(),
+ "--default",
+ ]);
+
+ let buyer_pubkey = buyer.public_key_hex();
+ let seller_pubkey = identity_public(77).public_key_hex;
+ let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ";
+ let listing_addr = format!("30402:{seller_pubkey}:{listing_d_tag}");
+ let listing_event_id = seed_orderable_listing(&sandbox, listing_addr.as_str());
+ let order_id = "018f47a8-7b2c-7000-8000-000000000016";
+ let record_id = seed_app_order_record(
+ &sandbox,
+ account_id,
+ buyer_pubkey.as_str(),
+ seller_pubkey.as_str(),
+ order_id,
+ listing_addr.as_str(),
+ listing_event_id.as_str(),
+ );
+ let signed_event = signed_app_order_request_event(
+ &buyer,
+ order_id,
+ listing_addr.as_str(),
+ listing_event_id.as_str(),
+ seller_pubkey.as_str(),
+ 2,
+ );
+ let signed_event_id = signed_event.id.to_hex();
+ append_app_signed_order_request_record(
+ &sandbox,
+ account_id,
+ listing_addr.as_str(),
+ &signed_event,
+ );
+
+ let app_list = sandbox.json_success(&["--format", "json", "order", "app", "list"]);
+ let listed = &app_list["result"]["records"][0];
+ assert_eq!(listed["record_id"], record_id);
+ assert_eq!(listed["status"], "submitted");
+ assert_eq!(listed["ready_for_submit"], false);
+ assert_eq!(listed["exportable"], false);
+ assert_eq!(
+ listed["actions"].as_array().expect("actions"),
+ &vec![json!(format!("radroots order status get {order_id}"))]
+ );
+
+ let orders = sandbox.json_success(&["--format", "json", "order", "list"]);
+ assert_eq!(orders["result"]["state"], "ready");
+ assert_eq!(orders["result"]["orders"][0]["state"], "submitted");
+ assert_eq!(orders["result"]["orders"][0]["ready_for_submit"], false);
+
+ let get_by_record =
+ sandbox.json_success(&["--format", "json", "order", "get", record_id.as_str()]);
+ assert_eq!(get_by_record["result"]["state"], "submitted");
+ assert_eq!(get_by_record["result"]["ready_for_submit"], false);
+ assert_eq!(
+ get_by_record["result"]["issues"][0]["code"],
+ "app_order_already_submitted"
+ );
+ assert_eq!(
+ get_by_record["result"]["issues"][0]["event_ids"][0],
+ signed_event_id
+ );
+ assert_eq!(
+ get_by_record["result"]["actions"]
+ .as_array()
+ .expect("actions"),
+ &vec![json!(format!("radroots order status get {order_id}"))]
+ );
+
+ let export_path = sandbox.root().join("submitted-app-order.toml");
+ let export_path_arg = export_path.to_string_lossy();
+ let (export_output, export) = sandbox.json_output(&[
+ "--format",
+ "json",
+ "order",
+ "app",
+ "export",
+ record_id.as_str(),
+ "--output",
+ export_path_arg.as_ref(),
+ ]);
+ assert!(!export_output.status.success());
+ assert_eq!(export["operation_id"], "order.app.export");
+ assert_eq!(export["errors"][0]["detail"]["state"], "already_submitted");
+ assert_eq!(export["errors"][0]["detail"]["valid"], false);
+ assert!(!export_path.exists());
+
+ let submit = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "order",
+ "submit",
+ record_id.as_str(),
+ ]);
+ assert_eq!(submit["operation_id"], "order.submit");
+ assert_eq!(submit["result"]["state"], "submitted");
+ assert_eq!(submit["result"]["deduplicated"], true);
+ assert_eq!(submit["result"]["event_id"], signed_event_id);
+ assert!(
+ submit["result"]
+ .get("actions")
+ .and_then(Value::as_array)
+ .is_none_or(Vec::is_empty)
+ );
+}
+
+#[test]
+fn order_app_records_fail_closed_when_signed_evidence_conflicts() {
+ let sandbox = RadrootsCliSandbox::new();
+ let buyer = identity_secret(98);
+ let buyer_public_file =
+ write_public_identity_profile(&sandbox, "app-order-conflict-buyer", &buyer.to_public());
+ let imported = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--approval-token",
+ "approve",
+ "account",
+ "import",
+ "--default",
+ buyer_public_file.to_string_lossy().as_ref(),
+ ]);
+ let account_id = imported["result"]["account"]["id"]
+ .as_str()
+ .expect("account id");
+ let buyer_secret_file = write_secret_identity_profile(&sandbox, "app-order-conflict", &buyer);
+ sandbox.json_success(&[
+ "--format",
+ "json",
+ "--approval-token",
+ "approve",
+ "account",
+ "attach-secret",
+ account_id,
+ buyer_secret_file.to_string_lossy().as_ref(),
+ "--default",
+ ]);
+
+ let buyer_pubkey = buyer.public_key_hex();
+ let seller_pubkey = identity_public(78).public_key_hex;
+ let listing_addr = format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAQ");
+ let listing_event_id = seed_orderable_listing(&sandbox, listing_addr.as_str());
+ let order_id = "018f47a8-7b2c-7000-8000-000000000017";
+ let record_id = seed_app_order_record(
+ &sandbox,
+ account_id,
+ buyer_pubkey.as_str(),
+ seller_pubkey.as_str(),
+ order_id,
+ listing_addr.as_str(),
+ listing_event_id.as_str(),
+ );
+ let signed_event = signed_app_order_request_event(
+ &buyer,
+ order_id,
+ listing_addr.as_str(),
+ listing_event_id.as_str(),
+ seller_pubkey.as_str(),
+ 3,
+ );
+ append_app_signed_order_request_record(
+ &sandbox,
+ account_id,
+ listing_addr.as_str(),
+ &signed_event,
+ );
+
+ let app_list = sandbox.json_success(&["--format", "json", "order", "app", "list"]);
+ let listed = &app_list["result"]["records"][0];
+ assert_eq!(listed["record_id"], record_id);
+ assert_eq!(listed["status"], "conflict");
+ assert_eq!(listed["ready_for_submit"], false);
+ assert_eq!(listed["exportable"], false);
+ assert!(
+ listed["reason"]
+ .as_str()
+ .expect("conflict reason")
+ .contains("conflicts")
+ );
+ assert!(
+ !listed["actions"]
+ .as_array()
+ .expect("actions")
+ .iter()
+ .any(|action| action
+ .as_str()
+ .expect("action")
+ .contains("order app export"))
+ );
+
+ let export_path = sandbox.root().join("conflicting-signed-app-order.toml");
+ let export_path_arg = export_path.to_string_lossy();
+ let (export_output, export) = sandbox.json_output(&[
+ "--format",
+ "json",
+ "order",
+ "app",
+ "export",
+ record_id.as_str(),
+ "--output",
+ export_path_arg.as_ref(),
+ ]);
+ assert!(!export_output.status.success());
+ assert_eq!(export["operation_id"], "order.app.export");
+ assert_eq!(export["errors"][0]["detail"]["state"], "conflict");
+ assert_eq!(
+ export["errors"][0]["detail"]["issues"][0]["code"],
+ "app_order_signed_evidence_conflict"
+ );
+ assert!(!export_path.exists());
+
+ let (submit_output, submit) = sandbox.json_output(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "order",
+ "submit",
+ record_id.as_str(),
+ ]);
+ assert!(!submit_output.status.success());
+ assert_eq!(submit["operation_id"], "order.submit");
+ assert_eq!(submit["errors"][0]["detail"]["state"], "invalid");
+ assert_eq!(
+ submit["errors"][0]["detail"]["issues"][0]["code"],
+ "app_order_signed_evidence_conflict"
+ );
+}
+
+#[test]
fn order_app_records_fail_closed_when_not_current_or_supported() {
let sandbox = RadrootsCliSandbox::new();
let account = sandbox.json_success(&["--format", "json", "account", "create"]);