diff --git a/chrome-extension/src/background/chains/solanaHandler.ts b/chrome-extension/src/background/chains/solanaHandler.ts index f7f20c3..db1ec02 100644 --- a/chrome-extension/src/background/chains/solanaHandler.ts +++ b/chrome-extension/src/background/chains/solanaHandler.ts @@ -7,11 +7,46 @@ const TAG = ' | solanaHandler | '; // Vault REST API and Solana mainnet RPC const VAULT_URL = 'http://localhost:1646'; -const SOLANA_RPC_URL = 'https://api.mainnet-beta.solana.com'; +const SOLANA_RPC_URLS = [ + 'https://api.mainnet-beta.solana.com', + 'https://mainnet.helius-rpc.com/?api-key=1d8740dc-e5f4-421c-b823-e1bad1889eff', +]; + +let cachedRpcUrl: string | null = null; +let cachedRpcTimestamp = 0; +const RPC_CACHE_TTL = 60000; // cache healthy RPC for 60s + +async function getSolanaRpcUrl(): Promise { + const now = Date.now(); + if (cachedRpcUrl && now - cachedRpcTimestamp < RPC_CACHE_TTL) { + return cachedRpcUrl; + } + for (const url of SOLANA_RPC_URLS) { + try { + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getHealth' }), + signal: AbortSignal.timeout(3000), + }); + if (resp.ok) { + cachedRpcUrl = url; + cachedRpcTimestamp = now; + return url; + } + } catch { /* try next */ } + } + return SOLANA_RPC_URLS[0]; // fallback to primary +} // Cached address from device let cachedAddress: string | null = null; +/** Reset cached state (call when device disconnects or wallet re-inits) */ +export function resetSolanaState() { + cachedAddress = null; +} + /** Convert a number[] to base64 string (chunked to avoid call-stack limit) */ function toBase64(arr: number[]): string { const CHUNK = 8192; @@ -24,7 +59,11 @@ function toBase64(arr: number[]): string { /** Convert a base64 string to number[] */ function fromBase64(b64: string): number[] { - return Array.from(atob(b64), c => c.charCodeAt(0)); + try { + return Array.from(atob(b64), c => c.charCodeAt(0)); + } catch (e: any) { + throw createProviderRpcError(-32603, `Failed to decode base64 response: ${e.message}`); + } } // BIP44 path for Solana: m/44'/501'/0'/0' @@ -105,7 +144,11 @@ async function requestUserApproval( /** Get the Bearer API key from the SDK for direct REST calls */ function getApiKey(): string { const sdk = wallet.getSdk(); - return sdk.getClient?.()?.getApiKey?.() || ''; + const key = sdk.getClient?.()?.getApiKey?.(); + if (!key) { + throw createProviderRpcError(-32603, 'API key not available — vault may not be connected'); + } + return key; } /** @@ -119,17 +162,26 @@ function getApiKey(): string { */ async function signTransactionViaRest(txBase64: string): Promise<{ signature: string; serializedTx: string }> { const apiKey = getApiKey(); - const resp = await fetch(`${VAULT_URL}/solana/sign-transaction`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - raw_tx: txBase64, - address_n: SOLANA_ADDRESS_N, - }), - }); + let resp: Response; + try { + resp = await fetch(`${VAULT_URL}/solana/sign-transaction`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + raw_tx: txBase64, + address_n: SOLANA_ADDRESS_N, + }), + signal: AbortSignal.timeout(30000), + }); + } catch (e: any) { + if (e.name === 'TimeoutError' || e.name === 'AbortError') { + throw createProviderRpcError(-32603, 'Vault signing timed out'); + } + throw createProviderRpcError(-32603, `Vault connection failed: ${e.message}`); + } if (!resp.ok) { const text = await resp.text().catch(() => ''); @@ -137,13 +189,14 @@ async function signTransactionViaRest(txBase64: string): Promise<{ signature: st } const result = await resp.json(); - if (!result.signature && !result.serializedTx) { - throw createProviderRpcError(-32603, 'Vault returned empty sign result'); + const serializedTx = result.serializedTx || result.serialized || ''; + if (!serializedTx) { + throw createProviderRpcError(-32603, 'Vault returned no signed transaction data'); } return { signature: result.signature || '', - serializedTx: result.serializedTx || result.serialized || '', + serializedTx, }; } @@ -158,17 +211,26 @@ async function signTransactionViaRest(txBase64: string): Promise<{ signature: st */ async function signMessageViaRest(messageBase64: string): Promise { const apiKey = getApiKey(); - const resp = await fetch(`${VAULT_URL}/solana/sign-message`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - message: messageBase64, - address_n: SOLANA_ADDRESS_N, - }), - }); + let resp: Response; + try { + resp = await fetch(`${VAULT_URL}/solana/sign-message`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + message: messageBase64, + address_n: SOLANA_ADDRESS_N, + }), + signal: AbortSignal.timeout(30000), + }); + } catch (e: any) { + if (e.name === 'TimeoutError' || e.name === 'AbortError') { + throw createProviderRpcError(-32603, 'Vault sign-message timed out'); + } + throw createProviderRpcError(-32603, `Vault connection failed: ${e.message}`); + } if (!resp.ok) { const text = await resp.text().catch(() => ''); @@ -191,16 +253,26 @@ async function signMessageViaRest(messageBase64: string): Promise { * Vault has NO broadcast endpoint — we send directly to Solana RPC. */ async function broadcastTransaction(signedTxBase64: string): Promise { - const response = await fetch(SOLANA_RPC_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'sendTransaction', - params: [signedTxBase64, { encoding: 'base64' }], - }), - }); + 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 createProviderRpcError(-32603, 'Solana RPC broadcast timed out'); + } + throw createProviderRpcError(-32603, `Solana RPC connection failed: ${e.message}`); + } if (!response.ok) { throw createProviderRpcError(-32603, `Solana RPC broadcast failed: ${response.status}`); @@ -306,6 +378,7 @@ export const handleSolanaRequest = async ( action: 'transaction_complete', txHash: txSignature, explorerTxLink: 'https://solscan.io/tx/', + networkId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', }) .catch(() => {}); diff --git a/chrome-extension/src/background/index.ts b/chrome-extension/src/background/index.ts index 5ad8dca..70c3470 100644 --- a/chrome-extension/src/background/index.ts +++ b/chrome-extension/src/background/index.ts @@ -7,6 +7,7 @@ globalThis.Buffer = Buffer; import packageJson from '../../package.json'; import * as wallet from './wallet'; +import { resetSolanaState } from './chains/solanaHandler'; import { handleWalletRequest } from './methods'; import { JsonRpcProvider, formatEther } from 'ethers'; import { ChainToNetworkId, Chain, COIN_MAP_LONG, shortListSymbolToCaip, NetworkIdToChain } from './chainConfig'; @@ -76,6 +77,11 @@ async function checkKeepKey() { if (KEEPKEY_STATE !== 4) { console.warn('KeepKey endpoint not found:', error?.message || error); } + // Clear cached per-chain state when transitioning from connected → disconnected + // so a hot-swapped device doesn't sign against a stale cached address. + if (prevState === 2 || prevState === 5) { + resetSolanaState(); + } KEEPKEY_STATE = 4; // Set state to errored updateIcon(); if (KEEPKEY_STATE !== prevState) pushStateChangeEvent(); @@ -312,6 +318,7 @@ const onStart = async function () { const tag = TAG + ' | onStart | '; try { console.log(tag, 'Starting...'); + resetSolanaState(); // clear stale cached address before re-init await wallet.init(); console.log(tag, 'Wallet initialized');