lib

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

commit d6e18b33b717316f825950ef746a5d7d8d625e5c
parent 0f7ec974950451818552d354966fcf66d02a6a59
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 19:22:44 +0000

core(nostr): add local relay profile client

- adapt the pinned Nostr SDK behind the application client port
- query configured relays for bounded verified kind-zero events
- prove signed profile exchange through an ephemeral local relay
- keep the relay test lane independent of public network services

Diffstat:
Mcrates/studio_application/Cargo.toml | 6++++++
Mcrates/studio_application/src/lib.rs | 2++
Acrates/studio_application/src/nostr_client.rs | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mimports/studio_workspace/Cargo.toml | 6++++++
4 files changed, 152 insertions(+), 0 deletions(-)

diff --git a/crates/studio_application/Cargo.toml b/crates/studio_application/Cargo.toml @@ -7,9 +7,15 @@ license.workspace = true repository.workspace = true [dependencies] +nostr.workspace = true +nostr-sdk.workspace = true radroots-studio-domain = { path = "../domain" } radroots-studio-nostr = { path = "../nostr" } secrecy.workspace = true +[dev-dependencies] +nostr-relay-builder.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } + [lints] workspace = true diff --git a/crates/studio_application/src/lib.rs b/crates/studio_application/src/lib.rs @@ -3,6 +3,7 @@ pub mod accounts; pub mod app_core; pub mod config; +pub mod nostr_client; pub mod ports; pub mod recovery; pub mod secrets; @@ -18,6 +19,7 @@ pub use app_core::{AppCore, AppObserver, ObserverHandle, RemovalConfirmationToke pub use config::{ RelayRuntimeMode, relay_configuration_from_environment, relay_configuration_from_value, }; +pub use nostr_client::SdkNostrClient; pub use ports::{ AccountNamespaceRepository, AccountOperationKind, AccountOperationPhase, AccountPreferenceKey, AccountRepository, AppStateRepository, BoxFuture, CachedProfile, Clock, NostrClient, diff --git a/crates/studio_application/src/nostr_client.rs b/crates/studio_application/src/nostr_client.rs @@ -0,0 +1,138 @@ +use std::time::Duration; + +use nostr::{Filter, JsonUtil, Kind, PublicKey as NostrPublicKey}; +use nostr_sdk::ClientBuilder; +use radroots_studio_domain::{ + Kind0ProfileCandidate, PublicKey, RelayUrl, SafeError, SafeErrorCode, SafeMessage, + select_latest_kind0, +}; + +use crate::{BoxFuture, NostrClient}; + +pub struct SdkNostrClient { + timeout: Duration, +} + +impl SdkNostrClient { + #[must_use] + pub const fn new(timeout: Duration) -> Self { + Self { timeout } + } +} + +impl NostrClient for SdkNostrClient { + fn fetch_profile<'a>( + &'a self, + public_key: PublicKey, + relays: &'a [RelayUrl], + ) -> BoxFuture<'a, Result<Option<Kind0ProfileCandidate>, SafeError>> { + Box::pin(async move { + if relays.is_empty() { + return Err(invalid_relay_configuration()); + } + + let client = ClientBuilder::new().build(); + for relay in relays { + client + .add_relay(relay.as_str()) + .await + .map_err(|_| relay_connection_failed())?; + } + client.connect().await; + client.wait_for_connection(self.timeout).await; + + let author = NostrPublicKey::from_slice(public_key.as_bytes()) + .map_err(|_| profile_refresh_failed())?; + let filter = Filter::new().author(author).kind(Kind::Metadata).limit(64); + let fetched = client.fetch_events(filter, self.timeout).await; + client.shutdown().await; + let events = fetched.map_err(|_| relay_connection_failed())?; + + let mut candidates = Vec::with_capacity(events.len()); + for event in events.iter() { + candidates.push(radroots_studio_nostr::parse_verified_kind0( + &event.as_json(), + public_key, + )?); + } + Ok(select_latest_kind0(candidates)) + }) + } +} + +const fn invalid_relay_configuration() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidRelayConfiguration, + SafeMessage::new("No Nostr relay is configured."), + ) +} + +const fn relay_connection_failed() -> SafeError { + SafeError::new( + SafeErrorCode::RelayConnectionFailed, + SafeMessage::new("The Nostr relays could not be reached."), + ) +} + +const fn profile_refresh_failed() -> SafeError { + SafeError::new( + SafeErrorCode::ProfileRefreshFailed, + SafeMessage::new("The Nostr profile could not be refreshed."), + ) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use nostr::{EventBuilder, Keys, Metadata}; + use nostr_relay_builder::MockRelay; + use nostr_sdk::Client; + use radroots_studio_domain::{PublicKey, RelayUrl, SafeErrorCode}; + + use crate::{NostrClient, SdkNostrClient}; + + #[tokio::test] + async fn sdk_client_fetches_verified_profile_from_ephemeral_local_relay() { + let relay = MockRelay::run().await.expect("local relay"); + let relay_url = relay.url().await; + let keys = Keys::generate(); + let publisher = Client::new(keys.clone()); + publisher + .add_relay(relay_url.clone()) + .await + .expect("add relay"); + publisher.connect().await; + publisher.wait_for_connection(Duration::from_secs(2)).await; + publisher + .send_event_builder(EventBuilder::metadata( + &Metadata::new().name("Farmer").display_name("Farm Account"), + )) + .await + .expect("publish metadata"); + + let adapter = SdkNostrClient::new(Duration::from_secs(2)); + let domain_relay = RelayUrl::parse(relay_url.as_str()).expect("domain relay URL"); + let public_key = PublicKey::from_bytes(keys.public_key().to_bytes()); + let profile = adapter + .fetch_profile(public_key, &[domain_relay]) + .await + .expect("fetch profile") + .expect("published profile"); + + assert_eq!(profile.author(), public_key); + assert_eq!(profile.metadata().preferred_name(), Some("Farm Account")); + publisher.shutdown().await; + relay.shutdown(); + } + + #[tokio::test] + async fn sdk_client_rejects_empty_configuration_without_network_access() { + let error = SdkNostrClient::new(Duration::from_millis(10)) + .fetch_profile(PublicKey::from_bytes([1; 32]), &[]) + .await + .expect_err("empty relay list"); + + assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration); + } +} diff --git a/imports/studio_workspace/Cargo.toml b/imports/studio_workspace/Cargo.toml @@ -26,8 +26,14 @@ pedantic = "deny" [workspace.dependencies] keyring = "=4.1.6" nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr" } +nostr-sdk = "=0.44.1" +nostr-relay-builder = "=0.44.1" refinery = { version = "=0.9.2", default-features = false, features = ["rusqlite"] } rusqlite = { version = "=0.39.0", features = ["bundled"] } secrecy = "=0.10.3" url = "=2.5.8" zeroize = "=1.9.0" +tokio = "=1.47.1" + +[patch.crates-io] +nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219" }