UNPKG

create-midnight-app

Version:

🌙 Scaffold for building Midnight smart contracts with dynamic type detection and automated CLI generation

335 lines (297 loc) 12.2 kB
import { type Interface } from 'node:readline/promises'; import { type Logger } from 'pino'; import { ContractAnalyzer, type ContractAnalysis, type ContractFunction } from './contract-analyzer.js'; import { type CounterProviders, type DeployedCounterContract } from './common-types.js'; export interface MenuItem { id: string; label: string; description: string; action: (providers: CounterProviders, contract: DeployedCounterContract, rli: Interface) => Promise<void>; isReadOnly: boolean; } /** * Dynamically generates CLI menus and handlers based on contract analysis */ export class DynamicCLIGenerator { private analyzer: ContractAnalyzer; private logger: Logger; private contractAnalysis: ContractAnalysis | null = null; constructor(logger: Logger) { this.analyzer = new ContractAnalyzer(); this.logger = logger; } /** * Initialize the CLI generator by analyzing the contract */ async initialize(): Promise<void> { try { this.contractAnalysis = await this.analyzer.analyzeContract(); this.logger.info(`Analyzed contract: ${this.contractAnalysis.contractName}`); this.logger.info(`Found ${this.contractAnalysis.functions.length} functions`); } catch (error) { this.logger.error('Failed to analyze contract:', error); throw error; } } /** * Generate menu items based on contract functions */ generateMenuItems(): MenuItem[] { if (!this.contractAnalysis) { throw new Error('Contract analysis not initialized. Call initialize() first.'); } const menuItems: MenuItem[] = []; // Add contract functions this.contractAnalysis.functions.forEach((func, index) => { const menuItem: MenuItem = { id: `func_${func.name}`, label: this.formatFunctionLabel(func), description: func.description || `Execute ${func.name}`, action: this.createFunctionHandler(func), isReadOnly: this.analyzer.isReadOnlyFunction(func.name) }; menuItems.push(menuItem); }); // Add utility functions menuItems.push({ id: 'display_state', label: 'Display contract state', description: 'Show current values of all ledger state', action: this.createStateDisplayHandler(), isReadOnly: true }); menuItems.push({ id: 'exit', label: 'Exit', description: 'Exit the CLI', action: async () => { this.logger.info('Exiting...'); return; }, isReadOnly: true }); return menuItems; } /** * Generate the main menu question text */ generateMenuQuestion(menuItems: MenuItem[]): string { let question = '\nYou can do one of the following:\n'; menuItems.forEach((item, index) => { const number = index + 1; // Only show (read-only) for utility functions like "Display contract state" and "Exit" const isUtilityFunction = item.id === 'display_state' || item.id === 'exit'; const readOnlyIndicator = (item.isReadOnly && isUtilityFunction) ? ' (read-only)' : ''; question += ` ${number}. ${item.label}${readOnlyIndicator}\n`; }); question += 'Which would you like to do? '; return question; } /** * Create a function handler for a specific contract function */ private createFunctionHandler(func: ContractFunction): (providers: CounterProviders, contract: DeployedCounterContract, rli: Interface) => Promise<void> { return async (providers: CounterProviders, contract: DeployedCounterContract, rli: Interface) => { try { this.logger.info(`🔧 Executing ${func.name}...`); // Collect parameters if needed const args: any[] = []; for (const param of func.parameters) { const value = await this.collectParameter(param, rli); args.push(value); } // Execute the function if (this.analyzer.isReadOnlyFunction(func.name)) { // For read-only functions, call them and display the result await this.executeReadOnlyFunction(func.name, args, providers, contract); } else { // For state-changing functions, execute them through the contract await this.executeStateChangingFunction(func.name, args, contract); } this.logger.info(`✅ ${func.name} executed successfully!`); } catch (error: unknown) { if (error instanceof Error) { this.logger.error(`❌ Operation failed: ${error.message}`); if (error.message.includes('member')) { this.logger.warn('💡 This might be because you have already voted. Each wallet can only vote once.'); } } else { this.logger.error(`❌ Unknown error occurred: ${error}`); } } }; } /** * Create a handler for displaying contract state */ private createStateDisplayHandler(): (providers: CounterProviders, contract: DeployedCounterContract, rli: Interface) => Promise<void> { return async (providers: CounterProviders, contract: DeployedCounterContract) => { if (!this.contractAnalysis) return; const api = await import('./api.js'); this.logger.info('=== Contract State ==='); this.logger.info(`Contract Address: ${contract.deployTxData.public.contractAddress}`); // Get the full ledger state try { const contractState = await providers.publicDataProvider.queryContractState(contract.deployTxData.public.contractAddress); if (contractState) { // Import contract module to get ledger function const { contracts } = await import('@midnight-ntwrk/contract'); const contractNames = Object.keys(contracts); if (contractNames.length > 0) { const contractModule = contracts[contractNames[0]]; const ledgerState = contractModule.ledger(contractState.data); // Display each ledger state variable for (const [stateName, stateType] of Object.entries(this.contractAnalysis.ledgerState)) { try { let value: any; if (stateName === 'items' && typeof ledgerState[stateName] === 'object') { // Handle Set<data> - check for size and isEmpty methods const itemsSet = ledgerState[stateName]; if (typeof itemsSet.size === 'function' && typeof itemsSet.isEmpty === 'function') { const size = itemsSet.size(); const isEmpty = itemsSet.isEmpty(); if (isEmpty) { value = 'Empty set'; } else { this.logger.info('Checking items set...'); // Try to iterate through items const items: string[] = []; try { for (const item of itemsSet) { items.push(Array.from(item).join(',')); if (items.length >= 10) break; // Limit to 10 items for display } value = `Set with ${size} item(s)${items.length > 0 ? ': [' + items.join(', ') + ']' : ''}`; } catch { value = `Set with ${size} item(s)`; } this.logger.info(`Found ${size} items in set`); } } else { value = 'Set<data>'; } } else { // Handle regular state variables value = ledgerState[stateName]; if (typeof value === 'bigint') { value = value.toString(); } } this.logger.info(`${stateName} (${stateType}): ${value}`); } catch (error) { this.logger.warn(`Could not fetch ${stateName}: ${error}`); this.logger.info(`${stateName} (${stateType}): Error reading value`); } } } else { this.logger.error('No contract module found'); } } else { this.logger.error('Could not query contract state'); } } catch (error) { this.logger.error(`Failed to fetch contract state: ${error}`); } }; } /** * Collect a parameter value from user input */ private async collectParameter(param: {name: string, type: string}, rli: Interface): Promise<any> { if (this.analyzer.requiresSpecialHandling(param.name)) { return await this.collectSpecialParameter(param, rli); } const prompt = `Enter ${param.name} (${param.type}): `; const input = await rli.question(prompt); // Convert input based on type switch (param.type) { case 'number': const num = parseInt(input, 10); if (isNaN(num)) { throw new Error(`Invalid number: ${input}`); } return BigInt(num); case 'boolean': return input.toLowerCase() === 'true' || input === '1'; case 'bytes': // Convert hex string to Uint8Array if (input.startsWith('0x')) { return new Uint8Array(Buffer.from(input.slice(2), 'hex')); } return new Uint8Array(Buffer.from(input, 'utf8')); case 'text': // Handle opaque string types by creating proper opaque value const api = await import('./api.js'); try { // Create opaque string value - remove quotes if they exist const cleanInput = input.replace(/^["']|["']$/g, ''); return api.createOpaqueString(cleanInput); } catch (error) { // Fallback to plain string if opaque creation fails this.logger.debug(`Failed to create opaque string, using plain string: ${error}`); return input.replace(/^["']|["']$/g, ''); } default: return input; } } /** * Handle special parameter collection (e.g., voting options) */ private async collectSpecialParameter(param: {name: string, type: string}, rli: Interface): Promise<any> { if (param.name.includes('index')) { // For vote_for and get_vote_count functions const choice = await rli.question( 'Select option:\n 0. Option A\n 1. Option B\nEnter choice (0 or 1): ' ); const index = parseInt(choice, 10); if (index !== 0 && index !== 1) { throw new Error('Invalid choice. Please enter 0 or 1.'); } return BigInt(index); } // Fallback to normal parameter collection return await this.collectParameter(param, rli); } /** * Execute a read-only function and display results */ private async executeReadOnlyFunction( functionName: string, args: any[], providers: CounterProviders, contract: DeployedCounterContract ): Promise<void> { const api = await import('./api.js'); this.logger.info(`Read-only function ${functionName} executed with args: ${JSON.stringify(args)}`); } /** * Execute a state-changing function */ private async executeStateChangingFunction( functionName: string, args: any[], contract: DeployedCounterContract ): Promise<void> { // Use dynamic property access to call the function const contractFunction = (contract.callTx as any)[functionName]; if (!contractFunction) { throw new Error(`Function ${functionName} not found on contract`); } const result = await contractFunction(...args); this.logger.info(`Transaction ${result.public.txId} added in block ${result.public.blockHeight}`); } /** * Format function name for display */ private formatFunctionLabel(func: ContractFunction): string { // Convert snake_case to title case and add parameter info const formatted = func.name .split('_') .map(word => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); const paramCount = func.parameters.length; const paramInfo = paramCount > 0 ? ` (${paramCount} param${paramCount > 1 ? 's' : ''})` : ''; return `${formatted}${paramInfo}`; } }