|
| 1 | +const { ethers } = require("ethers"); |
| 2 | +const dotenv = require("dotenv"); |
| 3 | +const fs = require("fs"); |
| 4 | +const path = require("path"); |
| 5 | +const chalk = require("chalk"); |
| 6 | + |
| 7 | +dotenv.config(); |
| 8 | + |
| 9 | +// === CONFIG === |
| 10 | +// List of public RPCs for Celo. |
| 11 | +const RPCS = [ |
| 12 | + "https://rpc.ankr.com/celo", |
| 13 | + "https://celo.drpc.org", |
| 14 | + "https://forno.celo.org", |
| 15 | + "https://1rpc.io/celo" |
| 16 | +]; |
| 17 | +const GAS_LIMIT = 21000; |
| 18 | +const keysFile = "key.txt"; |
| 19 | +let lastKey = null; |
| 20 | + |
| 21 | +// --- Load keys from file --- |
| 22 | +const PRIVATE_KEYS = fs.readFileSync(keysFile, "utf-8") |
| 23 | + .split("\n") |
| 24 | + .map(line => line.trim()) |
| 25 | + .filter(line => line.length > 0); |
| 26 | + |
| 27 | +// --- Wallet Persona Management --- |
| 28 | +const personaFile = "personas.json"; |
| 29 | +let walletProfiles = {}; |
| 30 | +let lastPersonaSave = 0; |
| 31 | +const PERSONA_SAVE_DEBOUNCE_MS = 5_000; // coalesce multiple writes into one |
| 32 | + |
| 33 | +function loadPersonas() { |
| 34 | + if (fs.existsSync(personaFile)) { |
| 35 | + try { |
| 36 | + walletProfiles = JSON.parse(fs.readFileSync(personaFile, "utf-8")); |
| 37 | + console.log(chalk.cyan("🎭 Loaded existing personas")); |
| 38 | + } catch (e) { |
| 39 | + console.error(chalk.bgRed.white.bold("❌ Error parsing personas.json, starting fresh.")); |
| 40 | + walletProfiles = {}; |
| 41 | + } |
| 42 | + } |
| 43 | +} |
| 44 | + |
| 45 | +// Debounced save to avoid frequent blocking disk writes |
| 46 | +function savePersonas() { |
| 47 | + try { |
| 48 | + const now = Date.now(); |
| 49 | + if (now - lastPersonaSave < PERSONA_SAVE_DEBOUNCE_MS) { |
| 50 | + // schedule a final write shortly |
| 51 | + setTimeout(() => { |
| 52 | + try { fs.writeFileSync(personaFile, JSON.stringify(walletProfiles, null, 2)); lastPersonaSave = Date.now(); } |
| 53 | + catch (e) { console.error("failed saving personas:", e.message); } |
| 54 | + }, PERSONA_SAVE_DEBOUNCE_MS); |
| 55 | + return; |
| 56 | + } |
| 57 | + fs.writeFileSync(personaFile, JSON.stringify(walletProfiles, null, 2)); |
| 58 | + lastPersonaSave = now; |
| 59 | + } catch (e) { |
| 60 | + console.error("failed saving personas:", e.message); |
| 61 | + } |
| 62 | +} |
| 63 | + |
| 64 | +function ensurePersona(wallet) { |
| 65 | + if (!walletProfiles[wallet.address]) { |
| 66 | + walletProfiles[wallet.address] = { |
| 67 | + idleBias: Math.random() * 0.25, |
| 68 | + pingBias: Math.random() * 0.25, |
| 69 | + minAmount: 0.00005 + Math.random() * 0.0001, |
| 70 | + maxAmount: 0.015 + Math.random() * 0.005, |
| 71 | + // NEW TRAITS |
| 72 | + activeHours: [6 + Math.floor(Math.random() * 6), 22], // e.g. 06:00-22:00 UTC |
| 73 | + cooldownAfterFail: 60 + Math.floor(Math.random() * 180), // 1-4 min |
| 74 | + avgWait: 60 + Math.floor(Math.random() * 120), // base wait time 1-3 min |
| 75 | + retryBias: Math.random() * 0.5, // 0-50% chance to retry |
| 76 | + // dynamic per-wallet nonce retirement |
| 77 | + maxNonce: 520 + Math.floor(Math.random() * 100), |
| 78 | + // failure tracking |
| 79 | + failCount: 0, |
| 80 | + lastFailAt: null |
| 81 | + }; |
| 82 | + savePersonas(); |
| 83 | + } |
| 84 | + return walletProfiles[wallet.address]; |
| 85 | +} |
| 86 | + |
| 87 | +// --- Dynamic Log File Management (Daily Rotation) --- |
| 88 | +// This function returns the log file path for the current day. |
| 89 | +function getLogFile() { |
| 90 | + const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format |
| 91 | + return path.join(__dirname, `tx_log_${today}.csv`); |
| 92 | +} |
| 93 | + |
| 94 | +// This function initializes the log file with a header if it doesn't exist. |
| 95 | +function initLogFile() { |
| 96 | + const logFile = getLogFile(); |
| 97 | + if (!fs.existsSync(logFile)) { |
| 98 | + fs.writeFileSync( |
| 99 | + logFile, |
| 100 | + "timestamp,wallet,tx_hash,nonce,gas_used,gas_price_gwei,fee_celo,status,action\n" |
| 101 | + ); |
| 102 | + } |
| 103 | + return logFile; |
| 104 | +} |
| 105 | + |
| 106 | +// --- Tx Log Buffer --- |
| 107 | +let txBuffer = []; |
| 108 | +const FLUSH_INTERVAL = 300 * 1000; |
| 109 | +function bufferTxLog(entry) { |
| 110 | + txBuffer.push(entry); |
| 111 | +} |
| 112 | +function flushTxLog() { |
| 113 | + if (txBuffer.length === 0) return; |
| 114 | + const logFile = initLogFile(); |
| 115 | + fs.appendFileSync(logFile, txBuffer.join("\n") + "\n"); |
| 116 | + console.log(chalk.gray(`📝 Flushed ${txBuffer.length} tx logs to disk`)); |
| 117 | + txBuffer = []; |
| 118 | +} |
| 119 | +// Periodic flusher |
| 120 | +setInterval(flushTxLog, FLUSH_INTERVAL); |
| 121 | + |
| 122 | +// --- Pick random key (with small chance of reusing last key) --- |
| 123 | +// Provides a more "human-like" key selection by sometimes re-using the last key. |
| 124 | +function pickRandomKey() { |
| 125 | + if (lastKey && Math.random() < 0.2) return lastKey; |
| 126 | + const idx = Math.floor(Math.random() * PRIVATE_KEYS.length); |
| 127 | + lastKey = PRIVATE_KEYS[idx]; |
| 128 | + return lastKey; |
| 129 | +} |
| 130 | + |
| 131 | +// --- Provider without proxy --- |
| 132 | +function getProvider(rpcUrl) { |
| 133 | + const network = { |
| 134 | + chainId: 42220, |
| 135 | + name: "celo" |
| 136 | + }; |
| 137 | + return new ethers.JsonRpcProvider(rpcUrl, network); |
| 138 | +} |
| 139 | + |
| 140 | +/** |
| 141 | + * Attempts to connect to an RPC endpoint. |
| 142 | + * @returns {Promise<{provider: ethers.JsonRpcProvider, url: string}|null>} The working provider and its URL, or null if all fail. |
| 143 | + */ |
| 144 | +async function tryProviders() { |
| 145 | + console.log(chalk.hex("#00FFFF").bold("🔍 Searching for a working RPC endpoint...")); |
| 146 | + for (const url of RPCS) { |
| 147 | + try { |
| 148 | + const provider = getProvider(url); |
| 149 | + const network = await Promise.race([ |
| 150 | + provider.getNetwork(), |
| 151 | + new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), 5000)) |
| 152 | + ]); |
| 153 | + console.log(chalk.hex("#00FF7F").bold(`✅ Connected: ${url}, Chain ID: ${network.chainId}`)); |
| 154 | + return { provider, url }; |
| 155 | + } catch (e) { |
| 156 | + console.log(chalk.hex("#FF5555").bold(`❌ Failed to connect to ${url}: ${e.message}`)); |
| 157 | + } |
| 158 | + } |
| 159 | + return null; |
| 160 | +} |
| 161 | + |
| 162 | +/** |
| 163 | + * Iterates through the list of RPCs and returns the first one that successfully connects. |
| 164 | + * @returns {Promise<{provider: ethers.JsonRpcProvider, url: string}>} The working provider and its URL. |
| 165 | + */ |
| 166 | +async function getWorkingProvider() { |
| 167 | + return await tryProviders(); |
| 168 | +} |
| 169 | + |
| 170 | +function randomDelay(minSec, maxSec) { |
| 171 | + const ms = (Math.floor(Math.random() * (maxSec - minSec + 1)) + minSec) * 1000; |
| 172 | + return new Promise(resolve => setTimeout(resolve, ms)); |
| 173 | +} |
| 174 | + |
| 175 | +function isWithinActiveHours(profile) { |
| 176 | + const nowUTC = new Date().getUTCHours(); |
| 177 | + return nowUTC >= profile.activeHours[0] && nowUTC <= profile.activeHours[1]; |
| 178 | +} |
| 179 | + |
| 180 | +async function sendTx(wallet, provider, profile, url) { |
| 181 | + try { |
| 182 | + if (Math.random() < profile.idleBias) { |
| 183 | + console.log(chalk.hex("#808080").italic("\n😴 Persona idle mode, skipping this cycle...")); |
| 184 | + return; |
| 185 | + } |
| 186 | + |
| 187 | + const balance = await provider.getBalance(wallet.address); |
| 188 | + await randomDelay(2, 4); |
| 189 | + |
| 190 | + const walletNonce = await provider.getTransactionCount(wallet.address); |
| 191 | + await randomDelay(2, 4); // Another short delay |
| 192 | + |
| 193 | + const maxNonce = profile.maxNonce; |
| 194 | + if (walletNonce >= maxNonce) { |
| 195 | + console.log(chalk.bgYellow.black.bold(`\n🟡 Wallet: ${wallet.address} nonce ${walletNonce} >= ${maxNonce}. Skipping.`)); |
| 196 | + return; |
| 197 | + } |
| 198 | + |
| 199 | + console.log(chalk.hex("#1E90FF").bold.underline(`\n🎲 Wallet: ${wallet.address}`)); |
| 200 | + console.log(chalk.hex("#1E90FF").bold(`Using RPC: ${url}`)); |
| 201 | + console.log(chalk.hex("#FFD700").bold(`Balance: ${ethers.formatEther(balance)} CELO`)); |
| 202 | + console.log(chalk.hex("#FFD700").bold(`Nonce: ${walletNonce}`)); |
| 203 | + |
| 204 | + if (balance < ethers.parseEther("0.01")) { |
| 205 | + console.log(chalk.hex("#FFA500").bold("⚠️ Not enough balance, skipping...")); |
| 206 | + return; |
| 207 | + } |
| 208 | + |
| 209 | + // === Decide action: normal send vs ping === |
| 210 | + let action = "normal"; |
| 211 | + let value; |
| 212 | + if (Math.random() < profile.pingBias) { |
| 213 | + action = "ping"; |
| 214 | + value = 0n; // 0 CELO, just burns gas |
| 215 | + } else { |
| 216 | + const amount = profile.minAmount + Math.random() * (profile.maxAmount - profile.minAmount); |
| 217 | + value = ethers.parseEther(amount.toFixed(6)); |
| 218 | + } |
| 219 | + |
| 220 | + const tx = await wallet.sendTransaction({ |
| 221 | + to: wallet.address, |
| 222 | + value: value, |
| 223 | + gasLimit: GAS_LIMIT |
| 224 | + }); |
| 225 | + |
| 226 | + console.log(chalk.hex("#7FFF00").bold(`✅ Sent tx: ${tx.hash}`)); |
| 227 | + // Safely log the gas price, accounting for EIP-1559 transactions. |
| 228 | + const gasPrice = tx.gasPrice ?? tx.maxFeePerGas ?? 0n; |
| 229 | + if (gasPrice > 0n) { |
| 230 | + console.log(chalk.hex("#FF69B4").bold(`⛽ Gas Price (RPC): ${ethers.formatUnits(gasPrice, "gwei")} gwei`)); |
| 231 | + } |
| 232 | + console.log(chalk.dim(`Explorer link: https://celoscan.io/tx/${tx.hash}`)); |
| 233 | + |
| 234 | + // Initialize variables outside of the try block to ensure scope |
| 235 | + let status = "pending", gasUsed = "", feeCELO = "", gasPriceGwei = "", txNonce = tx.nonce; |
| 236 | + |
| 237 | + try { |
| 238 | + // Use Promise.race with a timeout that resolves to null |
| 239 | + const receipt = await Promise.race([ |
| 240 | + tx.wait(), |
| 241 | + new Promise(resolve => setTimeout(() => resolve(null), 30000)) // returns null on timeout |
| 242 | + ]); |
| 243 | + |
| 244 | + if (receipt) { |
| 245 | + // Only access receipt properties if it's not null/undefined |
| 246 | + status = "confirmed"; |
| 247 | + // Safely handle potentially missing gasPrice or gasUsed |
| 248 | + const gasPriceUsed = receipt?.effectiveGasPrice ?? receipt?.gasPrice ?? 0n; |
| 249 | + gasUsed = (receipt?.gasUsed ?? 0n).toString(); |
| 250 | + gasPriceGwei = ethers.formatUnits(gasPriceUsed, "gwei"); |
| 251 | + feeCELO = ethers.formatEther(gasPriceUsed * (receipt?.gasUsed ?? 0n)); |
| 252 | + |
| 253 | + console.log(chalk.bgGreen.white.bold("🟢 Confirmed!")); |
| 254 | + console.log(` Nonce: ${txNonce}`); |
| 255 | + console.log(chalk.hex("#ADFF2F").bold(` Gas Used: ${gasUsed}`)); |
| 256 | + console.log(chalk.hex("#FFB6C1").bold(` Gas Price: ${gasPriceGwei} gwei`)); |
| 257 | + console.log(chalk.hex("#FFD700").bold(` Fee Paid: ${feeCELO} CELO`)); |
| 258 | + } else { |
| 259 | + console.log(chalk.bgYellow.white.bold("🟡 No confirmation in 30s, moving on...")); |
| 260 | + status = "timeout"; |
| 261 | + } |
| 262 | + } catch (err) { |
| 263 | + console.error(chalk.bgRed.white.bold("❌ Error fetching receipt:", err.message)); |
| 264 | + status = "error"; |
| 265 | + } |
| 266 | + |
| 267 | + // === Buffer to CSV (daily rotation) === |
| 268 | + const line = [ |
| 269 | + new Date().toISOString(), |
| 270 | + wallet.address, |
| 271 | + tx.hash, |
| 272 | + txNonce, |
| 273 | + gasUsed, |
| 274 | + gasPriceGwei, |
| 275 | + feeCELO, |
| 276 | + status, |
| 277 | + action |
| 278 | + ].join(","); |
| 279 | + bufferTxLog(line); |
| 280 | + |
| 281 | + } catch (err) { |
| 282 | + console.error(chalk.bgRed.white.bold("❌ Error in sendTx:", err.message)); |
| 283 | + throw err; // Re-throw to be caught by safeSendTx for cooldown logic |
| 284 | + } |
| 285 | +} |
| 286 | + |
| 287 | +async function safeSendTx(wallet, provider, profile, url) { |
| 288 | + try { |
| 289 | + await sendTx(wallet, provider, profile, url); |
| 290 | + } catch (err) { |
| 291 | + console.log(chalk.hex("#FFA500").bold("⚠️ Transaction failed. Checking persona retry bias...")); |
| 292 | + |
| 293 | + // NEW LOGIC: Check retry bias and apply cooldown |
| 294 | + if (Math.random() > profile.retryBias) { |
| 295 | + console.log(chalk.hex("#FF8C00")(`⏸ Persona ${wallet.address} cooling down after fail...`)); |
| 296 | + // Add a small jitter to the cooldown period |
| 297 | + const cooldownSec = profile.cooldownAfterFail + Math.floor(Math.random() * 60); |
| 298 | + await randomDelay(cooldownSec, cooldownSec); |
| 299 | + return; |
| 300 | + } |
| 301 | + |
| 302 | + console.log(chalk.hex("#FFA500").bold("⚠️ Retrying after error...")); |
| 303 | + await randomDelay(5, 10); |
| 304 | + try { await sendTx(wallet, provider, profile, url); } catch (retryErr) { |
| 305 | + console.error(chalk.bgRed.white.bold("❌ Error on retry:", retryErr.message)); |
| 306 | + } |
| 307 | + } |
| 308 | +} |
| 309 | + |
| 310 | +async function loop() { |
| 311 | + loadPersonas(); // Load personas at the start of the loop |
| 312 | + while (true) { |
| 313 | + // === NEW LOGIC: Retry loop for RPC connection === |
| 314 | + let provider = null; |
| 315 | + let url = null; |
| 316 | + while (!provider) { |
| 317 | + const providerResult = await getWorkingProvider(); |
| 318 | + if (providerResult) { |
| 319 | + provider = providerResult.provider; |
| 320 | + url = providerResult.url; |
| 321 | + } else { |
| 322 | + console.log(chalk.hex("#FF8C00").bold("🚫 All RPCs failed to connect. Retrying in 10 seconds...")); |
| 323 | + await randomDelay(10, 15); |
| 324 | + } |
| 325 | + } |
| 326 | + // === END NEW LOGIC === |
| 327 | + |
| 328 | + const key = pickRandomKey(); |
| 329 | + const wallet = new ethers.Wallet(key, provider); |
| 330 | + const profile = ensurePersona(wallet); |
| 331 | + |
| 332 | + // Check if the wallet is within its active hours |
| 333 | + if (!isWithinActiveHours(profile)) { |
| 334 | + const sleepSec = 600 + Math.floor(Math.random() * 600); // 10–20 min idle |
| 335 | + console.log(chalk.gray(`🛌 Wallet ${wallet.address} is outside active hours, sleeping ${sleepSec}s`)); |
| 336 | + await randomDelay(sleepSec, sleepSec); |
| 337 | + continue; |
| 338 | + } |
| 339 | + |
| 340 | + // Execute the transaction logic, including retries |
| 341 | + await safeSendTx(wallet, provider, profile, url); |
| 342 | + |
| 343 | + // NEW LOGIC: Use persona's avgWait for the wait loop |
| 344 | + let waitSec = Math.floor(profile.avgWait * (0.8 + Math.random() * 0.4)); |
| 345 | + |
| 346 | + console.log(chalk.hex("#00CED1").italic.bold(`⏳ Waiting ${waitSec}s before next tx...`)); |
| 347 | + await randomDelay(waitSec, waitSec); |
| 348 | + } |
| 349 | +} |
| 350 | + |
| 351 | +loop(); |
| 352 | + |
| 353 | +// ensure flush/persona save on termination/unhandled |
| 354 | +process.on("SIGINT", () => { console.log("SIGINT"); flushTxLog(); savePersonas(); process.exit(); }); |
| 355 | +process.on("SIGTERM", () => { console.log("SIGTERM"); flushTxLog(); savePersonas(); process.exit(); }); |
| 356 | +process.on("exit", () => { flushTxLog(); savePersonas(); }); |
| 357 | +process.on("unhandledRejection", (r) => { console.error("unhandledRejection:", r); flushTxLog(); savePersonas(); }); |
0 commit comments