Big changes and fixes. Uses action history. Improve role selection. Remove unused logs

This commit is contained in:
2026-02-08 15:41:14 +00:00
parent da096af0fa
commit df57f1b9ad
16 changed files with 1250 additions and 1181 deletions

View File

@@ -9,6 +9,7 @@ import type { XOInvitation } from '@xo-cash/types';
import { Invitation } from './invitation.js';
import { Storage } from './storage.js';
import { SyncServer } from '../utils/sync-server.js';
import { HistoryService } from './history.js';
import { EventEmitter } from '../utils/event-emitter.js';
@@ -31,6 +32,7 @@ export class AppService extends EventEmitter<AppEventMap> {
public engine: Engine;
public storage: Storage;
public config: AppConfig;
public history: HistoryService;
public invitations: Invitation[] = [];
@@ -42,9 +44,6 @@ export class AppService extends EventEmitter<AppEventMap> {
// We want to only prefix the file name
const prefixedStoragePath = `${seedHash.slice(0, 8)}-${config.engineConfig.databaseFilename}`;
console.log('Prefixed storage path:', prefixedStoragePath);
console.log('Engine config:', config.engineConfig);
// Create the engine
const engine = await Engine.create(seed, {
...config.engineConfig,
@@ -75,6 +74,7 @@ export class AppService extends EventEmitter<AppEventMap> {
this.engine = engine;
this.storage = storage;
this.config = config;
this.history = new HistoryService(engine, this.invitations);
}
async createInvitation(invitation: XOInvitation | string): Promise<Invitation> {
@@ -118,12 +118,16 @@ export class AppService extends EventEmitter<AppEventMap> {
const invitationsDb = this.storage.child('invitations');
// Load invitations from storage
console.time('loadInvitations');
const invitations = await invitationsDb.all() as { key: string; value: XOInvitation }[];
console.timeEnd('loadInvitations');
// Start the invitations
for (const { key } of invitations) {
// TODO: This is doing some double work of grabbing the invitation data. We can probably skip it, but who knows.
console.time('createInvitations');
await Promise.all(invitations.map(async ({ key }) => {
await this.createInvitation(key);
}
}));
console.timeEnd('createInvitations');
}
}

252
src/services/history.ts Normal file
View File

@@ -0,0 +1,252 @@
/**
* History Service - Derives wallet history from invitations and UTXOs.
*
* Provides a unified view of wallet activity including:
* - UTXO reservations (from invitation commits that reference our UTXOs as inputs)
* - UTXOs we own (with descriptions derived from template outputs)
*/
import type { Engine } from '@xo-cash/engine';
import type { XOInvitation, XOTemplate } from '@xo-cash/types';
import type { UnspentOutputData } from '@xo-cash/state';
import type { Invitation } from './invitation.js';
import { binToHex } from '@bitauth/libauth';
/**
* Types of history events.
*/
export type HistoryItemType =
| 'utxo_received'
| 'utxo_reserved'
| 'invitation_created';
/**
* A single item in the wallet history.
*/
export interface HistoryItem {
/** Unique identifier for this history item. */
id: string;
/** Unix timestamp of when the event occurred (if available). */
timestamp?: number;
/** The type of history event. */
type: HistoryItemType;
/** Human-readable description derived from the template. */
description: string;
/** The value in satoshis (for UTXO-related events). */
valueSatoshis?: bigint;
/** The invitation identifier this event relates to (if applicable). */
invitationIdentifier?: string;
/** The template identifier for reference. */
templateIdentifier?: string;
/** The UTXO outpoint (for UTXO-related events). */
outpoint?: {
txid: string;
index: number;
};
/** Whether this UTXO is reserved. */
reserved?: boolean;
}
/**
* Service for deriving wallet history from invitations and UTXOs.
*
* This service takes the engine and invitations array as dependencies
* and derives history events from them. Since invitations is passed
* by reference, getHistory() always sees the current data.
*/
export class HistoryService {
/**
* Creates a new HistoryService.
*
* @param engine - The XO engine instance for querying UTXOs and templates.
* @param invitations - The array of invitations to derive history from.
*/
constructor(
private engine: Engine,
private invitations: Invitation[]
) {}
/**
* Gets the wallet history derived from invitations and UTXOs.
*
* @returns Array of history items sorted by timestamp (newest first), then UTXOs without timestamps.
*/
async getHistory(): Promise<HistoryItem[]> {
const items: HistoryItem[] = [];
// 1. Get all our UTXOs
const allUtxos = await this.engine.listUnspentOutputsData();
// Create a map for quick UTXO lookup by outpoint
const utxoMap = new Map<string, UnspentOutputData>();
for (const utxo of allUtxos) {
const key = `${utxo.outpointTransactionHash}:${utxo.outpointIndex}`;
utxoMap.set(key, utxo);
}
// 2. Process invitations to find UTXO reservations from commits
for (const invitation of this.invitations) {
const invData = invitation.data;
// Add invitation created event
const template = await this.engine.getTemplate(invData.templateIdentifier);
const invDescription = template
? this.deriveInvitationDescription(invData, template)
: 'Unknown action';
items.push({
id: `inv-${invData.invitationIdentifier}`,
timestamp: invData.createdAtTimestamp,
type: 'invitation_created',
description: invDescription,
invitationIdentifier: invData.invitationIdentifier,
templateIdentifier: invData.templateIdentifier,
});
// Check each commit for inputs that reference our UTXOs
for (const commit of invData.commits) {
const commitInputs = commit.data.inputs ?? [];
for (const input of commitInputs) {
// Input's outpointTransactionHash could be Uint8Array or string
const txHash = input.outpointTransactionHash
? (input.outpointTransactionHash instanceof Uint8Array
? binToHex(input.outpointTransactionHash)
: String(input.outpointTransactionHash))
: undefined;
if (!txHash || input.outpointIndex === undefined) continue;
const utxoKey = `${txHash}:${input.outpointIndex}`;
const matchingUtxo = utxoMap.get(utxoKey);
// If this input references one of our UTXOs, it's a reservation event
if (matchingUtxo) {
const utxoTemplate = await this.engine.getTemplate(matchingUtxo.templateIdentifier);
const utxoDescription = utxoTemplate
? this.deriveUtxoDescription(matchingUtxo, utxoTemplate)
: 'Unknown UTXO';
items.push({
id: `reserved-${commit.commitIdentifier}-${utxoKey}`,
timestamp: invData.createdAtTimestamp, // Use invitation timestamp as proxy
type: 'utxo_reserved',
description: `Reserved for: ${invDescription}`,
valueSatoshis: BigInt(matchingUtxo.valueSatoshis),
invitationIdentifier: invData.invitationIdentifier,
templateIdentifier: matchingUtxo.templateIdentifier,
outpoint: {
txid: txHash,
index: input.outpointIndex,
},
reserved: true,
});
}
}
}
}
// 3. Add all UTXOs as "received" events (without timestamps)
for (const utxo of allUtxos) {
const template = await this.engine.getTemplate(utxo.templateIdentifier);
const description = template
? this.deriveUtxoDescription(utxo, template)
: 'Unknown output';
items.push({
id: `utxo-${utxo.outpointTransactionHash}:${utxo.outpointIndex}`,
// No timestamp available for UTXOs
type: 'utxo_received',
description,
valueSatoshis: BigInt(utxo.valueSatoshis),
templateIdentifier: utxo.templateIdentifier,
outpoint: {
txid: utxo.outpointTransactionHash,
index: utxo.outpointIndex,
},
reserved: utxo.reserved,
invitationIdentifier: utxo.invitationIdentifier || undefined,
});
}
// Sort: items with timestamps first (newest first), then items without timestamps
return items.sort((a, b) => {
// Both have timestamps: sort by timestamp descending
if (a.timestamp !== undefined && b.timestamp !== undefined) {
return b.timestamp - a.timestamp;
}
// Only a has timestamp: a comes first
if (a.timestamp !== undefined) return -1;
// Only b has timestamp: b comes first
if (b.timestamp !== undefined) return 1;
// Neither has timestamp: maintain order
return 0;
});
}
/**
* Derives a human-readable description for a UTXO from its template output definition.
*
* @param utxo - The UTXO data.
* @param template - The template definition.
* @returns Human-readable description string.
*/
private deriveUtxoDescription(utxo: UnspentOutputData, template: XOTemplate): string {
const outputDef = template.outputs?.[utxo.outputIdentifier];
if (!outputDef) {
return `${utxo.outputIdentifier} output`;
}
// Start with the output name or identifier
let description = outputDef.name || utxo.outputIdentifier;
// If there's a description, parse it and replace variable placeholders
if (outputDef.description) {
description = outputDef.description
// Replace <variableName> placeholders (we don't have variable values here, so just clean up)
.replace(/<([^>]+)>/g, (_, varId) => varId)
// Remove $() wrappers
.replace(/\$\(([^)]+)\)/g, '$1');
}
return description;
}
/**
* Derives a human-readable description from an invitation and its template.
* Parses the transaction description and replaces variable placeholders.
*
* @param invitation - The invitation data.
* @param template - The template definition.
* @returns Human-readable description string.
*/
private deriveInvitationDescription(invitation: XOInvitation, template: XOTemplate): string {
const action = template.actions?.[invitation.actionIdentifier];
const transactionName = action?.transaction;
const transaction = transactionName ? template.transactions?.[transactionName] : null;
if (!transaction?.description) {
return action?.name ?? invitation.actionIdentifier;
}
const committedVariables = invitation.commits.flatMap(c => c.data.variables ?? []);
return transaction.description
// Replace <variableName> with actual values
.replace(/<([^>]+)>/g, (match, varId) => {
const variable = committedVariables.find(v => v.variableIdentifier === varId);
return variable ? String(variable.value) : match;
})
// Remove the $() wrapper around variable expressions
.replace(/\$\(([^)]+)\)/g, '$1');
}
}

View File

@@ -1,5 +1,5 @@
import type { AppendInvitationParameters, Engine, FindSuitableResourcesParameters } from '@xo-cash/engine';
import type { XOInvitation, XOInvitationInput, XOInvitationOutput, XOInvitationVariable } from '@xo-cash/types';
import type { XOInvitation, XOInvitationCommit, XOInvitationInput, XOInvitationOutput, XOInvitationVariable } from '@xo-cash/types';
import type { UnspentOutputData } from '@xo-cash/state';
import type { SSEvent } from '../utils/sse-client.js';
@@ -50,18 +50,12 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
throw new Error(`Template not found: ${invitation.templateIdentifier}`);
}
console.log('Invitation:', invitation);
// Create the invitation
const invitationInstance = new Invitation(invitation, dependencies);
console.log('Invitation instance:', invitationInstance);
// Start the invitation and its tracking
await invitationInstance.start();
console.log('Invitation started:', invitationInstance);
return invitationInstance;
}
@@ -114,21 +108,28 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
async start(): Promise<void> {
// Connect to the sync server and get the invitation (in parallel)
console.time(`connectAndGetInvitation-${this.data.invitationIdentifier}`);
const [_, invitation] = await Promise.all([
this.syncServer.connect(),
this.syncServer.getInvitation(this.data.invitationIdentifier),
]);
console.timeEnd(`connectAndGetInvitation-${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 = { ...this.data, ...invitation, commits: [...sseCommits, ...(invitation?.commits ?? [])] };
console.time(`mergeCommits-${this.data.invitationIdentifier}`);
// Merge the commits
const combinedCommits = this.mergeCommits(sseCommits, invitation?.commits ?? []);
console.timeEnd(`mergeCommits-${this.data.invitationIdentifier}`);
console.log('Invitation data:', this.data);
console.time(`setInvitationData-${this.data.invitationIdentifier}`);
// 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);
console.timeEnd(`setInvitationData-${this.data.invitationIdentifier}`);
}
/**
@@ -143,17 +144,16 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
const data = JSON.parse(event.data) as { topic?: string; data?: unknown };
if (data.topic === 'invitation-updated') {
const invitation = decodeExtendedJsonObject(data.data) as XOInvitation;
console.log('Invitation updated:', invitation);
if (invitation.invitationIdentifier !== this.data.invitationIdentifier) {
return;
}
console.log('New commits:', invitation.commits);
// 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);
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
this.updateStatus();
@@ -163,6 +163,28 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
}
}
/**
* 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());
}
/**
* Update the status of the invitation based on the filled in information
*/

View File

@@ -36,11 +36,9 @@ export class Storage {
async set(key: string, value: any): Promise<void> {
// Encode the extended json object
const encodedValue = encodeExtendedJson(value);
console.log('Encoded value:', encodedValue);
// Insert or replace the value into the database with full key (including basePath)
const fullKey = this.getFullKey(key);
console.log('Full key:', fullKey);
this.database.prepare('INSERT OR REPLACE INTO storage (key, value) VALUES (?, ?)').run(fullKey, encodedValue);
}