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:
@@ -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}`));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user