diff --git a/.eslintrc b/.eslintrc index 9607a03..c0923d3 100644 --- a/.eslintrc +++ b/.eslintrc @@ -44,6 +44,7 @@ }, "ignorePatterns": [ "watch.js", - "dist/**" + "dist/**", + "pages/side-panel/src/approval/**" ] } diff --git a/CLAUDE.md b/CLAUDE.md index ae49034..7a9e451 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,13 +144,13 @@ Note: Firefox extensions are temporary and need reloading after browser restart pnpm i -w # Install for specific workspace -pnpm i -F @extension/popup +pnpm i -F @extension/sidepanel # Run command in specific workspace pnpm -F @extension/e2e e2e # Build specific packages -turbo build --filter=@extension/popup +turbo build --filter=@extension/sidepanel ``` ## State Management @@ -168,7 +168,7 @@ 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` +- **Side-panel Entry**: `pages/side-panel/src/index.tsx` (also hosts dApp approval overlay under `src/approval/`) - **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/manifest.js b/chrome-extension/manifest.js index 98cb355..e3bf00a 100755 --- a/chrome-extension/manifest.js +++ b/chrome-extension/manifest.js @@ -4,6 +4,20 @@ import deepmerge from 'deepmerge'; const packageJson = JSON.parse(fs.readFileSync('../package.json', 'utf8')); const isFirefox = process.env.__FIREFOX__ === 'true'; +// Firefox has no side-panel API and the approval popup was removed in the +// popup→side-panel merge. Building for Firefox in this state would produce +// an extension with no UI at all for approvals — silently unusable on the +// Firefox side. Fail loud until task #5 restores a Firefox-specific surface +// (a popup shim or `sidebar_action`). Set KEEPKEY_ALLOW_BROKEN_FIREFOX=1 to +// override if you really want to build it anyway (e.g. portfolio-only dev). +if (isFirefox && process.env.KEEPKEY_ALLOW_BROKEN_FIREFOX !== '1') { + throw new Error( + 'Firefox build is disabled: approval surface is still missing. ' + + 'See task #5 (Firefox fallback) in the popup→side-panel merge PR. ' + + 'Set KEEPKEY_ALLOW_BROKEN_FIREFOX=1 to force-build anyway.', + ); +} + const sidePanelConfig = { side_panel: { default_path: 'side-panel/index.html', diff --git a/chrome-extension/public/injected.js b/chrome-extension/public/injected.js index efe165d..f2f65f1 100644 --- a/chrome-extension/public/injected.js +++ b/chrome-extension/public/injected.js @@ -1,15 +1,569 @@ -"use strict";(()=>{var K="123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";function M(l){let a=[0];for(let i of l){let d=K.indexOf(i);if(d===-1)throw new Error("Invalid base58 character");let n=d;for(let h=0;h>=8;for(;n>0;)a.push(n&255),n>>=8}for(let i of l){if(i!=="1")break;a.push(0)}return new Uint8Array(a.reverse())}var U=class l{#o;#e=[];#t=null;#s=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"];static ACCOUNT_FEATURES=["solana:signTransaction","solana:signAndSendTransaction","solana:signMessage"];get accounts(){return this.#e}features={"standard:connect":{version:"1.0.0",connect:async()=>{if(this.#e.length>0)return{accounts:this.#e};let a=this.#t||await this.#n("solana_connect",[]);return a&&this.#a(a),{accounts:this.#e}}},"standard:disconnect":{version:"1.0.0",disconnect:async()=>{await this.#n("solana_disconnect",[]).catch(()=>{}),this.#e=[];try{localStorage.removeItem("keepkey-solana")}catch{}this.#r()}},"standard:events":{version:"1.0.0",on:(a,i)=>(a==="change"&&this.#s.add(i),()=>{this.#s.delete(i)})},"solana:signMessage":{version:"1.0.0",signMessage:async(...a)=>{let i=[];for(let{message:d}of a){let n=await this.#n("solana_signMessage",[Array.from(d)]);i.push({signedMessage:d,signature:new Uint8Array(n)})}return i}},"solana:signTransaction":{version:"1.0.0",supportedTransactionVersions:new Set(["legacy",0]),signTransaction:async(...a)=>{let i=[];for(let{transaction:d}of a){let n=await this.#n("solana_signTransaction",[Array.from(d)]);i.push({signedTransaction:new Uint8Array(n)})}return i}},"solana:signAndSendTransaction":{version:"1.0.0",supportedTransactionVersions:new Set(["legacy",0]),signAndSendTransaction:async(...a)=>{let i=[];for(let{transaction:d}of a){let n=await this.#n("solana_signAndSendTransaction",[Array.from(d)]);i.push({signature:M(n)})}return i}},"solana:signIn":{version:"1.0.0",signIn:async(...a)=>{var d;let i=[];for(let n of a){if(this.#e.length===0){let w=this.#t||await this.#n("solana_connect",[]);w&&this.#a(w)}let h=this.#e[0];if(!h)throw new Error("Not connected");let y=(n==null?void 0:n.domain)||location.host,p=(n==null?void 0:n.address)||h.address,C=(n==null?void 0:n.uri)||location.href,b=(n==null?void 0:n.version)||"1",E=(n==null?void 0:n.chainId)||"mainnet",v=(n==null?void 0:n.nonce)||Math.random().toString(36).substring(2),I=(n==null?void 0:n.issuedAt)||new Date().toISOString(),R=(n==null?void 0:n.statement)||"",f=`${y} wants you to sign in with your Solana account: -${p}`;if(R&&(f+=` +'use strict'; +(() => { + var N = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + function M(l) { + let a = [0]; + for (let i of l) { + let d = N.indexOf(i); + if (d === -1) throw new Error('Invalid base58 character'); + let n = d; + for (let h = 0; h < a.length; h++) ((n += a[h] * 58), (a[h] = n & 255), (n >>= 8)); + for (; n > 0; ) (a.push(n & 255), (n >>= 8)); + } + for (let i of l) { + if (i !== '1') break; + a.push(0); + } + return new Uint8Array(a.reverse()); + } + var U = class l { + #o; + #e = []; + #t = null; + #s = 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']; + static ACCOUNT_FEATURES = ['solana:signTransaction', 'solana:signAndSendTransaction', 'solana:signMessage']; + get accounts() { + return this.#e; + } + features = { + 'standard:connect': { + version: '1.0.0', + connect: async () => { + if (this.#e.length > 0) return { accounts: this.#e }; + let a = this.#t || (await this.#n('solana_connect', [])); + return (a && this.#a(a), { accounts: this.#e }); + }, + }, + 'standard:disconnect': { + version: '1.0.0', + disconnect: async () => { + (await this.#n('solana_disconnect', []).catch(() => {}), (this.#e = [])); + try { + localStorage.removeItem('keepkey-solana'); + } catch {} + this.#r(); + }, + }, + 'standard:events': { + version: '1.0.0', + on: (a, i) => ( + a === 'change' && this.#s.add(i), + () => { + this.#s.delete(i); + } + ), + }, + 'solana:signMessage': { + version: '1.0.0', + signMessage: async (...a) => { + let i = []; + for (let { message: d } of a) { + let n = await this.#n('solana_signMessage', [Array.from(d)]); + i.push({ signedMessage: d, signature: new Uint8Array(n) }); + } + return i; + }, + }, + 'solana:signTransaction': { + version: '1.0.0', + supportedTransactionVersions: new Set(['legacy', 0]), + signTransaction: async (...a) => { + let i = []; + for (let { transaction: d } of a) { + let n = await this.#n('solana_signTransaction', [Array.from(d)]); + i.push({ signedTransaction: new Uint8Array(n) }); + } + return i; + }, + }, + 'solana:signAndSendTransaction': { + version: '1.0.0', + supportedTransactionVersions: new Set(['legacy', 0]), + signAndSendTransaction: async (...a) => { + let i = []; + for (let { transaction: d } of a) { + let n = await this.#n('solana_signAndSendTransaction', [Array.from(d)]); + i.push({ signature: M(n) }); + } + return i; + }, + }, + 'solana:signIn': { + version: '1.0.0', + signIn: async (...a) => { + var d; + let i = []; + for (let n of a) { + if (this.#e.length === 0) { + let m = this.#t || (await this.#n('solana_connect', [])); + m && this.#a(m); + } + let h = this.#e[0]; + if (!h) throw new Error('Not connected'); + let y = (n == null ? void 0 : n.domain) || location.host, + p = (n == null ? void 0 : n.address) || h.address, + C = (n == null ? void 0 : n.uri) || location.href, + b = (n == null ? void 0 : n.version) || '1', + E = (n == null ? void 0 : n.chainId) || 'mainnet', + v = (n == null ? void 0 : n.nonce) || Math.random().toString(36).substring(2), + I = (n == null ? void 0 : n.issuedAt) || new Date().toISOString(), + R = (n == null ? void 0 : n.statement) || '', + f = `${y} wants you to sign in with your Solana account: +${p}`; + if ( + (R && + (f += ` -${R}`),f+=` +${R}`), + (f += ` -URI: ${C}`,f+=` -Version: ${b}`,f+=` -Chain ID: ${E}`,f+=` -Nonce: ${v}`,f+=` -Issued At: ${I}`,n!=null&&n.expirationTime&&(f+=` -Expiration Time: ${n.expirationTime}`),n!=null&&n.notBefore&&(f+=` -Not Before: ${n.notBefore}`),n!=null&&n.requestId&&(f+=` -Request ID: ${n.requestId}`),(d=n==null?void 0:n.resources)!=null&&d.length){f+=` -Resources:`;for(let w of n.resources)f+=` -- ${w}`}let k=new TextEncoder().encode(f),T=await this.#n("solana_signMessage",[Array.from(k)]);i.push({account:h,signedMessage:k,signature:new Uint8Array(T)})}return i}}};constructor(a){this.#o=a;try{let i=localStorage.getItem("keepkey-solana");if(i){let{address:d}=JSON.parse(i);d&&typeof d=="string"&&(this.#t=d)}}catch{}this.#c()}#i(a){return{address:a,publicKey:M(a),chains:["solana:mainnet"],features:[...l.ACCOUNT_FEATURES]}}#a(a){this.#e=[this.#i(a)];try{localStorage.setItem("keepkey-solana",JSON.stringify({address:a}))}catch{}this.#r()}async#c(){try{let a=await this.#n("solana_connect",[]);if(a&&typeof a=="string"){this.#t=a;try{localStorage.setItem("keepkey-solana",JSON.stringify({address:a}))}catch{}}}catch{}}#r(){let a=this.#e,i=this.features;this.#s.forEach(d=>{try{d({accounts:a,features:i})}catch{}})}#n(a,i){return new Promise((d,n)=>{this.#o(a,i,"solana",(h,y)=>{h?n(h):d(y)})})}};function O(l){let a=({register:i})=>{i(l)};try{let i=window.navigator;i.wallets||(i.wallets=[]),Array.isArray(i.wallets)?i.wallets.push(a):typeof i.wallets.register=="function"&&i.wallets.register(l)}catch{}try{window.dispatchEvent(new CustomEvent("wallet-standard:register-wallet",{detail:a}))}catch{}window.addEventListener("wallet-standard:app-ready",i=>{let d=i;try{typeof d.detail=="function"&&d.detail(a)}catch{}})}(function(){let l=" | KeepKeyInjected | ",a="2.1.0",y=window,p={isInjected:!1,version:a,injectedAt:Date.now(),retryCount:0};if(y.keepkeyInjectionState){let o=y.keepkeyInjectionState;if(console.warn(l,`Existing injection detected v${o.version}, current v${a}`),o.version>=a){console.log(l,"Skipping injection, newer or same version already present");return}console.log(l,"Upgrading injection to newer version")}y.keepkeyInjectionState=p,console.log(l,`Initializing KeepKey Injection v${a}`);let C={siteUrl:window.location.href,scriptSource:"KeepKey Extension",version:a,injectedTime:new Date().toISOString(),origin:window.location.origin,protocol:window.location.protocol},b=0,E=new Map,v=[],I=!1;setInterval(()=>{let o=Date.now();E.forEach((t,s)=>{o-t.timestamp>3e5&&(console.warn(l,`Callback timeout for request ${s} (${t.method})`),t.callback(new Error("Request timeout")),E.delete(s))})},5e3);let f=o=>{v.length>=100&&(console.warn(l,"Message queue full, removing oldest message"),v.shift()),v.push(o)},k=()=>{if(I)for(;v.length>0;){let o=v.shift();o&&window.postMessage(o,window.location.origin)}},T=(o=0)=>new Promise(t=>{let s=++b,e=setTimeout(()=>{o<3?(console.log(l,`Verification attempt ${o+1} failed, retrying...`),setTimeout(()=>{T(o+1).then(t)},100*Math.pow(2,o))):(console.error(l,"Failed to verify injection after max retries"),p.lastError="Failed to verify injection",t(!1))},1e3),c=r=>{var g,u,m;r.source===window&&((g=r.data)==null?void 0:g.source)==="keepkey-content"&&((u=r.data)==null?void 0:u.type)==="INJECTION_CONFIRMED"&&((m=r.data)==null?void 0:m.requestId)===s&&(clearTimeout(e),window.removeEventListener("message",c),I=!0,p.isInjected=!0,console.log(l,"Injection verified successfully"),k(),t(!0))};window.addEventListener("message",c),window.postMessage({source:"keepkey-injected",type:"INJECTION_VERIFY",requestId:s,version:a,timestamp:Date.now()},window.location.origin)});function w(o,t=[],s,e){let c=l+" | walletRequest | ";if(!o||typeof o!="string"){console.error(c,"Invalid method:",o),e(new Error("Invalid method"));return}Array.isArray(t)||(console.warn(c,"Params not an array, wrapping:",t),t=[t]);try{let r=++b,g={id:r,method:o,params:t,chain:s,siteUrl:C.siteUrl,scriptSource:C.scriptSource,version:C.version,requestTime:new Date().toISOString(),referrer:document.referrer,href:window.location.href,userAgent:navigator.userAgent,platform:navigator.platform,language:navigator.language};E.set(r,{callback:e,timestamp:Date.now(),method:o});let u={source:"keepkey-injected",type:"WALLET_REQUEST",requestId:r,requestInfo:g,timestamp:Date.now()};I?window.postMessage(u,window.location.origin):(console.log(c,"Content script not ready, queueing request"),f(u))}catch(r){console.error(c,"Error in walletRequest:",r),e(r)}}window.addEventListener("message",o=>{let t=l+" | message | ";if(o.source!==window)return;let s=o.data;if(!(!s||typeof s!="object")){if(s.source==="keepkey-content"&&s.type==="INJECTION_CONFIRMED"){I=!0,k();return}if(s.source==="keepkey-content"&&s.type==="WALLET_RESPONSE"&&s.requestId){let e=E.get(s.requestId);e?(s.error?e.callback(s.error):e.callback(null,s.result),E.delete(s.requestId)):console.warn(t,"No callback found for requestId:",s.requestId)}}});class B{events=new Map;on(t,s){this.events.has(t)||this.events.set(t,new Set),this.events.get(t).add(s)}off(t,s){var e;(e=this.events.get(t))==null||e.delete(s)}removeListener(t,s){this.off(t,s)}removeAllListeners(t){t?this.events.delete(t):this.events.clear()}emit(t,...s){var e;(e=this.events.get(t))==null||e.forEach(c=>{try{c(...s)}catch(r){console.error(l,`Error in event handler for ${t}:`,r)}})}once(t,s){let e=(...c)=>{s(...c),this.off(t,e)};this.on(t,e)}}function A(o){console.log(l,"Creating wallet object for chain:",o);let t=new B,s={network:"mainnet",isKeepKey:!0,isMetaMask:!0,isConnected:()=>I,request:({method:e,params:c=[]})=>new Promise((r,g)=>{w(e,c,o,(u,m)=>{u?g(u):r(m)})}),send:(e,c,r)=>{if(e.chain||(e.chain=o),typeof r=="function"){w(e.method,e.params||c,o,(g,u)=>{g?r(g):r(null,{id:e.id,jsonrpc:"2.0",result:u})});return}else return console.warn(l,"Synchronous send is deprecated and may not work properly"),{id:e.id,jsonrpc:"2.0",result:null}},sendAsync:(e,c,r)=>{e.chain||(e.chain=o);let g=r||c;if(typeof g!="function"){console.error(l,"sendAsync requires a callback function");return}w(e.method,e.params||c,o,(u,m)=>{u?g(u):g(null,{id:e.id,jsonrpc:"2.0",result:m})})},on:(e,c)=>(t.on(e,c),s),off:(e,c)=>(t.off(e,c),s),removeListener:(e,c)=>(t.removeListener(e,c),s),removeAllListeners:e=>(t.removeAllListeners(e),s),emit:(e,...c)=>(t.emit(e,...c),s),once:(e,c)=>(t.once(e,c),s),enable:()=>s.request({method:"eth_requestAccounts"}),_metamask:{isUnlocked:()=>Promise.resolve(!0)}};return o==="ethereum"&&(s.chainId="0x1",s.networkVersion="1",s.selectedAddress=null,s._handleAccountsChanged=e=>{s.selectedAddress=e[0]||null,t.emit("accountsChanged",e)},s._handleChainChanged=e=>{s.chainId=e,t.emit("chainChanged",e)},s._handleConnect=e=>{t.emit("connect",e)},s._handleDisconnect=e=>{s.selectedAddress=null,t.emit("disconnect",e)}),s}function S(o){let t={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"},s=new CustomEvent("eip6963:announceProvider",{detail:Object.freeze({info:t,provider:o})});console.log(l,"Announcing EIP-6963 provider"),window.dispatchEvent(s)}async function j(){let o=l+" | mountWallet | ";console.log(o,"Starting wallet mount process");let t=A("ethereum"),s={binance:A("binance"),bitcoin:A("bitcoin"),bitcoincash:A("bitcoincash"),dogecoin:A("dogecoin"),dash:A("dash"),ethereum:t,keplr:A("keplr"),litecoin:A("litecoin"),thorchain:A("thorchain"),mayachain:A("mayachain")},e={binance:A("binance"),bitcoin:A("bitcoin"),bitcoincash:A("bitcoincash"),dogecoin:A("dogecoin"),dash:A("dash"),ethereum:t,osmosis:A("osmosis"),cosmos:A("cosmos"),litecoin:A("litecoin"),thorchain:A("thorchain"),mayachain:A("mayachain"),ripple:A("ripple")},c=(r,g)=>{y[r]&&console.warn(o,`${r} already exists, checking if override is allowed`);try{Object.defineProperty(y,r,{value:g,writable:!1,configurable:!0}),console.log(o,`Successfully mounted window.${r}`)}catch(u){console.error(o,`Failed to mount window.${r}:`,u),p.lastError=`Failed to mount ${r}`}};c("ethereum",t),c("xfi",s),c("keepkey",e),window.addEventListener("eip6963:requestProvider",()=>{console.log(o,"Re-announcing provider on request"),S(t)}),S(t),setTimeout(()=>{console.log(o,"Delayed EIP-6963 announcement for late-loading dApps"),S(t)},100);try{let r=new U(w);O(r),console.log(o,"Solana wallet registered via Wallet Standard")}catch(r){console.error(o,"Failed to register Solana wallet:",r)}window.addEventListener("message",r=>{var g,u,m;((g=r.data)==null?void 0:g.type)==="CHAIN_CHANGED"&&(console.log(o,"Chain changed:",r.data),t.emit("chainChanged",(u=r.data.provider)==null?void 0:u.chainId)),((m=r.data)==null?void 0:m.type)==="ACCOUNTS_CHANGED"&&(console.log(o,"Accounts changed:",r.data),t._handleAccountsChanged&&t._handleAccountsChanged(r.data.accounts||[]))}),T().then(r=>{r?console.log(o,"Injection verified successfully"):(console.error(o,"Failed to verify injection, wallet features may not work"),p.lastError="Injection not verified")}),console.log(o,"Wallet mount complete")}j(),document.readyState==="loading"&&document.addEventListener("DOMContentLoaded",()=>{if(console.log(l,"DOM loaded, re-announcing provider for late-loading dApps"),y.ethereum&&typeof y.dispatchEvent=="function"){let o=y.ethereum;S(o)}}),console.log(l,"Injection script loaded and initialized")})();})(); +URI: ${C}`), + (f += ` +Version: ${b}`), + (f += ` +Chain ID: ${E}`), + (f += ` +Nonce: ${v}`), + (f += ` +Issued At: ${I}`), + n != null && + n.expirationTime && + (f += ` +Expiration Time: ${n.expirationTime}`), + n != null && + n.notBefore && + (f += ` +Not Before: ${n.notBefore}`), + n != null && + n.requestId && + (f += ` +Request ID: ${n.requestId}`), + (d = n == null ? void 0 : n.resources) != null && d.length) + ) { + f += ` +Resources:`; + for (let m of n.resources) + f += ` +- ${m}`; + } + let k = new TextEncoder().encode(f), + T = await this.#n('solana_signMessage', [Array.from(k)]); + i.push({ account: h, signedMessage: k, signature: new Uint8Array(T) }); + } + return i; + }, + }, + }; + constructor(a) { + this.#o = a; + try { + let i = localStorage.getItem('keepkey-solana'); + if (i) { + let { address: d } = JSON.parse(i); + d && typeof d == 'string' && (this.#t = d); + } + } catch {} + this.#c(); + } + #i(a) { + return { address: a, publicKey: M(a), chains: ['solana:mainnet'], features: [...l.ACCOUNT_FEATURES] }; + } + #a(a) { + this.#e = [this.#i(a)]; + try { + localStorage.setItem('keepkey-solana', JSON.stringify({ address: a })); + } catch {} + this.#r(); + } + async #c() { + try { + let a = await this.#n('solana_connect', []); + if (a && typeof a == 'string') { + this.#t = a; + try { + localStorage.setItem('keepkey-solana', JSON.stringify({ address: a })); + } catch {} + } + } catch {} + } + #r() { + let a = this.#e, + i = this.features; + this.#s.forEach(d => { + try { + d({ accounts: a, features: i }); + } catch {} + }); + } + #n(a, i) { + return new Promise((d, n) => { + this.#o(a, i, 'solana', (h, y) => { + h ? n(h) : d(y); + }); + }); + } + }; + function O(l) { + let a = ({ register: i }) => { + i(l); + }; + try { + let i = window.navigator; + (i.wallets || (i.wallets = []), + Array.isArray(i.wallets) + ? i.wallets.push(a) + : typeof i.wallets.register == 'function' && i.wallets.register(l)); + } catch {} + try { + window.dispatchEvent(new CustomEvent('wallet-standard:register-wallet', { detail: a })); + } catch {} + window.addEventListener('wallet-standard:app-ready', i => { + let d = i; + try { + typeof d.detail == 'function' && d.detail(a); + } catch {} + }); + } + (function () { + let l = ' | KeepKeyInjected | ', + a = '2.1.0', + y = window, + p = { isInjected: !1, version: a, injectedAt: Date.now(), retryCount: 0 }; + if (y.keepkeyInjectionState) { + let o = y.keepkeyInjectionState; + if ((console.warn(l, `Existing injection detected v${o.version}, current v${a}`), o.version >= a)) { + console.log(l, 'Skipping injection, newer or same version already present'); + return; + } + console.log(l, 'Upgrading injection to newer version'); + } + ((y.keepkeyInjectionState = p), console.log(l, `Initializing KeepKey Injection v${a}`)); + let C = { + siteUrl: window.location.href, + scriptSource: 'KeepKey Extension', + version: a, + injectedTime: new Date().toISOString(), + origin: window.location.origin, + protocol: window.location.protocol, + }, + b = 0, + E = new Map(), + v = [], + I = !1; + setInterval(() => { + let o = Date.now(); + E.forEach((t, s) => { + o - t.timestamp > 3e5 && + (console.warn(l, `Callback timeout for request ${s} (${t.method})`), + t.callback(new Error('Request timeout')), + E.delete(s)); + }); + }, 5e3); + let f = o => { + (v.length >= 100 && (console.warn(l, 'Message queue full, removing oldest message'), v.shift()), v.push(o)); + }, + k = () => { + if (I) + for (; v.length > 0; ) { + let o = v.shift(); + o && window.postMessage(o, window.location.origin); + } + }, + T = (o = 0) => + new Promise(t => { + let s = ++b, + e = setTimeout(() => { + o < 3 + ? (console.log(l, `Verification attempt ${o + 1} failed, retrying...`), + setTimeout( + () => { + T(o + 1).then(t); + }, + 100 * Math.pow(2, o), + )) + : (console.error(l, 'Failed to verify injection after max retries'), + (p.lastError = 'Failed to verify injection'), + t(!1)); + }, 1e3), + c = r => { + var g, u, w; + r.source === window && + ((g = r.data) == null ? void 0 : g.source) === 'keepkey-content' && + ((u = r.data) == null ? void 0 : u.type) === 'INJECTION_CONFIRMED' && + ((w = r.data) == null ? void 0 : w.requestId) === s && + (clearTimeout(e), + window.removeEventListener('message', c), + (I = !0), + (p.isInjected = !0), + console.log(l, 'Injection verified successfully'), + k(), + t(!0)); + }; + (window.addEventListener('message', c), + window.postMessage( + { source: 'keepkey-injected', type: 'INJECTION_VERIFY', requestId: s, version: a, timestamp: Date.now() }, + window.location.origin, + )); + }); + function m(o, t = [], s, e) { + let c = l + ' | walletRequest | '; + if (!o || typeof o != 'string') { + (console.error(c, 'Invalid method:', o), e(new Error('Invalid method'))); + return; + } + Array.isArray(t) || (console.warn(c, 'Params not an array, wrapping:', t), (t = [t])); + try { + let r = ++b, + g = { + id: r, + method: o, + params: t, + chain: s, + siteUrl: C.siteUrl, + scriptSource: C.scriptSource, + version: C.version, + requestTime: new Date().toISOString(), + referrer: document.referrer, + href: window.location.href, + userAgent: navigator.userAgent, + platform: navigator.platform, + language: navigator.language, + }; + E.set(r, { callback: e, timestamp: Date.now(), method: o }); + let u = { + source: 'keepkey-injected', + type: 'WALLET_REQUEST', + requestId: r, + requestInfo: g, + timestamp: Date.now(), + }; + I + ? window.postMessage(u, window.location.origin) + : (console.log(c, 'Content script not ready, queueing request'), f(u)); + } catch (r) { + (console.error(c, 'Error in walletRequest:', r), e(r)); + } + } + window.addEventListener('message', o => { + let t = l + ' | message | '; + if (o.source !== window) return; + let s = o.data; + if (!(!s || typeof s != 'object')) { + if (s.source === 'keepkey-content' && s.type === 'INJECTION_CONFIRMED') { + ((I = !0), k()); + return; + } + if (s.source === 'keepkey-content' && s.type === 'WALLET_RESPONSE' && s.requestId) { + let e = E.get(s.requestId); + e + ? (s.error ? e.callback(s.error) : e.callback(null, s.result), E.delete(s.requestId)) + : console.warn(t, 'No callback found for requestId:', s.requestId); + } + } + }); + class B { + events = new Map(); + on(t, s) { + (this.events.has(t) || this.events.set(t, new Set()), this.events.get(t).add(s)); + } + off(t, s) { + var e; + (e = this.events.get(t)) == null || e.delete(s); + } + removeListener(t, s) { + this.off(t, s); + } + removeAllListeners(t) { + t ? this.events.delete(t) : this.events.clear(); + } + emit(t, ...s) { + var e; + (e = this.events.get(t)) == null || + e.forEach(c => { + try { + c(...s); + } catch (r) { + console.error(l, `Error in event handler for ${t}:`, r); + } + }); + } + once(t, s) { + let e = (...c) => { + (s(...c), this.off(t, e)); + }; + this.on(t, e); + } + } + function A(o) { + console.log(l, 'Creating wallet object for chain:', o); + let t = new B(), + s = { + network: 'mainnet', + isKeepKey: !0, + isMetaMask: !0, + isConnected: () => I, + request: ({ method: e, params: c = [] }) => + new Promise((r, g) => { + m(e, c, o, (u, w) => { + u ? g(u) : r(w); + }); + }), + send: (e, c, r) => { + if ((e.chain || (e.chain = o), typeof r == 'function')) { + m(e.method, e.params || c, o, (g, u) => { + g ? r(g) : r(null, { id: e.id, jsonrpc: '2.0', result: u }); + }); + return; + } else + return ( + console.warn(l, 'Synchronous send is deprecated and may not work properly'), + { id: e.id, jsonrpc: '2.0', result: null } + ); + }, + sendAsync: (e, c, r) => { + e.chain || (e.chain = o); + let g = r || c; + if (typeof g != 'function') { + console.error(l, 'sendAsync requires a callback function'); + return; + } + m(e.method, e.params || c, o, (u, w) => { + u ? g(u) : g(null, { id: e.id, jsonrpc: '2.0', result: w }); + }); + }, + on: (e, c) => (t.on(e, c), s), + off: (e, c) => (t.off(e, c), s), + removeListener: (e, c) => (t.removeListener(e, c), s), + removeAllListeners: e => (t.removeAllListeners(e), s), + emit: (e, ...c) => (t.emit(e, ...c), s), + once: (e, c) => (t.once(e, c), s), + enable: () => s.request({ method: 'eth_requestAccounts' }), + _metamask: { isUnlocked: () => Promise.resolve(!0) }, + }; + return ( + o === 'ethereum' && + ((s.chainId = '0x1'), + (s.networkVersion = '1'), + (s.selectedAddress = null), + (s._handleAccountsChanged = e => { + ((s.selectedAddress = e[0] || null), t.emit('accountsChanged', e)); + }), + (s._handleChainChanged = e => { + ((s.chainId = e), t.emit('chainChanged', e)); + }), + (s._handleConnect = e => { + t.emit('connect', e); + }), + (s._handleDisconnect = e => { + ((s.selectedAddress = null), t.emit('disconnect', e)); + })), + s + ); + } + function S(o) { + let t = { + 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', + }, + s = new CustomEvent('eip6963:announceProvider', { detail: Object.freeze({ info: t, provider: o }) }); + (console.log(l, 'Announcing EIP-6963 provider'), window.dispatchEvent(s)); + } + async function j() { + let o = l + ' | mountWallet | '; + console.log(o, 'Starting wallet mount process'); + let t = A('ethereum'), + s = { + binance: A('binance'), + bitcoin: A('bitcoin'), + bitcoincash: A('bitcoincash'), + dogecoin: A('dogecoin'), + dash: A('dash'), + ethereum: t, + keplr: A('keplr'), + litecoin: A('litecoin'), + thorchain: A('thorchain'), + mayachain: A('mayachain'), + }, + e = { + binance: A('binance'), + bitcoin: A('bitcoin'), + bitcoincash: A('bitcoincash'), + dogecoin: A('dogecoin'), + dash: A('dash'), + ethereum: t, + osmosis: A('osmosis'), + cosmos: A('cosmos'), + litecoin: A('litecoin'), + thorchain: A('thorchain'), + mayachain: A('mayachain'), + ripple: A('ripple'), + }, + c = (r, g, { force: u = !1 } = {}) => { + if (y[r] && !u) { + console.warn( + o, + `window.${r} already present \u2014 yielding to it (EIP-6963 still announced for discovery)`, + ); + return; + } + try { + (Object.defineProperty(y, r, { value: g, writable: !1, configurable: !0 }), + console.log(o, `Successfully mounted window.${r}`)); + } catch (K) { + (console.error(o, `Failed to mount window.${r}:`, K), (p.lastError = `Failed to mount ${r}`)); + } + }; + (c('ethereum', t), + c('xfi', s), + c('keepkey', e, { force: !0 }), + window.addEventListener('eip6963:requestProvider', () => { + (console.log(o, 'Re-announcing provider on request'), S(t)); + }), + S(t), + setTimeout(() => { + (console.log(o, 'Delayed EIP-6963 announcement for late-loading dApps'), S(t)); + }, 100)); + try { + let r = new U(m); + (O(r), console.log(o, 'Solana wallet registered via Wallet Standard')); + } catch (r) { + console.error(o, 'Failed to register Solana wallet:', r); + } + (window.addEventListener('message', r => { + var g, u, w; + (((g = r.data) == null ? void 0 : g.type) === 'CHAIN_CHANGED' && + (console.log(o, 'Chain changed:', r.data), + t.emit('chainChanged', (u = r.data.provider) == null ? void 0 : u.chainId)), + ((w = r.data) == null ? void 0 : w.type) === 'ACCOUNTS_CHANGED' && + (console.log(o, 'Accounts changed:', r.data), + t._handleAccountsChanged && t._handleAccountsChanged(r.data.accounts || []))); + }), + T().then(r => { + r + ? console.log(o, 'Injection verified successfully') + : (console.error(o, 'Failed to verify injection, wallet features may not work'), + (p.lastError = 'Injection not verified')); + }), + console.log(o, 'Wallet mount complete')); + } + (j(), + document.readyState === 'loading' && + document.addEventListener('DOMContentLoaded', () => { + if ( + (console.log(l, 'DOM loaded, re-announcing provider for late-loading dApps'), + y.ethereum && typeof y.dispatchEvent == 'function') + ) { + let o = y.ethereum; + S(o); + } + }), + console.log(l, 'Injection script loaded and initialized')); + })(); +})(); diff --git a/chrome-extension/src/background/chains/ethereumHandler.ts b/chrome-extension/src/background/chains/ethereumHandler.ts index 4dcdb06..2f84985 100644 --- a/chrome-extension/src/background/chains/ethereumHandler.ts +++ b/chrome-extension/src/background/chains/ethereumHandler.ts @@ -4,10 +4,15 @@ import { JsonRpcProvider, parseEther } from 'ethers'; import { createProviderRpcError, ProviderRpcError } from '../utils'; -import { requestStorage, web3ProviderStorage, assetContextStorage, blockchainDataStorage } from '@extension/storage'; +import { + requestStorage, + web3ProviderStorage, + assetContextStorage, + blockchainDataStorage, + blockchainStorage, +} from '@extension/storage'; import { EIP155_CHAINS } from '../chains'; import { v4 as uuidv4 } from 'uuid'; -import { blockchainStorage } from '@extension/storage'; import { ChainToNetworkId, caipToNetworkId, networkIdToIcon } from '../chainConfig'; import * as wallet from '../wallet'; @@ -470,9 +475,18 @@ const handleWalletAddEthereumChain = async (params, KEEPKEY_WALLET, requestInfo, console.log(tag, 'Cleaned provider config:', newProvider); - // Require user approval before adding chain + // Require user approval before adding chain. The event id MUST match + // requestInfo.id — methods.ts:requireApproval() keys its eth_sign_response + // listener on requestInfo.id, and the sidebar echoes the stored event.id + // back. The previous code stored a fresh uuid while leaving requestInfo.id + // alone, so approvals never resolved and every wallet_addEthereumChain + // silently timed out after 10 minutes. Match the pattern used by + // handleSigningMethods / handleTransfer below: mutate requestInfo.id to a + // uuid first (collision-safe across concurrent dApp requests) and then + // use it as the event id. + requestInfo.id = uuidv4(); const approvalEvent = { - id: uuidv4(), + id: requestInfo.id, networkId, chain: 'ethereum', type: 'wallet_addEthereumChain', @@ -487,6 +501,9 @@ const handleWalletAddEthereumChain = async (params, KEEPKEY_WALLET, requestInfo, await requestStorage.addEvent(approvalEvent); const approval = await requireApproval(networkId, requestInfo, 'ethereum', 'wallet_addEthereumChain', params[0]); if (!approval?.success) { + // UI removes the event on reject, but guard against duplicate state if + // reject came from the approval timeout instead of the user button. + await requestStorage.removeEventById(requestInfo.id).catch(() => {}); throw createProviderRpcError(4001, 'User rejected adding the chain'); } @@ -497,6 +514,20 @@ const handleWalletAddEthereumChain = async (params, KEEPKEY_WALLET, requestInfo, // Switch to the newly added chain await switchToProvider(newProvider, KEEPKEY_WALLET, tag); + + // Unlike signing methods this flow has no txHash and no on-device step, + // so neither signMessage nor sendTransaction emit anything for us. Clean + // up the pending event ourselves and reuse `signature_complete` — it's + // the contract the sidebar uses to dismiss the overlay without trying + // to build a TxidPage (transaction_complete would demand a txHash). + await requestStorage.removeEventById(requestInfo.id).catch(() => {}); + chrome.runtime + .sendMessage({ + action: 'signature_complete', + eventId: requestInfo.id, + }) + .catch(() => {}); + return null; }; @@ -562,7 +593,7 @@ const handleSigningMethods = async (method, params, requestInfo, ADDRESS, KEEPKE console.log(tag, 'networkId:', networkId); if (!networkId) throw Error('Failed to set context before sending!'); // Require user approval - let unsignedTx = params[0]; + const unsignedTx = params[0]; requestInfo.id = uuidv4(); const event = { id: requestInfo.id, diff --git a/chrome-extension/src/background/index.ts b/chrome-extension/src/background/index.ts index d89ebd8..44f71fa 100644 --- a/chrome-extension/src/background/index.ts +++ b/chrome-extension/src/background/index.ts @@ -23,11 +23,22 @@ import { } from '@extension/storage'; import { EIP155_CHAINS } from './chains'; import { formatUserError } from './utils'; +import { filterSpamTokens } from './spamFilter'; const TAG = ' | background/index.js | '; console.log('Background script loaded'); console.log('Version:', packageJson.version); +// Make clicking the extension icon open the side panel. Required because +// `chrome.sidePanel.open()` from a dApp-triggered approval flow isn't a +// user gesture and may be ignored — the icon click is the guaranteed +// fallback path. No-op on Firefox (no sidePanel API). +if (chrome.sidePanel?.setPanelBehavior) { + chrome.sidePanel + .setPanelBehavior({ openPanelOnActionClick: true }) + .catch(e => console.warn(TAG, 'setPanelBehavior failed', e)); +} + const PIONEER_API = 'https://api.keepkey.info'; const KEEPKEY_STATES = { @@ -132,11 +143,9 @@ let balancesFetchInProgress: Promise | null = null; let latestFetchId = 0; function pushBalancesUpdated() { - chrome.runtime - .sendMessage({ type: 'BALANCES_UPDATED' }) - .catch(() => { - // No popup/sidebar listening — ignore. - }); + chrome.runtime.sendMessage({ type: 'BALANCES_UPDATED' }).catch(() => { + // No popup/sidebar listening — ignore. + }); } // All EVM CAPIPs (deduplicated) — used to fan out EVM wildcard addresses @@ -274,8 +283,7 @@ async function fetchBalancesFromPioneer(forceRefresh = false): Promise { for (const raw of allEntries) { const entry = normalizeSolanaCasing({ ...raw }); const caipPath = (entry.caip || '').split('/')[1] || ''; - const isToken = - entry.type === 'token' || caipPath.startsWith('token:') || caipPath.startsWith('spl:'); + const isToken = entry.type === 'token' || caipPath.startsWith('token:') || caipPath.startsWith('spl:'); if (isToken) tokens.push(entry); else natives.push(entry); } @@ -301,8 +309,9 @@ async function fetchBalancesFromPioneer(forceRefresh = false): Promise { return cachedBalances; } - // Transform native balances - const balances: any[] = rawBalances.map((b: any) => { + // Transform native balances. `let` because we reassign after spam + // filtering below; token entries are appended earlier, filtered later. + let balances: any[] = rawBalances.map((b: any) => { const caip = b.caip || ''; const networkId = b.networkId || caip.split('/')[0] || ''; return { @@ -393,6 +402,14 @@ async function fetchBalancesFromPioneer(forceRefresh = false): Promise { console.warn('[fetchBalances] Custom chain enrichment error:', e.message); } + const preFilterCount = balances.length; + balances = filterSpamTokens(balances); + if (balances.length !== preFilterCount) { + console.log( + `[fetchBalances] Spam filter dropped ${preFilterCount - balances.length}/${preFilterCount} token entries`, + ); + } + console.log( `[fetchBalances] Got ${balances.length} balance entries (${balances.filter((b: any) => b.isNative).length} native, ${balances.filter((b: any) => !b.isNative).length} tokens)`, ); @@ -556,6 +573,18 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a const { requestInfo } = message; const { method, params, chain } = requestInfo; + // Tag the request with the sender's browser tab/window so the + // approval side panel opens in the SAME window the dApp lives + // in — not whichever web tab was focused last. Using "most + // recently accessed" risked surfacing a signing prompt in a + // completely different browser window than the one that + // triggered it, which is a real phishing / mis-sign risk now + // that the sidebar is the sole approval surface. + if (sender?.tab) { + requestInfo.__senderTabId = sender.tab.id; + requestInfo.__senderWindowId = sender.tab.windowId; + } + if (method) { try { // KEEPKEY_WALLET and ADDRESS are passed for backward compat with handler signatures @@ -570,45 +599,6 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a break; } - case 'open_sidebar': - case 'OPEN_SIDEBAR': { - console.log(tag, 'Opening sidebar ** '); - chrome.tabs.query({}, tabs => { - if (chrome.runtime.lastError) { - console.error('Error querying tabs:', chrome.runtime.lastError); - return; - } - - const webPageTabs = tabs.filter(tab => { - return ( - tab.url && - !tab.url.startsWith('chrome://') && - !tab.url.startsWith('chrome-extension://') && - !tab.url.startsWith('about:') - ); - }); - - if (webPageTabs.length > 0) { - webPageTabs.sort((a, b) => b.lastAccessed - a.lastAccessed); - const tab = webPageTabs[0]; - const windowId = tab.windowId; - - console.log(tag, 'Opening sidebar in tab:', tab); - - chrome.sidePanel.open({ windowId }, () => { - if (chrome.runtime.lastError) { - console.error('Error opening side panel:', chrome.runtime.lastError); - } else { - console.log('Side panel opened successfully.'); - } - }); - } else { - console.error('No suitable web page tabs found to open the side panel.'); - } - }); - break; - } - case 'GET_KEEPKEY_STATE': { sendResponse({ state: KEEPKEY_STATE }); break; @@ -664,8 +654,13 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a case 'RESET_APP': { console.log(tag, 'Resetting app...'); + // Reply FIRST so the caller sees the ack before the service worker + // reload tears down the message channel. Every other handler in + // this file returns `{ success: true }` — align here too so UI + // callers that branch on `response?.success` don't log/toast a + // false failure on a successful reset. + sendResponse({ success: true }); chrome.runtime.reload(); - sendResponse({ result: true }); break; } @@ -855,9 +850,38 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a } case 'GET_PUBKEY_CONTEXT': { - // Return first pubkey as context - const pubkeys = wallet.getPubkeys(); - sendResponse({ pubkeyContext: pubkeys.length > 0 ? pubkeys[0] : null }); + // Scope to the currently selected asset so Receive shows the correct + // address. Returning pubkeys[0] unconditionally meant a multi-account + // or multi-chain wallet would surface account-0 / Bitcoin for every + // asset switch — a foot-gun serious enough to send funds to the + // wrong place. Fall back to pubkeys[0] only if no asset context is + // set (cold-start before any selection). + try { + const ctx = await assetContextStorage.get(); + const allPubkeys = wallet.getPubkeys(); + let chosen: any = null; + + if (ctx?.networkId) { + const scoped = wallet.getPubkeys(ctx.networkId); + if (scoped.length > 0) { + // Prefer a pubkey whose accountIndex matches the ctx (asset + // carries accountIndex when the UI drilled into a non-default + // account); otherwise the first match on this network. + chosen = + (ctx as any).accountIndex !== undefined + ? scoped.find((pk: any) => pk.accountIndex === (ctx as any).accountIndex) + : null; + if (!chosen) chosen = scoped[0]; + } + } + + if (!chosen) chosen = allPubkeys[0] ?? null; + sendResponse({ pubkeyContext: chosen }); + } catch (e) { + console.error('GET_PUBKEY_CONTEXT failed:', e); + const pubkeys = wallet.getPubkeys(); + sendResponse({ pubkeyContext: pubkeys[0] ?? null }); + } break; } @@ -944,7 +968,12 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a const { accountIndex: removeIdx } = message; try { const accounts = await ethAccountsStorage.removeAccount(removeIdx); - sendResponse({ success: true, accounts }); + // Without clearing runtime state the signer and pubkey list keep + // the removed account — the UI shows it gone while the wallet + // still holds it, and the next request could sign against the + // supposedly-removed account. + await wallet.removePathByNote(`Ethereum account ${removeIdx}`); + sendResponse({ success: true, accounts, pubkeys: wallet.getPubkeys() }); } catch (error) { console.error('Error removing ETH account:', error); sendResponse({ error: 'Failed to remove ETH account' }); @@ -967,6 +996,29 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a const { network } = message; try { const networks = await customEvmNetworksStorage.addNetwork(network); + // Mirror into the storages the SET_ASSET_CONTEXT handler reads + // for provider config. Without this, the header dropdown renders + // the new network (from customEvmNetworksStorage) but selecting + // it falls through to EIP155_CHAINS, which doesn't know about + // it, and the provider is never configured. + const cleanRpc = (network.rpc || '').trim(); + const cleanExplorer = (network.explorerUrl || '').trim(); + const chainIdHex = '0x' + Number(network.chainId).toString(16); + await blockchainDataStorage.addBlockchainData(network.networkId, { + chainId: chainIdHex, + caip: `${network.networkId}/slip44:60`, + name: network.name, + symbol: network.symbol, + explorer: cleanExplorer, + explorerAddressLink: cleanExplorer ? `${cleanExplorer}/address/` : '', + explorerTxLink: cleanExplorer ? `${cleanExplorer}/tx/` : '', + blockExplorerUrls: cleanExplorer ? [cleanExplorer] : [], + providerUrl: cleanRpc, + providers: cleanRpc ? [cleanRpc] : [], + nativeCurrency: { name: network.symbol, symbol: network.symbol, decimals: 18 }, + type: 'evm', + } as any); + await blockchainStorage.addBlockchain(network.networkId); sendResponse({ success: true, networks }); } catch (error) { console.error('Error adding custom EVM network:', error); @@ -979,6 +1031,29 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a const { networkId: removeNetId } = message; try { const networks = await customEvmNetworksStorage.removeNetwork(removeNetId); + await blockchainStorage.removeBlockchain(removeNetId); + // blockchainDataStorage has no remove API; drop the key via the + // raw set helper so we don't leave an orphaned provider entry. + await blockchainDataStorage.set((prev: any) => { + if (!prev || !(removeNetId in prev)) return prev || {}; + const next = { ...prev }; + delete next[removeNetId]; + return next; + }); + // If the removed network was actively selected, the asset + // context and web3 provider still point at it — the signer + // would keep using a chain the user just deleted. Clear both + // and tell the sidebar so it can drop its drawer / header + // selection. + const currentCtx = await assetContextStorage.get().catch(() => null); + const currentProvider = await web3ProviderStorage.getWeb3Provider().catch(() => null); + if ((currentCtx as any)?.networkId === removeNetId) { + await assetContextStorage.clearContext().catch(() => {}); + chrome.runtime.sendMessage({ type: 'ASSET_CONTEXT_CLEARED' }).catch(() => {}); + } + if ((currentProvider as any)?.networkId === removeNetId) { + await web3ProviderStorage.clearWeb3Provider().catch(() => {}); + } sendResponse({ success: true, networks }); } catch (error) { console.error('Error removing custom EVM network:', error); @@ -1184,10 +1259,19 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a case 'GET_CHARTS': { try { + const { networkIds } = message; let balances = cachedBalances; if (balances.length === 0 && wallet.isInitialized()) { balances = await fetchBalancesFromPioneer(); } + // Honor the networkIds filter the UI hooks send. Previously this + // parameter was ignored and "discover tokens for this network" + // returned the global set, making stale/unrelated balances leak + // into single-network views. + if (Array.isArray(networkIds) && networkIds.length > 0) { + const allow = new Set(networkIds); + balances = balances.filter((b: any) => allow.has(b.networkId)); + } const totalValueUsd = balances.reduce((sum: number, b: any) => sum + parseFloat(b.valueUsd || '0'), 0); sendResponse({ success: true, diff --git a/chrome-extension/src/background/methods.ts b/chrome-extension/src/background/methods.ts index 5266db2..bb386b3 100644 --- a/chrome-extension/src/background/methods.ts +++ b/chrome-extension/src/background/methods.ts @@ -14,83 +14,73 @@ import { handleOsmosisRequest } from './chains/osmosisHandler'; import { handleMayaRequest } from './chains/mayaHandler'; import { handleRippleRequest } from './chains/rippleHandler'; import { handleSolanaRequest } from './chains/solanaHandler'; -import { createProviderRpcError, ProviderRpcError, formatUserError } from './utils'; +import type { ProviderRpcError } from './utils'; +import { createProviderRpcError, formatUserError } from './utils'; const TAG = ' | METHODS | '; -const POPUP_URL = chrome.runtime.getURL('popup/index.html'); +// Approval requests are dApp-triggered, which means we're NOT inside a user +// gesture. `chrome.sidePanel.open()` requires a recent user gesture, so the +// call below may be ignored. The fallback path is the action badge plus +// `setPanelBehavior({openPanelOnActionClick: true})` wired in index.ts — +// the user clicks the extension icon (a real user gesture), the sidebar +// opens, and its `requestStorage` subscription picks up the pending event. +// +// Hard timeout on the promise so nothing hangs forever if the user +// ignores the request. Matches the sidebar's event-age eviction window. +const APPROVAL_TIMEOUT_MS = 10 * 60_000; -// Single window-close listener registered at module load, not per-open. -// Listeners watching for "this popup closed" subscribe via onPopupClosed(). -const popupCloseListeners = new Set<(windowId: number) => void>(); -const onPopupClosed = (listener: (windowId: number) => void): (() => void) => { - popupCloseListeners.add(listener); - return () => { - popupCloseListeners.delete(listener); - }; -}; -chrome.windows.onRemoved.addListener(windowId => { - popupCloseListeners.forEach(fn => { - try { - fn(windowId); - } catch (e) { - console.error(TAG, 'popup close listener threw', e); - } - }); -}); - -// Track the current approval popup's windowId. Source of truth is -// chrome.windows.getAll(), not this variable — the variable is an optimistic -// cache that can be stale after a service worker restart. -let popupWindowId: number | null = null; - -// Find an already-open approval popup by URL. Survives service worker restarts. -const findExistingPopup = async (): Promise => { +const findTargetWindowId = async (preferred?: number | null): Promise => { try { - const windows = await chrome.windows.getAll({ populate: true, windowTypes: ['popup'] }); - for (const w of windows) { - const tabs = w.tabs || []; - if (tabs.some(t => t.url === POPUP_URL || t.pendingUrl === POPUP_URL)) { - return w; + // ALWAYS prefer the sender tab's own window when we know it — that's + // the browser window the dApp is running in, and the side panel + // MUST open there. Falling through to "most recently accessed web + // tab" meant a request originating in Window A could surface its + // approval UI in Window B. + if (preferred != null) { + try { + const w = await chrome.windows.get(preferred); + if (w?.id != null) return w.id; + } catch { + // Window closed between request and approval — fall through. } } - } catch (e) { - console.error(TAG, 'findExistingPopup failed', e); + const current = await chrome.windows.getLastFocused({}); + return current?.id ?? null; + } catch { + return null; } - return null; }; -// Returns the popup windowId once the popup is guaranteed to exist. Focuses -// an existing popup if one is already open; otherwise creates a new one. -const openPopup = async (): Promise => { - const tag = TAG + ' | openPopup | '; +const openSidePanel = async (requestInfo: any): Promise => { + const tag = TAG + ' | openSidePanel | '; + // Firefox has no sidePanel API; the user sees the badge and approval + // remains unreachable until task #5 wires a Firefox-specific surface. + if (!chrome.sidePanel?.open) return; try { - const existing = await findExistingPopup(); - if (existing?.id != null) { - popupWindowId = existing.id; - console.log(tag, 'Popup already open, focusing existing window:', popupWindowId); - try { - await chrome.windows.update(popupWindowId, { focused: true }); - } catch (e) { - console.warn(tag, 'Failed to focus existing popup', e); - } - return popupWindowId; + const windowId = await findTargetWindowId(requestInfo?.__senderWindowId); + if (windowId == null) { + console.warn(tag, 'No target window found — user must click the extension icon to open the panel'); + return; + } + try { + await chrome.sidePanel.open({ windowId }); + console.log(tag, 'Side panel opened for windowId:', windowId); + } catch (e) { + // Expected when no recent user gesture — badge path takes over. + console.warn(tag, 'sidePanel.open failed (likely no user gesture), falling back to badge', e); } - - console.log(tag, 'Opening popup'); - const created = await chrome.windows.create({ - url: POPUP_URL, - type: 'popup', - width: 360, - height: 900, - }); - popupWindowId = created?.id ?? null; - console.log(tag, 'Popup window created:', popupWindowId); - return popupWindowId; } catch (e) { console.error(tag, e); - popupWindowId = null; - return null; + } +}; + +const setApprovalBadge = (pending: boolean) => { + try { + chrome.action.setBadgeText({ text: pending ? '!' : '' }); + if (pending) chrome.action.setBadgeBackgroundColor({ color: '#e74c3c' }); + } catch (e) { + console.warn(TAG, 'setApprovalBadge failed', e); } }; @@ -174,18 +164,20 @@ const requireApproval = async function ( // throw new Error('Event not saved'); // } - const activePopupId = await openPopup(); + setApprovalBadge(true); + await openSidePanel(requestInfo); - // Wait for user's decision and return the result. Cleans up on ANY of: - // - user approves/rejects in popup (eth_sign_response arrives) - // - popup window is closed (treated as implicit reject) + // Wait for user's decision. Resolves on ANY of: + // - user approves/rejects in sidebar (eth_sign_response arrives) + // - APPROVAL_TIMEOUT_MS elapses without a response (treated as reject) return new Promise(resolve => { let settled = false; - let unsubClose: (() => void) | null = null; + let timer: ReturnType | null = null; const cleanup = () => { chrome.runtime.onMessage.removeListener(listener); - if (unsubClose) unsubClose(); + if (timer != null) clearTimeout(timer); + setApprovalBadge(false); }; const listener = (message: any) => { @@ -199,16 +191,13 @@ const requireApproval = async function ( }; chrome.runtime.onMessage.addListener(listener); - unsubClose = onPopupClosed((closedId: number) => { - // If we couldn't determine the popup windowId at open time, treat any - // popup close as potentially ours — safer than hanging forever. - if (activePopupId != null && closedId !== activePopupId) return; + timer = setTimeout(() => { if (settled) return; settled = true; - console.log(tag, 'Popup closed without response, rejecting approval for event:', requestInfo.id); + console.log(tag, 'Approval timed out, rejecting for event:', requestInfo.id); cleanup(); resolve({ success: false }); - }); + }, APPROVAL_TIMEOUT_MS); }); } catch (e) { console.error(tag, e); @@ -216,16 +205,6 @@ const requireApproval = async function ( } }; -const requireUnlock = async function () { - const tag = TAG + ' | requireUnlock | '; - try { - console.log(tag, 'requireUnlock for domain'); - // openPopup(); - } catch (e) { - console.error(e); - } -}; - export const handleWalletRequest = async ( requestInfo: any, chain: string, diff --git a/chrome-extension/src/background/spamFilter.ts b/chrome-extension/src/background/spamFilter.ts new file mode 100644 index 0000000..37ef0a0 --- /dev/null +++ b/chrome-extension/src/background/spamFilter.ts @@ -0,0 +1,305 @@ +/** + * Token Spam Filter — multi-tier heuristic detection. + * Ported from keepkey-vault-v11 spamFilter.ts. + * + * Detection order (first match wins): + * 1. User override (chrome.storage.local 'visible'/'hidden') — absolute precedence + * 2. Name/symbol contains URL or phishing keywords → CONFIRMED spam + * 3. Symbol has suspicious characters or excessive length → CONFIRMED spam + * 4. Known stablecoin symbol with value < $0.50 → CONFIRMED spam + * 5. Dust airdrop: huge quantity (>1M) + near-zero unit price (<$0.0001) → CONFIRMED spam + * 6. Value < $1 → POSSIBLE spam + * 7. Otherwise → clean + */ + +export const KNOWN_STABLECOINS = [ + 'USDT', + 'USDC', + 'DAI', + 'BUSD', + 'UST', + 'TUSD', + 'USDD', + 'USDP', + 'GUSD', + 'PYUSD', + 'FRAX', + 'LUSD', + 'SUSD', + 'ALUSD', + 'FEI', + 'MIM', + 'DOLA', + 'AGEUR', + 'EURT', + 'EURS', +]; + +/** Well-known legitimate token symbols — exempt from dust-airdrop heuristic */ +const KNOWN_LEGIT_SYMBOLS = new Set([ + // Top tokens by market cap + 'ETH', + 'BTC', + 'WETH', + 'WBTC', + 'BNB', + 'MATIC', + 'POL', + 'AVAX', + 'SOL', + 'DOT', + 'ADA', + 'LINK', + 'UNI', + 'AAVE', + 'MKR', + 'CRV', + 'COMP', + 'SNX', + 'SUSHI', + 'YFI', + 'LDO', + 'RPL', + 'ARB', + 'OP', + 'FTM', + 'ATOM', + 'OSMO', + 'RUNE', + 'CACAO', + 'XRP', + 'DOGE', + 'LTC', + 'BCH', + 'DASH', + 'ZEC', + 'ETC', + // Wrapped / bridged + 'WAVAX', + 'WBNB', + 'WMATIC', + 'WPOL', + 'WFTM', + // Major stablecoins + ...KNOWN_STABLECOINS, + // Major DeFi / governance + 'GRT', + 'ENS', + 'APE', + 'SHIB', + 'PEPE', + 'WLD', + 'IMX', + 'RNDR', + 'FET', + 'OCEAN', + 'SAND', + 'MANA', + 'AXS', + 'GALA', + 'ILV', + 'BLUR', + 'PENDLE', + 'ENA', + 'ETHFI', + 'STX', + 'INJ', + 'TIA', + 'SEI', + 'SUI', + 'APT', + 'NEAR', + 'FIL', + 'AR', + // LSTs / LRTs + 'STETH', + 'RETH', + 'CBETH', + 'WSTETH', + 'SWETH', + 'EETH', + 'WEETH', + 'METH', + 'RSETH', + // FOX + 'FOX', +]); + +export type SpamLevel = 'confirmed' | 'possible' | null; +export type TokenVisibilityStatus = 'visible' | 'hidden'; + +export interface SpamResult { + isSpam: boolean; + level: SpamLevel; + reason: string; +} + +/** Balance entry shape used by the extension background */ +export interface TokenBalanceEntry { + symbol?: string; + name?: string; + balance?: string; + valueUsd?: string; + priceUsd?: string; + caip?: string; + isNative?: boolean; + [key: string]: any; +} + +// ── Heuristic helpers ──────────────────────────────────────────────── + +/** URL-like patterns in name or symbol — nearly always phishing */ +const URL_PATTERN = /(?:\.[a-z]{2,6}(?:\/|$))|https?:|www\./i; + +/** Phishing action words that appear in scam token names */ +const PHISHING_KEYWORDS = /\b(claim|visit|reward|bonus|airdrop|free|voucher|gift|redeem|activate|eligible)\b/i; + +/** Symbols should be short alphanumeric; these chars indicate scam */ +const SUSPICIOUS_SYMBOL_CHARS = /[./:$!@#%^&*()+=\[\]{}|\\<>,?~`'"]/; + +/** Max reasonable symbol length — real tokens are 2-11 chars */ +const MAX_SYMBOL_LENGTH = 11; + +/** + * Detect whether a token is spam. + */ +export function detectSpamToken(token: TokenBalanceEntry, userOverride?: TokenVisibilityStatus | null): SpamResult { + // ── Tier 0: User override — absolute precedence ────────────────── + if (userOverride === 'visible') { + return { isSpam: false, level: null, reason: 'User marked as safe' }; + } + if (userOverride === 'hidden') { + return { isSpam: true, level: 'confirmed', reason: 'User marked as hidden' }; + } + + const usd = parseFloat(token.valueUsd || '0'); + const sym = (token.symbol || '').toUpperCase(); + const name = token.name || ''; + + // ── Tier 1: Name/symbol contains URL → CONFIRMED spam ──────────── + if (URL_PATTERN.test(name) || URL_PATTERN.test(token.symbol || '')) { + return { + isSpam: true, + level: 'confirmed', + reason: 'Name/symbol contains URL — phishing token', + }; + } + + // ── Tier 2: Name contains phishing keywords → CONFIRMED spam ───── + if (PHISHING_KEYWORDS.test(name)) { + return { + isSpam: true, + level: 'confirmed', + reason: 'Name contains phishing keyword', + }; + } + + // ── Tier 3: Suspicious symbol characters or length → CONFIRMED ─── + if (SUSPICIOUS_SYMBOL_CHARS.test(token.symbol || '') || (token.symbol || '').length > MAX_SYMBOL_LENGTH) { + return { + isSpam: true, + level: 'confirmed', + reason: 'Symbol has suspicious characters or is too long', + }; + } + + // ── Tier 4: Fake stablecoin (symbol matches but value way off) ─── + if (KNOWN_STABLECOINS.includes(sym) && usd < 0.5) { + return { + isSpam: true, + level: 'confirmed', + reason: `Fake ${sym} — real ${sym} is ~$1.00, this has $${usd.toFixed(2)}`, + }; + } + + // ── Tier 5: Dust airdrop heuristic ─────────────────────────────── + if (!KNOWN_LEGIT_SYMBOLS.has(sym)) { + const qty = parseFloat(token.balance || '0'); + const price = parseFloat(token.priceUsd || '0'); + + if (qty > 1_000_000 && price < 0.0001) { + return { + isSpam: true, + level: 'confirmed', + reason: `Dust airdrop — ${qty.toLocaleString()} units at $${price.toFixed(8)}/unit`, + }; + } + + // Moderate quantity + zero price but somehow has USD value (manipulated) + if (qty > 10_000 && price === 0 && usd > 0) { + return { + isSpam: true, + level: 'confirmed', + reason: 'Suspicious — large quantity with $0 price but non-zero value', + }; + } + } + + // ── Tier 6: Low value → POSSIBLE spam ──────────────────────────── + if (usd < 1) { + return { + isSpam: true, + level: 'possible', + reason: `Low value ($${usd.toFixed(4)}) — common airdrop spam pattern`, + }; + } + + // ── Clean — passed all checks ──────────────────────────────────── + return { isSpam: false, level: null, reason: 'Passed all spam checks' }; +} + +/** + * Filter spam tokens from a balance array. + * + * Only `confirmed` spam (URL-shaped names, phishing keywords, suspicious + * symbols, fake stablecoins, dust airdrops) and user-marked-hidden tokens + * are dropped. `possible` spam (the low-USD-value heuristic) is KEPT — + * the tier-6 `< $1` rule is too coarse to drop silently when there is no + * override UI: legitimate small holdings, fresh custom tokens, testnet- + * like balances, and anything with missing price data all land there. + * Callers that want to visually de-emphasize possible-spam can call + * `detectSpamToken` themselves and render accordingly. + * + * Native chain balances are always kept regardless of classification. + */ +export function filterSpamTokens( + balances: TokenBalanceEntry[], + overrides?: Map, +): TokenBalanceEntry[] { + return balances.filter(b => { + if (b.isNative) return true; + + const override = overrides?.get(b.caip?.toLowerCase() || '') ?? null; + const result = detectSpamToken(b, override); + return !(result.isSpam && result.level === 'confirmed'); + }); +} + +// ── Token visibility persistence (chrome.storage.local) ──────────── + +const STORAGE_KEY = 'keepkey-token-visibility'; + +export async function getTokenVisibilityMap(): Promise> { + return new Promise(resolve => { + chrome.storage.local.get(STORAGE_KEY, data => { + const raw = data[STORAGE_KEY] || {}; + resolve(new Map(Object.entries(raw) as [string, TokenVisibilityStatus][])); + }); + }); +} + +export async function setTokenVisibility(caip: string, status: TokenVisibilityStatus): Promise { + const map = await getTokenVisibilityMap(); + map.set(caip.toLowerCase(), status); + return new Promise(resolve => { + chrome.storage.local.set({ [STORAGE_KEY]: Object.fromEntries(map) }, resolve); + }); +} + +export async function removeTokenVisibility(caip: string): Promise { + const map = await getTokenVisibilityMap(); + map.delete(caip.toLowerCase()); + return new Promise(resolve => { + chrome.storage.local.set({ [STORAGE_KEY]: Object.fromEntries(map) }, resolve); + }); +} diff --git a/chrome-extension/src/background/wallet.ts b/chrome-extension/src/background/wallet.ts index 6034dff..292ed6f 100644 --- a/chrome-extension/src/background/wallet.ts +++ b/chrome-extension/src/background/wallet.ts @@ -253,6 +253,32 @@ export function addPath(path: PathConfig): void { state.paths.push(path); } +/** + * Remove a path (and its corresponding pubkey) by matching note. + * Counterpart to addPath so callers that remove accounts from storage + * can also clear the runtime signer state — otherwise the wallet keeps + * signing against a path that no longer appears in the UI. + * Persists the updated pubkey list if a device is known. + */ +export async function removePathByNote(note: string): Promise { + const tag = TAG + ' | removePathByNote | '; + const beforePaths = state.paths.length; + const beforePubkeys = state.pubkeys.length; + state.paths = state.paths.filter(p => p.note !== note); + state.pubkeys = state.pubkeys.filter((pk: any) => pk.note !== note); + console.log( + tag, + `Removed ${beforePaths - state.paths.length} paths and ${beforePubkeys - state.pubkeys.length} pubkeys for note: ${note}`, + ); + if (state.deviceInfo) { + try { + await pubkeyStorage.savePubkeys(state.pubkeys, state.deviceInfo); + } catch (e) { + console.warn(tag, 'Failed to persist pubkeys after removal:', e); + } + } +} + /** * Append a pubkey entry to state and persist to cache. Used for addresses * derived outside of the batch xpub flow (e.g. Solana via solanaGetAddress). diff --git a/chrome-extension/src/injected/injected.ts b/chrome-extension/src/injected/injected.ts index 863ee41..05caa2c 100644 --- a/chrome-extension/src/injected/injected.ts +++ b/chrome-extension/src/injected/injected.ts @@ -484,11 +484,23 @@ import { registerSolanaWallet } from './solana-wallet-register'; 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 + // Mount providers without stomping existing wallets. + // + // Modern dApps use EIP-6963 for multi-wallet discovery (announced below), + // so we don't need to own `window.ethereum`. Overwriting another wallet's + // provider is a dApp-compatibility landmine — it breaks that wallet's + // connection flow, corrupts its event state, and is hard to debug. + // + // Policy: + // - `window.keepkey` → always mount (our own namespace, no collision risk) + // - `window.ethereum` → only mount if nothing is there; otherwise rely + // on EIP-6963 announceProvider for discovery + // - `window.xfi` → only mount if nothing is there (XDEFI's namespace) + const mountProvider = (name: string, provider: any, { force = false } = {}) => { + const existing = (kWindow as any)[name]; + if (existing && !force) { + console.warn(tag, `window.${name} already present — yielding to it (EIP-6963 still announced for discovery)`); + return; } try { @@ -504,10 +516,12 @@ import { registerSolanaWallet } from './solana-wallet-register'; } }; - // Mount providers + // Mount providers — `keepkey` is forced because it's our own namespace + // and previous page-load state (e.g. from a stale injection) should not + // block us from rebinding to the current request pipeline. mountProvider('ethereum', ethereum); mountProvider('xfi', xfi); - mountProvider('keepkey', keepkey); + mountProvider('keepkey', keepkey, { force: true }); // CRITICAL: Set up EIP-6963 listener BEFORE announcing // This ensures we catch any immediate requests diff --git a/packages/storage/lib/customStorage.ts b/packages/storage/lib/customStorage.ts index 7de2abf..0efbcff 100644 --- a/packages/storage/lib/customStorage.ts +++ b/packages/storage/lib/customStorage.ts @@ -1,4 +1,5 @@ -import { BaseStorage, createStorage, StorageType } from './base'; +import type { BaseStorage } from './base'; +import { createStorage, StorageType } from './base'; type Event = { id: string; diff --git a/packages/storage/lib/exampleThemeStorage.ts b/packages/storage/lib/exampleThemeStorage.ts index 9d23c1b..baa3085 100644 --- a/packages/storage/lib/exampleThemeStorage.ts +++ b/packages/storage/lib/exampleThemeStorage.ts @@ -1,4 +1,5 @@ -import { BaseStorage, createStorage, StorageType } from './base'; +import type { BaseStorage } from './base'; +import { createStorage, StorageType } from './base'; type Theme = 'light' | 'dark'; type SidebarPreference = boolean; diff --git a/packages/storage/lib/providerStorage.ts b/packages/storage/lib/providerStorage.ts index 178ffd8..211d8e9 100644 --- a/packages/storage/lib/providerStorage.ts +++ b/packages/storage/lib/providerStorage.ts @@ -2,7 +2,8 @@ Network Context Storage */ -import { BaseStorage, createStorage, StorageType } from './base'; +import type { BaseStorage } from './base'; +import { createStorage, StorageType } from './base'; type ChainId = string; diff --git a/packages/storage/lib/pubkeyStorage.ts b/packages/storage/lib/pubkeyStorage.ts index 86bf191..9596ae7 100644 --- a/packages/storage/lib/pubkeyStorage.ts +++ b/packages/storage/lib/pubkeyStorage.ts @@ -3,7 +3,8 @@ * Enables view-only mode by caching device pubkeys in chrome.storage.local */ -import { BaseStorage, createStorage, StorageType } from './base'; +import type { BaseStorage } from './base'; +import { createStorage, StorageType } from './base'; // Storage key constants const STORAGE_KEYS = { diff --git a/pages/content/src/index.ts b/pages/content/src/index.ts index 363145a..edd4cb5 100644 --- a/pages/content/src/index.ts +++ b/pages/content/src/index.ts @@ -13,12 +13,18 @@ console.log(TAG, 'Content script initializing'); let injectionAttempts = 0; let isInjected = false; -// Validate message origin (configurable) +// Validate message origin. Defence-in-depth on top of the +// `event.source === window` check below: that guard already blocks +// cross-frame injection, and this one rejects anything whose origin +// doesn't match the frame we're installed in. The content script is +// injected per-frame, so window.location.origin is the "right" origin +// for every message we legitimately handle. `null` origins (sandboxed +// iframes, data: URLs) are allowed for same-window messages — they're +// common in test harnesses and can't forge arbitrary origins. function isAllowedOrigin(origin: string): boolean { - // In production, this should check against user settings - // For now, allow all origins but log them - console.log(TAG, 'Message from origin:', origin); - return true; // TODO: Implement proper origin validation + if (origin === window.location.origin) return true; + if (origin === 'null') return true; + return false; } // Validate message structure @@ -155,14 +161,17 @@ window.addEventListener('message', (event: MessageEvent) => { console.log(TAG, 'Received response from background:', response); - // Send response back to injected script + // Send response back to injected script. `|| null` would collapse + // legitimate `false` / `0` / `''` results into null — wrong for any + // JSON-RPC method with a falsy success value (e.g. a boolean + // negative). Use explicit undefined checks. window.postMessage( { source: 'keepkey-content', type: 'WALLET_RESPONSE', requestId, - result: response?.result || null, - error: response?.error || null, + result: response?.result !== undefined ? response.result : null, + error: response?.error ?? null, } as WalletMessage, '*', ); diff --git a/pages/popup/index.html b/pages/popup/index.html deleted file mode 100644 index f75fabf..0000000 --- a/pages/popup/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - Popup - - - -
- - - diff --git a/pages/popup/package.json b/pages/popup/package.json deleted file mode 100644 index d5720cd..0000000 --- a/pages/popup/package.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "name": "@extension/popup", - "version": "0.0.26", - "description": "chrome extension - popup", - "private": true, - "sideEffects": true, - "files": [ - "dist/**" - ], - "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", - "lint": "eslint . --ext .ts,.tsx", - "lint:fix": "pnpm lint --fix", - "prettier": "prettier . --write --ignore-path ../../.prettierignore", - "type-check": "tsc --noEmit" - }, - "dependencies": { - "@chakra-ui/icons": "^2.1.1", - "@chakra-ui/react": "^2.8.2", - "@emotion/react": "^11.13.3", - "@emotion/styled": "^11.13.0", - "@extension/content-runtime-script": "workspace:*", - "@extension/shared": "workspace:*", - "@extension/storage": "workspace:*", - "axios": "^1.7.7", - "ethers": "^6.13.2", - "framer-motion": "^11.5.4", - "react-code-blocks": "^0.1.6", - "react-confetti": "^6.1.0", - "react-json-view": "^1.21.3" - }, - "devDependencies": { - "@extension/tailwindcss-config": "workspace:*", - "@extension/tsconfig": "workspace:*", - "@extension/vite-config": "workspace:*", - "cross-env": "^7.0.3", - "postcss-load-config": "^6.0.1" - }, - "postcss": { - "plugins": { - "tailwindcss": {}, - "autoprefixer": {} - } - } -} diff --git a/pages/popup/public/logo_vertical.svg b/pages/popup/public/logo_vertical.svg deleted file mode 100644 index 5768b87..0000000 --- a/pages/popup/public/logo_vertical.svg +++ /dev/null @@ -1,124 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pages/popup/public/logo_vertical_dark.svg b/pages/popup/public/logo_vertical_dark.svg deleted file mode 100644 index b4089d8..0000000 --- a/pages/popup/public/logo_vertical_dark.svg +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pages/popup/public/sounds/send.mp3 b/pages/popup/public/sounds/send.mp3 deleted file mode 100644 index 4e98931..0000000 Binary files a/pages/popup/public/sounds/send.mp3 and /dev/null differ diff --git a/pages/popup/src/Popup.tsx b/pages/popup/src/Popup.tsx deleted file mode 100644 index 02b332b..0000000 --- a/pages/popup/src/Popup.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { withErrorBoundary, withSuspense } from '@extension/shared'; -import { Button, Flex, Spinner, Text } from '@chakra-ui/react'; -import EventsViewer from './components/Events'; - -const Popup = () => { - return ( -
- -
- ); -}; - -const LoadingFallback = ( - - - - Loading... - - -); - -const ErrorFallback = ( - - Something went wrong - - The approval window hit an unexpected error. You can close this window and retry the request in your dapp. - - - -); - -export default withErrorBoundary(withSuspense(Popup, LoadingFallback), ErrorFallback); diff --git a/pages/popup/src/components/Events.tsx b/pages/popup/src/components/Events.tsx deleted file mode 100644 index 662bac2..0000000 --- a/pages/popup/src/components/Events.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { useEffect, useState, useCallback, useRef } from 'react'; -import { Box, Spinner, Flex, Text } from '@chakra-ui/react'; -import { requestStorage } from '@extension/storage'; -import Transaction from './Transaction'; - -// Events older than this are treated as abandoned and dropped on load. -const MAX_EVENT_AGE_MINUTES = 10; -// How long to show the empty state before auto-closing the popup. Long enough -// for the post-sign "signature_complete" → cleanup → Transaction.tsx window.close() -// to settle, short enough that a stuck/no-event popup doesn't linger. -const EMPTY_STATE_AUTO_CLOSE_MS = 3000; - -const EventsViewer = () => { - const [events, setEvents] = useState([]); - const [currentIndex, setCurrentIndex] = useState(0); - const [loading, setLoading] = useState(true); - const [fetchError, setFetchError] = useState(null); - const autoCloseTimerRef = useRef | null>(null); - - const fetchEvents = useCallback(async () => { - try { - const storedEvents = (await requestStorage.getEvents()) || []; - const now = Date.now(); - const valid: any[] = []; - - for (const event of storedEvents) { - const ageMs = now - new Date(event.timestamp).getTime(); - if (ageMs <= MAX_EVENT_AGE_MINUTES * 60_000) { - valid.push(event); - } else { - // Fire-and-forget; don't block the fetch on cleanup. - void requestStorage.removeEventById(event.id); - } - } - - setEvents(valid.reverse()); - setFetchError(null); - } catch (e: any) { - console.error('EventsViewer: fetchEvents failed', e); - setFetchError(e?.message || 'Failed to load pending requests'); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - fetchEvents(); - // Live-refresh when events are added/removed by the background. - const unsubscribe = requestStorage.subscribe?.(() => { - fetchEvents(); - }); - return () => { - if (typeof unsubscribe === 'function') unsubscribe(); - }; - }, [fetchEvents]); - - // Auto-close the popup whenever there's nothing the user can act on — - // empty state (dapp cancelled, cleanup ran, no pending request) OR a fetch - // failure (storage corruption/null). Both UIs tell the user the window - // will close itself, so the timer needs to cover both. - useEffect(() => { - if (loading) return; - const shouldAutoClose = fetchError !== null || events.length === 0; - if (!shouldAutoClose) return; - autoCloseTimerRef.current = setTimeout(() => { - console.log('EventsViewer: dead-end state timeout, closing popup'); - window.close(); - }, EMPTY_STATE_AUTO_CLOSE_MS); - return () => { - if (autoCloseTimerRef.current) { - clearTimeout(autoCloseTimerRef.current); - autoCloseTimerRef.current = null; - } - }; - }, [events.length, loading, fetchError]); - - // Clamp currentIndex inline so a mid-render shrink of the events list never - // hands `undefined` to . Doing this in a useEffect leaves a - // one-render gap where events[currentIndex] is undefined and crashes the - // child before the effect can snap the index back. - const safeIndex = events.length > 0 ? Math.min(currentIndex, events.length - 1) : 0; - - return ( - - {loading && ( - - - - Loading pending requests... - - - )} - - {!loading && fetchError && ( - - Couldn't load pending requests - - {fetchError} - - - This window will close automatically. - - - )} - - {!loading && !fetchError && events.length > 0 && ( - - )} - - {!loading && !fetchError && events.length === 0 && ( - - No pending requests - - Nothing to approve right now. - - - This window will close automatically. - - - )} - - ); -}; - -export default EventsViewer; diff --git a/pages/popup/src/components/utxo/CoinControlCard.tsx b/pages/popup/src/components/utxo/CoinControlCard.tsx deleted file mode 100644 index 7ea4214..0000000 --- a/pages/popup/src/components/utxo/CoinControlCard.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { - Box, - Text, - VStack, - HStack, - Button, - Slider, - SliderTrack, - SliderFilledTrack, - SliderThumb, - Card, - CardBody, - Badge, - Flex, - Divider, -} from '@chakra-ui/react'; -import { ExternalLinkIcon } from '@chakra-ui/icons'; - -export default function CoinControl({ transaction }) { - const [inputs, setInputs] = useState([]); - const [outputs, setOutputs] = useState([]); - const [adjustedFee, setAdjustedFee] = useState(0); - const [assetContext, setAssetContext] = useState({ priceUsd: 30000 }); // Placeholder for asset price - const [feeOption, setFeeOption] = useState('medium'); - const [customFeeRate, setCustomFeeRate] = useState(10); - - const recommendedFees = { high: 20, medium: 10, low: 5 }; - - useEffect(() => { - if (transaction && transaction.unsignedTx) { - setInputs(transaction.unsignedTx.inputs || []); - setOutputs(transaction.unsignedTx.outputs || []); - setAdjustedFee(transaction.unsignedTx.fee || 0); - } - }, [transaction]); - - // Update fee based on selected option - useEffect(() => { - let feeRate = recommendedFees.medium; - - if (feeOption === 'high') feeRate = recommendedFees.high; - else if (feeOption === 'low') feeRate = recommendedFees.low; - else if (feeOption === 'custom') feeRate = customFeeRate; - - const txSizeInBytes = 190; // Example tx size - const newFee = Math.ceil(txSizeInBytes * feeRate); - setAdjustedFee(newFee); - }, [feeOption, customFeeRate]); - - const feeInUsd = (adjustedFee / 1e8 * assetContext.priceUsd).toFixed(2); // Fee in USD - - // Simple Transaction Diagram - const renderTransactionDiagram = () => { - return ( - - Transaction Diagram - - {/* Inputs */} - - {inputs.map((input, index) => ( - - Input {index + 1} - - - ))} - - - {/* Transaction Node */} - - Transaction - - - {/* Outputs & Fees */} - - {outputs.map((output, index) => ( - - - {output.addressType === 'change' ? 'Change' : 'Recipient'} - - ))} - - {/* Fee Line */} - - - Fee - - - - - ); - }; - - return ( - - - {/* Transaction Diagram */} - {renderTransactionDiagram()} - - {/* Inputs/Outputs Sections */} - - Inputs - - {inputs.map((input, index) => ( - - - - Amount: {input.amount} sats - Script Type: {input.scriptType} - - - - ))} - - - Outputs - - {outputs.map((output, index) => ( - - - - Address: {output.address} - - {output.addressType === 'change' ? 'Change' : 'Recipient'} - - - - - ))} - - - - ); -} diff --git a/pages/popup/src/index.tsx b/pages/popup/src/index.tsx deleted file mode 100644 index 8f0b076..0000000 --- a/pages/popup/src/index.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { createRoot } from 'react-dom/client'; -import { useEffect } from 'react'; -import { ChakraProvider, useColorMode } from '@chakra-ui/react'; -import { theme } from '@src/styles/theme'; -import Popup from '@src/Popup'; - -const ForceDarkMode = ({ children }: { children: React.ReactNode }) => { - const { setColorMode } = useColorMode(); - - useEffect(() => { - setColorMode('dark'); - }, [setColorMode]); - - return <>{children}; -}; - -function init() { - const appContainer = document.querySelector('#app-container'); - if (!appContainer) { - throw new Error('Can not find #app-container'); - } - const root = createRoot(appContainer); - root.render( - - - - - , - ); -} - -init(); diff --git a/pages/popup/src/styles/theme/config.ts b/pages/popup/src/styles/theme/config.ts deleted file mode 100644 index 0138c05..0000000 --- a/pages/popup/src/styles/theme/config.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { ThemeConfig } from '@chakra-ui/react'; - -export const config: ThemeConfig = { - disableTransitionOnChange: false, -}; diff --git a/pages/popup/src/styles/theme/index.ts b/pages/popup/src/styles/theme/index.ts deleted file mode 100644 index 99b93ca..0000000 --- a/pages/popup/src/styles/theme/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { extendTheme } from '@chakra-ui/react'; -import { config } from './config'; - -// Define the extended KeepKey-themed color palette -const colors = { - keepKeyGold: { - 50: '#fffaf0', - 100: '#f4e5b2', - 200: '#e8cc84', - 300: '#ddb356', - 400: '#d29929', - 500: '#b57f1e', - 600: '#916419', - 700: '#6d4a13', - 800: '#49300e', - 900: '#251807', - }, - keepKeyBlack: { - 50: '#e5e5e5', - 100: '#b8b8b8', - 200: '#8a8a8a', - 300: '#5c5c5c', - 400: '#3d3d3d', - 500: '#1f1f1f', - 600: '#1a1a1a', - 700: '#141414', - 800: '#0f0f0f', - 900: '#0a0a0a', - }, -}; - -export const theme = extendTheme({ - initialColorMode: 'dark', - useSystemColorMode: false, - colors: { - keepKeyGold: colors.keepKeyGold, - gray: colors.keepKeyBlack, - }, - fonts: { - heading: 'Plus Jakarta Sans, sans-serif', - body: 'Plus Jakarta Sans, sans-serif', - }, - components: { - // Button: { - // baseStyle: { - // fontWeight: 'bold', - // }, - // variants: { - // solid: (props: any) => ({ - // bg: props.colorMode === 'dark' ? 'keepKeyGold.500' : 'keepKeyGold.400', - // color: 'white', - // _hover: { - // bg: 'keepKeyGold.600', - // }, - // }), - // }, - // defaultProps: { - // size: 'md', - // variant: 'solid', - // }, - // }, - // You can extend other components here in a similar fashion - }, - config, -}); diff --git a/pages/popup/tsconfig.json b/pages/popup/tsconfig.json deleted file mode 100644 index 0837c72..0000000 --- a/pages/popup/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "@extension/tsconfig/base", - "compilerOptions": { - "baseUrl": ".", - "paths": { - "@src/*": ["src/*"] - }, - "types": ["chrome", "../../vite-env.d.ts"] - }, - "include": ["src"] -} diff --git a/pages/popup/vite.config.mts b/pages/popup/vite.config.mts deleted file mode 100644 index 74de3ee..0000000 --- a/pages/popup/vite.config.mts +++ /dev/null @@ -1,17 +0,0 @@ -import { resolve } from 'node:path'; -import { withPageConfig } from '@extension/vite-config'; - -const rootDir = resolve(__dirname); -const srcDir = resolve(rootDir, 'src'); - -export default withPageConfig({ - resolve: { - alias: { - '@src': srcDir, - }, - }, - publicDir: resolve(rootDir, 'public'), - build: { - outDir: resolve(rootDir, '..', '..', 'dist', 'popup'), - }, -}); diff --git a/pages/side-panel/package.json b/pages/side-panel/package.json index 342a733..5cefc0c 100644 --- a/pages/side-panel/package.json +++ b/pages/side-panel/package.json @@ -30,7 +30,10 @@ "date-fns": "^4.1.0", "framer-motion": "^11.5.4", "qrcode": "^1.5.4", - "react-icons": "^5.5.0" + "react-code-blocks": "^0.1.6", + "react-confetti": "^6.1.0", + "react-icons": "^5.5.0", + "react-json-view": "^1.21.3" }, "devDependencies": { "@extension/tailwindcss-config": "workspace:*", diff --git a/pages/side-panel/src/SidePanel.tsx b/pages/side-panel/src/SidePanel.tsx index a8207ba..0f6f320 100644 --- a/pages/side-panel/src/SidePanel.tsx +++ b/pages/side-panel/src/SidePanel.tsx @@ -23,6 +23,7 @@ import { } from '@chakra-ui/react'; import { ArrowUpIcon, ArrowDownIcon, ChevronLeftIcon } from '@chakra-ui/icons'; import { withErrorBoundary, withSuspense } from '@extension/shared'; +import { requestStorage } from '@extension/storage'; import Connect from './components/Connect'; import Loading from './components/Loading'; @@ -34,6 +35,11 @@ import { Receive } from './components/Receive'; import AssetDetail from './components/AssetDetail'; import DonutChart from './components/DonutChart'; import NetworkAccountHeader from './components/NetworkAccountHeader'; +import Transaction from './approval/Transaction'; + +// Events older than this are dropped on load — an abandoned-tab pending +// request shouldn't hijack the sidebar forever. +const MAX_EVENT_AGE_MINUTES = 10; const HEADER_HEIGHT = '60px'; @@ -46,6 +52,7 @@ const SidePanel = () => { const [isRefreshing, setIsRefreshing] = useState(false); const [selectedAsset, setSelectedAsset] = useState(null); const [balancesInitialLoading, setBalancesInitialLoading] = useState(true); + const [pendingEvent, setPendingEvent] = useState(null); // Disclosures for drawers/modals const { isOpen: isSettingsOpen, onOpen: onSettingsOpen, onClose: onSettingsClose } = useDisclosure(); @@ -86,13 +93,23 @@ const SidePanel = () => { chrome.runtime.sendMessage({ type: 'CLEAR_ASSET_CONTEXT' }); }; + // Prefer native chain rows over ERC-20 / SPL tokens when picking a + // default for global Send / Receive. Picking the highest-USD raw row + // meant a stablecoin or token could hijack the default action — a + // behavior change from the asset-centric UX and a surprise for users + // who expect "Send" to mean "send from my main chain wallet". + const pickDefaultAsset = () => { + if (balances.length === 0) return null; + const byUsd = (a: any, b: any) => parseFloat(b.valueUsd || '0') - parseFloat(a.valueUsd || '0'); + const natives = balances.filter((b: any) => b.isNative).sort(byUsd); + if (natives.length > 0) return natives[0]; + return [...balances].sort(byUsd)[0]; + }; + // Handle global send action const handleGlobalSend = () => { - if (balances.length > 0) { - const sortedBalances = [...balances].sort( - (a, b) => parseFloat(b.valueUsd || '0') - parseFloat(a.valueUsd || '0'), - ); - const defaultToken = sortedBalances[0]; + const defaultToken = pickDefaultAsset(); + if (defaultToken) { chrome.runtime.sendMessage({ type: 'SET_ASSET_CONTEXT', asset: defaultToken }, () => { onSendOpen(); }); @@ -101,11 +118,8 @@ const SidePanel = () => { // Handle global receive action const handleGlobalReceive = () => { - if (balances.length > 0) { - const sortedBalances = [...balances].sort( - (a, b) => parseFloat(b.valueUsd || '0') - parseFloat(a.valueUsd || '0'), - ); - const defaultToken = sortedBalances[0]; + const defaultToken = pickDefaultAsset(); + if (defaultToken) { chrome.runtime.sendMessage({ type: 'SET_ASSET_CONTEXT', asset: defaultToken }, () => { onReceiveOpen(); }); @@ -142,6 +156,41 @@ const SidePanel = () => { } }; + // Subscribe to requestStorage so any dApp-triggered approval request shown + // here takes over the panel as an overlay. Abandoned events beyond the age + // window are evicted on load so a stuck request can't wedge the UI. + const fetchPendingEvent = useCallback(async () => { + try { + const events = (await requestStorage.getEvents()) || []; + const now = Date.now(); + const fresh: any[] = []; + for (const ev of events) { + const ageMs = now - new Date(ev.timestamp).getTime(); + if (ageMs <= MAX_EVENT_AGE_MINUTES * 60_000) { + fresh.push(ev); + } else { + void requestStorage.removeEventById(ev.id); + } + } + // Newest-first — matches popup behavior; user sees the freshest request. + fresh.reverse(); + setPendingEvent(fresh[0] ?? null); + } catch (e) { + console.error('SidePanel: fetchPendingEvent failed', e); + setPendingEvent(null); + } + }, []); + + useEffect(() => { + fetchPendingEvent(); + const unsubscribe = requestStorage.subscribe?.(() => { + fetchPendingEvent(); + }); + return () => { + if (typeof unsubscribe === 'function') unsubscribe(); + }; + }, [fetchPendingEvent]); + // Listen for state changes and external asset context updates (e.g. dApp wallet_addEthereumChain) useEffect(() => { const messageListener = (message: any) => { @@ -153,8 +202,14 @@ const SidePanel = () => { } if (message.type === 'ASSET_CONTEXT_UPDATED' && message.assetContext?.networkId) { const ctx = message.assetContext; + // Pass the full context through. The old projection dropped + // accountIndex, pubkeys, contractAddress, decimals, balances — + // anything the asset-detail / send / receive flows read to + // stay consistent with the rest of the sidebar. Fill in the + // display-required fields with sensible fallbacks when the + // context was minimally populated. const asset = { - networkId: ctx.networkId, + ...ctx, caip: ctx.caip || ctx.networkId, name: ctx.name || ctx.networkId, symbol: ctx.symbol || ctx.nativeCurrency?.symbol || '', @@ -255,6 +310,17 @@ const SidePanel = () => { } }; + // Pending dApp approval takes over the panel. We intentionally skip rendering + // the usual header/balances below so the user can't accidentally navigate + // while an approval is live — matches the old popup's singular-focus UX. + if (pendingEvent) { + return ( + + + + ); + } + return ( {/* Sticky header — floats above drawers */} diff --git a/pages/popup/src/components/AwaitingApproval.tsx b/pages/side-panel/src/approval/AwaitingApproval.tsx similarity index 93% rename from pages/popup/src/components/AwaitingApproval.tsx rename to pages/side-panel/src/approval/AwaitingApproval.tsx index 13cac1a..bff7590 100644 --- a/pages/popup/src/components/AwaitingApproval.tsx +++ b/pages/side-panel/src/approval/AwaitingApproval.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Flex, Card, CardBody, Image, Heading, Button, CloseButton } from '@chakra-ui/react'; import holdAndReleaseIcon from '../assets/svg/hold-and-release.svg'; -const AwaitingApproval = ({ onCancel }: { onCancel: () => void; onClose: () => void }) => { +const AwaitingApproval = ({ onCancel }: { onCancel: () => void }) => { return ( diff --git a/pages/popup/src/components/Transaction.tsx b/pages/side-panel/src/approval/Transaction.tsx similarity index 89% rename from pages/popup/src/components/Transaction.tsx rename to pages/side-panel/src/approval/Transaction.tsx index 8cf6512..73cc5f5 100644 --- a/pages/popup/src/components/Transaction.tsx +++ b/pages/side-panel/src/approval/Transaction.tsx @@ -22,7 +22,15 @@ const requestAssetContext = () => { }); }; -const Transaction = ({ event, reloadEvents }: { event: any; reloadEvents: () => void }) => { +const Transaction = ({ + event, + reloadEvents, + onDismiss, +}: { + event: any; + reloadEvents: () => void; + onDismiss: () => void; +}) => { const [transactionType, setTransactionType] = useState(null); const [txHash, setTxHash] = useState(null); const [awaitingDeviceApproval, setAwaitingDeviceApproval] = useState(false); @@ -50,16 +58,6 @@ const Transaction = ({ event, reloadEvents }: { event: any; reloadEvents: () => // No need to construct it here }, []); - const openSidebar = () => { - chrome.runtime.sendMessage({ type: 'OPEN_SIDEBAR' }, response => { - if (response?.success) { - console.log('Sidebar opened successfully'); - } else { - console.error('Failed to open sidebar:', response?.error); - } - }); - }; - const cancelRequest = () => { chrome.runtime.sendMessage({ type: 'RESET_APP' }, response => { if (response?.success) { @@ -80,7 +78,6 @@ const Transaction = ({ event, reloadEvents }: { event: any; reloadEvents: () => await requestStorage.removeEventById(event.id); reloadEvents(); } else if (decision === 'accept') { - openSidebar(); setAwaitingDeviceApproval(true); } } catch (error) { @@ -125,25 +122,23 @@ const Transaction = ({ event, reloadEvents }: { event: any; reloadEvents: () => console.error('Error playing sound:', e); } - // For signatures (not transactions), clean up and close popup - console.log('Signature complete, closing popup'); + // For signatures (not transactions), clean up and dismiss the overlay + console.log('Signature complete, dismissing approval overlay'); setAwaitingDeviceApproval(false); setTransactionInProgress(false); - // Remove the event from storage and close popup + // Remove the event from storage and dismiss overlay requestStorage .removeEventById(event.id) .then(() => { - // Close the popup window after a brief delay to show success setTimeout(() => { - window.close(); + onDismiss(); }, 500); }) .catch(error => { console.error('Error removing event:', error); - // Close anyway even if there's an error setTimeout(() => { - window.close(); + onDismiss(); }, 500); }); } else if (message.action === 'transaction_error') { @@ -156,19 +151,17 @@ const Transaction = ({ event, reloadEvents }: { event: any; reloadEvents: () => errorDetails.includes('user rejected') || errorDetails.includes('User rejected') ) { - console.log('User denied transaction, closing popup'); - // Remove event and close popup after brief delay + console.log('User denied transaction, dismissing approval overlay'); requestStorage .removeEventById(event.id) .then(() => { setTimeout(() => { - window.close(); + onDismiss(); }, 1000); }) .catch(() => { - // Close anyway even if removal fails setTimeout(() => { - window.close(); + onDismiss(); }, 1000); }); } else { @@ -226,7 +219,7 @@ const Transaction = ({ event, reloadEvents }: { event: any; reloadEvents: () => await requestStorage.removeEventById(event.id); await approvalStorage.addEvent(updatedEvent); reloadEvents(); - chrome.runtime.sendMessage({ action: 'open_sidebar' }); + onDismiss(); } catch (error) { console.error('Error closing tab and storing event:', error); } diff --git a/pages/popup/src/components/TxidPage.tsx b/pages/side-panel/src/approval/TxidPage.tsx similarity index 97% rename from pages/popup/src/components/TxidPage.tsx rename to pages/side-panel/src/approval/TxidPage.tsx index ce07333..5d24423 100644 --- a/pages/popup/src/components/TxidPage.tsx +++ b/pages/side-panel/src/approval/TxidPage.tsx @@ -34,11 +34,7 @@ const TxidPage = ({ txHash, explorerUrl, onClose }: { txHash: string; explorerUr }; const handleClose = () => { - if (onClose) { - onClose(); - } else { - window.close(); - } + onClose?.(); }; const truncatedHash = txHash.length > 20 ? `${txHash.slice(0, 10)}...${txHash.slice(-10)}` : txHash; diff --git a/pages/popup/src/components/evm/ChainSelect.tsx b/pages/side-panel/src/approval/evm/ChainSelect.tsx similarity index 100% rename from pages/popup/src/components/evm/ChainSelect.tsx rename to pages/side-panel/src/approval/evm/ChainSelect.tsx diff --git a/pages/popup/src/components/evm/ContractDetailsCard.tsx b/pages/side-panel/src/approval/evm/ContractDetailsCard.tsx similarity index 100% rename from pages/popup/src/components/evm/ContractDetailsCard.tsx rename to pages/side-panel/src/approval/evm/ContractDetailsCard.tsx diff --git a/pages/popup/src/components/evm/HarpyDetailsCard.tsx b/pages/side-panel/src/approval/evm/HarpyDetailsCard.tsx similarity index 100% rename from pages/popup/src/components/evm/HarpyDetailsCard.tsx rename to pages/side-panel/src/approval/evm/HarpyDetailsCard.tsx diff --git a/pages/popup/src/components/evm/ProjectInfoCard.tsx b/pages/side-panel/src/approval/evm/ProjectInfoCard.tsx similarity index 100% rename from pages/popup/src/components/evm/ProjectInfoCard.tsx rename to pages/side-panel/src/approval/evm/ProjectInfoCard.tsx diff --git a/pages/popup/src/components/evm/RequestDataCard.tsx b/pages/side-panel/src/approval/evm/RequestDataCard.tsx similarity index 100% rename from pages/popup/src/components/evm/RequestDataCard.tsx rename to pages/side-panel/src/approval/evm/RequestDataCard.tsx diff --git a/pages/popup/src/components/evm/RequestDetailsCard.tsx b/pages/side-panel/src/approval/evm/RequestDetailsCard.tsx similarity index 69% rename from pages/popup/src/components/evm/RequestDetailsCard.tsx rename to pages/side-panel/src/approval/evm/RequestDetailsCard.tsx index a48b814..0b8c593 100644 --- a/pages/popup/src/components/evm/RequestDetailsCard.tsx +++ b/pages/side-panel/src/approval/evm/RequestDetailsCard.tsx @@ -1,8 +1,12 @@ -import { useState, useEffect } from 'react'; +import React, { useState, useEffect, Fragment } from 'react'; import { Box, Spinner, Flex } from '@chakra-ui/react'; -import React, { Fragment } from 'react'; import LegacyTx from './txTypes/legacy'; import Eip712Tx from './txTypes/eip712'; +import PersonalSignTx from './txTypes/personalSign'; + +// Message-signing methods have no `unsignedTx` — we must not block them +// behind the "waiting for transaction build" spinner meant for transfers. +const MESSAGE_SIGN_METHODS = new Set(['personal_sign', 'eth_sign']); // Function to request asset context from background script const requestAssetContext = () => { @@ -41,13 +45,18 @@ export default function RequestDetailsCard({ transaction }: any) { case 'eth_signTypedData_v3': case 'eth_signTypedData': return ; + case 'personal_sign': + case 'eth_sign': + return ; default: return ; } }; - if (!transaction?.unsignedTx) { - // Show spinner if transaction.unsignedTx is not set + // Wait for the unsigned-tx build only for methods that produce one. + // Message-signing methods (personal_sign / eth_sign) never populate it. + const isMessageSign = MESSAGE_SIGN_METHODS.has(transaction?.type); + if (!isMessageSign && !transaction?.unsignedTx) { return ( diff --git a/pages/popup/src/components/evm/RequestFeeCard.tsx b/pages/side-panel/src/approval/evm/RequestFeeCard.tsx similarity index 100% rename from pages/popup/src/components/evm/RequestFeeCard.tsx rename to pages/side-panel/src/approval/evm/RequestFeeCard.tsx diff --git a/pages/popup/src/components/evm/RequestMethodCard.tsx b/pages/side-panel/src/approval/evm/RequestMethodCard.tsx similarity index 100% rename from pages/popup/src/components/evm/RequestMethodCard.tsx rename to pages/side-panel/src/approval/evm/RequestMethodCard.tsx diff --git a/pages/popup/src/components/evm/ThreatPrompt.tsx b/pages/side-panel/src/approval/evm/ThreatPrompt.tsx similarity index 100% rename from pages/popup/src/components/evm/ThreatPrompt.tsx rename to pages/side-panel/src/approval/evm/ThreatPrompt.tsx diff --git a/pages/popup/src/components/evm/index.tsx b/pages/side-panel/src/approval/evm/index.tsx similarity index 95% rename from pages/popup/src/components/evm/index.tsx rename to pages/side-panel/src/approval/evm/index.tsx index 898d8ed..8346c42 100644 --- a/pages/popup/src/components/evm/index.tsx +++ b/pages/side-panel/src/approval/evm/index.tsx @@ -50,7 +50,7 @@ export function EvmTransaction({ transaction, reloadEvents, handleResponse }: an {/* Fees Tab */} - {transaction.type !== 'personal_sign' && ( + {transaction.type !== 'personal_sign' && transaction.type !== 'eth_sign' && ( <> diff --git a/pages/popup/src/components/evm/txTypes/eip712.tsx b/pages/side-panel/src/approval/evm/txTypes/eip712.tsx similarity index 100% rename from pages/popup/src/components/evm/txTypes/eip712.tsx rename to pages/side-panel/src/approval/evm/txTypes/eip712.tsx diff --git a/pages/popup/src/components/evm/txTypes/legacy.tsx b/pages/side-panel/src/approval/evm/txTypes/legacy.tsx similarity index 100% rename from pages/popup/src/components/evm/txTypes/legacy.tsx rename to pages/side-panel/src/approval/evm/txTypes/legacy.tsx diff --git a/pages/side-panel/src/approval/evm/txTypes/personalSign.tsx b/pages/side-panel/src/approval/evm/txTypes/personalSign.tsx new file mode 100644 index 0000000..d83159a --- /dev/null +++ b/pages/side-panel/src/approval/evm/txTypes/personalSign.tsx @@ -0,0 +1,137 @@ +import { Badge, Box, Divider, Flex, HStack, Switch, Table, Tbody, Td, Text, Textarea, Tr } from '@chakra-ui/react'; +import React, { useMemo, useState } from 'react'; + +/** + * Approval-dialog view for EIP-191 `personal_sign` (and the legacy + * `eth_sign`) requests. + * + * Why this exists: dApps pass messages to the wallet as hex-encoded UTF-8 + * text per the JSON-RPC spec. The KeepKey firmware displays the hash as + * "Sign Bytes" on-device whenever the payload contains any non-printable + * byte — `\n` (0x0A), `\r`, `\t`. Real SIWE (EIP-4361) login challenges + * (OpenSea, Uniswap, Blur, etc.) are always multi-line, so the device + * physically cannot render them as text. The user's only surface for + * reading what they're signing is this approval popup — if we show raw + * hex here too, informed consent is impossible. + * + * Mirror of the fix shipped in the KeepKey Vault at + * `projects/keepkey-vault-v11/projects/keepkey-vault/src/mainview/components/device/SigningApproval.tsx` + * (EthMessageSection). Same decode semantics: try UTF-8 with the fatal + * decoder, fall back to raw-hex display on failure, keep the raw hex + * always-available behind a toggle so power users can hash-verify. + */ +export default function PersonalSignTx({ transaction }: any) { + const method: string = transaction?.type || ''; + const params: any[] = transaction?.request || transaction?.requestInfo?.params || []; + + // JSON-RPC ordering differs between the two historical methods: + // personal_sign → params = [message, address] + // eth_sign → params = [address, message] + // If the dApp got it wrong (both params happen to be strings), we still + // pick the one that starts with `0x{even-number-of-hex-chars}` or, if + // neither does, fall back to the conventional slot. + const [rawMessage, signer] = useMemo(() => { + if (method === 'eth_sign') return [String(params?.[1] ?? ''), String(params?.[0] ?? '')]; + return [String(params?.[0] ?? ''), String(params?.[1] ?? '')]; + }, [method, params]); + + const decoded = useMemo(() => decodeEip191Message(rawMessage), [rawMessage]); + const [showHex, setShowHex] = useState(!decoded.isUtf8Text); + + return ( + + + + + + + + + + + + + + + + + +
+ Method: + {method}
+ Signer: + + {signer || 'Unknown'} +
+ Message: + + {decoded.isUtf8Text ? ( + + + {decoded.text} + + + ) : ( + + Message is not valid UTF-8 — verify the raw hex below before approving. + + )} +
+ + + Show raw hex + setShowHex(s => !s)} isChecked={showHex} /> + + + {showHex && ( +