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();