import { binToHex, hexToBin, sha256 } from "@bitauth/libauth"; import { compileCashAssemblyString, type Engine } from "@xo-cash/engine"; import type { ScriptHashData, UnspentOutputData } from "@xo-cash/state"; import type { XOInvitation, XOInvitationCommit, XOInvitationInput, XOInvitationOutput, XOInvitationVariableValue, XOTemplate, } from "@xo-cash/types"; import type { Invitation } from "./invitation.js"; export type WalletHistorySource = "invitation" | "utxo"; export type WalletHistoryInput = { id: string; commitIdentifier?: string; inputIdentifier?: string; role?: string; description: string; valueSatoshis?: bigint; outpoint: { txid: string; index: number; }; scriptHash?: string; }; export type WalletHistoryOutput = { id: string; commitIdentifier?: string; outputIdentifier?: string; role?: string; description: string; valueSatoshis?: bigint; outpoint?: { txid: string; index: number; }; lockingBytecode?: string; scriptHash?: string; reserved?: boolean; }; export type WalletHistoryItem = { id: string; source: WalletHistorySource; invitationIdentifier?: string; createdAtTimestamp?: number; templateIdentifier: string; template: string; action?: string; roles: string[]; description: string; valueSatoshis: bigint; inputs: WalletHistoryInput[]; outputs: WalletHistoryOutput[]; }; export type HistoryItem = WalletHistoryItem; interface InvitationContext { invitation: Invitation; template: XOTemplate | null; variables: Record; } interface UtxoContext { utxo: UnspentOutputData; scriptHashData?: ScriptHashData; template: XOTemplate | null; } interface WalletMetadataIndex { scriptHashDataByScriptHash: Map; } /* * This needs a thorough and significant rewrite and design. * I've tried to fundamental approaches so far: * - UTXO first * - Invitation first * * The issue is that neither of these end up being simple or effective * UTXO first makes tracking utxos across invitations extremely difficult. So if you receive a UTXO from an invitation and then spend it on another, you wont even see that old invitation. * Invitation first makes fitting UTXOs that dont have an invitation (say if someone sent directly to your address) extremely difficult. You end up having to run a UTXO first pass anyway, and then end up with conflicts around resolved roles. * Inferring roles is also extremely difficult. We cant just say "does this have an output for our P2PKH receiving roll? it does? Ok, we are a receiver" because this would match `true` because of our change outputs. * If anyone has any idea of how to address this without tying knots of spaghetti, please let me know. * This has been rewritten multiple times to try and simplify it, but its still extremely hard to follow and understand, while not even providing information that we want. */ export class HistoryService { constructor( private engine: Engine, private invitations: Invitation[], ) {} /** * I Might swap this over to invitation based history before the event to make it a bit more evident... Really not happy with the UTXO for demo purposes * But for the actual usage, UTXO is easier to follow - just not good for demo * Long term, this is intended to be in the Engine, so we will just be a consumer of history state. */ async getHistory(): Promise { const allUtxos = await this.engine.listUnspentOutputsData(); const metadataIndex = await this.buildWalletMetadataIndex(allUtxos); const invitationContexts = await this.buildInvitationContextIndex(); const utxoContexts = await Promise.all( allUtxos.map((utxo) => this.buildUtxoContext(utxo, metadataIndex)), ); const reservedUtxosByInvitation = new Map(); const standaloneUtxos: UtxoContext[] = []; for (const context of utxoContexts) { const invitationIdentifier = context.utxo.reservedBy; if (invitationIdentifier && invitationContexts.has(invitationIdentifier)) { const group = reservedUtxosByInvitation.get(invitationIdentifier) ?? []; group.push(context); reservedUtxosByInvitation.set(invitationIdentifier, group); continue; } standaloneUtxos.push(context); } const invitationItems = [...reservedUtxosByInvitation.entries()].map( ([invitationIdentifier, reservedContexts]) => this.projectInvitationHistory( invitationContexts.get(invitationIdentifier)!, reservedContexts, ), ); const standaloneItems = standaloneUtxos.map((context) => this.projectStandaloneUtxo(context), ); return [...standaloneItems, ...invitationItems].sort((a, b) => { if (a.source !== b.source) return a.source === "utxo" ? -1 : 1; return (b.createdAtTimestamp ?? 0) - (a.createdAtTimestamp ?? 0); }); } private async buildInvitationContextIndex(): Promise> { const contexts = new Map(); for (const invitation of this.invitations) { const template = (await this.engine.getTemplate(invitation.data.templateIdentifier)) ?? null; contexts.set(invitation.data.invitationIdentifier, { invitation, template, variables: this.extractInvitationVariables(invitation.data), }); } return contexts; } private async buildWalletMetadataIndex( allUtxos: UnspentOutputData[], ): Promise { const scriptHashDataByScriptHash = new Map(); const templateIdentifiers = new Set(); for (const utxo of allUtxos) { templateIdentifiers.add(utxo.templateIdentifier); } for (const invitation of this.invitations) { templateIdentifiers.add(invitation.data.templateIdentifier); } for (const templateIdentifier of templateIdentifiers) { const scriptHashDataList = await this.engine.listScriptHashesForTemplate(templateIdentifier); for (const scriptHashData of scriptHashDataList) { scriptHashDataByScriptHash.set(scriptHashData.scriptHash, scriptHashData); } } return { scriptHashDataByScriptHash }; } private async buildUtxoContext( utxo: UnspentOutputData, metadataIndex: WalletMetadataIndex, ): Promise { const scriptHashData = metadataIndex.scriptHashDataByScriptHash.get(utxo.scriptHash); const templateIdentifier = scriptHashData?.templateIdentifier ?? utxo.templateIdentifier; const template = (await this.engine.getTemplate(templateIdentifier)) ?? null; return { utxo, scriptHashData, template, }; } private projectInvitationHistory( context: InvitationContext, reservedContexts: UtxoContext[], ): WalletHistoryItem { const invitation = context.invitation.data; const entityRoles = this.deriveInvitationEntityRoles(context); const inputs = this.projectInvitationInputs(context, reservedContexts, entityRoles); const inputUtxoIds = this.listInvitationInputUtxoIds(context, reservedContexts); const outputs = this.projectInvitationOutputs( context, reservedContexts, entityRoles, inputUtxoIds, ); const roles = this.deriveRoles(inputs, outputs); const valueSatoshis = this.calculateValueSatoshis(inputs, outputs); return { id: `inv-${invitation.invitationIdentifier}`, source: "invitation", invitationIdentifier: invitation.invitationIdentifier, createdAtTimestamp: invitation.createdAtTimestamp, templateIdentifier: invitation.templateIdentifier, template: context.template?.name ?? "UnknownTemplate", action: invitation.actionIdentifier, roles, description: this.describeInvitation(context, roles[0]), valueSatoshis, inputs, outputs, }; } private projectInvitationInputs( context: InvitationContext, reservedContexts: UtxoContext[], entityRoles: Map, ): WalletHistoryInput[] { const invitation = context.invitation.data; const inputs: WalletHistoryInput[] = []; const reservedByOutpoint = new Map( reservedContexts.map((context) => [ this.getOutpointKey( context.utxo.outpointTransactionHash, context.utxo.outpointIndex, ), context, ]), ); for (const commit of invitation.commits) { for (const [index, input] of (commit.data.inputs ?? []).entries()) { const txid = this.getInputTxid(input); const outpointIndex = input.outpointIndex; if (txid === undefined || outpointIndex === undefined) continue; const utxoContext = reservedByOutpoint.get(this.getOutpointKey(txid, outpointIndex)); // TODO: Remove this reservation-based filter once Engine/library cleanup releases stale invitation reservations internally. if (!utxoContext) continue; const inputIdentifier = input.inputIdentifier; const role = input.roleIdentifier ?? this.getFirstEntityRole(entityRoles, commit.entityIdentifier) ?? utxoContext.scriptHashData?.roleIdentifier; inputs.push({ id: `input-${invitation.invitationIdentifier}-${commit.commitIdentifier}-${index}`, commitIdentifier: commit.commitIdentifier, inputIdentifier, role, description: this.describeInput(inputIdentifier, context), valueSatoshis: -BigInt(utxoContext.utxo.valueSatoshis), outpoint: { txid, index: outpointIndex }, scriptHash: utxoContext.utxo.scriptHash, }); } } return inputs; } private projectInvitationOutputs( context: InvitationContext, reservedContexts: UtxoContext[], entityRoles: Map, inputUtxoIds: Set, ): WalletHistoryOutput[] { const invitation = context.invitation.data; const outputs: WalletHistoryOutput[] = []; const usedUtxoIds = new Set(); for (const commit of invitation.commits) { for (const [index, output] of (commit.data.outputs ?? []).entries()) { const matchingContext = this.findReservedOutputContext( output, reservedContexts, usedUtxoIds, ); // UTXO-first: committed outputs only matter here if they resolve to a wallet UTXO currently reserved by this invitation. if (!matchingContext) continue; const lockingBytecode = this.getOutputLockingBytecodeHex(output) ?? matchingContext.scriptHashData?.lockingBytecode; const outputIdentifier = output.outputIdentifier ?? matchingContext.scriptHashData?.outputIdentifier ?? matchingContext.utxo.outputIdentifier; const role = output.roleIdentifier ?? this.getFirstEntityRole(entityRoles, commit.entityIdentifier) ?? matchingContext.scriptHashData?.roleIdentifier; const valueSatoshis = output.valueSatoshis !== undefined ? BigInt(output.valueSatoshis) : BigInt(matchingContext.utxo.valueSatoshis); usedUtxoIds.add(this.getUtxoId(matchingContext.utxo)); outputs.push({ id: `output-${invitation.invitationIdentifier}-${commit.commitIdentifier}-${index}`, commitIdentifier: commit.commitIdentifier, outputIdentifier, role, description: this.describeOutput(outputIdentifier, context), valueSatoshis, outpoint: { txid: matchingContext.utxo.outpointTransactionHash, index: matchingContext.utxo.outpointIndex, }, lockingBytecode, scriptHash: matchingContext.utxo.scriptHash, reserved: true, }); } } for (const reservedContext of reservedContexts) { if (usedUtxoIds.has(this.getUtxoId(reservedContext.utxo))) continue; if (inputUtxoIds.has(this.getUtxoId(reservedContext.utxo))) continue; outputs.push(this.projectUtxoOutput(reservedContext)); } return outputs; } private listInvitationInputUtxoIds( context: InvitationContext, reservedContexts: UtxoContext[], ): Set { const invitationInputUtxoIds = new Set(); const reservedByOutpoint = new Map( reservedContexts.map((context) => [ this.getOutpointKey( context.utxo.outpointTransactionHash, context.utxo.outpointIndex, ), context, ]), ); for (const commit of context.invitation.data.commits) { for (const input of commit.data.inputs ?? []) { const txid = this.getInputTxid(input); const outpointIndex = input.outpointIndex; if (txid === undefined || outpointIndex === undefined) continue; const utxoContext = reservedByOutpoint.get(this.getOutpointKey(txid, outpointIndex)); if (utxoContext) invitationInputUtxoIds.add(this.getUtxoId(utxoContext.utxo)); } } return invitationInputUtxoIds; } private findReservedOutputContext( output: XOInvitationOutput, reservedContexts: UtxoContext[], usedUtxoIds: Set, ): UtxoContext | undefined { const lockingBytecode = this.getOutputLockingBytecodeHex(output); const scriptHash = lockingBytecode ? this.lockingBytecodeToScriptHash(lockingBytecode) : undefined; return reservedContexts.find((context) => { if (usedUtxoIds.has(this.getUtxoId(context.utxo))) return false; if (scriptHash && context.utxo.scriptHash === scriptHash) return true; if (lockingBytecode && context.scriptHashData?.lockingBytecode === lockingBytecode) return true; if (output.outputIdentifier && context.utxo.outputIdentifier === output.outputIdentifier) return true; return false; }); } private projectStandaloneUtxo(context: UtxoContext): WalletHistoryItem { const output = this.projectUtxoOutput(context); const templateIdentifier = context.scriptHashData?.templateIdentifier ?? context.utxo.templateIdentifier; const role = output.role; return { id: `utxo-${context.utxo.outpointTransactionHash}:${context.utxo.outpointIndex}`, source: "utxo", templateIdentifier, template: context.template?.name ?? "UnknownTemplate", roles: role ? [role] : ["unknown"], description: output.description, valueSatoshis: output.valueSatoshis ?? 0n, inputs: [], outputs: [output], }; } private projectUtxoOutput(context: UtxoContext): WalletHistoryOutput { const outputIdentifier = context.scriptHashData?.outputIdentifier ?? context.utxo.outputIdentifier; const role = context.scriptHashData?.roleIdentifier; return { id: this.getUtxoId(context.utxo), outputIdentifier, role, description: this.describeOutputFromTemplate(outputIdentifier, context.template, {}), valueSatoshis: BigInt(context.utxo.valueSatoshis), outpoint: { txid: context.utxo.outpointTransactionHash, index: context.utxo.outpointIndex, }, lockingBytecode: context.scriptHashData?.lockingBytecode, scriptHash: context.utxo.scriptHash, reserved: context.utxo.reservedBy !== undefined, }; } private deriveInvitationEntityRoles(context: InvitationContext): Map { const invitation = context.invitation.data; const rolesByEntity = new Map>(); const allEntities = new Set(invitation.commits.map((commit) => commit.entityIdentifier)); for (const entityIdentifier of allEntities) { rolesByEntity.set(entityIdentifier, new Set()); } for (const commit of invitation.commits) { const roles = rolesByEntity.get(commit.entityIdentifier) ?? 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); } rolesByEntity.set(commit.entityIdentifier, roles); } const action = context.template?.actions?.[invitation.actionIdentifier]; const participantRoles = action?.requirements?.participants ?.map((participant) => participant.role) .filter((role): role is string => typeof role === "string") ?? []; const explicitlyFilledRoles = new Set(); for (const roles of rolesByEntity.values()) { for (const role of roles) explicitlyFilledRoles.add(role); } const unfilledParticipantRoles = participantRoles.filter( (role) => !explicitlyFilledRoles.has(role), ); const entitiesWithoutRoles = [...rolesByEntity.entries()] .filter(([, roles]) => roles.size === 0) .map(([entityIdentifier]) => entityIdentifier); if (unfilledParticipantRoles.length === 1 && entitiesWithoutRoles.length >= 1) { const inferredRole = unfilledParticipantRoles[0]; if (inferredRole !== undefined) { for (const entityIdentifier of entitiesWithoutRoles) { rolesByEntity.get(entityIdentifier)?.add(inferredRole); } } } return new Map( [...rolesByEntity.entries()].map(([entityIdentifier, roles]) => [ entityIdentifier, [...roles], ]), ); } private getFirstEntityRole( entityRoles: Map, entityIdentifier: string, ): string | undefined { return entityRoles.get(entityIdentifier)?.[0]; } private deriveRoles( inputs: WalletHistoryInput[], outputs: WalletHistoryOutput[], ): string[] { const roles = new Set(); for (const input of inputs) { if (input.role) roles.add(input.role); } for (const output of outputs) { if (output.role) roles.add(output.role); } return roles.size > 0 ? [...roles] : ["unknown"]; } private calculateValueSatoshis( inputs: WalletHistoryInput[], outputs: WalletHistoryOutput[], ): bigint { const inputTotal = inputs.reduce((total, input) => total + (input.valueSatoshis ?? 0n), 0n); const outputTotal = outputs.reduce((total, output) => total + (output.valueSatoshis ?? 0n), 0n); return inputTotal + outputTotal; } private describeInvitation(context: InvitationContext, role?: string): string { const invitation = context.invitation.data; const template = context.template; if (!template) return invitation.actionIdentifier; const action = template.actions?.[invitation.actionIdentifier]; const transaction = action?.transaction ? template.transactions?.[action.transaction] : undefined; const roleData = role ? transaction?.roles?.[role] : undefined; const descriptionTemplate = roleData?.description ?? transaction?.description ?? roleData?.name ?? transaction?.name ?? action?.description ?? action?.name ?? invitation.actionIdentifier; return this.compileDescription(descriptionTemplate, context.variables); } private describeInput(inputIdentifier: string | undefined, context: InvitationContext): string { if (!inputIdentifier) return "Input"; const input = context.template?.inputs?.[inputIdentifier]; return this.compileDescription(input?.description ?? input?.name ?? inputIdentifier, context.variables); } private describeOutput(outputIdentifier: string | undefined, context: InvitationContext): string { return this.describeOutputFromTemplate(outputIdentifier, context.template, context.variables); } private describeOutputFromTemplate( outputIdentifier: string | undefined, template: XOTemplate | null, variables: Record, ): string { if (!outputIdentifier) return "Output"; const output = template?.outputs?.[outputIdentifier]; return this.compileDescription(output?.description ?? output?.name ?? outputIdentifier, variables); } private compileDescription( description: string, variables: Record, ): string { try { return compileCashAssemblyString(description, variables); } catch { return this.interpolateSimpleCashAssemblyVariables(description, variables); } } 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 getInputTxid(input: XOInvitationInput): string | undefined { if (!input.outpointTransactionHash) return undefined; return input.outpointTransactionHash instanceof Uint8Array ? binToHex(input.outpointTransactionHash) : String(input.outpointTransactionHash); } private getOutputLockingBytecodeHex(output: XOInvitationOutput): string | undefined { if (output.lockingBytecode === undefined) return undefined; return typeof output.lockingBytecode === "string" ? output.lockingBytecode : binToHex(output.lockingBytecode); } private getOutpointKey(txid: string, index: number): string { return `${txid}:${index}`; } private getUtxoId(utxo: UnspentOutputData): string { return `utxo-${utxo.outpointTransactionHash}:${utxo.outpointIndex}`; } private lockingBytecodeToScriptHash(lockingBytecode: string): string { const hash = sha256.hash(hexToBin(lockingBytecode)); return binToHex(hash.reverse()); } 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]); }, ); } }