Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 111 additions & 38 deletions chrome-extension/src/background/chains/solanaHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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;
Expand All @@ -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'
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -119,31 +162,41 @@ 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(() => '');
throw createProviderRpcError(-32603, `Vault sign failed (${resp.status}): ${text}`);
}

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,
};
}

Expand All @@ -158,17 +211,26 @@ async function signTransactionViaRest(txBase64: string): Promise<{ signature: st
*/
async function signMessageViaRest(messageBase64: string): Promise<number[]> {
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(() => '');
Expand All @@ -191,16 +253,26 @@ async function signMessageViaRest(messageBase64: string): Promise<number[]> {
* Vault has NO broadcast endpoint — we send directly to Solana RPC.
*/
async function broadcastTransaction(signedTxBase64: string): Promise<string> {
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}`);
Expand Down Expand Up @@ -306,6 +378,7 @@ export const handleSolanaRequest = async (
action: 'transaction_complete',
txHash: txSignature,
explorerTxLink: 'https://solscan.io/tx/',
networkId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
})
.catch(() => {});

Expand Down
7 changes: 7 additions & 0 deletions chrome-extension/src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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');

Expand Down
Loading