diff --git a/chrome-extension/public/injected.js b/chrome-extension/public/injected.js index 53fb066..435d82c 100644 --- a/chrome-extension/public/injected.js +++ b/chrome-extension/public/injected.js @@ -1,19 +1,19 @@ -"use strict";(()=>{var z="123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";function F(d){let e=[0];for(let t of d){let i=z.indexOf(t);if(i===-1)throw new Error("Invalid base58 character");let n=i;for(let g=0;g>=8;for(;n>0;)e.push(n&255),n>>=8}for(let t of d){if(t!=="1")break;e.push(0)}return new Uint8Array(e.reverse())}var W=class d{#r;#e=[];#n=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 e=this.#n||await this.#t("solana_connect",[]);return e&&this.#a(e),{accounts:this.#e}}},"standard:disconnect":{version:"1.0.0",disconnect:async()=>{await this.#t("solana_disconnect",[]).catch(()=>{}),this.#e=[];try{localStorage.removeItem("keepkey-solana")}catch{}this.#i()}},"standard:events":{version:"1.0.0",on:(e,t)=>(e==="change"&&this.#s.add(t),()=>{this.#s.delete(t)})},"solana:signMessage":{version:"1.0.0",signMessage:async(...e)=>{let t=[];for(let{message:i}of e){let n=await this.#t("solana_signMessage",[Array.from(i)]);t.push({signedMessage:i,signature:new Uint8Array(n)})}return t}},"solana:signTransaction":{version:"1.0.0",supportedTransactionVersions:new Set(["legacy",0]),signTransaction:async(...e)=>{let t=[];for(let{transaction:i}of e){let n=await this.#t("solana_signTransaction",[Array.from(i)]);t.push({signedTransaction:new Uint8Array(n)})}return t}},"solana:signAndSendTransaction":{version:"1.0.0",supportedTransactionVersions:new Set(["legacy",0]),signAndSendTransaction:async(...e)=>{let t=[];for(let{transaction:i}of e){let n=await this.#t("solana_signAndSendTransaction",[Array.from(i)]);t.push({signature:F(n)})}return t}},"keepkey:signOffchainMessage":{version:"1.0.0",signOffchainMessage:async e=>{let t=typeof e.message=="string"?Array.from(new TextEncoder().encode(e.message)):Array.from(e.message);return await this.#t("solana_signOffchainMessage",[{message:t,version:e.version,messageFormat:e.messageFormat}])}},"solana:signIn":{version:"1.0.0",signIn:async(...e)=>{var i;let t=[];for(let n of e){if(this.#e.length===0){let E=this.#n||await this.#t("solana_connect",[]);E&&this.#a(E)}let g=this.#e[0];if(!g)throw new Error("Not connected");let o=(n==null?void 0:n.domain)||location.host,u=(n==null?void 0:n.address)||g.address,y=(n==null?void 0:n.uri)||location.href,p=(n==null?void 0:n.version)||"1",k=(n==null?void 0:n.chainId)||"mainnet",v=(n==null?void 0:n.nonce)||Math.random().toString(36).substring(2),M=(n==null?void 0:n.issuedAt)||new Date().toISOString(),x=(n==null?void 0:n.statement)||"",m=`${o} wants you to sign in with your Solana account: -${u}`;if(x&&(m+=` +"use strict";(()=>{var z="123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";function F(d){let e=[0];for(let t of d){let o=z.indexOf(t);if(o===-1)throw new Error("Invalid base58 character");let n=o;for(let u=0;u>=8;for(;n>0;)e.push(n&255),n>>=8}for(let t of d){if(t!=="1")break;e.push(0)}return new Uint8Array(e.reverse())}var O=class d{#r;#e=[];#n=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 e=this.#n||await this.#t("solana_connect",[]);return e&&this.#a(e),{accounts:this.#e}}},"standard:disconnect":{version:"1.0.0",disconnect:async()=>{await this.#t("solana_disconnect",[]).catch(()=>{}),this.#e=[];try{localStorage.removeItem("keepkey-solana")}catch{}this.#i()}},"standard:events":{version:"1.0.0",on:(e,t)=>(e==="change"&&this.#s.add(t),()=>{this.#s.delete(t)})},"solana:signMessage":{version:"1.0.0",signMessage:async(...e)=>{let t=[];for(let{message:o}of e){let n=await this.#t("solana_signMessage",[Array.from(o)]);t.push({signedMessage:o,signature:new Uint8Array(n)})}return t}},"solana:signTransaction":{version:"1.0.0",supportedTransactionVersions:new Set(["legacy",0]),signTransaction:async(...e)=>{let t=[];for(let{transaction:o}of e){let n=await this.#t("solana_signTransaction",[Array.from(o)]);t.push({signedTransaction:new Uint8Array(n)})}return t}},"solana:signAndSendTransaction":{version:"1.0.0",supportedTransactionVersions:new Set(["legacy",0]),signAndSendTransaction:async(...e)=>{let t=[];for(let{transaction:o}of e){let n=await this.#t("solana_signAndSendTransaction",[Array.from(o)]);t.push({signature:F(n)})}return t}},"keepkey:signOffchainMessage":{version:"1.0.0",signOffchainMessage:async e=>{let t=typeof e.message=="string"?Array.from(new TextEncoder().encode(e.message)):Array.from(e.message);return await this.#t("solana_signOffchainMessage",[{message:t,version:e.version,messageFormat:e.messageFormat}])}},"solana:signIn":{version:"1.0.0",signIn:async(...e)=>{var o;let t=[];for(let n of e){if(this.#e.length===0){let E=this.#n||await this.#t("solana_connect",[]);E&&this.#a(E)}let u=this.#e[0];if(!u)throw new Error("Not connected");let a=(n==null?void 0:n.domain)||location.host,g=(n==null?void 0:n.address)||u.address,h=(n==null?void 0:n.uri)||location.href,p=(n==null?void 0:n.version)||"1",k=(n==null?void 0:n.chainId)||"mainnet",v=(n==null?void 0:n.nonce)||Math.random().toString(36).substring(2),M=(n==null?void 0:n.issuedAt)||new Date().toISOString(),N=(n==null?void 0:n.statement)||"",m=`${a} wants you to sign in with your Solana account: +${g}`;if(N&&(m+=` -${x}`),m+=` +${N}`),m+=` -URI: ${y}`,m+=` +URI: ${h}`,m+=` Version: ${p}`,m+=` Chain ID: ${k}`,m+=` Nonce: ${v}`,m+=` Issued At: ${M}`,n!=null&&n.expirationTime&&(m+=` Expiration Time: ${n.expirationTime}`),n!=null&&n.notBefore&&(m+=` Not Before: ${n.notBefore}`),n!=null&&n.requestId&&(m+=` -Request ID: ${n.requestId}`),(i=n==null?void 0:n.resources)!=null&&i.length){m+=` +Request ID: ${n.requestId}`),(o=n==null?void 0:n.resources)!=null&&o.length){m+=` Resources:`;for(let E of n.resources)m+=` -- ${E}`}let T=new TextEncoder().encode(m),S=await this.#t("solana_signMessage",[Array.from(T)]);t.push({account:g,signedMessage:T,signature:new Uint8Array(S)})}return t}}};constructor(e){this.#r=e;try{let t=localStorage.getItem("keepkey-solana");if(t){let{address:i}=JSON.parse(t);i&&typeof i=="string"&&(this.#n=i)}}catch{}this.#c()}#o(e){return{address:e,publicKey:F(e),chains:["solana:mainnet"],features:[...d.ACCOUNT_FEATURES]}}#a(e){this.#e=[this.#o(e)];try{localStorage.setItem("keepkey-solana",JSON.stringify({address:e}))}catch{}this.#i()}async#c(){try{let e=await this.#t("solana_connect",[]);if(e&&typeof e=="string"){this.#n=e;try{localStorage.setItem("keepkey-solana",JSON.stringify({address:e}))}catch{}}}catch{}}#i(){let e=this.#e,t=this.features;this.#s.forEach(i=>{try{i({accounts:e,features:t})}catch{}})}#t(e,t){return new Promise((i,n)=>{this.#r(e,t,"solana",(g,o)=>{g?n(g):i(o)})})}};function q(d){let e=({register:t})=>{t(d)};try{let t=window.navigator;t.wallets||(t.wallets=[]),Array.isArray(t.wallets)?t.wallets.push(e):typeof t.wallets.register=="function"&&t.wallets.register(d)}catch{}try{window.dispatchEvent(new CustomEvent("wallet-standard:register-wallet",{detail:e}))}catch{}window.addEventListener("wallet-standard:app-ready",t=>{let i=t;try{typeof i.detail=="function"&&i.detail(e)}catch{}})}var U="https://api.trongrid.io",J="123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";function Q(d){let e=[0];for(let t of d){let i=J.indexOf(t);if(i===-1)throw new Error("Invalid base58 character");let n=i;for(let g=0;g>=8;for(;n>0;)e.push(n&255),n>>=8}for(let t of d){if(t!=="1")break;e.push(0)}return new Uint8Array(e.reverse())}function X(d){let e="";for(let t of d)e+=t.toString(16).padStart(2,"0");return e}function O(d){let e=Q(d);if(e.length!==25||e[0]!==65)throw new Error(`Invalid Tron address: ${d}`);return X(e.slice(0,21))}function _(d){if(typeof d!="string"||d.length!==34||!d.startsWith("T"))return!1;try{return O(d),!0}catch{return!1}}var K=class{events=new Map;on(e,t){this.events.has(e)||this.events.set(e,new Set),this.events.get(e).add(t)}off(e,t){var i;(i=this.events.get(e))==null||i.delete(t)}emit(e,...t){var i;(i=this.events.get(e))==null||i.forEach(n=>{try{n(...t)}catch{}})}};function I(d,e,t){return new Promise((i,n)=>{d(e,t,"tron",(g,o)=>{g?n(g):i(o)})})}async function C(d,e){let t=await fetch(`${U}${d}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(!t.ok){let i=await t.text().catch(()=>"");throw new Error(`TronGrid ${d} failed (${t.status}): ${i}`)}return t.json()}var N=class{tronWeb;tronLink;address=null;hexAddress=null;emitter=new K;walletRequest;constructor(e){this.walletRequest=e,this.tronWeb=this.buildTronWeb(),this.tronLink=this.buildTronLink()}setAddress(e){!e||!_(e)||(this.address=e,this.hexAddress=O(e),this.tronWeb.ready=!0,this.tronWeb.defaultAddress={base58:e,hex:this.hexAddress,name:"KeepKey",type:1},this.tronLink.ready=!0,this.fireMessage("setAccount",{address:e,name:"KeepKey",type:1}),this.fireMessage("accountsChanged",{address:e}))}fireMessage(e,t){try{window.postMessage({message:{action:e,data:t},isTronLink:!0},window.location.origin)}catch{}this.emitter.emit(e,t)}buildTronLink(){return{isTronLink:!0,ready:!1,tronWeb:null,request:async({method:e,params:t})=>{switch(e){case"tron_requestAccounts":case"tron_accounts":{let i=await I(this.walletRequest,"tron_requestAccounts",[]);return!i||typeof i!="string"?{code:4001,message:"User denied account access"}:(this.setAddress(i),{code:200,message:"ok"})}default:return I(this.walletRequest,e,Array.isArray(t)?t:[t])}},on:(e,t)=>this.emitter.on(e,t),off:(e,t)=>this.emitter.off(e,t)}}buildTronWeb(){let e=this,t={sign:async(o,u,y,p)=>{if(typeof o=="string")throw new Error("tronWeb.trx.sign(message) not supported \u2014 use tronWeb.trx.signMessage()");if(!o||!o.raw_data_hex)throw new Error("tronWeb.trx.sign expects a built transaction with raw_data_hex");return await I(e.walletRequest,"tron_sign",[o])},signMessage:async(o,u)=>await I(e.walletRequest,"tron_signMessage",[o]),signMessageV2:async(o,u)=>await I(e.walletRequest,"signMessageV2",[o]),sendRawTransaction:async o=>C("/wallet/broadcasttransaction",o),broadcast:async o=>t.sendRawTransaction(o),getBalance:async o=>{let u=o||e.address;if(!u)throw new Error("No address \u2014 call tron_requestAccounts first");let y=await C("/wallet/getaccount",{address:u,visible:!0});return typeof(y==null?void 0:y.balance)=="number"?y.balance:0},getAccount:async o=>{let u=o||e.address;if(!u)throw new Error("No address \u2014 call tron_requestAccounts first");return C("/wallet/getaccount",{address:u,visible:!0})},getUnconfirmedAccount:async o=>{let u=o||e.address;if(!u)throw new Error("No address \u2014 call tron_requestAccounts first");return C("/wallet/getaccount",{address:u,visible:!0})},getTransaction:async o=>C("/wallet/gettransactionbyid",{value:o})},g={isTronLink:!0,ready:!1,defaultAddress:{base58:!1,hex:!1,name:!1,type:-1},fullNode:{host:U},solidityNode:{host:U},eventServer:{host:U},trx:t,transactionBuilder:{sendTrx:async(o,u,y)=>{let p=y||e.address;if(!p)throw new Error("No address \u2014 call tron_requestAccounts first");return C("/wallet/createtransaction",{owner_address:p,to_address:o,amount:u,visible:!0})},triggerSmartContract:async(o,u,y={},p=[],k)=>{let v=k||e.address;if(!v)throw new Error("No address \u2014 call tron_requestAccounts first");return C("/wallet/triggersmartcontract",{contract_address:o,function_selector:u,parameter:V(p),fee_limit:y.feeLimit??1e8,call_value:y.callValue??0,owner_address:v,visible:!0})}},utils:{isAddress:o=>_(o),fromSun:o=>String(Number(o)/1e6),toSun:o=>String(Math.round(Number(o)*1e6)),toHex:o=>O(o)},on:(o,u)=>this.emitter.on(o,u),off:(o,u)=>this.emitter.off(o,u),setAddress:o=>{},isConnected:()=>this.address!==null};return queueMicrotask(()=>{this.tronLink&&(this.tronLink.tronWeb=g)}),g}};function V(d){if(!Array.isArray(d)||d.length===0)return"";let e="";for(let t of d)if(t.type==="address"){let i=String(t.value),n=i.startsWith("T")?O(i).slice(2):i.replace(/^0x/,"").replace(/^41/,"");e+=n.padStart(64,"0")}else if(t.type==="uint256"||t.type==="uint"){let i=BigInt(t.value);e+=i.toString(16).padStart(64,"0")}else{let i=String(t.value).replace(/^0x/,"");e+=i.padStart(64,"0")}return e}(function(){let d="2.1.0",g=window,o={isInjected:!1,version:d,injectedAt:Date.now(),retryCount:0};if(g.keepkeyInjectionState&&g.keepkeyInjectionState.version>=d)return;g.keepkeyInjectionState=o;let u=(()=>{var r;let l={enableMetaMaskMasking:!1,enableXfiMasking:!1,enableKeplrMasking:!1};try{let c=document.currentScript,s=document.getElementById("keepkey-injected-script"),a=(r=c==null?void 0:c.dataset)!=null&&r.masking?c:s,A=a==null?void 0:a.dataset.masking;if(!A)return l;let f=JSON.parse(A);return{enableMetaMaskMasking:f.enableMetaMaskMasking===!0,enableXfiMasking:f.enableXfiMasking===!0,enableKeplrMasking:f.enableKeplrMasking===!0}}catch{return l}})();console.log(`[KeepKey] masking: metamask=${u.enableMetaMaskMasking?"on":"off"} xfi=${u.enableXfiMasking?"on":"off"} keplr=${u.enableKeplrMasking?"on":"off"}`);let y={siteUrl:window.location.href,scriptSource:"KeepKey Extension",version:d,injectedTime:new Date().toISOString(),origin:window.location.origin,protocol:window.location.protocol},p=0,k=new Map,v=[],M=!1;setInterval(()=>{let l=Date.now();k.forEach((r,c)=>{l-r.timestamp>3e5&&(r.callback(new Error("Request timeout")),k.delete(c))})},5e3);let m=l=>{v.length>=100&&v.shift(),v.push(l)},T=()=>{if(M)for(;v.length>0;){let l=v.shift();l&&window.postMessage(l,window.location.origin)}},S=(l=0)=>new Promise(r=>{let c=++p,s=setTimeout(()=>{l<3?setTimeout(()=>{S(l+1).then(r)},100*Math.pow(2,l)):(o.lastError="Failed to verify injection",r(!1))},1e3),a=A=>{var f,w,b;A.source===window&&((f=A.data)==null?void 0:f.source)==="keepkey-content"&&((w=A.data)==null?void 0:w.type)==="INJECTION_CONFIRMED"&&((b=A.data)==null?void 0:b.requestId)===c&&(clearTimeout(s),window.removeEventListener("message",a),M=!0,o.isInjected=!0,T(),r(!0))};window.addEventListener("message",a),window.postMessage({source:"keepkey-injected",type:"INJECTION_VERIFY",requestId:c,version:d,timestamp:Date.now()},window.location.origin)});function E(l,r=[],c,s){if(!l||typeof l!="string"){s(new Error("Invalid method"));return}Array.isArray(r)||(r=[r]);try{let a=++p,A={id:a,method:l,params:r,chain:c,siteUrl:y.siteUrl,scriptSource:y.scriptSource,version:y.version,requestTime:new Date().toISOString(),referrer:document.referrer,href:window.location.href,userAgent:navigator.userAgent,platform:navigator.platform,language:navigator.language};k.set(a,{callback:s,timestamp:Date.now(),method:l});let f={source:"keepkey-injected",type:"WALLET_REQUEST",requestId:a,requestInfo:A,timestamp:Date.now()};M?window.postMessage(f,window.location.origin):m(f)}catch(a){s(a)}}window.addEventListener("message",l=>{if(l.source!==window)return;let r=l.data;if(!(!r||typeof r!="object")){if(r.source==="keepkey-content"&&r.type==="INJECTION_CONFIRMED"){M=!0,T();return}if(r.source==="keepkey-content"&&r.type==="WALLET_RESPONSE"&&r.requestId){let c=k.get(r.requestId);c&&(r.error?c.callback(r.error):c.callback(null,r.result),k.delete(r.requestId))}}});class j{events=new Map;on(r,c){this.events.has(r)||this.events.set(r,new Set),this.events.get(r).add(c)}off(r,c){var s;(s=this.events.get(r))==null||s.delete(c)}removeListener(r,c){this.off(r,c)}removeAllListeners(r){r?this.events.delete(r):this.events.clear()}emit(r,...c){var s;(s=this.events.get(r))==null||s.forEach(a=>{try{a(...c)}catch{}})}once(r,c){let s=(...a)=>{c(...a),this.off(r,s)};this.on(r,s)}}function h(l){let r=new j,c={network:"mainnet",isKeepKey:!0,isMetaMask:u.enableMetaMaskMasking,isConnected:()=>M,request:({method:s,params:a=[]})=>new Promise((A,f)=>{E(s,a,l,(w,b)=>{if(w)console.log(`[HANDOFF] dApp \u2190 KeepKey (${l}/${s}) REJECT - params=${JSON.stringify(a)} +- ${E}`}let C=new TextEncoder().encode(m),S=await this.#t("solana_signMessage",[Array.from(C)]);t.push({account:u,signedMessage:C,signature:new Uint8Array(S)})}return t}}};constructor(e){this.#r=e;try{let t=localStorage.getItem("keepkey-solana");if(t){let{address:o}=JSON.parse(t);o&&typeof o=="string"&&(this.#n=o)}}catch{}this.#c()}#o(e){return{address:e,publicKey:F(e),chains:["solana:mainnet"],features:[...d.ACCOUNT_FEATURES]}}#a(e){this.#e=[this.#o(e)];try{localStorage.setItem("keepkey-solana",JSON.stringify({address:e}))}catch{}this.#i()}async#c(){try{let e=await this.#t("solana_connect",[]);if(e&&typeof e=="string"){this.#n=e;try{localStorage.setItem("keepkey-solana",JSON.stringify({address:e}))}catch{}}}catch{}}#i(){let e=this.#e,t=this.features;this.#s.forEach(o=>{try{o({accounts:e,features:t})}catch{}})}#t(e,t){return new Promise((o,n)=>{this.#r(e,t,"solana",(u,a)=>{u?n(u):o(a)})})}};function _(d){let e=({register:t})=>{t(d)};try{let t=window.navigator;t.wallets||(t.wallets=[]),Array.isArray(t.wallets)?t.wallets.push(e):typeof t.wallets.register=="function"&&t.wallets.register(d)}catch{}try{window.dispatchEvent(new CustomEvent("wallet-standard:register-wallet",{detail:e}))}catch{}window.addEventListener("wallet-standard:app-ready",t=>{let o=t;try{typeof o.detail=="function"&&o.detail(e)}catch{}})}var U="https://api.trongrid.io",J="123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";function Q(d){let e=[0];for(let t of d){let o=J.indexOf(t);if(o===-1)throw new Error("Invalid base58 character");let n=o;for(let u=0;u>=8;for(;n>0;)e.push(n&255),n>>=8}for(let t of d){if(t!=="1")break;e.push(0)}return new Uint8Array(e.reverse())}function X(d){let e="";for(let t of d)e+=t.toString(16).padStart(2,"0");return e}function W(d){let e=Q(d);if(e.length!==25||e[0]!==65)throw new Error(`Invalid Tron address: ${d}`);return X(e.slice(0,21))}function q(d){if(typeof d!="string"||d.length!==34||!d.startsWith("T"))return!1;try{return W(d),!0}catch{return!1}}var K=class{events=new Map;on(e,t){this.events.has(e)||this.events.set(e,new Set),this.events.get(e).add(t)}off(e,t){var o;(o=this.events.get(e))==null||o.delete(t)}emit(e,...t){var o;(o=this.events.get(e))==null||o.forEach(n=>{try{n(...t)}catch{}})}};function I(d,e,t){return new Promise((o,n)=>{d(e,t,"tron",(u,a)=>{u?n(u):o(a)})})}var V=8e3,Y=12e3,Z=d=>d>=500&&d<600;async function T(d,e){let o=d.includes("broadcasttransaction")?Y:V,n=2,u;for(let a=1;a<=n;a++)try{let g=await fetch(`${U}${d}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e),signal:AbortSignal.timeout(o)});if(!g.ok){if(Z(g.status)&&asetTimeout(p,200*a));continue}let h=await g.text().catch(()=>"");throw new Error(`TronGrid ${d} failed (${g.status}): ${h}`)}return await g.json()}catch(g){if(u=g,asetTimeout(h,200*a));continue}}throw u}var B=class{tronWeb;tronLink;address=null;hexAddress=null;emitter=new K;walletRequest;constructor(e){this.walletRequest=e,this.tronWeb=this.buildTronWeb(),this.tronLink=this.buildTronLink()}setAddress(e){!e||!q(e)||(this.address=e,this.hexAddress=W(e),this.tronWeb.ready=!0,this.tronWeb.defaultAddress={base58:e,hex:this.hexAddress,name:"KeepKey",type:1},this.tronLink.ready=!0,this.fireMessage("setAccount",{address:e,name:"KeepKey",type:1}),this.fireMessage("accountsChanged",{address:e}))}fireMessage(e,t){try{window.postMessage({message:{action:e,data:t},isTronLink:!0},window.location.origin)}catch{}this.emitter.emit(e,t)}buildTronLink(){return{isTronLink:!0,ready:!1,tronWeb:null,request:async({method:e,params:t})=>{switch(e){case"tron_requestAccounts":case"tron_accounts":{let o=await I(this.walletRequest,"tron_requestAccounts",[]);return!o||typeof o!="string"?{code:4001,message:"User denied account access"}:(this.setAddress(o),{code:200,message:"ok"})}default:return I(this.walletRequest,e,Array.isArray(t)?t:[t])}},on:(e,t)=>this.emitter.on(e,t),off:(e,t)=>this.emitter.off(e,t)}}buildTronWeb(){let e=this,t={sign:async(a,g,h,p)=>{if(typeof a=="string")throw new Error("tronWeb.trx.sign(message) not supported \u2014 use tronWeb.trx.signMessage()");if(!a||!a.raw_data_hex)throw new Error("tronWeb.trx.sign expects a built transaction with raw_data_hex");return await I(e.walletRequest,"tron_sign",[a])},signMessage:async(a,g)=>await I(e.walletRequest,"tron_signMessage",[a]),signMessageV2:async(a,g)=>await I(e.walletRequest,"signMessageV2",[a]),sendRawTransaction:async a=>T("/wallet/broadcasttransaction",a),broadcast:async a=>t.sendRawTransaction(a),getBalance:async a=>{let g=a||e.address;if(!g)throw new Error("No address \u2014 call tron_requestAccounts first");let h=await T("/wallet/getaccount",{address:g,visible:!0});return typeof(h==null?void 0:h.balance)=="number"?h.balance:0},getAccount:async a=>{let g=a||e.address;if(!g)throw new Error("No address \u2014 call tron_requestAccounts first");return T("/wallet/getaccount",{address:g,visible:!0})},getUnconfirmedAccount:async a=>{let g=a||e.address;if(!g)throw new Error("No address \u2014 call tron_requestAccounts first");return T("/wallet/getaccount",{address:g,visible:!0})},getTransaction:async a=>T("/wallet/gettransactionbyid",{value:a})},u={isTronLink:!0,ready:!1,defaultAddress:{base58:!1,hex:!1,name:!1,type:-1},fullNode:{host:U},solidityNode:{host:U},eventServer:{host:U},trx:t,transactionBuilder:{sendTrx:async(a,g,h)=>{let p=h||e.address;if(!p)throw new Error("No address \u2014 call tron_requestAccounts first");return T("/wallet/createtransaction",{owner_address:p,to_address:a,amount:g,visible:!0})},triggerSmartContract:async(a,g,h={},p=[],k)=>{let v=k||e.address;if(!v)throw new Error("No address \u2014 call tron_requestAccounts first");return T("/wallet/triggersmartcontract",{contract_address:a,function_selector:g,parameter:$(p),fee_limit:h.feeLimit??1e8,call_value:h.callValue??0,owner_address:v,visible:!0})}},utils:{isAddress:a=>q(a),fromSun:a=>String(Number(a)/1e6),toSun:a=>String(Math.round(Number(a)*1e6)),toHex:a=>W(a)},on:(a,g)=>this.emitter.on(a,g),off:(a,g)=>this.emitter.off(a,g),setAddress:a=>{},isConnected:()=>this.address!==null};return queueMicrotask(()=>{this.tronLink&&(this.tronLink.tronWeb=u)}),u}};function $(d){if(!Array.isArray(d)||d.length===0)return"";let e="";for(let t of d)if(t.type==="address"){let o=String(t.value),n=o.startsWith("T")?W(o).slice(2):o.replace(/^0x/,"").replace(/^41/,"");e+=n.padStart(64,"0")}else if(t.type==="uint256"||t.type==="uint"){let o=BigInt(t.value);e+=o.toString(16).padStart(64,"0")}else{let o=String(t.value).replace(/^0x/,"");e+=o.padStart(64,"0")}return e}(function(){let d="2.1.0",u=window,a={isInjected:!1,version:d,injectedAt:Date.now(),retryCount:0};if(u.keepkeyInjectionState&&u.keepkeyInjectionState.version>=d)return;u.keepkeyInjectionState=a;let g=(()=>{var r;let l={enableMetaMaskMasking:!1,enableXfiMasking:!1,enableKeplrMasking:!1};try{let c=document.currentScript,s=document.getElementById("keepkey-injected-script"),i=(r=c==null?void 0:c.dataset)!=null&&r.masking?c:s,A=i==null?void 0:i.dataset.masking;if(!A)return l;let f=JSON.parse(A);return{enableMetaMaskMasking:f.enableMetaMaskMasking===!0,enableXfiMasking:f.enableXfiMasking===!0,enableKeplrMasking:f.enableKeplrMasking===!0}}catch{return l}})();console.log(`[KeepKey] masking: metamask=${g.enableMetaMaskMasking?"on":"off"} xfi=${g.enableXfiMasking?"on":"off"} keplr=${g.enableKeplrMasking?"on":"off"}`);let h={siteUrl:window.location.href,scriptSource:"KeepKey Extension",version:d,injectedTime:new Date().toISOString(),origin:window.location.origin,protocol:window.location.protocol},p=0,k=new Map,v=[],M=!1;setInterval(()=>{let l=Date.now();k.forEach((r,c)=>{l-r.timestamp>3e5&&(r.callback(new Error("Request timeout")),k.delete(c))})},5e3);let m=l=>{v.length>=100&&v.shift(),v.push(l)},C=()=>{if(M)for(;v.length>0;){let l=v.shift();l&&window.postMessage(l,window.location.origin)}},S=(l=0)=>new Promise(r=>{let c=++p,s=setTimeout(()=>{l<3?setTimeout(()=>{S(l+1).then(r)},100*Math.pow(2,l)):(a.lastError="Failed to verify injection",r(!1))},1e3),i=A=>{var f,w,b;A.source===window&&((f=A.data)==null?void 0:f.source)==="keepkey-content"&&((w=A.data)==null?void 0:w.type)==="INJECTION_CONFIRMED"&&((b=A.data)==null?void 0:b.requestId)===c&&(clearTimeout(s),window.removeEventListener("message",i),M=!0,a.isInjected=!0,C(),r(!0))};window.addEventListener("message",i),window.postMessage({source:"keepkey-injected",type:"INJECTION_VERIFY",requestId:c,version:d,timestamp:Date.now()},window.location.origin)});function E(l,r=[],c,s){if(!l||typeof l!="string"){s(new Error("Invalid method"));return}Array.isArray(r)||(r=[r]);try{let i=++p,A={id:i,method:l,params:r,chain:c,siteUrl:h.siteUrl,scriptSource:h.scriptSource,version:h.version,requestTime:new Date().toISOString(),referrer:document.referrer,href:window.location.href,userAgent:navigator.userAgent,platform:navigator.platform,language:navigator.language};k.set(i,{callback:s,timestamp:Date.now(),method:l});let f={source:"keepkey-injected",type:"WALLET_REQUEST",requestId:i,requestInfo:A,timestamp:Date.now()};M?window.postMessage(f,window.location.origin):m(f)}catch(i){s(i)}}window.addEventListener("message",l=>{if(l.source!==window)return;let r=l.data;if(!(!r||typeof r!="object")){if(r.source==="keepkey-content"&&r.type==="INJECTION_CONFIRMED"){M=!0,C();return}if(r.source==="keepkey-content"&&r.type==="WALLET_RESPONSE"&&r.requestId){let c=k.get(r.requestId);c&&(r.error?c.callback(r.error):c.callback(null,r.result),k.delete(r.requestId))}}});class j{events=new Map;on(r,c){this.events.has(r)||this.events.set(r,new Set),this.events.get(r).add(c)}off(r,c){var s;(s=this.events.get(r))==null||s.delete(c)}removeListener(r,c){this.off(r,c)}removeAllListeners(r){r?this.events.delete(r):this.events.clear()}emit(r,...c){var s;(s=this.events.get(r))==null||s.forEach(i=>{try{i(...c)}catch{}})}once(r,c){let s=(...i)=>{c(...i),this.off(r,s)};this.on(r,s)}}function y(l){let r=new j,c={network:"mainnet",isKeepKey:!0,isMetaMask:g.enableMetaMaskMasking,isConnected:()=>M,request:({method:s,params:i=[]})=>new Promise((A,f)=>{E(s,i,l,(w,b)=>{if(w)console.log(`[HANDOFF] dApp \u2190 KeepKey (${l}/${s}) REJECT + params=${JSON.stringify(i)} error=`,w),f(w);else{let L=typeof b,H=L==="string"?`len=${b.length} value=${b}`:`value=${JSON.stringify(b)}`;console.log(`[HANDOFF] dApp \u2190 KeepKey (${l}/${s}) RESOLVE - params=${JSON.stringify(a)} - type=${L} ${H}`),A(b)}})}),send:(s,a,A)=>{if(s.chain||(s.chain=l),typeof A=="function"){E(s.method,s.params||a,l,(f,w)=>{f?A(f):A(null,{id:s.id,jsonrpc:"2.0",result:w})});return}else return{id:s.id,jsonrpc:"2.0",result:null}},sendAsync:(s,a,A)=>{s.chain||(s.chain=l);let f=A||a;typeof f=="function"&&E(s.method,s.params||a,l,(w,b)=>{w?f(w):f(null,{id:s.id,jsonrpc:"2.0",result:b})})},on:(s,a)=>(r.on(s,a),c),off:(s,a)=>(r.off(s,a),c),removeListener:(s,a)=>(r.removeListener(s,a),c),removeAllListeners:s=>(r.removeAllListeners(s),c),emit:(s,...a)=>(r.emit(s,...a),c),once:(s,a)=>(r.once(s,a),c),enable:()=>c.request({method:"eth_requestAccounts"}),_metamask:{isUnlocked:()=>Promise.resolve(!0)}};return l==="ethereum"&&(c.chainId="0x1",c.networkVersion="1",c.selectedAddress=null,c._handleAccountsChanged=s=>{c.selectedAddress=s[0]||null,r.emit("accountsChanged",s)},c._handleChainChanged=s=>{c.chainId=s,r.emit("chainChanged",s)},c._handleConnect=s=>{r.emit("connect",s)},c._handleDisconnect=s=>{c.selectedAddress=null,r.emit("disconnect",s)}),c}let D="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAIAAAAACshmLzAAADUklEQVRYCb1XTUgUYRie3bXEWhVLQaUsgwVLoUtEQjUJiZX0A0GX7BIZXurkOTSvdo2kvETHAsOshFgqOqhlRD9C7SGS1JTCsj1krU7PM+w7zMzOzuzMqi88+73v9z7vz3zzzTeziuIgmqbFgG5gBPguFOgq4CXLIMwCo0AXEJN4zxHkEuA6kAIMkUBMqMZk7so/UG8AUcnjOIKwFXgHZIgEwKFmOHOfYO4aySVjmAoc7O4R0EB7lYS5h9K1jBJ6A7CuAfXG7OopbKLXkh4dccNZ7jlsi0gAJlWLI5jBPWFsTK5AGxCRImswFqDGWanDBo6IsYbjUanFbmrFWIHxD3IsmfJsgB4y2aJuF4UrUC5GnuNtxJeEQqEoAb3LJV+F4ctlHwkZXDULv8fEKQCHB4+rCJ9ngKcIGUTVRubT027y8yR9bOM4mhKTTwNJZD4miaDXAG8dqzlMShw3YRCZRVAr7vU4g5F/D4ZBoJK2H+Em9CsfEdBoKn4K9jPAd3G9sMPqZEzpRPzAwRfWJpN9EfZSRkAOE5LD7wrw8dkpwRh55VMm27fqt4FiVBjGBTaxEm4Db8d+4BPtIOK3AdbYCPC1qh/haGIS9gHgDeBbgjTAIkXAfTRxkgaamMNwCHgB+BMk4Decq0hGkFQbka/WMyZ/EeyHNo6TuSwx3Nn8gHQVIYOkOhB5Gp4zcdbBHiDvZ2pRuzozru2euKuDOucg/KliTAjKKMa9ksBpxBLrbzRwVfifOnB4RR2g3QSH3Cfx5FRdc2KoGstroUeQKh47vnAwWvUKjsPcA/wWdBUkjRAgZdsznO8D5xLGC/Opxc3NiQeV9uIsgkNDaUoMFpNDLleAn0cTQNBjGaFW6fn2Wrky/dI6abPOl9eN9deoWhjLloCv3+bPy7w3/9kzfvjX120g1cuSdsJ47xm1CgS9AaxCErlbV6qJ22W1nq22lG75AtIHWQEeJpOYaAT6gBQQWC5XNCjc7dkkHFKWe6v3FcLfbzRAMlcC6IC6C+gGxgCectZnCRMuopVG1v+Nx04sYINlxLH4wI6W52UFhT+Q41b2Nl0qeLnwZPGQucNHrXN6ZDG94RQuO688XbwNFzvjlSuwH03wEW8H+Bf/dxrUOWdc+H8mKXtEpGpY3AAAAABJRU5ErkJggg==",P="data:image/svg+xml;utf8,"+encodeURIComponent('MM');function R(l){let r={uuid:"350670db-19fa-4704-a166-e52e178b59d4",name:"KeepKey",icon:D,rdns:"com.keepkey.client"};if(window.dispatchEvent(new CustomEvent("eip6963:announceProvider",{detail:Object.freeze({info:r,provider:l})})),u.enableMetaMaskMasking){let c={uuid:"9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",name:"MetaMask",icon:P,rdns:"io.metamask"};window.dispatchEvent(new CustomEvent("eip6963:announceProvider",{detail:Object.freeze({info:c,provider:l})}))}}async function G(){let l=h("ethereum"),r={binance:h("binance"),bitcoin:h("bitcoin"),bitcoincash:h("bitcoincash"),dogecoin:h("dogecoin"),dash:h("dash"),ethereum:l,keplr:h("keplr"),litecoin:h("litecoin"),thorchain:h("thorchain"),mayachain:h("mayachain")},c={binance:h("binance"),bitcoin:h("bitcoin"),bitcoincash:h("bitcoincash"),dogecoin:h("dogecoin"),dash:h("dash"),ethereum:l,osmosis:h("osmosis"),cosmos:h("cosmos"),litecoin:h("litecoin"),thorchain:h("thorchain"),mayachain:h("mayachain"),ripple:h("ripple")},s=(a,A,{force:f=!1}={})=>{if(!(g[a]&&!f))try{Object.defineProperty(g,a,{value:A,writable:!1,configurable:!0})}catch{o.lastError=`Failed to mount ${a}`}};u.enableMetaMaskMasking&&s("ethereum",l),u.enableXfiMasking&&s("xfi",r),s("keepkey",c,{force:!0}),window.addEventListener("eip6963:requestProvider",()=>{R(l)}),R(l),setTimeout(()=>{R(l)},100);try{let a=new W(E);q(a)}catch{}try{let a=new N(E);g.tronLink||Object.defineProperty(g,"tronLink",{value:a.tronLink,writable:!1,configurable:!0}),g.tronWeb||Object.defineProperty(g,"tronWeb",{value:a.tronWeb,writable:!1,configurable:!0})}catch{}window.addEventListener("message",a=>{var A,f,w;((A=a.data)==null?void 0:A.type)==="CHAIN_CHANGED"&&l.emit("chainChanged",(f=a.data.provider)==null?void 0:f.chainId),((w=a.data)==null?void 0:w.type)==="ACCOUNTS_CHANGED"&&l._handleAccountsChanged&&l._handleAccountsChanged(a.data.accounts||[])}),S().then(a=>{a||(o.lastError="Injection not verified")})}G(),document.readyState==="loading"&&document.addEventListener("DOMContentLoaded",()=>{if(g.ethereum&&typeof g.dispatchEvent=="function"){let l=g.ethereum;R(l)}})})();})(); + params=${JSON.stringify(i)} + type=${L} ${H}`),A(b)}})}),send:(s,i,A)=>{if(s.chain||(s.chain=l),typeof A=="function"){E(s.method,s.params||i,l,(f,w)=>{f?A(f):A(null,{id:s.id,jsonrpc:"2.0",result:w})});return}else return{id:s.id,jsonrpc:"2.0",result:null}},sendAsync:(s,i,A)=>{s.chain||(s.chain=l);let f=A||i;typeof f=="function"&&E(s.method,s.params||i,l,(w,b)=>{w?f(w):f(null,{id:s.id,jsonrpc:"2.0",result:b})})},on:(s,i)=>(r.on(s,i),c),off:(s,i)=>(r.off(s,i),c),removeListener:(s,i)=>(r.removeListener(s,i),c),removeAllListeners:s=>(r.removeAllListeners(s),c),emit:(s,...i)=>(r.emit(s,...i),c),once:(s,i)=>(r.once(s,i),c),enable:()=>c.request({method:"eth_requestAccounts"}),_metamask:{isUnlocked:()=>Promise.resolve(!0)}};return l==="ethereum"&&(c.chainId="0x1",c.networkVersion="1",c.selectedAddress=null,c._handleAccountsChanged=s=>{c.selectedAddress=s[0]||null,r.emit("accountsChanged",s)},c._handleChainChanged=s=>{c.chainId=s,r.emit("chainChanged",s)},c._handleConnect=s=>{r.emit("connect",s)},c._handleDisconnect=s=>{c.selectedAddress=null,r.emit("disconnect",s)}),c}let D="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAIAAAAACshmLzAAADUklEQVRYCb1XTUgUYRie3bXEWhVLQaUsgwVLoUtEQjUJiZX0A0GX7BIZXurkOTSvdo2kvETHAsOshFgqOqhlRD9C7SGS1JTCsj1krU7PM+w7zMzOzuzMqi88+73v9z7vz3zzzTeziuIgmqbFgG5gBPguFOgq4CXLIMwCo0AXEJN4zxHkEuA6kAIMkUBMqMZk7so/UG8AUcnjOIKwFXgHZIgEwKFmOHOfYO4aySVjmAoc7O4R0EB7lYS5h9K1jBJ6A7CuAfXG7OopbKLXkh4dccNZ7jlsi0gAJlWLI5jBPWFsTK5AGxCRImswFqDGWanDBo6IsYbjUanFbmrFWIHxD3IsmfJsgB4y2aJuF4UrUC5GnuNtxJeEQqEoAb3LJV+F4ctlHwkZXDULv8fEKQCHB4+rCJ9ngKcIGUTVRubT027y8yR9bOM4mhKTTwNJZD4miaDXAG8dqzlMShw3YRCZRVAr7vU4g5F/D4ZBoJK2H+Em9CsfEdBoKn4K9jPAd3G9sMPqZEzpRPzAwRfWJpN9EfZSRkAOE5LD7wrw8dkpwRh55VMm27fqt4FiVBjGBTaxEm4Db8d+4BPtIOK3AdbYCPC1qh/haGIS9gHgDeBbgjTAIkXAfTRxkgaamMNwCHgB+BMk4Decq0hGkFQbka/WMyZ/EeyHNo6TuSwx3Nn8gHQVIYOkOhB5Gp4zcdbBHiDvZ2pRuzozru2euKuDOucg/KliTAjKKMa9ksBpxBLrbzRwVfifOnB4RR2g3QSH3Cfx5FRdc2KoGstroUeQKh47vnAwWvUKjsPcA/wWdBUkjRAgZdsznO8D5xLGC/Opxc3NiQeV9uIsgkNDaUoMFpNDLleAn0cTQNBjGaFW6fn2Wrky/dI6abPOl9eN9deoWhjLloCv3+bPy7w3/9kzfvjX120g1cuSdsJ47xm1CgS9AaxCErlbV6qJ22W1nq22lG75AtIHWQEeJpOYaAT6gBQQWC5XNCjc7dkkHFKWe6v3FcLfbzRAMlcC6IC6C+gGxgCectZnCRMuopVG1v+Nx04sYINlxLH4wI6W52UFhT+Q41b2Nl0qeLnwZPGQucNHrXN6ZDG94RQuO688XbwNFzvjlSuwH03wEW8H+Bf/dxrUOWdc+H8mKXtEpGpY3AAAAABJRU5ErkJggg==",P="data:image/svg+xml;utf8,"+encodeURIComponent('MM');function R(l){let r={uuid:"350670db-19fa-4704-a166-e52e178b59d4",name:"KeepKey",icon:D,rdns:"com.keepkey.client"};if(window.dispatchEvent(new CustomEvent("eip6963:announceProvider",{detail:Object.freeze({info:r,provider:l})})),g.enableMetaMaskMasking){let c={uuid:"9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",name:"MetaMask",icon:P,rdns:"io.metamask"};window.dispatchEvent(new CustomEvent("eip6963:announceProvider",{detail:Object.freeze({info:c,provider:l})}))}}async function G(){let l=y("ethereum"),r={binance:y("binance"),bitcoin:y("bitcoin"),bitcoincash:y("bitcoincash"),dogecoin:y("dogecoin"),dash:y("dash"),ethereum:l,keplr:y("keplr"),litecoin:y("litecoin"),thorchain:y("thorchain"),mayachain:y("mayachain")},c={binance:y("binance"),bitcoin:y("bitcoin"),bitcoincash:y("bitcoincash"),dogecoin:y("dogecoin"),dash:y("dash"),ethereum:l,osmosis:y("osmosis"),cosmos:y("cosmos"),litecoin:y("litecoin"),thorchain:y("thorchain"),mayachain:y("mayachain"),ripple:y("ripple")},s=(i,A,{force:f=!1}={})=>{if(!(u[i]&&!f))try{Object.defineProperty(u,i,{value:A,writable:!1,configurable:!0})}catch{a.lastError=`Failed to mount ${i}`}};g.enableMetaMaskMasking&&s("ethereum",l),g.enableXfiMasking&&s("xfi",r),s("keepkey",c,{force:!0}),window.addEventListener("eip6963:requestProvider",()=>{R(l)}),R(l),setTimeout(()=>{R(l)},100);try{let i=new O(E);_(i)}catch{}try{let i=new B(E);u.tronLink||Object.defineProperty(u,"tronLink",{value:i.tronLink,writable:!1,configurable:!0}),u.tronWeb||Object.defineProperty(u,"tronWeb",{value:i.tronWeb,writable:!1,configurable:!0})}catch{}window.addEventListener("message",i=>{var A,f,w;((A=i.data)==null?void 0:A.type)==="CHAIN_CHANGED"&&l.emit("chainChanged",(f=i.data.provider)==null?void 0:f.chainId),((w=i.data)==null?void 0:w.type)==="ACCOUNTS_CHANGED"&&l._handleAccountsChanged&&l._handleAccountsChanged(i.data.accounts||[])}),S().then(i=>{i||(a.lastError="Injection not verified")})}G(),document.readyState==="loading"&&document.addEventListener("DOMContentLoaded",()=>{if(u.ethereum&&typeof u.dispatchEvent=="function"){let l=u.ethereum;R(l)}})})();})(); diff --git a/chrome-extension/src/background/chains/bitcoinCashHandler.ts b/chrome-extension/src/background/chains/bitcoinCashHandler.ts index c5a3a0b..b2df742 100644 --- a/chrome-extension/src/background/chains/bitcoinCashHandler.ts +++ b/chrome-extension/src/background/chains/bitcoinCashHandler.ts @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Chain, ChainToNetworkId, shortListSymbolToCaip, caipToNetworkId } from '../chainConfig'; import * as wallet from '../wallet'; import { createProviderRpcError } from '../utils'; +import { fetchJsonWithTimeout } from '../fetchUtils'; const TAG = ' | bitcoinCashHandler | '; @@ -41,24 +42,22 @@ export const handleBitcoinCashRequest = async ( isMax: params[0].isMax, }; - const buildTx = async function () { - try { - const buildResponse = await fetch('https://api.keepkey.info/api/v1/buildTx', { + // Build before approval — see bitcoinHandler for the race rationale. + let unsignedTx: any; + try { + unsignedTx = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/buildTx', + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...sendPayload, pubkeys }), - }); - const unsignedTx = await buildResponse.json(); - const storedEvent = await requestStorage.getEventById(requestInfo.id); - storedEvent.unsignedTx = unsignedTx; - await requestStorage.updateEventById(requestInfo.id, storedEvent); - chrome.runtime.sendMessage({ action: 'utxo_build_tx', unsignedTx: requestInfo }); - } catch (e) { - console.error(e); - chrome.runtime.sendMessage({ action: 'transaction_error', eventId: requestInfo.id, error: JSON.stringify(e) }); - } - }; - buildTx(); + }, + { timeoutMs: 15000, retries: 1 }, + ); + } catch (e) { + console.error(tag, 'buildTx failed:', e); + throw createProviderRpcError(4000, `Failed to build transaction: ${(e as Error)?.message || e}`); + } const event = { id: requestInfo.id, @@ -74,6 +73,7 @@ export const handleBitcoinCashRequest = async ( injectScriptVersion: requestInfo.version, chain: 'bitcoincash', requestInfo, + unsignedTx, type: 'transfer', request: params, status: 'request', @@ -92,12 +92,15 @@ export const handleBitcoinCashRequest = async ( response.signedTx = signedTx; await requestStorage.updateEventById(requestInfo.id, response); - const broadcastResponse = await fetch('https://api.keepkey.info/api/v1/broadcastTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), - }); - let txHash = await broadcastResponse.json(); + let txHash: any = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/broadcastTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), + }, + { timeoutMs: 15000, retries: 1 }, + ); if (txHash.txHash) txHash = txHash.txHash; if (txHash.txid) txHash = txHash.txid; response.txid = txHash; diff --git a/chrome-extension/src/background/chains/bitcoinHandler.ts b/chrome-extension/src/background/chains/bitcoinHandler.ts index 302b569..592cd07 100644 --- a/chrome-extension/src/background/chains/bitcoinHandler.ts +++ b/chrome-extension/src/background/chains/bitcoinHandler.ts @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Chain, ChainToNetworkId, shortListSymbolToCaip, caipToNetworkId } from '../chainConfig'; import * as wallet from '../wallet'; import { createProviderRpcError } from '../utils'; +import { fetchJsonWithTimeout } from '../fetchUtils'; const TAG = ' | bitcoinHandler | '; @@ -62,35 +63,27 @@ export const handleBitcoinRequest = async ( }; console.log(tag, 'Send Payload: ', sendPayload); - // Build UTXO transaction via Pioneer API HTTP call - const buildTx = async function () { - try { - const buildResponse = await fetch('https://api.keepkey.info/api/v1/buildTx', { + // Build the unsigned tx BEFORE creating the approval event so the + // event always carries an unsignedTx the moment the user sees it. + // The previous fire-and-forget pattern raced: if the user approved + // before buildTx resolved, response.unsignedTx was undefined; if + // buildTx finished before addEvent, getEventById returned null. + let unsignedTx: any; + try { + unsignedTx = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/buildTx', + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...sendPayload, pubkeys }), - }); - const unsignedTx = await buildResponse.json(); - console.log(tag, 'unsignedTx: ', unsignedTx); - - const storedEvent = await requestStorage.getEventById(requestInfo.id); - storedEvent.unsignedTx = unsignedTx; - await requestStorage.updateEventById(requestInfo.id, storedEvent); - - chrome.runtime.sendMessage({ - action: 'utxo_build_tx', - unsignedTx: requestInfo, - }); - } catch (e) { - console.error(e); - chrome.runtime.sendMessage({ - action: 'transaction_error', - eventId: requestInfo.id, - error: JSON.stringify(e), - }); - } - }; - buildTx(); + }, + { timeoutMs: 15000, retries: 1 }, + ); + console.log(tag, 'unsignedTx: ', unsignedTx); + } catch (e) { + console.error(tag, 'buildTx failed:', e); + throw createProviderRpcError(4000, `Failed to build transaction: ${(e as Error)?.message || e}`); + } const event = { id: requestInfo.id, @@ -106,6 +99,7 @@ export const handleBitcoinRequest = async ( injectScriptVersion: requestInfo.version, chain: 'bitcoin', requestInfo, + unsignedTx, type: 'transfer', request: params, status: 'request', @@ -131,13 +125,20 @@ export const handleBitcoinRequest = async ( await requestStorage.updateEventById(requestInfo.id, response); try { - // Broadcast via Pioneer API - const broadcastResponse = await fetch('https://api.keepkey.info/api/v1/broadcastTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), - }); - let txHash = await broadcastResponse.json(); + // Broadcast via Pioneer API. fetchJsonWithTimeout enforces an + // explicit response.ok check + retry on 5xx — without that, + // a transient Pioneer hiccup (e.g. node failover) would either + // hang the dApp or surface as a malformed JSON error from the + // raw `await response.json()` below. + let txHash: any = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/broadcastTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), + }, + { timeoutMs: 15000, retries: 1 }, + ); if (txHash.txHash) txHash = txHash.txHash; if (txHash.txid) txHash = txHash.txid; @@ -158,6 +159,11 @@ export const handleBitcoinRequest = async ( eventId: requestInfo.id, error: JSON.stringify(e), }); + // Re-throw so the dApp sees the actual broadcast error. Without + // this the case falls through to `default:` below and the dApp + // gets "Method transfer not supported" instead of the real + // failure (timeout, HTTP 5xx, etc.). + throw e instanceof Error ? e : createProviderRpcError(4000, `Broadcast failed: ${String(e)}`); } } else { throw createProviderRpcError(4200, 'User denied transaction'); diff --git a/chrome-extension/src/background/chains/cosmosHandler.ts b/chrome-extension/src/background/chains/cosmosHandler.ts index 84663c1..0c48255 100644 --- a/chrome-extension/src/background/chains/cosmosHandler.ts +++ b/chrome-extension/src/background/chains/cosmosHandler.ts @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Chain, ChainToNetworkId, shortListSymbolToCaip, caipToNetworkId } from '../chainConfig'; import * as wallet from '../wallet'; import { createProviderRpcError } from '../utils'; +import { fetchJsonWithTimeout } from '../fetchUtils'; const TAG = ' | cosmosHandler | '; @@ -43,16 +44,19 @@ export const handleCosmosRequest = async ( let unsignedTx: any; try { - const buildResponse = await fetch('https://api.keepkey.info/api/v1/buildTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...sendPayload, pubkeys }), - }); - unsignedTx = await buildResponse.json(); + unsignedTx = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/buildTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...sendPayload, pubkeys }), + }, + { timeoutMs: 15000, retries: 1 }, + ); console.log(tag, 'unsignedTx: ', unsignedTx); } catch (e) { console.error(tag, 'buildTx failed:', e); - throw createProviderRpcError(4000, 'Failed to build transaction'); + throw createProviderRpcError(4000, `Failed to build transaction: ${(e as Error)?.message || e}`); } const event = { @@ -90,12 +94,15 @@ export const handleCosmosRequest = async ( response.signedTx = signedTx; await requestStorage.updateEventById(requestInfo.id, response); - const broadcastResponse = await fetch('https://api.keepkey.info/api/v1/broadcastTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), - }); - let txHash = await broadcastResponse.json(); + let txHash: any = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/broadcastTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), + }, + { timeoutMs: 15000, retries: 1 }, + ); if (txHash.txHash) txHash = txHash.txHash; if (txHash.txid) txHash = txHash.txid; diff --git a/chrome-extension/src/background/chains/dashHandler.ts b/chrome-extension/src/background/chains/dashHandler.ts index 798d4e9..f14cd7e 100644 --- a/chrome-extension/src/background/chains/dashHandler.ts +++ b/chrome-extension/src/background/chains/dashHandler.ts @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Chain, ChainToNetworkId, shortListSymbolToCaip, caipToNetworkId } from '../chainConfig'; import * as wallet from '../wallet'; import { createProviderRpcError } from '../utils'; +import { fetchJsonWithTimeout } from '../fetchUtils'; const TAG = ' | dashHandler | '; @@ -41,24 +42,22 @@ export const handleDashRequest = async ( isMax: params[0].isMax, }; - const buildTx = async function () { - try { - const buildResponse = await fetch('https://api.keepkey.info/api/v1/buildTx', { + // Build before approval — see bitcoinHandler for the race rationale. + let unsignedTx: any; + try { + unsignedTx = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/buildTx', + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...sendPayload, pubkeys }), - }); - const unsignedTx = await buildResponse.json(); - const storedEvent = await requestStorage.getEventById(requestInfo.id); - storedEvent.unsignedTx = unsignedTx; - await requestStorage.updateEventById(requestInfo.id, storedEvent); - chrome.runtime.sendMessage({ action: 'utxo_build_tx', unsignedTx: requestInfo }); - } catch (e) { - console.error(e); - chrome.runtime.sendMessage({ action: 'transaction_error', eventId: requestInfo.id, error: JSON.stringify(e) }); - } - }; - buildTx(); + }, + { timeoutMs: 15000, retries: 1 }, + ); + } catch (e) { + console.error(tag, 'buildTx failed:', e); + throw createProviderRpcError(4000, `Failed to build transaction: ${(e as Error)?.message || e}`); + } const event = { id: requestInfo.id, @@ -74,6 +73,7 @@ export const handleDashRequest = async ( injectScriptVersion: requestInfo.version, chain: 'dash', requestInfo, + unsignedTx, type: 'transfer', request: params, status: 'request', @@ -92,12 +92,15 @@ export const handleDashRequest = async ( response.signedTx = signedTx; await requestStorage.updateEventById(requestInfo.id, response); - const broadcastResponse = await fetch('https://api.keepkey.info/api/v1/broadcastTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), - }); - let txHash = await broadcastResponse.json(); + let txHash: any = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/broadcastTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), + }, + { timeoutMs: 15000, retries: 1 }, + ); if (txHash.txHash) txHash = txHash.txHash; if (txHash.txid) txHash = txHash.txid; response.txid = txHash; diff --git a/chrome-extension/src/background/chains/dogecoinHandler.ts b/chrome-extension/src/background/chains/dogecoinHandler.ts index d3306ed..05e681e 100644 --- a/chrome-extension/src/background/chains/dogecoinHandler.ts +++ b/chrome-extension/src/background/chains/dogecoinHandler.ts @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Chain, ChainToNetworkId, shortListSymbolToCaip, caipToNetworkId } from '../chainConfig'; import * as wallet from '../wallet'; import { createProviderRpcError } from '../utils'; +import { fetchJsonWithTimeout } from '../fetchUtils'; const TAG = ' | dogecoinHandler | '; @@ -44,24 +45,22 @@ export const handleDogecoinRequest = async ( isMax: params[0].isMax, }; - const buildTx = async function () { - try { - const buildResponse = await fetch('https://api.keepkey.info/api/v1/buildTx', { + // Build before approval — see bitcoinHandler for the race rationale. + let unsignedTx: any; + try { + unsignedTx = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/buildTx', + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...sendPayload, pubkeys }), - }); - const unsignedTx = await buildResponse.json(); - const storedEvent = await requestStorage.getEventById(requestInfo.id); - storedEvent.unsignedTx = unsignedTx; - await requestStorage.updateEventById(requestInfo.id, storedEvent); - chrome.runtime.sendMessage({ action: 'utxo_build_tx', unsignedTx: requestInfo }); - } catch (e) { - console.error(e); - chrome.runtime.sendMessage({ action: 'transaction_error', eventId: requestInfo.id, error: JSON.stringify(e) }); - } - }; - buildTx(); + }, + { timeoutMs: 15000, retries: 1 }, + ); + } catch (e) { + console.error(tag, 'buildTx failed:', e); + throw createProviderRpcError(4000, `Failed to build transaction: ${(e as Error)?.message || e}`); + } const event = { id: requestInfo.id, @@ -77,6 +76,7 @@ export const handleDogecoinRequest = async ( injectScriptVersion: requestInfo.version, chain: 'dogecoin', requestInfo, + unsignedTx, type: 'transfer', request: params, status: 'request', @@ -95,12 +95,15 @@ export const handleDogecoinRequest = async ( response.signedTx = signedTx; await requestStorage.updateEventById(requestInfo.id, response); - const broadcastResponse = await fetch('https://api.keepkey.info/api/v1/broadcastTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), - }); - let txHash = await broadcastResponse.json(); + let txHash: any = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/broadcastTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), + }, + { timeoutMs: 15000, retries: 1 }, + ); if (txHash.txHash) txHash = txHash.txHash; if (txHash.txid) txHash = txHash.txid; response.txid = txHash; diff --git a/chrome-extension/src/background/chains/ethereumHandler.ts b/chrome-extension/src/background/chains/ethereumHandler.ts index 025ec32..35e912a 100644 --- a/chrome-extension/src/background/chains/ethereumHandler.ts +++ b/chrome-extension/src/background/chains/ethereumHandler.ts @@ -1378,9 +1378,28 @@ const signTypedData = async (params: any, KEEPKEY_WALLET: any, ADDRESS: string, */ const DROP_CHECK_ALARM_PREFIX = 'eth-drop-check-'; +// Map hash → URL that successfully accepted the broadcast. Drop-check +// then queries that exact RPC instead of running getProvider() again, +// which could pick a *different* RPC that never saw the tx — leading to +// false-positive drop warnings (especially after a last-resort +// fallback succeeded). Cleared after the longest scheduled check +// (45s) by performDropCheck. Service-worker restart wipes this; in +// that case we fall back to getProvider() — best effort. +const dropCheckUrlByHash = new Map(); + const performDropCheck = async (hash: string, scheduledDelayMs: number) => { try { - const provider = await getProvider(); + const successUrl = dropCheckUrlByHash.get(hash); + let provider; + if (successUrl) { + // 4s transport timeout: drop-check is best-effort; we'd rather + // miss a check than block the service worker on a slow RPC. + provider = makeStaticProvider(successUrl, 0, { timeoutMs: 4000 }); + } else { + // No bound URL (post-restart, or scheduled before this PR + // landed). Fall back to whichever provider is current. + provider = await getProvider(); + } const tx = await provider.getTransaction(hash); if (tx == null) { console.warn( @@ -1396,10 +1415,14 @@ const performDropCheck = async (hash: string, scheduledDelayMs: number) => { } } catch (e) { console.warn('[DROP-CHECK] failed to query tx', hash, e); + } finally { + // Clean up after the *latest* scheduled check (45s ≥ 8s window). + if (scheduledDelayMs >= 45_000) dropCheckUrlByHash.delete(hash); } }; -const scheduleDropCheck = (hash: string, delayMs: number) => { +const scheduleDropCheck = (hash: string, delayMs: number, successUrl?: string) => { + if (successUrl) dropCheckUrlByHash.set(hash, successUrl); if (delayMs < 30_000) { setTimeout(() => performDropCheck(hash, delayMs), delayMs); return; @@ -1417,7 +1440,9 @@ const scheduleDropCheck = (hash: string, delayMs: number) => { }; // Registered at module load — re-runs on every service-worker startup, -// which is exactly when the alarm fires and wakes the SW. +// which is exactly when the alarm fires and wakes the SW. (URL hint +// is not recovered across SW restart; drop-check falls back to +// getProvider in that case.) if (typeof chrome !== 'undefined' && chrome.alarms?.onAlarm) { chrome.alarms.onAlarm.addListener(alarm => { if (!alarm.name.startsWith(DROP_CHECK_ALARM_PREFIX)) return; @@ -1668,10 +1693,12 @@ const broadcastTransaction = async (signedTx: string, expectedFrom?: string) => ); // Two-stage drop check: 8s catches "never landed in mempool" cases; // 45s catches "landed briefly then evicted". Fire-and-forget; do - // not block the dApp response. + // not block the dApp response. Bind to the URL that ACCEPTED the + // broadcast — querying a different RPC (e.g. the active provider) + // can produce false-positive drop warnings if it never saw the tx. if (txResponse?.hash) { - scheduleDropCheck(txResponse.hash, 8_000); - scheduleDropCheck(txResponse.hash, 45_000); + scheduleDropCheck(txResponse.hash, 8_000, url); + scheduleDropCheck(txResponse.hash, 45_000, url); } return txResponse.hash; } catch (e: any) { @@ -1687,11 +1714,12 @@ const broadcastTransaction = async (signedTx: string, expectedFrom?: string) => if (kind === 'already-known') { // Tx is in mempool somewhere. Use the locally-recovered hash and - // treat as success. + // treat as success. Drop-check binds to *this* URL since it's + // the one that knows about the tx. if (parsedHash) { console.log(tag, `Broadcast on ${url} returned already-known; using parsed hash:`, parsedHash); - scheduleDropCheck(parsedHash, 8_000); - scheduleDropCheck(parsedHash, 45_000); + scheduleDropCheck(parsedHash, 8_000, url); + scheduleDropCheck(parsedHash, 45_000, url); return parsedHash; } // No parsed hash — fall through to next URL. diff --git a/chrome-extension/src/background/chains/litecoinHandler.ts b/chrome-extension/src/background/chains/litecoinHandler.ts index bd6515d..f3b7e7a 100644 --- a/chrome-extension/src/background/chains/litecoinHandler.ts +++ b/chrome-extension/src/background/chains/litecoinHandler.ts @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Chain, ChainToNetworkId, shortListSymbolToCaip, caipToNetworkId } from '../chainConfig'; import * as wallet from '../wallet'; import { createProviderRpcError } from '../utils'; +import { fetchJsonWithTimeout } from '../fetchUtils'; const TAG = ' | litecoinHandler | '; @@ -41,24 +42,22 @@ export const handleLitecoinRequest = async ( isMax: params[0].isMax, }; - const buildTx = async function () { - try { - const buildResponse = await fetch('https://api.keepkey.info/api/v1/buildTx', { + // Build before approval — see bitcoinHandler for the race rationale. + let unsignedTx: any; + try { + unsignedTx = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/buildTx', + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...sendPayload, pubkeys }), - }); - const unsignedTx = await buildResponse.json(); - const storedEvent = await requestStorage.getEventById(requestInfo.id); - storedEvent.unsignedTx = unsignedTx; - await requestStorage.updateEventById(requestInfo.id, storedEvent); - chrome.runtime.sendMessage({ action: 'utxo_build_tx', unsignedTx: requestInfo }); - } catch (e) { - console.error(e); - chrome.runtime.sendMessage({ action: 'transaction_error', eventId: requestInfo.id, error: JSON.stringify(e) }); - } - }; - buildTx(); + }, + { timeoutMs: 15000, retries: 1 }, + ); + } catch (e) { + console.error(tag, 'buildTx failed:', e); + throw createProviderRpcError(4000, `Failed to build transaction: ${(e as Error)?.message || e}`); + } const event = { id: requestInfo.id, @@ -74,6 +73,7 @@ export const handleLitecoinRequest = async ( injectScriptVersion: requestInfo.version, chain: 'litecoin', requestInfo, + unsignedTx, type: 'transfer', request: params, status: 'request', @@ -92,12 +92,15 @@ export const handleLitecoinRequest = async ( response.signedTx = signedTx; await requestStorage.updateEventById(requestInfo.id, response); - const broadcastResponse = await fetch('https://api.keepkey.info/api/v1/broadcastTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), - }); - let txHash = await broadcastResponse.json(); + let txHash: any = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/broadcastTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), + }, + { timeoutMs: 15000, retries: 1 }, + ); if (txHash.txHash) txHash = txHash.txHash; if (txHash.txid) txHash = txHash.txid; response.txid = txHash; diff --git a/chrome-extension/src/background/chains/mayaHandler.ts b/chrome-extension/src/background/chains/mayaHandler.ts index 5b6af87..fc0c265 100644 --- a/chrome-extension/src/background/chains/mayaHandler.ts +++ b/chrome-extension/src/background/chains/mayaHandler.ts @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Chain, ChainToNetworkId, shortListSymbolToCaip, caipToNetworkId } from '../chainConfig'; import * as wallet from '../wallet'; import { createProviderRpcError } from '../utils'; +import { fetchJsonWithTimeout } from '../fetchUtils'; const TAG = ' | mayaHandler | '; @@ -43,16 +44,19 @@ export const handleMayaRequest = async ( let unsignedTx: any; try { - const buildResponse = await fetch('https://api.keepkey.info/api/v1/buildTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...sendPayload, pubkeys }), - }); - unsignedTx = await buildResponse.json(); + unsignedTx = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/buildTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...sendPayload, pubkeys }), + }, + { timeoutMs: 15000, retries: 1 }, + ); console.log(tag, 'unsignedTx: ', unsignedTx); } catch (e) { console.error(tag, 'buildTx failed:', e); - throw createProviderRpcError(4000, 'Failed to build transaction'); + throw createProviderRpcError(4000, `Failed to build transaction: ${(e as Error)?.message || e}`); } const event = { @@ -90,12 +94,15 @@ export const handleMayaRequest = async ( response.signedTx = signedTx; await requestStorage.updateEventById(requestInfo.id, response); - const broadcastResponse = await fetch('https://api.keepkey.info/api/v1/broadcastTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), - }); - let txHash = await broadcastResponse.json(); + let txHash: any = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/broadcastTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), + }, + { timeoutMs: 15000, retries: 1 }, + ); if (txHash.txHash) txHash = txHash.txHash; if (txHash.txid) txHash = txHash.txid; diff --git a/chrome-extension/src/background/chains/osmosisHandler.ts b/chrome-extension/src/background/chains/osmosisHandler.ts index 36ea0ef..6dbc92f 100644 --- a/chrome-extension/src/background/chains/osmosisHandler.ts +++ b/chrome-extension/src/background/chains/osmosisHandler.ts @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Chain, ChainToNetworkId, shortListSymbolToCaip, caipToNetworkId } from '../chainConfig'; import * as wallet from '../wallet'; import { createProviderRpcError } from '../utils'; +import { fetchJsonWithTimeout } from '../fetchUtils'; const TAG = ' | osmosisHandler | '; @@ -43,16 +44,19 @@ export const handleOsmosisRequest = async ( let unsignedTx: any; try { - const buildResponse = await fetch('https://api.keepkey.info/api/v1/buildTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...sendPayload, pubkeys }), - }); - unsignedTx = await buildResponse.json(); + unsignedTx = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/buildTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...sendPayload, pubkeys }), + }, + { timeoutMs: 15000, retries: 1 }, + ); console.log(tag, 'unsignedTx: ', unsignedTx); } catch (e) { console.error(tag, 'buildTx failed:', e); - throw createProviderRpcError(4000, 'Failed to build transaction'); + throw createProviderRpcError(4000, `Failed to build transaction: ${(e as Error)?.message || e}`); } const event = { @@ -90,12 +94,15 @@ export const handleOsmosisRequest = async ( response.signedTx = signedTx; await requestStorage.updateEventById(requestInfo.id, response); - const broadcastResponse = await fetch('https://api.keepkey.info/api/v1/broadcastTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), - }); - let txHash = await broadcastResponse.json(); + let txHash: any = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/broadcastTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), + }, + { timeoutMs: 15000, retries: 1 }, + ); if (txHash.txHash) txHash = txHash.txHash; if (txHash.txid) txHash = txHash.txid; diff --git a/chrome-extension/src/background/chains/rippleHandler.ts b/chrome-extension/src/background/chains/rippleHandler.ts index d9c81ba..88b4775 100644 --- a/chrome-extension/src/background/chains/rippleHandler.ts +++ b/chrome-extension/src/background/chains/rippleHandler.ts @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Chain, ChainToNetworkId, shortListSymbolToCaip, caipToNetworkId } from '../chainConfig'; import * as wallet from '../wallet'; import { createProviderRpcError } from '../utils'; +import { fetchJsonWithTimeout } from '../fetchUtils'; const TAG = ' | rippleHandler | '; @@ -43,16 +44,19 @@ export const handleRippleRequest = async ( let unsignedTx: any; try { - const buildResponse = await fetch('https://api.keepkey.info/api/v1/buildTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...sendPayload, pubkeys }), - }); - unsignedTx = await buildResponse.json(); + unsignedTx = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/buildTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...sendPayload, pubkeys }), + }, + { timeoutMs: 15000, retries: 1 }, + ); console.log(tag, 'unsignedTx: ', unsignedTx); } catch (e) { console.error(tag, 'buildTx failed:', e); - throw createProviderRpcError(4000, 'Failed to build transaction'); + throw createProviderRpcError(4000, `Failed to build transaction: ${(e as Error)?.message || e}`); } const event = { @@ -90,12 +94,15 @@ export const handleRippleRequest = async ( response.signedTx = signedTx; await requestStorage.updateEventById(requestInfo.id, response); - const broadcastResponse = await fetch('https://api.keepkey.info/api/v1/broadcastTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), - }); - let txHash = await broadcastResponse.json(); + let txHash: any = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/broadcastTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), + }, + { timeoutMs: 15000, retries: 1 }, + ); if (txHash.txHash) txHash = txHash.txHash; if (txHash.txid) txHash = txHash.txid; diff --git a/chrome-extension/src/background/chains/rpcFailover.ts b/chrome-extension/src/background/chains/rpcFailover.ts new file mode 100644 index 0000000..c53f154 --- /dev/null +++ b/chrome-extension/src/background/chains/rpcFailover.ts @@ -0,0 +1,129 @@ +/** + * EVM RPC failover for read-style calls keyed by networkId. + * + * The active-provider failover (`withRpcFailover` in ethereumHandler.ts) + * iterates the URLs the user is currently connected to. This module + * covers the *read* sites that resolve a networkId on demand — + * GET_ASSET_BALANCE, GET_EVM_BALANCE, VALIDATE_ERC20_TOKEN — where the + * caller passes "give me a working RPC for chain X" rather than "use + * whatever the user picked". + * + * Priority order matches the rest of the codebase: + * 1. user override (custom RPC from Add Network UI / blockchainDataStorage) + * 2. Pioneer-discovered URLs (registry.getChainInfo) + * 3. last-resort hardcoded list (lastResortRpcs) + * + * Per-attempt timeout via makeStaticProvider's transport-level config + * stops a hung URL from stalling the loop. + */ + +import type { JsonRpcProvider } from 'ethers'; +import { blockchainDataStorage } from '@extension/storage'; +import { getChainInfo, makeStaticProvider } from './registry'; +import { getLastResortRpcs } from './lastResortRpcs'; + +const FAILED_RPC_COOLDOWN_MS = 60_000; +// Distinct from ethereumHandler's failedRpcs map. The active-provider +// path and the by-networkId reads have different rate-limit blast +// radii (active provider = current chain only; reads = any chain), so +// keeping the cooldowns independent prevents one slow network from +// blocking the other. +const failedRpcs = new Map(); + +const isTransientRpcError = (errMsg: string): boolean => { + const m = errMsg.toLowerCase(); + return ( + m.includes('rate limit') || + m.includes('throttle') || + m.includes('429') || + m.includes('timeout') || + m.includes('econnreset') || + m.includes('etimedout') || + m.includes('network') || + m.includes('server_error') || + m.includes('exceeded maximum retry') || + /\b5\d{2}\b/.test(m) // 5xx + ); +}; + +async function buildCandidates(networkId: string): Promise { + const customChain = await blockchainDataStorage.getBlockchainData(networkId); + const customUrls: string[] = + customChain?.providers && customChain.providers.length > 0 + ? customChain.providers + : customChain?.providerUrl + ? [customChain.providerUrl] + : []; + const pioneer = await getChainInfo(networkId); + const pioneerUrls: string[] = pioneer?.rpcs || []; + const lastResort = getLastResortRpcs(networkId); + + const seen = new Set(); + const ordered: string[] = []; + for (const u of [...customUrls, ...pioneerUrls, ...lastResort]) { + const t = (u || '').trim(); + if (t && !seen.has(t)) { + seen.add(t); + ordered.push(t); + } + } + return ordered; +} + +function applyCooldown(candidates: string[]): string[] { + const now = Date.now(); + for (const [url, failedAt] of failedRpcs) { + if (now - failedAt >= FAILED_RPC_COOLDOWN_MS) failedRpcs.delete(url); + } + const available = candidates.filter(url => { + const failedAt = failedRpcs.get(url); + return !(failedAt && now - failedAt < FAILED_RPC_COOLDOWN_MS); + }); + // If every candidate is cooling, clear and try them all rather than + // hard-failing — the same convention as ethereumHandler's getProvider. + if (available.length === 0 && candidates.length > 0) { + failedRpcs.clear(); + return candidates.slice(); + } + return available; +} + +/** + * Run an RPC operation with failover across the candidate list for + * `networkId`. Definitive errors (revert, invalid params) surface + * immediately. Transient errors (rate limit, 5xx, network, timeout) + * fail over to the next URL. + */ +export async function withRpcFailoverByNetworkId( + networkId: string, + op: (provider: JsonRpcProvider, url: string) => Promise, + options?: { timeoutMs?: number }, +): Promise { + const candidates = await buildCandidates(networkId); + const available = applyCooldown(candidates); + if (available.length === 0) { + throw new Error(`No RPC URLs available for ${networkId}`); + } + + const errors: { url: string; error: string }[] = []; + let lastErr: unknown = null; + const now = Date.now(); + for (const url of available) { + try { + const provider = makeStaticProvider(url, networkId, { timeoutMs: options?.timeoutMs ?? 5000 }); + return await op(provider, url); + } catch (e: any) { + const errMsg = String(e?.message || e); + if (!isTransientRpcError(errMsg)) { + // Definitive — won't help to try another RPC. + throw e; + } + console.warn(`[rpcFailover] ${networkId} ${url} transient failure, trying next:`, errMsg); + errors.push({ url, error: errMsg }); + failedRpcs.set(url, now); + lastErr = e; + } + } + if (lastErr) throw lastErr; + throw new Error(`All ${available.length} RPC endpoints failed for ${networkId}`); +} diff --git a/chrome-extension/src/background/chains/solanaHandler.ts b/chrome-extension/src/background/chains/solanaHandler.ts index 18ff181..98594a6 100644 --- a/chrome-extension/src/background/chains/solanaHandler.ts +++ b/chrome-extension/src/background/chains/solanaHandler.ts @@ -487,42 +487,190 @@ function bytesToHex(bytes: Uint8Array | number[]): string { return out; } +/** + * Encode bytes to base58 (Bitcoin alphabet — same as Solana). Pairs + * with the existing `base58Decode` defined for the tx-builder path + * above; both share `BASE58_ALPHABET`. Used in the rare "already + * processed" broadcast-recovery path to derive the tx signature + * locally from the signed bytes. + */ +function bytesToBase58(bytes: Uint8Array): string { + if (bytes.length === 0) return ''; + let zeros = 0; + while (zeros < bytes.length && bytes[zeros] === 0) zeros++; + const digits: number[] = [0]; + for (let i = zeros; i < bytes.length; i++) { + let carry = bytes[i]; + for (let j = 0; j < digits.length; j++) { + carry += digits[j] << 8; + digits[j] = carry % 58; + carry = (carry / 58) | 0; + } + while (carry > 0) { + digits.push(carry % 58); + carry = (carry / 58) | 0; + } + } + let out = ''; + for (let i = 0; i < zeros; i++) out += '1'; + for (let i = digits.length - 1; i >= 0; i--) out += BASE58_ALPHABET[digits[i]]; + return out; +} + +/** + * A signed Solana transaction's first 64 bytes after the signature + * count are the first signature, which IS the transaction's canonical + * signature (and what `sendTransaction` returns). We can derive it + * locally without an RPC round-trip — useful when an RPC reports + * "already processed" (the tx is in mempool somewhere; the dApp still + * needs the signature). + * + * Layout: [compact-u16 sig_count] [64-byte sig × N] [message] + * For the >253 sig case the count is multi-byte, but real txs almost + * always have 1–3 sigs (one byte). We read defensively just in case. + */ +function extractFirstSignatureBase58(signedTxBase64: string): string | null { + try { + const bin = atob(signedTxBase64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + if (bytes.length < 65) return null; + // compact-u16: each byte uses low 7 bits + continuation bit. Start + // by skipping continuation bytes to find the sig array offset. + let cursor = 0; + while (cursor < bytes.length && (bytes[cursor] & 0x80) !== 0 && cursor < 3) cursor++; + cursor++; // include the final length byte + if (bytes.length < cursor + 64) return null; + return bytesToBase58(bytes.slice(cursor, cursor + 64)); + } catch { + return null; + } +} + +/** + * Classify a Solana sendTransaction error. + * + * - 'transient' → rate limit / network / 5xx, AND a few RPC-state + * quirks ("blockhash not found" can be RPC + * freshness for dApp-supplied txs; "account in + * use" can be a transient race) — try next URL. + * - 'already-processed' → tx is already in mempool / processed. + * Treat as success; pull sig from signed bytes. + * - 'definitive' → tx-level reject (insufficient funds, signature + * verification, block height exceeded — the + * blockhash window has truly closed). + */ +type SolanaBroadcastErrorKind = 'transient' | 'already-processed' | 'definitive'; +function classifySolanaBroadcastError(msg: string): SolanaBroadcastErrorKind { + const m = msg.toLowerCase(); + if (m.includes('already processed')) return 'already-processed'; + if ( + m.includes('insufficient funds') || + m.includes('insufficient lamports') || + m.includes('block height exceeded') || + m.includes('invalid signature') || + m.includes('signature verification') + ) { + return 'definitive'; + } + // Everything else — rate limit / network / 5xx, plus 'blockhash not + // found' (RPC freshness) and 'account in use' (transient race) — is + // worth trying the next URL. + return 'transient'; +} + /** * Broadcast a signed Solana transaction via Solana JSON-RPC. * Vault has NO broadcast endpoint — we send directly to Solana RPC. + * + * Iterates SOLANA_RPC_URLS on transient failures. Health-checked URLs + * sometimes pass `getHealth` but reject `sendTransaction` (rate-limit, + * regional throttling), so the failover loop reaches further than the + * pre-flight selection in `getSolanaRpcUrl`. */ async function broadcastTransaction(signedTxBase64: string): Promise { - const rpcUrl = await getSolanaRpcUrl(); - let response: Response; - try { - response = await fetch(rpcUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'sendTransaction', - params: [signedTxBase64, { encoding: 'base64' }], - }), - signal: AbortSignal.timeout(30000), - }); - } catch (e: any) { - if (e.name === 'TimeoutError' || e.name === 'AbortError') { - throw createTimeoutError('Solana RPC broadcast timed out'); + const errors: { url: string; error: string }[] = []; + // Try the cached/healthy URL first, then any others not yet attempted. + const primary = await getSolanaRpcUrl(); + const ordered = [primary, ...SOLANA_RPC_URLS.filter(u => u !== primary)]; + + for (const rpcUrl of ordered) { + let response: Response; + try { + response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'sendTransaction', + params: [signedTxBase64, { encoding: 'base64' }], + }), + signal: AbortSignal.timeout(15000), + }); + } catch (e: any) { + const errMsg = e.name === 'TimeoutError' || e.name === 'AbortError' ? 'broadcast timed out' : e.message; + errors.push({ url: rpcUrl, error: errMsg }); + // Network/timeout — invalidate the cached health pick so the + // next caller will retest and try another candidate first. + cachedRpcUrl = null; + continue; } - throw createProviderRpcError(-32603, `Solana RPC connection failed: ${e.message}`); - } - if (!response.ok) { - throw createProviderRpcError(-32603, `Solana RPC broadcast failed: ${response.status}`); - } + if (!response.ok) { + // 4xx is definitive (bad request / signature). 5xx + 429 are transient. + const errMsg = `HTTP ${response.status}`; + if (response.status >= 500 || response.status === 429) { + errors.push({ url: rpcUrl, error: errMsg }); + cachedRpcUrl = null; + continue; + } + throw createProviderRpcError(-32603, `Solana RPC broadcast failed: ${errMsg}`); + } + + const result = await response.json(); + if (result.error) { + const errMsg = result.error.message || JSON.stringify(result.error); + const kind = classifySolanaBroadcastError(errMsg); + + if (kind === 'already-processed') { + // Tx is already in mempool / processed somewhere. Recover the + // signature from the signed bytes (it's deterministic; first + // sig of the signed tx == tx signature). Returning preserves + // dApp UX: the user sees a successful send and polls the + // signature normally. + const sig = extractFirstSignatureBase58(signedTxBase64); + if (sig) { + console.log(`[solana broadcast] ${rpcUrl} reports already-processed; using extracted sig ${sig}`); + return sig; + } + // Fallback: extraction failed (malformed signedTx). Treat as + // transient — maybe another RPC has the signature stored. + console.warn(`[solana broadcast] ${rpcUrl} already-processed but sig extraction failed; trying next URL`); + errors.push({ url: rpcUrl, error: errMsg }); + cachedRpcUrl = null; + continue; + } + + if (kind === 'transient') { + errors.push({ url: rpcUrl, error: errMsg }); + cachedRpcUrl = null; + continue; + } + // Definitive — won't recover on another RPC. + throw createProviderRpcError(-32603, `Solana RPC error: ${errMsg}`); + } - const result = await response.json(); - if (result.error) { - throw createProviderRpcError(-32603, `Solana RPC error: ${result.error.message}`); + return result.result; // transaction signature (base58) } - return result.result; // transaction signature (base58) + // All candidates failed transient. + console.error('[solana broadcast] all RPCs failed:', errors); + const last = errors[errors.length - 1]?.error || 'unknown'; + if (/timed out|timeout/i.test(last)) { + throw createTimeoutError('Solana RPC broadcast timed out'); + } + throw createProviderRpcError(-32603, `All ${ordered.length} Solana RPCs failed broadcast: ${last}`); } export const handleSolanaRequest = async ( diff --git a/chrome-extension/src/background/chains/thorchainHandler.ts b/chrome-extension/src/background/chains/thorchainHandler.ts index 54c5aa5..fed1053 100644 --- a/chrome-extension/src/background/chains/thorchainHandler.ts +++ b/chrome-extension/src/background/chains/thorchainHandler.ts @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Chain, ChainToNetworkId, shortListSymbolToCaip, caipToNetworkId } from '../chainConfig'; import * as wallet from '../wallet'; import { createProviderRpcError } from '../utils'; +import { fetchJsonWithTimeout } from '../fetchUtils'; const TAG = ' | thorchainHandler | '; @@ -41,19 +42,24 @@ export const handleThorchainRequest = async ( isMax: params[0].isMax, }; - // Build tx via Pioneer API + // Build tx via Pioneer API. fetchJsonWithTimeout enforces an + // AbortSignal + response.ok check + retry on 5xx — without that, + // a transient Pioneer hiccup hangs the dApp. let unsignedTx: any; try { - const buildResponse = await fetch('https://api.keepkey.info/api/v1/buildTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...sendPayload, pubkeys }), - }); - unsignedTx = await buildResponse.json(); + unsignedTx = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/buildTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...sendPayload, pubkeys }), + }, + { timeoutMs: 15000, retries: 1 }, + ); console.log(tag, 'unsignedTx: ', unsignedTx); } catch (e) { console.error(tag, 'buildTx failed:', e); - throw createProviderRpcError(4000, 'Failed to build transaction'); + throw createProviderRpcError(4000, `Failed to build transaction: ${(e as Error)?.message || e}`); } const event = { @@ -91,13 +97,16 @@ export const handleThorchainRequest = async ( response.signedTx = signedTx; await requestStorage.updateEventById(requestInfo.id, response); - // Broadcast via Pioneer API - const broadcastResponse = await fetch('https://api.keepkey.info/api/v1/broadcastTx', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), - }); - let txHash = await broadcastResponse.json(); + // Broadcast via Pioneer API. + let txHash: any = await fetchJsonWithTimeout( + 'https://api.keepkey.info/api/v1/broadcastTx', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ caip, signedTx: signedTx.serializedTx || signedTx }), + }, + { timeoutMs: 15000, retries: 1 }, + ); if (txHash.txHash) txHash = txHash.txHash; if (txHash.txid) txHash = txHash.txid; diff --git a/chrome-extension/src/background/fetchUtils.ts b/chrome-extension/src/background/fetchUtils.ts new file mode 100644 index 0000000..8de7dd1 --- /dev/null +++ b/chrome-extension/src/background/fetchUtils.ts @@ -0,0 +1,95 @@ +/** + * Shared fetch helpers for background-side network calls. + * + * Two reasons this exists: + * + * 1. **Timeouts.** Default `fetch()` has no timeout. A stalled HTTP + * request can hang the awaiting code path indefinitely — the most + * visible failure mode is the popup / portfolio loader sitting at a + * spinner forever when Pioneer or a localhost service is degraded. + * + * 2. **Transient retries.** Pioneer (and similar single-endpoint + * services) occasionally return 5xx or transient network errors. + * A small retry budget eats those without bothering the user. + * + * Use: + * - `fetchJsonWithTimeout` for endpoints whose response we parse as + * JSON (the common case in this codebase). + * - `fetchWithTimeout` when you need the raw `Response` (status + * check, streaming, etc.). + */ + +export interface FetchOptions { + /** Total per-attempt budget. Default 8000ms. */ + timeoutMs?: number; + /** Number of retry attempts on transient errors (5xx, network). 0 = no retry. Default 0. */ + retries?: number; + /** Override what counts as "transient" if you have endpoint-specific knowledge. */ + retryOn?: (status: number) => boolean; +} + +const DEFAULT_TIMEOUT_MS = 8000; +const DEFAULT_RETRIES = 0; + +const isTransientStatus = (status: number) => status >= 500 && status < 600; + +/** + * fetch with a per-attempt AbortSignal.timeout and optional retry on + * transient errors. Throws on the final failure. + */ +export async function fetchWithTimeout( + url: string, + init: RequestInit = {}, + options: FetchOptions = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const maxAttempts = (options.retries ?? DEFAULT_RETRIES) + 1; + const retryOn = options.retryOn ?? isTransientStatus; + let lastErr: unknown; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const resp = await fetch(url, { + ...init, + signal: init.signal ?? AbortSignal.timeout(timeoutMs), + }); + // Retry only on caller-classified transient statuses. Definitive + // errors (4xx) are returned to the caller for normal handling. + if (!resp.ok && retryOn(resp.status) && attempt < maxAttempts) { + const backoffMs = 200 * 2 ** (attempt - 1); + console.warn(`[fetchWithTimeout] ${url} attempt ${attempt} got ${resp.status}; retrying in ${backoffMs}ms`); + await new Promise(r => setTimeout(r, backoffMs)); + continue; + } + return resp; + } catch (e: any) { + lastErr = e; + // AbortError (timeout) and network errors are worth retrying. + if (attempt < maxAttempts) { + const backoffMs = 200 * 2 ** (attempt - 1); + console.warn(`[fetchWithTimeout] ${url} attempt ${attempt} threw ${e?.name || ''}; retrying in ${backoffMs}ms`); + await new Promise(r => setTimeout(r, backoffMs)); + continue; + } + } + } + throw lastErr; +} + +/** + * fetch + parse JSON with timeout + retry. Throws on transport + * failure, non-OK response, or JSON parse failure. Caller doesn't have + * to remember to check `resp.ok` separately. + */ +export async function fetchJsonWithTimeout( + url: string, + init: RequestInit = {}, + options: FetchOptions = {}, +): Promise { + const resp = await fetchWithTimeout(url, init, options); + if (!resp.ok) { + const body = await resp.text().catch(() => ''); + throw new Error(`HTTP ${resp.status} from ${url}: ${body}`); + } + return (await resp.json()) as T; +} diff --git a/chrome-extension/src/background/index.ts b/chrome-extension/src/background/index.ts index 63fea92..dd07c5b 100644 --- a/chrome-extension/src/background/index.ts +++ b/chrome-extension/src/background/index.ts @@ -13,6 +13,7 @@ import { resetTonState, prefetchTonAddress } from './chains/tonHandler'; import { resetTronState, prefetchTronPubkey } from './chains/tronHandler'; import { handleWalletRequest } from './methods'; import { setApprovalBadge } from './popup'; +import { fetchJsonWithTimeout } from './fetchUtils'; import { JsonRpcProvider, formatEther } from 'ethers'; import { ChainToNetworkId, Chain, COIN_MAP_LONG, shortListSymbolToCaip, NetworkIdToChain } from './chainConfig'; import { @@ -26,6 +27,7 @@ import { customEvmNetworksStorage, } from '@extension/storage'; import { getChainInfo, makeStaticProvider } from './chains/registry'; +import { withRpcFailoverByNetworkId } from './chains/rpcFailover'; import { formatUserError } from './utils'; import { filterSpamTokens } from './spamFilter'; @@ -172,10 +174,19 @@ async function handleDeviceSwitch(newDeviceInfo: any) { } } +// Singleflight guard: a stalled localhost:1646 request would otherwise +// stack probes every 5s, generating overlapping async work and stale +// state transitions. If the previous tick is still running, skip this +// one. Combined with AbortSignal.timeout(3000) below, the worst case is +// one stalled request hung for 3s before the next tick can run. +let healthPollInflight = false; + async function checkKeepKey() { + if (healthPollInflight) return; + healthPollInflight = true; const prevState = KEEPKEY_STATE; try { - const response = await fetch('http://localhost:1646/docs'); + const response = await fetch('http://localhost:1646/docs', { signal: AbortSignal.timeout(3000) }); if (response.ok) { if (KEEPKEY_STATE < 2) { KEEPKEY_STATE = 2; // Set state to connected @@ -239,6 +250,8 @@ async function checkKeepKey() { KEEPKEY_STATE = 4; // Set state to errored updateIcon(); if (KEEPKEY_STATE !== prevState) pushStateChangeEvent(); + } finally { + healthPollInflight = false; } } @@ -368,23 +381,25 @@ async function fetchBalancesFromPioneer(forceRefresh = false): Promise { if (batch.length === 0) return { balances: [] as any[], tokens: [] as any[] }; try { const url = `${PIONEER_API}/api/v1/portfolio${forceRefresh ? '?forceRefresh=true' : ''}`; - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - // Pioneer's api_key security reads the Authorization header - // verbatim (no Bearer prefix). Any unique `key:public-*` - // string works for anonymous reads; the timestamp is just - // a cache-busting nonce. - Authorization: `key:public-${Date.now()}`, + // 12s budget: portfolio is the heaviest Pioneer endpoint + // (cold token discovery), so default 8s is too tight on first + // load. One retry on 5xx absorbs single transient failures. + const json = await fetchJsonWithTimeout( + url, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // Pioneer's api_key security reads the Authorization + // header verbatim (no Bearer prefix). Any unique + // `key:public-*` string works for anonymous reads; the + // timestamp is just a cache-busting nonce. + Authorization: `key:public-${Date.now()}`, + }, + body: JSON.stringify({ pubkeys: batch }), }, - body: JSON.stringify({ pubkeys: batch }), - }); - if (!response.ok) { - console.warn(`[fetchBalances] portfolio returned ${response.status}`); - return { balances: [] as any[], tokens: [] as any[] }; - } - const json = await response.json(); + { timeoutMs: 12000, retries: 1 }, + ); // Unwrap: /portfolio returns { balances: [...] } at the top // level; some deployments wrap in { data: { balances } } via // middleware, so handle both. @@ -869,12 +884,15 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a if (!tx) throw new Error('Invalid request: missing tx'); if (!source) throw new Error('Invalid request: missing source'); - const response = await fetch(`${PIONEER_API}/api/v1/insight`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tx, source }), - }); - const result = await response.json(); + const result = await fetchJsonWithTimeout( + `${PIONEER_API}/api/v1/insight`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tx, source }), + }, + { timeoutMs: 8000, retries: 1 }, + ); console.log(tag, 'GET_TX_INSIGHT result:', result); sendResponse(result); } catch (error: any) { @@ -1338,23 +1356,14 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a console.log(tag, 'GET_ASSET_BALANCE'); const { networkId } = message; - // User overrides (Add Network UI / custom RPC) win over Pioneer. - let rpcUrl: string | null = null; - const customChain = await blockchainDataStorage.getBlockchainData(networkId); - if (customChain?.providerUrl) { - rpcUrl = customChain.providerUrl; - } else { - const chainInfo = await getChainInfo(networkId); - if (chainInfo) rpcUrl = chainInfo.rpc; - } - - if (rpcUrl && ADDRESS) { - const evmProvider = makeStaticProvider(rpcUrl, networkId); - const balance = await evmProvider.getBalance(ADDRESS); - sendResponse('0x' + balance.toString(16)); - } else { + if (!ADDRESS) { sendResponse('0'); + break; } + // Fail over across user-override → Pioneer → last-resort if + // any candidate rate-limits or 5xx's. + const balance = await withRpcFailoverByNetworkId(networkId, p => p.getBalance(ADDRESS)); + sendResponse('0x' + balance.toString(16)); } catch (error) { console.error('Error fetching balance:', error); sendResponse({ error: 'Failed to fetch balance' }); @@ -1366,8 +1375,11 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a try { const { networkId } = message; const chainId = networkId.replace('eip155:', ''); - const response = await fetch(`${PIONEER_API}/api/v1/nodes?chainId=${encodeURIComponent(chainId)}`); - const data = await response.json(); + const data = await fetchJsonWithTimeout( + `${PIONEER_API}/api/v1/nodes?chainId=${encodeURIComponent(chainId)}`, + {}, + { timeoutMs: 5000, retries: 1 }, + ); sendResponse(data); } catch (error) { console.error('Error fetching asset info:', error); @@ -1467,35 +1479,28 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a break; } - // Find RPC URL — try custom chains first, then static list - let rpcUrl: string | undefined; + // Resolve display metadata. The actual RPC call goes through + // withRpcFailoverByNetworkId below, which iterates the same + // priority list (custom → Pioneer → last-resort) on transient + // failure. If neither custom nor Pioneer knows the chain, the + // failover helper itself throws — caught and surfaced. let chainName = evmNetworkId; let chainSymbol = 'ETH'; - const customChain = await blockchainDataStorage.getBlockchainData(evmNetworkId); - if (customChain?.providerUrl) { - rpcUrl = customChain.providerUrl; + if (customChain) { chainName = customChain.name || evmNetworkId; chainSymbol = customChain.nativeCurrency?.symbol || customChain.symbol || 'ETH'; } else { const pioneerChain = await getChainInfo(evmNetworkId); - if (pioneerChain?.rpc) { - rpcUrl = pioneerChain.rpc; + if (pioneerChain) { chainName = pioneerChain.name; chainSymbol = pioneerChain.symbol || 'ETH'; } } - if (!rpcUrl) { - sendResponse({ balance: '0', valueUsd: '0', error: 'No RPC for network' }); - break; - } - - const rpcProvider = makeStaticProvider(rpcUrl, evmNetworkId); - const rawBal = await Promise.race([ - rpcProvider.getBalance(evmAddress), - new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 8000)), - ]); + const rawBal = await withRpcFailoverByNetworkId(evmNetworkId, p => p.getBalance(evmAddress), { + timeoutMs: 8000, + }); const balStr = formatEther(rawBal); // Try to get USD price from cached balances for this network @@ -1560,12 +1565,15 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a const payload: any = { networkId, contractAddress }; if (userAddress) payload.userAddress = userAddress; - const response = await fetch(`${PIONEER_API}/api/v1/tokens/metadata`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - const data = await response.json(); + const data = await fetchJsonWithTimeout( + `${PIONEER_API}/api/v1/tokens/metadata`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + { timeoutMs: 8000, retries: 1 }, + ); sendResponse({ success: true, data }); } catch (error: any) { console.error('Error looking up token metadata:', error); @@ -1581,24 +1589,27 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a throw new Error('userAddress and token are required'); } - const response = await fetch(`${PIONEER_API}/api/v1/tokens/custom`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - userAddress, - token: { - networkId: token.networkId, - address: token.address, - caip: token.caip, - name: token.name, - symbol: token.symbol, - decimals: token.decimals, - icon: token.icon, - coingeckoId: token.coingeckoId, - }, - }), - }); - const data = await response.json(); + const data = await fetchJsonWithTimeout( + `${PIONEER_API}/api/v1/tokens/custom`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userAddress, + token: { + networkId: token.networkId, + address: token.address, + caip: token.caip, + name: token.name, + symbol: token.symbol, + decimals: token.decimals, + icon: token.icon, + coingeckoId: token.coingeckoId, + }, + }), + }, + { timeoutMs: 8000, retries: 1 }, + ); sendResponse({ success: data?.success || false, data }); } catch (error: any) { console.error('Error adding custom token:', error); @@ -1617,8 +1628,7 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a let url = `${PIONEER_API}/api/v1/tokens/custom?userAddress=${encodeURIComponent(userAddress)}`; if (networkId) url += `&networkId=${encodeURIComponent(networkId)}`; - const response = await fetch(url); - const data = await response.json(); + const data = await fetchJsonWithTimeout(url, {}, { timeoutMs: 8000, retries: 1 }); const tokens = data?.data?.tokens || data?.tokens || []; sendResponse({ success: true, tokens }); } catch (error: any) { @@ -1635,10 +1645,11 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a throw new Error('networkId and address are required'); } - const response = await fetch( + const data = await fetchJsonWithTimeout( `${PIONEER_API}/api/v1/tokens/balances?networkId=${encodeURIComponent(networkId)}&address=${encodeURIComponent(address)}`, + {}, + { timeoutMs: 8000, retries: 1 }, ); - const data = await response.json(); const tokens = data?.data?.tokens || data?.tokens || []; sendResponse({ success: true, tokens }); } catch (error: any) { @@ -1655,12 +1666,15 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a throw new Error('userAddress, networkId, and tokenAddress are required'); } - const response = await fetch(`${PIONEER_API}/api/v1/tokens/custom`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ userAddress, networkId, tokenAddress }), - }); - const data = await response.json(); + const data = await fetchJsonWithTimeout( + `${PIONEER_API}/api/v1/tokens/custom`, + { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userAddress, networkId, tokenAddress }), + }, + { timeoutMs: 8000, retries: 1 }, + ); sendResponse({ success: data?.success || false, data }); } catch (error: any) { console.error('Error removing custom token:', error); @@ -1683,23 +1697,6 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a break; } - // User overrides win over Pioneer for token validation too — - // contract calls must go through the same RPC the user picked. - let rpcUrl: string | null = null; - const customChain = await blockchainDataStorage.getBlockchainData(networkId); - if (customChain?.providerUrl) { - rpcUrl = customChain.providerUrl; - } else { - const chainInfo = await getChainInfo(networkId); - if (chainInfo) rpcUrl = chainInfo.rpc; - } - if (!rpcUrl) { - sendResponse({ valid: false, error: 'Unsupported network' }); - break; - } - - const rpcProvider = makeStaticProvider(rpcUrl, networkId); - // ERC-20 ABI for name, symbol, and decimals const ERC20_ABI = [ 'function name() view returns (string)', @@ -1708,13 +1705,25 @@ chrome.runtime.onMessage.addListener((message: any, sender: any, sendResponse: a ]; const { Contract } = await import('ethers'); - const tokenContract = new Contract(contractAddress, ERC20_ABI, rpcProvider); - const [name, symbol, decimals] = await Promise.all([ - tokenContract.name(), - tokenContract.symbol(), - tokenContract.decimals(), - ]); + // Run all three reads against the same provider per failover + // attempt — splitting them across providers would risk a + // partial result if one URL rate-limits mid-validation. + // ERC20 reads are read-only views; if they fail, the next + // candidate URL gets the whole bundle. + const { name, symbol, decimals } = await withRpcFailoverByNetworkId( + networkId, + async rpcProvider => { + const tokenContract = new Contract(contractAddress, ERC20_ABI, rpcProvider); + const [n, s, d] = await Promise.all([ + tokenContract.name(), + tokenContract.symbol(), + tokenContract.decimals(), + ]); + return { name: n, symbol: s, decimals: d }; + }, + { timeoutMs: 8000 }, + ); const caip = `${networkId}/erc20:${contractAddress.toLowerCase()}`; diff --git a/chrome-extension/src/injected/tron-provider.ts b/chrome-extension/src/injected/tron-provider.ts index f3e541e..acafe01 100644 --- a/chrome-extension/src/injected/tron-provider.ts +++ b/chrome-extension/src/injected/tron-provider.ts @@ -133,18 +133,50 @@ function promisifyRequest(walletRequest: WalletRequestFn, method: string, params * TronGrid directly rather than routing through the extension — there's * nothing sensitive about reading chain state, and keeping the round-trip * short matters for dApp UX. + * + * Per-request timeout + one retry on 5xx/transient errors so a stalled + * TronGrid edge can't hang dApp flows (most painful on broadcasts via + * /wallet/broadcasttransaction). Lives inline because this file is + * bundled into the injected script — it can't import from the + * background's fetchUtils. */ +const TRONGRID_TIMEOUT_MS = 8000; +const TRONGRID_BROADCAST_TIMEOUT_MS = 12000; +const isTransientTronStatus = (status: number) => status >= 500 && status < 600; + async function tronGridPost(path: string, body: any): Promise { - const resp = await fetch(`${TRONGRID_URL}${path}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - if (!resp.ok) { - const text = await resp.text().catch(() => ''); - throw new Error(`TronGrid ${path} failed (${resp.status}): ${text}`); + const isBroadcast = path.includes('broadcasttransaction'); + const timeoutMs = isBroadcast ? TRONGRID_BROADCAST_TIMEOUT_MS : TRONGRID_TIMEOUT_MS; + const maxAttempts = 2; + let lastErr: any; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const resp = await fetch(`${TRONGRID_URL}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }); + if (!resp.ok) { + if (isTransientTronStatus(resp.status) && attempt < maxAttempts) { + await new Promise(r => setTimeout(r, 200 * attempt)); + continue; + } + const text = await resp.text().catch(() => ''); + throw new Error(`TronGrid ${path} failed (${resp.status}): ${text}`); + } + return await resp.json(); + } catch (e: any) { + lastErr = e; + // Timeout / network — worth one retry. Bail on the second attempt + // so a fully-down TronGrid doesn't lock the dApp for ~24s. + if (attempt < maxAttempts) { + await new Promise(r => setTimeout(r, 200 * attempt)); + continue; + } + } } - return resp.json(); + throw lastErr; } export class KeepKeyTronProvider {