Files
xo-cli/src/services/history.ts
2026-05-16 05:59:55 +00:00

622 lines
22 KiB
TypeScript

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<string, XOInvitationVariableValue>;
}
interface UtxoContext {
utxo: UnspentOutputData;
scriptHashData?: ScriptHashData;
template: XOTemplate | null;
}
interface WalletMetadataIndex {
scriptHashDataByScriptHash: Map<string, ScriptHashData>;
}
/*
* 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<WalletHistoryItem[]> {
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<string, UtxoContext[]>();
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<Map<string, InvitationContext>> {
const contexts = new Map<string, InvitationContext>();
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<WalletMetadataIndex> {
const scriptHashDataByScriptHash = new Map<string, ScriptHashData>();
const templateIdentifiers = new Set<string>();
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<UtxoContext> {
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<string, string[]>,
): 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<string, string[]>,
inputUtxoIds: Set<string>,
): WalletHistoryOutput[] {
const invitation = context.invitation.data;
const outputs: WalletHistoryOutput[] = [];
const usedUtxoIds = new Set<string>();
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<string> {
const invitationInputUtxoIds = new Set<string>();
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<string>,
): 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<string, string[]> {
const invitation = context.invitation.data;
const rolesByEntity = new Map<string, Set<string>>();
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<string>();
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<string>();
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<string, string[]>,
entityIdentifier: string,
): string | undefined {
return entityRoles.get(entityIdentifier)?.[0];
}
private deriveRoles(
inputs: WalletHistoryInput[],
outputs: WalletHistoryOutput[],
): string[] {
const roles = new Set<string>();
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, XOInvitationVariableValue>,
): 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, XOInvitationVariableValue>,
): string {
try {
return compileCashAssemblyString(description, variables);
} catch {
return this.interpolateSimpleCashAssemblyVariables(description, variables);
}
}
private extractInvitationVariables(
invitation: XOInvitation,
): Record<string, XOInvitationVariableValue> {
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<string, XOInvitationVariableValue>,
);
}
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, XOInvitationVariableValue>,
): string {
return text.replace(
/\$\(<([^>]+)>\)/g,
(match, variableIdentifier: string) => {
if (!Object.prototype.hasOwnProperty.call(variables, variableIdentifier)) {
return match;
}
return String(variables[variableIdentifier]);
},
);
}
}