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

@@ -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,