Large amount of changes. Successfully broadcasts txs

This commit is contained in:
2026-03-08 15:53:50 +00:00
parent 66e9918e04
commit 9ef1720e1f
19 changed files with 1374 additions and 352 deletions

View File

@@ -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>
);
}