transfer_balance_entropy_chains.ts 14 KB

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