import { binToHex } from '@bitauth/libauth'; import { compileCashAssemblyString, type Engine } from '@xo-cash/engine'; import type { UnspentOutputData } from '@xo-cash/state'; import type { XOInvitation, XOInvitationCommit, XOInvitationVariableValue, XOTemplate } from '@xo-cash/types'; import type { Invitation } from './invitation.js'; export type HistoryEntryKind = 'invitation' | 'utxo'; export interface HistoryDescriptionParts { template: string; role: string; outputIdentifier: string; description: string; valueSatoshis?: number; } export interface HistoryUtxoItem { kind: 'utxo'; id: string; invitationIdentifier?: string; templateIdentifier: string; outputIdentifier: string; outpoint: { txid: string; index: number; }; valueSatoshis?: bigint; reserved?: boolean; direction: 'input' | 'output' | 'standalone'; description: string; descriptionParts: HistoryDescriptionParts; } export interface HistoryInvitationItem { kind: 'invitation'; id: string; createdAtTimestamp: number; templateIdentifier: string; invitationIdentifier: string; roles: string[]; description: string; descriptionParts: { template: string; roles: string[]; description: string; }; inputs: HistoryUtxoItem[]; outputs: HistoryUtxoItem[]; } export type HistoryItem = HistoryInvitationItem | HistoryUtxoItem; interface InvitationContext { invitation: Invitation; template: XOTemplate | null; variables: Record; walletEntityIdentifier?: string; } interface UtxoOriginContext { invitationIdentifier: string; roleIdentifier?: string; } export class HistoryService { constructor( private engine: Engine, private invitations: Invitation[] ) {} async getHistory(): Promise { const allUtxos = await this.engine.listUnspentOutputsData(); const ownOutpoints = new Set(); const ownLockingBytecodes = new Set(); const invitationByOrigin = new Map(); const outpointValueSatoshis = new Map(); for (const utxo of allUtxos) { const outpointKey = this.getOutpointKey(utxo.outpointTransactionHash, utxo.outpointIndex); ownOutpoints.add(outpointKey); ownLockingBytecodes.add(utxo.lockingBytecode); outpointValueSatoshis.set(outpointKey, BigInt(utxo.valueSatoshis)); } const contexts = new Map(); for (const invitation of this.invitations) { const variables = this.extractInvitationVariables(invitation.data); const template = await this.engine.getTemplate(invitation.data.templateIdentifier) ?? null; const walletEntityIdentifier = this.resolveWalletEntityIdentifier(invitation, ownOutpoints, ownLockingBytecodes); contexts.set(invitation.data.invitationIdentifier, { invitation, template, variables, walletEntityIdentifier, }); this.indexInvitationOutputsByUtxoOrigin(invitationByOrigin, invitation); } const usedUtxoIds = new Set(); const invitationItems: HistoryInvitationItem[] = []; for (const context of contexts.values()) { const invitation = context.invitation.data; const templateName = context.template?.name ?? 'UnknownTemplate'; const invitationOutputs = this.buildWalletOutputItemsForInvitation( context, allUtxos, invitationByOrigin, usedUtxoIds, ); const roles = this.deriveWalletRolesForInvitation(context, invitationOutputs); const invitationInputs = this.buildWalletInputItemsForInvitation( context, roles[0], invitationOutputs.length > 0, outpointValueSatoshis, ); const invitationDescription = this.deriveInvitationDescription(invitation, context.template, context.variables, roles[0]); invitationItems.push({ kind: 'invitation', id: `inv-${invitation.invitationIdentifier}`, createdAtTimestamp: invitation.createdAtTimestamp, templateIdentifier: invitation.templateIdentifier, invitationIdentifier: invitation.invitationIdentifier, roles, description: invitationDescription, descriptionParts: { template: templateName, roles, description: invitationDescription, }, inputs: invitationInputs, outputs: invitationOutputs, }); } invitationItems.sort((a, b) => b.createdAtTimestamp - a.createdAtTimestamp); const standaloneUtxos: HistoryUtxoItem[] = []; for (const utxo of allUtxos) { const utxoId = this.getUtxoId(utxo); if (usedUtxoIds.has(utxoId)) continue; const template = await this.engine.getTemplate(utxo.templateIdentifier) ?? null; const inferredRole = this.inferRoleFromOutputIdentifier(utxo.outputIdentifier); const description = this.deriveUtxoDescription(utxo, template, {}, inferredRole); standaloneUtxos.push(this.buildUtxoHistoryItem( utxo, description, template?.name ?? 'UnknownTemplate', inferredRole, 'standalone', )); } return [ ...invitationItems, ...standaloneUtxos ]; } private buildWalletOutputItemsForInvitation( context: InvitationContext, allUtxos: UnspentOutputData[], invitationByOrigin: Map, usedUtxoIds: Set ): HistoryUtxoItem[] { const invitationId = context.invitation.data.invitationIdentifier; const outputs: HistoryUtxoItem[] = []; for (const utxo of allUtxos) { const resolvedInvitationId = this.resolveInvitationIdentifierForUtxo(utxo, invitationByOrigin); if (resolvedInvitationId !== invitationId) continue; const role = this.resolveRoleIdentifierForUtxo(utxo, invitationByOrigin) ?? this.inferRoleFromOutputIdentifier(utxo.outputIdentifier) ?? 'receiver'; const description = this.deriveUtxoDescription(utxo, context.template, context.variables, role); outputs.push(this.buildUtxoHistoryItem(utxo, description, context.template?.name ?? 'UnknownTemplate', role, 'output')); usedUtxoIds.add(this.getUtxoId(utxo)); } return outputs; } private buildWalletInputItemsForInvitation( context: InvitationContext, walletRole?: string, hasWalletOutputs: boolean = false, outpointValueSatoshis: Map = new Map(), ): HistoryUtxoItem[] { const invitation = context.invitation.data; const commits = invitation.commits ?? []; const commitsByEntity = context.walletEntityIdentifier ? commits.filter((commit) => commit.entityIdentifier === context.walletEntityIdentifier) : []; const commitsByRole = walletRole ? commits.filter((commit) => this.deriveCommitRoleIdentifier(commit, invitation, context.template) === walletRole) : []; let relevantCommits = commitsByEntity.filter((commit) => (commit.data.inputs?.length ?? 0) > 0); if (relevantCommits.length === 0) { relevantCommits = commitsByRole.filter((commit) => (commit.data.inputs?.length ?? 0) > 0); } if (relevantCommits.length === 0 && walletRole === 'sender') { relevantCommits = commits.filter((commit) => (commit.data.inputs?.length ?? 0) > 0); } // Sender fallback only when no wallet outputs were matched. if (relevantCommits.length === 0 && !hasWalletOutputs) { relevantCommits = commits.filter((commit) => (commit.data.inputs?.length ?? 0) > 0); } const txDescription = this.deriveTransactionActivityDescription( invitation, context.template, context.variables, walletRole, ); const inputs: HistoryUtxoItem[] = []; for (const commit of relevantCommits) { for (const input of commit.data.inputs ?? []) { const txHash = input.outpointTransactionHash ? (input.outpointTransactionHash instanceof Uint8Array ? binToHex(input.outpointTransactionHash) : String(input.outpointTransactionHash)) : 'unknown-tx'; const inputIndex = input.outpointIndex ?? -1; const inputIdentifier = input.inputIdentifier ?? 'input'; const inputDescription = this.deriveInputDescription(inputIdentifier, context.template, context.variables); const templateName = context.template?.name ?? 'UnknownTemplate'; const role = walletRole ?? 'sender'; const inputValue = this.resolveInputSatoshis(txHash, inputIndex, outpointValueSatoshis, context.variables); inputs.push({ kind: 'utxo', id: `input-${invitation.invitationIdentifier}-${commit.commitIdentifier}-${txHash}:${inputIndex}-${inputIdentifier}`, invitationIdentifier: invitation.invitationIdentifier, templateIdentifier: invitation.templateIdentifier, outputIdentifier: inputIdentifier, outpoint: { txid: txHash, index: inputIndex, }, direction: 'input', valueSatoshis: inputValue, description: `${txDescription} - ${inputDescription}`, descriptionParts: { template: templateName, role, outputIdentifier: inputIdentifier, description: `${txDescription} - ${inputDescription}`, valueSatoshis: inputValue !== undefined ? Number(inputValue) : undefined, }, }); } } return inputs; } private buildUtxoHistoryItem( utxo: UnspentOutputData, description: string, templateName: string, roleIdentifier: string | undefined, direction: HistoryUtxoItem['direction'] ): HistoryUtxoItem { return { kind: 'utxo', id: this.getUtxoId(utxo), invitationIdentifier: utxo.invitationIdentifier || undefined, templateIdentifier: utxo.templateIdentifier, outputIdentifier: utxo.outputIdentifier, outpoint: { txid: utxo.outpointTransactionHash, index: utxo.outpointIndex, }, valueSatoshis: BigInt(utxo.valueSatoshis), reserved: utxo.reserved, direction, description, descriptionParts: { template: templateName, role: roleIdentifier ?? 'unknown', outputIdentifier: utxo.outputIdentifier, description, valueSatoshis: utxo.valueSatoshis, }, }; } private deriveWalletRolesForInvitation( context: InvitationContext, outputs: HistoryUtxoItem[] ): string[] { const roles = new Set(); for (const output of outputs) { const outputRole = output.descriptionParts.role; if (outputRole && outputRole !== 'unknown') { roles.add(outputRole); } } if (roles.size === 0 && outputs.length > 0) { roles.add('receiver'); } const hasInputCommit = (context.walletEntityIdentifier ? context.invitation.data.commits.filter((c) => c.entityIdentifier === context.walletEntityIdentifier) : context.invitation.data.commits ).some((c) => (c.data.inputs?.length ?? 0) > 0); if (hasInputCommit) roles.add('sender'); if (!hasInputCommit && outputs.length === 0 && context.invitation.data.commits.some((c) => (c.data.inputs?.length ?? 0) > 0)) { roles.add('sender'); } if (roles.size === 0) { const inferred = this.extractInvitationRoleIdentifier(context.invitation.data, context.template, context.walletEntityIdentifier); if (inferred) roles.add(inferred); } return roles.size > 0 ? Array.from(roles) : [ 'unknown' ]; } private extractInvitationVariables(invitation: XOInvitation): Record { const committedVariables = invitation.commits.flatMap(c => c.data.variables ?? []); return committedVariables.reduce((acc, variable) => { if (!variable.variableIdentifier) return acc; acc[variable.variableIdentifier] = variable.value; return acc; }, {} as Record); } private indexInvitationOutputsByUtxoOrigin( invitationByUtxoOrigin: Map, invitation: Invitation ): void { for (const commit of invitation.data.commits) { for (const output of commit.data.outputs ?? []) { if (!output.outputIdentifier || !output.lockingBytecode) continue; const lockingBytecodeHex = this.toLockingBytecodeHex(output.lockingBytecode); const key = this.getUtxoOriginKey(invitation.data.templateIdentifier, output.outputIdentifier, lockingBytecodeHex); invitationByUtxoOrigin.set(key, { invitationIdentifier: invitation.data.invitationIdentifier, roleIdentifier: output.roleIdentifier, }); } } } private resolveInvitationIdentifierForUtxo( utxo: UnspentOutputData, invitationByUtxoOrigin: Map ): string | undefined { if (utxo.invitationIdentifier) return utxo.invitationIdentifier; const originKey = this.getUtxoOriginKey(utxo.templateIdentifier, utxo.outputIdentifier, utxo.lockingBytecode); return invitationByUtxoOrigin.get(originKey)?.invitationIdentifier; } private resolveRoleIdentifierForUtxo( utxo: UnspentOutputData, invitationByUtxoOrigin: Map ): string | undefined { const originKey = this.getUtxoOriginKey(utxo.templateIdentifier, utxo.outputIdentifier, utxo.lockingBytecode); return invitationByUtxoOrigin.get(originKey)?.roleIdentifier; } private resolveWalletEntityIdentifier( invitation: Invitation, ownUtxoOutpointKeys: Set, ownLockingBytecodes: Set ): string | undefined { const scores = new Map(); const addScore = (entityIdentifier: string, delta: number): void => { scores.set(entityIdentifier, (scores.get(entityIdentifier) ?? 0) + delta); }; for (const commit of invitation.data.commits) { for (const input of commit.data.inputs ?? []) { const txHash = input.outpointTransactionHash ? (input.outpointTransactionHash instanceof Uint8Array ? binToHex(input.outpointTransactionHash) : String(input.outpointTransactionHash)) : undefined; if (!txHash || input.outpointIndex === undefined) continue; if (ownUtxoOutpointKeys.has(this.getOutpointKey(txHash, input.outpointIndex))) { addScore(commit.entityIdentifier, 3); } } for (const output of commit.data.outputs ?? []) { const lockingBytecodeHex = output.lockingBytecode ? this.toLockingBytecodeHex(output.lockingBytecode) : undefined; if (!lockingBytecodeHex) continue; if (ownLockingBytecodes.has(lockingBytecodeHex)) { addScore(commit.entityIdentifier, 2); } } } let bestEntity: string | undefined; let bestScore = 0; for (const [ entity, score ] of scores.entries()) { if (score > bestScore) { bestScore = score; bestEntity = entity; } } return bestEntity; } private deriveUtxoDescription( utxo: UnspentOutputData, template: XOTemplate | null, variables: Record, roleIdentifier?: string ): string { const templateName = template?.name ?? 'UnknownTemplate'; const role = roleIdentifier ?? 'unknown'; const outputDef = template?.outputs?.[utxo.outputIdentifier]; let detail = outputDef?.name ?? utxo.outputIdentifier; if (outputDef?.description) { try { detail = compileCashAssemblyString(outputDef.description, variables); } catch { detail = this.interpolateSimpleCashAssemblyVariables(outputDef.description, variables); } } return `[${templateName}:${role}] ${detail}`; } private deriveInvitationDescription( invitation: XOInvitation, template: XOTemplate | null, variables: Record, roleIdentifier?: string ): string { if (!template) return invitation.actionIdentifier; const action = template.actions?.[invitation.actionIdentifier]; const transactionName = action?.transaction; const transaction = transactionName ? template.transactions?.[transactionName] : null; const role = roleIdentifier ?? 'unknown'; const baseTemplate = transaction?.description ?? action?.description ?? action?.name ?? invitation.actionIdentifier; let detail = baseTemplate; try { detail = compileCashAssemblyString(baseTemplate, variables); } catch { detail = this.interpolateSimpleCashAssemblyVariables(baseTemplate, variables); } return `[${template.name}:${role}] ${detail}`; } private deriveInputDescription( inputIdentifier: string, template: XOTemplate | null, variables: Record ): string { if (inputIdentifier === 'input') return 'Funding input'; const inputDef = template?.inputs?.[inputIdentifier]; if (!inputDef) return inputIdentifier; if (!inputDef.description) return inputDef.name ?? inputIdentifier; try { return compileCashAssemblyString(inputDef.description, variables); } catch { return this.interpolateSimpleCashAssemblyVariables(inputDef.description, variables); } } private deriveTransactionActivityDescription( invitation: XOInvitation, template: XOTemplate | null, variables: Record, roleIdentifier?: string ): string { if (!template) return invitation.actionIdentifier; const action = template.actions?.[invitation.actionIdentifier]; const transactionName = action?.transaction; const transaction = transactionName ? template.transactions?.[transactionName] : null; const roleData = roleIdentifier ? transaction?.roles?.[roleIdentifier] : undefined; const descriptionTemplate = roleData?.description ?? transaction?.description ?? roleData?.name ?? transaction?.name ?? action?.name ?? invitation.actionIdentifier; try { return compileCashAssemblyString(descriptionTemplate, variables); } catch { return this.interpolateSimpleCashAssemblyVariables(descriptionTemplate, variables); } } private deriveCommitRoleIdentifier( commit: XOInvitationCommit, invitation: XOInvitation, template: XOTemplate | null ): string | undefined { const explicitRoles = new Set(); for (const input of commit.data.inputs ?? []) { if (input.roleIdentifier) explicitRoles.add(input.roleIdentifier); } for (const output of commit.data.outputs ?? []) { if (output.roleIdentifier) explicitRoles.add(output.roleIdentifier); } for (const variable of commit.data.variables ?? []) { if (variable.roleIdentifier) explicitRoles.add(variable.roleIdentifier); } if (explicitRoles.size === 1) return Array.from(explicitRoles)[0]; const action = template?.actions?.[invitation.actionIdentifier]; if ((commit.data.inputs?.length ?? 0) > 0 && action?.roles?.sender) return 'sender'; if ((commit.data.variables?.length ?? 0) > 0 && action?.roles?.receiver) return 'receiver'; return undefined; } private extractInvitationRoleIdentifier( invitation: XOInvitation, template: XOTemplate | null, walletEntityIdentifier?: string ): string | undefined { if (walletEntityIdentifier) { const commits = invitation.commits.filter((commit) => commit.entityIdentifier === walletEntityIdentifier); for (const commit of commits) { const role = this.deriveCommitRoleIdentifier(commit, invitation, template); if (role) return role; } } return undefined; } private inferRoleFromOutputIdentifier(outputIdentifier: string): string | undefined { const normalized = outputIdentifier.toLowerCase(); if (normalized.includes('receive') || normalized.includes('request')) return 'receiver'; if (normalized.includes('change') || normalized.includes('send')) return 'sender'; return undefined; } private resolveInputSatoshis( txHash: string, index: number, outpointValueSatoshis: Map, variables: Record ): bigint | undefined { const outpointKey = this.getOutpointKey(txHash, index); const matchedValue = outpointValueSatoshis.get(outpointKey); if (matchedValue !== undefined) return matchedValue; const requestedSatoshis = variables.requestedSatoshis; if (requestedSatoshis !== undefined) { try { return BigInt(String(requestedSatoshis)); } catch { return undefined; } } return undefined; } private getUtxoId(utxo: UnspentOutputData): string { return `utxo-${utxo.outpointTransactionHash}:${utxo.outpointIndex}`; } private getOutpointKey(txid: string, index: number): string { return `${txid}:${index}`; } private getUtxoOriginKey(templateIdentifier: string, outputIdentifier: string, lockingBytecodeHex: string): string { return `${templateIdentifier}:${outputIdentifier}:${lockingBytecodeHex}`; } private toLockingBytecodeHex(lockingBytecode: string | Uint8Array): string { if (typeof lockingBytecode === 'string') return lockingBytecode; return binToHex(lockingBytecode); } private interpolateSimpleCashAssemblyVariables( text: string, variables: Record ): string { return text.replace(/\$\(<([^>]+)>\)/g, (match, variableIdentifier: string) => { if (!Object.prototype.hasOwnProperty.call(variables, variableIdentifier)) return match; return String(variables[variableIdentifier]); }); } }