Big changes and fixes. Uses action history. Improve role selection. Remove unused logs
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ dist/
|
|||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
resolvedTemplate.json
|
||||||
@@ -9,6 +9,7 @@ import type { XOInvitation } from '@xo-cash/types';
|
|||||||
import { Invitation } from './invitation.js';
|
import { Invitation } from './invitation.js';
|
||||||
import { Storage } from './storage.js';
|
import { Storage } from './storage.js';
|
||||||
import { SyncServer } from '../utils/sync-server.js';
|
import { SyncServer } from '../utils/sync-server.js';
|
||||||
|
import { HistoryService } from './history.js';
|
||||||
|
|
||||||
import { EventEmitter } from '../utils/event-emitter.js';
|
import { EventEmitter } from '../utils/event-emitter.js';
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
public engine: Engine;
|
public engine: Engine;
|
||||||
public storage: Storage;
|
public storage: Storage;
|
||||||
public config: AppConfig;
|
public config: AppConfig;
|
||||||
|
public history: HistoryService;
|
||||||
|
|
||||||
public invitations: Invitation[] = [];
|
public invitations: Invitation[] = [];
|
||||||
|
|
||||||
@@ -42,9 +44,6 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
// We want to only prefix the file name
|
// We want to only prefix the file name
|
||||||
const prefixedStoragePath = `${seedHash.slice(0, 8)}-${config.engineConfig.databaseFilename}`;
|
const prefixedStoragePath = `${seedHash.slice(0, 8)}-${config.engineConfig.databaseFilename}`;
|
||||||
|
|
||||||
console.log('Prefixed storage path:', prefixedStoragePath);
|
|
||||||
console.log('Engine config:', config.engineConfig);
|
|
||||||
|
|
||||||
// Create the engine
|
// Create the engine
|
||||||
const engine = await Engine.create(seed, {
|
const engine = await Engine.create(seed, {
|
||||||
...config.engineConfig,
|
...config.engineConfig,
|
||||||
@@ -75,6 +74,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
this.engine = engine;
|
this.engine = engine;
|
||||||
this.storage = storage;
|
this.storage = storage;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
this.history = new HistoryService(engine, this.invitations);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createInvitation(invitation: XOInvitation | string): Promise<Invitation> {
|
async createInvitation(invitation: XOInvitation | string): Promise<Invitation> {
|
||||||
@@ -118,12 +118,16 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
const invitationsDb = this.storage.child('invitations');
|
const invitationsDb = this.storage.child('invitations');
|
||||||
|
|
||||||
// Load invitations from storage
|
// Load invitations from storage
|
||||||
|
console.time('loadInvitations');
|
||||||
const invitations = await invitationsDb.all() as { key: string; value: XOInvitation }[];
|
const invitations = await invitationsDb.all() as { key: string; value: XOInvitation }[];
|
||||||
|
console.timeEnd('loadInvitations');
|
||||||
|
|
||||||
// Start the invitations
|
console.time('createInvitations');
|
||||||
for (const { key } of invitations) {
|
|
||||||
// TODO: This is doing some double work of grabbing the invitation data. We can probably skip it, but who knows.
|
await Promise.all(invitations.map(async ({ key }) => {
|
||||||
await this.createInvitation(key);
|
await this.createInvitation(key);
|
||||||
}
|
}));
|
||||||
|
|
||||||
|
console.timeEnd('createInvitations');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
252
src/services/history.ts
Normal file
252
src/services/history.ts
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
/**
|
||||||
|
* 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 } from '@xo-cash/engine';
|
||||||
|
import type { XOInvitation, XOTemplate } from '@xo-cash/types';
|
||||||
|
import type { UnspentOutputData } from '@xo-cash/state';
|
||||||
|
import type { Invitation } from './invitation.js';
|
||||||
|
import { binToHex } from '@bitauth/libauth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Types of history events.
|
||||||
|
*/
|
||||||
|
export type HistoryItemType =
|
||||||
|
| 'utxo_received'
|
||||||
|
| 'utxo_reserved'
|
||||||
|
| 'invitation_created';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. */
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
/** The value in satoshis (for UTXO-related events). */
|
||||||
|
valueSatoshis?: bigint;
|
||||||
|
|
||||||
|
/** The invitation identifier this event relates to (if applicable). */
|
||||||
|
invitationIdentifier?: string;
|
||||||
|
|
||||||
|
/** The template identifier for reference. */
|
||||||
|
templateIdentifier?: string;
|
||||||
|
|
||||||
|
/** The UTXO outpoint (for UTXO-related events). */
|
||||||
|
outpoint?: {
|
||||||
|
txid: string;
|
||||||
|
index: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Whether this UTXO is reserved. */
|
||||||
|
reserved?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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>();
|
||||||
|
for (const utxo of allUtxos) {
|
||||||
|
const key = `${utxo.outpointTransactionHash}:${utxo.outpointIndex}`;
|
||||||
|
utxoMap.set(key, utxo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Process invitations to find UTXO reservations from commits
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
// 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 `${utxo.outputIdentifier} output`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = outputDef.description
|
||||||
|
// Replace <variableName> placeholders (we don't have variable values here, so just clean up)
|
||||||
|
.replace(/<([^>]+)>/g, (_, varId) => varId)
|
||||||
|
// Remove $() wrappers
|
||||||
|
.replace(/\$\(([^)]+)\)/g, '$1');
|
||||||
|
}
|
||||||
|
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
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 committedVariables = invitation.commits.flatMap(c => c.data.variables ?? []);
|
||||||
|
|
||||||
|
return transaction.description
|
||||||
|
// Replace <variableName> with actual values
|
||||||
|
.replace(/<([^>]+)>/g, (match, varId) => {
|
||||||
|
const variable = committedVariables.find(v => v.variableIdentifier === varId);
|
||||||
|
return variable ? String(variable.value) : match;
|
||||||
|
})
|
||||||
|
// Remove the $() wrapper around variable expressions
|
||||||
|
.replace(/\$\(([^)]+)\)/g, '$1');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AppendInvitationParameters, Engine, FindSuitableResourcesParameters } from '@xo-cash/engine';
|
import type { AppendInvitationParameters, Engine, FindSuitableResourcesParameters } from '@xo-cash/engine';
|
||||||
import type { XOInvitation, XOInvitationInput, XOInvitationOutput, XOInvitationVariable } from '@xo-cash/types';
|
import type { XOInvitation, XOInvitationCommit, XOInvitationInput, XOInvitationOutput, XOInvitationVariable } from '@xo-cash/types';
|
||||||
import type { UnspentOutputData } from '@xo-cash/state';
|
import type { UnspentOutputData } from '@xo-cash/state';
|
||||||
|
|
||||||
import type { SSEvent } from '../utils/sse-client.js';
|
import type { SSEvent } from '../utils/sse-client.js';
|
||||||
@@ -50,18 +50,12 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
throw new Error(`Template not found: ${invitation.templateIdentifier}`);
|
throw new Error(`Template not found: ${invitation.templateIdentifier}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Invitation:', invitation);
|
|
||||||
|
|
||||||
// Create the invitation
|
// Create the invitation
|
||||||
const invitationInstance = new Invitation(invitation, dependencies);
|
const invitationInstance = new Invitation(invitation, dependencies);
|
||||||
|
|
||||||
console.log('Invitation instance:', invitationInstance);
|
|
||||||
|
|
||||||
// Start the invitation and its tracking
|
// Start the invitation and its tracking
|
||||||
await invitationInstance.start();
|
await invitationInstance.start();
|
||||||
|
|
||||||
console.log('Invitation started:', invitationInstance);
|
|
||||||
|
|
||||||
return invitationInstance;
|
return invitationInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,21 +108,28 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
|
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
// Connect to the sync server and get the invitation (in parallel)
|
// Connect to the sync server and get the invitation (in parallel)
|
||||||
|
console.time(`connectAndGetInvitation-${this.data.invitationIdentifier}`);
|
||||||
const [_, invitation] = await Promise.all([
|
const [_, invitation] = await Promise.all([
|
||||||
this.syncServer.connect(),
|
this.syncServer.connect(),
|
||||||
this.syncServer.getInvitation(this.data.invitationIdentifier),
|
this.syncServer.getInvitation(this.data.invitationIdentifier),
|
||||||
]);
|
]);
|
||||||
|
console.timeEnd(`connectAndGetInvitation-${this.data.invitationIdentifier}`);
|
||||||
|
|
||||||
// There is a chance we get SSE messages before the invitation is returned, so we want to combine any commits
|
// There is a chance we get SSE messages before the invitation is returned, so we want to combine any commits
|
||||||
const sseCommits = this.data.commits;
|
const sseCommits = this.data.commits;
|
||||||
|
|
||||||
// Set the invitation data with the combined commits
|
console.time(`mergeCommits-${this.data.invitationIdentifier}`);
|
||||||
this.data = { ...this.data, ...invitation, commits: [...sseCommits, ...(invitation?.commits ?? [])] };
|
// Merge the commits
|
||||||
|
const combinedCommits = this.mergeCommits(sseCommits, invitation?.commits ?? []);
|
||||||
|
console.timeEnd(`mergeCommits-${this.data.invitationIdentifier}`);
|
||||||
|
|
||||||
console.log('Invitation data:', this.data);
|
console.time(`setInvitationData-${this.data.invitationIdentifier}`);
|
||||||
|
// Set the invitation data with the combined commits
|
||||||
|
this.data = { ...this.data, ...invitation, commits: combinedCommits };
|
||||||
|
|
||||||
// Store the invitation in the storage
|
// Store the invitation in the storage
|
||||||
await this.storage.set(this.data.invitationIdentifier, this.data);
|
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||||
|
console.timeEnd(`setInvitationData-${this.data.invitationIdentifier}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -143,17 +144,16 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
const data = JSON.parse(event.data) as { topic?: string; data?: unknown };
|
const data = JSON.parse(event.data) as { topic?: string; data?: unknown };
|
||||||
if (data.topic === 'invitation-updated') {
|
if (data.topic === 'invitation-updated') {
|
||||||
const invitation = decodeExtendedJsonObject(data.data) as XOInvitation;
|
const invitation = decodeExtendedJsonObject(data.data) as XOInvitation;
|
||||||
console.log('Invitation updated:', invitation);
|
|
||||||
|
|
||||||
if (invitation.invitationIdentifier !== this.data.invitationIdentifier) {
|
if (invitation.invitationIdentifier !== this.data.invitationIdentifier) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('New commits:', invitation.commits);
|
|
||||||
|
|
||||||
// Filter out commits that already exist (probably a faster way to do this. This is n^2)
|
// Filter out commits that already exist (probably a faster way to do this. This is n^2)
|
||||||
const newCommits = invitation.commits.filter(commit => !this.data.commits.some(c => c.commitIdentifier === commit.commitIdentifier));
|
const newCommits = this.mergeCommits(this.data.commits, invitation.commits);
|
||||||
this.data.commits.push(...newCommits);
|
|
||||||
|
// Set the new commits
|
||||||
|
this.data = { ...this.data, commits: newCommits };
|
||||||
|
|
||||||
// Calculate the new status of the invitation
|
// Calculate the new status of the invitation
|
||||||
this.updateStatus();
|
this.updateStatus();
|
||||||
@@ -163,6 +163,28 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge the commits
|
||||||
|
* @param initial - The initial commits
|
||||||
|
* @param additional - The additional commits
|
||||||
|
* @returns The merged commits
|
||||||
|
*/
|
||||||
|
private mergeCommits(initial: XOInvitationCommit[], additional: XOInvitationCommit[]): XOInvitationCommit[] {
|
||||||
|
// Create a map of the initial commits
|
||||||
|
const initialMap = new Map<string, XOInvitationCommit>();
|
||||||
|
for(const commit of initial) {
|
||||||
|
initialMap.set(commit.commitIdentifier, commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge the additional commits
|
||||||
|
// TODO: They are immutable? So, it should be fine to "ovewrite" existing commits as it should be the same data, right?
|
||||||
|
for(const commit of additional) {
|
||||||
|
initialMap.set(commit.commitIdentifier, commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the merged commits
|
||||||
|
return Array.from(initialMap.values());
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Update the status of the invitation based on the filled in information
|
* Update the status of the invitation based on the filled in information
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -36,11 +36,9 @@ export class Storage {
|
|||||||
async set(key: string, value: any): Promise<void> {
|
async set(key: string, value: any): Promise<void> {
|
||||||
// Encode the extended json object
|
// Encode the extended json object
|
||||||
const encodedValue = encodeExtendedJson(value);
|
const encodedValue = encodeExtendedJson(value);
|
||||||
console.log('Encoded value:', encodedValue);
|
|
||||||
|
|
||||||
// Insert or replace the value into the database with full key (including basePath)
|
// Insert or replace the value into the database with full key (including basePath)
|
||||||
const fullKey = this.getFullKey(key);
|
const fullKey = this.getFullKey(key);
|
||||||
console.log('Full key:', fullKey);
|
|
||||||
this.database.prepare('INSERT OR REPLACE INTO storage (key, value) VALUES (?, ?)').run(fullKey, encodedValue);
|
this.database.prepare('INSERT OR REPLACE INTO storage (key, value) VALUES (?, ?)').run(fullKey, encodedValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export function VariableInputField({
|
|||||||
borderColor={isFocused ? focusColor : borderColor}
|
borderColor={isFocused ? focusColor : borderColor}
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
marginTop={1}
|
marginTop={1}
|
||||||
|
gap={1}
|
||||||
>
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={variable.value}
|
value={variable.value}
|
||||||
@@ -50,6 +51,9 @@ export function VariableInputField({
|
|||||||
placeholder={`Enter ${variable.name}...`}
|
placeholder={`Enter ${variable.name}...`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* TODO: this may need to be conditional. Need to play around with other templates though */}
|
||||||
|
<Text color={borderColor} dimColor>{variable.hint}</Text>
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
{variable.type === 'integer' && variable.hint === 'satoshis' && (
|
{variable.type === 'integer' && variable.hint === 'satoshis' && (
|
||||||
<Box>
|
<Box>
|
||||||
|
|||||||
@@ -1,920 +0,0 @@
|
|||||||
/**
|
|
||||||
* Action Wizard Screen - Step-by-step walkthrough for template actions.
|
|
||||||
*
|
|
||||||
* Guides users through:
|
|
||||||
* - Reviewing action requirements
|
|
||||||
* - Entering variables (e.g., requestedSatoshis)
|
|
||||||
* - Selecting inputs (UTXOs) for funding
|
|
||||||
* - Reviewing outputs and change
|
|
||||||
* - Creating and publishing invitation
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { Box, Text, useInput } from 'ink';
|
|
||||||
import TextInput from 'ink-text-input';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Isolated Variable Input Component.
|
|
||||||
* This component handles its own input without interference from parent useInput hooks.
|
|
||||||
*/
|
|
||||||
interface VariableInputFieldProps {
|
|
||||||
variable: { id: string; name: string; type: string; hint?: string; value: string };
|
|
||||||
index: number;
|
|
||||||
isFocused: boolean;
|
|
||||||
onChange: (index: number, value: string) => void;
|
|
||||||
onSubmit: () => void;
|
|
||||||
borderColor: string;
|
|
||||||
focusColor: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function VariableInputField({
|
|
||||||
variable,
|
|
||||||
index,
|
|
||||||
isFocused,
|
|
||||||
onChange,
|
|
||||||
onSubmit,
|
|
||||||
borderColor,
|
|
||||||
focusColor,
|
|
||||||
}: VariableInputFieldProps): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<Box flexDirection='column' marginBottom={1}>
|
|
||||||
<Text color={focusColor}>{variable.name}</Text>
|
|
||||||
{variable.hint && (
|
|
||||||
<Text color={borderColor} dimColor>({variable.hint})</Text>
|
|
||||||
)}
|
|
||||||
<Box
|
|
||||||
borderStyle='single'
|
|
||||||
borderColor={isFocused ? focusColor : borderColor}
|
|
||||||
paddingX={1}
|
|
||||||
marginTop={1}
|
|
||||||
>
|
|
||||||
<TextInput
|
|
||||||
value={variable.value}
|
|
||||||
onChange={value => onChange(index, value)}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
focus={isFocused}
|
|
||||||
placeholder={`Enter ${variable.name}...`}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
import { StepIndicator, type Step } from '../components/ProgressBar.js';
|
|
||||||
import { Button } from '../components/Button.js';
|
|
||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
|
||||||
import { colors, logoSmall, formatSatoshis, formatHex } from '../theme.js';
|
|
||||||
import { copyToClipboard } from '../utils/clipboard.js';
|
|
||||||
import type { XOTemplate, XOInvitation } from '@xo-cash/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wizard step types.
|
|
||||||
*/
|
|
||||||
type StepType = 'info' | 'variables' | 'inputs' | 'review' | 'publish';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wizard step definition.
|
|
||||||
*/
|
|
||||||
interface WizardStep {
|
|
||||||
name: string;
|
|
||||||
type: StepType;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Variable input state.
|
|
||||||
*/
|
|
||||||
interface VariableInput {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
hint?: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UTXO for selection.
|
|
||||||
*/
|
|
||||||
interface SelectableUTXO {
|
|
||||||
outpointTransactionHash: string;
|
|
||||||
outpointIndex: number;
|
|
||||||
valueSatoshis: bigint;
|
|
||||||
lockingBytecode?: string;
|
|
||||||
selected: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Action Wizard Screen Component.
|
|
||||||
*/
|
|
||||||
export function ActionWizardScreen(): React.ReactElement {
|
|
||||||
const { navigate, goBack, data: navData } = useNavigation();
|
|
||||||
const { appService, showError, showInfo } = useAppContext();
|
|
||||||
const { setStatus } = useStatus();
|
|
||||||
|
|
||||||
// Extract navigation data
|
|
||||||
const templateIdentifier = navData.templateIdentifier as string | undefined;
|
|
||||||
const actionIdentifier = navData.actionIdentifier as string | undefined;
|
|
||||||
const roleIdentifier = navData.roleIdentifier as string | undefined;
|
|
||||||
const template = navData.template as XOTemplate | undefined;
|
|
||||||
|
|
||||||
// Wizard state
|
|
||||||
const [steps, setSteps] = useState<WizardStep[]>([]);
|
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
|
||||||
|
|
||||||
// Variable inputs
|
|
||||||
const [variables, setVariables] = useState<VariableInput[]>([]);
|
|
||||||
|
|
||||||
// UTXO selection
|
|
||||||
const [availableUtxos, setAvailableUtxos] = useState<SelectableUTXO[]>([]);
|
|
||||||
const [selectedUtxoIndex, setSelectedUtxoIndex] = useState(0);
|
|
||||||
const [requiredAmount, setRequiredAmount] = useState<bigint>(0n);
|
|
||||||
const [fee, setFee] = useState<bigint>(500n); // Default fee estimate
|
|
||||||
|
|
||||||
// Invitation state
|
|
||||||
const [invitation, setInvitation] = useState<XOInvitation | null>(null);
|
|
||||||
const [invitationId, setInvitationId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// UI state
|
|
||||||
const [focusedInput, setFocusedInput] = useState(0);
|
|
||||||
const [focusedButton, setFocusedButton] = useState<'back' | 'cancel' | 'next'>('next');
|
|
||||||
const [focusArea, setFocusArea] = useState<'content' | 'buttons'>('content');
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize wizard on mount.
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
if (!template || !actionIdentifier || !roleIdentifier) {
|
|
||||||
showError('Missing wizard data');
|
|
||||||
goBack();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build steps based on template
|
|
||||||
const action = template.actions?.[actionIdentifier];
|
|
||||||
const role = action?.roles?.[roleIdentifier];
|
|
||||||
const requirements = role?.requirements;
|
|
||||||
|
|
||||||
const wizardSteps: WizardStep[] = [
|
|
||||||
{ name: 'Welcome', type: 'info' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add variables step if needed
|
|
||||||
if (requirements?.variables && requirements.variables.length > 0) {
|
|
||||||
wizardSteps.push({ name: 'Variables', type: 'variables' });
|
|
||||||
|
|
||||||
// Initialize variable inputs
|
|
||||||
const varInputs = requirements.variables.map(varId => {
|
|
||||||
const varDef = template.variables?.[varId];
|
|
||||||
return {
|
|
||||||
id: varId,
|
|
||||||
name: varDef?.name || varId,
|
|
||||||
type: varDef?.type || 'string',
|
|
||||||
hint: varDef?.hint,
|
|
||||||
value: '',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setVariables(varInputs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add inputs step if role requires slots (funding inputs)
|
|
||||||
// Slots indicate the role needs to provide transaction inputs/outputs
|
|
||||||
if (requirements?.slots && requirements.slots.min > 0) {
|
|
||||||
wizardSteps.push({ name: 'Select UTXOs', type: 'inputs' });
|
|
||||||
}
|
|
||||||
|
|
||||||
wizardSteps.push({ name: 'Review', type: 'review' });
|
|
||||||
wizardSteps.push({ name: 'Publish', type: 'publish' });
|
|
||||||
|
|
||||||
setSteps(wizardSteps);
|
|
||||||
setStatus(`${actionIdentifier}/${roleIdentifier}`);
|
|
||||||
}, [template, actionIdentifier, roleIdentifier, showError, goBack, setStatus]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current step data.
|
|
||||||
*/
|
|
||||||
const currentStepData = steps[currentStep];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate selected amount.
|
|
||||||
*/
|
|
||||||
const selectedAmount = availableUtxos
|
|
||||||
.filter(u => u.selected)
|
|
||||||
.reduce((sum, u) => sum + u.valueSatoshis, 0n);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate change amount.
|
|
||||||
*/
|
|
||||||
const changeAmount = selectedAmount - requiredAmount - fee;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load available UTXOs for the inputs step.
|
|
||||||
*/
|
|
||||||
const loadAvailableUtxos = useCallback(async () => {
|
|
||||||
if (!invitation || !templateIdentifier || !appService || !invitationId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsProcessing(true);
|
|
||||||
setStatus('Finding suitable UTXOs...');
|
|
||||||
|
|
||||||
// First, get the required amount from variables (e.g., requestedSatoshis)
|
|
||||||
const requestedVar = variables.find(v =>
|
|
||||||
v.id.toLowerCase().includes('satoshi') ||
|
|
||||||
v.id.toLowerCase().includes('amount')
|
|
||||||
);
|
|
||||||
const requested = requestedVar ? BigInt(requestedVar.value || '0') : 0n;
|
|
||||||
setRequiredAmount(requested);
|
|
||||||
|
|
||||||
// Get the invitation instance
|
|
||||||
const invitationInstance = appService.invitations.find(
|
|
||||||
inv => inv.data.invitationIdentifier === invitationId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!invitationInstance) {
|
|
||||||
throw new Error('Invitation not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find suitable resources
|
|
||||||
const unspentOutputs = await invitationInstance.findSuitableResources({
|
|
||||||
templateIdentifier,
|
|
||||||
outputIdentifier: 'receiveOutput', // Common output identifier
|
|
||||||
});
|
|
||||||
|
|
||||||
// Convert 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 to cover required amount + fee
|
|
||||||
let accumulated = 0n;
|
|
||||||
const seenLockingBytecodes = new Set<string>();
|
|
||||||
|
|
||||||
for (const utxo of utxos) {
|
|
||||||
// Ensure lockingBytecode uniqueness
|
|
||||||
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);
|
|
||||||
setStatus('Ready');
|
|
||||||
} catch (error) {
|
|
||||||
showError(`Failed to load UTXOs: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
}, [invitation, templateIdentifier, variables, appService, invitationId, showError, setStatus]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle UTXO selection.
|
|
||||||
*/
|
|
||||||
const toggleUtxoSelection = useCallback((index: number) => {
|
|
||||||
setAvailableUtxos(prev => {
|
|
||||||
const updated = [...prev];
|
|
||||||
const utxo = updated[index];
|
|
||||||
if (utxo) {
|
|
||||||
updated[index] = { ...utxo, selected: !utxo.selected };
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate to next step.
|
|
||||||
*/
|
|
||||||
const nextStep = useCallback(async () => {
|
|
||||||
if (currentStep >= steps.length - 1) return;
|
|
||||||
|
|
||||||
const stepType = currentStepData?.type;
|
|
||||||
|
|
||||||
// Handle step-specific logic
|
|
||||||
if (stepType === 'variables') {
|
|
||||||
// Validate that all required variables have values
|
|
||||||
const emptyVars = variables.filter(v => !v.value || v.value.trim() === '');
|
|
||||||
if (emptyVars.length > 0) {
|
|
||||||
showError(`Please enter values for: ${emptyVars.map(v => v.name).join(', ')}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create invitation and add variables
|
|
||||||
await createInvitationWithVariables();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stepType === 'inputs') {
|
|
||||||
// Add selected inputs and outputs to invitation
|
|
||||||
await addInputsAndOutputs();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stepType === 'review') {
|
|
||||||
// Publish invitation
|
|
||||||
await publishInvitation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentStep(prev => prev + 1);
|
|
||||||
setFocusArea('content');
|
|
||||||
setFocusedInput(0);
|
|
||||||
}, [currentStep, steps.length, currentStepData, variables, showError]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create invitation and add variables.
|
|
||||||
*/
|
|
||||||
const createInvitationWithVariables = useCallback(async () => {
|
|
||||||
if (!templateIdentifier || !actionIdentifier || !roleIdentifier || !template || !appService) return;
|
|
||||||
|
|
||||||
setIsProcessing(true);
|
|
||||||
setStatus('Creating invitation...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create invitation using the engine
|
|
||||||
const xoInvitation = await appService.engine.createInvitation({
|
|
||||||
templateIdentifier,
|
|
||||||
actionIdentifier,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wrap it in an Invitation instance and add to AppService tracking
|
|
||||||
const invitationInstance = await appService.createInvitation(xoInvitation);
|
|
||||||
|
|
||||||
console.log('Invitation Instance:', invitationInstance);
|
|
||||||
|
|
||||||
let inv = invitationInstance.data;
|
|
||||||
const invId = inv.invitationIdentifier;
|
|
||||||
setInvitationId(invId);
|
|
||||||
|
|
||||||
// Add variables if any
|
|
||||||
if (variables.length > 0) {
|
|
||||||
const variableData = variables.map(v => {
|
|
||||||
// Determine if this is a numeric type that should be BigInt
|
|
||||||
// Template types include: 'integer', 'number', 'satoshis'
|
|
||||||
// Hints include: 'satoshis', 'amount'
|
|
||||||
const isNumeric = ['integer', 'number', 'satoshis'].includes(v.type) ||
|
|
||||||
(v.hint && ['satoshis', 'amount'].includes(v.hint));
|
|
||||||
|
|
||||||
return {
|
|
||||||
variableIdentifier: v.id,
|
|
||||||
roleIdentifier: roleIdentifier,
|
|
||||||
value: isNumeric ? BigInt(v.value || '0') : v.value,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
await invitationInstance.addVariables(variableData);
|
|
||||||
inv = invitationInstance.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add template-required outputs for the current role
|
|
||||||
// This is critical - the template defines which outputs the initiator must create
|
|
||||||
const action = template.actions?.[actionIdentifier];
|
|
||||||
const transaction = action?.transaction ? template.transactions?.[action.transaction] : null;
|
|
||||||
|
|
||||||
if (transaction?.outputs && transaction.outputs.length > 0) {
|
|
||||||
setStatus('Adding required outputs...');
|
|
||||||
|
|
||||||
// Add each required output with just its identifier
|
|
||||||
// IMPORTANT: Do NOT pass roleIdentifier here - if roleIdentifier is set,
|
|
||||||
// the engine skips generating the lockingBytecode (see engine.ts appendInvitation)
|
|
||||||
// The engine will automatically generate the locking bytecode based on the template
|
|
||||||
const outputsToAdd = transaction.outputs.map((outputId: string) => ({
|
|
||||||
outputIdentifier: outputId,
|
|
||||||
// Note: roleIdentifier intentionally omitted to trigger lockingBytecode generation
|
|
||||||
}));
|
|
||||||
|
|
||||||
await invitationInstance.addOutputs(outputsToAdd);
|
|
||||||
inv = invitationInstance.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
setInvitation(inv);
|
|
||||||
|
|
||||||
// Check if next step is inputs
|
|
||||||
const nextStepType = steps[currentStep + 1]?.type;
|
|
||||||
if (nextStepType === 'inputs') {
|
|
||||||
setCurrentStep(prev => prev + 1);
|
|
||||||
// Load UTXOs after step change
|
|
||||||
setTimeout(() => loadAvailableUtxos(), 100);
|
|
||||||
} else {
|
|
||||||
setCurrentStep(prev => prev + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus('Invitation created');
|
|
||||||
} catch (error) {
|
|
||||||
showError(`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
}, [templateIdentifier, actionIdentifier, roleIdentifier, template, variables, appService, steps, currentStep, showError, setStatus, loadAvailableUtxos]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add selected inputs and change output to invitation.
|
|
||||||
*/
|
|
||||||
const addInputsAndOutputs = useCallback(async () => {
|
|
||||||
if (!invitationId || !invitation || !appService) return;
|
|
||||||
|
|
||||||
const selectedUtxos = availableUtxos.filter(u => u.selected);
|
|
||||||
|
|
||||||
if (selectedUtxos.length === 0) {
|
|
||||||
showError('Please select at least one UTXO');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedAmount < requiredAmount + fee) {
|
|
||||||
showError(`Insufficient funds. Need ${formatSatoshis(requiredAmount + fee)}, selected ${formatSatoshis(selectedAmount)}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changeAmount < 546n) { // Dust threshold
|
|
||||||
showError(`Change amount (${changeAmount}) is below dust threshold (546 sats)`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsProcessing(true);
|
|
||||||
setStatus('Adding inputs and outputs...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get the invitation instance
|
|
||||||
const invitationInstance = appService.invitations.find(
|
|
||||||
inv => inv.data.invitationIdentifier === invitationId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!invitationInstance) {
|
|
||||||
throw new Error('Invitation not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add inputs
|
|
||||||
const inputs = selectedUtxos.map(utxo => ({
|
|
||||||
outpointTransactionHash: new Uint8Array(Buffer.from(utxo.outpointTransactionHash, 'hex')),
|
|
||||||
outpointIndex: utxo.outpointIndex,
|
|
||||||
}));
|
|
||||||
|
|
||||||
await invitationInstance.addInputs(inputs);
|
|
||||||
|
|
||||||
// Add change output
|
|
||||||
const outputs = [{
|
|
||||||
valueSatoshis: changeAmount,
|
|
||||||
// The engine will automatically generate the locking bytecode for change
|
|
||||||
}];
|
|
||||||
|
|
||||||
await invitationInstance.addOutputs(outputs);
|
|
||||||
|
|
||||||
// Add transaction metadata
|
|
||||||
// Note: This would be done via appendInvitation but we don't have direct access here
|
|
||||||
// The engine should handle defaults
|
|
||||||
|
|
||||||
setCurrentStep(prev => prev + 1);
|
|
||||||
setStatus('Inputs and outputs added');
|
|
||||||
} catch (error) {
|
|
||||||
showError(`Failed to add inputs/outputs: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
}, [invitationId, invitation, availableUtxos, selectedAmount, requiredAmount, fee, changeAmount, appService, showError, setStatus]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish invitation.
|
|
||||||
*/
|
|
||||||
const publishInvitation = useCallback(async () => {
|
|
||||||
if (!invitationId || !appService) return;
|
|
||||||
|
|
||||||
setIsProcessing(true);
|
|
||||||
setStatus('Publishing invitation...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get the invitation instance
|
|
||||||
const invitationInstance = appService.invitations.find(
|
|
||||||
inv => inv.data.invitationIdentifier === invitationId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!invitationInstance) {
|
|
||||||
throw new Error('Invitation not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// The invitation is already being tracked and synced via SSE
|
|
||||||
// (started when created by appService.createInvitation)
|
|
||||||
// No additional publish step needed
|
|
||||||
|
|
||||||
setCurrentStep(prev => prev + 1);
|
|
||||||
setStatus('Invitation published');
|
|
||||||
} catch (error) {
|
|
||||||
showError(`Failed to publish: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
}, [invitationId, appService, showError, setStatus]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate to previous step.
|
|
||||||
*/
|
|
||||||
const previousStep = useCallback(() => {
|
|
||||||
if (currentStep <= 0) {
|
|
||||||
goBack();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCurrentStep(prev => prev - 1);
|
|
||||||
setFocusArea('content');
|
|
||||||
setFocusedInput(0);
|
|
||||||
}, [currentStep, goBack]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel wizard.
|
|
||||||
*/
|
|
||||||
const cancel = useCallback(() => {
|
|
||||||
goBack();
|
|
||||||
}, [goBack]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy invitation ID to clipboard.
|
|
||||||
*/
|
|
||||||
const copyId = useCallback(async () => {
|
|
||||||
if (!invitationId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await copyToClipboard(invitationId);
|
|
||||||
showInfo(`Copied to clipboard!\n\n${invitationId}`);
|
|
||||||
} catch (error) {
|
|
||||||
showError(`Failed to copy: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
}, [invitationId, showInfo, showError]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update variable value.
|
|
||||||
*/
|
|
||||||
const updateVariable = useCallback((index: number, value: string) => {
|
|
||||||
setVariables(prev => {
|
|
||||||
const updated = [...prev];
|
|
||||||
const variable = updated[index];
|
|
||||||
if (variable) {
|
|
||||||
updated[index] = { ...variable, value };
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Check if TextInput should have exclusive focus (variables step with content focus)
|
|
||||||
const textInputHasFocus = currentStepData?.type === 'variables' && focusArea === 'content';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle TextInput submit (Enter key) - moves to next variable or buttons.
|
|
||||||
*/
|
|
||||||
const handleTextInputSubmit = useCallback(() => {
|
|
||||||
if (focusedInput < variables.length - 1) {
|
|
||||||
setFocusedInput(prev => prev + 1);
|
|
||||||
} else {
|
|
||||||
setFocusArea('buttons');
|
|
||||||
setFocusedButton('next');
|
|
||||||
}
|
|
||||||
}, [focusedInput, variables.length]);
|
|
||||||
|
|
||||||
// Keyboard handler - COMPLETELY DISABLED when TextInput has focus
|
|
||||||
// This allows TextInput to receive character input without interference
|
|
||||||
// When TextInput is focused, use Enter to navigate (handled by onSubmit callback)
|
|
||||||
useInput((input, key) => {
|
|
||||||
// Tab to switch between content and buttons
|
|
||||||
if (key.tab) {
|
|
||||||
if (focusArea === 'content') {
|
|
||||||
// Handle tab based on current step type
|
|
||||||
if (currentStepData?.type === 'inputs' && availableUtxos.length > 0) {
|
|
||||||
if (selectedUtxoIndex < availableUtxos.length - 1) {
|
|
||||||
setSelectedUtxoIndex(prev => prev + 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setFocusArea('buttons');
|
|
||||||
setFocusedButton('next');
|
|
||||||
} else {
|
|
||||||
if (focusedButton === 'back') {
|
|
||||||
setFocusedButton('cancel');
|
|
||||||
} else if (focusedButton === 'cancel') {
|
|
||||||
setFocusedButton('next');
|
|
||||||
} else {
|
|
||||||
setFocusArea('content');
|
|
||||||
setFocusedInput(0);
|
|
||||||
setSelectedUtxoIndex(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Arrow keys for UTXO selection
|
|
||||||
if (focusArea === 'content' && currentStepData?.type === 'inputs') {
|
|
||||||
if (key.upArrow) {
|
|
||||||
setSelectedUtxoIndex(prev => Math.max(0, prev - 1));
|
|
||||||
} else if (key.downArrow) {
|
|
||||||
setSelectedUtxoIndex(prev => Math.min(availableUtxos.length - 1, prev + 1));
|
|
||||||
} else if (key.return || input === ' ') {
|
|
||||||
toggleUtxoSelection(selectedUtxoIndex);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Arrow keys in buttons area
|
|
||||||
if (focusArea === 'buttons') {
|
|
||||||
if (key.leftArrow) {
|
|
||||||
setFocusedButton(prev =>
|
|
||||||
prev === 'next' ? 'cancel' : prev === 'cancel' ? 'back' : 'back'
|
|
||||||
);
|
|
||||||
} else if (key.rightArrow) {
|
|
||||||
setFocusedButton(prev =>
|
|
||||||
prev === 'back' ? 'cancel' : prev === 'cancel' ? 'next' : 'next'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enter on buttons
|
|
||||||
if (key.return && focusArea === 'buttons') {
|
|
||||||
if (focusedButton === 'back') previousStep();
|
|
||||||
else if (focusedButton === 'cancel') cancel();
|
|
||||||
else if (focusedButton === 'next') nextStep();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 'c' to copy on publish step
|
|
||||||
if (input === 'c' && currentStepData?.type === 'publish' && invitationId) {
|
|
||||||
copyId();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 'a' to select all UTXOs
|
|
||||||
if (input === 'a' && currentStepData?.type === 'inputs') {
|
|
||||||
setAvailableUtxos(prev => prev.map(u => ({ ...u, selected: true })));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 'n' to deselect all UTXOs
|
|
||||||
if (input === 'n' && currentStepData?.type === 'inputs') {
|
|
||||||
setAvailableUtxos(prev => prev.map(u => ({ ...u, selected: false })));
|
|
||||||
}
|
|
||||||
}, { isActive: !textInputHasFocus });
|
|
||||||
|
|
||||||
// Get action details
|
|
||||||
const action = template?.actions?.[actionIdentifier ?? ''];
|
|
||||||
const actionName = action?.name || actionIdentifier || 'Unknown';
|
|
||||||
|
|
||||||
// Render step content
|
|
||||||
const renderStepContent = () => {
|
|
||||||
if (!currentStepData) return null;
|
|
||||||
|
|
||||||
switch (currentStepData.type) {
|
|
||||||
case 'info':
|
|
||||||
return (
|
|
||||||
<Box flexDirection='column'>
|
|
||||||
<Text color={colors.primary} bold>Action: {actionName}</Text>
|
|
||||||
<Text color={colors.textMuted}>{action?.description || 'No description'}</Text>
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text color={colors.text}>Your Role: </Text>
|
|
||||||
<Text color={colors.accent}>{roleIdentifier}</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{action?.roles?.[roleIdentifier ?? '']?.requirements && (
|
|
||||||
<Box marginTop={1} flexDirection='column'>
|
|
||||||
<Text color={colors.text}>Requirements:</Text>
|
|
||||||
{action.roles[roleIdentifier ?? '']?.requirements?.variables?.map(v => (
|
|
||||||
<Text key={v} color={colors.textMuted}> • Variable: {v}</Text>
|
|
||||||
))}
|
|
||||||
{action.roles[roleIdentifier ?? '']?.requirements?.slots && (
|
|
||||||
<Text color={colors.textMuted}> • Slots: {action.roles[roleIdentifier ?? '']?.requirements?.slots?.min} min (UTXO selection required)</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'variables':
|
|
||||||
return (
|
|
||||||
<Box flexDirection='column'>
|
|
||||||
<Text color={colors.text} bold>Enter required values:</Text>
|
|
||||||
<Box marginTop={1} flexDirection='column'>
|
|
||||||
{variables.map((variable, index) => (
|
|
||||||
<VariableInputField
|
|
||||||
key={variable.id}
|
|
||||||
variable={variable}
|
|
||||||
index={index}
|
|
||||||
isFocused={focusArea === 'content' && focusedInput === index}
|
|
||||||
onChange={updateVariable}
|
|
||||||
onSubmit={handleTextInputSubmit}
|
|
||||||
borderColor={colors.border as string}
|
|
||||||
focusColor={colors.primary as string}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text color={colors.textMuted} dimColor>
|
|
||||||
Type your value, then press Enter to continue
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'inputs':
|
|
||||||
return (
|
|
||||||
<Box flexDirection='column'>
|
|
||||||
<Text color={colors.text} bold>Select UTXOs to fund the transaction:</Text>
|
|
||||||
|
|
||||||
<Box marginTop={1} flexDirection='column'>
|
|
||||||
<Text color={colors.textMuted}>
|
|
||||||
Required: {formatSatoshis(requiredAmount)} + {formatSatoshis(fee)} fee
|
|
||||||
</Text>
|
|
||||||
<Text color={selectedAmount >= requiredAmount + fee ? colors.success : colors.warning}>
|
|
||||||
Selected: {formatSatoshis(selectedAmount)}
|
|
||||||
</Text>
|
|
||||||
{selectedAmount > requiredAmount + fee && (
|
|
||||||
<Text color={colors.info}>
|
|
||||||
Change: {formatSatoshis(changeAmount)}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box marginTop={1} flexDirection='column' borderStyle='single' borderColor={colors.border} paddingX={1}>
|
|
||||||
{availableUtxos.length === 0 ? (
|
|
||||||
<Text color={colors.textMuted}>No UTXOs available</Text>
|
|
||||||
) : (
|
|
||||||
availableUtxos.map((utxo, index) => (
|
|
||||||
<Box key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}>
|
|
||||||
<Text
|
|
||||||
color={selectedUtxoIndex === index && focusArea === 'content' ? colors.focus : colors.text}
|
|
||||||
bold={selectedUtxoIndex === index && focusArea === 'content'}
|
|
||||||
>
|
|
||||||
{selectedUtxoIndex === index && focusArea === 'content' ? '▸ ' : ' '}
|
|
||||||
[{utxo.selected ? 'X' : ' '}] {formatSatoshis(utxo.valueSatoshis)} - {formatHex(utxo.outpointTransactionHash, 12)}:{utxo.outpointIndex}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text color={colors.textMuted} dimColor>
|
|
||||||
Space/Enter: Toggle • a: Select all • n: Deselect all
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'review':
|
|
||||||
const selectedUtxos = availableUtxos.filter(u => u.selected);
|
|
||||||
return (
|
|
||||||
<Box flexDirection='column'>
|
|
||||||
<Text color={colors.text} bold>Review your invitation:</Text>
|
|
||||||
|
|
||||||
<Box marginTop={1} flexDirection='column'>
|
|
||||||
<Text color={colors.textMuted}>Template: {template?.name}</Text>
|
|
||||||
<Text color={colors.textMuted}>Action: {actionName}</Text>
|
|
||||||
<Text color={colors.textMuted}>Role: {roleIdentifier}</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{variables.length > 0 && (
|
|
||||||
<Box marginTop={1} flexDirection='column'>
|
|
||||||
<Text color={colors.text}>Variables:</Text>
|
|
||||||
{variables.map(v => (
|
|
||||||
<Text key={v.id} color={colors.textMuted}>
|
|
||||||
{' '}{v.name}: {v.value || '(empty)'}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedUtxos.length > 0 && (
|
|
||||||
<Box marginTop={1} flexDirection='column'>
|
|
||||||
<Text color={colors.text}>Inputs ({selectedUtxos.length}):</Text>
|
|
||||||
{selectedUtxos.slice(0, 3).map(u => (
|
|
||||||
<Text key={`${u.outpointTransactionHash}:${u.outpointIndex}`} color={colors.textMuted}>
|
|
||||||
{' '}{formatSatoshis(u.valueSatoshis)}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
{selectedUtxos.length > 3 && (
|
|
||||||
<Text color={colors.textMuted}> ...and {selectedUtxos.length - 3} more</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{changeAmount > 0 && (
|
|
||||||
<Box marginTop={1} flexDirection='column'>
|
|
||||||
<Text color={colors.text}>Outputs:</Text>
|
|
||||||
<Text color={colors.textMuted}> Change: {formatSatoshis(changeAmount)}</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text color={colors.warning}>
|
|
||||||
Press Next to create and publish the invitation.
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'publish':
|
|
||||||
return (
|
|
||||||
<Box flexDirection='column'>
|
|
||||||
<Text color={colors.success} bold>✓ Invitation Created & Published!</Text>
|
|
||||||
<Box marginTop={1} flexDirection='column'>
|
|
||||||
<Text color={colors.text}>Invitation ID:</Text>
|
|
||||||
<Box
|
|
||||||
borderStyle='single'
|
|
||||||
borderColor={colors.primary}
|
|
||||||
paddingX={1}
|
|
||||||
marginTop={1}
|
|
||||||
>
|
|
||||||
<Text color={colors.accent}>{invitationId}</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text color={colors.textMuted}>
|
|
||||||
Share this ID with the other party to complete the transaction.
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text color={colors.warning}>Press 'c' to copy ID to clipboard</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert steps to StepIndicator format
|
|
||||||
const stepIndicatorSteps: Step[] = steps.map(s => ({ label: s.name }));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box flexDirection='column' flexGrow={1}>
|
|
||||||
{/* Header */}
|
|
||||||
<Box borderStyle='single' borderColor={colors.secondary} paddingX={1} flexDirection='column'>
|
|
||||||
<Text color={colors.primary} bold>{logoSmall} - Action Wizard</Text>
|
|
||||||
<Text color={colors.textMuted}>
|
|
||||||
{template?.name} {'>'} {actionName} (as {roleIdentifier})
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Progress indicator */}
|
|
||||||
<Box marginTop={1} paddingX={1}>
|
|
||||||
<StepIndicator steps={stepIndicatorSteps} currentStep={currentStep} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Content area */}
|
|
||||||
<Box
|
|
||||||
borderStyle='single'
|
|
||||||
borderColor={focusArea === 'content' ? colors.focus : colors.primary}
|
|
||||||
flexDirection='column'
|
|
||||||
paddingX={1}
|
|
||||||
paddingY={1}
|
|
||||||
marginTop={1}
|
|
||||||
marginX={1}
|
|
||||||
flexGrow={1}
|
|
||||||
>
|
|
||||||
<Text color={colors.primary} bold>
|
|
||||||
{' '}{currentStepData?.name} ({currentStep + 1}/{steps.length}){' '}
|
|
||||||
</Text>
|
|
||||||
<Box marginTop={1}>
|
|
||||||
{isProcessing ? (
|
|
||||||
<Text color={colors.info}>Processing...</Text>
|
|
||||||
) : (
|
|
||||||
renderStepContent()
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Buttons */}
|
|
||||||
<Box marginTop={1} marginX={1} justifyContent='space-between'>
|
|
||||||
<Box gap={1}>
|
|
||||||
<Button
|
|
||||||
label='Back'
|
|
||||||
focused={focusArea === 'buttons' && focusedButton === 'back'}
|
|
||||||
disabled={currentStepData?.type === 'publish'}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
label='Cancel'
|
|
||||||
focused={focusArea === 'buttons' && focusedButton === 'cancel'}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Button
|
|
||||||
label={currentStepData?.type === 'publish' ? 'Done' : 'Next'}
|
|
||||||
focused={focusArea === 'buttons' && focusedButton === 'next'}
|
|
||||||
disabled={isProcessing}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Help text */}
|
|
||||||
<Box marginTop={1} marginX={1}>
|
|
||||||
<Text color={colors.textMuted} dimColor>
|
|
||||||
Tab: Navigate • Enter: Select • Esc: Back
|
|
||||||
{currentStepData?.type === 'publish' ? ' • c: Copy ID' : ''}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,20 @@ import { colors, logoSmall } from '../theme.js';
|
|||||||
|
|
||||||
// XO Imports
|
// XO Imports
|
||||||
import { generateTemplateIdentifier } from '@xo-cash/engine';
|
import { generateTemplateIdentifier } from '@xo-cash/engine';
|
||||||
import type { XOTemplate, XOTemplateActionRoleRequirement, XOTemplateStartingActions } from '@xo-cash/types';
|
import type { XOTemplate } from '@xo-cash/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A unique starting action (deduplicated by action identifier).
|
||||||
|
* Multiple roles that can start the same action are counted
|
||||||
|
* but not shown as separate entries — role selection happens
|
||||||
|
* inside the Action Wizard.
|
||||||
|
*/
|
||||||
|
interface UniqueStartingAction {
|
||||||
|
actionIdentifier: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
roleCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Template item with metadata.
|
* Template item with metadata.
|
||||||
@@ -22,7 +35,7 @@ import type { XOTemplate, XOTemplateActionRoleRequirement, XOTemplateStartingAct
|
|||||||
interface TemplateItem {
|
interface TemplateItem {
|
||||||
template: XOTemplate;
|
template: XOTemplate;
|
||||||
templateIdentifier: string;
|
templateIdentifier: string;
|
||||||
startingActions: XOTemplateStartingActions;
|
startingActions: UniqueStartingAction[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,8 +72,30 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
const loadedTemplates = await Promise.all(
|
const loadedTemplates = await Promise.all(
|
||||||
templateList.map(async (template) => {
|
templateList.map(async (template) => {
|
||||||
const templateIdentifier = generateTemplateIdentifier(template);
|
const templateIdentifier = generateTemplateIdentifier(template);
|
||||||
const startingActions = await appService.engine.listStartingActions(templateIdentifier);
|
const rawStartingActions = await appService.engine.listStartingActions(templateIdentifier);
|
||||||
return { template, templateIdentifier, startingActions };
|
|
||||||
|
// Deduplicate by action identifier — role selection
|
||||||
|
// is handled inside the Action Wizard, not here.
|
||||||
|
const actionMap = new Map<string, UniqueStartingAction>();
|
||||||
|
for (const sa of rawStartingActions) {
|
||||||
|
if (actionMap.has(sa.action)) {
|
||||||
|
actionMap.get(sa.action)!.roleCount++;
|
||||||
|
} else {
|
||||||
|
const actionDef = template.actions?.[sa.action];
|
||||||
|
actionMap.set(sa.action, {
|
||||||
|
actionIdentifier: sa.action,
|
||||||
|
name: actionDef?.name || sa.action,
|
||||||
|
description: actionDef?.description,
|
||||||
|
roleCount: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
template,
|
||||||
|
templateIdentifier,
|
||||||
|
startingActions: Array.from(actionMap.values()),
|
||||||
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -86,6 +121,7 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles action selection.
|
* Handles action selection.
|
||||||
|
* Navigates to the Action Wizard where the user will choose their role.
|
||||||
*/
|
*/
|
||||||
const handleActionSelect = useCallback(() => {
|
const handleActionSelect = useCallback(() => {
|
||||||
if (!currentTemplate || currentActions.length === 0) return;
|
if (!currentTemplate || currentActions.length === 0) return;
|
||||||
@@ -93,11 +129,10 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
const action = currentActions[selectedActionIndex];
|
const action = currentActions[selectedActionIndex];
|
||||||
if (!action) return;
|
if (!action) return;
|
||||||
|
|
||||||
// Navigate to action wizard with selected template and action
|
// Navigate to the Action Wizard — role selection happens there
|
||||||
navigate('wizard', {
|
navigate('wizard', {
|
||||||
templateIdentifier: currentTemplate.templateIdentifier,
|
templateIdentifier: currentTemplate.templateIdentifier,
|
||||||
actionIdentifier: action.action,
|
actionIdentifier: action.actionIdentifier,
|
||||||
roleIdentifier: action.role,
|
|
||||||
template: currentTemplate.template,
|
template: currentTemplate.template,
|
||||||
});
|
});
|
||||||
}, [currentTemplate, currentActions, selectedActionIndex, navigate]);
|
}, [currentTemplate, currentActions, selectedActionIndex, navigate]);
|
||||||
@@ -211,20 +246,19 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Starting actions state
|
// Starting actions state
|
||||||
return currentActions.map((action, index) => {
|
return currentActions.map((action, index) => (
|
||||||
const actionDef = currentTemplate.template.actions?.[action.action];
|
<Text
|
||||||
const name = actionDef?.name || action.action;
|
key={action.actionIdentifier}
|
||||||
return (
|
color={index === selectedActionIndex ? colors.focus : colors.text}
|
||||||
<Text
|
bold={index === selectedActionIndex}
|
||||||
key={`${action.action}-${action.role}`}
|
>
|
||||||
color={index === selectedActionIndex ? colors.focus : colors.text}
|
{index === selectedActionIndex && focusedPanel === 'actions' ? '▸ ' : ' '}
|
||||||
bold={index === selectedActionIndex}
|
{index + 1}. {action.name}
|
||||||
>
|
{action.roleCount > 1
|
||||||
{index === selectedActionIndex && focusedPanel === 'actions' ? '▸ ' : ' '}
|
? ` (${action.roleCount} roles)`
|
||||||
{index + 1}. {name} (as {action.role})
|
: ''}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
));
|
||||||
});
|
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
@@ -280,50 +314,34 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
const action = currentActions[selectedActionIndex];
|
const action = currentActions[selectedActionIndex];
|
||||||
if (!action) return null;
|
if (!action) return null;
|
||||||
|
|
||||||
const actionDef = currentTemplate.template.actions?.[action.action];
|
// Collect all roles that can start this action
|
||||||
const roleDef = currentTemplate.template.roles?.[action.role];
|
const startEntries = (currentTemplate.template.start ?? [])
|
||||||
|
.filter((s) => s.action === action.actionIdentifier);
|
||||||
// if (!actionDef || !roleDef) return null;
|
|
||||||
|
|
||||||
const [_roleName, role] = Object.entries(actionDef?.roles ?? {}).find(([roleId, role]) => roleId === action.role) || [];
|
|
||||||
|
|
||||||
console.log(JSON.stringify(role, null, 2));
|
|
||||||
|
|
||||||
const variableKeys = role?.requirements?.variables || []
|
|
||||||
|
|
||||||
console.log('variables', variableKeys);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text color={colors.text} bold>
|
<Text color={colors.text} bold>
|
||||||
{actionDef?.name || action.action}
|
{action.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={colors.textMuted}>
|
<Text color={colors.textMuted}>
|
||||||
{actionDef?.description || 'No description available'}
|
{action.description || 'No description available'}
|
||||||
</Text>
|
</Text>
|
||||||
<Box marginTop={1} flexDirection='column'>
|
|
||||||
<Text color={colors.text}>
|
{/* List available roles for this action */}
|
||||||
Role: {roleDef?.name || action.role}
|
{startEntries.length > 0 && (
|
||||||
</Text>
|
<Box marginTop={1} flexDirection='column'>
|
||||||
{roleDef?.description && (
|
<Text color={colors.text}>Available Roles:</Text>
|
||||||
<Text color={colors.textMuted}>
|
{startEntries.map((entry) => {
|
||||||
{' '}{roleDef.description}
|
const roleDef = currentTemplate.template.roles?.[entry.role];
|
||||||
</Text>
|
return (
|
||||||
)}
|
<Text key={entry.role} color={colors.textMuted}>
|
||||||
</Box>
|
{' '}- {roleDef?.name || entry.role}
|
||||||
{/* Display variables if available */}
|
{roleDef?.description ? `: ${roleDef.description}` : ''}
|
||||||
{
|
|
||||||
variableKeys.length > 0 && (
|
|
||||||
<Box marginTop={1} flexDirection='column'>
|
|
||||||
<Text color={colors.text}>Variables:</Text>
|
|
||||||
{variableKeys.map((variableKey) => (
|
|
||||||
<Text key={variableKey} color={colors.text}>
|
|
||||||
- {currentTemplate.template.variables?.[variableKey]?.name || variableKey}: {currentTemplate.template.variables?.[variableKey]?.description || 'No description'}
|
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
);
|
||||||
</Box>
|
})}
|
||||||
)
|
</Box>
|
||||||
}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Wallet State Screen - Displays wallet balances and UTXOs.
|
* Wallet State Screen - Displays wallet balances and history.
|
||||||
*
|
*
|
||||||
* Shows:
|
* Shows:
|
||||||
* - Total balance
|
* - Total balance
|
||||||
* - List of unspent outputs
|
* - Wallet history (invitations, reservations)
|
||||||
* - Navigation to other actions
|
* - Navigation to other actions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { Box, Text, useInput } from 'ink';
|
import { Box, Text, useInput } from 'ink';
|
||||||
import SelectInput from 'ink-select-input';
|
import SelectInput from 'ink-select-input';
|
||||||
import { type ListItem } from '../components/List.js';
|
|
||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
import { useNavigation } from '../hooks/useNavigation.js';
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||||
import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js';
|
import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js';
|
||||||
|
import type { HistoryItem } from '../../services/history.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Menu action items.
|
* Menu action items.
|
||||||
@@ -26,20 +26,9 @@ const menuItems = [
|
|||||||
{ label: 'Refresh', value: 'refresh' },
|
{ label: 'Refresh', value: 'refresh' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* UTXO display item.
|
|
||||||
*/
|
|
||||||
interface UTXOItem {
|
|
||||||
key: string;
|
|
||||||
satoshis: bigint;
|
|
||||||
txid: string;
|
|
||||||
index: number;
|
|
||||||
reserved: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wallet State Screen Component.
|
* Wallet State Screen Component.
|
||||||
* Displays wallet balance, UTXOs, and action menu.
|
* Displays wallet balance, history, and action menu.
|
||||||
*/
|
*/
|
||||||
export function WalletStateScreen(): React.ReactElement {
|
export function WalletStateScreen(): React.ReactElement {
|
||||||
const { navigate } = useNavigation();
|
const { navigate } = useNavigation();
|
||||||
@@ -48,9 +37,9 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
|
|
||||||
// State
|
// State
|
||||||
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
|
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
|
||||||
const [utxos, setUtxos] = useState<UTXOItem[]>([]);
|
const [history, setHistory] = useState<HistoryItem[]>([]);
|
||||||
const [focusedPanel, setFocusedPanel] = useState<'menu' | 'utxos'>('menu');
|
const [focusedPanel, setFocusedPanel] = useState<'menu' | 'history'>('menu');
|
||||||
const [selectedUtxoIndex, setSelectedUtxoIndex] = useState(0);
|
const [selectedHistoryIndex, setSelectedHistoryIndex] = useState(0);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,23 +55,21 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setStatus('Loading wallet state...');
|
setStatus('Loading wallet state...');
|
||||||
|
|
||||||
// Get UTXOs
|
// Get UTXOs for balance calculation
|
||||||
const utxoData = await appService.engine.listUnspentOutputsData();
|
const utxoData = await appService.engine.listUnspentOutputsData();
|
||||||
setUtxos(utxoData.map((utxo) => ({
|
|
||||||
key: `${utxo.outpointTransactionHash}:${utxo.outpointIndex}`,
|
|
||||||
satoshis: BigInt(utxo.valueSatoshis),
|
|
||||||
txid: utxo.outpointTransactionHash,
|
|
||||||
index: utxo.outpointIndex,
|
|
||||||
reserved: utxo.reserved ?? false,
|
|
||||||
})));
|
|
||||||
|
|
||||||
// Get balance
|
// Calculate balance
|
||||||
const balanceData = utxoData.reduce((acc, utxo) => acc + BigInt(utxo.valueSatoshis), BigInt(0));
|
const selectableUtxos = utxoData.filter(utxo => utxo.selectable);
|
||||||
|
const balanceData = selectableUtxos.reduce((acc, utxo) => acc + BigInt(utxo.valueSatoshis), BigInt(0));
|
||||||
setBalance({
|
setBalance({
|
||||||
totalSatoshis: balanceData,
|
totalSatoshis: balanceData,
|
||||||
utxoCount: utxoData.length,
|
utxoCount: selectableUtxos.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get wallet history from the history service
|
||||||
|
const historyData = await appService.history.getHistory();
|
||||||
|
setHistory(historyData);
|
||||||
|
|
||||||
setStatus('Wallet ready');
|
setStatus('Wallet ready');
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -162,17 +149,19 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
// Handle keyboard navigation between panels
|
// Handle keyboard navigation between panels
|
||||||
useInput((input, key) => {
|
useInput((input, key) => {
|
||||||
if (key.tab) {
|
if (key.tab) {
|
||||||
setFocusedPanel(prev => prev === 'menu' ? 'utxos' : 'menu');
|
setFocusedPanel(prev => prev === 'menu' ? 'history' : 'menu');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate history items when focused
|
||||||
|
if (focusedPanel === 'history' && history.length > 0) {
|
||||||
|
if (key.upArrow) {
|
||||||
|
setSelectedHistoryIndex(prev => Math.max(0, prev - 1));
|
||||||
|
} else if (key.downArrow) {
|
||||||
|
setSelectedHistoryIndex(prev => Math.min(history.length - 1, prev + 1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert UTXOs to list items
|
|
||||||
const utxoListItems: ListItem[] = utxos.map((utxo, index) => ({
|
|
||||||
key: utxo.key,
|
|
||||||
label: `${formatSatoshis(utxo.satoshis)} | ${formatHex(utxo.txid, 16)}:${utxo.index}`,
|
|
||||||
description: utxo.reserved ? '[Reserved]' : undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection='column' flexGrow={1}>
|
<Box flexDirection='column' flexGrow={1}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -251,33 +240,96 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* UTXO list */}
|
{/* Wallet History */}
|
||||||
<Box marginTop={1} flexGrow={1}>
|
<Box marginTop={1} flexGrow={1}>
|
||||||
<Box
|
<Box
|
||||||
borderStyle='single'
|
borderStyle='single'
|
||||||
borderColor={focusedPanel === 'utxos' ? colors.focus : colors.border}
|
borderColor={focusedPanel === 'history' ? colors.focus : colors.border}
|
||||||
flexDirection='column'
|
flexDirection='column'
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
width='100%'
|
width='100%'
|
||||||
|
height={14}
|
||||||
|
overflow='hidden'
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Unspent Outputs (UTXOs) </Text>
|
<Text color={colors.primary} bold> Wallet History {history.length > 0 ? `(${selectedHistoryIndex + 1}/${history.length})` : ''}</Text>
|
||||||
<Box marginTop={1} flexDirection='column'>
|
<Box marginTop={1} flexDirection='column'>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Text color={colors.textMuted}>Loading...</Text>
|
<Text color={colors.textMuted}>Loading...</Text>
|
||||||
) : utxoListItems.length === 0 ? (
|
) : history.length === 0 ? (
|
||||||
<Text color={colors.textMuted}>No unspent outputs found</Text>
|
<Text color={colors.textMuted}>No history found</Text>
|
||||||
) : (
|
) : (
|
||||||
utxoListItems.map((item, index) => (
|
// Show a scrolling window of items
|
||||||
<Box key={item.key}>
|
(() => {
|
||||||
<Text color={index === selectedUtxoIndex && focusedPanel === 'utxos' ? colors.focus : colors.text}>
|
const maxVisible = 10;
|
||||||
{index === selectedUtxoIndex && focusedPanel === 'utxos' ? '▸ ' : ' '}
|
const halfWindow = Math.floor(maxVisible / 2);
|
||||||
{index + 1}. {item.label}
|
let startIndex = Math.max(0, selectedHistoryIndex - halfWindow);
|
||||||
</Text>
|
const endIndex = Math.min(history.length, startIndex + maxVisible);
|
||||||
{item.description && (
|
// Adjust start if we're near the end
|
||||||
<Text color={colors.warning}> {item.description}</Text>
|
if (endIndex - startIndex < maxVisible) {
|
||||||
)}
|
startIndex = Math.max(0, endIndex - maxVisible);
|
||||||
</Box>
|
}
|
||||||
))
|
const visibleItems = history.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
return visibleItems.map((item, idx) => {
|
||||||
|
const actualIndex = startIndex + idx;
|
||||||
|
const isSelected = actualIndex === selectedHistoryIndex && focusedPanel === 'history';
|
||||||
|
const indicator = isSelected ? '▸ ' : ' ';
|
||||||
|
const dateStr = item.timestamp
|
||||||
|
? new Date(item.timestamp).toLocaleDateString()
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Format the history item based on type
|
||||||
|
if (item.type === 'invitation_created') {
|
||||||
|
return (
|
||||||
|
<Box key={item.id} flexDirection='row' justifyContent='space-between'>
|
||||||
|
<Text color={isSelected ? colors.focus : colors.text}>
|
||||||
|
{indicator}[Invitation] {item.description}
|
||||||
|
</Text>
|
||||||
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
} else if (item.type === 'utxo_reserved') {
|
||||||
|
const sats = item.valueSatoshis ?? 0n;
|
||||||
|
return (
|
||||||
|
<Box key={item.id} flexDirection='row' justifyContent='space-between'>
|
||||||
|
<Box>
|
||||||
|
<Text color={isSelected ? colors.focus : colors.warning}>
|
||||||
|
{indicator}[Reserved] {formatSatoshis(sats)}
|
||||||
|
</Text>
|
||||||
|
<Text color={colors.textMuted}> {item.description}</Text>
|
||||||
|
</Box>
|
||||||
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
} else if (item.type === 'utxo_received') {
|
||||||
|
const sats = item.valueSatoshis ?? 0n;
|
||||||
|
const reservedTag = item.reserved ? ' [Reserved]' : '';
|
||||||
|
return (
|
||||||
|
<Box key={item.id} flexDirection='row' justifyContent='space-between'>
|
||||||
|
<Box flexDirection='row'>
|
||||||
|
<Text color={isSelected ? colors.focus : colors.success}>
|
||||||
|
{indicator}{formatSatoshis(sats)}
|
||||||
|
</Text>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{' '}{item.description}{reservedTag}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for other types
|
||||||
|
return (
|
||||||
|
<Box key={item.id} flexDirection='row' justifyContent='space-between'>
|
||||||
|
<Text color={isSelected ? colors.focus : colors.text}>
|
||||||
|
{indicator}{item.type}: {item.description}
|
||||||
|
</Text>
|
||||||
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useActionWizard } from './useActionWizard.js';
|
|||||||
|
|
||||||
// Steps
|
// Steps
|
||||||
import { InfoStep } from './steps/InfoStep.js';
|
import { InfoStep } from './steps/InfoStep.js';
|
||||||
|
import { RoleSelectStep } from './steps/RoleSelectStep.js';
|
||||||
import { VariablesStep } from './steps/VariablesStep.js';
|
import { VariablesStep } from './steps/VariablesStep.js';
|
||||||
import { InputsStep } from './steps/InputsStep.js';
|
import { InputsStep } from './steps/InputsStep.js';
|
||||||
import { ReviewStep } from './steps/ReviewStep.js';
|
import { ReviewStep } from './steps/ReviewStep.js';
|
||||||
@@ -21,6 +22,19 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
// Tab to cycle between content area and button bar
|
// Tab to cycle between content area and button bar
|
||||||
if (key.tab) {
|
if (key.tab) {
|
||||||
if (wizard.focusArea === 'content') {
|
if (wizard.focusArea === 'content') {
|
||||||
|
// Within the role-select step, tab through roles first
|
||||||
|
if (
|
||||||
|
wizard.currentStepData?.type === 'role-select' &&
|
||||||
|
wizard.availableRoles.length > 0
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
wizard.selectedRoleIndex <
|
||||||
|
wizard.availableRoles.length - 1
|
||||||
|
) {
|
||||||
|
wizard.setSelectedRoleIndex((prev) => prev + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
// Within the inputs step, tab through UTXOs first
|
// Within the inputs step, tab through UTXOs first
|
||||||
if (
|
if (
|
||||||
wizard.currentStepData?.type === 'inputs' &&
|
wizard.currentStepData?.type === 'inputs' &&
|
||||||
@@ -47,11 +61,27 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
wizard.setFocusArea('content');
|
wizard.setFocusArea('content');
|
||||||
wizard.setFocusedInput(0);
|
wizard.setFocusedInput(0);
|
||||||
wizard.setSelectedUtxoIndex(0);
|
wizard.setSelectedUtxoIndex(0);
|
||||||
|
wizard.setSelectedRoleIndex(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Arrow keys for role selection in the content area
|
||||||
|
if (
|
||||||
|
wizard.focusArea === 'content' &&
|
||||||
|
wizard.currentStepData?.type === 'role-select'
|
||||||
|
) {
|
||||||
|
if (key.upArrow) {
|
||||||
|
wizard.setSelectedRoleIndex((p) => Math.max(0, p - 1));
|
||||||
|
} else if (key.downArrow) {
|
||||||
|
wizard.setSelectedRoleIndex((p) =>
|
||||||
|
Math.min(wizard.availableRoles.length - 1, p + 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Arrow keys for UTXO selection in the content area
|
// Arrow keys for UTXO selection in the content area
|
||||||
if (
|
if (
|
||||||
wizard.focusArea === 'content' &&
|
wizard.focusArea === 'content' &&
|
||||||
@@ -131,6 +161,16 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
actionName={wizard.actionName}
|
actionName={wizard.actionName}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case 'role-select':
|
||||||
|
return (
|
||||||
|
<RoleSelectStep
|
||||||
|
template={wizard.template!}
|
||||||
|
actionIdentifier={wizard.actionIdentifier!}
|
||||||
|
availableRoles={wizard.availableRoles}
|
||||||
|
selectedRoleIndex={wizard.selectedRoleIndex}
|
||||||
|
focusArea={wizard.focusArea}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case 'variables':
|
case 'variables':
|
||||||
return (
|
return (
|
||||||
<VariablesStep
|
<VariablesStep
|
||||||
@@ -189,8 +229,8 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
{logoSmall} - Action Wizard
|
{logoSmall} - Action Wizard
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={colors.textMuted}>
|
<Text color={colors.textMuted}>
|
||||||
{wizard.template?.name} {">"} {wizard.actionName} (as{" "}
|
{wizard.template?.name} {">"} {wizard.actionName}
|
||||||
{wizard.roleIdentifier})
|
{wizard.roleIdentifier ? ` (as ${wizard.roleIdentifier})` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
120
src/tui/screens/action-wizard/steps/RoleSelectStep.tsx
Normal file
120
src/tui/screens/action-wizard/steps/RoleSelectStep.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Role Selection Step - Allows the user to choose which role they want
|
||||||
|
* to take for the selected action.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { colors } from '../../../theme.js';
|
||||||
|
import type { XOTemplate } from '@xo-cash/types';
|
||||||
|
import type { FocusArea } from '../types.js';
|
||||||
|
|
||||||
|
interface RoleSelectStepProps {
|
||||||
|
/** The loaded template definition. */
|
||||||
|
template: XOTemplate;
|
||||||
|
/** The selected action identifier. */
|
||||||
|
actionIdentifier: string;
|
||||||
|
/** Role identifiers available for this action. */
|
||||||
|
availableRoles: string[];
|
||||||
|
/** The currently focused role index. */
|
||||||
|
selectedRoleIndex: number;
|
||||||
|
/** Whether the content area or button bar is focused. */
|
||||||
|
focusArea: FocusArea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays the available roles for the selected action and
|
||||||
|
* lets the user navigate between them with arrow keys.
|
||||||
|
*/
|
||||||
|
export function RoleSelectStep({
|
||||||
|
template,
|
||||||
|
actionIdentifier,
|
||||||
|
availableRoles,
|
||||||
|
selectedRoleIndex,
|
||||||
|
focusArea,
|
||||||
|
}: RoleSelectStepProps): React.ReactElement {
|
||||||
|
const action = template.actions?.[actionIdentifier];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.text} bold>
|
||||||
|
Select your role for this action:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Action info */}
|
||||||
|
{action && (
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{action.description || 'No description available'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Role list */}
|
||||||
|
<Box
|
||||||
|
marginTop={1}
|
||||||
|
flexDirection="column"
|
||||||
|
borderStyle="single"
|
||||||
|
borderColor={colors.border}
|
||||||
|
paddingX={1}
|
||||||
|
>
|
||||||
|
{availableRoles.length === 0 ? (
|
||||||
|
<Text color={colors.textMuted}>No roles available</Text>
|
||||||
|
) : (
|
||||||
|
availableRoles.map((roleId, index) => {
|
||||||
|
const isCursor =
|
||||||
|
selectedRoleIndex === index && focusArea === 'content';
|
||||||
|
const roleDef = template.roles?.[roleId];
|
||||||
|
const actionRole = action?.roles?.[roleId];
|
||||||
|
const requirements = actionRole?.requirements;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={roleId} flexDirection="column" marginY={0}>
|
||||||
|
<Text
|
||||||
|
color={isCursor ? colors.focus : colors.text}
|
||||||
|
bold={isCursor}
|
||||||
|
>
|
||||||
|
{isCursor ? '▸ ' : ' '}
|
||||||
|
{roleDef?.name || roleId}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Show role description indented below the name */}
|
||||||
|
{roleDef?.description && (
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{' '}
|
||||||
|
{roleDef.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show a brief summary of requirements */}
|
||||||
|
{requirements && (
|
||||||
|
<Box flexDirection="row" paddingLeft={4}>
|
||||||
|
{requirements.variables && requirements.variables.length > 0 && (
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
{requirements.variables.length} variable
|
||||||
|
{requirements.variables.length !== 1 ? 's' : ''}
|
||||||
|
{' '}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{requirements.slots && requirements.slots.min > 0 && (
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
{requirements.slots.min} input slot
|
||||||
|
{requirements.slots.min !== 1 ? 's' : ''}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
↑↓: Navigate • Next: Confirm selection
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './InfoStep.js';
|
export * from './InfoStep.js';
|
||||||
|
export * from './RoleSelectStep.js';
|
||||||
export * from './VariablesStep.js';
|
export * from './VariablesStep.js';
|
||||||
export * from './InputsStep.js';
|
export * from './InputsStep.js';
|
||||||
export * from './ReviewStep.js';
|
export * from './ReviewStep.js';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { XOTemplate } from '@xo-cash/types';
|
import type { XOTemplate } from '@xo-cash/types';
|
||||||
|
|
||||||
export type StepType = 'info' | 'variables' | 'inputs' | 'review' | 'publish';
|
export type StepType = 'info' | 'role-select' | 'variables' | 'inputs' | 'review' | 'publish';
|
||||||
|
|
||||||
export interface WizardStep {
|
export interface WizardStep {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useNavigation } from '../../hooks/useNavigation.js';
|
import { useNavigation } from '../../hooks/useNavigation.js';
|
||||||
import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
|
||||||
import { formatSatoshis } from '../../theme.js';
|
import { formatSatoshis } from '../../theme.js';
|
||||||
@@ -18,11 +18,29 @@ export function useActionWizard() {
|
|||||||
const { setStatus } = useStatus();
|
const { setStatus } = useStatus();
|
||||||
|
|
||||||
// ── Navigation data ──────────────────────────────────────────────
|
// ── Navigation data ──────────────────────────────────────────────
|
||||||
|
// Role is no longer passed via navigation — it is selected in the wizard.
|
||||||
const templateIdentifier = navData.templateIdentifier as string | undefined;
|
const templateIdentifier = navData.templateIdentifier as string | undefined;
|
||||||
const actionIdentifier = navData.actionIdentifier as string | undefined;
|
const actionIdentifier = navData.actionIdentifier as string | undefined;
|
||||||
const roleIdentifier = navData.roleIdentifier as string | undefined;
|
|
||||||
const template = navData.template as XOTemplate | undefined;
|
const template = navData.template as XOTemplate | undefined;
|
||||||
|
|
||||||
|
// ── Role selection state ────────────────────────────────────────
|
||||||
|
const [roleIdentifier, setRoleIdentifier] = useState<string | undefined>();
|
||||||
|
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roles that can start this action, derived from the template's
|
||||||
|
* `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]);
|
||||||
|
|
||||||
// ── Wizard state ─────────────────────────────────────────────────
|
// ── Wizard state ─────────────────────────────────────────────────
|
||||||
const [steps, setSteps] = useState<WizardStep[]>([]);
|
const [steps, setSteps] = useState<WizardStep[]>([]);
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
@@ -61,51 +79,55 @@ export function useActionWizard() {
|
|||||||
currentStepData?.type === 'variables' && focusArea === 'content';
|
currentStepData?.type === 'variables' && focusArea === 'content';
|
||||||
|
|
||||||
// ── Initialization ───────────────────────────────────────────────
|
// ── Initialization ───────────────────────────────────────────────
|
||||||
|
// Builds the wizard steps dynamically based on the selected role.
|
||||||
|
// Re-runs when roleIdentifier changes to add role-specific steps.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!template || !actionIdentifier || !roleIdentifier) {
|
if (!template || !actionIdentifier) {
|
||||||
showError('Missing wizard data');
|
showError('Missing wizard data');
|
||||||
goBack();
|
goBack();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const act = template.actions?.[actionIdentifier];
|
|
||||||
const role = act?.roles?.[roleIdentifier];
|
|
||||||
const requirements = role?.requirements;
|
|
||||||
|
|
||||||
// const wizardSteps: WizardStep[] = [{ name: 'Welcome', type: 'info' }];
|
|
||||||
const wizardSteps: WizardStep[] = [];
|
const wizardSteps: WizardStep[] = [];
|
||||||
|
|
||||||
// Add variables step if needed
|
// Always start with role selection
|
||||||
if (requirements?.variables && requirements.variables.length > 0) {
|
wizardSteps.push({ name: 'Select Role', type: 'role-select' });
|
||||||
wizardSteps.push({ name: 'Variables', type: 'variables' });
|
|
||||||
|
|
||||||
const varInputs = requirements.variables.map((varId) => {
|
// Add role-specific steps only after role is selected
|
||||||
const varDef = template.variables?.[varId];
|
if (roleIdentifier) {
|
||||||
return {
|
const act = template.actions?.[actionIdentifier];
|
||||||
id: varId,
|
const role = act?.roles?.[roleIdentifier];
|
||||||
name: varDef?.name || varId,
|
const requirements = role?.requirements;
|
||||||
type: varDef?.type || 'string',
|
|
||||||
hint: varDef?.hint,
|
// Add variables step if needed
|
||||||
value: '',
|
if (requirements?.variables && requirements.variables.length > 0) {
|
||||||
};
|
wizardSteps.push({ name: 'Variables', type: 'variables' });
|
||||||
});
|
|
||||||
setVariables(varInputs);
|
const varInputs = requirements.variables.map((varId) => {
|
||||||
|
const varDef = template.variables?.[varId];
|
||||||
|
return {
|
||||||
|
id: varId,
|
||||||
|
name: varDef?.name || varId,
|
||||||
|
type: varDef?.type || 'string',
|
||||||
|
hint: varDef?.hint,
|
||||||
|
value: '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setVariables(varInputs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add inputs step if role requires slots (funding inputs)
|
||||||
|
if (requirements?.slots && requirements.slots.min > 0) {
|
||||||
|
wizardSteps.push({ name: 'Select UTXOs', type: 'inputs' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add inputs step if role requires slots (funding inputs)
|
// Always add review and publish at the end
|
||||||
// Slots indicate the role needs to provide transaction inputs/outputs
|
|
||||||
if (requirements?.slots && requirements.slots.min > 0) {
|
|
||||||
wizardSteps.push({ name: 'Select UTXOs', type: 'inputs' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add review step
|
|
||||||
wizardSteps.push({ name: 'Review', type: 'review' });
|
wizardSteps.push({ name: 'Review', type: 'review' });
|
||||||
|
|
||||||
// Add publish step
|
|
||||||
wizardSteps.push({ name: 'Publish', type: 'publish' });
|
wizardSteps.push({ name: 'Publish', type: 'publish' });
|
||||||
|
|
||||||
setSteps(wizardSteps);
|
setSteps(wizardSteps);
|
||||||
setStatus(`${actionIdentifier}/${roleIdentifier}`);
|
setStatus(roleIdentifier ? `${actionIdentifier}/${roleIdentifier}` : actionIdentifier);
|
||||||
}, [
|
}, [
|
||||||
template,
|
template,
|
||||||
actionIdentifier,
|
actionIdentifier,
|
||||||
@@ -115,6 +137,17 @@ export function useActionWizard() {
|
|||||||
setStatus,
|
setStatus,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// ── Auto-advance from role-select after role is chosen ──────────
|
||||||
|
// 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') {
|
||||||
|
setCurrentStep(1);
|
||||||
|
setFocusArea('content');
|
||||||
|
setFocusedInput(0);
|
||||||
|
}
|
||||||
|
}, [roleIdentifier, currentStep, steps]);
|
||||||
|
|
||||||
// ── Update a single variable value ───────────────────────────────
|
// ── Update a single variable value ───────────────────────────────
|
||||||
const updateVariable = useCallback((index: number, value: string) => {
|
const updateVariable = useCallback((index: number, value: string) => {
|
||||||
setVariables((prev) => {
|
setVariables((prev) => {
|
||||||
@@ -255,103 +288,108 @@ export function useActionWizard() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// ── Create invitation and persist variables ─────────────────────
|
// ── Create invitation and persist variables ─────────────────────
|
||||||
const createInvitationWithVariables = useCallback(async () => {
|
/**
|
||||||
if (
|
* Creates an invitation, optionally persists variable values,
|
||||||
!templateIdentifier ||
|
* and adds template-required outputs.
|
||||||
!actionIdentifier ||
|
*
|
||||||
!roleIdentifier ||
|
* Accepts an explicit `roleId` to avoid stale-closure issues
|
||||||
!template ||
|
* when called immediately after setting role state.
|
||||||
!appService
|
*
|
||||||
) {
|
* Does NOT advance the wizard step — the caller is responsible.
|
||||||
return;
|
*
|
||||||
}
|
* @returns `true` on success, `false` on failure.
|
||||||
|
*/
|
||||||
|
const createInvitationWithVariables = useCallback(
|
||||||
|
async (roleId?: string): Promise<boolean> => {
|
||||||
|
const effectiveRole = roleId ?? roleIdentifier;
|
||||||
|
|
||||||
setIsProcessing(true);
|
if (
|
||||||
setStatus('Creating invitation...');
|
!templateIdentifier ||
|
||||||
|
!actionIdentifier ||
|
||||||
|
!effectiveRole ||
|
||||||
|
!template ||
|
||||||
|
!appService
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
setIsProcessing(true);
|
||||||
// Create via the engine
|
setStatus('Creating invitation...');
|
||||||
const xoInvitation = await appService.engine.createInvitation({
|
|
||||||
templateIdentifier,
|
|
||||||
actionIdentifier,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wrap and track
|
try {
|
||||||
const invitationInstance =
|
// Create via the engine
|
||||||
await appService.createInvitation(xoInvitation);
|
const xoInvitation = await appService.engine.createInvitation({
|
||||||
|
templateIdentifier,
|
||||||
let inv = invitationInstance.data;
|
actionIdentifier,
|
||||||
const invId = inv.invitationIdentifier;
|
|
||||||
setInvitationId(invId);
|
|
||||||
|
|
||||||
// Persist variable values
|
|
||||||
if (variables.length > 0) {
|
|
||||||
const variableData = variables.map((v) => {
|
|
||||||
const isNumeric =
|
|
||||||
['integer', 'number', 'satoshis'].includes(v.type) ||
|
|
||||||
(v.hint && ['satoshis', 'amount'].includes(v.hint));
|
|
||||||
|
|
||||||
return {
|
|
||||||
variableIdentifier: v.id,
|
|
||||||
roleIdentifier,
|
|
||||||
value: isNumeric ? BigInt(v.value || '0') : v.value,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
await invitationInstance.addVariables(variableData);
|
|
||||||
inv = invitationInstance.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add template-required outputs for the current role
|
// Wrap and track
|
||||||
const act = template.actions?.[actionIdentifier];
|
const invitationInstance =
|
||||||
const transaction = act?.transaction
|
await appService.createInvitation(xoInvitation);
|
||||||
? template.transactions?.[act.transaction]
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (transaction?.outputs && transaction.outputs.length > 0) {
|
let inv = invitationInstance.data;
|
||||||
setStatus('Adding required outputs...');
|
const invId = inv.invitationIdentifier;
|
||||||
|
setInvitationId(invId);
|
||||||
|
|
||||||
const outputsToAdd = transaction.outputs.map(
|
// Persist variable values
|
||||||
(outputId: string) => ({
|
if (variables.length > 0) {
|
||||||
outputIdentifier: outputId,
|
const variableData = variables.map((v) => {
|
||||||
})
|
const isNumeric =
|
||||||
|
['integer', 'number', 'satoshis'].includes(v.type) ||
|
||||||
|
(v.hint && ['satoshis', 'amount'].includes(v.hint));
|
||||||
|
|
||||||
|
return {
|
||||||
|
variableIdentifier: v.id,
|
||||||
|
roleIdentifier: effectiveRole,
|
||||||
|
value: isNumeric ? BigInt(v.value || '0') : v.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await invitationInstance.addVariables(variableData);
|
||||||
|
inv = invitationInstance.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add template-required outputs for the current role
|
||||||
|
const act = template.actions?.[actionIdentifier];
|
||||||
|
const transaction = act?.transaction
|
||||||
|
? template.transactions?.[act.transaction]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (transaction?.outputs && transaction.outputs.length > 0) {
|
||||||
|
setStatus('Adding required outputs...');
|
||||||
|
|
||||||
|
const outputsToAdd = transaction.outputs.map(
|
||||||
|
(outputId: string) => ({
|
||||||
|
outputIdentifier: outputId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await invitationInstance.addOutputs(outputsToAdd);
|
||||||
|
inv = invitationInstance.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInvitation(inv);
|
||||||
|
setStatus('Invitation created');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
showError(
|
||||||
|
`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
|
return false;
|
||||||
await invitationInstance.addOutputs(outputsToAdd);
|
} finally {
|
||||||
inv = invitationInstance.data;
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
setInvitation(inv);
|
[
|
||||||
|
templateIdentifier,
|
||||||
// Advance and optionally kick off UTXO loading
|
actionIdentifier,
|
||||||
const nextStepType = steps[currentStep + 1]?.type;
|
roleIdentifier,
|
||||||
if (nextStepType === 'inputs') {
|
template,
|
||||||
setCurrentStep((prev) => prev + 1);
|
variables,
|
||||||
setTimeout(() => loadAvailableUtxos(), 100);
|
appService,
|
||||||
} else {
|
showError,
|
||||||
setCurrentStep((prev) => prev + 1);
|
setStatus,
|
||||||
}
|
]
|
||||||
|
);
|
||||||
setStatus('Invitation created');
|
|
||||||
} catch (error) {
|
|
||||||
showError(
|
|
||||||
`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
templateIdentifier,
|
|
||||||
actionIdentifier,
|
|
||||||
roleIdentifier,
|
|
||||||
template,
|
|
||||||
variables,
|
|
||||||
appService,
|
|
||||||
steps,
|
|
||||||
currentStep,
|
|
||||||
showError,
|
|
||||||
setStatus,
|
|
||||||
loadAvailableUtxos,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ── Add selected inputs + change output to the invitation ───────
|
// ── Add selected inputs + change output to the invitation ───────
|
||||||
const addInputsAndOutputs = useCallback(async () => {
|
const addInputsAndOutputs = useCallback(async () => {
|
||||||
@@ -465,6 +503,41 @@ export function useActionWizard() {
|
|||||||
|
|
||||||
const stepType = currentStepData?.type;
|
const stepType = currentStepData?.type;
|
||||||
|
|
||||||
|
// ── Role selection ──────────────────────────────────────────
|
||||||
|
if (stepType === 'role-select') {
|
||||||
|
const selectedRole = availableRoles[selectedRoleIndex];
|
||||||
|
if (!selectedRole) {
|
||||||
|
showError('Please select a role');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check what the selected role requires
|
||||||
|
const act = template?.actions?.[actionIdentifier ?? ''];
|
||||||
|
const role = act?.roles?.[selectedRole];
|
||||||
|
const requirements = role?.requirements;
|
||||||
|
|
||||||
|
const hasVariables =
|
||||||
|
requirements?.variables && requirements.variables.length > 0;
|
||||||
|
const hasSlots = requirements?.slots && requirements.slots.min > 0;
|
||||||
|
|
||||||
|
// If there is no variables step, the invitation must be created now
|
||||||
|
// because the variables step would normally handle it.
|
||||||
|
if (!hasVariables) {
|
||||||
|
const success = await createInvitationWithVariables(selectedRole);
|
||||||
|
if (!success) return;
|
||||||
|
|
||||||
|
// If we're going to the inputs step, load UTXOs
|
||||||
|
if (hasSlots) {
|
||||||
|
setTimeout(() => loadAvailableUtxos(), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set role — this triggers the useEffect to rebuild steps and advance
|
||||||
|
setRoleIdentifier(selectedRole);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Variables ───────────────────────────────────────────────
|
||||||
if (stepType === 'variables') {
|
if (stepType === 'variables') {
|
||||||
const emptyVars = variables.filter(
|
const emptyVars = variables.filter(
|
||||||
(v) => !v.value || v.value.trim() === ''
|
(v) => !v.value || v.value.trim() === ''
|
||||||
@@ -475,30 +548,53 @@ export function useActionWizard() {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await createInvitationWithVariables();
|
|
||||||
|
// Create the invitation and persist the variable values
|
||||||
|
const success = await createInvitationWithVariables();
|
||||||
|
if (!success) return;
|
||||||
|
|
||||||
|
// Advance, optionally kicking off UTXO loading
|
||||||
|
const nextStepType = steps[currentStep + 1]?.type;
|
||||||
|
if (nextStepType === 'inputs') {
|
||||||
|
setCurrentStep((prev) => prev + 1);
|
||||||
|
setTimeout(() => loadAvailableUtxos(), 100);
|
||||||
|
} else {
|
||||||
|
setCurrentStep((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFocusArea('content');
|
||||||
|
setFocusedInput(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Inputs ──────────────────────────────────────────────────
|
||||||
if (stepType === 'inputs') {
|
if (stepType === 'inputs') {
|
||||||
await addInputsAndOutputs();
|
await addInputsAndOutputs();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Review ──────────────────────────────────────────────────
|
||||||
if (stepType === 'review') {
|
if (stepType === 'review') {
|
||||||
await publishInvitation();
|
await publishInvitation();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Generic advance (e.g. publish → done) ───────────────────
|
||||||
setCurrentStep((prev) => prev + 1);
|
setCurrentStep((prev) => prev + 1);
|
||||||
setFocusArea('content');
|
setFocusArea('content');
|
||||||
setFocusedInput(0);
|
setFocusedInput(0);
|
||||||
}, [
|
}, [
|
||||||
currentStep,
|
currentStep,
|
||||||
steps.length,
|
steps,
|
||||||
currentStepData,
|
currentStepData,
|
||||||
|
availableRoles,
|
||||||
|
selectedRoleIndex,
|
||||||
|
template,
|
||||||
|
actionIdentifier,
|
||||||
variables,
|
variables,
|
||||||
showError,
|
showError,
|
||||||
createInvitationWithVariables,
|
createInvitationWithVariables,
|
||||||
|
loadAvailableUtxos,
|
||||||
addInputsAndOutputs,
|
addInputsAndOutputs,
|
||||||
publishInvitation,
|
publishInvitation,
|
||||||
]);
|
]);
|
||||||
@@ -529,6 +625,11 @@ export function useActionWizard() {
|
|||||||
action,
|
action,
|
||||||
actionName,
|
actionName,
|
||||||
|
|
||||||
|
// Role selection
|
||||||
|
availableRoles,
|
||||||
|
selectedRoleIndex,
|
||||||
|
setSelectedRoleIndex,
|
||||||
|
|
||||||
// Steps
|
// Steps
|
||||||
steps,
|
steps,
|
||||||
currentStep,
|
currentStep,
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
|||||||
throw new Error(`Failed to get invitation: ${response.statusText}`);
|
throw new Error(`Failed to get invitation: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const invitation = await response.json() as XOInvitation | undefined;
|
const invitation = decodeExtendedJsonObject(await response.text()) as XOInvitation | undefined;
|
||||||
return invitation;
|
return invitation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
376
src/utils/templates.ts
Normal file
376
src/utils/templates.ts
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
import type { XOTemplate } from "@xo-cash/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of helper functions to make templates easy to develop with.
|
||||||
|
*
|
||||||
|
* Most of these are centered around the fact that the templates are very disjointed and sporadic in where the information lies.
|
||||||
|
*
|
||||||
|
* I.e. required variables ** names ** are stored in actions.roles.requirements.variables, but the variable definitions are stored in the template.variables object.
|
||||||
|
* so to make a UI out of that, you first need to iterate over the actions.roles.requirements.variables and then lookup the variable definition in the template.variables object.
|
||||||
|
* this is a pain, so these functions are here to help.
|
||||||
|
*
|
||||||
|
* Simiarly for inputs, outputs, locking scripts, etc. The get referenced in the actions, but then the actual lookup of what is actually is becomes a pain.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deepens a templates object by append the actual definitions for the objects as they are referenced in the template.
|
||||||
|
* NOTE: Whether this is better as part of the template or not can be debated endlessly.
|
||||||
|
* The decision for separating the defintions from where they are used is likely to reduce template size...
|
||||||
|
* This could be fruitless though, as its easily compressible (gzip, msgpack, etc) will yield similar results to the separated approach.
|
||||||
|
*/
|
||||||
|
type ResolutionMode = "single" | "map";
|
||||||
|
|
||||||
|
interface ResolutionRule {
|
||||||
|
/**
|
||||||
|
* Dot-separated path pattern.
|
||||||
|
* - `*` matches any key in an object.
|
||||||
|
* - `[]` iterates items in an array.
|
||||||
|
*/
|
||||||
|
path: string;
|
||||||
|
/** Root-level collection key to resolve references from. */
|
||||||
|
from: string;
|
||||||
|
/**
|
||||||
|
* - `single`: replaces a string reference with its definition.
|
||||||
|
* - `map`: converts a `string[]` into a `Record<string, definition>`.
|
||||||
|
*/
|
||||||
|
mode: ResolutionMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rules are ordered by dependency so that each phase reads
|
||||||
|
* collections already enriched by earlier phases.
|
||||||
|
*
|
||||||
|
* Dependency graph (leaf → root):
|
||||||
|
* scripts (no deps)
|
||||||
|
* variables / roles / data / constants (no deps)
|
||||||
|
* lockingScripts ← scripts, variables
|
||||||
|
* inputs ← scripts
|
||||||
|
* outputs ← lockingScripts
|
||||||
|
* transactions ← inputs, outputs
|
||||||
|
* actions ← variables, transactions, data, roles
|
||||||
|
* lockingScripts.roles.actions ← actions, roles, variables (2nd pass)
|
||||||
|
* start ← actions, roles
|
||||||
|
*/
|
||||||
|
const RESOLUTION_RULES: ResolutionRule[] = [
|
||||||
|
// ── Phase 1: lockingScripts ← scripts, variables ──────────
|
||||||
|
{
|
||||||
|
path: "lockingScripts.*.lockingScript",
|
||||||
|
from: "scripts",
|
||||||
|
mode: "single",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "lockingScripts.*.unlockingScript",
|
||||||
|
from: "scripts",
|
||||||
|
mode: "single",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "lockingScripts.*.roles.*.state.secrets",
|
||||||
|
from: "variables",
|
||||||
|
mode: "map",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "lockingScripts.*.roles.*.state.variables",
|
||||||
|
from: "variables",
|
||||||
|
mode: "map",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Phase 2: inputs ← scripts ─────────────────────────────
|
||||||
|
{ path: "inputs.*.unlockingScript", from: "scripts", mode: "single" },
|
||||||
|
|
||||||
|
// ── Phase 3: outputs ← lockingScripts (now enriched) ──────
|
||||||
|
{ path: "outputs.*.lockscript", from: "lockingScripts", mode: "single" },
|
||||||
|
|
||||||
|
// ── Phase 4: transactions ← inputs, outputs ───────────────
|
||||||
|
{ path: "transactions.*.inputs", from: "inputs", mode: "map" },
|
||||||
|
{ path: "transactions.*.outputs", from: "outputs", mode: "map" },
|
||||||
|
|
||||||
|
// ── Phase 5: actions ← variables, transactions, data, roles
|
||||||
|
{
|
||||||
|
path: "actions.*.roles.*.requirements.variables",
|
||||||
|
from: "variables",
|
||||||
|
mode: "map",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "actions.*.roles.*.requirements.secrets",
|
||||||
|
from: "variables",
|
||||||
|
mode: "map",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "actions.*.roles.*.requirements.generate",
|
||||||
|
from: "variables",
|
||||||
|
mode: "map",
|
||||||
|
},
|
||||||
|
{ path: "actions.*.transaction", from: "transactions", mode: "single" },
|
||||||
|
{ path: "actions.*.data", from: "data", mode: "map" },
|
||||||
|
{
|
||||||
|
path: "actions.*.requirements.roles.[].role",
|
||||||
|
from: "roles",
|
||||||
|
mode: "single",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Phase 6: lockingScripts.roles.actions ← actions (now enriched), roles, variables
|
||||||
|
{
|
||||||
|
path: "lockingScripts.*.roles.*.actions.[].action",
|
||||||
|
from: "actions",
|
||||||
|
mode: "single",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "lockingScripts.*.roles.*.actions.[].role",
|
||||||
|
from: "roles",
|
||||||
|
mode: "single",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "lockingScripts.*.roles.*.actions.[].secrets",
|
||||||
|
from: "variables",
|
||||||
|
mode: "single",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Phase 7: start ← actions, roles ───────────────────────
|
||||||
|
{ path: "start.[].action", from: "actions", mode: "single" },
|
||||||
|
{ path: "start.[].role", from: "roles", mode: "single" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively walks `obj` following the path pattern described by `parts`,
|
||||||
|
* and resolves the leaf reference(s) from `root[from]`.
|
||||||
|
*/
|
||||||
|
function applyRule(
|
||||||
|
obj: unknown,
|
||||||
|
root: Record<string, any>,
|
||||||
|
parts: string[],
|
||||||
|
depth: number,
|
||||||
|
from: string,
|
||||||
|
mode: ResolutionMode,
|
||||||
|
): void {
|
||||||
|
if (obj == null || typeof obj !== "object") return;
|
||||||
|
|
||||||
|
const part = parts[depth]!;
|
||||||
|
const isLast = depth === parts.length - 1;
|
||||||
|
|
||||||
|
// ── Leaf: perform the resolution ──────────────────────────
|
||||||
|
if (isLast) {
|
||||||
|
const collection = root[from] as Record<string, unknown> | undefined;
|
||||||
|
const record = obj as Record<string, unknown>;
|
||||||
|
if (!collection || !(part in record)) return;
|
||||||
|
|
||||||
|
const value = record[part];
|
||||||
|
|
||||||
|
if (mode === "single") {
|
||||||
|
if (typeof value === "string" && value in collection) {
|
||||||
|
record[part] = structuredClone(collection[value]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// "map" – convert string[] → Record<string, definition>
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const resolved: Record<string, unknown> = {};
|
||||||
|
for (const ref of value) {
|
||||||
|
if (typeof ref === "string" && ref in collection) {
|
||||||
|
resolved[ref] = structuredClone(collection[ref]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
record[part] = resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Intermediate path segments ────────────────────────────
|
||||||
|
if (part === "*") {
|
||||||
|
// Wildcard: iterate every key of the current object
|
||||||
|
for (const key of Object.keys(obj as Record<string, unknown>)) {
|
||||||
|
applyRule(
|
||||||
|
(obj as Record<string, unknown>)[key],
|
||||||
|
root,
|
||||||
|
parts,
|
||||||
|
depth + 1,
|
||||||
|
from,
|
||||||
|
mode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (part === "[]") {
|
||||||
|
// Array wildcard: iterate every item
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
for (const item of obj) {
|
||||||
|
applyRule(item, root, parts, depth + 1, from, mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Exact key: descend
|
||||||
|
const next = (obj as Record<string, unknown>)[part];
|
||||||
|
if (next !== undefined) {
|
||||||
|
applyRule(next, root, parts, depth + 1, from, mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a deep clone of `template` with every string reference replaced
|
||||||
|
* by the full definition it points to.
|
||||||
|
*
|
||||||
|
* References are resolved in dependency order so that embedded objects
|
||||||
|
* themselves contain resolved (not string) references wherever possible.
|
||||||
|
*
|
||||||
|
* The only place resolution deliberately stops is at the circular edge
|
||||||
|
* `lockingScripts → actions → transactions → outputs → lockingScripts`:
|
||||||
|
* the lockingScript copies embedded inside output→transaction→action chains
|
||||||
|
* will have their script/variable refs resolved but will *not* re-embed
|
||||||
|
* actions (which would cause infinite nesting).
|
||||||
|
*/
|
||||||
|
export function resolveTemplateReferences(
|
||||||
|
template: XOTemplate,
|
||||||
|
): ResolvedXOTemplate {
|
||||||
|
const resolved = structuredClone(template);
|
||||||
|
|
||||||
|
for (const rule of RESOLUTION_RULES) {
|
||||||
|
applyRule(resolved, resolved, rule.path.split("."), 0, rule.from, rule.mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved as unknown as ResolvedXOTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Base definition types (inferred from your template) ─────────
|
||||||
|
// Adjust these to match your actual @xo-cash/types definitions.
|
||||||
|
|
||||||
|
interface VariableDefinition {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
type: string;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoleDefinition {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScriptDefinition {
|
||||||
|
// scripts are raw strings in your template
|
||||||
|
script: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataDefinition {
|
||||||
|
value: string;
|
||||||
|
type: string;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Resolved sub-types ──────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ResolvedActionRoleRequirements {
|
||||||
|
variables?: Record<string, VariableDefinition>;
|
||||||
|
secrets?: Record<string, VariableDefinition>;
|
||||||
|
generate?: Record<string, VariableDefinition>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedActionRole {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
requirements?: ResolvedActionRoleRequirements;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedRoleSlot {
|
||||||
|
role: RoleDefinition; // was string
|
||||||
|
slots: { min: number; max: number | undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedOutputDefinition {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
lockscript: ResolvedLockingScriptDefinition; // was string
|
||||||
|
valueSatoshis?: string | null;
|
||||||
|
token?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedInputDefinition {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
unlockingScript: string; // resolved from scripts (string → string)
|
||||||
|
token?: unknown;
|
||||||
|
omitChangeAmounts?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedTransactionDefinition {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon?: string;
|
||||||
|
roles?: Record<string, unknown>;
|
||||||
|
inputs: Record<string, ResolvedInputDefinition>; // was string[]
|
||||||
|
outputs: Record<string, ResolvedOutputDefinition>; // was string[]
|
||||||
|
version: number;
|
||||||
|
locktime: number;
|
||||||
|
composable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedActionDefinition {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
roles: Record<string, ResolvedActionRole>;
|
||||||
|
requirements: {
|
||||||
|
roles: ResolvedRoleSlot[];
|
||||||
|
};
|
||||||
|
transaction?: ResolvedTransactionDefinition; // was string
|
||||||
|
data?: Record<string, DataDefinition>; // was string[]
|
||||||
|
condition?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedLockingScriptRoleAction {
|
||||||
|
action: ResolvedActionDefinition; // was string
|
||||||
|
role: RoleDefinition; // was string
|
||||||
|
secrets: VariableDefinition; // was string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedLockingScriptRole {
|
||||||
|
state?: {
|
||||||
|
variables: Record<string, VariableDefinition>; // was string[]
|
||||||
|
secrets: Record<string, VariableDefinition>; // was string[]
|
||||||
|
};
|
||||||
|
actions?: ResolvedLockingScriptRoleAction[];
|
||||||
|
selectable?: boolean;
|
||||||
|
privacy?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedLockingScriptDefinition {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
lockingType: string;
|
||||||
|
lockingScript: string; // resolved from scripts (string → string)
|
||||||
|
unlockingScript?: string;
|
||||||
|
roles?: Record<string, ResolvedLockingScriptRole>;
|
||||||
|
actions?: unknown[];
|
||||||
|
state?: unknown[];
|
||||||
|
secrets?: unknown[];
|
||||||
|
balance?: boolean;
|
||||||
|
selectable?: boolean;
|
||||||
|
privacy?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedStartEntry {
|
||||||
|
action: ResolvedActionDefinition; // was string
|
||||||
|
role: RoleDefinition; // was string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── The full resolved template ──────────────────────────────────
|
||||||
|
|
||||||
|
interface ResolvedXOTemplate
|
||||||
|
extends Omit<
|
||||||
|
XOTemplate,
|
||||||
|
| "actions"
|
||||||
|
| "transactions"
|
||||||
|
| "outputs"
|
||||||
|
| "inputs"
|
||||||
|
| "lockingScripts"
|
||||||
|
| "start"
|
||||||
|
> {
|
||||||
|
start: ResolvedStartEntry[];
|
||||||
|
actions: Record<string, ResolvedActionDefinition>;
|
||||||
|
transactions: Record<string, ResolvedTransactionDefinition>;
|
||||||
|
outputs: Record<string, ResolvedOutputDefinition>;
|
||||||
|
inputs: Record<string, ResolvedInputDefinition>;
|
||||||
|
lockingScripts: Record<string, ResolvedLockingScriptDefinition>;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user