lib

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

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:
Acrates/replica_sync_wasm/Cargo.toml | 34++++++++++++++++++++++++++++++++++
Acrates/replica_sync_wasm/README | 25+++++++++++++++++++++++++
Acrates/replica_sync_wasm/src/lib.rs | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/Cargo.toml | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/README | 23+++++++++++++++++++++++
Acrates/sdk/src/adapters/mod.rs | 8++++++++
Acrates/sdk/src/adapters/radrootsd.rs | 835+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/src/adapters/relay.rs | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/src/adapters/signer.rs | 24++++++++++++++++++++++++
Acrates/sdk/src/adapters/signing.rs | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/src/client.rs | 2711+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/src/config.rs | 388+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/src/farm.rs | 9+++++++++
Acrates/sdk/src/identity.rs | 33+++++++++++++++++++++++++++++++++
Acrates/sdk/src/lib.rs | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/src/listing.rs | 40++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/src/order.rs | 281+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/src/profile.rs | 12++++++++++++
Acrates/sdk/tests/client.rs | 906+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/config.rs | 562+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/facade.rs | 269+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/radrootsd.rs | 1952+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/relay_direct.rs | 1406+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/replica_ingest.rs | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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); +}