commit 6820aa55412b4d6822ebdd72a902a067e9e5865f
parent 31216709a03aa95680e4af582d35184a61a5fc52
Author: triesap <triesap@radroots.dev>
Date: Tue, 6 Jan 2026 16:39:25 +0000
refactor: restore minimal runtime and transports
- reintroduce app runtime with config loading and logging
- rebuild jsonrpc transport with nip46 methods
- wire nostr listener to handle nip46 requests
- add core state for keys and session store
Diffstat:
28 files changed, 1454 insertions(+), 4 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
@@ -21,8 +21,9 @@ anyhow = { version = "1" }
clap = { version = "4", features = ["derive"] }
jsonrpsee = { version = "0.26", features = ["server"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
-serde = { version = "1", default-features = false }
+serde = { version = "1", default-features = false, features = ["derive"] }
serde_json = { version = "1", default-features = false }
+serde_qs = { version = "0.12" }
tokio = { version = "1", features = ["full"] }
thiserror = { version = "1" }
tracing = { version = "0.1" }
diff --git a/src/app/cli.rs b/src/app/cli.rs
@@ -0,0 +1,35 @@
+use std::path::PathBuf;
+
+use clap::{command, Parser, ValueHint};
+
+#[derive(Parser, Debug, Clone)]
+#[command(
+ about = env!("CARGO_PKG_DESCRIPTION"),
+ author = env!("CARGO_PKG_AUTHORS"),
+ version = env!("CARGO_PKG_VERSION")
+)]
+pub struct Args {
+ #[arg(
+ long,
+ value_name = "PATH",
+ value_hint = ValueHint::FilePath,
+ default_value = "config.toml",
+ help = "Path to the daemon configuration file (defaults to config.toml)"
+ )]
+ pub config: PathBuf,
+
+ #[arg(
+ long,
+ value_name = "PATH",
+ value_hint = ValueHint::FilePath,
+ help = "Path to the daemon identity file (json, txt, or raw 32-byte key; defaults to identity.json)",
+ )]
+ pub identity: Option<PathBuf>,
+
+ #[arg(
+ long,
+ action = clap::ArgAction::SetTrue,
+ help = "Allow generating a new identity file if missing; if not set and identity file is absent, the daemon will fail"
+ )]
+ pub allow_generate_identity: bool,
+}
diff --git a/src/app/config.rs b/src/app/config.rs
@@ -0,0 +1,83 @@
+use radroots_nostr::prelude::RadrootsNostrMetadata;
+use serde::{Deserialize, Serialize};
+
+fn default_rpc_addr() -> String {
+ "127.0.0.1:7070".to_string()
+}
+
+fn default_max_request_body_size() -> u32 {
+ 10 * 1024 * 1024
+}
+
+fn default_max_response_body_size() -> u32 {
+ 10 * 1024 * 1024
+}
+
+fn default_max_connections() -> u32 {
+ 100
+}
+
+fn default_max_subscriptions_per_connection() -> u32 {
+ 1024
+}
+
+fn default_message_buffer_capacity() -> u32 {
+ 1024
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct RpcConfig {
+ #[serde(default = "default_rpc_addr")]
+ pub addr: String,
+ #[serde(default = "default_max_request_body_size")]
+ pub max_request_body_size: u32,
+ #[serde(default = "default_max_response_body_size")]
+ pub max_response_body_size: u32,
+ #[serde(default = "default_max_connections")]
+ pub max_connections: u32,
+ #[serde(default = "default_max_subscriptions_per_connection")]
+ pub max_subscriptions_per_connection: u32,
+ #[serde(default = "default_message_buffer_capacity")]
+ pub message_buffer_capacity: u32,
+ #[serde(default)]
+ pub batch_request_limit: Option<u32>,
+}
+
+impl Default for RpcConfig {
+ fn default() -> Self {
+ Self {
+ addr: default_rpc_addr(),
+ max_request_body_size: default_max_request_body_size(),
+ max_response_body_size: default_max_response_body_size(),
+ max_connections: default_max_connections(),
+ max_subscriptions_per_connection: default_max_subscriptions_per_connection(),
+ message_buffer_capacity: default_message_buffer_capacity(),
+ batch_request_limit: None,
+ }
+ }
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct Configuration {
+ pub logs_dir: String,
+ #[serde(default)]
+ pub rpc: RpcConfig,
+ #[serde(default)]
+ pub rpc_addr: Option<String>,
+ #[serde(default)]
+ pub relays: Vec<String>,
+}
+
+impl Configuration {
+ pub fn rpc_addr(&self) -> &str {
+ self.rpc_addr
+ .as_deref()
+ .unwrap_or(self.rpc.addr.as_str())
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Settings {
+ pub metadata: RadrootsNostrMetadata,
+ pub config: Configuration,
+}
diff --git a/src/app/mod.rs b/src/app/mod.rs
@@ -0,0 +1,7 @@
+pub mod cli;
+pub mod config;
+mod runtime;
+
+pub use cli::Args;
+pub use config::Settings;
+pub use runtime::run;
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
@@ -0,0 +1,51 @@
+use anyhow::{Context, Result};
+use radroots_identity::RadrootsIdentity;
+use tracing::info;
+
+use crate::app::{cli, config};
+use crate::core::Radrootsd;
+use crate::transport::jsonrpc;
+use crate::transport::nostr::listener::spawn_nip46_listener;
+
+pub async fn run() -> Result<()> {
+ let (args, settings): (cli::Args, config::Settings) =
+ radroots_runtime::parse_and_load_path_with_init(
+ |a: &cli::Args| Some(a.config.as_path()),
+ |cfg: &config::Settings| cfg.config.logs_dir.as_str(),
+ None,
+ )
+ .context("load configuration")?;
+
+ info!("Starting radrootsd");
+
+ let identity = RadrootsIdentity::load_or_generate(
+ args.identity.as_ref(),
+ args.allow_generate_identity,
+ )?;
+ let keys = identity.keys().clone();
+ let radrootsd = Radrootsd::new(keys, settings.metadata.clone());
+
+ for relay in settings.config.relays.iter() {
+ radrootsd.client.add_relay(relay).await?;
+ }
+
+ if !settings.config.relays.is_empty() {
+ spawn_nip46_listener(radrootsd.clone());
+ }
+
+ let addr: std::net::SocketAddr = settings.config.rpc_addr().parse()?;
+ let handle = jsonrpc::start_rpc(radrootsd.clone(), addr, &settings.config.rpc).await?;
+ info!("JSON-RPC listening on {addr}");
+
+ let stop_handle = handle.clone();
+
+ tokio::select! {
+ _ = radroots_runtime::shutdown_signal() => {
+ info!("Shutting down…");
+ let _ = stop_handle.stop();
+ }
+ _ = handle.stopped() => {}
+ }
+
+ Ok(())
+}
diff --git a/src/core/mod.rs b/src/core/mod.rs
@@ -1 +1,4 @@
+pub mod nip46;
pub mod state;
+
+pub use state::Radrootsd;
diff --git a/src/core/nip46/mod.rs b/src/core/nip46/mod.rs
@@ -0,0 +1,3 @@
+pub mod session;
+
+pub use session::{Nip46Session, Nip46SessionStore};
diff --git a/src/core/nip46/session.rs b/src/core/nip46/session.rs
@@ -0,0 +1,70 @@
+#![forbid(unsafe_code)]
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use tokio::sync::Mutex;
+
+use radroots_nostr::prelude::{
+ RadrootsNostrClient,
+ RadrootsNostrKeys,
+ RadrootsNostrPublicKey,
+};
+
+#[derive(Clone)]
+pub struct Nip46SessionStore {
+ inner: Arc<Mutex<HashMap<String, Nip46Session>>>,
+}
+
+#[derive(Clone)]
+pub struct Nip46Session {
+ pub id: String,
+ pub client: RadrootsNostrClient,
+ pub client_keys: RadrootsNostrKeys,
+ pub client_pubkey: RadrootsNostrPublicKey,
+ pub remote_signer_pubkey: RadrootsNostrPublicKey,
+ pub user_pubkey: Option<RadrootsNostrPublicKey>,
+ pub relays: Vec<String>,
+ pub perms: Vec<String>,
+ pub name: Option<String>,
+ pub url: Option<String>,
+ pub image: Option<String>,
+}
+
+impl Nip46SessionStore {
+ pub fn new() -> Self {
+ Self {
+ inner: Arc::new(Mutex::new(HashMap::new())),
+ }
+ }
+
+ pub async fn insert(&self, session: Nip46Session) {
+ let mut sessions = self.inner.lock().await;
+ sessions.insert(session.id.clone(), session);
+ }
+
+ pub async fn get(&self, session_id: &str) -> Option<Nip46Session> {
+ let sessions = self.inner.lock().await;
+ sessions.get(session_id).cloned()
+ }
+
+ pub async fn remove(&self, session_id: &str) -> bool {
+ let mut sessions = self.inner.lock().await;
+ sessions.remove(session_id).is_some()
+ }
+
+ pub async fn set_user_pubkey(
+ &self,
+ session_id: &str,
+ pubkey: RadrootsNostrPublicKey,
+ ) -> bool {
+ let mut sessions = self.inner.lock().await;
+ match sessions.get_mut(session_id) {
+ Some(session) => {
+ session.user_pubkey = Some(pubkey);
+ true
+ }
+ None => false,
+ }
+ }
+}
diff --git a/src/core/state.rs b/src/core/state.rs
@@ -11,26 +11,31 @@ use radroots_nostr::prelude::{
pub struct Radrootsd {
pub(crate) started: Instant,
pub client: RadrootsNostrClient,
+ pub keys: RadrootsNostrKeys,
pub pubkey: RadrootsNostrPublicKey,
pub metadata: RadrootsNostrMetadata,
pub info: serde_json::Value,
+ pub(crate) nip46_sessions: crate::core::nip46::session::Nip46SessionStore,
}
impl Radrootsd {
pub fn new(keys: RadrootsNostrKeys, metadata: RadrootsNostrMetadata) -> Self {
let pubkey = keys.public_key();
- let client = RadrootsNostrClient::new(keys);
+ let client = RadrootsNostrClient::new(keys.clone());
let info = serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
"build": option_env!("GIT_HASH").unwrap_or("unknown"),
});
+ let nip46_sessions = crate::core::nip46::session::Nip46SessionStore::new();
Self {
started: Instant::now(),
client,
+ keys,
pubkey,
metadata,
info,
+ nip46_sessions,
}
}
}
diff --git a/src/main.rs b/src/main.rs
@@ -1 +1,21 @@
-fn main() {}
+#![forbid(unsafe_code)]
+
+use std::process::ExitCode;
+
+use anyhow::Result;
+
+#[tokio::main]
+async fn main() -> ExitCode {
+ match run().await {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(err) => {
+ tracing::error!(error = ?err, "Fatal error");
+ eprintln!("Fatal error: {err:#}");
+ ExitCode::FAILURE
+ }
+ }
+}
+
+async fn run() -> Result<()> {
+ radrootsd::app::run().await
+}
diff --git a/src/transport/jsonrpc/context.rs b/src/transport/jsonrpc/context.rs
@@ -0,0 +1,17 @@
+#![forbid(unsafe_code)]
+
+use crate::core::Radrootsd;
+
+use super::registry::MethodRegistry;
+
+#[derive(Clone)]
+pub struct RpcContext {
+ pub state: Radrootsd,
+ pub methods: MethodRegistry,
+}
+
+impl RpcContext {
+ pub fn new(state: Radrootsd, methods: MethodRegistry) -> Self {
+ Self { state, methods }
+ }
+}
diff --git a/src/transport/jsonrpc/error.rs b/src/transport/jsonrpc/error.rs
@@ -0,0 +1,30 @@
+#![forbid(unsafe_code)]
+
+use jsonrpsee::types::{ErrorObject, ErrorObjectOwned};
+use thiserror::Error;
+
+#[derive(Debug, Error)]
+pub enum RpcError {
+ #[error("failed to add relay {0}: {1}")]
+ AddRelay(String, String),
+ #[error("no relays configured; call relays.add first")]
+ NoRelays,
+ #[error("invalid params: {0}")]
+ InvalidParams(String),
+ #[error("method not found: {0}")]
+ MethodNotFound(String),
+ #[error("{0}")]
+ Other(String),
+}
+
+impl From<RpcError> for ErrorObjectOwned {
+ fn from(err: RpcError) -> Self {
+ match err {
+ RpcError::InvalidParams(msg) => ErrorObject::owned(-32602, msg, None::<()>),
+ RpcError::MethodNotFound(name) => {
+ ErrorObject::owned(-32601, format!("method not found: {name}"), None::<()>)
+ }
+ other => ErrorObject::owned(-32000, other.to_string(), None::<()>),
+ }
+ }
+}
diff --git a/src/transport/jsonrpc/methods/mod.rs b/src/transport/jsonrpc/methods/mod.rs
@@ -0,0 +1,17 @@
+#![forbid(unsafe_code)]
+
+use anyhow::Result;
+use jsonrpsee::server::RpcModule;
+
+use crate::transport::jsonrpc::{MethodRegistry, RpcContext};
+
+pub mod nip46;
+
+pub fn register_all(
+ root: &mut RpcModule<RpcContext>,
+ ctx: RpcContext,
+ registry: MethodRegistry,
+) -> Result<()> {
+ root.merge(nip46::module(ctx, registry)?)?;
+ Ok(())
+}
diff --git a/src/transport/jsonrpc/methods/nip46/connect.rs b/src/transport/jsonrpc/methods/nip46/connect.rs
@@ -0,0 +1,437 @@
+use std::time::Duration;
+
+use anyhow::Result;
+use jsonrpsee::server::RpcModule;
+use serde::{Deserialize, Serialize};
+use tokio::sync::broadcast;
+use tokio::time::sleep;
+use uuid::Uuid;
+
+use crate::core::nip46::session::Nip46Session;
+use crate::transport::jsonrpc::nip46::connection::{
+ parse_connect_url,
+ Nip46ConnectInfo,
+ Nip46ConnectMode,
+};
+use crate::transport::jsonrpc::params::DEFAULT_TIMEOUT_SECS;
+use crate::transport::jsonrpc::{MethodRegistry, RpcContext, RpcError};
+use radroots_nostr::prelude::{
+ radroots_nostr_filter_tag,
+ radroots_nostr_parse_pubkey,
+ RadrootsNostrClient,
+ RadrootsNostrEventBuilder,
+ RadrootsNostrFilter,
+ RadrootsNostrKind,
+ RadrootsNostrKeys,
+ RadrootsNostrPublicKey,
+ RadrootsNostrSecretKey,
+ RadrootsNostrRelayPoolNotification,
+ RadrootsNostrTimestamp,
+};
+use nostr::nips::{nip44, nip46::NostrConnectMessage, nip46::NostrConnectRequest};
+use nostr::JsonUtil;
+
+#[derive(Debug, Deserialize)]
+struct Nip46ConnectParams {
+ url: String,
+ client_secret_key: Option<String>,
+}
+
+#[derive(Clone, Debug, Serialize)]
+struct Nip46ConnectResponse {
+ session_id: String,
+ mode: Nip46ConnectMode,
+ remote_signer_pubkey: String,
+ client_pubkey: String,
+ relays: Vec<String>,
+}
+
+pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> {
+ registry.track("nip46.connect");
+ m.register_async_method("nip46.connect", |params, ctx, _| async move {
+ let Nip46ConnectParams {
+ url,
+ client_secret_key,
+ } = params
+ .parse()
+ .map_err(|e| RpcError::InvalidParams(e.to_string()))?;
+ let response = connect_nip46(ctx.as_ref().clone(), url, client_secret_key).await?;
+ Ok::<Nip46ConnectResponse, RpcError>(response)
+ })?;
+ Ok(())
+}
+
+async fn connect_nip46(
+ ctx: RpcContext,
+ url: String,
+ client_secret_key: Option<String>,
+) -> Result<Nip46ConnectResponse, RpcError> {
+ let info = parse_connect_url(&url)?;
+ match info.mode {
+ Nip46ConnectMode::Bunker => connect_bunker(ctx, info).await,
+ Nip46ConnectMode::Nostrconnect => {
+ connect_nostrconnect(ctx, info, client_secret_key).await
+ }
+ }
+}
+
+async fn connect_bunker(
+ ctx: RpcContext,
+ info: Nip46ConnectInfo,
+) -> Result<Nip46ConnectResponse, RpcError> {
+ if info.relays.is_empty() {
+ return Err(RpcError::InvalidParams("missing relay".to_string()));
+ }
+
+ let remote_signer_raw = info.remote_signer_pubkey.as_ref().ok_or_else(|| {
+ RpcError::InvalidParams("missing remote signer pubkey".to_string())
+ })?;
+ let remote_signer_pubkey = radroots_nostr_parse_pubkey(remote_signer_raw)
+ .map_err(|e| RpcError::InvalidParams(format!("invalid remote signer: {e}")))?;
+
+ let client_keys = RadrootsNostrKeys::generate();
+ let client_pubkey = client_keys.public_key();
+ let client = RadrootsNostrClient::new(client_keys.clone());
+
+ add_relays(&client, &info.relays).await?;
+ client.connect().await;
+ client
+ .wait_for_connection(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
+ .await;
+
+ let request_id = send_connect_request(
+ &client,
+ &client_keys,
+ &remote_signer_pubkey,
+ info.secret.as_deref(),
+ )
+ .await?;
+
+ let response = wait_for_connect_response(
+ &client,
+ &client_keys,
+ &remote_signer_pubkey,
+ &client_pubkey,
+ &request_id,
+ )
+ .await?;
+
+ validate_connect_response(&response, info.secret.as_deref())?;
+
+ let session_id = Uuid::new_v4().to_string();
+ let session = Nip46Session {
+ id: session_id.clone(),
+ client,
+ client_keys,
+ client_pubkey,
+ remote_signer_pubkey,
+ user_pubkey: None,
+ relays: info.relays.clone(),
+ perms: info.perms.clone(),
+ name: info.name.clone(),
+ url: info.url.clone(),
+ image: info.image.clone(),
+ };
+ ctx.state.nip46_sessions.insert(session).await;
+
+ Ok(Nip46ConnectResponse {
+ session_id,
+ mode: info.mode,
+ remote_signer_pubkey: remote_signer_raw.to_string(),
+ client_pubkey: client_pubkey.to_hex(),
+ relays: info.relays,
+ })
+}
+
+async fn connect_nostrconnect(
+ ctx: RpcContext,
+ info: Nip46ConnectInfo,
+ client_secret_key: Option<String>,
+) -> Result<Nip46ConnectResponse, RpcError> {
+ if info.relays.is_empty() {
+ return Err(RpcError::InvalidParams("missing relay".to_string()));
+ }
+ let secret = info
+ .secret
+ .as_deref()
+ .ok_or_else(|| RpcError::InvalidParams("missing secret".to_string()))?;
+ let client_secret_key = client_secret_key
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty())
+ .ok_or_else(|| RpcError::InvalidParams("missing client_secret_key".to_string()))?;
+ let client_secret_key = RadrootsNostrSecretKey::parse(&client_secret_key)
+ .map_err(|e| RpcError::InvalidParams(format!("invalid client_secret_key: {e}")))?;
+ let client_keys = RadrootsNostrKeys::new(client_secret_key);
+ let client_pubkey = client_keys.public_key();
+ let client_pubkey_raw = info.client_pubkey.as_ref().ok_or_else(|| {
+ RpcError::InvalidParams("missing client pubkey".to_string())
+ })?;
+ let expected_pubkey = radroots_nostr_parse_pubkey(client_pubkey_raw)
+ .map_err(|e| RpcError::InvalidParams(format!("invalid client pubkey: {e}")))?;
+ if expected_pubkey != client_pubkey {
+ return Err(RpcError::InvalidParams(
+ "client_secret_key does not match client pubkey".to_string(),
+ ));
+ }
+
+ let client = RadrootsNostrClient::new(client_keys.clone());
+ add_relays(&client, &info.relays).await?;
+ client.connect().await;
+ client
+ .wait_for_connection(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
+ .await;
+
+ let (remote_signer_pubkey, response) = wait_for_nostrconnect_response(
+ &client,
+ &client_keys,
+ &client_pubkey,
+ secret,
+ )
+ .await?;
+ validate_nostrconnect_response(&response, secret)?;
+
+ let session_id = Uuid::new_v4().to_string();
+ let session = Nip46Session {
+ id: session_id.clone(),
+ client,
+ client_keys,
+ client_pubkey,
+ remote_signer_pubkey,
+ user_pubkey: None,
+ relays: info.relays.clone(),
+ perms: info.perms.clone(),
+ name: info.name.clone(),
+ url: info.url.clone(),
+ image: info.image.clone(),
+ };
+ ctx.state.nip46_sessions.insert(session).await;
+
+ Ok(Nip46ConnectResponse {
+ session_id,
+ mode: info.mode,
+ remote_signer_pubkey: remote_signer_pubkey.to_hex(),
+ client_pubkey: client_pubkey.to_hex(),
+ relays: info.relays,
+ })
+}
+
+async fn add_relays(client: &RadrootsNostrClient, relays: &[String]) -> Result<(), RpcError> {
+ for relay in relays.iter() {
+ client
+ .add_relay(relay)
+ .await
+ .map_err(|e| RpcError::Other(format!("nip46 relay add failed: {e}")))?;
+ }
+ Ok(())
+}
+
+async fn send_connect_request(
+ client: &RadrootsNostrClient,
+ client_keys: &RadrootsNostrKeys,
+ remote_signer_pubkey: &RadrootsNostrPublicKey,
+ secret: Option<&str>,
+) -> Result<String, RpcError> {
+ let req = NostrConnectRequest::Connect {
+ remote_signer_public_key: remote_signer_pubkey.clone(),
+ secret: secret.map(|value| value.to_string()),
+ };
+ let message = NostrConnectMessage::request(&req);
+ let request_id = message.id().to_string();
+ let event = RadrootsNostrEventBuilder::nostr_connect(
+ client_keys,
+ remote_signer_pubkey.clone(),
+ message,
+ )
+ .map_err(|e| RpcError::Other(format!("nip46 connect request failed: {e}")))?;
+ client
+ .send_event_builder(event)
+ .await
+ .map_err(|e| RpcError::Other(format!("nip46 connect request failed: {e}")))?;
+ Ok(request_id)
+}
+
+async fn wait_for_connect_response(
+ client: &RadrootsNostrClient,
+ client_keys: &RadrootsNostrKeys,
+ remote_signer_pubkey: &RadrootsNostrPublicKey,
+ client_pubkey: &RadrootsNostrPublicKey,
+ request_id: &str,
+) -> Result<NostrConnectMessage, RpcError> {
+ let filter = RadrootsNostrFilter::new()
+ .kind(RadrootsNostrKind::NostrConnect)
+ .author(remote_signer_pubkey.clone())
+ .since(RadrootsNostrTimestamp::now());
+ let filter = radroots_nostr_filter_tag(filter, "p", vec![client_pubkey.to_hex()])
+ .map_err(|e| RpcError::Other(format!("nip46 connect filter failed: {e}")))?;
+ let mut notifications = client.notifications();
+ let subscription = client
+ .subscribe(filter, None)
+ .await
+ .map_err(|e| RpcError::Other(format!("nip46 connect failed: {e}")))?;
+ let timeout = sleep(Duration::from_secs(DEFAULT_TIMEOUT_SECS));
+ tokio::pin!(timeout);
+
+ loop {
+ tokio::select! {
+ _ = &mut timeout => {
+ client.unsubscribe(&subscription.val).await;
+ return Err(RpcError::Other("nip46 connect response not found".to_string()));
+ }
+ msg = notifications.recv() => {
+ let notification = match msg {
+ Ok(notification) => notification,
+ Err(broadcast::error::RecvError::Lagged(_)) => continue,
+ Err(broadcast::error::RecvError::Closed) => {
+ return Err(RpcError::Other("nip46 connect notification closed".to_string()));
+ }
+ };
+ let RadrootsNostrRelayPoolNotification::Event { event, .. } = notification else {
+ continue;
+ };
+ let event = (*event).clone();
+ if event.kind != RadrootsNostrKind::NostrConnect
+ || event.pubkey != *remote_signer_pubkey
+ {
+ continue;
+ }
+ let decrypted = nip44::decrypt(
+ client_keys.secret_key(),
+ remote_signer_pubkey,
+ &event.content,
+ )
+ .map_err(|e| RpcError::Other(format!("nip46 connect decrypt failed: {e}")))?;
+ let message = NostrConnectMessage::from_json(&decrypted)
+ .map_err(|e| RpcError::Other(format!("nip46 connect response parse failed: {e}")))?;
+ if message.is_response() && message.id() == request_id {
+ client.unsubscribe(&subscription.val).await;
+ return Ok(message);
+ }
+ }
+ }
+ }
+}
+
+fn validate_connect_response(
+ response: &NostrConnectMessage,
+ secret: Option<&str>,
+) -> Result<(), RpcError> {
+ let (result, error) = match response {
+ NostrConnectMessage::Response { result, error, .. } => (result, error),
+ _ => {
+ return Err(RpcError::Other(
+ "nip46 connect response invalid".to_string(),
+ ))
+ }
+ };
+
+ if let Some(error) = error {
+ return Err(RpcError::Other(format!("nip46 connect error: {error}")));
+ }
+
+ let result = result
+ .as_deref()
+ .ok_or_else(|| RpcError::Other("nip46 connect missing result".to_string()))?;
+
+ if result == "ack" {
+ return Ok(());
+ }
+
+ if secret.is_some_and(|expected| expected == result) {
+ return Ok(());
+ }
+
+ Err(RpcError::Other(format!(
+ "nip46 connect unexpected result: {result}"
+ )))
+}
+
+fn validate_nostrconnect_response(
+ response: &NostrConnectMessage,
+ secret: &str,
+) -> Result<(), RpcError> {
+ let (result, error) = match response {
+ NostrConnectMessage::Response { result, error, .. } => (result, error),
+ _ => {
+ return Err(RpcError::Other(
+ "nip46 connect response invalid".to_string(),
+ ))
+ }
+ };
+
+ if let Some(error) = error {
+ return Err(RpcError::Other(format!("nip46 connect error: {error}")));
+ }
+
+ let Some(value) = result.as_deref() else {
+ return Err(RpcError::Other(
+ "nip46 connect missing result".to_string(),
+ ));
+ };
+
+ if value == secret {
+ return Ok(());
+ }
+
+ Err(RpcError::Other(format!(
+ "nip46 connect unexpected result: {value}"
+ )))
+}
+
+async fn wait_for_nostrconnect_response(
+ client: &RadrootsNostrClient,
+ client_keys: &RadrootsNostrKeys,
+ client_pubkey: &RadrootsNostrPublicKey,
+ secret: &str,
+) -> Result<(RadrootsNostrPublicKey, NostrConnectMessage), RpcError> {
+ let filter = RadrootsNostrFilter::new()
+ .kind(RadrootsNostrKind::NostrConnect)
+ .since(RadrootsNostrTimestamp::now());
+ let filter = radroots_nostr_filter_tag(filter, "p", vec![client_pubkey.to_hex()])
+ .map_err(|e| RpcError::Other(format!("nip46 connect filter failed: {e}")))?;
+ let mut notifications = client.notifications();
+ let subscription = client
+ .subscribe(filter, None)
+ .await
+ .map_err(|e| RpcError::Other(format!("nip46 connect failed: {e}")))?;
+ let timeout = sleep(Duration::from_secs(DEFAULT_TIMEOUT_SECS));
+ tokio::pin!(timeout);
+
+ loop {
+ tokio::select! {
+ _ = &mut timeout => {
+ client.unsubscribe(&subscription.val).await;
+ return Err(RpcError::Other("nip46 connect response not found".to_string()));
+ }
+ msg = notifications.recv() => {
+ let notification = match msg {
+ Ok(notification) => notification,
+ Err(broadcast::error::RecvError::Lagged(_)) => continue,
+ Err(broadcast::error::RecvError::Closed) => {
+ return Err(RpcError::Other("nip46 connect notification closed".to_string()));
+ }
+ };
+ let RadrootsNostrRelayPoolNotification::Event { event, .. } = notification else {
+ continue;
+ };
+ let event = (*event).clone();
+ if event.kind != RadrootsNostrKind::NostrConnect {
+ continue;
+ }
+ let decrypted = nip44::decrypt(
+ client_keys.secret_key(),
+ &event.pubkey,
+ &event.content,
+ )
+ .map_err(|e| RpcError::Other(format!("nip46 connect decrypt failed: {e}")))?;
+ let message = NostrConnectMessage::from_json(&decrypted)
+ .map_err(|e| RpcError::Other(format!("nip46 connect response parse failed: {e}")))?;
+ if !message.is_response() || message.id().is_empty() {
+ continue;
+ }
+ validate_nostrconnect_response(&message, secret)?;
+ client.unsubscribe(&subscription.val).await;
+ return Ok((event.pubkey, message));
+ }
+ }
+ }
+}
diff --git a/src/transport/jsonrpc/methods/nip46/get_public_key.rs b/src/transport/jsonrpc/methods/nip46/get_public_key.rs
@@ -0,0 +1,81 @@
+use anyhow::Result;
+use jsonrpsee::server::RpcModule;
+use serde::{Deserialize, Serialize};
+
+use crate::core::nip46::session::Nip46Session;
+use crate::transport::jsonrpc::{MethodRegistry, RpcContext, RpcError};
+use crate::transport::jsonrpc::nip46::client;
+use nostr::nips::nip46::{NostrConnectMethod, NostrConnectRequest, ResponseResult};
+
+#[derive(Debug, Deserialize)]
+struct Nip46GetPublicKeyParams {
+ session_id: String,
+}
+
+#[derive(Clone, Debug, Serialize)]
+struct Nip46GetPublicKeyResponse {
+ pubkey: String,
+}
+
+pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> {
+ registry.track("nip46.get_public_key");
+ m.register_async_method("nip46.get_public_key", |params, ctx, _| async move {
+ let Nip46GetPublicKeyParams { session_id } = params
+ .parse()
+ .map_err(|e| RpcError::InvalidParams(e.to_string()))?;
+ let session = ctx
+ .state
+ .nip46_sessions
+ .get(&session_id)
+ .await
+ .ok_or_else(|| RpcError::InvalidParams("unknown session".to_string()))?;
+ let (pubkey, updated) = request_get_public_key(&session).await?;
+ if updated {
+ if !ctx
+ .state
+ .nip46_sessions
+ .set_user_pubkey(&session_id, pubkey.clone())
+ .await
+ {
+ return Err(RpcError::Other("nip46 session update failed".to_string()));
+ }
+ }
+ Ok::<Nip46GetPublicKeyResponse, RpcError>(Nip46GetPublicKeyResponse {
+ pubkey: pubkey.to_hex(),
+ })
+ })?;
+ Ok(())
+}
+
+async fn request_get_public_key(
+ session: &Nip46Session,
+) -> Result<(radroots_nostr::prelude::RadrootsNostrPublicKey, bool), RpcError> {
+ let req = NostrConnectRequest::GetPublicKey;
+ let response = client::request(session, req, "get_public_key").await?;
+ let response = response
+ .to_response(NostrConnectMethod::GetPublicKey)
+ .map_err(|e| RpcError::Other(format!("nip46 get_public_key failed: {e}")))?;
+
+ if let Some(error) = response.error {
+ return Err(RpcError::Other(format!(
+ "nip46 get_public_key error: {error}"
+ )));
+ }
+
+ let pubkey = match response.result {
+ Some(ResponseResult::GetPublicKey(pubkey)) => pubkey,
+ Some(_) => {
+ return Err(RpcError::Other(
+ "nip46 get_public_key unexpected response".to_string(),
+ ))
+ }
+ None => {
+ return Err(RpcError::Other(
+ "nip46 get_public_key missing response".to_string(),
+ ))
+ }
+ };
+
+ let updated = session.user_pubkey.map(|existing| existing != pubkey).unwrap_or(true);
+ Ok((pubkey, updated))
+}
diff --git a/src/transport/jsonrpc/methods/nip46/mod.rs b/src/transport/jsonrpc/methods/nip46/mod.rs
@@ -0,0 +1,24 @@
+#![forbid(unsafe_code)]
+
+use anyhow::Result;
+use jsonrpsee::server::RpcModule;
+
+use crate::transport::jsonrpc::{MethodRegistry, RpcContext};
+
+pub mod connect;
+pub mod get_public_key;
+pub mod ping;
+pub mod session_close;
+pub mod session_status;
+pub mod status;
+
+pub fn module(ctx: RpcContext, registry: MethodRegistry) -> Result<RpcModule<RpcContext>> {
+ let mut m = RpcModule::new(ctx);
+ status::register(&mut m, ®istry)?;
+ connect::register(&mut m, ®istry)?;
+ ping::register(&mut m, ®istry)?;
+ get_public_key::register(&mut m, ®istry)?;
+ session_status::register(&mut m, ®istry)?;
+ session_close::register(&mut m, ®istry)?;
+ Ok(m)
+}
diff --git a/src/transport/jsonrpc/methods/nip46/ping.rs b/src/transport/jsonrpc/methods/nip46/ping.rs
@@ -0,0 +1,57 @@
+use anyhow::Result;
+use jsonrpsee::server::RpcModule;
+use serde::{Deserialize, Serialize};
+
+use crate::core::nip46::session::Nip46Session;
+use crate::transport::jsonrpc::{MethodRegistry, RpcContext, RpcError};
+use crate::transport::jsonrpc::nip46::client;
+use nostr::nips::nip46::{NostrConnectMethod, NostrConnectRequest, ResponseResult};
+
+#[derive(Debug, Deserialize)]
+struct Nip46PingParams {
+ session_id: String,
+}
+
+#[derive(Clone, Debug, Serialize)]
+struct Nip46PingResponse {
+ result: String,
+}
+
+pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> {
+ registry.track("nip46.ping");
+ m.register_async_method("nip46.ping", |params, ctx, _| async move {
+ let Nip46PingParams { session_id } = params
+ .parse()
+ .map_err(|e| RpcError::InvalidParams(e.to_string()))?;
+ let session = ctx
+ .state
+ .nip46_sessions
+ .get(&session_id)
+ .await
+ .ok_or_else(|| RpcError::InvalidParams("unknown session".to_string()))?;
+ Ok::<Nip46PingResponse, RpcError>(Nip46PingResponse {
+ result: request_ping(&session).await?,
+ })
+ })?;
+ Ok(())
+}
+
+async fn request_ping(session: &Nip46Session) -> Result<String, RpcError> {
+ let req = NostrConnectRequest::Ping;
+ let response = client::request(session, req, "ping").await?;
+ let response = response
+ .to_response(NostrConnectMethod::Ping)
+ .map_err(|e| RpcError::Other(format!("nip46 ping failed: {e}")))?;
+
+ if let Some(error) = response.error {
+ return Err(RpcError::Other(format!("nip46 ping error: {error}")));
+ }
+
+ match response.result {
+ Some(ResponseResult::Pong) => Ok("pong".to_string()),
+ Some(_) => Err(RpcError::Other(
+ "nip46 ping unexpected response".to_string(),
+ )),
+ None => Err(RpcError::Other("nip46 ping missing response".to_string())),
+ }
+}
diff --git a/src/transport/jsonrpc/methods/nip46/session_close.rs b/src/transport/jsonrpc/methods/nip46/session_close.rs
@@ -0,0 +1,29 @@
+use anyhow::Result;
+use jsonrpsee::server::RpcModule;
+use serde::{Deserialize, Serialize};
+
+use crate::transport::jsonrpc::{MethodRegistry, RpcContext, RpcError};
+
+#[derive(Debug, Deserialize)]
+struct Nip46SessionCloseParams {
+ session_id: String,
+}
+
+#[derive(Clone, Debug, Serialize)]
+struct Nip46SessionCloseResponse {
+ closed: bool,
+}
+
+pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> {
+ registry.track("nip46.session.close");
+ m.register_async_method("nip46.session.close", |params, ctx, _| async move {
+ let Nip46SessionCloseParams { session_id } = params
+ .parse()
+ .map_err(|e| RpcError::InvalidParams(e.to_string()))?;
+ let closed = ctx.state.nip46_sessions.remove(&session_id).await;
+ Ok::<Nip46SessionCloseResponse, RpcError>(Nip46SessionCloseResponse {
+ closed,
+ })
+ })?;
+ Ok(())
+}
diff --git a/src/transport/jsonrpc/methods/nip46/session_status.rs b/src/transport/jsonrpc/methods/nip46/session_status.rs
@@ -0,0 +1,50 @@
+use anyhow::Result;
+use jsonrpsee::server::RpcModule;
+use serde::{Deserialize, Serialize};
+
+use crate::transport::jsonrpc::{MethodRegistry, RpcContext, RpcError};
+
+#[derive(Debug, Deserialize)]
+struct Nip46SessionStatusParams {
+ session_id: String,
+}
+
+#[derive(Clone, Debug, Serialize)]
+struct Nip46SessionStatusResponse {
+ session_id: String,
+ client_pubkey: String,
+ remote_signer_pubkey: String,
+ user_pubkey: Option<String>,
+ relays: Vec<String>,
+ perms: Vec<String>,
+ name: Option<String>,
+ url: Option<String>,
+ image: Option<String>,
+}
+
+pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> {
+ registry.track("nip46.session.status");
+ m.register_async_method("nip46.session.status", |params, ctx, _| async move {
+ let Nip46SessionStatusParams { session_id } = params
+ .parse()
+ .map_err(|e| RpcError::InvalidParams(e.to_string()))?;
+ let session = ctx
+ .state
+ .nip46_sessions
+ .get(&session_id)
+ .await
+ .ok_or_else(|| RpcError::InvalidParams("unknown session".to_string()))?;
+ Ok::<Nip46SessionStatusResponse, RpcError>(Nip46SessionStatusResponse {
+ session_id,
+ client_pubkey: session.client_pubkey.to_hex(),
+ remote_signer_pubkey: session.remote_signer_pubkey.to_hex(),
+ user_pubkey: session.user_pubkey.map(|pubkey| pubkey.to_hex()),
+ relays: session.relays,
+ perms: session.perms,
+ name: session.name,
+ url: session.url,
+ image: session.image,
+ })
+ })?;
+ Ok(())
+}
diff --git a/src/transport/jsonrpc/methods/nip46/status.rs b/src/transport/jsonrpc/methods/nip46/status.rs
@@ -0,0 +1,18 @@
+use anyhow::Result;
+use jsonrpsee::server::RpcModule;
+use serde::Serialize;
+
+use crate::transport::jsonrpc::{MethodRegistry, RpcContext, RpcError};
+
+#[derive(Clone, Debug, Serialize)]
+struct Nip46StatusResponse {
+ ready: bool,
+}
+
+pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> {
+ registry.track("nip46.status");
+ m.register_method("nip46.status", |_p, _ctx, _| {
+ Ok::<Nip46StatusResponse, RpcError>(Nip46StatusResponse { ready: true })
+ })?;
+ Ok(())
+}
diff --git a/src/transport/jsonrpc/mod.rs b/src/transport/jsonrpc/mod.rs
@@ -0,0 +1,39 @@
+#![forbid(unsafe_code)]
+
+use std::net::SocketAddr;
+
+use anyhow::Result;
+use jsonrpsee::server::{RpcModule, ServerHandle};
+
+use crate::app::config::RpcConfig;
+use crate::core::Radrootsd;
+
+mod context;
+mod error;
+mod params;
+mod registry;
+mod server;
+
+pub mod methods;
+pub mod nip46;
+
+pub use context::RpcContext;
+pub use error::RpcError;
+pub use registry::MethodRegistry;
+pub(crate) use params::DEFAULT_TIMEOUT_SECS;
+
+pub async fn start_rpc(
+ state: Radrootsd,
+ addr: SocketAddr,
+ rpc_cfg: &RpcConfig,
+) -> Result<ServerHandle> {
+ let registry = MethodRegistry::default();
+ let ctx = RpcContext::new(state, registry.clone());
+ let server = server::build_server(addr, rpc_cfg).await?;
+
+ let mut root = RpcModule::new(ctx.clone());
+ methods::register_all(&mut root, ctx, registry)?;
+
+ let handle = server.start(root);
+ Ok(handle)
+}
diff --git a/src/transport/jsonrpc/nip46/client.rs b/src/transport/jsonrpc/nip46/client.rs
@@ -0,0 +1,156 @@
+#![forbid(unsafe_code)]
+
+use std::time::Duration;
+
+use crate::core::nip46::session::Nip46Session;
+use crate::transport::jsonrpc::{params::DEFAULT_TIMEOUT_SECS, RpcError};
+use radroots_nostr::prelude::{
+ radroots_nostr_filter_tag,
+ RadrootsNostrEventBuilder,
+ RadrootsNostrFilter,
+ RadrootsNostrKind,
+ RadrootsNostrRelayPoolNotification,
+ RadrootsNostrTimestamp,
+};
+use nostr::nips::{
+ nip44,
+ nip46::{NostrConnectMessage, NostrConnectMethod, NostrConnectRequest, ResponseResult},
+};
+use nostr::JsonUtil;
+use nostr::UnsignedEvent;
+use tokio::sync::broadcast;
+use tokio::time::sleep;
+
+pub async fn sign_event(
+ session: &Nip46Session,
+ unsigned: UnsignedEvent,
+ label: &str,
+) -> Result<nostr::Event, RpcError> {
+ let req = NostrConnectRequest::SignEvent(unsigned);
+ let response = request(session, req, label).await?;
+ let response = response
+ .to_response(NostrConnectMethod::SignEvent)
+ .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?;
+
+ if let Some(error) = response.error {
+ return Err(RpcError::Other(format!("nip46 {label} error: {error}")));
+ }
+
+ let event = match response.result {
+ Some(ResponseResult::SignEvent(event)) => *event,
+ Some(_) => {
+ return Err(RpcError::Other(format!(
+ "nip46 {label} unexpected response"
+ )))
+ }
+ None => {
+ return Err(RpcError::Other(format!(
+ "nip46 {label} missing response"
+ )))
+ }
+ };
+
+ event
+ .verify()
+ .map_err(|e| RpcError::Other(format!("nip46 {label} invalid event: {e}")))?;
+
+ Ok(event)
+}
+
+pub async fn request(
+ session: &Nip46Session,
+ request: NostrConnectRequest,
+ label: &str,
+) -> Result<NostrConnectMessage, RpcError> {
+ let request_id = send_request(session, request, label).await?;
+ wait_for_response(session, &request_id, label).await
+}
+
+async fn send_request(
+ session: &Nip46Session,
+ request: NostrConnectRequest,
+ label: &str,
+) -> Result<String, RpcError> {
+ session.client.connect().await;
+ session
+ .client
+ .wait_for_connection(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
+ .await;
+
+ let message = NostrConnectMessage::request(&request);
+ let request_id = message.id().to_string();
+ let event = RadrootsNostrEventBuilder::nostr_connect(
+ &session.client_keys,
+ session.remote_signer_pubkey.clone(),
+ message,
+ )
+ .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?;
+
+ session
+ .client
+ .send_event_builder(event)
+ .await
+ .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?;
+
+ Ok(request_id)
+}
+
+async fn wait_for_response(
+ session: &Nip46Session,
+ request_id: &str,
+ label: &str,
+) -> Result<NostrConnectMessage, RpcError> {
+ let filter = RadrootsNostrFilter::new()
+ .kind(RadrootsNostrKind::NostrConnect)
+ .author(session.remote_signer_pubkey.clone())
+ .since(RadrootsNostrTimestamp::now());
+ let filter = radroots_nostr_filter_tag(filter, "p", vec![session.client_pubkey.to_hex()])
+ .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?;
+ let mut notifications = session.client.notifications();
+ let subscription = session
+ .client
+ .subscribe(filter, None)
+ .await
+ .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?;
+ let timeout = sleep(Duration::from_secs(DEFAULT_TIMEOUT_SECS));
+ tokio::pin!(timeout);
+
+ loop {
+ tokio::select! {
+ _ = &mut timeout => {
+ session.client.unsubscribe(&subscription.val).await;
+ return Err(RpcError::Other(format!("nip46 {label} response not found")));
+ }
+ msg = notifications.recv() => {
+ let notification = match msg {
+ Ok(notification) => notification,
+ Err(broadcast::error::RecvError::Lagged(_)) => continue,
+ Err(broadcast::error::RecvError::Closed) => {
+ return Err(RpcError::Other(format!("nip46 {label} notification closed")));
+ }
+ };
+ let RadrootsNostrRelayPoolNotification::Event { event, .. } = notification else {
+ continue;
+ };
+ let event = (*event).clone();
+ if event.kind != RadrootsNostrKind::NostrConnect
+ || event.pubkey != session.remote_signer_pubkey
+ {
+ continue;
+ }
+ let decrypted = nip44::decrypt(
+ session.client_keys.secret_key(),
+ &session.remote_signer_pubkey,
+ &event.content,
+ )
+ .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?;
+ let message = NostrConnectMessage::from_json(&decrypted)
+ .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?;
+ if message.is_response() && message.id() == request_id {
+ session.client.unsubscribe(&subscription.val).await;
+ return Ok(message);
+ }
+ }
+ }
+ }
+}
diff --git a/src/transport/jsonrpc/nip46/connection.rs b/src/transport/jsonrpc/nip46/connection.rs
@@ -0,0 +1,99 @@
+use radroots_nostr::prelude::radroots_nostr_parse_pubkey;
+use serde::{Deserialize, Serialize};
+use url::Url;
+
+use crate::transport::jsonrpc::RpcError;
+
+#[derive(Clone, Debug, Serialize)]
+pub enum Nip46ConnectMode {
+ Bunker,
+ Nostrconnect,
+}
+
+#[derive(Clone, Debug)]
+pub struct Nip46ConnectInfo {
+ pub mode: Nip46ConnectMode,
+ pub relays: Vec<String>,
+ pub remote_signer_pubkey: Option<String>,
+ pub client_pubkey: Option<String>,
+ pub secret: Option<String>,
+ pub perms: Vec<String>,
+ pub name: Option<String>,
+ pub url: Option<String>,
+ pub image: Option<String>,
+}
+
+#[derive(Debug, Deserialize)]
+struct Nip46ConnectQuery {
+ relay: Option<Vec<String>>,
+ secret: Option<String>,
+ perms: Option<String>,
+ name: Option<String>,
+ url: Option<String>,
+ image: Option<String>,
+}
+
+pub fn parse_connect_url(raw: &str) -> Result<Nip46ConnectInfo, RpcError> {
+ let url = Url::parse(raw).map_err(|e| RpcError::InvalidParams(e.to_string()))?;
+ match url.scheme() {
+ "bunker" => parse_bunker_url(&url),
+ "nostrconnect" => parse_nostrconnect_url(&url),
+ _ => Err(RpcError::InvalidParams("unsupported scheme".to_string())),
+ }
+}
+
+fn parse_bunker_url(url: &Url) -> Result<Nip46ConnectInfo, RpcError> {
+ let remote_signer_pubkey = url.host_str().map(|host| host.to_string());
+ let query: Nip46ConnectQuery =
+ serde_qs::from_str(url.query().unwrap_or_default())
+ .map_err(|e| RpcError::InvalidParams(e.to_string()))?;
+ let relays = query.relay.unwrap_or_default();
+ let perms = parse_perms(query.perms);
+
+ Ok(Nip46ConnectInfo {
+ mode: Nip46ConnectMode::Bunker,
+ relays,
+ remote_signer_pubkey,
+ client_pubkey: None,
+ secret: query.secret,
+ perms,
+ name: query.name,
+ url: query.url,
+ image: query.image,
+ })
+}
+
+fn parse_nostrconnect_url(url: &Url) -> Result<Nip46ConnectInfo, RpcError> {
+ let client_pubkey = url
+ .host_str()
+ .map(|host| host.to_string())
+ .ok_or_else(|| RpcError::InvalidParams("missing client pubkey".to_string()))?;
+ radroots_nostr_parse_pubkey(&client_pubkey)
+ .map_err(|e| RpcError::InvalidParams(format!("invalid client pubkey: {e}")))?;
+ let query: Nip46ConnectQuery =
+ serde_qs::from_str(url.query().unwrap_or_default())
+ .map_err(|e| RpcError::InvalidParams(e.to_string()))?;
+ let relays = query.relay.unwrap_or_default();
+ let perms = parse_perms(query.perms);
+
+ Ok(Nip46ConnectInfo {
+ mode: Nip46ConnectMode::Nostrconnect,
+ relays,
+ remote_signer_pubkey: None,
+ client_pubkey: Some(client_pubkey),
+ secret: query.secret,
+ perms,
+ name: query.name,
+ url: query.url,
+ image: query.image,
+ })
+}
+
+fn parse_perms(perms: Option<String>) -> Vec<String> {
+ perms
+ .unwrap_or_default()
+ .split(',')
+ .map(|entry| entry.trim().to_string())
+ .filter(|entry| !entry.is_empty())
+ .collect()
+}
diff --git a/src/transport/jsonrpc/nip46/mod.rs b/src/transport/jsonrpc/nip46/mod.rs
@@ -0,0 +1,2 @@
+pub mod client;
+pub mod connection;
diff --git a/src/transport/jsonrpc/params.rs b/src/transport/jsonrpc/params.rs
@@ -0,0 +1 @@
+pub const DEFAULT_TIMEOUT_SECS: u64 = 10;
diff --git a/src/transport/jsonrpc/registry.rs b/src/transport/jsonrpc/registry.rs
@@ -0,0 +1,23 @@
+#![forbid(unsafe_code)]
+
+use std::sync::{Arc, RwLock};
+
+#[derive(Clone, Default)]
+pub struct MethodRegistry {
+ inner: Arc<RwLock<Vec<String>>>,
+}
+
+impl MethodRegistry {
+ pub fn track(&self, name: &'static str) {
+ let mut methods = self.inner.write().unwrap_or_else(|e| e.into_inner());
+ if methods.iter().any(|entry| entry == name) {
+ return;
+ }
+ methods.push(name.to_string());
+ methods.sort();
+ }
+
+ pub fn list(&self) -> Vec<String> {
+ self.inner.read().unwrap_or_else(|e| e.into_inner()).clone()
+ }
+}
diff --git a/src/transport/jsonrpc/server.rs b/src/transport/jsonrpc/server.rs
@@ -0,0 +1,30 @@
+#![forbid(unsafe_code)]
+
+use std::net::SocketAddr;
+
+use anyhow::Result;
+use jsonrpsee::server::{BatchRequestConfig, Server, ServerBuilder, ServerConfigBuilder};
+
+use crate::app::config::RpcConfig;
+
+pub async fn build_server(addr: SocketAddr, rpc_cfg: &RpcConfig) -> Result<Server> {
+ let mut builder = ServerConfigBuilder::new()
+ .max_request_body_size(rpc_cfg.max_request_body_size)
+ .max_response_body_size(rpc_cfg.max_response_body_size)
+ .max_connections(rpc_cfg.max_connections)
+ .max_subscriptions_per_connection(rpc_cfg.max_subscriptions_per_connection)
+ .set_message_buffer_capacity(rpc_cfg.message_buffer_capacity);
+
+ if let Some(limit) = rpc_cfg.batch_request_limit {
+ let cfg = if limit == 0 {
+ BatchRequestConfig::Disabled
+ } else {
+ BatchRequestConfig::Limit(limit)
+ };
+ builder = builder.set_batch_request_config(cfg);
+ }
+
+ let server_cfg = builder.build();
+ let server = ServerBuilder::with_config(server_cfg).build(addr).await?;
+ Ok(server)
+}
diff --git a/src/transport/nostr/listener.rs b/src/transport/nostr/listener.rs
@@ -1,12 +1,16 @@
use std::time::Duration;
use anyhow::{anyhow, Result};
+use nostr::nips::nip44;
+use nostr::nips::nip46::{NostrConnectMessage, NostrConnectRequest, NostrConnectResponse, ResponseResult};
+use nostr::JsonUtil;
use tokio::sync::broadcast;
use tracing::{info, warn};
use crate::core::state::Radrootsd;
use radroots_nostr::prelude::{
radroots_nostr_filter_tag,
+ RadrootsNostrEventBuilder,
RadrootsNostrFilter,
RadrootsNostrKind,
RadrootsNostrRelayPoolNotification,
@@ -55,6 +59,64 @@ async fn run_nip46_listener(radrootsd: Radrootsd) -> Result<()> {
if event.kind != RadrootsNostrKind::NostrConnect {
continue;
}
- info!("NIP-46 request received: {}", event.id);
+
+ let decrypted = match nip44::decrypt(
+ radrootsd.keys.secret_key(),
+ &event.pubkey,
+ &event.content,
+ ) {
+ Ok(value) => value,
+ Err(err) => {
+ warn!("NIP-46 decrypt failed: {err}");
+ continue;
+ }
+ };
+ let message = match NostrConnectMessage::from_json(&decrypted) {
+ Ok(value) => value,
+ Err(err) => {
+ warn!("NIP-46 parse failed: {err}");
+ continue;
+ }
+ };
+ if !message.is_request() {
+ continue;
+ }
+
+ let request_id = message.id().to_string();
+ let request = match message.to_request() {
+ Ok(value) => value,
+ Err(err) => {
+ warn!("NIP-46 request invalid: {err}");
+ continue;
+ }
+ };
+ let response = handle_request(&radrootsd, request);
+ let response_message = NostrConnectMessage::response(request_id, response);
+ let response_event = RadrootsNostrEventBuilder::nostr_connect(
+ &radrootsd.keys,
+ event.pubkey,
+ response_message,
+ )
+ .map_err(|err| anyhow!("nip46 response build failed: {err}"))?;
+ let _ = radrootsd.client.send_event_builder(response_event).await;
+ }
+}
+
+fn handle_request(radrootsd: &Radrootsd, request: NostrConnectRequest) -> NostrConnectResponse {
+ match request {
+ NostrConnectRequest::Connect {
+ remote_signer_public_key,
+ ..
+ } => {
+ if remote_signer_public_key != radrootsd.pubkey {
+ return NostrConnectResponse::with_error("remote signer pubkey mismatch");
+ }
+ NostrConnectResponse::with_result(ResponseResult::Ack)
+ }
+ NostrConnectRequest::GetPublicKey => {
+ NostrConnectResponse::with_result(ResponseResult::GetPublicKey(radrootsd.pubkey))
+ }
+ NostrConnectRequest::Ping => NostrConnectResponse::with_result(ResponseResult::Pong),
+ _ => NostrConnectResponse::with_error("unsupported request"),
}
}