@qrlwallet/connect 3.1.1 → 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/README.md +26 -6
- package/dist/index.d.mts +147 -3
- package/dist/index.d.ts +147 -3
- package/dist/index.js +317 -7
- package/dist/index.mjs +316 -7
- package/package.json +1 -1
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
|
|
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
|
-
})
|
|
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
|
|
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
|
|
@@ -1648,6 +1779,41 @@ function getAppStoreUrl() {
|
|
|
1648
1779
|
}
|
|
1649
1780
|
return "https://apps.apple.com/app/myqrlwallet/id6742219498";
|
|
1650
1781
|
}
|
|
1782
|
+
function attemptWalletRedirect(uri, timeoutMs = 1800) {
|
|
1783
|
+
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
1784
|
+
return Promise.resolve(false);
|
|
1785
|
+
}
|
|
1786
|
+
if (!uri.trim().toLowerCase().startsWith("qrlconnect:")) {
|
|
1787
|
+
return Promise.resolve(false);
|
|
1788
|
+
}
|
|
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
|
+
});
|
|
1816
|
+
}
|
|
1651
1817
|
|
|
1652
1818
|
// src/QRLConnectProvider.ts
|
|
1653
1819
|
var QRL_CONNECT_PROVIDER_INFO = {
|
|
@@ -1657,6 +1823,16 @@ var QRL_CONNECT_PROVIDER_INFO = {
|
|
|
1657
1823
|
};
|
|
1658
1824
|
var EIP6963_ANNOUNCE_EVENT = "eip6963:announceProvider";
|
|
1659
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
|
+
}
|
|
1660
1836
|
var QRLConnectProvider = class extends EventEmitter4 {
|
|
1661
1837
|
constructor(options) {
|
|
1662
1838
|
super();
|
|
@@ -1665,6 +1841,7 @@ var QRLConnectProvider = class extends EventEmitter4 {
|
|
|
1665
1841
|
this.eip6963RequestListener = null;
|
|
1666
1842
|
this.resumeListener = null;
|
|
1667
1843
|
this.resumeDebounce = null;
|
|
1844
|
+
this.orphanedRequests = /* @__PURE__ */ new Map();
|
|
1668
1845
|
// Random per-instance prefix keeps request ids unique across page loads.
|
|
1669
1846
|
// A bare counter restarts at 1 on reload, and the relay buffers messages
|
|
1670
1847
|
// for 5 minutes, so a stale buffered response could otherwise be matched
|
|
@@ -1673,6 +1850,10 @@ var QRLConnectProvider = class extends EventEmitter4 {
|
|
|
1673
1850
|
this.requestCounter = 0;
|
|
1674
1851
|
this.isQRLConnect = true;
|
|
1675
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
|
+
}
|
|
1676
1857
|
if (options.debug) {
|
|
1677
1858
|
setDebug(true);
|
|
1678
1859
|
}
|
|
@@ -1785,6 +1966,16 @@ var QRLConnectProvider = class extends EventEmitter4 {
|
|
|
1785
1966
|
this.connectionManager.on("jsonrpc_response", (response) => {
|
|
1786
1967
|
const pending = this.pendingRequests.get(response.id);
|
|
1787
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
|
+
}
|
|
1788
1979
|
warn("Provider", `No pending request for id ${response.id}`);
|
|
1789
1980
|
return;
|
|
1790
1981
|
}
|
|
@@ -1813,6 +2004,13 @@ var QRLConnectProvider = class extends EventEmitter4 {
|
|
|
1813
2004
|
}
|
|
1814
2005
|
this.pendingRequests.clear();
|
|
1815
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
|
+
});
|
|
1816
2014
|
}
|
|
1817
2015
|
/**
|
|
1818
2016
|
* Generate a connection URI for QR code display or deep link redirect.
|
|
@@ -1849,7 +2047,13 @@ var QRLConnectProvider = class extends EventEmitter4 {
|
|
|
1849
2047
|
throw new Error(`Unsupported method: ${method}`);
|
|
1850
2048
|
}
|
|
1851
2049
|
if (this.connectionManager.getStatus() !== "connected" /* CONNECTED */) {
|
|
1852
|
-
|
|
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
|
+
}
|
|
1853
2057
|
}
|
|
1854
2058
|
const id = `${this.requestIdPrefix}-${++this.requestCounter}`;
|
|
1855
2059
|
return new Promise((resolve, reject) => {
|
|
@@ -1872,20 +2076,109 @@ var QRLConnectProvider = class extends EventEmitter4 {
|
|
|
1872
2076
|
const originalReject = pending.reject;
|
|
1873
2077
|
pending.resolve = (result) => {
|
|
1874
2078
|
clearTimeout(timeout);
|
|
2079
|
+
this.settleInflight(id);
|
|
1875
2080
|
originalResolve(result);
|
|
1876
2081
|
};
|
|
1877
2082
|
pending.reject = (error2) => {
|
|
1878
2083
|
clearTimeout(timeout);
|
|
2084
|
+
this.settleInflight(id);
|
|
1879
2085
|
originalReject(error2);
|
|
1880
2086
|
};
|
|
1881
|
-
|
|
2087
|
+
if (RESTRICTED_METHODS.has(method)) {
|
|
2088
|
+
this.persistInflight(id, method);
|
|
2089
|
+
}
|
|
2090
|
+
const sent = this.connectionManager.sendJsonRpc({
|
|
1882
2091
|
jsonrpc: "2.0",
|
|
1883
2092
|
id,
|
|
1884
2093
|
method,
|
|
1885
2094
|
params
|
|
1886
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
|
+
}
|
|
1887
2108
|
});
|
|
1888
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
|
+
}
|
|
1889
2182
|
/**
|
|
1890
2183
|
* Get the current connection status.
|
|
1891
2184
|
*/
|
|
@@ -1916,6 +2209,19 @@ var QRLConnectProvider = class extends EventEmitter4 {
|
|
|
1916
2209
|
hasStoredSession() {
|
|
1917
2210
|
return this.connectionManager.hasStoredSession();
|
|
1918
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
|
+
}
|
|
1919
2225
|
/**
|
|
1920
2226
|
* Reset the connection and start a fresh pairing with a new channel.
|
|
1921
2227
|
* Use this when the user wants to create a new connection instead of
|
|
@@ -1926,6 +2232,7 @@ var QRLConnectProvider = class extends EventEmitter4 {
|
|
|
1926
2232
|
pending.reject(new Error("Connection reset"));
|
|
1927
2233
|
}
|
|
1928
2234
|
this.pendingRequests.clear();
|
|
2235
|
+
this.clearAllInflight();
|
|
1929
2236
|
await this.connectionManager.resetForNewChannel();
|
|
1930
2237
|
return this.getConnectionURI();
|
|
1931
2238
|
}
|
|
@@ -1940,6 +2247,7 @@ var QRLConnectProvider = class extends EventEmitter4 {
|
|
|
1940
2247
|
pending.reject(new Error("Disconnected"));
|
|
1941
2248
|
}
|
|
1942
2249
|
this.pendingRequests.clear();
|
|
2250
|
+
this.clearAllInflight();
|
|
1943
2251
|
this.teardownResumeListeners();
|
|
1944
2252
|
await this.connectionManager.disconnect();
|
|
1945
2253
|
}
|
|
@@ -2329,6 +2637,7 @@ export {
|
|
|
2329
2637
|
SCHEME_VERSION_TYPED,
|
|
2330
2638
|
SocketClient,
|
|
2331
2639
|
UNRESTRICTED_METHODS,
|
|
2640
|
+
attemptWalletRedirect,
|
|
2332
2641
|
bytesToHex,
|
|
2333
2642
|
cidFromString,
|
|
2334
2643
|
cidRandom,
|