Skip to content

Commit 40ba340

Browse files
authored
feat: backport wallet-sdk heartbeat (#22948) to v4-next (#22956)
Backport of #22948 to `v4-next`. ## Original PR > Implements a liveness check on wallets, avoiding promises hanging forever. Due to proving taking a long time, the global backwards compatible timer is set to 5min (since every request can refresh the timer), but once we deprecate `v4` we can tighten it. Closes: - https://linear.app/aztec-labs/issue/F-510/audit-143-silent-error-swallowing-in-wallet-extension-decryption - https://linear.app/aztec-labs/issue/F-511/audit-142-extensionwalletpostmessage-has-no-timeout-requests-hang ## Conflict resolution The cherry-pick produced a single **modify/delete** conflict on `docs/examples/webapp-tutorial/test-extension/src/background.ts` — that tutorial example file exists only on `next`, not on `v4-next`. All 8 wallet-sdk source files applied cleanly. The conflict was resolved by dropping the docs/example file (it's not part of v4-next); the wallet-sdk content is identical to the original PR. Three-commit history preserved per backport guidelines: 1. `feat: wallet-sdk heartbeat (#22948)` — original cherry-pick, with the tutorial file added (as left in tree by git) 2. `fix: resolve cherry-pick conflicts in webapp-tutorial background.ts` — remove the tutorial file 3. *(no build-fix commit needed — all foundation/aztec.js imports resolve cleanly on v4-next)* Detailed analysis: https://gist.github.com/AztecBot/78418f5937bd4b60a797ea26f477479d ClaudeBox log: https://claudebox.work/s/da6b542d03dd5623?run=1
2 parents c89b966 + dde6862 commit 40ba340

9 files changed

Lines changed: 487 additions & 28 deletions

File tree

noir-projects/aztec-nr/aztec/src/contract_self/mod.nr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ pub mod contract_self_private;
22
pub mod contract_self_public;
33
pub mod contract_self_utility;
44

5-
pub use contract_self_private::PrivateUtilityCalls;
65
pub use contract_self_private::ContractSelfPrivate;
6+
pub use contract_self_private::PrivateUtilityCalls;
77
pub use contract_self_public::ContractSelfPublic;
88
pub use contract_self_utility::ContractSelfUtility;

yarn-project/wallet-sdk/src/extension/handlers/background_connection_handler.ts

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
type WalletMessage,
1818
WalletMessageType,
1919
type WalletResponse,
20+
type WalletSdkLogger,
2021
} from '../../types.js';
2122
import {
2223
type BackgroundMessage,
@@ -131,6 +132,8 @@ export interface BackgroundConnectionConfig {
131132
walletVersion: string;
132133
/** Optional wallet icon URL. */
133134
walletIcon?: string;
135+
/** Logger used for diagnostics. */
136+
logger: WalletSdkLogger;
134137
}
135138

136139
/**
@@ -149,6 +152,7 @@ export interface BackgroundConnectionConfig {
149152
* walletId: 'my-wallet',
150153
* walletName: 'My Wallet',
151154
* walletVersion: '1.0.0',
155+
* logger: console,
152156
* },
153157
* {
154158
* sendToTab: (tabId, message) => browser.tabs.sendMessage(tabId, message),
@@ -167,12 +171,15 @@ export interface BackgroundConnectionConfig {
167171
export class BackgroundConnectionHandler {
168172
private pendingDiscoveries = new Map<string, PendingDiscovery>();
169173
private activeSessions = new Map<string, ActiveSession>();
174+
private log: WalletSdkLogger;
170175

171176
constructor(
172177
private config: BackgroundConnectionConfig,
173178
private transport: BackgroundTransport,
174179
private callbacks: BackgroundConnectionCallbacks = {},
175-
) {}
180+
) {
181+
this.log = config.logger;
182+
}
176183

177184
initialize(): void {
178185
this.transport.addContentListener(this.handleMessage);
@@ -198,8 +205,8 @@ export class BackgroundConnectionHandler {
198205
break;
199206
case InternalMessageType.KEY_EXCHANGE_REQUEST:
200207
if (sessionId) {
201-
this.handleKeyExchangeRequest(sessionId, content as KeyExchangeRequest).catch(() => {
202-
// Key exchange failed - session won't be established
208+
this.handleKeyExchangeRequest(sessionId, content as KeyExchangeRequest).catch(err => {
209+
this.log.warn('Key exchange failed session will not be established', { sessionId, err });
203210
});
204211
}
205212
break;
@@ -213,9 +220,31 @@ export class BackgroundConnectionHandler {
213220
void this.handleEncryptedMessage(sessionId, content as EncryptedPayload);
214221
}
215222
break;
223+
case InternalMessageType.PING:
224+
if (sessionId) {
225+
this.handlePing(sessionId);
226+
}
227+
break;
216228
}
217229
};
218230

231+
/**
232+
* Reply to a dApp PING with a PONG. Used as a liveness probe so the dApp can
233+
* tell the difference between a slow request and a dead extension.
234+
* @param sessionId - The session that sent the PING.
235+
*/
236+
private handlePing(sessionId: string): void {
237+
const session = this.activeSessions.get(sessionId);
238+
if (!session) {
239+
return;
240+
}
241+
this.transport.sendToTab(session.tabId, {
242+
origin: MessageOrigin.BACKGROUND,
243+
type: InternalMessageType.PONG,
244+
sessionId,
245+
});
246+
}
247+
219248
getWalletInfo(): WalletInfo {
220249
return {
221250
id: this.config.walletId,
@@ -315,8 +344,8 @@ export class BackgroundConnectionHandler {
315344
});
316345

317346
this.callbacks.onSessionEstablished?.(session);
318-
} catch {
319-
// Key exchange failed silently - session won't be established
347+
} catch (err) {
348+
this.log.warn('Key exchange failed session will not be established', { sessionId, err });
320349
}
321350
}
322351

@@ -329,8 +358,8 @@ export class BackgroundConnectionHandler {
329358
try {
330359
const message = await decrypt<WalletMessage>(session.sharedKey, encrypted);
331360
this.callbacks.onWalletMessage?.(session, message);
332-
} catch {
333-
// Decryption failed - ignore malformed message
361+
} catch (err) {
362+
this.log.warn('Failed to decrypt incoming wallet message', { sessionId, err });
334363
}
335364
}
336365

@@ -348,8 +377,12 @@ export class BackgroundConnectionHandler {
348377
sessionId,
349378
content: encrypted,
350379
});
351-
} catch {
352-
// Encryption failed - response won't be sent
380+
} catch (err) {
381+
this.log.error('Failed to encrypt wallet response — response will not be sent', {
382+
sessionId,
383+
messageId: response.messageId,
384+
err,
385+
});
353386
}
354387
}
355388

yarn-project/wallet-sdk/src/extension/handlers/content_script_connection_handler.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,20 @@ export class ContentScriptConnectionHandler {
139139
case InternalMessageType.SESSION_DISCONNECTED:
140140
this.handleSessionDisconnected(sessionId);
141141
break;
142+
case InternalMessageType.PONG:
143+
this.handlePong(sessionId);
144+
break;
142145
}
143146
};
144147

148+
private handlePong(sessionId: string): void {
149+
const connection = this.ports.get(sessionId);
150+
if (!connection) {
151+
return;
152+
}
153+
connection.port.postMessage({ type: WalletMessageType.PONG });
154+
}
155+
145156
private handleDiscoveryRequest(request: DiscoveryRequest): void {
146157
this.transport.sendToBackground({
147158
origin: MessageOrigin.CONTENT_SCRIPT,
@@ -178,6 +189,13 @@ export class ContentScriptConnectionHandler {
178189
content: data,
179190
});
180191
break;
192+
case WalletMessageType.PING:
193+
this.transport.sendToBackground({
194+
origin: MessageOrigin.CONTENT_SCRIPT,
195+
type: InternalMessageType.PING,
196+
sessionId,
197+
});
198+
break;
181199
default:
182200
this.transport.sendToBackground({
183201
origin: MessageOrigin.CONTENT_SCRIPT,

yarn-project/wallet-sdk/src/extension/handlers/internal_message_types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ export const InternalMessageType = {
99
KEY_EXCHANGE_REQUEST: 'key-exchange-request',
1010
SECURE_MESSAGE: 'secure-message',
1111
DISCONNECT_REQUEST: 'disconnect-request',
12+
PING: 'ping',
1213
// Background → Content script
1314
DISCOVERY_APPROVED: 'discovery-approved',
1415
KEY_EXCHANGE_RESPONSE: 'key-exchange-response',
1516
SECURE_RESPONSE: 'secure-response',
1617
SESSION_DISCONNECTED: 'session-disconnected',
18+
PONG: 'pong',
1719
} as const;
1820

1921
/**
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import type { ChainInfo } from '@aztec/aztec.js/account';
2+
import { Fr } from '@aztec/foundation/curves/bn254';
3+
import { jsonStringify } from '@aztec/foundation/json-rpc';
4+
import { promiseWithResolvers } from '@aztec/foundation/promise';
5+
import { sleep } from '@aztec/foundation/sleep';
6+
7+
import {
8+
type EncryptedPayload,
9+
decrypt,
10+
deriveSessionKeys,
11+
encrypt,
12+
exportPublicKey,
13+
generateKeyPair,
14+
importPublicKey,
15+
} from '../../crypto.js';
16+
import { type WalletMessage, WalletMessageType, type WalletResponse } from '../../types.js';
17+
import { ExtensionWallet } from './extension_wallet.js';
18+
19+
async function makeSharedKeys(): Promise<{ appKey: CryptoKey; walletKey: CryptoKey }> {
20+
const appPair = await generateKeyPair();
21+
const walletPair = await generateKeyPair();
22+
23+
const appPub = await exportPublicKey(appPair.publicKey);
24+
const walletPub = await exportPublicKey(walletPair.publicKey);
25+
26+
const appSession = await deriveSessionKeys(appPair, await importPublicKey(walletPub), true);
27+
const walletSession = await deriveSessionKeys(walletPair, await importPublicKey(appPub), false);
28+
29+
return { appKey: appSession.encryptionKey, walletKey: walletSession.encryptionKey };
30+
}
31+
32+
const chainInfo: ChainInfo = { chainId: new Fr(1), version: new Fr(1) };
33+
34+
// Tight heartbeat tuning so tests run quickly. Real defaults (5s/300s) would take
35+
// a minute per test.
36+
const FAST_HEARTBEAT = { intervalMs: 50, deadAfterMs: 250 };
37+
38+
describe('ExtensionWallet heartbeat', () => {
39+
it('treats PONG as proof of liveness — no false disconnect on slow but alive peer', async () => {
40+
const { appKey, walletKey } = await makeSharedKeys();
41+
const channel = new MessageChannel();
42+
const walletId = 'test-wallet';
43+
44+
const requestArrived = promiseWithResolvers<WalletMessage>();
45+
let pongs = 0;
46+
47+
channel.port2.onmessage = async (event: MessageEvent) => {
48+
const data = event.data;
49+
if (data?.type === WalletMessageType.PING) {
50+
pongs++;
51+
channel.port2.postMessage({ type: WalletMessageType.PONG });
52+
return;
53+
}
54+
const decoded = await decrypt<WalletMessage>(walletKey, data as EncryptedPayload);
55+
requestArrived.resolve(decoded);
56+
};
57+
channel.port2.start();
58+
59+
const extWallet = ExtensionWallet.create(
60+
walletId,
61+
channel.port1,
62+
appKey,
63+
chainInfo,
64+
'test-app',
65+
undefined,
66+
FAST_HEARTBEAT,
67+
);
68+
const wallet = extWallet.asWallet();
69+
70+
const pending = wallet.getChainInfo();
71+
pending.catch(() => {});
72+
const message = await requestArrived.promise;
73+
74+
// Let several heartbeat ticks fire while the peer responds with PONGs only —
75+
// no real response yet. This is the slow-but-alive-wallet scenario. Wait
76+
// ~3× the dead-after window to prove that PONGs alone keep the channel alive.
77+
await sleep(FAST_HEARTBEAT.deadAfterMs * 3);
78+
79+
expect(pongs).toBeGreaterThan(0);
80+
expect(extWallet.isDisconnected()).toBe(false);
81+
82+
// Now have the wallet respond. The dApp must settle the promise — the channel
83+
// is alive. We send an error to avoid having to construct a schema-valid
84+
// result object; what matters here is that the in-flight promise *settles*
85+
// rather than hanging forever after the heartbeat-eligible window passes.
86+
const response: WalletResponse = {
87+
messageId: message.messageId,
88+
error: 'simulated wallet response',
89+
walletId,
90+
};
91+
const encrypted = await encrypt(walletKey, jsonStringify(response));
92+
// Wallet side posts on port2 so the dApp receives on port1.
93+
channel.port2.postMessage(encrypted);
94+
95+
await expect(pending).rejects.toThrow(/simulated/);
96+
97+
channel.port1.close();
98+
channel.port2.close();
99+
});
100+
101+
it('declares disconnect when the channel is fully silent past the dead window', async () => {
102+
const { appKey } = await makeSharedKeys();
103+
const channel = new MessageChannel();
104+
// Deliberately do NOT wire up port2 — peer is fully unresponsive.
105+
channel.port2.start();
106+
107+
const extWallet = ExtensionWallet.create(
108+
'dead-wallet',
109+
channel.port1,
110+
appKey,
111+
chainInfo,
112+
'test-app',
113+
undefined,
114+
FAST_HEARTBEAT,
115+
);
116+
const wallet = extWallet.asWallet();
117+
118+
let disconnected = false;
119+
extWallet.onDisconnect(() => {
120+
disconnected = true;
121+
});
122+
123+
const pending = wallet.getChainInfo();
124+
125+
await expect(pending).rejects.toThrow(/disconnected/i);
126+
expect(disconnected).toBe(true);
127+
expect(extWallet.isDisconnected()).toBe(true);
128+
129+
channel.port2.close();
130+
});
131+
132+
it('does not start a heartbeat when there are no in-flight requests', async () => {
133+
const { appKey } = await makeSharedKeys();
134+
const channel = new MessageChannel();
135+
136+
let pings = 0;
137+
channel.port2.onmessage = (event: MessageEvent) => {
138+
if (event.data?.type === WalletMessageType.PING) {
139+
pings++;
140+
}
141+
};
142+
channel.port2.start();
143+
144+
ExtensionWallet.create('idle-wallet', channel.port1, appKey, chainInfo, 'test-app', undefined, FAST_HEARTBEAT);
145+
146+
await sleep(150);
147+
148+
expect(pings).toBe(0);
149+
150+
channel.port1.close();
151+
channel.port2.close();
152+
});
153+
});

0 commit comments

Comments
 (0)