app

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

commit c50c875991d3193741f1b8e099ff5da2bc016ebc
parent 63feea28db87214b1de6a4da14f4d7e3c6b9d587
Author: triesap <tyson@radroots.org>
Date:   Mon, 25 May 2026 02:39:29 +0000

buyer: persist listing relay provenance

- add SQLite storage for listing relay snapshots on cart and order lines
- project signed listing relay delivery into buyer listings and order exports
- publish order requests from listing provenance instead of app relay config
- cover provenance selection with focused SQLite and runtime tests

Diffstat:
Mcrates/launchers/desktop/src/runtime.rs | 91++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/shared/models/src/lib.rs | 2++
Acrates/shared/sqlite/migrations/0018_listing_relay_provenance.sql | 3+++
Mcrates/shared/sqlite/src/buyer.rs | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/shared/sqlite/src/lib.rs | 10++++++++++
Mcrates/shared/sqlite/src/migrations.rs | 4++++
6 files changed, 204 insertions(+), 20 deletions(-)

diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -168,7 +168,7 @@ impl SdkDirectRelayAppSyncTransport { ) })?; let relay_urls = normalized_app_sync_relay_urls(&self.relay_urls)?; - let client = direct_relay_sdk_client(relay_urls, self.timeout_ms)?; + let client = direct_relay_sdk_client(relay_urls.clone(), self.timeout_ms)?; let mut published_receipts = Vec::new(); for operation in &request.pending_operations { @@ -193,7 +193,8 @@ impl SdkDirectRelayAppSyncTransport { "pending app publish work is blocked: {reason_codes}" )) })?; - let receipt = publish_app_payload_sync(&client, &identity, &publish_payload)?; + let receipt = + publish_app_payload_sync(&client, &identity, &publish_payload, &relay_urls)?; published_receipts.push(published_operation_receipt( operation.operation_key.as_str(), &publish_payload, @@ -3616,7 +3617,7 @@ impl DesktopAppRuntimeState { order_document_json: Some(local_work.payload.clone()), listing_addr: export.listing_addr, listing_event_id: export.listing_event_id, - listing_relays: self.nostr_relay_urls.clone(), + listing_relays: export.listing_relays, buyer_pubkey: export.buyer_pubkey, seller_pubkey: export.seller_pubkey, items: order @@ -4855,18 +4856,22 @@ fn publish_app_payload_sync( client: &RadrootsSdkClient, identity: &RadrootsIdentity, payload: &AppPublishPayload, + configured_relay_urls: &[String], ) -> Result<SdkPublishReceipt, AppSyncTransportError> { let runtime = TokioRuntimeBuilder::new_current_thread() .enable_all() .build() .map_err(|error| AppSyncTransportError::failed(error.to_string()))?; - runtime.block_on(async { publish_app_payload(client, identity, payload).await }) + runtime.block_on(async { + publish_app_payload(client, identity, payload, configured_relay_urls).await + }) } async fn publish_app_payload( client: &RadrootsSdkClient, identity: &RadrootsIdentity, payload: &AppPublishPayload, + configured_relay_urls: &[String], ) -> Result<SdkPublishReceipt, AppSyncTransportError> { match payload { AppPublishPayload::FarmProfile(payload) => { @@ -4900,7 +4905,7 @@ async fn publish_app_payload( .map_err(|error| AppSyncTransportError::failed(error.to_string())) } AppPublishPayload::OrderRequest(payload) => { - let listing_event = order_request_listing_event_ptr(payload)?; + let listing_event = order_request_listing_event_ptr(payload, configured_relay_urls)?; let order = order_request_publish_payload_to_sdk_order(payload)?; client .trade() @@ -5093,6 +5098,7 @@ fn parse_app_listing_delivery_method( fn order_request_listing_event_ptr( payload: &AppOrderRequestPublishPayload, + configured_relay_urls: &[String], ) -> Result<RadrootsNostrEventPtr, AppSyncTransportError> { let listing_event_id = payload .listing_event_id @@ -5102,15 +5108,10 @@ fn order_request_listing_event_ptr( })? .trim() .to_owned(); - let listing_relay = payload - .listing_relays - .iter() - .map(|relay| relay.trim()) - .find(|relay| !relay.is_empty()) + let listing_relay = selected_listing_relay(&payload.listing_relays, configured_relay_urls) .ok_or_else(|| { AppSyncTransportError::failed("order request publish requires listing relay") - })? - .to_owned(); + })?; Ok(RadrootsNostrEventPtr { id: listing_event_id, @@ -5118,6 +5119,29 @@ fn order_request_listing_event_ptr( }) } +fn selected_listing_relay( + listing_relays: &[String], + configured_relay_urls: &[String], +) -> Option<String> { + let mut seen = BTreeSet::new(); + let mut known_relays = Vec::new(); + for relay in listing_relays { + let relay = relay.trim(); + if !relay.is_empty() && seen.insert(relay.to_owned()) { + known_relays.push(relay.to_owned()); + } + } + 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()); + } + } + known_relays.into_iter().next() +} + fn order_request_publish_payload_to_sdk_order( payload: &AppOrderRequestPublishPayload, ) -> Result<RadrootsTradeOrderRequested, AppSyncTransportError> { @@ -5301,6 +5325,7 @@ struct AppBuyerOrderRequestExport { seller_pubkey: Option<String>, listing_addr: Option<String>, listing_event_id: Option<String>, + listing_relays: Vec<String>, farm_key: Option<String>, order_items: Vec<serde_json::Value>, line_refs: Vec<serde_json::Value>, @@ -5330,6 +5355,7 @@ impl AppBuyerOrderRequestExport { shared_optional_line_value(&order.lines, |line| line.listing_addr.as_deref()); let listing_event_id = shared_optional_line_value(&order.lines, |line| line.listing_event_id.as_deref()); + let listing_relays = shared_listing_relays(&order.lines); let seller_pubkey = shared_optional_line_value(&order.lines, |line| line.seller_pubkey.as_deref()); let farm_key = shared_optional_line_value(&order.lines, |line| line.farm_key.as_deref()) @@ -5341,6 +5367,9 @@ impl AppBuyerOrderRequestExport { if listing_event_id.is_none() { support_issues.push("listing_event_id_required"); } + if listing_relays.is_empty() { + support_issues.push("listing_relays_required"); + } if seller_pubkey.is_none() { support_issues.push("seller_pubkey_required"); } @@ -5370,6 +5399,7 @@ impl AppBuyerOrderRequestExport { }, "listing_addr": line.listing_addr, "listing_event_id": line.listing_event_id, + "listing_relays": line.listing_relays, "listing_bin_id": line.listing_bin_id, "seller_pubkey": line.seller_pubkey, "farm_key": line.farm_key, @@ -5383,6 +5413,7 @@ impl AppBuyerOrderRequestExport { seller_pubkey, listing_addr, listing_event_id, + listing_relays, farm_key, order_items, line_refs, @@ -5441,6 +5472,7 @@ fn buyer_order_request_local_work_payload( "order_id": order.order_id.to_string(), "listing_addr": export.listing_addr.as_deref().unwrap_or_default(), "listing_event_id": export.listing_event_id.as_deref().unwrap_or_default(), + "listing_relays": export.listing_relays.clone(), "buyer_pubkey": export.buyer_pubkey.as_deref().unwrap_or_default(), "seller_pubkey": export.seller_pubkey.as_deref().unwrap_or_default(), "items": export.order_items.clone(), @@ -5632,6 +5664,20 @@ fn shared_optional_line_value( resolved } +fn shared_listing_relays(lines: &[BuyerOrderLocalEventLine]) -> Vec<String> { + let mut seen = BTreeSet::new(); + let mut relays = Vec::new(); + for line in lines { + for relay in &line.listing_relays { + let relay = relay.trim(); + if !relay.is_empty() && seen.insert(relay.to_owned()) { + relays.push(relay.to_owned()); + } + } + } + relays +} + fn canonical_quantity_unit(unit_label: &str) -> Option<&'static str> { match unit_label.trim().to_ascii_lowercase().as_str() { "each" | "ea" | "count" => Some("each"), @@ -7116,6 +7162,22 @@ mod tests { } #[test] + fn order_request_listing_pointer_prefers_configured_listing_relay() { + let selected = super::selected_listing_relay( + &[ + "wss://relay-b.example".to_owned(), + "wss://relay-a.example".to_owned(), + ], + &[ + "wss://relay-a.example".to_owned(), + "wss://relay-c.example".to_owned(), + ], + ); + + assert_eq!(selected.as_deref(), Some("wss://relay-a.example")); + } + + #[test] fn runtime_direct_relay_transport_requires_local_signing_custody() { let relay = ThreadedAckRelay::spawn(); let manager = RadrootsNostrAccountsManager::new_in_memory(); @@ -10157,6 +10219,7 @@ mod tests { pending_payload.listing_event_id.as_deref(), Some("event-cli:signed_event:buyer-order-supported-listing") ); + assert_eq!(pending_payload.listing_relays, vec!["ws://127.0.0.1:1234/"]); assert_eq!( pending_payload.seller_pubkey.as_deref(), Some("buyer-visible-seller-pubkey") @@ -10263,6 +10326,10 @@ mod tests { "event-cli:signed_event:buyer-order-supported-listing" ); assert_eq!( + payload["document"]["order"]["listing_relays"], + json!(["ws://127.0.0.1:1234/"]) + ); + assert_eq!( payload["document"]["order"]["seller_pubkey"], "buyer-visible-seller-pubkey" ); diff --git a/crates/shared/models/src/lib.rs b/crates/shared/models/src/lib.rs @@ -1268,6 +1268,7 @@ pub struct BuyerListingRow { pub product_id: ProductId, pub farm_id: FarmId, pub farm_display_name: String, + pub listing_relays: Vec<String>, pub title: String, pub subtitle: Option<String>, pub price: ProductPricePresentation, @@ -3565,6 +3566,7 @@ mod tests { product_id, farm_id, farm_display_name: "Cedar Grove Farm".to_owned(), + listing_relays: vec!["wss://relay.example".to_owned()], title: "Spring salad mix".to_owned(), subtitle: Some("Tender leaves".to_owned()), price: ProductPricePresentation { diff --git a/crates/shared/sqlite/migrations/0018_listing_relay_provenance.sql b/crates/shared/sqlite/migrations/0018_listing_relay_provenance.sql @@ -0,0 +1,3 @@ +ALTER TABLE buyer_cart_lines ADD COLUMN listing_relays_json TEXT; + +ALTER TABLE order_lines ADD COLUMN listing_relays_json TEXT; diff --git a/crates/shared/sqlite/src/buyer.rs b/crates/shared/sqlite/src/buyer.rs @@ -11,6 +11,7 @@ use radroots_app_models::{ RepeatDemandHandoffProjection, }; use rusqlite::{Connection, OptionalExtension, params}; +use serde_json::Value; use crate::AppSqliteError; @@ -56,6 +57,7 @@ pub struct BuyerOrderLocalEventLine { pub farm_key: Option<String>, pub listing_addr: Option<String>, pub listing_event_id: Option<String>, + pub listing_relays: Vec<String>, pub seller_pubkey: Option<String>, } @@ -191,6 +193,7 @@ impl<'a> AppBuyerRepository<'a> { for line in &cart.lines { let snapshot = self.load_buyer_cart_line_snapshot(line.product_id)?; + let listing_relays_json = encode_listing_relays(&snapshot.listing_relays)?; self.connection .execute( "insert into buyer_cart_lines ( @@ -204,9 +207,10 @@ impl<'a> AppBuyerRepository<'a> { farm_key, listing_addr, listing_event_id, + listing_relays_json, seller_pubkey, updated_at - ) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))", + ) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))", params![ context_key.as_str(), line.product_id.to_string(), @@ -218,6 +222,7 @@ impl<'a> AppBuyerRepository<'a> { snapshot.farm_key.as_deref(), snapshot.listing_addr.as_deref(), snapshot.listing_event_id.as_deref(), + listing_relays_json.as_deref(), snapshot.seller_pubkey.as_deref(), ], ) @@ -413,6 +418,7 @@ impl<'a> AppBuyerRepository<'a> { })?; for (index, line) in line_records.iter().enumerate() { + let listing_relays_json = encode_listing_relays(&line.listing.listing_relays)?; self.connection .execute( "insert into order_lines ( @@ -428,9 +434,10 @@ impl<'a> AppBuyerRepository<'a> { farm_key, listing_addr, listing_event_id, + listing_relays_json, seller_pubkey, sort_index - ) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", + ) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", params![ format!("{}:{}", order_id, line.listing.product_id), order_id.to_string(), @@ -444,6 +451,7 @@ impl<'a> AppBuyerRepository<'a> { line.listing.farm_key.as_deref(), line.listing.listing_addr.as_deref(), line.listing.listing_event_id.as_deref(), + listing_relays_json.as_deref(), line.listing.seller_pubkey.as_deref(), index as i64, ], @@ -1203,6 +1211,16 @@ impl<'a> AppBuyerRepository<'a> { order by li.local_seq desc limit 1 ), + ( + select li.relay_delivery_json + from local_interop_imports li + where li.projected_kind = 'listing' + and li.projected_id = p.id + and li.relay_delivery_json is not null + and trim(li.relay_delivery_json) <> '' + order by li.local_seq desc + limit 1 + ), p.stock_count, fw.id, fw.label, @@ -1250,15 +1268,16 @@ impl<'a> AppBuyerRepository<'a> { row.get::<_, Option<String>>(12)?, row.get::<_, Option<String>>(13)?, row.get::<_, Option<String>>(14)?, - row.get::<_, Option<u32>>(15)?, - row.get::<_, Option<String>>(16)?, + row.get::<_, Option<String>>(15)?, + row.get::<_, Option<u32>>(16)?, row.get::<_, Option<String>>(17)?, row.get::<_, Option<String>>(18)?, row.get::<_, Option<String>>(19)?, row.get::<_, Option<String>>(20)?, - row.get::<_, i64>(21)?, + row.get::<_, Option<String>>(21)?, row.get::<_, i64>(22)?, row.get::<_, i64>(23)?, + row.get::<_, i64>(24)?, )) }) .map_err(|source| AppSqliteError::Query { @@ -1284,6 +1303,7 @@ impl<'a> AppBuyerRepository<'a> { listing_addr, listing_event_id, seller_pubkey, + listing_relay_delivery_json, stock_count, fulfillment_window_id, fulfillment_window_label, @@ -1313,6 +1333,7 @@ impl<'a> AppBuyerRepository<'a> { farm_key: farm_key.and_then(empty_string_to_none), listing_addr: listing_addr.and_then(empty_string_to_none), listing_event_id: listing_event_id.and_then(empty_string_to_none), + listing_relays: listing_relays_from_json(listing_relay_delivery_json)?, seller_pubkey: seller_pubkey.and_then(empty_string_to_none), stock_count, fulfillment_window_id: parse_optional_typed_id( @@ -1362,6 +1383,7 @@ impl<'a> AppBuyerRepository<'a> { farm_key: listing.farm_key, listing_addr: listing.listing_addr, listing_event_id: listing.listing_event_id, + listing_relays: listing.listing_relays, seller_pubkey: listing.seller_pubkey, }) .unwrap_or_default()) @@ -1461,6 +1483,16 @@ impl<'a> AppBuyerRepository<'a> { order by li.local_seq desc limit 1 )), + coalesce(nullif(bcl.listing_relays_json, ''), ( + select li.relay_delivery_json + from local_interop_imports li + where li.projected_kind = 'listing' + and li.projected_id = p.id + and li.relay_delivery_json is not null + and trim(li.relay_delivery_json) <> '' + order by li.local_seq desc + limit 1 + )), coalesce(nullif(bcl.seller_pubkey, ''), ( select li.owner_pubkey from local_interop_imports li @@ -1522,15 +1554,16 @@ impl<'a> AppBuyerRepository<'a> { row.get::<_, Option<String>>(13)?, row.get::<_, Option<String>>(14)?, row.get::<_, Option<String>>(15)?, - row.get::<_, Option<u32>>(16)?, - row.get::<_, Option<String>>(17)?, + row.get::<_, Option<String>>(16)?, + row.get::<_, Option<u32>>(17)?, row.get::<_, Option<String>>(18)?, row.get::<_, Option<String>>(19)?, row.get::<_, Option<String>>(20)?, row.get::<_, Option<String>>(21)?, - row.get::<_, i64>(22)?, + row.get::<_, Option<String>>(22)?, row.get::<_, i64>(23)?, row.get::<_, i64>(24)?, + row.get::<_, i64>(25)?, )) }) .map_err(|source| AppSqliteError::Query { @@ -1556,6 +1589,7 @@ impl<'a> AppBuyerRepository<'a> { farm_key, listing_addr, listing_event_id, + listing_relays_json, seller_pubkey, stock_count, fulfillment_window_id, @@ -1585,6 +1619,7 @@ impl<'a> AppBuyerRepository<'a> { farm_key: farm_key.and_then(empty_string_to_none), listing_addr: listing_addr.and_then(empty_string_to_none), listing_event_id: listing_event_id.and_then(empty_string_to_none), + listing_relays: listing_relays_from_json(listing_relays_json)?, seller_pubkey: seller_pubkey.and_then(empty_string_to_none), stock_count, fulfillment_window_id: parse_optional_typed_id( @@ -1672,6 +1707,7 @@ impl<'a> AppBuyerRepository<'a> { ol.farm_key, ol.listing_addr, ol.listing_event_id, + ol.listing_relays_json, ol.seller_pubkey from order_lines ol where ol.order_id = ?1 @@ -1696,6 +1732,7 @@ impl<'a> AppBuyerRepository<'a> { row.get::<_, Option<String>>(9)?, row.get::<_, Option<String>>(10)?, row.get::<_, Option<String>>(11)?, + row.get::<_, Option<String>>(12)?, )) }) .map_err(|source| AppSqliteError::Query { @@ -1717,6 +1754,7 @@ impl<'a> AppBuyerRepository<'a> { farm_key, listing_addr, listing_event_id, + listing_relays_json, seller_pubkey, ) = row.map_err(|source| AppSqliteError::Query { operation: "read buyer order local event line", @@ -1745,6 +1783,7 @@ impl<'a> AppBuyerRepository<'a> { farm_key: farm_key.and_then(empty_string_to_none), listing_addr: listing_addr.and_then(empty_string_to_none), listing_event_id: listing_event_id.and_then(empty_string_to_none), + listing_relays: listing_relays_from_json(listing_relays_json)?, seller_pubkey: seller_pubkey.and_then(empty_string_to_none), }); } @@ -1990,6 +2029,7 @@ struct BuyerListingRecord { farm_key: Option<String>, listing_addr: Option<String>, listing_event_id: Option<String>, + listing_relays: Vec<String>, seller_pubkey: Option<String>, stock_count: Option<u32>, fulfillment_window_id: Option<FulfillmentWindowId>, @@ -2057,6 +2097,7 @@ impl BuyerListingRecord { product_id: self.product_id, farm_id: self.farm_id, farm_display_name: self.farm_display_name, + listing_relays: self.listing_relays, title: self.title, subtitle: self.subtitle, price, @@ -2168,6 +2209,7 @@ struct BuyerCartLineSnapshot { farm_key: Option<String>, listing_addr: Option<String>, listing_event_id: Option<String>, + listing_relays: Vec<String>, seller_pubkey: Option<String>, } @@ -2556,6 +2598,62 @@ fn empty_string_to_none_option(value: Option<String>) -> Option<String> { value.and_then(empty_string_to_none) } +fn encode_listing_relays(relays: &[String]) -> Result<Option<String>, AppSqliteError> { + let relays = normalized_listing_relays(relays.iter().map(String::as_str)); + if relays.is_empty() { + return Ok(None); + } + + serde_json::to_string(&relays) + .map(Some) + .map_err(|_| AppSqliteError::InvalidProjection { + reason: "listing relay provenance must encode", + }) +} + +fn listing_relays_from_json(value: Option<String>) -> Result<Vec<String>, AppSqliteError> { + let Some(value) = empty_string_to_none_option(value) else { + return Ok(Vec::new()); + }; + let value = serde_json::from_str::<Value>(value.as_str()).map_err(|_| { + AppSqliteError::InvalidProjection { + reason: "listing relay provenance json must decode", + } + })?; + if let Some(relays) = value.as_array() { + return Ok(relays_from_json_array(relays)); + } + + for key in ["acknowledged_relays", "target_relays", "connected_relays"] { + let relays = value + .get(key) + .and_then(Value::as_array) + .map(|relays| relays_from_json_array(relays)) + .unwrap_or_default(); + if !relays.is_empty() { + return Ok(relays); + } + } + + Ok(Vec::new()) +} + +fn relays_from_json_array(relays: &[Value]) -> Vec<String> { + normalized_listing_relays(relays.iter().filter_map(Value::as_str)) +} + +fn normalized_listing_relays<'a>(relays: impl IntoIterator<Item = &'a str>) -> Vec<String> { + let mut seen = BTreeSet::new(); + let mut normalized = Vec::new(); + for relay in relays { + let relay = relay.trim(); + if !relay.is_empty() && seen.insert(relay.to_owned()) { + normalized.push(relay.to_owned()); + } + } + normalized +} + #[cfg(test)] mod tests { use std::collections::BTreeSet; diff --git a/crates/shared/sqlite/src/lib.rs b/crates/shared/sqlite/src/lib.rs @@ -813,6 +813,11 @@ mod tests { )); assert!(column_exists(connection, "order_lines", "price_currency")); assert!(column_exists(connection, "order_lines", "listing_addr")); + assert!(column_exists( + connection, + "order_lines", + "listing_relays_json" + )); assert!(column_exists(connection, "products", "category")); assert!(column_exists(connection, "products", "listing_bin_id")); assert!(column_exists(connection, "buyer_carts", "buyer_email")); @@ -839,6 +844,11 @@ mod tests { "buyer_cart_lines", "listing_event_id" )); + assert!(column_exists( + connection, + "buyer_cart_lines", + "listing_relays_json" + )); assert!(column_exists(connection, "orders", "buyer_context_key")); assert!(column_exists(connection, "orders", "buyer_email")); assert!(column_exists(connection, "orders", "buyer_phone")); diff --git a/crates/shared/sqlite/src/migrations.rs b/crates/shared/sqlite/src/migrations.rs @@ -72,6 +72,10 @@ const MIGRATIONS: &[Migration] = &[ version: 17, sql: include_str!("../migrations/0017_product_category.sql"), }, + Migration { + version: 18, + sql: include_str!("../migrations/0018_listing_relay_provenance.sql"), + }, ]; pub fn latest_schema_version() -> u32 {