/** * InputsSelectStep — lets the user select UTXOs to fund the invitation. * * On mount, queries for suitable resources via the invitation's `findSuitableResources`. * Auto-selects greedily, then lets the user toggle individual UTXOs. * Shows required, selected, and change amounts. */ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Box, Text } from 'ink'; import { colors, formatSatoshis } from '../../../../theme.js'; import { useSatoshisConversion } from '../../../../hooks/useSatoshisConversion.js'; import { useLayeredInput } from '../../../../hooks/useInputLayer.js'; import type { InputsSelectStepProps, SelectableUTXO } from '../types.js'; import { autoSelectGreedyUtxos, mapUnspentOutputsToSelectable } from '../../../../../utils/invitation-flow.js'; import type { UnspentOutputData } from '@xo-cash/state'; /** Default fee estimate in satoshis. */ const DEFAULT_FEE = 500n; /** Dust threshold — outputs below this are unspendable. */ const DUST_THRESHOLD = 546n; export function InputsSelectStep({ invitation, appService, onComplete, onCancel, isActive, }: InputsSelectStepProps): React.ReactElement { const [utxos, setUtxos] = useState([]); const [focusedIndex, setFocusedIndex] = useState(0); const [requiredAmount, setRequiredAmount] = useState(0n); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const { formatSatoshisToFiat } = useSatoshisConversion('USD'); const fee = DEFAULT_FEE; // Derived totals const selectedAmount = utxos .filter(u => u.selected) .reduce((sum, u) => sum + u.valueSatoshis, 0n); const changeAmount = selectedAmount - requiredAmount - fee; const hasEnough = selectedAmount >= requiredAmount + fee; const getFiatSuffix = (satoshis: bigint): string => { const fiatValue = formatSatoshisToFiat(satoshis); return fiatValue ? ` (~${fiatValue})` : ''; }; /** * Determine the required satoshi amount from the invitation's variables. */ const computeRequiredAmount = useCallback(async (): Promise => { return await invitation.getSatsOut() ?? 0n; }, [invitation]); /** * Fetch suitable UTXOs from the engine and auto-select greedily. */ const loadUtxos = useCallback(async () => { setIsLoading(true); setError(null); try { const required = await computeRequiredAmount(); setRequiredAmount(required); const template = await appService.engine.getTemplate(invitation.data.templateIdentifier); if (!template) { throw new Error('Template not found'); } // Get the action that we are calling from the template const action = template.actions[invitation.data.actionIdentifier]; if (!action) { throw new Error('Action not found'); } if (!action.transaction) { throw new Error('Action does not have a transaction'); } // Get the transaction that the action is creating const transaction = template.transactions?.[action.transaction]; if (!transaction) { throw new Error(`Transaction not found for action: ${action.transaction}`); } if (!transaction.outputs) { throw new Error(`Transaction does not have outputs`); } // Create a set to store all the output identifiers const outputIdentifiers = new Set(); for (const output of transaction.outputs) { outputIdentifiers.add(output.output); } // Create a map of the utxoID to suitable resource const utxoIdToSuitableResource = new Map(); for (const outputIdentifier of outputIdentifiers) { const suitableResources = await invitation.findSuitableResources(); for (const suitableResource of suitableResources) { utxoIdToSuitableResource.set(suitableResource.outpointTransactionHash + ':' + suitableResource.outpointIndex, suitableResource); } } const selectable = mapUnspentOutputsToSelectable(Array.from(utxoIdToSuitableResource.values())); const autoSelected = autoSelectGreedyUtxos(selectable, required + fee); setUtxos(autoSelected as SelectableUTXO[]); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setIsLoading(false); } }, [invitation, computeRequiredAmount, fee]); // Load UTXOs once on mount. We use a ref guard to prevent re-firing when // `loadUtxos` identity changes due to parent re-renders — each re-fire // flashes the loading state, causing the visible flicker bug. const hasLoadedRef = useRef(false); useEffect(() => { if (isActive && !hasLoadedRef.current) { hasLoadedRef.current = true; loadUtxos(); } }, [isActive, loadUtxos]); /** * Toggle the selection of a UTXO at the given index. */ const toggleSelection = useCallback((index: number) => { setUtxos(prev => { const updated = [...prev]; const utxo = updated[index]; if (utxo) updated[index] = { ...utxo, selected: !utxo.selected }; return updated; }); }, []); // Keyboard handling — gated by the import-flow layer so dialogs on top block input. useLayeredInput('import-flow', (input, key) => { if (key.upArrow || input === 'k') { setFocusedIndex(prev => Math.max(0, prev - 1)); } else if (key.downArrow || input === 'j') { setFocusedIndex(prev => Math.min(utxos.length - 1, prev + 1)); } else if (input === ' ') { if (utxos.length > 0) toggleSelection(focusedIndex); } else if (input === 'a') { setUtxos(prev => prev.map(u => ({ ...u, selected: true }))); } else if (input === 'n') { setUtxos(prev => prev.map(u => ({ ...u, selected: false }))); } else if (key.return) { if (hasEnough) { onComplete(utxos.filter(u => u.selected)); } } else if (key.escape) { onCancel(); } }, { isActive }); // Loading state if (isLoading) { return ( Finding suitable UTXOs... ); } // Error state if (error) { return ( Failed to load UTXOs {error} Esc: Cancel ); } // No UTXOs found if (utxos.length === 0) { return ( No suitable UTXOs found. Make sure your wallet has funds. Esc: Cancel ); } return ( {/* Summary bar */} Required: {formatSatoshis(requiredAmount + fee)} {getFiatSuffix(requiredAmount + fee)} (amount {formatSatoshis(requiredAmount)} + fee {formatSatoshis(fee)}) Selected: {formatSatoshis(selectedAmount)} {getFiatSuffix(selectedAmount)} {hasEnough && changeAmount >= DUST_THRESHOLD && ( {' '} (change: {formatSatoshis(changeAmount)} {getFiatSuffix(changeAmount)}) )} {!hasEnough && ( {' '} — need {formatSatoshis(requiredAmount + fee - selectedAmount)} {getFiatSuffix(requiredAmount + fee - selectedAmount)} more )} {/* UTXO list */} UTXOs ({utxos.length}): {utxos.map((utxo, index) => { const isFocused = index === focusedIndex; const checkMark = utxo.selected ? '☑' : '☐'; const txShort = utxo.outpointTransactionHash.slice(0, 8); return ( {isFocused ? '▸ ' : ' '}{checkMark} {formatSatoshis(utxo.valueSatoshis)} ({txShort}…:{utxo.outpointIndex}) {formatSatoshisToFiat(utxo.valueSatoshis) && ( {' '}≈ {formatSatoshisToFiat(utxo.valueSatoshis)} )} ); })} {/* Navigation hint */} ↑↓: Navigate • Space: Toggle • a: All • n: None • return: Confirm • Esc: Cancel ); }