Add currency settings, Settings service, and dialog to select fiat currency. Add support for non Official currencies like DOGE when using rates.

This commit is contained in:
2026-05-11 10:41:41 +00:00
parent ebe1d8acda
commit 6c01ac1c1b
28 changed files with 1102 additions and 48 deletions

View File

@@ -12,6 +12,7 @@ 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 { SettingsService } from "./settings.js";
import { EventEmitter } from "../utils/event-emitter.js";
@@ -49,6 +50,7 @@ export class AppService extends EventEmitter<AppEventMap> {
public history: HistoryService;
public electrum: BlockchainService;
public rates: RatesService;
public settings: SettingsService;
public invitations: Invitation[] = [];
private invitationEventCleanup = new Map<
@@ -59,7 +61,11 @@ export class AppService extends EventEmitter<AppEventMap> {
}
>();
static async create(seed: string, config: AppConfig): Promise<AppService> {
static async create(
seed: string,
config: AppConfig,
settings: SettingsService = new SettingsService(),
): Promise<AppService> {
// Because of a bug that lets wallets read the unspents of other wallets, we are going to manually namespace the storage paths for the app.
// We are going to do this by computing a hash of the seed and prefixing the storage paths with it.
const seedHash = createHash("sha256").update(seed).digest("hex");
@@ -110,9 +116,9 @@ export class AppService extends EventEmitter<AppEventMap> {
host: config.electrumHost,
applicationIdentifier: config.electrumApplicationIdentifier,
});
const rates = await RatesService.create();
const rates = await RatesService.create(settings);
return new AppService(engine, walletStorage, config, electrum, rates);
return new AppService(engine, walletStorage, config, electrum, rates, settings);
}
constructor(
@@ -121,6 +127,7 @@ export class AppService extends EventEmitter<AppEventMap> {
config: AppConfig,
electrum: BlockchainService,
rates: RatesService,
settings: SettingsService,
) {
super();
@@ -129,6 +136,7 @@ export class AppService extends EventEmitter<AppEventMap> {
this.config = config;
this.electrum = electrum;
this.rates = rates;
this.settings = settings;
this.history = new HistoryService(engine, this.invitations);
}

View File

@@ -3,6 +3,7 @@ import {
type RatesEventMap,
} from '../utils/rates/base-rates.js';
import { RatesOracle } from '../utils/rates/rates-oracles.js';
import { SettingsService } from './settings.js';
/**
* Event map emitted by {@link RatesService}.
@@ -52,13 +53,15 @@ export interface RatesAdapter {
*/
export class RatesService extends EventEmitter<RatesServiceEventMap> {
private readonly adapter: RatesAdapter;
private readonly settings: SettingsService;
private readonly ratesByPair = new Map<string, CachedRate>();
private unsubscribeFromAdapter: (() => void) | null = null;
private started = false;
constructor(adapter: RatesAdapter) {
constructor(adapter: RatesAdapter, settings: SettingsService) {
super();
this.adapter = adapter;
this.settings = settings;
}
/**
@@ -66,9 +69,12 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
*
* 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);
public static async create(
settings: SettingsService,
adapter?: RatesAdapter,
): Promise<RatesService> {
const resolvedAdapter = adapter ?? (await RatesOracle.from(undefined, settings));
return new RatesService(resolvedAdapter, settings);
}
/**
@@ -162,6 +168,20 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
return this.adapter.formatCurrency(amount, currencyCode.toUpperCase());
}
/**
* Lists available market pairs in NUMERATOR/DENOMINATOR format.
*/
public async listPairs(): Promise<Set<string>> {
return this.adapter.listPairs();
}
/**
* Returns the fiat currency currently configured in settings.
*/
public getConfiguredCurrency(): string {
return this.settings.getCurrency();
}
/**
* Handles normalized updates from the underlying adapter.
*/

194
src/services/settings.ts Normal file
View File

@@ -0,0 +1,194 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { EventEmitter } from "../utils/event-emitter.js";
import { getSettingsPath } from "../utils/paths.js";
/**
* Supported persisted settings keys.
*/
export type SettingsData = {
"default-mnemonic"?: string;
currency: string;
};
/**
* Event payloads emitted by {@link SettingsService}.
*/
export type SettingsServiceEventMap = {
"settings-updated": {
key: keyof SettingsData;
value: string | undefined;
settings: SettingsData;
};
};
/**
* Runtime defaults for settings that should always exist in memory.
*/
const DEFAULT_SETTINGS: SettingsData = {
currency: "USD",
};
/**
* Handles loading, migrating, and persisting wallet settings.
*
* The backing file is `~/.config/xo-cli/.wallet`. Historically it stored a raw
* mnemonic reference string. This service migrates that legacy format to JSON:
* `{ "default-mnemonic": "<value>", "currency": "USD" }`.
*/
export class SettingsService extends EventEmitter<SettingsServiceEventMap> {
private readonly settingsPath: string;
private settings: SettingsData;
/**
* Creates a new settings service instance.
*
* @param settingsPath - Optional custom settings file path (useful for tests)
*/
constructor(settingsPath: string = getSettingsPath()) {
super();
this.settingsPath = settingsPath;
this.settings = this.loadSettings();
}
/**
* Returns the current settings snapshot.
*/
public getSettings(): SettingsData {
return { ...this.settings };
}
/**
* Returns the currently selected default mnemonic reference.
*/
public getDefaultMnemonic(): string | undefined {
return this.settings["default-mnemonic"];
}
/**
* Updates the default mnemonic reference and persists it to disk.
*/
public setDefaultMnemonic(mnemonicRef: string): void {
const normalizedMnemonicRef = mnemonicRef.trim();
if (normalizedMnemonicRef.length === 0) {
throw new Error("default-mnemonic cannot be empty");
}
this.settings["default-mnemonic"] = normalizedMnemonicRef;
this.persistSettings();
this.emit("settings-updated", {
key: "default-mnemonic",
value: normalizedMnemonicRef,
settings: this.getSettings(),
});
}
/**
* Returns the selected fiat currency code (ISO-like uppercase).
*/
public getCurrency(): string {
return this.settings.currency;
}
/**
* Updates the selected fiat currency and persists it to disk.
*/
public setCurrency(currencyCode: string): void {
const normalizedCurrency = this.normalizeCurrency(currencyCode);
if (this.settings.currency === normalizedCurrency) {
return;
}
this.settings.currency = normalizedCurrency;
this.persistSettings();
this.emit("settings-updated", {
key: "currency",
value: normalizedCurrency,
settings: this.getSettings(),
});
}
/**
* Reads and normalizes the settings file from disk.
*
* If the file contains the old legacy format (raw mnemonic string), the
* migrated JSON shape is written back immediately.
*/
private loadSettings(): SettingsData {
if (!existsSync(this.settingsPath)) {
return { ...DEFAULT_SETTINGS };
}
const rawContents = readFileSync(this.settingsPath, "utf8").trim();
if (rawContents.length === 0) {
return { ...DEFAULT_SETTINGS };
}
try {
const parsed = JSON.parse(rawContents);
const normalized = this.normalizeSettings(parsed);
return normalized;
} catch {
const migrated = this.normalizeSettings({
"default-mnemonic": rawContents,
});
this.persistSettings(migrated);
return migrated;
}
}
/**
* Writes the given settings object to disk as pretty JSON.
*
* @param nextSettings - Optional explicit value, defaults to in-memory state
*/
private persistSettings(nextSettings?: SettingsData): void {
if (nextSettings) {
this.settings = nextSettings;
}
writeFileSync(
this.settingsPath,
`${JSON.stringify(this.settings, null, 2)}\n`,
"utf8",
);
}
/**
* Coerces unknown input into a safe settings object.
*/
private normalizeSettings(input: unknown): SettingsData {
const normalized: SettingsData = {
...DEFAULT_SETTINGS,
};
if (!input || typeof input !== "object") {
return normalized;
}
const maybeMnemonic = (input as Record<string, unknown>)["default-mnemonic"];
if (typeof maybeMnemonic === "string" && maybeMnemonic.trim().length > 0) {
normalized["default-mnemonic"] = maybeMnemonic.trim();
}
const maybeCurrency = (input as Record<string, unknown>).currency;
if (typeof maybeCurrency === "string" && maybeCurrency.trim().length > 0) {
normalized.currency = this.normalizeCurrency(maybeCurrency);
}
return normalized;
}
/**
* Ensures currency values stay uppercase and non-empty.
*/
private normalizeCurrency(currencyCode: string): string {
const normalizedCurrency = currencyCode.trim().toUpperCase();
if (normalizedCurrency.length === 0) {
throw new Error("currency cannot be empty");
}
return normalizedCurrency;
}
}