app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 0aa809291cebebbd33452dfde12ff82dc3d684f9
parent cdcca6b7b30ecec4af02f14720ceb167893a9075
Author: triesap <tyson@radroots.org>
Date:   Mon, 25 May 2026 08:15:28 +0000

sync: gate order provenance relay

Diffstat:
Mcrates/launchers/desktop/src/runtime.rs | 127++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
1 file changed, 110 insertions(+), 17 deletions(-)

diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -5690,10 +5690,7 @@ fn order_request_listing_event_ptr( })? .trim() .to_owned(); - let listing_relay = selected_listing_relay(&payload.listing_relays, configured_relay_urls) - .ok_or_else(|| { - AppSyncTransportError::failed("order request publish requires listing relay") - })?; + let listing_relay = selected_listing_relay(&payload.listing_relays, configured_relay_urls)?; Ok(RadrootsNostrEventPtr { id: listing_event_id, @@ -5704,7 +5701,7 @@ fn order_request_listing_event_ptr( fn selected_listing_relay( listing_relays: &[String], configured_relay_urls: &[String], -) -> Option<String> { +) -> Result<String, AppSyncTransportError> { let mut seen = BTreeSet::new(); let mut known_relays = Vec::new(); for relay in listing_relays { @@ -5713,15 +5710,30 @@ fn selected_listing_relay( known_relays.push(relay.to_owned()); } } + if known_relays.is_empty() { + return Err(AppSyncTransportError::failed( + "order request publish requires listing relay", + )); + } for configured_relay in configured_relay_urls { let configured_relay = configured_relay.trim(); if !configured_relay.is_empty() && known_relays.iter().any(|relay| relay == configured_relay) { - return Some(configured_relay.to_owned()); + return Ok(configured_relay.to_owned()); } } - known_relays.into_iter().next() + Err(missing_listing_provenance_relay_error(&known_relays)) +} + +fn missing_listing_provenance_relay_error(known_relays: &[String]) -> AppSyncTransportError { + AppSyncTransportError::failed( + json!({ + "code": "missing_listing_provenance_relay", + "missing_provenance_relays": known_relays, + }) + .to_string(), + ) } fn order_request_publish_payload_to_sdk_order( @@ -7542,13 +7554,13 @@ mod tests { HomeRoute, }; use radroots_app_sync::{ - AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderRequestPublishPayload, - AppPublishContext, AppPublishPayload, AppPublishedOperationReceipt, AppSyncRequest, - AppSyncResult, AppSyncRunStatus, AppSyncTransport, AppSyncTransportError, - PendingSyncOperation, PendingSyncOperationState, RecordedAppSyncTransport, - SyncAggregateRef, SyncCheckpointState, SyncCheckpointStatus, SyncConflict, - SyncConflictKind, SyncConflictResolutionStatus, SyncConflictSeverity, SyncOperationKind, - SyncTrigger, + AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderRequestItemPayload, + AppOrderRequestPublishPayload, AppPublishContext, AppPublishPayload, + AppPublishedOperationReceipt, AppSyncRequest, AppSyncResult, AppSyncRunStatus, + AppSyncTransport, AppSyncTransportError, PendingSyncOperation, PendingSyncOperationState, + RecordedAppSyncTransport, SyncAggregateRef, SyncCheckpointState, SyncCheckpointStatus, + SyncConflict, SyncConflictKind, SyncConflictResolutionStatus, SyncConflictSeverity, + SyncOperationKind, SyncTrigger, }; use radroots_identity::RadrootsIdentity; use radroots_local_events::{ @@ -7599,6 +7611,7 @@ mod tests { struct ThreadedAckRelay { url: String, + events: Arc<Mutex<Vec<serde_json::Value>>>, shutdown_tx: Option<oneshot::Sender<()>>, join_handle: Option<thread::JoinHandle<()>>, } @@ -7608,6 +7621,7 @@ mod tests { let (url_tx, url_rx) = mpsc::channel(); let (shutdown_tx, shutdown_rx) = oneshot::channel(); let events: Arc<Mutex<Vec<serde_json::Value>>> = Arc::new(Mutex::new(Vec::new())); + let thread_events = events.clone(); let join_handle = thread::spawn(move || { let runtime = TokioRuntimeBuilder::new_current_thread() .enable_all() @@ -7630,7 +7644,7 @@ mod tests { let Ok((stream, _)) = accepted else { break; }; - let events = events.clone(); + let events = thread_events.clone(); tokio::spawn(async move { let Ok(websocket) = tokio_tungstenite::accept_async(stream).await else { return; @@ -7687,6 +7701,7 @@ mod tests { Self { url, + events, shutdown_tx: Some(shutdown_tx), join_handle: Some(join_handle), } @@ -7695,6 +7710,10 @@ mod tests { fn url(&self) -> &str { self.url.as_str() } + + fn event_count(&self) -> usize { + self.events.lock().expect("relay events lock").len() + } } fn relay_event_matches_filters( @@ -7728,6 +7747,19 @@ mod tests { true } + fn assert_missing_listing_provenance_relay_error( + error: &AppSyncTransportError, + relay_url: &str, + ) { + let AppSyncTransportError::Failed { message } = error else { + panic!("unexpected error: {error}"); + }; + let value = + serde_json::from_str::<serde_json::Value>(message).expect("structured relay error"); + assert_eq!(value["code"], "missing_listing_provenance_relay"); + assert_eq!(value["missing_provenance_relays"], json!([relay_url])); + } + impl Drop for ThreadedAckRelay { fn drop(&mut self) { if let Some(shutdown_tx) = self.shutdown_tx.take() { @@ -8051,9 +8083,70 @@ mod tests { "wss://relay-a.example".to_owned(), "wss://relay-c.example".to_owned(), ], - ); + ) + .expect("configured listing relay should be selected"); + + assert_eq!(selected.as_str(), "wss://relay-a.example"); + } + + #[test] + fn order_request_listing_pointer_rejects_missing_configured_provenance_relay() { + let error = super::selected_listing_relay( + &["wss://listing.example".to_owned()], + &["wss://target.example".to_owned()], + ) + .expect_err("missing listing provenance relay should fail"); + + assert_missing_listing_provenance_relay_error(&error, "wss://listing.example"); + } + + #[test] + fn runtime_direct_relay_transport_rejects_order_request_missing_listing_provenance_target() { + let relay = ThreadedAckRelay::spawn(); + let manager = RadrootsNostrAccountsManager::new_in_memory(); + let account_id = manager + .generate_identity(Some("Buyer".to_owned()), true) + .expect("buyer account should generate"); + let identity = manager + .get_signing_identity(&account_id) + .expect("buyer signer lookup should succeed") + .expect("buyer account should have local signer"); + let seller_pubkey = "2222222222222222222222222222222222222222222222222222222222222222"; + let payload = AppPublishPayload::OrderRequest(AppOrderRequestPublishPayload { + context: AppPublishContext::new(account_id.to_string(), "order_missing_listing_relay"), + order_id: OrderId::new(), + farm_id: FarmId::new(), + status: Some("needs_action".to_owned()), + order_document_json: Some(json!({"document": {"order": {}}})), + listing_addr: Some(format!("30402:{seller_pubkey}:listing-key")), + listing_event_id: Some("listing-event-id".to_owned()), + listing_relays: vec!["wss://listing.example".to_owned()], + buyer_pubkey: Some(identity.public_key_hex()), + seller_pubkey: Some(seller_pubkey.to_owned()), + items: vec![AppOrderRequestItemPayload { + product_id: ProductId::new(), + quantity: 1, + }], + currency_code: Some("USD".to_owned()), + total_minor_units: Some(450), + note: None, + }); + let operation = PendingSyncOperation::from_publish_payload(payload, "2026-05-25T07:00:00Z") + .expect("order publish payload should serialize"); + let mut transport = + SdkDirectRelayAppSyncTransport::with_relay_urls(manager, vec![relay.url().to_owned()]); + + let error = transport + .sync(AppSyncRequest { + trigger: SyncTrigger::ManualRefresh, + checkpoint: SyncCheckpointStatus::never_synced(), + pending_operations: vec![operation], + known_conflicts: Vec::new(), + }) + .expect_err("missing listing provenance relay should fail"); - assert_eq!(selected.as_deref(), Some("wss://relay-a.example")); + assert_missing_listing_provenance_relay_error(&error, "wss://listing.example"); + assert_eq!(relay.event_count(), 0); } #[test]