commit 0516f2f7d4a493c73ae339b8a24d9f4a7d5d8fc4
parent ab70f5cc46f6bf3eeef71b948c97fcda8602ad03
Author: triesap <tyson@radroots.org>
Date: Mon, 25 May 2026 19:20:38 +0000
order: require listing relay provenance
- capture listing relay provenance in order drafts from market and shared listing evidence
- expose listing relay provenance across order views and submit error details
- reject order submit before signing when configured relays miss known listing provenance
- add focused order provenance tests and provenance-qualified CLI fixtures
Diffstat:
6 files changed, 526 insertions(+), 34 deletions(-)
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -1301,6 +1301,8 @@ pub struct OrderNewView {
pub listing_addr: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub listing_event_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub listing_relays: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub buyer_account_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -1348,6 +1350,8 @@ pub struct OrderGetView {
pub listing_addr: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub listing_event_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub listing_relays: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub buyer_account_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -1451,6 +1455,8 @@ pub struct OrderAppRecordSummaryView {
pub farm_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub listing_addr: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub listing_relays: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub order_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -1481,6 +1487,8 @@ pub struct OrderAppRecordExportView {
pub listing_addr: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub listing_event_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub listing_relays: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub buyer_account_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -1522,6 +1530,8 @@ pub struct OrderSubmitView {
pub listing_addr: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub listing_event_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub listing_relays: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub buyer_account_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -2528,6 +2538,8 @@ pub struct OrderSummaryView {
pub listing_addr: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub listing_event_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub listing_relays: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub buyer_account_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
diff --git a/src/operation_order.rs b/src/operation_order.rs
@@ -1399,6 +1399,7 @@ fn order_submit_error_detail(view: &OrderSubmitView) -> Value {
"listing_lookup": &view.listing_lookup,
"listing_addr": &view.listing_addr,
"listing_event_id": &view.listing_event_id,
+ "listing_relays": &view.listing_relays,
"buyer_account_id": &view.buyer_account_id,
"buyer_pubkey": &view.buyer_pubkey,
"seller_pubkey": &view.seller_pubkey,
diff --git a/src/runtime/order.rs b/src/runtime/order.rs
@@ -49,7 +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, SourceRuntime, validate_supported_buyer_order_request_local_work_payload,
+ LocalRecordStatus, 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,
@@ -109,6 +110,7 @@ use crate::runtime::local_events::{
use crate::runtime::signer::ActorWriteBindingError;
use crate::runtime::sync::{
RelayIngestScope, freshness_for_scope, freshness_requires_refresh, market_refresh,
+ relay_provenance_relays_for_scope,
};
use crate::runtime_args::{
OrderAppRecordExportArgs, OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs,
@@ -216,6 +218,7 @@ struct AppOrderRecordListEntry {
struct ResolvedOrderListing {
listing_addr: String,
listing_event_id: String,
+ listing_relays: Vec<String>,
seller_pubkey: String,
economics_product: Option<ResolvedOrderEconomicsProduct>,
}
@@ -380,6 +383,10 @@ pub fn scaffold(
.as_ref()
.map(|listing| listing.listing_event_id.clone())
.unwrap_or_default();
+ let listing_relays = resolved_listing
+ .as_ref()
+ .map(|listing| listing.listing_relays.clone())
+ .unwrap_or_default();
let items = match normalize_optional(args.bin_id.as_deref()) {
Some(bin_id) => vec![OrderDraftItem {
@@ -407,7 +414,7 @@ pub fn scaffold(
order_id: order_id.clone(),
listing_addr,
listing_event_id,
- listing_relays: Vec::new(),
+ listing_relays,
buyer_pubkey,
seller_pubkey,
items,
@@ -462,6 +469,10 @@ pub fn scaffold_preflight(
.as_ref()
.map(|listing| listing.listing_event_id.clone())
.unwrap_or_default();
+ let listing_relays = resolved_listing
+ .as_ref()
+ .map(|listing| listing.listing_relays.clone())
+ .unwrap_or_default();
let items = match normalize_optional(args.bin_id.as_deref()) {
Some(bin_id) => vec![OrderDraftItem {
@@ -486,7 +497,7 @@ pub fn scaffold_preflight(
order_id: order_id.clone(),
listing_addr,
listing_event_id,
- listing_relays: Vec::new(),
+ listing_relays,
buyer_pubkey,
seller_pubkey,
items,
@@ -532,6 +543,7 @@ pub fn get(config: &RuntimeConfig, args: &RecordLookupArgs) -> Result<OrderGetVi
listing_lookup: None,
listing_addr: None,
listing_event_id: None,
+ listing_relays: Vec::new(),
buyer_account_id: None,
buyer_pubkey: None,
buyer_actor_source: None,
@@ -564,6 +576,7 @@ pub fn get(config: &RuntimeConfig, args: &RecordLookupArgs) -> Result<OrderGetVi
listing_lookup: None,
listing_addr: None,
listing_event_id: None,
+ listing_relays: Vec::new(),
buyer_account_id: None,
buyer_pubkey: None,
buyer_actor_source: None,
@@ -705,6 +718,7 @@ pub fn app_record_export(
order_id: None,
listing_addr: None,
listing_event_id: None,
+ listing_relays: Vec::new(),
buyer_account_id: None,
buyer_pubkey: None,
buyer_actor_source: None,
@@ -738,6 +752,7 @@ pub fn app_record_export(
listing_event_id: non_empty_string(
app_order.loaded.document.order.listing_event_id.clone(),
),
+ listing_relays: order_listing_relays(&app_order.loaded.document),
buyer_account_id: buyer_account_id(&app_order.loaded.document),
buyer_pubkey: non_empty_string(app_order.loaded.document.order.buyer_pubkey.clone()),
buyer_actor_source: buyer_actor_source(&app_order.loaded.document),
@@ -779,6 +794,7 @@ pub fn app_record_export(
listing_event_id: non_empty_string(
app_order.loaded.document.order.listing_event_id.clone(),
),
+ listing_relays: order_listing_relays(&app_order.loaded.document),
buyer_account_id: buyer_account_id(&app_order.loaded.document),
buyer_pubkey: non_empty_string(app_order.loaded.document.order.buyer_pubkey.clone()),
buyer_actor_source: buyer_actor_source(&app_order.loaded.document),
@@ -819,6 +835,7 @@ pub fn submit(
listing_lookup: None,
listing_addr: None,
listing_event_id: None,
+ listing_relays: Vec::new(),
buyer_account_id: None,
buyer_pubkey: None,
buyer_actor_source: None,
@@ -855,6 +872,7 @@ pub fn submit(
listing_lookup: None,
listing_addr: None,
listing_event_id: None,
+ listing_relays: Vec::new(),
buyer_account_id: None,
buyer_pubkey: None,
buyer_actor_source: None,
@@ -899,6 +917,7 @@ pub fn submit(
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),
@@ -933,6 +952,17 @@ pub fn submit(
return Ok(view);
}
+ if config.relay.urls.is_empty() {
+ return Err(RuntimeError::Network(
+ "order submit requires at least one configured relay before publish preflight"
+ .to_owned(),
+ ));
+ }
+
+ if let Some(view) = order_submit_listing_provenance_preflight_view(config, &loaded, args)? {
+ return Ok(view);
+ }
+
let signing = match resolve_local_order_signing_identity(config, &loaded) {
Ok(signing) => signing,
Err(ActorWriteBindingError::Account(failure)) => return Err(failure.into()),
@@ -948,13 +978,6 @@ pub fn submit(
.as_str(),
)?;
- if config.relay.urls.is_empty() {
- return Err(RuntimeError::Network(
- "order submit requires at least one configured relay before publish preflight"
- .to_owned(),
- ));
- }
-
if let Some(view) = order_submit_market_freshness_view(config, &loaded, args)? {
return Ok(view);
}
@@ -8781,12 +8804,30 @@ fn resolve_order_listing(
"explicit listing_addr must reference a public NIP-99 listing".to_owned(),
));
}
- let listing_event_id =
- resolve_active_listing_event_id(config, listing_addr, &parsed)?.unwrap_or_default();
+ let replica_listing_event_id =
+ resolve_active_listing_event_id(config, listing_addr, &parsed)?;
+ let shared_provenance = resolve_shared_signed_listing_provenance(
+ config,
+ listing_addr,
+ replica_listing_event_id.as_deref(),
+ )?;
+ let listing_event_id = replica_listing_event_id
+ .or_else(|| {
+ shared_provenance
+ .as_ref()
+ .map(|provenance| provenance.event_id.clone())
+ })
+ .unwrap_or_default();
+ let listing_relays = listing_provenance_relays(
+ config,
+ listing_event_id.as_str(),
+ shared_provenance.as_ref(),
+ )?;
let economics_product = resolve_trade_product_by_listing_addr(config, listing_addr)?;
return Ok(Some(ResolvedOrderListing {
listing_addr: listing_addr.to_owned(),
listing_event_id,
+ listing_relays,
seller_pubkey: parsed.seller_pubkey,
economics_product,
}));
@@ -8837,10 +8878,21 @@ fn resolve_order_listing(
"listing `{listing_lookup}` is missing the latest listing event pointer; run `radroots market refresh` before creating an order from this listing"
))
})?;
+ let shared_provenance = resolve_shared_signed_listing_provenance(
+ config,
+ listing_addr.as_str(),
+ Some(listing_event_id.as_str()),
+ )?;
+ let listing_relays = listing_provenance_relays(
+ config,
+ listing_event_id.as_str(),
+ shared_provenance.as_ref(),
+ )?;
Ok(Some(ResolvedOrderListing {
listing_addr,
listing_event_id,
+ listing_relays,
seller_pubkey: parsed.seller_pubkey,
economics_product: Some(economics_product),
}))
@@ -8935,6 +8987,72 @@ fn resolve_active_listing_event_id(
Ok(Some(state.last_event_id))
}
+#[derive(Debug, Clone)]
+struct SharedListingProvenance {
+ event_id: String,
+ relays: Vec<String>,
+}
+
+fn listing_provenance_relays(
+ config: &RuntimeConfig,
+ listing_event_id: &str,
+ shared_provenance: Option<&SharedListingProvenance>,
+) -> Result<Vec<String>, RuntimeError> {
+ let mut relays = Vec::<String>::new();
+ if let Some(provenance) = shared_provenance
+ && provenance.event_id == listing_event_id
+ {
+ relays.extend(provenance.relays.iter().cloned());
+ }
+ relays.extend(relay_provenance_relays_for_scope(
+ config,
+ RelayIngestScope::MarketRefresh,
+ )?);
+ normalize_listing_relay_set(relays)
+ .map_err(|error| RuntimeError::Config(format!("listing provenance relays: {error}")))
+}
+
+fn resolve_shared_signed_listing_provenance(
+ config: &RuntimeConfig,
+ listing_addr: &str,
+ listing_event_id: Option<&str>,
+) -> Result<Option<SharedListingProvenance>, RuntimeError> {
+ let mut candidates = list_shared_records_latest(config, ORDER_APP_RECORD_LIST_LIMIT)?
+ .into_iter()
+ .filter(|record| record.family == LocalRecordFamily::SignedEvent)
+ .filter(|record| record.status == LocalRecordStatus::Published)
+ .filter(|record| record.event_kind == Some(i64::from(KIND_LISTING)))
+ .filter(|record| record.listing_addr.as_deref() == Some(listing_addr))
+ .filter(|record| {
+ listing_event_id.is_none() || record.event_id.as_deref() == listing_event_id
+ })
+ .filter_map(|record| {
+ let event_id = record.event_id?;
+ if !is_valid_event_id(event_id.as_str()) {
+ return None;
+ }
+ let delivery = record.relay_delivery_json.as_ref()?;
+ let evidence = RelayDeliveryEvidence::from_json_value(delivery).ok()?;
+ if evidence.state != RelayDeliveryState::Acknowledged {
+ return None;
+ }
+ let relays = normalize_listing_relay_set(evidence.acknowledged_relays).ok()?;
+ if relays.is_empty() {
+ return None;
+ }
+ Some(SharedListingProvenance { event_id, relays })
+ })
+ .collect::<Vec<_>>();
+ candidates.sort_by(|left, right| left.event_id.cmp(&right.event_id));
+ candidates.dedup_by(|left, right| left.event_id == right.event_id);
+ if candidates.len() > 1 && listing_event_id.is_none() {
+ return Err(RuntimeError::Config(format!(
+ "listing address `{listing_addr}` has multiple published shared local listing events; run `radroots market refresh` or pass a current listing event id source"
+ )));
+ }
+ Ok(candidates.pop())
+}
+
fn trade_product_listing_addr_filter(listing_addr: &str) -> ITradeProductFieldsFilter {
ITradeProductFieldsFilter {
id: None,
@@ -9285,6 +9403,7 @@ fn view_from_loaded_with_source_issues(
listing_lookup: loaded.document.listing_lookup.clone(),
listing_addr,
listing_event_id,
+ 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),
@@ -9343,6 +9462,7 @@ fn summary_from_loaded_with_source_issues(
listing_lookup: loaded.document.listing_lookup.clone(),
listing_addr,
listing_event_id,
+ 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),
@@ -9370,6 +9490,7 @@ fn summary_for_invalid_file(path: &Path, reason: String) -> OrderSummaryView {
listing_lookup: None,
listing_addr: None,
listing_event_id: None,
+ listing_relays: Vec::new(),
buyer_account_id: None,
buyer_pubkey: None,
buyer_actor_source: None,
@@ -9687,6 +9808,7 @@ fn app_order_record_summary(
.listing_addr
.clone()
.or_else(|| non_empty_string(app_order.loaded.document.order.listing_addr.clone())),
+ listing_relays: order_listing_relays(document),
order_id: non_empty_string(document.order.order_id.clone()),
buyer_account_id: buyer_account_id(document),
buyer_pubkey: non_empty_string(document.order.buyer_pubkey.clone()),
@@ -9983,6 +10105,20 @@ fn collect_issues(document: &OrderDraftDocument) -> Vec<OrderIssueView> {
)),
}
+ match normalize_listing_relay_set(document.order.listing_relays.iter()) {
+ Ok(listing_relays) if listing_relays.is_empty() => issues.push(issue_with_code(
+ "listing_provenance_missing",
+ "order.listing_relays",
+ "listing relay provenance is required before order submit; run `radroots market refresh` and create the order from current local market data",
+ )),
+ Ok(_) => {}
+ Err(error) => issues.push(issue_with_code(
+ "listing_provenance_invalid",
+ "order.listing_relays",
+ format!("listing relay provenance is invalid: {error}"),
+ )),
+ }
+
if document.order.items.is_empty() {
issues.push(issue(
"order.items",
@@ -10603,6 +10739,7 @@ fn order_submit_unconfigured_view(
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),
@@ -10643,6 +10780,7 @@ fn order_submit_invalid_quantity_view(
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),
@@ -10671,6 +10809,82 @@ fn order_submit_invalid_quantity_view(
}
}
+fn order_submit_listing_provenance_preflight_view(
+ config: &RuntimeConfig,
+ loaded: &LoadedOrderDraft,
+ args: &OrderSubmitArgs,
+) -> Result<Option<OrderSubmitView>, RuntimeError> {
+ let listing_relays =
+ normalize_listing_relay_set(loaded.document.order.listing_relays.iter())
+ .map_err(|error| RuntimeError::Config(format!("listing provenance relays: {error}")))?;
+ let target_relays = normalize_listing_relay_set(config.relay.urls.iter())
+ .map_err(|error| RuntimeError::Config(format!("configured relay target: {error}")))?;
+ let reachable_relays = listing_relays
+ .iter()
+ .filter(|relay| target_relays.contains(relay))
+ .cloned()
+ .collect::<Vec<_>>();
+ if !reachable_relays.is_empty() {
+ return Ok(None);
+ }
+
+ let mut actions = listing_relays
+ .iter()
+ .map(|relay| {
+ format!(
+ "radroots --relay {} order submit {}",
+ relay, loaded.document.order.order_id
+ )
+ })
+ .collect::<Vec<_>>();
+ actions.push(format!(
+ "radroots order get {}",
+ loaded.document.order.order_id
+ ));
+ Ok(Some(OrderSubmitView {
+ state: "unconfigured".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,
+ 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,
+ connected_relays: Vec::new(),
+ acknowledged_relays: Vec::new(),
+ failed_relays: Vec::new(),
+ idempotency_key: args.idempotency_key.clone(),
+ signer_mode: Some(config.signer.backend.as_str().to_owned()),
+ signer_session_id: None,
+ requested_signer_session_id: None,
+ reason: Some(
+ "order submit requires at least one configured relay that is known to carry the listing"
+ .to_owned(),
+ ),
+ job: None,
+ issues: vec![issue_with_code(
+ "listing_relay_target_mismatch",
+ "order.listing_relays",
+ format!(
+ "configured relays must include one of the listing provenance relays: {}",
+ loaded.document.order.listing_relays.join(", ")
+ ),
+ )],
+ actions,
+ }))
+}
+
fn order_submit_market_freshness_view(
config: &RuntimeConfig,
loaded: &LoadedOrderDraft,
@@ -10916,6 +11130,7 @@ fn order_submit_deduplicated_view(
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),
@@ -10957,6 +11172,7 @@ fn order_submit_dry_run_view(
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),
@@ -11004,6 +11220,7 @@ fn order_submit_invalid_existing_request_view(
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),
@@ -11068,10 +11285,7 @@ fn publish_order_request(
signing: accounts::AccountSigningIdentity,
payload: RadrootsTradeOrderRequested,
) -> Result<OrderSubmitView, RuntimeError> {
- let listing_event = RadrootsNostrEventPtr {
- id: loaded.document.order.listing_event_id.clone(),
- relays: None,
- };
+ let listing_event = order_listing_event_ptr(config, loaded)?;
let client = order_relay_publish_client(config)?;
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
@@ -11090,6 +11304,26 @@ fn publish_order_request(
published_order_submit_view(config, loaded, args, receipt)
}
+fn order_listing_event_ptr(
+ config: &RuntimeConfig,
+ loaded: &LoadedOrderDraft,
+) -> Result<RadrootsNostrEventPtr, RuntimeError> {
+ let listing_relays =
+ normalize_listing_relay_set(loaded.document.order.listing_relays.iter())
+ .map_err(|error| RuntimeError::Config(format!("listing provenance relays: {error}")))?;
+ let target_relays = normalize_listing_relay_set(config.relay.urls.iter())
+ .map_err(|error| RuntimeError::Config(format!("configured relay target: {error}")))?;
+ let selected_relay = listing_relays
+ .iter()
+ .find(|relay| target_relays.contains(relay))
+ .or_else(|| listing_relays.first())
+ .cloned();
+ Ok(RadrootsNostrEventPtr {
+ id: loaded.document.order.listing_event_id.clone(),
+ relays: selected_relay,
+ })
+}
+
fn order_relay_publish_client(config: &RuntimeConfig) -> Result<RadrootsSdkClient, RuntimeError> {
let mut sdk_config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom);
sdk_config.transport = SdkTransportMode::RelayDirect;
@@ -11139,6 +11373,7 @@ fn published_order_submit_view(
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),
@@ -11186,6 +11421,7 @@ fn order_binding_error_view(
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),
@@ -11748,6 +11984,19 @@ fn normalize_optional(value: Option<&str>) -> Option<String> {
}
}
+fn normalize_listing_relay_set<I, S>(values: I) -> Result<Vec<String>, String>
+where
+ I: IntoIterator<Item = S>,
+ S: AsRef<str>,
+{
+ normalize_relay_urls(values).map_err(|error| error.to_string())
+}
+
+fn order_listing_relays(document: &OrderDraftDocument) -> Vec<String> {
+ normalize_listing_relay_set(document.order.listing_relays.iter())
+ .unwrap_or_else(|_| document.order.listing_relays.clone())
+}
+
fn non_empty_string(value: String) -> Option<String> {
if value.trim().is_empty() {
None
@@ -11880,6 +12129,7 @@ impl From<OrderGetView> for OrderNewView {
listing_lookup: view.listing_lookup,
listing_addr: view.listing_addr,
listing_event_id: view.listing_event_id,
+ listing_relays: view.listing_relays,
buyer_account_id: view.buyer_account_id,
buyer_pubkey: view.buyer_pubkey,
buyer_actor_source: view.buyer_actor_source,
@@ -11955,9 +12205,9 @@ mod tests {
order_decision_view_from_resolution, order_economics_from_resolved_listing,
order_event_list_entry_from_event, order_event_list_from_receipt,
order_fulfillment_dry_run_view, order_fulfillment_preflight_view_from_status,
- order_payment_dry_run_view, order_payment_event_parts, order_payment_payload_from_status,
- order_payment_preflight_view_from_status, order_receipt_dry_run_view,
- order_receipt_event_parts, order_receipt_payload_from_status,
+ order_listing_event_ptr, order_payment_dry_run_view, order_payment_event_parts,
+ order_payment_payload_from_status, order_payment_preflight_view_from_status,
+ order_receipt_dry_run_view, order_receipt_event_parts, order_receipt_payload_from_status,
order_receipt_preflight_view_from_status, order_relay_publish_client, order_request_filter,
order_revision_decision_event_parts, order_revision_decision_payload_from_proposal,
order_revision_decision_preflight_view_from_status, order_revision_event_parts,
@@ -11968,7 +12218,8 @@ mod tests {
order_status_filter, order_status_from_receipt, order_status_from_receipt_with_context,
order_status_from_receipt_with_deferred_payment,
order_status_reduction_from_receipt_with_context, order_submit_dry_run_view,
- order_submit_existing_request_view_from_receipt, proposed_accept_decision_record,
+ order_submit_existing_request_view_from_receipt,
+ order_submit_listing_provenance_preflight_view, proposed_accept_decision_record,
resolve_local_order_fulfillment_signing_identity,
seller_order_request_resolution_from_receipt,
};
@@ -12058,6 +12309,7 @@ mod tests {
let listing = ResolvedOrderListing {
listing_addr: "30402:seller:AAAAAAAAAAAAAAAAAAAAAg".to_owned(),
listing_event_id: "1".repeat(64),
+ listing_relays: vec!["ws://relay.test".to_owned()],
seller_pubkey: "seller".to_owned(),
economics_product: Some(ResolvedOrderEconomicsProduct {
qty_amt_exact: Some("1".to_owned()),
@@ -12132,6 +12384,7 @@ mod tests {
let listing = ResolvedOrderListing {
listing_addr: "30402:seller:AAAAAAAAAAAAAAAAAAAAAg".to_owned(),
listing_event_id: "1".repeat(64),
+ listing_relays: vec!["ws://relay.test".to_owned()],
seller_pubkey: "seller".to_owned(),
economics_product: Some(ResolvedOrderEconomicsProduct {
qty_amt_exact: Some("0.5".to_owned()),
@@ -12172,6 +12425,7 @@ mod tests {
let listing = ResolvedOrderListing {
listing_addr: "30402:seller:AAAAAAAAAAAAAAAAAAAAAg".to_owned(),
listing_event_id: "1".repeat(64),
+ listing_relays: vec!["ws://relay.test".to_owned()],
seller_pubkey: "seller".to_owned(),
economics_product: Some(ResolvedOrderEconomicsProduct {
qty_amt_exact: None,
@@ -12252,6 +12506,55 @@ mod tests {
}
#[test]
+ fn order_draft_requires_listing_relays_for_submit_readiness() {
+ let dir = tempdir().expect("tempdir");
+ let config = sample_config(dir.path());
+ let buyer = accounts::create_or_migrate_default_account(&config)
+ .expect("buyer account")
+ .account;
+ let buyer_account_id = buyer.record.account_id.to_string();
+ let buyer_pubkey = buyer.record.public_identity.public_key_hex;
+ let document = OrderDraftDocument {
+ version: 1,
+ kind: ORDER_DRAFT_KIND.to_owned(),
+ order: OrderDraft {
+ order_id: "ord_AAAAAAAAAAAAAAAAAAAAAg".to_owned(),
+ listing_addr: "30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg".to_owned(),
+ listing_event_id: "1".repeat(64),
+ listing_relays: Vec::new(),
+ buyer_pubkey: buyer_pubkey.clone(),
+ seller_pubkey: "deadbeef".to_owned(),
+ items: vec![OrderDraftItem {
+ bin_id: "bin-1".to_owned(),
+ bin_count: 2,
+ }],
+ economics: Some(sample_order_economics(
+ "ord_AAAAAAAAAAAAAAAAAAAAAg",
+ "bin-1",
+ 2,
+ )),
+ },
+ buyer_actor: OrderDraftBuyerActor {
+ account_id: buyer_account_id,
+ pubkey: buyer_pubkey,
+ source: ORDER_BUYER_ACTOR_SOURCE_RESOLVED_ACCOUNT.to_owned(),
+ },
+ listing_lookup: Some("fresh-eggs".to_owned()),
+ };
+
+ let inspection = inspect_document(&config, &document).expect("inspect order draft");
+
+ assert_eq!(inspection.state, "draft");
+ assert!(!inspection.ready_for_submit);
+ assert!(
+ collect_issues(&document)
+ .iter()
+ .any(|issue| issue.code == "listing_provenance_missing"
+ && issue.field == "order.listing_relays")
+ );
+ }
+
+ #[test]
fn order_request_event_decodes_to_history_entry() {
let buyer = RadrootsIdentity::generate();
let seller = RadrootsIdentity::generate();
@@ -13060,6 +13363,72 @@ mod tests {
assert!(view.failed_relays.is_empty());
assert_eq!(view.signer_mode.as_deref(), Some("local"));
assert_eq!(view.idempotency_key.as_deref(), Some("idem-dry-submit"));
+ assert_eq!(view.listing_relays, vec!["ws://relay.test"]);
+ }
+
+ #[test]
+ fn order_submit_listing_provenance_preflight_rejects_disjoint_relay_targets() {
+ let dir = tempdir().expect("tempdir");
+ let mut config = sample_config(dir.path());
+ config.output.dry_run = true;
+ config.relay.urls = vec!["ws://relay-b.test".to_owned()];
+ let fixture = order_status_fixture();
+ let mut loaded = loaded_order_draft_for_fixture(&fixture);
+ loaded.document.order.listing_relays = vec!["ws://relay-a.test".to_owned()];
+ let args = OrderSubmitArgs {
+ key: fixture.order_id.clone(),
+ idempotency_key: Some("idem-provenance".to_owned()),
+ };
+
+ let view = order_submit_listing_provenance_preflight_view(&config, &loaded, &args)
+ .expect("provenance preflight")
+ .expect("preflight rejection");
+
+ assert_eq!(view.state, "unconfigured");
+ assert_eq!(view.target_relays, vec!["ws://relay-b.test"]);
+ assert_eq!(view.listing_relays, vec!["ws://relay-a.test"]);
+ assert_eq!(view.event_kind, Some(3422));
+ assert_eq!(view.issues[0].code, "listing_relay_target_mismatch");
+ assert!(view.event_id.is_none());
+ assert!(view.connected_relays.is_empty());
+ assert!(view.acknowledged_relays.is_empty());
+ }
+
+ #[test]
+ fn order_submit_listing_provenance_preflight_accepts_matching_relay_target() {
+ let dir = tempdir().expect("tempdir");
+ let mut config = sample_config(dir.path());
+ config.relay.urls = vec![
+ "ws://relay-b.test".to_owned(),
+ "ws://relay-a.test".to_owned(),
+ ];
+ let fixture = order_status_fixture();
+ let mut loaded = loaded_order_draft_for_fixture(&fixture);
+ loaded.document.order.listing_relays = vec!["ws://relay-a.test".to_owned()];
+ let args = OrderSubmitArgs {
+ key: fixture.order_id.clone(),
+ idempotency_key: None,
+ };
+
+ let view = order_submit_listing_provenance_preflight_view(&config, &loaded, &args)
+ .expect("provenance preflight");
+
+ assert!(view.is_none());
+ }
+
+ #[test]
+ fn order_listing_event_ptr_carries_listing_provenance_relays() {
+ let fixture = order_status_fixture();
+ let mut loaded = loaded_order_draft_for_fixture(&fixture);
+ loaded.document.order.listing_relays = vec!["ws://relay.test".to_owned()];
+
+ let mut config = sample_config(tempdir().expect("tempdir").path());
+ config.relay.urls = vec!["ws://relay.test".to_owned()];
+
+ let ptr = order_listing_event_ptr(&config, &loaded).expect("listing event ptr");
+
+ assert_eq!(ptr.id, fixture.listing_event_id);
+ assert_eq!(ptr.relays, Some("ws://relay.test".to_owned()));
}
#[test]
@@ -17656,7 +18025,7 @@ mod tests {
order_id: fixture.order_id.clone(),
listing_addr: fixture.listing_addr.clone(),
listing_event_id: fixture.listing_event_id.clone(),
- listing_relays: Vec::new(),
+ listing_relays: vec!["ws://relay.test".to_owned()],
buyer_pubkey: fixture.buyer_pubkey.clone(),
seller_pubkey: fixture.seller_pubkey.clone(),
items: vec![OrderDraftItem {
diff --git a/src/runtime/sync.rs b/src/runtime/sync.rs
@@ -112,6 +112,9 @@ struct SyncRunRecord {
struct SyncRunRow {
scope: String,
relay_set_fingerprint: String,
+ target_relays_json: String,
+ connected_relays_json: String,
+ failed_relays_json: String,
started_at: i64,
completed_at: Option<i64>,
state: String,
@@ -979,6 +982,29 @@ pub(crate) fn freshness_for_scope(
freshness_for_scope_from_executor(config, &executor, scope)
}
+pub(crate) fn relay_provenance_relays_for_scope(
+ config: &RuntimeConfig,
+ scope: RelayIngestScope,
+) -> Result<Vec<String>, RuntimeError> {
+ if !config.local.replica_db_path.exists() {
+ return Ok(Vec::new());
+ }
+ let executor = SqliteExecutor::open(&config.local.replica_db_path)?;
+ migrations::run_all_up(&executor)?;
+ ensure_sync_run_table(&executor)?;
+ let current_fingerprint = relay_set_fingerprint(&config.relay.urls);
+ let Some(run) = latest_sync_run(&executor, scope)? else {
+ return Ok(Vec::new());
+ };
+ if run.relay_set_fingerprint != current_fingerprint || !sync_run_successful(&run) {
+ return Ok(Vec::new());
+ }
+ let mut relays: Vec<String> = serde_json::from_str(run.connected_relays_json.as_str())?;
+ relays.sort();
+ relays.dedup();
+ Ok(relays)
+}
+
pub(crate) fn freshness_for_scope_from_executor(
config: &RuntimeConfig,
executor: &SqliteExecutor,
@@ -1142,6 +1168,9 @@ fn latest_sync_run(
&format!(
"SELECT scope,
relay_set_fingerprint,
+ target_relays_json,
+ connected_relays_json,
+ failed_relays_json,
started_at,
completed_at,
state,
@@ -1166,9 +1195,9 @@ fn sync_run_record_from_row(row: SyncRunRow) -> SyncRunRecord {
SyncRunRecord {
scope: row.scope,
relay_set_fingerprint: row.relay_set_fingerprint,
- target_relays_json: String::new(),
- connected_relays_json: String::new(),
- failed_relays_json: String::new(),
+ target_relays_json: row.target_relays_json,
+ connected_relays_json: row.connected_relays_json,
+ failed_relays_json: row.failed_relays_json,
started_at: u64_from_db(row.started_at),
completed_at: row.completed_at.map(u64_from_db),
state: row.state,
@@ -1583,8 +1612,8 @@ mod tests {
use super::{
DirectRelayFailure, DirectRelayFetchError, DirectRelayFetchReceipt,
- DirectRelayPublishReceipt, market_refresh_with_fetcher, pull_with_fetcher,
- push_with_publisher, status,
+ DirectRelayPublishReceipt, RelayIngestScope, market_refresh_with_fetcher,
+ pull_with_fetcher, push_with_publisher, relay_provenance_relays_for_scope, status,
};
use crate::runtime::config::{
AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig,
@@ -2036,6 +2065,33 @@ mod tests {
}
#[test]
+ fn market_refresh_records_relay_provenance_relays_for_order_drafts() {
+ let dir = tempdir().expect("tempdir");
+ let config = sample_config(
+ dir.path(),
+ vec![
+ "wss://relay-a.example.com".to_owned(),
+ "wss://relay-b.example.com".to_owned(),
+ ],
+ );
+ crate::runtime::local::init(&config).expect("store init");
+ let seller = identity(9);
+
+ let _ = market_refresh_with_fetcher(&config, fake_fetcher(vec![listing_event(&seller)]))
+ .expect("market refresh");
+ let relays = relay_provenance_relays_for_scope(&config, RelayIngestScope::MarketRefresh)
+ .expect("relay provenance");
+
+ assert_eq!(
+ relays,
+ vec![
+ "wss://relay-a.example.com".to_owned(),
+ "wss://relay-b.example.com".to_owned()
+ ]
+ );
+ }
+
+ #[test]
fn relay_refresh_records_current_run_freshness() {
let dir = tempdir().expect("tempdir");
let config = sample_config(dir.path(), vec!["wss://relay.example.com".to_owned()]);
diff --git a/tests/support/mod.rs b/tests/support/mod.rs
@@ -10,16 +10,21 @@ use radroots_events::RadrootsNostrEvent;
use radroots_events::kinds::{KIND_FARM, KIND_LISTING};
use radroots_events_codec::trade::RadrootsTradeListingAddress;
use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic};
-use radroots_local_events::{LocalEventRecord, LocalEventsStore};
+use radroots_local_events::{
+ LocalEventRecord, LocalEventRecordInput, LocalEventsStore, LocalRecordFamily,
+ LocalRecordStatus, PublishOutboxStatus, RelayDeliveryEvidence, SourceRuntime,
+ canonical_relay_set_fingerprint,
+};
use radroots_replica_sync::{RadrootsReplicaIngestOutcome, radroots_replica_ingest_event};
use radroots_sql_core::{SqlExecutor, SqliteExecutor};
-use serde_json::Value;
+use serde_json::{Value, json};
use tempfile::TempDir;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
static COMMAND_LOCK: Mutex<()> = Mutex::new(());
+pub const ORDERABLE_LISTING_RELAY: &str = "ws://127.0.0.1:9";
pub fn radroots() -> Command {
Command::cargo_bin("radroots").expect("binary")
@@ -293,9 +298,56 @@ pub fn seed_orderable_listing(sandbox: &RadrootsCliSandbox, listing_addr: &str)
radroots_replica_ingest_event(&executor, &event).expect("ingest listing"),
RadrootsReplicaIngestOutcome::Applied
);
+ seed_orderable_listing_signed_event(sandbox, &event, listing_addr);
event_id
}
+fn seed_orderable_listing_signed_event(
+ sandbox: &RadrootsCliSandbox,
+ event: &RadrootsNostrEvent,
+ listing_addr: &str,
+) {
+ let database_path = sandbox.local_events_db_path();
+ fs::create_dir_all(database_path.parent().expect("local events parent"))
+ .expect("local events parent");
+ let executor = SqliteExecutor::open(database_path).expect("open local events");
+ let store = LocalEventsStore::new(executor);
+ store.migrate_up().expect("migrate local events");
+ let delivery = RelayDeliveryEvidence::acknowledged(
+ [ORDERABLE_LISTING_RELAY],
+ [ORDERABLE_LISTING_RELAY],
+ [ORDERABLE_LISTING_RELAY],
+ Vec::new(),
+ )
+ .expect("listing relay delivery evidence");
+ store
+ .append_record(&LocalEventRecordInput {
+ record_id: format!("test:signed_listing:{}", event.id),
+ family: LocalRecordFamily::SignedEvent,
+ status: LocalRecordStatus::Published,
+ source_runtime: SourceRuntime::Cli,
+ created_at_ms: 1_779_000_001_000,
+ inserted_at_ms: 1_779_000_001_000,
+ owner_account_id: None,
+ owner_pubkey: Some(event.author.clone()),
+ farm_id: None,
+ listing_addr: Some(listing_addr.to_owned()),
+ local_work_json: None,
+ event_id: Some(event.id.clone()),
+ event_kind: Some(i64::from(event.kind)),
+ event_pubkey: Some(event.author.clone()),
+ event_created_at: Some(i64::try_from(event.created_at).expect("event created_at")),
+ event_tags_json: Some(json!(event.tags)),
+ event_content: Some(event.content.clone()),
+ event_sig: Some(event.sig.clone()),
+ raw_event_json: Some(json!(event)),
+ 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")),
+ })
+ .expect("append listing signed event record");
+}
+
pub fn remove_orderable_listing(sandbox: &RadrootsCliSandbox, listing_addr: &str) {
let executor = SqliteExecutor::open(sandbox.replica_db_path()).expect("open replica db");
let params = serde_json::to_string(&vec![listing_addr]).expect("delete listing params");
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -30,12 +30,13 @@ use serde_json::Value;
use serde_json::json;
use support::{
- RadrootsCliSandbox, assert_contains, assert_no_daemon_runtime_reference,
- assert_no_removed_command_reference, create_listing_draft, identity_public, identity_secret,
- json_from_stdout, make_listing_publishable, make_listing_publishable_with_seller,
- ndjson_from_stdout, radroots, remove_orderable_listing, replace_latest_listing_event_id,
- seed_orderable_listing, toml_string, update_orderable_listing_available_amount,
- write_public_identity_profile, write_secret_identity_profile,
+ ORDERABLE_LISTING_RELAY, RadrootsCliSandbox, assert_contains,
+ assert_no_daemon_runtime_reference, assert_no_removed_command_reference, create_listing_draft,
+ identity_public, identity_secret, json_from_stdout, make_listing_publishable,
+ make_listing_publishable_with_seller, ndjson_from_stdout, radroots, remove_orderable_listing,
+ replace_latest_listing_event_id, seed_orderable_listing, toml_string,
+ update_orderable_listing_available_amount, write_public_identity_profile,
+ write_secret_identity_profile,
};
const LISTING_ADDR: &str =
@@ -577,6 +578,7 @@ fn seed_app_order_record_variant_with_record_id(
"order_id": order_id,
"listing_addr": listing_addr,
"listing_event_id": listing_event_id,
+ "listing_relays": [ORDERABLE_LISTING_RELAY],
"buyer_pubkey": buyer_pubkey,
"seller_pubkey": seller_pubkey,
"items": [