use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use thiserror::Error;
use url::Url;
use crate::event::{Event, EventError, Kind, UnsignedEvent, UnsignedEventError};
use crate::key::{PublicKey, PublicKeyError};
use crate::types::{RelayUrl, RelayUrlError};
use crate::util::JsonUtil;
pub const URI_SCHEME_CLIENT: &str = "nostrconnect";
pub const URI_SCHEME_BUNKER: &str = "bunker";
pub const KIND: u16 = 24_133;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Nip46Error {
#[error(transparent)]
PublicKey(#[from] PublicKeyError),
#[error(transparent)]
RelayUrl(#[from] RelayUrlError),
#[error("invalid JSON payload: {0}")]
Json(#[from] serde_json::Error),
#[error(transparent)]
UnsignedEvent(#[from] UnsignedEventError),
#[error(transparent)]
Event(#[from] EventError),
#[error("method `{method}` expects {expected} param(s), got {actual}")]
InvalidParamLength {
method: Method,
expected: usize,
actual: usize,
},
#[error("unsupported NIP-46 method: {0}")]
UnsupportedMethod(String),
#[error("invalid switch_relays response payload")]
InvalidSwitchRelaysPayload,
#[error("{0}")]
WrongMessageKind(&'static str),
#[error("unknown URI scheme `{0}` (expected `bunker` or `nostrconnect`)")]
UnknownUriScheme(String),
#[error("malformed connection URI: {0}")]
MalformedUri(&'static str),
#[error(transparent)]
Url(#[from] url::ParseError),
#[error("unexpected response for method `{method}` (expected {expected}, got `{received}`)")]
UnexpectedResponse {
method: Method,
expected: &'static str,
received: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[non_exhaustive]
pub enum Method {
Connect,
GetPublicKey,
SignEvent,
Nip04Encrypt,
Nip04Decrypt,
Nip44Encrypt,
Nip44Decrypt,
Ping,
SwitchRelays,
}
impl Method {
#[must_use]
pub const fn as_str(self) -> &'static 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",
}
}
}
impl fmt::Display for Method {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for Method {
type Err = Nip46Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
"connect" => Self::Connect,
"get_public_key" => Self::GetPublicKey,
"sign_event" => Self::SignEvent,
"nip04_encrypt" => Self::Nip04Encrypt,
"nip04_decrypt" => Self::Nip04Decrypt,
"nip44_encrypt" => Self::Nip44Encrypt,
"nip44_decrypt" => Self::Nip44Decrypt,
"ping" => Self::Ping,
"switch_relays" => Self::SwitchRelays,
other => return Err(Nip46Error::UnsupportedMethod(other.to_owned())),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Permission {
Method(Method),
SignEventKind(Kind),
Other(String),
}
impl Permission {
#[must_use]
pub fn to_wire(&self) -> String {
match self {
Self::Method(method) => method.to_string(),
Self::SignEventKind(kind) => {
format!("{}:{}", Method::SignEvent.as_str(), kind.as_u16())
}
Self::Other(raw) => raw.clone(),
}
}
#[must_use]
pub fn join(perms: &[Self]) -> String {
let mut out = String::new();
for (i, perm) in perms.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&perm.to_wire());
}
out
}
#[must_use]
pub fn split(wire: &str) -> Vec<Self> {
if wire.is_empty() {
return Vec::new();
}
wire.split(',')
.map(str::trim)
.filter(|tok| !tok.is_empty())
.map(|tok| tok.parse().unwrap_or_else(|_| Self::Other(tok.to_owned())))
.collect()
}
}
impl fmt::Display for Permission {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_wire())
}
}
impl FromStr for Permission {
type Err = Nip46Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Some((head, tail)) = s.split_once(':') {
if head == Method::SignEvent.as_str()
&& let Ok(raw) = tail.parse::<u16>()
{
return Ok(Self::SignEventKind(Kind::new(raw)));
}
return Ok(Self::Other(s.to_owned()));
}
Ok(s.parse::<Method>()
.map_or_else(|_| Self::Other(s.to_owned()), Self::Method))
}
}
impl Serialize for Method {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for Method {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = <&str>::deserialize(deserializer)?;
Self::from_str(raw).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Request {
Connect {
remote_signer_public_key: PublicKey,
secret: Option<String>,
perms: Option<Vec<Permission>>,
},
GetPublicKey,
SignEvent(UnsignedEvent),
Nip04Encrypt {
peer: PublicKey,
text: String,
},
Nip04Decrypt {
peer: PublicKey,
ciphertext: String,
},
Nip44Encrypt {
peer: PublicKey,
text: String,
},
Nip44Decrypt {
peer: PublicKey,
ciphertext: String,
},
Ping,
SwitchRelays,
}
impl Request {
#[must_use]
pub const fn method(&self) -> Method {
match self {
Self::Connect { .. } => Method::Connect,
Self::GetPublicKey => Method::GetPublicKey,
Self::SignEvent(_) => Method::SignEvent,
Self::Nip04Encrypt { .. } => Method::Nip04Encrypt,
Self::Nip04Decrypt { .. } => Method::Nip04Decrypt,
Self::Nip44Encrypt { .. } => Method::Nip44Encrypt,
Self::Nip44Decrypt { .. } => Method::Nip44Decrypt,
Self::Ping => Method::Ping,
Self::SwitchRelays => Method::SwitchRelays,
}
}
#[must_use]
pub fn params(&self) -> Vec<String> {
match self {
Self::Connect {
remote_signer_public_key,
secret,
perms,
} => {
let mut out = Vec::with_capacity(
1 + usize::from(secret.is_some()) + usize::from(perms.is_some()),
);
out.push(remote_signer_public_key.to_hex());
if perms.is_some() {
out.push(secret.clone().unwrap_or_default());
} else if let Some(s) = secret {
out.push(s.clone());
}
if let Some(perms) = perms {
out.push(Permission::join(perms));
}
out
}
Self::GetPublicKey | Self::Ping | Self::SwitchRelays => Vec::new(),
Self::SignEvent(unsigned) => vec![unsigned.try_to_json().unwrap_or_default()],
Self::Nip04Encrypt { peer, text } | Self::Nip44Encrypt { peer, text } => {
vec![peer.to_hex(), text.clone()]
}
Self::Nip04Decrypt { peer, ciphertext } | Self::Nip44Decrypt { peer, ciphertext } => {
vec![peer.to_hex(), ciphertext.clone()]
}
}
}
pub fn from_wire(method: Method, params: &[String]) -> Result<Self, Nip46Error> {
match (method, params) {
(Method::Connect, [pk_hex]) => Ok(Self::Connect {
remote_signer_public_key: PublicKey::parse(pk_hex)?,
secret: None,
perms: None,
}),
(Method::Connect, [pk_hex, secret]) => Ok(Self::Connect {
remote_signer_public_key: PublicKey::parse(pk_hex)?,
secret: Some(secret.clone()),
perms: None,
}),
(Method::Connect, [pk_hex, secret, perms]) => Ok(Self::Connect {
remote_signer_public_key: PublicKey::parse(pk_hex)?,
secret: if secret.is_empty() {
None
} else {
Some(secret.clone())
},
perms: Some(Permission::split(perms)),
}),
(Method::GetPublicKey, []) => Ok(Self::GetPublicKey),
(Method::SignEvent, [json]) => Ok(Self::SignEvent(UnsignedEvent::from_json(json)?)),
(Method::Nip04Encrypt, [pk_hex, text]) => Ok(Self::Nip04Encrypt {
peer: PublicKey::parse(pk_hex)?,
text: text.clone(),
}),
(Method::Nip44Encrypt, [pk_hex, text]) => Ok(Self::Nip44Encrypt {
peer: PublicKey::parse(pk_hex)?,
text: text.clone(),
}),
(Method::Nip04Decrypt, [pk_hex, ciphertext]) => Ok(Self::Nip04Decrypt {
peer: PublicKey::parse(pk_hex)?,
ciphertext: ciphertext.clone(),
}),
(Method::Nip44Decrypt, [pk_hex, ciphertext]) => Ok(Self::Nip44Decrypt {
peer: PublicKey::parse(pk_hex)?,
ciphertext: ciphertext.clone(),
}),
(Method::Ping, []) => Ok(Self::Ping),
(Method::SwitchRelays, []) => Ok(Self::SwitchRelays),
(Method::GetPublicKey | Method::Ping | Method::SwitchRelays, _) => {
Err(invalid_param_length(method, 0, params.len()))
}
(Method::SignEvent | Method::Connect, _) => {
Err(invalid_param_length(method, 1, params.len()))
}
(
Method::Nip04Encrypt
| Method::Nip04Decrypt
| Method::Nip44Encrypt
| Method::Nip44Decrypt,
_,
) => Err(invalid_param_length(method, 2, params.len())),
}
}
}
const fn invalid_param_length(method: Method, expected: usize, actual: usize) -> Nip46Error {
Nip46Error::InvalidParamLength {
method,
expected,
actual,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ResponseResult {
Ack,
ConnectSecret(String),
GetPublicKey(PublicKey),
SignEvent(Box<Event>),
Nip04Encrypt(String),
Nip04Decrypt(String),
Nip44Encrypt(String),
Nip44Decrypt(String),
Pong,
SwitchRelays(Option<Vec<RelayUrl>>),
AuthUrl,
Error,
}
impl ResponseResult {
pub fn from_wire(method: Method, result: &str) -> Result<Self, Nip46Error> {
match result {
"auth_url" => return Ok(Self::AuthUrl),
"error" => return Ok(Self::Error),
_ => {}
}
match method {
Method::Connect => {
if result == "ack" {
Ok(Self::Ack)
} else {
Ok(Self::ConnectSecret(result.to_owned()))
}
}
Method::GetPublicKey => Ok(Self::GetPublicKey(PublicKey::parse(result)?)),
Method::SignEvent => Ok(Self::SignEvent(Box::new(Event::from_json(result)?))),
Method::Nip04Encrypt => Ok(Self::Nip04Encrypt(result.to_owned())),
Method::Nip04Decrypt => Ok(Self::Nip04Decrypt(result.to_owned())),
Method::Nip44Encrypt => Ok(Self::Nip44Encrypt(result.to_owned())),
Method::Nip44Decrypt => Ok(Self::Nip44Decrypt(result.to_owned())),
Method::Ping => {
if result == "pong" {
Ok(Self::Pong)
} else {
Err(Nip46Error::UnexpectedResponse {
method,
expected: "pong",
received: result.to_owned(),
})
}
}
Method::SwitchRelays => {
let trimmed = result.trim();
if trimmed == "null" {
return Ok(Self::SwitchRelays(None));
}
let raw: Vec<String> = serde_json::from_str(trimmed)
.map_err(|_| Nip46Error::InvalidSwitchRelaysPayload)?;
let mut relays = Vec::with_capacity(raw.len());
for url in raw {
relays.push(RelayUrl::parse(&url)?);
}
Ok(Self::SwitchRelays(Some(relays)))
}
}
}
#[must_use]
pub fn to_wire(&self) -> String {
match self {
Self::Ack => "ack".to_owned(),
Self::ConnectSecret(s)
| Self::Nip04Encrypt(s)
| Self::Nip04Decrypt(s)
| Self::Nip44Encrypt(s)
| Self::Nip44Decrypt(s) => s.clone(),
Self::GetPublicKey(pk) => pk.to_hex(),
Self::SignEvent(ev) => ev.try_to_json().unwrap_or_default(),
Self::Pong => "pong".to_owned(),
Self::SwitchRelays(None) => "null".to_owned(),
Self::SwitchRelays(Some(relays)) => {
let urls: Vec<&str> = relays.iter().map(RelayUrl::as_str).collect();
serde_json::to_string(&urls).unwrap_or_else(|_| "null".to_owned())
}
Self::AuthUrl => "auth_url".to_owned(),
Self::Error => "error".to_owned(),
}
}
#[must_use]
pub const fn is_auth_url(&self) -> bool {
matches!(self, Self::AuthUrl)
}
#[must_use]
pub const fn is_error(&self) -> bool {
matches!(self, Self::Error)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Response {
pub result: Option<ResponseResult>,
pub error: Option<String>,
}
impl Response {
#[must_use]
pub const fn with_result(result: ResponseResult) -> Self {
Self {
result: Some(result),
error: None,
}
}
#[must_use]
pub fn with_error(error: impl Into<String>) -> Self {
Self {
result: None,
error: Some(error.into()),
}
}
pub fn from_wire(
method: Method,
result: Option<&str>,
error: Option<String>,
) -> Result<Self, Nip46Error> {
let decoded = match result {
Some(s) => Some(ResponseResult::from_wire(method, s)?),
None => None,
};
Ok(Self {
result: decoded,
error,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
#[non_exhaustive]
pub enum Message {
Request {
id: String,
method: Method,
params: Vec<String>,
},
Response {
id: String,
result: Option<String>,
error: Option<String>,
},
}
impl Message {
#[must_use]
pub fn request(id: impl Into<String>, request: &Request) -> Self {
Self::Request {
id: id.into(),
method: request.method(),
params: request.params(),
}
}
#[must_use]
pub fn response(id: impl Into<String>, response: Response) -> Self {
Self::Response {
id: id.into(),
result: response.result.as_ref().map(ResponseResult::to_wire),
error: response.error,
}
}
#[must_use]
pub fn id(&self) -> &str {
match self {
Self::Request { id, .. } | Self::Response { id, .. } => id,
}
}
pub fn into_request(self) -> Result<Request, Nip46Error> {
match self {
Self::Request { method, params, .. } => Request::from_wire(method, ¶ms),
Self::Response { .. } => Err(Nip46Error::WrongMessageKind(
"expected Request, got Response",
)),
}
}
pub fn into_response(self, method: Method) -> Result<Response, Nip46Error> {
match self {
Self::Response { result, error, .. } => {
Response::from_wire(method, result.as_deref(), error)
}
Self::Request { .. } => Err(Nip46Error::WrongMessageKind(
"expected Response, got Request",
)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Metadata {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub icons: Option<Vec<String>>,
}
impl Metadata {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
url: None,
description: None,
icons: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Uri {
Bunker {
remote_signer_public_key: PublicKey,
relays: Vec<RelayUrl>,
secret: Option<String>,
},
Client {
public_key: PublicKey,
relays: Vec<RelayUrl>,
metadata: Metadata,
secret: String,
perms: Vec<Permission>,
},
}
impl Uri {
pub fn parse(uri: &str) -> Result<Self, Nip46Error> {
let parsed = Url::parse(uri)?;
let host = parsed
.host_str()
.ok_or(Nip46Error::MalformedUri("missing pubkey host"))?;
let public_key = PublicKey::parse(host)?;
let mut relays: Vec<RelayUrl> = Vec::new();
let mut secret: Option<String> = None;
let mut metadata: Option<Metadata> = None;
let mut perms: Vec<Permission> = Vec::new();
for (key, value) in parsed.query_pairs() {
match key.as_ref() {
"relay" => relays.push(RelayUrl::parse(value.as_ref())?),
"secret" => secret = Some(value.into_owned()),
"metadata" => metadata = Some(Metadata::from_json(value.as_ref())?),
"perms" => perms = Permission::split(value.as_ref()),
_ => {}
}
}
match parsed.scheme() {
URI_SCHEME_BUNKER => Ok(Self::Bunker {
remote_signer_public_key: public_key,
relays,
secret,
}),
URI_SCHEME_CLIENT => {
let secret = secret.ok_or(Nip46Error::MalformedUri(
"`nostrconnect://` URIs require the `secret` query parameter",
))?;
let metadata = metadata.ok_or(Nip46Error::MalformedUri(
"`nostrconnect://` URIs require the `metadata` query parameter",
))?;
Ok(Self::Client {
public_key,
relays,
metadata,
secret,
perms,
})
}
other => Err(Nip46Error::UnknownUriScheme(other.to_owned())),
}
}
#[must_use]
pub const fn is_bunker(&self) -> bool {
matches!(self, Self::Bunker { .. })
}
#[must_use]
pub fn relays(&self) -> &[RelayUrl] {
match self {
Self::Bunker { relays, .. } | Self::Client { relays, .. } => relays,
}
}
#[must_use]
pub fn secret(&self) -> Option<&str> {
match self {
Self::Bunker { secret, .. } => secret.as_deref(),
Self::Client { secret, .. } => Some(secret),
}
}
}
impl FromStr for Uri {
type Err = Nip46Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl fmt::Display for Uri {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Bunker {
remote_signer_public_key,
relays,
secret,
} => {
write!(f, "{URI_SCHEME_BUNKER}://{remote_signer_public_key}")?;
write_query(f, relays, secret.as_deref(), None, &[])
}
Self::Client {
public_key,
relays,
metadata,
secret,
perms,
} => {
write!(f, "{URI_SCHEME_CLIENT}://{public_key}")?;
let metadata_json = metadata.try_to_json().unwrap_or_default();
write_query(f, relays, Some(secret), Some(&metadata_json), perms)
}
}
}
}
fn write_query(
out: &mut fmt::Formatter<'_>,
relays: &[RelayUrl],
secret: Option<&str>,
metadata_json: Option<&str>,
perms: &[Permission],
) -> fmt::Result {
let mut first = true;
let mut emit = |sink: &mut fmt::Formatter<'_>, key: &str, value: &str| -> fmt::Result {
sink.write_str(if first { "?" } else { "&" })?;
first = false;
write!(sink, "{key}={}", url_encode(value))
};
for relay in relays {
emit(out, "relay", relay.as_str())?;
}
if let Some(meta) = metadata_json {
emit(out, "metadata", meta)?;
}
if let Some(s) = secret {
emit(out, "secret", s)?;
}
if !perms.is_empty() {
emit(out, "perms", &Permission::join(perms))?;
}
Ok(())
}
fn url_encode(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for byte in input.bytes() {
let preserve =
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~' | b'/' | b':');
if preserve {
out.push(byte as char);
} else {
out.push('%');
out.push(hex_nibble(byte >> 4));
out.push(hex_nibble(byte & 0x0f));
}
}
out
}
const fn hex_nibble(n: u8) -> char {
match n {
0..=9 => (b'0' + n) as char,
10..=15 => (b'A' + (n - 10)) as char,
_ => '0',
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Keys;
fn fixture_pk() -> PublicKey {
*Keys::parse("0000000000000000000000000000000000000000000000000000000000000003")
.unwrap()
.public_key()
}
#[test]
fn method_round_trips_through_str() {
for method in [
Method::Connect,
Method::GetPublicKey,
Method::SignEvent,
Method::Nip04Encrypt,
Method::Nip04Decrypt,
Method::Nip44Encrypt,
Method::Nip44Decrypt,
Method::Ping,
Method::SwitchRelays,
] {
let s = method.as_str();
let parsed: Method = s.parse().unwrap();
assert_eq!(parsed, method);
}
}
#[test]
fn unknown_method_is_rejected() {
let err: Nip46Error = "open_my_drone".parse::<Method>().unwrap_err();
assert!(matches!(err, Nip46Error::UnsupportedMethod(s) if s == "open_my_drone"));
}
#[test]
fn request_round_trip_through_wire_params() {
let pk = fixture_pk();
let cases: Vec<Request> = vec![
Request::Connect {
remote_signer_public_key: pk,
secret: Some("hunter2".to_owned()),
perms: None,
},
Request::Connect {
remote_signer_public_key: pk,
secret: None,
perms: None,
},
Request::Connect {
remote_signer_public_key: pk,
secret: Some("hunter2".to_owned()),
perms: Some(vec![
Permission::Method(Method::Nip44Encrypt),
Permission::SignEventKind(Kind::TEXT_NOTE),
]),
},
Request::Connect {
remote_signer_public_key: pk,
secret: None,
perms: Some(vec![Permission::Method(Method::GetPublicKey)]),
},
Request::Connect {
remote_signer_public_key: pk,
secret: None,
perms: Some(Vec::new()),
},
Request::GetPublicKey,
Request::Nip04Encrypt {
peer: pk,
text: "hi".to_owned(),
},
Request::Nip04Decrypt {
peer: pk,
ciphertext: "AAAA?iv=AAAA".to_owned(),
},
Request::Nip44Encrypt {
peer: pk,
text: "hello".to_owned(),
},
Request::Nip44Decrypt {
peer: pk,
ciphertext: "AgAB...".to_owned(),
},
Request::Ping,
Request::SwitchRelays,
];
for req in cases {
let method = req.method();
let params = req.params();
let recovered = Request::from_wire(method, ¶ms).unwrap();
assert_eq!(recovered, req);
}
}
#[test]
fn request_param_count_validation() {
let pk = fixture_pk();
let bad = Request::from_wire(Method::Nip04Encrypt, &[pk.to_hex()]).unwrap_err();
assert!(matches!(
bad,
Nip46Error::InvalidParamLength {
method: Method::Nip04Encrypt,
expected: 2,
actual: 1,
}
));
}
#[test]
fn response_decode_handles_universal_sentinels() {
let auth = ResponseResult::from_wire(Method::SignEvent, "auth_url").unwrap();
assert!(auth.is_auth_url());
let err = ResponseResult::from_wire(Method::Connect, "error").unwrap();
assert!(err.is_error());
}
#[test]
fn response_decode_for_each_method() {
let pk = fixture_pk();
match ResponseResult::from_wire(Method::GetPublicKey, &pk.to_hex()).unwrap() {
ResponseResult::GetPublicKey(decoded) => assert_eq!(decoded, pk),
other => panic!("unexpected variant: {other:?}"),
}
let ack = ResponseResult::from_wire(Method::Connect, "ack").unwrap();
assert!(matches!(ack, ResponseResult::Ack));
let secret = ResponseResult::from_wire(Method::Connect, "abcdef0123").unwrap();
assert!(matches!(secret, ResponseResult::ConnectSecret(s) if s == "abcdef0123"));
let pong = ResponseResult::from_wire(Method::Ping, "pong").unwrap();
assert!(matches!(pong, ResponseResult::Pong));
let err = ResponseResult::from_wire(Method::Ping, "ping").unwrap_err();
assert!(matches!(err, Nip46Error::UnexpectedResponse { .. }));
}
#[test]
fn message_request_round_trips_through_json() {
let pk = fixture_pk();
let request = Request::Nip44Encrypt {
peer: pk,
text: "hello".to_owned(),
};
let msg = Message::request("req-1", &request);
let json = msg.try_to_json().unwrap();
let recovered = Message::from_json(&json).unwrap();
assert_eq!(recovered.id(), "req-1");
let recovered_req = recovered.into_request().unwrap();
assert_eq!(recovered_req, request);
}
#[test]
fn message_response_round_trips_through_json() {
let response = Response::with_result(ResponseResult::Pong);
let msg = Message::response("ping-42", response);
let json = msg.try_to_json().unwrap();
let recovered = Message::from_json(&json).unwrap();
assert_eq!(recovered.id(), "ping-42");
let recovered_resp = recovered.into_response(Method::Ping).unwrap();
assert!(matches!(recovered_resp.result, Some(ResponseResult::Pong)));
assert!(recovered_resp.error.is_none());
}
#[test]
fn into_request_rejects_response_envelopes() {
let msg = Message::Response {
id: "x".into(),
result: Some("ack".into()),
error: None,
};
let err = msg.into_request().unwrap_err();
assert!(matches!(err, Nip46Error::WrongMessageKind(_)));
}
#[test]
fn bunker_uri_round_trip() {
let pk = fixture_pk();
let original = format!(
"bunker://{}?relay=wss%3A%2F%2Frelay.example%2F&secret=hunter2",
pk.to_hex(),
);
let parsed = Uri::parse(&original).unwrap();
match &parsed {
Uri::Bunker {
remote_signer_public_key,
relays,
secret,
} => {
assert_eq!(*remote_signer_public_key, pk);
assert_eq!(relays.len(), 1);
assert_eq!(relays[0].as_str(), "wss://relay.example/");
assert_eq!(secret.as_deref(), Some("hunter2"));
}
other => panic!("unexpected variant: {other:?}"),
}
let rendered = parsed.to_string();
let reparsed = Uri::parse(&rendered).unwrap();
assert_eq!(reparsed, parsed);
}
#[test]
fn nostrconnect_uri_requires_secret() {
let pk = fixture_pk();
let bad = format!(
"nostrconnect://{}?relay=wss%3A%2F%2Frelay.example%2F&metadata=%7B%22name%22%3A%22demo%22%7D",
pk.to_hex(),
);
let err = Uri::parse(&bad).unwrap_err();
assert!(matches!(err, Nip46Error::MalformedUri(_)));
}
#[test]
fn nostrconnect_uri_round_trip() {
let pk = fixture_pk();
let metadata = Metadata::new("demo");
let original = Uri::Client {
public_key: pk,
relays: vec![RelayUrl::parse("wss://relay.example/").unwrap()],
metadata: metadata.clone(),
secret: "anti-mitm".into(),
perms: Vec::new(),
};
let rendered = original.to_string();
let reparsed = Uri::parse(&rendered).unwrap();
assert_eq!(reparsed, original);
assert_eq!(reparsed.secret(), Some("anti-mitm"));
match reparsed {
Uri::Client {
metadata: parsed_meta,
..
} => assert_eq!(parsed_meta, metadata),
other => panic!("unexpected variant: {other:?}"),
}
}
#[test]
fn unknown_scheme_is_rejected() {
let pk = fixture_pk();
let err = Uri::parse(&format!("nip46://{}", pk.to_hex())).unwrap_err();
assert!(matches!(err, Nip46Error::UnknownUriScheme(s) if s == "nip46"));
}
#[test]
fn permission_token_round_trips() {
let bare: Permission = "get_public_key".parse().unwrap();
assert_eq!(bare, Permission::Method(Method::GetPublicKey));
assert_eq!(bare.to_wire(), "get_public_key");
let kinded: Permission = "sign_event:4".parse().unwrap();
assert_eq!(kinded, Permission::SignEventKind(Kind::new(4)));
assert_eq!(kinded.to_wire(), "sign_event:4");
let vendor: Permission = "weird_vendor:opt=1".parse().unwrap();
assert_eq!(vendor, Permission::Other("weird_vendor:opt=1".to_owned()));
assert_eq!(vendor.to_wire(), "weird_vendor:opt=1");
let extensible: Permission = "sign_event:any".parse().unwrap();
assert_eq!(extensible, Permission::Other("sign_event:any".to_owned()));
}
#[test]
fn permission_list_round_trips_via_join_split() {
let perms = vec![
Permission::Method(Method::Nip44Encrypt),
Permission::SignEventKind(Kind::new(4)),
];
let joined = Permission::join(&perms);
assert_eq!(joined, "nip44_encrypt,sign_event:4");
let parsed = Permission::split(&joined);
assert_eq!(parsed, perms);
assert!(Permission::split("").is_empty());
assert_eq!(
Permission::split(" ping , sign_event:1 "),
vec![
Permission::Method(Method::Ping),
Permission::SignEventKind(Kind::TEXT_NOTE),
],
);
}
#[test]
fn connect_request_with_perms_emits_positional_layout() {
let pk = fixture_pk();
let req = Request::Connect {
remote_signer_public_key: pk,
secret: None,
perms: Some(vec![Permission::Method(Method::GetPublicKey)]),
};
let params = req.params();
assert_eq!(params.len(), 3);
assert_eq!(params[0], pk.to_hex());
assert_eq!(params[1], "");
assert_eq!(params[2], "get_public_key");
let recovered = Request::from_wire(Method::Connect, ¶ms).unwrap();
assert_eq!(recovered, req);
}
#[test]
fn switch_relays_response_round_trips_through_wire() {
let null_value = ResponseResult::SwitchRelays(None);
assert_eq!(null_value.to_wire(), "null");
let null_recovered = ResponseResult::from_wire(Method::SwitchRelays, "null").unwrap();
assert_eq!(null_recovered, null_value);
let empty_value = ResponseResult::SwitchRelays(Some(Vec::new()));
let empty_wire = empty_value.to_wire();
assert_eq!(empty_wire, "[]");
let empty_recovered = ResponseResult::from_wire(Method::SwitchRelays, &empty_wire).unwrap();
assert_eq!(empty_recovered, empty_value);
let relays = vec![
RelayUrl::parse("wss://relay.one/").unwrap(),
RelayUrl::parse("wss://relay.two/").unwrap(),
];
let populated = ResponseResult::SwitchRelays(Some(relays));
let populated_wire = populated.to_wire();
let populated_recovered =
ResponseResult::from_wire(Method::SwitchRelays, &populated_wire).unwrap();
assert_eq!(populated_recovered, populated);
let err =
ResponseResult::from_wire(Method::SwitchRelays, "not-json").expect_err("must reject");
assert!(matches!(err, Nip46Error::InvalidSwitchRelaysPayload));
}
#[test]
fn switch_relays_request_envelope_round_trips_through_json() {
let msg = Message::request("sw-1", &Request::SwitchRelays);
let json = msg.try_to_json().unwrap();
let recovered = Message::from_json(&json).unwrap();
assert_eq!(recovered.id(), "sw-1");
let req = recovered.into_request().unwrap();
assert_eq!(req, Request::SwitchRelays);
}
#[test]
fn nostrconnect_uri_carries_perms_round_trip() {
let pk = fixture_pk();
let metadata = Metadata::new("demo");
let original = Uri::Client {
public_key: pk,
relays: vec![RelayUrl::parse("wss://relay.example/").unwrap()],
metadata,
secret: "anti-mitm".into(),
perms: vec![
Permission::Method(Method::Nip44Encrypt),
Permission::Method(Method::Nip44Decrypt),
Permission::SignEventKind(Kind::new(13)),
Permission::SignEventKind(Kind::new(14)),
Permission::SignEventKind(Kind::new(1059)),
],
};
let rendered = original.to_string();
assert!(rendered.contains("perms="));
let reparsed = Uri::parse(&rendered).unwrap();
assert_eq!(reparsed, original);
}
}