diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 619b830..4ed8bad 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -49,7 +49,7 @@ jobs:
- uses: actions/setup-node@v4
with:
- node-version: 22
+ node-version: 22.22.x
cache: npm
cache-dependency-path: |
package-lock.json
@@ -72,6 +72,10 @@ jobs:
working-directory: frontend
run: npm run lint
+ - name: Test frontend lifecycle guards
+ working-directory: frontend
+ run: npm test
+
- name: Build frontend
working-directory: frontend
run: npm run build
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index f9be517..a00c5d0 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -11,8 +11,8 @@
"@fontsource-variable/instrument-sans": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/sora": "^5.2.8",
- "@qrlwallet/connect": "^3.3.0",
- "@qrlwallet/connect-ui": "^0.1.0",
+ "@qrlwallet/connect": "4.0.0",
+ "@qrlwallet/connect-ui": "0.2.0",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.1",
"@theqrl/web3": "^1.0.1",
@@ -21,9 +21,9 @@
"lucide-react": "^0.562.0",
"mobx": "^6.13.0",
"mobx-react-lite": "^4.0.7",
- "react": "^19.2.3",
- "react-dom": "^19.2.3",
- "react-router-dom": "7.18.2",
+ "react": "19.2.7",
+ "react-dom": "19.2.7",
+ "react-router": "8.3.0",
"tailwind-merge": "^2.6.0"
},
"devDependencies": {
@@ -42,6 +42,9 @@
"typescript": "~5.8.3",
"typescript-eslint": "^8.24.1",
"vite": "^7.3.2"
+ },
+ "engines": {
+ "node": ">=22.22.0"
}
},
"node_modules/@adraffy/ens-normalize": {
@@ -1213,9 +1216,9 @@
}
},
"node_modules/@qrlwallet/connect": {
- "version": "3.3.0",
- "resolved": "https://registry.npmjs.org/@qrlwallet/connect/-/connect-3.3.0.tgz",
- "integrity": "sha512-Gsg4pXNLGu2uEFXBvqusBC0f90xwYGr5mnQXBjTWsVQvO1qXXHLMG7RlRbopf6jJ8aGefFtF7mdHwaMCdONm0w==",
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@qrlwallet/connect/-/connect-4.0.0.tgz",
+ "integrity": "sha512-U5JVykDspf0HH3QR1U5pY/amXkMCqFjQvcdrpP+xOdzDydJf7hjpEartkjh/4z7ULmWPO1Dmd25GFqKsPbE2sw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^2.2.0",
@@ -1225,19 +1228,19 @@
"socket.io-client": "^4.8.1"
},
"engines": {
- "node": ">=18"
+ "node": ">=20.19.0"
}
},
"node_modules/@qrlwallet/connect-ui": {
- "version": "0.1.0",
- "resolved": "https://registry.npmjs.org/@qrlwallet/connect-ui/-/connect-ui-0.1.0.tgz",
- "integrity": "sha512-WkhH/hzCmkTn4ki4m74JsTuxvMe86VaIf/hGveetF2QzNzR3Z2iHXRUi/B5uZHyVAZKrPgWm6pVcNWkopfrkWA==",
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/@qrlwallet/connect-ui/-/connect-ui-0.2.0.tgz",
+ "integrity": "sha512-Enn9ms9cDXdCa67RzRWEdb0iqZG+NLCIGQ2Wn4to0w1mrk05vlJGPSY5AHaP8JitEm/2ZW8IfkDKQ+K5cAEh9A==",
"license": "MIT",
"dependencies": {
"qrcode": "^1.5.4"
},
"peerDependencies": {
- "@qrlwallet/connect": ">=3.1.0 <4"
+ "@qrlwallet/connect": ">=4.0.0 <5"
},
"peerDependenciesMeta": {
"@qrlwallet/connect": {
@@ -3566,18 +3569,11 @@
"dev": true,
"license": "MIT"
},
- "node_modules/cookie": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
- "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
- "license": "MIT",
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/express"
- }
+ "node_modules/cookie-es": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz",
+ "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==",
+ "license": "MIT"
},
"node_modules/crc-32": {
"version": "1.2.2",
@@ -5267,20 +5263,19 @@
}
},
"node_modules/react-router": {
- "version": "7.18.2",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
- "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.3.0.tgz",
+ "integrity": "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==",
"license": "MIT",
"dependencies": {
- "cookie": "^1.0.1",
- "set-cookie-parser": "^2.6.0"
+ "cookie-es": "^3.1.1"
},
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.22.0"
},
"peerDependencies": {
- "react": ">=18",
- "react-dom": ">=18"
+ "react": ">=19.2.7",
+ "react-dom": ">=19.2.7"
},
"peerDependenciesMeta": {
"react-dom": {
@@ -5288,22 +5283,6 @@
}
}
},
- "node_modules/react-router-dom": {
- "version": "7.18.2",
- "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
- "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
- "license": "MIT",
- "dependencies": {
- "react-router": "7.18.2"
- },
- "engines": {
- "node": ">=20.0.0"
- },
- "peerDependencies": {
- "react": ">=18",
- "react-dom": ">=18"
- }
- },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -5459,12 +5438,6 @@
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
- "node_modules/set-cookie-parser": {
- "version": "2.7.2",
- "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
- "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
- "license": "MIT"
- },
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 9e25470..eb21f7c 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -7,6 +7,7 @@
"scripts": {
"dev": "vite",
"dev:lan": "vite --host 0.0.0.0",
+ "test": "node --test --experimental-strip-types src/utils/*.test.ts",
"build": "tsc -b && vite build",
"lint": "eslint . --max-warnings 0",
"preview": "vite preview"
@@ -15,8 +16,8 @@
"@fontsource-variable/instrument-sans": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/sora": "^5.2.8",
- "@qrlwallet/connect": "^3.3.0",
- "@qrlwallet/connect-ui": "^0.1.0",
+ "@qrlwallet/connect": "4.0.0",
+ "@qrlwallet/connect-ui": "0.2.0",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.1",
"@theqrl/web3": "^1.0.1",
@@ -25,9 +26,9 @@
"lucide-react": "^0.562.0",
"mobx": "^6.13.0",
"mobx-react-lite": "^4.0.7",
- "react": "^19.2.3",
- "react-dom": "^19.2.3",
- "react-router-dom": "7.18.2",
+ "react": "19.2.7",
+ "react-dom": "19.2.7",
+ "react-router": "8.3.0",
"tailwind-merge": "^2.6.0"
},
"devDependencies": {
@@ -47,6 +48,9 @@
"typescript-eslint": "^8.24.1",
"vite": "^7.3.2"
},
+ "engines": {
+ "node": ">=22.22.0"
+ },
"overrides": {
"brace-expansion@1": "1.1.18",
"brace-expansion@5": "5.0.9",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 1ba5881..aa73352 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,6 +1,6 @@
import { useEffect } from "react";
import { observer } from "mobx-react-lite";
-import { BrowserRouter, Route, Routes } from "react-router-dom";
+import { BrowserRouter, Route, Routes } from "react-router";
import { AlertTriangle, X } from "lucide-react";
import { Header } from "@/components/Layout/Header";
import { Footer } from "@/components/Layout/Footer";
diff --git a/frontend/src/components/ConnectButton.tsx b/frontend/src/components/ConnectButton.tsx
index 0724f2d..5b066f9 100644
--- a/frontend/src/components/ConnectButton.tsx
+++ b/frontend/src/components/ConnectButton.tsx
@@ -23,11 +23,14 @@ export const ConnectButton = observer(() => {
);
diff --git a/frontend/src/components/Layout/Footer.tsx b/frontend/src/components/Layout/Footer.tsx
index e12af6a..58196c0 100644
--- a/frontend/src/components/Layout/Footer.tsx
+++ b/frontend/src/components/Layout/Footer.tsx
@@ -1,4 +1,4 @@
-import { Link } from "react-router-dom";
+import { Link } from "react-router";
import { useStore } from "@/stores/store";
export function Footer() {
diff --git a/frontend/src/components/Layout/Header.tsx b/frontend/src/components/Layout/Header.tsx
index 95df6e6..3dcda46 100644
--- a/frontend/src/components/Layout/Header.tsx
+++ b/frontend/src/components/Layout/Header.tsx
@@ -1,4 +1,4 @@
-import { NavLink, Link } from "react-router-dom";
+import { NavLink, Link } from "react-router";
import { observer } from "mobx-react-lite";
import { Logo } from "@/components/Logo";
import { ConnectButton } from "@/components/ConnectButton";
diff --git a/frontend/src/components/QrPairModal.tsx b/frontend/src/components/QrPairModal.tsx
index f1038fe..4540523 100644
--- a/frontend/src/components/QrPairModal.tsx
+++ b/frontend/src/components/QrPairModal.tsx
@@ -25,7 +25,7 @@ export const QrPairModal = observer(() => {
el.setAttribute("uri", uri);
if (statusRef.current) el.setAttribute("status", statusRef.current);
const onNew = () => void poolStore.newConnection();
- const onDismiss = () => poolStore.cancelPairing();
+ const onDismiss = () => void poolStore.cancelPairing();
el.addEventListener("qrl-new-connection", onNew);
el.addEventListener("qrl-cancel", onDismiss);
hostRef.current?.append(el);
diff --git a/frontend/src/components/RouteSeo.tsx b/frontend/src/components/RouteSeo.tsx
index e740dc5..ac15e5d 100644
--- a/frontend/src/components/RouteSeo.tsx
+++ b/frontend/src/components/RouteSeo.tsx
@@ -1,5 +1,5 @@
import { useEffect } from "react";
-import { useLocation } from "react-router-dom";
+import { useLocation } from "react-router";
const CANONICAL_ORIGIN = "https://quantapool.com";
diff --git a/frontend/src/pages/HowItWorksPage.tsx b/frontend/src/pages/HowItWorksPage.tsx
index d4f869d..377c77c 100644
--- a/frontend/src/pages/HowItWorksPage.tsx
+++ b/frontend/src/pages/HowItWorksPage.tsx
@@ -1,4 +1,4 @@
-import { Link } from "react-router-dom";
+import { Link } from "react-router";
import {
ArrowDownToLine,
Clock,
diff --git a/frontend/src/pages/StakePage.tsx b/frontend/src/pages/StakePage.tsx
index 7599829..0776c8f 100644
--- a/frontend/src/pages/StakePage.tsx
+++ b/frontend/src/pages/StakePage.tsx
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { observer } from "mobx-react-lite";
-import { Link } from "react-router-dom";
+import { Link } from "react-router";
import { Zap } from "lucide-react";
import { Button } from "@/components/UI/Button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/UI/Card";
diff --git a/frontend/src/stores/poolStore.ts b/frontend/src/stores/poolStore.ts
index cf600f5..bee94ee 100644
--- a/frontend/src/stores/poolStore.ts
+++ b/frontend/src/stores/poolStore.ts
@@ -12,6 +12,14 @@ import {
} from "@/utils/web3/extension";
import { formatUnits, parseUnits } from "@/utils/format";
+import { requireQrlAccount } from "@/utils/qrlAddress";
+import {
+ activateExtensionAfterRelayRetirement,
+ ChannelTaskGuard,
+ ConnectionAttemptGuard,
+ RelayResetGuard,
+ shouldIgnoreRelayResetEvent,
+} from "@/utils/relayReset";
/** EIP-6963 rdns for the two QRL-capable wallets we surface in the picker. */
const QRL_EXTENSION_RDNS = new Set(["theqrl.org", "com.qrlwallet.extension"]);
@@ -202,6 +210,7 @@ export class PoolStore {
qrlPriceChange24h: number | null = null;
isConnecting = false;
+ isDisconnecting = false;
connectError: string | null = null;
provider: ExtensionProvider | null = null;
/** Wallets EIP-6963 discovered (QRL extension + MyQRLWallet relay). */
@@ -241,6 +250,9 @@ export class PoolStore {
* this gate that would pop an unsolicited QR the user never asked for.
*/
private relayEstablished = false;
+ private relayAuthorization = new ChannelTaskGuard();
+ private relayResetGuard = new RelayResetGuard();
+ private connectionAttemptGuard = new ConnectionAttemptGuard();
private walletsInitialized = false;
/** Extension providers already wired for EIP-1193 events (avoid duplicates). */
private wiredExtensionProviders = new WeakSet();
@@ -262,6 +274,9 @@ export class PoolStore {
providerKind: false,
relayUserDisconnected: false,
relayEstablished: false,
+ relayAuthorization: false,
+ relayResetGuard: false,
+ connectionAttemptGuard: false,
walletsInitialized: false,
wiredExtensionProviders: false,
onEip6963Announce: false,
@@ -415,71 +430,152 @@ export class PoolStore {
/** Connect the wallet the user clicked in the picker. */
async connectWallet(uuid: string): Promise {
const detail = this.discoveredMap.get(uuid);
- if (!detail) return;
+ if (!detail || this.relayResetGuard.active) return;
+ const kind = detail.info.rdns === QRL_CONNECT_RDNS ? "relay" : "extension";
+ const attemptGeneration = this.connectionAttemptGuard.begin(kind);
+ if (attemptGeneration === null) return;
runInAction(() => {
this.walletPickerOpen = false;
this.connectError = null;
});
- if (detail.info.rdns === QRL_CONNECT_RDNS) {
- await this.connectViaRelay();
- } else {
- await this.connectViaExtension(detail);
+ try {
+ if (kind === "relay") {
+ await this.connectViaRelay(attemptGeneration);
+ } else {
+ await this.connectViaExtension(detail, attemptGeneration);
+ }
+ } finally {
+ this.connectionAttemptGuard.finish(attemptGeneration);
}
}
/** Relay pairing: generate a URI, show the QR (or deep-link on mobile). */
- private async connectViaRelay(): Promise {
+ private async connectViaRelay(attemptGeneration: number): Promise {
const qrl = this.qrlConnect;
if (!qrl) return;
- this.providerKind = "relay";
- runInAction(() => {
- this.activeWalletName = QRL_CONNECT_PROVIDER_INFO.name;
- });
+ const previousChannelId = qrl.getChannelId();
+ const resetGeneration = this.relayResetGuard.begin();
+ let uri: string;
try {
- const uri = await qrl.getConnectionURI();
- if (qrl.isMobile()) {
- // Deep-link into the app; if nothing handles the protocol (app not
- // installed, or chooser dismissed) fall back to the pairing modal
- // instead of dead-ending on an unknown-protocol navigation.
- const opened = await attemptWalletRedirect(uri);
- if (opened) return;
- runInAction(() => {
- this.connectError = `MyQRLWallet app not detected. Install it (${getAppStoreUrl()}) or use the copy-code option with the wallet at qrlwallet.com.`;
- });
+ uri = await qrl.getConnectionURI();
+ if (
+ !this.connectionAttemptGuard.isCurrent(attemptGeneration) ||
+ !this.relayResetGuard.isCurrent(resetGeneration)
+ ) {
+ return;
}
+ this.provider = qrl as unknown as ExtensionProvider;
+ this.providerKind = "relay";
+ this.relayEstablished = false;
runInAction(() => {
- this.pairingUri = uri;
- this.pairingStatus = String(qrl.getStatus());
+ this.activeWalletName = QRL_CONNECT_PROVIDER_INFO.name;
+ this.account = null;
+ this.withdrawals = [];
+ this.activity = [];
+ this.activityError = null;
+ this.pairingUri = null;
});
- // The 'connect'/'accountsChanged' relay events finish the handshake.
+ this.finalizedRequests.clear();
+ this.tx = IDLE_TX;
} catch (error) {
- this.providerKind = null;
+ if (
+ !this.connectionAttemptGuard.isCurrent(attemptGeneration) ||
+ !this.relayResetGuard.isCurrent(resetGeneration)
+ ) {
+ return;
+ }
+ const channelChanged = qrl.getChannelId() !== previousChannelId;
+ if (channelChanged) this.resetWalletState();
runInAction(() => {
this.connectError = errorMessage(error);
+ if (!channelChanged && this.providerKind === "relay") {
+ this.pairingStatus = errorMessage(error);
+ }
+ });
+ return;
+ } finally {
+ this.relayResetGuard.finish(resetGeneration);
+ }
+
+ if (!this.connectionAttemptGuard.isCurrent(attemptGeneration)) return;
+ runInAction(() => {
+ this.pairingUri = uri;
+ this.pairingStatus = String(qrl.getStatus());
+ });
+ if (qrl.isMobile()) {
+ // Deep-link into the app; if nothing handles the protocol (app not
+ // installed, or chooser dismissed) fall back to the pairing modal
+ // instead of dead-ending on an unknown-protocol navigation.
+ const opened = await attemptWalletRedirect(uri).catch(() => false);
+ if (opened) return;
+ runInAction(() => {
+ this.connectError = `MyQRLWallet app not detected. Install it (${getAppStoreUrl()}) or use the copy-code option with the wallet at qrlwallet.com.`;
});
}
+ // The 'connect'/'accountsChanged' relay events finish the handshake.
}
/** Extension: request accounts directly from the injected provider. */
- private async connectViaExtension(detail: EIP6963Detail): Promise {
- this.providerKind = "extension";
+ private async connectViaExtension(
+ detail: EIP6963Detail,
+ attemptGeneration: number,
+ ): Promise {
runInAction(() => {
this.isConnecting = true;
this.connectError = null;
});
try {
- const accounts = await detail.provider.request({
- method: "qrl_requestAccounts",
- });
- const address = accounts?.[0];
- if (!address) throw new ConnectionRejectedError();
- this.wireExtensionEvents(detail);
- this.onWalletConnected(address, detail.provider, "extension", detail.info.name);
+ const activation = await activateExtensionAfterRelayRetirement(
+ async () => {
+ const qrl = this.qrlConnect;
+ if (qrl) {
+ this.isDisconnecting = true;
+ this.relayUserDisconnected = true;
+ try {
+ await qrl.disconnect();
+ } catch (error) {
+ return error;
+ } finally {
+ this.relayUserDisconnected = false;
+ this.isDisconnecting = false;
+ }
+ }
+
+ // Relay retirement succeeded. Forget the old local transport before
+ // asking an injected wallet to expose its account.
+ this.resetWalletState();
+ return null;
+ },
+ async () => {
+ if (!this.connectionAttemptGuard.isCurrent(attemptGeneration)) {
+ throw new Error("Wallet connection attempt changed");
+ }
+ return detail.provider.request({
+ method: "qrl_requestAccounts",
+ });
+ },
+ (accounts) => {
+ if (!this.connectionAttemptGuard.isCurrent(attemptGeneration)) {
+ throw new Error("Wallet connection attempt changed");
+ }
+ const address = requireQrlAccount(accounts);
+ this.wireExtensionEvents(detail);
+ this.onWalletConnected(address, detail.provider, "extension", detail.info.name);
+ },
+ );
+
+ if (!this.connectionAttemptGuard.isCurrent(attemptGeneration)) return;
+ if (!activation.ok) {
+ const message = `Could not retire relay session: ${errorMessage(activation.retirementError)}`;
+ runInAction(() => {
+ this.connectError = message;
+ if (this.pairingUri) this.pairingStatus = message;
+ });
+ return;
+ }
} catch (error) {
- this.provider = null;
- this.providerKind = null;
+ if (!this.connectionAttemptGuard.isCurrent(attemptGeneration)) return;
runInAction(() => {
- this.account = null;
this.connectError = errorMessage(error);
});
} finally {
@@ -493,37 +589,101 @@ export class PoolStore {
async newConnection(): Promise {
const qrl = this.qrlConnect;
if (!qrl) return;
- this.providerKind = "relay";
+ if (this.relayResetGuard.active || this.connectionAttemptGuard.isPending()) return;
+ const previousChannelId = qrl.getChannelId();
+ const resetGeneration = this.relayResetGuard.begin();
+ runInAction(() => {
+ this.connectError = null;
+ this.pairingStatus = "Rotating connection...";
+ });
+ let uri: string;
try {
- const uri = await qrl.newConnection();
- if (qrl.isMobile()) {
- // Same fallback as connectViaRelay: an unhandled deep link (app not
- // installed) must not dead-end the rotation flow either.
- const opened = await attemptWalletRedirect(uri);
- if (opened) return;
- runInAction(() => {
- this.connectError = `MyQRLWallet app not detected. Install it (${getAppStoreUrl()}) or use the copy-code option with the wallet at qrlwallet.com.`;
- });
- }
+ uri = await qrl.newConnection();
+ if (!this.relayResetGuard.isCurrent(resetGeneration)) return;
+ this.provider = qrl as unknown as ExtensionProvider;
+ this.providerKind = "relay";
+ this.relayEstablished = false;
runInAction(() => {
- this.pairingUri = uri;
- this.pairingStatus = String(qrl.getStatus());
+ this.activeWalletName = QRL_CONNECT_PROVIDER_INFO.name;
+ this.account = null;
+ this.withdrawals = [];
+ this.activity = [];
+ this.activityError = null;
+ this.pairingUri = null;
});
+ this.finalizedRequests.clear();
+ this.tx = IDLE_TX;
} catch (error) {
+ if (!this.relayResetGuard.isCurrent(resetGeneration)) return;
+ const channelChanged = qrl.getChannelId() !== previousChannelId;
+ if (channelChanged) this.resetWalletState();
+ const message = errorMessage(error);
runInAction(() => {
- this.connectError = errorMessage(error);
+ this.connectError = message;
+ if (!channelChanged) this.pairingStatus = message;
+ });
+ return;
+ } finally {
+ this.relayResetGuard.finish(resetGeneration);
+ }
+
+ runInAction(() => {
+ this.pairingUri = uri;
+ this.pairingStatus = String(qrl.getStatus());
+ });
+ if (qrl.isMobile()) {
+ // Same fallback as connectViaRelay: an unhandled deep link (app not
+ // installed) must not dead-end the rotation flow either.
+ const opened = await attemptWalletRedirect(uri).catch(() => false);
+ if (opened) return;
+ runInAction(() => {
+ this.connectError = `MyQRLWallet app not detected. Install it (${getAppStoreUrl()}) or use the copy-code option with the wallet at qrlwallet.com.`;
});
}
}
- /** Dismiss the QR modal and reopen the picker so another wallet can be chosen. */
- cancelPairing(): void {
- if (this.providerKind === "relay") this.providerKind = null;
+ /** Retire the pending relay channel before reopening the wallet picker. */
+ async cancelPairing(): Promise {
+ const qrl = this.qrlConnect;
+ if (this.providerKind !== "relay" || !qrl) {
+ runInAction(() => {
+ this.pairingUri = null;
+ this.pairingStatus = "";
+ this.walletPickerOpen = true;
+ });
+ return;
+ }
+ if (
+ this.isDisconnecting ||
+ this.relayResetGuard.active ||
+ this.connectionAttemptGuard.isPending()
+ ) {
+ return;
+ }
+
+ this.isDisconnecting = true;
+ this.relayUserDisconnected = true;
runInAction(() => {
- this.pairingUri = null;
- this.pairingStatus = "";
- this.walletPickerOpen = true;
+ this.pairingStatus = "Cancelling...";
+ this.connectError = null;
});
+ try {
+ await qrl.disconnect();
+ this.relayUserDisconnected = false;
+ this.resetWalletState();
+ runInAction(() => {
+ this.walletPickerOpen = true;
+ });
+ } catch (error) {
+ this.relayUserDisconnected = false;
+ const message = `Could not cancel pairing: ${errorMessage(error)}`;
+ runInAction(() => {
+ this.connectError = message;
+ this.pairingStatus = message;
+ });
+ } finally {
+ this.isDisconnecting = false;
+ }
}
/** Unified post-connect: adopt the provider, seed the account, refresh. */
@@ -561,18 +721,48 @@ export class PoolStore {
void this.refreshAccount(address);
}
- disconnect(): void {
- if (this.providerKind === "relay" && this.qrlConnect) {
- this.relayUserDisconnected = true;
- void this.qrlConnect.disconnect().catch(() => undefined);
+ async disconnect(): Promise {
+ if (
+ this.isDisconnecting ||
+ this.relayResetGuard.active ||
+ this.connectionAttemptGuard.isPending()
+ ) {
+ return false;
+ }
+ if (this.providerKind !== "relay" || !this.qrlConnect) {
+ this.resetWalletState();
+ return true;
+ }
+
+ this.isDisconnecting = true;
+ this.relayUserDisconnected = true;
+ runInAction(() => {
+ this.connectError = null;
+ });
+ try {
+ await this.qrlConnect.disconnect();
+ this.relayUserDisconnected = false;
+ this.resetWalletState();
+ return true;
+ } catch (error) {
+ this.relayUserDisconnected = false;
+ const message = `Could not disconnect wallet: ${errorMessage(error)}`;
+ runInAction(() => {
+ this.connectError = message;
+ if (this.pairingUri) this.pairingStatus = message;
+ });
+ return false;
+ } finally {
+ this.isDisconnecting = false;
}
- this.resetWalletState();
}
/** Clear all wallet/account/tx state back to disconnected. */
private resetWalletState(): void {
+ this.relayResetGuard.invalidate();
this.provider = null;
this.providerKind = null;
+ this.relayUserDisconnected = false;
this.relayEstablished = false;
runInAction(() => {
this.account = null;
@@ -640,23 +830,36 @@ export class PoolStore {
/** Wire the relay SDK's EIP-1193 events into store state. */
private wireRelayEvents(qrl: QRLConnect): void {
qrl.on("connect", () => {
- if (this.providerKind !== "relay") return;
- const address = qrl.getAccounts()[0];
- if (address) {
- this.onWalletConnected(
- address,
- qrl as unknown as ExtensionProvider,
- "relay",
- QRL_CONNECT_PROVIDER_INFO.name,
- );
+ if (
+ this.providerKind !== "relay" ||
+ this.relayUserDisconnected ||
+ this.relayResetGuard.active ||
+ this.connectionAttemptGuard.isPending("extension")
+ ) {
+ return;
}
+ void this.authorizeRelayAccount(qrl);
});
qrl.on("accountsChanged", (accounts: string[]) => {
- if (this.providerKind !== "relay") return;
- const next = accounts[0];
- if (!next) {
- this.disconnect();
+ if (this.providerKind !== "relay" || this.relayUserDisconnected) return;
+ if (this.connectionAttemptGuard.isPending("extension")) return;
+ if (shouldIgnoreRelayResetEvent(this.relayResetGuard, "accounts")) return;
+ if (Array.isArray(accounts) && accounts.length === 0) {
+ void this.disconnect();
+ return;
+ }
+ let next: string;
+ try {
+ next = requireQrlAccount(accounts);
+ } catch (error) {
+ void this.disconnect().then((retired) => {
+ if (retired) {
+ runInAction(() => {
+ this.connectError = errorMessage(error);
+ });
+ }
+ });
return;
}
this.onWalletConnected(
@@ -669,6 +872,8 @@ export class PoolStore {
qrl.on("statusChanged", (status) => {
if (this.providerKind !== "relay") return;
+ if (this.connectionAttemptGuard.isPending("extension")) return;
+ if (shouldIgnoreRelayResetEvent(this.relayResetGuard, "status")) return;
runInAction(() => {
this.pairingStatus = String(status);
});
@@ -676,6 +881,8 @@ export class PoolStore {
qrl.on("disconnect", () => {
if (this.providerKind !== "relay") return;
+ if (this.connectionAttemptGuard.isPending("extension")) return;
+ if (shouldIgnoreRelayResetEvent(this.relayResetGuard, "disconnect")) return;
if (this.relayUserDisconnected) {
this.relayUserDisconnected = false;
this.resetWalletState();
@@ -706,6 +913,71 @@ export class PoolStore {
});
}
+ /** Adopt an authorized cache on reconnect, or prompt on a fresh pairing. */
+ private authorizeRelayAccount(qrl: QRLConnect): Promise {
+ const channelId = qrl.getChannelId();
+ return this.relayAuthorization.run(channelId, () =>
+ this.authorizeRelayAccountOnce(qrl, channelId),
+ );
+ }
+
+ private async authorizeRelayAccountOnce(
+ qrl: QRLConnect,
+ channelId: string,
+ ): Promise {
+ try {
+ const cached = qrl.getAccounts();
+ if (!Array.isArray(cached)) throw new Error("Wallet returned an invalid QRL account cache");
+ const accounts = cached.length
+ ? cached
+ : await qrl.request({ method: "qrl_requestAccounts" });
+ const address = requireQrlAccount(accounts);
+ if (
+ this.providerKind !== "relay" ||
+ qrl.getChannelId() !== channelId ||
+ this.relayResetGuard.active ||
+ this.connectionAttemptGuard.isPending("extension")
+ ) {
+ return;
+ }
+ this.onWalletConnected(
+ address,
+ qrl as unknown as ExtensionProvider,
+ "relay",
+ QRL_CONNECT_PROVIDER_INFO.name,
+ );
+ } catch (error) {
+ if (
+ this.providerKind !== "relay" ||
+ qrl.getChannelId() !== channelId ||
+ this.relayUserDisconnected ||
+ this.isDisconnecting ||
+ this.relayResetGuard.active
+ ) {
+ return;
+ }
+ this.isDisconnecting = true;
+ this.relayUserDisconnected = true;
+ try {
+ await qrl.disconnect();
+ this.relayUserDisconnected = false;
+ this.resetWalletState();
+ runInAction(() => {
+ this.connectError = `Could not authorize wallet account: ${errorMessage(error)}`;
+ });
+ } catch (disconnectError) {
+ this.relayUserDisconnected = false;
+ const message = `Could not authorize wallet account: ${errorMessage(error)}. Could not retire pairing: ${errorMessage(disconnectError)}`;
+ runInAction(() => {
+ this.connectError = message;
+ if (this.pairingUri) this.pairingStatus = message;
+ });
+ } finally {
+ this.isDisconnecting = false;
+ }
+ }
+ }
+
/** After a wallet-side drop, show a fresh QR to reconnect. */
private async regenerateRelayQr(): Promise {
const qrl = this.qrlConnect;
@@ -713,23 +985,35 @@ export class PoolStore {
this.resetWalletState();
return;
}
+ if (this.relayResetGuard.active || this.connectionAttemptGuard.isPending()) return;
+ const resetGeneration = this.relayResetGuard.begin();
runInAction(() => {
this.account = null;
this.withdrawals = [];
this.activity = [];
});
this.finalizedRequests.clear();
+ let uri: string;
try {
- const uri = await qrl.getConnectionURI();
- runInAction(() => {
- this.pairingUri = uri;
- this.pairingStatus = String(qrl.getStatus());
- });
- } catch {
+ uri = await qrl.getConnectionURI();
+ if (!this.relayResetGuard.isCurrent(resetGeneration)) return;
+ } catch (error) {
+ if (!this.relayResetGuard.isCurrent(resetGeneration)) return;
// The old channel is gone and a fresh one failed: fall back to fully
// disconnected rather than leaving a dead QR on screen.
this.resetWalletState();
+ runInAction(() => {
+ this.connectError = `Could not create replacement pairing: ${errorMessage(error)}`;
+ });
+ return;
+ } finally {
+ this.relayResetGuard.finish(resetGeneration);
}
+
+ runInAction(() => {
+ this.pairingUri = uri;
+ this.pairingStatus = String(qrl.getStatus());
+ });
}
/**
@@ -745,9 +1029,18 @@ export class PoolStore {
provider.on("accountsChanged", (accounts) => {
if (this.provider !== provider) return;
- const next = Array.isArray(accounts) ? (accounts[0] as string | undefined) : undefined;
- if (!next) {
- this.disconnect();
+ if (Array.isArray(accounts) && accounts.length === 0) {
+ void this.disconnect();
+ return;
+ }
+ let next: string;
+ try {
+ next = requireQrlAccount(accounts);
+ } catch (error) {
+ this.resetWalletState();
+ runInAction(() => {
+ this.connectError = errorMessage(error);
+ });
return;
}
this.onWalletConnected(next, provider, "extension", detail.info.name);
diff --git a/frontend/src/utils/qrlAddress.test.ts b/frontend/src/utils/qrlAddress.test.ts
new file mode 100644
index 0000000..7e033c6
--- /dev/null
+++ b/frontend/src/utils/qrlAddress.test.ts
@@ -0,0 +1,20 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { isQrlAddress, requireQrlAccount } from "./qrlAddress.ts";
+
+const ACCOUNT = `Q${"12".repeat(20)}`;
+const SECOND_ACCOUNT = `Q${"34".repeat(20)}`;
+
+test("current QRL accounts require uppercase Q plus exactly 40 hex characters", () => {
+ assert.equal(isQrlAddress(ACCOUNT), true);
+ assert.equal(isQrlAddress(`Z${ACCOUNT.slice(1)}`), false);
+ assert.equal(isQrlAddress(`${ACCOUNT}00`), false);
+ assert.equal(isQrlAddress("Qshort"), false);
+});
+
+test("wallet responses require exactly one valid account", () => {
+ assert.equal(requireQrlAccount([ACCOUNT]), ACCOUNT);
+ assert.throws(() => requireQrlAccount([]), /invalid QRL account/);
+ assert.throws(() => requireQrlAccount([ACCOUNT, SECOND_ACCOUNT]), /invalid QRL account/);
+ assert.throws(() => requireQrlAccount([`Z${ACCOUNT.slice(1)}`]), /invalid QRL account/);
+});
diff --git a/frontend/src/utils/qrlAddress.ts b/frontend/src/utils/qrlAddress.ts
new file mode 100644
index 0000000..f72baeb
--- /dev/null
+++ b/frontend/src/utils/qrlAddress.ts
@@ -0,0 +1,16 @@
+const QRL_ADDRESS_RE = /^Q[0-9a-fA-F]{40}$/;
+
+export function isQrlAddress(value: unknown): value is string {
+ return typeof value === "string" && QRL_ADDRESS_RE.test(value);
+}
+
+export function requireQrlAccount(accounts: unknown): string {
+ if (
+ !Array.isArray(accounts) ||
+ accounts.length !== 1 ||
+ !accounts.every(isQrlAddress)
+ ) {
+ throw new Error("Wallet returned an invalid QRL account");
+ }
+ return accounts[0];
+}
diff --git a/frontend/src/utils/relayReset.test.ts b/frontend/src/utils/relayReset.test.ts
new file mode 100644
index 0000000..3456414
--- /dev/null
+++ b/frontend/src/utils/relayReset.test.ts
@@ -0,0 +1,162 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ activateExtensionAfterRelayRetirement,
+ ChannelTaskGuard,
+ ConnectionAttemptGuard,
+ RelayResetGuard,
+ shouldIgnoreRelayResetEvent,
+} from "./relayReset.ts";
+
+test("SDK reset teardown events are ignored only while a rotation is active", () => {
+ const guard = new RelayResetGuard();
+ assert.equal(shouldIgnoreRelayResetEvent(guard, "accounts"), false);
+ assert.equal(shouldIgnoreRelayResetEvent(guard, "disconnect"), false);
+
+ const generation = guard.begin();
+ assert.equal(shouldIgnoreRelayResetEvent(guard, "accounts"), true);
+ assert.equal(shouldIgnoreRelayResetEvent(guard, "disconnect"), true);
+ assert.equal(shouldIgnoreRelayResetEvent(guard, "status"), true);
+
+ assert.equal(guard.finish(generation), true);
+ assert.equal(shouldIgnoreRelayResetEvent(guard, "accounts"), false);
+});
+
+test("a stale reset completion cannot clear a newer rotation", () => {
+ const guard = new RelayResetGuard();
+ const first = guard.begin();
+ const second = guard.begin();
+
+ assert.equal(guard.finish(first), false);
+ assert.equal(guard.isCurrent(second), true);
+ assert.equal(shouldIgnoreRelayResetEvent(guard, "disconnect"), true);
+ assert.equal(guard.finish(second), true);
+});
+
+test("invalidation makes late reset work stale", () => {
+ const guard = new RelayResetGuard();
+ const generation = guard.begin();
+ guard.invalidate();
+
+ assert.equal(guard.isCurrent(generation), false);
+ assert.equal(guard.finish(generation), false);
+ assert.equal(guard.active, false);
+});
+
+test("wallet selection attempts are serialized and generation-bound", () => {
+ const guard = new ConnectionAttemptGuard();
+ const extension = guard.begin("extension");
+ assert.notEqual(extension, null);
+ assert.equal(guard.isPending("extension"), true);
+ assert.equal(guard.begin("relay"), null);
+
+ assert.equal(guard.finish(extension as number), true);
+ const relay = guard.begin("relay");
+ assert.notEqual(relay, null);
+ assert.equal(guard.finish(extension as number), false);
+ assert.equal(guard.isCurrent(relay as number), true);
+ guard.invalidate();
+ assert.equal(guard.isCurrent(relay as number), false);
+});
+
+test("relay retirement precedes extension approval and activation", async () => {
+ const order: string[] = [];
+ const result = await activateExtensionAfterRelayRetirement(
+ async () => {
+ order.push("retire relay");
+ return null;
+ },
+ async () => {
+ order.push("request approval");
+ return ["account"];
+ },
+ (accounts) => {
+ order.push("activate extension");
+ return accounts[0];
+ },
+ );
+
+ assert.deepEqual(order, ["retire relay", "request approval", "activate extension"]);
+ assert.deepEqual(result, { ok: true, value: "account" });
+});
+
+test("failed relay retirement blocks extension approval and activation", async () => {
+ const retirementError = new Error("relay still live");
+ let requested = false;
+ let activated = false;
+ const result = await activateExtensionAfterRelayRetirement(
+ async () => retirementError,
+ async () => {
+ requested = true;
+ return ["account"];
+ },
+ () => {
+ activated = true;
+ },
+ );
+
+ assert.equal(requested, false);
+ assert.equal(activated, false);
+ assert.deepEqual(result, { ok: false, retirementError });
+});
+
+test("rejected extension approval cannot activate the extension transport", async () => {
+ const order: string[] = [];
+ await assert.rejects(
+ activateExtensionAfterRelayRetirement(
+ async () => {
+ order.push("retire relay");
+ return null;
+ },
+ async () => {
+ order.push("request approval");
+ throw new Error("user rejected");
+ },
+ () => {
+ order.push("activate extension");
+ },
+ ),
+ /user rejected/,
+ );
+ assert.deepEqual(order, ["retire relay", "request approval"]);
+});
+
+test("authorization work deduplicates per channel without blocking a replacement", async () => {
+ const guard = new ChannelTaskGuard();
+ let resolveOld!: () => void;
+ let resolveNew!: () => void;
+ let starts = 0;
+ const oldTask = guard.run(
+ "old-channel",
+ () =>
+ new Promise((resolve) => {
+ starts += 1;
+ resolveOld = resolve;
+ }),
+ );
+ assert.equal(
+ guard.run("old-channel", async () => {
+ starts += 1;
+ }),
+ oldTask,
+ );
+
+ const newTask = guard.run(
+ "new-channel",
+ () =>
+ new Promise((resolve) => {
+ starts += 1;
+ resolveNew = resolve;
+ }),
+ );
+ assert.notEqual(newTask, oldTask);
+ assert.equal(starts, 2);
+ assert.equal(guard.isPending("new-channel"), true);
+
+ resolveOld();
+ await oldTask;
+ assert.equal(guard.isPending("new-channel"), true);
+ resolveNew();
+ await newTask;
+ assert.equal(guard.isPending(), false);
+});
diff --git a/frontend/src/utils/relayReset.ts b/frontend/src/utils/relayReset.ts
new file mode 100644
index 0000000..8e39156
--- /dev/null
+++ b/frontend/src/utils/relayReset.ts
@@ -0,0 +1,115 @@
+export type RelayResetEvent = "accounts" | "disconnect" | "status";
+
+/** Generation guard for the teardown events emitted by SDK newConnection(). */
+export class RelayResetGuard {
+ private generation = 0;
+ private activeGeneration: number | null = null;
+
+ begin(): number {
+ const generation = ++this.generation;
+ this.activeGeneration = generation;
+ return generation;
+ }
+
+ isCurrent(generation: number): boolean {
+ return this.activeGeneration === generation;
+ }
+
+ finish(generation: number): boolean {
+ if (!this.isCurrent(generation)) return false;
+ this.activeGeneration = null;
+ return true;
+ }
+
+ invalidate(): void {
+ this.generation += 1;
+ this.activeGeneration = null;
+ }
+
+ get active(): boolean {
+ return this.activeGeneration !== null;
+ }
+}
+
+export function shouldIgnoreRelayResetEvent(
+ guard: RelayResetGuard,
+ event: RelayResetEvent,
+): boolean {
+ return (
+ guard.active && (event === "accounts" || event === "disconnect" || event === "status")
+ );
+}
+
+export type WalletConnectionKind = "relay" | "extension";
+
+export type ExtensionActivationResult =
+ | { ok: true; value: T }
+ | { ok: false; retirementError: unknown };
+
+/** Retire every relay session before requesting and activating an extension. */
+export async function activateExtensionAfterRelayRetirement(
+ retireRelay: () => Promise,
+ requestAccounts: () => Promise,
+ activate: (accounts: TAccounts) => TResult | Promise,
+): Promise> {
+ const retirementError = await retireRelay();
+ if (retirementError !== null) return { ok: false, retirementError };
+
+ const accounts = await requestAccounts();
+ return { ok: true, value: await activate(accounts) };
+}
+
+/** Serializes picker selections and generation-binds their async results. */
+export class ConnectionAttemptGuard {
+ private generation = 0;
+ private current: { generation: number; kind: WalletConnectionKind } | null = null;
+
+ begin(kind: WalletConnectionKind): number | null {
+ if (this.current) return null;
+ const generation = ++this.generation;
+ this.current = { generation, kind };
+ return generation;
+ }
+
+ isCurrent(generation: number): boolean {
+ return this.current?.generation === generation;
+ }
+
+ isPending(kind?: WalletConnectionKind): boolean {
+ return this.current !== null && (kind === undefined || this.current.kind === kind);
+ }
+
+ finish(generation: number): boolean {
+ if (!this.isCurrent(generation)) return false;
+ this.current = null;
+ return true;
+ }
+
+ invalidate(): void {
+ this.generation += 1;
+ this.current = null;
+ }
+}
+
+/** Deduplicates work within one channel without blocking a replacement channel. */
+export class ChannelTaskGuard {
+ private current: { channelId: string; task: Promise } | null = null;
+
+ run(channelId: string, start: () => Promise): Promise {
+ if (this.current?.channelId === channelId) return this.current.task;
+
+ const work = start();
+ const tracked = work.finally(() => {
+ if (this.current?.task === tracked) this.current = null;
+ });
+ this.current = { channelId, task: tracked };
+ return tracked;
+ }
+
+ isPending(channelId?: string): boolean {
+ return (
+ this.current !== null &&
+ (channelId === undefined || this.current.channelId === channelId)
+ );
+ }
+}
diff --git a/frontend/src/utils/web3/extension.ts b/frontend/src/utils/web3/extension.ts
index 362c362..9810a9d 100644
--- a/frontend/src/utils/web3/extension.ts
+++ b/frontend/src/utils/web3/extension.ts
@@ -3,6 +3,8 @@
* pattern used by myqrlwallet-frontend.
*/
+import { requireQrlAccount } from "@/utils/qrlAddress";
+
export interface ExtensionProvider {
request: (args: {
method: string;
@@ -97,8 +99,7 @@ export async function connectToExtension(): Promise {
const accounts = await detail.provider.request({
method: "qrl_requestAccounts",
});
- const address = accounts?.[0];
- if (!address) throw new ConnectionRejectedError();
+ const address = requireQrlAccount(accounts);
return { address, provider: detail.provider };
} catch (error) {
if (providerErrorCode(error) === 4001) throw new ConnectionRejectedError();