Huge commit. Multiple fixes. Refactored commands. Invitations, resources, template inspection, mnemonic stuff, cli utils, pretty printing, remove unreserve on start, fix connectino requirement for invitations, format cashAddress to lockingBytecode on send, lots and lots of other stuff.

This commit is contained in:
2026-04-06 11:56:09 +00:00
parent b475b23beb
commit 55c75501d5
24 changed files with 3284 additions and 77 deletions

View File

@@ -22,6 +22,14 @@ 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 {
@@ -40,6 +48,13 @@ export class AppService extends EventEmitter<AppEventMap> {
public electrum: ElectrumService;
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.
@@ -57,9 +72,10 @@ export class AppService extends EventEmitter<AppEventMap> {
// 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);
const { templateIdentifier } = await engine.importTemplate(p2pkhTemplate);
// console.log('p2pkhTemplate', JSON.stringify(p2pkhTemplate.transactions, null, 2));
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.
@@ -80,32 +96,6 @@ export class AppService extends EventEmitter<AppEventMap> {
applicationIdentifier: config.electrumApplicationIdentifier,
});
// TEMP because testing is painful
// Remove all reserved UTXOs on startup
// First, get every unspent output
const allUnspentOutputs = await engine.listUnspentOutputsData();
// Get a set of all the invitation identifiers
const allInvitationIdentifiers = new Set(
allUnspentOutputs.map((output) => output.invitationIdentifier),
);
// Iterate over the invitation identifiers and unreserve the outputs
for (const invitationIdentifier of allInvitationIdentifiers) {
// Get the outputs for the invitation
const outputs = allUnspentOutputs.filter(
(output) => output.invitationIdentifier === invitationIdentifier,
);
// Unreserve the outputs
await engine.unreserveResources(
outputs.map((output) => ({
outpointTransactionHash: hexToBin(output.outpointTransactionHash),
outpointIndex: output.outpointIndex,
})),
invitationIdentifier,
);
}
return new AppService(engine, walletStorage, config, electrum);
}
@@ -145,27 +135,116 @@ export class AppService extends EventEmitter<AppEventMap> {
// 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> {
// Remove the invitation from the invitations array
this.invitations = this.invitations.filter((i) => i !== invitation);
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.reserved);
// 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.invitationIdentifier) ?? [];
existing.push(output);
byInvitation.set(output.invitationIdentifier, 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> {
@@ -180,7 +259,7 @@ export class AppService extends EventEmitter<AppEventMap> {
await Promise.all(
invitations.map(async ({ key }) => {
await this.createInvitation(key);
await this.createInvitation(key).catch(err => console.error(`Error creating invitation ${key}: ${err}`));
}),
);
}

View File

@@ -34,6 +34,7 @@ import { compileCashAssemblyString } from "@xo-cash/engine";
export type InvitationEventMap = {
"invitation-updated": XOInvitation;
"invitation-status-changed": string;
"error": Error;
};
export type InvitationDependencies = {
@@ -146,32 +147,38 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
* Start the invitation - Connect sync server and download latest invitation data.
*/
async start(): Promise<void> {
// Connect to the sync server and get the invitation (in parallel)
const [_, invitation] = await Promise.all([
this.syncServer.connect(),
this.syncServer.getInvitation(this.data.invitationIdentifier),
]);
// There is a chance we get SSE messages before the invitation is returned, so we want to combine any commits
const sseCommits = this.data.commits;
// Merge the commits
const combinedCommits = this.mergeCommits(
sseCommits,
invitation?.commits ?? [],
);
// Set the invitation data with the combined commits
this.data = { ...this.data, ...invitation, commits: combinedCommits };
// Store the invitation in the storage
await this.storage.set(this.data.invitationIdentifier, this.data);
// Publish the invitation to the sync server
this.syncServer.publishInvitation(this.data);
// Compute and emit initial status
await this.updateStatus();
try {
// Connect to the sync server and get the invitation (in parallel)
const [_, invitation] = await Promise.all([
this.syncServer.connect(),
this.syncServer.getInvitation(this.data.invitationIdentifier),
]);
// There is a chance we get SSE messages before the invitation is returned, so we want to combine any commits
const sseCommits = this.data.commits;
// Merge the commits
const combinedCommits = this.mergeCommits(
sseCommits,
invitation?.commits ?? [],
);
// Set the invitation data with the combined commits
this.data = { ...this.data, ...invitation, commits: combinedCommits };
// Store the invitation in the storage
await this.storage.set(this.data.invitationIdentifier, this.data);
// Publish the invitation to the sync server
this.publishInvitation(this.data);
// Compute and emit initial status
await this.updateStatus();
} catch (err) {
// console.error(`Error starting invitation, could not connect to sync server or get invitation`, err);
// Emit the error event. We might want to throw? but we need a better way of handling errors in the invitation system because we need the invitation to successfully initialize.
this.emit("error", err instanceof Error ? err : new Error(String(err)));
}
}
/**
@@ -205,6 +212,18 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
}
}
/**
* Publish the invitation to the sync server
*/
private async publishInvitation(invitation: XOInvitation = this.data): Promise<void> {
try {
await this.syncServer.publishInvitation(invitation);
} catch (err) {
// Emit the error event. We might want to throw? but we need a better way of handling errors in the invitation system because we need the invitation to successfully initialize.
this.emit("error", err instanceof Error ? err : new Error(String(err)));
}
}
/**
* Merge the commits
* @param initial - The initial commits
@@ -359,7 +378,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
this.data = await this.engine.acceptInvitation(this.data, acceptParams);
// Sync the invitation to the sync server
this.syncServer.publishInvitation(this.data);
this.publishInvitation(this.data);
// Update the status of the invitation
await this.updateStatus();
@@ -373,7 +392,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
const signedInvitation = await this.engine.signInvitation(this.data);
// Publish the signed invitation to the sync server
this.syncServer.publishInvitation(signedInvitation);
this.publishInvitation(signedInvitation);
// Store the signed invitation in the storage
await this.storage.set(this.data.invitationIdentifier, signedInvitation);
@@ -385,16 +404,17 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
}
/**
* Broadcast the invitation
* Broadcast the invitation.
* @returns The transaction hash returned by the network after broadcast.
*/
async broadcast(): Promise<void> {
// Broadcast the transaction (executeAction returns transaction hash when broadcastTransaction: true)
await this.engine.executeAction(this.data, {
async broadcast(): Promise<string> {
const txHash = await this.engine.executeAction(this.data, {
broadcastTransaction: true,
});
// Update the status of the invitation
await this.updateStatus();
return String(txHash);
}
// ============================================================================
@@ -409,7 +429,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
this.data = await this.engine.appendInvitation(this.data, data);
// Sync the invitation to the sync server
await this.syncServer.publishInvitation(this.data);
await this.publishInvitation(this.data);
// Store the invitation in the storage
await this.storage.set(this.data.invitationIdentifier, this.data);
@@ -426,7 +446,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
await this.append({ inputs });
// Sync the invitation to the sync server
await this.syncServer.publishInvitation(this.data);
await this.publishInvitation(this.data);
}
/**
@@ -449,7 +469,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
await this.append({ outputs });
// Sync the invitation to the sync server
await this.syncServer.publishInvitation(this.data);
await this.publishInvitation(this.data);
}
async addVariables(variables: XOInvitationVariable[]): Promise<void> {
@@ -457,7 +477,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
await this.append({ variables });
// Sync the invitation to the sync server
await this.syncServer.publishInvitation(this.data);
await this.publishInvitation(this.data);
}
async findSuitableResources(