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 { 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"; 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 invitations: Invitation[] = []; private invitationEventCleanup = new Map< string, { onUpdated: (invitation: XOInvitation) => void; onStatusChanged: (status: string) => void; } >(); static async create(seed: string, config: AppConfig): 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}`)); // 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(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, }); return new AppService(engine, walletStorage, config, electrum); } constructor( engine: Engine, storage: BaseStorage, config: AppConfig, electrum: BlockchainService, ) { super(); this.engine = engine; this.storage = storage; this.config = config; this.electrum = electrum; 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 { // 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}`)); }), ); } }