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 { 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(); private invitationEventCleanup = new Map< string, { onUpdated: (invitation: XOInvitation) => void; onStatusChanged: (status: string) => void; onRemoved: () => void; } >(); private stopPromise: Promise | null = null; static async create( seed: string, config: AppConfig, settings: SettingsService = new SettingsService(), ): Promise { // 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 { // 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 { 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 { 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 { 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(); 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 { // 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 { 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; } }