diff --git a/Electrum.sqlite-journal b/Electrum.sqlite-journal new file mode 100644 index 0000000..36a5776 Binary files /dev/null and b/Electrum.sqlite-journal differ diff --git a/src/controllers/invitation-controller.ts b/src/controllers/invitation-controller.ts index a1c91ff..90552d6 100644 --- a/src/controllers/invitation-controller.ts +++ b/src/controllers/invitation-controller.ts @@ -133,9 +133,15 @@ export class InvitationController extends EventEmitter { outpointTransactionHash: string; outpointIndex: number; sequenceNumber?: number; + inputIdentifier?: string; }>, ): 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 {