Big changes and fixes. Uses action history. Improve role selection. Remove unused logs
This commit is contained in:
@@ -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
252
src/services/history.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user