Clean up and fixes
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -2,4 +2,8 @@ __sysdb__.sqlite
|
|||||||
Electrum.sqlite
|
Electrum.sqlite
|
||||||
XO.sqlite
|
XO.sqlite
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
*.sqlite
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx src/index.ts",
|
"dev": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com tsx src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js",
|
"start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
|||||||
97
src/app.ts
97
src/app.ts
@@ -1,72 +1,43 @@
|
|||||||
/**
|
/**
|
||||||
* Application bootstrap and lifecycle management.
|
* 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 React from 'react';
|
||||||
import { render, type Instance } from 'ink';
|
import { render, type Instance } from 'ink';
|
||||||
import { App as AppComponent } from './tui/App.js';
|
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.
|
* Configuration options for the CLI application.
|
||||||
*/
|
*/
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
/** URL of the sync server (default: http://localhost:3000) */
|
/** URL of the sync server (default: http://localhost:3000) */
|
||||||
syncServerUrl?: string;
|
syncServerUrl: string;
|
||||||
/** Database path for wallet state storage */
|
/** Database path for wallet state storage */
|
||||||
databasePath?: string;
|
databasePath: string;
|
||||||
/** Database filename */
|
/** 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 {
|
export class App {
|
||||||
/** Ink render instance */
|
/** Ink render instance */
|
||||||
private inkInstance: Instance | null = null;
|
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 */
|
/** Application configuration */
|
||||||
private config: Required<AppConfig>;
|
private config: AppConfig;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new App instance.
|
* Creates a new App instance.
|
||||||
* @param config - Application configuration options
|
* @param config - Application configuration options
|
||||||
*/
|
*/
|
||||||
private constructor(config: AppConfig = {}) {
|
private constructor(config: AppConfig) {
|
||||||
// Set default configuration
|
this.config = config;
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,22 +45,32 @@ export class App {
|
|||||||
* @param config - Application configuration options
|
* @param config - Application configuration options
|
||||||
* @returns Running App instance
|
* @returns Running App instance
|
||||||
*/
|
*/
|
||||||
static async create(config: AppConfig = {}): Promise<App> {
|
static async create(config: Partial<AppConfig> = {}): Promise<App> {
|
||||||
const app = new App(config);
|
// 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();
|
await app.start();
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts the application.
|
* Starts the application.
|
||||||
* Renders the Ink-based TUI.
|
* Renders the Ink-based TUI immediately.
|
||||||
*/
|
*/
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
// Render the Ink app
|
// Render the Ink app with config
|
||||||
|
// TUI will handle AppService creation after seed input
|
||||||
this.inkInstance = render(
|
this.inkInstance = render(
|
||||||
React.createElement(AppComponent, {
|
React.createElement(AppComponent, {
|
||||||
walletController: this.walletController,
|
config: this.config,
|
||||||
invitationController: this.invitationController,
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -101,34 +82,10 @@ export class App {
|
|||||||
* Stops the application and cleans up resources.
|
* Stops the application and cleans up resources.
|
||||||
*/
|
*/
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
// Stop the wallet engine if running
|
|
||||||
await this.walletController.stop();
|
|
||||||
|
|
||||||
// Unmount Ink app
|
// Unmount Ink app
|
||||||
if (this.inkInstance) {
|
if (this.inkInstance) {
|
||||||
this.inkInstance.unmount();
|
this.inkInstance.unmount();
|
||||||
this.inkInstance = null;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<TrackedInvitation> {
|
|
||||||
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<string> {
|
|
||||||
// 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<TrackedInvitation> {
|
|
||||||
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<TrackedInvitation> {
|
|
||||||
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<TrackedInvitation> {
|
|
||||||
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<TrackedInvitation> {
|
|
||||||
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<TrackedInvitation> {
|
|
||||||
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<TrackedInvitation> {
|
|
||||||
return this.flowManager.signInvitation(invitationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Broadcasts the transaction for an invitation.
|
|
||||||
* @param invitationId - Invitation ID
|
|
||||||
* @returns Transaction hash
|
|
||||||
*/
|
|
||||||
async broadcastTransaction(invitationId: string): Promise<string> {
|
|
||||||
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<string[]> {
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<void> {
|
|
||||||
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<string> {
|
|
||||||
// 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<void> {
|
|
||||||
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<WalletBalance> {
|
|
||||||
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<UnspentOutputData[]> {
|
|
||||||
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<LockingBytecodeData[]> {
|
|
||||||
const engine = this.getEngine();
|
|
||||||
return engine.listLockingBytecodesForTemplate(templateIdentifier);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Template Operations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets all imported templates.
|
|
||||||
* @returns Array of templates
|
|
||||||
*/
|
|
||||||
async getTemplates(): Promise<XOTemplate[]> {
|
|
||||||
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<XOTemplate | undefined> {
|
|
||||||
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<XOTemplateStartingActions> {
|
|
||||||
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<void> {
|
|
||||||
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<string> {
|
|
||||||
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<XOInvitation> {
|
|
||||||
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<XOInvitation> {
|
|
||||||
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<XOInvitation> {
|
|
||||||
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<typeof engine.appendInvitation>[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Signs an invitation.
|
|
||||||
* @param invitation - Invitation to sign
|
|
||||||
* @returns Signed invitation
|
|
||||||
*/
|
|
||||||
async signInvitation(invitation: XOInvitation): Promise<XOInvitation> {
|
|
||||||
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<boolean> {
|
|
||||||
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<string[]> {
|
|
||||||
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<string> {
|
|
||||||
const engine = this.getEngine();
|
|
||||||
const txHash = await engine.executeAction(invitation, {
|
|
||||||
broadcastTransaction: options.broadcastTransaction ?? true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (options.broadcastTransaction) {
|
|
||||||
this.emit('state-updated');
|
|
||||||
}
|
|
||||||
|
|
||||||
return txHash;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,7 @@ async function main(): Promise<void> {
|
|||||||
await App.create({
|
await App.create({
|
||||||
syncServerUrl: process.env['SYNC_SERVER_URL'] ?? 'http://localhost:3000',
|
syncServerUrl: process.env['SYNC_SERVER_URL'] ?? 'http://localhost:3000',
|
||||||
databasePath: process.env['DB_PATH'] ?? './',
|
databasePath: process.env['DB_PATH'] ?? './',
|
||||||
databaseFilename: process.env['DB_FILENAME'] ?? 'xo-wallet',
|
databaseFilename: process.env['DB_FILENAME'] ?? 'xo-wallet.db',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to start XO Wallet CLI:', error);
|
console.error('Failed to start XO Wallet CLI:', error);
|
||||||
|
|||||||
@@ -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 type { XOInvitation } from '@xo-cash/types';
|
||||||
|
|
||||||
import { Invitation } from './invitation.js';
|
import { Invitation } from './invitation.js';
|
||||||
@@ -7,6 +12,10 @@ import { SyncServer } from '../utils/sync-server.js';
|
|||||||
|
|
||||||
import { EventEmitter } from '../utils/event-emitter.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 = {
|
export type AppEventMap = {
|
||||||
'invitation-added': Invitation;
|
'invitation-added': Invitation;
|
||||||
'invitation-removed': Invitation;
|
'invitation-removed': Invitation;
|
||||||
@@ -26,8 +35,32 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
public invitations: Invitation[] = [];
|
public invitations: Invitation[] = [];
|
||||||
|
|
||||||
static async create(seed: string, config: AppConfig): Promise<AppService> {
|
static async create(seed: string, config: AppConfig): Promise<AppService> {
|
||||||
|
// 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
|
// 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
|
// Create our own storage for the invitations
|
||||||
const storage = await Storage.create(config.invitationStoragePath);
|
const storage = await Storage.create(config.invitationStoragePath);
|
||||||
@@ -67,9 +100,6 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
async addInvitation(invitation: Invitation): Promise<void> {
|
async addInvitation(invitation: Invitation): Promise<void> {
|
||||||
// Add the invitation to the invitations array
|
// Add the invitation to the invitations array
|
||||||
this.invitations.push(invitation);
|
this.invitations.push(invitation);
|
||||||
|
|
||||||
// Make sure the invitation is started
|
|
||||||
await invitation.start();
|
|
||||||
|
|
||||||
// Emit the invitation-added event
|
// Emit the invitation-added event
|
||||||
this.emit('invitation-added', invitation);
|
this.emit('invitation-added', invitation);
|
||||||
|
|||||||
@@ -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<string, TrackedInvitation> = 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<TrackedInvitation> {
|
|
||||||
// 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<TrackedInvitation> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<TrackedInvitation> {
|
|
||||||
// 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<TrackedInvitation> {
|
|
||||||
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<WalletController['appendInvitation']>[1],
|
|
||||||
): Promise<TrackedInvitation> {
|
|
||||||
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<TrackedInvitation> {
|
|
||||||
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<string> {
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -44,15 +44,24 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
throw new Error(`Invitation not found in local or remote storage: ${invitation}`);
|
throw new Error(`Invitation not found in local or remote storage: ${invitation}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make sure the engine has the template imported
|
const template = await dependencies.engine.getTemplate(invitation.templateIdentifier);
|
||||||
await dependencies.engine.importTemplate(invitation.templateIdentifier);
|
|
||||||
|
if (!template) {
|
||||||
|
throw new Error(`Template not found: ${invitation.templateIdentifier}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Invitation:', invitation);
|
||||||
|
|
||||||
// Create the invitation
|
// Create the invitation
|
||||||
const invitationInstance = new Invitation(invitation, dependencies);
|
const invitationInstance = new Invitation(invitation, dependencies);
|
||||||
|
|
||||||
|
console.log('Invitation instance:', invitationInstance);
|
||||||
|
|
||||||
// Start the invitation and its tracking
|
// Start the invitation and its tracking
|
||||||
await invitationInstance.start();
|
await invitationInstance.start();
|
||||||
|
|
||||||
|
console.log('Invitation started:', invitationInstance);
|
||||||
|
|
||||||
return invitationInstance;
|
return invitationInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +123,9 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
const sseCommits = this.data.commits;
|
const sseCommits = this.data.commits;
|
||||||
|
|
||||||
// Set the invitation data with the combined 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
|
// Store the invitation in the storage
|
||||||
await this.storage.set(this.data.invitationIdentifier, this.data);
|
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||||
@@ -132,10 +143,14 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
const data = JSON.parse(event.data) as { topic?: string; data?: unknown };
|
const data = JSON.parse(event.data) as { topic?: string; data?: unknown };
|
||||||
if (data.topic === 'invitation-updated') {
|
if (data.topic === 'invitation-updated') {
|
||||||
const invitation = decodeExtendedJsonObject(data.data) as XOInvitation;
|
const invitation = decodeExtendedJsonObject(data.data) as XOInvitation;
|
||||||
|
console.log('Invitation updated:', invitation);
|
||||||
|
|
||||||
if (invitation.invitationIdentifier !== this.data.invitationIdentifier) {
|
if (invitation.invitationIdentifier !== this.data.invitationIdentifier) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('New commits:', invitation.commits);
|
||||||
|
|
||||||
// Filter out commits that already exist (probably a faster way to do this. This is n^2)
|
// 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));
|
const newCommits = invitation.commits.filter(commit => !this.data.commits.some(c => c.commitIdentifier === commit.commitIdentifier));
|
||||||
this.data.commits.push(...newCommits);
|
this.data.commits.push(...newCommits);
|
||||||
@@ -271,28 +286,28 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
/**
|
/**
|
||||||
* Get the missing requirements for the invitation
|
* Get the missing requirements for the invitation
|
||||||
*/
|
*/
|
||||||
get missingRequirements() {
|
async getMissingRequirements() {
|
||||||
return this.engine.listMissingRequirements(this.data);
|
return this.engine.listMissingRequirements(this.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the requirements for the invitation
|
* Get the requirements for the invitation
|
||||||
*/
|
*/
|
||||||
get requirements() {
|
async getRequirements() {
|
||||||
return this.engine.listRequirements(this.data);
|
return this.engine.listRequirements(this.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the available roles for the invitation
|
* Get the available roles for the invitation
|
||||||
*/
|
*/
|
||||||
get availableRoles() {
|
async getAvailableRoles() {
|
||||||
return this.engine.listAvailableRoles(this.data);
|
return this.engine.listAvailableRoles(this.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the starting actions for the invitation
|
* Get the starting actions for the invitation
|
||||||
*/
|
*/
|
||||||
get startingActions() {
|
async getStartingActions() {
|
||||||
return this.engine.listStartingActions(this.data.templateIdentifier);
|
return this.engine.listStartingActions(this.data.templateIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import { decodeExtendedJsonObject, encodeExtendedJsonObject } from '../utils/ext-json.js';
|
import { decodeExtendedJson, encodeExtendedJson } from '../utils/ext-json.js';
|
||||||
|
|
||||||
export class Storage {
|
export class Storage {
|
||||||
static async create(dbPath: string): Promise<Storage> {
|
static async create(dbPath: string): Promise<Storage> {
|
||||||
@@ -35,10 +35,12 @@ export class Storage {
|
|||||||
|
|
||||||
async set(key: string, value: any): Promise<void> {
|
async set(key: string, value: any): Promise<void> {
|
||||||
// Encode the extended json object
|
// 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)
|
// Insert or replace the value into the database with full key (including basePath)
|
||||||
const fullKey = this.getFullKey(key);
|
const fullKey = this.getFullKey(key);
|
||||||
|
console.log('Full key:', fullKey);
|
||||||
this.database.prepare('INSERT OR REPLACE INTO storage (key, value) VALUES (?, ?)').run(fullKey, encodedValue);
|
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
|
// Decode the extended json objects and strip basePath from keys
|
||||||
return filteredRows.map(row => ({
|
return filteredRows.map(row => ({
|
||||||
key: this.stripBasePath(row.key),
|
key: this.stripBasePath(row.key),
|
||||||
value: decodeExtendedJsonObject(row.value)
|
value: decodeExtendedJson(row.value)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +83,7 @@ export class Storage {
|
|||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
|
|
||||||
// Decode the extended json object
|
// Decode the extended json object
|
||||||
return decodeExtendedJsonObject(row.value);
|
return decodeExtendedJson(row.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(key: string): Promise<void> {
|
async remove(key: string): Promise<void> {
|
||||||
|
|||||||
@@ -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<T> {
|
|
||||||
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<T>(
|
|
||||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
|
||||||
path: string,
|
|
||||||
body?: unknown,
|
|
||||||
): Promise<T> {
|
|
||||||
const url = `${this.baseUrl}${path}`;
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'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<XOInvitation> {
|
|
||||||
return this.request<XOInvitation>('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<XOInvitation | undefined> {
|
|
||||||
try {
|
|
||||||
// Use query parameter for GET request (can't have body)
|
|
||||||
const response = await this.request<XOInvitation>(
|
|
||||||
'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<XOInvitation> {
|
|
||||||
// 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<boolean> {
|
|
||||||
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)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,15 +7,14 @@ import React from 'react';
|
|||||||
import { Box, Text, useApp, useInput } from 'ink';
|
import { Box, Text, useApp, useInput } from 'ink';
|
||||||
import { NavigationProvider, useNavigation } from './hooks/useNavigation.js';
|
import { NavigationProvider, useNavigation } from './hooks/useNavigation.js';
|
||||||
import { AppProvider, useAppContext, useDialog, useStatus } from './hooks/useAppContext.js';
|
import { AppProvider, useAppContext, useDialog, useStatus } from './hooks/useAppContext.js';
|
||||||
import type { WalletController } from '../controllers/wallet-controller.js';
|
import type { AppConfig } from '../app.js';
|
||||||
import type { InvitationController } from '../controllers/invitation-controller.js';
|
|
||||||
import { colors, logoSmall } from './theme.js';
|
import { colors, logoSmall } from './theme.js';
|
||||||
|
|
||||||
// Screen imports (will be created)
|
// Screen imports
|
||||||
import { SeedInputScreen } from './screens/SeedInput.js';
|
import { SeedInputScreen } from './screens/SeedInput.js';
|
||||||
import { WalletStateScreen } from './screens/WalletState.js';
|
import { WalletStateScreen } from './screens/WalletState.js';
|
||||||
import { TemplateListScreen } from './screens/TemplateList.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 { InvitationScreen } from './screens/Invitation.js';
|
||||||
import { TransactionScreen } from './screens/Transaction.js';
|
import { TransactionScreen } from './screens/Transaction.js';
|
||||||
|
|
||||||
@@ -23,8 +22,7 @@ import { TransactionScreen } from './screens/Transaction.js';
|
|||||||
* Props for the App component.
|
* Props for the App component.
|
||||||
*/
|
*/
|
||||||
interface AppProps {
|
interface AppProps {
|
||||||
walletController: WalletController;
|
config: AppConfig;
|
||||||
invitationController: InvitationController;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -141,6 +139,7 @@ function DialogOverlay(): React.ReactElement | null {
|
|||||||
function MainContent(): React.ReactElement {
|
function MainContent(): React.ReactElement {
|
||||||
const { exit } = useApp();
|
const { exit } = useApp();
|
||||||
const { goBack, canGoBack } = useNavigation();
|
const { goBack, canGoBack } = useNavigation();
|
||||||
|
const { screen } = useNavigation();
|
||||||
const { dialog } = useDialog();
|
const { dialog } = useDialog();
|
||||||
const appContext = useAppContext();
|
const appContext = useAppContext();
|
||||||
|
|
||||||
@@ -158,6 +157,14 @@ function MainContent(): React.ReactElement {
|
|||||||
// Go back on Escape
|
// Go back on Escape
|
||||||
if (key.escape && canGoBack) {
|
if (key.escape && canGoBack) {
|
||||||
goBack();
|
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.
|
* Main App component.
|
||||||
* Sets up providers and renders the main content.
|
* 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 { exit } = useApp();
|
||||||
|
|
||||||
const handleExit = () => {
|
const handleExit = () => {
|
||||||
// Cleanup controllers if needed
|
// Cleanup will be handled by React when components unmount
|
||||||
walletController.stop();
|
|
||||||
exit();
|
exit();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppProvider
|
<AppProvider
|
||||||
walletController={walletController}
|
config={config}
|
||||||
invitationController={invitationController}
|
|
||||||
onExit={handleExit}
|
onExit={handleExit}
|
||||||
>
|
>
|
||||||
<NavigationProvider initialScreen="seed-input">
|
<NavigationProvider initialScreen="seed-input">
|
||||||
|
|||||||
64
src/tui/components/VariableInputField.tsx
Normal file
64
src/tui/components/VariableInputField.tsx
Normal file
@@ -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 (
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Text color={focusColor}>{variable.name}</Text>
|
||||||
|
{variable.hint && (
|
||||||
|
<Text color={borderColor} dimColor>
|
||||||
|
({variable.hint})
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
borderStyle="single"
|
||||||
|
borderColor={isFocused ? focusColor : borderColor}
|
||||||
|
paddingX={1}
|
||||||
|
marginTop={1}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
value={variable.value}
|
||||||
|
onChange={(value) => onChange(index, value)}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
focus={isFocused}
|
||||||
|
placeholder={`Enter ${variable.name}...`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</Box>
|
||||||
|
{variable.type === 'integer' && variable.hint === 'satoshis' && (
|
||||||
|
<Box>
|
||||||
|
<Text color={borderColor} dimColor>
|
||||||
|
{/* 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
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,3 +4,10 @@
|
|||||||
|
|
||||||
export { NavigationProvider, useNavigation } from './useNavigation.js';
|
export { NavigationProvider, useNavigation } from './useNavigation.js';
|
||||||
export { AppProvider, useAppContext, useDialog, useStatus } from './useAppContext.js';
|
export { AppProvider, useAppContext, useDialog, useStatus } from './useAppContext.js';
|
||||||
|
export {
|
||||||
|
useInvitations,
|
||||||
|
useInvitation,
|
||||||
|
useInvitationData,
|
||||||
|
useCreateInvitation,
|
||||||
|
useInvitationIds,
|
||||||
|
} from './useInvitations.js';
|
||||||
|
|||||||
@@ -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 React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||||
import type { WalletController } from '../../controllers/wallet-controller.js';
|
import { AppService } from '../../services/app.js';
|
||||||
import type { InvitationController } from '../../controllers/invitation-controller.js';
|
import type { AppConfig } from '../../app.js';
|
||||||
import type { AppContextType, DialogState } from '../types.js';
|
import type { AppContextType, DialogState } from '../types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,27 +37,50 @@ const StatusContext = createContext<StatusContextType | null>(null);
|
|||||||
*/
|
*/
|
||||||
interface AppProviderProps {
|
interface AppProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
walletController: WalletController;
|
config: AppConfig;
|
||||||
invitationController: InvitationController;
|
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App provider component.
|
* 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({
|
export function AppProvider({
|
||||||
children,
|
children,
|
||||||
walletController,
|
config,
|
||||||
invitationController,
|
|
||||||
onExit,
|
onExit,
|
||||||
}: AppProviderProps): React.ReactElement {
|
}: AppProviderProps): React.ReactElement {
|
||||||
|
const [appService, setAppService] = useState<AppService | null>(null);
|
||||||
const [dialog, setDialog] = useState<DialogState | null>(null);
|
const [dialog, setDialog] = useState<DialogState | null>(null);
|
||||||
const [status, setStatusState] = useState<string>('Ready');
|
const [status, setStatusState] = useState<string>('Ready');
|
||||||
const [isWalletInitialized, setWalletInitialized] = useState(false);
|
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.
|
* Show an error dialog.
|
||||||
@@ -88,7 +111,6 @@ export function AppProvider({
|
|||||||
*/
|
*/
|
||||||
const confirm = useCallback((message: string): Promise<boolean> => {
|
const confirm = useCallback((message: string): Promise<boolean> => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
setConfirmResolver(() => resolve);
|
|
||||||
setDialog({
|
setDialog({
|
||||||
visible: true,
|
visible: true,
|
||||||
type: 'confirm',
|
type: 'confirm',
|
||||||
@@ -113,15 +135,15 @@ export function AppProvider({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const appValue: AppContextType = {
|
const appValue: AppContextType = {
|
||||||
walletController,
|
appService,
|
||||||
invitationController,
|
initializeWallet,
|
||||||
|
isWalletInitialized,
|
||||||
|
config,
|
||||||
showError,
|
showError,
|
||||||
showInfo,
|
showInfo,
|
||||||
confirm,
|
confirm,
|
||||||
exit: onExit,
|
exit: onExit,
|
||||||
setStatus,
|
setStatus,
|
||||||
isWalletInitialized,
|
|
||||||
setWalletInitialized,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const dialogValue: DialogContextType = {
|
const dialogValue: DialogContextType = {
|
||||||
|
|||||||
144
src/tui/hooks/useInvitations.tsx
Normal file
144
src/tui/hooks/useInvitations.tsx
Normal file
@@ -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<Invitation> => {
|
||||||
|
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]);
|
||||||
|
}
|
||||||
@@ -37,13 +37,13 @@ function VariableInputField({
|
|||||||
focusColor,
|
focusColor,
|
||||||
}: VariableInputFieldProps): React.ReactElement {
|
}: VariableInputFieldProps): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
<Box flexDirection='column' marginBottom={1}>
|
||||||
<Text color={focusColor}>{variable.name}</Text>
|
<Text color={focusColor}>{variable.name}</Text>
|
||||||
{variable.hint && (
|
{variable.hint && (
|
||||||
<Text color={borderColor} dimColor>({variable.hint})</Text>
|
<Text color={borderColor} dimColor>({variable.hint})</Text>
|
||||||
)}
|
)}
|
||||||
<Box
|
<Box
|
||||||
borderStyle="single"
|
borderStyle='single'
|
||||||
borderColor={isFocused ? focusColor : borderColor}
|
borderColor={isFocused ? focusColor : borderColor}
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
marginTop={1}
|
marginTop={1}
|
||||||
@@ -107,7 +107,7 @@ interface SelectableUTXO {
|
|||||||
*/
|
*/
|
||||||
export function ActionWizardScreen(): React.ReactElement {
|
export function ActionWizardScreen(): React.ReactElement {
|
||||||
const { navigate, goBack, data: navData } = useNavigation();
|
const { navigate, goBack, data: navData } = useNavigation();
|
||||||
const { walletController, invitationController, showError, showInfo } = useAppContext();
|
const { appService, showError, showInfo } = useAppContext();
|
||||||
const { setStatus } = useStatus();
|
const { setStatus } = useStatus();
|
||||||
|
|
||||||
// Extract navigation data
|
// Extract navigation data
|
||||||
@@ -210,7 +210,7 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
* Load available UTXOs for the inputs step.
|
* Load available UTXOs for the inputs step.
|
||||||
*/
|
*/
|
||||||
const loadAvailableUtxos = useCallback(async () => {
|
const loadAvailableUtxos = useCallback(async () => {
|
||||||
if (!invitation || !templateIdentifier) return;
|
if (!invitation || !templateIdentifier || !appService || !invitationId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
@@ -224,14 +224,23 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
const requested = requestedVar ? BigInt(requestedVar.value || '0') : 0n;
|
const requested = requestedVar ? BigInt(requestedVar.value || '0') : 0n;
|
||||||
setRequiredAmount(requested);
|
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
|
// Find suitable resources
|
||||||
const resources = await walletController.findSuitableResources(invitation, {
|
const unspentOutputs = await invitationInstance.findSuitableResources({
|
||||||
templateIdentifier,
|
templateIdentifier,
|
||||||
outputIdentifier: 'receiveOutput', // Common output identifier
|
outputIdentifier: 'receiveOutput', // Common output identifier
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert to selectable UTXOs
|
// Convert to selectable UTXOs
|
||||||
const utxos: SelectableUTXO[] = (resources.unspentOutputs || []).map((utxo: any) => ({
|
const utxos: SelectableUTXO[] = unspentOutputs.map((utxo: any) => ({
|
||||||
outpointTransactionHash: utxo.outpointTransactionHash,
|
outpointTransactionHash: utxo.outpointTransactionHash,
|
||||||
outpointIndex: utxo.outpointIndex,
|
outpointIndex: utxo.outpointIndex,
|
||||||
valueSatoshis: BigInt(utxo.valueSatoshis),
|
valueSatoshis: BigInt(utxo.valueSatoshis),
|
||||||
@@ -271,7 +280,7 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
}, [invitation, templateIdentifier, variables, walletController, showError, setStatus]);
|
}, [invitation, templateIdentifier, variables, appService, invitationId, showError, setStatus]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle UTXO selection.
|
* Toggle UTXO selection.
|
||||||
@@ -330,19 +339,24 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
* Create invitation and add variables.
|
* Create invitation and add variables.
|
||||||
*/
|
*/
|
||||||
const createInvitationWithVariables = useCallback(async () => {
|
const createInvitationWithVariables = useCallback(async () => {
|
||||||
if (!templateIdentifier || !actionIdentifier || !roleIdentifier || !template) return;
|
if (!templateIdentifier || !actionIdentifier || !roleIdentifier || !template || !appService) return;
|
||||||
|
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
setStatus('Creating invitation...');
|
setStatus('Creating invitation...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create invitation
|
// Create invitation using the engine
|
||||||
const tracked = await invitationController.createInvitation(
|
const xoInvitation = await appService.engine.createInvitation({
|
||||||
templateIdentifier,
|
templateIdentifier,
|
||||||
actionIdentifier,
|
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;
|
const invId = inv.invitationIdentifier;
|
||||||
setInvitationId(invId);
|
setInvitationId(invId);
|
||||||
|
|
||||||
@@ -361,8 +375,8 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
value: isNumeric ? BigInt(v.value || '0') : v.value,
|
value: isNumeric ? BigInt(v.value || '0') : v.value,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const updated = await invitationController.addVariables(invId, variableData);
|
await invitationInstance.addVariables(variableData);
|
||||||
inv = updated.invitation;
|
inv = invitationInstance.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add template-required outputs for the current role
|
// 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
|
// Note: roleIdentifier intentionally omitted to trigger lockingBytecode generation
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const updated = await invitationController.addOutputs(invId, outputsToAdd);
|
await invitationInstance.addOutputs(outputsToAdd);
|
||||||
inv = updated.invitation;
|
inv = invitationInstance.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
setInvitation(inv);
|
setInvitation(inv);
|
||||||
@@ -404,13 +418,13 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
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.
|
* Add selected inputs and change output to invitation.
|
||||||
*/
|
*/
|
||||||
const addInputsAndOutputs = useCallback(async () => {
|
const addInputsAndOutputs = useCallback(async () => {
|
||||||
if (!invitationId || !invitation) return;
|
if (!invitationId || !invitation || !appService) return;
|
||||||
|
|
||||||
const selectedUtxos = availableUtxos.filter(u => u.selected);
|
const selectedUtxos = availableUtxos.filter(u => u.selected);
|
||||||
|
|
||||||
@@ -433,13 +447,22 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
setStatus('Adding inputs and outputs...');
|
setStatus('Adding inputs and outputs...');
|
||||||
|
|
||||||
try {
|
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
|
// Add inputs
|
||||||
const inputs = selectedUtxos.map(utxo => ({
|
const inputs = selectedUtxos.map(utxo => ({
|
||||||
outpointTransactionHash: utxo.outpointTransactionHash,
|
outpointTransactionHash: new Uint8Array(Buffer.from(utxo.outpointTransactionHash, 'hex')),
|
||||||
outpointIndex: utxo.outpointIndex,
|
outpointIndex: utxo.outpointIndex,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await invitationController.addInputs(invitationId, inputs);
|
await invitationInstance.addInputs(inputs);
|
||||||
|
|
||||||
// Add change output
|
// Add change output
|
||||||
const outputs = [{
|
const outputs = [{
|
||||||
@@ -447,7 +470,7 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
// The engine will automatically generate the locking bytecode for change
|
// The engine will automatically generate the locking bytecode for change
|
||||||
}];
|
}];
|
||||||
|
|
||||||
await invitationController.addOutputs(invitationId, outputs);
|
await invitationInstance.addOutputs(outputs);
|
||||||
|
|
||||||
// Add transaction metadata
|
// Add transaction metadata
|
||||||
// Note: This would be done via appendInvitation but we don't have direct access here
|
// 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 {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
}, [invitationId, invitation, availableUtxos, selectedAmount, requiredAmount, fee, changeAmount, invitationController, showError, setStatus]);
|
}, [invitationId, invitation, availableUtxos, selectedAmount, requiredAmount, fee, changeAmount, appService, showError, setStatus]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publish invitation.
|
* Publish invitation.
|
||||||
*/
|
*/
|
||||||
const publishInvitation = useCallback(async () => {
|
const publishInvitation = useCallback(async () => {
|
||||||
if (!invitationId) return;
|
if (!invitationId || !appService) return;
|
||||||
|
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
setStatus('Publishing invitation...');
|
setStatus('Publishing invitation...');
|
||||||
|
|
||||||
try {
|
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);
|
setCurrentStep(prev => prev + 1);
|
||||||
setStatus('Invitation published');
|
setStatus('Invitation published');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -480,7 +515,7 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
}, [invitationId, invitationController, showError, setStatus]);
|
}, [invitationId, appService, showError, setStatus]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate to previous step.
|
* Navigate to previous step.
|
||||||
@@ -634,7 +669,7 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
switch (currentStepData.type) {
|
switch (currentStepData.type) {
|
||||||
case 'info':
|
case 'info':
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection='column'>
|
||||||
<Text color={colors.primary} bold>Action: {actionName}</Text>
|
<Text color={colors.primary} bold>Action: {actionName}</Text>
|
||||||
<Text color={colors.textMuted}>{action?.description || 'No description'}</Text>
|
<Text color={colors.textMuted}>{action?.description || 'No description'}</Text>
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
@@ -643,7 +678,7 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{action?.roles?.[roleIdentifier ?? '']?.requirements && (
|
{action?.roles?.[roleIdentifier ?? '']?.requirements && (
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection='column'>
|
||||||
<Text color={colors.text}>Requirements:</Text>
|
<Text color={colors.text}>Requirements:</Text>
|
||||||
{action.roles[roleIdentifier ?? '']?.requirements?.variables?.map(v => (
|
{action.roles[roleIdentifier ?? '']?.requirements?.variables?.map(v => (
|
||||||
<Text key={v} color={colors.textMuted}> • Variable: {v}</Text>
|
<Text key={v} color={colors.textMuted}> • Variable: {v}</Text>
|
||||||
@@ -658,9 +693,9 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
|
|
||||||
case 'variables':
|
case 'variables':
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection='column'>
|
||||||
<Text color={colors.text} bold>Enter required values:</Text>
|
<Text color={colors.text} bold>Enter required values:</Text>
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection='column'>
|
||||||
{variables.map((variable, index) => (
|
{variables.map((variable, index) => (
|
||||||
<VariableInputField
|
<VariableInputField
|
||||||
key={variable.id}
|
key={variable.id}
|
||||||
@@ -684,10 +719,10 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
|
|
||||||
case 'inputs':
|
case 'inputs':
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection='column'>
|
||||||
<Text color={colors.text} bold>Select UTXOs to fund the transaction:</Text>
|
<Text color={colors.text} bold>Select UTXOs to fund the transaction:</Text>
|
||||||
|
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection='column'>
|
||||||
<Text color={colors.textMuted}>
|
<Text color={colors.textMuted}>
|
||||||
Required: {formatSatoshis(requiredAmount)} + {formatSatoshis(fee)} fee
|
Required: {formatSatoshis(requiredAmount)} + {formatSatoshis(fee)} fee
|
||||||
</Text>
|
</Text>
|
||||||
@@ -701,7 +736,7 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box marginTop={1} flexDirection="column" borderStyle="single" borderColor={colors.border} paddingX={1}>
|
<Box marginTop={1} flexDirection='column' borderStyle='single' borderColor={colors.border} paddingX={1}>
|
||||||
{availableUtxos.length === 0 ? (
|
{availableUtxos.length === 0 ? (
|
||||||
<Text color={colors.textMuted}>No UTXOs available</Text>
|
<Text color={colors.textMuted}>No UTXOs available</Text>
|
||||||
) : (
|
) : (
|
||||||
@@ -730,17 +765,17 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
case 'review':
|
case 'review':
|
||||||
const selectedUtxos = availableUtxos.filter(u => u.selected);
|
const selectedUtxos = availableUtxos.filter(u => u.selected);
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection='column'>
|
||||||
<Text color={colors.text} bold>Review your invitation:</Text>
|
<Text color={colors.text} bold>Review your invitation:</Text>
|
||||||
|
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection='column'>
|
||||||
<Text color={colors.textMuted}>Template: {template?.name}</Text>
|
<Text color={colors.textMuted}>Template: {template?.name}</Text>
|
||||||
<Text color={colors.textMuted}>Action: {actionName}</Text>
|
<Text color={colors.textMuted}>Action: {actionName}</Text>
|
||||||
<Text color={colors.textMuted}>Role: {roleIdentifier}</Text>
|
<Text color={colors.textMuted}>Role: {roleIdentifier}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{variables.length > 0 && (
|
{variables.length > 0 && (
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection='column'>
|
||||||
<Text color={colors.text}>Variables:</Text>
|
<Text color={colors.text}>Variables:</Text>
|
||||||
{variables.map(v => (
|
{variables.map(v => (
|
||||||
<Text key={v.id} color={colors.textMuted}>
|
<Text key={v.id} color={colors.textMuted}>
|
||||||
@@ -751,7 +786,7 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedUtxos.length > 0 && (
|
{selectedUtxos.length > 0 && (
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection='column'>
|
||||||
<Text color={colors.text}>Inputs ({selectedUtxos.length}):</Text>
|
<Text color={colors.text}>Inputs ({selectedUtxos.length}):</Text>
|
||||||
{selectedUtxos.slice(0, 3).map(u => (
|
{selectedUtxos.slice(0, 3).map(u => (
|
||||||
<Text key={`${u.outpointTransactionHash}:${u.outpointIndex}`} color={colors.textMuted}>
|
<Text key={`${u.outpointTransactionHash}:${u.outpointIndex}`} color={colors.textMuted}>
|
||||||
@@ -765,7 +800,7 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{changeAmount > 0 && (
|
{changeAmount > 0 && (
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection='column'>
|
||||||
<Text color={colors.text}>Outputs:</Text>
|
<Text color={colors.text}>Outputs:</Text>
|
||||||
<Text color={colors.textMuted}> Change: {formatSatoshis(changeAmount)}</Text>
|
<Text color={colors.textMuted}> Change: {formatSatoshis(changeAmount)}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -781,12 +816,12 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
|
|
||||||
case 'publish':
|
case 'publish':
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection='column'>
|
||||||
<Text color={colors.success} bold>✓ Invitation Created & Published!</Text>
|
<Text color={colors.success} bold>✓ Invitation Created & Published!</Text>
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection='column'>
|
||||||
<Text color={colors.text}>Invitation ID:</Text>
|
<Text color={colors.text}>Invitation ID:</Text>
|
||||||
<Box
|
<Box
|
||||||
borderStyle="single"
|
borderStyle='single'
|
||||||
borderColor={colors.primary}
|
borderColor={colors.primary}
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
marginTop={1}
|
marginTop={1}
|
||||||
@@ -816,9 +851,9 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
const stepIndicatorSteps: Step[] = steps.map(s => ({ label: s.name }));
|
const stepIndicatorSteps: Step[] = steps.map(s => ({ label: s.name }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" flexGrow={1}>
|
<Box flexDirection='column' flexGrow={1}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1} flexDirection="column">
|
<Box borderStyle='single' borderColor={colors.secondary} paddingX={1} flexDirection='column'>
|
||||||
<Text color={colors.primary} bold>{logoSmall} - Action Wizard</Text>
|
<Text color={colors.primary} bold>{logoSmall} - Action Wizard</Text>
|
||||||
<Text color={colors.textMuted}>
|
<Text color={colors.textMuted}>
|
||||||
{template?.name} {'>'} {actionName} (as {roleIdentifier})
|
{template?.name} {'>'} {actionName} (as {roleIdentifier})
|
||||||
@@ -832,9 +867,9 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
|
|
||||||
{/* Content area */}
|
{/* Content area */}
|
||||||
<Box
|
<Box
|
||||||
borderStyle="single"
|
borderStyle='single'
|
||||||
borderColor={focusArea === 'content' ? colors.focus : colors.primary}
|
borderColor={focusArea === 'content' ? colors.focus : colors.primary}
|
||||||
flexDirection="column"
|
flexDirection='column'
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
paddingY={1}
|
paddingY={1}
|
||||||
marginTop={1}
|
marginTop={1}
|
||||||
@@ -854,15 +889,15 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Buttons */}
|
{/* Buttons */}
|
||||||
<Box marginTop={1} marginX={1} justifyContent="space-between">
|
<Box marginTop={1} marginX={1} justifyContent='space-between'>
|
||||||
<Box gap={1}>
|
<Box gap={1}>
|
||||||
<Button
|
<Button
|
||||||
label="Back"
|
label='Back'
|
||||||
focused={focusArea === 'buttons' && focusedButton === 'back'}
|
focused={focusArea === 'buttons' && focusedButton === 'back'}
|
||||||
disabled={currentStepData?.type === 'publish'}
|
disabled={currentStepData?.type === 'publish'}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
label="Cancel"
|
label='Cancel'
|
||||||
focused={focusArea === 'buttons' && focusedButton === 'cancel'}
|
focused={focusArea === 'buttons' && focusedButton === 'cancel'}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -14,14 +14,36 @@ import { Box, Text, useInput } from 'ink';
|
|||||||
import { InputDialog } from '../components/Dialog.js';
|
import { InputDialog } from '../components/Dialog.js';
|
||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
import { useNavigation } from '../hooks/useNavigation.js';
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||||
|
import { useInvitations } from '../hooks/useInvitations.js';
|
||||||
import { colors, logoSmall, formatHex, formatSatoshis } from '../theme.js';
|
import { colors, logoSmall, formatHex, formatSatoshis } from '../theme.js';
|
||||||
import { copyToClipboard } from '../utils/clipboard.js';
|
import { copyToClipboard } from '../utils/clipboard.js';
|
||||||
import type { TrackedInvitation, InvitationState } from '../../services/invitation-flow.js';
|
import type { Invitation } from '../../services/invitation.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get state display string for invitation.
|
||||||
|
* For now we'll use a simple derived state based on commits.
|
||||||
|
*/
|
||||||
|
function getInvitationState(invitation: Invitation): string {
|
||||||
|
const commits = invitation.data.commits || [];
|
||||||
|
if (commits.length === 0) return 'created';
|
||||||
|
|
||||||
|
// Check if invitation has been signed (has signatures)
|
||||||
|
const hasSig = commits.some(c => c.signature);
|
||||||
|
if (hasSig) return 'signed';
|
||||||
|
|
||||||
|
// Check if invitation has inputs/outputs
|
||||||
|
const hasInputs = commits.some(c => c.data?.inputs && c.data.inputs.length > 0);
|
||||||
|
const hasOutputs = commits.some(c => c.data?.outputs && c.data.outputs.length > 0);
|
||||||
|
|
||||||
|
if (hasInputs || hasOutputs) return 'pending';
|
||||||
|
|
||||||
|
return 'published';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get color for invitation state.
|
* Get color for invitation state.
|
||||||
*/
|
*/
|
||||||
function getStateColor(state: InvitationState): string {
|
function getStateColor(state: string): string {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case 'created':
|
case 'created':
|
||||||
case 'published':
|
case 'published':
|
||||||
@@ -52,7 +74,6 @@ const actionItems = [
|
|||||||
{ label: 'Sign Transaction', value: 'sign' },
|
{ label: 'Sign Transaction', value: 'sign' },
|
||||||
{ label: 'View Transaction', value: 'transaction' },
|
{ label: 'View Transaction', value: 'transaction' },
|
||||||
{ label: 'Copy Invitation ID', value: 'copy' },
|
{ label: 'Copy Invitation ID', value: 'copy' },
|
||||||
{ label: 'Refresh', value: 'refresh' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,11 +81,13 @@ const actionItems = [
|
|||||||
*/
|
*/
|
||||||
export function InvitationScreen(): React.ReactElement {
|
export function InvitationScreen(): React.ReactElement {
|
||||||
const { navigate, data: navData } = useNavigation();
|
const { navigate, data: navData } = useNavigation();
|
||||||
const { walletController, invitationController, showError, showInfo } = useAppContext();
|
const { appService, showError, showInfo } = useAppContext();
|
||||||
const { setStatus } = useStatus();
|
const { setStatus } = useStatus();
|
||||||
|
|
||||||
|
// Use hooks for reactive invitation list
|
||||||
|
const invitations = useInvitations();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [invitations, setInvitations] = useState<TrackedInvitation[]>([]);
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
||||||
const [focusedPanel, setFocusedPanel] = useState<'list' | 'details' | 'actions'>('list');
|
const [focusedPanel, setFocusedPanel] = useState<'list' | 'details' | 'actions'>('list');
|
||||||
@@ -75,37 +98,13 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
const initialMode = navData.mode as string | undefined;
|
const initialMode = navData.mode as string | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load invitations.
|
* Show import dialog on mount if needed.
|
||||||
*/
|
|
||||||
const loadInvitations = useCallback(() => {
|
|
||||||
const tracked = invitationController.getAllInvitations();
|
|
||||||
setInvitations(tracked);
|
|
||||||
}, [invitationController]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set up event listeners and initial load.
|
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadInvitations();
|
|
||||||
|
|
||||||
// Listen for updates
|
|
||||||
const handleUpdate = () => {
|
|
||||||
loadInvitations();
|
|
||||||
};
|
|
||||||
|
|
||||||
invitationController.on('invitation-updated', handleUpdate);
|
|
||||||
invitationController.on('invitation-state-changed', handleUpdate);
|
|
||||||
|
|
||||||
// Show import dialog if mode is 'import'
|
|
||||||
if (initialMode === 'import') {
|
if (initialMode === 'import') {
|
||||||
setShowImportDialog(true);
|
setShowImportDialog(true);
|
||||||
}
|
}
|
||||||
|
}, [initialMode]);
|
||||||
return () => {
|
|
||||||
invitationController.off('invitation-updated', handleUpdate);
|
|
||||||
invitationController.off('invitation-state-changed', handleUpdate);
|
|
||||||
};
|
|
||||||
}, [invitationController, loadInvitations, initialMode]);
|
|
||||||
|
|
||||||
// Get selected invitation
|
// Get selected invitation
|
||||||
const selectedInvitation = invitations[selectedIndex];
|
const selectedInvitation = invitations[selectedIndex];
|
||||||
@@ -115,24 +114,23 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
*/
|
*/
|
||||||
const importInvitation = useCallback(async (invitationId: string) => {
|
const importInvitation = useCallback(async (invitationId: string) => {
|
||||||
setShowImportDialog(false);
|
setShowImportDialog(false);
|
||||||
if (!invitationId.trim()) return;
|
if (!invitationId.trim() || !appService) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setStatus('Importing invitation...');
|
setStatus('Importing invitation...');
|
||||||
|
|
||||||
const tracked = await invitationController.importInvitation(invitationId);
|
// Create invitation instance (will fetch from sync server)
|
||||||
await invitationController.publishAndSubscribe(tracked.invitation.invitationIdentifier);
|
const invitation = await appService.createInvitation(invitationId);
|
||||||
|
|
||||||
loadInvitations();
|
showInfo(`Invitation imported!\n\nTemplate: ${invitation.data.templateIdentifier}\nAction: ${invitation.data.actionIdentifier}`);
|
||||||
showInfo(`Invitation imported!\n\nTemplate: ${tracked.invitation.templateIdentifier}\nAction: ${tracked.invitation.actionIdentifier}`);
|
|
||||||
setStatus('Ready');
|
setStatus('Ready');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Failed to import: ${error instanceof Error ? error.message : String(error)}`);
|
showError(`Failed to import: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [invitationController, loadInvitations, showInfo, showError, setStatus]);
|
}, [appService, showInfo, showError, setStatus]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accept selected invitation.
|
* Accept selected invitation.
|
||||||
@@ -147,8 +145,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setStatus('Accepting invitation...');
|
setStatus('Accepting invitation...');
|
||||||
|
|
||||||
await invitationController.acceptInvitation(selectedInvitation.invitation.invitationIdentifier);
|
await selectedInvitation.accept();
|
||||||
loadInvitations();
|
|
||||||
showInfo('Invitation accepted! You are now a participant.\n\nNext step: Use "Fill Requirements" to add your UTXOs.');
|
showInfo('Invitation accepted! You are now a participant.\n\nNext step: Use "Fill Requirements" to add your UTXOs.');
|
||||||
setStatus('Ready');
|
setStatus('Ready');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -162,7 +159,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [selectedInvitation, invitationController, loadInvitations, showInfo, showError, setStatus]);
|
}, [selectedInvitation, showInfo, showError, setStatus]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign selected invitation.
|
* Sign selected invitation.
|
||||||
@@ -177,8 +174,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setStatus('Signing invitation...');
|
setStatus('Signing invitation...');
|
||||||
|
|
||||||
await invitationController.signInvitation(selectedInvitation.invitation.invitationIdentifier);
|
await selectedInvitation.sign();
|
||||||
loadInvitations();
|
|
||||||
showInfo('Invitation signed!');
|
showInfo('Invitation signed!');
|
||||||
setStatus('Ready');
|
setStatus('Ready');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -186,7 +182,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [selectedInvitation, invitationController, loadInvitations, showInfo, showError, setStatus]);
|
}, [selectedInvitation, showInfo, showError, setStatus]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy invitation ID.
|
* Copy invitation ID.
|
||||||
@@ -198,8 +194,8 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await copyToClipboard(selectedInvitation.invitation.invitationIdentifier);
|
await copyToClipboard(selectedInvitation.data.invitationIdentifier);
|
||||||
showInfo(`Copied!\n\n${selectedInvitation.invitation.invitationIdentifier}`);
|
showInfo(`Copied!\n\n${selectedInvitation.data.invitationIdentifier}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Failed to copy: ${error instanceof Error ? error.message : String(error)}`);
|
showError(`Failed to copy: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
}
|
}
|
||||||
@@ -219,14 +215,12 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const invId = selectedInvitation.invitation.invitationIdentifier;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
// Step 1: Check available roles
|
// Step 1: Check available roles
|
||||||
setStatus('Checking available roles...');
|
setStatus('Checking available roles...');
|
||||||
const availableRoles = await invitationController.getAvailableRoles(invId);
|
const availableRoles = await selectedInvitation.getAvailableRoles();
|
||||||
|
|
||||||
if (availableRoles.length === 0) {
|
if (availableRoles.length === 0) {
|
||||||
// Already participating, check if we can add inputs
|
// Already participating, check if we can add inputs
|
||||||
@@ -240,7 +234,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
setStatus(`Accepting as ${roleToTake}...`);
|
setStatus(`Accepting as ${roleToTake}...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await invitationController.acceptInvitation(invId);
|
await selectedInvitation.accept();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(`Failed to accept role: ${e instanceof Error ? e.message : String(e)}`);
|
showError(`Failed to accept role: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
setStatus('Ready');
|
setStatus('Ready');
|
||||||
@@ -250,17 +244,11 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
|
|
||||||
// Step 2: Check if invitation already has inputs or needs funding
|
// Step 2: Check if invitation already has inputs or needs funding
|
||||||
setStatus('Analyzing invitation...');
|
setStatus('Analyzing invitation...');
|
||||||
|
|
||||||
// Get the tracked invitation with updated state
|
|
||||||
const tracked = invitationController.getInvitation(invId);
|
|
||||||
if (!tracked) {
|
|
||||||
throw new Error('Invitation not found after accepting');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate how much we need
|
// Calculate how much we need
|
||||||
// Look for a requestedSatoshis variable in the invitation
|
// Look for a requestedSatoshis variable in the invitation
|
||||||
let requiredAmount = 0n;
|
let requiredAmount = 0n;
|
||||||
const commits = tracked.invitation.commits || [];
|
const commits = selectedInvitation.data.commits || [];
|
||||||
for (const commit of commits) {
|
for (const commit of commits) {
|
||||||
const variables = commit.data?.variables || [];
|
const variables = commit.data?.variables || [];
|
||||||
for (const variable of variables) {
|
for (const variable of variables) {
|
||||||
@@ -276,17 +264,11 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
const dust = 546n; // Dust threshold
|
const dust = 546n; // Dust threshold
|
||||||
const totalNeeded = requiredAmount + fee + dust;
|
const totalNeeded = requiredAmount + fee + dust;
|
||||||
|
|
||||||
|
|
||||||
// Find resources - use a common output identifier
|
// Find resources - use a common output identifier
|
||||||
const resources = await walletController.findSuitableResources(
|
const utxos = await selectedInvitation.findSuitableResources({
|
||||||
tracked.invitation,
|
templateIdentifier: selectedInvitation.data.templateIdentifier,
|
||||||
{
|
outputIdentifier: 'receiveOutput', // Try common identifier
|
||||||
templateIdentifier: tracked.invitation.templateIdentifier,
|
});
|
||||||
outputIdentifier: 'receiveOutput', // Try common identifier
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const utxos = (resources as any)?.unspentOutputs || [];
|
|
||||||
|
|
||||||
if (utxos.length === 0) {
|
if (utxos.length === 0) {
|
||||||
showError('No suitable UTXOs found. Make sure your wallet has funds.');
|
showError('No suitable UTXOs found. Make sure your wallet has funds.');
|
||||||
@@ -342,10 +324,9 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
|
|
||||||
// Step 6: Add inputs to the invitation
|
// Step 6: Add inputs to the invitation
|
||||||
setStatus('Adding inputs...');
|
setStatus('Adding inputs...');
|
||||||
await invitationController.addInputs(
|
await selectedInvitation.addInputs(
|
||||||
invId,
|
|
||||||
selectedUtxos.map(u => ({
|
selectedUtxos.map(u => ({
|
||||||
outpointTransactionHash: u.outpointTransactionHash,
|
outpointTransactionHash: new Uint8Array(Buffer.from(u.outpointTransactionHash, 'hex')),
|
||||||
outpointIndex: u.outpointIndex,
|
outpointIndex: u.outpointIndex,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
@@ -353,13 +334,12 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
// Step 7: Add change output
|
// Step 7: Add change output
|
||||||
if (changeAmount >= dust) {
|
if (changeAmount >= dust) {
|
||||||
setStatus('Adding change output...');
|
setStatus('Adding change output...');
|
||||||
await invitationController.addOutputs(invId, [{
|
await selectedInvitation.addOutputs([{
|
||||||
valueSatoshis: changeAmount,
|
valueSatoshis: changeAmount,
|
||||||
}]);
|
}]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload and show success
|
// Show success
|
||||||
loadInvitations();
|
|
||||||
showInfo(
|
showInfo(
|
||||||
`Requirements filled!\n\n` +
|
`Requirements filled!\n\n` +
|
||||||
`• Selected ${selectedUtxos.length} UTXO(s)\n` +
|
`• Selected ${selectedUtxos.length} UTXO(s)\n` +
|
||||||
@@ -377,7 +357,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [selectedInvitation, invitationController, walletController, loadInvitations, showInfo, showError, setStatus]);
|
}, [selectedInvitation, showInfo, showError, setStatus]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle action selection.
|
* Handle action selection.
|
||||||
@@ -401,14 +381,11 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
break;
|
break;
|
||||||
case 'transaction':
|
case 'transaction':
|
||||||
if (selectedInvitation) {
|
if (selectedInvitation) {
|
||||||
navigate('transaction', { invitationId: selectedInvitation.invitation.invitationIdentifier });
|
navigate('transaction', { invitationId: selectedInvitation.data.invitationIdentifier });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'refresh':
|
|
||||||
loadInvitations();
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, navigate, loadInvitations]);
|
}, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, navigate]);
|
||||||
|
|
||||||
// Handle keyboard navigation
|
// Handle keyboard navigation
|
||||||
useInput((input, key) => {
|
useInput((input, key) => {
|
||||||
@@ -482,17 +459,20 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
{invitations.length === 0 ? (
|
{invitations.length === 0 ? (
|
||||||
<Text color={colors.textMuted}>No invitations</Text>
|
<Text color={colors.textMuted}>No invitations</Text>
|
||||||
) : (
|
) : (
|
||||||
invitations.map((inv, index) => (
|
invitations.map((inv, index) => {
|
||||||
<Text
|
const state = getInvitationState(inv);
|
||||||
key={inv.invitation.invitationIdentifier}
|
return (
|
||||||
color={index === selectedIndex ? colors.focus : colors.text}
|
<Text
|
||||||
bold={index === selectedIndex}
|
key={inv.data.invitationIdentifier}
|
||||||
>
|
color={index === selectedIndex ? colors.focus : colors.text}
|
||||||
{index === selectedIndex && focusedPanel === 'list' ? '▸ ' : ' '}
|
bold={index === selectedIndex}
|
||||||
<Text color={getStateColor(inv.state)}>[{inv.state}]</Text>
|
>
|
||||||
{' '}{formatHex(inv.invitation.invitationIdentifier, 12)}
|
{index === selectedIndex && focusedPanel === 'list' ? '▸ ' : ' '}
|
||||||
</Text>
|
<Text color={getStateColor(state)}>[{state}]</Text>
|
||||||
))
|
{' '}{formatHex(inv.data.invitationIdentifier, 12)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -511,61 +491,68 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection="column">
|
||||||
{selectedInvitation ? (
|
{selectedInvitation ? (
|
||||||
<>
|
<>
|
||||||
<Text color={colors.text}>ID: {formatHex(selectedInvitation.invitation.invitationIdentifier, 20)}</Text>
|
{(() => {
|
||||||
<Text color={colors.text}>
|
const state = getInvitationState(selectedInvitation);
|
||||||
State: <Text color={getStateColor(selectedInvitation.state)}>{selectedInvitation.state}</Text>
|
return (
|
||||||
</Text>
|
|
||||||
<Text color={colors.textMuted}>
|
|
||||||
Template: {selectedInvitation.invitation.templateIdentifier?.slice(0, 20)}...
|
|
||||||
</Text>
|
|
||||||
<Text color={colors.textMuted}>
|
|
||||||
Action: {selectedInvitation.invitation.actionIdentifier}
|
|
||||||
</Text>
|
|
||||||
<Text color={colors.textMuted}>
|
|
||||||
Commits: {selectedInvitation.invitation.commits?.length ?? 0}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* State-specific guidance */}
|
|
||||||
<Box marginTop={1} flexDirection="column">
|
|
||||||
{selectedInvitation.state === 'created' && (
|
|
||||||
<Text color={colors.info}>→ Share this ID with the other party</Text>
|
|
||||||
)}
|
|
||||||
{selectedInvitation.state === 'published' && (
|
|
||||||
<Text color={colors.info}>→ Waiting for other party to join...</Text>
|
|
||||||
)}
|
|
||||||
{selectedInvitation.state === 'pending' && (
|
|
||||||
<>
|
<>
|
||||||
<Text color={colors.warning}>→ Action needed!</Text>
|
<Text color={colors.text}>ID: {formatHex(selectedInvitation.data.invitationIdentifier, 20)}</Text>
|
||||||
<Text color={colors.warning}> Use "Fill Requirements" to add</Text>
|
<Text color={colors.text}>
|
||||||
<Text color={colors.warning}> your UTXOs and complete your part</Text>
|
State: <Text color={getStateColor(state)}>{state}</Text>
|
||||||
|
</Text>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Template: {selectedInvitation.data.templateIdentifier?.slice(0, 20)}...
|
||||||
|
</Text>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Action: {selectedInvitation.data.actionIdentifier}
|
||||||
|
</Text>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Commits: {selectedInvitation.data.commits?.length ?? 0}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* State-specific guidance */}
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
{state === 'created' && (
|
||||||
|
<Text color={colors.info}>→ Share this ID with the other party</Text>
|
||||||
|
)}
|
||||||
|
{state === 'published' && (
|
||||||
|
<Text color={colors.info}>→ Waiting for other party to join...</Text>
|
||||||
|
)}
|
||||||
|
{state === 'pending' && (
|
||||||
|
<>
|
||||||
|
<Text color={colors.warning}>→ Action needed!</Text>
|
||||||
|
<Text color={colors.warning}> Use "Fill Requirements" to add</Text>
|
||||||
|
<Text color={colors.warning}> your UTXOs and complete your part</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{state === 'ready' && (
|
||||||
|
<>
|
||||||
|
<Text color={colors.success}>→ Ready to sign!</Text>
|
||||||
|
<Text color={colors.success}> Use "Sign Transaction"</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{state === 'signed' && (
|
||||||
|
<>
|
||||||
|
<Text color={colors.success}>→ Signed!</Text>
|
||||||
|
<Text color={colors.success}> View Transaction to broadcast</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{state === 'broadcast' && (
|
||||||
|
<Text color={colors.success}>→ Transaction broadcast! Waiting for confirmation...</Text>
|
||||||
|
)}
|
||||||
|
{state === 'completed' && (
|
||||||
|
<Text color={colors.success}>✓ Transaction completed!</Text>
|
||||||
|
)}
|
||||||
|
{state === 'error' && (
|
||||||
|
<Text color={colors.error}>✗ Error - check logs</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted} dimColor>c: Copy ID</Text>
|
||||||
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
);
|
||||||
{selectedInvitation.state === 'ready' && (
|
})()}
|
||||||
<>
|
|
||||||
<Text color={colors.success}>→ Ready to sign!</Text>
|
|
||||||
<Text color={colors.success}> Use "Sign Transaction"</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{selectedInvitation.state === 'signed' && (
|
|
||||||
<>
|
|
||||||
<Text color={colors.success}>→ Signed!</Text>
|
|
||||||
<Text color={colors.success}> View Transaction to broadcast</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{selectedInvitation.state === 'broadcast' && (
|
|
||||||
<Text color={colors.success}>→ Transaction broadcast! Waiting for confirmation...</Text>
|
|
||||||
)}
|
|
||||||
{selectedInvitation.state === 'completed' && (
|
|
||||||
<Text color={colors.success}>✓ Transaction completed!</Text>
|
|
||||||
)}
|
|
||||||
{selectedInvitation.state === 'error' && (
|
|
||||||
<Text color={colors.error}>✗ Error - check logs</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text color={colors.textMuted} dimColor>c: Copy ID</Text>
|
|
||||||
</Box>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Text color={colors.textMuted}>Select an invitation</Text>
|
<Text color={colors.textMuted}>Select an invitation</Text>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type StatusType = 'idle' | 'loading' | 'error' | 'success';
|
|||||||
*/
|
*/
|
||||||
export function SeedInputScreen(): React.ReactElement {
|
export function SeedInputScreen(): React.ReactElement {
|
||||||
const { navigate } = useNavigation();
|
const { navigate } = useNavigation();
|
||||||
const { walletController, showError, setWalletInitialized } = useAppContext();
|
const { initializeWallet } = useAppContext();
|
||||||
const { setStatus } = useStatus();
|
const { setStatus } = useStatus();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
@@ -65,12 +65,11 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Initialize wallet via controller
|
// Initialize wallet and create AppService
|
||||||
await walletController.initialize(seed);
|
await initializeWallet(seed);
|
||||||
|
|
||||||
showStatus('Wallet initialized successfully!', 'success');
|
showStatus('Wallet initialized successfully!', 'success');
|
||||||
setStatus('Wallet ready');
|
setStatus('Wallet ready');
|
||||||
setWalletInitialized(true);
|
|
||||||
|
|
||||||
// Clear sensitive data before navigating
|
// Clear sensitive data before navigating
|
||||||
setSeedPhrase('');
|
setSeedPhrase('');
|
||||||
@@ -86,7 +85,7 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
setStatus('Initialization failed');
|
setStatus('Initialization failed');
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [seedPhrase, walletController, navigate, showStatus, setStatus, setWalletInitialized]);
|
}, [seedPhrase, initializeWallet, navigate, showStatus, setStatus]);
|
||||||
|
|
||||||
// Handle keyboard navigation
|
// Handle keyboard navigation
|
||||||
useInput((input, key) => {
|
useInput((input, key) => {
|
||||||
@@ -116,7 +115,7 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
colors.border;
|
colors.border;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" alignItems="center" paddingY={1}>
|
<Box flexDirection='column' alignItems='center' paddingY={1}>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Box marginBottom={1}>
|
<Box marginBottom={1}>
|
||||||
<Text color={colors.primary}>{logo}</Text>
|
<Text color={colors.primary}>{logo}</Text>
|
||||||
@@ -130,10 +129,10 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
<Box marginY={1} />
|
<Box marginY={1} />
|
||||||
|
|
||||||
{/* Input section */}
|
{/* Input section */}
|
||||||
<Box flexDirection="column" width={64}>
|
<Box flexDirection='column' width={64}>
|
||||||
<Text color={colors.text} bold>Seed Phrase (12 or 24 words):</Text>
|
<Text color={colors.text} bold>Seed Phrase (12 or 24 words):</Text>
|
||||||
<Box
|
<Box
|
||||||
borderStyle="single"
|
borderStyle='single'
|
||||||
borderColor={inputBorderColor}
|
borderColor={inputBorderColor}
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
marginTop={1}
|
marginTop={1}
|
||||||
@@ -142,7 +141,7 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
value={seedPhrase}
|
value={seedPhrase}
|
||||||
onChange={setSeedPhrase}
|
onChange={setSeedPhrase}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
placeholder="Enter your seed phrase..."
|
placeholder='Enter your seed phrase...'
|
||||||
focus={focusedElement === 'input' && !isSubmitting}
|
focus={focusedElement === 'input' && !isSubmitting}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -160,12 +159,12 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Submit button */}
|
{/* Submit button */}
|
||||||
<Box justifyContent="center" marginTop={1}>
|
<Box justifyContent='center' marginTop={1}>
|
||||||
<Button
|
<Button
|
||||||
label="Continue"
|
label='Continue'
|
||||||
focused={focusedElement === 'button'}
|
focused={focusedElement === 'button'}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
shortcut="Enter"
|
shortcut='Enter'
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import { Box, Text, useInput } from 'ink';
|
|||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
import { useNavigation } from '../hooks/useNavigation.js';
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||||
import { colors, logoSmall } from '../theme.js';
|
import { colors, logoSmall } from '../theme.js';
|
||||||
import type { XOTemplate, XOTemplateStartingActions } from '@xo-cash/types';
|
|
||||||
|
// XO Imports
|
||||||
|
import { generateTemplateIdentifier } from '@xo-cash/engine';
|
||||||
|
import type { XOTemplate, XOTemplateActionRoleRequirement, XOTemplateStartingActions } from '@xo-cash/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Template item with metadata.
|
* Template item with metadata.
|
||||||
@@ -28,7 +31,7 @@ interface TemplateItem {
|
|||||||
*/
|
*/
|
||||||
export function TemplateListScreen(): React.ReactElement {
|
export function TemplateListScreen(): React.ReactElement {
|
||||||
const { navigate } = useNavigation();
|
const { navigate } = useNavigation();
|
||||||
const { walletController, showError } = useAppContext();
|
const { appService, showError } = useAppContext();
|
||||||
const { setStatus } = useStatus();
|
const { setStatus } = useStatus();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
@@ -39,20 +42,24 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads templates from the wallet controller.
|
* Loads templates from the engine.
|
||||||
*/
|
*/
|
||||||
const loadTemplates = useCallback(async () => {
|
const loadTemplates = useCallback(async () => {
|
||||||
|
if (!appService) {
|
||||||
|
showError('AppService not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setStatus('Loading templates...');
|
setStatus('Loading templates...');
|
||||||
|
|
||||||
const templateList = await walletController.getTemplates();
|
const templateList = await appService.engine.listImportedTemplates();
|
||||||
const { generateTemplateIdentifier } = await import('@xo-cash/engine');
|
|
||||||
|
|
||||||
const loadedTemplates = await Promise.all(
|
const loadedTemplates = await Promise.all(
|
||||||
templateList.map(async (template) => {
|
templateList.map(async (template) => {
|
||||||
const templateIdentifier = generateTemplateIdentifier(template);
|
const templateIdentifier = generateTemplateIdentifier(template);
|
||||||
const startingActions = await walletController.getStartingActions(templateIdentifier);
|
const startingActions = await appService.engine.listStartingActions(templateIdentifier);
|
||||||
return { template, templateIdentifier, startingActions };
|
return { template, templateIdentifier, startingActions };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -66,7 +73,7 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
showError(`Failed to load templates: ${error instanceof Error ? error.message : String(error)}`);
|
showError(`Failed to load templates: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [walletController, setStatus, showError]);
|
}, [appService, setStatus, showError]);
|
||||||
|
|
||||||
// Load templates on mount
|
// Load templates on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -127,31 +134,39 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" flexGrow={1}>
|
<Box flexDirection='column' flexGrow={1}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1}>
|
<Box borderStyle='single' borderColor={colors.secondary} paddingX={1}>
|
||||||
<Text color={colors.primary} bold>{logoSmall} - Select Template & Action</Text>
|
<Text color={colors.primary} bold>{logoSmall} - Select Template & Action</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Main content - two columns */}
|
{/* Main content - two columns */}
|
||||||
<Box flexDirection="row" marginTop={1} flexGrow={1}>
|
<Box flexDirection='row' marginTop={1} flexGrow={1}>
|
||||||
{/* Left column: Template list */}
|
{/* Left column: Template list */}
|
||||||
<Box flexDirection="column" width="40%" paddingRight={1}>
|
<Box flexDirection='column' width='40%' paddingRight={1}>
|
||||||
<Box
|
<Box
|
||||||
borderStyle="single"
|
borderStyle='single'
|
||||||
borderColor={focusedPanel === 'templates' ? colors.focus : colors.primary}
|
borderColor={focusedPanel === 'templates' ? colors.focus : colors.primary}
|
||||||
flexDirection="column"
|
flexDirection='column'
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
flexGrow={1}
|
flexGrow={1}
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Templates </Text>
|
<Text color={colors.primary} bold> Templates </Text>
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection='column'>
|
||||||
{isLoading ? (
|
|
||||||
<Text color={colors.textMuted}>Loading...</Text>
|
{(() => {
|
||||||
) : templates.length === 0 ? (
|
// Loading State
|
||||||
<Text color={colors.textMuted}>No templates imported</Text>
|
if (isLoading) {
|
||||||
) : (
|
return <Text color={colors.textMuted}>Loading...</Text>;
|
||||||
templates.map((item, index) => (
|
}
|
||||||
|
|
||||||
|
// No templates state
|
||||||
|
if (templates.length === 0) {
|
||||||
|
return <Text color={colors.textMuted}>No templates imported</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Templates state
|
||||||
|
return templates.map((item, index) => (
|
||||||
<Text
|
<Text
|
||||||
key={item.templateIdentifier}
|
key={item.templateIdentifier}
|
||||||
color={index === selectedTemplateIndex ? colors.focus : colors.text}
|
color={index === selectedTemplateIndex ? colors.focus : colors.text}
|
||||||
@@ -160,29 +175,43 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
{index === selectedTemplateIndex && focusedPanel === 'templates' ? '▸ ' : ' '}
|
{index === selectedTemplateIndex && focusedPanel === 'templates' ? '▸ ' : ' '}
|
||||||
{index + 1}. {item.template.name || 'Unnamed Template'}
|
{index + 1}. {item.template.name || 'Unnamed Template'}
|
||||||
</Text>
|
</Text>
|
||||||
))
|
));
|
||||||
)}
|
})()}
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Right column: Actions list */}
|
{/* Right column: Actions list */}
|
||||||
<Box flexDirection="column" width="60%" paddingLeft={1}>
|
<Box flexDirection='column' width='60%' paddingLeft={1}>
|
||||||
<Box
|
<Box
|
||||||
borderStyle="single"
|
borderStyle='single'
|
||||||
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
|
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
|
||||||
flexDirection="column"
|
flexDirection='column'
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
flexGrow={1}
|
flexGrow={1}
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Starting Actions </Text>
|
<Text color={colors.primary} bold> Starting Actions </Text>
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection='column'>
|
||||||
{!currentTemplate ? (
|
|
||||||
<Text color={colors.textMuted}>Select a template...</Text>
|
{(() => {
|
||||||
) : currentActions.length === 0 ? (
|
// Loading state
|
||||||
<Text color={colors.textMuted}>No starting actions available</Text>
|
if (isLoading) {
|
||||||
) : (
|
return <Text color={colors.textMuted}>Loading...</Text>;
|
||||||
currentActions.map((action, index) => {
|
}
|
||||||
|
|
||||||
|
// No template selected state
|
||||||
|
if (!currentTemplate) {
|
||||||
|
return <Text color={colors.textMuted}>Select a template...</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No starting actions state
|
||||||
|
if (currentActions.length === 0) {
|
||||||
|
return <Text color={colors.textMuted}>No starting actions available</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starting actions state
|
||||||
|
return currentActions.map((action, index) => {
|
||||||
const actionDef = currentTemplate.template.actions?.[action.action];
|
const actionDef = currentTemplate.template.actions?.[action.action];
|
||||||
const name = actionDef?.name || action.action;
|
const name = actionDef?.name || action.action;
|
||||||
return (
|
return (
|
||||||
@@ -195,8 +224,9 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
{index + 1}. {name} (as {action.role})
|
{index + 1}. {name} (as {action.role})
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
})
|
});
|
||||||
)}
|
})()}
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -205,16 +235,18 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
{/* Description box */}
|
{/* Description box */}
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Box
|
<Box
|
||||||
borderStyle="single"
|
borderStyle='single'
|
||||||
borderColor={colors.border}
|
borderColor={colors.border}
|
||||||
flexDirection="column"
|
flexDirection='column'
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
paddingY={1}
|
paddingY={1}
|
||||||
width="100%"
|
width='100%'
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Description </Text>
|
<Text color={colors.primary} bold> Description </Text>
|
||||||
{currentTemplate ? (
|
|
||||||
<Box marginTop={1} flexDirection="column">
|
{/* Show template description when templates panel is focused */}
|
||||||
|
{focusedPanel === 'templates' && currentTemplate ? (
|
||||||
|
<Box marginTop={1} flexDirection='column'>
|
||||||
<Text color={colors.text} bold>
|
<Text color={colors.text} bold>
|
||||||
{currentTemplate.template.name || 'Unnamed Template'}
|
{currentTemplate.template.name || 'Unnamed Template'}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -227,7 +259,7 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{currentTemplate.template.roles && (
|
{currentTemplate.template.roles && (
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection='column'>
|
||||||
<Text color={colors.text}>Roles:</Text>
|
<Text color={colors.text}>Roles:</Text>
|
||||||
{Object.entries(currentTemplate.template.roles).map(([roleId, role]) => (
|
{Object.entries(currentTemplate.template.roles).map(([roleId, role]) => (
|
||||||
<Text key={roleId} color={colors.textMuted}>
|
<Text key={roleId} color={colors.textMuted}>
|
||||||
@@ -237,9 +269,70 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : focusedPanel === 'templates' && !currentTemplate ? (
|
||||||
<Text color={colors.textMuted}>Select a template to see details</Text>
|
<Text color={colors.textMuted}>Select a template to see details</Text>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
|
{/* Show action description when actions panel is focused */}
|
||||||
|
{focusedPanel === 'actions' && currentTemplate && currentActions.length > 0 ? (
|
||||||
|
<Box marginTop={1} flexDirection='column'>
|
||||||
|
{(() => {
|
||||||
|
const action = currentActions[selectedActionIndex];
|
||||||
|
if (!action) return null;
|
||||||
|
|
||||||
|
const actionDef = currentTemplate.template.actions?.[action.action];
|
||||||
|
const roleDef = currentTemplate.template.roles?.[action.role];
|
||||||
|
|
||||||
|
// if (!actionDef || !roleDef) return null;
|
||||||
|
|
||||||
|
const [_roleName, role] = Object.entries(actionDef?.roles ?? {}).find(([roleId, role]) => roleId === action.role) || [];
|
||||||
|
|
||||||
|
console.log(JSON.stringify(role, null, 2));
|
||||||
|
|
||||||
|
const variableKeys = role?.requirements?.variables || []
|
||||||
|
|
||||||
|
console.log('variables', variableKeys);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text color={colors.text} bold>
|
||||||
|
{actionDef?.name || action.action}
|
||||||
|
</Text>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{actionDef?.description || 'No description available'}
|
||||||
|
</Text>
|
||||||
|
<Box marginTop={1} flexDirection='column'>
|
||||||
|
<Text color={colors.text}>
|
||||||
|
Role: {roleDef?.name || action.role}
|
||||||
|
</Text>
|
||||||
|
{roleDef?.description && (
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{' '}{roleDef.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{/* Display variables if available */}
|
||||||
|
{
|
||||||
|
variableKeys.length > 0 && (
|
||||||
|
<Box marginTop={1} flexDirection='column'>
|
||||||
|
<Text color={colors.text}>Variables:</Text>
|
||||||
|
{variableKeys.map((variableKey) => (
|
||||||
|
<Text key={variableKey} color={colors.text}>
|
||||||
|
- {currentTemplate.template.variables?.[variableKey]?.name || variableKey}: {currentTemplate.template.variables?.[variableKey]?.description || 'No description'}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Box>
|
||||||
|
) : focusedPanel === 'actions' && !currentTemplate ? (
|
||||||
|
<Text color={colors.textMuted}>Select a template first</Text>
|
||||||
|
) : focusedPanel === 'actions' && currentActions.length === 0 ? (
|
||||||
|
<Text color={colors.textMuted}>No starting actions available</Text>
|
||||||
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Box, Text, useInput } from 'ink';
|
|||||||
import { ConfirmDialog } from '../components/Dialog.js';
|
import { ConfirmDialog } from '../components/Dialog.js';
|
||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
import { useNavigation } from '../hooks/useNavigation.js';
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||||
|
import { useInvitation } from '../hooks/useInvitations.js';
|
||||||
import { colors, logoSmall, formatSatoshis, formatHex } from '../theme.js';
|
import { colors, logoSmall, formatSatoshis, formatHex } from '../theme.js';
|
||||||
import { copyToClipboard } from '../utils/clipboard.js';
|
import { copyToClipboard } from '../utils/clipboard.js';
|
||||||
import type { XOInvitation } from '@xo-cash/types';
|
import type { XOInvitation } from '@xo-cash/types';
|
||||||
@@ -32,59 +33,51 @@ const actionItems = [
|
|||||||
*/
|
*/
|
||||||
export function TransactionScreen(): React.ReactElement {
|
export function TransactionScreen(): React.ReactElement {
|
||||||
const { navigate, goBack, data: navData } = useNavigation();
|
const { navigate, goBack, data: navData } = useNavigation();
|
||||||
const { invitationController, showError, showInfo, confirm } = useAppContext();
|
const { showError, showInfo } = useAppContext();
|
||||||
const { setStatus } = useStatus();
|
const { setStatus } = useStatus();
|
||||||
|
|
||||||
// Extract invitation ID from navigation data
|
// Extract invitation ID from navigation data
|
||||||
const invitationId = navData.invitationId as string | undefined;
|
const invitationId = navData.invitationId as string | undefined;
|
||||||
|
|
||||||
|
// Use hook to get invitation reactively
|
||||||
|
const invitationInstance = useInvitation(invitationId ?? null);
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [invitation, setInvitation] = useState<XOInvitation | null>(null);
|
|
||||||
const [focusedPanel, setFocusedPanel] = useState<'inputs' | 'outputs' | 'actions'>('actions');
|
const [focusedPanel, setFocusedPanel] = useState<'inputs' | 'outputs' | 'actions'>('actions');
|
||||||
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [showBroadcastConfirm, setShowBroadcastConfirm] = useState(false);
|
const [showBroadcastConfirm, setShowBroadcastConfirm] = useState(false);
|
||||||
|
|
||||||
/**
|
// Check if invitation exists
|
||||||
* Load invitation data.
|
useEffect(() => {
|
||||||
*/
|
|
||||||
const loadInvitation = useCallback(() => {
|
|
||||||
if (!invitationId) {
|
if (!invitationId) {
|
||||||
showError('No invitation ID provided');
|
showError('No invitation ID provided');
|
||||||
goBack();
|
goBack();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tracked = invitationController.getInvitation(invitationId);
|
if (invitationId && !invitationInstance) {
|
||||||
if (!tracked) {
|
|
||||||
showError('Invitation not found');
|
showError('Invitation not found');
|
||||||
goBack();
|
goBack();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}, [invitationId, invitationInstance, showError, goBack]);
|
||||||
|
|
||||||
setInvitation(tracked.invitation);
|
const invitation = invitationInstance?.data ?? null;
|
||||||
}, [invitationId, invitationController, showError, goBack]);
|
|
||||||
|
|
||||||
// Load on mount
|
|
||||||
useEffect(() => {
|
|
||||||
loadInvitation();
|
|
||||||
}, [loadInvitation]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Broadcast transaction.
|
* Broadcast transaction.
|
||||||
*/
|
*/
|
||||||
const broadcastTransaction = useCallback(async () => {
|
const broadcastTransaction = useCallback(async () => {
|
||||||
if (!invitationId) return;
|
if (!invitationInstance) return;
|
||||||
|
|
||||||
setShowBroadcastConfirm(false);
|
setShowBroadcastConfirm(false);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setStatus('Broadcasting transaction...');
|
setStatus('Broadcasting transaction...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const txHash = await invitationController.broadcastTransaction(invitationId);
|
await invitationInstance.broadcast();
|
||||||
showInfo(
|
showInfo(
|
||||||
`Transaction Broadcast Successful!\n\n` +
|
`Transaction Broadcast Successful!\n\n` +
|
||||||
`Transaction Hash:\n${txHash}\n\n` +
|
|
||||||
`The transaction has been submitted to the network.`
|
`The transaction has been submitted to the network.`
|
||||||
);
|
);
|
||||||
navigate('wallet');
|
navigate('wallet');
|
||||||
@@ -94,20 +87,19 @@ export function TransactionScreen(): React.ReactElement {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setStatus('Ready');
|
setStatus('Ready');
|
||||||
}
|
}
|
||||||
}, [invitationId, invitationController, showInfo, showError, navigate, setStatus]);
|
}, [invitationInstance, showInfo, showError, navigate, setStatus]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign transaction.
|
* Sign transaction.
|
||||||
*/
|
*/
|
||||||
const signTransaction = useCallback(async () => {
|
const signTransaction = useCallback(async () => {
|
||||||
if (!invitationId) return;
|
if (!invitationInstance) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setStatus('Signing transaction...');
|
setStatus('Signing transaction...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await invitationController.signInvitation(invitationId);
|
await invitationInstance.sign();
|
||||||
loadInvitation();
|
|
||||||
showInfo('Transaction signed successfully!');
|
showInfo('Transaction signed successfully!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Failed to sign: ${error instanceof Error ? error.message : String(error)}`);
|
showError(`Failed to sign: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
@@ -115,7 +107,7 @@ export function TransactionScreen(): React.ReactElement {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setStatus('Ready');
|
setStatus('Ready');
|
||||||
}
|
}
|
||||||
}, [invitationId, invitationController, loadInvitation, showInfo, showError, setStatus]);
|
}, [invitationInstance, showInfo, showError, setStatus]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy transaction hex.
|
* Copy transaction hex.
|
||||||
@@ -273,24 +265,24 @@ export function TransactionScreen(): React.ReactElement {
|
|||||||
const hasUnresolvedInputs = inputs.length > 0; // Input values are always unknown from commit data
|
const hasUnresolvedInputs = inputs.length > 0; // Input values are always unknown from commit data
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" flexGrow={1}>
|
<Box flexDirection='column' flexGrow={1}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1}>
|
<Box borderStyle='single' borderColor={colors.secondary} paddingX={1}>
|
||||||
<Text color={colors.primary} bold>{logoSmall} - Transaction Review</Text>
|
<Text color={colors.primary} bold>{logoSmall} - Transaction Review</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Summary box */}
|
{/* Summary box */}
|
||||||
<Box
|
<Box
|
||||||
borderStyle="single"
|
borderStyle='single'
|
||||||
borderColor={colors.primary}
|
borderColor={colors.primary}
|
||||||
marginTop={1}
|
marginTop={1}
|
||||||
marginX={1}
|
marginX={1}
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
flexDirection="column"
|
flexDirection='column'
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Transaction Summary </Text>
|
<Text color={colors.primary} bold> Transaction Summary </Text>
|
||||||
{invitation ? (
|
{invitation ? (
|
||||||
<Box flexDirection="column" marginTop={1}>
|
<Box flexDirection='column' marginTop={1}>
|
||||||
<Text color={colors.text}>Inputs: {inputs.length} | Outputs: {resolvedOutputs.length} | Commits: {commits.length}</Text>
|
<Text color={colors.text}>Inputs: {inputs.length} | Outputs: {resolvedOutputs.length} | Commits: {commits.length}</Text>
|
||||||
{hasUnresolvedInputs && (
|
{hasUnresolvedInputs && (
|
||||||
<Text color={colors.textMuted}>Total In: (requires UTXO lookup)</Text>
|
<Text color={colors.textMuted}>Total In: (requires UTXO lookup)</Text>
|
||||||
@@ -308,22 +300,22 @@ export function TransactionScreen(): React.ReactElement {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Inputs and Outputs */}
|
{/* Inputs and Outputs */}
|
||||||
<Box flexDirection="row" marginTop={1} marginX={1} flexGrow={1}>
|
<Box flexDirection='row' marginTop={1} marginX={1} flexGrow={1}>
|
||||||
{/* Inputs */}
|
{/* Inputs */}
|
||||||
<Box
|
<Box
|
||||||
borderStyle="single"
|
borderStyle='single'
|
||||||
borderColor={focusedPanel === 'inputs' ? colors.focus : colors.border}
|
borderColor={focusedPanel === 'inputs' ? colors.focus : colors.border}
|
||||||
width="50%"
|
width='50%'
|
||||||
flexDirection="column"
|
flexDirection='column'
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Inputs </Text>
|
<Text color={colors.primary} bold> Inputs </Text>
|
||||||
<Box flexDirection="column" marginTop={1}>
|
<Box flexDirection='column' marginTop={1}>
|
||||||
{inputs.length === 0 ? (
|
{inputs.length === 0 ? (
|
||||||
<Text color={colors.textMuted}>No inputs</Text>
|
<Text color={colors.textMuted}>No inputs</Text>
|
||||||
) : (
|
) : (
|
||||||
inputs.map((input, index) => (
|
inputs.map((input, index) => (
|
||||||
<Box key={`${input.txid}-${input.index}`} flexDirection="column" marginBottom={1}>
|
<Box key={`${input.txid}-${input.index}`} flexDirection='column' marginBottom={1}>
|
||||||
<Text color={colors.text}>
|
<Text color={colors.text}>
|
||||||
{index + 1}. {formatHex(input.txid, 12)}:{input.index}
|
{index + 1}. {formatHex(input.txid, 12)}:{input.index}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -338,20 +330,20 @@ export function TransactionScreen(): React.ReactElement {
|
|||||||
|
|
||||||
{/* Outputs */}
|
{/* Outputs */}
|
||||||
<Box
|
<Box
|
||||||
borderStyle="single"
|
borderStyle='single'
|
||||||
borderColor={focusedPanel === 'outputs' ? colors.focus : colors.border}
|
borderColor={focusedPanel === 'outputs' ? colors.focus : colors.border}
|
||||||
width="50%"
|
width='50%'
|
||||||
flexDirection="column"
|
flexDirection='column'
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
marginLeft={1}
|
marginLeft={1}
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Outputs </Text>
|
<Text color={colors.primary} bold> Outputs </Text>
|
||||||
<Box flexDirection="column" marginTop={1}>
|
<Box flexDirection='column' marginTop={1}>
|
||||||
{resolvedOutputs.length === 0 ? (
|
{resolvedOutputs.length === 0 ? (
|
||||||
<Text color={colors.textMuted}>No outputs</Text>
|
<Text color={colors.textMuted}>No outputs</Text>
|
||||||
) : (
|
) : (
|
||||||
resolvedOutputs.map((output, index) => (
|
resolvedOutputs.map((output, index) => (
|
||||||
<Box key={index} flexDirection="column" marginBottom={1}>
|
<Box key={index} flexDirection='column' marginBottom={1}>
|
||||||
<Text color={colors.text}>
|
<Text color={colors.text}>
|
||||||
{index + 1}. {output.value !== undefined ? formatSatoshis(output.value) : '(pending)'}
|
{index + 1}. {output.value !== undefined ? formatSatoshis(output.value) : '(pending)'}
|
||||||
{output.outputIdentifier && (
|
{output.outputIdentifier && (
|
||||||
@@ -371,15 +363,15 @@ export function TransactionScreen(): React.ReactElement {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<Box
|
<Box
|
||||||
borderStyle="single"
|
borderStyle='single'
|
||||||
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
|
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
|
||||||
marginTop={1}
|
marginTop={1}
|
||||||
marginX={1}
|
marginX={1}
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
flexDirection="column"
|
flexDirection='column'
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Actions </Text>
|
<Text color={colors.primary} bold> Actions </Text>
|
||||||
<Box flexDirection="column" marginTop={1}>
|
<Box flexDirection='column' marginTop={1}>
|
||||||
{actionItems.map((item, index) => (
|
{actionItems.map((item, index) => (
|
||||||
<Text
|
<Text
|
||||||
key={item.value}
|
key={item.value}
|
||||||
@@ -403,16 +395,16 @@ export function TransactionScreen(): React.ReactElement {
|
|||||||
{/* Broadcast confirmation dialog */}
|
{/* Broadcast confirmation dialog */}
|
||||||
{showBroadcastConfirm && (
|
{showBroadcastConfirm && (
|
||||||
<Box
|
<Box
|
||||||
position="absolute"
|
position='absolute'
|
||||||
flexDirection="column"
|
flexDirection='column'
|
||||||
alignItems="center"
|
alignItems='center'
|
||||||
justifyContent="center"
|
justifyContent='center'
|
||||||
width="100%"
|
width='100%'
|
||||||
height="100%"
|
height='100%'
|
||||||
>
|
>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Broadcast Transaction"
|
title='Broadcast Transaction'
|
||||||
message="Are you sure you want to broadcast this transaction? This action cannot be undone."
|
message='Are you sure you want to broadcast this transaction? This action cannot be undone.'
|
||||||
onConfirm={broadcastTransaction}
|
onConfirm={broadcastTransaction}
|
||||||
onCancel={() => setShowBroadcastConfirm(false)}
|
onCancel={() => setShowBroadcastConfirm(false)}
|
||||||
isActive={showBroadcastConfirm}
|
isActive={showBroadcastConfirm}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ interface UTXOItem {
|
|||||||
*/
|
*/
|
||||||
export function WalletStateScreen(): React.ReactElement {
|
export function WalletStateScreen(): React.ReactElement {
|
||||||
const { navigate } = useNavigation();
|
const { navigate } = useNavigation();
|
||||||
const { walletController, showError, showInfo } = useAppContext();
|
const { appService, showError, showInfo } = useAppContext();
|
||||||
const { setStatus } = useStatus();
|
const { setStatus } = useStatus();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
@@ -57,19 +57,17 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
* Refreshes wallet state.
|
* Refreshes wallet state.
|
||||||
*/
|
*/
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
|
if (!appService) {
|
||||||
|
showError('AppService not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setStatus('Loading wallet state...');
|
setStatus('Loading wallet state...');
|
||||||
|
|
||||||
// Get balance
|
|
||||||
const balanceData = await walletController.getBalance();
|
|
||||||
setBalance({
|
|
||||||
totalSatoshis: balanceData.totalSatoshis,
|
|
||||||
utxoCount: balanceData.utxoCount,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get UTXOs
|
// Get UTXOs
|
||||||
const utxoData = await walletController.getUnspentOutputs();
|
const utxoData = await appService.engine.listUnspentOutputsData();
|
||||||
setUtxos(utxoData.map((utxo) => ({
|
setUtxos(utxoData.map((utxo) => ({
|
||||||
key: `${utxo.outpointTransactionHash}:${utxo.outpointIndex}`,
|
key: `${utxo.outpointTransactionHash}:${utxo.outpointIndex}`,
|
||||||
satoshis: BigInt(utxo.valueSatoshis),
|
satoshis: BigInt(utxo.valueSatoshis),
|
||||||
@@ -78,13 +76,20 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
reserved: utxo.reserved ?? false,
|
reserved: utxo.reserved ?? false,
|
||||||
})));
|
})));
|
||||||
|
|
||||||
|
// Get balance
|
||||||
|
const balanceData = utxoData.reduce((acc, utxo) => acc + BigInt(utxo.valueSatoshis), BigInt(0));
|
||||||
|
setBalance({
|
||||||
|
totalSatoshis: balanceData,
|
||||||
|
utxoCount: utxoData.length,
|
||||||
|
});
|
||||||
|
|
||||||
setStatus('Wallet ready');
|
setStatus('Wallet ready');
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Failed to load wallet state: ${error instanceof Error ? error.message : String(error)}`);
|
showError(`Failed to load wallet state: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [walletController, setStatus, showError]);
|
}, [appService, setStatus, showError]);
|
||||||
|
|
||||||
// Load wallet state on mount
|
// Load wallet state on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -95,11 +100,16 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
* Generates a new receiving address.
|
* Generates a new receiving address.
|
||||||
*/
|
*/
|
||||||
const generateNewAddress = useCallback(async () => {
|
const generateNewAddress = useCallback(async () => {
|
||||||
|
if (!appService) {
|
||||||
|
showError('AppService not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setStatus('Generating new address...');
|
setStatus('Generating new address...');
|
||||||
|
|
||||||
// Get the default P2PKH template
|
// Get the default P2PKH template
|
||||||
const templates = await walletController.getTemplates();
|
const templates = await appService.engine.listImportedTemplates();
|
||||||
const p2pkhTemplate = templates.find(t => t.name?.includes('P2PKH'));
|
const p2pkhTemplate = templates.find(t => t.name?.includes('P2PKH'));
|
||||||
|
|
||||||
if (!p2pkhTemplate) {
|
if (!p2pkhTemplate) {
|
||||||
@@ -111,7 +121,7 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
const { generateTemplateIdentifier } = await import('@xo-cash/engine');
|
const { generateTemplateIdentifier } = await import('@xo-cash/engine');
|
||||||
const templateId = generateTemplateIdentifier(p2pkhTemplate);
|
const templateId = generateTemplateIdentifier(p2pkhTemplate);
|
||||||
|
|
||||||
const lockingBytecode = await walletController.generateLockingBytecode(
|
const lockingBytecode = await appService.engine.generateLockingBytecode(
|
||||||
templateId,
|
templateId,
|
||||||
'receiveOutput',
|
'receiveOutput',
|
||||||
'receiver',
|
'receiver',
|
||||||
@@ -124,7 +134,7 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Failed to generate address: ${error instanceof Error ? error.message : String(error)}`);
|
showError(`Failed to generate address: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
}
|
}
|
||||||
}, [walletController, setStatus, showInfo, showError, refresh]);
|
}, [appService, setStatus, showInfo, showError, refresh]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles menu selection.
|
* Handles menu selection.
|
||||||
@@ -164,29 +174,29 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" flexGrow={1}>
|
<Box flexDirection='column' flexGrow={1}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1}>
|
<Box borderStyle='single' borderColor={colors.secondary} paddingX={1}>
|
||||||
<Text color={colors.primary} bold>{logoSmall} - Wallet Overview</Text>
|
<Text color={colors.primary} bold>{logoSmall} - Wallet Overview</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<Box flexDirection="row" marginTop={1} flexGrow={1}>
|
<Box flexDirection='row' marginTop={1} flexGrow={1}>
|
||||||
{/* Left column: Balance */}
|
{/* Left column: Balance */}
|
||||||
<Box
|
<Box
|
||||||
flexDirection="column"
|
flexDirection='column'
|
||||||
width="50%"
|
width='50%'
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
borderStyle="single"
|
borderStyle='single'
|
||||||
borderColor={colors.primary}
|
borderColor={colors.primary}
|
||||||
flexDirection="column"
|
flexDirection='column'
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
paddingY={1}
|
paddingY={1}
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Balance </Text>
|
<Text color={colors.primary} bold> Balance </Text>
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection='column'>
|
||||||
<Text color={colors.text}>Total Balance:</Text>
|
<Text color={colors.text}>Total Balance:</Text>
|
||||||
{balance ? (
|
{balance ? (
|
||||||
<>
|
<>
|
||||||
@@ -206,14 +216,14 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
|
|
||||||
{/* Right column: Actions menu */}
|
{/* Right column: Actions menu */}
|
||||||
<Box
|
<Box
|
||||||
flexDirection="column"
|
flexDirection='column'
|
||||||
width="50%"
|
width='50%'
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
borderStyle="single"
|
borderStyle='single'
|
||||||
borderColor={focusedPanel === 'menu' ? colors.focus : colors.border}
|
borderColor={focusedPanel === 'menu' ? colors.focus : colors.border}
|
||||||
flexDirection="column"
|
flexDirection='column'
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Actions </Text>
|
<Text color={colors.primary} bold> Actions </Text>
|
||||||
@@ -244,14 +254,14 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
{/* UTXO list */}
|
{/* UTXO list */}
|
||||||
<Box marginTop={1} flexGrow={1}>
|
<Box marginTop={1} flexGrow={1}>
|
||||||
<Box
|
<Box
|
||||||
borderStyle="single"
|
borderStyle='single'
|
||||||
borderColor={focusedPanel === 'utxos' ? colors.focus : colors.border}
|
borderColor={focusedPanel === 'utxos' ? colors.focus : colors.border}
|
||||||
flexDirection="column"
|
flexDirection='column'
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
width="100%"
|
width='100%'
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Unspent Outputs (UTXOs) </Text>
|
<Text color={colors.primary} bold> Unspent Outputs (UTXOs) </Text>
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection='column'>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Text color={colors.textMuted}>Loading...</Text>
|
<Text color={colors.textMuted}>Loading...</Text>
|
||||||
) : utxoListItems.length === 0 ? (
|
) : utxoListItems.length === 0 ? (
|
||||||
|
|||||||
268
src/tui/screens/action-wizard/ActionWizardScreen.tsx
Normal file
268
src/tui/screens/action-wizard/ActionWizardScreen.tsx
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text, useInput } from 'ink';
|
||||||
|
import { StepIndicator, type Step } from '../../components/ProgressBar.js';
|
||||||
|
import { Button } from '../../components/Button.js';
|
||||||
|
import { colors, logoSmall } from '../../theme.js';
|
||||||
|
import { useActionWizard } from './useActionWizard.js';
|
||||||
|
|
||||||
|
// Steps
|
||||||
|
import { InfoStep } from './steps/InfoStep.js';
|
||||||
|
import { VariablesStep } from './steps/VariablesStep.js';
|
||||||
|
import { InputsStep } from './steps/InputsStep.js';
|
||||||
|
import { ReviewStep } from './steps/ReviewStep.js';
|
||||||
|
import { PublishStep } from './steps/PublishStep.js';
|
||||||
|
|
||||||
|
export function ActionWizardScreen(): React.ReactElement {
|
||||||
|
const wizard = useActionWizard();
|
||||||
|
|
||||||
|
// ── Keyboard handling ──────────────────────────────────────────
|
||||||
|
useInput(
|
||||||
|
(input, key) => {
|
||||||
|
// Tab to cycle between content area and button bar
|
||||||
|
if (key.tab) {
|
||||||
|
if (wizard.focusArea === 'content') {
|
||||||
|
// Within the inputs step, tab through UTXOs first
|
||||||
|
if (
|
||||||
|
wizard.currentStepData?.type === 'inputs' &&
|
||||||
|
wizard.availableUtxos.length > 0
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
wizard.selectedUtxoIndex <
|
||||||
|
wizard.availableUtxos.length - 1
|
||||||
|
) {
|
||||||
|
wizard.setSelectedUtxoIndex((prev) => prev + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Move focus down to the button bar
|
||||||
|
wizard.setFocusArea('buttons');
|
||||||
|
wizard.setFocusedButton('next');
|
||||||
|
} else {
|
||||||
|
// Cycle through buttons, then wrap back to content
|
||||||
|
if (wizard.focusedButton === 'back') {
|
||||||
|
wizard.setFocusedButton('cancel');
|
||||||
|
} else if (wizard.focusedButton === 'cancel') {
|
||||||
|
wizard.setFocusedButton('next');
|
||||||
|
} else {
|
||||||
|
wizard.setFocusArea('content');
|
||||||
|
wizard.setFocusedInput(0);
|
||||||
|
wizard.setSelectedUtxoIndex(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrow keys for UTXO selection in the content area
|
||||||
|
if (
|
||||||
|
wizard.focusArea === 'content' &&
|
||||||
|
wizard.currentStepData?.type === 'inputs'
|
||||||
|
) {
|
||||||
|
if (key.upArrow) {
|
||||||
|
wizard.setSelectedUtxoIndex((p) => Math.max(0, p - 1));
|
||||||
|
} else if (key.downArrow) {
|
||||||
|
wizard.setSelectedUtxoIndex((p) =>
|
||||||
|
Math.min(wizard.availableUtxos.length - 1, p + 1)
|
||||||
|
);
|
||||||
|
} else if (key.return || input === ' ') {
|
||||||
|
wizard.toggleUtxoSelection(wizard.selectedUtxoIndex);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrow keys in button bar
|
||||||
|
if (wizard.focusArea === 'buttons') {
|
||||||
|
if (key.leftArrow) {
|
||||||
|
wizard.setFocusedButton((p) =>
|
||||||
|
p === 'next' ? 'cancel' : p === 'cancel' ? 'back' : 'back'
|
||||||
|
);
|
||||||
|
} else if (key.rightArrow) {
|
||||||
|
wizard.setFocusedButton((p) =>
|
||||||
|
p === 'back' ? 'cancel' : p === 'cancel' ? 'next' : 'next'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter on a button
|
||||||
|
if (key.return) {
|
||||||
|
if (wizard.focusedButton === 'back') wizard.previousStep();
|
||||||
|
else if (wizard.focusedButton === 'cancel') wizard.cancel();
|
||||||
|
else if (wizard.focusedButton === 'next') wizard.nextStep();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'c' to copy invitation ID on the publish step
|
||||||
|
if (
|
||||||
|
input === 'c' &&
|
||||||
|
wizard.currentStepData?.type === 'publish' &&
|
||||||
|
wizard.invitationId
|
||||||
|
) {
|
||||||
|
wizard.copyId();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'a' to select all UTXOs
|
||||||
|
if (input === 'a' && wizard.currentStepData?.type === 'inputs') {
|
||||||
|
wizard.setAvailableUtxos((p) =>
|
||||||
|
p.map((u) => ({ ...u, selected: true }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'n' to deselect all UTXOs
|
||||||
|
if (input === 'n' && wizard.currentStepData?.type === 'inputs') {
|
||||||
|
wizard.setAvailableUtxos((p) =>
|
||||||
|
p.map((u) => ({ ...u, selected: false }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: !wizard.textInputHasFocus }
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Step router ────────────────────────────────────────────────
|
||||||
|
const renderStep = () => {
|
||||||
|
if (wizard.isProcessing) {
|
||||||
|
return <Text color={colors.info}>Processing...</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (wizard.currentStepData?.type) {
|
||||||
|
case 'info':
|
||||||
|
return (
|
||||||
|
<InfoStep
|
||||||
|
template={wizard.template!}
|
||||||
|
actionIdentifier={wizard.actionIdentifier!}
|
||||||
|
roleIdentifier={wizard.roleIdentifier!}
|
||||||
|
actionName={wizard.actionName}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'variables':
|
||||||
|
return (
|
||||||
|
<VariablesStep
|
||||||
|
variables={wizard.variables}
|
||||||
|
updateVariable={wizard.updateVariable}
|
||||||
|
handleTextInputSubmit={wizard.handleTextInputSubmit}
|
||||||
|
focusArea={wizard.focusArea}
|
||||||
|
focusedInput={wizard.focusedInput}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'inputs':
|
||||||
|
return (
|
||||||
|
<InputsStep
|
||||||
|
availableUtxos={wizard.availableUtxos}
|
||||||
|
selectedUtxoIndex={wizard.selectedUtxoIndex}
|
||||||
|
requiredAmount={wizard.requiredAmount}
|
||||||
|
fee={wizard.fee}
|
||||||
|
selectedAmount={wizard.selectedAmount}
|
||||||
|
changeAmount={wizard.changeAmount}
|
||||||
|
focusArea={wizard.focusArea}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'review':
|
||||||
|
return (
|
||||||
|
<ReviewStep
|
||||||
|
template={wizard.template!}
|
||||||
|
actionName={wizard.actionName}
|
||||||
|
roleIdentifier={wizard.roleIdentifier!}
|
||||||
|
variables={wizard.variables}
|
||||||
|
availableUtxos={wizard.availableUtxos}
|
||||||
|
changeAmount={wizard.changeAmount}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'publish':
|
||||||
|
return <PublishStep invitationId={wizard.invitationId} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Layout ─────────────────────────────────────────────────────
|
||||||
|
const stepIndicatorSteps: Step[] = wizard.steps.map((s) => ({
|
||||||
|
label: s.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box
|
||||||
|
borderStyle="single"
|
||||||
|
borderColor={colors.secondary}
|
||||||
|
paddingX={1}
|
||||||
|
flexDirection="column"
|
||||||
|
>
|
||||||
|
<Text color={colors.primary} bold>
|
||||||
|
{logoSmall} - Action Wizard
|
||||||
|
</Text>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{wizard.template?.name} {">"} {wizard.actionName} (as{" "}
|
||||||
|
{wizard.roleIdentifier})
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Progress indicator */}
|
||||||
|
<Box marginTop={1} paddingX={1}>
|
||||||
|
<StepIndicator
|
||||||
|
steps={stepIndicatorSteps}
|
||||||
|
currentStep={wizard.currentStep}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Content area */}
|
||||||
|
<Box
|
||||||
|
borderStyle="single"
|
||||||
|
borderColor={
|
||||||
|
wizard.focusArea === "content" ? colors.focus : colors.primary
|
||||||
|
}
|
||||||
|
flexDirection="column"
|
||||||
|
paddingX={1}
|
||||||
|
paddingY={1}
|
||||||
|
marginTop={1}
|
||||||
|
marginX={1}
|
||||||
|
flexGrow={1}
|
||||||
|
>
|
||||||
|
<Text color={colors.primary} bold>
|
||||||
|
{" "}
|
||||||
|
{wizard.currentStepData?.name} ({wizard.currentStep + 1}/
|
||||||
|
{wizard.steps.length}){" "}
|
||||||
|
</Text>
|
||||||
|
<Box marginTop={1}>{renderStep()}</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<Box marginTop={1} marginX={1} justifyContent="space-between">
|
||||||
|
<Box gap={1}>
|
||||||
|
<Button
|
||||||
|
label="Back"
|
||||||
|
focused={
|
||||||
|
wizard.focusArea === "buttons" &&
|
||||||
|
wizard.focusedButton === "back"
|
||||||
|
}
|
||||||
|
disabled={wizard.currentStepData?.type === "publish"}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Cancel"
|
||||||
|
focused={
|
||||||
|
wizard.focusArea === "buttons" &&
|
||||||
|
wizard.focusedButton === "cancel"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
label={
|
||||||
|
wizard.currentStepData?.type === "publish" ? "Done" : "Next"
|
||||||
|
}
|
||||||
|
focused={
|
||||||
|
wizard.focusArea === "buttons" &&
|
||||||
|
wizard.focusedButton === "next"
|
||||||
|
}
|
||||||
|
disabled={wizard.isProcessing}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Help text */}
|
||||||
|
<Box marginTop={1} marginX={1}>
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
Tab: Navigate • Enter: Select • Esc: Back
|
||||||
|
{wizard.currentStepData?.type === "publish"
|
||||||
|
? " • c: Copy ID"
|
||||||
|
: ""}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
src/tui/screens/action-wizard/index.ts
Normal file
4
src/tui/screens/action-wizard/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './ActionWizardScreen.js';
|
||||||
|
export * from './useActionWizard.js';
|
||||||
|
export * from './types.js';
|
||||||
|
export * from './steps/index.js';
|
||||||
52
src/tui/screens/action-wizard/steps/InfoStep.tsx
Normal file
52
src/tui/screens/action-wizard/steps/InfoStep.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { colors } from '../../../theme.js';
|
||||||
|
import type { WizardStepProps } from '../types.js';
|
||||||
|
|
||||||
|
type Props = Pick<
|
||||||
|
WizardStepProps,
|
||||||
|
'template' | 'actionIdentifier' | 'roleIdentifier' | 'actionName'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function InfoStep({
|
||||||
|
template,
|
||||||
|
actionIdentifier,
|
||||||
|
roleIdentifier,
|
||||||
|
actionName,
|
||||||
|
}: Props): React.ReactElement {
|
||||||
|
const action = template?.actions?.[actionIdentifier];
|
||||||
|
const role = action?.roles?.[roleIdentifier];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection='column'>
|
||||||
|
<Text color={colors.primary} bold>
|
||||||
|
Action: {actionName}
|
||||||
|
</Text>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{action?.description || 'No description'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.text}>Your Role: </Text>
|
||||||
|
<Text color={colors.accent}>{roleIdentifier}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{role?.requirements && (
|
||||||
|
<Box marginTop={1} flexDirection='column'>
|
||||||
|
<Text color={colors.text}>Requirements:</Text>
|
||||||
|
{role.requirements.variables?.map((v) => (
|
||||||
|
<Text key={v} color={colors.textMuted}>
|
||||||
|
{' '}• Variable: {v}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
{role.requirements.slots && (
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{' '}• Slots: {role.requirements.slots.min} min (UTXO selection
|
||||||
|
required)
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/tui/screens/action-wizard/steps/InputsStep.tsx
Normal file
92
src/tui/screens/action-wizard/steps/InputsStep.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { colors, formatSatoshis, formatHex } from '../../../theme.js';
|
||||||
|
import type { WizardStepProps } from '../types.js';
|
||||||
|
|
||||||
|
type Props = Pick<
|
||||||
|
WizardStepProps,
|
||||||
|
| 'availableUtxos'
|
||||||
|
| 'selectedUtxoIndex'
|
||||||
|
| 'requiredAmount'
|
||||||
|
| 'fee'
|
||||||
|
| 'selectedAmount'
|
||||||
|
| 'changeAmount'
|
||||||
|
| 'focusArea'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function InputsStep({
|
||||||
|
availableUtxos,
|
||||||
|
selectedUtxoIndex,
|
||||||
|
requiredAmount,
|
||||||
|
fee,
|
||||||
|
selectedAmount,
|
||||||
|
changeAmount,
|
||||||
|
focusArea,
|
||||||
|
}: Props): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Box flexDirection='column'>
|
||||||
|
<Text color={colors.text} bold>
|
||||||
|
Select UTXOs to fund the transaction:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box marginTop={1} flexDirection='column'>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Required: {formatSatoshis(requiredAmount)} +{' '}
|
||||||
|
{formatSatoshis(fee)} fee
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
color={
|
||||||
|
selectedAmount >= requiredAmount + fee
|
||||||
|
? colors.success
|
||||||
|
: colors.warning
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Selected: {formatSatoshis(selectedAmount)}
|
||||||
|
</Text>
|
||||||
|
{selectedAmount > requiredAmount + fee && (
|
||||||
|
<Text color={colors.info}>
|
||||||
|
Change: {formatSatoshis(changeAmount)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
marginTop={1}
|
||||||
|
flexDirection='column'
|
||||||
|
borderStyle='single'
|
||||||
|
borderColor={colors.border}
|
||||||
|
paddingX={1}
|
||||||
|
>
|
||||||
|
{availableUtxos.length === 0 ? (
|
||||||
|
<Text color={colors.textMuted}>No UTXOs available</Text>
|
||||||
|
) : (
|
||||||
|
availableUtxos.map((utxo, index) => {
|
||||||
|
const isCursor =
|
||||||
|
selectedUtxoIndex === index && focusArea === 'content';
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
color={isCursor ? colors.focus : colors.text}
|
||||||
|
bold={isCursor}
|
||||||
|
>
|
||||||
|
{isCursor ? '▸ ' : ' '}[{utxo.selected ? 'X' : ' '}]{' '}
|
||||||
|
{formatSatoshis(utxo.valueSatoshis)} -{' '}
|
||||||
|
{formatHex(utxo.outpointTransactionHash, 12)}:
|
||||||
|
{utxo.outpointIndex}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
Space/Enter: Toggle • a: Select all • n: Deselect all
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/tui/screens/action-wizard/steps/PublishStep.tsx
Normal file
45
src/tui/screens/action-wizard/steps/PublishStep.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { colors } from '../../../theme.js';
|
||||||
|
|
||||||
|
interface PublishStepProps {
|
||||||
|
invitationId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PublishStep({
|
||||||
|
invitationId,
|
||||||
|
}: PublishStepProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Box flexDirection='column'>
|
||||||
|
<Text color={colors.success} bold>
|
||||||
|
✓ Invitation Created & Published!
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box marginTop={1} flexDirection='column'>
|
||||||
|
<Text color={colors.text}>Invitation ID:</Text>
|
||||||
|
<Box
|
||||||
|
borderStyle='single'
|
||||||
|
borderColor={colors.primary}
|
||||||
|
paddingX={1}
|
||||||
|
marginTop={1}
|
||||||
|
>
|
||||||
|
<Text color={colors.accent}>
|
||||||
|
{invitationId ?? '(unknown)'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Share this ID with the other party to complete the transaction.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.warning}>
|
||||||
|
Press 'c' to copy ID to clipboard
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/tui/screens/action-wizard/steps/ReviewStep.tsx
Normal file
93
src/tui/screens/action-wizard/steps/ReviewStep.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { colors, formatSatoshis } from '../../../theme.js';
|
||||||
|
import type { VariableInput, SelectableUTXO } from '../types.js';
|
||||||
|
import type { XOTemplate } from '@xo-cash/types';
|
||||||
|
|
||||||
|
interface ReviewStepProps {
|
||||||
|
template: XOTemplate;
|
||||||
|
actionName: string;
|
||||||
|
roleIdentifier: string;
|
||||||
|
variables: VariableInput[];
|
||||||
|
availableUtxos: SelectableUTXO[];
|
||||||
|
changeAmount: bigint;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewStep({
|
||||||
|
template,
|
||||||
|
actionName,
|
||||||
|
roleIdentifier,
|
||||||
|
variables,
|
||||||
|
availableUtxos,
|
||||||
|
changeAmount,
|
||||||
|
}: ReviewStepProps): React.ReactElement {
|
||||||
|
const selectedUtxos = availableUtxos.filter((u) => u.selected);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection='column'>
|
||||||
|
<Text color={colors.text} bold>
|
||||||
|
Review your invitation:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<Box marginTop={1} flexDirection='column'>
|
||||||
|
<Text color={colors.textMuted}>Template: {template?.name}</Text>
|
||||||
|
<Text color={colors.textMuted}>Action: {actionName}</Text>
|
||||||
|
<Text color={colors.textMuted}>Role: {roleIdentifier}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Variables */}
|
||||||
|
{variables.length > 0 && (
|
||||||
|
<Box marginTop={1} flexDirection='column'>
|
||||||
|
<Text color={colors.text}>Variables:</Text>
|
||||||
|
{variables.map((v) => (
|
||||||
|
<Text key={v.id} color={colors.textMuted}>
|
||||||
|
{' '}
|
||||||
|
{v.name}: {v.value || '(empty)'}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Inputs */}
|
||||||
|
{selectedUtxos.length > 0 && (
|
||||||
|
<Box marginTop={1} flexDirection='column'>
|
||||||
|
<Text color={colors.text}>
|
||||||
|
Inputs ({selectedUtxos.length}):
|
||||||
|
</Text>
|
||||||
|
{selectedUtxos.slice(0, 3).map((u) => (
|
||||||
|
<Text
|
||||||
|
key={`${u.outpointTransactionHash}:${u.outpointIndex}`}
|
||||||
|
color={colors.textMuted}
|
||||||
|
>
|
||||||
|
{' '}
|
||||||
|
{formatSatoshis(u.valueSatoshis)}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
{selectedUtxos.length > 3 && (
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{' '}...and {selectedUtxos.length - 3} more
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Outputs */}
|
||||||
|
{changeAmount > 0n && (
|
||||||
|
<Box marginTop={1} flexDirection='column'>
|
||||||
|
<Text color={colors.text}>Outputs:</Text>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{' '}Change: {formatSatoshis(changeAmount)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirmation prompt */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.warning}>
|
||||||
|
Press Next to create and publish the invitation.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/tui/screens/action-wizard/steps/VariablesStep.tsx
Normal file
49
src/tui/screens/action-wizard/steps/VariablesStep.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { colors } from '../../../theme.js';
|
||||||
|
import { VariableInputField } from '../../../components/VariableInputField.js';
|
||||||
|
import type { WizardStepProps } from '../types.js';
|
||||||
|
|
||||||
|
type Props = Pick<
|
||||||
|
WizardStepProps,
|
||||||
|
| 'variables'
|
||||||
|
| 'updateVariable'
|
||||||
|
| 'handleTextInputSubmit'
|
||||||
|
| 'focusArea'
|
||||||
|
| 'focusedInput'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function VariablesStep({
|
||||||
|
variables,
|
||||||
|
updateVariable,
|
||||||
|
handleTextInputSubmit,
|
||||||
|
focusArea,
|
||||||
|
focusedInput,
|
||||||
|
}: Props): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Box flexDirection='column'>
|
||||||
|
<Text color={colors.text} bold>
|
||||||
|
Enter required values:
|
||||||
|
</Text>
|
||||||
|
<Box marginTop={1} flexDirection='column'>
|
||||||
|
{variables.map((variable, index) => (
|
||||||
|
<VariableInputField
|
||||||
|
key={variable.id}
|
||||||
|
variable={variable}
|
||||||
|
index={index}
|
||||||
|
isFocused={focusArea === 'content' && focusedInput === index}
|
||||||
|
onChange={updateVariable}
|
||||||
|
onSubmit={handleTextInputSubmit}
|
||||||
|
borderColor={colors.border as string}
|
||||||
|
focusColor={colors.primary as string}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
Type your value, then press Enter to continue
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/tui/screens/action-wizard/steps/index.ts
Normal file
5
src/tui/screens/action-wizard/steps/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './InfoStep.js';
|
||||||
|
export * from './VariablesStep.js';
|
||||||
|
export * from './InputsStep.js';
|
||||||
|
export * from './ReviewStep.js';
|
||||||
|
export * from './PublishStep.js';
|
||||||
62
src/tui/screens/action-wizard/types.ts
Normal file
62
src/tui/screens/action-wizard/types.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { XOTemplate } from '@xo-cash/types';
|
||||||
|
|
||||||
|
export type StepType = 'info' | 'variables' | 'inputs' | 'review' | 'publish';
|
||||||
|
|
||||||
|
export interface WizardStep {
|
||||||
|
name: string;
|
||||||
|
type: StepType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VariableInput {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
hint?: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectableUTXO {
|
||||||
|
outpointTransactionHash: string;
|
||||||
|
outpointIndex: number;
|
||||||
|
valueSatoshis: bigint;
|
||||||
|
lockingBytecode?: string;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FocusArea = 'content' | 'buttons';
|
||||||
|
export type ButtonFocus = 'back' | 'cancel' | 'next';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 'downward' contract — what every step component receives.
|
||||||
|
*/
|
||||||
|
export interface WizardStepProps {
|
||||||
|
// Data
|
||||||
|
template: XOTemplate;
|
||||||
|
actionIdentifier: string;
|
||||||
|
roleIdentifier: string;
|
||||||
|
actionName: string;
|
||||||
|
|
||||||
|
// Variable state
|
||||||
|
variables: VariableInput[];
|
||||||
|
updateVariable: (index: number, value: string) => void;
|
||||||
|
|
||||||
|
// UTXO state
|
||||||
|
availableUtxos: SelectableUTXO[];
|
||||||
|
selectedUtxoIndex: number;
|
||||||
|
requiredAmount: bigint;
|
||||||
|
fee: bigint;
|
||||||
|
selectedAmount: bigint;
|
||||||
|
changeAmount: bigint;
|
||||||
|
toggleUtxoSelection: (index: number) => void;
|
||||||
|
|
||||||
|
// Invitation
|
||||||
|
invitationId: string | null;
|
||||||
|
|
||||||
|
// Focus
|
||||||
|
focusArea: FocusArea;
|
||||||
|
focusedInput: number;
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
handleTextInputSubmit: () => void;
|
||||||
|
copyId: () => Promise<void>;
|
||||||
|
}
|
||||||
576
src/tui/screens/action-wizard/useActionWizard.ts
Normal file
576
src/tui/screens/action-wizard/useActionWizard.ts
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useNavigation } from '../../hooks/useNavigation.js';
|
||||||
|
import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
|
||||||
|
import { formatSatoshis } from '../../theme.js';
|
||||||
|
import { copyToClipboard } from '../../utils/clipboard.js';
|
||||||
|
import type { XOTemplate, XOInvitation } from '@xo-cash/types';
|
||||||
|
import type {
|
||||||
|
WizardStep,
|
||||||
|
VariableInput,
|
||||||
|
SelectableUTXO,
|
||||||
|
FocusArea,
|
||||||
|
ButtonFocus,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
export function useActionWizard() {
|
||||||
|
const { navigate, goBack, data: navData } = useNavigation();
|
||||||
|
const { appService, showError, showInfo } = useAppContext();
|
||||||
|
const { setStatus } = useStatus();
|
||||||
|
|
||||||
|
// ── Navigation data ──────────────────────────────────────────────
|
||||||
|
const templateIdentifier = navData.templateIdentifier as string | undefined;
|
||||||
|
const actionIdentifier = navData.actionIdentifier as string | undefined;
|
||||||
|
const roleIdentifier = navData.roleIdentifier as string | undefined;
|
||||||
|
const template = navData.template as XOTemplate | undefined;
|
||||||
|
|
||||||
|
// ── Wizard state ─────────────────────────────────────────────────
|
||||||
|
const [steps, setSteps] = useState<WizardStep[]>([]);
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
|
||||||
|
// ── Variable inputs ──────────────────────────────────────────────
|
||||||
|
const [variables, setVariables] = useState<VariableInput[]>([]);
|
||||||
|
|
||||||
|
// ── UTXO selection ───────────────────────────────────────────────
|
||||||
|
const [availableUtxos, setAvailableUtxos] = useState<SelectableUTXO[]>([]);
|
||||||
|
const [selectedUtxoIndex, setSelectedUtxoIndex] = useState(0);
|
||||||
|
const [requiredAmount, setRequiredAmount] = useState<bigint>(0n);
|
||||||
|
const [fee, setFee] = useState<bigint>(500n);
|
||||||
|
|
||||||
|
// ── Invitation ───────────────────────────────────────────────────
|
||||||
|
const [invitation, setInvitation] = useState<XOInvitation | null>(null);
|
||||||
|
const [invitationId, setInvitationId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ── UI state ─────────────────────────────────────────────────────
|
||||||
|
const [focusedInput, setFocusedInput] = useState(0);
|
||||||
|
const [focusedButton, setFocusedButton] = useState<ButtonFocus>('next');
|
||||||
|
const [focusArea, setFocusArea] = useState<FocusArea>('content');
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
||||||
|
// ── Derived values ───────────────────────────────────────────────
|
||||||
|
const currentStepData = steps[currentStep];
|
||||||
|
const action = template?.actions?.[actionIdentifier ?? ''];
|
||||||
|
const actionName = action?.name || actionIdentifier || 'Unknown';
|
||||||
|
|
||||||
|
const selectedAmount = availableUtxos
|
||||||
|
.filter((u) => u.selected)
|
||||||
|
.reduce((sum, u) => sum + u.valueSatoshis, 0n);
|
||||||
|
|
||||||
|
const changeAmount = selectedAmount - requiredAmount - fee;
|
||||||
|
|
||||||
|
const textInputHasFocus =
|
||||||
|
currentStepData?.type === 'variables' && focusArea === 'content';
|
||||||
|
|
||||||
|
// ── Initialization ───────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!template || !actionIdentifier || !roleIdentifier) {
|
||||||
|
showError('Missing wizard data');
|
||||||
|
goBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const act = template.actions?.[actionIdentifier];
|
||||||
|
const role = act?.roles?.[roleIdentifier];
|
||||||
|
const requirements = role?.requirements;
|
||||||
|
|
||||||
|
// const wizardSteps: WizardStep[] = [{ name: 'Welcome', type: 'info' }];
|
||||||
|
const wizardSteps: WizardStep[] = [];
|
||||||
|
|
||||||
|
// Add variables step if needed
|
||||||
|
if (requirements?.variables && requirements.variables.length > 0) {
|
||||||
|
wizardSteps.push({ name: 'Variables', type: 'variables' });
|
||||||
|
|
||||||
|
const varInputs = requirements.variables.map((varId) => {
|
||||||
|
const varDef = template.variables?.[varId];
|
||||||
|
return {
|
||||||
|
id: varId,
|
||||||
|
name: varDef?.name || varId,
|
||||||
|
type: varDef?.type || 'string',
|
||||||
|
hint: varDef?.hint,
|
||||||
|
value: '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setVariables(varInputs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add inputs step if role requires slots (funding inputs)
|
||||||
|
// Slots indicate the role needs to provide transaction inputs/outputs
|
||||||
|
if (requirements?.slots && requirements.slots.min > 0) {
|
||||||
|
wizardSteps.push({ name: 'Select UTXOs', type: 'inputs' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add review step
|
||||||
|
wizardSteps.push({ name: 'Review', type: 'review' });
|
||||||
|
|
||||||
|
// Add publish step
|
||||||
|
wizardSteps.push({ name: 'Publish', type: 'publish' });
|
||||||
|
|
||||||
|
setSteps(wizardSteps);
|
||||||
|
setStatus(`${actionIdentifier}/${roleIdentifier}`);
|
||||||
|
}, [
|
||||||
|
template,
|
||||||
|
actionIdentifier,
|
||||||
|
roleIdentifier,
|
||||||
|
showError,
|
||||||
|
goBack,
|
||||||
|
setStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Update a single variable value ───────────────────────────────
|
||||||
|
const updateVariable = useCallback((index: number, value: string) => {
|
||||||
|
setVariables((prev) => {
|
||||||
|
const updated = [...prev];
|
||||||
|
const variable = updated[index];
|
||||||
|
if (variable) {
|
||||||
|
updated[index] = { ...variable, value };
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Toggle a UTXO's selected state ──────────────────────────────
|
||||||
|
const toggleUtxoSelection = useCallback((index: number) => {
|
||||||
|
setAvailableUtxos((prev) => {
|
||||||
|
const updated = [...prev];
|
||||||
|
const utxo = updated[index];
|
||||||
|
if (utxo) {
|
||||||
|
updated[index] = { ...utxo, selected: !utxo.selected };
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Handle Enter inside a TextInput ─────────────────────────────
|
||||||
|
const handleTextInputSubmit = useCallback(() => {
|
||||||
|
if (focusedInput < variables.length - 1) {
|
||||||
|
setFocusedInput((prev) => prev + 1);
|
||||||
|
} else {
|
||||||
|
setFocusArea('buttons');
|
||||||
|
setFocusedButton('next');
|
||||||
|
}
|
||||||
|
}, [focusedInput, variables.length]);
|
||||||
|
|
||||||
|
// ── Copy invitation ID to clipboard ─────────────────────────────
|
||||||
|
const copyId = useCallback(async () => {
|
||||||
|
if (!invitationId) return;
|
||||||
|
try {
|
||||||
|
await copyToClipboard(invitationId);
|
||||||
|
showInfo(`Copied to clipboard!\n\n${invitationId}`);
|
||||||
|
} catch (error) {
|
||||||
|
showError(
|
||||||
|
`Failed to copy: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [invitationId, showInfo, showError]);
|
||||||
|
|
||||||
|
// ── Load available UTXOs for the inputs step ────────────────────
|
||||||
|
const loadAvailableUtxos = useCallback(async () => {
|
||||||
|
if (!invitation || !templateIdentifier || !appService || !invitationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsProcessing(true);
|
||||||
|
setStatus('Finding suitable UTXOs...');
|
||||||
|
|
||||||
|
// Determine required amount from variables
|
||||||
|
const requestedVar = variables.find(
|
||||||
|
(v) =>
|
||||||
|
v.id.toLowerCase().includes('satoshi') ||
|
||||||
|
v.id.toLowerCase().includes('amount')
|
||||||
|
);
|
||||||
|
const requested = requestedVar
|
||||||
|
? BigInt(requestedVar.value || '0')
|
||||||
|
: 0n;
|
||||||
|
setRequiredAmount(requested);
|
||||||
|
|
||||||
|
// Find the tracked invitation instance
|
||||||
|
const invitationInstance = appService.invitations.find(
|
||||||
|
(inv) => inv.data.invitationIdentifier === invitationId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!invitationInstance) {
|
||||||
|
throw new Error('Invitation not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query for suitable resources
|
||||||
|
const unspentOutputs = await invitationInstance.findSuitableResources({
|
||||||
|
templateIdentifier,
|
||||||
|
outputIdentifier: 'receiveOutput',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map to selectable UTXOs
|
||||||
|
const utxos: SelectableUTXO[] = unspentOutputs.map((utxo: any) => ({
|
||||||
|
outpointTransactionHash: utxo.outpointTransactionHash,
|
||||||
|
outpointIndex: utxo.outpointIndex,
|
||||||
|
valueSatoshis: BigInt(utxo.valueSatoshis),
|
||||||
|
lockingBytecode: utxo.lockingBytecode
|
||||||
|
? typeof utxo.lockingBytecode === 'string'
|
||||||
|
? utxo.lockingBytecode
|
||||||
|
: Buffer.from(utxo.lockingBytecode).toString('hex')
|
||||||
|
: undefined,
|
||||||
|
selected: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Auto-select UTXOs greedily until the requirement is met
|
||||||
|
let accumulated = 0n;
|
||||||
|
const seenLockingBytecodes = new Set<string>();
|
||||||
|
|
||||||
|
for (const utxo of utxos) {
|
||||||
|
if (
|
||||||
|
utxo.lockingBytecode &&
|
||||||
|
seenLockingBytecodes.has(utxo.lockingBytecode)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (utxo.lockingBytecode) {
|
||||||
|
seenLockingBytecodes.add(utxo.lockingBytecode);
|
||||||
|
}
|
||||||
|
|
||||||
|
utxo.selected = true;
|
||||||
|
accumulated += utxo.valueSatoshis;
|
||||||
|
|
||||||
|
if (accumulated >= requested + fee) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAvailableUtxos(utxos);
|
||||||
|
setStatus('Ready');
|
||||||
|
} catch (error) {
|
||||||
|
showError(
|
||||||
|
`Failed to load UTXOs: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
invitation,
|
||||||
|
templateIdentifier,
|
||||||
|
variables,
|
||||||
|
appService,
|
||||||
|
invitationId,
|
||||||
|
fee,
|
||||||
|
showError,
|
||||||
|
setStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Create invitation and persist variables ─────────────────────
|
||||||
|
const createInvitationWithVariables = useCallback(async () => {
|
||||||
|
if (
|
||||||
|
!templateIdentifier ||
|
||||||
|
!actionIdentifier ||
|
||||||
|
!roleIdentifier ||
|
||||||
|
!template ||
|
||||||
|
!appService
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
setStatus('Creating invitation...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create via the engine
|
||||||
|
const xoInvitation = await appService.engine.createInvitation({
|
||||||
|
templateIdentifier,
|
||||||
|
actionIdentifier,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrap and track
|
||||||
|
const invitationInstance =
|
||||||
|
await appService.createInvitation(xoInvitation);
|
||||||
|
|
||||||
|
let inv = invitationInstance.data;
|
||||||
|
const invId = inv.invitationIdentifier;
|
||||||
|
setInvitationId(invId);
|
||||||
|
|
||||||
|
// Persist variable values
|
||||||
|
if (variables.length > 0) {
|
||||||
|
const variableData = variables.map((v) => {
|
||||||
|
const isNumeric =
|
||||||
|
['integer', 'number', 'satoshis'].includes(v.type) ||
|
||||||
|
(v.hint && ['satoshis', 'amount'].includes(v.hint));
|
||||||
|
|
||||||
|
return {
|
||||||
|
variableIdentifier: v.id,
|
||||||
|
roleIdentifier,
|
||||||
|
value: isNumeric ? BigInt(v.value || '0') : v.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await invitationInstance.addVariables(variableData);
|
||||||
|
inv = invitationInstance.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add template-required outputs for the current role
|
||||||
|
const act = template.actions?.[actionIdentifier];
|
||||||
|
const transaction = act?.transaction
|
||||||
|
? template.transactions?.[act.transaction]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (transaction?.outputs && transaction.outputs.length > 0) {
|
||||||
|
setStatus('Adding required outputs...');
|
||||||
|
|
||||||
|
const outputsToAdd = transaction.outputs.map(
|
||||||
|
(outputId: string) => ({
|
||||||
|
outputIdentifier: outputId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await invitationInstance.addOutputs(outputsToAdd);
|
||||||
|
inv = invitationInstance.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInvitation(inv);
|
||||||
|
|
||||||
|
// Advance and optionally kick off UTXO loading
|
||||||
|
const nextStepType = steps[currentStep + 1]?.type;
|
||||||
|
if (nextStepType === 'inputs') {
|
||||||
|
setCurrentStep((prev) => prev + 1);
|
||||||
|
setTimeout(() => loadAvailableUtxos(), 100);
|
||||||
|
} else {
|
||||||
|
setCurrentStep((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('Invitation created');
|
||||||
|
} catch (error) {
|
||||||
|
showError(
|
||||||
|
`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
templateIdentifier,
|
||||||
|
actionIdentifier,
|
||||||
|
roleIdentifier,
|
||||||
|
template,
|
||||||
|
variables,
|
||||||
|
appService,
|
||||||
|
steps,
|
||||||
|
currentStep,
|
||||||
|
showError,
|
||||||
|
setStatus,
|
||||||
|
loadAvailableUtxos,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Add selected inputs + change output to the invitation ───────
|
||||||
|
const addInputsAndOutputs = useCallback(async () => {
|
||||||
|
if (!invitationId || !invitation || !appService) return;
|
||||||
|
|
||||||
|
const selectedUtxos = availableUtxos.filter((u) => u.selected);
|
||||||
|
|
||||||
|
if (selectedUtxos.length === 0) {
|
||||||
|
showError('Please select at least one UTXO');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedAmount < requiredAmount + fee) {
|
||||||
|
showError(
|
||||||
|
`Insufficient funds. Need ${formatSatoshis(requiredAmount + fee)}, selected ${formatSatoshis(selectedAmount)}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changeAmount < 546n) {
|
||||||
|
showError(
|
||||||
|
`Change amount (${changeAmount}) is below dust threshold (546 sats)`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
setStatus('Adding inputs and outputs...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const invitationInstance = appService.invitations.find(
|
||||||
|
(inv) => inv.data.invitationIdentifier === invitationId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!invitationInstance) {
|
||||||
|
throw new Error('Invitation not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add selected inputs
|
||||||
|
const inputs = selectedUtxos.map((utxo) => ({
|
||||||
|
outpointTransactionHash: new Uint8Array(
|
||||||
|
Buffer.from(utxo.outpointTransactionHash, 'hex')
|
||||||
|
),
|
||||||
|
outpointIndex: utxo.outpointIndex,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await invitationInstance.addInputs(inputs);
|
||||||
|
|
||||||
|
// Add change output
|
||||||
|
const outputs = [
|
||||||
|
{
|
||||||
|
valueSatoshis: changeAmount,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await invitationInstance.addOutputs(outputs);
|
||||||
|
|
||||||
|
setCurrentStep((prev) => prev + 1);
|
||||||
|
setStatus('Inputs and outputs added');
|
||||||
|
} catch (error) {
|
||||||
|
showError(
|
||||||
|
`Failed to add inputs/outputs: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
invitationId,
|
||||||
|
invitation,
|
||||||
|
availableUtxos,
|
||||||
|
selectedAmount,
|
||||||
|
requiredAmount,
|
||||||
|
fee,
|
||||||
|
changeAmount,
|
||||||
|
appService,
|
||||||
|
showError,
|
||||||
|
setStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Publish the invitation ──────────────────────────────────────
|
||||||
|
const publishInvitation = useCallback(async () => {
|
||||||
|
if (!invitationId || !appService) return;
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
setStatus('Publishing invitation...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const invitationInstance = appService.invitations.find(
|
||||||
|
(inv) => inv.data.invitationIdentifier === invitationId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!invitationInstance) {
|
||||||
|
throw new Error('Invitation not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already tracked and synced via SSE from createInvitation
|
||||||
|
setCurrentStep((prev) => prev + 1);
|
||||||
|
setStatus('Invitation published');
|
||||||
|
} catch (error) {
|
||||||
|
showError(
|
||||||
|
`Failed to publish: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}, [invitationId, appService, showError, setStatus]);
|
||||||
|
|
||||||
|
// ── Navigate to the next step ───────────────────────────────────
|
||||||
|
const nextStep = useCallback(async () => {
|
||||||
|
if (currentStep >= steps.length - 1) return;
|
||||||
|
|
||||||
|
const stepType = currentStepData?.type;
|
||||||
|
|
||||||
|
if (stepType === 'variables') {
|
||||||
|
const emptyVars = variables.filter(
|
||||||
|
(v) => !v.value || v.value.trim() === ''
|
||||||
|
);
|
||||||
|
if (emptyVars.length > 0) {
|
||||||
|
showError(
|
||||||
|
`Please enter values for: ${emptyVars.map((v) => v.name).join(', ')}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await createInvitationWithVariables();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepType === 'inputs') {
|
||||||
|
await addInputsAndOutputs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepType === 'review') {
|
||||||
|
await publishInvitation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentStep((prev) => prev + 1);
|
||||||
|
setFocusArea('content');
|
||||||
|
setFocusedInput(0);
|
||||||
|
}, [
|
||||||
|
currentStep,
|
||||||
|
steps.length,
|
||||||
|
currentStepData,
|
||||||
|
variables,
|
||||||
|
showError,
|
||||||
|
createInvitationWithVariables,
|
||||||
|
addInputsAndOutputs,
|
||||||
|
publishInvitation,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Navigate to the previous step ──────────────────────────────
|
||||||
|
const previousStep = useCallback(() => {
|
||||||
|
if (currentStep <= 0) {
|
||||||
|
goBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCurrentStep((prev) => prev - 1);
|
||||||
|
setFocusArea('content');
|
||||||
|
setFocusedInput(0);
|
||||||
|
}, [currentStep, goBack]);
|
||||||
|
|
||||||
|
// ── Cancel the wizard entirely ──────────────────────────────────
|
||||||
|
const cancel = useCallback(() => {
|
||||||
|
goBack();
|
||||||
|
}, [goBack]);
|
||||||
|
|
||||||
|
// ── Public API ──────────────────────────────────────────────────
|
||||||
|
return {
|
||||||
|
// Navigation / meta
|
||||||
|
template,
|
||||||
|
templateIdentifier,
|
||||||
|
actionIdentifier,
|
||||||
|
roleIdentifier,
|
||||||
|
action,
|
||||||
|
actionName,
|
||||||
|
|
||||||
|
// Steps
|
||||||
|
steps,
|
||||||
|
currentStep,
|
||||||
|
currentStepData,
|
||||||
|
|
||||||
|
// Variables
|
||||||
|
variables,
|
||||||
|
updateVariable,
|
||||||
|
handleTextInputSubmit,
|
||||||
|
|
||||||
|
// UTXOs
|
||||||
|
availableUtxos,
|
||||||
|
setAvailableUtxos,
|
||||||
|
selectedUtxoIndex,
|
||||||
|
setSelectedUtxoIndex,
|
||||||
|
requiredAmount,
|
||||||
|
fee,
|
||||||
|
selectedAmount,
|
||||||
|
changeAmount,
|
||||||
|
toggleUtxoSelection,
|
||||||
|
|
||||||
|
// Invitation
|
||||||
|
invitation,
|
||||||
|
invitationId,
|
||||||
|
|
||||||
|
// UI focus
|
||||||
|
focusedInput,
|
||||||
|
setFocusedInput,
|
||||||
|
focusedButton,
|
||||||
|
setFocusedButton,
|
||||||
|
focusArea,
|
||||||
|
setFocusArea,
|
||||||
|
isProcessing,
|
||||||
|
textInputHasFocus,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
nextStep,
|
||||||
|
previousStep,
|
||||||
|
cancel,
|
||||||
|
copyId,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience type so other files can type the return value. */
|
||||||
|
export type ActionWizardState = ReturnType<typeof useActionWizard>;
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
* Export all screen components.
|
* Export all screen components.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export * from './action-wizard/index.js';
|
||||||
export { SeedInputScreen } from './SeedInput.js';
|
export { SeedInputScreen } from './SeedInput.js';
|
||||||
export { WalletStateScreen } from './WalletState.js';
|
export { WalletStateScreen } from './WalletState.js';
|
||||||
export { TemplateListScreen } from './TemplateList.js';
|
export { TemplateListScreen } from './TemplateList.js';
|
||||||
export { ActionWizardScreen } from './ActionWizard.js';
|
|
||||||
export { InvitationScreen } from './Invitation.js';
|
export { InvitationScreen } from './Invitation.js';
|
||||||
export { TransactionScreen } from './Transaction.js';
|
export { TransactionScreen } from './Transaction.js';
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
* Shared types for the CLI TUI.
|
* Shared types for the CLI TUI.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { WalletController } from '../controllers/wallet-controller.js';
|
import type { AppService } from '../services/app.js';
|
||||||
import type { InvitationController } from '../controllers/invitation-controller.js';
|
import type { AppConfig } from '../app.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Screen names for navigation.
|
* Screen names for navigation.
|
||||||
@@ -51,13 +51,17 @@ export interface NavigationContextType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App context interface - provides access to controllers and app-level functions.
|
* App context interface - provides access to AppService and app-level functions.
|
||||||
*/
|
*/
|
||||||
export interface AppContextType {
|
export interface AppContextType {
|
||||||
/** Wallet controller for wallet operations */
|
/** AppService instance (null before wallet initialization) */
|
||||||
walletController: WalletController;
|
appService: AppService | null;
|
||||||
/** Invitation controller for invitation operations */
|
/** Initialize wallet with seed phrase and create AppService */
|
||||||
invitationController: InvitationController;
|
initializeWallet: (seed: string) => Promise<void>;
|
||||||
|
/** Whether the wallet is initialized */
|
||||||
|
isWalletInitialized: boolean;
|
||||||
|
/** Application configuration */
|
||||||
|
config: AppConfig;
|
||||||
/** Show an error message dialog */
|
/** Show an error message dialog */
|
||||||
showError: (message: string) => void;
|
showError: (message: string) => void;
|
||||||
/** Show an info message dialog */
|
/** Show an info message dialog */
|
||||||
@@ -68,10 +72,6 @@ export interface AppContextType {
|
|||||||
exit: () => void;
|
exit: () => void;
|
||||||
/** Update status bar message */
|
/** Update status bar message */
|
||||||
setStatus: (message: string) => void;
|
setStatus: (message: string) => void;
|
||||||
/** Whether the wallet is initialized */
|
|
||||||
isWalletInitialized: boolean;
|
|
||||||
/** Set wallet initialized state */
|
|
||||||
setWalletInitialized: (initialized: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
46
src/utils/logger.ts
Normal file
46
src/utils/logger.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
export class Logger {
|
||||||
|
constructor(
|
||||||
|
private readonly endpoint: string,
|
||||||
|
private readonly token: string,
|
||||||
|
private readonly path: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
send(level: 'log' | 'error' | 'warn' | 'info', message: string, ...metadata: unknown[]) {
|
||||||
|
const data = {
|
||||||
|
level,
|
||||||
|
message: `${this.path}: ${message}`,
|
||||||
|
metadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch(`${this.endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': this.token,
|
||||||
|
},
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('Failed to send log to logger:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
log(message: string, ...metadata: unknown[]) {
|
||||||
|
this.send('log', message, ...metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: string, ...metadata: unknown[]) {
|
||||||
|
this.send('error', message, ...metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(message: string, ...metadata: unknown[]) {
|
||||||
|
this.send('warn', message, ...metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
info(message: string, ...metadata: unknown[]) {
|
||||||
|
this.send('info', message, ...metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
child(path: string): Logger {
|
||||||
|
return new Logger(this.endpoint, this.token, `${this.path}.${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,10 +58,15 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
|||||||
* @param identifier - The invitation identifier.
|
* @param identifier - The invitation identifier.
|
||||||
* @returns The invitation.
|
* @returns The invitation.
|
||||||
*/
|
*/
|
||||||
async getInvitation(identifier: string): Promise<XOInvitation> {
|
async getInvitation(identifier: string): Promise<XOInvitation | undefined> {
|
||||||
// Send a GET request to the sync server
|
// Send a GET request to the sync server
|
||||||
const response = await fetch(`${this.baseUrl}/invitations/${identifier}`);
|
const response = await fetch(`${this.baseUrl}/invitations?invitationIdentifier=${identifier}`);
|
||||||
const invitation = await response.json() as XOInvitation;
|
|
||||||
|
if(!response.ok) {
|
||||||
|
throw new Error(`Failed to get invitation: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const invitation = await response.json() as XOInvitation | undefined;
|
||||||
return invitation;
|
return invitation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +80,9 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
|||||||
const response = await fetch(`${this.baseUrl}/invitations`, {
|
const response = await fetch(`${this.baseUrl}/invitations`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: encodeExtendedJson(invitation),
|
body: encodeExtendedJson(invitation),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Throw is there was an issue with the request
|
// Throw is there was an issue with the request
|
||||||
|
|||||||
Reference in New Issue
Block a user