diff --git a/src/configuration/constants.ts b/src/configuration/constants.ts index d34bd42..f1df510 100644 --- a/src/configuration/constants.ts +++ b/src/configuration/constants.ts @@ -177,7 +177,7 @@ export const EXECUTOR = { }, QUEUE: { - QUEUE_PROCESSOR_INTERVAL: 3000, // 3 seconds (changed from 60000) + QUEUE_PROCESSOR_INTERVAL: 15000, // 15 seconds (increased from 3 seconds for CU optimization) MAX_BATCH_SIZE: 100, // Maximum number of deposits per batch MIN_BATCH_SIZE: 1, // Minimum number of deposits per batch MAX_RETRIES: 3, // Maximum number of retries per transaction diff --git a/src/configuration/helpers.ts b/src/configuration/helpers.ts index 474a045..6680864 100644 --- a/src/configuration/helpers.ts +++ b/src/configuration/helpers.ts @@ -108,7 +108,7 @@ export function calculateGasLimit( } /** - * Polls for a transaction receipt with retries + * Polls for a transaction receipt with exponential backoff */ export async function pollForReceipt( txHash: string, @@ -116,8 +116,12 @@ export async function pollForReceipt( logger: Logger, confirmations: number = 1, ): Promise { - const maxAttempts = 30; // Try for about 5 minutes with 10-second intervals - const pollingInterval = 10000; // 10 seconds + const maxAttempts = 15; // Reduced from 30 + const initialPollingInterval = 3000; // Start with 3 seconds + const maxPollingInterval = 30000; // Cap at 30 seconds + const backoffMultiplier = 1.5; // Exponential backoff factor + + let pollingInterval = initialPollingInterval; for (let attempt = 0; attempt < maxAttempts; attempt++) { try { @@ -127,8 +131,19 @@ export async function pollForReceipt( )) as unknown as EthersTransactionReceipt; if (!receipt) { - // If no receipt yet, wait and try again + // If no receipt yet, wait with exponential backoff + logger.debug('Transaction receipt not found, retrying with backoff', { + txHash, + attempt: attempt + 1, + nextPollingIntervalMs: pollingInterval, + }); await new Promise((resolve) => setTimeout(resolve, pollingInterval)); + + // Increase polling interval for next attempt (exponential backoff) + pollingInterval = Math.min( + pollingInterval * backoffMultiplier, + maxPollingInterval, + ); continue; } @@ -137,20 +152,38 @@ export async function pollForReceipt( const receiptConfirmations = currentBlock - receipt.blockNumber + 1; if (receiptConfirmations >= confirmations) { + logger.info('Transaction confirmed', { + txHash, + blockNumber: receipt.blockNumber, + confirmations: receiptConfirmations, + attemptsTaken: attempt + 1, + }); return receipt; } - // Not enough confirmations, wait and try again - await new Promise((resolve) => setTimeout(resolve, pollingInterval)); + // Not enough confirmations, wait with current interval (no backoff for confirmations) + logger.debug('Waiting for confirmations', { + txHash, + currentConfirmations: receiptConfirmations, + requiredConfirmations: confirmations, + }); + await new Promise((resolve) => + setTimeout(resolve, Math.min(pollingInterval, 5000)), + ); // Cap confirmation polling at 5s } catch (error) { logger.warn('Error polling for receipt', { error: error instanceof Error ? error.message : String(error), txHash, - attempt, + attempt: attempt + 1, + nextPollingIntervalMs: pollingInterval, }); - // Wait and try again + // Wait with exponential backoff on error await new Promise((resolve) => setTimeout(resolve, pollingInterval)); + pollingInterval = Math.min( + pollingInterval * backoffMultiplier, + maxPollingInterval, + ); } } diff --git a/src/configuration/index.ts b/src/configuration/index.ts index 431fd48..ec1f686 100644 --- a/src/configuration/index.ts +++ b/src/configuration/index.ts @@ -41,7 +41,7 @@ export const CONFIG = { | 'warn' | 'error', databaseType: (process.env.DB || 'json') as 'json' | 'supabase', - pollInterval: parseInt(process.env.POLL_INTERVAL || '15'), + pollInterval: parseInt(process.env.POLL_INTERVAL || '30'), // Increased from 15 to 30 seconds maxBlockRange: parseInt(process.env.MAX_BLOCK_RANGE || '2000'), maxRetries: parseInt(process.env.MAX_RETRIES || '5'), reorgDepth: parseInt(process.env.REORG_DEPTH || '64'), diff --git a/src/executor/strategies/BaseExecutor.ts b/src/executor/strategies/BaseExecutor.ts index 460b5b3..479fa1d 100644 --- a/src/executor/strategies/BaseExecutor.ts +++ b/src/executor/strategies/BaseExecutor.ts @@ -99,6 +99,9 @@ export class BaseExecutor implements IExecutor { private readonly config: ExecutorConfig; private readonly gasCostEstimator: GasCostEstimator; private readonly simulationService: SimulationService | null; + private cachedGasPrice: bigint; + private lastGasPriceUpdate: number; + private readonly GAS_PRICE_CACHE_TTL = 30000; // 30 seconds cache /** * Creates a new BaseExecutor instance @@ -129,6 +132,8 @@ export class BaseExecutor implements IExecutor { this.processingInterval = null; this.provider = provider; this.config = config; + this.cachedGasPrice = 0n; + this.lastGasPriceUpdate = 0; // Initialize simulation service (with fallback) try { @@ -391,28 +396,10 @@ export class BaseExecutor implements IExecutor { }; } - // Get current gas price and calculate gas cost - const feeData = await this.provider.getFeeData(); - if (!feeData.gasPrice) { - const error = new TransactionValidationError( - 'Failed to get gas price from provider', - { - feeData: feeData, - }, - ); - if (this.errorLogger) { - await this.errorLogger.warn(error, { - context: 'base-executor-gas-price-missing', - }); - } - return { - isValid: false, - error, - }; - } - + // Get current gas price and calculate gas cost using cached method + const gasPrice = await this.getCachedGasPrice(); const gasBoostMultiplier = BigInt(100 + this.config.gasBoostPercentage); - const boostedGasPrice = (feeData.gasPrice * gasBoostMultiplier) / 100n; + const boostedGasPrice = (gasPrice * gasBoostMultiplier) / 100n; const estimatedGasCost = boostedGasPrice * profitability.estimates.gas_estimate; @@ -720,6 +707,58 @@ export class BaseExecutor implements IExecutor { return this.provider.getBalance(this.wallet.address); } + /** + * Gets cached gas price or fetches fresh if cache expired + * @returns Gas price in wei + */ + private async getCachedGasPrice(): Promise { + try { + const now = Date.now(); + + // Use cached gas price if it's still fresh + if ( + this.cachedGasPrice > 0n && + now - this.lastGasPriceUpdate < this.GAS_PRICE_CACHE_TTL + ) { + return this.cachedGasPrice; + } + + // Fetch fresh gas price + const feeData = await this.provider.getFeeData(); + const gasPrice = feeData.gasPrice || 0n; + + // Handle very low gas prices (sub 1 gwei) + const MIN_GAS_PRICE_GWEI = 1n; + const MIN_GAS_PRICE_WEI = MIN_GAS_PRICE_GWEI * 10n ** 9n; + + const adjustedGasPrice = + gasPrice < MIN_GAS_PRICE_WEI ? MIN_GAS_PRICE_WEI : gasPrice; + + // Handle zero gas price + const finalGasPrice = + adjustedGasPrice === 0n ? 3n * 10n ** 9n : adjustedGasPrice; // 3 gwei fallback + + this.cachedGasPrice = finalGasPrice; + this.lastGasPriceUpdate = now; + + return finalGasPrice; + } catch (error) { + this.logger.warn('Failed to get gas price, using cached or fallback', { + error: error instanceof Error ? error.message : String(error), + cachedGasPrice: this.cachedGasPrice.toString(), + }); + + if (this.errorLogger) { + await this.errorLogger.warn(error as Error, { + context: 'get-cached-gas-price', + }); + } + + // Use cached value if available, otherwise use fallback + return this.cachedGasPrice > 0n ? this.cachedGasPrice : 3n * 10n ** 9n; // 3 gwei fallback + } + } + /** * Starts the queue processor */ @@ -1073,17 +1112,7 @@ export class BaseExecutor implements IExecutor { throw error; } - // Get current gas price with buffer - const feeData = await this.provider.getFeeData(); - if (!feeData.gasPrice) { - const error = new Error('Failed to get gas price from provider'); - if (this.errorLogger) { - await this.errorLogger.warn(error, { - context: 'base-executor-estimate-gas-no-gas-price', - }); - } - throw error; - } + // Gas price will be retrieved via cached method in calculateGasParameters const tipReceiver = this.config.defaultTipReceiver || this.wallet.address; if (!tipReceiver) { @@ -1172,11 +1201,13 @@ export class BaseExecutor implements IExecutor { finalGasLimit: bigint; boostedGasPrice: bigint; }> { + const cachedGasPrice = await this.getCachedGasPrice(); return calculateGasParameters( this.provider, gasEstimate, this.config.gasBoostPercentage, this.logger, + cachedGasPrice, ); } diff --git a/src/executor/strategies/helpers/helpers.ts b/src/executor/strategies/helpers/helpers.ts index dc61304..3c763d8 100644 --- a/src/executor/strategies/helpers/helpers.ts +++ b/src/executor/strategies/helpers/helpers.ts @@ -101,13 +101,20 @@ export async function calculateGasParameters( gasEstimate: bigint, gasBoostPercentage: number, logger: Logger, + cachedGasPrice?: bigint, ): Promise<{ finalGasLimit: bigint; boostedGasPrice: bigint; }> { const finalGasLimit = calculateGasLimit(gasEstimate, 1, logger); - const feeData = await provider.getFeeData(); - const baseGasPrice = feeData.gasPrice || 0n; + + let baseGasPrice: bigint; + if (cachedGasPrice !== undefined) { + baseGasPrice = cachedGasPrice; + } else { + const feeData = await provider.getFeeData(); + baseGasPrice = feeData.gasPrice || 0n; + } // Ensure minimum gas price for stability const MIN_GAS_PRICE_WEI = 1000000000n; // 1 gwei @@ -119,6 +126,7 @@ export async function calculateGasParameters( actualGasPriceGwei: Number(baseGasPrice) / 1e9, minGasPriceGwei: 1, usingMinimum: true, + usingCachedPrice: cachedGasPrice !== undefined, }); } diff --git a/src/executor/strategies/helpers/simulation-helpers.ts b/src/executor/strategies/helpers/simulation-helpers.ts index 3c22c6c..f242523 100644 --- a/src/executor/strategies/helpers/simulation-helpers.ts +++ b/src/executor/strategies/helpers/simulation-helpers.ts @@ -5,6 +5,32 @@ import { SimulationTransaction } from '@/simulation/interfaces'; import { ethers } from 'ethers'; import { CONFIG } from '@/configuration'; +// Simulation cache for optimization +interface SimulationCacheEntry { + result: { + success: boolean; + gasEstimate: bigint | null; + error?: string; + optimizedGasLimit?: bigint; + }; + timestamp: number; +} + +const simulationCache = new Map(); +const SIMULATION_CACHE_TTL = 300000; // 5 minutes cache + +/** + * Generates a cache key for simulation results + */ +function generateSimulationCacheKey( + contractAddress: string, + recipient: string, + minExpectedReward: bigint, + depositIds: bigint[], +): string { + return `${contractAddress}-${recipient}-${minExpectedReward.toString()}-${depositIds.map((id) => id.toString()).join(',')}`; +} + /** * Simulates a transaction to verify it will succeed and estimate gas costs * This uses Tenderly simulation API to validate the transaction without submitting to the chain @@ -75,6 +101,27 @@ export async function simulateTransaction( return { success: true, gasEstimate: null }; } + // Check simulation cache first + const contractAddress = lstContract.target.toString(); + const cacheKey = generateSimulationCacheKey( + contractAddress, + finalSignerAddress, + minExpectedReward, + depositIds, + ); + + const now = Date.now(); + const cachedEntry = simulationCache.get(cacheKey); + + if (cachedEntry && now - cachedEntry.timestamp < SIMULATION_CACHE_TTL) { + logger.info('Using cached simulation result', { + txId: tx.id, + cacheKey: cacheKey.substring(0, 32) + '...', + cacheAgeMs: now - cachedEntry.timestamp, + }); + return cachedEntry.result; + } + try { // Get current gas price for realistic cost calculations let realGasPrice: bigint = BigInt(0); @@ -239,12 +286,20 @@ export async function simulateTransaction( optimizedGasLimit: newGasLimit.toString(), }); - return { + const result = { success: true, gasEstimate: gasEstimate || BigInt(Math.ceil(retryResult.gasUsed * 1.2)), optimizedGasLimit: BigInt(newGasLimit), }; + + // Cache the successful retry result + simulationCache.set(cacheKey, { + result, + timestamp: now, + }); + + return result; } } @@ -257,11 +312,19 @@ export async function simulateTransaction( gasUsed: simulationResult.gasUsed || 0, }); - return { + const result = { success: false, gasEstimate: null, error: `${simulationResult.error?.code}: ${simulationResult.error?.message}`, }; + + // Cache failed results with shorter TTL (1 minute) + simulationCache.set(cacheKey, { + result, + timestamp: now, + }); + + return result; } // Use gas from simulation if it's higher than our estimate (plus buffer) @@ -279,10 +342,18 @@ export async function simulateTransaction( simulationStatus: simulationResult.status, }); - return { + const result = { success: true, gasEstimate, }; + + // Cache the successful result + simulationCache.set(cacheKey, { + result, + timestamp: now, + }); + + return result; } catch (error) { // Enhance error logging const errorMessage = error instanceof Error ? error.message : String(error); @@ -297,11 +368,24 @@ export async function simulateTransaction( depositCount: depositIds.length, }); - return { + const result = { success: false, gasEstimate: null, error: errorMessage, }; + + // Only cache non-network errors (avoid caching transient failures) + if ( + !errorMessage.includes('network') && + !errorMessage.includes('timeout') + ) { + simulationCache.set(cacheKey, { + result, + timestamp: now, + }); + } + + return result; } } diff --git a/src/monitor/StakerMonitor.ts b/src/monitor/StakerMonitor.ts index 4127784..880517d 100644 --- a/src/monitor/StakerMonitor.ts +++ b/src/monitor/StakerMonitor.ts @@ -56,6 +56,9 @@ export class StakerMonitor extends EventEmitter { private processingPromise?: Promise; private lastProcessedBlock: number; private depositScanInProgress: boolean; + private cachedBlockNumber: number; + private lastBlockNumberUpdate: number; + private readonly BLOCK_NUMBER_CACHE_TTL = 5000; // 5 seconds cache constructor(config: ExtendedMonitorConfig) { super(); @@ -82,6 +85,8 @@ export class StakerMonitor extends EventEmitter { this.isRunning = false; this.lastProcessedBlock = config.startBlock; this.depositScanInProgress = false; + this.cachedBlockNumber = 0; + this.lastBlockNumberUpdate = 0; } /** @@ -189,7 +194,22 @@ export class StakerMonitor extends EventEmitter { private async getCurrentBlock(): Promise { try { - return await this.provider.getBlockNumber(); + const now = Date.now(); + + // Use cached block number if it's still fresh + if ( + this.cachedBlockNumber > 0 && + now - this.lastBlockNumberUpdate < this.BLOCK_NUMBER_CACHE_TTL + ) { + return this.cachedBlockNumber; + } + + // Fetch fresh block number + const blockNumber = await this.provider.getBlockNumber(); + this.cachedBlockNumber = blockNumber; + this.lastBlockNumberUpdate = now; + + return blockNumber; } catch (error) { if (this.errorLogger) { await this.errorLogger.error(error as Error, { @@ -254,27 +274,21 @@ export class StakerMonitor extends EventEmitter { } /** - * Processes events within a specified block range. - * Fetches and processes StakeDeposited, StakeWithdrawn, and DelegateeAltered events. - * Groups related events by transaction for atomic processing. - * + * Queries contract events in batches for improved efficiency + * @param contract - Contract instance to query + * @param eventNames - Array of event names to query * @param fromBlock - Starting block number * @param toBlock - Ending block number + * @returns Flattened array of all events */ - private async processBlockRange( + private async queryContractEvents( + contract: ethers.Contract, + eventNames: string[], fromBlock: number, toBlock: number, - ): Promise { + ): Promise { try { - this.logger.info(`Processing blocks ${fromBlock} to ${toBlock}`); - - // Define a helper function to safely query an event filter - const safeQueryFilter = async ( - contract: ethers.Contract, - eventName: string, - fromBlock: number, - toBlock: number, - ) => { + const eventPromises = eventNames.map(async (eventName) => { try { const filter = contract.filters[eventName]; if (typeof filter === 'function') { @@ -287,59 +301,133 @@ export class StakerMonitor extends EventEmitter { ); return []; } - }; + }); - // Query for events, safely handling missing filters - const [ - lstDepositEvents, - depositedEvents, - withdrawnEvents, - alteredEvents, - stakedWithAttributionEvents, - unstakedEvents, - depositInitializedEvents, - depositUpdatedEvents, - claimerAlteredEvents, - rewardClaimedEvents, - depositSubsidizedEvents, - earningPowerBumpedEvents, - rewardNotifiedEvents, - ] = await Promise.all([ - safeQueryFilter(this.lstContract, 'Staked', fromBlock, toBlock), - safeQueryFilter(this.contract, 'StakeDeposited', fromBlock, toBlock), - safeQueryFilter(this.contract, 'StakeWithdrawn', fromBlock, toBlock), - safeQueryFilter(this.contract, 'DelegateeAltered', fromBlock, toBlock), - safeQueryFilter( - this.lstContract, - 'StakedWithAttribution', - fromBlock, - toBlock, - ), - safeQueryFilter(this.lstContract, 'Unstaked', fromBlock, toBlock), - safeQueryFilter( - this.lstContract, - 'DepositInitialized', - fromBlock, - toBlock, - ), - safeQueryFilter(this.lstContract, 'DepositUpdated', fromBlock, toBlock), - safeQueryFilter(this.contract, 'ClaimerAltered', fromBlock, toBlock), - safeQueryFilter(this.contract, 'RewardClaimed', fromBlock, toBlock), - safeQueryFilter( + const eventArrays = await Promise.all(eventPromises); + return eventArrays.flat(); + } catch (error) { + this.logger.error('Error in batch event querying:', { + error: error instanceof Error ? error.message : String(error), + contractAddress: await contract.getAddress(), + eventNames, + fromBlock, + toBlock, + }); + return []; + } + } + + /** + * Processes events within a specified block range. + * Fetches and processes StakeDeposited, StakeWithdrawn, and DelegateeAltered events. + * Groups related events by transaction for atomic processing. + * + * @param fromBlock - Starting block number + * @param toBlock - Ending block number + */ + private async processBlockRange( + fromBlock: number, + toBlock: number, + ): Promise { + try { + this.logger.info(`Processing blocks ${fromBlock} to ${toBlock}`); + + // Query events in batches for better efficiency + const [lstContractEvents, stakerContractEvents] = await Promise.all([ + this.queryContractEvents( this.lstContract, - 'DepositSubsidized', + [ + 'Staked', + 'StakedWithAttribution', + 'Unstaked', + 'DepositInitialized', + 'DepositUpdated', + 'DepositSubsidized', + ], fromBlock, toBlock, ), - safeQueryFilter( + this.queryContractEvents( this.contract, - 'EarningPowerBumped', + [ + 'StakeDeposited', + 'StakeWithdrawn', + 'DelegateeAltered', + 'ClaimerAltered', + 'RewardClaimed', + 'EarningPowerBumped', + 'RewardNotified', + ], fromBlock, toBlock, ), - safeQueryFilter(this.contract, 'RewardNotified', fromBlock, toBlock), ]); + // Helper function to filter events by topic hash + const filterEventsByTopic = ( + events: ethers.Log[], + eventSignature: string, + ): ethers.Log[] => { + const topic = ethers.id(eventSignature); + return events.filter((log) => log.topics[0] === topic); + }; + + // Filter LST contract events by type + const lstDepositEvents = filterEventsByTopic( + lstContractEvents, + 'Staked(address,uint256)', + ); + const stakedWithAttributionEvents = filterEventsByTopic( + lstContractEvents, + 'StakedWithAttribution(address,uint256,uint256)', + ); + const unstakedEvents = filterEventsByTopic( + lstContractEvents, + 'Unstaked(address,uint256)', + ); + const depositInitializedEvents = filterEventsByTopic( + lstContractEvents, + 'DepositInitialized(uint256,address)', + ); + const depositUpdatedEvents = filterEventsByTopic( + lstContractEvents, + 'DepositUpdated(uint256,uint256,uint256)', + ); + const depositSubsidizedEvents = filterEventsByTopic( + lstContractEvents, + 'DepositSubsidized(uint256,uint256)', + ); + + // Filter Staker contract events by type + const depositedEvents = filterEventsByTopic( + stakerContractEvents, + 'StakeDeposited(address,uint256,uint256)', + ); + const withdrawnEvents = filterEventsByTopic( + stakerContractEvents, + 'StakeWithdrawn(uint256,uint256,uint256)', + ); + const alteredEvents = filterEventsByTopic( + stakerContractEvents, + 'DelegateeAltered(uint256,address)', + ); + const claimerAlteredEvents = filterEventsByTopic( + stakerContractEvents, + 'ClaimerAltered(uint256,address)', + ); + const rewardClaimedEvents = filterEventsByTopic( + stakerContractEvents, + 'RewardClaimed(uint256,address,uint256)', + ); + const earningPowerBumpedEvents = filterEventsByTopic( + stakerContractEvents, + 'EarningPowerBumped(uint256,uint256,uint256)', + ); + const rewardNotifiedEvents = filterEventsByTopic( + stakerContractEvents, + 'RewardNotified(uint256,uint256,uint256)', + ); + this.logger.info('Events found:', { lstDeposit: lstDepositEvents.length, deposited: depositedEvents.length, diff --git a/src/profitability/constants.ts b/src/profitability/constants.ts index 3110637..3256e2c 100644 --- a/src/profitability/constants.ts +++ b/src/profitability/constants.ts @@ -9,7 +9,7 @@ export const GAS_CONSTANTS = { // Queue Processing Constants export const QUEUE_CONSTANTS = { - PROCESSOR_INTERVAL: 60_000, // 1 minute in ms + PROCESSOR_INTERVAL: 120_000, // 2 minutes in ms (increased from 1 minute for CU optimization) MAX_BATCH_SIZE: 50, MIN_BATCH_SIZE: 1, } as const; diff --git a/src/profitability/strategies/GovLstProfitabilityEngine.ts b/src/profitability/strategies/GovLstProfitabilityEngine.ts index c901d60..f596267 100644 --- a/src/profitability/strategies/GovLstProfitabilityEngine.ts +++ b/src/profitability/strategies/GovLstProfitabilityEngine.ts @@ -16,6 +16,7 @@ import { CoinMarketCapFeed } from '@/prices/CoinmarketcapFeed'; import { TokenPrice } from '@/prices/interface'; import { SimulationService } from '@/simulation'; import { estimateGasUsingSimulation } from '@/executor/strategies/helpers/simulation-helpers'; +import { MulticallBatcher } from '@/utils/multicall'; /** * Updated ProfitabilityConfig to include errorLogger @@ -33,6 +34,7 @@ export class GovLstProfitabilityEngine implements IGovLstProfitabilityEngine { private readonly logger: Logger; private readonly errorLogger?: ErrorLogger; private readonly priceFeed: CoinMarketCapFeed; + private readonly multicallBatcher: MulticallBatcher; private isRunning: boolean; private lastGasPrice: bigint; private lastUpdateTimestamp: number; @@ -84,6 +86,7 @@ export class GovLstProfitabilityEngine implements IGovLstProfitabilityEngine { this.lastUpdateTimestamp = 0; this.config = config; this.simulationService = simulationService; + this.multicallBatcher = new MulticallBatcher(provider, this.logger); // Initialize price feed this.priceFeed = new CoinMarketCapFeed( @@ -964,17 +967,20 @@ export class GovLstProfitabilityEngine implements IGovLstProfitabilityEngine { this.activeBin.gas_estimate = actualGasEstimate; // Use actual simulation gas estimate this.activeBin.total_payout = payoutAmount; - // Generate deposit details for profitability check - const depositDetails = await Promise.all( - this.activeBin.deposit_ids.map(async (id) => { - const reward = await this.stakerContract.unclaimedReward(id); - return { - depositId: id, - rewards: reward, - }; - }), + // Generate deposit details for profitability check using batched calls + const batchedRewards = await this.multicallBatcher.batchUnclaimedRewards( + this.stakerContract, + this.activeBin.deposit_ids, ); + const depositDetails = this.activeBin.deposit_ids.map((id, index) => { + const reward = batchedRewards[index] || BigInt(0); // Use 0 if batch call failed + return { + depositId: id, + rewards: reward, + }; + }); + // Calculate minimum expected reward threshold const minExpectedReward = this.calculateMinExpectedReward( payoutAmount, @@ -1081,15 +1087,19 @@ export class GovLstProfitabilityEngine implements IGovLstProfitabilityEngine { // Function to process a batch with retries const processBatch = async (batchIds: bigint[]) => { - // Process batch with retries + // Process batch with retries using multicall const results = await this.withRetry( async () => { - const results = await Promise.all( - batchIds.map(async (id) => { - const reward = await this.stakerContract.unclaimedReward(id); - return { id, reward }; - }), - ); + const batchedRewards = + await this.multicallBatcher.batchUnclaimedRewards( + this.stakerContract, + batchIds, + ); + + const results = batchIds.map((id, index) => ({ + id, + reward: batchedRewards[index] || BigInt(0), // Use 0 if batch call failed + })); // Check if all rewards are 0, might indicate we need to wait for chain update const allZero = results.every(({ reward }) => reward === BigInt(0)); diff --git a/src/utils/multicall.ts b/src/utils/multicall.ts new file mode 100644 index 0000000..87bc4fc --- /dev/null +++ b/src/utils/multicall.ts @@ -0,0 +1,160 @@ +import { ethers } from 'ethers'; +import { Logger } from '@/monitor/logging'; + +/** + * Utility class for batch contract calls using multicall pattern + */ +export class MulticallBatcher { + private readonly provider: ethers.Provider; + private readonly logger: Logger; + + constructor(provider: ethers.Provider, logger: Logger) { + this.provider = provider; + this.logger = logger; + } + + /** + * Batches multiple contract calls into a single RPC request using eth_call + * @param calls Array of contract calls to batch + * @returns Array of decoded results + */ + async batchCalls( + calls: Array<{ + contract: ethers.Contract; + method: string; + params: unknown[]; + resultDecoder: (data: string) => T; + }>, + ): Promise> { + if (calls.length === 0) return []; + + try { + // Group calls by contract for efficiency + const callPromises = calls.map(async (call) => { + try { + // Encode the function call + const callData = call.contract.interface.encodeFunctionData( + call.method, + call.params, + ); + + // Execute the call + const result = await this.provider.call({ + to: await call.contract.getAddress(), + data: callData, + }); + + // Decode the result + return call.resultDecoder(result); + } catch (error) { + this.logger.warn('Individual call failed in batch', { + method: call.method, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + }); + + // Execute all calls in parallel + const results = await Promise.all(callPromises); + + this.logger.info('Batch call completed', { + totalCalls: calls.length, + successfulCalls: results.filter((r) => r !== null).length, + failedCalls: results.filter((r) => r === null).length, + }); + + return results; + } catch (error) { + this.logger.error('Batch call failed', { + error: error instanceof Error ? error.message : String(error), + callCount: calls.length, + }); + throw error; + } + } + + /** + * Batches unclaimed reward calls for multiple deposit IDs + * @param contract Staker contract instance + * @param depositIds Array of deposit IDs to check + * @returns Array of unclaimed reward amounts (null for failed calls) + */ + async batchUnclaimedRewards( + contract: ethers.Contract, + depositIds: bigint[], + ): Promise> { + const calls = depositIds.map((depositId) => ({ + contract, + method: 'unclaimedReward', + params: [depositId], + resultDecoder: (data: string) => { + try { + const decoded = contract.interface.decodeFunctionResult( + 'unclaimedReward', + data, + ); + return BigInt(decoded[0].toString()); + } catch (error) { + this.logger.warn('Failed to decode unclaimedReward result', { + depositId: depositId.toString(), + data, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + }, + })); + + return this.batchCalls(calls); + } + + /** + * Batches deposit info calls for multiple deposit IDs + * @param contract Staker contract instance + * @param depositIds Array of deposit IDs to fetch + * @returns Array of deposit info objects (null for failed calls) + */ + async batchDepositInfo( + contract: ethers.Contract, + depositIds: bigint[], + ): Promise< + Array<{ + balance: bigint; + owner: string; + delegatee: string; + earningPower: bigint; + claimer: string; + } | null> + > { + const calls = depositIds.map((depositId) => ({ + contract, + method: 'deposits', + params: [depositId], + resultDecoder: (data: string) => { + try { + const decoded = contract.interface.decodeFunctionResult( + 'deposits', + data, + ); + return { + balance: BigInt(decoded[0].toString()), + owner: decoded[1], + delegatee: decoded[2], + earningPower: BigInt(decoded[3].toString()), + claimer: decoded[4], + }; + } catch (error) { + this.logger.warn('Failed to decode deposits result', { + depositId: depositId.toString(), + data, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + }, + })); + + return this.batchCalls(calls); + } +}