Files
xo-cli/src/tui/screens/ActionWizard.tsx
2026-01-30 02:52:09 +00:00

825 lines
27 KiB
TypeScript

/**
* 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<WizardStep[]>([]);
const [currentStep, setCurrentStep] = useState(0);
// Variable inputs
const [variables, setVariables] = useState<VariableInput[]>([]);
// UTXO selection
const [availableUtxos, setAvailableUtxos] = useState<SelectableUTXO[]>([]);
const [selectedUtxoIndex, setSelectedUtxoIndex] = useState(0);
const [requiredAmount, setRequiredAmount] = useState<bigint>(0n);
const [fee, setFee] = useState<bigint>(500n); // Default fee estimate
// Invitation state
const [invitation, setInvitation] = useState<XOInvitation | null>(null);
const [invitationId, setInvitationId] = useState<string | null>(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<string>();
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 (
<Box flexDirection="column">
<Text color={colors.primary} bold>Action: {actionName}</Text>
<Text color={colors.textMuted}>{action?.description || 'No description'}</Text>
<Box marginTop={1}>
<Text color={colors.text}>Your Role: </Text>
<Text color={colors.accent}>{roleIdentifier}</Text>
</Box>
{action?.roles?.[roleIdentifier ?? '']?.requirements && (
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Requirements:</Text>
{action.roles[roleIdentifier ?? '']?.requirements?.variables?.map(v => (
<Text key={v} color={colors.textMuted}> Variable: {v}</Text>
))}
{action.roles[roleIdentifier ?? '']?.requirements?.slots && (
<Text color={colors.textMuted}> Slots: {action.roles[roleIdentifier ?? '']?.requirements?.slots?.min} min (UTXO selection required)</Text>
)}
</Box>
)}
</Box>
);
case 'variables':
return (
<Box flexDirection="column">
<Text color={colors.text} bold>Enter required values:</Text>
<Box marginTop={1} flexDirection="column">
{variables.map((variable, index) => (
<Box key={variable.id} flexDirection="column" marginBottom={1}>
<Text color={colors.primary}>{variable.name}</Text>
{variable.hint && (
<Text color={colors.textMuted} dimColor>({variable.hint})</Text>
)}
<Box
borderStyle="single"
borderColor={focusArea === 'content' && focusedInput === index ? colors.focus : colors.border}
paddingX={1}
marginTop={1}
>
<TextInput
value={variable.value}
onChange={value => updateVariable(index, value)}
focus={focusArea === 'content' && focusedInput === index}
placeholder={`Enter ${variable.name}...`}
/>
</Box>
</Box>
))}
</Box>
</Box>
);
case 'inputs':
return (
<Box flexDirection="column">
<Text color={colors.text} bold>Select UTXOs to fund the transaction:</Text>
<Box marginTop={1} flexDirection="column">
<Text color={colors.textMuted}>
Required: {formatSatoshis(requiredAmount)} + {formatSatoshis(fee)} fee
</Text>
<Text color={selectedAmount >= requiredAmount + fee ? colors.success : colors.warning}>
Selected: {formatSatoshis(selectedAmount)}
</Text>
{selectedAmount > requiredAmount + fee && (
<Text color={colors.info}>
Change: {formatSatoshis(changeAmount)}
</Text>
)}
</Box>
<Box marginTop={1} flexDirection="column" borderStyle="single" borderColor={colors.border} paddingX={1}>
{availableUtxos.length === 0 ? (
<Text color={colors.textMuted}>No UTXOs available</Text>
) : (
availableUtxos.map((utxo, index) => (
<Box key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}>
<Text
color={selectedUtxoIndex === index && focusArea === 'content' ? colors.focus : colors.text}
bold={selectedUtxoIndex === index && focusArea === 'content'}
>
{selectedUtxoIndex === index && focusArea === 'content' ? '▸ ' : ' '}
[{utxo.selected ? 'X' : ' '}] {formatSatoshis(utxo.valueSatoshis)} - {formatHex(utxo.outpointTransactionHash, 12)}:{utxo.outpointIndex}
</Text>
</Box>
))
)}
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted} dimColor>
Space/Enter: Toggle a: Select all n: Deselect all
</Text>
</Box>
</Box>
);
case 'review':
const selectedUtxos = availableUtxos.filter(u => u.selected);
return (
<Box flexDirection="column">
<Text color={colors.text} bold>Review your invitation:</Text>
<Box marginTop={1} flexDirection="column">
<Text color={colors.textMuted}>Template: {template?.name}</Text>
<Text color={colors.textMuted}>Action: {actionName}</Text>
<Text color={colors.textMuted}>Role: {roleIdentifier}</Text>
</Box>
{variables.length > 0 && (
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Variables:</Text>
{variables.map(v => (
<Text key={v.id} color={colors.textMuted}>
{' '}{v.name}: {v.value || '(empty)'}
</Text>
))}
</Box>
)}
{selectedUtxos.length > 0 && (
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Inputs ({selectedUtxos.length}):</Text>
{selectedUtxos.slice(0, 3).map(u => (
<Text key={`${u.outpointTransactionHash}:${u.outpointIndex}`} color={colors.textMuted}>
{' '}{formatSatoshis(u.valueSatoshis)}
</Text>
))}
{selectedUtxos.length > 3 && (
<Text color={colors.textMuted}> ...and {selectedUtxos.length - 3} more</Text>
)}
</Box>
)}
{changeAmount > 0 && (
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Outputs:</Text>
<Text color={colors.textMuted}> Change: {formatSatoshis(changeAmount)}</Text>
</Box>
)}
<Box marginTop={1}>
<Text color={colors.warning}>
Press Next to create and publish the invitation.
</Text>
</Box>
</Box>
);
case 'publish':
return (
<Box flexDirection="column">
<Text color={colors.success} bold> Invitation Created & Published!</Text>
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Invitation ID:</Text>
<Box
borderStyle="single"
borderColor={colors.primary}
paddingX={1}
marginTop={1}
>
<Text color={colors.accent}>{invitationId}</Text>
</Box>
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted}>
Share this ID with the other party to complete the transaction.
</Text>
</Box>
<Box marginTop={1}>
<Text color={colors.warning}>Press 'c' to copy ID to clipboard</Text>
</Box>
</Box>
);
default:
return null;
}
};
// Convert steps to StepIndicator format
const stepIndicatorSteps: Step[] = steps.map(s => ({ label: s.name }));
return (
<Box flexDirection="column" flexGrow={1}>
{/* Header */}
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1} flexDirection="column">
<Text color={colors.primary} bold>{logoSmall} - Action Wizard</Text>
<Text color={colors.textMuted}>
{template?.name} {'>'} {actionName} (as {roleIdentifier})
</Text>
</Box>
{/* Progress indicator */}
<Box marginTop={1} paddingX={1}>
<StepIndicator steps={stepIndicatorSteps} currentStep={currentStep} />
</Box>
{/* Content area */}
<Box
borderStyle="single"
borderColor={focusArea === 'content' ? colors.focus : colors.primary}
flexDirection="column"
paddingX={1}
paddingY={1}
marginTop={1}
marginX={1}
flexGrow={1}
>
<Text color={colors.primary} bold>
{' '}{currentStepData?.name} ({currentStep + 1}/{steps.length}){' '}
</Text>
<Box marginTop={1}>
{isProcessing ? (
<Text color={colors.info}>Processing...</Text>
) : (
renderStepContent()
)}
</Box>
</Box>
{/* Buttons */}
<Box marginTop={1} marginX={1} justifyContent="space-between">
<Box gap={1}>
<Button
label="Back"
focused={focusArea === 'buttons' && focusedButton === 'back'}
disabled={currentStepData?.type === 'publish'}
/>
<Button
label="Cancel"
focused={focusArea === 'buttons' && focusedButton === 'cancel'}
/>
</Box>
<Button
label={currentStepData?.type === 'publish' ? 'Done' : 'Next'}
focused={focusArea === 'buttons' && focusedButton === 'next'}
disabled={isProcessing}
/>
</Box>
{/* Help text */}
<Box marginTop={1} marginX={1}>
<Text color={colors.textMuted} dimColor>
Tab: Navigate Enter: Select Esc: Back
{currentStepData?.type === 'publish' ? ' • c: Copy ID' : ''}
</Text>
</Box>
</Box>
);
}