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 extractEntities(invitation: XOInvitation): Promise { const entities = new Set(); for (const commit of invitation.commits) { entities.add(commit.entityIdentifier); } return Array.from(entities); } // Entities are currently static per invitation. So, we can try to match the roles to entities by: // Iterating through each commit, extract the entity into a Map. // While we iterate through the commits, if we see a role declaration in the commit, we save that role onto the entity's roles array. // After we have iterated through all the commits, we can return the Map. async matchRolesToEntities( invitation: XOInvitation, entities: string[], ): Promise> { const entitiesMap = new Map>(); for (const entity of entities) { entitiesMap.set(entity, new Set()); } // First pass, we are just going to try and find roleIdentifer values in the inputs, outputs, and variables. // TODO: Update this once the invitations use XPubs for (const commit of invitation.commits) { const entity = commit.entityIdentifier; const roles = entitiesMap.get(entity) ?? new Set(); for (const input of commit.data.inputs ?? []) { if (input.roleIdentifier) roles.add(input.roleIdentifier); } for (const output of commit.data.outputs ?? []) { if (output.roleIdentifier) roles.add(output.roleIdentifier); } for (const variable of commit.data.variables ?? []) { if (variable.roleIdentifier) roles.add(variable.roleIdentifier); } } // TODO: We might be able to use the lockingBytecodes that we have generated to infer which role we are. But the templates dont tell us which role is responsible for a particular output. // I.e, if we dont know what role an output was from, we cant match it using the lockingBytecode to a role. // Example: 2 inputs to a TX for the same amount. We dont know whether we would be Sender1 or Sender2. // So, for now we are just going to rely on the roleIdentifiers that we have found in the first pass. // Format into a record for easier access. const entitiesRecord: Record = {}; for (const [entity, roles] of entitiesMap.entries()) { entitiesRecord[entity] = Array.from(roles); } return entitiesRecord; } 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); // TODO: Remove or use this. Its a test for extracting the roles to entities. // const entities = await this.extractEntities(invitation.data); // const entitiesRecord = await this.matchRolesToEntities(invitation.data, entities); // console.log(entitiesRecord); } 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, }, }; } /** * TODO: This is completely incorrect. It should be deriving the roles of the user based on the entity IDs in the commits, then mapping each commit to Inputs/Outputs so we know what belongs to the user. * There are a few changes that will need to be made to make this work: * 1. Provide a way to derive all entity IDs of the user for the invitation (If we are going with XPub) * 2. Provide a way to get only the User's commits (and their inputs/outputs) * 3. (Maybe) Include role on inputs and outputs - This one might be fine with just using the commit entity id */ 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]); }, ); } }