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:
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 {