268 lines
9.1 KiB
TypeScript
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>
|
|
);
|
|
}
|