Files
xo-cli/src/tui/screens/invitations/invitation-import/steps/InputsSelectStep.tsx
2026-04-27 08:42:51 +00:00

268 lines
9.1 KiB
TypeScript

/**
* 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<SelectableUTXO[]>([]);
const [focusedIndex, setFocusedIndex] = useState(0);
const [requiredAmount, setRequiredAmount] = useState(0n);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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<bigint> => {
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<string>();
for (const output of transaction.outputs) {
outputIdentifiers.add(output.output);
}
// Create a map of the utxoID to suitable resource
const utxoIdToSuitableResource = new Map<string, UnspentOutputData>();
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 (
<Box flexDirection="column">
<Text color={colors.info}>Finding suitable UTXOs...</Text>
</Box>
);
}
// Error state
if (error) {
return (
<Box flexDirection="column">
<Text color={colors.error} bold>Failed to load UTXOs</Text>
<Text color={colors.textMuted}>{error}</Text>
<Box marginTop={1}>
<Text color={colors.textMuted}>Esc: Cancel</Text>
</Box>
</Box>
);
}
// No UTXOs found
if (utxos.length === 0) {
return (
<Box flexDirection="column">
<Text color={colors.warning}>No suitable UTXOs found. Make sure your wallet has funds.</Text>
<Box marginTop={1}>
<Text color={colors.textMuted}>Esc: Cancel</Text>
</Box>
</Box>
);
}
return (
<Box flexDirection="column">
{/* Summary bar */}
<Box flexDirection="row" marginBottom={1}>
<Text color={colors.primary} bold>Required: </Text>
<Text color={colors.text}>
{formatSatoshis(requiredAmount + fee)}
{getFiatSuffix(requiredAmount + fee)}
</Text>
<Text color={colors.textMuted}> (amount {formatSatoshis(requiredAmount)} + fee {formatSatoshis(fee)})</Text>
</Box>
<Box flexDirection="row" marginBottom={1}>
<Text color={colors.primary} bold>Selected: </Text>
<Text color={hasEnough ? colors.success : colors.error}>
{formatSatoshis(selectedAmount)}
{getFiatSuffix(selectedAmount)}
</Text>
{hasEnough && changeAmount >= DUST_THRESHOLD && (
<Text color={colors.textMuted}>
{' '}
(change: {formatSatoshis(changeAmount)}
{getFiatSuffix(changeAmount)})
</Text>
)}
{!hasEnough && (
<Text color={colors.error}>
{' '}
need {formatSatoshis(requiredAmount + fee - selectedAmount)}
{getFiatSuffix(requiredAmount + fee - selectedAmount)} more
</Text>
)}
</Box>
{/* UTXO list */}
<Text color={colors.primary} bold>UTXOs ({utxos.length}):</Text>
{utxos.map((utxo, index) => {
const isFocused = index === focusedIndex;
const checkMark = utxo.selected ? '☑' : '☐';
const txShort = utxo.outpointTransactionHash.slice(0, 8);
return (
<Box
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
flexDirection="column"
>
<Text
color={isFocused ? colors.focus : utxo.selected ? colors.success : colors.text}
bold={isFocused}
>
{isFocused ? '▸ ' : ' '}{checkMark} {formatSatoshis(utxo.valueSatoshis)} ({txShort}:{utxo.outpointIndex})
</Text>
{formatSatoshisToFiat(utxo.valueSatoshis) && (
<Text color={colors.textMuted}>
{' '} {formatSatoshisToFiat(utxo.valueSatoshis)}
</Text>
)}
</Box>
);
})}
{/* Navigation hint */}
<Box marginTop={1}>
<Text color={colors.textMuted}>
: Navigate Space: Toggle a: All n: None return: Confirm Esc: Cancel
</Text>
</Box>
</Box>
);
}