Files
xo-cli/src/services/history.ts

809 lines
26 KiB
TypeScript

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<string, XOInvitationVariableValue>;
walletEntityIdentifier?: string;
}
interface UtxoOriginContext {
invitationIdentifier: string;
roleIdentifier?: string;
}
export class HistoryService {
constructor(
private engine: Engine,
private invitations: Invitation[],
) {}
async extractEntities(invitation: XOInvitation): Promise<string[]> {
const entities = new Set<string>();
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<entityId: string, roles: string[]>.
// 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<entityId: string, roles: string[]>.
async matchRolesToEntities(
invitation: XOInvitation,
entities: string[],
): Promise<Record<string, string[]>> {
const entitiesMap = new Map<string, Set<string>>();
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<string, string[]> = {};
for (const [entity, roles] of entitiesMap.entries()) {
entitiesRecord[entity] = Array.from(roles);
}
return entitiesRecord;
}
async getHistory(): Promise<HistoryItem[]> {
const allUtxos = await this.engine.listUnspentOutputsData();
const ownOutpoints = new Set<string>();
const ownLockingBytecodes = new Set<string>();
const invitationByOrigin = new Map<string, UtxoOriginContext>();
const outpointValueSatoshis = new Map<string, bigint>();
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<string, InvitationContext>();
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<string>();
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<string, UtxoOriginContext>,
usedUtxoIds: Set<string>,
): 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<string, bigint> = 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<string>();
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<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 indexInvitationOutputsByUtxoOrigin(
invitationByUtxoOrigin: Map<string, UtxoOriginContext>,
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, UtxoOriginContext>,
): 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, UtxoOriginContext>,
): string | undefined {
const originKey = this.getUtxoOriginKey(
utxo.templateIdentifier,
utxo.outputIdentifier,
utxo.lockingBytecode,
);
return invitationByUtxoOrigin.get(originKey)?.roleIdentifier;
}
private resolveWalletEntityIdentifier(
invitation: Invitation,
ownUtxoOutpointKeys: Set<string>,
ownLockingBytecodes: Set<string>,
): string | undefined {
const scores = new Map<string, number>();
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<string, XOInvitationVariableValue>,
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<string, XOInvitationVariableValue>,
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, XOInvitationVariableValue>,
): 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<string, XOInvitationVariableValue>,
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<string>();
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<string, bigint>,
variables: Record<string, XOInvitationVariableValue>,
): 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, XOInvitationVariableValue>,
): string {
return text.replace(
/\$\(<([^>]+)>\)/g,
(match, variableIdentifier: string) => {
if (
!Object.prototype.hasOwnProperty.call(variables, variableIdentifier)
)
return match;
return String(variables[variableIdentifier]);
},
);
}
}