Files
xo-cli/src/services/app.ts
T
2026-09-08 17:33:55 +00:00

406 lines
14 KiB
TypeScript

import {
ElectrumBlockchainProvider,
Engine,
type XOEngineOptions,
computeAccountHash,
// This is temporary. Will likely be moved to where we import templates in the cli. I think that makes more sense as this is a library thing
generateTemplateIdentifier,
} from "@xo-cash/engine";
import type { XOInvitation } from "@xo-cash/types";
import { Invitation } from "./invitation.js";
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 { SettingsService } from "./settings.js";
import { EventEmitter } from "../utils/event-emitter.js";
// TODO: Remove this. Exists to hash the seed for database namespace.
import { createHash } from "crypto";
import { hexToBin } from "@bitauth/libauth";
import { parseTemplate, BlockchainMonitor } from "@xo-cash/engine";
import { p2pkhTemplate } from "@xo-cash/templates";
import { vendingMachineTemplate } from "../templates/vending-machine.js";
import { wrapBCHTemplate } from "../templates/wrap-template.js";
import { createStorageAdapter, State, StorageType } from "@xo-cash/state";
export type AppEventMap = {
"invitation-added": Invitation;
"invitation-removed": Invitation;
"wallet-state-changed": {
reason:
| "invitation-added"
| "invitation-removed"
| "invitation-updated"
| "invitation-status-changed";
invitationIdentifier: string;
};
};
export interface AppConfig {
syncServerUrl: string;
engineConfig: XOEngineOptions;
invitationStoragePath: string;
electrumHost?: string;
electrumApplicationIdentifier?: string;
}
export class AppService extends EventEmitter<AppEventMap> {
public engine: Engine;
public storage: BaseStorage;
public config: AppConfig;
public history: HistoryService;
public electrum: BlockchainService;
public rates: RatesService;
public settings: SettingsService;
public state: State;
public invitations: Invitation[] = [];
/**
* Incremented whenever the invitation list or any invitation's data/status changes.
* Used by TUI hooks so useSyncExternalStore snapshots change on in-place mutations.
*/
public invitationsRevision = 0;
private invitationRevisions = new Map<string, number>();
private invitationEventCleanup = new Map<
string,
{
onUpdated: (invitation: XOInvitation) => void;
onStatusChanged: (status: string) => void;
onRemoved: () => void;
}
>();
private stopPromise: Promise<void> | null = null;
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");
const accountHash = computeAccountHash(seed);
// The v2 client derives an isolated signing key from this key for each invitation resource.
const syncPrivateKey = hexToBin(seedHash);
// We want to only prefix the file name
const prefixedStoragePath = `${seedHash.slice(0, 8)}-${config.engineConfig.databaseFilename}`;
// Initialize the blockchain provider using Electrum
const blockchainProvider = new ElectrumBlockchainProvider({
applicationIdentifier: config.electrumApplicationIdentifier ?? 'XO-CLI',
electrumOptions: config.engineConfig,
});
// Start the blockchain provider (do this async, otherwise we block startup - the data is event-based so we dont need to await it)
blockchainProvider.initialize();
// Create the storage adapter
const storageAdapter = await createStorageAdapter({
storageType: config.engineConfig.storageType ?? StorageType.INDEXEDDB,
databasePath: config.engineConfig.databasePath,
databaseFilename: prefixedStoragePath,
accountHash,
});
// Create the state instance
const state = new State(storageAdapter);
// Initialize the blockchain monitor, event listeners for electrum application events
const blockchainMonitor = new BlockchainMonitor(state, blockchainProvider);
blockchainMonitor.initializeEventListeners();
// Create the engine
// TODO: Remove this type assertion. Make Engine constructor public OR allow full pre-defined dependency injection to `.create()`
const engine = new Engine(seed, state, blockchainProvider, blockchainMonitor);
// Initialize the state sync server.
await engine.initializeStateSync();
// TODO: We *technically* dont want this here, but we also need some initial templates for the wallet, so im doing it here
// Import the default P2PKH template
await engine.importTemplate(p2pkhTemplate);
await engine.importTemplate(vendingMachineTemplate);
await engine.importTemplate(wrapBCHTemplate);
// Update all the unspents for every template, and subscribe to the locking bytecodes for changes
// TODO: Remove the above lines that do the same thing. Minimising changes for BLISS.
const updateTemplates = async () => {
const templates = await engine.listImportedTemplates();
// maaan, I have no clue if this is required still
// templates.forEach(async (template) => {
// engine.updateUnspentOutputsForTemplate(
// generateTemplateIdentifier(template),
// );
// engine.subscribeToScriptHashForTemplate(
// generateTemplateIdentifier(template),
// );
// });
};
updateTemplates();
// Set default locking parameters for P2PKH
// To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically.
// TODO: Add discovery for funds in the first index? Or until we return 0 TXs?
await engine.updateFallbackLockingParameters({
templateIdentifier: generateTemplateIdentifier(parseTemplate(p2pkhTemplate)),
outputIdentifier: "receiveOutput",
roleIdentifier: "receiver",
});
// Create our own storage for the invitations
const storage = await Storage.create(config.invitationStoragePath);
const walletStorage = await storage.child(seedHash.slice(0, 8));
// Create the app service
const electrum = new ElectrumService({
host: config.electrumHost,
applicationIdentifier: config.electrumApplicationIdentifier,
});
const rates = await RatesService.create(settings);
return new AppService(
engine,
walletStorage,
config,
electrum,
rates,
settings,
state,
syncPrivateKey,
);
}
constructor(
engine: Engine,
storage: BaseStorage,
config: AppConfig,
electrum: BlockchainService,
rates: RatesService,
settings: SettingsService,
state: State,
private readonly syncPrivateKey: Uint8Array,
) {
super();
this.engine = engine;
this.storage = storage;
this.config = config;
this.electrum = electrum;
this.rates = rates;
this.settings = settings;
this.state = state;
this.history = new HistoryService(engine, this.invitations, state);
}
async createInvitation(
invitation: XOInvitation | string,
): Promise<Invitation> {
// Make sure the engine has the template imported
const invitationStorage = this.storage.child("invitations");
const invitationSyncServer = new SyncServer(
this.config.syncServerUrl,
typeof invitation === "string"
? invitation
: invitation.invitationIdentifier,
this.syncPrivateKey,
);
const deps = {
engine: this.engine,
syncServer: invitationSyncServer,
storage: invitationStorage,
electrum: this.electrum,
};
// Create the invitation
const invitationInstance = await Invitation.create(invitation, deps);
// Attach listeners before SSE connects so updates are not missed.
await this.addInvitation(invitationInstance);
invitationInstance.start();
return invitationInstance;
}
async addInvitation(invitation: Invitation): Promise<void> {
this.attachInvitationListeners(invitation);
// Add the invitation to the invitations array
this.invitations.push(invitation);
this.bumpInvitationRevision(invitation.data.invitationIdentifier);
// Emit the invitation-added event
this.emit("invitation-added", invitation);
this.emit("wallet-state-changed", {
reason: "invitation-added",
invitationIdentifier: invitation.data.invitationIdentifier,
});
}
async removeInvitation(invitation: Invitation): Promise<void> {
const invitationIdentifier = invitation.data.invitationIdentifier;
this.detachInvitationListeners(invitationIdentifier);
// Remove the invitation from the invitations array while preserving the array reference.
const invitationIndex = this.invitations.indexOf(invitation);
if (invitationIndex >= 0) {
this.invitations.splice(invitationIndex, 1);
}
this.bumpInvitationRevision(invitationIdentifier);
// Emit the invitation-removed event
this.emit("invitation-removed", invitation);
this.emit("wallet-state-changed", {
reason: "invitation-removed",
invitationIdentifier,
});
}
private attachInvitationListeners(invitation: Invitation): void {
const invitationIdentifier = invitation.data.invitationIdentifier;
if (this.invitationEventCleanup.has(invitationIdentifier)) return;
const onUpdated = () => {
this.bumpInvitationRevision(invitationIdentifier);
this.emit("wallet-state-changed", {
reason: "invitation-updated",
invitationIdentifier,
});
};
const onStatusChanged = () => {
this.bumpInvitationRevision(invitationIdentifier);
this.emit("wallet-state-changed", {
reason: "invitation-status-changed",
invitationIdentifier,
});
};
const onRemoved = () => {
this.detachInvitationListeners(invitationIdentifier);
this.invitations.splice(this.invitations.indexOf(invitation), 1);
this.bumpInvitationRevision(invitationIdentifier);
this.emit("invitation-removed", invitation);
this.emit("wallet-state-changed", {
reason: "invitation-removed",
invitationIdentifier: invitationIdentifier,
});
};
invitation.on("invitation-updated", onUpdated);
invitation.on("invitation-status-changed", onStatusChanged);
invitation.on("invitation-removed", onRemoved);
this.invitationEventCleanup.set(invitationIdentifier, {
onUpdated,
onStatusChanged,
onRemoved,
});
}
getInvitationRevision(invitationIdentifier: string): number {
return this.invitationRevisions.get(invitationIdentifier) ?? 0;
}
private bumpInvitationRevision(invitationIdentifier: string): void {
this.invitationsRevision += 1;
this.invitationRevisions.set(
invitationIdentifier,
this.getInvitationRevision(invitationIdentifier) + 1,
);
}
private detachInvitationListeners(invitationIdentifier: string): void {
const trackedInvitation = this.invitations.find(
(candidate) =>
candidate.data.invitationIdentifier === invitationIdentifier,
);
const cleanup = this.invitationEventCleanup.get(invitationIdentifier);
if (!trackedInvitation || !cleanup) return;
trackedInvitation.off("invitation-updated", cleanup.onUpdated);
trackedInvitation.off("invitation-status-changed", cleanup.onStatusChanged);
this.invitationEventCleanup.delete(invitationIdentifier);
}
/**
* Unreserves all reserved UTXOs across every invitation.
* Useful when stale reservations from previous sessions block spending.
*
* @returns The number of UTXOs that were unreserved.
*/
async unreserveAllResources(): Promise<number> {
const allUnspentOutputs = await this.engine.listUnspentOutputsData();
const reserved = allUnspentOutputs.filter((o) => o.reservedBy);
// Group by invitation identifier so the engine can clear them properly.
const byInvitation = new Map<string, typeof reserved>();
for (const output of reserved) {
const existing = byInvitation.get(output.reservedBy!) ?? [];
existing.push(output);
byInvitation.set(output.reservedBy!, existing);
}
// console.error('Unreserving resources is not currently supported by the engine')
for (const [invitationIdentifier, outputs] of byInvitation) {
// Remove them directly from state
// TODO: Make this parallel. CBF doing it now, because it likely breaks things.
await this.state.archiveInvitation(invitationIdentifier);
// await this.engine.unreserveResources(
// outputs.map((o) => ({
// outpointTransactionHash: hexToBin(o.outpointTransactionHash),
// outpointIndex: o.outpointIndex,
// })),
// invitationIdentifier,
// );
}
return reserved.length;
}
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");
// Load invitations from storage
const invitations = (await invitationsDb.all()) as {
key: string;
value: XOInvitation;
}[];
await Promise.all(
invitations.map(async ({ key }) => {
await this.createInvitation(key).catch((err) =>
console.error(`Error creating invitation ${key}: ${err}`),
);
}),
);
}
/** Stop background services before the process exits or updates. */
async stop(): Promise<void> {
this.stopPromise ??= (async () => {
await Promise.allSettled([
this.rates.stop(),
this.electrum.stop?.() ?? Promise.resolve(),
...this.invitations.map((invitation) => invitation.stop()),
]);
await this.engine.stop();
})();
await this.stopPromise;
}
}