diff --git a/chrome-extension/src/background/chains/solanaHandler.ts b/chrome-extension/src/background/chains/solanaHandler.ts index 8b98b4d..9e0c3d5 100644 --- a/chrome-extension/src/background/chains/solanaHandler.ts +++ b/chrome-extension/src/background/chains/solanaHandler.ts @@ -409,6 +409,56 @@ function getApiKey(): string { * The vault replaces the dummy 64-byte signature at bytes 1-64 in raw_tx * with the real Ed25519 signature from the device. */ +/** + * Ask the vault what a transaction DOES, before the user is asked to approve it. + * + * The vault runs this same decoder inside its signing gate, but that happens + * AFTER the extension has already collected the user's approval — so without + * this call the approval card has nothing to render and shows "N/A" over a real + * transfer. Decode-only: no device, no signing (see /solana/decode-transaction). + * + * Never throws: a decode failure must still reach the card, as an explicit + * error the user can see, never as a missing field that reads like "nothing is + * being moved". + */ +async function decodeTransactionViaRest(txBase64: string): Promise<{ + solanaDecoded?: any; + solanaDecodeError?: string; + requiresBlindSigningConsent?: boolean; +}> { + try { + const resp = await fetch(`${VAULT_URL}/solana/decode-transaction`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getApiKey()}` }, + body: JSON.stringify({ raw_tx: txBase64 }), + // No device involved, so this is a plain RPC-speed call — but it does one + // ALT lookup for v0 messages. Short timeout: the approval card is waiting. + signal: AbortSignal.timeout(15_000), + }); + if (!resp.ok) { + return { solanaDecodeError: `vault decode failed: HTTP ${resp.status}`, requiresBlindSigningConsent: true }; + } + return await resp.json(); + } catch (e: any) { + return { + solanaDecodeError: `${e?.name || 'Error'}: ${e?.message || String(e)}`, + requiresBlindSigningConsent: true, + }; + } +} + +/** + * Build an approval event for a dApp-supplied transaction, with the decode + * attached. Every tx approval must go through here: buildEvent() alone leaves + * unsignedTx undefined, and the card then renders an empty payment table. + */ +async function buildTxApprovalEvent(requestInfo: any, method: string, params: any[], txBase64: string) { + const decoded = await decodeTransactionViaRest(txBase64); + const event: any = buildEvent(requestInfo, method, params); + event.unsignedTx = { kind: 'solana', txBase64, ...decoded }; + return event; +} + async function signTransactionViaRest( txBase64: string, accountIndex = 0, @@ -876,10 +926,9 @@ export const handleSolanaRequest = async ( throw createProviderRpcError(4000, 'Invalid params: expected transaction as number[]'); } - const txEvent = buildEvent(requestInfo, method, params); - await requestUserApproval(txEvent, requestInfo, method, params, requireApproval); - const txBase64 = toBase64(txArray); + const txEvent = await buildTxApprovalEvent(requestInfo, method, params, txBase64); + await requestUserApproval(txEvent, requestInfo, method, params, requireApproval); const txSignResult = await signTransactionViaRest(txBase64, resolveSolanaAccountIndex(params, requestInfo)); // Return the fully signed transaction (vault replaces dummy sig at bytes 1-64) @@ -992,11 +1041,13 @@ export const handleSolanaRequest = async ( throw createProviderRpcError(4000, 'Invalid params: expected transaction as number[]'); } - const sendEvent = buildEvent(requestInfo, method, params); + // Decode BEFORE approval: this path broadcasts immediately after signing, + // so an unreviewable screen here is the most expensive one in the wallet. + const sendBase64 = toBase64(sendTxArray); + const sendEvent = await buildTxApprovalEvent(requestInfo, method, params, sendBase64); await requestUserApproval(sendEvent, requestInfo, method, params, requireApproval); // Sign via direct REST call - const sendBase64 = toBase64(sendTxArray); const signResult = await signTransactionViaRest(sendBase64, resolveSolanaAccountIndex(params, requestInfo)); // Broadcast via Solana RPC (vault has no broadcast endpoint) diff --git a/pages/side-panel/src/approval/other/RequestDataCard.tsx b/pages/side-panel/src/approval/other/RequestDataCard.tsx index 8731054..731c8bd 100644 --- a/pages/side-panel/src/approval/other/RequestDataCard.tsx +++ b/pages/side-panel/src/approval/other/RequestDataCard.tsx @@ -9,7 +9,9 @@ import { requestStorage } from '@extension/storage'; // Import the requestStorag */ export default function RequestDataCard({ transaction }: any) { const [isOpen, setIsOpen] = useState(false); - const [fetchedTransaction, setFetchedTransaction] = useState(transaction.unsignedTx); + // Fall back to the raw request: a dApp event may carry no unsignedTx at all, + // and showing the bytes it sent beats an empty panel under a blind approval. + const [fetchedTransaction, setFetchedTransaction] = useState(transaction.unsignedTx ?? transaction.request); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -27,7 +29,7 @@ export default function RequestDataCard({ transaction }: any) { // Fetch the transaction from storage using its ID const response = await requestStorage.getEventById(transaction.id); if (response) { - setFetchedTransaction(response.unsignedTx); // Update the transaction data with the fetched data + setFetchedTransaction(response.unsignedTx ?? (response as any).request); } else { setError('Transaction not found in storage'); } diff --git a/pages/side-panel/src/approval/other/RequestDetailsCard.tsx b/pages/side-panel/src/approval/other/RequestDetailsCard.tsx index 964bd7b..47ba62c 100644 --- a/pages/side-panel/src/approval/other/RequestDetailsCard.tsx +++ b/pages/side-panel/src/approval/other/RequestDetailsCard.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { Box, Divider, Flex, Table, Tbody, Tr, Td, Badge, Avatar, IconButton, Tooltip } from '@chakra-ui/react'; +import { Box, Divider, Flex, Table, Tbody, Tr, Td, Badge, Avatar, IconButton, Text, Tooltip } from '@chakra-ui/react'; import { CopyIcon, CheckIcon } from '@chakra-ui/icons'; /** @@ -278,6 +278,84 @@ export default function RequestDetailsCard({ transaction }: any) { ); } + // Solana: a transaction is a list of instructions, not a to/amount pair. + // solanaHandler decodes it through the vault BEFORE asking for approval, and + // this branch is what the user reviews. Never fall through to the payment + // table below for Solana — an empty table reads as "nothing is being moved", + // which is exactly the wrong thing to say about a transfer we failed to read. + if (unsignedTx?.kind === 'solana') { + const decoded = unsignedTx.solanaDecoded; + const decodeError: string | undefined = unsignedTx.solanaDecodeError; + const instructions: any[] = Array.isArray(decoded?.instructions) ? decoded.instructions : []; + const blind = !!unsignedTx.requiresBlindSigningConsent; + return ( +
+ + {decodeError && ( + + + Could not decode this transaction — do not approve unless you trust this site. + + + {decodeError} + + + )} + {!decodeError && blind && ( + + + Blind signing — your KeepKey cannot show what this transaction does. + + + )} + {!decodeError && ( + + + + + + + + {instructions.map((ix: any, i: number) => ( + + + + + ))} + {decoded?.altResolutionIncomplete && ( + + + + + )} + +
+ Instructions: + + {instructions.length} ({decoded?.version || 'legacy'}) +
+ + {ix.programName || 'unknown program'} + + + {/* An undecoded instruction says so — it never renders blank. */} + {ix.instructionName || 'unrecognized instruction'} + {Array.isArray(ix.args) && ix.args.length > 0 && ( + + {ix.args.map((a: any) => `${a.name}: ${a.value}`).join(', ')} + + )} +
+ Warning: + Some address lookup tables could not be resolved
+
+ )} + +
+
+ ); + } + // Hive operation batches have no single destination or amount — a tx can // carry up to four ops of different shapes. hiveHandler stashes a rendered // one-liner per op (opSummary); show those instead of the amount table,