Fix receive and send
This commit is contained in:
@@ -76,6 +76,8 @@ export class App {
|
||||
|
||||
// Wait for the app to exit
|
||||
await this.inkInstance.waitUntilExit();
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -137,7 +137,11 @@ function MainContent(): React.ReactElement {
|
||||
if (dialog?.visible) return;
|
||||
|
||||
// Quit on 'q' or Ctrl+C
|
||||
if (input === 'q' || (key.ctrl && input === 'c')) {
|
||||
if (
|
||||
// Commenting out 'q'. Its annoying me - It activates in text inputs.
|
||||
// input === 'q'
|
||||
(key.ctrl && input === 'c')
|
||||
) {
|
||||
appContext.exit();
|
||||
exit();
|
||||
}
|
||||
@@ -179,8 +183,8 @@ function MainContent(): React.ReactElement {
|
||||
export function App({ config }: AppProps): React.ReactElement {
|
||||
const { exit } = useApp();
|
||||
|
||||
// Cleanup will be handled by React when components unmount
|
||||
const handleExit = () => {
|
||||
// Cleanup will be handled by React when components unmount
|
||||
exit();
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Box, Text, useInput, measureElement } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
import TextInput from './TextInput.js';
|
||||
import { colors } from '../theme.js';
|
||||
|
||||
/**
|
||||
@@ -29,6 +29,7 @@ export function DialogWrapper({
|
||||
borderColor = colors.primary,
|
||||
children,
|
||||
width = 60,
|
||||
backgroundColor = colors.bg,
|
||||
}: DialogWrapperProps): React.ReactElement {
|
||||
const ref = useRef<any>(null);
|
||||
const [height, setHeight] = useState<number | null>(null);
|
||||
@@ -51,9 +52,12 @@ export function DialogWrapper({
|
||||
flexDirection="column"
|
||||
width={width}
|
||||
height={height}
|
||||
backgroundColor={backgroundColor}
|
||||
>
|
||||
{Array.from({ length: height }).map((_, i) => (
|
||||
<Text key={i}>{' '.repeat(width)}</Text>
|
||||
<Text key={i} backgroundColor={backgroundColor}>
|
||||
{' '.repeat(width)}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
@@ -67,6 +71,7 @@ export function DialogWrapper({
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
width={width}
|
||||
backgroundColor={backgroundColor}
|
||||
>
|
||||
<Text color={borderColor} bold>
|
||||
{title}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
import TextInput from './TextInput.js';
|
||||
import { colors } from '../theme.js';
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
import TextInput from './TextInput.js';
|
||||
import { colors } from '../theme.js';
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -116,9 +116,15 @@ interface LoadingProps {
|
||||
}
|
||||
|
||||
export function Loading({ message = 'Loading...' }: LoadingProps): React.ReactElement {
|
||||
// Simple spinner using Ink's spinner component
|
||||
const Spinner = require('ink-spinner').default;
|
||||
|
||||
|
||||
// Was using ink-spinner, but its not updated for react 19.
|
||||
// Just putting nothing here for now
|
||||
const Spinner = (props: any) => {
|
||||
return (
|
||||
<></>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={colors.primary}>
|
||||
|
||||
216
src/tui/components/TextInput.tsx
Normal file
216
src/tui/components/TextInput.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import React, {useState, useEffect} from 'react';
|
||||
import {Text, useInput} from 'ink';
|
||||
import chalk from 'chalk';
|
||||
import type {Except} from 'type-fest';
|
||||
|
||||
export type Props = {
|
||||
/**
|
||||
* Text to display when `value` is empty.
|
||||
*/
|
||||
readonly placeholder?: string;
|
||||
|
||||
/**
|
||||
* Listen to user's input. Useful in case there are multiple input components
|
||||
* at the same time and input must be "routed" to a specific component.
|
||||
*/
|
||||
readonly focus?: boolean; // eslint-disable-line react/boolean-prop-naming
|
||||
|
||||
/**
|
||||
* Replace all chars and mask the value. Useful for password inputs.
|
||||
*/
|
||||
readonly mask?: string;
|
||||
|
||||
/**
|
||||
* Whether to show cursor and allow navigation inside text input with arrow keys.
|
||||
*/
|
||||
readonly showCursor?: boolean; // eslint-disable-line react/boolean-prop-naming
|
||||
|
||||
/**
|
||||
* Highlight pasted text
|
||||
*/
|
||||
readonly highlightPastedText?: boolean; // eslint-disable-line react/boolean-prop-naming
|
||||
|
||||
/**
|
||||
* Value to display in a text input.
|
||||
*/
|
||||
readonly value: string;
|
||||
|
||||
/**
|
||||
* Function to call when value updates.
|
||||
*/
|
||||
readonly onChange: (value: string) => void;
|
||||
|
||||
/**
|
||||
* Function to call when `Enter` is pressed, where first argument is a value of the input.
|
||||
*/
|
||||
readonly onSubmit?: (value: string) => void;
|
||||
};
|
||||
|
||||
function TextInput({
|
||||
value: originalValue,
|
||||
placeholder = '',
|
||||
focus = true,
|
||||
mask,
|
||||
highlightPastedText = false,
|
||||
showCursor = true,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const [state, setState] = useState({
|
||||
cursorOffset: (originalValue || '').length,
|
||||
cursorWidth: 0,
|
||||
});
|
||||
|
||||
const {cursorOffset, cursorWidth} = state;
|
||||
|
||||
useEffect(() => {
|
||||
setState(previousState => {
|
||||
if (!focus || !showCursor) {
|
||||
return previousState;
|
||||
}
|
||||
|
||||
const newValue = originalValue || '';
|
||||
|
||||
if (previousState.cursorOffset > newValue.length - 1) {
|
||||
return {
|
||||
cursorOffset: newValue.length,
|
||||
cursorWidth: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return previousState;
|
||||
});
|
||||
}, [originalValue, focus, showCursor]);
|
||||
|
||||
const cursorActualWidth = highlightPastedText ? cursorWidth : 0;
|
||||
|
||||
const value = mask ? mask.repeat(originalValue.length) : originalValue;
|
||||
let renderedValue = value;
|
||||
let renderedPlaceholder = placeholder ? chalk.grey(placeholder) : undefined;
|
||||
|
||||
// Fake mouse cursor, because it's too inconvenient to deal with actual cursor and ansi escapes
|
||||
if (showCursor && focus) {
|
||||
renderedPlaceholder =
|
||||
placeholder.length > 0
|
||||
? chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1))
|
||||
: chalk.inverse(' ');
|
||||
|
||||
renderedValue = value.length > 0 ? '' : chalk.inverse(' ');
|
||||
|
||||
let i = 0;
|
||||
|
||||
for (const char of value) {
|
||||
renderedValue +=
|
||||
i >= cursorOffset - cursorActualWidth && i <= cursorOffset
|
||||
? chalk.inverse(char)
|
||||
: char;
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
if (value.length > 0 && cursorOffset === value.length) {
|
||||
renderedValue += chalk.inverse(' ');
|
||||
}
|
||||
}
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (
|
||||
key.upArrow ||
|
||||
key.downArrow ||
|
||||
(key.ctrl && input === 'c') ||
|
||||
key.tab ||
|
||||
(key.shift && key.tab)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
if (onSubmit) {
|
||||
onSubmit(originalValue);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let nextCursorOffset = cursorOffset;
|
||||
let nextValue = originalValue;
|
||||
let nextCursorWidth = 0;
|
||||
|
||||
if (key.leftArrow) {
|
||||
if (showCursor) {
|
||||
nextCursorOffset--;
|
||||
}
|
||||
} else if (key.rightArrow) {
|
||||
if (showCursor) {
|
||||
nextCursorOffset++;
|
||||
}
|
||||
} else if (key.backspace || key.delete) {
|
||||
if (cursorOffset > 0) {
|
||||
nextValue =
|
||||
originalValue.slice(0, cursorOffset - 1) +
|
||||
originalValue.slice(cursorOffset, originalValue.length);
|
||||
|
||||
nextCursorOffset--;
|
||||
}
|
||||
} else {
|
||||
nextValue =
|
||||
originalValue.slice(0, cursorOffset) +
|
||||
input +
|
||||
originalValue.slice(cursorOffset, originalValue.length);
|
||||
|
||||
nextCursorOffset += input.length;
|
||||
|
||||
if (input.length > 1) {
|
||||
nextCursorWidth = input.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (cursorOffset < 0) {
|
||||
nextCursorOffset = 0;
|
||||
}
|
||||
|
||||
if (cursorOffset > originalValue.length) {
|
||||
nextCursorOffset = originalValue.length;
|
||||
}
|
||||
|
||||
setState({
|
||||
cursorOffset: nextCursorOffset,
|
||||
cursorWidth: nextCursorWidth,
|
||||
});
|
||||
|
||||
if (nextValue !== originalValue) {
|
||||
onChange(nextValue);
|
||||
}
|
||||
},
|
||||
{isActive: focus},
|
||||
);
|
||||
|
||||
return (
|
||||
<Text>
|
||||
{placeholder
|
||||
? value.length > 0
|
||||
? renderedValue
|
||||
: renderedPlaceholder
|
||||
: renderedValue}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextInput;
|
||||
|
||||
type UncontrolledProps = {
|
||||
/**
|
||||
* Initial value.
|
||||
*/
|
||||
readonly initialValue?: string;
|
||||
} & Except<Props, 'value' | 'onChange'>;
|
||||
|
||||
export function UncontrolledTextInput({
|
||||
initialValue = '',
|
||||
...props
|
||||
}: UncontrolledProps) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
|
||||
return <TextInput {...props} value={value} onChange={setValue} />;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import TextInput from "ink-text-input";
|
||||
import { formatSatoshis } from "../theme.js";
|
||||
import TextInput from "./TextInput.js";
|
||||
|
||||
interface VariableInputFieldProps {
|
||||
variable: {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
import TextInput from '../components/TextInput.js';
|
||||
import { Button } from '../components/Button.js';
|
||||
import { useNavigation } from '../hooks/useNavigation.js';
|
||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||
|
||||
@@ -21,10 +21,7 @@ import type { XOTemplate } from '@xo-cash/types';
|
||||
import {
|
||||
formatTemplateListItem,
|
||||
formatActionListItem,
|
||||
deduplicateStartingActions,
|
||||
getTemplateRoles,
|
||||
getRolesForAction,
|
||||
type UniqueStartingAction,
|
||||
} from '../../utils/template-utils.js';
|
||||
|
||||
/**
|
||||
@@ -33,7 +30,7 @@ import {
|
||||
interface TemplateItem {
|
||||
template: XOTemplate;
|
||||
templateIdentifier: string;
|
||||
startingActions: UniqueStartingAction[];
|
||||
availableActions: TemplateActionItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,9 +39,17 @@ interface TemplateItem {
|
||||
type TemplateListItem = ListItemData<TemplateItem>;
|
||||
|
||||
/**
|
||||
* Action list item with UniqueStartingAction value.
|
||||
* Action list item with available action value.
|
||||
*/
|
||||
type ActionListItem = ListItemData<UniqueStartingAction>;
|
||||
type ActionListItem = ListItemData<TemplateActionItem>;
|
||||
|
||||
interface TemplateActionItem {
|
||||
actionIdentifier: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
roles: string[];
|
||||
source: 'starting' | 'next' | 'starting+next';
|
||||
}
|
||||
|
||||
/**
|
||||
* Template List Screen Component.
|
||||
@@ -76,19 +81,90 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
setStatus('Loading templates...');
|
||||
|
||||
const templateList = await appService.engine.listImportedTemplates();
|
||||
const allUtxos = await appService.engine.listUnspentOutputsData();
|
||||
|
||||
const ownedOutputsByTemplate = new Map<string, Set<string>>();
|
||||
for (const utxo of allUtxos) {
|
||||
const existing = ownedOutputsByTemplate.get(utxo.templateIdentifier) ?? new Set<string>();
|
||||
existing.add(utxo.outputIdentifier);
|
||||
ownedOutputsByTemplate.set(utxo.templateIdentifier, existing);
|
||||
}
|
||||
|
||||
const loadedTemplates = await Promise.all(
|
||||
templateList.map(async (template) => {
|
||||
const templateIdentifier = generateTemplateIdentifier(template);
|
||||
const rawStartingActions = await appService.engine.listStartingActions(templateIdentifier);
|
||||
const actionMap = new Map<string, TemplateActionItem>();
|
||||
|
||||
// Use utility function to deduplicate actions
|
||||
const startingActions = deduplicateStartingActions(template, rawStartingActions);
|
||||
for (const startingAction of rawStartingActions) {
|
||||
const existing = actionMap.get(startingAction.action);
|
||||
if (existing) {
|
||||
if (!existing.roles.includes(startingAction.role)) {
|
||||
existing.roles.push(startingAction.role);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const actionDef = template.actions?.[startingAction.action];
|
||||
actionMap.set(startingAction.action, {
|
||||
actionIdentifier: startingAction.action,
|
||||
name: actionDef?.name || startingAction.action,
|
||||
description: actionDef?.description,
|
||||
roles: [startingAction.role],
|
||||
source: 'starting',
|
||||
});
|
||||
}
|
||||
|
||||
const ownedOutputIdentifiers = ownedOutputsByTemplate.get(templateIdentifier) ?? new Set<string>();
|
||||
for (const outputIdentifier of ownedOutputIdentifiers) {
|
||||
const outputDef = template.outputs?.[outputIdentifier];
|
||||
if (!outputDef || typeof outputDef.lockscript !== 'string') continue;
|
||||
|
||||
const lockingScriptDefinition = (template.lockingScripts as Record<string, unknown> | undefined)?.[outputDef.lockscript] as
|
||||
| { roles?: Record<string, { actions?: Array<{ action?: string; role?: string } | string> }> }
|
||||
| undefined;
|
||||
if (!lockingScriptDefinition?.roles) continue;
|
||||
|
||||
for (const [lockscriptRoleId, lockscriptRoleDef] of Object.entries(lockingScriptDefinition.roles)) {
|
||||
for (const actionSpec of lockscriptRoleDef.actions ?? []) {
|
||||
const actionIdentifier = typeof actionSpec === 'string'
|
||||
? actionSpec
|
||||
: actionSpec.action;
|
||||
if (!actionIdentifier) continue;
|
||||
|
||||
const roleIdentifier = typeof actionSpec === 'string'
|
||||
? lockscriptRoleId
|
||||
: (actionSpec.role ?? lockscriptRoleId);
|
||||
|
||||
const existing = actionMap.get(actionIdentifier);
|
||||
if (existing) {
|
||||
if (!existing.roles.includes(roleIdentifier)) {
|
||||
existing.roles.push(roleIdentifier);
|
||||
}
|
||||
if (existing.source === 'starting') {
|
||||
existing.source = 'starting+next';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const actionDef = template.actions?.[actionIdentifier];
|
||||
actionMap.set(actionIdentifier, {
|
||||
actionIdentifier,
|
||||
name: actionDef?.name || actionIdentifier,
|
||||
description: actionDef?.description,
|
||||
roles: [roleIdentifier],
|
||||
source: 'next',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const availableActions = Array.from(actionMap.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return {
|
||||
template,
|
||||
templateIdentifier,
|
||||
startingActions,
|
||||
availableActions,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -111,7 +187,7 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
|
||||
// Get current template and its actions
|
||||
const currentTemplate = templates[selectedTemplateIndex];
|
||||
const currentActions = currentTemplate?.startingActions ?? [];
|
||||
const currentActions = currentTemplate?.availableActions ?? [];
|
||||
|
||||
/**
|
||||
* Build template list items for ScrollableList.
|
||||
@@ -137,12 +213,17 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
const formatted = formatActionListItem(
|
||||
action.actionIdentifier,
|
||||
currentTemplate?.template?.actions?.[action.actionIdentifier],
|
||||
action.roleCount,
|
||||
action.roles.length,
|
||||
index
|
||||
);
|
||||
const sourceSuffix = action.source === 'next'
|
||||
? ' [next]'
|
||||
: action.source === 'starting+next'
|
||||
? ' [start+next]'
|
||||
: '';
|
||||
return {
|
||||
key: action.actionIdentifier,
|
||||
label: formatted.label,
|
||||
label: `${formatted.label}${sourceSuffix}`,
|
||||
description: formatted.description,
|
||||
value: action,
|
||||
hidden: !formatted.isValid,
|
||||
@@ -171,6 +252,7 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
navigate('wizard', {
|
||||
templateIdentifier: currentTemplate.templateIdentifier,
|
||||
actionIdentifier: action.actionIdentifier,
|
||||
actionRoles: action.roles,
|
||||
template: currentTemplate.template,
|
||||
});
|
||||
}, [currentTemplate, navigate]);
|
||||
@@ -267,7 +349,7 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
paddingX={1}
|
||||
flexGrow={1}
|
||||
>
|
||||
<Text color={colors.primary} bold> Starting Actions </Text>
|
||||
<Text color={colors.primary} bold> Available Actions </Text>
|
||||
{isLoading ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>Loading...</Text>
|
||||
@@ -283,7 +365,7 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
onSelect={setSelectedActionIndex}
|
||||
onActivate={handleActionActivate}
|
||||
focus={focusedPanel === 'actions'}
|
||||
emptyMessage="No starting actions available"
|
||||
emptyMessage="No actions available"
|
||||
renderItem={renderActionItem}
|
||||
/>
|
||||
)}
|
||||
@@ -339,9 +421,6 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
const action = currentActions[selectedActionIndex];
|
||||
if (!action) return null;
|
||||
|
||||
// Get roles that can start this action using utility function
|
||||
const availableRoles = getRolesForAction(currentTemplate.template, action.actionIdentifier);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text color={colors.text} bold>
|
||||
@@ -351,16 +430,24 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
{action.description || 'No description available'}
|
||||
</Text>
|
||||
|
||||
{/* List available roles for this action */}
|
||||
{availableRoles.length > 0 && (
|
||||
{/* List roles available for this action in current context */}
|
||||
{action.roles.length > 0 && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={colors.text}>Available Roles:</Text>
|
||||
{availableRoles.map((role) => (
|
||||
<Text key={role.roleId} color={colors.textMuted}>
|
||||
{' '}- {role.name}
|
||||
{role.description ? `: ${role.description}` : ''}
|
||||
</Text>
|
||||
))}
|
||||
{action.roles.map((roleId) => {
|
||||
const roleDef = currentTemplate.template.roles?.[roleId];
|
||||
const roleName = typeof roleDef === 'object' ? roleDef?.name ?? roleId : roleId;
|
||||
const roleDescription = typeof roleDef === 'object' ? roleDef?.description : undefined;
|
||||
return (
|
||||
<Text key={roleId} color={colors.textMuted}>
|
||||
{' '}- {roleName}
|
||||
{roleDescription ? `: ${roleDescription}` : ''}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
<Text color={colors.textMuted}>
|
||||
{' '}Source: {action.source}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
@@ -370,7 +457,7 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
) : focusedPanel === 'actions' && !currentTemplate ? (
|
||||
<Text color={colors.textMuted}>Select a template first</Text>
|
||||
) : focusedPanel === 'actions' && currentActions.length === 0 ? (
|
||||
<Text color={colors.textMuted}>No starting actions available</Text>
|
||||
<Text color={colors.textMuted}>No actions available</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -19,9 +19,10 @@ import { generateTemplateIdentifier } from '@xo-cash/engine';
|
||||
|
||||
// Import utility functions
|
||||
import {
|
||||
formatHistoryListItem,
|
||||
buildHistoryDisplayRows,
|
||||
getHistoryItemColorName,
|
||||
formatHistoryDate,
|
||||
type HistoryDisplayRow,
|
||||
type HistoryColorName,
|
||||
} from '../../utils/history-utils.js';
|
||||
|
||||
@@ -58,9 +59,9 @@ const menuItems: ListItemData<string>[] = [
|
||||
];
|
||||
|
||||
/**
|
||||
* History list item with HistoryItem value.
|
||||
* History list item with display row value.
|
||||
*/
|
||||
type HistoryListItem = ListItemData<HistoryItem>;
|
||||
type HistoryListItem = ListItemData<HistoryDisplayRow>;
|
||||
|
||||
/**
|
||||
* Wallet State Screen Component.
|
||||
@@ -196,15 +197,14 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
* Build history list items for ScrollableList.
|
||||
*/
|
||||
const historyListItems = useMemo((): HistoryListItem[] => {
|
||||
return history.map(item => {
|
||||
const formatted = formatHistoryListItem(item, false);
|
||||
return buildHistoryDisplayRows(history).map(row => {
|
||||
return {
|
||||
key: item.id,
|
||||
label: formatted.label,
|
||||
description: formatted.description,
|
||||
value: item,
|
||||
color: formatted.color,
|
||||
hidden: !formatted.isValid,
|
||||
key: row.id,
|
||||
label: row.label,
|
||||
description: row.description,
|
||||
value: row,
|
||||
color: getHistoryItemColorName(row, false),
|
||||
hidden: false,
|
||||
};
|
||||
});
|
||||
}, [history]);
|
||||
@@ -224,49 +224,63 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
isSelected: boolean,
|
||||
isFocused: boolean
|
||||
): React.ReactNode => {
|
||||
const historyItem = item.value;
|
||||
if (!historyItem) return null;
|
||||
const row = item.value;
|
||||
if (!row) return null;
|
||||
|
||||
const colorName = getHistoryItemColorName(historyItem.type, isFocused);
|
||||
const colorName = getHistoryItemColorName(row, isFocused);
|
||||
const itemColor = isFocused ? colors.focus : getHistoryColor(colorName);
|
||||
const dateStr = formatHistoryDate(historyItem.timestamp);
|
||||
const dateStr = formatHistoryDate(row.timestamp);
|
||||
const indicator = isFocused ? '▸ ' : ' ';
|
||||
const groupingPrefix = row.isNested ? ' -> ' : '';
|
||||
|
||||
// Format based on type
|
||||
if (historyItem.type === 'invitation_created') {
|
||||
if (row.type === 'invitation') {
|
||||
return (
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
<Text color={itemColor}>
|
||||
{indicator}[Invitation] {historyItem.description}
|
||||
{indicator}[Invitation] {row.label}
|
||||
</Text>
|
||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||
</Box>
|
||||
);
|
||||
} else if (historyItem.type === 'utxo_reserved') {
|
||||
const sats = historyItem.valueSatoshis ?? 0n;
|
||||
}
|
||||
|
||||
if (row.type === 'invitation_input') {
|
||||
return (
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
<Box>
|
||||
<Text color={itemColor}>
|
||||
{indicator}[Reserved] {formatSatoshis(sats)}
|
||||
{indicator}{groupingPrefix}[Input] {row.label}
|
||||
</Text>
|
||||
<Text color={colors.textMuted}> {historyItem.description}</Text>
|
||||
{row.description && <Text color={colors.textMuted}> {row.description}</Text>}
|
||||
</Box>
|
||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||
</Box>
|
||||
);
|
||||
} else if (historyItem.type === 'utxo_received') {
|
||||
const sats = historyItem.valueSatoshis ?? 0n;
|
||||
const reservedTag = historyItem.reserved ? ' [Reserved]' : '';
|
||||
}
|
||||
|
||||
if (row.type === 'invitation_output') {
|
||||
const sats = row.utxo?.valueSatoshis ?? 0n;
|
||||
return (
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
<Box flexDirection="row">
|
||||
<Text color={itemColor}>
|
||||
{indicator}{formatSatoshis(sats)}
|
||||
</Text>
|
||||
<Text color={colors.textMuted}>
|
||||
{' '}{historyItem.description}{reservedTag}
|
||||
{indicator}{groupingPrefix}[Output] {formatSatoshis(sats)}
|
||||
</Text>
|
||||
{row.description && <Text color={colors.textMuted}> {row.description}</Text>}
|
||||
</Box>
|
||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (row.type === 'utxo') {
|
||||
const sats = row.utxo?.valueSatoshis ?? 0n;
|
||||
const reservedTag = row.utxo?.reserved ? ' [Reserved]' : '';
|
||||
return (
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
<Box flexDirection="row">
|
||||
<Text color={itemColor}>{indicator}{formatSatoshis(sats)}</Text>
|
||||
{row.description && <Text color={colors.textMuted}> {row.description}{reservedTag}</Text>}
|
||||
</Box>
|
||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||
</Box>
|
||||
@@ -277,7 +291,7 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
return (
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
<Text color={itemColor}>
|
||||
{indicator}{historyItem.type}: {historyItem.description}
|
||||
{indicator}{row.label}
|
||||
</Text>
|
||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||
</Box>
|
||||
|
||||
@@ -205,7 +205,13 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
/>
|
||||
);
|
||||
case 'publish':
|
||||
return <PublishStep invitationId={wizard.invitationId} />;
|
||||
return (
|
||||
<PublishStep
|
||||
invitationId={wizard.invitationId}
|
||||
requirementsComplete={wizard.requirementsComplete}
|
||||
hasSignedAndBroadcasted={wizard.hasSignedAndBroadcasted}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -284,7 +290,9 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
</Box>
|
||||
<Button
|
||||
label={
|
||||
wizard.currentStepData?.type === "publish" ? "Done" : "Next"
|
||||
wizard.currentStepData?.type === "publish"
|
||||
? (wizard.canSignAndBroadcast ? "Sign & Broadcast" : "Done")
|
||||
: "Next"
|
||||
}
|
||||
focused={
|
||||
wizard.focusArea === "buttons" &&
|
||||
|
||||
@@ -4,15 +4,19 @@ import { colors } from '../../../theme.js';
|
||||
|
||||
interface PublishStepProps {
|
||||
invitationId: string | null;
|
||||
requirementsComplete: boolean;
|
||||
hasSignedAndBroadcasted: boolean;
|
||||
}
|
||||
|
||||
export function PublishStep({
|
||||
invitationId,
|
||||
requirementsComplete,
|
||||
hasSignedAndBroadcasted,
|
||||
}: PublishStepProps): React.ReactElement {
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text color={colors.success} bold>
|
||||
✓ Invitation Created & Published!
|
||||
✓ Invitation Ready
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
@@ -30,9 +34,19 @@ export function PublishStep({
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>
|
||||
Share this ID with the other party to complete the transaction.
|
||||
</Text>
|
||||
{hasSignedAndBroadcasted ? (
|
||||
<Text color={colors.success}>
|
||||
Transaction signed and broadcasted.
|
||||
</Text>
|
||||
) : requirementsComplete ? (
|
||||
<Text color={colors.textMuted}>
|
||||
Requirements are complete. Use the Sign & Broadcast button to finalize.
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={colors.warning}>
|
||||
Requirements are incomplete. Complete missing requirements before signing.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
|
||||
@@ -4,6 +4,15 @@ import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
|
||||
import { formatSatoshis } from '../../theme.js';
|
||||
import { copyToClipboard } from '../../utils/clipboard.js';
|
||||
import type { XOTemplate, XOInvitation, XOTemplateTransactionOutput } from '@xo-cash/types';
|
||||
import {
|
||||
autoSelectGreedyUtxos,
|
||||
getTransactionOutputIdentifier,
|
||||
isInvitationRequirementsComplete,
|
||||
mapUnspentOutputsToSelectable,
|
||||
resolveActionRoles,
|
||||
resolveProvidedLockingBytecodeHex,
|
||||
roleRequiresInputs,
|
||||
} from '../../../utils/invitation-flow.js';
|
||||
import type {
|
||||
WizardStep,
|
||||
VariableInput,
|
||||
@@ -22,6 +31,7 @@ export function useActionWizard() {
|
||||
const templateIdentifier = navData.templateIdentifier as string | undefined;
|
||||
const actionIdentifier = navData.actionIdentifier as string | undefined;
|
||||
const template = navData.template as XOTemplate | undefined;
|
||||
const actionRolesFromNavigation = navData.actionRoles as string[] | undefined;
|
||||
|
||||
// ── Role selection state ────────────────────────────────────────
|
||||
const [roleIdentifier, setRoleIdentifier] = useState<string | undefined>();
|
||||
@@ -32,14 +42,20 @@ export function useActionWizard() {
|
||||
* `start` entries filtered to the current action.
|
||||
*/
|
||||
const availableRoles = useMemo(() => {
|
||||
if (!template || !actionIdentifier) return [];
|
||||
const starts = template.start ?? [];
|
||||
const roleIds = starts
|
||||
.filter((s) => s.action === actionIdentifier)
|
||||
.map((s) => s.role);
|
||||
// Deduplicate while preserving order
|
||||
return [...new Set(roleIds)];
|
||||
}, [template, actionIdentifier]);
|
||||
return resolveActionRoles(template, actionIdentifier, actionRolesFromNavigation);
|
||||
}, [template, actionIdentifier, actionRolesFromNavigation]);
|
||||
|
||||
const effectiveRoleForFlow = roleIdentifier ?? (
|
||||
availableRoles.length === 1 ? availableRoles[0] : undefined
|
||||
);
|
||||
|
||||
// Keep role state aligned when only one role exists for the selected action.
|
||||
// This preserves existing UI bindings that read roleIdentifier directly.
|
||||
useEffect(() => {
|
||||
if (!roleIdentifier && availableRoles.length === 1) {
|
||||
setRoleIdentifier(availableRoles[0]);
|
||||
}
|
||||
}, [roleIdentifier, availableRoles]);
|
||||
|
||||
// ── Wizard state ─────────────────────────────────────────────────
|
||||
const [steps, setSteps] = useState<WizardStep[]>([]);
|
||||
@@ -57,6 +73,8 @@ export function useActionWizard() {
|
||||
// ── Invitation ───────────────────────────────────────────────────
|
||||
const [invitation, setInvitation] = useState<XOInvitation | null>(null);
|
||||
const [invitationId, setInvitationId] = useState<string | null>(null);
|
||||
const [requirementsComplete, setRequirementsComplete] = useState(false);
|
||||
const [hasSignedAndBroadcasted, setHasSignedAndBroadcasted] = useState(false);
|
||||
|
||||
// ── UI state ─────────────────────────────────────────────────────
|
||||
const [focusedInput, setFocusedInput] = useState(0);
|
||||
@@ -78,9 +96,19 @@ export function useActionWizard() {
|
||||
const textInputHasFocus =
|
||||
currentStepData?.type === 'variables' && focusArea === 'content';
|
||||
|
||||
// Whether the wizard actually includes an inputs step — this determines if
|
||||
// the creator provided funding and therefore can sign & broadcast locally.
|
||||
const wizardCollectedInputs = steps.some((s) => s.type === 'inputs');
|
||||
|
||||
const canSignAndBroadcast =
|
||||
currentStepData?.type === 'publish'
|
||||
&& wizardCollectedInputs
|
||||
&& requirementsComplete
|
||||
&& !hasSignedAndBroadcasted;
|
||||
|
||||
// ── Initialization ───────────────────────────────────────────────
|
||||
// Builds the wizard steps dynamically based on the selected role.
|
||||
// Re-runs when roleIdentifier changes to add role-specific steps.
|
||||
// Re-runs when role selection changes to add role-specific steps.
|
||||
useEffect(() => {
|
||||
if (!template || !actionIdentifier) {
|
||||
showError('Missing wizard data');
|
||||
@@ -89,14 +117,17 @@ export function useActionWizard() {
|
||||
}
|
||||
|
||||
const wizardSteps: WizardStep[] = [];
|
||||
const shouldShowRoleSelection = availableRoles.length > 1;
|
||||
|
||||
// Always start with role selection
|
||||
wizardSteps.push({ name: 'Select Role', type: 'role-select' });
|
||||
// Only require explicit role selection when the action is actually ambiguous.
|
||||
if (shouldShowRoleSelection) {
|
||||
wizardSteps.push({ name: 'Select Role', type: 'role-select' });
|
||||
}
|
||||
|
||||
// Add role-specific steps only after role is selected
|
||||
if (roleIdentifier) {
|
||||
if (effectiveRoleForFlow) {
|
||||
const act = template.actions?.[actionIdentifier];
|
||||
const role = act?.roles?.[roleIdentifier];
|
||||
const role = act?.roles?.[effectiveRoleForFlow];
|
||||
const requirements = role?.requirements;
|
||||
|
||||
// Add variables step if needed
|
||||
@@ -116,8 +147,23 @@ export function useActionWizard() {
|
||||
setVariables(varInputs);
|
||||
}
|
||||
|
||||
// Add inputs step if role requires slots (funding inputs)
|
||||
if (requirements?.slots && requirements.slots.min > 0) {
|
||||
// Determine whether the creator should provide inputs during this wizard.
|
||||
//
|
||||
// Single-role actions (e.g. "send"): the creator is the sole participant,
|
||||
// so we collect inputs here if the role needs them at all.
|
||||
//
|
||||
// Multi-role actions (e.g. "receive"): the creator is setting up the
|
||||
// invitation for another party to accept. We only collect inputs during
|
||||
// creation if the role EXPLICITLY requires them (slots.min > 0).
|
||||
// Implicit inputs (transaction-level) are assumed to be provided later
|
||||
// by the accepting party.
|
||||
const totalActionRoles = Object.keys(act?.roles ?? {}).length;
|
||||
const isSingleRoleAction = totalActionRoles <= 1;
|
||||
|
||||
const shouldCollectInputs =
|
||||
isSingleRoleAction && roleRequiresInputs(template, actionIdentifier, effectiveRoleForFlow);
|
||||
|
||||
if (shouldCollectInputs) {
|
||||
wizardSteps.push({ name: 'Select UTXOs', type: 'inputs' });
|
||||
}
|
||||
}
|
||||
@@ -127,11 +173,12 @@ export function useActionWizard() {
|
||||
wizardSteps.push({ name: 'Publish', type: 'publish' });
|
||||
|
||||
setSteps(wizardSteps);
|
||||
setStatus(roleIdentifier ? `${actionIdentifier}/${roleIdentifier}` : actionIdentifier);
|
||||
setStatus(effectiveRoleForFlow ? `${actionIdentifier}/${effectiveRoleForFlow}` : actionIdentifier);
|
||||
}, [
|
||||
template,
|
||||
actionIdentifier,
|
||||
roleIdentifier,
|
||||
availableRoles.length,
|
||||
effectiveRoleForFlow,
|
||||
showError,
|
||||
goBack,
|
||||
setStatus,
|
||||
@@ -141,12 +188,12 @@ export function useActionWizard() {
|
||||
// This runs after the main useEffect has rebuilt steps, ensuring
|
||||
// we advance to the correct step (variables, inputs, or review).
|
||||
useEffect(() => {
|
||||
if (roleIdentifier && currentStep === 0 && steps[0]?.type === 'role-select') {
|
||||
if (effectiveRoleForFlow && currentStep === 0 && steps[0]?.type === 'role-select') {
|
||||
setCurrentStep(1);
|
||||
setFocusArea('content');
|
||||
setFocusedInput(0);
|
||||
}
|
||||
}, [roleIdentifier, currentStep, steps]);
|
||||
}, [effectiveRoleForFlow, currentStep, steps]);
|
||||
|
||||
// ── Update a single variable value ───────────────────────────────
|
||||
const updateVariable = useCallback((index: number, value: string) => {
|
||||
@@ -195,6 +242,25 @@ export function useActionWizard() {
|
||||
}
|
||||
}, [invitationId, showInfo, showError]);
|
||||
|
||||
const refreshRequirementState = useCallback(async (identifier: string | null = invitationId) => {
|
||||
if (!identifier || !appService) {
|
||||
setRequirementsComplete(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const invitationInstance = appService.invitations.find(
|
||||
(inv) => inv.data.invitationIdentifier === identifier
|
||||
);
|
||||
if (!invitationInstance) {
|
||||
setRequirementsComplete(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const complete = await isInvitationRequirementsComplete(invitationInstance);
|
||||
setRequirementsComplete(complete);
|
||||
return complete;
|
||||
}, [appService, invitationId]);
|
||||
|
||||
// ── Load available UTXOs for the inputs step ────────────────────
|
||||
const loadAvailableUtxos = useCallback(async () => {
|
||||
if (!invitation || !templateIdentifier || !appService || !invitationId) {
|
||||
@@ -225,49 +291,19 @@ export function useActionWizard() {
|
||||
throw new Error('Invitation not found');
|
||||
}
|
||||
|
||||
// Query for suitable resources
|
||||
// Query for suitable resources.
|
||||
// NOTE: Even for single-role actions we still keep the user in the loop for inputs:
|
||||
// we only surface UTXOs the engine/template currently considers "selectable" and let
|
||||
// the user confirm them in the inputs step. If selectable semantics evolve, revisit here.
|
||||
const unspentOutputs = await invitationInstance.findSuitableResources({
|
||||
templateIdentifier,
|
||||
outputIdentifier: 'receiveOutput',
|
||||
});
|
||||
|
||||
// Map to selectable UTXOs
|
||||
const utxos: SelectableUTXO[] = unspentOutputs.map((utxo: any) => ({
|
||||
outpointTransactionHash: utxo.outpointTransactionHash,
|
||||
outpointIndex: utxo.outpointIndex,
|
||||
valueSatoshis: BigInt(utxo.valueSatoshis),
|
||||
lockingBytecode: utxo.lockingBytecode
|
||||
? typeof utxo.lockingBytecode === 'string'
|
||||
? utxo.lockingBytecode
|
||||
: Buffer.from(utxo.lockingBytecode).toString('hex')
|
||||
: undefined,
|
||||
selected: false,
|
||||
}));
|
||||
|
||||
// Auto-select UTXOs greedily until the requirement is met
|
||||
let accumulated = 0n;
|
||||
const seenLockingBytecodes = new Set<string>();
|
||||
|
||||
for (const utxo of utxos) {
|
||||
if (
|
||||
utxo.lockingBytecode &&
|
||||
seenLockingBytecodes.has(utxo.lockingBytecode)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (utxo.lockingBytecode) {
|
||||
seenLockingBytecodes.add(utxo.lockingBytecode);
|
||||
}
|
||||
|
||||
utxo.selected = true;
|
||||
accumulated += utxo.valueSatoshis;
|
||||
|
||||
if (accumulated >= requested + fee) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setAvailableUtxos(utxos);
|
||||
// Map to selectable UTXOs and pre-select greedily.
|
||||
const mappedUtxos = mapUnspentOutputsToSelectable(unspentOutputs);
|
||||
const autoSelectedUtxos = autoSelectGreedyUtxos(mappedUtxos, requested + fee);
|
||||
setAvailableUtxos(autoSelectedUtxos as SelectableUTXO[]);
|
||||
setStatus('Ready');
|
||||
} catch (error) {
|
||||
showError(
|
||||
@@ -301,7 +337,7 @@ export function useActionWizard() {
|
||||
*/
|
||||
const createInvitationWithVariables = useCallback(
|
||||
async (roleId?: string): Promise<boolean> => {
|
||||
const effectiveRole = roleId ?? roleIdentifier;
|
||||
const effectiveRole = roleId ?? effectiveRoleForFlow;
|
||||
|
||||
if (
|
||||
!templateIdentifier ||
|
||||
@@ -350,6 +386,14 @@ export function useActionWizard() {
|
||||
inv = invitationInstance.data;
|
||||
}
|
||||
|
||||
const variableValuesByIdentifier = variables.reduce((acc, variable) => {
|
||||
if (typeof variable.value === 'string' && variable.value.trim().length > 0) {
|
||||
acc[variable.id] = variable.value;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
// Add template-required outputs for the current role
|
||||
const act = template.actions?.[actionIdentifier];
|
||||
const transaction = act?.transaction
|
||||
@@ -358,17 +402,26 @@ export function useActionWizard() {
|
||||
|
||||
if (transaction?.outputs && transaction.outputs.length > 0) {
|
||||
setStatus('Adding required outputs...');
|
||||
const outputsToAdd = await Promise.all(transaction.outputs.map(async (output: XOTemplateTransactionOutput) => {
|
||||
const outputIdentifier = getTransactionOutputIdentifier(output);
|
||||
if (!outputIdentifier) {
|
||||
throw new Error('Invalid transaction output definition');
|
||||
}
|
||||
|
||||
const outputsToAdd = await Promise.all(transaction.outputs.map(
|
||||
async (output: XOTemplateTransactionOutput) => ({
|
||||
// TODO: Fix this. Currently, there is a type mismatch due to branches/versions of the libraries
|
||||
outputIdentifier: output as unknown as string,
|
||||
// roleIdentifier: roleIdentifier,
|
||||
const providedLockingBytecodeHex = resolveProvidedLockingBytecodeHex(
|
||||
template,
|
||||
outputIdentifier,
|
||||
variableValuesByIdentifier,
|
||||
);
|
||||
|
||||
// TODO: This feels like an odd requirement? Shouldnt this be handled in the engine?
|
||||
lockingBytecode: await invitationInstance.generateLockingBytecode(output as unknown as string, roleIdentifier),
|
||||
})
|
||||
));
|
||||
const lockingBytecodeHex = providedLockingBytecodeHex
|
||||
?? await invitationInstance.generateLockingBytecode(outputIdentifier, effectiveRole);
|
||||
|
||||
return {
|
||||
outputIdentifier,
|
||||
lockingBytecode: lockingBytecodeHex,
|
||||
};
|
||||
}));
|
||||
|
||||
// TODO: Clean this up. Suggestions: 1. Convert to bytes above. 2. Have addOuputs accept a hex string. 3. Have addOutputs handling the lockscript generation
|
||||
await invitationInstance.addOutputs(outputsToAdd.map((output) => ({
|
||||
@@ -381,6 +434,7 @@ export function useActionWizard() {
|
||||
}
|
||||
|
||||
setInvitation(inv);
|
||||
await refreshRequirementState(invId);
|
||||
setStatus('Invitation created');
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -395,15 +449,51 @@ export function useActionWizard() {
|
||||
[
|
||||
templateIdentifier,
|
||||
actionIdentifier,
|
||||
roleIdentifier,
|
||||
effectiveRoleForFlow,
|
||||
template,
|
||||
variables,
|
||||
appService,
|
||||
showError,
|
||||
setStatus,
|
||||
refreshRequirementState,
|
||||
]
|
||||
);
|
||||
|
||||
// Ensure invitation exists before entering input/review/publish stages.
|
||||
useEffect(() => {
|
||||
const ensureInvitation = async () => {
|
||||
if (!currentStepData) return;
|
||||
if (currentStepData.type !== 'inputs' && currentStepData.type !== 'review' && currentStepData.type !== 'publish') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (invitationId) {
|
||||
if (currentStepData.type === 'inputs' && availableUtxos.length === 0 && !isProcessing) {
|
||||
await loadAvailableUtxos();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!effectiveRoleForFlow || isProcessing) return;
|
||||
const success = await createInvitationWithVariables(effectiveRoleForFlow);
|
||||
if (!success) return;
|
||||
|
||||
if (currentStepData.type === 'inputs') {
|
||||
await loadAvailableUtxos();
|
||||
}
|
||||
};
|
||||
|
||||
ensureInvitation().catch(() => {});
|
||||
}, [
|
||||
currentStepData,
|
||||
invitationId,
|
||||
effectiveRoleForFlow,
|
||||
isProcessing,
|
||||
createInvitationWithVariables,
|
||||
loadAvailableUtxos,
|
||||
availableUtxos.length,
|
||||
]);
|
||||
|
||||
// ── Add selected inputs + change output to the invitation ───────
|
||||
const addInputsAndOutputs = useCallback(async () => {
|
||||
if (!invitationId || !invitation || !appService) return;
|
||||
@@ -459,6 +549,7 @@ export function useActionWizard() {
|
||||
];
|
||||
|
||||
await invitationInstance.addOutputs(outputs);
|
||||
await refreshRequirementState(invitationId);
|
||||
|
||||
setCurrentStep((prev) => prev + 1);
|
||||
setStatus('Inputs and outputs added');
|
||||
@@ -480,14 +571,15 @@ export function useActionWizard() {
|
||||
appService,
|
||||
showError,
|
||||
setStatus,
|
||||
refreshRequirementState,
|
||||
]);
|
||||
|
||||
// ── Publish the invitation ──────────────────────────────────────
|
||||
const publishInvitation = useCallback(async () => {
|
||||
// ── Move to publish step ────────────────────────────────────────
|
||||
const advanceToPublishStep = useCallback(async () => {
|
||||
if (!invitationId || !appService) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
setStatus('Publishing invitation...');
|
||||
setStatus('Preparing publish step...');
|
||||
|
||||
try {
|
||||
const invitationInstance = appService.invitations.find(
|
||||
@@ -498,23 +590,61 @@ export function useActionWizard() {
|
||||
throw new Error('Invitation not found');
|
||||
}
|
||||
|
||||
// Already tracked and synced via SSE from createInvitation
|
||||
await refreshRequirementState(invitationId);
|
||||
setCurrentStep((prev) => prev + 1);
|
||||
setStatus('Invitation published');
|
||||
setStatus('Ready to publish');
|
||||
} catch (error) {
|
||||
showError(
|
||||
`Failed to publish: ${error instanceof Error ? error.message : String(error)}`
|
||||
`Failed to prepare publish step: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [invitationId, appService, showError, setStatus]);
|
||||
}, [invitationId, appService, showError, setStatus, refreshRequirementState]);
|
||||
|
||||
// ── Sign and broadcast from publish step ────────────────────────
|
||||
const signAndBroadcastInvitation = useCallback(async () => {
|
||||
if (!invitationId || !appService) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
setStatus('Signing invitation...');
|
||||
|
||||
try {
|
||||
const invitationInstance = appService.invitations.find(
|
||||
(inv) => inv.data.invitationIdentifier === invitationId
|
||||
);
|
||||
if (!invitationInstance) {
|
||||
throw new Error('Invitation not found');
|
||||
}
|
||||
|
||||
const complete = await refreshRequirementState(invitationId);
|
||||
if (!complete) {
|
||||
showError('Invitation requirements are not complete yet.');
|
||||
return;
|
||||
}
|
||||
if (!wizardCollectedInputs) {
|
||||
showError('This action does not require funding inputs, so it cannot be signed and broadcasted here.');
|
||||
return;
|
||||
}
|
||||
|
||||
await invitationInstance.sign();
|
||||
setStatus('Broadcasting transaction...');
|
||||
await invitationInstance.broadcast();
|
||||
setHasSignedAndBroadcasted(true);
|
||||
setStatus('Transaction signed and broadcasted');
|
||||
showInfo('Transaction signed and broadcasted.');
|
||||
await refreshRequirementState(invitationId);
|
||||
} catch (error) {
|
||||
showError(`Failed to sign and broadcast: ${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [invitationId, appService, setStatus, showError, showInfo, refreshRequirementState, wizardCollectedInputs]);
|
||||
|
||||
// ── Navigate to the next step ───────────────────────────────────
|
||||
const nextStep = useCallback(async () => {
|
||||
if (currentStep >= steps.length - 1) return;
|
||||
|
||||
const stepType = currentStepData?.type;
|
||||
if (currentStep >= steps.length - 1 && stepType !== 'publish') return;
|
||||
|
||||
// ── Role selection ──────────────────────────────────────────
|
||||
if (stepType === 'role-select') {
|
||||
@@ -531,7 +661,19 @@ export function useActionWizard() {
|
||||
|
||||
const hasVariables =
|
||||
requirements?.variables && requirements.variables.length > 0;
|
||||
const hasSlots = requirements?.slots && requirements.slots.min > 0;
|
||||
|
||||
// Mirror the inputs-step inference from the step-building effect:
|
||||
// single-role → any inputs; multi-role → explicit requirements only.
|
||||
const totalActionRoles = Object.keys(act?.roles ?? {}).length;
|
||||
const roleExplicitlyNeedsInputs =
|
||||
(requirements?.slots && requirements.slots.min > 0)
|
||||
|| (act?.requirements?.roles?.find(
|
||||
(r: { role: string; slots?: { min?: number } }) => r.role === selectedRole,
|
||||
)?.slots?.min ?? 0) > 0;
|
||||
|
||||
const hasSlots = totalActionRoles <= 1
|
||||
? roleRequiresInputs(template, actionIdentifier, selectedRole)
|
||||
: roleExplicitlyNeedsInputs;
|
||||
|
||||
// If there is no variables step, the invitation must be created now
|
||||
// because the variables step would normally handle it.
|
||||
@@ -582,17 +724,38 @@ export function useActionWizard() {
|
||||
|
||||
// ── Inputs ──────────────────────────────────────────────────
|
||||
if (stepType === 'inputs') {
|
||||
if (!invitationId) {
|
||||
const success = await createInvitationWithVariables();
|
||||
if (!success) return;
|
||||
await loadAvailableUtxos();
|
||||
return;
|
||||
}
|
||||
await addInputsAndOutputs();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Review ──────────────────────────────────────────────────
|
||||
if (stepType === 'review') {
|
||||
await publishInvitation();
|
||||
if (!invitationId) {
|
||||
const success = await createInvitationWithVariables();
|
||||
if (!success) return;
|
||||
}
|
||||
await advanceToPublishStep();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Generic advance (e.g. publish → done) ───────────────────
|
||||
// ── Publish ─────────────────────────────────────────────────
|
||||
if (stepType === 'publish') {
|
||||
if (canSignAndBroadcast) {
|
||||
await signAndBroadcastInvitation();
|
||||
return;
|
||||
}
|
||||
// Done should exit the wizard, not advance past the final step.
|
||||
goBack();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Generic advance ─────────────────────────────────────────
|
||||
setCurrentStep((prev) => prev + 1);
|
||||
setFocusArea('content');
|
||||
setFocusedInput(0);
|
||||
@@ -600,6 +763,7 @@ export function useActionWizard() {
|
||||
currentStep,
|
||||
steps,
|
||||
currentStepData,
|
||||
canSignAndBroadcast,
|
||||
availableRoles,
|
||||
selectedRoleIndex,
|
||||
template,
|
||||
@@ -609,7 +773,11 @@ export function useActionWizard() {
|
||||
createInvitationWithVariables,
|
||||
loadAvailableUtxos,
|
||||
addInputsAndOutputs,
|
||||
publishInvitation,
|
||||
advanceToPublishStep,
|
||||
requirementsComplete,
|
||||
hasSignedAndBroadcasted,
|
||||
signAndBroadcastInvitation,
|
||||
goBack,
|
||||
]);
|
||||
|
||||
// ── Navigate to the previous step ──────────────────────────────
|
||||
@@ -667,6 +835,9 @@ export function useActionWizard() {
|
||||
// Invitation
|
||||
invitation,
|
||||
invitationId,
|
||||
requirementsComplete,
|
||||
hasSignedAndBroadcasted,
|
||||
canSignAndBroadcast,
|
||||
|
||||
// UI focus
|
||||
focusedInput,
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
* Shows required, selected, and change amounts.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { colors, formatSatoshis } from '../../../../theme.js';
|
||||
import type { InputsSelectStepProps, SelectableUTXO } from '../types.js';
|
||||
import { autoSelectGreedyUtxos, mapUnspentOutputsToSelectable } from '../../../../../utils/invitation-flow.js';
|
||||
|
||||
/** Default fee estimate in satoshis. */
|
||||
const DEFAULT_FEE = 500n;
|
||||
@@ -64,34 +65,9 @@ export function InputsSelectStep({
|
||||
outputIdentifier: 'receiveOutput',
|
||||
});
|
||||
|
||||
// Map to selectable UTXOs
|
||||
const selectable: SelectableUTXO[] = unspentOutputs.map((utxo: any) => ({
|
||||
outpointTransactionHash: utxo.outpointTransactionHash,
|
||||
outpointIndex: utxo.outpointIndex,
|
||||
valueSatoshis: BigInt(utxo.valueSatoshis),
|
||||
lockingBytecode: utxo.lockingBytecode
|
||||
? typeof utxo.lockingBytecode === 'string'
|
||||
? utxo.lockingBytecode
|
||||
: Buffer.from(utxo.lockingBytecode).toString('hex')
|
||||
: undefined,
|
||||
selected: false,
|
||||
}));
|
||||
|
||||
// Greedy auto-select, skipping duplicate locking bytecodes
|
||||
let accumulated = 0n;
|
||||
const seenBytecodes = new Set<string>();
|
||||
|
||||
for (const utxo of selectable) {
|
||||
if (utxo.lockingBytecode && seenBytecodes.has(utxo.lockingBytecode)) continue;
|
||||
if (utxo.lockingBytecode) seenBytecodes.add(utxo.lockingBytecode);
|
||||
|
||||
utxo.selected = true;
|
||||
accumulated += utxo.valueSatoshis;
|
||||
|
||||
if (accumulated >= required + fee) break;
|
||||
}
|
||||
|
||||
setUtxos(selectable);
|
||||
const selectable = mapUnspentOutputsToSelectable(unspentOutputs);
|
||||
const autoSelected = autoSelectGreedyUtxos(selectable, required + fee);
|
||||
setUtxos(autoSelected as SelectableUTXO[]);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@@ -99,9 +75,15 @@ export function InputsSelectStep({
|
||||
}
|
||||
}, [invitation, computeRequiredAmount, fee]);
|
||||
|
||||
// Load UTXOs on mount
|
||||
// Load UTXOs once on mount. We use a ref guard to prevent re-firing when
|
||||
// `loadUtxos` identity changes due to parent re-renders — each re-fire
|
||||
// flashes the loading state, causing the visible flicker bug.
|
||||
const hasLoadedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (isActive) loadUtxos();
|
||||
if (isActive && !hasLoadedRef.current) {
|
||||
hasLoadedRef.current = true;
|
||||
loadUtxos();
|
||||
}
|
||||
}, [isActive, loadUtxos]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Cross-platform clipboard utility with multiple fallback methods.
|
||||
*/
|
||||
|
||||
import clipboardy from 'clipboardy';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
@@ -50,8 +51,7 @@ export async function copyToClipboard(text: string): Promise<void> {
|
||||
|
||||
// Fallback to clipboardy
|
||||
try {
|
||||
const clipboard = await import('clipboardy');
|
||||
await clipboard.default.write(text);
|
||||
clipboardy.writeSync(text);
|
||||
return;
|
||||
} catch {
|
||||
// clipboardy also failed
|
||||
|
||||
170
src/utils/bch-mnemonic-url.ts
Normal file
170
src/utils/bch-mnemonic-url.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* Handles BCH Mnemonic parsing to/from URL form.
|
||||
* Pulled directly from the old stack package.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
|
||||
export type BCHMnemonicURLRaw = {
|
||||
entropy: Uint8Array;
|
||||
passphrase?: string;
|
||||
language?: (typeof BCHMnemonicURL.SUPPORTED_LANGUAGES)[number];
|
||||
comment?: string;
|
||||
path?: string;
|
||||
startHeight?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles BCHMnemonic URLs
|
||||
*/
|
||||
export class BCHMnemonicURL {
|
||||
static PROTOCOL = 'bch-mnemonic';
|
||||
|
||||
/**
|
||||
* Check if a URL is a valid wallet backup URL
|
||||
*
|
||||
* @param url The URL to check
|
||||
* @returns True if the URL is a valid wallet backup URL, false otherwise
|
||||
*/
|
||||
public static canHandle(urlStr: string): boolean {
|
||||
try {
|
||||
BCHMnemonicURL.fromURL(urlStr);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a BCHMnemonic from a URL-encoded string
|
||||
* @param urlStr - The URL-encoded mnemonic string
|
||||
* @returns A new BCHMnemonic instance
|
||||
* @throws Error if the URL format is invalid or entropy is invalid
|
||||
*/
|
||||
static fromURL(urlStr: string): BCHMnemonicURL {
|
||||
const url = new URL(urlStr);
|
||||
|
||||
if (url.protocol !== `${BCHMnemonicURL.PROTOCOL}:`) {
|
||||
throw new Error(`Invalid URL protocol: ${url.protocol}`);
|
||||
}
|
||||
|
||||
// Decode the entropy.
|
||||
const entropy = new Uint8Array(Buffer.from(url.pathname, 'base64'));
|
||||
|
||||
// Pick out our encoding keys from the URL
|
||||
const params = BCHMnemonicURL.schema.parse(
|
||||
Object.fromEntries(url.searchParams.entries()),
|
||||
);
|
||||
|
||||
// Create and return the backup with validated parameters
|
||||
return BCHMnemonicURL.fromRaw({
|
||||
entropy,
|
||||
language: params[BCHMnemonicURL.ENCODING_KEYS.language],
|
||||
comment: params[BCHMnemonicURL.ENCODING_KEYS.comment],
|
||||
path: params[BCHMnemonicURL.ENCODING_KEYS.path],
|
||||
startHeight: params[BCHMnemonicURL.ENCODING_KEYS.startHeight],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new WalletBackup from a raw object
|
||||
*
|
||||
* @param raw - The raw object to create the WalletBackup from
|
||||
* @returns The created WalletBackup
|
||||
*/
|
||||
static fromRaw(raw: BCHMnemonicURLRaw): BCHMnemonicURL {
|
||||
// Add entropy validation
|
||||
if (!raw.entropy || raw.entropy.length === 0) {
|
||||
throw new Error('Invalid entropy: must be non-empty');
|
||||
}
|
||||
|
||||
// Validate entropy length (typically 16, 20, 24, 28, or 32 bytes for BIP39)
|
||||
const validLengths = [16, 20, 24, 28, 32];
|
||||
if (!validLengths.includes(raw.entropy.length)) {
|
||||
throw new Error(`Invalid entropy length: ${raw.entropy.length} bytes`);
|
||||
}
|
||||
|
||||
return new BCHMnemonicURL(raw);
|
||||
}
|
||||
|
||||
constructor(protected raw: BCHMnemonicURLRaw) {}
|
||||
|
||||
toObject() {
|
||||
return this.raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode the backup into a URL encoding
|
||||
*
|
||||
* @param prefix - The prefix to use for the URL encoding
|
||||
* @returns The URL encoding of the backup
|
||||
*/
|
||||
toURL(): string {
|
||||
// Conver the mnemonic words into the entropy used to derive the mnemonic words
|
||||
const entropyBase64 = Buffer.from(this.raw.entropy).toString('base64');
|
||||
|
||||
// Create a new URL object with the prefix and the base64 encoded mnemonic
|
||||
const url = new URL(`${BCHMnemonicURL.PROTOCOL}:${entropyBase64}`);
|
||||
|
||||
// Add the raw values to the url encoded string. Only add the values that are defined.
|
||||
if (this.raw.language !== undefined) {
|
||||
url.searchParams.set(
|
||||
BCHMnemonicURL.ENCODING_KEYS.language,
|
||||
this.raw.language,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.raw.comment !== undefined) {
|
||||
url.searchParams.set(
|
||||
BCHMnemonicURL.ENCODING_KEYS.comment,
|
||||
this.raw.comment,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.raw.path !== undefined) {
|
||||
url.searchParams.set(BCHMnemonicURL.ENCODING_KEYS.path, this.raw.path);
|
||||
}
|
||||
|
||||
if (this.raw.startHeight !== undefined) {
|
||||
url.searchParams.set(
|
||||
BCHMnemonicURL.ENCODING_KEYS.startHeight,
|
||||
this.raw.startHeight.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
static ENCODING_KEYS = {
|
||||
language: 'l',
|
||||
passphrase: 'p',
|
||||
comment: 'c',
|
||||
path: 'd',
|
||||
startHeight: 'h',
|
||||
} as const;
|
||||
|
||||
static SUPPORTED_LANGUAGES = [
|
||||
'en',
|
||||
'zh-CN',
|
||||
'zh-TW',
|
||||
'ja',
|
||||
'es',
|
||||
'pt',
|
||||
'ko',
|
||||
'fr',
|
||||
'it',
|
||||
'cs',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Zod schema for validating URL parameters
|
||||
*/
|
||||
static schema = z.object({
|
||||
[BCHMnemonicURL.ENCODING_KEYS.language]: z
|
||||
.enum(BCHMnemonicURL.SUPPORTED_LANGUAGES)
|
||||
.optional(),
|
||||
[BCHMnemonicURL.ENCODING_KEYS.passphrase]: z.string().optional(),
|
||||
[BCHMnemonicURL.ENCODING_KEYS.comment]: z.string().optional(),
|
||||
[BCHMnemonicURL.ENCODING_KEYS.path]: z.string().optional(),
|
||||
[BCHMnemonicURL.ENCODING_KEYS.startHeight]: z.coerce.number().optional(),
|
||||
});
|
||||
}
|
||||
@@ -1,259 +1,92 @@
|
||||
/**
|
||||
* History utility functions.
|
||||
*
|
||||
* Pure functions for parsing and formatting wallet history data.
|
||||
* These functions have no React dependencies and can be used
|
||||
* in both TUI and CLI contexts.
|
||||
*/
|
||||
import type { HistoryItem, HistoryInvitationItem, HistoryUtxoItem } from '../services/history.js';
|
||||
|
||||
import type { HistoryItem, HistoryItemType } from '../services/history.js';
|
||||
|
||||
/**
|
||||
* Color names for history item types.
|
||||
* These are semantic color names that can be mapped to actual colors
|
||||
* by the consuming application (TUI or CLI).
|
||||
*/
|
||||
export type HistoryColorName = 'info' | 'warning' | 'success' | 'error' | 'muted' | 'text';
|
||||
|
||||
/**
|
||||
* Formatted history list item data.
|
||||
*/
|
||||
export interface FormattedHistoryItem {
|
||||
/** The display label for the history item */
|
||||
export type HistoryRowType = 'invitation' | 'invitation_input' | 'invitation_output' | 'utxo';
|
||||
|
||||
export interface HistoryDisplayRow {
|
||||
id: string;
|
||||
type: HistoryRowType;
|
||||
label: string;
|
||||
/** Optional secondary description */
|
||||
description?: string;
|
||||
/** The formatted date string */
|
||||
dateStr?: string;
|
||||
/** The semantic color name for this item type */
|
||||
color: HistoryColorName;
|
||||
/** The history item type */
|
||||
type: HistoryItemType;
|
||||
/** Whether the item data is valid */
|
||||
isValid: boolean;
|
||||
timestamp?: number;
|
||||
isNested: boolean;
|
||||
utxo?: HistoryUtxoItem;
|
||||
invitation?: HistoryInvitationItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the semantic color name for a history item type.
|
||||
*
|
||||
* @param type - The history item type
|
||||
* @param isSelected - Whether the item is currently selected
|
||||
* @returns A semantic color name
|
||||
*/
|
||||
export function getHistoryItemColorName(type: HistoryItemType, isSelected: boolean = false): HistoryColorName {
|
||||
if (isSelected) return 'info'; // Use focus color when selected
|
||||
|
||||
switch (type) {
|
||||
case 'invitation_created':
|
||||
return 'text';
|
||||
case 'utxo_reserved':
|
||||
return 'warning';
|
||||
case 'utxo_received':
|
||||
return 'success';
|
||||
default:
|
||||
return 'text';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a satoshi value for display.
|
||||
*
|
||||
* @param satoshis - The value in satoshis
|
||||
* @returns Formatted string with BCH amount
|
||||
*/
|
||||
export function formatSatoshisValue(satoshis: bigint | number): string {
|
||||
const value = typeof satoshis === 'bigint' ? satoshis : BigInt(satoshis);
|
||||
const bch = Number(value) / 100_000_000;
|
||||
return `${bch.toFixed(8)} BCH (${value.toLocaleString()} sats)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a timestamp for display.
|
||||
*
|
||||
* @param timestamp - Unix timestamp in milliseconds
|
||||
* @returns Formatted date string or undefined
|
||||
*/
|
||||
export function formatHistoryDate(timestamp?: number): string | undefined {
|
||||
if (!timestamp) return undefined;
|
||||
return new Date(timestamp).toLocaleDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a history item for display in a list.
|
||||
*
|
||||
* @param item - The history item to format
|
||||
* @param isSelected - Whether the item is currently selected
|
||||
* @returns Formatted item data for display
|
||||
*/
|
||||
export function formatHistoryListItem(
|
||||
item: HistoryItem | null | undefined,
|
||||
isSelected: boolean = false
|
||||
): FormattedHistoryItem {
|
||||
if (!item) {
|
||||
return {
|
||||
label: '',
|
||||
description: undefined,
|
||||
dateStr: undefined,
|
||||
color: 'muted',
|
||||
type: 'utxo_received',
|
||||
isValid: false,
|
||||
};
|
||||
}
|
||||
|
||||
const dateStr = formatHistoryDate(item.timestamp);
|
||||
const color = getHistoryItemColorName(item.type, isSelected);
|
||||
|
||||
switch (item.type) {
|
||||
case 'invitation_created':
|
||||
return {
|
||||
label: `[Invitation] ${item.description}`,
|
||||
description: undefined,
|
||||
dateStr,
|
||||
color,
|
||||
type: item.type,
|
||||
isValid: true,
|
||||
};
|
||||
|
||||
case 'utxo_reserved': {
|
||||
const satsStr = item.valueSatoshis !== undefined
|
||||
? formatSatoshisValue(item.valueSatoshis)
|
||||
: 'Unknown amount';
|
||||
return {
|
||||
label: `[Reserved] ${satsStr}`,
|
||||
description: item.description,
|
||||
dateStr,
|
||||
color,
|
||||
type: item.type,
|
||||
isValid: true,
|
||||
};
|
||||
}
|
||||
|
||||
case 'utxo_received': {
|
||||
const satsStr = item.valueSatoshis !== undefined
|
||||
? formatSatoshisValue(item.valueSatoshis)
|
||||
: 'Unknown amount';
|
||||
const reservedTag = item.reserved ? ' [Reserved]' : '';
|
||||
return {
|
||||
label: satsStr,
|
||||
description: `${item.description}${reservedTag}`,
|
||||
dateStr,
|
||||
color,
|
||||
type: item.type,
|
||||
isValid: true,
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
label: `${item.type}: ${item.description}`,
|
||||
description: undefined,
|
||||
dateStr,
|
||||
color: 'text',
|
||||
type: item.type,
|
||||
isValid: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a type label for display.
|
||||
*
|
||||
* @param type - The history item type
|
||||
* @returns Human-readable type label
|
||||
*/
|
||||
export function getHistoryTypeLabel(type: HistoryItemType): string {
|
||||
switch (type) {
|
||||
case 'invitation_created':
|
||||
return 'Invitation';
|
||||
case 'utxo_reserved':
|
||||
return 'Reserved';
|
||||
case 'utxo_received':
|
||||
return 'Received';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate scrolling window indices for a list.
|
||||
*
|
||||
* @param selectedIndex - Currently selected index
|
||||
* @param totalItems - Total number of items
|
||||
* @param maxVisible - Maximum visible items
|
||||
* @returns Start and end indices for the visible window
|
||||
*/
|
||||
export function calculateScrollWindow(
|
||||
selectedIndex: number,
|
||||
totalItems: number,
|
||||
maxVisible: number
|
||||
): { startIndex: number; endIndex: number } {
|
||||
const halfWindow = Math.floor(maxVisible / 2);
|
||||
let startIndex = Math.max(0, selectedIndex - halfWindow);
|
||||
const endIndex = Math.min(totalItems, startIndex + maxVisible);
|
||||
|
||||
// Adjust start if we're near the end
|
||||
if (endIndex - startIndex < maxVisible) {
|
||||
startIndex = Math.max(0, endIndex - maxVisible);
|
||||
}
|
||||
|
||||
return { startIndex, endIndex };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a history item is a UTXO-related event.
|
||||
*
|
||||
* @param item - The history item to check
|
||||
* @returns True if the item is UTXO-related
|
||||
*/
|
||||
export function isUtxoEvent(item: HistoryItem): boolean {
|
||||
return item.type === 'utxo_received' || item.type === 'utxo_reserved';
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter history items by type.
|
||||
*
|
||||
* @param items - Array of history items
|
||||
* @param types - Types to include
|
||||
* @returns Filtered array
|
||||
*/
|
||||
export function filterHistoryByType(
|
||||
items: HistoryItem[],
|
||||
types: HistoryItemType[]
|
||||
): HistoryItem[] {
|
||||
return items.filter(item => types.includes(item.type));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary statistics for history items.
|
||||
*
|
||||
* @param items - Array of history items
|
||||
* @returns Summary statistics
|
||||
*/
|
||||
export function getHistorySummary(items: HistoryItem[]): {
|
||||
totalReceived: bigint;
|
||||
totalReserved: bigint;
|
||||
invitationCount: number;
|
||||
utxoCount: number;
|
||||
} {
|
||||
let totalReceived = 0n;
|
||||
let totalReserved = 0n;
|
||||
let invitationCount = 0;
|
||||
let utxoCount = 0;
|
||||
export function buildHistoryDisplayRows(items: HistoryItem[]): HistoryDisplayRow[] {
|
||||
const rows: HistoryDisplayRow[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
switch (item.type) {
|
||||
case 'invitation_created':
|
||||
invitationCount++;
|
||||
break;
|
||||
case 'utxo_reserved':
|
||||
totalReserved += item.valueSatoshis ?? 0n;
|
||||
break;
|
||||
case 'utxo_received':
|
||||
totalReceived += item.valueSatoshis ?? 0n;
|
||||
utxoCount++;
|
||||
break;
|
||||
if (item.kind === 'invitation') {
|
||||
rows.push({
|
||||
id: item.id,
|
||||
type: 'invitation',
|
||||
label: item.description,
|
||||
timestamp: item.createdAtTimestamp,
|
||||
isNested: false,
|
||||
invitation: item,
|
||||
});
|
||||
|
||||
for (const input of item.inputs) {
|
||||
const satsPrefix = input.valueSatoshis !== undefined ? `${input.valueSatoshis.toLocaleString()} sats ` : '';
|
||||
rows.push({
|
||||
id: `${item.id}-input-${input.id}`,
|
||||
type: 'invitation_input',
|
||||
label: `${satsPrefix}${input.outpoint.txid}:${input.outpoint.index}`,
|
||||
description: input.description,
|
||||
isNested: true,
|
||||
utxo: input,
|
||||
invitation: item,
|
||||
});
|
||||
}
|
||||
|
||||
for (const output of item.outputs) {
|
||||
rows.push({
|
||||
id: `${item.id}-output-${output.id}`,
|
||||
type: 'invitation_output',
|
||||
label: output.valueSatoshis !== undefined ? `${output.valueSatoshis.toLocaleString()} sats` : 'Output',
|
||||
description: output.description,
|
||||
isNested: true,
|
||||
utxo: output,
|
||||
invitation: item,
|
||||
});
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
id: item.id,
|
||||
type: 'utxo',
|
||||
label: item.valueSatoshis !== undefined ? `${item.valueSatoshis.toLocaleString()} sats` : 'UTXO',
|
||||
description: item.description,
|
||||
isNested: false,
|
||||
utxo: item,
|
||||
});
|
||||
}
|
||||
|
||||
return { totalReceived, totalReserved, invitationCount, utxoCount };
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function getHistoryItemColorName(row: HistoryDisplayRow, isSelected: boolean = false): HistoryColorName {
|
||||
if (isSelected) return 'info';
|
||||
switch (row.type) {
|
||||
case 'invitation':
|
||||
return 'text';
|
||||
case 'invitation_input':
|
||||
return 'error';
|
||||
case 'invitation_output':
|
||||
return 'success';
|
||||
case 'utxo':
|
||||
return row.utxo?.reserved ? 'warning' : 'success';
|
||||
default:
|
||||
return 'text';
|
||||
}
|
||||
}
|
||||
|
||||
152
src/utils/invitation-flow.ts
Normal file
152
src/utils/invitation-flow.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { XOTemplate, XOTemplateTransactionOutput } from '@xo-cash/types';
|
||||
import type { Invitation } from '../services/invitation.js';
|
||||
|
||||
export interface SelectableUtxoLike {
|
||||
outpointTransactionHash: string;
|
||||
outpointIndex: number;
|
||||
valueSatoshis: bigint;
|
||||
lockingBytecode?: string;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export const hasMissingRequirements = (missingRequirements: {
|
||||
variables?: string[];
|
||||
inputs?: string[];
|
||||
outputs?: string[];
|
||||
roles?: Record<string, unknown>;
|
||||
}): boolean => {
|
||||
return (
|
||||
(missingRequirements.variables?.length ?? 0) > 0
|
||||
|| (missingRequirements.inputs?.length ?? 0) > 0
|
||||
|| (missingRequirements.outputs?.length ?? 0) > 0
|
||||
|| (missingRequirements.roles !== undefined && Object.keys(missingRequirements.roles).length > 0)
|
||||
);
|
||||
};
|
||||
|
||||
export const isInvitationRequirementsComplete = async (invitation: Invitation): Promise<boolean> => {
|
||||
const missingRequirements = await invitation.getMissingRequirements();
|
||||
return !hasMissingRequirements(missingRequirements);
|
||||
};
|
||||
|
||||
export const resolveActionRoles = (
|
||||
template: XOTemplate | undefined,
|
||||
actionIdentifier: string | undefined,
|
||||
rolesFromNavigation?: string[],
|
||||
): string[] => {
|
||||
if (rolesFromNavigation && rolesFromNavigation.length > 0) {
|
||||
return [ ...new Set(rolesFromNavigation) ];
|
||||
}
|
||||
|
||||
if (!template || !actionIdentifier) return [];
|
||||
const starts = template.start ?? [];
|
||||
const roleIds = starts
|
||||
.filter((entry) => entry.action === actionIdentifier)
|
||||
.map((entry) => entry.role);
|
||||
|
||||
return [ ...new Set(roleIds) ];
|
||||
};
|
||||
|
||||
export const roleRequiresInputs = (
|
||||
template: XOTemplate | undefined,
|
||||
actionIdentifier: string | undefined,
|
||||
roleIdentifier: string | undefined,
|
||||
): boolean => {
|
||||
if (!template || !actionIdentifier || !roleIdentifier) return false;
|
||||
const action = template.actions?.[actionIdentifier];
|
||||
if (!action) return false;
|
||||
|
||||
const actionRole = action.roles?.[roleIdentifier];
|
||||
const roleSlotsMin = actionRole?.requirements?.slots?.min ?? 0;
|
||||
if (roleSlotsMin > 0) return true;
|
||||
|
||||
// Some templates specify slot/input requirements at action.requirements.roles
|
||||
// instead of role.requirements. Respect those as well.
|
||||
const roleRequirement = action.requirements?.roles?.find((requirement) => requirement.role === roleIdentifier);
|
||||
const actionLevelSlotsMin = roleRequirement?.slots?.min ?? 0;
|
||||
if (actionLevelSlotsMin > 0) return true;
|
||||
|
||||
const transactionIdentifier = action.transaction;
|
||||
const transaction = transactionIdentifier ? template.transactions?.[transactionIdentifier] : undefined;
|
||||
const roleInputs = transaction?.roles?.[roleIdentifier]?.inputs;
|
||||
|
||||
return (roleInputs?.length ?? 0) > 0;
|
||||
};
|
||||
|
||||
export const getTransactionOutputIdentifier = (output: XOTemplateTransactionOutput): string | undefined => {
|
||||
if (typeof output === 'string') return output;
|
||||
if (output && typeof output === 'object' && 'output' in output && typeof output.output === 'string') {
|
||||
return output.output;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const normalizeLockingBytecodeHex = (value: string): string => value.trim().replace(/^0x/i, '');
|
||||
|
||||
export const resolveProvidedLockingBytecodeHex = (
|
||||
template: XOTemplate,
|
||||
outputIdentifier: string,
|
||||
variableValues: Record<string, string>,
|
||||
): string | undefined => {
|
||||
const outputDefinition = template.outputs?.[outputIdentifier];
|
||||
if (!outputDefinition || typeof outputDefinition.lockscript !== 'string') return undefined;
|
||||
|
||||
const lockingScriptDefinition = (template.lockingScripts as Record<string, unknown> | undefined)?.[outputDefinition.lockscript] as
|
||||
| { lockingScript?: string }
|
||||
| undefined;
|
||||
const scriptIdentifier = lockingScriptDefinition?.lockingScript;
|
||||
if (!scriptIdentifier) return undefined;
|
||||
|
||||
const scriptExpression = (template.scripts as Record<string, unknown> | undefined)?.[scriptIdentifier];
|
||||
if (typeof scriptExpression !== 'string') return undefined;
|
||||
|
||||
const directVariableMatch = scriptExpression.match(/^<\s*([A-Za-z0-9_]+)\s*>$/);
|
||||
if (!directVariableMatch) return undefined;
|
||||
|
||||
const variableIdentifier = directVariableMatch[1];
|
||||
if (!variableIdentifier) return undefined;
|
||||
|
||||
const providedValue = variableValues[variableIdentifier];
|
||||
if (!providedValue) return undefined;
|
||||
|
||||
return normalizeLockingBytecodeHex(providedValue);
|
||||
};
|
||||
|
||||
export const mapUnspentOutputsToSelectable = (unspentOutputs: any[]): SelectableUtxoLike[] => {
|
||||
return unspentOutputs.map((utxo: any) => ({
|
||||
outpointTransactionHash: utxo.outpointTransactionHash,
|
||||
outpointIndex: utxo.outpointIndex,
|
||||
valueSatoshis: BigInt(utxo.valueSatoshis),
|
||||
lockingBytecode: utxo.lockingBytecode
|
||||
? typeof utxo.lockingBytecode === 'string'
|
||||
? utxo.lockingBytecode
|
||||
: Buffer.from(utxo.lockingBytecode).toString('hex')
|
||||
: undefined,
|
||||
selected: false,
|
||||
}));
|
||||
};
|
||||
|
||||
export const autoSelectGreedyUtxos = (
|
||||
utxos: SelectableUtxoLike[],
|
||||
requiredWithFee: bigint,
|
||||
): SelectableUtxoLike[] => {
|
||||
let accumulated = 0n;
|
||||
const seenLockingBytecodes = new Set<string>();
|
||||
|
||||
for (const utxo of utxos) {
|
||||
if (utxo.lockingBytecode && seenLockingBytecodes.has(utxo.lockingBytecode)) {
|
||||
continue;
|
||||
}
|
||||
if (utxo.lockingBytecode) {
|
||||
seenLockingBytecodes.add(utxo.lockingBytecode);
|
||||
}
|
||||
|
||||
utxo.selected = true;
|
||||
accumulated += utxo.valueSatoshis;
|
||||
|
||||
if (accumulated >= requiredWithFee) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return utxos;
|
||||
};
|
||||
@@ -84,6 +84,7 @@ export function getStateColorName(state: string): StateColorName {
|
||||
return 'warning';
|
||||
case 'ready':
|
||||
case 'signed':
|
||||
case 'complete':
|
||||
case 'broadcast':
|
||||
case 'completed':
|
||||
return 'success';
|
||||
|
||||
Reference in New Issue
Block a user