commit c13a36cc86b80877a9b286cb056e698b96eb0df5
parent c489bb75228ae1ffd4ae5f9a5c6c404b038a1af4
Author: triesap <tyson@radroots.org>
Date: Mon, 13 Apr 2026 00:51:39 +0000
sdk: add config defaults
Diffstat:
3 files changed, 430 insertions(+), 10 deletions(-)
diff --git a/crates/sdk/src/config.rs b/crates/sdk/src/config.rs
@@ -0,0 +1,260 @@
+#[cfg(not(feature = "std"))]
+use alloc::{string::String, vec::Vec};
+use core::fmt;
+#[cfg(feature = "std")]
+use std::{string::String, vec::Vec};
+
+pub const RADROOTS_SDK_PRODUCTION_RELAY_URL: &str = "wss://radroots.org";
+pub const RADROOTS_SDK_STAGING_RELAY_URL: &str = "wss://staging.radroots.org";
+pub const RADROOTS_SDK_LOCAL_RELAY_URL: &str = "ws://127.0.0.1:8080";
+
+pub const RADROOTS_SDK_PRODUCTION_RADROOTSD_ENDPOINT: &str = "https://rpc.radroots.org/jsonrpc";
+pub const RADROOTS_SDK_STAGING_RADROOTSD_ENDPOINT: &str =
+ "https://rpc.staging.radroots.org/jsonrpc";
+pub const RADROOTS_SDK_LOCAL_RADROOTSD_ENDPOINT: &str = "http://127.0.0.1:7070";
+
+pub const RADROOTS_SDK_DEFAULT_TIMEOUT_MS: u64 = 10_000;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RadrootsSdkConfig {
+ pub environment: SdkEnvironment,
+ pub transport: SdkTransportMode,
+ pub relay: RelayConfig,
+ pub radrootsd: RadrootsdConfig,
+ pub signer: SignerConfig,
+ pub network: NetworkConfig,
+}
+
+impl RadrootsSdkConfig {
+ pub fn production() -> Self {
+ Self::for_environment(SdkEnvironment::Production)
+ }
+
+ pub fn staging() -> Self {
+ Self::for_environment(SdkEnvironment::Staging)
+ }
+
+ pub fn local() -> Self {
+ Self::for_environment(SdkEnvironment::Local)
+ }
+
+ pub fn custom() -> Self {
+ Self::for_environment(SdkEnvironment::Custom)
+ }
+
+ pub fn for_environment(environment: SdkEnvironment) -> Self {
+ Self {
+ environment,
+ transport: SdkTransportMode::RelayDirect,
+ relay: RelayConfig::default(),
+ radrootsd: RadrootsdConfig::default(),
+ signer: SignerConfig::default(),
+ network: NetworkConfig::default(),
+ }
+ }
+
+ pub fn resolved_relay_urls(&self) -> Result<Vec<String>, SdkConfigError> {
+ self.relay.resolved_urls(self.environment)
+ }
+
+ pub fn resolved_radrootsd_endpoint(&self) -> Result<String, SdkConfigError> {
+ self.radrootsd.resolved_endpoint(self.environment)
+ }
+}
+
+impl Default for RadrootsSdkConfig {
+ fn default() -> Self {
+ Self::production()
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SdkEnvironment {
+ Production,
+ Staging,
+ Local,
+ Custom,
+}
+
+impl SdkEnvironment {
+ pub fn default_relay_urls(self) -> Option<Vec<String>> {
+ match self {
+ Self::Production => Some(vec![RADROOTS_SDK_PRODUCTION_RELAY_URL.to_owned()]),
+ Self::Staging => Some(vec![RADROOTS_SDK_STAGING_RELAY_URL.to_owned()]),
+ Self::Local => Some(vec![RADROOTS_SDK_LOCAL_RELAY_URL.to_owned()]),
+ Self::Custom => None,
+ }
+ }
+
+ pub fn default_radrootsd_endpoint(self) -> Option<&'static str> {
+ match self {
+ Self::Production => Some(RADROOTS_SDK_PRODUCTION_RADROOTSD_ENDPOINT),
+ Self::Staging => Some(RADROOTS_SDK_STAGING_RADROOTSD_ENDPOINT),
+ Self::Local => Some(RADROOTS_SDK_LOCAL_RADROOTSD_ENDPOINT),
+ Self::Custom => None,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SdkTransportMode {
+ RelayDirect,
+ Radrootsd,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct RelayConfig {
+ pub urls: Vec<String>,
+}
+
+impl RelayConfig {
+ pub fn resolved_urls(
+ &self,
+ environment: SdkEnvironment,
+ ) -> Result<Vec<String>, SdkConfigError> {
+ if self.urls.is_empty() {
+ return environment
+ .default_relay_urls()
+ .ok_or(SdkConfigError::MissingCustomRelayUrls);
+ }
+
+ normalize_relay_urls(&self.urls)
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RadrootsdConfig {
+ pub endpoint: Option<String>,
+ pub auth: RadrootsdAuth,
+}
+
+impl RadrootsdConfig {
+ pub fn resolved_endpoint(&self, environment: SdkEnvironment) -> Result<String, SdkConfigError> {
+ match self.endpoint.as_deref() {
+ Some(endpoint) => normalize_radrootsd_endpoint(endpoint),
+ None => environment
+ .default_radrootsd_endpoint()
+ .map(str::to_owned)
+ .ok_or(SdkConfigError::MissingCustomRadrootsdEndpoint),
+ }
+ }
+}
+
+impl Default for RadrootsdConfig {
+ fn default() -> Self {
+ Self {
+ endpoint: None,
+ auth: RadrootsdAuth::default(),
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub enum RadrootsdAuth {
+ #[default]
+ None,
+ BearerToken(String),
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub enum SignerConfig {
+ #[default]
+ DraftOnly,
+ LocalIdentity,
+ Nip46,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct NetworkConfig {
+ pub timeout_ms: u64,
+ pub retry_policy: RetryPolicy,
+}
+
+impl Default for NetworkConfig {
+ fn default() -> Self {
+ Self {
+ timeout_ms: RADROOTS_SDK_DEFAULT_TIMEOUT_MS,
+ retry_policy: RetryPolicy::default(),
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub enum RetryPolicy {
+ #[default]
+ None,
+ Fixed {
+ max_attempts: u32,
+ backoff_ms: u64,
+ },
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum SdkConfigError {
+ MissingCustomRelayUrls,
+ MissingCustomRadrootsdEndpoint,
+ EmptyRelayUrl,
+ InvalidRelayUrl(String),
+ EmptyRadrootsdEndpoint,
+ InvalidRadrootsdEndpoint(String),
+}
+
+impl fmt::Display for SdkConfigError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::MissingCustomRelayUrls => {
+ f.write_str("custom sdk environment requires explicit relay urls")
+ }
+ Self::MissingCustomRadrootsdEndpoint => {
+ f.write_str("custom sdk environment requires an explicit radrootsd endpoint")
+ }
+ Self::EmptyRelayUrl => f.write_str("relay url must not be empty"),
+ Self::InvalidRelayUrl(value) => {
+ write!(f, "relay url must use ws or wss, got `{value}`")
+ }
+ Self::EmptyRadrootsdEndpoint => f.write_str("radrootsd endpoint must not be empty"),
+ Self::InvalidRadrootsdEndpoint(value) => {
+ write!(
+ f,
+ "radrootsd endpoint must use http or https, got `{value}`"
+ )
+ }
+ }
+ }
+}
+
+#[cfg(feature = "std")]
+impl std::error::Error for SdkConfigError {}
+
+fn normalize_relay_urls(values: &[String]) -> Result<Vec<String>, SdkConfigError> {
+ let mut normalized = Vec::new();
+ for value in values {
+ let relay = normalize_relay_url(value.as_str())?;
+ if !normalized.iter().any(|existing| existing == &relay) {
+ normalized.push(relay);
+ }
+ }
+ Ok(normalized)
+}
+
+fn normalize_relay_url(value: &str) -> Result<String, SdkConfigError> {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ return Err(SdkConfigError::EmptyRelayUrl);
+ }
+ if !(trimmed.starts_with("ws://") || trimmed.starts_with("wss://")) {
+ return Err(SdkConfigError::InvalidRelayUrl(trimmed.to_owned()));
+ }
+ Ok(trimmed.to_owned())
+}
+
+fn normalize_radrootsd_endpoint(value: &str) -> Result<String, SdkConfigError> {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ return Err(SdkConfigError::EmptyRadrootsdEndpoint);
+ }
+ if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
+ return Err(SdkConfigError::InvalidRadrootsdEndpoint(trimmed.to_owned()));
+ }
+ Ok(trimmed.to_owned())
+}
diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs
@@ -4,24 +4,32 @@
#[cfg(not(feature = "std"))]
extern crate alloc;
-#[cfg(feature = "std")]
-use std::{string::String, vec::Vec};
#[cfg(not(feature = "std"))]
use alloc::{string::String, vec::Vec};
+#[cfg(feature = "std")]
+use std::{string::String, vec::Vec};
-pub mod farm;
-#[cfg(feature = "identity-models")]
-pub mod identity;
-pub mod listing;
-pub mod profile;
-pub mod trade;
#[cfg(any(
feature = "signing",
feature = "relay-client",
feature = "signer-adapters"
))]
pub mod adapters;
+pub mod config;
+pub mod farm;
+#[cfg(feature = "identity-models")]
+pub mod identity;
+pub mod listing;
+pub mod profile;
+pub mod trade;
+pub use crate::config::{
+ NetworkConfig, RADROOTS_SDK_LOCAL_RADROOTSD_ENDPOINT, RADROOTS_SDK_LOCAL_RELAY_URL,
+ RADROOTS_SDK_PRODUCTION_RADROOTSD_ENDPOINT, RADROOTS_SDK_PRODUCTION_RELAY_URL,
+ RADROOTS_SDK_STAGING_RADROOTSD_ENDPOINT, RADROOTS_SDK_STAGING_RELAY_URL, RadrootsSdkConfig,
+ RadrootsdAuth, RadrootsdConfig, RelayConfig, RetryPolicy, SdkConfigError, SdkEnvironment,
+ SdkTransportMode, SignerConfig,
+};
pub use radroots_events::{
RadrootsNostrEvent, RadrootsNostrEventPtr, RadrootsNostrEventRef,
farm::RadrootsFarm,
@@ -31,8 +39,7 @@ pub use radroots_events::{
};
#[cfg(feature = "serde_json")]
pub use radroots_events_codec::trade::{
- RadrootsTradeEnvelopeParseError, RadrootsTradeListingAddress,
- RadrootsTradeListingAddressError,
+ RadrootsTradeEnvelopeParseError, RadrootsTradeListingAddress, RadrootsTradeListingAddressError,
};
pub use radroots_events_codec::wire::{EventDraft as UnsignedEventDraft, WireEventParts};
pub use radroots_trade::listing::validation::RadrootsTradeListing as TradeListingValidateResult;
diff --git a/crates/sdk/tests/config.rs b/crates/sdk/tests/config.rs
@@ -0,0 +1,153 @@
+use radroots_sdk::{
+ NetworkConfig, RADROOTS_SDK_LOCAL_RADROOTSD_ENDPOINT, RADROOTS_SDK_LOCAL_RELAY_URL,
+ RADROOTS_SDK_PRODUCTION_RADROOTSD_ENDPOINT, RADROOTS_SDK_PRODUCTION_RELAY_URL,
+ RADROOTS_SDK_STAGING_RADROOTSD_ENDPOINT, RADROOTS_SDK_STAGING_RELAY_URL, RadrootsSdkConfig,
+ RadrootsdAuth, RetryPolicy, SdkConfigError, SdkEnvironment, SdkTransportMode, SignerConfig,
+};
+
+#[test]
+fn default_config_uses_production_relay_direct_draft_only() {
+ let config = RadrootsSdkConfig::default();
+
+ assert_eq!(config.environment, SdkEnvironment::Production);
+ assert_eq!(config.transport, SdkTransportMode::RelayDirect);
+ assert_eq!(config.signer, SignerConfig::DraftOnly);
+ assert_eq!(config.network, NetworkConfig::default());
+ assert_eq!(config.radrootsd.auth, RadrootsdAuth::None);
+}
+
+#[test]
+fn production_environment_resolves_radroots_org_defaults() {
+ let config = RadrootsSdkConfig::production();
+
+ assert_eq!(
+ config.resolved_relay_urls().expect("relay defaults"),
+ vec![RADROOTS_SDK_PRODUCTION_RELAY_URL.to_owned()]
+ );
+ assert_eq!(
+ config
+ .resolved_radrootsd_endpoint()
+ .expect("radrootsd endpoint"),
+ RADROOTS_SDK_PRODUCTION_RADROOTSD_ENDPOINT
+ );
+}
+
+#[test]
+fn staging_environment_resolves_staging_defaults() {
+ let config = RadrootsSdkConfig::staging();
+
+ assert_eq!(
+ config.resolved_relay_urls().expect("relay defaults"),
+ vec![RADROOTS_SDK_STAGING_RELAY_URL.to_owned()]
+ );
+ assert_eq!(
+ config
+ .resolved_radrootsd_endpoint()
+ .expect("radrootsd endpoint"),
+ RADROOTS_SDK_STAGING_RADROOTSD_ENDPOINT
+ );
+}
+
+#[test]
+fn local_environment_resolves_localhost_defaults() {
+ let config = RadrootsSdkConfig::local();
+
+ assert_eq!(
+ config.resolved_relay_urls().expect("relay defaults"),
+ vec![RADROOTS_SDK_LOCAL_RELAY_URL.to_owned()]
+ );
+ assert_eq!(
+ config
+ .resolved_radrootsd_endpoint()
+ .expect("radrootsd endpoint"),
+ RADROOTS_SDK_LOCAL_RADROOTSD_ENDPOINT
+ );
+}
+
+#[test]
+fn explicit_coordinates_override_environment_defaults_exactly() {
+ let mut config = RadrootsSdkConfig::production();
+ config.relay.urls = vec![
+ " wss://relay.custom.one ".to_owned(),
+ "wss://relay.custom.one".to_owned(),
+ "ws://relay.custom.two".to_owned(),
+ ];
+ config.radrootsd.endpoint = Some(" https://rpc.custom.radroots.org ".to_owned());
+
+ assert_eq!(
+ config.resolved_relay_urls().expect("relay overrides"),
+ vec![
+ "wss://relay.custom.one".to_owned(),
+ "ws://relay.custom.two".to_owned(),
+ ]
+ );
+ assert_eq!(
+ config
+ .resolved_radrootsd_endpoint()
+ .expect("endpoint override"),
+ "https://rpc.custom.radroots.org"
+ );
+}
+
+#[test]
+fn custom_environment_requires_explicit_coordinates() {
+ let config = RadrootsSdkConfig::custom();
+
+ assert_eq!(
+ config
+ .resolved_relay_urls()
+ .expect_err("custom relay error"),
+ SdkConfigError::MissingCustomRelayUrls
+ );
+ assert_eq!(
+ config
+ .resolved_radrootsd_endpoint()
+ .expect_err("custom radrootsd error"),
+ SdkConfigError::MissingCustomRadrootsdEndpoint
+ );
+}
+
+#[test]
+fn custom_environment_accepts_explicit_coordinates() {
+ let mut config = RadrootsSdkConfig::custom();
+ config.relay.urls = vec!["wss://relay.custom.radroots.org".to_owned()];
+ config.radrootsd.endpoint = Some("https://rpc.custom.radroots.org".to_owned());
+
+ assert_eq!(
+ config.resolved_relay_urls().expect("custom relay"),
+ vec!["wss://relay.custom.radroots.org".to_owned()]
+ );
+ assert_eq!(
+ config
+ .resolved_radrootsd_endpoint()
+ .expect("custom endpoint"),
+ "https://rpc.custom.radroots.org"
+ );
+}
+
+#[test]
+fn invalid_coordinate_schemes_fail_loudly() {
+ let mut config = RadrootsSdkConfig::production();
+ config.relay.urls = vec!["https://relay.bad".to_owned()];
+ config.radrootsd.endpoint = Some("wss://rpc.bad".to_owned());
+
+ assert_eq!(
+ config
+ .resolved_relay_urls()
+ .expect_err("relay scheme error"),
+ SdkConfigError::InvalidRelayUrl("https://relay.bad".to_owned())
+ );
+ assert_eq!(
+ config
+ .resolved_radrootsd_endpoint()
+ .expect_err("endpoint scheme error"),
+ SdkConfigError::InvalidRadrootsdEndpoint("wss://rpc.bad".to_owned())
+ );
+}
+
+#[test]
+fn retry_policy_is_explicit_and_non_ambient() {
+ let config = RadrootsSdkConfig::default();
+
+ assert_eq!(config.network.retry_policy, RetryPolicy::None);
+}