import { Engine, type XOEngineOptions, // 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 { p2pkhTemplate } from "@xo-cash/templates"; import { hexToBin } from "@bitauth/libauth"; import { parseTemplate } from "@xo-cash/engine"; 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 invitations: Invitation[] = []; private invitationEventCleanup = new Map< string, { onUpdated: (invitation: XOInvitation) => void; onStatusChanged: (status: string) => void; } >(); 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"); // We want to only prefix the file name const prefixedStoragePath = `${seedHash.slice(0, 8)}-${config.engineConfig.databaseFilename}`; // Create the engine const engine = await Engine.create(seed, { ...config.engineConfig, databaseFilename: prefixedStoragePath, }); // 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 const { templateIdentifier } = await engine.importTemplate(p2pkhTemplate); // engine // .subscribeToLockingBytecodesForTemplate(templateIdentifier) // .catch((err) => // console.error( // `Error subscribing to locking bytecodes for template ${templateIdentifier}: ${err}`, // ), // ); // engine // .updateUnspentOutputsForTemplate(templateIdentifier) // .catch((err) => // console.error( // `Error updating unspent outputs for template ${templateIdentifier}: ${err}`, // ), // ); // 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(); 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.setDefaultLockingParameters( // generateTemplateIdentifier(parseTemplate(p2pkhTemplate)), // "receiveOutput", // "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); } constructor( engine: Engine, storage: BaseStorage, config: AppConfig, electrum: BlockchainService, rates: RatesService, settings: SettingsService, ) { super(); this.engine = engine; this.storage = storage; this.config = config; this.electrum = electrum; this.rates = rates; this.settings = settings; this.history = new HistoryService(engine, this.invitations); } 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, ); const deps = { engine: this.engine, syncServer: invitationSyncServer, storage: invitationStorage, electrum: this.electrum, }; // Create the invitation const invitationInstance = await Invitation.create(invitation, deps); // Add the invitation to the invitations array await this.addInvitation(invitationInstance); return invitationInstance; } async addInvitation(invitation: Invitation): Promise { this.attachInvitationListeners(invitation); // Add the invitation to the invitations array this.invitations.push(invitation); // 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); } // 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.emit("wallet-state-changed", { reason: "invitation-updated", invitationIdentifier, }); }; const onStatusChanged = () => { this.emit("wallet-state-changed", { reason: "invitation-status-changed", invitationIdentifier, }); }; invitation.on("invitation-updated", onUpdated); invitation.on("invitation-status-changed", onStatusChanged); this.invitationEventCleanup.set(invitationIdentifier, { onUpdated, onStatusChanged, }); } 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); } for (const [invitationIdentifier, outputs] of byInvitation) { 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}`), ); }), ); } }