Fix receive and send

This commit is contained in:
2026-03-16 06:48:29 +00:00
parent 9ef1720e1f
commit dd275593cd
28 changed files with 1918 additions and 769 deletions

View File

@@ -1,247 +1,582 @@
/**
* History Service - Derives wallet history from invitations and UTXOs.
*
* Provides a unified view of wallet activity including:
* - UTXO reservations (from invitation commits that reference our UTXOs as inputs)
* - UTXOs we own (with descriptions derived from template outputs)
*/
import { type Engine, compileCashAssemblyString } from '@xo-cash/engine';
import type { XOInvitation, XOInvitationVariableValue, XOTemplate } from '@xo-cash/types';
import type { UnspentOutputData } from '@xo-cash/state';
import type { Invitation } from './invitation.js';
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';
/**
* Types of history events.
*/
export type HistoryItemType =
| 'utxo_received'
| 'utxo_reserved'
| 'invitation_created';
export type HistoryEntryKind = 'invitation' | 'utxo';
/**
* A single item in the wallet history.
*/
export interface HistoryItem {
/** Unique identifier for this history item. */
id: string;
/** Unix timestamp of when the event occurred (if available). */
timestamp?: number;
/** The type of history event. */
type: HistoryItemType;
/** Human-readable description derived from the template. */
export interface HistoryDescriptionParts {
template: string;
role: string;
outputIdentifier: string;
description: string;
/** The value in satoshis (for UTXO-related events). */
valueSatoshis?: bigint;
/** The invitation identifier this event relates to (if applicable). */
valueSatoshis?: number;
}
export interface HistoryUtxoItem {
kind: 'utxo';
id: string;
invitationIdentifier?: string;
/** The template identifier for reference. */
templateIdentifier?: string;
/** The UTXO outpoint (for UTXO-related events). */
outpoint?: {
templateIdentifier: string;
outputIdentifier: string;
outpoint: {
txid: string;
index: number;
};
/** Whether this UTXO is reserved. */
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;
}
/**
* Service for deriving wallet history from invitations and UTXOs.
*
* This service takes the engine and invitations array as dependencies
* and derives history events from them. Since invitations is passed
* by reference, getHistory() always sees the current data.
*/
export class HistoryService {
/**
* Creates a new HistoryService.
*
* @param engine - The XO engine instance for querying UTXOs and templates.
* @param invitations - The array of invitations to derive history from.
*/
constructor(
private engine: Engine,
private invitations: Invitation[]
) {}
/**
* Gets the wallet history derived from invitations and UTXOs.
*
* @returns Array of history items sorted by timestamp (newest first), then UTXOs without timestamps.
*/
async getHistory(): Promise<HistoryItem[]> {
const items: HistoryItem[] = [];
// 1. Get all our UTXOs
const allUtxos = await this.engine.listUnspentOutputsData();
// Create a map for quick UTXO lookup by outpoint
const utxoMap = new Map<string, UnspentOutputData>();
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 key = `${utxo.outpointTransactionHash}:${utxo.outpointIndex}`;
utxoMap.set(key, utxo);
const outpointKey = this.getOutpointKey(utxo.outpointTransactionHash, utxo.outpointIndex);
ownOutpoints.add(outpointKey);
ownLockingBytecodes.add(utxo.lockingBytecode);
outpointValueSatoshis.set(outpointKey, BigInt(utxo.valueSatoshis));
}
// 2. Process invitations to find UTXO reservations from commits
const contexts = new Map<string, InvitationContext>();
for (const invitation of this.invitations) {
const invData = invitation.data;
// Add invitation created event
const template = await this.engine.getTemplate(invData.templateIdentifier);
const invDescription = template
? this.deriveInvitationDescription(invData, template)
: 'Unknown action';
items.push({
id: `inv-${invData.invitationIdentifier}`,
timestamp: invData.createdAtTimestamp,
type: 'invitation_created',
description: invDescription,
invitationIdentifier: invData.invitationIdentifier,
templateIdentifier: invData.templateIdentifier,
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,
});
// Check each commit for inputs that reference our UTXOs
for (const commit of invData.commits) {
const commitInputs = commit.data.inputs ?? [];
for (const input of commitInputs) {
// Input's outpointTransactionHash could be Uint8Array or string
const txHash = input.outpointTransactionHash
? (input.outpointTransactionHash instanceof Uint8Array
? binToHex(input.outpointTransactionHash)
: String(input.outpointTransactionHash))
: undefined;
if (!txHash || input.outpointIndex === undefined) continue;
const utxoKey = `${txHash}:${input.outpointIndex}`;
const matchingUtxo = utxoMap.get(utxoKey);
// If this input references one of our UTXOs, it's a reservation event
if (matchingUtxo) {
const utxoTemplate = await this.engine.getTemplate(matchingUtxo.templateIdentifier);
const utxoDescription = utxoTemplate
? this.deriveUtxoDescription(matchingUtxo, utxoTemplate)
: 'Unknown UTXO';
items.push({
id: `reserved-${commit.commitIdentifier}-${utxoKey}`,
timestamp: invData.createdAtTimestamp, // Use invitation timestamp as proxy
type: 'utxo_reserved',
description: `Reserved for: ${invDescription}`,
valueSatoshis: BigInt(matchingUtxo.valueSatoshis),
invitationIdentifier: invData.invitationIdentifier,
templateIdentifier: matchingUtxo.templateIdentifier,
outpoint: {
txid: txHash,
index: input.outpointIndex,
},
reserved: true,
});
}
this.indexInvitationOutputsByUtxoOrigin(invitationByOrigin, invitation);
}
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,
},
};
}
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);
}
}
}
// 3. Add all UTXOs as "received" events (without timestamps)
for (const utxo of allUtxos) {
const template = await this.engine.getTemplate(utxo.templateIdentifier);
const description = template
? this.deriveUtxoDescription(utxo, template)
: 'Unknown output';
items.push({
id: `utxo-${utxo.outpointTransactionHash}:${utxo.outpointIndex}`,
// No timestamp available for UTXOs
type: 'utxo_received',
description,
valueSatoshis: BigInt(utxo.valueSatoshis),
templateIdentifier: utxo.templateIdentifier,
outpoint: {
txid: utxo.outpointTransactionHash,
index: utxo.outpointIndex,
},
reserved: utxo.reserved,
invitationIdentifier: utxo.invitationIdentifier || undefined,
});
}
// Sort: items with timestamps first (newest first), then items without timestamps
return items.sort((a, b) => {
// Both have timestamps: sort by timestamp descending
if (a.timestamp !== undefined && b.timestamp !== undefined) {
return b.timestamp - a.timestamp;
let bestEntity: string | undefined;
let bestScore = 0;
for (const [ entity, score ] of scores.entries()) {
if (score > bestScore) {
bestScore = score;
bestEntity = entity;
}
// Only a has timestamp: a comes first
if (a.timestamp !== undefined) return -1;
// Only b has timestamp: b comes first
if (b.timestamp !== undefined) return 1;
// Neither has timestamp: maintain order
return 0;
});
}
return bestEntity;
}
/**
* Derives a human-readable description for a UTXO from its template output definition.
*
* @param utxo - The UTXO data.
* @param template - The template definition.
* @returns Human-readable description string.
*/
private deriveUtxoDescription(utxo: UnspentOutputData, template: XOTemplate): string {
const outputDef = template.outputs?.[utxo.outputIdentifier];
if (!outputDef) {
return `[${template.name}] ${utxo.outputIdentifier} output`;
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);
}
}
// Start with the output name or identifier
let description = outputDef.name || utxo.outputIdentifier;
// If there's a description, parse it and replace variable placeholders
if (outputDef.description) {
description = compileCashAssemblyString(outputDef.description, {})
}
return description;
return `[${templateName}:${role}] ${detail}`;
}
/**
* Derives a human-readable description from an invitation and its template.
* Parses the transaction description and replaces variable placeholders.
*
* @param invitation - The invitation data.
* @param template - The template definition.
* @returns Human-readable description string.
*/
private deriveInvitationDescription(invitation: XOInvitation, template: XOTemplate): string {
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;
if (!transaction?.description) {
return action?.name ?? invitation.actionIdentifier;
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);
}
const committedVariables = invitation.commits.flatMap(c => c.data.variables ?? []);
const formattedVariables = committedVariables.reduce((acc, v) => {
acc[v.variableIdentifier ?? ''] = v.value;
return acc;
}, {} as Record<string, XOInvitationVariableValue>);
return `[${template.name}:${role}] ${detail}`;
}
const description = compileCashAssemblyString(transaction.description, formattedVariables);
return description;
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]);
});
}
}