248 lines
8.6 KiB
TypeScript
248 lines
8.6 KiB
TypeScript
/**
|
|
* 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<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 `[${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<string, XOInvitationVariableValue>);
|
|
|
|
const description = compileCashAssemblyString(transaction.description, formattedVariables);
|
|
|
|
return description;
|
|
}
|
|
}
|