diff --git a/src/services/invitation.ts b/src/services/invitation.ts index ff3ace6..a740853 100644 --- a/src/services/invitation.ts +++ b/src/services/invitation.ts @@ -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 { ); } - 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(); diff --git a/src/tui/screens/invitations/invitation-import/InvitationImportFlow.tsx b/src/tui/screens/invitations/invitation-import/InvitationImportFlow.tsx index b0ac7e5..a646d71 100644 --- a/src/tui/screens/invitations/invitation-import/InvitationImportFlow.tsx +++ b/src/tui/screens/invitations/invitation-import/InvitationImportFlow.tsx @@ -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(null); - const [buildableInvitation, setBuildableInvitation] = useState(null); const [template, setTemplate] = useState(null); const [availableRoles, setAvailableRoles] = useState([]); const [selectedRole, setSelectedRole] = useState(null); + const [variableInputs, setVariableInputs] = useState([]); const [selectedInputs, setSelectedInputs] = useState([]); 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 ( + + ); + case 'inputs-select': if (!invitation || !selectedRole) return null; return ( diff --git a/src/tui/screens/invitations/invitation-import/steps/VariablesStep.tsx b/src/tui/screens/invitations/invitation-import/steps/VariablesStep.tsx new file mode 100644 index 0000000..22f54b7 --- /dev/null +++ b/src/tui/screens/invitations/invitation-import/steps/VariablesStep.tsx @@ -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(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 ( + + + Required Variables + + + + {variables.map((variable, index) => ( + + ))} + + + {validationError && ( + + {validationError} + + )} + + + + {helpText} ↑↓: Change field • Esc: Cancel + + + + ); +} diff --git a/src/tui/screens/invitations/invitation-import/types.ts b/src/tui/screens/invitations/invitation-import/types.ts index 626c44e..8978165 100644 --- a/src/tui/screens/invitations/invitation-import/types.ts +++ b/src/tui/screens/invitations/invitation-import/types.ts @@ -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;