267 lines
8.8 KiB
TypeScript
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}`));
|
|
}),
|
|
);
|
|
}
|
|
}
|