Fix receive and send
This commit is contained in:
@@ -10,6 +10,7 @@ import { Invitation } from './invitation.js';
|
||||
import { Storage } from './storage.js';
|
||||
import { SyncServer } from '../utils/sync-server.js';
|
||||
import { HistoryService } from './history.js';
|
||||
import { ElectrumService } from './electrum.js';
|
||||
|
||||
import { EventEmitter } from '../utils/event-emitter.js';
|
||||
|
||||
@@ -26,6 +27,8 @@ export interface AppConfig {
|
||||
syncServerUrl: string;
|
||||
engineConfig: XOEngineOptions;
|
||||
invitationStoragePath: string;
|
||||
electrumHost?: string;
|
||||
electrumApplicationIdentifier?: string;
|
||||
}
|
||||
|
||||
export class AppService extends EventEmitter<AppEventMap> {
|
||||
@@ -33,6 +36,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
public storage: Storage;
|
||||
public config: AppConfig;
|
||||
public history: HistoryService;
|
||||
public electrum: ElectrumService;
|
||||
|
||||
public invitations: Invitation[] = [];
|
||||
|
||||
@@ -68,15 +72,21 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
const walletStorage = await storage.child(seedHash.slice(0, 8))
|
||||
|
||||
// Create the app service
|
||||
return new AppService(engine, walletStorage, config);
|
||||
const electrum = new ElectrumService({
|
||||
host: config.electrumHost,
|
||||
applicationIdentifier: config.electrumApplicationIdentifier,
|
||||
});
|
||||
|
||||
return new AppService(engine, walletStorage, config, electrum);
|
||||
}
|
||||
|
||||
constructor(engine: Engine, storage: Storage, config: AppConfig) {
|
||||
constructor(engine: Engine, storage: Storage, config: AppConfig, electrum: ElectrumService) {
|
||||
super();
|
||||
|
||||
this.engine = engine;
|
||||
this.storage = storage;
|
||||
this.config = config;
|
||||
this.electrum = electrum;
|
||||
this.history = new HistoryService(engine, this.invitations);
|
||||
}
|
||||
|
||||
@@ -89,6 +99,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
engine: this.engine,
|
||||
syncServer: invitationSyncServer,
|
||||
storage: invitationStorage,
|
||||
electrum: this.electrum,
|
||||
};
|
||||
|
||||
// Create the invitation
|
||||
|
||||
46
src/services/electrum.ts
Normal file
46
src/services/electrum.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { fetchTransactionBlockHeight, initializeElectrumClient } from '@electrum-cash/protocol';
|
||||
|
||||
export interface ElectrumServiceConfig {
|
||||
host?: string;
|
||||
applicationIdentifier?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Small Electrum adapter used by CLI services.
|
||||
* Keeps connection logic in one place and exposes a tiny API.
|
||||
*/
|
||||
export class ElectrumService {
|
||||
private readonly host: string;
|
||||
private readonly applicationIdentifier: string;
|
||||
private clientPromise?: ReturnType<typeof initializeElectrumClient>;
|
||||
|
||||
constructor(config: ElectrumServiceConfig = {}) {
|
||||
this.host = config.host ?? process.env['ELECTRUM_HOST'] ?? 'bch.imaginary.cash';
|
||||
this.applicationIdentifier = 'xo-cli';
|
||||
}
|
||||
|
||||
private async getClient() {
|
||||
if (!this.clientPromise) {
|
||||
this.clientPromise = initializeElectrumClient(this.applicationIdentifier, this.host);
|
||||
}
|
||||
|
||||
return this.clientPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the transaction is known by Electrum
|
||||
* (confirmed or currently in mempool).
|
||||
*/
|
||||
async hasSeenTransaction(transactionHash: string): Promise<boolean> {
|
||||
try {
|
||||
const client = await this.getClient();
|
||||
const height = await fetchTransactionBlockHeight(client, transactionHash);
|
||||
|
||||
// Electrum returns numbers for known transactions
|
||||
// (e.g. >0 confirmed, 0/-1 unconfirmed variants).
|
||||
return typeof height === 'number';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { AcceptInvitationParameters, AppendInvitationParameters, Engine, FindSuitableResourcesParameters } from '@xo-cash/engine';
|
||||
import { hasInvitationExpired } from '@xo-cash/engine';
|
||||
import { hasInvitationExpired, mergeInvitationCommits } from '@xo-cash/engine';
|
||||
import type { XOInvitation, XOInvitationCommit, XOInvitationInput, XOInvitationOutput, XOInvitationVariable, XOInvitationVariableValue } from '@xo-cash/types';
|
||||
import type { UnspentOutputData } from '@xo-cash/state';
|
||||
import { binToHex, encodeTransaction, generateTransaction, hashTransaction, hexToBin } from '@bitauth/libauth';
|
||||
|
||||
import type { SSEvent } from '../utils/sse-client.js';
|
||||
import type { SyncServer } from '../utils/sync-server.js';
|
||||
import type { Storage } from './storage.js';
|
||||
import type { ElectrumService } from './electrum.js';
|
||||
|
||||
import { EventEmitter } from '../utils/event-emitter.js'
|
||||
import { decodeExtendedJsonObject } from '../utils/ext-json.js';
|
||||
@@ -20,6 +22,7 @@ export type InvitationDependencies = {
|
||||
syncServer: SyncServer;
|
||||
storage: Storage;
|
||||
engine: Engine;
|
||||
electrum: ElectrumService;
|
||||
}
|
||||
|
||||
export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
@@ -87,16 +90,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
* TODO: This should be a composite with the sync server (probably. We currently double handle this work, which is stupid)
|
||||
*/
|
||||
private storage: Storage;
|
||||
|
||||
/**
|
||||
* True after we have successfully called sign() on this invitation (session-only, not persisted).
|
||||
*/
|
||||
private _weHaveSigned = false;
|
||||
|
||||
/**
|
||||
* True after we have successfully called broadcast() on this invitation (session-only, not persisted).
|
||||
*/
|
||||
private _broadcasted = false;
|
||||
private electrum: ElectrumService;
|
||||
|
||||
/**
|
||||
* The status of the invitation (last emitted word: pending, actionable, signed, ready, complete, expired, unknown).
|
||||
@@ -116,6 +110,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
this.engine = dependencies.engine;
|
||||
this.syncServer = dependencies.syncServer;
|
||||
this.storage = dependencies.storage;
|
||||
this.electrum = dependencies.electrum;
|
||||
|
||||
// I cannot express this enough, but the event handler does not need a clean up.
|
||||
// There is this beautiful thing called a "garbage collector". Once this class is removed from scope (removed from the invitations array) all the references
|
||||
@@ -217,21 +212,14 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
/**
|
||||
* Internal status computation: returns a single word.
|
||||
* NOTE: This could be a Enum-like object as well. May be a nice improvement. - DO NOT USE TS ENUM, THEY ARENT NATIVELY SUPPORTED IN NODE.JS
|
||||
* - expired: any commit has expired
|
||||
* - complete: we have broadcast this invitation
|
||||
* - expired: any commit has expired
|
||||
* - ready: no missing requirements and we have signed (ready to broadcast)
|
||||
* - signed: we have signed but there are still missing parts (waiting for others)
|
||||
* - actionable: you can provide data (missing requirements and/or you can sign)
|
||||
* - unknown: template/action not found or error
|
||||
*/
|
||||
private async computeStatusInternal(): Promise<string> {
|
||||
if (hasInvitationExpired(this.data)) {
|
||||
return 'expired';
|
||||
}
|
||||
if (this._broadcasted) {
|
||||
return 'complete';
|
||||
}
|
||||
|
||||
let missingReqs;
|
||||
try {
|
||||
missingReqs = await this.engine.listMissingRequirements(this.data);
|
||||
@@ -245,15 +233,74 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
(missingReqs.outputs?.length ?? 0) > 0 ||
|
||||
(missingReqs.roles !== undefined && Object.keys(missingReqs.roles).length > 0);
|
||||
|
||||
if (!hasMissing && this._weHaveSigned) {
|
||||
const hasSignedCommit = this.hasSignedCommitInInvitation();
|
||||
|
||||
if (!hasMissing) {
|
||||
const transactionHash = await this.deriveTransactionHash();
|
||||
if (transactionHash && await this.electrum.hasSeenTransaction(transactionHash)) {
|
||||
return 'complete';
|
||||
}
|
||||
}
|
||||
|
||||
if (hasInvitationExpired(this.data)) {
|
||||
return 'expired';
|
||||
}
|
||||
|
||||
if (!hasMissing && hasSignedCommit) {
|
||||
return 'ready';
|
||||
}
|
||||
if (hasMissing && this._weHaveSigned) {
|
||||
if (hasMissing && hasSignedCommit) {
|
||||
return 'signed';
|
||||
}
|
||||
return 'actionable';
|
||||
}
|
||||
|
||||
private hasSignedCommitInInvitation(): boolean {
|
||||
for (const commit of this.data.commits) {
|
||||
for (const input of commit.data.inputs ?? []) {
|
||||
if (!input.mergesWith) continue;
|
||||
if (input.unlockingBytecode === undefined) continue;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the transaction to get the TX hash, this is so we can check its status on the blockchain.
|
||||
* TODO: Remove this. This should be part of the engine. The code is virtually identical to `executeAction` except it doesnt throw if the invitation is expired
|
||||
* @returns txHash or undefined if the transaction could not be built
|
||||
*/
|
||||
private async deriveTransactionHash(): Promise<string | undefined> {
|
||||
try {
|
||||
const template = await this.engine.getTemplate(this.data.templateIdentifier);
|
||||
if (!template) return undefined;
|
||||
|
||||
const mergedCommit = mergeInvitationCommits(this.data, template);
|
||||
if (!mergedCommit) return undefined;
|
||||
|
||||
const transactionResult = generateTransaction({
|
||||
version: mergedCommit.transactionVersion,
|
||||
locktime: mergedCommit.transactionLocktime,
|
||||
// @ts-expect-error merged inputs include additional invitation metadata.
|
||||
inputs: mergedCommit.inputs,
|
||||
// @ts-expect-error merged outputs include additional invitation metadata.
|
||||
outputs: mergedCommit.outputs,
|
||||
});
|
||||
|
||||
if (!transactionResult.success) return undefined;
|
||||
|
||||
const transactionHex = binToHex(encodeTransaction(transactionResult.transaction));
|
||||
const rawHash: unknown = hashTransaction(hexToBin(transactionHex));
|
||||
if (typeof rawHash === 'string') return rawHash;
|
||||
if (rawHash instanceof Uint8Array) return binToHex(rawHash);
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status of the invitation and emit the new single-word status.
|
||||
*/
|
||||
@@ -291,7 +338,6 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
await this.storage.set(this.data.invitationIdentifier, signedInvitation);
|
||||
|
||||
this.data = signedInvitation;
|
||||
this._weHaveSigned = true;
|
||||
|
||||
// Update the status of the invitation
|
||||
await this.updateStatus();
|
||||
@@ -306,8 +352,6 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
broadcastTransaction: true,
|
||||
});
|
||||
|
||||
this._broadcasted = true;
|
||||
|
||||
// Update the status of the invitation
|
||||
await this.updateStatus();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user