/** * 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(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 ( {/* Header */} {logoSmall} - Transaction Review {/* Summary box */} Transaction Summary {invitation ? ( Inputs: {inputs.length} | Outputs: {resolvedOutputs.length} | Commits: {commits.length} {hasUnresolvedInputs && ( Total In: (requires UTXO lookup) )} Total Out: {formatSatoshis(totalOut)}{hasUnresolvedOutputs ? ' (partial)' : ''} {hasUnresolvedInputs ? ( Fee: (calculated at broadcast) ) : ( Fee: {formatSatoshis(0n)} )} ) : ( Loading... )} {/* Inputs and Outputs */} {/* Inputs */} Inputs {inputs.length === 0 ? ( No inputs ) : ( inputs.map((input, index) => ( {index + 1}. {formatHex(input.txid, 12)}:{input.index} {input.value !== undefined && ( {formatSatoshis(input.value)} )} )) )} {/* Outputs */} Outputs {resolvedOutputs.length === 0 ? ( No outputs ) : ( resolvedOutputs.map((output, index) => ( {index + 1}. {output.value !== undefined ? formatSatoshis(output.value) : '(pending)'} {output.outputIdentifier && ( [{output.outputIdentifier}] )} {output.lockingBytecode !== '(pending)' ? formatHex(output.lockingBytecode, 20) : '(pending)'} {(output as any).resolvedFrom && ( (from ${(output as any).resolvedFrom}) )} )) )} {/* Actions */} Actions {actionItems.map((item, index) => ( {index === selectedActionIndex && focusedPanel === 'actions' ? '▸ ' : ' '} {item.label} ))} {/* Help text */} Tab: Switch focus • Enter: Select • Esc: Back {/* Broadcast confirmation dialog */} {showBroadcastConfirm && ( setShowBroadcastConfirm(false)} isActive={showBroadcastConfirm} /> )} ); }