417 lines
14 KiB
TypeScript
417 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 { useInvitation } from '../hooks/useInvitations.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 { showError, showInfo } = useAppContext();
|
|
const { setStatus } = useStatus();
|
|
|
|
// Extract invitation ID from navigation data
|
|
const invitationId = navData.invitationId as string | undefined;
|
|
|
|
// Use hook to get invitation reactively
|
|
const invitationInstance = useInvitation(invitationId ?? null);
|
|
|
|
// State
|
|
const [focusedPanel, setFocusedPanel] = useState<'inputs' | 'outputs' | 'actions'>('actions');
|
|
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [showBroadcastConfirm, setShowBroadcastConfirm] = useState(false);
|
|
|
|
// Check if invitation exists
|
|
useEffect(() => {
|
|
if (!invitationId) {
|
|
showError('No invitation ID provided');
|
|
goBack();
|
|
return;
|
|
}
|
|
|
|
if (invitationId && !invitationInstance) {
|
|
showError('Invitation not found');
|
|
goBack();
|
|
}
|
|
}, [invitationId, invitationInstance, showError, goBack]);
|
|
|
|
const invitation = invitationInstance?.data ?? null;
|
|
|
|
/**
|
|
* Broadcast transaction.
|
|
*/
|
|
const broadcastTransaction = useCallback(async () => {
|
|
if (!invitationInstance) return;
|
|
|
|
setShowBroadcastConfirm(false);
|
|
setIsLoading(true);
|
|
setStatus('Broadcasting transaction...');
|
|
|
|
try {
|
|
await invitationInstance.broadcast();
|
|
showInfo(
|
|
`Transaction Broadcast Successful!\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');
|
|
}
|
|
}, [invitationInstance, showInfo, showError, navigate, setStatus]);
|
|
|
|
/**
|
|
* Sign transaction.
|
|
*/
|
|
const signTransaction = useCallback(async () => {
|
|
if (!invitationInstance) return;
|
|
|
|
setIsLoading(true);
|
|
setStatus('Signing transaction...');
|
|
|
|
try {
|
|
await invitationInstance.sign();
|
|
showInfo('Transaction signed successfully!');
|
|
} catch (error) {
|
|
showError(`Failed to sign: ${error instanceof Error ? error.message : String(error)}`);
|
|
} finally {
|
|
setIsLoading(false);
|
|
setStatus('Ready');
|
|
}
|
|
}, [invitationInstance, 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>
|
|
);
|
|
}
|