Skip to content
Open
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
61 changes: 56 additions & 5 deletions chrome-extension/src/background/chains/solanaHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions pages/side-panel/src/approval/other/RequestDataCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>(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<any>(transaction.unsignedTx ?? transaction.request);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

Expand All @@ -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');
}
Expand Down
80 changes: 79 additions & 1 deletion pages/side-panel/src/approval/other/RequestDetailsCard.tsx
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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 (
<div>
<Flex direction="column" mb={4}>
{decodeError && (
<Box mb={3} p={3} borderWidth="1px" borderColor="red.500" borderRadius="md" bg="red.900">
<Text fontWeight="bold" color="red.200">
Could not decode this transaction — do not approve unless you trust this site.
</Text>
<Text fontSize="xs" color="red.200" mt={1} wordBreak="break-all">
{decodeError}
</Text>
</Box>
)}
{!decodeError && blind && (
<Box mb={3} p={3} borderWidth="1px" borderColor="orange.400" borderRadius="md">
<Text fontWeight="bold" color="orange.300">
Blind signing — your KeepKey cannot show what this transaction does.
</Text>
</Box>
)}
{!decodeError && (
<Box mb={2}>
<Table variant="simple" size="sm">
<Tbody>
<Tr>
<Td>
<Badge>Instructions:</Badge>
</Td>
<Td>
{instructions.length} ({decoded?.version || 'legacy'})
</Td>
</Tr>
{instructions.map((ix: any, i: number) => (
<Tr key={i}>
<Td>
<Badge colorScheme={ix.status === 'known' ? 'green' : 'orange'}>
{ix.programName || 'unknown program'}
</Badge>
</Td>
<Td whiteSpace="pre-wrap" wordBreak="break-word">
{/* An undecoded instruction says so — it never renders blank. */}
{ix.instructionName || 'unrecognized instruction'}
{Array.isArray(ix.args) && ix.args.length > 0 && (
<Text fontSize="xs" color="gray.400">
{ix.args.map((a: any) => `${a.name}: ${a.value}`).join(', ')}
</Text>
)}
</Td>
</Tr>
))}
{decoded?.altResolutionIncomplete && (
<Tr>
<Td>
<Badge colorScheme="orange">Warning:</Badge>
</Td>
<Td>Some address lookup tables could not be resolved</Td>
</Tr>
)}
</Tbody>
</Table>
</Box>
)}
<Divider my={2} />
</Flex>
</div>
);
}

// 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,
Expand Down
Loading