diff --git a/.gitignore b/.gitignore index c0eec1e..68e9920 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,8 @@ __sysdb__.sqlite Electrum.sqlite XO.sqlite node_modules/ -dist/ \ No newline at end of file +dist/ +*.db +*.db-shm +*.db-wal +*.sqlite \ No newline at end of file diff --git a/package.json b/package.json index 19a0714..13a4a89 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "main": "dist/index.js", "type": "module", "scripts": { - "dev": "tsx src/index.ts", + "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" diff --git a/src/app.ts b/src/app.ts index 9244b79..f7c548d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,72 +1,43 @@ /** * Application bootstrap and lifecycle management. - * Coordinates initialization of all CLI components. + * Simplified to render TUI immediately and let it handle AppService creation. */ import React from 'react'; import { render, type Instance } from 'ink'; import { App as AppComponent } from './tui/App.js'; -import { WalletController } from './controllers/wallet-controller.js'; -import { InvitationController } from './controllers/invitation-controller.js'; -import { SyncClient } from './services/sync-client.js'; /** * Configuration options for the CLI application. */ export interface AppConfig { /** URL of the sync server (default: http://localhost:3000) */ - syncServerUrl?: string; + syncServerUrl: string; /** Database path for wallet state storage */ - databasePath?: string; + databasePath: string; /** Database filename */ - databaseFilename?: string; + databaseFilename: string; + /** Path for invitation storage database */ + invitationStoragePath: string; } /** - * Main application class that orchestrates all CLI components. + * Main application class that orchestrates the CLI. + * Renders the TUI immediately and passes config for later AppService creation. */ export class App { /** Ink render instance */ private inkInstance: Instance | null = null; - /** Wallet controller for engine operations */ - private walletController: WalletController; - - /** Invitation controller for collaborative transactions */ - private invitationController: InvitationController; - - /** HTTP client for sync server communication */ - private syncClient: SyncClient; - /** Application configuration */ - private config: Required; + private config: AppConfig; /** * Creates a new App instance. * @param config - Application configuration options */ - private constructor(config: AppConfig = {}) { - // Set default configuration - this.config = { - syncServerUrl: config.syncServerUrl ?? 'http://localhost:3000', - databasePath: config.databasePath ?? './', - databaseFilename: config.databaseFilename ?? 'xo-wallet', - }; - - // Initialize sync client - this.syncClient = new SyncClient(this.config.syncServerUrl); - - // Initialize wallet controller (engine will be created when seed is provided) - this.walletController = new WalletController({ - databasePath: this.config.databasePath, - databaseFilename: this.config.databaseFilename, - }); - - // Initialize invitation controller - this.invitationController = new InvitationController( - this.walletController, - this.syncClient, - ); + private constructor(config: AppConfig) { + this.config = config; } /** @@ -74,22 +45,32 @@ export class App { * @param config - Application configuration options * @returns Running App instance */ - static async create(config: AppConfig = {}): Promise { - const app = new App(config); + static async create(config: Partial = {}): Promise { + // Set default configuration + const fullConfig: AppConfig = { + syncServerUrl: config.syncServerUrl ?? 'http://localhost:3000', + databasePath: config.databasePath ?? './', + databaseFilename: config.databaseFilename ?? 'xo-wallet.db', + invitationStoragePath: config.invitationStoragePath ?? './xo-invitations.db', + }; + + console.log('Full config:', fullConfig); + + const app = new App(fullConfig); await app.start(); return app; } /** * Starts the application. - * Renders the Ink-based TUI. + * Renders the Ink-based TUI immediately. */ async start(): Promise { - // Render the Ink app + // Render the Ink app with config + // TUI will handle AppService creation after seed input this.inkInstance = render( React.createElement(AppComponent, { - walletController: this.walletController, - invitationController: this.invitationController, + config: this.config, }) ); @@ -101,34 +82,10 @@ export class App { * Stops the application and cleans up resources. */ async stop(): Promise { - // Stop the wallet engine if running - await this.walletController.stop(); - // Unmount Ink app if (this.inkInstance) { this.inkInstance.unmount(); this.inkInstance = null; } } - - /** - * Gets the wallet controller for external access. - */ - getWalletController(): WalletController { - return this.walletController; - } - - /** - * Gets the invitation controller for external access. - */ - getInvitationController(): InvitationController { - return this.invitationController; - } - - /** - * Gets the sync client for external access. - */ - getSyncClient(): SyncClient { - return this.syncClient; - } } diff --git a/src/controllers/invitation-controller.ts b/src/controllers/invitation-controller.ts deleted file mode 100644 index 6d34b8b..0000000 --- a/src/controllers/invitation-controller.ts +++ /dev/null @@ -1,293 +0,0 @@ -/** - * Invitation Controller - High-level interface for invitation management. - * - * Provides a simplified API for the TUI to interact with invitations, - * wrapping the InvitationFlowManager and coordinating with the WalletController. - */ - -import { EventEmitter } from 'events'; -import type { XOInvitation } from '@xo-cash/types'; -import { InvitationFlowManager, type TrackedInvitation, type InvitationState } from '../services/invitation-flow.js'; -import type { WalletController } from './wallet-controller.js'; -import type { SyncClient } from '../services/sync-client.js'; - -/** - * Events emitted by the invitation controller. - */ -export interface InvitationControllerEvents { - 'invitation-created': (invitationId: string) => void; - 'invitation-updated': (invitationId: string) => void; - 'invitation-state-changed': (invitationId: string, state: InvitationState) => void; - 'error': (error: Error) => void; -} - -/** - * Controller for managing invitations in the TUI. - */ -export class InvitationController extends EventEmitter { - /** Flow manager for invitation lifecycle */ - private flowManager: InvitationFlowManager; - - /** Wallet controller reference */ - private walletController: WalletController; - - /** Sync client reference */ - private syncClient: SyncClient; - - /** - * Creates a new invitation controller. - * @param walletController - Wallet controller instance - * @param syncClient - Sync client instance - */ - constructor(walletController: WalletController, syncClient: SyncClient) { - super(); - - this.walletController = walletController; - this.syncClient = syncClient; - this.flowManager = new InvitationFlowManager(walletController, syncClient); - - // Forward events from flow manager - this.flowManager.on('invitation-created', (invitation: XOInvitation) => { - this.emit('invitation-created', invitation.invitationIdentifier); - }); - - this.flowManager.on('invitation-updated', (invitationId: string) => { - this.emit('invitation-updated', invitationId); - }); - - this.flowManager.on('invitation-state-changed', (invitationId: string, state: InvitationState) => { - this.emit('invitation-state-changed', invitationId, state); - }); - - this.flowManager.on('error', (_invitationId: string, error: Error) => { - this.emit('error', error); - }); - } - - // ============================================================================ - // Invitation Creation Flow - // ============================================================================ - - /** - * Creates a new invitation from a template action. - * @param templateIdentifier - Template ID - * @param actionIdentifier - Action ID - * @returns Created tracked invitation - */ - async createInvitation( - templateIdentifier: string, - actionIdentifier: string, - ): Promise { - return this.flowManager.createInvitation(templateIdentifier, actionIdentifier); - } - - /** - * Publishes an invitation to the sync server and starts listening for updates. - * @param invitationId - Invitation ID to publish - * @returns The invitation ID for sharing - */ - async publishAndSubscribe(invitationId: string): Promise { - // Publish to sync server - await this.flowManager.publishInvitation(invitationId); - - // Subscribe to SSE updates - await this.flowManager.subscribeToUpdates(invitationId); - - return invitationId; - } - - // ============================================================================ - // Invitation Import Flow - // ============================================================================ - - /** - * Imports an invitation by ID from the sync server. - * @param invitationId - Invitation ID to import - * @returns Imported tracked invitation - */ - async importInvitation(invitationId: string): Promise { - return this.flowManager.importInvitation(invitationId); - } - - /** - * Accepts an imported invitation (joins as participant). - * @param invitationId - Invitation ID to accept - * @returns Updated tracked invitation - */ - async acceptInvitation(invitationId: string): Promise { - return this.flowManager.acceptInvitation(invitationId); - } - - // ============================================================================ - // Invitation Data Operations - // ============================================================================ - - /** - * Appends inputs to an invitation. - * @param invitationId - Invitation ID - * @param inputs - Inputs to add - */ - async addInputs( - invitationId: string, - inputs: Array<{ - outpointTransactionHash: string; - outpointIndex: number; - sequenceNumber?: number; - inputIdentifier?: string; - }>, - ): Promise { - return this.flowManager.appendToInvitation(invitationId, { inputs }); - } - - /** - * Appends outputs to an invitation. - * @param invitationId - Invitation ID - * @param outputs - Outputs to add - */ - async addOutputs( - invitationId: string, - outputs: Array<{ - valueSatoshis?: bigint; - lockingBytecode?: Uint8Array; - outputIdentifier?: string; - roleIdentifier?: string; - }>, - ): Promise { - return this.flowManager.appendToInvitation(invitationId, { outputs }); - } - - /** - * Appends variables to an invitation. - * @param invitationId - Invitation ID - * @param variables - Variables to add - */ - async addVariables( - invitationId: string, - variables: Array<{ - variableIdentifier: string; - value: bigint | boolean | number | string; - roleIdentifier?: string; - }>, - ): Promise { - return this.flowManager.appendToInvitation(invitationId, { variables }); - } - - // ============================================================================ - // Signing & Broadcasting - // ============================================================================ - - /** - * Signs an invitation. - * @param invitationId - Invitation ID to sign - * @returns Updated tracked invitation - */ - async signInvitation(invitationId: string): Promise { - return this.flowManager.signInvitation(invitationId); - } - - /** - * Broadcasts the transaction for an invitation. - * @param invitationId - Invitation ID - * @returns Transaction hash - */ - async broadcastTransaction(invitationId: string): Promise { - return this.flowManager.broadcastTransaction(invitationId); - } - - // ============================================================================ - // Queries - // ============================================================================ - - /** - * Gets a tracked invitation by ID. - * @param invitationId - Invitation ID - * @returns Tracked invitation or undefined - */ - getInvitation(invitationId: string): TrackedInvitation | undefined { - return this.flowManager.get(invitationId); - } - - /** - * Gets all tracked invitations. - * @returns Array of tracked invitations - */ - getAllInvitations(): TrackedInvitation[] { - return this.flowManager.getAll(); - } - - /** - * Gets the invitation data. - * @param invitationId - Invitation ID - * @returns The XOInvitation or undefined - */ - getInvitationData(invitationId: string): XOInvitation | undefined { - return this.flowManager.get(invitationId)?.invitation; - } - - /** - * Gets the state of an invitation. - * @param invitationId - Invitation ID - * @returns Invitation state or undefined - */ - getInvitationState(invitationId: string): InvitationState | undefined { - return this.flowManager.get(invitationId)?.state; - } - - /** - * Gets available roles for an invitation. - * @param invitationId - Invitation ID - * @returns Array of available role identifiers - */ - async getAvailableRoles(invitationId: string): Promise { - const tracked = this.flowManager.get(invitationId); - if (!tracked) { - throw new Error(`Invitation not found: ${invitationId}`); - } - return this.walletController.getAvailableRoles(tracked.invitation); - } - - /** - * Gets missing requirements for an invitation. - * @param invitationId - Invitation ID - * @returns Missing requirements - */ - async getMissingRequirements(invitationId: string) { - const tracked = this.flowManager.get(invitationId); - if (!tracked) { - throw new Error(`Invitation not found: ${invitationId}`); - } - return this.walletController.getMissingRequirements(tracked.invitation); - } - - /** - * Gets requirements for an invitation. - * @param invitationId - Invitation ID - * @returns Requirements - */ - async getRequirements(invitationId: string) { - const tracked = this.flowManager.get(invitationId); - if (!tracked) { - throw new Error(`Invitation not found: ${invitationId}`); - } - return this.walletController.getRequirements(tracked.invitation); - } - - // ============================================================================ - // Cleanup - // ============================================================================ - - /** - * Stops tracking an invitation. - * @param invitationId - Invitation ID to stop tracking - */ - stopTracking(invitationId: string): void { - this.flowManager.untrack(invitationId); - } - - /** - * Cleans up all resources. - */ - destroy(): void { - this.flowManager.destroy(); - } -} diff --git a/src/controllers/wallet-controller.ts b/src/controllers/wallet-controller.ts deleted file mode 100644 index 9de10fc..0000000 --- a/src/controllers/wallet-controller.ts +++ /dev/null @@ -1,408 +0,0 @@ -/** - * Wallet Controller - Orchestrates wallet operations via the XO Engine. - * - * Responsibilities: - * - Initializes Engine with user seed - * - Exposes wallet state queries (balances, UTXOs) - * - Delegates template/invitation operations to Engine - * - Emits state change events for UI updates - */ - -import { EventEmitter } from 'events'; -import { Engine } from '@xo-cash/engine'; -import type { XOInvitation, XOTemplate, XOTemplateStartingActions } from '@xo-cash/types'; -import type { UnspentOutputData, LockingBytecodeData } from '@xo-cash/state'; -import { p2pkhTemplate } from '@xo-cash/templates'; - -/** - * Configuration options for the wallet controller. - */ -export interface WalletControllerConfig { - /** Path for database storage */ - databasePath?: string; - /** Database filename */ - databaseFilename?: string; - /** Electrum application identifier */ - electrumApplicationIdentifier?: string; -} - -/** - * Balance information for display. - */ -export interface WalletBalance { - /** Total satoshis across all UTXOs */ - totalSatoshis: bigint; - /** Number of UTXOs */ - utxoCount: number; -} - -/** - * Events emitted by the wallet controller. - */ -export interface WalletControllerEvents { - 'initialized': () => void; - 'state-updated': () => void; - 'error': (error: Error) => void; -} - -/** - * Controller for wallet operations. - */ -export class WalletController extends EventEmitter { - /** The XO Engine instance */ - private engine: Engine | null = null; - - /** Controller configuration */ - private config: WalletControllerConfig; - - /** Whether the wallet is initialized */ - private initialized: boolean = false; - - /** - * Creates a new wallet controller. - * @param config - Controller configuration options - */ - constructor(config: WalletControllerConfig = {}) { - super(); - this.config = config; - } - - /** - * Checks if the wallet is initialized. - */ - isInitialized(): boolean { - return this.initialized && this.engine !== null; - } - - /** - * Initializes the wallet with a seed phrase. - * @param seed - BIP39 seed phrase - */ - async initialize(seed: string): Promise { - try { - // Create the engine with the provided seed - this.engine = await Engine.create(seed, { - databasePath: this.config.databasePath ?? './', - databaseFilename: this.config.databaseFilename ?? 'xo-wallet', - electrumApplicationIdentifier: this.config.electrumApplicationIdentifier ?? 'xo-wallet-cli', - }); - - // Import the default P2PKH template - await this.engine.importTemplate(p2pkhTemplate); - - // Set default locking parameters for P2PKH - await this.engine.setDefaultLockingParameters( - await this.getTemplateIdentifier(p2pkhTemplate), - 'receiveOutput', - 'receiver', - ); - - // Generate an initial receiving address - const templateId = await this.getTemplateIdentifier(p2pkhTemplate); - await this.engine.generateLockingBytecode(templateId, 'receiveOutput', 'receiver'); - - this.initialized = true; - this.emit('initialized'); - } catch (error) { - this.emit('error', error instanceof Error ? error : new Error(String(error))); - throw error; - } - } - - /** - * Gets the template identifier from a template. - * @param template - The XO template - * @returns The template identifier - */ - private async getTemplateIdentifier(template: XOTemplate): Promise { - // Import the utility to generate template identifier - const { generateTemplateIdentifier } = await import('@xo-cash/engine'); - return generateTemplateIdentifier(template); - } - - /** - * Stops the wallet engine and cleans up resources. - */ - async stop(): Promise { - if (this.engine) { - await this.engine.stop(); - this.engine = null; - this.initialized = false; - } - } - - /** - * Gets the engine instance. - * @throws Error if engine is not initialized - */ - getEngine(): Engine { - if (!this.engine) { - throw new Error('Wallet not initialized. Please enter your seed phrase first.'); - } - return this.engine; - } - - // ============================================================================ - // Balance & UTXO Operations - // ============================================================================ - - /** - * Gets the wallet balance. - * @returns Wallet balance information - */ - async getBalance(): Promise { - const engine = this.getEngine(); - const utxos = await engine.listUnspentOutputsData(); - - const totalSatoshis = utxos.reduce( - (sum, utxo) => sum + BigInt(utxo.valueSatoshis), - BigInt(0), - ); - - return { - totalSatoshis, - utxoCount: utxos.length, - }; - } - - /** - * Gets all unspent outputs. - * @returns Array of unspent output data - */ - async getUnspentOutputs(): Promise { - const engine = this.getEngine(); - return engine.listUnspentOutputsData(); - } - - /** - * Gets locking bytecodes for a template. - * @param templateIdentifier - Template identifier - * @returns Array of locking bytecode data - */ - async getLockingBytecodes(templateIdentifier: string): Promise { - const engine = this.getEngine(); - return engine.listLockingBytecodesForTemplate(templateIdentifier); - } - - // ============================================================================ - // Template Operations - // ============================================================================ - - /** - * Gets all imported templates. - * @returns Array of templates - */ - async getTemplates(): Promise { - const engine = this.getEngine(); - return engine.listImportedTemplates(); - } - - /** - * Gets a specific template by identifier. - * @param templateIdentifier - Template identifier - * @returns The template or undefined - */ - async getTemplate(templateIdentifier: string): Promise { - const engine = this.getEngine(); - return engine.getTemplate(templateIdentifier); - } - - /** - * Gets starting actions for a template. - * @param templateIdentifier - Template identifier - * @returns Starting actions - */ - async getStartingActions(templateIdentifier: string): Promise { - const engine = this.getEngine(); - return engine.listStartingActions(templateIdentifier); - } - - /** - * Imports a template into the wallet. - * @param template - Template to import (JSON or object) - */ - async importTemplate(template: unknown): Promise { - const engine = this.getEngine(); - await engine.importTemplate(template); - this.emit('state-updated'); - } - - /** - * Generates a new locking bytecode (receiving address). - * @param templateIdentifier - Template identifier - * @param outputIdentifier - Output identifier - * @param roleIdentifier - Role identifier - * @returns Generated locking bytecode as hex - */ - async generateLockingBytecode( - templateIdentifier: string, - outputIdentifier: string, - roleIdentifier?: string, - ): Promise { - const engine = this.getEngine(); - const lockingBytecode = await engine.generateLockingBytecode( - templateIdentifier, - outputIdentifier, - roleIdentifier, - ); - this.emit('state-updated'); - return lockingBytecode; - } - - // ============================================================================ - // Invitation Operations - // ============================================================================ - - /** - * Creates a new invitation. - * @param templateIdentifier - Template identifier - * @param actionIdentifier - Action identifier - * @returns Created invitation - */ - async createInvitation( - templateIdentifier: string, - actionIdentifier: string, - ): Promise { - const engine = this.getEngine(); - return engine.createInvitation({ - templateIdentifier, - actionIdentifier, - }); - } - - /** - * Accepts an invitation. - * @param invitation - Invitation to accept - * @returns Updated invitation - */ - async acceptInvitation(invitation: XOInvitation): Promise { - const engine = this.getEngine(); - return engine.acceptInvitation(invitation); - } - - /** - * Appends data to an invitation. - * @param invitation - Invitation to append to - * @param params - Data to append - * @returns Updated invitation - */ - async appendInvitation( - invitation: XOInvitation, - params: { - inputs?: Array<{ - outpointTransactionHash?: string; - outpointIndex?: number; - sequenceNumber?: number; - mergesWith?: { commitIdentifier: string; index: number }; - unlockingBytecode?: Uint8Array; - }>; - outputs?: Array<{ - valueSatoshis?: bigint; - lockingBytecode?: Uint8Array; - outputIdentifier?: string; - roleIdentifier?: string; - mergesWith?: { commitIdentifier: string; index: number }; - }>; - variables?: Array<{ - variableIdentifier: string; - value: bigint | boolean | number | string; - roleIdentifier?: string; - }>; - }, - ): Promise { - const engine = this.getEngine(); - // Cast through unknown to handle strict type checking from engine's AppendInvitationParameters - // The engine expects Uint8Array for outpointTransactionHash but we accept string for convenience - return engine.appendInvitation(invitation, params as unknown as Parameters[1]); - } - - /** - * Signs an invitation. - * @param invitation - Invitation to sign - * @returns Signed invitation - */ - async signInvitation(invitation: XOInvitation): Promise { - const engine = this.getEngine(); - return engine.signInvitation(invitation); - } - - /** - * Validates an invitation. - * @param invitation - Invitation to validate - * @returns Whether the invitation is valid - */ - async isInvitationValid(invitation: XOInvitation): Promise { - const engine = this.getEngine(); - return engine.isInvitationValid(invitation); - } - - /** - * Gets available roles for an invitation. - * @param invitation - Invitation to check - * @returns Array of available role identifiers - */ - async getAvailableRoles(invitation: XOInvitation): Promise { - const engine = this.getEngine(); - return engine.listAvailableRoles(invitation); - } - - /** - * Gets requirements for an invitation. - * @param invitation - Invitation to check - * @returns Requirements information - */ - async getRequirements(invitation: XOInvitation) { - const engine = this.getEngine(); - return engine.listRequirements(invitation); - } - - /** - * Gets missing requirements for an invitation. - * @param invitation - Invitation to check - * @returns Missing requirements information - */ - async getMissingRequirements(invitation: XOInvitation) { - const engine = this.getEngine(); - return engine.listMissingRequirements(invitation); - } - - /** - * Finds suitable UTXOs for an invitation. - * @param invitation - Invitation to find resources for - * @param options - Search options - * @returns Suitable unspent outputs - */ - async findSuitableResources( - invitation: XOInvitation, - options: { templateIdentifier: string; outputIdentifier: string }, - ) { - const engine = this.getEngine(); - return engine.findSuitableResources(invitation, options); - } - - // ============================================================================ - // Transaction Operations - // ============================================================================ - - /** - * Executes an action (broadcasts transaction). - * @param invitation - Invitation with completed transaction - * @param options - Execution options - * @returns Transaction hash - */ - async executeAction( - invitation: XOInvitation, - options: { broadcastTransaction?: boolean } = { broadcastTransaction: true }, - ): Promise { - const engine = this.getEngine(); - const txHash = await engine.executeAction(invitation, { - broadcastTransaction: options.broadcastTransaction ?? true, - }); - - if (options.broadcastTransaction) { - this.emit('state-updated'); - } - - return txHash; - } -} diff --git a/src/index.ts b/src/index.ts index dec599c..55035b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,7 @@ async function main(): Promise { await App.create({ syncServerUrl: process.env['SYNC_SERVER_URL'] ?? 'http://localhost:3000', databasePath: process.env['DB_PATH'] ?? './', - databaseFilename: process.env['DB_FILENAME'] ?? 'xo-wallet', + databaseFilename: process.env['DB_FILENAME'] ?? 'xo-wallet.db', }); } catch (error) { console.error('Failed to start XO Wallet CLI:', error); diff --git a/src/services/app.ts b/src/services/app.ts index 84c274b..1ec61dd 100644 --- a/src/services/app.ts +++ b/src/services/app.ts @@ -1,4 +1,9 @@ -import { Engine, type XOEngineOptions } from '@xo-cash/engine'; +import { + Engine, + type XOEngineOptions, + // This is temporary. Will likely be moved to where we import templates in the cli. I think that makes more sense as this is a library thing + generateTemplateIdentifier, +} from '@xo-cash/engine'; import type { XOInvitation } from '@xo-cash/types'; import { Invitation } from './invitation.js'; @@ -7,6 +12,10 @@ import { SyncServer } from '../utils/sync-server.js'; import { EventEmitter } from '../utils/event-emitter.js'; +// TODO: Remove this. Exists to hash the seed for database namespace. +import { createHash } from 'crypto'; +import { p2pkhTemplate } from '@xo-cash/templates'; + export type AppEventMap = { 'invitation-added': Invitation; 'invitation-removed': Invitation; @@ -26,8 +35,32 @@ export class AppService extends EventEmitter { public invitations: Invitation[] = []; static async create(seed: string, config: AppConfig): Promise { + // Because of a bug that lets wallets read the unspents of other wallets, we are going to manually namespace the storage paths for the app. + // We are going to do this by computing a hash of the seed and prefixing the storage paths with it. + const seedHash = createHash('sha256').update(seed).digest('hex'); + + // We want to only prefix the file name + const prefixedStoragePath = `${seedHash.slice(0, 8)}-${config.engineConfig.databaseFilename}`; + + console.log('Prefixed storage path:', prefixedStoragePath); + console.log('Engine config:', config.engineConfig); + // Create the engine - const engine = await Engine.create(seed, config.engineConfig); + const engine = await Engine.create(seed, { + ...config.engineConfig, + databaseFilename: prefixedStoragePath, + }); + + // TODO: We *technically* dont want this here, but we also need some initial templates for the wallet, so im doing it here + // Import the default P2PKH template + await engine.importTemplate(p2pkhTemplate); + + // Set default locking parameters for P2PKH + await engine.setDefaultLockingParameters( + generateTemplateIdentifier(p2pkhTemplate), + 'receiveOutput', + 'receiver', + ); // Create our own storage for the invitations const storage = await Storage.create(config.invitationStoragePath); @@ -67,9 +100,6 @@ export class AppService extends EventEmitter { async addInvitation(invitation: Invitation): Promise { // Add the invitation to the invitations array this.invitations.push(invitation); - - // Make sure the invitation is started - await invitation.start(); // Emit the invitation-added event this.emit('invitation-added', invitation); diff --git a/src/services/invitation-flow.ts b/src/services/invitation-flow.ts deleted file mode 100644 index 529efc0..0000000 --- a/src/services/invitation-flow.ts +++ /dev/null @@ -1,435 +0,0 @@ -/** - * Invitation Flow Manager - Manages the collaborative invitation lifecycle. - * - * Responsibilities: - * - Coordinates between local Engine and remote sync-server - * - Subscribes to SSE for real-time updates - * - Tracks invitation state machine - */ - -import { EventEmitter } from 'events'; -import type { XOInvitation } from '@xo-cash/types'; -import { SSESession } from '../utils/sse-client.js'; -import type { SyncClient } from './sync-client.js'; -import type { WalletController } from '../controllers/wallet-controller.js'; -import { decodeExtendedJsonObject } from '../utils/ext-json.js'; - -/** - * States an invitation can be in. - */ -export type InvitationState = - | 'created' // Just created locally - | 'published' // Published to sync server - | 'pending' // Waiting for other party - | 'ready' // All requirements met, ready to sign - | 'signed' // Signed and ready to broadcast - | 'broadcast' // Transaction broadcast - | 'completed' // Transaction confirmed - | 'expired' // Invitation expired - | 'error'; // Error state - -/** - * Tracked invitation with state information. - */ -export interface TrackedInvitation { - /** The invitation data */ - invitation: XOInvitation; - /** Current state */ - state: InvitationState; - /** SSE session for updates (if subscribed) */ - sseSession?: SSESession; - /** Timestamp when tracking started */ - trackedAt: number; - /** Last update timestamp */ - lastUpdatedAt: number; - /** Error message if in error state */ - error?: string; -} - -/** - * Events emitted by the invitation flow manager. - */ -export interface InvitationFlowEvents { - 'invitation-created': (invitation: XOInvitation) => void; - 'invitation-updated': (invitationId: string, invitation: XOInvitation) => void; - 'invitation-state-changed': (invitationId: string, state: InvitationState) => void; - 'error': (invitationId: string, error: Error) => void; -} - -/** - * Manages the invitation workflow. - */ -export class InvitationFlowManager extends EventEmitter { - /** Map of tracked invitations by ID */ - private trackedInvitations: Map = new Map(); - - /** Wallet controller reference */ - private walletController: WalletController; - - /** Sync client reference */ - private syncClient: SyncClient; - - /** - * Creates a new invitation flow manager. - * @param walletController - Wallet controller instance - * @param syncClient - Sync client instance - */ - constructor(walletController: WalletController, syncClient: SyncClient) { - super(); - this.walletController = walletController; - this.syncClient = syncClient; - } - - // ============================================================================ - // Invitation Tracking - // ============================================================================ - - /** - * Starts tracking an invitation. - * @param invitation - Invitation to track - * @param initialState - Initial state (default: 'created') - */ - track(invitation: XOInvitation, initialState: InvitationState = 'created'): TrackedInvitation { - const tracked: TrackedInvitation = { - invitation, - state: initialState, - trackedAt: Date.now(), - lastUpdatedAt: Date.now(), - }; - - this.trackedInvitations.set(invitation.invitationIdentifier, tracked); - return tracked; - } - - /** - * Gets a tracked invitation by ID. - * @param invitationId - Invitation ID - * @returns Tracked invitation or undefined - */ - get(invitationId: string): TrackedInvitation | undefined { - return this.trackedInvitations.get(invitationId); - } - - /** - * Gets all tracked invitations. - * @returns Array of tracked invitations - */ - getAll(): TrackedInvitation[] { - return Array.from(this.trackedInvitations.values()); - } - - /** - * Updates the state of a tracked invitation. - * @param invitationId - Invitation ID - * @param state - New state - */ - private updateState(invitationId: string, state: InvitationState): void { - const tracked = this.trackedInvitations.get(invitationId); - if (tracked) { - tracked.state = state; - tracked.lastUpdatedAt = Date.now(); - this.emit('invitation-state-changed', invitationId, state); - } - } - - /** - * Updates a tracked invitation with new data. - * @param invitation - Updated invitation - */ - private updateInvitation(invitation: XOInvitation): void { - const tracked = this.trackedInvitations.get(invitation.invitationIdentifier); - if (tracked) { - tracked.invitation = invitation; - tracked.lastUpdatedAt = Date.now(); - this.emit('invitation-updated', invitation.invitationIdentifier, invitation); - } - } - - /** - * Stops tracking an invitation. - * @param invitationId - Invitation ID - */ - untrack(invitationId: string): void { - const tracked = this.trackedInvitations.get(invitationId); - if (tracked?.sseSession) { - tracked.sseSession.close(); - } - this.trackedInvitations.delete(invitationId); - } - - // ============================================================================ - // Flow Operations - // ============================================================================ - - /** - * Creates a new invitation and starts tracking it. - * @param templateIdentifier - Template ID - * @param actionIdentifier - Action ID - * @returns Created and tracked invitation - */ - async createInvitation( - templateIdentifier: string, - actionIdentifier: string, - ): Promise { - // Create invitation via wallet controller - const invitation = await this.walletController.createInvitation( - templateIdentifier, - actionIdentifier, - ); - - // Track the invitation - const tracked = this.track(invitation, 'created'); - this.emit('invitation-created', invitation); - - return tracked; - } - - /** - * Publishes an invitation to the sync server. - * @param invitationId - Invitation ID to publish - * @returns Updated tracked invitation - */ - async publishInvitation(invitationId: string): Promise { - const tracked = this.trackedInvitations.get(invitationId); - if (!tracked) { - throw new Error(`Invitation not found: ${invitationId}`); - } - - try { - // Post to sync server - await this.syncClient.postInvitation(tracked.invitation); - - // Update state - this.updateState(invitationId, 'published'); - - return tracked; - } catch (error) { - tracked.state = 'error'; - tracked.error = error instanceof Error ? error.message : String(error); - this.emit('error', invitationId, error instanceof Error ? error : new Error(String(error))); - throw error; - } - } - - /** - * Subscribes to SSE updates for an invitation. - * @param invitationId - Invitation ID to subscribe to - */ - async subscribeToUpdates(invitationId: string): Promise { - const tracked = this.trackedInvitations.get(invitationId); - if (!tracked) { - throw new Error(`Invitation not found: ${invitationId}`); - } - - // Close existing SSE session if any - if (tracked.sseSession) { - tracked.sseSession.close(); - } - - // Create new SSE session - const sseUrl = this.syncClient.getSSEUrl(invitationId); - - tracked.sseSession = await SSESession.from(sseUrl, { - method: 'GET', - headers: { - 'Accept': 'text/event-stream', - }, - onMessage: (event) => { - this.handleSSEMessage(invitationId, event); - }, - onError: (error) => { - console.error(`SSE error for ${invitationId}:`, error); - this.emit('error', invitationId, error instanceof Error ? error : new Error(String(error))); - }, - onConnected: () => { - console.log(`SSE connected for invitation: ${invitationId}`); - }, - onDisconnected: () => { - console.log(`SSE disconnected for invitation: ${invitationId}`); - }, - attemptReconnect: true, - persistent: true, - retryDelay: 3000, - }); - - // Update state to pending (waiting for updates) - this.updateState(invitationId, 'pending'); - } - - /** - * Handles an SSE message for an invitation. - * @param invitationId - Invitation ID - * @param event - SSE event - */ - private handleSSEMessage(invitationId: string, event: { data: string; event?: string }): void { - try { - // Parse the event data - const parsed = JSON.parse(event.data) as { topic?: string; data?: unknown }; - - if (event.event === 'invitation-updated' || parsed.topic === 'invitation-updated') { - // Decode the invitation data (handles ExtJSON) - const invitationData = decodeExtendedJsonObject(parsed.data ?? parsed); - const invitation = invitationData as XOInvitation; - - // Update tracked invitation - this.updateInvitation(invitation); - - // Check if all requirements are met - this.checkInvitationState(invitationId); - } - } catch (error) { - console.error(`Error parsing SSE message for ${invitationId}:`, error); - } - } - - /** - * Checks and updates the state of an invitation based on its data. - * @param invitationId - Invitation ID to check - */ - private async checkInvitationState(invitationId: string): Promise { - const tracked = this.trackedInvitations.get(invitationId); - if (!tracked) return; - - try { - // Check missing requirements - const missing = await this.walletController.getMissingRequirements(tracked.invitation); - - // If no missing inputs/outputs, it's ready to sign - const hasNoMissingInputs = !missing.inputs || missing.inputs.length === 0; - const hasNoMissingOutputs = !missing.outputs || missing.outputs.length === 0; - - if (hasNoMissingInputs && hasNoMissingOutputs) { - this.updateState(invitationId, 'ready'); - } - } catch (error) { - // Ignore errors during state check - console.error(`Error checking invitation state: ${error}`); - } - } - - /** - * Imports an invitation from the sync server. - * @param invitationId - Invitation ID to import - * @returns Tracked invitation - */ - async importInvitation(invitationId: string): Promise { - // Fetch from sync server - const invitation = await this.syncClient.getInvitation(invitationId); - if (!invitation) { - throw new Error(`Invitation not found on server: ${invitationId}`); - } - - // Track the invitation - const tracked = this.track(invitation, 'pending'); - - return tracked; - } - - /** - * Accepts an invitation (joins as a participant). - * @param invitationId - Invitation ID to accept - * @returns Updated tracked invitation - */ - async acceptInvitation(invitationId: string): Promise { - const tracked = this.trackedInvitations.get(invitationId); - if (!tracked) { - throw new Error(`Invitation not found: ${invitationId}`); - } - - // Accept via wallet controller - const updatedInvitation = await this.walletController.acceptInvitation(tracked.invitation); - this.updateInvitation(updatedInvitation); - - return tracked; - } - - /** - * Appends data to an invitation. - * @param invitationId - Invitation ID - * @param params - Data to append - * @returns Updated tracked invitation - */ - async appendToInvitation( - invitationId: string, - params: Parameters[1], - ): Promise { - const tracked = this.trackedInvitations.get(invitationId); - if (!tracked) { - throw new Error(`Invitation not found: ${invitationId}`); - } - - // Append via wallet controller - const updatedInvitation = await this.walletController.appendInvitation( - tracked.invitation, - params, - ); - this.updateInvitation(updatedInvitation); - - // Publish update to sync server - await this.syncClient.updateInvitation(updatedInvitation); - - return tracked; - } - - /** - * Signs an invitation. - * @param invitationId - Invitation ID - * @returns Updated tracked invitation - */ - async signInvitation(invitationId: string): Promise { - const tracked = this.trackedInvitations.get(invitationId); - if (!tracked) { - throw new Error(`Invitation not found: ${invitationId}`); - } - - // Sign via wallet controller - const signedInvitation = await this.walletController.signInvitation(tracked.invitation); - this.updateInvitation(signedInvitation); - this.updateState(invitationId, 'signed'); - - // Publish signed invitation to sync server - await this.syncClient.updateInvitation(signedInvitation); - - return tracked; - } - - /** - * Broadcasts the transaction for an invitation. - * @param invitationId - Invitation ID - * @returns Transaction hash - */ - async broadcastTransaction(invitationId: string): Promise { - const tracked = this.trackedInvitations.get(invitationId); - if (!tracked) { - throw new Error(`Invitation not found: ${invitationId}`); - } - - // Execute action (broadcast) - const txHash = await this.walletController.executeAction(tracked.invitation, { - broadcastTransaction: true, - }); - - this.updateState(invitationId, 'broadcast'); - - // Close SSE session since we're done - if (tracked.sseSession) { - tracked.sseSession.close(); - delete tracked.sseSession; - } - - return txHash; - } - - /** - * Cleans up all resources. - */ - destroy(): void { - // Close all SSE sessions - for (const tracked of this.trackedInvitations.values()) { - if (tracked.sseSession) { - tracked.sseSession.close(); - } - } - this.trackedInvitations.clear(); - } -} diff --git a/src/services/invitation.ts b/src/services/invitation.ts index 77f0623..846e5d4 100644 --- a/src/services/invitation.ts +++ b/src/services/invitation.ts @@ -44,15 +44,24 @@ export class Invitation extends EventEmitter { throw new Error(`Invitation not found in local or remote storage: ${invitation}`); } - // Make sure the engine has the template imported - await dependencies.engine.importTemplate(invitation.templateIdentifier); + const template = await dependencies.engine.getTemplate(invitation.templateIdentifier); + + if (!template) { + throw new Error(`Template not found: ${invitation.templateIdentifier}`); + } + + console.log('Invitation:', invitation); // Create the invitation const invitationInstance = new Invitation(invitation, dependencies); + console.log('Invitation instance:', invitationInstance); + // Start the invitation and its tracking await invitationInstance.start(); + console.log('Invitation started:', invitationInstance); + return invitationInstance; } @@ -114,7 +123,9 @@ export class Invitation extends EventEmitter { const sseCommits = this.data.commits; // Set the invitation data with the combined commits - this.data = { ...invitation, commits: [...sseCommits, ...invitation.commits] }; + this.data = { ...this.data, ...invitation, commits: [...sseCommits, ...(invitation?.commits ?? [])] }; + + console.log('Invitation data:', this.data); // Store the invitation in the storage await this.storage.set(this.data.invitationIdentifier, this.data); @@ -132,10 +143,14 @@ export class Invitation extends EventEmitter { const data = JSON.parse(event.data) as { topic?: string; data?: unknown }; if (data.topic === 'invitation-updated') { const invitation = decodeExtendedJsonObject(data.data) as XOInvitation; + console.log('Invitation updated:', invitation); if (invitation.invitationIdentifier !== this.data.invitationIdentifier) { return; } + + console.log('New commits:', invitation.commits); + // Filter out commits that already exist (probably a faster way to do this. This is n^2) const newCommits = invitation.commits.filter(commit => !this.data.commits.some(c => c.commitIdentifier === commit.commitIdentifier)); this.data.commits.push(...newCommits); @@ -271,28 +286,28 @@ export class Invitation extends EventEmitter { /** * Get the missing requirements for the invitation */ - get missingRequirements() { + async getMissingRequirements() { return this.engine.listMissingRequirements(this.data); } /** * Get the requirements for the invitation */ - get requirements() { + async getRequirements() { return this.engine.listRequirements(this.data); } /** * Get the available roles for the invitation */ - get availableRoles() { + async getAvailableRoles() { return this.engine.listAvailableRoles(this.data); } /** * Get the starting actions for the invitation */ - get startingActions() { + async getStartingActions() { return this.engine.listStartingActions(this.data.templateIdentifier); } diff --git a/src/services/storage.ts b/src/services/storage.ts index 67a82c1..e3bdda4 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -1,5 +1,5 @@ import Database from 'better-sqlite3'; -import { decodeExtendedJsonObject, encodeExtendedJsonObject } from '../utils/ext-json.js'; +import { decodeExtendedJson, encodeExtendedJson } from '../utils/ext-json.js'; export class Storage { static async create(dbPath: string): Promise { @@ -35,10 +35,12 @@ export class Storage { async set(key: string, value: any): Promise { // Encode the extended json object - const encodedValue = encodeExtendedJsonObject(value); + const encodedValue = encodeExtendedJson(value); + console.log('Encoded value:', encodedValue); // Insert or replace the value into the database with full key (including basePath) const fullKey = this.getFullKey(key); + console.log('Full key:', fullKey); this.database.prepare('INSERT OR REPLACE INTO storage (key, value) VALUES (?, ?)').run(fullKey, encodedValue); } @@ -68,7 +70,7 @@ export class Storage { // Decode the extended json objects and strip basePath from keys return filteredRows.map(row => ({ key: this.stripBasePath(row.key), - value: decodeExtendedJsonObject(row.value) + value: decodeExtendedJson(row.value) })); } @@ -81,7 +83,7 @@ export class Storage { if (!row) return null; // Decode the extended json object - return decodeExtendedJsonObject(row.value); + return decodeExtendedJson(row.value); } async remove(key: string): Promise { diff --git a/src/services/sync-client.ts b/src/services/sync-client.ts deleted file mode 100644 index 91b35bb..0000000 --- a/src/services/sync-client.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Sync Server Client - HTTP client for sync-server communication. - * - * Handles: - * - Creating/updating invitations on the server - * - Fetching invitations by ID - * - ExtJSON encoding/decoding for data transfer - */ - -import type { XOInvitation } from '@xo-cash/types'; -import { encodeExtendedJson, decodeExtendedJson } from '../utils/ext-json.js'; - -/** - * Response from the sync server. - */ -export interface SyncServerResponse { - success: boolean; - data?: T; - error?: string; -} - -/** - * HTTP client for sync-server communication. - */ -export class SyncClient { - /** Base URL of the sync server */ - private baseUrl: string; - - /** - * Creates a new sync client. - * @param baseUrl - Base URL of the sync server (e.g., http://localhost:3000) - */ - constructor(baseUrl: string) { - // Remove trailing slash if present - this.baseUrl = baseUrl.replace(/\/$/, ''); - } - - /** - * Makes an HTTP request to the sync server. - * @param method - HTTP method - * @param path - Request path - * @param body - Optional request body - * @returns Response data - */ - private async request( - method: 'GET' | 'POST' | 'PUT' | 'DELETE', - path: string, - body?: unknown, - ): Promise { - const url = `${this.baseUrl}${path}`; - - const headers: Record = { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }; - - const options: RequestInit = { - method, - headers, - }; - - if (body !== undefined) { - // Encode body using ExtJSON for proper BigInt and Uint8Array serialization - options.body = encodeExtendedJson(body); - } - - const response = await fetch(url, options); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`HTTP ${response.status}: ${errorText}`); - } - - const responseText = await response.text(); - - // Return empty object if no response body - if (!responseText) { - return {} as T; - } - - // Decode response using ExtJSON - return decodeExtendedJson(responseText) as T; - } - - // ============================================================================ - // Invitation Operations - // ============================================================================ - - /** - * Posts an invitation to the sync server (create or update). - * @param invitation - Invitation to post - * @returns The stored invitation - */ - async postInvitation(invitation: XOInvitation): Promise { - return this.request('POST', '/invitations', invitation); - } - - /** - * Gets an invitation from the sync server. - * @param invitationIdentifier - Invitation ID to fetch - * @returns The invitation or undefined if not found - */ - async getInvitation(invitationIdentifier: string): Promise { - try { - // Use query parameter for GET request (can't have body) - const response = await this.request( - 'GET', - `/invitations?invitationIdentifier=${encodeURIComponent(invitationIdentifier)}` - ); - return response; - } catch (error) { - // Return undefined if not found (404) - if (error instanceof Error && error.message.includes('404')) { - return undefined; - } - throw error; - } - } - - /** - * Updates an invitation on the sync server. - * @param invitation - Updated invitation - * @returns The updated invitation - */ - async updateInvitation(invitation: XOInvitation): Promise { - // Uses the same POST endpoint which handles both create and update - return this.postInvitation(invitation); - } - - // ============================================================================ - // Health Check - // ============================================================================ - - /** - * Checks if the sync server is healthy. - * @returns True if server is healthy - */ - async isHealthy(): Promise { - try { - const response = await this.request<{ status: string }>('GET', '/health'); - return response.status === 'ok'; - } catch { - return false; - } - } - - /** - * Gets the base URL of the sync server. - */ - getBaseUrl(): string { - return this.baseUrl; - } - - /** - * Gets the SSE endpoint URL for an invitation. - * @param invitationId - Invitation ID to subscribe to - * @returns SSE endpoint URL - */ - getSSEUrl(invitationIdentifier: string): string { - return `${this.baseUrl}/invitations?invitationIdentifier=${encodeURIComponent(invitationIdentifier)}`; - } -} diff --git a/src/tui/App.tsx b/src/tui/App.tsx index d93a89a..5c6714c 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -7,15 +7,14 @@ import React from 'react'; import { Box, Text, useApp, useInput } from 'ink'; import { NavigationProvider, useNavigation } from './hooks/useNavigation.js'; import { AppProvider, useAppContext, useDialog, useStatus } from './hooks/useAppContext.js'; -import type { WalletController } from '../controllers/wallet-controller.js'; -import type { InvitationController } from '../controllers/invitation-controller.js'; +import type { AppConfig } from '../app.js'; import { colors, logoSmall } from './theme.js'; -// Screen imports (will be created) +// Screen imports import { SeedInputScreen } from './screens/SeedInput.js'; import { WalletStateScreen } from './screens/WalletState.js'; import { TemplateListScreen } from './screens/TemplateList.js'; -import { ActionWizardScreen } from './screens/ActionWizard.js'; +import { ActionWizardScreen } from './screens/action-wizard/ActionWizardScreen.js'; import { InvitationScreen } from './screens/Invitation.js'; import { TransactionScreen } from './screens/Transaction.js'; @@ -23,8 +22,7 @@ import { TransactionScreen } from './screens/Transaction.js'; * Props for the App component. */ interface AppProps { - walletController: WalletController; - invitationController: InvitationController; + config: AppConfig; } /** @@ -141,6 +139,7 @@ function DialogOverlay(): React.ReactElement | null { function MainContent(): React.ReactElement { const { exit } = useApp(); const { goBack, canGoBack } = useNavigation(); + const { screen } = useNavigation(); const { dialog } = useDialog(); const appContext = useAppContext(); @@ -158,6 +157,14 @@ function MainContent(): React.ReactElement { // Go back on Escape if (key.escape && canGoBack) { goBack(); + + // If we went back to the seed input screen, remove the current engine + // TODO: This was to support going back to seed input then re-opening your seed, but there is a bug in the engine which prevents it from closing the current + // storage instance, giving us an error about the database already being opened. + if (screen === 'seed-input') { + appContext.appService?.engine.stop(); + appContext.appService = null; + } } }); @@ -181,19 +188,17 @@ function MainContent(): React.ReactElement { * Main App component. * Sets up providers and renders the main content. */ -export function App({ walletController, invitationController }: AppProps): React.ReactElement { +export function App({ config }: AppProps): React.ReactElement { const { exit } = useApp(); const handleExit = () => { - // Cleanup controllers if needed - walletController.stop(); + // Cleanup will be handled by React when components unmount exit(); }; return ( diff --git a/src/tui/components/VariableInputField.tsx b/src/tui/components/VariableInputField.tsx new file mode 100644 index 0000000..9b1fafe --- /dev/null +++ b/src/tui/components/VariableInputField.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import { Box, Text } from "ink"; +import TextInput from "ink-text-input"; +import { formatSatoshis } from "../theme.js"; + +interface VariableInputFieldProps { + variable: { + id: string; + name: string; + type: string; + hint?: string; + value: string; + }; + index: number; + isFocused: boolean; + onChange: (index: number, value: string) => void; + onSubmit: () => void; + borderColor: string; + focusColor: string; +} + +export function VariableInputField({ + variable, + index, + isFocused, + onChange, + onSubmit, + borderColor, + focusColor, +}: VariableInputFieldProps): React.ReactElement { + return ( + + {variable.name} + {variable.hint && ( + + ({variable.hint}) + + )} + + onChange(index, value)} + onSubmit={onSubmit} + focus={isFocused} + placeholder={`Enter ${variable.name}...`} + /> + + + {variable.type === 'integer' && variable.hint === 'satoshis' && ( + + + {/* Convert from sats to bch. NOTE: we can't use the formatSatoshis function because it is too verbose and returns too many values in the string*/} + {(Number(variable.value) / 100_000_000).toFixed(8)} BCH + + + )} + + ); +} \ No newline at end of file diff --git a/src/tui/hooks/index.ts b/src/tui/hooks/index.ts index bc7ec2b..8f53882 100644 --- a/src/tui/hooks/index.ts +++ b/src/tui/hooks/index.ts @@ -4,3 +4,10 @@ export { NavigationProvider, useNavigation } from './useNavigation.js'; export { AppProvider, useAppContext, useDialog, useStatus } from './useAppContext.js'; +export { + useInvitations, + useInvitation, + useInvitationData, + useCreateInvitation, + useInvitationIds, +} from './useInvitations.js'; diff --git a/src/tui/hooks/useAppContext.tsx b/src/tui/hooks/useAppContext.tsx index a84d892..619e3be 100644 --- a/src/tui/hooks/useAppContext.tsx +++ b/src/tui/hooks/useAppContext.tsx @@ -1,10 +1,10 @@ /** - * App context hook for accessing controllers and app-level functions. + * App context hook for accessing AppService and app-level functions. */ import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; -import type { WalletController } from '../../controllers/wallet-controller.js'; -import type { InvitationController } from '../../controllers/invitation-controller.js'; +import { AppService } from '../../services/app.js'; +import type { AppConfig } from '../../app.js'; import type { AppContextType, DialogState } from '../types.js'; /** @@ -37,27 +37,50 @@ const StatusContext = createContext(null); */ interface AppProviderProps { children: ReactNode; - walletController: WalletController; - invitationController: InvitationController; + config: AppConfig; onExit: () => void; } /** * App provider component. - * Provides controllers, dialog management, and app-level functions to children. + * Provides AppService, dialog management, and app-level functions to children. */ export function AppProvider({ children, - walletController, - invitationController, + config, onExit, }: AppProviderProps): React.ReactElement { + const [appService, setAppService] = useState(null); const [dialog, setDialog] = useState(null); const [status, setStatusState] = useState('Ready'); const [isWalletInitialized, setWalletInitialized] = useState(false); - // Promise resolver for confirm dialogs - const [confirmResolver, setConfirmResolver] = useState<((value: boolean) => void) | null>(null); + /** + * Initialize wallet with seed phrase and create AppService. + */ + const initializeWallet = useCallback(async (seed: string) => { + try { + // Create the AppService with the seed + const service = await AppService.create(seed, { + syncServerUrl: config.syncServerUrl, + engineConfig: { + databasePath: config.databasePath, + databaseFilename: config.databaseFilename, + }, + invitationStoragePath: config.invitationStoragePath, + }); + + // Start the AppService (loads existing invitations) + await service.start(); + + // Set the service and mark as initialized + setAppService(service); + setWalletInitialized(true); + } catch (error) { + // Re-throw the error so the caller can handle it + throw error; + } + }, [config]); /** * Show an error dialog. @@ -88,7 +111,6 @@ export function AppProvider({ */ const confirm = useCallback((message: string): Promise => { return new Promise((resolve) => { - setConfirmResolver(() => resolve); setDialog({ visible: true, type: 'confirm', @@ -113,15 +135,15 @@ export function AppProvider({ }, []); const appValue: AppContextType = { - walletController, - invitationController, + appService, + initializeWallet, + isWalletInitialized, + config, showError, showInfo, confirm, exit: onExit, setStatus, - isWalletInitialized, - setWalletInitialized, }; const dialogValue: DialogContextType = { diff --git a/src/tui/hooks/useInvitations.tsx b/src/tui/hooks/useInvitations.tsx new file mode 100644 index 0000000..bedd00c --- /dev/null +++ b/src/tui/hooks/useInvitations.tsx @@ -0,0 +1,144 @@ +/** + * Performance-optimized invitation hooks. + * Uses useSyncExternalStore for fine-grained reactivity. + */ + +import { useSyncExternalStore, useMemo, useCallback } from 'react'; +import type { Invitation } from '../../services/invitation.js'; +import type { XOInvitation } from '@xo-cash/types'; +import { useAppContext } from './useAppContext.js'; + +/** + * Get all invitations reactively. + * Re-renders when invitations are added or removed. + */ +export function useInvitations(): Invitation[] { + const { appService } = useAppContext(); + + const subscribe = useCallback( + (callback: () => void) => { + if (!appService) { + return () => {}; + } + + // Subscribe to invitation list changes + const onAdded = () => callback(); + const onRemoved = () => callback(); + + appService.on('invitation-added', onAdded); + appService.on('invitation-removed', onRemoved); + + return () => { + appService.off('invitation-added', onAdded); + appService.off('invitation-removed', onRemoved); + }; + }, + [appService] + ); + + const getSnapshot = useCallback(() => { + return appService?.invitations ?? []; + }, [appService]); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +/** + * Get a single invitation by ID with selective re-rendering. + * Only re-renders when the specific invitation is updated. + */ +export function useInvitation(invitationId: string | null): Invitation | null { + const { appService } = useAppContext(); + + const subscribe = useCallback( + (callback: () => void) => { + if (!appService || !invitationId) { + return () => {}; + } + + // Find the invitation instance + const invitation = appService.invitations.find( + (inv) => inv.data.invitationIdentifier === invitationId + ); + + if (!invitation) { + return () => {}; + } + + // Subscribe to this specific invitation's updates + const onUpdated = () => callback(); + const onStatusChanged = () => callback(); + + invitation.on('invitation-updated', onUpdated); + invitation.on('invitation-status-changed', onStatusChanged); + + // Also subscribe to list changes in case the invitation is removed + const onRemoved = () => callback(); + appService.on('invitation-removed', onRemoved); + + return () => { + invitation.off('invitation-updated', onUpdated); + invitation.off('invitation-status-changed', onStatusChanged); + appService.off('invitation-removed', onRemoved); + }; + }, + [appService, invitationId] + ); + + const getSnapshot = useCallback(() => { + if (!appService || !invitationId) { + return null; + } + + return ( + appService.invitations.find( + (inv) => inv.data.invitationIdentifier === invitationId + ) ?? null + ); + }, [appService, invitationId]); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +/** + * Get invitation data with memoization. + * Returns stable references to prevent unnecessary re-renders. + */ +export function useInvitationData(invitationId: string | null): XOInvitation | null { + const invitation = useInvitation(invitationId); + + return useMemo(() => { + return invitation?.data ?? null; + }, [invitation?.data.invitationIdentifier, invitation?.data.commits?.length]); +} + +/** + * Hook to create invitations. + * Returns a memoized function to create invitations. + */ +export function useCreateInvitation() { + const { appService } = useAppContext(); + + return useCallback( + async (invitation: XOInvitation | string): Promise => { + if (!appService) { + throw new Error('AppService not initialized'); + } + + return await appService.createInvitation(invitation); + }, + [appService] + ); +} + +/** + * Hook to get all invitations with their IDs. + * Useful for lists where you only need IDs (prevents re-renders on data changes). + */ +export function useInvitationIds(): string[] { + const invitations = useInvitations(); + + return useMemo(() => { + return invitations.map((inv) => inv.data.invitationIdentifier); + }, [invitations]); +} diff --git a/src/tui/screens/ActionWizard.tsx b/src/tui/screens/ActionWizard-do-I-still-need-this.tsx similarity index 87% rename from src/tui/screens/ActionWizard.tsx rename to src/tui/screens/ActionWizard-do-I-still-need-this.tsx index bc0adda..2de3b8d 100644 --- a/src/tui/screens/ActionWizard.tsx +++ b/src/tui/screens/ActionWizard-do-I-still-need-this.tsx @@ -37,13 +37,13 @@ function VariableInputField({ focusColor, }: VariableInputFieldProps): React.ReactElement { return ( - + {variable.name} {variable.hint && ( ({variable.hint}) )} { - if (!invitation || !templateIdentifier) return; + if (!invitation || !templateIdentifier || !appService || !invitationId) return; try { setIsProcessing(true); @@ -224,14 +224,23 @@ export function ActionWizardScreen(): React.ReactElement { const requested = requestedVar ? BigInt(requestedVar.value || '0') : 0n; setRequiredAmount(requested); + // Get the invitation instance + const invitationInstance = appService.invitations.find( + inv => inv.data.invitationIdentifier === invitationId + ); + + if (!invitationInstance) { + throw new Error('Invitation not found'); + } + // Find suitable resources - const resources = await walletController.findSuitableResources(invitation, { + const unspentOutputs = await invitationInstance.findSuitableResources({ templateIdentifier, outputIdentifier: 'receiveOutput', // Common output identifier }); // Convert to selectable UTXOs - const utxos: SelectableUTXO[] = (resources.unspentOutputs || []).map((utxo: any) => ({ + const utxos: SelectableUTXO[] = unspentOutputs.map((utxo: any) => ({ outpointTransactionHash: utxo.outpointTransactionHash, outpointIndex: utxo.outpointIndex, valueSatoshis: BigInt(utxo.valueSatoshis), @@ -271,7 +280,7 @@ export function ActionWizardScreen(): React.ReactElement { } finally { setIsProcessing(false); } - }, [invitation, templateIdentifier, variables, walletController, showError, setStatus]); + }, [invitation, templateIdentifier, variables, appService, invitationId, showError, setStatus]); /** * Toggle UTXO selection. @@ -330,19 +339,24 @@ export function ActionWizardScreen(): React.ReactElement { * Create invitation and add variables. */ const createInvitationWithVariables = useCallback(async () => { - if (!templateIdentifier || !actionIdentifier || !roleIdentifier || !template) return; + if (!templateIdentifier || !actionIdentifier || !roleIdentifier || !template || !appService) return; setIsProcessing(true); setStatus('Creating invitation...'); try { - // Create invitation - const tracked = await invitationController.createInvitation( + // Create invitation using the engine + const xoInvitation = await appService.engine.createInvitation({ templateIdentifier, actionIdentifier, - ); + }); - let inv = tracked.invitation; + // Wrap it in an Invitation instance and add to AppService tracking + const invitationInstance = await appService.createInvitation(xoInvitation); + + console.log('Invitation Instance:', invitationInstance); + + let inv = invitationInstance.data; const invId = inv.invitationIdentifier; setInvitationId(invId); @@ -361,8 +375,8 @@ export function ActionWizardScreen(): React.ReactElement { value: isNumeric ? BigInt(v.value || '0') : v.value, }; }); - const updated = await invitationController.addVariables(invId, variableData); - inv = updated.invitation; + await invitationInstance.addVariables(variableData); + inv = invitationInstance.data; } // Add template-required outputs for the current role @@ -382,8 +396,8 @@ export function ActionWizardScreen(): React.ReactElement { // Note: roleIdentifier intentionally omitted to trigger lockingBytecode generation })); - const updated = await invitationController.addOutputs(invId, outputsToAdd); - inv = updated.invitation; + await invitationInstance.addOutputs(outputsToAdd); + inv = invitationInstance.data; } setInvitation(inv); @@ -404,13 +418,13 @@ export function ActionWizardScreen(): React.ReactElement { } finally { setIsProcessing(false); } - }, [templateIdentifier, actionIdentifier, roleIdentifier, template, variables, invitationController, steps, currentStep, showError, setStatus, loadAvailableUtxos]); + }, [templateIdentifier, actionIdentifier, roleIdentifier, template, variables, appService, steps, currentStep, showError, setStatus, loadAvailableUtxos]); /** * Add selected inputs and change output to invitation. */ const addInputsAndOutputs = useCallback(async () => { - if (!invitationId || !invitation) return; + if (!invitationId || !invitation || !appService) return; const selectedUtxos = availableUtxos.filter(u => u.selected); @@ -433,13 +447,22 @@ export function ActionWizardScreen(): React.ReactElement { setStatus('Adding inputs and outputs...'); try { + // Get the invitation instance + const invitationInstance = appService.invitations.find( + inv => inv.data.invitationIdentifier === invitationId + ); + + if (!invitationInstance) { + throw new Error('Invitation not found'); + } + // Add inputs const inputs = selectedUtxos.map(utxo => ({ - outpointTransactionHash: utxo.outpointTransactionHash, + outpointTransactionHash: new Uint8Array(Buffer.from(utxo.outpointTransactionHash, 'hex')), outpointIndex: utxo.outpointIndex, })); - await invitationController.addInputs(invitationId, inputs); + await invitationInstance.addInputs(inputs); // Add change output const outputs = [{ @@ -447,7 +470,7 @@ export function ActionWizardScreen(): React.ReactElement { // The engine will automatically generate the locking bytecode for change }]; - await invitationController.addOutputs(invitationId, outputs); + await invitationInstance.addOutputs(outputs); // Add transaction metadata // Note: This would be done via appendInvitation but we don't have direct access here @@ -460,19 +483,31 @@ export function ActionWizardScreen(): React.ReactElement { } finally { setIsProcessing(false); } - }, [invitationId, invitation, availableUtxos, selectedAmount, requiredAmount, fee, changeAmount, invitationController, showError, setStatus]); + }, [invitationId, invitation, availableUtxos, selectedAmount, requiredAmount, fee, changeAmount, appService, showError, setStatus]); /** * Publish invitation. */ const publishInvitation = useCallback(async () => { - if (!invitationId) return; + if (!invitationId || !appService) return; setIsProcessing(true); setStatus('Publishing invitation...'); try { - await invitationController.publishAndSubscribe(invitationId); + // Get the invitation instance + const invitationInstance = appService.invitations.find( + inv => inv.data.invitationIdentifier === invitationId + ); + + if (!invitationInstance) { + throw new Error('Invitation not found'); + } + + // The invitation is already being tracked and synced via SSE + // (started when created by appService.createInvitation) + // No additional publish step needed + setCurrentStep(prev => prev + 1); setStatus('Invitation published'); } catch (error) { @@ -480,7 +515,7 @@ export function ActionWizardScreen(): React.ReactElement { } finally { setIsProcessing(false); } - }, [invitationId, invitationController, showError, setStatus]); + }, [invitationId, appService, showError, setStatus]); /** * Navigate to previous step. @@ -634,7 +669,7 @@ export function ActionWizardScreen(): React.ReactElement { switch (currentStepData.type) { case 'info': return ( - + Action: {actionName} {action?.description || 'No description'} @@ -643,7 +678,7 @@ export function ActionWizardScreen(): React.ReactElement { {action?.roles?.[roleIdentifier ?? '']?.requirements && ( - + Requirements: {action.roles[roleIdentifier ?? '']?.requirements?.variables?.map(v => ( • Variable: {v} @@ -658,9 +693,9 @@ export function ActionWizardScreen(): React.ReactElement { case 'variables': return ( - + Enter required values: - + {variables.map((variable, index) => ( + Select UTXOs to fund the transaction: - + Required: {formatSatoshis(requiredAmount)} + {formatSatoshis(fee)} fee @@ -701,7 +736,7 @@ export function ActionWizardScreen(): React.ReactElement { )} - + {availableUtxos.length === 0 ? ( No UTXOs available ) : ( @@ -730,17 +765,17 @@ export function ActionWizardScreen(): React.ReactElement { case 'review': const selectedUtxos = availableUtxos.filter(u => u.selected); return ( - + Review your invitation: - + Template: {template?.name} Action: {actionName} Role: {roleIdentifier} {variables.length > 0 && ( - + Variables: {variables.map(v => ( @@ -751,7 +786,7 @@ export function ActionWizardScreen(): React.ReactElement { )} {selectedUtxos.length > 0 && ( - + Inputs ({selectedUtxos.length}): {selectedUtxos.slice(0, 3).map(u => ( @@ -765,7 +800,7 @@ export function ActionWizardScreen(): React.ReactElement { )} {changeAmount > 0 && ( - + Outputs: Change: {formatSatoshis(changeAmount)} @@ -781,12 +816,12 @@ export function ActionWizardScreen(): React.ReactElement { case 'publish': return ( - + ✓ Invitation Created & Published! - + Invitation ID: ({ label: s.name })); return ( - + {/* Header */} - + {logoSmall} - Action Wizard {template?.name} {'>'} {actionName} (as {roleIdentifier}) @@ -832,9 +867,9 @@ export function ActionWizardScreen(): React.ReactElement { {/* Content area */} {/* Buttons */} - +