@qrlwallet/connect 3.2.0 → 3.3.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.js CHANGED
@@ -631,6 +631,7 @@ var KeyExchange = class extends import_eventemitter3.default {
631
631
  var import_socket = require("socket.io-client");
632
632
  var import_eventemitter32 = __toESM(require("eventemitter3"));
633
633
  var PENDING_JOIN_TIMEOUT_MS = 2e4;
634
+ var SEND_FLUSH_TIMEOUT_MS = 600;
634
635
  var SocketClient = class extends import_eventemitter32.default {
635
636
  constructor(relayUrl, clientType) {
636
637
  super();
@@ -821,6 +822,48 @@ var SocketClient = class extends import_eventemitter32.default {
821
822
  );
822
823
  });
823
824
  }
825
+ /**
826
+ * Emit an event and resolve once the relay acks it, or after a bounded
827
+ * flush window. Lets a caller await transmission before tearing the
828
+ * socket down.
829
+ */
830
+ flushEmit(event, payload) {
831
+ return new Promise((resolve) => {
832
+ if (!this.socket?.connected) {
833
+ resolve();
834
+ return;
835
+ }
836
+ let settled = false;
837
+ const done = () => {
838
+ if (settled) return;
839
+ settled = true;
840
+ resolve();
841
+ };
842
+ const timer = setTimeout(done, SEND_FLUSH_TIMEOUT_MS);
843
+ this.socket.emit(event, payload, () => {
844
+ clearTimeout(timer);
845
+ done();
846
+ });
847
+ });
848
+ }
849
+ /**
850
+ * Explicitly terminate the channel on the relay (durable tombstone), as
851
+ * opposed to the transient leaveChannel(). Used when the session is dead
852
+ * in a way an encrypted TERMINATE cannot communicate (AEAD desync: the
853
+ * peer could not open it). Resolves once the close is flushed or the
854
+ * bounded window elapses, so the caller can safely disconnect afterwards.
855
+ */
856
+ closeChannel() {
857
+ const channelId = this.channelId;
858
+ this.channelId = null;
859
+ if (this.pendingJoin) {
860
+ clearTimeout(this.pendingJoin.watchdog);
861
+ this.pendingJoin.reject(new Error("Channel closed before join completed"));
862
+ this.pendingJoin = null;
863
+ }
864
+ if (!this.socket?.connected || !channelId) return Promise.resolve();
865
+ return this.flushEmit("close_channel", { channelId });
866
+ }
824
867
  /**
825
868
  * Leave the current channel.
826
869
  */
@@ -1121,11 +1164,13 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
1121
1164
  this.connectedAccounts = [];
1122
1165
  this.unresponsiveTimer = null;
1123
1166
  this.reconnectProbeTimer = null;
1167
+ this.restoreInFlight = null;
1124
1168
  this.failedReconnects = 0;
1125
1169
  this.walletPresent = false;
1126
1170
  this.pendingRestore = null;
1127
1171
  this.messageQueue = Promise.resolve();
1128
1172
  this.outboundQueue = Promise.resolve();
1173
+ this.consecutiveDecryptFailures = 0;
1129
1174
  this.dappMetadata = options.dappMetadata;
1130
1175
  this.relayUrl = options.relayUrl ?? DEFAULT_RELAY_URL;
1131
1176
  this.chainId = options.chainId ?? "0x0";
@@ -1244,6 +1289,7 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
1244
1289
  this.clearReconnectProbe();
1245
1290
  this.pendingRestore = null;
1246
1291
  this.walletPresent = false;
1292
+ this.consecutiveDecryptFailures = 0;
1247
1293
  if (this.keyExchange) {
1248
1294
  this.keyExchange.reset();
1249
1295
  } else {
@@ -1282,9 +1328,18 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
1282
1328
  * Returns false if there is nothing to restore.
1283
1329
  */
1284
1330
  async reconnect() {
1331
+ if (this.restoreInFlight) return this.restoreInFlight;
1332
+ const task = this.reconnectNow().finally(() => {
1333
+ this.restoreInFlight = null;
1334
+ });
1335
+ this.restoreInFlight = task;
1336
+ return task;
1337
+ }
1338
+ async reconnectNow() {
1285
1339
  if (!this.pendingRestore) return false;
1286
1340
  this.setStatus("reconnecting" /* RECONNECTING */);
1287
1341
  this.walletPresent = false;
1342
+ this.consecutiveDecryptFailures = 0;
1288
1343
  try {
1289
1344
  const session = await KeyExchange.sessionFromPersisted(this.pendingRestore.keyExchange);
1290
1345
  this.keyExchange = new KeyExchange(true, session);
@@ -1360,15 +1415,53 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
1360
1415
  }
1361
1416
  }
1362
1417
  }
1418
+ /**
1419
+ * Make sure the socket is open and joined to the paired session's channel
1420
+ * so an outbound request can be routed - or relay-buffered if the wallet is
1421
+ * currently absent (its socket dies within seconds of the app
1422
+ * backgrounding; the relay holds channel traffic for it). Revives the
1423
+ * socket after a probe-timeout teardown and hydrates a cold stored
1424
+ * session. Returns false when there is no session to revive (never paired,
1425
+ * explicit disconnect, or the channel was tombstoned).
1426
+ */
1427
+ async ensureChannelJoined() {
1428
+ if (this.keyExchange?.areKeysExchanged()) {
1429
+ if (this.socketClient.isConnected() && this.socketClient.getChannelId()) {
1430
+ return true;
1431
+ }
1432
+ this.setStatus("reconnecting" /* RECONNECTING */);
1433
+ this.walletPresent = false;
1434
+ return this.joinAndSettle();
1435
+ }
1436
+ if (this.pendingRestore) {
1437
+ return this.reconnect();
1438
+ }
1439
+ return false;
1440
+ }
1441
+ /**
1442
+ * True while a pairing exists (handshake complete, session keys in
1443
+ * memory), even when the wallet's socket is momentarily out of the
1444
+ * channel. Survives the reconnect-probe teardown; false after an explicit
1445
+ * disconnect or a relay tombstone.
1446
+ */
1447
+ isPaired() {
1448
+ return this.keyExchange?.areKeysExchanged() ?? false;
1449
+ }
1450
+ /** True while the relay roster last showed the wallet in the channel. */
1451
+ isWalletPresent() {
1452
+ return this.walletPresent;
1453
+ }
1363
1454
  /**
1364
1455
  * A session was terminated for good (wallet sent a relay 'close', or the
1365
1456
  * join ack reported a tombstone). Clear local state and surface
1366
1457
  * DISCONNECTED so the consumer drops to a fresh-pairing UI.
1367
1458
  */
1368
1459
  handleSessionTerminated() {
1460
+ this.emit("session_terminated");
1369
1461
  this.clearReconnectProbe();
1370
1462
  this.clearUnresponsiveTimer();
1371
1463
  this.walletPresent = false;
1464
+ this.consecutiveDecryptFailures = 0;
1372
1465
  this.pendingRestore = null;
1373
1466
  this.keyExchange = null;
1374
1467
  this.socketClient.leaveChannel();
@@ -1397,22 +1490,28 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
1397
1490
  }
1398
1491
  }
1399
1492
  /**
1400
- * Send a JSON-RPC request to the wallet (async, fire-and-forget).
1493
+ * Send a JSON-RPC request to the wallet. Returns the outbound send promise
1494
+ * (settled on the relay's ack) so callers that must sequence on delivery -
1495
+ * e.g. the provider's wallet-wake redirect, which cannot navigate away
1496
+ * before the ciphertext reaches the relay - can await it. Callers may also
1497
+ * ignore it: failures are logged here either way.
1401
1498
  */
1402
1499
  sendJsonRpc(request) {
1403
1500
  if (!this.keyExchange?.areKeysExchanged()) {
1404
1501
  throw new Error("Not connected: key exchange not complete");
1405
1502
  }
1406
- this.sendEncrypted({
1503
+ const sent = this.sendEncrypted({
1407
1504
  type: "jsonrpc" /* JSONRPC */,
1408
1505
  jsonrpc: "2.0",
1409
1506
  id: request.id,
1410
1507
  method: request.method,
1411
1508
  params: request.params
1412
- }).catch((err) => {
1509
+ });
1510
+ sent.catch((err) => {
1413
1511
  error("ConnectionManager", "Failed to send JSON-RPC:", err);
1414
1512
  });
1415
1513
  this.startUnresponsiveTimer();
1514
+ return sent;
1416
1515
  }
1417
1516
  getStatus() {
1418
1517
  return this.status;
@@ -1526,8 +1625,19 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
1526
1625
  }
1527
1626
  }
1528
1627
  if (typeof message === "string" && this.keyExchange?.areKeysExchanged()) {
1628
+ let decrypted;
1629
+ try {
1630
+ decrypted = await this.keyExchange.decryptMessage(message);
1631
+ } catch (err) {
1632
+ error("ConnectionManager", "Failed to decrypt message:", err);
1633
+ this.consecutiveDecryptFailures++;
1634
+ if (this.consecutiveDecryptFailures >= _ConnectionManager.MAX_DECRYPT_FAILURES) {
1635
+ await this.teardownDesyncedSession();
1636
+ }
1637
+ return;
1638
+ }
1639
+ this.consecutiveDecryptFailures = 0;
1529
1640
  try {
1530
- const decrypted = await this.keyExchange.decryptMessage(message);
1531
1641
  await this.persistSession();
1532
1642
  const parsed = JSON.parse(decrypted);
1533
1643
  if (isRecord(parsed)) {
@@ -1536,10 +1646,24 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
1536
1646
  warn("ConnectionManager", "Dropping non-object decrypted payload");
1537
1647
  }
1538
1648
  } catch (err) {
1539
- error("ConnectionManager", "Failed to decrypt message:", err);
1649
+ error("ConnectionManager", "Failed to handle decrypted message:", err);
1540
1650
  }
1541
1651
  }
1542
1652
  }
1653
+ /**
1654
+ * The receive stream is cryptographically unrecoverable: the peer's send
1655
+ * counter is ahead of our recv counter for good (relay buffer TTL/cap
1656
+ * dropped a ciphertext), so every future message would fail its tag.
1657
+ * An encrypted TERMINATE cannot communicate this (the desynced peer could
1658
+ * not open it either); tombstone the channel on the relay instead so the
1659
+ * wallet learns the pairing is dead even if it only re-joins later, then
1660
+ * clear local state and surface the standard terminated teardown.
1661
+ */
1662
+ async teardownDesyncedSession() {
1663
+ warn("ConnectionManager", "AEAD stream desynced beyond recovery; terminating session");
1664
+ await this.socketClient.closeChannel();
1665
+ this.handleSessionTerminated();
1666
+ }
1543
1667
  async handleSynAck(msg) {
1544
1668
  if (!this.keyExchange) return;
1545
1669
  this.setStatus("key_exchange" /* KEY_EXCHANGE */);
@@ -1593,6 +1717,7 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
1593
1717
  }
1594
1718
  case "terminate" /* TERMINATE */: {
1595
1719
  log("ConnectionManager", "Received terminate from wallet");
1720
+ this.emit("session_terminated");
1596
1721
  void this.disconnect();
1597
1722
  break;
1598
1723
  }
@@ -1712,6 +1837,12 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
1712
1837
  }
1713
1838
  };
1714
1839
  _ConnectionManager.MAX_RECONNECT_FAILURES = 5;
1840
+ // Nonces derive from the recv counter and there is no gap tolerance, so a
1841
+ // genuinely desynced stream (peer advanced past us, e.g. the relay's
1842
+ // buffer TTL dropped a ciphertext) fails EVERY subsequent open. Two in a
1843
+ // row cannot happen on a healthy stream; requiring the second guards
1844
+ // against a one-off injected junk ciphertext killing a live session.
1845
+ _ConnectionManager.MAX_DECRYPT_FAILURES = 2;
1715
1846
  var ConnectionManager = _ConnectionManager;
1716
1847
 
1717
1848
  // src/utils/platform.ts
@@ -1769,6 +1900,16 @@ var QRL_CONNECT_PROVIDER_INFO = {
1769
1900
  };
1770
1901
  var EIP6963_ANNOUNCE_EVENT = "eip6963:announceProvider";
1771
1902
  var EIP6963_REQUEST_EVENT = "eip6963:requestProvider";
1903
+ function isRecordObj(v) {
1904
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1905
+ }
1906
+ function parseInflightRecord(v) {
1907
+ if (!isRecordObj(v)) return null;
1908
+ const { id, method, ts } = v;
1909
+ if (typeof id !== "string" && typeof id !== "number") return null;
1910
+ if (typeof method !== "string" || typeof ts !== "number") return null;
1911
+ return { id, method, ts };
1912
+ }
1772
1913
  var QRLConnectProvider = class extends import_eventemitter34.default {
1773
1914
  constructor(options) {
1774
1915
  super();
@@ -1777,6 +1918,7 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
1777
1918
  this.eip6963RequestListener = null;
1778
1919
  this.resumeListener = null;
1779
1920
  this.resumeDebounce = null;
1921
+ this.orphanedRequests = /* @__PURE__ */ new Map();
1780
1922
  // Random per-instance prefix keeps request ids unique across page loads.
1781
1923
  // A bare counter restarts at 1 on reload, and the relay buffers messages
1782
1924
  // for 5 minutes, so a stale buffered response could otherwise be matched
@@ -1785,6 +1927,10 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
1785
1927
  this.requestCounter = 0;
1786
1928
  this.isQRLConnect = true;
1787
1929
  this.options = options;
1930
+ this.inflightKey = `${options.storageKey ?? `${STORAGE_KEY_PREFIX}:session`}:inflight`;
1931
+ for (const rec of this.readInflight()) {
1932
+ this.orphanedRequests.set(rec.id, { method: rec.method });
1933
+ }
1788
1934
  if (options.debug) {
1789
1935
  setDebug(true);
1790
1936
  }
@@ -1897,6 +2043,16 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
1897
2043
  this.connectionManager.on("jsonrpc_response", (response) => {
1898
2044
  const pending = this.pendingRequests.get(response.id);
1899
2045
  if (!pending) {
2046
+ const orphan = this.orphanedRequests.get(response.id);
2047
+ if (orphan) {
2048
+ this.settleInflight(response.id);
2049
+ this.emit("late_response", {
2050
+ id: response.id,
2051
+ method: orphan.method,
2052
+ ...response.error ? { error: response.error } : { result: response.result }
2053
+ });
2054
+ return;
2055
+ }
1900
2056
  warn("Provider", `No pending request for id ${response.id}`);
1901
2057
  return;
1902
2058
  }
@@ -1925,6 +2081,13 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
1925
2081
  }
1926
2082
  this.pendingRequests.clear();
1927
2083
  });
2084
+ this.connectionManager.on("session_terminated", () => {
2085
+ for (const [, pending] of this.pendingRequests) {
2086
+ pending.reject(new Error("Session terminated by wallet"));
2087
+ }
2088
+ this.pendingRequests.clear();
2089
+ this.clearAllInflight();
2090
+ });
1928
2091
  }
1929
2092
  /**
1930
2093
  * Generate a connection URI for QR code display or deep link redirect.
@@ -1961,7 +2124,13 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
1961
2124
  throw new Error(`Unsupported method: ${method}`);
1962
2125
  }
1963
2126
  if (this.connectionManager.getStatus() !== "connected" /* CONNECTED */) {
1964
- throw new Error("Not connected to QRL Wallet");
2127
+ if (method === "qrl_requestAccounts" && this.connectionManager.isPaired() && this.connectionManager.getAccounts().length > 0) {
2128
+ return this.connectionManager.getAccounts();
2129
+ }
2130
+ const joined = await this.connectionManager.ensureChannelJoined();
2131
+ if (!joined) {
2132
+ throw new Error("Not connected to QRL Wallet");
2133
+ }
1965
2134
  }
1966
2135
  const id = `${this.requestIdPrefix}-${++this.requestCounter}`;
1967
2136
  return new Promise((resolve, reject) => {
@@ -1984,20 +2153,109 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
1984
2153
  const originalReject = pending.reject;
1985
2154
  pending.resolve = (result) => {
1986
2155
  clearTimeout(timeout);
2156
+ this.settleInflight(id);
1987
2157
  originalResolve(result);
1988
2158
  };
1989
2159
  pending.reject = (error2) => {
1990
2160
  clearTimeout(timeout);
2161
+ this.settleInflight(id);
1991
2162
  originalReject(error2);
1992
2163
  };
1993
- this.connectionManager.sendJsonRpc({
2164
+ if (RESTRICTED_METHODS.has(method)) {
2165
+ this.persistInflight(id, method);
2166
+ }
2167
+ const sent = this.connectionManager.sendJsonRpc({
1994
2168
  jsonrpc: "2.0",
1995
2169
  id,
1996
2170
  method,
1997
2171
  params
1998
2172
  });
2173
+ sent.catch((err) => {
2174
+ const failed = this.pendingRequests.get(id);
2175
+ if (!failed) return;
2176
+ this.pendingRequests.delete(id);
2177
+ failed.reject(err instanceof Error ? err : new Error(String(err)));
2178
+ });
2179
+ if (this.shouldRedirectToWallet(method)) {
2180
+ void sent.then(
2181
+ () => attemptWalletRedirect(this.walletWakeUri()),
2182
+ () => void 0
2183
+ );
2184
+ }
1999
2185
  });
2000
2186
  }
2187
+ /**
2188
+ * Redirect only for calls the user must approve, on mobile, and only when
2189
+ * the wallet is actually absent from the channel: right after pairing its
2190
+ * socket lingers and a pointless app flash would be jarring.
2191
+ *
2192
+ * qrl_requestAccounts is excluded even though it is restricted: dApps call
2193
+ * it on page load to restore a session, so it can run with no user gesture
2194
+ * (an unsolicited "Open in MyQRLWallet?" sheet on load) and pairing UIs
2195
+ * already own their own deep-link step.
2196
+ */
2197
+ shouldRedirectToWallet(method) {
2198
+ return this.options.walletRedirectOnRequest !== false && RESTRICTED_METHODS.has(method) && method !== "qrl_requestAccounts" && isMobileBrowser() && !this.connectionManager.isWalletPresent();
2199
+ }
2200
+ // ── In-flight persistence (survive the return-redirect reload) ──────
2201
+ readInflight() {
2202
+ if (typeof localStorage === "undefined") return [];
2203
+ try {
2204
+ const raw = localStorage.getItem(this.inflightKey);
2205
+ if (!raw) return [];
2206
+ const parsed = JSON.parse(raw);
2207
+ if (!Array.isArray(parsed)) return [];
2208
+ const now = Date.now();
2209
+ const records = [];
2210
+ for (const v of parsed) {
2211
+ const rec = parseInflightRecord(v);
2212
+ if (rec && now - rec.ts <= REQUEST_TIMEOUT_MS) records.push(rec);
2213
+ }
2214
+ return records;
2215
+ } catch {
2216
+ return [];
2217
+ }
2218
+ }
2219
+ writeInflight(records) {
2220
+ if (typeof localStorage === "undefined") return;
2221
+ try {
2222
+ if (records.length === 0) {
2223
+ localStorage.removeItem(this.inflightKey);
2224
+ } else {
2225
+ localStorage.setItem(this.inflightKey, JSON.stringify(records));
2226
+ }
2227
+ } catch {
2228
+ }
2229
+ }
2230
+ persistInflight(id, method) {
2231
+ const records = this.readInflight().filter((r) => r.id !== id);
2232
+ records.push({ id, method, ts: Date.now() });
2233
+ this.writeInflight(records);
2234
+ }
2235
+ settleInflight(id) {
2236
+ this.orphanedRequests.delete(id);
2237
+ this.writeInflight(this.readInflight().filter((r) => r.id !== id));
2238
+ }
2239
+ clearAllInflight() {
2240
+ this.orphanedRequests.clear();
2241
+ this.writeInflight([]);
2242
+ }
2243
+ /**
2244
+ * Wake URI for the wallet app. Carries no pairing payload: the app
2245
+ * foregrounds on any `qrlconnect:` URL and re-joins its sessions (draining
2246
+ * the buffered request); the cid lets a future wallet jump straight to the
2247
+ * right approval.
2248
+ *
2249
+ * MUST stay query-only (no host/path). The mobile app routes deep links
2250
+ * with expo-router, which maps anything after the scheme to an in-app
2251
+ * route: `qrlconnect://resume?...` lands on the "+not-found" screen and
2252
+ * unmounts the WebView, so the wake achieves nothing. The hostless form
2253
+ * routes to the WebView tab exactly like the pairing URIs
2254
+ * (`qrlconnect://?q=...`), which is the whole point of the wake.
2255
+ */
2256
+ walletWakeUri() {
2257
+ return `qrlconnect://?wake=${encodeURIComponent(this.connectionManager.getChannelId())}`;
2258
+ }
2001
2259
  /**
2002
2260
  * Get the current connection status.
2003
2261
  */
@@ -2028,6 +2286,19 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
2028
2286
  hasStoredSession() {
2029
2287
  return this.connectionManager.hasStoredSession();
2030
2288
  }
2289
+ /**
2290
+ * True while a pairing exists (keys exchanged), even when the wallet app
2291
+ * is backgrounded and its socket is out of the channel. Combine with
2292
+ * getStatus() to tell "wallet away, session intact" (paired + WAITING)
2293
+ * apart from "waiting for a first scan" in pairing UIs.
2294
+ */
2295
+ isPaired() {
2296
+ return this.connectionManager.isPaired();
2297
+ }
2298
+ /** True while the wallet is actually present in the relay channel. */
2299
+ isWalletPresent() {
2300
+ return this.connectionManager.isWalletPresent();
2301
+ }
2031
2302
  /**
2032
2303
  * Reset the connection and start a fresh pairing with a new channel.
2033
2304
  * Use this when the user wants to create a new connection instead of
@@ -2038,6 +2309,7 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
2038
2309
  pending.reject(new Error("Connection reset"));
2039
2310
  }
2040
2311
  this.pendingRequests.clear();
2312
+ this.clearAllInflight();
2041
2313
  await this.connectionManager.resetForNewChannel();
2042
2314
  return this.getConnectionURI();
2043
2315
  }
@@ -2052,6 +2324,7 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
2052
2324
  pending.reject(new Error("Disconnected"));
2053
2325
  }
2054
2326
  this.pendingRequests.clear();
2327
+ this.clearAllInflight();
2055
2328
  this.teardownResumeListeners();
2056
2329
  await this.connectionManager.disconnect();
2057
2330
  }