Add oracle rates
This commit is contained in:
13
package-lock.json
generated
13
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<AppEventMap> {
|
||||
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<AppEventMap> {
|
||||
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<AppEventMap> {
|
||||
storage: BaseStorage,
|
||||
config: AppConfig,
|
||||
electrum: BlockchainService,
|
||||
rates: RatesService,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -123,6 +127,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
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<AppEventMap> {
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
// 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");
|
||||
|
||||
|
||||
197
src/services/rates.ts
Normal file
197
src/services/rates.ts
Normal file
@@ -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<void>;
|
||||
stop(): Promise<void>;
|
||||
listPairs(): Promise<Set<string>>;
|
||||
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<RatesServiceEventMap> {
|
||||
private readonly adapter: RatesAdapter;
|
||||
private readonly ratesByPair = new Map<string, CachedRate>();
|
||||
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<RatesService> {
|
||||
const resolvedAdapter = adapter ?? (await RatesOracle.from());
|
||||
return new RatesService(resolvedAdapter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the underlying adapter and begins collecting live updates.
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
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<void> {
|
||||
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()}`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
{shouldShowSatoshisConversion && (
|
||||
<Box flexDirection="column">
|
||||
{formattedBch ? (
|
||||
<>
|
||||
<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
|
||||
{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>
|
||||
|
||||
@@ -23,3 +23,5 @@ export {
|
||||
useBlockableInput,
|
||||
useIsInputCaptured,
|
||||
} from "./useInputLayer.js";
|
||||
export { useRate, useBchToFiatRate } from "./useRates.js";
|
||||
export { useSatoshisConversion } from "./useSatoshisConversion.js";
|
||||
|
||||
68
src/tui/hooks/useRates.tsx
Normal file
68
src/tui/hooks/useRates.tsx
Normal 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');
|
||||
}
|
||||
42
src/tui/hooks/useSatoshisConversion.tsx
Normal file
42
src/tui/hooks/useSatoshisConversion.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}`}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
56
src/utils/rates/base-rates.ts
Normal file
56
src/utils/rates/base-rates.ts
Normal file
@@ -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<T> {
|
||||
/** Starts the given rates adapter so that it will emit events on price updates. */
|
||||
public abstract start(): Promise<void>;
|
||||
|
||||
/** Stops the given rates adapter so that it will stop checking for price updates. */
|
||||
public abstract stop(): Promise<void>;
|
||||
|
||||
/**
|
||||
* List all available market products (pairs).
|
||||
* @returns A set of strings in the format "NUMERATOR/DENOMINATOR"
|
||||
*/
|
||||
public abstract listPairs(): Promise<Set<string>>;
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
170
src/utils/rates/rates-oracles.ts
Normal file
170
src/utils/rates/rates-oracles.ts
Normal file
@@ -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<RatesOracleEventMap> {
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user