UNPKG

nano-mcp

Version:

NANO MCP (Nano Cryptocurrency) Server for AI Assistants - A JSON-RPC 2.0 API server for Nano cryptocurrency operations with QR code generation, local work generation and auto-receive pending blocks

915 lines (812 loc) 43.8 kB
// WARNING: This file is locked and should not be modified in the future. // Any changes to this file may affect the compatibility of nano-mcp. // Please refer to the documentation for any updates or modifications. // ⚠️⚠️⚠️ CRITICAL: RPC NODE CONFIGURATION ⚠️⚠️⚠️ // ⚠️ DO NOT CHANGE THE RPC NODE: https://uk1.public.xnopay.com/proxy // ⚠️ DO NOT add fallback nodes or alternative RPC endpoints // ⚠️ This node has been specifically configured by the user // ⚠️ Work generation is done LOCALLY - not on the RPC node // ⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️ "use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.NanoTransactions = void 0; const node_fetch_1 = __importDefault(require("node-fetch")); const nanocurrency_web_1 = require("nanocurrency-web"); const nanocurrency = require('nanocurrency'); const { block } = require('nanocurrency-web'); const { EnhancedErrorHandler } = require('./error-handler'); const { BalanceConverter } = require('./balance-converter'); class NanoTransactions { /** * Constructs a NanoTransactions instance with custom and global configuration. * @param {Object} customConfig - Custom configuration for this instance. * @param {Object} config - Optional global configuration object. */ constructor(customConfig, config) { const globalConfig = config?.getNanoConfig() || {}; this.rpcNodes = customConfig?.rpcNodes || [customConfig?.apiUrl] || [globalConfig.rpcUrl] || ['https://rpc.nano.to']; this.currentNodeIndex = 0; // Allow null rpcKey to be explicitly set this.rpcKey = customConfig?.hasOwnProperty('rpcKey') ? customConfig.rpcKey : (globalConfig.rpcKey || null); this.gpuKey = customConfig?.gpuKey || globalConfig.gpuKey; this.defaultRepresentative = customConfig?.defaultRepresentative || globalConfig.defaultRepresentative || 'nano_3qya5xpjfsbk3ndfebo9dsrj6iy6f6idmogqtn1mtzdtwnxu6rw3dz18i6xf'; this.config = config; if (config) { const errors = config.validateConfig(); if (errors.length > 0) { throw new Error(`Configuration errors: ${errors.join(', ')}`); } } this.failoverAttempts = 0; this.maxFailoverAttempts = this.rpcNodes.length * 2; // Try each node twice before giving up // Track recently attempted blocks to prevent duplicates this.recentBlockAttempts = new Map(); // key: blockHash, value: { timestamp, attempts } this.duplicateBlockTTL = 60000; // 60 seconds console.log('[NanoTransactions] Initialized without work cache to prevent cache-related issues'); console.log('[NanoTransactions] Duplicate block detection enabled (60s TTL)'); } async getCurrentRpcNode() { return this.rpcNodes[this.currentNodeIndex]; } switchToNextNode() { this.currentNodeIndex = (this.currentNodeIndex + 1) % this.rpcNodes.length; console.log(`Switching to RPC node: ${this.rpcNodes[this.currentNodeIndex]}`); } /** * Makes a generic RPC call to the configured Nano node. * @param {string} action - The RPC action to perform. * @param {Object} params - Additional parameters for the RPC call. * @returns {Promise<Object>} - The response from the Nano node. */ async rpcCall(action, params = {}) { let lastError = null; this.failoverAttempts = 0; while (this.failoverAttempts < this.maxFailoverAttempts) { const currentNode = await this.getCurrentRpcNode(); console.log(`Making RPC call to ${currentNode}:`, { action, ...params }); try { const requestBody = { action, ...params }; // Only include key if it's not null if (this.rpcKey) { requestBody.key = this.rpcKey; } const response = await node_fetch_1.default(currentNode, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }); // Check for rate limiting response if (response.status === 429) { console.log('Rate limit hit, switching nodes...'); this.switchToNextNode(); this.failoverAttempts++; continue; } if (!response.ok) { throw new Error(`RPC call failed: ${response.statusText}`); } const data = await response.json(); console.log('RPC Response:', data); // Reset failover attempts on successful call this.failoverAttempts = 0; return data; } catch (error) { console.error(`Error with RPC node ${currentNode}:`, error.message); lastError = error; this.switchToNextNode(); this.failoverAttempts++; } } // If we've tried all nodes and still failed, throw the last error throw new Error(`All RPC nodes failed. Last error: ${lastError?.message}`); } /** * Validates the provided configuration and throws if errors are found. * @param {Array<string>} errors - List of configuration errors. * @returns {Promise<Object>} - Validation result object. */ async validateConfig(errors) { if (errors?.length > 0) { throw new Error(`Configuration errors: ${errors.join(', ')}`); } return { isValid: true, errors: [], warnings: [] }; } /** * Retrieves account information for a given Nano account. * @param {string} account - The Nano account address. * @returns {Promise<Object>} - Account information from the node. */ async getAccountInfo(account) { const info = await this.rpcCall('account_info', { account }); return info; } /** * Retrieves pending (unreceived) blocks for a given Nano account. * @param {string} account - The Nano account address. * @returns {Promise<Object>} - Pending blocks information. */ async getPendingBlocks(account) { const pending = await this.rpcCall('pending', { account, count: '1', source: 'true' }); return pending; } /** * Generates proof-of-work for a given hash using RPC node with timeout protection. * Uses RPC node's work_generate action for fast, reliable work generation. * @param {string} hash - The hash to generate work for. * @param {boolean} isOpen - Whether this is for an open block (lower difficulty). * @returns {Promise<string>} - The generated work value. */ async generateWork(hash, isOpen = false) { if (!hash) { throw new Error('Hash is required for work generation'); } console.log('Generating work using RPC node for hash:', hash); try { // NANO network difficulty thresholds // Receive/Open blocks: lower difficulty (fffffe0000000000) // Send/Change blocks: higher difficulty (fffffff800000000) const difficulty = isOpen ? 'fffffe0000000000' : 'fffffff800000000'; console.log(`Requesting work generation from RPC with ${isOpen ? 'RECEIVE/OPEN' : 'SEND/CHANGE'} difficulty: ${difficulty}`); // Set timeout based on block type (10s for send, 5s for receive) const timeout = isOpen ? 5000 : 10000; // Use RPC node's work_generate with timeout protection const work = await this._generateWorkWithTimeout(hash, difficulty, timeout); if (!work) { throw new Error('Work generation returned null - RPC node failed to generate work'); } console.log('Work generated successfully via RPC:', work); return work; } catch (error) { console.error('RPC work generation failed:', error); throw new Error(`Failed to generate work via RPC: ${error.message}`); } } /** * Internal method: Generate work via RPC with timeout protection * @private */ async _generateWorkWithTimeout(hash, difficulty, timeoutMs) { return new Promise(async (resolve, reject) => { const timer = setTimeout(() => { reject(new Error(`Work generation timed out after ${timeoutMs}ms. RPC node may be slow or unavailable. Please retry.`)); }, timeoutMs); try { const workResult = await this.rpcCall('work_generate', { hash, difficulty }); clearTimeout(timer); if (workResult.error) { reject(new Error(`RPC work_generate error: ${workResult.error}`)); return; } if (!workResult.work) { reject(new Error('RPC returned no work value')); return; } resolve(workResult.work); } catch (error) { clearTimeout(timer); reject(error); } }); } /** * Generates proof-of-work using RPC node with retry logic and exponential backoff. * This is useful for production environments where RPC work generation may occasionally fail. * Much faster than local work generation (typically <1 second vs 10-15 seconds). * @param {string} hash - The hash to generate work for. * @param {boolean} isOpen - Whether this is for an open block (lower difficulty). * @param {number} maxRetries - Maximum number of retry attempts (default: 3). * @returns {Promise<string>} - The generated work value. */ async generateWorkWithRetry(hash, isOpen = false, maxRetries = 3) { console.log(`Generating work via RPC with retry logic (max ${maxRetries} attempts)...`); let lastError; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { console.log(`RPC work generation attempt ${attempt}/${maxRetries}...`); const work = await this.generateWork(hash, isOpen); console.log(`RPC work generation succeeded on attempt ${attempt}`); return work; } catch (error) { lastError = error; console.error(`RPC work generation attempt ${attempt} failed:`, error.message); if (attempt < maxRetries) { // Exponential backoff: 1s, 2s, 4s, etc. const backoffMs = Math.pow(2, attempt - 1) * 1000; console.log(`Retrying RPC work generation in ${backoffMs}ms...`); await new Promise(resolve => setTimeout(resolve, backoffMs)); } else { console.error(`All ${maxRetries} RPC work generation attempts failed`); } } } throw new Error(`RPC work generation failed after ${maxRetries} retry attempts. Last error: ${lastError.message}`); } /** * Creates and processes a receive (or open) block for a pending transaction. * Handles both new and existing accounts, calculates the correct new balance, and submits the block. * @param {string} account - The Nano account address. * @param {string} privateKey - The private key for signing the block. * @param {string} pendingBlock - The hash of the pending block to receive. * @param {string|number|BigInt} pendingAmount - The amount to receive (in raw). * @param {Object|null} accountInfo - Optional account info; if null, treated as a new account. * @returns {Promise<Object>} - The result of the process RPC call. */ async createReceiveBlock(account, privateKey, pendingBlock, pendingAmount, accountInfo = null) { try { // For first receive (opening account), use the account's public key // For subsequent receives, use the account's frontier let workHash; if (accountInfo && !accountInfo.error) { workHash = accountInfo.frontier; console.log('Using frontier as work hash:', workHash); } else { // For new accounts, we need to use the account's public key console.log('Account for public key derivation:', account); workHash = nanocurrency_web_1.tools.addressToPublicKey(account); console.log('Derived public key from address:', workHash); } if (!workHash) { throw new Error('Failed to determine work hash'); } console.log('Using work hash:', workHash); // Generate work via RPC with retry logic for production reliability console.log('Generating work via RPC with retry protection...'); const workValue = await this.generateWorkWithRetry(workHash, !accountInfo || accountInfo.error, 2); const work = { work: workValue }; // Ensure account is in nano_ format const nanoAccount = account.replace('xrb_', 'nano_'); // Get the representative let representative = this.defaultRepresentative; if (accountInfo && !accountInfo.error && accountInfo.representative) { representative = accountInfo.representative; } console.log('Using representative:', representative); // Calculate new balance for verification let newBalance; let currentBalance; if (accountInfo && !accountInfo.error) { // For existing accounts, add pending amount to current balance currentBalance = accountInfo.balance; newBalance = (BigInt(currentBalance) + BigInt(pendingAmount)).toString(); } else { // For new accounts, balance starts at 0 currentBalance = '0'; newBalance = pendingAmount; } console.log('Current balance:', currentBalance); console.log('Pending amount:', pendingAmount); console.log('Expected new balance:', newBalance); // Prepare block data for block.receive // IMPORTANT: nanocurrency-web's block.receive() calculates newBalance internally by doing: // newBalance = walletBalanceRaw + amountRaw // So walletBalanceRaw should be the CURRENT balance (before receive), NOT the new balance const receiveBlockData = { walletBalanceRaw: currentBalance, // Current balance BEFORE receive toAddress: nanoAccount, representativeAddress: representative, frontier: accountInfo && !accountInfo.error ? accountInfo.frontier : '0000000000000000000000000000000000000000000000000000000000000000', transactionHash: pendingBlock, amountRaw: pendingAmount, // Amount to receive (will be added by library) work: work.work }; console.log('Block data for nanocurrency-web:', receiveBlockData); // Sign the block using nanocurrency-web's block.receive const signedBlock = block.receive(receiveBlockData, privateKey); console.log('Signed block:', signedBlock); // Process the block using the signed block const processResult = await this.rpcCall('process', { json_block: 'true', subtype: accountInfo && !accountInfo.error ? 'receive' : 'open', block: signedBlock }); if (processResult.error) { // Handle blockchain errors with enhanced messaging const errorResult = EnhancedErrorHandler.blockchainError( processResult.error, 'receive block', { account: account, pendingBlock: pendingBlock, pendingAmount: pendingAmount, hash: workHash, work: workValue, blockType: accountInfo && !accountInfo.error ? 'receive' : 'open' } ); // For createReceiveBlock, throw the enhanced error so it can be caught by caller const error = new Error(errorResult.error); error.enhancedError = errorResult; throw error; } return processResult; } catch (error) { console.error('Error in createReceiveBlock:', error); // If it's an enhanced error, return it directly if (error.enhancedError) { throw error.enhancedError; } throw error; } } /** * Receives all pending blocks for a given account, processing each one sequentially. * @param {string} address - The Nano account address. * @param {string} privateKey - The private key for signing receive blocks. * @returns {Promise<Array<Object>>} - Results of processing each pending block. */ async receiveAllPending(address, privateKey) { // Get account info let accountInfo; try { accountInfo = await this.getAccountInfo(address); } catch (error) { // Account not opened yet, which is fine accountInfo = null; } // Get pending blocks const pending = await this.getPendingBlocks(address); const results = []; if (pending.blocks && Object.keys(pending.blocks).length > 0) { for (const [hash, blockInfo] of Object.entries(pending.blocks)) { try { // Log the raw amount (avoid RPC call for conversion) console.log(`Receiving ${blockInfo.amount} raw from block ${hash}`); const result = await this.createReceiveBlock( address, privateKey, hash, blockInfo.amount, accountInfo ); results.push(result); // Update accountInfo for next iteration if the block was processed if (result.hash) { try { accountInfo = await this.getAccountInfo(address); } catch (error) { console.error('Failed to update account info:', error); } } } catch (error) { console.error('Failed to process pending block:', error); // If it's an enhanced error object, include it directly if (error.errorCode) { results.push(error); } else { results.push({ error: error.message }); } } } } return results; } /** * Initializes a NANO account by receiving the first pending block. * This is used to open/activate a new account that has received funds. * @param {string} address - The Nano account address to initialize. * @param {string} privateKey - The private key for signing the block. * @returns {Promise<Object>} - Initialization result with status and details. */ async initializeAccount(address, privateKey) { try { // Check if account is already initialized let accountInfo; try { accountInfo = await this.getAccountInfo(address); if (accountInfo && !accountInfo.error) { return { initialized: true, alreadyInitialized: true, message: 'Account is already initialized', representative: accountInfo.representative, balance: accountInfo.balance, frontier: accountInfo.frontier }; } } catch (error) { // Account doesn't exist yet, which is expected console.log('Account not yet initialized, checking for pending blocks...'); } // Get pending blocks to initialize the account const pending = await this.getPendingBlocks(address); if (!pending.blocks || Object.keys(pending.blocks).length === 0) { return { initialized: false, message: 'No pending blocks to initialize the account. Send some NANO to this address first.', address: address }; } // Process the first pending block to initialize the account const firstBlockHash = Object.keys(pending.blocks)[0]; const firstBlockInfo = pending.blocks[firstBlockHash]; console.log(`Initializing account with pending block ${firstBlockHash}...`); const result = await this.createReceiveBlock( address, privateKey, firstBlockHash, firstBlockInfo.amount, null // null accountInfo indicates this is a new account ); if (result.hash) { // Get updated account info const updatedAccountInfo = await this.getAccountInfo(address); return { initialized: true, alreadyInitialized: false, message: 'Account successfully initialized', blockHash: result.hash, representative: updatedAccountInfo.representative || this.defaultRepresentative, balance: updatedAccountInfo.balance, frontier: updatedAccountInfo.frontier }; } else { throw new Error('Failed to process initialization block'); } } catch (error) { console.error('Error initializing account:', error); throw new Error(`Failed to initialize account: ${error.message}`); } } /** * Generates a new Nano wallet (seed, account, private/public key). * @returns {Promise<Object>} - The generated wallet information. */ async generateWallet() { const walletData = nanocurrency_web_1.wallet.generateLegacy(); return { address: walletData.accounts[0].address, privateKey: walletData.accounts[0].privateKey, publicKey: walletData.accounts[0].publicKey, seed: walletData.seed }; } /** * Makes a generic RPC request to the Nano node. * @param {string} method - The RPC method/action. * @param {Object} params - Parameters for the RPC call. * @returns {Promise<Object>} - The response from the Nano node. */ async makeRequest(method, params) { return this.rpcCall(method, params); } /** * Sends a transaction from one Nano account to another. * Handles work generation, block signing, and submission. * @param {string} fromAddress - The sender's Nano account address. * @param {string} privateKey - The sender's private key. * @param {string} toAddress - The recipient's Nano account address. * @param {string|number|BigInt} amountRaw - The amount to send (in raw). * @returns {Promise<Object>} - Success status and block hash or error message. */ async sendTransaction(fromAddress, privateKey, toAddress, amountRaw) { // Define these outside try block so they're available in catch and throughout function let formattedFromAddress; let formattedToAddress; let privateKeyString; let amountRawString; try { // Ensure all parameters are properly typed formattedFromAddress = String(fromAddress).replace('xrb_', 'nano_'); formattedToAddress = String(toAddress).replace('xrb_', 'nano_'); privateKeyString = String(privateKey); amountRawString = String(amountRaw); // Log the requested amount IMMEDIATELY to track any doubling console.log('[AmountTrack] ========== SEND TRANSACTION STARTED =========='); console.log('[AmountTrack] Amount REQUESTED by user (raw):', amountRawString); try { const requestedNano = BalanceConverter.rawToNano(amountRawString); console.log('[AmountTrack] Amount REQUESTED by user (NANO):', requestedNano); } catch (e) { console.log('[AmountTrack] Could not convert to NANO (invalid raw value?)'); } // IMPORTANT: Check for pending blocks and receive them first console.log('Checking for pending blocks before sending...'); const pending = await this.getPendingBlocks(formattedFromAddress); let lastFrontier = null; if (pending.blocks && Object.keys(pending.blocks).length > 0) { console.log(`Found ${Object.keys(pending.blocks).length} pending block(s). Receiving them first...`); // Get frontier BEFORE receiving const preReceiveInfo = await this.makeRequest('account_info', { account: formattedFromAddress }); lastFrontier = preReceiveInfo.frontier; console.log('[FrontierTrack] Frontier before receive:', lastFrontier); const receiveResults = await this.receiveAllPending(formattedFromAddress, privateKeyString); console.log('Received all pending blocks:', receiveResults); // Get the new frontier from the last successful receive const lastSuccessfulReceive = receiveResults.filter(r => r.hash).pop(); if (lastSuccessfulReceive && lastSuccessfulReceive.hash) { lastFrontier = lastSuccessfulReceive.hash; console.log('[FrontierTrack] New frontier from receive:', lastFrontier); } // Wait for RPC node to update with retry mechanism console.log('[FrontierTrack] Waiting for RPC node to confirm new frontier...'); let retries = 0; const maxRetries = 5; while (retries < maxRetries) { await new Promise(resolve => setTimeout(resolve, 2000)); // Wait 2 seconds const checkInfo = await this.makeRequest('account_info', { account: formattedFromAddress }); console.log(`[FrontierTrack] Retry ${retries + 1}/${maxRetries} - Current frontier:`, checkInfo.frontier); if (checkInfo.frontier === lastFrontier) { console.log('[FrontierTrack] ✅ Frontier confirmed! RPC node is up to date.'); break; } retries++; if (retries === maxRetries) { console.warn('[FrontierTrack] ⚠️ Max retries reached. Proceeding with last known frontier.'); } } } else { console.log('No pending blocks to receive.'); } // Get account info for current balance and frontier const accountInfo = await this.makeRequest('account_info', { account: formattedFromAddress, representative: true, json_block: 'true' }); console.log('[FrontierTrack] Final frontier for send transaction:', accountInfo.frontier); if (accountInfo.error) { if (accountInfo.error === 'Account not found') { // Check if there are pending blocks to initialize with const pendingCheck = await this.getPendingBlocks(formattedFromAddress); return EnhancedErrorHandler.accountNotInitialized(formattedFromAddress, pendingCheck); } throw new Error(accountInfo.error); } console.log('Account info:', accountInfo); // Calculate new balance after sending const currentBalance = BigInt(accountInfo.balance); const sendAmount = BigInt(amountRawString); console.log('[AmountTrack] Current balance (raw):', currentBalance.toString()); console.log('[AmountTrack] Amount to SEND (raw):', sendAmount.toString()); console.log('[AmountTrack] Verifying: sendAmount === requested?', sendAmount.toString() === amountRawString); // Check if sufficient balance BEFORE attempting transaction if (currentBalance < sendAmount) { console.log('Insufficient balance detected'); return EnhancedErrorHandler.insufficientBalance( accountInfo.balance, amountRawString, formattedFromAddress ); } const newBalance = (currentBalance - sendAmount).toString(); console.log('Current balance:', currentBalance.toString()); console.log('Send amount:', sendAmount.toString()); console.log('New balance after send:', newBalance); console.log('[AmountTrack] Remaining balance after send (raw):', newBalance); try { const remainingNano = BalanceConverter.rawToNano(newBalance); const sendingNano = BalanceConverter.rawToNano(sendAmount.toString()); console.log('[AmountTrack] Amount SENDING (NANO):', sendingNano); console.log('[AmountTrack] Remaining balance (NANO):', remainingNano); } catch (e) { console.log('[AmountTrack] Conversion error:', e.message); } // Generate work via RPC with retry logic for production reliability console.log('Generating work via RPC for send transaction with retry protection...'); const workValue = await this.generateWorkWithRetry(accountInfo.frontier, false, 2); const workData = { work: workValue }; if (!workData || !workData.work) { throw new Error('Failed to generate work'); } // Get the representative let representative = this.defaultRepresentative; if (accountInfo && accountInfo.representative) { representative = accountInfo.representative; } console.log('Using representative:', representative); // Prepare block data // IMPORTANT: nanocurrency-web's block.send() calculates newBalance internally by doing: // newBalance = walletBalanceRaw - amountRaw // So walletBalanceRaw should be the CURRENT balance (before send), NOT the remaining balance const blockData = { walletBalanceRaw: currentBalance.toString(), // Current balance BEFORE send fromAddress: formattedFromAddress, toAddress: formattedToAddress, representativeAddress: representative, frontier: accountInfo.frontier, amountRaw: amountRawString, // Amount to send (will be subtracted by library) work: workData.work }; console.log('Block data for send:', blockData); console.log('[AmountTrack] ========== BLOCK DATA VERIFICATION =========='); console.log('[AmountTrack] Block.amountRaw (what receiver will get):', amountRawString); console.log('[AmountTrack] Block.walletBalanceRaw (sender current balance BEFORE send):', currentBalance.toString()); console.log('[AmountTrack] Expected balance AFTER send:', newBalance); console.log('[AmountTrack] IMPORTANT: nanocurrency-web will calculate: finalBalance = walletBalanceRaw - amountRaw'); console.log('[AmountTrack] IMPORTANT: This prevents double-deduction bug'); // Log block-determining parameters (these create the block hash) console.log('[BlockHash] Block hash is determined by:'); console.log('[BlockHash] - Frontier:', accountInfo.frontier); console.log('[BlockHash] - New Balance:', newBalance); console.log('[BlockHash] - Amount:', amountRawString); console.log('[BlockHash] - Destination:', formattedToAddress); console.log('[BlockHash] - Representative:', representative); console.log('[BlockHash] NOTE: Work is NOT part of hash, only validates PoW'); // Sign the block using nanocurrency-web const signedBlock = nanocurrency_web_1.block.send(blockData, privateKeyString); console.log('Signed block:', signedBlock); console.log('[BlockHash] Generated block hash:', signedBlock.hash || 'N/A'); console.log('[AmountTrack] ========== SIGNED BLOCK VERIFICATION =========='); if (signedBlock.link) { console.log('[AmountTrack] Signed block link (destination):', signedBlock.link); } if (signedBlock.balance) { console.log('[AmountTrack] Signed block balance (remaining after send):', signedBlock.balance); console.log('[AmountTrack] NOTE: This is the REMAINING balance, NOT the sent amount!'); } console.log('[AmountTrack] Amount being sent to receiver:', amountRawString); // Check for duplicate block attempts const blockHash = signedBlock.hash; const now = Date.now(); // Clean up old entries for (const [hash, data] of this.recentBlockAttempts.entries()) { if (now - data.timestamp > this.duplicateBlockTTL) { this.recentBlockAttempts.delete(hash); } } // Check if this exact block was recently attempted if (this.recentBlockAttempts.has(blockHash)) { const attemptData = this.recentBlockAttempts.get(blockHash); attemptData.attempts++; attemptData.lastAttempt = now; console.warn('[BlockHash] ⚠️ DUPLICATE BLOCK DETECTED!'); console.warn(`[BlockHash] This exact block hash has been attempted ${attemptData.attempts} time(s) in the last 60 seconds`); console.warn('[BlockHash] Block hash:', blockHash); console.warn('[BlockHash] This means identical parameters: frontier, balance, amount, destination'); console.warn('[BlockHash] CRITICAL: If previous attempt failed, frontier may be stale!'); console.warn('[BlockHash] Proceeding with attempt, but this will likely fail...'); } else { this.recentBlockAttempts.set(blockHash, { timestamp: now, lastAttempt: now, attempts: 1, parameters: { frontier: accountInfo.frontier, balance: newBalance, amount: amountRawString, destination: formattedToAddress } }); console.log('[BlockHash] First attempt with this block hash'); } // Process the block const processResult = await this.makeRequest('process', { json_block: 'true', subtype: 'send', block: signedBlock }); if (processResult.error) { // Log duplicate block info if this was a duplicate attempt if (this.recentBlockAttempts.has(blockHash)) { const attemptData = this.recentBlockAttempts.get(blockHash); console.error('[BlockHash] ❌ Transaction failed and this was a DUPLICATE block!'); console.error('[BlockHash] Previous attempts:', attemptData.attempts); console.error('[BlockHash] Likely cause: Stale frontier data (account state not updated)'); console.error('[BlockHash] Solution: Wait a moment for network to settle, then fetch fresh account_info'); } // Handle blockchain errors with enhanced messaging const errorResponse = EnhancedErrorHandler.blockchainError( processResult.error, 'send transaction', { fromAddress: formattedFromAddress, toAddress: formattedToAddress, amountRaw: amountRawString, currentBalance: accountInfo.balance, hash: accountInfo.frontier, work: workData.work, blockType: 'send', duplicateBlock: this.recentBlockAttempts.has(blockHash), blockHash: blockHash } ); // Add duplicate block guidance if detected if (this.recentBlockAttempts.has(blockHash)) { errorResponse.duplicateBlockDetected = true; errorResponse.duplicateBlockGuidance = [ "⚠️ DUPLICATE BLOCK: Same block hash was attempted multiple times", "This happens when retrying with identical: frontier, balance, amount, destination", "SOLUTION: Wait 5-10 seconds for network to settle before retry", "SOLUTION: Check account_info to get fresh frontier before retry", "SOLUTION: If sending full balance, try sending slightly less", "CRITICAL: Do NOT retry immediately - you'll create same invalid block!" ]; } return errorResponse; } // Success! Clean up this block from recent attempts this.recentBlockAttempts.delete(blockHash); console.log('[BlockHash] ✅ Block processed successfully, hash:', processResult.hash); return { success: true, hash: processResult.hash }; } catch (error) { console.error('Send Transaction Error:', error); // Return enhanced error if it's already an enhanced error object if (error.errorCode) { return error; } // Otherwise wrap in blockchain error return EnhancedErrorHandler.blockchainError( error.message, 'send transaction preparation', { fromAddress: formattedFromAddress, toAddress: formattedToAddress } ); } } /** * Retrieves the balance for a given Nano account. * @param {string} account - The Nano account address. * @returns {Promise<Object>} - Balance information from the node. */ async getBalance(account) { const balance = await this.rpcCall('account_balance', { account }); return { balance: balance.balance, pending: balance.pending }; } /** * Generate QR code for Nano payment * @param {string} address - Nano address to receive payment * @param {string} amount - Amount in decimal XNO (e.g., "0.140366") * @returns {Promise<Object>} - QR code data with base64 image and payment string */ async generateQrCode(address, amount) { try { // Validate address format if (!address || !address.startsWith('nano_')) { throw new Error('Invalid Nano address format. Address must start with "nano_"'); } // Validate amount const amountNum = parseFloat(amount); if (isNaN(amountNum) || amountNum <= 0) { throw new Error('Invalid amount. Must be a positive number'); } // Use format: nano:nano_address?amount=0.140366 (decimal, not raw) const paymentString = `nano:${address}?amount=${amount}`; console.log('Generating QR code for payment:', paymentString); // Generate QR code using the qrcode library const QRCode = require('qrcode'); const qrDataUrl = await QRCode.toDataURL(paymentString, { errorCorrectionLevel: 'L', width: 400, margin: 4, color: { dark: '#000000', light: '#FFFFFF', }, }); console.log('QR code generated successfully'); return { success: true, qrCode: qrDataUrl, paymentString: paymentString, address: address, amount: amount, format: 'base64 Data URL (PNG)' }; } catch (error) { console.error('Error generating QR code:', error); throw new Error(`Failed to generate QR code: ${error.message}`); } } } exports.NanoTransactions = NanoTransactions;