diff --git a/bin/index.ts b/bin/index.ts index f240d42..6991d24 100644 --- a/bin/index.ts +++ b/bin/index.ts @@ -10,145 +10,185 @@ import { deployCommand } from "../src/commands/deploy.js"; import { verifyCommand } from "../src/commands/verify.js"; import { ReadContract } from "../src/commands/contract.js"; import { bridgeCommand } from "../src/commands/bridge.js"; +import { batchTransferCommand } from "../src/commands/batchTransfer.js"; interface CommandOptions { - testnet?: boolean; - address?: string; - value?: string; - txid?: string; - abi?: string; - bytecode?: string; - args?: any; - json?: any; - name?: string; - decodedArgs?: any; + testnet?: boolean; + address?: string; + value?: string; + txid?: string; + abi?: string; + bytecode?: string; + args?: any; + json?: any; + name?: string; + decodedArgs?: any; + file?: string; + interactive?: boolean; } const orange = chalk.rgb(255, 165, 0); console.log( - orange( - figlet.textSync("Rootstock", { - font: "3D-ASCII", - horizontalLayout: "fitted", - verticalLayout: "fitted", - }) - ) + orange( + figlet.textSync("Rootstock", { + font: "3D-ASCII", + horizontalLayout: "fitted", + verticalLayout: "fitted", + }) + ) ); const program = new Command(); program - .name("rsk-cli") - .description("CLI tool for interacting with Rootstock blockchain") - .version("1.0.4", "-v, --version", "Display the current version"); + .name("rsk-cli") + .description("CLI tool for interacting with Rootstock blockchain") + .version("1.0.4", "-v, --version", "Display the current version"); program - .command("wallet") - .description( - "Manage your wallet: create a new one, use an existing wallet, or import a custom wallet" - ) - .action(async () => { - await walletCommand(); - }); + .command("wallet") + .description( + "Manage your wallet: create a new one, use an existing wallet, or import a custom wallet" + ) + .action(async () => { + await walletCommand(); + }); program - .command("balance") - .description("Check the balance of the saved wallet") - .option("-t, --testnet", "Check the balance on the testnet") - .action(async (options: CommandOptions) => { - await balanceCommand(!!options.testnet); - }); + .command("balance") + .description("Check the balance of the saved wallet") + .option("-t, --testnet", "Check the balance on the testnet") + .action(async (options: CommandOptions) => { + await balanceCommand(!!options.testnet); + }); program - .command("transfer") - .description("Transfer rBTC to the provided address") - .option("-t, --testnet", "Transfer on the testnet") - .requiredOption("-a, --address
", "Recipient address") - .requiredOption("-v, --value ", "Amount to transfer in rBTC") - .action(async (options: CommandOptions) => { - try { - const address = `0x${options.address!.replace( - /^0x/, - "" - )}` as `0x${string}`; - await transferCommand( - !!options.testnet, - address, - parseFloat(options.value!) - ); - } catch (error) { - console.error(chalk.red("Error during transfer:"), error); - } - }); + .command("transfer") + .description("Transfer rBTC to the provided address") + .option("-t, --testnet", "Transfer on the testnet") + .requiredOption("-a, --address
", "Recipient address") + .requiredOption("-v, --value ", "Amount to transfer in rBTC") + .action(async (options: CommandOptions) => { + try { + const address = `0x${options.address!.replace( + /^0x/, + "" + )}` as `0x${string}`; + await transferCommand( + !!options.testnet, + address, + parseFloat(options.value!) + ); + } catch (error) { + console.error(chalk.red("Error during transfer:"), error); + } + }); program - .command("tx") - .description("Check the status of a transaction") - .option("-t, --testnet", "Check the transaction status on the testnet") - .requiredOption("-i, --txid ", "Transaction ID") - .action(async (options: CommandOptions) => { - const formattedTxId = options.txid!.startsWith("0x") - ? options.txid - : `0x${options.txid}`; - - await txCommand(!!options.testnet, formattedTxId as `0x${string}`); - }); + .command("tx") + .description("Check the status of a transaction") + .option("-t, --testnet", "Check the transaction status on the testnet") + .requiredOption("-i, --txid ", "Transaction ID") + .action(async (options: CommandOptions) => { + const formattedTxId = options.txid!.startsWith("0x") + ? options.txid + : `0x${options.txid}`; + + await txCommand(!!options.testnet, formattedTxId as `0x${string}`); + }); + +program + .command("deploy") + .description("Deploy a contract") + .requiredOption("--abi ", "Path to the ABI file") + .requiredOption("--bytecode ", "Path to the bytecode file") + .option("--args ", "Constructor arguments (space-separated)") + .option("-t, --testnet", "Deploy on the testnet") + .action(async (options: CommandOptions) => { + const args = options.args || []; + await deployCommand( + options.abi!, + options.bytecode!, + !!options.testnet, + args + ); + }); program - .command("deploy") - .description("Deploy a contract") - .requiredOption("--abi ", "Path to the ABI file") - .requiredOption("--bytecode ", "Path to the bytecode file") - .option("--args ", "Constructor arguments (space-separated)") - .option("-t, --testnet", "Deploy on the testnet") - .action(async (options: CommandOptions) => { - const args = options.args || []; - await deployCommand( - options.abi!, - options.bytecode!, - !!options.testnet, - args - ); - }); + .command("verify") + .description("Verify a contract") + .requiredOption("--json ", "Path to the JSON Standard Input") + .requiredOption("--name ", "Name of the contract") + .requiredOption( + "-a, --address
", + "Address of the deployed contract" + ) + .option("-t, --testnet", "Deploy on the testnet") + .option( + "-da, --decodedArgs ", + "Decoded Constructor arguments (space-separated)" + ) + .action(async (options: CommandOptions) => { + const args = options.decodedArgs || []; + await verifyCommand( + options.json!, + options.address!, + options.name!, + !!options.testnet, + + args + ); + }); program - .command("verify") - .description("Verify a contract") - .requiredOption("--json ", "Path to the JSON Standard Input") - .requiredOption("--name ", "Name of the contract") - .requiredOption("-a, --address
", "Address of the deployed contract") - .option("-t, --testnet", "Deploy on the testnet") - .option( - "-da, --decodedArgs ", - "Decoded Constructor arguments (space-separated)" - ) - .action(async (options: CommandOptions) => { - const args = options.decodedArgs || []; - await verifyCommand( - options.json!, - options.address!, - options.name!, - !!options.testnet, - args - ); - }); + .command("contract") + .description("Interact with a contract") + .requiredOption("-a, --address
", "Address of a verified contract") + .option("-t, --testnet", "Deploy on the testnet") + .action(async (options: CommandOptions) => { + await ReadContract( + options.address! as `0x${string}`, + !!options.testnet + ); + }); program - .command("contract") - .description("Interact with a contract") - .requiredOption("-a, --address
", "Address of a verified contract") - .option("-t, --testnet", "Deploy on the testnet") - .action(async (options: CommandOptions) => { - await ReadContract(options.address! as `0x${string}`, !!options.testnet); - }); + .command("bridge") + .description("Interact with RSK bridge") + .option("-t, --testnet", "Deploy on the testnet") + .action(async (options: CommandOptions) => { + await bridgeCommand(!!options.testnet); + }); program - .command("bridge") - .description("Interact with RSK bridge") - .option("-t, --testnet", "Deploy on the testnet") - .action(async (options: CommandOptions) => { - await bridgeCommand(!!options.testnet); - }); + .command("batch-transfer") + .description("Execute batch transactions interactively or from stdin") + .option("-i, --interactive", "Execute interactively and input transactions") + .option("-t, --testnet", "Execute on the testnet") + .option("-f, --file ", "Execute transactions from a file") + .action(async (options) => { + try { + const interactive = !!options.interactive; + const testnet = !!options.testnet; + const file = options.file; + + if (interactive && file) { + console.error( + chalk.red( + "🚨 Cannot use both interactive mode and file input simultaneously." + ) + ); + return; + } + + await batchTransferCommand(file, testnet, interactive); + } catch (error: any) { + console.error( + chalk.red("🚨 Error during batch transfer:"), + chalk.yellow(error.message || "Unknown error") + ); + } + }); program.parse(process.argv); diff --git a/package.json b/package.json index e9962d9..ae2faaa 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "wallet": "pnpm run build && node dist/bin/index.js wallet", "balance": "pnpm run build && node dist/bin/index.js balance", "transfer": "pnpm run build && node dist/bin/index.js transfer --testnet --address 0xa5f45f5bddefC810C48aCC1D5CdA5e5a4c6BC59E --value 0.001", - "tx-status": "pnpm run build && node dist/bin/index.js tx --testnet --txid 0x876a0a9b167889350c41930a4204e5d9acf5704a7f201447a337094189af961c4" + "tx-status": "pnpm run build && node dist/bin/index.js tx --testnet --txid 0x876a0a9b167889350c41930a4204e5d9acf5704a7f201447a337094189af961c4", + "batch-transfer": "pnpm run build && node dist/bin/index.js batch-transfer" }, "keywords": [ "rootstock", diff --git a/src/commands/batchTransfer.ts b/src/commands/batchTransfer.ts new file mode 100644 index 0000000..18347fa --- /dev/null +++ b/src/commands/batchTransfer.ts @@ -0,0 +1,193 @@ +import fs from "fs"; +import chalk from "chalk"; +import ora from "ora"; +import readline from "readline"; +import { walletFilePath } from "../utils/constants.js"; +import ViemProvider from "../utils/viemProvider.js"; +import { Address } from "viem"; + +export async function batchTransferCommand( + filePath?: string, + testnet: boolean = false, + interactive: boolean = false +) { + try { + let batchData: { to: Address; value: number }[] = []; + + // Handle input mode + if (interactive) { + // Interactive input + batchData = await promptForTransactions(); + } else if (filePath) { + // File input + if (!fs.existsSync(filePath)) { + console.log( + chalk.red( + "🚫 Batch file not found. Please provide a valid file." + ) + ); + return; + } + const fileContent = JSON.parse(fs.readFileSync(filePath, "utf8")); + batchData = fileContent.transactions.map((tx: any) => ({ + to: validateAddress(tx.address), + value: tx.amount, + })); + } else { + // JSON input via stdin + const stdin = await readStdin(); + if (stdin) { + const jsonData = JSON.parse(stdin); + if (!Array.isArray(jsonData.transactions)) { + console.log( + chalk.red("🚫 Invalid JSON format for transactions.") + ); + return; + } + batchData = jsonData.transactions.map((tx: any) => ({ + to: validateAddress(tx.address), + value: tx.amount, + })); + } + } + + if (batchData.length === 0) { + console.log(chalk.red("⚠️ No transactions provided. Exiting...")); + return; + } + + // Initialize wallet provider + const provider = new ViemProvider(testnet); + const walletClient = await provider.getWalletClient(); + const account = walletClient.account; + + if (!account) { + console.log( + chalk.red("🚫 Failed to retrieve wallet account. Exiting...") + ); + return; + } + + // Fetch balance + const publicClient = await provider.getPublicClient(); + const balance = await publicClient.getBalance({ + address: account.address, + }); + const rbtcBalance = Number(balance) / 10 ** 18; + + console.log( + chalk.white(`📄 Wallet Address:`), + chalk.green(account.address) + ); + console.log( + chalk.white(`💰 Current Balance:`), + chalk.green(`${rbtcBalance} RBTC`) + ); + + // Process transactions + for (const { to, value } of batchData) { + if (rbtcBalance < value) { + console.log( + chalk.red( + `🚫 Insufficient balance to transfer ${value} RBTC.` + ) + ); + break; + } + + const txHash = await walletClient.sendTransaction({ + account, + chain: provider.chain, + to, + value: BigInt(Math.floor(value * 10 ** 18)), + }); + + console.log( + chalk.white(`🔄 Transaction initiated. TxHash:`), + chalk.green(txHash) + ); + + const spinner = ora("⏳ Waiting for confirmation...").start(); + + const receipt = await publicClient.waitForTransactionReceipt({ + hash: txHash, + }); + spinner.stop(); + + if (receipt.status === "success") { + console.log( + chalk.green("✅ Transaction confirmed successfully!") + ); + console.log( + chalk.white(`📦 Block Number:`), + chalk.green(receipt.blockNumber) + ); + console.log( + chalk.white(`⛽ Gas Used:`), + chalk.green(receipt.gasUsed.toString()) + ); + } else { + console.log(chalk.red("❌ Transaction failed.")); + } + } + } catch (error: any) { + console.error( + chalk.red("🚨 Error during batch transfer:"), + chalk.yellow(error.message || "Unknown error") + ); + } +} + +async function promptForTransactions() { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const transactions: { to: Address; value: number }[] = []; + + while (true) { + const to = validateAddress(await askQuestion(rl, "Enter address: ")); + const value = parseFloat(await askQuestion(rl, "Enter amount: ")); + + if (isNaN(value)) { + console.log(chalk.red("⚠️ Invalid amount. Please try again.")); + continue; + } + + transactions.push({ to, value }); + + const addAnother = await askQuestion( + rl, + "Add another transaction? (y/n): " + ); + if (addAnother.toLowerCase() !== "y") break; + } + + rl.close(); + return transactions; +} + +function validateAddress(address: string): Address { + if (!/^0x[a-fA-F0-9]{40}$/.test(address)) { + throw new Error(`Invalid Ethereum address: ${address}`); + } + return address as Address; +} + +async function readStdin(): Promise { + return new Promise((resolve, reject) => { + let data = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => (data += chunk)); + process.stdin.on("end", () => resolve(data)); + process.stdin.on("error", reject); + }); +} + +function askQuestion( + rl: readline.Interface, + question: string +): Promise { + return new Promise((resolve) => rl.question(question, resolve)); +}