Mid-rewrite

This commit is contained in:
2026-02-06 13:14:24 +00:00
parent 601c3db9d0
commit eb1bf9020e
12 changed files with 1300 additions and 103 deletions

305
src/services/invitation.ts Normal file
View File

@@ -0,0 +1,305 @@
import type { AppendInvitationParameters, Engine, FindSuitableResourcesParameters } from '@xo-cash/engine';
import type { XOInvitation, XOInvitationInput, XOInvitationOutput, XOInvitationVariable } from '@xo-cash/types';
import type { UnspentOutputData } from '@xo-cash/state';
import type { SSEvent } from '../utils/sse-client.js';
import type { SyncServer } from '../utils/sync-server.js';
import type { Storage } from './storage.js';
import { EventEmitter } from '../utils/event-emitter.js'
import { decodeExtendedJsonObject } from '../utils/ext-json.js';
export type InvitationEventMap = {
'invitation-updated': XOInvitation;
'invitation-status-changed': string;
}
export type InvitationDependencies = {
syncServer: SyncServer;
storage: Storage;
engine: Engine;
}
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) {
return this.create(invitationFromSyncServer, dependencies);
}
// We cant find it. Throw an error.
throw new Error(`Invitation not found in local or remote storage: ${invitation}`);
}
// Make sure the engine has the template imported
await dependencies.engine.importTemplate(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;
/**
* 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;
// 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));
}
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;
// Set the invitation data with the combined commits
this.data = { ...invitation, commits: [...sseCommits, ...invitation.commits] };
// Store the invitation in the storage
await this.storage.set(this.data.invitationIdentifier, this.data);
}
/**
* 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') {
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 = invitation.commits.filter(commit => !this.data.commits.some(c => c.commitIdentifier === commit.commitIdentifier));
this.data.commits.push(...newCommits);
// Calculate the new status of the invitation
this.updateStatus();
// Emit the updated event
this.emit('invitation-updated', this.data);
}
}
/**
* Update the status of the invitation based on the filled in information
*/
private updateStatus(): void {
// Calculate the status of the invitation
this.emit('invitation-status-changed', 'pending - this is temporary');
}
/**
* Accept the invitation
*/
async accept(): Promise<void> {
// Accept the invitation
this.data = await this.engine.acceptInvitation(this.data);
// Sync the invitation to the sync server
this.syncServer.publishInvitation(this.data);
// Update the status of the invitation
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.syncServer.publishInvitation(signedInvitation);
// Store the signed invitation in the storage
await this.storage.set(this.data.invitationIdentifier, signedInvitation);
// Update the status of the invitation
this.updateStatus();
}
/**
* Broadcast the invitation
*/
async broadcast(): Promise<void> {
// Broadcast the invitation
const broadcastedInvitation = await this.engine.executeAction(this.data, {
broadcastTransaction: true,
});
// Store the broadcasted invitation in the storage
await this.storage.set(this.data.invitationIdentifier, broadcastedInvitation);
// TODO: Am I supposed to do something here?
// Update the status of the invitation
this.updateStatus();
}
// ============================================================================
// 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.syncServer.publishInvitation(this.data);
// Store the invitation in the storage
await this.storage.set(this.data.invitationIdentifier, this.data);
// Update the status of the invitation
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.syncServer.publishInvitation(this.data);
}
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.syncServer.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.syncServer.publishInvitation(this.data);
}
async findSuitableResources(options: FindSuitableResourcesParameters): Promise<UnspentOutputData[]> {
// Find the suitable resources
const { unspentOutputs } = await this.engine.findSuitableResources(this.data, options);
// Update the status of the invitation
this.updateStatus();
// Return the suitable resources
return unspentOutputs;
}
// ============================================================================
// Getters and Queries
// ============================================================================
/**
* Get the missing requirements for the invitation
*/
get missingRequirements() {
return this.engine.listMissingRequirements(this.data);
}
/**
* Get the requirements for the invitation
*/
get requirements() {
return this.engine.listRequirements(this.data);
}
/**
* Get the available roles for the invitation
*/
get availableRoles() {
return this.engine.listAvailableRoles(this.data);
}
/**
* Get the starting actions for the invitation
*/
get startingActions() {
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);
}
}