Format with prettier. Use screen mode for invitation import - dialog mode is broken.

This commit is contained in:
2026-03-23 10:15:48 +00:00
parent 7fd89c5663
commit b475b23beb
47 changed files with 1718 additions and 1098 deletions

View File

@@ -1,55 +1,84 @@
import type { AcceptInvitationParameters, AppendInvitationParameters, Engine, FindSuitableResourcesParameters } from '@xo-cash/engine';
import { hasInvitationExpired, mergeInvitationCommits } from '@xo-cash/engine';
import type { XOInvitation, XOInvitationCommit, XOInvitationInput, XOInvitationOutput, XOInvitationVariable, XOInvitationVariableValue } from '@xo-cash/types';
import type { UnspentOutputData } from '@xo-cash/state';
import { binToHex, encodeTransaction, generateTransaction, hashTransaction, hexToBin } from '@bitauth/libauth';
import type {
AcceptInvitationParameters,
AppendInvitationParameters,
Engine,
FindSuitableResourcesParameters,
} from "@xo-cash/engine";
import { hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine";
import type {
XOInvitation,
XOInvitationCommit,
XOInvitationInput,
XOInvitationOutput,
XOInvitationVariable,
XOInvitationVariableValue,
} from "@xo-cash/types";
import type { UnspentOutputData } from "@xo-cash/state";
import {
binToHex,
encodeTransaction,
generateTransaction,
hashTransaction,
hexToBin,
} from "@bitauth/libauth";
import type { SSEvent } from '../utils/sse-client.js';
import type { SyncServer } from '../utils/sync-server.js';
import type { Storage } from './storage.js';
import type { ElectrumService } from './electrum.js';
import type { SSEvent } from "../utils/sse-client.js";
import type { SyncServer } from "../utils/sync-server.js";
import type { Storage } from "./storage.js";
import type { ElectrumService } from "./electrum.js";
import { EventEmitter } from '../utils/event-emitter.js'
import { decodeExtendedJsonObject } from '../utils/ext-json.js';
import { compileCashAssemblyString } from '@xo-cash/engine';
import { EventEmitter } from "../utils/event-emitter.js";
import { decodeExtendedJsonObject } from "../utils/ext-json.js";
import { compileCashAssemblyString } from "@xo-cash/engine";
export type InvitationEventMap = {
'invitation-updated': XOInvitation;
'invitation-status-changed': string;
}
"invitation-updated": XOInvitation;
"invitation-status-changed": string;
};
export type InvitationDependencies = {
syncServer: SyncServer;
storage: Storage;
engine: Engine;
electrum: ElectrumService;
}
};
export class Invitation extends EventEmitter<InvitationEventMap> {
/**
* Create an invitation and start the SSE Session required for it.
*/
static async create(invitation: XOInvitation | string, dependencies: InvitationDependencies): Promise<Invitation> {
static async create(
invitation: XOInvitation | string,
dependencies: InvitationDependencies,
): Promise<Invitation> {
// If the invitation is a string, its probably an invitation identifier.
// We will try to find the data then just call the create method again, but this time with the data.
if(typeof invitation === 'string') {
if (typeof invitation === "string") {
// Try to get the invitation from the storage
const invitationFromStorage = await dependencies.storage.get(invitation);
if (invitationFromStorage) {
return this.create(invitationFromStorage, dependencies);
}
// Try to get the invitation from the sync server
const invitationFromSyncServer = await dependencies.syncServer.getInvitation(invitation);
if (invitationFromSyncServer && invitationFromSyncServer.invitationIdentifier === invitation) {
const invitationFromSyncServer =
await dependencies.syncServer.getInvitation(invitation);
if (
invitationFromSyncServer &&
invitationFromSyncServer.invitationIdentifier === invitation
) {
return this.create(invitationFromSyncServer, dependencies);
}
// We cant find it. Throw an error.
throw new Error(`Invitation not found in local or remote storage: ${invitation}`);
throw new Error(
`Invitation not found in local or remote storage: ${invitation}`,
);
}
const template = await dependencies.engine.getTemplate(invitation.templateIdentifier);
const template = await dependencies.engine.getTemplate(
invitation.templateIdentifier,
);
if (!template) {
throw new Error(`Template not found: ${invitation.templateIdentifier}`);
@@ -68,11 +97,11 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
* The invitation data.
*/
public data: XOInvitation = {
invitationIdentifier: '',
invitationIdentifier: "",
commits: [],
createdAtTimestamp: 0,
templateIdentifier: '',
actionIdentifier: '',
templateIdentifier: "",
actionIdentifier: "",
};
/**
@@ -95,15 +124,12 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
/**
* The status of the invitation (last emitted word: pending, actionable, signed, ready, complete, expired, unknown).
*/
public status: string = 'unknown';
public status: string = "unknown";
/**
* Create an invitation and start the SSE Session required for it.
*/
constructor(
invitation: XOInvitation,
dependencies: InvitationDependencies
) {
constructor(invitation: XOInvitation, dependencies: InvitationDependencies) {
super();
this.data = invitation;
@@ -112,12 +138,13 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
this.storage = dependencies.storage;
this.electrum = dependencies.electrum;
// I cannot express this enough, but the event handler does not need a clean up.
// There is this beautiful thing called a "garbage collector". Once this class is removed from scope (removed from the invitations array) all the references
// will be removed, including the SSE Session (and therefore this handler).
this.syncServer.on('message', this.handleSSEMessage.bind(this));
// Create a listerner for the messages from the SSE Session (sync server)
this.syncServer.on("message", this.handleSSEMessage.bind(this));
}
/**
* 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([
@@ -129,7 +156,10 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
const sseCommits = this.data.commits;
// Merge the commits
const combinedCommits = this.mergeCommits(sseCommits, invitation?.commits ?? []);
const combinedCommits = this.mergeCommits(
sseCommits,
invitation?.commits ?? [],
);
// Set the invitation data with the combined commits
this.data = { ...this.data, ...invitation, commits: combinedCommits };
@@ -146,15 +176,12 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
/**
* Handle an SSE message.
*
*
* TODO: Invitation should sync up the initial data (top level) then everything after that should be the commits. This makes it easier to merge as we go instead of just having to overwrite the entire invitation.
* Why this level of thought is required is beyond me. We should be given a `mergeCommits` method or "something" that lets us take whole invitation and merge commits into it.
* NOTE: signInvitation does merge the commits... But we want to be able to add commits in *before* signing the invitation. So, we are just going to receive a single commit at a time, then just invitation.commits.push(commit); to get around this.
* I hope we dont end up with duplicate commits :/... We also dont have a way to list invitiations, which is an... interesting choice.
*/
private handleSSEMessage(event: SSEvent): void {
const data = JSON.parse(event.data) as { topic?: string; data?: unknown };
if (data.topic === 'invitation-updated') {
if (data.topic === "invitation-updated") {
const invitation = decodeExtendedJsonObject(data.data) as XOInvitation;
if (invitation.invitationIdentifier !== this.data.invitationIdentifier) {
@@ -162,7 +189,10 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
}
// Filter out commits that already exist (probably a faster way to do this. This is n^2)
const newCommits = this.mergeCommits(this.data.commits, invitation.commits);
const newCommits = this.mergeCommits(
this.data.commits,
invitation.commits,
);
// Set the new commits
this.data = { ...this.data, commits: newCommits };
@@ -171,7 +201,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
this.updateStatus().catch(() => {});
// Emit the updated event
this.emit('invitation-updated', this.data);
this.emit("invitation-updated", this.data);
}
}
@@ -181,16 +211,19 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
* @param additional - The additional commits
* @returns The merged commits
*/
private mergeCommits(initial: XOInvitationCommit[], additional: XOInvitationCommit[]): XOInvitationCommit[] {
private mergeCommits(
initial: XOInvitationCommit[],
additional: XOInvitationCommit[],
): XOInvitationCommit[] {
// Create a map of the initial commits
const initialMap = new Map<string, XOInvitationCommit>();
for(const commit of initial) {
for (const commit of initial) {
initialMap.set(commit.commitIdentifier, commit);
}
// Merge the additional commits
// Merge the additional commits
// TODO: They are immutable? So, it should be fine to "ovewrite" existing commits as it should be the same data, right?
for(const commit of additional) {
for (const commit of additional) {
initialMap.set(commit.commitIdentifier, commit);
}
@@ -224,35 +257,39 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
try {
missingReqs = await this.engine.listMissingRequirements(this.data);
} catch {
return 'unknown';
return "unknown";
}
const hasMissing =
(missingReqs.variables?.length ?? 0) > 0 ||
(missingReqs.inputs?.length ?? 0) > 0 ||
(missingReqs.outputs?.length ?? 0) > 0 ||
(missingReqs.roles !== undefined && Object.keys(missingReqs.roles).length > 0);
(missingReqs.roles !== undefined &&
Object.keys(missingReqs.roles).length > 0);
const hasSignedCommit = this.hasSignedCommitInInvitation();
if (!hasMissing) {
const transactionHash = await this.deriveTransactionHash();
if (transactionHash && await this.electrum.hasSeenTransaction(transactionHash)) {
return 'complete';
if (
transactionHash &&
(await this.electrum.hasSeenTransaction(transactionHash))
) {
return "complete";
}
}
if (hasInvitationExpired(this.data)) {
return 'expired';
return "expired";
}
if (!hasMissing && hasSignedCommit) {
return 'ready';
return "ready";
}
if (hasMissing && hasSignedCommit) {
return 'signed';
return "signed";
}
return 'actionable';
return "actionable";
}
private hasSignedCommitInInvitation(): boolean {
@@ -274,7 +311,9 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
*/
private async deriveTransactionHash(): Promise<string | undefined> {
try {
const template = await this.engine.getTemplate(this.data.templateIdentifier);
const template = await this.engine.getTemplate(
this.data.templateIdentifier,
);
if (!template) return undefined;
const mergedCommit = mergeInvitationCommits(this.data, template);
@@ -291,9 +330,11 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
if (!transactionResult.success) return undefined;
const transactionHex = binToHex(encodeTransaction(transactionResult.transaction));
const transactionHex = binToHex(
encodeTransaction(transactionResult.transaction),
);
const rawHash: unknown = hashTransaction(hexToBin(transactionHex));
if (typeof rawHash === 'string') return rawHash;
if (typeof rawHash === "string") return rawHash;
if (rawHash instanceof Uint8Array) return binToHex(rawHash);
return undefined;
} catch {
@@ -307,7 +348,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
private async updateStatus(): Promise<void> {
const status = await this.computeStatus();
this.status = status;
this.emit('invitation-status-changed', status);
this.emit("invitation-status-changed", status);
}
/**
@@ -392,8 +433,15 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
* Generate the locking bytecode for the invitation
* TODO: Find out if this has side-effects or needs special handling
*/
async generateLockingBytecode(outputIdentifier: string, roleIdentifier?: string): Promise<string> {
return this.engine.generateLockingBytecode(this.data.templateIdentifier, outputIdentifier, roleIdentifier);
async generateLockingBytecode(
outputIdentifier: string,
roleIdentifier?: string,
): Promise<string> {
return this.engine.generateLockingBytecode(
this.data.templateIdentifier,
outputIdentifier,
roleIdentifier,
);
}
async addOutputs(outputs: XOInvitationOutput[]): Promise<void> {
@@ -412,9 +460,14 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
await this.syncServer.publishInvitation(this.data);
}
async findSuitableResources(options: Partial<FindSuitableResourcesParameters> = {}): Promise<UnspentOutputData[]> {
async findSuitableResources(
options: Partial<FindSuitableResourcesParameters> = {},
): Promise<UnspentOutputData[]> {
// Find the suitable resources
const { unspentOutputs } = await this.engine.findSuitableResources(this.data, options);
const { unspentOutputs } = await this.engine.findSuitableResources(
this.data,
options,
);
// Update the status of the invitation
await this.updateStatus();
@@ -458,8 +511,15 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
/**
* Get the locking bytecode for the invitation
*/
async getLockingBytecode(outputIdentifier: string, roleIdentifier?: string): Promise<string> {
return this.engine.generateLockingBytecode(this.data.templateIdentifier, outputIdentifier, roleIdentifier);
async getLockingBytecode(
outputIdentifier: string,
roleIdentifier?: string,
): Promise<string> {
return this.engine.generateLockingBytecode(
this.data.templateIdentifier,
outputIdentifier,
roleIdentifier,
);
}
/**
@@ -470,33 +530,49 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
// If an output identifier is provided, find all outputs with that identifier, and its valueSatoshis identifier back to the variables
if (outputIdentifier) {
// Get the valueSatoshis identifier from the template
const template = await this.engine.getTemplate(this.data.templateIdentifier);
const template = await this.engine.getTemplate(
this.data.templateIdentifier,
);
if (!template) {
throw new Error(`Template not found: ${this.data.templateIdentifier} when trying to get sats out for output: ${outputIdentifier}`);
throw new Error(
`Template not found: ${this.data.templateIdentifier} when trying to get sats out for output: ${outputIdentifier}`,
);
}
const output = template.outputs[outputIdentifier];
if (!output) {
throw new Error(`Output not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`);
throw new Error(
`Output not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`,
);
}
const valueSatoshisIdentifier = output.valueSatoshis;
if (!valueSatoshisIdentifier) {
throw new Error(`Value satoshis identifier not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`);
throw new Error(
`Value satoshis identifier not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`,
);
}
// Create a list of all the variables from the commits
const variables = this.data.commits.flatMap(c => c.data?.variables ?? []);
const variables = this.data.commits.flatMap(
(c) => c.data?.variables ?? [],
);
// Create a dictionary of the variables
const formattedVariables = variables.reduce((acc, v) => {
acc[v.variableIdentifier ?? ''] = v.value;
return acc;
}, {} as Record<string, XOInvitationVariableValue>);
const formattedVariables = variables.reduce(
(acc, v) => {
acc[v.variableIdentifier ?? ""] = v.value;
return acc;
},
{} as Record<string, XOInvitationVariableValue>,
);
// Compile the CashAssembly expression to get the value satoshis (It handles the variable replacement for us)
const valueSatoshis = await compileCashAssemblyString(String(valueSatoshisIdentifier), formattedVariables);
const valueSatoshis = await compileCashAssemblyString(
String(valueSatoshisIdentifier),
formattedVariables,
);
// Return the value satoshis as a bigint
// TODO: Check this of a vulnerability or crash - I assume there might be one if someone made the `valueSatoshis` a malicious expression
return BigInt(valueSatoshis);
@@ -505,31 +581,43 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
// If we didnt get an output identifier, go through the action outputs and sum the valueSatoshis
const action = this.data.actionIdentifier;
if (!action) {
throw new Error(`Action not found: ${this.data.actionIdentifier} when trying to get sats out for output: ${outputIdentifier}`);
throw new Error(
`Action not found: ${this.data.actionIdentifier} when trying to get sats out for output: ${outputIdentifier}`,
);
}
// Get the template
const template = await this.engine.getTemplate(this.data.templateIdentifier);
const template = await this.engine.getTemplate(
this.data.templateIdentifier,
);
if (!template) {
throw new Error(`Template not found: ${this.data.templateIdentifier} when trying to get sats out for action: ${action}`);
throw new Error(
`Template not found: ${this.data.templateIdentifier} when trying to get sats out for action: ${action}`,
);
}
// Get the transaction ID from the action
const transactionID = template.actions[action]?.transaction
const transactionID = template.actions[action]?.transaction;
if (!transactionID) {
throw new Error(`Transactions not found: ${action} in template: ${this.data.templateIdentifier}`);
throw new Error(
`Transactions not found: ${action} in template: ${this.data.templateIdentifier}`,
);
}
// Get the transaction from the template
const transaction = template.transactions?.[transactionID];
if (!transaction) {
throw new Error(`Transaction not found: ${transactionID} in template: ${this.data.templateIdentifier}`);
throw new Error(
`Transaction not found: ${transactionID} in template: ${this.data.templateIdentifier}`,
);
}
// Get the outputs from the transaction
const outputs = transaction.outputs;
if (!outputs) {
throw new Error(`Outputs not found: ${transactionID} in template: ${this.data.templateIdentifier}`);
throw new Error(
`Outputs not found: ${transactionID} in template: ${this.data.templateIdentifier}`,
);
}
// Create a value to store the cummulative total of the outputs
@@ -537,7 +625,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
// Iterate through the outputs and sum the valueSatoshis
for (const output of outputs) {
if (typeof output === 'string') {
if (typeof output === "string") {
totalSats += await this.getSatsOut(output);
} else {
totalSats += await this.getSatsOut(output.output);