diff --git a/.gitignore b/.gitignore index 8185c1b..432ff21 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # dependencies **/node_modules - +examples/* # testing **/coverage diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ae49034 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,174 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repository Overview + +KeepKey Client is a browser extension for the KeepKey hardware wallet, built using React, TypeScript, and Vite with a Turborepo monorepo architecture. The extension supports Chrome and Firefox (Manifest V3). + +## Development Commands + +### Core Development +```bash +# Install dependencies (requires pnpm 9.9.0+, Node >=18.19.1) +pnpm install + +# Development mode with hot reload +pnpm dev # Chrome +pnpm dev:firefox # Firefox + +# Production build +pnpm build # Chrome +pnpm build:firefox # Firefox + +# Create distributable zip +pnpm zip # Chrome (creates extension.zip) +pnpm zip:firefox # Firefox +``` + +### Testing & Quality +```bash +# Run E2E tests (builds and zips first) +pnpm e2e # Chrome +pnpm e2e:firefox # Firefox + +# Type checking +pnpm type-check + +# Linting & formatting +pnpm lint # Run ESLint with fixes +pnpm lint:fix # Fix all linting issues +pnpm prettier # Format code with Prettier + +# Run specific E2E test +pnpm -F @extension/e2e e2e +``` + +### Clean & Reset +```bash +# Clean build artifacts +pnpm clean:bundle # Remove dist and dist-zip +pnpm clean:node_modules # Remove all node_modules +pnpm clean:turbo # Clear Turbo cache +pnpm clean # Full clean (all above) +pnpm clean:install # Clean + reinstall dependencies +``` + +## Architecture & Structure + +### Monorepo Layout +The project uses Turborepo with pnpm workspaces: + +- **`chrome-extension/`**: Core extension with background script and manifest configuration +- **`packages/`**: Shared packages used across the extension + - `dev-utils`: Development utilities and manifest parser + - `hmr`: Custom hot module reload plugin for Vite + - `i18n`: Internationalization with type safety + - `shared`: Shared hooks, components, and utilities + - `storage`: Chrome storage API helpers with TypeScript + - `ui`: Reusable UI components + - `vite-config`: Shared Vite configuration + - `zipper`: Build artifact packaging +- **`pages/`**: Extension pages and entry points + - `popup`: Main extension popup UI + - `side-panel`: Chrome side panel (not available in Firefox) + - `options`: Extension settings page + - `content`: Content script for page injection + - `content-ui`: Content script UI components + - `devtools`: Developer tools integration + +### Key Architecture Patterns + +**Background Service Worker**: Located at `chrome-extension/src/background/index.ts`, handles: +- KeepKey hardware wallet connection monitoring (polls `http://localhost:1646` every 5 seconds) +- Chain-specific request handlers (Bitcoin, Ethereum, Cosmos, etc.) +- State management and icon updates based on connection status +- RPC request routing and approval flows + +**Multi-Chain Support**: Each blockchain has a dedicated handler in `chrome-extension/src/background/chains/`: +- EVM chains (Ethereum, BSC, Avalanche, etc.) +- UTXO chains (Bitcoin, Litecoin, Dogecoin, etc.) +- Cosmos ecosystem (THORChain, Maya, Osmosis) +- Unique chains (Ripple) + +**Storage Architecture**: Uses Chrome storage API with TypeScript wrappers: +- `requestStorage`: Pending wallet requests +- `web3ProviderStorage`: Web3 provider configuration +- `exampleSidebarStorage`: UI state persistence + +**Message Passing**: The extension uses Chrome runtime messaging for: +- Background ↔ Popup communication +- Content script ↔ Background communication +- State change notifications (KEEPKEY_STATE_CHANGED events) + +### Build System + +**Vite Configuration**: +- Each package/page has its own `vite.config.mts` +- Shared config via `@extension/vite-config` +- IIFE format for background script and content scripts +- Source maps in dev, minification in production + +**Manifest Generation**: Dynamic manifest.js with: +- Conditional features (side panel only for Chrome) +- Localization support via `__MSG_*` placeholders +- All permissions required for hardware wallet interaction + +## Environment Variables + +Create `.env` from `.example.env` and define types in `vite-env.d.ts`: +```typescript +interface ImportMetaEnv { + // Add your env var types here +} +``` + +## Extension Loading + +### Chrome +1. Navigate to `chrome://extensions` +2. Enable "Developer mode" +3. Click "Load unpacked" +4. Select the `dist` folder + +### Firefox +1. Navigate to `about:debugging#/runtime/this-firefox` +2. Click "Load Temporary Add-on" +3. Select `manifest.json` from `dist` folder +Note: Firefox extensions are temporary and need reloading after browser restart + +## Working with Turborepo + +```bash +# Install dependency for root +pnpm i -w + +# Install for specific workspace +pnpm i -F @extension/popup + +# Run command in specific workspace +pnpm -F @extension/e2e e2e + +# Build specific packages +turbo build --filter=@extension/popup +``` + +## State Management + +The extension tracks KeepKey connection states: +- 0: Unknown +- 1: Disconnected +- 2: Connected +- 3: Busy +- 4: Errored +- 5: Paired (address available) + +Icon changes based on state (online/offline variants). + +## Critical Files & Entry Points + +- **Background Script**: `chrome-extension/src/background/index.ts` +- **Popup Entry**: `pages/popup/src/index.tsx` +- **Manifest Config**: `chrome-extension/manifest.js` +- **Chain Handlers**: `chrome-extension/src/background/chains/*.ts` +- **Storage Types**: `packages/storage/lib/types.ts` \ No newline at end of file diff --git a/chrome-extension/build-injected.mjs b/chrome-extension/build-injected.mjs new file mode 100644 index 0000000..ad238a3 --- /dev/null +++ b/chrome-extension/build-injected.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node + +import * as esbuild from 'esbuild'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const isDev = process.env.NODE_ENV === 'development'; + +async function build() { + try { + await esbuild.build({ + entryPoints: [resolve(__dirname, 'src/injected/injected.ts')], + bundle: true, + outfile: resolve(__dirname, 'public/injected.js'), + format: 'iife', + platform: 'browser', + target: ['chrome90', 'firefox90'], + minify: !isDev, + sourcemap: isDev ? 'inline' : false, + define: { + 'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'), + }, + logLevel: 'info', + }); + + console.log('✅ Injected script built successfully'); + } catch (error) { + console.error('❌ Build failed:', error); + process.exit(1); + } +} + +build(); \ No newline at end of file diff --git a/chrome-extension/package.json b/chrome-extension/package.json index d97f980..2dda6f8 100644 --- a/chrome-extension/package.json +++ b/chrome-extension/package.json @@ -1,14 +1,15 @@ { "name": "chrome-extension", - "version": "0.3.10", + "version": "0.3.13", "description": "chrome extension - core settings", "type": "module", "scripts": { "clean:node_modules": "pnpx rimraf node_modules", "clean:turbo": "rimraf .turbo", "clean": "pnpm clean:turbo && pnpm clean:node_modules", - "build": "vite build", - "dev": "cross-env __DEV__=true vite build --mode development", + "build:injected": "node build-injected.mjs", + "build": "pnpm build:injected && vite build", + "dev": "cross-env __DEV__=true NODE_ENV=development pnpm build:injected && vite build --mode development", "test": "vitest run", "lint": "eslint ./ --ext .ts,.js,.tsx,.jsx", "lint:fix": "pnpm lint --fix", @@ -16,14 +17,15 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@coinmasters/pioneer-sdk": "^4.8.26", - "@coinmasters/types": "^4.8.5", "@extension/shared": "workspace:*", "@extension/storage": "workspace:*", - "@pioneer-platform/helpers": "^4.0.12", - "@pioneer-platform/pioneer-caip": "^9.2.32", - "@pioneer-platform/pioneer-coins": "^9.2.23", + "@pioneer-platform/helpers": "^4.0.13", + "@pioneer-platform/pioneer-caip": "^9.2.38", + "@pioneer-platform/pioneer-coins": "^9.11.0", + "@pioneer-platform/pioneer-discovery": "^0.8.4", + "@pioneer-platform/pioneer-sdk": "^4.21.14", "axios": "^1.7.7", + "buffer": "^6.0.3", "coinselect": "^3.1.13", "ethers": "^6.13.2", "uuid": "^10.0.0", diff --git a/chrome-extension/public/injected.js b/chrome-extension/public/injected.js index 6935fcc..70fba0b 100644 --- a/chrome-extension/public/injected.js +++ b/chrome-extension/public/injected.js @@ -1,324 +1,327 @@ -(function () { - const TAG = ' | InjectedScript | '; - const VERSION = '1.0.17'; - console.log('**** KeepKey Injection script ****:', VERSION); - - // Prevent multiple injections - if (window.keepkeyInjected) { - //console.log(TAG, 'KeepKey is already injected.'); - return; - } - window.keepkeyInjected = true; - - const SITE_URL = window.location.href; - const SOURCE_INFO = { - siteUrl: SITE_URL, - scriptSource: 'KeepKey Extension', - version: VERSION, - injectedTime: new Date().toISOString(), - }; - console.log('SOURCE_INFO:', SOURCE_INFO); - - let messageId = 0; - const callbacks = {}; - const messageQueue = []; - - function processQueue(requestInfo, callback) { - for (let i = 0; i < messageQueue.length; i++) { - const queuedMessage = messageQueue[i]; - if (queuedMessage.id === requestInfo.id) { - callback(null, queuedMessage.result); - messageQueue.splice(i, 1); // Remove the processed message from the queue - return true; +'use strict'; +(() => { + (function () { + let a = ' | KeepKeyInjected | ', + A = '2.0.0', + u = window, + g = { isInjected: !1, version: A, injectedAt: Date.now(), retryCount: 0 }; + if (u.keepkeyInjectionState) { + let o = u.keepkeyInjectionState; + if ((console.warn(a, `Existing injection detected v${o.version}, current v${A}`), o.version >= A)) { + console.log(a, 'Skipping injection, newer or same version already present'); + return; } + console.log(a, 'Upgrading injection to newer version'); } - return false; - } - - function walletRequest(method, params = [], chain, callback) { - const tag = TAG + ' | walletRequest | '; - try { - const requestId = ++messageId; - const requestInfo = { - id: requestId, - method, - params, - chain, - siteUrl: SOURCE_INFO.siteUrl, - scriptSource: SOURCE_INFO.scriptSource, - version: SOURCE_INFO.version, - requestTime: new Date().toISOString(), - referrer: document.referrer, - href: window.location.href, - userAgent: navigator.userAgent, - platform: navigator.platform, - language: navigator.language, - }; - //console.log(tag, 'method:', method); - //console.log(tag, 'params:', params); - //console.log(tag, 'chain:', chain); - - callbacks[requestId] = { callback }; - - window.postMessage( - { + ((u.keepkeyInjectionState = g), console.log(a, `Initializing KeepKey Injection v${A}`)); + let p = { + siteUrl: window.location.href, + scriptSource: 'KeepKey Extension', + version: A, + injectedTime: new Date().toISOString(), + origin: window.location.origin, + protocol: window.location.protocol, + }, + v = 0, + f = new Map(), + w = [], + m = !1; + setInterval(() => { + let o = Date.now(); + f.forEach((n, t) => { + o - n.timestamp > 3e4 && + (console.warn(a, `Callback timeout for request ${t} (${n.method})`), + n.callback(new Error('Request timeout')), + f.delete(t)); + }); + }, 5e3); + let C = o => { + (w.length >= 100 && (console.warn(a, 'Message queue full, removing oldest message'), w.shift()), w.push(o)); + }, + y = () => { + if (m) + for (; w.length > 0; ) { + let o = w.shift(); + o && window.postMessage(o, window.location.origin); + } + }, + I = (o = 0) => + new Promise(n => { + let t = ++v, + e = setTimeout(() => { + o < 3 + ? (console.log(a, `Verification attempt ${o + 1} failed, retrying...`), + setTimeout( + () => { + I(o + 1).then(n); + }, + 100 * Math.pow(2, o), + )) + : (console.error(a, 'Failed to verify injection after max retries'), + (g.lastError = 'Failed to verify injection'), + n(!1)); + }, 1e3), + s = i => { + var c, l, d; + i.source === window && + ((c = i.data) == null ? void 0 : c.source) === 'keepkey-content' && + ((l = i.data) == null ? void 0 : l.type) === 'INJECTION_CONFIRMED' && + ((d = i.data) == null ? void 0 : d.requestId) === t && + (clearTimeout(e), + window.removeEventListener('message', s), + (m = !0), + (g.isInjected = !0), + console.log(a, 'Injection verified successfully'), + y(), + n(!0)); + }; + (window.addEventListener('message', s), + window.postMessage( + { source: 'keepkey-injected', type: 'INJECTION_VERIFY', requestId: t, version: A, timestamp: Date.now() }, + window.location.origin, + )); + }); + function E(o, n = [], t, e) { + let s = a + ' | walletRequest | '; + if (!o || typeof o != 'string') { + (console.error(s, 'Invalid method:', o), e(new Error('Invalid method'))); + return; + } + Array.isArray(n) || (console.warn(s, 'Params not an array, wrapping:', n), (n = [n])); + try { + let i = ++v, + c = { + id: i, + method: o, + params: n, + chain: t, + siteUrl: p.siteUrl, + scriptSource: p.scriptSource, + version: p.version, + requestTime: new Date().toISOString(), + referrer: document.referrer, + href: window.location.href, + userAgent: navigator.userAgent, + platform: navigator.platform, + language: navigator.language, + }; + f.set(i, { callback: e, timestamp: Date.now(), method: o }); + let l = { source: 'keepkey-injected', type: 'WALLET_REQUEST', - requestId, - requestInfo, - }, - '*', - ); - - // Recheck the queue for any pending matches - processQueue(requestInfo, callback); - } catch (error) { - console.error(tag, `Error in walletRequest:`, error); - callback(error); // Use callback to return the error - } - } - - // Listen for responses from the content script - window.addEventListener('message', event => { - const tag = TAG + ' | window.message | '; - if (event.source !== window) return; - if (event.data && event.data.source === 'keepkey-content' && event.data.type === 'WALLET_RESPONSE') { - const { requestId, result, error } = event.data; - const storedCallback = callbacks[requestId]; - if (storedCallback) { - if (error) { - storedCallback.callback(error); - } else { - storedCallback.callback(null, result); - } - delete callbacks[requestId]; - } else { - console.warn(tag, 'No callback found for requestId:', requestId); + requestId: i, + requestInfo: c, + timestamp: Date.now(), + }; + m + ? window.postMessage(l, window.location.origin) + : (console.log(s, 'Content script not ready, queueing request'), C(l)); + } catch (i) { + (console.error(s, 'Error in walletRequest:', i), e(i)); } } - }); - - function sendRequestAsync(payload, param1, callback) { - const tag = TAG + ' | sendRequestAsync | '; - //console.log(tag, 'payload:', payload); - //console.log(tag, 'param1:', param1); - //console.log(tag, 'callback:', callback); - - let chain = payload.chain || 'ethereum'; - - if (typeof callback === 'function') { - walletRequest(payload.method, payload.params, chain, (error, result) => { - if (error) { - callback(error); - } else { - callback(null, { id: payload.id, jsonrpc: '2.0', result }); + window.addEventListener('message', o => { + let n = a + ' | message | '; + if (o.source !== window) return; + let t = o.data; + if (!(!t || typeof t != 'object')) { + if (t.source === 'keepkey-content' && t.type === 'INJECTION_CONFIRMED') { + ((m = !0), y()); + return; } - }); - } else { - console.error(tag, 'Callback is not a function:', callback); - } - } - - function sendRequestSync(payload, param1) { - const tag = TAG + ' | sendRequestSync | '; - //console.log(tag, 'wallet.sendSync called with:', payload); - let params = payload.params || param1; - let method = payload.method || payload; - let chain = payload.chain || 'ethereum'; - //console.log(tag, 'selected payload:', payload); - //console.log(tag, 'selected params:', params); - //console.log(tag, 'selected chain:', chain); - - return { - id: payload.id, - jsonrpc: '2.0', - result: walletRequest(method, params, chain, () => {}), - }; - } - - function createWalletObject(chain) { - console.log('Creating wallet object for chain:', chain); - let wallet = { - network: 'mainnet', - isKeepKey: true, - isMetaMask: true, - isConnected: true, - request: ({ method, params }) => { - return new Promise((resolve, reject) => { - walletRequest(method, params, chain, (error, result) => { - if (error) { - reject(error); - } else { - resolve(result); - } - }); - }); - }, - send: (payload, param1, callback) => { - //console.log('send:', { payload, param1, callback }); - if (!payload.chain) { - payload.chain = chain; - } - return callback ? sendRequestAsync(payload, param1, callback) : sendRequestSync(payload, param1); - }, - sendAsync: (payload, param1, callback) => { - //console.log('sendAsync:', { payload, param1, callback }); - if (!payload.chain) { - payload.chain = chain; + if (t.source === 'keepkey-content' && t.type === 'WALLET_RESPONSE' && t.requestId) { + let e = f.get(t.requestId); + e + ? (t.error ? e.callback(t.error) : e.callback(null, t.result), f.delete(t.requestId)) + : console.warn(n, 'No callback found for requestId:', t.requestId); } - return sendRequestAsync(payload, param1, callback); - }, - on: (event, handler) => { - //console.log('Adding event listener for:', event); - window.addEventListener(event, handler); - }, - removeListener: (event, handler) => { - //console.log('Removing event listener for:', event); - window.removeEventListener(event, handler); - }, - removeAllListeners: () => { - //console.log('Removing all event listeners'); - // Implement as needed - }, - }; - if (chain === 'ethereum') { - wallet.chainId = '0x1'; - wallet.networkVersion = '1'; - } - - return wallet; - } - - function announceProvider(ethereumProvider) { - const info = { - uuid: '350670db-19fa-4704-a166-e52e178b59d4', - name: 'KeepKey Client', - icon: 'https://pioneers.dev/coins/keepkey.png', - rdns: 'com.keepkey', - }; - - const announceEvent = new CustomEvent('eip6963:announceProvider', { - detail: { info, provider: ethereumProvider }, + } }); - - console.log(TAG, 'Dispatching provider event with correct detail:', announceEvent); - window.dispatchEvent(announceEvent); - } - - function mountWallet() { - const tag = TAG + ' | window.wallet | '; - - // Create wallet objects for each chain - const ethereum = createWalletObject('ethereum'); - const xfi = { - binance: createWalletObject('binance'), - bitcoin: createWalletObject('bitcoin'), - bitcoincash: createWalletObject('bitcoincash'), - dogecoin: createWalletObject('dogecoin'), - dash: createWalletObject('dash'), - ethereum: ethereum, - keplr: createWalletObject('keplr'), - litecoin: createWalletObject('litecoin'), - thorchain: createWalletObject('thorchain'), - mayachain: createWalletObject('mayachain'), - // solana: createWalletObject('solana'), - }; - - const keepkey = { - binance: createWalletObject('binance'), - bitcoin: createWalletObject('bitcoin'), - bitcoincash: createWalletObject('bitcoincash'), - dogecoin: createWalletObject('dogecoin'), - dash: createWalletObject('dash'), - ethereum: ethereum, - osmosis: createWalletObject('osmosis'), - cosmos: createWalletObject('cosmos'), - litecoin: createWalletObject('litecoin'), - thorchain: createWalletObject('thorchain'), - mayachain: createWalletObject('mayachain'), - ripple: createWalletObject('ripple'), - // solana: createWalletObject('solana'), - }; - - const handler = { - get: function (target, prop, receiver) { - //console.log(tag, `Proxy get handler: ${prop}`); - return Reflect.get(target, prop, receiver); - }, - set: function (target, prop, value) { - //console.log(tag, `Proxy set handler: ${prop} = ${value}`); - return Reflect.set(target, prop, value); - }, - }; - - const proxyEthereum = new Proxy(ethereum, handler); - const proxyXfi = new Proxy(xfi, handler); - const proxyKeepKey = new Proxy(keepkey, handler); - - const userOverrideSetting = true; - if (userOverrideSetting) { - if (typeof window.ethereum === 'undefined') { - console.log('Mounting window.ethereum'); - try { - Object.defineProperty(window, 'ethereum', { - value: proxyEthereum, - writable: false, - configurable: false, - }); - } catch (e) { - console.error('Failed to mount window.ethereum'); - } + class k { + events = new Map(); + on(n, t) { + (this.events.has(n) || this.events.set(n, new Set()), this.events.get(n).add(t)); } - } - - if (userOverrideSetting) { - if (typeof window.xfi === 'undefined') { - try { - Object.defineProperty(window, 'xfi', { - value: proxyXfi, - writable: false, - configurable: false, - }); - } catch (e) { - console.error('Failed to mount xfi'); - } + off(n, t) { + var e; + (e = this.events.get(n)) == null || e.delete(t); } - } - - if (typeof window.keepkey === 'undefined') { - try { - Object.defineProperty(window, 'keepkey', { - value: proxyKeepKey, - writable: false, - configurable: false, - }); - } catch (e) { - console.error('Failed to mount keepkey'); + removeListener(n, t) { + this.off(n, t); } - } - - console.log(tag, 'window.ethereum and window.keepkey have been mounted'); - - announceProvider(proxyEthereum); - - window.addEventListener('message', event => { - const tag = TAG + ' | window.message | '; - if (event.data.type === 'CHAIN_CHANGED') { - console.log(tag, 'Received CHAIN_CHANGED', event); - window.ethereum.emit('chainChanged', event.provider.chainId); // Notify dApps + removeAllListeners(n) { + n ? this.events.delete(n) : this.events.clear(); + } + emit(n, ...t) { + var e; + (e = this.events.get(n)) == null || + e.forEach(s => { + try { + s(...t); + } catch (i) { + console.error(a, `Error in event handler for ${n}:`, i); + } + }); } - if (event.source !== window) return; - if (event.data.type === 'ANNOUNCE_REQUEST') { - console.log(tag, 'Received ANNOUNCE_REQUEST'); - announceProvider(proxyEthereum); + once(n, t) { + let e = (...s) => { + (t(...s), this.off(n, e)); + }; + this.on(n, e); } - }); - } - - mountWallet(); - if (document.readyState === 'complete' || document.readyState === 'interactive') { - mountWallet(); - } else { - document.addEventListener('DOMContentLoaded', mountWallet); - } + } + function r(o) { + console.log(a, 'Creating wallet object for chain:', o); + let n = new k(), + t = { + network: 'mainnet', + isKeepKey: !0, + isMetaMask: !0, + isConnected: () => m, + request: ({ method: e, params: s = [] }) => + new Promise((i, c) => { + E(e, s, o, (l, d) => { + l ? c(l) : i(d); + }); + }), + send: (e, s, i) => { + if ((e.chain || (e.chain = o), typeof i == 'function')) + E(e.method, e.params || s, o, (c, l) => { + c ? i(c) : i(null, { id: e.id, jsonrpc: '2.0', result: l }); + }); + else + return ( + console.warn(a, 'Synchronous send is deprecated and may not work properly'), + { id: e.id, jsonrpc: '2.0', result: null } + ); + }, + sendAsync: (e, s, i) => { + e.chain || (e.chain = o); + let c = i || s; + if (typeof c != 'function') { + console.error(a, 'sendAsync requires a callback function'); + return; + } + E(e.method, e.params || s, o, (l, d) => { + l ? c(l) : c(null, { id: e.id, jsonrpc: '2.0', result: d }); + }); + }, + on: (e, s) => (n.on(e, s), t), + off: (e, s) => (n.off(e, s), t), + removeListener: (e, s) => (n.removeListener(e, s), t), + removeAllListeners: e => (n.removeAllListeners(e), t), + emit: (e, ...s) => (n.emit(e, ...s), t), + once: (e, s) => (n.once(e, s), t), + enable: () => t.request({ method: 'eth_requestAccounts' }), + _metamask: { isUnlocked: () => Promise.resolve(!0) }, + }; + return ( + o === 'ethereum' && + ((t.chainId = '0x1'), + (t.networkVersion = '1'), + (t.selectedAddress = null), + (t._handleAccountsChanged = e => { + ((t.selectedAddress = e[0] || null), n.emit('accountsChanged', e)); + }), + (t._handleChainChanged = e => { + ((t.chainId = e), n.emit('chainChanged', e)); + }), + (t._handleConnect = e => { + n.emit('connect', e); + }), + (t._handleDisconnect = e => { + ((t.selectedAddress = null), n.emit('disconnect', e)); + })), + t + ); + } + function h(o) { + let n = { + uuid: '350670db-19fa-4704-a166-e52e178b59d4', + name: 'KeepKey', + icon: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAIAAAAACshmLzAAADUklEQVRYCb1XTUgUYRie3bXEWhVLQaUsgwVLoUtEQjUJiZX0A0GX7BIZXurkOTSvdo2kvETHAsOshFgqOqhlRD9C7SGS1JTCsj1krU7PM+w7zMzOzuzMqi88+73v9z7vz3zzzTeziuIgmqbFgG5gBPguFOgq4CXLIMwCo0AXEJN4zxHkEuA6kAIMkUBMqMZk7so/UG8AUcnjOIKwFXgHZIgEwKFmOHOfYO4aySVjmAoc7O4R0EB7lYS5h9K1jBJ6A7CuAfXG7OopbKLXkh4dccNZ7jlsi0gAJlWLI5jBPWFsTK5AGxCRImswFqDGWanDBo6IsYbjUanFbmrFWIHxD3IsmfJsgB4y2aJuF4UrUC5GnuNtxJeEQqEoAb3LJV+F4ctlHwkZXDULv8fEKQCHB4+rCJ9ngKcIGUTVRubT027y8yR9bOM4mhKTTwNJZD4miaDXAG8dqzlMShw3YRCZRVAr7vU4g5F/D4ZBoJK2H+Em9CsfEdBoKn4K9jPAd3G9sMPqZEzpRPzAwRfWJpN9EfZSRkAOE5LD7wrw8dkpwRh55VMm27fqt4FiVBjGBTaxEm4Db8d+4BPtIOK3AdbYCPC1qh/haGIS9gHgDeBbgjTAIkXAfTRxkgaamMNwCHgB+BMk4Decq0hGkFQbka/WMyZ/EeyHNo6TuSwx3Nn8gHQVIYOkOhB5Gp4zcdbBHiDvZ2pRuzozru2euKuDOucg/KliTAjKKMa9ksBpxBLrbzRwVfifOnB4RR2g3QSH3Cfx5FRdc2KoGstroUeQKh47vnAwWvUKjsPcA/wWdBUkjRAgZdsznO8D5xLGC/Opxc3NiQeV9uIsgkNDaUoMFpNDLleAn0cTQNBjGaFW6fn2Wrky/dI6abPOl9eN9deoWhjLloCv3+bPy7w3/9kzfvjX120g1cuSdsJ47xm1CgS9AaxCErlbV6qJ02W1nq22lG75AtIHWQEeJpOYaAT6gBQQWC5XNCjc7dkkHFKWe6v3FcLfbzRAMlcC6IC6C+gGxgCectZnCRMuopVG1v+Nx04sYINlxLH4wI6W52UFhT+Q41b2Nl0qeLnwZPGQucNHrXN6ZDG94RQuO688XbwNFzvjlSuwH03wEW8H+Bf/dxrUOWdc+H8mKXtEpGpY3AAAAABJRU5ErkJggg==', + rdns: 'com.keepkey.client', + }, + t = new CustomEvent('eip6963:announceProvider', { detail: Object.freeze({ info: n, provider: o }) }); + (console.log(a, 'Announcing EIP-6963 provider'), window.dispatchEvent(t)); + } + async function b() { + let o = a + ' | mountWallet | '; + console.log(o, 'Starting wallet mount process'); + let n = r('ethereum'), + t = { + binance: r('binance'), + bitcoin: r('bitcoin'), + bitcoincash: r('bitcoincash'), + dogecoin: r('dogecoin'), + dash: r('dash'), + ethereum: n, + keplr: r('keplr'), + litecoin: r('litecoin'), + thorchain: r('thorchain'), + mayachain: r('mayachain'), + }, + e = { + binance: r('binance'), + bitcoin: r('bitcoin'), + bitcoincash: r('bitcoincash'), + dogecoin: r('dogecoin'), + dash: r('dash'), + ethereum: n, + osmosis: r('osmosis'), + cosmos: r('cosmos'), + litecoin: r('litecoin'), + thorchain: r('thorchain'), + mayachain: r('mayachain'), + ripple: r('ripple'), + }, + s = (i, c) => { + u[i] && console.warn(o, `${i} already exists, checking if override is allowed`); + try { + (Object.defineProperty(u, i, { value: c, writable: !1, configurable: !0 }), + console.log(o, `Successfully mounted window.${i}`)); + } catch (l) { + (console.error(o, `Failed to mount window.${i}:`, l), (g.lastError = `Failed to mount ${i}`)); + } + }; + (s('ethereum', n), + s('xfi', t), + s('keepkey', e), + window.addEventListener('eip6963:requestProvider', () => { + (console.log(o, 'Re-announcing provider on request'), h(n)); + }), + h(n), + setTimeout(() => { + (console.log(o, 'Delayed EIP-6963 announcement for late-loading dApps'), h(n)); + }, 100), + window.addEventListener('message', i => { + var c, l, d; + (((c = i.data) == null ? void 0 : c.type) === 'CHAIN_CHANGED' && + (console.log(o, 'Chain changed:', i.data), + n.emit('chainChanged', (l = i.data.provider) == null ? void 0 : l.chainId)), + ((d = i.data) == null ? void 0 : d.type) === 'ACCOUNTS_CHANGED' && + (console.log(o, 'Accounts changed:', i.data), + n._handleAccountsChanged && n._handleAccountsChanged(i.data.accounts || []))); + }), + I().then(i => { + i + ? console.log(o, 'Injection verified successfully') + : (console.error(o, 'Failed to verify injection, wallet features may not work'), + (g.lastError = 'Injection not verified')); + }), + console.log(o, 'Wallet mount complete')); + } + (b(), + document.readyState === 'loading' && + document.addEventListener('DOMContentLoaded', () => { + if ( + (console.log(a, 'DOM loaded, re-announcing provider for late-loading dApps'), + u.ethereum && typeof u.dispatchEvent == 'function') + ) { + let o = u.ethereum; + h(o); + } + }), + console.log(a, 'Injection script loaded and initialized')); + })(); })(); diff --git a/chrome-extension/src/background/chains/bitcoinCashHandler.ts b/chrome-extension/src/background/chains/bitcoinCashHandler.ts index ebe78ba..aef8e0c 100644 --- a/chrome-extension/src/background/chains/bitcoinCashHandler.ts +++ b/chrome-extension/src/background/chains/bitcoinCashHandler.ts @@ -2,7 +2,7 @@ import { bip32ToAddressNList } from '@pioneer-platform/pioneer-coins'; const TAG = ' | bitcoinCashHandler | '; import { JsonRpcProvider } from 'ethers'; -import { Chain, DerivationPath } from '@coinmasters/types'; +import { Chain } from '@pioneer-platform/pioneer-caip'; import { AssetValue } from '@pioneer-platform/helpers'; // @ts-ignore // @ts-ignore @@ -154,7 +154,7 @@ export const handleBitcoinCashRequest = async ( console.log(tag, 'response: ', response); if (result.success && response.unsignedTx) { - const signedTx = await KEEPKEY_WALLET.signTx({ caip, unsignedTx: response.unsignedTx }); + const signedTx = await KEEPKEY_WALLET.signTx(caip, response.unsignedTx); response.signedTx = signedTx; await requestStorage.updateEventById(requestInfo.id, response); diff --git a/chrome-extension/src/background/chains/bitcoinHandler.ts b/chrome-extension/src/background/chains/bitcoinHandler.ts index 0782ef0..78066f1 100644 --- a/chrome-extension/src/background/chains/bitcoinHandler.ts +++ b/chrome-extension/src/background/chains/bitcoinHandler.ts @@ -1,7 +1,7 @@ import { requestStorage } from '@extension/storage/dist/lib'; const TAG = ' | bitcoinHandler | '; -import { Chain, DerivationPath } from '@coinmasters/types'; +import { Chain } from '@pioneer-platform/pioneer-caip'; import { AssetValue } from '@pioneer-platform/helpers'; import { ChainToNetworkId, shortListSymbolToCaip, caipToNetworkId } from '@pioneer-platform/pioneer-caip'; //@ts-ignore @@ -158,7 +158,16 @@ export const handleBitcoinRequest = async ( console.log(tag, 'response: ', response); if (result.success && response.unsignedTx) { - const signedTx = await KEEPKEY_WALLET.signTx({ caip, unsignedTx: response.unsignedTx }); + // DEBUG: Log the structure of unsignedTx before signing + console.log(tag, 'DEBUG: About to sign Bitcoin transaction'); + console.log(tag, 'DEBUG: caip:', caip, '(type:', typeof caip, ')'); + console.log(tag, 'DEBUG: unsignedTx:', response.unsignedTx); + console.log(tag, 'DEBUG: Checking all unsignedTx field types:'); + for (const [key, value] of Object.entries(response.unsignedTx)) { + console.log(tag, ` ${key}:`, typeof value, Array.isArray(value) ? '(array)' : '', value); + } + + const signedTx = await KEEPKEY_WALLET.signTx(caip, response.unsignedTx); console.log(tag, 'signedTx: ', signedTx); response.signedTx = signedTx; diff --git a/chrome-extension/src/background/chains/cosmosHandler.ts b/chrome-extension/src/background/chains/cosmosHandler.ts index 2db95d1..1398226 100644 --- a/chrome-extension/src/background/chains/cosmosHandler.ts +++ b/chrome-extension/src/background/chains/cosmosHandler.ts @@ -1,6 +1,6 @@ const TAG = ' | cosmosHandler | '; import { JsonRpcProvider } from 'ethers'; -import { Chain } from '@coinmasters/types'; +import { Chain } from '@pioneer-platform/pioneer-caip'; import { AssetValue } from '@pioneer-platform/helpers'; import { requestStorage, web3ProviderStorage, assetContextStorage } from '@extension/storage'; @@ -125,10 +125,7 @@ export const handleCosmosRequest = async ( if (result.success && requestInfo.unsignedTx) { //send tx // Sign the transaction - const signedTx = await KEEPKEY_WALLET.signTx({ - caip, - unsignedTx: requestInfo.unsignedTx, - }); + const signedTx = await KEEPKEY_WALLET.signTx(caip, requestInfo.unsignedTx); console.log(tag, 'signedTx:', signedTx); // Update storage with signed transaction diff --git a/chrome-extension/src/background/chains/dashHandler.ts b/chrome-extension/src/background/chains/dashHandler.ts index 7d62e98..bc43d36 100644 --- a/chrome-extension/src/background/chains/dashHandler.ts +++ b/chrome-extension/src/background/chains/dashHandler.ts @@ -1,7 +1,7 @@ const TAG = ' | dashHandler | '; import { requestStorage } from '@extension/storage/dist/lib'; import { JsonRpcProvider } from 'ethers'; -import { Chain, DerivationPath } from '@coinmasters/types'; +import { Chain } from '@pioneer-platform/pioneer-caip'; import { AssetValue } from '@pioneer-platform/helpers'; //@ts-ignore import * as coinSelect from 'coinselect'; @@ -155,7 +155,7 @@ export const handleDashRequest = async ( console.log(tag, 'response: ', response); if (result.success && response.unsignedTx) { - const signedTx = await KEEPKEY_WALLET.signTx({ caip, unsignedTx: response.unsignedTx }); + const signedTx = await KEEPKEY_WALLET.signTx(caip, response.unsignedTx); console.log(tag, 'signedTx: ', signedTx); response.signedTx = signedTx; diff --git a/chrome-extension/src/background/chains/dogecoinHandler.ts b/chrome-extension/src/background/chains/dogecoinHandler.ts index 1d60bab..e152709 100644 --- a/chrome-extension/src/background/chains/dogecoinHandler.ts +++ b/chrome-extension/src/background/chains/dogecoinHandler.ts @@ -2,7 +2,7 @@ import { bip32ToAddressNList } from '@pioneer-platform/pioneer-coins'; const TAG = ' | dogecoinHandler | '; import type { JsonRpcProvider } from 'ethers'; -import { Chain, DerivationPath } from '@coinmasters/types'; +import { Chain } from '@pioneer-platform/pioneer-caip'; import { AssetValue } from '@pioneer-platform/helpers'; // @ts-ignore import { ChainToNetworkId, shortListSymbolToCaip, caipToNetworkId } from '@pioneer-platform/pioneer-caip'; @@ -155,7 +155,7 @@ export const handleDogecoinRequest = async ( console.log(tag, 'response: ', response); if (result.success && response.unsignedTx) { - const signedTx = await KEEPKEY_WALLET.signTx({ caip, unsignedTx: response.unsignedTx }); + const signedTx = await KEEPKEY_WALLET.signTx(caip, response.unsignedTx); console.log(tag, 'signedTx: ', signedTx); response.signedTx = signedTx; diff --git a/chrome-extension/src/background/chains/ethereumHandler.ts b/chrome-extension/src/background/chains/ethereumHandler.ts index a08834f..fa8c70a 100644 --- a/chrome-extension/src/background/chains/ethereumHandler.ts +++ b/chrome-extension/src/background/chains/ethereumHandler.ts @@ -2,7 +2,7 @@ Ethereum Provider Refactored */ -import { Chain } from '@coinmasters/types'; +import { Chain } from '@pioneer-platform/pioneer-caip'; import { JsonRpcProvider } from 'ethers'; import { createProviderRpcError } from '../utils'; import { requestStorage, web3ProviderStorage, assetContextStorage, blockchainDataStorage } from '@extension/storage'; @@ -89,20 +89,91 @@ const sanitizeChainId = (chainId: string): string => { return chainId.replace(/^0x0x/, '0x'); }; -// Helper function to get the provider +// Track failed RPCs to avoid retrying them immediately +const failedRpcs = new Map(); // URL -> timestamp of failure +const RPC_RETRY_DELAY = 60000; // Don't retry failed RPC for 1 minute + +// Helper function to get the provider with RPC failover const getProvider = async (): Promise => { + const tag = TAG + ' | getProvider | '; const currentProvider = await web3ProviderStorage.getWeb3Provider(); - if (!currentProvider || !currentProvider.providerUrl) { - throw new Error('Provider not properly configured'); + console.log(tag, 'currentProvider from storage:', currentProvider); + + if (!currentProvider) { + throw createProviderRpcError(4900, 'Provider not properly configured'); + } + + // Get all available RPC URLs + const rpcUrls = currentProvider.providers || [currentProvider.providerUrl]; + if (!rpcUrls || rpcUrls.length === 0) { + throw createProviderRpcError(4900, 'No RPC URLs available'); + } + + console.log(tag, 'Available RPCs:', rpcUrls.length); + + // Filter out recently failed RPCs + const now = Date.now(); + const availableRpcs = rpcUrls.filter(url => { + const failedAt = failedRpcs.get(url); + if (failedAt && now - failedAt < RPC_RETRY_DELAY) { + console.log(tag, 'Skipping recently failed RPC:', url); + return false; + } + return true; + }); + + if (availableRpcs.length === 0) { + console.warn(tag, 'All RPCs recently failed, retrying anyway...'); + failedRpcs.clear(); // Reset failures and try again + availableRpcs.push(...rpcUrls); + } + + // Try each RPC until one works + const errors = []; + for (const rpcUrl of availableRpcs) { + try { + const cleanUrl = rpcUrl.trim(); + console.log(tag, `Trying RPC [${availableRpcs.indexOf(rpcUrl) + 1}/${availableRpcs.length}]:`, cleanUrl); + + const provider = new JsonRpcProvider(cleanUrl); + + // Test the connection with a quick call (with timeout) + const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('RPC timeout')), 5000)); + const blockNumber = await Promise.race([provider.getBlockNumber(), timeoutPromise]); + + console.log(tag, '✅ RPC working! Block:', blockNumber, 'URL:', cleanUrl); + + // Update the primary URL to the working one + if (currentProvider.providerUrl !== cleanUrl) { + currentProvider.providerUrl = cleanUrl; + await web3ProviderStorage.saveWeb3Provider(currentProvider); + console.log(tag, 'Updated primary RPC to:', cleanUrl); + } + + return provider; + } catch (error) { + console.error(tag, '❌ RPC failed:', rpcUrl, error.message); + errors.push({ url: rpcUrl, error: error.message }); + failedRpcs.set(rpcUrl, now); + } } - return new JsonRpcProvider(currentProvider.providerUrl); + + // All RPCs failed + console.error(tag, 'All RPCs failed:', errors); + throw createProviderRpcError( + 4900, + `All ${availableRpcs.length} RPC endpoints failed. Errors: ${errors.map(e => `${e.url}: ${e.error}`).join('; ')}`, + ); }; // Handler functions for each method const handleEthChainId = async () => { const currentProvider = await web3ProviderStorage.getWeb3Provider(); - return currentProvider.chainId; + const chainIdDecimal = parseInt(currentProvider.chainId, 10); + const chainIdHex = '0x' + chainIdDecimal.toString(16); + console.log(TAG, 'eth_chainId returning:', chainIdHex, '(decimal:', currentProvider.chainId, ')'); + return chainIdHex; }; const handleNetVersion = async () => { @@ -124,9 +195,20 @@ const handleEthBlockNumber = async () => { }; const handleEthGetBalance = async params => { - const provider = await getProvider(); - const balance = await provider.getBalance(params[0], params[1]); - return '0x' + balance.toString(16); + const tag = TAG + ' | handleEthGetBalance | '; + try { + console.log(tag, 'Getting balance for address:', params[0], 'block:', params[1]); + const provider = await getProvider(); + console.log(tag, 'Provider created, calling getBalance...'); + + const balance = await provider.getBalance(params[0], params[1]); + console.log(tag, 'Balance retrieved:', balance.toString()); + + return '0x' + balance.toString(16); + } catch (error) { + console.error(tag, 'Error getting balance:', error); + throw error; + } }; const handleEthGetTransactionReceipt = async params => { @@ -202,114 +284,172 @@ const handleEthSendRawTransaction = async params => { return txResponse.hash; }; -const handleWalletAddEthereumChain = async (params, KEEPKEY_WALLET) => { - const tag = TAG + ' | handleWalletAddEthereumChain | '; - console.log(tag, 'Switching Chain params: ', params); - if (!params || !params[0] || !params[0].chainId) throw new Error('Invalid chainId (Required)'); +// Helper function to switch to a provider and update contexts +const switchToProvider = async (currentProvider: any, KEEPKEY_WALLET: any, tag: string) => { + console.log(tag, 'Switching to provider (raw):', currentProvider); + + if (!currentProvider.caip) { + throw createProviderRpcError(4900, 'Invalid provider configuration - missing caip'); + } + if (!currentProvider.networkId) { + throw createProviderRpcError(4900, 'Invalid provider configuration - missing networkId'); + } + + // Clean provider URLs to handle malformed data from storage or dApps + const cleanedProvider = { + ...currentProvider, + providerUrl: currentProvider.providerUrl?.trim(), + explorer: currentProvider.explorer?.trim(), + explorerAddressLink: currentProvider.explorerAddressLink?.trim(), + explorerTxLink: currentProvider.explorerTxLink?.trim(), + providers: Array.isArray(currentProvider.providers) + ? currentProvider.providers.map((url: string) => url?.trim()).filter((url: string) => url && url.length > 0) + : [], + }; + + console.log(tag, 'Cleaned provider URL:', cleanedProvider.providerUrl); + + // Save cleaned provider (this will fix stored data) + await web3ProviderStorage.saveWeb3Provider(cleanedProvider); + await assetContextStorage.updateContext(cleanedProvider); + + // Set asset context + try { + console.log(tag, 'Setting asset context...'); + const result = await KEEPKEY_WALLET.setAssetContext(cleanedProvider); + console.log(tag, 'setAssetContext result:', result); + } catch (error) { + console.error(tag, 'Failed to set asset context:', error); + throw createProviderRpcError(4900, `Failed to set asset context: ${error.message}`, error); + } + + // Notify listeners with cleaned provider + chrome.runtime.sendMessage({ type: 'PROVIDER_CHANGED', provider: cleanedProvider }); + chrome.runtime.sendMessage({ type: 'ASSET_CONTEXT_UPDATED', assetContext: KEEPKEY_WALLET.assetContext }); + chrome.runtime.sendMessage({ type: 'CHAIN_CHANGED', provider: cleanedProvider }); + console.log(tag, 'Chain switched successfully'); +}; + +// Handle wallet_switchEthereumChain - switch to existing chain only +const handleWalletSwitchEthereumChain = async (params, KEEPKEY_WALLET) => { + const tag = TAG + ' | handleWalletSwitchEthereumChain | '; + console.log(tag, 'Switch Chain params: ', params); + + if (!params || !params[0] || !params[0].chainId) { + throw createProviderRpcError(4001, 'Invalid chainId parameter (Required)'); + } const chainIdHex = params[0].chainId; const chainIdDecimal = parseInt(chainIdHex, 16); const chainId = chainIdDecimal.toString(); const networkId = 'eip155:' + chainIdDecimal; - console.log(tag, 'Switching Chain networkId: ', networkId); - - let currentProvider: any = await web3ProviderStorage.getWeb3Provider(); - - if (params[0].rpcUrls && params[0].rpcUrls[0]) { - const name = params[0].chainName; - console.log(tag, 'Switching Chain name: ', name); - currentProvider = { - explorer: params[0].blockExplorerUrls[0], - explorerAddressLink: params[0].blockExplorerUrls[0] + '/address/', - explorerTxLink: params[0].blockExplorerUrls[0] + '/tx/', - chainId, + console.log(tag, 'networkId: ', networkId); + + // Check if chain exists in our defaults + if (EIP155_CHAINS[networkId]) { + console.log(tag, 'Chain found in defaults, switching...'); + const currentProvider = { + chainId: chainId, + caip: EIP155_CHAINS[networkId].caip, networkId, - caip: `eip155:${chainIdDecimal}/slip44:60`, - name: params[0].chainName, - type: 'evm', - identifier: params[0].chainName, - nativeCurrency: params[0].nativeCurrency, - symbol: params[0].nativeCurrency.symbol, - precision: params[0].nativeCurrency.decimals, - providerUrl: params[0].rpcUrls[0], - providers: params[0].rpcUrls, + name: EIP155_CHAINS[networkId].name, + providerUrl: EIP155_CHAINS[networkId].rpc, }; - blockchainStorage.addBlockchain(currentProvider.networkId); - blockchainDataStorage.addBlockchainData(currentProvider.networkId, currentProvider); - console.log(tag, 'currentProvider', currentProvider); - } else { - console.log(tag, 'Switching to network without loading provider!: networkId', networkId); - - let chainFound = false; - - if (EIP155_CHAINS[networkId]) { - console.log(tag, 'Chain found in defaults'); - currentProvider = { - chainId: chainId, - caip: EIP155_CHAINS[networkId].caip, - networkId, - name: EIP155_CHAINS[networkId].name, - providerUrl: EIP155_CHAINS[networkId].rpc, - }; - chainFound = true; - } else { - console.log(tag, 'Chain not found in defaults'); - const nodeInfoResponse = await KEEPKEY_WALLET.pioneer.SearchNodesByNetworkId({ chainId }); - const nodeInfo = nodeInfoResponse.data; - console.log(tag, 'nodeInfo', nodeInfo); - if (!nodeInfo[0] || !nodeInfo[0].service) throw new Error('Node not found! Unable to change networks!'); - - let allProviders = []; - for (let i = 0; i < nodeInfo.length; i++) { - allProviders = allProviders.concat(nodeInfo[i].network); - } + await switchToProvider(currentProvider, KEEPKEY_WALLET, tag); + return null; + } - currentProvider = { - explorer: nodeInfo[0].infoURL, - explorerAddressLink: nodeInfo[0].infoURL + '/address/', - explorerTxLink: nodeInfo[0].infoURL + '/tx/', - chainId: chainId, - networkId, - symbol: nodeInfo[0].nativeCurrency.symbol, - name: nodeInfo[0].name, - icon: nodeInfo[0].image, - logo: nodeInfo[0].image, - image: nodeInfo[0].image, - type: nodeInfo[0].type.toLowerCase(), - caip: nodeInfo[0].caip, - rpc: nodeInfo[0].service, - providerUrl: nodeInfo[0].service, - providers: allProviders, - }; - chainFound = true; - blockchainStorage.addBlockchain(currentProvider.networkId); - blockchainDataStorage.addBlockchainData(currentProvider.networkId, currentProvider); - } + // Check if chain exists in storage (previously added custom chain) + const storedChainData = await blockchainDataStorage.getBlockchainData(networkId); + if (storedChainData) { + console.log(tag, 'Chain found in storage, switching...'); + await switchToProvider(storedChainData, KEEPKEY_WALLET, tag); + return null; + } - if (!chainFound) { - throw new Error(`Chain with chainId ${chainId} not found.`); - } + // Chain not found - return 4902 per EIP-3326 + console.log(tag, 'Chain not found, returning 4902 error'); + throw createProviderRpcError( + 4902, + `Unrecognized chain ID "${chainIdHex}". Try adding the chain using wallet_addEthereumChain first.`, + ); +}; + +// Handle wallet_addEthereumChain - add new chain with user approval +const handleWalletAddEthereumChain = async (params, KEEPKEY_WALLET) => { + const tag = TAG + ' | handleWalletAddEthereumChain | '; + console.log(tag, 'Add Chain params: ', params); + console.log(tag, 'KEEPKEY_WALLET exists:', !!KEEPKEY_WALLET); + console.log(tag, 'KEEPKEY_WALLET.pioneer exists:', !!KEEPKEY_WALLET?.pioneer); + + if (!params || !params[0] || !params[0].chainId) { + throw createProviderRpcError(4001, 'Invalid chainId parameter (Required)'); } - assetContextStorage.updateContext(currentProvider); - if (currentProvider != null) { - await web3ProviderStorage.saveWeb3Provider(currentProvider); - } else { - throw Error('Failed to set provider! empty provider!'); + const chainIdHex = params[0].chainId; + const chainIdDecimal = parseInt(chainIdHex, 16); + const chainId = chainIdDecimal.toString(); + const networkId = 'eip155:' + chainIdDecimal; + console.log(tag, 'Adding Chain networkId: ', networkId); + + // Check if dApp provided RPC URLs (full chain details) + if (!params[0].rpcUrls || !params[0].rpcUrls[0]) { + // No RPC URLs provided - cannot add chain without details + throw createProviderRpcError( + 4001, + `Missing rpcUrls parameter. To add chain ${chainIdHex}, please provide rpcUrls, chainName, and nativeCurrency.`, + ); + } + + // Validate required parameters for adding chain + if (!params[0].chainName) { + throw createProviderRpcError(4001, 'Missing chainName parameter'); + } + if (!params[0].nativeCurrency) { + throw createProviderRpcError(4001, 'Missing nativeCurrency parameter'); } - console.log('Changing context to caip', currentProvider.caip); - console.log('Changing context to networkId', currentProvider.networkId); - if (!currentProvider.caip) throw Error('invalid provider! missing caip'); - if (!currentProvider.networkId) throw Error('invalid provider! missing networkId'); - const result = await KEEPKEY_WALLET.setAssetContext(currentProvider); - console.log('Result ', result); - console.log('KEEPKEY_WALLET.assetContext ', KEEPKEY_WALLET.assetContext); + // Build provider config from dApp params + console.log(tag, 'Adding custom chain:', params[0].chainName); - chrome.runtime.sendMessage({ type: 'PROVIDER_CHANGED', provider: currentProvider }); - chrome.runtime.sendMessage({ type: 'ASSET_CONTEXT_UPDATED', assetContext: KEEPKEY_WALLET.assetContext }); - chrome.runtime.sendMessage({ type: 'CHAIN_CHANGED', provider: currentProvider }); - console.log('Pushing CHAIN_CHANGED event'); + // Clean and validate RPC URLs (trim whitespace) + const cleanRpcUrls = params[0].rpcUrls.map((url: string) => url.trim()).filter((url: string) => url.length > 0); + if (cleanRpcUrls.length === 0) { + throw createProviderRpcError(4001, 'Invalid rpcUrls - all URLs are empty after cleaning'); + } + + const cleanExplorer = params[0].blockExplorerUrls?.[0]?.trim() || ''; + + const newProvider = { + explorer: cleanExplorer, + explorerAddressLink: cleanExplorer ? `${cleanExplorer}/address/` : '', + explorerTxLink: cleanExplorer ? `${cleanExplorer}/tx/` : '', + chainId, + networkId, + caip: `eip155:${chainIdDecimal}/slip44:60`, + name: params[0].chainName.trim(), + type: 'evm', + identifier: params[0].chainName.trim(), + nativeCurrency: params[0].nativeCurrency, + symbol: params[0].nativeCurrency.symbol.trim(), + precision: params[0].nativeCurrency.decimals, + providerUrl: cleanRpcUrls[0], + providers: cleanRpcUrls, + }; + + console.log(tag, 'Cleaned provider config:', newProvider); + + // TODO: Show user approval dialog here + // For now, auto-approve - but we should ask user permission + console.log(tag, 'Auto-approving chain addition (TODO: add user prompt)'); + + // Store the custom chain + await blockchainStorage.addBlockchain(newProvider.networkId); + await blockchainDataStorage.addBlockchainData(newProvider.networkId, newProvider); + console.log(tag, 'Custom chain stored:', newProvider); + + // Switch to the newly added chain + await switchToProvider(newProvider, KEEPKEY_WALLET, tag); return null; }; @@ -326,6 +466,34 @@ const handleWalletPermissions = async () => { return permissions; }; +const handleWalletGetCapabilities = async (params: any[]) => { + // wallet_getCapabilities is used by dApps to determine what features the wallet supports + // Uniswap uses this to check for things like atomic batch transactions + const address = params[0]?.toLowerCase(); + + // Return capabilities for the specified address or all addresses + const capabilities: Record = {}; + + // Add base capabilities that KeepKey supports + const baseCapabilities = { + atomicBatch: { + supported: false, // KeepKey doesn't support atomic batch transactions yet + }, + paymasterService: { + supported: false, // No paymaster service support + }, + }; + + if (address) { + capabilities[address] = baseCapabilities; + } else { + // Return for all addresses if none specified + capabilities['0x0000000000000000000000000000000000000000'] = baseCapabilities; + } + + return capabilities; +}; + const handleEthAccounts = async ADDRESS => { const accounts = [ADDRESS]; return accounts; @@ -471,10 +639,21 @@ const handleTransfer = async (params, requestInfo, ADDRESS, KEEPKEY_WALLET, requ if (result.success && response.unsignedTx) { console.log(tag, 'FINAL: unsignedTx: ', response.unsignedTx); - const signedTx = await KEEPKEY_WALLET.signTx({ - caip, - unsignedTx: response.unsignedTx, - }); + + // Convert chainId from number to hex string if needed + const txForSigning = { + ...response.unsignedTx, + chainId: + typeof response.unsignedTx.chainId === 'number' + ? '0x' + response.unsignedTx.chainId.toString(16) + : response.unsignedTx.chainId, + }; + + console.log(tag, 'txForSigning (chainId converted to hex):', txForSigning); + + // CRITICAL: signTx expects TWO separate parameters (caip, unsignedTx) + // NOT an object { caip, unsignedTx } + const signedTx = await KEEPKEY_WALLET.signTx(caip, txForSigning); console.log(tag, 'signedTx:', signedTx); // Update storage with signed transaction @@ -573,8 +752,10 @@ export const handleEthereumRequest = async ( case 'eth_sendRawTransaction': return await handleEthSendRawTransaction(params); - case 'wallet_addEthereumChain': case 'wallet_switchEthereumChain': + return await handleWalletSwitchEthereumChain(params, KEEPKEY_WALLET); + + case 'wallet_addEthereumChain': return await handleWalletAddEthereumChain(params, KEEPKEY_WALLET); case 'wallet_getSnaps': @@ -587,6 +768,9 @@ export const handleEthereumRequest = async ( case 'wallet_requestPermissions': return await handleWalletPermissions(); + case 'wallet_getCapabilities': + return await handleWalletGetCapabilities(params); + case 'request_accounts': case 'eth_accounts': return await handleEthAccounts(ADDRESS); diff --git a/chrome-extension/src/background/chains/litecoinHandler.ts b/chrome-extension/src/background/chains/litecoinHandler.ts index 4b02721..ff0db36 100644 --- a/chrome-extension/src/background/chains/litecoinHandler.ts +++ b/chrome-extension/src/background/chains/litecoinHandler.ts @@ -2,7 +2,7 @@ import { bip32ToAddressNList } from '@pioneer-platform/pioneer-coins'; const TAG = ' | litecoinHandler | '; import { JsonRpcProvider } from 'ethers'; -import { Chain, DerivationPath } from '@coinmasters/types'; +import { Chain } from '@pioneer-platform/pioneer-caip'; import { AssetValue } from '@pioneer-platform/helpers'; import { EIP155_CHAINS } from '../chains'; // @ts-ignore @@ -142,7 +142,7 @@ export const handleLitecoinRequest = async ( console.log(tag, 'response: ', response); if (result.success && response.unsignedTx) { - const signedTx = await KEEPKEY_WALLET.signTx({ caip, unsignedTx: response.unsignedTx }); + const signedTx = await KEEPKEY_WALLET.signTx(caip, response.unsignedTx); console.log(tag, 'signedTx: ', signedTx); response.signedTx = signedTx; diff --git a/chrome-extension/src/background/chains/mayaHandler.ts b/chrome-extension/src/background/chains/mayaHandler.ts index ee10777..ddc2aca 100644 --- a/chrome-extension/src/background/chains/mayaHandler.ts +++ b/chrome-extension/src/background/chains/mayaHandler.ts @@ -1,6 +1,6 @@ const TAG = ' | mayaHandler | '; import { JsonRpcProvider } from 'ethers'; -import { Chain } from '@coinmasters/types'; +import { Chain } from '@pioneer-platform/pioneer-caip'; import { AssetValue } from '@pioneer-platform/helpers'; import { EIP155_CHAINS } from '../chains'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -125,10 +125,7 @@ export const handleMayaRequest = async ( if (result.success && requestInfo.unsignedTx) { //send tx // Sign the transaction - const signedTx = await KEEPKEY_WALLET.signTx({ - caip, - unsignedTx: requestInfo.unsignedTx, - }); + const signedTx = await KEEPKEY_WALLET.signTx(caip, requestInfo.unsignedTx); console.log(tag, 'signedTx:', signedTx); // Update storage with signed transaction diff --git a/chrome-extension/src/background/chains/osmosisHandler.ts b/chrome-extension/src/background/chains/osmosisHandler.ts index d737db7..f94daf1 100644 --- a/chrome-extension/src/background/chains/osmosisHandler.ts +++ b/chrome-extension/src/background/chains/osmosisHandler.ts @@ -1,5 +1,5 @@ const TAG = ' | osmosisHandler | '; -import { Chain } from '@coinmasters/types'; +import { Chain } from '@pioneer-platform/pioneer-caip'; import { AssetValue } from '@pioneer-platform/helpers'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error @@ -125,10 +125,7 @@ export const handleOsmosisRequest = async ( if (approvalResponse.success && requestInfo.unsignedTx) { // Sign the transaction - const signedTx = await KEEPKEY_WALLET.signTx({ - caip, - unsignedTx: requestInfo.unsignedTx, - }); + const signedTx = await KEEPKEY_WALLET.signTx(caip, requestInfo.unsignedTx); console.log(tag, 'signedTx:', signedTx); // Update storage with signed transaction diff --git a/chrome-extension/src/background/chains/rippleHandler.ts b/chrome-extension/src/background/chains/rippleHandler.ts index 907a597..23110ca 100644 --- a/chrome-extension/src/background/chains/rippleHandler.ts +++ b/chrome-extension/src/background/chains/rippleHandler.ts @@ -1,6 +1,6 @@ const TAG = ' | rippleHandler | '; import { JsonRpcProvider } from 'ethers'; -import { Chain } from '@coinmasters/types'; +import { Chain } from '@pioneer-platform/pioneer-caip'; import { AssetValue } from '@pioneer-platform/helpers'; import { EIP155_CHAINS } from '../chains'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -117,7 +117,7 @@ export const handleRippleRequest = async ( if (result.success && requestInfo.unsignedTx) { //sign - const signedTx = await KEEPKEY_WALLET.signTx({ caip, unsignedTx: requestInfo.unsignedTx }); + const signedTx = await KEEPKEY_WALLET.signTx(caip, requestInfo.unsignedTx); console.log(tag, 'signedTx: ', signedTx); // Update storage with signed transaction diff --git a/chrome-extension/src/background/chains/thorchainHandler.ts b/chrome-extension/src/background/chains/thorchainHandler.ts index 251ea54..0ff1773 100644 --- a/chrome-extension/src/background/chains/thorchainHandler.ts +++ b/chrome-extension/src/background/chains/thorchainHandler.ts @@ -1,6 +1,6 @@ const TAG = ' | thorchainHandler | '; import { JsonRpcProvider } from 'ethers'; -import { Chain } from '@coinmasters/types'; +import { Chain } from '@pioneer-platform/pioneer-caip'; import { AssetValue } from '@pioneer-platform/helpers'; import { EIP155_CHAINS } from '../chains'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -117,7 +117,7 @@ export const handleThorchainRequest = async ( if (result.success && eventUpdated.unsignedTx) { //sign - const signedTx = await KEEPKEY_WALLET.signTx({ caip, unsignedTx: eventUpdated.unsignedTx }); + const signedTx = await KEEPKEY_WALLET.signTx(caip, eventUpdated.unsignedTx); console.log(tag, 'signedTx: ', signedTx); //broadcast diff --git a/chrome-extension/src/background/index.ts b/chrome-extension/src/background/index.ts index f451489..96f838d 100644 --- a/chrome-extension/src/background/index.ts +++ b/chrome-extension/src/background/index.ts @@ -1,12 +1,15 @@ import 'webextension-polyfill'; +// Buffer polyfill for browser environment +import { Buffer } from 'buffer'; +globalThis.Buffer = Buffer; + import packageJson from '../../package.json'; // Adjust the path as needed import { onStartKeepkey } from './keepkey'; import { handleWalletRequest } from './methods'; // import { listenForApproval } from './approvals'; import { JsonRpcProvider } from 'ethers'; -import { ChainToNetworkId } from '@pioneer-platform/pioneer-caip'; -import { Chain } from '@coinmasters/types'; -import { requestStorage, exampleSidebarStorage, web3ProviderStorage } from '@extension/storage'; // Re-import the storage +import { ChainToNetworkId, Chain } from '@pioneer-platform/pioneer-caip'; +import { requestStorage, exampleSidebarStorage, web3ProviderStorage, blockchainDataStorage } from '@extension/storage'; // Re-import the storage import { EIP155_CHAINS } from './chains'; import axios from 'axios'; @@ -82,6 +85,59 @@ const onStart = async function () { console.log(tag, 'APP.balances: ', APP.balances); console.log(tag, 'APP.pubkeys: ', APP.pubkeys); + // Fetch balances for all available networks + if (APP.balances && APP.balances.length === 0) { + console.log(tag, 'No initial balances, fetching all network balances...'); + try { + // Get unique network IDs from pubkeys + const networkIds = new Set(); + APP.pubkeys.forEach((pubkey: any) => { + if (pubkey.networks && Array.isArray(pubkey.networks)) { + pubkey.networks.forEach((networkId: string) => networkIds.add(networkId)); + } + }); + + console.log(tag, 'Found networks to fetch balances for:', Array.from(networkIds)); + + // Fetch balances for each network and accumulate them + const allBalances: any[] = []; + for (const networkId of networkIds) { + try { + await APP.getBalance(networkId); + // After each fetch, collect the balances + if (APP.balances && APP.balances.length > 0) { + APP.balances.forEach((balance: any) => { + // Only add if not already in allBalances + if (!allBalances.find((b: any) => b.caip === balance.caip)) { + allBalances.push(balance); + } + }); + } + } catch (e) { + console.error(tag, `Failed to fetch balance for ${networkId}:`, e); + } + } + + // Set all accumulated balances + if (allBalances.length > 0) { + APP.balances = allBalances; + } + + console.log(tag, 'Finished fetching all balances, total:', APP.balances?.length); + } catch (e) { + console.error(tag, 'Error fetching initial balances:', e); + } + } + + // Discover tokens for all networks + console.log(tag, 'Discovering tokens via APP.getCharts()...'); + try { + await APP.getCharts(); + console.log(tag, 'Token discovery complete, total balances:', APP.balances?.length); + } catch (e) { + console.error(tag, 'Error discovering tokens:', e); + } + const pubkeysEth = APP.pubkeys.filter((e: any) => e.networks.includes(ChainToNetworkId[Chain.Ethereum])); if (pubkeysEth.length > 0) { console.log(tag, 'pubkeys:', pubkeysEth); @@ -341,6 +397,11 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a case 'CLEAR_ASSET_CONTEXT': { if (APP) { APP.setAssetContext(); + // Notify all tabs/panels that asset context has been cleared + chrome.runtime.sendMessage({ type: 'ASSET_CONTEXT_CLEARED' }).catch(() => { + // Ignore errors if no listeners + }); + sendResponse({ success: true }); } else { sendResponse({ error: 'APP not initialized' }); } @@ -352,10 +413,49 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a const { asset } = message; if (asset && asset.caip) { try { + // Store existing balances before fetching new ones + const existingBalances = APP.balances ? [...APP.balances] : []; + console.log(tag, 'Existing balances count before update:', existingBalances.length); + //refresh balances for network const networkId = asset.networkId; await APP.getBalance(networkId); + // Check if getBalance replaced all balances + console.log(tag, 'Balances count after getBalance:', APP.balances?.length); + + // If we had more balances before and now have fewer, merge them + if (existingBalances.length > 0 && APP.balances) { + // Create a map of new balances for the updated network + const newBalancesMap = new Map(); + APP.balances.forEach((balance: any) => { + if (balance.networkId === networkId) { + newBalancesMap.set(balance.caip, balance); + } + }); + + // Update existing balances with new data for this network only + const mergedBalances = existingBalances.map((balance: any) => { + // If this balance is for the network we just updated, use the new data + if (balance.networkId === networkId && newBalancesMap.has(balance.caip)) { + return newBalancesMap.get(balance.caip); + } + // Otherwise keep the existing balance + return balance; + }); + + // Add any new balances for this network that didn't exist before + newBalancesMap.forEach((newBalance: any, caip: string) => { + if (!mergedBalances.find((b: any) => b.caip === caip)) { + mergedBalances.push(newBalance); + } + }); + + // Restore the full balances array + APP.balances = mergedBalances; + console.log(tag, 'Restored balances count:', APP.balances.length); + } + console.log(tag, 'Setting asset context:', asset); const response = await APP.setAssetContext(asset); console.log('Asset context set:', response); @@ -368,9 +468,35 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a const currentAssetContext = await APP.assetContext; //if eip155 then set web3 provider if (currentAssetContext.networkId.includes('eip155')) { - const newProvider = EIP155_CHAINS[currentAssetContext.networkId].provider; - console.log('newProvider', newProvider); - await web3ProviderStorage.setWeb3Provider(newProvider); + // Try to get provider data from custom chains first (user-added networks) + let providerData = await blockchainDataStorage.getBlockchainData(currentAssetContext.networkId); + + // Fallback to static chain list if not found in custom storage + if (!providerData) { + const chainInfo = EIP155_CHAINS[currentAssetContext.networkId]; + if (chainInfo) { + // Build provider object from static chain info + providerData = { + chainId: chainInfo.chainId, + caip: chainInfo.caip, + blockExplorerUrls: [], + name: chainInfo.name, + providerUrl: chainInfo.rpc, + fallbacks: [], + }; + } else { + console.error( + tag, + 'Network not found in custom or static chains:', + currentAssetContext.networkId, + ); + } + } + + console.log('newProvider', providerData); + if (providerData) { + await web3ProviderStorage.setWeb3Provider(providerData); + } } } catch (error) { console.error('Error setting asset context:', error); @@ -555,6 +681,13 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a case 'GET_APP_BALANCES': { if (APP) { + console.log(tag, 'GET_APP_BALANCES - Total balances:', APP.balances?.length); + console.log(tag, 'GET_APP_BALANCES - Sample balance:', APP.balances?.[0]); + const tokens = APP.balances?.filter((b: any) => b.token === true); + console.log(tag, 'GET_APP_BALANCES - Tokens found:', tokens?.length); + if (tokens && tokens.length > 0) { + console.log(tag, 'GET_APP_BALANCES - Sample token:', tokens[0]); + } sendResponse({ balances: APP.balances }); } else { sendResponse({ error: 'APP not initialized' }); @@ -562,6 +695,377 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a break; } + case 'REFRESH_ALL_BALANCES': { + if (APP) { + console.log(tag, 'Refreshing all balances...'); + try { + // Get unique network IDs from pubkeys + const networkIds = new Set(); + APP.pubkeys.forEach((pubkey: any) => { + if (pubkey.networks && Array.isArray(pubkey.networks)) { + pubkey.networks.forEach((networkId: string) => networkIds.add(networkId)); + } + }); + + // Fetch and accumulate balances for all networks + const allBalances: any[] = []; + for (const networkId of networkIds) { + try { + await APP.getBalance(networkId); + // After each fetch, collect the balances + if (APP.balances && APP.balances.length > 0) { + APP.balances.forEach((balance: any) => { + // Only add if not already in allBalances + if (!allBalances.find((b: any) => b.caip === balance.caip)) { + allBalances.push(balance); + } + }); + } + } catch (e) { + console.error(tag, `Failed to fetch balance for ${networkId}:`, e); + } + } + + // Update APP.balances with all accumulated balances + if (allBalances.length > 0) { + APP.balances = allBalances; + } + + console.log(tag, 'All balances refreshed, total:', APP.balances?.length); + sendResponse({ balances: APP.balances }); + } catch (error) { + console.error('Error refreshing all balances:', error); + sendResponse({ error: 'Failed to refresh all balances' }); + } + } else { + sendResponse({ error: 'APP not initialized' }); + } + break; + } + + case 'GET_CHARTS': { + if (APP) { + console.log(tag, 'Fetching charts (discovering tokens)...'); + try { + const { networkIds } = message; + + // Call getCharts with optional network filter + // If networkIds array is provided, only fetch for those networks + // If not provided, fetch for all networks + if (networkIds && Array.isArray(networkIds) && networkIds.length > 0) { + console.log(tag, `Fetching charts for specific networks: ${networkIds.join(', ')}`); + await APP.getCharts(networkIds); + } else { + console.log(tag, 'Fetching charts for all networks'); + await APP.getCharts(); + } + + console.log(tag, 'Charts fetched successfully, balances count:', APP.balances?.length); + + // Return the updated balances + sendResponse({ + success: true, + balances: APP.balances, + message: 'Charts fetched successfully', + }); + } catch (error: any) { + console.error('Error fetching charts:', error); + sendResponse({ + error: error.message || 'Failed to fetch charts', + success: false, + }); + } + } else { + sendResponse({ error: 'APP not initialized', success: false }); + } + break; + } + + case 'LOOKUP_TOKEN_METADATA': { + if (APP) { + console.log(tag, 'Looking up token metadata...'); + try { + const { networkId, contractAddress, userAddress } = message; + + if (!networkId || !contractAddress) { + throw new Error('networkId and contractAddress are required'); + } + + console.log(tag, 'Calling APP.pioneer.LookupTokenMetadata:', { networkId, contractAddress, userAddress }); + + const payload: any = { + networkId, + contractAddress, + }; + + // Include userAddress if provided to get balance too + if (userAddress) { + payload.userAddress = userAddress; + } + + const result = await APP.pioneer.LookupTokenMetadata(payload); + + console.log(tag, 'Token metadata lookup result:', result); + + sendResponse({ + success: true, + data: result.data, + }); + } catch (error: any) { + console.error('Error looking up token metadata:', error); + sendResponse({ + success: false, + error: error.message || 'Failed to lookup token metadata', + }); + } + } else { + sendResponse({ error: 'APP not initialized', success: false }); + } + break; + } + + case 'ADD_CUSTOM_TOKEN': { + if (APP) { + console.log(tag, 'Adding custom token...'); + try { + const { userAddress, token } = message; + + if (!userAddress || !token) { + throw new Error('userAddress and token are required'); + } + + console.log(tag, 'Calling APP.pioneer.AddCustomToken:', { userAddress, token }); + + const result = await APP.pioneer.AddCustomToken({ + userAddress, + token: { + networkId: token.networkId, + address: token.address, + caip: token.caip, + name: token.name, + symbol: token.symbol, + decimals: token.decimals, + icon: token.icon, + coingeckoId: token.coingeckoId, + }, + }); + + console.log(tag, 'Add custom token result:', result); + + sendResponse({ + success: result?.success || result?.data?.success || false, + data: result.data, + }); + } catch (error: any) { + console.error('Error adding custom token:', error); + sendResponse({ + success: false, + error: error.message || 'Failed to add custom token', + }); + } + } else { + sendResponse({ error: 'APP not initialized', success: false }); + } + break; + } + + case 'GET_CUSTOM_TOKENS': { + if (APP) { + console.log(tag, 'Getting custom tokens...'); + try { + const { userAddress, networkId } = message; + + if (!userAddress) { + throw new Error('userAddress is required'); + } + + console.log(tag, 'Calling APP.pioneer.GetCustomTokens:', { userAddress, networkId }); + + const payload: any = { userAddress }; + if (networkId) { + payload.networkId = networkId; + } + + const result = await APP.pioneer.GetCustomTokens(payload); + + console.log(tag, 'Get custom tokens result:', result); + + // Handle nested response structure: result.data.data.tokens + const tokens = result?.data?.data?.tokens || result?.data?.tokens || result?.tokens || []; + + sendResponse({ + success: true, + tokens, + }); + } catch (error: any) { + console.error('Error getting custom tokens:', error); + sendResponse({ + success: false, + error: error.message || 'Failed to get custom tokens', + tokens: [], + }); + } + } else { + sendResponse({ error: 'APP not initialized', success: false }); + } + break; + } + + case 'GET_CUSTOM_TOKEN_BALANCES': { + if (APP) { + console.log(tag, 'Getting custom token balances...'); + try { + const { networkId, address } = message; + + if (!networkId || !address) { + throw new Error('networkId and address are required'); + } + + console.log(tag, 'Calling APP.pioneer.GetCustomTokenBalances:', { networkId, address }); + + const result = await APP.pioneer.GetCustomTokenBalances({ + networkId, + address, + }); + + console.log(tag, 'Get custom token balances result:', result); + + // Handle nested response structure + const tokens = result?.data?.data?.tokens || result?.data?.tokens || result?.tokens || []; + + sendResponse({ + success: true, + tokens, + }); + } catch (error: any) { + console.error('Error getting custom token balances:', error); + sendResponse({ + success: false, + error: error.message || 'Failed to get custom token balances', + tokens: [], + }); + } + } else { + sendResponse({ error: 'APP not initialized', success: false }); + } + break; + } + + case 'REMOVE_CUSTOM_TOKEN': { + if (APP) { + console.log(tag, 'Removing custom token...'); + try { + const { userAddress, networkId, tokenAddress } = message; + + if (!userAddress || !networkId || !tokenAddress) { + throw new Error('userAddress, networkId, and tokenAddress are required'); + } + + console.log(tag, 'Calling APP.pioneer.RemoveCustomToken:', { userAddress, networkId, tokenAddress }); + + const result = await APP.pioneer.RemoveCustomToken({ + userAddress, + networkId, + tokenAddress, + }); + + console.log(tag, 'Remove custom token result:', result); + + sendResponse({ + success: result?.success || result?.data?.success || false, + data: result.data, + }); + } catch (error: any) { + console.error('Error removing custom token:', error); + sendResponse({ + success: false, + error: error.message || 'Failed to remove custom token', + }); + } + } else { + sendResponse({ error: 'APP not initialized', success: false }); + } + break; + } + + case 'VALIDATE_ERC20_TOKEN': { + if (APP) { + try { + const { contractAddress, networkId } = message; + + if (!contractAddress) { + sendResponse({ valid: false, error: 'Contract address is required' }); + break; + } + + if (!networkId) { + sendResponse({ valid: false, error: 'Network ID is required' }); + break; + } + + // Extract chain ID from network ID (e.g., 'eip155:1' -> '1') + const chainId = networkId.replace('eip155:', ''); + + // Get RPC provider for the network + const chainInfo = EIP155_CHAINS[networkId]; + if (!chainInfo) { + sendResponse({ valid: false, error: 'Unsupported network' }); + break; + } + + const rpcProvider = new JsonRpcProvider(chainInfo.rpc); + + // ERC-20 ABI for name, symbol, and decimals + const ERC20_ABI = [ + 'function name() view returns (string)', + 'function symbol() view returns (string)', + 'function decimals() view returns (uint8)', + ]; + + const { Contract } = await import('ethers'); + const tokenContract = new Contract(contractAddress, ERC20_ABI, rpcProvider); + + // Validate token by calling its methods + const [name, symbol, decimals] = await Promise.all([ + tokenContract.name(), + tokenContract.symbol(), + tokenContract.decimals(), + ]); + + // Build CAIP identifier + const caip = `${networkId}/erc20:${contractAddress.toLowerCase()}`; + + const tokenData = { + address: contractAddress, + symbol, + name, + decimals: Number(decimals), + caip, + networkId, + }; + + console.log(tag, 'Token validated successfully:', tokenData); + sendResponse({ valid: true, token: tokenData }); + } catch (error: any) { + console.error(tag, 'Error validating ERC-20 token:', error); + sendResponse({ + valid: false, + error: error.message || 'Failed to validate token. Make sure it is a valid ERC-20 contract.', + }); + } + } else { + sendResponse({ valid: false, error: 'APP not initialized' }); + } + break; + } + + case 'INJECTION_SUCCESS': { + // Content script successfully injected - just log it + console.log(tag, 'Injection successful:', message.url); + sendResponse({ success: true }); + break; + } + default: console.error('Unknown message:', message); sendResponse({ error: 'Unknown message type: ' + message.type }); diff --git a/chrome-extension/src/background/keepkey.ts b/chrome-extension/src/background/keepkey.ts index 838761f..999229f 100644 --- a/chrome-extension/src/background/keepkey.ts +++ b/chrome-extension/src/background/keepkey.ts @@ -2,11 +2,11 @@ KeepKey Wallet */ import { AssetValue } from '@pioneer-platform/helpers'; -import { WalletOption, availableChainsByWallet, ChainToNetworkId, getChainEnumValue } from '@coinmasters/types'; +import { ChainToNetworkId, getChainEnumValue } from '@pioneer-platform/pioneer-caip'; import { getPaths } from '@pioneer-platform/pioneer-coins'; import { keepKeyApiKeyStorage, pioneerKeyStorage } from '@extension/storage'; // Re-import the storage // @ts-ignore -import { SDK } from '@coinmasters/pioneer-sdk'; +import { SDK } from '@pioneer-platform/pioneer-sdk'; import { v4 as uuidv4 } from 'uuid'; // import assert from 'assert'; @@ -170,8 +170,8 @@ export const onStartKeepkey = async function () { const keepkeyApiKey = (await keepKeyApiKeyStorage.getApiKey()) || 'key:123'; let username = await pioneerKeyStorage.getUsername(); let queryKey = await pioneerKeyStorage.getUsername(); - const spec = (await pioneerKeyStorage.getPioneerSpec()) || 'https://pioneers.dev/spec/swagger.json'; - const wss = (await pioneerKeyStorage.getPioneerWss()) || 'wss://pioneers.dev'; + const spec = (await pioneerKeyStorage.getPioneerSpec()) || 'https://api.keepkey.info/spec/swagger.json'; + const wss = (await pioneerKeyStorage.getPioneerWss()) || 'wss://api.keepkey.info'; if (!queryKey) { queryKey = `key:${uuidv4()}`; pioneerKeyStorage.saveQueryKey(queryKey); @@ -186,7 +186,7 @@ export const onStartKeepkey = async function () { console.log(tag, 'queryKey:', queryKey); console.log(tag, 'spec:', spec); console.log(tag, 'wss:', wss); - //let spec = 'https://pioneers.dev/spec/swagger.json' + //let spec = 'https://api.keepkey.info/spec/swagger.json' const config: any = { appName: 'KeepKey Client', diff --git a/chrome-extension/src/injected/injected.ts b/chrome-extension/src/injected/injected.ts new file mode 100644 index 0000000..c21b177 --- /dev/null +++ b/chrome-extension/src/injected/injected.ts @@ -0,0 +1,570 @@ +import type { + WalletRequestInfo, + WalletMessage, + ProviderInfo, + WalletCallback, + InjectionState, + ChainType, + WalletProvider, + KeepKeyWindow, +} from './types'; + +(function () { + const TAG = ' | KeepKeyInjected | '; + const VERSION = '2.0.0'; + const MAX_RETRY_COUNT = 3; + const RETRY_DELAY = 100; // ms + const CALLBACK_TIMEOUT = 30000; // 30 seconds + const MESSAGE_QUEUE_MAX = 100; + + const kWindow = window as KeepKeyWindow; + + // Enhanced injection state tracking + const injectionState: InjectionState = { + isInjected: false, + version: VERSION, + injectedAt: Date.now(), + retryCount: 0, + }; + + // Check for existing injection with version comparison + if (kWindow.keepkeyInjectionState) { + const existing = kWindow.keepkeyInjectionState; + console.warn(TAG, `Existing injection detected v${existing.version}, current v${VERSION}`); + + // Only skip if same or newer version + if (existing.version >= VERSION) { + console.log(TAG, 'Skipping injection, newer or same version already present'); + return; + } + console.log(TAG, 'Upgrading injection to newer version'); + } + + // Set injection state + kWindow.keepkeyInjectionState = injectionState; + + console.log(TAG, `Initializing KeepKey Injection v${VERSION}`); + + // Enhanced source information + const SOURCE_INFO = { + siteUrl: window.location.href, + scriptSource: 'KeepKey Extension', + version: VERSION, + injectedTime: new Date().toISOString(), + origin: window.location.origin, + protocol: window.location.protocol, + }; + + let messageId = 0; + const callbacks = new Map(); + const messageQueue: WalletMessage[] = []; + let isContentScriptReady = false; + + // Cleanup old callbacks periodically + const cleanupCallbacks = () => { + const now = Date.now(); + callbacks.forEach((callback, id) => { + if (now - callback.timestamp > CALLBACK_TIMEOUT) { + console.warn(TAG, `Callback timeout for request ${id} (${callback.method})`); + callback.callback(new Error('Request timeout')); + callbacks.delete(id); + } + }); + }; + + setInterval(cleanupCallbacks, 5000); + + // Manage message queue size + const addToQueue = (message: WalletMessage) => { + if (messageQueue.length >= MESSAGE_QUEUE_MAX) { + console.warn(TAG, 'Message queue full, removing oldest message'); + messageQueue.shift(); + } + messageQueue.push(message); + }; + + // Process queued messages when content script becomes ready + const processQueue = () => { + if (!isContentScriptReady) return; + + while (messageQueue.length > 0) { + const message = messageQueue.shift(); + if (message) { + window.postMessage(message, window.location.origin); + } + } + }; + + // Verify injection with content script + const verifyInjection = (retryCount = 0): Promise => { + return new Promise(resolve => { + const verifyId = ++messageId; + const timeout = setTimeout(() => { + if (retryCount < MAX_RETRY_COUNT) { + console.log(TAG, `Verification attempt ${retryCount + 1} failed, retrying...`); + setTimeout( + () => { + verifyInjection(retryCount + 1).then(resolve); + }, + RETRY_DELAY * Math.pow(2, retryCount), + ); // Exponential backoff + } else { + console.error(TAG, 'Failed to verify injection after max retries'); + injectionState.lastError = 'Failed to verify injection'; + resolve(false); + } + }, 1000); + + const handleVerification = (event: MessageEvent) => { + if ( + event.source === window && + event.data?.source === 'keepkey-content' && + event.data?.type === 'INJECTION_CONFIRMED' && + event.data?.requestId === verifyId + ) { + clearTimeout(timeout); + window.removeEventListener('message', handleVerification); + isContentScriptReady = true; + injectionState.isInjected = true; + console.log(TAG, 'Injection verified successfully'); + processQueue(); + resolve(true); + } + }; + + window.addEventListener('message', handleVerification); + + // Send verification request + window.postMessage( + { + source: 'keepkey-injected', + type: 'INJECTION_VERIFY', + requestId: verifyId, + version: VERSION, + timestamp: Date.now(), + } as WalletMessage, + window.location.origin, + ); + }); + }; + + // Enhanced wallet request with validation + function walletRequest( + method: string, + params: any[] = [], + chain: ChainType, + callback: (error: any, result?: any) => void, + ) { + const tag = TAG + ' | walletRequest | '; + + // Validate inputs + if (!method || typeof method !== 'string') { + console.error(tag, 'Invalid method:', method); + callback(new Error('Invalid method')); + return; + } + + if (!Array.isArray(params)) { + console.warn(tag, 'Params not an array, wrapping:', params); + params = [params]; + } + + try { + const requestId = ++messageId; + const requestInfo: WalletRequestInfo = { + id: requestId, + method, + params, + chain, + siteUrl: SOURCE_INFO.siteUrl, + scriptSource: SOURCE_INFO.scriptSource, + version: SOURCE_INFO.version, + requestTime: new Date().toISOString(), + referrer: document.referrer, + href: window.location.href, + userAgent: navigator.userAgent, + platform: navigator.platform, + language: navigator.language, + }; + + // Store callback with metadata + callbacks.set(requestId, { + callback, + timestamp: Date.now(), + method, + }); + + const message: WalletMessage = { + source: 'keepkey-injected', + type: 'WALLET_REQUEST', + requestId, + requestInfo, + timestamp: Date.now(), + }; + + if (isContentScriptReady) { + window.postMessage(message, window.location.origin); + } else { + console.log(tag, 'Content script not ready, queueing request'); + addToQueue(message); + } + } catch (error) { + console.error(tag, 'Error in walletRequest:', error); + callback(error); + } + } + + // Listen for responses with enhanced validation + window.addEventListener('message', (event: MessageEvent) => { + const tag = TAG + ' | message | '; + + // Security: Validate origin + if (event.source !== window) return; + + const data = event.data as WalletMessage; + if (!data || typeof data !== 'object') return; + + // Handle injection confirmation + if (data.source === 'keepkey-content' && data.type === 'INJECTION_CONFIRMED') { + isContentScriptReady = true; + processQueue(); + return; + } + + // Handle wallet responses + if (data.source === 'keepkey-content' && data.type === 'WALLET_RESPONSE' && data.requestId) { + const callback = callbacks.get(data.requestId); + if (callback) { + if (data.error) { + callback.callback(data.error); + } else { + callback.callback(null, data.result); + } + callbacks.delete(data.requestId); + } else { + console.warn(tag, 'No callback found for requestId:', data.requestId); + } + } + }); + + // Event emitter implementation for EIP-1193 compatibility + class EventEmitter { + private events: Map> = new Map(); + + on(event: string, handler: Function) { + if (!this.events.has(event)) { + this.events.set(event, new Set()); + } + this.events.get(event)!.add(handler); + } + + off(event: string, handler: Function) { + this.events.get(event)?.delete(handler); + } + + removeListener(event: string, handler: Function) { + this.off(event, handler); + } + + removeAllListeners(event?: string) { + if (event) { + this.events.delete(event); + } else { + this.events.clear(); + } + } + + emit(event: string, ...args: any[]) { + this.events.get(event)?.forEach(handler => { + try { + handler(...args); + } catch (error) { + console.error(TAG, `Error in event handler for ${event}:`, error); + } + }); + } + + once(event: string, handler: Function) { + const onceHandler = (...args: any[]) => { + handler(...args); + this.off(event, onceHandler); + }; + this.on(event, onceHandler); + } + } + + // Create wallet provider with proper typing + function createWalletObject(chain: ChainType): WalletProvider { + console.log(TAG, 'Creating wallet object for chain:', chain); + + const eventEmitter = new EventEmitter(); + + const wallet: WalletProvider = { + network: 'mainnet', + isKeepKey: true, + isMetaMask: true, + isConnected: () => isContentScriptReady, + + request: ({ method, params = [] }) => { + return new Promise((resolve, reject) => { + walletRequest(method, params, chain, (error, result) => { + if (error) { + reject(error); + } else { + resolve(result); + } + }); + }); + }, + + send: (payload: any, param1?: any, callback?: any) => { + if (!payload.chain) { + payload.chain = chain; + } + + if (typeof callback === 'function') { + // Async send + walletRequest(payload.method, payload.params || param1, chain, (error, result) => { + if (error) { + callback(error); + } else { + callback(null, { id: payload.id, jsonrpc: '2.0', result }); + } + }); + } else { + // Sync send (deprecated, but required for compatibility) + console.warn(TAG, 'Synchronous send is deprecated and may not work properly'); + return { id: payload.id, jsonrpc: '2.0', result: null }; + } + }, + + sendAsync: (payload: any, param1?: any, callback?: any) => { + if (!payload.chain) { + payload.chain = chain; + } + + const cb = callback || param1; + if (typeof cb !== 'function') { + console.error(TAG, 'sendAsync requires a callback function'); + return; + } + + walletRequest(payload.method, payload.params || param1, chain, (error, result) => { + if (error) { + cb(error); + } else { + cb(null, { id: payload.id, jsonrpc: '2.0', result }); + } + }); + }, + + on: (event: string, handler: Function) => { + eventEmitter.on(event, handler); + return wallet; // Return this for chaining + }, + + off: (event: string, handler: Function) => { + eventEmitter.off(event, handler); + return wallet; // Return this for chaining + }, + + removeListener: (event: string, handler: Function) => { + eventEmitter.removeListener(event, handler); + return wallet; // Return this for chaining + }, + + removeAllListeners: (event?: string) => { + eventEmitter.removeAllListeners(event); + return wallet; // Return this for chaining + }, + + emit: (event: string, ...args: any[]) => { + eventEmitter.emit(event, ...args); + return wallet; // Return this for chaining + }, + + once: (event: string, handler: Function) => { + eventEmitter.once(event, handler); + return wallet; // Return this for chaining + }, + + // Additional methods for compatibility + enable: () => { + // Legacy method for backward compatibility + return wallet.request({ method: 'eth_requestAccounts' }); + }, + + _metamask: { + isUnlocked: () => Promise.resolve(true), + }, + }; + + // Add chain-specific properties + if (chain === 'ethereum') { + wallet.chainId = '0x1'; + wallet.networkVersion = '1'; + wallet.selectedAddress = null; // Will be populated after connection + + // Auto-connect handler + wallet._handleAccountsChanged = (accounts: string[]) => { + wallet.selectedAddress = accounts[0] || null; + eventEmitter.emit('accountsChanged', accounts); + }; + + wallet._handleChainChanged = (chainId: string) => { + wallet.chainId = chainId; + eventEmitter.emit('chainChanged', chainId); + }; + + wallet._handleConnect = (info: { chainId: string }) => { + eventEmitter.emit('connect', info); + }; + + wallet._handleDisconnect = (error: { code: number; message: string }) => { + wallet.selectedAddress = null; + eventEmitter.emit('disconnect', error); + }; + } + + return wallet; + } + + // EIP-6963 Provider Announcement + function announceProvider(ethereumProvider: WalletProvider) { + const info: ProviderInfo = { + uuid: '350670db-19fa-4704-a166-e52e178b59d4', + name: 'KeepKey', + icon: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAIAAAAACshmLzAAADUklEQVRYCb1XTUgUYRie3bXEWhVLQaUsgwVLoUtEQjUJiZX0A0GX7BIZXurkOTSvdo2kvETHAsOshFgqOqhlRD9C7SGS1JTCsj1krU7PM+w7zMzOzuzMqi88+73v9z7vz3zzzTeziuIgmqbFgG5gBPguFOgq4CXLIMwCo0AXEJN4zxHkEuA6kAIMkUBMqMZk7so/UG8AUcnjOIKwFXgHZIgEwKFmOHOfYO4aySVjmAoc7O4R0EB7lYS5h9K1jBJ6A7CuAfXG7OopbKLXkh4dccNZ7jlsi0gAJlWLI5jBPWFsTK5AGxCRImswFqDGWanDBo6IsYbjUanFbmrFWIHxD3IsmfJsgB4y2aJuF4UrUC5GnuNtxJeEQqEoAb3LJV+F4ctlHwkZXDULv8fEKQCHB4+rCJ9ngKcIGUTVRubT027y8yR9bOM4mhKTTwNJZD4miaDXAG8dqzlMShw3YRCZRVAr7vU4g5F/D4ZBoJK2H+Em9CsfEdBoKn4K9jPAd3G9sMPqZEzpRPzAwRfWJpN9EfZSRkAOE5LD7wrw8dkpwRh55VMm27fqt4FiVBjGBTaxEm4Db8d+4BPtIOK3AdbYCPC1qh/haGIS9gHgDeBbgjTAIkXAfTRxkgaamMNwCHgB+BMk4Decq0hGkFQbka/WMyZ/EeyHNo6TuSwx3Nn8gHQVIYOkOhB5Gp4zcdbBHiDvZ2pRuzozru2euKuDOucg/KliTAjKKMa9ksBpxBLrbzRwVfifOnB4RR2g3QSH3Cfx5FRdc2KoGstroUeQKh47vnAwWvUKjsPcA/wWdBUkjRAgZdsznO8D5xLGC/Opxc3NiQeV9uIsgkNDaUoMFpNDLleAn0cTQNBjGaFW6fn2Wrky/dI6abPOl9eN9deoWhjLloCv3+bPy7w3/9kzfvjX120g1cuSdsJ47xm1CgS9AaxCErlbV6qJ02W1nq22lG75AtIHWQEeJpOYaAT6gBQQWC5XNCjc7dkkHFKWe6v3FcLfbzRAMlcC6IC6C+gGxgCectZnCRMuopVG1v+Nx04sYINlxLH4wI6W52UFhT+Q41b2Nl0qeLnwZPGQucNHrXN6ZDG94RQuO688XbwNFzvjlSuwH03wEW8H+Bf/dxrUOWdc+H8mKXtEpGpY3AAAAABJRU5ErkJggg==', + rdns: 'com.keepkey.client', + }; + + const announceEvent = new CustomEvent('eip6963:announceProvider', { + detail: Object.freeze({ info, provider: ethereumProvider }), + }); + + console.log(TAG, 'Announcing EIP-6963 provider'); + window.dispatchEvent(announceEvent); + } + + // Mount wallet with proper state management + async function mountWallet() { + const tag = TAG + ' | mountWallet | '; + console.log(tag, 'Starting wallet mount process'); + + // Create wallet objects immediately - don't wait for verification + const ethereum = createWalletObject('ethereum'); + const xfi: Record = { + binance: createWalletObject('binance'), + bitcoin: createWalletObject('bitcoin'), + bitcoincash: createWalletObject('bitcoincash'), + dogecoin: createWalletObject('dogecoin'), + dash: createWalletObject('dash'), + ethereum: ethereum, + keplr: createWalletObject('keplr'), + litecoin: createWalletObject('litecoin'), + thorchain: createWalletObject('thorchain'), + mayachain: createWalletObject('mayachain'), + }; + + const keepkey: Record = { + binance: createWalletObject('binance'), + bitcoin: createWalletObject('bitcoin'), + bitcoincash: createWalletObject('bitcoincash'), + dogecoin: createWalletObject('dogecoin'), + dash: createWalletObject('dash'), + ethereum: ethereum, + osmosis: createWalletObject('osmosis'), + cosmos: createWalletObject('cosmos'), + litecoin: createWalletObject('litecoin'), + thorchain: createWalletObject('thorchain'), + mayachain: createWalletObject('mayachain'), + ripple: createWalletObject('ripple'), + }; + + // Mount providers with conflict detection + const mountProvider = (name: string, provider: any) => { + if ((kWindow as any)[name]) { + console.warn(tag, `${name} already exists, checking if override is allowed`); + // TODO: Add user preference check here + } + + try { + Object.defineProperty(kWindow, name, { + value: provider, + writable: false, + configurable: true, // Allow reconfiguration for updates + }); + console.log(tag, `Successfully mounted window.${name}`); + } catch (e) { + console.error(tag, `Failed to mount window.${name}:`, e); + injectionState.lastError = `Failed to mount ${name}`; + } + }; + + // Mount providers + mountProvider('ethereum', ethereum); + mountProvider('xfi', xfi); + mountProvider('keepkey', keepkey); + + // CRITICAL: Set up EIP-6963 listener BEFORE announcing + // This ensures we catch any immediate requests + window.addEventListener('eip6963:requestProvider', () => { + console.log(tag, 'Re-announcing provider on request'); + announceProvider(ethereum); + }); + + // Announce EIP-6963 provider immediately + announceProvider(ethereum); + + // Also announce with a slight delay to catch late-loading dApps + setTimeout(() => { + console.log(tag, 'Delayed EIP-6963 announcement for late-loading dApps'); + announceProvider(ethereum); + }, 100); + + // Handle chain changes and other events + window.addEventListener('message', (event: MessageEvent) => { + if (event.data?.type === 'CHAIN_CHANGED') { + console.log(tag, 'Chain changed:', event.data); + ethereum.emit('chainChanged', event.data.provider?.chainId); + } + if (event.data?.type === 'ACCOUNTS_CHANGED') { + console.log(tag, 'Accounts changed:', event.data); + if (ethereum._handleAccountsChanged) { + ethereum._handleAccountsChanged(event.data.accounts || []); + } + } + }); + + // Now verify injection for content script communication + // This is non-blocking for EIP-6963 + verifyInjection().then(verified => { + if (!verified) { + console.error(tag, 'Failed to verify injection, wallet features may not work'); + injectionState.lastError = 'Injection not verified'; + } else { + console.log(tag, 'Injection verified successfully'); + } + }); + + console.log(tag, 'Wallet mount complete'); + } + + // Initialize immediately for EIP-6963 compliance + // The spec requires announcement as early as possible + mountWallet(); + + // Also re-run when DOM is ready in case dApp loads later + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + console.log(TAG, 'DOM loaded, re-announcing provider for late-loading dApps'); + // Re-announce when DOM is ready + if (kWindow.ethereum && typeof kWindow.dispatchEvent === 'function') { + const ethereum = kWindow.ethereum as WalletProvider; + announceProvider(ethereum); + } + }); + } + + console.log(TAG, 'Injection script loaded and initialized'); +})(); diff --git a/chrome-extension/src/injected/types.ts b/chrome-extension/src/injected/types.ts new file mode 100644 index 0000000..eb57400 --- /dev/null +++ b/chrome-extension/src/injected/types.ts @@ -0,0 +1,99 @@ +// Type definitions for the injected script + +export interface WalletRequestInfo { + id: number; + method: string; + params: any[]; + chain: string; + siteUrl: string; + scriptSource: string; + version: string; + requestTime: string; + referrer: string; + href: string; + userAgent: string; + platform: string; + language: string; +} + +export interface WalletMessage { + source: 'keepkey-injected' | 'keepkey-content'; + type: 'WALLET_REQUEST' | 'WALLET_RESPONSE' | 'INJECTION_CONFIRMED' | 'INJECTION_VERIFY'; + requestId?: number; + requestInfo?: WalletRequestInfo; + result?: any; + error?: any; + version?: string; + timestamp?: number; +} + +export interface ProviderInfo { + uuid: string; + name: string; + icon: string; + rdns: string; +} + +export interface WalletCallback { + callback: (error: any, result?: any) => void; + timestamp: number; + method: string; +} + +export interface InjectionState { + isInjected: boolean; + version: string; + injectedAt: number; + retryCount: number; + lastError?: string; +} + +export type ChainType = + | 'ethereum' + | 'binance' + | 'bitcoin' + | 'bitcoincash' + | 'dogecoin' + | 'dash' + | 'litecoin' + | 'thorchain' + | 'mayachain' + | 'osmosis' + | 'cosmos' + | 'ripple' + | 'keplr'; + +export interface WalletProvider { + network: string; + isKeepKey: boolean; + isMetaMask: boolean; + isConnected: boolean | (() => boolean); + chainId?: string; + networkVersion?: string; + selectedAddress?: string | null; + request: (args: { method: string; params?: any[] }) => Promise; + send: (payload: any, param1?: any, callback?: any) => any; + sendAsync: (payload: any, param1?: any, callback?: any) => any; + on: (event: string, handler: Function) => WalletProvider; + off?: (event: string, handler: Function) => WalletProvider; + once?: (event: string, handler: Function) => WalletProvider; + removeListener: (event: string, handler: Function) => WalletProvider; + removeAllListeners: (event?: string) => WalletProvider; + emit: (event: string, ...args: any[]) => WalletProvider; + enable?: () => Promise; + _metamask?: { + isUnlocked: () => Promise; + }; + _handleAccountsChanged?: (accounts: string[]) => void; + _handleChainChanged?: (chainId: string) => void; + _handleConnect?: (info: { chainId: string }) => void; + _handleDisconnect?: (error: { code: number; message: string }) => void; +} + +export interface KeepKeyWindow extends Window { + keepkeyInjected?: boolean; + keepkeyInjectionState?: InjectionState; + ethereum?: WalletProvider; + xfi?: Record; + keepkey?: Record; +} diff --git a/chrome-extension/vite.config.mts b/chrome-extension/vite.config.mts index 9664b4f..73f4668 100644 --- a/chrome-extension/vite.config.mts +++ b/chrome-extension/vite.config.mts @@ -15,8 +15,12 @@ export default defineConfig({ '@root': rootDir, '@src': srcDir, '@assets': resolve(srcDir, 'assets'), + buffer: 'buffer', }, }, + define: { + global: 'globalThis', + }, plugins: [ libAssetsPlugin({ outputPath: outDir, @@ -26,6 +30,9 @@ export default defineConfig({ isDev && watchRebuildPlugin({ reload: true }), ], publicDir: resolve(rootDir, 'public'), + optimizeDeps: { + include: ['swagger-client'], + }, build: { lib: { formats: ['iife'], @@ -39,6 +46,10 @@ export default defineConfig({ minify: isProduction, reportCompressedSize: isProduction, watch: watchOption, + commonjsOptions: { + include: [/swagger-client/, /node_modules/], + transformMixedEsModules: true, + }, rollupOptions: { external: ['chrome'], }, diff --git a/decode-tx.mjs b/decode-tx.mjs new file mode 100644 index 0000000..3efe6e1 --- /dev/null +++ b/decode-tx.mjs @@ -0,0 +1,49 @@ +import { ethers, Transaction } from 'ethers'; + +// Signed transaction from the logs +const signedTx = '0xf86d8201a48506fc23ac00825208947d1bb46c5d7453356d853565f68e62ee3cd0294f871b186f244314008025a0a12d28d15e938708d3a026c86bc7f5998057885cb9b83862e8aafbef75b114e6a028c7dced1657643f7f777b1a6cba030919dcc924df0c26c1992b202ec6cda5ec'; + +try { + // Parse the signed transaction (ethers v6 API) + const tx = Transaction.from(signedTx); + + console.log('=== TRANSACTION DETAILS ==='); + console.log('From address:', tx.from); + console.log('To address:', tx.to); + console.log('Value (wei):', tx.value.toString()); + console.log('Value (ETH):', ethers.formatEther(tx.value)); + console.log('Nonce:', tx.nonce); + console.log('Gas Price:', ethers.formatUnits(tx.gasPrice, 'gwei'), 'gwei'); + console.log('Gas Limit:', tx.gasLimit.toString()); + console.log('Chain ID:', tx.chainId); + + // Calculate transaction cost + const txCost = tx.gasPrice * tx.gasLimit; + const totalCost = tx.value + txCost; + + console.log('\n=== COST ANALYSIS ==='); + console.log('Gas cost (wei):', txCost.toString()); + console.log('Gas cost (ETH):', ethers.formatEther(txCost)); + console.log('Total needed (value + gas) (ETH):', ethers.formatEther(totalCost)); + + // Now check the actual on-chain balance + console.log('\n=== CHECKING ON-CHAIN BALANCE ==='); + const provider = new ethers.JsonRpcProvider('https://eth.llamarpc.com'); + + const balance = await provider.getBalance(tx.from); + console.log('Current balance (wei):', balance.toString()); + console.log('Current balance (ETH):', ethers.formatEther(balance)); + + const nonce = await provider.getTransactionCount(tx.from); + console.log('Current nonce on-chain:', nonce); + + console.log('\n=== VERIFICATION ==='); + console.log('Expected sender:', '0x141D9959cAe3853b035000490C03991eB70Fc4aC'); + console.log('Actual sender:', tx.from); + console.log('Addresses match:', tx.from.toLowerCase() === '0x141D9959cAe3853b035000490C03991eB70Fc4aC'.toLowerCase() ? '✅' : '❌'); + console.log('Has sufficient balance:', balance >= totalCost ? '✅' : '❌'); + console.log('Nonce matches:', nonce === tx.nonce ? '✅' : `❌ (expected ${nonce}, got ${tx.nonce})`); + +} catch (error) { + console.error('Error decoding transaction:', error); +} diff --git a/docs/EIP-6963.md b/docs/EIP-6963.md new file mode 100644 index 0000000..b5656c4 --- /dev/null +++ b/docs/EIP-6963.md @@ -0,0 +1,247 @@ +Ethereum Improvement Proposals +All +Core +Networking +Interface +ERC +Meta +Informational +Standards Track: Interface +EIP-6963: Multi Injected Provider Discovery +Using window events to announce injected Wallet Providers +Authors Pedro Gomes (@pedrouid), Kosala Hemachandra (@kvhnuke), Richard Moore (@ricmoo), Gregory Markou (@GregTheGreek), Kyle Den Hartog (@kdenhartog), Glitch (@glitch-txs), Jake Moxey (@jxom), Pierre Bertet (@bpierre), Darryl Yeo (@darrylyeo), Yaroslav Sergievsky (@everdimension) +Created 2023-05-01 +Requires EIP-1193 + +Table of Contents +Abstract +Motivation +Specification +Definitions +Provider Info +Provider Detail +Window Events +Rationale +Interfaces +Backwards Compatibility +Reference Implementation +Wallet Provider +DApp implementation +Security Considerations +EIP-1193 Security considerations +Prototype Pollution of Wallet Provider objects +Wallet Imitation and Manipulation +Prevent SVG Javascript Execution +Prevent Wallet Fingerprinting +Copyright +Abstract +An alternative discovery mechanism to window.ethereum for EIP-1193 providers which supports discovering multiple injected Wallet Providers in a web page using Javascript’s window events. + +Motivation +Currently, Wallet Provider that offer browser extensions must inject their Ethereum providers (EIP-1193) into the same window object window.ethereum; however, this creates conflicts for users that may install more than one browser extension. + +Browser extensions are loaded in the web page in an unpredictable and unstable order, resulting in a race condition where the user does not have control over which Wallet Provider is selected to expose the Ethereum interface under the window.ethereum object. Instead, the last wallet to load usually wins. + +This results not only in a degraded user experience but also increases the barrier to entry for new browser extensions as users are forced to only install one browser extension at a time. + +Some browser extensions attempt to counteract this problem by delaying their injection to overwrite the same window.ethereum object which creates an unfair competition for Wallet Providers and lack of interoperability. + +In this proposal, we present a solution that focuses on optimizing the interoperability of multiple Wallet Providers. This solution aims to foster fairer competition by reducing the barriers to entry for new Wallet Providers, along with enhancing the user experience on Ethereum networks. + +This is achieved by introducing a set of window events to provide a two-way communication protocol between Ethereum libraries and injected scripts provided by browser extensions thus enabling users to select their wallet of choice. + +Specification +The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC-2119. + +Definitions +Wallet Provider: A user agent that manages keys and facilitates transactions with Ethereum. + +Decentralized Application (DApp): A web page that relies upon one or many Web3 platform APIs which are exposed to the web page via the Wallet. + +Provider Discovery Library: A library or piece of software that assists a DApp to interact with the Wallet. + +Provider Info +Each Wallet Provider will be announced with the following interface EIP6963ProviderInfo. The values in the EIP6963ProviderInfo MUST be included within the EIP6963ProviderInfo object. The EIP6963ProviderInfo MAY also include extra extensible properties within the object. If a DApp does not recognize the additional properties, it SHOULD ignore them. + +uuid - a globally unique identifier the Wallet Provider that MUST be (UUIDv4 compliant) to uniquely distinguish different EIP-1193 provider sessions that have matching properties defined below during the lifetime of the page. The cryptographic uniqueness provided by UUIDv4 guarantees that two independent EIP6963ProviderInfo objects can be separately identified. +name - a human-readable local alias of the Wallet Provider to be displayed to the user on the DApp. (e.g. Example Wallet Extension or Awesome Example Wallet) +icon - a URI pointing to an image. The image SHOULD be a square with 96x96px minimum resolution. See the Images/Icons below for further requirements of this property. +rdns - The Wallet MUST supply the rdns property which is intended to be a domain name from the Domain Name System in reverse syntax ordering such as com.example.subdomain. It’s up to the Wallet to determine the domain name they wish to use, but it’s generally expected the identifier will remain the same throughout the development of the Wallet. It’s also worth noting that similar to a user agent string in browsers, there are times where the supplied value could be unknown, invalid, incorrect, or attempt to imitate a different Wallet. Therefore, the DApp SHOULD be able to handle these failure cases with minimal degradation to the functionality of the DApp. +/** +* Represents the assets needed to display a wallet + */ + interface EIP6963ProviderInfo { + uuid: string; + name: string; + icon: string; + rdns: string; + } + Images/Icons + A URI-encoded image was chosen to enable flexibility for multiple protocols for fetching and rendering icons, for example: + +# svg (data uri) +data:image/svg+xml, +# png (data uri) +data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg== +The icon string MUST be a data URI as defined in RFC-2397. The image SHOULD be a square with 96x96px minimum resolution. The image format is RECOMMENDED to be either lossless or vector based such as PNG, WebP or SVG to make the image easy to render on the DApp. Since SVG images can execute Javascript, applications and libraries MUST render SVG images using the tag to ensure no untrusted Javascript execution can occur. + +RDNS +The rdns (Reverse-DNS) property serves to provide an identifier which DApps can rely on to be stable between sessions. The Reverse Domain Name Notation is chosen to prevent namespace collisions. The Reverse-DNS convention implies that the value should start with a reversed DNS domain name controlled by the Provider. The domain name should be followed by a subdomain or a product name. Example: com.example.MyBrowserWallet. + +The rdns value MUST BE a valid RFC-1034 Domain Name; +The DNS part of the rdns value SHOULD BE an active domain controlled by the Provider; +DApps MAY reject the Providers which do not follow the Reverse-DNS convention correctly; +DApps SHOULD NOT use the rnds value for feature detection as these are self-attested and prone to impersonation or bad incentives without an additional verification mechanism; feature-discovery and verification are both out of scope of this interface specification. +Provider Detail +The EIP6963ProviderDetail is used as a composition interface to announce a Wallet Provider and related metadata about the Wallet Provider. The EIP6963ProviderDetail MUST contain an info property of type EIP6963ProviderInfo and a provider property of type EIP1193Provider defined by EIP-1193. + +interface EIP6963ProviderDetail { +info: EIP6963ProviderInfo; +provider: EIP1193Provider; +} +Window Events +In order to prevent provider collisions, the DApp and the Wallet are expected to emit an event and instantiate an eventListener to discover the various Wallets. This forms an Event concurrency loop. + +Since the DApp code and Wallet code aren’t guaranteed to run in a particular order, the events are designed to handle such race conditions. + +To emit events, both DApps and Wallets MUST use the window.dispatchEvent function to emit events and MUST use the window.addEventListener function to observe events. There are two Event interfaces used for the DApp and Wallet to discover each other. + +Announce and Request Events +The EIP6963AnnounceProviderEvent interface MUST be a CustomEvent object with a type property containing a string value of eip6963:announceProvider and a detail property with an object value of type EIP6963ProviderDetail. The EIP6963ProviderDetail object SHOULD be frozen by calling Object.freeze() on the value of the detail property. + +// Announce Event dispatched by a Wallet +interface EIP6963AnnounceProviderEvent extends CustomEvent { +type: "eip6963:announceProvider"; +detail: EIP6963ProviderDetail; +} +The EIP6963RequestProviderEvent interface MUST be an Event object with a type property containing a string value of eip6963:requestProvider. + +// Request Event dispatched by a DApp +interface EIP6963RequestProviderEvent extends Event { +type: "eip6963:requestProvider"; +} +The Wallet MUST announce the EIP6963AnnounceProviderEvent to the DApp via a window.dispatchEvent() function call. The Wallet MUST add an EventListener to catch an EIP6963RequestProviderEvent dispatched from the DApp. This EventListener MUST use a handler that will re-dispatch an EIP6963AnnounceProviderEvent. This re-announcement by the Wallet is useful for when a Wallet’s initial Event announcement may have been delayed or fired before the DApp had initialized its EventListener. This allows the various Wallet Providers to react to the DApp without the need to pollute the window.ethereum namespace which can produce non-deterministic wallet behavior such as different wallets connecting each time. + +The Wallet dispatches the "eip6963:announceProvider" event with immutable contents and listens to the "eip6963:requestProvider" event: + +let info: EIP6963ProviderInfo; +let provider: EIP1193Provider; + +const announceEvent: EIP6963AnnounceProviderEvent = new CustomEvent( +"eip6963:announceProvider", +{ detail: Object.freeze({ info, provider }) } +); + +// The Wallet dispatches an announce event which is heard by +// the DApp code that had run earlier +window.dispatchEvent(announceEvent); + +// The Wallet listens to the request events which may be +// dispatched later and re-dispatches the `EIP6963AnnounceProviderEvent` +window.addEventListener("eip6963:requestProvider", () => { +window.dispatchEvent(announceEvent); +}); +The DApp MUST listen for the EIP6963AnnounceProviderEvent dispatched by the Wallet via a window.addEventListener() method and MUST NOT remove the Event Listener for the lifetime of the page so that the DApp can continue to handle Events beyond the initial page load interaction. The DApp MUST dispatch the EIP6963RequestProviderEvent via a window.dispatchEvent() function call after the EIP6963AnnounceProviderEvent handler has been initialized. + +// The DApp listens to announced providers +window.addEventListener( +"eip6963:announceProvider", +(event: EIP6963AnnounceProviderEvent) => {} +); + +// The DApp dispatches a request event which will be heard by +// Wallets' code that had run earlier +window.dispatchEvent(new Event("eip6963:requestProvider")); +The DApp MAY elect to persist various EIP6963ProviderDetail objects contained in the announcement events sent by multiple wallets. Thus, if the user wishes to utilize a different Wallet over time, the user can express this within the DApp’s interface and the DApp can immediately elect to send transactions to that new Wallet. Otherwise, the DApp MAY re-initiate the wallet discovery flow via dispatching a new EIP6963RequestProviderEvent, potentially discovering a different set of wallets. + +The described orchestration of events guarantees that the DApp is able to discover the Wallet, regardless of which code executes first, the Wallet code or the DApp code. + +Rationale +The previous proposal introduced mechanisms that relied on a single, mutable window object that could be overwritten by multiple parties. We opted for an event-based approach to avoid the race conditions, the namespace collisions, and the potential for “pollution” attacks on a shared mutable object; the event-based orchestration creates a bidirectional communication channel between wallet and dapp that can be re-orchestrated over time. + +To follow the Javascript event name conventions, the names are written in present tense and are prefixed with the number of this document (EIP6963). + +Interfaces +Standardizing an interface for provider information (EIP6963ProviderInfo) allows a DApp to determine all information necessary to populate a user-friendly wallet selection modal. This is particularly useful for DApps that rely on libraries such as Web3Modal, RainbowKit, Web3-Onboard, or ConnectKit to programmatically generate such selection modals. + +Regarding the announced provider interface (EIP6963ProviderDetail), it was important to leave the EIP-1193 provider interface untouched for backwards compatibility; this allows conformant DApps to interface with wallets conforming to either, and for Wallets conformant to this spec to still inject EIP-1193 providers for legacy DApps. Note that a legacy dapp or a DApp conformant with this spec connecting to a legacy wallet cannot guarantee the correct wallet will be selected if multiple are present. + +Backwards Compatibility +This EIP doesn’t require supplanting window.ethereum, so it doesn’t directly break existing applications that cannot update to this method of Wallet discovery. However, it is RECOMMENDED DApps implement this EIP to ensure discovery of multiple Wallet Providers and SHOULD disable window.ethereum usage except as a fail-over when discovery fails. Similarly, Wallets SHOULD keep compatibility of window.ethereum to ensure backwards compatibility for DApps that have not implemented this EIP. In order to prevent the previous issues of namespace collisions, it’s also RECOMMENDED that wallets inject their provider object under a wallet specific namespace then proxy the object into the window.ethereum namespace. + +Reference Implementation +Wallet Provider +Here is a reference implementation for an injected script by a Wallet Provider to support this new interface in parallel with the existing pattern. + +function onPageLoad() { +let provider: EIP1193Provider; + +window.ethereum = provider; + +function announceProvider() { +const info: EIP6963ProviderInfo = { +uuid: "350670db-19fa-4704-a166-e52e178b59d2", +name: "Example Wallet", +icon: "data:image/svg+xml,", +rdns: "com.example.wallet" +}; +window.dispatchEvent( +new CustomEvent("eip6963:announceProvider", { +detail: Object.freeze({ info, provider }), +}) +); +} + +window.addEventListener( +"eip6963:requestProvider", +(event: EIP6963RequestProviderEvent) => { +announceProvider(); +} +); + +announceProvider(); +} +DApp implementation +Here is a reference implementation for a DApp to display and track multiple Wallet Providers that are injected by browser extensions. + +const providers: EIP6963ProviderDetail[]; + +function onPageLoad() { + +window.addEventListener( +"eip6963:announceProvider", +(event: EIP6963AnnounceProviderEvent) => { +providers.push(event.detail); +} +); + +window.dispatchEvent(new Event("eip6963:requestProvider")); +} +Security Considerations +EIP-1193 Security considerations +The security considerations of EIP-1193 apply to this EIP. Implementers are expected to consider and follow the guidance of the providers they’re utilizing as well. + +Prototype Pollution of Wallet Provider objects +Browser extensions, and therefore Wallet extensions, are able to modify the contents of the page and the Provider object by design. The provider objects of various Wallets are considered a highly trusted interface to communicate transaction data. In order to prevent the page or various other extensions from modifying the interaction between the DApp and the Wallet in an unexpected way, the best practice is to “freeze” the provider discovery object by utilizing object.freeze() on the EIP1193Provider object before the wallet dispatches it in the eip6963:announceProvider Event. However, there are difficulties that can occur around web compatibility where pages need to monkey patch the object. In scenarios like this there’s a tradeoff that needs to be made between security and web compatibility that Wallet implementers are expected to consider. + +Wallet Imitation and Manipulation +Similarly so, DApps are expected to actively detect for misbehavior of properties or functions being modified in order to tamper with or modify other wallets. One way this can be easily achieved is to look for when the uuid property within two EIP6963ProviderInfo objects match. DApps and DApp discovery libraries are expected to consider other potential methods that the EIP6963ProviderInfo objects are being tampered with and consider additional mitigation techniques to prevent this as well in order to protect the user. + +Prevent SVG Javascript Execution +The use of SVG images introduces a cross-site scripting risk as they can include JavaScript code. This Javascript executes within the context of the page and can therefore modify the page or the contents of the page. So when considering the experience of rendering the icons, DApps need to take into consideration how they’ll approach handling these concerns in order to prevent an image being used as an obfuscation technique to hide malicious modifications to the page or to other wallets. + +Prevent Wallet Fingerprinting +One advantage to the concurrency Event loop utilized by this design is that it operates in a manner where either the DApp or the Wallet can initiate the flow to announce a provider. For this reason, Wallet implementers can now consider whether or not they wish to announce themselves to all pages or attempt alternative means in order to reduce the ability for a user to be fingerprinted by the injection of the window.ethereum object. Some examples, of alternative flows to consider would be to wait to inject the provider object until the DApp has announced the eip6963:requestProvider. At that point, the wallet can initiate a UI consent flow to ask the user if they would like to share their wallet address. This allows for the Wallet to enable the option of a “private connect” feature. However, if this approach is taken, Wallets must also consider how they intend to support backwards compatibility with a DApp that does not support this EIP. + +Copyright +Copyright and related rights waived via CC0. + +Citation +Please cite this document as: + +Pedro Gomes (@pedrouid), Kosala Hemachandra (@kvhnuke), Richard Moore (@ricmoo), Gregory Markou (@GregTheGreek), Kyle Den Hartog (@kdenhartog), Glitch (@glitch-txs), Jake Moxey (@jxom), Pierre Bertet (@bpierre), Darryl Yeo (@darrylyeo), Yaroslav Sergievsky (@everdimension), "EIP-6963: Multi Injected Provider Discovery," Ethereum Improvement Proposals, no. 6963, May 2023. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-6963. + +Ethereum Improvement Proposals +Ethereum Improvement Proposals +ethereum/EIPs +Ethereum Improvement Proposals (EIPs) describe standards for the Ethereum platform, such as core protocol specifications. \ No newline at end of file diff --git a/docs/INJECTION_ARCHITECTURE.md b/docs/INJECTION_ARCHITECTURE.md new file mode 100644 index 0000000..6e6850a --- /dev/null +++ b/docs/INJECTION_ARCHITECTURE.md @@ -0,0 +1,205 @@ +# JavaScript Injection Architecture Documentation + +## Current Implementation Overview + +The KeepKey extension uses a multi-layer injection system to provide wallet functionality to web pages. This document details the current architecture, communication flow, and identified reliability issues. + +## Architecture Layers + +### 1. Manifest Declaration (`chrome-extension/manifest.js`) +```javascript +content_scripts: [ + { + matches: ['http://*/*', 'https://*/*', ''], + js: ['content/index.iife.js'], + run_at: 'document_start', // Critical timing issue #1 + }, + { + matches: ['http://*/*', 'https://*/*', ''], + js: ['content-ui/index.iife.js'], + // No run_at specified - defaults to 'document_idle' (timing issue #2) + } +] +``` + +### 2. Content Script (`pages/content/src/index.ts`) +- Runs in isolated context with access to Chrome APIs +- Creates and injects `injected.js` into the page's DOM +- Acts as message bridge between page and background script + +### 3. Injected Script (`chrome-extension/public/injected.js`) +- Runs in page context with direct access to `window` object +- Creates wallet provider objects (`window.ethereum`, `window.xfi`, `window.keepkey`) +- Implements EIP-6963 provider announcement +- Version: 1.0.17 + +## Communication Flow + +```mermaid +graph TD + A[Web Page/dApp] -->|window.ethereum.request| B[Injected Script] + B -->|postMessage| C[Content Script] + C -->|chrome.runtime.sendMessage| D[Background Script] + D -->|Process Request| E[KeepKey Hardware] + E -->|Response| D + D -->|sendResponse| C + C -->|postMessage| B + B -->|Promise resolve| A +``` + +## Detailed Flow Analysis + +### 1. Injection Process +```javascript +// Content script injects the provider script +const script = document.createElement('script'); +script.src = chrome.runtime.getURL('injected.js'); +script.onload = function() { this.remove(); }; // Self-cleaning +(document.head || document.documentElement).appendChild(script); +``` + +### 2. Message Passing Protocol + +#### Request Flow: +1. **dApp → Injected Script**: Direct method call (e.g., `ethereum.request()`) +2. **Injected Script → Content Script**: `window.postMessage` with structure: + ```javascript + { + source: 'keepkey-injected', + type: 'WALLET_REQUEST', + requestId: number, + requestInfo: { + id, method, params, chain, siteUrl, + scriptSource, version, requestTime, ...metadata + } + } + ``` +3. **Content Script → Background**: `chrome.runtime.sendMessage` +4. **Background Processing**: Routes to appropriate chain handler + +#### Response Flow: +Reverse path with `WALLET_RESPONSE` type messages + +## Identified Reliability Issues + +### 🚨 Critical Issues + +#### 1. Race Condition at Page Load +**Problem**: Content script runs at `document_start` but injected script may not be ready when dApp initializes +**Impact**: dApps fail to detect wallet provider +**Current Mitigation**: Multiple mount attempts (lines 318-323 in injected.js) +```javascript +mountWallet(); +if (document.readyState === 'complete' || document.readyState === 'interactive') { + mountWallet(); // Duplicate call - inefficient +} else { + document.addEventListener('DOMContentLoaded', mountWallet); +} +``` + +#### 2. No Injection Verification +**Problem**: No way to confirm successful injection from content script +**Impact**: Silent failures when injection fails + +#### 3. Global Namespace Pollution +**Problem**: Direct modification of `window` object without proper isolation +**Impact**: Conflicts with other extensions/scripts + +#### 4. Message Queue Management +**Problem**: Basic queue implementation without timeout or overflow handling +```javascript +const messageQueue = []; // Unbounded growth potential +``` + +### ⚠️ Moderate Issues + +#### 5. Synchronous Request Handling +**Problem**: `sendRequestSync` function doesn't actually work synchronously +**Impact**: Incompatible with legacy dApps expecting sync responses + +#### 6. Error Handling Gaps +- No retry mechanism for failed injections +- Callbacks can be orphaned if responses never arrive +- No cleanup for stale callbacks + +#### 7. Version Mismatch Detection +**Problem**: No mechanism to detect/handle version mismatches between components +**Current Version**: 1.0.17 hardcoded in injected.js + +#### 8. Duplicate Prevention Logic +```javascript +if (window.keepkeyInjected) return; // Too simplistic +window.keepkeyInjected = true; +``` +**Issue**: Can be bypassed, doesn't handle reload scenarios properly + +### 📊 Performance Issues + +#### 9. Multiple Injection Points +- Three separate content scripts loaded +- Shadow DOM created for UI components (overhead) +- Multiple event listeners without cleanup + +#### 10. Inefficient Provider Creation +- Creates wallet objects for all chains even if unused +- No lazy loading mechanism + +## Security Concerns + +### 1. Unrestricted Message Passing +- Uses `'*'` for postMessage targetOrigin +- No origin validation in message handlers + +### 2. Information Leakage +Extensive metadata collection in requests: +```javascript +requestInfo: { + siteUrl, referrer, href, userAgent, + platform, language // Privacy concerns +} +``` + +### 3. Configuration Exposure +```javascript +const userOverrideSetting = true; // Hardcoded, should be configurable +``` + +## Recommendations for Redesign + +### Immediate Improvements +1. **Add injection confirmation mechanism** + - Content script should verify injection success + - Implement retry logic with exponential backoff + +2. **Improve race condition handling** + - Use MutationObserver to detect when to inject + - Implement proper ready state management + +3. **Add message validation** + - Validate origin and source + - Add message schema validation + +### Architecture Redesign +1. **Use declarative injection** when possible (web_accessible_resources) +2. **Implement proper state machine** for injection lifecycle +3. **Add telemetry** for injection success/failure rates +4. **Create abstraction layer** for multi-chain support +5. **Implement connection pooling** for message passing + +### Code Quality +1. **TypeScript migration** for injected.js +2. **Unit tests** for injection logic +3. **Integration tests** for dApp compatibility +4. **Performance monitoring** for injection timing + +## Testing Scenarios + +Critical test cases for reliable injection: +1. Fast page loads (cached resources) +2. Slow page loads (large assets) +3. Single-page applications (React, Vue, Angular) +4. Page refreshes and navigation +5. Multiple tabs simultaneously +6. Extension update/reload scenarios +7. Conflicting wallet extensions +8. CSP-restricted pages \ No newline at end of file diff --git a/docs/Notes.md b/docs/Notes.md new file mode 100644 index 0000000..290f1d6 --- /dev/null +++ b/docs/Notes.md @@ -0,0 +1,434 @@ +Yeah—this will keep “losing” to MetaMask by design. Two big truths about MV3 + wallets: + +You can’t reliably “win” window.ethereum. Chrome doesn’t guarantee extension execution order, so a race with MetaMask is unwinnable. + +Many sites (Google Sheets/Docs especially) are weird: multiple sandboxed iframes, strict CSP/Trusted Types, and busy message buses. Your current request/response dance with window.postMessage + one-shot callbacks is fragile there. + +Here’s how to make this solid and “MetaMask-friendly”: + +1) Stop trying to be window.ethereum + +Don’t set isMetaMask: true (ever). That makes dapps mis-detect you and can break them. + +Don’t overwrite window.ethereum. You can’t guarantee ordering, and some dapps now ignore non-MetaMask replacements anyway. + +Instead, embrace EIP-6963 (multi-wallet discovery). Many modern dapps already use it. Your injected script should only: + +Expose window.keepkey (nice to have). + +Announce your provider via eip6963:announceProvider. + +Re-announce when the page asks via eip6963:requestProvider. + +Injected (page-world) provider skeleton +(() => { +const info = { +uuid: '350670db-19fa-4704-a166-e52e178b59d4', // keep stable +name: 'KeepKey Client', +icon: 'https://pioneers.dev/coins/keepkey.png', +rdns: 'com.keepkey', +}; + +// Minimal EIP-1193 provider +class KeepKeyProvider { +constructor(chainHint = 'ethereum') { +this._chainHint = chainHint; +this._listeners = new Map(); +this.isKeepKey = true; +this.isMetaMask = false; // important +} +request({ method, params }) { +return new Promise((resolve, reject) => { +window.postMessage({ +source: 'keepkey-injected', +type: 'WALLET_REQUEST', +requestId: crypto.randomUUID(), +requestInfo: { method, params, chain: this._chainHint } +}, window.origin); +const onMsg = (ev) => { +if (ev.source !== window) return; +const d = ev.data; +if (d?.source === 'keepkey-content' && d.type === 'WALLET_RESPONSE' && d.req?.method === method) { +window.removeEventListener('message', onMsg); +d.error ? reject(d.error) : resolve(d.result); +} +}; +window.addEventListener('message', onMsg); +}); +} +on(event, handler) { +const arr = this._listeners.get(event) || []; +arr.push(handler); +this._listeners.set(event, arr); +} +removeListener(event, handler) { +const arr = this._listeners.get(event) || []; +this._listeners.set(event, arr.filter(h => h !== handler)); +} +_emit(event, payload) { +(this._listeners.get(event) || []).forEach(h => { try { h(payload); } catch {} }); +} +} + +const provider = new KeepKeyProvider('ethereum'); + +// Optional: expose window.keepkey (don’t touch window.ethereum) +if (typeof window.keepkey === 'undefined') { +Object.defineProperty(window, 'keepkey', { value: { ethereum: provider }, configurable: false }); +} + +function announce() { +window.dispatchEvent(new CustomEvent('eip6963:announceProvider', { +detail: { info, provider } +})); +} + +// Announce immediately and whenever requested +announce(); +window.addEventListener('eip6963:requestProvider', announce); +})(); + +2) Make your transport reliable (use Ports, not ad-hoc messages) + +chrome.runtime.sendMessage is fine for one-offs, but for wallet traffic you want a persistent Port: + +Less chance of lost messages (esp. on heavy pages like Sheets). + +Easy correlation and back-pressure. + +content-script.js (isolated world) +// Bridge page <-> background with a Port +const port = chrome.runtime.connect({ name: 'keepkey-port' }); + +window.addEventListener('message', (event) => { +if (event.source !== window) return; +const d = event.data; +if (d?.source === 'keepkey-injected' && d.type === 'WALLET_REQUEST') { +port.postMessage({ type: 'WALLET_REQUEST', request: d.requestInfo }); +} +}); + +port.onMessage.addListener((msg) => { +if (msg.type === 'WALLET_RESPONSE') { +window.postMessage({ +source: 'keepkey-content', +type: 'WALLET_RESPONSE', +req: msg.req, // echo back some correlation (e.g., method) +result: msg.result ?? null, +error: msg.error ?? null +}, window.origin); +} +}); + +// Inject page-world script +const s = document.createElement('script'); +s.src = chrome.runtime.getURL('injected.js'); +(document.head || document.documentElement).appendChild(s); +s.remove(); + +background.js +chrome.runtime.onConnect.addListener((port) => { +if (port.name !== 'keepkey-port') return; +port.onMessage.addListener(async (msg) => { +if (msg.type === 'WALLET_REQUEST') { +try { +const result = await handleWalletRequest(msg.request); // your logic +port.postMessage({ type:'WALLET_RESPONSE', req: msg.request, result }); +} catch (error) { +port.postMessage({ type:'WALLET_RESPONSE', req: msg.request, error: serializeErr(error) }); +} +} +}); +}); + +3) Play nice with Google Sheets/Docs + +These pages can be brittle. A few hard-won tips: + +Don’t inject on Docs/Sheets until needed. If you don’t need wallet access there, exclude them: + +"content_scripts": [{ +"matches": ["http://*/*", "https://*/*"], +"exclude_matches": [ +"https://docs.google.com/*", +"https://drive.google.com/*" +], +"js": ["content/index.iife.js"], +"run_at": "document_start" +}] + + +If you do need to support them, use Ports (above) and set targetOrigin to window.origin (you did this in my snippet) instead of '*'. + +Some frames are sandboxed. Consider all_frames: true and gate your injection to window.top === window to avoid flooding subframes; inject the page-world script only in the top frame. + +4) Manifest adjustments that help + +You don’t need to carpet-bomb matches three times. Merge your three content script blocks; load CSS/JS together. + +Consider all_frames: true and run_at: "document_start" (you already have it) so you’re ready when pages probe for wallets early. + +When you occasionally must run code in the MAIN world (instead of isolated) without DOM injection, you can use chrome.scripting.executeScript with { world: 'MAIN' } from the background on demand (manifest needs "scripting" permission). For a default always-on provider, the “inject a