Skip to main content
Glama
index.ts62.9 kB
/* istanbul ignore file */ import { createLogger } from '../logger/index.js'; import type { Logger } from 'pino'; import { toHex } from '@midnight-ntwrk/midnight-js-utils'; import { webcrypto } from 'crypto'; import * as path from 'node:path'; import * as fs from 'node:fs'; import * as Rx from 'rxjs'; // Remove direct import of testcontainers // import { DockerComposeEnvironment, Wait, type StartedDockerComposeEnvironment } from 'testcontainers'; import { setNetworkId, NetworkId, getZswapNetworkId } from '@midnight-ntwrk/midnight-js-network-id'; import { nativeToken } from '@midnight-ntwrk/ledger'; import { WalletBuilder } from '@midnight-ntwrk/wallet'; import { type Wallet } from '@midnight-ntwrk/wallet-api'; import { type Resource } from '@midnight-ntwrk/wallet'; import { config } from '../config.js'; import { convertBigIntToDecimal, convertDecimalToBigInt } from './utils.js'; import { WalletStatus, WalletBalances, SendFundsResult, TransactionVerificationResult, TransactionState, TransactionRecord, InitiateTransactionResult, TransactionStatusResult, MarketplaceUserData, RegistrationResult, VerificationResult, TokenInfo, TokenBalance, TokenOperationResult, CoinInfo } from '../types/wallet.js'; import { TransactionDatabase } from './db/TransactionDatabase.js'; import { FileManager, FileType } from '../utils/file-manager.js'; // Import audit trail components import { TransactionTraceLogger, AgentDecisionLogger, AuditTrailService } from '../audit/index.js'; // Import marketplace API functions import { isPublicKeyRegistered, verifyTextPure, joinContract, register, marketplaceRegistryContractInstance, configureProviders } from '../integrations/marketplace/api.js'; // Import shielded token manager import { ShieldedTokenManager } from './shielded-tokens.js'; // Import DAO service import { DaoService } from './dao.js'; // Set up crypto for Scala.js // globalThis.crypto = webcrypto; // Define necessary types for testcontainers to keep TypeScript happy // These will be used only for type annotations, not in runtime interface DockerContainer { getFirstMappedPort(): number; } interface StartedDockerComposeEnvironment { getContainer(containerName: string): DockerContainer; down(): Promise<void>; } interface DockerComposeEnvironment { withWaitStrategy(containerName: string, waitStrategy: any): DockerComposeEnvironment; up(): Promise<StartedDockerComposeEnvironment>; } // Flag to check if we're in development or production const isDevelopment = process.env.NODE_ENV !== 'production'; /** * Configuration for wallet connection to Midnight network */ export interface WalletConfig { indexer: string; indexerWS: string; node: string; proofServer: string; logDir?: string; networkId?: NetworkId; useExternalProofServer?: boolean; // Flag to indicate if we should use an external proof server } /** * Transaction history entry as available in the wallet state */ interface TransactionHistoryEntry { applyStage: string; deltas: Record<string, bigint>; identifiers: string[]; transactionHash: string; transaction: { __wbg_ptr: number }; // WebAssembly pointer, not directly usable } const CONTAINER_NAME = 'proof-server'; /** * Testnet remote configuration */ export class TestnetRemoteConfig implements WalletConfig { public indexer = 'https://indexer.testnet-02.midnight.network/api/v1/graphql'; public indexerWS = 'wss://indexer.testnet-02.midnight.network/api/v1/graphql/ws'; public node = 'https://rpc.testnet-02.midnight.network'; public proofServer = 'http://127.0.0.1:6300'; public logDir: string; constructor() { this.logDir = path.resolve('./logs', `${new Date().toISOString()}.log`); setNetworkId(NetworkId.TestNet); } } /** * Helper function to get the current directory */ export const getCurrentDir = (): string => { return path.resolve(process.cwd()); }; /** * Maps container ports for Docker environment */ const mapContainerPort = (env: StartedDockerComposeEnvironment, url: string, containerName: string): string => { const mappedUrl = new URL(url); const container = env.getContainer(containerName); mappedUrl.port = String(container.getFirstMappedPort()); return mappedUrl.toString().replace(/\/+$/, ''); }; /** * Helper to convert stream to string */ const streamToString = async (stream: NodeJS.ReadableStream): Promise<string> => { const chunks: Buffer[] = []; return new Promise((resolve, reject) => { stream.on('data', (chunk) => chunks.push(Buffer.from(chunk))); stream.on('error', (err) => reject(err)); stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); }); }; // Internal wallet balances interface for type safety (using bigint for calculations) interface InternalWalletBalances { // The total spendable balance in the wallet balance: bigint; // Coins that are pending and not yet available for spending pendingBalance: bigint; } /** * WalletManager that handles wallet operations, initialization, and Docker containers */ export class WalletManager { private readonly fileManager = FileManager.getInstance(); private wallet: (Wallet & Resource) | null = null; private ready: boolean = false; private config: WalletConfig; private logger: Logger; private dockerEnv?: any; // Use any for now, will be assigned proper type at runtime private startedEnv?: StartedDockerComposeEnvironment; private walletSyncSubscription?: Rx.Subscription; private walletInitPromise: Promise<void>; private walletAddress: string = ''; private walletFilename: string = ''; private lastSaveTime: number = 0; private saveInterval: number = 5000; // Save at most every 5 seconds private recoveryAttempts: number = 0; private maxRecoveryAttempts: number = 5; private recoveryBackoffMs: number = 5000; // Start with 5 seconds backoff private walletSeed: string = ''; private isRecovering: boolean = false; private syncedIndices: bigint = 0n; private applyGap: bigint = 0n; private sourceGap: bigint = 0n; private walletState: any = null; private agentId: string; // Transaction tracking private transactionDb: TransactionDatabase; private transactionPoller?: NodeJS.Timeout; private pollingInterval: number = 15000; // Poll for completed transactions every 15 seconds // Audit trail components private auditService: AuditTrailService; private transactionLogger: TransactionTraceLogger; private agentLogger: AgentDecisionLogger; // Shielded token manager private shieldedTokenManager: ShieldedTokenManager; // DAO service private daoService: DaoService; // Track different balance types for the wallet (using bigint internally) private walletBalances: InternalWalletBalances = { balance: 0n, pendingBalance: 0n }; /** * Create a new WalletManager instance * @param networkId Optional network ID to connect to (defaults to TestNet) * @param seed Optional hex seed for the wallet * @param walletFilename Optional filename to restore wallet from * @param externalConfig Optional external configuration for connecting to a proof server */ constructor(networkId: NetworkId, seed: string, walletFilename: string, externalConfig?: WalletConfig) { this.agentId = config.agentId; this.logger = createLogger('wallet-manager'); // Initialize audit trail components this.auditService = AuditTrailService.getInstance(); this.transactionLogger = new TransactionTraceLogger(this.auditService); this.agentLogger = new AgentDecisionLogger(this.auditService); // Initialize shielded token manager this.shieldedTokenManager = new ShieldedTokenManager(this); // DAO service will be initialized after wallet is ready this.daoService = null as any; // Set network ID if provided, default to TestNet this.logger.info('Initializing WalletManager with networkId: %s, walletFilename: %s, externalConfig: %s, agentId: %s', networkId, walletFilename, externalConfig?.useExternalProofServer, this.agentId); this.config = externalConfig || new TestnetRemoteConfig(); if (networkId) { setNetworkId(networkId); } // Initialize logger this.logger.info('Initializing WalletManager'); // Store wallet filename and seed for recovery this.walletFilename = walletFilename; this.walletSeed = seed; // Initialize the transaction database with agent-specific path const dbPath = path.join(config.walletBackupFolder, `${walletFilename}-transactions.db`); this.transactionDb = new TransactionDatabase(dbPath); this.logger.info(`Transaction database initialized at ${dbPath}`); // Initialize wallet asynchronously to not block MCP server startup // Properly chain the async operations this.walletInitPromise = this.initWalletWithProperSetup(seed, walletFilename, externalConfig); // Start transaction status poller when wallet is ready this.walletInitPromise.then(() => { this.startTransactionPoller(); }).catch(err => { this.logger.error('Failed to start transaction poller due to wallet init error', err); }); } /** * Private method to handle the proper setup sequence * This ensures Docker environment is fully set up before wallet initialization */ private async initWalletWithProperSetup(seed: string, walletFilename: string, externalConfig?: WalletConfig): Promise<void> { try { // Handle Docker environment setup first if needed if (!externalConfig?.useExternalProofServer && isDevelopment) { this.logger.info('Setting up Docker environment, using internal proof server'); try { await this.setupDockerEnvironment(); this.logger.info('Docker environment setup completed successfully'); } catch (dockerSetupError) { this.logger.error('Failed to set up Docker environment', dockerSetupError); throw new Error('Docker environment setup failed. Wallet initialization cannot proceed.'); } } else if (externalConfig?.useExternalProofServer) { this.logger.info(`Using external proof server at ${this.config.proofServer}`); } else if (!isDevelopment) { this.logger.info('Running in production mode, skipping Docker environment setup. External proof server must be configured.'); // In production, ensure we have an external proof server if Docker can't be used if (!externalConfig?.useExternalProofServer) { this.logger.warn('WARNING: Running in production without external proof server configuration.'); } } // Now that environment is properly set up, initialize the wallet try { await this.initializeWallet(seed, walletFilename); this.logger.info('Wallet initialization sequence completed'); } catch (walletInitError: unknown) { this.logger.error('Failed to initialize wallet', walletInitError); // Rethrow with a clearer message throw new Error(`Wallet initialization failed: ${walletInitError instanceof Error ? walletInitError.message : 'Unknown error'}`); } } catch (error) { this.logger.error('Critical error during wallet setup process', error); // Keep track of the setup failure to allow checking the state later this.isRecovering = true; this.recoveryAttempts += 1; throw error; // Propagate error for higher-level handling } } /** * Configure Docker environment for proof server */ private async setupDockerEnvironment(): Promise<void> { try { // Skip setup in production if (!isDevelopment) { this.logger.info('Skipping Docker environment setup in production mode'); return; } const currentDir = getCurrentDir(); const configDir = path.resolve(currentDir, './src/wallet/config'); this.logger.debug('Config directory: %s', configDir); // verify the file exists const proofServerYml = path.resolve(configDir, 'proof-server-testnet.yml'); this.logger.debug('Proof server YAML path: %s', proofServerYml); if (!fs.existsSync(proofServerYml)) { const filesInConfigDir = fs.readdirSync(configDir); this.logger.error('Files inside configDir:', filesInConfigDir); throw new Error(`Proof server YAML file not found at ${proofServerYml}`); } try { // Dynamically import testcontainers only in development mode const { DockerComposeEnvironment, Wait } = await import('testcontainers'); this.dockerEnv = new DockerComposeEnvironment( configDir, 'proof-server-testnet.yml', ).withWaitStrategy( CONTAINER_NAME, Wait.forLogMessage('Actix runtime found; starting in Actix runtime', 1) ); this.logger.info('Docker environment configured'); } catch (importError) { this.logger.error('Failed to import testcontainers', importError); throw new Error('Failed to import testcontainers. Make sure it is installed as a development dependency.'); } } catch (error) { this.logger.error('Failed to configure Docker environment', error); } } /** * Initialize wallet by starting Docker and configuring wallet */ private async initializeWallet(seed: string, walletFilename?: string): Promise<void> { try { // Start Docker environment if not using external proof server and in development mode if (this.dockerEnv && !this.config.useExternalProofServer && isDevelopment) { this.logger.info('Starting Docker environment'); try { this.startedEnv = await this.dockerEnv.up(); // Update config with mapped ports if (this.startedEnv) { this.config.proofServer = mapContainerPort( this.startedEnv, this.config.proofServer, CONTAINER_NAME ); this.logger.info(`Docker environment started, proof server at ${this.config.proofServer}`); } else { this.logger.error('Docker environment started but startedEnv is undefined'); } } catch (dockerError) { this.logger.error('Failed to start Docker environment', dockerError); throw new Error('Failed to start Docker environment. If running in production, configure an external proof server.'); } } else if (this.config.useExternalProofServer) { this.logger.info(`Using external proof server at ${this.config.proofServer}`); } else if (!isDevelopment) { this.logger.info('Running in production mode without Docker, using configured proof server'); } // Generate a random seed if not provided const finalFilename = walletFilename || ''; // Initialize wallet try { this.wallet = await this.buildWalletFromSeed(seed, finalFilename); if (this.wallet) { // Initialize DAO service now that wallet is ready this.daoService = new DaoService(this.wallet); // Subscribe to wallet state changes with error recovery this.setupWalletSubscription(); this.logger.info('Wallet initialized successfully, syncing in progress'); } else { this.logger.error('Failed to initialize wallet, wallet instance is null'); } } catch (error) { this.logger.error('Failed to initialize wallet', error); } } catch (error) { this.logger.error('Error during wallet initialization process', error); } } /** * Sets up the wallet subscription with error handling and recovery */ private setupWalletSubscription(): void { if (!this.wallet) { this.logger.error('Cannot setup subscription: wallet is null'); return; } if (this.walletSyncSubscription) { this.walletSyncSubscription.unsubscribe(); } this.walletSyncSubscription = this.wallet.state().subscribe({ next: async (state) => { try { this.walletState = state; this.recoveryAttempts = 0; this.recoveryBackoffMs = 5000; // Only use lag for progress, synced is now a boolean const applyGap = state.syncProgress?.lag?.applyGap ?? 0n; const sourceGap = state.syncProgress?.lag?.sourceGap ?? 0n; const isSynced = state.syncProgress?.synced ?? false; this.walletAddress = state.address || ''; const nativeBalance = state.balances[nativeToken()] ?? 0n; const pendingBalance = state.pendingCoins.filter(coin => coin.type === nativeToken()).reduce((acc, coin) => acc + coin.value, 0n); this.walletBalances = { balance: nativeBalance, pendingBalance: pendingBalance }; // this.logger.info(`Native balance: ${convertBigIntToDecimal(nativeBalance)}`); // this.logger.info(`Pending balance: ${convertBigIntToDecimal(pendingBalance)}`); // No more total/syncedIndices, just lag this.applyGap = applyGap; this.sourceGap = sourceGap; // Check if wallet is fully synced (no gaps) if (isSynced) { if (!this.ready) { this.ready = true; this.logger.info('Wallet is fully synced and ready!'); this.logger.info(`Wallet address: ${this.walletAddress}`); this.logger.info(`Wallet balance: ${convertBigIntToDecimal(this.walletBalances.balance)}`); await this.saveWalletToFile(this.walletFilename); } } else { this.logger.info(`Wallet syncing: applyGap=${applyGap}, sourceGap=${sourceGap}`); const now = Date.now(); if (now - this.lastSaveTime >= this.saveInterval) { this.lastSaveTime = now; await this.saveWalletToFile(this.walletFilename); } } } catch (error) { this.logger.error('Error processing wallet state update', error); this.attemptWalletRecovery('State processing error'); } }, error: (err) => { this.logger.error(`Wallet state subscription error: ${err}`); this.attemptWalletRecovery('Subscription error'); }, complete: () => { this.logger.warn('Wallet state subscription completed unexpectedly'); this.attemptWalletRecovery('Subscription completed'); } }); } /** * Attempts to recover the wallet after an error * @param reason Reason for recovery attempt */ private async attemptWalletRecovery(reason: string): Promise<void> { if (this.isRecovering) { this.logger.info('Recovery already in progress, skipping new attempt'); return; } this.isRecovering = true; try { this.recoveryAttempts++; this.ready = false; this.syncedIndices = 0n; this.applyGap = 0n; this.sourceGap = 0n; this.logger.warn(`Attempting wallet recovery (${this.recoveryAttempts}/${this.maxRecoveryAttempts}). Reason: ${reason}`); // Check if we've exceeded max recovery attempts if (this.recoveryAttempts > this.maxRecoveryAttempts) { this.logger.error(`Max recovery attempts (${this.maxRecoveryAttempts}) exceeded. Wallet may need manual intervention.`); return; } // Unsubscribe from current subscription if (this.walletSyncSubscription) { this.walletSyncSubscription.unsubscribe(); this.walletSyncSubscription = undefined; } // Try to save current wallet state if possible if (this.wallet) { try { await this.saveWalletToFile(this.walletFilename); this.logger.info('Successfully saved wallet state before recovery'); } catch (saveError) { this.logger.warn('Failed to save wallet state before recovery', saveError); } // Close the current wallet try { await this.wallet.close(); this.logger.info('Successfully closed wallet for recovery'); } catch (closeError) { this.logger.warn('Error closing wallet during recovery', closeError); } this.wallet = null; } // Apply exponential backoff before reconnecting const backoffTime = Math.min(this.recoveryBackoffMs * Math.pow(1.5, this.recoveryAttempts - 1), 60000); // Max 1 minute this.logger.info(`Waiting ${backoffTime}ms before attempting recovery`); await new Promise(resolve => setTimeout(resolve, backoffTime)); // Attempt to rebuild the wallet try { this.wallet = await this.buildWalletFromSeed(this.walletSeed, this.walletFilename); if (this.wallet) { // Re-initialize DAO service after wallet recovery this.daoService = new DaoService(this.wallet); this.setupWalletSubscription(); this.logger.info('Wallet recovered successfully'); } else { this.logger.error('Failed to rebuild wallet during recovery'); } } catch (rebuildError) { this.logger.error('Error rebuilding wallet during recovery', rebuildError); // Schedule another recovery attempt with backoff if we haven't exceeded max attempts if (this.recoveryAttempts < this.maxRecoveryAttempts) { this.recoveryBackoffMs = Math.min(this.recoveryBackoffMs * 2, 60000); // Double backoff time, max 1 minute this.logger.info(`Scheduling another recovery attempt in ${this.recoveryBackoffMs}ms`); setTimeout(() => { this.isRecovering = false; this.attemptWalletRecovery('Previous recovery failed'); }, this.recoveryBackoffMs); } } } finally { this.isRecovering = false; } } /** * Build wallet from seed and optionally restore from file */ private async buildWalletFromSeed(seed: string, filename: string): Promise<Wallet & Resource> { const { indexer, indexerWS, node, proofServer } = this.config; let wallet: Wallet & Resource; // Store the filename for future saves if (filename) { this.walletFilename = filename; } const formattedFilename = `${filename}.json`; // Try to restore wallet from file if filename is provided if (filename && this.fileManager.fileExists(FileType.WALLET_BACKUP, this.agentId, formattedFilename)) { this.logger.info(`Attempting to restore wallet from ${formattedFilename}`); try { const serialized = this.fileManager.readFile(FileType.WALLET_BACKUP, this.agentId, formattedFilename); const cleanSerialized = serialized.trim().startsWith('"') ? JSON.parse(serialized) : serialized; // Restore wallet from serialized state this.logger.info(`Restoring wallet with seed: ${seed}`); wallet = await WalletBuilder.restore( indexer, indexerWS, proofServer, node, seed, cleanSerialized, 'info' ); wallet.start(); this.logger.info('Wallet restored from file and started'); } catch (error) { this.logger.warn('Failed to restore wallet, building from seed', error); wallet = await WalletBuilder.buildFromSeed( indexer, indexerWS, proofServer, node, seed, getZswapNetworkId(), 'info' ); wallet.start(); } } else { // Build new wallet from seed this.logger.info('Building fresh wallet from seed'); wallet = await WalletBuilder.buildFromSeed( indexer, indexerWS, proofServer, node, seed, getZswapNetworkId(), 'info' ); wallet.start(); } return wallet; } /** * Check if the wallet is ready for operations * @param withDetails Whether to return detailed status information * @returns true if wallet is synced and ready, or status object if withDetails is true */ public isReady(withDetails: boolean = false): boolean | { ready: boolean; recovering: boolean; recoveryAttempts: number; synced?: string; applyGap?: string; sourceGap?: string; } { if (withDetails) { return { ready: this.ready, recovering: this.isRecovering, recoveryAttempts: this.recoveryAttempts, synced: this.syncedIndices.toString(), applyGap: this.applyGap.toString(), sourceGap: this.sourceGap.toString() }; } return this.ready; } /** * Get the wallet's address * @returns The wallet address as a string * @throws Error if wallet is not ready */ public getAddress(): string { return this.walletAddress; } /** * Get the wallet's current balance with detailed breakdown * @returns An object containing different balance types with dust amounts as strings * @throws Error if wallet is not ready */ public getBalance(): WalletBalances { if (!this.ready) throw new Error('Wallet not ready'); return { balance: convertBigIntToDecimal(this.walletBalances.balance), pendingBalance: convertBigIntToDecimal(this.walletBalances.pendingBalance) }; } /** * Send funds to the specified destination address * @param to Address to send the funds to * @param amount Amount of funds to send (as a string with decimal value in dust) * @returns Transaction result with hash, sync status, and the amount as a dust string * @throws Error if wallet is not ready or if there are insufficient funds */ public async sendFunds(to: string, amount: string): Promise<SendFundsResult> { if (!this.ready) throw new Error('Wallet not ready'); if (!this.wallet) throw new Error('Wallet instance not available'); // Generate correlation ID for audit trail const correlationId = this.auditService.generateCorrelationId(); const transactionId = `tx-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; // Start transaction trace this.transactionLogger.startTrace(transactionId, correlationId, { amount, recipient: to, agentId: this.agentId, operation: 'sendFunds' }); // Log agent decision for transaction this.agentLogger.logTransactionDecision( this.agentId, transactionId, 'approve', `Transaction validated: amount ${amount} to ${to}`, amount, to, correlationId ); try { const amountBigInt = convertDecimalToBigInt(amount); // Add validation step const validationStepId = this.transactionLogger.addStep( transactionId, 'validate_funds', 'wallet-manager', { amount, to, currentBalance: convertBigIntToDecimal(this.walletBalances.balance) } ); if (this.walletBalances.balance < amountBigInt) { if (this.walletBalances.balance >= amountBigInt) { const pendingAmount = this.walletBalances.pendingBalance; const formattedAvailableBalance = convertBigIntToDecimal(this.walletBalances.balance); const formattedPendingAmount = convertBigIntToDecimal(pendingAmount); const error = new Error( `Insufficient available funds for transaction. You have ${formattedAvailableBalance} available, ` + `but ${formattedPendingAmount} is still pending. Please wait for pending transactions to complete.` ); // Complete validation step with error this.transactionLogger.completeStep(transactionId, validationStepId, undefined, error); this.transactionLogger.completeTrace(transactionId, 'failed', 'Insufficient available funds'); throw error; } const formattedTotalBalance = convertBigIntToDecimal(this.walletBalances.balance); const error = new Error(`Insufficient funds for transaction. You have ${formattedTotalBalance} total, but need ${amount}.`); // Complete validation step with error this.transactionLogger.completeStep(transactionId, validationStepId, undefined, error); this.transactionLogger.completeTrace(transactionId, 'failed', 'Insufficient funds'); throw error; } // Complete validation step successfully this.transactionLogger.completeStep(transactionId, validationStepId, { valid: true, availableBalance: convertBigIntToDecimal(this.walletBalances.balance), requiredAmount: amount }); // Add transaction creation step const creationStepId = this.transactionLogger.addStep( transactionId, 'create_transaction', 'wallet-manager', { amount, to, amountBigInt: amountBigInt.toString() } ); const transferRecipe = await this.wallet.transferTransaction([ { amount: amountBigInt, type: nativeToken(), receiverAddress: to } ]); // Complete creation step this.transactionLogger.completeStep(transactionId, creationStepId, { transferRecipe: 'created', nativeToken: true }); // Add proof generation step const proofStepId = this.transactionLogger.addStep( transactionId, 'generate_proof', 'wallet-manager', { transferRecipe: 'ready' } ); const provenTransaction = await this.wallet.proveTransaction(transferRecipe); // Complete proof step this.transactionLogger.completeStep(transactionId, proofStepId, { proven: true, proofGenerated: true }); // Add submission step const submissionStepId = this.transactionLogger.addStep( transactionId, 'submit_transaction', 'wallet-manager', { provenTransaction: 'ready' } ); const submittedTransaction = await this.wallet.submitTransaction(provenTransaction); // Complete submission step this.transactionLogger.completeStep(transactionId, submissionStepId, { submitted: true, txIdentifier: submittedTransaction }); this.logger.info(`Transaction submitted: ${submittedTransaction}`); // Log transaction sent to blockchain this.transactionLogger.logTransactionSent(transactionId, submittedTransaction, correlationId); const isFullySynced = this.walletState?.syncProgress?.synced ?? false; const transaction = this.transactionDb.createTransaction( this.walletAddress, to, amount ); this.transactionDb.markTransactionAsSent(transaction.id, submittedTransaction); // Complete transaction trace successfully this.transactionLogger.completeTrace(transactionId, 'completed', 'Transaction submitted successfully', { txIdentifier: submittedTransaction, transactionId: transaction.id, syncStatus: isFullySynced }); return { txIdentifier: submittedTransaction, syncStatus: { syncedIndices: this.syncedIndices.toString(), lag: { applyGap: this.applyGap.toString(), sourceGap: this.sourceGap.toString() }, isFullySynced }, amount }; } catch (error) { this.logger.error('Failed to send funds', error); // Log transaction failure this.transactionLogger.logTransactionFailure(transactionId, error as Error, { amount, recipient: to, agentId: this.agentId }, correlationId); throw error; } } /** * Save wallet state to file * @param filename Optional filename to save to * @returns path to saved file */ public async saveWalletToFile(filename?: string): Promise<string | null> { if (!this.wallet) { this.logger.error('Cannot save wallet: wallet not initialized'); return null; } try { // Use provided filename or use the one stored in the class const walletFilename = filename || this.walletFilename || `wallet-${Date.now()}`; const formattedFilename = `${walletFilename}.json`; this.logger.info(`Saving wallet to file ${formattedFilename} for agent ${this.agentId}`); const walletJson = await this.wallet.serializeState(); this.fileManager.writeFile(FileType.WALLET_BACKUP, this.agentId, walletJson, formattedFilename); const filePath = this.fileManager.getPath(FileType.WALLET_BACKUP, this.agentId, formattedFilename); this.logger.info(`Wallet saved to ${filePath}`); return filePath; } catch (error) { this.logger.error(`Failed to save wallet: ${error}`); return null; } } /** * Close the wallet manager, shutting down wallet and Docker */ public async close(): Promise<void> { try { // Set ready to false early to prevent new operations this.ready = false; // Stop transaction poller if (this.transactionPoller) { clearInterval(this.transactionPoller); this.transactionPoller = undefined; this.logger.info('Transaction poller stopped'); } // Close transaction database try { this.transactionDb.close(); this.logger.info('Transaction database closed'); } catch (dbError) { this.logger.warn('Error closing transaction database', dbError); } // Save wallet state before closing if (this.wallet) { try { await this.saveWalletToFile(this.walletFilename); this.logger.info('Wallet state saved before shutdown'); } catch (saveError) { this.logger.warn('Could not save wallet before shutdown', saveError); } } // Unsubscribe from wallet state updates if (this.walletSyncSubscription) { this.walletSyncSubscription.unsubscribe(); this.logger.info('Wallet sync subscription unsubscribed'); } // Close wallet if (this.wallet) { await this.wallet.close(); this.logger.info('Wallet closed successfully'); } // Shutdown Docker environment only if we started it and we're in development mode if (this.startedEnv && !this.config.useExternalProofServer && isDevelopment) { await this.startedEnv.down(); this.logger.info('Docker environment shut down successfully'); } } catch (error) { this.logger.error('Error during shutdown', error); throw error; } } /** * Manually triggers wallet recovery * Useful when external systems detect issues with the wallet * @returns Promise that resolves when recovery attempt is complete */ public async recoverWallet(): Promise<void> { this.logger.info('Manual wallet recovery triggered'); await this.attemptWalletRecovery('Manual recovery request'); // Wait a bit to let the recovery process start return new Promise(resolve => setTimeout(resolve, 100)); } /** * Verifies if the wallet has received a transaction with the specified identifier * * @param identifier The transaction identifier to look for * @returns Object containing verification result and current sync status * @throws Error if wallet is not ready or not initialized */ public hasReceivedTransactionByIdentifier(identifier: string): TransactionVerificationResult { if (!this.ready) throw new Error('Wallet not ready'); if (!this.wallet) throw new Error('Wallet instance not available'); try { if (!this.walletState || !this.walletState.transactionHistory || !Array.isArray(this.walletState.transactionHistory)) { this.logger.warn('Transaction history not available in stored wallet state'); return { exists: false, syncStatus: { syncedIndices: this.syncedIndices.toString(), lag: { applyGap: this.applyGap.toString(), sourceGap: this.sourceGap.toString() }, isFullySynced: this.walletState?.syncProgress?.synced ?? false }, transactionAmount: '0' }; } const matchingTransaction = this.walletState.transactionHistory.find((tx: TransactionHistoryEntry) => tx && Array.isArray(tx.identifiers) && tx.identifiers.length > 0 && tx.identifiers.includes(identifier) ); if (!matchingTransaction) { return { exists: false, syncStatus: { syncedIndices: this.syncedIndices.toString(), lag: { applyGap: this.applyGap.toString(), sourceGap: this.sourceGap.toString() }, isFullySynced: this.walletState?.syncProgress?.synced ?? false }, transactionAmount: '0' }; } // Get the amount from the transaction deltas const nativeTokenAmount = matchingTransaction.deltas[nativeToken()] ?? 0n; const amount = convertBigIntToDecimal(nativeTokenAmount); return { exists: true, syncStatus: { syncedIndices: this.syncedIndices.toString(), lag: { applyGap: this.applyGap.toString(), sourceGap: this.sourceGap.toString() }, isFullySynced: this.walletState?.syncProgress?.synced ?? false }, transactionAmount: amount }; } catch (error) { this.logger.error(`Error verifying transaction receipt: ${error}`); throw error; } } /** * Get detailed wallet status including sync progress, readiness, and recovery state * @returns Detailed wallet status object with balances as dust strings */ public getWalletStatus(): WalletStatus { // Only use lag for progress, synced is a boolean const applyGap = this.applyGap; const sourceGap = this.sourceGap; const isSynced = this.walletState?.syncProgress?.synced ?? false; // Percentage: 100 if fully synced, 0 otherwise (or you can use a custom logic) const syncPercentage = isSynced ? 100 : 0; const isFullySynced = isSynced; return { ready: this.ready, syncing: applyGap > 0n || sourceGap > 0n, syncProgress: { synced: isSynced, lag: { applyGap: applyGap.toString(), sourceGap: sourceGap.toString() }, percentage: syncPercentage }, address: this.walletAddress, balances: { balance: convertBigIntToDecimal(this.walletBalances.balance), pendingBalance: convertBigIntToDecimal(this.walletBalances.pendingBalance) }, recovering: this.isRecovering, recoveryAttempts: this.recoveryAttempts, maxRecoveryAttempts: this.maxRecoveryAttempts, isFullySynced }; } /** * Start the transaction status poller */ private startTransactionPoller(): void { // Clear any existing poller first if (this.transactionPoller) { clearInterval(this.transactionPoller); this.transactionPoller = undefined; } // Only start poller if wallet is ready if (!this.ready || !this.wallet) { this.logger.debug('Skipping transaction poller start - wallet not ready'); return; } this.logger.info(`Starting transaction poller with interval of ${this.pollingInterval}ms`); // Check for any pending sent transactions that might have completed during downtime this.checkPendingTransactions(); this.transactionPoller = setInterval(() => { // Only run if wallet is still ready if (this.ready && this.wallet && !this.isRecovering) { this.checkPendingTransactions(); } else { // Stop the poller if wallet is no longer ready if (this.transactionPoller) { clearInterval(this.transactionPoller); this.transactionPoller = undefined; this.logger.debug('Stopped transaction poller - wallet no longer ready'); } } }, this.pollingInterval); } /** * Check for pending transactions that might have been completed */ public async checkPendingTransactions(): Promise<void> { if (!this.ready || !this.wallet) { return; } try { // Get all transactions in SENT state that need to be checked const sentTransactions = this.transactionDb.getTransactionsByState(TransactionState.SENT); if (sentTransactions.length === 0) { return; } this.logger.info(`Checking ${sentTransactions.length} sent transactions for completion`); for (const tx of sentTransactions) { if (!tx.txIdentifier) continue; // Check if transaction appears in wallet history const verificationResult = this.hasReceivedTransactionByIdentifier(tx.txIdentifier); if (verificationResult.exists) { this.logger.info(`Transaction ${tx.id} with txIdentifier ${tx.txIdentifier} found in blockchain history, marking as completed`); this.transactionDb.markTransactionAsCompleted(tx.txIdentifier); } else { this.logger.debug(`Transaction ${tx.id} with txIdentifier ${tx.txIdentifier} not yet found in blockchain history`); } } } catch (error) { // Only log errors if the wallet is still ready and we're not in the process of shutting down if (this.ready && this.wallet && !this.isRecovering) { this.logger.error('Error checking pending transactions', error); } } } /** * Initiate a transaction to send funds, but don't wait for completion * @param to Address to send the funds to * @param amount Amount of funds to send (as a string with decimal value in dust) * @returns Object containing the initiated transaction details * @throws Error if wallet is not ready or if there are insufficient funds */ public async initiateSendFunds(to: string, amount: string): Promise<InitiateTransactionResult> { if (!this.ready) throw new Error('Wallet not ready'); if (!this.wallet) throw new Error('Wallet instance not available'); // Generate correlation ID for audit trail const correlationId = this.auditService.generateCorrelationId(); try { const amountBigInt = convertDecimalToBigInt(amount); // Log agent decision for transaction initiation this.agentLogger.logTransactionDecision( this.agentId, 'initiation', 'approve', `Transaction initiation validated: amount ${amount} to ${to}`, amount, to, correlationId ); if (this.walletBalances.balance < amountBigInt) { if (this.walletBalances.balance >= amountBigInt) { const pendingAmount = this.walletBalances.pendingBalance; const formattedAvailableBalance = convertBigIntToDecimal(this.walletBalances.balance); const formattedPendingAmount = convertBigIntToDecimal(pendingAmount); const error = new Error( `Insufficient available funds for transaction. You have ${formattedAvailableBalance} available, ` + `but ${formattedPendingAmount} is still pending. Please wait for pending transactions to complete.` ); // Log transaction failure this.transactionLogger.logTransactionFailure('initiation', error, { amount, recipient: to, agentId: this.agentId }, correlationId); throw error; } const formattedTotalBalance = convertBigIntToDecimal(this.walletBalances.balance); const error = new Error(`Insufficient funds for transaction. You have ${formattedTotalBalance} total, but need ${amount}.`); // Log transaction failure this.transactionLogger.logTransactionFailure('initiation', error, { amount, recipient: to, agentId: this.agentId }, correlationId); throw error; } // Create transaction record in database const transaction = this.transactionDb.createTransaction( this.walletAddress, to, amount ); this.logger.info(`Initiated transaction ${transaction.id} to ${to} for ${amount}`); // Start transaction trace for the initiated transaction this.transactionLogger.startTrace(transaction.id, correlationId, { amount, recipient: to, agentId: this.agentId, operation: 'initiateSendFunds', transactionId: transaction.id }); // Start the async process to send funds, but don't await it this.processSendFundsAsync(transaction.id, to, amount, correlationId); return { id: transaction.id, state: TransactionState.INITIATED, toAddress: to, amount, createdAt: transaction.createdAt }; } catch (error) { this.logger.error('Failed to initiate funds transfer', error); throw error; } } /** * Async process to send funds after initiation (doesn't block the caller) * @param transactionId UUID of the transaction record * @param to Recipient address * @param amount Amount to send in dust string format * @param correlationId Optional correlation ID for audit trail */ private async processSendFundsAsync(transactionId: string, to: string, amount: string, correlationId?: string): Promise<void> { if (!this.wallet) throw new Error('Wallet instance not available'); try { const amountBigInt = convertDecimalToBigInt(amount); // Add transaction creation step const creationStepId = this.transactionLogger.addStep( transactionId, 'create_transfer_recipe', 'wallet-manager', { amount, to, amountBigInt: amountBigInt.toString() } ); // Create a transfer transaction const transferRecipe = await this.wallet.transferTransaction([ { amount: amountBigInt, type: nativeToken(), receiverAddress: to } ]); // Complete creation step this.transactionLogger.completeStep(transactionId, creationStepId, { transferRecipe: 'created', nativeToken: true }); // Add proof generation step const proofStepId = this.transactionLogger.addStep( transactionId, 'generate_proof', 'wallet-manager', { transferRecipe: 'ready' } ); // Prove and submit the transaction const provenTransaction = await this.wallet.proveTransaction(transferRecipe); // Complete proof step this.transactionLogger.completeStep(transactionId, proofStepId, { proven: true, proofGenerated: true }); // Add submission step const submissionStepId = this.transactionLogger.addStep( transactionId, 'submit_transaction', 'wallet-manager', { provenTransaction: 'ready' } ); const submittedTransaction = await this.wallet.submitTransaction(provenTransaction); // Complete submission step this.transactionLogger.completeStep(transactionId, submissionStepId, { submitted: true, txIdentifier: submittedTransaction }); this.logger.info(`Transaction submitted for ${transactionId}: ${submittedTransaction}`); // Log transaction sent to blockchain this.transactionLogger.logTransactionSent(transactionId, submittedTransaction, correlationId); // Update transaction record with txIdentifier and set state to SENT this.transactionDb.markTransactionAsSent(transactionId, submittedTransaction); // Complete transaction trace successfully this.transactionLogger.completeTrace(transactionId, 'completed', 'Transaction submitted successfully', { txIdentifier: submittedTransaction, transactionId: transactionId }); } catch (error) { this.logger.error(`Failed to process send funds for transaction ${transactionId}`, error); // Log transaction failure this.transactionLogger.logTransactionFailure(transactionId, error as Error, { amount, recipient: to, agentId: this.agentId }, correlationId); // Mark the transaction as failed in the database this.transactionDb.markTransactionAsFailed(transactionId, error instanceof Error ? `Failed at processing transaction: ${error.message}` : 'Unknown error processing transaction'); throw error; } } /** * Get transaction status by ID * @param id Transaction ID * @returns Transaction status including blockchain verification */ public getTransactionStatus(id: string): TransactionStatusResult | null { try { const transaction = this.transactionDb.getTransactionById(id); if (!transaction) { return null; } // If we have a txIdentifier and transaction is in SENT state, check blockchain status if (transaction.txIdentifier && transaction.state === TransactionState.SENT) { const blockchainStatus = this.hasReceivedTransactionByIdentifier(transaction.txIdentifier); // Convert BigInt values to strings for safe JSON serialization return { transaction, blockchainStatus: { exists: blockchainStatus.exists, syncStatus: { syncedIndices: blockchainStatus.syncStatus.syncedIndices.toString(), lag: blockchainStatus.syncStatus.lag, isFullySynced: blockchainStatus.syncStatus.isFullySynced } } }; } // For COMPLETED, FAILED, and INITIATED states, don't include blockchain status return { transaction }; } catch (error) { this.logger.error(`Failed to get transaction status for ${id}`, error); throw error; } } /** * Get all transactions with a specific state * @param state Optional transaction state to filter by * @returns Array of transaction records */ public getTransactions(): TransactionRecord[] { try { const transactions = this.transactionDb.getAllTransactions(); return transactions.map(tx => ({ ...tx, id: tx.txIdentifier ?? tx.id ?? '' })); } catch (error) { this.logger.error('Failed to get transactions', error); throw error; } } /** * Get pending transactions (INITIATED or SENT) * @returns Array of pending transaction records */ public getPendingTransactions(): TransactionRecord[] { try { return this.transactionDb.getPendingTransactions(); } catch (error) { this.logger.error('Failed to get pending transactions', error); throw error; } } /** * Register a user in the marketplace * @param userId The user ID to register * @param userData The user data to register * @returns Registration result */ public async registerInMarketplace(userId: string, userData: MarketplaceUserData): Promise<RegistrationResult> { if (!this.ready) throw new Error('Wallet not ready'); if (!this.wallet) throw new Error('Wallet instance not available'); try { // Get the wallet's public key const walletAddress = this.getAddress(); this.logger.info(`Registering user ${userId} in marketplace with wallet address ${walletAddress}`); // Create marketplace providers from the wallet const providers = await configureProviders(this.wallet); const contractAddress = userData.marketplaceAddress; // Join the marketplace contract const marketplaceContract = await joinContract(providers, contractAddress); // Create the registration text (combine userId and userData) const registrationText = userId; // Register the user in the marketplace const registrationResult = await register(marketplaceContract, registrationText); const result = { success: true, userId, userData, walletAddress, transactionId: registrationResult.txId, blockHeight: registrationResult.blockHeight, timestamp: new Date().toISOString(), message: 'Registration successful', contractAddress: marketplaceContract.deployTxData.public.contractAddress }; this.logger.info(`User ${userId} registered in marketplace with transaction ${registrationResult.txId}`); return result; } catch (error) { this.logger.error('Failed to register in marketplace', error); throw error; } } /** * Verify a user in the marketplace * @param userId The user ID to verify * @param verificationData The verification data * @returns Verification result */ public async verifyUserInMarketplace(userId: string, verificationData: MarketplaceUserData): Promise<VerificationResult> { if (!this.ready) throw new Error('Wallet not ready'); if (!this.wallet) throw new Error('Wallet instance not available'); try { // Create marketplace providers from the wallet const providers = await configureProviders(this.wallet); const contractAddress = verificationData.marketplaceAddress; this.logger.info(`Verifying user ${userId} in marketplace with contract address ${contractAddress}`); // Check if the wallet's public key is registered const isRegistered = await isPublicKeyRegistered(providers, contractAddress, verificationData.pubkey); if (!isRegistered) { return { valid: false, userId, userData: verificationData, reason: 'Wallet is not registered in the marketplace' } } // Verify the text identifier for this public key const verifiedText = await verifyTextPure(providers, contractAddress, verificationData.pubkey); if (!verifiedText) { return { valid: false, userId, userData: verificationData, reason: 'Verification failed' } } // Verify that the userId matches if (verifiedText !== userId) { return { valid: false, userId, userData: verificationData, reason: 'User ID mismatch - wallet is registered for a different user' } } const result = { valid: true, userId, userData: verificationData, reason: 'Verification successful' }; this.logger.info(`User ${userId} verified in marketplace`); return result; } catch (error) { this.logger.error('Failed to verify user in marketplace', error); throw error; } } // ==================== SHIELDED TOKEN OPERATIONS ==================== /** * Register a token with a human-readable name * @param name Human-readable token name * @param symbol Token symbol * @param contractAddress Contract address for the token * @param domainSeparator Domain separator for token type generation * @param description Optional description * @param decimals Number of decimal places (default: 6) * @returns Token operation result */ public registerToken( name: string, symbol: string, contractAddress: string, domainSeparator: string = 'custom_token', description?: string, decimals?: number ): TokenOperationResult { return this.shieldedTokenManager.registerToken(name, symbol, contractAddress, domainSeparator, description, decimals); } /** * Get token information by name * @param tokenName Token name * @returns Token information or null if not found */ public getTokenInfo(tokenName: string): TokenInfo | null { return this.shieldedTokenManager.getTokenInfo(tokenName); } /** * Get token balance by name * @param tokenName Token name * @returns Token balance as string or "0" if not found */ public getTokenBalance(tokenName: string): string { return this.shieldedTokenManager.getTokenBalance(tokenName); } /** * Send tokens to another address * @param tokenName Token name * @param toAddress Recipient address * @param amount Amount to send * @returns Transaction result */ public async sendToken( tokenName: string, toAddress: string, amount: string ): Promise<SendFundsResult> { return this.shieldedTokenManager.sendToken(tokenName, toAddress, amount); } /** * List all registered tokens with their balances * @returns Array of token balances */ public listWalletTokens(): TokenBalance[] { return this.shieldedTokenManager.listWalletTokens(); } /** * Create a coin for DAO voting (requires 500 tokens) * @param tokenName Token name to create coin for * @param amount Amount for the coin (default 500 for DAO voting) * @returns Coin info for transactions */ public createCoinForVoting(tokenName: string, amount: bigint = 500n): CoinInfo { return this.shieldedTokenManager.createCoinForVoting(tokenName, amount); } /** * Create a coin for treasury funding (default 100 tokens) * @param tokenName Token name to create coin for * @param amount Amount for the coin (default 100 for treasury funding) * @returns Coin info for transactions */ public createCoinForFunding(tokenName: string, amount: bigint = 100n): CoinInfo { return this.shieldedTokenManager.createCoinForFunding(tokenName, amount); } /** * Get all registered token names * @returns Array of registered token names */ public getRegisteredTokenNames(): string[] { return this.shieldedTokenManager.getRegisteredTokenNames(); } /** * Check if a token is registered * @param tokenName Token name * @returns True if token is registered */ public isTokenRegistered(tokenName: string): boolean { return this.shieldedTokenManager.isTokenRegistered(tokenName); } /** * Remove a token from registry * @param tokenName Token name * @returns True if token was removed */ public unregisterToken(tokenName: string): boolean { return this.shieldedTokenManager.unregisterToken(tokenName); } /** * Get token registry statistics * @returns Registry statistics */ public getTokenRegistryStats(): { totalTokens: number; tokensBySymbol: Record<string, number> } { return this.shieldedTokenManager.getRegistryStats(); } /** * Register multiple tokens in batch * @param tokenConfigs Array of token configurations * @returns Batch registration result */ public registerTokensBatch(tokenConfigs: Array<{ name: string; symbol: string; contractAddress: string; domainSeparator?: string; description?: string; }>): { success: boolean; registeredCount: number; errors: Array<{ token: string; error: string }>; registeredTokens: string[]; } { return this.shieldedTokenManager.registerTokensBatch(tokenConfigs); } /** * Register tokens from environment variable string * @param envValue Environment variable value with token configurations * @returns Batch registration result */ public registerTokensFromEnvString(envValue: string): { success: boolean; registeredCount: number; errors: Array<{ token: string; error: string }>; registeredTokens: string[]; } { return this.shieldedTokenManager.registerTokensFromEnvString(envValue); } /** * Get token configuration template for environment variables * @returns Example configuration string */ public getTokenEnvConfigTemplate(): string { return this.shieldedTokenManager.getEnvConfigTemplate(); } // ==================== DAO OPERATIONS ==================== /** * Open a new election in the DAO voting contract * @param electionId Unique identifier for the election * @returns Transaction result */ public async openDaoElection(electionId: string) { if (!this.ready || !this.daoService) { throw new Error('Wallet not ready or DAO service not initialized'); } return await this.daoService.openDaoElection(electionId); } /** * Close the current election in the DAO voting contract * @returns Transaction result */ public async closeDaoElection() { if (!this.ready || !this.daoService) { throw new Error('Wallet not ready or DAO service not initialized'); } return await this.daoService.closeDaoElection(); } /** * Cast a vote in the DAO election * @param voteType Type of vote (YES, NO, ABSENT) * @returns Transaction result */ public async castDaoVote(voteType: string) { if (!this.ready || !this.daoService) { throw new Error('Wallet not ready or DAO service not initialized'); } return await this.daoService.castDaoVote(voteType); } /** * Fund the DAO treasury with tokens * @param amount Amount to fund the treasury * @returns Transaction result */ public async fundDaoTreasury(amount: string) { if (!this.ready || !this.daoService) { throw new Error('Wallet not ready or DAO service not initialized'); } return await this.daoService.fundDaoTreasury(amount); } /** * Payout an approved proposal from the DAO treasury * @returns Transaction result */ public async payoutDaoProposal() { if (!this.ready || !this.daoService) { throw new Error('Wallet not ready or DAO service not initialized'); } return await this.daoService.payoutDaoProposal(); } /** * Get the current status of the DAO election * @returns Election status */ public async getDaoElectionStatus() { if (!this.ready || !this.daoService) { throw new Error('Wallet not ready or DAO service not initialized'); } return await this.daoService.getDaoElectionStatus(); } /** * Get the full state of the DAO voting contract * @returns DAO state */ public async getDaoState() { if (!this.ready || !this.daoService) { throw new Error('Wallet not ready or DAO service not initialized'); } return await this.daoService.getDaoState(); } /** * Get DAO configuration template * @returns DAO configuration template */ public getDaoConfigTemplate(): string { const { getDaoEnvConfigTemplate } = require('./dao-config.js'); return getDaoEnvConfigTemplate(); } } export default WalletManager;

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/evilpixi/pixi-midnight-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server