@rsksmart/rsk-cli
Version:
CLI tool for Rootstock network using Viem
403 lines (402 loc) • 18.5 kB
JavaScript
import ViemProvider from "../utils/viemProvider.js";
import chalk from "chalk";
import ora from "ora";
import fs from "fs";
import inquirer from "inquirer";
import { parseEther, formatEther, getAddress } from "viem";
import { walletFilePath } from "../utils/constants.js";
import { getTokenInfo, isERC20Contract } from "../utils/tokenHelper.js";
import { getConfig } from "./config.js";
function logMessage(params, message, color = chalk.white) {
if (!params.isExternal) {
console.log(color(message));
}
}
function logError(params, message) {
logMessage(params, `❌ ${message}`, chalk.red);
}
function logSuccess(params, message) {
logMessage(params, `✅ ${message}`, chalk.green);
}
function logWarning(params, message) {
logMessage(params, `⚠️ ${message}`, chalk.yellow);
}
function logInfo(params, message) {
logMessage(params, `📊 ${message}`, chalk.blue);
}
export async function transactionCommand(testnet, toAddress, value, name, tokenAddress, options, isExternal) {
const config = getConfig();
const isTestnet = testnet !== undefined ? testnet : (config.defaultNetwork === 'testnet');
const params = { isExternal };
logInfo(params, `Network: ${isTestnet ? 'Testnet' : 'Mainnet'}`);
try {
if (!fs.existsSync(walletFilePath)) {
logError(params, "No saved wallet found. Please create a wallet first.");
return;
}
const walletsData = JSON.parse(fs.readFileSync(walletFilePath, "utf8"));
if (!walletsData.currentWallet || !walletsData.wallets) {
logError(params, "No valid wallet found. Please create or import a wallet first.");
return;
}
const { currentWallet, wallets } = walletsData;
let wallet = name ? wallets[name] : wallets[currentWallet];
if (!wallet) {
logError(params, "Wallet not found.");
return;
}
const provider = new ViemProvider(isTestnet);
const publicClient = await provider.getPublicClient();
const walletClient = await provider.getWalletClient(name);
const account = walletClient.account;
if (!account) {
logError(params, "Failed to retrieve the account.");
return;
}
if (!toAddress || !value) {
const transactionType = await promptTransactionType();
const txDetails = await promptTransactionDetails(transactionType, publicClient, wallet.address);
toAddress = txDetails.to;
value = txDetails.value;
tokenAddress = txDetails.tokenAddress;
options = txDetails.options;
}
if (!toAddress || value === undefined) {
throw new Error("Recipient address and value are required");
}
try {
toAddress = getAddress(toAddress);
}
catch (error) {
logError(params, `Invalid recipient address: ${toAddress}. Please provide a valid Ethereum address.`);
throw new Error(`Invalid recipient address: ${toAddress}. Please provide a valid Ethereum address.`);
}
if (tokenAddress) {
try {
tokenAddress = getAddress(tokenAddress);
}
catch (error) {
logError(params, `Invalid token address: ${tokenAddress}. Please provide a valid Ethereum address.`);
throw new Error(`Invalid token address: ${tokenAddress}. Please provide a valid Ethereum address.`);
}
}
const fixedValue = typeof value === 'number' ? value.toFixed(18) : String(value);
const numericValue = parseFloat(fixedValue);
if (numericValue <= 0) {
throw new Error("Transaction value must be greater than 0");
}
const stringValue = numericValue.toFixed(18);
const gasPrice = await publicClient.getGasPrice();
const formattedGasPrice = formatEther(gasPrice);
logInfo(params, `Current Gas Price: ${formattedGasPrice} RBTC`);
logInfo(params, `Checking balance for address: ${wallet.address}`);
const balance = await publicClient.getBalance({ address: wallet.address });
const balanceInRBTC = formatEther(balance);
logInfo(params, `Wallet Balance: ${balanceInRBTC} RBTC`);
logInfo(params, `Network RPC: ${isTestnet ? 'Rootstock Testnet' : 'Rootstock Mainnet'}`);
if (balance === 0n) {
logError(params, "Wallet balance is 0 RBTC.");
logWarning(params, "You need to fund your wallet first.");
logInfo(params, `Your wallet address: ${wallet.address}`);
logInfo(params, "Options to get RBTC:");
logInfo(params, " 1. Use a faucet: https://faucet.rootstock.io/");
logInfo(params, " 2. Buy RBTC from an exchange");
logInfo(params, " 3. Receive RBTC from another wallet");
logInfo(params, " 4. Check your balance with: rsk-cli balance");
return;
}
if (balance < parseEther(stringValue)) {
logError(params, "Insufficient balance for this transaction.");
logWarning(params, `Required: ${numericValue} RBTC, Available: ${balanceInRBTC} RBTC`);
logWarning(params, `You need ${(numericValue - parseFloat(balanceInRBTC)).toFixed(6)} more RBTC`);
return;
}
if (tokenAddress) {
await handleTokenTransfer(publicClient, walletClient, account, tokenAddress, toAddress, parseFloat(stringValue), wallet.address, isTestnet, options, params);
}
else {
await handleRBTCTransfer(publicClient, walletClient, account, toAddress, parseFloat(stringValue), wallet.address, isTestnet, options, balance, params);
}
}
catch (error) {
logError(params, "Error during transaction, please check the transaction details.");
logError(params, `Error details: ${error.message || error}`);
if (error.message && error.message.includes('insufficient funds')) {
logWarning(params, 'Tip: Your wallet balance is insufficient. Check your balance with: rsk-cli balance');
}
else if (error.message && error.message.includes('gas')) {
logWarning(params, 'Tip: Gas estimation failed. Try with a higher gas limit or gas price.');
}
else if (error.message && error.message.includes('network')) {
logWarning(params, 'Tip: Network connection issue. Check your internet connection and try again.');
}
else if (error.message && error.message.includes('wallet')) {
logWarning(params, 'Tip: Wallet issue. Try creating a new wallet or importing an existing one.');
}
else if (error.message && error.message.includes('balance')) {
logWarning(params, 'Tip: Your wallet has insufficient balance. You need to fund your wallet first.');
logInfo(params, 'Get RBTC from: https://faucet.rootstock.io/');
}
}
}
async function promptTransactionType() {
const { type } = await inquirer.prompt([
{
type: 'list',
name: 'type',
message: '📝 What type of transaction would you like to create?',
choices: [
{ name: 'Simple Transfer (RBTC or Token)', value: 'simple' },
{ name: 'Advanced Transfer (with custom gas settings)', value: 'advanced' },
{ name: 'Raw Transaction (with custom data)', value: 'raw' }
]
}
]);
return type;
}
async function promptTransactionDetails(type, publicClient, fromAddress) {
const details = {};
const common = await inquirer.prompt([
{
type: 'input',
name: 'to',
message: '🎯 Enter recipient address:',
validate: (input) => input.startsWith('0x') && input.length === 42
},
{
type: 'confirm',
name: 'isToken',
message: '🪙 Is this a token transfer?',
default: false
}
]);
details.to = common.to;
if (common.isToken) {
const tokenDetails = await inquirer.prompt([
{
type: 'input',
name: 'tokenAddress',
message: '📝 Enter token contract address:',
validate: async (input) => {
if (!input.startsWith('0x') || input.length !== 42)
return false;
return await isERC20Contract(publicClient, input);
}
}
]);
details.tokenAddress = tokenDetails.tokenAddress;
const { decimals } = await getTokenInfo(publicClient, details.tokenAddress, fromAddress);
const { value } = await inquirer.prompt([
{
type: 'input',
name: 'value',
message: '💰 Enter amount to transfer:',
validate: (input) => !isNaN(parseFloat(input)) && parseFloat(input) > 0
}
]);
details.value = parseFloat(value);
}
else {
const { value } = await inquirer.prompt([
{
type: 'input',
name: 'value',
message: '💰 Enter amount in RBTC:',
validate: (input) => !isNaN(parseFloat(input)) && parseFloat(input) > 0
}
]);
details.value = parseFloat(value);
}
if (type === 'advanced' || type === 'raw') {
const config = getConfig();
const advanced = await inquirer.prompt([
{
type: 'input',
name: 'gasLimit',
message: '⛽ Enter gas limit (optional):',
default: config.defaultGasLimit.toString()
},
{
type: 'input',
name: 'maxFeePerGas',
message: '💰 Enter max fee per gas in RBTC (optional):',
default: config.defaultGasPrice > 0 ? config.defaultGasPrice.toString() : ''
},
{
type: 'input',
name: 'maxPriorityFeePerGas',
message: '💰 Enter max priority fee per gas in RBTC (optional):',
default: ''
}
]);
details.options = {
...(advanced.gasLimit && { gasLimit: BigInt(advanced.gasLimit) }),
...(advanced.maxFeePerGas && { maxFeePerGas: parseEther(advanced.maxFeePerGas.toString()) }),
...(advanced.maxPriorityFeePerGas && { maxPriorityFeePerGas: parseEther(advanced.maxPriorityFeePerGas.toString()) })
};
if (type === 'raw') {
const { data } = await inquirer.prompt([
{
type: 'input',
name: 'data',
message: '📝 Enter transaction data (hex):',
validate: (input) => input.startsWith('0x')
}
]);
details.options.data = data;
}
}
return details;
}
async function handleTokenTransfer(publicClient, walletClient, account, tokenAddress, toAddress, value, fromAddress, testnet, options, params) {
const tokenInfo = await getTokenInfo(publicClient, tokenAddress, fromAddress);
const defaultParams = params || { isExternal: false };
logInfo(defaultParams, 'Token Transfer Details:');
logInfo(defaultParams, `Token: ${tokenInfo.name} (${tokenInfo.symbol})`);
logInfo(defaultParams, `Contract: ${tokenAddress}`);
logInfo(defaultParams, `From: ${fromAddress}`);
logInfo(defaultParams, `To: ${toAddress}`);
logInfo(defaultParams, `Amount: ${value} ${tokenInfo.symbol}`);
const spinner = ora('⏳ Simulating token transfer...').start();
try {
const { request } = await publicClient.simulateContract({
account,
address: tokenAddress,
abi: [{
name: 'transfer',
type: 'function',
stateMutability: 'nonpayable',
inputs: [
{ name: 'recipient', type: 'address' },
{ name: 'amount', type: 'uint256' }
],
outputs: [{ type: 'bool' }]
}],
functionName: 'transfer',
args: [toAddress, BigInt(value * (10 ** tokenInfo.decimals))],
...options
});
spinner.succeed('✅ Simulation successful');
const txHash = await walletClient.writeContract(request);
await handleTransactionReceipt(publicClient, txHash, testnet, defaultParams);
}
catch (error) {
spinner.fail('❌ Transaction failed');
logError(defaultParams, `Error details: ${error.message}`);
if (error.message.includes('insufficient funds')) {
logWarning(defaultParams, 'Tip: Check your RBTC balance for gas fees.');
}
else if (error.message.includes('insufficient token balance')) {
logWarning(defaultParams, 'Tip: Check your token balance. You might not have enough tokens to transfer.');
}
else if (error.message.includes('gas')) {
logWarning(defaultParams, 'Tip: Try increasing the gas limit or gas price.');
}
else if (error.message.includes('allowance')) {
logWarning(defaultParams, 'Tip: You might need to approve the token spending first.');
}
throw error;
}
}
async function handleRBTCTransfer(publicClient, walletClient, account, toAddress, value, fromAddress, testnet, options, balance, params) {
const defaultParams = params || { isExternal: false };
logInfo(defaultParams, 'RBTC Transfer Details:');
logInfo(defaultParams, `From: ${fromAddress}`);
logInfo(defaultParams, `To: ${toAddress}`);
logInfo(defaultParams, `Amount: ${value.toFixed(18)} RBTC`);
if (value < 0.000001) {
logWarning(defaultParams, 'Very small amount detected. This might fail due to gas costs.');
}
const spinner = ora('⏳ Preparing transaction...').start();
try {
const gasPrice = await publicClient.getGasPrice();
const gasEstimate = await publicClient.estimateGas({
account,
to: toAddress,
value: parseEther(value.toFixed(18)),
});
logInfo(defaultParams, `Estimated Gas: ${gasEstimate}`);
logInfo(defaultParams, `Gas Price: ${formatEther(gasPrice)} RBTC`);
const finalGasPrice = options?.gasPrice || gasPrice;
const gasPriceInRBTC = formatEther(finalGasPrice);
const gasPriceInGwei = Number(gasPriceInRBTC) * 1e9;
if (gasPriceInGwei > 1000) {
logWarning(defaultParams, `Gas price is very high: ${gasPriceInGwei.toFixed(2)} gwei`);
logWarning(defaultParams, `Consider using a lower gas price for better cost efficiency`);
}
const totalGasCost = BigInt(gasEstimate) * finalGasPrice;
const valueInWei = parseEther(value.toFixed(18));
const totalCost = totalGasCost + valueInWei;
const totalCostInRBTC = formatEther(totalCost);
logInfo(defaultParams, `Total Transaction Cost: ${totalCostInRBTC} RBTC`);
logInfo(defaultParams, `Gas Cost: ${formatEther(totalGasCost)} RBTC`);
logInfo(defaultParams, `Value: ${value.toFixed(18)} RBTC`);
if (balance && totalCost > balance) {
logError(defaultParams, `Transaction cost (${totalCostInRBTC} RBTC) exceeds wallet balance (${formatEther(balance)} RBTC)`);
logWarning(defaultParams, `You need ${formatEther(totalCost - balance)} more RBTC to complete this transaction`);
return;
}
const txHash = await walletClient.sendTransaction({
account,
to: toAddress,
value: parseEther(value.toFixed(18)),
gas: gasEstimate,
gasPrice: options?.gasPrice || gasPrice,
...(options?.gasLimit && { gas: BigInt(options.gasLimit) }),
...(options?.data && { data: options.data })
});
spinner.succeed('✅ Transaction sent');
await handleTransactionReceipt(publicClient, txHash, testnet, defaultParams);
}
catch (error) {
spinner.fail('❌ Transaction failed');
logError(defaultParams, `Error details: ${error.message}`);
if (error.message.includes('insufficient funds')) {
logWarning(defaultParams, 'Tip: Check your wallet balance. You need enough RBTC for the transaction amount plus gas fees.');
}
else if (error.message.includes('gas')) {
logWarning(defaultParams, 'Tip: Try increasing the gas limit or gas price.');
}
else if (error.message.includes('value')) {
logWarning(defaultParams, 'Tip: The transaction amount might be too small. Try a larger amount.');
}
throw error;
}
}
async function handleTransactionReceipt(publicClient, txHash, testnet, params) {
const spinner = ora('⏳ Waiting for confirmation...').start();
const config = getConfig();
const defaultParams = params || { isExternal: false };
try {
const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash });
spinner.stop();
if (receipt.status === 'success') {
logSuccess(defaultParams, 'Transaction confirmed successfully!');
if (config.displayPreferences.showBlockDetails) {
logInfo(defaultParams, `Block Number: ${receipt.blockNumber}`);
}
if (config.displayPreferences.showGasDetails) {
logInfo(defaultParams, `Gas Used: ${receipt.gasUsed}`);
}
if (config.displayPreferences.showExplorerLinks) {
const explorerUrl = testnet
? `https://explorer.testnet.rootstock.io/tx/${txHash}`
: `https://explorer.rootstock.io/tx/${txHash}`;
logInfo(defaultParams, `View on Explorer: ${explorerUrl}`);
}
if (config.displayPreferences.compactMode) {
logInfo(defaultParams, `Tx: ${txHash}`);
}
else {
logInfo(defaultParams, `Transaction Hash: ${txHash}`);
}
}
else {
logError(defaultParams, 'Transaction failed');
}
}
catch (error) {
spinner.fail('❌ Transaction confirmation failed');
throw error;
}
}