diff --git a/package-lock.json b/package-lock.json index c29441c..1f98eb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@bitauth/libauth": "^3.0.0", "@electrum-cash/protocol": "^2.3.1", + "@generalprotocols/oracle-client": "^0.0.1-development.11945476152", "@xo-cash/crypto": "file:../crypto", "@xo-cash/engine": "file:../engine", "@xo-cash/state": "file:../state", @@ -118,7 +119,7 @@ "license": "MIT", "dependencies": { "@bitauth/libauth": "^3.1.0-next.8", - "@xo-cash/types": "0.0.1-development.13730885533", + "@xo-cash/types": "0.0.1", "@xo-cash/utils": "0.0.1", "better-sqlite3": "^12.5.0", "idb": "^8.0.3", @@ -330,6 +331,16 @@ "ws": "^8.13.0" } }, + "node_modules/@generalprotocols/oracle-client": { + "version": "0.0.1-development.11945476152", + "resolved": "https://registry.npmjs.org/@generalprotocols/oracle-client/-/oracle-client-0.0.1-development.11945476152.tgz", + "integrity": "sha512-1Q43NfacrVfSbatCREzIX7U3DgACBUegNjV977y+pql+Fve03bOyTiUQClevymCi7M3T6mCyMzSEGT8zA6EZtQ==", + "license": "MIT", + "dependencies": { + "@bitauth/libauth": "^3.0.0", + "zod": "^4.1.12" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", diff --git a/package.json b/package.json index fa7391d..5e95750 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "dependencies": { "@bitauth/libauth": "^3.0.0", "@electrum-cash/protocol": "^2.3.1", + "@generalprotocols/oracle-client": "^0.0.1-development.11945476152", "@xo-cash/crypto": "file:../crypto", "@xo-cash/engine": "file:../engine", "@xo-cash/state": "file:../state", diff --git a/src/services/app.ts b/src/services/app.ts index 6d27c32..06d0fe7 100644 --- a/src/services/app.ts +++ b/src/services/app.ts @@ -11,6 +11,7 @@ import { BaseStorage, Storage } from "./storage.js"; import { SyncServer } from "../utils/sync-server.js"; import { HistoryService } from "./history.js"; import { type BlockchainService, ElectrumService } from "./electrum.js"; +import { RatesService } from "./rates.js"; import { EventEmitter } from "../utils/event-emitter.js"; @@ -46,6 +47,7 @@ export class AppService extends EventEmitter { public config: AppConfig; public history: HistoryService; public electrum: BlockchainService; + public rates: RatesService; public invitations: Invitation[] = []; private invitationEventCleanup = new Map< @@ -107,8 +109,9 @@ export class AppService extends EventEmitter { host: config.electrumHost, applicationIdentifier: config.electrumApplicationIdentifier, }); + const rates = await RatesService.create(); - return new AppService(engine, walletStorage, config, electrum); + return new AppService(engine, walletStorage, config, electrum, rates); } constructor( @@ -116,6 +119,7 @@ export class AppService extends EventEmitter { storage: BaseStorage, config: AppConfig, electrum: BlockchainService, + rates: RatesService, ) { super(); @@ -123,6 +127,7 @@ export class AppService extends EventEmitter { this.storage = storage; this.config = config; this.electrum = electrum; + this.rates = rates; this.history = new HistoryService(engine, this.invitations); } @@ -257,6 +262,11 @@ export class AppService extends EventEmitter { } async start(): Promise { + // Start rates in the background so BCH -> fiat conversions become reactive in the TUI. + this.rates.start().catch((err) => + console.error('Error starting rates service:', err), + ); + // Get the invitations db const invitationsDb = this.storage.child("invitations"); diff --git a/src/services/rates.ts b/src/services/rates.ts new file mode 100644 index 0000000..62b436b --- /dev/null +++ b/src/services/rates.ts @@ -0,0 +1,197 @@ +import { EventEmitter } from '../utils/event-emitter.js'; +import { + type RatesEventMap, +} from '../utils/rates/base-rates.js'; +import { RatesOracle } from '../utils/rates/rates-oracles.js'; + +/** + * Event map emitted by {@link RatesService}. + */ +export type RatesServiceEventMap = { + 'rate-updated': { + numeratorUnitCode: string; + denominatorUnitCode: string; + price: number; + pair: string; + updatedAt: number; + }; +}; + +/** + * In-memory representation of a market rate. + */ +type CachedRate = { + price: number; + updatedAt: number; +}; + +/** + * Minimal adapter contract that RatesService depends on. + * + * Using a small interface keeps the service decoupled and avoids inheriting + * implementation-specific type constraints from concrete adapters. + */ +export interface RatesAdapter { + start(): Promise; + stop(): Promise; + listPairs(): Promise>; + formatCurrency(amount: number, targetCurrency: string): string; + on( + type: 'rateUpdated', + listener: (detail: RatesEventMap['rateUpdated']) => void, + ): () => void; +} + +/** + * Orchestrates the rates adapter lifecycle and provides BCH -> fiat helpers + * for the TUI. + * + * This service keeps a small in-memory snapshot of the latest prices and emits + * a normalized event whenever a pair changes. React components can subscribe + * through `useSyncExternalStore` for clean and predictable reactivity. + */ +export class RatesService extends EventEmitter { + private readonly adapter: RatesAdapter; + private readonly ratesByPair = new Map(); + private unsubscribeFromAdapter: (() => void) | null = null; + private started = false; + + private constructor(adapter: RatesAdapter) { + super(); + this.adapter = adapter; + } + + /** + * Creates a rates service. + * + * If no adapter is passed, this defaults to the Oracle-backed adapter. + */ + public static async create(adapter?: RatesAdapter): Promise { + const resolvedAdapter = adapter ?? (await RatesOracle.from()); + return new RatesService(resolvedAdapter); + } + + /** + * Starts the underlying adapter and begins collecting live updates. + */ + public async start(): Promise { + if (this.started) { + return; + } + this.started = true; + + this.unsubscribeFromAdapter = this.adapter.on('rateUpdated', (event) => { + this.handleRateUpdated(event); + }); + + try { + await this.adapter.start(); + } catch (error) { + this.unsubscribeFromAdapter?.(); + this.unsubscribeFromAdapter = null; + this.started = false; + throw error; + } + } + + /** + * Stops live rate collection. + */ + public async stop(): Promise { + if (!this.started) { + return; + } + this.started = false; + + this.unsubscribeFromAdapter?.(); + this.unsubscribeFromAdapter = null; + + await this.adapter.stop(); + } + + /** + * Returns the latest price for a pair in NUMERATOR/DENOMINATOR form. + * + * Example: `getRate("USD", "BCH")`. + */ + public getRate( + numeratorUnitCode: string, + denominatorUnitCode: string, + ): number | null { + const pair = this.getPairKey(numeratorUnitCode, denominatorUnitCode); + return this.ratesByPair.get(pair)?.price ?? null; + } + + /** + * Converts satoshis to fiat using the latest BCH/fiat rate. + * + * Example: `convertBchToFiat(1234n, "USD")`. + */ + public convertBchToFiat( + satoshis: bigint, + targetCurrency: string = 'USD', + ): number | null { + const rate = this.getRate(targetCurrency, 'BCH'); + if (rate === null) { + return null; + } + + const amountInBch = Number(satoshis) / 100_000_000; + return amountInBch * rate; + } + + /** + * Formats a BCH -> fiat converted amount using the adapter formatter. + */ + public formatBchToFiat( + satoshis: bigint, + targetCurrency: string = 'USD', + ): string | null { + const normalizedCurrency = targetCurrency.toUpperCase(); + const amount = this.convertBchToFiat(satoshis, normalizedCurrency); + if (amount === null) { + return null; + } + return this.adapter.formatCurrency(amount, normalizedCurrency); + } + + /** + * Formats an arbitrary fiat amount in a currency-aware way. + */ + public formatCurrency(amount: number, currencyCode: string): string { + return this.adapter.formatCurrency(amount, currencyCode.toUpperCase()); + } + + /** + * Handles normalized updates from the underlying adapter. + */ + private handleRateUpdated(event: RatesEventMap['rateUpdated']): void { + const numeratorUnitCode = event.numeratorUnitCode.toUpperCase(); + const denominatorUnitCode = event.denominatorUnitCode.toUpperCase(); + const pair = this.getPairKey(numeratorUnitCode, denominatorUnitCode); + const updatedAt = Date.now(); + + this.ratesByPair.set(pair, { + price: event.price, + updatedAt, + }); + + this.emit('rate-updated', { + numeratorUnitCode, + denominatorUnitCode, + price: event.price, + pair, + updatedAt, + }); + } + + /** + * Creates a stable key for pair lookups. + */ + private getPairKey( + numeratorUnitCode: string, + denominatorUnitCode: string, + ): string { + return `${numeratorUnitCode.toUpperCase()}/${denominatorUnitCode.toUpperCase()}`; + } +} diff --git a/src/tui/components/VariableInputField.tsx b/src/tui/components/VariableInputField.tsx index e0524db..7d5227b 100644 --- a/src/tui/components/VariableInputField.tsx +++ b/src/tui/components/VariableInputField.tsx @@ -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 ( {variable.name} @@ -54,12 +114,29 @@ export function VariableInputField({ {variable.hint} - {variable.type === 'integer' && variable.hint === 'satoshis' && ( - - - {/* 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 - + {shouldShowSatoshisConversion && ( + + {formattedBch ? ( + <> + + {formattedBch} + + + {formattedFiat + ? `Approx. ${currencyCode}: ${formattedFiat}` + : `Approx. ${currencyCode}: waiting for live rate...`} + + {formattedFiatPerBchRate && ( + + 1 BCH = {formattedFiatPerBchRate} + + )} + + ) : ( + + Enter a whole satoshi amount to preview BCH/{currencyCode} conversion. + + )} )} diff --git a/src/tui/hooks/index.ts b/src/tui/hooks/index.ts index 341f840..eb9124a 100644 --- a/src/tui/hooks/index.ts +++ b/src/tui/hooks/index.ts @@ -23,3 +23,5 @@ export { useBlockableInput, useIsInputCaptured, } from "./useInputLayer.js"; +export { useRate, useBchToFiatRate } from "./useRates.js"; +export { useSatoshisConversion } from "./useSatoshisConversion.js"; diff --git a/src/tui/hooks/useRates.tsx b/src/tui/hooks/useRates.tsx new file mode 100644 index 0000000..2d06bbd --- /dev/null +++ b/src/tui/hooks/useRates.tsx @@ -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'); +} diff --git a/src/tui/hooks/useSatoshisConversion.tsx b/src/tui/hooks/useSatoshisConversion.tsx new file mode 100644 index 0000000..c934ff5 --- /dev/null +++ b/src/tui/hooks/useSatoshisConversion.tsx @@ -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; +} diff --git a/src/tui/screens/WalletState.tsx b/src/tui/screens/WalletState.tsx index 85e6ccb..582790c 100644 --- a/src/tui/screens/WalletState.tsx +++ b/src/tui/screens/WalletState.tsx @@ -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 ( {indicator}{groupingPrefix}[Input] {row.label} + {inputFiatSuffix} {row.description && {row.description}} @@ -355,6 +387,7 @@ export function WalletStateScreen(): React.ReactElement { {indicator}{groupingPrefix}[Output] {formatSatoshis(sats)} + {getFiatSuffix(sats)} {row.description && {row.description}} @@ -369,7 +402,10 @@ export function WalletStateScreen(): React.ReactElement { return ( - {indicator}{formatSatoshis(sats)} + + {indicator}{formatSatoshis(sats)} + {getFiatSuffix(sats)} + {row.description && {row.description}{reservedTag}} {dateStr && {dateStr}} @@ -386,7 +422,7 @@ export function WalletStateScreen(): React.ReactElement { {dateStr && {dateStr}} ); - }, []); + }, [getFiatSuffix]); return ( @@ -418,6 +454,20 @@ export function WalletStateScreen(): React.ReactElement { {formatSatoshis(balance.totalSatoshis)} + {formattedUsdBalance ? ( + + Approx. Fiat ({currencyCode}): {formattedUsdBalance} + + ) : ( + + Approx. Fiat ({currencyCode}): Waiting for BCH/{currencyCode} rate... + + )} + {formattedUsdPerBchRate && ( + + 1 BCH = {formattedUsdPerBchRate} + + )} UTXOs: {balance.utxoCount} diff --git a/src/tui/screens/action-wizard/steps/InputsStep.tsx b/src/tui/screens/action-wizard/steps/InputsStep.tsx index d406ee5..9402dd1 100644 --- a/src/tui/screens/action-wizard/steps/InputsStep.tsx +++ b/src/tui/screens/action-wizard/steps/InputsStep.tsx @@ -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 ( @@ -32,6 +40,7 @@ export function InputsStep({ Required: {formatSatoshis(requiredAmount)} +{' '} {formatSatoshis(fee)} fee + {getFiatSuffix(requiredAmount + fee)} Selected: {formatSatoshis(selectedAmount)} + {getFiatSuffix(selectedAmount)} {selectedAmount > requiredAmount + fee && ( Change: {formatSatoshis(changeAmount)} + {getFiatSuffix(changeAmount)} )} @@ -65,6 +76,7 @@ export function InputsStep({ return ( + {(() => { + const fiatValue = formatSatoshisToFiat(utxo.valueSatoshis); + if (!fiatValue) return null; + return ( + + {' '}≈ {fiatValue} + + ); + })()} ); }) diff --git a/src/tui/screens/action-wizard/steps/ReviewStep.tsx b/src/tui/screens/action-wizard/steps/ReviewStep.tsx index 09da0bc..57429c4 100644 --- a/src/tui/screens/action-wizard/steps/ReviewStep.tsx +++ b/src/tui/screens/action-wizard/steps/ReviewStep.tsx @@ -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 ( @@ -44,6 +71,7 @@ export function ReviewStep({ {' '} {v.name}: {v.value || '(empty)'} + {v.value ? getVariableFiatSuffix(v) : ''} ))} @@ -62,6 +90,7 @@ export function ReviewStep({ > {' '} {formatSatoshis(u.valueSatoshis)} + {getFiatSuffix(u.valueSatoshis)} ))} {selectedUtxos.length > 3 && ( @@ -78,6 +107,7 @@ export function ReviewStep({ Outputs: {' '}Change: {formatSatoshis(changeAmount)} + {getFiatSuffix(changeAmount)} )} diff --git a/src/tui/screens/invitations/InvitationScreen.tsx b/src/tui/screens/invitations/InvitationScreen.tsx index 4cf6170..b78fde8 100644 --- a/src/tui/screens/invitations/InvitationScreen.tsx +++ b/src/tui/screens/invitations/InvitationScreen.tsx @@ -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 ( {/* Type & Status */} @@ -514,6 +555,11 @@ export function InvitationScreen(): React.ReactElement { Action: {action?.name ?? selectedInvitation.data.actionIdentifier} + {formattedFiatPerBchRate && ( + + 1 BCH = {formattedFiatPerBchRate} + + )} {action?.description && ( {action.description} )} @@ -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 ( ); }) @@ -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 ( {' '}{isUserOutput ? '• ' : '○ '} {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`} - {output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`} + {outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)}${getFiatSuffix(outputSatoshis)})`} ); }) @@ -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 ( {' '}{isUserVariable ? '• ' : '○ '} {varTemplate?.name ?? variable.variableIdentifier}: {displayValue} + {parsedVariableSatoshis !== null && + ` (${formatSatoshis(parsedVariableSatoshis)}${getFiatSuffix(parsedVariableSatoshis)})`} {varTemplate?.description && ( - {varTemplate.description} )} diff --git a/src/tui/screens/invitations/invitation-import/steps/InputsSelectStep.tsx b/src/tui/screens/invitations/invitation-import/steps/InputsSelectStep.tsx index 3bdb22a..d1a8850 100644 --- a/src/tui/screens/invitations/invitation-import/steps/InputsSelectStep.tsx +++ b/src/tui/screens/invitations/invitation-import/steps/InputsSelectStep.tsx @@ -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(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 */} Required: - {formatSatoshis(requiredAmount + fee)} + + {formatSatoshis(requiredAmount + fee)} + {getFiatSuffix(requiredAmount + fee)} + (amount {formatSatoshis(requiredAmount)} + fee {formatSatoshis(fee)}) Selected: - {formatSatoshis(selectedAmount)} + + {formatSatoshis(selectedAmount)} + {getFiatSuffix(selectedAmount)} + {hasEnough && changeAmount >= DUST_THRESHOLD && ( - (change: {formatSatoshis(changeAmount)}) + + {' '} + (change: {formatSatoshis(changeAmount)} + {getFiatSuffix(changeAmount)}) + )} {!hasEnough && ( - — need {formatSatoshis(requiredAmount + fee - selectedAmount)} more + + {' '} + — need {formatSatoshis(requiredAmount + fee - selectedAmount)} + {getFiatSuffix(requiredAmount + fee - selectedAmount)} more + )} @@ -216,13 +237,22 @@ export function InputsSelectStep({ const txShort = utxo.outpointTransactionHash.slice(0, 8); return ( - - {isFocused ? '▸ ' : ' '}{checkMark} {formatSatoshis(utxo.valueSatoshis)} ({txShort}…:{utxo.outpointIndex}) - + + {isFocused ? '▸ ' : ' '}{checkMark} {formatSatoshis(utxo.valueSatoshis)} ({txShort}…:{utxo.outpointIndex}) + + {formatSatoshisToFiat(utxo.valueSatoshis) && ( + + {' '}≈ {formatSatoshisToFiat(utxo.valueSatoshis)} + + )} + ); })} diff --git a/src/tui/screens/invitations/invitation-import/steps/PreviewInvitationStep.tsx b/src/tui/screens/invitations/invitation-import/steps/PreviewInvitationStep.tsx index 27606f4..5f9e717 100644 --- a/src/tui/screens/invitations/invitation-import/steps/PreviewInvitationStep.tsx +++ b/src/tui/screens/invitations/invitation-import/steps/PreviewInvitationStep.tsx @@ -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 ( {' '}• {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`} {output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`} + {fiatValue && ` (~${fiatValue})`} ); diff --git a/src/tui/screens/invitations/invitation-import/steps/ReviewStep.tsx b/src/tui/screens/invitations/invitation-import/steps/ReviewStep.tsx index 5a002ee..4acb79a 100644 --- a/src/tui/screens/invitations/invitation-import/steps/ReviewStep.tsx +++ b/src/tui/screens/invitations/invitation-import/steps/ReviewStep.tsx @@ -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(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({ Funding: • UTXOs: {selectedInputs.length} - • Total: {formatSatoshis(totalSelected)} - • Required: {formatSatoshis(requiredAmount)} - • Fee: {formatSatoshis(fee)} + • Total: {formatSatoshis(totalSelected)}{getFiatSuffix(totalSelected)} + • Required: {formatSatoshis(requiredAmount)}{getFiatSuffix(requiredAmount)} + • Fee: {formatSatoshis(fee)}{getFiatSuffix(fee)} {changeAmount >= DUST_THRESHOLD && ( - • Change: {formatSatoshis(changeAmount)} + • Change: {formatSatoshis(changeAmount)}{getFiatSuffix(changeAmount)} )} + {selectedInputs.length > 0 && ( + + Selected UTXOs: + {selectedInputs.slice(0, 3).map((utxo) => ( + + {' '}• {formatSatoshis(utxo.valueSatoshis)} + {getFiatSuffix(utxo.valueSatoshis)} + + ))} + {selectedInputs.length > 3 && ( + + {' '}...and {selectedInputs.length - 3} more + + )} + + )} + {/* Error display */} {error && ( diff --git a/src/utils/rates/base-rates.ts b/src/utils/rates/base-rates.ts new file mode 100644 index 0000000..beb5707 --- /dev/null +++ b/src/utils/rates/base-rates.ts @@ -0,0 +1,56 @@ +import { EventEmitter } from '../event-emitter.js'; + +/** + * Events emitted by our Rates Adapters + */ +export type RatesEventMap = { + rateUpdated: { + numeratorUnitCode: string; + denominatorUnitCode: string; + price: number; + }; +}; + +export abstract class BaseRates< + T extends RatesEventMap = RatesEventMap, +> extends EventEmitter { + /** Starts the given rates adapter so that it will emit events on price updates. */ + public abstract start(): Promise; + + /** Stops the given rates adapter so that it will stop checking for price updates. */ + public abstract stop(): Promise; + + /** + * List all available market products (pairs). + * @returns A set of strings in the format "NUMERATOR/DENOMINATOR" + */ + public abstract listPairs(): Promise>; + + // TODO: Consider whether we actually want the below. + // Ideally, we will want to replace this with something like the Units class: + // See: https://gitlab.com/GeneralProtocols/xo/stack/-/issues/44 + /** + * Format the amount in the target currency to the correct number of decimal places. + * + * @param {number} amount - The amount to format. + * @param {string} targetCurrency - The target currency. + * + * @returns The formatted amount. + */ + public formatCurrency(amount: number, targetCurrency: string): string { + const minimumFractionDigitsMap: { [currency: string]: number } = { + AUD: 2, + BCH: 8, + USD: 2, + }; + + const formatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: targetCurrency, + currencyDisplay: 'narrowSymbol', + minimumFractionDigits: minimumFractionDigitsMap[targetCurrency] || 0, + }); + + return formatter.format(amount); + } +} diff --git a/src/utils/rates/rates-oracles.ts b/src/utils/rates/rates-oracles.ts new file mode 100644 index 0000000..43efce1 --- /dev/null +++ b/src/utils/rates/rates-oracles.ts @@ -0,0 +1,170 @@ +import { + OracleClient, + OracleMetadataMessage, + OraclePriceMessage, + type OracleMetadataMap, +} from '@generalprotocols/oracle-client'; + +import { type RatesEventMap, BaseRates } from './base-rates.js'; + +// Add the Oracle Price Message to our Events for this Adapter. +export type RatesOracleEventMap = RatesEventMap & { + rateUpdated: { + oraclePriceMessage: OraclePriceMessage; + }; +}; + +// TODO: Add RatesHistorical trait since Oracles can provide historical rates. +export class RatesOracle extends BaseRates { + /** + * Create a new rates oracle. + * + * @param client The underlying oracle client. If not provided, a new client will be created. + * @returns The rates oracle. + */ + static async from(client?: OracleClient) { + const ratesOracle = new RatesOracle(client ?? (await OracleClient.from())); + + return ratesOracle; + } + + private client: OracleClient; + private oracles: OracleMetadataMap; + + private started: boolean = false; + + private constructor(client: OracleClient) { + super(); + + this.client = client; + this.oracles = {}; + } + + /** + * Start the rates oracle and the underlying client. + */ + async start() { + if (this.started) { + return; + } + this.started = true; + + // Create event listeners for the client. + this.client.setOnMetadataMessage(this.handleMetadataMessage.bind(this)); + this.client.setOnPriceMessage(this.handlePriceMessage.bind(this)); + + // Get the metadata for the client. + this.oracles = await this.client.getMetadataMap(); + + // Start the client. + await this.client.start(); + + // Refresh the prices. + await this.refreshPrices(); + } + + /** + * Stop the rates oracle and the underlying client. + */ + async stop() { + if (!this.started) { + return; + } + this.started = false; + + // Remove event listeners by setting them to empty functions. + this.client.setOnMetadataMessage(() => {}); + this.client.setOnPriceMessage(() => {}); + + await this.client.stop(); + } + + /** + * List the pairs that we are tracking. + * + * @returns A set of pairs. + */ + async listPairs() { + return new Set( + Object.values(this.oracles).map((oracle) => { + return `${oracle.SOURCE_NUMERATOR_UNIT_CODE}/${oracle.SOURCE_DENOMINATOR_UNIT_CODE}`; + }), + ); + } + + /** + * Get the latest prices for all the pairs and emit a rate updated event for each. + */ + public async refreshPrices() { + const oracles = await this.client.getOracles(); + + // For each oracle, get the lastest dataSequence (price) message and emit a rate updated event. + await Promise.allSettled( + oracles.map(async (oracle) => { + try { + const messages = await this.client.getOracleMessages({ + publicKey: oracle.publicKey, + minDataSequence: 1, + count: 1, + }); + + // We are only expecting a single message back. Just in case, we take the latest one. + const message = messages.reduce((latest, msg) => { + if ( + msg instanceof OraclePriceMessage && + msg.messageSequence > (latest?.messageSequence ?? 0) + ) { + return msg; + } + return latest; + }, messages[0]); + + // If the message is a price message, handle it. + if (message instanceof OraclePriceMessage) { + this.handlePriceMessage(message); + } + } catch (error) { + console.error('Error refreshing prices for oracle:', oracle.publicKey, error); + } + }), + ); + } + + /** + * Update the metadata map that we use to track the pairs. + * + * @param message The metadata message. + */ + private handleMetadataMessage(message: OracleMetadataMessage) { + this.oracles = OracleClient.updateMetadataMap(this.oracles, message); + } + + /** + * Emit a rate updated event for the given pair. + * + * @param message The price message. + */ + private handlePriceMessage(message: OraclePriceMessage) { + const oracle = this.oracles[message.toHexObject().publicKey]; + + // If the oracle doesn't have the required metadata, we can't use it. + if ( + !oracle || + !oracle.SOURCE_NUMERATOR_UNIT_CODE || + !oracle.SOURCE_DENOMINATOR_UNIT_CODE || + !oracle.ATTESTATION_SCALING + ) { + return; + } + + // Scale the price + const priceValue = message.priceValue / oracle.ATTESTATION_SCALING; + + this.emit('rateUpdated', { + numeratorUnitCode: oracle.SOURCE_NUMERATOR_UNIT_CODE, + denominatorUnitCode: oracle.SOURCE_DENOMINATOR_UNIT_CODE, + price: priceValue, + oraclePriceMessage: message, + }); + } +}