/** * Action Wizard Screen - Step-by-step walkthrough for template actions. * * Guides users through: * - Reviewing action requirements * - Entering variables (e.g., requestedSatoshis) * - Selecting inputs (UTXOs) for funding * - Reviewing outputs and change * - Creating and publishing invitation */ import React, { useState, useEffect, useCallback } from 'react'; import { Box, Text, useInput } from 'ink'; import TextInput from 'ink-text-input'; import { StepIndicator, type Step } from '../components/ProgressBar.js'; import { Button } from '../components/Button.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 { XOTemplate, XOInvitation } from '@xo-cash/types'; /** * Wizard step types. */ type StepType = 'info' | 'variables' | 'inputs' | 'review' | 'publish'; /** * Wizard step definition. */ interface WizardStep { name: string; type: StepType; } /** * Variable input state. */ interface VariableInput { id: string; name: string; type: string; hint?: string; value: string; } /** * UTXO for selection. */ interface SelectableUTXO { outpointTransactionHash: string; outpointIndex: number; valueSatoshis: bigint; lockingBytecode?: string; selected: boolean; } /** * Action Wizard Screen Component. */ export function ActionWizardScreen(): React.ReactElement { const { navigate, goBack, data: navData } = useNavigation(); const { walletController, invitationController, showError, showInfo } = useAppContext(); const { setStatus } = useStatus(); // Extract navigation data const templateIdentifier = navData.templateIdentifier as string | undefined; const actionIdentifier = navData.actionIdentifier as string | undefined; const roleIdentifier = navData.roleIdentifier as string | undefined; const template = navData.template as XOTemplate | undefined; // Wizard state const [steps, setSteps] = useState([]); const [currentStep, setCurrentStep] = useState(0); // Variable inputs const [variables, setVariables] = useState([]); // UTXO selection const [availableUtxos, setAvailableUtxos] = useState([]); const [selectedUtxoIndex, setSelectedUtxoIndex] = useState(0); const [requiredAmount, setRequiredAmount] = useState(0n); const [fee, setFee] = useState(500n); // Default fee estimate // Invitation state const [invitation, setInvitation] = useState(null); const [invitationId, setInvitationId] = useState(null); // UI state const [focusedInput, setFocusedInput] = useState(0); const [focusedButton, setFocusedButton] = useState<'back' | 'cancel' | 'next'>('next'); const [focusArea, setFocusArea] = useState<'content' | 'buttons'>('content'); const [isProcessing, setIsProcessing] = useState(false); /** * Initialize wizard on mount. */ useEffect(() => { if (!template || !actionIdentifier || !roleIdentifier) { showError('Missing wizard data'); goBack(); return; } // Build steps based on template const action = template.actions?.[actionIdentifier]; const role = action?.roles?.[roleIdentifier]; const requirements = role?.requirements; const wizardSteps: WizardStep[] = [ { name: 'Welcome', type: 'info' }, ]; // Add variables step if needed if (requirements?.variables && requirements.variables.length > 0) { wizardSteps.push({ name: 'Variables', type: 'variables' }); // Initialize variable inputs const varInputs = requirements.variables.map(varId => { const varDef = template.variables?.[varId]; return { id: varId, name: varDef?.name || varId, type: varDef?.type || 'string', hint: varDef?.hint, value: '', }; }); setVariables(varInputs); } // Add inputs step if role requires slots (funding inputs) // Slots indicate the role needs to provide transaction inputs/outputs if (requirements?.slots && requirements.slots.min > 0) { wizardSteps.push({ name: 'Select UTXOs', type: 'inputs' }); } wizardSteps.push({ name: 'Review', type: 'review' }); wizardSteps.push({ name: 'Publish', type: 'publish' }); setSteps(wizardSteps); setStatus(`${actionIdentifier}/${roleIdentifier}`); }, [template, actionIdentifier, roleIdentifier, showError, goBack, setStatus]); /** * Get current step data. */ const currentStepData = steps[currentStep]; /** * Calculate selected amount. */ const selectedAmount = availableUtxos .filter(u => u.selected) .reduce((sum, u) => sum + u.valueSatoshis, 0n); /** * Calculate change amount. */ const changeAmount = selectedAmount - requiredAmount - fee; /** * Load available UTXOs for the inputs step. */ const loadAvailableUtxos = useCallback(async () => { if (!invitation || !templateIdentifier) return; try { setIsProcessing(true); setStatus('Finding suitable UTXOs...'); // First, get the required amount from variables (e.g., requestedSatoshis) const requestedVar = variables.find(v => v.id.toLowerCase().includes('satoshi') || v.id.toLowerCase().includes('amount') ); const requested = requestedVar ? BigInt(requestedVar.value || '0') : 0n; setRequiredAmount(requested); // Find suitable resources const resources = await walletController.findSuitableResources(invitation, { templateIdentifier, outputIdentifier: 'receiveOutput', // Common output identifier }); // Convert to selectable UTXOs const utxos: SelectableUTXO[] = (resources.unspentOutputs || []).map((utxo: any) => ({ outpointTransactionHash: utxo.outpointTransactionHash, outpointIndex: utxo.outpointIndex, valueSatoshis: BigInt(utxo.valueSatoshis), lockingBytecode: utxo.lockingBytecode ? typeof utxo.lockingBytecode === 'string' ? utxo.lockingBytecode : Buffer.from(utxo.lockingBytecode).toString('hex') : undefined, selected: false, })); // Auto-select UTXOs to cover required amount + fee let accumulated = 0n; const seenLockingBytecodes = new Set(); for (const utxo of utxos) { // Ensure lockingBytecode uniqueness if (utxo.lockingBytecode && seenLockingBytecodes.has(utxo.lockingBytecode)) { continue; } if (utxo.lockingBytecode) { seenLockingBytecodes.add(utxo.lockingBytecode); } utxo.selected = true; accumulated += utxo.valueSatoshis; if (accumulated >= requested + fee) { break; } } setAvailableUtxos(utxos); setStatus('Ready'); } catch (error) { showError(`Failed to load UTXOs: ${error instanceof Error ? error.message : String(error)}`); } finally { setIsProcessing(false); } }, [invitation, templateIdentifier, variables, walletController, showError, setStatus]); /** * Toggle UTXO selection. */ const toggleUtxoSelection = useCallback((index: number) => { setAvailableUtxos(prev => { const updated = [...prev]; const utxo = updated[index]; if (utxo) { updated[index] = { ...utxo, selected: !utxo.selected }; } return updated; }); }, []); /** * Navigate to next step. */ const nextStep = useCallback(async () => { if (currentStep >= steps.length - 1) return; const stepType = currentStepData?.type; // Handle step-specific logic if (stepType === 'variables') { // Create invitation and add variables await createInvitationWithVariables(); return; } if (stepType === 'inputs') { // Add selected inputs and outputs to invitation await addInputsAndOutputs(); return; } if (stepType === 'review') { // Publish invitation await publishInvitation(); return; } setCurrentStep(prev => prev + 1); setFocusArea('content'); setFocusedInput(0); }, [currentStep, steps.length, currentStepData]); /** * Create invitation and add variables. */ const createInvitationWithVariables = useCallback(async () => { if (!templateIdentifier || !actionIdentifier || !roleIdentifier || !template) return; setIsProcessing(true); setStatus('Creating invitation...'); try { // Create invitation const tracked = await invitationController.createInvitation( templateIdentifier, actionIdentifier, ); let inv = tracked.invitation; const invId = inv.invitationIdentifier; setInvitationId(invId); // Add variables if any if (variables.length > 0) { const variableData = variables.map(v => { // Determine if this is a numeric type that should be BigInt // Template types include: 'integer', 'number', 'satoshis' // Hints include: 'satoshis', 'amount' const isNumeric = ['integer', 'number', 'satoshis'].includes(v.type) || (v.hint && ['satoshis', 'amount'].includes(v.hint)); return { variableIdentifier: v.id, roleIdentifier: roleIdentifier, value: isNumeric ? BigInt(v.value || '0') : v.value, }; }); const updated = await invitationController.addVariables(invId, variableData); inv = updated.invitation; } // Add template-required outputs for the current role // This is critical - the template defines which outputs the initiator must create const action = template.actions?.[actionIdentifier]; const transaction = action?.transaction ? template.transactions?.[action.transaction] : null; if (transaction?.outputs && transaction.outputs.length > 0) { setStatus('Adding required outputs...'); // Add each required output with just its identifier // IMPORTANT: Do NOT pass roleIdentifier here - if roleIdentifier is set, // the engine skips generating the lockingBytecode (see engine.ts appendInvitation) // The engine will automatically generate the locking bytecode based on the template const outputsToAdd = transaction.outputs.map((outputId: string) => ({ outputIdentifier: outputId, // Note: roleIdentifier intentionally omitted to trigger lockingBytecode generation })); const updated = await invitationController.addOutputs(invId, outputsToAdd); inv = updated.invitation; } setInvitation(inv); // Check if next step is inputs const nextStepType = steps[currentStep + 1]?.type; if (nextStepType === 'inputs') { setCurrentStep(prev => prev + 1); // Load UTXOs after step change setTimeout(() => loadAvailableUtxos(), 100); } else { setCurrentStep(prev => prev + 1); } setStatus('Invitation created'); } catch (error) { showError(`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`); } finally { setIsProcessing(false); } }, [templateIdentifier, actionIdentifier, roleIdentifier, template, variables, invitationController, steps, currentStep, showError, setStatus, loadAvailableUtxos]); /** * Add selected inputs and change output to invitation. */ const addInputsAndOutputs = useCallback(async () => { if (!invitationId || !invitation) return; const selectedUtxos = availableUtxos.filter(u => u.selected); if (selectedUtxos.length === 0) { showError('Please select at least one UTXO'); return; } if (selectedAmount < requiredAmount + fee) { showError(`Insufficient funds. Need ${formatSatoshis(requiredAmount + fee)}, selected ${formatSatoshis(selectedAmount)}`); return; } if (changeAmount < 546n) { // Dust threshold showError(`Change amount (${changeAmount}) is below dust threshold (546 sats)`); return; } setIsProcessing(true); setStatus('Adding inputs and outputs...'); try { // Add inputs const inputs = selectedUtxos.map(utxo => ({ outpointTransactionHash: utxo.outpointTransactionHash, outpointIndex: utxo.outpointIndex, })); await invitationController.addInputs(invitationId, inputs); // Add change output const outputs = [{ valueSatoshis: changeAmount, // The engine will automatically generate the locking bytecode for change }]; await invitationController.addOutputs(invitationId, outputs); // Add transaction metadata // Note: This would be done via appendInvitation but we don't have direct access here // The engine should handle defaults setCurrentStep(prev => prev + 1); setStatus('Inputs and outputs added'); } catch (error) { showError(`Failed to add inputs/outputs: ${error instanceof Error ? error.message : String(error)}`); } finally { setIsProcessing(false); } }, [invitationId, invitation, availableUtxos, selectedAmount, requiredAmount, fee, changeAmount, invitationController, showError, setStatus]); /** * Publish invitation. */ const publishInvitation = useCallback(async () => { if (!invitationId) return; setIsProcessing(true); setStatus('Publishing invitation...'); try { await invitationController.publishAndSubscribe(invitationId); setCurrentStep(prev => prev + 1); setStatus('Invitation published'); } catch (error) { showError(`Failed to publish: ${error instanceof Error ? error.message : String(error)}`); } finally { setIsProcessing(false); } }, [invitationId, invitationController, showError, setStatus]); /** * Navigate to previous step. */ const previousStep = useCallback(() => { if (currentStep <= 0) { goBack(); return; } setCurrentStep(prev => prev - 1); setFocusArea('content'); setFocusedInput(0); }, [currentStep, goBack]); /** * Cancel wizard. */ const cancel = useCallback(() => { goBack(); }, [goBack]); /** * Copy invitation ID to clipboard. */ const copyId = useCallback(async () => { if (!invitationId) return; try { await copyToClipboard(invitationId); showInfo(`Copied to clipboard!\n\n${invitationId}`); } catch (error) { showError(`Failed to copy: ${error instanceof Error ? error.message : String(error)}`); } }, [invitationId, showInfo, showError]); /** * Update variable value. */ const updateVariable = useCallback((index: number, value: string) => { setVariables(prev => { const updated = [...prev]; const variable = updated[index]; if (variable) { updated[index] = { ...variable, value }; } return updated; }); }, []); // Handle keyboard navigation useInput((input, key) => { // Tab to switch between content and buttons if (key.tab) { if (focusArea === 'content') { // Handle tab based on current step type if (currentStepData?.type === 'variables' && variables.length > 0) { if (focusedInput < variables.length - 1) { setFocusedInput(prev => prev + 1); return; } } if (currentStepData?.type === 'inputs' && availableUtxos.length > 0) { if (selectedUtxoIndex < availableUtxos.length - 1) { setSelectedUtxoIndex(prev => prev + 1); return; } } setFocusArea('buttons'); setFocusedButton('next'); } else { if (focusedButton === 'back') { setFocusedButton('cancel'); } else if (focusedButton === 'cancel') { setFocusedButton('next'); } else { setFocusArea('content'); setFocusedInput(0); setSelectedUtxoIndex(0); } } return; } // Arrow keys for UTXO selection if (focusArea === 'content' && currentStepData?.type === 'inputs') { if (key.upArrow) { setSelectedUtxoIndex(prev => Math.max(0, prev - 1)); } else if (key.downArrow) { setSelectedUtxoIndex(prev => Math.min(availableUtxos.length - 1, prev + 1)); } else if (key.return || input === ' ') { toggleUtxoSelection(selectedUtxoIndex); } return; } // Arrow keys in buttons area if (focusArea === 'buttons') { if (key.leftArrow) { setFocusedButton(prev => prev === 'next' ? 'cancel' : prev === 'cancel' ? 'back' : 'back' ); } else if (key.rightArrow) { setFocusedButton(prev => prev === 'back' ? 'cancel' : prev === 'cancel' ? 'next' : 'next' ); } } // Enter on buttons if (key.return && focusArea === 'buttons') { if (focusedButton === 'back') previousStep(); else if (focusedButton === 'cancel') cancel(); else if (focusedButton === 'next') nextStep(); } // 'c' to copy on publish step if (input === 'c' && currentStepData?.type === 'publish' && invitationId) { copyId(); } // 'a' to select all UTXOs if (input === 'a' && currentStepData?.type === 'inputs') { setAvailableUtxos(prev => prev.map(u => ({ ...u, selected: true }))); } // 'n' to deselect all UTXOs if (input === 'n' && currentStepData?.type === 'inputs') { setAvailableUtxos(prev => prev.map(u => ({ ...u, selected: false }))); } }); // Get action details const action = template?.actions?.[actionIdentifier ?? '']; const actionName = action?.name || actionIdentifier || 'Unknown'; // Render step content const renderStepContent = () => { if (!currentStepData) return null; switch (currentStepData.type) { case 'info': return ( Action: {actionName} {action?.description || 'No description'} Your Role: {roleIdentifier} {action?.roles?.[roleIdentifier ?? '']?.requirements && ( Requirements: {action.roles[roleIdentifier ?? '']?.requirements?.variables?.map(v => ( • Variable: {v} ))} {action.roles[roleIdentifier ?? '']?.requirements?.slots && ( • Slots: {action.roles[roleIdentifier ?? '']?.requirements?.slots?.min} min (UTXO selection required) )} )} ); case 'variables': return ( Enter required values: {variables.map((variable, index) => ( {variable.name} {variable.hint && ( ({variable.hint}) )} updateVariable(index, value)} focus={focusArea === 'content' && focusedInput === index} placeholder={`Enter ${variable.name}...`} /> ))} ); case 'inputs': return ( Select UTXOs to fund the transaction: Required: {formatSatoshis(requiredAmount)} + {formatSatoshis(fee)} fee = requiredAmount + fee ? colors.success : colors.warning}> Selected: {formatSatoshis(selectedAmount)} {selectedAmount > requiredAmount + fee && ( Change: {formatSatoshis(changeAmount)} )} {availableUtxos.length === 0 ? ( No UTXOs available ) : ( availableUtxos.map((utxo, index) => ( {selectedUtxoIndex === index && focusArea === 'content' ? '▸ ' : ' '} [{utxo.selected ? 'X' : ' '}] {formatSatoshis(utxo.valueSatoshis)} - {formatHex(utxo.outpointTransactionHash, 12)}:{utxo.outpointIndex} )) )} Space/Enter: Toggle • a: Select all • n: Deselect all ); case 'review': const selectedUtxos = availableUtxos.filter(u => u.selected); return ( Review your invitation: Template: {template?.name} Action: {actionName} Role: {roleIdentifier} {variables.length > 0 && ( Variables: {variables.map(v => ( {' '}{v.name}: {v.value || '(empty)'} ))} )} {selectedUtxos.length > 0 && ( Inputs ({selectedUtxos.length}): {selectedUtxos.slice(0, 3).map(u => ( {' '}{formatSatoshis(u.valueSatoshis)} ))} {selectedUtxos.length > 3 && ( ...and {selectedUtxos.length - 3} more )} )} {changeAmount > 0 && ( Outputs: Change: {formatSatoshis(changeAmount)} )} Press Next to create and publish the invitation. ); case 'publish': return ( ✓ Invitation Created & Published! Invitation ID: {invitationId} Share this ID with the other party to complete the transaction. Press 'c' to copy ID to clipboard ); default: return null; } }; // Convert steps to StepIndicator format const stepIndicatorSteps: Step[] = steps.map(s => ({ label: s.name })); return ( {/* Header */} {logoSmall} - Action Wizard {template?.name} {'>'} {actionName} (as {roleIdentifier}) {/* Progress indicator */} {/* Content area */} {' '}{currentStepData?.name} ({currentStep + 1}/{steps.length}){' '} {isProcessing ? ( Processing... ) : ( renderStepContent() )} {/* Buttons */}