From 4629be8b29a08a902694df58c49ad66ec63dcdf6 Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Thu, 29 Jan 2026 08:30:08 +0000 Subject: [PATCH] Fix input filling --- Electrum.sqlite-journal | Bin 0 -> 12824 bytes src/controllers/invitation-controller.ts | 15 +- src/tui/screens/ActionWizard.tsx | 483 ++++++++++++++++++----- src/tui/screens/Invitation.tsx | 249 +++++++++++- 4 files changed, 644 insertions(+), 103 deletions(-) create mode 100644 Electrum.sqlite-journal diff --git a/Electrum.sqlite-journal b/Electrum.sqlite-journal new file mode 100644 index 0000000000000000000000000000000000000000..36a5776683ba290b97106af378076a85d51c84d0 GIT binary patch literal 12824 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, ): Promise { - return this.flowManager.appendToInvitation(invitationId, { inputs }); + // Ensure each input has an inputIdentifier (sync server requires it) + const inputsWithIds = inputs.map((input, index) => ({ + ...input, + inputIdentifier: input.inputIdentifier ?? `senderInput_${Date.now()}_${index}`, + })); + return this.flowManager.appendToInvitation(invitationId, { inputs: inputsWithIds }); } /** @@ -152,7 +158,12 @@ export class InvitationController extends EventEmitter { roleIdentifier?: string; }>, ): Promise { - return this.flowManager.appendToInvitation(invitationId, { outputs }); + // Ensure each output has an outputIdentifier (sync server may require it) + const outputsWithIds = outputs.map((output, index) => ({ + ...output, + outputIdentifier: output.outputIdentifier ?? `changeOutput_${Date.now()}_${index}`, + })); + return this.flowManager.appendToInvitation(invitationId, { outputs: outputsWithIds }); } /** diff --git a/src/tui/screens/ActionWizard.tsx b/src/tui/screens/ActionWizard.tsx index e122f02..d410b5c 100644 --- a/src/tui/screens/ActionWizard.tsx +++ b/src/tui/screens/ActionWizard.tsx @@ -4,7 +4,8 @@ * Guides users through: * - Reviewing action requirements * - Entering variables (e.g., requestedSatoshis) - * - Reviewing outputs + * - Selecting inputs (UTXOs) for funding + * - Reviewing outputs and change * - Creating and publishing invitation */ @@ -12,17 +13,17 @@ import React, { useState, useEffect, useCallback } from 'react'; import { Box, Text, useInput } from 'ink'; import TextInput from 'ink-text-input'; import { StepIndicator, type Step } from '../components/ProgressBar.js'; -import { Button, ButtonRow } from '../components/Button.js'; +import { Button } from '../components/Button.js'; import { useNavigation } from '../hooks/useNavigation.js'; import { useAppContext, useStatus } from '../hooks/useAppContext.js'; -import { colors, logoSmall, formatSatoshis } from '../theme.js'; +import { colors, logoSmall, formatSatoshis, formatHex } from '../theme.js'; import { copyToClipboard } from '../utils/clipboard.js'; -import type { XOTemplate } from '@xo-cash/types'; +import type { XOTemplate, XOInvitation } from '@xo-cash/types'; /** * Wizard step types. */ -type StepType = 'info' | 'variables' | 'review' | 'publish'; +type StepType = 'info' | 'variables' | 'inputs' | 'review' | 'publish'; /** * Wizard step definition. @@ -43,6 +44,17 @@ interface VariableInput { value: string; } +/** + * UTXO for selection. + */ +interface SelectableUTXO { + outpointTransactionHash: string; + outpointIndex: number; + valueSatoshis: bigint; + lockingBytecode?: string; + selected: boolean; +} + /** * Action Wizard Screen Component. */ @@ -57,15 +69,28 @@ export function ActionWizardScreen(): React.ReactElement { const roleIdentifier = navData.roleIdentifier as string | undefined; const template = navData.template as XOTemplate | undefined; - // State + // Wizard state const [steps, setSteps] = useState([]); const [currentStep, setCurrentStep] = useState(0); + + // Variable inputs const [variables, setVariables] = useState([]); + + // UTXO selection + const [availableUtxos, setAvailableUtxos] = useState([]); + const [selectedUtxoIndex, setSelectedUtxoIndex] = useState(0); + const [requiredAmount, setRequiredAmount] = useState(0n); + const [fee, setFee] = useState(500n); // Default fee estimate + + // Invitation state + const [invitation, setInvitation] = useState(null); + const [invitationId, setInvitationId] = useState(null); + + // UI state const [focusedInput, setFocusedInput] = useState(0); const [focusedButton, setFocusedButton] = useState<'back' | 'cancel' | 'next'>('next'); const [focusArea, setFocusArea] = useState<'content' | 'buttons'>('content'); - const [invitationId, setInvitationId] = useState(null); - const [isCreating, setIsCreating] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); /** * Initialize wizard on mount. @@ -104,6 +129,12 @@ export function ActionWizardScreen(): React.ReactElement { setVariables(varInputs); } + // Add inputs step if role requires slots (funding inputs) + // Slots indicate the role needs to provide transaction inputs/outputs + if (requirements?.slots && requirements.slots.min > 0) { + wizardSteps.push({ name: 'Select UTXOs', type: 'inputs' }); + } + wizardSteps.push({ name: 'Review', type: 'review' }); wizardSteps.push({ name: 'Publish', type: 'publish' }); @@ -116,15 +147,123 @@ export function ActionWizardScreen(): React.ReactElement { */ const currentStepData = steps[currentStep]; + /** + * Calculate selected amount. + */ + const selectedAmount = availableUtxos + .filter(u => u.selected) + .reduce((sum, u) => sum + u.valueSatoshis, 0n); + + /** + * Calculate change amount. + */ + const changeAmount = selectedAmount - requiredAmount - fee; + + /** + * Load available UTXOs for the inputs step. + */ + const loadAvailableUtxos = useCallback(async () => { + if (!invitation || !templateIdentifier) return; + + try { + setIsProcessing(true); + setStatus('Finding suitable UTXOs...'); + + // First, get the required amount from variables (e.g., requestedSatoshis) + const requestedVar = variables.find(v => + v.id.toLowerCase().includes('satoshi') || + v.id.toLowerCase().includes('amount') + ); + const requested = requestedVar ? BigInt(requestedVar.value || '0') : 0n; + setRequiredAmount(requested); + + // Find suitable resources + const resources = await walletController.findSuitableResources(invitation, { + templateIdentifier, + outputIdentifier: 'receiveOutput', // Common output identifier + }); + + // Convert to selectable UTXOs + const utxos: SelectableUTXO[] = (resources.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 to cover required amount + fee + let accumulated = 0n; + const seenLockingBytecodes = new Set(); + + for (const utxo of utxos) { + // Ensure lockingBytecode uniqueness + 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); + setStatus('Ready'); + } catch (error) { + showError(`Failed to load UTXOs: ${error instanceof Error ? error.message : String(error)}`); + } finally { + setIsProcessing(false); + } + }, [invitation, templateIdentifier, variables, walletController, showError, setStatus]); + + /** + * Toggle UTXO selection. + */ + const toggleUtxoSelection = useCallback((index: number) => { + setAvailableUtxos(prev => { + const updated = [...prev]; + const utxo = updated[index]; + if (utxo) { + updated[index] = { ...utxo, selected: !utxo.selected }; + } + return updated; + }); + }, []); + /** * Navigate to next step. */ const nextStep = useCallback(async () => { if (currentStep >= steps.length - 1) return; - // If on review step, create invitation - if (currentStepData?.type === 'review') { - await createInvitation(); + const stepType = currentStepData?.type; + + // Handle step-specific logic + if (stepType === 'variables') { + // Create invitation and add variables + await createInvitationWithVariables(); + return; + } + + if (stepType === 'inputs') { + // Add selected inputs and outputs to invitation + await addInputsAndOutputs(); + return; + } + + if (stepType === 'review') { + // Publish invitation + await publishInvitation(); return; } @@ -133,6 +272,135 @@ export function ActionWizardScreen(): React.ReactElement { setFocusedInput(0); }, [currentStep, steps.length, currentStepData]); + /** + * Create invitation and add variables. + */ + const createInvitationWithVariables = useCallback(async () => { + if (!templateIdentifier || !actionIdentifier || !roleIdentifier) return; + + setIsProcessing(true); + setStatus('Creating invitation...'); + + try { + // Create invitation + const tracked = await invitationController.createInvitation( + templateIdentifier, + actionIdentifier, + ); + + let inv = tracked.invitation; + const invId = inv.invitationIdentifier; + setInvitationId(invId); + + // Add variables if any + if (variables.length > 0) { + const variableData = variables.map(v => ({ + variableIdentifier: v.id, + roleIdentifier: roleIdentifier, + value: v.type === 'number' || v.type === 'satoshis' + ? BigInt(v.value || '0') + : v.value, + })); + const updated = await invitationController.addVariables(invId, variableData); + inv = updated.invitation; + } + + setInvitation(inv); + + // Check if next step is inputs + const nextStepType = steps[currentStep + 1]?.type; + if (nextStepType === 'inputs') { + setCurrentStep(prev => prev + 1); + // Load UTXOs after step change + setTimeout(() => loadAvailableUtxos(), 100); + } else { + setCurrentStep(prev => prev + 1); + } + + setStatus('Invitation created'); + } catch (error) { + showError(`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`); + } finally { + setIsProcessing(false); + } + }, [templateIdentifier, actionIdentifier, roleIdentifier, variables, invitationController, steps, currentStep, showError, setStatus, loadAvailableUtxos]); + + /** + * Add selected inputs and change output to invitation. + */ + const addInputsAndOutputs = useCallback(async () => { + if (!invitationId || !invitation) return; + + const selectedUtxos = availableUtxos.filter(u => u.selected); + + if (selectedUtxos.length === 0) { + showError('Please select at least one UTXO'); + return; + } + + if (selectedAmount < requiredAmount + fee) { + showError(`Insufficient funds. Need ${formatSatoshis(requiredAmount + fee)}, selected ${formatSatoshis(selectedAmount)}`); + return; + } + + if (changeAmount < 546n) { // Dust threshold + showError(`Change amount (${changeAmount}) is below dust threshold (546 sats)`); + return; + } + + setIsProcessing(true); + setStatus('Adding inputs and outputs...'); + + try { + // Add inputs + const inputs = selectedUtxos.map(utxo => ({ + outpointTransactionHash: utxo.outpointTransactionHash, + outpointIndex: utxo.outpointIndex, + })); + + await invitationController.addInputs(invitationId, inputs); + + // Add change output + const outputs = [{ + valueSatoshis: changeAmount, + // The engine will automatically generate the locking bytecode for change + }]; + + await invitationController.addOutputs(invitationId, outputs); + + // Add transaction metadata + // Note: This would be done via appendInvitation but we don't have direct access here + // The engine should handle defaults + + setCurrentStep(prev => prev + 1); + setStatus('Inputs and outputs added'); + } catch (error) { + showError(`Failed to add inputs/outputs: ${error instanceof Error ? error.message : String(error)}`); + } finally { + setIsProcessing(false); + } + }, [invitationId, invitation, availableUtxos, selectedAmount, requiredAmount, fee, changeAmount, invitationController, showError, setStatus]); + + /** + * Publish invitation. + */ + const publishInvitation = useCallback(async () => { + if (!invitationId) return; + + setIsProcessing(true); + setStatus('Publishing invitation...'); + + try { + await invitationController.publishAndSubscribe(invitationId); + setCurrentStep(prev => prev + 1); + setStatus('Invitation published'); + } catch (error) { + showError(`Failed to publish: ${error instanceof Error ? error.message : String(error)}`); + } finally { + setIsProcessing(false); + } + }, [invitationId, invitationController, showError, setStatus]); + /** * Navigate to previous step. */ @@ -153,49 +421,6 @@ export function ActionWizardScreen(): React.ReactElement { goBack(); }, [goBack]); - /** - * Create invitation. - */ - const createInvitation = useCallback(async () => { - if (!templateIdentifier || !actionIdentifier || !roleIdentifier) return; - - setIsCreating(true); - setStatus('Creating invitation...'); - - try { - // Create invitation - const tracked = await invitationController.createInvitation( - templateIdentifier, - actionIdentifier, - ); - - const invId = tracked.invitation.invitationIdentifier; - setInvitationId(invId); - - // Add variables if any - if (variables.length > 0) { - const variableData = variables.map(v => ({ - variableIdentifier: v.id, - roleIdentifier: roleIdentifier, - value: v.type === 'number' || v.type === 'satoshis' - ? BigInt(v.value || '0') - : v.value, - })); - await invitationController.addVariables(invId, variableData); - } - - // Publish to sync server - await invitationController.publishAndSubscribe(invId); - - setCurrentStep(prev => prev + 1); - setStatus('Invitation created'); - } catch (error) { - showError(`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`); - } finally { - setIsCreating(false); - } - }, [templateIdentifier, actionIdentifier, roleIdentifier, variables, invitationController, showError, setStatus]); - /** * Copy invitation ID to clipboard. */ @@ -229,17 +454,22 @@ export function ActionWizardScreen(): React.ReactElement { // Tab to switch between content and buttons if (key.tab) { if (focusArea === 'content') { - // In variables step, tab cycles through inputs first + // Handle tab based on current step type if (currentStepData?.type === 'variables' && variables.length > 0) { if (focusedInput < variables.length - 1) { setFocusedInput(prev => prev + 1); return; } } + if (currentStepData?.type === 'inputs' && availableUtxos.length > 0) { + if (selectedUtxoIndex < availableUtxos.length - 1) { + setSelectedUtxoIndex(prev => prev + 1); + return; + } + } setFocusArea('buttons'); setFocusedButton('next'); } else { - // Cycle through buttons if (focusedButton === 'back') { setFocusedButton('cancel'); } else if (focusedButton === 'cancel') { @@ -247,31 +477,20 @@ export function ActionWizardScreen(): React.ReactElement { } else { setFocusArea('content'); setFocusedInput(0); + setSelectedUtxoIndex(0); } } return; } - // Shift+Tab - if (key.shift && key.tab) { - if (focusArea === 'buttons') { - if (focusedButton === 'next') { - setFocusedButton('cancel'); - } else if (focusedButton === 'cancel') { - setFocusedButton('back'); - } else { - setFocusArea('content'); - if (currentStepData?.type === 'variables' && variables.length > 0) { - setFocusedInput(variables.length - 1); - } - } - } else { - if (focusedInput > 0) { - setFocusedInput(prev => prev - 1); - } else { - setFocusArea('buttons'); - setFocusedButton('back'); - } + // Arrow keys for UTXO selection + if (focusArea === 'content' && currentStepData?.type === 'inputs') { + if (key.upArrow) { + setSelectedUtxoIndex(prev => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setSelectedUtxoIndex(prev => Math.min(availableUtxos.length - 1, prev + 1)); + } else if (key.return || input === ' ') { + toggleUtxoSelection(selectedUtxoIndex); } return; } @@ -300,6 +519,16 @@ export function ActionWizardScreen(): React.ReactElement { if (input === 'c' && currentStepData?.type === 'publish' && invitationId) { copyId(); } + + // 'a' to select all UTXOs + if (input === 'a' && currentStepData?.type === 'inputs') { + setAvailableUtxos(prev => prev.map(u => ({ ...u, selected: true }))); + } + + // 'n' to deselect all UTXOs + if (input === 'n' && currentStepData?.type === 'inputs') { + setAvailableUtxos(prev => prev.map(u => ({ ...u, selected: false }))); + } }); // Get action details @@ -321,13 +550,15 @@ export function ActionWizardScreen(): React.ReactElement { {roleIdentifier} - {/* Show requirements */} {action?.roles?.[roleIdentifier ?? '']?.requirements && ( Requirements: {action.roles[roleIdentifier ?? '']?.requirements?.variables?.map(v => ( • Variable: {v} ))} + {action.roles[roleIdentifier ?? '']?.requirements?.slots && ( + • Slots: {action.roles[roleIdentifier ?? '']?.requirements?.slots?.min} min (UTXO selection required) + )} )} @@ -363,27 +594,95 @@ export function ActionWizardScreen(): React.ReactElement { ); + case 'inputs': + return ( + + Select UTXOs to fund the transaction: + + + + Required: {formatSatoshis(requiredAmount)} + {formatSatoshis(fee)} fee + + = requiredAmount + fee ? colors.success : colors.warning}> + Selected: {formatSatoshis(selectedAmount)} + + {selectedAmount > requiredAmount + fee && ( + + Change: {formatSatoshis(changeAmount)} + + )} + + + + {availableUtxos.length === 0 ? ( + No UTXOs available + ) : ( + availableUtxos.map((utxo, index) => ( + + + {selectedUtxoIndex === index && focusArea === 'content' ? '▸ ' : ' '} + [{utxo.selected ? 'X' : ' '}] {formatSatoshis(utxo.valueSatoshis)} - {formatHex(utxo.outpointTransactionHash, 12)}:{utxo.outpointIndex} + + + )) + )} + + + + + Space/Enter: Toggle • a: Select all • n: Deselect all + + + + ); + case 'review': + const selectedUtxos = availableUtxos.filter(u => u.selected); return ( Review your invitation: + Template: {template?.name} Action: {actionName} Role: {roleIdentifier} - - {variables.length > 0 && ( - - Variables: - {variables.map(v => ( - - {' '}{v.name}: {v.value || '(empty)'} - - ))} - - )} + {variables.length > 0 && ( + + Variables: + {variables.map(v => ( + + {' '}{v.name}: {v.value || '(empty)'} + + ))} + + )} + + {selectedUtxos.length > 0 && ( + + Inputs ({selectedUtxos.length}): + {selectedUtxos.slice(0, 3).map(u => ( + + {' '}{formatSatoshis(u.valueSatoshis)} + + ))} + {selectedUtxos.length > 3 && ( + ...and {selectedUtxos.length - 3} more + )} + + )} + + {changeAmount > 0 && ( + + Outputs: + Change: {formatSatoshis(changeAmount)} + + )} + Press Next to create and publish the invitation. @@ -395,7 +694,7 @@ export function ActionWizardScreen(): React.ReactElement { case 'publish': return ( - ✓ Invitation Created! + ✓ Invitation Created & Published! Invitation ID: - {isCreating ? ( - Creating invitation... + {isProcessing ? ( + Processing... ) : ( renderStepContent() )} @@ -482,7 +781,7 @@ export function ActionWizardScreen(): React.ReactElement {