370 lines
14 KiB
JavaScript
370 lines
14 KiB
JavaScript
/**
|
||
* Менеджер nonce для управления транзакциями в разных сетях
|
||
* Решает проблему "nonce too low" при деплое в нескольких сетях
|
||
*/
|
||
|
||
const { ethers } = require('ethers');
|
||
const { getRpcUrls } = require('./networkLoader');
|
||
|
||
class NonceManager {
|
||
constructor() {
|
||
this.nonceCache = new Map(); // Кэш nonce для каждого адреса и сети
|
||
this.pendingTransactions = new Map(); // Отслеживание pending транзакций
|
||
}
|
||
|
||
/**
|
||
* Получить актуальный nonce для адреса в сети с таймаутом и retry логикой
|
||
* @param {string} address - Адрес кошелька
|
||
* @param {string} rpcUrl - RPC URL сети
|
||
* @param {number} chainId - ID сети
|
||
* @param {Object} options - Опции (timeout, maxRetries)
|
||
* @returns {Promise<number>} - Актуальный nonce
|
||
*/
|
||
async getNonce(address, rpcUrl, chainId, options = {}) {
|
||
const { timeout = 15000, maxRetries = 5 } = options; // Увеличиваем таймаут и попытки
|
||
|
||
const cacheKey = `${address}-${chainId}`;
|
||
|
||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||
try {
|
||
const provider = new ethers.JsonRpcProvider(rpcUrl, undefined, {
|
||
polling: false, // Отключаем polling для более быстрого получения nonce
|
||
staticNetwork: true
|
||
});
|
||
|
||
// Получаем nonce из сети с таймаутом
|
||
const networkNonce = await Promise.race([
|
||
provider.getTransactionCount(address, 'pending'),
|
||
new Promise((_, reject) =>
|
||
setTimeout(() => reject(new Error('Nonce timeout')), timeout)
|
||
)
|
||
]);
|
||
|
||
// ВАЖНО: Не используем кэш для критических операций деплоя
|
||
// Всегда получаем актуальный nonce из сети
|
||
this.nonceCache.set(cacheKey, networkNonce);
|
||
|
||
console.log(`[NonceManager] ${address}:${chainId} nonce=${networkNonce} (попытка ${attempt})`);
|
||
|
||
return networkNonce;
|
||
} catch (error) {
|
||
console.error(`[NonceManager] Ошибка ${address}:${chainId} (${attempt}):`, error.message);
|
||
|
||
if (attempt === maxRetries) {
|
||
// В случае критической ошибки, сбрасываем кэш и пробуем еще раз
|
||
this.nonceCache.delete(cacheKey);
|
||
throw new Error(`Не удалось получить nonce после ${maxRetries} попыток: ${error.message}`);
|
||
}
|
||
|
||
// Увеличиваем задержку между попытками
|
||
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Зарезервировать nonce для транзакции
|
||
* @param {string} address - Адрес кошелька
|
||
* @param {number} chainId - ID сети
|
||
* @param {number} nonce - Nonce для резервирования
|
||
*/
|
||
reserveNonce(address, chainId, nonce) {
|
||
const cacheKey = `${address}-${chainId}`;
|
||
const currentNonce = this.nonceCache.get(cacheKey) || 0;
|
||
|
||
if (nonce >= currentNonce) {
|
||
this.nonceCache.set(cacheKey, nonce + 1);
|
||
console.log(`[NonceManager] Зарезервирован nonce ${nonce} для ${address} в сети ${chainId}`);
|
||
} else {
|
||
console.warn(`[NonceManager] Попытка использовать nonce ${nonce} меньше текущего ${currentNonce} для ${address} в сети ${chainId}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Отметить транзакцию как pending
|
||
* @param {string} address - Адрес кошелька
|
||
* @param {number} chainId - ID сети
|
||
* @param {number} nonce - Nonce транзакции
|
||
* @param {string} txHash - Хэш транзакции
|
||
*/
|
||
markTransactionPending(address, chainId, nonce, txHash) {
|
||
const cacheKey = `${address}-${chainId}`;
|
||
const pendingTxs = this.pendingTransactions.get(cacheKey) || [];
|
||
|
||
pendingTxs.push({
|
||
nonce,
|
||
txHash,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
this.pendingTransactions.set(cacheKey, pendingTxs);
|
||
console.log(`[NonceManager] Отмечена pending транзакция ${txHash} с nonce ${nonce} для ${address} в сети ${chainId}`);
|
||
}
|
||
|
||
/**
|
||
* Отметить транзакцию как подтвержденную
|
||
* @param {string} address - Адрес кошелька
|
||
* @param {number} chainId - ID сети
|
||
* @param {string} txHash - Хэш транзакции
|
||
*/
|
||
markTransactionConfirmed(address, chainId, txHash) {
|
||
const cacheKey = `${address}-${chainId}`;
|
||
const pendingTxs = this.pendingTransactions.get(cacheKey) || [];
|
||
|
||
const txIndex = pendingTxs.findIndex(tx => tx.txHash === txHash);
|
||
if (txIndex !== -1) {
|
||
const tx = pendingTxs[txIndex];
|
||
pendingTxs.splice(txIndex, 1);
|
||
|
||
console.log(`[NonceManager] Транзакция ${txHash} подтверждена для ${address} в сети ${chainId}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Очистить старые pending транзакции
|
||
* @param {string} address - Адрес кошелька
|
||
* @param {number} chainId - ID сети
|
||
* @param {number} maxAge - Максимальный возраст в миллисекундах (по умолчанию 5 минут)
|
||
*/
|
||
clearOldPendingTransactions(address, chainId, maxAge = 5 * 60 * 1000) {
|
||
const cacheKey = `${address}-${chainId}`;
|
||
const pendingTxs = this.pendingTransactions.get(cacheKey) || [];
|
||
const now = Date.now();
|
||
|
||
const validTxs = pendingTxs.filter(tx => (now - tx.timestamp) < maxAge);
|
||
|
||
if (validTxs.length !== pendingTxs.length) {
|
||
this.pendingTransactions.set(cacheKey, validTxs);
|
||
console.log(`[NonceManager] Очищено ${pendingTxs.length - validTxs.length} старых pending транзакций для ${address} в сети ${chainId}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Получить информацию о pending транзакциях
|
||
* @param {string} address - Адрес кошелька
|
||
* @param {number} chainId - ID сети
|
||
* @returns {Array} - Массив pending транзакций
|
||
*/
|
||
getPendingTransactions(address, chainId) {
|
||
const cacheKey = `${address}-${chainId}`;
|
||
return this.pendingTransactions.get(cacheKey) || [];
|
||
}
|
||
|
||
/**
|
||
* Сбросить кэш nonce для адреса и сети
|
||
* @param {string} address - Адрес кошелька
|
||
* @param {number} chainId - ID сети
|
||
*/
|
||
resetNonce(address, chainId) {
|
||
const cacheKey = `${address}-${chainId}`;
|
||
this.nonceCache.delete(cacheKey);
|
||
this.pendingTransactions.delete(cacheKey);
|
||
console.log(`[NonceManager] Сброшен кэш nonce для ${address} в сети ${chainId}`);
|
||
}
|
||
|
||
/**
|
||
* Получить статистику по nonce
|
||
* @returns {Object} - Статистика
|
||
*/
|
||
getStats() {
|
||
return {
|
||
nonceCache: Object.fromEntries(this.nonceCache),
|
||
pendingTransactions: Object.fromEntries(this.pendingTransactions)
|
||
};
|
||
}
|
||
|
||
// getNonceFast функция удалена - используем getNonce() вместо этого
|
||
|
||
/**
|
||
* Получить RPC URLs из базы данных с fallback
|
||
* @param {number} chainId - ID сети
|
||
* @param {string} primaryRpcUrl - Основной RPC URL (опциональный)
|
||
* @returns {Promise<Array>} - Массив RPC URL
|
||
*/
|
||
async getRpcUrlsFromDatabase(chainId, primaryRpcUrl = null) {
|
||
const rpcUrls = [];
|
||
|
||
// Добавляем основной RPC URL если указан
|
||
if (primaryRpcUrl) {
|
||
rpcUrls.push(primaryRpcUrl);
|
||
}
|
||
|
||
try {
|
||
// Используем networkLoader для получения RPC URLs
|
||
const { getRpcUrls } = require('./networkLoader');
|
||
const rpcUrlsFromLoader = await getRpcUrls();
|
||
|
||
// Получаем RPC для конкретного chainId
|
||
const chainRpcUrl = rpcUrlsFromLoader[chainId] || rpcUrlsFromLoader[chainId.toString()];
|
||
if (chainRpcUrl && !rpcUrls.includes(chainRpcUrl)) {
|
||
rpcUrls.push(chainRpcUrl);
|
||
console.log(`[NonceManager] ✅ RPC из networkLoader для chainId ${chainId}: ${chainRpcUrl}`);
|
||
}
|
||
} catch (error) {
|
||
console.warn(`[NonceManager] networkLoader недоступен для chainId ${chainId}, используем fallback: ${error.message}`);
|
||
}
|
||
|
||
// Всегда добавляем fallback RPC для надежности
|
||
const fallbackRPCs = this.getFallbackRPCs(chainId);
|
||
for (const fallbackRpc of fallbackRPCs) {
|
||
if (!rpcUrls.includes(fallbackRpc)) {
|
||
rpcUrls.push(fallbackRpc);
|
||
}
|
||
}
|
||
|
||
console.log(`[NonceManager] RPC URLs для chainId ${chainId}:`, rpcUrls);
|
||
return rpcUrls;
|
||
}
|
||
|
||
/**
|
||
* Получить список fallback RPC для сети
|
||
* @param {number} chainId - ID сети
|
||
* @returns {Array} - Массив RPC URL
|
||
*/
|
||
getFallbackRPCs(chainId) {
|
||
const fallbackRPCs = {
|
||
1: [ // Mainnet
|
||
'https://eth.llamarpc.com',
|
||
'https://rpc.ankr.com/eth',
|
||
'https://ethereum.publicnode.com'
|
||
],
|
||
11155111: [ // Sepolia
|
||
'https://rpc.sepolia.org',
|
||
process.env.SEPOLIA_INFURA_URL || 'https://sepolia.infura.io/v3/YOUR_INFURA_KEY'
|
||
],
|
||
17000: [ // Holesky
|
||
'https://ethereum-holesky.publicnode.com',
|
||
process.env.HOLESKY_INFURA_URL || 'https://holesky.infura.io/v3/YOUR_INFURA_KEY'
|
||
],
|
||
421614: [ // Arbitrum Sepolia
|
||
'https://sepolia-rollup.arbitrum.io/rpc'
|
||
],
|
||
84532: [ // Base Sepolia
|
||
'https://sepolia.base.org'
|
||
]
|
||
};
|
||
|
||
return fallbackRPCs[chainId] || [];
|
||
}
|
||
|
||
/**
|
||
* Интеграция с существующими системами - замена для rpcConnectionManager
|
||
* @param {Object} provider - Провайдер ethers
|
||
* @param {string} address - Адрес кошелька
|
||
* @param {Object} options - Опции
|
||
* @returns {Promise<number>} - Nonce
|
||
*/
|
||
async getNonceWithRetry(provider, address, options = {}) {
|
||
// Извлекаем chainId из провайдера
|
||
const network = await provider.getNetwork();
|
||
const chainId = Number(network.chainId);
|
||
|
||
// Получаем RPC URL из провайдера (если возможно)
|
||
const rpcUrl = provider._getConnection?.()?.url || 'unknown';
|
||
|
||
return await this.getNonce(address, rpcUrl, chainId, options);
|
||
}
|
||
|
||
/**
|
||
* Принудительно обновляет nonce из сети (для обработки race conditions)
|
||
* @param {string} address - Адрес кошелька
|
||
* @param {string} rpcUrl - RPC URL сети
|
||
* @param {number} chainId - ID сети
|
||
* @returns {Promise<number>} - Актуальный nonce
|
||
*/
|
||
async forceRefreshNonce(address, rpcUrl, chainId) {
|
||
const cacheKey = `${address}-${chainId}`;
|
||
|
||
try {
|
||
const provider = new ethers.JsonRpcProvider(rpcUrl);
|
||
const networkNonce = await provider.getTransactionCount(address, 'pending');
|
||
|
||
// Принудительно обновляем кэш
|
||
this.nonceCache.set(cacheKey, networkNonce);
|
||
|
||
console.log(`[NonceManager] Force refreshed nonce for ${address}:${chainId} = ${networkNonce}`);
|
||
return networkNonce;
|
||
} catch (error) {
|
||
console.error(`[NonceManager] Force refresh failed for ${address}:${chainId}:`, error.message);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Выровнять nonce до целевого значения с помощью filler транзакций
|
||
* @param {string} address - Адрес кошелька
|
||
* @param {string} rpcUrl - RPC URL сети
|
||
* @param {number} chainId - ID сети
|
||
* @param {number} targetNonce - Целевой nonce
|
||
* @param {Object} wallet - Кошелек для отправки транзакций
|
||
* @param {Object} options - Опции (gasLimit, maxRetries)
|
||
* @returns {Promise<number>} - Финальный nonce
|
||
*/
|
||
async alignNonceToTarget(address, rpcUrl, chainId, targetNonce, wallet, options = {}) {
|
||
const { gasLimit = 21000, maxRetries = 5 } = options;
|
||
const burnAddress = "0x000000000000000000000000000000000000dEaD";
|
||
|
||
// Получаем текущий nonce
|
||
let current = await this.getNonce(address, rpcUrl, chainId, { timeout: 15000, maxRetries: 5 });
|
||
console.log(`[NonceManager] Aligning nonce ${current} -> ${targetNonce} for ${address}:${chainId}`);
|
||
|
||
if (current >= targetNonce) {
|
||
console.log(`[NonceManager] Nonce already aligned: ${current} >= ${targetNonce}`);
|
||
return current;
|
||
}
|
||
|
||
// Используем RPCConnectionManager для retry логики
|
||
const RPCConnectionManager = require('./rpcConnectionManager');
|
||
const rpcManager = new RPCConnectionManager();
|
||
|
||
// Выравниваем nonce с помощью filler транзакций
|
||
while (current < targetNonce) {
|
||
console.log(`[NonceManager] Sending filler tx nonce=${current} for ${address}:${chainId}`);
|
||
|
||
try {
|
||
const txData = {
|
||
to: burnAddress,
|
||
value: 0n,
|
||
nonce: current,
|
||
gasLimit,
|
||
};
|
||
|
||
const result = await rpcManager.sendTransactionWithRetry(wallet, txData, { maxRetries });
|
||
console.log(`[NonceManager] Filler tx sent: ${result.tx.hash} for nonce=${current}`);
|
||
current++;
|
||
|
||
} catch (e) {
|
||
const errorMsg = e?.message || e;
|
||
console.warn(`[NonceManager] Filler tx failed: ${errorMsg}`);
|
||
|
||
// Обрабатываем ошибки nonce
|
||
if (String(errorMsg).toLowerCase().includes('nonce too low')) {
|
||
this.resetNonce(address, chainId);
|
||
const newNonce = await this.getNonce(address, rpcUrl, chainId, { timeout: 15000, maxRetries: 5 });
|
||
console.log(`[NonceManager] Nonce changed from ${current} to ${newNonce}`);
|
||
current = newNonce;
|
||
|
||
if (current >= targetNonce) {
|
||
console.log(`[NonceManager] Nonce alignment completed: ${current} >= ${targetNonce}`);
|
||
return current;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
throw new Error(`Failed to send filler tx for nonce=${current}: ${errorMsg}`);
|
||
}
|
||
}
|
||
|
||
console.log(`[NonceManager] Nonce alignment completed: ${current} for ${address}:${chainId}`);
|
||
return current;
|
||
}
|
||
}
|
||
|
||
// Создаем глобальный экземпляр
|
||
const nonceManager = new NonceManager();
|
||
|
||
module.exports = {
|
||
NonceManager,
|
||
nonceManager
|
||
};
|