diff --git a/chrome-extension/package.json b/chrome-extension/package.json index 5a4a664..2640bc6 100644 --- a/chrome-extension/package.json +++ b/chrome-extension/package.json @@ -23,7 +23,11 @@ "ethers": "^6.13.2", "keepkey-vault-sdk": "^2.0.1", "uuid": "^10.0.0", - "webextension-polyfill": "^0.12.0" + "webextension-polyfill": "^0.12.0", + "@wallet-standard/wallet": "^1.1.0", + "@wallet-standard/base": "^1.1.0", + "@wallet-standard/features": "^1.1.0", + "@solana/wallet-standard-features": "^1.3.0" }, "devDependencies": { "@extension/dev-utils": "workspace:*", diff --git a/chrome-extension/public/injected.js b/chrome-extension/public/injected.js index e23d5f8..6ead085 100644 --- a/chrome-extension/public/injected.js +++ b/chrome-extension/public/injected.js @@ -1,103 +1,226 @@ 'use strict'; (() => { + var O = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + function K(i) { + let a = [0]; + for (let c of i) { + let u = O.indexOf(c); + if (u === -1) throw new Error('Invalid base58 character'); + let g = u; + for (let h = 0; h < a.length; h++) ((g += a[h] * 58), (a[h] = g & 255), (g >>= 8)); + for (; g > 0; ) (a.push(g & 255), (g >>= 8)); + } + for (let c of i) { + if (c !== '1') break; + a.push(0); + } + return new Uint8Array(a.reverse()); + } + var I = class { + #o; + #e = []; + #t = new Set(); + version = '1.0.0'; + 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=='; + chains = ['solana:mainnet']; + get accounts() { + return this.#e; + } + features = { + 'standard:connect': { + version: '1.0.0', + connect: async () => { + let a = await this.#n('solana_connect', []); + if (a) { + let c = K(a); + ((this.#e = [ + { + address: a, + publicKey: c, + chains: ['solana:mainnet'], + features: ['solana:signTransaction', 'solana:signMessage'], + }, + ]), + this.#s()); + } + return { accounts: this.#e }; + }, + }, + 'standard:disconnect': { + version: '1.0.0', + disconnect: async () => { + (await this.#n('solana_disconnect', []).catch(() => {}), (this.#e = []), this.#s()); + }, + }, + 'standard:events': { + version: '1.0.0', + on: (a, c) => ( + a === 'change' && this.#t.add(c), + () => { + this.#t.delete(c); + } + ), + }, + 'solana:signMessage': { + version: '1.0.0', + signMessage: async (...a) => { + let c = []; + for (let { message: u } of a) { + let g = await this.#n('solana_signMessage', [Array.from(u)]); + c.push({ signedMessage: u, signature: new Uint8Array(g) }); + } + return c; + }, + }, + 'solana:signTransaction': { + version: '1.0.0', + supportedTransactionVersions: new Set(['legacy', 0]), + signTransaction: async (...a) => { + let c = []; + for (let { transaction: u } of a) { + let g = await this.#n('solana_signTransaction', [Array.from(u)]); + c.push({ signedTransaction: new Uint8Array(g) }); + } + return c; + }, + }, + }; + constructor(a) { + this.#o = a; + } + #s() { + let a = this.#e; + this.#t.forEach(c => { + try { + c({ accounts: a }); + } catch {} + }); + } + #n(a, c) { + return new Promise((u, g) => { + this.#o(a, c, 'solana', (h, f) => { + h ? g(h) : u(f); + }); + }); + } + }; + function W(i) { + var c, u; + let a = ({ register: g }) => { + g(i); + }; + try { + (u = (c = window.navigator.wallets) == null ? void 0 : c.register) == null || u.call(c, i); + } catch {} + (window.dispatchEvent(new CustomEvent('wallet-standard:register-wallet', { detail: a })), + window.addEventListener('wallet-standard:app-ready', g => { + var f; + let h = g; + try { + (f = h.detail) == null || f.call(h, { register: w => (w == null ? void 0 : w(i)) }); + } catch {} + })); + } (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'); + let i = ' | KeepKeyInjected | ', + a = '2.1.0', + f = window, + w = { isInjected: !1, version: a, injectedAt: Date.now(), retryCount: 0 }; + if (f.keepkeyInjectionState) { + let o = f.keepkeyInjectionState; + if ((console.warn(i, `Existing injection detected v${o.version}, current v${a}`), o.version >= a)) { + console.log(i, 'Skipping injection, newer or same version already present'); return; } - console.log(a, 'Upgrading injection to newer version'); + console.log(i, 'Upgrading injection to newer version'); } - ((u.keepkeyInjectionState = g), console.log(a, `Initializing KeepKey Injection v${A}`)); - let p = { + ((f.keepkeyInjectionState = w), console.log(i, `Initializing KeepKey Injection v${a}`)); + let k = { siteUrl: window.location.href, scriptSource: 'KeepKey Extension', - version: A, + version: a, injectedTime: new Date().toISOString(), origin: window.location.origin, protocol: window.location.protocol, }, - y = 0, - f = new Map(), - w = [], - m = !1; + b = 0, + y = new Map(), + p = [], + E = !1; setInterval(() => { let o = Date.now(); - f.forEach((n, t) => { - o - n.timestamp > 3e4 && - (console.warn(a, `Callback timeout for request ${t} (${n.method})`), + y.forEach((n, t) => { + o - n.timestamp > 3e5 && + (console.warn(i, `Callback timeout for request ${t} (${n.method})`), n.callback(new Error('Request timeout')), - f.delete(t)); + y.delete(t)); }); }, 5e3); - let C = o => { - (w.length >= 100 && (console.warn(a, 'Message queue full, removing oldest message'), w.shift()), w.push(o)); + let M = o => { + (p.length >= 100 && (console.warn(i, 'Message queue full, removing oldest message'), p.shift()), p.push(o)); }, - v = () => { - if (m) - for (; w.length > 0; ) { - let o = w.shift(); + R = () => { + if (E) + for (; p.length > 0; ) { + let o = p.shift(); o && window.postMessage(o, window.location.origin); } }, - I = (o = 0) => + T = (o = 0) => new Promise(n => { - let t = ++y, + let t = ++b, e = setTimeout(() => { o < 3 - ? (console.log(a, `Verification attempt ${o + 1} failed, retrying...`), + ? (console.log(i, `Verification attempt ${o + 1} failed, retrying...`), setTimeout( () => { - I(o + 1).then(n); + T(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'), + : (console.error(i, 'Failed to verify injection after max retries'), + (w.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 && + r = s => { + var A, d, m; + s.source === window && + ((A = s.data) == null ? void 0 : A.source) === 'keepkey-content' && + ((d = s.data) == null ? void 0 : d.type) === 'INJECTION_CONFIRMED' && + ((m = s.data) == null ? void 0 : m.requestId) === t && (clearTimeout(e), - window.removeEventListener('message', s), - (m = !0), - (g.isInjected = !0), - console.log(a, 'Injection verified successfully'), - v(), + window.removeEventListener('message', r), + (E = !0), + (w.isInjected = !0), + console.log(i, 'Injection verified successfully'), + R(), n(!0)); }; - (window.addEventListener('message', s), + (window.addEventListener('message', r), window.postMessage( - { source: 'keepkey-injected', type: 'INJECTION_VERIFY', requestId: t, version: A, timestamp: Date.now() }, + { 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 | '; + function v(o, n = [], t, e) { + let r = i + ' | walletRequest | '; if (!o || typeof o != 'string') { - (console.error(s, 'Invalid method:', o), e(new Error('Invalid method'))); + (console.error(r, 'Invalid method:', o), e(new Error('Invalid method'))); return; } - Array.isArray(n) || (console.warn(s, 'Params not an array, wrapping:', n), (n = [n])); + Array.isArray(n) || (console.warn(r, 'Params not an array, wrapping:', n), (n = [n])); try { - let i = ++y, - c = { - id: i, + let s = ++b, + A = { + id: s, method: o, params: n, chain: t, - siteUrl: p.siteUrl, - scriptSource: p.scriptSource, - version: p.version, + siteUrl: k.siteUrl, + scriptSource: k.scriptSource, + version: k.version, requestTime: new Date().toISOString(), referrer: document.referrer, href: window.location.href, @@ -105,39 +228,39 @@ platform: navigator.platform, language: navigator.language, }; - f.set(i, { callback: e, timestamp: Date.now(), method: o }); - let l = { + y.set(s, { callback: e, timestamp: Date.now(), method: o }); + let d = { source: 'keepkey-injected', type: 'WALLET_REQUEST', - requestId: i, - requestInfo: c, + requestId: s, + requestInfo: A, 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)); + E + ? window.postMessage(d, window.location.origin) + : (console.log(r, 'Content script not ready, queueing request'), M(d)); + } catch (s) { + (console.error(r, 'Error in walletRequest:', s), e(s)); } } window.addEventListener('message', o => { - let n = a + ' | message | '; + let n = i + ' | 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), v()); + ((E = !0), R()); return; } if (t.source === 'keepkey-content' && t.type === 'WALLET_RESPONSE' && t.requestId) { - let e = f.get(t.requestId); + let e = y.get(t.requestId); e - ? (t.error ? e.callback(t.error) : e.callback(null, t.result), f.delete(t.requestId)) + ? (t.error ? e.callback(t.error) : e.callback(null, t.result), y.delete(t.requestId)) : console.warn(n, 'No callback found for requestId:', t.requestId); } } }); - class k { + class j { events = new Map(); on(n, t) { (this.events.has(n) || this.events.set(n, new Set()), this.events.get(n).add(t)); @@ -155,64 +278,64 @@ emit(n, ...t) { var e; (e = this.events.get(n)) == null || - e.forEach(s => { + e.forEach(r => { try { - s(...t); - } catch (i) { - console.error(a, `Error in event handler for ${n}:`, i); + r(...t); + } catch (s) { + console.error(i, `Error in event handler for ${n}:`, s); } }); } once(n, t) { - let e = (...s) => { - (t(...s), this.off(n, e)); + let e = (...r) => { + (t(...r), this.off(n, e)); }; this.on(n, e); } } - function r(o) { - console.log(a, 'Creating wallet object for chain:', o); - let n = new k(), + function l(o) { + console.log(i, 'Creating wallet object for chain:', o); + let n = new j(), 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); + isConnected: () => E, + request: ({ method: e, params: r = [] }) => + new Promise((s, A) => { + v(e, r, o, (d, m) => { + d ? A(d) : s(m); }); }), - 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 }); + send: (e, r, s) => { + if ((e.chain || (e.chain = o), typeof s == 'function')) { + v(e.method, e.params || r, o, (A, d) => { + A ? s(A) : s(null, { id: e.id, jsonrpc: '2.0', result: d }); }); return; } else return ( - console.warn(a, 'Synchronous send is deprecated and may not work properly'), + console.warn(i, 'Synchronous send is deprecated and may not work properly'), { id: e.id, jsonrpc: '2.0', result: null } ); }, - sendAsync: (e, s, i) => { + sendAsync: (e, r, s) => { e.chain || (e.chain = o); - let c = i || s; - if (typeof c != 'function') { - console.error(a, 'sendAsync requires a callback function'); + let A = s || r; + if (typeof A != 'function') { + console.error(i, '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 }); + v(e.method, e.params || r, o, (d, m) => { + d ? A(d) : A(null, { id: e.id, jsonrpc: '2.0', result: m }); }); }, - on: (e, s) => (n.on(e, s), t), - off: (e, s) => (n.off(e, s), t), - removeListener: (e, s) => (n.removeListener(e, s), t), + on: (e, r) => (n.on(e, r), t), + off: (e, r) => (n.off(e, r), t), + removeListener: (e, r) => (n.removeListener(e, r), t), removeAllListeners: e => (n.removeAllListeners(e), t), - emit: (e, ...s) => (n.emit(e, ...s), t), - once: (e, s) => (n.once(e, s), t), + emit: (e, ...r) => (n.emit(e, ...r), t), + once: (e, r) => (n.once(e, r), t), enable: () => t.request({ method: 'eth_requestAccounts' }), _metamask: { isUnlocked: () => Promise.resolve(!0) }, }; @@ -236,7 +359,7 @@ t ); } - function h(o) { + function C(o) { let n = { uuid: '350670db-19fa-4704-a166-e52e178b59d4', name: 'KeepKey', @@ -244,85 +367,91 @@ 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)); + (console.log(i, 'Announcing EIP-6963 provider'), window.dispatchEvent(t)); } - async function b() { - let o = a + ' | mountWallet | '; + async function B() { + let o = i + ' | mountWallet | '; console.log(o, 'Starting wallet mount process'); - let n = r('ethereum'), + let n = l('ethereum'), t = { - binance: r('binance'), - bitcoin: r('bitcoin'), - bitcoincash: r('bitcoincash'), - dogecoin: r('dogecoin'), - dash: r('dash'), + binance: l('binance'), + bitcoin: l('bitcoin'), + bitcoincash: l('bitcoincash'), + dogecoin: l('dogecoin'), + dash: l('dash'), ethereum: n, - keplr: r('keplr'), - litecoin: r('litecoin'), - thorchain: r('thorchain'), - mayachain: r('mayachain'), + keplr: l('keplr'), + litecoin: l('litecoin'), + thorchain: l('thorchain'), + mayachain: l('mayachain'), }, e = { - binance: r('binance'), - bitcoin: r('bitcoin'), - bitcoincash: r('bitcoincash'), - dogecoin: r('dogecoin'), - dash: r('dash'), + binance: l('binance'), + bitcoin: l('bitcoin'), + bitcoincash: l('bitcoincash'), + dogecoin: l('dogecoin'), + dash: l('dash'), ethereum: n, - osmosis: r('osmosis'), - cosmos: r('cosmos'), - litecoin: r('litecoin'), - thorchain: r('thorchain'), - mayachain: r('mayachain'), - ripple: r('ripple'), + osmosis: l('osmosis'), + cosmos: l('cosmos'), + litecoin: l('litecoin'), + thorchain: l('thorchain'), + mayachain: l('mayachain'), + ripple: l('ripple'), }, - s = (i, c) => { - u[i] && console.warn(o, `${i} already exists, checking if override is allowed`); + r = (s, A) => { + f[s] && console.warn(o, `${s} 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}`)); + (Object.defineProperty(f, s, { value: A, writable: !1, configurable: !0 }), + console.log(o, `Successfully mounted window.${s}`)); + } catch (d) { + (console.error(o, `Failed to mount window.${s}:`, d), (w.lastError = `Failed to mount ${s}`)); } }; - (s('ethereum', n), - s('xfi', t), - s('keepkey', e), + (r('ethereum', n), + r('xfi', t), + r('keepkey', e), window.addEventListener('eip6963:requestProvider', () => { - (console.log(o, 'Re-announcing provider on request'), h(n)); + (console.log(o, 'Re-announcing provider on request'), C(n)); }), - h(n), + C(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, 'Delayed EIP-6963 announcement for late-loading dApps'), C(n)); + }, 100)); + try { + let s = new I(v); + (W(s), console.log(o, 'Solana wallet registered via Wallet Standard')); + } catch (s) { + console.error(o, 'Failed to register Solana wallet:', s); + } + (window.addEventListener('message', s => { + var A, d, m; + (((A = s.data) == null ? void 0 : A.type) === 'CHAIN_CHANGED' && + (console.log(o, 'Chain changed:', s.data), + n.emit('chainChanged', (d = s.data.provider) == null ? void 0 : d.chainId)), + ((m = s.data) == null ? void 0 : m.type) === 'ACCOUNTS_CHANGED' && + (console.log(o, 'Accounts changed:', s.data), + n._handleAccountsChanged && n._handleAccountsChanged(s.data.accounts || []))); + }), + T().then(s => { + s ? console.log(o, 'Injection verified successfully') : (console.error(o, 'Failed to verify injection, wallet features may not work'), - (g.lastError = 'Injection not verified')); + (w.lastError = 'Injection not verified')); }), console.log(o, 'Wallet mount complete')); } - (b(), + (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') + (console.log(i, 'DOM loaded, re-announcing provider for late-loading dApps'), + f.ethereum && typeof f.dispatchEvent == 'function') ) { - let o = u.ethereum; - h(o); + let o = f.ethereum; + C(o); } }), - console.log(a, 'Injection script loaded and initialized')); + console.log(i, 'Injection script loaded and initialized')); })(); })(); diff --git a/chrome-extension/src/background/chains/ethereumHandler.ts b/chrome-extension/src/background/chains/ethereumHandler.ts index b0ef76d..9cdfcc5 100644 --- a/chrome-extension/src/background/chains/ethereumHandler.ts +++ b/chrome-extension/src/background/chains/ethereumHandler.ts @@ -3,7 +3,7 @@ */ import { JsonRpcProvider } from 'ethers'; -import { createProviderRpcError } from '../utils'; +import { createProviderRpcError, ProviderRpcError } from '../utils'; import { requestStorage, web3ProviderStorage, assetContextStorage, blockchainDataStorage } from '@extension/storage'; import { EIP155_CHAINS } from '../chains'; import { v4 as uuidv4 } from 'uuid'; @@ -36,11 +36,6 @@ type Event = { timestamp: string; }; -interface ProviderRpcError extends Error { - code: number; - data?: unknown; -} - let isPopupOpen = false; // Flag to track popup state const openPopup = function () { diff --git a/chrome-extension/src/background/chains/solanaHandler.ts b/chrome-extension/src/background/chains/solanaHandler.ts new file mode 100644 index 0000000..a6e028b --- /dev/null +++ b/chrome-extension/src/background/chains/solanaHandler.ts @@ -0,0 +1,261 @@ +import { requestStorage } from '@extension/storage'; +import { v4 as uuidv4 } from 'uuid'; +import * as wallet from '../wallet'; +import { createProviderRpcError } from '../utils'; + +const TAG = ' | solanaHandler | '; + +// Solana mainnet RPC for broadcasting (vault has NO broadcast endpoint) +const SOLANA_RPC_URL = 'https://api.mainnet-beta.solana.com'; + +// Cached address from device +let cachedAddress: string | null = null; + +/** Convert a number[] to base64 string (chunked to avoid call-stack limit) */ +function toBase64(arr: number[]): string { + const CHUNK = 8192; + let str = ''; + for (let i = 0; i < arr.length; i += CHUNK) { + str += String.fromCharCode(...arr.slice(i, i + CHUNK)); + } + return btoa(str); +} + +/** Convert a base64 string to number[] */ +function fromBase64(b64: string): number[] { + return Array.from(atob(b64), c => c.charCodeAt(0)); +} + +// BIP44 path for Solana: m/44'/501'/0'/0' +const SOLANA_ADDRESS_N = [ + 0x80000000 + 44, // 0x8000002C + 0x80000000 + 501, // 0x800001F5 + 0x80000000 + 0, // 0x80000000 + 0x80000000 + 0, // 0x80000000 +]; + +async function getSolanaAddress(): Promise { + if (cachedAddress) return cachedAddress; + + const sdk = wallet.getSdk(); + const result = await sdk.address.solanaGetAddress({ address_n: SOLANA_ADDRESS_N }); + + const address = result.address || result; + if (!address || typeof address !== 'string') { + throw createProviderRpcError(-32603, 'Vault returned invalid Solana address'); + } + + cachedAddress = address; + return address; +} + +/** Build the event object for popup approval flow */ +function buildEvent(requestInfo: any, method: string, params: any[]) { + return { + id: requestInfo.id || uuidv4(), + networkId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + chain: 'solana', + href: requestInfo.href, + language: requestInfo.language, + platform: requestInfo.platform, + referrer: requestInfo.referrer, + requestTime: requestInfo.requestTime, + scriptSource: requestInfo.scriptSource, + siteUrl: requestInfo.siteUrl, + userAgent: requestInfo.userAgent, + injectScriptVersion: requestInfo.version, + requestInfo, + type: method, + request: params, + status: 'request', + timestamp: new Date().toISOString(), + }; +} + +/** Save event to storage + open popup + wait for user approval */ +async function requestUserApproval( + event: any, + requestInfo: any, + method: string, + params: any[], + requireApproval: (networkId: string, requestInfo: any, chain: any, method: string, params: any) => Promise, +) { + // @ts-expect-error + await requestStorage.addEvent(event); + chrome.runtime + .sendMessage({ + action: 'TRANSACTION_CONTEXT_UPDATED', + id: event.id, + }) + .catch(() => {}); + + const approval = await requireApproval( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + requestInfo, + 'solana', + method, + params, + ); + if (!approval?.success) { + throw createProviderRpcError(4001, 'User rejected the request'); + } +} + +/** + * Sign a Solana transaction via the KeepKey SDK. + * + * SDK method: sdk.solana.solanaSignTransaction({ raw_tx: base64 }) + * Response: SignedTx { signature?: string, serializedTx?: string } + * + * The vault replaces the dummy 64-byte signature at bytes 1-64 in raw_tx + * with the real Ed25519 signature from the device. + */ +async function signTransactionViaSdk(txBase64: string): Promise<{ signature: string; serializedTx: string }> { + const sdk = wallet.getSdk(); + const result = await sdk.solana.solanaSignTransaction({ + raw_tx: txBase64, + address_n: SOLANA_ADDRESS_N, + }); + + if (!result.signature && !result.serializedTx) { + throw createProviderRpcError(-32603, 'Vault returned empty sign result'); + } + + return { + signature: result.signature || '', + serializedTx: result.serializedTx || result.serialized || '', + }; +} + +/** + * Broadcast a signed Solana transaction via Solana JSON-RPC. + * Vault has NO broadcast endpoint — we send directly to Solana RPC. + */ +async function broadcastTransaction(signedTxBase64: string): Promise { + const response = await fetch(SOLANA_RPC_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'sendTransaction', + params: [signedTxBase64, { encoding: 'base64' }], + }), + }); + + if (!response.ok) { + throw createProviderRpcError(-32603, `Solana RPC broadcast failed: ${response.status}`); + } + + const result = await response.json(); + if (result.error) { + throw createProviderRpcError(-32603, `Solana RPC error: ${result.error.message}`); + } + + return result.result; // transaction signature (base58) +} + +export const handleSolanaRequest = async ( + method: string, + params: any[], + requestInfo: any, + ADDRESS: string, + KEEPKEY_WALLET: any, + requireApproval: (networkId: string, requestInfo: any, chain: any, method: string, params: any) => Promise, +): Promise => { + const tag = TAG + ' | handleSolanaRequest | '; + console.log(tag, 'method:', method); + + switch (method) { + // ---- Connect ---- + case 'solana_connect': { + const address = await getSolanaAddress(); + console.log(tag, 'Connected with address:', address); + return address; + } + + // ---- Disconnect ---- + case 'solana_disconnect': { + cachedAddress = null; + console.log(tag, 'Disconnected'); + return true; + } + + // ---- Get public key ---- + case 'solana_getPublicKey': { + return await getSolanaAddress(); + } + + // ---- Sign message ---- + // NOTE: Vault has NO sign-message endpoint. + // We use signTransaction with the message wrapped as raw_tx. + case 'solana_signMessage': { + const messageArray: number[] = params[0]; + if (!messageArray || !Array.isArray(messageArray)) { + throw createProviderRpcError(4000, 'Invalid params: expected message as number[]'); + } + + const event = buildEvent(requestInfo, method, params); + await requestUserApproval(event, requestInfo, method, params, requireApproval); + + // Pass message bytes as raw_tx — vault signs whatever it receives. + const messageBase64 = toBase64(messageArray); + const signResult = await signTransactionViaSdk(messageBase64); + const signatureArray = fromBase64(signResult.signature); + + chrome.runtime.sendMessage({ action: 'signature_complete' }).catch(() => {}); + return signatureArray; + } + + // ---- Sign transaction ---- + case 'solana_signTransaction': { + const txArray: number[] = params[0]; + if (!txArray || !Array.isArray(txArray)) { + throw createProviderRpcError(4000, 'Invalid params: expected transaction as number[]'); + } + + const txEvent = buildEvent(requestInfo, method, params); + await requestUserApproval(txEvent, requestInfo, method, params, requireApproval); + + const txBase64 = toBase64(txArray); + const txSignResult = await signTransactionViaSdk(txBase64); + + // Return the fully signed transaction (vault replaces dummy sig at bytes 1-64) + const signedTxArray = fromBase64(txSignResult.serializedTx); + + chrome.runtime.sendMessage({ action: 'signature_complete' }).catch(() => {}); + return signedTxArray; + } + + // ---- Sign and send transaction ---- + case 'solana_signAndSendTransaction': { + const sendTxArray: number[] = params[0]; + if (!sendTxArray || !Array.isArray(sendTxArray)) { + throw createProviderRpcError(4000, 'Invalid params: expected transaction as number[]'); + } + + const sendEvent = buildEvent(requestInfo, method, params); + await requestUserApproval(sendEvent, requestInfo, method, params, requireApproval); + + // Sign via SDK + const sendBase64 = toBase64(sendTxArray); + const signResult = await signTransactionViaSdk(sendBase64); + + // Broadcast via Solana RPC (vault has no broadcast endpoint) + const txSignature = await broadcastTransaction(signResult.serializedTx); + + chrome.runtime + .sendMessage({ + action: 'transaction_complete', + txHash: txSignature, + explorerTxLink: 'https://solscan.io/tx/', + }) + .catch(() => {}); + + return txSignature; + } + + default: + throw createProviderRpcError(4200, `Unsupported Solana method: ${method}`); + } +}; diff --git a/chrome-extension/src/background/methods.ts b/chrome-extension/src/background/methods.ts index 94a0606..8cf1c52 100644 --- a/chrome-extension/src/background/methods.ts +++ b/chrome-extension/src/background/methods.ts @@ -13,21 +13,11 @@ import { handleCosmosRequest } from './chains/cosmosHandler'; import { handleOsmosisRequest } from './chains/osmosisHandler'; import { handleMayaRequest } from './chains/mayaHandler'; import { handleRippleRequest } from './chains/rippleHandler'; +import { handleSolanaRequest } from './chains/solanaHandler'; +import { createProviderRpcError, ProviderRpcError } from './utils'; const TAG = ' | METHODS | '; -interface ProviderRpcError extends Error { - code: number; - data?: unknown; -} - -export const createProviderRpcError = (code: number, message: string, data?: unknown): ProviderRpcError => { - const error = new Error(message) as ProviderRpcError; - error.code = code; - if (data) error.data = data; - return error; -}; - let isPopupOpen = false; // Flag to track popup state let popupWindowId: number | null = null; // Track the popup window ID @@ -259,6 +249,10 @@ export const handleWalletRequest = async ( return await handleMayaRequest(method, params, requestInfo, ADDRESS, _KEEPKEY_WALLET, requireApproval); break; } + case 'solana': { + return await handleSolanaRequest(method, params, requestInfo, ADDRESS, _KEEPKEY_WALLET, requireApproval); + break; + } default: { console.log(tag, `Chain ${chain} not supported`); throw createProviderRpcError(4200, `Chain ${chain} not supported`); diff --git a/chrome-extension/src/background/networks.ts b/chrome-extension/src/background/networks.ts deleted file mode 100644 index e69de29..0000000 diff --git a/chrome-extension/src/injected/injected.ts b/chrome-extension/src/injected/injected.ts index c44d73d..863ee41 100644 --- a/chrome-extension/src/injected/injected.ts +++ b/chrome-extension/src/injected/injected.ts @@ -8,13 +8,15 @@ import type { WalletProvider, KeepKeyWindow, } from './types'; +import { KeepKeySolanaWallet } from './solana-wallet-standard'; +import { registerSolanaWallet } from './solana-wallet-register'; (function () { const TAG = ' | KeepKeyInjected | '; - const VERSION = '2.0.0'; + const VERSION = '2.1.0'; const MAX_RETRY_COUNT = 3; const RETRY_DELAY = 100; // ms - const CALLBACK_TIMEOUT = 30000; // 30 seconds + const CALLBACK_TIMEOUT = 300000; // 5 minutes (hardware wallet needs time) const MESSAGE_QUEUE_MAX = 100; const kWindow = window as KeepKeyWindow; @@ -523,6 +525,16 @@ import type { announceProvider(ethereum); }, 100); + // Solana Wallet Standard registration (completely separate from Ethereum) + // Never touches window.solana — relies purely on wallet-standard registry + try { + const solanaWallet = new KeepKeySolanaWallet(walletRequest); + registerSolanaWallet(solanaWallet); + console.log(tag, 'Solana wallet registered via Wallet Standard'); + } catch (e) { + console.error(tag, 'Failed to register Solana wallet:', e); + } + // Handle chain changes and other events window.addEventListener('message', (event: MessageEvent) => { if (event.data?.type === 'CHAIN_CHANGED') { diff --git a/chrome-extension/src/injected/solana-wallet-register.ts b/chrome-extension/src/injected/solana-wallet-register.ts new file mode 100644 index 0000000..360ce3f --- /dev/null +++ b/chrome-extension/src/injected/solana-wallet-register.ts @@ -0,0 +1,35 @@ +/** + * Register a Solana Wallet Standard wallet. + * + * CRITICAL: The event detail MUST be a callback function, NOT an object. + * dApps call event.detail({ register }) where register is provided by the + * wallet-adapter framework. + * + * See: https://github.com/wallet-standard/wallet-standard + */ + +export function registerSolanaWallet(wallet: any): void { + const callback = ({ register }: { register: (w: any) => void }) => { + register(wallet); + }; + + // For adapters that use navigator.wallets + try { + (window.navigator as any).wallets?.register?.(wallet); + } catch { + // ignore + } + + // Dispatch the standard registration event + window.dispatchEvent(new CustomEvent('wallet-standard:register-wallet', { detail: callback })); + + // Also handle late-loading dApps that request wallets after registration + window.addEventListener('wallet-standard:app-ready', (event: Event) => { + const appReadyEvent = event as CustomEvent; + try { + appReadyEvent.detail?.({ register: (registerFn: any) => registerFn?.(wallet) }); + } catch { + // ignore + } + }); +} diff --git a/chrome-extension/src/injected/solana-wallet-standard.ts b/chrome-extension/src/injected/solana-wallet-standard.ts new file mode 100644 index 0000000..2c41f23 --- /dev/null +++ b/chrome-extension/src/injected/solana-wallet-standard.ts @@ -0,0 +1,180 @@ +/** + * KeepKey Solana Wallet Standard implementation. + * + * Completely isolated from Ethereum EIP-1193 / EIP-6963 code. + * Registers via the Wallet Standard registry so dApps discover the wallet + * without touching window.solana. + */ + +import type { ChainType } from './types'; + +// ---------- Base58 (inline, no external dep) ---------- + +const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + +function base58Decode(str: string): Uint8Array { + const bytes: number[] = [0]; + for (const char of str) { + const idx = BASE58_ALPHABET.indexOf(char); + if (idx === -1) throw new Error('Invalid base58 character'); + let carry = idx; + for (let j = 0; j < bytes.length; j++) { + carry += bytes[j] * 58; + bytes[j] = carry & 0xff; + carry >>= 8; + } + while (carry > 0) { + bytes.push(carry & 0xff); + carry >>= 8; + } + } + // Leading zeros + for (const char of str) { + if (char !== '1') break; + bytes.push(0); + } + return new Uint8Array(bytes.reverse()); +} + +// ---------- Types (wallet-standard shapes) ---------- + +interface WalletAccount { + address: string; + publicKey: Uint8Array; + chains: readonly string[]; + features: readonly string[]; +} + +type ChangeListener = (props: { accounts: readonly WalletAccount[] }) => void; + +// ---------- Wallet class ---------- + +export class KeepKeySolanaWallet { + readonly #walletRequest: ( + method: string, + params: any[], + chain: ChainType, + callback: (error: any, result?: any) => void, + ) => void; + + #accounts: WalletAccount[] = []; + readonly #listeners = new Set(); + + // Wallet Standard required fields + readonly version = '1.0.0' as const; + readonly name = 'KeepKey'; + readonly 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==' as `data:image/png;base64,${string}`; + + readonly chains = ['solana:mainnet'] as const; + + get accounts(): readonly WalletAccount[] { + return this.#accounts; + } + + readonly features = { + 'standard:connect': { + version: '1.0.0' as const, + connect: async () => { + const address = await this.#rpc('solana_connect', []); + if (address) { + const publicKey = base58Decode(address); + this.#accounts = [ + { + address, + publicKey, + chains: ['solana:mainnet'] as readonly string[], + features: ['solana:signTransaction', 'solana:signMessage'] as readonly string[], + }, + ]; + this.#emit(); + } + return { accounts: this.#accounts }; + }, + }, + + 'standard:disconnect': { + version: '1.0.0' as const, + disconnect: async () => { + await this.#rpc('solana_disconnect', []).catch(() => {}); + this.#accounts = []; + this.#emit(); + }, + }, + + 'standard:events': { + version: '1.0.0' as const, + on: (event: string, listener: ChangeListener) => { + if (event === 'change') { + this.#listeners.add(listener); + } + return () => { + this.#listeners.delete(listener); + }; + }, + }, + + 'solana:signMessage': { + version: '1.0.0' as const, + signMessage: async (...inputs: { message: Uint8Array; account: WalletAccount }[]) => { + const outputs: { signedMessage: Uint8Array; signature: Uint8Array }[] = []; + for (const { message } of inputs) { + const sigArray: number[] = await this.#rpc('solana_signMessage', [Array.from(message)]); + outputs.push({ + signedMessage: message, + signature: new Uint8Array(sigArray), + }); + } + return outputs; + }, + }, + + 'solana:signTransaction': { + version: '1.0.0' as const, + supportedTransactionVersions: new Set(['legacy', 0] as const), + signTransaction: async (...inputs: { transaction: Uint8Array; account: WalletAccount; chain?: string }[]) => { + const outputs: { signedTransaction: Uint8Array }[] = []; + for (const { transaction } of inputs) { + const signedArray: number[] = await this.#rpc('solana_signTransaction', [Array.from(transaction)]); + outputs.push({ + signedTransaction: new Uint8Array(signedArray), + }); + } + return outputs; + }, + }, + }; + + constructor( + walletRequest: ( + method: string, + params: any[], + chain: ChainType, + callback: (error: any, result?: any) => void, + ) => void, + ) { + this.#walletRequest = walletRequest; + } + + // ---------- Internal helpers ---------- + + #emit() { + const accounts = this.#accounts; + this.#listeners.forEach(fn => { + try { + fn({ accounts }); + } catch { + // swallow listener errors + } + }); + } + + #rpc(method: string, params: any[]): Promise { + return new Promise((resolve, reject) => { + this.#walletRequest(method, params, 'solana' as ChainType, (error, result) => { + if (error) reject(error); + else resolve(result); + }); + }); + } +} diff --git a/chrome-extension/src/injected/types.ts b/chrome-extension/src/injected/types.ts index eb57400..4f58b5e 100644 --- a/chrome-extension/src/injected/types.ts +++ b/chrome-extension/src/injected/types.ts @@ -61,7 +61,8 @@ export type ChainType = | 'osmosis' | 'cosmos' | 'ripple' - | 'keplr'; + | 'keplr' + | 'solana'; export interface WalletProvider { network: string; diff --git a/docs/PIONEER_SDK_INTEGRATION.md b/docs/PIONEER_SDK_INTEGRATION.md deleted file mode 100644 index d912656..0000000 --- a/docs/PIONEER_SDK_INTEGRATION.md +++ /dev/null @@ -1,489 +0,0 @@ -# Pioneer SDK Integration Guide - -Critical lessons learned from integrating Pioneer SDK into KeepKey Client browser extension. - ---- - -## Overview - -Pioneer SDK is the core library for blockchain interactions in the KeepKey ecosystem. This guide documents the **critical pain points** and **gotchas** encountered during integration. - ---- - -## Critical Issue #1: Type Definitions Don't Match Implementation - -### The Problem - -**Pioneer SDK has a type definition mismatch** between declared interface and actual implementation. - -**Type Definition** (`pioneer-sdk/src/index.ts:154`): -```typescript -public signTx: (unsignedTx: any) => Promise; -``` - -**Actual Implementation** (`pioneer-sdk/src/index.ts:956`): -```typescript -this.signTx = async function (caip: string, unsignedTx: any) { - // Implementation expects TWO parameters - let signedTx = await txManager.sign({ caip, unsignedTx }); - return signedTx; -} -``` - -### The Impact - -1. TypeScript won't catch the error at compile time -2. Calling `signTx({ caip, unsignedTx })` (one object) passes the entire object as the `caip` parameter -3. SDK's internal `classifyCaip(caip)` method tries to call `.startsWith()` on an object -4. Results in: `TypeError: e.startsWith is not a function` - -### The Fix - -**Always use TWO separate parameters:** - -```typescript -// ❌ WRONG - One object parameter -const signedTx = await sdk.signTx({ caip, unsignedTx }); - -// ✅ CORRECT - Two separate parameters -const signedTx = await sdk.signTx(caip, unsignedTx); -``` - -### Affected Files - -All chain handlers were fixed to use correct signature: -- `ethereumHandler.ts:655` -- `bitcoinHandler.ts:170` -- `litecoinHandler.ts:145` -- `dogecoinHandler.ts:158` -- `dashHandler.ts:158` -- `bitcoinCashHandler.ts:157` -- `rippleHandler.ts:120` -- `thorchainHandler.ts:120` -- `osmosisHandler.ts:128` -- `cosmosHandler.ts:128` -- `mayaHandler.ts:128` - -### Prevention - -1. **Don't trust type definitions** - always verify against actual implementation -2. **Test at runtime** - type definitions can be wrong -3. **Read the source code** - when in doubt, check the implementation -4. **Submit PR to Pioneer SDK** - fix the type definition upstream - ---- - -## Critical Issue #2: ChainId Format Requirements - -### The Problem - -Pioneer SDK expects `chainId` as a **hex string**, but many sources provide it as a **number**. - -```javascript -// What buildTx returns -{ - chainId: 1, // ❌ Number - nonce: '0x01a4', - gasPrice: '0x06fc23ac00', - ... -} - -// What signTx expects -{ - chainId: '0x1', // ✅ Hex string - nonce: '0x01a4', - gasPrice: '0x06fc23ac00', - ... -} -``` - -### The Impact - -When `chainId` is passed as a number: -- SDK may derive wrong parameters -- Transaction gets signed with incorrect chain-specific data -- Results in **transaction signed by wrong address** - -### The Fix - -**Always convert before signing:** - -```typescript -const txForSigning = { - ...unsignedTx, - chainId: typeof unsignedTx.chainId === 'number' - ? '0x' + unsignedTx.chainId.toString(16) - : unsignedTx.chainId -}; - -const signedTx = await sdk.signTx(caip, txForSigning); -``` - -### Prevention - -1. **Validate chainId format** in all transaction builders -2. **Convert early** - do conversion right after `buildTx` -3. **Add type guards** - validate before passing to SDK -4. **Test with multiple chains** - different chainIds expose the issue - ---- - -## Critical Issue #3: Pubkey Context Must Be Set - -### The Problem - -Pioneer SDK maintains **two contexts**: -1. **Asset Context** - which blockchain/asset is active -2. **Pubkey Context** - which account/address to use - -If pubkey context isn't set, SDK defaults to **account 0**, which may not be the connected dApp account. - -### The Impact - -```typescript -// Connected dApp account (has balance) -ADDRESS = '0x141D9959cAe3853b035000490C03991eB70Fc4aC'; - -// But SDK uses account 0 (no balance) -// Transaction signed by: 0xc6Ff068Ca10F9697F665c97af998F51E8c7C2395 - -// RPC correctly rejects: "insufficient funds" -``` - -### The Fix - -**Always set pubkey context before building transactions:** - -```typescript -// Set asset context -await KEEPKEY_WALLET.setAssetContext({ caip: 'eip155:1/slip44:60' }); - -// CRITICAL: Set pubkey context to match connected dApp account -if (ADDRESS && KEEPKEY_WALLET.pubkeys) { - const matchingPubkey = KEEPKEY_WALLET.pubkeys.find( - pk => pk.address?.toLowerCase() === ADDRESS.toLowerCase() - ); - - if (matchingPubkey) { - console.log('Setting pubkey context to:', matchingPubkey.address); - await KEEPKEY_WALLET.setPubkeyContext(matchingPubkey); - } else { - console.warn('No matching pubkey found for:', ADDRESS); - // Transaction will likely fail with wrong address - } -} - -// Now buildTx and signTx will use correct account -const unsignedTx = await KEEPKEY_WALLET.buildTx({...}); -``` - -### Prevention - -1. **Always set pubkey context** before transaction operations -2. **Verify context** - log the address being used -3. **Handle missing pubkey** - warn if no match found -4. **Restore context** - re-set after context switches - ---- - -## Critical Issue #4: Silent Failures with Generic Errors - -### The Problem - -Pioneer SDK often returns **generic error messages** that don't indicate the actual problem: - -- `"didn't sign right"` - could mean wrong parameters, wrong format, wrong context, etc. -- `TypeError: e.startsWith is not a function` - actually means wrong function signature -- `"Insufficient funds"` - could mean wrong address is signing - -### The Impact - -**Debugging takes hours** because error messages don't point to root cause. - -### The Fix - -**Add comprehensive logging around SDK calls:** - -```typescript -console.log('=== BEFORE SIGNING ==='); -console.log('caip:', caip, '(type:', typeof caip, ')'); -console.log('unsignedTx:', unsignedTx); -console.log('chainId type:', typeof unsignedTx.chainId); -console.log('pubkeyContext:', KEEPKEY_WALLET.pubkeyContext?.address); -console.log('assetContext:', KEEPKEY_WALLET.assetContext?.address); - -// Log all field types -for (const [key, value] of Object.entries(unsignedTx)) { - console.log(` ${key}:`, typeof value, Array.isArray(value) ? '(array)' : '', value); -} - -try { - const signedTx = await KEEPKEY_WALLET.signTx(caip, unsignedTx); - - // Decode to verify sender - const tx = Transaction.from(signedTx); - console.log('=== AFTER SIGNING ==='); - console.log('Signed by:', tx.from); - console.log('Expected:', ADDRESS); - console.log('Match:', tx.from === ADDRESS ? '✅' : '❌'); - - return signedTx; -} catch (error) { - console.error('=== SIGNING FAILED ==='); - console.error('Error:', error); - console.error('Check parameters above for type mismatches'); - throw error; -} -``` - -### Prevention - -1. **Log everything** before and after SDK calls -2. **Validate inputs** manually before calling SDK -3. **Decode outputs** to verify correctness -4. **Create wrapper functions** that add validation and logging - ---- - -## Best Practices for Pioneer SDK Integration - -### 1. Always Set Context First - -```typescript -// Set asset context -await sdk.setAssetContext({ caip: 'eip155:1/slip44:60' }); - -// Set pubkey context -const pubkey = sdk.pubkeys.find(pk => pk.address === ADDRESS); -await sdk.setPubkeyContext(pubkey); -``` - -### 2. Validate Before Calling - -```typescript -function validateBeforeSign(caip: any, unsignedTx: any) { - if (typeof caip !== 'string') { - throw Error(`caip must be string, got ${typeof caip}`); - } - - if (!caip.startsWith('eip155:') && !caip.startsWith('bip122:') && !caip.startsWith('cosmos:')) { - throw Error(`Invalid caip format: ${caip}`); - } - - if (typeof unsignedTx !== 'object') { - throw Error(`unsignedTx must be object, got ${typeof unsignedTx}`); - } - - if (typeof unsignedTx.chainId === 'number') { - throw Error('chainId must be hex string, not number'); - } -} - -validateBeforeSign(caip, txForSigning); -const signedTx = await sdk.signTx(caip, txForSigning); -``` - -### 3. Verify After Signing - -```typescript -const signedTx = await sdk.signTx(caip, txForSigning); - -// Decode and verify -const tx = Transaction.from(signedTx); - -if (tx.from.toLowerCase() !== expectedAddress.toLowerCase()) { - throw Error( - `Transaction signed by wrong address!\n` + - `Expected: ${expectedAddress}\n` + - `Actual: ${tx.from}\n` + - `Check pubkey context and unsignedTx parameters` - ); -} - -return signedTx; -``` - -### 4. Use Wrapper Functions - -```typescript -async function safeSignTx( - sdk: any, - caip: string, - unsignedTx: any, - expectedAddress: string -): Promise { - // Validate inputs - if (typeof caip !== 'string') { - throw Error(`caip must be string, got ${typeof caip}`); - } - - // Convert chainId if needed - const txForSigning = { - ...unsignedTx, - chainId: typeof unsignedTx.chainId === 'number' - ? '0x' + unsignedTx.chainId.toString(16) - : unsignedTx.chainId - }; - - // Log for debugging - console.log('Signing with:', { caip, txForSigning }); - - // Sign - const signedTx = await sdk.signTx(caip, txForSigning); - - // Verify - const tx = Transaction.from(signedTx); - if (tx.from.toLowerCase() !== expectedAddress.toLowerCase()) { - throw Error(`Wrong signer: ${tx.from} !== ${expectedAddress}`); - } - - return signedTx; -} -``` - -### 5. Handle Context Switches - -```typescript -// Save current context -const previousContext = { - asset: sdk.assetContext, - pubkey: sdk.pubkeyContext -}; - -// Switch to new chain -await sdk.setAssetContext({ caip: newCaip }); -await sdk.setPubkeyContext(newPubkey); - -// Do work... - -// Restore previous context -await sdk.setAssetContext(previousContext.asset); -await sdk.setPubkeyContext(previousContext.pubkey); -``` - ---- - -## Common Pitfalls - -### ❌ Pitfall #1: Trusting Type Definitions -```typescript -// Type says one parameter, but implementation needs two! -public signTx: (unsignedTx: any) => Promise; -``` - -**Solution:** Always verify against implementation, not types. - -### ❌ Pitfall #2: Assuming Number/String Interchangeable -```typescript -// SDK expects hex strings, not numbers -chainId: 1 // ❌ Wrong -chainId: '0x1' // ✅ Correct -``` - -**Solution:** Always use hex strings for chain-related values. - -### ❌ Pitfall #3: Forgetting Pubkey Context -```typescript -// Without setPubkeyContext, SDK uses default (account 0) -await sdk.buildTx({...}); // ❌ Might use wrong account -``` - -**Solution:** Always set pubkey context before transactions. - -### ❌ Pitfall #4: Not Verifying Signed Transactions -```typescript -const signedTx = await sdk.signTx(caip, unsignedTx); -// ❌ Didn't check if it was signed by correct address -``` - -**Solution:** Decode and verify sender matches expected address. - -### ❌ Pitfall #5: Insufficient Logging -```typescript -try { - return await sdk.signTx(caip, unsignedTx); -} catch (e) { - console.error('Signing failed'); // ❌ Not enough info -} -``` - -**Solution:** Log inputs, outputs, contexts, and detailed errors. - ---- - -## Testing Strategy - -### Unit Tests -```typescript -describe('signTx', () => { - it('should call with two parameters', async () => { - const spy = jest.spyOn(sdk, 'signTx'); - await safeSignTx(sdk, caip, unsignedTx, ADDRESS); - - expect(spy).toHaveBeenCalledWith(caip, expect.any(Object)); - expect(spy).not.toHaveBeenCalledWith(expect.objectContaining({ caip })); - }); - - it('should convert chainId to hex', async () => { - const unsignedTx = { chainId: 1, ... }; - await safeSignTx(sdk, caip, unsignedTx, ADDRESS); - - const callArgs = spy.mock.calls[0][1]; - expect(callArgs.chainId).toBe('0x1'); - }); - - it('should verify signer matches expected address', async () => { - const signedTx = await safeSignTx(sdk, caip, unsignedTx, ADDRESS); - const tx = Transaction.from(signedTx); - - expect(tx.from.toLowerCase()).toBe(ADDRESS.toLowerCase()); - }); -}); -``` - -### Integration Tests -```typescript -describe('Full transaction flow', () => { - it('should sign and broadcast successfully', async () => { - // Set contexts - await sdk.setAssetContext({ caip }); - const pubkey = sdk.pubkeys.find(pk => pk.address === ADDRESS); - await sdk.setPubkeyContext(pubkey); - - // Build - const unsignedTx = await sdk.buildTx({ caip, to, amount, feeLevel: 5 }); - - // Convert chainId - const txForSigning = { - ...unsignedTx, - chainId: typeof unsignedTx.chainId === 'number' - ? '0x' + unsignedTx.chainId.toString(16) - : unsignedTx.chainId - }; - - // Sign - const signedTx = await sdk.signTx(caip, txForSigning); - - // Verify signer - const tx = Transaction.from(signedTx); - expect(tx.from).toBe(ADDRESS); - - // Broadcast - const txHash = await sdk.broadcastTx(caip, signedTx); - expect(txHash).toMatch(/^0x[a-fA-F0-9]{64}$/); - }); -}); -``` - ---- - -## Related Documentation - -- **Comprehensive API Guide**: `projects/pioneer/e2e/transfers/e2e-transfer-suite/SIGNING_API_DOCUMENTATION.md` -- **Troubleshooting Guide**: `docs/TROUBLESHOOTING.md` -- **Pioneer SDK Source**: `projects/pioneer/modules/pioneer/pioneer-sdk/src/index.ts` - ---- - -**Last Updated:** 2025-01-25 -**Status:** Production-ready after fixes in commit a815786 diff --git a/packages/storage/lib/customStorage.ts b/packages/storage/lib/customStorage.ts index 30a3c92..e6fa659 100644 --- a/packages/storage/lib/customStorage.ts +++ b/packages/storage/lib/customStorage.ts @@ -15,17 +15,6 @@ type ApiKeyStorage = BaseStorage & { getApiKey: () => Promise; }; -type PioneerStorage = BaseStorage & { - savePioneerWss: (wss: string) => Promise; - getPioneerWss: () => Promise; - savePioneerSpec: (spec: string) => Promise; - getPioneerSpec: () => Promise; - saveQueryKey: (queryKey: string) => Promise; - getQueryKey: () => Promise; - saveUsername: (username: string) => Promise; - getUsername: () => Promise; -}; - type EventStorage = BaseStorage & { addEvent: (event: Event) => Promise; getEvents: () => Promise; @@ -67,62 +56,6 @@ type MaskingSettingsStorage = BaseStorage & { const TAG = ' | customStorage | '; -// Create Pioneer Storage -const createPioneerStorage = (): PioneerStorage => { - const queryKeyStorage = createStorage('pioneer-query-key', '', { - storageType: StorageType.Local, - liveUpdate: true, - }); - - const usernameStorage = createStorage('pioneer-username', '', { - storageType: StorageType.Local, - liveUpdate: true, - }); - - const specStorage = createStorage('pioneer-spec', '', { - storageType: StorageType.Local, - liveUpdate: true, - }); - - const wssStorage = createStorage('pioneer-wss', '', { - storageType: StorageType.Local, - liveUpdate: true, - }); - - return { - ...queryKeyStorage, - ...usernameStorage, - ...specStorage, - ...wssStorage, - savePioneerSpec: async (spec: string) => { - await specStorage.set(() => spec); - }, - getPioneerSpec: async () => { - return await specStorage.get(); - }, - savePioneerWss: async (wss: string) => { - await wssStorage.set(() => wss); - }, - getPioneerWss: async () => { - return await wssStorage.get(); - }, - saveQueryKey: async (queryKey: string) => { - await queryKeyStorage.set(() => queryKey); - }, - getQueryKey: async () => { - return await queryKeyStorage.get(); - }, - saveUsername: async (username: string) => { - await usernameStorage.set(() => username); - }, - getUsername: async () => { - return await usernameStorage.get(); - }, - }; -}; - -export const pioneerKeyStorage = createPioneerStorage(); - // Create API Key Storage const createApiKeyStorage = (): ApiKeyStorage => { const storage = createStorage('keepkey-api-key', '', { diff --git a/packages/storage/lib/index.ts b/packages/storage/lib/index.ts index c1d0728..3111b5d 100644 --- a/packages/storage/lib/index.ts +++ b/packages/storage/lib/index.ts @@ -1,7 +1,6 @@ import { createStorage, StorageType, type BaseStorage, SessionAccessLevel } from './base'; import { keepKeyApiKeyStorage, - pioneerKeyStorage, requestStorage, approvalStorage, completedStorage, @@ -21,7 +20,6 @@ export type { DeviceInfo, StoredPubkeys, PubkeyStorageType } from './pubkeyStora export { chainIdStorage, - pioneerKeyStorage, keepKeyApiKeyStorage, web3ProviderStorage, requestStorage, diff --git a/pages/content/src/index.ts b/pages/content/src/index.ts index aca3608..e9044c3 100644 --- a/pages/content/src/index.ts +++ b/pages/content/src/index.ts @@ -91,7 +91,7 @@ window.addEventListener('message', (event: MessageEvent) => { } as WalletMessage, '*', ); - }, 30000); // 30 second timeout + }, 300000); // 5 minute timeout (hardware wallet needs time) // Check if extension context is still valid before sending message if (!chrome.runtime?.id) { diff --git a/pages/popup/src/components/Transaction.tsx b/pages/popup/src/components/Transaction.tsx index ed5f058..40af830 100644 --- a/pages/popup/src/components/Transaction.tsx +++ b/pages/popup/src/components/Transaction.tsx @@ -205,6 +205,9 @@ const Transaction = ({ event, reloadEvents }: { event: any; reloadEvents: () => case 'ripple': setTransactionType('other'); break; + case 'solana': + setTransactionType('other'); + break; default: setTransactionType('unknown'); } diff --git a/pages/side-panel/src/components/Settings.tsx b/pages/side-panel/src/components/Settings.tsx index 912d9b6..b7cbb38 100644 --- a/pages/side-panel/src/components/Settings.tsx +++ b/pages/side-panel/src/components/Settings.tsx @@ -1,7 +1,6 @@ import React, { useEffect, useState } from 'react'; import { VStack, HStack, Avatar, Text, Switch, Link, Button, Image, Box, useToast } from '@chakra-ui/react'; import { - pioneerKeyStorage, maskingSettingsStorage, requestStorage, approvalStorage, @@ -62,9 +61,6 @@ const Settings = () => { console.log(TAG, 'Clearing all custom storages...'); // Clear specific storages - // await pioneerKeyStorage.set(() => ''); - // console.log(TAG, 'Cleared Pioneer Key Storage'); - // // await keepKeyApiKeyStorage.set(() => ''); // console.log(TAG, 'Cleared API Key Storage'); // diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 408d2df..be3bc8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,18 @@ importers: '@extension/storage': specifier: workspace:* version: link:../packages/storage + '@solana/wallet-standard-features': + specifier: ^1.3.0 + version: 1.3.0 + '@wallet-standard/base': + specifier: ^1.1.0 + version: 1.1.0 + '@wallet-standard/features': + specifier: ^1.1.0 + version: 1.1.0 + '@wallet-standard/wallet': + specifier: ^1.1.0 + version: 1.1.0 buffer: specifier: ^6.0.3 version: 6.0.3 @@ -1629,6 +1641,10 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@solana/wallet-standard-features@1.3.0': + resolution: {integrity: sha512-ZhpZtD+4VArf6RPitsVExvgkF+nGghd1rzPjd97GmBximpnt1rsUxMOEyoIEuH3XBxPyNB6Us7ha7RHWQR+abg==} + engines: {node: '>=16'} + '@swc/core-darwin-arm64@1.13.5': resolution: {integrity: sha512-lKNv7SujeXvKn16gvQqUQI5DdyY8v7xcoO3k06/FJbHJS90zEwZdQiMNRiqpYw/orU543tPaWgz7cIYWhbopiQ==} engines: {node: '>=10'} @@ -1898,6 +1914,18 @@ packages: '@vitest/snapshot@3.2.4': resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@wallet-standard/base@1.1.0': + resolution: {integrity: sha512-DJDQhjKmSNVLKWItoKThJS+CsJQjR9AOBOirBVT1F9YpRyC9oYHE+ZnSf8y8bxUphtKqdQMPVQ2mHohYdRvDVQ==} + engines: {node: '>=16'} + + '@wallet-standard/features@1.1.0': + resolution: {integrity: sha512-hiEivWNztx73s+7iLxsuD1sOJ28xtRix58W7Xnz4XzzA/pF0+aicnWgjOdA10doVDEDZdUuZCIIqG96SFNlDUg==} + engines: {node: '>=16'} + + '@wallet-standard/wallet@1.1.0': + resolution: {integrity: sha512-Gt8TnSlDZpAl+RWOOAB/kuvC7RpcdWAlFbHNoi4gsXsfaWa1QCT6LBcfIYTPdOZC9OVZUDwqGuGAcqZejDmHjg==} + engines: {node: '>=16'} + '@wdio/cli@9.19.2': resolution: {integrity: sha512-GR011VfgW57tCycaTYzYD74eJnMebqWtVeHrBNLWgLTIl3bVMIkBGPEO7jjQJSZum3p1rxIJKoy+49Xc4YcY2g==} engines: {node: '>=18.20.0'} @@ -6252,6 +6280,11 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@solana/wallet-standard-features@1.3.0': + dependencies: + '@wallet-standard/base': 1.1.0 + '@wallet-standard/features': 1.1.0 + '@swc/core-darwin-arm64@1.13.5': optional: true @@ -6526,6 +6559,16 @@ snapshots: magic-string: 0.30.18 pathe: 2.0.3 + '@wallet-standard/base@1.1.0': {} + + '@wallet-standard/features@1.1.0': + dependencies: + '@wallet-standard/base': 1.1.0 + + '@wallet-standard/wallet@1.1.0': + dependencies: + '@wallet-standard/base': 1.1.0 + '@wdio/cli@9.19.2(@types/node@22.7.5)(expect-webdriverio@5.4.2(@wdio/globals@9.17.0)(@wdio/logger@9.18.0)(webdriverio@9.19.2))': dependencies: '@vitest/snapshot': 2.1.9