@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.mjs CHANGED
@@ -554,6 +554,7 @@ var KeyExchange = class extends EventEmitter {
554
554
  import { io } from "socket.io-client";
555
555
  import EventEmitter2 from "eventemitter3";
556
556
  var PENDING_JOIN_TIMEOUT_MS = 2e4;
557
+ var SEND_FLUSH_TIMEOUT_MS = 600;
557
558
  var SocketClient = class extends EventEmitter2 {
558
559
  constructor(relayUrl, clientType) {
559
560
  super();
@@ -744,6 +745,48 @@ var SocketClient = class extends EventEmitter2 {
744
745
  );
745
746
  });
746
747
  }
748
+ /**
749
+ * Emit an event and resolve once the relay acks it, or after a bounded
750
+ * flush window. Lets a caller await transmission before tearing the
751
+ * socket down.
752
+ */
753
+ flushEmit(event, payload) {
754
+ return new Promise((resolve) => {
755
+ if (!this.socket?.connected) {
756
+ resolve();
757
+ return;
758
+ }
759
+ let settled = false;
760
+ const done = () => {
761
+ if (settled) return;
762
+ settled = true;
763
+ resolve();
764
+ };
765
+ const timer = setTimeout(done, SEND_FLUSH_TIMEOUT_MS);
766
+ this.socket.emit(event, payload, () => {
767
+ clearTimeout(timer);
768
+ done();
769
+ });
770
+ });
771
+ }
772
+ /**
773
+ * Explicitly terminate the channel on the relay (durable tombstone), as
774
+ * opposed to the transient leaveChannel(). Used when the session is dead
775
+ * in a way an encrypted TERMINATE cannot communicate (AEAD desync: the
776
+ * peer could not open it). Resolves once the close is flushed or the
777
+ * bounded window elapses, so the caller can safely disconnect afterwards.
778
+ */
779
+ closeChannel() {
780
+ const channelId = this.channelId;
781
+ this.channelId = null;
782
+ if (this.pendingJoin) {
783
+ clearTimeout(this.pendingJoin.watchdog);
784
+ this.pendingJoin.reject(new Error("Channel closed before join completed"));
785
+ this.pendingJoin = null;
786
+ }
787
+ if (!this.socket?.connected || !channelId) return Promise.resolve();
788
+ return this.flushEmit("close_channel", { channelId });
789
+ }
747
790
  /**
748
791
  * Leave the current channel.
749
792
  */
@@ -1044,11 +1087,13 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1044
1087
  this.connectedAccounts = [];
1045
1088
  this.unresponsiveTimer = null;
1046
1089
  this.reconnectProbeTimer = null;
1090
+ this.restoreInFlight = null;
1047
1091
  this.failedReconnects = 0;
1048
1092
  this.walletPresent = false;
1049
1093
  this.pendingRestore = null;
1050
1094
  this.messageQueue = Promise.resolve();
1051
1095
  this.outboundQueue = Promise.resolve();
1096
+ this.consecutiveDecryptFailures = 0;
1052
1097
  this.dappMetadata = options.dappMetadata;
1053
1098
  this.relayUrl = options.relayUrl ?? DEFAULT_RELAY_URL;
1054
1099
  this.chainId = options.chainId ?? "0x0";
@@ -1167,6 +1212,7 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1167
1212
  this.clearReconnectProbe();
1168
1213
  this.pendingRestore = null;
1169
1214
  this.walletPresent = false;
1215
+ this.consecutiveDecryptFailures = 0;
1170
1216
  if (this.keyExchange) {
1171
1217
  this.keyExchange.reset();
1172
1218
  } else {
@@ -1205,9 +1251,18 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1205
1251
  * Returns false if there is nothing to restore.
1206
1252
  */
1207
1253
  async reconnect() {
1254
+ if (this.restoreInFlight) return this.restoreInFlight;
1255
+ const task = this.reconnectNow().finally(() => {
1256
+ this.restoreInFlight = null;
1257
+ });
1258
+ this.restoreInFlight = task;
1259
+ return task;
1260
+ }
1261
+ async reconnectNow() {
1208
1262
  if (!this.pendingRestore) return false;
1209
1263
  this.setStatus("reconnecting" /* RECONNECTING */);
1210
1264
  this.walletPresent = false;
1265
+ this.consecutiveDecryptFailures = 0;
1211
1266
  try {
1212
1267
  const session = await KeyExchange.sessionFromPersisted(this.pendingRestore.keyExchange);
1213
1268
  this.keyExchange = new KeyExchange(true, session);
@@ -1283,15 +1338,53 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1283
1338
  }
1284
1339
  }
1285
1340
  }
1341
+ /**
1342
+ * Make sure the socket is open and joined to the paired session's channel
1343
+ * so an outbound request can be routed - or relay-buffered if the wallet is
1344
+ * currently absent (its socket dies within seconds of the app
1345
+ * backgrounding; the relay holds channel traffic for it). Revives the
1346
+ * socket after a probe-timeout teardown and hydrates a cold stored
1347
+ * session. Returns false when there is no session to revive (never paired,
1348
+ * explicit disconnect, or the channel was tombstoned).
1349
+ */
1350
+ async ensureChannelJoined() {
1351
+ if (this.keyExchange?.areKeysExchanged()) {
1352
+ if (this.socketClient.isConnected() && this.socketClient.getChannelId()) {
1353
+ return true;
1354
+ }
1355
+ this.setStatus("reconnecting" /* RECONNECTING */);
1356
+ this.walletPresent = false;
1357
+ return this.joinAndSettle();
1358
+ }
1359
+ if (this.pendingRestore) {
1360
+ return this.reconnect();
1361
+ }
1362
+ return false;
1363
+ }
1364
+ /**
1365
+ * True while a pairing exists (handshake complete, session keys in
1366
+ * memory), even when the wallet's socket is momentarily out of the
1367
+ * channel. Survives the reconnect-probe teardown; false after an explicit
1368
+ * disconnect or a relay tombstone.
1369
+ */
1370
+ isPaired() {
1371
+ return this.keyExchange?.areKeysExchanged() ?? false;
1372
+ }
1373
+ /** True while the relay roster last showed the wallet in the channel. */
1374
+ isWalletPresent() {
1375
+ return this.walletPresent;
1376
+ }
1286
1377
  /**
1287
1378
  * A session was terminated for good (wallet sent a relay 'close', or the
1288
1379
  * join ack reported a tombstone). Clear local state and surface
1289
1380
  * DISCONNECTED so the consumer drops to a fresh-pairing UI.
1290
1381
  */
1291
1382
  handleSessionTerminated() {
1383
+ this.emit("session_terminated");
1292
1384
  this.clearReconnectProbe();
1293
1385
  this.clearUnresponsiveTimer();
1294
1386
  this.walletPresent = false;
1387
+ this.consecutiveDecryptFailures = 0;
1295
1388
  this.pendingRestore = null;
1296
1389
  this.keyExchange = null;
1297
1390
  this.socketClient.leaveChannel();
@@ -1320,22 +1413,28 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1320
1413
  }
1321
1414
  }
1322
1415
  /**
1323
- * Send a JSON-RPC request to the wallet (async, fire-and-forget).
1416
+ * Send a JSON-RPC request to the wallet. Returns the outbound send promise
1417
+ * (settled on the relay's ack) so callers that must sequence on delivery -
1418
+ * e.g. the provider's wallet-wake redirect, which cannot navigate away
1419
+ * before the ciphertext reaches the relay - can await it. Callers may also
1420
+ * ignore it: failures are logged here either way.
1324
1421
  */
1325
1422
  sendJsonRpc(request) {
1326
1423
  if (!this.keyExchange?.areKeysExchanged()) {
1327
1424
  throw new Error("Not connected: key exchange not complete");
1328
1425
  }
1329
- this.sendEncrypted({
1426
+ const sent = this.sendEncrypted({
1330
1427
  type: "jsonrpc" /* JSONRPC */,
1331
1428
  jsonrpc: "2.0",
1332
1429
  id: request.id,
1333
1430
  method: request.method,
1334
1431
  params: request.params
1335
- }).catch((err) => {
1432
+ });
1433
+ sent.catch((err) => {
1336
1434
  error("ConnectionManager", "Failed to send JSON-RPC:", err);
1337
1435
  });
1338
1436
  this.startUnresponsiveTimer();
1437
+ return sent;
1339
1438
  }
1340
1439
  getStatus() {
1341
1440
  return this.status;
@@ -1449,8 +1548,19 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1449
1548
  }
1450
1549
  }
1451
1550
  if (typeof message === "string" && this.keyExchange?.areKeysExchanged()) {
1551
+ let decrypted;
1552
+ try {
1553
+ decrypted = await this.keyExchange.decryptMessage(message);
1554
+ } catch (err) {
1555
+ error("ConnectionManager", "Failed to decrypt message:", err);
1556
+ this.consecutiveDecryptFailures++;
1557
+ if (this.consecutiveDecryptFailures >= _ConnectionManager.MAX_DECRYPT_FAILURES) {
1558
+ await this.teardownDesyncedSession();
1559
+ }
1560
+ return;
1561
+ }
1562
+ this.consecutiveDecryptFailures = 0;
1452
1563
  try {
1453
- const decrypted = await this.keyExchange.decryptMessage(message);
1454
1564
  await this.persistSession();
1455
1565
  const parsed = JSON.parse(decrypted);
1456
1566
  if (isRecord(parsed)) {
@@ -1459,10 +1569,24 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1459
1569
  warn("ConnectionManager", "Dropping non-object decrypted payload");
1460
1570
  }
1461
1571
  } catch (err) {
1462
- error("ConnectionManager", "Failed to decrypt message:", err);
1572
+ error("ConnectionManager", "Failed to handle decrypted message:", err);
1463
1573
  }
1464
1574
  }
1465
1575
  }
1576
+ /**
1577
+ * The receive stream is cryptographically unrecoverable: the peer's send
1578
+ * counter is ahead of our recv counter for good (relay buffer TTL/cap
1579
+ * dropped a ciphertext), so every future message would fail its tag.
1580
+ * An encrypted TERMINATE cannot communicate this (the desynced peer could
1581
+ * not open it either); tombstone the channel on the relay instead so the
1582
+ * wallet learns the pairing is dead even if it only re-joins later, then
1583
+ * clear local state and surface the standard terminated teardown.
1584
+ */
1585
+ async teardownDesyncedSession() {
1586
+ warn("ConnectionManager", "AEAD stream desynced beyond recovery; terminating session");
1587
+ await this.socketClient.closeChannel();
1588
+ this.handleSessionTerminated();
1589
+ }
1466
1590
  async handleSynAck(msg) {
1467
1591
  if (!this.keyExchange) return;
1468
1592
  this.setStatus("key_exchange" /* KEY_EXCHANGE */);
@@ -1516,6 +1640,7 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1516
1640
  }
1517
1641
  case "terminate" /* TERMINATE */: {
1518
1642
  log("ConnectionManager", "Received terminate from wallet");
1643
+ this.emit("session_terminated");
1519
1644
  void this.disconnect();
1520
1645
  break;
1521
1646
  }
@@ -1635,6 +1760,12 @@ var _ConnectionManager = class _ConnectionManager extends EventEmitter3 {
1635
1760
  }
1636
1761
  };
1637
1762
  _ConnectionManager.MAX_RECONNECT_FAILURES = 5;
1763
+ // Nonces derive from the recv counter and there is no gap tolerance, so a
1764
+ // genuinely desynced stream (peer advanced past us, e.g. the relay's
1765
+ // buffer TTL dropped a ciphertext) fails EVERY subsequent open. Two in a
1766
+ // row cannot happen on a healthy stream; requiring the second guards
1767
+ // against a one-off injected junk ciphertext killing a live session.
1768
+ _ConnectionManager.MAX_DECRYPT_FAILURES = 2;
1638
1769
  var ConnectionManager = _ConnectionManager;
1639
1770
 
1640
1771
  // src/utils/platform.ts
@@ -1692,6 +1823,16 @@ var QRL_CONNECT_PROVIDER_INFO = {
1692
1823
  };
1693
1824
  var EIP6963_ANNOUNCE_EVENT = "eip6963:announceProvider";
1694
1825
  var EIP6963_REQUEST_EVENT = "eip6963:requestProvider";
1826
+ function isRecordObj(v) {
1827
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1828
+ }
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 };
1835
+ }
1695
1836
  var QRLConnectProvider = class extends EventEmitter4 {
1696
1837
  constructor(options) {
1697
1838
  super();
@@ -1700,6 +1841,7 @@ var QRLConnectProvider = class extends EventEmitter4 {
1700
1841
  this.eip6963RequestListener = null;
1701
1842
  this.resumeListener = null;
1702
1843
  this.resumeDebounce = null;
1844
+ this.orphanedRequests = /* @__PURE__ */ new Map();
1703
1845
  // Random per-instance prefix keeps request ids unique across page loads.
1704
1846
  // A bare counter restarts at 1 on reload, and the relay buffers messages
1705
1847
  // for 5 minutes, so a stale buffered response could otherwise be matched
@@ -1708,6 +1850,10 @@ var QRLConnectProvider = class extends EventEmitter4 {
1708
1850
  this.requestCounter = 0;
1709
1851
  this.isQRLConnect = true;
1710
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 });
1856
+ }
1711
1857
  if (options.debug) {
1712
1858
  setDebug(true);
1713
1859
  }
@@ -1820,6 +1966,16 @@ var QRLConnectProvider = class extends EventEmitter4 {
1820
1966
  this.connectionManager.on("jsonrpc_response", (response) => {
1821
1967
  const pending = this.pendingRequests.get(response.id);
1822
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
+ }
1823
1979
  warn("Provider", `No pending request for id ${response.id}`);
1824
1980
  return;
1825
1981
  }
@@ -1848,6 +2004,13 @@ var QRLConnectProvider = class extends EventEmitter4 {
1848
2004
  }
1849
2005
  this.pendingRequests.clear();
1850
2006
  });
2007
+ this.connectionManager.on("session_terminated", () => {
2008
+ for (const [, pending] of this.pendingRequests) {
2009
+ pending.reject(new Error("Session terminated by wallet"));
2010
+ }
2011
+ this.pendingRequests.clear();
2012
+ this.clearAllInflight();
2013
+ });
1851
2014
  }
1852
2015
  /**
1853
2016
  * Generate a connection URI for QR code display or deep link redirect.
@@ -1884,7 +2047,13 @@ var QRLConnectProvider = class extends EventEmitter4 {
1884
2047
  throw new Error(`Unsupported method: ${method}`);
1885
2048
  }
1886
2049
  if (this.connectionManager.getStatus() !== "connected" /* CONNECTED */) {
1887
- throw new Error("Not connected to QRL Wallet");
2050
+ if (method === "qrl_requestAccounts" && this.connectionManager.isPaired() && this.connectionManager.getAccounts().length > 0) {
2051
+ return this.connectionManager.getAccounts();
2052
+ }
2053
+ const joined = await this.connectionManager.ensureChannelJoined();
2054
+ if (!joined) {
2055
+ throw new Error("Not connected to QRL Wallet");
2056
+ }
1888
2057
  }
1889
2058
  const id = `${this.requestIdPrefix}-${++this.requestCounter}`;
1890
2059
  return new Promise((resolve, reject) => {
@@ -1907,20 +2076,109 @@ var QRLConnectProvider = class extends EventEmitter4 {
1907
2076
  const originalReject = pending.reject;
1908
2077
  pending.resolve = (result) => {
1909
2078
  clearTimeout(timeout);
2079
+ this.settleInflight(id);
1910
2080
  originalResolve(result);
1911
2081
  };
1912
2082
  pending.reject = (error2) => {
1913
2083
  clearTimeout(timeout);
2084
+ this.settleInflight(id);
1914
2085
  originalReject(error2);
1915
2086
  };
1916
- this.connectionManager.sendJsonRpc({
2087
+ if (RESTRICTED_METHODS.has(method)) {
2088
+ this.persistInflight(id, method);
2089
+ }
2090
+ const sent = this.connectionManager.sendJsonRpc({
1917
2091
  jsonrpc: "2.0",
1918
2092
  id,
1919
2093
  method,
1920
2094
  params
1921
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
+ );
2107
+ }
1922
2108
  });
1923
2109
  }
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();
2122
+ }
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 [];
2140
+ }
2141
+ }
2142
+ writeInflight(records) {
2143
+ if (typeof localStorage === "undefined") return;
2144
+ try {
2145
+ if (records.length === 0) {
2146
+ localStorage.removeItem(this.inflightKey);
2147
+ } else {
2148
+ localStorage.setItem(this.inflightKey, JSON.stringify(records));
2149
+ }
2150
+ } catch {
2151
+ }
2152
+ }
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);
2157
+ }
2158
+ settleInflight(id) {
2159
+ this.orphanedRequests.delete(id);
2160
+ this.writeInflight(this.readInflight().filter((r) => r.id !== id));
2161
+ }
2162
+ clearAllInflight() {
2163
+ this.orphanedRequests.clear();
2164
+ this.writeInflight([]);
2165
+ }
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())}`;
2181
+ }
1924
2182
  /**
1925
2183
  * Get the current connection status.
1926
2184
  */
@@ -1951,6 +2209,19 @@ var QRLConnectProvider = class extends EventEmitter4 {
1951
2209
  hasStoredSession() {
1952
2210
  return this.connectionManager.hasStoredSession();
1953
2211
  }
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();
2220
+ }
2221
+ /** True while the wallet is actually present in the relay channel. */
2222
+ isWalletPresent() {
2223
+ return this.connectionManager.isWalletPresent();
2224
+ }
1954
2225
  /**
1955
2226
  * Reset the connection and start a fresh pairing with a new channel.
1956
2227
  * Use this when the user wants to create a new connection instead of
@@ -1961,6 +2232,7 @@ var QRLConnectProvider = class extends EventEmitter4 {
1961
2232
  pending.reject(new Error("Connection reset"));
1962
2233
  }
1963
2234
  this.pendingRequests.clear();
2235
+ this.clearAllInflight();
1964
2236
  await this.connectionManager.resetForNewChannel();
1965
2237
  return this.getConnectionURI();
1966
2238
  }
@@ -1975,6 +2247,7 @@ var QRLConnectProvider = class extends EventEmitter4 {
1975
2247
  pending.reject(new Error("Disconnected"));
1976
2248
  }
1977
2249
  this.pendingRequests.clear();
2250
+ this.clearAllInflight();
1978
2251
  this.teardownResumeListeners();
1979
2252
  await this.connectionManager.disconnect();
1980
2253
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qrlwallet/connect",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "description": "Connect your dApp to QRL Wallet via QR code or deep link",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",