Big changes and fixes. Uses action history. Improve role selection. Remove unused logs
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,4 +6,5 @@ dist/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.sqlite
|
||||
*.sqlite
|
||||
resolvedTemplate.json
|
||||
@@ -9,6 +9,7 @@ import type { XOInvitation } from '@xo-cash/types';
|
||||
import { Invitation } from './invitation.js';
|
||||
import { Storage } from './storage.js';
|
||||
import { SyncServer } from '../utils/sync-server.js';
|
||||
import { HistoryService } from './history.js';
|
||||
|
||||
import { EventEmitter } from '../utils/event-emitter.js';
|
||||
|
||||
@@ -31,6 +32,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
public engine: Engine;
|
||||
public storage: Storage;
|
||||
public config: AppConfig;
|
||||
public history: HistoryService;
|
||||
|
||||
public invitations: Invitation[] = [];
|
||||
|
||||
@@ -42,9 +44,6 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
// We want to only prefix the file name
|
||||
const prefixedStoragePath = `${seedHash.slice(0, 8)}-${config.engineConfig.databaseFilename}`;
|
||||
|
||||
console.log('Prefixed storage path:', prefixedStoragePath);
|
||||
console.log('Engine config:', config.engineConfig);
|
||||
|
||||
// Create the engine
|
||||
const engine = await Engine.create(seed, {
|
||||
...config.engineConfig,
|
||||
@@ -75,6 +74,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
this.engine = engine;
|
||||
this.storage = storage;
|
||||
this.config = config;
|
||||
this.history = new HistoryService(engine, this.invitations);
|
||||
}
|
||||
|
||||
async createInvitation(invitation: XOInvitation | string): Promise<Invitation> {
|
||||
@@ -118,12 +118,16 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
const invitationsDb = this.storage.child('invitations');
|
||||
|
||||
// Load invitations from storage
|
||||
console.time('loadInvitations');
|
||||
const invitations = await invitationsDb.all() as { key: string; value: XOInvitation }[];
|
||||
console.timeEnd('loadInvitations');
|
||||
|
||||
// Start the invitations
|
||||
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.
|
||||
console.time('createInvitations');
|
||||
|
||||
await Promise.all(invitations.map(async ({ 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 { 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 { SSEvent } from '../utils/sse-client.js';
|
||||
@@ -50,18 +50,12 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
throw new Error(`Template not found: ${invitation.templateIdentifier}`);
|
||||
}
|
||||
|
||||
console.log('Invitation:', invitation);
|
||||
|
||||
// Create the invitation
|
||||
const invitationInstance = new Invitation(invitation, dependencies);
|
||||
|
||||
console.log('Invitation instance:', invitationInstance);
|
||||
|
||||
// Start the invitation and its tracking
|
||||
await invitationInstance.start();
|
||||
|
||||
console.log('Invitation started:', invitationInstance);
|
||||
|
||||
return invitationInstance;
|
||||
}
|
||||
|
||||
@@ -114,21 +108,28 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
|
||||
async start(): Promise<void> {
|
||||
// Connect to the sync server and get the invitation (in parallel)
|
||||
console.time(`connectAndGetInvitation-${this.data.invitationIdentifier}`);
|
||||
const [_, invitation] = await Promise.all([
|
||||
this.syncServer.connect(),
|
||||
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
|
||||
const sseCommits = this.data.commits;
|
||||
|
||||
// Set the invitation data with the combined commits
|
||||
this.data = { ...this.data, ...invitation, commits: [...sseCommits, ...(invitation?.commits ?? [])] };
|
||||
console.time(`mergeCommits-${this.data.invitationIdentifier}`);
|
||||
// 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
|
||||
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 };
|
||||
if (data.topic === 'invitation-updated') {
|
||||
const invitation = decodeExtendedJsonObject(data.data) as XOInvitation;
|
||||
console.log('Invitation updated:', invitation);
|
||||
|
||||
if (invitation.invitationIdentifier !== this.data.invitationIdentifier) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('New commits:', invitation.commits);
|
||||
|
||||
// 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));
|
||||
this.data.commits.push(...newCommits);
|
||||
const newCommits = this.mergeCommits(this.data.commits, invitation.commits);
|
||||
|
||||
// Set the new commits
|
||||
this.data = { ...this.data, commits: newCommits };
|
||||
|
||||
// Calculate the new status of the invitation
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -36,11 +36,9 @@ export class Storage {
|
||||
async set(key: string, value: any): Promise<void> {
|
||||
// Encode the extended json object
|
||||
const encodedValue = encodeExtendedJson(value);
|
||||
console.log('Encoded value:', encodedValue);
|
||||
|
||||
// Insert or replace the value into the database with full key (including basePath)
|
||||
const fullKey = this.getFullKey(key);
|
||||
console.log('Full key:', fullKey);
|
||||
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}
|
||||
paddingX={1}
|
||||
marginTop={1}
|
||||
gap={1}
|
||||
>
|
||||
<TextInput
|
||||
value={variable.value}
|
||||
@@ -49,6 +50,9 @@ export function VariableInputField({
|
||||
focus={isFocused}
|
||||
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>
|
||||
{variable.type === 'integer' && variable.hint === 'satoshis' && (
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
@@ -22,7 +35,7 @@ import type { XOTemplate, XOTemplateActionRoleRequirement, XOTemplateStartingAct
|
||||
interface TemplateItem {
|
||||
template: XOTemplate;
|
||||
templateIdentifier: string;
|
||||
startingActions: XOTemplateStartingActions;
|
||||
startingActions: UniqueStartingAction[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,8 +72,30 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
const loadedTemplates = await Promise.all(
|
||||
templateList.map(async (template) => {
|
||||
const templateIdentifier = generateTemplateIdentifier(template);
|
||||
const startingActions = await appService.engine.listStartingActions(templateIdentifier);
|
||||
return { template, templateIdentifier, startingActions };
|
||||
const rawStartingActions = await appService.engine.listStartingActions(templateIdentifier);
|
||||
|
||||
// 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.
|
||||
* Navigates to the Action Wizard where the user will choose their role.
|
||||
*/
|
||||
const handleActionSelect = useCallback(() => {
|
||||
if (!currentTemplate || currentActions.length === 0) return;
|
||||
@@ -93,11 +129,10 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
const action = currentActions[selectedActionIndex];
|
||||
if (!action) return;
|
||||
|
||||
// Navigate to action wizard with selected template and action
|
||||
// Navigate to the Action Wizard — role selection happens there
|
||||
navigate('wizard', {
|
||||
templateIdentifier: currentTemplate.templateIdentifier,
|
||||
actionIdentifier: action.action,
|
||||
roleIdentifier: action.role,
|
||||
actionIdentifier: action.actionIdentifier,
|
||||
template: currentTemplate.template,
|
||||
});
|
||||
}, [currentTemplate, currentActions, selectedActionIndex, navigate]);
|
||||
@@ -211,20 +246,19 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
}
|
||||
|
||||
// Starting actions state
|
||||
return currentActions.map((action, index) => {
|
||||
const actionDef = currentTemplate.template.actions?.[action.action];
|
||||
const name = actionDef?.name || action.action;
|
||||
return (
|
||||
<Text
|
||||
key={`${action.action}-${action.role}`}
|
||||
color={index === selectedActionIndex ? colors.focus : colors.text}
|
||||
bold={index === selectedActionIndex}
|
||||
>
|
||||
{index === selectedActionIndex && focusedPanel === 'actions' ? '▸ ' : ' '}
|
||||
{index + 1}. {name} (as {action.role})
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
return currentActions.map((action, index) => (
|
||||
<Text
|
||||
key={action.actionIdentifier}
|
||||
color={index === selectedActionIndex ? colors.focus : colors.text}
|
||||
bold={index === selectedActionIndex}
|
||||
>
|
||||
{index === selectedActionIndex && focusedPanel === 'actions' ? '▸ ' : ' '}
|
||||
{index + 1}. {action.name}
|
||||
{action.roleCount > 1
|
||||
? ` (${action.roleCount} roles)`
|
||||
: ''}
|
||||
</Text>
|
||||
));
|
||||
})()}
|
||||
|
||||
</Box>
|
||||
@@ -280,50 +314,34 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
const action = currentActions[selectedActionIndex];
|
||||
if (!action) return null;
|
||||
|
||||
const actionDef = currentTemplate.template.actions?.[action.action];
|
||||
const roleDef = currentTemplate.template.roles?.[action.role];
|
||||
// Collect all roles that can start this action
|
||||
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 (
|
||||
<>
|
||||
<Text color={colors.text} bold>
|
||||
{actionDef?.name || action.action}
|
||||
{action.name}
|
||||
</Text>
|
||||
<Text color={colors.textMuted}>
|
||||
{actionDef?.description || 'No description available'}
|
||||
{action.description || 'No description available'}
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text color={colors.text}>
|
||||
Role: {roleDef?.name || action.role}
|
||||
</Text>
|
||||
{roleDef?.description && (
|
||||
<Text color={colors.textMuted}>
|
||||
{' '}{roleDef.description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{/* Display variables if available */}
|
||||
{
|
||||
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'}
|
||||
|
||||
{/* List available roles for this action */}
|
||||
{startEntries.length > 0 && (
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text color={colors.text}>Available Roles:</Text>
|
||||
{startEntries.map((entry) => {
|
||||
const roleDef = currentTemplate.template.roles?.[entry.role];
|
||||
return (
|
||||
<Text key={entry.role} color={colors.textMuted}>
|
||||
{' '}- {roleDef?.name || entry.role}
|
||||
{roleDef?.description ? `: ${roleDef.description}` : ''}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
/**
|
||||
* Wallet State Screen - Displays wallet balances and UTXOs.
|
||||
* Wallet State Screen - Displays wallet balances and history.
|
||||
*
|
||||
* Shows:
|
||||
* - Total balance
|
||||
* - List of unspent outputs
|
||||
* - Wallet history (invitations, reservations)
|
||||
* - Navigation to other actions
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import SelectInput from 'ink-select-input';
|
||||
import { type ListItem } from '../components/List.js';
|
||||
import { useNavigation } from '../hooks/useNavigation.js';
|
||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||
import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js';
|
||||
import type { HistoryItem } from '../../services/history.js';
|
||||
|
||||
/**
|
||||
* Menu action items.
|
||||
@@ -26,20 +26,9 @@ const menuItems = [
|
||||
{ label: 'Refresh', value: 'refresh' },
|
||||
];
|
||||
|
||||
/**
|
||||
* UTXO display item.
|
||||
*/
|
||||
interface UTXOItem {
|
||||
key: string;
|
||||
satoshis: bigint;
|
||||
txid: string;
|
||||
index: number;
|
||||
reserved: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wallet State Screen Component.
|
||||
* Displays wallet balance, UTXOs, and action menu.
|
||||
* Displays wallet balance, history, and action menu.
|
||||
*/
|
||||
export function WalletStateScreen(): React.ReactElement {
|
||||
const { navigate } = useNavigation();
|
||||
@@ -48,9 +37,9 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
|
||||
// State
|
||||
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
|
||||
const [utxos, setUtxos] = useState<UTXOItem[]>([]);
|
||||
const [focusedPanel, setFocusedPanel] = useState<'menu' | 'utxos'>('menu');
|
||||
const [selectedUtxoIndex, setSelectedUtxoIndex] = useState(0);
|
||||
const [history, setHistory] = useState<HistoryItem[]>([]);
|
||||
const [focusedPanel, setFocusedPanel] = useState<'menu' | 'history'>('menu');
|
||||
const [selectedHistoryIndex, setSelectedHistoryIndex] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
/**
|
||||
@@ -66,23 +55,21 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
setIsLoading(true);
|
||||
setStatus('Loading wallet state...');
|
||||
|
||||
// Get UTXOs
|
||||
// Get UTXOs for balance calculation
|
||||
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
|
||||
const balanceData = utxoData.reduce((acc, utxo) => acc + BigInt(utxo.valueSatoshis), BigInt(0));
|
||||
// Calculate balance
|
||||
const selectableUtxos = utxoData.filter(utxo => utxo.selectable);
|
||||
const balanceData = selectableUtxos.reduce((acc, utxo) => acc + BigInt(utxo.valueSatoshis), BigInt(0));
|
||||
setBalance({
|
||||
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');
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
@@ -162,17 +149,19 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
// Handle keyboard navigation between panels
|
||||
useInput((input, key) => {
|
||||
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 (
|
||||
<Box flexDirection='column' flexGrow={1}>
|
||||
{/* Header */}
|
||||
@@ -251,33 +240,96 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* UTXO list */}
|
||||
{/* Wallet History */}
|
||||
<Box marginTop={1} flexGrow={1}>
|
||||
<Box
|
||||
borderStyle='single'
|
||||
borderColor={focusedPanel === 'utxos' ? colors.focus : colors.border}
|
||||
borderColor={focusedPanel === 'history' ? colors.focus : colors.border}
|
||||
flexDirection='column'
|
||||
paddingX={1}
|
||||
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'>
|
||||
{isLoading ? (
|
||||
<Text color={colors.textMuted}>Loading...</Text>
|
||||
) : utxoListItems.length === 0 ? (
|
||||
<Text color={colors.textMuted}>No unspent outputs found</Text>
|
||||
) : history.length === 0 ? (
|
||||
<Text color={colors.textMuted}>No history found</Text>
|
||||
) : (
|
||||
utxoListItems.map((item, index) => (
|
||||
<Box key={item.key}>
|
||||
<Text color={index === selectedUtxoIndex && focusedPanel === 'utxos' ? colors.focus : colors.text}>
|
||||
{index === selectedUtxoIndex && focusedPanel === 'utxos' ? '▸ ' : ' '}
|
||||
{index + 1}. {item.label}
|
||||
</Text>
|
||||
{item.description && (
|
||||
<Text color={colors.warning}> {item.description}</Text>
|
||||
)}
|
||||
</Box>
|
||||
))
|
||||
// Show a scrolling window of items
|
||||
(() => {
|
||||
const maxVisible = 10;
|
||||
const halfWindow = Math.floor(maxVisible / 2);
|
||||
let startIndex = Math.max(0, selectedHistoryIndex - halfWindow);
|
||||
const endIndex = Math.min(history.length, startIndex + maxVisible);
|
||||
// Adjust start if we're near the end
|
||||
if (endIndex - startIndex < maxVisible) {
|
||||
startIndex = Math.max(0, endIndex - maxVisible);
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useActionWizard } from './useActionWizard.js';
|
||||
|
||||
// Steps
|
||||
import { InfoStep } from './steps/InfoStep.js';
|
||||
import { RoleSelectStep } from './steps/RoleSelectStep.js';
|
||||
import { VariablesStep } from './steps/VariablesStep.js';
|
||||
import { InputsStep } from './steps/InputsStep.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
|
||||
if (key.tab) {
|
||||
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
|
||||
if (
|
||||
wizard.currentStepData?.type === 'inputs' &&
|
||||
@@ -47,11 +61,27 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
wizard.setFocusArea('content');
|
||||
wizard.setFocusedInput(0);
|
||||
wizard.setSelectedUtxoIndex(0);
|
||||
wizard.setSelectedRoleIndex(0);
|
||||
}
|
||||
}
|
||||
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
|
||||
if (
|
||||
wizard.focusArea === 'content' &&
|
||||
@@ -131,6 +161,16 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
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':
|
||||
return (
|
||||
<VariablesStep
|
||||
@@ -189,8 +229,8 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
{logoSmall} - Action Wizard
|
||||
</Text>
|
||||
<Text color={colors.textMuted}>
|
||||
{wizard.template?.name} {">"} {wizard.actionName} (as{" "}
|
||||
{wizard.roleIdentifier})
|
||||
{wizard.template?.name} {">"} {wizard.actionName}
|
||||
{wizard.roleIdentifier ? ` (as ${wizard.roleIdentifier})` : ''}
|
||||
</Text>
|
||||
</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 './RoleSelectStep.js';
|
||||
export * from './VariablesStep.js';
|
||||
export * from './InputsStep.js';
|
||||
export * from './ReviewStep.js';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 {
|
||||
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 { useAppContext, useStatus } from '../../hooks/useAppContext.js';
|
||||
import { formatSatoshis } from '../../theme.js';
|
||||
@@ -18,11 +18,29 @@ export function useActionWizard() {
|
||||
const { setStatus } = useStatus();
|
||||
|
||||
// ── Navigation data ──────────────────────────────────────────────
|
||||
// Role is no longer passed via navigation — it is selected in the wizard.
|
||||
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;
|
||||
|
||||
// ── 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 ─────────────────────────────────────────────────
|
||||
const [steps, setSteps] = useState<WizardStep[]>([]);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
@@ -61,51 +79,55 @@ export function useActionWizard() {
|
||||
currentStepData?.type === 'variables' && focusArea === 'content';
|
||||
|
||||
// ── Initialization ───────────────────────────────────────────────
|
||||
// Builds the wizard steps dynamically based on the selected role.
|
||||
// Re-runs when roleIdentifier changes to add role-specific steps.
|
||||
useEffect(() => {
|
||||
if (!template || !actionIdentifier || !roleIdentifier) {
|
||||
if (!template || !actionIdentifier) {
|
||||
showError('Missing wizard data');
|
||||
goBack();
|
||||
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[] = [];
|
||||
|
||||
// Add variables step if needed
|
||||
if (requirements?.variables && requirements.variables.length > 0) {
|
||||
wizardSteps.push({ name: 'Variables', type: 'variables' });
|
||||
// Always start with role selection
|
||||
wizardSteps.push({ name: 'Select Role', type: 'role-select' });
|
||||
|
||||
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 role-specific steps only after role is selected
|
||||
if (roleIdentifier) {
|
||||
const act = template.actions?.[actionIdentifier];
|
||||
const role = act?.roles?.[roleIdentifier];
|
||||
const requirements = role?.requirements;
|
||||
|
||||
// Add variables step if needed
|
||||
if (requirements?.variables && requirements.variables.length > 0) {
|
||||
wizardSteps.push({ name: 'Variables', type: 'variables' });
|
||||
|
||||
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)
|
||||
// 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
|
||||
// Always add review and publish at the end
|
||||
wizardSteps.push({ name: 'Review', type: 'review' });
|
||||
|
||||
// Add publish step
|
||||
wizardSteps.push({ name: 'Publish', type: 'publish' });
|
||||
|
||||
setSteps(wizardSteps);
|
||||
setStatus(`${actionIdentifier}/${roleIdentifier}`);
|
||||
setStatus(roleIdentifier ? `${actionIdentifier}/${roleIdentifier}` : actionIdentifier);
|
||||
}, [
|
||||
template,
|
||||
actionIdentifier,
|
||||
@@ -115,6 +137,17 @@ export function useActionWizard() {
|
||||
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 ───────────────────────────────
|
||||
const updateVariable = useCallback((index: number, value: string) => {
|
||||
setVariables((prev) => {
|
||||
@@ -255,103 +288,108 @@ export function useActionWizard() {
|
||||
]);
|
||||
|
||||
// ── Create invitation and persist variables ─────────────────────
|
||||
const createInvitationWithVariables = useCallback(async () => {
|
||||
if (
|
||||
!templateIdentifier ||
|
||||
!actionIdentifier ||
|
||||
!roleIdentifier ||
|
||||
!template ||
|
||||
!appService
|
||||
) {
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* Creates an invitation, optionally persists variable values,
|
||||
* and adds template-required outputs.
|
||||
*
|
||||
* Accepts an explicit `roleId` to avoid stale-closure issues
|
||||
* when called immediately after setting role state.
|
||||
*
|
||||
* Does NOT advance the wizard step — the caller is responsible.
|
||||
*
|
||||
* @returns `true` on success, `false` on failure.
|
||||
*/
|
||||
const createInvitationWithVariables = useCallback(
|
||||
async (roleId?: string): Promise<boolean> => {
|
||||
const effectiveRole = roleId ?? roleIdentifier;
|
||||
|
||||
setIsProcessing(true);
|
||||
setStatus('Creating invitation...');
|
||||
if (
|
||||
!templateIdentifier ||
|
||||
!actionIdentifier ||
|
||||
!effectiveRole ||
|
||||
!template ||
|
||||
!appService
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create via the engine
|
||||
const xoInvitation = await appService.engine.createInvitation({
|
||||
templateIdentifier,
|
||||
actionIdentifier,
|
||||
});
|
||||
setIsProcessing(true);
|
||||
setStatus('Creating invitation...');
|
||||
|
||||
// Wrap and track
|
||||
const invitationInstance =
|
||||
await appService.createInvitation(xoInvitation);
|
||||
|
||||
let inv = invitationInstance.data;
|
||||
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,
|
||||
};
|
||||
try {
|
||||
// Create via the engine
|
||||
const xoInvitation = await appService.engine.createInvitation({
|
||||
templateIdentifier,
|
||||
actionIdentifier,
|
||||
});
|
||||
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;
|
||||
// Wrap and track
|
||||
const invitationInstance =
|
||||
await appService.createInvitation(xoInvitation);
|
||||
|
||||
if (transaction?.outputs && transaction.outputs.length > 0) {
|
||||
setStatus('Adding required outputs...');
|
||||
let inv = invitationInstance.data;
|
||||
const invId = inv.invitationIdentifier;
|
||||
setInvitationId(invId);
|
||||
|
||||
const outputsToAdd = transaction.outputs.map(
|
||||
(outputId: string) => ({
|
||||
outputIdentifier: outputId,
|
||||
})
|
||||
// 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: 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)}`
|
||||
);
|
||||
|
||||
await invitationInstance.addOutputs(outputsToAdd);
|
||||
inv = invitationInstance.data;
|
||||
return false;
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
|
||||
setInvitation(inv);
|
||||
|
||||
// Advance and optionally kick off UTXO loading
|
||||
const nextStepType = steps[currentStep + 1]?.type;
|
||||
if (nextStepType === 'inputs') {
|
||||
setCurrentStep((prev) => prev + 1);
|
||||
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,
|
||||
]);
|
||||
},
|
||||
[
|
||||
templateIdentifier,
|
||||
actionIdentifier,
|
||||
roleIdentifier,
|
||||
template,
|
||||
variables,
|
||||
appService,
|
||||
showError,
|
||||
setStatus,
|
||||
]
|
||||
);
|
||||
|
||||
// ── Add selected inputs + change output to the invitation ───────
|
||||
const addInputsAndOutputs = useCallback(async () => {
|
||||
@@ -465,6 +503,41 @@ export function useActionWizard() {
|
||||
|
||||
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') {
|
||||
const emptyVars = variables.filter(
|
||||
(v) => !v.value || v.value.trim() === ''
|
||||
@@ -475,30 +548,53 @@ export function useActionWizard() {
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
// ── Inputs ──────────────────────────────────────────────────
|
||||
if (stepType === 'inputs') {
|
||||
await addInputsAndOutputs();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Review ──────────────────────────────────────────────────
|
||||
if (stepType === 'review') {
|
||||
await publishInvitation();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Generic advance (e.g. publish → done) ───────────────────
|
||||
setCurrentStep((prev) => prev + 1);
|
||||
setFocusArea('content');
|
||||
setFocusedInput(0);
|
||||
}, [
|
||||
currentStep,
|
||||
steps.length,
|
||||
steps,
|
||||
currentStepData,
|
||||
availableRoles,
|
||||
selectedRoleIndex,
|
||||
template,
|
||||
actionIdentifier,
|
||||
variables,
|
||||
showError,
|
||||
createInvitationWithVariables,
|
||||
loadAvailableUtxos,
|
||||
addInputsAndOutputs,
|
||||
publishInvitation,
|
||||
]);
|
||||
@@ -529,6 +625,11 @@ export function useActionWizard() {
|
||||
action,
|
||||
actionName,
|
||||
|
||||
// Role selection
|
||||
availableRoles,
|
||||
selectedRoleIndex,
|
||||
setSelectedRoleIndex,
|
||||
|
||||
// Steps
|
||||
steps,
|
||||
currentStep,
|
||||
|
||||
@@ -66,7 +66,7 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
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