Fix receive and send

This commit is contained in:
2026-03-16 06:48:29 +00:00
parent 9ef1720e1f
commit dd275593cd
28 changed files with 1918 additions and 769 deletions

View File

@@ -6,7 +6,7 @@
import React, { useState, useCallback } from 'react';
import { Box, Text, useInput } from 'ink';
import TextInput from 'ink-text-input';
import TextInput from '../components/TextInput.js';
import { Button } from '../components/Button.js';
import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js';

View File

@@ -21,10 +21,7 @@ import type { XOTemplate } from '@xo-cash/types';
import {
formatTemplateListItem,
formatActionListItem,
deduplicateStartingActions,
getTemplateRoles,
getRolesForAction,
type UniqueStartingAction,
} from '../../utils/template-utils.js';
/**
@@ -33,7 +30,7 @@ import {
interface TemplateItem {
template: XOTemplate;
templateIdentifier: string;
startingActions: UniqueStartingAction[];
availableActions: TemplateActionItem[];
}
/**
@@ -42,9 +39,17 @@ interface TemplateItem {
type TemplateListItem = ListItemData<TemplateItem>;
/**
* Action list item with UniqueStartingAction value.
* Action list item with available action value.
*/
type ActionListItem = ListItemData<UniqueStartingAction>;
type ActionListItem = ListItemData<TemplateActionItem>;
interface TemplateActionItem {
actionIdentifier: string;
name: string;
description?: string;
roles: string[];
source: 'starting' | 'next' | 'starting+next';
}
/**
* Template List Screen Component.
@@ -76,19 +81,90 @@ export function TemplateListScreen(): React.ReactElement {
setStatus('Loading templates...');
const templateList = await appService.engine.listImportedTemplates();
const allUtxos = await appService.engine.listUnspentOutputsData();
const ownedOutputsByTemplate = new Map<string, Set<string>>();
for (const utxo of allUtxos) {
const existing = ownedOutputsByTemplate.get(utxo.templateIdentifier) ?? new Set<string>();
existing.add(utxo.outputIdentifier);
ownedOutputsByTemplate.set(utxo.templateIdentifier, existing);
}
const loadedTemplates = await Promise.all(
templateList.map(async (template) => {
const templateIdentifier = generateTemplateIdentifier(template);
const rawStartingActions = await appService.engine.listStartingActions(templateIdentifier);
const actionMap = new Map<string, TemplateActionItem>();
// Use utility function to deduplicate actions
const startingActions = deduplicateStartingActions(template, rawStartingActions);
for (const startingAction of rawStartingActions) {
const existing = actionMap.get(startingAction.action);
if (existing) {
if (!existing.roles.includes(startingAction.role)) {
existing.roles.push(startingAction.role);
}
continue;
}
const actionDef = template.actions?.[startingAction.action];
actionMap.set(startingAction.action, {
actionIdentifier: startingAction.action,
name: actionDef?.name || startingAction.action,
description: actionDef?.description,
roles: [startingAction.role],
source: 'starting',
});
}
const ownedOutputIdentifiers = ownedOutputsByTemplate.get(templateIdentifier) ?? new Set<string>();
for (const outputIdentifier of ownedOutputIdentifiers) {
const outputDef = template.outputs?.[outputIdentifier];
if (!outputDef || typeof outputDef.lockscript !== 'string') continue;
const lockingScriptDefinition = (template.lockingScripts as Record<string, unknown> | undefined)?.[outputDef.lockscript] as
| { roles?: Record<string, { actions?: Array<{ action?: string; role?: string } | string> }> }
| undefined;
if (!lockingScriptDefinition?.roles) continue;
for (const [lockscriptRoleId, lockscriptRoleDef] of Object.entries(lockingScriptDefinition.roles)) {
for (const actionSpec of lockscriptRoleDef.actions ?? []) {
const actionIdentifier = typeof actionSpec === 'string'
? actionSpec
: actionSpec.action;
if (!actionIdentifier) continue;
const roleIdentifier = typeof actionSpec === 'string'
? lockscriptRoleId
: (actionSpec.role ?? lockscriptRoleId);
const existing = actionMap.get(actionIdentifier);
if (existing) {
if (!existing.roles.includes(roleIdentifier)) {
existing.roles.push(roleIdentifier);
}
if (existing.source === 'starting') {
existing.source = 'starting+next';
}
continue;
}
const actionDef = template.actions?.[actionIdentifier];
actionMap.set(actionIdentifier, {
actionIdentifier,
name: actionDef?.name || actionIdentifier,
description: actionDef?.description,
roles: [roleIdentifier],
source: 'next',
});
}
}
}
const availableActions = Array.from(actionMap.values()).sort((a, b) => a.name.localeCompare(b.name));
return {
template,
templateIdentifier,
startingActions,
availableActions,
};
})
);
@@ -111,7 +187,7 @@ export function TemplateListScreen(): React.ReactElement {
// Get current template and its actions
const currentTemplate = templates[selectedTemplateIndex];
const currentActions = currentTemplate?.startingActions ?? [];
const currentActions = currentTemplate?.availableActions ?? [];
/**
* Build template list items for ScrollableList.
@@ -137,12 +213,17 @@ export function TemplateListScreen(): React.ReactElement {
const formatted = formatActionListItem(
action.actionIdentifier,
currentTemplate?.template?.actions?.[action.actionIdentifier],
action.roleCount,
action.roles.length,
index
);
const sourceSuffix = action.source === 'next'
? ' [next]'
: action.source === 'starting+next'
? ' [start+next]'
: '';
return {
key: action.actionIdentifier,
label: formatted.label,
label: `${formatted.label}${sourceSuffix}`,
description: formatted.description,
value: action,
hidden: !formatted.isValid,
@@ -171,6 +252,7 @@ export function TemplateListScreen(): React.ReactElement {
navigate('wizard', {
templateIdentifier: currentTemplate.templateIdentifier,
actionIdentifier: action.actionIdentifier,
actionRoles: action.roles,
template: currentTemplate.template,
});
}, [currentTemplate, navigate]);
@@ -267,7 +349,7 @@ export function TemplateListScreen(): React.ReactElement {
paddingX={1}
flexGrow={1}
>
<Text color={colors.primary} bold> Starting Actions </Text>
<Text color={colors.primary} bold> Available Actions </Text>
{isLoading ? (
<Box marginTop={1}>
<Text color={colors.textMuted}>Loading...</Text>
@@ -283,7 +365,7 @@ export function TemplateListScreen(): React.ReactElement {
onSelect={setSelectedActionIndex}
onActivate={handleActionActivate}
focus={focusedPanel === 'actions'}
emptyMessage="No starting actions available"
emptyMessage="No actions available"
renderItem={renderActionItem}
/>
)}
@@ -339,9 +421,6 @@ export function TemplateListScreen(): React.ReactElement {
const action = currentActions[selectedActionIndex];
if (!action) return null;
// Get roles that can start this action using utility function
const availableRoles = getRolesForAction(currentTemplate.template, action.actionIdentifier);
return (
<>
<Text color={colors.text} bold>
@@ -351,16 +430,24 @@ export function TemplateListScreen(): React.ReactElement {
{action.description || 'No description available'}
</Text>
{/* List available roles for this action */}
{availableRoles.length > 0 && (
{/* List roles available for this action in current context */}
{action.roles.length > 0 && (
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Available Roles:</Text>
{availableRoles.map((role) => (
<Text key={role.roleId} color={colors.textMuted}>
{' '}- {role.name}
{role.description ? `: ${role.description}` : ''}
</Text>
))}
{action.roles.map((roleId) => {
const roleDef = currentTemplate.template.roles?.[roleId];
const roleName = typeof roleDef === 'object' ? roleDef?.name ?? roleId : roleId;
const roleDescription = typeof roleDef === 'object' ? roleDef?.description : undefined;
return (
<Text key={roleId} color={colors.textMuted}>
{' '}- {roleName}
{roleDescription ? `: ${roleDescription}` : ''}
</Text>
);
})}
<Text color={colors.textMuted}>
{' '}Source: {action.source}
</Text>
</Box>
)}
</>
@@ -370,7 +457,7 @@ export function TemplateListScreen(): React.ReactElement {
) : focusedPanel === 'actions' && !currentTemplate ? (
<Text color={colors.textMuted}>Select a template first</Text>
) : focusedPanel === 'actions' && currentActions.length === 0 ? (
<Text color={colors.textMuted}>No starting actions available</Text>
<Text color={colors.textMuted}>No actions available</Text>
) : null}
</Box>
</Box>

View File

@@ -19,9 +19,10 @@ import { generateTemplateIdentifier } from '@xo-cash/engine';
// Import utility functions
import {
formatHistoryListItem,
buildHistoryDisplayRows,
getHistoryItemColorName,
formatHistoryDate,
type HistoryDisplayRow,
type HistoryColorName,
} from '../../utils/history-utils.js';
@@ -58,9 +59,9 @@ const menuItems: ListItemData<string>[] = [
];
/**
* History list item with HistoryItem value.
* History list item with display row value.
*/
type HistoryListItem = ListItemData<HistoryItem>;
type HistoryListItem = ListItemData<HistoryDisplayRow>;
/**
* Wallet State Screen Component.
@@ -196,15 +197,14 @@ export function WalletStateScreen(): React.ReactElement {
* Build history list items for ScrollableList.
*/
const historyListItems = useMemo((): HistoryListItem[] => {
return history.map(item => {
const formatted = formatHistoryListItem(item, false);
return buildHistoryDisplayRows(history).map(row => {
return {
key: item.id,
label: formatted.label,
description: formatted.description,
value: item,
color: formatted.color,
hidden: !formatted.isValid,
key: row.id,
label: row.label,
description: row.description,
value: row,
color: getHistoryItemColorName(row, false),
hidden: false,
};
});
}, [history]);
@@ -224,49 +224,63 @@ export function WalletStateScreen(): React.ReactElement {
isSelected: boolean,
isFocused: boolean
): React.ReactNode => {
const historyItem = item.value;
if (!historyItem) return null;
const row = item.value;
if (!row) return null;
const colorName = getHistoryItemColorName(historyItem.type, isFocused);
const colorName = getHistoryItemColorName(row, isFocused);
const itemColor = isFocused ? colors.focus : getHistoryColor(colorName);
const dateStr = formatHistoryDate(historyItem.timestamp);
const dateStr = formatHistoryDate(row.timestamp);
const indicator = isFocused ? '▸ ' : ' ';
const groupingPrefix = row.isNested ? ' -> ' : '';
// Format based on type
if (historyItem.type === 'invitation_created') {
if (row.type === 'invitation') {
return (
<Box flexDirection="row" justifyContent="space-between">
<Text color={itemColor}>
{indicator}[Invitation] {historyItem.description}
{indicator}[Invitation] {row.label}
</Text>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
} else if (historyItem.type === 'utxo_reserved') {
const sats = historyItem.valueSatoshis ?? 0n;
}
if (row.type === 'invitation_input') {
return (
<Box flexDirection="row" justifyContent="space-between">
<Box>
<Text color={itemColor}>
{indicator}[Reserved] {formatSatoshis(sats)}
{indicator}{groupingPrefix}[Input] {row.label}
</Text>
<Text color={colors.textMuted}> {historyItem.description}</Text>
{row.description && <Text color={colors.textMuted}> {row.description}</Text>}
</Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
} else if (historyItem.type === 'utxo_received') {
const sats = historyItem.valueSatoshis ?? 0n;
const reservedTag = historyItem.reserved ? ' [Reserved]' : '';
}
if (row.type === 'invitation_output') {
const sats = row.utxo?.valueSatoshis ?? 0n;
return (
<Box flexDirection="row" justifyContent="space-between">
<Box flexDirection="row">
<Text color={itemColor}>
{indicator}{formatSatoshis(sats)}
</Text>
<Text color={colors.textMuted}>
{' '}{historyItem.description}{reservedTag}
{indicator}{groupingPrefix}[Output] {formatSatoshis(sats)}
</Text>
{row.description && <Text color={colors.textMuted}> {row.description}</Text>}
</Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
}
if (row.type === 'utxo') {
const sats = row.utxo?.valueSatoshis ?? 0n;
const reservedTag = row.utxo?.reserved ? ' [Reserved]' : '';
return (
<Box flexDirection="row" justifyContent="space-between">
<Box flexDirection="row">
<Text color={itemColor}>{indicator}{formatSatoshis(sats)}</Text>
{row.description && <Text color={colors.textMuted}> {row.description}{reservedTag}</Text>}
</Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
@@ -277,7 +291,7 @@ export function WalletStateScreen(): React.ReactElement {
return (
<Box flexDirection="row" justifyContent="space-between">
<Text color={itemColor}>
{indicator}{historyItem.type}: {historyItem.description}
{indicator}{row.label}
</Text>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>

View File

@@ -205,7 +205,13 @@ export function ActionWizardScreen(): React.ReactElement {
/>
);
case 'publish':
return <PublishStep invitationId={wizard.invitationId} />;
return (
<PublishStep
invitationId={wizard.invitationId}
requirementsComplete={wizard.requirementsComplete}
hasSignedAndBroadcasted={wizard.hasSignedAndBroadcasted}
/>
);
default:
return null;
}
@@ -284,7 +290,9 @@ export function ActionWizardScreen(): React.ReactElement {
</Box>
<Button
label={
wizard.currentStepData?.type === "publish" ? "Done" : "Next"
wizard.currentStepData?.type === "publish"
? (wizard.canSignAndBroadcast ? "Sign & Broadcast" : "Done")
: "Next"
}
focused={
wizard.focusArea === "buttons" &&

View File

@@ -4,15 +4,19 @@ import { colors } from '../../../theme.js';
interface PublishStepProps {
invitationId: string | null;
requirementsComplete: boolean;
hasSignedAndBroadcasted: boolean;
}
export function PublishStep({
invitationId,
requirementsComplete,
hasSignedAndBroadcasted,
}: PublishStepProps): React.ReactElement {
return (
<Box flexDirection='column'>
<Text color={colors.success} bold>
Invitation Created & Published!
Invitation Ready
</Text>
<Box marginTop={1} flexDirection='column'>
@@ -30,9 +34,19 @@ export function PublishStep({
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted}>
Share this ID with the other party to complete the transaction.
</Text>
{hasSignedAndBroadcasted ? (
<Text color={colors.success}>
Transaction signed and broadcasted.
</Text>
) : requirementsComplete ? (
<Text color={colors.textMuted}>
Requirements are complete. Use the Sign & Broadcast button to finalize.
</Text>
) : (
<Text color={colors.warning}>
Requirements are incomplete. Complete missing requirements before signing.
</Text>
)}
</Box>
<Box marginTop={1}>

View File

@@ -4,6 +4,15 @@ import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
import { formatSatoshis } from '../../theme.js';
import { copyToClipboard } from '../../utils/clipboard.js';
import type { XOTemplate, XOInvitation, XOTemplateTransactionOutput } from '@xo-cash/types';
import {
autoSelectGreedyUtxos,
getTransactionOutputIdentifier,
isInvitationRequirementsComplete,
mapUnspentOutputsToSelectable,
resolveActionRoles,
resolveProvidedLockingBytecodeHex,
roleRequiresInputs,
} from '../../../utils/invitation-flow.js';
import type {
WizardStep,
VariableInput,
@@ -22,6 +31,7 @@ export function useActionWizard() {
const templateIdentifier = navData.templateIdentifier as string | undefined;
const actionIdentifier = navData.actionIdentifier as string | undefined;
const template = navData.template as XOTemplate | undefined;
const actionRolesFromNavigation = navData.actionRoles as string[] | undefined;
// ── Role selection state ────────────────────────────────────────
const [roleIdentifier, setRoleIdentifier] = useState<string | undefined>();
@@ -32,14 +42,20 @@ export function useActionWizard() {
* `start` entries filtered to the current action.
*/
const availableRoles = useMemo(() => {
if (!template || !actionIdentifier) return [];
const starts = template.start ?? [];
const roleIds = starts
.filter((s) => s.action === actionIdentifier)
.map((s) => s.role);
// Deduplicate while preserving order
return [...new Set(roleIds)];
}, [template, actionIdentifier]);
return resolveActionRoles(template, actionIdentifier, actionRolesFromNavigation);
}, [template, actionIdentifier, actionRolesFromNavigation]);
const effectiveRoleForFlow = roleIdentifier ?? (
availableRoles.length === 1 ? availableRoles[0] : undefined
);
// Keep role state aligned when only one role exists for the selected action.
// This preserves existing UI bindings that read roleIdentifier directly.
useEffect(() => {
if (!roleIdentifier && availableRoles.length === 1) {
setRoleIdentifier(availableRoles[0]);
}
}, [roleIdentifier, availableRoles]);
// ── Wizard state ─────────────────────────────────────────────────
const [steps, setSteps] = useState<WizardStep[]>([]);
@@ -57,6 +73,8 @@ export function useActionWizard() {
// ── Invitation ───────────────────────────────────────────────────
const [invitation, setInvitation] = useState<XOInvitation | null>(null);
const [invitationId, setInvitationId] = useState<string | null>(null);
const [requirementsComplete, setRequirementsComplete] = useState(false);
const [hasSignedAndBroadcasted, setHasSignedAndBroadcasted] = useState(false);
// ── UI state ─────────────────────────────────────────────────────
const [focusedInput, setFocusedInput] = useState(0);
@@ -78,9 +96,19 @@ export function useActionWizard() {
const textInputHasFocus =
currentStepData?.type === 'variables' && focusArea === 'content';
// Whether the wizard actually includes an inputs step — this determines if
// the creator provided funding and therefore can sign & broadcast locally.
const wizardCollectedInputs = steps.some((s) => s.type === 'inputs');
const canSignAndBroadcast =
currentStepData?.type === 'publish'
&& wizardCollectedInputs
&& requirementsComplete
&& !hasSignedAndBroadcasted;
// ── Initialization ───────────────────────────────────────────────
// Builds the wizard steps dynamically based on the selected role.
// Re-runs when roleIdentifier changes to add role-specific steps.
// Re-runs when role selection changes to add role-specific steps.
useEffect(() => {
if (!template || !actionIdentifier) {
showError('Missing wizard data');
@@ -89,14 +117,17 @@ export function useActionWizard() {
}
const wizardSteps: WizardStep[] = [];
const shouldShowRoleSelection = availableRoles.length > 1;
// Always start with role selection
wizardSteps.push({ name: 'Select Role', type: 'role-select' });
// Only require explicit role selection when the action is actually ambiguous.
if (shouldShowRoleSelection) {
wizardSteps.push({ name: 'Select Role', type: 'role-select' });
}
// Add role-specific steps only after role is selected
if (roleIdentifier) {
if (effectiveRoleForFlow) {
const act = template.actions?.[actionIdentifier];
const role = act?.roles?.[roleIdentifier];
const role = act?.roles?.[effectiveRoleForFlow];
const requirements = role?.requirements;
// Add variables step if needed
@@ -116,8 +147,23 @@ export function useActionWizard() {
setVariables(varInputs);
}
// Add inputs step if role requires slots (funding inputs)
if (requirements?.slots && requirements.slots.min > 0) {
// Determine whether the creator should provide inputs during this wizard.
//
// Single-role actions (e.g. "send"): the creator is the sole participant,
// so we collect inputs here if the role needs them at all.
//
// Multi-role actions (e.g. "receive"): the creator is setting up the
// invitation for another party to accept. We only collect inputs during
// creation if the role EXPLICITLY requires them (slots.min > 0).
// Implicit inputs (transaction-level) are assumed to be provided later
// by the accepting party.
const totalActionRoles = Object.keys(act?.roles ?? {}).length;
const isSingleRoleAction = totalActionRoles <= 1;
const shouldCollectInputs =
isSingleRoleAction && roleRequiresInputs(template, actionIdentifier, effectiveRoleForFlow);
if (shouldCollectInputs) {
wizardSteps.push({ name: 'Select UTXOs', type: 'inputs' });
}
}
@@ -127,11 +173,12 @@ export function useActionWizard() {
wizardSteps.push({ name: 'Publish', type: 'publish' });
setSteps(wizardSteps);
setStatus(roleIdentifier ? `${actionIdentifier}/${roleIdentifier}` : actionIdentifier);
setStatus(effectiveRoleForFlow ? `${actionIdentifier}/${effectiveRoleForFlow}` : actionIdentifier);
}, [
template,
actionIdentifier,
roleIdentifier,
availableRoles.length,
effectiveRoleForFlow,
showError,
goBack,
setStatus,
@@ -141,12 +188,12 @@ export function useActionWizard() {
// This runs after the main useEffect has rebuilt steps, ensuring
// we advance to the correct step (variables, inputs, or review).
useEffect(() => {
if (roleIdentifier && currentStep === 0 && steps[0]?.type === 'role-select') {
if (effectiveRoleForFlow && currentStep === 0 && steps[0]?.type === 'role-select') {
setCurrentStep(1);
setFocusArea('content');
setFocusedInput(0);
}
}, [roleIdentifier, currentStep, steps]);
}, [effectiveRoleForFlow, currentStep, steps]);
// ── Update a single variable value ───────────────────────────────
const updateVariable = useCallback((index: number, value: string) => {
@@ -195,6 +242,25 @@ export function useActionWizard() {
}
}, [invitationId, showInfo, showError]);
const refreshRequirementState = useCallback(async (identifier: string | null = invitationId) => {
if (!identifier || !appService) {
setRequirementsComplete(false);
return false;
}
const invitationInstance = appService.invitations.find(
(inv) => inv.data.invitationIdentifier === identifier
);
if (!invitationInstance) {
setRequirementsComplete(false);
return false;
}
const complete = await isInvitationRequirementsComplete(invitationInstance);
setRequirementsComplete(complete);
return complete;
}, [appService, invitationId]);
// ── Load available UTXOs for the inputs step ────────────────────
const loadAvailableUtxos = useCallback(async () => {
if (!invitation || !templateIdentifier || !appService || !invitationId) {
@@ -225,49 +291,19 @@ export function useActionWizard() {
throw new Error('Invitation not found');
}
// Query for suitable resources
// Query for suitable resources.
// NOTE: Even for single-role actions we still keep the user in the loop for inputs:
// we only surface UTXOs the engine/template currently considers "selectable" and let
// the user confirm them in the inputs step. If selectable semantics evolve, revisit here.
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);
// Map to selectable UTXOs and pre-select greedily.
const mappedUtxos = mapUnspentOutputsToSelectable(unspentOutputs);
const autoSelectedUtxos = autoSelectGreedyUtxos(mappedUtxos, requested + fee);
setAvailableUtxos(autoSelectedUtxos as SelectableUTXO[]);
setStatus('Ready');
} catch (error) {
showError(
@@ -301,7 +337,7 @@ export function useActionWizard() {
*/
const createInvitationWithVariables = useCallback(
async (roleId?: string): Promise<boolean> => {
const effectiveRole = roleId ?? roleIdentifier;
const effectiveRole = roleId ?? effectiveRoleForFlow;
if (
!templateIdentifier ||
@@ -350,6 +386,14 @@ export function useActionWizard() {
inv = invitationInstance.data;
}
const variableValuesByIdentifier = variables.reduce((acc, variable) => {
if (typeof variable.value === 'string' && variable.value.trim().length > 0) {
acc[variable.id] = variable.value;
}
return acc;
}, {} as Record<string, string>);
// Add template-required outputs for the current role
const act = template.actions?.[actionIdentifier];
const transaction = act?.transaction
@@ -358,17 +402,26 @@ export function useActionWizard() {
if (transaction?.outputs && transaction.outputs.length > 0) {
setStatus('Adding required outputs...');
const outputsToAdd = await Promise.all(transaction.outputs.map(async (output: XOTemplateTransactionOutput) => {
const outputIdentifier = getTransactionOutputIdentifier(output);
if (!outputIdentifier) {
throw new Error('Invalid transaction output definition');
}
const outputsToAdd = await Promise.all(transaction.outputs.map(
async (output: XOTemplateTransactionOutput) => ({
// TODO: Fix this. Currently, there is a type mismatch due to branches/versions of the libraries
outputIdentifier: output as unknown as string,
// roleIdentifier: roleIdentifier,
const providedLockingBytecodeHex = resolveProvidedLockingBytecodeHex(
template,
outputIdentifier,
variableValuesByIdentifier,
);
// TODO: This feels like an odd requirement? Shouldnt this be handled in the engine?
lockingBytecode: await invitationInstance.generateLockingBytecode(output as unknown as string, roleIdentifier),
})
));
const lockingBytecodeHex = providedLockingBytecodeHex
?? await invitationInstance.generateLockingBytecode(outputIdentifier, effectiveRole);
return {
outputIdentifier,
lockingBytecode: lockingBytecodeHex,
};
}));
// TODO: Clean this up. Suggestions: 1. Convert to bytes above. 2. Have addOuputs accept a hex string. 3. Have addOutputs handling the lockscript generation
await invitationInstance.addOutputs(outputsToAdd.map((output) => ({
@@ -381,6 +434,7 @@ export function useActionWizard() {
}
setInvitation(inv);
await refreshRequirementState(invId);
setStatus('Invitation created');
return true;
} catch (error) {
@@ -395,15 +449,51 @@ export function useActionWizard() {
[
templateIdentifier,
actionIdentifier,
roleIdentifier,
effectiveRoleForFlow,
template,
variables,
appService,
showError,
setStatus,
refreshRequirementState,
]
);
// Ensure invitation exists before entering input/review/publish stages.
useEffect(() => {
const ensureInvitation = async () => {
if (!currentStepData) return;
if (currentStepData.type !== 'inputs' && currentStepData.type !== 'review' && currentStepData.type !== 'publish') {
return;
}
if (invitationId) {
if (currentStepData.type === 'inputs' && availableUtxos.length === 0 && !isProcessing) {
await loadAvailableUtxos();
}
return;
}
if (!effectiveRoleForFlow || isProcessing) return;
const success = await createInvitationWithVariables(effectiveRoleForFlow);
if (!success) return;
if (currentStepData.type === 'inputs') {
await loadAvailableUtxos();
}
};
ensureInvitation().catch(() => {});
}, [
currentStepData,
invitationId,
effectiveRoleForFlow,
isProcessing,
createInvitationWithVariables,
loadAvailableUtxos,
availableUtxos.length,
]);
// ── Add selected inputs + change output to the invitation ───────
const addInputsAndOutputs = useCallback(async () => {
if (!invitationId || !invitation || !appService) return;
@@ -459,6 +549,7 @@ export function useActionWizard() {
];
await invitationInstance.addOutputs(outputs);
await refreshRequirementState(invitationId);
setCurrentStep((prev) => prev + 1);
setStatus('Inputs and outputs added');
@@ -480,14 +571,15 @@ export function useActionWizard() {
appService,
showError,
setStatus,
refreshRequirementState,
]);
// ── Publish the invitation ──────────────────────────────────────
const publishInvitation = useCallback(async () => {
// ── Move to publish step ────────────────────────────────────────
const advanceToPublishStep = useCallback(async () => {
if (!invitationId || !appService) return;
setIsProcessing(true);
setStatus('Publishing invitation...');
setStatus('Preparing publish step...');
try {
const invitationInstance = appService.invitations.find(
@@ -498,23 +590,61 @@ export function useActionWizard() {
throw new Error('Invitation not found');
}
// Already tracked and synced via SSE from createInvitation
await refreshRequirementState(invitationId);
setCurrentStep((prev) => prev + 1);
setStatus('Invitation published');
setStatus('Ready to publish');
} catch (error) {
showError(
`Failed to publish: ${error instanceof Error ? error.message : String(error)}`
`Failed to prepare publish step: ${error instanceof Error ? error.message : String(error)}`
);
} finally {
setIsProcessing(false);
}
}, [invitationId, appService, showError, setStatus]);
}, [invitationId, appService, showError, setStatus, refreshRequirementState]);
// ── Sign and broadcast from publish step ────────────────────────
const signAndBroadcastInvitation = useCallback(async () => {
if (!invitationId || !appService) return;
setIsProcessing(true);
setStatus('Signing invitation...');
try {
const invitationInstance = appService.invitations.find(
(inv) => inv.data.invitationIdentifier === invitationId
);
if (!invitationInstance) {
throw new Error('Invitation not found');
}
const complete = await refreshRequirementState(invitationId);
if (!complete) {
showError('Invitation requirements are not complete yet.');
return;
}
if (!wizardCollectedInputs) {
showError('This action does not require funding inputs, so it cannot be signed and broadcasted here.');
return;
}
await invitationInstance.sign();
setStatus('Broadcasting transaction...');
await invitationInstance.broadcast();
setHasSignedAndBroadcasted(true);
setStatus('Transaction signed and broadcasted');
showInfo('Transaction signed and broadcasted.');
await refreshRequirementState(invitationId);
} catch (error) {
showError(`Failed to sign and broadcast: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsProcessing(false);
}
}, [invitationId, appService, setStatus, showError, showInfo, refreshRequirementState, wizardCollectedInputs]);
// ── Navigate to the next step ───────────────────────────────────
const nextStep = useCallback(async () => {
if (currentStep >= steps.length - 1) return;
const stepType = currentStepData?.type;
if (currentStep >= steps.length - 1 && stepType !== 'publish') return;
// ── Role selection ──────────────────────────────────────────
if (stepType === 'role-select') {
@@ -531,7 +661,19 @@ export function useActionWizard() {
const hasVariables =
requirements?.variables && requirements.variables.length > 0;
const hasSlots = requirements?.slots && requirements.slots.min > 0;
// Mirror the inputs-step inference from the step-building effect:
// single-role → any inputs; multi-role → explicit requirements only.
const totalActionRoles = Object.keys(act?.roles ?? {}).length;
const roleExplicitlyNeedsInputs =
(requirements?.slots && requirements.slots.min > 0)
|| (act?.requirements?.roles?.find(
(r: { role: string; slots?: { min?: number } }) => r.role === selectedRole,
)?.slots?.min ?? 0) > 0;
const hasSlots = totalActionRoles <= 1
? roleRequiresInputs(template, actionIdentifier, selectedRole)
: roleExplicitlyNeedsInputs;
// If there is no variables step, the invitation must be created now
// because the variables step would normally handle it.
@@ -582,17 +724,38 @@ export function useActionWizard() {
// ── Inputs ──────────────────────────────────────────────────
if (stepType === 'inputs') {
if (!invitationId) {
const success = await createInvitationWithVariables();
if (!success) return;
await loadAvailableUtxos();
return;
}
await addInputsAndOutputs();
return;
}
// ── Review ──────────────────────────────────────────────────
if (stepType === 'review') {
await publishInvitation();
if (!invitationId) {
const success = await createInvitationWithVariables();
if (!success) return;
}
await advanceToPublishStep();
return;
}
// ── Generic advance (e.g. publish → done) ───────────────────
// ── Publish ─────────────────────────────────────────────────
if (stepType === 'publish') {
if (canSignAndBroadcast) {
await signAndBroadcastInvitation();
return;
}
// Done should exit the wizard, not advance past the final step.
goBack();
return;
}
// ── Generic advance ─────────────────────────────────────────
setCurrentStep((prev) => prev + 1);
setFocusArea('content');
setFocusedInput(0);
@@ -600,6 +763,7 @@ export function useActionWizard() {
currentStep,
steps,
currentStepData,
canSignAndBroadcast,
availableRoles,
selectedRoleIndex,
template,
@@ -609,7 +773,11 @@ export function useActionWizard() {
createInvitationWithVariables,
loadAvailableUtxos,
addInputsAndOutputs,
publishInvitation,
advanceToPublishStep,
requirementsComplete,
hasSignedAndBroadcasted,
signAndBroadcastInvitation,
goBack,
]);
// ── Navigate to the previous step ──────────────────────────────
@@ -667,6 +835,9 @@ export function useActionWizard() {
// Invitation
invitation,
invitationId,
requirementsComplete,
hasSignedAndBroadcasted,
canSignAndBroadcast,
// UI focus
focusedInput,

View File

@@ -6,10 +6,11 @@
* Shows required, selected, and change amounts.
*/
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Box, Text, useInput } from 'ink';
import { colors, formatSatoshis } from '../../../../theme.js';
import type { InputsSelectStepProps, SelectableUTXO } from '../types.js';
import { autoSelectGreedyUtxos, mapUnspentOutputsToSelectable } from '../../../../../utils/invitation-flow.js';
/** Default fee estimate in satoshis. */
const DEFAULT_FEE = 500n;
@@ -64,34 +65,9 @@ export function InputsSelectStep({
outputIdentifier: 'receiveOutput',
});
// Map to selectable UTXOs
const selectable: 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,
}));
// Greedy auto-select, skipping duplicate locking bytecodes
let accumulated = 0n;
const seenBytecodes = new Set<string>();
for (const utxo of selectable) {
if (utxo.lockingBytecode && seenBytecodes.has(utxo.lockingBytecode)) continue;
if (utxo.lockingBytecode) seenBytecodes.add(utxo.lockingBytecode);
utxo.selected = true;
accumulated += utxo.valueSatoshis;
if (accumulated >= required + fee) break;
}
setUtxos(selectable);
const selectable = mapUnspentOutputsToSelectable(unspentOutputs);
const autoSelected = autoSelectGreedyUtxos(selectable, required + fee);
setUtxos(autoSelected as SelectableUTXO[]);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -99,9 +75,15 @@ export function InputsSelectStep({
}
}, [invitation, computeRequiredAmount, fee]);
// Load UTXOs on mount
// Load UTXOs once on mount. We use a ref guard to prevent re-firing when
// `loadUtxos` identity changes due to parent re-renders — each re-fire
// flashes the loading state, causing the visible flicker bug.
const hasLoadedRef = useRef(false);
useEffect(() => {
if (isActive) loadUtxos();
if (isActive && !hasLoadedRef.current) {
hasLoadedRef.current = true;
loadUtxos();
}
}, [isActive, loadUtxos]);
/**