Big changes and fixes. Uses action history. Improve role selection. Remove unused logs

This commit is contained in:
2026-02-08 15:41:14 +00:00
parent da096af0fa
commit df57f1b9ad
16 changed files with 1250 additions and 1181 deletions

3
.gitignore vendored
View File

@@ -6,4 +6,5 @@ dist/
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite
resolvedTemplate.json

View File

@@ -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
View 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');
}
}

View File

@@ -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
*/

View File

@@ -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);
}

View File

@@ -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' && (

View File

@@ -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>
);
}

View File

@@ -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>
)}
</>
);
})()}

View File

@@ -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>

View File

@@ -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>

View 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>
);
}

View File

@@ -1,4 +1,5 @@
export * from './InfoStep.js';
export * from './RoleSelectStep.js';
export * from './VariablesStep.js';
export * from './InputsStep.js';
export * from './ReviewStep.js';

View File

@@ -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;

View File

@@ -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,

View File

@@ -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
View 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>;
}