Large amount of changes. Successfully broadcasts txs

This commit is contained in:
2026-03-08 15:53:50 +00:00
parent 66e9918e04
commit 9ef1720e1f
19 changed files with 1374 additions and 352 deletions

View File

@@ -55,6 +55,8 @@ export class AppService extends EventEmitter<AppEventMap> {
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<AppEventMap> {
// 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) {

View File

@@ -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 <variableName> 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<string, XOInvitationVariableValue>);
const description = compileCashAssemblyString(transaction.description, formattedVariables);
return transaction.description
// Replace <variableName> with actual values
.replace(/<([^>]+)>/g, (match, varId) => {
const variable = committedVariables.find(v => v.variableIdentifier === varId);
return variable ? String(variable.value) : match;
})
// Remove the $() wrapper around variable expressions
.replace(/\$\(([^)]+)\)/g, '$1');
return description;
}
}

View File

@@ -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<InvitationEventMap> {
// 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<InvitationEventMap> {
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<string> {
return this.engine.generateLockingBytecode(this.data.templateIdentifier, outputIdentifier, roleIdentifier);
}
async addOutputs(outputs: XOInvitationOutput[]): Promise<void> {
// Add the outputs to the invitation
await this.append({ outputs });
@@ -410,4 +417,89 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
async getLockingBytecode(outputIdentifier: string, roleIdentifier?: string): Promise<string> {
return this.engine.generateLockingBytecode(this.data.templateIdentifier, outputIdentifier, roleIdentifier);
}
}
/**
* 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<bigint> {
// 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<string, XOInvitationVariableValue>);
// 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;
}
}

View File

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

View File

@@ -24,7 +24,7 @@ interface DialogWrapperProps {
}
function DialogWrapper({
export function DialogWrapper({
title,
borderColor = colors.primary,
children,

View File

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

View File

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

View File

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

View File

@@ -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<Invitation | null>(null);
const [availableRoles, setAvailableRoles] = useState<string[]>([]);
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0);
const [importTemplate, setImportTemplate] = useState<XOTemplate | null>(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<string | null>(null);
// Template cache for displaying invitation list with template names
// ── Template cache ───────────────────────────────────────────────────────
const [templateCache, setTemplateCache] = useState<Map<string, XOTemplate>>(new Map());
// Selected invitation template for details view
const [selectedTemplate, setSelectedTemplate] = useState<XOTemplate | null>(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<string>, index: number) => {
const handleActionItemActivate = useCallback((item: ListItemData<string>, _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 (
<Text
@@ -608,7 +458,6 @@ export function InvitationScreen(): React.ReactElement {
);
}
// Invitation item
const inv = item.value;
if (!inv) return null;
@@ -628,9 +477,6 @@ export function InvitationScreen(): React.ReactElement {
);
}, [templateCache]);
/**
* Render detailed invitation information.
*/
const renderDetails = () => {
if (!selectedInvitation) {
return <Text color={colors.textMuted}>Select an invitation to view details</Text>;
@@ -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 (
<Box flexDirection="column">
{/* Row 1: Type, Description, Status */}
{/* Type & Status */}
<Box flexDirection="row" marginBottom={1}>
<Box width="50%">
<Box flexDirection="column">
@@ -675,7 +520,7 @@ export function InvitationScreen(): React.ReactElement {
</Box>
</Box>
{/* Row 2: Your Role */}
{/* Your Role */}
{userRole && (
<Box marginBottom={1} flexDirection="column">
<Text color={colors.primary} bold>Your Role: </Text>
@@ -686,9 +531,8 @@ export function InvitationScreen(): React.ReactElement {
</Box>
)}
{/* Row 3: Inputs & Outputs side by side */}
{/* Inputs & Outputs */}
<Box flexDirection="row" marginBottom={1}>
{/* Inputs */}
<Box width="50%" flexDirection="column">
<Text color={colors.primary} bold>Inputs ({inputs.length}):</Text>
{inputs.length === 0 ? (
@@ -698,8 +542,8 @@ export function InvitationScreen(): React.ReactElement {
const isUserInput = input.entityIdentifier === userEntityId;
const inputTemplate = selectedTemplate?.inputs?.[input.inputIdentifier ?? ''];
return (
<Text
key={`input-${idx}`}
<Text
key={`input-${idx}`}
color={isUserInput ? colors.success : colors.text}
>
{' '}{isUserInput ? '• ' : '○ '}
@@ -711,7 +555,6 @@ export function InvitationScreen(): React.ReactElement {
)}
</Box>
{/* Outputs */}
<Box width="50%" flexDirection="column">
<Text color={colors.primary} bold>Outputs ({outputs.length}):</Text>
{outputs.length === 0 ? (
@@ -721,8 +564,8 @@ export function InvitationScreen(): React.ReactElement {
const isUserOutput = output.entityIdentifier === userEntityId;
const outputTemplate = selectedTemplate?.outputs?.[output.outputIdentifier ?? ''];
return (
<Text
key={`output-${idx}`}
<Text
key={`output-${idx}`}
color={isUserOutput ? colors.success : colors.text}
>
{' '}{isUserOutput ? '• ' : '○ '}
@@ -735,7 +578,7 @@ export function InvitationScreen(): React.ReactElement {
</Box>
</Box>
{/* Row 4: Variables */}
{/* Variables */}
<Box flexDirection="column">
<Text color={colors.primary} bold>Variables ({variables.length}):</Text>
{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 (
<Text
key={`var-${idx}`}
<Text
key={`var-${idx}`}
color={isUserVariable ? colors.success : colors.text}
>
{' '}{isUserVariable ? '• ' : '○ '}
@@ -763,7 +606,6 @@ export function InvitationScreen(): React.ReactElement {
)}
</Box>
{/* Shortcuts */}
<Box marginTop={1}>
<Text color={colors.textMuted} dimColor>c: Copy ID</Text>
</Box>
@@ -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 (
<Box
position="absolute"
flexDirection="column"
alignItems="center"
justifyContent="center"
width="100%"
height="100%"
>
<Box
flexDirection="column"
borderStyle="double"
borderColor={colors.primary}
backgroundColor="black"
paddingX={2}
paddingY={1}
width={70}
>
<Text color={colors.primary} bold>Import Invitation - Select Role</Text>
{/* Invitation Details */}
<Box marginY={1} flexDirection="column">
<Text color={colors.text}>Template: {importTemplate?.name ?? 'Unknown'}</Text>
{importTemplate?.description && (
<Text color={colors.textMuted} dimColor>{importTemplate.description}</Text>
)}
<Text color={colors.text}>Action: {action?.name ?? importingInvitation.data.actionIdentifier}</Text>
{action?.description && (
<Text color={colors.textMuted} dimColor>{action.description}</Text>
)}
</Box>
{/* Role Selection */}
<Box marginY={1} flexDirection="column">
<Text color={colors.primary} bold>Available Roles:</Text>
{availableRoles.length === 0 ? (
<Text color={colors.warning}>No roles available (you may have already joined)</Text>
) : (
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 (
<Box key={role} flexDirection="column">
<Text
color={index === selectedRoleIndex ? colors.focus : colors.text}
bold={index === selectedRoleIndex}
>
{index === selectedRoleIndex ? '▸ ' : ' '}
{roleInfo?.name ?? role}
</Text>
{(roleInfo?.description || actionRole?.description) && (
<Text color={colors.textMuted} dimColor>
{' '}{actionRole?.description ?? roleInfo?.description}
</Text>
)}
</Box>
);
})
)}
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted}>: Select role Enter: Accept Esc: Decline</Text>
</Box>
</Box>
</Box>
);
};
// ── Main render ──────────────────────────────────────────────────────────
return (
<Box flexDirection="column" flexGrow={1}>
@@ -857,7 +622,7 @@ export function InvitationScreen(): React.ReactElement {
<Text color={colors.primary} bold>{logoSmall} - Invitations</Text>
</Box>
{/* Main content - Top row: List + Actions */}
{/* Top row: List + Actions */}
<Box flexDirection="row" marginTop={1} height={12}>
{/* Left column: Invitation list */}
<Box flexDirection="column" width="70%" paddingRight={1}>
@@ -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"
/>
</Box>
</Box>
</Box>
{/* Bottom row: Details (full width) */}
{/* Bottom row: Details */}
<Box flexDirection="column" marginTop={1} flexGrow={1}>
<Box
borderStyle="single"
@@ -928,8 +693,8 @@ export function InvitationScreen(): React.ReactElement {
</Text>
</Box>
{/* Import ID dialog (Stage 1) */}
{importStage === 'id' && (
{/* Import ID dialog */}
{showIdDialog && (
<Box
position="absolute"
flexDirection="column"
@@ -943,14 +708,24 @@ export function InvitationScreen(): React.ReactElement {
prompt="Enter Invitation ID:"
placeholder="Paste invitation ID..."
onSubmit={handleImportIdSubmit}
onCancel={() => setImportStage(null)}
onCancel={() => setShowIdDialog(false)}
isActive={true}
/>
</Box>
)}
{/* Role Selection dialog (Stage 2) */}
{importStage === 'role-select' && renderRoleSelectionDialog()}
{/* Multi-step import flow */}
{importingId && appService && (
<InvitationImportFlow
invitationId={importingId}
mode="dialog"
appService={appService}
onClose={handleImportFlowClose}
showError={showError}
showInfo={showInfo}
setStatus={setStatus}
/>
)}
</Box>
);
}

View File

@@ -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<Invitation | null>(null);
const [buildableInvitation, setBuildableInvitation] = useState<InvitationBuilder | null>(null);
const [template, setTemplate] = useState<XOTemplate | null>(null);
const [availableRoles, setAvailableRoles] = useState<string[]>([]);
const [selectedRole, setSelectedRole] = useState<string | null>(null);
const [selectedInputs, setSelectedInputs] = useState<SelectableUTXO[]>([]);
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 (
<FetchInvitationStep
invitationId={invitationId}
appService={appService}
onComplete={handleFetchComplete}
onCancel={handleCancel}
isActive={true}
/>
);
case 'preview':
if (!invitation) return null;
return (
<PreviewInvitationStep
invitation={invitation}
template={template}
onComplete={handlePreviewComplete}
onCancel={handleCancel}
isActive={true}
/>
);
case 'role-select':
if (!invitation) return null;
return (
<RoleSelectStep
invitation={invitation}
template={template}
availableRoles={availableRoles}
onComplete={handleRoleComplete}
onCancel={handleCancel}
isActive={true}
/>
);
case 'inputs-select':
if (!invitation || !selectedRole) return null;
return (
<InputsSelectStep
invitation={invitation}
template={template}
selectedRole={selectedRole}
appService={appService}
onComplete={handleInputsComplete}
onCancel={handleCancel}
isActive={true}
/>
);
case 'review':
if (!invitation || !selectedRole) return null;
return (
<ReviewStep
invitation={invitation}
template={template}
selectedRole={selectedRole}
selectedInputs={selectedInputs}
changeAmount={changeAmount}
requiredAmount={requiredAmount}
appService={appService}
onComplete={handleReviewComplete}
onCancel={handleCancel}
isActive={true}
/>
);
default:
return null;
}
};
// ── Step indicator data ──────────────────────────────────────────────────
const indicatorSteps: Step[] = IMPORT_STEPS.map(s => ({ label: s.name }));
// ── Layout: dialog mode ──────────────────────────────────────────────────
if (mode === 'dialog') {
return (
<Box
position="absolute"
flexDirection="column"
alignItems="center"
justifyContent="center"
width="100%"
height="100%"
>
<DialogWrapper title="Import Invitation" borderColor={colors.primary}>
{/* Step indicator (compact) */}
<Box marginTop={1}>
<StepIndicator steps={indicatorSteps} currentStep={currentStep} />
</Box>
{/* Step content */}
<Box marginTop={1} flexDirection="column">
{renderStep()}
</Box>
</DialogWrapper>
</Box>
);
}
// ── Layout: screen mode ──────────────────────────────────────────────────
return (
<Box flexDirection="column" flexGrow={1}>
{/* Header */}
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1} flexDirection="column">
<Text color={colors.primary} bold>{logoSmall} - Import Invitation</Text>
<Text color={colors.textMuted}>
{template?.name ?? 'Loading...'}
{selectedRole ? ` (as ${selectedRole})` : ''}
</Text>
</Box>
{/* Step indicator */}
<Box marginTop={1} paddingX={1}>
<StepIndicator steps={indicatorSteps} currentStep={currentStep} />
</Box>
{/* Step content */}
<Box
borderStyle="single"
borderColor={colors.primary}
flexDirection="column"
paddingX={1}
paddingY={1}
marginTop={1}
marginX={1}
flexGrow={1}
>
<Text color={colors.primary} bold>
{IMPORT_STEPS[currentStep]?.name ?? 'Unknown'} ({currentStep + 1}/{IMPORT_STEPS.length})
</Text>
<Box marginTop={1} flexDirection="column">
{renderStep()}
</Box>
</Box>
{/* Help text */}
<Box marginTop={1} marginX={1}>
<Text color={colors.textMuted} dimColor>
Esc: Cancel import
</Text>
</Box>
</Box>
);
}

View File

@@ -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<string | null>(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 (
<Box flexDirection="column">
{status === 'loading' && (
<Box flexDirection="column">
<Text color={colors.info}>Fetching invitation...</Text>
<Text color={colors.textMuted} dimColor>ID: {invitationId}</Text>
</Box>
)}
{status === 'error' && (
<Box flexDirection="column">
<Text color={colors.error} bold>Failed to fetch invitation</Text>
<Text color={colors.textMuted} wrap="wrap">{errorMessage}</Text>
<Box marginTop={1}>
<Text color={colors.textMuted}>Press Enter to retry or Esc to cancel</Text>
</Box>
</Box>
)}
</Box>
);
}

View File

@@ -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<SelectableUTXO[]>([]);
const [focusedIndex, setFocusedIndex] = useState(0);
const [requiredAmount, setRequiredAmount] = useState(0n);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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<bigint> => {
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<string>();
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 (
<Box flexDirection="column">
<Text color={colors.info}>Finding suitable UTXOs...</Text>
</Box>
);
}
// Error state
if (error) {
return (
<Box flexDirection="column">
<Text color={colors.error} bold>Failed to load UTXOs</Text>
<Text color={colors.textMuted}>{error}</Text>
<Box marginTop={1}>
<Text color={colors.textMuted}>Esc: Cancel</Text>
</Box>
</Box>
);
}
// No UTXOs found
if (utxos.length === 0) {
return (
<Box flexDirection="column">
<Text color={colors.warning}>No suitable UTXOs found. Make sure your wallet has funds.</Text>
<Box marginTop={1}>
<Text color={colors.textMuted}>Esc: Cancel</Text>
</Box>
</Box>
);
}
return (
<Box flexDirection="column">
{/* Summary bar */}
<Box flexDirection="row" marginBottom={1}>
<Text color={colors.primary} bold>Required: </Text>
<Text color={colors.text}>{formatSatoshis(requiredAmount + fee)}</Text>
<Text color={colors.textMuted}> (amount {formatSatoshis(requiredAmount)} + fee {formatSatoshis(fee)})</Text>
</Box>
<Box flexDirection="row" marginBottom={1}>
<Text color={colors.primary} bold>Selected: </Text>
<Text color={hasEnough ? colors.success : colors.error}>{formatSatoshis(selectedAmount)}</Text>
{hasEnough && changeAmount >= DUST_THRESHOLD && (
<Text color={colors.textMuted}> (change: {formatSatoshis(changeAmount)})</Text>
)}
{!hasEnough && (
<Text color={colors.error}> need {formatSatoshis(requiredAmount + fee - selectedAmount)} more</Text>
)}
</Box>
{/* UTXO list */}
<Text color={colors.primary} bold>UTXOs ({utxos.length}):</Text>
{utxos.map((utxo, index) => {
const isFocused = index === focusedIndex;
const checkMark = utxo.selected ? '☑' : '☐';
const txShort = utxo.outpointTransactionHash.slice(0, 8);
return (
<Text
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
color={isFocused ? colors.focus : utxo.selected ? colors.success : colors.text}
bold={isFocused}
>
{isFocused ? '▸ ' : ' '}{checkMark} {formatSatoshis(utxo.valueSatoshis)} ({txShort}:{utxo.outpointIndex})
</Text>
);
})}
{/* Navigation hint */}
<Box marginTop={1}>
<Text color={colors.textMuted}>
: Navigate Space: Toggle a: All n: None Tab: Confirm Esc: Cancel
</Text>
</Box>
</Box>
);
}

View File

@@ -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<string>();
for (const commit of invitation.data.commits ?? []) {
for (const input of commit.data?.inputs ?? []) {
if (input.roleIdentifier) filledRoles.add(input.roleIdentifier);
}
}
return (
<Box flexDirection="column">
{/* Template & action info */}
<Box flexDirection="column" marginBottom={1}>
<Text color={colors.primary} bold>Template: </Text>
<Text color={colors.text}>{template?.name ?? invitation.data.templateIdentifier}</Text>
{template?.description && (
<Text color={colors.textMuted} dimColor>{template.description}</Text>
)}
</Box>
<Box flexDirection="row" marginBottom={1}>
<Box width="50%" flexDirection="column">
<Text color={colors.primary} bold>Action: </Text>
<Text color={colors.text}>{action?.name ?? invitation.data.actionIdentifier}</Text>
{action?.description && (
<Text color={colors.textMuted} dimColor>{action.description}</Text>
)}
</Box>
<Box width="50%" flexDirection="column">
<Text color={colors.primary} bold>Status: </Text>
<Text color={stateColor(state)}>{state}</Text>
</Box>
</Box>
{/* Roles already filled */}
<Box flexDirection="column" marginBottom={1}>
<Text color={colors.primary} bold>Roles Filled ({filledRoles.size}):</Text>
{filledRoles.size === 0 ? (
<Text color={colors.textMuted}> None yet</Text>
) : (
Array.from(filledRoles).map(role => {
const roleInfoRaw = template?.roles?.[role];
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
return (
<Text key={role} color={colors.text}> {roleInfo?.name ?? role}</Text>
);
})
)}
</Box>
{/* Inputs & Outputs side by side */}
<Box flexDirection="row" marginBottom={1}>
<Box width="50%" flexDirection="column">
<Text color={colors.primary} bold>Inputs ({inputs.length}):</Text>
{inputs.length === 0 ? (
<Text color={colors.textMuted}> None yet</Text>
) : (
inputs.map((input, idx) => {
const inputTemplate = template?.inputs?.[input.inputIdentifier ?? ''];
return (
<Text key={`input-${idx}`} color={colors.text}>
{' '} {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
{input.roleIdentifier && ` (${input.roleIdentifier})`}
</Text>
);
})
)}
</Box>
<Box width="50%" flexDirection="column">
<Text color={colors.primary} bold>Outputs ({outputs.length}):</Text>
{outputs.length === 0 ? (
<Text color={colors.textMuted}> None yet</Text>
) : (
outputs.map((output, idx) => {
const outputTemplate = template?.outputs?.[output.outputIdentifier ?? ''];
return (
<Text key={`output-${idx}`} color={colors.text}>
{' '} {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
</Text>
);
})
)}
</Box>
</Box>
{/* Variables */}
<Box flexDirection="column" marginBottom={1}>
<Text color={colors.primary} bold>Variables ({variables.length}):</Text>
{variables.length === 0 ? (
<Text color={colors.textMuted}> None set</Text>
) : (
variables.map((variable, idx) => {
const varTemplate = template?.variables?.[variable.variableIdentifier];
const displayValue = typeof variable.value === 'bigint'
? variable.value.toString()
: String(variable.value);
return (
<Text key={`var-${idx}`} color={colors.text}>
{' '} {varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
</Text>
);
})
)}
</Box>
{/* Navigation hint */}
<Box marginTop={1}>
<Text color={colors.textMuted}>Enter: Continue Esc: Cancel</Text>
</Box>
</Box>
);
}

View File

@@ -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<string | null>(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 (
<Box flexDirection="column">
<Text color={colors.primary} bold>Review Import</Text>
{/* Template & action */}
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Template: {template?.name ?? invitation.data.templateIdentifier}</Text>
<Text color={colors.text}>Action: {action?.name ?? invitation.data.actionIdentifier}</Text>
<Text color={colors.text}>Role: {roleInfo?.name ?? selectedRole}</Text>
</Box>
{/* Funding summary */}
<Box marginTop={1} flexDirection="column">
<Text color={colors.primary} bold>Funding:</Text>
<Text color={colors.text}> UTXOs: {selectedInputs.length}</Text>
<Text color={colors.text}> Total: {formatSatoshis(totalSelected)}</Text>
<Text color={colors.text}> Required: {formatSatoshis(requiredAmount)}</Text>
<Text color={colors.text}> Fee: {formatSatoshis(fee)}</Text>
{changeAmount >= DUST_THRESHOLD && (
<Text color={colors.text}> Change: {formatSatoshis(changeAmount)}</Text>
)}
</Box>
{/* Error display */}
{error && (
<Box marginTop={1}>
<Text color={colors.error} bold>Error: {error}</Text>
</Box>
)}
{/* Status / hint */}
<Box marginTop={1}>
{isSubmitting ? (
<Text color={colors.info}>Submitting...</Text>
) : (
<Text color={colors.textMuted}>Enter: Confirm & Import Esc: Cancel</Text>
)}
</Box>
</Box>
);
}

View File

@@ -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 (
<Box flexDirection="column">
{/* Context header */}
<Box marginBottom={1} flexDirection="column">
<Text color={colors.text}>Template: {template?.name ?? 'Unknown'}</Text>
<Text color={colors.text}>Action: {action?.name ?? invitation.data.actionIdentifier}</Text>
</Box>
{/* Role list */}
<Box flexDirection="column">
<Text color={colors.primary} bold>Available Roles:</Text>
{availableRoles.length === 0 ? (
<Text color={colors.warning}>No roles available (you may have already joined)</Text>
) : (
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 (
<Box key={role} flexDirection="column">
<Text
color={isFocused ? colors.focus : colors.text}
bold={isFocused}
>
{isFocused ? '▸ ' : ' '}
{roleInfo?.name ?? role}
</Text>
{(roleInfo?.description || actionRole?.description) && (
<Text color={colors.textMuted} dimColor>
{' '}{actionRole?.description ?? roleInfo?.description}
</Text>
)}
</Box>
);
})
)}
</Box>
{/* Navigation hint */}
<Box marginTop={1}>
<Text color={colors.textMuted}>: Select role Enter: Accept Esc: Cancel</Text>
</Box>
</Box>
);
}

View File

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