Large amount of changes. Successfully broadcasts txs
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 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 } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { colors, formatSatoshis } from '../../../../theme.js';
|
||||
import type { InputsSelectStepProps, SelectableUTXO } from '../types.js';
|
||||
|
||||
/** Default fee estimate in satoshis. */
|
||||
const DEFAULT_FEE = 500n;
|
||||
|
||||
/** Dust threshold — outputs below this are unspendable. */
|
||||
const DUST_THRESHOLD = 546n;
|
||||
|
||||
export function InputsSelectStep({
|
||||
invitation,
|
||||
template,
|
||||
selectedRole,
|
||||
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 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;
|
||||
|
||||
/**
|
||||
* 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 unspentOutputs = await invitation.findSuitableResources({
|
||||
templateIdentifier: invitation.data.templateIdentifier,
|
||||
outputIdentifier: 'receiveOutput',
|
||||
});
|
||||
|
||||
// Map to selectable UTXOs
|
||||
const selectable: SelectableUTXO[] = 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,
|
||||
}));
|
||||
|
||||
// Greedy auto-select, skipping duplicate locking bytecodes
|
||||
let accumulated = 0n;
|
||||
const seenBytecodes = new Set<string>();
|
||||
|
||||
for (const utxo of selectable) {
|
||||
if (utxo.lockingBytecode && seenBytecodes.has(utxo.lockingBytecode)) continue;
|
||||
if (utxo.lockingBytecode) seenBytecodes.add(utxo.lockingBytecode);
|
||||
|
||||
utxo.selected = true;
|
||||
accumulated += utxo.valueSatoshis;
|
||||
|
||||
if (accumulated >= required + fee) break;
|
||||
}
|
||||
|
||||
setUtxos(selectable);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [invitation, computeRequiredAmount, fee]);
|
||||
|
||||
// Load UTXOs on mount
|
||||
useEffect(() => {
|
||||
if (isActive) 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
|
||||
useInput((input, key) => {
|
||||
if (!isActive) return;
|
||||
|
||||
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 === ' ' || (key.return && utxos.length > 0)) {
|
||||
// Space or Enter toggles the focused UTXO
|
||||
if (utxos.length > 0) toggleSelection(focusedIndex);
|
||||
} else if (input === 'a') {
|
||||
// Select all
|
||||
setUtxos(prev => prev.map(u => ({ ...u, selected: true })));
|
||||
} else if (input === 'n') {
|
||||
// Deselect all
|
||||
setUtxos(prev => prev.map(u => ({ ...u, selected: false })));
|
||||
} else if (key.tab) {
|
||||
// Tab confirms selection (moves to next step)
|
||||
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)}</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)}</Text>
|
||||
{hasEnough && changeAmount >= DUST_THRESHOLD && (
|
||||
<Text color={colors.textMuted}> (change: {formatSatoshis(changeAmount)})</Text>
|
||||
)}
|
||||
{!hasEnough && (
|
||||
<Text color={colors.error}> — need {formatSatoshis(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 (
|
||||
<Text
|
||||
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
|
||||
color={isFocused ? colors.focus : utxo.selected ? colors.success : colors.text}
|
||||
bold={isFocused}
|
||||
>
|
||||
{isFocused ? '▸ ' : ' '}{checkMark} {formatSatoshis(utxo.valueSatoshis)} ({txShort}…:{utxo.outpointIndex})
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Navigation hint */}
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>
|
||||
↑↓: Navigate • Space: Toggle • a: All • n: None • Tab: Confirm • Esc: Cancel
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user