Fix receive and send
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user