Add oracle rates

This commit is contained in:
2026-04-27 08:42:51 +00:00
parent dbfb2c68d2
commit 7ad17a7c0e
17 changed files with 884 additions and 25 deletions

View File

@@ -1,6 +1,7 @@
import React from "react";
import React, { useMemo } from "react";
import { Box, Text } from "ink";
import TextInput from "./TextInput.js";
import { useSatoshisConversion } from "../hooks/useSatoshisConversion.js";
interface VariableInputFieldProps {
variable: {
@@ -18,6 +19,45 @@ interface VariableInputFieldProps {
focusColor: string;
}
const SATOSHIS_PER_BCH = 100_000_000n;
/**
* Returns true when the variable is an integer satoshis field.
*/
function isSatoshisVariable(variable: VariableInputFieldProps["variable"]): boolean {
return (
variable.type === "integer" &&
variable.hint?.toLowerCase().includes("satoshi") === true
);
}
/**
* Parse a strict integer string into bigint.
*/
function parseSatoshis(value: string): bigint | null {
const trimmed = value.trim();
if (!/^[-]?\d+$/.test(trimmed)) {
return null;
}
try {
return BigInt(trimmed);
} catch {
return null;
}
}
/**
* Format satoshis as BCH with fixed 8 decimals, preserving bigint precision.
*/
function formatBchFromSatoshis(satoshis: bigint): string {
const sign = satoshis < 0n ? "-" : "";
const absolute = satoshis < 0n ? satoshis * -1n : satoshis;
const whole = absolute / SATOSHIS_PER_BCH;
const fractional = absolute % SATOSHIS_PER_BCH;
return `${sign}${whole.toString()}.${fractional.toString().padStart(8, "0")} BCH`;
}
export function VariableInputField({
variable,
index,
@@ -27,6 +67,26 @@ export function VariableInputField({
borderColor,
focusColor,
}: VariableInputFieldProps): React.ReactElement {
const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } =
useSatoshisConversion("USD");
const satoshisValue = useMemo(
() => parseSatoshis(variable.value),
[variable.value],
);
const formattedBch = useMemo(() => {
if (satoshisValue === null) {
return null;
}
return formatBchFromSatoshis(satoshisValue);
}, [satoshisValue]);
const formattedFiat = useMemo(() => {
if (satoshisValue === null) {
return null;
}
return formatSatoshisToFiat(satoshisValue);
}, [satoshisValue, formatSatoshisToFiat]);
const shouldShowSatoshisConversion = isSatoshisVariable(variable);
return (
<Box flexDirection="column" marginBottom={1}>
<Text color={focusColor}>{variable.name}</Text>
@@ -54,12 +114,29 @@ export function VariableInputField({
<Text color={borderColor} dimColor>{variable.hint}</Text>
</Box>
{variable.type === 'integer' && variable.hint === 'satoshis' && (
<Box>
<Text color={borderColor} dimColor>
{/* Convert from sats to bch. NOTE: we can't use the formatSatoshis function because it is too verbose and returns too many values in the string*/}
{(Number(variable.value) / 100_000_000).toFixed(8)} BCH
</Text>
{shouldShowSatoshisConversion && (
<Box flexDirection="column">
{formattedBch ? (
<>
<Text color={borderColor} dimColor>
{formattedBch}
</Text>
<Text color={borderColor} dimColor>
{formattedFiat
? `Approx. ${currencyCode}: ${formattedFiat}`
: `Approx. ${currencyCode}: waiting for live rate...`}
</Text>
{formattedFiatPerBchRate && (
<Text color={borderColor} dimColor>
1 BCH = {formattedFiatPerBchRate}
</Text>
)}
</>
) : (
<Text color={borderColor} dimColor>
Enter a whole satoshi amount to preview BCH/{currencyCode} conversion.
</Text>
)}
</Box>
)}
</Box>

View File

@@ -23,3 +23,5 @@ export {
useBlockableInput,
useIsInputCaptured,
} from "./useInputLayer.js";
export { useRate, useBchToFiatRate } from "./useRates.js";
export { useSatoshisConversion } from "./useSatoshisConversion.js";

View File

@@ -0,0 +1,68 @@
import { useCallback, useMemo, useSyncExternalStore } from 'react';
import type { RatesServiceEventMap } from '../../services/rates.js';
import { useAppContext } from './useAppContext.js';
/**
* Reactive hook for a single market pair.
*
* Pair format is NUMERATOR / DENOMINATOR, e.g. USD / BCH.
*/
export function useRate(
numeratorUnitCode: string,
denominatorUnitCode: string,
): number | null {
const { appService } = useAppContext();
const normalizedNumerator = useMemo(
() => numeratorUnitCode.toUpperCase(),
[numeratorUnitCode],
);
const normalizedDenominator = useMemo(
() => denominatorUnitCode.toUpperCase(),
[denominatorUnitCode],
);
const subscribe = useCallback(
(callback: () => void) => {
if (!appService) {
return () => {};
}
const onRateUpdated = (event: RatesServiceEventMap['rate-updated']) => {
if (
event.numeratorUnitCode === normalizedNumerator &&
event.denominatorUnitCode === normalizedDenominator
) {
callback();
}
};
const unsubscribe = appService.rates.on('rate-updated', onRateUpdated);
return () => {
unsubscribe();
};
},
[appService, normalizedNumerator, normalizedDenominator],
);
const getSnapshot = useCallback(() => {
if (!appService) {
return null;
}
return appService.rates.getRate(normalizedNumerator, normalizedDenominator);
}, [appService, normalizedNumerator, normalizedDenominator]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
/**
* Convenience hook for BCH -> fiat market rates.
*/
export function useBchToFiatRate(
targetCurrency: string = 'USD',
): number | null {
return useRate(targetCurrency, 'BCH');
}

View File

@@ -0,0 +1,42 @@
import { useCallback, useMemo } from 'react';
import { useAppContext } from './useAppContext.js';
import { useBchToFiatRate } from './useRates.js';
/**
* Reactive BCH satoshis -> fiat conversion helpers for TUI screens.
*
* This hook subscribes to rate updates through `useBchToFiatRate`, so any
* component using it will re-render automatically when the selected pair
* receives a new quote.
*/
export function useSatoshisConversion(targetCurrency: string = 'USD') {
const { appService } = useAppContext();
const currencyCode = useMemo(() => targetCurrency.toUpperCase(), [targetCurrency]);
const fiatPerBchRate = useBchToFiatRate(currencyCode);
const formattedFiatPerBchRate = useMemo(() => {
if (!appService || fiatPerBchRate === null) {
return null;
}
return appService.rates.formatCurrency(fiatPerBchRate, currencyCode);
}, [appService, fiatPerBchRate, currencyCode]);
const formatSatoshisToFiat = useCallback(
(satoshis: bigint): string | null => {
if (!appService || fiatPerBchRate === null) {
return null;
}
return appService.rates.formatBchToFiat(satoshis, currencyCode);
},
[appService, fiatPerBchRate, currencyCode],
);
return {
currencyCode,
fiatPerBchRate,
formattedFiatPerBchRate,
formatSatoshisToFiat,
} as const;
}

View File

@@ -13,6 +13,7 @@ import { ScrollableList, type ListItemData } from '../components/List.js';
import { QRCode } from '../components/QRCode.js';
import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { useSatoshisConversion } from '../hooks/useSatoshisConversion.js';
import { useInputLayer, useLayeredInput, useBlockableInput, useIsInputCaptured } from '../hooks/useInputLayer.js';
import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js';
import type { HistoryItem } from '../../services/history.js';
@@ -108,6 +109,12 @@ export function WalletStateScreen(): React.ReactElement {
const { navigate } = useNavigation();
const { appService, showError, showInfo } = useAppContext();
const { setStatus } = useStatus();
const {
currencyCode,
fiatPerBchRate,
formattedFiatPerBchRate,
formatSatoshisToFiat,
} = useSatoshisConversion('USD');
// State
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
@@ -297,6 +304,26 @@ export function WalletStateScreen(): React.ReactElement {
});
}, [history]);
/**
* Fiat values are memoized so we only recompute when balance or rate changes.
*/
const formattedUsdPerBchRate = useMemo(() => {
return formattedFiatPerBchRate;
}, [formattedFiatPerBchRate]);
const formattedUsdBalance = useMemo(() => {
if (!balance || fiatPerBchRate === null) {
return null;
}
return formatSatoshisToFiat(balance.totalSatoshis);
}, [balance, fiatPerBchRate, formatSatoshisToFiat]);
const getFiatSuffix = useCallback((satoshis: bigint): string => {
const fiatValue = formatSatoshisToFiat(satoshis);
return fiatValue ? ` (~${fiatValue})` : '';
}, [formatSatoshisToFiat]);
// Screen input — automatically blocked when any dialog/overlay is capturing.
const isCaptured = useIsInputCaptured();
@@ -335,11 +362,16 @@ export function WalletStateScreen(): React.ReactElement {
}
if (row.type === 'invitation_input') {
const inputSatoshis = row.utxo?.valueSatoshis;
const inputFiatSuffix = inputSatoshis !== undefined
? getFiatSuffix(inputSatoshis)
: '';
return (
<Box flexDirection="row" justifyContent="space-between">
<Box>
<Text color={itemColor}>
{indicator}{groupingPrefix}[Input] {row.label}
{inputFiatSuffix}
</Text>
{row.description && <Text color={colors.textMuted}> {row.description}</Text>}
</Box>
@@ -355,6 +387,7 @@ export function WalletStateScreen(): React.ReactElement {
<Box flexDirection="row">
<Text color={itemColor}>
{indicator}{groupingPrefix}[Output] {formatSatoshis(sats)}
{getFiatSuffix(sats)}
</Text>
{row.description && <Text color={colors.textMuted}> {row.description}</Text>}
</Box>
@@ -369,7 +402,10 @@ export function WalletStateScreen(): React.ReactElement {
return (
<Box flexDirection="row" justifyContent="space-between">
<Box flexDirection="row">
<Text color={itemColor}>{indicator}{formatSatoshis(sats)}</Text>
<Text color={itemColor}>
{indicator}{formatSatoshis(sats)}
{getFiatSuffix(sats)}
</Text>
{row.description && <Text color={colors.textMuted}> {row.description}{reservedTag}</Text>}
</Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
@@ -386,7 +422,7 @@ export function WalletStateScreen(): React.ReactElement {
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
}, []);
}, [getFiatSuffix]);
return (
<Box flexDirection="column" flexGrow={1}>
@@ -418,6 +454,20 @@ export function WalletStateScreen(): React.ReactElement {
<Text color={colors.success} bold>
{formatSatoshis(balance.totalSatoshis)}
</Text>
{formattedUsdBalance ? (
<Text color={colors.info}>
Approx. Fiat ({currencyCode}): {formattedUsdBalance}
</Text>
) : (
<Text color={colors.textMuted}>
Approx. Fiat ({currencyCode}): Waiting for BCH/{currencyCode} rate...
</Text>
)}
{formattedUsdPerBchRate && (
<Text color={colors.textMuted}>
1 BCH = {formattedUsdPerBchRate}
</Text>
)}
<Text color={colors.textMuted}>
UTXOs: {balance.utxoCount}
</Text>

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { Box, Text } from 'ink';
import { colors, formatSatoshis, formatHex } from '../../../theme.js';
import { useSatoshisConversion } from '../../../hooks/useSatoshisConversion.js';
import type { SelectableUTXO, FocusArea } from '../types.js';
interface Props {
@@ -22,6 +23,13 @@ export function InputsStep({
changeAmount,
focusArea,
}: Props): React.ReactElement {
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
const getFiatSuffix = (satoshis: bigint): string => {
const fiatValue = formatSatoshisToFiat(satoshis);
return fiatValue ? ` (~${fiatValue})` : '';
};
return (
<Box flexDirection='column'>
<Text color={colors.text} bold>
@@ -32,6 +40,7 @@ export function InputsStep({
<Text color={colors.textMuted}>
Required: {formatSatoshis(requiredAmount)} +{' '}
{formatSatoshis(fee)} fee
{getFiatSuffix(requiredAmount + fee)}
</Text>
<Text
color={
@@ -41,10 +50,12 @@ export function InputsStep({
}
>
Selected: {formatSatoshis(selectedAmount)}
{getFiatSuffix(selectedAmount)}
</Text>
{selectedAmount > requiredAmount + fee && (
<Text color={colors.info}>
Change: {formatSatoshis(changeAmount)}
{getFiatSuffix(changeAmount)}
</Text>
)}
</Box>
@@ -65,6 +76,7 @@ export function InputsStep({
return (
<Box
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
flexDirection='column'
>
<Text
color={isCursor ? colors.focus : colors.text}
@@ -75,6 +87,15 @@ export function InputsStep({
{formatHex(utxo.outpointTransactionHash, 12)}:
{utxo.outpointIndex}
</Text>
{(() => {
const fiatValue = formatSatoshisToFiat(utxo.valueSatoshis);
if (!fiatValue) return null;
return (
<Text color={colors.textMuted}>
{' '} {fiatValue}
</Text>
);
})()}
</Box>
);
})

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { Box, Text } from 'ink';
import { colors, formatSatoshis } from '../../../theme.js';
import { useSatoshisConversion } from '../../../hooks/useSatoshisConversion.js';
import type { VariableInput, SelectableUTXO } from '../types.js';
import type { XOTemplate } from '@xo-cash/types';
@@ -22,6 +23,32 @@ export function ReviewStep({
changeAmount,
}: ReviewStepProps): React.ReactElement {
const selectedUtxos = availableUtxos.filter((u) => u.selected);
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
const getFiatSuffix = (satoshis: bigint): string => {
const fiatValue = formatSatoshisToFiat(satoshis);
return fiatValue ? ` (~${fiatValue})` : '';
};
const getVariableFiatSuffix = (variable: VariableInput): string => {
if (variable.type !== 'integer') {
return '';
}
if (variable.hint?.toLowerCase().includes('satoshi') !== true) {
return '';
}
if (!/^[-]?\d+$/.test(variable.value.trim())) {
return '';
}
try {
return getFiatSuffix(BigInt(variable.value));
} catch {
return '';
}
};
return (
<Box flexDirection='column'>
@@ -44,6 +71,7 @@ export function ReviewStep({
<Text key={v.id} color={colors.textMuted}>
{' '}
{v.name}: {v.value || '(empty)'}
{v.value ? getVariableFiatSuffix(v) : ''}
</Text>
))}
</Box>
@@ -62,6 +90,7 @@ export function ReviewStep({
>
{' '}
{formatSatoshis(u.valueSatoshis)}
{getFiatSuffix(u.valueSatoshis)}
</Text>
))}
{selectedUtxos.length > 3 && (
@@ -78,6 +107,7 @@ export function ReviewStep({
<Text color={colors.text}>Outputs:</Text>
<Text color={colors.textMuted}>
{' '}Change: {formatSatoshis(changeAmount)}
{getFiatSuffix(changeAmount)}
</Text>
</Box>
)}

View File

@@ -17,6 +17,7 @@ import { useNavigation } from '../../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
import { useBlockableInput, useIsInputCaptured } from '../../hooks/useInputLayer.js';
import { useInvitations } from '../../hooks/useInvitations.js';
import { useSatoshisConversion } from '../../hooks/useSatoshisConversion.js';
import { colors, logoSmall, formatSatoshis } from '../../theme.js';
import { copyToClipboard } from '../../utils/clipboard.js';
import type { Invitation } from '../../../services/invitation.js';
@@ -88,6 +89,8 @@ export function InvitationScreen(): React.ReactElement {
const { setStatus } = useStatus();
const invitations = useInvitations();
const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } =
useSatoshisConversion('USD');
// ── UI state ─────────────────────────────────────────────────────────────
const [selectedIndex, setSelectedIndex] = useState(0);
@@ -494,6 +497,44 @@ export function InvitationScreen(): React.ReactElement {
const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole];
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
const getFiatSuffix = (satoshis: bigint): string => {
const fiatValue = formatSatoshisToFiat(satoshis);
return fiatValue ? ` (~${fiatValue})` : '';
};
const parseNumberishToBigInt = (value: unknown): bigint | null => {
if (typeof value === 'bigint') {
return value;
}
const asString = String(value).trim();
if (!/^[-]?\d+$/.test(asString)) {
return null;
}
try {
return BigInt(asString);
} catch {
return null;
}
};
const isSatoshisVariable = (variableIdentifier: string): boolean => {
const templateVariable = selectedTemplate?.variables?.[variableIdentifier];
const templateType = templateVariable?.type?.toLowerCase();
const templateHint = templateVariable?.hint?.toLowerCase();
const identifier = variableIdentifier.toLowerCase();
if (templateHint?.includes('satoshi')) {
return true;
}
return (
templateType === 'integer' &&
(identifier.includes('satoshi') || identifier.includes('amount'))
);
};
return (
<Box flexDirection="column">
{/* Type & Status */}
@@ -514,6 +555,11 @@ export function InvitationScreen(): React.ReactElement {
<Text color={colors.textMuted}>
Action: {action?.name ?? selectedInvitation.data.actionIdentifier}
</Text>
{formattedFiatPerBchRate && (
<Text color={colors.textMuted}>
1 BCH = {formattedFiatPerBchRate}
</Text>
)}
{action?.description && (
<Text color={colors.textMuted} dimColor>{action.description}</Text>
)}
@@ -542,6 +588,11 @@ export function InvitationScreen(): React.ReactElement {
inputs.map((input, idx) => {
const isUserInput = input.entityIdentifier === userEntityId;
const inputTemplate = selectedTemplate?.inputs?.[input.inputIdentifier ?? ''];
const inputSatoshis = (
'valueSatoshis' in input && input.valueSatoshis !== undefined
)
? parseNumberishToBigInt(input.valueSatoshis)
: null;
return (
<Text
key={`input-${idx}`}
@@ -550,6 +601,7 @@ export function InvitationScreen(): React.ReactElement {
{' '}{isUserInput ? '• ' : '○ '}
{inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
{input.roleIdentifier && ` (${input.roleIdentifier})`}
{inputSatoshis !== null && ` ${formatSatoshis(inputSatoshis)}${getFiatSuffix(inputSatoshis)}`}
</Text>
);
})
@@ -564,6 +616,9 @@ export function InvitationScreen(): React.ReactElement {
outputs.map((output, idx) => {
const isUserOutput = output.entityIdentifier === userEntityId;
const outputTemplate = selectedTemplate?.outputs?.[output.outputIdentifier ?? ''];
const outputSatoshis = output.valueSatoshis !== undefined
? parseNumberishToBigInt(output.valueSatoshis)
: null;
return (
<Text
key={`output-${idx}`}
@@ -571,7 +626,7 @@ export function InvitationScreen(): React.ReactElement {
>
{' '}{isUserOutput ? '• ' : '○ '}
{outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
{outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)}${getFiatSuffix(outputSatoshis)})`}
</Text>
);
})
@@ -591,6 +646,9 @@ export function InvitationScreen(): React.ReactElement {
const displayValue = typeof variable.value === 'bigint'
? variable.value.toString()
: String(variable.value);
const parsedVariableSatoshis = isSatoshisVariable(variable.variableIdentifier)
? parseNumberishToBigInt(variable.value)
: null;
return (
<Text
key={`var-${idx}`}
@@ -598,6 +656,8 @@ export function InvitationScreen(): React.ReactElement {
>
{' '}{isUserVariable ? '• ' : '○ '}
{varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
{parsedVariableSatoshis !== null &&
` (${formatSatoshis(parsedVariableSatoshis)}${getFiatSuffix(parsedVariableSatoshis)})`}
{varTemplate?.description && (
<Text color={colors.textMuted} dimColor> - {varTemplate.description}</Text>
)}

View File

@@ -9,6 +9,7 @@
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';
@@ -32,6 +33,7 @@ export function InputsSelectStep({
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;
@@ -42,6 +44,11 @@ export function InputsSelectStep({
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.
*/
@@ -193,18 +200,32 @@ export function InputsSelectStep({
{/* 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.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)}</Text>
<Text color={hasEnough ? colors.success : colors.error}>
{formatSatoshis(selectedAmount)}
{getFiatSuffix(selectedAmount)}
</Text>
{hasEnough && changeAmount >= DUST_THRESHOLD && (
<Text color={colors.textMuted}> (change: {formatSatoshis(changeAmount)})</Text>
<Text color={colors.textMuted}>
{' '}
(change: {formatSatoshis(changeAmount)}
{getFiatSuffix(changeAmount)})
</Text>
)}
{!hasEnough && (
<Text color={colors.error}> need {formatSatoshis(requiredAmount + fee - selectedAmount)} more</Text>
<Text color={colors.error}>
{' '}
need {formatSatoshis(requiredAmount + fee - selectedAmount)}
{getFiatSuffix(requiredAmount + fee - selectedAmount)} more
</Text>
)}
</Box>
@@ -216,13 +237,22 @@ export function InputsSelectStep({
const txShort = utxo.outpointTransactionHash.slice(0, 8);
return (
<Text
<Box
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
color={isFocused ? colors.focus : utxo.selected ? colors.success : colors.text}
bold={isFocused}
flexDirection="column"
>
{isFocused ? '▸ ' : ' '}{checkMark} {formatSatoshis(utxo.valueSatoshis)} ({txShort}:{utxo.outpointIndex})
</Text>
<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>
);
})}

View File

@@ -9,6 +9,7 @@
import React 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 {
getInvitationState,
@@ -41,6 +42,8 @@ export function PreviewInvitationStep({
onCancel,
isActive,
}: PreviewStepProps): React.ReactElement {
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
useLayeredInput('import-flow', (_input, key) => {
if (key.return) onComplete();
if (key.escape) onCancel();
@@ -168,11 +171,15 @@ export function PreviewInvitationStep({
) : (
outputs.map((output, idx) => {
const outputTemplate = template?.outputs?.[output.outputIdentifier ?? ''];
const fiatValue = output.valueSatoshis !== undefined
? formatSatoshisToFiat(output.valueSatoshis)
: null;
return (
<Box key={`output-${idx}`}>
<Text color={colors.text}>
{' '} {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
{fiatValue && ` (~${fiatValue})`}
</Text>
</Box>
);

View File

@@ -10,6 +10,7 @@
import React, { useState, useCallback } 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 { ReviewStepProps, SelectableUTXO } from '../types.js';
@@ -32,6 +33,7 @@ export function ReviewStep({
}: ReviewStepProps): React.ReactElement {
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
const fee = DEFAULT_FEE;
const action = template?.actions?.[invitation.data.actionIdentifier];
@@ -39,6 +41,11 @@ export function ReviewStep({
// Compute totals from selected inputs
const totalSelected = selectedInputs.reduce((sum, u) => sum + u.valueSatoshis, 0n);
const getFiatSuffix = (satoshis: bigint): string => {
const fiatValue = formatSatoshisToFiat(satoshis);
return fiatValue ? ` (~${fiatValue})` : '';
};
/**
* Execute the import: add inputs (with role) and optional change output.
*/
@@ -85,14 +92,34 @@ export function ReviewStep({
<Box marginTop={1} flexDirection="column">
<Text color={colors.primary} bold>Funding:</Text>
<Text color={colors.text}> UTXOs: {selectedInputs.length}</Text>
<Text color={colors.text}> Total: {formatSatoshis(totalSelected)}</Text>
<Text color={colors.text}> Required: {formatSatoshis(requiredAmount)}</Text>
<Text color={colors.text}> Fee: {formatSatoshis(fee)}</Text>
<Text color={colors.text}> Total: {formatSatoshis(totalSelected)}{getFiatSuffix(totalSelected)}</Text>
<Text color={colors.text}> Required: {formatSatoshis(requiredAmount)}{getFiatSuffix(requiredAmount)}</Text>
<Text color={colors.text}> Fee: {formatSatoshis(fee)}{getFiatSuffix(fee)}</Text>
{changeAmount >= DUST_THRESHOLD && (
<Text color={colors.text}> Change: {formatSatoshis(changeAmount)}</Text>
<Text color={colors.text}> Change: {formatSatoshis(changeAmount)}{getFiatSuffix(changeAmount)}</Text>
)}
</Box>
{selectedInputs.length > 0 && (
<Box marginTop={1} flexDirection="column">
<Text color={colors.primary} bold>Selected UTXOs:</Text>
{selectedInputs.slice(0, 3).map((utxo) => (
<Text
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
color={colors.textMuted}
>
{' '} {formatSatoshis(utxo.valueSatoshis)}
{getFiatSuffix(utxo.valueSatoshis)}
</Text>
))}
{selectedInputs.length > 3 && (
<Text color={colors.textMuted}>
{' '}...and {selectedInputs.length - 3} more
</Text>
)}
</Box>
)}
{/* Error display */}
{error && (
<Box marginTop={1}>