transfer_balance_entropy_chains.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453
  1. import yargs from "yargs";
  2. import { hideBin } from "yargs/helpers";
  3. import { DefaultStore } from "../src/node/utils/store";
  4. import { PrivateKey, toPrivateKey } from "../src/core/base";
  5. import { EvmChain } from "../src/core/chains";
  6. import Web3 from "web3";
  7. interface TransferResult {
  8. chain: string;
  9. success: boolean;
  10. sourceAddress: string;
  11. destinationAddress: string;
  12. originalBalance: string;
  13. transferAmount: string;
  14. remainingBalance: string;
  15. transactionHash?: string;
  16. error?: string;
  17. }
  18. const parser = yargs(hideBin(process.argv))
  19. .usage(
  20. "Multi-Chain Balance Transfer Tool for Pyth Entropy Chains\n\nUsage: $0 --source-private-key <key> --destination-address <addr> [chain-selection] [transfer-method] [options]",
  21. )
  22. .options({
  23. "source-private-key": {
  24. type: "string",
  25. demandOption: true,
  26. desc: "Private key of the source wallet to transfer from",
  27. },
  28. "destination-address": {
  29. type: "string",
  30. demandOption: true,
  31. desc: "Public address of the destination wallet",
  32. },
  33. chain: {
  34. type: "array",
  35. string: true,
  36. desc: "Specific chain IDs to transfer on (e.g., --chain optimism_sepolia --chain avalanche)",
  37. },
  38. testnets: {
  39. type: "boolean",
  40. default: false,
  41. desc: "Transfer on all testnet entropy chains",
  42. },
  43. mainnets: {
  44. type: "boolean",
  45. default: false,
  46. desc: "Transfer on all mainnet entropy chains",
  47. },
  48. amount: {
  49. type: "number",
  50. desc: "Exact amount in ETH to transfer from each chain",
  51. },
  52. ratio: {
  53. type: "number",
  54. desc: "Ratio of available balance to transfer (0-1, e.g., 0.5 for half, 1.0 for all)",
  55. },
  56. "min-balance": {
  57. type: "number",
  58. default: 0.001,
  59. desc: "Minimum balance in ETH required before attempting transfer",
  60. },
  61. "gas-multiplier": {
  62. type: "number",
  63. default: 2,
  64. desc: "Gas multiplier for transaction safety",
  65. },
  66. "dry-run": {
  67. type: "boolean",
  68. default: false,
  69. desc: "Preview transfers without executing transactions",
  70. },
  71. })
  72. .group(
  73. ["chain", "testnets", "mainnets"],
  74. "Chain Selection (choose exactly one):",
  75. )
  76. .group(["amount", "ratio"], "Transfer Method (choose exactly one):")
  77. .group(["min-balance", "gas-multiplier", "dry-run"], "Optional Parameters:")
  78. .example([
  79. [
  80. "$0 --source-private-key abc123... --destination-address 0x742d35... --mainnets --amount 0.1",
  81. "Transfer 0.1 ETH from all mainnet chains",
  82. ],
  83. [
  84. "$0 --source-private-key abc123... --destination-address 0x742d35... --testnets --ratio 0.75",
  85. "Transfer 75% of balance from all testnet chains",
  86. ],
  87. [
  88. "$0 --source-private-key abc123... --destination-address 0x742d35... --chain ethereum --chain avalanche --amount 0.05",
  89. "Transfer 0.05 ETH from specific chains",
  90. ],
  91. [
  92. "$0 --source-private-key abc123... --destination-address 0x742d35... --testnets --ratio 0.5 --dry-run",
  93. "Preview 50% transfer on all testnet chains",
  94. ],
  95. ])
  96. .help()
  97. .alias("help", "h")
  98. .version(false);
  99. async function transferOnChain(
  100. chain: EvmChain,
  101. sourcePrivateKey: PrivateKey,
  102. destinationAddress: string,
  103. minBalance: number,
  104. gasMultiplier: number,
  105. dryRun: boolean,
  106. transferAmount?: number,
  107. transferRatio?: number,
  108. ): Promise<TransferResult> {
  109. const web3 = chain.getWeb3();
  110. const signer = web3.eth.accounts.privateKeyToAccount(sourcePrivateKey);
  111. const sourceAddress = signer.address;
  112. try {
  113. // Get balance
  114. const balanceWei = await web3.eth.getBalance(sourceAddress);
  115. const balanceEth = Number(web3.utils.fromWei(balanceWei, "ether"));
  116. console.log(`\n${chain.getId()}: Checking balance for ${sourceAddress}`);
  117. console.log(` Balance: ${balanceEth.toFixed(6)} ETH`);
  118. if (balanceEth < minBalance) {
  119. console.log(
  120. ` Balance below minimum threshold (${minBalance} ETH), skipping`,
  121. );
  122. return {
  123. chain: chain.getId(),
  124. success: false,
  125. sourceAddress,
  126. destinationAddress,
  127. originalBalance: balanceEth.toFixed(6),
  128. transferAmount: "0",
  129. remainingBalance: balanceEth.toFixed(6),
  130. error: `Balance ${balanceEth.toFixed(6)} ETH below minimum ${minBalance} ETH`,
  131. };
  132. }
  133. // Calculate gas costs
  134. const gasPrice = await web3.eth.getGasPrice();
  135. const estimatedGas = await web3.eth.estimateGas({
  136. from: sourceAddress,
  137. to: destinationAddress,
  138. value: "1", // Minimal value for estimation
  139. });
  140. const gasCostWei =
  141. BigInt(estimatedGas) * BigInt(gasPrice) * BigInt(gasMultiplier);
  142. const gasCostEth = Number(
  143. web3.utils.fromWei(gasCostWei.toString(), "ether"),
  144. );
  145. // Calculate transfer amount
  146. let transferAmountEth: number;
  147. if (transferAmount !== undefined) {
  148. transferAmountEth = transferAmount;
  149. } else {
  150. // transferRatio is guaranteed to be defined at this point
  151. if (transferRatio === undefined) {
  152. throw new Error(
  153. "Transfer ratio must be defined when amount is not specified",
  154. );
  155. }
  156. transferAmountEth = (balanceEth - gasCostEth) * transferRatio;
  157. }
  158. // Round to 10 decimal places to avoid Web3 conversion errors
  159. transferAmountEth = Math.round(transferAmountEth * 1e10) / 1e10;
  160. // Validate transfer amount
  161. if (transferAmountEth <= 0) {
  162. console.log(
  163. ` Not enough balance to cover transfer and gas costs, skipping`,
  164. );
  165. return {
  166. chain: chain.getId(),
  167. success: false,
  168. sourceAddress,
  169. destinationAddress,
  170. originalBalance: balanceEth.toFixed(6),
  171. transferAmount: "0",
  172. remainingBalance: balanceEth.toFixed(6),
  173. error: `Insufficient balance for transfer amount and gas costs (${gasCostEth.toFixed(6)} ETH)`,
  174. };
  175. }
  176. if (transferAmountEth + gasCostEth > balanceEth) {
  177. console.log(` Transfer amount plus gas costs exceed balance, skipping`);
  178. return {
  179. chain: chain.getId(),
  180. success: false,
  181. sourceAddress,
  182. destinationAddress,
  183. originalBalance: balanceEth.toFixed(6),
  184. transferAmount: "0",
  185. remainingBalance: balanceEth.toFixed(6),
  186. error: `Transfer amount ${transferAmountEth.toFixed(6)} ETH plus gas ${gasCostEth.toFixed(6)} ETH exceeds balance`,
  187. };
  188. }
  189. const transferAmountWei = web3.utils.toWei(
  190. transferAmountEth.toString(),
  191. "ether",
  192. );
  193. console.log(` Transfer amount: ${transferAmountEth.toFixed(6)} ETH`);
  194. console.log(` Estimated gas cost: ${gasCostEth.toFixed(6)} ETH`);
  195. console.log(` Destination: ${destinationAddress}`);
  196. if (dryRun) {
  197. console.log(
  198. ` DRY RUN: Would transfer ${transferAmountEth.toFixed(6)} ETH`,
  199. );
  200. return {
  201. chain: chain.getId(),
  202. success: true,
  203. sourceAddress,
  204. destinationAddress,
  205. originalBalance: balanceEth.toFixed(6),
  206. transferAmount: transferAmountEth.toFixed(6),
  207. remainingBalance: (balanceEth - transferAmountEth).toFixed(6),
  208. };
  209. }
  210. // Perform the transfer
  211. web3.eth.accounts.wallet.add(signer);
  212. console.log(` Executing transfer...`);
  213. const tx = await web3.eth.sendTransaction({
  214. from: sourceAddress,
  215. to: destinationAddress,
  216. value: transferAmountWei,
  217. gas: Number(estimatedGas) * gasMultiplier,
  218. gasPrice: gasPrice,
  219. });
  220. // Get updated balance
  221. const newBalanceWei = await web3.eth.getBalance(sourceAddress);
  222. const newBalanceEth = Number(web3.utils.fromWei(newBalanceWei, "ether"));
  223. console.log(` Transfer successful!`);
  224. console.log(` Transaction hash: ${tx.transactionHash}`);
  225. console.log(` New balance: ${newBalanceEth.toFixed(6)} ETH`);
  226. return {
  227. chain: chain.getId(),
  228. success: true,
  229. sourceAddress,
  230. destinationAddress,
  231. originalBalance: balanceEth.toFixed(6),
  232. transferAmount: transferAmountEth.toFixed(6),
  233. remainingBalance: newBalanceEth.toFixed(6),
  234. transactionHash: tx.transactionHash,
  235. };
  236. } catch (error) {
  237. console.log(` Transfer failed: ${error}`);
  238. return {
  239. chain: chain.getId(),
  240. success: false,
  241. sourceAddress,
  242. destinationAddress,
  243. originalBalance: "unknown",
  244. transferAmount: "0",
  245. remainingBalance: "unknown",
  246. error: error instanceof Error ? error.message : String(error),
  247. };
  248. }
  249. }
  250. function getSelectedChains(argv: {
  251. chain?: string[];
  252. testnets: boolean;
  253. mainnets: boolean;
  254. }): EvmChain[] {
  255. // Check for mutually exclusive options
  256. const optionCount =
  257. (argv.testnets ? 1 : 0) + (argv.mainnets ? 1 : 0) + (argv.chain ? 1 : 0);
  258. if (optionCount !== 1) {
  259. throw new Error(
  260. "Must specify exactly one of: --testnets, --mainnets, or --chain",
  261. );
  262. }
  263. // Get all entropy contract chains
  264. const allEntropyChains: EvmChain[] = [];
  265. for (const contract of Object.values(DefaultStore.entropy_contracts)) {
  266. const chain = contract.getChain();
  267. if (chain instanceof EvmChain) {
  268. allEntropyChains.push(chain);
  269. }
  270. }
  271. let selectedChains: EvmChain[];
  272. if (argv.testnets) {
  273. selectedChains = allEntropyChains.filter((chain) => !chain.isMainnet());
  274. } else if (argv.mainnets) {
  275. selectedChains = allEntropyChains.filter((chain) => chain.isMainnet());
  276. } else {
  277. // Specific chains
  278. const entropyChainIds = new Set(
  279. allEntropyChains.map((chain) => chain.getId()),
  280. );
  281. selectedChains = [];
  282. if (!argv.chain) {
  283. throw new Error(
  284. "Chain argument must be defined for specific chain selection",
  285. );
  286. }
  287. for (const chainId of argv.chain) {
  288. if (!entropyChainIds.has(chainId)) {
  289. throw new Error(
  290. `Chain ${chainId} does not have entropy contracts deployed`,
  291. );
  292. }
  293. const chain = DefaultStore.chains[chainId];
  294. if (!(chain instanceof EvmChain)) {
  295. throw new Error(`Chain ${chainId} is not an EVM chain`);
  296. }
  297. selectedChains.push(chain);
  298. }
  299. }
  300. if (selectedChains.length === 0) {
  301. const mode = argv.testnets
  302. ? "testnet"
  303. : argv.mainnets
  304. ? "mainnet"
  305. : "specified";
  306. throw new Error(`No valid ${mode} entropy chains found`);
  307. }
  308. return selectedChains;
  309. }
  310. async function main() {
  311. const argv = await parser.argv;
  312. // Validate inputs
  313. if (!Web3.utils.isAddress(argv.destinationAddress)) {
  314. throw new Error("Invalid destination address format");
  315. }
  316. // Validate transfer amount options
  317. if (argv.amount !== undefined && argv.ratio !== undefined) {
  318. throw new Error("Cannot specify both --amount and --ratio options");
  319. }
  320. if (argv.amount === undefined && argv.ratio === undefined) {
  321. throw new Error("Must specify either --amount or --ratio option");
  322. }
  323. if (argv.ratio !== undefined && (argv.ratio <= 0 || argv.ratio > 1)) {
  324. throw new Error(
  325. "Ratio must be between 0 and 1 (exclusive of 0, inclusive of 1)",
  326. );
  327. }
  328. if (argv.amount !== undefined && argv.amount <= 0) {
  329. throw new Error("Amount must be greater than 0");
  330. }
  331. const sourcePrivateKey = toPrivateKey(argv.sourcePrivateKey);
  332. const selectedChains = getSelectedChains(argv);
  333. // Determine transfer method for display
  334. let transferMethod: string;
  335. if (argv.amount !== undefined) {
  336. transferMethod = `${argv.amount} ETH (fixed amount)`;
  337. } else {
  338. if (argv.ratio === undefined) {
  339. throw new Error("Ratio must be defined when amount is not specified");
  340. }
  341. transferMethod = `${(argv.ratio * 100).toFixed(1)}% of available balance`;
  342. }
  343. console.log(`\nConfiguration:`);
  344. console.log(
  345. ` Network: ${argv.testnets ? "Testnet" : argv.mainnets ? "Mainnet" : "Specific chains"}`,
  346. );
  347. console.log(` Destination: ${argv.destinationAddress}`);
  348. console.log(` Transfer method: ${transferMethod}`);
  349. console.log(` Minimum balance: ${argv.minBalance} ETH`);
  350. console.log(` Gas multiplier: ${argv.gasMultiplier}x`);
  351. console.log(` Dry run: ${argv.dryRun ? "Yes" : "No"}`);
  352. console.log(` Chains: ${selectedChains.map((c) => c.getId()).join(", ")}`);
  353. if (argv.dryRun) {
  354. console.log(`\nRUNNING IN DRY-RUN MODE - NO TRANSACTIONS WILL BE EXECUTED`);
  355. }
  356. const results: TransferResult[] = [];
  357. // Process each chain
  358. for (const chain of selectedChains) {
  359. const result = await transferOnChain(
  360. chain,
  361. sourcePrivateKey,
  362. argv.destinationAddress,
  363. argv.minBalance,
  364. argv.gasMultiplier,
  365. argv.dryRun,
  366. argv.amount,
  367. argv.ratio,
  368. );
  369. results.push(result);
  370. }
  371. // Summary
  372. console.log("\nTRANSFER SUMMARY");
  373. console.log("==================");
  374. const successful = results.filter((r) => r.success);
  375. const failed = results.filter((r) => !r.success);
  376. console.log(`Successful transfers: ${successful.length}`);
  377. console.log(`Failed transfers: ${failed.length}`);
  378. console.log(
  379. `Total transferred: ${successful.reduce((sum, r) => sum + parseFloat(r.transferAmount), 0).toFixed(6)} ETH`,
  380. );
  381. if (successful.length > 0) {
  382. console.log("\nSuccessful Transfers:");
  383. console.table(
  384. successful.map((r) => ({
  385. Chain: r.chain,
  386. "Transfer Amount (ETH)": r.transferAmount,
  387. "TX Hash": r.transactionHash || "N/A (dry run)",
  388. "Remaining Balance (ETH)": r.remainingBalance,
  389. })),
  390. );
  391. }
  392. if (failed.length > 0) {
  393. console.log("\nFailed Transfers:");
  394. console.table(
  395. failed.map((r) => ({
  396. Chain: r.chain,
  397. "Original Balance (ETH)": r.originalBalance,
  398. Error: r.error,
  399. })),
  400. );
  401. }
  402. console.log("\nTransfer process completed!");
  403. }
  404. main().catch((error) => {
  405. console.error("Script failed:", error);
  406. process.exit(1);
  407. });