commit fe9cf3259f3b663dc5c583c700d9539967b4c60b
parent 9f2a3205ba3aa4b27275bef9842659ae17589dcc
Author: triesap <tyson@radroots.org>
Date: Sat, 13 Jun 2026 15:08:32 -0700
sdk: own Rust SDK and wasm packages
- move radroots_sdk into the SDK workspace as the first-class Rust SDK crate
- add SDK-owned wasm binding crates and tracked npm package manifests
- teach xtask to generate wasm packages with wasm-pack --no-pack
- document SDK workspace ownership across Rust, TypeScript, and wasm surfaces
Diffstat:
24 files changed, 10035 insertions(+), 0 deletions(-)
diff --git a/crates/replica_sync_wasm/Cargo.toml b/crates/replica_sync_wasm/Cargo.toml
@@ -0,0 +1,34 @@
+[package]
+name = "radroots_replica_sync_wasm"
+publish = false
+version = "0.1.0-alpha.2"
+edition.workspace = true
+authors = ["Tyson Lupul <tyson@radroots.org>"]
+rust-version.workspace = true
+license.workspace = true
+description = "WASM boundary for Radroots replica synchronization"
+repository.workspace = true
+homepage.workspace = true
+readme = "README"
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+
+[dependencies]
+base64 = { workspace = true }
+radroots_events = { workspace = true, default-features = false, features = [
+ "serde",
+] }
+radroots_sql_core = { workspace = true, features = ["bridge"] }
+radroots_sql_wasm_core = { workspace = true, default-features = false, features = [
+ "bridge",
+] }
+radroots_replica_sync = { workspace = true, features = ["std"] }
+serde = { workspace = true, features = ["derive"] }
+serde_json = { workspace = true }
+serde-wasm-bindgen = { workspace = true }
+uuid = { workspace = true, features = ["js"] }
+wasm-bindgen = { workspace = true }
+
+[lints.rust]
+unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }
diff --git a/crates/replica_sync_wasm/README b/crates/replica_sync_wasm/README
@@ -0,0 +1,25 @@
+# radroots_replica_sync_wasm
+
+This is the README for `radroots_replica_sync_wasm`, which provides WebAssembly
+bindings for `radroots_replica_sync` in the `radroots` core libraries.
+
+## Overview
+
+ * wasm32 entry points for full sync and single-event ingest operations;
+ * integration with `WasmSqlExecutor` from `radroots_sql_core` for database
+ access;
+ * UUIDv7 id generation and JSON and base64 boundary handling for JavaScript
+ callers;
+ * a small target-specific wrapper around the Rust sync crate rather than a
+ separate sync engine.
+
+## Copyright
+
+Except as otherwise noted, all files in the `radroots_replica_sync_wasm`
+distribution are
+
+ Copyright (c) 2026 Tyson Lupul
+
+For information on usage and redistribution, and for a DISCLAIMER OF ALL
+WARRANTIES, see LICENSE included in the `radroots_replica_sync_wasm`
+distribution.
diff --git a/crates/replica_sync_wasm/src/lib.rs b/crates/replica_sync_wasm/src/lib.rs
@@ -0,0 +1,124 @@
+#![cfg(any(target_arch = "wasm32", coverage_nightly))]
+#![forbid(unsafe_code)]
+
+#[cfg(target_arch = "wasm32")]
+use base64::Engine;
+#[cfg(target_arch = "wasm32")]
+use base64::engine::general_purpose::URL_SAFE_NO_PAD;
+#[cfg(target_arch = "wasm32")]
+use radroots_events::RadrootsNostrEvent;
+#[cfg(target_arch = "wasm32")]
+use radroots_replica_sync::{
+ RadrootsReplicaIdFactory, RadrootsReplicaIngestOutcome, RadrootsReplicaSyncRequest,
+ radroots_replica_ingest_event_with_factory, radroots_replica_sync_all,
+};
+#[cfg(target_arch = "wasm32")]
+use radroots_sql_core::WasmSqlExecutor;
+#[cfg(target_arch = "wasm32")]
+use serde::Deserialize;
+#[cfg(target_arch = "wasm32")]
+use uuid::Uuid;
+#[cfg(target_arch = "wasm32")]
+use wasm_bindgen::prelude::*;
+
+#[cfg(target_arch = "wasm32")]
+fn err_js<E: ToString>(err: E) -> JsValue {
+ JsValue::from_str(&err.to_string())
+}
+
+#[cfg(target_arch = "wasm32")]
+struct WasmIdFactory;
+
+#[cfg(target_arch = "wasm32")]
+impl RadrootsReplicaIdFactory for WasmIdFactory {
+ fn new_d_tag(&self) -> String {
+ let uuid = Uuid::now_v7();
+ URL_SAFE_NO_PAD.encode(uuid.as_bytes())
+ }
+}
+
+#[cfg(target_arch = "wasm32")]
+#[derive(Deserialize)]
+struct NostrEventEnvelope {
+ id: String,
+ #[serde(default)]
+ author: Option<String>,
+ #[serde(default)]
+ pubkey: Option<String>,
+ created_at: u32,
+ kind: u32,
+ tags: Vec<Vec<String>>,
+ content: String,
+ sig: String,
+}
+
+#[cfg(target_arch = "wasm32")]
+fn parse_request(request_json: &str) -> Result<RadrootsReplicaSyncRequest, JsValue> {
+ serde_json::from_str(request_json).map_err(err_js)
+}
+
+#[cfg(target_arch = "wasm32")]
+fn parse_event(event_json: &str) -> Result<RadrootsNostrEvent, JsValue> {
+ let envelope: NostrEventEnvelope = serde_json::from_str(event_json).map_err(err_js)?;
+ let author = match (envelope.author, envelope.pubkey) {
+ (Some(author), Some(pubkey)) if author != pubkey => {
+ return Err(JsValue::from_str("author/pubkey mismatch"));
+ }
+ (Some(author), _) => author,
+ (None, Some(pubkey)) => pubkey,
+ (None, None) => return Err(JsValue::from_str("missing author/pubkey")),
+ };
+ Ok(RadrootsNostrEvent {
+ id: envelope.id,
+ author,
+ created_at: envelope.created_at,
+ kind: envelope.kind,
+ tags: envelope.tags,
+ content: envelope.content,
+ sig: envelope.sig,
+ })
+}
+
+#[cfg(target_arch = "wasm32")]
+#[wasm_bindgen(js_name = replica_sync_sync_all)]
+pub fn replica_sync_sync_all(request_json: &str) -> Result<JsValue, JsValue> {
+ let request = parse_request(request_json)?;
+ let exec = WasmSqlExecutor::new();
+ let bundle = radroots_replica_sync_all(&exec, &request).map_err(err_js)?;
+ serde_wasm_bindgen::to_value(&bundle).map_err(err_js)
+}
+
+#[cfg(target_arch = "wasm32")]
+#[wasm_bindgen(js_name = replica_sync_ingest_event)]
+pub fn replica_sync_ingest_event(event_json: &str) -> Result<JsValue, JsValue> {
+ let event = parse_event(event_json)?;
+ let exec = WasmSqlExecutor::new();
+ let factory = WasmIdFactory;
+ let outcome =
+ radroots_replica_ingest_event_with_factory(&exec, &event, &factory).map_err(err_js)?;
+ let value = match outcome {
+ RadrootsReplicaIngestOutcome::Applied => "applied",
+ RadrootsReplicaIngestOutcome::Skipped => "skipped",
+ };
+ Ok(JsValue::from_str(value))
+}
+
+#[cfg(coverage_nightly)]
+pub fn coverage_branch_probe(input: bool) -> &'static str {
+ if input {
+ "replica-sync-wasm"
+ } else {
+ "replica-sync-wasm"
+ }
+}
+
+#[cfg(all(test, coverage_nightly))]
+mod tests {
+ use super::coverage_branch_probe;
+
+ #[test]
+ fn coverage_branch_probe_hits_both_paths() {
+ assert_eq!(coverage_branch_probe(true), "replica-sync-wasm");
+ assert_eq!(coverage_branch_probe(false), "replica-sync-wasm");
+ }
+}
diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml
@@ -0,0 +1,77 @@
+[package]
+name = "radroots_sdk"
+publish = ["crates-io"]
+version.workspace = true
+edition.workspace = true
+authors = ["Tyson Lupul <tyson@radroots.org>"]
+rust-version.workspace = true
+license.workspace = true
+description = "Public Rust SDK for Radroots"
+repository.workspace = true
+homepage.workspace = true
+readme = "README"
+
+[features]
+default = ["std", "serde", "serde_json", "identity-models"]
+std = ["radroots_events/std", "radroots_events_codec/std", "radroots_trade/std"]
+serde = ["dep:serde", "radroots_events/serde", "radroots_trade/serde"]
+serde_json = [
+ "dep:serde_json",
+ "serde",
+ "nostr",
+ "radroots_events_codec/serde_json",
+ "radroots_trade/serde_json",
+]
+nostr = ["radroots_events_codec/nostr"]
+identity-models = [
+ "dep:radroots_identity",
+ "radroots_identity/profile",
+ "radroots_identity/std",
+]
+identity-storage = ["identity-models", "std", "radroots_identity/std"]
+signing = ["dep:radroots_nostr", "nostr"]
+relay-client = ["signing", "std", "serde_json", "radroots_nostr/client"]
+radrootsd-client = ["std", "serde_json", "dep:reqwest"]
+signer-adapters = [
+ "identity-models",
+ "signing",
+ "std",
+ "dep:radroots_nostr_connect",
+ "dep:radroots_nostr_signer",
+]
+
+[dependencies]
+radroots_events = { workspace = true, default-features = false }
+radroots_events_codec = { workspace = true, default-features = false }
+radroots_trade = { workspace = true, default-features = false }
+radroots_identity = { workspace = true, optional = true, default-features = false }
+radroots_nostr = { workspace = true, optional = true, default-features = false }
+radroots_nostr_connect = { workspace = true, optional = true }
+radroots_nostr_signer = { workspace = true, optional = true, default-features = false }
+reqwest = { workspace = true, optional = true, default-features = false, features = [
+ "json",
+ "rustls-tls",
+] }
+serde = { workspace = true, optional = true, default-features = false, features = [
+ "derive",
+ "alloc",
+] }
+serde_json = { workspace = true, optional = true, default-features = false, features = [
+ "alloc",
+] }
+
+[dev-dependencies]
+futures = { workspace = true }
+nostr = { workspace = true }
+radroots_core = { workspace = true, default-features = false, features = [
+ "std",
+] }
+radroots_replica_db = { workspace = true, default-features = false, features = [
+ "native",
+] }
+radroots_replica_db_schema = { workspace = true }
+radroots_replica_sync = { workspace = true, features = ["std"] }
+radroots_sql_core = { workspace = true, features = ["native"] }
+tempfile = { workspace = true }
+tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+tokio-tungstenite = "0.26.2"
diff --git a/crates/sdk/README b/crates/sdk/README
@@ -0,0 +1,23 @@
+# radroots_sdk
+
+Curated Rad Roots Rust SDK for the public marketplace event contract.
+
+This crate provides the recommended Rust entrypoint for building, parsing, and
+validating Rad Roots profile, farm, listing, and trade events. It is a thin
+facade over the underlying `rr-rs` substrate crates and does not duplicate the
+core event or trade implementations.
+
+The deterministic event contract lives at the crate root:
+
+- `profile`
+- `farm`
+- `listing`
+- `trade`
+
+Optional advanced substrate is explicitly feature-scoped:
+
+- `identity-models`: identity data types without local storage coupling
+- `identity-storage`: encrypted identity-file helpers
+- `signing`: Nostr builder and local signing adapters
+- `relay-client`: relay client and publish adapters
+- `signer-adapters`: NIP-46 and signer-session primitives
diff --git a/crates/sdk/src/adapters/mod.rs b/crates/sdk/src/adapters/mod.rs
@@ -0,0 +1,8 @@
+#[cfg(feature = "radrootsd-client")]
+pub mod radrootsd;
+#[cfg(feature = "relay-client")]
+pub mod relay;
+#[cfg(feature = "signer-adapters")]
+pub mod signer;
+#[cfg(feature = "signing")]
+pub mod signing;
diff --git a/crates/sdk/src/adapters/radrootsd.rs b/crates/sdk/src/adapters/radrootsd.rs
@@ -0,0 +1,835 @@
+use core::fmt;
+use core::time::Duration;
+
+use crate::config::RadrootsdAuth;
+use crate::farm::RadrootsFarm;
+use crate::listing;
+use crate::listing::RadrootsListing;
+use crate::order;
+use crate::profile::{RadrootsProfile, RadrootsProfileType};
+use crate::{RadrootsNostrEvent, RadrootsNostrEventPtr};
+use radroots_events::kinds::KIND_LISTING;
+use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
+use serde::{Deserialize, Serialize, de::DeserializeOwned};
+use serde_json::{Value, json};
+
+#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct SdkRadrootsdSignerAuthority {
+ pub provider_runtime_id: String,
+ pub account_identity_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub provider_signer_session_id: Option<String>,
+}
+
+impl fmt::Debug for SdkRadrootsdSignerAuthority {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let mut debug = f.debug_struct("SdkRadrootsdSignerAuthority");
+ debug.field("provider_runtime_id", &self.provider_runtime_id);
+ debug.field("account_identity_id", &self.account_identity_id);
+ debug.field(
+ "provider_signer_session_id",
+ &self
+ .provider_signer_session_id
+ .as_ref()
+ .map(|_| "<redacted>"),
+ );
+ debug.finish()
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub enum SdkRadrootsdSignerSessionMode {
+ #[serde(alias = "bunker")]
+ Bunker,
+ #[serde(alias = "nostrconnect")]
+ Nostrconnect,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum SdkRadrootsdSignerSessionRole {
+ InboundLocalSigner,
+ OutboundRemoteSigner,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum SdkRadrootsdBridgeDeliveryPolicy {
+ Any,
+ Quorum,
+ All,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum SdkRadrootsdBridgeJobStatus {
+ Accepted,
+ Published,
+ Failed,
+}
+
+#[derive(Clone, PartialEq, Eq, Serialize)]
+pub struct SdkRadrootsdSignerSessionConnectRequest {
+ pub url: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub client_secret_key: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub signer_authority: Option<SdkRadrootsdSignerAuthority>,
+}
+
+impl SdkRadrootsdSignerSessionConnectRequest {
+ pub fn bunker(url: impl Into<String>) -> Self {
+ Self {
+ url: url.into(),
+ client_secret_key: None,
+ signer_authority: None,
+ }
+ }
+
+ pub fn nostrconnect(url: impl Into<String>, client_secret_key: impl Into<String>) -> Self {
+ Self {
+ url: url.into(),
+ client_secret_key: Some(client_secret_key.into()),
+ signer_authority: None,
+ }
+ }
+
+ pub fn with_signer_authority(mut self, signer_authority: SdkRadrootsdSignerAuthority) -> Self {
+ self.signer_authority = Some(signer_authority);
+ self
+ }
+}
+
+impl fmt::Debug for SdkRadrootsdSignerSessionConnectRequest {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let mut debug = f.debug_struct("SdkRadrootsdSignerSessionConnectRequest");
+ debug.field("url", &self.url);
+ debug.field(
+ "client_secret_key",
+ &self.client_secret_key.as_ref().map(|_| "<redacted>"),
+ );
+ debug.field("signer_authority", &self.signer_authority);
+ debug.finish()
+ }
+}
+
+#[derive(Clone, Serialize)]
+pub struct SdkRadrootsdProfilePublishRequest {
+ pub profile: RadrootsProfile,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub profile_type: Option<RadrootsProfileType>,
+ pub signer_session_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub signer_authority: Option<SdkRadrootsdSignerAuthority>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub idempotency_key: Option<String>,
+}
+
+impl fmt::Debug for SdkRadrootsdProfilePublishRequest {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let mut debug = f.debug_struct("SdkRadrootsdProfilePublishRequest");
+ debug.field("profile", &self.profile);
+ debug.field("profile_type", &self.profile_type);
+ debug.field("signer_session_id", &"<redacted>");
+ debug.field("signer_authority", &self.signer_authority);
+ debug.field("idempotency_key", &self.idempotency_key);
+ debug.finish()
+ }
+}
+
+#[derive(Clone, Serialize)]
+pub struct SdkRadrootsdFarmPublishRequest {
+ pub farm: RadrootsFarm,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub kind: Option<u32>,
+ pub signer_session_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub signer_authority: Option<SdkRadrootsdSignerAuthority>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub idempotency_key: Option<String>,
+}
+
+impl fmt::Debug for SdkRadrootsdFarmPublishRequest {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let mut debug = f.debug_struct("SdkRadrootsdFarmPublishRequest");
+ debug.field("farm", &self.farm);
+ debug.field("kind", &self.kind);
+ debug.field("signer_session_id", &"<redacted>");
+ debug.field("signer_authority", &self.signer_authority);
+ debug.field("idempotency_key", &self.idempotency_key);
+ debug.finish()
+ }
+}
+
+#[derive(Clone, Serialize)]
+pub struct SdkRadrootsdListingPublishRequest {
+ pub listing: RadrootsListing,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub kind: Option<u32>,
+ pub signer_session_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub signer_authority: Option<SdkRadrootsdSignerAuthority>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub idempotency_key: Option<String>,
+}
+
+impl fmt::Debug for SdkRadrootsdListingPublishRequest {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let mut debug = f.debug_struct("SdkRadrootsdListingPublishRequest");
+ debug.field("listing", &self.listing);
+ debug.field("kind", &self.kind);
+ debug.field("signer_session_id", &"<redacted>");
+ debug.field("signer_authority", &self.signer_authority);
+ debug.field("idempotency_key", &self.idempotency_key);
+ debug.finish()
+ }
+}
+
+#[derive(Clone, PartialEq, Eq, Serialize)]
+pub(crate) struct SdkRadrootsdOrderRequestPublishRequest {
+ pub order: order::RadrootsOrderRequest,
+ pub listing_event: RadrootsNostrEventPtr,
+ pub signer_session_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub signer_authority: Option<SdkRadrootsdSignerAuthority>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub idempotency_key: Option<String>,
+}
+
+impl fmt::Debug for SdkRadrootsdOrderRequestPublishRequest {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let mut debug = f.debug_struct("SdkRadrootsdOrderRequestPublishRequest");
+ debug.field("order", &self.order);
+ debug.field("listing_event", &self.listing_event);
+ debug.field("signer_session_id", &"<redacted>");
+ debug.field("signer_authority", &self.signer_authority);
+ debug.field("idempotency_key", &self.idempotency_key);
+ debug.finish()
+ }
+}
+
+impl SdkRadrootsdListingPublishRequest {
+ pub fn from_event(
+ event: &RadrootsNostrEvent,
+ signer_session_id: impl Into<String>,
+ signer_authority: Option<SdkRadrootsdSignerAuthority>,
+ idempotency_key: Option<String>,
+ ) -> Result<Self, listing::RadrootsListingParseError> {
+ if event.kind != KIND_LISTING {
+ return Err(listing::RadrootsListingParseError::InvalidKind(event.kind));
+ }
+ Ok(Self {
+ listing: listing::parse_event(event)?,
+ kind: Some(event.kind),
+ signer_session_id: signer_session_id.into(),
+ signer_authority,
+ idempotency_key,
+ })
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
+pub(crate) struct SdkRadrootsdSignerSessionConnectResponse {
+ pub session_id: String,
+ pub mode: SdkRadrootsdSignerSessionMode,
+ pub remote_signer_pubkey: String,
+ pub client_pubkey: String,
+ pub relays: Vec<String>,
+}
+
+#[derive(Clone, PartialEq, Eq, Deserialize)]
+pub(crate) struct SdkRadrootsdSignerSessionViewResponse {
+ pub session_id: String,
+ pub role: SdkRadrootsdSignerSessionRole,
+ pub client_pubkey: String,
+ pub signer_pubkey: String,
+ #[serde(default)]
+ pub user_pubkey: Option<String>,
+ pub relays: Vec<String>,
+ pub permissions: Vec<String>,
+ #[serde(default)]
+ pub name: Option<String>,
+ #[serde(default)]
+ pub url: Option<String>,
+ #[serde(default)]
+ pub image: Option<String>,
+ pub auth_required: bool,
+ pub authorized: bool,
+ #[serde(default)]
+ pub auth_url: Option<String>,
+ #[serde(default)]
+ pub expires_in_secs: Option<u64>,
+ #[serde(default)]
+ pub signer_authority: Option<SdkRadrootsdSignerAuthority>,
+}
+
+impl fmt::Debug for SdkRadrootsdSignerSessionViewResponse {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let mut debug = f.debug_struct("SdkRadrootsdSignerSessionViewResponse");
+ debug.field("session_id", &"<redacted>");
+ debug.field("role", &self.role);
+ debug.field("client_pubkey", &self.client_pubkey);
+ debug.field("signer_pubkey", &self.signer_pubkey);
+ debug.field("user_pubkey", &self.user_pubkey);
+ debug.field("relays", &self.relays);
+ debug.field("permissions", &self.permissions);
+ debug.field("name", &self.name);
+ debug.field("url", &self.url);
+ debug.field("image", &self.image);
+ debug.field("auth_required", &self.auth_required);
+ debug.field("authorized", &self.authorized);
+ debug.field("auth_url", &self.auth_url);
+ debug.field("expires_in_secs", &self.expires_in_secs);
+ debug.field("signer_authority", &self.signer_authority);
+ debug.finish()
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
+pub(crate) struct SdkRadrootsdSignerSessionAuthorizeResponse {
+ pub authorized: bool,
+ pub replayed: bool,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
+pub(crate) struct SdkRadrootsdSignerSessionPublicKeyResponse {
+ pub pubkey: String,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
+pub(crate) struct SdkRadrootsdSignerSessionRequireAuthResponse {
+ pub required: bool,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
+pub(crate) struct SdkRadrootsdSignerSessionCloseResponse {
+ pub closed: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
+pub struct SdkRadrootsdBridgePublishResponse {
+ pub deduplicated: bool,
+ pub job: SdkRadrootsdBridgeJob,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
+pub struct SdkRadrootsdBridgeStatusResponse {
+ pub enabled: bool,
+ pub ready: bool,
+ pub auth_mode: String,
+ pub signer_mode: String,
+ pub default_signer_mode: String,
+ pub supported_signer_modes: Vec<String>,
+ pub available_nip46_signer_sessions: usize,
+ pub relay_count: usize,
+ pub delivery_policy: SdkRadrootsdBridgeDeliveryPolicy,
+ #[serde(default)]
+ pub delivery_quorum: Option<usize>,
+ pub publish_max_attempts: usize,
+ pub publish_initial_backoff_millis: u64,
+ pub publish_max_backoff_millis: u64,
+ pub job_status_retention: usize,
+ pub retained_jobs: usize,
+ pub retained_idempotency_keys: usize,
+ pub accepted_jobs: usize,
+ pub published_jobs: usize,
+ pub failed_jobs: usize,
+ pub recovered_failed_jobs: usize,
+ pub methods: Vec<String>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct SdkRadrootsdBridgeRelayPublishResult {
+ pub relay_url: String,
+ pub acknowledged: bool,
+ #[serde(default)]
+ pub detail: Option<String>,
+}
+
+#[derive(Clone, PartialEq, Eq, Deserialize)]
+pub struct SdkRadrootsdBridgeJob {
+ pub job_id: String,
+ pub command: String,
+ pub status: String,
+ pub terminal: bool,
+ pub recovered_after_restart: bool,
+ pub signer_mode: String,
+ #[serde(default)]
+ pub signer_session_id: Option<String>,
+ pub event_kind: u32,
+ #[serde(default)]
+ pub event_id: Option<String>,
+ #[serde(default)]
+ pub event_addr: Option<String>,
+ pub relay_count: usize,
+ pub acknowledged_relay_count: usize,
+}
+
+impl fmt::Debug for SdkRadrootsdBridgeJob {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let mut debug = f.debug_struct("SdkRadrootsdBridgeJob");
+ debug.field("job_id", &self.job_id);
+ debug.field("command", &self.command);
+ debug.field("status", &self.status);
+ debug.field("terminal", &self.terminal);
+ debug.field("recovered_after_restart", &self.recovered_after_restart);
+ debug.field("signer_mode", &"<redacted>");
+ debug.field(
+ "signer_session_id",
+ &self.signer_session_id.as_ref().map(|_| "<redacted>"),
+ );
+ debug.field("event_kind", &self.event_kind);
+ debug.field("event_id", &self.event_id);
+ debug.field("event_addr", &self.event_addr);
+ debug.field("relay_count", &self.relay_count);
+ debug.field("acknowledged_relay_count", &self.acknowledged_relay_count);
+ debug.finish()
+ }
+}
+
+#[derive(Clone, PartialEq, Eq, Deserialize)]
+pub struct SdkRadrootsdBridgeJobView {
+ pub job_id: String,
+ pub command: String,
+ #[serde(default)]
+ pub idempotency_key: Option<String>,
+ pub status: SdkRadrootsdBridgeJobStatus,
+ pub terminal: bool,
+ pub recovered_after_restart: bool,
+ pub requested_at_unix: u64,
+ #[serde(default)]
+ pub completed_at_unix: Option<u64>,
+ pub signer_mode: String,
+ #[serde(default)]
+ pub signer_session_id: Option<String>,
+ pub event_kind: u32,
+ #[serde(default)]
+ pub event_id: Option<String>,
+ #[serde(default)]
+ pub event_addr: Option<String>,
+ pub delivery_policy: SdkRadrootsdBridgeDeliveryPolicy,
+ #[serde(default)]
+ pub delivery_quorum: Option<usize>,
+ pub relay_count: usize,
+ pub acknowledged_relay_count: usize,
+ pub required_acknowledged_relay_count: usize,
+ pub attempt_count: usize,
+ #[serde(default)]
+ pub attempt_summaries: Vec<String>,
+ #[serde(default)]
+ pub relay_results: Vec<SdkRadrootsdBridgeRelayPublishResult>,
+ pub relay_outcome_summary: String,
+}
+
+impl fmt::Debug for SdkRadrootsdBridgeJobView {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let mut debug = f.debug_struct("SdkRadrootsdBridgeJobView");
+ debug.field("job_id", &self.job_id);
+ debug.field("command", &self.command);
+ debug.field("idempotency_key", &self.idempotency_key);
+ debug.field("status", &self.status);
+ debug.field("terminal", &self.terminal);
+ debug.field("recovered_after_restart", &self.recovered_after_restart);
+ debug.field("requested_at_unix", &self.requested_at_unix);
+ debug.field("completed_at_unix", &self.completed_at_unix);
+ debug.field("signer_mode", &self.signer_mode.as_str());
+ debug.field(
+ "signer_session_id",
+ &self.signer_session_id.as_ref().map(|_| "<redacted>"),
+ );
+ debug.field("event_kind", &self.event_kind);
+ debug.field("event_id", &self.event_id);
+ debug.field("event_addr", &self.event_addr);
+ debug.field("delivery_policy", &self.delivery_policy);
+ debug.field("delivery_quorum", &self.delivery_quorum);
+ debug.field("relay_count", &self.relay_count);
+ debug.field("acknowledged_relay_count", &self.acknowledged_relay_count);
+ debug.field(
+ "required_acknowledged_relay_count",
+ &self.required_acknowledged_relay_count,
+ );
+ debug.field("attempt_count", &self.attempt_count);
+ debug.field("attempt_summaries", &self.attempt_summaries);
+ debug.field("relay_results", &self.relay_results);
+ debug.field("relay_outcome_summary", &self.relay_outcome_summary);
+ debug.finish()
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RadrootsdError {
+ InvalidAuthHeader(String),
+ Http(String),
+ JsonRpc(String),
+ MalformedResponse(String),
+}
+
+impl core::fmt::Display for RadrootsdError {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ match self {
+ Self::InvalidAuthHeader(value) => {
+ write!(f, "invalid radrootsd bearer token header: {value}")
+ }
+ Self::Http(value) => write!(f, "{value}"),
+ Self::JsonRpc(value) => write!(f, "{value}"),
+ Self::MalformedResponse(value) => write!(f, "{value}"),
+ }
+ }
+}
+
+impl std::error::Error for RadrootsdError {}
+
+#[derive(Debug, Deserialize)]
+struct JsonRpcEnvelope<T> {
+ result: Option<T>,
+ error: Option<JsonRpcError>,
+}
+
+#[derive(Debug, Deserialize)]
+struct JsonRpcError {
+ code: i64,
+ message: String,
+}
+
+#[derive(Debug, Serialize)]
+struct SdkRadrootsdSignerSessionParams<'a> {
+ session_id: &'a str,
+}
+
+#[derive(Debug, Serialize)]
+struct SdkRadrootsdSignerSessionRequireAuthParams<'a> {
+ session_id: &'a str,
+ auth_url: &'a str,
+}
+
+#[derive(Debug, Serialize)]
+struct SdkRadrootsdBridgeJobParams<'a> {
+ job_id: &'a str,
+}
+
+pub async fn publish_listing(
+ endpoint: &str,
+ auth: &RadrootsdAuth,
+ request: &SdkRadrootsdListingPublishRequest,
+ timeout: Duration,
+) -> Result<SdkRadrootsdBridgePublishResponse, RadrootsdError> {
+ jsonrpc_call(
+ endpoint,
+ auth,
+ "radroots-sdk-listing-publish",
+ "bridge.listing.publish",
+ request,
+ timeout,
+ )
+ .await
+}
+
+pub(crate) async fn publish_profile(
+ endpoint: &str,
+ auth: &RadrootsdAuth,
+ request: &SdkRadrootsdProfilePublishRequest,
+ timeout: Duration,
+) -> Result<SdkRadrootsdBridgePublishResponse, RadrootsdError> {
+ jsonrpc_call(
+ endpoint,
+ auth,
+ "radroots-sdk-profile-publish",
+ "bridge.profile.publish",
+ request,
+ timeout,
+ )
+ .await
+}
+
+pub(crate) async fn publish_farm(
+ endpoint: &str,
+ auth: &RadrootsdAuth,
+ request: &SdkRadrootsdFarmPublishRequest,
+ timeout: Duration,
+) -> Result<SdkRadrootsdBridgePublishResponse, RadrootsdError> {
+ jsonrpc_call(
+ endpoint,
+ auth,
+ "radroots-sdk-farm-publish",
+ "bridge.farm.publish",
+ request,
+ timeout,
+ )
+ .await
+}
+
+pub(crate) async fn publish_order_request(
+ endpoint: &str,
+ auth: &RadrootsdAuth,
+ request: &SdkRadrootsdOrderRequestPublishRequest,
+ timeout: Duration,
+) -> Result<SdkRadrootsdBridgePublishResponse, RadrootsdError> {
+ jsonrpc_call(
+ endpoint,
+ auth,
+ "radroots-sdk-order-request-publish",
+ "bridge.order.request",
+ request,
+ timeout,
+ )
+ .await
+}
+
+pub(crate) async fn connect_signer_session(
+ endpoint: &str,
+ auth: &RadrootsdAuth,
+ request: &SdkRadrootsdSignerSessionConnectRequest,
+ timeout: Duration,
+) -> Result<SdkRadrootsdSignerSessionConnectResponse, RadrootsdError> {
+ jsonrpc_call(
+ endpoint,
+ auth,
+ "radroots-sdk-nip46-connect",
+ "nip46.connect",
+ request,
+ timeout,
+ )
+ .await
+}
+
+pub(crate) async fn signer_session_status(
+ endpoint: &str,
+ auth: &RadrootsdAuth,
+ session_id: &str,
+ timeout: Duration,
+) -> Result<SdkRadrootsdSignerSessionViewResponse, RadrootsdError> {
+ jsonrpc_call(
+ endpoint,
+ auth,
+ "radroots-sdk-nip46-session-status",
+ "nip46.session.status",
+ &SdkRadrootsdSignerSessionParams { session_id },
+ timeout,
+ )
+ .await
+}
+
+pub(crate) async fn list_signer_sessions(
+ endpoint: &str,
+ auth: &RadrootsdAuth,
+ timeout: Duration,
+) -> Result<Vec<SdkRadrootsdSignerSessionViewResponse>, RadrootsdError> {
+ jsonrpc_call(
+ endpoint,
+ auth,
+ "radroots-sdk-nip46-session-list",
+ "nip46.session.list",
+ &json!({}),
+ timeout,
+ )
+ .await
+}
+
+pub(crate) async fn authorize_signer_session(
+ endpoint: &str,
+ auth: &RadrootsdAuth,
+ session_id: &str,
+ timeout: Duration,
+) -> Result<SdkRadrootsdSignerSessionAuthorizeResponse, RadrootsdError> {
+ jsonrpc_call(
+ endpoint,
+ auth,
+ "radroots-sdk-nip46-session-authorize",
+ "nip46.session.authorize",
+ &SdkRadrootsdSignerSessionParams { session_id },
+ timeout,
+ )
+ .await
+}
+
+pub(crate) async fn get_signer_session_public_key(
+ endpoint: &str,
+ auth: &RadrootsdAuth,
+ session_id: &str,
+ timeout: Duration,
+) -> Result<SdkRadrootsdSignerSessionPublicKeyResponse, RadrootsdError> {
+ jsonrpc_call(
+ endpoint,
+ auth,
+ "radroots-sdk-nip46-get-public-key",
+ "nip46.get_public_key",
+ &SdkRadrootsdSignerSessionParams { session_id },
+ timeout,
+ )
+ .await
+}
+
+pub(crate) async fn require_signer_session_auth(
+ endpoint: &str,
+ auth: &RadrootsdAuth,
+ session_id: &str,
+ auth_url: &str,
+ timeout: Duration,
+) -> Result<SdkRadrootsdSignerSessionRequireAuthResponse, RadrootsdError> {
+ jsonrpc_call(
+ endpoint,
+ auth,
+ "radroots-sdk-nip46-session-require-auth",
+ "nip46.session.require_auth",
+ &SdkRadrootsdSignerSessionRequireAuthParams {
+ session_id,
+ auth_url,
+ },
+ timeout,
+ )
+ .await
+}
+
+pub(crate) async fn close_signer_session(
+ endpoint: &str,
+ auth: &RadrootsdAuth,
+ session_id: &str,
+ timeout: Duration,
+) -> Result<SdkRadrootsdSignerSessionCloseResponse, RadrootsdError> {
+ jsonrpc_call(
+ endpoint,
+ auth,
+ "radroots-sdk-nip46-session-close",
+ "nip46.session.close",
+ &SdkRadrootsdSignerSessionParams { session_id },
+ timeout,
+ )
+ .await
+}
+
+pub(crate) async fn bridge_status(
+ endpoint: &str,
+ auth: &RadrootsdAuth,
+ timeout: Duration,
+) -> Result<SdkRadrootsdBridgeStatusResponse, RadrootsdError> {
+ jsonrpc_call(
+ endpoint,
+ auth,
+ "radroots-sdk-bridge-status",
+ "bridge.status",
+ &json!({}),
+ timeout,
+ )
+ .await
+}
+
+pub(crate) async fn bridge_job_status(
+ endpoint: &str,
+ auth: &RadrootsdAuth,
+ job_id: &str,
+ timeout: Duration,
+) -> Result<SdkRadrootsdBridgeJobView, RadrootsdError> {
+ jsonrpc_call(
+ endpoint,
+ auth,
+ "radroots-sdk-bridge-job-status",
+ "bridge.job.status",
+ &SdkRadrootsdBridgeJobParams { job_id },
+ timeout,
+ )
+ .await
+}
+
+pub(crate) async fn list_bridge_jobs(
+ endpoint: &str,
+ auth: &RadrootsdAuth,
+ timeout: Duration,
+) -> Result<Vec<SdkRadrootsdBridgeJobView>, RadrootsdError> {
+ jsonrpc_call(
+ endpoint,
+ auth,
+ "radroots-sdk-bridge-job-list",
+ "bridge.job.list",
+ &json!({}),
+ timeout,
+ )
+ .await
+}
+
+fn auth_headers(auth: &RadrootsdAuth) -> Result<HeaderMap, RadrootsdError> {
+ let mut headers = HeaderMap::new();
+ match auth {
+ RadrootsdAuth::None => Ok(headers),
+ RadrootsdAuth::BearerToken(token) => {
+ let value = HeaderValue::from_str(format!("Bearer {token}").as_str())
+ .map_err(|err| RadrootsdError::InvalidAuthHeader(err.to_string()))?;
+ headers.insert(AUTHORIZATION, value);
+ Ok(headers)
+ }
+ }
+}
+
+pub fn bridge_listing_publish_request_json(
+ request: &SdkRadrootsdListingPublishRequest,
+) -> Result<Value, RadrootsdError> {
+ serde_json::to_value(request).map_err(|err| {
+ RadrootsdError::MalformedResponse(format!(
+ "serialize radrootsd listing publish request: {err}"
+ ))
+ })
+}
+
+async fn jsonrpc_call<P, R>(
+ endpoint: &str,
+ auth: &RadrootsdAuth,
+ request_id: &str,
+ method: &str,
+ params: &P,
+ timeout: Duration,
+) -> Result<R, RadrootsdError>
+where
+ P: Serialize + ?Sized,
+ R: DeserializeOwned,
+{
+ let client = reqwest::Client::builder()
+ .timeout(timeout)
+ .build()
+ .map_err(|err| RadrootsdError::Http(format!("build radrootsd client: {err}")))?;
+ let mut request_builder = client
+ .post(endpoint)
+ .headers(auth_headers(auth)?)
+ .json(&json!({
+ "jsonrpc": "2.0",
+ "id": request_id,
+ "method": method,
+ "params": params,
+ }));
+
+ request_builder = request_builder.header(CONTENT_TYPE, "application/json");
+
+ let response = request_builder
+ .send()
+ .await
+ .map_err(|err| RadrootsdError::Http(format!("send radrootsd {method} request: {err}")))?;
+ let status = response.status();
+ let body = response
+ .text()
+ .await
+ .map_err(|err| RadrootsdError::Http(format!("read radrootsd response body: {err}")))?;
+
+ if !status.is_success() {
+ return Err(RadrootsdError::Http(format!(
+ "radrootsd returned http {}: {}",
+ status.as_u16(),
+ body
+ )));
+ }
+
+ let envelope: JsonRpcEnvelope<R> = serde_json::from_str(body.as_str()).map_err(|err| {
+ RadrootsdError::MalformedResponse(format!("decode radrootsd {method} response: {err}"))
+ })?;
+ match (envelope.result, envelope.error) {
+ (Some(result), None) => Ok(result),
+ (None, Some(error)) => Err(RadrootsdError::JsonRpc(format!(
+ "radrootsd {method} failed {}: {}",
+ error.code, error.message
+ ))),
+ (Some(_), Some(error)) => Err(RadrootsdError::MalformedResponse(format!(
+ "radrootsd {method} returned result and error: {} {}",
+ error.code, error.message
+ ))),
+ (None, None) => Err(RadrootsdError::MalformedResponse(format!(
+ "radrootsd {method} returned neither result nor error"
+ ))),
+ }
+}
diff --git a/crates/sdk/src/adapters/relay.rs b/crates/sdk/src/adapters/relay.rs
@@ -0,0 +1,96 @@
+use core::time::Duration;
+
+use crate::adapters::signing::SignedNostrEvent;
+use crate::identity::RadrootsIdentity;
+use radroots_nostr::prelude::{
+ RadrootsNostrClient, RadrootsNostrClientOptions, RadrootsNostrError, RadrootsNostrEventId,
+ RadrootsNostrOutput,
+};
+
+pub type RelayClient = RadrootsNostrClient;
+pub type RelayClientOptions = RadrootsNostrClientOptions;
+pub type RelayError = RadrootsNostrError;
+pub type RelayEventId = RadrootsNostrEventId;
+pub type RelayOutput<T> = RadrootsNostrOutput<T>;
+
+pub fn signerless_client() -> RelayClient {
+ RelayClient::new_signerless()
+}
+
+pub fn signerless_client_with_options(
+ options: RelayClientOptions,
+) -> Result<RelayClient, RelayError> {
+ RelayClient::new_signerless_with_options(options)
+}
+
+pub fn client_from_identity(identity: &RadrootsIdentity) -> RelayClient {
+ RelayClient::from_identity(identity)
+}
+
+pub async fn configure_write_relays(
+ client: &RelayClient,
+ relay_urls: &[String],
+ connect_timeout: Duration,
+) -> Result<(), RelayError> {
+ for relay_url in relay_urls {
+ client.add_write_relay(relay_url).await?;
+ }
+ client.connect().await;
+ client.wait_for_connection(connect_timeout).await;
+ Ok(())
+}
+
+pub async fn connected_client_from_identity(
+ identity: &RadrootsIdentity,
+ relay_urls: &[String],
+ connect_timeout: Duration,
+) -> Result<RelayClient, RelayError> {
+ let client = client_from_identity(identity);
+ configure_write_relays(&client, relay_urls, connect_timeout).await?;
+ Ok(client)
+}
+
+pub async fn connected_relay_urls(client: &RelayClient) -> Vec<String> {
+ let mut relay_urls = client
+ .relays()
+ .await
+ .into_values()
+ .filter(|relay| relay.is_connected())
+ .map(|relay| relay.url().to_string())
+ .collect::<Vec<_>>();
+ relay_urls.sort();
+ relay_urls
+}
+
+pub async fn publish_signed_event(
+ client: &RelayClient,
+ event: &SignedNostrEvent,
+) -> Result<RelayOutput<RelayEventId>, RelayError> {
+ client.send_event(event).await
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{client_from_identity, signerless_client, signerless_client_with_options};
+ use crate::identity::RadrootsIdentity;
+ use tokio::runtime::Runtime;
+
+ #[test]
+ fn client_constructors_build_without_runtime_net() {
+ let identity = RadrootsIdentity::generate();
+ let _client = client_from_identity(&identity);
+ let _signerless = signerless_client();
+ let _signerless_with_options =
+ signerless_client_with_options(super::RelayClientOptions::new())
+ .expect("signerless client with options");
+ }
+
+ #[test]
+ fn signerless_client_has_no_signer() {
+ let runtime = Runtime::new().expect("tokio runtime");
+ runtime.block_on(async {
+ let client = signerless_client();
+ assert!(!client.has_signer().await);
+ });
+ }
+}
diff --git a/crates/sdk/src/adapters/signer.rs b/crates/sdk/src/adapters/signer.rs
@@ -0,0 +1,24 @@
+pub use radroots_nostr_connect::prelude::{
+ RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR, RADROOTS_NOSTR_CONNECT_RPC_KIND,
+ RadrootsNostrConnectBunkerUri, RadrootsNostrConnectClientMetadata,
+ RadrootsNostrConnectClientUri, RadrootsNostrConnectError, RadrootsNostrConnectMethod,
+ RadrootsNostrConnectPendingConnectionPollOutcome, RadrootsNostrConnectPermission,
+ RadrootsNostrConnectPermissions, RadrootsNostrConnectRemoteSessionCapability,
+ RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse,
+ RadrootsNostrConnectResponseEnvelope, RadrootsNostrConnectUri,
+};
+pub use radroots_nostr_signer::prelude::{
+ RadrootsNostrEmbeddedSignerBackend, RadrootsNostrLocalSignerAvailability,
+ RadrootsNostrLocalSignerCapability, RadrootsNostrRemoteSessionSignerCapability,
+ RadrootsNostrSignerBackend, RadrootsNostrSignerBackendCapabilities,
+ RadrootsNostrSignerCapability, RadrootsNostrSignerConnectEvaluation,
+ RadrootsNostrSignerConnectProposal, RadrootsNostrSignerError,
+ RadrootsNostrSignerHandledRequest, RadrootsNostrSignerHandledRequestOutcome,
+ RadrootsNostrSignerManager, RadrootsNostrSignerNip46Codec,
+ RadrootsNostrSignerNip46ConnectDecision, RadrootsNostrSignerNip46Handler,
+ RadrootsNostrSignerNip46Policy, RadrootsNostrSignerNip46Signer,
+ RadrootsNostrSignerPublishTransition, RadrootsNostrSignerRequestAction,
+ RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerRequestResponseHint,
+ RadrootsNostrSignerSessionLookup, connect_response_outcome, handled_request_for_action,
+ response_from_hint,
+};
diff --git a/crates/sdk/src/adapters/signing.rs b/crates/sdk/src/adapters/signing.rs
@@ -0,0 +1,63 @@
+use crate::WireEventParts;
+use crate::identity::RadrootsIdentity;
+use radroots_nostr::prelude::{RadrootsNostrError, radroots_nostr_build_event};
+
+pub type SignedNostrEvent = radroots_nostr::prelude::RadrootsNostrEvent;
+pub type NostrEventBuilder = radroots_nostr::prelude::RadrootsNostrEventBuilder;
+pub type SigningError = RadrootsNostrError;
+
+pub fn event_builder_from_parts(parts: WireEventParts) -> Result<NostrEventBuilder, SigningError> {
+ radroots_nostr_build_event(parts.kind, parts.content, parts.tags)
+}
+
+pub fn sign_parts_with_identity(
+ identity: &RadrootsIdentity,
+ parts: WireEventParts,
+) -> Result<SignedNostrEvent, SigningError> {
+ let builder = event_builder_from_parts(parts)?;
+ sign_builder_with_identity(identity, builder)
+}
+
+pub fn sign_builder_with_identity(
+ identity: &RadrootsIdentity,
+ builder: NostrEventBuilder,
+) -> Result<SignedNostrEvent, SigningError> {
+ builder.sign_with_keys(identity.keys()).map_err(Into::into)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{event_builder_from_parts, sign_parts_with_identity};
+ use crate::{WireEventParts, identity::RadrootsIdentity};
+
+ #[test]
+ fn event_builder_from_parts_preserves_kind_and_content() {
+ let builder = event_builder_from_parts(WireEventParts {
+ kind: 30402,
+ content: "hello".into(),
+ tags: vec![vec!["x".into(), "y".into()]],
+ })
+ .expect("builder");
+ let identity = RadrootsIdentity::generate();
+ let event = builder.build(identity.keys().public_key());
+
+ assert_eq!(u16::from(event.kind), 30402);
+ assert_eq!(event.content, "hello");
+ }
+
+ #[test]
+ fn sign_parts_with_identity_signs_event() {
+ let identity = RadrootsIdentity::generate();
+ let event = sign_parts_with_identity(
+ &identity,
+ WireEventParts {
+ kind: 30402,
+ content: "hello".into(),
+ tags: vec![],
+ },
+ )
+ .expect("signed event");
+
+ assert_eq!(event.pubkey.to_hex(), identity.public_key_hex());
+ }
+}
diff --git a/crates/sdk/src/client.rs b/crates/sdk/src/client.rs
@@ -0,0 +1,2711 @@
+#[cfg(not(feature = "std"))]
+use alloc::{string::String, vec::Vec};
+use core::fmt;
+#[cfg(feature = "std")]
+use std::{string::String, vec::Vec};
+
+#[cfg(feature = "radrootsd-client")]
+use crate::adapters::radrootsd;
+#[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+))]
+use crate::adapters::{relay, signing};
+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,
+ TradeListingValidateResult, WireEventParts, farm, listing, order, profile,
+};
+#[cfg(any(
+ feature = "radrootsd-client",
+ all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+ )
+))]
+use core::time::Duration;
+use radroots_events::ids::RadrootsEventId;
+#[cfg(feature = "radrootsd-client")]
+use radroots_events::kinds::{KIND_FARM, KIND_LISTING};
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct SdkPublishReceipt {
+ pub transport: SdkTransportMode,
+ pub event_kind: Option<u32>,
+ pub event_id: Option<String>,
+ pub transport_receipt: SdkTransportReceipt,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum SdkTransportReceipt {
+ RelayDirect(SdkRelayPublishReceipt),
+ Radrootsd(SdkRadrootsdPublishReceipt),
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct SdkRelayPublishReceipt {
+ pub event: RadrootsNostrEvent,
+ pub event_id: String,
+ pub event_kind: u32,
+ pub created_at: u32,
+ pub signature: String,
+ pub target_relays: Vec<String>,
+ pub connected_relays: Vec<String>,
+ pub acknowledged_relays: Vec<String>,
+ pub failed_relays: Vec<SdkRelayFailure>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct SdkRelayFailure {
+ pub relay_url: String,
+ pub error: String,
+}
+
+#[derive(Clone, PartialEq, Eq, Default)]
+pub struct SdkRadrootsdPublishReceipt {
+ pub accepted: bool,
+ pub deduplicated: bool,
+ pub job_id: Option<String>,
+ pub status: Option<String>,
+ pub signer_mode: Option<String>,
+ pub signer_session_id: Option<String>,
+ pub event_addr: Option<String>,
+ pub relay_count: Option<usize>,
+ pub acknowledged_relay_count: Option<usize>,
+}
+
+impl fmt::Debug for SdkRadrootsdPublishReceipt {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let mut debug = f.debug_struct("SdkRadrootsdPublishReceipt");
+ debug.field("accepted", &self.accepted);
+ debug.field("deduplicated", &self.deduplicated);
+ debug.field("job_id", &self.job_id);
+ debug.field("status", &self.status);
+ debug.field(
+ "signer_mode",
+ &self.signer_mode.as_ref().map(|_| "<redacted>"),
+ );
+ debug.field(
+ "signer_session_id",
+ &self.signer_session_id.as_ref().map(|_| "<redacted>"),
+ );
+ debug.field("event_addr", &self.event_addr);
+ debug.field("relay_count", &self.relay_count);
+ debug.field("acknowledged_relay_count", &self.acknowledged_relay_count);
+ debug.finish()
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl SdkRadrootsdPublishReceipt {
+ pub fn job(&self) -> Option<SdkRadrootsdBridgeJobRef> {
+ self.job_id
+ .as_ref()
+ .map(|job_id| SdkRadrootsdBridgeJobRef::new(job_id.clone()))
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum SdkPublishError {
+ Config(SdkConfigError),
+ Encode(String),
+ UnsupportedTransport {
+ transport: SdkTransportMode,
+ operation: &'static str,
+ },
+ UnsupportedSignerMode {
+ transport: SdkTransportMode,
+ signer: SignerConfig,
+ required: SignerConfig,
+ operation: &'static str,
+ },
+ Relay(String),
+ RelaySetup {
+ transport: SdkTransportMode,
+ operation: &'static str,
+ target_relays: Vec<String>,
+ error: String,
+ },
+ RelayNotAcknowledged {
+ transport: SdkTransportMode,
+ failed_relays: Vec<SdkRelayFailure>,
+ },
+ Radrootsd(String),
+}
+
+impl From<SdkConfigError> for SdkPublishError {
+ fn from(value: SdkConfigError) -> Self {
+ Self::Config(value)
+ }
+}
+
+impl core::fmt::Display for SdkPublishError {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ match self {
+ Self::Config(err) => write!(f, "{err}"),
+ Self::Encode(message) => write!(f, "{message}"),
+ Self::UnsupportedTransport {
+ transport,
+ operation,
+ } => {
+ write!(
+ f,
+ "{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::RelaySetup {
+ transport,
+ operation,
+ target_relays,
+ error,
+ } => {
+ if target_relays.is_empty() {
+ write!(
+ f,
+ "{operation} failed to prepare {transport:?} relay publish: {error}"
+ )
+ } else {
+ let relays = target_relays.join(", ");
+ write!(
+ f,
+ "{operation} failed to prepare {transport:?} relay publish for {relays}: {error}"
+ )
+ }
+ }
+ Self::RelayNotAcknowledged {
+ transport,
+ failed_relays,
+ } => {
+ if failed_relays.is_empty() {
+ write!(f, "{transport:?} publish was not acknowledged by any relay")
+ } else {
+ let summary = failed_relays
+ .iter()
+ .map(|failure| format!("{}: {}", failure.relay_url, failure.error))
+ .collect::<Vec<_>>()
+ .join(", ");
+ write!(
+ f,
+ "{transport:?} publish was not acknowledged by any relay: {summary}"
+ )
+ }
+ }
+ Self::Radrootsd(message) => write!(f, "{message}"),
+ }
+ }
+}
+
+#[cfg(feature = "std")]
+impl std::error::Error for SdkPublishError {}
+
+#[cfg(feature = "radrootsd-client")]
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum SdkRadrootsdSessionError {
+ Config(SdkConfigError),
+ UnsupportedTransport {
+ transport: SdkTransportMode,
+ operation: &'static str,
+ },
+ Radrootsd(String),
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl From<SdkConfigError> for SdkRadrootsdSessionError {
+ fn from(value: SdkConfigError) -> Self {
+ Self::Config(value)
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl fmt::Display for SdkRadrootsdSessionError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Config(err) => write!(f, "{err}"),
+ Self::UnsupportedTransport {
+ transport,
+ operation,
+ } => {
+ write!(
+ f,
+ "{operation} requires a different sdk transport mode than {transport:?}"
+ )
+ }
+ Self::Radrootsd(message) => write!(f, "{message}"),
+ }
+ }
+}
+
+#[cfg(all(feature = "radrootsd-client", feature = "std"))]
+impl std::error::Error for SdkRadrootsdSessionError {}
+
+#[cfg(feature = "radrootsd-client")]
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum SdkRadrootsdBridgeError {
+ Config(SdkConfigError),
+ UnsupportedTransport {
+ transport: SdkTransportMode,
+ operation: &'static str,
+ },
+ Radrootsd(String),
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl From<SdkConfigError> for SdkRadrootsdBridgeError {
+ fn from(value: SdkConfigError) -> Self {
+ Self::Config(value)
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl fmt::Display for SdkRadrootsdBridgeError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Config(err) => write!(f, "{err}"),
+ Self::UnsupportedTransport {
+ transport,
+ operation,
+ } => write!(
+ f,
+ "{operation} requires a different sdk transport mode than {transport:?}"
+ ),
+ Self::Radrootsd(message) => write!(f, "{message}"),
+ }
+ }
+}
+
+#[cfg(all(feature = "radrootsd-client", feature = "std"))]
+impl std::error::Error for SdkRadrootsdBridgeError {}
+
+#[cfg(feature = "radrootsd-client")]
+#[derive(Clone, PartialEq, Eq)]
+pub struct SdkRadrootsdSignerSessionRef {
+ session_id: String,
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl fmt::Debug for SdkRadrootsdSignerSessionRef {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_struct("SdkRadrootsdSignerSessionRef")
+ .field("session_id", &"<redacted>")
+ .finish()
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl SdkRadrootsdSignerSessionRef {
+ pub fn from_session_id(session_id: impl Into<String>) -> Self {
+ Self {
+ session_id: session_id.into(),
+ }
+ }
+
+ pub fn session_id(&self) -> &str {
+ self.session_id.as_str()
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct SdkRadrootsdBridgeJobRef {
+ job_id: String,
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl SdkRadrootsdBridgeJobRef {
+ pub fn new(job_id: impl Into<String>) -> Self {
+ Self {
+ job_id: job_id.into(),
+ }
+ }
+
+ pub fn job_id(&self) -> &str {
+ self.job_id.as_str()
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct SdkRadrootsdBridgeStatus {
+ pub enabled: bool,
+ pub ready: bool,
+ pub auth_mode: String,
+ pub signer_mode: String,
+ pub default_signer_mode: String,
+ pub supported_signer_modes: Vec<String>,
+ pub available_nip46_signer_sessions: usize,
+ pub relay_count: usize,
+ pub delivery_policy: radrootsd::SdkRadrootsdBridgeDeliveryPolicy,
+ pub delivery_quorum: Option<usize>,
+ pub publish_max_attempts: usize,
+ pub publish_initial_backoff_millis: u64,
+ pub publish_max_backoff_millis: u64,
+ pub job_status_retention: usize,
+ pub retained_jobs: usize,
+ pub retained_idempotency_keys: usize,
+ pub accepted_jobs: usize,
+ pub published_jobs: usize,
+ pub failed_jobs: usize,
+ pub recovered_failed_jobs: usize,
+ pub methods: Vec<String>,
+}
+
+#[cfg(feature = "radrootsd-client")]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct SdkRadrootsdBridgeJobView {
+ job: SdkRadrootsdBridgeJobRef,
+ pub command: String,
+ pub idempotency_key: Option<String>,
+ pub status: radrootsd::SdkRadrootsdBridgeJobStatus,
+ pub terminal: bool,
+ pub recovered_after_restart: bool,
+ pub requested_at_unix: u64,
+ pub completed_at_unix: Option<u64>,
+ pub signer_mode: String,
+ pub signer_session_id: Option<String>,
+ pub event_kind: u32,
+ pub event_id: Option<String>,
+ pub event_addr: Option<String>,
+ pub delivery_policy: radrootsd::SdkRadrootsdBridgeDeliveryPolicy,
+ pub delivery_quorum: Option<usize>,
+ pub relay_count: usize,
+ pub acknowledged_relay_count: usize,
+ pub required_acknowledged_relay_count: usize,
+ pub attempt_count: usize,
+ pub attempt_summaries: Vec<String>,
+ pub relay_results: Vec<radrootsd::SdkRadrootsdBridgeRelayPublishResult>,
+ pub relay_outcome_summary: String,
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl SdkRadrootsdBridgeJobView {
+ pub fn job(&self) -> &SdkRadrootsdBridgeJobRef {
+ &self.job
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl From<radrootsd::SdkRadrootsdBridgeStatusResponse> for SdkRadrootsdBridgeStatus {
+ fn from(value: radrootsd::SdkRadrootsdBridgeStatusResponse) -> Self {
+ Self {
+ enabled: value.enabled,
+ ready: value.ready,
+ auth_mode: value.auth_mode,
+ signer_mode: value.signer_mode,
+ default_signer_mode: value.default_signer_mode,
+ supported_signer_modes: value.supported_signer_modes,
+ available_nip46_signer_sessions: value.available_nip46_signer_sessions,
+ relay_count: value.relay_count,
+ delivery_policy: value.delivery_policy,
+ delivery_quorum: value.delivery_quorum,
+ publish_max_attempts: value.publish_max_attempts,
+ publish_initial_backoff_millis: value.publish_initial_backoff_millis,
+ publish_max_backoff_millis: value.publish_max_backoff_millis,
+ job_status_retention: value.job_status_retention,
+ retained_jobs: value.retained_jobs,
+ retained_idempotency_keys: value.retained_idempotency_keys,
+ accepted_jobs: value.accepted_jobs,
+ published_jobs: value.published_jobs,
+ failed_jobs: value.failed_jobs,
+ recovered_failed_jobs: value.recovered_failed_jobs,
+ methods: value.methods,
+ }
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl From<radrootsd::SdkRadrootsdBridgeJobView> for SdkRadrootsdBridgeJobView {
+ fn from(value: radrootsd::SdkRadrootsdBridgeJobView) -> Self {
+ Self {
+ job: SdkRadrootsdBridgeJobRef::new(value.job_id),
+ command: value.command,
+ idempotency_key: value.idempotency_key,
+ status: value.status,
+ terminal: value.terminal,
+ recovered_after_restart: value.recovered_after_restart,
+ requested_at_unix: value.requested_at_unix,
+ completed_at_unix: value.completed_at_unix,
+ signer_mode: value.signer_mode,
+ signer_session_id: value.signer_session_id,
+ event_kind: value.event_kind,
+ event_id: value.event_id,
+ event_addr: value.event_addr,
+ delivery_policy: value.delivery_policy,
+ delivery_quorum: value.delivery_quorum,
+ relay_count: value.relay_count,
+ acknowledged_relay_count: value.acknowledged_relay_count,
+ required_acknowledged_relay_count: value.required_acknowledged_relay_count,
+ attempt_count: value.attempt_count,
+ attempt_summaries: value.attempt_summaries,
+ relay_results: value.relay_results,
+ relay_outcome_summary: value.relay_outcome_summary,
+ }
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+#[derive(Clone, PartialEq, Eq)]
+pub struct SdkRadrootsdSignerSessionHandle {
+ session: SdkRadrootsdSignerSessionRef,
+ mode: radrootsd::SdkRadrootsdSignerSessionMode,
+ remote_signer_pubkey: String,
+ client_pubkey: String,
+ relays: Vec<String>,
+}
+
+#[cfg(feature = "radrootsd-client")]
+#[derive(Clone, PartialEq, Eq)]
+pub struct SdkRadrootsdSignerSessionView {
+ session: SdkRadrootsdSignerSessionRef,
+ pub role: radrootsd::SdkRadrootsdSignerSessionRole,
+ pub client_pubkey: String,
+ pub signer_pubkey: String,
+ pub user_pubkey: Option<String>,
+ pub relays: Vec<String>,
+ pub permissions: Vec<String>,
+ pub name: Option<String>,
+ pub url: Option<String>,
+ pub image: Option<String>,
+ pub auth_required: bool,
+ pub authorized: bool,
+ pub auth_url: Option<String>,
+ pub expires_in_secs: Option<u64>,
+ pub signer_authority: Option<radrootsd::SdkRadrootsdSignerAuthority>,
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl fmt::Debug for SdkRadrootsdSignerSessionView {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let mut debug = f.debug_struct("SdkRadrootsdSignerSessionView");
+ debug.field("session", &self.session);
+ debug.field("role", &self.role);
+ debug.field("client_pubkey", &self.client_pubkey);
+ debug.field("signer_pubkey", &self.signer_pubkey);
+ debug.field("user_pubkey", &self.user_pubkey);
+ debug.field("relays", &self.relays);
+ debug.field("permissions", &self.permissions);
+ debug.field("name", &self.name);
+ debug.field("url", &self.url);
+ debug.field("image", &self.image);
+ debug.field("auth_required", &self.auth_required);
+ debug.field("authorized", &self.authorized);
+ debug.field("auth_url", &self.auth_url);
+ debug.field("expires_in_secs", &self.expires_in_secs);
+ debug.field("signer_authority", &self.signer_authority);
+ debug.finish()
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl SdkRadrootsdSignerSessionView {
+ pub fn session(&self) -> &SdkRadrootsdSignerSessionRef {
+ &self.session
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct SdkRadrootsdSignerSessionAuthorizeResult {
+ pub authorized: bool,
+ pub replayed: bool,
+}
+
+#[cfg(feature = "radrootsd-client")]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct SdkRadrootsdSignerSessionPublicKeyResult {
+ pub pubkey: String,
+}
+
+#[cfg(feature = "radrootsd-client")]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct SdkRadrootsdSignerSessionRequireAuthResult {
+ pub required: bool,
+}
+
+#[cfg(feature = "radrootsd-client")]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct SdkRadrootsdSignerSessionCloseResult {
+ pub closed: bool,
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl From<radrootsd::SdkRadrootsdSignerSessionViewResponse> for SdkRadrootsdSignerSessionView {
+ fn from(value: radrootsd::SdkRadrootsdSignerSessionViewResponse) -> Self {
+ Self {
+ session: SdkRadrootsdSignerSessionRef {
+ session_id: value.session_id,
+ },
+ role: value.role,
+ client_pubkey: value.client_pubkey,
+ signer_pubkey: value.signer_pubkey,
+ user_pubkey: value.user_pubkey,
+ relays: value.relays,
+ permissions: value.permissions,
+ name: value.name,
+ url: value.url,
+ image: value.image,
+ auth_required: value.auth_required,
+ authorized: value.authorized,
+ auth_url: value.auth_url,
+ expires_in_secs: value.expires_in_secs,
+ signer_authority: value.signer_authority,
+ }
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl From<radrootsd::SdkRadrootsdSignerSessionAuthorizeResponse>
+ for SdkRadrootsdSignerSessionAuthorizeResult
+{
+ fn from(value: radrootsd::SdkRadrootsdSignerSessionAuthorizeResponse) -> Self {
+ Self {
+ authorized: value.authorized,
+ replayed: value.replayed,
+ }
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl From<radrootsd::SdkRadrootsdSignerSessionPublicKeyResponse>
+ for SdkRadrootsdSignerSessionPublicKeyResult
+{
+ fn from(value: radrootsd::SdkRadrootsdSignerSessionPublicKeyResponse) -> Self {
+ Self {
+ pubkey: value.pubkey,
+ }
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl From<radrootsd::SdkRadrootsdSignerSessionRequireAuthResponse>
+ for SdkRadrootsdSignerSessionRequireAuthResult
+{
+ fn from(value: radrootsd::SdkRadrootsdSignerSessionRequireAuthResponse) -> Self {
+ Self {
+ required: value.required,
+ }
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl From<radrootsd::SdkRadrootsdSignerSessionCloseResponse>
+ for SdkRadrootsdSignerSessionCloseResult
+{
+ fn from(value: radrootsd::SdkRadrootsdSignerSessionCloseResponse) -> Self {
+ Self {
+ closed: value.closed,
+ }
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl fmt::Debug for SdkRadrootsdSignerSessionHandle {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let mut debug = f.debug_struct("SdkRadrootsdSignerSessionHandle");
+ debug.field("session", &self.session);
+ debug.field("mode", &self.mode);
+ debug.field("remote_signer_pubkey", &self.remote_signer_pubkey);
+ debug.field("client_pubkey", &self.client_pubkey);
+ debug.field("relays", &self.relays);
+ debug.finish()
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl SdkRadrootsdSignerSessionHandle {
+ pub fn session(&self) -> &SdkRadrootsdSignerSessionRef {
+ &self.session
+ }
+
+ pub fn mode(&self) -> radrootsd::SdkRadrootsdSignerSessionMode {
+ self.mode
+ }
+
+ pub fn remote_signer_pubkey(&self) -> &str {
+ self.remote_signer_pubkey.as_str()
+ }
+
+ pub fn client_pubkey(&self) -> &str {
+ self.client_pubkey.as_str()
+ }
+
+ pub fn relays(&self) -> &[String] {
+ self.relays.as_slice()
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl From<radrootsd::SdkRadrootsdSignerSessionConnectResponse> for SdkRadrootsdSignerSessionHandle {
+ fn from(value: radrootsd::SdkRadrootsdSignerSessionConnectResponse) -> Self {
+ Self {
+ session: SdkRadrootsdSignerSessionRef {
+ session_id: value.session_id,
+ },
+ mode: value.mode,
+ remote_signer_pubkey: value.remote_signer_pubkey,
+ client_pubkey: value.client_pubkey,
+ relays: value.relays,
+ }
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+#[derive(Clone, PartialEq, Eq)]
+pub struct SdkRadrootsdProfilePublishOptions {
+ session: SdkRadrootsdSignerSessionRef,
+ idempotency_key: Option<String>,
+ signer_authority: Option<radrootsd::SdkRadrootsdSignerAuthority>,
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl SdkRadrootsdProfilePublishOptions {
+ pub fn from_signer_session(session: &SdkRadrootsdSignerSessionHandle) -> Self {
+ Self {
+ session: session.session().clone(),
+ idempotency_key: None,
+ signer_authority: None,
+ }
+ }
+
+ pub fn from_signer_session_ref(session: &SdkRadrootsdSignerSessionRef) -> Self {
+ Self {
+ session: session.clone(),
+ idempotency_key: None,
+ signer_authority: None,
+ }
+ }
+
+ pub fn with_idempotency_key(mut self, idempotency_key: impl Into<String>) -> Self {
+ self.idempotency_key = Some(idempotency_key.into());
+ self
+ }
+
+ pub fn with_signer_authority(
+ mut self,
+ signer_authority: radrootsd::SdkRadrootsdSignerAuthority,
+ ) -> Self {
+ self.signer_authority = Some(signer_authority);
+ self
+ }
+
+ pub fn session(&self) -> &SdkRadrootsdSignerSessionRef {
+ &self.session
+ }
+
+ pub fn idempotency_key(&self) -> Option<&str> {
+ self.idempotency_key.as_deref()
+ }
+
+ pub fn signer_authority(&self) -> Option<&radrootsd::SdkRadrootsdSignerAuthority> {
+ self.signer_authority.as_ref()
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl fmt::Debug for SdkRadrootsdProfilePublishOptions {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let mut debug = f.debug_struct("SdkRadrootsdProfilePublishOptions");
+ debug.field("session", &self.session);
+ debug.field("idempotency_key", &self.idempotency_key);
+ debug.field("signer_authority", &self.signer_authority);
+ debug.finish()
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+#[derive(Clone, PartialEq, Eq)]
+pub struct SdkRadrootsdFarmPublishOptions {
+ session: SdkRadrootsdSignerSessionRef,
+ idempotency_key: Option<String>,
+ signer_authority: Option<radrootsd::SdkRadrootsdSignerAuthority>,
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl SdkRadrootsdFarmPublishOptions {
+ pub fn from_signer_session(session: &SdkRadrootsdSignerSessionHandle) -> Self {
+ Self {
+ session: session.session().clone(),
+ idempotency_key: None,
+ signer_authority: None,
+ }
+ }
+
+ pub fn from_signer_session_ref(session: &SdkRadrootsdSignerSessionRef) -> Self {
+ Self {
+ session: session.clone(),
+ idempotency_key: None,
+ signer_authority: None,
+ }
+ }
+
+ pub fn with_idempotency_key(mut self, idempotency_key: impl Into<String>) -> Self {
+ self.idempotency_key = Some(idempotency_key.into());
+ self
+ }
+
+ pub fn with_signer_authority(
+ mut self,
+ signer_authority: radrootsd::SdkRadrootsdSignerAuthority,
+ ) -> Self {
+ self.signer_authority = Some(signer_authority);
+ self
+ }
+
+ pub fn session(&self) -> &SdkRadrootsdSignerSessionRef {
+ &self.session
+ }
+
+ pub fn idempotency_key(&self) -> Option<&str> {
+ self.idempotency_key.as_deref()
+ }
+
+ pub fn signer_authority(&self) -> Option<&radrootsd::SdkRadrootsdSignerAuthority> {
+ self.signer_authority.as_ref()
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl fmt::Debug for SdkRadrootsdFarmPublishOptions {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let mut debug = f.debug_struct("SdkRadrootsdFarmPublishOptions");
+ debug.field("session", &self.session);
+ debug.field("idempotency_key", &self.idempotency_key);
+ debug.field("signer_authority", &self.signer_authority);
+ debug.finish()
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+#[derive(Clone, PartialEq, Eq)]
+pub struct SdkRadrootsdListingPublishOptions {
+ session: SdkRadrootsdSignerSessionRef,
+ idempotency_key: Option<String>,
+ signer_authority: Option<radrootsd::SdkRadrootsdSignerAuthority>,
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl SdkRadrootsdListingPublishOptions {
+ pub fn from_signer_session(session: &SdkRadrootsdSignerSessionHandle) -> Self {
+ Self {
+ session: session.session().clone(),
+ idempotency_key: None,
+ signer_authority: None,
+ }
+ }
+
+ pub fn from_signer_session_ref(session: &SdkRadrootsdSignerSessionRef) -> Self {
+ Self {
+ session: session.clone(),
+ idempotency_key: None,
+ signer_authority: None,
+ }
+ }
+
+ pub fn with_idempotency_key(mut self, idempotency_key: impl Into<String>) -> Self {
+ self.idempotency_key = Some(idempotency_key.into());
+ self
+ }
+
+ pub fn with_signer_authority(
+ mut self,
+ signer_authority: radrootsd::SdkRadrootsdSignerAuthority,
+ ) -> Self {
+ self.signer_authority = Some(signer_authority);
+ self
+ }
+
+ pub fn session(&self) -> &SdkRadrootsdSignerSessionRef {
+ &self.session
+ }
+
+ pub fn idempotency_key(&self) -> Option<&str> {
+ self.idempotency_key.as_deref()
+ }
+
+ pub fn signer_authority(&self) -> Option<&radrootsd::SdkRadrootsdSignerAuthority> {
+ self.signer_authority.as_ref()
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl fmt::Debug for SdkRadrootsdListingPublishOptions {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let mut debug = f.debug_struct("SdkRadrootsdListingPublishOptions");
+ debug.field("session", &self.session);
+ debug.field("idempotency_key", &self.idempotency_key);
+ debug.field("signer_authority", &self.signer_authority);
+ debug.finish()
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+#[derive(Clone, PartialEq, Eq)]
+pub struct SdkRadrootsdOrderRequestPublishOptions {
+ session: SdkRadrootsdSignerSessionRef,
+ idempotency_key: Option<String>,
+ signer_authority: Option<radrootsd::SdkRadrootsdSignerAuthority>,
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl SdkRadrootsdOrderRequestPublishOptions {
+ pub fn from_signer_session(session: &SdkRadrootsdSignerSessionHandle) -> Self {
+ Self {
+ session: session.session().clone(),
+ idempotency_key: None,
+ signer_authority: None,
+ }
+ }
+
+ pub fn from_signer_session_ref(session: &SdkRadrootsdSignerSessionRef) -> Self {
+ Self {
+ session: session.clone(),
+ idempotency_key: None,
+ signer_authority: None,
+ }
+ }
+
+ pub fn with_idempotency_key(mut self, idempotency_key: impl Into<String>) -> Self {
+ self.idempotency_key = Some(idempotency_key.into());
+ self
+ }
+
+ pub fn with_signer_authority(
+ mut self,
+ signer_authority: radrootsd::SdkRadrootsdSignerAuthority,
+ ) -> Self {
+ self.signer_authority = Some(signer_authority);
+ self
+ }
+
+ pub fn session(&self) -> &SdkRadrootsdSignerSessionRef {
+ &self.session
+ }
+
+ pub fn idempotency_key(&self) -> Option<&str> {
+ self.idempotency_key.as_deref()
+ }
+
+ pub fn signer_authority(&self) -> Option<&radrootsd::SdkRadrootsdSignerAuthority> {
+ self.signer_authority.as_ref()
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl fmt::Debug for SdkRadrootsdOrderRequestPublishOptions {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let mut debug = f.debug_struct("SdkRadrootsdOrderRequestPublishOptions");
+ debug.field("session", &self.session);
+ debug.field("idempotency_key", &self.idempotency_key);
+ debug.field("signer_authority", &self.signer_authority);
+ debug.finish()
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RadrootsSdkClient {
+ config: RadrootsSdkConfig,
+ resolved_transport_target: SdkResolvedTransportTarget,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum SdkResolvedTransportTarget {
+ RelayDirect { relay_urls: Vec<String> },
+ Radrootsd { endpoint: String },
+}
+
+impl RadrootsSdkClient {
+ pub fn from_config(config: RadrootsSdkConfig) -> Result<Self, SdkConfigError> {
+ let resolved_transport_target = match config.transport {
+ SdkTransportMode::RelayDirect => SdkResolvedTransportTarget::RelayDirect {
+ relay_urls: config.resolved_relay_urls()?,
+ },
+ SdkTransportMode::Radrootsd => SdkResolvedTransportTarget::Radrootsd {
+ endpoint: config.resolved_radrootsd_endpoint()?,
+ },
+ };
+ Ok(Self {
+ config,
+ resolved_transport_target,
+ })
+ }
+
+ pub fn config(&self) -> &RadrootsSdkConfig {
+ &self.config
+ }
+
+ pub fn transport(&self) -> SdkTransportMode {
+ self.config.transport
+ }
+
+ pub fn signer(&self) -> SignerConfig {
+ self.config.signer
+ }
+
+ pub fn resolved_transport_target(&self) -> &SdkResolvedTransportTarget {
+ &self.resolved_transport_target
+ }
+
+ pub fn profile(&self) -> ProfileClient<'_> {
+ ProfileClient { client: self }
+ }
+
+ pub fn farm(&self) -> FarmClient<'_> {
+ FarmClient { client: self }
+ }
+
+ pub fn listing(&self) -> ListingClient<'_> {
+ ListingClient { client: self }
+ }
+
+ pub fn order(&self) -> TradeClient<'_> {
+ TradeClient { client: self }
+ }
+
+ #[cfg(feature = "radrootsd-client")]
+ pub fn radrootsd(&self) -> RadrootsdClient<'_> {
+ RadrootsdClient { client: self }
+ }
+
+ #[cfg(any(
+ feature = "radrootsd-client",
+ 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,
+ parts: WireEventParts,
+ operation: &'static str,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ if self.transport() != SdkTransportMode::RelayDirect {
+ return Err(SdkPublishError::UnsupportedTransport {
+ transport: self.transport(),
+ operation,
+ });
+ }
+ self.require_signer_mode(SignerConfig::LocalIdentity, operation)?;
+
+ let relay_urls = match &self.resolved_transport_target {
+ SdkResolvedTransportTarget::RelayDirect { relay_urls } => relay_urls.clone(),
+ SdkResolvedTransportTarget::Radrootsd { .. } => {
+ return Err(SdkPublishError::UnsupportedTransport {
+ transport: self.transport(),
+ operation,
+ });
+ }
+ };
+ let client = relay::connected_client_from_identity(
+ identity,
+ &relay_urls,
+ Duration::from_millis(self.config.network.timeout_ms),
+ )
+ .await
+ .map_err(|err| SdkPublishError::RelaySetup {
+ transport: SdkTransportMode::RelayDirect,
+ operation,
+ target_relays: relay_urls.clone(),
+ error: err.to_string(),
+ })?;
+ let connected_relays = relay::connected_relay_urls(&client).await;
+ if connected_relays.is_empty() {
+ return Err(SdkPublishError::RelaySetup {
+ transport: SdkTransportMode::RelayDirect,
+ operation,
+ target_relays: relay_urls,
+ error: "no relay connection was established".to_owned(),
+ });
+ }
+ let signed_event = signing::sign_parts_with_identity(identity, parts)
+ .map_err(|err| SdkPublishError::Relay(err.to_string()))?;
+ let output = relay::publish_signed_event(&client, &signed_event)
+ .await
+ .map_err(|err| SdkPublishError::RelaySetup {
+ transport: SdkTransportMode::RelayDirect,
+ operation,
+ target_relays: relay_urls.clone(),
+ error: err.to_string(),
+ })?;
+ sdk_publish_receipt_from_relay_output(signed_event, relay_urls, connected_relays, output)
+ }
+
+ #[cfg(feature = "radrootsd-client")]
+ async fn publish_listing_via_radrootsd(
+ &self,
+ request: &radrootsd::SdkRadrootsdListingPublishRequest,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ if self.transport() != SdkTransportMode::Radrootsd {
+ return Err(SdkPublishError::UnsupportedTransport {
+ transport: self.transport(),
+ operation: "listing.publish_via_radrootsd",
+ });
+ }
+ self.require_signer_mode(SignerConfig::Nip46, "listing.publish_via_radrootsd")?;
+
+ let endpoint = match &self.resolved_transport_target {
+ SdkResolvedTransportTarget::Radrootsd { endpoint } => endpoint.as_str(),
+ SdkResolvedTransportTarget::RelayDirect { .. } => {
+ return Err(SdkPublishError::UnsupportedTransport {
+ transport: self.transport(),
+ operation: "listing.publish_via_radrootsd",
+ });
+ }
+ };
+ let response = radrootsd::publish_listing(
+ endpoint,
+ &self.config.radrootsd.auth,
+ request,
+ Duration::from_millis(self.config.network.timeout_ms),
+ )
+ .await
+ .map_err(|err| SdkPublishError::Radrootsd(err.to_string()))?;
+ Ok(sdk_publish_receipt_from_radrootsd_bridge_response(response))
+ }
+
+ #[cfg(feature = "radrootsd-client")]
+ async fn publish_profile_via_radrootsd(
+ &self,
+ request: &radrootsd::SdkRadrootsdProfilePublishRequest,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ if self.transport() != SdkTransportMode::Radrootsd {
+ return Err(SdkPublishError::UnsupportedTransport {
+ transport: self.transport(),
+ operation: "profile.publish_via_radrootsd",
+ });
+ }
+ self.require_signer_mode(SignerConfig::Nip46, "profile.publish_via_radrootsd")?;
+
+ let endpoint = match &self.resolved_transport_target {
+ SdkResolvedTransportTarget::Radrootsd { endpoint } => endpoint.as_str(),
+ SdkResolvedTransportTarget::RelayDirect { .. } => {
+ return Err(SdkPublishError::UnsupportedTransport {
+ transport: self.transport(),
+ operation: "profile.publish_via_radrootsd",
+ });
+ }
+ };
+ let response = radrootsd::publish_profile(
+ endpoint,
+ &self.config.radrootsd.auth,
+ request,
+ Duration::from_millis(self.config.network.timeout_ms),
+ )
+ .await
+ .map_err(|err| SdkPublishError::Radrootsd(err.to_string()))?;
+ Ok(sdk_publish_receipt_from_radrootsd_bridge_response(response))
+ }
+
+ #[cfg(feature = "radrootsd-client")]
+ async fn publish_farm_via_radrootsd(
+ &self,
+ request: &radrootsd::SdkRadrootsdFarmPublishRequest,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ if self.transport() != SdkTransportMode::Radrootsd {
+ return Err(SdkPublishError::UnsupportedTransport {
+ transport: self.transport(),
+ operation: "farm.publish_via_radrootsd",
+ });
+ }
+ self.require_signer_mode(SignerConfig::Nip46, "farm.publish_via_radrootsd")?;
+
+ let endpoint = match &self.resolved_transport_target {
+ SdkResolvedTransportTarget::Radrootsd { endpoint } => endpoint.as_str(),
+ SdkResolvedTransportTarget::RelayDirect { .. } => {
+ return Err(SdkPublishError::UnsupportedTransport {
+ transport: self.transport(),
+ operation: "farm.publish_via_radrootsd",
+ });
+ }
+ };
+ let response = radrootsd::publish_farm(
+ endpoint,
+ &self.config.radrootsd.auth,
+ request,
+ Duration::from_millis(self.config.network.timeout_ms),
+ )
+ .await
+ .map_err(|err| SdkPublishError::Radrootsd(err.to_string()))?;
+ Ok(sdk_publish_receipt_from_radrootsd_bridge_response(response))
+ }
+
+ #[cfg(feature = "radrootsd-client")]
+ async fn publish_order_request_via_radrootsd(
+ &self,
+ request: &radrootsd::SdkRadrootsdOrderRequestPublishRequest,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ if self.transport() != SdkTransportMode::Radrootsd {
+ return Err(SdkPublishError::UnsupportedTransport {
+ transport: self.transport(),
+ operation: "order.publish_order_request_via_radrootsd",
+ });
+ }
+ self.require_signer_mode(
+ SignerConfig::Nip46,
+ "order.publish_order_request_via_radrootsd",
+ )?;
+
+ let endpoint = match &self.resolved_transport_target {
+ SdkResolvedTransportTarget::Radrootsd { endpoint } => endpoint.as_str(),
+ SdkResolvedTransportTarget::RelayDirect { .. } => {
+ return Err(SdkPublishError::UnsupportedTransport {
+ transport: self.transport(),
+ operation: "order.publish_order_request_via_radrootsd",
+ });
+ }
+ };
+ let response = radrootsd::publish_order_request(
+ endpoint,
+ &self.config.radrootsd.auth,
+ request,
+ Duration::from_millis(self.config.network.timeout_ms),
+ )
+ .await
+ .map_err(|err| SdkPublishError::Radrootsd(err.to_string()))?;
+ Ok(sdk_publish_receipt_from_radrootsd_bridge_response(response))
+ }
+
+ #[cfg(feature = "radrootsd-client")]
+ async fn connect_radrootsd_signer_session(
+ &self,
+ request: &radrootsd::SdkRadrootsdSignerSessionConnectRequest,
+ ) -> Result<SdkRadrootsdSignerSessionHandle, SdkRadrootsdSessionError> {
+ if self.transport() != SdkTransportMode::Radrootsd {
+ return Err(SdkRadrootsdSessionError::UnsupportedTransport {
+ transport: self.transport(),
+ operation: "radrootsd.signer_sessions.connect",
+ });
+ }
+
+ let endpoint = self.require_radrootsd_endpoint("radrootsd.signer_sessions.connect")?;
+ let response = radrootsd::connect_signer_session(
+ endpoint,
+ &self.config.radrootsd.auth,
+ request,
+ Duration::from_millis(self.config.network.timeout_ms),
+ )
+ .await
+ .map_err(|err| SdkRadrootsdSessionError::Radrootsd(err.to_string()))?;
+ Ok(response.into())
+ }
+
+ #[cfg(feature = "radrootsd-client")]
+ fn require_radrootsd_endpoint(
+ &self,
+ operation: &'static str,
+ ) -> Result<&str, SdkRadrootsdSessionError> {
+ match &self.resolved_transport_target {
+ SdkResolvedTransportTarget::Radrootsd { endpoint } => Ok(endpoint.as_str()),
+ SdkResolvedTransportTarget::RelayDirect { .. } => {
+ Err(SdkRadrootsdSessionError::UnsupportedTransport {
+ transport: self.transport(),
+ operation,
+ })
+ }
+ }
+ }
+
+ #[cfg(feature = "radrootsd-client")]
+ async fn radrootsd_signer_session_status(
+ &self,
+ session: &SdkRadrootsdSignerSessionRef,
+ ) -> Result<SdkRadrootsdSignerSessionView, SdkRadrootsdSessionError> {
+ if self.transport() != SdkTransportMode::Radrootsd {
+ return Err(SdkRadrootsdSessionError::UnsupportedTransport {
+ transport: self.transport(),
+ operation: "radrootsd.signer_sessions.status",
+ });
+ }
+
+ let response = radrootsd::signer_session_status(
+ self.require_radrootsd_endpoint("radrootsd.signer_sessions.status")?,
+ &self.config.radrootsd.auth,
+ session.session_id(),
+ Duration::from_millis(self.config.network.timeout_ms),
+ )
+ .await
+ .map_err(|err| SdkRadrootsdSessionError::Radrootsd(err.to_string()))?;
+ Ok(response.into())
+ }
+
+ #[cfg(feature = "radrootsd-client")]
+ async fn radrootsd_list_signer_sessions(
+ &self,
+ ) -> Result<Vec<SdkRadrootsdSignerSessionView>, SdkRadrootsdSessionError> {
+ if self.transport() != SdkTransportMode::Radrootsd {
+ return Err(SdkRadrootsdSessionError::UnsupportedTransport {
+ transport: self.transport(),
+ operation: "radrootsd.signer_sessions.list",
+ });
+ }
+
+ let response = radrootsd::list_signer_sessions(
+ self.require_radrootsd_endpoint("radrootsd.signer_sessions.list")?,
+ &self.config.radrootsd.auth,
+ Duration::from_millis(self.config.network.timeout_ms),
+ )
+ .await
+ .map_err(|err| SdkRadrootsdSessionError::Radrootsd(err.to_string()))?;
+ Ok(response.into_iter().map(Into::into).collect())
+ }
+
+ #[cfg(feature = "radrootsd-client")]
+ async fn authorize_radrootsd_signer_session(
+ &self,
+ session: &SdkRadrootsdSignerSessionRef,
+ ) -> Result<SdkRadrootsdSignerSessionAuthorizeResult, SdkRadrootsdSessionError> {
+ if self.transport() != SdkTransportMode::Radrootsd {
+ return Err(SdkRadrootsdSessionError::UnsupportedTransport {
+ transport: self.transport(),
+ operation: "radrootsd.signer_sessions.authorize",
+ });
+ }
+
+ let response = radrootsd::authorize_signer_session(
+ self.require_radrootsd_endpoint("radrootsd.signer_sessions.authorize")?,
+ &self.config.radrootsd.auth,
+ session.session_id(),
+ Duration::from_millis(self.config.network.timeout_ms),
+ )
+ .await
+ .map_err(|err| SdkRadrootsdSessionError::Radrootsd(err.to_string()))?;
+ Ok(response.into())
+ }
+
+ #[cfg(feature = "radrootsd-client")]
+ async fn get_radrootsd_signer_session_public_key(
+ &self,
+ session: &SdkRadrootsdSignerSessionRef,
+ ) -> Result<SdkRadrootsdSignerSessionPublicKeyResult, SdkRadrootsdSessionError> {
+ if self.transport() != SdkTransportMode::Radrootsd {
+ return Err(SdkRadrootsdSessionError::UnsupportedTransport {
+ transport: self.transport(),
+ operation: "radrootsd.signer_sessions.get_public_key",
+ });
+ }
+
+ let response = radrootsd::get_signer_session_public_key(
+ self.require_radrootsd_endpoint("radrootsd.signer_sessions.get_public_key")?,
+ &self.config.radrootsd.auth,
+ session.session_id(),
+ Duration::from_millis(self.config.network.timeout_ms),
+ )
+ .await
+ .map_err(|err| SdkRadrootsdSessionError::Radrootsd(err.to_string()))?;
+ Ok(response.into())
+ }
+
+ #[cfg(feature = "radrootsd-client")]
+ async fn require_radrootsd_signer_session_auth(
+ &self,
+ session: &SdkRadrootsdSignerSessionRef,
+ auth_url: &str,
+ ) -> Result<SdkRadrootsdSignerSessionRequireAuthResult, SdkRadrootsdSessionError> {
+ if self.transport() != SdkTransportMode::Radrootsd {
+ return Err(SdkRadrootsdSessionError::UnsupportedTransport {
+ transport: self.transport(),
+ operation: "radrootsd.signer_sessions.require_auth",
+ });
+ }
+
+ let response = radrootsd::require_signer_session_auth(
+ self.require_radrootsd_endpoint("radrootsd.signer_sessions.require_auth")?,
+ &self.config.radrootsd.auth,
+ session.session_id(),
+ auth_url,
+ Duration::from_millis(self.config.network.timeout_ms),
+ )
+ .await
+ .map_err(|err| SdkRadrootsdSessionError::Radrootsd(err.to_string()))?;
+ Ok(response.into())
+ }
+
+ #[cfg(feature = "radrootsd-client")]
+ async fn close_radrootsd_signer_session(
+ &self,
+ session: &SdkRadrootsdSignerSessionRef,
+ ) -> Result<SdkRadrootsdSignerSessionCloseResult, SdkRadrootsdSessionError> {
+ if self.transport() != SdkTransportMode::Radrootsd {
+ return Err(SdkRadrootsdSessionError::UnsupportedTransport {
+ transport: self.transport(),
+ operation: "radrootsd.signer_sessions.close",
+ });
+ }
+
+ let response = radrootsd::close_signer_session(
+ self.require_radrootsd_endpoint("radrootsd.signer_sessions.close")?,
+ &self.config.radrootsd.auth,
+ session.session_id(),
+ Duration::from_millis(self.config.network.timeout_ms),
+ )
+ .await
+ .map_err(|err| SdkRadrootsdSessionError::Radrootsd(err.to_string()))?;
+ Ok(response.into())
+ }
+
+ #[cfg(feature = "radrootsd-client")]
+ fn require_radrootsd_bridge_endpoint(
+ &self,
+ operation: &'static str,
+ ) -> Result<&str, SdkRadrootsdBridgeError> {
+ match &self.resolved_transport_target {
+ SdkResolvedTransportTarget::Radrootsd { endpoint } => Ok(endpoint.as_str()),
+ SdkResolvedTransportTarget::RelayDirect { .. } => {
+ Err(SdkRadrootsdBridgeError::UnsupportedTransport {
+ transport: self.transport(),
+ operation,
+ })
+ }
+ }
+ }
+
+ #[cfg(feature = "radrootsd-client")]
+ async fn radrootsd_bridge_status(
+ &self,
+ ) -> Result<SdkRadrootsdBridgeStatus, SdkRadrootsdBridgeError> {
+ if self.transport() != SdkTransportMode::Radrootsd {
+ return Err(SdkRadrootsdBridgeError::UnsupportedTransport {
+ transport: self.transport(),
+ operation: "radrootsd.bridge.status",
+ });
+ }
+
+ let response = radrootsd::bridge_status(
+ self.require_radrootsd_bridge_endpoint("radrootsd.bridge.status")?,
+ &self.config.radrootsd.auth,
+ Duration::from_millis(self.config.network.timeout_ms),
+ )
+ .await
+ .map_err(|err| SdkRadrootsdBridgeError::Radrootsd(err.to_string()))?;
+ Ok(response.into())
+ }
+
+ #[cfg(feature = "radrootsd-client")]
+ async fn radrootsd_bridge_job_status(
+ &self,
+ job: &SdkRadrootsdBridgeJobRef,
+ ) -> Result<SdkRadrootsdBridgeJobView, SdkRadrootsdBridgeError> {
+ if self.transport() != SdkTransportMode::Radrootsd {
+ return Err(SdkRadrootsdBridgeError::UnsupportedTransport {
+ transport: self.transport(),
+ operation: "radrootsd.bridge.job",
+ });
+ }
+
+ let response = radrootsd::bridge_job_status(
+ self.require_radrootsd_bridge_endpoint("radrootsd.bridge.job")?,
+ &self.config.radrootsd.auth,
+ job.job_id(),
+ Duration::from_millis(self.config.network.timeout_ms),
+ )
+ .await
+ .map_err(|err| SdkRadrootsdBridgeError::Radrootsd(err.to_string()))?;
+ Ok(response.into())
+ }
+
+ #[cfg(feature = "radrootsd-client")]
+ async fn radrootsd_bridge_jobs(
+ &self,
+ ) -> Result<Vec<SdkRadrootsdBridgeJobView>, SdkRadrootsdBridgeError> {
+ if self.transport() != SdkTransportMode::Radrootsd {
+ return Err(SdkRadrootsdBridgeError::UnsupportedTransport {
+ transport: self.transport(),
+ operation: "radrootsd.bridge.jobs",
+ });
+ }
+
+ let response = radrootsd::list_bridge_jobs(
+ self.require_radrootsd_bridge_endpoint("radrootsd.bridge.jobs")?,
+ &self.config.radrootsd.auth,
+ Duration::from_millis(self.config.network.timeout_ms),
+ )
+ .await
+ .map_err(|err| SdkRadrootsdBridgeError::Radrootsd(err.to_string()))?;
+ Ok(response.into_iter().map(Into::into).collect())
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+#[derive(Debug, Clone, Copy)]
+pub struct RadrootsdClient<'a> {
+ client: &'a RadrootsSdkClient,
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl<'a> RadrootsdClient<'a> {
+ pub fn sdk(&self) -> &'a RadrootsSdkClient {
+ self.client
+ }
+
+ pub fn transport(&self) -> SdkTransportMode {
+ self.client.transport()
+ }
+
+ pub fn signer(&self) -> SignerConfig {
+ self.client.signer()
+ }
+
+ pub fn signer_sessions(&self) -> RadrootsdSignerSessionClient<'a> {
+ RadrootsdSignerSessionClient {
+ client: self.client,
+ }
+ }
+
+ pub fn bridge(&self) -> RadrootsdBridgeClient<'a> {
+ RadrootsdBridgeClient {
+ client: self.client,
+ }
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+#[derive(Debug, Clone, Copy)]
+pub struct RadrootsdSignerSessionClient<'a> {
+ client: &'a RadrootsSdkClient,
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl<'a> RadrootsdSignerSessionClient<'a> {
+ pub fn sdk(&self) -> &'a RadrootsSdkClient {
+ self.client
+ }
+
+ pub fn transport(&self) -> SdkTransportMode {
+ self.client.transport()
+ }
+
+ pub fn signer(&self) -> SignerConfig {
+ self.client.signer()
+ }
+
+ pub async fn connect(
+ &self,
+ request: &radrootsd::SdkRadrootsdSignerSessionConnectRequest,
+ ) -> Result<SdkRadrootsdSignerSessionHandle, SdkRadrootsdSessionError> {
+ self.client.connect_radrootsd_signer_session(request).await
+ }
+
+ pub async fn connect_bunker(
+ &self,
+ url: impl Into<String>,
+ ) -> Result<SdkRadrootsdSignerSessionHandle, SdkRadrootsdSessionError> {
+ let request = radrootsd::SdkRadrootsdSignerSessionConnectRequest::bunker(url);
+ self.connect(&request).await
+ }
+
+ pub async fn connect_nostrconnect(
+ &self,
+ url: impl Into<String>,
+ client_secret_key: impl Into<String>,
+ ) -> Result<SdkRadrootsdSignerSessionHandle, SdkRadrootsdSessionError> {
+ let request = radrootsd::SdkRadrootsdSignerSessionConnectRequest::nostrconnect(
+ url,
+ client_secret_key,
+ );
+ self.connect(&request).await
+ }
+
+ pub async fn status(
+ &self,
+ session: &SdkRadrootsdSignerSessionRef,
+ ) -> Result<SdkRadrootsdSignerSessionView, SdkRadrootsdSessionError> {
+ self.client.radrootsd_signer_session_status(session).await
+ }
+
+ pub async fn list(
+ &self,
+ ) -> Result<Vec<SdkRadrootsdSignerSessionView>, SdkRadrootsdSessionError> {
+ self.client.radrootsd_list_signer_sessions().await
+ }
+
+ pub async fn authorize(
+ &self,
+ session: &SdkRadrootsdSignerSessionRef,
+ ) -> Result<SdkRadrootsdSignerSessionAuthorizeResult, SdkRadrootsdSessionError> {
+ self.client
+ .authorize_radrootsd_signer_session(session)
+ .await
+ }
+
+ pub async fn get_public_key(
+ &self,
+ session: &SdkRadrootsdSignerSessionRef,
+ ) -> Result<SdkRadrootsdSignerSessionPublicKeyResult, SdkRadrootsdSessionError> {
+ self.client
+ .get_radrootsd_signer_session_public_key(session)
+ .await
+ }
+
+ pub async fn require_auth(
+ &self,
+ session: &SdkRadrootsdSignerSessionRef,
+ auth_url: impl AsRef<str>,
+ ) -> Result<SdkRadrootsdSignerSessionRequireAuthResult, SdkRadrootsdSessionError> {
+ self.client
+ .require_radrootsd_signer_session_auth(session, auth_url.as_ref())
+ .await
+ }
+
+ pub async fn close(
+ &self,
+ session: &SdkRadrootsdSignerSessionRef,
+ ) -> Result<SdkRadrootsdSignerSessionCloseResult, SdkRadrootsdSessionError> {
+ self.client.close_radrootsd_signer_session(session).await
+ }
+}
+
+#[cfg(feature = "radrootsd-client")]
+#[derive(Debug, Clone, Copy)]
+pub struct RadrootsdBridgeClient<'a> {
+ client: &'a RadrootsSdkClient,
+}
+
+#[cfg(feature = "radrootsd-client")]
+impl<'a> RadrootsdBridgeClient<'a> {
+ pub fn sdk(&self) -> &'a RadrootsSdkClient {
+ self.client
+ }
+
+ pub fn transport(&self) -> SdkTransportMode {
+ self.client.transport()
+ }
+
+ pub fn signer(&self) -> SignerConfig {
+ self.client.signer()
+ }
+
+ pub async fn status(&self) -> Result<SdkRadrootsdBridgeStatus, SdkRadrootsdBridgeError> {
+ self.client.radrootsd_bridge_status().await
+ }
+
+ pub async fn job(
+ &self,
+ job: &SdkRadrootsdBridgeJobRef,
+ ) -> Result<SdkRadrootsdBridgeJobView, SdkRadrootsdBridgeError> {
+ self.client.radrootsd_bridge_job_status(job).await
+ }
+
+ pub async fn jobs(&self) -> Result<Vec<SdkRadrootsdBridgeJobView>, SdkRadrootsdBridgeError> {
+ self.client.radrootsd_bridge_jobs().await
+ }
+}
+
+#[derive(Debug, Clone, Copy)]
+pub struct ProfileClient<'a> {
+ client: &'a RadrootsSdkClient,
+}
+
+impl<'a> ProfileClient<'a> {
+ pub fn sdk(&self) -> &'a RadrootsSdkClient {
+ self.client
+ }
+
+ pub fn transport(&self) -> SdkTransportMode {
+ self.client.transport()
+ }
+
+ pub fn signer(&self) -> SignerConfig {
+ self.client.signer()
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn build_draft(
+ &self,
+ profile_value: &RadrootsProfile,
+ profile_type: Option<RadrootsProfileType>,
+ ) -> Result<WireEventParts, profile::ProfileEncodeError> {
+ profile::build_draft(profile_value, profile_type)
+ }
+
+ #[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+ ))]
+ pub async fn publish_with_identity(
+ &self,
+ identity: &RadrootsIdentity,
+ profile_value: &RadrootsProfile,
+ profile_type: Option<RadrootsProfileType>,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ let parts = profile::build_draft(profile_value, profile_type)
+ .map_err(|err| SdkPublishError::Encode(err.to_string()))?;
+ self.client
+ .publish_parts_via_relay_with_identity(identity, parts, "profile.publish_with_identity")
+ .await
+ }
+
+ #[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+ ))]
+ pub async fn publish_draft_with_identity(
+ &self,
+ identity: &RadrootsIdentity,
+ draft: WireEventParts,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ self.client
+ .publish_parts_via_relay_with_identity(
+ identity,
+ draft,
+ "profile.publish_draft_with_identity",
+ )
+ .await
+ }
+
+ #[cfg(feature = "radrootsd-client")]
+ pub async fn publish_profile_via_radrootsd(
+ &self,
+ profile_value: &RadrootsProfile,
+ profile_type: Option<RadrootsProfileType>,
+ session: &SdkRadrootsdSignerSessionHandle,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ self.publish_profile_via_radrootsd_with_options(
+ profile_value,
+ profile_type,
+ &SdkRadrootsdProfilePublishOptions::from_signer_session(session),
+ )
+ .await
+ }
+
+ #[cfg(feature = "radrootsd-client")]
+ pub async fn publish_profile_via_radrootsd_with_options(
+ &self,
+ profile_value: &RadrootsProfile,
+ profile_type: Option<RadrootsProfileType>,
+ options: &SdkRadrootsdProfilePublishOptions,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ let request = radrootsd::SdkRadrootsdProfilePublishRequest {
+ profile: profile_value.clone(),
+ profile_type,
+ signer_session_id: options.session().session_id().to_owned(),
+ signer_authority: options.signer_authority().cloned(),
+ idempotency_key: options.idempotency_key().map(str::to_owned),
+ };
+ self.client.publish_profile_via_radrootsd(&request).await
+ }
+}
+
+#[derive(Debug, Clone, Copy)]
+pub struct FarmClient<'a> {
+ client: &'a RadrootsSdkClient,
+}
+
+impl<'a> FarmClient<'a> {
+ pub fn sdk(&self) -> &'a RadrootsSdkClient {
+ self.client
+ }
+
+ pub fn transport(&self) -> SdkTransportMode {
+ self.client.transport()
+ }
+
+ pub fn signer(&self) -> SignerConfig {
+ self.client.signer()
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn build_draft(
+ &self,
+ farm_value: &farm::RadrootsFarm,
+ ) -> Result<WireEventParts, farm::EventEncodeError> {
+ farm::build_draft(farm_value)
+ }
+
+ #[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+ ))]
+ pub async fn publish_with_identity(
+ &self,
+ identity: &RadrootsIdentity,
+ farm_value: &farm::RadrootsFarm,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ let parts = farm::build_draft(farm_value)
+ .map_err(|err| SdkPublishError::Encode(err.to_string()))?;
+ self.client
+ .publish_parts_via_relay_with_identity(identity, parts, "farm.publish_with_identity")
+ .await
+ }
+
+ #[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+ ))]
+ pub async fn publish_draft_with_identity(
+ &self,
+ identity: &RadrootsIdentity,
+ draft: WireEventParts,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ self.client
+ .publish_parts_via_relay_with_identity(
+ identity,
+ draft,
+ "farm.publish_draft_with_identity",
+ )
+ .await
+ }
+
+ #[cfg(feature = "radrootsd-client")]
+ pub async fn publish_farm_via_radrootsd(
+ &self,
+ farm_value: &farm::RadrootsFarm,
+ session: &SdkRadrootsdSignerSessionHandle,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ self.publish_farm_via_radrootsd_with_options(
+ farm_value,
+ &SdkRadrootsdFarmPublishOptions::from_signer_session(session),
+ )
+ .await
+ }
+
+ #[cfg(feature = "radrootsd-client")]
+ pub async fn publish_farm_via_radrootsd_with_options(
+ &self,
+ farm_value: &farm::RadrootsFarm,
+ options: &SdkRadrootsdFarmPublishOptions,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ let request = radrootsd::SdkRadrootsdFarmPublishRequest {
+ farm: farm_value.clone(),
+ kind: Some(KIND_FARM),
+ signer_session_id: options.session().session_id().to_owned(),
+ signer_authority: options.signer_authority().cloned(),
+ idempotency_key: options.idempotency_key().map(str::to_owned),
+ };
+ self.client.publish_farm_via_radrootsd(&request).await
+ }
+}
+
+#[derive(Debug, Clone, Copy)]
+pub struct ListingClient<'a> {
+ client: &'a RadrootsSdkClient,
+}
+
+impl<'a> ListingClient<'a> {
+ pub fn sdk(&self) -> &'a RadrootsSdkClient {
+ self.client
+ }
+
+ pub fn transport(&self) -> SdkTransportMode {
+ self.client.transport()
+ }
+
+ pub fn signer(&self) -> SignerConfig {
+ self.client.signer()
+ }
+
+ pub fn build_tags(
+ &self,
+ listing_value: &listing::RadrootsListing,
+ ) -> Result<NostrTags, listing::EventEncodeError> {
+ listing::build_tags(listing_value)
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn build_draft(
+ &self,
+ listing_value: &listing::RadrootsListing,
+ ) -> Result<listing::RadrootsListingDraft, listing::EventEncodeError> {
+ listing::build_draft(listing_value)
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn parse_event(
+ &self,
+ event: &RadrootsNostrEvent,
+ ) -> Result<listing::RadrootsListing, listing::RadrootsListingParseError> {
+ listing::parse_event(event)
+ }
+
+ #[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+ ))]
+ pub async fn publish_with_identity(
+ &self,
+ identity: &RadrootsIdentity,
+ listing_value: &listing::RadrootsListing,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ let parts = listing::build_draft(listing_value)
+ .map_err(|err| SdkPublishError::Encode(err.to_string()))?
+ .into_wire_parts();
+ self.client
+ .publish_parts_via_relay_with_identity(identity, parts, "listing.publish_with_identity")
+ .await
+ }
+
+ #[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+ ))]
+ pub async fn publish_draft_with_identity(
+ &self,
+ identity: &RadrootsIdentity,
+ draft: listing::RadrootsListingDraft,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ self.client
+ .publish_parts_via_relay_with_identity(
+ identity,
+ draft.into_wire_parts(),
+ "listing.publish_draft_with_identity",
+ )
+ .await
+ }
+
+ #[cfg(feature = "radrootsd-client")]
+ pub async fn publish_listing_via_radrootsd(
+ &self,
+ listing_value: &listing::RadrootsListing,
+ session: &SdkRadrootsdSignerSessionHandle,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ self.publish_listing_via_radrootsd_with_options(
+ listing_value,
+ &SdkRadrootsdListingPublishOptions::from_signer_session(session),
+ )
+ .await
+ }
+
+ #[cfg(feature = "radrootsd-client")]
+ pub async fn publish_listing_via_radrootsd_with_options(
+ &self,
+ listing_value: &listing::RadrootsListing,
+ options: &SdkRadrootsdListingPublishOptions,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ let request = radrootsd::SdkRadrootsdListingPublishRequest {
+ listing: listing_value.clone(),
+ kind: Some(KIND_LISTING),
+ signer_session_id: options.session().session_id().to_owned(),
+ signer_authority: options.signer_authority().cloned(),
+ idempotency_key: options.idempotency_key().map(str::to_owned),
+ };
+ self.client.publish_listing_via_radrootsd(&request).await
+ }
+
+ #[cfg(feature = "radrootsd-client")]
+ pub async fn publish_draft_via_radrootsd(
+ &self,
+ draft: listing::RadrootsListingDraft,
+ session: &SdkRadrootsdSignerSessionHandle,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ self.publish_draft_via_radrootsd_with_options(
+ draft,
+ &SdkRadrootsdListingPublishOptions::from_signer_session(session),
+ )
+ .await
+ }
+
+ #[cfg(feature = "radrootsd-client")]
+ pub async fn publish_draft_via_radrootsd_with_options(
+ &self,
+ draft: listing::RadrootsListingDraft,
+ options: &SdkRadrootsdListingPublishOptions,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ let parts = draft.into_wire_parts();
+ let event = RadrootsNostrEvent {
+ id: String::new(),
+ author: String::new(),
+ created_at: 0,
+ kind: parts.kind,
+ tags: parts.tags,
+ content: parts.content,
+ sig: String::new(),
+ };
+ let request = radrootsd::SdkRadrootsdListingPublishRequest::from_event(
+ &event,
+ options.session().session_id().to_owned(),
+ options.signer_authority().cloned(),
+ options.idempotency_key().map(str::to_owned),
+ )
+ .map_err(|err| SdkPublishError::Encode(err.to_string()))?;
+ self.client.publish_listing_via_radrootsd(&request).await
+ }
+}
+
+#[derive(Debug, Clone, Copy)]
+pub struct TradeClient<'a> {
+ client: &'a RadrootsSdkClient,
+}
+
+impl<'a> TradeClient<'a> {
+ pub fn sdk(&self) -> &'a RadrootsSdkClient {
+ self.client
+ }
+
+ pub fn transport(&self) -> SdkTransportMode {
+ self.client.transport()
+ }
+
+ pub fn signer(&self) -> SignerConfig {
+ self.client.signer()
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn parse_listing_address(
+ &self,
+ listing_addr: &str,
+ ) -> Result<order::RadrootsOrderListingAddress, order::RadrootsOrderListingAddressError> {
+ order::parse_listing_address(listing_addr)
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn validate_listing_event(
+ &self,
+ event: &RadrootsNostrEvent,
+ ) -> Result<TradeListingValidateResult, order::RadrootsTradeValidationListingError> {
+ order::validate_listing_event(event)
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn build_order_request_draft(
+ &self,
+ listing_event: &RadrootsNostrEventPtr,
+ payload: &order::RadrootsOrderRequest,
+ ) -> Result<order::RadrootsOrderRequestDraft, order::EventEncodeError> {
+ order::build_order_request_draft(listing_event, payload)
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn build_order_decision_draft(
+ &self,
+ root_event_id: &RadrootsEventId,
+ prev_event_id: &RadrootsEventId,
+ payload: &order::RadrootsOrderDecision,
+ ) -> Result<order::RadrootsOrderDecisionDraft, order::EventEncodeError> {
+ order::build_order_decision_draft(root_event_id, prev_event_id, payload)
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn build_order_revision_proposal_draft(
+ &self,
+ root_event_id: &RadrootsEventId,
+ prev_event_id: &RadrootsEventId,
+ payload: &order::RadrootsOrderRevisionProposal,
+ ) -> Result<order::RadrootsOrderRevisionProposalDraft, order::EventEncodeError> {
+ order::build_order_revision_proposal_draft(root_event_id, prev_event_id, payload)
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn build_order_revision_decision_draft(
+ &self,
+ root_event_id: &RadrootsEventId,
+ prev_event_id: &RadrootsEventId,
+ payload: &order::RadrootsOrderRevisionDecision,
+ ) -> Result<order::RadrootsOrderRevisionDecisionDraft, order::EventEncodeError> {
+ order::build_order_revision_decision_draft(root_event_id, prev_event_id, payload)
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn build_fulfillment_update_draft(
+ &self,
+ root_event_id: &RadrootsEventId,
+ prev_event_id: &RadrootsEventId,
+ payload: &order::RadrootsOrderFulfillmentUpdate,
+ ) -> Result<order::RadrootsOrderFulfillmentUpdateDraft, order::EventEncodeError> {
+ order::build_fulfillment_update_draft(root_event_id, prev_event_id, payload)
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn build_order_cancellation_draft(
+ &self,
+ root_event_id: &RadrootsEventId,
+ prev_event_id: &RadrootsEventId,
+ payload: &order::RadrootsOrderCancellation,
+ ) -> Result<order::RadrootsOrderCancellationDraft, order::EventEncodeError> {
+ order::build_order_cancellation_draft(root_event_id, prev_event_id, payload)
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn build_buyer_receipt_draft(
+ &self,
+ root_event_id: &RadrootsEventId,
+ prev_event_id: &RadrootsEventId,
+ payload: &order::RadrootsOrderReceipt,
+ ) -> Result<order::RadrootsOrderReceiptDraft, order::EventEncodeError> {
+ order::build_buyer_receipt_draft(root_event_id, prev_event_id, payload)
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn parse_order_request(
+ &self,
+ event: &RadrootsNostrEvent,
+ ) -> Result<
+ order::RadrootsOrderEnvelope<order::RadrootsOrderRequest>,
+ order::RadrootsOrderEnvelopeParseError,
+ > {
+ order::parse_order_request(event)
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn parse_order_decision(
+ &self,
+ event: &RadrootsNostrEvent,
+ ) -> Result<
+ order::RadrootsOrderEnvelope<order::RadrootsOrderDecision>,
+ order::RadrootsOrderEnvelopeParseError,
+ > {
+ order::parse_order_decision(event)
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn parse_order_revision_proposal(
+ &self,
+ event: &RadrootsNostrEvent,
+ ) -> Result<
+ order::RadrootsOrderEnvelope<order::RadrootsOrderRevisionProposal>,
+ order::RadrootsOrderEnvelopeParseError,
+ > {
+ order::parse_order_revision_proposal(event)
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn parse_order_revision_decision(
+ &self,
+ event: &RadrootsNostrEvent,
+ ) -> Result<
+ order::RadrootsOrderEnvelope<order::RadrootsOrderRevisionDecision>,
+ order::RadrootsOrderEnvelopeParseError,
+ > {
+ order::parse_order_revision_decision(event)
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn parse_fulfillment_update(
+ &self,
+ event: &RadrootsNostrEvent,
+ ) -> Result<
+ order::RadrootsOrderEnvelope<order::RadrootsOrderFulfillmentUpdate>,
+ order::RadrootsOrderEnvelopeParseError,
+ > {
+ order::parse_fulfillment_update(event)
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn parse_order_cancellation(
+ &self,
+ event: &RadrootsNostrEvent,
+ ) -> Result<
+ order::RadrootsOrderEnvelope<order::RadrootsOrderCancellation>,
+ order::RadrootsOrderEnvelopeParseError,
+ > {
+ order::parse_order_cancellation(event)
+ }
+
+ #[cfg(feature = "serde_json")]
+ pub fn parse_buyer_receipt(
+ &self,
+ event: &RadrootsNostrEvent,
+ ) -> Result<
+ order::RadrootsOrderEnvelope<order::RadrootsOrderReceipt>,
+ order::RadrootsOrderEnvelopeParseError,
+ > {
+ order::parse_buyer_receipt(event)
+ }
+
+ #[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+ ))]
+ pub async fn publish_order_request_with_identity(
+ &self,
+ identity: &RadrootsIdentity,
+ listing_event: &RadrootsNostrEventPtr,
+ payload: &order::RadrootsOrderRequest,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ let draft = order::build_order_request_draft(listing_event, payload)
+ .map_err(|err| SdkPublishError::Encode(err.to_string()))?;
+ self.client
+ .publish_parts_via_relay_with_identity(
+ identity,
+ draft.into_wire_parts(),
+ "order.publish_order_request_with_identity",
+ )
+ .await
+ }
+
+ #[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+ ))]
+ pub async fn publish_order_revision_proposal_with_identity(
+ &self,
+ identity: &RadrootsIdentity,
+ root_event_id: &RadrootsEventId,
+ prev_event_id: &RadrootsEventId,
+ payload: &order::RadrootsOrderRevisionProposal,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ let draft =
+ order::build_order_revision_proposal_draft(root_event_id, prev_event_id, payload)
+ .map_err(|err| SdkPublishError::Encode(err.to_string()))?;
+ self.client
+ .publish_parts_via_relay_with_identity(
+ identity,
+ draft.into_wire_parts(),
+ "order.publish_order_revision_proposal_with_identity",
+ )
+ .await
+ }
+
+ #[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+ ))]
+ pub async fn publish_order_revision_decision_with_identity(
+ &self,
+ identity: &RadrootsIdentity,
+ root_event_id: &RadrootsEventId,
+ prev_event_id: &RadrootsEventId,
+ payload: &order::RadrootsOrderRevisionDecision,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ let draft =
+ order::build_order_revision_decision_draft(root_event_id, prev_event_id, payload)
+ .map_err(|err| SdkPublishError::Encode(err.to_string()))?;
+ self.client
+ .publish_parts_via_relay_with_identity(
+ identity,
+ draft.into_wire_parts(),
+ "order.publish_order_revision_decision_with_identity",
+ )
+ .await
+ }
+
+ #[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+ ))]
+ pub async fn publish_order_decision_with_identity(
+ &self,
+ identity: &RadrootsIdentity,
+ root_event_id: &RadrootsEventId,
+ prev_event_id: &RadrootsEventId,
+ payload: &order::RadrootsOrderDecision,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ let draft = order::build_order_decision_draft(root_event_id, prev_event_id, payload)
+ .map_err(|err| SdkPublishError::Encode(err.to_string()))?;
+ self.client
+ .publish_parts_via_relay_with_identity(
+ identity,
+ draft.into_wire_parts(),
+ "order.publish_order_decision_with_identity",
+ )
+ .await
+ }
+
+ #[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+ ))]
+ pub async fn publish_fulfillment_update_with_identity(
+ &self,
+ identity: &RadrootsIdentity,
+ root_event_id: &RadrootsEventId,
+ prev_event_id: &RadrootsEventId,
+ payload: &order::RadrootsOrderFulfillmentUpdate,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ let draft = order::build_fulfillment_update_draft(root_event_id, prev_event_id, payload)
+ .map_err(|err| SdkPublishError::Encode(err.to_string()))?;
+ self.client
+ .publish_parts_via_relay_with_identity(
+ identity,
+ draft.into_wire_parts(),
+ "order.publish_fulfillment_update_with_identity",
+ )
+ .await
+ }
+
+ #[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+ ))]
+ pub async fn publish_order_revision_proposal_draft_with_identity(
+ &self,
+ identity: &RadrootsIdentity,
+ draft: order::RadrootsOrderRevisionProposalDraft,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ self.client
+ .publish_parts_via_relay_with_identity(
+ identity,
+ draft.into_wire_parts(),
+ "order.publish_order_revision_proposal_draft_with_identity",
+ )
+ .await
+ }
+
+ #[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+ ))]
+ pub async fn publish_order_revision_decision_draft_with_identity(
+ &self,
+ identity: &RadrootsIdentity,
+ draft: order::RadrootsOrderRevisionDecisionDraft,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ self.client
+ .publish_parts_via_relay_with_identity(
+ identity,
+ draft.into_wire_parts(),
+ "order.publish_order_revision_decision_draft_with_identity",
+ )
+ .await
+ }
+
+ #[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+ ))]
+ pub async fn publish_order_cancellation_with_identity(
+ &self,
+ identity: &RadrootsIdentity,
+ root_event_id: &RadrootsEventId,
+ prev_event_id: &RadrootsEventId,
+ payload: &order::RadrootsOrderCancellation,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ let draft = order::build_order_cancellation_draft(root_event_id, prev_event_id, payload)
+ .map_err(|err| SdkPublishError::Encode(err.to_string()))?;
+ self.client
+ .publish_parts_via_relay_with_identity(
+ identity,
+ draft.into_wire_parts(),
+ "order.publish_order_cancellation_with_identity",
+ )
+ .await
+ }
+
+ #[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+ ))]
+ pub async fn publish_buyer_receipt_with_identity(
+ &self,
+ identity: &RadrootsIdentity,
+ root_event_id: &RadrootsEventId,
+ prev_event_id: &RadrootsEventId,
+ payload: &order::RadrootsOrderReceipt,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ let draft = order::build_buyer_receipt_draft(root_event_id, prev_event_id, payload)
+ .map_err(|err| SdkPublishError::Encode(err.to_string()))?;
+ self.client
+ .publish_parts_via_relay_with_identity(
+ identity,
+ draft.into_wire_parts(),
+ "order.publish_buyer_receipt_with_identity",
+ )
+ .await
+ }
+
+ #[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+ ))]
+ pub async fn publish_order_request_draft_with_identity(
+ &self,
+ identity: &RadrootsIdentity,
+ draft: order::RadrootsOrderRequestDraft,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ self.client
+ .publish_parts_via_relay_with_identity(
+ identity,
+ draft.into_wire_parts(),
+ "order.publish_order_request_draft_with_identity",
+ )
+ .await
+ }
+
+ #[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+ ))]
+ pub async fn publish_order_decision_draft_with_identity(
+ &self,
+ identity: &RadrootsIdentity,
+ draft: order::RadrootsOrderDecisionDraft,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ self.client
+ .publish_parts_via_relay_with_identity(
+ identity,
+ draft.into_wire_parts(),
+ "order.publish_order_decision_draft_with_identity",
+ )
+ .await
+ }
+
+ #[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+ ))]
+ pub async fn publish_fulfillment_update_draft_with_identity(
+ &self,
+ identity: &RadrootsIdentity,
+ draft: order::RadrootsOrderFulfillmentUpdateDraft,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ self.client
+ .publish_parts_via_relay_with_identity(
+ identity,
+ draft.into_wire_parts(),
+ "order.publish_fulfillment_update_draft_with_identity",
+ )
+ .await
+ }
+
+ #[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+ ))]
+ pub async fn publish_order_cancellation_draft_with_identity(
+ &self,
+ identity: &RadrootsIdentity,
+ draft: order::RadrootsOrderCancellationDraft,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ self.client
+ .publish_parts_via_relay_with_identity(
+ identity,
+ draft.into_wire_parts(),
+ "order.publish_order_cancellation_draft_with_identity",
+ )
+ .await
+ }
+
+ #[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+ ))]
+ pub async fn publish_buyer_receipt_draft_with_identity(
+ &self,
+ identity: &RadrootsIdentity,
+ draft: order::RadrootsOrderReceiptDraft,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ self.client
+ .publish_parts_via_relay_with_identity(
+ identity,
+ draft.into_wire_parts(),
+ "order.publish_buyer_receipt_draft_with_identity",
+ )
+ .await
+ }
+
+ #[cfg(feature = "radrootsd-client")]
+ pub async fn publish_order_request_via_radrootsd(
+ &self,
+ order: &order::RadrootsOrderRequest,
+ listing_event: &RadrootsNostrEventPtr,
+ session: &SdkRadrootsdSignerSessionHandle,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ self.publish_order_request_via_radrootsd_with_options(
+ order,
+ listing_event,
+ &SdkRadrootsdOrderRequestPublishOptions::from_signer_session(session),
+ )
+ .await
+ }
+
+ #[cfg(feature = "radrootsd-client")]
+ pub async fn publish_order_request_via_radrootsd_with_options(
+ &self,
+ order: &order::RadrootsOrderRequest,
+ listing_event: &RadrootsNostrEventPtr,
+ options: &SdkRadrootsdOrderRequestPublishOptions,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ let request = radrootsd::SdkRadrootsdOrderRequestPublishRequest {
+ order: order.clone(),
+ listing_event: listing_event.clone(),
+ signer_session_id: options.session().session_id().to_owned(),
+ signer_authority: options.signer_authority().cloned(),
+ idempotency_key: options.idempotency_key().map(str::to_owned),
+ };
+ self.client
+ .publish_order_request_via_radrootsd(&request)
+ .await
+ }
+}
+
+#[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+))]
+fn sdk_publish_receipt_from_relay_output(
+ signed_event: signing::SignedNostrEvent,
+ target_relays: Vec<String>,
+ connected_relays: Vec<String>,
+ output: relay::RelayOutput<relay::RelayEventId>,
+) -> Result<SdkPublishReceipt, SdkPublishError> {
+ let event = sdk_event_from_signed_event(&signed_event);
+ let event_id = event.id.clone();
+ let event_kind = event.kind;
+ let created_at = event.created_at;
+ let signature = event.sig.clone();
+ let target_relays = sorted_unique_strings(target_relays);
+ let connected_relays = sorted_unique_strings(connected_relays);
+ let mut acknowledged_relays = output
+ .success
+ .into_iter()
+ .map(|relay| relay.to_string())
+ .collect::<Vec<_>>();
+ acknowledged_relays = sorted_unique_strings(acknowledged_relays);
+
+ let mut failed_relays = output
+ .failed
+ .into_iter()
+ .map(|(relay_url, error)| SdkRelayFailure {
+ relay_url: relay_url.to_string(),
+ error,
+ })
+ .collect::<Vec<_>>();
+ failed_relays.sort_by(|left, right| left.relay_url.cmp(&right.relay_url));
+
+ if acknowledged_relays.is_empty() {
+ return Err(SdkPublishError::RelayNotAcknowledged {
+ transport: SdkTransportMode::RelayDirect,
+ failed_relays,
+ });
+ }
+
+ Ok(SdkPublishReceipt {
+ transport: SdkTransportMode::RelayDirect,
+ event_kind: Some(event_kind),
+ event_id: Some(event_id.clone()),
+ transport_receipt: SdkTransportReceipt::RelayDirect(SdkRelayPublishReceipt {
+ event,
+ event_id,
+ event_kind,
+ created_at,
+ signature,
+ target_relays,
+ connected_relays,
+ acknowledged_relays,
+ failed_relays,
+ }),
+ })
+}
+
+#[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+))]
+fn sdk_event_from_signed_event(event: &signing::SignedNostrEvent) -> RadrootsNostrEvent {
+ RadrootsNostrEvent {
+ id: event.id.to_string(),
+ author: event.pubkey.to_string(),
+ created_at: u32::try_from(event.created_at.as_secs()).unwrap_or(u32::MAX),
+ kind: event.kind.as_u16() as u32,
+ tags: event
+ .tags
+ .iter()
+ .map(|tag| tag.as_slice().to_vec())
+ .collect(),
+ content: event.content.clone(),
+ sig: event.sig.to_string(),
+ }
+}
+
+#[cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+))]
+fn sorted_unique_strings(mut values: Vec<String>) -> Vec<String> {
+ values.sort();
+ values.dedup();
+ values
+}
+
+#[cfg(feature = "radrootsd-client")]
+fn sdk_publish_receipt_from_radrootsd_bridge_response(
+ response: radrootsd::SdkRadrootsdBridgePublishResponse,
+) -> SdkPublishReceipt {
+ let job = response.job;
+ SdkPublishReceipt {
+ transport: SdkTransportMode::Radrootsd,
+ event_kind: Some(job.event_kind),
+ event_id: job.event_id.clone(),
+ transport_receipt: SdkTransportReceipt::Radrootsd(SdkRadrootsdPublishReceipt {
+ accepted: true,
+ deduplicated: response.deduplicated,
+ job_id: Some(job.job_id),
+ status: Some(job.status),
+ signer_mode: Some(job.signer_mode),
+ signer_session_id: job.signer_session_id,
+ event_addr: job.event_addr,
+ relay_count: Some(job.relay_count),
+ acknowledged_relay_count: Some(job.acknowledged_relay_count),
+ }),
+ }
+}
+
+#[cfg(all(
+ test,
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+))]
+mod tests {
+ use super::{
+ SdkPublishError, SdkRelayFailure, SdkTransportMode, sdk_publish_receipt_from_relay_output,
+ };
+ use crate::WireEventParts;
+ use crate::adapters::relay::RelayOutput;
+ use crate::adapters::signing::sign_parts_with_identity;
+ use crate::identity::RadrootsIdentity;
+ use radroots_nostr::prelude::RadrootsNostrEventId;
+ use std::collections::{HashMap, HashSet};
+
+ #[test]
+ fn relay_output_maps_to_normalized_publish_receipt() {
+ let identity = RadrootsIdentity::generate();
+ let signed_event = sign_parts_with_identity(
+ &identity,
+ WireEventParts {
+ kind: 30402,
+ content: "listing".to_owned(),
+ tags: vec![vec!["d".to_owned(), "AAAAAAAAAAAAAAAAAAAAAg".to_owned()]],
+ },
+ )
+ .expect("signed event");
+ let event_id = signed_event.id.to_string();
+ let event_created_at = u32::try_from(signed_event.created_at.as_secs()).unwrap();
+ let event_signature = signed_event.sig.to_string();
+ let output = RelayOutput {
+ val: RadrootsNostrEventId::parse(event_id.as_str()).expect("event id"),
+ success: HashSet::from([
+ nostr::RelayUrl::parse("ws://127.0.0.1:8080").expect("relay a"),
+ nostr::RelayUrl::parse("ws://127.0.0.1:8081").expect("relay b"),
+ ]),
+ failed: HashMap::from([(
+ nostr::RelayUrl::parse("ws://127.0.0.1:8082").expect("relay c"),
+ "timeout".to_owned(),
+ )]),
+ };
+
+ let receipt = sdk_publish_receipt_from_relay_output(
+ signed_event,
+ vec![
+ "ws://127.0.0.1:8081".to_owned(),
+ "ws://127.0.0.1:8080".to_owned(),
+ ],
+ vec!["ws://127.0.0.1:8080".to_owned()],
+ output,
+ )
+ .expect("receipt");
+
+ assert_eq!(receipt.transport, SdkTransportMode::RelayDirect);
+ assert_eq!(receipt.event_kind, Some(30402));
+ assert_eq!(receipt.event_id, Some(event_id.clone()));
+ let relay_receipt = match receipt.transport_receipt {
+ super::SdkTransportReceipt::RelayDirect(relay_receipt) => relay_receipt,
+ super::SdkTransportReceipt::Radrootsd(_) => panic!("unexpected radrootsd receipt"),
+ };
+ assert_eq!(relay_receipt.event.id, event_id);
+ assert_eq!(relay_receipt.event_id, relay_receipt.event.id);
+ assert_eq!(relay_receipt.event_kind, 30402);
+ assert_eq!(relay_receipt.created_at, event_created_at);
+ assert_eq!(relay_receipt.signature, event_signature);
+ assert_eq!(
+ relay_receipt.target_relays,
+ vec![
+ "ws://127.0.0.1:8080".to_owned(),
+ "ws://127.0.0.1:8081".to_owned(),
+ ]
+ );
+ assert_eq!(
+ relay_receipt.connected_relays,
+ vec!["ws://127.0.0.1:8080".to_owned()]
+ );
+ }
+
+ #[test]
+ fn relay_output_without_acknowledgement_is_rejected() {
+ let identity = RadrootsIdentity::generate();
+ let signed_event = sign_parts_with_identity(
+ &identity,
+ WireEventParts {
+ kind: 30402,
+ content: "listing".to_owned(),
+ tags: vec![],
+ },
+ )
+ .expect("signed event");
+ let output = RelayOutput {
+ val: RadrootsNostrEventId::parse(signed_event.id.to_string().as_str())
+ .expect("event id"),
+ success: HashSet::new(),
+ failed: HashMap::from([(
+ nostr::RelayUrl::parse("ws://127.0.0.1:8082").expect("relay c"),
+ "blocked".to_owned(),
+ )]),
+ };
+
+ let error = sdk_publish_receipt_from_relay_output(signed_event, vec![], vec![], output)
+ .expect_err("error");
+
+ assert_eq!(
+ error,
+ SdkPublishError::RelayNotAcknowledged {
+ transport: SdkTransportMode::RelayDirect,
+ failed_relays: vec![SdkRelayFailure {
+ relay_url: "ws://127.0.0.1:8082".to_owned(),
+ error: "blocked".to_owned(),
+ }],
+ }
+ );
+ }
+}
diff --git a/crates/sdk/src/config.rs b/crates/sdk/src/config.rs
@@ -0,0 +1,388 @@
+#[cfg(not(feature = "std"))]
+use alloc::{string::String, vec::Vec};
+use core::fmt;
+#[cfg(feature = "std")]
+use std::{env, 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;
+
+#[cfg(feature = "std")]
+const LOCAL_RELAY_SCHEME_ENV: &str = "NOSTR_RS_RELAY_PUBLIC_SCHEME";
+#[cfg(feature = "std")]
+const LOCAL_RELAY_HOST_ENV: &str = "NOSTR_RS_RELAY_PUBLIC_HOST";
+#[cfg(feature = "std")]
+const LOCAL_RELAY_PORT_ENV: &str = "NOSTR_RS_RELAY_PUBLIC_PORT";
+#[cfg(feature = "std")]
+const LOCAL_RADROOTSD_ENDPOINT_ENV: &str = "RADROOTSD_RPC_URL";
+#[cfg(feature = "std")]
+const LOCAL_RADROOTSD_HOST_ENV: &str = "RADROOTSD_RPC_HOST";
+#[cfg(feature = "std")]
+const LOCAL_RADROOTSD_PORT_ENV: &str = "RADROOTSD_RPC_PORT";
+
+#[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() {
+ if environment == SdkEnvironment::Local {
+ #[cfg(feature = "std")]
+ if let Some(local_url) = resolve_local_relay_url_from_env() {
+ return Ok(vec![normalize_relay_url(local_url.as_str())?]);
+ }
+ }
+ 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 => {
+ if environment == SdkEnvironment::Local {
+ #[cfg(feature = "std")]
+ if let Some(endpoint) = resolve_local_radrootsd_endpoint_from_env() {
+ return normalize_radrootsd_endpoint(endpoint.as_str());
+ }
+ }
+
+ 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(Clone, PartialEq, Eq, Default)]
+pub enum RadrootsdAuth {
+ #[default]
+ None,
+ BearerToken(String),
+}
+
+impl fmt::Debug for RadrootsdAuth {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::None => f.write_str("None"),
+ Self::BearerToken(_) => f.write_str("BearerToken(\"<redacted>\")"),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum SignerConfig {
+ #[default]
+ DraftOnly,
+ LocalIdentity,
+ Nip46,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct NetworkConfig {
+ pub timeout_ms: u64,
+}
+
+impl Default for NetworkConfig {
+ fn default() -> Self {
+ Self {
+ timeout_ms: RADROOTS_SDK_DEFAULT_TIMEOUT_MS,
+ }
+ }
+}
+
+#[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 {}
+
+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 {
+ 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);
+ }
+
+ let rest = if let Some(rest) = trimmed.strip_prefix("ws://") {
+ rest
+ } else if let Some(rest) = trimmed.strip_prefix("wss://") {
+ rest
+ } else {
+ return Err(SdkConfigError::InvalidRelayUrl(trimmed.to_owned()));
+ };
+
+ if relay_authority_is_invalid(rest) {
+ return Err(SdkConfigError::InvalidRelayUrl(trimmed.to_owned()));
+ }
+
+ Ok(trimmed.to_owned())
+}
+
+fn relay_authority_is_invalid(rest: &str) -> bool {
+ let authority_end = rest
+ .char_indices()
+ .find(|(_, ch)| matches!(ch, '/' | '?' | '#'))
+ .map(|(index, _)| index)
+ .unwrap_or(rest.len());
+ let authority = &rest[..authority_end];
+
+ if authority.is_empty() || authority.chars().any(char::is_whitespace) {
+ return true;
+ }
+ if authority.contains('@') {
+ return true;
+ }
+
+ if let Some(after_open) = authority.strip_prefix('[') {
+ let Some(close_index) = after_open.find(']') else {
+ return true;
+ };
+ let host = &after_open[..close_index];
+ let after_host = &after_open[close_index + 1..];
+ if host.is_empty() {
+ return true;
+ }
+ return relay_port_suffix_is_invalid(after_host);
+ }
+
+ let colon_count = authority.bytes().filter(|byte| *byte == b':').count();
+ match colon_count {
+ 0 => false,
+ 1 => {
+ let (host, port) = authority
+ .split_once(':')
+ .expect("one colon in relay authority");
+ host.is_empty() || relay_port_is_invalid(port)
+ }
+ _ => true,
+ }
+}
+
+fn relay_port_suffix_is_invalid(after_host: &str) -> bool {
+ if after_host.is_empty() {
+ return false;
+ }
+ let Some(port) = after_host.strip_prefix(':') else {
+ return true;
+ };
+ relay_port_is_invalid(port)
+}
+
+fn relay_port_is_invalid(port: &str) -> bool {
+ port.is_empty() || !port.bytes().all(|byte| byte.is_ascii_digit())
+}
+
+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())
+}
+
+#[cfg(feature = "std")]
+fn resolve_local_relay_url_from_env() -> Option<String> {
+ let scheme = read_trimmed_env(LOCAL_RELAY_SCHEME_ENV)?;
+ let host = read_trimmed_env(LOCAL_RELAY_HOST_ENV)?;
+ let port = read_trimmed_env(LOCAL_RELAY_PORT_ENV)?;
+ Some(format!("{scheme}://{host}:{port}"))
+}
+
+#[cfg(feature = "std")]
+fn resolve_local_radrootsd_endpoint_from_env() -> Option<String> {
+ if let Some(endpoint) = read_trimmed_env(LOCAL_RADROOTSD_ENDPOINT_ENV) {
+ return Some(endpoint);
+ }
+
+ let host = read_trimmed_env(LOCAL_RADROOTSD_HOST_ENV)?;
+ let port = read_trimmed_env(LOCAL_RADROOTSD_PORT_ENV)?;
+ Some(format!("http://{host}:{port}"))
+}
+
+#[cfg(feature = "std")]
+fn read_trimmed_env(key: &str) -> Option<String> {
+ let value = env::var(key).ok()?;
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ return None;
+ }
+ Some(trimmed.to_owned())
+}
diff --git a/crates/sdk/src/farm.rs b/crates/sdk/src/farm.rs
@@ -0,0 +1,9 @@
+pub use radroots_events::farm::*;
+pub use radroots_events_codec::error::EventEncodeError;
+
+use crate::WireEventParts;
+
+#[cfg(feature = "serde_json")]
+pub fn build_draft(farm: &RadrootsFarm) -> Result<WireEventParts, EventEncodeError> {
+ radroots_events_codec::farm::encode::to_wire_parts(farm)
+}
diff --git a/crates/sdk/src/identity.rs b/crates/sdk/src/identity.rs
@@ -0,0 +1,33 @@
+pub use radroots_identity::{
+ DEFAULT_IDENTITY_PATH, IdentityError, RADROOTS_USERNAME_MAX_LEN, RADROOTS_USERNAME_MIN_LEN,
+ RADROOTS_USERNAME_REGEX, RadrootsIdentity, RadrootsIdentityFile, RadrootsIdentityId,
+ RadrootsIdentityProfile, RadrootsIdentityPublic, RadrootsIdentitySecretKeyFormat,
+ radroots_username_is_valid, radroots_username_normalize,
+};
+
+#[cfg(feature = "identity-storage")]
+pub use radroots_identity::{
+ RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT, RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX,
+ RadrootsEncryptedIdentityFile, encrypted_identity_wrapping_key_path, load_encrypted_identity,
+ load_encrypted_identity_with_key_slot, load_identity_profile, rotate_encrypted_identity,
+ rotate_encrypted_identity_with_key_slot, store_encrypted_identity,
+ store_encrypted_identity_with_key_slot, store_identity_profile,
+};
+
+#[cfg(all(feature = "identity-models", feature = "identity-storage"))]
+#[cfg(test)]
+mod tests {
+ use super::{RadrootsEncryptedIdentityFile, RadrootsIdentity};
+
+ #[test]
+ fn encrypted_identity_file_round_trips() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let file = RadrootsEncryptedIdentityFile::new(temp.path().join("identity.enc.json"));
+ let identity = RadrootsIdentity::generate();
+
+ file.store(&identity).expect("store identity");
+ let loaded = file.load().expect("load identity");
+
+ assert_eq!(loaded.public_key_hex(), identity.public_key_hex());
+ }
+}
diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs
@@ -0,0 +1,72 @@
+#![cfg_attr(not(feature = "std"), no_std)]
+#![forbid(unsafe_code)]
+
+#[cfg(not(feature = "std"))]
+extern crate alloc;
+
+#[cfg(not(feature = "std"))]
+use alloc::{string::String, vec::Vec};
+#[cfg(feature = "std")]
+use std::{string::String, vec::Vec};
+
+#[cfg(any(
+ feature = "radrootsd-client",
+ feature = "signing",
+ feature = "relay-client",
+ feature = "signer-adapters"
+))]
+pub mod adapters;
+pub mod client;
+pub mod config;
+pub mod farm;
+#[cfg(feature = "identity-models")]
+pub mod identity;
+pub mod listing;
+pub mod order;
+pub mod profile;
+
+#[cfg(feature = "radrootsd-client")]
+pub use crate::adapters::radrootsd::{
+ SdkRadrootsdBridgeDeliveryPolicy, SdkRadrootsdBridgeJobStatus,
+ SdkRadrootsdBridgeRelayPublishResult, SdkRadrootsdSignerAuthority,
+ SdkRadrootsdSignerSessionConnectRequest, SdkRadrootsdSignerSessionMode,
+ SdkRadrootsdSignerSessionRole,
+};
+pub use crate::client::{
+ FarmClient, ListingClient, ProfileClient, RadrootsSdkClient, SdkPublishError,
+ SdkPublishReceipt, SdkRadrootsdPublishReceipt, SdkRelayFailure, SdkRelayPublishReceipt,
+ SdkResolvedTransportTarget, SdkTransportReceipt, TradeClient,
+};
+#[cfg(feature = "radrootsd-client")]
+pub use crate::client::{
+ RadrootsdBridgeClient, RadrootsdClient, RadrootsdSignerSessionClient, SdkRadrootsdBridgeError,
+ SdkRadrootsdBridgeJobRef, SdkRadrootsdBridgeJobView, SdkRadrootsdBridgeStatus,
+ SdkRadrootsdFarmPublishOptions, SdkRadrootsdListingPublishOptions,
+ SdkRadrootsdOrderRequestPublishOptions, SdkRadrootsdProfilePublishOptions,
+ SdkRadrootsdSessionError, SdkRadrootsdSignerSessionAuthorizeResult,
+ SdkRadrootsdSignerSessionCloseResult, SdkRadrootsdSignerSessionHandle,
+ SdkRadrootsdSignerSessionPublicKeyResult, SdkRadrootsdSignerSessionRef,
+ SdkRadrootsdSignerSessionRequireAuthResult, SdkRadrootsdSignerSessionView,
+};
+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, SdkConfigError, SdkEnvironment, SdkTransportMode,
+ SignerConfig,
+};
+pub use radroots_events::{
+ RadrootsNostrEvent, RadrootsNostrEventPtr, RadrootsNostrEventRef,
+ draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent},
+ farm::RadrootsFarm,
+ listing::RadrootsListing,
+ profile::{RadrootsProfile, RadrootsProfileType},
+};
+#[cfg(feature = "serde_json")]
+pub use radroots_events_codec::order::{
+ RadrootsOrderEnvelopeParseError, RadrootsOrderListingAddress, RadrootsOrderListingAddressError,
+};
+pub use radroots_events_codec::wire::WireEventParts;
+pub use radroots_trade::listing::validation::RadrootsTradeListing as TradeListingValidateResult;
+
+pub type NostrTags = Vec<Vec<String>>;
diff --git a/crates/sdk/src/listing.rs b/crates/sdk/src/listing.rs
@@ -0,0 +1,40 @@
+pub use radroots_events::listing::*;
+pub use radroots_events::order::RadrootsListingParseError;
+pub use radroots_events::trade_validation::RadrootsTradeValidationListingError;
+pub use radroots_events_codec::error::EventEncodeError;
+pub use radroots_trade::listing::validation::RadrootsTradeListing as TradeListingValidateResult;
+
+use crate::{NostrTags, RadrootsNostrEvent, WireEventParts};
+
+#[derive(Debug, Clone)]
+pub struct RadrootsListingDraft {
+ parts: WireEventParts,
+}
+
+impl RadrootsListingDraft {
+ pub fn as_wire_parts(&self) -> &WireEventParts {
+ &self.parts
+ }
+
+ pub fn into_wire_parts(self) -> WireEventParts {
+ self.parts
+ }
+}
+
+pub fn build_tags(listing: &RadrootsListing) -> Result<NostrTags, EventEncodeError> {
+ radroots_events_codec::listing::encode::listing_build_tags(listing)
+}
+
+#[cfg(feature = "serde_json")]
+pub fn build_draft(listing: &RadrootsListing) -> Result<RadrootsListingDraft, EventEncodeError> {
+ Ok(RadrootsListingDraft {
+ parts: radroots_events_codec::listing::encode::to_wire_parts(listing)?,
+ })
+}
+
+#[cfg(feature = "serde_json")]
+pub fn parse_event(
+ event: &RadrootsNostrEvent,
+) -> Result<RadrootsListing, RadrootsListingParseError> {
+ radroots_trade::listing::parse_listing_event(event)
+}
diff --git a/crates/sdk/src/order.rs b/crates/sdk/src/order.rs
@@ -0,0 +1,281 @@
+pub use radroots_events::order::*;
+pub use radroots_events::trade_validation::*;
+pub use radroots_events_codec::error::EventEncodeError;
+#[cfg(feature = "serde_json")]
+pub use radroots_events_codec::order::{
+ RadrootsOrderEnvelopeParseError, RadrootsOrderEventContext, RadrootsOrderListingAddress,
+ RadrootsOrderListingAddressError,
+};
+pub use radroots_trade::listing::validation::RadrootsTradeListing as TradeListingValidateResult;
+
+use crate::{RadrootsNostrEvent, RadrootsNostrEventPtr, WireEventParts};
+use radroots_events::ids::RadrootsEventId;
+
+#[derive(Debug, Clone)]
+pub struct RadrootsOrderRequestDraft {
+ parts: WireEventParts,
+}
+
+#[derive(Debug, Clone)]
+pub struct RadrootsOrderDecisionDraft {
+ parts: WireEventParts,
+}
+
+#[derive(Debug, Clone)]
+pub struct RadrootsOrderRevisionProposalDraft {
+ parts: WireEventParts,
+}
+
+#[derive(Debug, Clone)]
+pub struct RadrootsOrderRevisionDecisionDraft {
+ parts: WireEventParts,
+}
+
+#[derive(Debug, Clone)]
+pub struct RadrootsOrderFulfillmentUpdateDraft {
+ parts: WireEventParts,
+}
+
+#[derive(Debug, Clone)]
+pub struct RadrootsOrderCancellationDraft {
+ parts: WireEventParts,
+}
+
+#[derive(Debug, Clone)]
+pub struct RadrootsOrderReceiptDraft {
+ parts: WireEventParts,
+}
+
+impl RadrootsOrderRequestDraft {
+ pub fn as_wire_parts(&self) -> &WireEventParts {
+ &self.parts
+ }
+
+ pub fn into_wire_parts(self) -> WireEventParts {
+ self.parts
+ }
+}
+
+impl RadrootsOrderDecisionDraft {
+ pub fn as_wire_parts(&self) -> &WireEventParts {
+ &self.parts
+ }
+
+ pub fn into_wire_parts(self) -> WireEventParts {
+ self.parts
+ }
+}
+
+impl RadrootsOrderRevisionProposalDraft {
+ pub fn as_wire_parts(&self) -> &WireEventParts {
+ &self.parts
+ }
+
+ pub fn into_wire_parts(self) -> WireEventParts {
+ self.parts
+ }
+}
+
+impl RadrootsOrderRevisionDecisionDraft {
+ pub fn as_wire_parts(&self) -> &WireEventParts {
+ &self.parts
+ }
+
+ pub fn into_wire_parts(self) -> WireEventParts {
+ self.parts
+ }
+}
+
+impl RadrootsOrderFulfillmentUpdateDraft {
+ pub fn as_wire_parts(&self) -> &WireEventParts {
+ &self.parts
+ }
+
+ pub fn into_wire_parts(self) -> WireEventParts {
+ self.parts
+ }
+}
+
+impl RadrootsOrderCancellationDraft {
+ pub fn as_wire_parts(&self) -> &WireEventParts {
+ &self.parts
+ }
+
+ pub fn into_wire_parts(self) -> WireEventParts {
+ self.parts
+ }
+}
+
+impl RadrootsOrderReceiptDraft {
+ pub fn as_wire_parts(&self) -> &WireEventParts {
+ &self.parts
+ }
+
+ pub fn into_wire_parts(self) -> WireEventParts {
+ self.parts
+ }
+}
+
+#[cfg(feature = "serde_json")]
+pub fn build_order_request_draft(
+ listing_event: &RadrootsNostrEventPtr,
+ payload: &RadrootsOrderRequest,
+) -> Result<RadrootsOrderRequestDraft, EventEncodeError> {
+ Ok(RadrootsOrderRequestDraft {
+ parts: radroots_events_codec::order::order_request_event_build(listing_event, payload)?,
+ })
+}
+
+#[cfg(feature = "serde_json")]
+pub fn build_order_decision_draft(
+ root_event_id: &RadrootsEventId,
+ prev_event_id: &RadrootsEventId,
+ payload: &RadrootsOrderDecision,
+) -> Result<RadrootsOrderDecisionDraft, EventEncodeError> {
+ Ok(RadrootsOrderDecisionDraft {
+ parts: radroots_events_codec::order::order_decision_event_build(
+ root_event_id,
+ prev_event_id,
+ payload,
+ )?,
+ })
+}
+
+#[cfg(feature = "serde_json")]
+pub fn build_order_revision_proposal_draft(
+ root_event_id: &RadrootsEventId,
+ prev_event_id: &RadrootsEventId,
+ payload: &RadrootsOrderRevisionProposal,
+) -> Result<RadrootsOrderRevisionProposalDraft, EventEncodeError> {
+ Ok(RadrootsOrderRevisionProposalDraft {
+ parts: radroots_events_codec::order::order_revision_proposal_event_build(
+ root_event_id,
+ prev_event_id,
+ payload,
+ )?,
+ })
+}
+
+#[cfg(feature = "serde_json")]
+pub fn build_order_revision_decision_draft(
+ root_event_id: &RadrootsEventId,
+ prev_event_id: &RadrootsEventId,
+ payload: &RadrootsOrderRevisionDecision,
+) -> Result<RadrootsOrderRevisionDecisionDraft, EventEncodeError> {
+ Ok(RadrootsOrderRevisionDecisionDraft {
+ parts: radroots_events_codec::order::order_revision_decision_event_build(
+ root_event_id,
+ prev_event_id,
+ payload,
+ )?,
+ })
+}
+
+#[cfg(feature = "serde_json")]
+pub fn build_fulfillment_update_draft(
+ root_event_id: &RadrootsEventId,
+ prev_event_id: &RadrootsEventId,
+ payload: &RadrootsOrderFulfillmentUpdate,
+) -> Result<RadrootsOrderFulfillmentUpdateDraft, EventEncodeError> {
+ Ok(RadrootsOrderFulfillmentUpdateDraft {
+ parts: radroots_events_codec::order::order_fulfillment_update_event_build(
+ root_event_id,
+ prev_event_id,
+ payload,
+ )?,
+ })
+}
+
+#[cfg(feature = "serde_json")]
+pub fn build_order_cancellation_draft(
+ root_event_id: &RadrootsEventId,
+ prev_event_id: &RadrootsEventId,
+ payload: &RadrootsOrderCancellation,
+) -> Result<RadrootsOrderCancellationDraft, EventEncodeError> {
+ Ok(RadrootsOrderCancellationDraft {
+ parts: radroots_events_codec::order::order_cancellation_event_build(
+ root_event_id,
+ prev_event_id,
+ payload,
+ )?,
+ })
+}
+
+#[cfg(feature = "serde_json")]
+pub fn build_buyer_receipt_draft(
+ root_event_id: &RadrootsEventId,
+ prev_event_id: &RadrootsEventId,
+ payload: &RadrootsOrderReceipt,
+) -> Result<RadrootsOrderReceiptDraft, EventEncodeError> {
+ Ok(RadrootsOrderReceiptDraft {
+ parts: radroots_events_codec::order::order_receipt_event_build(
+ root_event_id,
+ prev_event_id,
+ payload,
+ )?,
+ })
+}
+
+#[cfg(feature = "serde_json")]
+pub fn parse_order_request(
+ event: &RadrootsNostrEvent,
+) -> Result<RadrootsOrderEnvelope<RadrootsOrderRequest>, RadrootsOrderEnvelopeParseError> {
+ radroots_events_codec::order::order_request_from_event(event)
+}
+
+#[cfg(feature = "serde_json")]
+pub fn parse_order_decision(
+ event: &RadrootsNostrEvent,
+) -> Result<RadrootsOrderEnvelope<RadrootsOrderDecision>, RadrootsOrderEnvelopeParseError> {
+ radroots_events_codec::order::order_decision_from_event(event)
+}
+
+#[cfg(feature = "serde_json")]
+pub fn parse_order_revision_proposal(
+ event: &RadrootsNostrEvent,
+) -> Result<RadrootsOrderEnvelope<RadrootsOrderRevisionProposal>, RadrootsOrderEnvelopeParseError> {
+ radroots_events_codec::order::order_revision_proposal_from_event(event)
+}
+
+#[cfg(feature = "serde_json")]
+pub fn parse_order_revision_decision(
+ event: &RadrootsNostrEvent,
+) -> Result<RadrootsOrderEnvelope<RadrootsOrderRevisionDecision>, RadrootsOrderEnvelopeParseError> {
+ radroots_events_codec::order::order_revision_decision_from_event(event)
+}
+
+#[cfg(feature = "serde_json")]
+pub fn parse_fulfillment_update(
+ event: &RadrootsNostrEvent,
+) -> Result<RadrootsOrderEnvelope<RadrootsOrderFulfillmentUpdate>, RadrootsOrderEnvelopeParseError>
+{
+ radroots_events_codec::order::order_fulfillment_update_from_event(event)
+}
+
+#[cfg(feature = "serde_json")]
+pub fn parse_order_cancellation(
+ event: &RadrootsNostrEvent,
+) -> Result<RadrootsOrderEnvelope<RadrootsOrderCancellation>, RadrootsOrderEnvelopeParseError> {
+ radroots_events_codec::order::order_cancellation_from_event(event)
+}
+
+#[cfg(feature = "serde_json")]
+pub fn parse_buyer_receipt(
+ event: &RadrootsNostrEvent,
+) -> Result<RadrootsOrderEnvelope<RadrootsOrderReceipt>, RadrootsOrderEnvelopeParseError> {
+ radroots_events_codec::order::order_receipt_from_event(event)
+}
+
+#[cfg(feature = "serde_json")]
+pub fn parse_listing_address(
+ listing_addr: &str,
+) -> Result<RadrootsOrderListingAddress, RadrootsOrderListingAddressError> {
+ RadrootsOrderListingAddress::parse(listing_addr)
+}
+
+#[cfg(feature = "serde_json")]
+pub fn validate_listing_event(
+ event: &RadrootsNostrEvent,
+) -> Result<TradeListingValidateResult, RadrootsTradeValidationListingError> {
+ radroots_trade::listing::validation::validate_listing_event(event)
+}
diff --git a/crates/sdk/src/profile.rs b/crates/sdk/src/profile.rs
@@ -0,0 +1,12 @@
+pub use radroots_events::profile::{RadrootsProfile, RadrootsProfileType};
+pub use radroots_events_codec::profile::error::ProfileEncodeError;
+
+use crate::WireEventParts;
+
+#[cfg(feature = "serde_json")]
+pub fn build_draft(
+ profile: &RadrootsProfile,
+ profile_type: Option<RadrootsProfileType>,
+) -> Result<WireEventParts, ProfileEncodeError> {
+ radroots_events_codec::profile::encode::to_wire_parts_with_profile_type(profile, profile_type)
+}
diff --git a/crates/sdk/tests/client.rs b/crates/sdk/tests/client.rs
@@ -0,0 +1,906 @@
+use radroots_core::{
+ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
+ RadrootsCoreQuantityPrice, RadrootsCoreUnit,
+};
+use radroots_events::farm::{RadrootsFarm, RadrootsFarmRef};
+use radroots_events::ids::{RadrootsEventId, RadrootsPublicKey};
+use radroots_events::kinds::{
+ KIND_FARM, KIND_LISTING, KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION,
+ KIND_ORDER_FULFILLMENT_UPDATE, KIND_ORDER_RECEIPT, KIND_ORDER_REQUEST,
+ KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL, KIND_PROFILE,
+};
+use radroots_events::listing::{
+ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
+ RadrootsListingDeliveryMethod, RadrootsListingLocation, RadrootsListingProduct,
+ RadrootsListingStatus,
+};
+use radroots_events::order::{
+ RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome,
+ RadrootsOrderEconomicItem, RadrootsOrderEconomics, RadrootsOrderFulfillmentState,
+ RadrootsOrderFulfillmentUpdate, RadrootsOrderInventoryCommitment, RadrootsOrderItem,
+ RadrootsOrderPricingBasis, RadrootsOrderReceipt, RadrootsOrderRequest,
+ RadrootsOrderRevisionDecision, RadrootsOrderRevisionOutcome, RadrootsOrderRevisionProposal,
+};
+use radroots_events::profile::{RadrootsProfile, RadrootsProfileType};
+use radroots_sdk::{
+ RADROOTS_SDK_PRODUCTION_RELAY_URL, RadrootsNostrEvent, RadrootsNostrEventPtr,
+ RadrootsSdkClient, RadrootsSdkConfig, RelayConfig, SdkConfigError, SdkEnvironment,
+ SdkPublishError, SdkRadrootsdPublishReceipt, SdkRelayFailure, SdkResolvedTransportTarget,
+ SdkTransportMode, SignerConfig, WireEventParts,
+};
+
+fn sample_farm() -> RadrootsFarm {
+ RadrootsFarm {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(),
+ name: "North Farm".into(),
+ about: Some("Organic coffee".into()),
+ website: None,
+ picture: None,
+ banner: None,
+ location: None,
+ tags: Some(vec!["coffee".into()]),
+ }
+}
+
+fn sample_listing() -> RadrootsListing {
+ RadrootsListing {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAg".parse().expect("listing d tag"),
+ published_at: None,
+ farm: RadrootsFarmRef {
+ pubkey: "seller".into(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(),
+ },
+ product: RadrootsListingProduct {
+ key: "coffee".into(),
+ title: "Coffee".into(),
+ category: "coffee".into(),
+ summary: Some("Single origin coffee".into()),
+ process: None,
+ lot: None,
+ location: None,
+ profile: None,
+ year: None,
+ },
+ primary_bin_id: "bin-1".parse().expect("primary bin id"),
+ bins: vec![RadrootsListingBin {
+ bin_id: "bin-1".parse().expect("bin id"),
+ quantity: RadrootsCoreQuantity::new(
+ RadrootsCoreDecimal::from(1000u32),
+ RadrootsCoreUnit::MassG,
+ ),
+ price_per_canonical_unit: RadrootsCoreQuantityPrice {
+ amount: RadrootsCoreMoney::new(
+ RadrootsCoreDecimal::from(20u32),
+ RadrootsCoreCurrency::USD,
+ ),
+ quantity: RadrootsCoreQuantity::new(
+ RadrootsCoreDecimal::from(1u32),
+ RadrootsCoreUnit::MassG,
+ ),
+ },
+ display_amount: None,
+ display_unit: None,
+ display_label: None,
+ display_price: None,
+ display_price_unit: None,
+ }],
+ resource_area: None,
+ plot: None,
+ discounts: None,
+ inventory_available: Some(RadrootsCoreDecimal::from(5u32)),
+ availability: Some(RadrootsListingAvailability::Status {
+ status: RadrootsListingStatus::Active,
+ }),
+ delivery_method: Some(RadrootsListingDeliveryMethod::Pickup),
+ location: Some(RadrootsListingLocation {
+ primary: "North Farm".into(),
+ city: None,
+ region: None,
+ country: None,
+ lat: None,
+ lng: None,
+ geohash: None,
+ }),
+ images: None,
+ }
+}
+
+fn sample_profile() -> RadrootsProfile {
+ RadrootsProfile {
+ name: "north-farm".into(),
+ display_name: Some("North Farm".into()),
+ nip05: None,
+ about: Some("Farm profile".into()),
+ website: None,
+ picture: None,
+ banner: None,
+ lud06: None,
+ lud16: None,
+ bot: None,
+ }
+}
+
+fn decimal(raw: &str) -> RadrootsCoreDecimal {
+ raw.parse().expect("decimal")
+}
+
+fn usd(raw: &str) -> RadrootsCoreMoney {
+ RadrootsCoreMoney::new(decimal(raw), RadrootsCoreCurrency::USD)
+}
+
+fn listing_event_ptr() -> RadrootsNostrEventPtr {
+ RadrootsNostrEventPtr {
+ id: event_id_wire('a'),
+ relays: Some("wss://listing.relay.example".into()),
+ }
+}
+
+fn public_key(value: String) -> RadrootsPublicKey {
+ value.parse().expect("public key")
+}
+
+fn event_id(character: char) -> RadrootsEventId {
+ core::iter::repeat_n(character, 64)
+ .collect::<String>()
+ .parse()
+ .expect("event id")
+}
+
+fn event_id_wire(character: char) -> String {
+ event_id(character).into_string()
+}
+
+fn sample_order_request(buyer_pubkey: String, seller_pubkey: String) -> RadrootsOrderRequest {
+ let buyer_pubkey = public_key(buyer_pubkey);
+ let seller_pubkey = public_key(seller_pubkey);
+ RadrootsOrderRequest {
+ order_id: "order-1".parse().expect("order id"),
+ listing_addr: format!("{KIND_LISTING}:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg")
+ .parse()
+ .expect("listing address"),
+ buyer_pubkey,
+ seller_pubkey,
+ items: vec![RadrootsOrderItem {
+ bin_id: "bin-1".parse().expect("bin id"),
+ bin_count: 2,
+ }],
+ economics: RadrootsOrderEconomics {
+ quote_id: "quote-1".parse().expect("quote id"),
+ quote_version: 1,
+ pricing_basis: RadrootsOrderPricingBasis::ListingEvent,
+ currency: RadrootsCoreCurrency::USD,
+ items: vec![RadrootsOrderEconomicItem {
+ bin_id: "bin-1".parse().expect("bin id"),
+ bin_count: 2,
+ quantity_amount: decimal("1"),
+ quantity_unit: RadrootsCoreUnit::Each,
+ unit_price_amount: decimal("5"),
+ unit_price_currency: RadrootsCoreCurrency::USD,
+ line_subtotal: usd("10"),
+ }],
+ discounts: Vec::new(),
+ adjustments: Vec::new(),
+ subtotal: usd("10"),
+ discount_total: usd("0"),
+ adjustment_total: usd("0"),
+ total: usd("10"),
+ },
+ }
+}
+
+fn sample_order_decision(buyer_pubkey: String, seller_pubkey: String) -> RadrootsOrderDecision {
+ let buyer_pubkey = public_key(buyer_pubkey);
+ let seller_pubkey = public_key(seller_pubkey);
+ RadrootsOrderDecision {
+ order_id: "order-1".parse().expect("order id"),
+ listing_addr: format!("{KIND_LISTING}:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg")
+ .parse()
+ .expect("listing address"),
+ buyer_pubkey,
+ seller_pubkey,
+ decision: RadrootsOrderDecisionOutcome::Accepted {
+ inventory_commitments: vec![RadrootsOrderInventoryCommitment {
+ bin_id: "bin-1".parse().expect("bin id"),
+ bin_count: 2,
+ }],
+ },
+ }
+}
+
+fn sample_order_revision_proposal(
+ buyer_pubkey: String,
+ seller_pubkey: String,
+ root_event_id: String,
+ prev_event_id: String,
+) -> RadrootsOrderRevisionProposal {
+ let buyer_pubkey = public_key(buyer_pubkey);
+ let seller_pubkey = public_key(seller_pubkey);
+ RadrootsOrderRevisionProposal {
+ revision_id: "revision-1".parse().expect("revision id"),
+ order_id: "order-1".parse().expect("order id"),
+ listing_addr: format!("{KIND_LISTING}:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg")
+ .parse()
+ .expect("listing address"),
+ buyer_pubkey,
+ seller_pubkey,
+ root_event_id: root_event_id.parse().expect("root event id"),
+ prev_event_id: prev_event_id.parse().expect("previous event id"),
+ items: vec![RadrootsOrderItem {
+ bin_id: "bin-1".parse().expect("bin id"),
+ bin_count: 3,
+ }],
+ economics: RadrootsOrderEconomics {
+ quote_id: "revision-quote-1".parse().expect("revision quote id"),
+ quote_version: 2,
+ pricing_basis: RadrootsOrderPricingBasis::ListingEvent,
+ currency: RadrootsCoreCurrency::USD,
+ items: vec![RadrootsOrderEconomicItem {
+ bin_id: "bin-1".parse().expect("bin id"),
+ bin_count: 3,
+ quantity_amount: decimal("1"),
+ quantity_unit: RadrootsCoreUnit::Each,
+ unit_price_amount: decimal("5"),
+ unit_price_currency: RadrootsCoreCurrency::USD,
+ line_subtotal: usd("15"),
+ }],
+ discounts: Vec::new(),
+ adjustments: Vec::new(),
+ subtotal: usd("15"),
+ discount_total: usd("0"),
+ adjustment_total: usd("0"),
+ total: usd("15"),
+ },
+ reason: "update count".into(),
+ }
+}
+
+fn sample_order_revision_decision(
+ proposal: &RadrootsOrderRevisionProposal,
+ decision: RadrootsOrderRevisionOutcome,
+) -> RadrootsOrderRevisionDecision {
+ RadrootsOrderRevisionDecision {
+ revision_id: proposal.revision_id.clone(),
+ order_id: proposal.order_id.clone(),
+ listing_addr: proposal.listing_addr.clone(),
+ buyer_pubkey: proposal.buyer_pubkey.clone(),
+ seller_pubkey: proposal.seller_pubkey.clone(),
+ root_event_id: proposal.root_event_id.clone(),
+ prev_event_id: event_id('3'),
+ decision,
+ }
+}
+
+fn sample_fulfillment_update(
+ buyer_pubkey: String,
+ seller_pubkey: String,
+) -> RadrootsOrderFulfillmentUpdate {
+ let buyer_pubkey = public_key(buyer_pubkey);
+ let seller_pubkey = public_key(seller_pubkey);
+ RadrootsOrderFulfillmentUpdate {
+ order_id: "order-1".parse().expect("order id"),
+ listing_addr: format!("{KIND_LISTING}:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg")
+ .parse()
+ .expect("listing address"),
+ buyer_pubkey,
+ seller_pubkey,
+ status: RadrootsOrderFulfillmentState::ReadyForPickup,
+ }
+}
+
+fn sample_order_cancellation(
+ buyer_pubkey: String,
+ seller_pubkey: String,
+) -> RadrootsOrderCancellation {
+ let buyer_pubkey = public_key(buyer_pubkey);
+ let seller_pubkey = public_key(seller_pubkey);
+ RadrootsOrderCancellation {
+ order_id: "order-1".parse().expect("order id"),
+ listing_addr: format!("{KIND_LISTING}:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg")
+ .parse()
+ .expect("listing address"),
+ buyer_pubkey,
+ seller_pubkey,
+ reason: "schedule changed".into(),
+ }
+}
+
+fn sample_buyer_receipt(buyer_pubkey: String, seller_pubkey: String) -> RadrootsOrderReceipt {
+ let buyer_pubkey = public_key(buyer_pubkey);
+ let seller_pubkey = public_key(seller_pubkey);
+ RadrootsOrderReceipt {
+ order_id: "order-1".parse().expect("order id"),
+ listing_addr: format!("{KIND_LISTING}:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg")
+ .parse()
+ .expect("listing address"),
+ buyer_pubkey,
+ seller_pubkey,
+ received: true,
+ issue: None,
+ received_at: 1_785_000_000,
+ }
+}
+
+fn event_from_parts(
+ id: &str,
+ author: &str,
+ created_at: u32,
+ parts: WireEventParts,
+) -> RadrootsNostrEvent {
+ RadrootsNostrEvent {
+ id: id.into(),
+ author: author.into(),
+ created_at,
+ kind: parts.kind,
+ tags: parts.tags,
+ content: parts.content,
+ sig: String::new(),
+ }
+}
+
+#[test]
+fn client_default_config_uses_production_relay_direct() {
+ let client = RadrootsSdkClient::from_config(RadrootsSdkConfig::default()).expect("sdk client");
+
+ assert_eq!(client.transport(), SdkTransportMode::RelayDirect);
+ assert_eq!(
+ client.resolved_transport_target(),
+ &SdkResolvedTransportTarget::RelayDirect {
+ relay_urls: vec![RADROOTS_SDK_PRODUCTION_RELAY_URL.to_string()],
+ }
+ );
+}
+
+#[test]
+fn client_rejects_invalid_config_on_construction() {
+ let mut config = RadrootsSdkConfig::custom();
+ config.transport = SdkTransportMode::RelayDirect;
+ config.relay = RelayConfig {
+ urls: vec!["https://radroots.org".into()],
+ };
+
+ let error = RadrootsSdkClient::from_config(config).expect_err("invalid config");
+ assert_eq!(
+ error,
+ SdkConfigError::InvalidRelayUrl("https://radroots.org".into())
+ );
+}
+
+#[test]
+fn client_rejects_invalid_radrootsd_config_on_construction() {
+ let mut missing = RadrootsSdkConfig::custom();
+ missing.transport = SdkTransportMode::Radrootsd;
+
+ assert_eq!(
+ RadrootsSdkClient::from_config(missing).expect_err("missing radrootsd endpoint"),
+ SdkConfigError::MissingCustomRadrootsdEndpoint
+ );
+
+ let mut invalid = RadrootsSdkConfig::custom();
+ invalid.transport = SdkTransportMode::Radrootsd;
+ invalid.radrootsd.endpoint = Some("wss://rpc.bad".into());
+
+ assert_eq!(
+ RadrootsSdkClient::from_config(invalid).expect_err("invalid radrootsd endpoint"),
+ SdkConfigError::InvalidRadrootsdEndpoint("wss://rpc.bad".into())
+ );
+}
+
+#[test]
+fn client_allows_custom_relay_without_radrootsd_endpoint() {
+ let mut config = RadrootsSdkConfig::custom();
+ config.transport = SdkTransportMode::RelayDirect;
+ config.relay = RelayConfig {
+ urls: vec!["wss://radroots.org".into()],
+ };
+
+ let client = RadrootsSdkClient::from_config(config).expect("relay-only sdk client");
+ assert_eq!(
+ client.resolved_transport_target(),
+ &SdkResolvedTransportTarget::RelayDirect {
+ relay_urls: vec!["wss://radroots.org".to_string()],
+ }
+ );
+}
+
+#[test]
+fn client_allows_custom_radrootsd_without_relay_urls() {
+ let endpoint = "https://custom.radroots.org/jsonrpc";
+ let mut config = RadrootsSdkConfig::custom();
+ config.transport = SdkTransportMode::Radrootsd;
+ config.radrootsd.endpoint = Some(endpoint.into());
+
+ let client = RadrootsSdkClient::from_config(config).expect("radrootsd-only sdk client");
+ assert_eq!(
+ client.resolved_transport_target(),
+ &SdkResolvedTransportTarget::Radrootsd {
+ endpoint: endpoint.to_string(),
+ }
+ );
+}
+
+#[test]
+fn namespace_clients_reflect_explicit_transport_mode() {
+ let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Production);
+ config.transport = SdkTransportMode::Radrootsd;
+ config.signer = SignerConfig::LocalIdentity;
+
+ let client = RadrootsSdkClient::from_config(config).expect("sdk client");
+
+ assert_eq!(client.transport(), SdkTransportMode::Radrootsd);
+ assert_eq!(client.profile().transport(), SdkTransportMode::Radrootsd);
+ assert_eq!(client.farm().transport(), SdkTransportMode::Radrootsd);
+ assert_eq!(client.listing().transport(), SdkTransportMode::Radrootsd);
+ assert_eq!(client.order().transport(), SdkTransportMode::Radrootsd);
+ #[cfg(feature = "radrootsd-client")]
+ assert_eq!(client.radrootsd().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.order().signer(), SignerConfig::LocalIdentity);
+ #[cfg(feature = "radrootsd-client")]
+ assert_eq!(client.radrootsd().signer(), SignerConfig::LocalIdentity);
+}
+
+#[test]
+fn namespace_clients_expose_parent_sdk_and_draft_facades() {
+ let client =
+ RadrootsSdkClient::from_config(RadrootsSdkConfig::production()).expect("sdk client");
+ let profile = client.profile();
+ let farm = client.farm();
+ let listing = client.listing();
+ let order = client.order();
+
+ assert_eq!(client.config().environment, SdkEnvironment::Production);
+ assert!(std::ptr::eq(profile.sdk(), &client));
+ assert!(std::ptr::eq(farm.sdk(), &client));
+ assert!(std::ptr::eq(listing.sdk(), &client));
+ assert!(std::ptr::eq(order.sdk(), &client));
+
+ let profile_draft = profile
+ .build_draft(&sample_profile(), Some(RadrootsProfileType::Farm))
+ .expect("profile draft");
+ assert_eq!(profile_draft.kind, KIND_PROFILE);
+
+ let farm_draft = farm.build_draft(&sample_farm()).expect("farm draft");
+ assert_eq!(farm_draft.kind, KIND_FARM);
+
+ let listing_draft = listing
+ .build_draft(&sample_listing())
+ .expect("listing draft");
+ assert_eq!(listing_draft.as_wire_parts().kind, KIND_LISTING);
+ assert_eq!(listing_draft.into_wire_parts().kind, KIND_LISTING);
+
+ let mut invalid_listing = sample_listing();
+ invalid_listing.farm.pubkey.clear();
+ assert!(listing.build_draft(&invalid_listing).is_err());
+}
+
+#[test]
+fn listing_and_order_clients_wrap_existing_sdk_facades() {
+ let client = RadrootsSdkClient::from_config(RadrootsSdkConfig::local()).expect("sdk client");
+ let listing_value = sample_listing();
+ let buyer_pubkey = "b".repeat(64);
+ let seller_pubkey = "a".repeat(64);
+
+ let tags = client
+ .listing()
+ .build_tags(&listing_value)
+ .expect("listing tags");
+ assert!(!tags.is_empty());
+
+ let draft = client
+ .listing()
+ .build_draft(&listing_value)
+ .expect("listing draft");
+ assert_eq!(draft.as_wire_parts().kind, KIND_LISTING);
+
+ let event = RadrootsNostrEvent {
+ id: "listing-1".into(),
+ author: "seller".into(),
+ created_at: 1,
+ kind: draft.as_wire_parts().kind,
+ tags: draft.as_wire_parts().tags.clone(),
+ content: draft.as_wire_parts().content.clone(),
+ sig: String::new(),
+ };
+ let parsed = client
+ .listing()
+ .parse_event(&event)
+ .expect("parsed listing");
+ assert_eq!(parsed.d_tag, listing_value.d_tag);
+
+ let validated = client
+ .order()
+ .validate_listing_event(&event)
+ .expect("validated listing");
+ assert_eq!(validated.listing_id, listing_value.d_tag);
+
+ let listing_addr = format!("{KIND_LISTING}:{seller_pubkey}:{}", listing_value.d_tag);
+ let payload = sample_order_request(buyer_pubkey.clone(), seller_pubkey.clone());
+ let envelope = client
+ .order()
+ .build_order_request_draft(&listing_event_ptr(), &payload)
+ .expect("order draft");
+ assert_eq!(envelope.as_wire_parts().kind, KIND_ORDER_REQUEST);
+ let envelope_event = RadrootsNostrEvent {
+ id: "order-event-1".into(),
+ author: buyer_pubkey,
+ created_at: 2,
+ kind: envelope.as_wire_parts().kind,
+ tags: envelope.as_wire_parts().tags.clone(),
+ content: envelope.as_wire_parts().content.clone(),
+ sig: String::new(),
+ };
+ assert_eq!(
+ client
+ .order()
+ .parse_order_request(&envelope_event)
+ .expect("order envelope")
+ .payload
+ .order_id,
+ payload.order_id
+ );
+ let parsed_addr = client
+ .order()
+ .parse_listing_address(&listing_addr)
+ .expect("listing address");
+ assert_eq!(parsed_addr.listing_id, listing_value.d_tag);
+}
+
+#[test]
+fn order_facades_round_trip_all_draft_types() {
+ let client =
+ RadrootsSdkClient::from_config(RadrootsSdkConfig::production()).expect("sdk client");
+ let order_client = client.order();
+ let buyer_pubkey = "b".repeat(64);
+ let seller_pubkey = "a".repeat(64);
+ let root_event_id = event_id('1');
+ let decision_event_id = event_id('2');
+ let proposal_event_id = event_id('3');
+ let fulfillment_event_id = event_id('4');
+
+ let order_request = sample_order_request(buyer_pubkey.clone(), seller_pubkey.clone());
+ let order_draft = order_client
+ .build_order_request_draft(&listing_event_ptr(), &order_request)
+ .expect("order request draft");
+ assert_eq!(order_draft.as_wire_parts().kind, KIND_ORDER_REQUEST);
+ let order_event = event_from_parts(
+ root_event_id.as_str(),
+ &buyer_pubkey,
+ 1,
+ order_draft.clone().into_wire_parts(),
+ );
+ let order_envelope = order_client
+ .parse_order_request(&order_event)
+ .expect("order request envelope");
+ assert_eq!(order_envelope.payload.economics.total, usd("10"));
+
+ let decision = sample_order_decision(buyer_pubkey.clone(), seller_pubkey.clone());
+ let decision_draft = order_client
+ .build_order_decision_draft(&root_event_id, &root_event_id, &decision)
+ .expect("order decision draft");
+ assert_eq!(decision_draft.as_wire_parts().kind, KIND_ORDER_DECISION);
+ let decision_event = event_from_parts(
+ decision_event_id.as_str(),
+ &seller_pubkey,
+ 2,
+ decision_draft.clone().into_wire_parts(),
+ );
+ assert_eq!(
+ order_client
+ .parse_order_decision(&decision_event)
+ .expect("order decision envelope")
+ .payload
+ .decision,
+ decision.decision
+ );
+
+ let proposal = sample_order_revision_proposal(
+ buyer_pubkey.clone(),
+ seller_pubkey.clone(),
+ root_event_id.to_string(),
+ decision_event_id.to_string(),
+ );
+ let proposal_draft = order_client
+ .build_order_revision_proposal_draft(&root_event_id, &decision_event_id, &proposal)
+ .expect("revision proposal draft");
+ assert_eq!(
+ proposal_draft.as_wire_parts().kind,
+ KIND_ORDER_REVISION_PROPOSAL
+ );
+ let proposal_event = event_from_parts(
+ proposal_event_id.as_str(),
+ &seller_pubkey,
+ 3,
+ proposal_draft.clone().into_wire_parts(),
+ );
+ assert_eq!(
+ order_client
+ .parse_order_revision_proposal(&proposal_event)
+ .expect("revision proposal envelope")
+ .payload
+ .economics
+ .total,
+ usd("15")
+ );
+
+ let revision_decision =
+ sample_order_revision_decision(&proposal, RadrootsOrderRevisionOutcome::Accepted);
+ let revision_decision_draft = order_client
+ .build_order_revision_decision_draft(
+ &root_event_id,
+ &revision_decision.prev_event_id,
+ &revision_decision,
+ )
+ .expect("revision decision draft");
+ assert_eq!(
+ revision_decision_draft.as_wire_parts().kind,
+ KIND_ORDER_REVISION_DECISION
+ );
+ let revision_decision_event = event_from_parts(
+ "order-revision-decision-event-1",
+ &buyer_pubkey,
+ 4,
+ revision_decision_draft.clone().into_wire_parts(),
+ );
+ assert_eq!(
+ order_client
+ .parse_order_revision_decision(&revision_decision_event)
+ .expect("revision decision envelope")
+ .payload
+ .revision_id,
+ revision_decision.revision_id
+ );
+
+ let fulfillment = sample_fulfillment_update(buyer_pubkey.clone(), seller_pubkey.clone());
+ let fulfillment_draft = order_client
+ .build_fulfillment_update_draft(&root_event_id, &decision_event_id, &fulfillment)
+ .expect("fulfillment draft");
+ assert_eq!(
+ fulfillment_draft.as_wire_parts().kind,
+ KIND_ORDER_FULFILLMENT_UPDATE
+ );
+ let fulfillment_event = event_from_parts(
+ fulfillment_event_id.as_str(),
+ &seller_pubkey,
+ 5,
+ fulfillment_draft.clone().into_wire_parts(),
+ );
+ assert_eq!(
+ order_client
+ .parse_fulfillment_update(&fulfillment_event)
+ .expect("fulfillment envelope")
+ .payload
+ .status,
+ fulfillment.status
+ );
+
+ let cancellation = sample_order_cancellation(buyer_pubkey.clone(), seller_pubkey.clone());
+ let cancellation_draft = order_client
+ .build_order_cancellation_draft(&root_event_id, &decision_event_id, &cancellation)
+ .expect("cancellation draft");
+ assert_eq!(
+ cancellation_draft.as_wire_parts().kind,
+ KIND_ORDER_CANCELLATION
+ );
+ let cancellation_event = event_from_parts(
+ "order-cancellation-event-1",
+ &buyer_pubkey,
+ 6,
+ cancellation_draft.clone().into_wire_parts(),
+ );
+ assert_eq!(
+ order_client
+ .parse_order_cancellation(&cancellation_event)
+ .expect("cancellation envelope")
+ .payload
+ .reason,
+ cancellation.reason
+ );
+
+ let receipt = sample_buyer_receipt(buyer_pubkey.clone(), seller_pubkey.clone());
+ let receipt_draft = order_client
+ .build_buyer_receipt_draft(&root_event_id, &fulfillment_event_id, &receipt)
+ .expect("receipt draft");
+ assert_eq!(receipt_draft.as_wire_parts().kind, KIND_ORDER_RECEIPT);
+ let receipt_event = event_from_parts(
+ "receipt-event-1",
+ &buyer_pubkey,
+ 7,
+ receipt_draft.clone().into_wire_parts(),
+ );
+ assert!(
+ order_client
+ .parse_buyer_receipt(&receipt_event)
+ .expect("receipt envelope")
+ .payload
+ .received
+ );
+}
+
+#[test]
+fn order_draft_facades_return_encoder_errors() {
+ let client =
+ RadrootsSdkClient::from_config(RadrootsSdkConfig::production()).expect("sdk client");
+ let order = client.order();
+ let buyer_pubkey = "b".repeat(64);
+ let seller_pubkey = "a".repeat(64);
+ let root_event_id = event_id('1');
+ let decision_event_id = event_id('2');
+
+ let mut invalid_order = sample_order_request(buyer_pubkey.clone(), seller_pubkey.clone());
+ invalid_order.items.clear();
+ assert!(
+ order
+ .build_order_request_draft(&listing_event_ptr(), &invalid_order)
+ .is_err()
+ );
+
+ let mut invalid_decision = sample_order_decision(buyer_pubkey.clone(), seller_pubkey.clone());
+ invalid_decision.decision = RadrootsOrderDecisionOutcome::Accepted {
+ inventory_commitments: Vec::new(),
+ };
+ assert!(
+ order
+ .build_order_decision_draft(&root_event_id, &root_event_id, &invalid_decision)
+ .is_err()
+ );
+
+ let proposal = sample_order_revision_proposal(
+ buyer_pubkey.clone(),
+ seller_pubkey.clone(),
+ root_event_id.to_string(),
+ decision_event_id.to_string(),
+ );
+ let different_root_event_id = event_id('d');
+ assert!(
+ order
+ .build_order_revision_proposal_draft(
+ &different_root_event_id,
+ &decision_event_id,
+ &proposal,
+ )
+ .is_err()
+ );
+
+ let revision_decision =
+ sample_order_revision_decision(&proposal, RadrootsOrderRevisionOutcome::Accepted);
+ let different_prev_event_id = event_id('e');
+ assert!(
+ order
+ .build_order_revision_decision_draft(
+ &root_event_id,
+ &different_prev_event_id,
+ &revision_decision,
+ )
+ .is_err()
+ );
+
+ let mut fulfillment = sample_fulfillment_update(buyer_pubkey.clone(), seller_pubkey.clone());
+ fulfillment.status = RadrootsOrderFulfillmentState::AcceptedNotFulfilled;
+ assert!(
+ order
+ .build_fulfillment_update_draft(&root_event_id, &decision_event_id, &fulfillment)
+ .is_err()
+ );
+
+ let mut cancellation = sample_order_cancellation(buyer_pubkey.clone(), seller_pubkey.clone());
+ cancellation.reason.clear();
+ assert!(
+ order
+ .build_order_cancellation_draft(&root_event_id, &decision_event_id, &cancellation)
+ .is_err()
+ );
+
+ let mut receipt = sample_buyer_receipt(buyer_pubkey, seller_pubkey);
+ receipt.received = false;
+ assert!(
+ order
+ .build_buyer_receipt_draft(&root_event_id, &decision_event_id, &receipt)
+ .is_err()
+ );
+}
+
+#[test]
+fn publish_receipts_and_errors_format_public_details() {
+ let receipt = SdkRadrootsdPublishReceipt {
+ accepted: true,
+ deduplicated: true,
+ job_id: Some("job-1".into()),
+ status: Some("accepted".into()),
+ signer_mode: Some("secret-mode".into()),
+ signer_session_id: Some("secret-session".into()),
+ event_addr: Some("3432:pubkey:order-1".into()),
+ relay_count: Some(2),
+ acknowledged_relay_count: Some(1),
+ };
+ let debug = format!("{receipt:?}");
+
+ assert!(debug.contains("SdkRadrootsdPublishReceipt"));
+ assert!(debug.contains("<redacted>"));
+ assert!(!debug.contains("secret-mode"));
+ assert!(!debug.contains("secret-session"));
+
+ let relay_failure = SdkRelayFailure {
+ relay_url: "wss://relay.example".into(),
+ error: "closed".into(),
+ };
+ let formatted = [
+ SdkPublishError::from(SdkConfigError::EmptyRelayUrl).to_string(),
+ SdkPublishError::Encode("encode failed".into()).to_string(),
+ SdkPublishError::UnsupportedTransport {
+ transport: SdkTransportMode::Radrootsd,
+ operation: "order.publish",
+ }
+ .to_string(),
+ SdkPublishError::UnsupportedSignerMode {
+ transport: SdkTransportMode::RelayDirect,
+ signer: SignerConfig::DraftOnly,
+ required: SignerConfig::LocalIdentity,
+ operation: "order.publish",
+ }
+ .to_string(),
+ SdkPublishError::Relay("relay failed".into()).to_string(),
+ SdkPublishError::RelaySetup {
+ transport: SdkTransportMode::RelayDirect,
+ operation: "order.publish",
+ target_relays: Vec::new(),
+ error: "setup failed".into(),
+ }
+ .to_string(),
+ SdkPublishError::RelaySetup {
+ transport: SdkTransportMode::RelayDirect,
+ operation: "order.publish",
+ target_relays: vec!["wss://relay.example".into()],
+ error: "setup failed".into(),
+ }
+ .to_string(),
+ SdkPublishError::RelayNotAcknowledged {
+ transport: SdkTransportMode::RelayDirect,
+ failed_relays: Vec::new(),
+ }
+ .to_string(),
+ SdkPublishError::RelayNotAcknowledged {
+ transport: SdkTransportMode::RelayDirect,
+ failed_relays: vec![relay_failure],
+ }
+ .to_string(),
+ SdkPublishError::Radrootsd("radrootsd failed".into()).to_string(),
+ ];
+
+ assert!(
+ formatted
+ .iter()
+ .any(|message| message == "relay url must not be empty")
+ );
+ assert!(formatted.iter().any(|message| message == "encode failed"));
+ assert!(
+ formatted
+ .iter()
+ .any(|message| message.contains("requires signer mode `local_identity`"))
+ );
+ assert!(formatted.iter().any(|message| {
+ message.contains("failed to prepare RelayDirect relay publish for wss://relay.example")
+ }));
+ assert!(
+ formatted
+ .iter()
+ .any(|message| message.contains("wss://relay.example: closed"))
+ );
+ assert!(
+ formatted
+ .iter()
+ .any(|message| message == "radrootsd failed")
+ );
+}
+
+#[test]
+fn farm_client_wraps_existing_farm_facade() {
+ let client =
+ RadrootsSdkClient::from_config(RadrootsSdkConfig::production()).expect("sdk client");
+ let farm = sample_farm();
+
+ let draft = client.farm().build_draft(&farm).expect("farm draft");
+ assert!(!draft.tags.is_empty());
+}
diff --git a/crates/sdk/tests/config.rs b/crates/sdk/tests/config.rs
@@ -0,0 +1,562 @@
+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, RelayConfig, SdkConfigError, SdkEnvironment, SdkTransportMode, SignerConfig,
+};
+use std::{
+ ffi::OsString,
+ sync::{Mutex, OnceLock},
+};
+
+const LOCAL_SDK_ENV_KEYS: &[&str] = &[
+ "NOSTR_RS_RELAY_PUBLIC_SCHEME",
+ "NOSTR_RS_RELAY_PUBLIC_HOST",
+ "NOSTR_RS_RELAY_PUBLIC_PORT",
+ "RADROOTSD_RPC_URL",
+ "RADROOTSD_RPC_HOST",
+ "RADROOTSD_RPC_PORT",
+];
+
+fn sdk_env_lock() -> &'static Mutex<()> {
+ static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(()))
+}
+
+struct LocalSdkEnvRestore {
+ saved: Vec<(&'static str, Option<OsString>)>,
+}
+
+impl LocalSdkEnvRestore {
+ fn apply(pairs: &[(&str, &str)]) -> Self {
+ let saved = LOCAL_SDK_ENV_KEYS
+ .iter()
+ .map(|key| (*key, std::env::var_os(key)))
+ .collect::<Vec<_>>();
+
+ for key in LOCAL_SDK_ENV_KEYS {
+ unsafe {
+ std::env::remove_var(key);
+ }
+ }
+ for (key, value) in pairs {
+ assert!(
+ LOCAL_SDK_ENV_KEYS.contains(key),
+ "unexpected local sdk env key `{key}`"
+ );
+ unsafe {
+ std::env::set_var(key, value);
+ }
+ }
+
+ Self { saved }
+ }
+}
+
+impl Drop for LocalSdkEnvRestore {
+ fn drop(&mut self) {
+ for (key, original) in self.saved.drain(..) {
+ match original {
+ Some(value) => unsafe {
+ std::env::set_var(key, value);
+ },
+ None => unsafe {
+ std::env::remove_var(key);
+ },
+ }
+ }
+ }
+}
+
+struct EnvKeyRestore {
+ key: &'static str,
+ saved: Option<OsString>,
+}
+
+impl EnvKeyRestore {
+ fn capture(key: &'static str) -> Self {
+ Self {
+ key,
+ saved: std::env::var_os(key),
+ }
+ }
+}
+
+impl Drop for EnvKeyRestore {
+ fn drop(&mut self) {
+ match &self.saved {
+ Some(value) => unsafe {
+ std::env::set_var(self.key, value);
+ },
+ None => unsafe {
+ std::env::remove_var(self.key);
+ },
+ }
+ }
+}
+
+fn with_local_sdk_env<F>(pairs: &[(&str, &str)], test: F)
+where
+ F: FnOnce(),
+{
+ let _guard = sdk_env_lock().lock().expect("sdk env lock");
+ let _env_restore = LocalSdkEnvRestore::apply(pairs);
+
+ test();
+}
+
+#[test]
+fn local_sdk_env_restore_preserves_original_os_string_values() {
+ let _guard = sdk_env_lock().lock().expect("sdk env lock");
+ let key = "NOSTR_RS_RELAY_PUBLIC_HOST";
+ let _restore_key = EnvKeyRestore::capture(key);
+ let original = OsString::from("relay.before.example");
+
+ unsafe {
+ std::env::set_var(key, &original);
+ }
+
+ {
+ let _env_restore = LocalSdkEnvRestore::apply(&[("RADROOTSD_RPC_PORT", "18080")]);
+
+ assert_eq!(std::env::var_os(key), None);
+ }
+
+ assert_eq!(std::env::var_os(key), Some(original));
+}
+
+#[test]
+fn env_key_restore_restores_existing_value() {
+ let _guard = sdk_env_lock().lock().expect("sdk env lock");
+ let key = "NOSTR_RS_RELAY_PUBLIC_HOST";
+ let _restore_outer = EnvKeyRestore::capture(key);
+ let original = OsString::from("relay.before.example");
+ let changed = OsString::from("relay.changed.example");
+
+ unsafe {
+ std::env::set_var(key, &original);
+ }
+
+ {
+ let _restore_inner = EnvKeyRestore::capture(key);
+
+ unsafe {
+ std::env::set_var(key, &changed);
+ }
+ }
+
+ assert_eq!(std::env::var_os(key), Some(original));
+}
+
+#[cfg(unix)]
+#[test]
+fn local_sdk_env_restore_preserves_non_unicode_original_values() {
+ use std::os::unix::ffi::OsStringExt;
+
+ let _guard = sdk_env_lock().lock().expect("sdk env lock");
+ let key = "NOSTR_RS_RELAY_PUBLIC_HOST";
+ let _restore_key = EnvKeyRestore::capture(key);
+ let original = OsString::from_vec(vec![b'r', b'e', b'l', b'a', b'y', 0x80]);
+
+ unsafe {
+ std::env::set_var(key, &original);
+ }
+
+ {
+ let _env_restore = LocalSdkEnvRestore::apply(&[("RADROOTSD_RPC_PORT", "18080")]);
+
+ assert_eq!(std::env::var_os(key), None);
+ }
+
+ assert_eq!(std::env::var_os(key), Some(original));
+}
+
+#[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() {
+ with_local_sdk_env(&[], || {
+ 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 local_environment_prefers_root_env_contract_when_present() {
+ with_local_sdk_env(
+ &[
+ ("NOSTR_RS_RELAY_PUBLIC_SCHEME", "ws"),
+ ("NOSTR_RS_RELAY_PUBLIC_HOST", "127.0.0.1"),
+ ("NOSTR_RS_RELAY_PUBLIC_PORT", "18080"),
+ ("RADROOTSD_RPC_URL", "http://127.0.0.1:17070/jsonrpc"),
+ ],
+ || {
+ let config = RadrootsSdkConfig::local();
+
+ assert_eq!(
+ config.resolved_relay_urls().expect("relay defaults"),
+ vec!["ws://127.0.0.1:18080".to_owned()]
+ );
+ assert_eq!(
+ config
+ .resolved_radrootsd_endpoint()
+ .expect("radrootsd endpoint"),
+ "http://127.0.0.1:17070/jsonrpc"
+ );
+ },
+ );
+}
+
+#[test]
+fn local_environment_ignores_partial_or_blank_env_contracts() {
+ with_local_sdk_env(
+ &[
+ ("NOSTR_RS_RELAY_PUBLIC_SCHEME", "ws"),
+ ("NOSTR_RS_RELAY_PUBLIC_HOST", " "),
+ ("NOSTR_RS_RELAY_PUBLIC_PORT", "18080"),
+ ("RADROOTSD_RPC_HOST", "127.0.0.1"),
+ ],
+ || {
+ 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 local_environment_handles_invalid_and_missing_relay_port_env() {
+ with_local_sdk_env(
+ &[
+ ("NOSTR_RS_RELAY_PUBLIC_SCHEME", "http"),
+ ("NOSTR_RS_RELAY_PUBLIC_HOST", "127.0.0.1"),
+ ("NOSTR_RS_RELAY_PUBLIC_PORT", "18080"),
+ ],
+ || {
+ let config = RadrootsSdkConfig::local();
+
+ assert_eq!(
+ config.resolved_relay_urls().expect_err("invalid relay env"),
+ SdkConfigError::InvalidRelayUrl("http://127.0.0.1:18080".to_owned())
+ );
+ },
+ );
+
+ with_local_sdk_env(
+ &[
+ ("NOSTR_RS_RELAY_PUBLIC_SCHEME", "ws"),
+ ("NOSTR_RS_RELAY_PUBLIC_HOST", "127.0.0.1"),
+ ],
+ || {
+ let config = RadrootsSdkConfig::local();
+
+ assert_eq!(
+ config.resolved_relay_urls().expect("relay defaults"),
+ vec![RADROOTS_SDK_LOCAL_RELAY_URL.to_owned()]
+ );
+ },
+ );
+}
+
+#[test]
+fn local_environment_builds_radrootsd_endpoint_from_host_port_env() {
+ with_local_sdk_env(
+ &[
+ ("RADROOTSD_RPC_HOST", "127.0.0.1"),
+ ("RADROOTSD_RPC_PORT", "17070"),
+ ],
+ || {
+ let config = RadrootsSdkConfig::local();
+
+ assert_eq!(
+ config
+ .resolved_radrootsd_endpoint()
+ .expect("host port endpoint"),
+ "http://127.0.0.1:17070"
+ );
+ },
+ );
+}
+
+#[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 empty_coordinate_values_fail_loudly() {
+ let mut config = RadrootsSdkConfig::production();
+ config.relay = RelayConfig {
+ urls: vec![" ".to_owned()],
+ };
+ config.radrootsd.endpoint = Some(" ".to_owned());
+
+ assert_eq!(
+ config.resolved_relay_urls().expect_err("empty relay"),
+ SdkConfigError::EmptyRelayUrl
+ );
+ assert_eq!(
+ config
+ .resolved_radrootsd_endpoint()
+ .expect_err("empty radrootsd endpoint"),
+ SdkConfigError::EmptyRadrootsdEndpoint
+ );
+}
+
+#[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 invalid_relay_authorities_fail_loudly() {
+ let invalid_relays = [
+ "wss://",
+ "wss:///relay",
+ "ws://:8080",
+ "wss://relay.example:",
+ "wss://relay example",
+ "wss://user@relay.example",
+ "wss://relay.example:abc",
+ "wss://2001:db8::1",
+ ];
+
+ for relay_url in invalid_relays {
+ let mut config = RadrootsSdkConfig::production();
+ config.relay.urls = vec![relay_url.to_owned()];
+
+ assert_eq!(
+ config
+ .resolved_relay_urls()
+ .expect_err("relay authority error"),
+ SdkConfigError::InvalidRelayUrl(relay_url.to_owned())
+ );
+ }
+}
+
+#[test]
+fn invalid_bracketed_relay_authorities_fail_loudly() {
+ let invalid_relays = [
+ "wss://[2001:db8::1",
+ "wss://[]:443",
+ "wss://[2001:db8::1]suffix",
+ "wss://[2001:db8::1]:abc",
+ ];
+
+ for relay_url in invalid_relays {
+ let mut config = RadrootsSdkConfig::production();
+ config.relay.urls = vec![relay_url.to_owned()];
+
+ assert_eq!(
+ config
+ .resolved_relay_urls()
+ .expect_err("bracketed relay authority error"),
+ SdkConfigError::InvalidRelayUrl(relay_url.to_owned())
+ );
+ }
+}
+
+#[test]
+fn valid_relay_authorities_still_resolve() {
+ let mut config = RadrootsSdkConfig::production();
+ config.relay.urls = vec![
+ " wss://relay.example/nostr ".to_owned(),
+ "ws://127.0.0.1:8080".to_owned(),
+ "wss://[2001:db8::1]/relay".to_owned(),
+ "wss://[2001:db8::1]:443/relay".to_owned(),
+ ];
+
+ assert_eq!(
+ config.resolved_relay_urls().expect("valid relays"),
+ vec![
+ "wss://relay.example/nostr".to_owned(),
+ "ws://127.0.0.1:8080".to_owned(),
+ "wss://[2001:db8::1]/relay".to_owned(),
+ "wss://[2001:db8::1]:443/relay".to_owned()
+ ]
+ );
+}
+
+#[test]
+fn signer_modes_format_as_config_tokens() {
+ assert_eq!(SignerConfig::DraftOnly.to_string(), "draft_only");
+ assert_eq!(SignerConfig::LocalIdentity.to_string(), "local_identity");
+ assert_eq!(SignerConfig::Nip46.to_string(), "nip46");
+}
+
+#[test]
+fn config_errors_format_operator_facing_messages() {
+ let formatted = [
+ SdkConfigError::MissingCustomRelayUrls.to_string(),
+ SdkConfigError::MissingCustomRadrootsdEndpoint.to_string(),
+ SdkConfigError::EmptyRelayUrl.to_string(),
+ SdkConfigError::InvalidRelayUrl("http://relay.example".into()).to_string(),
+ SdkConfigError::EmptyRadrootsdEndpoint.to_string(),
+ SdkConfigError::InvalidRadrootsdEndpoint("ws://rpc.example".into()).to_string(),
+ ];
+
+ assert_eq!(
+ formatted,
+ [
+ "custom sdk environment requires explicit relay urls",
+ "custom sdk environment requires an explicit radrootsd endpoint",
+ "relay url must not be empty",
+ "relay url must use ws or wss, got `http://relay.example`",
+ "radrootsd endpoint must not be empty",
+ "radrootsd endpoint must use http or https, got `ws://rpc.example`",
+ ]
+ );
+}
+
+#[test]
+fn radrootsd_auth_debug_formats_none_and_redacts_bearer_tokens() {
+ assert_eq!(format!("{:?}", RadrootsdAuth::None), "None");
+
+ let bearer = RadrootsdAuth::BearerToken("sdk-secret-token".to_owned());
+ let debug = format!("{bearer:?}");
+
+ assert!(!debug.contains("sdk-secret-token"));
+ assert_eq!(debug, "BearerToken(\"<redacted>\")");
+}
+
+#[test]
+fn sdk_config_debug_redacts_bearer_tokens() {
+ let mut config = RadrootsSdkConfig::production();
+ config.radrootsd.auth = RadrootsdAuth::BearerToken("sdk-secret-token".to_owned());
+
+ let debug = format!("{config:?}");
+
+ assert!(!debug.contains("sdk-secret-token"));
+ assert!(debug.contains("BearerToken(\"<redacted>\")"));
+}
diff --git a/crates/sdk/tests/facade.rs b/crates/sdk/tests/facade.rs
@@ -0,0 +1,269 @@
+use radroots_core::{
+ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
+ RadrootsCoreQuantityPrice, RadrootsCoreUnit,
+};
+use radroots_events::farm::{RadrootsFarm, RadrootsFarmRef};
+use radroots_events::ids::RadrootsPublicKey;
+use radroots_events::kinds::{KIND_FARM, KIND_LISTING, KIND_ORDER_REQUEST, KIND_PROFILE};
+use radroots_events::listing::{
+ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
+ RadrootsListingDeliveryMethod, RadrootsListingLocation, RadrootsListingProduct,
+ RadrootsListingStatus,
+};
+use radroots_events::order::{
+ RadrootsOrderEconomicItem, RadrootsOrderEconomics, RadrootsOrderItem,
+ RadrootsOrderPricingBasis, RadrootsOrderRequest,
+};
+use radroots_events::profile::{RadrootsProfile, RadrootsProfileType};
+use radroots_sdk::{RadrootsNostrEvent, RadrootsNostrEventPtr, farm, listing, order, profile};
+
+fn sample_profile() -> RadrootsProfile {
+ RadrootsProfile {
+ name: "North Farm".into(),
+ display_name: Some("North Farm".into()),
+ nip05: None,
+ about: Some("Organic coffee".into()),
+ website: Some("https://example.com".into()),
+ picture: None,
+ banner: None,
+ lud06: None,
+ lud16: None,
+ bot: None,
+ }
+}
+
+fn sample_farm() -> RadrootsFarm {
+ RadrootsFarm {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(),
+ name: "North Farm".into(),
+ about: Some("Organic coffee".into()),
+ website: None,
+ picture: None,
+ banner: None,
+ location: None,
+ tags: Some(vec!["coffee".into()]),
+ }
+}
+
+fn sample_listing() -> RadrootsListing {
+ RadrootsListing {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAg".parse().expect("listing d tag"),
+ published_at: None,
+ farm: RadrootsFarmRef {
+ pubkey: "seller".into(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(),
+ },
+ product: RadrootsListingProduct {
+ key: "coffee".into(),
+ title: "Coffee".into(),
+ category: "coffee".into(),
+ summary: Some("Single origin coffee".into()),
+ process: None,
+ lot: None,
+ location: None,
+ profile: None,
+ year: None,
+ },
+ primary_bin_id: "bin-1".parse().expect("primary bin id"),
+ bins: vec![RadrootsListingBin {
+ bin_id: "bin-1".parse().expect("bin id"),
+ quantity: RadrootsCoreQuantity::new(
+ RadrootsCoreDecimal::from(1000u32),
+ RadrootsCoreUnit::MassG,
+ ),
+ price_per_canonical_unit: RadrootsCoreQuantityPrice {
+ amount: RadrootsCoreMoney::new(
+ RadrootsCoreDecimal::from(20u32),
+ RadrootsCoreCurrency::USD,
+ ),
+ quantity: RadrootsCoreQuantity::new(
+ RadrootsCoreDecimal::from(1u32),
+ RadrootsCoreUnit::MassG,
+ ),
+ },
+ display_amount: None,
+ display_unit: None,
+ display_label: None,
+ display_price: None,
+ display_price_unit: None,
+ }],
+ resource_area: None,
+ plot: None,
+ discounts: None,
+ inventory_available: Some(RadrootsCoreDecimal::from(5u32)),
+ availability: Some(RadrootsListingAvailability::Status {
+ status: RadrootsListingStatus::Active,
+ }),
+ delivery_method: Some(RadrootsListingDeliveryMethod::Pickup),
+ location: Some(RadrootsListingLocation {
+ primary: "North Farm".into(),
+ city: None,
+ region: None,
+ country: None,
+ lat: None,
+ lng: None,
+ geohash: None,
+ }),
+ images: None,
+ }
+}
+
+fn listing_event(listing_value: &RadrootsListing) -> RadrootsNostrEvent {
+ let parts = listing::build_draft(listing_value).expect("listing draft");
+ RadrootsNostrEvent {
+ id: "event-1".into(),
+ author: "seller".into(),
+ created_at: 1,
+ kind: parts.as_wire_parts().kind,
+ tags: parts.as_wire_parts().tags.clone(),
+ content: parts.as_wire_parts().content.clone(),
+ sig: String::new(),
+ }
+}
+
+fn listing_event_ptr() -> RadrootsNostrEventPtr {
+ RadrootsNostrEventPtr {
+ id: core::iter::repeat_n('a', 64).collect(),
+ relays: Some("wss://listing.relay.example".into()),
+ }
+}
+
+fn public_key(character: char) -> RadrootsPublicKey {
+ core::iter::repeat_n(character, 64)
+ .collect::<String>()
+ .parse()
+ .expect("public key")
+}
+
+fn sample_order_request() -> RadrootsOrderRequest {
+ let seller_pubkey = public_key('a');
+
+ RadrootsOrderRequest {
+ order_id: "order-1".parse().expect("order id"),
+ listing_addr: format!("{KIND_LISTING}:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg")
+ .parse()
+ .expect("listing address"),
+ buyer_pubkey: public_key('b'),
+ seller_pubkey,
+ items: vec![RadrootsOrderItem {
+ bin_id: "bin-1".parse().expect("bin id"),
+ bin_count: 2,
+ }],
+ economics: RadrootsOrderEconomics {
+ quote_id: "quote-1".parse().expect("quote id"),
+ quote_version: 1,
+ pricing_basis: RadrootsOrderPricingBasis::ListingEvent,
+ currency: RadrootsCoreCurrency::USD,
+ items: vec![RadrootsOrderEconomicItem {
+ bin_id: "bin-1".parse().expect("bin id"),
+ bin_count: 2,
+ quantity_amount: RadrootsCoreDecimal::from(1u32),
+ quantity_unit: RadrootsCoreUnit::Each,
+ unit_price_amount: RadrootsCoreDecimal::from(5u32),
+ unit_price_currency: RadrootsCoreCurrency::USD,
+ line_subtotal: RadrootsCoreMoney::new(
+ RadrootsCoreDecimal::from(10u32),
+ RadrootsCoreCurrency::USD,
+ ),
+ }],
+ discounts: Vec::new(),
+ adjustments: Vec::new(),
+ subtotal: RadrootsCoreMoney::new(
+ RadrootsCoreDecimal::from(10u32),
+ RadrootsCoreCurrency::USD,
+ ),
+ discount_total: RadrootsCoreMoney::new(
+ RadrootsCoreDecimal::from(0u32),
+ RadrootsCoreCurrency::USD,
+ ),
+ adjustment_total: RadrootsCoreMoney::new(
+ RadrootsCoreDecimal::from(0u32),
+ RadrootsCoreCurrency::USD,
+ ),
+ total: RadrootsCoreMoney::new(
+ RadrootsCoreDecimal::from(10u32),
+ RadrootsCoreCurrency::USD,
+ ),
+ },
+ }
+}
+
+#[test]
+fn profile_build_draft_wraps_profile_encoder() {
+ let parts =
+ profile::build_draft(&sample_profile(), Some(RadrootsProfileType::Farm)).expect("profile");
+
+ assert_eq!(parts.kind, KIND_PROFILE);
+ assert!(parts.tags.iter().any(|tag| {
+ tag.first().map(|value| value.as_str()) == Some("t")
+ && tag.get(1).map(|value| value.as_str()) == Some("radroots:type:farm")
+ }));
+}
+
+#[test]
+fn farm_build_draft_wraps_farm_encoder() {
+ let parts = farm::build_draft(&sample_farm()).expect("farm");
+
+ assert_eq!(parts.kind, KIND_FARM);
+ assert!(
+ parts
+ .tags
+ .iter()
+ .any(|tag| tag.first().map(|value| value.as_str()) == Some("d"))
+ );
+}
+
+#[test]
+fn listing_facade_wraps_build_parse_and_validate() {
+ let listing_value = sample_listing();
+ let tags = listing::build_tags(&listing_value).expect("listing tags");
+ assert!(!tags.is_empty());
+
+ let event = listing_event(&listing_value);
+ let parsed = listing::parse_event(&event).expect("parsed listing");
+ assert_eq!(parsed.d_tag, listing_value.d_tag);
+
+ let validated = order::validate_listing_event(&event).expect("validated listing");
+ assert_eq!(validated.listing_id, listing_value.d_tag);
+ assert_eq!(event.kind, KIND_LISTING);
+}
+
+#[test]
+fn listing_parse_rejects_non_listing_kind() {
+ let listing_value = sample_listing();
+ let mut event = listing_event(&listing_value);
+ event.kind = KIND_PROFILE;
+
+ assert_eq!(
+ listing::parse_event(&event).expect_err("listing kind error"),
+ listing::RadrootsListingParseError::InvalidKind(KIND_PROFILE)
+ );
+}
+
+#[test]
+fn order_facade_wraps_build_parse_and_address_ops() {
+ let listing_value = sample_listing();
+ let seller_pubkey = "a".repeat(64);
+ let listing_addr = format!("{KIND_LISTING}:{seller_pubkey}:{}", listing_value.d_tag);
+ let payload = sample_order_request();
+ let parts =
+ order::build_order_request_draft(&listing_event_ptr(), &payload).expect("order draft");
+
+ assert_eq!(parts.as_wire_parts().kind, KIND_ORDER_REQUEST);
+
+ let parsed_addr = order::parse_listing_address(&listing_addr).expect("listing address");
+ assert_eq!(parsed_addr.listing_id, listing_value.d_tag);
+
+ let event = RadrootsNostrEvent {
+ id: core::iter::repeat_n('b', 64).collect(),
+ author: payload.buyer_pubkey.to_string(),
+ created_at: 2,
+ kind: parts.as_wire_parts().kind,
+ tags: parts.as_wire_parts().tags.clone(),
+ content: parts.as_wire_parts().content.clone(),
+ sig: String::new(),
+ };
+ let envelope = order::parse_order_request(&event).expect("order envelope");
+ assert_eq!(envelope.payload.order_id, payload.order_id);
+ assert_eq!(envelope.payload.listing_addr, listing_addr);
+}
diff --git a/crates/sdk/tests/radrootsd.rs b/crates/sdk/tests/radrootsd.rs
@@ -0,0 +1,1952 @@
+#![cfg(feature = "radrootsd-client")]
+
+use radroots_core::{
+ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
+ RadrootsCoreQuantityPrice, RadrootsCoreUnit,
+};
+use radroots_events::farm::{RadrootsFarm, RadrootsFarmLocation, RadrootsFarmRef};
+use radroots_events::ids::RadrootsPublicKey;
+use radroots_events::kinds::{
+ KIND_FARM, KIND_LISTING, KIND_LISTING_DRAFT, KIND_ORDER_REQUEST, KIND_PROFILE,
+};
+use radroots_sdk::adapters::radrootsd::{
+ SdkRadrootsdBridgeJob, SdkRadrootsdBridgePublishResponse, SdkRadrootsdListingPublishRequest,
+ SdkRadrootsdSignerAuthority, SdkRadrootsdSignerSessionConnectRequest,
+ SdkRadrootsdSignerSessionMode,
+};
+use radroots_sdk::listing::{
+ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
+ RadrootsListingDeliveryMethod, RadrootsListingLocation, RadrootsListingParseError,
+ RadrootsListingProduct, RadrootsListingStatus,
+};
+use radroots_sdk::order::{
+ RadrootsOrderEconomicItem, RadrootsOrderEconomicLine, RadrootsOrderEconomics,
+ RadrootsOrderItem, RadrootsOrderPricingBasis, RadrootsOrderRequest,
+};
+use radroots_sdk::{
+ RadrootsNostrEvent, RadrootsNostrEventPtr, RadrootsProfile, RadrootsProfileType,
+ RadrootsSdkClient, RadrootsSdkConfig, RadrootsdAuth, RadrootsdConfig, SdkConfigError,
+ SdkEnvironment, SdkPublishError, SdkRadrootsdBridgeDeliveryPolicy, SdkRadrootsdBridgeError,
+ SdkRadrootsdBridgeJobStatus, SdkRadrootsdFarmPublishOptions, SdkRadrootsdListingPublishOptions,
+ SdkRadrootsdOrderRequestPublishOptions, SdkRadrootsdProfilePublishOptions,
+ SdkRadrootsdPublishReceipt, SdkRadrootsdSessionError, SdkRadrootsdSignerSessionHandle,
+ SdkRadrootsdSignerSessionRole, SdkRadrootsdSignerSessionView, SdkTransportMode,
+ SdkTransportReceipt, SignerConfig,
+};
+use serde_json::{Value, json};
+use std::collections::VecDeque;
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::TcpListener;
+use tokio::sync::{mpsc, oneshot};
+
+type TestResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
+
+struct JsonRpcServer {
+ endpoint: String,
+ shutdown_tx: Option<oneshot::Sender<()>>,
+}
+
+impl JsonRpcServer {
+ async fn spawn(
+ expected_auth: Option<&str>,
+ response_body: Value,
+ ) -> TestResult<(Self, oneshot::Receiver<Value>)> {
+ let listener = TcpListener::bind("127.0.0.1:0").await?;
+ let addr = listener.local_addr()?;
+ let endpoint = format!("http://{addr}/jsonrpc");
+ let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
+ let (request_tx, request_rx) = oneshot::channel();
+ let expected_auth = expected_auth.map(str::to_owned);
+ let response_text = response_body.to_string();
+
+ tokio::spawn(async move {
+ loop {
+ tokio::select! {
+ _ = &mut shutdown_rx => break,
+ accept = listener.accept() => {
+ let Ok((mut stream, _)) = accept else {
+ break;
+ };
+ let mut buffer = Vec::new();
+ let mut chunk = [0_u8; 4096];
+ let header_end = loop {
+ let Ok(read) = stream.read(&mut chunk).await else {
+ return;
+ };
+ if read == 0 {
+ return;
+ }
+ buffer.extend_from_slice(&chunk[..read]);
+ if let Some(index) = find_headers_end(&buffer) {
+ break index;
+ }
+ };
+
+ let headers = String::from_utf8_lossy(&buffer[..header_end]).into_owned();
+ let content_length = parse_content_length(headers.as_str()).unwrap_or(0);
+ let body_start = header_end + 4;
+ while buffer.len().saturating_sub(body_start) < content_length {
+ let Ok(read) = stream.read(&mut chunk).await else {
+ return;
+ };
+ if read == 0 {
+ break;
+ }
+ buffer.extend_from_slice(&chunk[..read]);
+ }
+
+ if let Some(expected_auth) = expected_auth.as_deref() {
+ let actual_auth = parse_authorization(headers.as_str());
+ if actual_auth.as_deref() != Some(expected_auth) {
+ let _ = write_http_response(
+ &mut stream,
+ 401,
+ json!({
+ "jsonrpc": "2.0",
+ "id": "sdk-test",
+ "error": {
+ "code": -32001,
+ "message": format!(
+ "unexpected authorization header: {:?}",
+ actual_auth
+ ),
+ }
+ })
+ .to_string()
+ .as_str(),
+ )
+ .await;
+ return;
+ }
+ }
+
+ let body = &buffer[body_start..body_start + content_length];
+ let Ok(request_json) = serde_json::from_slice::<Value>(body) else {
+ return;
+ };
+ let _ = request_tx.send(request_json);
+ let _ = write_http_response(&mut stream, 200, response_text.as_str()).await;
+ break;
+ }
+ }
+ }
+ });
+
+ Ok((
+ Self {
+ endpoint,
+ shutdown_tx: Some(shutdown_tx),
+ },
+ request_rx,
+ ))
+ }
+
+ fn endpoint(&self) -> &str {
+ self.endpoint.as_str()
+ }
+}
+
+struct JsonRpcSequenceServer {
+ endpoint: String,
+ shutdown_tx: Option<oneshot::Sender<()>>,
+}
+
+impl JsonRpcSequenceServer {
+ async fn spawn(
+ expected_auth: Option<&str>,
+ response_bodies: Vec<Value>,
+ ) -> TestResult<(Self, mpsc::UnboundedReceiver<Value>)> {
+ let listener = TcpListener::bind("127.0.0.1:0").await?;
+ let addr = listener.local_addr()?;
+ let endpoint = format!("http://{addr}/jsonrpc");
+ let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
+ let (request_tx, request_rx) = mpsc::unbounded_channel();
+ let expected_auth = expected_auth.map(str::to_owned);
+ let mut response_texts = response_bodies
+ .into_iter()
+ .map(|value| value.to_string())
+ .collect::<VecDeque<_>>();
+
+ tokio::spawn(async move {
+ loop {
+ if response_texts.is_empty() {
+ break;
+ }
+
+ tokio::select! {
+ _ = &mut shutdown_rx => break,
+ accept = listener.accept() => {
+ let Ok((mut stream, _)) = accept else {
+ break;
+ };
+ let mut buffer = Vec::new();
+ let mut chunk = [0_u8; 4096];
+ let header_end = loop {
+ let Ok(read) = stream.read(&mut chunk).await else {
+ return;
+ };
+ if read == 0 {
+ return;
+ }
+ buffer.extend_from_slice(&chunk[..read]);
+ if let Some(index) = find_headers_end(&buffer) {
+ break index;
+ }
+ };
+
+ let headers = String::from_utf8_lossy(&buffer[..header_end]).into_owned();
+ let content_length = parse_content_length(headers.as_str()).unwrap_or(0);
+ let body_start = header_end + 4;
+ while buffer.len().saturating_sub(body_start) < content_length {
+ let Ok(read) = stream.read(&mut chunk).await else {
+ return;
+ };
+ if read == 0 {
+ break;
+ }
+ buffer.extend_from_slice(&chunk[..read]);
+ }
+
+ if let Some(expected_auth) = expected_auth.as_deref() {
+ let actual_auth = parse_authorization(headers.as_str());
+ if actual_auth.as_deref() != Some(expected_auth) {
+ let _ = write_http_response(
+ &mut stream,
+ 401,
+ json!({
+ "jsonrpc": "2.0",
+ "id": "sdk-test",
+ "error": {
+ "code": -32001,
+ "message": format!(
+ "unexpected authorization header: {:?}",
+ actual_auth
+ ),
+ }
+ })
+ .to_string()
+ .as_str(),
+ )
+ .await;
+ return;
+ }
+ }
+
+ let body = &buffer[body_start..body_start + content_length];
+ let Ok(request_json) = serde_json::from_slice::<Value>(body) else {
+ return;
+ };
+ let _ = request_tx.send(request_json);
+ let Some(response_text) = response_texts.pop_front() else {
+ return;
+ };
+ let _ = write_http_response(&mut stream, 200, response_text.as_str()).await;
+ }
+ }
+ }
+ });
+
+ Ok((
+ Self {
+ endpoint,
+ shutdown_tx: Some(shutdown_tx),
+ },
+ request_rx,
+ ))
+ }
+
+ fn endpoint(&self) -> &str {
+ self.endpoint.as_str()
+ }
+}
+
+impl Drop for JsonRpcSequenceServer {
+ fn drop(&mut self) {
+ if let Some(shutdown_tx) = self.shutdown_tx.take() {
+ let _ = shutdown_tx.send(());
+ }
+ }
+}
+
+impl Drop for JsonRpcServer {
+ fn drop(&mut self) {
+ if let Some(shutdown_tx) = self.shutdown_tx.take() {
+ let _ = shutdown_tx.send(());
+ }
+ }
+}
+
+fn find_headers_end(buffer: &[u8]) -> Option<usize> {
+ buffer.windows(4).position(|window| window == b"\r\n\r\n")
+}
+
+fn parse_content_length(headers: &str) -> Option<usize> {
+ headers.lines().find_map(|line| {
+ let (name, value) = line.split_once(':')?;
+ if !name.eq_ignore_ascii_case("content-length") {
+ return None;
+ }
+ value.trim().parse().ok()
+ })
+}
+
+fn parse_authorization(headers: &str) -> Option<String> {
+ headers.lines().find_map(|line| {
+ let (name, value) = line.split_once(':')?;
+ if !name.eq_ignore_ascii_case("authorization") {
+ return None;
+ }
+ Some(value.trim().to_owned())
+ })
+}
+
+async fn write_http_response(
+ stream: &mut tokio::net::TcpStream,
+ status: u16,
+ body: &str,
+) -> Result<(), std::io::Error> {
+ let status_text = match status {
+ 200 => "OK",
+ 401 => "Unauthorized",
+ _ => "Internal Server Error",
+ };
+ let response = format!(
+ "HTTP/1.1 {status} {status_text}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
+ body.len(),
+ body
+ );
+ stream.write_all(response.as_bytes()).await
+}
+
+fn sample_listing() -> RadrootsListing {
+ RadrootsListing {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAg".parse().expect("listing d tag"),
+ published_at: None,
+ farm: RadrootsFarmRef {
+ pubkey: "seller".into(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(),
+ },
+ product: RadrootsListingProduct {
+ key: "coffee".into(),
+ title: "Coffee".into(),
+ category: "coffee".into(),
+ summary: Some("Single origin coffee".into()),
+ process: None,
+ lot: None,
+ location: None,
+ profile: None,
+ year: None,
+ },
+ primary_bin_id: "bin-1".parse().expect("primary bin id"),
+ bins: vec![RadrootsListingBin {
+ bin_id: "bin-1".parse().expect("bin id"),
+ quantity: RadrootsCoreQuantity::new(
+ RadrootsCoreDecimal::from(1000u32),
+ RadrootsCoreUnit::MassG,
+ ),
+ price_per_canonical_unit: RadrootsCoreQuantityPrice {
+ amount: RadrootsCoreMoney::new(
+ RadrootsCoreDecimal::from(20u32),
+ RadrootsCoreCurrency::USD,
+ ),
+ quantity: RadrootsCoreQuantity::new(
+ RadrootsCoreDecimal::from(1u32),
+ RadrootsCoreUnit::MassG,
+ ),
+ },
+ display_amount: None,
+ display_unit: None,
+ display_label: None,
+ display_price: None,
+ display_price_unit: None,
+ }],
+ resource_area: None,
+ plot: None,
+ discounts: None,
+ inventory_available: Some(RadrootsCoreDecimal::from(5u32)),
+ availability: Some(RadrootsListingAvailability::Status {
+ status: RadrootsListingStatus::Active,
+ }),
+ delivery_method: Some(RadrootsListingDeliveryMethod::Pickup),
+ location: Some(RadrootsListingLocation {
+ primary: "North Farm".into(),
+ city: None,
+ region: None,
+ country: None,
+ lat: None,
+ lng: None,
+ geohash: None,
+ }),
+ images: None,
+ }
+}
+
+fn sample_profile() -> RadrootsProfile {
+ RadrootsProfile {
+ name: "North Farm".into(),
+ display_name: Some("North Farm".into()),
+ nip05: None,
+ about: Some("Coffee farm".into()),
+ website: Some("https://example.invalid/north-farm".into()),
+ picture: None,
+ banner: None,
+ lud06: None,
+ lud16: None,
+ bot: None,
+ }
+}
+
+fn sample_farm() -> RadrootsFarm {
+ RadrootsFarm {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(),
+ name: "North Farm".into(),
+ about: Some("Coffee farm".into()),
+ website: Some("https://example.invalid/north-farm".into()),
+ picture: None,
+ banner: None,
+ location: Some(RadrootsFarmLocation {
+ primary: Some("North Farm".into()),
+ city: Some("San Francisco".into()),
+ region: Some("CA".into()),
+ country: Some("US".into()),
+ gcs: None,
+ }),
+ tags: Some(vec!["coffee".into()]),
+ }
+}
+
+fn sample_order_request_economics() -> RadrootsOrderEconomics {
+ RadrootsOrderEconomics {
+ quote_id: "quote-1".parse().expect("quote id"),
+ quote_version: 1,
+ pricing_basis: RadrootsOrderPricingBasis::ListingEvent,
+ currency: RadrootsCoreCurrency::USD,
+ items: vec![RadrootsOrderEconomicItem {
+ bin_id: "bin-1".parse().expect("bin id"),
+ bin_count: 2,
+ quantity_amount: RadrootsCoreDecimal::from(1u32),
+ quantity_unit: RadrootsCoreUnit::MassG,
+ unit_price_amount: RadrootsCoreDecimal::from(20u32),
+ unit_price_currency: RadrootsCoreCurrency::USD,
+ line_subtotal: RadrootsCoreMoney::new(
+ RadrootsCoreDecimal::from(40u32),
+ RadrootsCoreCurrency::USD,
+ ),
+ }],
+ discounts: Vec::<RadrootsOrderEconomicLine>::new(),
+ adjustments: Vec::<RadrootsOrderEconomicLine>::new(),
+ subtotal: RadrootsCoreMoney::new(
+ RadrootsCoreDecimal::from(40u32),
+ RadrootsCoreCurrency::USD,
+ ),
+ discount_total: RadrootsCoreMoney::new(
+ RadrootsCoreDecimal::from(0u32),
+ RadrootsCoreCurrency::USD,
+ ),
+ adjustment_total: RadrootsCoreMoney::new(
+ RadrootsCoreDecimal::from(0u32),
+ RadrootsCoreCurrency::USD,
+ ),
+ total: RadrootsCoreMoney::new(RadrootsCoreDecimal::from(40u32), RadrootsCoreCurrency::USD),
+ }
+}
+
+fn sample_order_request() -> RadrootsOrderRequest {
+ let seller_pubkey: RadrootsPublicKey = "a".repeat(64).parse().expect("seller public key");
+
+ RadrootsOrderRequest {
+ order_id: "order-1".parse().expect("order id"),
+ listing_addr: format!("{KIND_LISTING}:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg")
+ .parse()
+ .expect("listing address"),
+ buyer_pubkey: "b".repeat(64).parse().expect("buyer public key"),
+ seller_pubkey,
+ items: vec![RadrootsOrderItem {
+ bin_id: "bin-1".parse().expect("bin id"),
+ bin_count: 2,
+ }],
+ economics: sample_order_request_economics(),
+ }
+}
+
+fn listing_event_ptr_with_relays(relays: Option<&str>) -> RadrootsNostrEventPtr {
+ RadrootsNostrEventPtr {
+ id: "a".repeat(64),
+ relays: relays.map(str::to_owned),
+ }
+}
+
+fn sdk_event(
+ author: &str,
+ created_at: u32,
+ draft: radroots_sdk::listing::RadrootsListingDraft,
+) -> RadrootsNostrEvent {
+ let parts = draft.into_wire_parts();
+ RadrootsNostrEvent {
+ id: "event-1".to_owned(),
+ author: author.to_owned(),
+ created_at,
+ kind: parts.kind,
+ tags: parts.tags,
+ content: parts.content,
+ sig: "f".repeat(128),
+ }
+}
+
+fn radrootsd_test_client(endpoint: &str) -> Result<RadrootsSdkClient, SdkConfigError> {
+ let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Production);
+ config.transport = SdkTransportMode::Radrootsd;
+ config.signer = SignerConfig::Nip46;
+ config.radrootsd = RadrootsdConfig {
+ endpoint: Some(endpoint.to_owned()),
+ auth: RadrootsdAuth::BearerToken("sdk-secret".to_owned()),
+ };
+ RadrootsSdkClient::from_config(config)
+}
+
+fn sample_session_view_json(session_id: &str) -> Value {
+ json!({
+ "session_id": session_id,
+ "role": "outbound_remote_signer",
+ "client_pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "signer_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "user_pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ "relays": ["wss://radroots.org"],
+ "permissions": ["sign_event:30402"],
+ "name": "Radroots Signer",
+ "url": "https://radroots.org/signers/demo",
+ "image": "https://radroots.org/signers/demo.png",
+ "auth_required": false,
+ "authorized": true,
+ "auth_url": null,
+ "expires_in_secs": 120,
+ "signer_authority": {
+ "provider_runtime_id": "runtime-1",
+ "account_identity_id": "identity-1",
+ "provider_signer_session_id": "provider-session-123"
+ }
+ })
+}
+
+fn sample_bridge_status_json() -> Value {
+ json!({
+ "enabled": true,
+ "ready": true,
+ "auth_mode": "bearer_token",
+ "signer_mode": "selectable_per_request",
+ "default_signer_mode": "embedded_service_identity",
+ "supported_signer_modes": ["embedded_service_identity", "nip46_session"],
+ "available_nip46_signer_sessions": 2,
+ "relay_count": 1,
+ "delivery_policy": "quorum",
+ "delivery_quorum": 1,
+ "publish_max_attempts": 3,
+ "publish_initial_backoff_millis": 250,
+ "publish_max_backoff_millis": 4000,
+ "job_status_retention": 64,
+ "retained_jobs": 4,
+ "retained_idempotency_keys": 2,
+ "accepted_jobs": 1,
+ "published_jobs": 2,
+ "failed_jobs": 1,
+ "recovered_failed_jobs": 0,
+ "methods": ["bridge.status", "bridge.job.status", "bridge.job.list", "bridge.listing.publish"]
+ })
+}
+
+fn sample_bridge_job_json(job_id: &str) -> Value {
+ sample_bridge_job_json_for(job_id, "bridge.listing.publish", 30402)
+}
+
+fn sample_bridge_job_json_for(job_id: &str, command: &str, event_kind: u32) -> Value {
+ json!({
+ "job_id": job_id,
+ "command": command,
+ "idempotency_key": "idem-bridge-1",
+ "status": "published",
+ "terminal": true,
+ "recovered_after_restart": false,
+ "requested_at_unix": 1720000000u64,
+ "completed_at_unix": 1720000001u64,
+ "signer_mode": "nip46_session",
+ "signer_session_id": "session-123",
+ "event_kind": event_kind,
+ "event_id": "event-bridge-1",
+ "event_addr": "30402:seller:listing-bridge-1",
+ "delivery_policy": "quorum",
+ "delivery_quorum": 1,
+ "relay_count": 2,
+ "acknowledged_relay_count": 1,
+ "required_acknowledged_relay_count": 1,
+ "attempt_count": 1,
+ "attempt_summaries": ["attempt 1: 1/2 relays acknowledged"],
+ "relay_results": [
+ {
+ "relay_url": "wss://radroots.org",
+ "acknowledged": true,
+ "detail": null
+ },
+ {
+ "relay_url": "wss://backup.radroots.org",
+ "acknowledged": false,
+ "detail": "timeout"
+ }
+ ],
+ "relay_outcome_summary": "quorum satisfied with 1/2 relay acknowledgements"
+ })
+}
+
+async fn connected_bunker_session_handle(
+ session_id: &str,
+) -> TestResult<SdkRadrootsdSignerSessionHandle> {
+ let (server, _) = JsonRpcServer::spawn(
+ Some("Bearer sdk-secret"),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-nip46-connect",
+ "result": {
+ "session_id": session_id,
+ "mode": "Bunker",
+ "remote_signer_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "client_pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relays": ["wss://radroots.org"]
+ }
+ }),
+ )
+ .await?;
+ let client = radrootsd_test_client(server.endpoint())?;
+ client
+ .radrootsd()
+ .signer_sessions()
+ .connect_bunker(
+ "bunker://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?relay=wss%3A%2F%2Fradroots.org&secret=shared-secret",
+ )
+ .await
+ .map_err(Into::into)
+}
+
+#[test]
+fn radrootsd_debug_redacts_signer_session_values() {
+ let signer_authority = SdkRadrootsdSignerAuthority {
+ provider_runtime_id: "runtime-1".to_owned(),
+ account_identity_id: "identity-1".to_owned(),
+ provider_signer_session_id: Some("provider-session-123".to_owned()),
+ };
+ let request = SdkRadrootsdListingPublishRequest {
+ listing: sample_listing(),
+ kind: Some(30402),
+ signer_session_id: "session-123".to_owned(),
+ signer_authority: Some(signer_authority),
+ idempotency_key: Some("idem-1".to_owned()),
+ };
+ let job = SdkRadrootsdBridgeJob {
+ job_id: "job-1".to_owned(),
+ command: "bridge.listing.publish".to_owned(),
+ status: "published".to_owned(),
+ terminal: true,
+ recovered_after_restart: false,
+ signer_mode: "nip46_session:session-123".to_owned(),
+ signer_session_id: Some("session-123".to_owned()),
+ event_kind: 30402,
+ event_id: Some("event-1".to_owned()),
+ event_addr: Some("30402:seller:listing-1".to_owned()),
+ relay_count: 1,
+ acknowledged_relay_count: 1,
+ };
+ let response = SdkRadrootsdBridgePublishResponse {
+ deduplicated: false,
+ job,
+ };
+ let receipt = SdkRadrootsdPublishReceipt {
+ accepted: true,
+ deduplicated: false,
+ job_id: Some("job-1".to_owned()),
+ status: Some("published".to_owned()),
+ signer_mode: Some("nip46_session:session-123".to_owned()),
+ signer_session_id: Some("session-123".to_owned()),
+ event_addr: Some("30402:seller:listing-1".to_owned()),
+ relay_count: Some(1),
+ acknowledged_relay_count: Some(1),
+ };
+
+ let request_debug = format!("{request:?}");
+ let response_debug = format!("{response:?}");
+ let receipt_debug = format!("{receipt:?}");
+
+ assert!(!request_debug.contains("session-123"));
+ assert!(!request_debug.contains("provider-session-123"));
+ assert!(request_debug.contains("<redacted>"));
+
+ assert!(!response_debug.contains("session-123"));
+ assert!(response_debug.contains("<redacted>"));
+
+ assert!(!receipt_debug.contains("session-123"));
+ assert!(receipt_debug.contains("<redacted>"));
+
+ let connect_request = SdkRadrootsdSignerSessionConnectRequest::nostrconnect(
+ "nostrconnect://bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb?relay=wss%3A%2F%2Fradroots.org&secret=shared-secret",
+ "client-secret-key",
+ )
+ .with_signer_authority(SdkRadrootsdSignerAuthority {
+ provider_runtime_id: "runtime-1".to_owned(),
+ account_identity_id: "identity-1".to_owned(),
+ provider_signer_session_id: Some("provider-session-123".to_owned()),
+ });
+ let connect_request_debug = format!("{connect_request:?}");
+ assert!(!connect_request_debug.contains("client-secret-key"));
+ assert!(!connect_request_debug.contains("provider-session-123"));
+ assert!(connect_request_debug.contains("<redacted>"));
+}
+
+#[tokio::test]
+async fn radrootsd_signer_session_connect_returns_opaque_handle() -> TestResult<()> {
+ let (server, request_rx) = JsonRpcServer::spawn(
+ Some("Bearer sdk-secret"),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-nip46-connect",
+ "result": {
+ "session_id": "session-123",
+ "mode": "Nostrconnect",
+ "remote_signer_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "client_pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relays": ["wss://radroots.org"]
+ }
+ }),
+ )
+ .await?;
+
+ 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()),
+ };
+ let client = RadrootsSdkClient::from_config(config)?;
+ let request = SdkRadrootsdSignerSessionConnectRequest::nostrconnect(
+ "nostrconnect://bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb?relay=wss%3A%2F%2Fradroots.org&secret=shared-secret",
+ "client-secret-key",
+ );
+
+ let handle: SdkRadrootsdSignerSessionHandle = client
+ .radrootsd()
+ .signer_sessions()
+ .connect(&request)
+ .await?;
+ let request_json = request_rx.await?;
+
+ assert_eq!(request_json["method"], "nip46.connect");
+ assert_eq!(
+ request_json["params"]["url"],
+ "nostrconnect://bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb?relay=wss%3A%2F%2Fradroots.org&secret=shared-secret"
+ );
+ assert_eq!(
+ request_json["params"]["client_secret_key"],
+ "client-secret-key"
+ );
+ assert_eq!(handle.mode(), SdkRadrootsdSignerSessionMode::Nostrconnect);
+ assert_eq!(
+ handle.remote_signer_pubkey(),
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ );
+ assert_eq!(
+ handle.client_pubkey(),
+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+ );
+ assert_eq!(handle.relays(), &["wss://radroots.org".to_owned()]);
+
+ let handle_debug = format!("{handle:?}");
+ assert!(!handle_debug.contains("session-123"));
+ assert!(handle_debug.contains("<redacted>"));
+
+ let options = SdkRadrootsdListingPublishOptions::from_signer_session(&handle);
+ let options_debug = format!("{options:?}");
+ assert!(!options_debug.contains("session-123"));
+ assert!(options_debug.contains("<redacted>"));
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn radrootsd_signer_session_connect_bunker_supports_bunker_mode() -> TestResult<()> {
+ let (server, request_rx) = JsonRpcServer::spawn(
+ Some("Bearer sdk-secret"),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-nip46-connect",
+ "result": {
+ "session_id": "session-bunker",
+ "mode": "Bunker",
+ "remote_signer_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "client_pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relays": ["wss://radroots.org"]
+ }
+ }),
+ )
+ .await?;
+
+ let client = radrootsd_test_client(server.endpoint())?;
+ let handle: SdkRadrootsdSignerSessionHandle = client
+ .radrootsd()
+ .signer_sessions()
+ .connect_bunker(
+ "bunker://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?relay=wss%3A%2F%2Fradroots.org&secret=shared-secret",
+ )
+ .await?;
+ let request_json = request_rx.await?;
+
+ assert_eq!(request_json["method"], "nip46.connect");
+ assert_eq!(
+ request_json["params"]["url"],
+ "bunker://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?relay=wss%3A%2F%2Fradroots.org&secret=shared-secret"
+ );
+ assert!(request_json["params"]["client_secret_key"].is_null());
+ assert_eq!(handle.mode(), SdkRadrootsdSignerSessionMode::Bunker);
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn radrootsd_signer_session_status_returns_typed_view() -> TestResult<()> {
+ let (connect_server, _) = JsonRpcServer::spawn(
+ Some("Bearer sdk-secret"),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-nip46-connect",
+ "result": {
+ "session_id": "session-123",
+ "mode": "Nostrconnect",
+ "remote_signer_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "client_pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relays": ["wss://radroots.org"]
+ }
+ }),
+ )
+ .await?;
+ let connect_client = radrootsd_test_client(connect_server.endpoint())?;
+ let handle: SdkRadrootsdSignerSessionHandle = connect_client
+ .radrootsd()
+ .signer_sessions()
+ .connect_nostrconnect(
+ "nostrconnect://bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb?relay=wss%3A%2F%2Fradroots.org&secret=shared-secret",
+ "client-secret-key",
+ )
+ .await?;
+
+ let (status_server, request_rx) = JsonRpcServer::spawn(
+ Some("Bearer sdk-secret"),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-nip46-session-status",
+ "result": sample_session_view_json("session-123")
+ }),
+ )
+ .await?;
+ let status_client = radrootsd_test_client(status_server.endpoint())?;
+ let session: SdkRadrootsdSignerSessionView = status_client
+ .radrootsd()
+ .signer_sessions()
+ .status(handle.session())
+ .await?;
+ let request_json = request_rx.await?;
+
+ assert_eq!(request_json["method"], "nip46.session.status");
+ assert_eq!(request_json["params"]["session_id"], "session-123");
+ assert_eq!(session.session(), handle.session());
+ assert_eq!(
+ session.role,
+ SdkRadrootsdSignerSessionRole::OutboundRemoteSigner
+ );
+ assert_eq!(
+ session.client_pubkey,
+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+ );
+ assert_eq!(
+ session.signer_pubkey,
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ );
+ assert_eq!(
+ session.user_pubkey.as_deref(),
+ Some("cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc")
+ );
+ assert_eq!(session.relays, vec!["wss://radroots.org".to_owned()]);
+ assert_eq!(session.permissions, vec!["sign_event:30402".to_owned()]);
+ assert_eq!(session.name.as_deref(), Some("Radroots Signer"));
+ assert_eq!(
+ session.url.as_deref(),
+ Some("https://radroots.org/signers/demo")
+ );
+ assert_eq!(
+ session.image.as_deref(),
+ Some("https://radroots.org/signers/demo.png")
+ );
+ assert!(session.authorized);
+ assert!(!session.auth_required);
+ assert_eq!(session.expires_in_secs, Some(120));
+ assert_eq!(
+ session
+ .signer_authority
+ .as_ref()
+ .map(|value| value.provider_runtime_id.as_str()),
+ Some("runtime-1")
+ );
+
+ let debug = format!("{session:?}");
+ assert!(!debug.contains("session-123"));
+ assert!(debug.contains("<redacted>"));
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn radrootsd_signer_session_list_returns_typed_views() -> TestResult<()> {
+ let (server, request_rx) = JsonRpcServer::spawn(
+ Some("Bearer sdk-secret"),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-nip46-session-list",
+ "result": [
+ sample_session_view_json("session-123"),
+ sample_session_view_json("session-456")
+ ]
+ }),
+ )
+ .await?;
+ let client = radrootsd_test_client(server.endpoint())?;
+ let sessions: Vec<SdkRadrootsdSignerSessionView> =
+ client.radrootsd().signer_sessions().list().await?;
+ let request_json = request_rx.await?;
+
+ assert_eq!(request_json["method"], "nip46.session.list");
+ assert_eq!(sessions.len(), 2);
+ assert_eq!(
+ sessions[0].role,
+ SdkRadrootsdSignerSessionRole::OutboundRemoteSigner
+ );
+ let debug = format!("{:?}", sessions[0].session());
+ assert!(!debug.contains("session-123"));
+ assert!(debug.contains("<redacted>"));
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn radrootsd_signer_session_authorize_returns_typed_result() -> TestResult<()> {
+ let (connect_server, _) = JsonRpcServer::spawn(
+ Some("Bearer sdk-secret"),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-nip46-connect",
+ "result": {
+ "session_id": "session-123",
+ "mode": "Bunker",
+ "remote_signer_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "client_pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relays": ["wss://radroots.org"]
+ }
+ }),
+ )
+ .await?;
+ let connect_client = radrootsd_test_client(connect_server.endpoint())?;
+ let handle: SdkRadrootsdSignerSessionHandle = connect_client
+ .radrootsd()
+ .signer_sessions()
+ .connect_bunker(
+ "bunker://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?relay=wss%3A%2F%2Fradroots.org&secret=shared-secret",
+ )
+ .await?;
+
+ let (server, request_rx) = JsonRpcServer::spawn(
+ Some("Bearer sdk-secret"),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-nip46-session-authorize",
+ "result": {
+ "authorized": true,
+ "replayed": true
+ }
+ }),
+ )
+ .await?;
+ let client = radrootsd_test_client(server.endpoint())?;
+ let result = client
+ .radrootsd()
+ .signer_sessions()
+ .authorize(handle.session())
+ .await?;
+ let request_json = request_rx.await?;
+
+ assert_eq!(request_json["method"], "nip46.session.authorize");
+ assert_eq!(request_json["params"]["session_id"], "session-123");
+ assert!(result.authorized);
+ assert!(result.replayed);
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn radrootsd_signer_session_get_public_key_returns_typed_result() -> TestResult<()> {
+ let (connect_server, _) = JsonRpcServer::spawn(
+ Some("Bearer sdk-secret"),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-nip46-connect",
+ "result": {
+ "session_id": "session-123",
+ "mode": "Bunker",
+ "remote_signer_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "client_pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relays": ["wss://radroots.org"]
+ }
+ }),
+ )
+ .await?;
+ let connect_client = radrootsd_test_client(connect_server.endpoint())?;
+ let handle: SdkRadrootsdSignerSessionHandle = connect_client
+ .radrootsd()
+ .signer_sessions()
+ .connect_bunker(
+ "bunker://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?relay=wss%3A%2F%2Fradroots.org&secret=shared-secret",
+ )
+ .await?;
+
+ let (server, request_rx) = JsonRpcServer::spawn(
+ Some("Bearer sdk-secret"),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-nip46-get-public-key",
+ "result": {
+ "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
+ }
+ }),
+ )
+ .await?;
+ let client = radrootsd_test_client(server.endpoint())?;
+ let result = client
+ .radrootsd()
+ .signer_sessions()
+ .get_public_key(handle.session())
+ .await?;
+ let request_json = request_rx.await?;
+
+ assert_eq!(request_json["method"], "nip46.get_public_key");
+ assert_eq!(request_json["params"]["session_id"], "session-123");
+ assert_eq!(
+ result.pubkey,
+ "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
+ );
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn radrootsd_signer_session_require_auth_returns_typed_result() -> TestResult<()> {
+ let (connect_server, _) = JsonRpcServer::spawn(
+ Some("Bearer sdk-secret"),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-nip46-connect",
+ "result": {
+ "session_id": "session-123",
+ "mode": "Bunker",
+ "remote_signer_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "client_pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relays": ["wss://radroots.org"]
+ }
+ }),
+ )
+ .await?;
+ let connect_client = radrootsd_test_client(connect_server.endpoint())?;
+ let handle: SdkRadrootsdSignerSessionHandle = connect_client
+ .radrootsd()
+ .signer_sessions()
+ .connect_bunker(
+ "bunker://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?relay=wss%3A%2F%2Fradroots.org&secret=shared-secret",
+ )
+ .await?;
+
+ let (server, request_rx) = JsonRpcServer::spawn(
+ Some("Bearer sdk-secret"),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-nip46-session-require-auth",
+ "result": {
+ "required": true
+ }
+ }),
+ )
+ .await?;
+ let client = radrootsd_test_client(server.endpoint())?;
+ let result = client
+ .radrootsd()
+ .signer_sessions()
+ .require_auth(handle.session(), "https://radroots.org/auth")
+ .await?;
+ let request_json = request_rx.await?;
+
+ assert_eq!(request_json["method"], "nip46.session.require_auth");
+ assert_eq!(request_json["params"]["session_id"], "session-123");
+ assert_eq!(
+ request_json["params"]["auth_url"],
+ "https://radroots.org/auth"
+ );
+ assert!(result.required);
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn radrootsd_signer_session_close_returns_typed_result() -> TestResult<()> {
+ let (connect_server, _) = JsonRpcServer::spawn(
+ Some("Bearer sdk-secret"),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-nip46-connect",
+ "result": {
+ "session_id": "session-123",
+ "mode": "Bunker",
+ "remote_signer_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "client_pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relays": ["wss://radroots.org"]
+ }
+ }),
+ )
+ .await?;
+ let connect_client = radrootsd_test_client(connect_server.endpoint())?;
+ let handle: SdkRadrootsdSignerSessionHandle = connect_client
+ .radrootsd()
+ .signer_sessions()
+ .connect_bunker(
+ "bunker://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?relay=wss%3A%2F%2Fradroots.org&secret=shared-secret",
+ )
+ .await?;
+
+ let (server, request_rx) = JsonRpcServer::spawn(
+ Some("Bearer sdk-secret"),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-nip46-session-close",
+ "result": {
+ "closed": true
+ }
+ }),
+ )
+ .await?;
+ let client = radrootsd_test_client(server.endpoint())?;
+ let result = client
+ .radrootsd()
+ .signer_sessions()
+ .close(handle.session())
+ .await?;
+ let request_json = request_rx.await?;
+
+ assert_eq!(request_json["method"], "nip46.session.close");
+ assert_eq!(request_json["params"]["session_id"], "session-123");
+ assert!(result.closed);
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn radrootsd_signer_session_connect_rejects_relay_transport_mode() -> TestResult<()> {
+ let client = RadrootsSdkClient::from_config(RadrootsSdkConfig::production())?;
+ let request = SdkRadrootsdSignerSessionConnectRequest::bunker(
+ "bunker://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?relay=wss%3A%2F%2Fradroots.org&secret=shared-secret",
+ );
+
+ let error = client
+ .radrootsd()
+ .signer_sessions()
+ .connect(&request)
+ .await
+ .expect_err("unsupported transport");
+
+ assert!(matches!(
+ error,
+ SdkRadrootsdSessionError::UnsupportedTransport {
+ transport: SdkTransportMode::RelayDirect,
+ operation: "radrootsd.signer_sessions.connect",
+ }
+ ));
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn radrootsd_listing_publish_accepts_sdk_built_draft() -> TestResult<()> {
+ let (server, request_rx) = JsonRpcServer::spawn(
+ Some("Bearer sdk-secret"),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-listing-publish",
+ "result": {
+ "deduplicated": false,
+ "job": {
+ "job_id": "job-1",
+ "command": "bridge.listing.publish",
+ "status": "published",
+ "terminal": true,
+ "recovered_after_restart": false,
+ "signer_mode": "nip46_session:session-123",
+ "signer_session_id": "session-123",
+ "event_kind": 30402,
+ "event_id": "event-1",
+ "event_addr": "30402:seller:listing-1",
+ "relay_count": 1,
+ "acknowledged_relay_count": 1
+ }
+ }
+ }),
+ )
+ .await?;
+
+ let handle = connected_bunker_session_handle("session-123").await?;
+ let client = radrootsd_test_client(server.endpoint())?;
+ let draft = client.listing().build_draft(&sample_listing())?;
+ let options = SdkRadrootsdListingPublishOptions::from_signer_session(&handle)
+ .with_idempotency_key("idem-1");
+
+ let receipt = client
+ .listing()
+ .publish_draft_via_radrootsd_with_options(draft, &options)
+ .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"]["kind"], 30402);
+ assert_eq!(
+ request_json["params"]["listing"]["d_tag"],
+ "AAAAAAAAAAAAAAAAAAAAAg"
+ );
+
+ assert_eq!(receipt.transport, SdkTransportMode::Radrootsd);
+ assert_eq!(receipt.event_kind, Some(30402));
+ assert_eq!(receipt.event_id, Some("event-1".to_owned()));
+ match receipt.transport_receipt {
+ SdkTransportReceipt::Radrootsd(rpc_receipt) => {
+ assert!(rpc_receipt.accepted);
+ 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.relay_count, Some(1));
+ assert_eq!(rpc_receipt.acknowledged_relay_count, Some(1));
+ }
+ SdkTransportReceipt::RelayDirect(_) => panic!("unexpected relay receipt"),
+ }
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn radrootsd_listing_publish_accepts_typed_listing_value() -> TestResult<()> {
+ let (server, request_rx) = JsonRpcServer::spawn(
+ Some("Bearer sdk-secret"),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-listing-publish",
+ "result": {
+ "deduplicated": false,
+ "job": {
+ "job_id": "job-2",
+ "command": "bridge.listing.publish",
+ "status": "published",
+ "terminal": true,
+ "recovered_after_restart": false,
+ "signer_mode": "nip46_session:session-456",
+ "signer_session_id": "session-456",
+ "event_kind": 30402,
+ "event_id": "event-2",
+ "event_addr": "30402:seller:listing-2",
+ "relay_count": 1,
+ "acknowledged_relay_count": 1
+ }
+ }
+ }),
+ )
+ .await?;
+
+ let handle = connected_bunker_session_handle("session-456").await?;
+ let client = radrootsd_test_client(server.endpoint())?;
+
+ let receipt = client
+ .listing()
+ .publish_listing_via_radrootsd(&sample_listing(), &handle)
+ .await?;
+ let request_json = request_rx.await?;
+
+ assert_eq!(request_json["method"], "bridge.listing.publish");
+ assert_eq!(request_json["params"]["signer_session_id"], "session-456");
+ assert!(request_json["params"]["idempotency_key"].is_null());
+ assert_eq!(request_json["params"]["kind"], 30402);
+ assert_eq!(
+ request_json["params"]["listing"]["d_tag"],
+ "AAAAAAAAAAAAAAAAAAAAAg"
+ );
+
+ assert_eq!(receipt.transport, SdkTransportMode::Radrootsd);
+ assert_eq!(receipt.event_kind, Some(30402));
+ assert_eq!(receipt.event_id, Some("event-2".to_owned()));
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn radrootsd_profile_publish_accepts_typed_profile_value() -> TestResult<()> {
+ let (server, request_rx) = JsonRpcServer::spawn(
+ Some("Bearer sdk-secret"),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-profile-publish",
+ "result": {
+ "deduplicated": false,
+ "job": {
+ "job_id": "job-profile-1",
+ "command": "bridge.profile.publish",
+ "status": "published",
+ "terminal": true,
+ "recovered_after_restart": false,
+ "signer_mode": "nip46_session:session-profile-1",
+ "signer_session_id": "session-profile-1",
+ "event_kind": 0,
+ "event_id": "event-profile-1",
+ "relay_count": 1,
+ "acknowledged_relay_count": 1
+ }
+ }
+ }),
+ )
+ .await?;
+
+ let handle = connected_bunker_session_handle("session-profile-1").await?;
+ let client = radrootsd_test_client(server.endpoint())?;
+ let options = SdkRadrootsdProfilePublishOptions::from_signer_session(&handle)
+ .with_idempotency_key("profile-idem-1")
+ .with_signer_authority(SdkRadrootsdSignerAuthority {
+ provider_runtime_id: "runtime-profile".to_owned(),
+ account_identity_id: "identity-profile".to_owned(),
+ provider_signer_session_id: Some("provider-session-profile".to_owned()),
+ });
+
+ let receipt = client
+ .profile()
+ .publish_profile_via_radrootsd_with_options(
+ &sample_profile(),
+ Some(RadrootsProfileType::Farm),
+ &options,
+ )
+ .await?;
+ let request_json = request_rx.await?;
+
+ assert_eq!(request_json["method"], "bridge.profile.publish");
+ assert_eq!(
+ request_json["params"]["signer_session_id"],
+ "session-profile-1"
+ );
+ assert_eq!(request_json["params"]["profile_type"], "farm");
+ assert_eq!(request_json["params"]["profile"]["name"], "North Farm");
+ assert_eq!(request_json["params"]["idempotency_key"], "profile-idem-1");
+ assert_eq!(
+ request_json["params"]["signer_authority"]["provider_runtime_id"],
+ "runtime-profile"
+ );
+ assert_eq!(receipt.event_kind, Some(KIND_PROFILE));
+ assert_eq!(receipt.event_id, Some("event-profile-1".to_owned()));
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn radrootsd_farm_publish_accepts_typed_farm_value() -> TestResult<()> {
+ let (server, request_rx) = JsonRpcServer::spawn(
+ Some("Bearer sdk-secret"),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-farm-publish",
+ "result": {
+ "deduplicated": false,
+ "job": {
+ "job_id": "job-farm-1",
+ "command": "bridge.farm.publish",
+ "status": "published",
+ "terminal": true,
+ "recovered_after_restart": false,
+ "signer_mode": "nip46_session:session-farm-1",
+ "signer_session_id": "session-farm-1",
+ "event_kind": 30340,
+ "event_id": "event-farm-1",
+ "event_addr": "30340:seller:AAAAAAAAAAAAAAAAAAAAAA",
+ "relay_count": 1,
+ "acknowledged_relay_count": 1
+ }
+ }
+ }),
+ )
+ .await?;
+
+ let handle = connected_bunker_session_handle("session-farm-1").await?;
+ let client = radrootsd_test_client(server.endpoint())?;
+ let options = SdkRadrootsdFarmPublishOptions::from_signer_session(&handle)
+ .with_idempotency_key("farm-idem-1");
+
+ let receipt = client
+ .farm()
+ .publish_farm_via_radrootsd_with_options(&sample_farm(), &options)
+ .await?;
+ let request_json = request_rx.await?;
+
+ assert_eq!(request_json["method"], "bridge.farm.publish");
+ assert_eq!(
+ request_json["params"]["signer_session_id"],
+ "session-farm-1"
+ );
+ assert_eq!(request_json["params"]["kind"], KIND_FARM);
+ assert_eq!(
+ request_json["params"]["farm"]["d_tag"],
+ "AAAAAAAAAAAAAAAAAAAAAA"
+ );
+ assert_eq!(request_json["params"]["idempotency_key"], "farm-idem-1");
+ assert_eq!(receipt.event_kind, Some(KIND_FARM));
+ assert_eq!(receipt.event_id, Some("event-farm-1".to_owned()));
+ match receipt.transport_receipt {
+ SdkTransportReceipt::Radrootsd(receipt) => {
+ assert_eq!(
+ receipt.event_addr,
+ Some("30340:seller:AAAAAAAAAAAAAAAAAAAAAA".to_owned())
+ );
+ }
+ SdkTransportReceipt::RelayDirect(_) => panic!("unexpected relay receipt"),
+ }
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn radrootsd_listing_publish_with_options_forwards_typed_continuity_metadata()
+-> TestResult<()> {
+ let (server, request_rx) = JsonRpcServer::spawn(
+ Some("Bearer sdk-secret"),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-listing-publish",
+ "result": {
+ "deduplicated": false,
+ "job": {
+ "job_id": "job-3",
+ "command": "bridge.listing.publish",
+ "status": "published",
+ "terminal": true,
+ "recovered_after_restart": false,
+ "signer_mode": "nip46_session:session-789",
+ "signer_session_id": "session-789",
+ "event_kind": 30402,
+ "event_id": "event-3",
+ "event_addr": "30402:seller:listing-3",
+ "relay_count": 1,
+ "acknowledged_relay_count": 1
+ }
+ }
+ }),
+ )
+ .await?;
+
+ let handle = connected_bunker_session_handle("session-789").await?;
+ let client = radrootsd_test_client(server.endpoint())?;
+ let options = SdkRadrootsdListingPublishOptions::from_signer_session(&handle)
+ .with_idempotency_key("idem-3")
+ .with_signer_authority(SdkRadrootsdSignerAuthority {
+ provider_runtime_id: "runtime-1".to_owned(),
+ account_identity_id: "identity-1".to_owned(),
+ provider_signer_session_id: Some("provider-session-123".to_owned()),
+ });
+
+ let receipt = client
+ .listing()
+ .publish_listing_via_radrootsd_with_options(&sample_listing(), &options)
+ .await?;
+ let request_json = request_rx.await?;
+
+ assert_eq!(request_json["method"], "bridge.listing.publish");
+ assert_eq!(request_json["params"]["signer_session_id"], "session-789");
+ assert_eq!(request_json["params"]["idempotency_key"], "idem-3");
+ assert_eq!(
+ request_json["params"]["signer_authority"]["provider_runtime_id"],
+ "runtime-1"
+ );
+ assert_eq!(
+ request_json["params"]["signer_authority"]["account_identity_id"],
+ "identity-1"
+ );
+ assert_eq!(
+ request_json["params"]["signer_authority"]["provider_signer_session_id"],
+ "provider-session-123"
+ );
+ assert_eq!(receipt.event_id, Some("event-3".to_owned()));
+
+ Ok(())
+}
+
+#[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 handle = connected_bunker_session_handle("session-123").await?;
+
+ let error = client
+ .listing()
+ .publish_listing_via_radrootsd(&sample_listing(), &handle)
+ .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 handle = connected_bunker_session_handle("session-123").await?;
+
+ let error = client
+ .listing()
+ .publish_listing_via_radrootsd(&sample_listing(), &handle)
+ .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 handle = connected_bunker_session_handle("session-123").await?;
+
+ let error = client
+ .listing()
+ .publish_listing_via_radrootsd(&sample_listing(), &handle)
+ .await
+ .expect_err("unsupported transport");
+
+ assert!(matches!(
+ error,
+ SdkPublishError::UnsupportedTransport {
+ transport: SdkTransportMode::RelayDirect,
+ operation: "listing.publish_via_radrootsd",
+ }
+ ));
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn radrootsd_order_request_publish_accepts_session_handle() -> TestResult<()> {
+ let (server, request_rx) = JsonRpcServer::spawn(
+ Some("Bearer sdk-secret"),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-order-request-publish",
+ "result": {
+ "deduplicated": false,
+ "job": {
+ "job_id": "job-order-1",
+ "command": "bridge.order.request",
+ "status": "published",
+ "terminal": true,
+ "recovered_after_restart": false,
+ "signer_mode": "nip46_session:session-order-1",
+ "signer_session_id": "session-order-1",
+ "event_kind": KIND_ORDER_REQUEST,
+ "event_id": "event-order-1",
+ "event_addr": format!("{KIND_LISTING}:{}:AAAAAAAAAAAAAAAAAAAAAg", "a".repeat(64)),
+ "relay_count": 1,
+ "acknowledged_relay_count": 1
+ }
+ }
+ }),
+ )
+ .await?;
+
+ let handle = connected_bunker_session_handle("session-order-1").await?;
+ let client = radrootsd_test_client(server.endpoint())?;
+ let options = SdkRadrootsdOrderRequestPublishOptions::from_signer_session(&handle)
+ .with_idempotency_key("idem-order-1")
+ .with_signer_authority(SdkRadrootsdSignerAuthority {
+ provider_runtime_id: "runtime-1".to_owned(),
+ account_identity_id: "identity-1".to_owned(),
+ provider_signer_session_id: Some("provider-session-order-1".to_owned()),
+ });
+
+ let receipt = client
+ .order()
+ .publish_order_request_via_radrootsd_with_options(
+ &sample_order_request(),
+ &listing_event_ptr_with_relays(Some("wss://radroots.org")),
+ &options,
+ )
+ .await?;
+ let request_json = request_rx.await?;
+
+ assert_eq!(request_json["method"], "bridge.order.request");
+ assert_eq!(
+ request_json["params"]["signer_session_id"],
+ "session-order-1"
+ );
+ assert_eq!(request_json["params"]["idempotency_key"], "idem-order-1");
+ assert_eq!(request_json["params"]["order"]["order_id"], "order-1");
+ assert_eq!(
+ request_json["params"]["listing_event"]["id"],
+ "a".repeat(64)
+ );
+ assert_eq!(
+ request_json["params"]["listing_event"]["relays"],
+ "wss://radroots.org"
+ );
+ assert_eq!(
+ request_json["params"]["signer_authority"]["provider_runtime_id"],
+ "runtime-1"
+ );
+ assert_eq!(
+ request_json["params"]["signer_authority"]["provider_signer_session_id"],
+ "provider-session-order-1"
+ );
+ assert_eq!(receipt.event_kind, Some(KIND_ORDER_REQUEST));
+ assert_eq!(receipt.event_id, Some("event-order-1".to_owned()));
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn radrootsd_sdk_workflow_chains_session_listing_order_and_bridge_job() -> TestResult<()> {
+ let (server, mut request_rx) = JsonRpcSequenceServer::spawn(
+ Some("Bearer sdk-secret"),
+ vec![
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-nip46-connect",
+ "result": {
+ "session_id": "session-workflow-1",
+ "mode": "Bunker",
+ "remote_signer_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "client_pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relays": ["wss://radroots.org"]
+ }
+ }),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-listing-publish",
+ "result": {
+ "deduplicated": false,
+ "job": {
+ "job_id": "job-workflow-listing",
+ "command": "bridge.listing.publish",
+ "status": "published",
+ "terminal": true,
+ "recovered_after_restart": false,
+ "signer_mode": "nip46_session:session-workflow-1",
+ "signer_session_id": "session-workflow-1",
+ "event_kind": 30402,
+ "event_id": "event-workflow-listing",
+ "event_addr": "30402:seller:listing-workflow-1",
+ "relay_count": 1,
+ "acknowledged_relay_count": 1
+ }
+ }
+ }),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-order-request-publish",
+ "result": {
+ "deduplicated": false,
+ "job": {
+ "job_id": "job-workflow-order",
+ "command": "bridge.order.request",
+ "status": "published",
+ "terminal": true,
+ "recovered_after_restart": false,
+ "signer_mode": "nip46_session:session-workflow-1",
+ "signer_session_id": "session-workflow-1",
+ "event_kind": KIND_ORDER_REQUEST,
+ "event_id": "event-workflow-order",
+ "event_addr": format!("{KIND_LISTING}:{}:AAAAAAAAAAAAAAAAAAAAAg", "a".repeat(64)),
+ "relay_count": 1,
+ "acknowledged_relay_count": 1
+ }
+ }
+ }),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-bridge-job-status",
+ "result": sample_bridge_job_json_for(
+ "job-workflow-order",
+ "bridge.order.request",
+ KIND_ORDER_REQUEST,
+ )
+ }),
+ ],
+ )
+ .await?;
+
+ let client = radrootsd_test_client(server.endpoint())?;
+ let handle = client
+ .radrootsd()
+ .signer_sessions()
+ .connect_bunker(
+ "bunker://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?relay=wss%3A%2F%2Fradroots.org&secret=shared-secret",
+ )
+ .await?;
+ assert_eq!(handle.mode(), SdkRadrootsdSignerSessionMode::Bunker);
+
+ let connect_request = request_rx.recv().await.expect("connect request");
+ assert_eq!(connect_request["method"], "nip46.connect");
+
+ let listing_receipt = client
+ .listing()
+ .publish_listing_via_radrootsd(&sample_listing(), &handle)
+ .await?;
+ let listing_request = request_rx.recv().await.expect("listing publish request");
+ assert_eq!(listing_request["method"], "bridge.listing.publish");
+ assert_eq!(
+ listing_request["params"]["signer_session_id"],
+ "session-workflow-1"
+ );
+
+ let order_receipt = client
+ .order()
+ .publish_order_request_via_radrootsd(
+ &sample_order_request(),
+ &listing_event_ptr_with_relays(Some("wss://radroots.org")),
+ &handle,
+ )
+ .await?;
+ let order_request = request_rx.recv().await.expect("order publish request");
+ assert_eq!(order_request["method"], "bridge.order.request");
+ assert_eq!(
+ order_request["params"]["signer_session_id"],
+ "session-workflow-1"
+ );
+ assert_eq!(order_request["params"]["order"]["order_id"], "order-1");
+ assert_eq!(
+ order_request["params"]["listing_event"]["id"],
+ "a".repeat(64)
+ );
+
+ let order_job = match &order_receipt.transport_receipt {
+ SdkTransportReceipt::Radrootsd(receipt) => receipt.job(),
+ SdkTransportReceipt::RelayDirect(_) => None,
+ }
+ .expect("order publish receipt should expose a bridge job ref");
+
+ let job_view = client.radrootsd().bridge().job(&order_job).await?;
+ let job_request = request_rx.recv().await.expect("bridge job request");
+ assert_eq!(job_request["method"], "bridge.job.status");
+ assert_eq!(job_request["params"]["job_id"], "job-workflow-order");
+
+ assert_eq!(listing_receipt.event_kind, Some(30402));
+ assert_eq!(order_receipt.event_kind, Some(KIND_ORDER_REQUEST));
+ assert_eq!(job_view.job().job_id(), "job-workflow-order");
+ assert_eq!(job_view.command, "bridge.order.request");
+ assert_eq!(job_view.status, SdkRadrootsdBridgeJobStatus::Published);
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn radrootsd_bridge_status_returns_typed_status() -> TestResult<()> {
+ let (server, request_rx) = JsonRpcServer::spawn(
+ Some("Bearer sdk-secret"),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-bridge-status",
+ "result": sample_bridge_status_json()
+ }),
+ )
+ .await?;
+ let client = radrootsd_test_client(server.endpoint())?;
+ let status = client.radrootsd().bridge().status().await?;
+ let request_json = request_rx.await?;
+
+ assert_eq!(request_json["method"], "bridge.status");
+ assert!(status.enabled);
+ assert!(status.ready);
+ assert_eq!(
+ status.delivery_policy,
+ SdkRadrootsdBridgeDeliveryPolicy::Quorum
+ );
+ assert_eq!(status.delivery_quorum, Some(1));
+ assert_eq!(status.available_nip46_signer_sessions, 2);
+ assert!(
+ status
+ .methods
+ .contains(&"bridge.listing.publish".to_owned())
+ );
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn radrootsd_bridge_job_status_accepts_typed_job_ref_from_publish_receipt() -> TestResult<()>
+{
+ let (publish_server, publish_request_rx) = JsonRpcServer::spawn(
+ Some("Bearer sdk-secret"),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-listing-publish",
+ "result": {
+ "deduplicated": false,
+ "job": {
+ "job_id": "job-bridge-1",
+ "command": "bridge.listing.publish",
+ "status": "published",
+ "terminal": true,
+ "recovered_after_restart": false,
+ "signer_mode": "nip46_session:session-123",
+ "signer_session_id": "session-123",
+ "event_kind": 30402,
+ "event_id": "event-bridge-1",
+ "event_addr": "30402:seller:listing-bridge-1",
+ "relay_count": 1,
+ "acknowledged_relay_count": 1
+ }
+ }
+ }),
+ )
+ .await?;
+ let handle = connected_bunker_session_handle("session-123").await?;
+ let publish_client = radrootsd_test_client(publish_server.endpoint())?;
+ let publish_receipt = publish_client
+ .listing()
+ .publish_listing_via_radrootsd(&sample_listing(), &handle)
+ .await?;
+ let publish_request_json = publish_request_rx.await?;
+ assert_eq!(publish_request_json["method"], "bridge.listing.publish");
+
+ let job = match &publish_receipt.transport_receipt {
+ SdkTransportReceipt::Radrootsd(receipt) => receipt.job(),
+ SdkTransportReceipt::RelayDirect(_) => None,
+ }
+ .expect("publish receipt should expose a bridge job ref");
+
+ let (job_server, request_rx) = JsonRpcServer::spawn(
+ Some("Bearer sdk-secret"),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-bridge-job-status",
+ "result": sample_bridge_job_json("job-bridge-1")
+ }),
+ )
+ .await?;
+ let job_client = radrootsd_test_client(job_server.endpoint())?;
+ let job_view = job_client.radrootsd().bridge().job(&job).await?;
+ let request_json = request_rx.await?;
+
+ assert_eq!(request_json["method"], "bridge.job.status");
+ assert_eq!(request_json["params"]["job_id"], "job-bridge-1");
+ assert_eq!(job_view.job().job_id(), "job-bridge-1");
+ assert_eq!(job_view.status, SdkRadrootsdBridgeJobStatus::Published);
+ assert_eq!(
+ job_view.delivery_policy,
+ SdkRadrootsdBridgeDeliveryPolicy::Quorum
+ );
+ assert_eq!(job_view.attempt_count, 1);
+ assert_eq!(job_view.relay_results.len(), 2);
+ assert_eq!(job_view.relay_results[0].relay_url, "wss://radroots.org");
+ assert!(job_view.relay_results[0].acknowledged);
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn radrootsd_bridge_job_list_returns_typed_views() -> TestResult<()> {
+ let (server, request_rx) = JsonRpcServer::spawn(
+ Some("Bearer sdk-secret"),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-bridge-job-list",
+ "result": [
+ sample_bridge_job_json("job-bridge-1"),
+ sample_bridge_job_json("job-bridge-2")
+ ]
+ }),
+ )
+ .await?;
+ let client = radrootsd_test_client(server.endpoint())?;
+ let jobs = client.radrootsd().bridge().jobs().await?;
+ let request_json = request_rx.await?;
+
+ assert_eq!(request_json["method"], "bridge.job.list");
+ assert_eq!(jobs.len(), 2);
+ assert_eq!(jobs[0].job().job_id(), "job-bridge-1");
+ assert_eq!(jobs[1].job().job_id(), "job-bridge-2");
+ assert_eq!(jobs[0].status, SdkRadrootsdBridgeJobStatus::Published);
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn radrootsd_bridge_status_rejects_relay_transport_mode() -> TestResult<()> {
+ let client = RadrootsSdkClient::from_config(RadrootsSdkConfig::production())?;
+ let error = client
+ .radrootsd()
+ .bridge()
+ .status()
+ .await
+ .expect_err("unsupported transport");
+
+ assert!(matches!(
+ error,
+ SdkRadrootsdBridgeError::UnsupportedTransport {
+ transport: SdkTransportMode::RelayDirect,
+ operation: "radrootsd.bridge.status",
+ }
+ ));
+
+ Ok(())
+}
+
+#[test]
+fn radrootsd_listing_request_from_event_rejects_listing_draft_kind() -> TestResult<()> {
+ let draft = radroots_sdk::listing::build_draft(&sample_listing())?;
+ let mut event = sdk_event("seller", 1_720_000_000, draft);
+ event.kind = KIND_LISTING_DRAFT;
+
+ assert!(matches!(
+ SdkRadrootsdListingPublishRequest::from_event(&event, "session-123", None, None),
+ Err(RadrootsListingParseError::InvalidKind(KIND_LISTING_DRAFT))
+ ));
+
+ Ok(())
+}
diff --git a/crates/sdk/tests/relay_direct.rs b/crates/sdk/tests/relay_direct.rs
@@ -0,0 +1,1406 @@
+#![cfg(all(
+ feature = "identity-models",
+ feature = "relay-client",
+ feature = "signing"
+))]
+
+use futures::{SinkExt, StreamExt};
+use nostr::{ClientMessage, JsonUtil, RelayMessage};
+use radroots_core::{
+ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
+ RadrootsCoreQuantityPrice, RadrootsCoreUnit,
+};
+use radroots_events::ids::{RadrootsEventId, RadrootsPublicKey};
+use radroots_sdk::farm::{RadrootsFarm, RadrootsFarmLocation, RadrootsFarmRef};
+use radroots_sdk::identity::RadrootsIdentity;
+use radroots_sdk::listing::{
+ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
+ RadrootsListingDeliveryMethod, RadrootsListingLocation, RadrootsListingProduct,
+ RadrootsListingStatus,
+};
+use radroots_sdk::order::{
+ RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome,
+ RadrootsOrderEconomicItem, RadrootsOrderEconomics, RadrootsOrderFulfillmentState,
+ RadrootsOrderFulfillmentUpdate, RadrootsOrderInventoryCommitment, RadrootsOrderItem,
+ RadrootsOrderPricingBasis, RadrootsOrderReceipt, RadrootsOrderRequest,
+ RadrootsOrderRevisionDecision, RadrootsOrderRevisionOutcome, RadrootsOrderRevisionProposal,
+};
+use radroots_sdk::profile::{RadrootsProfile, RadrootsProfileType};
+use radroots_sdk::{
+ RadrootsNostrEventPtr, RadrootsSdkClient, RadrootsSdkConfig, RelayConfig, SdkEnvironment,
+ SdkPublishError, SdkTransportMode, SdkTransportReceipt, SignerConfig,
+};
+use tokio::net::TcpListener;
+use tokio::sync::oneshot;
+use tokio_tungstenite::tungstenite::Message;
+
+type TestResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
+
+struct AckRelay {
+ url: String,
+ shutdown_tx: Option<oneshot::Sender<()>>,
+}
+
+impl AckRelay {
+ async fn spawn() -> TestResult<Self> {
+ let listener = TcpListener::bind("127.0.0.1:0").await?;
+ let addr = listener.local_addr()?;
+ let url = format!("ws://{addr}");
+ let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
+
+ tokio::spawn(async move {
+ loop {
+ tokio::select! {
+ _ = &mut shutdown_rx => break,
+ accept = listener.accept() => {
+ let Ok((stream, _)) = accept else {
+ break;
+ };
+ tokio::spawn(async move {
+ let Ok(websocket) = tokio_tungstenite::accept_async(stream).await else {
+ return;
+ };
+ let (mut writer, mut reader) = websocket.split();
+ while let Some(message) = reader.next().await {
+ let Ok(message) = message else {
+ break;
+ };
+ let Message::Text(text) = message else {
+ continue;
+ };
+ let Ok(client_message) = ClientMessage::from_json(text.as_str()) else {
+ continue;
+ };
+ if let ClientMessage::Event(event) = client_message {
+ let relay_message =
+ RelayMessage::ok(event.id, true, "").as_json();
+ if writer
+ .send(Message::Text(relay_message.into()))
+ .await
+ .is_err()
+ {
+ break;
+ }
+ }
+ }
+ });
+ }
+ }
+ }
+ });
+
+ Ok(Self {
+ url,
+ shutdown_tx: Some(shutdown_tx),
+ })
+ }
+
+ fn url(&self) -> &str {
+ self.url.as_str()
+ }
+}
+
+impl Drop for AckRelay {
+ fn drop(&mut self) {
+ if let Some(shutdown_tx) = self.shutdown_tx.take() {
+ let _ = shutdown_tx.send(());
+ }
+ }
+}
+
+fn sample_listing() -> RadrootsListing {
+ RadrootsListing {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAg".parse().expect("listing d tag"),
+ published_at: None,
+ farm: RadrootsFarmRef {
+ pubkey: "seller".into(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(),
+ },
+ product: RadrootsListingProduct {
+ key: "coffee".into(),
+ title: "Coffee".into(),
+ category: "coffee".into(),
+ summary: Some("Single origin coffee".into()),
+ process: None,
+ lot: None,
+ location: None,
+ profile: None,
+ year: None,
+ },
+ primary_bin_id: "bin-1".parse().expect("primary bin id"),
+ bins: vec![RadrootsListingBin {
+ bin_id: "bin-1".parse().expect("bin id"),
+ quantity: RadrootsCoreQuantity::new(
+ RadrootsCoreDecimal::from(1000u32),
+ RadrootsCoreUnit::MassG,
+ ),
+ price_per_canonical_unit: RadrootsCoreQuantityPrice {
+ amount: RadrootsCoreMoney::new(
+ RadrootsCoreDecimal::from(20u32),
+ RadrootsCoreCurrency::USD,
+ ),
+ quantity: RadrootsCoreQuantity::new(
+ RadrootsCoreDecimal::from(1u32),
+ RadrootsCoreUnit::MassG,
+ ),
+ },
+ display_amount: None,
+ display_unit: None,
+ display_label: None,
+ display_price: None,
+ display_price_unit: None,
+ }],
+ resource_area: None,
+ plot: None,
+ discounts: None,
+ inventory_available: Some(RadrootsCoreDecimal::from(5u32)),
+ availability: Some(RadrootsListingAvailability::Status {
+ status: RadrootsListingStatus::Active,
+ }),
+ delivery_method: Some(RadrootsListingDeliveryMethod::Pickup),
+ location: Some(RadrootsListingLocation {
+ primary: "North Farm".into(),
+ city: None,
+ region: None,
+ country: None,
+ lat: None,
+ lng: None,
+ geohash: None,
+ }),
+ images: None,
+ }
+}
+
+fn sample_profile() -> RadrootsProfile {
+ RadrootsProfile {
+ name: "north-farm".into(),
+ display_name: Some("North Farm".into()),
+ nip05: None,
+ about: Some("Farm profile".into()),
+ website: None,
+ picture: None,
+ banner: None,
+ lud06: None,
+ lud16: None,
+ bot: None,
+ }
+}
+
+fn sample_farm() -> RadrootsFarm {
+ RadrootsFarm {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(),
+ name: "North Farm".into(),
+ about: Some("Vegetable farm".into()),
+ website: None,
+ picture: None,
+ banner: None,
+ location: Some(RadrootsFarmLocation {
+ primary: Some("North Road".into()),
+ city: None,
+ region: None,
+ country: Some("US".into()),
+ gcs: None,
+ }),
+ tags: Some(vec!["vegetables".into()]),
+ }
+}
+
+fn decimal(raw: &str) -> RadrootsCoreDecimal {
+ raw.parse().expect("decimal")
+}
+
+fn usd(raw: &str) -> RadrootsCoreMoney {
+ RadrootsCoreMoney::new(decimal(raw), RadrootsCoreCurrency::USD)
+}
+
+fn listing_event_ptr() -> RadrootsNostrEventPtr {
+ RadrootsNostrEventPtr {
+ id: event_id_wire('a'),
+ relays: Some("wss://listing.relay.example".into()),
+ }
+}
+
+fn public_key(value: String) -> RadrootsPublicKey {
+ value.parse().expect("public key")
+}
+
+fn event_id(character: char) -> RadrootsEventId {
+ core::iter::repeat_n(character, 64)
+ .collect::<String>()
+ .parse()
+ .expect("event id")
+}
+
+fn event_id_wire(character: char) -> String {
+ event_id(character).into_string()
+}
+
+fn sample_order_request(buyer_pubkey: String, seller_pubkey: String) -> RadrootsOrderRequest {
+ let buyer_pubkey = public_key(buyer_pubkey);
+ let seller_pubkey = public_key(seller_pubkey);
+ RadrootsOrderRequest {
+ order_id: "order-1".parse().expect("order id"),
+ listing_addr: format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg")
+ .parse()
+ .expect("listing address"),
+ buyer_pubkey,
+ seller_pubkey,
+ items: vec![RadrootsOrderItem {
+ bin_id: "bin-1".parse().expect("bin id"),
+ bin_count: 2,
+ }],
+ economics: RadrootsOrderEconomics {
+ quote_id: "quote-1".parse().expect("quote id"),
+ quote_version: 1,
+ pricing_basis: RadrootsOrderPricingBasis::ListingEvent,
+ currency: RadrootsCoreCurrency::USD,
+ items: vec![RadrootsOrderEconomicItem {
+ bin_id: "bin-1".parse().expect("bin id"),
+ bin_count: 2,
+ quantity_amount: decimal("1"),
+ quantity_unit: RadrootsCoreUnit::Each,
+ unit_price_amount: decimal("5"),
+ unit_price_currency: RadrootsCoreCurrency::USD,
+ line_subtotal: usd("10"),
+ }],
+ discounts: Vec::new(),
+ adjustments: Vec::new(),
+ subtotal: usd("10"),
+ discount_total: usd("0"),
+ adjustment_total: usd("0"),
+ total: usd("10"),
+ },
+ }
+}
+
+fn sample_order_decision(buyer_pubkey: String, seller_pubkey: String) -> RadrootsOrderDecision {
+ let buyer_pubkey = public_key(buyer_pubkey);
+ let seller_pubkey = public_key(seller_pubkey);
+ RadrootsOrderDecision {
+ order_id: "order-1".parse().expect("order id"),
+ listing_addr: format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg")
+ .parse()
+ .expect("listing address"),
+ buyer_pubkey,
+ seller_pubkey,
+ decision: RadrootsOrderDecisionOutcome::Accepted {
+ inventory_commitments: vec![RadrootsOrderInventoryCommitment {
+ bin_id: "bin-1".parse().expect("bin id"),
+ bin_count: 2,
+ }],
+ },
+ }
+}
+
+fn sample_order_revision_proposal(
+ buyer_pubkey: String,
+ seller_pubkey: String,
+ root_event_id: String,
+ prev_event_id: String,
+) -> RadrootsOrderRevisionProposal {
+ let buyer_pubkey = public_key(buyer_pubkey);
+ let seller_pubkey = public_key(seller_pubkey);
+ RadrootsOrderRevisionProposal {
+ revision_id: "revision-1".parse().expect("revision id"),
+ order_id: "order-1".parse().expect("order id"),
+ listing_addr: format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg")
+ .parse()
+ .expect("listing address"),
+ buyer_pubkey,
+ seller_pubkey,
+ root_event_id: root_event_id.parse().expect("root event id"),
+ prev_event_id: prev_event_id.parse().expect("previous event id"),
+ items: vec![RadrootsOrderItem {
+ bin_id: "bin-1".parse().expect("bin id"),
+ bin_count: 3,
+ }],
+ economics: RadrootsOrderEconomics {
+ quote_id: "revision-quote-1".parse().expect("revision quote id"),
+ quote_version: 2,
+ pricing_basis: RadrootsOrderPricingBasis::ListingEvent,
+ currency: RadrootsCoreCurrency::USD,
+ items: vec![RadrootsOrderEconomicItem {
+ bin_id: "bin-1".parse().expect("bin id"),
+ bin_count: 3,
+ quantity_amount: decimal("1"),
+ quantity_unit: RadrootsCoreUnit::Each,
+ unit_price_amount: decimal("5"),
+ unit_price_currency: RadrootsCoreCurrency::USD,
+ line_subtotal: usd("15"),
+ }],
+ discounts: Vec::new(),
+ adjustments: Vec::new(),
+ subtotal: usd("15"),
+ discount_total: usd("0"),
+ adjustment_total: usd("0"),
+ total: usd("15"),
+ },
+ reason: "update count".into(),
+ }
+}
+
+fn sample_order_revision_decision(
+ proposal: &RadrootsOrderRevisionProposal,
+ decision: RadrootsOrderRevisionOutcome,
+) -> RadrootsOrderRevisionDecision {
+ RadrootsOrderRevisionDecision {
+ revision_id: proposal.revision_id.clone(),
+ order_id: proposal.order_id.clone(),
+ listing_addr: proposal.listing_addr.clone(),
+ buyer_pubkey: proposal.buyer_pubkey.clone(),
+ seller_pubkey: proposal.seller_pubkey.clone(),
+ root_event_id: proposal.root_event_id.clone(),
+ prev_event_id: event_id('3'),
+ decision,
+ }
+}
+
+fn sample_fulfillment_update(
+ buyer_pubkey: String,
+ seller_pubkey: String,
+) -> RadrootsOrderFulfillmentUpdate {
+ let buyer_pubkey = public_key(buyer_pubkey);
+ let seller_pubkey = public_key(seller_pubkey);
+ RadrootsOrderFulfillmentUpdate {
+ order_id: "order-1".parse().expect("order id"),
+ listing_addr: format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg")
+ .parse()
+ .expect("listing address"),
+ buyer_pubkey,
+ seller_pubkey,
+ status: RadrootsOrderFulfillmentState::ReadyForPickup,
+ }
+}
+
+fn sample_order_cancellation(
+ buyer_pubkey: String,
+ seller_pubkey: String,
+) -> RadrootsOrderCancellation {
+ let buyer_pubkey = public_key(buyer_pubkey);
+ let seller_pubkey = public_key(seller_pubkey);
+ RadrootsOrderCancellation {
+ order_id: "order-1".parse().expect("order id"),
+ listing_addr: format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg")
+ .parse()
+ .expect("listing address"),
+ buyer_pubkey,
+ seller_pubkey,
+ reason: "schedule changed".into(),
+ }
+}
+
+fn sample_buyer_receipt(buyer_pubkey: String, seller_pubkey: String) -> RadrootsOrderReceipt {
+ let buyer_pubkey = public_key(buyer_pubkey);
+ let seller_pubkey = public_key(seller_pubkey);
+ RadrootsOrderReceipt {
+ order_id: "order-1".parse().expect("order id"),
+ listing_addr: format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg")
+ .parse()
+ .expect("listing address"),
+ buyer_pubkey,
+ seller_pubkey,
+ received: true,
+ issue: None,
+ received_at: 1_785_000_000,
+ }
+}
+
+#[tokio::test]
+async fn relay_direct_farm_publish_accepts_sdk_built_draft() -> 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::LocalIdentity;
+ config.relay = RelayConfig {
+ urls: vec![relay.url().to_owned()],
+ };
+ let client = RadrootsSdkClient::from_config(config)?;
+ let draft = client.farm().build_draft(&sample_farm())?;
+
+ let receipt = client
+ .farm()
+ .publish_draft_with_identity(&identity, draft)
+ .await?;
+
+ assert_eq!(receipt.transport, SdkTransportMode::RelayDirect);
+ assert_eq!(receipt.event_kind, Some(30340));
+ assert!(receipt.event_id.is_some());
+ match receipt.transport_receipt {
+ SdkTransportReceipt::RelayDirect(relay_receipt) => {
+ assert_eq!(
+ receipt.event_id.as_deref(),
+ Some(relay_receipt.event_id.as_str())
+ );
+ assert_eq!(relay_receipt.event.kind, 30340);
+ assert_eq!(relay_receipt.event.author, identity.public_key_hex());
+ assert_eq!(
+ relay_receipt.event.tags,
+ vec![
+ vec!["d".to_owned(), "AAAAAAAAAAAAAAAAAAAAAA".to_owned()],
+ vec!["t".to_owned(), "vegetables".to_owned()]
+ ]
+ );
+ assert_eq!(relay_receipt.target_relays, vec![relay.url().to_owned()]);
+ assert_eq!(relay_receipt.connected_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"),
+ }
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn relay_direct_order_request_publish_accepts_sdk_built_draft() -> TestResult<()> {
+ let relay = AckRelay::spawn().await?;
+ let buyer_identity = RadrootsIdentity::generate();
+ let seller_identity = RadrootsIdentity::generate();
+ let listing_event = listing_event_ptr();
+ let payload = sample_order_request(
+ buyer_identity.public_key_hex(),
+ seller_identity.public_key_hex(),
+ );
+ 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()],
+ };
+ let client = RadrootsSdkClient::from_config(config)?;
+ let draft = client
+ .order()
+ .build_order_request_draft(&listing_event, &payload)?;
+ assert_eq!(draft.as_wire_parts().kind, 3422);
+
+ let receipt = client
+ .order()
+ .publish_order_request_draft_with_identity(&buyer_identity, draft)
+ .await?;
+
+ assert_eq!(receipt.transport, SdkTransportMode::RelayDirect);
+ assert_eq!(receipt.event_kind, Some(3422));
+ assert!(receipt.event_id.is_some());
+ match receipt.transport_receipt {
+ SdkTransportReceipt::RelayDirect(relay_receipt) => {
+ assert_eq!(
+ receipt.event_id.as_deref(),
+ Some(relay_receipt.event_id.as_str())
+ );
+ assert_eq!(receipt.event_kind, Some(relay_receipt.event_kind));
+ assert_eq!(relay_receipt.event.kind, 3422);
+ assert_eq!(relay_receipt.event_id, relay_receipt.event.id);
+ assert_eq!(relay_receipt.signature, relay_receipt.event.sig);
+ assert_eq!(relay_receipt.created_at, relay_receipt.event.created_at);
+ assert_eq!(relay_receipt.event.author, buyer_identity.public_key_hex());
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["p".to_owned(), seller_identity.public_key_hex()])
+ );
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["a".to_owned(), payload.listing_addr.to_string()])
+ );
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["d".to_owned(), payload.order_id.to_string()])
+ );
+ assert!(relay_receipt.event.tags.contains(&vec![
+ "listing_event".to_owned(),
+ listing_event.id.clone(),
+ listing_event.relays.clone().expect("listing relay")
+ ]));
+ assert_eq!(relay_receipt.target_relays, vec![relay.url().to_owned()]);
+ assert_eq!(relay_receipt.connected_relays, vec![relay.url().to_owned()]);
+ assert_eq!(
+ relay_receipt.acknowledged_relays,
+ vec![relay.url().to_owned()]
+ );
+ assert!(relay_receipt.failed_relays.is_empty());
+ let envelope = client
+ .order()
+ .parse_order_request(&relay_receipt.event)
+ .expect("order request");
+ assert_eq!(envelope.order_id, payload.order_id);
+ assert_eq!(envelope.listing_addr, payload.listing_addr);
+ assert_eq!(envelope.payload.economics.quote_id, "quote-1");
+ assert_eq!(envelope.payload.economics.total, usd("10"));
+ }
+ SdkTransportReceipt::Radrootsd(_) => panic!("unexpected radrootsd receipt"),
+ }
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn relay_direct_order_decision_publish_accepts_sdk_built_draft() -> TestResult<()> {
+ let relay = AckRelay::spawn().await?;
+ let buyer_identity = RadrootsIdentity::generate();
+ let seller_identity = RadrootsIdentity::generate();
+ let root_event_id = event_id('1');
+ let payload = sample_order_decision(
+ buyer_identity.public_key_hex(),
+ seller_identity.public_key_hex(),
+ );
+ 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()],
+ };
+ let client = RadrootsSdkClient::from_config(config)?;
+ let draft =
+ client
+ .order()
+ .build_order_decision_draft(&root_event_id, &root_event_id, &payload)?;
+ assert_eq!(draft.as_wire_parts().kind, 3423);
+
+ let receipt = client
+ .order()
+ .publish_order_decision_draft_with_identity(&seller_identity, draft)
+ .await?;
+
+ assert_eq!(receipt.transport, SdkTransportMode::RelayDirect);
+ assert_eq!(receipt.event_kind, Some(3423));
+ assert!(receipt.event_id.is_some());
+ match receipt.transport_receipt {
+ SdkTransportReceipt::RelayDirect(relay_receipt) => {
+ assert_eq!(
+ receipt.event_id.as_deref(),
+ Some(relay_receipt.event_id.as_str())
+ );
+ assert_eq!(receipt.event_kind, Some(relay_receipt.event_kind));
+ assert_eq!(relay_receipt.event.kind, 3423);
+ assert_eq!(relay_receipt.event.author, seller_identity.public_key_hex());
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["p".to_owned(), buyer_identity.public_key_hex()])
+ );
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["a".to_owned(), payload.listing_addr.to_string()])
+ );
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["d".to_owned(), payload.order_id.to_string()])
+ );
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["e_root".to_owned(), root_event_id.to_string()])
+ );
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["e_prev".to_owned(), root_event_id.to_string()])
+ );
+ assert_eq!(relay_receipt.target_relays, vec![relay.url().to_owned()]);
+ assert_eq!(relay_receipt.connected_relays, vec![relay.url().to_owned()]);
+ assert_eq!(
+ relay_receipt.acknowledged_relays,
+ vec![relay.url().to_owned()]
+ );
+ assert!(relay_receipt.failed_relays.is_empty());
+ let envelope = client
+ .order()
+ .parse_order_decision(&relay_receipt.event)
+ .expect("order decision");
+ assert_eq!(envelope.order_id, payload.order_id);
+ assert_eq!(envelope.listing_addr, payload.listing_addr);
+ assert_eq!(envelope.payload.decision, payload.decision);
+ }
+ SdkTransportReceipt::Radrootsd(_) => panic!("unexpected radrootsd receipt"),
+ }
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn relay_direct_order_revision_publish_accepts_sdk_built_payloads() -> TestResult<()> {
+ let relay = AckRelay::spawn().await?;
+ let buyer_identity = RadrootsIdentity::generate();
+ let seller_identity = RadrootsIdentity::generate();
+ let buyer_pubkey = buyer_identity.public_key_hex();
+ let seller_pubkey = seller_identity.public_key_hex();
+ let root_event_id = event_id('1');
+ let decision_event_id = event_id('2');
+ let proposal = sample_order_revision_proposal(
+ buyer_pubkey.clone(),
+ seller_pubkey.clone(),
+ root_event_id.to_string(),
+ decision_event_id.to_string(),
+ );
+ let decision =
+ sample_order_revision_decision(&proposal, RadrootsOrderRevisionOutcome::Accepted);
+ 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()],
+ };
+ let client = RadrootsSdkClient::from_config(config)?;
+
+ let proposal_receipt = client
+ .order()
+ .publish_order_revision_proposal_with_identity(
+ &seller_identity,
+ &root_event_id,
+ &decision_event_id,
+ &proposal,
+ )
+ .await?;
+ let decision_receipt = client
+ .order()
+ .publish_order_revision_decision_with_identity(
+ &buyer_identity,
+ &root_event_id,
+ &decision.prev_event_id,
+ &decision,
+ )
+ .await?;
+
+ assert_eq!(proposal_receipt.event_kind, Some(3424));
+ assert_eq!(decision_receipt.event_kind, Some(3425));
+
+ match proposal_receipt.transport_receipt {
+ SdkTransportReceipt::RelayDirect(relay_receipt) => {
+ assert_eq!(relay_receipt.event.kind, 3424);
+ assert_eq!(relay_receipt.event.author, seller_pubkey);
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["p".to_owned(), buyer_pubkey.clone()])
+ );
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["e_root".to_owned(), root_event_id.to_string()])
+ );
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["e_prev".to_owned(), decision_event_id.to_string()])
+ );
+ let envelope = client
+ .order()
+ .parse_order_revision_proposal(&relay_receipt.event)
+ .expect("order revision proposal");
+ assert_eq!(envelope.order_id, proposal.order_id);
+ assert_eq!(envelope.listing_addr, proposal.listing_addr);
+ assert_eq!(envelope.payload.revision_id, "revision-1");
+ assert_eq!(envelope.payload.economics.total, usd("15"));
+ assert_eq!(envelope.payload.reason, "update count");
+ }
+ SdkTransportReceipt::Radrootsd(_) => panic!("unexpected radrootsd receipt"),
+ }
+
+ match decision_receipt.transport_receipt {
+ SdkTransportReceipt::RelayDirect(relay_receipt) => {
+ assert_eq!(relay_receipt.event.kind, 3425);
+ assert_eq!(relay_receipt.event.author, buyer_pubkey);
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["p".to_owned(), seller_pubkey])
+ );
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["e_root".to_owned(), root_event_id.to_string()])
+ );
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["e_prev".to_owned(), event_id_wire('3')])
+ );
+ let envelope = client
+ .order()
+ .parse_order_revision_decision(&relay_receipt.event)
+ .expect("order revision decision");
+ assert_eq!(envelope.order_id, decision.order_id);
+ assert_eq!(envelope.listing_addr, decision.listing_addr);
+ assert_eq!(envelope.payload.revision_id, decision.revision_id);
+ assert_eq!(
+ envelope.payload.decision,
+ RadrootsOrderRevisionOutcome::Accepted
+ );
+ }
+ SdkTransportReceipt::Radrootsd(_) => panic!("unexpected radrootsd receipt"),
+ }
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn relay_direct_order_lifecycle_publish_accepts_sdk_built_payloads() -> TestResult<()> {
+ let relay = AckRelay::spawn().await?;
+ let buyer_identity = RadrootsIdentity::generate();
+ let seller_identity = RadrootsIdentity::generate();
+ let buyer_pubkey = buyer_identity.public_key_hex();
+ let seller_pubkey = seller_identity.public_key_hex();
+ let root_event_id = event_id('1');
+ let decision_event_id = event_id('2');
+ let fulfillment_event_id = event_id('4');
+ let fulfillment = sample_fulfillment_update(buyer_pubkey.clone(), seller_pubkey.clone());
+ let cancellation = sample_order_cancellation(buyer_pubkey.clone(), seller_pubkey.clone());
+ let receipt = sample_buyer_receipt(buyer_pubkey.clone(), seller_pubkey.clone());
+ 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()],
+ };
+ let client = RadrootsSdkClient::from_config(config)?;
+
+ let fulfillment_receipt = client
+ .order()
+ .publish_fulfillment_update_with_identity(
+ &seller_identity,
+ &root_event_id,
+ &decision_event_id,
+ &fulfillment,
+ )
+ .await?;
+ let cancellation_receipt = client
+ .order()
+ .publish_order_cancellation_with_identity(
+ &buyer_identity,
+ &root_event_id,
+ &root_event_id,
+ &cancellation,
+ )
+ .await?;
+ let buyer_receipt = client
+ .order()
+ .publish_buyer_receipt_with_identity(
+ &buyer_identity,
+ &root_event_id,
+ &fulfillment_event_id,
+ &receipt,
+ )
+ .await?;
+
+ assert_eq!(fulfillment_receipt.event_kind, Some(3433));
+ assert_eq!(cancellation_receipt.event_kind, Some(3432));
+ assert_eq!(buyer_receipt.event_kind, Some(3434));
+
+ match fulfillment_receipt.transport_receipt {
+ SdkTransportReceipt::RelayDirect(relay_receipt) => {
+ assert_eq!(relay_receipt.event.kind, 3433);
+ assert_eq!(relay_receipt.event.author, seller_pubkey);
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["p".to_owned(), buyer_pubkey.clone()])
+ );
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["e_root".to_owned(), root_event_id.to_string()])
+ );
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["e_prev".to_owned(), decision_event_id.to_string()])
+ );
+ let envelope = client
+ .order()
+ .parse_fulfillment_update(&relay_receipt.event)
+ .expect("active fulfillment update");
+ assert_eq!(envelope.order_id, fulfillment.order_id);
+ assert_eq!(envelope.listing_addr, fulfillment.listing_addr);
+ assert_eq!(envelope.payload.status, fulfillment.status);
+ }
+ SdkTransportReceipt::Radrootsd(_) => panic!("unexpected radrootsd receipt"),
+ }
+
+ match cancellation_receipt.transport_receipt {
+ SdkTransportReceipt::RelayDirect(relay_receipt) => {
+ assert_eq!(relay_receipt.event.kind, 3432);
+ assert_eq!(relay_receipt.event.author, buyer_pubkey);
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["p".to_owned(), seller_pubkey.clone()])
+ );
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["e_root".to_owned(), root_event_id.to_string()])
+ );
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["e_prev".to_owned(), root_event_id.to_string()])
+ );
+ let envelope = client
+ .order()
+ .parse_order_cancellation(&relay_receipt.event)
+ .expect("order cancellation");
+ assert_eq!(envelope.order_id, cancellation.order_id);
+ assert_eq!(envelope.listing_addr, cancellation.listing_addr);
+ assert_eq!(envelope.payload.reason, cancellation.reason);
+ }
+ SdkTransportReceipt::Radrootsd(_) => panic!("unexpected radrootsd receipt"),
+ }
+
+ match buyer_receipt.transport_receipt {
+ SdkTransportReceipt::RelayDirect(relay_receipt) => {
+ assert_eq!(relay_receipt.event.kind, 3434);
+ assert_eq!(relay_receipt.event.author, buyer_pubkey);
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["p".to_owned(), seller_pubkey])
+ );
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["e_root".to_owned(), root_event_id.to_string()])
+ );
+ assert!(
+ relay_receipt
+ .event
+ .tags
+ .contains(&vec!["e_prev".to_owned(), fulfillment_event_id.to_string()])
+ );
+ let envelope = client
+ .order()
+ .parse_buyer_receipt(&relay_receipt.event)
+ .expect("active buyer receipt");
+ assert_eq!(envelope.order_id, receipt.order_id);
+ assert_eq!(envelope.listing_addr, receipt.listing_addr);
+ assert_eq!(envelope.payload.received, receipt.received);
+ }
+ SdkTransportReceipt::Radrootsd(_) => panic!("unexpected radrootsd receipt"),
+ }
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn relay_direct_order_decision_publish_builds_and_publishes_payload() -> TestResult<()> {
+ let relay = AckRelay::spawn().await?;
+ let buyer_identity = RadrootsIdentity::generate();
+ let seller_identity = RadrootsIdentity::generate();
+ let payload = sample_order_decision(
+ buyer_identity.public_key_hex(),
+ seller_identity.public_key_hex(),
+ );
+ 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()],
+ };
+ let client = RadrootsSdkClient::from_config(config)?;
+ let root_event_id = event_id('1');
+
+ let receipt = client
+ .order()
+ .publish_order_decision_with_identity(
+ &seller_identity,
+ &root_event_id,
+ &root_event_id,
+ &payload,
+ )
+ .await?;
+
+ assert_eq!(receipt.transport, SdkTransportMode::RelayDirect);
+ assert_eq!(receipt.event_kind, Some(3423));
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn relay_direct_order_request_publish_builds_and_publishes_payload() -> TestResult<()> {
+ let relay = AckRelay::spawn().await?;
+ let buyer_identity = RadrootsIdentity::generate();
+ let seller_identity = RadrootsIdentity::generate();
+ let payload = sample_order_request(
+ buyer_identity.public_key_hex(),
+ seller_identity.public_key_hex(),
+ );
+ 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()],
+ };
+ let client = RadrootsSdkClient::from_config(config)?;
+
+ let receipt = client
+ .order()
+ .publish_order_request_with_identity(&buyer_identity, &listing_event_ptr(), &payload)
+ .await?;
+
+ assert_eq!(receipt.transport, SdkTransportMode::RelayDirect);
+ assert_eq!(receipt.event_kind, Some(3422));
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn relay_direct_order_request_publish_rejects_radrootsd_transport_mode() -> TestResult<()> {
+ let buyer_identity = RadrootsIdentity::generate();
+ let seller_identity = RadrootsIdentity::generate();
+ let payload = sample_order_request(
+ buyer_identity.public_key_hex(),
+ seller_identity.public_key_hex(),
+ );
+ let mut config = RadrootsSdkConfig::production();
+ config.transport = SdkTransportMode::Radrootsd;
+ config.signer = SignerConfig::LocalIdentity;
+ let client = RadrootsSdkClient::from_config(config)?;
+
+ let error = client
+ .order()
+ .publish_order_request_with_identity(&buyer_identity, &listing_event_ptr(), &payload)
+ .await
+ .expect_err("unsupported transport");
+
+ assert!(matches!(
+ error,
+ SdkPublishError::UnsupportedTransport {
+ transport: SdkTransportMode::Radrootsd,
+ operation: "order.publish_order_request_with_identity",
+ }
+ ));
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn relay_direct_order_request_publish_rejects_draft_only_signer_mode() -> TestResult<()> {
+ let relay = AckRelay::spawn().await?;
+ let buyer_identity = RadrootsIdentity::generate();
+ let seller_identity = RadrootsIdentity::generate();
+ let payload = sample_order_request(
+ buyer_identity.public_key_hex(),
+ seller_identity.public_key_hex(),
+ );
+ 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()],
+ };
+ let client = RadrootsSdkClient::from_config(config)?;
+
+ let error = client
+ .order()
+ .publish_order_request_with_identity(&buyer_identity, &listing_event_ptr(), &payload)
+ .await
+ .expect_err("unsupported signer mode");
+
+ assert!(matches!(
+ error,
+ SdkPublishError::UnsupportedSignerMode {
+ transport: SdkTransportMode::RelayDirect,
+ signer: SignerConfig::DraftOnly,
+ required: SignerConfig::LocalIdentity,
+ operation: "order.publish_order_request_with_identity",
+ }
+ ));
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn relay_direct_order_request_publish_rejects_invalid_economics() -> TestResult<()> {
+ let buyer_identity = RadrootsIdentity::generate();
+ let seller_identity = RadrootsIdentity::generate();
+ let mut payload = sample_order_request(
+ buyer_identity.public_key_hex(),
+ seller_identity.public_key_hex(),
+ );
+ payload.economics.items[0].bin_count = 1;
+ let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom);
+ config.transport = SdkTransportMode::RelayDirect;
+ config.signer = SignerConfig::LocalIdentity;
+ config.relay = RelayConfig {
+ urls: vec!["ws://127.0.0.1:9".to_owned()],
+ };
+ let client = RadrootsSdkClient::from_config(config)?;
+
+ let error = client
+ .order()
+ .publish_order_request_with_identity(&buyer_identity, &listing_event_ptr(), &payload)
+ .await
+ .expect_err("invalid economics");
+
+ assert!(matches!(error, SdkPublishError::Encode(_)));
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn relay_direct_order_request_publish_reports_setup_error_detail() -> TestResult<()> {
+ let buyer_identity = RadrootsIdentity::generate();
+ let seller_identity = RadrootsIdentity::generate();
+ let payload = sample_order_request(
+ buyer_identity.public_key_hex(),
+ seller_identity.public_key_hex(),
+ );
+ let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom);
+ config.transport = SdkTransportMode::RelayDirect;
+ config.signer = SignerConfig::LocalIdentity;
+ config.network.timeout_ms = 10;
+ config.relay = RelayConfig {
+ urls: vec!["ws://127.0.0.1:9".to_owned()],
+ };
+ let client = RadrootsSdkClient::from_config(config)?;
+
+ let error = client
+ .order()
+ .publish_order_request_with_identity(&buyer_identity, &listing_event_ptr(), &payload)
+ .await
+ .expect_err("relay setup error");
+
+ assert!(matches!(
+ error,
+ SdkPublishError::RelaySetup {
+ transport: SdkTransportMode::RelayDirect,
+ operation: "order.publish_order_request_with_identity",
+ target_relays,
+ error: _,
+ } if target_relays == vec!["ws://127.0.0.1:9".to_owned()]
+ ));
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn relay_direct_farm_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
+ .farm()
+ .publish_with_identity(&identity, &sample_farm())
+ .await
+ .expect_err("unsupported transport");
+
+ assert!(matches!(
+ error,
+ SdkPublishError::UnsupportedTransport {
+ transport: SdkTransportMode::Radrootsd,
+ operation: "farm.publish_with_identity",
+ }
+ ));
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn relay_direct_farm_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()],
+ };
+ let client = RadrootsSdkClient::from_config(config)?;
+
+ let error = client
+ .farm()
+ .publish_with_identity(&identity, &sample_farm())
+ .await
+ .expect_err("unsupported signer mode");
+
+ assert!(matches!(
+ error,
+ SdkPublishError::UnsupportedSignerMode {
+ transport: SdkTransportMode::RelayDirect,
+ signer: SignerConfig::DraftOnly,
+ required: SignerConfig::LocalIdentity,
+ operation: "farm.publish_with_identity",
+ }
+ ));
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn relay_direct_profile_publish_accepts_sdk_built_draft() -> 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::LocalIdentity;
+ config.relay = RelayConfig {
+ urls: vec![relay.url().to_owned()],
+ };
+ let client = RadrootsSdkClient::from_config(config)?;
+ let draft = client
+ .profile()
+ .build_draft(&sample_profile(), Some(RadrootsProfileType::Farm))?;
+
+ let receipt = client
+ .profile()
+ .publish_draft_with_identity(&identity, draft)
+ .await?;
+
+ assert_eq!(receipt.transport, SdkTransportMode::RelayDirect);
+ assert_eq!(receipt.event_kind, Some(0));
+ assert!(receipt.event_id.is_some());
+ match receipt.transport_receipt {
+ SdkTransportReceipt::RelayDirect(relay_receipt) => {
+ assert_eq!(
+ receipt.event_id.as_deref(),
+ Some(relay_receipt.event_id.as_str())
+ );
+ assert_eq!(relay_receipt.event.kind, 0);
+ assert_eq!(relay_receipt.event.author, identity.public_key_hex());
+ assert_eq!(
+ relay_receipt.event.tags,
+ vec![vec!["t".to_owned(), "radroots:type:farm".to_owned()]]
+ );
+ assert_eq!(relay_receipt.target_relays, vec![relay.url().to_owned()]);
+ assert_eq!(relay_receipt.connected_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"),
+ }
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn relay_direct_profile_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
+ .profile()
+ .publish_with_identity(
+ &identity,
+ &sample_profile(),
+ Some(RadrootsProfileType::Farm),
+ )
+ .await
+ .expect_err("unsupported transport");
+
+ assert!(matches!(
+ error,
+ SdkPublishError::UnsupportedTransport {
+ transport: SdkTransportMode::Radrootsd,
+ operation: "profile.publish_with_identity",
+ }
+ ));
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn relay_direct_profile_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()],
+ };
+ let client = RadrootsSdkClient::from_config(config)?;
+
+ let error = client
+ .profile()
+ .publish_with_identity(
+ &identity,
+ &sample_profile(),
+ Some(RadrootsProfileType::Farm),
+ )
+ .await
+ .expect_err("unsupported signer mode");
+
+ assert!(matches!(
+ error,
+ SdkPublishError::UnsupportedSignerMode {
+ transport: SdkTransportMode::RelayDirect,
+ signer: SignerConfig::DraftOnly,
+ required: SignerConfig::LocalIdentity,
+ operation: "profile.publish_with_identity",
+ }
+ ));
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn relay_direct_listing_publish_accepts_sdk_built_draft() -> 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::LocalIdentity;
+ config.relay = RelayConfig {
+ urls: vec![relay.url().to_owned()],
+ };
+ let client = RadrootsSdkClient::from_config(config)?;
+ let draft = client.listing().build_draft(&sample_listing())?;
+
+ let receipt = client
+ .listing()
+ .publish_draft_with_identity(&identity, draft)
+ .await?;
+
+ assert_eq!(receipt.transport, SdkTransportMode::RelayDirect);
+ assert_eq!(receipt.event_kind, Some(30402));
+ assert!(receipt.event_id.is_some());
+ match receipt.transport_receipt {
+ SdkTransportReceipt::RelayDirect(relay_receipt) => {
+ assert_eq!(
+ receipt.event_id.as_deref(),
+ Some(relay_receipt.event_id.as_str())
+ );
+ assert_eq!(receipt.event_kind, Some(relay_receipt.event_kind));
+ assert_eq!(relay_receipt.event.kind, 30402);
+ assert_eq!(relay_receipt.event_id, relay_receipt.event.id);
+ assert_eq!(relay_receipt.signature, relay_receipt.event.sig);
+ assert_eq!(relay_receipt.created_at, relay_receipt.event.created_at);
+ assert_eq!(relay_receipt.event.author, identity.public_key_hex());
+ assert_eq!(relay_receipt.target_relays, vec![relay.url().to_owned()]);
+ assert_eq!(relay_receipt.connected_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"),
+ }
+
+ Ok(())
+}
+
+#[tokio::test]
+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
+ .listing()
+ .publish_with_identity(&identity, &sample_listing())
+ .await
+ .expect_err("unsupported transport");
+
+ assert!(matches!(
+ error,
+ SdkPublishError::UnsupportedTransport {
+ transport: SdkTransportMode::Radrootsd,
+ operation: "listing.publish_with_identity",
+ }
+ ));
+
+ 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()],
+ };
+ 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()],
+ };
+ 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(())
+}
diff --git a/crates/sdk/tests/replica_ingest.rs b/crates/sdk/tests/replica_ingest.rs
@@ -0,0 +1,85 @@
+use radroots_replica_db::ReplicaSql;
+use radroots_replica_db_schema::farm::IFarmFindMany;
+use radroots_replica_sync::{RadrootsReplicaIngestOutcome, radroots_replica_ingest_event};
+use radroots_sdk::{RadrootsFarm, RadrootsNostrEvent, farm};
+use radroots_sql_core::{SqlExecutor, SqliteExecutor};
+use tempfile::{TempDir, tempdir};
+
+fn seller_pubkey() -> String {
+ "a".repeat(64)
+}
+
+fn sdk_event(
+ id: u64,
+ author: &str,
+ created_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+) -> RadrootsNostrEvent {
+ RadrootsNostrEvent {
+ id: format!("{id:064x}"),
+ author: author.to_owned(),
+ created_at,
+ kind,
+ tags,
+ content,
+ sig: "f".repeat(128),
+ }
+}
+
+fn sample_farm() -> RadrootsFarm {
+ RadrootsFarm {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(),
+ name: "North Farm".into(),
+ about: Some("Organic coffee".into()),
+ website: None,
+ picture: None,
+ banner: None,
+ location: None,
+ tags: Some(vec!["coffee".into()]),
+ }
+}
+
+fn open_replica() -> (TempDir, ReplicaSql<SqliteExecutor>) {
+ let dir = tempdir().expect("tempdir");
+ let db_path = dir.path().join("replica.sqlite");
+ let executor = SqliteExecutor::open(&db_path).expect("open sqlite");
+ executor
+ .exec("PRAGMA foreign_keys = ON;", "[]")
+ .expect("enable foreign keys");
+ let replica = ReplicaSql::new(executor);
+ replica.migrate_up().expect("migrate");
+ (dir, replica)
+}
+
+fn ingest_farm(replica: &ReplicaSql<SqliteExecutor>) -> RadrootsNostrEvent {
+ let farm_value = sample_farm();
+ let author = seller_pubkey();
+ let parts = farm::build_draft(&farm_value).expect("farm draft");
+ let event = sdk_event(
+ 1,
+ &author,
+ 1_720_000_000,
+ parts.kind,
+ parts.content,
+ parts.tags,
+ );
+ let outcome = radroots_replica_ingest_event(replica.executor(), &event).expect("ingest farm");
+ assert_eq!(outcome, RadrootsReplicaIngestOutcome::Applied);
+ event
+}
+
+#[test]
+fn sdk_farm_draft_ingests_into_replica_projection() {
+ let (_dir, replica) = open_replica();
+ let event = ingest_farm(&replica);
+ let farms = replica
+ .farm_find_many(&IFarmFindMany { filter: None })
+ .expect("query farms")
+ .results;
+ assert_eq!(farms.len(), 1);
+ assert_eq!(farms[0].d_tag, sample_farm().d_tag);
+ assert_eq!(farms[0].name, sample_farm().name);
+ assert_eq!(farms[0].pubkey, event.author);
+}