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