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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
194
src/services/settings.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user