From b218608944d7fe5bcf11e9971df4cca67f0ed1db Mon Sep 17 00:00:00 2001 From: ghost-cy829 Date: Sun, 31 May 2026 06:14:40 +0000 Subject: [PATCH] feat: add wallet dependency scanning for Wallet Connection (Closes #415) - Add scanWalletDependencies() to detect Freighter and other wallets - Show detected wallets with user-friendly messages - Show install prompt when no wallets found - Add loading states during scanning - Add unit tests for dependency scanning - Add accessibility ARIA labels --- package-lock.json | 300 +++++++++++++++++- src/components/web3/WalletConnector.tsx | 140 ++++++-- .../web3/__tests__/WalletConnector.test.tsx | 52 +++ .../web3/__tests__/walletDetection.test.ts | 47 +++ src/utils/web3/walletDetection.ts | 70 ++++ src/utils/web3/walletValidation.ts | 8 +- 6 files changed, 583 insertions(+), 34 deletions(-) create mode 100644 src/utils/web3/__tests__/walletDetection.test.ts create mode 100644 src/utils/web3/walletDetection.ts diff --git a/package-lock.json b/package-lock.json index fc2a4d2a..c45b402c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "clsx": "^2.1.1", "date-fns": "^3.6.0", "dompurify": "^3.2.4", + "ethers": "^6.12.0", "framer-motion": "^12.23.0", "graphql": "^16.8.0", "graphql-ws": "^5.14.0", @@ -6238,9 +6239,12 @@ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" }, "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "dev": true, + "optional": true, + "peer": true, "dependencies": { "tslib": "^2.8.0" } @@ -9465,6 +9469,11 @@ "pkcs7": "^1.0.4" } }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==" + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -10831,6 +10840,14 @@ "lodash.clonedeep": "^4.5.0" } }, + "node_modules/chessify-protocol/node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/chessify-protocol/node_modules/next": { "version": "16.2.1", "resolved": "https://registry.npmjs.org/next/-/next-16.2.1.tgz", @@ -12821,6 +12838,98 @@ "url": "https://github.com/bgub/eta?sponsor=1" } }, + "node_modules/ethers": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz", + "integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "dependencies": { + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.17.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/@adraffy/ens-normalize": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", + "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==" + }, + "node_modules/ethers/node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethers/node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethers/node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/ethers/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, + "node_modules/ethers/node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" + }, + "node_modules/ethers/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -16625,6 +16734,14 @@ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/next/node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -26840,9 +26957,12 @@ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" }, "@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "dev": true, + "optional": true, + "peer": true, "requires": { "tslib": "^2.8.0" } @@ -28910,6 +29030,11 @@ "pkcs7": "^1.0.4" } }, + "aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==" + }, "agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -29866,6 +29991,14 @@ "lodash.clonedeep": "^4.5.0" } }, + "@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "requires": { + "tslib": "^2.8.0" + } + }, "next": { "version": "16.2.1", "resolved": "https://registry.npmjs.org/next/-/next-16.2.1.tgz", @@ -31295,6 +31428,64 @@ "resolved": "https://registry.npmjs.org/eta/-/eta-4.6.0.tgz", "integrity": "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==" }, + "ethers": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz", + "integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==", + "requires": { + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.17.1" + }, + "dependencies": { + "@adraffy/ens-normalize": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", + "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==" + }, + "@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "requires": { + "@noble/hashes": "1.3.2" + } + }, + "@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==" + }, + "@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "requires": { + "undici-types": "~6.19.2" + } + }, + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, + "undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" + }, + "ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "requires": {} + } + } + }, "eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -33863,6 +34054,7 @@ "eslint-config-prettier": "^8.10.2", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-unused-imports": "^4.4.1", + "ethers": "^6.12.0", "fast-check": "^3.22.0", "framer-motion": "^12.23.0", "graphql": "^16.8.0", @@ -37745,9 +37937,12 @@ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" }, "@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "dev": true, + "optional": true, + "peer": true, "requires": { "tslib": "^2.8.0" } @@ -39815,6 +40010,11 @@ "pkcs7": "^1.0.4" } }, + "aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==" + }, "agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -40771,6 +40971,14 @@ "lodash.clonedeep": "^4.5.0" } }, + "@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "requires": { + "tslib": "^2.8.0" + } + }, "next": { "version": "16.2.1", "resolved": "https://registry.npmjs.org/next/-/next-16.2.1.tgz", @@ -42200,6 +42408,64 @@ "resolved": "https://registry.npmjs.org/eta/-/eta-4.6.0.tgz", "integrity": "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==" }, + "ethers": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz", + "integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==", + "requires": { + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.17.1" + }, + "dependencies": { + "@adraffy/ens-normalize": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", + "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==" + }, + "@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "requires": { + "@noble/hashes": "1.3.2" + } + }, + "@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==" + }, + "@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "requires": { + "undici-types": "~6.19.2" + } + }, + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, + "undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" + }, + "ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "requires": {} + } + } + }, "eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -44783,6 +45049,14 @@ "styled-jsx": "5.1.6" }, "dependencies": { + "@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "requires": { + "tslib": "^2.8.0" + } + }, "postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -49188,6 +49462,14 @@ "styled-jsx": "5.1.6" }, "dependencies": { + "@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "requires": { + "tslib": "^2.8.0" + } + }, "postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", diff --git a/src/components/web3/WalletConnector.tsx b/src/components/web3/WalletConnector.tsx index 9cb0cc2d..a2d83457 100644 --- a/src/components/web3/WalletConnector.tsx +++ b/src/components/web3/WalletConnector.tsx @@ -1,8 +1,9 @@ 'use client'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Wallet, LogOut, AlertCircle, Loader2, Copy, Check, ChevronDown } from 'lucide-react'; import { useWeb3Wallet, type WalletProvider } from '@/hooks/useWeb3Wallet'; +import { scanWalletDependencies } from '@/utils/web3/walletDetection'; interface WalletConnectorProps { className?: string; @@ -39,13 +40,56 @@ export const WalletConnector: React.FC = ({ const wallet = useWeb3Wallet(); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [copiedAddress, setCopiedAddress] = useState(false); + const [scanState, setScanState] = useState({ + isScanning: true, + hasEthereum: false, + hasStarknet: false, + hasFreighter: false, + hasOtherStellarWallet: false, + detectedWalletNames: [], + recommendedInstallation: null, + }); - const walletProviders: { id: WalletProvider; name: string; description: string }[] = [ - { id: 'metamask', name: 'MetaMask', description: 'Connect using MetaMask extension' }, - { id: 'starknet', name: 'Starknet', description: 'Connect using ArgentX or Braavos' }, - ...(showServiceAccount ? [{ id: 'service', name: 'Service Account', description: 'Connect using backend service account' }] : []), + useEffect(() => { + setScanState((prev) => ({ ...prev, isScanning: true })); + const scanResult = scanWalletDependencies(); + setScanState({ ...scanResult, isScanning: false }); + }, [showServiceAccount]); + + const walletProviders: Array<{ + id: WalletProvider; + name: string; + description: string; + available: boolean; + }> = [ + { + id: 'metamask', + name: 'MetaMask', + description: 'Connect using MetaMask extension', + available: scanState.hasEthereum, + }, + { + id: 'starknet', + name: 'Starknet', + description: 'Connect using ArgentX or Braavos', + available: scanState.hasStarknet, + }, + ...(showServiceAccount + ? [ + { + id: 'service', + name: 'Service Account', + description: 'Connect using backend service account', + available: true, + }, + ] + : []), ]; + const availableWalletProviders = walletProviders.filter((provider) => provider.available); + const showInstallPrompt = !scanState.isScanning && availableWalletProviders.length === 0; + + /** * Handle wallet connection */ @@ -100,15 +144,17 @@ export const WalletConnector: React.FC = ({
- ))} + {availableWalletProviders.length > 0 ? ( + availableWalletProviders.map((provider) => ( + + )) + ) : ( +
+ No compatible wallet connectors are available. Please install a supported wallet. +
+ )}
)} diff --git a/src/components/web3/__tests__/WalletConnector.test.tsx b/src/components/web3/__tests__/WalletConnector.test.tsx index a6754eba..87edd99e 100644 --- a/src/components/web3/__tests__/WalletConnector.test.tsx +++ b/src/components/web3/__tests__/WalletConnector.test.tsx @@ -13,6 +13,9 @@ describe('WalletConnector', () => { beforeEach(() => { vi.clearAllMocks(); + delete (window as any).ethereum; + delete (window as any).starknet; + delete (window as any).stellar; }); it('renders "Connect Wallet" button when disconnected', () => { @@ -75,4 +78,53 @@ describe('WalletConnector', () => { fireEvent.click(dismissButton); expect(clearErrorMock).toHaveBeenCalled(); }); + + it('only renders detected wallet providers and install prompt when none are available', async () => { + mockUseWeb3Wallet.mockReturnValue({ + isConnected: false, + isConnecting: false, + address: null, + provider: null, + chainId: null, + balances: [], + error: null, + connect: vi.fn(), + disconnect: vi.fn(), + clearError: vi.fn(), + }); + + render(); + + const connectButton = await screen.findByRole('button', { name: /connect wallet/i }); + fireEvent.click(connectButton); + + expect(await screen.findByText(/no supported wallet providers were detected/i)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /install Freighter/i })).toBeInTheDocument(); + }); + + it('renders only MetaMask or Starknet options based on available wallet dependencies', async () => { + (window as any).starknet = {}; + + mockUseWeb3Wallet.mockReturnValue({ + isConnected: false, + isConnecting: false, + address: null, + provider: null, + chainId: null, + balances: [], + error: null, + connect: vi.fn(), + disconnect: vi.fn(), + clearError: vi.fn(), + }); + + render(); + + const connectButton = await screen.findByRole('button', { name: /connect wallet/i }); + fireEvent.click(connectButton); + + expect(await screen.findByText(/detected wallet providers: Starknet wallet/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /connect Starknet/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /connect MetaMask/i })).not.toBeInTheDocument(); + }); }); diff --git a/src/utils/web3/__tests__/walletDetection.test.ts b/src/utils/web3/__tests__/walletDetection.test.ts new file mode 100644 index 00000000..b80a5203 --- /dev/null +++ b/src/utils/web3/__tests__/walletDetection.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { scanWalletDependencies } from '../walletDetection'; + +describe('scanWalletDependencies', () => { + beforeEach(() => { + delete (window as any).ethereum; + delete (window as any).starknet; + delete (window as any).stellar; + }); + + it('returns no detected wallets when no providers exist', () => { + const result = scanWalletDependencies(); + + expect(result.hasEthereum).toBe(false); + expect(result.hasStarknet).toBe(false); + expect(result.hasFreighter).toBe(false); + expect(result.hasOtherStellarWallet).toBe(false); + expect(result.detectedWalletNames).toEqual([]); + expect(result.recommendedInstallation).toEqual({ + label: 'Freighter', + url: 'https://www.freighter.app/', + }); + }); + + it('detects MetaMask when ethereum provider exists', () => { + (window as any).ethereum = { isMetaMask: true }; + + const result = scanWalletDependencies(); + + expect(result.hasEthereum).toBe(true); + expect(result.detectedWalletNames).toContain('MetaMask'); + expect(result.recommendedInstallation).toBeNull(); + }); + + it('detects Freighter and other Stellar wallet providers', () => { + (window as any).stellar = { freighter: {} }; + (window as any).starknet = {}; + + const result = scanWalletDependencies(); + + expect(result.hasFreighter).toBe(true); + expect(result.hasStarknet).toBe(true); + expect(result.detectedWalletNames).toContain('Freighter'); + expect(result.detectedWalletNames).toContain('Starknet wallet'); + expect(result.recommendedInstallation).toBeNull(); + }); +}); diff --git a/src/utils/web3/walletDetection.ts b/src/utils/web3/walletDetection.ts new file mode 100644 index 00000000..e916aab5 --- /dev/null +++ b/src/utils/web3/walletDetection.ts @@ -0,0 +1,70 @@ +'use client'; + +export interface WalletScanResult { + hasEthereum: boolean; + hasStarknet: boolean; + hasFreighter: boolean; + hasOtherStellarWallet: boolean; + detectedWalletNames: string[]; + recommendedInstallation: { label: string; url: string } | null; + isScanning: boolean; +} + +export function scanWalletDependencies(): Omit { + if (typeof window === 'undefined') { + return { + hasEthereum: false, + hasStarknet: false, + hasFreighter: false, + hasOtherStellarWallet: false, + detectedWalletNames: [], + recommendedInstallation: { + label: 'Freighter', + url: 'https://www.freighter.app/', + }, + }; + } + + const ethereum = (window as Window & { ethereum?: unknown }).ethereum; + const starknet = (window as Window & { starknet?: unknown }).starknet; + const stellar = (window as Window & { stellar?: unknown }).stellar; + const hasEthereum = !!ethereum; + const hasStarknet = !!starknet; + const hasFreighter = !!(stellar && (stellar as any).freighter); + const hasOtherStellarWallet = !!stellar && !hasFreighter; + + const detectedWalletNames: string[] = []; + + if (hasFreighter) { + detectedWalletNames.push('Freighter'); + } + + if (hasOtherStellarWallet) { + detectedWalletNames.push('Stellar wallet'); + } + + if (hasEthereum) { + const isMetaMask = + typeof ethereum === 'object' && Boolean((ethereum as any).isMetaMask); + detectedWalletNames.push(isMetaMask ? 'MetaMask' : 'Ethereum wallet'); + } + + if (hasStarknet) { + detectedWalletNames.push('Starknet wallet'); + } + + return { + hasEthereum, + hasStarknet, + hasFreighter, + hasOtherStellarWallet, + detectedWalletNames, + recommendedInstallation: + detectedWalletNames.length === 0 + ? { + label: 'Freighter', + url: 'https://www.freighter.app/', + } + : null, + }; +} diff --git a/src/utils/web3/walletValidation.ts b/src/utils/web3/walletValidation.ts index 575fe829..cb77fd6c 100644 --- a/src/utils/web3/walletValidation.ts +++ b/src/utils/web3/walletValidation.ts @@ -24,11 +24,17 @@ export function validateWalletInteraction(): WalletInteractionResult { // Check if any supported wallet is available (MetaMask or Starknet) const hasEthereum = !!(window as Window & { ethereum?: unknown }).ethereum; const hasStarknet = !!(window as Window & { starknet?: unknown }).starknet; + const stellar = (window as Window & { stellar?: unknown }).stellar; + const hasFreighter = !!(stellar && (stellar as any).freighter); + const hasOtherStellarWallet = !!stellar && !hasFreighter; if (!hasEthereum && !hasStarknet) { return { canInteract: false, - reason: 'No Web3 wallet extension detected. Please install MetaMask, ArgentX, or Braavos.', + reason: + hasFreighter || hasOtherStellarWallet + ? 'Stellar wallet detected, but current connection flow supports MetaMask and Starknet.' + : 'No Web3 wallet extension detected. Please install MetaMask, ArgentX, or Braavos.', }; }