Clean up and fixes

This commit is contained in:
2026-02-08 02:32:50 +00:00
parent eb1bf9020e
commit da096af0fa
36 changed files with 2119 additions and 1751 deletions

View File

@@ -0,0 +1,268 @@
import React from 'react';
import { Box, Text, useInput } from 'ink';
import { StepIndicator, type Step } from '../../components/ProgressBar.js';
import { Button } from '../../components/Button.js';
import { colors, logoSmall } from '../../theme.js';
import { useActionWizard } from './useActionWizard.js';
// Steps
import { InfoStep } from './steps/InfoStep.js';
import { VariablesStep } from './steps/VariablesStep.js';
import { InputsStep } from './steps/InputsStep.js';
import { ReviewStep } from './steps/ReviewStep.js';
import { PublishStep } from './steps/PublishStep.js';
export function ActionWizardScreen(): React.ReactElement {
const wizard = useActionWizard();
// ── Keyboard handling ──────────────────────────────────────────
useInput(
(input, key) => {
// Tab to cycle between content area and button bar
if (key.tab) {
if (wizard.focusArea === 'content') {
// Within the inputs step, tab through UTXOs first
if (
wizard.currentStepData?.type === 'inputs' &&
wizard.availableUtxos.length > 0
) {
if (
wizard.selectedUtxoIndex <
wizard.availableUtxos.length - 1
) {
wizard.setSelectedUtxoIndex((prev) => prev + 1);
return;
}
}
// Move focus down to the button bar
wizard.setFocusArea('buttons');
wizard.setFocusedButton('next');
} else {
// Cycle through buttons, then wrap back to content
if (wizard.focusedButton === 'back') {
wizard.setFocusedButton('cancel');
} else if (wizard.focusedButton === 'cancel') {
wizard.setFocusedButton('next');
} else {
wizard.setFocusArea('content');
wizard.setFocusedInput(0);
wizard.setSelectedUtxoIndex(0);
}
}
return;
}
// Arrow keys for UTXO selection in the content area
if (
wizard.focusArea === 'content' &&
wizard.currentStepData?.type === 'inputs'
) {
if (key.upArrow) {
wizard.setSelectedUtxoIndex((p) => Math.max(0, p - 1));
} else if (key.downArrow) {
wizard.setSelectedUtxoIndex((p) =>
Math.min(wizard.availableUtxos.length - 1, p + 1)
);
} else if (key.return || input === ' ') {
wizard.toggleUtxoSelection(wizard.selectedUtxoIndex);
}
return;
}
// Arrow keys in button bar
if (wizard.focusArea === 'buttons') {
if (key.leftArrow) {
wizard.setFocusedButton((p) =>
p === 'next' ? 'cancel' : p === 'cancel' ? 'back' : 'back'
);
} else if (key.rightArrow) {
wizard.setFocusedButton((p) =>
p === 'back' ? 'cancel' : p === 'cancel' ? 'next' : 'next'
);
}
// Enter on a button
if (key.return) {
if (wizard.focusedButton === 'back') wizard.previousStep();
else if (wizard.focusedButton === 'cancel') wizard.cancel();
else if (wizard.focusedButton === 'next') wizard.nextStep();
}
}
// 'c' to copy invitation ID on the publish step
if (
input === 'c' &&
wizard.currentStepData?.type === 'publish' &&
wizard.invitationId
) {
wizard.copyId();
}
// 'a' to select all UTXOs
if (input === 'a' && wizard.currentStepData?.type === 'inputs') {
wizard.setAvailableUtxos((p) =>
p.map((u) => ({ ...u, selected: true }))
);
}
// 'n' to deselect all UTXOs
if (input === 'n' && wizard.currentStepData?.type === 'inputs') {
wizard.setAvailableUtxos((p) =>
p.map((u) => ({ ...u, selected: false }))
);
}
},
{ isActive: !wizard.textInputHasFocus }
);
// ── Step router ────────────────────────────────────────────────
const renderStep = () => {
if (wizard.isProcessing) {
return <Text color={colors.info}>Processing...</Text>;
}
switch (wizard.currentStepData?.type) {
case 'info':
return (
<InfoStep
template={wizard.template!}
actionIdentifier={wizard.actionIdentifier!}
roleIdentifier={wizard.roleIdentifier!}
actionName={wizard.actionName}
/>
);
case 'variables':
return (
<VariablesStep
variables={wizard.variables}
updateVariable={wizard.updateVariable}
handleTextInputSubmit={wizard.handleTextInputSubmit}
focusArea={wizard.focusArea}
focusedInput={wizard.focusedInput}
/>
);
case 'inputs':
return (
<InputsStep
availableUtxos={wizard.availableUtxos}
selectedUtxoIndex={wizard.selectedUtxoIndex}
requiredAmount={wizard.requiredAmount}
fee={wizard.fee}
selectedAmount={wizard.selectedAmount}
changeAmount={wizard.changeAmount}
focusArea={wizard.focusArea}
/>
);
case 'review':
return (
<ReviewStep
template={wizard.template!}
actionName={wizard.actionName}
roleIdentifier={wizard.roleIdentifier!}
variables={wizard.variables}
availableUtxos={wizard.availableUtxos}
changeAmount={wizard.changeAmount}
/>
);
case 'publish':
return <PublishStep invitationId={wizard.invitationId} />;
default:
return null;
}
};
// ── Layout ─────────────────────────────────────────────────────
const stepIndicatorSteps: Step[] = wizard.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}>
{wizard.template?.name} {">"} {wizard.actionName} (as{" "}
{wizard.roleIdentifier})
</Text>
</Box>
{/* Progress indicator */}
<Box marginTop={1} paddingX={1}>
<StepIndicator
steps={stepIndicatorSteps}
currentStep={wizard.currentStep}
/>
</Box>
{/* Content area */}
<Box
borderStyle="single"
borderColor={
wizard.focusArea === "content" ? colors.focus : colors.primary
}
flexDirection="column"
paddingX={1}
paddingY={1}
marginTop={1}
marginX={1}
flexGrow={1}
>
<Text color={colors.primary} bold>
{" "}
{wizard.currentStepData?.name} ({wizard.currentStep + 1}/
{wizard.steps.length}){" "}
</Text>
<Box marginTop={1}>{renderStep()}</Box>
</Box>
{/* Buttons */}
<Box marginTop={1} marginX={1} justifyContent="space-between">
<Box gap={1}>
<Button
label="Back"
focused={
wizard.focusArea === "buttons" &&
wizard.focusedButton === "back"
}
disabled={wizard.currentStepData?.type === "publish"}
/>
<Button
label="Cancel"
focused={
wizard.focusArea === "buttons" &&
wizard.focusedButton === "cancel"
}
/>
</Box>
<Button
label={
wizard.currentStepData?.type === "publish" ? "Done" : "Next"
}
focused={
wizard.focusArea === "buttons" &&
wizard.focusedButton === "next"
}
disabled={wizard.isProcessing}
/>
</Box>
{/* Help text */}
<Box marginTop={1} marginX={1}>
<Text color={colors.textMuted} dimColor>
Tab: Navigate Enter: Select Esc: Back
{wizard.currentStepData?.type === "publish"
? " • c: Copy ID"
: ""}
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,4 @@
export * from './ActionWizardScreen.js';
export * from './useActionWizard.js';
export * from './types.js';
export * from './steps/index.js';

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { Box, Text } from 'ink';
import { colors } from '../../../theme.js';
import type { WizardStepProps } from '../types.js';
type Props = Pick<
WizardStepProps,
'template' | 'actionIdentifier' | 'roleIdentifier' | 'actionName'
>;
export function InfoStep({
template,
actionIdentifier,
roleIdentifier,
actionName,
}: Props): React.ReactElement {
const action = template?.actions?.[actionIdentifier];
const role = action?.roles?.[roleIdentifier];
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>
{role?.requirements && (
<Box marginTop={1} flexDirection='column'>
<Text color={colors.text}>Requirements:</Text>
{role.requirements.variables?.map((v) => (
<Text key={v} color={colors.textMuted}>
{' '} Variable: {v}
</Text>
))}
{role.requirements.slots && (
<Text color={colors.textMuted}>
{' '} Slots: {role.requirements.slots.min} min (UTXO selection
required)
</Text>
)}
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,92 @@
import React from 'react';
import { Box, Text } from 'ink';
import { colors, formatSatoshis, formatHex } from '../../../theme.js';
import type { WizardStepProps } from '../types.js';
type Props = Pick<
WizardStepProps,
| 'availableUtxos'
| 'selectedUtxoIndex'
| 'requiredAmount'
| 'fee'
| 'selectedAmount'
| 'changeAmount'
| 'focusArea'
>;
export function InputsStep({
availableUtxos,
selectedUtxoIndex,
requiredAmount,
fee,
selectedAmount,
changeAmount,
focusArea,
}: Props): React.ReactElement {
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) => {
const isCursor =
selectedUtxoIndex === index && focusArea === 'content';
return (
<Box
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
>
<Text
color={isCursor ? colors.focus : colors.text}
bold={isCursor}
>
{isCursor ? '▸ ' : ' '}[{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>
);
}

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { Box, Text } from 'ink';
import { colors } from '../../../theme.js';
interface PublishStepProps {
invitationId: string | null;
}
export function PublishStep({
invitationId,
}: PublishStepProps): React.ReactElement {
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 ?? '(unknown)'}
</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>
);
}

View File

@@ -0,0 +1,93 @@
import React from 'react';
import { Box, Text } from 'ink';
import { colors, formatSatoshis } from '../../../theme.js';
import type { VariableInput, SelectableUTXO } from '../types.js';
import type { XOTemplate } from '@xo-cash/types';
interface ReviewStepProps {
template: XOTemplate;
actionName: string;
roleIdentifier: string;
variables: VariableInput[];
availableUtxos: SelectableUTXO[];
changeAmount: bigint;
}
export function ReviewStep({
template,
actionName,
roleIdentifier,
variables,
availableUtxos,
changeAmount,
}: ReviewStepProps): React.ReactElement {
const selectedUtxos = availableUtxos.filter((u) => u.selected);
return (
<Box flexDirection='column'>
<Text color={colors.text} bold>
Review your invitation:
</Text>
{/* Summary */}
<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 */}
{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>
)}
{/* Inputs */}
{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>
)}
{/* Outputs */}
{changeAmount > 0n && (
<Box marginTop={1} flexDirection='column'>
<Text color={colors.text}>Outputs:</Text>
<Text color={colors.textMuted}>
{' '}Change: {formatSatoshis(changeAmount)}
</Text>
</Box>
)}
{/* Confirmation prompt */}
<Box marginTop={1}>
<Text color={colors.warning}>
Press Next to create and publish the invitation.
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { Box, Text } from 'ink';
import { colors } from '../../../theme.js';
import { VariableInputField } from '../../../components/VariableInputField.js';
import type { WizardStepProps } from '../types.js';
type Props = Pick<
WizardStepProps,
| 'variables'
| 'updateVariable'
| 'handleTextInputSubmit'
| 'focusArea'
| 'focusedInput'
>;
export function VariablesStep({
variables,
updateVariable,
handleTextInputSubmit,
focusArea,
focusedInput,
}: Props): React.ReactElement {
return (
<Box flexDirection='column'>
<Text color={colors.text} bold>
Enter required values:
</Text>
<Box marginTop={1} flexDirection='column'>
{variables.map((variable, index) => (
<VariableInputField
key={variable.id}
variable={variable}
index={index}
isFocused={focusArea === 'content' && focusedInput === index}
onChange={updateVariable}
onSubmit={handleTextInputSubmit}
borderColor={colors.border as string}
focusColor={colors.primary as string}
/>
))}
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted} dimColor>
Type your value, then press Enter to continue
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,5 @@
export * from './InfoStep.js';
export * from './VariablesStep.js';
export * from './InputsStep.js';
export * from './ReviewStep.js';
export * from './PublishStep.js';

View File

@@ -0,0 +1,62 @@
import type { XOTemplate } from '@xo-cash/types';
export type StepType = 'info' | 'variables' | 'inputs' | 'review' | 'publish';
export interface WizardStep {
name: string;
type: StepType;
}
export interface VariableInput {
id: string;
name: string;
type: string;
hint?: string;
value: string;
}
export interface SelectableUTXO {
outpointTransactionHash: string;
outpointIndex: number;
valueSatoshis: bigint;
lockingBytecode?: string;
selected: boolean;
}
export type FocusArea = 'content' | 'buttons';
export type ButtonFocus = 'back' | 'cancel' | 'next';
/**
* The 'downward' contract — what every step component receives.
*/
export interface WizardStepProps {
// Data
template: XOTemplate;
actionIdentifier: string;
roleIdentifier: string;
actionName: string;
// Variable state
variables: VariableInput[];
updateVariable: (index: number, value: string) => void;
// UTXO state
availableUtxos: SelectableUTXO[];
selectedUtxoIndex: number;
requiredAmount: bigint;
fee: bigint;
selectedAmount: bigint;
changeAmount: bigint;
toggleUtxoSelection: (index: number) => void;
// Invitation
invitationId: string | null;
// Focus
focusArea: FocusArea;
focusedInput: number;
// Callbacks
handleTextInputSubmit: () => void;
copyId: () => Promise<void>;
}

View File

@@ -0,0 +1,576 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigation } from '../../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
import { formatSatoshis } from '../../theme.js';
import { copyToClipboard } from '../../utils/clipboard.js';
import type { XOTemplate, XOInvitation } from '@xo-cash/types';
import type {
WizardStep,
VariableInput,
SelectableUTXO,
FocusArea,
ButtonFocus,
} from './types.js';
export function useActionWizard() {
const { navigate, goBack, data: navData } = useNavigation();
const { appService, showError, showInfo } = useAppContext();
const { setStatus } = useStatus();
// ── 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);
// ── Invitation ───────────────────────────────────────────────────
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<ButtonFocus>('next');
const [focusArea, setFocusArea] = useState<FocusArea>('content');
const [isProcessing, setIsProcessing] = useState(false);
// ── Derived values ───────────────────────────────────────────────
const currentStepData = steps[currentStep];
const action = template?.actions?.[actionIdentifier ?? ''];
const actionName = action?.name || actionIdentifier || 'Unknown';
const selectedAmount = availableUtxos
.filter((u) => u.selected)
.reduce((sum, u) => sum + u.valueSatoshis, 0n);
const changeAmount = selectedAmount - requiredAmount - fee;
const textInputHasFocus =
currentStepData?.type === 'variables' && focusArea === 'content';
// ── Initialization ───────────────────────────────────────────────
useEffect(() => {
if (!template || !actionIdentifier || !roleIdentifier) {
showError('Missing wizard data');
goBack();
return;
}
const act = template.actions?.[actionIdentifier];
const role = act?.roles?.[roleIdentifier];
const requirements = role?.requirements;
// const wizardSteps: WizardStep[] = [{ name: 'Welcome', type: 'info' }];
const wizardSteps: WizardStep[] = [];
// Add variables step if needed
if (requirements?.variables && requirements.variables.length > 0) {
wizardSteps.push({ name: 'Variables', type: 'variables' });
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' });
}
// Add review step
wizardSteps.push({ name: 'Review', type: 'review' });
// Add publish step
wizardSteps.push({ name: 'Publish', type: 'publish' });
setSteps(wizardSteps);
setStatus(`${actionIdentifier}/${roleIdentifier}`);
}, [
template,
actionIdentifier,
roleIdentifier,
showError,
goBack,
setStatus,
]);
// ── Update a single 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;
});
}, []);
// ── Toggle a UTXO's selected state ──────────────────────────────
const toggleUtxoSelection = useCallback((index: number) => {
setAvailableUtxos((prev) => {
const updated = [...prev];
const utxo = updated[index];
if (utxo) {
updated[index] = { ...utxo, selected: !utxo.selected };
}
return updated;
});
}, []);
// ── Handle Enter inside a TextInput ─────────────────────────────
const handleTextInputSubmit = useCallback(() => {
if (focusedInput < variables.length - 1) {
setFocusedInput((prev) => prev + 1);
} else {
setFocusArea('buttons');
setFocusedButton('next');
}
}, [focusedInput, variables.length]);
// ── 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]);
// ── Load available UTXOs for the inputs step ────────────────────
const loadAvailableUtxos = useCallback(async () => {
if (!invitation || !templateIdentifier || !appService || !invitationId) {
return;
}
try {
setIsProcessing(true);
setStatus('Finding suitable UTXOs...');
// Determine required amount from variables
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 the tracked invitation instance
const invitationInstance = appService.invitations.find(
(inv) => inv.data.invitationIdentifier === invitationId
);
if (!invitationInstance) {
throw new Error('Invitation not found');
}
// Query for suitable resources
const unspentOutputs = await invitationInstance.findSuitableResources({
templateIdentifier,
outputIdentifier: 'receiveOutput',
});
// Map to selectable UTXOs
const utxos: SelectableUTXO[] = 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 greedily until the requirement is met
let accumulated = 0n;
const seenLockingBytecodes = new Set<string>();
for (const utxo of utxos) {
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,
appService,
invitationId,
fee,
showError,
setStatus,
]);
// ── Create invitation and persist variables ─────────────────────
const createInvitationWithVariables = useCallback(async () => {
if (
!templateIdentifier ||
!actionIdentifier ||
!roleIdentifier ||
!template ||
!appService
) {
return;
}
setIsProcessing(true);
setStatus('Creating invitation...');
try {
// Create via the engine
const xoInvitation = await appService.engine.createInvitation({
templateIdentifier,
actionIdentifier,
});
// Wrap and track
const invitationInstance =
await appService.createInvitation(xoInvitation);
let inv = invitationInstance.data;
const invId = inv.invitationIdentifier;
setInvitationId(invId);
// Persist variable values
if (variables.length > 0) {
const variableData = variables.map((v) => {
const isNumeric =
['integer', 'number', 'satoshis'].includes(v.type) ||
(v.hint && ['satoshis', 'amount'].includes(v.hint));
return {
variableIdentifier: v.id,
roleIdentifier,
value: isNumeric ? BigInt(v.value || '0') : v.value,
};
});
await invitationInstance.addVariables(variableData);
inv = invitationInstance.data;
}
// Add template-required outputs for the current role
const act = template.actions?.[actionIdentifier];
const transaction = act?.transaction
? template.transactions?.[act.transaction]
: null;
if (transaction?.outputs && transaction.outputs.length > 0) {
setStatus('Adding required outputs...');
const outputsToAdd = transaction.outputs.map(
(outputId: string) => ({
outputIdentifier: outputId,
})
);
await invitationInstance.addOutputs(outputsToAdd);
inv = invitationInstance.data;
}
setInvitation(inv);
// Advance and optionally kick off UTXO loading
const nextStepType = steps[currentStep + 1]?.type;
if (nextStepType === 'inputs') {
setCurrentStep((prev) => prev + 1);
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,
appService,
steps,
currentStep,
showError,
setStatus,
loadAvailableUtxos,
]);
// ── Add selected inputs + change output to the invitation ───────
const addInputsAndOutputs = useCallback(async () => {
if (!invitationId || !invitation || !appService) 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) {
showError(
`Change amount (${changeAmount}) is below dust threshold (546 sats)`
);
return;
}
setIsProcessing(true);
setStatus('Adding inputs and outputs...');
try {
const invitationInstance = appService.invitations.find(
(inv) => inv.data.invitationIdentifier === invitationId
);
if (!invitationInstance) {
throw new Error('Invitation not found');
}
// Add selected inputs
const inputs = selectedUtxos.map((utxo) => ({
outpointTransactionHash: new Uint8Array(
Buffer.from(utxo.outpointTransactionHash, 'hex')
),
outpointIndex: utxo.outpointIndex,
}));
await invitationInstance.addInputs(inputs);
// Add change output
const outputs = [
{
valueSatoshis: changeAmount,
},
];
await invitationInstance.addOutputs(outputs);
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,
appService,
showError,
setStatus,
]);
// ── Publish the invitation ──────────────────────────────────────
const publishInvitation = useCallback(async () => {
if (!invitationId || !appService) return;
setIsProcessing(true);
setStatus('Publishing invitation...');
try {
const invitationInstance = appService.invitations.find(
(inv) => inv.data.invitationIdentifier === invitationId
);
if (!invitationInstance) {
throw new Error('Invitation not found');
}
// Already tracked and synced via SSE from createInvitation
setCurrentStep((prev) => prev + 1);
setStatus('Invitation published');
} catch (error) {
showError(
`Failed to publish: ${error instanceof Error ? error.message : String(error)}`
);
} finally {
setIsProcessing(false);
}
}, [invitationId, appService, showError, setStatus]);
// ── Navigate to the next step ───────────────────────────────────
const nextStep = useCallback(async () => {
if (currentStep >= steps.length - 1) return;
const stepType = currentStepData?.type;
if (stepType === 'variables') {
const emptyVars = variables.filter(
(v) => !v.value || v.value.trim() === ''
);
if (emptyVars.length > 0) {
showError(
`Please enter values for: ${emptyVars.map((v) => v.name).join(', ')}`
);
return;
}
await createInvitationWithVariables();
return;
}
if (stepType === 'inputs') {
await addInputsAndOutputs();
return;
}
if (stepType === 'review') {
await publishInvitation();
return;
}
setCurrentStep((prev) => prev + 1);
setFocusArea('content');
setFocusedInput(0);
}, [
currentStep,
steps.length,
currentStepData,
variables,
showError,
createInvitationWithVariables,
addInputsAndOutputs,
publishInvitation,
]);
// ── Navigate to the previous step ──────────────────────────────
const previousStep = useCallback(() => {
if (currentStep <= 0) {
goBack();
return;
}
setCurrentStep((prev) => prev - 1);
setFocusArea('content');
setFocusedInput(0);
}, [currentStep, goBack]);
// ── Cancel the wizard entirely ──────────────────────────────────
const cancel = useCallback(() => {
goBack();
}, [goBack]);
// ── Public API ──────────────────────────────────────────────────
return {
// Navigation / meta
template,
templateIdentifier,
actionIdentifier,
roleIdentifier,
action,
actionName,
// Steps
steps,
currentStep,
currentStepData,
// Variables
variables,
updateVariable,
handleTextInputSubmit,
// UTXOs
availableUtxos,
setAvailableUtxos,
selectedUtxoIndex,
setSelectedUtxoIndex,
requiredAmount,
fee,
selectedAmount,
changeAmount,
toggleUtxoSelection,
// Invitation
invitation,
invitationId,
// UI focus
focusedInput,
setFocusedInput,
focusedButton,
setFocusedButton,
focusArea,
setFocusArea,
isProcessing,
textInputHasFocus,
// Actions
nextStep,
previousStep,
cancel,
copyId,
} as const;
}
/** Convenience type so other files can type the return value. */
export type ActionWizardState = ReturnType<typeof useActionWizard>;