Add variable step
This commit is contained in:
@@ -4,7 +4,7 @@ import type {
|
|||||||
Engine,
|
Engine,
|
||||||
GetSpendableResourcesParameters,
|
GetSpendableResourcesParameters,
|
||||||
} from "@xo-cash/engine";
|
} from "@xo-cash/engine";
|
||||||
import { hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine";
|
import { generateTemplateIdentifier, hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine";
|
||||||
import type {
|
import type {
|
||||||
XOInvitation,
|
XOInvitation,
|
||||||
XOInvitationCommit,
|
XOInvitationCommit,
|
||||||
@@ -498,16 +498,38 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedOptions: GetSpendableResourcesParameters = {
|
// const resolvedOptions: GetSpendableResourcesParameters = {
|
||||||
templateIdentifier,
|
// templateIdentifier,
|
||||||
outputIdentifier: options.outputIdentifier ?? fallbackOutputIdentifier ?? "",
|
// outputIdentifier: options.outputIdentifier ?? fallbackOutputIdentifier ?? "",
|
||||||
};
|
// };
|
||||||
|
|
||||||
// Find the suitable resources
|
// There are disagreements around whether all spendables should be returned from getSpendableResources.
|
||||||
const { unspentOutputs } = await this.engine.getSpendableResources(
|
// I had a fix merged in, but it got overwritten. So, im just going to get all of them manually and go around
|
||||||
this.data,
|
// The engine's expectations.
|
||||||
resolvedOptions,
|
// 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
|
// Update the status of the invitation
|
||||||
await this.updateStatus();
|
await this.updateStatus();
|
||||||
|
|||||||
@@ -17,15 +17,15 @@ import { StepIndicator, type Step } from '../../../components/ProgressBar.js';
|
|||||||
import { FetchInvitationStep } from './steps/FetchInvitationStep.js';
|
import { FetchInvitationStep } from './steps/FetchInvitationStep.js';
|
||||||
import { PreviewInvitationStep } from './steps/PreviewInvitationStep.js';
|
import { PreviewInvitationStep } from './steps/PreviewInvitationStep.js';
|
||||||
import { RoleSelectStep } from './steps/RoleSelectStep.js';
|
import { RoleSelectStep } from './steps/RoleSelectStep.js';
|
||||||
|
import { VariablesStep } from './steps/VariablesStep.js';
|
||||||
import { InputsSelectStep } from './steps/InputsSelectStep.js';
|
import { InputsSelectStep } from './steps/InputsSelectStep.js';
|
||||||
import { ReviewStep } from './steps/ReviewStep.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 { Invitation } from '../../../../services/invitation.js';
|
||||||
import type { XOTemplate } from '@xo-cash/types';
|
import type { XOTemplate } from '@xo-cash/types';
|
||||||
import { DialogWrapper } from '../../../components/Dialog.js';
|
import { DialogWrapper } from '../../../components/Dialog.js';
|
||||||
import { useInputLayer, useLayeredInput } from '../../../hooks/useInputLayer.js';
|
import { useInputLayer, useLayeredInput } from '../../../hooks/useInputLayer.js';
|
||||||
import { InvitationBuilder } from '@xo-cash/engine';
|
|
||||||
import { hexToBin } from '@bitauth/libauth';
|
import { hexToBin } from '@bitauth/libauth';
|
||||||
|
|
||||||
/** Default fee estimate in satoshis. */
|
/** Default fee estimate in satoshis. */
|
||||||
@@ -34,6 +34,24 @@ const DEFAULT_FEE = 500n;
|
|||||||
/** Dust threshold — outputs below this are unspendable. */
|
/** Dust threshold — outputs below this are unspendable. */
|
||||||
const DUST_THRESHOLD = 546n;
|
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({
|
export function InvitationImportFlow({
|
||||||
invitationId,
|
invitationId,
|
||||||
mode,
|
mode,
|
||||||
@@ -46,10 +64,10 @@ export function InvitationImportFlow({
|
|||||||
// ── Accumulated state ────────────────────────────────────────────────────
|
// ── Accumulated state ────────────────────────────────────────────────────
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const [invitation, setInvitation] = useState<Invitation | null>(null);
|
const [invitation, setInvitation] = useState<Invitation | null>(null);
|
||||||
const [buildableInvitation, setBuildableInvitation] = useState<InvitationBuilder | null>(null);
|
|
||||||
const [template, setTemplate] = useState<XOTemplate | null>(null);
|
const [template, setTemplate] = useState<XOTemplate | null>(null);
|
||||||
const [availableRoles, setAvailableRoles] = useState<string[]>([]);
|
const [availableRoles, setAvailableRoles] = useState<string[]>([]);
|
||||||
const [selectedRole, setSelectedRole] = useState<string | null>(null);
|
const [selectedRole, setSelectedRole] = useState<string | null>(null);
|
||||||
|
const [variableInputs, setVariableInputs] = useState<ImportVariableInput[]>([]);
|
||||||
const [selectedInputs, setSelectedInputs] = useState<SelectableUTXO[]>([]);
|
const [selectedInputs, setSelectedInputs] = useState<SelectableUTXO[]>([]);
|
||||||
const [changeAmount, setChangeAmount] = useState(0n);
|
const [changeAmount, setChangeAmount] = useState(0n);
|
||||||
const [requiredAmount, setRequiredAmount] = useState(0n);
|
const [requiredAmount, setRequiredAmount] = useState(0n);
|
||||||
@@ -79,9 +97,6 @@ export function InvitationImportFlow({
|
|||||||
setInvitation(inv);
|
setInvitation(inv);
|
||||||
setTemplate(tmpl);
|
setTemplate(tmpl);
|
||||||
|
|
||||||
const builder = InvitationBuilder.fromInvitation(inv.data);
|
|
||||||
setBuildableInvitation(builder);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const roles = await inv.getAvailableRoles();
|
const roles = await inv.getAvailableRoles();
|
||||||
setAvailableRoles(roles);
|
setAvailableRoles(roles);
|
||||||
@@ -89,20 +104,98 @@ export function InvitationImportFlow({
|
|||||||
showError(`Failed to get roles: ${err instanceof Error ? err.message : String(err)}`);
|
showError(`Failed to get roles: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentStep(1); // → Preview
|
setCurrentStep(PREVIEW_STEP_INDEX); // → Preview
|
||||||
}, [showError]);
|
}, [showError]);
|
||||||
|
|
||||||
/** PreviewStep completed — user reviewed the invitation state and wants to proceed. */
|
/** PreviewStep completed — user reviewed the invitation state and wants to proceed. */
|
||||||
const handlePreviewComplete = useCallback(() => {
|
const handlePreviewComplete = useCallback(() => {
|
||||||
setCurrentStep(2); // → Role Select
|
setCurrentStep(ROLE_SELECT_STEP_INDEX); // → Role Select
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/** RoleSelectStep completed — user picked a role. */
|
/** RoleSelectStep completed — user picked a role. */
|
||||||
const handleRoleComplete = useCallback((role: string) => {
|
const handleRoleComplete = useCallback((role: string) => {
|
||||||
setSelectedRole(role);
|
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. */
|
/** InputsSelectStep completed — user selected UTXOs. */
|
||||||
const handleInputsComplete = useCallback(async (inputs: SelectableUTXO[]) => {
|
const handleInputsComplete = useCallback(async (inputs: SelectableUTXO[]) => {
|
||||||
setSelectedInputs(inputs);
|
setSelectedInputs(inputs);
|
||||||
@@ -130,8 +223,8 @@ export function InvitationImportFlow({
|
|||||||
}]);
|
}]);
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentStep(4); // → Review
|
setCurrentStep(REVIEW_STEP_INDEX); // → Review
|
||||||
}, [invitation, buildableInvitation, selectedInputs]);
|
}, [invitation]);
|
||||||
|
|
||||||
/** ReviewStep completed — invitation import is done. */
|
/** ReviewStep completed — invitation import is done. */
|
||||||
const handleReviewComplete = useCallback(() => {
|
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':
|
case 'inputs-select':
|
||||||
if (!invitation || !selectedRole) return null;
|
if (!invitation || !selectedRole) return null;
|
||||||
return (
|
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"
|
| "fetch"
|
||||||
| "preview"
|
| "preview"
|
||||||
| "role-select"
|
| "role-select"
|
||||||
|
| "variables"
|
||||||
| "inputs-select"
|
| "inputs-select"
|
||||||
| "review";
|
| "review";
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export const IMPORT_STEPS: ImportStep[] = [
|
|||||||
{ name: "Fetch", type: "fetch" },
|
{ name: "Fetch", type: "fetch" },
|
||||||
{ name: "Preview", type: "preview" },
|
{ name: "Preview", type: "preview" },
|
||||||
{ name: "Select Role", type: "role-select" },
|
{ name: "Select Role", type: "role-select" },
|
||||||
|
{ name: "Variables", type: "variables" },
|
||||||
{ name: "Select Inputs", type: "inputs-select" },
|
{ name: "Select Inputs", type: "inputs-select" },
|
||||||
{ name: "Review", type: "review" },
|
{ name: "Review", type: "review" },
|
||||||
];
|
];
|
||||||
@@ -81,6 +83,24 @@ export interface RoleSelectStepProps {
|
|||||||
isActive: boolean;
|
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. */
|
/** Props for InputsSelectStep — lets user pick UTXOs to fund the invitation. */
|
||||||
export interface InputsSelectStepProps {
|
export interface InputsSelectStepProps {
|
||||||
invitation: Invitation;
|
invitation: Invitation;
|
||||||
|
|||||||
Reference in New Issue
Block a user