commit 3c2350da7924ed713aabfac941caa683f636eea7
parent e7eb1e4ad114630fe71de5596c9df2cc6d4e67e3
Author: triesap <tyson@radroots.org>
Date: Sat, 21 Mar 2026 11:49:04 +0000
nostr-connect: add nip-46 protocol crate
Diffstat:
14 files changed, 2265 insertions(+), 0 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -2237,6 +2237,17 @@ dependencies = [
]
[[package]]
+name = "radroots-nostr-connect"
+version = "0.1.0-alpha.1"
+dependencies = [
+ "nostr",
+ "serde",
+ "serde_json",
+ "thiserror 1.0.69",
+ "url",
+]
+
+[[package]]
name = "radroots-nostr-ndb"
version = "0.1.0-alpha.1"
dependencies = [
diff --git a/Cargo.toml b/Cargo.toml
@@ -11,6 +11,7 @@ members = [
"crates/net-core",
"crates/nostr",
"crates/nostr-accounts",
+ "crates/nostr-connect",
"crates/nostr-ndb",
"crates/nostr-runtime",
"crates/runtime",
@@ -45,6 +46,7 @@ radroots-events-indexed = { path = "crates/events-indexed", version = "0.1.0-alp
radroots-identity = { path = "crates/identity", version = "0.1.0-alpha.1", default-features = false }
radroots-nostr = { path = "crates/nostr", version = "0.1.0-alpha.1", default-features = false }
radroots-nostr-accounts = { path = "crates/nostr-accounts", version = "0.1.0-alpha.1", default-features = false }
+radroots-nostr-connect = { path = "crates/nostr-connect", version = "0.1.0-alpha.1", default-features = false }
radroots-nostr-ndb = { path = "crates/nostr-ndb", version = "0.1.0-alpha.1", default-features = false }
radroots-runtime = { path = "crates/runtime", version = "0.1.0-alpha.1", default-features = false }
radroots-log = { path = "crates/log", version = "0.1.0-alpha.1", default-features = false }
diff --git a/contract/coverage/policy.toml b/contract/coverage/policy.toml
@@ -22,6 +22,7 @@ crates = [
"radroots-net",
"radroots-nostr",
"radroots-nostr-accounts",
+ "radroots-nostr-connect",
"radroots-nostr-ndb",
"radroots-nostr-runtime",
"radroots-runtime",
diff --git a/contract/release/publish-set.toml b/contract/release/publish-set.toml
@@ -14,6 +14,7 @@ crates = [
"radroots-events-codec-wasm",
"radroots-events-indexed",
"radroots-nostr",
+ "radroots-nostr-connect",
"radroots-replica-db-schema",
"radroots-sql-wasm-bridge",
"radroots-nostr-runtime",
@@ -45,6 +46,7 @@ crates = [
"radroots-events-codec-wasm",
"radroots-events-indexed",
"radroots-nostr",
+ "radroots-nostr-connect",
"radroots-replica-db-schema",
"radroots-sql-wasm-bridge",
"radroots-nostr-runtime",
diff --git a/crates/nostr-connect/Cargo.toml b/crates/nostr-connect/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "radroots-nostr-connect"
+version = "0.1.0-alpha.1"
+edition.workspace = true
+authors = [
+ "Radroots Authors",
+]
+rust-version.workspace = true
+license.workspace = true
+description = "nip-46 protocol and uri primitives for the radroots sdk"
+repository.workspace = true
+homepage.workspace = true
+documentation = "https://docs.rs/radroots-nostr-connect"
+readme.workspace = true
+
+[dependencies]
+nostr = { workspace = true }
+serde = { workspace = true, features = ["derive"] }
+serde_json = { workspace = true }
+thiserror = { workspace = true }
+url = { workspace = true }
diff --git a/crates/nostr-connect/src/error.rs b/crates/nostr-connect/src/error.rs
@@ -0,0 +1,45 @@
+use thiserror::Error;
+
+#[derive(Debug, Error, Clone, PartialEq, Eq)]
+pub enum RadrootsNostrConnectError {
+ #[error("invalid NIP-46 method `{0}`")]
+ InvalidMethod(String),
+ #[error("invalid NIP-46 permission `{0}`")]
+ InvalidPermission(String),
+ #[error("invalid public key `{value}`: {reason}")]
+ InvalidPublicKey { value: String, reason: String },
+ #[error("invalid relay url `{value}`: {reason}")]
+ InvalidRelayUrl { value: String, reason: String },
+ #[error("invalid url `{value}`: {reason}")]
+ InvalidUrl { value: String, reason: String },
+ #[error("invalid URI scheme `{0}`")]
+ InvalidUriScheme(String),
+ #[error("invalid NIP-46 uri")]
+ InvalidUri,
+ #[error("missing public key in URI authority")]
+ MissingPublicKey,
+ #[error("missing relay in URI")]
+ MissingRelay,
+ #[error("missing secret in nostrconnect uri")]
+ MissingSecret,
+ #[error("missing response result")]
+ MissingResult,
+ #[error("invalid parameter count for method `{method}`: expected {expected}, got {received}")]
+ InvalidParams {
+ method: String,
+ expected: &'static str,
+ received: usize,
+ },
+ #[error("invalid request payload for method `{method}`: {reason}")]
+ InvalidRequestPayload { method: String, reason: String },
+ #[error("invalid response payload for method `{method}`: {reason}")]
+ InvalidResponsePayload { method: String, reason: String },
+ #[error("JSON error: {0}")]
+ Json(String),
+}
+
+impl From<serde_json::Error> for RadrootsNostrConnectError {
+ fn from(value: serde_json::Error) -> Self {
+ Self::Json(value.to_string())
+ }
+}
diff --git a/crates/nostr-connect/src/lib.rs b/crates/nostr-connect/src/lib.rs
@@ -0,0 +1,22 @@
+#![forbid(unsafe_code)]
+
+pub mod error;
+pub mod message;
+pub mod method;
+pub mod permission;
+pub mod uri;
+
+pub mod prelude {
+ pub use crate::error::RadrootsNostrConnectError;
+ pub use crate::message::{
+ RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectRequest,
+ RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse,
+ RadrootsNostrConnectResponseEnvelope,
+ };
+ pub use crate::method::RadrootsNostrConnectMethod;
+ pub use crate::permission::{RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions};
+ pub use crate::uri::{
+ RadrootsNostrConnectBunkerUri, RadrootsNostrConnectClientMetadata,
+ RadrootsNostrConnectClientUri, RadrootsNostrConnectUri,
+ };
+}
diff --git a/crates/nostr-connect/src/message.rs b/crates/nostr-connect/src/message.rs
@@ -0,0 +1,549 @@
+use crate::error::RadrootsNostrConnectError;
+use crate::method::RadrootsNostrConnectMethod;
+use crate::permission::RadrootsNostrConnectPermissions;
+use nostr::{Event, JsonUtil, PublicKey, RelayUrl, UnsignedEvent};
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+use serde_json::Value;
+use std::str::FromStr;
+use url::Url;
+
+pub const RADROOTS_NOSTR_CONNECT_RPC_KIND: u16 = 24_133;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RadrootsNostrConnectRequest {
+ Connect {
+ remote_signer_public_key: PublicKey,
+ secret: Option<String>,
+ requested_permissions: RadrootsNostrConnectPermissions,
+ },
+ GetPublicKey,
+ SignEvent(UnsignedEvent),
+ Nip04Encrypt {
+ public_key: PublicKey,
+ plaintext: String,
+ },
+ Nip04Decrypt {
+ public_key: PublicKey,
+ ciphertext: String,
+ },
+ Nip44Encrypt {
+ public_key: PublicKey,
+ plaintext: String,
+ },
+ Nip44Decrypt {
+ public_key: PublicKey,
+ ciphertext: String,
+ },
+ Ping,
+ SwitchRelays,
+ Custom {
+ method: RadrootsNostrConnectMethod,
+ params: Vec<String>,
+ },
+}
+
+impl RadrootsNostrConnectRequest {
+ pub fn method(&self) -> RadrootsNostrConnectMethod {
+ match self {
+ Self::Connect { .. } => RadrootsNostrConnectMethod::Connect,
+ Self::GetPublicKey => RadrootsNostrConnectMethod::GetPublicKey,
+ Self::SignEvent(_) => RadrootsNostrConnectMethod::SignEvent,
+ Self::Nip04Encrypt { .. } => RadrootsNostrConnectMethod::Nip04Encrypt,
+ Self::Nip04Decrypt { .. } => RadrootsNostrConnectMethod::Nip04Decrypt,
+ Self::Nip44Encrypt { .. } => RadrootsNostrConnectMethod::Nip44Encrypt,
+ Self::Nip44Decrypt { .. } => RadrootsNostrConnectMethod::Nip44Decrypt,
+ Self::Ping => RadrootsNostrConnectMethod::Ping,
+ Self::SwitchRelays => RadrootsNostrConnectMethod::SwitchRelays,
+ Self::Custom { method, .. } => method.clone(),
+ }
+ }
+
+ pub fn to_params(&self) -> Vec<String> {
+ match self {
+ Self::Connect {
+ remote_signer_public_key,
+ secret,
+ requested_permissions,
+ } => {
+ let mut params = vec![remote_signer_public_key.to_hex()];
+ let normalized_secret = secret.as_ref().filter(|value| !value.is_empty()).cloned();
+ if normalized_secret.is_some() || !requested_permissions.is_empty() {
+ params.push(normalized_secret.unwrap_or_default());
+ }
+ if !requested_permissions.is_empty() {
+ params.push(requested_permissions.to_string());
+ }
+ params
+ }
+ Self::GetPublicKey | Self::Ping | Self::SwitchRelays => Vec::new(),
+ Self::SignEvent(unsigned_event) => vec![unsigned_event.as_json()],
+ Self::Nip04Encrypt {
+ public_key,
+ plaintext,
+ }
+ | Self::Nip44Encrypt {
+ public_key,
+ plaintext,
+ } => vec![public_key.to_hex(), plaintext.clone()],
+ Self::Nip04Decrypt {
+ public_key,
+ ciphertext,
+ }
+ | Self::Nip44Decrypt {
+ public_key,
+ ciphertext,
+ } => vec![public_key.to_hex(), ciphertext.clone()],
+ Self::Custom { params, .. } => params.clone(),
+ }
+ }
+
+ pub fn from_parts(
+ method: RadrootsNostrConnectMethod,
+ params: Vec<String>,
+ ) -> Result<Self, RadrootsNostrConnectError> {
+ match method {
+ RadrootsNostrConnectMethod::Connect => {
+ if params.is_empty() || params.len() > 3 {
+ return Err(RadrootsNostrConnectError::InvalidParams {
+ method: method.to_string(),
+ expected: "1 to 3 params",
+ received: params.len(),
+ });
+ }
+ let remote_signer_public_key = parse_public_key(¶ms[0])?;
+ let secret = params
+ .get(1)
+ .cloned()
+ .and_then(|value| if value.is_empty() { None } else { Some(value) });
+ let requested_permissions = match params.get(2) {
+ Some(value) => RadrootsNostrConnectPermissions::from_str(value)?,
+ None => RadrootsNostrConnectPermissions::default(),
+ };
+ Ok(Self::Connect {
+ remote_signer_public_key,
+ secret,
+ requested_permissions,
+ })
+ }
+ RadrootsNostrConnectMethod::GetPublicKey => {
+ expect_param_count(&method, ¶ms, 0)?;
+ Ok(Self::GetPublicKey)
+ }
+ RadrootsNostrConnectMethod::SignEvent => {
+ expect_param_count(&method, ¶ms, 1)?;
+ let unsigned_event = serde_json::from_str(¶ms[0]).map_err(|error| {
+ RadrootsNostrConnectError::InvalidRequestPayload {
+ method: method.to_string(),
+ reason: error.to_string(),
+ }
+ })?;
+ Ok(Self::SignEvent(unsigned_event))
+ }
+ RadrootsNostrConnectMethod::Nip04Encrypt => {
+ expect_param_count(&method, ¶ms, 2)?;
+ Ok(Self::Nip04Encrypt {
+ public_key: parse_public_key(¶ms[0])?,
+ plaintext: params[1].clone(),
+ })
+ }
+ RadrootsNostrConnectMethod::Nip04Decrypt => {
+ expect_param_count(&method, ¶ms, 2)?;
+ Ok(Self::Nip04Decrypt {
+ public_key: parse_public_key(¶ms[0])?,
+ ciphertext: params[1].clone(),
+ })
+ }
+ RadrootsNostrConnectMethod::Nip44Encrypt => {
+ expect_param_count(&method, ¶ms, 2)?;
+ Ok(Self::Nip44Encrypt {
+ public_key: parse_public_key(¶ms[0])?,
+ plaintext: params[1].clone(),
+ })
+ }
+ RadrootsNostrConnectMethod::Nip44Decrypt => {
+ expect_param_count(&method, ¶ms, 2)?;
+ Ok(Self::Nip44Decrypt {
+ public_key: parse_public_key(¶ms[0])?,
+ ciphertext: params[1].clone(),
+ })
+ }
+ RadrootsNostrConnectMethod::Ping => {
+ expect_param_count(&method, ¶ms, 0)?;
+ Ok(Self::Ping)
+ }
+ RadrootsNostrConnectMethod::SwitchRelays => {
+ expect_param_count(&method, ¶ms, 0)?;
+ Ok(Self::SwitchRelays)
+ }
+ custom => Ok(Self::Custom {
+ method: custom,
+ params,
+ }),
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RadrootsNostrConnectRequestMessage {
+ pub id: String,
+ pub request: RadrootsNostrConnectRequest,
+}
+
+impl RadrootsNostrConnectRequestMessage {
+ pub fn new(id: impl Into<String>, request: RadrootsNostrConnectRequest) -> Self {
+ Self {
+ id: id.into(),
+ request,
+ }
+ }
+
+ fn into_raw(self) -> RawRequestMessage {
+ RawRequestMessage {
+ id: self.id,
+ method: self.request.method(),
+ params: self.request.to_params(),
+ }
+ }
+
+ fn from_raw(raw: RawRequestMessage) -> Result<Self, RadrootsNostrConnectError> {
+ Ok(Self {
+ id: raw.id,
+ request: RadrootsNostrConnectRequest::from_parts(raw.method, raw.params)?,
+ })
+ }
+}
+
+impl Serialize for RadrootsNostrConnectRequestMessage {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ self.clone().into_raw().serialize(serializer)
+ }
+}
+
+impl<'de> Deserialize<'de> for RadrootsNostrConnectRequestMessage {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ let raw = RawRequestMessage::deserialize(deserializer)?;
+ Self::from_raw(raw).map_err(serde::de::Error::custom)
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct RadrootsNostrConnectResponseEnvelope {
+ pub id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub result: Option<Value>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub error: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RadrootsNostrConnectResponse {
+ ConnectAcknowledged,
+ ConnectSecretEcho(String),
+ UserPublicKey(PublicKey),
+ SignedEvent(Event),
+ Pong,
+ Nip04Encrypt(String),
+ Nip04Decrypt(String),
+ Nip44Encrypt(String),
+ Nip44Decrypt(String),
+ RelayList(Vec<RelayUrl>),
+ RelayListUnchanged,
+ AuthUrl(String),
+ Error {
+ result: Option<Value>,
+ error: String,
+ },
+ Custom {
+ result: Option<Value>,
+ error: Option<String>,
+ },
+}
+
+impl RadrootsNostrConnectResponse {
+ pub fn into_envelope(
+ self,
+ id: impl Into<String>,
+ ) -> Result<RadrootsNostrConnectResponseEnvelope, RadrootsNostrConnectError> {
+ let id = id.into();
+ let envelope = match self {
+ Self::ConnectAcknowledged => RadrootsNostrConnectResponseEnvelope {
+ id,
+ result: Some(Value::String("ack".to_owned())),
+ error: None,
+ },
+ Self::ConnectSecretEcho(secret) => RadrootsNostrConnectResponseEnvelope {
+ id,
+ result: Some(Value::String(secret)),
+ error: None,
+ },
+ Self::UserPublicKey(public_key) => RadrootsNostrConnectResponseEnvelope {
+ id,
+ result: Some(Value::String(public_key.to_hex())),
+ error: None,
+ },
+ Self::SignedEvent(event) => RadrootsNostrConnectResponseEnvelope {
+ id,
+ result: Some(Value::String(event.as_json())),
+ error: None,
+ },
+ Self::Pong => RadrootsNostrConnectResponseEnvelope {
+ id,
+ result: Some(Value::String("pong".to_owned())),
+ error: None,
+ },
+ Self::Nip04Encrypt(text)
+ | Self::Nip04Decrypt(text)
+ | Self::Nip44Encrypt(text)
+ | Self::Nip44Decrypt(text) => RadrootsNostrConnectResponseEnvelope {
+ id,
+ result: Some(Value::String(text)),
+ error: None,
+ },
+ Self::RelayList(relays) => {
+ let relays = relays
+ .into_iter()
+ .map(|relay| relay.to_string())
+ .collect::<Vec<_>>();
+ RadrootsNostrConnectResponseEnvelope {
+ id,
+ result: Some(Value::Array(
+ relays.into_iter().map(Value::String).collect(),
+ )),
+ error: None,
+ }
+ }
+ Self::RelayListUnchanged => RadrootsNostrConnectResponseEnvelope {
+ id,
+ result: Some(Value::Null),
+ error: None,
+ },
+ Self::AuthUrl(url) => {
+ let normalized = validate_url(&url)?;
+ RadrootsNostrConnectResponseEnvelope {
+ id,
+ result: Some(Value::String("auth_url".to_owned())),
+ error: Some(normalized),
+ }
+ }
+ Self::Error { result, error } => RadrootsNostrConnectResponseEnvelope {
+ id,
+ result,
+ error: Some(error),
+ },
+ Self::Custom { result, error } => {
+ RadrootsNostrConnectResponseEnvelope { id, result, error }
+ }
+ };
+ Ok(envelope)
+ }
+
+ pub fn from_envelope(
+ method: &RadrootsNostrConnectMethod,
+ envelope: RadrootsNostrConnectResponseEnvelope,
+ ) -> Result<Self, RadrootsNostrConnectError> {
+ if let (Some(Value::String(result)), Some(url)) = (&envelope.result, &envelope.error) {
+ if result == "auth_url" {
+ return Ok(Self::AuthUrl(validate_url(url)?));
+ }
+ }
+
+ if let Some(error) = envelope.error {
+ if let RadrootsNostrConnectMethod::Custom(_) = method {
+ return Ok(Self::Custom {
+ result: envelope.result,
+ error: Some(error),
+ });
+ }
+ return Ok(Self::Error {
+ result: envelope.result,
+ error,
+ });
+ }
+
+ match method {
+ RadrootsNostrConnectMethod::Connect => {
+ let result = expect_string_result(method, envelope.result)?;
+ if result == "ack" {
+ Ok(Self::ConnectAcknowledged)
+ } else {
+ Ok(Self::ConnectSecretEcho(result))
+ }
+ }
+ RadrootsNostrConnectMethod::GetPublicKey => {
+ let result = expect_string_result(method, envelope.result)?;
+ Ok(Self::UserPublicKey(parse_public_key(&result)?))
+ }
+ RadrootsNostrConnectMethod::SignEvent => {
+ let event = parse_json_string_result::<Event>(method, envelope.result)?;
+ Ok(Self::SignedEvent(event))
+ }
+ RadrootsNostrConnectMethod::Ping => {
+ let result = expect_string_result(method, envelope.result)?;
+ if result != "pong" {
+ return Err(RadrootsNostrConnectError::InvalidResponsePayload {
+ method: method.to_string(),
+ reason: format!("expected `pong`, got `{result}`"),
+ });
+ }
+ Ok(Self::Pong)
+ }
+ RadrootsNostrConnectMethod::Nip04Encrypt => Ok(Self::Nip04Encrypt(
+ expect_string_result(method, envelope.result)?,
+ )),
+ RadrootsNostrConnectMethod::Nip04Decrypt => Ok(Self::Nip04Decrypt(
+ expect_string_result(method, envelope.result)?,
+ )),
+ RadrootsNostrConnectMethod::Nip44Encrypt => Ok(Self::Nip44Encrypt(
+ expect_string_result(method, envelope.result)?,
+ )),
+ RadrootsNostrConnectMethod::Nip44Decrypt => Ok(Self::Nip44Decrypt(
+ expect_string_result(method, envelope.result)?,
+ )),
+ RadrootsNostrConnectMethod::SwitchRelays => {
+ parse_switch_relays_response(envelope.result)
+ }
+ RadrootsNostrConnectMethod::Custom(_) => Ok(Self::Custom {
+ result: envelope.result,
+ error: None,
+ }),
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+struct RawRequestMessage {
+ id: String,
+ method: RadrootsNostrConnectMethod,
+ params: Vec<String>,
+}
+
+fn expect_param_count(
+ method: &RadrootsNostrConnectMethod,
+ params: &[String],
+ expected: usize,
+) -> Result<(), RadrootsNostrConnectError> {
+ if params.len() == expected {
+ return Ok(());
+ }
+
+ Err(RadrootsNostrConnectError::InvalidParams {
+ method: method.to_string(),
+ expected: if expected == 0 {
+ "no params"
+ } else if expected == 1 {
+ "exactly 1 param"
+ } else {
+ "exactly 2 params"
+ },
+ received: params.len(),
+ })
+}
+
+fn parse_public_key(value: &str) -> Result<PublicKey, RadrootsNostrConnectError> {
+ PublicKey::parse(value)
+ .or_else(|_| PublicKey::from_hex(value))
+ .map_err(|error| RadrootsNostrConnectError::InvalidPublicKey {
+ value: value.to_owned(),
+ reason: error.to_string(),
+ })
+}
+
+fn expect_string_result(
+ method: &RadrootsNostrConnectMethod,
+ result: Option<Value>,
+) -> Result<String, RadrootsNostrConnectError> {
+ match result {
+ Some(Value::String(value)) => Ok(value),
+ Some(other) => Err(RadrootsNostrConnectError::InvalidResponsePayload {
+ method: method.to_string(),
+ reason: format!("expected string result, got {other}"),
+ }),
+ None => Err(RadrootsNostrConnectError::MissingResult),
+ }
+}
+
+fn parse_json_string_result<T>(
+ method: &RadrootsNostrConnectMethod,
+ result: Option<Value>,
+) -> Result<T, RadrootsNostrConnectError>
+where
+ T: for<'de> Deserialize<'de>,
+{
+ match result {
+ Some(Value::String(value)) => serde_json::from_str(&value).map_err(|error| {
+ RadrootsNostrConnectError::InvalidResponsePayload {
+ method: method.to_string(),
+ reason: error.to_string(),
+ }
+ }),
+ Some(other) => serde_json::from_value(other).map_err(|error| {
+ RadrootsNostrConnectError::InvalidResponsePayload {
+ method: method.to_string(),
+ reason: error.to_string(),
+ }
+ }),
+ None => Err(RadrootsNostrConnectError::MissingResult),
+ }
+}
+
+fn parse_switch_relays_response(
+ result: Option<Value>,
+) -> Result<RadrootsNostrConnectResponse, RadrootsNostrConnectError> {
+ let method = RadrootsNostrConnectMethod::SwitchRelays;
+ match result {
+ None | Some(Value::Null) => Ok(RadrootsNostrConnectResponse::RelayListUnchanged),
+ Some(Value::Array(values)) => {
+ let relays = parse_relay_values(values)?;
+ Ok(RadrootsNostrConnectResponse::RelayList(relays))
+ }
+ Some(Value::String(value)) if value == "null" => {
+ Ok(RadrootsNostrConnectResponse::RelayListUnchanged)
+ }
+ Some(Value::String(value)) => {
+ let parsed = serde_json::from_str::<Value>(&value).map_err(|error| {
+ RadrootsNostrConnectError::InvalidResponsePayload {
+ method: method.to_string(),
+ reason: error.to_string(),
+ }
+ })?;
+ parse_switch_relays_response(Some(parsed))
+ }
+ Some(other) => Err(RadrootsNostrConnectError::InvalidResponsePayload {
+ method: method.to_string(),
+ reason: format!("expected relay list or null, got {other}"),
+ }),
+ }
+}
+
+fn parse_relay_values(values: Vec<Value>) -> Result<Vec<RelayUrl>, RadrootsNostrConnectError> {
+ values
+ .into_iter()
+ .map(|value| match value {
+ Value::String(value) => RelayUrl::parse(&value).map_err(|error| {
+ RadrootsNostrConnectError::InvalidRelayUrl {
+ value,
+ reason: error.to_string(),
+ }
+ }),
+ other => Err(RadrootsNostrConnectError::InvalidResponsePayload {
+ method: RadrootsNostrConnectMethod::SwitchRelays.to_string(),
+ reason: format!("expected relay string, got {other}"),
+ }),
+ })
+ .collect()
+}
+
+fn validate_url(value: &str) -> Result<String, RadrootsNostrConnectError> {
+ Url::parse(value)
+ .map(|url| url.to_string())
+ .map_err(|error| RadrootsNostrConnectError::InvalidUrl {
+ value: value.to_owned(),
+ reason: error.to_string(),
+ })
+}
diff --git a/crates/nostr-connect/src/method.rs b/crates/nostr-connect/src/method.rs
@@ -0,0 +1,80 @@
+use crate::error::RadrootsNostrConnectError;
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+use std::fmt;
+use std::str::FromStr;
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub enum RadrootsNostrConnectMethod {
+ Connect,
+ GetPublicKey,
+ SignEvent,
+ Nip04Encrypt,
+ Nip04Decrypt,
+ Nip44Encrypt,
+ Nip44Decrypt,
+ Ping,
+ SwitchRelays,
+ Custom(String),
+}
+
+impl RadrootsNostrConnectMethod {
+ pub fn as_str(&self) -> &str {
+ match self {
+ Self::Connect => "connect",
+ Self::GetPublicKey => "get_public_key",
+ Self::SignEvent => "sign_event",
+ Self::Nip04Encrypt => "nip04_encrypt",
+ Self::Nip04Decrypt => "nip04_decrypt",
+ Self::Nip44Encrypt => "nip44_encrypt",
+ Self::Nip44Decrypt => "nip44_decrypt",
+ Self::Ping => "ping",
+ Self::SwitchRelays => "switch_relays",
+ Self::Custom(value) => value.as_str(),
+ }
+ }
+}
+
+impl fmt::Display for RadrootsNostrConnectMethod {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(self.as_str())
+ }
+}
+
+impl FromStr for RadrootsNostrConnectMethod {
+ type Err = RadrootsNostrConnectError;
+
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
+ match value {
+ "connect" => Ok(Self::Connect),
+ "get_public_key" => Ok(Self::GetPublicKey),
+ "sign_event" => Ok(Self::SignEvent),
+ "nip04_encrypt" => Ok(Self::Nip04Encrypt),
+ "nip04_decrypt" => Ok(Self::Nip04Decrypt),
+ "nip44_encrypt" => Ok(Self::Nip44Encrypt),
+ "nip44_decrypt" => Ok(Self::Nip44Decrypt),
+ "ping" => Ok(Self::Ping),
+ "switch_relays" => Ok(Self::SwitchRelays),
+ other if !other.trim().is_empty() => Ok(Self::Custom(other.to_owned())),
+ _ => Err(RadrootsNostrConnectError::InvalidMethod(value.to_owned())),
+ }
+ }
+}
+
+impl Serialize for RadrootsNostrConnectMethod {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ serializer.serialize_str(self.as_str())
+ }
+}
+
+impl<'de> Deserialize<'de> for RadrootsNostrConnectMethod {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ let value = String::deserialize(deserializer)?;
+ Self::from_str(&value).map_err(serde::de::Error::custom)
+ }
+}
diff --git a/crates/nostr-connect/src/permission.rs b/crates/nostr-connect/src/permission.rs
@@ -0,0 +1,142 @@
+use crate::error::RadrootsNostrConnectError;
+use crate::method::RadrootsNostrConnectMethod;
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+use std::fmt;
+use std::str::FromStr;
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct RadrootsNostrConnectPermission {
+ pub method: RadrootsNostrConnectMethod,
+ pub parameter: Option<String>,
+}
+
+impl RadrootsNostrConnectPermission {
+ pub fn new(method: RadrootsNostrConnectMethod) -> Self {
+ Self {
+ method,
+ parameter: None,
+ }
+ }
+
+ pub fn with_parameter(
+ method: RadrootsNostrConnectMethod,
+ parameter: impl Into<String>,
+ ) -> Self {
+ Self {
+ method,
+ parameter: Some(parameter.into()),
+ }
+ }
+}
+
+impl fmt::Display for RadrootsNostrConnectPermission {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self.parameter.as_deref() {
+ Some(parameter) => write!(f, "{}:{parameter}", self.method),
+ None => write!(f, "{}", self.method),
+ }
+ }
+}
+
+impl FromStr for RadrootsNostrConnectPermission {
+ type Err = RadrootsNostrConnectError;
+
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ return Err(RadrootsNostrConnectError::InvalidPermission(
+ value.to_owned(),
+ ));
+ }
+
+ let (method, parameter) = match trimmed.split_once(':') {
+ Some((method, parameter)) if !parameter.is_empty() => (method, Some(parameter)),
+ Some(_) => {
+ return Err(RadrootsNostrConnectError::InvalidPermission(
+ value.to_owned(),
+ ));
+ }
+ None => (trimmed, None),
+ };
+
+ Ok(Self {
+ method: RadrootsNostrConnectMethod::from_str(method)?,
+ parameter: parameter.map(ToOwned::to_owned),
+ })
+ }
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct RadrootsNostrConnectPermissions(Vec<RadrootsNostrConnectPermission>);
+
+impl RadrootsNostrConnectPermissions {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ pub fn as_slice(&self) -> &[RadrootsNostrConnectPermission] {
+ self.0.as_slice()
+ }
+
+ pub fn into_vec(self) -> Vec<RadrootsNostrConnectPermission> {
+ self.0
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.0.is_empty()
+ }
+}
+
+impl From<Vec<RadrootsNostrConnectPermission>> for RadrootsNostrConnectPermissions {
+ fn from(value: Vec<RadrootsNostrConnectPermission>) -> Self {
+ Self(value)
+ }
+}
+
+impl fmt::Display for RadrootsNostrConnectPermissions {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let rendered = self
+ .0
+ .iter()
+ .map(ToString::to_string)
+ .collect::<Vec<_>>()
+ .join(",");
+ f.write_str(&rendered)
+ }
+}
+
+impl FromStr for RadrootsNostrConnectPermissions {
+ type Err = RadrootsNostrConnectError;
+
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ return Ok(Self::default());
+ }
+
+ let permissions = trimmed
+ .split(',')
+ .map(RadrootsNostrConnectPermission::from_str)
+ .collect::<Result<Vec<_>, _>>()?;
+ Ok(Self(permissions))
+ }
+}
+
+impl Serialize for RadrootsNostrConnectPermissions {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ serializer.serialize_str(&self.to_string())
+ }
+}
+
+impl<'de> Deserialize<'de> for RadrootsNostrConnectPermissions {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ let value = String::deserialize(deserializer)?;
+ Self::from_str(&value).map_err(serde::de::Error::custom)
+ }
+}
diff --git a/crates/nostr-connect/src/uri.rs b/crates/nostr-connect/src/uri.rs
@@ -0,0 +1,204 @@
+use crate::error::RadrootsNostrConnectError;
+use crate::permission::RadrootsNostrConnectPermissions;
+use nostr::{PublicKey, RelayUrl};
+use serde::{Deserialize, Serialize};
+use std::fmt;
+use std::str::FromStr;
+use url::Url;
+
+pub const RADROOTS_NOSTR_CONNECT_URI_SCHEME: &str = "nostrconnect";
+pub const RADROOTS_NOSTR_CONNECT_BUNKER_URI_SCHEME: &str = "bunker";
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct RadrootsNostrConnectBunkerUri {
+ pub remote_signer_public_key: PublicKey,
+ pub relays: Vec<RelayUrl>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub secret: Option<String>,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct RadrootsNostrConnectClientMetadata {
+ #[serde(
+ default,
+ skip_serializing_if = "RadrootsNostrConnectPermissions::is_empty"
+ )]
+ pub requested_permissions: RadrootsNostrConnectPermissions,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub name: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub url: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub image: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct RadrootsNostrConnectClientUri {
+ pub client_public_key: PublicKey,
+ pub relays: Vec<RelayUrl>,
+ pub secret: String,
+ #[serde(default)]
+ pub metadata: RadrootsNostrConnectClientMetadata,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RadrootsNostrConnectUri {
+ Bunker(RadrootsNostrConnectBunkerUri),
+ Client(RadrootsNostrConnectClientUri),
+}
+
+impl RadrootsNostrConnectUri {
+ pub fn parse(value: &str) -> Result<Self, RadrootsNostrConnectError> {
+ let url = Url::parse(value).map_err(|error| RadrootsNostrConnectError::InvalidUrl {
+ value: value.to_owned(),
+ reason: error.to_string(),
+ })?;
+ let host = url
+ .host_str()
+ .ok_or(RadrootsNostrConnectError::MissingPublicKey)?;
+
+ match url.scheme() {
+ RADROOTS_NOSTR_CONNECT_BUNKER_URI_SCHEME => {
+ let remote_signer_public_key = parse_public_key(host)?;
+ let mut relays = Vec::new();
+ let mut secret = None;
+
+ for (key, value) in url.query_pairs() {
+ match key.as_ref() {
+ "relay" => relays.push(parse_relay_url(value.as_ref())?),
+ "secret" => secret = Some(value.into_owned()),
+ _ => {}
+ }
+ }
+
+ if relays.is_empty() {
+ return Err(RadrootsNostrConnectError::MissingRelay);
+ }
+
+ Ok(Self::Bunker(RadrootsNostrConnectBunkerUri {
+ remote_signer_public_key,
+ relays,
+ secret,
+ }))
+ }
+ RADROOTS_NOSTR_CONNECT_URI_SCHEME => {
+ let client_public_key = parse_public_key(host)?;
+ let mut relays = Vec::new();
+ let mut secret = None;
+ let mut metadata = RadrootsNostrConnectClientMetadata::default();
+
+ for (key, value) in url.query_pairs() {
+ match key.as_ref() {
+ "relay" => relays.push(parse_relay_url(value.as_ref())?),
+ "secret" => secret = Some(value.into_owned()),
+ "perms" => {
+ metadata.requested_permissions =
+ RadrootsNostrConnectPermissions::from_str(value.as_ref())?;
+ }
+ "name" => metadata.name = Some(value.into_owned()),
+ "url" => metadata.url = Some(validate_url(value.as_ref())?),
+ "image" => metadata.image = Some(validate_url(value.as_ref())?),
+ _ => {}
+ }
+ }
+
+ if relays.is_empty() {
+ return Err(RadrootsNostrConnectError::MissingRelay);
+ }
+
+ let secret = secret.ok_or(RadrootsNostrConnectError::MissingSecret)?;
+
+ Ok(Self::Client(RadrootsNostrConnectClientUri {
+ client_public_key,
+ relays,
+ secret,
+ metadata,
+ }))
+ }
+ scheme => Err(RadrootsNostrConnectError::InvalidUriScheme(
+ scheme.to_owned(),
+ )),
+ }
+ }
+}
+
+impl FromStr for RadrootsNostrConnectUri {
+ type Err = RadrootsNostrConnectError;
+
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
+ Self::parse(value)
+ }
+}
+
+impl fmt::Display for RadrootsNostrConnectUri {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Bunker(uri) => {
+ let mut serializer = url::form_urlencoded::Serializer::new(String::new());
+ for relay in &uri.relays {
+ serializer.append_pair("relay", &relay.to_string());
+ }
+ if let Some(secret) = &uri.secret {
+ serializer.append_pair("secret", secret);
+ }
+ let query = serializer.finish();
+ write!(
+ f,
+ "{RADROOTS_NOSTR_CONNECT_BUNKER_URI_SCHEME}://{}?{query}",
+ uri.remote_signer_public_key
+ )
+ }
+ Self::Client(uri) => {
+ let mut serializer = url::form_urlencoded::Serializer::new(String::new());
+ for relay in &uri.relays {
+ serializer.append_pair("relay", &relay.to_string());
+ }
+ serializer.append_pair("secret", &uri.secret);
+ if !uri.metadata.requested_permissions.is_empty() {
+ serializer
+ .append_pair("perms", &uri.metadata.requested_permissions.to_string());
+ }
+ if let Some(name) = &uri.metadata.name {
+ serializer.append_pair("name", name);
+ }
+ if let Some(url) = &uri.metadata.url {
+ serializer.append_pair("url", url);
+ }
+ if let Some(image) = &uri.metadata.image {
+ serializer.append_pair("image", image);
+ }
+ let query = serializer.finish();
+ write!(
+ f,
+ "{RADROOTS_NOSTR_CONNECT_URI_SCHEME}://{}?{query}",
+ uri.client_public_key
+ )
+ }
+ }
+ }
+}
+
+fn parse_public_key(value: &str) -> Result<PublicKey, RadrootsNostrConnectError> {
+ PublicKey::parse(value)
+ .or_else(|_| PublicKey::from_hex(value))
+ .map_err(|error| RadrootsNostrConnectError::InvalidPublicKey {
+ value: value.to_owned(),
+ reason: error.to_string(),
+ })
+}
+
+fn parse_relay_url(value: &str) -> Result<RelayUrl, RadrootsNostrConnectError> {
+ RelayUrl::parse(value).map_err(|error| RadrootsNostrConnectError::InvalidRelayUrl {
+ value: value.to_owned(),
+ reason: error.to_string(),
+ })
+}
+
+fn validate_url(value: &str) -> Result<String, RadrootsNostrConnectError> {
+ Url::parse(value)
+ .map(|url| url.to_string())
+ .map_err(|error| RadrootsNostrConnectError::InvalidUrl {
+ value: value.to_owned(),
+ reason: error.to_string(),
+ })
+}
diff --git a/crates/nostr-connect/tests/coverage.rs b/crates/nostr-connect/tests/coverage.rs
@@ -0,0 +1,974 @@
+use nostr::{Event, EventBuilder, Keys, PublicKey, RelayUrl, SecretKey, Timestamp, UnsignedEvent};
+use radroots_nostr_connect::prelude::{
+ RadrootsNostrConnectError, RadrootsNostrConnectMethod, RadrootsNostrConnectPermission,
+ RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest,
+ RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse,
+ RadrootsNostrConnectResponseEnvelope, RadrootsNostrConnectUri,
+};
+use serde_json::{Value, json};
+use std::str::FromStr;
+
+fn test_public_key() -> PublicKey {
+ PublicKey::parse("83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5")
+ .expect("public key")
+}
+
+fn test_keys() -> Keys {
+ let secret_key =
+ SecretKey::from_hex("6d5f4530cbf6a9e8f021eb409c8c5f2ee7ea123c76364b6f53c2d8a3507f7f5b")
+ .expect("secret key");
+ Keys::new(secret_key)
+}
+
+fn unsigned_event() -> UnsignedEvent {
+ serde_json::from_value(json!({
+ "pubkey": test_public_key().to_hex(),
+ "created_at": 1714078911u64,
+ "kind": 1u16,
+ "tags": [],
+ "content": "hello"
+ }))
+ .expect("unsigned event")
+}
+
+fn signed_event() -> Event {
+ EventBuilder::text_note("hello world")
+ .custom_created_at(Timestamp::from(1_714_078_911))
+ .sign_with_keys(&test_keys())
+ .expect("sign event")
+}
+
+fn relay(value: &str) -> RelayUrl {
+ RelayUrl::parse(value).expect("relay")
+}
+
+#[test]
+fn error_method_and_permission_surfaces_cover_public_paths() {
+ let json_error = serde_json::from_str::<Value>("{").expect_err("invalid json");
+ assert!(matches!(
+ RadrootsNostrConnectError::from(json_error),
+ RadrootsNostrConnectError::Json(message) if !message.is_empty()
+ ));
+
+ let methods = [
+ (RadrootsNostrConnectMethod::Connect, "connect"),
+ (RadrootsNostrConnectMethod::GetPublicKey, "get_public_key"),
+ (RadrootsNostrConnectMethod::SignEvent, "sign_event"),
+ (RadrootsNostrConnectMethod::Nip04Encrypt, "nip04_encrypt"),
+ (RadrootsNostrConnectMethod::Nip04Decrypt, "nip04_decrypt"),
+ (RadrootsNostrConnectMethod::Nip44Encrypt, "nip44_encrypt"),
+ (RadrootsNostrConnectMethod::Nip44Decrypt, "nip44_decrypt"),
+ (RadrootsNostrConnectMethod::Ping, "ping"),
+ (RadrootsNostrConnectMethod::SwitchRelays, "switch_relays"),
+ ];
+ for (method, raw) in methods {
+ assert_eq!(method.as_str(), raw);
+ assert_eq!(method.to_string(), raw);
+ assert_eq!(
+ RadrootsNostrConnectMethod::from_str(raw).expect("parse method"),
+ method
+ );
+ }
+ assert_eq!(
+ RadrootsNostrConnectMethod::from_str("publish_note").expect("custom method"),
+ RadrootsNostrConnectMethod::Custom("publish_note".to_owned())
+ );
+ assert!(matches!(
+ RadrootsNostrConnectMethod::from_str(" "),
+ Err(RadrootsNostrConnectError::InvalidMethod(value)) if value == " "
+ ));
+ assert_eq!(
+ serde_json::from_str::<RadrootsNostrConnectMethod>("\"do_work\"")
+ .expect("deserialize custom method"),
+ RadrootsNostrConnectMethod::Custom("do_work".to_owned())
+ );
+ assert!(
+ serde_json::from_str::<RadrootsNostrConnectMethod>("123")
+ .expect_err("non-string method")
+ .to_string()
+ .contains("invalid type")
+ );
+ assert!(
+ serde_json::from_str::<RadrootsNostrConnectMethod>("\"\"")
+ .expect_err("blank method")
+ .to_string()
+ .contains("invalid NIP-46 method")
+ );
+
+ let simple = RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Ping);
+ assert_eq!(simple.to_string(), "ping");
+ let parameterized = RadrootsNostrConnectPermission::with_parameter(
+ RadrootsNostrConnectMethod::SignEvent,
+ "1059",
+ );
+ assert_eq!(parameterized.to_string(), "sign_event:1059");
+ assert_eq!(
+ RadrootsNostrConnectPermission::from_str("sign_event:1059").expect("parse permission"),
+ parameterized
+ );
+ assert!(matches!(
+ RadrootsNostrConnectPermission::from_str(" "),
+ Err(RadrootsNostrConnectError::InvalidPermission(value)) if value == " "
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectPermission::from_str("sign_event:"),
+ Err(RadrootsNostrConnectError::InvalidPermission(value)) if value == "sign_event:"
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectPermission::from_str(" :kind"),
+ Err(RadrootsNostrConnectError::InvalidMethod(_))
+ ));
+
+ let empty = RadrootsNostrConnectPermissions::new();
+ assert!(empty.is_empty());
+ assert!(empty.as_slice().is_empty());
+ assert!(empty.clone().into_vec().is_empty());
+ assert_eq!(
+ RadrootsNostrConnectPermissions::from_str(" ").expect("empty permissions"),
+ empty
+ );
+
+ let permissions = RadrootsNostrConnectPermissions::from(vec![
+ RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip44Encrypt),
+ RadrootsNostrConnectPermission::with_parameter(RadrootsNostrConnectMethod::SignEvent, "13"),
+ ]);
+ assert_eq!(permissions.to_string(), "nip44_encrypt,sign_event:13");
+ assert_eq!(
+ serde_json::to_string(&permissions).expect("serialize permissions"),
+ "\"nip44_encrypt,sign_event:13\""
+ );
+ assert_eq!(
+ serde_json::from_str::<RadrootsNostrConnectPermissions>("\"nip44_encrypt,sign_event:13\"")
+ .expect("deserialize permissions"),
+ permissions
+ );
+ assert!(
+ serde_json::from_str::<RadrootsNostrConnectPermissions>("123")
+ .expect_err("non-string permissions")
+ .to_string()
+ .contains("invalid type")
+ );
+ assert!(matches!(
+ RadrootsNostrConnectPermissions::from_str("sign_event:,ping"),
+ Err(RadrootsNostrConnectError::InvalidPermission(value)) if value == "sign_event:"
+ ));
+}
+
+#[test]
+fn uri_surface_covers_rendering_ignored_queries_and_error_paths() {
+ let bunker = RadrootsNostrConnectUri::parse(
+ "bunker://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=wss%3A%2F%2Frelay.example.com&foo=bar",
+ )
+ .expect("parse bunker");
+ let bunker_rendered = bunker.to_string();
+ assert!(bunker_rendered.contains("relay=wss%3A%2F%2Frelay.example.com"));
+ assert!(!bunker_rendered.contains("secret="));
+
+ let minimal_client: RadrootsNostrConnectUri =
+ "nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=wss%3A%2F%2Frelay.example.com&secret=shared"
+ .parse()
+ .expect("parse minimal client");
+ let minimal_client_rendered = minimal_client.to_string();
+ assert!(minimal_client_rendered.contains("secret=shared"));
+ assert!(!minimal_client_rendered.contains("perms="));
+ assert!(!minimal_client_rendered.contains("name="));
+ assert!(!minimal_client_rendered.contains("url="));
+ assert!(!minimal_client_rendered.contains("image="));
+
+ let metadata_client = RadrootsNostrConnectUri::parse(
+ "nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=wss%3A%2F%2Frelay.example.com&secret=shared&perms=ping&name=myc&url=https%3A%2F%2Fexample.com&image=https%3A%2F%2Fexample.com%2Flogo.png&ignored=value",
+ )
+ .expect("parse metadata client");
+ let metadata_rendered = metadata_client.to_string();
+ assert!(metadata_rendered.contains("perms=ping"));
+ assert!(metadata_rendered.contains("name=myc"));
+ assert!(metadata_rendered.contains("url=https%3A%2F%2Fexample.com%2F"));
+ assert!(metadata_rendered.contains("image=https%3A%2F%2Fexample.com%2Flogo.png"));
+
+ assert!(matches!(
+ RadrootsNostrConnectUri::parse("not a uri"),
+ Err(RadrootsNostrConnectError::InvalidUrl { .. })
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectUri::parse(
+ "nostrconnect:///path?relay=wss%3A%2F%2Frelay.example.com&secret=abc"
+ ),
+ Err(RadrootsNostrConnectError::MissingPublicKey)
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectUri::parse(
+ "bunker://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5"
+ ),
+ Err(RadrootsNostrConnectError::MissingRelay)
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectUri::parse(
+ "nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?secret=abc"
+ ),
+ Err(RadrootsNostrConnectError::MissingRelay)
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectUri::parse(
+ "nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=wss%3A%2F%2Frelay.example.com"
+ ),
+ Err(RadrootsNostrConnectError::MissingSecret)
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectUri::parse("https://example.com"),
+ Err(RadrootsNostrConnectError::InvalidUriScheme(value)) if value == "https"
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectUri::parse(
+ "nostrconnect://bad-key?relay=wss%3A%2F%2Frelay.example.com&secret=abc"
+ ),
+ Err(RadrootsNostrConnectError::InvalidPublicKey { .. })
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectUri::parse(
+ "nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=http%3A%2F%2Frelay.example.com&secret=abc"
+ ),
+ Err(RadrootsNostrConnectError::InvalidRelayUrl { .. })
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectUri::parse(
+ "nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=wss%3A%2F%2Frelay.example.com&secret=abc&url=not-a-url"
+ ),
+ Err(RadrootsNostrConnectError::InvalidUrl { value, .. }) if value == "not-a-url"
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectUri::parse("bunker://bad-key?relay=wss%3A%2F%2Frelay.example.com"),
+ Err(RadrootsNostrConnectError::InvalidPublicKey { .. })
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectUri::parse(
+ "bunker://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=http%3A%2F%2Frelay.example.com"
+ ),
+ Err(RadrootsNostrConnectError::InvalidRelayUrl { .. })
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectUri::parse(
+ "nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=wss%3A%2F%2Frelay.example.com&secret=abc&perms=sign_event%3A"
+ ),
+ Err(RadrootsNostrConnectError::InvalidPermission(value)) if value == "sign_event:"
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectUri::parse(
+ "nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=wss%3A%2F%2Frelay.example.com&secret=abc&image=not-a-url"
+ ),
+ Err(RadrootsNostrConnectError::InvalidUrl { value, .. }) if value == "not-a-url"
+ ));
+}
+
+#[test]
+fn request_surface_covers_variant_methods_serialization_and_validation() {
+ let ping_permission =
+ RadrootsNostrConnectPermissions::from(vec![RadrootsNostrConnectPermission::new(
+ RadrootsNostrConnectMethod::Ping,
+ )]);
+
+ let requests = vec![
+ (
+ RadrootsNostrConnectRequest::Connect {
+ remote_signer_public_key: test_public_key(),
+ secret: None,
+ requested_permissions: RadrootsNostrConnectPermissions::default(),
+ },
+ RadrootsNostrConnectMethod::Connect,
+ vec![test_public_key().to_hex()],
+ ),
+ (
+ RadrootsNostrConnectRequest::Connect {
+ remote_signer_public_key: test_public_key(),
+ secret: None,
+ requested_permissions: ping_permission.clone(),
+ },
+ RadrootsNostrConnectMethod::Connect,
+ vec![test_public_key().to_hex(), String::new(), "ping".to_owned()],
+ ),
+ (
+ RadrootsNostrConnectRequest::GetPublicKey,
+ RadrootsNostrConnectMethod::GetPublicKey,
+ Vec::new(),
+ ),
+ (
+ RadrootsNostrConnectRequest::SignEvent(unsigned_event()),
+ RadrootsNostrConnectMethod::SignEvent,
+ vec![serde_json::to_string(&unsigned_event()).expect("serialize unsigned event")],
+ ),
+ (
+ RadrootsNostrConnectRequest::Nip04Encrypt {
+ public_key: test_public_key(),
+ plaintext: "hello".to_owned(),
+ },
+ RadrootsNostrConnectMethod::Nip04Encrypt,
+ vec![test_public_key().to_hex(), "hello".to_owned()],
+ ),
+ (
+ RadrootsNostrConnectRequest::Nip04Decrypt {
+ public_key: test_public_key(),
+ ciphertext: "cipher".to_owned(),
+ },
+ RadrootsNostrConnectMethod::Nip04Decrypt,
+ vec![test_public_key().to_hex(), "cipher".to_owned()],
+ ),
+ (
+ RadrootsNostrConnectRequest::Nip44Encrypt {
+ public_key: test_public_key(),
+ plaintext: "hello".to_owned(),
+ },
+ RadrootsNostrConnectMethod::Nip44Encrypt,
+ vec![test_public_key().to_hex(), "hello".to_owned()],
+ ),
+ (
+ RadrootsNostrConnectRequest::Nip44Decrypt {
+ public_key: test_public_key(),
+ ciphertext: "cipher".to_owned(),
+ },
+ RadrootsNostrConnectMethod::Nip44Decrypt,
+ vec![test_public_key().to_hex(), "cipher".to_owned()],
+ ),
+ (
+ RadrootsNostrConnectRequest::Ping,
+ RadrootsNostrConnectMethod::Ping,
+ Vec::new(),
+ ),
+ (
+ RadrootsNostrConnectRequest::SwitchRelays,
+ RadrootsNostrConnectMethod::SwitchRelays,
+ Vec::new(),
+ ),
+ (
+ RadrootsNostrConnectRequest::Custom {
+ method: RadrootsNostrConnectMethod::Custom("publish_note".to_owned()),
+ params: vec!["one".to_owned(), "two".to_owned()],
+ },
+ RadrootsNostrConnectMethod::Custom("publish_note".to_owned()),
+ vec!["one".to_owned(), "two".to_owned()],
+ ),
+ ];
+ for (request, method, params) in requests {
+ assert_eq!(request.method(), method);
+ assert_eq!(request.to_params(), params);
+ }
+
+ assert_eq!(
+ RadrootsNostrConnectRequest::from_parts(
+ RadrootsNostrConnectMethod::Connect,
+ vec![test_public_key().to_hex()],
+ )
+ .expect("connect without secret or perms"),
+ RadrootsNostrConnectRequest::Connect {
+ remote_signer_public_key: test_public_key(),
+ secret: None,
+ requested_permissions: RadrootsNostrConnectPermissions::default(),
+ }
+ );
+ assert_eq!(
+ RadrootsNostrConnectRequest::from_parts(
+ RadrootsNostrConnectMethod::Connect,
+ vec![test_public_key().to_hex(), String::new(), "ping".to_owned()],
+ )
+ .expect("connect with empty secret"),
+ RadrootsNostrConnectRequest::Connect {
+ remote_signer_public_key: test_public_key(),
+ secret: None,
+ requested_permissions: RadrootsNostrConnectPermissions::from(vec![
+ RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Ping),
+ ]),
+ }
+ );
+ assert_eq!(
+ RadrootsNostrConnectRequest::from_parts(
+ RadrootsNostrConnectMethod::GetPublicKey,
+ Vec::new(),
+ )
+ .expect("get_public_key from parts"),
+ RadrootsNostrConnectRequest::GetPublicKey
+ );
+ assert_eq!(
+ RadrootsNostrConnectRequest::from_parts(
+ RadrootsNostrConnectMethod::Nip04Encrypt,
+ vec![test_public_key().to_hex(), "hello".to_owned()],
+ )
+ .expect("nip04 encrypt from parts"),
+ RadrootsNostrConnectRequest::Nip04Encrypt {
+ public_key: test_public_key(),
+ plaintext: "hello".to_owned(),
+ }
+ );
+ assert_eq!(
+ RadrootsNostrConnectRequest::from_parts(
+ RadrootsNostrConnectMethod::Nip04Decrypt,
+ vec![test_public_key().to_hex(), "cipher".to_owned()],
+ )
+ .expect("nip04 decrypt from parts"),
+ RadrootsNostrConnectRequest::Nip04Decrypt {
+ public_key: test_public_key(),
+ ciphertext: "cipher".to_owned(),
+ }
+ );
+ assert_eq!(
+ RadrootsNostrConnectRequest::from_parts(
+ RadrootsNostrConnectMethod::Nip44Encrypt,
+ vec![test_public_key().to_hex(), "hello".to_owned()],
+ )
+ .expect("nip44 encrypt from parts"),
+ RadrootsNostrConnectRequest::Nip44Encrypt {
+ public_key: test_public_key(),
+ plaintext: "hello".to_owned(),
+ }
+ );
+ assert_eq!(
+ RadrootsNostrConnectRequest::from_parts(
+ RadrootsNostrConnectMethod::Nip44Decrypt,
+ vec![test_public_key().to_hex(), "cipher".to_owned()],
+ )
+ .expect("nip44 decrypt from parts"),
+ RadrootsNostrConnectRequest::Nip44Decrypt {
+ public_key: test_public_key(),
+ ciphertext: "cipher".to_owned(),
+ }
+ );
+ assert_eq!(
+ RadrootsNostrConnectRequest::from_parts(RadrootsNostrConnectMethod::Ping, Vec::new())
+ .expect("ping from parts"),
+ RadrootsNostrConnectRequest::Ping
+ );
+ assert_eq!(
+ RadrootsNostrConnectRequest::from_parts(
+ RadrootsNostrConnectMethod::SwitchRelays,
+ Vec::new(),
+ )
+ .expect("switch relays from parts"),
+ RadrootsNostrConnectRequest::SwitchRelays
+ );
+
+ for (method, params, expected_error) in [
+ (
+ RadrootsNostrConnectMethod::GetPublicKey,
+ vec!["oops".to_owned()],
+ "no params",
+ ),
+ (
+ RadrootsNostrConnectMethod::SignEvent,
+ Vec::new(),
+ "exactly 1 param",
+ ),
+ (
+ RadrootsNostrConnectMethod::Nip04Encrypt,
+ vec!["only-one".to_owned()],
+ "exactly 2 params",
+ ),
+ (
+ RadrootsNostrConnectMethod::Nip04Decrypt,
+ vec!["only-one".to_owned()],
+ "exactly 2 params",
+ ),
+ (
+ RadrootsNostrConnectMethod::Nip44Encrypt,
+ vec!["only-one".to_owned()],
+ "exactly 2 params",
+ ),
+ (
+ RadrootsNostrConnectMethod::Nip44Decrypt,
+ vec!["only-one".to_owned()],
+ "exactly 2 params",
+ ),
+ (
+ RadrootsNostrConnectMethod::Ping,
+ vec!["oops".to_owned()],
+ "no params",
+ ),
+ (
+ RadrootsNostrConnectMethod::SwitchRelays,
+ vec!["oops".to_owned()],
+ "no params",
+ ),
+ ] {
+ assert!(matches!(
+ RadrootsNostrConnectRequest::from_parts(method, params),
+ Err(RadrootsNostrConnectError::InvalidParams { expected, .. }) if expected == expected_error
+ ));
+ }
+ assert!(matches!(
+ RadrootsNostrConnectRequest::from_parts(RadrootsNostrConnectMethod::Connect, Vec::new()),
+ Err(RadrootsNostrConnectError::InvalidParams { expected, received, .. })
+ if expected == "1 to 3 params" && received == 0
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectRequest::from_parts(
+ RadrootsNostrConnectMethod::Connect,
+ vec!["bad-key".to_owned()],
+ ),
+ Err(RadrootsNostrConnectError::InvalidPublicKey { .. })
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectRequest::from_parts(
+ RadrootsNostrConnectMethod::Connect,
+ vec![test_public_key().to_hex(), "secret".to_owned(), "sign_event:".to_owned()],
+ ),
+ Err(RadrootsNostrConnectError::InvalidPermission(value)) if value == "sign_event:"
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectRequest::from_parts(
+ RadrootsNostrConnectMethod::Connect,
+ vec![
+ test_public_key().to_hex(),
+ "secret".to_owned(),
+ "ping".to_owned(),
+ "extra".to_owned(),
+ ],
+ ),
+ Err(RadrootsNostrConnectError::InvalidParams { expected, received, .. })
+ if expected == "1 to 3 params" && received == 4
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectRequest::from_parts(
+ RadrootsNostrConnectMethod::SignEvent,
+ vec!["not-json".to_owned()],
+ ),
+ Err(RadrootsNostrConnectError::InvalidRequestPayload { .. })
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectRequest::from_parts(
+ RadrootsNostrConnectMethod::Nip04Encrypt,
+ vec!["bad-key".to_owned(), "hello".to_owned()],
+ ),
+ Err(RadrootsNostrConnectError::InvalidPublicKey { .. })
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectRequest::from_parts(
+ RadrootsNostrConnectMethod::Nip04Decrypt,
+ vec!["bad-key".to_owned(), "cipher".to_owned()],
+ ),
+ Err(RadrootsNostrConnectError::InvalidPublicKey { .. })
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectRequest::from_parts(
+ RadrootsNostrConnectMethod::Nip44Encrypt,
+ vec!["bad-key".to_owned(), "hello".to_owned()],
+ ),
+ Err(RadrootsNostrConnectError::InvalidPublicKey { .. })
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectRequest::from_parts(
+ RadrootsNostrConnectMethod::Nip44Decrypt,
+ vec!["bad-key".to_owned(), "cipher".to_owned()],
+ ),
+ Err(RadrootsNostrConnectError::InvalidPublicKey { .. })
+ ));
+
+ let custom_message = RadrootsNostrConnectRequestMessage::new(
+ "req-custom",
+ RadrootsNostrConnectRequest::Custom {
+ method: RadrootsNostrConnectMethod::Custom("publish_note".to_owned()),
+ params: vec!["a".to_owned()],
+ },
+ );
+ let encoded = serde_json::to_string(&custom_message).expect("serialize custom request");
+ let decoded: RadrootsNostrConnectRequestMessage =
+ serde_json::from_str(&encoded).expect("deserialize custom request");
+ assert_eq!(decoded, custom_message);
+ assert!(
+ serde_json::from_str::<RadrootsNostrConnectRequestMessage>("{")
+ .expect_err("invalid request message json")
+ .to_string()
+ .contains("EOF")
+ );
+ assert!(
+ serde_json::from_str::<RadrootsNostrConnectRequestMessage>(
+ "{\"id\":\"req\",\"method\":\"get_public_key\",\"params\":[\"oops\"]}",
+ )
+ .expect_err("invalid request params")
+ .to_string()
+ .contains("invalid parameter count")
+ );
+}
+
+#[test]
+fn response_surface_covers_success_and_error_paths() {
+ let event = signed_event();
+ let cases = vec![
+ (
+ RadrootsNostrConnectResponse::ConnectAcknowledged,
+ RadrootsNostrConnectMethod::Connect,
+ RadrootsNostrConnectResponse::ConnectAcknowledged,
+ ),
+ (
+ RadrootsNostrConnectResponse::ConnectSecretEcho("secret".to_owned()),
+ RadrootsNostrConnectMethod::Connect,
+ RadrootsNostrConnectResponse::ConnectSecretEcho("secret".to_owned()),
+ ),
+ (
+ RadrootsNostrConnectResponse::UserPublicKey(test_public_key()),
+ RadrootsNostrConnectMethod::GetPublicKey,
+ RadrootsNostrConnectResponse::UserPublicKey(test_public_key()),
+ ),
+ (
+ RadrootsNostrConnectResponse::SignedEvent(event.clone()),
+ RadrootsNostrConnectMethod::SignEvent,
+ RadrootsNostrConnectResponse::SignedEvent(event.clone()),
+ ),
+ (
+ RadrootsNostrConnectResponse::Pong,
+ RadrootsNostrConnectMethod::Ping,
+ RadrootsNostrConnectResponse::Pong,
+ ),
+ (
+ RadrootsNostrConnectResponse::Nip04Encrypt("cipher".to_owned()),
+ RadrootsNostrConnectMethod::Nip04Encrypt,
+ RadrootsNostrConnectResponse::Nip04Encrypt("cipher".to_owned()),
+ ),
+ (
+ RadrootsNostrConnectResponse::Nip04Decrypt("plain".to_owned()),
+ RadrootsNostrConnectMethod::Nip04Decrypt,
+ RadrootsNostrConnectResponse::Nip04Decrypt("plain".to_owned()),
+ ),
+ (
+ RadrootsNostrConnectResponse::Nip44Encrypt("cipher".to_owned()),
+ RadrootsNostrConnectMethod::Nip44Encrypt,
+ RadrootsNostrConnectResponse::Nip44Encrypt("cipher".to_owned()),
+ ),
+ (
+ RadrootsNostrConnectResponse::Nip44Decrypt("plain".to_owned()),
+ RadrootsNostrConnectMethod::Nip44Decrypt,
+ RadrootsNostrConnectResponse::Nip44Decrypt("plain".to_owned()),
+ ),
+ (
+ RadrootsNostrConnectResponse::RelayList(vec![
+ relay("wss://relay1.example.com"),
+ relay("wss://relay2.example.com"),
+ ]),
+ RadrootsNostrConnectMethod::SwitchRelays,
+ RadrootsNostrConnectResponse::RelayList(vec![
+ relay("wss://relay1.example.com"),
+ relay("wss://relay2.example.com"),
+ ]),
+ ),
+ (
+ RadrootsNostrConnectResponse::RelayListUnchanged,
+ RadrootsNostrConnectMethod::SwitchRelays,
+ RadrootsNostrConnectResponse::RelayListUnchanged,
+ ),
+ ];
+ for (response, method, expected) in cases {
+ let envelope = response.into_envelope("req").expect("serialize response");
+ let parsed =
+ RadrootsNostrConnectResponse::from_envelope(&method, envelope).expect("parse response");
+ assert_eq!(parsed, expected);
+ }
+
+ let error_envelope = RadrootsNostrConnectResponse::Error {
+ result: Some(json!("partial")),
+ error: "denied".to_owned(),
+ }
+ .into_envelope("req-error")
+ .expect("serialize error response");
+ assert_eq!(error_envelope.error.as_deref(), Some("denied"));
+
+ let custom_envelope = RadrootsNostrConnectResponse::Custom {
+ result: Some(json!({"ok": true})),
+ error: Some("warning".to_owned()),
+ }
+ .into_envelope("req-custom")
+ .expect("serialize custom response");
+ assert_eq!(custom_envelope.error.as_deref(), Some("warning"));
+
+ let auth_envelope =
+ RadrootsNostrConnectResponse::AuthUrl("https://auth.example.com/challenge".to_owned())
+ .into_envelope("req-auth")
+ .expect("serialize auth_url");
+ assert_eq!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::SignEvent,
+ auth_envelope,
+ )
+ .expect("parse auth_url"),
+ RadrootsNostrConnectResponse::AuthUrl("https://auth.example.com/challenge".to_owned())
+ );
+
+ assert_eq!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::Custom("publish_note".to_owned()),
+ RadrootsNostrConnectResponseEnvelope {
+ id: "req-custom".to_owned(),
+ result: Some(json!("ok")),
+ error: None,
+ },
+ )
+ .expect("parse custom response without error"),
+ RadrootsNostrConnectResponse::Custom {
+ result: Some(json!("ok")),
+ error: None,
+ }
+ );
+ assert_eq!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::Custom("publish_note".to_owned()),
+ RadrootsNostrConnectResponseEnvelope {
+ id: "req-custom".to_owned(),
+ result: Some(json!({"ok": true})),
+ error: Some("warning".to_owned()),
+ },
+ )
+ .expect("parse custom response"),
+ RadrootsNostrConnectResponse::Custom {
+ result: Some(json!({"ok": true})),
+ error: Some("warning".to_owned()),
+ }
+ );
+ assert_eq!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::Ping,
+ RadrootsNostrConnectResponseEnvelope {
+ id: "req-error".to_owned(),
+ result: Some(json!("partial")),
+ error: Some("denied".to_owned()),
+ },
+ )
+ .expect("parse error response"),
+ RadrootsNostrConnectResponse::Error {
+ result: Some(json!("partial")),
+ error: "denied".to_owned(),
+ }
+ );
+ assert_eq!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::SignEvent,
+ RadrootsNostrConnectResponseEnvelope {
+ id: "req-event".to_owned(),
+ result: Some(serde_json::to_value(&event).expect("event value")),
+ error: None,
+ },
+ )
+ .expect("parse object event"),
+ RadrootsNostrConnectResponse::SignedEvent(event)
+ );
+ assert_eq!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::SwitchRelays,
+ RadrootsNostrConnectResponseEnvelope {
+ id: "req-switch".to_owned(),
+ result: Some(json!("null")),
+ error: None,
+ },
+ )
+ .expect("parse string null"),
+ RadrootsNostrConnectResponse::RelayListUnchanged
+ );
+ assert_eq!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::SwitchRelays,
+ RadrootsNostrConnectResponseEnvelope {
+ id: "req-switch".to_owned(),
+ result: Some(json!("[\"wss://relay1.example.com\"]")),
+ error: None,
+ },
+ )
+ .expect("parse stringified relay list"),
+ RadrootsNostrConnectResponse::RelayList(vec![relay("wss://relay1.example.com")])
+ );
+
+ assert!(matches!(
+ RadrootsNostrConnectResponse::AuthUrl("not-a-url".to_owned()).into_envelope("req"),
+ Err(RadrootsNostrConnectError::InvalidUrl { value, .. }) if value == "not-a-url"
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::SignEvent,
+ RadrootsNostrConnectResponseEnvelope {
+ id: "req-auth".to_owned(),
+ result: Some(json!("auth_url")),
+ error: Some("not-a-url".to_owned()),
+ },
+ ),
+ Err(RadrootsNostrConnectError::InvalidUrl { value, .. }) if value == "not-a-url"
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::GetPublicKey,
+ RadrootsNostrConnectResponseEnvelope {
+ id: "req-key".to_owned(),
+ result: Some(json!("bad-key")),
+ error: None,
+ },
+ ),
+ Err(RadrootsNostrConnectError::InvalidPublicKey { .. })
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::Connect,
+ RadrootsNostrConnectResponseEnvelope {
+ id: "req-connect".to_owned(),
+ result: None,
+ error: None,
+ },
+ ),
+ Err(RadrootsNostrConnectError::MissingResult)
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::GetPublicKey,
+ RadrootsNostrConnectResponseEnvelope {
+ id: "req-key".to_owned(),
+ result: None,
+ error: None,
+ },
+ ),
+ Err(RadrootsNostrConnectError::MissingResult)
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::Ping,
+ RadrootsNostrConnectResponseEnvelope {
+ id: "req-ping".to_owned(),
+ result: Some(json!("nope")),
+ error: None,
+ },
+ ),
+ Err(RadrootsNostrConnectError::InvalidResponsePayload { .. })
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::Ping,
+ RadrootsNostrConnectResponseEnvelope {
+ id: "req-ping".to_owned(),
+ result: None,
+ error: None,
+ },
+ ),
+ Err(RadrootsNostrConnectError::MissingResult)
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::Nip04Encrypt,
+ RadrootsNostrConnectResponseEnvelope {
+ id: "req-nip04".to_owned(),
+ result: Some(json!(5)),
+ error: None,
+ },
+ ),
+ Err(RadrootsNostrConnectError::InvalidResponsePayload { .. })
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::Nip04Encrypt,
+ RadrootsNostrConnectResponseEnvelope {
+ id: "req-nip04".to_owned(),
+ result: None,
+ error: None,
+ },
+ ),
+ Err(RadrootsNostrConnectError::MissingResult)
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::SignEvent,
+ RadrootsNostrConnectResponseEnvelope {
+ id: "req-event".to_owned(),
+ result: Some(json!("not-json")),
+ error: None,
+ },
+ ),
+ Err(RadrootsNostrConnectError::InvalidResponsePayload { .. })
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::SignEvent,
+ RadrootsNostrConnectResponseEnvelope {
+ id: "req-event".to_owned(),
+ result: Some(json!(5)),
+ error: None,
+ },
+ ),
+ Err(RadrootsNostrConnectError::InvalidResponsePayload { .. })
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::SignEvent,
+ RadrootsNostrConnectResponseEnvelope {
+ id: "req-event".to_owned(),
+ result: None,
+ error: None,
+ },
+ ),
+ Err(RadrootsNostrConnectError::MissingResult)
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::Nip04Decrypt,
+ RadrootsNostrConnectResponseEnvelope {
+ id: "req-nip04d".to_owned(),
+ result: None,
+ error: None,
+ },
+ ),
+ Err(RadrootsNostrConnectError::MissingResult)
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::Nip44Encrypt,
+ RadrootsNostrConnectResponseEnvelope {
+ id: "req-nip44e".to_owned(),
+ result: None,
+ error: None,
+ },
+ ),
+ Err(RadrootsNostrConnectError::MissingResult)
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::Nip44Decrypt,
+ RadrootsNostrConnectResponseEnvelope {
+ id: "req-nip44d".to_owned(),
+ result: None,
+ error: None,
+ },
+ ),
+ Err(RadrootsNostrConnectError::MissingResult)
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::SwitchRelays,
+ RadrootsNostrConnectResponseEnvelope {
+ id: "req-switch".to_owned(),
+ result: Some(json!("[invalid")),
+ error: None,
+ },
+ ),
+ Err(RadrootsNostrConnectError::InvalidResponsePayload { .. })
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::SwitchRelays,
+ RadrootsNostrConnectResponseEnvelope {
+ id: "req-switch".to_owned(),
+ result: Some(json!([1])),
+ error: None,
+ },
+ ),
+ Err(RadrootsNostrConnectError::InvalidResponsePayload { .. })
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::SwitchRelays,
+ RadrootsNostrConnectResponseEnvelope {
+ id: "req-switch".to_owned(),
+ result: Some(json!(["http://relay.example.com"])),
+ error: None,
+ },
+ ),
+ Err(RadrootsNostrConnectError::InvalidRelayUrl { .. })
+ ));
+ assert!(matches!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::SwitchRelays,
+ RadrootsNostrConnectResponseEnvelope {
+ id: "req-switch".to_owned(),
+ result: Some(json!(5)),
+ error: None,
+ },
+ ),
+ Err(RadrootsNostrConnectError::InvalidResponsePayload { .. })
+ ));
+}
diff --git a/crates/nostr-connect/tests/protocol.rs b/crates/nostr-connect/tests/protocol.rs
@@ -0,0 +1,211 @@
+use nostr::{EventBuilder, Keys, PublicKey, RelayUrl, SecretKey, Timestamp, UnsignedEvent};
+use radroots_nostr_connect::prelude::{
+ RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions,
+ RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse,
+ RadrootsNostrConnectResponseEnvelope, RadrootsNostrConnectUri,
+};
+use serde_json::{Value, json};
+
+fn test_public_key() -> PublicKey {
+ PublicKey::parse("83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5")
+ .expect("public key")
+}
+
+fn test_keys() -> Keys {
+ let secret_key =
+ SecretKey::from_hex("6d5f4530cbf6a9e8f021eb409c8c5f2ee7ea123c76364b6f53c2d8a3507f7f5b")
+ .expect("secret key");
+ Keys::new(secret_key)
+}
+
+#[test]
+fn parses_client_uri_with_current_spec_query_fields() {
+ let uri = "nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=wss%3A%2F%2Frelay1.example.com&relay=wss%3A%2F%2Frelay2.example.com&secret=0s8j2djs&perms=nip44_encrypt%2Csign_event%3A1059&name=My+Client&url=https%3A%2F%2Fexample.com&image=https%3A%2F%2Fexample.com%2Flogo.png";
+ let parsed = RadrootsNostrConnectUri::parse(uri).expect("parse client uri");
+
+ match parsed {
+ RadrootsNostrConnectUri::Client(client) => {
+ assert_eq!(client.client_public_key, test_public_key());
+ assert_eq!(client.relays.len(), 2);
+ assert_eq!(client.secret, "0s8j2djs");
+ assert_eq!(client.metadata.name.as_deref(), Some("My Client"));
+ assert_eq!(
+ client.metadata.requested_permissions,
+ RadrootsNostrConnectPermissions::from(vec![
+ RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip44Encrypt,),
+ RadrootsNostrConnectPermission::with_parameter(
+ RadrootsNostrConnectMethod::SignEvent,
+ "1059",
+ ),
+ ])
+ );
+ assert_eq!(client.metadata.url.as_deref(), Some("https://example.com/"));
+ assert_eq!(
+ client.metadata.image.as_deref(),
+ Some("https://example.com/logo.png")
+ );
+ }
+ other => panic!("expected client uri, got {other:?}"),
+ }
+}
+
+#[test]
+fn parses_bunker_uri_and_roundtrips() {
+ let source = "bunker://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=wss%3A%2F%2Frelay.example.com&secret=abcd";
+ let parsed = RadrootsNostrConnectUri::parse(source).expect("parse bunker uri");
+ let rendered = parsed.to_string();
+ let reparsed = RadrootsNostrConnectUri::parse(&rendered).expect("reparse bunker uri");
+ assert_eq!(parsed, reparsed);
+}
+
+#[test]
+fn rejects_client_uri_without_required_secret() {
+ let source = "nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=wss%3A%2F%2Frelay.example.com";
+ assert!(RadrootsNostrConnectUri::parse(source).is_err());
+}
+
+#[test]
+fn requested_permissions_roundtrip_as_csv() {
+ let permissions = RadrootsNostrConnectPermissions::from(vec![
+ RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip44Encrypt),
+ RadrootsNostrConnectPermission::with_parameter(RadrootsNostrConnectMethod::SignEvent, "13"),
+ ]);
+
+ let rendered = permissions.to_string();
+ assert_eq!(rendered, "nip44_encrypt,sign_event:13");
+ let reparsed: RadrootsNostrConnectPermissions = rendered.parse().expect("parse permissions");
+ assert_eq!(permissions, reparsed);
+}
+
+#[test]
+fn connect_request_roundtrips_requested_permissions() {
+ let request = RadrootsNostrConnectRequest::Connect {
+ remote_signer_public_key: test_public_key(),
+ secret: Some("abcd".to_owned()),
+ requested_permissions: RadrootsNostrConnectPermissions::from(vec![
+ RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip44Encrypt),
+ RadrootsNostrConnectPermission::with_parameter(
+ RadrootsNostrConnectMethod::SignEvent,
+ "1059",
+ ),
+ ]),
+ };
+ let message = RadrootsNostrConnectRequestMessage::new("req-1", request);
+ let encoded = serde_json::to_value(&message).expect("serialize request");
+ assert_eq!(
+ encoded,
+ json!({
+ "id": "req-1",
+ "method": "connect",
+ "params": [
+ "83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5",
+ "abcd",
+ "nip44_encrypt,sign_event:1059"
+ ]
+ })
+ );
+
+ let decoded: RadrootsNostrConnectRequestMessage =
+ serde_json::from_value(encoded).expect("deserialize request");
+ assert_eq!(decoded, message);
+}
+
+#[test]
+fn sign_event_request_roundtrips_unsigned_event_payload() {
+ let unsigned_event: UnsignedEvent = serde_json::from_value(json!({
+ "pubkey": test_public_key().to_hex(),
+ "created_at": 1714078911u64,
+ "kind": 1u16,
+ "tags": [],
+ "content": "Hello, I'm signing remotely"
+ }))
+ .expect("unsigned event");
+
+ let message = RadrootsNostrConnectRequestMessage::new(
+ "req-sign",
+ RadrootsNostrConnectRequest::SignEvent(unsigned_event.clone()),
+ );
+ let encoded = serde_json::to_value(&message).expect("serialize sign request");
+ assert_eq!(encoded["method"], "sign_event");
+
+ let decoded: RadrootsNostrConnectRequestMessage =
+ serde_json::from_value(encoded).expect("deserialize sign request");
+ assert_eq!(decoded, message);
+ assert_eq!(
+ decoded.request,
+ RadrootsNostrConnectRequest::SignEvent(unsigned_event)
+ );
+}
+
+#[test]
+fn switch_relays_response_accepts_array_or_null() {
+ let relays_response = RadrootsNostrConnectResponseEnvelope {
+ id: "req-switch".to_owned(),
+ result: Some(json!([
+ "wss://relay1.example.com",
+ "wss://relay2.example.com"
+ ])),
+ error: None,
+ };
+ let parsed = RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::SwitchRelays,
+ relays_response,
+ )
+ .expect("parse relay list");
+ assert_eq!(
+ parsed,
+ RadrootsNostrConnectResponse::RelayList(vec![
+ RelayUrl::parse("wss://relay1.example.com").expect("relay 1"),
+ RelayUrl::parse("wss://relay2.example.com").expect("relay 2"),
+ ])
+ );
+
+ let unchanged = RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::SwitchRelays,
+ RadrootsNostrConnectResponseEnvelope {
+ id: "req-switch".to_owned(),
+ result: Some(Value::Null),
+ error: None,
+ },
+ )
+ .expect("parse null relay result");
+ assert_eq!(unchanged, RadrootsNostrConnectResponse::RelayListUnchanged);
+}
+
+#[test]
+fn auth_url_response_parses_from_result_and_error_fields() {
+ let response = RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::SignEvent,
+ RadrootsNostrConnectResponseEnvelope {
+ id: "req-auth".to_owned(),
+ result: Some(json!("auth_url")),
+ error: Some("https://auth.example.com/challenge".to_owned()),
+ },
+ )
+ .expect("parse auth challenge");
+
+ assert_eq!(
+ response,
+ RadrootsNostrConnectResponse::AuthUrl("https://auth.example.com/challenge".to_owned())
+ );
+}
+
+#[test]
+fn sign_event_response_roundtrips_signed_event_json_string() {
+ let keys = test_keys();
+ let event = EventBuilder::text_note("hello world")
+ .custom_created_at(Timestamp::from(1_714_078_911))
+ .sign_with_keys(&keys)
+ .expect("sign event");
+
+ let envelope = RadrootsNostrConnectResponse::SignedEvent(event.clone())
+ .into_envelope("req-sign")
+ .expect("serialize response");
+ let parsed = RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::SignEvent,
+ envelope,
+ )
+ .expect("parse signed event response");
+
+ assert_eq!(parsed, RadrootsNostrConnectResponse::SignedEvent(event));
+}
diff --git a/nix/common.nix b/nix/common.nix
@@ -102,6 +102,7 @@ let
"radroots-replica-db-schema"
"radroots-events-codec"
"radroots-events-codec-wasm"
+ "radroots-nostr-connect"
];
sdkContractCargoArgs = lib.concatStringsSep " " (map (crate: "-p ${crate}") sdkContractCrates);
craneLib = (crane.mkLib pkgs).overrideToolchain toolchains.stable;