Files
xo-cli/src/tui/screens/Transaction.tsx
2026-01-30 02:16:30 +00:00

425 lines
14 KiB
TypeScript

/**
* Transaction Screen - Reviews and broadcasts transactions.
*
* Provides:
* - Transaction details review
* - Input/output inspection
* - Fee calculation display
* - Broadcast confirmation
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Box, Text, useInput } from 'ink';
import { ConfirmDialog } from '../components/Dialog.js';
import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { colors, logoSmall, formatSatoshis, formatHex } from '../theme.js';
import { copyToClipboard } from '../utils/clipboard.js';
import type { XOInvitation } from '@xo-cash/types';
/**
* Action menu items.
*/
const actionItems = [
{ label: 'Broadcast Transaction', value: 'broadcast' },
{ label: 'Sign Transaction', value: 'sign' },
{ label: 'Copy Transaction Hex', value: 'copy' },
{ label: 'Back to Invitation', value: 'back' },
];
/**
* Transaction Screen Component.
*/
export function TransactionScreen(): React.ReactElement {
const { navigate, goBack, data: navData } = useNavigation();
const { invitationController, showError, showInfo, confirm } = useAppContext();
const { setStatus } = useStatus();
// Extract invitation ID from navigation data
const invitationId = navData.invitationId as string | undefined;
// State
const [invitation, setInvitation] = useState<XOInvitation | null>(null);
const [focusedPanel, setFocusedPanel] = useState<'inputs' | 'outputs' | 'actions'>('actions');
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [showBroadcastConfirm, setShowBroadcastConfirm] = useState(false);
/**
* Load invitation data.
*/
const loadInvitation = useCallback(() => {
if (!invitationId) {
showError('No invitation ID provided');
goBack();
return;
}
const tracked = invitationController.getInvitation(invitationId);
if (!tracked) {
showError('Invitation not found');
goBack();
return;
}
setInvitation(tracked.invitation);
}, [invitationId, invitationController, showError, goBack]);
// Load on mount
useEffect(() => {
loadInvitation();
}, [loadInvitation]);
/**
* Broadcast transaction.
*/
const broadcastTransaction = useCallback(async () => {
if (!invitationId) return;
setShowBroadcastConfirm(false);
setIsLoading(true);
setStatus('Broadcasting transaction...');
try {
const txHash = await invitationController.broadcastTransaction(invitationId);
showInfo(
`Transaction Broadcast Successful!\n\n` +
`Transaction Hash:\n${txHash}\n\n` +
`The transaction has been submitted to the network.`
);
navigate('wallet');
} catch (error) {
showError(`Failed to broadcast: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsLoading(false);
setStatus('Ready');
}
}, [invitationId, invitationController, showInfo, showError, navigate, setStatus]);
/**
* Sign transaction.
*/
const signTransaction = useCallback(async () => {
if (!invitationId) return;
setIsLoading(true);
setStatus('Signing transaction...');
try {
await invitationController.signInvitation(invitationId);
loadInvitation();
showInfo('Transaction signed successfully!');
} catch (error) {
showError(`Failed to sign: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsLoading(false);
setStatus('Ready');
}
}, [invitationId, invitationController, loadInvitation, showInfo, showError, setStatus]);
/**
* Copy transaction hex.
*/
const copyTransactionHex = useCallback(async () => {
if (!invitation) return;
try {
await copyToClipboard(invitation.invitationIdentifier);
showInfo(
`Copied Invitation ID!\n\n` +
`ID: ${invitation.invitationIdentifier}\n` +
`Commits: ${invitation.commits.length}`
);
} catch (error) {
showError(`Failed to copy: ${error instanceof Error ? error.message : String(error)}`);
}
}, [invitation, showInfo, showError]);
/**
* Handle action selection.
*/
const handleAction = useCallback((action: string) => {
switch (action) {
case 'broadcast':
setShowBroadcastConfirm(true);
break;
case 'sign':
signTransaction();
break;
case 'copy':
copyTransactionHex();
break;
case 'back':
goBack();
break;
}
}, [signTransaction, copyTransactionHex, goBack]);
// Handle keyboard navigation
useInput((input, key) => {
if (showBroadcastConfirm) return;
// Tab to switch panels
if (key.tab) {
setFocusedPanel(prev => {
if (prev === 'inputs') return 'outputs';
if (prev === 'outputs') return 'actions';
return 'inputs';
});
return;
}
// Up/Down in actions
if (focusedPanel === 'actions') {
if (key.upArrow || input === 'k') {
setSelectedActionIndex(prev => Math.max(0, prev - 1));
} else if (key.downArrow || input === 'j') {
setSelectedActionIndex(prev => Math.min(actionItems.length - 1, prev + 1));
}
}
// Enter to select
if (key.return && focusedPanel === 'actions') {
const action = actionItems[selectedActionIndex];
if (action) {
handleAction(action.value);
}
}
}, { isActive: !showBroadcastConfirm });
// Extract transaction data from invitation
const commits = invitation?.commits ?? [];
const inputs: Array<{ txid: string; index: number; value?: bigint; inputIdentifier?: string }> = [];
const outputs: Array<{ value?: bigint; lockingBytecode: string; outputIdentifier?: string; isTemplate: boolean }> = [];
const variables: Array<{ id: string; value: string }> = [];
// Parse commits for inputs, outputs, and variables
for (const commit of commits) {
// Extract variables (to help understand output values)
if (commit.data?.variables) {
for (const variable of commit.data.variables) {
variables.push({
id: variable.variableIdentifier,
value: String(variable.value),
});
}
}
if (commit.data?.inputs) {
for (const input of commit.data.inputs) {
// Convert Uint8Array to hex string if needed
const txidHex = input.outpointTransactionHash
? typeof input.outpointTransactionHash === 'string'
? input.outpointTransactionHash
: Buffer.from(input.outpointTransactionHash).toString('hex')
: undefined;
// Skip inputs that are just placeholders (no txid)
if (txidHex) {
inputs.push({
txid: txidHex,
index: input.outpointIndex ?? 0,
value: undefined, // Will be looked up from UTXO data
inputIdentifier: (input as any).inputIdentifier,
});
}
}
}
if (commit.data?.outputs) {
for (const output of commit.data.outputs) {
// Convert Uint8Array to hex string if needed
const lockingBytecodeHex = output.lockingBytecode
? typeof output.lockingBytecode === 'string'
? output.lockingBytecode
: Buffer.from(output.lockingBytecode).toString('hex')
: undefined;
// Check if this is a template-defined output (has outputIdentifier but no direct value)
const isTemplateOutput = !!(output as any).outputIdentifier && !output.valueSatoshis;
outputs.push({
value: output.valueSatoshis,
lockingBytecode: lockingBytecodeHex ?? '(pending)',
outputIdentifier: (output as any).outputIdentifier,
isTemplate: isTemplateOutput,
});
}
}
}
// Try to resolve template output values from variables
const resolvedOutputs = outputs.map(output => {
if (output.isTemplate && output.outputIdentifier) {
// Look for a matching variable (e.g., requestSatoshisOutput -> requestedSatoshis)
const satoshiVar = variables.find(v =>
v.id.toLowerCase().includes('satoshi') ||
v.id.toLowerCase().includes('amount')
);
if (satoshiVar) {
return {
...output,
value: BigInt(satoshiVar.value),
resolvedFrom: satoshiVar.id,
};
}
}
return output;
});
// Calculate totals (only for resolved values)
const totalOut = resolvedOutputs.reduce((sum, o) => sum + (o.value ?? 0n), 0n);
// Note: We can't calculate totalIn without UTXO lookup, so fee is unknown
const hasUnresolvedOutputs = resolvedOutputs.some(o => o.value === undefined);
const hasUnresolvedInputs = inputs.length > 0; // Input values are always unknown from commit data
return (
<Box flexDirection="column" flexGrow={1}>
{/* Header */}
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1}>
<Text color={colors.primary} bold>{logoSmall} - Transaction Review</Text>
</Box>
{/* Summary box */}
<Box
borderStyle="single"
borderColor={colors.primary}
marginTop={1}
marginX={1}
paddingX={1}
flexDirection="column"
>
<Text color={colors.primary} bold> Transaction Summary </Text>
{invitation ? (
<Box flexDirection="column" marginTop={1}>
<Text color={colors.text}>Inputs: {inputs.length} | Outputs: {resolvedOutputs.length} | Commits: {commits.length}</Text>
{hasUnresolvedInputs && (
<Text color={colors.textMuted}>Total In: (requires UTXO lookup)</Text>
)}
<Text color={colors.warning}>Total Out: {formatSatoshis(totalOut)}{hasUnresolvedOutputs ? ' (partial)' : ''}</Text>
{hasUnresolvedInputs ? (
<Text color={colors.textMuted}>Fee: (calculated at broadcast)</Text>
) : (
<Text color={colors.info}>Fee: {formatSatoshis(0n)}</Text>
)}
</Box>
) : (
<Text color={colors.textMuted}>Loading...</Text>
)}
</Box>
{/* Inputs and Outputs */}
<Box flexDirection="row" marginTop={1} marginX={1} flexGrow={1}>
{/* Inputs */}
<Box
borderStyle="single"
borderColor={focusedPanel === 'inputs' ? colors.focus : colors.border}
width="50%"
flexDirection="column"
paddingX={1}
>
<Text color={colors.primary} bold> Inputs </Text>
<Box flexDirection="column" marginTop={1}>
{inputs.length === 0 ? (
<Text color={colors.textMuted}>No inputs</Text>
) : (
inputs.map((input, index) => (
<Box key={`${input.txid}-${input.index}`} flexDirection="column" marginBottom={1}>
<Text color={colors.text}>
{index + 1}. {formatHex(input.txid, 12)}:{input.index}
</Text>
{input.value !== undefined && (
<Text color={colors.textMuted}> {formatSatoshis(input.value)}</Text>
)}
</Box>
))
)}
</Box>
</Box>
{/* Outputs */}
<Box
borderStyle="single"
borderColor={focusedPanel === 'outputs' ? colors.focus : colors.border}
width="50%"
flexDirection="column"
paddingX={1}
marginLeft={1}
>
<Text color={colors.primary} bold> Outputs </Text>
<Box flexDirection="column" marginTop={1}>
{resolvedOutputs.length === 0 ? (
<Text color={colors.textMuted}>No outputs</Text>
) : (
resolvedOutputs.map((output, index) => (
<Box key={index} flexDirection="column" marginBottom={1}>
<Text color={colors.text}>
{index + 1}. {output.value !== undefined ? formatSatoshis(output.value) : '(pending)'}
{output.outputIdentifier && (
<Text color={colors.info}> [{output.outputIdentifier}]</Text>
)}
</Text>
<Text color={colors.textMuted}> {output.lockingBytecode !== '(pending)' ? formatHex(output.lockingBytecode, 20) : '(pending)'}</Text>
{(output as any).resolvedFrom && (
<Text color={colors.textMuted} dimColor> (from ${(output as any).resolvedFrom})</Text>
)}
</Box>
))
)}
</Box>
</Box>
</Box>
{/* Actions */}
<Box
borderStyle="single"
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
marginTop={1}
marginX={1}
paddingX={1}
flexDirection="column"
>
<Text color={colors.primary} bold> Actions </Text>
<Box flexDirection="column" marginTop={1}>
{actionItems.map((item, index) => (
<Text
key={item.value}
color={index === selectedActionIndex && focusedPanel === 'actions' ? colors.focus : colors.text}
bold={index === selectedActionIndex && focusedPanel === 'actions'}
>
{index === selectedActionIndex && focusedPanel === 'actions' ? '▸ ' : ' '}
{item.label}
</Text>
))}
</Box>
</Box>
{/* Help text */}
<Box marginTop={1} marginX={1}>
<Text color={colors.textMuted} dimColor>
Tab: Switch focus Enter: Select Esc: Back
</Text>
</Box>
{/* Broadcast confirmation dialog */}
{showBroadcastConfirm && (
<Box
position="absolute"
flexDirection="column"
alignItems="center"
justifyContent="center"
width="100%"
height="100%"
>
<ConfirmDialog
title="Broadcast Transaction"
message="Are you sure you want to broadcast this transaction? This action cannot be undone."
onConfirm={broadcastTransaction}
onCancel={() => setShowBroadcastConfirm(false)}
isActive={showBroadcastConfirm}
/>
</Box>
)}
</Box>
);
}