commit 54b0573ea77c03dbfde9ceb21da8f9e540a18272
parent 17c7792a905f4b2cc6af94d55823d843e4417f94
Author: triesap <tyson@radroots.org>
Date: Sat, 28 Mar 2026 22:41:39 +0000
bridge: reject invalid listing contracts before signing
Diffstat:
1 file changed, 76 insertions(+), 21 deletions(-)
diff --git a/src/transport/jsonrpc/methods/bridge/listing_publish.rs b/src/transport/jsonrpc/methods/bridge/listing_publish.rs
@@ -1,8 +1,10 @@
use anyhow::Result;
use jsonrpsee::server::RpcModule;
+use radroots_events::RadrootsNostrEvent;
use radroots_events::listing::RadrootsListing;
use radroots_events_codec::listing::encode::to_wire_parts;
-use radroots_nostr::prelude::{radroots_event_from_nostr, radroots_nostr_build_event};
+use radroots_events_codec::wire::WireEventParts;
+use radroots_nostr::prelude::radroots_nostr_build_event;
use radroots_trade::listing::validation::validate_listing_event;
use serde::Deserialize;
use uuid::Uuid;
@@ -57,9 +59,10 @@ async fn publish_listing(
fingerprint_bridge_request("bridge.listing.publish", &signer, &listing)?;
let parts = to_wire_parts(&listing)
.map_err(|error| RpcError::InvalidParams(format!("invalid listing contract: {error}")))?;
+ let validated =
+ validate_canonical_listing_contract_for_signer(&listing, signer_pubkey.as_str(), &parts)?;
let builder = radroots_nostr_build_event(parts.kind, parts.content, parts.tags)
.map_err(|error| RpcError::Other(format!("failed to build listing event: {error}")))?;
- let listing_addr = format!("{}:{}:{}", parts.kind, signer_pubkey, listing.d_tag.trim());
let reserved = reserve_bridge_job(
&ctx,
@@ -69,7 +72,7 @@ async fn publish_listing(
signer.signer_mode(),
parts.kind,
None,
- listing_addr,
+ validated.listing_addr.clone(),
ctx.state.bridge_config.delivery_policy,
ctx.state.bridge_config.delivery_quorum,
),
@@ -99,23 +102,6 @@ async fn publish_listing(
return Err(error);
}
};
- let canonical = radroots_event_from_nostr(&event);
- let validated = match validate_listing_event(&canonical) {
- Ok(validated) => validated,
- Err(error) => {
- let _ = ctx.state.bridge_jobs.complete(
- &job.job_id,
- Some(event.id.to_hex()),
- failed_prepublish_execution(
- &publish_settings,
- format!("invalid listing contract: {error}"),
- ),
- );
- return Err(RpcError::InvalidParams(format!(
- "invalid listing contract: {error}"
- )));
- }
- };
let execution = connect_and_publish_event(&ctx.state.client, &publish_settings, &event).await;
let job = ctx
@@ -145,6 +131,26 @@ fn canonicalize_listing_for_signer(
listing
}
+fn validate_canonical_listing_contract_for_signer(
+ listing: &RadrootsListing,
+ signer_pubkey: &str,
+ parts: &WireEventParts,
+) -> Result<radroots_trade::listing::validation::RadrootsTradeListing, RpcError> {
+ let canonical = RadrootsNostrEvent {
+ id: String::new(),
+ author: signer_pubkey.to_string(),
+ created_at: 0,
+ kind: parts.kind,
+ tags: parts.tags.clone(),
+ content: parts.content.clone(),
+ sig: String::new(),
+ };
+ let validated = validate_listing_event(&canonical)
+ .map_err(|error| RpcError::InvalidParams(format!("invalid listing contract: {error}")))?;
+ debug_assert_eq!(validated.listing.d_tag, listing.d_tag);
+ Ok(validated)
+}
+
#[cfg(test)]
mod tests {
use radroots_core::{
@@ -156,6 +162,7 @@ mod tests {
RadrootsListingDeliveryMethod, RadrootsListingFarmRef, RadrootsListingLocation,
RadrootsListingProduct,
};
+ use radroots_events_codec::listing::encode::to_wire_parts;
use radroots_identity::RadrootsIdentity;
use radroots_nostr::prelude::RadrootsNostrMetadata;
@@ -163,7 +170,10 @@ mod tests {
use crate::core::Radrootsd;
use crate::transport::jsonrpc::{MethodRegistry, RpcContext};
- use super::{BridgeListingPublishParams, canonicalize_listing_for_signer, publish_listing};
+ use super::{
+ BridgeListingPublishParams, canonicalize_listing_for_signer, publish_listing,
+ validate_canonical_listing_contract_for_signer,
+ };
#[test]
fn canonicalize_listing_sets_missing_farm_pubkey() {
@@ -171,6 +181,17 @@ mod tests {
assert_eq!(listing.farm.pubkey, "abc123");
}
+ #[test]
+ fn validate_canonical_listing_contract_rejects_mismatched_seller_before_sign() {
+ let listing = canonicalize_listing_for_signer(base_listing(), "abc123");
+ let mut invalid = listing.clone();
+ invalid.farm.pubkey = "other".to_string();
+ let parts = to_wire_parts(&invalid).expect("wire parts");
+ let err =
+ validate_canonical_listing_contract_for_signer(&invalid, "abc123", &parts).unwrap_err();
+ assert!(err.to_string().contains("invalid listing contract"));
+ }
+
#[tokio::test]
async fn publish_listing_is_job_backed_and_idempotent() {
let identity = RadrootsIdentity::generate();
@@ -213,6 +234,40 @@ mod tests {
assert_eq!(second.job.job_id, first.job.job_id);
}
+ #[tokio::test]
+ async fn publish_listing_rejects_invalid_seller_before_job_reserve() {
+ let identity = RadrootsIdentity::generate();
+ let metadata: RadrootsNostrMetadata =
+ serde_json::from_str(r#"{"name":"radrootsd-test"}"#).expect("metadata");
+ let state = Radrootsd::new(
+ identity,
+ metadata,
+ BridgeConfig {
+ enabled: true,
+ bearer_token: Some("secret".to_string()),
+ ..BridgeConfig::default()
+ },
+ Nip46Config::default(),
+ )
+ .expect("state");
+ let ctx = RpcContext::new(state, MethodRegistry::default());
+ let mut listing = base_listing();
+ listing.farm.pubkey =
+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string();
+ let err = publish_listing(
+ ctx.clone(),
+ BridgeListingPublishParams {
+ listing,
+ signer_session_id: None,
+ idempotency_key: Some("bad-listing".to_string()),
+ },
+ )
+ .await
+ .expect_err("invalid seller rejected");
+ assert!(err.to_string().contains("invalid listing contract"));
+ assert_eq!(ctx.state.bridge_jobs.snapshot().retained_jobs, 0);
+ }
+
fn base_listing() -> RadrootsListing {
RadrootsListing {
d_tag: "AAAAAAAAAAAAAAAAAAAAAg".to_string(),