Add oracle rates
This commit is contained in:
13
package-lock.json
generated
13
package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitauth/libauth": "^3.0.0",
|
"@bitauth/libauth": "^3.0.0",
|
||||||
"@electrum-cash/protocol": "^2.3.1",
|
"@electrum-cash/protocol": "^2.3.1",
|
||||||
|
"@generalprotocols/oracle-client": "^0.0.1-development.11945476152",
|
||||||
"@xo-cash/crypto": "file:../crypto",
|
"@xo-cash/crypto": "file:../crypto",
|
||||||
"@xo-cash/engine": "file:../engine",
|
"@xo-cash/engine": "file:../engine",
|
||||||
"@xo-cash/state": "file:../state",
|
"@xo-cash/state": "file:../state",
|
||||||
@@ -118,7 +119,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitauth/libauth": "^3.1.0-next.8",
|
"@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",
|
"@xo-cash/utils": "0.0.1",
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
@@ -330,6 +331,16 @@
|
|||||||
"ws": "^8.13.0"
|
"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": {
|
"node_modules/@jridgewell/resolve-uri": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitauth/libauth": "^3.0.0",
|
"@bitauth/libauth": "^3.0.0",
|
||||||
"@electrum-cash/protocol": "^2.3.1",
|
"@electrum-cash/protocol": "^2.3.1",
|
||||||
|
"@generalprotocols/oracle-client": "^0.0.1-development.11945476152",
|
||||||
"@xo-cash/crypto": "file:../crypto",
|
"@xo-cash/crypto": "file:../crypto",
|
||||||
"@xo-cash/engine": "file:../engine",
|
"@xo-cash/engine": "file:../engine",
|
||||||
"@xo-cash/state": "file:../state",
|
"@xo-cash/state": "file:../state",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { BaseStorage, Storage } from "./storage.js";
|
|||||||
import { SyncServer } from "../utils/sync-server.js";
|
import { SyncServer } from "../utils/sync-server.js";
|
||||||
import { HistoryService } from "./history.js";
|
import { HistoryService } from "./history.js";
|
||||||
import { type BlockchainService, ElectrumService } from "./electrum.js";
|
import { type BlockchainService, ElectrumService } from "./electrum.js";
|
||||||
|
import { RatesService } from "./rates.js";
|
||||||
|
|
||||||
import { EventEmitter } from "../utils/event-emitter.js";
|
import { EventEmitter } from "../utils/event-emitter.js";
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
public config: AppConfig;
|
public config: AppConfig;
|
||||||
public history: HistoryService;
|
public history: HistoryService;
|
||||||
public electrum: BlockchainService;
|
public electrum: BlockchainService;
|
||||||
|
public rates: RatesService;
|
||||||
|
|
||||||
public invitations: Invitation[] = [];
|
public invitations: Invitation[] = [];
|
||||||
private invitationEventCleanup = new Map<
|
private invitationEventCleanup = new Map<
|
||||||
@@ -107,8 +109,9 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
host: config.electrumHost,
|
host: config.electrumHost,
|
||||||
applicationIdentifier: config.electrumApplicationIdentifier,
|
applicationIdentifier: config.electrumApplicationIdentifier,
|
||||||
});
|
});
|
||||||
|
const rates = await RatesService.create();
|
||||||
|
|
||||||
return new AppService(engine, walletStorage, config, electrum);
|
return new AppService(engine, walletStorage, config, electrum, rates);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -116,6 +119,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
storage: BaseStorage,
|
storage: BaseStorage,
|
||||||
config: AppConfig,
|
config: AppConfig,
|
||||||
electrum: BlockchainService,
|
electrum: BlockchainService,
|
||||||
|
rates: RatesService,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
@@ -123,6 +127,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
this.storage = storage;
|
this.storage = storage;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.electrum = electrum;
|
this.electrum = electrum;
|
||||||
|
this.rates = rates;
|
||||||
this.history = new HistoryService(engine, this.invitations);
|
this.history = new HistoryService(engine, this.invitations);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +262,11 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async start(): Promise<void> {
|
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
|
// Get the invitations db
|
||||||
const invitationsDb = this.storage.child("invitations");
|
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 { Box, Text } from "ink";
|
||||||
import TextInput from "./TextInput.js";
|
import TextInput from "./TextInput.js";
|
||||||
|
import { useSatoshisConversion } from "../hooks/useSatoshisConversion.js";
|
||||||
|
|
||||||
interface VariableInputFieldProps {
|
interface VariableInputFieldProps {
|
||||||
variable: {
|
variable: {
|
||||||
@@ -18,6 +19,45 @@ interface VariableInputFieldProps {
|
|||||||
focusColor: string;
|
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({
|
export function VariableInputField({
|
||||||
variable,
|
variable,
|
||||||
index,
|
index,
|
||||||
@@ -27,6 +67,26 @@ export function VariableInputField({
|
|||||||
borderColor,
|
borderColor,
|
||||||
focusColor,
|
focusColor,
|
||||||
}: VariableInputFieldProps): React.ReactElement {
|
}: 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 (
|
return (
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
<Text color={focusColor}>{variable.name}</Text>
|
<Text color={focusColor}>{variable.name}</Text>
|
||||||
@@ -54,12 +114,29 @@ export function VariableInputField({
|
|||||||
<Text color={borderColor} dimColor>{variable.hint}</Text>
|
<Text color={borderColor} dimColor>{variable.hint}</Text>
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
{variable.type === 'integer' && variable.hint === 'satoshis' && (
|
{shouldShowSatoshisConversion && (
|
||||||
<Box>
|
<Box flexDirection="column">
|
||||||
<Text color={borderColor} dimColor>
|
{formattedBch ? (
|
||||||
{/* 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 color={borderColor} dimColor>
|
||||||
</Text>
|
{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>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -23,3 +23,5 @@ export {
|
|||||||
useBlockableInput,
|
useBlockableInput,
|
||||||
useIsInputCaptured,
|
useIsInputCaptured,
|
||||||
} from "./useInputLayer.js";
|
} 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 { QRCode } from '../components/QRCode.js';
|
||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
import { useNavigation } from '../hooks/useNavigation.js';
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||||
|
import { useSatoshisConversion } from '../hooks/useSatoshisConversion.js';
|
||||||
import { useInputLayer, useLayeredInput, useBlockableInput, useIsInputCaptured } from '../hooks/useInputLayer.js';
|
import { useInputLayer, useLayeredInput, useBlockableInput, useIsInputCaptured } from '../hooks/useInputLayer.js';
|
||||||
import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js';
|
import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js';
|
||||||
import type { HistoryItem } from '../../services/history.js';
|
import type { HistoryItem } from '../../services/history.js';
|
||||||
@@ -108,6 +109,12 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
const { navigate } = useNavigation();
|
const { navigate } = useNavigation();
|
||||||
const { appService, showError, showInfo } = useAppContext();
|
const { appService, showError, showInfo } = useAppContext();
|
||||||
const { setStatus } = useStatus();
|
const { setStatus } = useStatus();
|
||||||
|
const {
|
||||||
|
currencyCode,
|
||||||
|
fiatPerBchRate,
|
||||||
|
formattedFiatPerBchRate,
|
||||||
|
formatSatoshisToFiat,
|
||||||
|
} = useSatoshisConversion('USD');
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
|
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
|
||||||
@@ -297,6 +304,26 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
});
|
});
|
||||||
}, [history]);
|
}, [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.
|
// Screen input — automatically blocked when any dialog/overlay is capturing.
|
||||||
const isCaptured = useIsInputCaptured();
|
const isCaptured = useIsInputCaptured();
|
||||||
|
|
||||||
@@ -335,11 +362,16 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (row.type === 'invitation_input') {
|
if (row.type === 'invitation_input') {
|
||||||
|
const inputSatoshis = row.utxo?.valueSatoshis;
|
||||||
|
const inputFiatSuffix = inputSatoshis !== undefined
|
||||||
|
? getFiatSuffix(inputSatoshis)
|
||||||
|
: '';
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="row" justifyContent="space-between">
|
<Box flexDirection="row" justifyContent="space-between">
|
||||||
<Box>
|
<Box>
|
||||||
<Text color={itemColor}>
|
<Text color={itemColor}>
|
||||||
{indicator}{groupingPrefix}[Input] {row.label}
|
{indicator}{groupingPrefix}[Input] {row.label}
|
||||||
|
{inputFiatSuffix}
|
||||||
</Text>
|
</Text>
|
||||||
{row.description && <Text color={colors.textMuted}> {row.description}</Text>}
|
{row.description && <Text color={colors.textMuted}> {row.description}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -355,6 +387,7 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
<Box flexDirection="row">
|
<Box flexDirection="row">
|
||||||
<Text color={itemColor}>
|
<Text color={itemColor}>
|
||||||
{indicator}{groupingPrefix}[Output] {formatSatoshis(sats)}
|
{indicator}{groupingPrefix}[Output] {formatSatoshis(sats)}
|
||||||
|
{getFiatSuffix(sats)}
|
||||||
</Text>
|
</Text>
|
||||||
{row.description && <Text color={colors.textMuted}> {row.description}</Text>}
|
{row.description && <Text color={colors.textMuted}> {row.description}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -369,7 +402,10 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
return (
|
return (
|
||||||
<Box flexDirection="row" justifyContent="space-between">
|
<Box flexDirection="row" justifyContent="space-between">
|
||||||
<Box flexDirection="row">
|
<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>}
|
{row.description && <Text color={colors.textMuted}> {row.description}{reservedTag}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
@@ -386,7 +422,7 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}, []);
|
}, [getFiatSuffix]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" flexGrow={1}>
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
@@ -418,6 +454,20 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
<Text color={colors.success} bold>
|
<Text color={colors.success} bold>
|
||||||
{formatSatoshis(balance.totalSatoshis)}
|
{formatSatoshis(balance.totalSatoshis)}
|
||||||
</Text>
|
</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}>
|
<Text color={colors.textMuted}>
|
||||||
UTXOs: {balance.utxoCount}
|
UTXOs: {balance.utxoCount}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { colors, formatSatoshis, formatHex } from '../../../theme.js';
|
import { colors, formatSatoshis, formatHex } from '../../../theme.js';
|
||||||
|
import { useSatoshisConversion } from '../../../hooks/useSatoshisConversion.js';
|
||||||
import type { SelectableUTXO, FocusArea } from '../types.js';
|
import type { SelectableUTXO, FocusArea } from '../types.js';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -22,6 +23,13 @@ export function InputsStep({
|
|||||||
changeAmount,
|
changeAmount,
|
||||||
focusArea,
|
focusArea,
|
||||||
}: Props): React.ReactElement {
|
}: Props): React.ReactElement {
|
||||||
|
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
|
||||||
|
|
||||||
|
const getFiatSuffix = (satoshis: bigint): string => {
|
||||||
|
const fiatValue = formatSatoshisToFiat(satoshis);
|
||||||
|
return fiatValue ? ` (~${fiatValue})` : '';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection='column'>
|
<Box flexDirection='column'>
|
||||||
<Text color={colors.text} bold>
|
<Text color={colors.text} bold>
|
||||||
@@ -32,6 +40,7 @@ export function InputsStep({
|
|||||||
<Text color={colors.textMuted}>
|
<Text color={colors.textMuted}>
|
||||||
Required: {formatSatoshis(requiredAmount)} +{' '}
|
Required: {formatSatoshis(requiredAmount)} +{' '}
|
||||||
{formatSatoshis(fee)} fee
|
{formatSatoshis(fee)} fee
|
||||||
|
{getFiatSuffix(requiredAmount + fee)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
color={
|
color={
|
||||||
@@ -41,10 +50,12 @@ export function InputsStep({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
Selected: {formatSatoshis(selectedAmount)}
|
Selected: {formatSatoshis(selectedAmount)}
|
||||||
|
{getFiatSuffix(selectedAmount)}
|
||||||
</Text>
|
</Text>
|
||||||
{selectedAmount > requiredAmount + fee && (
|
{selectedAmount > requiredAmount + fee && (
|
||||||
<Text color={colors.info}>
|
<Text color={colors.info}>
|
||||||
Change: {formatSatoshis(changeAmount)}
|
Change: {formatSatoshis(changeAmount)}
|
||||||
|
{getFiatSuffix(changeAmount)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -65,6 +76,7 @@ export function InputsStep({
|
|||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
|
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
|
||||||
|
flexDirection='column'
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
color={isCursor ? colors.focus : colors.text}
|
color={isCursor ? colors.focus : colors.text}
|
||||||
@@ -75,6 +87,15 @@ export function InputsStep({
|
|||||||
{formatHex(utxo.outpointTransactionHash, 12)}:
|
{formatHex(utxo.outpointTransactionHash, 12)}:
|
||||||
{utxo.outpointIndex}
|
{utxo.outpointIndex}
|
||||||
</Text>
|
</Text>
|
||||||
|
{(() => {
|
||||||
|
const fiatValue = formatSatoshisToFiat(utxo.valueSatoshis);
|
||||||
|
if (!fiatValue) return null;
|
||||||
|
return (
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{' '}≈ {fiatValue}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { colors, formatSatoshis } from '../../../theme.js';
|
import { colors, formatSatoshis } from '../../../theme.js';
|
||||||
|
import { useSatoshisConversion } from '../../../hooks/useSatoshisConversion.js';
|
||||||
import type { VariableInput, SelectableUTXO } from '../types.js';
|
import type { VariableInput, SelectableUTXO } from '../types.js';
|
||||||
import type { XOTemplate } from '@xo-cash/types';
|
import type { XOTemplate } from '@xo-cash/types';
|
||||||
|
|
||||||
@@ -22,6 +23,32 @@ export function ReviewStep({
|
|||||||
changeAmount,
|
changeAmount,
|
||||||
}: ReviewStepProps): React.ReactElement {
|
}: ReviewStepProps): React.ReactElement {
|
||||||
const selectedUtxos = availableUtxos.filter((u) => u.selected);
|
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 (
|
return (
|
||||||
<Box flexDirection='column'>
|
<Box flexDirection='column'>
|
||||||
@@ -44,6 +71,7 @@ export function ReviewStep({
|
|||||||
<Text key={v.id} color={colors.textMuted}>
|
<Text key={v.id} color={colors.textMuted}>
|
||||||
{' '}
|
{' '}
|
||||||
{v.name}: {v.value || '(empty)'}
|
{v.name}: {v.value || '(empty)'}
|
||||||
|
{v.value ? getVariableFiatSuffix(v) : ''}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -62,6 +90,7 @@ export function ReviewStep({
|
|||||||
>
|
>
|
||||||
{' '}
|
{' '}
|
||||||
{formatSatoshis(u.valueSatoshis)}
|
{formatSatoshis(u.valueSatoshis)}
|
||||||
|
{getFiatSuffix(u.valueSatoshis)}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
{selectedUtxos.length > 3 && (
|
{selectedUtxos.length > 3 && (
|
||||||
@@ -78,6 +107,7 @@ export function ReviewStep({
|
|||||||
<Text color={colors.text}>Outputs:</Text>
|
<Text color={colors.text}>Outputs:</Text>
|
||||||
<Text color={colors.textMuted}>
|
<Text color={colors.textMuted}>
|
||||||
{' '}Change: {formatSatoshis(changeAmount)}
|
{' '}Change: {formatSatoshis(changeAmount)}
|
||||||
|
{getFiatSuffix(changeAmount)}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useNavigation } from '../../hooks/useNavigation.js';
|
|||||||
import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
|
||||||
import { useBlockableInput, useIsInputCaptured } from '../../hooks/useInputLayer.js';
|
import { useBlockableInput, useIsInputCaptured } from '../../hooks/useInputLayer.js';
|
||||||
import { useInvitations } from '../../hooks/useInvitations.js';
|
import { useInvitations } from '../../hooks/useInvitations.js';
|
||||||
|
import { useSatoshisConversion } from '../../hooks/useSatoshisConversion.js';
|
||||||
import { colors, logoSmall, formatSatoshis } from '../../theme.js';
|
import { colors, logoSmall, formatSatoshis } from '../../theme.js';
|
||||||
import { copyToClipboard } from '../../utils/clipboard.js';
|
import { copyToClipboard } from '../../utils/clipboard.js';
|
||||||
import type { Invitation } from '../../../services/invitation.js';
|
import type { Invitation } from '../../../services/invitation.js';
|
||||||
@@ -88,6 +89,8 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
const { setStatus } = useStatus();
|
const { setStatus } = useStatus();
|
||||||
|
|
||||||
const invitations = useInvitations();
|
const invitations = useInvitations();
|
||||||
|
const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } =
|
||||||
|
useSatoshisConversion('USD');
|
||||||
|
|
||||||
// ── UI state ─────────────────────────────────────────────────────────────
|
// ── UI state ─────────────────────────────────────────────────────────────
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
@@ -494,6 +497,44 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole];
|
const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole];
|
||||||
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
|
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 (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{/* Type & Status */}
|
{/* Type & Status */}
|
||||||
@@ -514,6 +555,11 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
<Text color={colors.textMuted}>
|
<Text color={colors.textMuted}>
|
||||||
Action: {action?.name ?? selectedInvitation.data.actionIdentifier}
|
Action: {action?.name ?? selectedInvitation.data.actionIdentifier}
|
||||||
</Text>
|
</Text>
|
||||||
|
{formattedFiatPerBchRate && (
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
1 BCH = {formattedFiatPerBchRate}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
{action?.description && (
|
{action?.description && (
|
||||||
<Text color={colors.textMuted} dimColor>{action.description}</Text>
|
<Text color={colors.textMuted} dimColor>{action.description}</Text>
|
||||||
)}
|
)}
|
||||||
@@ -542,6 +588,11 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
inputs.map((input, idx) => {
|
inputs.map((input, idx) => {
|
||||||
const isUserInput = input.entityIdentifier === userEntityId;
|
const isUserInput = input.entityIdentifier === userEntityId;
|
||||||
const inputTemplate = selectedTemplate?.inputs?.[input.inputIdentifier ?? ''];
|
const inputTemplate = selectedTemplate?.inputs?.[input.inputIdentifier ?? ''];
|
||||||
|
const inputSatoshis = (
|
||||||
|
'valueSatoshis' in input && input.valueSatoshis !== undefined
|
||||||
|
)
|
||||||
|
? parseNumberishToBigInt(input.valueSatoshis)
|
||||||
|
: null;
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
key={`input-${idx}`}
|
key={`input-${idx}`}
|
||||||
@@ -550,6 +601,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
{' '}{isUserInput ? '• ' : '○ '}
|
{' '}{isUserInput ? '• ' : '○ '}
|
||||||
{inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
|
{inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
|
||||||
{input.roleIdentifier && ` (${input.roleIdentifier})`}
|
{input.roleIdentifier && ` (${input.roleIdentifier})`}
|
||||||
|
{inputSatoshis !== null && ` ${formatSatoshis(inputSatoshis)}${getFiatSuffix(inputSatoshis)}`}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -564,6 +616,9 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
outputs.map((output, idx) => {
|
outputs.map((output, idx) => {
|
||||||
const isUserOutput = output.entityIdentifier === userEntityId;
|
const isUserOutput = output.entityIdentifier === userEntityId;
|
||||||
const outputTemplate = selectedTemplate?.outputs?.[output.outputIdentifier ?? ''];
|
const outputTemplate = selectedTemplate?.outputs?.[output.outputIdentifier ?? ''];
|
||||||
|
const outputSatoshis = output.valueSatoshis !== undefined
|
||||||
|
? parseNumberishToBigInt(output.valueSatoshis)
|
||||||
|
: null;
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
key={`output-${idx}`}
|
key={`output-${idx}`}
|
||||||
@@ -571,7 +626,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
>
|
>
|
||||||
{' '}{isUserOutput ? '• ' : '○ '}
|
{' '}{isUserOutput ? '• ' : '○ '}
|
||||||
{outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
|
{outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
|
||||||
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
|
{outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)}${getFiatSuffix(outputSatoshis)})`}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -591,6 +646,9 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
const displayValue = typeof variable.value === 'bigint'
|
const displayValue = typeof variable.value === 'bigint'
|
||||||
? variable.value.toString()
|
? variable.value.toString()
|
||||||
: String(variable.value);
|
: String(variable.value);
|
||||||
|
const parsedVariableSatoshis = isSatoshisVariable(variable.variableIdentifier)
|
||||||
|
? parseNumberishToBigInt(variable.value)
|
||||||
|
: null;
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
key={`var-${idx}`}
|
key={`var-${idx}`}
|
||||||
@@ -598,6 +656,8 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
>
|
>
|
||||||
{' '}{isUserVariable ? '• ' : '○ '}
|
{' '}{isUserVariable ? '• ' : '○ '}
|
||||||
{varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
|
{varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
|
||||||
|
{parsedVariableSatoshis !== null &&
|
||||||
|
` (${formatSatoshis(parsedVariableSatoshis)}${getFiatSuffix(parsedVariableSatoshis)})`}
|
||||||
{varTemplate?.description && (
|
{varTemplate?.description && (
|
||||||
<Text color={colors.textMuted} dimColor> - {varTemplate.description}</Text>
|
<Text color={colors.textMuted} dimColor> - {varTemplate.description}</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { colors, formatSatoshis } from '../../../../theme.js';
|
import { colors, formatSatoshis } from '../../../../theme.js';
|
||||||
|
import { useSatoshisConversion } from '../../../../hooks/useSatoshisConversion.js';
|
||||||
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
|
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
|
||||||
import type { InputsSelectStepProps, SelectableUTXO } from '../types.js';
|
import type { InputsSelectStepProps, SelectableUTXO } from '../types.js';
|
||||||
import { autoSelectGreedyUtxos, mapUnspentOutputsToSelectable } from '../../../../../utils/invitation-flow.js';
|
import { autoSelectGreedyUtxos, mapUnspentOutputsToSelectable } from '../../../../../utils/invitation-flow.js';
|
||||||
@@ -32,6 +33,7 @@ export function InputsSelectStep({
|
|||||||
const [requiredAmount, setRequiredAmount] = useState(0n);
|
const [requiredAmount, setRequiredAmount] = useState(0n);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
|
||||||
|
|
||||||
const fee = DEFAULT_FEE;
|
const fee = DEFAULT_FEE;
|
||||||
|
|
||||||
@@ -42,6 +44,11 @@ export function InputsSelectStep({
|
|||||||
const changeAmount = selectedAmount - requiredAmount - fee;
|
const changeAmount = selectedAmount - requiredAmount - fee;
|
||||||
const hasEnough = 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.
|
* Determine the required satoshi amount from the invitation's variables.
|
||||||
*/
|
*/
|
||||||
@@ -193,18 +200,32 @@ export function InputsSelectStep({
|
|||||||
{/* Summary bar */}
|
{/* Summary bar */}
|
||||||
<Box flexDirection="row" marginBottom={1}>
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
<Text color={colors.primary} bold>Required: </Text>
|
<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>
|
<Text color={colors.textMuted}> (amount {formatSatoshis(requiredAmount)} + fee {formatSatoshis(fee)})</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box flexDirection="row" marginBottom={1}>
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
<Text color={colors.primary} bold>Selected: </Text>
|
<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 && (
|
{hasEnough && changeAmount >= DUST_THRESHOLD && (
|
||||||
<Text color={colors.textMuted}> (change: {formatSatoshis(changeAmount)})</Text>
|
<Text color={colors.textMuted}>
|
||||||
|
{' '}
|
||||||
|
(change: {formatSatoshis(changeAmount)}
|
||||||
|
{getFiatSuffix(changeAmount)})
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
{!hasEnough && (
|
{!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>
|
</Box>
|
||||||
|
|
||||||
@@ -216,13 +237,22 @@ export function InputsSelectStep({
|
|||||||
const txShort = utxo.outpointTransactionHash.slice(0, 8);
|
const txShort = utxo.outpointTransactionHash.slice(0, 8);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text
|
<Box
|
||||||
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
|
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
|
||||||
color={isFocused ? colors.focus : utxo.selected ? colors.success : colors.text}
|
flexDirection="column"
|
||||||
bold={isFocused}
|
|
||||||
>
|
>
|
||||||
{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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { colors, formatSatoshis } from '../../../../theme.js';
|
import { colors, formatSatoshis } from '../../../../theme.js';
|
||||||
|
import { useSatoshisConversion } from '../../../../hooks/useSatoshisConversion.js';
|
||||||
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
|
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
|
||||||
import {
|
import {
|
||||||
getInvitationState,
|
getInvitationState,
|
||||||
@@ -41,6 +42,8 @@ export function PreviewInvitationStep({
|
|||||||
onCancel,
|
onCancel,
|
||||||
isActive,
|
isActive,
|
||||||
}: PreviewStepProps): React.ReactElement {
|
}: PreviewStepProps): React.ReactElement {
|
||||||
|
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
|
||||||
|
|
||||||
useLayeredInput('import-flow', (_input, key) => {
|
useLayeredInput('import-flow', (_input, key) => {
|
||||||
if (key.return) onComplete();
|
if (key.return) onComplete();
|
||||||
if (key.escape) onCancel();
|
if (key.escape) onCancel();
|
||||||
@@ -168,11 +171,15 @@ export function PreviewInvitationStep({
|
|||||||
) : (
|
) : (
|
||||||
outputs.map((output, idx) => {
|
outputs.map((output, idx) => {
|
||||||
const outputTemplate = template?.outputs?.[output.outputIdentifier ?? ''];
|
const outputTemplate = template?.outputs?.[output.outputIdentifier ?? ''];
|
||||||
|
const fiatValue = output.valueSatoshis !== undefined
|
||||||
|
? formatSatoshisToFiat(output.valueSatoshis)
|
||||||
|
: null;
|
||||||
return (
|
return (
|
||||||
<Box key={`output-${idx}`}>
|
<Box key={`output-${idx}`}>
|
||||||
<Text color={colors.text}>
|
<Text color={colors.text}>
|
||||||
{' '}• {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
|
{' '}• {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
|
||||||
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
|
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
|
||||||
|
{fiatValue && ` (~${fiatValue})`}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { colors, formatSatoshis } from '../../../../theme.js';
|
import { colors, formatSatoshis } from '../../../../theme.js';
|
||||||
|
import { useSatoshisConversion } from '../../../../hooks/useSatoshisConversion.js';
|
||||||
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
|
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
|
||||||
import type { ReviewStepProps, SelectableUTXO } from '../types.js';
|
import type { ReviewStepProps, SelectableUTXO } from '../types.js';
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ export function ReviewStep({
|
|||||||
}: ReviewStepProps): React.ReactElement {
|
}: ReviewStepProps): React.ReactElement {
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
|
||||||
|
|
||||||
const fee = DEFAULT_FEE;
|
const fee = DEFAULT_FEE;
|
||||||
const action = template?.actions?.[invitation.data.actionIdentifier];
|
const action = template?.actions?.[invitation.data.actionIdentifier];
|
||||||
@@ -39,6 +41,11 @@ export function ReviewStep({
|
|||||||
// Compute totals from selected inputs
|
// Compute totals from selected inputs
|
||||||
const totalSelected = selectedInputs.reduce((sum, u) => sum + u.valueSatoshis, 0n);
|
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.
|
* Execute the import: add inputs (with role) and optional change output.
|
||||||
*/
|
*/
|
||||||
@@ -85,14 +92,34 @@ export function ReviewStep({
|
|||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection="column">
|
||||||
<Text color={colors.primary} bold>Funding:</Text>
|
<Text color={colors.primary} bold>Funding:</Text>
|
||||||
<Text color={colors.text}> • UTXOs: {selectedInputs.length}</Text>
|
<Text color={colors.text}> • UTXOs: {selectedInputs.length}</Text>
|
||||||
<Text color={colors.text}> • Total: {formatSatoshis(totalSelected)}</Text>
|
<Text color={colors.text}> • Total: {formatSatoshis(totalSelected)}{getFiatSuffix(totalSelected)}</Text>
|
||||||
<Text color={colors.text}> • Required: {formatSatoshis(requiredAmount)}</Text>
|
<Text color={colors.text}> • Required: {formatSatoshis(requiredAmount)}{getFiatSuffix(requiredAmount)}</Text>
|
||||||
<Text color={colors.text}> • Fee: {formatSatoshis(fee)}</Text>
|
<Text color={colors.text}> • Fee: {formatSatoshis(fee)}{getFiatSuffix(fee)}</Text>
|
||||||
{changeAmount >= DUST_THRESHOLD && (
|
{changeAmount >= DUST_THRESHOLD && (
|
||||||
<Text color={colors.text}> • Change: {formatSatoshis(changeAmount)}</Text>
|
<Text color={colors.text}> • Change: {formatSatoshis(changeAmount)}{getFiatSuffix(changeAmount)}</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</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 display */}
|
||||||
{error && (
|
{error && (
|
||||||
<Box marginTop={1}>
|
<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