809 lines
26 KiB
TypeScript
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]);
|
|
},
|
|
);
|
|
}
|
|
}
|