commit ec2d26f050a21b4d41de1fe67dca6add9a7d4253
parent a7857a0bde787f477788eefb7cfdf9700f15a125
Author: triesap <tyson@radroots.org>
Date: Tue, 23 Jun 2026 23:10:30 +0000
nostr-connect: add async NIP-46 client substrate
- add transport-neutral client target, request, progress, and async execution primitives
- encrypt and decrypt request and response events with signer/client identity checks
- expose typed protocol, transport, timeout, signing, and crypto errors
- cover connect, capability, sign, auth, timeout, malformed, and unrelated responses
Diffstat:
6 files changed, 638 insertions(+), 1 deletion(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -4387,6 +4387,7 @@ dependencies = [
"serde",
"serde_json",
"thiserror 1.0.69",
+ "tokio",
"url",
]
diff --git a/crates/nostr_connect/Cargo.toml b/crates/nostr_connect/Cargo.toml
@@ -13,10 +13,11 @@ documentation = "https://docs.rs/radroots_nostr_connect"
readme = "README"
[dependencies]
-nostr = { workspace = true }
+nostr = { workspace = true, features = ["nip44"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
url = { workspace = true }
[dev-dependencies]
+tokio = { workspace = true, features = ["macros", "rt"] }
diff --git a/crates/nostr_connect/src/client.rs b/crates/nostr_connect/src/client.rs
@@ -0,0 +1,175 @@
+use crate::error::RadrootsNostrConnectError;
+use crate::message::{
+ RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectRequest,
+ RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse,
+ RadrootsNostrConnectResponseEnvelope,
+};
+use crate::method::RadrootsNostrConnectMethod;
+use nostr::nips::nip44::{self, Version};
+use nostr::{Event, EventBuilder, Keys, Kind, PublicKey, RelayUrl, Tag};
+use std::future::Future;
+use std::pin::Pin;
+
+pub type RadrootsNostrConnectClientTransportFuture<'a, T> =
+ Pin<Box<dyn Future<Output = Result<T, RadrootsNostrConnectError>> + Send + 'a>>;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RadrootsNostrConnectClientTarget {
+ pub remote_signer_public_key: PublicKey,
+ pub relays: Vec<RelayUrl>,
+}
+
+impl RadrootsNostrConnectClientTarget {
+ pub fn new(remote_signer_public_key: PublicKey, relays: Vec<RelayUrl>) -> Self {
+ Self {
+ remote_signer_public_key,
+ relays,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RadrootsNostrConnectClientRequest {
+ pub request_id: String,
+ pub request: RadrootsNostrConnectRequest,
+}
+
+impl RadrootsNostrConnectClientRequest {
+ pub fn new(request_id: impl Into<String>, request: RadrootsNostrConnectRequest) -> Self {
+ Self {
+ request_id: request_id.into(),
+ request,
+ }
+ }
+
+ pub fn method(&self) -> RadrootsNostrConnectMethod {
+ self.request.method()
+ }
+
+ pub fn into_message(self) -> RadrootsNostrConnectRequestMessage {
+ RadrootsNostrConnectRequestMessage::new(self.request_id, self.request)
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RadrootsNostrConnectClientProgress {
+ AuthChallenge { url: String },
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RadrootsNostrConnectClientEventOutcome {
+ Ignore,
+ Progress(RadrootsNostrConnectClientProgress),
+ Response(RadrootsNostrConnectResponse),
+}
+
+pub trait RadrootsNostrConnectClientTransport {
+ fn publish_request_event<'a>(
+ &'a mut self,
+ event: Event,
+ ) -> RadrootsNostrConnectClientTransportFuture<'a, ()>;
+
+ fn next_response_event<'a>(
+ &'a mut self,
+ ) -> RadrootsNostrConnectClientTransportFuture<'a, Event>;
+}
+
+pub fn build_request_event(
+ client_keys: &Keys,
+ target: &RadrootsNostrConnectClientTarget,
+ message: RadrootsNostrConnectRequestMessage,
+) -> Result<Event, RadrootsNostrConnectError> {
+ let payload = serde_json::to_string(&message).map_err(RadrootsNostrConnectError::from)?;
+ let ciphertext = nip44::encrypt(
+ client_keys.secret_key(),
+ &target.remote_signer_public_key,
+ payload,
+ Version::V2,
+ )
+ .map_err(|error| RadrootsNostrConnectError::Encrypt {
+ reason: error.to_string(),
+ })?;
+
+ EventBuilder::new(Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND), ciphertext)
+ .tag(Tag::public_key(target.remote_signer_public_key))
+ .sign_with_keys(client_keys)
+ .map_err(|error| RadrootsNostrConnectError::Sign {
+ reason: error.to_string(),
+ })
+}
+
+pub fn parse_response_event(
+ client_keys: &Keys,
+ target: &RadrootsNostrConnectClientTarget,
+ request_id: &str,
+ method: &RadrootsNostrConnectMethod,
+ event: &Event,
+) -> Result<RadrootsNostrConnectClientEventOutcome, RadrootsNostrConnectError> {
+ if event.kind != Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND) {
+ return Ok(RadrootsNostrConnectClientEventOutcome::Ignore);
+ }
+
+ if event.pubkey != target.remote_signer_public_key {
+ return Ok(RadrootsNostrConnectClientEventOutcome::Ignore);
+ }
+
+ let client_public_key = client_keys.public_key();
+ if !event
+ .tags
+ .public_keys()
+ .any(|public_key| *public_key == client_public_key)
+ {
+ return Ok(RadrootsNostrConnectClientEventOutcome::Ignore);
+ }
+
+ let decrypted = nip44::decrypt(
+ client_keys.secret_key(),
+ &target.remote_signer_public_key,
+ &event.content,
+ )
+ .map_err(|error| RadrootsNostrConnectError::Decrypt {
+ reason: error.to_string(),
+ })?;
+
+ let envelope: RadrootsNostrConnectResponseEnvelope =
+ serde_json::from_str(&decrypted).map_err(RadrootsNostrConnectError::from)?;
+ if envelope.id != request_id {
+ return Ok(RadrootsNostrConnectClientEventOutcome::Ignore);
+ }
+
+ let response = RadrootsNostrConnectResponse::from_envelope(method, envelope)?;
+ Ok(match response {
+ RadrootsNostrConnectResponse::AuthUrl(url) => {
+ RadrootsNostrConnectClientEventOutcome::Progress(
+ RadrootsNostrConnectClientProgress::AuthChallenge { url },
+ )
+ }
+ response => RadrootsNostrConnectClientEventOutcome::Response(response),
+ })
+}
+
+pub async fn execute_request_with_transport<T, F>(
+ client_keys: &Keys,
+ target: &RadrootsNostrConnectClientTarget,
+ request: RadrootsNostrConnectClientRequest,
+ transport: &mut T,
+ mut on_progress: F,
+) -> Result<RadrootsNostrConnectResponse, RadrootsNostrConnectError>
+where
+ T: RadrootsNostrConnectClientTransport,
+ F: FnMut(RadrootsNostrConnectClientProgress) -> Result<(), RadrootsNostrConnectError>,
+{
+ let method = request.method();
+ let request_id = request.request_id.clone();
+ let event = build_request_event(client_keys, target, request.into_message())?;
+ transport.publish_request_event(event).await?;
+
+ loop {
+ let event = transport.next_response_event().await?;
+ match parse_response_event(client_keys, target, &request_id, &method, &event)? {
+ RadrootsNostrConnectClientEventOutcome::Ignore => {}
+ RadrootsNostrConnectClientEventOutcome::Progress(progress) => on_progress(progress)?,
+ RadrootsNostrConnectClientEventOutcome::Response(response) => return Ok(response),
+ }
+ }
+}
diff --git a/crates/nostr_connect/src/error.rs b/crates/nostr_connect/src/error.rs
@@ -2,6 +2,16 @@ use thiserror::Error;
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum RadrootsNostrConnectError {
+ #[error("NIP-46 request encryption failed: {reason}")]
+ Encrypt { reason: String },
+ #[error("NIP-46 response decryption failed: {reason}")]
+ Decrypt { reason: String },
+ #[error("NIP-46 event signing failed: {reason}")]
+ Sign { reason: String },
+ #[error("NIP-46 transport failed: {reason}")]
+ Transport { reason: String },
+ #[error("NIP-46 request timed out")]
+ RequestTimedOut,
#[error("invalid NIP-46 method `{0}`")]
InvalidMethod(String),
#[error("invalid NIP-46 permission `{0}`")]
diff --git a/crates/nostr_connect/src/lib.rs b/crates/nostr_connect/src/lib.rs
@@ -1,5 +1,6 @@
#![forbid(unsafe_code)]
+pub mod client;
pub mod error;
pub mod message;
pub mod method;
@@ -7,6 +8,12 @@ pub mod permission;
pub mod uri;
pub mod prelude {
+ pub use crate::client::{
+ RadrootsNostrConnectClientEventOutcome, RadrootsNostrConnectClientProgress,
+ RadrootsNostrConnectClientRequest, RadrootsNostrConnectClientTarget,
+ RadrootsNostrConnectClientTransport, RadrootsNostrConnectClientTransportFuture,
+ build_request_event, execute_request_with_transport, parse_response_event,
+ };
pub use crate::error::RadrootsNostrConnectError;
pub use crate::message::{
RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR, RADROOTS_NOSTR_CONNECT_RPC_KIND,
diff --git a/crates/nostr_connect/tests/client.rs b/crates/nostr_connect/tests/client.rs
@@ -0,0 +1,443 @@
+#[path = "../src/test_fixtures.rs"]
+mod test_fixtures;
+
+use nostr::nips::nip44::{self, Version};
+use nostr::{
+ Event, EventBuilder, Keys, Kind, PublicKey, RelayUrl, SecretKey, Tag, Timestamp, UnsignedEvent,
+};
+use radroots_nostr_connect::prelude::{
+ RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectClientEventOutcome,
+ RadrootsNostrConnectClientProgress, RadrootsNostrConnectClientRequest,
+ RadrootsNostrConnectClientTarget, RadrootsNostrConnectClientTransport,
+ RadrootsNostrConnectClientTransportFuture, RadrootsNostrConnectError,
+ RadrootsNostrConnectMethod, RadrootsNostrConnectRemoteSessionCapability,
+ RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse,
+ build_request_event, execute_request_with_transport, parse_response_event,
+};
+use std::collections::VecDeque;
+use test_fixtures::{FIXTURE_ALICE, FIXTURE_BOB, FIXTURE_CAROL, RELAY_PRIMARY_WSS};
+
+fn keys(secret_key_hex: &str) -> Keys {
+ let secret_key = SecretKey::from_hex(secret_key_hex).expect("secret key");
+ Keys::new(secret_key)
+}
+
+fn client_keys() -> Keys {
+ keys(FIXTURE_ALICE.secret_key_hex)
+}
+
+fn remote_signer_keys() -> Keys {
+ keys(FIXTURE_BOB.secret_key_hex)
+}
+
+fn other_keys() -> Keys {
+ keys(FIXTURE_CAROL.secret_key_hex)
+}
+
+fn relay() -> RelayUrl {
+ RelayUrl::parse(RELAY_PRIMARY_WSS).expect("relay")
+}
+
+fn target(remote_keys: &Keys) -> RadrootsNostrConnectClientTarget {
+ RadrootsNostrConnectClientTarget::new(remote_keys.public_key(), vec![relay()])
+}
+
+fn unsigned_event(pubkey: PublicKey) -> UnsignedEvent {
+ EventBuilder::text_note("remote signing")
+ .custom_created_at(Timestamp::from(1_714_078_911))
+ .build(pubkey)
+}
+
+fn signed_event(keys: &Keys) -> Event {
+ EventBuilder::text_note("signed remotely")
+ .custom_created_at(Timestamp::from(1_714_078_911))
+ .sign_with_keys(keys)
+ .expect("signed event")
+}
+
+fn response_event(
+ remote_keys: &Keys,
+ client_public_key: PublicKey,
+ request_id: &str,
+ response: RadrootsNostrConnectResponse,
+) -> Event {
+ let envelope = response
+ .into_envelope(request_id)
+ .expect("response envelope");
+ let payload = serde_json::to_string(&envelope).expect("response payload");
+ let ciphertext = nip44::encrypt(
+ remote_keys.secret_key(),
+ &client_public_key,
+ payload,
+ Version::V2,
+ )
+ .expect("response ciphertext");
+
+ EventBuilder::new(Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND), ciphertext)
+ .tag(Tag::public_key(client_public_key))
+ .sign_with_keys(remote_keys)
+ .expect("response event")
+}
+
+fn untagged_response_event(
+ remote_keys: &Keys,
+ client_public_key: PublicKey,
+ request_id: &str,
+ response: RadrootsNostrConnectResponse,
+) -> Event {
+ let envelope = response
+ .into_envelope(request_id)
+ .expect("response envelope");
+ let payload = serde_json::to_string(&envelope).expect("response payload");
+ let ciphertext = nip44::encrypt(
+ remote_keys.secret_key(),
+ &client_public_key,
+ payload,
+ Version::V2,
+ )
+ .expect("response ciphertext");
+
+ EventBuilder::new(Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND), ciphertext)
+ .sign_with_keys(remote_keys)
+ .expect("response event")
+}
+
+fn remote_session_capability(remote_keys: &Keys) -> RadrootsNostrConnectRemoteSessionCapability {
+ RadrootsNostrConnectRemoteSessionCapability {
+ user_public_key: remote_keys.public_key(),
+ relays: vec![relay()],
+ permissions: Vec::new().into(),
+ }
+}
+
+struct MockTransport {
+ published: Vec<Event>,
+ inbound: VecDeque<Event>,
+}
+
+impl MockTransport {
+ fn new(inbound: Vec<Event>) -> Self {
+ Self {
+ published: Vec::new(),
+ inbound: inbound.into(),
+ }
+ }
+}
+
+impl RadrootsNostrConnectClientTransport for MockTransport {
+ fn publish_request_event<'a>(
+ &'a mut self,
+ event: Event,
+ ) -> RadrootsNostrConnectClientTransportFuture<'a, ()> {
+ self.published.push(event);
+ Box::pin(async { Ok(()) })
+ }
+
+ fn next_response_event<'a>(
+ &'a mut self,
+ ) -> RadrootsNostrConnectClientTransportFuture<'a, Event> {
+ let next = self.inbound.pop_front();
+ Box::pin(async move { next.ok_or(RadrootsNostrConnectError::RequestTimedOut) })
+ }
+}
+
+#[tokio::test]
+async fn executes_connect_request_and_secret_echo_response() {
+ let client_keys = client_keys();
+ let remote_keys = remote_signer_keys();
+ let target = target(&remote_keys);
+ let mut transport = MockTransport::new(vec![response_event(
+ &remote_keys,
+ client_keys.public_key(),
+ "req-connect",
+ RadrootsNostrConnectResponse::ConnectSecretEcho("connect-secret".to_owned()),
+ )]);
+
+ let response = execute_request_with_transport(
+ &client_keys,
+ &target,
+ RadrootsNostrConnectClientRequest::new(
+ "req-connect",
+ RadrootsNostrConnectRequest::Connect {
+ remote_signer_public_key: remote_keys.public_key(),
+ secret: Some("connect-secret".to_owned()),
+ requested_permissions: Vec::new().into(),
+ },
+ ),
+ &mut transport,
+ |_| Ok(()),
+ )
+ .await
+ .expect("connect response");
+
+ assert_eq!(
+ response,
+ RadrootsNostrConnectResponse::ConnectSecretEcho("connect-secret".to_owned())
+ );
+ assert_eq!(transport.published.len(), 1);
+}
+
+#[tokio::test]
+async fn executes_capability_request_and_typed_response() {
+ let client_keys = client_keys();
+ let remote_keys = remote_signer_keys();
+ let target = target(&remote_keys);
+ let capability = remote_session_capability(&remote_keys);
+ let mut transport = MockTransport::new(vec![response_event(
+ &remote_keys,
+ client_keys.public_key(),
+ "req-capability",
+ RadrootsNostrConnectResponse::RemoteSessionCapability(capability.clone()),
+ )]);
+
+ let response = execute_request_with_transport(
+ &client_keys,
+ &target,
+ RadrootsNostrConnectClientRequest::new(
+ "req-capability",
+ RadrootsNostrConnectRequest::GetSessionCapability,
+ ),
+ &mut transport,
+ |_| Ok(()),
+ )
+ .await
+ .expect("capability response");
+
+ assert_eq!(
+ response,
+ RadrootsNostrConnectResponse::RemoteSessionCapability(capability)
+ );
+ assert_eq!(transport.published.len(), 1);
+}
+
+#[tokio::test]
+async fn reports_timeout_when_transport_has_no_matching_response() {
+ let client_keys = client_keys();
+ let remote_keys = remote_signer_keys();
+ let target = target(&remote_keys);
+ let mut transport = MockTransport::new(Vec::new());
+
+ let error = execute_request_with_transport(
+ &client_keys,
+ &target,
+ RadrootsNostrConnectClientRequest::new("req-timeout", RadrootsNostrConnectRequest::Ping),
+ &mut transport,
+ |_| Ok(()),
+ )
+ .await
+ .expect_err("timeout");
+
+ assert_eq!(error, RadrootsNostrConnectError::RequestTimedOut);
+ assert_eq!(transport.published.len(), 1);
+}
+
+#[test]
+fn builds_encrypted_request_event_for_remote_signer() {
+ let client_keys = client_keys();
+ let remote_keys = remote_signer_keys();
+ let target = target(&remote_keys);
+ let message =
+ RadrootsNostrConnectRequestMessage::new("req-ping", RadrootsNostrConnectRequest::Ping);
+
+ let event = build_request_event(&client_keys, &target, message.clone()).expect("event");
+
+ assert_eq!(event.kind, Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND));
+ assert_eq!(event.pubkey, client_keys.public_key());
+ assert!(
+ event
+ .tags
+ .public_keys()
+ .any(|public_key| *public_key == remote_keys.public_key())
+ );
+ assert!(!event.content.contains("ping"));
+
+ let decrypted = nip44::decrypt(
+ remote_keys.secret_key(),
+ &client_keys.public_key(),
+ &event.content,
+ )
+ .expect("decrypt request");
+ let decoded: RadrootsNostrConnectRequestMessage =
+ serde_json::from_str(&decrypted).expect("decode request");
+ assert_eq!(decoded, message);
+}
+
+#[test]
+fn ignores_response_from_unexpected_signer_identity() {
+ let client_keys = client_keys();
+ let remote_keys = remote_signer_keys();
+ let other_keys = other_keys();
+ let target = target(&remote_keys);
+ let response = response_event(
+ &other_keys,
+ client_keys.public_key(),
+ "req-ping",
+ RadrootsNostrConnectResponse::Pong,
+ );
+
+ let outcome = parse_response_event(
+ &client_keys,
+ &target,
+ "req-ping",
+ &RadrootsNostrConnectMethod::Ping,
+ &response,
+ )
+ .expect("parse response");
+
+ assert_eq!(outcome, RadrootsNostrConnectClientEventOutcome::Ignore);
+}
+
+#[tokio::test]
+async fn executes_request_through_transport_with_auth_progress() {
+ let client_keys = client_keys();
+ let remote_keys = remote_signer_keys();
+ let target = target(&remote_keys);
+ let signed = signed_event(&remote_keys);
+ let inbound = vec![
+ response_event(
+ &remote_keys,
+ client_keys.public_key(),
+ "other-request",
+ RadrootsNostrConnectResponse::Pong,
+ ),
+ response_event(
+ &remote_keys,
+ client_keys.public_key(),
+ "req-sign",
+ RadrootsNostrConnectResponse::AuthUrl("https://auth.example.com/challenge".to_owned()),
+ ),
+ response_event(
+ &remote_keys,
+ client_keys.public_key(),
+ "req-sign",
+ RadrootsNostrConnectResponse::SignedEvent(signed.clone()),
+ ),
+ ];
+ let mut transport = MockTransport::new(inbound);
+ let mut progress = Vec::new();
+
+ let response = execute_request_with_transport(
+ &client_keys,
+ &target,
+ RadrootsNostrConnectClientRequest::new(
+ "req-sign",
+ RadrootsNostrConnectRequest::SignEvent(unsigned_event(remote_keys.public_key())),
+ ),
+ &mut transport,
+ |event| {
+ progress.push(event);
+ Ok(())
+ },
+ )
+ .await
+ .expect("response");
+
+ assert_eq!(transport.published.len(), 1);
+ assert_eq!(
+ progress,
+ vec![RadrootsNostrConnectClientProgress::AuthChallenge {
+ url: "https://auth.example.com/challenge".to_owned()
+ }]
+ );
+ assert_eq!(response, RadrootsNostrConnectResponse::SignedEvent(signed));
+}
+
+#[tokio::test]
+async fn ignores_events_not_addressed_by_expected_signer_and_client() {
+ let client_keys = client_keys();
+ let remote_keys = remote_signer_keys();
+ let other_keys = other_keys();
+ let target = target(&remote_keys);
+ let wrong_author = EventBuilder::new(
+ Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND),
+ "not encrypted for this client",
+ )
+ .tag(Tag::public_key(client_keys.public_key()))
+ .sign_with_keys(&other_keys)
+ .expect("wrong author event");
+ let missing_client_tag = untagged_response_event(
+ &remote_keys,
+ client_keys.public_key(),
+ "req-ping",
+ RadrootsNostrConnectResponse::Pong,
+ );
+ let valid = response_event(
+ &remote_keys,
+ client_keys.public_key(),
+ "req-ping",
+ RadrootsNostrConnectResponse::Pong,
+ );
+ let mut transport = MockTransport::new(vec![wrong_author, missing_client_tag, valid]);
+
+ let response = execute_request_with_transport(
+ &client_keys,
+ &target,
+ RadrootsNostrConnectClientRequest::new("req-ping", RadrootsNostrConnectRequest::Ping),
+ &mut transport,
+ |_| Ok(()),
+ )
+ .await
+ .expect("response");
+
+ assert_eq!(response, RadrootsNostrConnectResponse::Pong);
+ assert_eq!(transport.published.len(), 1);
+}
+
+#[test]
+fn reports_decryption_failure_from_expected_signer() {
+ let client_keys = client_keys();
+ let remote_keys = remote_signer_keys();
+ let target = target(&remote_keys);
+ let malformed = EventBuilder::new(
+ Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND),
+ "not nip44 ciphertext",
+ )
+ .tag(Tag::public_key(client_keys.public_key()))
+ .sign_with_keys(&remote_keys)
+ .expect("malformed response");
+
+ let error = parse_response_event(
+ &client_keys,
+ &target,
+ "req-ping",
+ &RadrootsNostrConnectMethod::Ping,
+ &malformed,
+ )
+ .expect_err("decrypt failure");
+
+ assert!(matches!(
+ error,
+ RadrootsNostrConnectError::Decrypt { reason } if !reason.is_empty()
+ ));
+}
+
+#[test]
+fn parses_auth_challenge_as_progress_without_consuming_final_response() {
+ let client_keys = client_keys();
+ let remote_keys = remote_signer_keys();
+ let target = target(&remote_keys);
+ let auth = response_event(
+ &remote_keys,
+ client_keys.public_key(),
+ "req-sign",
+ RadrootsNostrConnectResponse::AuthUrl("https://auth.example.com/continue".to_owned()),
+ );
+
+ let outcome = parse_response_event(
+ &client_keys,
+ &target,
+ "req-sign",
+ &RadrootsNostrConnectMethod::SignEvent,
+ &auth,
+ )
+ .expect("parse auth");
+
+ assert_eq!(
+ outcome,
+ RadrootsNostrConnectClientEventOutcome::Progress(
+ RadrootsNostrConnectClientProgress::AuthChallenge {
+ url: "https://auth.example.com/continue".to_owned()
+ }
+ )
+ );
+}