cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

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:
Msrc/domain/runtime.rs | 2+-
Msrc/runtime/order.rs | 394+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mtests/target_cli.rs | 399++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
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"]);