658 lines
20 KiB
TypeScript
658 lines
20 KiB
TypeScript
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 { 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;
|
|
"error": Error;
|
|
};
|
|
|
|
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> {
|
|
// 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") {
|
|
// 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
|
|
) {
|
|
return this.create(invitationFromSyncServer, dependencies);
|
|
}
|
|
|
|
// We cant find it. Throw an error.
|
|
throw new Error(
|
|
`Invitation not found in local or remote storage: ${invitation}`,
|
|
);
|
|
}
|
|
|
|
const template = await dependencies.engine.getTemplate(
|
|
invitation.templateIdentifier,
|
|
);
|
|
|
|
if (!template) {
|
|
throw new Error(`Template not found: ${invitation.templateIdentifier}`);
|
|
}
|
|
|
|
// Create the invitation
|
|
const invitationInstance = new Invitation(invitation, dependencies);
|
|
|
|
// Start the invitation and its tracking
|
|
await invitationInstance.start();
|
|
|
|
return invitationInstance;
|
|
}
|
|
|
|
/**
|
|
* The invitation data.
|
|
*/
|
|
public data: XOInvitation = {
|
|
invitationIdentifier: "",
|
|
commits: [],
|
|
createdAtTimestamp: 0,
|
|
templateIdentifier: "",
|
|
actionIdentifier: "",
|
|
};
|
|
|
|
/**
|
|
* The sync server instance.
|
|
*/
|
|
private syncServer: SyncServer;
|
|
|
|
/**
|
|
* The engine instance.
|
|
*/
|
|
private engine: Engine;
|
|
|
|
/**
|
|
* The storage instance.
|
|
* TODO: This should be a composite with the sync server (probably. We currently double handle this work, which is stupid)
|
|
*/
|
|
private storage: Storage;
|
|
private electrum: ElectrumService;
|
|
|
|
/**
|
|
* The status of the invitation (last emitted word: pending, actionable, signed, ready, complete, expired, unknown).
|
|
*/
|
|
public status: string = "unknown";
|
|
|
|
/**
|
|
* Create an invitation and start the SSE Session required for it.
|
|
*/
|
|
constructor(invitation: XOInvitation, dependencies: InvitationDependencies) {
|
|
super();
|
|
|
|
this.data = invitation;
|
|
this.engine = dependencies.engine;
|
|
this.syncServer = dependencies.syncServer;
|
|
this.storage = dependencies.storage;
|
|
this.electrum = dependencies.electrum;
|
|
|
|
// 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> {
|
|
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)));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
private handleSSEMessage(event: SSEvent): void {
|
|
const data = JSON.parse(event.data) as { topic?: string; data?: unknown };
|
|
if (data.topic === "invitation-updated") {
|
|
const invitation = decodeExtendedJsonObject(data.data) as XOInvitation;
|
|
|
|
if (invitation.invitationIdentifier !== this.data.invitationIdentifier) {
|
|
return;
|
|
}
|
|
|
|
// 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,
|
|
);
|
|
|
|
// Set the new commits
|
|
this.data = { ...this.data, commits: newCommits };
|
|
|
|
// Calculate the new status of the invitation (fire-and-forget; handler is sync)
|
|
this.updateStatus().catch(() => {});
|
|
|
|
// Emit the updated event
|
|
this.emit("invitation-updated", this.data);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* @param additional - The additional commits
|
|
* @returns The merged commits
|
|
*/
|
|
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) {
|
|
initialMap.set(commit.commitIdentifier, commit);
|
|
}
|
|
|
|
// 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) {
|
|
initialMap.set(commit.commitIdentifier, commit);
|
|
}
|
|
|
|
// Return the merged commits
|
|
return Array.from(initialMap.values());
|
|
}
|
|
|
|
/**
|
|
* Compute the invitation status as a single word: expired | complete | ready | signed | actionable | unknown.
|
|
*/
|
|
private async computeStatus(): Promise<string> {
|
|
try {
|
|
return await this.computeStatusInternal();
|
|
} catch (err) {
|
|
return `error (${err instanceof Error ? err.message : String(err)})`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Internal status computation: returns a single word.
|
|
* NOTE: This could be a Enum-like object as well. May be a nice improvement. - DO NOT USE TS ENUM, THEY ARENT NATIVELY SUPPORTED IN NODE.JS
|
|
* - complete: we have broadcast this invitation
|
|
* - expired: any commit has expired
|
|
* - ready: no missing requirements and we have signed (ready to broadcast)
|
|
* - signed: we have signed but there are still missing parts (waiting for others)
|
|
* - actionable: you can provide data (missing requirements and/or you can sign)
|
|
* - unknown: template/action not found or error
|
|
*/
|
|
private async computeStatusInternal(): Promise<string> {
|
|
let missingReqs;
|
|
try {
|
|
missingReqs = await this.engine.listMissingRequirements(this.data);
|
|
} catch {
|
|
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);
|
|
|
|
const hasSignedCommit = this.hasSignedCommitInInvitation();
|
|
|
|
if (!hasMissing) {
|
|
const transactionHash = await this.deriveTransactionHash();
|
|
if (
|
|
transactionHash &&
|
|
(await this.electrum.hasSeenTransaction(transactionHash))
|
|
) {
|
|
return "complete";
|
|
}
|
|
}
|
|
|
|
if (hasInvitationExpired(this.data)) {
|
|
return "expired";
|
|
}
|
|
|
|
if (!hasMissing && hasSignedCommit) {
|
|
return "ready";
|
|
}
|
|
if (hasMissing && hasSignedCommit) {
|
|
return "signed";
|
|
}
|
|
return "actionable";
|
|
}
|
|
|
|
private hasSignedCommitInInvitation(): boolean {
|
|
for (const commit of this.data.commits) {
|
|
for (const input of commit.data.inputs ?? []) {
|
|
if (!input.mergesWith) continue;
|
|
if (input.unlockingBytecode === undefined) continue;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Build the transaction to get the TX hash, this is so we can check its status on the blockchain.
|
|
* TODO: Remove this. This should be part of the engine. The code is virtually identical to `executeAction` except it doesnt throw if the invitation is expired
|
|
* @returns txHash or undefined if the transaction could not be built
|
|
*/
|
|
private async deriveTransactionHash(): Promise<string | undefined> {
|
|
try {
|
|
const template = await this.engine.getTemplate(
|
|
this.data.templateIdentifier,
|
|
);
|
|
if (!template) return undefined;
|
|
|
|
const mergedCommit = mergeInvitationCommits(this.data, template);
|
|
if (!mergedCommit) return undefined;
|
|
|
|
const transactionResult = generateTransaction({
|
|
version: mergedCommit.transactionVersion,
|
|
locktime: mergedCommit.transactionLocktime,
|
|
// @ts-expect-error merged inputs include additional invitation metadata.
|
|
inputs: mergedCommit.inputs,
|
|
// @ts-expect-error merged outputs include additional invitation metadata.
|
|
outputs: mergedCommit.outputs,
|
|
});
|
|
|
|
if (!transactionResult.success) return undefined;
|
|
|
|
const transactionHex = binToHex(
|
|
encodeTransaction(transactionResult.transaction),
|
|
);
|
|
const rawHash: unknown = hashTransaction(hexToBin(transactionHex));
|
|
if (typeof rawHash === "string") return rawHash;
|
|
if (rawHash instanceof Uint8Array) return binToHex(rawHash);
|
|
return undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update the status of the invitation and emit the new single-word status.
|
|
*/
|
|
private async updateStatus(): Promise<void> {
|
|
const status = await this.computeStatus();
|
|
this.status = status;
|
|
this.emit("invitation-status-changed", status);
|
|
}
|
|
|
|
/**
|
|
* Accept the invitation
|
|
*/
|
|
async accept(acceptParams?: AcceptInvitationParameters): Promise<void> {
|
|
// Accept the invitation
|
|
this.data = await this.engine.acceptInvitation(this.data, acceptParams);
|
|
|
|
// Sync the invitation to the sync server
|
|
this.publishInvitation(this.data);
|
|
|
|
// Update the status of the invitation
|
|
await this.updateStatus();
|
|
}
|
|
|
|
/**
|
|
* Sign the invitation
|
|
*/
|
|
async sign(): Promise<void> {
|
|
// Sign the invitation
|
|
const signedInvitation = await this.engine.signInvitation(this.data);
|
|
|
|
// Publish the signed invitation to the sync server
|
|
this.publishInvitation(signedInvitation);
|
|
|
|
// Store the signed invitation in the storage
|
|
await this.storage.set(this.data.invitationIdentifier, signedInvitation);
|
|
|
|
this.data = signedInvitation;
|
|
|
|
// Update the status of the invitation
|
|
await this.updateStatus();
|
|
}
|
|
|
|
/**
|
|
* Broadcast the invitation.
|
|
* @returns The transaction hash returned by the network after broadcast.
|
|
*/
|
|
async broadcast(): Promise<string> {
|
|
const txHash = await this.engine.executeAction(this.data, {
|
|
broadcastTransaction: true,
|
|
});
|
|
|
|
await this.updateStatus();
|
|
|
|
return String(txHash);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Append Operations
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Append a commit to the invitation
|
|
*/
|
|
async append(data: AppendInvitationParameters): Promise<void> {
|
|
// Append the commit to the invitation
|
|
this.data = await this.engine.appendInvitation(this.data, data);
|
|
|
|
// Sync the invitation to the sync server
|
|
await this.publishInvitation(this.data);
|
|
|
|
// Store the invitation in the storage
|
|
await this.storage.set(this.data.invitationIdentifier, this.data);
|
|
|
|
// Update the status of the invitation
|
|
await this.updateStatus();
|
|
}
|
|
|
|
/**
|
|
* Add inputs to the invitation
|
|
*/
|
|
async addInputs(inputs: XOInvitationInput[]): Promise<void> {
|
|
// Append the inputs to the invitation
|
|
await this.append({ inputs });
|
|
|
|
// Sync the invitation to the sync server
|
|
await this.publishInvitation(this.data);
|
|
}
|
|
|
|
/**
|
|
* 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 addOutputs(outputs: XOInvitationOutput[]): Promise<void> {
|
|
// Add the outputs to the invitation
|
|
await this.append({ outputs });
|
|
|
|
// Sync the invitation to the sync server
|
|
await this.publishInvitation(this.data);
|
|
}
|
|
|
|
async addVariables(variables: XOInvitationVariable[]): Promise<void> {
|
|
// Add the variables to the invitation
|
|
await this.append({ variables });
|
|
|
|
// Sync the invitation to the sync server
|
|
await this.publishInvitation(this.data);
|
|
}
|
|
|
|
async findSuitableResources(
|
|
options: Partial<FindSuitableResourcesParameters> = {},
|
|
): Promise<UnspentOutputData[]> {
|
|
// Find the suitable resources
|
|
const { unspentOutputs } = await this.engine.findSuitableResources(
|
|
this.data,
|
|
options,
|
|
);
|
|
|
|
// Update the status of the invitation
|
|
await this.updateStatus();
|
|
|
|
// Return the suitable resources
|
|
return unspentOutputs;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Getters and Queries
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Get the missing requirements for the invitation
|
|
*/
|
|
async getMissingRequirements() {
|
|
return this.engine.listMissingRequirements(this.data);
|
|
}
|
|
|
|
/**
|
|
* Get the requirements for the invitation
|
|
*/
|
|
async getRequirements() {
|
|
return this.engine.listRequirements(this.data);
|
|
}
|
|
|
|
/**
|
|
* Get the available roles for the invitation
|
|
*/
|
|
async getAvailableRoles() {
|
|
return this.engine.listAvailableRoles(this.data);
|
|
}
|
|
|
|
/**
|
|
* Get the starting actions for the invitation
|
|
*/
|
|
async getStartingActions() {
|
|
return this.engine.listStartingActions(this.data.templateIdentifier);
|
|
}
|
|
|
|
/**
|
|
* Get the locking bytecode for the invitation
|
|
*/
|
|
async getLockingBytecode(
|
|
outputIdentifier: string,
|
|
roleIdentifier?: string,
|
|
): Promise<string> {
|
|
return this.engine.generateLockingBytecode(
|
|
this.data.templateIdentifier,
|
|
outputIdentifier,
|
|
roleIdentifier,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get the sats out for the invitation
|
|
* TODO: Clean up this function. Why is it so big? Can obviously make it 2 functions instead of recursive, but still...
|
|
*/
|
|
async getSatsOut(outputIdentifier?: string): Promise<bigint> {
|
|
// 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,
|
|
);
|
|
if (!template) {
|
|
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}`,
|
|
);
|
|
}
|
|
|
|
const valueSatoshisIdentifier = output.valueSatoshis;
|
|
if (!valueSatoshisIdentifier) {
|
|
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 ?? [],
|
|
);
|
|
|
|
// Create a dictionary of the variables
|
|
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,
|
|
);
|
|
|
|
// 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);
|
|
}
|
|
|
|
// 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}`,
|
|
);
|
|
}
|
|
|
|
// Get the template
|
|
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}`,
|
|
);
|
|
}
|
|
|
|
// Get the transaction ID from the action
|
|
const transactionID = template.actions[action]?.transaction;
|
|
if (!transactionID) {
|
|
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}`,
|
|
);
|
|
}
|
|
|
|
// Get the outputs from the transaction
|
|
const outputs = transaction.outputs;
|
|
if (!outputs) {
|
|
throw new Error(
|
|
`Outputs not found: ${transactionID} in template: ${this.data.templateIdentifier}`,
|
|
);
|
|
}
|
|
|
|
// Create a value to store the cummulative total of the outputs
|
|
let totalSats = 0n;
|
|
|
|
// Iterate through the outputs and sum the valueSatoshis
|
|
for (const output of outputs) {
|
|
if (typeof output === "string") {
|
|
totalSats += await this.getSatsOut(output);
|
|
} else {
|
|
totalSats += await this.getSatsOut(output.output);
|
|
}
|
|
}
|
|
|
|
return totalSats;
|
|
}
|
|
}
|