From af43af77ef8b3f9b118d6df1bceff1f834d8773a Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 30 Apr 2026 17:23:46 -0500 Subject: [PATCH 1/2] fix(audit): UTXO build race + cross-chain broadcast resilience + read failover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the eight remaining audit findings from PR #59's review. P1 — UTXO build-then-approval race (#1) and broadcast resilience (#2): - bitcoin/dogecoin/litecoin/bitcoincash/dash all awaited buildTx() fire-and-forget then created the approval event afterwards. Race produced phantom approvals (response.unsignedTx undefined) or null storedEvent depending on timing. Now buildTx is awaited BEFORE addEvent, with unsignedTx attached to the event from the start. Also applied to thorchain/cosmos/maya/osmosis/ripple — they already awaited the build but used raw fetch() without timeout/retry. - Both build and broadcast now go through fetchJsonWithTimeout with 15s budget + 1 retry on 5xx. P2 — cross-cutting resilience: - New chrome-extension/src/background/fetchUtils.ts with fetchJsonWithTimeout + fetchWithTimeout. Applied to all six Pioneer endpoints in index.ts (portfolio, insight, nodes, tokens/metadata, tokens/custom GET/POST/DELETE, tokens/balances) and the UTXO/Tendermint chain handlers above. - New chrome-extension/src/background/chains/rpcFailover.ts with withRpcFailoverByNetworkId. Iterates user-override → Pioneer → last-resort with transient/definitive classification and per-attempt transport timeout. Applied to GET_ASSET_BALANCE, GET_EVM_BALANCE, VALIDATE_ERC20_TOKEN. - Drop check binds to the URL that accepted the broadcast — querying a different RPC could produce false-positive drop warnings, especially after a last-resort fallback succeeded. dropCheckUrlByHash maps hash → success URL; performDropCheck queries that exact URL with a 4s timeout, falling back to getProvider only on service-worker restart. - Solana broadcastTransaction iterates SOLANA_RPC_URLS on transient failures (rate limit, 5xx, timeout). Definitive errors (insufficient funds, blockhash expired, signature verification, account-in-use) skip the loop. Cached health-checked URL is invalidated on failure so the next caller re-tests. - Tron tronGridPost (injected bundle, can't import fetchUtils) gets inline timeout + one retry on 5xx. Broadcast paths get a longer 12s budget; reads stay at 8s. - Health poll (checkKeepKey) gets AbortSignal.timeout(3000) + a singleflight guard so a stalled localhost:1646 can't stack overlapping probes every 5s. Out of scope: fetchBalances per-chain RPC enrichment (line 500) still uses a single makeStaticProvider per chain since it's already inside a chain-iteration loop; further failover there would be over-engineering. GET_GAS_ESTIMATE (line 909) still uses the active provider directly — could be migrated to withRpcFailover later. Co-Authored-By: Claude Opus 4.7 (1M context) --- chrome-extension/public/injected.js | 18 +- .../background/chains/bitcoinCashHandler.ts | 45 ++-- .../src/background/chains/bitcoinHandler.ts | 67 ++--- .../src/background/chains/cosmosHandler.ts | 33 ++- .../src/background/chains/dashHandler.ts | 45 ++-- .../src/background/chains/dogecoinHandler.ts | 45 ++-- .../src/background/chains/ethereumHandler.ts | 46 +++- .../src/background/chains/litecoinHandler.ts | 45 ++-- .../src/background/chains/mayaHandler.ts | 33 ++- .../src/background/chains/osmosisHandler.ts | 33 ++- .../src/background/chains/rippleHandler.ts | 33 ++- .../src/background/chains/rpcFailover.ts | 129 ++++++++++ .../src/background/chains/solanaHandler.ts | 113 +++++++-- .../src/background/chains/thorchainHandler.ts | 39 +-- chrome-extension/src/background/fetchUtils.ts | 95 +++++++ chrome-extension/src/background/index.ts | 237 +++++++++--------- .../src/injected/tron-provider.ts | 50 +++- 17 files changed, 755 insertions(+), 351 deletions(-) create mode 100644 chrome-extension/src/background/chains/rpcFailover.ts create mode 100644 chrome-extension/src/background/fetchUtils.ts 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..d92e7d9 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; 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..c76d639 100644 --- a/chrome-extension/src/background/chains/solanaHandler.ts +++ b/chrome-extension/src/background/chains/solanaHandler.ts @@ -487,42 +487,103 @@ function bytesToHex(bytes: Uint8Array | number[]): string { return out; } +/** + * Heuristic: is this Solana broadcast error worth retrying against + * another RPC? Definitive errors (insufficient funds, blockhash + * expired, signature verify failure) will reject identically on every + * RPC; transient errors (rate limit, network, 5xx) may succeed + * elsewhere. + */ +function isTransientSolanaBroadcastError(msg: string): boolean { + const m = msg.toLowerCase(); + // Definitive — won't help to try another RPC. + if ( + m.includes('insufficient funds') || + m.includes('insufficient lamports') || + m.includes('blockhash not found') || + m.includes('block height exceeded') || + m.includes('invalid signature') || + m.includes('signature verification') || + m.includes('already processed') || + m.includes('account in use') + ) { + return false; + } + // Everything else (rate limit, network, timeout, 5xx) is worth retrying. + return true; +} + /** * 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) { - throw createProviderRpcError(-32603, `Solana RPC error: ${result.error.message}`); + const result = await response.json(); + if (result.error) { + const errMsg = result.error.message || JSON.stringify(result.error); + if (isTransientSolanaBroadcastError(errMsg)) { + errors.push({ url: rpcUrl, error: errMsg }); + cachedRpcUrl = null; + continue; + } + // Definitive — won't recover on another RPC. + throw createProviderRpcError(-32603, `Solana RPC error: ${errMsg}`); + } + + 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 { From 1837558485d3ddf9edf2f490b97f0535059566b2 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 30 Apr 2026 17:32:47 -0500 Subject: [PATCH 2/2] fix(review): Solana classifier + Bitcoin fall-through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from PR #60's review. P1: Solana broadcast classifier was over-eager about definitive errors. - 'already processed' is treated as success — the tx is in mempool / processed somewhere; the dApp still needs the signature. Recover it from the signed bytes (first 64-byte sig in the signed-tx layout IS the canonical Solana transaction signature). Inline base58 encoder added; reuses the existing BASE58_ALPHABET const that base58Decode already declared. - 'blockhash not found' demoted from definitive to transient — can be RPC freshness for dApp-supplied transactions; trying the next URL is worth it. 'block height exceeded' stays definitive (the blockhash window has truly closed). - 'account in use' demoted to transient — usually a parallel-write race that can resolve on the next URL. P2: bitcoinHandler's broadcast catch logged + sent transaction_error but didn't re-throw, so the case 'transfer' fell through to default and returned 'Method transfer not supported' to the dApp instead of the real broadcast error. Other UTXO/Tendermint handlers don't have this catch — only bitcoin. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/background/chains/bitcoinHandler.ts | 5 + .../src/background/chains/solanaHandler.ts | 117 +++++++++++++++--- 2 files changed, 107 insertions(+), 15 deletions(-) diff --git a/chrome-extension/src/background/chains/bitcoinHandler.ts b/chrome-extension/src/background/chains/bitcoinHandler.ts index d92e7d9..592cd07 100644 --- a/chrome-extension/src/background/chains/bitcoinHandler.ts +++ b/chrome-extension/src/background/chains/bitcoinHandler.ts @@ -159,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/solanaHandler.ts b/chrome-extension/src/background/chains/solanaHandler.ts index c76d639..98594a6 100644 --- a/chrome-extension/src/background/chains/solanaHandler.ts +++ b/chrome-extension/src/background/chains/solanaHandler.ts @@ -488,29 +488,95 @@ function bytesToHex(bytes: Uint8Array | number[]): string { } /** - * Heuristic: is this Solana broadcast error worth retrying against - * another RPC? Definitive errors (insufficient funds, blockhash - * expired, signature verify failure) will reject identically on every - * RPC; transient errors (rate limit, network, 5xx) may succeed - * elsewhere. + * 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 isTransientSolanaBroadcastError(msg: string): boolean { +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(); - // Definitive — won't help to try another RPC. + if (m.includes('already processed')) return 'already-processed'; if ( m.includes('insufficient funds') || m.includes('insufficient lamports') || - m.includes('blockhash not found') || m.includes('block height exceeded') || m.includes('invalid signature') || - m.includes('signature verification') || - m.includes('already processed') || - m.includes('account in use') + m.includes('signature verification') ) { - return false; + return 'definitive'; } - // Everything else (rate limit, network, timeout, 5xx) is worth retrying. - return true; + // 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'; } /** @@ -565,7 +631,28 @@ async function broadcastTransaction(signedTxBase64: string): Promise { const result = await response.json(); if (result.error) { const errMsg = result.error.message || JSON.stringify(result.error); - if (isTransientSolanaBroadcastError(errMsg)) { + 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;