Add variable step
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user