UNPKG

delegate-framework

Version:

A TypeScript framework for building robust, production-ready blockchain workflows with comprehensive error handling, logging, and testing. Maintained by delegate.fun

914 lines 84.9 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.HeliusClient = void 0; const web3_js_1 = require("@solana/web3.js"); const spl_token_1 = require("@solana/spl-token"); const bs58_1 = __importDefault(require("bs58")); const error_handling_1 = require("../../utils/error-handling"); class HeliusClient { constructor(config) { this.requestId = 0; // Rate limit tracking this.rateLimitInfo = { remaining: -1, limit: -1, reset: -1, lastUpdate: 0 }; this.config = { rpcUrl: HeliusClient.DEFAULT_RPC_URL, enhancedApiUrl: HeliusClient.DEFAULT_ENHANCED_API_URL, timeout: HeliusClient.DEFAULT_TIMEOUT, retries: HeliusClient.DEFAULT_RETRIES, ...config, }; this.logger = this.config.logger; } /** * Send a transaction to the Solana network * @param transaction - The transaction to send * @param options - Optional configuration for the transaction * @returns Transaction signature */ async sendTransaction(transaction, options = {}) { return this.makeRequest('sendTransaction', [ bs58_1.default.encode(transaction.serialize()), { encoding: options.encoding || 'base58', skipPreflight: options.skipPreflight ?? false, preflightCommitment: options.preflightCommitment || 'confirmed', } ]); } /** * Send native SOL transfer * @param from - Source wallet keypair * @param to - Destination wallet public key * @param amount - Amount in lamports * @param options - Optional transaction options * @returns Transaction signature */ async sendNativeTransfer(from, to, amount, options = {}) { const transaction = new web3_js_1.Transaction(); try { // Get recent blockhash with retry mechanism this.logger?.debug('Fetching recent blockhash for native transfer...'); const blockhashResponse = await this.getLatestBlockhashWithRetry(); // Extract blockhash from the correct response structure let blockhash; if (blockhashResponse?.value?.blockhash) { // JSON-RPC response structure: { value: { blockhash: "...", lastValidBlockHeight: ... } } blockhash = blockhashResponse.value.blockhash; } else if (blockhashResponse?.blockhash) { // Direct blockhash property (backward compatibility) blockhash = blockhashResponse.blockhash; } else { throw new Error(`Invalid blockhash response structure: ${JSON.stringify(blockhashResponse)}`); } // Comprehensive validation if (!blockhash) { throw new Error('Blockhash is null or undefined'); } if (typeof blockhash !== 'string') { throw new Error(`Blockhash is not a string: ${typeof blockhash}`); } if (blockhash.length === 0) { throw new Error('Blockhash is empty string'); } // Validate blockhash format (base58, typical length) if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(blockhash)) { throw new Error(`Invalid blockhash format: ${blockhash}`); } this.logger?.debug(`Blockhash obtained: ${blockhash}`); // CORRECT ORDER: Set fee payer first, then add instructions, then set blockhash transaction.feePayer = from.publicKey; transaction.add(web3_js_1.SystemProgram.transfer({ fromPubkey: from.publicKey, toPubkey: to, lamports: amount, })); // Set the blockhash AFTER adding instructions transaction.recentBlockhash = blockhash; // Verify transaction state before signing this.logger?.debug('Transaction state before signing:', { hasBlockhash: !!transaction.recentBlockhash, blockhash: transaction.recentBlockhash, feePayer: transaction.feePayer?.toString(), instructions: transaction.instructions.length }); // Verify blockhash is set before signing if (!transaction.recentBlockhash) { throw new Error('Transaction blockhash not set before signing'); } // Sign the transaction transaction.sign(from); return this.sendTransaction(transaction, options); } catch (error) { this.logger?.error('Error in sendNativeTransfer:', error); throw error; } } /** * Send SPL token transfer * @param to - Destination token account public key * @param owner - Owner keypair of the source token account * @param amount - Amount to transfer * @param mint - Token mint address * @param options - Optional transaction options * @returns Transaction signature */ async sendTokenTransfer(to, owner, amount, mint, options = {}) { const transaction = new web3_js_1.Transaction(); try { // Get the source token account for this owner and mint const sourceTokenAccount = await this.getTokenAccount(owner.publicKey, mint); if (!sourceTokenAccount || !sourceTokenAccount.value || sourceTokenAccount.value.length === 0) { throw new Error(`No token account found for owner ${owner.publicKey.toString()} and mint ${mint.toString()}`); } const fromTokenAccount = new web3_js_1.PublicKey(sourceTokenAccount.value[0].pubkey); // Get recent blockhash with retry mechanism this.logger?.debug('Fetching recent blockhash for token transfer...'); const blockhashResponse = await this.getLatestBlockhashWithRetry(); // Extract blockhash from the correct response structure let blockhash; if (blockhashResponse?.value?.blockhash) { // JSON-RPC response structure: { value: { blockhash: "...", lastValidBlockHeight: ... } } blockhash = blockhashResponse.value.blockhash; } else if (blockhashResponse?.blockhash) { // Direct blockhash property (backward compatibility) blockhash = blockhashResponse.blockhash; } else { throw new Error(`Invalid blockhash response structure: ${JSON.stringify(blockhashResponse)}`); } // Comprehensive validation if (!blockhash) { throw new Error('Blockhash is null or undefined'); } if (typeof blockhash !== 'string') { throw new Error(`Blockhash is not a string: ${typeof blockhash}`); } if (blockhash.length === 0) { throw new Error('Blockhash is empty string'); } // Validate blockhash format (base58, typical length) if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(blockhash)) { throw new Error(`Invalid blockhash format: ${blockhash}`); } this.logger?.debug(`Blockhash obtained: ${blockhash}`); // Set fee payer first transaction.feePayer = owner.publicKey; // Add token transfer instruction transaction.add((0, spl_token_1.createTransferInstruction)(fromTokenAccount, to, owner.publicKey, amount, [], spl_token_1.TOKEN_PROGRAM_ID)); // Set the blockhash AFTER adding instructions transaction.recentBlockhash = blockhash; // Verify transaction state before signing this.logger?.debug('Transaction state before signing:', { hasBlockhash: !!transaction.recentBlockhash, blockhash: transaction.recentBlockhash, feePayer: transaction.feePayer?.toString(), instructions: transaction.instructions.length }); // Verify blockhash is set before signing if (!transaction.recentBlockhash) { throw new Error('Transaction blockhash not set before signing'); } // Sign the transaction transaction.sign(owner); return this.sendTransaction(transaction, options); } catch (error) { this.logger?.error('Error in sendTokenTransfer:', error); throw error; } } /** * Get the balance of a public key * @param publicKey - The public key to check balance for * @returns Balance in lamports */ async getBalance(publicKey) { return this.makeRequest('getBalance', [publicKey.toString()]); } /** * Get account information with enhanced Metaplex metadata parsing * @param publicKey - The public key to get account info for * @param encodingOrOptions - Optional encoding or configuration options * @returns Account information with optional parsed Metaplex metadata */ async getAccountInfo(publicKey, encodingOrOptions) { let options; if (typeof encodingOrOptions === 'string') { options = { encoding: encodingOrOptions }; } else { options = encodingOrOptions || { encoding: 'base64' }; } const { encoding = 'base64', parseMetaplexMetadata = false, includeOffChainMetadata = false } = options; // Get basic account info const accountInfo = await this.makeRequest('getAccountInfo', [ publicKey.toString(), { encoding } ]); // If Metaplex metadata parsing is requested and this is a metadata account if (parseMetaplexMetadata && accountInfo?.value?.owner === HeliusClient.METAPLEX_METADATA_PROGRAM_ID) { return this.parseMetaplexMetadata(accountInfo, includeOffChainMetadata); } // If parseMetaplexMetadata is true but this isn't a metadata account, try to derive metadata from mint if (parseMetaplexMetadata && accountInfo?.value?.owner !== HeliusClient.METAPLEX_METADATA_PROGRAM_ID) { try { const metadataAccount = await this.getMetaplexMetadataAccount(publicKey); if (metadataAccount) { return this.parseMetaplexMetadata(metadataAccount, includeOffChainMetadata); } } catch (error) { this.logger?.debug('Failed to derive metadata account from mint:', error); } } return accountInfo; } /** * Get transaction details * @param signature - Transaction signature * @param commitment - Optional commitment level * @returns Transaction details */ async getTransaction(signature, commitment = 'confirmed') { return this.makeRequest('getTransaction', [ signature, { commitment } ]); } /** * Get transactions for a public key * @param publicKey - The public key to get transactions for * @param options - Optional configuration for transaction retrieval * @returns Array of Transaction objects */ async getTransactions(publicKey, options = {}) { // Debug logging at start of method if (options.debug) { this.debugLog(`Starting getTransactions request:`, { publicKey: publicKey.toString(), options: { limit: options.limit || 'default', before: options.before || 'none', until: options.until || 'none', debug: options.debug } }); } // Log rate limit status before making request const rateLimitInfo = this.getRateLimitInfo(); if (rateLimitInfo.remaining >= 0 && rateLimitInfo.remaining < 10) { this.logger?.warn(`Low rate limit remaining before getTransactions: ${rateLimitInfo.remaining}/${rateLimitInfo.limit} (${rateLimitInfo.usagePercentage.toFixed(1)}% used)`); } // Build URL with query parameters const baseUrl = this.config.enhancedApiUrl.replace(/\/$/, ''); // Remove trailing slash if present const url = new URL(`${baseUrl}/addresses/${publicKey.toString()}/transactions`); url.searchParams.set('api-key', this.config.apiKey); // Add optional parameters if provided if (options.limit !== undefined) { if (options.limit <= 0) { throw new Error('Limit must be greater than 0'); } url.searchParams.set('limit', options.limit.toString()); } if (options.before !== undefined) { if (!options.before || typeof options.before !== 'string') { throw new Error('Before parameter must be a non-empty string'); } url.searchParams.set('before', options.before); } if (options.until !== undefined) { if (!options.until || typeof options.until !== 'string') { throw new Error('Until parameter must be a non-empty string'); } url.searchParams.set('until', options.until); } // Validate parameter combinations this.validatePaginationParameters(options); const response = await this.makeRestRequest(url.toString()); // Ensure the response is an array of Transaction objects if (!Array.isArray(response)) { throw new Error('Invalid response format: expected array of transactions'); } // Validate that each item has the required Transaction properties const transactions = response.map((item, index) => { if (!item || typeof item !== 'object') { throw new Error(`Invalid transaction at index ${index}: not an object`); } if (typeof item.signature !== 'string') { throw new Error(`Invalid transaction at index ${index}: missing or invalid signature`); } if (typeof item.slot !== 'number') { throw new Error(`Invalid transaction at index ${index}: missing or invalid slot`); } if (typeof item.timestamp !== 'number') { throw new Error(`Invalid transaction at index ${index}: missing or invalid timestamp`); } if (typeof item.description !== 'string') { throw new Error(`Invalid transaction at index ${index}: missing or invalid description`); } // Ensure nativeTransfers and tokenTransfers are arrays if (!Array.isArray(item.nativeTransfers)) { throw new Error(`Invalid transaction at index ${index}: nativeTransfers must be an array`); } if (!Array.isArray(item.tokenTransfers)) { throw new Error(`Invalid transaction at index ${index}: tokenTransfers must be an array`); } return item; }); // Debug logging for batch signatures if (options.debug) { if (transactions.length > 0) { const firstTransaction = transactions[0]; const lastTransaction = transactions[transactions.length - 1]; if (firstTransaction && lastTransaction) { const firstSignature = firstTransaction.signature; const lastSignature = lastTransaction.signature; const firstSlot = firstTransaction.slot; const lastSlot = lastTransaction.slot; const firstTimestamp = new Date(firstTransaction.timestamp * 1000).toISOString(); const lastTimestamp = new Date(lastTransaction.timestamp * 1000).toISOString(); this.debugLog(`Batch debug info:`, { batchSize: transactions.length, firstSignature: `${firstSignature} (slot: ${firstSlot}, time: ${firstTimestamp})`, lastSignature: `${lastSignature} (slot: ${lastSlot}, time: ${lastTimestamp})`, paginationParams: { before: options.before || 'none', until: options.until || 'none', limit: options.limit || 'default' }, publicKey: publicKey.toString() }); } } else { this.debugLog(`No transactions found:`, { publicKey: publicKey.toString(), paginationParams: { before: options.before || 'none', until: options.until || 'none', limit: options.limit || 'default' } }); } } return transactions; } /** * Validate pagination parameter combinations * @param options - The pagination options to validate * @private */ validatePaginationParameters(options) { // Check for conflicting backward pagination parameters if (options.before && options.until) { this.logger?.warn('Both before and until parameters are provided. This may result in unexpected behavior.'); } } /** * Get all transactions for a public key with automatic pagination * @param publicKey - The public key to get all transactions for * @param options - Optional configuration for transaction retrieval (supports all pagination parameters) * @returns Array of all Transaction objects */ async getAllTransactions(publicKey, options = {}) { // Log initial rate limit status const initialRateLimit = this.getRateLimitInfo(); this.logger?.info(`Starting getAllTransactions: fetching all transactions in batches of ${options.limit || 100}`, { publicKey: publicKey.toString(), batchLimit: options.limit || 100, initialRateLimit, debugMode: options.debug || false }); const allTransactions = []; const batchLimit = options.limit || 100; // Default batch size let batchCount = 0; let consecutiveEmptyBatches = 0; const maxConsecutiveEmptyBatches = 3; // Stop after 3 consecutive empty batches // For backward pagination, we track the oldest signature for next batch let paginationSignature = null; let isFirstBatch = true; while (true) { batchCount++; const batchOptions = { limit: batchLimit }; // Handle backward pagination - CRITICAL FIX: Don't use user's before/until for first batch // if we want to get ALL transactions from the beginning if (paginationSignature) { batchOptions.before = paginationSignature; } else if (options.before || options.until) { // If user provided before/until, use it for the first batch only if (options.before) batchOptions.before = options.before; if (options.until) batchOptions.until = options.until; } // If no pagination parameters, start from the most recent (default behavior) // Debug logging for getAllTransactions batch start if (options.debug) { this.debugLog(`Starting getAllTransactions batch ${batchCount}:`, { batchNumber: batchCount, requestedLimit: batchLimit, totalRetrieved: allTransactions.length, paginationSignature: paginationSignature || 'none', isFirstBatch, userBefore: options.before || 'none', userUntil: options.until || 'none' }); } // Check rate limit before each batch const batchRateLimit = this.getRateLimitInfo(); if (batchRateLimit.remaining >= 0 && batchRateLimit.remaining < 5) { this.logger?.warn(`Very low rate limit before getAllTransactions batch ${batchCount}: ${batchRateLimit.remaining}/${batchRateLimit.limit} remaining`); } try { const transactions = await this.getTransactions(publicKey, batchOptions); if (transactions && transactions.length > 0) { // Reset consecutive empty batches counter consecutiveEmptyBatches = 0; this.logger?.debug(`Fetched batch ${batchCount} of ${transactions.length} transactions`); // For subsequent batches, we need to handle potential overlap // The 'before' parameter is exclusive, so we might have gaps if (!isFirstBatch && allTransactions.length > 0) { const lastPreviousTransaction = allTransactions[allTransactions.length - 1]; const firstCurrentTransaction = transactions[0]; if (lastPreviousTransaction && firstCurrentTransaction) { const lastPreviousSignature = lastPreviousTransaction.signature; const firstCurrentSignature = firstCurrentTransaction.signature; // Check if there's a gap between batches if (lastPreviousSignature !== firstCurrentSignature) { this.logger?.warn(`Gap detected between batches ${batchCount - 1} and ${batchCount}. Last previous: ${lastPreviousSignature}, First current: ${firstCurrentSignature}`); // Try to fill the gap by requesting transactions between these signatures try { const gapOptions = { limit: Math.min(100, batchLimit * 2), // Use larger limit for gap filling before: lastPreviousSignature, until: firstCurrentSignature }; this.logger?.debug(`Attempting to fill gap with until parameter: ${firstCurrentSignature}`); const gapTransactions = await this.getTransactions(publicKey, gapOptions); if (gapTransactions && gapTransactions.length > 0) { this.logger?.info(`Successfully filled gap with ${gapTransactions.length} transactions`); allTransactions.push(...gapTransactions); } } catch (gapError) { this.logger?.warn('Failed to fill gap:', gapError); } } } } allTransactions.push(...transactions); isFirstBatch = false; // Debug logging for getAllTransactions batch completion if (options.debug && transactions.length > 0) { const firstTransaction = transactions[0]; const lastTransaction = transactions[transactions.length - 1]; if (firstTransaction && lastTransaction) { this.debugLog(`getAllTransactions batch ${batchCount} completed:`, { batchNumber: batchCount, batchSize: transactions.length, firstSignature: firstTransaction.signature, lastSignature: lastTransaction.signature, firstSlot: firstTransaction.slot, lastSlot: lastTransaction.slot, firstTimestamp: new Date(firstTransaction.timestamp * 1000).toISOString(), lastTimestamp: new Date(lastTransaction.timestamp * 1000).toISOString(), totalRetrieved: allTransactions.length, isFirstBatch: false }); } } // Update pagination signature for backward pagination // Use the oldest transaction signature for the next 'before' parameter const lastTransaction = transactions[transactions.length - 1]; if (lastTransaction) { paginationSignature = lastTransaction.signature; this.logger?.debug(`Batch ${batchCount}: Next pagination signature: ${paginationSignature}`); } // If we got fewer transactions than requested, we've reached the end if (transactions.length < batchLimit) { this.logger?.debug(`Batch ${batchCount}: Reached end of transactions (got ${transactions.length} < ${batchLimit})`); break; } } else { consecutiveEmptyBatches++; this.logger?.warn(`Batch ${batchCount}: No transactions found. Consecutive empty batches: ${consecutiveEmptyBatches}`); // If we've had multiple consecutive empty batches, we might be at the end if (consecutiveEmptyBatches >= maxConsecutiveEmptyBatches) { this.logger?.warn(`Stopping after ${maxConsecutiveEmptyBatches} consecutive empty batches`); break; } // Wait before retrying await this.delay(Math.pow(2, consecutiveEmptyBatches) * 1000); } } catch (error) { this.logger?.error(`Batch ${batchCount} failed:`, error); consecutiveEmptyBatches++; if (consecutiveEmptyBatches >= maxConsecutiveEmptyBatches) { this.logger?.error(`Stopping after ${maxConsecutiveEmptyBatches} consecutive failures`); break; } // Wait before retrying await this.delay(Math.pow(2, consecutiveEmptyBatches) * 1000); } } // Log final rate limit status const finalRateLimit = this.getRateLimitInfo(); this.logger?.info(`Finished fetching all transactions. Total: ${allTransactions.length} in ${batchCount} batches`, { finalRateLimit, totalTransactions: allTransactions.length, batchesProcessed: batchCount, consecutiveEmptyBatches }); // Log a warning if we didn't get many transactions if (allTransactions.length < batchLimit * 2) { this.logger?.warn(`Only retrieved ${allTransactions.length} transactions. This might indicate limited transaction history or pagination issues.`); } return allTransactions; } /** * Get a specific number of transactions for a public key with automatic pagination * @param publicKey - The public key to get transactions for * @param totalLimit - Total number of transactions to fetch * @param options - Optional configuration for transaction retrieval (supports all pagination parameters) * @param batchSize - Number of transactions to fetch per API call (default: 10, max: 100) * @returns Array of Transaction objects up to the specified limit */ async getTransactionsWithLimit(publicKey, totalLimit, options = {}, batchSize = 10) { if (batchSize <= 0 || batchSize > 100) { throw new Error('Batch size must be between 1 and 100'); } // Log initial rate limit status const initialRateLimit = this.getRateLimitInfo(); this.logger?.info(`Starting getTransactionsWithLimit: requesting ${totalLimit} transactions in batches of ${batchSize}`, { publicKey: publicKey.toString(), totalLimit, batchSize, initialRateLimit, debugMode: options.debug || false }); const transactions = []; let batchCount = 0; let consecutiveEmptyBatches = 0; const maxConsecutiveEmptyBatches = 3; // Stop after 3 consecutive empty batches // For backward pagination, we track the oldest signature for next batch let paginationSignature = null; let isFirstBatch = true; let lastBatchSize = 0; while (transactions.length < totalLimit) { batchCount++; const remainingLimit = totalLimit - transactions.length; const currentBatchLimit = Math.min(batchSize, remainingLimit); const batchOptions = { ...options, limit: currentBatchLimit }; // Handle backward pagination if (paginationSignature) { batchOptions.before = paginationSignature; // Remove the original until parameter for subsequent batches delete batchOptions.until; } // For the first batch, keep the original 'before' or 'until' parameter // Debug logging for batch start if (options.debug) { this.debugLog(`Starting batch ${batchCount}:`, { batchNumber: batchCount, requestedLimit: currentBatchLimit, totalRetrieved: transactions.length, remainingToFetch: totalLimit - transactions.length, paginationSignature: paginationSignature || 'none' }); } this.logger?.debug(`Batch ${batchCount}: Requesting ${currentBatchLimit} transactions${paginationSignature ? ` before ${paginationSignature}` : ''}`); // Check rate limit before each batch const batchRateLimit = this.getRateLimitInfo(); if (batchRateLimit.remaining >= 0 && batchRateLimit.remaining < 5) { this.logger?.warn(`Very low rate limit before batch ${batchCount}: ${batchRateLimit.remaining}/${batchRateLimit.limit} remaining`); } const batchTransactions = await this.getTransactions(publicKey, batchOptions); if (batchTransactions && batchTransactions.length > 0) { // Reset consecutive empty batches counter consecutiveEmptyBatches = 0; // For subsequent batches, we need to handle potential overlap // The 'before' parameter is exclusive, so we might have gaps if (!isFirstBatch && transactions.length > 0) { const lastPreviousTransaction = transactions[transactions.length - 1]; const firstCurrentTransaction = batchTransactions[0]; if (lastPreviousTransaction && firstCurrentTransaction) { const lastPreviousSignature = lastPreviousTransaction.signature; const firstCurrentSignature = firstCurrentTransaction.signature; // Check if there's a gap between batches if (lastPreviousSignature !== firstCurrentSignature) { this.logger?.warn(`Potential gap detected between batches. Last previous: ${lastPreviousSignature}, First current: ${firstCurrentSignature}`); // If we detect a gap, we might need to adjust our pagination strategy // For now, we'll continue but log the issue } } } transactions.push(...batchTransactions); isFirstBatch = false; lastBatchSize = batchTransactions.length; this.logger?.debug(`Batch ${batchCount}: Received ${batchTransactions.length} transactions. Total so far: ${transactions.length}`); // Debug logging for batch completion if (options.debug && batchTransactions.length > 0) { const firstTransaction = batchTransactions[0]; const lastTransaction = batchTransactions[batchTransactions.length - 1]; if (firstTransaction && lastTransaction) { this.debugLog(`Batch ${batchCount} completed:`, { batchNumber: batchCount, batchSize: batchTransactions.length, firstSignature: firstTransaction.signature, lastSignature: lastTransaction.signature, firstSlot: firstTransaction.slot, lastSlot: lastTransaction.slot, firstTimestamp: new Date(firstTransaction.timestamp * 1000).toISOString(), lastTimestamp: new Date(lastTransaction.timestamp * 1000).toISOString(), totalRetrieved: transactions.length, progress: `${((transactions.length / totalLimit) * 100).toFixed(1)}%` }); } } // Update pagination signature for backward pagination // Use the oldest transaction signature for the next 'before' parameter const lastTransaction = batchTransactions[batchTransactions.length - 1]; if (lastTransaction) { paginationSignature = lastTransaction.signature; this.logger?.debug(`Batch ${batchCount}: Next pagination signature: ${paginationSignature}`); } // If we got fewer transactions than requested, we've reached the end if (batchTransactions.length < currentBatchLimit) { this.logger?.debug(`Batch ${batchCount}: Reached end of transactions (got ${batchTransactions.length} < ${currentBatchLimit})`); break; } } else { consecutiveEmptyBatches++; this.logger?.warn(`Batch ${batchCount}: No transactions found. Consecutive empty batches: ${consecutiveEmptyBatches}`); // If we've had multiple consecutive empty batches, we might be at the end // or there might be an issue with our pagination if (consecutiveEmptyBatches >= maxConsecutiveEmptyBatches) { this.logger?.warn(`Stopping after ${maxConsecutiveEmptyBatches} consecutive empty batches`); break; } // If this is not the first batch and we got an empty response, // we might need to try a different pagination strategy if (!isFirstBatch && lastBatchSize > 0) { this.logger?.warn(`Empty batch after receiving ${lastBatchSize} transactions in previous batch. This might indicate a pagination issue.`); } break; } } // Log final rate limit status const finalRateLimit = this.getRateLimitInfo(); this.logger?.info(`Finished fetching transactions with limit. Total: ${transactions.length}/${totalLimit} in ${batchCount} batches`, { finalRateLimit, batchesProcessed: batchCount, successRate: `${((transactions.length / totalLimit) * 100).toFixed(1)}%` }); // Log a warning if we didn't reach the requested limit if (transactions.length < totalLimit) { this.logger?.warn(`Only retrieved ${transactions.length} transactions out of ${totalLimit} requested. This might indicate missing transactions due to pagination gaps.`); } return transactions; } /** * Get transactions with limit using a more robust pagination strategy * This method attempts to handle potential gaps in transaction history by using * a combination of 'before' and 'until' parameters and retry logic * @param publicKey - The public key to get transactions for * @param totalLimit - Total number of transactions to fetch * @param options - Optional configuration for transaction retrieval * @param batchSize - Number of transactions to fetch per API call (default: 50, max: 100) * @returns Array of Transaction objects up to the specified limit */ async getTransactionsWithLimitRobust(publicKey, totalLimit, options = {}, batchSize = 50) { if (batchSize <= 0 || batchSize > 100) { throw new Error('Batch size must be between 1 and 100'); } // Log initial rate limit status const initialRateLimit = this.getRateLimitInfo(); this.logger?.info(`Starting getTransactionsWithLimitRobust: requesting ${totalLimit} transactions in batches of ${batchSize}`, { publicKey: publicKey.toString(), totalLimit, batchSize, initialRateLimit, debugMode: options.debug || false }); const transactions = []; let batchCount = 0; let paginationSignature = null; let retryCount = 0; const maxRetries = 3; while (transactions.length < totalLimit && retryCount < maxRetries) { batchCount++; const remainingLimit = totalLimit - transactions.length; const currentBatchLimit = Math.min(batchSize, remainingLimit); const batchOptions = { ...options, limit: currentBatchLimit }; // Handle backward pagination if (paginationSignature) { batchOptions.before = paginationSignature; // Remove the original until parameter for subsequent batches delete batchOptions.until; } // Debug logging for robust batch start if (options.debug) { this.debugLog(`Starting robust batch ${batchCount}:`, { batchNumber: batchCount, requestedLimit: currentBatchLimit, totalRetrieved: transactions.length, remainingToFetch: totalLimit - transactions.length, paginationSignature: paginationSignature || 'none', retryCount }); } this.logger?.debug(`Robust Batch ${batchCount}: Requesting ${currentBatchLimit} transactions${paginationSignature ? ` before ${paginationSignature}` : ''}`); // Check rate limit before each batch const batchRateLimit = this.getRateLimitInfo(); if (batchRateLimit.remaining >= 0 && batchRateLimit.remaining < 5) { this.logger?.warn(`Very low rate limit before robust batch ${batchCount}: ${batchRateLimit.remaining}/${batchRateLimit.limit} remaining`); } try { const batchTransactions = await this.getTransactions(publicKey, batchOptions); if (batchTransactions && batchTransactions.length > 0) { // Check for potential gaps if (transactions.length > 0 && batchTransactions.length > 0) { const lastPreviousTransaction = transactions[transactions.length - 1]; const firstCurrentTransaction = batchTransactions[0]; if (lastPreviousTransaction && firstCurrentTransaction) { const lastPreviousSignature = lastPreviousTransaction.signature; const firstCurrentSignature = firstCurrentTransaction.signature; if (lastPreviousSignature !== firstCurrentSignature) { this.logger?.warn(`Gap detected in robust method. Last previous: ${lastPreviousSignature}, First current: ${firstCurrentSignature}`); // Try to fill the gap by requesting transactions between these signatures try { const gapOptions = { ...options, limit: Math.min(100, totalLimit - transactions.length), before: lastPreviousSignature, until: firstCurrentSignature }; this.logger?.debug(`Attempting to fill gap with until parameter: ${firstCurrentSignature}`); const gapTransactions = await this.getTransactions(publicKey, gapOptions); if (gapTransactions && gapTransactions.length > 0) { this.logger?.info(`Successfully filled gap with ${gapTransactions.length} transactions`); transactions.push(...gapTransactions); } } catch (gapError) { this.logger?.warn('Failed to fill gap:', gapError); } } } } transactions.push(...batchTransactions); retryCount = 0; // Reset retry count on successful batch this.logger?.debug(`Robust Batch ${batchCount}: Received ${batchTransactions.length} transactions. Total so far: ${transactions.length}`); // Debug logging for robust batch completion if (options.debug && batchTransactions.length > 0) { const firstTransaction = batchTransactions[0]; const lastTransaction = batchTransactions[batchTransactions.length - 1]; if (firstTransaction && lastTransaction) { this.debugLog(`Robust batch ${batchCount} completed:`, { batchNumber: batchCount, batchSize: batchTransactions.length, firstSignature: firstTransaction.signature, lastSignature: lastTransaction.signature, firstSlot: firstTransaction.slot, lastSlot: lastTransaction.slot, firstTimestamp: new Date(firstTransaction.timestamp * 1000).toISOString(), lastTimestamp: new Date(lastTransaction.timestamp * 1000).toISOString(), totalRetrieved: transactions.length, progress: `${((transactions.length / totalLimit) * 100).toFixed(1)}%`, retryCount }); } } // Update pagination signature for backward pagination const lastTransaction = batchTransactions[batchTransactions.length - 1]; if (lastTransaction) { paginationSignature = lastTransaction.signature; this.logger?.debug(`Robust Batch ${batchCount}: Next pagination signature: ${paginationSignature}`); } // If we got fewer transactions than requested, we've reached the end if (batchTransactions.length < currentBatchLimit) { this.logger?.debug(`Robust Batch ${batchCount}: Reached end of transactions (got ${batchTransactions.length} < ${currentBatchLimit})`); break; } } else { retryCount++; this.logger?.warn(`Robust Batch ${batchCount}: No transactions found. Retry ${retryCount}/${maxRetries}`); if (retryCount >= maxRetries) { this.logger?.warn(`Stopping after ${maxRetries} consecutive empty batches`); break; } // Wait before retrying await this.delay(Math.pow(2, retryCount) * 1000); } } catch (error) { retryCount++; this.logger?.error(`Robust Batch ${batchCount} failed:`, error); if (retryCount >= maxRetries) { this.logger?.error(`Stopping after ${maxRetries} consecutive failures`); break; } // Wait before retrying await this.delay(Math.pow(2, retryCount) * 1000); } } // Log final rate limit status const finalRateLimit = this.getRateLimitInfo(); this.logger?.info(`Finished robust transaction fetching. Total: ${transactions.length}/${totalLimit} in ${batchCount} batches`, { finalRateLimit, batchesProcessed: batchCount, retriesUsed: retryCount, successRate: `${((transactions.length / totalLimit) * 100).toFixed(1)}%` }); if (transactions.length < totalLimit) { this.logger?.warn(`Only retrieved ${transactions.length} transactions out of ${totalLimit} requested using robust method.`); } return transactions; } /** * Diagnostic method to analyze transaction pagination behavior * This method helps identify gaps and understand the pagination patterns * @param publicKey - The public key to analyze * @param sampleSize - Number of transactions to analyze (default: 1000) * @param batchSize - Batch size for analysis (default: 100) * @returns Analysis results including gap detection and pagination statistics */ async analyzeTransactionPagination(publicKey, sampleSize = 1000, batchSize = 100) { this.logger?.info(`Starting pagination analysis for ${publicKey.toString()}`); const transactions = []; let batchCount = 0; let paginationSignature = null; const gaps = []; const paginationIssues = []; const recommendations = []; while (transactions.length < sampleSize) { batchCount++; const remainingLimit = sampleSize - transactions.length; const currentBatchLimit = Math.min(batchSize, remainingLimit); const batchOptions = { limit: currentBatchLimit }; if (paginationSignature) { batchOptions.before = paginationSignature; } this.logger?.debug(`Analysis Batch ${batchCount}: Requesting ${currentBatchLimit} transactions`); try { const batchTransactions = await this.getTransactions(publicKey, batchOptions); if (batchTransactions && batchTransactions.length > 0) { // Check for gaps between batches if (transactions.length > 0 && batchTransactions.length > 0) { const lastPreviousTransaction = transactions[transactions.length - 1]; const firstCurrentTransaction = batchTransactions[0]; if (lastPreviousTransaction && firstCurrentTransaction) { const lastPreviousSignature = lastPreviousTransaction.signature; const firstCurrentSignature = firstCurrentTransaction.signature; if (lastPreviousSignature !== firstCurrentSignature) { gaps.push({ before: lastPreviousSignature, after: firstCurrentSignature, estimatedGapSize: 0 // Will be calculated if we can fetch gap transactions }); paginationIssues.push(`Gap detected between batches ${batchCount - 1} and ${batchCount}`); // Try to estimate gap size try { const gapOptions = { limit: 100, before: lastPreviousSignature, until: firstCurrentSignature }; const gapTransactions = await this.getTransactions(publicKey, gapOptions); if (gapTransactions && gapTransactions.length > 0) { const lastGap = gaps[gaps.length - 1]; if (lastGap) { lastGap.estimatedGapSize = gapTransactions.length; this.logger?.info(`Gap contains approximately ${gapTransactions.length} transactions`); } } } catch (gapError) { this.logger?.warn('Could not estimate gap