From fde78a4bb988e87cb9181c5147e12d29788d9af5 Mon Sep 17 00:00:00 2001 From: digitalSloth Date: Sat, 19 Sep 2026 19:54:39 +0100 Subject: [PATCH] feat: add walletconnect and injected dapp provider with approval flow Adds WalletConnect pairing and a read-only injected window.zenon provider, with a shared approval flow for dApp signing requests. Core: - account-block-params, account-block-validation, message-guard, message-signing: validate and decode dApp signing requests, including contract-call data previews - dapp-approvals, site-permissions, inpage-protocol, inpage-approval-bridge: approval queue, per-site permissions and the inpage message protocol - walletconnect-service, walletconnect-client, walletconnect-sign-client-adapter, storage/walletconnect-storage: WalletConnect sessions and persistence Extension: - content-script.ts, inpage.ts: inject window.zenon with read-only and prompting provider methods - sw/: service-worker router, ports, approval inbox and public state - approve.html, ApproveApp.vue, approve-main.ts: approval window opened as a popup UI: - pages/WalletConnect.vue, DappApprovalDialog.vue, SettingsDialog.vue: pairing, session list and approval dialog - composables: useWalletConnect, useDappApprovals, useConnectedSites Also updates manifest, build configs, router, package.json and the README. --- README.md | 15 + approve.html | 12 + manifest.json | 15 + package-lock.json | 998 +++++++++++++++++- package.json | 5 + src/App.vue | 66 +- src/ApproveApp.vue | 153 +++ src/approve-main.ts | 23 + src/background.ts | 45 +- src/components/DappApprovalDialog.vue | 261 +++++ src/components/SettingsDialog.vue | 61 +- src/config.ts | 47 + src/content-script.ts | 221 ++++ src/core/account-block-params.ts | 59 ++ src/core/account-block-validation.ts | 146 +++ src/core/composables/index.ts | 2 + src/core/composables/useConnectedSites.ts | 54 + src/core/composables/useDappApprovals.ts | 166 +++ src/core/composables/useWalletConnect.ts | 87 ++ src/core/dapp-approvals.ts | 93 ++ src/core/index.ts | 1 + src/core/inpage-approval-bridge.ts | 164 +++ src/core/inpage-protocol.ts | 209 ++++ src/core/message-guard.ts | 36 + src/core/message-signing.ts | 20 + src/core/site-permissions.ts | 80 ++ src/core/storage/walletconnect-storage.ts | 77 ++ src/core/wallet-service.ts | 4 +- src/core/walletconnect-client.ts | 42 + src/core/walletconnect-service.ts | 435 ++++++++ src/core/walletconnect-sign-client-adapter.ts | 132 +++ src/inpage.ts | 180 ++++ src/main.ts | 9 +- src/pages/WalletConnect.vue | 183 ++++ src/router.ts | 5 + src/sw/approval-inbox.ts | 220 ++++ src/sw/ports.ts | 84 ++ src/sw/public-state.ts | 81 ++ src/sw/router.ts | 302 ++++++ src/vite-env.d.ts | 21 + vite.config.extension.ts | 8 + vite.shared.ts | 12 +- 42 files changed, 4811 insertions(+), 23 deletions(-) create mode 100644 approve.html create mode 100644 src/ApproveApp.vue create mode 100644 src/approve-main.ts create mode 100644 src/components/DappApprovalDialog.vue create mode 100644 src/content-script.ts create mode 100644 src/core/account-block-params.ts create mode 100644 src/core/account-block-validation.ts create mode 100644 src/core/composables/useConnectedSites.ts create mode 100644 src/core/composables/useDappApprovals.ts create mode 100644 src/core/composables/useWalletConnect.ts create mode 100644 src/core/dapp-approvals.ts create mode 100644 src/core/inpage-approval-bridge.ts create mode 100644 src/core/inpage-protocol.ts create mode 100644 src/core/message-guard.ts create mode 100644 src/core/message-signing.ts create mode 100644 src/core/site-permissions.ts create mode 100644 src/core/storage/walletconnect-storage.ts create mode 100644 src/core/walletconnect-client.ts create mode 100644 src/core/walletconnect-service.ts create mode 100644 src/core/walletconnect-sign-client-adapter.ts create mode 100644 src/inpage.ts create mode 100644 src/pages/WalletConnect.vue create mode 100644 src/sw/approval-inbox.ts create mode 100644 src/sw/ports.ts create mode 100644 src/sw/public-state.ts create mode 100644 src/sw/router.ts diff --git a/README.md b/README.md index 1707af6..6c4caaa 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Your keys stay on your device. Wallets are encrypted with a password you choose, - **Multiple networks** — connect to the default Zenon node or point at your own - **In-browser Proof-of-Work** — generates the required PoW locally when an account lacks plasma; nothing is outsourced - **Password-encrypted storage** with an auto-locking session (30-minute inactivity timeout) +- **WalletConnect** — pair with dApps over the Reown relay, in both the web app and the extension ## Getting Started @@ -62,6 +63,20 @@ On first launch the wallet has no accounts, so you're taken to the **setup** flo Sending a transaction requires the active wallet to be unlocked — if it's locked, the wallet prompts you for your password and then continues to the action. +## WalletConnect + +The wallet can pair with dApps over [WalletConnect](https://walletconnect.com), in both the web app and the extension (via its WalletConnect popup window). Pairing needs a Reown project ID: + +1. Get a project ID from [dashboard.reown.com](https://dashboard.reown.com) +2. Copy `.env.example` to `.env` and set `VITE_WALLETCONNECT_PROJECT_ID` +3. Rebuild — without a project ID, WalletConnect is disabled with no relay dependency and the rest of the wallet is unaffected + +Portions © 2025 Reown, Inc. All Rights Reserved. `@walletconnect/sign-client` is used under the WalletConnect Community License Agreement (see `LICENSE.md` in the package, or [the walletconnect-monorepo repository](https://github.com/walletconnect/walletconnect-monorepo)). + +### Known limitation: macOS native fullscreen + +The extension's approval window and its WalletConnect window are both separate popup windows (`chrome.windows.create`). On macOS, when the browser is in native fullscreen (the green traffic-light button, not maximized), Chrome cannot attach or reliably surface a new popup window — this is a platform limitation of Chrome on macOS, not specific to this wallet ([MetaMask has the same issue](https://github.com/MetaMask/metamask-extension/issues/13590)). Exit fullscreen before approving a dApp request or pairing over WalletConnect in the extension. + ## Scripts | Command | Description | diff --git a/approve.html b/approve.html new file mode 100644 index 0000000..cad2659 --- /dev/null +++ b/approve.html @@ -0,0 +1,12 @@ + + + + + + NoM Wallet — Approve + + +
+ + + diff --git a/manifest.json b/manifest.json index 3d9de67..4b10274 100644 --- a/manifest.json +++ b/manifest.json @@ -14,6 +14,21 @@ "service_worker": "src/background.ts", "type": "module" }, + "content_scripts": [ + { + "matches": ["http://*/*", "https://*/*"], + "js": ["src/inpage.ts"], + "run_at": "document_start", + "all_frames": true, + "world": "MAIN" + }, + { + "matches": ["http://*/*", "https://*/*"], + "js": ["src/content-script.ts"], + "run_at": "document_start", + "all_frames": true + } + ], "sandbox": { "pages": ["pow-sandbox.html"] }, diff --git a/package-lock.json b/package-lock.json index a08a216..d283d91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,12 @@ "version": "1.0.0", "dependencies": { "@vueuse/core": "^14.2.0", + "@walletconnect/sign-client": "^2.24.0", + "@walletconnect/utils": "^2.24.0", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-en": "^3.0.2", + "bech32": "^2.0.0", "buffer": "^6.0.3", "lucide-vue-next": "^0.563.0", "nom-ui": "github:digitalSloth/nom-ui", @@ -30,6 +33,8 @@ "@typescript-eslint/parser": "^8.54.0", "@vitejs/plugin-vue": "^6.0.4", "@vue/eslint-config-typescript": "^14.6.0", + "@walletconnect/keyvaluestorage": "^1.1.1", + "@walletconnect/types": "^2.24.0", "eslint": "^9.39.2", "eslint-plugin-vue": "^10.7.0", "prettier": "^3.2.0", @@ -43,6 +48,12 @@ "vue-tsc": "^3.3.3" } }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -1016,6 +1027,54 @@ "vue": ">=3.0.1" } }, + "node_modules/@msgpack/msgpack": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz", + "integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==", + "license": "ISC", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.0.tgz", + "integrity": "sha512-j84kjAbzEnQHaSIhRPUmB3/eVXu2k3dKPl2LOrR8fSOIL+89U+7lV117EWHtq/GHM3ReGHM46iRBdZfpc4HRUQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.7.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.0.tgz", + "integrity": "sha512-HXydb0DgzTpDPwbVeDGCG1gIu7X6+AuU6Zl6av/E/KG8LMsvPntvq+w17CHRpKBmN6Ybdrt1eP3k4cj8DJa78w==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/ed25519": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-2.3.0.tgz", @@ -1520,6 +1579,35 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@scure/bip39": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", @@ -1772,6 +1860,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", @@ -2596,6 +2750,356 @@ "vue": "^3.5.0" } }, + "node_modules/@walletconnect/core": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@walletconnect/core/-/core-2.25.0.tgz", + "integrity": "sha512-ZcZauyQtSBhwwIbXIVTojcg/iWf1QLnf06zSJNGqHAJHHvWedg2Hpj+E2hinKRkPEqeaqf/ltkwOBOpF1Lcuzg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@walletconnect/heartbeat": "1.2.2", + "@walletconnect/jsonrpc-provider": "1.0.14", + "@walletconnect/jsonrpc-types": "1.0.4", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/jsonrpc-ws-connection": "1.0.16", + "@walletconnect/keyvaluestorage": "1.1.1", + "@walletconnect/logger": "3.0.2", + "@walletconnect/relay-api": "1.0.11", + "@walletconnect/relay-auth": "1.1.0", + "@walletconnect/safe-json": "1.0.2", + "@walletconnect/time": "1.0.2", + "@walletconnect/types": "2.25.0", + "@walletconnect/utils": "2.25.0", + "@walletconnect/window-getters": "1.0.1", + "es-toolkit": "1.45.1", + "events": "3.3.0", + "uint8arrays": "3.1.1" + }, + "engines": { + "node": ">=18.20.8" + } + }, + "node_modules/@walletconnect/environment": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@walletconnect/environment/-/environment-1.0.1.tgz", + "integrity": "sha512-T426LLZtHj8e8rYnKfzsw1aG6+M0BT1ZxayMdv/p8yM0MU+eJDISqNY3/bccxRr4LrF9csq02Rhqt08Ibl0VRg==", + "license": "MIT", + "dependencies": { + "tslib": "1.14.1" + } + }, + "node_modules/@walletconnect/environment/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@walletconnect/events": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@walletconnect/events/-/events-1.0.1.tgz", + "integrity": "sha512-NPTqaoi0oPBVNuLv7qPaJazmGHs5JGyO8eEAk5VGKmJzDR7AHzD4k6ilox5kxk1iwiOnFopBOOMLs86Oa76HpQ==", + "license": "MIT", + "dependencies": { + "keyvaluestorage-interface": "^1.0.0", + "tslib": "1.14.1" + } + }, + "node_modules/@walletconnect/events/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@walletconnect/heartbeat": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@walletconnect/heartbeat/-/heartbeat-1.2.2.tgz", + "integrity": "sha512-uASiRmC5MwhuRuf05vq4AT48Pq8RMi876zV8rr8cV969uTOzWdB/k+Lj5yI2PBtB1bGQisGen7MM1GcZlQTBXw==", + "license": "MIT", + "dependencies": { + "@walletconnect/events": "^1.0.1", + "@walletconnect/time": "^1.0.2", + "events": "^3.3.0" + } + }, + "node_modules/@walletconnect/jsonrpc-provider": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@walletconnect/jsonrpc-provider/-/jsonrpc-provider-1.0.14.tgz", + "integrity": "sha512-rtsNY1XqHvWj0EtITNeuf8PHMvlCLiS3EjQL+WOkxEOA4KPxsohFnBDeyPYiNm4ZvkQdLnece36opYidmtbmow==", + "license": "MIT", + "dependencies": { + "@walletconnect/jsonrpc-utils": "^1.0.8", + "@walletconnect/safe-json": "^1.0.2", + "events": "^3.3.0" + } + }, + "node_modules/@walletconnect/jsonrpc-types": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@walletconnect/jsonrpc-types/-/jsonrpc-types-1.0.4.tgz", + "integrity": "sha512-P6679fG/M+wuWg9TY8mh6xFSdYnFyFjwFelxyISxMDrlbXokorEVXYOxiqEbrU3x1BmBoCAJJ+vtEaEoMlpCBQ==", + "license": "MIT", + "dependencies": { + "events": "^3.3.0", + "keyvaluestorage-interface": "^1.0.0" + } + }, + "node_modules/@walletconnect/jsonrpc-utils": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@walletconnect/jsonrpc-utils/-/jsonrpc-utils-1.0.8.tgz", + "integrity": "sha512-vdeb03bD8VzJUL6ZtzRYsFMq1eZQcM3EAzT0a3st59dyLfJ0wq+tKMpmGH7HlB7waD858UWgfIcudbPFsbzVdw==", + "license": "MIT", + "dependencies": { + "@walletconnect/environment": "^1.0.1", + "@walletconnect/jsonrpc-types": "^1.0.3", + "tslib": "1.14.1" + } + }, + "node_modules/@walletconnect/jsonrpc-utils/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@walletconnect/jsonrpc-ws-connection": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/@walletconnect/jsonrpc-ws-connection/-/jsonrpc-ws-connection-1.0.16.tgz", + "integrity": "sha512-G81JmsMqh5nJheE1mPst1W0WfVv0SG3N7JggwLLGnI7iuDZJq8cRJvQwLGKHn5H1WTW7DEPCo00zz5w62AbL3Q==", + "license": "MIT", + "dependencies": { + "@walletconnect/jsonrpc-utils": "^1.0.6", + "@walletconnect/safe-json": "^1.0.2", + "events": "^3.3.0", + "ws": "^7.5.1" + } + }, + "node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@walletconnect/keyvaluestorage": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@walletconnect/keyvaluestorage/-/keyvaluestorage-1.1.1.tgz", + "integrity": "sha512-V7ZQq2+mSxAq7MrRqDxanTzu2RcElfK1PfNYiaVnJgJ7Q7G7hTVwF8voIBx92qsRyGHZihrwNPHuZd1aKkd0rA==", + "license": "MIT", + "dependencies": { + "@walletconnect/safe-json": "^1.0.1", + "idb-keyval": "^6.2.1", + "unstorage": "^1.9.0" + }, + "peerDependencies": { + "@react-native-async-storage/async-storage": "1.x" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@walletconnect/logger": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@walletconnect/logger/-/logger-3.0.2.tgz", + "integrity": "sha512-7wR3wAwJTOmX4gbcUZcFMov8fjftY05+5cO/d4cpDD8wDzJ+cIlKdYOXaXfxHLSYeDazMXIsxMYjHYVDfkx+nA==", + "license": "MIT", + "dependencies": { + "@walletconnect/safe-json": "^1.0.2", + "pino": "10.0.0" + } + }, + "node_modules/@walletconnect/relay-api": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@walletconnect/relay-api/-/relay-api-1.0.11.tgz", + "integrity": "sha512-tLPErkze/HmC9aCmdZOhtVmYZq1wKfWTJtygQHoWtgg722Jd4homo54Cs4ak2RUFUZIGO2RsOpIcWipaua5D5Q==", + "license": "MIT", + "dependencies": { + "@walletconnect/jsonrpc-types": "^1.0.2" + } + }, + "node_modules/@walletconnect/relay-auth": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@walletconnect/relay-auth/-/relay-auth-1.1.0.tgz", + "integrity": "sha512-qFw+a9uRz26jRCDgL7Q5TA9qYIgcNY8jpJzI1zAWNZ8i7mQjaijRnWFKsCHAU9CyGjvt6RKrRXyFtFOpWTVmCQ==", + "license": "MIT", + "dependencies": { + "@noble/curves": "1.8.0", + "@noble/hashes": "1.7.0", + "@walletconnect/safe-json": "^1.0.1", + "@walletconnect/time": "^1.0.2", + "uint8arrays": "^3.0.0" + } + }, + "node_modules/@walletconnect/relay-auth/node_modules/@noble/hashes": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.0.tgz", + "integrity": "sha512-HXydb0DgzTpDPwbVeDGCG1gIu7X6+AuU6Zl6av/E/KG8LMsvPntvq+w17CHRpKBmN6Ybdrt1eP3k4cj8DJa78w==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/safe-json": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@walletconnect/safe-json/-/safe-json-1.0.2.tgz", + "integrity": "sha512-Ogb7I27kZ3LPC3ibn8ldyUr5544t3/STow9+lzz7Sfo808YD7SBWk7SAsdBFlYgP2zDRy2hS3sKRcuSRM0OTmA==", + "license": "MIT", + "dependencies": { + "tslib": "1.14.1" + } + }, + "node_modules/@walletconnect/safe-json/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@walletconnect/sign-client": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@walletconnect/sign-client/-/sign-client-2.25.0.tgz", + "integrity": "sha512-oOnOZpaoMTjzPlj+PY530zBc0wLKMH1qkFStu+UbfZ162iPQcikE29QXb0bCKiKgqiRZUWacBmsQBdLpYFVhOg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@walletconnect/core": "2.25.0", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/logger": "3.0.2", + "@walletconnect/time": "1.0.2", + "@walletconnect/types": "2.25.0", + "@walletconnect/utils": "2.25.0", + "events": "3.3.0" + } + }, + "node_modules/@walletconnect/time": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@walletconnect/time/-/time-1.0.2.tgz", + "integrity": "sha512-uzdd9woDcJ1AaBZRhqy5rNC9laqWGErfc4dxA9a87mPdKOgWMD85mcFo9dIYIts/Jwocfwn07EC6EzclKubk/g==", + "license": "MIT", + "dependencies": { + "tslib": "1.14.1" + } + }, + "node_modules/@walletconnect/time/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@walletconnect/types": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@walletconnect/types/-/types-2.25.0.tgz", + "integrity": "sha512-FByWQZ40GiFF4CDlz9jU5AN/Bzafwfj+hxnciukzUNUBhMxWTipCvjO5kcLuxVIqFaXkWut349K4Sofz5aG8uQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@walletconnect/events": "1.0.1", + "@walletconnect/heartbeat": "1.2.2", + "@walletconnect/jsonrpc-types": "1.0.4", + "@walletconnect/keyvaluestorage": "1.1.1", + "@walletconnect/logger": "3.0.2", + "events": "3.3.0" + } + }, + "node_modules/@walletconnect/utils": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@walletconnect/utils/-/utils-2.25.0.tgz", + "integrity": "sha512-vNjBaO18BTS97F/SIJt6k/d3l0ngk5hGPEnouRNYbeE/KeG8xzQNZB07iAujhFWS2NNlPQ8/jqYz8PVpeFS2fw==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@msgpack/msgpack": "3.1.3", + "@noble/ciphers": "1.3.0", + "@noble/curves": "1.9.7", + "@noble/hashes": "1.8.0", + "@scure/base": "1.2.6", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/keyvaluestorage": "1.1.1", + "@walletconnect/logger": "3.0.2", + "@walletconnect/relay-api": "1.0.11", + "@walletconnect/relay-auth": "1.1.0", + "@walletconnect/time": "1.0.2", + "@walletconnect/types": "2.25.0", + "@walletconnect/window-getters": "1.0.1", + "@walletconnect/window-metadata": "1.0.1", + "blakejs": "1.2.1", + "detect-browser": "5.3.0", + "ox": "0.9.3", + "uint8arrays": "3.1.1" + } + }, + "node_modules/@walletconnect/utils/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/window-getters": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@walletconnect/window-getters/-/window-getters-1.0.1.tgz", + "integrity": "sha512-vHp+HqzGxORPAN8gY03qnbTMnhqIwjeRJNOMOAzePRg4xVEEE2WvYsI9G2NMjOknA8hnuYbU3/hwLcKbjhc8+Q==", + "license": "MIT", + "dependencies": { + "tslib": "1.14.1" + } + }, + "node_modules/@walletconnect/window-getters/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@walletconnect/window-metadata": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@walletconnect/window-metadata/-/window-metadata-1.0.1.tgz", + "integrity": "sha512-9koTqyGrM2cqFRW517BPY/iEtUDx2r1+Pwwu5m7sJ7ka79wi3EyqhqcICk/yDmv6jAS1rjKgTKXlEhanYjijcA==", + "license": "MIT", + "dependencies": { + "@walletconnect/window-getters": "^1.0.1", + "tslib": "1.14.1" + } + }, + "node_modules/@walletconnect/window-metadata/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/@webcomponents/custom-elements": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@webcomponents/custom-elements/-/custom-elements-1.6.0.tgz", @@ -2624,6 +3128,27 @@ "integrity": "sha512-Zp+zL+I6Un2Bj0tRXNs6VUBq3Djt+hwTwUz4dkt2qgsQz47U0/XthZ4ULrT/RxjwJRl5LwiaKOOZeOtmixHnjg==", "license": "MIT" }, + "node_modules/abitype": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.3.0.tgz", + "integrity": "sha512-fk6Te+bojIFrMvMZrnOO+SxCB+RUksTGOzq/60ZRvs1L+BVzvi2bqt9L3W/17ZLdZsyM1FuYf65P5nlmoiH1Bg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2700,6 +3225,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/argon2": { "version": "0.43.1", "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.43.1.tgz", @@ -2781,6 +3319,15 @@ "node": ">=12" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -2832,6 +3379,12 @@ "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==", "license": "MIT" }, + "node_modules/blakejs": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/blakejs/-/blakejs-1.2.1.tgz", + "integrity": "sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ==", + "license": "MIT" + }, "node_modules/bn.js": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", @@ -3145,6 +3698,21 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/cipher-base": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz", @@ -3236,6 +3804,12 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie-es": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz", + "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -3307,6 +3881,15 @@ "node": ">= 8" } }, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, "node_modules/crypto-browserify": { "version": "3.12.1", "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.1.tgz", @@ -3458,6 +4041,18 @@ "minimalistic-assert": "^1.0.0" } }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/detect-browser": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/detect-browser/-/detect-browser-5.3.0.tgz", + "integrity": "sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==", + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3666,6 +4261,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -3990,7 +4595,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8.x" @@ -4320,6 +4924,23 @@ "dev": true, "license": "ISC" }, + "node_modules/h3": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", + "integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==", + "license": "MIT", + "dependencies": { + "cookie-es": "^1.2.3", + "crossws": "^0.3.5", + "defu": "^6.1.6", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.4", + "radix3": "^1.1.2", + "ufo": "^1.6.3", + "uncrypto": "^0.1.3" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -4432,6 +5053,12 @@ "dev": true, "license": "MIT" }, + "node_modules/idb-keyval": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.3.0.tgz", + "integrity": "sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==", + "license": "Apache-2.0" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -4495,6 +5122,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, "node_modules/is-arguments": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", @@ -4766,6 +5402,12 @@ "json-buffer": "3.0.1" } }, + "node_modules/keyvaluestorage-interface": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/keyvaluestorage-interface/-/keyvaluestorage-interface-1.0.0.tgz", + "integrity": "sha512-8t6Q3TclQ4uZynJY9IGr2+SsIGwK9JHcO6ootkHCGA0CrQCRy+VkouYNO2xicET6b9al7QKzpebNow+gkpCL8g==", + "license": "MIT" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5064,6 +5706,15 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/lucide-vue-next": { "version": "0.563.0", "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.563.0.tgz", @@ -5188,6 +5839,12 @@ "dev": true, "license": "MIT" }, + "node_modules/multiformats": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", + "license": "(Apache-2.0 AND MIT)" + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -5222,6 +5879,12 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -5244,6 +5907,12 @@ "he": "1.2.0" } }, + "node_modules/node-mock-http": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.5.tgz", + "integrity": "sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==", + "license": "MIT" + }, "node_modules/node-stdlib-browser": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/node-stdlib-browser/-/node-stdlib-browser-1.3.1.tgz", @@ -5336,6 +6005,15 @@ "vue": "^3.4.0" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -5424,12 +6102,32 @@ "node": ">=12.20.0" } }, + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", "license": "MIT" }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5455,6 +6153,57 @@ "dev": true, "license": "MIT" }, + "node_modules/ox": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.9.3.tgz", + "integrity": "sha512-KzyJP+fPV4uhuuqrTZyok4DC7vFzi7HLUFiUNEmpbyh59htKWkOC98IONC1zgXJPbHAhQgqs6B0Z6StCGhmQvg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.0.9", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/ox/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ox/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -5591,7 +6340,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -5600,6 +6348,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.0.0.tgz", + "integrity": "sha512-eI9pKwWEix40kfvSzqEP6ldqOoBIN7dwD/o91TY5z8vQI12sAffpR/pOqAD1IVVwIVHDpHjkq0joBPdJD0rafA==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "slow-redact": "^0.3.0", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pkg-dir": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-5.0.0.tgz", @@ -5785,6 +6570,22 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/public-encrypt": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", @@ -5861,6 +6662,18 @@ ], "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "license": "MIT" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -5905,6 +6718,28 @@ "node": ">= 6" } }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/reka-ui": { "version": "2.9.10", "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.9.10.tgz", @@ -6154,6 +6989,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/semver": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", @@ -6317,6 +7161,21 @@ "dev": true, "license": "ISC" }, + "node_modules/slow-redact": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/slow-redact/-/slow-redact-0.3.2.tgz", + "integrity": "sha512-MseHyi2+E/hBRqdOi5COy6wZ7j7DxXRz9NkseavNYSvvWC06D8a5cidVZX3tcG5eCW3NIyVU4zT63hw0Q486jw==", + "license": "MIT" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6326,6 +7185,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -6460,6 +7328,15 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/thread-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", + "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/timers-browserify": { "version": "2.0.12", "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz", @@ -6672,6 +7549,27 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, + "node_modules/uint8arrays": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.1.1.tgz", + "integrity": "sha512-+QJa8QRnbdXVpHYjLoTpJIdCTiw9Ir62nocClWuXIq2JIh4Uta0cQsTSpFL678p2CN8B+XSApwcU+pQEqVpKWg==", + "license": "MIT", + "dependencies": { + "multiformats": "^9.4.2" + } + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -6688,6 +7586,102 @@ "node": ">= 10.0.0" } }, + "node_modules/unstorage": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", + "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.10", + "lru-cache": "^11.2.7", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index 6200883..33a53a3 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,12 @@ }, "dependencies": { "@vueuse/core": "^14.2.0", + "@walletconnect/sign-client": "^2.24.0", + "@walletconnect/utils": "^2.24.0", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-en": "^3.0.2", + "bech32": "^2.0.0", "buffer": "^6.0.3", "lucide-vue-next": "^0.563.0", "nom-ui": "github:digitalSloth/nom-ui", @@ -39,6 +42,8 @@ "@typescript-eslint/parser": "^8.54.0", "@vitejs/plugin-vue": "^6.0.4", "@vue/eslint-config-typescript": "^14.6.0", + "@walletconnect/keyvaluestorage": "^1.1.1", + "@walletconnect/types": "^2.24.0", "eslint": "^9.39.2", "eslint-plugin-vue": "^10.7.0", "prettier": "^3.2.0", diff --git a/src/App.vue b/src/App.vue index 084c1df..9d74c59 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,7 +1,8 @@ + + diff --git a/src/approve-main.ts b/src/approve-main.ts new file mode 100644 index 0000000..109f07f --- /dev/null +++ b/src/approve-main.ts @@ -0,0 +1,23 @@ +import { Buffer } from 'buffer' +import __global_polyfill from 'vite-plugin-node-polyfills/shims/global' +import __process_polyfill from 'vite-plugin-node-polyfills/shims/process' +import { createApp } from 'vue' +import { useToast } from 'nom-ui' +import ApproveApp from './ApproveApp.vue' +import './style.css' + +// Same polyfill setup as main.ts — this is its own rollup entry (§5.1), so it +// needs its own copy; the SDK is used here too (parsing/sending blocks). +globalThis.Buffer = Buffer +globalThis.global = globalThis.global || __global_polyfill +globalThis.process = globalThis.process || __process_polyfill + +const app = createApp(ApproveApp) + +const { show } = useToast() +app.config.errorHandler = (err, _instance, info) => { + console.error('Unhandled Vue error:', err, info) + show('Something went wrong. Please try again.', 'error') +} + +app.mount('#app') diff --git a/src/background.ts b/src/background.ts index 252cb2b..a60bb7c 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,8 +1,41 @@ // Background service worker for the MV3 extension. -// Restrict chrome.storage.local to trusted extension contexts (no content -// scripts today, but this fails closed if any are added later). Feature-detected -// because setAccessLevel is not available on all supported Chrome versions. +// Registers the provider's onMessage/onConnect listeners and the public-state +// onChanged broadcaster, both at module top level within their own files, so +// every event source wakes a terminated worker. +import './sw/router' +import './sw/public-state' +import {ERR_USER_REJECTED} from '@/core/inpage-protocol' +import {SESSION_KEY_WALLETCONNECT_WINDOW_ID} from '@/config' +import {clearApprovalWindow, rejectEntriesForWindow} from './sw/approval-inbox' + +async function clearWalletConnectWindow(windowId: number): Promise { + const stored = await chrome.storage.session.get(SESSION_KEY_WALLETCONNECT_WINDOW_ID) + if (stored[SESSION_KEY_WALLETCONNECT_WINDOW_ID] === windowId) { + await chrome.storage.session.remove(SESSION_KEY_WALLETCONNECT_WINDOW_ID) + } +} + +chrome.windows.onRemoved.addListener((windowId) => { + // The approval window closing without a decision rejects only that + // window's entries — each one was stamped with the window that showed it + // (§6.6). + void rejectEntriesForWindow(windowId, { + code: ERR_USER_REJECTED, + message: 'The approval window was closed.', + }) + void clearApprovalWindow(windowId) + // The WalletConnect popup window (App.vue's openWalletConnect) is reused + // by stored id the same way; forget it once closed so the next open + // creates a fresh window instead of trying to focus a dead one. + void clearWalletConnectWindow(windowId) +}) + +// Restrict chrome.storage.local to trusted extension contexts — the content +// scripts (src/inpage.ts, src/content-script.ts) are untrusted contexts and +// must not reach storage directly; they only relay chrome.runtime messages. +// Feature-detected because setAccessLevel is not available on all supported +// Chrome versions. async function restrictStorageAccess(): Promise { const storage = chrome.storage.local as chrome.storage.LocalStorageArea & { setAccessLevel?: (options: {accessLevel: string}) => Promise @@ -19,9 +52,3 @@ async function restrictStorageAccess(): Promise { chrome.runtime.onInstalled.addListener(() => { void restrictStorageAccess() }) - -// Placeholder message handler; expanded in Phase 2 (WalletConnect routing). -chrome.runtime.onMessage.addListener((_message, _sender, sendResponse) => { - sendResponse({success: true}) - return true -}) diff --git a/src/components/DappApprovalDialog.vue b/src/components/DappApprovalDialog.vue new file mode 100644 index 0000000..5d95f06 --- /dev/null +++ b/src/components/DappApprovalDialog.vue @@ -0,0 +1,261 @@ + + + diff --git a/src/components/SettingsDialog.vue b/src/components/SettingsDialog.vue index 7b50172..b5c79dd 100644 --- a/src/components/SettingsDialog.vue +++ b/src/components/SettingsDialog.vue @@ -5,12 +5,18 @@ import WalletList from './WalletList.vue' import UnlockWalletDialog from './UnlockWalletDialog.vue' import ExportMnemonicDialog from './ExportMnemonicDialog.vue' import NetworkSelector from './NetworkSelector.vue' +import {useConnectedSites} from '@/core/composables/useConnectedSites' import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemTitle, Tabs, TabsContent, TabsList, @@ -47,6 +53,21 @@ const emit = defineEmits<{ const { mode, setTheme } = useTheme() +// Connected-sites (§6.7) — extension only. useConnectedSites.ts's underlying +// storage calls already no-op in the web app, but it's imported by path here +// rather than from the composables barrel so no other page pulls it in. +// Bound to a local const because the template compiler can't resolve the +// ambient __IS_EXTENSION__ global directly. +const isExtension = __IS_EXTENSION__ +const connectedSites = useConnectedSites() + +watch( + () => props.open, + (open) => { + if (open && isExtension) void connectedSites.refresh() + } +) + const isOpen = computed({ get: () => props.open, set: (value: boolean) => { @@ -135,9 +156,10 @@ defineExpose({ - + Wallet Network + Connected Sites Appearance @@ -176,6 +198,37 @@ defineExpose({ /> + +
+
+

No connected sites

+
+ + +
+
+
@@ -206,6 +259,12 @@ defineExpose({
+ +

+ Portions © 2025 Reown, Inc. All Rights Reserved. WalletConnect pairing uses + @walletconnect/sign-client under the WalletConnect Community License + Agreement. +

diff --git a/src/config.ts b/src/config.ts index e4d0525..16a3fa8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -86,3 +86,50 @@ export const KDF_CONFIG = { hashLength: 32, parallelism: 4, } as const + +// --- dApp surfaces --- + +/** phase 1 — wallet-service.ts. Moved here so the service worker (phase 3a/3b) + * can read `activeAccountAddress` without importing wallet-service.ts and its + * SDK dependency. */ +export const STORAGE_KEY_WALLETS = 'nom-wallet-storage' + +/** phase 1 — dapp-approvals.ts. Cap on a single document's pending-approval + * queue; enforced per document, not shared across surfaces. */ +export const MAX_PENDING_DAPP_REQUESTS = 5 + +/** phase 2 — WalletConnect surface. */ +export const WALLETCONNECT_PROJECT_ID: string = import.meta.env.VITE_WALLETCONNECT_PROJECT_ID ?? '' +/** phase 2 — WalletConnect surface. */ +export const WALLETCONNECT_ENABLED = WALLETCONNECT_PROJECT_ID.length >= 32 +/** phase 2 — WalletConnect surface. The wallet's own peer identity, rendered + * as text and an image inside every dApp that pairs with it. */ +export const WALLETCONNECT_METADATA = { + name: 'NoM Wallet', + description: 'A secure cryptocurrency wallet for the Zenon Network of Momentum.', + url: 'https://github.com/digitalSloth/nom-webwallet', + icons: ['https://raw.githubusercontent.com/digitalSloth/nom-webwallet/main/icons/icon-128.png'], +} +/** phase 2 — WalletConnect surface. */ +export const WC_STORAGE_PREFIX = 'nom-wallet-wc:' + +/** phase 3a — site-permissions.ts. */ +export const STORAGE_KEY_SITE_PERMISSIONS = 'nom-wallet-site-permissions' + +/** phase 3b — approval-inbox.ts. */ +export const SESSION_KEY_PENDING_REQUESTS = 'nom-wallet.pendingRequests' +/** phase 3b — approval-inbox.ts. */ +export const SESSION_KEY_APPROVAL_WINDOW_ID = 'nom-wallet.approvalWindowId' +/** phase 3b — approval-inbox.ts. */ +export const SESSION_KEY_OUTCOMES = 'nom-wallet.outcomes' +/** phase 3b — approval-inbox.ts. */ +export const APPROVAL_WINDOW_IDLE_MS = 5 * 60 * 1000 +/** + * phase 3b — approval-inbox.ts. Retention for outcomes that have already been + * delivered, for duplicate suppression only. Undelivered outcomes are never + * expired. + */ +export const DELIVERED_OUTCOME_TTL_MS = 60 * 1000 + +/** App.vue — the extension's WalletConnect popup window, reused across opens. */ +export const SESSION_KEY_WALLETCONNECT_WINDOW_ID = 'nom-wallet.walletConnectWindowId' diff --git a/src/content-script.ts b/src/content-script.ts new file mode 100644 index 0000000..392dd00 --- /dev/null +++ b/src/content-script.ts @@ -0,0 +1,221 @@ +import type { + InpageEventMessage, + InpageRequestMessage, + InpageResponseMessage, + PortEventMessage, + PortMessageFromWorker, + ReadOnlyRequest, + ReadOnlyResponse, +} from '@/core/inpage-protocol' + +/** + * Isolated-world relay between the page's window.zenon (src/inpage.ts) and + * the service worker (§6.3, §6.4). Owns the provider port's lifecycle: + * opened when the origin is granted or a prompting request is outstanding, + * closed otherwise, reconnected immediately on disconnect with no backoff. + * + * Deliberately duplicates the wire-protocol string constants below rather + * than importing them as values from inpage-protocol.ts: that module is also + * imported by the service worker, and a runtime import shared across entries + * makes crxjs wrap this content script in a dynamic-import loader instead of + * a self-contained IIFE — subject to a visited page's CSP (§5.1). Type-only + * imports above are erased at build and carry no such cost. + */ +const TARGET_CONTENT_SCRIPT = 'znn-contentscript' +const TARGET_INPAGE = 'znn-inpage' +const PROVIDER_PORT_NAME = 'znn-provider' +const PROVIDER_MESSAGE_CHANNEL = 'znn-provider' +const ZNN_IS_GRANTED = 'znn_isGranted' +const READ_ONLY_METHODS = new Set(['znn_accounts', 'znn_chainId', 'znn_nodeUrl', 'znn_disconnect']) + +// A request whose id comes back `unknown` from a resync this many times in a +// row is genuinely lost, not just racing a reconnect (§6.4, edge case 32). +const MAX_UNKNOWN_BEFORE_GIVING_UP = 2 +const LOST_REQUEST_ERROR = { code: -32603, message: 'Request was lost and could not be recovered.' } + +interface OutstandingPrompt { + pageId: string + method: string + params: unknown + unknownCount: number +} + +// Keyed by the content-script-minted port id (never the page's own id — see +// sendPrompting below). +const outstanding = new Map() + +let port: chrome.runtime.Port | null = null +// True only when this content script closed the port on purpose (revoke, or +// the tab unloading) — the signal that stops the no-backoff reconnect below. +let closedByUs = false + +function replyToPage(pageId: string, result: unknown, error: InpageResponseMessage['error']): void { + const reply: InpageResponseMessage = { target: TARGET_INPAGE, id: pageId, result, error } + window.postMessage(reply, '*') +} + +function forwardEvent(message: PortEventMessage): void { + const forwarded: InpageEventMessage = { + target: TARGET_INPAGE, + kind: 'event', + name: message.name, + data: message.data, + } + window.postMessage(forwarded, '*') +} + +function handlePortMessage(message: PortMessageFromWorker): void { + switch (message.kind) { + case 'event': + forwardEvent(message) + return + + case 'accepted': + // Durably tracked by the worker now; nothing to do until its outcome. + return + + case 'outcome': { + const entry = outstanding.get(message.id) + if (!entry) return + outstanding.delete(message.id) + replyToPage(entry.pageId, message.result, message.error) + // A settled connect() may have just granted (or a rejection left the + // origin ungranted) — re-check whether the port should stay open. + if (outstanding.size === 0) void reconcileFromGrantState() + return + } + + case 'resyncResult': { + let settledAny = false + for (const id of message.unknown) { + const entry = outstanding.get(id) + if (!entry) continue + + entry.unknownCount += 1 + if (entry.unknownCount >= MAX_UNKNOWN_BEFORE_GIVING_UP) { + outstanding.delete(id) + settledAny = true + replyToPage(entry.pageId, undefined, LOST_REQUEST_ERROR) + } else { + // Re-send silently — the canonical lost-request case is benign + // (worker terminated or port dropped before its listener ran), and + // idempotent request handling on the worker makes this safe even + // if the original request actually did land (§6.4, edge case 27). + port?.postMessage({ kind: 'request', id, method: entry.method, params: entry.params }) + } + } + if (settledAny && outstanding.size === 0) void reconcileFromGrantState() + return + } + } +} + +function openPort(): void { + if (port) return + closedByUs = false + + const opened = chrome.runtime.connect({ name: PROVIDER_PORT_NAME }) + opened.onMessage.addListener(handlePortMessage) + opened.onDisconnect.addListener(() => { + if (port === opened) port = null + if (closedByUs) return + + if (outstanding.size > 0) { + // Reconnect immediately, no backoff (§6.4) — an implementer who adds + // backoff here silently breaks outcome delivery. No async detour here: + // outstanding work must reopen the port unconditionally and promptly, + // since the resync that recovers it depends on the port being back. + openPort() + } else { + // Nothing outstanding — re-verify the grant before reopening. This is + // what makes a revoke initiated from the settings dialog (rather than + // the dApp's own disconnect()) actually stick: the worker disconnects + // this port (§6.7), and without this check the port would otherwise + // just reconnect right back. + void reconcileFromGrantState() + } + }) + + port = opened + + // On (re)connect, resync every request this frame is still waiting on — + // the worker may have answered while the port was down. + if (outstanding.size > 0) { + port.postMessage({ kind: 'resync', ids: [...outstanding.keys()] }) + } +} + +function closePort(): void { + if (!port) return + closedByUs = true + port.disconnect() + port = null +} + +/** Opens/closes the port to match the origin's grant state and outstanding work (§6.4). */ +function reconcilePort(granted: boolean): void { + if (granted || outstanding.size > 0) openPort() + else closePort() +} + +function sendReadOnly(method: ReadOnlyRequest['method']): Promise { + return chrome.runtime.sendMessage({ + channel: PROVIDER_MESSAGE_CHANNEL, + method, + }) +} + +async function reconcileFromGrantState(): Promise { + const response = await sendReadOnly(ZNN_IS_GRANTED) + reconcilePort(response.granted) +} + +/** Mints the worker-facing id and sends a prompting request over the port (§6.4). */ +function sendPrompting(pageId: string, method: string, params: unknown): void { + const id = `ip:${crypto.randomUUID()}` + outstanding.set(id, { pageId, method, params, unknownCount: 0 }) + // The content script ensures the port is open before relaying the request + // — this is what lets a first-ever connect() work: the port exists before + // the request is sent, so an outcome never has nowhere to go. + openPort() + port?.postMessage({ kind: 'request', id, method, params }) +} + +// Bootstrap: learn the origin's grant state before the page has called +// anything, so the port opens at document_start when already granted. +void sendReadOnly(ZNN_IS_GRANTED).then((response) => reconcilePort(response.granted)) + +function isInpageRequest(data: unknown): data is InpageRequestMessage { + const d = data as Partial | undefined + return !!d && d.target === TARGET_CONTENT_SCRIPT && typeof d.id === 'string' && typeof d.method === 'string' +} + +window.addEventListener('message', (event: MessageEvent) => { + // Both ends of the postMessage bridge must filter on event.source — a + // cross-origin iframe could otherwise raise a request attributed to this + // frame's origin (§6.3, edge case 20). + if (event.source !== window) return + if (!isInpageRequest(event.data)) return + + const { id, method, params } = event.data + + if (READ_ONLY_METHODS.has(method)) { + void sendReadOnly(method as ReadOnlyRequest['method']).then((response) => { + // Re-evaluated on every relayed message, including read-only ones — + // the router already answered a permission check on this path, so the + // port opens here if the grant has appeared since load (§6.4). + reconcilePort(response.granted) + replyToPage(id, response.result, response.error) + }) + return + } + + sendPrompting(id, method, params) +}) + +// Stop reconnecting once the tab is actually going away. +window.addEventListener('pagehide', () => { + closedByUs = true + port?.disconnect() + port = null +}) diff --git a/src/core/account-block-params.ts b/src/core/account-block-params.ts new file mode 100644 index 0000000..4538125 --- /dev/null +++ b/src/core/account-block-params.ts @@ -0,0 +1,59 @@ +import { AccountBlockTemplate, Address, Hash, TokenStandard } from 'znn-typescript-sdk' +import { Buffer } from 'buffer' +import { ZenonService } from './zenon-service' +import { accountBlockProblem, sendTransactionParamsProblem } from './account-block-validation' +import { InvalidParamsError } from './message-guard' + +export { InvalidParamsError } from './message-guard' + +/** + * Constructs `AccountBlockTemplate`s directly from validated dApp JSON — never + * via `fromJson`, which throws on a partial block and would let a dApp-chosen + * `version`/`chainIdentifier` reach the signed digest. Callers must + * `await ZenonService.getInstance().ensureInitialized()` first, so the chain ID + * read here is the persisted one. + */ + +/** Throws InvalidParamsError when the JSON is not a usable account block. */ +export function parseAccountBlockJson(json: unknown): AccountBlockTemplate { + const problem = accountBlockProblem(json) + if (problem) throw new InvalidParamsError(problem) + + const block = json as Record + const chainIdentifier = ZenonService.getInstance().getChainId() + + if (block.blockType === 2) { + return new AccountBlockTemplate({ + blockType: 2, + toAddress: Address.parse(block.toAddress as string), + tokenStandard: TokenStandard.parse(block.tokenStandard as string), + amount: BigInt(String(block.amount)), + data: block.data ? Buffer.from(block.data as string, 'base64') : Buffer.from([]), + chainIdentifier, + }) + } + + // blockType 3 — receive. Nothing else, whatever else the dApp sent. + return new AccountBlockTemplate({ + blockType: 3, + fromBlockHash: Hash.parse(block.fromBlockHash as string), + chainIdentifier, + }) +} + +/** Throws InvalidParamsError when the params are not a usable {to, tokenStandard, amount}. */ +export function parseSendTransactionParams(params: unknown): AccountBlockTemplate { + const problem = sendTransactionParamsProblem(params) + if (problem) throw new InvalidParamsError(problem) + + const p = params as Record + + return new AccountBlockTemplate({ + blockType: 2, + toAddress: Address.parse(p.to as string), + tokenStandard: TokenStandard.parse(p.tokenStandard as string), + amount: BigInt(String(p.amount)), + data: Buffer.from([]), + chainIdentifier: ZenonService.getInstance().getChainId(), + }) +} diff --git a/src/core/account-block-validation.ts b/src/core/account-block-validation.ts new file mode 100644 index 0000000..8235eca --- /dev/null +++ b/src/core/account-block-validation.ts @@ -0,0 +1,146 @@ +import { bech32 } from 'bech32' + +/** + * Pure predicates over raw JSON, mirroring the SDK's own address/token-standard/ + * account-block validation exactly (not a weaker approximation), so the service + * worker's verdict and the page's `Address.parse`/`AccountBlockTemplate` + * construction can never disagree. Runnable anywhere — no SDK import, only + * `bech32`, which the SDK itself uses for the same decode. + */ + +// Network protocol constants — cannot change without a chain fork. +// dist/model/primitives/address.js:58-60 +const ADDRESS_PREFIX = 'z' +const ADDRESS_CORE_SIZE = 20 +// dist/model/primitives/tokenStandard.js:49-50 +const TOKEN_STANDARD_PREFIX = 'zts' +const TOKEN_STANDARD_CORE_SIZE = 10 + +const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ +const HEX_64_PATTERN = /^[0-9a-fA-F]{64}$/ +// A leading '0x'/'0X' is not part of the base64 alphabet's meaning here — it is +// the one unambiguous signal that a dApp sent hex instead of base64, so it is +// refused. Bare hex with no prefix (e.g. 'deadbeef') is genuinely indistinguishable +// from valid base64 and is accepted by design; rejecting it would also reject +// real base64 payloads that happen to use only [0-9a-fA-F] characters (e.g. 'AAAA'). + +export function addressProblem(value: unknown): string | null { + if (typeof value !== 'string') return 'Address must be a string.' + try { + const decoded = bech32.decode(value) // throws on a bad checksum + if (decoded.prefix !== ADDRESS_PREFIX) return `Invalid address prefix '${decoded.prefix}'.` + // NOT decoded.words.length — decode() yields 32 5-bit words for a 20-byte core. + // fromWords() also throws on invalid padding, so it must sit inside this try. + if (bech32.fromWords(decoded.words).length !== ADDRESS_CORE_SIZE) { + return 'Invalid address length.' + } + } catch { + return 'Invalid bech32 encoding for address.' + } + return null +} + +export function tokenStandardProblem(value: unknown): string | null { + if (typeof value !== 'string') return 'Token standard must be a string.' + try { + const decoded = bech32.decode(value) + if (decoded.prefix !== TOKEN_STANDARD_PREFIX) { + return `Invalid token standard prefix '${decoded.prefix}'.` + } + // NOT decoded.words.length — see addressProblem. + if (bech32.fromWords(decoded.words).length !== TOKEN_STANDARD_CORE_SIZE) { + return 'Invalid token standard length.' + } + } catch { + return 'Invalid bech32 encoding for token standard.' + } + return null +} + +function amountProblem(amount: unknown): string | null { + if (typeof amount !== 'string' && typeof amount !== 'number') { + return "Amount must be a non-negative integer in the token's smallest unit." + } + if (typeof amount === 'number' && !Number.isSafeInteger(amount)) { + return 'Amount is too large to be sent as a JSON number; pass it as a string.' + } + const asString = String(amount) + if (!/^\d+$/.test(asString)) { + return "Amount must be a non-negative integer in the token's smallest unit." + } + try { + BigInt(asString) + } catch { + return "Amount must be a non-negative integer in the token's smallest unit." + } + return null +} + +function dataProblem(data: unknown): string | null { + if (data === undefined || data === null || data === '') return null + if ( + typeof data !== 'string' || + /^0x/i.test(data) || + !BASE64_PATTERN.test(data) || + data.length % 4 !== 0 + ) { + return 'Block data must be base64.' + } + return null +} + +/** null when the JSON is a usable account block, else the user-facing reason. */ +export function accountBlockProblem(json: unknown): string | null { + if (typeof json !== 'object' || json === null || Array.isArray(json)) { + return 'Account block must be an object.' + } + + const block = json as Record + + if (block.blockType !== 2 && block.blockType !== 3) { + return 'Unsupported block type. Only send (2) and receive (3) blocks can be requested.' + } + + if (block.blockType === 2) { + const addrProblem = addressProblem(block.toAddress) + if (addrProblem) return addrProblem + + const ztsProblem = tokenStandardProblem(block.tokenStandard) + if (ztsProblem) return ztsProblem + + const amtProblem = amountProblem(block.amount) + if (amtProblem) return amtProblem + + return dataProblem(block.data) + } + + // blockType === 3 (receive). toAddress, tokenStandard and amount are + // ignored, not rejected — desktop emits them on receives, and requiring + // them would refuse AccountBlockTemplate.receive's own minimal shape. + if (typeof block.fromBlockHash !== 'string' || !HEX_64_PATTERN.test(block.fromBlockHash)) { + return 'A receive block needs a 64-character hex fromBlockHash.' + } + + if (block.data !== undefined && block.data !== null && block.data !== '') { + return 'Receive blocks cannot carry data.' + } + + return null +} + +/** null when {to, tokenStandard, amount} is usable, else the reason. */ +export function sendTransactionParamsProblem(params: unknown): string | null { + if (typeof params !== 'object' || params === null || Array.isArray(params)) { + return 'Params must be an object.' + } + + const p = params as Record + + const addrProblem = addressProblem(p.to) + if (addrProblem) return addrProblem + + const ztsProblem = tokenStandardProblem(p.tokenStandard) + if (ztsProblem) return ztsProblem + + return amountProblem(p.amount) +} diff --git a/src/core/composables/index.ts b/src/core/composables/index.ts index 9d16e87..a69ecea 100644 --- a/src/core/composables/index.ts +++ b/src/core/composables/index.ts @@ -11,6 +11,8 @@ export type { PillarWithApr } from './usePillar' export { useStorage } from './useStorage' export { usePasswordStrength } from './usePasswordStrength' export { useToken } from './useToken' +export { useDappApprovals } from './useDappApprovals' +export { useWalletConnect } from './useWalletConnect' export { runActivity } from './useActivity' export type { ActivityController } from './useActivity' export * from './utils/formatters' diff --git a/src/core/composables/useConnectedSites.ts b/src/core/composables/useConnectedSites.ts new file mode 100644 index 0000000..5c94c74 --- /dev/null +++ b/src/core/composables/useConnectedSites.ts @@ -0,0 +1,54 @@ +import { ref } from 'vue' +import type { Ref } from 'vue' +import { INTERNAL_MESSAGE_CHANNEL, type PortsDisconnectMessage } from '../inpage-protocol' +import { list, revoke, revokeAll, type SitePermission } from '../site-permissions' + +/** + * Reactive wrapper around site-permissions.ts for the settings dialog's + * connected-sites section (§6.7). Deliberately not exported from + * composables/index.ts or the @/core barrel — importing this by path keeps + * a chrome.storage-calling module out of the web bundle. + */ + +const sites = ref([]) +const isLoading = ref(false) + +function requestPortsDisconnect(origin?: string): void { + if (!__IS_EXTENSION__) return + const message: PortsDisconnectMessage = { channel: INTERNAL_MESSAGE_CHANNEL, kind: 'ports.disconnect', origin } + void chrome.runtime.sendMessage(message) +} + +export function useConnectedSites(): { + sites: Ref + isLoading: Ref + refresh(): Promise + revokeOrigin(origin: string): Promise + revokeAllSites(): Promise +} { + async function refresh(): Promise { + isLoading.value = true + try { + sites.value = await list() + } finally { + isLoading.value = false + } + } + + async function revokeOrigin(origin: string): Promise { + await revoke(origin) + // Storage is already updated above (any extension context can write + // chrome.storage.local); this additionally asks the worker to drop the + // live port, which only it can see (§6.4, §6.7). + requestPortsDisconnect(origin) + await refresh() + } + + async function revokeAllSites(): Promise { + await revokeAll() + requestPortsDisconnect() + await refresh() + } + + return { sites, isLoading, refresh, revokeOrigin, revokeAllSites } +} diff --git a/src/core/composables/useDappApprovals.ts b/src/core/composables/useDappApprovals.ts new file mode 100644 index 0000000..a2016e3 --- /dev/null +++ b/src/core/composables/useDappApprovals.ts @@ -0,0 +1,166 @@ +import { computed, ref } from 'vue' +import type { ComputedRef, Ref } from 'vue' +import { + approvalQueue, + dequeueApproval, + getHandlers, + type ApprovalHandlers, + type PendingApproval, +} from '../dapp-approvals' +import { sessionManager } from '../session-manager' +import { signMessage } from '../message-signing' +import { InvalidParamsError } from '../message-guard' +import { TransactionService } from '../transaction-service' +import { useWallet } from './useWallet' +import { runActivity } from './useActivity' + +// Module-level reactive state — shared across every useDappApprovals() caller +const isBusy = ref(false) + +const currentApproval = computed(() => approvalQueue.value[0] ?? null) +const pendingCount = computed(() => approvalQueue.value.length) + +/** + * Settles a pending approval exactly once. A handler failure — relay I/O from + * phase 2 onward — is logged and swallowed rather than retried as a second + * `handlers.reject` call; the approval is dequeued regardless of outcome. + */ +async function settle(id: string, call: () => Promise): Promise { + try { + await call() + } catch (err) { + console.error('dApp approval handler failed', err) + } finally { + dequeueApproval(id) + } +} + +export function useDappApprovals(): { + approvals: ComputedRef + currentApproval: ComputedRef + pendingCount: ComputedRef + isBusy: Ref + approve(id: string): Promise + reject(id: string): Promise +} { + // `id` is the approval the caller saw rendered when the click happened. If + // the queue head has since moved on — the previous head settled between the + // click and this call — this is a no-op rather than acting on whatever is + // now current, so a click can never approve/reject a different request + // than the one on screen when it was made. + async function approve(id: string): Promise { + const approval = currentApproval.value + if (!approval || approval.id !== id || isBusy.value) return + + isBusy.value = true + try { + // Guard: expiry. Responding to an expired request is worse than silence. + if (approval.expiresAt !== undefined && approval.expiresAt <= Date.now()) { + dequeueApproval(approval.id) + return + } + + // Guard: missing handlers. Fetched before any handler.reject call below, + // so an entry with no handlers cannot wedge the head of the queue. + const handlers: ApprovalHandlers | undefined = getHandlers(approval.id) + if (!handlers) { + dequeueApproval(approval.id) + return + } + + const wallet = useWallet() + const activeWallet = wallet.activeWallet.value + const account = activeWallet?.accounts.find( + (acc) => acc.address === wallet.activeAccountAddress.value + ) + + if (!activeWallet || !wallet.activeAccountAddress.value || !account) { + await settle(approval.id, () => handlers.reject({ reason: 'walletLocked' })) + return + } + + if (approval.action.kind === 'connect') { + await settle(approval.id, () => + handlers.resolve({ kind: 'connect', address: account.address }) + ) + return + } + + // Guard: locked wallet, for signMessage and sendBlock only. A dialog can + // sit on screen for the whole idle period, so this refreshes wallet + // state — re-rendering the dialog in its locked state — rather than + // rejecting a request the user is about to approve. + const keyStore = sessionManager.getKeyStore(activeWallet.baseAddress) + if (!keyStore) { + await wallet.loadWalletData() + return + } + const keyPair = keyStore.getKeyPair(account.index) + const action = approval.action + + try { + if (action.kind === 'signMessage') { + const { message } = action + const { signature, publicKey } = signMessage(message, keyPair) + await settle(approval.id, () => + handlers.resolve({ + kind: 'signMessage', + message, + address: account.address, + publicKey, + signature, + }) + ) + } else { + const sent = await runActivity('Sending dApp transaction', () => + TransactionService.getInstance().sendEmbeddedContractBlock(action.block, keyPair) + ) + await settle(approval.id, () => handlers.resolve({ kind: 'sendBlock', block: sent })) + } + } catch (err) { + if (err instanceof InvalidParamsError) { + await settle(approval.id, () => + handlers.reject({ reason: 'invalidParams', detail: err.message }) + ) + } else { + const detail = err instanceof Error ? err.message : 'Failed to send.' + await settle(approval.id, () => handlers.reject({ reason: 'sendFailed', detail })) + } + } + } finally { + isBusy.value = false + } + } + + async function reject(id: string): Promise { + const approval = currentApproval.value + if (!approval || approval.id !== id || isBusy.value) return + + isBusy.value = true + try { + if (approval.expiresAt !== undefined && approval.expiresAt <= Date.now()) { + dequeueApproval(approval.id) + return + } + + const handlers = getHandlers(approval.id) + if (!handlers) { + dequeueApproval(approval.id) + return + } + + await settle(approval.id, () => handlers.reject({ reason: 'userRejected' })) + } finally { + isBusy.value = false + } + } + + return { + approvals: approvalQueue, + currentApproval, + pendingCount, + isBusy, + approve, + reject, + } +} diff --git a/src/core/composables/useWalletConnect.ts b/src/core/composables/useWalletConnect.ts new file mode 100644 index 0000000..45bfa01 --- /dev/null +++ b/src/core/composables/useWalletConnect.ts @@ -0,0 +1,87 @@ +import { ref, watch } from 'vue' +import type { ComputedRef, Ref } from 'vue' +import type { SessionTypes } from '@walletconnect/types' +import { WALLETCONNECT_ENABLED } from '@/config' +import { WalletConnectService, walletConnectSessions } from '../walletconnect-service' +import { useWallet } from './useWallet' +import { useNetwork } from './useNetwork' + +/** + * Module-level singleton state for pairing and sessions only — approvals + * belong to useDappApprovals. `sessions` itself lives in + * walletconnect-service.ts (the module that receives the client's events); + * this composable only adds the operation-status state around calling it, + * matching useNetwork.ts's isChecking/error pattern. + */ +const isInitialized = ref(false) +const isPairing = ref(false) +const error = ref(null) +let watchersStarted = false + +export function useWalletConnect(): { + isEnabled: boolean + isInitialized: Ref + isPairing: Ref + sessions: ComputedRef + error: Ref + initialize(): Promise + pair(uri: string): Promise + disconnect(topic: string): Promise +} { + const service = WalletConnectService.getInstance() + + function startWatchers(): void { + if (watchersStarted) return + watchersStarted = true + + const wallet = useWallet() + const network = useNetwork() + + watch(wallet.activeAccountAddress, (address) => { + if (address) void service.setActiveAccount(address) + }) + watch(network.chainId, (chainId) => void service.emitChainIdChange(chainId)) + } + + async function initialize(): Promise { + if (!WALLETCONNECT_ENABLED || isInitialized.value) return + + error.value = null + try { + await service.initialize() + isInitialized.value = true + startWatchers() + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to initialize WalletConnect' + throw err + } + } + + async function pair(uri: string): Promise { + isPairing.value = true + error.value = null + try { + await service.pair(uri) + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to pair' + throw err + } finally { + isPairing.value = false + } + } + + async function disconnect(topic: string): Promise { + await service.disconnect(topic) + } + + return { + isEnabled: WALLETCONNECT_ENABLED, + isInitialized, + isPairing, + sessions: walletConnectSessions, + error, + initialize, + pair, + disconnect, + } +} diff --git a/src/core/dapp-approvals.ts b/src/core/dapp-approvals.ts new file mode 100644 index 0000000..d92d5b1 --- /dev/null +++ b/src/core/dapp-approvals.ts @@ -0,0 +1,93 @@ +import { computed, shallowRef } from 'vue' +import type { AccountBlockTemplate } from 'znn-typescript-sdk' +import { MAX_PENDING_DAPP_REQUESTS } from '@/config' + +/** + * The page-side approval queue: the type vocabulary shared by both dApp + * surfaces, and the FIFO queue that backs the one dialog component. Follows + * `pow-status.ts` — a module-level ref inside `src/core`, not a composable — + * because the module is page-only (it names `AccountBlockTemplate`) and every + * consumer needs the same reactive view regardless of who enqueues. + */ + +export type ApprovalSurface = 'walletconnect' | 'inpage' + +export type ApprovalAction = + | { kind: 'connect' } + | { kind: 'signMessage'; message: string } + | { kind: 'sendBlock'; block: AccountBlockTemplate } + +export interface PendingApproval { + id: string // `wc:${topic}:${rpcId}` or `ip:${uuid}` + surface: ApprovalSurface + origin: string // WC peer url, or the requesting frame's origin + topFrameHost?: string // set for a subframe request + peer: { name: string; url: string; icons: string[] } + createdAt: number + expiresAt?: number + action: ApprovalAction +} + +export interface ApprovalHandlers { + resolve(result: ApprovalResult): Promise + reject(reason: WalletRejection): Promise +} + +export type ApprovalResult = + | { kind: 'connect'; address: string } + | { kind: 'signMessage'; message: string; address: string; publicKey: string; signature: string } + | { kind: 'sendBlock'; block: AccountBlockTemplate } + +/** Surface-neutral failure reasons. Each surface maps these to its own codes. */ +export type WalletRejection = + | { reason: 'userRejected' } + | { reason: 'walletLocked' } + | { reason: 'invalidParams'; detail: string } + | { reason: 'notConnected' } + | { reason: 'unsupportedMethod' } + | { reason: 'sendFailed'; detail: string } + | { reason: 'tooManyRequests' } + +// shallowRef, not ref: a sendBlock action carries an AccountBlockTemplate, and +// a deep ref would proxy it, causing re-render churn during PoW and handing a +// Proxy back to the dApp on resolve. The array itself stays reactive because +// every mutation below replaces queue.value with a new array. +const queue = shallowRef([]) +const handlers = new Map() + +/** Read-only reactive view for useDappApprovals. */ +export const approvalQueue = computed(() => queue.value) + +function prune(): void { + const now = Date.now() + const expired = queue.value.filter((a) => a.expiresAt !== undefined && a.expiresAt <= now) + if (expired.length === 0) return + + for (const approval of expired) { + handlers.delete(approval.id) + } + queue.value = queue.value.filter((a) => a.expiresAt === undefined || a.expiresAt > now) +} + +export function enqueueApproval(a: PendingApproval, h: ApprovalHandlers): boolean { + prune() + if (queue.value.length >= MAX_PENDING_DAPP_REQUESTS) return false + + queue.value = [...queue.value, a] + handlers.set(a.id, h) + return true +} + +export function dequeueApproval(id: string): void { + queue.value = queue.value.filter((a) => a.id !== id) + handlers.delete(id) +} + +export function listApprovals(): PendingApproval[] { + prune() + return queue.value +} + +export function getHandlers(id: string): ApprovalHandlers | undefined { + return handlers.get(id) +} diff --git a/src/core/index.ts b/src/core/index.ts index 1442b98..e2dfd45 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -6,6 +6,7 @@ export type { RewardType, RewardInfo } from './rewards-service' export { PlasmaBotError, PLASMA_BOT_TIERS } from './plasma-bot-service' export type { PlasmaBotTierKey, PlasmaBotTier } from './plasma-bot-service' export { isGeneratingPow } from './pow-status' +export type { PendingApproval } from './dapp-approvals' export { estimatePasswordStrength } from './password-strength' export type { PasswordStrength } from './password-strength' export * from './composables' diff --git a/src/core/inpage-approval-bridge.ts b/src/core/inpage-approval-bridge.ts new file mode 100644 index 0000000..108b5b3 --- /dev/null +++ b/src/core/inpage-approval-bridge.ts @@ -0,0 +1,164 @@ +import { SESSION_KEY_PENDING_REQUESTS } from '@/config' +import { listInbox } from '@/sw/approval-inbox' +import { ZenonService } from './zenon-service' +import { InvalidParamsError, parseAccountBlockJson, parseSendTransactionParams } from './account-block-params' +import { + enqueueApproval, + type ApprovalAction, + type ApprovalHandlers, + type ApprovalResult, + type PendingApproval, + type WalletRejection, +} from './dapp-approvals' +import { + ERR_INVALID_PARAMS, + ERR_TOO_MANY_REQUESTS, + INTERNAL_MESSAGE_CHANNEL, + ZNN_CONNECT, + ZNN_SEND_TRANSACTION, + ZNN_SIGN, + ZNN_SIGN_AND_SEND_BLOCK, + type ApprovalsSettleMessage, + type InboxEntry, + type ProviderError, +} from './inpage-protocol' + +/** + * Converts service-worker inbox entries into the shared approval queue + * (dapp-approvals.ts) that DappApprovalDialog.vue already renders (§6.5). + * Page-only — started once by ApproveApp.vue on mount. Reaches into + * sw/approval-inbox.ts for a plain storage read; that module otherwise + * requires chrome.windows, which is fine here too (the approval window is a + * full trusted extension page, not a content script — unlike phase 3a's + * inpage.ts/content-script.ts, nothing here is subject to a visited page's + * CSP, so there is no reason to avoid the shared chunk). + */ + +const processed = new Set() + +function postSettle(id: string, payload: { result?: unknown; error?: ProviderError }): void { + const message: ApprovalsSettleMessage = { + channel: INTERNAL_MESSAGE_CHANNEL, + kind: 'approvals.settle', + id, + ...payload, + } + void chrome.runtime.sendMessage(message) +} + +function toOutgoingError(rejection: WalletRejection): ProviderError { + switch (rejection.reason) { + case 'userRejected': + return { code: 4001, message: 'User rejected the request.' } + case 'walletLocked': + return { code: 4900, message: 'Wallet is locked.' } + case 'invalidParams': + return { code: ERR_INVALID_PARAMS, message: rejection.detail } + case 'notConnected': + return { code: 4100, message: 'Origin is not connected.' } + case 'unsupportedMethod': + return { code: 4200, message: 'Unsupported method.' } + case 'sendFailed': + return { code: -32603, message: rejection.detail } + case 'tooManyRequests': + return { code: ERR_TOO_MANY_REQUESTS, message: 'Too many pending requests.' } + } +} + +/** §6.1: sendTransaction returns a bare hash; sendAccountBlock returns the block JSON. */ +function toProviderResult(method: InboxEntry['method'], result: ApprovalResult): unknown { + switch (result.kind) { + case 'connect': + return result.address + case 'signMessage': + return { + message: result.message, + address: result.address, + publicKey: result.publicKey, + signature: result.signature, + } + case 'sendBlock': + return method === ZNN_SEND_TRANSACTION ? result.block.hash.toString() : result.block.toJson() + } +} + +function buildAction(entry: InboxEntry): ApprovalAction { + switch (entry.method) { + case ZNN_CONNECT: + return { kind: 'connect' } + case ZNN_SIGN: + return { kind: 'signMessage', message: entry.params as string } + case ZNN_SEND_TRANSACTION: + return { kind: 'sendBlock', block: parseSendTransactionParams(entry.params) } + case ZNN_SIGN_AND_SEND_BLOCK: + return { kind: 'sendBlock', block: parseAccountBlockJson(entry.params) } + } +} + +function claim(entry: InboxEntry): void { + if (processed.has(entry.id)) return + processed.add(entry.id) + + let action: ApprovalAction + try { + action = buildAction(entry) + } catch (err) { + // Not expected — the worker ran the same validators over the same JSON + // (§1.2) — but handled as a real error path: the entry settles with no + // dialog rendered, rather than assumed away (§6.5). + const message = err instanceof InvalidParamsError ? err.message : 'Invalid request.' + postSettle(entry.id, { error: { code: ERR_INVALID_PARAMS, message } }) + return + } + + const approval: PendingApproval = { + id: entry.id, + surface: 'inpage', + origin: entry.origin, + topFrameHost: entry.topFrameHost, + peer: { + name: entry.title ?? entry.origin, + url: entry.origin, + icons: entry.favicon ? [entry.favicon] : [], + }, + createdAt: entry.createdAt, + action, + } + + const handlers: ApprovalHandlers = { + resolve: async (result) => { + postSettle(entry.id, { result: toProviderResult(entry.method, result) }) + }, + reject: async (reason) => { + postSettle(entry.id, { error: toOutgoingError(reason) }) + }, + } + + // The shared queue enforces its own MAX_PENDING_DAPP_REQUESTS cap. The + // worker's inbox cap already keeps this from being reachable in practice, + // since both queues share the same limit (§1.4). + const enqueued = enqueueApproval(approval, handlers) + if (!enqueued) { + postSettle(entry.id, { error: { code: ERR_TOO_MANY_REQUESTS, message: 'Too many pending requests.' } }) + } +} + +async function claimAll(): Promise { + await ZenonService.getInstance().ensureInitialized() + const current = (await chrome.windows.getCurrent()).id + const inbox = await listInbox() + for (const entry of inbox) { + if (entry.windowId === current) claim(entry) + } +} + +/** Starts draining the inbox into the shared approval queue. Idempotent — safe to call once. */ +export function startInboxBridge(): void { + void claimAll() + + chrome.storage.onChanged.addListener((changes, areaName) => { + if (areaName === 'session' && SESSION_KEY_PENDING_REQUESTS in changes) { + void claimAll() + } + }) +} diff --git a/src/core/inpage-protocol.ts b/src/core/inpage-protocol.ts new file mode 100644 index 0000000..80e184d --- /dev/null +++ b/src/core/inpage-protocol.ts @@ -0,0 +1,209 @@ +import { + accountBlockProblem, + addressProblem, + sendTransactionParamsProblem, + tokenStandardProblem, +} from './account-block-validation' + +/** + * Shared vocabulary between the service worker and every page context + * (content script, inpage script, approval window). Zero SDK imports, so it + * is safe to import from the service worker. Provider-only — phases 1 and 2 + * never import this file, and the web bundle never sees these names. + */ + +export { accountBlockProblem, addressProblem, sendTransactionParamsProblem, tokenStandardProblem } + +// --- postMessage bridge targets (inpage.ts ↔ content-script.ts, §6.3) --- +export const TARGET_CONTENT_SCRIPT = 'znn-contentscript' +export const TARGET_INPAGE = 'znn-inpage' + +/** Name of the chrome.runtime.Port content-script.ts opens to the worker. */ +export const PROVIDER_PORT_NAME = 'znn-provider' + +/** `message.channel` for the one-shot read-only request/response below. */ +export const PROVIDER_MESSAGE_CHANNEL = 'znn-provider' + +// --- Wire method names (§6.1) --- +// Read-only, one-shot chrome.runtime.sendMessage (phase 3a). +export const ZNN_ACCOUNTS = 'znn_accounts' +export const ZNN_CHAIN_ID = 'znn_chainId' +export const ZNN_NODE_URL = 'znn_nodeUrl' +export const ZNN_DISCONNECT = 'znn_disconnect' +// Prompting, chrome.runtime.Port (phase 3b). Named now so the whole wire +// contract is one vocabulary; not reachable until window.zenon exposes them. +export const ZNN_CONNECT = 'znn_connect' +export const ZNN_SIGN = 'znn_sign' +export const ZNN_SEND_TRANSACTION = 'znn_sendTransaction' +export const ZNN_SIGN_AND_SEND_BLOCK = 'znn_signAndSendBlock' + +export type ReadOnlyMethod = + | typeof ZNN_ACCOUNTS + | typeof ZNN_CHAIN_ID + | typeof ZNN_NODE_URL + | typeof ZNN_DISCONNECT + +export type PromptingMethod = + | typeof ZNN_CONNECT + | typeof ZNN_SIGN + | typeof ZNN_SEND_TRANSACTION + | typeof ZNN_SIGN_AND_SEND_BLOCK + +/** + * Content-script-internal check, never exposed on `window.zenon`. Lets the + * content script learn its origin's grant state at `document_start` — before + * the page has called anything — so it can decide whether to open its port + * (§6.4: "opened at document_start if the origin is already granted"). + */ +export const ZNN_IS_GRANTED = 'znn_isGranted' + +// --- EIP-1193 error codes (§6.2) --- +export const ERR_USER_REJECTED = 4001 +export const ERR_NOT_CONNECTED = 4100 +export const ERR_UNKNOWN_METHOD = 4200 +export const ERR_WALLET_UNAVAILABLE = 4900 +export const ERR_INVALID_PARAMS = -32602 +export const ERR_TRANSPORT_OR_SEND_FAILED = -32603 +export const ERR_TOO_MANY_REQUESTS = -32005 + +export interface ProviderError { + code: number + message: string +} + +// --- One-shot read-only channel (page → content script → SW → content script → page) --- + +/** content-script.ts → src/sw/router.ts, over chrome.runtime.sendMessage. */ +export interface ReadOnlyRequest { + channel: typeof PROVIDER_MESSAGE_CHANNEL + method: ReadOnlyMethod | typeof ZNN_IS_GRANTED +} + +/** src/sw/router.ts → content-script.ts, in the sendResponse callback. */ +export interface ReadOnlyResponse { + granted: boolean + result?: unknown + error?: ProviderError +} + +// --- Port channel (page → content script → SW, long-lived) --- + +export type PortMessageKind = 'request' | 'accepted' | 'outcome' | 'resync' | 'resyncResult' | 'event' + +export type ProviderEventName = 'accountsChanged' | 'chainChanged' | 'nodeChanged' + +/** SW → content script → page, fanned out to every port of a granted origin (§6.8). */ +export interface PortEventMessage { + kind: 'event' + name: ProviderEventName + data: unknown +} + +/** content-script.ts → SW: a prompting call, id minted by the content script. */ +export interface PortRequestMessage { + kind: 'request' + id: string + method: PromptingMethod + params: unknown +} + +/** content-script.ts → SW, on reconnect, for every still-outstanding request id. */ +export interface PortResyncMessage { + kind: 'resync' + ids: string[] +} + +/** SW → content-script.ts: the request id is now durably tracked (inbox or outcome store). */ +export interface PortAcceptedMessage { + kind: 'accepted' + id: string +} + +/** SW → content-script.ts, delivered to exactly one port (§6.4) — never fanned out. */ +export interface PortOutcomeMessage { + kind: 'outcome' + id: string + result?: unknown + error?: ProviderError +} + +/** SW → content-script.ts, reply to `resync`. An id is `unknown` only if it is in neither the inbox nor the outcome store. */ +export interface PortResyncResultMessage { + kind: 'resyncResult' + unknown: string[] +} + +export type PortMessageToWorker = PortRequestMessage | PortResyncMessage +export type PortMessageFromWorker = + | PortAcceptedMessage + | PortOutcomeMessage + | PortResyncResultMessage + | PortEventMessage + +// --- The service-worker inbox (§6.5) --- + +export interface InboxEntry { + id: string // `ip:${uuid}`, minted by the content script (§6.4) + origin: string // the requesting frame's origin, from port.sender.origin + tabId: number // from port.sender.tab.id — the outcome's destination + frameId: number // from port.sender.frameId + topFrameHost?: string // host of port.sender.tab.url; set only when frameId !== 0 + title?: string + favicon?: string + method: PromptingMethod + params: unknown // already validated + createdAt: number + windowId?: number // stamped when a window is shown the entry +} + +// --- The internal settle channel (approval window → SW, chrome.runtime.sendMessage) --- + +/** `message.channel` for the approval window's settle notification. */ +export const INTERNAL_MESSAGE_CHANNEL = 'internal' + +export interface ApprovalsSettleMessage { + channel: typeof INTERNAL_MESSAGE_CHANNEL + kind: 'approvals.settle' + id: string + result?: unknown + error?: ProviderError +} + +/** + * useConnectedSites.ts → SW: a revoke initiated from the settings dialog, + * not the dApp's own tab. Storage is already updated by the caller (any + * extension context can write chrome.storage.local); this asks the worker + * to also disconnect the live port(s), which only it can see (§6.4, §6.7). + * Omit `origin` to disconnect every open port (revoke all). + */ +export interface PortsDisconnectMessage { + channel: typeof INTERNAL_MESSAGE_CHANNEL + kind: 'ports.disconnect' + origin?: string +} + +// --- window.postMessage bridge shapes (inpage.ts ↔ content-script.ts) --- + +/** inpage.ts → content-script.ts, for a read-only or prompting call. */ +export interface InpageRequestMessage { + target: typeof TARGET_CONTENT_SCRIPT + id: string + method: ReadOnlyMethod | PromptingMethod + params?: unknown +} + +/** content-script.ts → inpage.ts, settling a pending call. */ +export interface InpageResponseMessage { + target: typeof TARGET_INPAGE + id: string + result?: unknown + error?: ProviderError +} + +/** content-script.ts → inpage.ts, forwarding a fanned-out event. */ +export interface InpageEventMessage { + target: typeof TARGET_INPAGE + kind: 'event' + name: ProviderEventName + data: unknown +} diff --git a/src/core/message-guard.ts b/src/core/message-guard.ts new file mode 100644 index 0000000..893924e --- /dev/null +++ b/src/core/message-guard.ts @@ -0,0 +1,36 @@ +/** + * Pure, dependency-free rules for what a dApp may ask the wallet to sign as a + * message. No imports at all, so this module runs unchanged in the page, a + * content script and the service worker — `TextEncoder` is a platform global + * in all three. + */ + +/** Both limits are measured in UTF-8 bytes, never characters. */ +export const MAX_MESSAGE_LENGTH = 8192 +export const BLOCK_HASH_LENGTH = 32 + +/** Thrown by the page-side parsers and signer when a dApp's params are unusable. */ +export class InvalidParamsError extends Error {} + +/** UTF-8. See message-signing.ts for the interop rationale. */ +export function encodeMessage(message: string): Uint8Array { + return new TextEncoder().encode(message) +} + +/** null when the message is signable, else the user-facing reason. */ +export function messageProblem(message: unknown): string | null { + if (typeof message !== 'string') return 'Message must be a string.' + if (message.length === 0) return 'Message must not be empty.' + + const bytes = encodeMessage(message).length + + if (bytes > MAX_MESSAGE_LENGTH) { + return `Message is too long (${bytes} bytes, maximum ${MAX_MESSAGE_LENGTH}).` + } + + if (bytes === BLOCK_HASH_LENGTH) { + return 'A 32-byte message is the size of an account block hash and cannot be signed. Add or remove a character.' + } + + return null +} diff --git a/src/core/message-signing.ts b/src/core/message-signing.ts new file mode 100644 index 0000000..750efdf --- /dev/null +++ b/src/core/message-signing.ts @@ -0,0 +1,20 @@ +import { Buffer } from 'buffer' +import type { KeyPair } from 'znn-typescript-sdk' +import { encodeMessage, InvalidParamsError, messageProblem } from './message-guard' + +/** + * Page-only signing entry point for the dApp surfaces. The wallet, never the + * dApp, chooses which key signs — callers pass the active account's keypair. + */ +export function signMessage( + message: string, + keyPair: KeyPair +): { signature: string; publicKey: string } { + const problem = messageProblem(message) + if (problem) throw new InvalidParamsError(problem) + + return { + signature: keyPair.sign(Buffer.from(encodeMessage(message))).toString('hex'), + publicKey: keyPair.getPublicKey().toString('hex'), + } +} diff --git a/src/core/site-permissions.ts b/src/core/site-permissions.ts new file mode 100644 index 0000000..092580c --- /dev/null +++ b/src/core/site-permissions.ts @@ -0,0 +1,80 @@ +import { STORAGE_KEY_SITE_PERMISSIONS } from '@/config' + +/** + * Origin grants for the injected provider (§6.7). A grant covers the + * read-only methods only — signing and sending prompt every time and are + * never remembered. No expiry. Every function is a no-op (or returns an + * empty result) when `!__IS_EXTENSION__`, so the module is inert if it ever + * reaches the web bundle. + */ + +export interface SitePermission { + origin: string + title?: string + favicon?: string + connectedAt: number + lastUsedAt: number +} + +type PermissionsMap = Record + +async function readAll(): Promise { + if (!__IS_EXTENSION__) return {} + const stored = await chrome.storage.local.get(STORAGE_KEY_SITE_PERMISSIONS) + const value = stored[STORAGE_KEY_SITE_PERMISSIONS] as PermissionsMap | undefined + return value && typeof value === 'object' ? value : {} +} + +async function writeAll(map: PermissionsMap): Promise { + await chrome.storage.local.set({ [STORAGE_KEY_SITE_PERMISSIONS]: map }) +} + +export async function grant( + origin: string, + meta: { title?: string; favicon?: string } = {} +): Promise { + if (!__IS_EXTENSION__) return + const map = await readAll() + const now = Date.now() + const existing = map[origin] + map[origin] = { + origin, + title: meta.title ?? existing?.title, + favicon: meta.favicon ?? existing?.favicon, + connectedAt: existing?.connectedAt ?? now, + lastUsedAt: now, + } + await writeAll(map) +} + +export async function revoke(origin: string): Promise { + if (!__IS_EXTENSION__) return + const map = await readAll() + delete map[origin] + await writeAll(map) +} + +export async function revokeAll(): Promise { + if (!__IS_EXTENSION__) return + await writeAll({}) +} + +export async function touch(origin: string): Promise { + if (!__IS_EXTENSION__) return + const map = await readAll() + if (!map[origin]) return + map[origin] = { ...map[origin], lastUsedAt: Date.now() } + await writeAll(map) +} + +export async function isGranted(origin: string): Promise { + if (!__IS_EXTENSION__) return false + const map = await readAll() + return origin in map +} + +export async function list(): Promise { + if (!__IS_EXTENSION__) return [] + const map = await readAll() + return Object.values(map) +} diff --git a/src/core/storage/walletconnect-storage.ts b/src/core/storage/walletconnect-storage.ts new file mode 100644 index 0000000..ee340e7 --- /dev/null +++ b/src/core/storage/walletconnect-storage.ts @@ -0,0 +1,77 @@ +import type { IKeyValueStorage } from '@walletconnect/keyvaluestorage' +import type { StorageAdapter } from '@/types' +import { storageService } from './storage-service' +import { WC_STORAGE_PREFIX } from '@/config' + +/** + * `SignClient.init({storage})` accepts a custom store, which keeps WalletConnect + * state in the same place as wallet state in both delivery modes. `StorageAdapter` + * has no enumeration method, so this class maintains its own index — one extra key, + * `${prefix}__index`, holding the list of WalletConnect key names — rather than + * widening the shared three-method interface for this one consumer. + */ +export class WalletConnectStorage implements IKeyValueStorage { + // Every mutation is serialised through this promise: sign-client's keychain, + // subscription, pairing and session stores each write independently with no + // serialisation of their own, and two overlapping read-modify-writes of the + // index would drop a key name — the corresponding record then silently + // vanishes from the next init(). + private tail: Promise = Promise.resolve() + + constructor( + private storage: StorageAdapter = storageService, + private prefix: string = WC_STORAGE_PREFIX + ) {} + + private indexKey(): string { + return `${this.prefix}__index` + } + + private async readIndex(): Promise { + const index = await this.storage.get(this.indexKey()) + // A corrupt or missing index is treated as empty: sign-client re-pairs + // rather than crashing. + return Array.isArray(index) ? index : [] + } + + private mutateIndex(mutate: (index: string[]) => string[]): Promise { + const next = this.tail.then(async () => { + const index = await this.readIndex() + await this.storage.set(this.indexKey(), mutate(index)) + }) + this.tail = next + return next + } + + async getKeys(): Promise { + return this.readIndex() + } + + async getEntries(): Promise<[string, T][]> { + const keys = await this.readIndex() + const entries: [string, T][] = [] + for (const key of keys) { + const value = await this.storage.get(`${this.prefix}${key}`) + if (value !== null) entries.push([key, value]) + } + return entries + } + + async getItem(key: string): Promise { + // StorageAdapter.get returns null for an absent key; sign-client's Store + // treats null as a present value, so this must normalise to undefined. + const value = await this.storage.get(`${this.prefix}${key}`) + return value === null ? undefined : value + } + + async setItem(key: string, value: T): Promise { + await this.storage.set(`${this.prefix}${key}`, value) + await this.mutateIndex((index) => (index.includes(key) ? index : [...index, key])) + } + + async removeItem(key: string): Promise { + await this.storage.remove(`${this.prefix}${key}`) + // Update the index even when the value was already absent. + await this.mutateIndex((index) => index.filter((k) => k !== key)) + } +} diff --git a/src/core/wallet-service.ts b/src/core/wallet-service.ts index 6e9f834..5bb7d11 100644 --- a/src/core/wallet-service.ts +++ b/src/core/wallet-service.ts @@ -1,12 +1,10 @@ import {KeyFile, KeyStore} from 'znn-typescript-sdk' import type {StorageAdapter, Wallet, WalletAccount, WalletStorage} from '@/types' -import {KDF_CONFIG} from '@/config' +import {KDF_CONFIG, STORAGE_KEY_WALLETS as STORAGE_KEY} from '@/config' import {sessionManager} from './session-manager' import {storageService} from './storage/storage-service' import {Buffer} from 'buffer' -const STORAGE_KEY = 'nom-wallet-storage' - const MAX_UNLOCK_ATTEMPTS = 5 const BASE_LOCKOUT_MS = 5_000 // 5 seconds after 5th failed attempt diff --git a/src/core/walletconnect-client.ts b/src/core/walletconnect-client.ts new file mode 100644 index 0000000..06ef480 --- /dev/null +++ b/src/core/walletconnect-client.ts @@ -0,0 +1,42 @@ +import type { SessionTypes, SignClientTypes } from '@walletconnect/types' + +/** + * The vendor seam: one interface, one adapter (`walletconnect-sign-client-adapter.ts`) + * construct and call `@walletconnect/sign-client`. Everything else — session, approval + * and signing logic — talks to this interface only, so a licence change or a different + * relay needs a new adapter and nothing else. + */ + +/** Semantic rejection reasons. The adapter maps these to the vendor's error registry. */ +export type ProtocolRejection = + | 'USER_REJECTED' + | 'UNSUPPORTED_CHAINS' + | 'UNSUPPORTED_METHODS' + | 'UNSUPPORTED_NAMESPACE_KEY' + | 'UNSUPPORTED_METHOD' + +export type OutgoingError = + | { kind: 'protocol'; rejection: ProtocolRejection } + | { kind: 'literal'; code: number; message: string } + +export interface WalletConnectClientHandlers { + onProposal(p: SignClientTypes.EventArguments['session_proposal']): void + onRequest(r: SignClientTypes.EventArguments['session_request']): void + onSessionsChanged(s: SessionTypes.Struct[]): void +} + +export interface IWalletConnectClient { + init(handlers: WalletConnectClientHandlers): Promise + pair(uri: string): Promise + listSessions(): SessionTypes.Struct[] + // Namespaces here, not ProposalTypes.RequiredNamespaces: the approved namespace + // carries `accounts` (SessionTypes.Namespace), which a required namespace doesn't. + approveProposal(id: number, namespaces: SessionTypes.Namespaces): Promise + rejectProposal(id: number, error: OutgoingError): Promise + respond(topic: string, rpcId: number, result: unknown): Promise + respondError(topic: string, rpcId: number, error: OutgoingError): Promise + updateSessionAccounts(topic: string, accounts: string[]): Promise + emit(topic: string, event: { name: string; data: unknown }): Promise + disconnect(topic: string): Promise + destroy(): Promise +} diff --git a/src/core/walletconnect-service.ts b/src/core/walletconnect-service.ts new file mode 100644 index 0000000..171fe31 --- /dev/null +++ b/src/core/walletconnect-service.ts @@ -0,0 +1,435 @@ +import { computed, shallowRef, watch } from 'vue' +import { toast } from 'vue-sonner' +import type { AccountBlockTemplate } from 'znn-typescript-sdk' +import type { SessionTypes, SignClientTypes } from '@walletconnect/types' +import { WalletService } from './wallet-service' +import { ZenonService } from './zenon-service' +import { messageProblem } from './message-guard' +import { accountBlockProblem } from './account-block-validation' +import { InvalidParamsError, parseAccountBlockJson } from './account-block-params' +import { approvalQueue, enqueueApproval, type WalletRejection } from './dapp-approvals' +import type { IWalletConnectClient, OutgoingError } from './walletconnect-client' + +/** + * Orchestration for the WalletConnect surface — the interop contract (§2.1 in + * the design), enqueued through the shared approval queue (dapp-approvals.ts) + * and rendered by the one dialog component. `sessions` is module-level state + * fed by the client's event handlers, following dapp-approvals.ts's own + * pattern: only this module receives those events, so it is the owner. + */ + +const ZENON_NAMESPACE = 'zenon' +const ZENON_CHAIN = 'zenon:1' +const SUPPORTED_METHODS = ['znn_info', 'znn_sign', 'znn_send'] +const SUPPORTED_EVENTS = ['chainIdChange', 'addressChange'] + +const WALLET_LOCKED_ERROR: OutgoingError = { kind: 'literal', code: 9000, message: 'Wallet is locked' } + +function invalidParamsError(message: string): OutgoingError { + return { kind: 'literal', code: -32602, message } +} + +function tooManyRequestsError(): OutgoingError { + return { kind: 'literal', code: -32005, message: 'Too many pending requests' } +} + +function sendFailedError(message: string): OutgoingError { + return { kind: 'literal', code: -32603, message } +} + +function toOutgoingError(rejection: WalletRejection): OutgoingError { + switch (rejection.reason) { + case 'userRejected': + return { kind: 'protocol', rejection: 'USER_REJECTED' } + case 'walletLocked': + return WALLET_LOCKED_ERROR + case 'invalidParams': + return invalidParamsError(rejection.detail) + case 'unsupportedMethod': + return { kind: 'protocol', rejection: 'UNSUPPORTED_METHOD' } + case 'tooManyRequests': + return tooManyRequestsError() + case 'sendFailed': + return sendFailedError(rejection.detail) + case 'notConnected': + // Not reachable on this surface — a session is the connection (§4). + return invalidParamsError('No active session.') + } +} + +/** Canonical form is a bare string; also accepts `["msg"]` and `{message: "…"}`. */ +function parseSignParams(params: unknown): string | null { + if (typeof params === 'string') return params + if (Array.isArray(params) && params.length === 1 && typeof params[0] === 'string') { + return params[0] + } + if (params && typeof params === 'object' && typeof (params as Record).message === 'string') { + return (params as Record).message as string + } + return null +} + +/** Canonical form is {accountBlock, fromAddress}; also accepts a single-element array. */ +function parseSendParams(params: unknown): { accountBlock: unknown; fromAddress: unknown } | null { + const candidate = Array.isArray(params) && params.length === 1 ? params[0] : params + if ( + candidate && + typeof candidate === 'object' && + 'accountBlock' in candidate && + 'fromAddress' in candidate + ) { + const c = candidate as Record + return { accountBlock: c.accountBlock, fromAddress: c.fromAddress } + } + return null +} + +const sessions = shallowRef([]) +/** Read-only reactive view for useWalletConnect. */ +export const walletConnectSessions = computed(() => sessions.value) + +export class WalletConnectService { + private static instance: WalletConnectService | null = null + + static getInstance(): WalletConnectService { + if (!WalletConnectService.instance) { + WalletConnectService.instance = new WalletConnectService() + } + return WalletConnectService.instance + } + + private client: IWalletConnectClient | null = null + private initPromise: Promise | null = null + private badgeWatcherStarted = false + + // The client is not a defaulted constructor argument: a default would be a + // static import of the adapter, which statically imports the SDK, putting the + // whole WalletConnect dependency graph into the main chunk of every page + // context — including the approval window and the action popup. + initialize(client?: IWalletConnectClient): Promise { + if (this.initPromise) return this.initPromise + + this.initPromise = (async () => { + const impl = + client ?? new (await import('./walletconnect-sign-client-adapter')).SignClientAdapter() + this.client = impl + + await impl.init({ + onProposal: (p) => void this.handleProposal(p), + onRequest: (r) => void this.handleRequest(r), + onSessionsChanged: (list) => { + sessions.value = list + }, + }) + + this.startBadgeWatcher() + })() + + return this.initPromise + } + + private requireClient(): IWalletConnectClient { + if (!this.client) throw new Error('WalletConnect client is not initialized.') + return this.client + } + + async pair(uri: string): Promise { + await this.requireClient().pair(uri) + } + + async disconnect(topic: string): Promise { + await this.requireClient().disconnect(topic) + sessions.value = sessions.value.filter((s) => s.topic !== topic) + } + + /** §2.3 item 4 — account-switch broadcast, driven by useWalletConnect's watcher. */ + async setActiveAccount(address: string): Promise { + if (!this.client) return + for (const session of this.client.listSessions()) { + await this.client.updateSessionAccounts(session.topic, [`${ZENON_CHAIN}:${address}`]) + await this.client.emit(session.topic, { name: 'addressChange', data: address }) + } + } + + /** §2.3 item 4 — chain-change broadcast, driven by useWalletConnect's watcher. */ + async emitChainIdChange(chainId: number): Promise { + if (!this.client) return + for (const session of this.client.listSessions()) { + await this.client.emit(session.topic, { name: 'chainIdChange', data: chainId }) + } + } + + private getPeer(topic: string): { name: string; url: string; icons: string[] } { + const session = this.requireClient() + .listSessions() + .find((s) => s.topic === topic) + const metadata = session?.peer.metadata + return { + name: metadata?.name ?? 'Unknown dApp', + url: metadata?.url ?? '', + icons: metadata?.icons ?? [], + } + } + + private async handleProposal( + p: SignClientTypes.EventArguments['session_proposal'] + ): Promise { + const client = this.requireClient() + const required = p.params.requiredNamespaces + + for (const key of Object.keys(required)) { + if (key !== ZENON_NAMESPACE) { + await client.rejectProposal(p.id, { kind: 'protocol', rejection: 'UNSUPPORTED_NAMESPACE_KEY' }) + return + } + } + + const zenonNamespace = required[ZENON_NAMESPACE] + if (zenonNamespace) { + const chains = zenonNamespace.chains ?? [] + if (chains.some((chain) => chain !== ZENON_CHAIN)) { + await client.rejectProposal(p.id, { kind: 'protocol', rejection: 'UNSUPPORTED_CHAINS' }) + return + } + if (zenonNamespace.methods.some((method) => !SUPPORTED_METHODS.includes(method))) { + await client.rejectProposal(p.id, { kind: 'protocol', rejection: 'UNSUPPORTED_METHODS' }) + return + } + } + + const activeAddress = await WalletService.getInstance().getActiveAccount() + if (!activeAddress) { + await client.rejectProposal(p.id, { kind: 'protocol', rejection: 'USER_REJECTED' }) + toast.error('A wallet must be created before a dApp can connect.') + return + } + + const proposer = p.params.proposer.metadata + const enqueued = enqueueApproval( + { + id: `wc:proposal:${p.id}`, + surface: 'walletconnect', + origin: proposer.url, + peer: { name: proposer.name, url: proposer.url, icons: proposer.icons }, + createdAt: Date.now(), + action: { kind: 'connect' }, + }, + { + resolve: async (result) => { + if (result.kind !== 'connect') return + await client.approveProposal(p.id, { + [ZENON_NAMESPACE]: { + chains: [ZENON_CHAIN], + methods: SUPPORTED_METHODS, + events: SUPPORTED_EVENTS, + accounts: [`${ZENON_CHAIN}:${result.address}`], + }, + }) + // Don't wait on the SDK's session_connect event for this — it's + // emitted from onSessionSettleRequest, which is the handler for + // *receiving* a settle request (the dApp's role), not guaranteed to + // fire for the wallet that just sent it. approveProposal() already + // awaited full settlement, so the new session is in the SDK's own + // store now; read it directly instead of waiting on an event that + // may never come on this side. + sessions.value = client.listSessions() + }, + reject: async () => { + await client.rejectProposal(p.id, { kind: 'protocol', rejection: 'USER_REJECTED' }) + }, + } + ) + + if (!enqueued) { + await client.rejectProposal(p.id, tooManyRequestsError()) + return + } + await this.grabAttention() + } + + private async handleRequest( + r: SignClientTypes.EventArguments['session_request'] + ): Promise { + const client = this.requireClient() + const { topic, id: rpcId } = r + const { method, params } = r.params.request + + if (method === 'znn_info') { + await this.respondInfo(client, topic, rpcId) + return + } + + if (method === 'znn_sign') { + await this.handleSign(client, topic, rpcId, params) + return + } + + if (method === 'znn_send') { + await this.handleSend(client, topic, rpcId, params) + return + } + + await client.respondError(topic, rpcId, { kind: 'protocol', rejection: 'UNSUPPORTED_METHOD' }) + } + + private async respondInfo(client: IWalletConnectClient, topic: string, rpcId: number): Promise { + const address = await WalletService.getInstance().getActiveAccount() + if (!address) { + await client.respondError(topic, rpcId, WALLET_LOCKED_ERROR) + return + } + + const zenonService = ZenonService.getInstance() + await client.respond(topic, rpcId, { + address, + nodeUrl: zenonService.getNodeUrl(), + chainId: zenonService.getChainId(), + }) + } + + private async handleSign( + client: IWalletConnectClient, + topic: string, + rpcId: number, + rawParams: unknown + ): Promise { + const message = parseSignParams(rawParams) + if (message === null) { + await client.respondError(topic, rpcId, invalidParamsError('Malformed znn_sign params.')) + return + } + + const problem = messageProblem(message) + if (problem) { + await client.respondError(topic, rpcId, invalidParamsError(problem)) + return + } + + const peer = this.getPeer(topic) + const enqueued = enqueueApproval( + { + id: `wc:${topic}:${rpcId}`, + surface: 'walletconnect', + origin: peer.url, + peer, + createdAt: Date.now(), + action: { kind: 'signMessage', message }, + }, + { + resolve: async (result) => { + if (result.kind !== 'signMessage') return + await client.respond(topic, rpcId, { + signature: result.signature, + publicKey: result.publicKey, + }) + }, + reject: async (reason) => { + await client.respondError(topic, rpcId, toOutgoingError(reason)) + }, + } + ) + + if (!enqueued) { + await client.respondError(topic, rpcId, tooManyRequestsError()) + return + } + await this.grabAttention() + } + + private async handleSend( + client: IWalletConnectClient, + topic: string, + rpcId: number, + rawParams: unknown + ): Promise { + const parsed = parseSendParams(rawParams) + if (!parsed) { + await client.respondError(topic, rpcId, invalidParamsError('Malformed znn_send params.')) + return + } + + const activeAddress = await WalletService.getInstance().getActiveAccount() + if (!activeAddress) { + await client.respondError(topic, rpcId, WALLET_LOCKED_ERROR) + return + } + if (parsed.fromAddress !== activeAddress) { + // The wallet, never the dApp, decides which key signs — a fromAddress + // that isn't the active account is refused here, before any dialog. + await client.respondError(topic, rpcId, invalidParamsError('fromAddress is not the active account.')) + return + } + + const problem = accountBlockProblem(parsed.accountBlock) + if (problem) { + await client.respondError(topic, rpcId, invalidParamsError(problem)) + return + } + + let block: AccountBlockTemplate + try { + await ZenonService.getInstance().ensureInitialized() + block = parseAccountBlockJson(parsed.accountBlock) + } catch (err) { + const message = err instanceof InvalidParamsError ? err.message : 'Invalid account block.' + await client.respondError(topic, rpcId, invalidParamsError(message)) + return + } + + const peer = this.getPeer(topic) + const enqueued = enqueueApproval( + { + id: `wc:${topic}:${rpcId}`, + surface: 'walletconnect', + origin: peer.url, + peer, + createdAt: Date.now(), + action: { kind: 'sendBlock', block }, + }, + { + resolve: async (result) => { + if (result.kind !== 'sendBlock') return + await client.respond(topic, rpcId, result.block.toJson()) + }, + reject: async (reason) => { + await client.respondError(topic, rpcId, toOutgoingError(reason)) + }, + } + ) + + if (!enqueued) { + await client.respondError(topic, rpcId, tooManyRequestsError()) + return + } + await this.grabAttention() + } + + /** + * §2.3 item 5. Extension only: raises the WalletConnect tab and its window so + * an approval never opens somewhere the user can't see it, and sets the + * badge so a background tab still signals a waiting request. + */ + private async grabAttention(): Promise { + if (!__IS_EXTENSION__) return + + await chrome.action.setBadgeText({ text: String(listWalletConnectApprovals()) }) + const self = await chrome.tabs.getCurrent() + if (self?.id) await chrome.tabs.update(self.id, { active: true }) + if (self?.windowId) await chrome.windows.update(self.windowId, { focused: true }) + } + + private startBadgeWatcher(): void { + if (!__IS_EXTENSION__ || this.badgeWatcherStarted) return + this.badgeWatcherStarted = true + + watch(approvalQueue, (list) => { + if (list.filter((a) => a.surface === 'walletconnect').length === 0) { + void chrome.action.setBadgeText({ text: '' }) + } + }) + } +} + +function listWalletConnectApprovals(): number { + return approvalQueue.value.filter((a) => a.surface === 'walletconnect').length +} diff --git a/src/core/walletconnect-sign-client-adapter.ts b/src/core/walletconnect-sign-client-adapter.ts new file mode 100644 index 0000000..0fd5849 --- /dev/null +++ b/src/core/walletconnect-sign-client-adapter.ts @@ -0,0 +1,132 @@ +import SignClient from '@walletconnect/sign-client' +import { getSdkError, type SdkErrorKey } from '@walletconnect/utils' +import type { SessionTypes } from '@walletconnect/types' +import { WALLETCONNECT_METADATA, WALLETCONNECT_PROJECT_ID } from '@/config' +import { WalletConnectStorage } from './storage/walletconnect-storage' +import type { + IWalletConnectClient, + OutgoingError, + ProtocolRejection, + WalletConnectClientHandlers, +} from './walletconnect-client' + +/** + * The only file with a runtime import of `@walletconnect/sign-client`, + * `@walletconnect/utils` or `@walletconnect/core`. If the licence terms become + * unacceptable, a fork appears, or a different relay is adopted, this file is + * replaced and nothing else changes. + */ + +const PROTOCOL_REJECTION_TO_SDK_KEY: Record = { + USER_REJECTED: 'USER_REJECTED', + UNSUPPORTED_CHAINS: 'UNSUPPORTED_CHAINS', + UNSUPPORTED_METHODS: 'UNSUPPORTED_METHODS', + UNSUPPORTED_NAMESPACE_KEY: 'UNSUPPORTED_NAMESPACE_KEY', + UNSUPPORTED_METHOD: 'WC_METHOD_UNSUPPORTED', +} + +function toSdkError(error: OutgoingError): { code: number; message: string } { + if (error.kind === 'protocol') { + return getSdkError(PROTOCOL_REJECTION_TO_SDK_KEY[error.rejection]) + } + return { code: error.code, message: error.message } +} + +// zenon:1 is the only chain this wallet ever proposes or accepts (§2.1), so it +// is the one value every emit() needs and no caller of IWalletConnectClient +// should have to repeat. +const ZENON_CHAIN_ID = 'zenon:1' + +export class SignClientAdapter implements IWalletConnectClient { + private client: SignClient | null = null + private initPromise: Promise | null = null + + init(handlers: WalletConnectClientHandlers): Promise { + if (this.initPromise) return this.initPromise + + this.initPromise = (async () => { + const client = await SignClient.init({ + projectId: WALLETCONNECT_PROJECT_ID, + metadata: WALLETCONNECT_METADATA, + storage: new WalletConnectStorage(), + }) + + client.on('session_proposal', (proposal) => handlers.onProposal(proposal)) + client.on('session_request', (request) => handlers.onRequest(request)) + client.on('session_delete', () => handlers.onSessionsChanged(client.session.getAll())) + client.on('session_update', () => handlers.onSessionsChanged(client.session.getAll())) + // Fires when a session finishes settling after approve() — session_update + // is only for namespace changes on an *existing* session, so without this + // a newly approved session never appears until something else happens to + // refresh the list (e.g. reopening the window). + client.on('session_connect', () => handlers.onSessionsChanged(client.session.getAll())) + + this.client = client + handlers.onSessionsChanged(client.session.getAll()) + })() + + return this.initPromise + } + + private requireClient(): SignClient { + if (!this.client) throw new Error('WalletConnect client is not initialized.') + return this.client + } + + async pair(uri: string): Promise { + await this.requireClient().pair({ uri }) + } + + listSessions(): SessionTypes.Struct[] { + return this.requireClient().session.getAll() + } + + async approveProposal(id: number, namespaces: SessionTypes.Namespaces): Promise { + const { acknowledged } = await this.requireClient().approve({ id, namespaces }) + await acknowledged() + } + + async rejectProposal(id: number, error: OutgoingError): Promise { + await this.requireClient().reject({ id, reason: toSdkError(error) }) + } + + async respond(topic: string, rpcId: number, result: unknown): Promise { + await this.requireClient().respond({ + topic, + response: { id: rpcId, jsonrpc: '2.0', result }, + }) + } + + async respondError(topic: string, rpcId: number, error: OutgoingError): Promise { + await this.requireClient().respond({ + topic, + response: { id: rpcId, jsonrpc: '2.0', error: toSdkError(error) }, + }) + } + + async updateSessionAccounts(topic: string, accounts: string[]): Promise { + const client = this.requireClient() + const session = client.session.get(topic) + const namespaces: SessionTypes.Namespaces = Object.fromEntries( + Object.entries(session.namespaces).map(([key, namespace]) => [ + key, + { ...namespace, accounts }, + ]) + ) + const { acknowledged } = await client.update({ topic, namespaces }) + await acknowledged() + } + + async emit(topic: string, event: { name: string; data: unknown }): Promise { + await this.requireClient().emit({ topic, event, chainId: ZENON_CHAIN_ID }) + } + + async disconnect(topic: string): Promise { + await this.requireClient().disconnect({ topic, reason: getSdkError('USER_DISCONNECTED') }) + } + + async destroy(): Promise { + this.client = null + this.initPromise = null + } +} diff --git a/src/inpage.ts b/src/inpage.ts new file mode 100644 index 0000000..32e862b --- /dev/null +++ b/src/inpage.ts @@ -0,0 +1,180 @@ +import type { + InpageEventMessage, + InpageRequestMessage, + InpageResponseMessage, + ProviderError, + ProviderEventName, + PromptingMethod, + ReadOnlyMethod, +} from '@/core/inpage-protocol' + +/** + * Runs in the MAIN world at document_start, on every frame. Defines + * `window.zenon` and talks to content-script.ts over window.postMessage, + * correlating replies by request id (§6.1, §6.3). Read-only methods have a + * 30s timeout; prompting methods have none — the user may take arbitrarily + * long, and their terminal failure path is the twice-unknown rule in + * content-script.ts, not a timer. + * + * Deliberately duplicates the wire-protocol string constants below rather + * than importing them as values from inpage-protocol.ts — see the matching + * note in content-script.ts. This file must stay a self-contained IIFE with + * no shared chunk, or crxjs wraps it in a dynamic-import loader subject to + * the visited page's CSP (§5.1). + */ +const TARGET_CONTENT_SCRIPT = 'znn-contentscript' +const TARGET_INPAGE = 'znn-inpage' +const ZNN_ACCOUNTS = 'znn_accounts' +const ZNN_CHAIN_ID = 'znn_chainId' +const ZNN_NODE_URL = 'znn_nodeUrl' +const ZNN_DISCONNECT = 'znn_disconnect' +const ZNN_CONNECT = 'znn_connect' +const ZNN_SIGN = 'znn_sign' +const ZNN_SEND_TRANSACTION = 'znn_sendTransaction' +const ZNN_SIGN_AND_SEND_BLOCK = 'znn_signAndSendBlock' + +const READ_ONLY_TIMEOUT_MS = 30_000 + +type EventListener = (data: unknown) => void + +const listeners = new Map>() + +function emit(name: ProviderEventName, data: unknown): void { + for (const listener of listeners.get(name) ?? []) { + try { + listener(data) + } catch (err) { + console.error('window.zenon listener threw', err) + } + } +} + +interface PendingCall { + resolve: (value: unknown) => void + reject: (reason: ProviderError) => void + timer?: ReturnType +} + +const pending = new Map() +let nextId = 0 + +/** `timeoutMs: null` means no deadline — used for the four prompting methods. */ +function call( + method: ReadOnlyMethod | PromptingMethod, + params: unknown, + timeoutMs: number | null +): Promise { + const id = `${Date.now()}-${nextId++}` + + return new Promise((resolve, reject) => { + const entry: PendingCall = { resolve, reject } + if (timeoutMs !== null) { + entry.timer = setTimeout(() => { + pending.delete(id) + reject({ code: -32603, message: `Timed out waiting for ${method}.` }) + }, timeoutMs) + } + + pending.set(id, entry) + const request: InpageRequestMessage = { target: TARGET_CONTENT_SCRIPT, id, method, params } + window.postMessage(request, '*') + }) +} + +function isResponse(data: unknown): data is InpageResponseMessage { + const d = data as Partial | undefined + return !!d && d.target === TARGET_INPAGE && typeof d.id === 'string' +} + +function isEvent(data: unknown): data is InpageEventMessage { + const d = data as Partial | undefined + return !!d && d.target === TARGET_INPAGE && d.kind === 'event' && typeof d.name === 'string' +} + +window.addEventListener('message', (event: MessageEvent) => { + // Both ends of the postMessage bridge must filter on event.source — without + // it a child frame could forge a result into this page's pending promise + // (§6.3, edge case 20). + if (event.source !== window) return + + if (isEvent(event.data)) { + emit(event.data.name, event.data.data) + return + } + + if (!isResponse(event.data)) return + const entry = pending.get(event.data.id) + if (!entry) return + + pending.delete(event.data.id) + clearTimeout(entry.timer) + if (event.data.error) entry.reject(event.data.error) + else entry.resolve(event.data.result) +}) + +interface SignMessageResult { + message: string + address: string + publicKey: string + signature: string +} + +interface SendTransactionParams { + to: string + tokenStandard: string + amount: string | number +} + +const zenon = { + async getAccounts(): Promise { + return (await call(ZNN_ACCOUNTS, undefined, READ_ONLY_TIMEOUT_MS)) as string[] + }, + + async getChainId(): Promise { + return (await call(ZNN_CHAIN_ID, undefined, READ_ONLY_TIMEOUT_MS)) as number + }, + + async getNodeUrl(): Promise { + return (await call(ZNN_NODE_URL, undefined, READ_ONLY_TIMEOUT_MS)) as string + }, + + async disconnect(): Promise { + await call(ZNN_DISCONNECT, undefined, READ_ONLY_TIMEOUT_MS) + }, + + async connect(): Promise { + return (await call(ZNN_CONNECT, undefined, null)) as string + }, + + async signMessage(message: string): Promise { + return (await call(ZNN_SIGN, message, null)) as SignMessageResult + }, + + /** amount is in the token's smallest unit. Result: the published block hash (hex). */ + async sendTransaction(params: SendTransactionParams): Promise { + return (await call(ZNN_SEND_TRANSACTION, params, null)) as string + }, + + /** Result: the published block, as JSON. */ + async sendAccountBlock(block: unknown): Promise { + return await call(ZNN_SIGN_AND_SEND_BLOCK, block, null) + }, + + on(event: ProviderEventName, listener: EventListener): void { + const set = listeners.get(event) ?? new Set() + set.add(listener) + listeners.set(event, set) + }, + + removeListener(event: ProviderEventName, listener: EventListener): void { + listeners.get(event)?.delete(listener) + }, +} + +// Defined at document_start, non-configurable and non-writable, so no later +// page script can shadow it (§6.9, edge case 41). +Object.defineProperty(window, 'zenon', { + value: zenon, + writable: false, + configurable: false, +}) diff --git a/src/main.ts b/src/main.ts index 442c5f9..ac34869 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,12 +1,19 @@ import {Buffer} from 'buffer' +import __global_polyfill from 'vite-plugin-node-polyfills/shims/global' +import __process_polyfill from 'vite-plugin-node-polyfills/shims/process' import {createApp} from 'vue' import {useToast} from 'nom-ui' import App from './App.vue' import {router} from './router' import './style.css' -// Make Buffer available globally for SDK +// Make Buffer/global/process available for the SDK, matching what the +// node-polyfills plugin used to inject build-wide. That's scoped to this page +// entry now, not the MAIN-world content script (see vite.shared.ts) — a +// visited page must never see these globals. globalThis.Buffer = Buffer +globalThis.global = globalThis.global || __global_polyfill +globalThis.process = globalThis.process || __process_polyfill // An extension popup sizes itself to its content unless the document has an // explicit width, which collapses narrow pages (e.g. /setup) into a tall sliver. diff --git a/src/pages/WalletConnect.vue b/src/pages/WalletConnect.vue new file mode 100644 index 0000000..c69fb82 --- /dev/null +++ b/src/pages/WalletConnect.vue @@ -0,0 +1,183 @@ + + + diff --git a/src/router.ts b/src/router.ts index 510a2fd..9c97b40 100644 --- a/src/router.ts +++ b/src/router.ts @@ -32,6 +32,11 @@ export const router = createRouter({ component: () => import('@/pages/TokenDetails.vue'), meta: { requiresWallet: true }, }, + { + path: '/walletconnect', + component: () => import('@/pages/WalletConnect.vue'), + meta: { requiresWallet: true }, + }, // Catch-all: unknown paths fall back to home (which the guard re-routes to // /setup if no wallet exists yet). { path: '/:pathMatch(.*)*', redirect: '/' }, diff --git a/src/sw/approval-inbox.ts b/src/sw/approval-inbox.ts new file mode 100644 index 0000000..9b941c7 --- /dev/null +++ b/src/sw/approval-inbox.ts @@ -0,0 +1,220 @@ +import { + DELIVERED_OUTCOME_TTL_MS, + MAX_PENDING_DAPP_REQUESTS, + SESSION_KEY_APPROVAL_WINDOW_ID, + SESSION_KEY_OUTCOMES, + SESSION_KEY_PENDING_REQUESTS, +} from '@/config' +import type { InboxEntry, ProviderError } from '@/core/inpage-protocol' +import { getPort } from './ports' + +/** + * The chrome.storage.session inbox, its cap, the approval-window lifecycle, + * the outcome store, and single-port delivery (§6.4, §6.5, §6.6). Session + * storage — never key material, only requests and outcomes — because a + * manifest v3 service worker can be unloaded at any time. + */ + +interface StoredOutcome { + id: string + tabId: number + frameId: number + result?: unknown + error?: ProviderError + deliveredAt?: number +} + +async function readInbox(): Promise { + const stored = await chrome.storage.session.get(SESSION_KEY_PENDING_REQUESTS) + const value = stored[SESSION_KEY_PENDING_REQUESTS] as InboxEntry[] | undefined + return Array.isArray(value) ? value : [] +} + +async function writeInbox(entries: InboxEntry[]): Promise { + await chrome.storage.session.set({ [SESSION_KEY_PENDING_REQUESTS]: entries }) +} + +async function readOutcomes(): Promise { + const stored = await chrome.storage.session.get(SESSION_KEY_OUTCOMES) + const value = stored[SESSION_KEY_OUTCOMES] as StoredOutcome[] | undefined + return Array.isArray(value) ? value : [] +} + +async function writeOutcomes(entries: StoredOutcome[]): Promise { + await chrome.storage.session.set({ [SESSION_KEY_OUTCOMES]: entries }) +} + +export async function findInboxEntry(id: string): Promise { + return (await readInbox()).find((e) => e.id === id) +} + +async function findOutcome(id: string): Promise { + return (await readOutcomes()).find((o) => o.id === id) +} + +/** An id is `unknown` only if it is in neither the inbox nor the outcome store (§6.4). */ +export async function isTracked(id: string): Promise { + return (await findInboxEntry(id)) !== undefined || (await findOutcome(id)) !== undefined +} + +export async function listInbox(): Promise { + return readInbox() +} + +/** + * Appends a new inbox entry; returns false at cap. Callers must check + * isTracked() first — this never dedupes by id itself, so the + * write-before-reply and idempotent-by-id rules stay explicit at the call + * site in router.ts (§6.4), not hidden in here. + */ +export async function addToInbox(entry: InboxEntry): Promise { + const inbox = await readInbox() + if (inbox.length >= MAX_PENDING_DAPP_REQUESTS) return false + inbox.push(entry) + await writeInbox(inbox) + return true +} + +async function removeFromInbox(id: string): Promise { + const inbox = await readInbox() + const index = inbox.findIndex((e) => e.id === id) + if (index === -1) return undefined + const [entry] = inbox.splice(index, 1) + await writeInbox(inbox) + return entry +} + +/** Stamps every not-yet-stamped entry with the window now showing it (§6.6). */ +export async function stampWindow(windowId: number): Promise { + const inbox = await readInbox() + let changed = false + for (const entry of inbox) { + if (entry.windowId === undefined) { + entry.windowId = windowId + changed = true + } + } + if (changed) await writeInbox(inbox) +} + +/** chrome.windows.onRemoved rejects only that window's entries (§6.6). */ +export async function rejectEntriesForWindow(windowId: number, reason: ProviderError): Promise { + const inbox = await readInbox() + const forWindow = inbox.filter((e) => e.windowId === windowId) + if (forWindow.length === 0) return + + await writeInbox(inbox.filter((e) => e.windowId !== windowId)) + for (const entry of forWindow) { + await settle(entry.id, entry.tabId, entry.frameId, { error: reason }) + } +} + +/** Settles an inbox entry by id: removes it, records the outcome, delivers it. */ +export async function settle( + id: string, + tabId: number, + frameId: number, + payload: { result?: unknown; error?: ProviderError } +): Promise { + await removeFromInbox(id) + await recordOutcome(id, tabId, frameId, payload) +} + +/** + * Records an outcome as undelivered, then attempts single-port delivery to + * the port registered for `${tabId}:${frameId}` — never fanned out (§6.4). + * Undelivered outcomes are kept forever; only delivered ones are pruned, + * after DELIVERED_OUTCOME_TTL_MS, purely to suppress a duplicate on an + * overlapping resync. + */ +async function recordOutcome( + id: string, + tabId: number, + frameId: number, + payload: { result?: unknown; error?: ProviderError } +): Promise { + const outcomes = await readOutcomes() + const stored: StoredOutcome = { id, tabId, frameId, ...payload } + const next = pruneDelivered([...outcomes.filter((o) => o.id !== id), stored]) + await writeOutcomes(next) + await deliver(stored) +} + +function pruneDelivered(outcomes: StoredOutcome[]): StoredOutcome[] { + const now = Date.now() + return outcomes.filter((o) => o.deliveredAt === undefined || now - o.deliveredAt < DELIVERED_OUTCOME_TTL_MS) +} + +async function deliver(outcome: StoredOutcome): Promise { + const port = getPort(outcome.tabId, outcome.frameId) + if (!port) return + try { + port.postMessage({ kind: 'outcome', id: outcome.id, result: outcome.result, error: outcome.error }) + await markDelivered(outcome.id) + } catch { + // The port died between lookup and post; the outcome stays undelivered + // and a future resync will pick it up. + } +} + +async function markDelivered(id: string): Promise { + const outcomes = await readOutcomes() + const entry = outcomes.find((o) => o.id === id) + if (!entry || entry.deliveredAt !== undefined) return + entry.deliveredAt = Date.now() + await writeOutcomes(outcomes) +} + +/** Re-attempts delivery for a set of ids addressed to this tab/frame — called on resync. */ +export async function redeliverTo(tabId: number, frameId: number, ids: string[]): Promise { + const outcomes = await readOutcomes() + for (const outcome of outcomes) { + if (outcome.tabId === tabId && outcome.frameId === frameId && ids.includes(outcome.id)) { + await deliver(outcome) + } + } +} + +// --- Approval window lifecycle (§6.6) --- + +/** + * chrome.windows.create({url: approve.html, type: 'popup'}), reusing a + * stored window id via chrome.windows.update when one is open. + * + * Known limitation: on macOS, Chrome cannot reliably attach a new popup + * window while the browser is in native fullscreen — a platform limitation + * (MetaMask has the same issue), not something fixable from here. See the + * README's "Known limitation" note under WalletConnect. + */ +export async function openOrFocusApprovalWindow(): Promise { + const stored = await chrome.storage.session.get(SESSION_KEY_APPROVAL_WINDOW_ID) + const existingId = stored[SESSION_KEY_APPROVAL_WINDOW_ID] as number | undefined + + if (existingId !== undefined) { + try { + await chrome.windows.update(existingId, { focused: true }) + await stampWindow(existingId) + return + } catch { + // The stored window no longer exists; fall through and create one. + } + } + + const created = await chrome.windows.create({ + url: chrome.runtime.getURL('approve.html'), + type: 'popup', + width: 380, + height: 600, + }) + if (created.id === undefined) return + + await chrome.storage.session.set({ [SESSION_KEY_APPROVAL_WINDOW_ID]: created.id }) + await stampWindow(created.id) +} + +export async function clearApprovalWindow(windowId: number): Promise { + const stored = await chrome.storage.session.get(SESSION_KEY_APPROVAL_WINDOW_ID) + if (stored[SESSION_KEY_APPROVAL_WINDOW_ID] === windowId) { + await chrome.storage.session.remove(SESSION_KEY_APPROVAL_WINDOW_ID) + } +} diff --git a/src/sw/ports.ts b/src/sw/ports.ts new file mode 100644 index 0000000..8b6bb1a --- /dev/null +++ b/src/sw/ports.ts @@ -0,0 +1,84 @@ +import type { PortEventMessage } from '@/core/inpage-protocol' + +/** + * Port registry keyed `${tabId}:${frameId}` (§6.4, §6.8). Each granted-origin + * frame holds one open port; events fan out to every port of a granted + * origin. Phase 3b adds single-port outcome delivery and `resyncResult` on + * top of this same registry. + */ + +type PortKey = `${number}:${number}` + +interface RegisteredPort { + port: chrome.runtime.Port + origin: string + tabId: number + frameId: number +} + +const ports = new Map() + +function keyFor(tabId: number, frameId: number): PortKey { + return `${tabId}:${frameId}` +} + +/** Registers a connected provider port, keyed by its sender's tab/frame. */ +export function registerPort(port: chrome.runtime.Port): void { + const sender = port.sender + const tabId = sender?.tab?.id + const frameId = sender?.frameId + const origin = sender?.origin + + if (tabId === undefined || frameId === undefined || !origin) { + port.disconnect() + return + } + + const key = keyFor(tabId, frameId) + ports.set(key, { port, origin, tabId, frameId }) + + port.onDisconnect.addListener(() => { + // Only delete if this is still the port registered for the key — a + // reconnect may already have replaced it by the time onDisconnect fires. + if (ports.get(key)?.port === port) ports.delete(key) + }) +} + +/** The port for one frame, if it has one open. Used for single-port delivery (phase 3b). */ +export function getPort(tabId: number, frameId: number): chrome.runtime.Port | undefined { + return ports.get(keyFor(tabId, frameId))?.port +} + +/** Fans an event out to every open port belonging to the given origin (§6.8). */ +export function broadcastEvent(origin: string, message: PortEventMessage): void { + for (const entry of ports.values()) { + if (entry.origin !== origin) continue + try { + entry.port.postMessage(message) + } catch { + // The port died before its onDisconnect listener fired; ignore. + } + } +} + +/** + * Disconnects every open port for one origin — used when a connected-sites + * revoke happens outside the dApp's own tab (the settings dialog), so the + * content script actually drops its port instead of only learning about the + * revoke on its next unrelated read-only round trip. + */ +export function disconnectPortsForOrigin(origin: string): void { + for (const [key, entry] of ports) { + if (entry.origin !== origin) continue + entry.port.disconnect() + ports.delete(key) + } +} + +/** Disconnects every open port — used by connected-sites "revoke all". */ +export function disconnectAllPorts(): void { + for (const [key, entry] of ports) { + entry.port.disconnect() + ports.delete(key) + } +} diff --git a/src/sw/public-state.ts b/src/sw/public-state.ts new file mode 100644 index 0000000..9c3856b --- /dev/null +++ b/src/sw/public-state.ts @@ -0,0 +1,81 @@ +import { DEFAULT_NODE_URL, STORAGE_KEY_CHAIN_ID, STORAGE_KEY_SELECTED_NODE, STORAGE_KEY_WALLETS } from '@/config' +import type { WalletStorage } from '@/types' +import type { PortEventMessage, ProviderEventName } from '@/core/inpage-protocol' +import { list as listGrantedOrigins } from '@/core/site-permissions' +import { broadcastEvent } from './ports' + +/** + * Reads address/chainId/nodeUrl straight from chrome.storage.local — never + * via wallet-service.ts or zenon-service.ts, which pull in the SDK. Falls + * back to DEFAULT_NODE_URL and chain 1 on a fresh install with no persisted + * config, matching ZenonService's own constructor defaults (§6.8) so the + * worker and the page never disagree. + */ + +export async function getActiveAccountAddress(): Promise { + const stored = await chrome.storage.local.get(STORAGE_KEY_WALLETS) + const data = stored[STORAGE_KEY_WALLETS] as WalletStorage | undefined + return data?.activeAccountAddress ?? null +} + +/** No wallet in storage at all — there is nothing to unlock into (§6.2). */ +export async function hasAnyWallet(): Promise { + const stored = await chrome.storage.local.get(STORAGE_KEY_WALLETS) + const data = stored[STORAGE_KEY_WALLETS] as WalletStorage | undefined + return (data?.wallets.length ?? 0) > 0 +} + +export async function getChainId(): Promise { + const stored = await chrome.storage.local.get(STORAGE_KEY_CHAIN_ID) + const value = stored[STORAGE_KEY_CHAIN_ID] + return typeof value === 'number' ? value : 1 +} + +export async function getNodeUrl(): Promise { + const stored = await chrome.storage.local.get(STORAGE_KEY_SELECTED_NODE) + const value = stored[STORAGE_KEY_SELECTED_NODE] + return typeof value === 'string' ? value : DEFAULT_NODE_URL +} + +function eventMessage(name: ProviderEventName, data: unknown): PortEventMessage { + return { kind: 'event', name, data } +} + +async function broadcastToGrantedOrigins(message: PortEventMessage): Promise { + const origins = await listGrantedOrigins() + for (const { origin } of origins) { + broadcastEvent(origin, message) + } +} + +// Registered synchronously at module top level (background.ts imports this +// module for its side effect), so any storage write wakes a terminated +// worker and is never missed. +chrome.storage.onChanged.addListener((changes, areaName) => { + if (areaName !== 'local') return + + if (STORAGE_KEY_WALLETS in changes) { + // The wallets blob changes for many reasons (rename, derive, hide) that + // are not an active-account switch — only broadcast when the active + // address itself actually changed. + const before = changes[STORAGE_KEY_WALLETS].oldValue as WalletStorage | undefined + const after = changes[STORAGE_KEY_WALLETS].newValue as WalletStorage | undefined + const oldAddress = before?.activeAccountAddress ?? null + const newAddress = after?.activeAccountAddress ?? null + if (oldAddress !== newAddress) { + void broadcastToGrantedOrigins(eventMessage('accountsChanged', newAddress ? [newAddress] : [])) + } + } + + if (STORAGE_KEY_CHAIN_ID in changes) { + const value = changes[STORAGE_KEY_CHAIN_ID].newValue + void broadcastToGrantedOrigins(eventMessage('chainChanged', typeof value === 'number' ? value : 1)) + } + + if (STORAGE_KEY_SELECTED_NODE in changes) { + const value = changes[STORAGE_KEY_SELECTED_NODE].newValue + void broadcastToGrantedOrigins( + eventMessage('nodeChanged', typeof value === 'string' ? value : DEFAULT_NODE_URL) + ) + } +}) diff --git a/src/sw/router.ts b/src/sw/router.ts new file mode 100644 index 0000000..5060932 --- /dev/null +++ b/src/sw/router.ts @@ -0,0 +1,302 @@ +import { + accountBlockProblem, + ERR_INVALID_PARAMS, + ERR_NOT_CONNECTED, + ERR_TOO_MANY_REQUESTS, + ERR_UNKNOWN_METHOD, + ERR_WALLET_UNAVAILABLE, + INTERNAL_MESSAGE_CHANNEL, + PROVIDER_MESSAGE_CHANNEL, + PROVIDER_PORT_NAME, + sendTransactionParamsProblem, + ZNN_ACCOUNTS, + ZNN_CHAIN_ID, + ZNN_CONNECT, + ZNN_DISCONNECT, + ZNN_IS_GRANTED, + ZNN_NODE_URL, + ZNN_SEND_TRANSACTION, + ZNN_SIGN, + ZNN_SIGN_AND_SEND_BLOCK, + type ApprovalsSettleMessage, + type InboxEntry, + type PortMessageToWorker, + type PortRequestMessage, + type PortResyncMessage, + type PortsDisconnectMessage, + type PromptingMethod, + type ReadOnlyRequest, + type ReadOnlyResponse, +} from '@/core/inpage-protocol' +import { messageProblem } from '@/core/message-guard' +import { grant, isGranted, revoke, touch } from '@/core/site-permissions' +import { getActiveAccountAddress, getChainId, getNodeUrl, hasAnyWallet } from './public-state' +import { disconnectAllPorts, disconnectPortsForOrigin, registerPort } from './ports' +import { + addToInbox, + findInboxEntry, + isTracked, + openOrFocusApprovalWindow, + redeliverTo, + settle, +} from './approval-inbox' + +/** + * Channel routing, permission checks, and answers for both the read-only + * one-shot channel (§6.3, §6.4) and the prompting port channel, plus the + * approval window's internal settle notification (§6.5). Registered + * synchronously at module top level — background.ts imports this module for + * its side effect — so any message wakes a terminated worker. + */ + +const PROMPTING_METHODS: readonly PromptingMethod[] = [ + ZNN_CONNECT, + ZNN_SIGN, + ZNN_SEND_TRANSACTION, + ZNN_SIGN_AND_SEND_BLOCK, +] + +// --- Read-only, one-shot channel (§6.3, §6.4) --- + +function isReadOnlyRequest(message: unknown): message is ReadOnlyRequest { + const m = message as Partial | undefined + return !!m && m.channel === PROVIDER_MESSAGE_CHANNEL && typeof m.method === 'string' +} + +async function handleReadOnlyRequest( + message: ReadOnlyRequest, + sender: chrome.runtime.MessageSender +): Promise { + const origin = sender.origin + if (!origin) { + return { granted: false, error: { code: ERR_NOT_CONNECTED, message: 'No sender origin.' } } + } + + const granted = await isGranted(origin) + + switch (message.method) { + case ZNN_IS_GRANTED: + return { granted } + + case ZNN_ACCOUNTS: { + if (!granted) return { granted, result: [] } + void touch(origin) + const address = await getActiveAccountAddress() + return { granted, result: address ? [address] : [] } + } + + // Chain id and node url are public, non-sensitive data — answered + // regardless of grant, matching desktop's znn_info (§2.1) and unlike + // znn_accounts, which identifies the user to the site. + case ZNN_CHAIN_ID: + return { granted, result: await getChainId() } + + case ZNN_NODE_URL: + return { granted, result: await getNodeUrl() } + + case ZNN_DISCONNECT: + if (granted) await revoke(origin) + return { granted: false } + + default: + return { + granted, + error: { code: ERR_UNKNOWN_METHOD, message: `Unknown method '${message.method as string}'.` }, + } + } +} + +// --- Prompting, port channel (§6.4) --- + +function isPromptingMethod(method: string): method is PromptingMethod { + return (PROMPTING_METHODS as readonly string[]).includes(method) +} + +function validateParams(method: PromptingMethod, params: unknown): string | null { + switch (method) { + case ZNN_CONNECT: + return null + case ZNN_SIGN: + return messageProblem(params) + case ZNN_SEND_TRANSACTION: + return sendTransactionParamsProblem(params) + case ZNN_SIGN_AND_SEND_BLOCK: + return accountBlockProblem(params) + } +} + +function hostOf(url: string | undefined): string | undefined { + if (!url) return undefined + try { + return new URL(url).host + } catch { + return undefined + } +} + +async function handlePortRequest(port: chrome.runtime.Port, message: PortRequestMessage): Promise { + const sender = port.sender + const origin = sender?.origin + const tabId = sender?.tab?.id + const frameId = sender?.frameId + const { id, method, params } = message + + // Can't attribute this request to a frame at all; nothing safe to do. + if (!origin || tabId === undefined || frameId === undefined) return + + // Idempotent by id: a request whose id is already tracked creates nothing + // and replies accepted again — without this a lost `accepted` would turn + // one re-sent request into two dialogs (§6.4). + if (await isTracked(id)) { + port.postMessage({ kind: 'accepted', id }) + return + } + + if (!isPromptingMethod(method)) { + port.postMessage({ kind: 'outcome', id, error: { code: ERR_UNKNOWN_METHOD, message: `Unknown method '${method}'.` } }) + return + } + + // znn_connect is exempt from the not-granted rule — it arrives from an + // ungranted origin by definition, so without this exemption the surface + // could never bootstrap (§6.4). + if (method !== ZNN_CONNECT && !(await isGranted(origin))) { + port.postMessage({ kind: 'outcome', id, error: { code: ERR_NOT_CONNECTED, message: 'Origin is not connected.' } }) + return + } + + if (!(await hasAnyWallet())) { + port.postMessage({ kind: 'outcome', id, error: { code: ERR_WALLET_UNAVAILABLE, message: 'No wallet exists.' } }) + return + } + + // Validated in the worker, before any window opens — a malformed request + // never becomes a dialog (§6.4). + const problem = validateParams(method, params) + if (problem) { + port.postMessage({ kind: 'outcome', id, error: { code: ERR_INVALID_PARAMS, message: problem } }) + return + } + + const tab = sender.tab + const entry: InboxEntry = { + id, + origin, + tabId, + frameId, + topFrameHost: frameId !== 0 ? hostOf(tab?.url) : undefined, + title: tab?.title, + favicon: tab?.favIconUrl, + method, + params, + createdAt: Date.now(), + } + + const added = await addToInbox(entry) + if (!added) { + port.postMessage({ kind: 'outcome', id, error: { code: ERR_TOO_MANY_REQUESTS, message: 'Too many pending requests.' } }) + return + } + + // Write before reply: the inbox write above is awaited before `accepted` + // goes out, so a resync racing the accept can never see this id as + // unknown while it is about to become a live dialog (§6.4). Getting this + // order wrong is the worst failure in the design — the page could give up + // on an id the user then approves, publishing against an abandoned request. + port.postMessage({ kind: 'accepted', id }) + await openOrFocusApprovalWindow() +} + +async function handlePortResync(port: chrome.runtime.Port, message: PortResyncMessage): Promise { + const sender = port.sender + const tabId = sender?.tab?.id + const frameId = sender?.frameId + if (tabId === undefined || frameId === undefined) return + + const unknown: string[] = [] + const trackedIds: string[] = [] + for (const id of message.ids) { + if (await isTracked(id)) trackedIds.push(id) + else unknown.push(id) + } + + // Ids still pending in the inbox are tracked but have no outcome yet — + // redeliverTo() is a no-op for those, which is correct: the user is still + // deciding, and nothing more is required (§6.4). + if (trackedIds.length > 0) await redeliverTo(tabId, frameId, trackedIds) + port.postMessage({ kind: 'resyncResult', unknown }) +} + +// --- The approval window's internal settle channel (§6.5) --- + +function isApprovalsSettleMessage(message: unknown): message is ApprovalsSettleMessage { + const m = message as Partial | undefined + return !!m && m.channel === INTERNAL_MESSAGE_CHANNEL && m.kind === 'approvals.settle' && typeof m.id === 'string' +} + +/** + * A content script's `sender.url` is the page it's injected into; only our + * own extension pages (the approval window) have a chrome-extension:// url. + * Without this check, a page's content script could forge an + * {channel:'internal'} message and settle someone else's pending request + * with an attacker-chosen result — the Syrius extension's own router gates + * its internal channel the same way. + */ +function isFromOwnExtensionPage(sender: chrome.runtime.MessageSender): boolean { + return !!sender.url && sender.url.startsWith(chrome.runtime.getURL('')) +} + +async function handleApprovalsSettle(message: ApprovalsSettleMessage): Promise { + const entry = await findInboxEntry(message.id) + if (!entry) return // already settled (e.g. a window-close race) — no-op + + if (entry.method === ZNN_CONNECT && !message.error) { + // Grant before delivering the outcome, so the port stays open under its + // granted-origin condition once the request completes (§6.5). + await grant(entry.origin, { title: entry.title, favicon: entry.favicon }) + } + + await settle(entry.id, entry.tabId, entry.frameId, { result: message.result, error: message.error }) +} + +// --- useConnectedSites.ts's internal port-teardown request (§6.7) --- + +function isPortsDisconnectMessage(message: unknown): message is PortsDisconnectMessage { + const m = message as Partial | undefined + return !!m && m.channel === INTERNAL_MESSAGE_CHANNEL && m.kind === 'ports.disconnect' +} + +// --- Listener registration --- + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (isReadOnlyRequest(message)) { + void handleReadOnlyRequest(message, sender).then(sendResponse) + return true + } + + if (!isFromOwnExtensionPage(sender)) return false + + if (isApprovalsSettleMessage(message)) { + void handleApprovalsSettle(message).then(() => sendResponse({ ok: true })) + return true + } + + if (isPortsDisconnectMessage(message)) { + if (message.origin) disconnectPortsForOrigin(message.origin) + else disconnectAllPorts() + sendResponse({ ok: true }) + return true + } + + return false +}) + +chrome.runtime.onConnect.addListener((port) => { + if (port.name !== PROVIDER_PORT_NAME) return + registerPort(port) + + port.onMessage.addListener((message: PortMessageToWorker) => { + if (message.kind === 'request') void handlePortRequest(port, message) + else if (message.kind === 'resync') void handlePortResync(port, message) + }) +}) diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 71372c3..f905238 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -2,3 +2,24 @@ // Build-time constant injected by Vite `define`. True only in the extension build. declare const __IS_EXTENSION__: boolean + +interface ImportMetaEnv { + readonly VITE_WALLETCONNECT_PROJECT_ID?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} + +// vite-plugin-node-polyfills ships no "types" export condition for its shim +// subpaths, though the shims themselves exist — see vite.shared.ts for why +// these are imported directly in page entries instead of injected plugin-wide. +declare module 'vite-plugin-node-polyfills/shims/global' { + const globalShim: typeof globalThis + export default globalShim +} + +declare module 'vite-plugin-node-polyfills/shims/process' { + const processShim: NodeJS.Process + export default processShim +} diff --git a/vite.config.extension.ts b/vite.config.extension.ts index 5540b0f..b682a97 100644 --- a/vite.config.extension.ts +++ b/vite.config.extension.ts @@ -33,6 +33,14 @@ export default defineConfig({ }, build: { outDir: 'dist-extension', + rollupOptions: { + // §5.1 spike: a second HTML entry alongside crx()'s own input (the + // popup's index.html), so the approval window gets its own chunk graph + // and does not pull in App.vue, the router, or WalletConnect. + input: { + approve: resolve(__dirname, 'approve.html'), + }, + }, }, worker: { format: 'es', diff --git a/vite.shared.ts b/vite.shared.ts index a5065e3..3aaf36f 100644 --- a/vite.shared.ts +++ b/vite.shared.ts @@ -36,11 +36,13 @@ export function copyPowFiles(): Plugin { } // Node polyfills required by znn-typescript-sdk. Identical for both build targets. +// +// No plugin-level `globals`: that injects globalThis.Buffer/global/process into +// every entry chunk, including the MAIN-world content script that runs on every +// http(s) page the user visits — which would leak polyfill globals onto sites +// that feature-detect `process` and ship the bytes there. The page entries +// (main.ts, approve-main.ts) set the three globals explicitly instead; the +// content-script entries then receive nothing. export const nodePolyfillsConfig: NonNullable[0]> = { include: ['crypto', 'buffer', 'stream', 'util'], - globals: { - Buffer: true, - global: true, - process: true, - }, }