@qrlwallet/connect 0.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -5,81 +5,142 @@ import EventEmitter4 from "eventemitter3";
5
5
  import EventEmitter3 from "eventemitter3";
6
6
  import { v4 as uuidv4 } from "uuid";
7
7
 
8
- // src/ECIESClient.ts
9
- import { PrivateKey, decrypt, encrypt } from "eciesjs";
8
+ // src/KeyExchange.ts
9
+ import EventEmitter from "eventemitter3";
10
10
 
11
- // src/utils/logger.ts
12
- var debugEnabled = false;
13
- function setDebug(enabled) {
14
- debugEnabled = enabled;
11
+ // src/PQCrypto.ts
12
+ import { ml_kem768 } from "@noble/post-quantum/ml-kem.js";
13
+ var textEncoder = new TextEncoder();
14
+ var LABEL = textEncoder.encode("pq-pair/v1");
15
+ var LABEL_AEAD_SUFFIX = textEncoder.encode(" aead");
16
+ var DIR_DAPP_TX = new Uint8Array([0, 0, 0, 1]);
17
+ var DIR_WALLET_TX = new Uint8Array([0, 0, 0, 2]);
18
+ var ML_KEM_768_CT_LEN = 1088;
19
+ var AEAD_KEY_LEN = 32;
20
+ function subtle() {
21
+ const c = globalThis.crypto;
22
+ if (!c || !c.subtle) {
23
+ throw new Error("PQCrypto: WebCrypto SubtleCrypto is not available in this environment");
24
+ }
25
+ return c.subtle;
15
26
  }
16
- function log(tag, ...args) {
17
- if (debugEnabled) {
18
- console.log(`[QRLConnect:${tag}]`, ...args);
19
- }
27
+ function bs(u) {
28
+ return u;
20
29
  }
21
- function warn(tag, ...args) {
22
- console.warn(`[QRLConnect:${tag}]`, ...args);
30
+ function kemKeygen() {
31
+ const { publicKey, secretKey } = ml_kem768.keygen();
32
+ return { pk: publicKey, sk: secretKey };
23
33
  }
24
- function error(tag, ...args) {
25
- console.error(`[QRLConnect:${tag}]`, ...args);
34
+ function kemEncaps(pk) {
35
+ const { cipherText, sharedSecret } = ml_kem768.encapsulate(pk);
36
+ return { ct: cipherText, ss: sharedSecret };
37
+ }
38
+ function kemDecaps(sk, ct) {
39
+ return ml_kem768.decapsulate(ct, sk);
40
+ }
41
+ async function transcriptHash(cid, pk, ct) {
42
+ const buf = concat(LABEL, cid, pk, ct);
43
+ return new Uint8Array(await subtle().digest("SHA-256", bs(buf)));
44
+ }
45
+ async function deriveAeadKey(ss, htx) {
46
+ const ikm = await subtle().importKey("raw", bs(ss), "HKDF", false, ["deriveKey"]);
47
+ const info = concat(LABEL, LABEL_AEAD_SUFFIX, htx);
48
+ return subtle().deriveKey(
49
+ { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(32), info: bs(info) },
50
+ ikm,
51
+ { name: "AES-GCM", length: 256 },
52
+ true,
53
+ ["encrypt", "decrypt"]
54
+ );
55
+ }
56
+ async function importRawAeadKey(raw) {
57
+ if (raw.length !== AEAD_KEY_LEN) {
58
+ throw new Error(`PQCrypto: raw AEAD key must be ${AEAD_KEY_LEN} bytes`);
59
+ }
60
+ return subtle().importKey("raw", bs(raw), { name: "AES-GCM", length: 256 }, true, [
61
+ "encrypt",
62
+ "decrypt"
63
+ ]);
64
+ }
65
+ async function exportRawAeadKey(key) {
66
+ const buf = await subtle().exportKey("raw", key);
67
+ return new Uint8Array(buf);
68
+ }
69
+ function nonce(dir, seq) {
70
+ if (dir.length !== 4) {
71
+ throw new Error("PQCrypto: direction tag must be 4 bytes");
72
+ }
73
+ const n = new Uint8Array(12);
74
+ n.set(dir, 0);
75
+ new DataView(n.buffer, n.byteOffset, n.byteLength).setBigUint64(4, BigInt(seq), true);
76
+ return n;
77
+ }
78
+ function aad(htx, seq) {
79
+ const out = new Uint8Array(htx.length + 8);
80
+ out.set(htx, 0);
81
+ new DataView(out.buffer, out.byteOffset, out.byteLength).setBigUint64(
82
+ htx.length,
83
+ BigInt(seq),
84
+ true
85
+ );
86
+ return out;
87
+ }
88
+ async function seal(key, dir, seq, htx, pt) {
89
+ const ct = await subtle().encrypt(
90
+ { name: "AES-GCM", iv: bs(nonce(dir, seq)), additionalData: bs(aad(htx, seq)) },
91
+ key,
92
+ bs(pt)
93
+ );
94
+ return new Uint8Array(ct);
95
+ }
96
+ async function open(key, dir, seq, htx, ct) {
97
+ const pt = await subtle().decrypt(
98
+ { name: "AES-GCM", iv: bs(nonce(dir, seq)), additionalData: bs(aad(htx, seq)) },
99
+ key,
100
+ bs(ct)
101
+ );
102
+ return new Uint8Array(pt);
103
+ }
104
+ function zeroize(b) {
105
+ b.fill(0);
106
+ }
107
+ function concat(...parts) {
108
+ let total = 0;
109
+ for (const p of parts) total += p.length;
110
+ const out = new Uint8Array(total);
111
+ let offset = 0;
112
+ for (const p of parts) {
113
+ out.set(p, offset);
114
+ offset += p.length;
115
+ }
116
+ return out;
26
117
  }
27
-
28
- // src/ECIESClient.ts
29
118
  function toBase64(bytes) {
30
- let binary = "";
31
- for (let i = 0; i < bytes.length; i++) {
32
- binary += String.fromCharCode(bytes[i]);
119
+ const CHUNK = 32768;
120
+ let bin = "";
121
+ for (let i = 0; i < bytes.length; i += CHUNK) {
122
+ const slice = bytes.subarray(i, i + CHUNK);
123
+ bin += String.fromCharCode.apply(null, slice);
33
124
  }
34
- return btoa(binary);
125
+ return btoa(bin);
35
126
  }
36
- function fromBase64(base64) {
37
- const binary = atob(base64);
38
- const bytes = new Uint8Array(binary.length);
39
- for (let i = 0; i < binary.length; i++) {
40
- bytes[i] = binary.charCodeAt(i);
41
- }
42
- return bytes;
127
+ function fromBase64(b64) {
128
+ const bin = atob(b64);
129
+ const out = new Uint8Array(bin.length);
130
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
131
+ return out;
132
+ }
133
+ function constantTimeEquals(a, b) {
134
+ if (a.length !== b.length) return false;
135
+ let d = 0;
136
+ for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i];
137
+ return d === 0;
43
138
  }
44
- var ECIESClient = class {
45
- constructor(existingPrivateKey) {
46
- if (existingPrivateKey) {
47
- this.privateKey = PrivateKey.fromHex(existingPrivateKey);
48
- log("ECIES", "Restored from existing private key");
49
- } else {
50
- this.privateKey = new PrivateKey();
51
- log("ECIES", "Generated new ECIES keypair");
52
- }
53
- }
54
- /** Get public key as hex string */
55
- getPublicKey() {
56
- return this.privateKey.publicKey.toHex();
57
- }
58
- /** Get private key as hex string (for session persistence) */
59
- getPrivateKeyHex() {
60
- return this.privateKey.toHex();
61
- }
62
- /** Encrypt a string message for the given public key. Returns base64. */
63
- encrypt(data, otherPublicKey) {
64
- const encoded = new TextEncoder().encode(data);
65
- const encrypted = encrypt(otherPublicKey, encoded);
66
- return toBase64(encrypted);
67
- }
68
- /** Decrypt a base64-encoded message using our private key. */
69
- decrypt(encryptedBase64) {
70
- const encryptedBytes = fromBase64(encryptedBase64);
71
- const decrypted = decrypt(this.privateKey.toHex(), encryptedBytes);
72
- return new TextDecoder().decode(decrypted);
73
- }
74
- };
75
-
76
- // src/KeyExchange.ts
77
- import EventEmitter from "eventemitter3";
78
139
 
79
140
  // src/config.ts
80
141
  var DEFAULT_RELAY_URL = "https://qrlwallet.com";
81
142
  var RELAY_PATH = "/relay";
82
- var PROTOCOL_VERSION = 1;
143
+ var PROTOCOL_VERSION = 2;
83
144
  var SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
84
145
  var STORAGE_KEY_PREFIX = "@qrlwallet/connect";
85
146
  var REQUEST_TIMEOUT_MS = 5 * 60 * 1e3;
@@ -165,149 +226,292 @@ var ConnectionStatus = /* @__PURE__ */ ((ConnectionStatus2) => {
165
226
  return ConnectionStatus2;
166
227
  })(ConnectionStatus || {});
167
228
 
229
+ // src/utils/logger.ts
230
+ var debugEnabled = false;
231
+ function setDebug(enabled) {
232
+ debugEnabled = enabled;
233
+ }
234
+ function log(tag, ...args) {
235
+ if (debugEnabled) {
236
+ console.log(`[QRLConnect:${tag}]`, ...args);
237
+ }
238
+ }
239
+ function warn(tag, ...args) {
240
+ console.warn(`[QRLConnect:${tag}]`, ...args);
241
+ }
242
+ function error(tag, ...args) {
243
+ console.error(`[QRLConnect:${tag}]`, ...args);
244
+ }
245
+
168
246
  // src/KeyExchange.ts
247
+ var textEncoder2 = new TextEncoder();
248
+ var textDecoder = new TextDecoder();
249
+ var HELLO_WALLET = textEncoder2.encode("hello/wallet/v1");
250
+ var HELLO_DAPP = textEncoder2.encode("hello/dapp/v1");
169
251
  var KeyExchange = class extends EventEmitter {
170
- constructor(ecies, isOriginator, otherPublicKey) {
252
+ constructor(isOriginator, restored) {
171
253
  super();
172
- this.otherPublicKey = null;
173
- this.keysExchanged = false;
254
+ this.keypair = null;
255
+ this.session = null;
256
+ this.step = "key_handshake_SYN" /* SYN */;
174
257
  this.awaitingSynAck = false;
175
258
  this.awaitingAck = false;
176
- this.step = "key_handshake_SYN" /* SYN */;
177
- this.ecies = ecies;
259
+ this.keysExchanged = false;
178
260
  this.isOriginator = isOriginator;
179
- if (otherPublicKey) {
180
- this.otherPublicKey = otherPublicKey;
261
+ if (restored) {
262
+ this.session = restored;
181
263
  this.keysExchanged = true;
182
- log("KeyExchange", "Restored with existing other public key");
264
+ this.step = "key_handshake_ACK" /* ACK */;
265
+ log("KeyExchange", "Hydrated from persisted session");
183
266
  }
184
267
  }
185
- /** Get the SYN message to start the handshake (originator/dApp side). */
186
- createSYN() {
268
+ /**
269
+ * Originator (dApp) side: begin a fresh handshake by generating an
270
+ * ephemeral ML-KEM-768 keypair. Returns pk to embed in the QR URI.
271
+ */
272
+ initiate() {
273
+ if (!this.isOriginator) {
274
+ throw new Error("KeyExchange: responder cannot initiate");
275
+ }
276
+ this.resetInternal(false);
277
+ this.keypair = kemKeygen();
187
278
  this.step = "key_handshake_SYN" /* SYN */;
188
279
  this.awaitingSynAck = true;
189
280
  this.emit("step_change", this.step);
281
+ return this.keypair.pk;
282
+ }
283
+ /**
284
+ * Originator: handle a SYNACK received over the relay.
285
+ * Returns an ACK wire message to send, or null if the SYNACK was a
286
+ * duplicate (handshake already complete).
287
+ */
288
+ async onSynAck(cid, msg) {
289
+ if (!this.isOriginator) {
290
+ warn("KeyExchange", "Responder received SYNACK \u2014 ignoring");
291
+ return null;
292
+ }
293
+ if (!this.awaitingSynAck || !this.keypair) {
294
+ return null;
295
+ }
296
+ this.awaitingSynAck = false;
297
+ const ct = fromBase64(msg.ct);
298
+ const c0 = fromBase64(msg.c0);
299
+ if (ct.length !== ML_KEM_768_CT_LEN) {
300
+ throw new Error(`KeyExchange: bad ct length ${ct.length}`);
301
+ }
302
+ const ss = kemDecaps(this.keypair.sk, ct);
303
+ const htx = await transcriptHash(cid, this.keypair.pk, ct);
304
+ const key = await deriveAeadKey(ss, htx);
305
+ let hello;
306
+ try {
307
+ hello = await open(key, DIR_WALLET_TX, 0, htx, c0);
308
+ } catch {
309
+ throw new Error(
310
+ "KeyExchange: wallet hello AEAD tag failed (tampered SYNACK, wrong QR, or protocol mismatch)"
311
+ );
312
+ }
313
+ if (!constantTimeEquals(hello, HELLO_WALLET)) {
314
+ throw new Error("KeyExchange: wallet hello mismatch");
315
+ }
316
+ zeroize(ss);
317
+ zeroize(this.keypair.sk);
318
+ this.keypair = null;
319
+ const c1 = await seal(key, DIR_DAPP_TX, 0, htx, HELLO_DAPP);
320
+ this.session = {
321
+ cid,
322
+ key,
323
+ htx,
324
+ sendDir: DIR_DAPP_TX,
325
+ recvDir: DIR_WALLET_TX,
326
+ sendSeq: 1,
327
+ recvSeq: 1
328
+ };
329
+ this.keysExchanged = true;
330
+ this.step = "key_handshake_ACK" /* ACK */;
331
+ this.emit("keys_exchanged");
332
+ this.emit("step_change", this.step);
190
333
  return {
191
- type: "key_handshake_SYN" /* SYN */,
192
- pubkey: this.ecies.getPublicKey(),
334
+ type: "key_handshake_ACK" /* ACK */,
335
+ c1: toBase64(c1),
193
336
  v: PROTOCOL_VERSION
194
337
  };
195
338
  }
196
339
  /**
197
- * Process an incoming key exchange message.
198
- * Returns a response message to send back, or null if handshake is complete.
340
+ * Responder (wallet) side: begin handshake from a scanned QR.
341
+ * Returns the SYNACK wire message to emit on the relay.
199
342
  */
200
- onMessage(message) {
201
- log("KeyExchange", `Received ${message.type}, isOriginator=${this.isOriginator}`);
202
- switch (message.type) {
203
- case "key_handshake_SYN" /* SYN */: {
204
- if (this.isOriginator) {
205
- warn("KeyExchange", "Originator received SYN - ignoring");
206
- return null;
207
- }
208
- if (message.pubkey) {
209
- this.otherPublicKey = message.pubkey;
210
- }
211
- this.step = "key_handshake_SYNACK" /* SYNACK */;
212
- this.awaitingAck = true;
213
- this.emit("step_change", this.step);
214
- return {
215
- type: "key_handshake_SYNACK" /* SYNACK */,
216
- pubkey: this.ecies.getPublicKey(),
217
- v: PROTOCOL_VERSION
218
- };
219
- }
220
- case "key_handshake_SYNACK" /* SYNACK */: {
221
- if (!this.isOriginator) {
222
- warn("KeyExchange", "Non-originator received SYNACK - ignoring");
223
- return null;
224
- }
225
- if (message.pubkey) {
226
- this.otherPublicKey = message.pubkey;
227
- }
228
- if (!this.awaitingSynAck) {
229
- return {
230
- type: "key_handshake_ACK" /* ACK */,
231
- v: PROTOCOL_VERSION
232
- };
233
- }
234
- this.awaitingSynAck = false;
235
- this.step = "key_handshake_ACK" /* ACK */;
236
- if (!this.keysExchanged) {
237
- this.keysExchanged = true;
238
- this.emit("keys_exchanged");
239
- }
240
- this.emit("step_change", this.step);
241
- return {
242
- type: "key_handshake_ACK" /* ACK */,
243
- v: PROTOCOL_VERSION
244
- };
245
- }
246
- case "key_handshake_ACK" /* ACK */: {
247
- if (this.isOriginator) {
248
- warn("KeyExchange", "Originator received ACK - ignoring");
249
- return null;
250
- }
251
- if (!this.awaitingAck) {
252
- return null;
253
- }
254
- this.awaitingAck = false;
255
- if (!this.keysExchanged) {
256
- this.keysExchanged = true;
257
- this.emit("keys_exchanged");
258
- }
259
- log("KeyExchange", "Key exchange complete (wallet side)");
260
- return null;
261
- }
262
- default:
263
- warn("KeyExchange", `Unknown key exchange type: ${message.type}`);
264
- return null;
343
+ async receiveQR(cid, pk) {
344
+ if (this.isOriginator) {
345
+ throw new Error("KeyExchange: originator cannot consume a QR");
265
346
  }
347
+ this.resetInternal(false);
348
+ const { ct, ss } = kemEncaps(pk);
349
+ const htx = await transcriptHash(cid, pk, ct);
350
+ const key = await deriveAeadKey(ss, htx);
351
+ const c0 = await seal(key, DIR_WALLET_TX, 0, htx, HELLO_WALLET);
352
+ zeroize(ss);
353
+ this.session = {
354
+ cid,
355
+ key,
356
+ htx,
357
+ sendDir: DIR_WALLET_TX,
358
+ recvDir: DIR_DAPP_TX,
359
+ sendSeq: 1,
360
+ recvSeq: 1
361
+ };
362
+ this.awaitingAck = true;
363
+ this.step = "key_handshake_SYNACK" /* SYNACK */;
364
+ this.emit("step_change", this.step);
365
+ return {
366
+ type: "key_handshake_SYNACK" /* SYNACK */,
367
+ ct: toBase64(ct),
368
+ c0: toBase64(c0),
369
+ v: PROTOCOL_VERSION
370
+ };
266
371
  }
267
- /** Encrypt a message for the counterparty. */
268
- encryptMessage(data) {
269
- if (!this.otherPublicKey) {
270
- throw new Error("Cannot encrypt: key exchange not complete");
372
+ /**
373
+ * Responder: verify an incoming ACK and finalize the session.
374
+ * Duplicates are silently ignored (handshake is idempotent).
375
+ */
376
+ async onAck(msg) {
377
+ if (this.isOriginator) {
378
+ warn("KeyExchange", "Originator received ACK \u2014 ignoring");
379
+ return;
271
380
  }
272
- return this.ecies.encrypt(data, this.otherPublicKey);
273
- }
274
- /** Decrypt a message from the counterparty. */
275
- decryptMessage(encryptedBase64) {
276
- return this.ecies.decrypt(encryptedBase64);
277
- }
278
- /** Reset key exchange state for a fresh handshake (e.g. new QR code). */
381
+ if (!this.awaitingAck) {
382
+ return;
383
+ }
384
+ if (!this.session) {
385
+ throw new Error("KeyExchange: onAck called without a session");
386
+ }
387
+ this.awaitingAck = false;
388
+ const c1 = fromBase64(msg.c1);
389
+ let hello;
390
+ try {
391
+ hello = await open(this.session.key, DIR_DAPP_TX, 0, this.session.htx, c1);
392
+ } catch {
393
+ throw new Error("KeyExchange: dApp hello AEAD tag failed");
394
+ }
395
+ if (!constantTimeEquals(hello, HELLO_DAPP)) {
396
+ throw new Error("KeyExchange: dApp hello mismatch");
397
+ }
398
+ this.keysExchanged = true;
399
+ this.step = "key_handshake_ACK" /* ACK */;
400
+ this.emit("keys_exchanged");
401
+ }
402
+ /** Encrypt a string for the counterparty. Returns base64. */
403
+ async encryptMessage(data) {
404
+ if (!this.session) {
405
+ throw new Error("KeyExchange: cannot encrypt \u2014 session not established");
406
+ }
407
+ const pt = textEncoder2.encode(data);
408
+ const ct = await seal(
409
+ this.session.key,
410
+ this.session.sendDir,
411
+ this.session.sendSeq,
412
+ this.session.htx,
413
+ pt
414
+ );
415
+ this.session.sendSeq++;
416
+ return toBase64(ct);
417
+ }
418
+ /** Decrypt a base64 ciphertext from the counterparty. */
419
+ async decryptMessage(b64) {
420
+ if (!this.session) {
421
+ throw new Error("KeyExchange: cannot decrypt \u2014 session not established");
422
+ }
423
+ const ct = fromBase64(b64);
424
+ const pt = await open(
425
+ this.session.key,
426
+ this.session.recvDir,
427
+ this.session.recvSeq,
428
+ this.session.htx,
429
+ ct
430
+ );
431
+ this.session.recvSeq++;
432
+ return textDecoder.decode(pt);
433
+ }
434
+ /** Reset all state for a fresh handshake. Zeroes any live secret-key buffer. */
279
435
  reset() {
280
- this.otherPublicKey = null;
436
+ this.resetInternal(true);
437
+ }
438
+ resetInternal(emit) {
439
+ if (this.keypair) {
440
+ zeroize(this.keypair.sk);
441
+ this.keypair = null;
442
+ }
443
+ this.session = null;
281
444
  this.keysExchanged = false;
282
445
  this.awaitingSynAck = false;
283
446
  this.awaitingAck = false;
284
447
  this.step = "key_handshake_SYN" /* SYN */;
285
- log("KeyExchange", "Reset for fresh handshake");
448
+ if (emit) this.emit("step_change", this.step);
286
449
  }
287
450
  areKeysExchanged() {
288
451
  return this.keysExchanged;
289
452
  }
290
- getOtherPublicKey() {
291
- return this.otherPublicKey;
453
+ getSession() {
454
+ return this.session;
292
455
  }
293
456
  getCurrentStep() {
294
457
  return this.step;
295
458
  }
459
+ /** Export the current session as a JSON-safe persisted record. */
460
+ async exportPersisted() {
461
+ if (!this.session) return null;
462
+ return {
463
+ cid: toBase64(this.session.cid),
464
+ kAeadRaw: toBase64(await exportRawAeadKey(this.session.key)),
465
+ htx: toBase64(this.session.htx),
466
+ sendDir: toBase64(this.session.sendDir),
467
+ recvDir: toBase64(this.session.recvDir),
468
+ sendSeq: this.session.sendSeq,
469
+ recvSeq: this.session.recvSeq
470
+ };
471
+ }
472
+ /** Rehydrate a Session from its persisted form. */
473
+ static async sessionFromPersisted(p) {
474
+ const key = await importRawAeadKey(fromBase64(p.kAeadRaw));
475
+ return {
476
+ cid: fromBase64(p.cid),
477
+ key,
478
+ htx: fromBase64(p.htx),
479
+ sendDir: fromBase64(p.sendDir),
480
+ recvDir: fromBase64(p.recvDir),
481
+ sendSeq: p.sendSeq,
482
+ recvSeq: p.recvSeq
483
+ };
484
+ }
296
485
  };
297
486
 
298
487
  // src/SocketClient.ts
299
488
  import { io } from "socket.io-client";
300
489
  import EventEmitter2 from "eventemitter3";
490
+ var PENDING_JOIN_TIMEOUT_MS = 2e4;
301
491
  var SocketClient = class extends EventEmitter2 {
302
492
  constructor(relayUrl, clientType) {
303
493
  super();
304
494
  this.socket = null;
305
495
  this.channelId = null;
496
+ // The dApp's own PK (base64) that must be uploaded with every join
497
+ // attempt so the relay can bind it to the channel. Wallet-side leaves
498
+ // this undefined.
499
+ this.publicKeyBase64 = null;
306
500
  this.seq = 0;
307
501
  this.pendingJoin = null;
308
502
  this.relayUrl = relayUrl;
309
503
  this.clientType = clientType;
310
504
  }
505
+ /**
506
+ * dApp-only: stash the PK so it gets uploaded on every join_channel
507
+ * attempt (including auto-rejoins after reconnect).
508
+ */
509
+ setPublicKey(publicKeyBase64) {
510
+ if (this.clientType !== "dapp") {
511
+ throw new Error("SocketClient.setPublicKey is only valid for dApp clients");
512
+ }
513
+ this.publicKeyBase64 = publicKeyBase64;
514
+ }
311
515
  /**
312
516
  * Connect to the relay server.
313
517
  */
@@ -329,17 +533,11 @@ var SocketClient = class extends EventEmitter2 {
329
533
  if (this.channelId) {
330
534
  const reconnectChannelId = this.channelId;
331
535
  this.joinChannelNow(reconnectChannelId).then((result) => {
332
- if (this.pendingJoin && this.pendingJoin.channelId === reconnectChannelId) {
333
- this.pendingJoin.resolve(result);
334
- this.pendingJoin = null;
335
- }
536
+ this.settlePendingJoin(reconnectChannelId, { result });
336
537
  this.emit("reconnected");
337
538
  }).catch((err) => {
338
539
  const reconnectErr = err instanceof Error ? err : new Error(String(err));
339
- if (this.pendingJoin && this.pendingJoin.channelId === reconnectChannelId) {
340
- this.pendingJoin.reject(reconnectErr);
341
- this.pendingJoin = null;
342
- }
540
+ this.settlePendingJoin(reconnectChannelId, { error: reconnectErr });
343
541
  error("Socket", `Rejoin failed: ${reconnectErr.message}`);
344
542
  this.emit("error", reconnectErr);
345
543
  });
@@ -363,7 +561,24 @@ var SocketClient = class extends EventEmitter2 {
363
561
  });
364
562
  }
365
563
  /**
366
- * Join a relay channel.
564
+ * Resolve or reject the currently-pending `joinChannel` promise, if it
565
+ * matches the given channelId. Clears the watchdog timer on settle so
566
+ * it doesn't fire after the fact.
567
+ */
568
+ settlePendingJoin(channelId, outcome) {
569
+ if (!this.pendingJoin || this.pendingJoin.channelId !== channelId) return;
570
+ clearTimeout(this.pendingJoin.watchdog);
571
+ const pending = this.pendingJoin;
572
+ this.pendingJoin = null;
573
+ if ("result" in outcome) {
574
+ pending.resolve(outcome.result);
575
+ } else {
576
+ pending.reject(outcome.error);
577
+ }
578
+ }
579
+ /**
580
+ * Join a relay channel. Resolves with any buffered messages plus the
581
+ * dApp's public key bound to the channel (for wallet joins).
367
582
  */
368
583
  async joinChannel(channelId) {
369
584
  if (!this.socket) {
@@ -377,6 +592,7 @@ var SocketClient = class extends EventEmitter2 {
377
592
  if (this.pendingJoin.channelId === channelId) {
378
593
  return this.pendingJoin.promise;
379
594
  }
595
+ clearTimeout(this.pendingJoin.watchdog);
380
596
  this.pendingJoin.reject(new Error("Join request superseded by a newer channel"));
381
597
  this.pendingJoin = null;
382
598
  }
@@ -386,11 +602,17 @@ var SocketClient = class extends EventEmitter2 {
386
602
  resolvePending = resolve;
387
603
  rejectPending = reject;
388
604
  });
605
+ const watchdog = setTimeout(() => {
606
+ this.settlePendingJoin(channelId, {
607
+ error: new Error(`joinChannel timed out after ${PENDING_JOIN_TIMEOUT_MS}ms`)
608
+ });
609
+ }, PENDING_JOIN_TIMEOUT_MS);
389
610
  this.pendingJoin = {
390
611
  channelId,
391
612
  promise,
392
613
  resolve: resolvePending,
393
- reject: rejectPending
614
+ reject: rejectPending,
615
+ watchdog
394
616
  };
395
617
  return promise;
396
618
  }
@@ -400,13 +622,20 @@ var SocketClient = class extends EventEmitter2 {
400
622
  reject(new Error("Socket not connected"));
401
623
  return;
402
624
  }
625
+ const payload = { channelId, clientType: this.clientType };
626
+ if (this.clientType === "dapp" && this.publicKeyBase64) {
627
+ payload.publicKey = this.publicKeyBase64;
628
+ }
403
629
  this.socket.emit(
404
630
  "join_channel",
405
- { channelId, clientType: this.clientType },
631
+ payload,
406
632
  (response) => {
407
633
  if (response.success) {
408
634
  log("Socket", `Joined channel ${channelId}`);
409
- resolve({ bufferedMessages: response.bufferedMessages || [] });
635
+ resolve({
636
+ bufferedMessages: response.bufferedMessages || [],
637
+ channelPublicKey: response.channelPublicKey ?? null
638
+ });
410
639
  } else {
411
640
  error("Socket", `Failed to join channel: ${response.error}`);
412
641
  reject(new Error(response.error || "Failed to join channel"));
@@ -443,6 +672,7 @@ var SocketClient = class extends EventEmitter2 {
443
672
  */
444
673
  leaveChannel() {
445
674
  if (this.pendingJoin) {
675
+ clearTimeout(this.pendingJoin.watchdog);
446
676
  this.pendingJoin.reject(new Error("Channel left before join completed"));
447
677
  this.pendingJoin = null;
448
678
  }
@@ -456,6 +686,7 @@ var SocketClient = class extends EventEmitter2 {
456
686
  */
457
687
  disconnect() {
458
688
  if (this.pendingJoin) {
689
+ clearTimeout(this.pendingJoin.watchdog);
459
690
  this.pendingJoin.reject(new Error("Socket disconnected before join completed"));
460
691
  this.pendingJoin = null;
461
692
  }
@@ -475,111 +706,256 @@ var SocketClient = class extends EventEmitter2 {
475
706
  }
476
707
  };
477
708
 
709
+ // src/utils/base45.ts
710
+ var ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:";
711
+ var DECODE = (() => {
712
+ const table = new Int8Array(128).fill(-1);
713
+ for (let i = 0; i < ALPHABET.length; i++) {
714
+ table[ALPHABET.charCodeAt(i)] = i;
715
+ }
716
+ return table;
717
+ })();
718
+ function decodeChar(charCode) {
719
+ if (charCode < 0 || charCode >= 128) return -1;
720
+ return DECODE[charCode];
721
+ }
722
+ function base45Encode(bytes) {
723
+ const n = bytes.length;
724
+ let out = "";
725
+ let i = 0;
726
+ while (i + 2 <= n) {
727
+ const v = bytes[i] << 8 | bytes[i + 1];
728
+ const c2 = Math.floor(v / 2025);
729
+ const r = v - c2 * 2025;
730
+ const c1 = Math.floor(r / 45);
731
+ const c0 = r - c1 * 45;
732
+ out += ALPHABET[c0] + ALPHABET[c1] + ALPHABET[c2];
733
+ i += 2;
734
+ }
735
+ if (i < n) {
736
+ const v = bytes[i];
737
+ const c1 = Math.floor(v / 45);
738
+ const c0 = v - c1 * 45;
739
+ out += ALPHABET[c0] + ALPHABET[c1];
740
+ }
741
+ return out;
742
+ }
743
+ function base45Decode(s) {
744
+ const len = s.length;
745
+ if (len === 0) return new Uint8Array(0);
746
+ const full = Math.floor(len / 3);
747
+ const tail = len - full * 3;
748
+ if (tail !== 0 && tail !== 2) {
749
+ throw new Error("base45: invalid input length");
750
+ }
751
+ const outLen = full * 2 + (tail === 2 ? 1 : 0);
752
+ const out = new Uint8Array(outLen);
753
+ for (let g = 0; g < full; g++) {
754
+ const d0 = decodeChar(s.charCodeAt(g * 3));
755
+ const d1 = decodeChar(s.charCodeAt(g * 3 + 1));
756
+ const d2 = decodeChar(s.charCodeAt(g * 3 + 2));
757
+ if (d0 < 0 || d1 < 0 || d2 < 0) {
758
+ throw new Error("base45: invalid character");
759
+ }
760
+ const v = d0 + d1 * 45 + d2 * 2025;
761
+ if (v > 65535) {
762
+ throw new Error("base45: decoded group exceeds 0xFFFF");
763
+ }
764
+ out[g * 2] = v >> 8 & 255;
765
+ out[g * 2 + 1] = v & 255;
766
+ }
767
+ if (tail === 2) {
768
+ const d0 = decodeChar(s.charCodeAt(full * 3));
769
+ const d1 = decodeChar(s.charCodeAt(full * 3 + 1));
770
+ if (d0 < 0 || d1 < 0) {
771
+ throw new Error("base45: invalid character");
772
+ }
773
+ const v = d0 + d1 * 45;
774
+ if (v > 255) {
775
+ throw new Error("base45: decoded byte exceeds 0xFF");
776
+ }
777
+ out[outLen - 1] = v;
778
+ }
779
+ return out;
780
+ }
781
+
478
782
  // src/utils/qrUri.ts
479
- function generateConnectionURI(params) {
480
- const searchParams = new URLSearchParams({
481
- channelId: params.channelId,
482
- pubKey: params.pubKey,
483
- name: params.name,
484
- url: params.url,
485
- chainId: params.chainId,
486
- relay: params.relayUrl
487
- });
488
- if (params.icon) {
489
- searchParams.set("icon", params.icon);
490
- }
491
- return `qrlconnect://?${searchParams.toString()}`;
783
+ var MAGIC = new Uint8Array([80, 81, 80, 50]);
784
+ var FP_LABEL = new TextEncoder().encode("pq-fp/v2");
785
+ var CID_LEN = 16;
786
+ var FP_LEN = 32;
787
+ var BLOB_LEN = 4 + CID_LEN + FP_LEN;
788
+ function bs2(u) {
789
+ return u;
790
+ }
791
+ async function computeFingerprint(cid, pk) {
792
+ if (cid.length !== CID_LEN) {
793
+ throw new Error(`qrUri: cid must be ${CID_LEN} bytes`);
794
+ }
795
+ const c = globalThis.crypto;
796
+ if (!c || !c.subtle) {
797
+ throw new Error("qrUri: WebCrypto SubtleCrypto is not available");
798
+ }
799
+ const buf = new Uint8Array(FP_LABEL.length + cid.length + pk.length);
800
+ buf.set(FP_LABEL, 0);
801
+ buf.set(cid, FP_LABEL.length);
802
+ buf.set(pk, FP_LABEL.length + cid.length);
803
+ const digest = await c.subtle.digest("SHA-256", bs2(buf));
804
+ return new Uint8Array(digest);
805
+ }
806
+ function fingerprintEquals(a, b) {
807
+ if (a.length !== b.length) return false;
808
+ let diff = 0;
809
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
810
+ return diff === 0;
811
+ }
812
+ async function generateConnectionURI(cid, pk, relayUrl) {
813
+ if (cid.length !== CID_LEN) {
814
+ throw new Error(`qrUri: cid must be ${CID_LEN} bytes (got ${cid.length})`);
815
+ }
816
+ const fp = await computeFingerprint(cid, pk);
817
+ const blob = new Uint8Array(BLOB_LEN);
818
+ blob.set(MAGIC, 0);
819
+ blob.set(cid, 4);
820
+ blob.set(fp, 4 + CID_LEN);
821
+ const params = new URLSearchParams({ q: base45Encode(blob) });
822
+ if (relayUrl) params.set("r", relayUrl);
823
+ return `qrlconnect://?${params.toString()}`;
492
824
  }
493
- function parseConnectionURI(uri) {
825
+ var PQP1_MAGIC = new Uint8Array([80, 81, 80, 49]);
826
+ function startsWith(buf, prefix) {
827
+ if (buf.length < prefix.length) return false;
828
+ for (let i = 0; i < prefix.length; i++) {
829
+ if (buf[i] !== prefix[i]) return false;
830
+ }
831
+ return true;
832
+ }
833
+ async function parseConnectionURI(uri) {
834
+ if (typeof uri !== "string" || uri.length === 0) {
835
+ throw new Error("qrUri: empty URI");
836
+ }
837
+ if (!/^qrlconnect:/i.test(uri)) {
838
+ throw new Error("qrUri: not a qrlconnect URI");
839
+ }
840
+ let params;
494
841
  try {
495
- const queryString = uri.replace(/^qrlconnect:\/?\/?/, "");
496
- const params = new URLSearchParams(queryString);
497
- const channelId = params.get("channelId");
498
- const pubKey = params.get("pubKey");
499
- const name = params.get("name");
500
- const url = params.get("url");
501
- const chainId = params.get("chainId");
502
- const relayUrl = params.get("relay");
503
- if (!channelId || !pubKey || !name || !url || !chainId || !relayUrl) {
504
- return null;
505
- }
506
- return {
507
- channelId,
508
- pubKey,
509
- name,
510
- url,
511
- icon: params.get("icon") || void 0,
512
- chainId,
513
- relayUrl
514
- };
842
+ const swapped = new URL(uri.replace(/^qrlconnect:\/?\/?/i, "https://qrlconnect/"));
843
+ params = swapped.searchParams;
515
844
  } catch {
516
- return null;
845
+ throw new Error("qrUri: malformed URI");
846
+ }
847
+ if (params.has("channelId") || params.has("pubKey")) {
848
+ throw new Error(
849
+ "qrUri: legacy v1 URI detected \u2014 this wallet and this dApp must both run protocol v2"
850
+ );
517
851
  }
852
+ const q = params.get("q");
853
+ if (!q) {
854
+ throw new Error("qrUri: missing q parameter");
855
+ }
856
+ let blob;
857
+ try {
858
+ blob = base45Decode(q);
859
+ } catch (err) {
860
+ const msg = err instanceof Error ? err.message : String(err);
861
+ throw new Error(`qrUri: base45 decode failed: ${msg}`);
862
+ }
863
+ if (blob.length !== BLOB_LEN) {
864
+ if (blob.length === 1208 && startsWith(blob, PQP1_MAGIC)) {
865
+ throw new Error("qrUri: legacy PQP1 URI detected \u2014 regenerate the QR with a v2.0+ dApp SDK");
866
+ }
867
+ throw new Error(`qrUri: expected ${BLOB_LEN}-byte blob, got ${blob.length}`);
868
+ }
869
+ if (!startsWith(blob, MAGIC)) {
870
+ throw new Error("qrUri: bad PQP2 magic");
871
+ }
872
+ const cid = blob.slice(4, 4 + CID_LEN);
873
+ const fp = blob.slice(4 + CID_LEN, 4 + CID_LEN + FP_LEN);
874
+ const relayUrl = params.get("r") || void 0;
875
+ return { cid, fp, relayUrl };
876
+ }
877
+ function cidToString(cid) {
878
+ if (cid.length !== CID_LEN) {
879
+ throw new Error(`cidToString: expected ${CID_LEN}-byte cid`);
880
+ }
881
+ let hex = "";
882
+ for (let i = 0; i < CID_LEN; i++) {
883
+ hex += cid[i].toString(16).padStart(2, "0");
884
+ }
885
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
886
+ }
887
+ function cidFromString(s) {
888
+ const hex = s.replace(/-/g, "");
889
+ if (!/^[0-9a-f]{32}$/i.test(hex)) {
890
+ throw new Error("cidFromString: not a 128-bit hex string");
891
+ }
892
+ const out = new Uint8Array(CID_LEN);
893
+ for (let i = 0; i < CID_LEN; i++) {
894
+ out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
895
+ }
896
+ return out;
897
+ }
898
+ function cidRandom() {
899
+ const c = globalThis.crypto;
900
+ if (!c || typeof c.getRandomValues !== "function") {
901
+ throw new Error("qrUri: crypto.getRandomValues is not available");
902
+ }
903
+ return c.getRandomValues(new Uint8Array(CID_LEN));
518
904
  }
519
905
 
520
906
  // src/ConnectionManager.ts
521
907
  var DAPP_PARTICIPANT_CONFLICT_ERROR_MSG = "dapp participant is already connected";
908
+ var TERMINATE_SEND_TIMEOUT_MS = 800;
522
909
  var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
523
910
  constructor(options) {
524
911
  super();
912
+ this.keyExchange = null;
525
913
  this.status = "disconnected" /* DISCONNECTED */;
526
914
  this.connectedAccounts = [];
527
915
  this.unresponsiveTimer = null;
528
- this.synRetryTimer = null;
529
916
  this.failedReconnects = 0;
530
- this.synSent = false;
531
- this.synAttempts = 0;
532
917
  this.walletPresent = false;
918
+ this.pendingRestore = null;
919
+ this.messageQueue = Promise.resolve();
533
920
  this.dappMetadata = options.dappMetadata;
534
921
  this.relayUrl = options.relayUrl || DEFAULT_RELAY_URL;
535
922
  this.chainId = options.chainId || "0x0";
536
923
  this.storageKey = options.storageKey || `${STORAGE_KEY_PREFIX}:session`;
537
- const session = this.loadSession();
538
- if (session) {
539
- this.channelId = session.channelId;
540
- this.ecies = new ECIESClient(session.privateKey);
541
- this.keyExchange = new KeyExchange(
542
- this.ecies,
543
- true,
544
- // dApp is always originator
545
- session.otherPublicKey || void 0
546
- );
547
- this.connectedAccounts = session.connectedAccounts;
548
- log("ConnectionManager", `Restored session for channel ${this.channelId}`);
924
+ const stored = this.readStoredSession();
925
+ if (stored) {
926
+ this.channelId = stored.channelId;
927
+ this.connectedAccounts = stored.connectedAccounts;
928
+ this.chainId = stored.chainId;
929
+ this.dappMetadata = stored.dappMetadata;
930
+ this.pendingRestore = stored;
931
+ log("ConnectionManager", `Found persisted session for channel ${this.channelId}`);
549
932
  } else {
550
933
  this.channelId = uuidv4();
551
- this.ecies = new ECIESClient();
552
- this.keyExchange = new KeyExchange(this.ecies, true);
553
934
  }
554
935
  this.socketClient = new SocketClient(this.relayUrl, "dapp");
555
936
  this.setupSocketListeners();
556
- this.setupKeyExchangeListeners();
557
937
  }
938
+ // ── Setup ──────────────────────────────────────────────────
558
939
  setupSocketListeners() {
559
940
  this.socketClient.on("connected", () => {
560
941
  if (this.status === "reconnecting" /* RECONNECTING */) {
561
942
  this.failedReconnects = 0;
562
- log("ConnectionManager", "Reconnected to relay");
563
943
  }
564
944
  });
565
945
  this.socketClient.on("reconnected", () => {
566
- if (this.keyExchange.areKeysExchanged()) {
946
+ if (this.keyExchange?.areKeysExchanged()) {
567
947
  if (this.walletPresent) {
568
948
  this.setStatus("connected" /* CONNECTED */);
569
949
  this.failedReconnects = 0;
570
950
  } else {
571
951
  this.setStatus("waiting" /* WAITING */);
572
952
  }
573
- this.synAttempts = 0;
574
- this.clearSynRetryTimer();
575
953
  } else {
576
- this.sendSYN();
954
+ this.setStatus("waiting" /* WAITING */);
577
955
  }
578
956
  });
579
- this.socketClient.on("disconnected", (_reason) => {
957
+ this.socketClient.on("disconnected", () => {
580
958
  this.walletPresent = false;
581
- this.synSent = false;
582
- this.clearSynRetryTimer();
583
959
  if (this.status === "connected" /* CONNECTED */) {
584
960
  this.setStatus("reconnecting" /* RECONNECTING */);
585
961
  this.failedReconnects++;
@@ -589,7 +965,7 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
589
965
  }
590
966
  });
591
967
  this.socketClient.on("message", (data) => {
592
- this.handleRelayMessage(data);
968
+ this.enqueueRelayMessage(data);
593
969
  });
594
970
  this.socketClient.on("error", (err) => {
595
971
  this.emit("error", err);
@@ -599,20 +975,15 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
599
975
  this.walletPresent = true;
600
976
  log("ConnectionManager", "Wallet joined channel");
601
977
  this.clearUnresponsiveTimer();
602
- if (this.keyExchange.areKeysExchanged()) {
978
+ if (this.keyExchange?.areKeysExchanged()) {
603
979
  this.failedReconnects = 0;
604
980
  this.setStatus("connected" /* CONNECTED */);
605
- } else if (!this.synSent) {
606
- this.sendSYN();
607
981
  }
608
982
  }
609
983
  if (data.event === "disconnect" || data.event === "leave") {
610
984
  if (data.clientType === "wallet" || !data.clientType) {
611
985
  this.walletPresent = false;
612
- if (!this.keyExchange.areKeysExchanged()) {
613
- this.synSent = false;
614
- this.clearSynRetryTimer();
615
- } else if (this.status === "connected" /* CONNECTED */ || this.status === "reconnecting" /* RECONNECTING */) {
986
+ if (this.keyExchange?.areKeysExchanged() && (this.status === "connected" /* CONNECTED */ || this.status === "reconnecting" /* RECONNECTING */)) {
616
987
  this.setStatus("waiting" /* WAITING */);
617
988
  }
618
989
  log("ConnectionManager", "Wallet left channel");
@@ -621,15 +992,14 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
621
992
  });
622
993
  }
623
994
  setupKeyExchangeListeners() {
995
+ if (!this.keyExchange) return;
624
996
  this.keyExchange.on("keys_exchanged", () => {
625
997
  log("ConnectionManager", "Key exchange complete");
626
998
  this.setStatus("connected" /* CONNECTED */);
627
999
  this.walletPresent = true;
628
- this.synSent = false;
629
- this.synAttempts = 0;
630
- this.clearSynRetryTimer();
631
- this.saveSession();
632
- this.sendEncrypted({
1000
+ this.failedReconnects = 0;
1001
+ void this.persistSession();
1002
+ void this.sendEncrypted({
633
1003
  type: "originator_info" /* ORIGINATOR_INFO */,
634
1004
  originatorInfo: {
635
1005
  ...this.dappMetadata,
@@ -638,16 +1008,24 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
638
1008
  });
639
1009
  });
640
1010
  }
1011
+ // ── Public API ─────────────────────────────────────────────
641
1012
  /**
642
- * Generate the connection URI and start listening for wallet connection.
1013
+ * Generate a new v2 connection URI. Rotates channel id and keypair.
1014
+ * Returns a `qrlconnect://?q=…` URI safe for QR-rendering or deep-link.
643
1015
  */
644
1016
  async getConnectionURI(retryOnConflict = true) {
645
1017
  this.setStatus("connecting" /* CONNECTING */);
646
- this.keyExchange.reset();
1018
+ this.pendingRestore = null;
647
1019
  this.walletPresent = false;
648
- this.synSent = false;
649
- this.synAttempts = 0;
650
- this.clearSynRetryTimer();
1020
+ if (this.keyExchange) {
1021
+ this.keyExchange.reset();
1022
+ } else {
1023
+ this.keyExchange = new KeyExchange(true);
1024
+ this.setupKeyExchangeListeners();
1025
+ }
1026
+ const pk = this.keyExchange.initiate();
1027
+ this.channelId = uuidv4();
1028
+ this.socketClient.setPublicKey(toBase64(pk));
651
1029
  this.socketClient.connect();
652
1030
  try {
653
1031
  await this.socketClient.joinChannel(this.channelId);
@@ -657,48 +1035,51 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
657
1035
  "ConnectionManager",
658
1036
  "Channel already has an active dApp participant. Rotating to a fresh channel."
659
1037
  );
660
- this.resetForNewChannel();
1038
+ this.channelId = uuidv4();
661
1039
  return this.getConnectionURI(false);
662
1040
  }
663
1041
  this.setStatus("disconnected" /* DISCONNECTED */);
664
1042
  throw err;
665
1043
  }
666
1044
  this.setStatus("waiting" /* WAITING */);
667
- const uri = generateConnectionURI({
668
- channelId: this.channelId,
669
- pubKey: this.ecies.getPublicKey(),
670
- name: this.dappMetadata.name,
671
- url: this.dappMetadata.url,
672
- icon: this.dappMetadata.icon,
673
- chainId: this.chainId,
674
- relayUrl: this.relayUrl
675
- });
676
- log("ConnectionManager", `Generated connection URI for channel ${this.channelId}`);
1045
+ const uri = await generateConnectionURI(
1046
+ cidFromString(this.channelId),
1047
+ pk,
1048
+ this.relayUrl === DEFAULT_RELAY_URL ? void 0 : this.relayUrl
1049
+ );
1050
+ log("ConnectionManager", `Generated v2 connection URI for channel ${this.channelId}`);
677
1051
  return uri;
678
1052
  }
679
1053
  /**
680
1054
  * Reconnect to an existing session.
1055
+ * Returns false if there is nothing to restore.
681
1056
  */
682
1057
  async reconnect() {
683
- const session = this.loadSession();
684
- if (!session) return false;
1058
+ if (!this.pendingRestore) return false;
685
1059
  this.setStatus("reconnecting" /* RECONNECTING */);
686
1060
  this.walletPresent = false;
687
- this.synSent = false;
688
- this.synAttempts = 0;
689
- this.clearSynRetryTimer();
1061
+ try {
1062
+ const session = await KeyExchange.sessionFromPersisted(this.pendingRestore.keyExchange);
1063
+ this.keyExchange = new KeyExchange(true, session);
1064
+ this.setupKeyExchangeListeners();
1065
+ } catch (err) {
1066
+ error("ConnectionManager", "Failed to hydrate persisted session:", err);
1067
+ this.clearSession();
1068
+ this.pendingRestore = null;
1069
+ this.setStatus("disconnected" /* DISCONNECTED */);
1070
+ return false;
1071
+ }
690
1072
  this.socketClient.connect();
691
1073
  try {
692
1074
  const { bufferedMessages } = await this.socketClient.joinChannel(this.channelId);
693
1075
  for (const msg of bufferedMessages) {
694
- this.handleRelayMessage(msg);
1076
+ this.enqueueRelayMessage(msg);
695
1077
  }
696
- if (this.keyExchange.areKeysExchanged() && this.walletPresent) {
697
- this.setStatus("connected" /* CONNECTED */);
698
- } else if (this.keyExchange.areKeysExchanged()) {
699
- this.setStatus("waiting" /* WAITING */);
1078
+ await this.messageQueue;
1079
+ if (this.keyExchange?.areKeysExchanged()) {
1080
+ this.setStatus(this.walletPresent ? "connected" /* CONNECTED */ : "waiting" /* WAITING */);
700
1081
  } else {
701
- this.sendSYN();
1082
+ this.setStatus("waiting" /* WAITING */);
702
1083
  }
703
1084
  return true;
704
1085
  } catch (err) {
@@ -708,10 +1089,10 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
708
1089
  }
709
1090
  }
710
1091
  /**
711
- * Send a JSON-RPC request to the wallet.
1092
+ * Send a JSON-RPC request to the wallet (async, fire-and-forget).
712
1093
  */
713
1094
  sendJsonRpc(request) {
714
- if (!this.keyExchange.areKeysExchanged()) {
1095
+ if (!this.keyExchange?.areKeysExchanged()) {
715
1096
  throw new Error("Not connected: key exchange not complete");
716
1097
  }
717
1098
  this.sendEncrypted({
@@ -720,16 +1101,88 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
720
1101
  id: request.id,
721
1102
  method: request.method,
722
1103
  params: request.params
1104
+ }).catch((err) => {
1105
+ error("ConnectionManager", "Failed to send JSON-RPC:", err);
723
1106
  });
724
1107
  this.startUnresponsiveTimer();
725
1108
  }
1109
+ getStatus() {
1110
+ return this.status;
1111
+ }
1112
+ getAccounts() {
1113
+ return this.connectedAccounts;
1114
+ }
1115
+ getChainId() {
1116
+ return this.chainId;
1117
+ }
1118
+ getChannelId() {
1119
+ return this.channelId;
1120
+ }
1121
+ /** Check (sync) if a persisted session exists and has not expired. */
1122
+ hasStoredSession() {
1123
+ if (typeof localStorage === "undefined") return false;
1124
+ try {
1125
+ const raw = localStorage.getItem(this.storageKey);
1126
+ if (!raw) return false;
1127
+ const session = JSON.parse(raw);
1128
+ if (session.version !== 2) return false;
1129
+ return Date.now() - session.createdAt <= SESSION_TTL_MS;
1130
+ } catch {
1131
+ return false;
1132
+ }
1133
+ }
1134
+ /**
1135
+ * Best-effort TERMINATE delivery before we tear the socket down. Without
1136
+ * awaiting, the socket.disconnect() below would win the race against the
1137
+ * outbound emit, the wallet would only see `participants_changed:
1138
+ * disconnect` and enter its stale-session grace period instead of an
1139
+ * instant disconnect. Mirrors the wallet side's pattern.
1140
+ */
1141
+ async flushTerminate() {
1142
+ if (!this.keyExchange?.areKeysExchanged()) return;
1143
+ const send = this.sendEncrypted({ type: "terminate" /* TERMINATE */ }).catch(() => {
1144
+ });
1145
+ const timeout = new Promise((resolve) => setTimeout(resolve, TERMINATE_SEND_TIMEOUT_MS));
1146
+ await Promise.race([send, timeout]);
1147
+ }
726
1148
  /**
727
- * Handle an incoming relay message.
1149
+ * Reset to a fresh channel + keypair. Sends TERMINATE to any live peer,
1150
+ * drops the persisted session, and prepares a clean state for
1151
+ * getConnectionURI() to be called again.
728
1152
  */
729
- handleRelayMessage(data) {
1153
+ async resetForNewChannel() {
1154
+ this.clearUnresponsiveTimer();
1155
+ this.walletPresent = false;
1156
+ await this.flushTerminate();
1157
+ this.socketClient.leaveChannel();
1158
+ this.socketClient.disconnect();
1159
+ this.clearSession();
1160
+ this.connectedAccounts = [];
1161
+ this.pendingRestore = null;
1162
+ this.channelId = uuidv4();
1163
+ this.keyExchange = null;
1164
+ this.socketClient = new SocketClient(this.relayUrl, "dapp");
1165
+ this.setupSocketListeners();
1166
+ this.setStatus("disconnected" /* DISCONNECTED */);
1167
+ }
1168
+ async disconnect() {
1169
+ this.clearUnresponsiveTimer();
1170
+ this.walletPresent = false;
1171
+ await this.flushTerminate();
1172
+ this.socketClient.leaveChannel();
1173
+ this.socketClient.disconnect();
1174
+ this.setStatus("disconnected" /* DISCONNECTED */);
1175
+ this.clearSession();
1176
+ this.connectedAccounts = [];
1177
+ }
1178
+ // ── Internals ──────────────────────────────────────────────
1179
+ enqueueRelayMessage(data) {
1180
+ this.messageQueue = this.messageQueue.then(() => this.handleRelayMessage(data)).catch((err) => error("ConnectionManager", "messageQueue handler error:", err));
1181
+ }
1182
+ async handleRelayMessage(data) {
730
1183
  if (data.clientType === "wallet") {
731
1184
  this.walletPresent = true;
732
- if (this.keyExchange.areKeysExchanged() && this.status !== "connected" /* CONNECTED */) {
1185
+ if (this.keyExchange?.areKeysExchanged() && this.status !== "connected" /* CONNECTED */) {
733
1186
  this.failedReconnects = 0;
734
1187
  this.setStatus("connected" /* CONNECTED */);
735
1188
  }
@@ -737,16 +1190,18 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
737
1190
  const message = data.message;
738
1191
  if (typeof message === "object" && message !== null) {
739
1192
  const msg = message;
740
- if (msg.type === "key_handshake_SYN" /* SYN */ || msg.type === "key_handshake_SYNACK" /* SYNACK */ || msg.type === "key_handshake_ACK" /* ACK */) {
741
- this.handleKeyExchangeMessage(
742
- msg
743
- );
1193
+ if (msg.type === "key_handshake_SYNACK" /* SYNACK */) {
1194
+ await this.handleSynAck(message);
1195
+ return;
1196
+ }
1197
+ if (msg.type === "key_handshake_SYN" /* SYN */ || msg.type === "key_handshake_ACK" /* ACK */) {
1198
+ warn("ConnectionManager", `Unexpected ${msg.type} on dApp side \u2014 ignoring`);
744
1199
  return;
745
1200
  }
746
1201
  }
747
- if (typeof message === "string" && this.keyExchange.areKeysExchanged()) {
1202
+ if (typeof message === "string" && this.keyExchange?.areKeysExchanged()) {
748
1203
  try {
749
- const decrypted = this.keyExchange.decryptMessage(message);
1204
+ const decrypted = await this.keyExchange.decryptMessage(message);
750
1205
  const parsed = JSON.parse(decrypted);
751
1206
  this.handleDecryptedMessage(parsed);
752
1207
  } catch (err) {
@@ -754,9 +1209,18 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
754
1209
  }
755
1210
  }
756
1211
  }
757
- handleKeyExchangeMessage(msg) {
1212
+ async handleSynAck(msg) {
1213
+ if (!this.keyExchange) return;
758
1214
  this.setStatus("key_exchange" /* KEY_EXCHANGE */);
759
- const response = this.keyExchange.onMessage(msg);
1215
+ let response;
1216
+ try {
1217
+ response = await this.keyExchange.onSynAck(cidFromString(this.channelId), msg);
1218
+ } catch (err) {
1219
+ const e = err instanceof Error ? err : new Error(String(err));
1220
+ error("ConnectionManager", "SYNACK processing failed:", e);
1221
+ this.emit("error", e);
1222
+ return;
1223
+ }
760
1224
  if (response) {
761
1225
  this.sendPlaintext(response);
762
1226
  }
@@ -773,17 +1237,13 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
773
1237
  const chainChanged = this.chainId !== nextChainId;
774
1238
  this.connectedAccounts = nextAccounts;
775
1239
  this.chainId = nextChainId;
776
- this.saveSession();
1240
+ void this.persistSession();
777
1241
  this.emit("wallet_info", {
778
1242
  accounts: this.connectedAccounts,
779
1243
  chainId: this.chainId
780
1244
  });
781
- if (accountsChanged) {
782
- this.emit("accounts_changed", this.connectedAccounts);
783
- }
784
- if (chainChanged) {
785
- this.emit("chain_changed", this.chainId);
786
- }
1245
+ if (accountsChanged) this.emit("accounts_changed", this.connectedAccounts);
1246
+ if (chainChanged) this.emit("chain_changed", this.chainId);
787
1247
  break;
788
1248
  }
789
1249
  case "jsonrpc" /* JSONRPC */: {
@@ -793,14 +1253,13 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
793
1253
  }
794
1254
  case "terminate" /* TERMINATE */: {
795
1255
  log("ConnectionManager", "Received terminate from wallet");
796
- this.disconnect();
1256
+ void this.disconnect();
797
1257
  break;
798
1258
  }
799
1259
  default:
800
1260
  log("ConnectionManager", `Unhandled message type: ${type}`);
801
1261
  }
802
1262
  }
803
- /** Send a plaintext (unencrypted) message through the relay. */
804
1263
  sendPlaintext(message) {
805
1264
  this.socketClient.sendMessage({
806
1265
  id: this.channelId,
@@ -810,20 +1269,16 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
810
1269
  error("ConnectionManager", "Failed to send plaintext:", err);
811
1270
  });
812
1271
  }
813
- /** Send an encrypted message through the relay. */
814
- sendEncrypted(message) {
815
- try {
816
- const encrypted = this.keyExchange.encryptMessage(JSON.stringify(message));
817
- this.socketClient.sendMessage({
818
- id: this.channelId,
819
- clientType: "dapp",
820
- message: encrypted
821
- }).catch((err) => {
822
- error("ConnectionManager", "Failed to send encrypted:", err);
823
- });
824
- } catch (err) {
825
- error("ConnectionManager", "Encryption failed:", err);
1272
+ async sendEncrypted(message) {
1273
+ if (!this.keyExchange?.areKeysExchanged()) {
1274
+ throw new Error("sendEncrypted: not connected");
826
1275
  }
1276
+ const encrypted = await this.keyExchange.encryptMessage(JSON.stringify(message));
1277
+ await this.socketClient.sendMessage({
1278
+ id: this.channelId,
1279
+ clientType: "dapp",
1280
+ message: encrypted
1281
+ });
827
1282
  }
828
1283
  startUnresponsiveTimer() {
829
1284
  this.clearUnresponsiveTimer();
@@ -837,46 +1292,6 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
837
1292
  this.unresponsiveTimer = null;
838
1293
  }
839
1294
  }
840
- sendSYN() {
841
- if (!this.socketClient.isConnected()) {
842
- return;
843
- }
844
- if (this.synAttempts >= _ConnectionManager.MAX_SYN_ATTEMPTS) {
845
- this.synSent = false;
846
- this.emit("error", new Error("Key exchange timed out waiting for wallet response"));
847
- return;
848
- }
849
- try {
850
- const syn = this.keyExchange.createSYN();
851
- this.sendPlaintext(syn);
852
- this.synSent = true;
853
- this.synAttempts++;
854
- this.scheduleSynRetry();
855
- log("ConnectionManager", "Sent SYN key exchange message");
856
- } catch (err) {
857
- error("ConnectionManager", "Failed to send SYN:", err);
858
- }
859
- }
860
- scheduleSynRetry() {
861
- this.clearSynRetryTimer();
862
- this.synRetryTimer = setTimeout(() => {
863
- this.synRetryTimer = null;
864
- if (this.keyExchange.areKeysExchanged()) {
865
- return;
866
- }
867
- this.synSent = false;
868
- if (!this.walletPresent || !this.socketClient.isConnected()) {
869
- return;
870
- }
871
- this.sendSYN();
872
- }, _ConnectionManager.SYN_RETRY_MS);
873
- }
874
- clearSynRetryTimer() {
875
- if (this.synRetryTimer) {
876
- clearTimeout(this.synRetryTimer);
877
- this.synRetryTimer = null;
878
- }
879
- }
880
1295
  setStatus(status) {
881
1296
  if (this.status !== status) {
882
1297
  this.status = status;
@@ -889,105 +1304,44 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
889
1304
  }
890
1305
  areArraysEqual(a, b) {
891
1306
  if (a.length !== b.length) return false;
892
- for (let i = 0; i < a.length; i++) {
893
- if (a[i] !== b[i]) return false;
894
- }
1307
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
895
1308
  return true;
896
1309
  }
897
- getStatus() {
898
- return this.status;
899
- }
900
- getAccounts() {
901
- return this.connectedAccounts;
902
- }
903
- getChainId() {
904
- return this.chainId;
905
- }
906
- getChannelId() {
907
- return this.channelId;
908
- }
909
- /** Check if a stored session exists (without loading/restoring it). */
910
- hasStoredSession() {
911
- if (typeof localStorage === "undefined") return false;
912
- try {
913
- const raw = localStorage.getItem(this.storageKey);
914
- if (!raw) return false;
915
- const session = JSON.parse(raw);
916
- return Date.now() - session.createdAt <= SESSION_TTL_MS;
917
- } catch {
918
- return false;
919
- }
920
- }
921
- /**
922
- * Reset the connection and create a new channel for a fresh pairing.
923
- * Disconnects any existing session, generates a new channel ID and keys.
924
- */
925
- resetForNewChannel() {
926
- this.clearUnresponsiveTimer();
927
- this.clearSynRetryTimer();
928
- this.synSent = false;
929
- this.synAttempts = 0;
930
- this.walletPresent = false;
931
- if (this.keyExchange.areKeysExchanged()) {
932
- try {
933
- this.sendEncrypted({ type: "terminate" /* TERMINATE */ });
934
- } catch {
935
- }
936
- }
937
- this.socketClient.leaveChannel();
938
- this.socketClient.disconnect();
939
- this.clearSession();
940
- this.connectedAccounts = [];
941
- this.channelId = uuidv4();
942
- this.ecies = new ECIESClient();
943
- this.keyExchange = new KeyExchange(this.ecies, true);
944
- this.socketClient = new SocketClient(this.relayUrl, "dapp");
945
- this.setupSocketListeners();
946
- this.setupKeyExchangeListeners();
947
- this.setStatus("disconnected" /* DISCONNECTED */);
948
- }
949
- /** Disconnect and clean up. */
950
- disconnect() {
951
- this.clearUnresponsiveTimer();
952
- this.clearSynRetryTimer();
953
- this.synSent = false;
954
- this.synAttempts = 0;
955
- this.walletPresent = false;
956
- if (this.keyExchange.areKeysExchanged()) {
957
- try {
958
- this.sendEncrypted({ type: "terminate" /* TERMINATE */ });
959
- } catch {
960
- }
961
- }
962
- this.socketClient.leaveChannel();
963
- this.socketClient.disconnect();
964
- this.setStatus("disconnected" /* DISCONNECTED */);
965
- this.clearSession();
966
- this.connectedAccounts = [];
967
- }
968
- // --- Session persistence ---
969
- saveSession() {
1310
+ // ── Persistence ────────────────────────────────────────────
1311
+ async persistSession() {
970
1312
  if (typeof localStorage === "undefined") return;
1313
+ if (!this.keyExchange) return;
1314
+ const persistedKex = await this.keyExchange.exportPersisted();
1315
+ if (!persistedKex) return;
971
1316
  const session = {
1317
+ version: 2,
972
1318
  channelId: this.channelId,
973
- privateKey: this.ecies.getPrivateKeyHex(),
974
- otherPublicKey: this.keyExchange.getOtherPublicKey(),
1319
+ keyExchange: persistedKex,
975
1320
  dappMetadata: this.dappMetadata,
976
1321
  connectedAccounts: this.connectedAccounts,
977
1322
  chainId: this.chainId,
978
- createdAt: Date.now(),
1323
+ createdAt: this.pendingRestore?.createdAt ?? Date.now(),
979
1324
  lastActivity: Date.now()
980
1325
  };
981
- localStorage.setItem(this.storageKey, JSON.stringify(session));
1326
+ try {
1327
+ localStorage.setItem(this.storageKey, JSON.stringify(session));
1328
+ } catch (err) {
1329
+ warn("ConnectionManager", "localStorage.setItem failed:", err);
1330
+ }
982
1331
  }
983
- loadSession() {
1332
+ readStoredSession() {
984
1333
  if (typeof localStorage === "undefined") return null;
985
1334
  try {
986
1335
  const raw = localStorage.getItem(this.storageKey);
987
1336
  if (!raw) return null;
988
1337
  const session = JSON.parse(raw);
989
- if (Date.now() - session.createdAt > SESSION_TTL_MS) {
990
- this.clearSession();
1338
+ if (session.version !== 2) {
1339
+ log("ConnectionManager", "Dropping legacy (pre-v2) session from storage");
1340
+ localStorage.removeItem(this.storageKey);
1341
+ return null;
1342
+ }
1343
+ if (!session.createdAt || Date.now() - session.createdAt > SESSION_TTL_MS) {
1344
+ localStorage.removeItem(this.storageKey);
991
1345
  return null;
992
1346
  }
993
1347
  return session;
@@ -1001,8 +1355,6 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1001
1355
  }
1002
1356
  };
1003
1357
  _ConnectionManager.MAX_RECONNECT_FAILURES = 5;
1004
- _ConnectionManager.SYN_RETRY_MS = 5e3;
1005
- _ConnectionManager.MAX_SYN_ATTEMPTS = 6;
1006
1358
  var ConnectionManager = _ConnectionManager;
1007
1359
 
1008
1360
  // src/utils/platform.ts
@@ -1202,33 +1554,44 @@ var QRLConnectProvider = class extends EventEmitter4 {
1202
1554
  pending.reject(new Error("Connection reset"));
1203
1555
  }
1204
1556
  this.pendingRequests.clear();
1205
- this.connectionManager.resetForNewChannel();
1557
+ await this.connectionManager.resetForNewChannel();
1206
1558
  return this.getConnectionURI();
1207
1559
  }
1208
1560
  /**
1209
- * Disconnect from wallet and clean up.
1561
+ * Disconnect from wallet and clean up. Returns once the TERMINATE has
1562
+ * either been flushed to the relay or the 800ms best-effort window has
1563
+ * elapsed — the wallet gets an instant disconnect instead of landing in
1564
+ * its stale-session grace period.
1210
1565
  */
1211
- disconnect() {
1566
+ async disconnect() {
1212
1567
  for (const [, pending] of this.pendingRequests) {
1213
1568
  pending.reject(new Error("Disconnected"));
1214
1569
  }
1215
1570
  this.pendingRequests.clear();
1216
- this.connectionManager.disconnect();
1571
+ await this.connectionManager.disconnect();
1217
1572
  }
1218
1573
  };
1219
1574
  export {
1575
+ BLOB_LEN,
1576
+ CID_LEN,
1220
1577
  ConnectionManager,
1221
1578
  ConnectionStatus,
1222
1579
  DEFAULT_RELAY_URL,
1223
- ECIESClient,
1580
+ FP_LEN,
1224
1581
  KeyExchange,
1225
1582
  KeyExchangeMessageType,
1226
1583
  MessageType,
1584
+ PROTOCOL_VERSION,
1227
1585
  QRLConnectProvider as QRLConnect,
1228
1586
  QRLConnectProvider,
1229
1587
  RESTRICTED_METHODS,
1230
1588
  SocketClient,
1231
1589
  UNRESTRICTED_METHODS,
1590
+ cidFromString,
1591
+ cidRandom,
1592
+ cidToString,
1593
+ computeFingerprint,
1594
+ fingerprintEquals,
1232
1595
  generateConnectionURI,
1233
1596
  getAppStoreUrl,
1234
1597
  isMobileBrowser,