@qrlwallet/connect 3.3.0 → 4.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
@@ -109,14 +109,115 @@ function constantTimeEquals(a, b) {
109
109
  return d === 0;
110
110
  }
111
111
 
112
+ // src/config.ts
113
+ var DEFAULT_RELAY_URL = "https://qrlwallet.com";
114
+ var RELAY_PATH = "/relay";
115
+ var PROTOCOL_VERSION = 3;
116
+ var PAIRING_CAPABILITY_LEN = 32;
117
+ var SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
118
+ var STORAGE_KEY_PREFIX = "@qrlwallet/connect";
119
+ var REQUEST_TIMEOUT_MS = 5 * 60 * 1e3;
120
+ var WALLET_UNRESPONSIVE_MS = 30 * 1e3;
121
+ var MAX_JSON_RPC_ID_LENGTH = 128;
122
+ var MAX_JSON_RPC_METHOD_LENGTH = 128;
123
+ var MAX_RELAY_URL_LENGTH = 2048;
124
+ var RECONNECT_WALLET_PROBE_MS = 12 * 1e3;
125
+ var CURRENT_QRL_ADDRESS_RE = /^Q[0-9a-fA-F]{40}$/;
126
+ function isCurrentQrlAddress(value) {
127
+ return typeof value === "string" && CURRENT_QRL_ADDRESS_RE.test(value);
128
+ }
129
+ function isExplicitLoopbackHostname(value) {
130
+ const hostname = value.toLowerCase();
131
+ return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
132
+ }
133
+ function normalizeRelayUrl(value) {
134
+ if (typeof value !== "string" || value.length === 0 || value.length > MAX_RELAY_URL_LENGTH) {
135
+ throw new Error("Invalid relay URL");
136
+ }
137
+ let url;
138
+ try {
139
+ url = new URL(value);
140
+ } catch {
141
+ throw new Error("Invalid relay URL");
142
+ }
143
+ const isLoopback = isExplicitLoopbackHostname(url.hostname);
144
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback) || url.username !== "" || url.password !== "" || url.hash !== "" || url.search !== "" || url.pathname !== "/") {
145
+ throw new Error("Invalid relay URL");
146
+ }
147
+ return `${url.protocol}//${url.host}`;
148
+ }
149
+ var RESTRICTED_METHOD_NAMES = [
150
+ "qrl_requestAccounts",
151
+ "qrl_sendTransaction",
152
+ "qrl_signTransaction",
153
+ "qrl_signMessage",
154
+ "qrl_signTypedData",
155
+ "wallet_switchQrlChain"
156
+ ];
157
+ var UNRESTRICTED_METHOD_NAMES = [
158
+ "qrl_chainId",
159
+ "qrl_blockNumber",
160
+ "qrl_getBalance",
161
+ "qrl_getTransactionCount",
162
+ "qrl_getBlockByNumber",
163
+ "qrl_getTransactionReceipt",
164
+ "qrl_call",
165
+ "qrl_estimateGas",
166
+ "qrl_gasPrice",
167
+ "qrl_getCode",
168
+ "qrl_getLogs",
169
+ // Wallet-local authorization cache, never forwarded to hosted node RPC.
170
+ "qrl_accounts",
171
+ "net_version",
172
+ "net_listening"
173
+ ];
174
+ var EXPLICITLY_UNSUPPORTED_METHOD_NAMES = [
175
+ "qrl_sendRawTransaction",
176
+ "wallet_addQrlChain",
177
+ "personal_sign",
178
+ "qrl_sign",
179
+ "qrl_signTypedData_v3",
180
+ "qrl_signTypedData_v4"
181
+ ];
182
+ var RESTRICTED_METHOD_POLICY = new Set(RESTRICTED_METHOD_NAMES);
183
+ var UNRESTRICTED_METHOD_POLICY = new Set(UNRESTRICTED_METHOD_NAMES);
184
+ var EXPLICITLY_UNSUPPORTED_METHOD_POLICY = new Set(EXPLICITLY_UNSUPPORTED_METHOD_NAMES);
185
+ var RESTRICTED_METHODS = new Set(RESTRICTED_METHOD_NAMES);
186
+ var UNRESTRICTED_METHODS = new Set(UNRESTRICTED_METHOD_NAMES);
187
+ var EXPLICITLY_UNSUPPORTED_METHODS = new Set(
188
+ EXPLICITLY_UNSUPPORTED_METHOD_NAMES
189
+ );
190
+ function isValidJsonRpcId(value) {
191
+ return typeof value === "string" && value.length > 0 && value.length <= MAX_JSON_RPC_ID_LENGTH || typeof value === "number" && Number.isSafeInteger(value);
192
+ }
193
+ function isValidJsonRpcMethod(value) {
194
+ return typeof value === "string" && value.length > 0 && value.length <= MAX_JSON_RPC_METHOD_LENGTH && /^[A-Za-z0-9_.]+$/.test(value);
195
+ }
196
+ function classifyRpcMethod(method) {
197
+ if (EXPLICITLY_UNSUPPORTED_METHOD_POLICY.has(method)) return "unsupported";
198
+ if (RESTRICTED_METHOD_POLICY.has(method)) return "restricted";
199
+ if (UNRESTRICTED_METHOD_POLICY.has(method)) return "unrestricted";
200
+ return "unsupported";
201
+ }
202
+
112
203
  // src/PQCrypto.ts
113
204
  var textEncoder = new TextEncoder();
114
- var LABEL = textEncoder.encode("pq-pair/v1");
205
+ var LABEL = textEncoder.encode("pq-pair/v3");
115
206
  var LABEL_AEAD_SUFFIX = textEncoder.encode(" aead");
116
207
  var DIR_DAPP_TX = new Uint8Array([0, 0, 0, 1]);
117
208
  var DIR_WALLET_TX = new Uint8Array([0, 0, 0, 2]);
209
+ var ML_KEM_768_PK_LEN = 1184;
118
210
  var ML_KEM_768_CT_LEN = 1088;
211
+ var SHARED_SECRET_LEN = 32;
119
212
  var AEAD_KEY_LEN = 32;
213
+ function requireLength(name, value, expected) {
214
+ if (value.length !== expected) {
215
+ throw new Error(`PQCrypto: ${name} must be ${expected} bytes`);
216
+ }
217
+ }
218
+ function generatePairingCapability() {
219
+ return randomBytes(PAIRING_CAPABILITY_LEN);
220
+ }
120
221
  function kemKeygen() {
121
222
  return mlkemKeygen();
122
223
  }
@@ -126,12 +227,30 @@ function kemEncaps(pk) {
126
227
  function kemDecaps(sk, ct) {
127
228
  return mlkemDecaps(sk, ct);
128
229
  }
129
- async function transcriptHash(cid, pk, ct) {
130
- return sha256(concat(LABEL, cid, pk, ct));
230
+ async function transcriptHash(cid, pk, ct, capability) {
231
+ requireLength("cid", cid, 16);
232
+ requireLength("ML-KEM public key", pk, ML_KEM_768_PK_LEN);
233
+ requireLength("ML-KEM ciphertext", ct, ML_KEM_768_CT_LEN);
234
+ requireLength("pairing capability", capability, PAIRING_CAPABILITY_LEN);
235
+ const preimage = concat(LABEL, cid, pk, ct, capability);
236
+ try {
237
+ return await sha256(preimage);
238
+ } finally {
239
+ zeroize(preimage);
240
+ }
131
241
  }
132
- async function deriveAeadKey(ss, htx) {
242
+ async function deriveAeadKey(ss, htx, capability) {
243
+ requireLength("shared secret", ss, SHARED_SECRET_LEN);
244
+ requireLength("transcript hash", htx, 32);
245
+ requireLength("pairing capability", capability, PAIRING_CAPABILITY_LEN);
133
246
  const info = concat(LABEL, LABEL_AEAD_SUFFIX, htx);
134
- return hkdfAesGcmKey(ss, new Uint8Array(32), info);
247
+ const salt = capability.slice();
248
+ try {
249
+ return await hkdfAesGcmKey(ss, salt, info);
250
+ } finally {
251
+ zeroize(salt);
252
+ zeroize(info);
253
+ }
135
254
  }
136
255
  async function importRawAeadKey(raw) {
137
256
  if (raw.length !== AEAD_KEY_LEN) {
@@ -190,6 +309,33 @@ function toBase64(bytes) {
190
309
  }
191
310
  return btoa(bin);
192
311
  }
312
+ function base64EncodedLength(decodedLength) {
313
+ if (!Number.isSafeInteger(decodedLength) || decodedLength < 0) {
314
+ throw new Error("PQCrypto: decoded base64 length must be a non-negative safe integer");
315
+ }
316
+ return Math.ceil(decodedLength / 3) * 4;
317
+ }
318
+ var PADDED_BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
319
+ function isCanonicalBase64OfLength(value, decodedLength) {
320
+ if (typeof value !== "string" || value.length !== base64EncodedLength(decodedLength) || !PADDED_BASE64_RE.test(value)) {
321
+ return false;
322
+ }
323
+ let decoded = null;
324
+ try {
325
+ decoded = fromBase64(value);
326
+ return decoded.length === decodedLength && toBase64(decoded) === value;
327
+ } catch {
328
+ return false;
329
+ } finally {
330
+ if (decoded) zeroize(decoded);
331
+ }
332
+ }
333
+ function fromBase64Exact(value, decodedLength, fieldName = "value") {
334
+ if (!isCanonicalBase64OfLength(value, decodedLength)) {
335
+ throw new Error(`PQCrypto: ${fieldName} must encode exactly ${decodedLength} bytes`);
336
+ }
337
+ return fromBase64(value);
338
+ }
193
339
  function fromBase64(b64) {
194
340
  const bin = atob(b64);
195
341
  const out = new Uint8Array(bin.length);
@@ -197,66 +343,6 @@ function fromBase64(b64) {
197
343
  return out;
198
344
  }
199
345
 
200
- // src/config.ts
201
- var DEFAULT_RELAY_URL = "https://qrlwallet.com";
202
- var RELAY_PATH = "/relay";
203
- var PROTOCOL_VERSION = 2;
204
- var SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
205
- var STORAGE_KEY_PREFIX = "@qrlwallet/connect";
206
- var REQUEST_TIMEOUT_MS = 5 * 60 * 1e3;
207
- var WALLET_UNRESPONSIVE_MS = 30 * 1e3;
208
- var RECONNECT_WALLET_PROBE_MS = 12 * 1e3;
209
- var RESTRICTED_METHODS = /* @__PURE__ */ new Set([
210
- "qrl_requestAccounts",
211
- "qrl_sendTransaction",
212
- "qrl_signTransaction",
213
- "qrl_signMessage",
214
- "qrl_signTypedData",
215
- "wallet_addQrlChain",
216
- "wallet_switchQrlChain"
217
- ]);
218
- var UNRESTRICTED_METHODS = /* @__PURE__ */ new Set([
219
- "qrl_chainId",
220
- "qrl_blockNumber",
221
- "qrl_getBalance",
222
- "qrl_getTransactionCount",
223
- "qrl_getBlockByNumber",
224
- "qrl_getBlockByHash",
225
- "qrl_getTransactionByHash",
226
- "qrl_getTransactionReceipt",
227
- "qrl_call",
228
- "qrl_estimateGas",
229
- "qrl_gasPrice",
230
- "qrl_getCode",
231
- "qrl_getStorageAt",
232
- "qrl_getLogs",
233
- "qrl_getBlockTransactionCountByHash",
234
- "qrl_getBlockTransactionCountByNumber",
235
- "qrl_getTransactionByBlockHashAndIndex",
236
- "qrl_getTransactionByBlockNumberAndIndex",
237
- "qrl_accounts",
238
- "net_version",
239
- "net_listening",
240
- "net_peerCount",
241
- "web3_clientVersion",
242
- "web3_sha3",
243
- "qrl_syncing",
244
- "qrl_coinbase",
245
- "qrl_mining",
246
- "qrl_hashrate",
247
- "qrl_protocolVersion",
248
- "qrl_getUncleCountByBlockHash",
249
- "qrl_getUncleCountByBlockNumber",
250
- "qrl_getUncleByBlockHashAndIndex",
251
- "qrl_getUncleByBlockNumberAndIndex",
252
- "qrl_getFilterChanges",
253
- "qrl_getFilterLogs",
254
- "qrl_newBlockFilter",
255
- "qrl_newFilter",
256
- "qrl_newPendingTransactionFilter",
257
- "qrl_uninstallFilter"
258
- ]);
259
-
260
346
  // src/types.ts
261
347
  var KeyExchangeMessageType = /* @__PURE__ */ ((KeyExchangeMessageType2) => {
262
348
  KeyExchangeMessageType2["SYN"] = "key_handshake_SYN";
@@ -283,6 +369,69 @@ var ConnectionStatus = /* @__PURE__ */ ((ConnectionStatus2) => {
283
369
  ConnectionStatus2["RECONNECTING"] = "reconnecting";
284
370
  return ConnectionStatus2;
285
371
  })(ConnectionStatus || {});
372
+ var SIGNATURE_HEX_BYTES = 4627;
373
+ var PUBLIC_KEY_HEX_BYTES = 2592;
374
+ var DIGEST_HEX_BYTES = 64;
375
+ var MESSAGE_RESULT_KEYS = [
376
+ "signature",
377
+ "publicKey",
378
+ "signer",
379
+ "digest",
380
+ "schemeVersion"
381
+ ];
382
+ var MESSAGE_RESULT_KEYS_WITH_DESCRIPTOR = [
383
+ ...MESSAGE_RESULT_KEYS,
384
+ "descriptor"
385
+ ];
386
+ var TYPED_RESULT_KEYS = [...MESSAGE_RESULT_KEYS, "domain"];
387
+ var TYPED_RESULT_KEYS_WITH_DESCRIPTOR = [...TYPED_RESULT_KEYS, "descriptor"];
388
+ function isSigningResultRecord(value) {
389
+ return typeof value === "object" && value !== null && !Array.isArray(value);
390
+ }
391
+ function hasOwn(record, key) {
392
+ return Object.prototype.hasOwnProperty.call(record, key);
393
+ }
394
+ function hasExactOwnKeys(record, expected) {
395
+ const keys = Reflect.ownKeys(record);
396
+ return keys.length === expected.length && keys.every((key) => typeof key === "string" && expected.includes(key));
397
+ }
398
+ function isFixedHex(value, bytes) {
399
+ return typeof value === "string" && value.length === bytes * 2 + 2 && /^0x[0-9a-fA-F]+$/.test(value);
400
+ }
401
+ function isSigningDescriptor(value) {
402
+ return typeof value === "string" && /^0x01[0-9a-fA-F]{4}$/.test(value);
403
+ }
404
+ function hasValidSigningFields(record) {
405
+ return isFixedHex(record.signature, SIGNATURE_HEX_BYTES) && isFixedHex(record.publicKey, PUBLIC_KEY_HEX_BYTES) && (!hasOwn(record, "descriptor") || isSigningDescriptor(record.descriptor)) && typeof record.signer === "string" && /^Q[0-9a-fA-F]{40}$/.test(record.signer) && isFixedHex(record.digest, DIGEST_HEX_BYTES);
406
+ }
407
+ function isQrlSignedMessageResult(value) {
408
+ try {
409
+ if (!isSigningResultRecord(value)) return false;
410
+ const keys = hasOwn(value, "descriptor") ? MESSAGE_RESULT_KEYS_WITH_DESCRIPTOR : MESSAGE_RESULT_KEYS;
411
+ return hasExactOwnKeys(value, keys) && value.schemeVersion === "QRL-SIGN-MSG-v1" && hasValidSigningFields(value);
412
+ } catch {
413
+ return false;
414
+ }
415
+ }
416
+ function isQrlSignedTypedDataResult(value) {
417
+ try {
418
+ if (!isSigningResultRecord(value)) return false;
419
+ const keys = hasOwn(value, "descriptor") ? TYPED_RESULT_KEYS_WITH_DESCRIPTOR : TYPED_RESULT_KEYS;
420
+ return hasExactOwnKeys(value, keys) && value.schemeVersion === "QRL-SIGN-TYPED-v1" && isSigningResultRecord(value.domain) && hasValidSigningFields(value);
421
+ } catch {
422
+ return false;
423
+ }
424
+ }
425
+ function isQrlSignedResult(value) {
426
+ return isQrlSignedMessageResult(value) || isQrlSignedTypedDataResult(value);
427
+ }
428
+ function hasSigningDescriptor(result) {
429
+ try {
430
+ return isQrlSignedResult(result) && hasOwn(result, "descriptor") && isSigningDescriptor(result.descriptor);
431
+ } catch {
432
+ return false;
433
+ }
434
+ }
286
435
 
287
436
  // src/utils/logger.ts
288
437
  var debugEnabled = false;
@@ -306,15 +455,30 @@ var textEncoder2 = new TextEncoder();
306
455
  var textDecoder = new TextDecoder();
307
456
  var HELLO_WALLET = textEncoder2.encode("hello/wallet/v1");
308
457
  var HELLO_DAPP = textEncoder2.encode("hello/dapp/v1");
458
+ var AES_GCM_TAG_LEN = 16;
459
+ var SYNACK_C0_LEN = HELLO_WALLET.length + AES_GCM_TAG_LEN;
460
+ var ACK_C1_LEN = HELLO_DAPP.length + AES_GCM_TAG_LEN;
461
+ function snapshotSession(session) {
462
+ return {
463
+ ...session,
464
+ cid: session.cid.slice(),
465
+ htx: session.htx.slice(),
466
+ sendDir: session.sendDir.slice(),
467
+ recvDir: session.recvDir.slice()
468
+ };
469
+ }
309
470
  var KeyExchange = class extends EventEmitter {
310
471
  constructor(isOriginator, restored) {
311
472
  super();
312
473
  this.keypair = null;
474
+ this.capability = null;
313
475
  this.session = null;
314
476
  this.step = "key_handshake_SYN" /* SYN */;
315
477
  this.awaitingSynAck = false;
316
478
  this.awaitingAck = false;
479
+ this.awaitingAckDelivery = false;
317
480
  this.keysExchanged = false;
481
+ this.stateGeneration = 0;
318
482
  // The ACK we produced for the current handshake. A wallet that lost its
319
483
  // transport right after sending SYNACK re-sends the identical SYNACK on
320
484
  // rejoin; we answer with this cached ACK so the handshake converges
@@ -324,7 +488,10 @@ var KeyExchange = class extends EventEmitter {
324
488
  this.lastAck = null;
325
489
  this.isOriginator = isOriginator;
326
490
  if (restored) {
327
- this.session = restored;
491
+ if (restored.protocolVersion !== PROTOCOL_VERSION) {
492
+ throw new Error("KeyExchange: cannot restore a pre-v3 session");
493
+ }
494
+ this.session = snapshotSession(restored);
328
495
  this.keysExchanged = true;
329
496
  this.step = "key_handshake_ACK" /* ACK */;
330
497
  log("KeyExchange", "Hydrated from persisted session");
@@ -332,7 +499,9 @@ var KeyExchange = class extends EventEmitter {
332
499
  }
333
500
  /**
334
501
  * Originator (dApp) side: begin a fresh handshake by generating an
335
- * ephemeral ML-KEM-768 keypair. Returns pk to embed in the QR URI.
502
+ * ephemeral ML-KEM-768 keypair and a fresh 32-byte pairing capability.
503
+ * Both returned arrays are copies; the originator retains its own secret
504
+ * capability until the handshake succeeds or is retired.
336
505
  */
337
506
  initiate() {
338
507
  if (!this.isOriginator) {
@@ -340,10 +509,14 @@ var KeyExchange = class extends EventEmitter {
340
509
  }
341
510
  this.resetInternal(false);
342
511
  this.keypair = kemKeygen();
512
+ this.capability = generatePairingCapability();
343
513
  this.step = "key_handshake_SYN" /* SYN */;
344
514
  this.awaitingSynAck = true;
345
515
  this.emit("step_change", this.step);
346
- return this.keypair.pk;
516
+ return {
517
+ publicKey: this.keypair.pk.slice(),
518
+ capability: this.capability.slice()
519
+ };
347
520
  }
348
521
  /**
349
522
  * Originator: handle a SYNACK received over the relay.
@@ -355,86 +528,147 @@ var KeyExchange = class extends EventEmitter {
355
528
  warn("KeyExchange", "Responder received SYNACK - ignoring");
356
529
  return null;
357
530
  }
358
- if (!this.awaitingSynAck || !this.keypair) {
531
+ if (!this.awaitingSynAck || !this.keypair || !this.capability) {
359
532
  return null;
360
533
  }
361
534
  this.awaitingSynAck = false;
362
- const ct = fromBase64(msg.ct);
363
- const c0 = fromBase64(msg.c0);
364
- if (ct.length !== ML_KEM_768_CT_LEN) {
365
- throw new Error(`KeyExchange: bad ct length ${ct.length}`);
366
- }
367
- const ss = kemDecaps(this.keypair.sk, ct);
368
- const htx = await transcriptHash(cid, this.keypair.pk, ct);
369
- const key = await deriveAeadKey(ss, htx);
370
- let hello;
535
+ const stableCid = cid.slice();
536
+ const keypair = this.keypair;
537
+ const capability = this.capability;
538
+ const generation = this.stateGeneration;
539
+ let ss = null;
371
540
  try {
372
- hello = await open(key, DIR_WALLET_TX, 0, htx, c0);
373
- } catch {
374
- throw new Error(
375
- "KeyExchange: wallet hello AEAD tag failed (tampered SYNACK, wrong QR, or protocol mismatch)"
376
- );
541
+ if (msg.v !== PROTOCOL_VERSION) {
542
+ throw new Error(`KeyExchange: expected protocol v${PROTOCOL_VERSION}`);
543
+ }
544
+ const ct = fromBase64Exact(msg.ct, ML_KEM_768_CT_LEN, "SYNACK ciphertext");
545
+ const c0 = fromBase64Exact(msg.c0, SYNACK_C0_LEN, "SYNACK wallet hello");
546
+ ss = kemDecaps(keypair.sk, ct);
547
+ const htx = await transcriptHash(stableCid, keypair.pk, ct, capability);
548
+ const key = await deriveAeadKey(ss, htx, capability);
549
+ let hello = null;
550
+ try {
551
+ try {
552
+ hello = await open(key, DIR_WALLET_TX, 0, htx, c0);
553
+ } catch {
554
+ throw new Error(
555
+ "KeyExchange: wallet hello AEAD tag failed (tampered SYNACK, wrong QR, or protocol mismatch)"
556
+ );
557
+ }
558
+ if (!constantTimeEquals(hello, HELLO_WALLET)) {
559
+ throw new Error("KeyExchange: wallet hello mismatch");
560
+ }
561
+ } finally {
562
+ if (hello) zeroize(hello);
563
+ }
564
+ if (this.stateGeneration !== generation || this.keypair !== keypair) {
565
+ throw new Error("KeyExchange: handshake generation changed");
566
+ }
567
+ const c1 = await seal(key, DIR_DAPP_TX, 0, htx, HELLO_DAPP);
568
+ if (this.stateGeneration !== generation || this.keypair !== keypair) {
569
+ throw new Error("KeyExchange: handshake generation changed");
570
+ }
571
+ zeroize(keypair.sk);
572
+ if (this.keypair === keypair) this.keypair = null;
573
+ zeroize(capability);
574
+ if (this.capability === capability) this.capability = null;
575
+ this.session = {
576
+ protocolVersion: PROTOCOL_VERSION,
577
+ cid: stableCid,
578
+ key,
579
+ htx,
580
+ sendDir: DIR_DAPP_TX.slice(),
581
+ recvDir: DIR_WALLET_TX.slice(),
582
+ sendSeq: 1,
583
+ recvSeq: 1
584
+ };
585
+ const ack = {
586
+ type: "key_handshake_ACK" /* ACK */,
587
+ c1: toBase64(c1),
588
+ v: PROTOCOL_VERSION
589
+ };
590
+ this.lastAck = { ...ack };
591
+ this.awaitingAckDelivery = true;
592
+ return ack;
593
+ } catch (error2) {
594
+ if (this.stateGeneration === generation) this.resetInternal(false);
595
+ throw error2;
596
+ } finally {
597
+ if (ss) zeroize(ss);
377
598
  }
378
- if (!constantTimeEquals(hello, HELLO_WALLET)) {
379
- throw new Error("KeyExchange: wallet hello mismatch");
599
+ }
600
+ /**
601
+ * Originator: publish completion only after the relay acknowledged ACK.
602
+ * This is deliberately separate from onSynAck() so transport ambiguity
603
+ * cannot leave a live or persistable one-sided session.
604
+ */
605
+ confirmOriginatorAckDelivered() {
606
+ if (!this.isOriginator) {
607
+ throw new Error("KeyExchange: responder cannot confirm originator ACK delivery");
380
608
  }
381
- zeroize(ss);
382
- zeroize(this.keypair.sk);
383
- this.keypair = null;
384
- const c1 = await seal(key, DIR_DAPP_TX, 0, htx, HELLO_DAPP);
385
- this.session = {
386
- cid,
387
- key,
388
- htx,
389
- sendDir: DIR_DAPP_TX,
390
- recvDir: DIR_WALLET_TX,
391
- sendSeq: 1,
392
- recvSeq: 1
393
- };
609
+ if (this.keysExchanged) return;
610
+ if (!this.awaitingAckDelivery || !this.session || !this.lastAck) {
611
+ throw new Error("KeyExchange: no provisional originator session to confirm");
612
+ }
613
+ this.awaitingAckDelivery = false;
394
614
  this.keysExchanged = true;
395
615
  this.step = "key_handshake_ACK" /* ACK */;
396
616
  this.emit("keys_exchanged");
397
617
  this.emit("step_change", this.step);
398
- const ack = {
399
- type: "key_handshake_ACK" /* ACK */,
400
- c1: toBase64(c1),
401
- v: PROTOCOL_VERSION
402
- };
403
- this.lastAck = ack;
404
- return ack;
405
618
  }
406
619
  /**
407
620
  * Responder (wallet) side: begin handshake from a scanned QR.
408
621
  * Returns the SYNACK wire message to emit on the relay.
409
622
  */
410
- async receiveQR(cid, pk) {
623
+ async receiveQR(cid, pk, capability) {
411
624
  if (this.isOriginator) {
412
625
  throw new Error("KeyExchange: originator cannot consume a QR");
413
626
  }
414
627
  this.resetInternal(false);
415
- const { ct, ss } = kemEncaps(pk);
416
- const htx = await transcriptHash(cid, pk, ct);
417
- const key = await deriveAeadKey(ss, htx);
418
- const c0 = await seal(key, DIR_WALLET_TX, 0, htx, HELLO_WALLET);
419
- zeroize(ss);
420
- this.session = {
421
- cid,
422
- key,
423
- htx,
424
- sendDir: DIR_WALLET_TX,
425
- recvDir: DIR_DAPP_TX,
426
- sendSeq: 1,
427
- recvSeq: 1
428
- };
429
- this.awaitingAck = true;
430
- this.step = "key_handshake_SYNACK" /* SYNACK */;
431
- this.emit("step_change", this.step);
432
- return {
433
- type: "key_handshake_SYNACK" /* SYNACK */,
434
- ct: toBase64(ct),
435
- c0: toBase64(c0),
436
- v: PROTOCOL_VERSION
437
- };
628
+ const generation = this.stateGeneration;
629
+ const stableCid = cid.slice();
630
+ const stablePk = pk.slice();
631
+ if (capability.length !== PAIRING_CAPABILITY_LEN) {
632
+ throw new Error(`KeyExchange: capability must be ${PAIRING_CAPABILITY_LEN} bytes`);
633
+ }
634
+ const stableCapability = capability.slice();
635
+ let ss = null;
636
+ try {
637
+ const encapsulated = kemEncaps(stablePk);
638
+ const { ct } = encapsulated;
639
+ ss = encapsulated.ss;
640
+ const htx = await transcriptHash(stableCid, stablePk, ct, stableCapability);
641
+ const key = await deriveAeadKey(ss, htx, stableCapability);
642
+ const c0 = await seal(key, DIR_WALLET_TX, 0, htx, HELLO_WALLET);
643
+ if (this.stateGeneration !== generation) {
644
+ throw new Error("KeyExchange: handshake generation changed");
645
+ }
646
+ this.session = {
647
+ protocolVersion: PROTOCOL_VERSION,
648
+ cid: stableCid,
649
+ key,
650
+ htx,
651
+ sendDir: DIR_WALLET_TX.slice(),
652
+ recvDir: DIR_DAPP_TX.slice(),
653
+ sendSeq: 1,
654
+ recvSeq: 1
655
+ };
656
+ this.awaitingAck = true;
657
+ this.step = "key_handshake_SYNACK" /* SYNACK */;
658
+ this.emit("step_change", this.step);
659
+ return {
660
+ type: "key_handshake_SYNACK" /* SYNACK */,
661
+ ct: toBase64(ct),
662
+ c0: toBase64(c0),
663
+ v: PROTOCOL_VERSION
664
+ };
665
+ } catch (error2) {
666
+ if (this.stateGeneration === generation) this.resetInternal(false);
667
+ throw error2;
668
+ } finally {
669
+ if (ss) zeroize(ss);
670
+ zeroize(stableCapability);
671
+ }
438
672
  }
439
673
  /**
440
674
  * Responder: verify an incoming ACK and finalize the session.
@@ -452,59 +686,101 @@ var KeyExchange = class extends EventEmitter {
452
686
  throw new Error("KeyExchange: onAck called without a session");
453
687
  }
454
688
  this.awaitingAck = false;
455
- const c1 = fromBase64(msg.c1);
456
- let hello;
689
+ const session = this.session;
690
+ const generation = this.stateGeneration;
457
691
  try {
458
- hello = await open(this.session.key, DIR_DAPP_TX, 0, this.session.htx, c1);
459
- } catch {
460
- throw new Error("KeyExchange: dApp hello AEAD tag failed");
461
- }
462
- if (!constantTimeEquals(hello, HELLO_DAPP)) {
463
- throw new Error("KeyExchange: dApp hello mismatch");
692
+ if (msg.v !== PROTOCOL_VERSION) {
693
+ throw new Error(`KeyExchange: expected protocol v${PROTOCOL_VERSION}`);
694
+ }
695
+ const c1 = fromBase64Exact(msg.c1, ACK_C1_LEN, "ACK dApp hello");
696
+ let hello = null;
697
+ try {
698
+ try {
699
+ hello = await open(session.key, DIR_DAPP_TX, 0, session.htx, c1);
700
+ } catch {
701
+ throw new Error("KeyExchange: dApp hello AEAD tag failed");
702
+ }
703
+ if (!constantTimeEquals(hello, HELLO_DAPP)) {
704
+ throw new Error("KeyExchange: dApp hello mismatch");
705
+ }
706
+ } finally {
707
+ if (hello) zeroize(hello);
708
+ }
709
+ if (this.stateGeneration !== generation || this.session !== session) {
710
+ throw new Error("KeyExchange: handshake generation changed");
711
+ }
712
+ this.keysExchanged = true;
713
+ this.step = "key_handshake_ACK" /* ACK */;
714
+ this.emit("keys_exchanged");
715
+ } catch (error2) {
716
+ if (this.stateGeneration === generation) this.resetInternal(false);
717
+ throw error2;
464
718
  }
465
- this.keysExchanged = true;
466
- this.step = "key_handshake_ACK" /* ACK */;
467
- this.emit("keys_exchanged");
468
719
  }
469
720
  /** Encrypt a string for the counterparty. Returns base64. */
470
721
  async encryptMessage(data) {
471
- if (!this.session) {
722
+ const session = this.session;
723
+ const generation = this.stateGeneration;
724
+ if (!session) {
472
725
  throw new Error("KeyExchange: cannot encrypt - session not established");
473
726
  }
474
- const seq = this.session.sendSeq++;
727
+ if (session.sendSeq >= Number.MAX_SAFE_INTEGER) {
728
+ throw new Error("KeyExchange: send counter exhausted");
729
+ }
730
+ const seq = session.sendSeq++;
475
731
  const pt = textEncoder2.encode(data);
476
- const ct = await seal(this.session.key, this.session.sendDir, seq, this.session.htx, pt);
477
- return toBase64(ct);
732
+ try {
733
+ const ct = await seal(session.key, session.sendDir, seq, session.htx, pt);
734
+ if (this.stateGeneration !== generation || this.session !== session) {
735
+ throw new Error("KeyExchange: session changed while encrypting");
736
+ }
737
+ return toBase64(ct);
738
+ } finally {
739
+ zeroize(pt);
740
+ }
478
741
  }
479
742
  /** Decrypt a base64 ciphertext from the counterparty. */
480
743
  async decryptMessage(b64) {
481
- if (!this.session) {
744
+ const session = this.session;
745
+ const generation = this.stateGeneration;
746
+ if (!session) {
482
747
  throw new Error("KeyExchange: cannot decrypt - session not established");
483
748
  }
749
+ if (session.recvSeq >= Number.MAX_SAFE_INTEGER) {
750
+ throw new Error("KeyExchange: receive counter exhausted");
751
+ }
484
752
  const ct = fromBase64(b64);
485
- const pt = await open(
486
- this.session.key,
487
- this.session.recvDir,
488
- this.session.recvSeq,
489
- this.session.htx,
490
- ct
491
- );
492
- this.session.recvSeq++;
493
- return textDecoder.decode(pt);
753
+ let pt = null;
754
+ try {
755
+ pt = await open(session.key, session.recvDir, session.recvSeq, session.htx, ct);
756
+ if (this.stateGeneration !== generation || this.session !== session) {
757
+ throw new Error("KeyExchange: session changed while decrypting");
758
+ }
759
+ session.recvSeq++;
760
+ return textDecoder.decode(pt);
761
+ } finally {
762
+ if (pt) zeroize(pt);
763
+ }
494
764
  }
495
765
  /** Reset all state for a fresh handshake. Zeroes any live secret-key buffer. */
496
766
  reset() {
497
767
  this.resetInternal(true);
498
768
  }
499
769
  resetInternal(emit) {
770
+ this.stateGeneration++;
500
771
  if (this.keypair) {
501
772
  zeroize(this.keypair.sk);
502
773
  this.keypair = null;
503
774
  }
775
+ if (this.capability) {
776
+ zeroize(this.capability);
777
+ this.capability = null;
778
+ }
504
779
  this.session = null;
505
780
  this.keysExchanged = false;
506
781
  this.awaitingSynAck = false;
507
782
  this.awaitingAck = false;
783
+ this.awaitingAckDelivery = false;
508
784
  this.lastAck = null;
509
785
  this.step = "key_handshake_SYN" /* SYN */;
510
786
  if (emit) this.emit("step_change", this.step);
@@ -514,36 +790,55 @@ var KeyExchange = class extends EventEmitter {
514
790
  }
515
791
  /** The ACK produced for the current handshake, for duplicate-SYNACK replies. */
516
792
  getLastAck() {
517
- return this.lastAck;
793
+ return this.lastAck ? { ...this.lastAck } : null;
518
794
  }
519
795
  getSession() {
520
- return this.session;
796
+ return this.session ? snapshotSession(this.session) : null;
521
797
  }
522
798
  getCurrentStep() {
523
799
  return this.step;
524
800
  }
525
801
  /** Export the current session as a JSON-safe persisted record. */
526
802
  async exportPersisted() {
527
- if (!this.session) return null;
528
- return {
529
- cid: toBase64(this.session.cid),
530
- kAeadRaw: toBase64(await exportRawAeadKey(this.session.key)),
531
- htx: toBase64(this.session.htx),
532
- sendDir: toBase64(this.session.sendDir),
533
- recvDir: toBase64(this.session.recvDir),
534
- sendSeq: this.session.sendSeq,
535
- recvSeq: this.session.recvSeq
536
- };
803
+ const session = this.session;
804
+ const generation = this.stateGeneration;
805
+ if (!session || !this.keysExchanged) return null;
806
+ const kAeadRaw = await exportRawAeadKey(session.key);
807
+ try {
808
+ if (this.stateGeneration !== generation || this.session !== session) return null;
809
+ return {
810
+ protocolVersion: PROTOCOL_VERSION,
811
+ cid: toBase64(session.cid),
812
+ kAeadRaw: toBase64(kAeadRaw),
813
+ htx: toBase64(session.htx),
814
+ sendDir: toBase64(session.sendDir),
815
+ recvDir: toBase64(session.recvDir),
816
+ sendSeq: session.sendSeq,
817
+ recvSeq: session.recvSeq
818
+ };
819
+ } finally {
820
+ zeroize(kAeadRaw);
821
+ }
537
822
  }
538
823
  /** Rehydrate a Session from its persisted form. */
539
824
  static async sessionFromPersisted(p) {
540
- const key = await importRawAeadKey(fromBase64(p.kAeadRaw));
825
+ if (p.protocolVersion !== PROTOCOL_VERSION) {
826
+ throw new Error("KeyExchange: cannot hydrate a pre-v3 session");
827
+ }
828
+ const kAeadRaw = fromBase64Exact(p.kAeadRaw, 32, "persisted AEAD key");
829
+ let key;
830
+ try {
831
+ key = await importRawAeadKey(kAeadRaw);
832
+ } finally {
833
+ zeroize(kAeadRaw);
834
+ }
541
835
  return {
542
- cid: fromBase64(p.cid),
836
+ protocolVersion: PROTOCOL_VERSION,
837
+ cid: fromBase64Exact(p.cid, 16, "persisted channel id"),
543
838
  key,
544
- htx: fromBase64(p.htx),
545
- sendDir: fromBase64(p.sendDir),
546
- recvDir: fromBase64(p.recvDir),
839
+ htx: fromBase64Exact(p.htx, 32, "persisted transcript hash"),
840
+ sendDir: fromBase64Exact(p.sendDir, 4, "persisted send direction"),
841
+ recvDir: fromBase64Exact(p.recvDir, 4, "persisted receive direction"),
547
842
  sendSeq: p.sendSeq,
548
843
  recvSeq: p.recvSeq
549
844
  };
@@ -555,6 +850,19 @@ import { io } from "socket.io-client";
555
850
  import EventEmitter2 from "eventemitter3";
556
851
  var PENDING_JOIN_TIMEOUT_MS = 2e4;
557
852
  var SEND_FLUSH_TIMEOUT_MS = 600;
853
+ var MESSAGE_ACK_TIMEOUT_MS = 15e3;
854
+ var MAX_JOIN_BUFFERED_MESSAGES = 50;
855
+ var MAX_JOIN_PARTICIPANTS = 1;
856
+ var MAX_RELAY_ERROR_CHARS = 512;
857
+ function isRecord(value) {
858
+ return typeof value === "object" && value !== null && !Array.isArray(value);
859
+ }
860
+ function parseParticipants(value, expectedPeerType) {
861
+ if (value === void 0) return [];
862
+ if (!Array.isArray(value) || value.length > MAX_JOIN_PARTICIPANTS) return null;
863
+ if (!value.every((participant) => participant === expectedPeerType)) return null;
864
+ return Array(value.length).fill(expectedPeerType);
865
+ }
558
866
  var SocketClient = class extends EventEmitter2 {
559
867
  constructor(relayUrl, clientType) {
560
868
  super();
@@ -566,9 +874,14 @@ var SocketClient = class extends EventEmitter2 {
566
874
  this.publicKeyBase64 = null;
567
875
  this.seq = 0;
568
876
  this.pendingJoin = null;
569
- this.relayUrl = relayUrl;
877
+ this.pendingSends = /* @__PURE__ */ new Set();
878
+ this.pendingJoinAttempts = /* @__PURE__ */ new Set();
879
+ this.relayUrl = normalizeRelayUrl(relayUrl);
570
880
  this.clientType = clientType;
571
881
  }
882
+ get expectedPeerType() {
883
+ return this.clientType === "dapp" ? "wallet" : "dapp";
884
+ }
572
885
  /**
573
886
  * dApp-only: stash the PK so it gets uploaded on every join_channel
574
887
  * attempt (including auto-rejoins after reconnect).
@@ -577,6 +890,9 @@ var SocketClient = class extends EventEmitter2 {
577
890
  if (this.clientType !== "dapp") {
578
891
  throw new Error("SocketClient.setPublicKey is only valid for dApp clients");
579
892
  }
893
+ if (!isCanonicalBase64OfLength(publicKeyBase64, ML_KEM_768_PK_LEN)) {
894
+ throw new Error(`SocketClient public key must be ${ML_KEM_768_PK_LEN} bytes`);
895
+ }
580
896
  this.publicKeyBase64 = publicKeyBase64;
581
897
  }
582
898
  /**
@@ -602,9 +918,10 @@ var SocketClient = class extends EventEmitter2 {
602
918
  this.emit("connected");
603
919
  if (this.channelId) {
604
920
  const reconnectChannelId = this.channelId;
921
+ const isExplicitPendingJoin = this.pendingJoin?.channelId === reconnectChannelId;
605
922
  this.joinChannelNow(reconnectChannelId).then((result) => {
606
923
  this.settlePendingJoin(reconnectChannelId, { result });
607
- this.emit("reconnected", result);
924
+ if (!isExplicitPendingJoin) this.emit("reconnected", result);
608
925
  }).catch((err) => {
609
926
  const reconnectErr = err instanceof Error ? err : new Error(String(err));
610
927
  this.settlePendingJoin(reconnectChannelId, { error: reconnectErr });
@@ -615,20 +932,28 @@ var SocketClient = class extends EventEmitter2 {
615
932
  });
616
933
  this.socket.on("disconnect", (reason) => {
617
934
  log("Socket", `Disconnected: ${reason}`);
935
+ this.rejectPendingJoinAttempts(
936
+ new Error(`Socket disconnected before join acknowledgement: ${reason}`)
937
+ );
938
+ this.rejectPendingSends(
939
+ new Error(`Socket disconnected before relay acknowledgement: ${reason}`)
940
+ );
618
941
  this.emit("disconnected", reason);
619
942
  });
620
943
  this.socket.on("message", (data) => {
621
- log("Socket", `Message received in channel ${data.id}`);
944
+ log("Socket", "Relay message received");
622
945
  this.emit("message", data);
623
946
  });
624
947
  this.socket.on("participants_changed", (data) => {
625
- if (typeof data !== "object" || data === null) return;
626
- const rec = { ...data };
627
- const event = typeof rec.event === "string" ? rec.event : "";
948
+ if (!isRecord(data)) return;
949
+ const event = data.event;
950
+ if (event !== "join" && event !== "leave" && event !== "disconnect" && event !== "close" || data.clientType !== this.expectedPeerType) {
951
+ return;
952
+ }
628
953
  log("Socket", `Participants changed: ${event}`);
629
954
  this.emit("participants_changed", {
630
955
  event,
631
- ...typeof rec.clientType === "string" ? { clientType: rec.clientType } : {}
956
+ clientType: this.expectedPeerType
632
957
  });
633
958
  });
634
959
  this.socket.on("connect_error", (err) => {
@@ -679,6 +1004,15 @@ var SocketClient = class extends EventEmitter2 {
679
1004
  rejectPending = reject;
680
1005
  });
681
1006
  const watchdog = setTimeout(() => {
1007
+ if (this.channelId === channelId) {
1008
+ this.channelId = null;
1009
+ const socket = this.socket;
1010
+ if (socket) {
1011
+ socket.removeAllListeners();
1012
+ socket.disconnect();
1013
+ if (this.socket === socket) this.socket = null;
1014
+ }
1015
+ }
682
1016
  this.settlePendingJoin(channelId, {
683
1017
  error: new Error(`joinChannel timed out after ${PENDING_JOIN_TIMEOUT_MS}ms`)
684
1018
  });
@@ -694,57 +1028,149 @@ var SocketClient = class extends EventEmitter2 {
694
1028
  }
695
1029
  joinChannelNow(channelId) {
696
1030
  return new Promise((resolve, reject) => {
697
- if (!this.socket?.connected) {
1031
+ const socket = this.socket;
1032
+ if (!socket?.connected) {
698
1033
  reject(new Error("Socket not connected"));
699
1034
  return;
700
1035
  }
1036
+ let settled = false;
1037
+ const abandonJoin = () => {
1038
+ if (this.channelId !== channelId) return;
1039
+ if (socket.connected) socket.emit("leave_channel", { channelId });
1040
+ this.channelId = null;
1041
+ };
1042
+ const finish = (outcome) => {
1043
+ if (settled) return;
1044
+ settled = true;
1045
+ clearTimeout(pending.timer);
1046
+ this.pendingJoinAttempts.delete(pending);
1047
+ if ("result" in outcome) resolve(outcome.result);
1048
+ else reject(outcome.error);
1049
+ };
1050
+ const pending = {
1051
+ timer: setTimeout(() => {
1052
+ abandonJoin();
1053
+ finish({
1054
+ error: new Error(
1055
+ `joinChannel acknowledgement timed out after ${PENDING_JOIN_TIMEOUT_MS}ms`
1056
+ )
1057
+ });
1058
+ }, PENDING_JOIN_TIMEOUT_MS),
1059
+ reject: (error2) => {
1060
+ finish({ error: error2 });
1061
+ }
1062
+ };
1063
+ this.pendingJoinAttempts.add(pending);
701
1064
  const payload = { channelId, clientType: this.clientType };
702
1065
  if (this.clientType === "dapp" && this.publicKeyBase64) {
703
1066
  payload.publicKey = this.publicKeyBase64;
704
1067
  }
705
- this.socket.emit(
706
- "join_channel",
707
- payload,
708
- (response) => {
709
- if (response.success) {
710
- log("Socket", `Joined channel ${channelId}`);
711
- resolve({
712
- bufferedMessages: response.bufferedMessages ?? [],
713
- channelPublicKey: response.channelPublicKey ?? null,
714
- participants: response.participants ?? [],
715
- terminated: response.terminated === true
716
- });
717
- } else {
718
- error("Socket", `Failed to join channel: ${response.error}`);
719
- reject(new Error(response.error ?? "Failed to join channel"));
1068
+ socket.emit("join_channel", payload, (response) => {
1069
+ if (!isRecord(response)) {
1070
+ abandonJoin();
1071
+ finish({ error: new Error("Relay returned a malformed join acknowledgement") });
1072
+ return;
1073
+ }
1074
+ if (response.success === true) {
1075
+ const rawMessages = response.bufferedMessages;
1076
+ if (rawMessages !== void 0 && !Array.isArray(rawMessages) || Array.isArray(rawMessages) && rawMessages.length > MAX_JOIN_BUFFERED_MESSAGES) {
1077
+ abandonJoin();
1078
+ finish({ error: new Error("Relay returned an invalid buffered message list") });
1079
+ return;
1080
+ }
1081
+ const rawParticipants = response.participants;
1082
+ const participants = parseParticipants(rawParticipants, this.expectedPeerType);
1083
+ if (!participants) {
1084
+ abandonJoin();
1085
+ finish({ error: new Error("Relay returned an invalid participant list") });
1086
+ return;
1087
+ }
1088
+ const rawPublicKey = response.channelPublicKey;
1089
+ if (rawPublicKey !== void 0 && rawPublicKey !== null && !isCanonicalBase64OfLength(rawPublicKey, ML_KEM_768_PK_LEN)) {
1090
+ abandonJoin();
1091
+ finish({ error: new Error("Relay returned an invalid channel public key") });
1092
+ return;
1093
+ }
1094
+ if (response.terminated !== void 0 && typeof response.terminated !== "boolean") {
1095
+ abandonJoin();
1096
+ finish({ error: new Error("Relay returned an invalid termination status") });
1097
+ return;
720
1098
  }
1099
+ log("Socket", `Joined channel ${channelId}`);
1100
+ finish({
1101
+ result: {
1102
+ bufferedMessages: Array.isArray(rawMessages) ? rawMessages.slice() : [],
1103
+ channelPublicKey: typeof rawPublicKey === "string" ? rawPublicKey : null,
1104
+ participants,
1105
+ terminated: response.terminated === true
1106
+ }
1107
+ });
1108
+ } else {
1109
+ const relayError = typeof response.error === "string" ? response.error.slice(0, MAX_RELAY_ERROR_CHARS) : "Failed to join channel";
1110
+ error("Socket", `Failed to join channel: ${relayError}`);
1111
+ abandonJoin();
1112
+ finish({ error: new Error(relayError) });
721
1113
  }
722
- );
1114
+ });
723
1115
  });
724
1116
  }
1117
+ rejectPendingJoinAttempts(error2) {
1118
+ for (const pending of [...this.pendingJoinAttempts]) pending.reject(error2);
1119
+ }
725
1120
  /**
726
1121
  * Send a message through the relay.
727
1122
  */
728
1123
  sendMessage(data) {
729
1124
  return new Promise((resolve, reject) => {
730
- if (!this.socket?.connected) {
1125
+ const socket = this.socket;
1126
+ if (!socket?.connected) {
731
1127
  reject(new Error("Socket not connected"));
732
1128
  return;
733
1129
  }
734
1130
  const dataWithSeq = { ...data, seq: this.seq++ };
735
- this.socket.emit(
736
- "message",
737
- dataWithSeq,
738
- (response) => {
739
- if (response?.success) {
740
- resolve({ success: true, buffered: response.buffered });
741
- } else {
742
- reject(new Error(response?.error ?? "Failed to send message"));
1131
+ let settled = false;
1132
+ const finish = (outcome) => {
1133
+ if (settled) return;
1134
+ settled = true;
1135
+ clearTimeout(pending.timer);
1136
+ this.pendingSends.delete(pending);
1137
+ if ("result" in outcome) resolve(outcome.result);
1138
+ else reject(outcome.error);
1139
+ };
1140
+ const pending = {
1141
+ timer: setTimeout(() => {
1142
+ finish({
1143
+ error: new Error(
1144
+ `Relay message acknowledgement timed out after ${MESSAGE_ACK_TIMEOUT_MS}ms`
1145
+ )
1146
+ });
1147
+ }, MESSAGE_ACK_TIMEOUT_MS),
1148
+ reject: (error2) => {
1149
+ finish({ error: error2 });
1150
+ }
1151
+ };
1152
+ this.pendingSends.add(pending);
1153
+ socket.emit("message", dataWithSeq, (response) => {
1154
+ if (!isRecord(response)) {
1155
+ finish({ error: new Error("Relay returned a malformed message acknowledgement") });
1156
+ return;
1157
+ }
1158
+ if (response.success === true) {
1159
+ if (typeof response.buffered !== "boolean") {
1160
+ finish({ error: new Error("Relay returned a malformed message acknowledgement") });
1161
+ return;
743
1162
  }
1163
+ finish({ result: { success: true, buffered: response.buffered } });
1164
+ } else {
1165
+ const relayError = typeof response.error === "string" ? response.error.slice(0, MAX_RELAY_ERROR_CHARS) : "Failed to send message";
1166
+ finish({ error: new Error(relayError) });
744
1167
  }
745
- );
1168
+ });
746
1169
  });
747
1170
  }
1171
+ rejectPendingSends(error2) {
1172
+ for (const pending of [...this.pendingSends]) pending.reject(error2);
1173
+ }
748
1174
  /**
749
1175
  * Emit an event and resolve once the relay acks it, or after a bounded
750
1176
  * flush window. Lets a caller await transmission before tearing the
@@ -753,19 +1179,21 @@ var SocketClient = class extends EventEmitter2 {
753
1179
  flushEmit(event, payload) {
754
1180
  return new Promise((resolve) => {
755
1181
  if (!this.socket?.connected) {
756
- resolve();
1182
+ resolve(false);
757
1183
  return;
758
1184
  }
759
1185
  let settled = false;
760
- const done = () => {
1186
+ const done = (confirmed) => {
761
1187
  if (settled) return;
762
1188
  settled = true;
763
- resolve();
1189
+ resolve(confirmed);
764
1190
  };
765
- const timer = setTimeout(done, SEND_FLUSH_TIMEOUT_MS);
766
- this.socket.emit(event, payload, () => {
1191
+ const timer = setTimeout(() => {
1192
+ done(false);
1193
+ }, SEND_FLUSH_TIMEOUT_MS);
1194
+ this.socket.emit(event, payload, (response) => {
767
1195
  clearTimeout(timer);
768
- done();
1196
+ done(isRecord(response) && response.success === true && response.terminated === true);
769
1197
  });
770
1198
  });
771
1199
  }
@@ -784,7 +1212,8 @@ var SocketClient = class extends EventEmitter2 {
784
1212
  this.pendingJoin.reject(new Error("Channel closed before join completed"));
785
1213
  this.pendingJoin = null;
786
1214
  }
787
- if (!this.socket?.connected || !channelId) return Promise.resolve();
1215
+ this.rejectPendingJoinAttempts(new Error("Channel closed before join completed"));
1216
+ if (!this.socket?.connected || !channelId) return Promise.resolve(false);
788
1217
  return this.flushEmit("close_channel", { channelId });
789
1218
  }
790
1219
  /**
@@ -796,6 +1225,7 @@ var SocketClient = class extends EventEmitter2 {
796
1225
  this.pendingJoin.reject(new Error("Channel left before join completed"));
797
1226
  this.pendingJoin = null;
798
1227
  }
1228
+ this.rejectPendingJoinAttempts(new Error("Channel left before join completed"));
799
1229
  if (this.socket?.connected && this.channelId) {
800
1230
  this.socket.emit("leave_channel", { channelId: this.channelId });
801
1231
  }
@@ -810,8 +1240,10 @@ var SocketClient = class extends EventEmitter2 {
810
1240
  this.pendingJoin.reject(new Error("Socket disconnected before join completed"));
811
1241
  this.pendingJoin = null;
812
1242
  }
1243
+ this.rejectPendingJoinAttempts(new Error("Socket disconnected before join completed"));
813
1244
  this.channelId = null;
814
1245
  this.seq = 0;
1246
+ this.rejectPendingSends(new Error("Socket disconnected before relay acknowledgement"));
815
1247
  if (this.socket) {
816
1248
  this.socket.removeAllListeners();
817
1249
  this.socket.disconnect();
@@ -900,38 +1332,67 @@ function base45Decode(s) {
900
1332
  }
901
1333
 
902
1334
  // src/utils/qrUri.ts
903
- var MAGIC = new Uint8Array([80, 81, 80, 50]);
904
- var FP_LABEL = new TextEncoder().encode("pq-fp/v2");
1335
+ var MAGIC = new Uint8Array([80, 81, 80, 51]);
1336
+ var FP_LABEL = new TextEncoder().encode("pq-fp/v3");
905
1337
  var CID_LEN = 16;
906
1338
  var FP_LEN = 32;
907
- var BLOB_LEN = 4 + CID_LEN + FP_LEN;
908
- async function computeFingerprint(cid, pk) {
1339
+ var CAP_LEN = PAIRING_CAPABILITY_LEN;
1340
+ var BLOB_LEN = 4 + CID_LEN + FP_LEN + CAP_LEN;
1341
+ var MAX_CONNECTION_URI_LENGTH = 4096;
1342
+ async function computeFingerprint(cid, pk, capability) {
909
1343
  if (cid.length !== CID_LEN) {
910
1344
  throw new Error(`qrUri: cid must be ${CID_LEN} bytes`);
911
1345
  }
912
- const buf = new Uint8Array(FP_LABEL.length + cid.length + pk.length);
1346
+ if (pk.length !== ML_KEM_768_PK_LEN) {
1347
+ throw new Error(`qrUri: ML-KEM public key must be ${ML_KEM_768_PK_LEN} bytes`);
1348
+ }
1349
+ if (capability.length !== CAP_LEN) {
1350
+ throw new Error(`qrUri: capability must be ${CAP_LEN} bytes`);
1351
+ }
1352
+ const buf = new Uint8Array(FP_LABEL.length + cid.length + pk.length + capability.length);
913
1353
  buf.set(FP_LABEL, 0);
914
1354
  buf.set(cid, FP_LABEL.length);
915
1355
  buf.set(pk, FP_LABEL.length + cid.length);
916
- return sha256(buf);
1356
+ buf.set(capability, FP_LABEL.length + cid.length + pk.length);
1357
+ try {
1358
+ return await sha256(buf);
1359
+ } finally {
1360
+ buf.fill(0);
1361
+ }
917
1362
  }
918
1363
  function fingerprintEquals(a, b) {
919
1364
  return constantTimeEquals(a, b);
920
1365
  }
921
- async function generateConnectionURI(cid, pk, relayUrl) {
1366
+ async function generateConnectionURI(cid, pk, capability, relayUrl) {
922
1367
  if (cid.length !== CID_LEN) {
923
1368
  throw new Error(`qrUri: cid must be ${CID_LEN} bytes (got ${cid.length})`);
924
1369
  }
925
- const fp = await computeFingerprint(cid, pk);
1370
+ if (pk.length !== ML_KEM_768_PK_LEN) {
1371
+ throw new Error(
1372
+ `qrUri: ML-KEM public key must be ${ML_KEM_768_PK_LEN} bytes (got ${pk.length})`
1373
+ );
1374
+ }
1375
+ if (capability.length !== CAP_LEN) {
1376
+ throw new Error(`qrUri: capability must be ${CAP_LEN} bytes (got ${capability.length})`);
1377
+ }
1378
+ const stableCapability = capability.slice();
926
1379
  const blob = new Uint8Array(BLOB_LEN);
927
- blob.set(MAGIC, 0);
928
- blob.set(cid, 4);
929
- blob.set(fp, 4 + CID_LEN);
930
- const params = new URLSearchParams({ q: base45Encode(blob) });
931
- if (relayUrl) params.set("r", relayUrl);
932
- return `qrlconnect://?${params.toString()}`;
1380
+ try {
1381
+ const fp = await computeFingerprint(cid, pk, stableCapability);
1382
+ blob.set(MAGIC, 0);
1383
+ blob.set(cid, 4);
1384
+ blob.set(fp, 4 + CID_LEN);
1385
+ blob.set(stableCapability, 4 + CID_LEN + FP_LEN);
1386
+ const params = new URLSearchParams({ q: base45Encode(blob) });
1387
+ if (relayUrl !== void 0) params.set("r", normalizeRelayUrl(relayUrl));
1388
+ return `qrlconnect://?${params.toString()}`;
1389
+ } finally {
1390
+ stableCapability.fill(0);
1391
+ blob.fill(0);
1392
+ }
933
1393
  }
934
1394
  var PQP1_MAGIC = new Uint8Array([80, 81, 80, 49]);
1395
+ var PQP2_MAGIC = new Uint8Array([80, 81, 80, 50]);
935
1396
  function startsWith(buf, prefix) {
936
1397
  if (buf.length < prefix.length) return false;
937
1398
  for (let i = 0; i < prefix.length; i++) {
@@ -943,21 +1404,38 @@ async function parseConnectionURI(uri) {
943
1404
  if (typeof uri !== "string" || uri.length === 0) {
944
1405
  throw new Error("qrUri: empty URI");
945
1406
  }
946
- if (!/^qrlconnect:/i.test(uri)) {
1407
+ if (uri.length > MAX_CONNECTION_URI_LENGTH) {
1408
+ throw new Error(`qrUri: URI exceeds ${MAX_CONNECTION_URI_LENGTH} characters`);
1409
+ }
1410
+ if (!uri.startsWith("qrlconnect:")) {
947
1411
  throw new Error("qrUri: not a qrlconnect URI");
948
1412
  }
1413
+ if (!uri.startsWith("qrlconnect://?") || uri.includes("#")) {
1414
+ throw new Error("qrUri: URI must use canonical qrlconnect://? query form");
1415
+ }
949
1416
  let params;
950
1417
  try {
951
- const swapped = new URL(uri.replace(/^qrlconnect:\/?\/?/i, "https://qrlconnect/"));
952
- params = swapped.searchParams;
1418
+ const parsed = new URL(uri);
1419
+ if (parsed.protocol !== "qrlconnect:" || parsed.host !== "" || parsed.pathname !== "" || parsed.hash !== "") {
1420
+ throw new Error("non-canonical URI components");
1421
+ }
1422
+ params = parsed.searchParams;
953
1423
  } catch {
954
1424
  throw new Error("qrUri: malformed URI");
955
1425
  }
956
1426
  if (params.has("channelId") || params.has("pubKey")) {
957
1427
  throw new Error(
958
- "qrUri: legacy v1 URI detected - this wallet and this dApp must both run protocol v2"
1428
+ "qrUri: legacy v1 URI detected - this wallet and this dApp must both run protocol v3"
959
1429
  );
960
1430
  }
1431
+ if (params.getAll("q").length > 1 || params.getAll("r").length > 1) {
1432
+ throw new Error("qrUri: duplicate q or r parameter");
1433
+ }
1434
+ for (const key of params.keys()) {
1435
+ if (key !== "q" && key !== "r") {
1436
+ throw new Error(`qrUri: unknown parameter ${key}`);
1437
+ }
1438
+ }
961
1439
  const q = params.get("q");
962
1440
  if (!q) {
963
1441
  throw new Error("qrUri: missing q parameter");
@@ -969,19 +1447,40 @@ async function parseConnectionURI(uri) {
969
1447
  const msg = err instanceof Error ? err.message : String(err);
970
1448
  throw new Error(`qrUri: base45 decode failed: ${msg}`);
971
1449
  }
972
- if (blob.length !== BLOB_LEN) {
973
- if (blob.length === 1208 && startsWith(blob, PQP1_MAGIC)) {
974
- throw new Error("qrUri: legacy PQP1 URI detected - regenerate the QR with a v2.0+ dApp SDK");
1450
+ try {
1451
+ if (blob.length !== BLOB_LEN) {
1452
+ if (blob.length === 1208 && startsWith(blob, PQP1_MAGIC)) {
1453
+ throw new Error(
1454
+ "qrUri: legacy PQP1 URI detected - regenerate the QR with a v4.0+ dApp SDK"
1455
+ );
1456
+ }
1457
+ if (blob.length === 52 && startsWith(blob, PQP2_MAGIC)) {
1458
+ throw new Error(
1459
+ "qrUri: legacy PQP2 URI detected - regenerate the QR with a v4.0+ dApp SDK"
1460
+ );
1461
+ }
1462
+ throw new Error(`qrUri: expected ${BLOB_LEN}-byte blob, got ${blob.length}`);
975
1463
  }
976
- throw new Error(`qrUri: expected ${BLOB_LEN}-byte blob, got ${blob.length}`);
977
- }
978
- if (!startsWith(blob, MAGIC)) {
979
- throw new Error("qrUri: bad PQP2 magic");
1464
+ if (!startsWith(blob, MAGIC)) {
1465
+ throw new Error("qrUri: bad PQP3 magic");
1466
+ }
1467
+ const cid = blob.slice(4, 4 + CID_LEN);
1468
+ const fp = blob.slice(4 + CID_LEN, 4 + CID_LEN + FP_LEN);
1469
+ const capability = blob.slice(4 + CID_LEN + FP_LEN, BLOB_LEN);
1470
+ const r = params.get("r");
1471
+ let relayUrl;
1472
+ if (r !== null) {
1473
+ if (r === "") throw new Error("qrUri: empty relay URL");
1474
+ try {
1475
+ relayUrl = normalizeRelayUrl(r);
1476
+ } catch {
1477
+ throw new Error("qrUri: invalid relay URL");
1478
+ }
1479
+ }
1480
+ return { cid, fp, capability, relayUrl };
1481
+ } finally {
1482
+ blob.fill(0);
980
1483
  }
981
- const cid = blob.slice(4, 4 + CID_LEN);
982
- const fp = blob.slice(4 + CID_LEN, 4 + CID_LEN + FP_LEN);
983
- const r = params.get("r");
984
- return { cid, fp, relayUrl: r === null || r === "" ? void 0 : r };
985
1484
  }
986
1485
  function cidToString(cid) {
987
1486
  if (cid.length !== CID_LEN) {
@@ -1008,31 +1507,196 @@ function cidRandom() {
1008
1507
  return randomBytes(CID_LEN);
1009
1508
  }
1010
1509
 
1011
- // src/ConnectionManager.ts
1510
+ // src/SessionOwnership.ts
1511
+ function getBrowserStorage() {
1512
+ try {
1513
+ return typeof localStorage === "undefined" ? null : localStorage;
1514
+ } catch {
1515
+ return null;
1516
+ }
1517
+ }
1518
+ function getBrowserLockManager() {
1519
+ try {
1520
+ if (typeof navigator === "undefined") return null;
1521
+ return navigator.locks ?? null;
1522
+ } catch {
1523
+ return null;
1524
+ }
1525
+ }
1526
+ var SessionOwnership = class {
1527
+ constructor(storageKey, lockManager) {
1528
+ this.owned = false;
1529
+ this.acquireInFlight = null;
1530
+ this.releaseLock = null;
1531
+ this.lockRequest = null;
1532
+ this.releaseInFlight = null;
1533
+ // Monotonic attempt identity. release() advances it before waiting for the
1534
+ // Web Locks request, so a callback dispatched after teardown can observe
1535
+ // cancellation and decline a late grant.
1536
+ this.ownershipGeneration = 0;
1537
+ this.lockName = `${storageKey}:aead-owner`;
1538
+ this.lockManager = lockManager;
1539
+ }
1540
+ isOwned() {
1541
+ return this.owned;
1542
+ }
1543
+ async acquire() {
1544
+ const generation = this.ownershipGeneration;
1545
+ if (this.releaseInFlight) await this.releaseInFlight;
1546
+ if (generation !== this.ownershipGeneration) return false;
1547
+ if (this.owned) return true;
1548
+ if (this.acquireInFlight) return this.acquireInFlight;
1549
+ const task = this.startAcquire(generation);
1550
+ this.acquireInFlight = task;
1551
+ try {
1552
+ return await task;
1553
+ } finally {
1554
+ if (this.acquireInFlight === task) this.acquireInFlight = null;
1555
+ }
1556
+ }
1557
+ startAcquire(generation) {
1558
+ return new Promise((resolve) => {
1559
+ let grantSettled = false;
1560
+ const settleGrant = (granted) => {
1561
+ if (grantSettled) return;
1562
+ grantSettled = true;
1563
+ resolve(granted);
1564
+ };
1565
+ let request;
1566
+ try {
1567
+ request = this.lockManager.request(
1568
+ this.lockName,
1569
+ { mode: "exclusive", ifAvailable: true },
1570
+ async (lock) => {
1571
+ if (!lock || generation !== this.ownershipGeneration) {
1572
+ settleGrant(false);
1573
+ return;
1574
+ }
1575
+ this.owned = true;
1576
+ settleGrant(true);
1577
+ await new Promise((release) => {
1578
+ this.releaseLock = release;
1579
+ });
1580
+ this.releaseLock = null;
1581
+ this.owned = false;
1582
+ }
1583
+ );
1584
+ } catch (err) {
1585
+ warn("SessionOwnership", "Web Lock request failed:", err);
1586
+ settleGrant(false);
1587
+ return;
1588
+ }
1589
+ this.lockRequest = request.then(() => void 0).catch((err) => {
1590
+ warn("SessionOwnership", "Web Lock request failed:", err);
1591
+ settleGrant(false);
1592
+ });
1593
+ });
1594
+ }
1595
+ release() {
1596
+ this.ownershipGeneration++;
1597
+ if (this.releaseInFlight) return this.releaseInFlight;
1598
+ const task = this.finishRelease();
1599
+ const tracked = task.finally(() => {
1600
+ if (this.releaseInFlight === tracked) this.releaseInFlight = null;
1601
+ });
1602
+ this.releaseInFlight = tracked;
1603
+ return tracked;
1604
+ }
1605
+ async finishRelease() {
1606
+ const release = this.releaseLock;
1607
+ this.releaseLock = null;
1608
+ if (release) release();
1609
+ const request = this.lockRequest;
1610
+ if (request) await request;
1611
+ this.owned = false;
1612
+ this.acquireInFlight = null;
1613
+ if (this.lockRequest === request) this.lockRequest = null;
1614
+ }
1615
+ };
1616
+
1617
+ // src/ConnectionManager.ts
1012
1618
  var DAPP_PARTICIPANT_CONFLICT_ERROR_MSG = "dapp participant is already connected";
1013
- function isRecord(v) {
1619
+ var MAX_RELAY_CIPHERTEXT_CHARS = 256 * 1024;
1620
+ var MAX_JSON_RPC_ERROR_MESSAGE_CHARS = 1024;
1621
+ var MAX_DAPP_NAME_CHARS = 128;
1622
+ var MAX_DAPP_URL_CHARS = 2048;
1623
+ var MAX_DAPP_ICON_CHARS = 4096;
1624
+ var MAX_CHAIN_ID_CHARS = 66;
1625
+ var MAX_STORED_CLOCK_SKEW_MS = 5 * 60 * 1e3;
1626
+ var PERSISTED_CID_LEN = 16;
1627
+ var PERSISTED_AEAD_KEY_LEN = 32;
1628
+ var PERSISTED_HTX_LEN = 32;
1629
+ var PERSISTED_DIRECTION_LEN = 4;
1630
+ var MAX_CONNECTED_ACCOUNTS = 1;
1631
+ var DAPP_TX_B64 = toBase64(DIR_DAPP_TX);
1632
+ var WALLET_TX_B64 = toBase64(DIR_WALLET_TX);
1633
+ function isRecord2(v) {
1014
1634
  return typeof v === "object" && v !== null && !Array.isArray(v);
1015
1635
  }
1016
- function isStringArray(v) {
1017
- return Array.isArray(v) && v.every((x) => typeof x === "string");
1636
+ function hasUnsafeDAppNameCharacter(value) {
1637
+ for (let index = 0; index < value.length; index++) {
1638
+ const code = value.charCodeAt(index);
1639
+ if (code <= 31 || code >= 127 && code <= 159 || code === 1564 || code === 6158 || code >= 8203 && code <= 8207 || code >= 8232 && code <= 8238 || code === 8288 || code >= 8294 && code <= 8297 || code === 65279) {
1640
+ return true;
1641
+ }
1642
+ }
1643
+ return false;
1644
+ }
1645
+ function canonicalChainId(value) {
1646
+ if (typeof value !== "string" || value.length === 0 || value.length > MAX_CHAIN_ID_CHARS || !/^0x[0-9a-fA-F]+$/.test(value)) {
1647
+ return null;
1648
+ }
1649
+ try {
1650
+ return `0x${BigInt(value).toString(16)}`;
1651
+ } catch {
1652
+ return null;
1653
+ }
1654
+ }
1655
+ function canonicalDAppHttpUrl(value) {
1656
+ if (typeof value !== "string" || value.length === 0 || value.length > MAX_DAPP_URL_CHARS || value.trim() !== value) {
1657
+ return null;
1658
+ }
1659
+ try {
1660
+ const url = new URL(value);
1661
+ const hostname = url.hostname.toLowerCase();
1662
+ const isLoopback = isExplicitLoopbackHostname(hostname);
1663
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback) || url.username !== "" || url.password !== "" || hostname === "") {
1664
+ return null;
1665
+ }
1666
+ return url.toString();
1667
+ } catch {
1668
+ return null;
1669
+ }
1670
+ }
1671
+ function isBoundedTimestamp(value, now) {
1672
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= now + MAX_STORED_CLOCK_SKEW_MS;
1673
+ }
1674
+ function isCurrentQrlAddressArray(v) {
1675
+ return Array.isArray(v) && v.length <= MAX_CONNECTED_ACCOUNTS && v.every(isCurrentQrlAddress) && new Set(v).size === v.length;
1018
1676
  }
1019
1677
  function isRelayMessage(v) {
1020
- return isRecord(v) && typeof v.id === "string" && (v.clientType === "dapp" || v.clientType === "wallet") && "message" in v && (typeof v.message === "string" || typeof v.message === "object" && v.message !== null);
1678
+ return isRecord2(v) && typeof v.id === "string" && v.id.length > 0 && v.id.length <= 128 && (v.clientType === "dapp" || v.clientType === "wallet") && "message" in v && (typeof v.message === "string" && v.message.length <= MAX_RELAY_CIPHERTEXT_CHARS || isRecord2(v.message));
1021
1679
  }
1022
1680
  var MESSAGE_TYPE_BY_VALUE = Object.fromEntries(
1023
1681
  Object.values(MessageType).map((m) => [m, m])
1024
1682
  );
1025
1683
  function parseJsonRpcResponse(msg) {
1026
- if (typeof msg.id !== "string" && typeof msg.id !== "number") return null;
1684
+ if (msg.jsonrpc !== "2.0" || !isValidJsonRpcId(msg.id)) return null;
1685
+ const hasResult = Object.prototype.hasOwnProperty.call(msg, "result");
1686
+ const hasError = Object.prototype.hasOwnProperty.call(msg, "error");
1687
+ if (hasResult === hasError) return null;
1027
1688
  const out = {
1028
- jsonrpc: typeof msg.jsonrpc === "string" ? msg.jsonrpc : "2.0",
1689
+ jsonrpc: "2.0",
1029
1690
  id: msg.id
1030
1691
  };
1031
- if ("result" in msg) out.result = msg.result;
1032
- if (msg.error !== void 0) {
1033
- if (!isRecord(msg.error) || typeof msg.error.message !== "string") return null;
1692
+ if (hasResult) out.result = msg.result;
1693
+ if (hasError) {
1694
+ const code = isRecord2(msg.error) ? msg.error.code : void 0;
1695
+ if (!isRecord2(msg.error) || typeof code !== "number" || !Number.isSafeInteger(code) || typeof msg.error.message !== "string" || msg.error.message.length === 0 || msg.error.message.length > MAX_JSON_RPC_ERROR_MESSAGE_CHARS) {
1696
+ return null;
1697
+ }
1034
1698
  out.error = {
1035
- code: typeof msg.error.code === "number" ? msg.error.code : -32e3,
1699
+ code,
1036
1700
  message: msg.error.message,
1037
1701
  data: msg.error.data
1038
1702
  };
@@ -1040,18 +1704,38 @@ function parseJsonRpcResponse(msg) {
1040
1704
  return out;
1041
1705
  }
1042
1706
  function parsePersistedKex(v) {
1043
- if (!isRecord(v)) return null;
1044
- const { cid, kAeadRaw, htx, sendDir, recvDir, sendSeq, recvSeq } = v;
1045
- if (typeof cid !== "string" || typeof kAeadRaw !== "string" || typeof htx !== "string" || typeof sendDir !== "string" || typeof recvDir !== "string" || typeof sendSeq !== "number" || !Number.isInteger(sendSeq) || sendSeq < 0 || typeof recvSeq !== "number" || !Number.isInteger(recvSeq) || recvSeq < 0) {
1707
+ if (!isRecord2(v)) return null;
1708
+ const { protocolVersion, cid, kAeadRaw, htx, sendDir, recvDir, sendSeq, recvSeq } = v;
1709
+ if (protocolVersion !== PROTOCOL_VERSION || typeof cid !== "string" || typeof kAeadRaw !== "string" || typeof htx !== "string" || typeof sendDir !== "string" || typeof recvDir !== "string" || typeof sendSeq !== "number" || !Number.isSafeInteger(sendSeq) || sendSeq >= Number.MAX_SAFE_INTEGER || sendSeq < 0 || typeof recvSeq !== "number" || !Number.isSafeInteger(recvSeq) || recvSeq >= Number.MAX_SAFE_INTEGER || recvSeq < 0 || !isCanonicalBase64OfLength(cid, PERSISTED_CID_LEN) || !isCanonicalBase64OfLength(kAeadRaw, PERSISTED_AEAD_KEY_LEN) || !isCanonicalBase64OfLength(htx, PERSISTED_HTX_LEN) || !isCanonicalBase64OfLength(sendDir, PERSISTED_DIRECTION_LEN) || !isCanonicalBase64OfLength(recvDir, PERSISTED_DIRECTION_LEN) || sendDir !== DAPP_TX_B64 || recvDir !== WALLET_TX_B64) {
1046
1710
  return null;
1047
1711
  }
1048
- return { cid, kAeadRaw, htx, sendDir, recvDir, sendSeq, recvSeq };
1712
+ return {
1713
+ protocolVersion: PROTOCOL_VERSION,
1714
+ cid,
1715
+ kAeadRaw,
1716
+ htx,
1717
+ sendDir,
1718
+ recvDir,
1719
+ sendSeq,
1720
+ recvSeq
1721
+ };
1049
1722
  }
1050
1723
  function parseDAppMetadata(v) {
1051
- if (!isRecord(v) || typeof v.name !== "string" || typeof v.url !== "string") return null;
1052
- const meta = { name: v.name, url: v.url };
1053
- if (typeof v.icon === "string") meta.icon = v.icon;
1054
- if (typeof v.redirectUrl === "string") meta.redirectUrl = v.redirectUrl;
1724
+ if (!isRecord2(v) || typeof v.name !== "string" || v.name.length === 0 || v.name.length > MAX_DAPP_NAME_CHARS || v.name.trim() !== v.name || hasUnsafeDAppNameCharacter(v.name) || typeof v.url !== "string") {
1725
+ return null;
1726
+ }
1727
+ const url = canonicalDAppHttpUrl(v.url);
1728
+ if (!url) return null;
1729
+ const meta = { name: v.name, url };
1730
+ if (v.icon !== void 0) {
1731
+ if (typeof v.icon !== "string" || v.icon.length > MAX_DAPP_ICON_CHARS) return null;
1732
+ meta.icon = v.icon;
1733
+ }
1734
+ if (v.redirectUrl !== void 0) {
1735
+ const redirectUrl = canonicalDAppHttpUrl(v.redirectUrl);
1736
+ if (!redirectUrl) return null;
1737
+ meta.redirectUrl = redirectUrl;
1738
+ }
1055
1739
  return meta;
1056
1740
  }
1057
1741
  function parseStoredSession(raw) {
@@ -1061,19 +1745,32 @@ function parseStoredSession(raw) {
1061
1745
  } catch {
1062
1746
  return null;
1063
1747
  }
1064
- if (!isRecord(v) || v.version !== 3) return null;
1748
+ if (!isRecord2(v) || v.version !== 5) return null;
1065
1749
  if (typeof v.channelId !== "string" || typeof v.chainId !== "string") return null;
1066
- if (typeof v.createdAt !== "number" || typeof v.lastActivity !== "number") return null;
1750
+ const parsedChainId = canonicalChainId(v.chainId);
1751
+ if (!parsedChainId || parsedChainId !== v.chainId) return null;
1752
+ let encodedChannelId;
1753
+ try {
1754
+ encodedChannelId = toBase64(cidFromString(v.channelId));
1755
+ } catch {
1756
+ return null;
1757
+ }
1758
+ const now = Date.now();
1759
+ if (!isBoundedTimestamp(v.createdAt, now) || !isBoundedTimestamp(v.lastActivity, now) || v.lastActivity < v.createdAt) {
1760
+ return null;
1761
+ }
1067
1762
  const keyExchange = parsePersistedKex(v.keyExchange);
1068
1763
  const dappMetadata = parseDAppMetadata(v.dappMetadata);
1069
- if (!keyExchange || !dappMetadata || !isStringArray(v.connectedAccounts)) return null;
1764
+ if (keyExchange?.cid !== encodedChannelId || !dappMetadata || !isCurrentQrlAddressArray(v.connectedAccounts)) {
1765
+ return null;
1766
+ }
1070
1767
  return {
1071
- version: 3,
1768
+ version: 5,
1072
1769
  channelId: v.channelId,
1073
1770
  keyExchange,
1074
1771
  dappMetadata,
1075
1772
  connectedAccounts: v.connectedAccounts,
1076
- chainId: v.chainId,
1773
+ chainId: parsedChainId,
1077
1774
  createdAt: v.createdAt,
1078
1775
  lastActivity: v.lastActivity
1079
1776
  };
@@ -1093,11 +1790,30 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1093
1790
  this.pendingRestore = null;
1094
1791
  this.messageQueue = Promise.resolve();
1095
1792
  this.outboundQueue = Promise.resolve();
1793
+ this.persistenceQueue = Promise.resolve();
1794
+ this.persistenceBroken = false;
1795
+ this.sessionCreatedAt = Date.now();
1796
+ this.sessionGeneration = 0;
1096
1797
  this.consecutiveDecryptFailures = 0;
1097
- this.dappMetadata = options.dappMetadata;
1098
- this.relayUrl = options.relayUrl ?? DEFAULT_RELAY_URL;
1099
- this.chainId = options.chainId ?? "0x0";
1798
+ const dappMetadata = parseDAppMetadata(options.dappMetadata);
1799
+ if (!dappMetadata) throw new Error("Invalid or unbounded dApp metadata");
1800
+ const initialChainId = canonicalChainId(options.chainId ?? "0x0");
1801
+ if (!initialChainId) throw new Error("Invalid chainId");
1802
+ this.dappMetadata = dappMetadata;
1803
+ this.relayUrl = normalizeRelayUrl(options.relayUrl ?? DEFAULT_RELAY_URL);
1804
+ this.chainId = initialChainId;
1100
1805
  this.storageKey = options.storageKey ?? `${STORAGE_KEY_PREFIX}:session`;
1806
+ this.storage = getBrowserStorage();
1807
+ const lockManager = getBrowserLockManager();
1808
+ this.persistenceEnabled = this.storage !== null && lockManager !== null;
1809
+ this.sessionOwnership = this.persistenceEnabled && lockManager ? new SessionOwnership(this.storageKey, lockManager) : null;
1810
+ if (this.storage && !this.persistenceEnabled) {
1811
+ warn(
1812
+ "ConnectionManager",
1813
+ "Web Locks unavailable; disabling persisted sessions for AEAD safety"
1814
+ );
1815
+ this.removeStoredSession();
1816
+ }
1101
1817
  const stored = this.readStoredSession();
1102
1818
  if (stored) {
1103
1819
  this.channelId = stored.channelId;
@@ -1105,6 +1821,7 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1105
1821
  this.chainId = stored.chainId;
1106
1822
  this.dappMetadata = stored.dappMetadata;
1107
1823
  this.pendingRestore = stored;
1824
+ this.sessionCreatedAt = stored.createdAt;
1108
1825
  log("ConnectionManager", `Found persisted session for channel ${this.channelId}`);
1109
1826
  } else {
1110
1827
  this.channelId = randomUuid();
@@ -1114,50 +1831,35 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1114
1831
  }
1115
1832
  // ── Setup ──────────────────────────────────────────────────
1116
1833
  setupSocketListeners() {
1117
- this.socketClient.on("connected", () => {
1118
- if (this.status === "reconnecting" /* RECONNECTING */) {
1119
- this.failedReconnects = 0;
1120
- }
1121
- });
1122
- this.socketClient.on("reconnected", (result) => {
1123
- if (result?.terminated) {
1124
- log("ConnectionManager", "Channel terminated, observed on auto-reconnect");
1125
- this.handleSessionTerminated();
1126
- return;
1127
- }
1128
- if (result) {
1129
- this.walletPresent = result.participants.includes("wallet");
1130
- }
1131
- if (this.keyExchange?.areKeysExchanged()) {
1132
- if (this.walletPresent) {
1133
- this.clearReconnectProbe();
1134
- this.setStatus("connected" /* CONNECTED */);
1135
- this.failedReconnects = 0;
1136
- } else {
1137
- this.setStatus("waiting" /* WAITING */);
1138
- this.armReconnectProbe();
1139
- }
1140
- } else {
1141
- this.setStatus("waiting" /* WAITING */);
1142
- }
1834
+ const socketClient = this.socketClient;
1835
+ const isCurrentSocket = () => this.socketClient === socketClient;
1836
+ socketClient.on("reconnected", (result) => {
1837
+ void this.handleAutoReconnected(socketClient, result);
1143
1838
  });
1144
- this.socketClient.on("disconnected", () => {
1839
+ socketClient.on("disconnected", () => {
1840
+ if (!isCurrentSocket()) return;
1145
1841
  this.walletPresent = false;
1146
- if (this.status === "connected" /* CONNECTED */) {
1842
+ if (this.keyExchange?.areKeysExchanged() && (this.status === "connected" /* CONNECTED */ || this.status === "reconnecting" /* RECONNECTING */ || this.status === "waiting" /* WAITING */)) {
1147
1843
  this.setStatus("reconnecting" /* RECONNECTING */);
1148
1844
  this.failedReconnects++;
1149
- if (this.failedReconnects >= _ConnectionManager.MAX_RECONNECT_FAILURES) {
1845
+ if (this.failedReconnects === _ConnectionManager.MAX_RECONNECT_FAILURES) {
1150
1846
  this.emit("connection_lost");
1151
1847
  }
1152
1848
  }
1153
1849
  });
1154
- this.socketClient.on("message", (data) => {
1155
- this.enqueueRelayMessage(data);
1850
+ socketClient.on("message", (data) => {
1851
+ if (!isCurrentSocket()) return;
1852
+ const context = this.captureSessionWorkContext();
1853
+ if (context?.socketClient !== socketClient) return;
1854
+ this.enqueueRelayMessage(data, context);
1156
1855
  });
1157
- this.socketClient.on("error", (err) => {
1856
+ socketClient.on("error", (err) => {
1857
+ if (!isCurrentSocket()) return;
1158
1858
  this.emit("error", err);
1159
1859
  });
1160
- this.socketClient.on("participants_changed", (data) => {
1860
+ socketClient.on("participants_changed", (data) => {
1861
+ if (!isCurrentSocket()) return;
1862
+ if (data.clientType !== "wallet") return;
1161
1863
  if (data.event === "join" && data.clientType === "wallet") {
1162
1864
  this.walletPresent = true;
1163
1865
  log("ConnectionManager", "Wallet joined channel");
@@ -1170,81 +1872,172 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1170
1872
  }
1171
1873
  if (data.event === "close") {
1172
1874
  log("ConnectionManager", "Wallet closed the channel (explicit terminate)");
1173
- this.handleSessionTerminated();
1875
+ this.handleSessionTerminated(true);
1174
1876
  return;
1175
1877
  }
1176
1878
  if (data.event === "disconnect" || data.event === "leave") {
1177
- if (data.clientType === "wallet" || !data.clientType) {
1178
- this.walletPresent = false;
1179
- if (this.keyExchange?.areKeysExchanged() && (this.status === "connected" /* CONNECTED */ || this.status === "reconnecting" /* RECONNECTING */)) {
1180
- this.setStatus("waiting" /* WAITING */);
1181
- }
1182
- log("ConnectionManager", "Wallet left channel");
1879
+ this.walletPresent = false;
1880
+ if (this.keyExchange?.areKeysExchanged() && (this.status === "connected" /* CONNECTED */ || this.status === "reconnecting" /* RECONNECTING */)) {
1881
+ this.setStatus("waiting" /* WAITING */);
1183
1882
  }
1883
+ log("ConnectionManager", "Wallet left channel");
1184
1884
  }
1185
1885
  });
1186
1886
  }
1887
+ async handleAutoReconnected(socketClient, result) {
1888
+ if (this.socketClient !== socketClient) return;
1889
+ const context = this.captureSessionWorkContext();
1890
+ if (context?.socketClient !== socketClient) return;
1891
+ if (result.terminated) {
1892
+ log("ConnectionManager", "Channel terminated, observed on auto-reconnect");
1893
+ this.handleSessionTerminated(true);
1894
+ return;
1895
+ }
1896
+ this.walletPresent = result.participants.includes("wallet");
1897
+ for (const message of result.bufferedMessages) {
1898
+ this.enqueueRelayMessage(message, context, false);
1899
+ }
1900
+ await this.messageQueue;
1901
+ if (!this.isSessionWorkCurrent(context)) return;
1902
+ if (context.keyExchange.areKeysExchanged() && this.walletPresent) {
1903
+ this.clearReconnectProbe();
1904
+ this.failedReconnects = 0;
1905
+ this.setStatus("connected" /* CONNECTED */);
1906
+ } else {
1907
+ this.setStatus("waiting" /* WAITING */);
1908
+ if (context.keyExchange.areKeysExchanged()) this.armReconnectProbe();
1909
+ }
1910
+ }
1187
1911
  setupKeyExchangeListeners() {
1188
- if (!this.keyExchange) return;
1189
- this.keyExchange.on("keys_exchanged", () => {
1912
+ const keyExchange = this.keyExchange;
1913
+ const generation = this.sessionGeneration;
1914
+ if (!keyExchange) return;
1915
+ keyExchange.on("keys_exchanged", () => {
1916
+ if (this.keyExchange !== keyExchange || this.sessionGeneration !== generation) return;
1190
1917
  log("ConnectionManager", "Key exchange complete");
1191
1918
  this.clearReconnectProbe();
1192
- this.setStatus("connected" /* CONNECTED */);
1193
1919
  this.walletPresent = true;
1194
1920
  this.failedReconnects = 0;
1195
- void this.persistSession();
1196
- void this.sendEncrypted({
1921
+ const originatorInfoSend = this.sendEncrypted({
1197
1922
  type: "originator_info" /* ORIGINATOR_INFO */,
1198
1923
  originatorInfo: {
1199
1924
  ...this.dappMetadata,
1200
1925
  chainId: this.chainId
1201
1926
  }
1202
1927
  });
1928
+ this.setStatus("connected" /* CONNECTED */);
1929
+ void originatorInfoSend.catch((err) => {
1930
+ error("ConnectionManager", "Failed to send originator info:", err);
1931
+ });
1203
1932
  });
1204
1933
  }
1205
1934
  // ── Public API ─────────────────────────────────────────────
1206
1935
  /**
1207
- * Generate a new v2 connection URI. Rotates channel id and keypair.
1936
+ * Generate a new v3 connection URI. Rotates channel id, keypair, and the
1937
+ * QR-only pairing capability.
1208
1938
  * Returns a `qrlconnect://?q=…` URI safe for QR-rendering or deep-link.
1209
1939
  */
1210
1940
  async getConnectionURI(retryOnConflict = true) {
1941
+ const acquisitionGeneration = this.sessionGeneration;
1942
+ if (!await this.acquireSessionOwnership()) {
1943
+ throw new Error("This QRL Connect session is active in another browser tab");
1944
+ }
1945
+ if (this.sessionGeneration !== acquisitionGeneration) {
1946
+ throw new Error("QRL Connect session changed while browser-tab ownership was pending");
1947
+ }
1948
+ if (retryOnConflict && (this.socketClient.getChannelId() !== null || this.pendingRestore !== null || this.keyExchange !== null)) {
1949
+ await this.resetForNewChannel();
1950
+ }
1951
+ this.invalidateSessionWork();
1952
+ this.socketClient.leaveChannel();
1953
+ this.socketClient.disconnect();
1954
+ this.socketClient = new SocketClient(this.relayUrl, "dapp");
1955
+ this.setupSocketListeners();
1211
1956
  this.setStatus("connecting" /* CONNECTING */);
1212
1957
  this.clearReconnectProbe();
1213
1958
  this.pendingRestore = null;
1214
1959
  this.walletPresent = false;
1215
1960
  this.consecutiveDecryptFailures = 0;
1216
- if (this.keyExchange) {
1217
- this.keyExchange.reset();
1218
- } else {
1219
- this.keyExchange = new KeyExchange(true);
1220
- this.setupKeyExchangeListeners();
1221
- }
1222
- const pk = this.keyExchange.initiate();
1223
- this.channelId = randomUuid();
1224
- this.socketClient.setPublicKey(toBase64(pk));
1225
- this.socketClient.connect();
1961
+ const accountsWereAuthorized = this.connectedAccounts.length > 0;
1962
+ this.connectedAccounts = [];
1963
+ if (accountsWereAuthorized) this.emit("accounts_changed", []);
1226
1964
  try {
1227
- await this.socketClient.joinChannel(this.channelId);
1965
+ this.removeStoredSessionOrThrow();
1228
1966
  } catch (err) {
1229
- if (retryOnConflict && this.isDappParticipantConflictError(err)) {
1230
- warn(
1231
- "ConnectionManager",
1232
- "Channel already has an active dApp participant. Rotating to a fresh channel."
1967
+ this.setStatus("disconnected" /* DISCONNECTED */);
1968
+ throw err;
1969
+ }
1970
+ this.persistenceBroken = false;
1971
+ this.sessionCreatedAt = Date.now();
1972
+ this.keyExchange = new KeyExchange(true);
1973
+ this.setupKeyExchangeListeners();
1974
+ const initiated = this.keyExchange.initiate();
1975
+ const pk = initiated.publicKey;
1976
+ const capability = initiated.capability;
1977
+ let context = null;
1978
+ try {
1979
+ this.channelId = randomUuid();
1980
+ context = this.captureSessionWorkContext();
1981
+ if (!context) throw new Error("Failed to initialize QRL Connect session");
1982
+ context.socketClient.setPublicKey(toBase64(pk));
1983
+ context.socketClient.connect();
1984
+ try {
1985
+ await context.socketClient.joinChannel(context.channelId);
1986
+ this.assertSessionWorkCurrent(context);
1987
+ } catch (err) {
1988
+ if (!this.isSessionWorkCurrent(context)) throw err;
1989
+ if (retryOnConflict && this.isDappParticipantConflictError(err)) {
1990
+ warn(
1991
+ "ConnectionManager",
1992
+ "Channel already has an active dApp participant. Rotating to a fresh channel."
1993
+ );
1994
+ this.channelId = randomUuid();
1995
+ return await this.getConnectionURI(false);
1996
+ }
1997
+ this.invalidateSessionWork();
1998
+ this.keyExchange = null;
1999
+ context.socketClient.leaveChannel();
2000
+ context.socketClient.disconnect();
2001
+ this.setStatus("disconnected" /* DISCONNECTED */);
2002
+ await this.releaseSessionOwnership();
2003
+ throw err;
2004
+ }
2005
+ this.setStatus("waiting" /* WAITING */);
2006
+ let uri;
2007
+ try {
2008
+ uri = await generateConnectionURI(
2009
+ cidFromString(context.channelId),
2010
+ pk,
2011
+ capability,
2012
+ this.relayUrl === DEFAULT_RELAY_URL ? void 0 : this.relayUrl
1233
2013
  );
1234
- this.channelId = randomUuid();
1235
- return this.getConnectionURI(false);
2014
+ this.assertSessionWorkCurrent(context);
2015
+ } catch (err) {
2016
+ if (this.isSessionWorkCurrent(context)) {
2017
+ this.invalidateSessionWork();
2018
+ this.keyExchange = null;
2019
+ context.socketClient.leaveChannel();
2020
+ context.socketClient.disconnect();
2021
+ this.setStatus("disconnected" /* DISCONNECTED */);
2022
+ await this.releaseSessionOwnership();
2023
+ }
2024
+ throw err;
2025
+ }
2026
+ log("ConnectionManager", `Generated v3 connection URI for channel ${context.channelId}`);
2027
+ return uri;
2028
+ } catch (err) {
2029
+ if (context ? this.isSessionWorkCurrent(context) : this.keyExchange !== null) {
2030
+ this.invalidateSessionWork();
2031
+ this.keyExchange = null;
2032
+ this.socketClient.leaveChannel();
2033
+ this.socketClient.disconnect();
2034
+ this.setStatus("disconnected" /* DISCONNECTED */);
2035
+ await this.releaseSessionOwnership();
1236
2036
  }
1237
- this.setStatus("disconnected" /* DISCONNECTED */);
1238
2037
  throw err;
2038
+ } finally {
2039
+ capability.fill(0);
1239
2040
  }
1240
- this.setStatus("waiting" /* WAITING */);
1241
- const uri = await generateConnectionURI(
1242
- cidFromString(this.channelId),
1243
- pk,
1244
- this.relayUrl === DEFAULT_RELAY_URL ? void 0 : this.relayUrl
1245
- );
1246
- log("ConnectionManager", `Generated v2 connection URI for channel ${this.channelId}`);
1247
- return uri;
1248
2041
  }
1249
2042
  /**
1250
2043
  * Reconnect to an existing session.
@@ -1259,18 +2052,68 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1259
2052
  return task;
1260
2053
  }
1261
2054
  async reconnectNow() {
2055
+ if (this.keyExchange?.areKeysExchanged()) return true;
1262
2056
  if (!this.pendingRestore) return false;
2057
+ const acquisitionGeneration = this.sessionGeneration;
2058
+ if (!await this.acquireSessionOwnership()) {
2059
+ this.setStatus("disconnected" /* DISCONNECTED */);
2060
+ return false;
2061
+ }
2062
+ if (this.sessionGeneration !== acquisitionGeneration) return false;
2063
+ const latest = this.readStoredSession();
2064
+ if (!latest) {
2065
+ this.pendingRestore = null;
2066
+ this.connectedAccounts = [];
2067
+ const storageInvalidated = this.clearSession();
2068
+ if (storageInvalidated) {
2069
+ await this.releaseSessionOwnership();
2070
+ } else {
2071
+ this.emit(
2072
+ "error",
2073
+ new Error("Unable to invalidate stored session; retaining browser-tab ownership")
2074
+ );
2075
+ }
2076
+ this.setStatus("disconnected" /* DISCONNECTED */);
2077
+ return false;
2078
+ }
2079
+ this.pendingRestore = latest;
2080
+ this.channelId = latest.channelId;
2081
+ this.connectedAccounts = latest.connectedAccounts;
2082
+ this.chainId = latest.chainId;
2083
+ this.dappMetadata = latest.dappMetadata;
2084
+ this.sessionCreatedAt = latest.createdAt;
2085
+ this.invalidateSessionWork();
2086
+ const restoreGeneration = this.sessionGeneration;
2087
+ const restoreChannelId = this.channelId;
2088
+ const restoreSocketClient = this.socketClient;
1263
2089
  this.setStatus("reconnecting" /* RECONNECTING */);
1264
2090
  this.walletPresent = false;
1265
2091
  this.consecutiveDecryptFailures = 0;
1266
2092
  try {
1267
2093
  const session = await KeyExchange.sessionFromPersisted(this.pendingRestore.keyExchange);
2094
+ if (this.sessionGeneration !== restoreGeneration || this.channelId !== restoreChannelId || this.socketClient !== restoreSocketClient) {
2095
+ return false;
2096
+ }
1268
2097
  this.keyExchange = new KeyExchange(true, session);
2098
+ this.pendingRestore = null;
1269
2099
  this.setupKeyExchangeListeners();
1270
2100
  } catch (err) {
2101
+ if (this.sessionGeneration !== restoreGeneration || this.channelId !== restoreChannelId || this.socketClient !== restoreSocketClient) {
2102
+ return false;
2103
+ }
1271
2104
  error("ConnectionManager", "Failed to hydrate persisted session:", err);
1272
- this.clearSession();
2105
+ const storageInvalidated = this.clearSession();
1273
2106
  this.pendingRestore = null;
2107
+ this.connectedAccounts = [];
2108
+ this.keyExchange = null;
2109
+ if (storageInvalidated) {
2110
+ await this.releaseSessionOwnership();
2111
+ } else {
2112
+ this.emit(
2113
+ "error",
2114
+ new Error("Unable to invalidate stored session; retaining browser-tab ownership")
2115
+ );
2116
+ }
1274
2117
  this.setStatus("disconnected" /* DISCONNECTED */);
1275
2118
  return false;
1276
2119
  }
@@ -1284,23 +2127,27 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1284
2127
  * that the caller has reset walletPresent + set RECONNECTING status.
1285
2128
  */
1286
2129
  async joinAndSettle() {
1287
- this.socketClient.connect();
2130
+ const context = this.captureSessionWorkContext();
2131
+ if (!context) return false;
2132
+ context.socketClient.connect();
1288
2133
  try {
1289
- const { bufferedMessages, participants, terminated } = await this.socketClient.joinChannel(
1290
- this.channelId
2134
+ const { bufferedMessages, participants, terminated } = await context.socketClient.joinChannel(
2135
+ context.channelId
1291
2136
  );
2137
+ if (!this.isSessionWorkCurrent(context)) return false;
1292
2138
  if (terminated) {
1293
2139
  log("ConnectionManager", "Stored session was terminated by the wallet; dropping it");
1294
- this.handleSessionTerminated();
2140
+ this.handleSessionTerminated(true);
1295
2141
  return false;
1296
2142
  }
1297
2143
  if (participants.includes("wallet")) {
1298
2144
  this.walletPresent = true;
1299
2145
  }
1300
2146
  for (const msg of bufferedMessages) {
1301
- this.enqueueRelayMessage(msg);
2147
+ this.enqueueRelayMessage(msg, context, false);
1302
2148
  }
1303
2149
  await this.messageQueue;
2150
+ if (!this.isSessionWorkCurrent(context)) return false;
1304
2151
  if (this.keyExchange?.areKeysExchanged() && this.walletPresent) {
1305
2152
  this.clearReconnectProbe();
1306
2153
  this.setStatus("connected" /* CONNECTED */);
@@ -1310,6 +2157,7 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1310
2157
  }
1311
2158
  return true;
1312
2159
  } catch (err) {
2160
+ if (!this.isSessionWorkCurrent(context)) return false;
1313
2161
  error("ConnectionManager", "Reconnect failed:", err);
1314
2162
  this.setStatus("disconnected" /* DISCONNECTED */);
1315
2163
  return false;
@@ -1379,17 +2227,30 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1379
2227
  * join ack reported a tombstone). Clear local state and surface
1380
2228
  * DISCONNECTED so the consumer drops to a fresh-pairing UI.
1381
2229
  */
1382
- handleSessionTerminated() {
1383
- this.emit("session_terminated");
2230
+ handleSessionTerminated(relayRetired) {
2231
+ this.invalidateSessionWork();
1384
2232
  this.clearReconnectProbe();
1385
2233
  this.clearUnresponsiveTimer();
1386
2234
  this.walletPresent = false;
1387
2235
  this.consecutiveDecryptFailures = 0;
1388
2236
  this.pendingRestore = null;
2237
+ const accountsWereAuthorized = this.connectedAccounts.length > 0;
2238
+ this.connectedAccounts = [];
2239
+ if (accountsWereAuthorized) this.emit("accounts_changed", []);
1389
2240
  this.keyExchange = null;
1390
2241
  this.socketClient.leaveChannel();
1391
2242
  this.socketClient.disconnect();
1392
- this.clearSession();
2243
+ const storageInvalidated = this.clearSession();
2244
+ if (storageInvalidated) {
2245
+ void this.releaseSessionOwnership();
2246
+ } else {
2247
+ this.emit(
2248
+ "error",
2249
+ new Error("Unable to invalidate stored session; retaining browser-tab ownership")
2250
+ );
2251
+ }
2252
+ if (!relayRetired) warn("ConnectionManager", "Relay channel termination was not confirmed");
2253
+ this.emit("session_terminated");
1393
2254
  this.setStatus("disconnected" /* DISCONNECTED */);
1394
2255
  }
1395
2256
  armReconnectProbe() {
@@ -1423,6 +2284,15 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1423
2284
  if (!this.keyExchange?.areKeysExchanged()) {
1424
2285
  throw new Error("Not connected: key exchange not complete");
1425
2286
  }
2287
+ if (!isValidJsonRpcId(request.id)) {
2288
+ throw new Error("Invalid JSON-RPC request id");
2289
+ }
2290
+ if (!isValidJsonRpcMethod(request.method) || classifyRpcMethod(request.method) === "unsupported") {
2291
+ throw new Error("Unsupported JSON-RPC method");
2292
+ }
2293
+ if (request.params !== void 0 && !Array.isArray(request.params)) {
2294
+ throw new Error("JSON-RPC params must be an array");
2295
+ }
1426
2296
  const sent = this.sendEncrypted({
1427
2297
  type: "jsonrpc" /* JSONRPC */,
1428
2298
  jsonrpc: "2.0",
@@ -1440,7 +2310,34 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1440
2310
  return this.status;
1441
2311
  }
1442
2312
  getAccounts() {
1443
- return this.connectedAccounts;
2313
+ return [...this.connectedAccounts];
2314
+ }
2315
+ /**
2316
+ * Commit accounts returned by an approved qrl_requestAccounts response.
2317
+ * WALLET_INFO alone cannot grant this authorization.
2318
+ */
2319
+ async authorizeAccounts(value) {
2320
+ if (!isCurrentQrlAddressArray(value) || value.length === 0) {
2321
+ throw new Error("qrl_requestAccounts returned an invalid account list");
2322
+ }
2323
+ const context = this.captureSessionWorkContext();
2324
+ if (!context?.keyExchange.areKeysExchanged()) {
2325
+ throw new Error("Cannot authorize accounts without an established session");
2326
+ }
2327
+ const nextAccounts = [...value];
2328
+ const previousAccounts = this.connectedAccounts;
2329
+ const changed = !this.areArraysEqual(previousAccounts, nextAccounts);
2330
+ this.connectedAccounts = nextAccounts;
2331
+ try {
2332
+ await this.persistSession(context);
2333
+ this.assertSessionWorkCurrent(context);
2334
+ } catch (error2) {
2335
+ if (this.isSessionWorkCurrent(context)) this.connectedAccounts = previousAccounts;
2336
+ await this.teardownPersistenceFailedSession(error2, context);
2337
+ throw error2;
2338
+ }
2339
+ if (changed) this.emit("accounts_changed", [...this.connectedAccounts]);
2340
+ return [...this.connectedAccounts];
1444
2341
  }
1445
2342
  getChainId() {
1446
2343
  return this.chainId;
@@ -1450,9 +2347,9 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1450
2347
  }
1451
2348
  /** Check (sync) if a persisted session exists and has not expired. */
1452
2349
  hasStoredSession() {
1453
- if (typeof localStorage === "undefined") return false;
2350
+ if (!this.persistenceEnabled || !this.storage) return false;
1454
2351
  try {
1455
- const raw = localStorage.getItem(this.storageKey);
2352
+ const raw = this.storage.getItem(this.storageKey);
1456
2353
  if (!raw) return false;
1457
2354
  const session = parseStoredSession(raw);
1458
2355
  if (!session) return false;
@@ -1474,6 +2371,26 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1474
2371
  const timeout = new Promise((resolve) => setTimeout(resolve, TERMINATE_SEND_TIMEOUT_MS));
1475
2372
  await Promise.race([send, timeout]);
1476
2373
  }
2374
+ /**
2375
+ * Ensure this socket is a relay participant before closing a restorable or
2376
+ * temporarily disconnected channel. close_channel is intentionally
2377
+ * participant-only on the relay, so a cold stored session cannot be retired
2378
+ * by emitting the close event from an unjoined socket.
2379
+ */
2380
+ async prepareRelayChannelForRetirement() {
2381
+ const socketChannelId = this.socketClient.getChannelId();
2382
+ const fallbackChannelId = this.pendingRestore !== null || this.keyExchange !== null ? this.channelId : null;
2383
+ const channelId = socketChannelId ?? fallbackChannelId;
2384
+ if (!channelId) return { hadRelayChannel: false, alreadyRetired: false };
2385
+ if (!this.socketClient.isConnected() || socketChannelId !== channelId) {
2386
+ this.socketClient.connect();
2387
+ const result = await this.socketClient.joinChannel(channelId);
2388
+ if (result.terminated) {
2389
+ return { hadRelayChannel: true, alreadyRetired: true };
2390
+ }
2391
+ }
2392
+ return { hadRelayChannel: true, alreadyRetired: false };
2393
+ }
1477
2394
  /**
1478
2395
  * Reset to a fresh channel + keypair. Sends TERMINATE to any live peer,
1479
2396
  * drops the persisted session, and prepares a clean state for
@@ -1483,60 +2400,118 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1483
2400
  this.clearUnresponsiveTimer();
1484
2401
  this.clearReconnectProbe();
1485
2402
  this.walletPresent = false;
1486
- await this.flushTerminate();
2403
+ const retirement = await this.prepareRelayChannelForRetirement();
2404
+ let relayRetired = retirement.alreadyRetired;
2405
+ try {
2406
+ if (!relayRetired) {
2407
+ await this.flushTerminate();
2408
+ relayRetired = await this.socketClient.closeChannel();
2409
+ }
2410
+ } catch (err) {
2411
+ error("ConnectionManager", "Failed to tombstone reset channel:", err);
2412
+ }
2413
+ if (retirement.hadRelayChannel && !relayRetired) {
2414
+ throw new Error("Unable to retire the previous relay channel");
2415
+ }
2416
+ this.invalidateSessionWork();
1487
2417
  this.socketClient.leaveChannel();
1488
2418
  this.socketClient.disconnect();
1489
- this.clearSession();
2419
+ const storageInvalidated = this.clearSession();
2420
+ const accountsWereAuthorized = this.connectedAccounts.length > 0;
1490
2421
  this.connectedAccounts = [];
2422
+ if (accountsWereAuthorized) this.emit("accounts_changed", []);
1491
2423
  this.pendingRestore = null;
1492
2424
  this.channelId = randomUuid();
1493
2425
  this.keyExchange = null;
2426
+ this.persistenceBroken = false;
2427
+ this.sessionCreatedAt = Date.now();
1494
2428
  this.socketClient = new SocketClient(this.relayUrl, "dapp");
1495
2429
  this.setupSocketListeners();
1496
2430
  this.setStatus("disconnected" /* DISCONNECTED */);
2431
+ if (!storageInvalidated) {
2432
+ const error2 = new Error(
2433
+ "Unable to invalidate stored session; retaining browser-tab ownership"
2434
+ );
2435
+ this.emit("error", error2);
2436
+ throw error2;
2437
+ }
1497
2438
  }
1498
2439
  async disconnect() {
1499
2440
  this.clearUnresponsiveTimer();
1500
2441
  this.clearReconnectProbe();
1501
2442
  this.walletPresent = false;
1502
- await this.flushTerminate();
2443
+ let hadRelayChannel = this.socketClient.getChannelId() !== null || this.pendingRestore !== null || this.keyExchange !== null;
2444
+ let relayRetired = false;
2445
+ try {
2446
+ const retirement = await this.prepareRelayChannelForRetirement();
2447
+ hadRelayChannel = retirement.hadRelayChannel;
2448
+ relayRetired = retirement.alreadyRetired;
2449
+ if (hadRelayChannel && !relayRetired) {
2450
+ await this.flushTerminate();
2451
+ relayRetired = await this.socketClient.closeChannel();
2452
+ }
2453
+ } catch (err) {
2454
+ error("ConnectionManager", "Failed to tombstone disconnected channel:", err);
2455
+ }
2456
+ if (hadRelayChannel && !relayRetired) {
2457
+ throw new Error("Unable to retire the relay channel");
2458
+ }
2459
+ this.invalidateSessionWork();
1503
2460
  this.socketClient.leaveChannel();
1504
2461
  this.socketClient.disconnect();
1505
- this.setStatus("disconnected" /* DISCONNECTED */);
1506
- this.clearSession();
2462
+ const storageInvalidated = this.clearSession();
1507
2463
  this.connectedAccounts = [];
1508
2464
  this.pendingRestore = null;
1509
2465
  this.keyExchange = null;
2466
+ if (storageInvalidated) await this.releaseSessionOwnership();
2467
+ this.setStatus("disconnected" /* DISCONNECTED */);
2468
+ if (hadRelayChannel && !relayRetired) {
2469
+ warn("ConnectionManager", "Disconnected channel termination was not confirmed");
2470
+ }
2471
+ if (!storageInvalidated) {
2472
+ const error2 = new Error(
2473
+ "Unable to invalidate stored session; retaining browser-tab ownership"
2474
+ );
2475
+ this.emit("error", error2);
2476
+ throw error2;
2477
+ }
1510
2478
  }
1511
2479
  // ── Internals ──────────────────────────────────────────────
1512
- enqueueRelayMessage(data) {
2480
+ enqueueRelayMessage(data, context, settleConnection = true) {
1513
2481
  if (!isRelayMessage(data)) {
1514
2482
  warn("ConnectionManager", "Dropping malformed relay envelope");
1515
2483
  return;
1516
2484
  }
1517
- this.messageQueue = this.messageQueue.then(() => this.handleRelayMessage(data)).catch((err) => {
1518
- error("ConnectionManager", "messageQueue handler error:", err);
1519
- });
1520
- }
1521
- async handleRelayMessage(data) {
1522
- if (data.clientType === "wallet") {
1523
- this.walletPresent = true;
1524
- this.clearReconnectProbe();
1525
- if (this.keyExchange?.areKeysExchanged() && this.status !== "connected" /* CONNECTED */) {
2485
+ if (data.id !== context.channelId || data.clientType !== "wallet") {
2486
+ warn("ConnectionManager", "Dropping relay envelope for the wrong channel or peer role");
2487
+ return;
2488
+ }
2489
+ this.messageQueue = this.messageQueue.then(() => this.handleRelayMessage(data, context)).then(() => {
2490
+ if (settleConnection && this.isSessionWorkCurrent(context) && context.keyExchange.areKeysExchanged() && this.walletPresent) {
1526
2491
  this.failedReconnects = 0;
1527
2492
  this.setStatus("connected" /* CONNECTED */);
1528
2493
  }
1529
- }
2494
+ }).catch((err) => {
2495
+ error("ConnectionManager", "messageQueue handler error:", err);
2496
+ });
2497
+ }
2498
+ async handleRelayMessage(data, context) {
2499
+ if (!this.isSessionWorkCurrent(context)) return;
2500
+ this.walletPresent = true;
2501
+ this.clearReconnectProbe();
1530
2502
  const message = data.message;
1531
- if (isRecord(message)) {
2503
+ if (isRecord2(message)) {
1532
2504
  if (message.type === "key_handshake_SYNACK" /* SYNACK */) {
1533
- if (typeof message.ct === "string" && typeof message.c0 === "string") {
1534
- await this.handleSynAck({
1535
- type: "key_handshake_SYNACK" /* SYNACK */,
1536
- ct: message.ct,
1537
- c0: message.c0,
1538
- v: typeof message.v === "number" ? message.v : 0
1539
- });
2505
+ if (isCanonicalBase64OfLength(message.ct, ML_KEM_768_CT_LEN) && isCanonicalBase64OfLength(message.c0, SYNACK_C0_LEN)) {
2506
+ await this.handleSynAck(
2507
+ {
2508
+ type: "key_handshake_SYNACK" /* SYNACK */,
2509
+ ct: message.ct,
2510
+ c0: message.c0,
2511
+ v: typeof message.v === "number" ? message.v : 0
2512
+ },
2513
+ context
2514
+ );
1540
2515
  } else {
1541
2516
  warn("ConnectionManager", "Dropping malformed SYNACK");
1542
2517
  }
@@ -1547,24 +2522,32 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1547
2522
  return;
1548
2523
  }
1549
2524
  }
1550
- if (typeof message === "string" && this.keyExchange?.areKeysExchanged()) {
2525
+ if (typeof message === "string" && context.keyExchange.areKeysExchanged()) {
1551
2526
  let decrypted;
1552
2527
  try {
1553
- decrypted = await this.keyExchange.decryptMessage(message);
2528
+ decrypted = await context.keyExchange.decryptMessage(message);
1554
2529
  } catch (err) {
2530
+ if (!this.isSessionWorkCurrent(context)) return;
1555
2531
  error("ConnectionManager", "Failed to decrypt message:", err);
1556
2532
  this.consecutiveDecryptFailures++;
1557
2533
  if (this.consecutiveDecryptFailures >= _ConnectionManager.MAX_DECRYPT_FAILURES) {
1558
- await this.teardownDesyncedSession();
2534
+ await this.teardownDesyncedSession(context);
1559
2535
  }
1560
2536
  return;
1561
2537
  }
2538
+ if (!this.isSessionWorkCurrent(context)) return;
1562
2539
  this.consecutiveDecryptFailures = 0;
1563
2540
  try {
1564
- await this.persistSession();
2541
+ await this.persistSession(context);
2542
+ } catch (err) {
2543
+ await this.teardownPersistenceFailedSession(err, context);
2544
+ return;
2545
+ }
2546
+ if (!this.isSessionWorkCurrent(context)) return;
2547
+ try {
1565
2548
  const parsed = JSON.parse(decrypted);
1566
- if (isRecord(parsed)) {
1567
- this.handleDecryptedMessage(parsed);
2549
+ if (isRecord2(parsed)) {
2550
+ await this.handleDecryptedMessage(parsed, context);
1568
2551
  } else {
1569
2552
  warn("ConnectionManager", "Dropping non-object decrypted payload");
1570
2553
  }
@@ -1582,50 +2565,114 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1582
2565
  * wallet learns the pairing is dead even if it only re-joins later, then
1583
2566
  * clear local state and surface the standard terminated teardown.
1584
2567
  */
1585
- async teardownDesyncedSession() {
2568
+ async teardownDesyncedSession(context) {
2569
+ if (!this.isSessionWorkCurrent(context)) return;
1586
2570
  warn("ConnectionManager", "AEAD stream desynced beyond recovery; terminating session");
1587
- await this.socketClient.closeChannel();
1588
- this.handleSessionTerminated();
2571
+ let relayRetired = false;
2572
+ try {
2573
+ relayRetired = await context.socketClient.closeChannel();
2574
+ } catch (err) {
2575
+ error("ConnectionManager", "Failed to tombstone desynced channel:", err);
2576
+ }
2577
+ if (!this.isSessionWorkCurrent(context)) return;
2578
+ this.handleSessionTerminated(relayRetired);
2579
+ }
2580
+ async teardownPersistenceFailedSession(cause, context = this.captureSessionWorkContext(), reason = "AEAD counter persistence failed; terminating session before further use") {
2581
+ if (!context || !this.isSessionWorkCurrent(context)) return;
2582
+ error("ConnectionManager", `${reason}:`, cause);
2583
+ let relayRetired = false;
2584
+ try {
2585
+ relayRetired = await context.socketClient.closeChannel();
2586
+ } catch (err) {
2587
+ error("ConnectionManager", "Failed to tombstone unusable channel:", err);
2588
+ }
2589
+ if (!this.isSessionWorkCurrent(context)) return;
2590
+ this.handleSessionTerminated(relayRetired);
1589
2591
  }
1590
- async handleSynAck(msg) {
1591
- if (!this.keyExchange) return;
2592
+ async handleSynAck(msg, context) {
2593
+ if (!this.isSessionWorkCurrent(context)) return;
1592
2594
  this.setStatus("key_exchange" /* KEY_EXCHANGE */);
1593
2595
  let response;
1594
2596
  try {
1595
- response = await this.keyExchange.onSynAck(cidFromString(this.channelId), msg);
2597
+ response = await context.keyExchange.onSynAck(cidFromString(context.channelId), msg);
1596
2598
  } catch (err) {
2599
+ if (!this.isSessionWorkCurrent(context)) return;
1597
2600
  const e = err instanceof Error ? err : new Error(String(err));
1598
2601
  error("ConnectionManager", "SYNACK processing failed:", e);
1599
2602
  this.emit("error", e);
2603
+ let relayRetired = false;
2604
+ try {
2605
+ relayRetired = await context.socketClient.closeChannel();
2606
+ } catch (closeError) {
2607
+ error("ConnectionManager", "Failed to tombstone rejected handshake:", closeError);
2608
+ }
2609
+ if (this.isSessionWorkCurrent(context)) this.handleSessionTerminated(relayRetired);
1600
2610
  return;
1601
2611
  }
2612
+ if (!this.isSessionWorkCurrent(context)) return;
1602
2613
  if (response) {
1603
- this.sendPlaintext(response);
2614
+ try {
2615
+ await this.sendPlaintext(response, context);
2616
+ this.assertSessionWorkCurrent(context);
2617
+ context.keyExchange.confirmOriginatorAckDelivered();
2618
+ } catch (err) {
2619
+ if (!this.isSessionWorkCurrent(context)) return;
2620
+ const error2 = err instanceof Error ? err : new Error(String(err));
2621
+ error("ConnectionManager", "ACK delivery failed; retiring provisional session:", error2);
2622
+ this.emit("error", error2);
2623
+ let relayRetired = false;
2624
+ try {
2625
+ relayRetired = await context.socketClient.closeChannel();
2626
+ } catch (closeError) {
2627
+ error("ConnectionManager", "Failed to tombstone ambiguous handshake:", closeError);
2628
+ }
2629
+ if (this.isSessionWorkCurrent(context)) this.handleSessionTerminated(relayRetired);
2630
+ }
1604
2631
  return;
1605
2632
  }
1606
- const cachedAck = this.keyExchange.getLastAck();
1607
- if (this.keyExchange.areKeysExchanged() && cachedAck) {
2633
+ const cachedAck = context.keyExchange.getLastAck();
2634
+ if (context.keyExchange.areKeysExchanged() && cachedAck) {
1608
2635
  log("ConnectionManager", "Duplicate SYNACK after handshake; re-sending cached ACK");
1609
- this.sendPlaintext(cachedAck);
2636
+ try {
2637
+ await this.sendPlaintext(cachedAck, context);
2638
+ } catch (err) {
2639
+ error("ConnectionManager", "Failed to re-send cached ACK:", err);
2640
+ }
1610
2641
  }
1611
2642
  }
1612
- handleDecryptedMessage(msg) {
2643
+ async handleDecryptedMessage(msg, context) {
2644
+ if (!this.isSessionWorkCurrent(context)) return;
1613
2645
  this.clearUnresponsiveTimer();
1614
2646
  const type = typeof msg.type === "string" ? msg.type : "";
1615
2647
  switch (MESSAGE_TYPE_BY_VALUE[type]) {
1616
2648
  case "wallet_info" /* WALLET_INFO */: {
1617
- const nextAccounts = isStringArray(msg.accounts) ? msg.accounts : [];
1618
- const nextChainId = typeof msg.chainId === "string" && msg.chainId ? msg.chainId : this.chainId;
2649
+ if (!isCurrentQrlAddressArray(msg.accounts)) {
2650
+ warn("ConnectionManager", "Dropping wallet info with malformed account addresses");
2651
+ break;
2652
+ }
2653
+ const reportedAccounts = [...msg.accounts];
2654
+ const nextAccounts = this.connectedAccounts.length > 0 && this.areArraysEqual(this.connectedAccounts, reportedAccounts) ? this.connectedAccounts : [];
2655
+ let nextChainId = this.chainId;
2656
+ if (msg.chainId !== void 0) {
2657
+ const parsedChainId = canonicalChainId(msg.chainId);
2658
+ if (!parsedChainId || parsedChainId !== msg.chainId) {
2659
+ warn("ConnectionManager", "Dropping wallet info with malformed chain id");
2660
+ break;
2661
+ }
2662
+ nextChainId = parsedChainId;
2663
+ }
1619
2664
  const accountsChanged = !this.areArraysEqual(this.connectedAccounts, nextAccounts);
1620
2665
  const chainChanged = this.chainId !== nextChainId;
1621
- this.connectedAccounts = nextAccounts;
2666
+ this.connectedAccounts = [...nextAccounts];
1622
2667
  this.chainId = nextChainId;
1623
- void this.persistSession();
2668
+ void this.persistSession(context).catch((err) => {
2669
+ void this.teardownPersistenceFailedSession(err, context);
2670
+ });
1624
2671
  this.emit("wallet_info", {
1625
- accounts: this.connectedAccounts,
2672
+ accounts: [...this.connectedAccounts],
1626
2673
  chainId: this.chainId
1627
2674
  });
1628
- if (accountsChanged) this.emit("accounts_changed", this.connectedAccounts);
2675
+ if (accountsChanged) this.emit("accounts_changed", [...this.connectedAccounts]);
1629
2676
  if (chainChanged) this.emit("chain_changed", this.chainId);
1630
2677
  break;
1631
2678
  }
@@ -1640,22 +2687,29 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1640
2687
  }
1641
2688
  case "terminate" /* TERMINATE */: {
1642
2689
  log("ConnectionManager", "Received terminate from wallet");
1643
- this.emit("session_terminated");
1644
- void this.disconnect();
2690
+ let relayRetired = false;
2691
+ try {
2692
+ relayRetired = await context.socketClient.closeChannel();
2693
+ } catch (err) {
2694
+ error("ConnectionManager", "Failed to tombstone terminated channel:", err);
2695
+ }
2696
+ if (this.isSessionWorkCurrent(context)) this.handleSessionTerminated(relayRetired);
1645
2697
  break;
1646
2698
  }
1647
2699
  default:
1648
2700
  log("ConnectionManager", `Unhandled message type: ${type}`);
1649
2701
  }
1650
2702
  }
1651
- sendPlaintext(message) {
1652
- this.socketClient.sendMessage({
1653
- id: this.channelId,
2703
+ async sendPlaintext(message, context = this.captureSessionWorkContext()) {
2704
+ if (!context || !this.isSessionWorkCurrent(context)) {
2705
+ throw new Error("Cannot send plaintext for a retired session");
2706
+ }
2707
+ await context.socketClient.sendMessage({
2708
+ id: context.channelId,
1654
2709
  clientType: "dapp",
1655
2710
  message
1656
- }).catch((err) => {
1657
- error("ConnectionManager", "Failed to send plaintext:", err);
1658
2711
  });
2712
+ this.assertSessionWorkCurrent(context);
1659
2713
  }
1660
2714
  /**
1661
2715
  * Serialize every encrypt+persist+send on a single outbound queue.
@@ -1666,24 +2720,53 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1666
2720
  * already prevented by the synchronous seq reservation in KeyExchange.)
1667
2721
  */
1668
2722
  sendEncrypted(message) {
1669
- const task = this.outboundQueue.then(() => this.sendEncryptedNow(message));
2723
+ const context = this.captureSessionWorkContext();
2724
+ if (!context) return Promise.reject(new Error("sendEncrypted: not connected"));
2725
+ const task = this.outboundQueue.then(() => this.sendEncryptedNow(message, context));
1670
2726
  this.outboundQueue = task.then(
1671
2727
  () => void 0,
1672
2728
  () => void 0
1673
2729
  );
1674
2730
  return task;
1675
2731
  }
1676
- async sendEncryptedNow(message) {
1677
- if (!this.keyExchange?.areKeysExchanged()) {
2732
+ async sendEncryptedNow(message, context) {
2733
+ if (!context.keyExchange.areKeysExchanged()) {
1678
2734
  throw new Error("sendEncrypted: not connected");
1679
2735
  }
1680
- const encrypted = await this.keyExchange.encryptMessage(JSON.stringify(message));
1681
- await this.persistSession();
1682
- await this.socketClient.sendMessage({
1683
- id: this.channelId,
1684
- clientType: "dapp",
1685
- message: encrypted
1686
- });
2736
+ this.assertSessionWorkCurrent(context);
2737
+ let encrypted;
2738
+ try {
2739
+ encrypted = await context.keyExchange.encryptMessage(JSON.stringify(message));
2740
+ } catch (err) {
2741
+ await this.teardownPersistenceFailedSession(
2742
+ err,
2743
+ context,
2744
+ "Message encryption failed after counter reservation; terminating session"
2745
+ );
2746
+ throw err;
2747
+ }
2748
+ this.assertSessionWorkCurrent(context);
2749
+ try {
2750
+ await this.persistSession(context);
2751
+ } catch (err) {
2752
+ await this.teardownPersistenceFailedSession(err, context);
2753
+ throw err;
2754
+ }
2755
+ this.assertSessionWorkCurrent(context);
2756
+ try {
2757
+ await context.socketClient.sendMessage({
2758
+ id: context.channelId,
2759
+ clientType: "dapp",
2760
+ message: encrypted
2761
+ });
2762
+ } catch (err) {
2763
+ await this.teardownPersistenceFailedSession(
2764
+ err,
2765
+ context,
2766
+ "Relay send outcome unknown; terminating session before further use"
2767
+ );
2768
+ throw err;
2769
+ }
1687
2770
  }
1688
2771
  startUnresponsiveTimer() {
1689
2772
  this.clearUnresponsiveTimer();
@@ -1713,40 +2796,93 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1713
2796
  return true;
1714
2797
  }
1715
2798
  // ── Persistence ────────────────────────────────────────────
1716
- async persistSession() {
1717
- if (typeof localStorage === "undefined") return;
1718
- if (!this.keyExchange) return;
1719
- const persistedKex = await this.keyExchange.exportPersisted();
1720
- if (!persistedKex) return;
1721
- const session = {
1722
- version: 3,
2799
+ invalidateSessionWork() {
2800
+ this.sessionGeneration++;
2801
+ this.keyExchange?.reset();
2802
+ this.messageQueue = Promise.resolve();
2803
+ this.outboundQueue = Promise.resolve();
2804
+ this.persistenceQueue = Promise.resolve();
2805
+ }
2806
+ captureSessionWorkContext() {
2807
+ if (!this.keyExchange) return null;
2808
+ return {
2809
+ generation: this.sessionGeneration,
1723
2810
  channelId: this.channelId,
1724
- keyExchange: persistedKex,
1725
- dappMetadata: this.dappMetadata,
1726
- connectedAccounts: this.connectedAccounts,
1727
- chainId: this.chainId,
1728
- createdAt: this.pendingRestore?.createdAt ?? Date.now(),
2811
+ keyExchange: this.keyExchange,
2812
+ socketClient: this.socketClient
2813
+ };
2814
+ }
2815
+ isSessionWorkCurrent(context) {
2816
+ return context.generation === this.sessionGeneration && context.channelId === this.channelId && context.keyExchange === this.keyExchange && context.socketClient === this.socketClient;
2817
+ }
2818
+ assertSessionWorkCurrent(context) {
2819
+ if (!this.isSessionWorkCurrent(context)) {
2820
+ throw new Error("QRL Connect session changed while encrypted work was pending");
2821
+ }
2822
+ }
2823
+ async acquireSessionOwnership() {
2824
+ if (!this.persistenceEnabled) return true;
2825
+ if (!this.sessionOwnership) return false;
2826
+ return this.sessionOwnership.acquire();
2827
+ }
2828
+ async releaseSessionOwnership() {
2829
+ await this.sessionOwnership?.release();
2830
+ }
2831
+ persistSession(context) {
2832
+ const task = this.persistenceQueue.then(() => this.persistSessionNow(context));
2833
+ this.persistenceQueue = task.then(
2834
+ () => void 0,
2835
+ () => void 0
2836
+ );
2837
+ return task;
2838
+ }
2839
+ async persistSessionNow(context) {
2840
+ this.assertSessionWorkCurrent(context);
2841
+ if (!this.persistenceEnabled || !this.storage) return;
2842
+ if (!this.sessionOwnership?.isOwned()) {
2843
+ throw new Error("Cannot persist AEAD counters without browser-tab ownership");
2844
+ }
2845
+ if (this.persistenceBroken) {
2846
+ throw new Error("AEAD counter persistence is unavailable");
2847
+ }
2848
+ const persistedKex = await context.keyExchange.exportPersisted();
2849
+ if (!persistedKex) throw new Error("Cannot export established AEAD session");
2850
+ this.assertSessionWorkCurrent(context);
2851
+ if (!this.sessionOwnership?.isOwned()) {
2852
+ throw new Error("Browser-tab ownership changed while persisting AEAD counters");
2853
+ }
2854
+ const session = {
2855
+ version: 5,
2856
+ channelId: context.channelId,
2857
+ keyExchange: persistedKex,
2858
+ dappMetadata: this.dappMetadata,
2859
+ connectedAccounts: this.connectedAccounts,
2860
+ chainId: this.chainId,
2861
+ createdAt: this.sessionCreatedAt,
1729
2862
  lastActivity: Date.now()
1730
2863
  };
1731
2864
  try {
1732
- localStorage.setItem(this.storageKey, JSON.stringify(session));
2865
+ this.storage.setItem(this.storageKey, JSON.stringify(session));
1733
2866
  } catch (err) {
1734
- warn("ConnectionManager", "localStorage.setItem failed:", err);
2867
+ this.persistenceBroken = true;
2868
+ this.removeStoredSession();
2869
+ error("ConnectionManager", "Failed to persist AEAD counters:", err);
2870
+ throw new Error("Failed to persist AEAD counters");
1735
2871
  }
1736
2872
  }
1737
2873
  readStoredSession() {
1738
- if (typeof localStorage === "undefined") return null;
2874
+ if (!this.persistenceEnabled || !this.storage) return null;
1739
2875
  try {
1740
- const raw = localStorage.getItem(this.storageKey);
2876
+ const raw = this.storage.getItem(this.storageKey);
1741
2877
  if (!raw) return null;
1742
2878
  const session = parseStoredSession(raw);
1743
2879
  if (!session) {
1744
2880
  log("ConnectionManager", "Dropping legacy or malformed session from storage");
1745
- localStorage.removeItem(this.storageKey);
2881
+ this.removeStoredSession();
1746
2882
  return null;
1747
2883
  }
1748
2884
  if (Date.now() - session.createdAt > SESSION_TTL_MS) {
1749
- localStorage.removeItem(this.storageKey);
2885
+ this.removeStoredSession();
1750
2886
  return null;
1751
2887
  }
1752
2888
  return session;
@@ -1754,9 +2890,34 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1754
2890
  return null;
1755
2891
  }
1756
2892
  }
2893
+ removeStoredSession() {
2894
+ if (!this.storage) return true;
2895
+ try {
2896
+ this.storage.removeItem(this.storageKey);
2897
+ return true;
2898
+ } catch (removeError) {
2899
+ try {
2900
+ this.storage.setItem(this.storageKey, '{"version":0}');
2901
+ warn("ConnectionManager", "localStorage.removeItem failed; invalidated record instead");
2902
+ return true;
2903
+ } catch (overwriteError) {
2904
+ warn(
2905
+ "ConnectionManager",
2906
+ "Unable to invalidate persisted session:",
2907
+ removeError,
2908
+ overwriteError
2909
+ );
2910
+ return false;
2911
+ }
2912
+ }
2913
+ }
2914
+ removeStoredSessionOrThrow() {
2915
+ if (!this.removeStoredSession()) {
2916
+ throw new Error("Unable to clear the previous persisted AEAD session");
2917
+ }
2918
+ }
1757
2919
  clearSession() {
1758
- if (typeof localStorage === "undefined") return;
1759
- localStorage.removeItem(this.storageKey);
2920
+ return this.removeStoredSession();
1760
2921
  }
1761
2922
  };
1762
2923
  _ConnectionManager.MAX_RECONNECT_FAILURES = 5;
@@ -1768,864 +2929,1405 @@ _ConnectionManager.MAX_RECONNECT_FAILURES = 5;
1768
2929
  _ConnectionManager.MAX_DECRYPT_FAILURES = 2;
1769
2930
  var ConnectionManager = _ConnectionManager;
1770
2931
 
1771
- // src/utils/platform.ts
1772
- function isMobileBrowser() {
1773
- if (typeof navigator === "undefined") return false;
1774
- return /Android|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop/i.test(navigator.userAgent);
1775
- }
1776
- function getAppStoreUrl() {
1777
- if (typeof navigator !== "undefined" && /android/i.test(navigator.userAgent)) {
1778
- return "https://play.google.com/store/apps/details?id=com.chiefdg.myqrlwallet";
2932
+ // src/signing/ctx.ts
2933
+ var SCHEME_VERSION_MSG = "QRL-SIGN-MSG-v1";
2934
+ var SCHEME_VERSION_TYPED = "QRL-SIGN-TYPED-v1";
2935
+ var SCHEME_TAG_MSG = new TextEncoder().encode(SCHEME_VERSION_MSG);
2936
+ var SCHEME_TAG_TYPED = new TextEncoder().encode(SCHEME_VERSION_TYPED);
2937
+ var DIGEST_LEN = 64;
2938
+
2939
+ // src/signing/bytes.ts
2940
+ function hexToBytes(hex) {
2941
+ if (typeof hex !== "string") throw new Error("hex must be a string");
2942
+ if (!/^0x([0-9a-fA-F]{2})*$/.test(hex)) {
2943
+ throw new Error("hex must be 0x-prefixed even-length");
1779
2944
  }
1780
- return "https://apps.apple.com/app/myqrlwallet/id6742219498";
2945
+ const n = (hex.length - 2) / 2;
2946
+ const out = new Uint8Array(n);
2947
+ for (let i = 0; i < n; i++) {
2948
+ out[i] = parseInt(hex.slice(2 + i * 2, 4 + i * 2), 16);
2949
+ }
2950
+ return out;
1781
2951
  }
1782
- function attemptWalletRedirect(uri, timeoutMs = 1800) {
1783
- if (typeof window === "undefined" || typeof document === "undefined") {
1784
- return Promise.resolve(false);
2952
+ function bytesToHex(bytes) {
2953
+ if (!(bytes instanceof Uint8Array)) {
2954
+ throw new Error("bytes must be a Uint8Array");
1785
2955
  }
1786
- if (!uri.trim().toLowerCase().startsWith("qrlconnect:")) {
1787
- return Promise.resolve(false);
2956
+ let s = "0x";
2957
+ for (const v of bytes) s += v.toString(16).padStart(2, "0");
2958
+ return s;
2959
+ }
2960
+ function concatBytes(...parts) {
2961
+ return concatBytesArr(parts);
2962
+ }
2963
+ function concatBytesArr(parts) {
2964
+ let total = 0;
2965
+ for (const p of parts) total += p.length;
2966
+ const out = new Uint8Array(total);
2967
+ let off = 0;
2968
+ for (const p of parts) {
2969
+ out.set(p, off);
2970
+ off += p.length;
1788
2971
  }
1789
- return new Promise((resolve) => {
1790
- let settled = false;
1791
- const finish = (opened) => {
1792
- if (settled) return;
1793
- settled = true;
1794
- document.removeEventListener("visibilitychange", onVisibility);
1795
- window.removeEventListener("pagehide", onHide);
1796
- window.clearTimeout(timer);
1797
- resolve(opened);
1798
- };
1799
- const onVisibility = () => {
1800
- if (document.visibilityState === "hidden") finish(true);
1801
- };
1802
- const onHide = () => {
1803
- finish(true);
1804
- };
1805
- document.addEventListener("visibilitychange", onVisibility);
1806
- window.addEventListener("pagehide", onHide);
1807
- const timer = window.setTimeout(() => {
1808
- finish(false);
1809
- }, timeoutMs);
1810
- try {
1811
- window.location.href = uri;
1812
- } catch {
1813
- finish(false);
1814
- }
1815
- });
2972
+ return out;
1816
2973
  }
1817
2974
 
1818
- // src/QRLConnectProvider.ts
1819
- var QRL_CONNECT_PROVIDER_INFO = {
1820
- name: "MyQRLWallet",
1821
- rdns: "com.qrlwallet.connect",
1822
- icon: "data:image/png;base64,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"
1823
- };
1824
- var EIP6963_ANNOUNCE_EVENT = "eip6963:announceProvider";
1825
- var EIP6963_REQUEST_EVENT = "eip6963:requestProvider";
1826
- function isRecordObj(v) {
1827
- return typeof v === "object" && v !== null && !Array.isArray(v);
2975
+ // src/signing/typedData.ts
2976
+ var SLOT = 32;
2977
+ var TYPED_DATA_LIMITS = Object.freeze({
2978
+ maxTypes: 32,
2979
+ maxFieldsPerType: 32,
2980
+ maxTotalFields: 256,
2981
+ maxTypeGraphDepth: 12,
2982
+ maxArrayNesting: 12,
2983
+ maxArrayLength: 256,
2984
+ maxEncodedValues: 2048,
2985
+ maxDynamicBytes: 16 * 1024,
2986
+ maxIdentifierLength: 64,
2987
+ maxFieldTypeLength: 128
2988
+ });
2989
+ function isMessageObject(value) {
2990
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1828
2991
  }
1829
- function parseInflightRecord(v) {
1830
- if (!isRecordObj(v)) return null;
1831
- const { id, method, ts } = v;
1832
- if (typeof id !== "string" && typeof id !== "number") return null;
1833
- if (typeof method !== "string" || typeof ts !== "number") return null;
1834
- return { id, method, ts };
2992
+ function hasExactOwnKeys2(value, expected) {
2993
+ const keys = Object.keys(value);
2994
+ return keys.length === expected.length && expected.every((key) => keys.includes(key));
1835
2995
  }
1836
- var QRLConnectProvider = class extends EventEmitter4 {
1837
- constructor(options) {
1838
- super();
1839
- this.pendingRequests = /* @__PURE__ */ new Map();
1840
- this.eip6963Detail = null;
1841
- this.eip6963RequestListener = null;
1842
- this.resumeListener = null;
1843
- this.resumeDebounce = null;
1844
- this.orphanedRequests = /* @__PURE__ */ new Map();
1845
- // Random per-instance prefix keeps request ids unique across page loads.
1846
- // A bare counter restarts at 1 on reload, and the relay buffers messages
1847
- // for 5 minutes, so a stale buffered response could otherwise be matched
1848
- // to a fresh request that drew the same small id.
1849
- this.requestIdPrefix = randomUuid().slice(0, 8);
1850
- this.requestCounter = 0;
1851
- this.isQRLConnect = true;
1852
- this.options = options;
1853
- this.inflightKey = `${options.storageKey ?? `${STORAGE_KEY_PREFIX}:session`}:inflight`;
1854
- for (const rec of this.readInflight()) {
1855
- this.orphanedRequests.set(rec.id, { method: rec.method });
2996
+ function isTypedField(value) {
2997
+ return isMessageObject(value) && hasExactOwnKeys2(value, ["name", "type"]) && typeof value.name === "string" && typeof value.type === "string";
2998
+ }
2999
+ function isTypeMap(value) {
3000
+ if (!isMessageObject(value)) return false;
3001
+ let typeCount = 0;
3002
+ for (const name in value) {
3003
+ if (!Object.prototype.hasOwnProperty.call(value, name)) continue;
3004
+ typeCount++;
3005
+ if (typeCount > TYPED_DATA_LIMITS.maxTypes) return false;
3006
+ const def = value[name];
3007
+ if (!Array.isArray(def) || def.length > TYPED_DATA_LIMITS.maxFieldsPerType || !def.every(isTypedField)) {
3008
+ return false;
1856
3009
  }
1857
- if (options.debug) {
1858
- setDebug(true);
3010
+ }
3011
+ return true;
3012
+ }
3013
+ function parsePayload(payload) {
3014
+ if (!isMessageObject(payload)) throw new Error("invalid typed data payload");
3015
+ if (!hasExactOwnKeys2(payload, ["types", "primaryType", "domain", "message"])) {
3016
+ throw new Error("typed data payload contains unknown top-level fields");
3017
+ }
3018
+ const { types, primaryType, domain, message } = payload;
3019
+ if (!isTypeMap(types)) throw new Error("typed data types must be a struct map");
3020
+ if (typeof primaryType !== "string") throw new Error("typed data primaryType must be a string");
3021
+ if (!isMessageObject(domain)) throw new Error("typed data domain must be an object");
3022
+ if (!isMessageObject(message)) throw new Error("typed data message must be an object");
3023
+ return { types, primaryType, domain, message };
3024
+ }
3025
+ var ATOMIC_RE = /^(?:(address|bool|string|bytes)|(u?int)(\d+)|bytes(\d+)|(.+?)\[(\d*)\])$/;
3026
+ var IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
3027
+ var UNSAFE_OBJECT_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
3028
+ function consumeValue(budget) {
3029
+ budget.remainingValues--;
3030
+ if (budget.remainingValues < 0) throw new Error("typed data contains too many encoded values");
3031
+ }
3032
+ function assertIdentifier(value, label) {
3033
+ if (!IDENTIFIER_RE.test(value) || value.length > TYPED_DATA_LIMITS.maxIdentifierLength || UNSAFE_OBJECT_KEYS.has(value)) {
3034
+ throw new Error(`invalid ${label}: ${value}`);
3035
+ }
3036
+ }
3037
+ function parseFieldType(type, types, depth = 0) {
3038
+ if (depth > TYPED_DATA_LIMITS.maxArrayNesting) {
3039
+ throw new Error(`type nesting too deep: ${type}`);
3040
+ }
3041
+ if (type.length > TYPED_DATA_LIMITS.maxFieldTypeLength) {
3042
+ throw new Error(`field type too long: ${type}`);
3043
+ }
3044
+ const m = ATOMIC_RE.exec(type);
3045
+ if (m) {
3046
+ const [, atomic, intKind, intWidthStr, bytesWidthStr, innerType, sizeStr] = m;
3047
+ if (atomic === "address" || atomic === "bool" || atomic === "string" || atomic === "bytes") {
3048
+ return { kind: atomic };
1859
3049
  }
1860
- this.connectionManager = new ConnectionManager({
1861
- dappMetadata: options.dappMetadata,
1862
- relayUrl: options.relayUrl,
1863
- chainId: options.chainId,
1864
- storageKey: options.storageKey
1865
- });
1866
- this.setupConnectionListeners();
1867
- if (options.autoReconnect !== false) {
1868
- void this.connectionManager.reconnect();
3050
+ if (intKind) {
3051
+ const width = Number(intWidthStr);
3052
+ if (!Number.isInteger(width) || width < 8 || width > 256 || width % 8 !== 0) {
3053
+ throw new Error(`invalid int width: ${type}`);
3054
+ }
3055
+ return { kind: intKind === "uint" ? "uintN" : "intN", width };
1869
3056
  }
1870
- this.setupResumeListeners();
1871
- if (options.announceProvider !== false) {
1872
- this.startEip6963Announce();
3057
+ if (bytesWidthStr) {
3058
+ const width = Number(bytesWidthStr);
3059
+ if (!Number.isInteger(width) || width < 1 || width > 32) {
3060
+ throw new Error(`invalid bytesN width: ${type}`);
3061
+ }
3062
+ return { kind: "bytesN", width };
1873
3063
  }
1874
- }
1875
- startEip6963Announce() {
1876
- if (typeof window === "undefined" || typeof CustomEvent === "undefined") {
1877
- return;
3064
+ if (innerType !== void 0) {
3065
+ parseFieldType(innerType, types, depth + 1);
3066
+ if (sizeStr) {
3067
+ const size = Number(sizeStr);
3068
+ if (!Number.isSafeInteger(size) || size <= 0 || size > TYPED_DATA_LIMITS.maxArrayLength) {
3069
+ throw new Error(`invalid array size: ${type}`);
3070
+ }
3071
+ return { kind: "array", inner: innerType, size };
3072
+ }
3073
+ return { kind: "array", inner: innerType };
1878
3074
  }
1879
- const overrides = this.options.providerInfo ?? {};
1880
- this.eip6963Detail = Object.freeze({
1881
- info: Object.freeze({
1882
- uuid: randomUuid(),
1883
- name: overrides.name ?? QRL_CONNECT_PROVIDER_INFO.name,
1884
- icon: overrides.icon ?? QRL_CONNECT_PROVIDER_INFO.icon,
1885
- rdns: overrides.rdns ?? QRL_CONNECT_PROVIDER_INFO.rdns
1886
- }),
1887
- provider: this
1888
- });
1889
- const announce = () => {
1890
- if (!this.eip6963Detail) return;
1891
- window.dispatchEvent(new CustomEvent(EIP6963_ANNOUNCE_EVENT, { detail: this.eip6963Detail }));
1892
- };
1893
- this.eip6963RequestListener = announce;
1894
- window.addEventListener(EIP6963_REQUEST_EVENT, announce);
1895
- announce();
1896
3075
  }
1897
- /**
1898
- * Re-open the relay socket when the dApp tab regains focus or the network
1899
- * comes back. Debounced because visibilitychange + online can fire together.
1900
- * No-op when already connected (ConnectionManager.resume guards that).
1901
- */
1902
- setupResumeListeners() {
1903
- if (typeof window === "undefined" || typeof document === "undefined") return;
1904
- if (this.resumeListener) return;
1905
- const resume = () => {
1906
- if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
1907
- if (this.resumeDebounce) clearTimeout(this.resumeDebounce);
1908
- this.resumeDebounce = setTimeout(() => {
1909
- this.resumeDebounce = null;
1910
- this.connectionManager.resume();
1911
- }, 300);
1912
- };
1913
- this.resumeListener = resume;
1914
- document.addEventListener("visibilitychange", resume);
1915
- window.addEventListener("online", resume);
1916
- window.addEventListener("pageshow", resume);
3076
+ if (Object.prototype.hasOwnProperty.call(types, type)) {
3077
+ return { kind: "ref", name: type };
1917
3078
  }
1918
- /**
1919
- * Remove the foreground/online resume listeners. Called on disconnect so a
1920
- * torn-down session can't be silently revived by a later tab focus; the
1921
- * listeners are re-armed by getConnectionURI()/newConnection() if the same
1922
- * provider is re-paired. Safe in any env.
1923
- */
1924
- teardownResumeListeners() {
1925
- if (typeof window !== "undefined" && typeof document !== "undefined" && this.resumeListener) {
1926
- document.removeEventListener("visibilitychange", this.resumeListener);
1927
- window.removeEventListener("online", this.resumeListener);
1928
- window.removeEventListener("pageshow", this.resumeListener);
1929
- }
1930
- this.resumeListener = null;
1931
- if (this.resumeDebounce) {
1932
- clearTimeout(this.resumeDebounce);
1933
- this.resumeDebounce = null;
1934
- }
3079
+ throw new Error(`unknown type: ${type}`);
3080
+ }
3081
+ function isReservedAtomicTypeName(name) {
3082
+ if (/^(?:address|bool|string|bytes)$/.test(name)) return true;
3083
+ const intMatch = /^(?:u?int)(\d+)$/.exec(name);
3084
+ if (intMatch) {
3085
+ const width = Number(intMatch[1]);
3086
+ return Number.isInteger(width) && width >= 8 && width <= 256 && width % 8 === 0;
3087
+ }
3088
+ const bytesMatch = /^bytes(\d+)$/.exec(name);
3089
+ if (bytesMatch) {
3090
+ const width = Number(bytesMatch[1]);
3091
+ return Number.isInteger(width) && width >= 1 && width <= 32;
3092
+ }
3093
+ return false;
3094
+ }
3095
+ function baseTypeName(type) {
3096
+ return type.replace(/(\[\d*\])+$/, "");
3097
+ }
3098
+ function collectDependencies(primary, types) {
3099
+ if (!Object.prototype.hasOwnProperty.call(types, primary)) {
3100
+ throw new Error(`unknown primary type: ${primary}`);
1935
3101
  }
1936
- /**
1937
- * Stop announcing this provider over EIP-6963. Safe to call from any env.
1938
- */
1939
- stopEip6963Announce() {
1940
- if (typeof window !== "undefined" && this.eip6963RequestListener) {
1941
- window.removeEventListener(EIP6963_REQUEST_EVENT, this.eip6963RequestListener);
3102
+ const visited = /* @__PURE__ */ new Set();
3103
+ const visit = (name, path) => {
3104
+ if (path.length > TYPED_DATA_LIMITS.maxTypeGraphDepth) {
3105
+ throw new Error(`type graph nesting too deep: ${[...path, name].join(" -> ")}`);
1942
3106
  }
1943
- this.eip6963RequestListener = null;
1944
- this.eip6963Detail = null;
1945
- }
1946
- setupConnectionListeners() {
1947
- this.connectionManager.on("status_changed", (status) => {
1948
- log("Provider", `Connection status: ${status}`);
1949
- this.emit("statusChanged", status);
1950
- if (status === "connected" /* CONNECTED */) {
1951
- this.emit("connect", { chainId: this.connectionManager.getChainId() });
1952
- }
1953
- if (status === "disconnected" /* DISCONNECTED */) {
1954
- this.emit("disconnect", {
1955
- code: 4900,
1956
- message: "Disconnected from QRL Wallet"
1957
- });
1958
- }
1959
- });
1960
- this.connectionManager.on("accounts_changed", (accounts) => {
1961
- this.emit("accountsChanged", accounts);
1962
- });
1963
- this.connectionManager.on("chain_changed", (chainId) => {
1964
- this.emit("chainChanged", chainId);
1965
- });
1966
- this.connectionManager.on("jsonrpc_response", (response) => {
1967
- const pending = this.pendingRequests.get(response.id);
1968
- if (!pending) {
1969
- const orphan = this.orphanedRequests.get(response.id);
1970
- if (orphan) {
1971
- this.settleInflight(response.id);
1972
- this.emit("late_response", {
1973
- id: response.id,
1974
- method: orphan.method,
1975
- ...response.error ? { error: response.error } : { result: response.result }
1976
- });
1977
- return;
1978
- }
1979
- warn("Provider", `No pending request for id ${response.id}`);
1980
- return;
1981
- }
1982
- this.pendingRequests.delete(response.id);
1983
- if (response.error) {
1984
- pending.reject(new Error(response.error.message || "Request failed"));
1985
- } else {
1986
- pending.resolve(response.result);
3107
+ if (path.includes(name)) {
3108
+ throw new Error(`cyclic type reference: ${[...path, name].join(" -> ")}`);
3109
+ }
3110
+ if (visited.has(name)) return;
3111
+ visited.add(name);
3112
+ const def = types[name];
3113
+ if (!def) throw new Error(`unknown type: ${name}`);
3114
+ for (const f of def) {
3115
+ const base = baseTypeName(f.type);
3116
+ if (Object.prototype.hasOwnProperty.call(types, base)) {
3117
+ visit(base, [...path, name]);
1987
3118
  }
1988
- });
1989
- this.connectionManager.on("wallet_info", (info) => {
1990
- for (const [id, pending] of this.pendingRequests) {
1991
- if (pending.method === "qrl_requestAccounts" || pending.method === "qrl_accounts") {
1992
- pending.resolve(info.accounts);
1993
- this.pendingRequests.delete(id);
1994
- }
3119
+ parseFieldType(f.type, types);
3120
+ }
3121
+ };
3122
+ visit(primary, []);
3123
+ return visited;
3124
+ }
3125
+ function validateTypeMap(types) {
3126
+ const entries = Object.entries(types);
3127
+ if (entries.length === 0 || entries.length > TYPED_DATA_LIMITS.maxTypes) {
3128
+ throw new Error(`typed data must contain 1-${TYPED_DATA_LIMITS.maxTypes} struct types`);
3129
+ }
3130
+ let totalFields = 0;
3131
+ for (const [name, def] of entries) {
3132
+ assertIdentifier(name, "struct name");
3133
+ if (name !== "QRLDomain" && isReservedAtomicTypeName(name)) {
3134
+ throw new Error(`struct name is reserved by an atomic type: ${name}`);
3135
+ }
3136
+ const defUnknown = def;
3137
+ if (!Array.isArray(defUnknown) || def.length === 0 || def.length > TYPED_DATA_LIMITS.maxFieldsPerType) {
3138
+ throw new Error(`empty or invalid struct: ${name}`);
3139
+ }
3140
+ totalFields += def.length;
3141
+ if (totalFields > TYPED_DATA_LIMITS.maxTotalFields) {
3142
+ throw new Error("typed data contains too many fields");
3143
+ }
3144
+ const seen = /* @__PURE__ */ new Set();
3145
+ for (const f of def) {
3146
+ if (!f || typeof f.name !== "string" || !f.name) {
3147
+ throw new Error(`bad field in ${name}`);
1995
3148
  }
1996
- });
1997
- this.connectionManager.on("error", (err) => {
1998
- warn("Provider", `ConnectionManager error: ${err.message}`);
1999
- this.emit("message", { type: "error", data: err.message });
2000
- });
2001
- this.connectionManager.on("connection_lost", () => {
2002
- for (const [, pending] of this.pendingRequests) {
2003
- pending.reject(new Error("Connection to QRL Wallet lost"));
3149
+ assertIdentifier(f.name, `field name in ${name}`);
3150
+ if (typeof f.type !== "string" || !f.type) {
3151
+ throw new Error(`bad field type in ${name}.${f.name}`);
2004
3152
  }
2005
- this.pendingRequests.clear();
2006
- });
2007
- this.connectionManager.on("session_terminated", () => {
2008
- for (const [, pending] of this.pendingRequests) {
2009
- pending.reject(new Error("Session terminated by wallet"));
3153
+ if (seen.has(f.name)) {
3154
+ throw new Error(`duplicate field "${f.name}" in ${name}`);
2010
3155
  }
2011
- this.pendingRequests.clear();
2012
- this.clearAllInflight();
2013
- });
3156
+ seen.add(f.name);
3157
+ }
2014
3158
  }
2015
- /**
2016
- * Generate a connection URI for QR code display or deep link redirect.
2017
- */
2018
- async getConnectionURI() {
2019
- this.setupResumeListeners();
2020
- return this.connectionManager.getConnectionURI();
3159
+ }
3160
+ function encodeType(primary, types) {
3161
+ validateTypeMap(types);
3162
+ const deps = collectDependencies(primary, types);
3163
+ const others = [...deps].filter((n) => n !== primary).sort();
3164
+ return [primary, ...others].map((name) => {
3165
+ const fields = types[name];
3166
+ if (!fields) throw new Error(`unknown type: ${name}`);
3167
+ const inner = fields.map((f) => `${f.type} ${f.name}`).join(",");
3168
+ return `${name}(${inner})`;
3169
+ }).join("");
3170
+ }
3171
+ function typeHash(primary, types) {
3172
+ return shake256Digest(new TextEncoder().encode(encodeType(primary, types)), DIGEST_LEN);
3173
+ }
3174
+ function parseQAddress(addr) {
3175
+ if (typeof addr !== "string" || !/^Q[0-9a-fA-F]{40}$/.test(addr)) {
3176
+ throw new Error(`invalid Q-address: ${addr}`);
2021
3177
  }
2022
- /**
2023
- * Check if the current browser is mobile.
2024
- */
2025
- isMobile() {
2026
- return isMobileBrowser();
3178
+ return hexToBytes("0x" + addr.slice(1).toLowerCase());
3179
+ }
3180
+ function padLeft32(bytes) {
3181
+ if (bytes.length > SLOT) throw new Error("cannot pad: bytes > 32");
3182
+ const out = new Uint8Array(SLOT);
3183
+ out.set(bytes, SLOT - bytes.length);
3184
+ return out;
3185
+ }
3186
+ function padRight32(bytes) {
3187
+ if (bytes.length > SLOT) throw new Error("cannot pad: bytes > 32");
3188
+ const out = new Uint8Array(SLOT);
3189
+ out.set(bytes, 0);
3190
+ return out;
3191
+ }
3192
+ function bigIntToSlot(value, width, signed) {
3193
+ if (signed) {
3194
+ const limit = 1n << BigInt(width - 1);
3195
+ if (value >= limit || value < -limit) {
3196
+ throw new Error(`int${width} out of range: ${value}`);
3197
+ }
3198
+ } else {
3199
+ if (value < 0n) throw new Error(`uint${width} negative: ${value}`);
3200
+ const limit = 1n << BigInt(width);
3201
+ if (value >= limit) throw new Error(`uint${width} out of range: ${value}`);
2027
3202
  }
2028
- /**
2029
- * Get the app store URL for the QRL Wallet app.
2030
- */
2031
- getAppStoreUrl() {
2032
- return getAppStoreUrl();
3203
+ const slotMask = (1n << 256n) - 1n;
3204
+ const repr = value < 0n ? value + (1n << 256n) & slotMask : value;
3205
+ const out = new Uint8Array(SLOT);
3206
+ let v = repr;
3207
+ for (let i = SLOT - 1; i >= 0; i--) {
3208
+ out[i] = Number(v & 0xffn);
3209
+ v >>= 8n;
2033
3210
  }
2034
- /**
2035
- * EIP-1193 request method.
2036
- */
2037
- async request(args) {
2038
- const { method, params } = args;
2039
- if (method === "qrl_chainId") {
2040
- return this.connectionManager.getChainId();
3211
+ return out;
3212
+ }
3213
+ function parseIntValue(v, typeLabel) {
3214
+ if (typeof v === "bigint") return v;
3215
+ if (typeof v === "string") {
3216
+ if (v.length > 80) throw new Error(`${typeLabel} value is too long`);
3217
+ if (/^-?0x[0-9a-fA-F]+$/i.test(v)) {
3218
+ const isNegative = v.startsWith("-");
3219
+ const abs = BigInt(isNegative ? v.slice(1) : v);
3220
+ return isNegative ? -abs : abs;
2041
3221
  }
2042
- if (method === "qrl_accounts") {
2043
- const accounts = this.connectionManager.getAccounts();
2044
- if (accounts.length > 0) return accounts;
3222
+ if (!/^-?(0|[1-9]\d*)$/.test(v)) {
3223
+ throw new Error(`invalid ${typeLabel} string: ${v}`);
2045
3224
  }
2046
- if (!RESTRICTED_METHODS.has(method) && !UNRESTRICTED_METHODS.has(method)) {
2047
- throw new Error(`Unsupported method: ${method}`);
3225
+ return BigInt(v);
3226
+ }
3227
+ if (typeof v === "number") {
3228
+ if (!Number.isInteger(v) || !Number.isSafeInteger(v)) {
3229
+ throw new Error(`unsafe ${typeLabel} number: ${v}, pass as string`);
2048
3230
  }
2049
- if (this.connectionManager.getStatus() !== "connected" /* CONNECTED */) {
2050
- if (method === "qrl_requestAccounts" && this.connectionManager.isPaired() && this.connectionManager.getAccounts().length > 0) {
2051
- return this.connectionManager.getAccounts();
3231
+ return BigInt(v);
3232
+ }
3233
+ throw new Error(`unsupported ${typeLabel} value: ${typeof v}`);
3234
+ }
3235
+ function encodeFieldWithBudget(type, value, types, budget, depth) {
3236
+ if (depth > TYPED_DATA_LIMITS.maxTypeGraphDepth + TYPED_DATA_LIMITS.maxArrayNesting) {
3237
+ throw new Error(`value nesting too deep: ${type}`);
3238
+ }
3239
+ consumeValue(budget);
3240
+ const parsed = parseFieldType(type, types);
3241
+ switch (parsed.kind) {
3242
+ case "ref":
3243
+ if (!isMessageObject(value)) {
3244
+ throw new Error(`struct field expects object: ${type}`);
2052
3245
  }
2053
- const joined = await this.connectionManager.ensureChannelJoined();
2054
- if (!joined) {
2055
- throw new Error("Not connected to QRL Wallet");
3246
+ return hashStructWithBudget(parsed.name, value, types, budget, depth + 1);
3247
+ case "address":
3248
+ if (typeof value !== "string") {
3249
+ throw new Error(`address field expects string: ${typeof value}`);
3250
+ }
3251
+ return padLeft32(parseQAddress(value));
3252
+ case "bool":
3253
+ if (typeof value !== "boolean") {
3254
+ throw new Error(`bool field expects boolean: ${typeof value}`);
2056
3255
  }
3256
+ return padLeft32(new Uint8Array([value ? 1 : 0]));
3257
+ case "string": {
3258
+ if (typeof value !== "string") {
3259
+ throw new Error(`string field expects string: ${typeof value}`);
3260
+ }
3261
+ if (value.length > TYPED_DATA_LIMITS.maxDynamicBytes) {
3262
+ throw new Error("string field exceeds typed data byte limit");
3263
+ }
3264
+ const encoded = new TextEncoder().encode(value);
3265
+ if (encoded.length > TYPED_DATA_LIMITS.maxDynamicBytes) {
3266
+ throw new Error("string field exceeds typed data byte limit");
3267
+ }
3268
+ return shake256Digest(encoded, DIGEST_LEN);
2057
3269
  }
2058
- const id = `${this.requestIdPrefix}-${++this.requestCounter}`;
2059
- return new Promise((resolve, reject) => {
2060
- const pending = {
2061
- id,
2062
- method,
2063
- params,
2064
- resolve,
2065
- reject,
2066
- timestamp: Date.now()
2067
- };
2068
- this.pendingRequests.set(id, pending);
2069
- const timeout = setTimeout(() => {
2070
- if (this.pendingRequests.has(id)) {
2071
- this.pendingRequests.delete(id);
2072
- reject(new Error(`Request timeout: ${method} (${REQUEST_TIMEOUT_MS}ms)`));
2073
- }
2074
- }, REQUEST_TIMEOUT_MS);
2075
- const originalResolve = pending.resolve;
2076
- const originalReject = pending.reject;
2077
- pending.resolve = (result) => {
2078
- clearTimeout(timeout);
2079
- this.settleInflight(id);
2080
- originalResolve(result);
2081
- };
2082
- pending.reject = (error2) => {
2083
- clearTimeout(timeout);
2084
- this.settleInflight(id);
2085
- originalReject(error2);
2086
- };
2087
- if (RESTRICTED_METHODS.has(method)) {
2088
- this.persistInflight(id, method);
3270
+ case "bytes":
3271
+ if (typeof value !== "string") {
3272
+ throw new Error(`bytes field expects 0x-hex string: ${typeof value}`);
2089
3273
  }
2090
- const sent = this.connectionManager.sendJsonRpc({
2091
- jsonrpc: "2.0",
2092
- id,
2093
- method,
2094
- params
2095
- });
2096
- sent.catch((err) => {
2097
- const failed = this.pendingRequests.get(id);
2098
- if (!failed) return;
2099
- this.pendingRequests.delete(id);
2100
- failed.reject(err instanceof Error ? err : new Error(String(err)));
2101
- });
2102
- if (this.shouldRedirectToWallet(method)) {
2103
- void sent.then(
2104
- () => attemptWalletRedirect(this.walletWakeUri()),
2105
- () => void 0
2106
- );
3274
+ if (value.length > TYPED_DATA_LIMITS.maxDynamicBytes * 2 + 2) {
3275
+ throw new Error("bytes field exceeds typed data byte limit");
2107
3276
  }
2108
- });
3277
+ return shake256Digest(hexToBytes(value), DIGEST_LEN);
3278
+ case "uintN":
3279
+ return bigIntToSlot(parseIntValue(value, `uint${parsed.width}`), parsed.width, false);
3280
+ case "intN":
3281
+ return bigIntToSlot(parseIntValue(value, `int${parsed.width}`), parsed.width, true);
3282
+ case "bytesN": {
3283
+ if (typeof value !== "string") {
3284
+ throw new Error(`bytes${parsed.width} expects 0x-hex string: ${typeof value}`);
3285
+ }
3286
+ if (value.length !== parsed.width * 2 + 2) {
3287
+ throw new Error(`bytes${parsed.width} requires ${parsed.width} bytes`);
3288
+ }
3289
+ const raw = hexToBytes(value);
3290
+ if (raw.length !== parsed.width) {
3291
+ throw new Error(`bytes${parsed.width} requires ${parsed.width} bytes, got ${raw.length}`);
3292
+ }
3293
+ return padRight32(raw);
3294
+ }
3295
+ case "array": {
3296
+ if (!Array.isArray(value)) {
3297
+ throw new Error(`array field expects array: ${typeof value}`);
3298
+ }
3299
+ if (value.length > TYPED_DATA_LIMITS.maxArrayLength) {
3300
+ throw new Error(`array ${type} exceeds length limit`);
3301
+ }
3302
+ if (parsed.size !== void 0 && value.length !== parsed.size) {
3303
+ throw new Error(`fixed array ${type} requires length ${parsed.size}, got ${value.length}`);
3304
+ }
3305
+ const chunks = value.map(
3306
+ (v) => encodeFieldWithBudget(parsed.inner, v, types, budget, depth + 1)
3307
+ );
3308
+ return shake256Digest(concatBytesArr(chunks), DIGEST_LEN);
3309
+ }
2109
3310
  }
2110
- /**
2111
- * Redirect only for calls the user must approve, on mobile, and only when
2112
- * the wallet is actually absent from the channel: right after pairing its
2113
- * socket lingers and a pointless app flash would be jarring.
2114
- *
2115
- * qrl_requestAccounts is excluded even though it is restricted: dApps call
2116
- * it on page load to restore a session, so it can run with no user gesture
2117
- * (an unsolicited "Open in MyQRLWallet?" sheet on load) and pairing UIs
2118
- * already own their own deep-link step.
2119
- */
2120
- shouldRedirectToWallet(method) {
2121
- return this.options.walletRedirectOnRequest !== false && RESTRICTED_METHODS.has(method) && method !== "qrl_requestAccounts" && isMobileBrowser() && !this.connectionManager.isWalletPresent();
3311
+ }
3312
+ function encodeField(type, value, types) {
3313
+ validateTypeMap(types);
3314
+ return encodeFieldWithBudget(
3315
+ type,
3316
+ value,
3317
+ types,
3318
+ { remainingValues: TYPED_DATA_LIMITS.maxEncodedValues },
3319
+ 0
3320
+ );
3321
+ }
3322
+ function hashStructWithBudget(primary, data, types, budget, depth) {
3323
+ consumeValue(budget);
3324
+ const fields = types[primary];
3325
+ if (!fields) throw new Error(`unknown struct: ${primary}`);
3326
+ const expected = new Set(fields.map((f) => f.name));
3327
+ let ownFieldCount = 0;
3328
+ for (const k in data) {
3329
+ if (!Object.prototype.hasOwnProperty.call(data, k)) continue;
3330
+ ownFieldCount++;
3331
+ if (ownFieldCount > TYPED_DATA_LIMITS.maxEncodedValues) {
3332
+ throw new Error("typed data object contains too many fields");
3333
+ }
3334
+ if (!expected.has(k)) throw new Error(`unknown field in ${primary}: ${k}`);
2122
3335
  }
2123
- // ── In-flight persistence (survive the return-redirect reload) ──────
2124
- readInflight() {
2125
- if (typeof localStorage === "undefined") return [];
2126
- try {
2127
- const raw = localStorage.getItem(this.inflightKey);
2128
- if (!raw) return [];
2129
- const parsed = JSON.parse(raw);
2130
- if (!Array.isArray(parsed)) return [];
2131
- const now = Date.now();
2132
- const records = [];
2133
- for (const v of parsed) {
2134
- const rec = parseInflightRecord(v);
2135
- if (rec && now - rec.ts <= REQUEST_TIMEOUT_MS) records.push(rec);
2136
- }
2137
- return records;
2138
- } catch {
2139
- return [];
3336
+ const parts = [typeHash(primary, types)];
3337
+ for (const f of fields) {
3338
+ if (!Object.prototype.hasOwnProperty.call(data, f.name)) {
3339
+ throw new Error(`missing field ${primary}.${f.name}`);
2140
3340
  }
3341
+ parts.push(encodeFieldWithBudget(f.type, data[f.name], types, budget, depth + 1));
2141
3342
  }
2142
- writeInflight(records) {
2143
- if (typeof localStorage === "undefined") return;
3343
+ return shake256Digest(concatBytesArr(parts), DIGEST_LEN);
3344
+ }
3345
+ function hashStruct(primary, data, types) {
3346
+ validateTypeMap(types);
3347
+ return hashStructWithBudget(
3348
+ primary,
3349
+ data,
3350
+ types,
3351
+ { remainingValues: TYPED_DATA_LIMITS.maxEncodedValues },
3352
+ 0
3353
+ );
3354
+ }
3355
+ var RESERVED_DOMAIN_FIELDS = {
3356
+ name: "string",
3357
+ version: "string",
3358
+ chainId: "uint256",
3359
+ verifyingContract: "address",
3360
+ salt: "bytes32"
3361
+ };
3362
+ function validateDomainTypes(types) {
3363
+ const def = types.QRLDomain;
3364
+ if (!def) throw new Error("QRLDomain type is required");
3365
+ let hasName = false;
3366
+ for (const f of def) {
3367
+ const expected = RESERVED_DOMAIN_FIELDS[f.name];
3368
+ if (!expected) {
3369
+ throw new Error(
3370
+ `QRLDomain field "${f.name}" not in reserved set (name, version, chainId, verifyingContract, salt)`
3371
+ );
3372
+ }
3373
+ if (f.type !== expected) {
3374
+ throw new Error(`QRLDomain field "${f.name}" must be type "${expected}", got "${f.type}"`);
3375
+ }
3376
+ if (f.name === "name") hasName = true;
3377
+ }
3378
+ if (!hasName) throw new Error("QRLDomain.name is required");
3379
+ }
3380
+ function validatePayloadReachability(primary, types) {
3381
+ const reachable = /* @__PURE__ */ new Set();
3382
+ for (const root of ["QRLDomain", primary]) {
3383
+ for (const t of collectDependencies(root, types)) reachable.add(t);
3384
+ }
3385
+ for (const k of Object.keys(types)) {
3386
+ if (!reachable.has(k)) throw new Error(`unused referenced type: ${k}`);
3387
+ }
3388
+ }
3389
+ function computeTypedDataDigest(payload) {
3390
+ const parsed = parsePayload(payload);
3391
+ validateTypeMap(parsed.types);
3392
+ assertIdentifier(parsed.primaryType, "primary type");
3393
+ if (parsed.primaryType === "QRLDomain") {
3394
+ throw new Error("QRLDomain cannot be the primary type");
3395
+ }
3396
+ validateDomainTypes(parsed.types);
3397
+ validatePayloadReachability(parsed.primaryType, parsed.types);
3398
+ const budget = { remainingValues: TYPED_DATA_LIMITS.maxEncodedValues };
3399
+ const domainHash = hashStructWithBudget("QRLDomain", parsed.domain, parsed.types, budget, 0);
3400
+ const messageHash = hashStructWithBudget(
3401
+ parsed.primaryType,
3402
+ parsed.message,
3403
+ parsed.types,
3404
+ budget,
3405
+ 0
3406
+ );
3407
+ return shake256Digest(concatBytes(SCHEME_TAG_TYPED, domainHash, messageHash), DIGEST_LEN);
3408
+ }
3409
+
3410
+ // src/utils/platform.ts
3411
+ function isMobileBrowser() {
3412
+ if (typeof navigator === "undefined") return false;
3413
+ return /Android|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop/i.test(navigator.userAgent);
3414
+ }
3415
+ function getAppStoreUrl() {
3416
+ if (typeof navigator !== "undefined" && /android/i.test(navigator.userAgent)) {
3417
+ return "https://play.google.com/store/apps/details?id=com.chiefdg.myqrlwallet";
3418
+ }
3419
+ return "https://apps.apple.com/app/myqrlwallet/id6742219498";
3420
+ }
3421
+ function attemptWalletRedirect(uri, timeoutMs = 1800) {
3422
+ if (typeof window === "undefined" || typeof document === "undefined") {
3423
+ return Promise.resolve(false);
3424
+ }
3425
+ if (!uri.trim().toLowerCase().startsWith("qrlconnect:")) {
3426
+ return Promise.resolve(false);
3427
+ }
3428
+ return new Promise((resolve) => {
3429
+ let settled = false;
3430
+ const finish = (opened) => {
3431
+ if (settled) return;
3432
+ settled = true;
3433
+ document.removeEventListener("visibilitychange", onVisibility);
3434
+ window.removeEventListener("pagehide", onHide);
3435
+ window.clearTimeout(timer);
3436
+ resolve(opened);
3437
+ };
3438
+ const onVisibility = () => {
3439
+ if (document.visibilityState === "hidden") finish(true);
3440
+ };
3441
+ const onHide = () => {
3442
+ finish(true);
3443
+ };
3444
+ document.addEventListener("visibilitychange", onVisibility);
3445
+ window.addEventListener("pagehide", onHide);
3446
+ const timer = window.setTimeout(() => {
3447
+ finish(false);
3448
+ }, timeoutMs);
2144
3449
  try {
2145
- if (records.length === 0) {
2146
- localStorage.removeItem(this.inflightKey);
2147
- } else {
2148
- localStorage.setItem(this.inflightKey, JSON.stringify(records));
2149
- }
3450
+ window.location.href = uri;
2150
3451
  } catch {
3452
+ finish(false);
2151
3453
  }
3454
+ });
3455
+ }
3456
+
3457
+ // src/QRLConnectProvider.ts
3458
+ var QRL_CONNECT_PROVIDER_INFO = {
3459
+ name: "MyQRLWallet",
3460
+ rdns: "com.qrlwallet.connect",
3461
+ icon: "data:image/png;base64,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"
3462
+ };
3463
+ var EIP6963_ANNOUNCE_EVENT = "eip6963:announceProvider";
3464
+ var EIP6963_REQUEST_EVENT = "eip6963:requestProvider";
3465
+ function isRecordObj(v) {
3466
+ return typeof v === "object" && v !== null && !Array.isArray(v);
3467
+ }
3468
+ function isUnknownArray(v) {
3469
+ return Array.isArray(v);
3470
+ }
3471
+ function parseInflightRecord(v) {
3472
+ if (!isRecordObj(v)) return null;
3473
+ const { id, method, ts } = v;
3474
+ if (!isValidJsonRpcId(id) || !isValidJsonRpcMethod(method)) return null;
3475
+ if (typeof ts !== "number" || !Number.isFinite(ts)) return null;
3476
+ if (v.expectedChainId !== void 0 && typeof v.expectedChainId !== "string") return null;
3477
+ return { id, method, ts, expectedChainId: v.expectedChainId };
3478
+ }
3479
+ function canonicalChainId2(value) {
3480
+ if (typeof value !== "string" || value.length > 66 || !/^0x[0-9a-fA-F]+$/.test(value)) {
3481
+ throw new Error("wallet_switchQrlChain requires a 0x-prefixed chainId");
2152
3482
  }
2153
- persistInflight(id, method) {
2154
- const records = this.readInflight().filter((r) => r.id !== id);
2155
- records.push({ id, method, ts: Date.now() });
2156
- this.writeInflight(records);
3483
+ return `0x${BigInt(value).toString(16)}`;
3484
+ }
3485
+ function requestedSwitchChainId(params) {
3486
+ if (params?.length !== 1) {
3487
+ throw new Error("wallet_switchQrlChain requires one chain configuration object");
2157
3488
  }
2158
- settleInflight(id) {
2159
- this.orphanedRequests.delete(id);
2160
- this.writeInflight(this.readInflight().filter((r) => r.id !== id));
3489
+ const request = params?.[0];
3490
+ if (!isRecordObj(request)) {
3491
+ throw new Error("wallet_switchQrlChain requires one chain configuration object");
2161
3492
  }
2162
- clearAllInflight() {
2163
- this.orphanedRequests.clear();
2164
- this.writeInflight([]);
3493
+ return canonicalChainId2(request.chainId);
3494
+ }
3495
+ var TRANSACTION_FIELDS = /* @__PURE__ */ new Set(["from", "to", "value", "gas", "data"]);
3496
+ var QRL_TRANSACTION_MAX_DATA_BYTES = 128 * 1024;
3497
+ var QRL_TRANSACTION_MAX_QUANTITY_HEX_DIGITS = 64;
3498
+ var RPC_QUANTITY_RE = /^0x(?:0|[1-9a-fA-F][0-9a-fA-F]*)$/;
3499
+ function validateRpcQuantity(value, field) {
3500
+ if (field === "gas" && typeof value === "number") {
3501
+ if (Number.isSafeInteger(value) && value >= 0) return;
3502
+ throw new Error("transaction gas must be a non-negative safe integer");
2165
3503
  }
2166
- /**
2167
- * Wake URI for the wallet app. Carries no pairing payload: the app
2168
- * foregrounds on any `qrlconnect:` URL and re-joins its sessions (draining
2169
- * the buffered request); the cid lets a future wallet jump straight to the
2170
- * right approval.
2171
- *
2172
- * MUST stay query-only (no host/path). The mobile app routes deep links
2173
- * with expo-router, which maps anything after the scheme to an in-app
2174
- * route: `qrlconnect://resume?...` lands on the "+not-found" screen and
2175
- * unmounts the WebView, so the wake achieves nothing. The hostless form
2176
- * routes to the WebView tab exactly like the pairing URIs
2177
- * (`qrlconnect://?q=...`), which is the whole point of the wake.
2178
- */
2179
- walletWakeUri() {
2180
- return `qrlconnect://?wake=${encodeURIComponent(this.connectionManager.getChannelId())}`;
3504
+ if (typeof value !== "string" || value.length > QRL_TRANSACTION_MAX_QUANTITY_HEX_DIGITS + 2 || !RPC_QUANTITY_RE.test(value)) {
3505
+ throw new Error(`transaction ${field} must be a canonical 0x quantity`);
2181
3506
  }
2182
- /**
2183
- * Get the current connection status.
2184
- */
2185
- getStatus() {
2186
- return this.connectionManager.getStatus();
3507
+ if (field === "gas" && BigInt(value) > BigInt(Number.MAX_SAFE_INTEGER)) {
3508
+ throw new Error("transaction gas exceeds the wallet safe-integer limit");
2187
3509
  }
2188
- /**
2189
- * Get connected accounts.
2190
- */
2191
- getAccounts() {
2192
- return this.connectionManager.getAccounts();
3510
+ }
3511
+ function validateTransactionRequest(method, params, authorizedAccount) {
3512
+ if (params?.length !== 1 || !isRecordObj(params[0])) {
3513
+ throw new Error(`${method} requires exactly one transaction object`);
2193
3514
  }
2194
- /**
2195
- * Get the channel ID for this connection.
2196
- */
2197
- getChannelId() {
2198
- return this.connectionManager.getChannelId();
3515
+ const tx = params[0];
3516
+ const unsupported = Object.keys(tx).find((field) => !TRANSACTION_FIELDS.has(field));
3517
+ if (unsupported) throw new Error(`transaction field is not supported: ${unsupported}`);
3518
+ if (!isCurrentQrlAddress(tx.from)) {
3519
+ throw new Error("transaction from must be a valid Q-address");
2199
3520
  }
2200
- /**
2201
- * Check if connected and keys exchanged.
2202
- */
2203
- isConnected() {
2204
- return this.connectionManager.getStatus() === "connected" /* CONNECTED */;
3521
+ if (tx.from !== authorizedAccount) {
3522
+ throw new Error("transaction from is not the authorized account");
2205
3523
  }
2206
- /**
2207
- * Check if a stored session exists that can be reconnected.
2208
- */
2209
- hasStoredSession() {
2210
- return this.connectionManager.hasStoredSession();
3524
+ if (!isCurrentQrlAddress(tx.to)) {
3525
+ throw new Error("transaction to must be a valid Q-address");
2211
3526
  }
2212
- /**
2213
- * True while a pairing exists (keys exchanged), even when the wallet app
2214
- * is backgrounded and its socket is out of the channel. Combine with
2215
- * getStatus() to tell "wallet away, session intact" (paired + WAITING)
2216
- * apart from "waiting for a first scan" in pairing UIs.
2217
- */
2218
- isPaired() {
2219
- return this.connectionManager.isPaired();
3527
+ if ("value" in tx) validateRpcQuantity(tx.value, "value");
3528
+ if ("gas" in tx) validateRpcQuantity(tx.gas, "gas");
3529
+ if ("data" in tx && (typeof tx.data !== "string" || tx.data.length > QRL_TRANSACTION_MAX_DATA_BYTES * 2 + 2 || !/^0x(?:[0-9a-fA-F]{2})*$/.test(tx.data))) {
3530
+ throw new Error("transaction data must be bounded 0x-prefixed bytes");
2220
3531
  }
2221
- /** True while the wallet is actually present in the relay channel. */
2222
- isWalletPresent() {
2223
- return this.connectionManager.isWalletPresent();
3532
+ }
3533
+ function snapshotRequestParams(params) {
3534
+ if (params === void 0) return void 0;
3535
+ try {
3536
+ const encoded = JSON.stringify(params);
3537
+ if (typeof encoded !== "string") throw new Error("params did not serialize");
3538
+ const snapshot = JSON.parse(encoded);
3539
+ if (!isUnknownArray(snapshot)) throw new Error("params must serialize to an array");
3540
+ return snapshot;
3541
+ } catch (error2) {
3542
+ const detail = error2 instanceof Error ? error2.message : String(error2);
3543
+ throw new Error(`Request params must be JSON-serializable: ${detail}`);
2224
3544
  }
2225
- /**
2226
- * Reset the connection and start a fresh pairing with a new channel.
2227
- * Use this when the user wants to create a new connection instead of
2228
- * reconnecting to an existing session.
2229
- */
2230
- async newConnection() {
2231
- for (const [, pending] of this.pendingRequests) {
2232
- pending.reject(new Error("Connection reset"));
3545
+ }
3546
+ function validateRestrictedRequest(method, params, authorizedAccounts) {
3547
+ if (method === "qrl_requestAccounts") {
3548
+ if (params !== void 0 && params.length !== 0) {
3549
+ throw new Error("qrl_requestAccounts does not accept parameters");
2233
3550
  }
2234
- this.pendingRequests.clear();
2235
- this.clearAllInflight();
2236
- await this.connectionManager.resetForNewChannel();
2237
- return this.getConnectionURI();
3551
+ return void 0;
2238
3552
  }
2239
- /**
2240
- * Disconnect from wallet and clean up. Returns once the TERMINATE has
2241
- * either been flushed to the relay or the 800ms best-effort window has
2242
- * elapsed - the wallet gets an instant disconnect instead of landing in
2243
- * its stale-session grace period.
2244
- */
2245
- async disconnect() {
2246
- for (const [, pending] of this.pendingRequests) {
2247
- pending.reject(new Error("Disconnected"));
2248
- }
2249
- this.pendingRequests.clear();
2250
- this.clearAllInflight();
2251
- this.teardownResumeListeners();
2252
- await this.connectionManager.disconnect();
3553
+ if (method === "wallet_switchQrlChain") {
3554
+ return requestedSwitchChainId(params);
2253
3555
  }
2254
- };
2255
-
2256
- // src/signing/ctx.ts
2257
- var SCHEME_VERSION_MSG = "QRL-SIGN-MSG-v1";
2258
- var SCHEME_VERSION_TYPED = "QRL-SIGN-TYPED-v1";
2259
- var SCHEME_TAG_MSG = new TextEncoder().encode(SCHEME_VERSION_MSG);
2260
- var SCHEME_TAG_TYPED = new TextEncoder().encode(SCHEME_VERSION_TYPED);
2261
- var DIGEST_LEN = 64;
2262
-
2263
- // src/signing/bytes.ts
2264
- function hexToBytes(hex) {
2265
- if (typeof hex !== "string") throw new Error("hex must be a string");
2266
- if (!/^0x([0-9a-fA-F]{2})*$/.test(hex)) {
2267
- throw new Error("hex must be 0x-prefixed even-length");
3556
+ const authorizedAccount = authorizedAccounts[0];
3557
+ if (!authorizedAccount || authorizedAccounts.length !== 1) {
3558
+ throw new Error("No authorized account: call qrl_requestAccounts first");
2268
3559
  }
2269
- const n = (hex.length - 2) / 2;
2270
- const out = new Uint8Array(n);
2271
- for (let i = 0; i < n; i++) {
2272
- out[i] = parseInt(hex.slice(2 + i * 2, 4 + i * 2), 16);
3560
+ if (method === "qrl_sendTransaction" || method === "qrl_signTransaction") {
3561
+ validateTransactionRequest(method, params, authorizedAccount);
3562
+ return void 0;
2273
3563
  }
2274
- return out;
2275
- }
2276
- function bytesToHex(bytes) {
2277
- if (!(bytes instanceof Uint8Array)) {
2278
- throw new Error("bytes must be a Uint8Array");
3564
+ if (method === "qrl_signMessage") {
3565
+ if (params?.length !== 2) {
3566
+ throw new Error("qrl_signMessage requires [signer, messageHex]");
3567
+ }
3568
+ const [signer, messageHex] = params;
3569
+ if (!isCurrentQrlAddress(signer)) {
3570
+ throw new Error("qrl_signMessage requires a valid Q-address signer");
3571
+ }
3572
+ if (signer !== authorizedAccount) {
3573
+ throw new Error("qrl_signMessage signer is not the authorized account");
3574
+ }
3575
+ if (typeof messageHex !== "string" || messageHex.length > TYPED_DATA_LIMITS.maxDynamicBytes * 2 + 2 || !/^0x([0-9a-fA-F]{2})*$/.test(messageHex)) {
3576
+ throw new Error("qrl_signMessage requires bounded 0x-prefixed bytes");
3577
+ }
3578
+ return void 0;
2279
3579
  }
2280
- let s = "0x";
2281
- for (const v of bytes) s += v.toString(16).padStart(2, "0");
2282
- return s;
2283
- }
2284
- function concatBytes(...parts) {
2285
- return concatBytesArr(parts);
2286
- }
2287
- function concatBytesArr(parts) {
2288
- let total = 0;
2289
- for (const p of parts) total += p.length;
2290
- const out = new Uint8Array(total);
2291
- let off = 0;
2292
- for (const p of parts) {
2293
- out.set(p, off);
2294
- off += p.length;
3580
+ if (method !== "qrl_signTypedData") return void 0;
3581
+ if (params?.length !== 2) {
3582
+ throw new Error("qrl_signTypedData requires [signer, payload]");
2295
3583
  }
2296
- return out;
2297
- }
2298
-
2299
- // src/signing/messageDigest.ts
2300
- function computeMessageDigest(messageBytes) {
2301
- if (!(messageBytes instanceof Uint8Array)) {
2302
- throw new Error("messageBytes must be a Uint8Array");
3584
+ if (!isCurrentQrlAddress(params[0])) {
3585
+ throw new Error("qrl_signTypedData requires a valid Q-address signer");
2303
3586
  }
2304
- return shake256Digest(concatBytes(SCHEME_TAG_MSG, messageBytes), DIGEST_LEN);
3587
+ if (params[0] !== authorizedAccount) {
3588
+ throw new Error("qrl_signTypedData signer is not the authorized account");
3589
+ }
3590
+ computeTypedDataDigest(params[1]);
3591
+ return void 0;
2305
3592
  }
2306
-
2307
- // src/signing/typedData.ts
2308
- var SLOT = 32;
2309
- function isMessageObject(v) {
2310
- return typeof v === "object" && v !== null && !Array.isArray(v);
3593
+ function requiresAuthorizedAccount(method) {
3594
+ return method === "qrl_sendTransaction" || method === "qrl_signTransaction" || method === "qrl_signMessage" || method === "qrl_signTypedData";
2311
3595
  }
2312
- var ATOMIC_RE = /^(?:(address|bool|string|bytes)|(u?int)(\d+)|bytes(\d+)|(.+?)\[(\d*)\])$/;
2313
- var MAX_TYPE_DEPTH = 12;
2314
- function parseFieldType(type, types, depth = 0) {
2315
- if (depth > MAX_TYPE_DEPTH) throw new Error(`type nesting too deep: ${type}`);
2316
- if (Object.prototype.hasOwnProperty.call(types, type)) {
2317
- return { kind: "ref", name: type };
3596
+ var QRLConnectProvider = class extends EventEmitter4 {
3597
+ constructor(options) {
3598
+ super();
3599
+ this.pendingRequests = /* @__PURE__ */ new Map();
3600
+ this.eip6963Detail = null;
3601
+ this.eip6963RequestListener = null;
3602
+ this.resumeListener = null;
3603
+ this.resumeDebounce = null;
3604
+ this.orphanedRequests = /* @__PURE__ */ new Map();
3605
+ // Random per-instance prefix keeps request ids unique across page loads.
3606
+ // A bare counter restarts at 1 on reload, and the relay buffers messages
3607
+ // for 5 minutes, so a stale buffered response could otherwise be matched
3608
+ // to a fresh request that drew the same small id.
3609
+ this.requestIdPrefix = randomUuid().slice(0, 8);
3610
+ this.requestCounter = 0;
3611
+ // Cancels work that is still awaiting channel restoration and therefore has
3612
+ // no pendingRequests entry yet. Without this barrier, disconnect() or
3613
+ // newConnection() could miss that request and let it cross into a later
3614
+ // channel once the stale ensureChannelJoined() promise settled.
3615
+ this.requestGeneration = 0;
3616
+ this.requestCancellationMessage = "Connection changed while request was pending";
3617
+ this.lifecycleTransition = null;
3618
+ this.disconnectInFlight = null;
3619
+ this.restrictedRequestActive = false;
3620
+ this.restrictedRequestQueue = [];
3621
+ this.isQRLConnect = true;
3622
+ this.options = options;
3623
+ this.inflightKey = `${options.storageKey ?? `${STORAGE_KEY_PREFIX}:session`}:inflight`;
3624
+ for (const rec of this.readInflight()) {
3625
+ this.orphanedRequests.set(rec.id, {
3626
+ method: rec.method,
3627
+ expectedChainId: rec.expectedChainId
3628
+ });
3629
+ }
3630
+ if (options.debug) {
3631
+ setDebug(true);
3632
+ }
3633
+ this.connectionManager = new ConnectionManager({
3634
+ dappMetadata: options.dappMetadata,
3635
+ relayUrl: options.relayUrl,
3636
+ chainId: options.chainId,
3637
+ storageKey: options.storageKey
3638
+ });
3639
+ this.setupConnectionListeners();
3640
+ if (options.autoReconnect !== false) {
3641
+ void this.connectionManager.reconnect();
3642
+ }
3643
+ this.setupResumeListeners();
3644
+ if (options.announceProvider !== false) {
3645
+ this.startEip6963Announce();
3646
+ }
2318
3647
  }
2319
- const m = ATOMIC_RE.exec(type);
2320
- if (!m) throw new Error(`unknown type: ${type}`);
2321
- const [, atomic, intKind, intWidthStr, bytesWidthStr, innerType, sizeStr] = m;
2322
- if (atomic === "address" || atomic === "bool" || atomic === "string" || atomic === "bytes") {
2323
- return { kind: atomic };
3648
+ startEip6963Announce() {
3649
+ if (typeof window === "undefined" || typeof CustomEvent === "undefined") {
3650
+ return;
3651
+ }
3652
+ const overrides = this.options.providerInfo ?? {};
3653
+ this.eip6963Detail = Object.freeze({
3654
+ info: Object.freeze({
3655
+ uuid: randomUuid(),
3656
+ name: overrides.name ?? QRL_CONNECT_PROVIDER_INFO.name,
3657
+ icon: overrides.icon ?? QRL_CONNECT_PROVIDER_INFO.icon,
3658
+ rdns: overrides.rdns ?? QRL_CONNECT_PROVIDER_INFO.rdns
3659
+ }),
3660
+ provider: this
3661
+ });
3662
+ const announce = () => {
3663
+ if (!this.eip6963Detail) return;
3664
+ window.dispatchEvent(new CustomEvent(EIP6963_ANNOUNCE_EVENT, { detail: this.eip6963Detail }));
3665
+ };
3666
+ this.eip6963RequestListener = announce;
3667
+ window.addEventListener(EIP6963_REQUEST_EVENT, announce);
3668
+ announce();
3669
+ }
3670
+ /**
3671
+ * Re-open the relay socket when the dApp tab regains focus or the network
3672
+ * comes back. Debounced because visibilitychange + online can fire together.
3673
+ * No-op when already connected (ConnectionManager.resume guards that).
3674
+ */
3675
+ setupResumeListeners() {
3676
+ if (typeof window === "undefined" || typeof document === "undefined") return;
3677
+ if (this.resumeListener) return;
3678
+ const resume = () => {
3679
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
3680
+ if (this.resumeDebounce) clearTimeout(this.resumeDebounce);
3681
+ this.resumeDebounce = setTimeout(() => {
3682
+ this.resumeDebounce = null;
3683
+ this.connectionManager.resume();
3684
+ }, 300);
3685
+ };
3686
+ this.resumeListener = resume;
3687
+ document.addEventListener("visibilitychange", resume);
3688
+ window.addEventListener("online", resume);
3689
+ window.addEventListener("pageshow", resume);
2324
3690
  }
2325
- if (intKind) {
2326
- const width = Number(intWidthStr);
2327
- if (!Number.isInteger(width) || width < 8 || width > 256 || width % 8 !== 0) {
2328
- throw new Error(`invalid int width: ${type}`);
3691
+ /**
3692
+ * Remove the foreground/online resume listeners. Called on disconnect so a
3693
+ * torn-down session can't be silently revived by a later tab focus; the
3694
+ * listeners are re-armed by getConnectionURI()/newConnection() if the same
3695
+ * provider is re-paired. Safe in any env.
3696
+ */
3697
+ teardownResumeListeners() {
3698
+ if (typeof window !== "undefined" && typeof document !== "undefined" && this.resumeListener) {
3699
+ document.removeEventListener("visibilitychange", this.resumeListener);
3700
+ window.removeEventListener("online", this.resumeListener);
3701
+ window.removeEventListener("pageshow", this.resumeListener);
3702
+ }
3703
+ this.resumeListener = null;
3704
+ if (this.resumeDebounce) {
3705
+ clearTimeout(this.resumeDebounce);
3706
+ this.resumeDebounce = null;
2329
3707
  }
2330
- return { kind: intKind === "uint" ? "uintN" : "intN", width };
2331
3708
  }
2332
- if (bytesWidthStr) {
2333
- const width = Number(bytesWidthStr);
2334
- if (!Number.isInteger(width) || width < 1 || width > 32) {
2335
- throw new Error(`invalid bytesN width: ${type}`);
3709
+ /**
3710
+ * Stop announcing this provider over EIP-6963. Safe to call from any env.
3711
+ */
3712
+ stopEip6963Announce() {
3713
+ if (typeof window !== "undefined" && this.eip6963RequestListener) {
3714
+ window.removeEventListener(EIP6963_REQUEST_EVENT, this.eip6963RequestListener);
2336
3715
  }
2337
- return { kind: "bytesN", width };
3716
+ this.eip6963RequestListener = null;
3717
+ this.eip6963Detail = null;
2338
3718
  }
2339
- if (innerType !== void 0) {
2340
- parseFieldType(innerType, types, depth + 1);
2341
- if (sizeStr) {
2342
- const size = Number(sizeStr);
2343
- if (!Number.isInteger(size) || size <= 0) {
2344
- throw new Error(`invalid array size: ${type}`);
3719
+ setupConnectionListeners() {
3720
+ this.connectionManager.on("status_changed", (status) => {
3721
+ log("Provider", `Connection status: ${status}`);
3722
+ this.emit("statusChanged", status);
3723
+ if (status === "connected" /* CONNECTED */) {
3724
+ this.emit("connect", { chainId: this.connectionManager.getChainId() });
2345
3725
  }
2346
- return { kind: "array", inner: innerType, size };
2347
- }
2348
- return { kind: "array", inner: innerType };
2349
- }
2350
- throw new Error(`unhandled type: ${type}`);
2351
- }
2352
- function baseTypeName(type) {
2353
- return type.replace(/(\[\d*\])+$/, "");
2354
- }
2355
- function collectDependencies(primary, types) {
2356
- if (!Object.prototype.hasOwnProperty.call(types, primary)) {
2357
- throw new Error(`unknown primary type: ${primary}`);
3726
+ if (status === "disconnected" /* DISCONNECTED */) {
3727
+ this.emit("disconnect", {
3728
+ code: 4900,
3729
+ message: "Disconnected from QRL Wallet"
3730
+ });
3731
+ }
3732
+ });
3733
+ this.connectionManager.on("accounts_changed", (accounts) => {
3734
+ this.emit("accountsChanged", accounts);
3735
+ });
3736
+ this.connectionManager.on("chain_changed", (chainId) => {
3737
+ this.emit("chainChanged", chainId);
3738
+ });
3739
+ this.connectionManager.on("jsonrpc_response", (response) => {
3740
+ void this.handleJsonRpcResponse(response);
3741
+ });
3742
+ this.connectionManager.on("error", (err) => {
3743
+ warn("Provider", `ConnectionManager error: ${err.message}`);
3744
+ this.emit("message", { type: "error", data: err.message });
3745
+ });
3746
+ this.connectionManager.on("connection_lost", () => {
3747
+ this.cancelAllRequests(new Error("Connection to QRL Wallet lost"));
3748
+ });
3749
+ this.connectionManager.on("session_terminated", () => {
3750
+ this.cancelAllRequests(new Error("Session terminated by wallet"));
3751
+ this.clearAllInflight();
3752
+ });
2358
3753
  }
2359
- const visited = /* @__PURE__ */ new Set();
2360
- const visit = (name, path) => {
2361
- if (path.includes(name)) {
2362
- throw new Error(`cyclic type reference: ${[...path, name].join(" -> ")}`);
2363
- }
2364
- if (visited.has(name)) return;
2365
- visited.add(name);
2366
- const def = types[name];
2367
- if (!def) throw new Error(`unknown type: ${name}`);
2368
- for (const f of def) {
2369
- const base = baseTypeName(f.type);
2370
- if (Object.prototype.hasOwnProperty.call(types, base)) {
2371
- visit(base, [...path, name]);
3754
+ async handleJsonRpcResponse(response) {
3755
+ const pending = this.pendingRequests.get(response.id);
3756
+ if (!pending) {
3757
+ const orphan = this.orphanedRequests.get(response.id);
3758
+ if (!orphan) {
3759
+ warn("Provider", `No pending request for id ${response.id}`);
3760
+ return;
2372
3761
  }
2373
- parseFieldType(f.type, types);
3762
+ this.settleInflight(response.id);
3763
+ if (!response.error && orphan.method === "qrl_requestAccounts") {
3764
+ try {
3765
+ const accounts = await this.connectionManager.authorizeAccounts(response.result);
3766
+ this.emit("late_response", { id: response.id, method: orphan.method, result: accounts });
3767
+ } catch (error2) {
3768
+ this.emit("late_response", {
3769
+ id: response.id,
3770
+ method: orphan.method,
3771
+ error: {
3772
+ code: -32e3,
3773
+ message: error2 instanceof Error ? error2.message : "Invalid account approval response"
3774
+ }
3775
+ });
3776
+ }
3777
+ return;
3778
+ }
3779
+ if (!response.error && orphan.method === "wallet_switchQrlChain" && (!orphan.expectedChainId || !this.isCurrentChain(orphan.expectedChainId))) {
3780
+ this.emit("late_response", {
3781
+ id: response.id,
3782
+ method: orphan.method,
3783
+ error: {
3784
+ code: -32e3,
3785
+ message: `Wallet reported success but did not switch to requested chain ${orphan.expectedChainId ?? "(unknown)"}`
3786
+ }
3787
+ });
3788
+ return;
3789
+ }
3790
+ this.emit("late_response", {
3791
+ id: response.id,
3792
+ method: orphan.method,
3793
+ ...response.error ? { error: response.error } : { result: response.result }
3794
+ });
3795
+ return;
2374
3796
  }
2375
- };
2376
- visit(primary, []);
2377
- return visited;
2378
- }
2379
- function validateTypeMap(types) {
2380
- for (const [name, def] of Object.entries(types)) {
2381
- const defUnknown = def;
2382
- if (!Array.isArray(defUnknown) || def.length === 0) {
2383
- throw new Error(`empty or invalid struct: ${name}`);
3797
+ this.pendingRequests.delete(response.id);
3798
+ if (response.error) {
3799
+ pending.reject(new Error(response.error.message || "Request failed"));
3800
+ return;
2384
3801
  }
2385
- const seen = /* @__PURE__ */ new Set();
2386
- for (const f of def) {
2387
- if (!f || typeof f.name !== "string" || !f.name) {
2388
- throw new Error(`bad field in ${name}`);
2389
- }
2390
- if (typeof f.type !== "string" || !f.type) {
2391
- throw new Error(`bad field type in ${name}.${f.name}`);
3802
+ try {
3803
+ if (pending.method === "qrl_requestAccounts") {
3804
+ const accounts = await this.connectionManager.authorizeAccounts(response.result);
3805
+ pending.resolve(accounts);
3806
+ return;
2392
3807
  }
2393
- if (seen.has(f.name)) {
2394
- throw new Error(`duplicate field "${f.name}" in ${name}`);
3808
+ if (pending.method === "wallet_switchQrlChain") {
3809
+ const requested = pending.expectedChainId;
3810
+ if (!requested) {
3811
+ throw new Error("Missing wallet_switchQrlChain postcondition");
3812
+ }
3813
+ if (!this.isCurrentChain(requested)) {
3814
+ throw new Error(
3815
+ `Wallet reported success but did not switch to requested chain ${requested}`
3816
+ );
3817
+ }
2395
3818
  }
2396
- seen.add(f.name);
3819
+ pending.resolve(response.result);
3820
+ } catch (error2) {
3821
+ pending.reject(error2 instanceof Error ? error2 : new Error(String(error2)));
2397
3822
  }
2398
3823
  }
2399
- }
2400
- function encodeType(primary, types) {
2401
- const deps = collectDependencies(primary, types);
2402
- const others = [...deps].filter((n) => n !== primary).sort();
2403
- return [primary, ...others].map((name) => {
2404
- const fields = types[name];
2405
- if (!fields) throw new Error(`unknown type: ${name}`);
2406
- const inner = fields.map((f) => `${f.type} ${f.name}`).join(",");
2407
- return `${name}(${inner})`;
2408
- }).join("");
2409
- }
2410
- function typeHash(primary, types) {
2411
- return shake256Digest(new TextEncoder().encode(encodeType(primary, types)), DIGEST_LEN);
2412
- }
2413
- function parseQAddress(addr) {
2414
- if (typeof addr !== "string" || !/^Q[0-9a-fA-F]{40}$/.test(addr)) {
2415
- throw new Error(`invalid Q-address: ${addr}`);
3824
+ cancelAllRequests(error2) {
3825
+ this.requestGeneration++;
3826
+ this.requestCancellationMessage = error2.message;
3827
+ for (const [, pending] of this.pendingRequests) pending.reject(error2);
3828
+ this.pendingRequests.clear();
3829
+ for (const queued of this.restrictedRequestQueue) queued.reject(error2);
3830
+ this.restrictedRequestQueue = [];
2416
3831
  }
2417
- return hexToBytes("0x" + addr.slice(1).toLowerCase());
2418
- }
2419
- function padLeft32(bytes) {
2420
- if (bytes.length > SLOT) throw new Error("cannot pad: bytes > 32");
2421
- const out = new Uint8Array(SLOT);
2422
- out.set(bytes, SLOT - bytes.length);
2423
- return out;
2424
- }
2425
- function padRight32(bytes) {
2426
- if (bytes.length > SLOT) throw new Error("cannot pad: bytes > 32");
2427
- const out = new Uint8Array(SLOT);
2428
- out.set(bytes, 0);
2429
- return out;
2430
- }
2431
- function bigIntToSlot(value, width, signed) {
2432
- if (signed) {
2433
- const limit = 1n << BigInt(width - 1);
2434
- if (value >= limit || value < -limit) {
2435
- throw new Error(`int${width} out of range: ${value}`);
3832
+ assertRequestGeneration(generation) {
3833
+ if (generation !== this.requestGeneration) {
3834
+ throw new Error(this.requestCancellationMessage);
2436
3835
  }
2437
- } else {
2438
- if (value < 0n) throw new Error(`uint${width} negative: ${value}`);
2439
- const limit = 1n << BigInt(width);
2440
- if (value >= limit) throw new Error(`uint${width} out of range: ${value}`);
2441
3836
  }
2442
- const slotMask = (1n << 256n) - 1n;
2443
- const repr = value < 0n ? value + (1n << 256n) & slotMask : value;
2444
- const out = new Uint8Array(SLOT);
2445
- let v = repr;
2446
- for (let i = SLOT - 1; i >= 0; i--) {
2447
- out[i] = Number(v & 0xffn);
2448
- v >>= 8n;
3837
+ isCurrentChain(expected) {
3838
+ try {
3839
+ return canonicalChainId2(this.connectionManager.getChainId()) === expected;
3840
+ } catch {
3841
+ return false;
3842
+ }
2449
3843
  }
2450
- return out;
2451
- }
2452
- function parseIntValue(v, typeLabel) {
2453
- if (typeof v === "bigint") return v;
2454
- if (typeof v === "string") {
2455
- if (/^-?0x[0-9a-fA-F]+$/i.test(v)) {
2456
- const isNegative = v.startsWith("-");
2457
- const abs = BigInt(isNegative ? v.slice(1) : v);
2458
- return isNegative ? -abs : abs;
3844
+ /**
3845
+ * Generate a connection URI for QR code display or deep link redirect.
3846
+ */
3847
+ async getConnectionURI() {
3848
+ if (this.lifecycleTransition) {
3849
+ throw new Error("Connection lifecycle transition is already in progress");
2459
3850
  }
2460
- if (!/^-?(0|[1-9]\d*)$/.test(v)) {
2461
- throw new Error(`invalid ${typeLabel} string: ${v}`);
3851
+ this.lifecycleTransition = "reset";
3852
+ this.cancelAllRequests(new Error("Connection reset"));
3853
+ this.clearAllInflight();
3854
+ try {
3855
+ this.setupResumeListeners();
3856
+ return await this.connectionManager.getConnectionURI();
3857
+ } finally {
3858
+ this.lifecycleTransition = null;
2462
3859
  }
2463
- return BigInt(v);
2464
3860
  }
2465
- if (typeof v === "number") {
2466
- if (!Number.isInteger(v) || !Number.isSafeInteger(v)) {
2467
- throw new Error(`unsafe ${typeLabel} number: ${v}, pass as string`);
3861
+ /**
3862
+ * Check if the current browser is mobile.
3863
+ */
3864
+ isMobile() {
3865
+ return isMobileBrowser();
3866
+ }
3867
+ /**
3868
+ * Get the app store URL for the QRL Wallet app.
3869
+ */
3870
+ getAppStoreUrl() {
3871
+ return getAppStoreUrl();
3872
+ }
3873
+ /**
3874
+ * EIP-1193 request method.
3875
+ */
3876
+ async request(args) {
3877
+ const { method, params } = args;
3878
+ if (!isValidJsonRpcMethod(method)) {
3879
+ throw new Error("Invalid JSON-RPC method");
2468
3880
  }
2469
- return BigInt(v);
3881
+ if (this.lifecycleTransition) {
3882
+ throw new Error(this.requestCancellationMessage);
3883
+ }
3884
+ if (method === "qrl_chainId") {
3885
+ return this.connectionManager.getChainId();
3886
+ }
3887
+ if (method === "qrl_accounts") {
3888
+ return this.connectionManager.getAccounts();
3889
+ }
3890
+ const policy = classifyRpcMethod(method);
3891
+ if (policy === "unsupported") {
3892
+ throw new Error(`Unsupported method: ${method}`);
3893
+ }
3894
+ const stableParams = snapshotRequestParams(params);
3895
+ const generation = this.requestGeneration;
3896
+ if (policy === "restricted") {
3897
+ if (requiresAuthorizedAccount(method) && this.connectionManager.getAccounts().length === 0) {
3898
+ throw new Error("No authorized account: call qrl_requestAccounts first");
3899
+ }
3900
+ const expectedChainId = validateRestrictedRequest(
3901
+ method,
3902
+ stableParams,
3903
+ this.connectionManager.getAccounts()
3904
+ );
3905
+ return this.enqueueRestrictedRequest(method, stableParams, expectedChainId, generation);
3906
+ }
3907
+ return this.performRequest(method, stableParams, false, void 0, generation);
2470
3908
  }
2471
- throw new Error(`unsupported ${typeLabel} value: ${typeof v}`);
2472
- }
2473
- function encodeField(type, value, types) {
2474
- const parsed = parseFieldType(type, types);
2475
- switch (parsed.kind) {
2476
- case "ref":
2477
- if (!isMessageObject(value)) {
2478
- throw new Error(`struct field expects object: ${type}`);
3909
+ /**
3910
+ * Keep at most one approval-bound request active. Wallet approval screens
3911
+ * are a serial human interaction, and request-id ordering must remain
3912
+ * stable even when a dApp fires several signing calls concurrently.
3913
+ */
3914
+ enqueueRestrictedRequest(method, params, expectedChainId, generation) {
3915
+ if (!this.restrictedRequestActive) {
3916
+ this.restrictedRequestActive = true;
3917
+ return this.runRestrictedRequest(method, params, expectedChainId, generation);
3918
+ }
3919
+ return new Promise((resolve, reject) => {
3920
+ this.restrictedRequestQueue.push({
3921
+ method,
3922
+ params,
3923
+ expectedChainId,
3924
+ generation,
3925
+ resolve,
3926
+ reject
3927
+ });
3928
+ });
3929
+ }
3930
+ runRestrictedRequest(method, params, expectedChainId, generation) {
3931
+ const task = this.performRequest(method, params, true, expectedChainId, generation);
3932
+ void task.then(
3933
+ () => {
3934
+ this.finishRestrictedRequest();
3935
+ },
3936
+ () => {
3937
+ this.finishRestrictedRequest();
2479
3938
  }
2480
- return hashStruct(parsed.name, value, types);
2481
- case "address":
2482
- if (typeof value !== "string") {
2483
- throw new Error(`address field expects string: ${typeof value}`);
3939
+ );
3940
+ return task;
3941
+ }
3942
+ finishRestrictedRequest() {
3943
+ const next = this.restrictedRequestQueue.shift();
3944
+ if (!next) {
3945
+ this.restrictedRequestActive = false;
3946
+ return;
3947
+ }
3948
+ const task = this.runRestrictedRequest(
3949
+ next.method,
3950
+ next.params,
3951
+ next.expectedChainId,
3952
+ next.generation
3953
+ );
3954
+ void task.then(next.resolve, next.reject);
3955
+ }
3956
+ async performRequest(method, params, restricted, expectedChainId, generation) {
3957
+ this.assertRequestGeneration(generation);
3958
+ if (this.connectionManager.getStatus() !== "connected" /* CONNECTED */) {
3959
+ if (method === "qrl_requestAccounts" && this.connectionManager.isPaired() && this.connectionManager.getAccounts().length > 0) {
3960
+ return this.connectionManager.getAccounts();
2484
3961
  }
2485
- return padLeft32(parseQAddress(value));
2486
- case "bool":
2487
- if (typeof value !== "boolean") {
2488
- throw new Error(`bool field expects boolean: ${typeof value}`);
3962
+ const joined = await this.connectionManager.ensureChannelJoined();
3963
+ this.assertRequestGeneration(generation);
3964
+ if (!joined) {
3965
+ throw new Error("Not connected to QRL Wallet");
2489
3966
  }
2490
- return padLeft32(new Uint8Array([value ? 1 : 0]));
2491
- case "string":
2492
- if (typeof value !== "string") {
2493
- throw new Error(`string field expects string: ${typeof value}`);
3967
+ }
3968
+ if (this.requestCounter >= Number.MAX_SAFE_INTEGER) {
3969
+ throw new Error("JSON-RPC request id counter exhausted");
3970
+ }
3971
+ const id = `${this.requestIdPrefix}-${++this.requestCounter}`;
3972
+ return new Promise((resolve, reject) => {
3973
+ const pending = {
3974
+ id,
3975
+ method,
3976
+ params,
3977
+ expectedChainId,
3978
+ resolve,
3979
+ reject,
3980
+ timestamp: Date.now()
3981
+ };
3982
+ this.pendingRequests.set(id, pending);
3983
+ const timeout = setTimeout(() => {
3984
+ const timedOut = this.pendingRequests.get(id);
3985
+ if (!timedOut) return;
3986
+ this.pendingRequests.delete(id);
3987
+ timedOut.reject(new Error(`Request timeout: ${method} (${REQUEST_TIMEOUT_MS}ms)`));
3988
+ }, REQUEST_TIMEOUT_MS);
3989
+ const originalResolve = pending.resolve;
3990
+ const originalReject = pending.reject;
3991
+ pending.resolve = (result) => {
3992
+ clearTimeout(timeout);
3993
+ this.settleInflight(id);
3994
+ originalResolve(result);
3995
+ };
3996
+ pending.reject = (error2) => {
3997
+ clearTimeout(timeout);
3998
+ this.settleInflight(id);
3999
+ originalReject(error2);
4000
+ };
4001
+ if (restricted) {
4002
+ this.persistInflight(id, method, expectedChainId);
2494
4003
  }
2495
- return shake256Digest(new TextEncoder().encode(value), DIGEST_LEN);
2496
- case "bytes":
2497
- if (typeof value !== "string") {
2498
- throw new Error(`bytes field expects 0x-hex string: ${typeof value}`);
4004
+ let sent;
4005
+ try {
4006
+ sent = this.connectionManager.sendJsonRpc({
4007
+ jsonrpc: "2.0",
4008
+ id,
4009
+ method,
4010
+ params
4011
+ });
4012
+ } catch (err) {
4013
+ this.pendingRequests.delete(id);
4014
+ pending.reject(err instanceof Error ? err : new Error(String(err)));
4015
+ return;
2499
4016
  }
2500
- return shake256Digest(hexToBytes(value), DIGEST_LEN);
2501
- case "uintN":
2502
- return bigIntToSlot(parseIntValue(value, `uint${parsed.width}`), parsed.width, false);
2503
- case "intN":
2504
- return bigIntToSlot(parseIntValue(value, `int${parsed.width}`), parsed.width, true);
2505
- case "bytesN": {
2506
- if (typeof value !== "string") {
2507
- throw new Error(`bytes${parsed.width} expects 0x-hex string: ${typeof value}`);
4017
+ sent.catch((err) => {
4018
+ const failed = this.pendingRequests.get(id);
4019
+ if (!failed) return;
4020
+ this.pendingRequests.delete(id);
4021
+ failed.reject(err instanceof Error ? err : new Error(String(err)));
4022
+ });
4023
+ if (this.shouldRedirectToWallet(method)) {
4024
+ void sent.then(
4025
+ () => attemptWalletRedirect(this.walletWakeUri()),
4026
+ () => void 0
4027
+ );
2508
4028
  }
2509
- const raw = hexToBytes(value);
2510
- if (raw.length !== parsed.width) {
2511
- throw new Error(`bytes${parsed.width} requires ${parsed.width} bytes, got ${raw.length}`);
4029
+ });
4030
+ }
4031
+ /**
4032
+ * Redirect only for calls the user must approve, on mobile, and only when
4033
+ * the wallet is actually absent from the channel: right after pairing its
4034
+ * socket lingers and a pointless app flash would be jarring.
4035
+ *
4036
+ * qrl_requestAccounts is excluded even though it is restricted: dApps call
4037
+ * it on page load to restore a session, so it can run with no user gesture
4038
+ * (an unsolicited "Open in MyQRLWallet?" sheet on load) and pairing UIs
4039
+ * already own their own deep-link step.
4040
+ */
4041
+ shouldRedirectToWallet(method) {
4042
+ return this.options.walletRedirectOnRequest !== false && classifyRpcMethod(method) === "restricted" && method !== "qrl_requestAccounts" && isMobileBrowser() && !this.connectionManager.isWalletPresent();
4043
+ }
4044
+ // ── In-flight persistence (survive the return-redirect reload) ──────
4045
+ readInflight() {
4046
+ if (typeof localStorage === "undefined") return [];
4047
+ try {
4048
+ const raw = localStorage.getItem(this.inflightKey);
4049
+ if (!raw) return [];
4050
+ const parsed = JSON.parse(raw);
4051
+ if (!Array.isArray(parsed)) return [];
4052
+ const now = Date.now();
4053
+ const records = [];
4054
+ for (const v of parsed) {
4055
+ const rec = parseInflightRecord(v);
4056
+ if (rec && now - rec.ts <= REQUEST_TIMEOUT_MS) records.push(rec);
2512
4057
  }
2513
- return padRight32(raw);
4058
+ return records;
4059
+ } catch {
4060
+ return [];
2514
4061
  }
2515
- case "array": {
2516
- if (!Array.isArray(value)) {
2517
- throw new Error(`array field expects array: ${typeof value}`);
2518
- }
2519
- if (parsed.size !== void 0 && value.length !== parsed.size) {
2520
- throw new Error(`fixed array ${type} requires length ${parsed.size}, got ${value.length}`);
4062
+ }
4063
+ writeInflight(records) {
4064
+ if (typeof localStorage === "undefined") return;
4065
+ try {
4066
+ if (records.length === 0) {
4067
+ localStorage.removeItem(this.inflightKey);
4068
+ } else {
4069
+ localStorage.setItem(this.inflightKey, JSON.stringify(records));
2521
4070
  }
2522
- const chunks = value.map((v) => encodeField(parsed.inner, v, types));
2523
- return shake256Digest(concatBytesArr(chunks), DIGEST_LEN);
4071
+ } catch {
2524
4072
  }
2525
4073
  }
2526
- }
2527
- function hashStruct(primary, data, types) {
2528
- const fields = types[primary];
2529
- if (!fields) throw new Error(`unknown struct: ${primary}`);
2530
- const expected = new Set(fields.map((f) => f.name));
2531
- for (const k of Object.keys(data)) {
2532
- if (!expected.has(k)) throw new Error(`unknown field in ${primary}: ${k}`);
4074
+ persistInflight(id, method, expectedChainId) {
4075
+ const records = this.readInflight().filter((r) => r.id !== id);
4076
+ records.push({ id, method, ts: Date.now(), expectedChainId });
4077
+ this.writeInflight(records);
2533
4078
  }
2534
- const parts = [typeHash(primary, types)];
2535
- for (const f of fields) {
2536
- if (!(f.name in data)) throw new Error(`missing field ${primary}.${f.name}`);
2537
- parts.push(encodeField(f.type, data[f.name], types));
4079
+ settleInflight(id) {
4080
+ this.orphanedRequests.delete(id);
4081
+ this.writeInflight(this.readInflight().filter((r) => r.id !== id));
2538
4082
  }
2539
- return shake256Digest(concatBytesArr(parts), DIGEST_LEN);
2540
- }
2541
- var RESERVED_DOMAIN_FIELDS = {
2542
- name: "string",
2543
- version: "string",
2544
- chainId: "uint256",
2545
- verifyingContract: "address",
2546
- salt: "bytes32"
2547
- };
2548
- function validateDomainTypes(types) {
2549
- const def = types.QRLDomain;
2550
- if (!def) throw new Error("QRLDomain type is required");
2551
- let hasName = false;
2552
- for (const f of def) {
2553
- const expected = RESERVED_DOMAIN_FIELDS[f.name];
2554
- if (!expected) {
2555
- throw new Error(
2556
- `QRLDomain field "${f.name}" not in reserved set (name, version, chainId, verifyingContract, salt)`
2557
- );
4083
+ clearAllInflight() {
4084
+ this.orphanedRequests.clear();
4085
+ this.writeInflight([]);
4086
+ }
4087
+ /**
4088
+ * Wake URI for the wallet app. Carries no pairing payload: the app
4089
+ * foregrounds on any `qrlconnect:` URL and re-joins its sessions (draining
4090
+ * the buffered request); the cid lets a future wallet jump straight to the
4091
+ * right approval.
4092
+ *
4093
+ * MUST stay query-only (no host/path). The mobile app routes deep links
4094
+ * with expo-router, which maps anything after the scheme to an in-app
4095
+ * route: `qrlconnect://resume?...` lands on the "+not-found" screen and
4096
+ * unmounts the WebView, so the wake achieves nothing. The hostless form
4097
+ * routes to the WebView tab exactly like the pairing URIs
4098
+ * (`qrlconnect://?q=...`), which is the whole point of the wake.
4099
+ */
4100
+ walletWakeUri() {
4101
+ return `qrlconnect://?wake=${encodeURIComponent(this.connectionManager.getChannelId())}`;
4102
+ }
4103
+ /**
4104
+ * Get the current connection status.
4105
+ */
4106
+ getStatus() {
4107
+ return this.connectionManager.getStatus();
4108
+ }
4109
+ /**
4110
+ * Get connected accounts.
4111
+ */
4112
+ getAccounts() {
4113
+ return this.connectionManager.getAccounts();
4114
+ }
4115
+ /**
4116
+ * Get the channel ID for this connection.
4117
+ */
4118
+ getChannelId() {
4119
+ return this.connectionManager.getChannelId();
4120
+ }
4121
+ /**
4122
+ * Check if connected and keys exchanged.
4123
+ */
4124
+ isConnected() {
4125
+ return this.connectionManager.getStatus() === "connected" /* CONNECTED */;
4126
+ }
4127
+ /**
4128
+ * Check if a stored session exists that can be reconnected.
4129
+ */
4130
+ hasStoredSession() {
4131
+ return this.connectionManager.hasStoredSession();
4132
+ }
4133
+ /**
4134
+ * True while a pairing exists (keys exchanged), even when the wallet app
4135
+ * is backgrounded and its socket is out of the channel. Combine with
4136
+ * getStatus() to tell "wallet away, session intact" (paired + WAITING)
4137
+ * apart from "waiting for a first scan" in pairing UIs.
4138
+ */
4139
+ isPaired() {
4140
+ return this.connectionManager.isPaired();
4141
+ }
4142
+ /** True while the wallet is actually present in the relay channel. */
4143
+ isWalletPresent() {
4144
+ return this.connectionManager.isWalletPresent();
4145
+ }
4146
+ /**
4147
+ * Reset the connection and start a fresh pairing with a new channel.
4148
+ * Use this when the user wants to create a new connection instead of
4149
+ * reconnecting to an existing session.
4150
+ */
4151
+ async newConnection() {
4152
+ if (this.lifecycleTransition) {
4153
+ throw new Error("Connection lifecycle transition is already in progress");
2558
4154
  }
2559
- if (f.type !== expected) {
2560
- throw new Error(`QRLDomain field "${f.name}" must be type "${expected}", got "${f.type}"`);
4155
+ this.lifecycleTransition = "reset";
4156
+ this.cancelAllRequests(new Error("Connection reset"));
4157
+ this.clearAllInflight();
4158
+ try {
4159
+ await this.connectionManager.resetForNewChannel();
4160
+ this.setupResumeListeners();
4161
+ return await this.connectionManager.getConnectionURI();
4162
+ } finally {
4163
+ this.lifecycleTransition = null;
2561
4164
  }
2562
- if (f.name === "name") hasName = true;
2563
4165
  }
2564
- if (!hasName) throw new Error("QRLDomain.name is required");
2565
- }
2566
- function validatePayloadReachability(primary, types) {
2567
- const reachable = /* @__PURE__ */ new Set();
2568
- for (const root of ["QRLDomain", primary]) {
2569
- for (const t of collectDependencies(root, types)) reachable.add(t);
4166
+ /**
4167
+ * Disconnect from wallet and clean up. Returns once the TERMINATE has
4168
+ * either been flushed to the relay or the 800ms best-effort window has
4169
+ * elapsed - the wallet gets an instant disconnect instead of landing in
4170
+ * its stale-session grace period.
4171
+ */
4172
+ disconnect() {
4173
+ if (this.disconnectInFlight) return this.disconnectInFlight;
4174
+ if (this.lifecycleTransition) {
4175
+ return Promise.reject(new Error("Connection lifecycle transition is already in progress"));
4176
+ }
4177
+ this.lifecycleTransition = "disconnect";
4178
+ this.cancelAllRequests(new Error("Disconnected"));
4179
+ this.clearAllInflight();
4180
+ this.teardownResumeListeners();
4181
+ const task = Promise.resolve().then(() => this.connectionManager.disconnect()).finally(() => {
4182
+ this.lifecycleTransition = null;
4183
+ if (this.disconnectInFlight === task) this.disconnectInFlight = null;
4184
+ });
4185
+ this.disconnectInFlight = task;
4186
+ return task;
2570
4187
  }
2571
- for (const k of Object.keys(types)) {
2572
- if (!reachable.has(k)) throw new Error(`unused referenced type: ${k}`);
4188
+ };
4189
+
4190
+ // src/signing/messageDigest.ts
4191
+ function computeMessageDigest(messageBytes) {
4192
+ if (!(messageBytes instanceof Uint8Array)) {
4193
+ throw new Error("messageBytes must be a Uint8Array");
2573
4194
  }
2574
- }
2575
- function computeTypedDataDigest(payload) {
2576
- if (!payload || typeof payload !== "object") {
2577
- throw new Error("invalid typed data payload");
2578
- }
2579
- validateTypeMap(payload.types);
2580
- validateDomainTypes(payload.types);
2581
- validatePayloadReachability(payload.primaryType, payload.types);
2582
- const domainHash = hashStruct("QRLDomain", payload.domain, payload.types);
2583
- const messageHash = hashStruct(payload.primaryType, payload.message, payload.types);
2584
- return shake256Digest(concatBytes(SCHEME_TAG_TYPED, domainHash, messageHash), DIGEST_LEN);
4195
+ return shake256Digest(concatBytes(SCHEME_TAG_MSG, messageBytes), DIGEST_LEN);
2585
4196
  }
2586
4197
 
2587
4198
  // src/signing/verify.ts
4199
+ var ML_DSA_87_DESCRIPTOR_TYPE = 1;
4200
+ var ML_DSA_DESCRIPTOR_BYTES = 3;
4201
+ var ML_DSA_87_PUBLIC_KEY_BYTES = 2592;
4202
+ var ML_DSA_87_SIGNATURE_BYTES = 4627;
4203
+ var CURRENT_QRL_ADDRESS_BYTES = 20;
2588
4204
  function bytesOrHex(v) {
2589
- if (v instanceof Uint8Array) return v;
4205
+ if (v instanceof Uint8Array) return new Uint8Array(v);
2590
4206
  if (typeof v === "string") return hexToBytes(v);
2591
4207
  throw new Error("expected Uint8Array or 0x-hex string");
2592
4208
  }
2593
- function verifyMessage({
4209
+ function fixedBytesOrHex(value, expectedBytes) {
4210
+ if (value instanceof Uint8Array) {
4211
+ return value.length === expectedBytes ? new Uint8Array(value) : void 0;
4212
+ }
4213
+ if (typeof value !== "string" || value.length !== expectedBytes * 2 + 2 || !/^0x[0-9a-fA-F]+$/.test(value)) {
4214
+ return void 0;
4215
+ }
4216
+ return hexToBytes(value);
4217
+ }
4218
+ function verifyMessageBytes(signature, publicKey, messageBytes) {
4219
+ const digest = computeMessageDigest(messageBytes);
4220
+ return mldsaVerify(signature, digest, publicKey, SCHEME_TAG_MSG);
4221
+ }
4222
+ function verifyTypedDataBytes(signature, publicKey, payload) {
4223
+ const digest = computeTypedDataDigest(payload);
4224
+ return mldsaVerify(signature, digest, publicKey, SCHEME_TAG_TYPED);
4225
+ }
4226
+ function verifyMessageSignature({
2594
4227
  signature,
2595
4228
  publicKey,
2596
4229
  messageBytes
2597
4230
  }) {
2598
4231
  try {
2599
- const sig = bytesOrHex(signature);
2600
- const pk = bytesOrHex(publicKey);
4232
+ const sig = fixedBytesOrHex(signature, ML_DSA_87_SIGNATURE_BYTES);
4233
+ const pk = fixedBytesOrHex(publicKey, ML_DSA_87_PUBLIC_KEY_BYTES);
4234
+ if (!sig || !pk) return false;
2601
4235
  const msg = bytesOrHex(messageBytes);
2602
- const digest = computeMessageDigest(msg);
2603
- return mldsaVerify(sig, digest, pk, SCHEME_TAG_MSG);
4236
+ return verifyMessageBytes(sig, pk, msg);
4237
+ } catch {
4238
+ return false;
4239
+ }
4240
+ }
4241
+ function verifyTypedDataSignature({
4242
+ signature,
4243
+ publicKey,
4244
+ payload
4245
+ }) {
4246
+ try {
4247
+ const sig = fixedBytesOrHex(signature, ML_DSA_87_SIGNATURE_BYTES);
4248
+ const pk = fixedBytesOrHex(publicKey, ML_DSA_87_PUBLIC_KEY_BYTES);
4249
+ if (!sig || !pk) return false;
4250
+ return verifyTypedDataBytes(sig, pk, payload);
4251
+ } catch {
4252
+ return false;
4253
+ }
4254
+ }
4255
+ function publicKeyMatchesSigner(expectedSigner, descriptor, publicKey) {
4256
+ if (!isCurrentQrlAddress(expectedSigner)) return false;
4257
+ if (descriptor[0] !== ML_DSA_87_DESCRIPTOR_TYPE) {
4258
+ return false;
4259
+ }
4260
+ const addressBytes = shake256Digest(
4261
+ concatBytes(descriptor, publicKey),
4262
+ CURRENT_QRL_ADDRESS_BYTES
4263
+ );
4264
+ const derived = `Q${bytesToHex(addressBytes).slice(2)}`;
4265
+ return derived.toLowerCase() === expectedSigner.toLowerCase();
4266
+ }
4267
+ function verifyMessageForSigner({
4268
+ expectedSigner,
4269
+ descriptor,
4270
+ signature,
4271
+ publicKey,
4272
+ messageBytes
4273
+ }) {
4274
+ try {
4275
+ if (descriptor === void 0) return false;
4276
+ const descriptorBytes = fixedBytesOrHex(descriptor, ML_DSA_DESCRIPTOR_BYTES);
4277
+ const publicKeyBytes = fixedBytesOrHex(publicKey, ML_DSA_87_PUBLIC_KEY_BYTES);
4278
+ const signatureBytes = fixedBytesOrHex(signature, ML_DSA_87_SIGNATURE_BYTES);
4279
+ if (!descriptorBytes || !publicKeyBytes || !signatureBytes) return false;
4280
+ const message = bytesOrHex(messageBytes);
4281
+ return publicKeyMatchesSigner(expectedSigner, descriptorBytes, publicKeyBytes) && verifyMessageBytes(signatureBytes, publicKeyBytes, message);
2604
4282
  } catch {
2605
4283
  return false;
2606
4284
  }
2607
4285
  }
2608
- function verifyTypedData({ signature, publicKey, payload }) {
4286
+ function verifyTypedDataForSigner({
4287
+ expectedSigner,
4288
+ descriptor,
4289
+ signature,
4290
+ publicKey,
4291
+ payload
4292
+ }) {
2609
4293
  try {
2610
- const sig = bytesOrHex(signature);
2611
- const pk = bytesOrHex(publicKey);
2612
- const digest = computeTypedDataDigest(payload);
2613
- return mldsaVerify(sig, digest, pk, SCHEME_TAG_TYPED);
4294
+ if (descriptor === void 0) return false;
4295
+ const descriptorBytes = fixedBytesOrHex(descriptor, ML_DSA_DESCRIPTOR_BYTES);
4296
+ const publicKeyBytes = fixedBytesOrHex(publicKey, ML_DSA_87_PUBLIC_KEY_BYTES);
4297
+ const signatureBytes = fixedBytesOrHex(signature, ML_DSA_87_SIGNATURE_BYTES);
4298
+ if (!descriptorBytes || !publicKeyBytes || !signatureBytes) return false;
4299
+ return publicKeyMatchesSigner(expectedSigner, descriptorBytes, publicKeyBytes) && verifyTypedDataBytes(signatureBytes, publicKeyBytes, payload);
2614
4300
  } catch {
2615
4301
  return false;
2616
4302
  }
2617
4303
  }
4304
+ function verifyMessage(params) {
4305
+ return verifyMessageSignature(params);
4306
+ }
4307
+ function verifyTypedData(params) {
4308
+ return verifyTypedDataSignature(params);
4309
+ }
2618
4310
  export {
2619
4311
  BLOB_LEN,
4312
+ CAP_LEN,
2620
4313
  CID_LEN,
2621
4314
  ConnectionManager,
2622
4315
  ConnectionStatus,
2623
4316
  DEFAULT_RELAY_URL,
2624
4317
  DIGEST_LEN,
4318
+ EXPLICITLY_UNSUPPORTED_METHODS,
2625
4319
  FP_LEN,
2626
4320
  KeyExchange,
2627
4321
  KeyExchangeMessageType,
4322
+ MAX_CONNECTION_URI_LENGTH,
4323
+ MAX_JSON_RPC_ID_LENGTH,
4324
+ MAX_JSON_RPC_METHOD_LENGTH,
4325
+ MAX_RELAY_URL_LENGTH,
4326
+ ML_DSA_87_PUBLIC_KEY_BYTES,
4327
+ ML_DSA_87_SIGNATURE_BYTES,
4328
+ ML_DSA_DESCRIPTOR_BYTES,
2628
4329
  MessageType,
4330
+ PAIRING_CAPABILITY_LEN,
2629
4331
  PROTOCOL_VERSION,
2630
4332
  QRLConnectProvider as QRLConnect,
2631
4333
  QRLConnectProvider,
@@ -2636,12 +4338,14 @@ export {
2636
4338
  SCHEME_VERSION_MSG,
2637
4339
  SCHEME_VERSION_TYPED,
2638
4340
  SocketClient,
4341
+ TYPED_DATA_LIMITS,
2639
4342
  UNRESTRICTED_METHODS,
2640
4343
  attemptWalletRedirect,
2641
4344
  bytesToHex,
2642
4345
  cidFromString,
2643
4346
  cidRandom,
2644
4347
  cidToString,
4348
+ classifyRpcMethod,
2645
4349
  computeFingerprint,
2646
4350
  computeMessageDigest,
2647
4351
  computeTypedDataDigest,
@@ -2651,11 +4355,22 @@ export {
2651
4355
  fingerprintEquals,
2652
4356
  generateConnectionURI,
2653
4357
  getAppStoreUrl,
4358
+ hasSigningDescriptor,
2654
4359
  hashStruct,
2655
4360
  hexToBytes,
2656
4361
  isMobileBrowser,
4362
+ isQrlSignedMessageResult,
4363
+ isQrlSignedResult,
4364
+ isQrlSignedTypedDataResult,
4365
+ isValidJsonRpcId,
4366
+ isValidJsonRpcMethod,
4367
+ normalizeRelayUrl,
2657
4368
  parseConnectionURI,
2658
4369
  typeHash,
2659
4370
  verifyMessage,
2660
- verifyTypedData
4371
+ verifyMessageForSigner,
4372
+ verifyMessageSignature,
4373
+ verifyTypedData,
4374
+ verifyTypedDataForSigner,
4375
+ verifyTypedDataSignature
2661
4376
  };