lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit fcd644e80c3e7a94308b640c300271d1ca682e3a
parent 938502894e3c147984021fcf93d702216554232d
Author: triesap <tyson@radroots.org>
Date:   Mon, 13 Apr 2026 01:38:57 +0000

sdk: lock signer mode boundaries

Diffstat:
Mcrates/sdk/src/client.rs | 109++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/sdk/src/config.rs | 12+++++++++++-
Mcrates/sdk/tests/client.rs | 10+++++++++-
Mcrates/sdk/tests/radrootsd.rs | 88++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/sdk/tests/relay_direct.rs | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
5 files changed, 281 insertions(+), 29 deletions(-)

diff --git a/crates/sdk/src/client.rs b/crates/sdk/src/client.rs @@ -5,11 +5,20 @@ use std::{string::String, vec::Vec}; #[cfg(feature = "radrootsd-client")] use crate::adapters::radrootsd; -#[cfg(all(feature = "identity-models", feature = "relay-client", feature = "signing"))] +#[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" +))] use crate::adapters::relay; -#[cfg(all(feature = "identity-models", feature = "relay-client", feature = "signing"))] -use crate::identity::RadrootsIdentity; +use crate::config::SignerConfig; use crate::config::{RadrootsSdkConfig, SdkConfigError, SdkTransportMode}; +#[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" +))] +use crate::identity::RadrootsIdentity; use crate::{ NostrTags, RadrootsNostrEvent, RadrootsNostrEventPtr, RadrootsProfile, RadrootsProfileType, RadrootsTradeEnvelope, TradeListingValidateResult, WireEventParts, farm, listing, profile, @@ -17,7 +26,11 @@ use crate::{ }; #[cfg(any( feature = "radrootsd-client", - all(feature = "identity-models", feature = "relay-client", feature = "signing") + all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ) ))] use core::time::Duration; @@ -68,6 +81,12 @@ pub enum SdkPublishError { transport: SdkTransportMode, operation: &'static str, }, + UnsupportedSignerMode { + transport: SdkTransportMode, + signer: SignerConfig, + required: SignerConfig, + operation: &'static str, + }, Relay(String), RelayNotAcknowledged { transport: SdkTransportMode, @@ -96,6 +115,15 @@ impl core::fmt::Display for SdkPublishError { "{operation} requires a different sdk transport mode than {transport:?}" ) } + Self::UnsupportedSignerMode { + transport, + signer, + required, + operation, + } => write!( + f, + "{operation} requires signer mode `{required}` for {transport:?} transport, got `{signer}`" + ), Self::Relay(message) => write!(f, "{message}"), Self::RelayNotAcknowledged { transport, @@ -143,6 +171,10 @@ impl RadrootsSdkClient { self.config.transport } + pub fn signer(&self) -> SignerConfig { + self.config.signer + } + pub fn resolved_relay_urls(&self) -> Result<Vec<String>, SdkConfigError> { self.config.resolved_relay_urls() } @@ -167,7 +199,28 @@ impl RadrootsSdkClient { TradeClient { client: self } } - #[cfg(all(feature = "identity-models", feature = "relay-client", feature = "signing"))] + fn require_signer_mode( + &self, + required: SignerConfig, + operation: &'static str, + ) -> Result<(), SdkPublishError> { + let signer = self.signer(); + if signer == required { + return Ok(()); + } + Err(SdkPublishError::UnsupportedSignerMode { + transport: self.transport(), + signer, + required, + operation, + }) + } + + #[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ))] async fn publish_parts_via_relay_with_identity( &self, identity: &RadrootsIdentity, @@ -180,6 +233,7 @@ impl RadrootsSdkClient { operation, }); } + self.require_signer_mode(SignerConfig::LocalIdentity, operation)?; let event_kind = u32::from(parts.kind); let relay_urls = self.resolved_relay_urls()?; @@ -207,6 +261,7 @@ impl RadrootsSdkClient { operation: "listing.publish_via_radrootsd", }); } + self.require_signer_mode(SignerConfig::Nip46, "listing.publish_via_radrootsd")?; let endpoint = self.resolved_radrootsd_endpoint()?; let response = radrootsd::publish_listing( @@ -237,6 +292,10 @@ impl<'a> ProfileClient<'a> { self.client.transport() } + pub fn signer(&self) -> SignerConfig { + self.client.signer() + } + #[cfg(feature = "serde_json")] pub fn build_draft( &self, @@ -261,6 +320,10 @@ impl<'a> FarmClient<'a> { self.client.transport() } + pub fn signer(&self) -> SignerConfig { + self.client.signer() + } + #[cfg(feature = "serde_json")] pub fn build_draft( &self, @@ -284,6 +347,10 @@ impl<'a> ListingClient<'a> { self.client.transport() } + pub fn signer(&self) -> SignerConfig { + self.client.signer() + } + pub fn build_tags( &self, listing_value: &listing::RadrootsListing, @@ -307,7 +374,11 @@ impl<'a> ListingClient<'a> { listing::parse_event(event) } - #[cfg(all(feature = "identity-models", feature = "relay-client", feature = "signing"))] + #[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ))] pub async fn publish_with_identity( &self, identity: &RadrootsIdentity, @@ -315,12 +386,9 @@ impl<'a> ListingClient<'a> { ) -> Result<SdkPublishReceipt, SdkPublishError> { let parts = listing::build_draft(listing_value) .map_err(|err| SdkPublishError::Encode(err.to_string()))?; - self.client.publish_parts_via_relay_with_identity( - identity, - parts, - "listing.publish_with_identity", - ) - .await + self.client + .publish_parts_via_relay_with_identity(identity, parts, "listing.publish_with_identity") + .await } #[cfg(feature = "radrootsd-client")] @@ -346,6 +414,10 @@ impl<'a> TradeClient<'a> { self.client.transport() } + pub fn signer(&self) -> SignerConfig { + self.client.signer() + } + #[cfg(feature = "serde_json")] #[allow(clippy::too_many_arguments)] pub fn build_envelope_draft( @@ -396,7 +468,11 @@ impl<'a> TradeClient<'a> { } } -#[cfg(all(feature = "identity-models", feature = "relay-client", feature = "signing"))] +#[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" +))] fn sdk_publish_receipt_from_relay_output( event_kind: u32, output: relay::RelayOutput<relay::RelayEventId>, @@ -459,7 +535,12 @@ fn sdk_publish_receipt_from_radrootsd_listing_response( } } -#[cfg(all(test, feature = "identity-models", feature = "relay-client", feature = "signing"))] +#[cfg(all( + test, + feature = "identity-models", + feature = "relay-client", + feature = "signing" +))] mod tests { use super::{ SdkPublishError, SdkRelayFailure, SdkTransportMode, sdk_publish_receipt_from_relay_output, diff --git a/crates/sdk/src/config.rs b/crates/sdk/src/config.rs @@ -156,7 +156,7 @@ pub enum RadrootsdAuth { BearerToken(String), } -#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum SignerConfig { #[default] DraftOnly, @@ -226,6 +226,16 @@ impl fmt::Display for SdkConfigError { #[cfg(feature = "std")] impl std::error::Error for SdkConfigError {} +impl fmt::Display for SignerConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DraftOnly => f.write_str("draft_only"), + Self::LocalIdentity => f.write_str("local_identity"), + Self::Nip46 => f.write_str("nip46"), + } + } +} + fn normalize_relay_urls(values: &[String]) -> Result<Vec<String>, SdkConfigError> { let mut normalized = Vec::new(); for value in values { diff --git a/crates/sdk/tests/client.rs b/crates/sdk/tests/client.rs @@ -116,7 +116,10 @@ fn client_rejects_invalid_config_on_construction() { }; let error = RadrootsSdkClient::from_config(config).expect_err("invalid config"); - assert_eq!(error, SdkConfigError::InvalidRelayUrl("https://radroots.org".into())); + assert_eq!( + error, + SdkConfigError::InvalidRelayUrl("https://radroots.org".into()) + ); } #[test] @@ -132,6 +135,11 @@ fn namespace_clients_reflect_explicit_transport_mode() { assert_eq!(client.farm().transport(), SdkTransportMode::Radrootsd); assert_eq!(client.listing().transport(), SdkTransportMode::Radrootsd); assert_eq!(client.trade().transport(), SdkTransportMode::Radrootsd); + assert_eq!(client.signer(), SignerConfig::LocalIdentity); + assert_eq!(client.profile().signer(), SignerConfig::LocalIdentity); + assert_eq!(client.farm().signer(), SignerConfig::LocalIdentity); + assert_eq!(client.listing().signer(), SignerConfig::LocalIdentity); + assert_eq!(client.trade().signer(), SignerConfig::LocalIdentity); } #[test] diff --git a/crates/sdk/tests/radrootsd.rs b/crates/sdk/tests/radrootsd.rs @@ -12,6 +12,7 @@ use radroots_sdk::listing::{ use radroots_sdk::{ RadrootsSdkClient, RadrootsSdkConfig, RadrootsdAuth, RadrootsdConfig, SdkEnvironment, SdkPublishError, SdkRadrootsdListingPublishRequest, SdkTransportMode, SdkTransportReceipt, + SignerConfig, }; use serde_json::{Value, json}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -267,6 +268,7 @@ async fn radrootsd_listing_publish_returns_normalized_receipt() -> TestResult<() let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Production); config.transport = SdkTransportMode::Radrootsd; + config.signer = SignerConfig::Nip46; config.radrootsd = RadrootsdConfig { endpoint: Some(server.endpoint().to_owned()), auth: RadrootsdAuth::BearerToken("sdk-secret".to_owned()), @@ -280,16 +282,16 @@ async fn radrootsd_listing_publish_returns_normalized_receipt() -> TestResult<() idempotency_key: Some("idem-1".to_owned()), }; - let receipt = client - .listing() - .publish_via_radrootsd(&request) - .await?; + let receipt = client.listing().publish_via_radrootsd(&request).await?; let request_json = request_rx.await?; assert_eq!(request_json["method"], "bridge.listing.publish"); assert_eq!(request_json["params"]["signer_session_id"], "session-123"); assert_eq!(request_json["params"]["idempotency_key"], "idem-1"); - assert_eq!(request_json["params"]["listing"]["d_tag"], "AAAAAAAAAAAAAAAAAAAAAg"); + assert_eq!( + request_json["params"]["listing"]["d_tag"], + "AAAAAAAAAAAAAAAAAAAAAg" + ); assert_eq!(receipt.transport, SdkTransportMode::Radrootsd); assert_eq!(receipt.event_kind, Some(30402)); @@ -300,8 +302,14 @@ async fn radrootsd_listing_publish_returns_normalized_receipt() -> TestResult<() assert!(!rpc_receipt.deduplicated); assert_eq!(rpc_receipt.job_id.as_deref(), Some("job-1")); assert_eq!(rpc_receipt.status.as_deref(), Some("published")); - assert_eq!(rpc_receipt.signer_session_id.as_deref(), Some("session-123")); - assert_eq!(rpc_receipt.event_addr.as_deref(), Some("30402:seller:listing-1")); + assert_eq!( + rpc_receipt.signer_session_id.as_deref(), + Some("session-123") + ); + assert_eq!( + rpc_receipt.event_addr.as_deref(), + Some("30402:seller:listing-1") + ); assert_eq!(rpc_receipt.relay_count, Some(1)); assert_eq!(rpc_receipt.acknowledged_relay_count, Some(1)); } @@ -312,6 +320,72 @@ async fn radrootsd_listing_publish_returns_normalized_receipt() -> TestResult<() } #[tokio::test] +async fn radrootsd_listing_publish_rejects_draft_only_signer_mode() -> TestResult<()> { + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Production); + config.transport = SdkTransportMode::Radrootsd; + config.signer = SignerConfig::DraftOnly; + let client = RadrootsSdkClient::from_config(config)?; + let request = SdkRadrootsdListingPublishRequest { + listing: sample_listing(), + kind: None, + signer_session_id: "session-123".to_owned(), + signer_authority: None, + idempotency_key: None, + }; + + let error = client + .listing() + .publish_via_radrootsd(&request) + .await + .expect_err("unsupported signer mode"); + + assert!(matches!( + error, + SdkPublishError::UnsupportedSignerMode { + transport: SdkTransportMode::Radrootsd, + signer: SignerConfig::DraftOnly, + required: SignerConfig::Nip46, + operation: "listing.publish_via_radrootsd", + } + )); + + Ok(()) +} + +#[tokio::test] +async fn radrootsd_listing_publish_rejects_local_identity_signer_mode() -> TestResult<()> { + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Production); + config.transport = SdkTransportMode::Radrootsd; + config.signer = SignerConfig::LocalIdentity; + let client = RadrootsSdkClient::from_config(config)?; + let request = SdkRadrootsdListingPublishRequest { + listing: sample_listing(), + kind: None, + signer_session_id: "session-123".to_owned(), + signer_authority: None, + idempotency_key: None, + }; + + let error = client + .listing() + .publish_via_radrootsd(&request) + .await + .expect_err("unsupported signer mode"); + + assert!(matches!( + error, + SdkPublishError::UnsupportedSignerMode { + transport: SdkTransportMode::Radrootsd, + signer: SignerConfig::LocalIdentity, + required: SignerConfig::Nip46, + operation: "listing.publish_via_radrootsd", + } + )); + + Ok(()) +} + +#[tokio::test] async fn radrootsd_listing_publish_rejects_relay_transport_mode() -> TestResult<()> { let client = RadrootsSdkClient::from_config(RadrootsSdkConfig::production())?; let request = SdkRadrootsdListingPublishRequest { diff --git a/crates/sdk/tests/relay_direct.rs b/crates/sdk/tests/relay_direct.rs @@ -1,4 +1,8 @@ -#![cfg(all(feature = "identity-models", feature = "relay-client", feature = "signing"))] +#![cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" +))] use futures::{SinkExt, StreamExt}; use nostr::{ClientMessage, JsonUtil, RelayMessage}; @@ -6,16 +10,16 @@ use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, }; -use radroots_sdk::{ - RadrootsSdkClient, RadrootsSdkConfig, RadrootsdAuth, RadrootsdConfig, RelayConfig, - SdkEnvironment, SdkPublishError, SdkTransportMode, SdkTransportReceipt, -}; use radroots_sdk::identity::RadrootsIdentity; use radroots_sdk::listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, RadrootsListingDeliveryMethod, RadrootsListingFarmRef, RadrootsListingLocation, RadrootsListingProduct, RadrootsListingStatus, }; +use radroots_sdk::{ + RadrootsSdkClient, RadrootsSdkConfig, RadrootsdAuth, RadrootsdConfig, RelayConfig, + SdkEnvironment, SdkPublishError, SdkTransportMode, SdkTransportReceipt, SignerConfig, +}; use tokio::net::TcpListener; use tokio::sync::oneshot; use tokio_tungstenite::tungstenite::Message; @@ -162,6 +166,7 @@ async fn relay_direct_listing_publish_returns_normalized_receipt() -> TestResult let identity = RadrootsIdentity::generate(); let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); config.transport = SdkTransportMode::RelayDirect; + config.signer = SignerConfig::LocalIdentity; config.relay = RelayConfig { urls: vec![relay.url().to_owned()], }; @@ -181,7 +186,10 @@ async fn relay_direct_listing_publish_returns_normalized_receipt() -> TestResult assert!(receipt.event_id.is_some()); match receipt.transport_receipt { SdkTransportReceipt::RelayDirect(relay_receipt) => { - assert_eq!(relay_receipt.acknowledged_relays, vec![relay.url().to_owned()]); + assert_eq!( + relay_receipt.acknowledged_relays, + vec![relay.url().to_owned()] + ); assert!(relay_receipt.failed_relays.is_empty()); } SdkTransportReceipt::Radrootsd(_) => panic!("unexpected radrootsd receipt"), @@ -195,6 +203,7 @@ async fn relay_direct_publish_rejects_radrootsd_transport_mode() -> TestResult<( let identity = RadrootsIdentity::generate(); let mut config = RadrootsSdkConfig::production(); config.transport = SdkTransportMode::Radrootsd; + config.signer = SignerConfig::LocalIdentity; let client = RadrootsSdkClient::from_config(config)?; let error = client @@ -213,3 +222,73 @@ async fn relay_direct_publish_rejects_radrootsd_transport_mode() -> TestResult<( Ok(()) } + +#[tokio::test] +async fn relay_direct_publish_rejects_draft_only_signer_mode() -> TestResult<()> { + let relay = AckRelay::spawn().await?; + let identity = RadrootsIdentity::generate(); + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); + config.transport = SdkTransportMode::RelayDirect; + config.signer = SignerConfig::DraftOnly; + config.relay = RelayConfig { + urls: vec![relay.url().to_owned()], + }; + config.radrootsd = RadrootsdConfig { + endpoint: Some("https://rpc.radroots.org/jsonrpc".into()), + auth: RadrootsdAuth::None, + }; + let client = RadrootsSdkClient::from_config(config)?; + + let error = client + .listing() + .publish_with_identity(&identity, &sample_listing()) + .await + .expect_err("unsupported signer mode"); + + assert!(matches!( + error, + SdkPublishError::UnsupportedSignerMode { + transport: SdkTransportMode::RelayDirect, + signer: SignerConfig::DraftOnly, + required: SignerConfig::LocalIdentity, + operation: "listing.publish_with_identity", + } + )); + + Ok(()) +} + +#[tokio::test] +async fn relay_direct_publish_rejects_nip46_signer_mode() -> TestResult<()> { + let relay = AckRelay::spawn().await?; + let identity = RadrootsIdentity::generate(); + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); + config.transport = SdkTransportMode::RelayDirect; + config.signer = SignerConfig::Nip46; + config.relay = RelayConfig { + urls: vec![relay.url().to_owned()], + }; + config.radrootsd = RadrootsdConfig { + endpoint: Some("https://rpc.radroots.org/jsonrpc".into()), + auth: RadrootsdAuth::None, + }; + let client = RadrootsSdkClient::from_config(config)?; + + let error = client + .listing() + .publish_with_identity(&identity, &sample_listing()) + .await + .expect_err("unsupported signer mode"); + + assert!(matches!( + error, + SdkPublishError::UnsupportedSignerMode { + transport: SdkTransportMode::RelayDirect, + signer: SignerConfig::Nip46, + required: SignerConfig::LocalIdentity, + operation: "listing.publish_with_identity", + } + )); + + Ok(()) +}