diff --git a/contract_manager/scripts/transfer_balance_entropy_chains.ts b/contract_manager/scripts/transfer_balance_entropy_chains.ts new file mode 100644 index 0000000000..bb26bf09f0 --- /dev/null +++ b/contract_manager/scripts/transfer_balance_entropy_chains.ts @@ -0,0 +1,437 @@ +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; +import { DefaultStore } from "../src/node/utils/store"; +import { PrivateKey, toPrivateKey } from "../src/core/base"; +import { EvmChain } from "../src/core/chains"; +import Web3 from "web3"; + +interface TransferResult { + chain: string; + success: boolean; + sourceAddress: string; + destinationAddress: string; + originalBalance: string; + transferAmount: string; + remainingBalance: string; + transactionHash?: string; + error?: string; +} + +const parser = yargs(hideBin(process.argv)) + .usage( + "Multi-Chain Balance Transfer Tool for Pyth Entropy Chains\n\nUsage: $0 --source-private-key --destination-address [chain-selection] [transfer-method] [options]", + ) + .options({ + "source-private-key": { + type: "string", + demandOption: true, + desc: "Private key of the source wallet to transfer from", + }, + "destination-address": { + type: "string", + demandOption: true, + desc: "Public address of the destination wallet", + }, + chain: { + type: "array", + string: true, + desc: "Specific chain IDs to transfer on (e.g., --chain optimism_sepolia --chain avalanche)", + }, + testnets: { + type: "boolean", + default: false, + desc: "Transfer on all testnet entropy chains", + }, + mainnets: { + type: "boolean", + default: false, + desc: "Transfer on all mainnet entropy chains", + }, + amount: { + type: "number", + desc: "Exact amount in ETH to transfer from each chain", + }, + ratio: { + type: "number", + desc: "Ratio of available balance to transfer (0-1, e.g., 0.5 for half, 1.0 for all)", + }, + "min-balance": { + type: "number", + default: 0.001, + desc: "Minimum balance in ETH required before attempting transfer", + }, + "gas-multiplier": { + type: "number", + default: 2, + desc: "Gas multiplier for transaction safety", + }, + "dry-run": { + type: "boolean", + default: false, + desc: "Preview transfers without executing transactions", + }, + }) + .group( + ["chain", "testnets", "mainnets"], + "Chain Selection (choose exactly one):", + ) + .group(["amount", "ratio"], "Transfer Method (choose exactly one):") + .group(["min-balance", "gas-multiplier", "dry-run"], "Optional Parameters:") + .example([ + [ + "$0 --source-private-key abc123... --destination-address 0x742d35... --mainnets --amount 0.1", + "Transfer 0.1 ETH from all mainnet chains", + ], + [ + "$0 --source-private-key abc123... --destination-address 0x742d35... --testnets --ratio 0.75", + "Transfer 75% of balance from all testnet chains", + ], + [ + "$0 --source-private-key abc123... --destination-address 0x742d35... --chain ethereum --chain avalanche --amount 0.05", + "Transfer 0.05 ETH from specific chains", + ], + [ + "$0 --source-private-key abc123... --destination-address 0x742d35... --testnets --ratio 0.5 --dry-run", + "Preview 50% transfer on all testnet chains", + ], + ]) + .help() + .alias("help", "h") + .version(false); + +async function transferOnChain( + chain: EvmChain, + sourcePrivateKey: PrivateKey, + destinationAddress: string, + minBalance: number, + gasMultiplier: number, + dryRun: boolean, + transferAmount?: number, + transferRatio?: number, +): Promise { + const web3 = chain.getWeb3(); + const signer = web3.eth.accounts.privateKeyToAccount(sourcePrivateKey); + const sourceAddress = signer.address; + + try { + // Get balance + const balanceWei = await web3.eth.getBalance(sourceAddress); + const balanceEth = Number(web3.utils.fromWei(balanceWei, "ether")); + + console.log(`\n${chain.getId()}: Checking balance for ${sourceAddress}`); + console.log(` Balance: ${balanceEth.toFixed(6)} ETH`); + + if (balanceEth < minBalance) { + console.log( + ` Balance below minimum threshold (${minBalance} ETH), skipping`, + ); + return { + chain: chain.getId(), + success: false, + sourceAddress, + destinationAddress, + originalBalance: balanceEth.toFixed(6), + transferAmount: "0", + remainingBalance: balanceEth.toFixed(6), + error: `Balance ${balanceEth.toFixed(6)} ETH below minimum ${minBalance} ETH`, + }; + } + + // Calculate gas costs + const gasPrice = await web3.eth.getGasPrice(); + const estimatedGas = await web3.eth.estimateGas({ + from: sourceAddress, + to: destinationAddress, + value: "1", // Minimal value for estimation + }); + + const gasCostWei = + BigInt(estimatedGas) * BigInt(gasPrice) * BigInt(gasMultiplier); + const gasCostEth = Number( + web3.utils.fromWei(gasCostWei.toString(), "ether"), + ); + + // Calculate transfer amount + let transferAmountEth: number; + if (transferAmount !== undefined) { + transferAmountEth = transferAmount; + } else { + // transferRatio is guaranteed to be defined at this point + transferAmountEth = (balanceEth - gasCostEth) * transferRatio!; + } + + // Validate transfer amount + if (transferAmountEth <= 0) { + console.log( + ` Not enough balance to cover transfer and gas costs, skipping`, + ); + return { + chain: chain.getId(), + success: false, + sourceAddress, + destinationAddress, + originalBalance: balanceEth.toFixed(6), + transferAmount: "0", + remainingBalance: balanceEth.toFixed(6), + error: `Insufficient balance for transfer amount and gas costs (${gasCostEth.toFixed(6)} ETH)`, + }; + } + + if (transferAmountEth + gasCostEth > balanceEth) { + console.log(` Transfer amount plus gas costs exceed balance, skipping`); + return { + chain: chain.getId(), + success: false, + sourceAddress, + destinationAddress, + originalBalance: balanceEth.toFixed(6), + transferAmount: "0", + remainingBalance: balanceEth.toFixed(6), + error: `Transfer amount ${transferAmountEth.toFixed(6)} ETH plus gas ${gasCostEth.toFixed(6)} ETH exceeds balance`, + }; + } + + const transferAmountWei = web3.utils.toWei( + transferAmountEth.toString(), + "ether", + ); + + console.log(` Transfer amount: ${transferAmountEth.toFixed(6)} ETH`); + console.log(` Estimated gas cost: ${gasCostEth.toFixed(6)} ETH`); + console.log(` Destination: ${destinationAddress}`); + + if (dryRun) { + console.log( + ` DRY RUN: Would transfer ${transferAmountEth.toFixed(6)} ETH`, + ); + return { + chain: chain.getId(), + success: true, + sourceAddress, + destinationAddress, + originalBalance: balanceEth.toFixed(6), + transferAmount: transferAmountEth.toFixed(6), + remainingBalance: (balanceEth - transferAmountEth).toFixed(6), + }; + } + + // Perform the transfer + web3.eth.accounts.wallet.add(signer); + + console.log(` Executing transfer...`); + const tx = await web3.eth.sendTransaction({ + from: sourceAddress, + to: destinationAddress, + value: transferAmountWei, + gas: Number(estimatedGas) * gasMultiplier, + gasPrice: gasPrice, + }); + + // Get updated balance + const newBalanceWei = await web3.eth.getBalance(sourceAddress); + const newBalanceEth = Number(web3.utils.fromWei(newBalanceWei, "ether")); + + console.log(` Transfer successful!`); + console.log(` Transaction hash: ${tx.transactionHash}`); + console.log(` New balance: ${newBalanceEth.toFixed(6)} ETH`); + + return { + chain: chain.getId(), + success: true, + sourceAddress, + destinationAddress, + originalBalance: balanceEth.toFixed(6), + transferAmount: transferAmountEth.toFixed(6), + remainingBalance: newBalanceEth.toFixed(6), + transactionHash: tx.transactionHash, + }; + } catch (error) { + console.log(` Transfer failed: ${error}`); + return { + chain: chain.getId(), + success: false, + sourceAddress, + destinationAddress, + originalBalance: "unknown", + transferAmount: "0", + remainingBalance: "unknown", + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function getSelectedChains(argv: { + chain?: string[]; + testnets: boolean; + mainnets: boolean; +}): EvmChain[] { + // Check for mutually exclusive options + const optionCount = + (argv.testnets ? 1 : 0) + (argv.mainnets ? 1 : 0) + (argv.chain ? 1 : 0); + if (optionCount !== 1) { + throw new Error( + "Must specify exactly one of: --testnets, --mainnets, or --chain", + ); + } + + // Get all entropy contract chains + const allEntropyChains: EvmChain[] = []; + for (const contract of Object.values(DefaultStore.entropy_contracts)) { + const chain = contract.getChain(); + if (chain instanceof EvmChain) { + allEntropyChains.push(chain); + } + } + + let selectedChains: EvmChain[]; + + if (argv.testnets) { + selectedChains = allEntropyChains.filter((chain) => !chain.isMainnet()); + } else if (argv.mainnets) { + selectedChains = allEntropyChains.filter((chain) => chain.isMainnet()); + } else { + // Specific chains + const entropyChainIds = new Set( + allEntropyChains.map((chain) => chain.getId()), + ); + selectedChains = []; + + for (const chainId of argv.chain!) { + if (!entropyChainIds.has(chainId)) { + throw new Error( + `Chain ${chainId} does not have entropy contracts deployed`, + ); + } + const chain = DefaultStore.chains[chainId]; + if (!(chain instanceof EvmChain)) { + throw new Error(`Chain ${chainId} is not an EVM chain`); + } + selectedChains.push(chain); + } + } + + if (selectedChains.length === 0) { + const mode = argv.testnets + ? "testnet" + : argv.mainnets + ? "mainnet" + : "specified"; + throw new Error(`No valid ${mode} entropy chains found`); + } + + return selectedChains; +} + +async function main() { + const argv = await parser.argv; + + // Validate inputs + if (!Web3.utils.isAddress(argv.destinationAddress)) { + throw new Error("Invalid destination address format"); + } + + // Validate transfer amount options + if (argv.amount !== undefined && argv.ratio !== undefined) { + throw new Error("Cannot specify both --amount and --ratio options"); + } + + if (argv.amount === undefined && argv.ratio === undefined) { + throw new Error("Must specify either --amount or --ratio option"); + } + + if (argv.ratio !== undefined && (argv.ratio <= 0 || argv.ratio > 1)) { + throw new Error( + "Ratio must be between 0 and 1 (exclusive of 0, inclusive of 1)", + ); + } + + if (argv.amount !== undefined && argv.amount <= 0) { + throw new Error("Amount must be greater than 0"); + } + + const sourcePrivateKey = toPrivateKey(argv.sourcePrivateKey); + const selectedChains = getSelectedChains(argv); + + // Determine transfer method for display + let transferMethod: string; + if (argv.amount !== undefined) { + transferMethod = `${argv.amount} ETH (fixed amount)`; + } else { + transferMethod = `${(argv.ratio! * 100).toFixed(1)}% of available balance`; + } + + console.log(`\nConfiguration:`); + console.log( + ` Network: ${argv.testnets ? "Testnet" : argv.mainnets ? "Mainnet" : "Specific chains"}`, + ); + console.log(` Destination: ${argv.destinationAddress}`); + console.log(` Transfer method: ${transferMethod}`); + console.log(` Minimum balance: ${argv.minBalance} ETH`); + console.log(` Gas multiplier: ${argv.gasMultiplier}x`); + console.log(` Dry run: ${argv.dryRun ? "Yes" : "No"}`); + console.log(` Chains: ${selectedChains.map((c) => c.getId()).join(", ")}`); + + if (argv.dryRun) { + console.log(`\nRUNNING IN DRY-RUN MODE - NO TRANSACTIONS WILL BE EXECUTED`); + } + + const results: TransferResult[] = []; + + // Process each chain + for (const chain of selectedChains) { + const result = await transferOnChain( + chain, + sourcePrivateKey, + argv.destinationAddress, + argv.minBalance, + argv.gasMultiplier, + argv.dryRun, + argv.amount, + argv.ratio, + ); + results.push(result); + } + + // Summary + console.log("\nTRANSFER SUMMARY"); + console.log("=================="); + + const successful = results.filter((r) => r.success); + const failed = results.filter((r) => !r.success); + + console.log(`Successful transfers: ${successful.length}`); + console.log(`Failed transfers: ${failed.length}`); + console.log( + `Total transferred: ${successful.reduce((sum, r) => sum + parseFloat(r.transferAmount), 0).toFixed(6)} ETH`, + ); + + if (successful.length > 0) { + console.log("\nSuccessful Transfers:"); + console.table( + successful.map((r) => ({ + Chain: r.chain, + "Transfer Amount (ETH)": r.transferAmount, + "TX Hash": r.transactionHash || "N/A (dry run)", + "Remaining Balance (ETH)": r.remainingBalance, + })), + ); + } + + if (failed.length > 0) { + console.log("\nFailed Transfers:"); + console.table( + failed.map((r) => ({ + Chain: r.chain, + "Original Balance (ETH)": r.originalBalance, + Error: r.error, + })), + ); + } + + console.log("\nTransfer process completed!"); +} + +main().catch((error) => { + console.error("Script failed:", error); + process.exit(1); +});