Files
xo-cli/src/services/app.ts

267 lines
8.8 KiB
TypeScript

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<AppEventMap> {
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<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");
// 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<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,
);
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<void> {
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<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);
}
// 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<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);
}
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<void> {
// 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}`));
}),
);
}
}