Add variable step

This commit is contained in:
2026-05-11 12:18:47 +00:00
parent 6c01ac1c1b
commit a0d9775015
4 changed files with 280 additions and 21 deletions

View File

@@ -4,7 +4,7 @@ import type {
Engine,
GetSpendableResourcesParameters,
} from "@xo-cash/engine";
import { hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine";
import { generateTemplateIdentifier, hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine";
import type {
XOInvitation,
XOInvitationCommit,
@@ -498,16 +498,38 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
);
}
const resolvedOptions: GetSpendableResourcesParameters = {
templateIdentifier,
outputIdentifier: options.outputIdentifier ?? fallbackOutputIdentifier ?? "",
};
// const resolvedOptions: GetSpendableResourcesParameters = {
// templateIdentifier,
// outputIdentifier: options.outputIdentifier ?? fallbackOutputIdentifier ?? "",
// };
// Find the suitable resources
const { unspentOutputs } = await this.engine.getSpendableResources(
this.data,
resolvedOptions,
);
// There are disagreements around whether all spendables should be returned from getSpendableResources.
// I had a fix merged in, but it got overwritten. So, im just going to get all of them manually and go around
// The engine's expectations.
// To do this, we are going to grab all out templates
const templates = await this.engine.listImportedTemplates();
// For each template, we need to create a 2d array of all the outputs
const outputs = templates.map(template => {
return Object.keys(template.outputs).map(output => {
const templateIdentifier = generateTemplateIdentifier(template);
return {
templateIdentifier,
outputIdentifier: output,
};
});
});
// then, for each output, we need to get the spendable resources
const spendableResources = await Promise.all(outputs.flat().map(output => {
return this.engine.getSpendableResources(this.data, {
templateIdentifier: output.templateIdentifier,
outputIdentifier: output.outputIdentifier,
});
}));
const unspentOutputs = spendableResources.flatMap(resource => resource.unspentOutputs);
// Update the status of the invitation
await this.updateStatus();

View File

@@ -17,15 +17,15 @@ import { StepIndicator, type Step } from '../../../components/ProgressBar.js';
import { FetchInvitationStep } from './steps/FetchInvitationStep.js';
import { PreviewInvitationStep } from './steps/PreviewInvitationStep.js';
import { RoleSelectStep } from './steps/RoleSelectStep.js';
import { VariablesStep } from './steps/VariablesStep.js';
import { InputsSelectStep } from './steps/InputsSelectStep.js';
import { ReviewStep } from './steps/ReviewStep.js';
import { IMPORT_STEPS, type ImportFlowProps, type SelectableUTXO } from './types.js';
import { IMPORT_STEPS, type ImportFlowProps, type ImportStepType, type ImportVariableInput, type SelectableUTXO } from './types.js';
import type { Invitation } from '../../../../services/invitation.js';
import type { XOTemplate } from '@xo-cash/types';
import { DialogWrapper } from '../../../components/Dialog.js';
import { useInputLayer, useLayeredInput } from '../../../hooks/useInputLayer.js';
import { InvitationBuilder } from '@xo-cash/engine';
import { hexToBin } from '@bitauth/libauth';
/** Default fee estimate in satoshis. */
@@ -34,6 +34,24 @@ const DEFAULT_FEE = 500n;
/** Dust threshold — outputs below this are unspendable. */
const DUST_THRESHOLD = 546n;
/**
* Resolve the fixed index of a flow step from `IMPORT_STEPS`.
* We centralize this so step transitions do not rely on magic numbers.
*/
function getStepIndex(type: ImportStepType): number {
const index = IMPORT_STEPS.findIndex((step) => step.type === type);
if (index === -1) {
throw new Error(`Import step not found: ${type}`);
}
return index;
}
const PREVIEW_STEP_INDEX = getStepIndex('preview');
const ROLE_SELECT_STEP_INDEX = getStepIndex('role-select');
const VARIABLES_STEP_INDEX = getStepIndex('variables');
const INPUTS_SELECT_STEP_INDEX = getStepIndex('inputs-select');
const REVIEW_STEP_INDEX = getStepIndex('review');
export function InvitationImportFlow({
invitationId,
mode,
@@ -46,10 +64,10 @@ export function InvitationImportFlow({
// ── Accumulated state ────────────────────────────────────────────────────
const [currentStep, setCurrentStep] = useState(0);
const [invitation, setInvitation] = useState<Invitation | null>(null);
const [buildableInvitation, setBuildableInvitation] = useState<InvitationBuilder | null>(null);
const [template, setTemplate] = useState<XOTemplate | null>(null);
const [availableRoles, setAvailableRoles] = useState<string[]>([]);
const [selectedRole, setSelectedRole] = useState<string | null>(null);
const [variableInputs, setVariableInputs] = useState<ImportVariableInput[]>([]);
const [selectedInputs, setSelectedInputs] = useState<SelectableUTXO[]>([]);
const [changeAmount, setChangeAmount] = useState(0n);
const [requiredAmount, setRequiredAmount] = useState(0n);
@@ -79,9 +97,6 @@ export function InvitationImportFlow({
setInvitation(inv);
setTemplate(tmpl);
const builder = InvitationBuilder.fromInvitation(inv.data);
setBuildableInvitation(builder);
try {
const roles = await inv.getAvailableRoles();
setAvailableRoles(roles);
@@ -89,20 +104,98 @@ export function InvitationImportFlow({
showError(`Failed to get roles: ${err instanceof Error ? err.message : String(err)}`);
}
setCurrentStep(1); // → Preview
setCurrentStep(PREVIEW_STEP_INDEX); // → Preview
}, [showError]);
/** PreviewStep completed — user reviewed the invitation state and wants to proceed. */
const handlePreviewComplete = useCallback(() => {
setCurrentStep(2); // → Role Select
setCurrentStep(ROLE_SELECT_STEP_INDEX); // → Role Select
}, []);
/** RoleSelectStep completed — user picked a role. */
const handleRoleComplete = useCallback((role: string) => {
setSelectedRole(role);
setCurrentStep(3); // → Inputs Select
const action = template?.actions?.[invitation?.data.actionIdentifier ?? ""];
const roleRequirements = action?.roles?.[role]?.requirements?.variables ?? [];
const hasRequiredVariables = roleRequirements.length > 0;
if (!hasRequiredVariables) {
setVariableInputs([]);
setCurrentStep(INPUTS_SELECT_STEP_INDEX); // → Inputs Select
return;
}
const initializedVariables: ImportVariableInput[] = roleRequirements.map((variableId) => {
const variableDefinition = template?.variables?.[variableId];
return {
id: variableId,
name: variableDefinition?.name ?? variableId,
type: variableDefinition?.type ?? 'string',
hint: variableDefinition?.hint,
value: '',
};
});
setVariableInputs(initializedVariables);
setCurrentStep(VARIABLES_STEP_INDEX); // → Variables
}, [template, invitation]);
/** VariablesStep edited a field value. */
const handleVariableUpdate = useCallback((index: number, value: string) => {
setVariableInputs((previous) => {
const updated = [...previous];
const current = updated[index];
if (current) {
updated[index] = { ...current, value };
}
return updated;
});
}, []);
/**
* Convert variable input value to its invitation payload representation.
* Numeric variables are persisted as bigint so they match action wizard behavior.
*/
const parseVariableValue = useCallback((variable: ImportVariableInput) => {
const variableHint = variable.hint?.toLowerCase();
const isNumeric =
['integer', 'number', 'satoshis'].includes(variable.type) ||
(variableHint !== undefined && ['satoshis', 'amount'].includes(variableHint));
if (!isNumeric) {
return variable.value;
}
return BigInt(variable.value || '0');
}, []);
/** VariablesStep completed — persist variables then continue to input selection. */
const handleVariablesComplete = useCallback(async () => {
if (!invitation || !selectedRole) return;
const emptyVariables = variableInputs.filter((variable) => variable.value.trim() === '');
if (emptyVariables.length > 0) {
showError(`Please enter values for: ${emptyVariables.map((variable) => variable.name).join(', ')}`);
return;
}
try {
await invitation.addVariables(
variableInputs.map((variable) => ({
variableIdentifier: variable.id,
roleIdentifier: selectedRole,
value: parseVariableValue(variable),
})),
);
setCurrentStep(INPUTS_SELECT_STEP_INDEX); // → Inputs Select
} catch (error) {
showError(
`Failed to add variables: ${error instanceof Error ? error.message : String(error)}`,
);
}
}, [invitation, selectedRole, variableInputs, parseVariableValue, showError]);
/** InputsSelectStep completed — user selected UTXOs. */
const handleInputsComplete = useCallback(async (inputs: SelectableUTXO[]) => {
setSelectedInputs(inputs);
@@ -130,8 +223,8 @@ export function InvitationImportFlow({
}]);
}
setCurrentStep(4); // → Review
}, [invitation, buildableInvitation, selectedInputs]);
setCurrentStep(REVIEW_STEP_INDEX); // → Review
}, [invitation]);
/** ReviewStep completed — invitation import is done. */
const handleReviewComplete = useCallback(() => {
@@ -205,6 +298,17 @@ export function InvitationImportFlow({
/>
);
case 'variables':
return (
<VariablesStep
variables={variableInputs}
onUpdateVariable={handleVariableUpdate}
onComplete={handleVariablesComplete}
onCancel={handleCancel}
isActive={true}
/>
);
case 'inputs-select':
if (!invitation || !selectedRole) return null;
return (

View File

@@ -0,0 +1,113 @@
/**
* VariablesStep — collects all required variable values for invitation import.
*
* This runs after role selection and before input selection so cashasm
* expressions can resolve required variables during `getSatsOut()`.
*/
import React, { useMemo, useState, useCallback } from "react";
import { Box, Text } from "ink";
import { colors } from "../../../../theme.js";
import { useLayeredInput } from "../../../../hooks/useInputLayer.js";
import { VariableInputField } from "../../../../components/VariableInputField.js";
import type { VariablesStepProps } from "../types.js";
/**
* Build a user-facing validation error for empty required fields.
*/
function validateVariables(
variables: VariablesStepProps["variables"],
): string | null {
const empty = variables.filter((v) => v.value.trim() === "");
if (empty.length === 0) return null;
return `Please enter values for: ${empty.map((v) => v.name).join(", ")}`;
}
export function VariablesStep({
variables,
onUpdateVariable,
onComplete,
onCancel,
isActive,
}: VariablesStepProps): React.ReactElement {
const [focusedInput, setFocusedInput] = useState(0);
const [validationError, setValidationError] = useState<string | null>(null);
const helpText = useMemo(() => {
if (variables.length === 0) {
return "No variables required for this role.";
}
return "Enter a value for each variable, then press Enter on the last field to continue.";
}, [variables.length]);
/**
* Move focus to next input, or finish the step if this is the last one.
*/
const handleInputSubmit = useCallback(() => {
if (variables.length === 0) {
onComplete();
return;
}
if (focusedInput < variables.length - 1) {
setFocusedInput((prev) => prev + 1);
return;
}
const validation = validateVariables(variables);
setValidationError(validation);
if (!validation) {
onComplete();
}
}, [variables, focusedInput, onComplete]);
// Keyboard navigation for non-text actions.
useLayeredInput(
"import-flow",
(input, key) => {
if (key.upArrow || input === "k") {
setFocusedInput((prev) => Math.max(0, prev - 1));
} else if (key.downArrow || input === "j") {
setFocusedInput((prev) => Math.min(variables.length - 1, prev + 1));
} else if (key.escape) {
onCancel();
}
},
{ isActive },
);
return (
<Box flexDirection="column">
<Text color={colors.primary} bold>
Required Variables
</Text>
<Box marginTop={1} flexDirection="column">
{variables.map((variable, index) => (
<VariableInputField
key={variable.id}
variable={variable}
index={index}
isFocused={focusedInput === index}
onChange={onUpdateVariable}
onSubmit={handleInputSubmit}
borderColor={colors.border as string}
focusColor={colors.primary as string}
/>
))}
</Box>
{validationError && (
<Box marginTop={1}>
<Text color={colors.error}>{validationError}</Text>
</Box>
)}
<Box marginTop={1}>
<Text color={colors.textMuted}>
{helpText} : Change field Esc: Cancel
</Text>
</Box>
</Box>
);
}

View File

@@ -16,6 +16,7 @@ export type ImportStepType =
| "fetch"
| "preview"
| "role-select"
| "variables"
| "inputs-select"
| "review";
@@ -30,6 +31,7 @@ export const IMPORT_STEPS: ImportStep[] = [
{ name: "Fetch", type: "fetch" },
{ name: "Preview", type: "preview" },
{ name: "Select Role", type: "role-select" },
{ name: "Variables", type: "variables" },
{ name: "Select Inputs", type: "inputs-select" },
{ name: "Review", type: "review" },
];
@@ -81,6 +83,24 @@ export interface RoleSelectStepProps {
isActive: boolean;
}
/** A single variable input required by the selected action role. */
export interface ImportVariableInput {
id: string;
name: string;
type: string;
hint?: string;
value: string;
}
/** Props for VariablesStep — collects required role/action variable values. */
export interface VariablesStepProps {
variables: ImportVariableInput[];
onUpdateVariable: (index: number, value: string) => void;
onComplete: () => void;
onCancel: () => void;
isActive: boolean;
}
/** Props for InputsSelectStep — lets user pick UTXOs to fund the invitation. */
export interface InputsSelectStepProps {
invitation: Invitation;