app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit e615e0ad2e2178da1d4fcef387d42e5c07808519
parent 71ce53ad12dfe62e61e14ef26d801049aee01a9b
Author: triesap <tyson@radroots.org>
Date:   Sat, 28 Mar 2026 17:30:28 +0000

app: tighten remote signer poll state handling

- replace pending string scraping with shared typed poll outcome classification
- distinguish signer rejection from transport failure during pending approval polling
- treat malformed NIP-46 signer responses as terminal protocol errors
- keep desktop iOS and android poll loops aligned on the shared outcome contract

Diffstat:
Mcrates/android/src/remote_signer.rs | 9++++++++-
Mcrates/desktop/src/remote_signer.rs | 9++++++++-
Mcrates/ios/src/remote_signer.rs | 9++++++++-
Mcrates/remote-signer/src/protocol.rs | 187++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
4 files changed, 182 insertions(+), 32 deletions(-)

diff --git a/crates/android/src/remote_signer.rs b/crates/android/src/remote_signer.rs @@ -157,7 +157,7 @@ impl AndroidRemoteSigner { .map_err(|error| error.to_string()) { Ok(RadrootsAppRemoteSignerPendingPollOutcome::PendingApproval) - | Ok(RadrootsAppRemoteSignerPendingPollOutcome::RetryableError { .. }) => { + | Ok(RadrootsAppRemoteSignerPendingPollOutcome::TransportFailure { .. }) => { std::thread::sleep(Duration::from_secs(1)); } Ok(RadrootsAppRemoteSignerPendingPollOutcome::Approved(user_identity)) => { @@ -176,6 +176,13 @@ impl AndroidRemoteSigner { tracker.polling.store(false, Ordering::Release); return; } + Ok(RadrootsAppRemoteSignerPendingPollOutcome::Rejected { message }) => { + let _ = remove_pending_session(); + let _ = remove_client_secret(client_account_id.as_str()); + tracker.push_update(Err(message)); + tracker.polling.store(false, Ordering::Release); + return; + } Ok(RadrootsAppRemoteSignerPendingPollOutcome::FatalError { message }) => { let _ = remove_pending_session(); let _ = remove_client_secret(client_account_id.as_str()); diff --git a/crates/desktop/src/remote_signer.rs b/crates/desktop/src/remote_signer.rs @@ -160,7 +160,7 @@ impl DesktopRemoteSigner { .map_err(|error| error.to_string()) { Ok(RadrootsAppRemoteSignerPendingPollOutcome::PendingApproval) - | Ok(RadrootsAppRemoteSignerPendingPollOutcome::RetryableError { .. }) => { + | Ok(RadrootsAppRemoteSignerPendingPollOutcome::TransportFailure { .. }) => { std::thread::sleep(Duration::from_secs(1)); } Ok(RadrootsAppRemoteSignerPendingPollOutcome::Approved(user_identity)) => { @@ -179,6 +179,13 @@ impl DesktopRemoteSigner { tracker.polling.store(false, Ordering::Release); return; } + Ok(RadrootsAppRemoteSignerPendingPollOutcome::Rejected { message }) => { + let _ = remove_pending_session(); + let _ = remove_client_secret(client_account_id.as_str()); + tracker.push_update(Err(message)); + tracker.polling.store(false, Ordering::Release); + return; + } Ok(RadrootsAppRemoteSignerPendingPollOutcome::FatalError { message }) => { let _ = remove_pending_session(); let _ = remove_client_secret(client_account_id.as_str()); diff --git a/crates/ios/src/remote_signer.rs b/crates/ios/src/remote_signer.rs @@ -157,7 +157,7 @@ impl IosRemoteSigner { .map_err(|error| error.to_string()) { Ok(RadrootsAppRemoteSignerPendingPollOutcome::PendingApproval) - | Ok(RadrootsAppRemoteSignerPendingPollOutcome::RetryableError { .. }) => { + | Ok(RadrootsAppRemoteSignerPendingPollOutcome::TransportFailure { .. }) => { std::thread::sleep(Duration::from_secs(1)); } Ok(RadrootsAppRemoteSignerPendingPollOutcome::Approved(user_identity)) => { @@ -176,6 +176,13 @@ impl IosRemoteSigner { tracker.polling.store(false, Ordering::Release); return; } + Ok(RadrootsAppRemoteSignerPendingPollOutcome::Rejected { message }) => { + let _ = remove_pending_session(); + let _ = remove_client_secret(client_account_id.as_str()); + tracker.push_update(Err(message)); + tracker.polling.store(false, Ordering::Release); + return; + } Ok(RadrootsAppRemoteSignerPendingPollOutcome::FatalError { message }) => { let _ = remove_pending_session(); let _ = remove_client_secret(client_account_id.as_str()); diff --git a/crates/remote-signer/src/protocol.rs b/crates/remote-signer/src/protocol.rs @@ -22,6 +22,7 @@ use tokio::time::timeout; const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const GET_PUBLIC_KEY_TIMEOUT: Duration = Duration::from_secs(10); +const REMOTE_SIGNER_PENDING_APPROVAL_ERROR: &str = "connection is pending"; static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(1); @@ -35,7 +36,8 @@ pub struct RadrootsAppRemoteSignerPendingSession { pub enum RadrootsAppRemoteSignerPendingPollOutcome { PendingApproval, Approved(RadrootsIdentityPublic), - RetryableError { message: String }, + TransportFailure { message: String }, + Rejected { message: String }, FatalError { message: String }, } @@ -121,29 +123,8 @@ async fn poll_pending_session( ) .await { - Ok(RadrootsNostrConnectResponse::UserPublicKey(public_key)) => { - Ok(RadrootsAppRemoteSignerPendingPollOutcome::Approved( - RadrootsIdentityPublic::new(public_key), - )) - } - Ok(RadrootsNostrConnectResponse::Error { error, .. }) => { - if error.contains("pending") { - Ok(RadrootsAppRemoteSignerPendingPollOutcome::PendingApproval) - } else { - Ok(RadrootsAppRemoteSignerPendingPollOutcome::FatalError { message: error }) - } - } - Ok(other) => Ok(RadrootsAppRemoteSignerPendingPollOutcome::FatalError { - message: format!("unexpected remote signer response: {other:?}"), - }), - Err(RadrootsAppRemoteSignerError::RequestTimedOut { .. }) => { - Ok(RadrootsAppRemoteSignerPendingPollOutcome::RetryableError { - message: "remote signer did not respond yet".to_owned(), - }) - } - Err(error) => Ok(RadrootsAppRemoteSignerPendingPollOutcome::RetryableError { - message: error.to_string(), - }), + Ok(response) => Ok(classify_pending_poll_response(response)), + Err(error) => Ok(classify_pending_poll_error(error)), } } @@ -267,17 +248,82 @@ fn parse_response_event( &event.pubkey, &event.content, ) - .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?; - let envelope: RadrootsNostrConnectResponseEnvelope = serde_json::from_str(&decrypted) - .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?; + .map_err(|error| RadrootsAppRemoteSignerError::UnexpectedResponse { + method: method.clone(), + response: format!("failed to decrypt signer response: {error}"), + })?; + let envelope: RadrootsNostrConnectResponseEnvelope = + serde_json::from_str(&decrypted).map_err(|error| { + RadrootsAppRemoteSignerError::UnexpectedResponse { + method: method.clone(), + response: format!("failed to decode signer response envelope: {error}"), + } + })?; if envelope.id != request_id { return Ok(None); } - let response = RadrootsNostrConnectResponse::from_envelope(method, envelope) - .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?; + let response = + RadrootsNostrConnectResponse::from_envelope(method, envelope).map_err(|error| { + RadrootsAppRemoteSignerError::UnexpectedResponse { + method: method.clone(), + response: format!("failed to decode signer response payload: {error}"), + } + })?; Ok(Some(response)) } +fn classify_pending_poll_response( + response: RadrootsNostrConnectResponse, +) -> RadrootsAppRemoteSignerPendingPollOutcome { + match response { + RadrootsNostrConnectResponse::UserPublicKey(public_key) => { + RadrootsAppRemoteSignerPendingPollOutcome::Approved(RadrootsIdentityPublic::new( + public_key, + )) + } + RadrootsNostrConnectResponse::Error { result, error } => { + if result.is_none() && error == REMOTE_SIGNER_PENDING_APPROVAL_ERROR { + RadrootsAppRemoteSignerPendingPollOutcome::PendingApproval + } else { + RadrootsAppRemoteSignerPendingPollOutcome::Rejected { message: error } + } + } + RadrootsNostrConnectResponse::AuthUrl(url) => { + RadrootsAppRemoteSignerPendingPollOutcome::FatalError { + message: format!( + "remote signer requires an unsupported authorization challenge: {url}" + ), + } + } + other => RadrootsAppRemoteSignerPendingPollOutcome::FatalError { + message: format!("unexpected remote signer response: {other:?}"), + }, + } +} + +fn classify_pending_poll_error( + error: RadrootsAppRemoteSignerError, +) -> RadrootsAppRemoteSignerPendingPollOutcome { + match error { + RadrootsAppRemoteSignerError::RequestTimedOut { .. } => { + RadrootsAppRemoteSignerPendingPollOutcome::TransportFailure { + message: "remote signer did not respond yet".to_owned(), + } + } + RadrootsAppRemoteSignerError::ConnectFailed(message) => { + RadrootsAppRemoteSignerPendingPollOutcome::TransportFailure { message } + } + RadrootsAppRemoteSignerError::UnexpectedResponse { .. } => { + RadrootsAppRemoteSignerPendingPollOutcome::FatalError { + message: error.to_string(), + } + } + other => RadrootsAppRemoteSignerPendingPollOutcome::FatalError { + message: other.to_string(), + }, + } +} + fn next_request_id(prefix: &str) -> String { let tick = REQUEST_COUNTER.fetch_add(1, Ordering::AcqRel); format!("{prefix}-{tick}") @@ -288,3 +334,86 @@ fn parse_public_key_hex(value: &str) -> Result<nostr::PublicKey, RadrootsAppRemo .or_else(|_| nostr::PublicKey::from_hex(value)) .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string())) } + +#[cfg(test)] +mod tests { + use super::*; + use nostr::PublicKey; + use radroots_app_test_support::{FIXTURE_ALICE, fixture_identity}; + + fn fixture_public_key() -> PublicKey { + fixture_identity(&FIXTURE_ALICE) + .expect("identity") + .public_key() + } + + #[test] + fn pending_error_response_is_classified_as_pending_approval() { + let outcome = classify_pending_poll_response(RadrootsNostrConnectResponse::Error { + result: None, + error: REMOTE_SIGNER_PENDING_APPROVAL_ERROR.to_owned(), + }); + + assert_eq!( + outcome, + RadrootsAppRemoteSignerPendingPollOutcome::PendingApproval + ); + } + + #[test] + fn signer_error_response_is_classified_as_rejected() { + let outcome = classify_pending_poll_response(RadrootsNostrConnectResponse::Error { + result: None, + error: "unauthorized".to_owned(), + }); + + assert_eq!( + outcome, + RadrootsAppRemoteSignerPendingPollOutcome::Rejected { + message: "unauthorized".to_owned(), + } + ); + } + + #[test] + fn get_public_key_success_is_classified_as_approved() { + let outcome = classify_pending_poll_response(RadrootsNostrConnectResponse::UserPublicKey( + fixture_public_key(), + )); + + assert!(matches!( + outcome, + RadrootsAppRemoteSignerPendingPollOutcome::Approved(identity) + if identity.public_key_hex == fixture_public_key().to_hex() + )); + } + + #[test] + fn timeout_error_is_classified_as_transport_failure() { + let outcome = classify_pending_poll_error(RadrootsAppRemoteSignerError::RequestTimedOut { + method: RadrootsNostrConnectMethod::GetPublicKey, + }); + + assert_eq!( + outcome, + RadrootsAppRemoteSignerPendingPollOutcome::TransportFailure { + message: "remote signer did not respond yet".to_owned(), + } + ); + } + + #[test] + fn unexpected_response_error_is_fatal() { + let outcome = + classify_pending_poll_error(RadrootsAppRemoteSignerError::UnexpectedResponse { + method: RadrootsNostrConnectMethod::GetPublicKey, + response: "failed to decode signer response envelope: bad".to_owned(), + }); + + assert!(matches!( + outcome, + RadrootsAppRemoteSignerPendingPollOutcome::FatalError { message } + if message.contains("unexpected `get_public_key` response") + )); + } +}