@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.js
CHANGED
|
@@ -51,6 +51,7 @@ __export(index_exports, {
|
|
|
51
51
|
SCHEME_VERSION_TYPED: () => SCHEME_VERSION_TYPED,
|
|
52
52
|
SocketClient: () => SocketClient,
|
|
53
53
|
UNRESTRICTED_METHODS: () => UNRESTRICTED_METHODS,
|
|
54
|
+
attemptWalletRedirect: () => attemptWalletRedirect,
|
|
54
55
|
bytesToHex: () => bytesToHex,
|
|
55
56
|
cidFromString: () => cidFromString,
|
|
56
57
|
cidRandom: () => cidRandom,
|
|
@@ -630,6 +631,7 @@ var KeyExchange = class extends import_eventemitter3.default {
|
|
|
630
631
|
var import_socket = require("socket.io-client");
|
|
631
632
|
var import_eventemitter32 = __toESM(require("eventemitter3"));
|
|
632
633
|
var PENDING_JOIN_TIMEOUT_MS = 2e4;
|
|
634
|
+
var SEND_FLUSH_TIMEOUT_MS = 600;
|
|
633
635
|
var SocketClient = class extends import_eventemitter32.default {
|
|
634
636
|
constructor(relayUrl, clientType) {
|
|
635
637
|
super();
|
|
@@ -820,6 +822,48 @@ var SocketClient = class extends import_eventemitter32.default {
|
|
|
820
822
|
);
|
|
821
823
|
});
|
|
822
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
|
+
}
|
|
823
867
|
/**
|
|
824
868
|
* Leave the current channel.
|
|
825
869
|
*/
|
|
@@ -1120,11 +1164,13 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
|
|
|
1120
1164
|
this.connectedAccounts = [];
|
|
1121
1165
|
this.unresponsiveTimer = null;
|
|
1122
1166
|
this.reconnectProbeTimer = null;
|
|
1167
|
+
this.restoreInFlight = null;
|
|
1123
1168
|
this.failedReconnects = 0;
|
|
1124
1169
|
this.walletPresent = false;
|
|
1125
1170
|
this.pendingRestore = null;
|
|
1126
1171
|
this.messageQueue = Promise.resolve();
|
|
1127
1172
|
this.outboundQueue = Promise.resolve();
|
|
1173
|
+
this.consecutiveDecryptFailures = 0;
|
|
1128
1174
|
this.dappMetadata = options.dappMetadata;
|
|
1129
1175
|
this.relayUrl = options.relayUrl ?? DEFAULT_RELAY_URL;
|
|
1130
1176
|
this.chainId = options.chainId ?? "0x0";
|
|
@@ -1243,6 +1289,7 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
|
|
|
1243
1289
|
this.clearReconnectProbe();
|
|
1244
1290
|
this.pendingRestore = null;
|
|
1245
1291
|
this.walletPresent = false;
|
|
1292
|
+
this.consecutiveDecryptFailures = 0;
|
|
1246
1293
|
if (this.keyExchange) {
|
|
1247
1294
|
this.keyExchange.reset();
|
|
1248
1295
|
} else {
|
|
@@ -1281,9 +1328,18 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
|
|
|
1281
1328
|
* Returns false if there is nothing to restore.
|
|
1282
1329
|
*/
|
|
1283
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() {
|
|
1284
1339
|
if (!this.pendingRestore) return false;
|
|
1285
1340
|
this.setStatus("reconnecting" /* RECONNECTING */);
|
|
1286
1341
|
this.walletPresent = false;
|
|
1342
|
+
this.consecutiveDecryptFailures = 0;
|
|
1287
1343
|
try {
|
|
1288
1344
|
const session = await KeyExchange.sessionFromPersisted(this.pendingRestore.keyExchange);
|
|
1289
1345
|
this.keyExchange = new KeyExchange(true, session);
|
|
@@ -1359,15 +1415,53 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
|
|
|
1359
1415
|
}
|
|
1360
1416
|
}
|
|
1361
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
|
+
}
|
|
1362
1454
|
/**
|
|
1363
1455
|
* A session was terminated for good (wallet sent a relay 'close', or the
|
|
1364
1456
|
* join ack reported a tombstone). Clear local state and surface
|
|
1365
1457
|
* DISCONNECTED so the consumer drops to a fresh-pairing UI.
|
|
1366
1458
|
*/
|
|
1367
1459
|
handleSessionTerminated() {
|
|
1460
|
+
this.emit("session_terminated");
|
|
1368
1461
|
this.clearReconnectProbe();
|
|
1369
1462
|
this.clearUnresponsiveTimer();
|
|
1370
1463
|
this.walletPresent = false;
|
|
1464
|
+
this.consecutiveDecryptFailures = 0;
|
|
1371
1465
|
this.pendingRestore = null;
|
|
1372
1466
|
this.keyExchange = null;
|
|
1373
1467
|
this.socketClient.leaveChannel();
|
|
@@ -1396,22 +1490,28 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
|
|
|
1396
1490
|
}
|
|
1397
1491
|
}
|
|
1398
1492
|
/**
|
|
1399
|
-
* Send a JSON-RPC request to the wallet
|
|
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.
|
|
1400
1498
|
*/
|
|
1401
1499
|
sendJsonRpc(request) {
|
|
1402
1500
|
if (!this.keyExchange?.areKeysExchanged()) {
|
|
1403
1501
|
throw new Error("Not connected: key exchange not complete");
|
|
1404
1502
|
}
|
|
1405
|
-
this.sendEncrypted({
|
|
1503
|
+
const sent = this.sendEncrypted({
|
|
1406
1504
|
type: "jsonrpc" /* JSONRPC */,
|
|
1407
1505
|
jsonrpc: "2.0",
|
|
1408
1506
|
id: request.id,
|
|
1409
1507
|
method: request.method,
|
|
1410
1508
|
params: request.params
|
|
1411
|
-
})
|
|
1509
|
+
});
|
|
1510
|
+
sent.catch((err) => {
|
|
1412
1511
|
error("ConnectionManager", "Failed to send JSON-RPC:", err);
|
|
1413
1512
|
});
|
|
1414
1513
|
this.startUnresponsiveTimer();
|
|
1514
|
+
return sent;
|
|
1415
1515
|
}
|
|
1416
1516
|
getStatus() {
|
|
1417
1517
|
return this.status;
|
|
@@ -1525,8 +1625,19 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
|
|
|
1525
1625
|
}
|
|
1526
1626
|
}
|
|
1527
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;
|
|
1528
1640
|
try {
|
|
1529
|
-
const decrypted = await this.keyExchange.decryptMessage(message);
|
|
1530
1641
|
await this.persistSession();
|
|
1531
1642
|
const parsed = JSON.parse(decrypted);
|
|
1532
1643
|
if (isRecord(parsed)) {
|
|
@@ -1535,10 +1646,24 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
|
|
|
1535
1646
|
warn("ConnectionManager", "Dropping non-object decrypted payload");
|
|
1536
1647
|
}
|
|
1537
1648
|
} catch (err) {
|
|
1538
|
-
error("ConnectionManager", "Failed to
|
|
1649
|
+
error("ConnectionManager", "Failed to handle decrypted message:", err);
|
|
1539
1650
|
}
|
|
1540
1651
|
}
|
|
1541
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
|
+
}
|
|
1542
1667
|
async handleSynAck(msg) {
|
|
1543
1668
|
if (!this.keyExchange) return;
|
|
1544
1669
|
this.setStatus("key_exchange" /* KEY_EXCHANGE */);
|
|
@@ -1592,6 +1717,7 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
|
|
|
1592
1717
|
}
|
|
1593
1718
|
case "terminate" /* TERMINATE */: {
|
|
1594
1719
|
log("ConnectionManager", "Received terminate from wallet");
|
|
1720
|
+
this.emit("session_terminated");
|
|
1595
1721
|
void this.disconnect();
|
|
1596
1722
|
break;
|
|
1597
1723
|
}
|
|
@@ -1711,6 +1837,12 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
|
|
|
1711
1837
|
}
|
|
1712
1838
|
};
|
|
1713
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;
|
|
1714
1846
|
var ConnectionManager = _ConnectionManager;
|
|
1715
1847
|
|
|
1716
1848
|
// src/utils/platform.ts
|
|
@@ -1724,6 +1856,41 @@ function getAppStoreUrl() {
|
|
|
1724
1856
|
}
|
|
1725
1857
|
return "https://apps.apple.com/app/myqrlwallet/id6742219498";
|
|
1726
1858
|
}
|
|
1859
|
+
function attemptWalletRedirect(uri, timeoutMs = 1800) {
|
|
1860
|
+
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
1861
|
+
return Promise.resolve(false);
|
|
1862
|
+
}
|
|
1863
|
+
if (!uri.trim().toLowerCase().startsWith("qrlconnect:")) {
|
|
1864
|
+
return Promise.resolve(false);
|
|
1865
|
+
}
|
|
1866
|
+
return new Promise((resolve) => {
|
|
1867
|
+
let settled = false;
|
|
1868
|
+
const finish = (opened) => {
|
|
1869
|
+
if (settled) return;
|
|
1870
|
+
settled = true;
|
|
1871
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
1872
|
+
window.removeEventListener("pagehide", onHide);
|
|
1873
|
+
window.clearTimeout(timer);
|
|
1874
|
+
resolve(opened);
|
|
1875
|
+
};
|
|
1876
|
+
const onVisibility = () => {
|
|
1877
|
+
if (document.visibilityState === "hidden") finish(true);
|
|
1878
|
+
};
|
|
1879
|
+
const onHide = () => {
|
|
1880
|
+
finish(true);
|
|
1881
|
+
};
|
|
1882
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
1883
|
+
window.addEventListener("pagehide", onHide);
|
|
1884
|
+
const timer = window.setTimeout(() => {
|
|
1885
|
+
finish(false);
|
|
1886
|
+
}, timeoutMs);
|
|
1887
|
+
try {
|
|
1888
|
+
window.location.href = uri;
|
|
1889
|
+
} catch {
|
|
1890
|
+
finish(false);
|
|
1891
|
+
}
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1727
1894
|
|
|
1728
1895
|
// src/QRLConnectProvider.ts
|
|
1729
1896
|
var QRL_CONNECT_PROVIDER_INFO = {
|
|
@@ -1733,6 +1900,16 @@ var QRL_CONNECT_PROVIDER_INFO = {
|
|
|
1733
1900
|
};
|
|
1734
1901
|
var EIP6963_ANNOUNCE_EVENT = "eip6963:announceProvider";
|
|
1735
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
|
+
}
|
|
1736
1913
|
var QRLConnectProvider = class extends import_eventemitter34.default {
|
|
1737
1914
|
constructor(options) {
|
|
1738
1915
|
super();
|
|
@@ -1741,6 +1918,7 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
|
|
|
1741
1918
|
this.eip6963RequestListener = null;
|
|
1742
1919
|
this.resumeListener = null;
|
|
1743
1920
|
this.resumeDebounce = null;
|
|
1921
|
+
this.orphanedRequests = /* @__PURE__ */ new Map();
|
|
1744
1922
|
// Random per-instance prefix keeps request ids unique across page loads.
|
|
1745
1923
|
// A bare counter restarts at 1 on reload, and the relay buffers messages
|
|
1746
1924
|
// for 5 minutes, so a stale buffered response could otherwise be matched
|
|
@@ -1749,6 +1927,10 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
|
|
|
1749
1927
|
this.requestCounter = 0;
|
|
1750
1928
|
this.isQRLConnect = true;
|
|
1751
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
|
+
}
|
|
1752
1934
|
if (options.debug) {
|
|
1753
1935
|
setDebug(true);
|
|
1754
1936
|
}
|
|
@@ -1861,6 +2043,16 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
|
|
|
1861
2043
|
this.connectionManager.on("jsonrpc_response", (response) => {
|
|
1862
2044
|
const pending = this.pendingRequests.get(response.id);
|
|
1863
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
|
+
}
|
|
1864
2056
|
warn("Provider", `No pending request for id ${response.id}`);
|
|
1865
2057
|
return;
|
|
1866
2058
|
}
|
|
@@ -1889,6 +2081,13 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
|
|
|
1889
2081
|
}
|
|
1890
2082
|
this.pendingRequests.clear();
|
|
1891
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
|
+
});
|
|
1892
2091
|
}
|
|
1893
2092
|
/**
|
|
1894
2093
|
* Generate a connection URI for QR code display or deep link redirect.
|
|
@@ -1925,7 +2124,13 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
|
|
|
1925
2124
|
throw new Error(`Unsupported method: ${method}`);
|
|
1926
2125
|
}
|
|
1927
2126
|
if (this.connectionManager.getStatus() !== "connected" /* CONNECTED */) {
|
|
1928
|
-
|
|
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
|
+
}
|
|
1929
2134
|
}
|
|
1930
2135
|
const id = `${this.requestIdPrefix}-${++this.requestCounter}`;
|
|
1931
2136
|
return new Promise((resolve, reject) => {
|
|
@@ -1948,20 +2153,109 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
|
|
|
1948
2153
|
const originalReject = pending.reject;
|
|
1949
2154
|
pending.resolve = (result) => {
|
|
1950
2155
|
clearTimeout(timeout);
|
|
2156
|
+
this.settleInflight(id);
|
|
1951
2157
|
originalResolve(result);
|
|
1952
2158
|
};
|
|
1953
2159
|
pending.reject = (error2) => {
|
|
1954
2160
|
clearTimeout(timeout);
|
|
2161
|
+
this.settleInflight(id);
|
|
1955
2162
|
originalReject(error2);
|
|
1956
2163
|
};
|
|
1957
|
-
|
|
2164
|
+
if (RESTRICTED_METHODS.has(method)) {
|
|
2165
|
+
this.persistInflight(id, method);
|
|
2166
|
+
}
|
|
2167
|
+
const sent = this.connectionManager.sendJsonRpc({
|
|
1958
2168
|
jsonrpc: "2.0",
|
|
1959
2169
|
id,
|
|
1960
2170
|
method,
|
|
1961
2171
|
params
|
|
1962
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
|
+
}
|
|
1963
2185
|
});
|
|
1964
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
|
+
}
|
|
1965
2259
|
/**
|
|
1966
2260
|
* Get the current connection status.
|
|
1967
2261
|
*/
|
|
@@ -1992,6 +2286,19 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
|
|
|
1992
2286
|
hasStoredSession() {
|
|
1993
2287
|
return this.connectionManager.hasStoredSession();
|
|
1994
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
|
+
}
|
|
1995
2302
|
/**
|
|
1996
2303
|
* Reset the connection and start a fresh pairing with a new channel.
|
|
1997
2304
|
* Use this when the user wants to create a new connection instead of
|
|
@@ -2002,6 +2309,7 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
|
|
|
2002
2309
|
pending.reject(new Error("Connection reset"));
|
|
2003
2310
|
}
|
|
2004
2311
|
this.pendingRequests.clear();
|
|
2312
|
+
this.clearAllInflight();
|
|
2005
2313
|
await this.connectionManager.resetForNewChannel();
|
|
2006
2314
|
return this.getConnectionURI();
|
|
2007
2315
|
}
|
|
@@ -2016,6 +2324,7 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
|
|
|
2016
2324
|
pending.reject(new Error("Disconnected"));
|
|
2017
2325
|
}
|
|
2018
2326
|
this.pendingRequests.clear();
|
|
2327
|
+
this.clearAllInflight();
|
|
2019
2328
|
this.teardownResumeListeners();
|
|
2020
2329
|
await this.connectionManager.disconnect();
|
|
2021
2330
|
}
|
|
@@ -2406,6 +2715,7 @@ function verifyTypedData({ signature, publicKey, payload }) {
|
|
|
2406
2715
|
SCHEME_VERSION_TYPED,
|
|
2407
2716
|
SocketClient,
|
|
2408
2717
|
UNRESTRICTED_METHODS,
|
|
2718
|
+
attemptWalletRedirect,
|
|
2409
2719
|
bytesToHex,
|
|
2410
2720
|
cidFromString,
|
|
2411
2721
|
cidRandom,
|