myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

commit a344de4d5c51e40906184993beb1ac16ebcf2e97
parent e1c1e3d527c49304d234c2c374c03b1169ca6769
Author: triesap <tyson@radroots.org>
Date:   Fri, 27 Mar 2026 02:15:52 +0000

interop: add restart recovery coverage

Diffstat:
Mtests/nip46_e2e.rs | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 138 insertions(+), 2 deletions(-)

diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs @@ -29,13 +29,13 @@ use nostr::{ use radroots_identity::RadrootsIdentity; use radroots_nostr::prelude::{ RadrootsNostrApplicationHandlerSpec, RadrootsNostrClient, RadrootsNostrEventBuilder, - RadrootsNostrKind, RadrootsNostrMetadata, RadrootsNostrRelayUrl, + RadrootsNostrKind, RadrootsNostrMetadata, RadrootsNostrRelayUrl, RadrootsNostrTag, radroots_nostr_build_application_handler_event, }; use radroots_nostr_connect::prelude::{ RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectClientMetadata, RadrootsNostrConnectClientUri, RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, - RadrootsNostrConnectResponseEnvelope, RadrootsNostrConnectUri, + RadrootsNostrConnectResponse, RadrootsNostrConnectResponseEnvelope, RadrootsNostrConnectUri, }; use radroots_nostr_signer::prelude::{ RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerAuthState, @@ -1636,6 +1636,142 @@ async fn live_listener_works_with_sqlite_signer_state_and_runtime_audit() -> Tes } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn external_nostr_client_recovers_connect_response_after_restart() -> TestResult<()> { + let relay = TestRelay::spawn().await?; + let test_runtime = MycTestRuntime::new(relay.url(), MycConnectionApproval::NotRequired); + let MycTestRuntime { + _temp: _tempdir, + runtime, + } = test_runtime; + let config = runtime.config().clone(); + let signer_public_key = runtime.signer_identity().public_key(); + let user_public_key = runtime.user_identity().public_key(); + let client_identity = + identity("5757575757575757575757575757575757575757575757575757575757575757"); + let base_created_at = Timestamp::now().as_secs(); + let connect_request_id = "external-recovery-connect"; + let connect_request = ExternalNostrConnectRequest::Connect { + remote_signer_public_key: signer_public_key, + secret: None, + }; + let request_message = build_external_request_message(connect_request_id, &connect_request); + let request_event = build_external_request_event( + &client_identity, + signer_public_key, + &request_message, + base_created_at, + ); + publish_event(relay.url(), &request_event).await?; + + let relay_url: RadrootsNostrRelayUrl = relay.url().parse()?; + let manager = runtime.signer_manager()?; + let connection = manager.register_connection( + RadrootsNostrSignerConnectionDraft::new( + client_identity.public_key(), + runtime.user_public_identity(), + ) + .with_relays(vec![relay_url.clone()]) + .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::NotRequired), + )?; + let response_envelope = + RadrootsNostrConnectResponse::ConnectAcknowledged.into_envelope(connect_request_id)?; + let response_payload = serde_json::to_string(&response_envelope)?; + let signer_identity = identity(runtime.signer_identity().secret_key_hex().as_str()); + let response_ciphertext = nip44::encrypt( + signer_identity.keys().secret_key(), + &client_identity.public_key(), + response_payload, + Version::V2, + )?; + let response_event = runtime.signer_identity().sign_event_builder( + RadrootsNostrEventBuilder::new( + RadrootsNostrKind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND), + response_ciphertext, + ) + .tags(vec![RadrootsNostrTag::public_key( + client_identity.public_key(), + )]), + "external recovery queued connect response", + )?; + let queued_record = MycDeliveryOutboxRecord::new( + MycDeliveryOutboxKind::ListenerResponsePublish, + response_event, + vec![relay_url], + )? + .with_connection_id(&connection.connection_id) + .with_request_id(connect_request_id); + runtime.delivery_outbox_store().enqueue(&queued_record)?; + assert_eq!( + queued_record.kind, + MycDeliveryOutboxKind::ListenerResponsePublish + ); + + let restarted_runtime = MycRuntime::bootstrap(config.clone())?; + let persisted_queued_record = restarted_runtime + .delivery_outbox_store() + .list_all()? + .into_iter() + .find(|record| record.request_id.as_deref() == Some(connect_request_id)) + .expect("persisted queued external connect record"); + assert_eq!( + persisted_queued_record.status, + MycDeliveryOutboxStatus::Queued + ); + + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + let service_runtime = restarted_runtime.clone(); + let restarted_listener_task = tokio::spawn(async move { + service_runtime + .run_until(async { + let _ = shutdown_rx.await; + }) + .await + }); + + let (_, connect_response) = wait_for_external_response( + &relay, + &client_identity, + signer_public_key, + connect_request_id, + ExternalNostrConnectMethod::Connect, + ) + .await?; + assert_eq!(connect_response.result, Some(ExternalResponseResult::Ack)); + assert_eq!(connect_response.error, None); + + let (_, get_public_key_response) = publish_external_request_and_wait_for_response( + &relay, + &client_identity, + signer_public_key, + "external-recovery-get-public-key", + ExternalNostrConnectRequest::GetPublicKey, + base_created_at + 1, + ) + .await?; + assert_eq!( + get_public_key_response.result, + Some(ExternalResponseResult::GetPublicKey(user_public_key)) + ); + assert_eq!(get_public_key_response.error, None); + + let _ = shutdown_tx.send(()); + restarted_listener_task.await??; + + let finalized_runtime = MycRuntime::bootstrap(config)?; + let finalized_record = finalized_runtime + .delivery_outbox_store() + .list_all()? + .into_iter() + .find(|record| record.request_id.as_deref() == Some(connect_request_id)) + .expect("finalized external connect recovery record"); + assert_eq!(finalized_record.status, MycDeliveryOutboxStatus::Finalized); + assert!(finalized_record.published_at_unix.is_some()); + assert!(finalized_record.finalized_at_unix.is_some()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn startup_recovery_republishes_queued_listener_connect_secret_job() -> TestResult<()> { let relay = TestRelay::spawn().await?; let test_runtime = MycTestRuntime::new_with_transport_relays(