commit fcd644e80c3e7a94308b640c300271d1ca682e3a
parent 938502894e3c147984021fcf93d702216554232d
Author: triesap <tyson@radroots.org>
Date: Mon, 13 Apr 2026 01:38:57 +0000
sdk: lock signer mode boundaries
Diffstat:
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(())
+}