UNPKG

snubb

Version:

A beautiful terminal UI for scanning blockchain token approvals and tracking your exposure

1,698 lines (1,497 loc) 55 kB
#!/usr/bin/env node import { keccak256, toHex, createPublicClient, http, formatUnits } from "viem"; import { mainnet } from "viem/chains"; import { HypersyncClient, LogField, JoinMode, TransactionField, Decoder, } from "@envio-dev/hypersync-client"; import chalk from "chalk"; import figlet from "figlet"; import { Command } from "commander"; import ora from "ora"; import readline from "readline"; import boxen from "boxen"; import fs from "fs"; import path from "path"; import Table from "cli-table3"; import { fileURLToPath } from "url"; import { dirname } from "path"; // Get directory of current module const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Import extraRpcs dynamically let extraRpcs = {}; try { const extraRpcsPath = path.resolve(__dirname, "./extraRpcs.js"); if (fs.existsSync(extraRpcsPath)) { const module = await import(extraRpcsPath); extraRpcs = module.default || {}; } } catch (error) { console.warn( chalk.yellow(`Warning: Could not load extraRpcs.js: ${error.message}`) ); } // List of safe chalk colors for dynamically assigned chains const SAFE_COLORS = [ "green", "yellow", "blue", "magenta", "cyan", "white", "gray", "redBright", "greenBright", "yellowBright", "blueBright", "magentaBright", "cyanBright", "whiteBright", ]; // Load chain data from networkCache.json or fetch from API let networkData = []; try { const networkCachePath = path.resolve(__dirname, "./networkCache.json"); if (fs.existsSync(networkCachePath)) { networkData = JSON.parse(fs.readFileSync(networkCachePath, "utf8")); } else { // In a production app, we'd fetch from API here console.warn(chalk.yellow("Warning: Could not find networkCache.json")); } } catch (error) { console.warn( chalk.yellow(`Warning: Could not load networkCache.json: ${error.message}`) ); } // List of preferred chains to include by default const PREFERRED_CHAINS = [ "eth", "optimism", "arbitrum", "gnosis", "xdc", "unichain", "avalanche", ]; // Create a map of name to chain data for quick lookup const chainNameToData = {}; networkData.forEach((chain) => { chainNameToData[chain.name] = chain; }); // Create dynamic SUPPORTED_CHAINS object with colors const SUPPORTED_CHAINS = {}; // First, add all chains from networkCache.json networkData.forEach((chain, index) => { if (chain.ecosystem === "evm" && chain.chain_id) { // Assign a color from the safe colors array, cycling through them if needed const colorIndex = index % SAFE_COLORS.length; const color = SAFE_COLORS[colorIndex]; // Capitalize first letter of chain name const displayName = chain.name.charAt(0).toUpperCase() + chain.name.slice(1); SUPPORTED_CHAINS[chain.chain_id] = { name: displayName, color: color, hypersyncUrl: `http://${chain.chain_id}.hypersync.xyz`, }; } }); // Apply special color assignments for well-known chains if (SUPPORTED_CHAINS[1]) SUPPORTED_CHAINS[1].color = "cyan"; // Ethereum if (SUPPORTED_CHAINS[10]) SUPPORTED_CHAINS[10].color = "redBright"; // Optimism if (SUPPORTED_CHAINS[137]) SUPPORTED_CHAINS[137].color = "magenta"; // Polygon if (SUPPORTED_CHAINS[42161]) SUPPORTED_CHAINS[42161].color = "blue"; // Arbitrum if (SUPPORTED_CHAINS[8453]) SUPPORTED_CHAINS[8453].color = "blue"; // Base if (SUPPORTED_CHAINS[100]) SUPPORTED_CHAINS[100].color = "green"; // Gnosis if (SUPPORTED_CHAINS[43114]) SUPPORTED_CHAINS[43114].color = "red"; // Avalanche // If no chains were loaded, provide fallbacks for core chains if (Object.keys(SUPPORTED_CHAINS).length === 0) { console.warn(chalk.yellow("Warning: Using fallback chain configuration")); // Fallback to core chains const fallbackChains = { 1: { name: "Ethereum", color: "cyan", hypersyncUrl: "http://1.hypersync.xyz", }, 10: { name: "Optimism", color: "redBright", hypersyncUrl: "http://10.hypersync.xyz", }, 137: { name: "Polygon", color: "magenta", hypersyncUrl: "http://137.hypersync.xyz", }, 42161: { name: "Arbitrum", color: "blue", hypersyncUrl: "http://42161.hypersync.xyz", }, 8453: { name: "Base", color: "greenBright", hypersyncUrl: "http://8453.hypersync.xyz", }, 100: { name: "Gnosis", color: "green", hypersyncUrl: "http://100.hypersync.xyz", }, 43114: { name: "Avalanche", color: "red", hypersyncUrl: "http://43114.hypersync.xyz", }, }; Object.assign(SUPPORTED_CHAINS, fallbackChains); } // Get default chain IDs string const DEFAULT_CHAIN_IDS = Object.keys(SUPPORTED_CHAINS).join(","); // Cache for token metadata const tokenMetadataCache = new Map(); // ERC20 ABI for token metadata const ERC20_ABI = [ { constant: true, inputs: [], name: "name", outputs: [{ name: "", type: "string" }], payable: false, stateMutability: "view", type: "function", }, { constant: true, inputs: [], name: "symbol", outputs: [{ name: "", type: "string" }], payable: false, stateMutability: "view", type: "function", }, { constant: true, inputs: [], name: "decimals", outputs: [{ name: "", type: "uint8" }], payable: false, stateMutability: "view", type: "function", }, ]; // Fetch token metadata from a list of RPCs with improved retry logic async function fetchTokenMetadata(tokenAddress, chainId = 1) { // Check cache first const cacheKey = `${chainId}:${tokenAddress}`; if (tokenMetadataCache.has(cacheKey)) { return tokenMetadataCache.get(cacheKey); } // Get RPC URLs for the chain let rpcUrls = []; if (extraRpcs[chainId]) { extraRpcs[chainId].rpcs.forEach((rpc) => { if (typeof rpc === "string") { rpcUrls.push(rpc); } else if (rpc.url) { rpcUrls.push(rpc.url); } }); } // If no RPCs available, return default values if (rpcUrls.length === 0) { return { success: false, name: "Unknown Token", symbol: "???", decimals: 18, formattedName: "Unknown Token (???)", }; } // Shuffle RPC URLs to avoid always hitting the same one first rpcUrls = shuffleArray([...rpcUrls]); let lastError = null; let retryCount = 0; // Try each RPC until one works, with exponential backoff between retries for (const rpcUrl of rpcUrls) { try { if (rpcUrl.startsWith("wss://")) continue; // Skip WebSocket RPCs for now // Add a small delay between retries with exponential backoff if (retryCount > 0) { await new Promise((resolve) => setTimeout(resolve, Math.min(200 * Math.pow(1.5, retryCount), 2000)) ); } retryCount++; // Create a viem client with timeout const client = createPublicClient({ chain: mainnet, // This is just for typing, we'll override with custom endpoint transport: http(rpcUrl, { timeout: 3000, // 3 second timeout for RPC calls fetchOptions: { headers: { "Content-Type": "application/json", }, }, }), }); // Fetch token metadata (name, symbol, decimals) in parallel const [name, symbol, decimals] = await Promise.all([ client .readContract({ address: tokenAddress, abi: ERC20_ABI, functionName: "name", }) .catch((e) => null), client .readContract({ address: tokenAddress, abi: ERC20_ABI, functionName: "symbol", }) .catch((e) => null), client .readContract({ address: tokenAddress, abi: ERC20_ABI, functionName: "decimals", }) .catch((e) => 18), ]); // If we got at least one piece of metadata if (name !== null || symbol !== null) { const finalName = name || "Unknown Token"; const finalSymbol = symbol || "???"; const metadata = { success: true, name: finalName, symbol: finalSymbol, decimals, formattedName: `${finalName} (${finalSymbol})`, }; // Cache the result tokenMetadataCache.set(cacheKey, metadata); return metadata; } // If both name and symbol are null, consider this attempt failed lastError = new Error("Token metadata not available"); } catch (error) { lastError = error; // Continue to the next RPC if this one fails } } // If we have a default (placeholder) metadata in cache from a previous failed attempt, // use that instead of creating a new default object every time const defaultMetadata = { success: false, name: "Unknown Token", symbol: "???", decimals: 18, formattedName: "Unknown Token (???)", }; // Cache the default result to avoid repeated failed requests tokenMetadataCache.set(cacheKey, defaultMetadata); return defaultMetadata; } // Utility to shuffle array (for randomizing RPC order) function shuffleArray(array) { for (let i = array.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [array[i], array[j]] = [array[j], array[i]]; } return array; } // Format token amounts with proper decimals function formatTokenAmount(amount, decimals) { if (!amount) return "0"; try { return formatUnits(amount, decimals); } catch (error) { return amount.toString(); } } // Global variables for interactive mode let approvalsList = []; let selectedApprovalIndex = 0; let currentPage = 0; const PAGE_SIZE = 8; // Number of approvals to show per page // Group approvals by token for better display let groupedApprovals = {}; // Scanning stats to preserve after completion let chainStats = {}; // Create global readline interface const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true, }); // CLI setup - note the change to use DEFAULT_CHAIN_IDS const program = new Command(); program .name("snubb") .description("Terminal UI for finding and revoking Ethereum token approvals") .version("1.0.0") .option("-a, --address <address>", "Ethereum address to check approvals for") .option( "-c, --chains <chainIds>", "Comma-separated chain IDs or 'many-networks' to scan multiple networks (default: 1 - Ethereum only)", "1" ) .option( "--list-chains", "Display a list of all supported chains from networkCache.json" ) .parse(process.argv); const options = program.opts(); // Check if user wants to list all supported chains if (options.listChains) { console.log( chalk.bold.cyan(figlet.textSync("Supported Chains", { font: "Small" })) ); console.log( chalk.bold.cyan("List of all supported chains from networkCache.json\n") ); // Create a table for better display const chainsTable = new Table({ head: [ chalk.cyan.bold("CHAIN ID"), chalk.cyan.bold("NAME"), chalk.cyan.bold("TIER"), ], colWidths: [12, 25, 12], style: { head: [], // No additional styling for headers border: [], // No additional styling for borders }, }); // Sort networkData by chain ID for easier reading const sortedChains = [...networkData] .filter((chain) => chain.ecosystem === "evm") // Only show EVM chains .sort((a, b) => a.chain_id - b.chain_id); // Add each chain to the table sortedChains.forEach((chain) => { chainsTable.push([chain.chain_id.toString(), chain.name, chain.tier]); }); // Display the table console.log(chainsTable.toString()); console.log( `\nTo use: ${chalk.green( "snubb --address <your-address> --chains <comma-separated-chain-ids>" )}` ); process.exit(0); } // Check if we have an address let TARGET_ADDRESS = options.address; if (!TARGET_ADDRESS) { console.log( chalk.bold.cyan( figlet.textSync("snubb", { font: "ANSI Shadow", horizontalLayout: "full", }) ) ); console.log( chalk.bold.cyan("multichain token approval scanner") + " - " + chalk.cyan("powered by ") + chalk.cyan.underline("envio.dev") + "\n" ); console.log(chalk.yellow("Usage:")); console.log( chalk.green( " snubb --address 0x7C25a8C86A04f40F2Db0434ab3A24b051FB3cA58\n" ) ); console.log(chalk.yellow("Options:")); console.log( chalk.green( ` --chains <chainIds> Comma-separated chain IDs to scan (default: 1 - Ethereum only)\n` ) ); console.log( chalk.green( ` --chains many-networks Scan multiple supported networks (${PREFERRED_CHAINS.join( ", " )})\n` ) ); console.log( chalk.green(` --list-chains Display a list of all supported chains\n`) ); process.exit(0); } // Get chain IDs from options let CHAIN_IDS = []; // Check if 'many-networks' keyword is used if (options.chains.toLowerCase() === "many-networks") { // Use all preferred networks for (const chainName of PREFERRED_CHAINS) { const chain = chainNameToData[chainName]; if (chain) { CHAIN_IDS.push(chain.chain_id); } } } else { // Otherwise use the specified chains const requestedChainIds = options.chains .split(",") .map((id) => parseInt(id.trim())); for (const chainId of requestedChainIds) { // Check if this chain ID exists in networkData (networkCache.json) const chainData = networkData.find( (chain) => chain.chain_id === chainId && chain.ecosystem === "evm" ); if (chainData) { // If in networkData, check if already added to SUPPORTED_CHAINS if (!SUPPORTED_CHAINS[chainId]) { // Get a color from SAFE_COLORS const colorIndex = Math.floor(Math.random() * SAFE_COLORS.length); const color = SAFE_COLORS[colorIndex]; // Add to SUPPORTED_CHAINS SUPPORTED_CHAINS[chainId] = { name: chainData.name.charAt(0).toUpperCase() + chainData.name.slice(1), color: color, hypersyncUrl: `http://${chainId}.hypersync.xyz`, }; } // Now add to CHAIN_IDS CHAIN_IDS.push(chainId); } else { // Chain not in networkCache.json - this is an error console.error(chalk.red(`Error: Chain ID ${chainId} is not supported.`)); console.error( chalk.yellow( `Run '${chalk.green( "snubb --list-chains" )}' to see all supported chains.` ) ); process.exit(1); } } } // If no valid chains, use Ethereum mainnet if (CHAIN_IDS.length === 0) { CHAIN_IDS.push(1); // Fallback to Ethereum mainnet } // Normalize address TARGET_ADDRESS = TARGET_ADDRESS.toLowerCase(); if (!TARGET_ADDRESS.startsWith("0x")) { TARGET_ADDRESS = "0x" + TARGET_ADDRESS; } // Address formatting for topic filtering const TARGET_ADDRESS_NO_PREFIX = TARGET_ADDRESS.substring(2).toLowerCase(); const TARGET_ADDRESS_PADDED = "0x000000000000000000000000" + TARGET_ADDRESS_NO_PREFIX; // Define ERC20 event signatures const event_signatures = [ "Transfer(address,address,uint256)", "Approval(address,address,uint256)", ]; // Create topic0 hashes from event signatures const topic0_list = event_signatures.map((sig) => keccak256(toHex(sig))); // Store individual topic hashes for easier comparison const TRANSFER_TOPIC = topic0_list[0]; const APPROVAL_TOPIC = topic0_list[1]; // Create mapping from topic0 hash to event name const topic0ToName = {}; topic0ToName[TRANSFER_TOPIC] = "Transfer"; topic0ToName[APPROVAL_TOPIC] = "Approval"; // Helper functions for UI const formatNumber = (num) => { return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); }; const formatToken = (tokenAddress, tokenMetadata) => { if (tokenMetadata && tokenMetadata.success) { return tokenMetadata.formattedName; } if (tokenAddress.length <= 12) return tokenAddress; return `${tokenAddress.slice(0, 6)}...${tokenAddress.slice(-6)}`; }; // Check if an amount is effectively unlimited (close to 2^256-1) const isEffectivelyUnlimited = (amount) => { // Common unlimited values (2^256-1 and similar large numbers) const MAX_UINT256 = BigInt( "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" ); const LARGE_THRESHOLD = MAX_UINT256 - MAX_UINT256 / BigInt(1000); // Within 0.1% of max return amount > LARGE_THRESHOLD; }; const formatAmount = (amount, tokenMetadata) => { if (!amount) return "0"; // Check for unlimited or very large approval (effectively unlimited) if ( amount === BigInt(2) ** BigInt(256) - BigInt(1) || amount === BigInt( "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" ) || isEffectivelyUnlimited(amount) ) { return "∞ (Unlimited)"; } // Format with decimals if available if (tokenMetadata && tokenMetadata.success) { return formatTokenAmount(amount, tokenMetadata.decimals); } // Format large numbers with abbr (fallback) if (amount > BigInt(1000000000000)) { return `${Number(amount / BigInt(1000000000000)).toFixed(2)}T`; } else if (amount > BigInt(1000000000)) { return `${Number(amount / BigInt(1000000000)).toFixed(2)}B`; } else if (amount > BigInt(1000000)) { return `${Number(amount / BigInt(1000000)).toFixed(2)}M`; } else if (amount > BigInt(1000)) { return `${Number(amount / BigInt(1000)).toFixed(2)}K`; } return amount.toString(); }; // Format chain name with color (safely) const formatChainName = (chainId) => { if (!SUPPORTED_CHAINS[chainId]) { return chalk.white(`Chain ${chainId}`); } const chain = SUPPORTED_CHAINS[chainId]; const colorName = chain.color || "white"; // Safely apply color try { if (chalk[colorName]) { return chalk[colorName](chain.name); } else { return chalk.white(chain.name); } } catch (error) { return chalk.white(chain.name); } }; // Draw progress bar with safe color handling function drawProgressBar(progress, width = 40, colorName = "cyan") { const filledWidth = Math.floor(width * progress); const emptyWidth = width - filledWidth; // Ensure we draw something even at 100% const filledChar = "█"; const emptyChar = "░"; const filledBar = filledChar.repeat(Math.max(1, filledWidth)); const emptyBar = emptyChar.repeat(emptyWidth); // Safely apply color try { if (chalk[colorName]) { return chalk[colorName](filledBar) + emptyBar; } else { return chalk.cyan(filledBar) + emptyBar; } } catch (error) { return chalk.cyan(filledBar) + emptyBar; } } // Create a query for ERC20 events related to our target address const createQuery = (fromBlock) => ({ fromBlock, logs: [ // Filter for Approval events where target address is the owner (topic1) { topics: [[APPROVAL_TOPIC], [TARGET_ADDRESS_PADDED], []], }, // Filter for Transfer events where target address is from (topic1) { topics: [[TRANSFER_TOPIC], [TARGET_ADDRESS_PADDED], []], }, // Also get Transfer events where target address is to (topic2) { topics: [[TRANSFER_TOPIC], [], [TARGET_ADDRESS_PADDED]], }, ], // Also filter for transactions involving the target address transactions: [ { from: [TARGET_ADDRESS], }, { to: [TARGET_ADDRESS], }, ], fieldSelection: { log: [ LogField.BlockNumber, LogField.LogIndex, LogField.TransactionIndex, LogField.TransactionHash, LogField.Data, LogField.Address, LogField.Topic0, LogField.Topic1, LogField.Topic2, LogField.Topic3, ], transaction: [ TransactionField.From, TransactionField.To, TransactionField.Hash, ], }, joinMode: JoinMode.JoinTransactions, }); // Add a new state variable near the other global variables let detailsExpanded = false; // Function to display the approvals list async function displayApprovalsList() { console.clear(); // Display header with logo and stats console.log(chalk.bold.cyan(figlet.textSync("snubb", { font: "Doom" }))); console.log( chalk.bold.cyan("multichain token approval scanner") + " - " + chalk.cyan("powered by ") + chalk.cyan.underline("envio.dev") + "\n" ); // Display scan progress and summary separately displayScanSummary(); // Calculate page bounds const startIdx = currentPage * PAGE_SIZE; const endIdx = Math.min(startIdx + PAGE_SIZE, approvalsList.length); const totalPages = Math.ceil(approvalsList.length / PAGE_SIZE); // Navigation header with enhanced information console.log( boxen( chalk.bold.cyan( `OUTSTANDING APPROVALS (${currentPage + 1}/${totalPages}) - Showing ${ startIdx + 1 }-${endIdx} of ${approvalsList.length}` ), { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderColor: "yellow", borderStyle: "round", } ) ); // Create a more structured table for approvals with proper hierarchy displayApprovalsTable(startIdx, endIdx); // Display details of the selected approval only if expanded if (approvalsList.length > 0 && detailsExpanded) { const approval = approvalsList[selectedApprovalIndex]; // Use cached token metadata if available const tokenMetadata = tokenMetadataCache.get( `${approval.chainId}:${approval.tokenAddress}` ); // Display approval details with available metadata displayApprovalDetails(approval, tokenMetadata || { success: false }); } else if (approvalsList.length > 0) { // Show a hint to expand details console.log( boxen( chalk.dim( "Press ENTER to view detailed information for the selected approval" ), { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderColor: "blue", borderStyle: "round", } ) ); } // Add revoke.cash link right above navigation commands const revokeLink = `https://revoke.cash/address/${TARGET_ADDRESS}`; console.log( boxen( chalk.bold.white( `⚠️ REVOKE APPROVALS: ${chalk.bold.cyan.underline(revokeLink)}` ), { padding: { top: 0, bottom: 0, left: 2, right: 2 }, margin: { top: 1, bottom: 0 }, borderColor: "red", borderStyle: "round", } ) ); // Move navigation instructions to the bottom near the input prompt console.log( "\n" + boxen( [ chalk.cyan("Navigation Commands:"), `${chalk.yellow("n")} - Next approval ${chalk.yellow( "p" )} - Previous approval`, `${chalk.yellow(">")} - Next page ${chalk.yellow( "<" )} - Previous page`, `${chalk.yellow("ENTER")} - Show/hide details`, `${chalk.yellow("q")} - Quit ${chalk.yellow("h")} - Help`, ].join("\n"), { padding: { top: 1, bottom: 1, left: 2, right: 2 }, margin: { top: 0, bottom: 1 }, borderColor: "magenta", borderStyle: "round", } ) ); // Start fetching metadata in the background fetchTokenMetadataInBackground(startIdx, endIdx); } // Function to display approvals in a professionally formatted table function displayApprovalsTable(startIdx, endIdx) { // Create a new table for approvals with clean styling const approvalsTable = new Table({ head: [ chalk.cyan.bold("CHAIN"), chalk.cyan.bold("TOKEN"), chalk.cyan.bold("SPENDER"), chalk.cyan.bold("AMOUNT"), ], colWidths: [10, 18, 23, 35], style: { head: [], // No additional styling for headers border: [], // No additional styling for borders compact: true, // More compact table }, chars: { top: "━", "top-mid": "┳", "top-left": "┏", "top-right": "┓", bottom: "━", "bottom-mid": "┻", "bottom-left": "┗", "bottom-right": "┛", left: "┃", "left-mid": "", mid: "", "mid-mid": "", right: "┃", "right-mid": "", middle: "┃", }, }); // Keep track of current chain to handle grouping let currentChainId = null; let currentTokenAddress = null; // Display the approvals with token metadata when available for (let i = startIdx; i < endIdx; i++) { const approval = approvalsList[i]; const isSelected = i === selectedApprovalIndex; // Check if this is a new chain const isNewChain = currentChainId !== approval.chainId; const isNewToken = currentTokenAddress !== approval.tokenAddress || isNewChain; // Get token metadata const tokenMetadata = tokenMetadataCache.get( `${approval.chainId}:${approval.tokenAddress}` ); // Format token display based on available metadata const tokenDisplay = tokenMetadata && tokenMetadata.success ? `${chalk.cyan(tokenMetadata.symbol)}` : chalk.cyan(approval.tokenAddress.slice(0, 6) + "..."); // Format spender display with selection indicator and truncation if needed const spenderText = formatToken(approval.spender); // Truncate long spender addresses to fit column const displaySpender = spenderText.length > 18 ? spenderText.slice(0, 8) + "..." + spenderText.slice(-8) : spenderText; const spenderDisplay = isSelected ? chalk.yellow.bold(`→ ${displaySpender}`) : chalk.yellow(displaySpender); // Update unlimited flag for effectively unlimited values const isEffectiveUnlimited = isEffectivelyUnlimited( approval.remainingApproval ); const displayAsUnlimited = approval.isUnlimited || isEffectiveUnlimited; // Format amount display const amountDisplay = displayAsUnlimited ? isSelected ? chalk.red.bold("⚠️ UNLIMITED") : chalk.red.bold("⚠️ ∞") : chalk.green(formatAmount(approval.remainingApproval, tokenMetadata)); // Handle chain grouping - only show chain name for the first entry of the chain const chainCell = isNewChain ? formatChainName(approval.chainId) : ""; // Add row to table approvalsTable.push([ chainCell, tokenDisplay, spenderDisplay, amountDisplay, ]); // Update tracking variables if (isNewChain) { currentChainId = approval.chainId; } if (isNewToken) { currentTokenAddress = approval.tokenAddress; } } // Display the table console.log(approvalsTable.toString()); } // Function to display progress bars and summary table sequentially function displayScanSummary() { // Calculate maximum width needed for chain names const chainNameWidth = Math.max( ...CHAIN_IDS.map((id) => formatChainName(id).length), 10 // Minimum width ) + 2; // Add some padding // Display progress bars header console.log(chalk.bold.yellow("SCAN PROGRESS")); // Display progress bars for (const chainId of CHAIN_IDS) { if (chainStats[chainId]) { const stats = chainStats[chainId]; // Use consistent padding and formatting for all chains const chainName = formatChainName(chainId); const paddedChainName = chainName.padEnd(chainNameWidth); // Create progress bar line with fixed spacing console.log( ` ${paddedChainName}: [${stats.progressBar}] 100.00% ${chalk.green( "✓ Complete" )}` ); } } // Create summary table console.log(chalk.bold.yellow("\nSUMMARY")); const statsTable = new Table({ head: [ chalk.cyan("CHAIN"), chalk.cyan("HEIGHT"), chalk.cyan("EVENTS"), chalk.cyan("TIME"), chalk.cyan("APPROVALS"), ], colWidths: [15, 15, 10, 8, 10], style: { head: [], // No additional styling for headers border: [], // No additional styling for borders compact: true, // More compact table with less padding }, }); // Add rows to the table from chain stats let totalApprovals = 0; for (const chainId of CHAIN_IDS) { if (chainStats[chainId]) { const stats = chainStats[chainId]; // Add a row with colored chain name and right-aligned numeric data statsTable.push([ formatChainName(chainId), // Already has color applied formatNumber(stats.height), formatNumber(stats.totalEvents), `${(stats.endTime / 1000).toFixed(1)}s`, stats.approvalsCount.toString(), ]); totalApprovals += stats.approvalsCount; } } // Add a totals row statsTable.push([ chalk.bold("TOTAL"), "", "", "", chalk.bold.white(totalApprovals.toString()), ]); // Display the table console.log(statsTable.toString()); console.log(""); // Add spacing } // Asynchronous function to fetch token metadata in background async function fetchTokenMetadataInBackground(startIdx, endIdx) { // Collection of unique token addresses on the current page const tokensToFetch = new Set(); // Collect all tokens that need metadata for (let i = startIdx; i < endIdx; i++) { if (i < approvalsList.length) { const approval = approvalsList[i]; const cacheKey = `${approval.chainId}:${approval.tokenAddress}`; // Only fetch tokens that aren't already in the cache if (!tokenMetadataCache.has(cacheKey)) { tokensToFetch.add({ chainId: approval.chainId, tokenAddress: approval.tokenAddress, }); } } } // If no tokens to fetch, we're done if (tokensToFetch.size === 0) return; // Fetch token metadata in parallel const promises = Array.from(tokensToFetch).map( async ({ chainId, tokenAddress }) => { await fetchTokenMetadata(tokenAddress, chainId); } ); // Wait for all fetches to complete then redraw the screen await Promise.all(promises); displayApprovalsList(); } // Function to display approval details function displayApprovalDetails(approval, tokenMetadata) { // Get chain info const chain = SUPPORTED_CHAINS[approval.chainId] || { name: `Chain ${approval.chainId}`, color: "white", }; console.log( "\n" + boxen(chalk.bold.cyan("APPROVAL DETAILS"), { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderColor: "green", borderStyle: "round", }) ); // Update unlimited flag for effectively unlimited values const isEffectiveUnlimited = isEffectivelyUnlimited( approval.remainingApproval ); const displayAsUnlimited = approval.isUnlimited || isEffectiveUnlimited; // Create a more readable single-column display const detailsContent = [ // Chain information `${chalk.cyan.bold("Chain:")} ${formatChainName(approval.chainId)}`, "", // Token information tokenMetadata && tokenMetadata.success ? `${chalk.cyan.bold("Token:")} ${chalk.green(tokenMetadata.name)} (${ tokenMetadata.symbol })` : `${chalk.cyan.bold("Token:")} ${chalk.green(approval.tokenAddress)}`, `${chalk.cyan.bold("Token Address:")} ${chalk.green( approval.tokenAddress )}`, "", // Spender information `${chalk.cyan.bold("Spender Address:")} ${chalk.green(approval.spender)}`, "", // Approval amounts chalk.cyan.bold("Approval Details:"), `${chalk.yellow("Approved Amount:")} ${chalk.green( displayAsUnlimited ? "∞ (Unlimited)" : formatAmount(approval.approvedAmount, tokenMetadata) )}`, `${chalk.yellow("Used Amount:")} ${chalk.green( formatAmount(approval.transferredAmount, tokenMetadata) )}`, `${chalk.yellow("Remaining:")} ${ displayAsUnlimited ? chalk.red.bold("∞ (UNLIMITED)") : chalk.green(formatAmount(approval.remainingApproval, tokenMetadata)) }`, "", // Transaction information chalk.cyan.bold("Transaction Details:"), `${chalk.yellow("Block Number:")} ${approval.blockNumber}`, `${chalk.yellow("Transaction Hash:")} ${approval.txHash}`, ].join("\n"); // Display the details console.log( boxen(detailsContent, { padding: 1, borderColor: "blue", borderStyle: "round", }) ); // Display warning for unlimited approvals if (displayAsUnlimited) { console.log( boxen( chalk.bold.white( "⚠️ UNLIMITED APPROVAL - This contract has unlimited access to this token in your wallet" ), { padding: 1, borderColor: "red", borderStyle: "round" } ) ); } } // Help screen to display all commands function displayHelpScreen() { console.clear(); console.log(chalk.bold.cyan(figlet.textSync("HELP", { font: "Doom" }))); console.log( chalk.bold.cyan("multichain token approval scanner") + " - " + chalk.cyan("powered by ") + chalk.cyan.underline("envio.dev") + "\n" ); const helpContent = boxen( [ chalk.bold.yellow("COMMAND REFERENCE"), "", `${chalk.yellow("n")} - Move to the next approval in the list`, `${chalk.yellow("p")} - Move to the previous approval in the list`, `${chalk.yellow(">")} - Go to next page of approvals`, `${chalk.yellow("<")} - Go to previous page of approvals`, `${chalk.yellow("h")} - Show this help screen`, `${chalk.yellow("q")} - Quit the application`, "", chalk.bold.yellow("ABOUT TOKEN APPROVALS"), "", `${chalk.white( "Token approvals give dApps permission to spend your tokens." )}`, `${chalk.white( "Unlimited approvals (∞) are a security risk as they never expire." )}`, `${chalk.white( "Consider revoking unused approvals to improve your wallet security." )}`, "", chalk.bold.yellow("PRESS ANY KEY TO RETURN"), ].join("\n"), { padding: 1, borderColor: "cyan", borderStyle: "round", } ); // Add a separate, more prominent box for the revoke.cash link const revokeLink = `https://revoke.cash/address/${TARGET_ADDRESS}`; const revokeLinkContent = boxen( [ chalk.bold.yellow("⚠️ HOW TO REVOKE APPROVALS ⚠️"), "", `${chalk.white("To manage and revoke token approvals, visit:")}`, "", `${chalk.bold.cyan.underline(revokeLink)}`, ].join("\n"), { padding: { top: 1, bottom: 1, left: 3, right: 3 }, margin: { top: 1, bottom: 1 }, borderColor: "red", borderStyle: "double", } ); console.log(helpContent); console.log(revokeLinkContent); // Wait for keypress to return process.stdin.once("data", () => { displayApprovalsList(); process.stdout.write(chalk.cyan.bold("> ")); }); } // Interactive mode with improved prompting function startInteractivePrompt() { // Use a visually distinct prompt process.stdout.write(chalk.cyan.bold("> ")); // Use a different approach with process.stdin directly process.stdin.resume(); // Resume stdin stream process.stdin.setEncoding("utf8"); process.stdin.on("data", function (data) { const command = data.toString().trim().toLowerCase(); if (command === "q") { console.log(chalk.green("Exiting...")); rl.close(); process.exit(0); } else if (command === "n") { if (selectedApprovalIndex < approvalsList.length - 1) { selectedApprovalIndex++; // Update current page if selection moves to next page if (selectedApprovalIndex >= (currentPage + 1) * PAGE_SIZE) { currentPage = Math.floor(selectedApprovalIndex / PAGE_SIZE); } } displayApprovalsList(); process.stdout.write(chalk.cyan.bold("> ")); } else if (command === "p") { if (selectedApprovalIndex > 0) { selectedApprovalIndex--; // Update current page if selection moves to previous page if (selectedApprovalIndex < currentPage * PAGE_SIZE) { currentPage = Math.floor(selectedApprovalIndex / PAGE_SIZE); } } displayApprovalsList(); process.stdout.write(chalk.cyan.bold("> ")); } else if (command === ">") { // Next page if ((currentPage + 1) * PAGE_SIZE < approvalsList.length) { currentPage++; // Update selected index to first item on new page selectedApprovalIndex = currentPage * PAGE_SIZE; } displayApprovalsList(); process.stdout.write(chalk.cyan.bold("> ")); } else if (command === "<") { // Previous page if (currentPage > 0) { currentPage--; // Update selected index to first item on new page selectedApprovalIndex = currentPage * PAGE_SIZE; } displayApprovalsList(); process.stdout.write(chalk.cyan.bold("> ")); } else if (command === "h") { // Show help screen displayHelpScreen(); } else if (command === "") { // Enter key - toggle details view detailsExpanded = !detailsExpanded; displayApprovalsList(); process.stdout.write(chalk.cyan.bold("> ")); } else if (command) { // Invalid command console.log( chalk.red(`Invalid command: '${command}'. Type 'h' for help.`) ); process.stdout.write(chalk.cyan.bold("> ")); } else { // Empty command, just redisplay prompt process.stdout.write(chalk.cyan.bold("> ")); } }); // Handle Ctrl+C to exit gracefully process.on("SIGINT", function () { console.log("\nExiting..."); process.exit(0); }); } // Main function async function main() { // Clear the screen and show welcome message console.clear(); console.log(chalk.bold.cyan(figlet.textSync("snubb", { font: "Doom" }))); console.log( chalk.bold.cyan("multichain token approval scanner") + " - " + chalk.cyan("powered by ") + chalk.cyan.underline("envio.dev") + "\n" ); console.log(chalk.yellow(`Address: ${chalk.green(TARGET_ADDRESS)}\n`)); // Show which chains will be scanned console.log(chalk.yellow("Scanning chains:")); for (const chainId of CHAIN_IDS) { const chain = SUPPORTED_CHAINS[chainId] || { name: `Chain ${chainId}`, color: "white", }; console.log(` - ${formatChainName(chainId)}`); } console.log(""); try { // Initialize chain statistics first (without spinner to show real-time progress) console.log(chalk.bold.yellow("INITIALIZING CHAINS")); // Get chain heights for all chains first for (const chainId of CHAIN_IDS) { // Initialize per-chain stats chainStats[chainId] = { height: 0, totalEvents: 0, startTime: 0, endTime: 0, progressBar: drawProgressBar(0), eventsPerSecond: 0, approvalsCount: 0, isScanning: false, isComplete: false, }; // Initialize Hypersync client for this chain const hypersyncUrl = `http://${chainId}.hypersync.xyz`; try { const client = HypersyncClient.new({ url: hypersyncUrl, }); // Get chain height console.log(` Connecting to ${formatChainName(chainId)}...`); const height = await client.getHeight(); chainStats[chainId].height = height; console.log( ` ${formatChainName(chainId)} height: ${formatNumber(height)}` ); } catch (error) { console.error( chalk.red(` Error connecting to ${hypersyncUrl}: ${error.message}`) ); } } console.log("\n" + chalk.bold.yellow("SCANNING PROGRESS")); // Display initial progress bars displayScanProgress(); // Start scanning each chain (in parallel) but with UI updates const scanPromises = CHAIN_IDS.map((chainId) => { // Mark this chain as scanning chainStats[chainId].isScanning = true; chainStats[chainId].startTime = performance.now(); // Return the scan promise return scanChain(chainId) .then((result) => { // Mark as complete and update UI chainStats[chainId].isScanning = false; chainStats[chainId].isComplete = true; displayScanProgress(); return result; }) .catch((error) => { // Handle error, mark as complete console.error( chalk.red(`Error scanning chain ${chainId}: ${error.message}`) ); chainStats[chainId].isScanning = false; chainStats[chainId].isComplete = true; displayScanProgress(); return { approvals: {}, transfersUsingApprovals: {} }; }); }); // Start UI update interval - refresh every 500ms while scanning const uiUpdateInterval = setInterval(() => { // Only continue updating while at least one chain is still scanning if (Object.values(chainStats).some((stats) => stats.isScanning)) { displayScanProgress(); } else { clearInterval(uiUpdateInterval); } }, 500); // Wait for all scans to complete const results = await Promise.all(scanPromises); // Clear the UI update interval (if not already cleared) clearInterval(uiUpdateInterval); // Show completion message console.log(chalk.green("\nAll chains scanned successfully!\n")); // Process approvals from all chains approvalsList = []; // Combine results from all chains results.forEach(({ approvals, transfersUsingApprovals }, index) => { const chainId = CHAIN_IDS[index]; let chainApprovalsCount = 0; // Process approvals for this chain for (const tokenAddress in approvals) { for (const spender in approvals[tokenAddress]) { const { amount: approvedAmount, blockNumber, txHash, } = approvals[tokenAddress][spender]; const transferredAmount = transfersUsingApprovals[tokenAddress]?.[spender] || BigInt(0); // Calculate remaining approval let remainingApproval; let isUnlimited = false; // Check for unlimited approval (common values) if ( approvedAmount === BigInt(2) ** BigInt(256) - BigInt(1) || approvedAmount === BigInt( "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" ) || isEffectivelyUnlimited(approvedAmount) ) { remainingApproval = approvedAmount; isUnlimited = true; } else { remainingApproval = approvedAmount > transferredAmount ? approvedAmount - transferredAmount : BigInt(0); } // Only show non-zero remaining approvals if (remainingApproval > 0) { approvalsList.push({ chainId, tokenAddress, spender, approvedAmount, transferredAmount, remainingApproval, isUnlimited, blockNumber, txHash, }); chainApprovalsCount++; } } } // Update chain stats with approval count if (chainStats[chainId]) { chainStats[chainId].approvalsCount = chainApprovalsCount; } }); // Sort approvals with priority: by chain, unlimited first across tokens, then largest amounts approvalsList.sort((a, b) => { // First by chain ID if (a.chainId !== b.chainId) { return a.chainId - b.chainId; } // Group by token + unlimited status to bring unlimited tokens to the top const aIsUnlimitedToken = a.isUnlimited || isEffectivelyUnlimited(a.remainingApproval); const bIsUnlimitedToken = b.isUnlimited || isEffectivelyUnlimited(b.remainingApproval); // Sort unlimited tokens first within the same chain if (aIsUnlimitedToken && !bIsUnlimitedToken) return -1; if (!aIsUnlimitedToken && bIsUnlimitedToken) return 1; // For tokens with the same unlimited status, sort by token address if (a.tokenAddress !== b.tokenAddress) { return a.tokenAddress.localeCompare(b.tokenAddress); } // Then by unlimited status (unlimited approvals first) for the same token if (a.isUnlimited && !b.isUnlimited) return -1; if (!a.isUnlimited && b.isUnlimited) return 1; // Then by remaining approval amount (highest first) for same token, non-unlimited approvals if (!a.isUnlimited && !b.isUnlimited) { if (b.remainingApproval > a.remainingApproval) return 1; if (b.remainingApproval < a.remainingApproval) return -1; } return 0; }); // Display summary console.log( chalk.cyan( `Found ${chalk.white( approvalsList.length )} outstanding approvals for ${chalk.white(TARGET_ADDRESS)}\n` ) ); if (approvalsList.length === 0) { console.log( chalk.green("No outstanding approvals found. Your wallets are secure!") ); rl.close(); process.exit(0); } // Display initial approvals list displayApprovalsList(); // Start interactive mode startInteractivePrompt(); } catch (error) { console.error(chalk.red(`Error: ${error.message}`)); process.exit(1); } } // Function to display ongoing scan progress function displayScanProgress() { // No need to clear the screen - we want to see continuous updates // Calculate maximum width needed for chain names const chainNameWidth = Math.max( ...CHAIN_IDS.map((id) => formatChainName(id).length), 10 // Minimum width ) + 2; // Add some padding // Display progress for each chain for (const chainId of CHAIN_IDS) { const stats = chainStats[chainId]; if (!stats) continue; // If chain is actively scanning if (stats.isScanning) { const elapsedTime = (performance.now() - stats.startTime) / 1000; const eventsPerSecond = stats.totalEvents > 0 ? Math.round(stats.totalEvents / elapsedTime) : 0; // Format numbers with consistent width const blockDisplay = `${formatNumber( stats.lastBlockSeen || 0 )}/${formatNumber(stats.height)}`.padEnd(20); const eventsDisplay = formatNumber(stats.totalEvents).padEnd(8); const speedDisplay = `${formatNumber(eventsPerSecond)}/s`.padEnd(10); // Calculate progress percentage - ensure it's greater than 0 if any blocks processed const progress = stats.lastBlockSeen ? Math.max(0.01, stats.lastBlockSeen / stats.height) : 0; // Update progress bar stats.progressBar = drawProgressBar( progress, 40, SUPPORTED_CHAINS[chainId]?.color || "cyan" ); // Use a different format for in-progress chains with better alignment process.stdout.write( `\r${formatChainName(chainId).padEnd(chainNameWidth)}: ${ stats.progressBar } Block: ${blockDisplay} | Events: ${eventsDisplay} | ${speedDisplay} ` ); process.stdout.write("\n"); } // If chain scan is complete else if (stats.isComplete) { const elapsedTime = (stats.endTime / 1000).toFixed(1); // Format numbers with consistent width const eventsDisplay = formatNumber(stats.totalEvents).padEnd(8); const timeDisplay = `${elapsedTime}s`.padEnd(6); // Ensure progress bar shows 100% for completed chains stats.progressBar = drawProgressBar( 1.0, 40, SUPPORTED_CHAINS[chainId]?.color || "cyan" ); // Show completed chain with checkmark and better alignment process.stdout.write( `\r${formatChainName(chainId).padEnd(chainNameWidth)}: ${ stats.progressBar } ${chalk.green( "✓" )} Complete | Events: ${eventsDisplay} in ${timeDisplay} ` ); process.stdout.write("\n"); } // If not yet started scanning else { process.stdout.write( `\r${formatChainName(chainId).padEnd(chainNameWidth)}: ${ stats.progressBar } Waiting to begin scan... ` ); process.stdout.write("\n"); } } // Move cursor position back up to overwrite the progress display on next update process.stdout.write(`\x1b[${CHAIN_IDS.length}A`); } // Function to scan a single chain async function scanChain(chainId) { // Initialize per-chain stats (should already be initialized in main) const stats = chainStats[chainId]; const chain = SUPPORTED_CHAINS[chainId] || { name: `Chain ${chainId}`, color: "white", }; const colorName = chain.color || "white"; // Initialize Hypersync client for this chain const hypersyncUrl = `http://${chainId}.hypersync.xyz`; const client = HypersyncClient.new({ url: hypersyncUrl, }); // Create decoder for events const decoder = Decoder.fromSignatures([ "Transfer(address indexed from, address indexed to, uint256 amount)", "Approval(address indexed owner, address indexed spender, uint256 amount)", ]); // Track approvals by token and spender const approvals = {}; const transfersUsingApprovals = {}; let query = createQuery(0); let lastOutputTime = Date.now(); // Start streaming events const stream = await client.stream(query, {}); while (true) { try { const res = await stream.recv(); // Exit if we've reached the end of the chain if (res === null) { break; } // Track the last block we've seen if (res.nextBlock) { stats.lastBlockSeen = res.nextBlock; } // Process events if (res.data && res.data.logs) { stats.totalEvents += res.data.logs.length; // Decode logs const decodedLogs = await decoder.decodeLogs(res.data.logs); // Process ERC20 events for (let i = 0; i < decodedLogs.length; i++) { const log = decodedLogs[i]; if (log === null) con