/** * 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, compileCashAssemblyString } from '@xo-cash/engine'; import type { XOInvitation, XOInvitationVariableValue, 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 { 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(); 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 `[${template.name}] ${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 = compileCashAssemblyString(outputDef.description, {}) } 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 ?? []); const formattedVariables = committedVariables.reduce((acc, v) => { acc[v.variableIdentifier ?? ''] = v.value; return acc; }, {} as Record); const description = compileCashAssemblyString(transaction.description, formattedVariables); return description; } }