Initial Commit
This commit is contained in:
372
src/tui/screens/Transaction.tsx
Normal file
372
src/tui/screens/Transaction.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* 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 }> = [];
|
||||
const outputs: Array<{ value: bigint; lockingBytecode: string }> = [];
|
||||
|
||||
// Parse commits for inputs and outputs
|
||||
for (const commit of commits) {
|
||||
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')
|
||||
: 'unknown';
|
||||
|
||||
inputs.push({
|
||||
txid: txidHex,
|
||||
index: input.outpointIndex ?? 0,
|
||||
value: undefined, // libauth Input doesn't have valueSatoshis directly
|
||||
});
|
||||
}
|
||||
}
|
||||
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')
|
||||
: 'unknown';
|
||||
|
||||
outputs.push({
|
||||
value: output.valueSatoshis ?? 0n,
|
||||
lockingBytecode: lockingBytecodeHex,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
const totalIn = inputs.reduce((sum, i) => sum + (i.value ?? 0n), 0n);
|
||||
const totalOut = outputs.reduce((sum, o) => sum + o.value, 0n);
|
||||
const fee = totalIn - totalOut;
|
||||
|
||||
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: {outputs.length} | Commits: {commits.length}</Text>
|
||||
<Text color={colors.success}>Total In: {formatSatoshis(totalIn)}</Text>
|
||||
<Text color={colors.warning}>Total Out: {formatSatoshis(totalOut)}</Text>
|
||||
<Text color={colors.info}>Fee: {formatSatoshis(fee)}</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}>
|
||||
{outputs.length === 0 ? (
|
||||
<Text color={colors.textMuted}>No outputs</Text>
|
||||
) : (
|
||||
outputs.map((output, index) => (
|
||||
<Box key={index} flexDirection="column" marginBottom={1}>
|
||||
<Text color={colors.text}>
|
||||
{index + 1}. {formatSatoshis(output.value)}
|
||||
</Text>
|
||||
<Text color={colors.textMuted}> {formatHex(output.lockingBytecode, 20)}</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user