From 9ef1720e1f7d78386058212ed93bbf7a0f1215e8 Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Sun, 8 Mar 2026 15:53:50 +0000 Subject: [PATCH] Large amount of changes. Successfully broadcasts txs --- Electrum.sqlite-journal | Bin 0 -> 25136 bytes package.json | 4 +- scripts/rm-dbs.ts | 35 ++ src/services/app.ts | 5 +- src/services/history.ts | 27 +- src/services/invitation.ts | 100 ++++- src/tui/App.tsx | 2 +- src/tui/components/Dialog.tsx | 2 +- src/tui/screens/WalletState.tsx | 6 +- .../screens/action-wizard/useActionWizard.ts | 28 +- src/tui/screens/index.tsx | 2 +- .../InvitationScreen.tsx} | 407 ++++-------------- .../InvitationImportFlow.tsx | 318 ++++++++++++++ .../steps/FetchInvitationStep.tsx | 74 ++++ .../steps/InputsSelectStep.tsx | 226 ++++++++++ .../steps/PreviewInvitationStep.tsx | 167 +++++++ .../invitation-import/steps/ReviewStep.tsx | 113 +++++ .../steps/RoleSelectStep.tsx | 88 ++++ .../invitations/invitation-import/types.ts | 122 ++++++ 19 files changed, 1374 insertions(+), 352 deletions(-) create mode 100644 Electrum.sqlite-journal create mode 100644 scripts/rm-dbs.ts rename src/tui/screens/{Invitation.tsx => invitations/InvitationScreen.tsx} (65%) create mode 100644 src/tui/screens/invitations/invitation-import/InvitationImportFlow.tsx create mode 100644 src/tui/screens/invitations/invitation-import/steps/FetchInvitationStep.tsx create mode 100644 src/tui/screens/invitations/invitation-import/steps/InputsSelectStep.tsx create mode 100644 src/tui/screens/invitations/invitation-import/steps/PreviewInvitationStep.tsx create mode 100644 src/tui/screens/invitations/invitation-import/steps/ReviewStep.tsx create mode 100644 src/tui/screens/invitations/invitation-import/steps/RoleSelectStep.tsx create mode 100644 src/tui/screens/invitations/invitation-import/types.ts diff --git a/Electrum.sqlite-journal b/Electrum.sqlite-journal new file mode 100644 index 0000000000000000000000000000000000000000..87d26252ce39eae35af2d3ec07ed1d1769f3bbad GIT binary patch literal 25136 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 literal 0 HcmV?d00001 diff --git a/package.json b/package.json index 13a4a89..4354e37 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "dev": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com tsx src/index.ts", "build": "tsc", "start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "nuke": "tsx scripts/rm-dbs.ts", + "nuke:dry": "tsx scripts/rm-dbs.ts --dry" }, "keywords": [ "crypto", diff --git a/scripts/rm-dbs.ts b/scripts/rm-dbs.ts new file mode 100644 index 0000000..66eb1b3 --- /dev/null +++ b/scripts/rm-dbs.ts @@ -0,0 +1,35 @@ +import fs from 'fs/promises'; + +/** + * Remove all the databases without the use of external tools + * TODO: Fix the ts linking issue here. Should just be adding this as a dir in tsconfig.json + */ +const rmDbs = async (dry = false) => { + // First, we need to find all the database base files + // These end in either .db.sqlite, .sqlite, .db + // Get all the files in the current directory + const files = await fs.readdir('./'); + + // Filter out the files that end in .db.sqlite, .sqlite, .db + const dbFiles = files.filter(file => file.endsWith('.db.sqlite') || file.endsWith('.sqlite') || file.endsWith('.db')); + + // We need to remove all the files + await deleteFiles(dbFiles, dry); +} + +const deleteFiles = async (files: string[], dry = false) => { + if (dry) { + console.log('Dry run, would delete:', files); + return + } + + await Promise.all(files.map(file => fs.rm(file))); + console.log('All databases removed'); +} + +// Read args +const args = process.argv.slice(2); +const dry = args.includes('--dry'); + +// Delete the files +await rmDbs(dry); \ No newline at end of file diff --git a/src/services/app.ts b/src/services/app.ts index fe5d91b..7441ee3 100644 --- a/src/services/app.ts +++ b/src/services/app.ts @@ -55,6 +55,8 @@ export class AppService extends EventEmitter { await engine.importTemplate(p2pkhTemplate); // Set default locking parameters for P2PKH + // To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically. + // TODO: Add discovery for funds in the first index? Or until we return 0 TXs? await engine.setDefaultLockingParameters( generateTemplateIdentifier(p2pkhTemplate), 'receiveOutput', @@ -63,9 +65,10 @@ export class AppService extends EventEmitter { // Create our own storage for the invitations const storage = await Storage.create(config.invitationStoragePath); + const walletStorage = await storage.child(seedHash.slice(0, 8)) // Create the app service - return new AppService(engine, storage, config); + return new AppService(engine, walletStorage, config); } constructor(engine: Engine, storage: Storage, config: AppConfig) { diff --git a/src/services/history.ts b/src/services/history.ts index 008e76d..86dc016 100644 --- a/src/services/history.ts +++ b/src/services/history.ts @@ -6,8 +6,8 @@ * - 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 Engine, compileCashAssemblyString } from '@xo-cash/engine'; +import type { XOInvitation, XOInvitationVariableValue, XOTemplate } from '@xo-cash/types'; import type { UnspentOutputData } from '@xo-cash/state'; import type { Invitation } from './invitation.js'; import { binToHex } from '@bitauth/libauth'; @@ -203,7 +203,7 @@ export class HistoryService { const outputDef = template.outputs?.[utxo.outputIdentifier]; if (!outputDef) { - return `${utxo.outputIdentifier} output`; + return `[${template.name}] ${utxo.outputIdentifier} output`; } // Start with the output name or identifier @@ -211,11 +211,7 @@ export class HistoryService { // 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'); + description = compileCashAssemblyString(outputDef.description, {}) } return description; @@ -239,14 +235,13 @@ export class HistoryService { } const committedVariables = invitation.commits.flatMap(c => c.data.variables ?? []); + const formattedVariables = committedVariables.reduce((acc, v) => { + acc[v.variableIdentifier ?? ''] = v.value; + return acc; + }, {} as Record); + + const description = compileCashAssemblyString(transaction.description, formattedVariables); - 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'); + return description; } } diff --git a/src/services/invitation.ts b/src/services/invitation.ts index a55fccc..ba4bd30 100644 --- a/src/services/invitation.ts +++ b/src/services/invitation.ts @@ -1,6 +1,6 @@ import type { AcceptInvitationParameters, AppendInvitationParameters, Engine, FindSuitableResourcesParameters } from '@xo-cash/engine'; import { hasInvitationExpired } from '@xo-cash/engine'; -import type { XOInvitation, XOInvitationCommit, XOInvitationInput, XOInvitationOutput, XOInvitationVariable } from '@xo-cash/types'; +import type { XOInvitation, XOInvitationCommit, XOInvitationInput, XOInvitationOutput, XOInvitationVariable, XOInvitationVariableValue } from '@xo-cash/types'; import type { UnspentOutputData } from '@xo-cash/state'; import type { SSEvent } from '../utils/sse-client.js'; @@ -9,6 +9,7 @@ import type { Storage } from './storage.js'; import { EventEmitter } from '../utils/event-emitter.js' import { decodeExtendedJsonObject } from '../utils/ext-json.js'; +import { compileCashAssemblyString } from '@xo-cash/engine'; export type InvitationEventMap = { 'invitation-updated': XOInvitation; @@ -32,14 +33,12 @@ export class Invitation extends EventEmitter { // Try to get the invitation from the storage const invitationFromStorage = await dependencies.storage.get(invitation); if (invitationFromStorage) { - console.log(`Invitation found in storage: ${invitation}`); return this.create(invitationFromStorage, dependencies); } // Try to get the invitation from the sync server const invitationFromSyncServer = await dependencies.syncServer.getInvitation(invitation); if (invitationFromSyncServer && invitationFromSyncServer.invitationIdentifier === invitation) { - console.log(`Invitation found in sync server: ${invitation}`); return this.create(invitationFromSyncServer, dependencies); } @@ -345,6 +344,14 @@ export class Invitation extends EventEmitter { await this.syncServer.publishInvitation(this.data); } + /** + * Generate the locking bytecode for the invitation + * TODO: Find out if this has side-effects or needs special handling + */ + async generateLockingBytecode(outputIdentifier: string, roleIdentifier?: string): Promise { + return this.engine.generateLockingBytecode(this.data.templateIdentifier, outputIdentifier, roleIdentifier); + } + async addOutputs(outputs: XOInvitationOutput[]): Promise { // Add the outputs to the invitation await this.append({ outputs }); @@ -410,4 +417,89 @@ export class Invitation extends EventEmitter { async getLockingBytecode(outputIdentifier: string, roleIdentifier?: string): Promise { return this.engine.generateLockingBytecode(this.data.templateIdentifier, outputIdentifier, roleIdentifier); } -} \ No newline at end of file + + /** + * Get the sats out for the invitation + * TODO: Clean up this function. Why is it so big? Can obviously make it 2 functions instead of recursive, but still... + */ + async getSatsOut(outputIdentifier?: string): Promise { + // If an output identifier is provided, find all outputs with that identifier, and its valueSatoshis identifier back to the variables + if (outputIdentifier) { + // Get the valueSatoshis identifier from the template + const template = await this.engine.getTemplate(this.data.templateIdentifier); + if (!template) { + throw new Error(`Template not found: ${this.data.templateIdentifier} when trying to get sats out for output: ${outputIdentifier}`); + } + + const output = template.outputs[outputIdentifier]; + if (!output) { + throw new Error(`Output not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`); + } + + const valueSatoshisIdentifier = output.valueSatoshis; + if (!valueSatoshisIdentifier) { + throw new Error(`Value satoshis identifier not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`); + } + + // Create a list of all the variables from the commits + const variables = this.data.commits.flatMap(c => c.data?.variables ?? []); + + // Create a dictionary of the variables + const formattedVariables = variables.reduce((acc, v) => { + acc[v.variableIdentifier ?? ''] = v.value; + return acc; + }, {} as Record); + + // Compile the CashAssembly expression to get the value satoshis (It handles the variable replacement for us) + const valueSatoshis = await compileCashAssemblyString(String(valueSatoshisIdentifier), formattedVariables); + + // Return the value satoshis as a bigint + // TODO: Check this of a vulnerability or crash - I assume there might be one if someone made the `valueSatoshis` a malicious expression + return BigInt(valueSatoshis); + } + + // If we didnt get an output identifier, go through the action outputs and sum the valueSatoshis + const action = this.data.actionIdentifier; + if (!action) { + throw new Error(`Action not found: ${this.data.actionIdentifier} when trying to get sats out for output: ${outputIdentifier}`); + } + + // Get the template + const template = await this.engine.getTemplate(this.data.templateIdentifier); + if (!template) { + throw new Error(`Template not found: ${this.data.templateIdentifier} when trying to get sats out for action: ${action}`); + } + + // Get the transaction ID from the action + const transactionID = template.actions[action]?.transaction + if (!transactionID) { + throw new Error(`Transactions not found: ${action} in template: ${this.data.templateIdentifier}`); + } + + // Get the transaction from the template + const transaction = template.transactions?.[transactionID]; + if (!transaction) { + throw new Error(`Transaction not found: ${transactionID} in template: ${this.data.templateIdentifier}`); + } + + // Get the outputs from the transaction + const outputs = transaction.outputs; + if (!outputs) { + throw new Error(`Outputs not found: ${transactionID} in template: ${this.data.templateIdentifier}`); + } + + // Create a value to store the cummulative total of the outputs + let totalSats = 0n; + + // Iterate through the outputs and sum the valueSatoshis + for (const output of outputs) { + if (typeof output === 'string') { + totalSats += await this.getSatsOut(output); + } else { + totalSats += await this.getSatsOut(output.output); + } + } + + return totalSats; + } +} diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 7ea245e..6725f9f 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -15,7 +15,7 @@ import { SeedInputScreen } from './screens/SeedInput.js'; import { WalletStateScreen } from './screens/WalletState.js'; import { TemplateListScreen } from './screens/TemplateList.js'; import { ActionWizardScreen } from './screens/action-wizard/ActionWizardScreen.js'; -import { InvitationScreen } from './screens/Invitation.js'; +import { InvitationScreen } from './screens/invitations/InvitationScreen.js'; import { TransactionScreen } from './screens/Transaction.js'; import { MessageDialog } from './components/Dialog.js'; diff --git a/src/tui/components/Dialog.tsx b/src/tui/components/Dialog.tsx index c34c742..1fe216c 100644 --- a/src/tui/components/Dialog.tsx +++ b/src/tui/components/Dialog.tsx @@ -24,7 +24,7 @@ interface DialogWrapperProps { } -function DialogWrapper({ +export function DialogWrapper({ title, borderColor = colors.primary, children, diff --git a/src/tui/screens/WalletState.tsx b/src/tui/screens/WalletState.tsx index 1484e18..3969738 100644 --- a/src/tui/screens/WalletState.tsx +++ b/src/tui/screens/WalletState.tsx @@ -15,6 +15,8 @@ import { useAppContext, useStatus } from '../hooks/useAppContext.js'; import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js'; import type { HistoryItem } from '../../services/history.js'; +import { generateTemplateIdentifier } from '@xo-cash/engine'; + // Import utility functions import { formatHistoryListItem, @@ -139,10 +141,10 @@ export function WalletStateScreen(): React.ReactElement { return; } - // Generate a new locking bytecode - const { generateTemplateIdentifier } = await import('@xo-cash/engine'); + // Generate the template identifier const templateId = generateTemplateIdentifier(p2pkhTemplate); + // Generate the locking bytecode const lockingBytecode = await appService.engine.generateLockingBytecode( templateId, 'receiveOutput', diff --git a/src/tui/screens/action-wizard/useActionWizard.ts b/src/tui/screens/action-wizard/useActionWizard.ts index e0b5c9a..1303a36 100644 --- a/src/tui/screens/action-wizard/useActionWizard.ts +++ b/src/tui/screens/action-wizard/useActionWizard.ts @@ -323,8 +323,6 @@ export function useActionWizard() { actionIdentifier, }); - console.log(xoInvitation) - // Wrap and track const invitationInstance = await appService.createInvitation(xoInvitation); @@ -333,6 +331,8 @@ export function useActionWizard() { const invId = inv.invitationIdentifier; setInvitationId(invId); + setStatus('Adding variables...'); + // Persist variable values if (variables.length > 0) { const variableData = variables.map((v) => { @@ -359,14 +359,24 @@ export function useActionWizard() { if (transaction?.outputs && transaction.outputs.length > 0) { setStatus('Adding required outputs...'); - const outputsToAdd = transaction.outputs.map( - (output: XOTemplateTransactionOutput) => ({ - outputIdentifier: output.output, - roleIdentifier: roleIdentifier, - }) - ); + const outputsToAdd = await Promise.all(transaction.outputs.map( + async (output: XOTemplateTransactionOutput) => ({ + // TODO: Fix this. Currently, there is a type mismatch due to branches/versions of the libraries + outputIdentifier: output as unknown as string, + // roleIdentifier: roleIdentifier, + + // TODO: This feels like an odd requirement? Shouldnt this be handled in the engine? + lockingBytecode: await invitationInstance.generateLockingBytecode(output as unknown as string, roleIdentifier), + }) + )); + + // TODO: Clean this up. Suggestions: 1. Convert to bytes above. 2. Have addOuputs accept a hex string. 3. Have addOutputs handling the lockscript generation + await invitationInstance.addOutputs(outputsToAdd.map((output) => ({ + outputIdentifier: output.outputIdentifier, + // roleIdentifier: output.roleIdentifier, + lockingBytecode: new Uint8Array(Buffer.from(output.lockingBytecode, 'hex')), + }))); - await invitationInstance.addOutputs(outputsToAdd); inv = invitationInstance.data; } diff --git a/src/tui/screens/index.tsx b/src/tui/screens/index.tsx index 8d20b46..98faa53 100644 --- a/src/tui/screens/index.tsx +++ b/src/tui/screens/index.tsx @@ -6,5 +6,5 @@ export * from './action-wizard/index.js'; export { SeedInputScreen } from './SeedInput.js'; export { WalletStateScreen } from './WalletState.js'; export { TemplateListScreen } from './TemplateList.js'; -export { InvitationScreen } from './Invitation.js'; +export { InvitationScreen } from './invitations/InvitationScreen.js'; export { TransactionScreen } from './Transaction.js'; diff --git a/src/tui/screens/Invitation.tsx b/src/tui/screens/invitations/InvitationScreen.tsx similarity index 65% rename from src/tui/screens/Invitation.tsx rename to src/tui/screens/invitations/InvitationScreen.tsx index f466af9..50cedbd 100644 --- a/src/tui/screens/Invitation.tsx +++ b/src/tui/screens/invitations/InvitationScreen.tsx @@ -1,8 +1,8 @@ /** * Invitation Screen - Manages invitations (create, import, view, monitor). - * + * * Provides: - * - Import invitation by ID with role selection + * - Import invitation by ID with multi-step import flow * - View active invitations with detailed information * - Monitor invitation updates via SSE * - Fill missing requirements @@ -11,17 +11,16 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { Box, Text, useInput } from 'ink'; -import { InputDialog } from '../components/Dialog.js'; -import { ScrollableList, type ListItemData, type ListGroup } from '../components/List.js'; -import { useNavigation } from '../hooks/useNavigation.js'; -import { useAppContext, useStatus } from '../hooks/useAppContext.js'; -import { useInvitations } from '../hooks/useInvitations.js'; -import { colors, logoSmall, formatSatoshis } from '../theme.js'; -import { copyToClipboard } from '../utils/clipboard.js'; -import type { Invitation } from '../../services/invitation.js'; +import { InputDialog } from '../../components/Dialog.js'; +import { ScrollableList, type ListItemData, type ListGroup } from '../../components/List.js'; +import { useNavigation } from '../../hooks/useNavigation.js'; +import { useAppContext, useStatus } from '../../hooks/useAppContext.js'; +import { useInvitations } from '../../hooks/useInvitations.js'; +import { colors, logoSmall, formatSatoshis } from '../../theme.js'; +import { copyToClipboard } from '../../utils/clipboard.js'; +import type { Invitation } from '../../../services/invitation.js'; import type { XOTemplate } from '@xo-cash/types'; -// Import utility functions import { getInvitationState, getStateColorName, @@ -31,7 +30,9 @@ import { getUserRole, formatInvitationListItem, formatInvitationId, -} from '../../utils/invitation-utils.js'; +} from '../../../utils/invitation-utils.js'; + +import { InvitationImportFlow } from './invitation-import/InvitationImportFlow.js'; /** * Map state color name to theme color. @@ -84,38 +85,30 @@ export function InvitationScreen(): React.ReactElement { const { navigate, data: navData } = useNavigation(); const { appService, showError, showInfo } = useAppContext(); const { setStatus } = useStatus(); - - // Use hooks for reactive invitation list + const invitations = useInvitations(); - // State + // ── UI state ───────────────────────────────────────────────────────────── const [selectedIndex, setSelectedIndex] = useState(0); const [selectedActionIndex, setSelectedActionIndex] = useState(0); const [focusedPanel, setFocusedPanel] = useState<'list' | 'actions'>('list'); const [isLoading, setIsLoading] = useState(false); - // Import flow state - two stages: 'id' for entering ID, 'role-select' for choosing role - const [importStage, setImportStage] = useState<'id' | 'role-select' | null>(null); - const [importingInvitation, setImportingInvitation] = useState(null); - const [availableRoles, setAvailableRoles] = useState([]); - const [selectedRoleIndex, setSelectedRoleIndex] = useState(0); - const [importTemplate, setImportTemplate] = useState(null); + // ── Import state ───────────────────────────────────────────────────────── + // Two phases: first the ID input dialog, then the multi-step import flow. + const [showIdDialog, setShowIdDialog] = useState(false); + const [importingId, setImportingId] = useState(null); - // Template cache for displaying invitation list with template names + // ── Template cache ─────────────────────────────────────────────────────── const [templateCache, setTemplateCache] = useState>(new Map()); - - // Selected invitation template for details view const [selectedTemplate, setSelectedTemplate] = useState(null); // Check if we should open import dialog on mount const initialMode = navData.mode as string | undefined; - /** - * Show import dialog on mount if needed. - */ useEffect(() => { if (initialMode === 'import') { - setImportStage('id'); + setShowIdDialog(true); } }, [initialMode]); @@ -124,7 +117,7 @@ export function InvitationScreen(): React.ReactElement { */ useEffect(() => { if (!appService) return; - + invitations.forEach(inv => { const templateId = inv.data.templateIdentifier; if (!templateCache.has(templateId)) { @@ -139,10 +132,8 @@ export function InvitationScreen(): React.ReactElement { /** * Build list items for ScrollableList. - * Index 0 is "Import Invitation", subsequent indices are actual invitations. */ const listItems = useMemo((): InvitationListItem[] => { - // Import action at top const importItem: InvitationListItem = { key: 'import', label: '+ Import Invitation', @@ -151,26 +142,23 @@ export function InvitationScreen(): React.ReactElement { color: 'info', }; - // Map invitations to list items const invitationItems: InvitationListItem[] = invitations.map(inv => { const template = templateCache.get(inv.data.templateIdentifier); const formatted = formatInvitationListItem(inv, template); - const state = getInvitationState(inv); - + return { key: inv.data.invitationIdentifier, label: formatted.label, value: inv, group: 'invitations', color: formatted.statusColor, - hidden: !formatted.isValid, // Hide invalid items + hidden: !formatted.isValid, }; }); return [importItem, ...invitationItems]; }, [invitations, templateCache]); - // Get selected invitation from list items const selectedItem = listItems[selectedIndex]; const selectedInvitation = selectedItem?.value ?? null; @@ -182,116 +170,34 @@ export function InvitationScreen(): React.ReactElement { setSelectedTemplate(null); return; } - + appService.engine.getTemplate(selectedInvitation.data.templateIdentifier) .then(template => setSelectedTemplate(template ?? null)); }, [selectedInvitation, appService]); + // ── Import flow callbacks ────────────────────────────────────────────── + /** - * Stage 1: Import invitation by ID (fetches invitation and moves to role selection). + * ID dialog submitted — transition to the multi-step import flow. */ - const handleImportIdSubmit = useCallback(async (invitationId: string) => { - if (!invitationId.trim() || !appService) { - setImportStage(null); + const handleImportIdSubmit = useCallback((invitationId: string) => { + if (!invitationId.trim()) { + setShowIdDialog(false); return; } - - console.log('Importing invitation:', invitationId); - - try { - setIsLoading(true); - setStatus('Fetching invitation...'); - - // Create invitation instance (will fetch from sync server) - const invitation = await appService.createInvitation(invitationId); - - console.log(invitation); - - const missingRequirements = await invitation.getMissingRequirements(); - console.log(missingRequirements); - - // Get available roles for this invitation - const roles = await invitation.getAvailableRoles(); - - console.log(roles); - - // Get the template for display - const template = await appService.engine.getTemplate(invitation.data.templateIdentifier); - - // Store for next stage - setImportingInvitation(invitation); - setAvailableRoles(roles); - setSelectedRoleIndex(0); - setImportTemplate(template ?? null); - - // Move to role selection stage - setImportStage('role-select'); - setStatus('Ready'); - } catch (error) { - showError(`Failed to import: ${error instanceof Error ? error.message : String(error)}`); - setImportStage(null); - } finally { - setIsLoading(false); - } - }, [appService, showError, setStatus]); + setShowIdDialog(false); + setImportingId(invitationId.trim()); + }, []); /** - * Stage 2: Accept invitation with selected role. + * Import flow closed (completed or cancelled). */ - const handleRoleSelect = useCallback(async () => { - if (!importingInvitation || !appService) return; + const handleImportFlowClose = useCallback(() => { + setImportingId(null); + }, []); - const selectedRole = availableRoles[selectedRoleIndex]; - if (!selectedRole) { - showError('No role selected'); - return; - } + // ── Action handlers ──────────────────────────────────────────────────── - try { - setIsLoading(true); - setStatus(`Accepting as ${selectedRole}...`); - - // TODO: Engine doesnt support "accepting" without supplying some kind of data along with it. - // We also dont have a way to say "this action will require inputs, so i will do that." - // If it did, we could add an "input" with the role identifier. - // For now, we are just going to hard-code the input with the role identifier. - await importingInvitation.addInputs([{ - roleIdentifier: selectedRole, - }]); - - showInfo(`Invitation imported and accepted!\n\nRole: ${selectedRole}\nTemplate: ${importTemplate?.name ?? importingInvitation.data.templateIdentifier}\nAction: ${importingInvitation.data.actionIdentifier}`); - setStatus('Ready'); - - // Reset import state - setImportStage(null); - setImportingInvitation(null); - setAvailableRoles([]); - setImportTemplate(null); - } catch (error) { - showError(`Failed to accept: ${error instanceof Error ? error.message : String(error)}`); - } finally { - setIsLoading(false); - } - }, [importingInvitation, availableRoles, selectedRoleIndex, appService, importTemplate, showInfo, showError, setStatus]); - - /** - * Cancel import and remove the invitation if it was added. - */ - const handleImportCancel = useCallback(async () => { - if (importingInvitation && appService) { - // Remove the invitation since user declined - await appService.removeInvitation(importingInvitation); - } - - setImportStage(null); - setImportingInvitation(null); - setAvailableRoles([]); - setImportTemplate(null); - }, [importingInvitation, appService]); - - /** - * Accept selected invitation (from actions menu). - */ const acceptInvitation = useCallback(async () => { if (!selectedInvitation) { showError('No invitation selected'); @@ -307,7 +213,6 @@ export function InvitationScreen(): React.ReactElement { setStatus('Ready'); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); - // Check if already accepted if (errorMsg.toLowerCase().includes('already') || errorMsg.toLowerCase().includes('participant')) { showInfo('You have already accepted this invitation.\n\nNext step: Use "Fill Requirements" to add your UTXOs.'); } else { @@ -318,9 +223,6 @@ export function InvitationScreen(): React.ReactElement { } }, [selectedInvitation, showInfo, showError, setStatus]); - /** - * Sign selected invitation. - */ const signInvitation = useCallback(async () => { if (!selectedInvitation) { showError('No invitation selected'); @@ -341,9 +243,6 @@ export function InvitationScreen(): React.ReactElement { } }, [selectedInvitation, showInfo, showError, setStatus]); - /** - * Copy invitation ID. - */ const copyId = useCallback(async () => { if (!selectedInvitation) { showError('No invitation selected'); @@ -358,9 +257,6 @@ export function InvitationScreen(): React.ReactElement { } }, [selectedInvitation, showInfo, showError]); - /** - * Fill requirements for selected invitation. - */ const fillRequirements = useCallback(async () => { if (!selectedInvitation) { showError('No invitation selected'); @@ -369,20 +265,17 @@ export function InvitationScreen(): React.ReactElement { try { setIsLoading(true); - - // Step 1: Check available roles + setStatus('Checking available roles...'); const roles = await selectedInvitation.getAvailableRoles(); - + if (roles.length === 0) { - // Already participating, check if we can add inputs showInfo('You are already participating in this invitation. Checking if inputs are needed...'); } else { - // Need to accept a role first const roleToTake = roles[0]; showInfo(`Accepting invitation as role: ${roleToTake}`); setStatus(`Accepting as ${roleToTake}...`); - + try { await selectedInvitation.accept(); } catch (e) { @@ -392,10 +285,8 @@ export function InvitationScreen(): React.ReactElement { } } - // Step 2: Check if invitation already has inputs or needs funding setStatus('Analyzing invitation...'); - // Calculate how much we need let requiredAmount = 0n; const commits = selectedInvitation.data.commits || []; for (const commit of commits) { @@ -413,21 +304,19 @@ export function InvitationScreen(): React.ReactElement { const dust = 546n; const totalNeeded = requiredAmount + fee + dust; - // Find resources const utxos = await selectedInvitation.findSuitableResources({ templateIdentifier: selectedInvitation.data.templateIdentifier, outputIdentifier: 'receiveOutput', }); - + if (utxos.length === 0) { showError('No suitable UTXOs found. Make sure your wallet has funds.'); setStatus('Ready'); return; } - // Select UTXOs setStatus('Selecting UTXOs...'); - + const selectedUtxos: Array<{ outpointTransactionHash: string; outpointIndex: number; @@ -443,12 +332,8 @@ export function InvitationScreen(): React.ReactElement { : Buffer.from(utxo.lockingBytecode).toString('hex') : undefined; - if (lockingBytecodeHex && seenLockingBytecodes.has(lockingBytecodeHex)) { - continue; - } - if (lockingBytecodeHex) { - seenLockingBytecodes.add(lockingBytecodeHex); - } + if (lockingBytecodeHex && seenLockingBytecodes.has(lockingBytecodeHex)) continue; + if (lockingBytecodeHex) seenLockingBytecodes.add(lockingBytecodeHex); selectedUtxos.push({ outpointTransactionHash: utxo.outpointTransactionHash, @@ -457,9 +342,7 @@ export function InvitationScreen(): React.ReactElement { }); accumulated += BigInt(utxo.valueSatoshis); - if (accumulated >= totalNeeded) { - break; - } + if (accumulated >= totalNeeded) break; } if (accumulated < totalNeeded) { @@ -470,7 +353,6 @@ export function InvitationScreen(): React.ReactElement { const changeAmount = accumulated - requiredAmount - fee; - // Add inputs setStatus('Adding inputs...'); await selectedInvitation.addInputs( selectedUtxos.map(u => ({ @@ -479,7 +361,6 @@ export function InvitationScreen(): React.ReactElement { })) ); - // Add change output if (changeAmount >= dust) { setStatus('Adding change output...'); await selectedInvitation.addOutputs([{ @@ -487,7 +368,6 @@ export function InvitationScreen(): React.ReactElement { }]); } - // Show success showInfo( `Requirements filled!\n\n` + `• Selected ${selectedUtxos.length} UTXO(s)\n` + @@ -498,7 +378,6 @@ export function InvitationScreen(): React.ReactElement { `Now use "Sign Transaction" to complete.` ); setStatus('Ready'); - } catch (error) { showError(`Failed to fill requirements: ${error instanceof Error ? error.message : String(error)}`); setStatus('Ready'); @@ -507,9 +386,6 @@ export function InvitationScreen(): React.ReactElement { } }, [selectedInvitation, showInfo, showError, setStatus]); - /** - * Handle action selection. - */ const handleAction = useCallback((action: string) => { switch (action) { case 'copy': @@ -532,70 +408,44 @@ export function InvitationScreen(): React.ReactElement { } }, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, navigate]); - /** - * Handle list item activation. - */ - const handleListItemActivate = useCallback((item: InvitationListItem, index: number) => { + const handleListItemActivate = useCallback((item: InvitationListItem, _index: number) => { if (item.key === 'import') { - setImportStage('id'); + setShowIdDialog(true); } - // For invitation items, we just select them - actions are in the actions panel }, []); - /** - * Handle action item activation. - */ - const handleActionItemActivate = useCallback((item: ListItemData, index: number) => { + const handleActionItemActivate = useCallback((item: ListItemData, _index: number) => { if (item.value) { handleAction(item.value); } }, [handleAction]); - // Handle keyboard navigation + // ── Keyboard navigation ────────────────────────────────────────────────── + // Disabled when the ID dialog or import flow is open. + const isOverlayOpen = showIdDialog || importingId !== null; + useInput((input, key) => { - // Handle role selection dialog navigation - if (importStage === 'role-select') { - if (key.upArrow || input === 'k') { - setSelectedRoleIndex(prev => Math.max(0, prev - 1)); - } else if (key.downArrow || input === 'j') { - setSelectedRoleIndex(prev => Math.min(availableRoles.length - 1, prev + 1)); - } else if (key.return) { - handleRoleSelect(); - } else if (key.escape) { - handleImportCancel(); - } - return; - } - - // Don't handle input while ID input dialog is open - if (importStage === 'id') return; - - // Tab to switch panels (list -> actions -> list) if (key.tab) { setFocusedPanel(prev => prev === 'list' ? 'actions' : 'list'); return; } - // 'c' to copy if (input === 'c' && selectedInvitation) { copyId(); } - // 'i' to import if (input === 'i') { - setImportStage('id'); + setShowIdDialog(true); } - }, { isActive: importStage !== 'id' }); + }, { isActive: !isOverlayOpen }); + + // ── Render helpers ─────────────────────────────────────────────────────── - /** - * Render custom list item for invitation list. - */ const renderInvitationListItem = useCallback(( item: InvitationListItem, isSelected: boolean, isFocused: boolean ): React.ReactNode => { - // Import item if (item.key === 'import') { return ( { if (!selectedInvitation) { return Select an invitation to view details; @@ -641,8 +487,7 @@ export function InvitationScreen(): React.ReactElement { const inputs = getInvitationInputs(selectedInvitation); const outputs = getInvitationOutputs(selectedInvitation); const variables = getInvitationVariables(selectedInvitation); - - // Try to determine user's entity ID (from first commit they made) + const userEntityId = selectedInvitation.data.commits?.[0]?.entityIdentifier ?? null; const userRole = getUserRole(selectedInvitation, userEntityId); const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole]; @@ -650,7 +495,7 @@ export function InvitationScreen(): React.ReactElement { return ( - {/* Row 1: Type, Description, Status */} + {/* Type & Status */} @@ -675,7 +520,7 @@ export function InvitationScreen(): React.ReactElement { - {/* Row 2: Your Role */} + {/* Your Role */} {userRole && ( Your Role: @@ -686,9 +531,8 @@ export function InvitationScreen(): React.ReactElement { )} - {/* Row 3: Inputs & Outputs side by side */} + {/* Inputs & Outputs */} - {/* Inputs */} Inputs ({inputs.length}): {inputs.length === 0 ? ( @@ -698,8 +542,8 @@ export function InvitationScreen(): React.ReactElement { const isUserInput = input.entityIdentifier === userEntityId; const inputTemplate = selectedTemplate?.inputs?.[input.inputIdentifier ?? '']; return ( - {' '}{isUserInput ? '• ' : '○ '} @@ -711,7 +555,6 @@ export function InvitationScreen(): React.ReactElement { )} - {/* Outputs */} Outputs ({outputs.length}): {outputs.length === 0 ? ( @@ -721,8 +564,8 @@ export function InvitationScreen(): React.ReactElement { const isUserOutput = output.entityIdentifier === userEntityId; const outputTemplate = selectedTemplate?.outputs?.[output.outputIdentifier ?? '']; return ( - {' '}{isUserOutput ? '• ' : '○ '} @@ -735,7 +578,7 @@ export function InvitationScreen(): React.ReactElement { - {/* Row 4: Variables */} + {/* Variables */} Variables ({variables.length}): {variables.length === 0 ? ( @@ -744,12 +587,12 @@ export function InvitationScreen(): React.ReactElement { variables.map((variable, idx) => { const isUserVariable = variable.entityIdentifier === userEntityId; const varTemplate = selectedTemplate?.variables?.[variable.variableIdentifier]; - const displayValue = typeof variable.value === 'bigint' - ? variable.value.toString() + const displayValue = typeof variable.value === 'bigint' + ? variable.value.toString() : String(variable.value); return ( - {' '}{isUserVariable ? '• ' : '○ '} @@ -763,7 +606,6 @@ export function InvitationScreen(): React.ReactElement { )} - {/* Shortcuts */} c: Copy ID @@ -771,84 +613,7 @@ export function InvitationScreen(): React.ReactElement { ); }; - /** - * Render role selection dialog for import flow. - */ - const renderRoleSelectionDialog = () => { - if (!importingInvitation) return null; - - const action = importTemplate?.actions?.[importingInvitation.data.actionIdentifier]; - - return ( - - - Import Invitation - Select Role - - {/* Invitation Details */} - - Template: {importTemplate?.name ?? 'Unknown'} - {importTemplate?.description && ( - {importTemplate.description} - )} - Action: {action?.name ?? importingInvitation.data.actionIdentifier} - {action?.description && ( - {action.description} - )} - - - {/* Role Selection */} - - Available Roles: - {availableRoles.length === 0 ? ( - No roles available (you may have already joined) - ) : ( - availableRoles.map((role, index) => { - const roleInfoRaw = importTemplate?.roles?.[role]; - const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null; - const actionRoleRaw = action?.roles?.[role]; - const actionRole = actionRoleRaw && typeof actionRoleRaw === 'object' ? actionRoleRaw : null; - return ( - - - {index === selectedRoleIndex ? '▸ ' : ' '} - {roleInfo?.name ?? role} - - {(roleInfo?.description || actionRole?.description) && ( - - {' '}{actionRole?.description ?? roleInfo?.description} - - )} - - ); - }) - )} - - - - ↑↓: Select role • Enter: Accept • Esc: Decline - - - - ); - }; + // ── Main render ────────────────────────────────────────────────────────── return ( @@ -857,7 +622,7 @@ export function InvitationScreen(): React.ReactElement { {logoSmall} - Invitations - {/* Main content - Top row: List + Actions */} + {/* Top row: List + Actions */} {/* Left column: Invitation list */} @@ -874,7 +639,7 @@ export function InvitationScreen(): React.ReactElement { selectedIndex={selectedIndex} onSelect={setSelectedIndex} onActivate={handleListItemActivate} - focus={focusedPanel === 'list'} + focus={focusedPanel === 'list' && !isOverlayOpen} maxVisible={6} groups={invitationListGroups} emptyMessage="No invitations yet" @@ -898,14 +663,14 @@ export function InvitationScreen(): React.ReactElement { selectedIndex={selectedActionIndex} onSelect={setSelectedActionIndex} onActivate={handleActionItemActivate} - focus={focusedPanel === 'actions'} + focus={focusedPanel === 'actions' && !isOverlayOpen} emptyMessage="No actions" /> - {/* Bottom row: Details (full width) */} + {/* Bottom row: Details */} - {/* Import ID dialog (Stage 1) */} - {importStage === 'id' && ( + {/* Import ID dialog */} + {showIdDialog && ( setImportStage(null)} + onCancel={() => setShowIdDialog(false)} isActive={true} /> )} - {/* Role Selection dialog (Stage 2) */} - {importStage === 'role-select' && renderRoleSelectionDialog()} + {/* Multi-step import flow */} + {importingId && appService && ( + + )} ); } diff --git a/src/tui/screens/invitations/invitation-import/InvitationImportFlow.tsx b/src/tui/screens/invitations/invitation-import/InvitationImportFlow.tsx new file mode 100644 index 0000000..54b2153 --- /dev/null +++ b/src/tui/screens/invitations/invitation-import/InvitationImportFlow.tsx @@ -0,0 +1,318 @@ +/** + * InvitationImportFlow — orchestrates the multi-step invitation import. + * + * Manages the step state machine, accumulates data from each step, and + * injects it into the next step via props (dependency injection). + * + * Supports two display modes: + * - `'dialog'`: renders as an absolute-positioned overlay (used when called from InvitationScreen) + * - `'screen'`: renders as a full-screen component with header, step indicator, and button bar + */ + +import React, { useState, useCallback } from 'react'; +import { Box, Text, useInput } from 'ink'; +import { colors, logoSmall } from '../../../theme.js'; +import { StepIndicator, type Step } from '../../../components/ProgressBar.js'; + +import { FetchInvitationStep } from './steps/FetchInvitationStep.js'; +import { PreviewInvitationStep } from './steps/PreviewInvitationStep.js'; +import { RoleSelectStep } from './steps/RoleSelectStep.js'; +import { InputsSelectStep } from './steps/InputsSelectStep.js'; +import { ReviewStep } from './steps/ReviewStep.js'; + +import { IMPORT_STEPS, type ImportFlowProps, type SelectableUTXO } from './types.js'; +import type { Invitation } from '../../../../services/invitation.js'; +import type { XOTemplate } from '@xo-cash/types'; +import { DialogWrapper } from '../../../components/Dialog.js'; +import { InvitationBuilder } from '@xo-cash/engine'; +import { hexToBin } from '@bitauth/libauth'; + +/** Default fee estimate in satoshis. */ +const DEFAULT_FEE = 500n; + +/** Dust threshold — outputs below this are unspendable. */ +const DUST_THRESHOLD = 546n; + +export function InvitationImportFlow({ + invitationId, + mode, + appService, + onClose, + showError, + showInfo, + setStatus, +}: ImportFlowProps): React.ReactElement { + // ── Accumulated state ──────────────────────────────────────────────────── + const [currentStep, setCurrentStep] = useState(0); + const [invitation, setInvitation] = useState(null); + const [buildableInvitation, setBuildableInvitation] = useState(null); + const [template, setTemplate] = useState(null); + const [availableRoles, setAvailableRoles] = useState([]); + const [selectedRole, setSelectedRole] = useState(null); + const [selectedInputs, setSelectedInputs] = useState([]); + const [changeAmount, setChangeAmount] = useState(0n); + const [requiredAmount, setRequiredAmount] = useState(0n); + + // ── Cancel handler ─────────────────────────────────────────────────────── + /** + * Cleans up (removes the invitation if it was fetched) and signals the parent. + */ + const handleCancel = useCallback(async () => { + if (invitation && appService) { + try { + await appService.removeInvitation(invitation); + } catch { + // Best-effort removal — don't block close on failure + } + } + onClose(); + }, [invitation, appService, onClose]); + + // ── Step completion callbacks ──────────────────────────────────────────── + + /** + * FetchStep completed — invitation and template are now available. + * Also pre-fetches available roles for the next steps. + */ + const handleFetchComplete = useCallback(async (inv: Invitation, tmpl: XOTemplate | null) => { + setInvitation(inv); + setTemplate(tmpl); + + const builder = InvitationBuilder.fromInvitation(inv.data); + setBuildableInvitation(builder); + + try { + const roles = await inv.getAvailableRoles(); + setAvailableRoles(roles); + } catch (err) { + showError(`Failed to get roles: ${err instanceof Error ? err.message : String(err)}`); + } + + setCurrentStep(1); // → Preview + }, [showError]); + + /** PreviewStep completed — user reviewed the invitation state and wants to proceed. */ + const handlePreviewComplete = useCallback(() => { + setCurrentStep(2); // → Role Select + }, []); + + /** RoleSelectStep completed — user picked a role. */ + const handleRoleComplete = useCallback((role: string) => { + setSelectedRole(role); + setCurrentStep(3); // → Inputs Select + }, []); + + /** InputsSelectStep completed — user selected UTXOs. */ + const handleInputsComplete = useCallback(async (inputs: SelectableUTXO[]) => { + setSelectedInputs(inputs); + + await invitation?.addInputs(inputs.map(input => ({ + outpointTransactionHash: hexToBin(input.outpointTransactionHash), + outpointIndex: input.outpointIndex, + }))); + + // Compute totals from selected inputs + const totalSelected = inputs.reduce((sum, u) => sum + u.valueSatoshis, 0n); + + // Determine required amount from invitation variables + const requiredSats = await invitation?.getSatsOut() ?? 0n; + setRequiredAmount(requiredSats); + + // Set the change amount for the review step + const changeAmountSats = totalSelected - requiredSats - DEFAULT_FEE; + setChangeAmount(changeAmountSats); + + console.log('totalSelected:', totalSelected); + console.log('requiredAmount:', requiredSats); + console.log('DEFAULT_FEE:', DEFAULT_FEE); + console.log('changeAmount:', changeAmount); + + // Add the change output if it exceeds the dust threshold + if (changeAmountSats >= DUST_THRESHOLD) { + await invitation?.addOutputs([{ + valueSatoshis: changeAmountSats, + }]); + } + + setCurrentStep(4); // → Review + }, [invitation, buildableInvitation, selectedInputs]); + + /** ReviewStep completed — invitation import is done. */ + const handleReviewComplete = useCallback(() => { + const roleName = (() => { + if (!selectedRole || !template) return selectedRole ?? ''; + const raw = template.roles?.[selectedRole]; + return (raw && typeof raw === 'object' && 'name' in raw) ? String(raw.name) : selectedRole; + })(); + + showInfo( + `Invitation imported and accepted!\n\n` + + `Role: ${roleName}\n` + + `Template: ${template?.name ?? invitation?.data.templateIdentifier ?? 'Unknown'}\n` + + `Action: ${invitation?.data.actionIdentifier ?? 'Unknown'}` + ); + setStatus('Ready'); + onClose(); + }, [selectedRole, template, invitation, showInfo, setStatus, onClose]); + + // ── Keyboard handling for FetchStep error retry ────────────────────────── + // FetchStep auto-advances on success but shows error state with retry on failure. + useInput((_input, key) => { + if (currentStep !== 0) return; + // Enter retries, Esc cancels — handled within FetchStep rendering, + // but we also catch Esc here for safety. + if (key.escape) handleCancel(); + }, { isActive: currentStep === 0 }); + + // ── Step router ────────────────────────────────────────────────────────── + const renderStep = (): React.ReactNode => { + const stepDef = IMPORT_STEPS[currentStep]; + if (!stepDef) return null; + + switch (stepDef.type) { + case 'fetch': + return ( + + ); + + case 'preview': + if (!invitation) return null; + return ( + + ); + + case 'role-select': + if (!invitation) return null; + return ( + + ); + + case 'inputs-select': + if (!invitation || !selectedRole) return null; + return ( + + ); + + case 'review': + if (!invitation || !selectedRole) return null; + return ( + + ); + + default: + return null; + } + }; + + // ── Step indicator data ────────────────────────────────────────────────── + const indicatorSteps: Step[] = IMPORT_STEPS.map(s => ({ label: s.name })); + + // ── Layout: dialog mode ────────────────────────────────────────────────── + if (mode === 'dialog') { + return ( + + + {/* Step indicator (compact) */} + + + + + {/* Step content */} + + {renderStep()} + + + + ); + } + + // ── Layout: screen mode ────────────────────────────────────────────────── + return ( + + {/* Header */} + + {logoSmall} - Import Invitation + + {template?.name ?? 'Loading...'} + {selectedRole ? ` (as ${selectedRole})` : ''} + + + + {/* Step indicator */} + + + + + {/* Step content */} + + + {IMPORT_STEPS[currentStep]?.name ?? 'Unknown'} ({currentStep + 1}/{IMPORT_STEPS.length}) + + + {renderStep()} + + + + {/* Help text */} + + + Esc: Cancel import + + + + ); +} diff --git a/src/tui/screens/invitations/invitation-import/steps/FetchInvitationStep.tsx b/src/tui/screens/invitations/invitation-import/steps/FetchInvitationStep.tsx new file mode 100644 index 0000000..8bad50d --- /dev/null +++ b/src/tui/screens/invitations/invitation-import/steps/FetchInvitationStep.tsx @@ -0,0 +1,74 @@ +/** + * FetchInvitationStep — first step in the import flow. + * + * Receives an invitation ID, fetches the invitation from the sync server, + * resolves its template, and auto-advances once loaded. + * Shows a loading spinner while fetching and an error state with retry/cancel. + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { Box, Text } from 'ink'; +import { colors } from '../../../../theme.js'; +import type { FetchStepProps } from '../types.js'; + +export function FetchInvitationStep({ + invitationId, + appService, + onComplete, + onCancel, + isActive, +}: FetchStepProps): React.ReactElement { + const [status, setStatus] = useState<'loading' | 'error'>('loading'); + const [errorMessage, setErrorMessage] = useState(null); + + /** + * Fetch the invitation and its template, then auto-advance. + */ + const fetchInvitation = useCallback(async () => { + setStatus('loading'); + setErrorMessage(null); + + try { + // Create/fetch the invitation instance (fetches from sync server if needed) + const invitation = await appService.createInvitation(invitationId); + + // Resolve the template for display in later steps + const template = await appService.engine.getTemplate(invitation.data.templateIdentifier); + + // Auto-advance — hand the loaded data to the flow controller + onComplete(invitation, template ?? null); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setErrorMessage(message); + setStatus('error'); + } + }, [invitationId, appService, onComplete]); + + // Kick off the fetch on mount + useEffect(() => { + if (isActive) { + fetchInvitation(); + } + }, [isActive, fetchInvitation]); + + return ( + + {status === 'loading' && ( + + Fetching invitation... + ID: {invitationId} + + )} + + {status === 'error' && ( + + Failed to fetch invitation + {errorMessage} + + Press Enter to retry or Esc to cancel + + + )} + + ); +} diff --git a/src/tui/screens/invitations/invitation-import/steps/InputsSelectStep.tsx b/src/tui/screens/invitations/invitation-import/steps/InputsSelectStep.tsx new file mode 100644 index 0000000..4251421 --- /dev/null +++ b/src/tui/screens/invitations/invitation-import/steps/InputsSelectStep.tsx @@ -0,0 +1,226 @@ +/** + * InputsSelectStep — lets the user select UTXOs to fund the invitation. + * + * On mount, queries for suitable resources via the invitation's `findSuitableResources`. + * Auto-selects greedily, then lets the user toggle individual UTXOs. + * Shows required, selected, and change amounts. + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { Box, Text, useInput } from 'ink'; +import { colors, formatSatoshis } from '../../../../theme.js'; +import type { InputsSelectStepProps, SelectableUTXO } from '../types.js'; + +/** Default fee estimate in satoshis. */ +const DEFAULT_FEE = 500n; + +/** Dust threshold — outputs below this are unspendable. */ +const DUST_THRESHOLD = 546n; + +export function InputsSelectStep({ + invitation, + template, + selectedRole, + appService, + onComplete, + onCancel, + isActive, +}: InputsSelectStepProps): React.ReactElement { + const [utxos, setUtxos] = useState([]); + const [focusedIndex, setFocusedIndex] = useState(0); + const [requiredAmount, setRequiredAmount] = useState(0n); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fee = DEFAULT_FEE; + + // Derived totals + const selectedAmount = utxos + .filter(u => u.selected) + .reduce((sum, u) => sum + u.valueSatoshis, 0n); + const changeAmount = selectedAmount - requiredAmount - fee; + const hasEnough = selectedAmount >= requiredAmount + fee; + + /** + * Determine the required satoshi amount from the invitation's variables. + */ + const computeRequiredAmount = useCallback(async (): Promise => { + return await invitation.getSatsOut() ?? 0n; + }, [invitation]); + + /** + * Fetch suitable UTXOs from the engine and auto-select greedily. + */ + const loadUtxos = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + const required = await computeRequiredAmount(); + setRequiredAmount(required); + + const unspentOutputs = await invitation.findSuitableResources({ + templateIdentifier: invitation.data.templateIdentifier, + outputIdentifier: 'receiveOutput', + }); + + // Map to selectable UTXOs + const selectable: SelectableUTXO[] = unspentOutputs.map((utxo: any) => ({ + outpointTransactionHash: utxo.outpointTransactionHash, + outpointIndex: utxo.outpointIndex, + valueSatoshis: BigInt(utxo.valueSatoshis), + lockingBytecode: utxo.lockingBytecode + ? typeof utxo.lockingBytecode === 'string' + ? utxo.lockingBytecode + : Buffer.from(utxo.lockingBytecode).toString('hex') + : undefined, + selected: false, + })); + + // Greedy auto-select, skipping duplicate locking bytecodes + let accumulated = 0n; + const seenBytecodes = new Set(); + + for (const utxo of selectable) { + if (utxo.lockingBytecode && seenBytecodes.has(utxo.lockingBytecode)) continue; + if (utxo.lockingBytecode) seenBytecodes.add(utxo.lockingBytecode); + + utxo.selected = true; + accumulated += utxo.valueSatoshis; + + if (accumulated >= required + fee) break; + } + + setUtxos(selectable); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setIsLoading(false); + } + }, [invitation, computeRequiredAmount, fee]); + + // Load UTXOs on mount + useEffect(() => { + if (isActive) loadUtxos(); + }, [isActive, loadUtxos]); + + /** + * Toggle the selection of a UTXO at the given index. + */ + const toggleSelection = useCallback((index: number) => { + setUtxos(prev => { + const updated = [...prev]; + const utxo = updated[index]; + if (utxo) updated[index] = { ...utxo, selected: !utxo.selected }; + return updated; + }); + }, []); + + // Keyboard handling + useInput((input, key) => { + if (!isActive) return; + + if (key.upArrow || input === 'k') { + setFocusedIndex(prev => Math.max(0, prev - 1)); + } else if (key.downArrow || input === 'j') { + setFocusedIndex(prev => Math.min(utxos.length - 1, prev + 1)); + } else if (input === ' ' || (key.return && utxos.length > 0)) { + // Space or Enter toggles the focused UTXO + if (utxos.length > 0) toggleSelection(focusedIndex); + } else if (input === 'a') { + // Select all + setUtxos(prev => prev.map(u => ({ ...u, selected: true }))); + } else if (input === 'n') { + // Deselect all + setUtxos(prev => prev.map(u => ({ ...u, selected: false }))); + } else if (key.tab) { + // Tab confirms selection (moves to next step) + if (hasEnough) { + onComplete(utxos.filter(u => u.selected)); + } + } else if (key.escape) { + onCancel(); + } + }, { isActive }); + + // Loading state + if (isLoading) { + return ( + + Finding suitable UTXOs... + + ); + } + + // Error state + if (error) { + return ( + + Failed to load UTXOs + {error} + + Esc: Cancel + + + ); + } + + // No UTXOs found + if (utxos.length === 0) { + return ( + + No suitable UTXOs found. Make sure your wallet has funds. + + Esc: Cancel + + + ); + } + + return ( + + {/* Summary bar */} + + Required: + {formatSatoshis(requiredAmount + fee)} + (amount {formatSatoshis(requiredAmount)} + fee {formatSatoshis(fee)}) + + + + Selected: + {formatSatoshis(selectedAmount)} + {hasEnough && changeAmount >= DUST_THRESHOLD && ( + (change: {formatSatoshis(changeAmount)}) + )} + {!hasEnough && ( + — need {formatSatoshis(requiredAmount + fee - selectedAmount)} more + )} + + + {/* UTXO list */} + UTXOs ({utxos.length}): + {utxos.map((utxo, index) => { + const isFocused = index === focusedIndex; + const checkMark = utxo.selected ? '☑' : '☐'; + const txShort = utxo.outpointTransactionHash.slice(0, 8); + + return ( + + {isFocused ? '▸ ' : ' '}{checkMark} {formatSatoshis(utxo.valueSatoshis)} ({txShort}…:{utxo.outpointIndex}) + + ); + })} + + {/* Navigation hint */} + + + ↑↓: Navigate • Space: Toggle • a: All • n: None • Tab: Confirm • Esc: Cancel + + + + ); +} diff --git a/src/tui/screens/invitations/invitation-import/steps/PreviewInvitationStep.tsx b/src/tui/screens/invitations/invitation-import/steps/PreviewInvitationStep.tsx new file mode 100644 index 0000000..ddfa7af --- /dev/null +++ b/src/tui/screens/invitations/invitation-import/steps/PreviewInvitationStep.tsx @@ -0,0 +1,167 @@ +/** + * PreviewInvitationStep — displays the current state of a fetched invitation. + * + * Shows which roles, inputs, outputs, and variables have already been filled + * so the user can understand what they're joining before proceeding. + * Press Enter to continue, Esc to cancel. + */ + +import React from 'react'; +import { Box, Text, useInput } from 'ink'; +import { colors, formatSatoshis } from '../../../../theme.js'; +import { + getInvitationState, + getStateColorName, + getInvitationInputs, + getInvitationOutputs, + getInvitationVariables, +} from '../../../../../utils/invitation-utils.js'; +import type { PreviewStepProps } from '../types.js'; + +/** + * Map a semantic color name to an actual theme color value. + */ +function stateColor(state: string): string { + const name = getStateColorName(state); + switch (name) { + case 'info': return colors.info as string; + case 'warning': return colors.warning as string; + case 'success': return colors.success as string; + case 'error': return colors.error as string; + case 'muted': + default: return colors.textMuted as string; + } +} + +export function PreviewInvitationStep({ + invitation, + template, + onComplete, + onCancel, + isActive, +}: PreviewStepProps): React.ReactElement { + useInput((_input, key) => { + if (!isActive) return; + if (key.return) onComplete(); + if (key.escape) onCancel(); + }, { isActive }); + + const state = getInvitationState(invitation); + const action = template?.actions?.[invitation.data.actionIdentifier]; + const inputs = getInvitationInputs(invitation); + const outputs = getInvitationOutputs(invitation); + const variables = getInvitationVariables(invitation); + + // Collect role identifiers that appear across all commits + const filledRoles = new Set(); + for (const commit of invitation.data.commits ?? []) { + for (const input of commit.data?.inputs ?? []) { + if (input.roleIdentifier) filledRoles.add(input.roleIdentifier); + } + } + + return ( + + {/* Template & action info */} + + Template: + {template?.name ?? invitation.data.templateIdentifier} + {template?.description && ( + {template.description} + )} + + + + + Action: + {action?.name ?? invitation.data.actionIdentifier} + {action?.description && ( + {action.description} + )} + + + Status: + {state} + + + + {/* Roles already filled */} + + Roles Filled ({filledRoles.size}): + {filledRoles.size === 0 ? ( + None yet + ) : ( + Array.from(filledRoles).map(role => { + const roleInfoRaw = template?.roles?.[role]; + const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null; + return ( + • {roleInfo?.name ?? role} + ); + }) + )} + + + {/* Inputs & Outputs side by side */} + + + Inputs ({inputs.length}): + {inputs.length === 0 ? ( + None yet + ) : ( + inputs.map((input, idx) => { + const inputTemplate = template?.inputs?.[input.inputIdentifier ?? '']; + return ( + + {' '}• {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`} + {input.roleIdentifier && ` (${input.roleIdentifier})`} + + ); + }) + )} + + + + Outputs ({outputs.length}): + {outputs.length === 0 ? ( + None yet + ) : ( + outputs.map((output, idx) => { + const outputTemplate = template?.outputs?.[output.outputIdentifier ?? '']; + return ( + + {' '}• {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`} + {output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`} + + ); + }) + )} + + + + {/* Variables */} + + Variables ({variables.length}): + {variables.length === 0 ? ( + None set + ) : ( + variables.map((variable, idx) => { + const varTemplate = template?.variables?.[variable.variableIdentifier]; + const displayValue = typeof variable.value === 'bigint' + ? variable.value.toString() + : String(variable.value); + return ( + + {' '}• {varTemplate?.name ?? variable.variableIdentifier}: {displayValue} + + ); + }) + )} + + + {/* Navigation hint */} + + Enter: Continue • Esc: Cancel + + + ); +} diff --git a/src/tui/screens/invitations/invitation-import/steps/ReviewStep.tsx b/src/tui/screens/invitations/invitation-import/steps/ReviewStep.tsx new file mode 100644 index 0000000..4382c29 --- /dev/null +++ b/src/tui/screens/invitations/invitation-import/steps/ReviewStep.tsx @@ -0,0 +1,113 @@ +/** + * ReviewStep — final step that summarizes the import and executes it. + * + * Displays the accumulated selections (role, inputs, amounts) and on confirmation: + * 1. Adds inputs (with the selected role identifier) to the invitation. + * 2. Optionally adds a change output if the change exceeds the dust threshold. + * 3. Calls `onComplete()` to signal the flow is finished. + */ + +import React, { useState, useCallback } from 'react'; +import { Box, Text, useInput } from 'ink'; +import { colors, formatSatoshis } from '../../../../theme.js'; +import type { ReviewStepProps, SelectableUTXO } from '../types.js'; + +/** Default fee estimate in satoshis. */ +const DEFAULT_FEE = 500n; + +/** Dust threshold — outputs below this are unspendable. */ +const DUST_THRESHOLD = 546n; + +export function ReviewStep({ + invitation, + template, + selectedRole, + selectedInputs, + requiredAmount, + changeAmount, + appService, + onComplete, + onCancel, + isActive, +}: ReviewStepProps): React.ReactElement { + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + const fee = DEFAULT_FEE; + const action = template?.actions?.[invitation.data.actionIdentifier]; + + // Compute totals from selected inputs + const totalSelected = selectedInputs.reduce((sum, u) => sum + u.valueSatoshis, 0n); + + /** + * Execute the import: add inputs (with role) and optional change output. + */ + const submit = useCallback(async () => { + setIsSubmitting(true); + setError(null); + + try { + onComplete(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setIsSubmitting(false); + } + }, [invitation, selectedRole, selectedInputs, onComplete]); + + // Keyboard handling + useInput((_input, key) => { + if (!isActive || isSubmitting) return; + + if (key.return) { + submit(); + } else if (key.escape) { + onCancel(); + } + }, { isActive }); + + // Resolve role display name + const roleInfoRaw = template?.roles?.[selectedRole]; + const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null; + + return ( + + Review Import + + {/* Template & action */} + + Template: {template?.name ?? invitation.data.templateIdentifier} + Action: {action?.name ?? invitation.data.actionIdentifier} + Role: {roleInfo?.name ?? selectedRole} + + + {/* Funding summary */} + + Funding: + • UTXOs: {selectedInputs.length} + • Total: {formatSatoshis(totalSelected)} + • Required: {formatSatoshis(requiredAmount)} + • Fee: {formatSatoshis(fee)} + {changeAmount >= DUST_THRESHOLD && ( + • Change: {formatSatoshis(changeAmount)} + )} + + + {/* Error display */} + {error && ( + + Error: {error} + + )} + + {/* Status / hint */} + + {isSubmitting ? ( + Submitting... + ) : ( + Enter: Confirm & Import • Esc: Cancel + )} + + + ); +} diff --git a/src/tui/screens/invitations/invitation-import/steps/RoleSelectStep.tsx b/src/tui/screens/invitations/invitation-import/steps/RoleSelectStep.tsx new file mode 100644 index 0000000..815bfb8 --- /dev/null +++ b/src/tui/screens/invitations/invitation-import/steps/RoleSelectStep.tsx @@ -0,0 +1,88 @@ +/** + * RoleSelectStep — lets the user choose which role to take in the invitation. + * + * Displays available roles with their template-level and action-level descriptions. + * Arrow keys to navigate, Enter to select, Esc to cancel. + */ + +import React, { useState } from 'react'; +import { Box, Text, useInput } from 'ink'; +import { colors } from '../../../../theme.js'; +import type { RoleSelectStepProps } from '../types.js'; + +export function RoleSelectStep({ + invitation, + template, + availableRoles, + onComplete, + onCancel, + isActive, +}: RoleSelectStepProps): React.ReactElement { + const [selectedIndex, setSelectedIndex] = useState(0); + + useInput((input, key) => { + if (!isActive) return; + + if (key.upArrow || input === 'k') { + setSelectedIndex(prev => Math.max(0, prev - 1)); + } else if (key.downArrow || input === 'j') { + setSelectedIndex(prev => Math.min(availableRoles.length - 1, prev + 1)); + } else if (key.return) { + const role = availableRoles[selectedIndex]; + if (role) onComplete(role); + } else if (key.escape) { + onCancel(); + } + }, { isActive }); + + const action = template?.actions?.[invitation.data.actionIdentifier]; + + return ( + + {/* Context header */} + + Template: {template?.name ?? 'Unknown'} + Action: {action?.name ?? invitation.data.actionIdentifier} + + + {/* Role list */} + + Available Roles: + + {availableRoles.length === 0 ? ( + No roles available (you may have already joined) + ) : ( + availableRoles.map((role, index) => { + const roleInfoRaw = template?.roles?.[role]; + const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null; + const actionRoleRaw = action?.roles?.[role]; + const actionRole = actionRoleRaw && typeof actionRoleRaw === 'object' ? actionRoleRaw : null; + const isFocused = index === selectedIndex; + + return ( + + + {isFocused ? '▸ ' : ' '} + {roleInfo?.name ?? role} + + {(roleInfo?.description || actionRole?.description) && ( + + {' '}{actionRole?.description ?? roleInfo?.description} + + )} + + ); + }) + )} + + + {/* Navigation hint */} + + ↑↓: Select role • Enter: Accept • Esc: Cancel + + + ); +} diff --git a/src/tui/screens/invitations/invitation-import/types.ts b/src/tui/screens/invitations/invitation-import/types.ts new file mode 100644 index 0000000..4f8c7e8 --- /dev/null +++ b/src/tui/screens/invitations/invitation-import/types.ts @@ -0,0 +1,122 @@ +/** + * Shared types for the invitation import flow. + * + * Each step in the flow receives only what it needs via props (dependency injection). + * The flow controller (`InvitationImportFlow`) accumulates data and passes it forward. + */ + +import type { Invitation } from '../../../../services/invitation.js'; +import type { AppService } from '../../../../services/app.js'; +import type { XOTemplate } from '@xo-cash/types'; + +// ── Step definitions ───────────────────────────────────────────────────────── + +/** Identifies each step in the import flow. */ +export type ImportStepType = 'fetch' | 'preview' | 'role-select' | 'inputs-select' | 'review'; + +/** A single step descriptor used by the flow controller and step indicator. */ +export interface ImportStep { + name: string; + type: ImportStepType; +} + +/** The ordered list of steps in the import flow. */ +export const IMPORT_STEPS: ImportStep[] = [ + { name: 'Fetch', type: 'fetch' }, + { name: 'Preview', type: 'preview' }, + { name: 'Select Role', type: 'role-select' }, + { name: 'Select Inputs', type: 'inputs-select' }, + { name: 'Review', type: 'review' }, +]; + +// ── Display mode ───────────────────────────────────────────────────────────── + +/** Controls whether the import flow renders as a dialog overlay or a full screen. */ +export type ImportFlowMode = 'dialog' | 'screen'; + +// ── UTXO selection ─────────────────────────────────────────────────────────── + +/** A UTXO that the user can toggle on/off during the inputs step. */ +export interface SelectableUTXO { + outpointTransactionHash: string; + outpointIndex: number; + valueSatoshis: bigint; + lockingBytecode?: string; + selected: boolean; +} + +// ── Step props ─────────────────────────────────────────────────────────────── +// Each step receives exactly the data and callbacks it needs. + +/** Props for FetchInvitationStep — loads the invitation from an ID. */ +export interface FetchStepProps { + invitationId: string; + appService: AppService; + onComplete: (invitation: Invitation, template: XOTemplate | null) => void; + onCancel: () => void; + isActive: boolean; +} + +/** Props for PreviewInvitationStep — displays invitation state. */ +export interface PreviewStepProps { + invitation: Invitation; + template: XOTemplate | null; + onComplete: () => void; + onCancel: () => void; + isActive: boolean; +} + +/** Props for RoleSelectStep — lets user pick a role. */ +export interface RoleSelectStepProps { + invitation: Invitation; + template: XOTemplate | null; + availableRoles: string[]; + onComplete: (selectedRole: string) => void; + onCancel: () => void; + isActive: boolean; +} + +/** Props for InputsSelectStep — lets user pick UTXOs to fund the invitation. */ +export interface InputsSelectStepProps { + invitation: Invitation; + template: XOTemplate | null; + selectedRole: string; + appService: AppService; + onComplete: (inputs: SelectableUTXO[]) => void; + onCancel: () => void; + isActive: boolean; +} + +/** Props for ReviewStep — summarizes and executes the import. */ +export interface ReviewStepProps { + invitation: Invitation; + template: XOTemplate | null; + selectedRole: string; + selectedInputs: SelectableUTXO[]; + changeAmount: bigint; + requiredAmount: bigint; + appService: AppService; + onComplete: () => void; + onCancel: () => void; + isActive: boolean; +} + +// ── Flow controller props ──────────────────────────────────────────────────── + +/** Props for the top-level InvitationImportFlow component. */ +export interface ImportFlowProps { + /** The invitation ID to import (already entered by the user in InvitationScreen). */ + invitationId: string; + /** Whether to render as a dialog overlay or a full screen. */ + mode: ImportFlowMode; + /** The application service — injected, not pulled from context. */ + appService: AppService; + /** Called when the flow completes or is cancelled. */ + onClose: () => void; + /** Display an error message to the user. */ + showError: (message: string) => void; + /** Display an info message to the user. */ + showInfo: (message: string) => void; + /** Update the global status bar. */ + setStatus: (message: string) => void; +}