fix(wc): pairing diagnostics + 45s visible-timeout

Aegis (and anything built on @wizardconnect/dapp 0.2.x) can pair on its
own side and never advance ours — the dapp library's handleWalletReady
silently swallows invalid session payloads at dapp-connection-manager.js:436:

    if (!isHdwalletv1Session(sessionData)) {
      console.error("[wizardconnect/dapp] Invalid hdwalletv1 session data:", sessionData);
      return;   // ← no 'walletready', no 'disconnect' event — just a stuck chip
    }

When this fires, nothing in our chip updates. Previously the user watched
"connecting…" forever with no clue why.

Two additions:

Diagnostic ring buffer (last 40 relay messages):
  manager.on('messagereceived') / ('messagesent') / ('reconnecting') →
  push {t, dir, action, raw} into wc.diag, exposed as window.wcDiag.
  After a failed pairing attempt the user can paste
    JSON.stringify(window.wcDiag, (_,v) => typeof v === 'bigint' ? v.toString() : v, 2)
  from the browser console, which — combined with the console.error the
  dapp lib prints — tells us precisely which of the three silent-failure
  modes hit: session-shape rejected, protocol mismatch, or no inbound at
  all (relay dead).

Visible 45-second timeout:
  If snap.phase is still 'awaiting-scan' 45s after connect(), snap flips
  to 'error' with a message pointing the user at window.wcDiag. Any
  inbound message resets the timer (so a slow-reconnect but alive wallet
  doesn't false-trigger). cleanup() + walletready both clear the timer.

Zero behavior change in the happy path — the diagnostic listeners are
purely observational, and the timeout only fires when the dapp lib's
silent-return bug would have left us hung.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Local Dev 2026-10-09 00:20:51 +02:00
parent a7e501087b
commit e66a8d6c8a

View file

@ -31,6 +31,11 @@ export class WcClient extends EventEmitter<WcEvents> {
private manager: DappConnectionManager | null = null;
private relay: DappRelayResult | null = null;
private snap: SessionSnapshot = { ...initialSnapshot };
private pairingTimer: ReturnType<typeof setTimeout> | null = null;
/** Diagnostic log of raw relay messages — exposed as window.wcDiag so a user
* pairing with a non-stock wallet can show us what it sent. Capped at the
* last 40 messages so a long-lived session doesn't leak memory. */
readonly diag: Array<{ t: number; dir: 'in' | 'out'; action?: string; raw?: unknown }> = [];
readonly network: Network;
readonly dappName: string;
readonly dappIcon?: string;
@ -61,6 +66,25 @@ export class WcClient extends EventEmitter<WcEvents> {
manager.on('walletready', (msg: WalletReadyMessage) => this.onWalletReady(msg));
manager.on('disconnect', (_reason, message) => this.onDisconnect(message ?? 'wallet disconnected'));
// Diagnostic listeners — surface any raw relay traffic so we can see why
// a wallet pair-and-silence event happened. The dapp lib bails SILENTLY
// in handleWalletReady() when a wallet sends a session shape its validator
// doesn't accept (dapp-connection-manager.js:436) — the only breadcrumb
// is a console.error we might miss. Mirror everything here into `diag`.
const push = (dir: 'in' | 'out', action: string | undefined, raw: unknown) => {
this.diag.push({ t: Date.now(), dir, action, raw });
if (this.diag.length > 40) this.diag.shift();
};
manager.on('messagereceived', (msg: unknown) => {
const action = (msg as { action?: string } | null)?.action;
push('in', action, msg);
// Reset the pairing timeout on ANY inbound message — the wallet is
// alive, we just might not have reached `paired` yet.
if (this.pairingTimer) { clearTimeout(this.pairingTimer); this.pairingTimer = null; }
});
manager.on('messagesent', (msg: unknown) => push('out', (msg as { action?: string } | null)?.action, msg));
manager.on('reconnecting', () => push('in', 'relay-reconnecting', null));
try {
const relay = initiateDappRelay((client, status) => manager.updateConnection(client, status));
this.relay = relay;
@ -68,7 +92,19 @@ export class WcClient extends EventEmitter<WcEvents> {
this.update({ uri: relay.uri, qrUri: relay.qrUri });
} catch (e) {
this.onError(e instanceof Error ? e.message : String(e));
return { ...this.snap };
}
// Visible timeout — if nothing (not even a message) arrives in 45 seconds
// of the user scanning the QR, surface a real error so they don't stare
// at a frozen chip. 45s = long enough for Aegis + a slow relay reconnect;
// short enough that a genuinely-dead relay doesn't leave the user guessing.
this.pairingTimer = setTimeout(() => {
if (this.snap.phase === 'awaiting-scan') {
this.onError('wallet did not respond — scan the QR again, check that Aegis has internet, or copy the diagnostic from window.wcDiag');
}
}, 45_000);
return { ...this.snap };
}
@ -82,6 +118,7 @@ export class WcClient extends EventEmitter<WcEvents> {
}
try {
const cashaddr = cashaddrFromPubkey(pubkey, this.network);
if (this.pairingTimer) { clearTimeout(this.pairingTimer); this.pairingTimer = null; }
this.update({ phase: 'paired', walletName: msg.wallet_name ?? 'wallet', cashaddr, error: null });
} catch (e) {
this.onError('cashaddr derivation failed: ' + (e instanceof Error ? e.message : String(e)));
@ -130,6 +167,7 @@ export class WcClient extends EventEmitter<WcEvents> {
}
private cleanup(): void {
if (this.pairingTimer) { clearTimeout(this.pairingTimer); this.pairingTimer = null; }
try { this.manager?.sendDisconnect('bye').catch(() => {}); } catch { /* noop */ }
try { this.relay?.cleanup(); } catch { /* noop */ }
this.manager = null;