From df57f1b9ad50ba1e2fe99774bf211a5f39bbb8d4 Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Sun, 8 Feb 2026 15:41:14 +0000 Subject: [PATCH] Big changes and fixes. Uses action history. Improve role selection. Remove unused logs --- .gitignore | 3 +- src/services/app.ts | 18 +- src/services/history.ts | 252 +++++ src/services/invitation.ts | 52 +- src/services/storage.ts | 2 - src/tui/components/VariableInputField.tsx | 4 + .../ActionWizard-do-I-still-need-this.tsx | 920 ------------------ src/tui/screens/TemplateList.tsx | 132 +-- src/tui/screens/WalletState.tsx | 158 ++- .../action-wizard/ActionWizardScreen.tsx | 44 +- .../action-wizard/steps/RoleSelectStep.tsx | 120 +++ src/tui/screens/action-wizard/steps/index.ts | 1 + src/tui/screens/action-wizard/types.ts | 2 +- .../screens/action-wizard/useActionWizard.ts | 345 ++++--- src/utils/sync-server.ts | 2 +- src/utils/templates.ts | 376 +++++++ 16 files changed, 1250 insertions(+), 1181 deletions(-) create mode 100644 src/services/history.ts delete mode 100644 src/tui/screens/ActionWizard-do-I-still-need-this.tsx create mode 100644 src/tui/screens/action-wizard/steps/RoleSelectStep.tsx create mode 100644 src/utils/templates.ts diff --git a/.gitignore b/.gitignore index 68e9920..bb8de35 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ dist/ *.db *.db-shm *.db-wal -*.sqlite \ No newline at end of file +*.sqlite +resolvedTemplate.json \ No newline at end of file diff --git a/src/services/app.ts b/src/services/app.ts index 1ec61dd..ab80fee 100644 --- a/src/services/app.ts +++ b/src/services/app.ts @@ -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 { public engine: Engine; public storage: Storage; public config: AppConfig; + public history: HistoryService; public invitations: Invitation[] = []; @@ -42,9 +44,6 @@ export class AppService extends EventEmitter { // 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 { this.engine = engine; this.storage = storage; this.config = config; + this.history = new HistoryService(engine, this.invitations); } async createInvitation(invitation: XOInvitation | string): Promise { @@ -118,12 +118,16 @@ export class AppService extends EventEmitter { 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'); } } \ No newline at end of file diff --git a/src/services/history.ts b/src/services/history.ts new file mode 100644 index 0000000..008e76d --- /dev/null +++ b/src/services/history.ts @@ -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 { + 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(); + 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 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 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'); + } +} diff --git a/src/services/invitation.ts b/src/services/invitation.ts index 846e5d4..7c19056 100644 --- a/src/services/invitation.ts +++ b/src/services/invitation.ts @@ -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 { 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 { async start(): Promise { // 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 { 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 { } } + /** + * 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(); + 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 */ diff --git a/src/services/storage.ts b/src/services/storage.ts index e3bdda4..cd18250 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -36,11 +36,9 @@ export class Storage { async set(key: string, value: any): Promise { // 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); } diff --git a/src/tui/components/VariableInputField.tsx b/src/tui/components/VariableInputField.tsx index 9b1fafe..2955228 100644 --- a/src/tui/components/VariableInputField.tsx +++ b/src/tui/components/VariableInputField.tsx @@ -41,6 +41,7 @@ export function VariableInputField({ borderColor={isFocused ? focusColor : borderColor} paddingX={1} marginTop={1} + gap={1} > + + {/* TODO: this may need to be conditional. Need to play around with other templates though */} + {variable.hint} {variable.type === 'integer' && variable.hint === 'satoshis' && ( diff --git a/src/tui/screens/ActionWizard-do-I-still-need-this.tsx b/src/tui/screens/ActionWizard-do-I-still-need-this.tsx deleted file mode 100644 index 2de3b8d..0000000 --- a/src/tui/screens/ActionWizard-do-I-still-need-this.tsx +++ /dev/null @@ -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 ( - - {variable.name} - {variable.hint && ( - ({variable.hint}) - )} - - onChange(index, value)} - onSubmit={onSubmit} - focus={isFocused} - placeholder={`Enter ${variable.name}...`} - /> - - - ); -} -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([]); - const [currentStep, setCurrentStep] = useState(0); - - // Variable inputs - const [variables, setVariables] = useState([]); - - // UTXO selection - const [availableUtxos, setAvailableUtxos] = useState([]); - const [selectedUtxoIndex, setSelectedUtxoIndex] = useState(0); - const [requiredAmount, setRequiredAmount] = useState(0n); - const [fee, setFee] = useState(500n); // Default fee estimate - - // Invitation state - const [invitation, setInvitation] = useState(null); - const [invitationId, setInvitationId] = useState(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(); - - 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 ( - - Action: {actionName} - {action?.description || 'No description'} - - Your Role: - {roleIdentifier} - - - {action?.roles?.[roleIdentifier ?? '']?.requirements && ( - - Requirements: - {action.roles[roleIdentifier ?? '']?.requirements?.variables?.map(v => ( - • Variable: {v} - ))} - {action.roles[roleIdentifier ?? '']?.requirements?.slots && ( - • Slots: {action.roles[roleIdentifier ?? '']?.requirements?.slots?.min} min (UTXO selection required) - )} - - )} - - ); - - case 'variables': - return ( - - Enter required values: - - {variables.map((variable, index) => ( - - ))} - - - - Type your value, then press Enter to continue - - - - ); - - case 'inputs': - return ( - - Select UTXOs to fund the transaction: - - - - Required: {formatSatoshis(requiredAmount)} + {formatSatoshis(fee)} fee - - = requiredAmount + fee ? colors.success : colors.warning}> - Selected: {formatSatoshis(selectedAmount)} - - {selectedAmount > requiredAmount + fee && ( - - Change: {formatSatoshis(changeAmount)} - - )} - - - - {availableUtxos.length === 0 ? ( - No UTXOs available - ) : ( - availableUtxos.map((utxo, index) => ( - - - {selectedUtxoIndex === index && focusArea === 'content' ? '▸ ' : ' '} - [{utxo.selected ? 'X' : ' '}] {formatSatoshis(utxo.valueSatoshis)} - {formatHex(utxo.outpointTransactionHash, 12)}:{utxo.outpointIndex} - - - )) - )} - - - - - Space/Enter: Toggle • a: Select all • n: Deselect all - - - - ); - - case 'review': - const selectedUtxos = availableUtxos.filter(u => u.selected); - return ( - - Review your invitation: - - - Template: {template?.name} - Action: {actionName} - Role: {roleIdentifier} - - - {variables.length > 0 && ( - - Variables: - {variables.map(v => ( - - {' '}{v.name}: {v.value || '(empty)'} - - ))} - - )} - - {selectedUtxos.length > 0 && ( - - Inputs ({selectedUtxos.length}): - {selectedUtxos.slice(0, 3).map(u => ( - - {' '}{formatSatoshis(u.valueSatoshis)} - - ))} - {selectedUtxos.length > 3 && ( - ...and {selectedUtxos.length - 3} more - )} - - )} - - {changeAmount > 0 && ( - - Outputs: - Change: {formatSatoshis(changeAmount)} - - )} - - - - Press Next to create and publish the invitation. - - - - ); - - case 'publish': - return ( - - ✓ Invitation Created & Published! - - Invitation ID: - - {invitationId} - - - - - - Share this ID with the other party to complete the transaction. - - - - - Press 'c' to copy ID to clipboard - - - ); - - default: - return null; - } - }; - - // Convert steps to StepIndicator format - const stepIndicatorSteps: Step[] = steps.map(s => ({ label: s.name })); - - return ( - - {/* Header */} - - {logoSmall} - Action Wizard - - {template?.name} {'>'} {actionName} (as {roleIdentifier}) - - - - {/* Progress indicator */} - - - - - {/* Content area */} - - - {' '}{currentStepData?.name} ({currentStep + 1}/{steps.length}){' '} - - - {isProcessing ? ( - Processing... - ) : ( - renderStepContent() - )} - - - - {/* Buttons */} - - -