diff --git a/README.md b/README.md index e5a44b8..cb8ff9f 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A CLI tool to interact with Monad's staking contract and execute operations by i - Compounding rewards - Changing Validator commission - Querying staking state on the chain +- Transferring funds between addresses ## Security Notes @@ -125,12 +126,12 @@ $ source cli-venv/bin/activate $ python staking-cli/main.py --help usage: main.py [-h] - {add-validator,delegate,undelegate,withdraw,claim-rewards,compound-rewards,change-commission,query,tui} ... + {add-validator,delegate,undelegate,withdraw,claim-rewards,compound-rewards,change-commission,transfer,query,tui} ... Staking CLI for Validators on Monad positional arguments: - {add-validator,delegate,undelegate,withdraw,claim-rewards,compound-rewards,change-commission,query,tui} + {add-validator,delegate,undelegate,withdraw,claim-rewards,compound-rewards,change-commission,transfer,query,tui} add-validator Add a new validator to network delegate Delegate to a validator in the network undelegate Undelegate Stake from validator @@ -138,6 +139,7 @@ positional arguments: claim-rewards Claim staking rewards compound-rewards Compound rewards to validator change-commission Change validator commission + transfer Transfer MON to an address query Query network information tui Use a menu-driven TUI @@ -170,8 +172,9 @@ $ python staking-cli/main.py tui │ │ │ 8. Query │ │ │ -│ 9. Exit │ +│ 9. Transfer │ │ │ +│ 10. Exit │ │ │ ╰──────────────────────────────────────╯ ``` diff --git a/docs/command-reference.md b/docs/command-reference.md index 5be6fe6..fafd4cd 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -116,6 +116,18 @@ INFO Commission successfully changed from 10.0% to 5.0% for validator 1 **Note:** Only the Validator's authorized address can change the commission. +### Transfer MON + +Transfer MON from the validator's account to a specified address. + +```sh +python main.py transfer \ +--address 0x123... \ +--amount 1000 \ +--config-path ~/config.toml \ +--force # optional: skip confirmation +``` + ## Query Commands ### Query Validator Information diff --git a/staking-cli/main.py b/staking-cli/main.py index 553cea6..e912f3e 100644 --- a/staking-cli/main.py +++ b/staking-cli/main.py @@ -18,6 +18,7 @@ from src.parser import init_parser from src.helpers import number_prompt, confirmation_prompt from src.signer import create_signer +from src.transfer import transfer, transfer_cli class StakingCLI: def __init__(self): @@ -91,7 +92,8 @@ def tui(self): [{self.colors["primary_text"]}]6. Compound[/]\n [{self.colors["primary_text"]}]7. Change Commission[/]\n [{self.colors["primary_text"]}]8. Query[/]\n - [{self.colors["primary_text"]}]9. Exit[/]\n + [{self.colors["primary_text"]}]9. Transfer[/]\n + [{self.colors["primary_text"]}]10. Exit[/]\n ''' menu_text = Align(menu_text, align="left") main_panel = Panel( @@ -101,7 +103,7 @@ def tui(self): expand=False ) while True: - choices = [str(x) for x in range(1,10)] + choices = [str(x) for x in range(1,11)] self.console.print(main_panel) choice = number_prompt("Enter a number as a choice", choices, default="9") @@ -130,6 +132,9 @@ def tui(self): query(self.config, self.signer) self.log.info("Exited Query Menu\n\n") elif choice == "9": + transfer(self.config, self.signer) + self.log.info("Exited Transfer\n\n") + elif choice == "10": self.log.info("Staking CLI has been exited!") sys.exit() @@ -172,6 +177,8 @@ def main(self): change_validator_commission_cli(self.config, self.signer, validator_id, commission_percentage) elif self.args.command == "query": query_cli(self.config, self.args) + elif self.args.command == "transfer": + transfer_cli(self.config, self.signer, self.args) if __name__ == "__main__": diff --git a/staking-cli/src/parser.py b/staking-cli/src/parser.py index 6e73cd6..6923d36 100644 --- a/staking-cli/src/parser.py +++ b/staking-cli/src/parser.py @@ -28,6 +28,9 @@ def init_parser() -> argparse.ArgumentParser: change_commission_parser = subparsers.add_parser( "change-commission", help="Change validator commission" ) + transfer_parser = subparsers.add_parser( + "transfer", help="Transfer MON to an address" + ) query_parser = subparsers.add_parser("query", help="Query network information") tui_parser = subparsers.add_parser("tui", help="Use a menu-driven TUI") @@ -185,6 +188,31 @@ def init_parser() -> argparse.ArgumentParser: help="Add a path to a config.toml file", ) + # transfer_parser + transfer_parser.add_argument( + "--address", + type=str, + required=True, + help="Recipient address", + ) + transfer_parser.add_argument( + "--amount", + type=int, + required=True, + help="Amount in MON to transfer", + ) + transfer_parser.add_argument( + "--force", + action="store_true", + help="Skip confirmation", + ) + transfer_parser.add_argument( + "--config-path", + type=str, + default="./config.toml", + help="Add a path to a config.toml file", + ) + # query_parser query_subparser = query_parser.add_subparsers(dest="query") val_info_parser = query_subparser.add_parser( diff --git a/staking-cli/src/transfer.py b/staking-cli/src/transfer.py new file mode 100644 index 0000000..17ec311 --- /dev/null +++ b/staking-cli/src/transfer.py @@ -0,0 +1,140 @@ + +from rich.console import Console +from rich.table import Table +from src.helpers import ( + address_prompt, + amount_prompt, + confirmation_prompt, + send_transaction, + wei, +) +from src.logger import init_logging +from web3 import Web3 + +from staking_sdk_py.signer_factory import Signer + +console = Console() + + +def transfer(config: dict, signer: Signer): + # read config + rpc_url = config["rpc_url"] + chain_id = config["chain_id"] + + w3 = Web3(Web3.HTTPProvider(rpc_url)) + from_address = signer.get_address() + + # Parameters for transfer + to_address = address_prompt(config, "recipient") + transfer_amount = amount_prompt(config, description="to transfer") + amount = wei(transfer_amount) + + # Check balance + balance = w3.eth.get_balance(from_address) + if balance < amount: + console.print("[bold red]Insufficient balance![/]") + return + + table = Table( + show_header=False, + title="Transfer Script Inputs", + title_style="red bold", + expand=True, + show_lines=True, + ) + table.add_column("Inputs") + table.add_column("Values") + table.add_row("[cyan][bold red]Recipient Address[/]:[/]", f"[green]{to_address}[/]") + table.add_row( + "[cyan][bold red]Amount to Transfer[/]:[/]", + f"[green]{transfer_amount} MON ({amount} wei)[/]", + ) + table.add_row("[cyan][bold red]From Address[/]:[/]", f"[green]{from_address}[/]") + table.add_row("[cyan][bold red]RPC[/]:[/]", f"[green]{rpc_url}[/]") + console.print(table) + + is_confirmed = confirmation_prompt( + "Do the inputs above look correct?", default=False + ) + + if is_confirmed: + # Generate transaction + try: + tx_hash = send_transaction(w3, signer, to_address, "0x", chain_id, amount) + receipt = w3.eth.wait_for_transaction_receipt(tx_hash) + except Exception as e: + console.print(f"Error! while trying to send tx: {e}") + return + + # Create formatted transaction summary + tx_table = Table(title="Transaction Results", show_header=False, expand=True) + tx_table.add_column("Field", style="cyan") + tx_table.add_column("Value", style="green") + tx_table.add_row("Status", "✅ Success" if receipt.status == 1 else "❌ Failed") + tx_table.add_row("Transaction Hash", "0x" + receipt.transactionHash.hex()) + tx_table.add_row("Block Number", str(receipt.blockNumber)) + tx_table.add_row("Gas Used", f"{receipt.gasUsed:,}") + tx_table.add_row("From", receipt["from"]) + tx_table.add_row("To", receipt.to) + console.print(tx_table) + + +def transfer_cli(config: dict, signer: Signer, args): + log = init_logging(config["log_level"]) + # read config + rpc_url = config["rpc_url"] + chain_id = config["chain_id"] + + w3 = Web3(Web3.HTTPProvider(rpc_url)) + from_address = signer.get_address() + + to_address = args.address + amount_mon = args.amount + force = args.force + + # Validate address + if not Web3.is_address(to_address): + log.error("Invalid recipient address") + return + to_address = Web3.to_checksum_address(to_address) + + # Get balance + balance_wei = w3.eth.get_balance(from_address) + + amount_wei = wei(amount_mon) + + # Check sufficient balance + if balance_wei < amount_wei: + log.error( + f"Insufficient balance. Have {balance_wei / 10**18:.2f} MON, need {amount_mon:.2f} MON" + ) + return + + # Confirmation + if not force: + console = Console() + table = Table( + show_header=False, + title="Transfer Details", + expand=True, + ) + table.add_column("Field") + table.add_column("Value") + table.add_row("Recipient Address", to_address) + table.add_row("Amount", f"{amount_mon:.2f} MON ({amount_wei} wei)") + table.add_row("From Address", from_address) + console.print(table) + is_confirmed = confirmation_prompt("Confirm transfer?", default=False) + if not is_confirmed: + log.info("Transfer cancelled") + return + + # Send transaction + try: + tx_hash = send_transaction(w3, signer, to_address, "0x", chain_id, amount_wei) + receipt = w3.eth.wait_for_transaction_receipt(tx_hash) + except Exception as e: + log.error(f"Error while sending tx: {e}") + return + + log.info(f"Tx hash: 0x{receipt.transactionHash.hex()}")