fix(pairing): subscribe to signer replies BEFORE the handshake publishes

The relay pushes events only to subscriptions that exist at delivery time.
Both connect flows spawned the handshake task before run_demux registered the
kind-24133 author subscription, so a fast signer reply (the live Sep 23 Amber
scan: clean connect + secret echo, then get_public_key) landed in the gap and
was silently dropped — the identity RPC timed out 30s later and the session
died with 'The signer would not reveal its public key' after the connection
was already stored, leaving the UI signed in with no profile.

Both run_sign_task (bunker flow) and run_paired (QR pairing) now subscribe
first via subscribe_to_signer and hand the stream + subscription to run_demux.
futures-util promoted from dev-dependency to runtime. cargo test 209+3e2e
green, clippy 0, fmt clean, release rebuilt.
This commit is contained in:
Avi 2026-09-23 09:00:17 -05:00
commit 2e98e69ddf
2 changed files with 46 additions and 10 deletions

View file

@ -20,6 +20,7 @@ rpassword = "7"
sha2 = "0.10"
async-trait = "0.1"
keyring = "4.2"
futures-util = "0.3"
[dev-dependencies]
base64 = "0.22"

View file

@ -870,6 +870,14 @@ impl Nip46ClientSigner {
conversation: ConversationKey,
client: Client,
) -> Result<(), String> {
// Subscribe BEFORE the handshake publishes anything: relays only push
// events to subscriptions that exist at delivery time, and the
// identity RPC (`get_public_key`) fires within milliseconds of this
// point. When the spawn raced ahead of the subscription, Amber's fast
// reply landed in the gap and was lost — the live Sep 23 scan died
// exactly there ("signer would not reveal its public key" after a
// clean `connect` + secret echo).
let (notifications, subscription) = self.subscribe_to_signer(&client, uri.peer).await?;
{
let handshake = self.clone();
let peer = uri.peer;
@ -879,7 +887,31 @@ impl Nip46ClientSigner {
}
});
}
self.run_demux(uri, keys, conversation, client).await
self.run_demux(uri, keys, conversation, client, notifications, subscription)
.await
}
/// Open the notification stream and register the kind-24133 author
/// subscription for the signer. Kept separate so every connect path can
/// establish it BEFORE publishing its first RPC.
async fn subscribe_to_signer(
&self,
client: &Client,
peer: PublicKey,
) -> Result<
(
std::pin::Pin<Box<dyn futures_util::Stream<Item = ClientNotification> + Send>>,
Output<SubscriptionId>,
),
String,
> {
let filter = Filter::new().kind(Kind::NostrConnect).author(peer);
let notifications = client.notifications();
let subscription = client
.subscribe(filter)
.await
.map_err(|e| format!("Could not subscribe: {e}"))?;
Ok((notifications, subscription))
}
/// Get current connection status.
@ -1177,6 +1209,12 @@ impl Nip46ClientSigner {
// Update connected relays
self.inner.lock().await.client = Some(client.clone());
// Subscribe BEFORE the handshake publishes `connect`: relays only
// push the signer's replies to subscriptions that exist at delivery
// time, so registering after the first publish can silently drop the
// ack (see run_paired for the live incident this fixes).
let (notifications, subscription) = self.subscribe_to_signer(&client, uri.peer).await?;
// The connect handshake runs as its own task: it publishes `connect`
// and then must AWAIT the signer's ack — which can wait on a human
// approving us in Amber — followed by `get_public_key` to learn the
@ -1195,7 +1233,8 @@ impl Nip46ClientSigner {
});
}
self.run_demux(uri, keys, conversation, client).await
self.run_demux(uri, keys, conversation, client, notifications, subscription)
.await
}
/// The long-lived request/response demux loop, shared by both connect
@ -1209,15 +1248,11 @@ impl Nip46ClientSigner {
keys: Keys,
conversation: ConversationKey,
client: Client,
mut notifications: std::pin::Pin<
Box<dyn futures_util::Stream<Item = ClientNotification> + Send>,
>,
subscription: Output<SubscriptionId>,
) -> Result<(), String> {
// Subscribe to kind 24133 from signer
let filter = Filter::new().kind(Kind::NostrConnect).author(uri.peer);
let mut notifications = client.notifications();
let subscription = client
.subscribe(filter)
.await
.map_err(|e| format!("Could not subscribe: {e}"))?;
// Handle incoming requests
loop {
let incoming =