radrootsd

JSON-RPC bridge for Radroots event publishing
git clone https://radroots.dev/git/radrootsd.git
Log | Files | Refs | README | LICENSE

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:
Msrc/transport/jsonrpc/methods/bridge/listing_publish.rs | 97++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
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(),