diff --git a/contracts/rust/adapter/src/evm.rs b/contracts/rust/adapter/src/evm.rs index a99d68a5339..0f946e9892d 100644 --- a/contracts/rust/adapter/src/evm.rs +++ b/contracts/rust/adapter/src/evm.rs @@ -1,22 +1,18 @@ -use alloy::{network::Ethereum, providers::PendingTransactionBuilder, sol_types::SolInterface}; +use alloy::{ + sol_types::SolInterface, + transports::{RpcError, TransportErrorKind}, +}; -pub trait DecodeRevert { - fn maybe_decode_revert( - self, - ) -> anyhow::Result>; +pub trait DecodeRevert { + fn maybe_decode_revert(self) -> anyhow::Result; } -impl DecodeRevert - for alloy::contract::Result, alloy::contract::Error> -{ - fn maybe_decode_revert( - self, - ) -> anyhow::Result> { +impl DecodeRevert for Result { + fn maybe_decode_revert(self) -> anyhow::Result { match self { Ok(ret) => Ok(ret), Err(err) => { - let decoded = err.as_decoded_interface_error::(); - let msg = match decoded { + let msg = match err.as_decoded_interface_error::() { Some(e) => format!("{e:?}"), None => format!("{err:?}"), }; @@ -26,18 +22,33 @@ impl DecodeRevert } } +impl DecodeRevert for Result> { + fn maybe_decode_revert(self) -> anyhow::Result { + match self { + Ok(ret) => Ok(ret), + Err(RpcError::ErrorResp(payload)) => match payload.as_decoded_interface_error::() { + Some(e) => Err(anyhow::anyhow!("{e:?}")), + None => Err(anyhow::anyhow!("{payload}")), + }, + Err(err) => Err(anyhow::anyhow!("{err:?}")), + } + } +} + #[cfg(test)] mod test { use alloy::{ primitives::{Address, U256}, - providers::ProviderBuilder, + providers::{Provider, ProviderBuilder}, + rpc::types::{TransactionInput, TransactionRequest}, + sol_types::SolCall, }; use super::*; - use crate::sol_types::EspToken::{self, EspTokenErrors}; + use crate::sol_types::EspToken::{self, transferCall, EspTokenErrors}; #[tokio::test] - async fn test_decode_revert_error() -> anyhow::Result<()> { + async fn test_decode_revert_contract_error() -> anyhow::Result<()> { let provider = ProviderBuilder::new().connect_anvil_with_wallet(); let token = EspToken::deploy(&provider).await?; @@ -51,4 +62,27 @@ mod test { Ok(()) } + + #[tokio::test] + async fn test_decode_revert_rpc_error() -> anyhow::Result<()> { + let provider = ProviderBuilder::new().connect_anvil_with_wallet(); + + let token = EspToken::deploy(&provider).await?; + let call = transferCall { + to: Address::random(), + value: U256::MAX, + }; + let tx = TransactionRequest::default() + .to(*token.address()) + .input(TransactionInput::new(call.abi_encode().into())); + + let err = provider + .send_transaction(tx) + .await + .maybe_decode_revert::() + .unwrap_err(); + assert!(err.to_string().contains("ERC20InsufficientBalance")); + + Ok(()) + } } diff --git a/staking-cli/README.md b/staking-cli/README.md index 17efd6f379a..ecf031ab7d0 100644 --- a/staking-cli/README.md +++ b/staking-cli/README.md @@ -11,10 +11,11 @@ This CLI helps users interact with the Espresso staking contract, either as a de - [Espresso staking CLI](#espresso-staking-cli) - [Getting Started](#getting-started) - [Getting Help](#getting-help) - - [Choose your type of wallet (mnemonic based or Ledger)](#choose-your-type-of-wallet-mnemonic-based-or-ledger) + - [Choose your type of wallet (mnemonic, private key, or Ledger)](#choose-your-type-of-wallet-mnemonic-private-key-or-ledger) - [Initialize the configuration file](#initialize-the-configuration-file) - [Inspect the configuration](#inspect-the-configuration) - [View the stake table](#view-the-stake-table) + - [Calldata Export (for Multisig Wallets)](#calldata-export-for-multisig-wallets) - [Delegators (or stakers)](#delegators-or-stakers) - [Delegating](#delegating) - [Undelegating](#undelegating) @@ -99,6 +100,11 @@ Options: [env: STAKE_TABLE_ADDRESS=] + --espresso-url [] + Espresso sequencer API URL for reward claims + + [env: ESPRESSO_URL=] + --mnemonic The mnemonic to use when deriving the key @@ -116,6 +122,34 @@ Options: [env: USE_LEDGER=] + --private-key + Raw private key (hex-encoded with or without 0x prefix) + + [env: PRIVATE_KEY=] + + --export-calldata + Export calldata for multisig wallets instead of sending transaction + + [env: EXPORT_CALLDATA=] + + --sender-address + Sender address for calldata export (required for simulation) + + [env: SENDER_ADDRESS=] + + --skip-simulation + Skip eth_call validation when exporting calldata + + [env: SKIP_SIMULATION=] + + --output + Output file path. If not specified, outputs to stdout + + --format + Output format for calldata export + + [possible values: json, toml] + ``` or by passing `--help` to a command, for example `delegate`: @@ -135,49 +169,72 @@ Options: -h, --help Print help ``` -### Choose your type of wallet (mnemonic based or Ledger) +### Choose your type of wallet (mnemonic, private key, or Ledger) -First, determine if you would like to use a Mnemonic phrase or ledger hardware wallet. +**Security** Utmost care must be taken to avoid leaking the Ethereum private key used for staking or registering +validators. There is currently no built-in key rotation feature for Ethereum keys. -If you don't know which account index to use, you can find it by running: +First, determine which signing method you would like to use: + +1. **Ledger hardware wallet** - (recommended) sign transactions with a Ledger device +1. **Mnemonic phrase** - derive keys from a BIP-39 mnemonic with account index +1. **Private key** - use a raw hex-encoded private key directly + +**Security recommendations:** For managing significant funds on mainnet, we recommend using a hardware wallet (Ledger) +for extra security. Hardware wallets keep your private keys isolated from your computer, offering some protection +against malware and phishing attacks. If you need support for other hardware signers, please open an issue at +https://github.com/EspressoSystems/espresso-network. + +For mnemonics and private keys, to avoid passing secrets on the command line, use environment variables: + +- `MNEMONIC` for mnemonic phrase +- `PRIVATE_KEY` for raw private key + +If using a ledger or mnemonic and you don't know which account index to use, you can find it by running: ```bash staking-cli --mnemonic MNEMONIC --account-index 0 account staking-cli --mnemonic MNEMONIC --account-index 1 account # etc, or -staking-cli --ledger-index 0 account -staking-cli --ledger-index 1 account +staking-cli --ledger --account-index 0 account +staking-cli --ledger --account-index 1 account # etc ``` Repeat with different indices until you find the address you want to use. +If using a private key, ensure PRIVATE_KEY env var is set + +```bash +staking-cli account +``` + Note that for ledger signing to work 1. the ledger needs to be unlocked, 1. the Ethereum app needs to be open, 1. blind signing needs to be enabled in the Ethereum app settings on the ledger. -To avoid passing the mnemonic on the command line, the MNEMONIC env var can be set instead. - -### Initialize the configuration file +### Initialize the configuration file (optional) Once you've identified your desired account index (here 2), initialize a configuration file: staking-cli init --mnemonic MNEMONIC --account-index 2 # or - staking-cli init --ledger-index 2 + staking-cli init --ledger --account-index 2 + # or + staking-cli init --private-key 0x1234...abcd This creates a TOML config file with the contracts of our decaf Testnet, deployed on Sepolia. With the config file you don't need to provide the configuration values every time you run the CLI. -NOTE: only for this `init` command the `--mnemonic` and `--ledger-index` flags are specified _after_ the command. +NOTE: only for this `init` command the wallet flags are specified _after_ the command. ### Inspect the configuration You can inspect the configuration file by running: - staking-cli config + staking-cli config ### View the stake table @@ -185,6 +242,62 @@ You can use the following command to display the current L1 stake table: staking-cli stake-table +## Calldata Export (for Multisig Wallets) + +If you're using a multisig wallet (e.g., Safe, Gnosis Safe) or other smart contract wallet, you can export the +transaction calldata instead of signing and sending the transaction directly. This allows you to propose the transaction +through your multisig's interface. + +To export calldata for any command, add the `--export-calldata` flag: + +```bash +# Export delegate calldata as JSON (default) +staking-cli --export-calldata delegate --validator-address 0x12...34 --amount 100 + +# Export as TOML +staking-cli --export-calldata --format toml delegate --validator-address 0x12...34 --amount 100 + +# Save to file +staking-cli --export-calldata --format json --output delegate.json delegate --validator-address 0x12...34 --amount 100 +``` + +The output includes the target contract address and the encoded calldata: + +```json +{ + "to": "0x...", + "data": "0x..." +} +``` + +This works with all state-changing commands: `approve`, `delegate`, `undelegate`, `claim-withdrawal`, +`claim-validator-exit`, `claim-rewards`, `register-validator`, `update-commission`, `update-metadata-uri`, +`update-consensus-keys`, `deregister-validator`, and `transfer`. + +Note: When using `--export-calldata`, no wallet/signer is required since the transaction is not sent. + +### Calldata Simulation + +By default, the CLI simulates exported calldata via `eth_call` to catch errors before you submit the transaction through +your multisig. Provide `--sender-address` (your multisig address) for accurate simulation: + +```bash +staking-cli --export-calldata --sender-address 0xYourSafe... delegate --validator-address 0x12...34 --amount 100 +``` + +To skip simulation (e.g., for batch exports): + +```bash +staking-cli --export-calldata --skip-simulation delegate --validator-address 0x12...34 --amount 100 +``` + +Note: The `claim-rewards` command always requires `--sender-address` (even with `--skip-simulation`) because the address +is needed to fetch the reward proof from the Espresso node: + +```bash +staking-cli --export-calldata --sender-address 0xYourSafe... --espresso-url https://... claim-rewards +``` + ## Delegators (or stakers) This section covers commands for stakers/delegators. @@ -245,7 +358,8 @@ This section covers commands for node operators. ### Registering a validator -1. Obtain your validator's BLS and state private keys, choose your commission in percent (with 2 decimals), and prepare a metadata URL. +1. Obtain your validator's BLS and state private keys, choose your commission in percent (with 2 decimals), and prepare + a metadata URL. 1. Use the `register-validator` command to register your validator. staking-cli register-validator --consensus-private-key --state-private-key --commission 4.99 --metadata-uri https://example.com/validator-metadata.json @@ -282,6 +396,7 @@ This section covers commands for node operators. ### Updating your commission Validators can update their commission rate, subject to the following rate limits: + - Commission updates are limited to once per week (7 days by default) - Commission increases are capped at 5% per update (e.g., from 10% to 15%) - Commission decreases have no limit @@ -296,8 +411,8 @@ Note: The minimum time interval and maximum increase are contract parameters tha ### Updating your metadata URL -Validators can update their metadata URL at any time. The metadata URL is used to provide additional -information about your validator but the official schema is yet to be decided. +Validators can update their metadata URL at any time. The metadata URL is used to provide additional information about +your validator but the official schema is yet to be decided. To update your metadata URL: @@ -308,12 +423,13 @@ To clear your metadata URL (set it to empty): staking-cli update-metadata-uri --no-metadata-uri The metadata URL: + - Must be a valid URL (e.g., starting with `https://`) unless using --no-metadata-uri flag - Can be empty when using --no-metadata-uri flag - Cannot exceed 2048 bytes -Note: The metadata URL is emitted in events only. Off-chain indexers track the current URL by -listening to registration and update events. +Note: The metadata URL is emitted in events only. Off-chain indexers track the current URL by listening to registration +and update events. ### De-registering your validator @@ -353,12 +469,13 @@ key updates. The exported payload can later be used to build the Ethereum transa Output formats: -- JSON to stdout (default): `staking-cli export-node-signatures --address 0x12...34 --consensus-private-key --state-private-key ` +- JSON to stdout (default): + `staking-cli export-node-signatures --address 0x12...34 --consensus-private-key --state-private-key ` - JSON to file: `--output signatures.json` - TOML to file: `--output signatures.toml` - Explicit format override: `--output signatures.json --format toml` (saves TOML content to .json file) -The command will generate a signature payload file that doesn't contain any secrets: +The command will generate a signature payload file that doesn't contain any secrets: ```toml address = "0x..." @@ -377,7 +494,8 @@ Format handling: - File extension auto-detection: `.json` and `.toml` files are automatically parsed in the correct format - Stdin defaults to JSON: `cat signatures.json | staking-cli register-validator --node-signatures - --commission 4.99` -- Explicit format for stdin: `cat signatures.toml | staking-cli register-validator --node-signatures - --format toml --commission 4.99` +- Explicit format for stdin: + `cat signatures.toml | staking-cli register-validator --node-signatures - --format toml --commission 4.99` ### Native Demo Staking diff --git a/staking-cli/src/claim.rs b/staking-cli/src/claim.rs index 8dcbda5e3f9..8bf2c22f3e0 100644 --- a/staking-cli/src/claim.rs +++ b/staking-cli/src/claim.rs @@ -1,51 +1,23 @@ use alloy::{ - network::Ethereum, primitives::{Address, U256}, - providers::{PendingTransactionBuilder, Provider}, + providers::Provider, }; use anyhow::{bail, Context as _, Result}; use hotshot_contract_adapter::{ - evm::DecodeRevert as _, reward::RewardClaimInput, - sol_types::{ - EspTokenV2, LightClientV3, - RewardClaim::{self, RewardClaimErrors}, - StakeTableV2::{self, StakeTableV2Errors}, - }, + sol_types::{EspTokenV2, LightClientV3, RewardClaim, StakeTableV2}, }; use url::Url; -pub async fn claim_withdrawal( - provider: impl Provider, - stake_table: Address, - validator_address: Address, -) -> Result> { - let st = StakeTableV2::new(stake_table, provider); - st.claimWithdrawal(validator_address) - .send() - .await - .maybe_decode_revert::() -} - -pub async fn claim_validator_exit( - provider: impl Provider, - stake_table: Address, - validator_address: Address, -) -> Result> { - let st = StakeTableV2::new(stake_table, provider); - st.claimValidatorExit(validator_address) - .send() - .await - .maybe_decode_revert::() -} +use crate::transaction::Transaction; struct RewardClaimData { reward_claim_address: Address, claim_input: RewardClaimInput, } -async fn try_fetch_reward_claim_data( - provider: impl Provider + Clone, +async fn fetch_reward_claim_data( + provider: impl Provider, stake_table_address: Address, espresso_url: &Url, claimer_address: Address, @@ -124,39 +96,13 @@ async fn try_fetch_reward_claim_data( })) } -pub async fn claim_reward( - provider: impl Provider + Clone, - stake_table_address: Address, - espresso_url: Url, - claimer_address: Address, -) -> Result> { - let data = try_fetch_reward_claim_data( - &provider, - stake_table_address, - &espresso_url, - claimer_address, - ) - .await? - .context("No reward claim data found for address")?; - - let reward_claim = RewardClaim::new(data.reward_claim_address, provider); - reward_claim - .claimRewards( - data.claim_input.lifetime_rewards, - data.claim_input.auth_data.into(), - ) - .send() - .await - .maybe_decode_revert::() -} - pub async fn unclaimed_rewards( - provider: impl Provider + Clone, + provider: impl Provider, stake_table_address: Address, espresso_url: Url, claimer_address: Address, ) -> Result { - let Some(data) = try_fetch_reward_claim_data( + let Some(data) = fetch_reward_claim_data( &provider, stake_table_address, &espresso_url, @@ -179,13 +125,39 @@ pub async fn unclaimed_rewards( Ok(unclaimed) } +/// Fetch reward claim inputs from the Espresso API and return a Transaction for claiming rewards +pub async fn fetch_claim_rewards_inputs( + provider: impl Provider, + stake_table_address: Address, + espresso_url: &Url, + claimer_address: Address, +) -> Result> { + let Some(data) = fetch_reward_claim_data( + &provider, + stake_table_address, + espresso_url, + claimer_address, + ) + .await? + else { + return Ok(None); + }; + + Ok(Some(Transaction::ClaimRewards { + reward_claim: data.reward_claim_address, + lifetime_rewards: data.claim_input.lifetime_rewards, + auth_data: data.claim_input.auth_data.into(), + })) +} + #[cfg(test)] mod test { use alloy::primitives::{utils::parse_ether, U256}; + use hotshot_contract_adapter::sol_types::{RewardClaim, StakeTableV2}; use warp::Filter as _; use super::*; - use crate::{deploy::TestSystem, receipt::ReceiptExt}; + use crate::{deploy::TestSystem, receipt::ReceiptExt as _, transaction::Transaction}; #[tokio::test] async fn test_claim_withdrawal() -> Result<()> { @@ -197,10 +169,11 @@ mod test { system.warp_to_unlock_time().await?; let validator_address = system.deployer_address; - let receipt = claim_withdrawal(&system.provider, system.stake_table, validator_address) - .await? - .assert_success() - .await?; + let tx = Transaction::ClaimWithdrawal { + stake_table: system.stake_table, + validator: validator_address, + }; + let receipt = tx.send(&system.provider).await?.assert_success().await?; let event = receipt .decoded_log::() @@ -220,10 +193,11 @@ mod test { system.warp_to_unlock_time().await?; let validator_address = system.deployer_address; - let receipt = claim_validator_exit(&system.provider, system.stake_table, validator_address) - .await? - .assert_success() - .await?; + let tx = Transaction::ClaimValidatorExit { + stake_table: system.stake_table, + validator: validator_address, + }; + let receipt = tx.send(&system.provider).await?.assert_success().await?; let event = receipt .decoded_log::() @@ -244,15 +218,16 @@ mod test { let balance_before = system.balance(system.deployer_address).await?; - let receipt = claim_reward( + let tx = fetch_claim_rewards_inputs( &system.provider, system.stake_table, - espresso_url, + &espresso_url, system.deployer_address, ) .await? - .assert_success() - .await?; + .expect("claim inputs should be available"); + + let receipt = tx.send(&system.provider).await?.assert_success().await?; let event = receipt .decoded_log::() diff --git a/staking-cli/src/cli.rs b/staking-cli/src/cli.rs new file mode 100644 index 00000000000..fbe936433de --- /dev/null +++ b/staking-cli/src/cli.rs @@ -0,0 +1,634 @@ +#![doc = include_str!("../../README.md")] +use std::path::PathBuf; + +use alloy::{ + self, + eips::BlockId, + network::{Ethereum, EthereumWallet, NetworkWallet}, + primitives::Address, + providers::{Provider, ProviderBuilder}, + rpc::types::Log, + sol_types::SolEventInterface, +}; +use anyhow::{Context, Result}; +use clap::Parser; +use clap_serde_derive::ClapSerde; +use hotshot_contract_adapter::sol_types::{ + EspToken::{self, EspTokenEvents}, + RewardClaim::RewardClaimEvents, + StakeTableV2::StakeTableV2Events, +}; +use hotshot_types::{ + light_client::{StateKeyPair, StateVerKey}, + signature_key::BLSPubKey, +}; +use sysinfo::System; + +use crate::{ + claim::fetch_claim_rewards_inputs, + demo::stake_for_demo, + info::{ + display_stake_table, fetch_stake_table_version, fetch_token_address, stake_table_info, + StakeTableContractVersion, + }, + output::{ + format_esp, output_calldata, output_error, output_success, output_warn, CalldataInfo, + }, + signature::{NodeSignatureDestination, NodeSignatureInput, NodeSignatures}, + transaction::Transaction, + Commands, Config, ValidSignerConfig, +}; + +#[derive(Parser)] +#[command(author, version, about)] +struct Args { + /// Config file + #[arg(short, long = "config")] + config_path: Option, + + /// Rest of arguments + #[command(flatten)] + pub config: ::Opt, +} + +impl Args { + fn config_path(&self) -> PathBuf { + // If the user provided a config path, use it. + self.config_path.clone().unwrap_or_else(|| { + // Otherwise create a config.toml in a platform specific config directory. + // + // (empty) qualifier, espresso organization, and application name + // see more + let project_dir = + directories::ProjectDirs::from("", "espresso", "espresso-staking-cli"); + let basename = "config.toml"; + if let Some(project_dir) = project_dir { + project_dir.config_dir().to_path_buf().join(basename) + } else { + // In the unlikely case that we can't find the config directory, + // create the config file in the current directory and issue a + // warning. + tracing::warn!("Unable to find config directory, using current directory"); + basename.into() + } + }) + } + + fn config_dir(&self) -> PathBuf { + if let Some(path) = self.config_path().parent() { + path.to_path_buf() + } else { + // Try to use the current directory + PathBuf::from(".") + } + } +} + +trait AddressExt { + fn or_from_wallet(self, wallet: Option<&EthereumWallet>) -> Option
; +} + +impl AddressExt for Option
{ + fn or_from_wallet(self, wallet: Option<&EthereumWallet>) -> Option
{ + self.or_else(|| wallet.map(NetworkWallet::::default_signer_address)) + } +} + +fn exit_err(msg: impl AsRef, err: impl core::fmt::Display) -> ! { + output_error(format!("{}: {err}", msg.as_ref())) +} + +fn exit(msg: impl AsRef) -> ! { + output_error(format!("Error: {}", msg.as_ref())) +} + +// Events containing custom structs do not get the Debug derive, due to a bug in +// foundry. We instead format those types nicely with tagged base64. +fn decode_and_display_logs(logs: &[Log]) { + for log in logs { + if let Ok(decoded) = StakeTableV2Events::decode_log(log.as_ref()) { + match &decoded.data { + StakeTableV2Events::ValidatorRegistered(e) => output_success(format!( + "event: ValidatorRegistered {{ account: {}, blsVk: {}, schnorrVk: {}, \ + commission: {} }}", + e.account, + BLSPubKey::from(e.blsVk), + StateVerKey::from(e.schnorrVk), + e.commission + )), + StakeTableV2Events::ValidatorRegisteredV2(e) => output_success(format!( + "event: ValidatorRegisteredV2 {{ account: {}, blsVK: {}, schnorrVK: {}, \ + commission: {}, metadataUri: {} }}", + e.account, + BLSPubKey::from(e.blsVK), + StateVerKey::from(e.schnorrVK), + e.commission, + e.metadataUri + )), + StakeTableV2Events::Delegated(e) => output_success(format!("event: {e:?}")), + StakeTableV2Events::Undelegated(e) => output_success(format!("event: {e:?}")), + StakeTableV2Events::UndelegatedV2(e) => output_success(format!("event: {e:?}")), + StakeTableV2Events::ValidatorExit(e) => output_success(format!("event: {e:?}")), + StakeTableV2Events::ValidatorExitV2(e) => output_success(format!("event: {e:?}")), + StakeTableV2Events::ConsensusKeysUpdated(e) => output_success(format!( + "event: ConsensusKeysUpdated {{ account: {}, blsVK: {}, schnorrVK: {} }}", + e.account, + BLSPubKey::from(e.blsVK), + StateVerKey::from(e.schnorrVK) + )), + StakeTableV2Events::ConsensusKeysUpdatedV2(e) => output_success(format!( + "event: ConsensusKeysUpdatedV2 {{ account: {}, blsVK: {}, schnorrVK: {} }}", + e.account, + BLSPubKey::from(e.blsVK), + StateVerKey::from(e.schnorrVK) + )), + StakeTableV2Events::CommissionUpdated(e) => output_success(format!("event: {e:?}")), + StakeTableV2Events::MetadataUriUpdated(e) => output_success(format!( + "event: MetadataUriUpdated {{ validator: {}, metadataUri: {} }}", + e.validator, e.metadataUri + )), + StakeTableV2Events::Withdrawal(e) => output_success(format!("event: {e:?}")), + StakeTableV2Events::WithdrawalClaimed(e) => output_success(format!("event: {e:?}")), + StakeTableV2Events::ValidatorExitClaimed(e) => { + output_success(format!("event: {e:?}")) + }, + + _ => {}, + } + } else if let Ok(decoded) = EspTokenEvents::decode_log(log.as_ref()) { + match &decoded.data { + EspTokenEvents::Transfer(e) => output_success(format!("event: {e:?}")), + EspTokenEvents::Approval(e) => output_success(format!("event: {e:?}")), + _ => {}, + } + } else if let Ok(decoded) = RewardClaimEvents::decode_log(log.as_ref()) { + if let RewardClaimEvents::RewardsClaimed(e) = &decoded.data { + output_success(format!("event: {e:?}")); + } + } + } +} + +fn resolve_node_signatures( + signature_args: &crate::signature::NodeSignatureArgs, + export_calldata: bool, + wallet: Option<&EthereumWallet>, + sender_address: Option
, +) -> Result { + if export_calldata { + let input = NodeSignatureInput::try_from((signature_args.clone(), sender_address))?; + NodeSignatures::try_from(input) + } else { + let wallet = wallet.ok_or_else(|| anyhow::anyhow!("Signer configuration required"))?; + let address = NetworkWallet::::default_signer_address(wallet); + let input = NodeSignatureInput::try_from((signature_args.clone(), Some(address)))?; + NodeSignatures::try_from((input, wallet)) + } +} + +pub async fn run() -> Result<()> { + let mut cli = Args::parse(); + + // initialize the logging ASAP so we don't accidentally hide any messages. + cli.config.logging.clone().unwrap_or_default().init(); + + let config_path = cli.config_path(); + // Get config file + let config = if let Ok(f) = std::fs::read_to_string(&config_path) { + // parse toml + match toml::from_str::(&f) { + Ok(config) => config.merge(&mut cli.config), + Err(err) => { + // This is a user error print the hopefully helpful error + // message without backtrace and exit. + exit_err( + format!("Error in configuration file at {}", config_path.display()), + err, + ); + }, + } + } else { + // If there is no config file return only config parsed from clap + Config::from(&mut cli.config) + }; + + if config.token_address.is_some() { + tracing::warn!("The `--token_address` argument is no longer necessary , and ignored"); + }; + + // Run the init command first because config values required by other + // commands are not present. + match config.commands { + Commands::Init { + mnemonic, + private_key, + account_index, + ledger, + } => { + let mut config = toml::from_str::(include_str!("../config.decaf.toml"))?; + config.signer.mnemonic = mnemonic; + config.signer.private_key = private_key; + config.signer.account_index = Some(account_index); + config.signer.ledger = ledger; + + // Create directory where config file will be saved + std::fs::create_dir_all(cli.config_dir()).unwrap_or_else(|err| { + exit_err("failed to create config directory", err); + }); + + // Save the config file + std::fs::write(&config_path, toml::to_string(&config)?) + .unwrap_or_else(|err| exit_err("failed to write config file", err)); + + println!("New config file saved to {}", config_path.display()); + return Ok(()); + }, + Commands::Purge { force } => { + // Check if the file exists + if !config_path.exists() { + println!("Config file not found at {}", config_path.display()); + return Ok(()); + } + if !force { + // Get a confirmation from the user before removing the config file. + println!( + "Are you sure you want to remove the config file at {}? [y/N]", + config_path.display() + ); + let mut input = String::new(); + std::io::stdin().read_line(&mut input).unwrap(); + if !input.trim().to_lowercase().starts_with('y') { + println!("Aborted"); + return Ok(()); + } + } + // Remove the config file + std::fs::remove_file(&config_path).unwrap_or_else(|err| { + exit_err("failed to remove config file", err); + }); + + println!("Config file removed from {}", config_path.display()); + return Ok(()); + }, + Commands::Config => { + println!("Config file at {}\n", config_path.display()); + let mut config = config; + config.signer.mnemonic = config.signer.mnemonic.map(|_| "***".to_string()); + config.signer.private_key = config.signer.private_key.map(|_| "***".to_string()); + println!("{}", toml::to_string_pretty(&config)?); + return Ok(()); + }, + Commands::Version => { + println!("staking-cli version: {}", env!("CARGO_PKG_VERSION")); + println!("{}", git_version::git_version!(prefix = "git rev: ")); + println!("OS: {}", System::long_os_version().unwrap_or_default()); + println!("Arch: {}", System::cpu_arch()); + return Ok(()); + }, + Commands::ExportNodeSignatures { + address, + consensus_private_key, + state_private_key, + output_args, + } => { + let destination = NodeSignatureDestination::try_from(output_args)?; + + let payload = NodeSignatures::create( + address, + &consensus_private_key.into(), + &StateKeyPair::from_sign_key(state_private_key), + ); + + payload.handle_output(destination)?; + return Ok(()); + }, + _ => {}, // Other commands handled after shared setup. + } + + // When the staking CLI is used for our testnet, the env var names are different. + let config = config.apply_env_var_overrides()?; + + // Commands that don't need a signer + if let Commands::StakeTable { + l1_block_number, + compact, + } = config.commands + { + let provider = ProviderBuilder::new().connect_http(config.rpc_url.clone()); + let query_block = l1_block_number.unwrap_or(BlockId::latest()); + let l1_block = provider.get_block(query_block).await?.unwrap_or_else(|| { + exit_err("Failed to get block {query_block}", "Block not found"); + }); + let l1_block_resolved = l1_block.header.number; + tracing::info!("Getting stake table info at block {l1_block_resolved}"); + let stake_table = stake_table_info( + config.rpc_url.clone(), + config.stake_table_address, + l1_block_resolved, + ) + .await?; + display_stake_table(stake_table, compact)?; + return Ok(()); + } + + // Clap serde will put default value if they aren't set. We check some + // common configuration mistakes. + if config.stake_table_address == Address::ZERO { + exit("Stake table address is not set use --stake-table-address or STAKE_TABLE_ADDRESS") + }; + + let stake_table_addr = config.stake_table_address; + + // For export_calldata mode, we may not need a signer for most commands. + // We create the provider without a wallet first for token address fetching + // and contract version detection. + let readonly_provider = ProviderBuilder::new().connect_http(config.rpc_url.clone()); + + // Check if we need token address for this command + let token_addr = if config.commands.needs_token_address() { + fetch_token_address(config.rpc_url.clone(), stake_table_addr).await? + } else { + Address::ZERO + }; + + let wallet = + if let Ok(signer_config) = TryInto::::try_into(config.signer.clone()) { + signer_config.wallet().await.ok() + } else { + None + }; + + // Commands that just read from chain + if let Commands::Account = config.commands { + let account = NetworkWallet::::default_signer_address( + wallet.as_ref().context("Signer configuration required")?, + ); + println!("{account}"); + return Ok(()); + } + + if let Commands::TokenBalance { address } = config.commands { + let address = address + .or_from_wallet(wallet.as_ref()) + .context("Address required - provide --address or configure a signer")?; + let token = EspToken::new(token_addr, &readonly_provider); + let balance = format_esp(token.balanceOf(address).call().await?); + output_success(format!("Token balance for {address}: {balance}")); + return Ok(()); + } + + if let Commands::TokenAllowance { owner } = config.commands { + let owner = owner + .or_from_wallet(wallet.as_ref()) + .context("Owner address required - provide --owner or configure a signer")?; + let token = EspToken::new(token_addr, &readonly_provider); + let allowance = format_esp( + token + .allowance(owner, config.stake_table_address) + .call() + .await?, + ); + output_success(format!( + "Stake table token allowance for {owner}: {allowance}" + )); + return Ok(()); + } + + if let Commands::UnclaimedRewards { address } = config.commands { + let address = address + .or_from_wallet(wallet.as_ref()) + .context("Address required - provide --address or configure a signer")?; + let espresso_url = config.espresso_url.ok_or_else(|| { + anyhow::anyhow!("espresso_url not set, use --espresso-url or ESPRESSO_URL") + })?; + let unclaimed = crate::claim::unclaimed_rewards( + &readonly_provider, + stake_table_addr, + espresso_url, + address, + ) + .await + .unwrap_or_else(|err| { + exit_err("Failed to check unclaimed rewards", err); + }); + println!("{}", format_esp(unclaimed)); + return Ok(()); + } + + if let Commands::StakeForDemo { + num_validators, + num_delegators_per_validator, + delegation_config, + } = config.commands + { + tracing::info!( + "Staking for demo with {num_validators} validators and config {delegation_config}" + ); + stake_for_demo( + &config, + num_validators, + num_delegators_per_validator, + delegation_config, + ) + .await + .unwrap(); + return Ok(()); + } + + // Build Transaction for state-changing commands + let tx: Transaction = match &config.commands { + Commands::RegisterValidator { + signature_args, + commission, + metadata_uri_args, + } => { + let version = fetch_stake_table_version(&readonly_provider, stake_table_addr).await?; + if config.export_calldata && matches!(version, StakeTableContractVersion::V1) { + anyhow::bail!( + "Calldata export is not supported for V1 stake table contracts. V1 is \ + deprecated." + ); + } + let payload = resolve_node_signatures( + signature_args, + config.export_calldata, + wallet.as_ref(), + config.sender_address, + )?; + let metadata_uri = metadata_uri_args.clone().try_into()?; + Transaction::RegisterValidator { + stake_table: stake_table_addr, + commission: *commission, + metadata_uri, + payload, + version, + } + }, + Commands::UpdateConsensusKeys { signature_args } => { + let version = fetch_stake_table_version(&readonly_provider, stake_table_addr).await?; + if config.export_calldata && matches!(version, StakeTableContractVersion::V1) { + anyhow::bail!( + "Calldata export is not supported for V1 stake table contracts. V1 is \ + deprecated." + ); + } + if let Some(w) = wallet.as_ref() { + let addr = NetworkWallet::::default_signer_address(w); + tracing::info!("Updating validator {} with new keys", addr); + } + let payload = resolve_node_signatures( + signature_args, + config.export_calldata, + wallet.as_ref(), + config.sender_address, + )?; + Transaction::UpdateConsensusKeys { + stake_table: stake_table_addr, + payload, + version, + } + }, + Commands::DeregisterValidator {} => Transaction::DeregisterValidator { + stake_table: stake_table_addr, + }, + Commands::UpdateCommission { new_commission } => Transaction::UpdateCommission { + stake_table: stake_table_addr, + new_commission: *new_commission, + }, + Commands::UpdateMetadataUri { metadata_uri_args } => { + let metadata_uri = metadata_uri_args.clone().try_into()?; + Transaction::UpdateMetadataUri { + stake_table: stake_table_addr, + metadata_uri, + } + }, + Commands::Approve { amount } => Transaction::Approve { + token: token_addr, + spender: stake_table_addr, + amount: *amount, + }, + Commands::Delegate { + validator_address, + amount, + } => Transaction::Delegate { + stake_table: stake_table_addr, + validator: *validator_address, + amount: *amount, + }, + Commands::Undelegate { + validator_address, + amount, + } => Transaction::Undelegate { + stake_table: stake_table_addr, + validator: *validator_address, + amount: *amount, + }, + Commands::ClaimWithdrawal { validator_address } => Transaction::ClaimWithdrawal { + stake_table: stake_table_addr, + validator: *validator_address, + }, + Commands::ClaimValidatorExit { validator_address } => Transaction::ClaimValidatorExit { + stake_table: stake_table_addr, + validator: *validator_address, + }, + Commands::ClaimRewards {} => { + let espresso_url = config.espresso_url.clone().ok_or_else(|| { + anyhow::anyhow!("espresso_url not set, use --espresso-url or ESPRESSO_URL") + })?; + let claimer_address = if config.export_calldata { + config.sender_address.ok_or_else(|| { + anyhow::anyhow!( + "claim-rewards with --export-calldata requires --sender-address" + ) + })? + } else { + NetworkWallet::::default_signer_address( + wallet.as_ref().context("Signer configuration required")?, + ) + }; + fetch_claim_rewards_inputs( + &readonly_provider, + stake_table_addr, + &espresso_url, + claimer_address, + ) + .await? + .ok_or_else(|| anyhow::anyhow!("No reward claim data found for address"))? + }, + Commands::Transfer { amount, to } => Transaction::Transfer { + token: token_addr, + to: *to, + amount: *amount, + }, + Commands::Version + | Commands::Config + | Commands::Init { .. } + | Commands::Purge { .. } + | Commands::StakeTable { .. } + | Commands::Account + | Commands::UnclaimedRewards { .. } + | Commands::TokenBalance { .. } + | Commands::TokenAllowance { .. } + | Commands::ExportNodeSignatures { .. } + | Commands::StakeForDemo { .. } => { + unreachable!("Non-state-change commands are handled earlier in the function") + }, + }; + + // Validate even for export mode to fail early if the transaction would fail on-chain. + tx.validate_delegate_amount(&readonly_provider).await?; + + // Single code path for both export and execute modes + if config.export_calldata { + if config.skip_simulation { + output_warn("Skipping calldata validation (--skip-simulation)"); + } else { + let sender = config.sender_address.ok_or_else(|| { + anyhow::anyhow!( + "--sender-address is required for calldata simulation (use --skip-simulation \ + to skip)" + ) + })?; + tx.simulate(&readonly_provider, sender).await?; + } + let (to, data) = tx.calldata(); + return output_calldata(&CalldataInfo::new(to, data), &config.output); + } + + // For execution, we need the wallet + let wallet = wallet.ok_or_else(|| { + anyhow::anyhow!("Signer configuration required for transaction execution") + })?; + let account = NetworkWallet::::default_signer_address(&wallet); + + // Check that our Ethereum balance isn't zero before proceeding. + let balance = readonly_provider.get_balance(account).await?; + if balance.is_zero() { + exit(format!( + "zero Ethereum balance for account {account}, please fund account" + )); + } + + // Create provider with wallet for signing + let provider = ProviderBuilder::new() + .wallet(wallet) + .connect_http(config.rpc_url.clone()); + + // Execute the state change + let pending_tx = tx + .send(&provider) + .await + .unwrap_or_else(|err| exit_err("Error", err)); + + match pending_tx.get_receipt().await { + Ok(receipt) => { + output_success(format!( + "Success! transaction hash: {}", + receipt.transaction_hash + )); + decode_and_display_logs(receipt.inner.logs()); + Ok(()) + }, + Err(err) => exit_err("Failed", err), + } +} diff --git a/staking-cli/src/delegation.rs b/staking-cli/src/delegation.rs index 62f498147bc..3c7b0169474 100644 --- a/staking-cli/src/delegation.rs +++ b/staking-cli/src/delegation.rs @@ -1,90 +1,16 @@ -use alloy::{ - network::Ethereum, - primitives::{utils::format_ether, Address, U256}, - providers::{PendingTransactionBuilder, Provider}, -}; -use anyhow::{bail, Result}; -use hotshot_contract_adapter::{ - evm::DecodeRevert as _, - sol_types::{ - EspToken::{self, EspTokenErrors}, - StakeTableV2::{self, StakeTableV2Errors}, - }, - stake_table::StakeTableContractVersion, -}; - -pub async fn approve( - provider: impl Provider, - token_addr: Address, - stake_table_address: Address, - amount: U256, -) -> Result> { - tracing::info!( - "approve {} ESP for {stake_table_address}", - format_ether(amount) - ); - let token = EspToken::new(token_addr, provider); - token - .approve(stake_table_address, amount) - .send() - .await - .maybe_decode_revert::() -} - -pub async fn delegate( - provider: impl Provider, - stake_table: Address, - validator_address: Address, - amount: U256, -) -> Result> { - tracing::info!( - "delegate {} ESP to {validator_address}", - format_ether(amount) - ); - let st = StakeTableV2::new(stake_table, provider); - - let version: StakeTableContractVersion = st.getVersion().call().await?.try_into()?; - if let StakeTableContractVersion::V2 = version { - let min_amount = st.minDelegateAmount().call().await?; - if amount < min_amount { - bail!( - "delegation amount {} ESP is below minimum of {} ESP", - format_ether(amount), - format_ether(min_amount) - ); - } - } - - st.delegate(validator_address, amount) - .send() - .await - .maybe_decode_revert::() -} - -pub async fn undelegate( - provider: impl Provider, - stake_table: Address, - validator_address: Address, - amount: U256, -) -> Result> { - tracing::info!( - "undelegate {} ESP from {validator_address}", - format_ether(amount) - ); - let st = StakeTableV2::new(stake_table, provider); - st.undelegate(validator_address, amount) - .send() - .await - .maybe_decode_revert::() -} - #[cfg(test)] mod test { - use alloy::primitives::utils::parse_ether; + use alloy::{ + primitives::{utils::parse_ether, U256}, + providers::Provider, + }; + use anyhow::Result; + use hotshot_contract_adapter::{ + sol_types::StakeTableV2, stake_table::StakeTableContractVersion, + }; use rstest::rstest; - use super::*; - use crate::{deploy::TestSystem, receipt::ReceiptExt}; + use crate::{deploy::TestSystem, receipt::ReceiptExt as _, transaction::Transaction}; #[rstest] #[case(StakeTableContractVersion::V1)] @@ -96,15 +22,12 @@ mod test { let validator_address = system.deployer_address; let amount = parse_ether("1.23")?; - let receipt = delegate( - &system.provider, - system.stake_table, - validator_address, + let tx = Transaction::Delegate { + stake_table: system.stake_table, + validator: validator_address, amount, - ) - .await? - .assert_success() - .await?; + }; + let receipt = tx.send(&system.provider).await?.assert_success().await?; let event = receipt.decoded_log::().unwrap(); assert_eq!(event.validator, validator_address); @@ -124,15 +47,12 @@ mod test { system.delegate(amount).await?; let validator_address = system.deployer_address; - let receipt = undelegate( - &system.provider, - system.stake_table, - validator_address, + let tx = Transaction::Undelegate { + stake_table: system.stake_table, + validator: validator_address, amount, - ) - .await? - .assert_success() - .await?; + }; + let receipt = tx.send(&system.provider).await?.assert_success().await?; match version { StakeTableContractVersion::V1 => { @@ -166,14 +86,15 @@ mod test { let validator_address = system.deployer_address; let amount = U256::from(123); - let err = delegate( - &system.provider, - system.stake_table, - validator_address, + let tx = Transaction::Delegate { + stake_table: system.stake_table, + validator: validator_address, amount, - ) - .await - .expect_err("should fail with amount below minimum"); + }; + let err = tx + .validate_delegate_amount(&system.provider) + .await + .expect_err("should fail with amount below minimum"); let err_msg = err.to_string(); assert!( @@ -181,7 +102,7 @@ mod test { "error should mention below minimum: {err_msg}" ); assert!( - err_msg.contains("1.000000000000000000 ESP"), + err_msg.contains("1 ESP"), "error should include min amount: {err_msg}" ); diff --git a/staking-cli/src/demo.rs b/staking-cli/src/demo.rs index 36d5c109c0d..e32a4398bc2 100644 --- a/staking-cli/src/demo.rs +++ b/staking-cli/src/demo.rs @@ -5,13 +5,16 @@ use std::{ use alloy::{ contract::Error as ContractError, - network::{Ethereum, EthereumWallet}, + network::{Ethereum, EthereumWallet, TransactionBuilder as _}, primitives::{ utils::{format_ether, parse_ether}, Address, U256, }, providers::{PendingTransactionBuilder, Provider, ProviderBuilder, WalletProvider}, - rpc::{client::RpcClient, types::TransactionReceipt}, + rpc::{ + client::RpcClient, + types::{TransactionReceipt, TransactionRequest}, + }, signers::local::PrivateKeySigner, transports::{http::Http, TransportError}, }; @@ -30,13 +33,11 @@ use thiserror::Error; use url::Url; use crate::{ - delegation::{approve, delegate}, - funding::{send_esp, send_eth}, info::fetch_token_address, parse::{parse_bls_priv_key, parse_state_priv_key, Commission, ParseCommissionError}, receipt::ReceiptExt as _, - registration::register_validator, signature::NodeSignatures, + transaction::Transaction, Config, }; @@ -212,6 +213,7 @@ struct TransactionProcessor

{ funder: P, stake_table: Address, token: Address, + version: StakeTableContractVersion, } impl TransactionProcessor

{ @@ -223,9 +225,18 @@ impl TransactionProcessor

{ async fn send_next(&self, tx: StakeTableTx) -> Result> { match tx { - StakeTableTx::SendEth { to, amount } => send_eth(&self.funder, to, amount).await, + StakeTableTx::SendEth { to, amount } => { + let tx = TransactionRequest::default().with_to(to).with_value(amount); + Ok(self.funder.send_transaction(tx).await?) + }, StakeTableTx::SendEsp { to, amount } => { - send_esp(&self.funder, self.token, to, amount).await + Transaction::Transfer { + token: self.token, + to, + amount, + } + .send(&self.funder) + .await }, StakeTableTx::RegisterValidator { from, @@ -233,23 +244,38 @@ impl TransactionProcessor

{ payload, } => { let metadata_uri = "https://example.com/metadata".parse()?; - register_validator( - self.provider(from)?, - self.stake_table, + Transaction::RegisterValidator { + stake_table: self.stake_table, commission, metadata_uri, - *payload, - ) + payload: *payload, + version: self.version, + } + .send(self.provider(from)?) .await }, StakeTableTx::Approve { from, amount } => { - approve(self.provider(from)?, self.token, self.stake_table, amount).await + Transaction::Approve { + token: self.token, + spender: self.stake_table, + amount, + } + .send(self.provider(from)?) + .await }, StakeTableTx::Delegate { from, validator, amount, - } => delegate(self.provider(from)?, self.stake_table, validator, amount).await, + } => { + Transaction::Delegate { + stake_table: self.stake_table, + validator, + amount, + } + .send(self.provider(from)?) + .await + }, } } @@ -639,6 +665,7 @@ impl StakingTransactions { funder: token_holder_provider, stake_table, token, + version, }, queues: TransactionQueues { funding, diff --git a/staking-cli/src/deploy.rs b/staking-cli/src/deploy.rs index a22a537665c..5ef205a65c0 100644 --- a/staking-cli/src/deploy.rs +++ b/staking-cli/src/deploy.rs @@ -1,15 +1,16 @@ use std::time::Duration; use alloy::{ - network::{Ethereum, EthereumWallet}, + network::{Ethereum, EthereumWallet, TransactionBuilder as _}, primitives::{utils::parse_ether, Address, B256, U256}, providers::{ ext::AnvilApi as _, fillers::{FillProvider, JoinFill, WalletFiller}, layers::AnvilProvider, utils::JoinedRecommendedFillers, - ProviderBuilder, RootProvider, WalletProvider, + Provider, ProviderBuilder, RootProvider, WalletProvider, }, + rpc::types::TransactionRequest, signers::local::PrivateKeySigner, sol_types::SolValue as _, }; @@ -38,16 +39,11 @@ use hotshot_types::light_client::StateKeyPair; use jf_merkle_tree_compat::{MerkleCommitment, MerkleTreeScheme, UniversalMerkleTreeScheme}; use rand::{rngs::StdRng, CryptoRng, Rng as _, RngCore, SeedableRng as _}; use url::Url; -use warp::Filter; +use warp::{http::StatusCode, Filter}; use crate::{ - delegation::{approve, delegate, undelegate}, - funding::{send_esp, send_eth}, - parse::Commission, - receipt::ReceiptExt as _, - registration::{deregister_validator, fetch_commission, register_validator}, - signature::NodeSignatures, - BLSKeyPair, DEV_MNEMONIC, + parse::Commission, receipt::ReceiptExt as _, registration::fetch_commission, + signature::NodeSignatures, transaction::Transaction, BLSKeyPair, DEV_MNEMONIC, }; type TestProvider = FillProvider< @@ -70,6 +66,7 @@ pub struct TestSystem { pub state_key_pair: StateKeyPair, pub commission: Commission, pub approval_amount: U256, + pub version: StakeTableContractVersion, } impl TestSystem { @@ -189,6 +186,7 @@ impl TestSystem { state_key_pair, commission: Commission::try_from("12.34")?, approval_amount, + version: stake_table_contract_version, }) } @@ -210,13 +208,14 @@ impl TestSystem { &self.state_key_pair.clone(), ); let metadata_uri = "https://example.com/metadata".parse()?; - register_validator( - &self.provider, - self.stake_table, - self.commission, + Transaction::RegisterValidator { + stake_table: self.stake_table, + commission: self.commission, metadata_uri, payload, - ) + version: self.version, + } + .send(&self.provider) .await? .assert_success() .await?; @@ -224,20 +223,23 @@ impl TestSystem { } pub async fn deregister_validator(&self) -> Result<()> { - deregister_validator(&self.provider, self.stake_table) - .await? - .assert_success() - .await?; + Transaction::DeregisterValidator { + stake_table: self.stake_table, + } + .send(&self.provider) + .await? + .assert_success() + .await?; Ok(()) } pub async fn delegate(&self, amount: U256) -> Result<()> { - delegate( - &self.provider, - self.stake_table, - self.deployer_address, + Transaction::Delegate { + stake_table: self.stake_table, + validator: self.deployer_address, amount, - ) + } + .send(&self.provider) .await? .assert_success() .await?; @@ -245,12 +247,12 @@ impl TestSystem { } pub async fn undelegate(&self, amount: U256) -> Result<()> { - undelegate( - &self.provider, - self.stake_table, - self.deployer_address, + Transaction::Undelegate { + stake_table: self.stake_table, + validator: self.deployer_address, amount, - ) + } + .send(&self.provider) .await? .assert_success() .await?; @@ -258,7 +260,9 @@ impl TestSystem { } pub async fn transfer_eth(&self, to: Address, amount: U256) -> Result<()> { - send_eth(&self.provider, to, amount) + let tx = TransactionRequest::default().with_to(to).with_value(amount); + self.provider + .send_transaction(tx) .await? .assert_success() .await?; @@ -266,10 +270,15 @@ impl TestSystem { } pub async fn transfer(&self, to: Address, amount: U256) -> Result<()> { - send_esp(&self.provider, self.token, to, amount) - .await? - .assert_success() - .await?; + Transaction::Transfer { + token: self.token, + to, + amount, + } + .send(&self.provider) + .await? + .assert_success() + .await?; Ok(()) } @@ -308,10 +317,15 @@ impl TestSystem { } pub async fn approve(&self, amount: U256) -> Result<()> { - approve(&self.provider, self.token, self.stake_table, amount) - .await? - .assert_success() - .await?; + Transaction::Approve { + token: self.token, + spender: self.stake_table, + amount, + } + .send(&self.provider) + .await? + .assert_success() + .await?; assert!(self.allowance(self.deployer_address).await? == amount); Ok(()) } @@ -376,6 +390,17 @@ impl TestSystem { Ok(format!("http://localhost:{}/", port).parse()?) } + + pub fn setup_reward_claim_not_found_mock(&self) -> Url { + let port = portpicker::pick_unused_port().expect("No ports available"); + + let route = warp::path!("reward-state-v2" / "reward-claim-input" / u64 / String) + .map(|_, _| warp::reply::with_status(warp::reply(), StatusCode::NOT_FOUND)); + + tokio::spawn(warp::serve(route).run(([127, 0, 0, 1], port))); + + format!("http://localhost:{}/", port).parse().unwrap() + } } #[cfg(test)] diff --git a/staking-cli/src/funding.rs b/staking-cli/src/funding.rs deleted file mode 100644 index 62e97bd930d..00000000000 --- a/staking-cli/src/funding.rs +++ /dev/null @@ -1,36 +0,0 @@ -use alloy::{ - network::{Ethereum, TransactionBuilder as _}, - primitives::{Address, U256}, - providers::{PendingTransactionBuilder, Provider}, - rpc::types::TransactionRequest, -}; -use anyhow::Result; -use hotshot_contract_adapter::{ - evm::DecodeRevert as _, - sol_types::EspToken::{self, EspTokenErrors}, -}; - -pub async fn send_eth( - provider: impl Provider, - to: Address, - amount: U256, -) -> Result> { - tracing::info!("fund address {to} with {amount} ETH"); - let tx = TransactionRequest::default().with_to(to).with_value(amount); - Ok(provider.send_transaction(tx).await?) -} - -pub async fn send_esp( - provider: impl Provider, - token_address: Address, - to: Address, - amount: U256, -) -> Result> { - tracing::info!("transfer {amount} ESP to {to}"); - let token = EspToken::new(token_address, provider); - token - .transfer(to, amount) - .send() - .await - .maybe_decode_revert::() -} diff --git a/staking-cli/src/info.rs b/staking-cli/src/info.rs index 78fcffe50e9..c3f7c3c9d7a 100644 --- a/staking-cli/src/info.rs +++ b/staking-cli/src/info.rs @@ -1,6 +1,6 @@ use alloy::{ primitives::{utils::format_ether, Address}, - providers::ProviderBuilder, + providers::{Provider, ProviderBuilder}, }; use anyhow::{Context as _, Result}; use espresso_types::{ @@ -8,6 +8,7 @@ use espresso_types::{ L1Client, }; use hotshot_contract_adapter::sol_types::StakeTableV2; +pub use hotshot_contract_adapter::stake_table::StakeTableContractVersion; use hotshot_types::signature_key::BLSPubKey; use url::Url; @@ -78,3 +79,16 @@ pub async fn fetch_token_address(rpc_url: Url, stake_table_address: Address) -> ) }) } + +pub async fn fetch_stake_table_version( + provider: impl Provider, + stake_table_address: Address, +) -> Result { + let stake_table = StakeTableV2::new(stake_table_address, provider); + stake_table + .getVersion() + .call() + .await? + .try_into() + .with_context(|| "Failed to parse stake table contract version") +} diff --git a/staking-cli/src/lib.rs b/staking-cli/src/lib.rs index 0ca77447bab..4dc291a245d 100644 --- a/staking-cli/src/lib.rs +++ b/staking-cli/src/lib.rs @@ -2,10 +2,10 @@ use alloy::{ eips::BlockId, network::EthereumWallet, primitives::{utils::parse_ether, Address, U256}, - signers::local::{coins_bip39::English, MnemonicBuilder}, + signers::local::{coins_bip39::English, MnemonicBuilder, PrivateKeySigner}, }; use anyhow::{bail, Result}; -use clap::{Args as ClapArgs, Parser, Subcommand}; +use clap::{ArgAction, Args as ClapArgs, Parser, Subcommand}; use clap_serde_derive::ClapSerde; use demo::DelegationConfig; use espresso_contract_deployer::provider::connect_ledger; @@ -15,27 +15,43 @@ use metadata::MetadataUri; use parse::Commission; use sequencer_utils::logging; use serde::{Deserialize, Serialize}; +use signature::OutputArgs; use url::Url; -pub mod claim; -pub mod delegation; +pub(crate) mod claim; +mod cli; +pub(crate) mod delegation; +/// Used by sequencer, espresso-dev-node, staking-ui-service tests. pub mod demo; -pub mod funding; -pub mod info; -pub mod l1; -pub mod metadata; -pub mod output; -pub mod parse; -pub mod receipt; +pub(crate) mod info; +pub(crate) mod l1; +pub(crate) mod metadata; +pub(crate) mod output; +pub(crate) mod parse; +pub(crate) mod receipt; +/// Used by sequencer tests (fetch_commission, update_commission). pub mod registration; +/// Used by staking-cli integration tests (NodeSignatures). pub mod signature; +pub(crate) mod transaction; +/// Used by staking-cli integration tests. #[cfg(feature = "testing")] pub mod deploy; -pub const DEV_MNEMONIC: &str = "test test test test test test test test test test test junk"; +pub use cli::run; -/// CLI to interact with the Espresso stake table contract +/// Used by staking-ui-service, sequencer tests, staking-cli integration tests. +pub const DEV_MNEMONIC: &str = "test test test test test test test test test test test junk"; +/// Private key for account index 0 derived from DEV_MNEMONIC. +/// +/// Used by staking-cli integration tests. +pub const DEV_PRIVATE_KEY: &str = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + +/// CLI to interact with the Espresso stake table contract. +/// +/// Used by staking-cli integration tests. #[derive(ClapSerde, Clone, Debug, Deserialize, Serialize)] #[command(version, about, long_about = None)] pub struct Config { @@ -61,6 +77,30 @@ pub struct Config { #[clap(flatten)] pub signer: SignerConfig, + /// Export calldata for multisig wallets instead of sending transaction. + #[clap( + long, + env = "EXPORT_CALLDATA", + action = ArgAction::SetTrue, + conflicts_with_all = ["mnemonic", "private_key", "ledger"] + )] + #[serde(skip)] + pub export_calldata: bool, + + /// Sender address for calldata export (required for simulation). + #[clap(long, env = "SENDER_ADDRESS")] + #[serde(skip)] + pub sender_address: Option

, + + /// Skip eth_call validation when exporting calldata. + #[clap(long, env = "SKIP_SIMULATION", action = ArgAction::SetTrue, requires = "export_calldata")] + #[serde(skip)] + pub skip_simulation: bool, + + #[clap(flatten)] + #[serde(skip)] + pub output: OutputArgs, + #[clap(flatten)] #[serde(skip)] pub logging: logging::Config, @@ -76,6 +116,10 @@ pub struct SignerConfig { #[clap(long, env = "MNEMONIC")] pub mnemonic: Option, + /// Raw private key (hex-encoded with or without 0x prefix). + #[clap(long, env = "PRIVATE_KEY")] + pub private_key: Option, + /// The mnemonic account index to use when deriving the key. #[clap(long, env = "ACCOUNT_INDEX")] #[default(Some(0))] @@ -95,6 +139,9 @@ pub enum ValidSignerConfig { mnemonic: String, account_index: u32, }, + PrivateKey { + private_key: String, + }, Ledger { account_index: usize, }, @@ -107,23 +154,25 @@ impl TryFrom for ValidSignerConfig { let account_index = config .account_index .ok_or_else(|| anyhow::anyhow!("Account index must be provided"))?; - if let Some(mnemonic) = config.mnemonic { + if config.ledger { + Ok(ValidSignerConfig::Ledger { + account_index: account_index as usize, + }) + } else if let Some(private_key) = config.private_key { + Ok(ValidSignerConfig::PrivateKey { private_key }) + } else if let Some(mnemonic) = config.mnemonic { Ok(ValidSignerConfig::Mnemonic { mnemonic, account_index, }) - } else if config.ledger { - Ok(ValidSignerConfig::Ledger { - account_index: account_index as usize, - }) } else { - bail!("Either mnemonic or --ledger flag must be provided") + bail!("Either --mnemonic, --private-key, or --ledger flag must be provided") } } } impl ValidSignerConfig { - pub async fn wallet(&self) -> Result<(EthereumWallet, Address)> { + pub async fn wallet(&self) -> Result { match self { ValidSignerConfig::Mnemonic { mnemonic, @@ -133,15 +182,15 @@ impl ValidSignerConfig { .phrase(mnemonic) .index(*account_index)? .build()?; - let account = signer.address(); - let wallet = EthereumWallet::from(signer); - Ok((wallet, account)) + Ok(EthereumWallet::from(signer)) + }, + ValidSignerConfig::PrivateKey { private_key } => { + let signer: PrivateKeySigner = private_key.parse()?; + Ok(EthereumWallet::from(signer)) }, ValidSignerConfig::Ledger { account_index } => { let signer = connect_ledger(*account_index).await?; - let account = signer.get_address().await?; - let wallet = EthereumWallet::from(signer); - Ok((wallet, account)) + Ok(EthereumWallet::from(signer)) }, } } @@ -180,6 +229,18 @@ impl Default for Commands { } } +impl Commands { + pub(crate) fn needs_token_address(&self) -> bool { + matches!( + self, + Commands::Approve { .. } + | Commands::Transfer { .. } + | Commands::TokenBalance { .. } + | Commands::TokenAllowance { .. } + ) + } +} + impl Config { pub fn apply_env_var_overrides(self) -> Result { let mut config = self.clone(); @@ -206,15 +267,19 @@ pub enum Commands { /// Initialize the config file with deployment and wallet info. Init { /// The mnemonic to use when deriving the key. - #[clap(long, env = "MNEMONIC", required_unless_present = "ledger")] + #[clap(long, env = "MNEMONIC", required_unless_present_any = ["ledger", "private_key"])] mnemonic: Option, - /// The mnemonic account index to use when deriving the key. + /// Raw private key (hex-encoded with or without 0x prefix). + #[clap(long, env = "PRIVATE_KEY", required_unless_present_any = ["ledger", "mnemonic"], conflicts_with = "account_index")] + private_key: Option, + + /// The account index for key derivation (only used with mnemonic or ledger). #[clap(long, env = "ACCOUNT_INDEX", default_value_t = 0)] account_index: u32, - /// The ledger account index to use when deriving the key. - #[clap(long, env = "LEDGER_INDEX", required_unless_present = "mnemonic")] + /// Use a ledger hardware wallet. + #[clap(long, env = "LEDGER_INDEX", required_unless_present_any = ["mnemonic", "private_key"])] ledger: bool, }, /// Remove the config file. @@ -299,7 +364,7 @@ pub enum Commands { validator_address: Address, }, /// Claim staking rewards. - ClaimRewards, + ClaimRewards {}, /// Check unclaimed staking rewards. UnclaimedRewards { /// The address to check. diff --git a/staking-cli/src/main.rs b/staking-cli/src/main.rs index 752dff539d9..af4f4a8d498 100644 --- a/staking-cli/src/main.rs +++ b/staking-cli/src/main.rs @@ -1,504 +1,6 @@ -#![doc = include_str!("../../README.md")] -use std::path::PathBuf; - -use alloy::{ - self, - eips::BlockId, - primitives::{utils::format_ether, Address, U256}, - providers::{Provider, ProviderBuilder}, - rpc::types::Log, - sol_types::SolEventInterface, -}; use anyhow::Result; -use clap::Parser; -use clap_serde_derive::ClapSerde; -use hotshot_contract_adapter::{ - evm::DecodeRevert as _, - sol_types::{ - EspToken::{self, EspTokenErrors, EspTokenEvents}, - RewardClaim::RewardClaimEvents, - StakeTableV2::StakeTableV2Events, - }, -}; -use hotshot_types::{ - light_client::{StateKeyPair, StateVerKey}, - signature_key::BLSPubKey, -}; -use staking_cli::{ - claim::{claim_reward, claim_validator_exit, claim_withdrawal, unclaimed_rewards}, - delegation::{approve, delegate, undelegate}, - demo::stake_for_demo, - info::{display_stake_table, fetch_token_address, stake_table_info}, - output::{output_error, output_success}, - registration::{ - deregister_validator, register_validator, update_commission, update_consensus_keys, - update_metadata_uri, - }, - signature::{NodeSignatureDestination, NodeSignatureInput, NodeSignatures}, - Commands, Config, ValidSignerConfig, -}; -use sysinfo::System; - -fn format_esp(value: U256) -> String { - let formatted = format_ether(value); - let trimmed = formatted.trim_end_matches('0').trim_end_matches('.'); - format!("{} ESP", trimmed) -} - -#[derive(Parser)] -#[command(version, about, long_about = None)] -struct Cli { - /// Optional name to operate on - name: Option, - - /// Sets a custom config file - #[arg(short, long, value_name = "FILE")] - config: Option, - - #[command(subcommand)] - command: Option, -} - -#[derive(Parser)] -#[command(author, version, about)] -struct Args { - /// Config file - #[arg(short, long = "config")] - config_path: Option, - - /// Rest of arguments - #[command(flatten)] - pub config: ::Opt, -} - -impl Args { - fn config_path(&self) -> PathBuf { - // If the user provided a config path, use it. - self.config_path.clone().unwrap_or_else(|| { - // Otherwise create a config.toml in a platform specific config directory. - // - // (empty) qualifier, espresso organization, and application name - // see more - let project_dir = - directories::ProjectDirs::from("", "espresso", "espresso-staking-cli"); - let basename = "config.toml"; - if let Some(project_dir) = project_dir { - project_dir.config_dir().to_path_buf().join(basename) - } else { - // In the unlikely case that we can't find the config directory, - // create the config file in the current directory and issue a - // warning. - tracing::warn!("Unable to find config directory, using current directory"); - basename.into() - } - }) - } - - fn config_dir(&self) -> PathBuf { - if let Some(path) = self.config_path().parent() { - path.to_path_buf() - } else { - // Try to use the current directory - PathBuf::from(".") - } - } -} - -fn exit_err(msg: impl AsRef, err: impl core::fmt::Display) -> ! { - output_error(format!("{}: {err}", msg.as_ref())) -} - -fn exit(msg: impl AsRef) -> ! { - output_error(format!("Error: {}", msg.as_ref())) -} - -// Events containing custom structs do not get the Debug derive, due to a bug in -// foundry. We instead format those types nicely with tagged base64. -fn decode_and_display_logs(logs: &[Log]) { - for log in logs { - if let Ok(decoded) = StakeTableV2Events::decode_log(log.as_ref()) { - match &decoded.data { - StakeTableV2Events::ValidatorRegistered(e) => output_success(format!( - "event: ValidatorRegistered {{ account: {}, blsVk: {}, schnorrVk: {}, \ - commission: {} }}", - e.account, - BLSPubKey::from(e.blsVk), - StateVerKey::from(e.schnorrVk), - e.commission - )), - StakeTableV2Events::ValidatorRegisteredV2(e) => output_success(format!( - "event: ValidatorRegisteredV2 {{ account: {}, blsVK: {}, schnorrVK: {}, \ - commission: {}, metadataUri: {} }}", - e.account, - BLSPubKey::from(e.blsVK), - StateVerKey::from(e.schnorrVK), - e.commission, - e.metadataUri - )), - StakeTableV2Events::Delegated(e) => output_success(format!("event: {e:?}")), - StakeTableV2Events::Undelegated(e) => output_success(format!("event: {e:?}")), - StakeTableV2Events::UndelegatedV2(e) => output_success(format!("event: {e:?}")), - StakeTableV2Events::ValidatorExit(e) => output_success(format!("event: {e:?}")), - StakeTableV2Events::ValidatorExitV2(e) => output_success(format!("event: {e:?}")), - StakeTableV2Events::ConsensusKeysUpdated(e) => output_success(format!( - "event: ConsensusKeysUpdated {{ account: {}, blsVK: {}, schnorrVK: {} }}", - e.account, - BLSPubKey::from(e.blsVK), - StateVerKey::from(e.schnorrVK) - )), - StakeTableV2Events::ConsensusKeysUpdatedV2(e) => output_success(format!( - "event: ConsensusKeysUpdatedV2 {{ account: {}, blsVK: {}, schnorrVK: {} }}", - e.account, - BLSPubKey::from(e.blsVK), - StateVerKey::from(e.schnorrVK) - )), - StakeTableV2Events::CommissionUpdated(e) => output_success(format!("event: {e:?}")), - StakeTableV2Events::MetadataUriUpdated(e) => output_success(format!( - "event: MetadataUriUpdated {{ validator: {}, metadataUri: {} }}", - e.validator, e.metadataUri - )), - StakeTableV2Events::Withdrawal(e) => output_success(format!("event: {e:?}")), - StakeTableV2Events::WithdrawalClaimed(e) => output_success(format!("event: {e:?}")), - StakeTableV2Events::ValidatorExitClaimed(e) => { - output_success(format!("event: {e:?}")) - }, - - _ => {}, - } - } else if let Ok(decoded) = EspTokenEvents::decode_log(log.as_ref()) { - match &decoded.data { - EspTokenEvents::Transfer(e) => output_success(format!("event: {e:?}")), - EspTokenEvents::Approval(e) => output_success(format!("event: {e:?}")), - _ => {}, - } - } else if let Ok(decoded) = RewardClaimEvents::decode_log(log.as_ref()) { - if let RewardClaimEvents::RewardsClaimed(e) = &decoded.data { - output_success(format!("event: {e:?}")); - } - } - } -} #[tokio::main] -pub async fn main() -> Result<()> { - let mut cli = Args::parse(); - - // initialize the logging ASAP so we don't accidentally hide any messages. - cli.config.logging.clone().unwrap_or_default().init(); - - let config_path = cli.config_path(); - // Get config file - let config = if let Ok(f) = std::fs::read_to_string(&config_path) { - // parse toml - match toml::from_str::(&f) { - Ok(config) => config.merge(&mut cli.config), - Err(err) => { - // This is a user error print the hopefully helpful error - // message without backtrace and exit. - exit_err( - format!("Error in configuration file at {}", config_path.display()), - err, - ); - }, - } - } else { - // If there is no config file return only config parsed from clap - Config::from(&mut cli.config) - }; - - if config.token_address.is_some() { - tracing::warn!("The `--token_address` argument is no longer necessary , and ignored"); - }; - - // Run the init command first because config values required by other - // commands are not present. - match config.commands { - Commands::Init { - mnemonic, - account_index, - ledger, - } => { - let mut config = toml::from_str::(include_str!("../config.decaf.toml"))?; - config.signer.mnemonic = mnemonic; - config.signer.account_index = Some(account_index); - config.signer.ledger = ledger; - - // Create directory where config file will be saved - std::fs::create_dir_all(cli.config_dir()).unwrap_or_else(|err| { - exit_err("failed to create config directory", err); - }); - - // Save the config file - std::fs::write(&config_path, toml::to_string(&config)?) - .unwrap_or_else(|err| exit_err("failed to write config file", err)); - - println!("New config file saved to {}", config_path.display()); - return Ok(()); - }, - Commands::Purge { force } => { - // Check if the file exists - if !config_path.exists() { - println!("Config file not found at {}", config_path.display()); - return Ok(()); - } - if !force { - // Get a confirmation from the user before removing the config file. - println!( - "Are you sure you want to remove the config file at {}? [y/N]", - config_path.display() - ); - let mut input = String::new(); - std::io::stdin().read_line(&mut input).unwrap(); - if !input.trim().to_lowercase().starts_with('y') { - println!("Aborted"); - return Ok(()); - } - } - // Remove the config file - std::fs::remove_file(&config_path).unwrap_or_else(|err| { - exit_err("failed to remove config file", err); - }); - - println!("Config file removed from {}", config_path.display()); - return Ok(()); - }, - Commands::Config => { - println!("Config file at {}\n", config_path.display()); - let mut config = config; - config.signer.mnemonic = config.signer.mnemonic.map(|_| "***".to_string()); - println!("{}", toml::to_string_pretty(&config)?); - return Ok(()); - }, - Commands::Version => { - println!("staking-cli version: {}", env!("CARGO_PKG_VERSION")); - println!("{}", git_version::git_version!(prefix = "git rev: ")); - println!("OS: {}", System::long_os_version().unwrap_or_default()); - println!("Arch: {}", System::cpu_arch()); - return Ok(()); - }, - Commands::ExportNodeSignatures { - address, - consensus_private_key, - state_private_key, - output_args, - } => { - let destination = NodeSignatureDestination::try_from(output_args)?; - - let payload = NodeSignatures::create( - address, - &consensus_private_key.into(), - &StateKeyPair::from_sign_key(state_private_key), - ); - - payload.handle_output(destination)?; - return Ok(()); - }, - _ => {}, // Other commands handled after shared setup. - } - - // When the staking CLI is used for our testnet, the env var names are different. - let config = config.apply_env_var_overrides()?; - - // Commands that don't need a signer - if let Commands::StakeTable { - l1_block_number, - compact, - } = config.commands - { - let provider = ProviderBuilder::new().connect_http(config.rpc_url.clone()); - let query_block = l1_block_number.unwrap_or(BlockId::latest()); - let l1_block = provider.get_block(query_block).await?.unwrap_or_else(|| { - exit_err("Failed to get block {query_block}", "Block not found"); - }); - let l1_block_resolved = l1_block.header.number; - tracing::info!("Getting stake table info at block {l1_block_resolved}"); - let stake_table = stake_table_info( - config.rpc_url.clone(), - config.stake_table_address, - l1_block_resolved, - ) - .await?; - display_stake_table(stake_table, compact)?; - return Ok(()); - } - - let (wallet, account) = TryInto::::try_into(config.signer.clone())? - .wallet() - .await?; - if let Commands::Account = config.commands { - println!("{account}"); - return Ok(()); - }; - - // Clap serde will put default value if they aren't set. We check some - // common configuration mistakes. - if config.stake_table_address == Address::ZERO { - exit("Stake table address is not set use --stake-table-address or STAKE_TABLE_ADDRESS") - }; - - let provider = ProviderBuilder::new() - .wallet(wallet.clone()) - .connect_http(config.rpc_url.clone()); - let stake_table_addr = config.stake_table_address; - let token_addr = fetch_token_address(config.rpc_url.clone(), stake_table_addr).await?; - let token = EspToken::new(token_addr, &provider); - - // Command that just read from chain, do not require a balance - match config.commands { - Commands::TokenBalance { address } => { - let address = address.unwrap_or(account); - let balance = format_ether(token.balanceOf(address).call().await?); - output_success(format!("Token balance for {address}: {balance} ESP")); - return Ok(()); - }, - Commands::TokenAllowance { owner } => { - let owner = owner.unwrap_or(account); - let allowance = format_ether( - token - .allowance(owner, config.stake_table_address) - .call() - .await?, - ); - output_success(format!( - "Stake table token allowance for {owner}: {allowance} ESP" - )); - return Ok(()); - }, - Commands::UnclaimedRewards { address } => { - let address = address.unwrap_or(account); - let espresso_url = config.espresso_url.ok_or_else(|| { - anyhow::anyhow!("espresso_url not set, use --espresso-url or ESPRESSO_URL") - })?; - let unclaimed = - unclaimed_rewards(&provider, config.stake_table_address, espresso_url, address) - .await - .unwrap_or_else(|err| { - exit_err("Failed to check unclaimed rewards", err); - }); - println!("{}", format_esp(unclaimed)); - return Ok(()); - }, - _ => { - // Continue with the rest of the commands that require a signer - }, - }; - - // Check that our Ethereum balance isn't zero before proceeding. - let balance = provider.get_balance(account).await?; - if balance.is_zero() { - exit(format!( - "zero Ethereum balance for account {account}, please fund account" - )); - } - - // Commands that require a signer - let pending_tx_result = match config.commands { - Commands::RegisterValidator { - signature_args, - commission, - metadata_uri_args, - } => { - let input = NodeSignatureInput::try_from((signature_args, &wallet))?; - let payload = NodeSignatures::try_from((input, &wallet))?; - let metadata_uri = metadata_uri_args.try_into()?; - register_validator( - &provider, - stake_table_addr, - commission, - metadata_uri, - payload, - ) - .await - }, - Commands::UpdateConsensusKeys { signature_args } => { - tracing::info!("Updating validator {account} with new keys"); - let input = NodeSignatureInput::try_from((signature_args, &wallet))?; - let payload = NodeSignatures::try_from((input, &wallet))?; - update_consensus_keys(&provider, stake_table_addr, payload).await - }, - Commands::DeregisterValidator {} => { - tracing::info!("Deregistering validator {account}"); - deregister_validator(&provider, stake_table_addr).await - }, - Commands::UpdateCommission { new_commission } => { - tracing::info!("Updating validator {account} commission to {new_commission}"); - update_commission(&provider, stake_table_addr, new_commission).await - }, - Commands::UpdateMetadataUri { metadata_uri_args } => { - tracing::info!("Updating validator {account} metadata URI"); - let metadata_uri = metadata_uri_args.try_into()?; - update_metadata_uri(&provider, stake_table_addr, metadata_uri).await - }, - Commands::Approve { amount } => { - approve(&provider, token_addr, stake_table_addr, amount).await - }, - Commands::Delegate { - validator_address, - amount, - } => delegate(&provider, stake_table_addr, validator_address, amount).await, - Commands::Undelegate { - validator_address, - amount, - } => undelegate(&provider, stake_table_addr, validator_address, amount).await, - Commands::ClaimWithdrawal { validator_address } => { - tracing::info!("Claiming withdrawal for {validator_address}"); - claim_withdrawal(&provider, stake_table_addr, validator_address).await - }, - Commands::ClaimValidatorExit { validator_address } => { - tracing::info!("Claiming validator exit for {validator_address}"); - claim_validator_exit(&provider, stake_table_addr, validator_address).await - }, - Commands::ClaimRewards => { - let espresso_url = config.espresso_url.ok_or_else(|| { - anyhow::anyhow!("espresso_url not set, use --espresso-url or ESPRESSO_URL") - })?; - tracing::info!("Claiming rewards from {espresso_url}"); - claim_reward(&provider, stake_table_addr, espresso_url, account).await - }, - Commands::StakeForDemo { - num_validators, - num_delegators_per_validator, - delegation_config, - } => { - tracing::info!( - "Staking for demo with {num_validators} validators and config {delegation_config}" - ); - stake_for_demo( - &config, - num_validators, - num_delegators_per_validator, - delegation_config, - ) - .await - .unwrap(); - return Ok(()); - }, - Commands::Transfer { amount, to } => { - let amount_esp = format_ether(amount); - tracing::info!("Transferring {amount_esp} ESP to {to}"); - token - .transfer(to, amount) - .send() - .await - .maybe_decode_revert::() - }, - _ => unreachable!(), - }; - - let pending_tx = match pending_tx_result { - Ok(tx) => tx, - Err(err) => exit_err("Error", err), - }; - - match pending_tx.get_receipt().await { - Ok(receipt) => { - output_success(format!( - "Success! transaction hash: {}", - receipt.transaction_hash - )); - decode_and_display_logs(receipt.inner.logs()); - Ok(()) - }, - Err(err) => exit_err("Failed", err), - } +async fn main() -> Result<()> { + staking_cli::run().await } diff --git a/staking-cli/src/output.rs b/staking-cli/src/output.rs index e9a8cc08a07..9b2136df846 100644 --- a/staking-cli/src/output.rs +++ b/staking-cli/src/output.rs @@ -1,3 +1,15 @@ +use alloy::primitives::{utils::format_ether, Address, Bytes, U256}; +use anyhow::Result; +use serde::Serialize; + +use crate::signature::{OutputArgs, SerializationFormat}; + +pub(crate) fn format_esp(value: U256) -> String { + let formatted = format_ether(value); + let trimmed = formatted.trim_end_matches('0').trim_end_matches('.'); + format!("{} ESP", trimmed) +} + pub fn output_success(msg: impl AsRef) { if std::env::var("RUST_LOG_FORMAT") == Ok("json".to_string()) { tracing::info!("{}", msg.as_ref()); @@ -6,6 +18,14 @@ pub fn output_success(msg: impl AsRef) { } } +pub(crate) fn output_warn(msg: impl AsRef) { + if std::env::var("RUST_LOG_FORMAT") == Ok("json".to_string()) { + tracing::warn!("{}", msg.as_ref()); + } else { + eprintln!("{}", msg.as_ref()); + } +} + pub fn output_error(msg: impl AsRef) -> ! { if std::env::var("RUST_LOG_FORMAT") == Ok("json".to_string()) { tracing::error!("{}", msg.as_ref()); @@ -14,3 +34,41 @@ pub fn output_error(msg: impl AsRef) -> ! { } std::process::exit(1); } + +#[derive(Serialize)] +pub(crate) struct CalldataInfo { + to: Address, + data: Bytes, + /// Included because Safe UI requires this field, even when value is 0. + value: U256, +} + +impl CalldataInfo { + pub(crate) fn new(to: Address, data: Bytes) -> Self { + Self { + to, + data, + value: U256::ZERO, + } + } +} + +pub(crate) fn output_calldata(info: &CalldataInfo, output: &OutputArgs) -> Result<()> { + let text = match output.format { + Some(SerializationFormat::Json) => serde_json::to_string_pretty(info)?, + Some(SerializationFormat::Toml) => toml::to_string_pretty(info)?, + None => format!( + "Target: {}\nCalldata: {}\nValue: {}", + info.to, info.data, info.value + ), + }; + + if let Some(path) = &output.output { + std::fs::write(path, &text)?; + output_success(format!("Calldata written to {}", path.display())); + } else { + output_success(&text); + } + + Ok(()) +} diff --git a/staking-cli/src/registration.rs b/staking-cli/src/registration.rs index 43fd2196af5..49d3a277c5e 100644 --- a/staking-cli/src/registration.rs +++ b/staking-cli/src/registration.rs @@ -10,109 +10,11 @@ use hotshot_contract_adapter::{ stake_table::StakeTableContractVersion, }; -use crate::{ - metadata::MetadataUri, - parse::Commission, - signature::{NodeSignatures, NodeSignaturesSol}, -}; - -pub async fn register_validator( - provider: impl Provider, - stake_table_addr: Address, - commission: Commission, - metadata_uri: MetadataUri, - payload: NodeSignatures, -) -> Result> { - tracing::info!( - "register validator {} with commission {commission}", - payload.address - ); - // NOTE: the StakeTableV2 ABI is a superset of the V1 ABI because the V2 inherits from V1 so we - // can always use the V2 bindings for calling functions and decoding events, even if we are - // connected to the V1 contract. - let stake_table = StakeTableV2::new(stake_table_addr, provider); - let sol_payload = NodeSignaturesSol::from(payload); - - let version = stake_table.getVersion().call().await?.try_into()?; - // There is a race-condition here if the contract is upgraded while this transactions is waiting - // to be mined. We're very unlikely to hit this in practice, and since we only perform the - // upgrade on decaf this is acceptable. - Ok(match version { - StakeTableContractVersion::V1 => stake_table - .registerValidator( - sol_payload.bls_vk, - sol_payload.schnorr_vk, - sol_payload.bls_signature.into(), - commission.to_evm(), - ) - .send() - .await - .maybe_decode_revert::()?, - StakeTableContractVersion::V2 => stake_table - .registerValidatorV2( - sol_payload.bls_vk, - sol_payload.schnorr_vk, - sol_payload.bls_signature.into(), - sol_payload.schnorr_signature.into(), - commission.to_evm(), - metadata_uri.to_string(), - ) - .send() - .await - .maybe_decode_revert::()?, - }) -} - -pub async fn update_consensus_keys( - provider: impl Provider, - stake_table_addr: Address, - payload: NodeSignatures, -) -> Result> { - // NOTE: the StakeTableV2 ABI is a superset of the V1 ABI because the V2 inherits from V1 so we - // can always use the V2 bindings for calling functions and decoding events, even if we are - // connected to the V1 contract. - let stake_table = StakeTableV2::new(stake_table_addr, provider); - let sol_payload = NodeSignaturesSol::from(payload); - - // There is a race-condition here if the contract is upgraded while this transactions is waiting - // to be mined. We're very unlikely to hit this in practice, and since we only perform the - // upgrade on decaf this is acceptable. - let version = stake_table.getVersion().call().await?.try_into()?; - Ok(match version { - StakeTableContractVersion::V1 => stake_table - .updateConsensusKeys( - sol_payload.bls_vk, - sol_payload.schnorr_vk, - sol_payload.bls_signature.into(), - ) - .send() - .await - .maybe_decode_revert::()?, - StakeTableContractVersion::V2 => stake_table - .updateConsensusKeysV2( - sol_payload.bls_vk, - sol_payload.schnorr_vk, - sol_payload.bls_signature.into(), - sol_payload.schnorr_signature.into(), - ) - .send() - .await - .maybe_decode_revert::()?, - }) -} - -pub async fn deregister_validator( - provider: impl Provider, - stake_table_addr: Address, -) -> Result> { - let stake_table = StakeTableV2::new(stake_table_addr, provider); - stake_table - .deregisterValidator() - .send() - .await - .maybe_decode_revert::() -} +use crate::parse::Commission; +/// Update validator commission rate. +/// +/// Used by sequencer tests. pub async fn update_commission( provider: impl Provider, stake_table_addr: Address, @@ -126,19 +28,9 @@ pub async fn update_commission( .maybe_decode_revert::() } -pub async fn update_metadata_uri( - provider: impl Provider, - stake_table_addr: Address, - metadata_uri: MetadataUri, -) -> Result> { - let stake_table = StakeTableV2::new(stake_table_addr, provider); - stake_table - .updateMetadataUri(metadata_uri.to_string()) - .send() - .await - .maybe_decode_revert::() -} - +/// Fetch validator commission rate. +/// +/// Used by sequencer tests. pub async fn fetch_commission( provider: impl Provider, stake_table_addr: Address, @@ -160,20 +52,25 @@ pub async fn fetch_commission( #[cfg(test)] mod test { use alloy::{primitives::U256, providers::WalletProvider as _}; + use anyhow::Result; use espresso_contract_deployer::build_provider; use espresso_types::{ v0_3::{Fetcher, StakeTableEvent}, L1Client, }; use hotshot_contract_adapter::{ - sol_types::{EdOnBN254PointSol, G1PointSol, G2PointSol}, + evm::DecodeRevert as _, + sol_types::{EdOnBN254PointSol, G1PointSol, G2PointSol, StakeTableV2::StakeTableV2Errors}, stake_table::{sign_address_bls, sign_address_schnorr, StateSignatureSol}, }; use rand::{rngs::StdRng, SeedableRng as _}; use rstest::rstest; use super::*; - use crate::{deploy::TestSystem, receipt::ReceiptExt}; + use crate::{ + deploy::TestSystem, metadata::MetadataUri, receipt::ReceiptExt as _, + signature::NodeSignatures, transaction::Transaction, + }; #[tokio::test] async fn test_register_validator() -> Result<()> { @@ -186,13 +83,14 @@ mod test { ); let metadata_uri = "https://example.com/metadata".parse()?; - let receipt = register_validator( - &system.provider, - system.stake_table, - system.commission, + let receipt = Transaction::RegisterValidator { + stake_table: system.stake_table, + commission: system.commission, metadata_uri, payload, - ) + version: StakeTableContractVersion::V2, + } + .send(&system.provider) .await? .assert_success() .await?; @@ -219,10 +117,13 @@ mod test { let system = TestSystem::deploy_version(version).await?; system.register_validator().await?; - let receipt = deregister_validator(&system.provider, system.stake_table) - .await? - .assert_success() - .await?; + let receipt = Transaction::DeregisterValidator { + stake_table: system.stake_table, + } + .send(&system.provider) + .await? + .assert_success() + .await?; match version { StakeTableContractVersion::V1 => { @@ -258,10 +159,15 @@ mod test { let (_, new_bls, new_schnorr) = TestSystem::gen_keys(&mut rng); let payload = NodeSignatures::create(validator_address, &new_bls, &new_schnorr); - let receipt = update_consensus_keys(&system.provider, system.stake_table, payload) - .await? - .assert_success() - .await?; + let receipt = Transaction::UpdateConsensusKeys { + stake_table: system.stake_table, + payload, + version: StakeTableContractVersion::V2, + } + .send(&system.provider) + .await? + .assert_success() + .await?; let event = receipt .decoded_log::() @@ -296,10 +202,14 @@ mod test { // Wait 2 seconds to ensure we're past the interval system.anvil_increase_time(U256::from(2)).await?; - let receipt = update_commission(&system.provider, system.stake_table, new_commission) - .await? - .assert_success() - .await?; + let receipt = Transaction::UpdateCommission { + stake_table: system.stake_table, + new_commission, + } + .send(&system.provider) + .await? + .assert_success() + .await?; let event = receipt .decoded_log::() @@ -455,10 +365,14 @@ mod test { system.register_validator().await?; let new_uri: MetadataUri = "https://example.com/updated".parse()?; - let receipt = update_metadata_uri(&system.provider, system.stake_table, new_uri.clone()) - .await? - .assert_success() - .await?; + let receipt = Transaction::UpdateMetadataUri { + stake_table: system.stake_table, + metadata_uri: new_uri.clone(), + } + .send(&system.provider) + .await? + .assert_success() + .await?; let event = receipt .decoded_log::() @@ -480,13 +394,14 @@ mod test { ); let metadata_uri = MetadataUri::empty(); - let receipt = register_validator( - &system.provider, - system.stake_table, - system.commission, + let receipt = Transaction::RegisterValidator { + stake_table: system.stake_table, + commission: system.commission, metadata_uri, payload, - ) + version: StakeTableContractVersion::V2, + } + .send(&system.provider) .await? .assert_success() .await?; @@ -507,10 +422,14 @@ mod test { system.register_validator().await?; let metadata_uri = MetadataUri::empty(); - let receipt = update_metadata_uri(&system.provider, system.stake_table, metadata_uri) - .await? - .assert_success() - .await?; + let receipt = Transaction::UpdateMetadataUri { + stake_table: system.stake_table, + metadata_uri, + } + .send(&system.provider) + .await? + .assert_success() + .await?; let event = receipt .decoded_log::() diff --git a/staking-cli/src/signature.rs b/staking-cli/src/signature.rs index 29797320b21..ce08a17faef 100644 --- a/staking-cli/src/signature.rs +++ b/staking-cli/src/signature.rs @@ -9,10 +9,7 @@ use alloy::{ }; use anyhow::bail; use clap::Args; -use hotshot_contract_adapter::{ - sol_types::{EdOnBN254PointSol, G1PointSol, G2PointSol}, - stake_table::{self, StateSignatureSol}, -}; +use hotshot_contract_adapter::stake_table; use hotshot_types::{ light_client::{StateKeyPair, StateSignature, StateVerKey}, signature_key::BLSPubKey, @@ -39,21 +36,6 @@ pub struct NodeSignatures { pub schnorr_signature: StateSignature, } -/// Only used for serialization to Solidity. -#[derive(Clone, Debug)] -pub struct NodeSignaturesSol { - /// The Ethereum address that was signed - pub address: Address, - /// BLS verification key - pub bls_vk: G2PointSol, - /// BLS signature over the address - pub bls_signature: G1PointSol, - /// Schnorr verification key - pub schnorr_vk: EdOnBN254PointSol, - /// Schnorr signature over the address - pub schnorr_signature: StateSignatureSol, -} - /// Represents either keys for signing or a pre-prepared node signature source #[allow(clippy::large_enum_variant)] pub enum NodeSignatureInput { @@ -122,7 +104,7 @@ pub struct NodeSignatureArgs { } /// Clap arguments for output operations -#[derive(Args, Clone, Debug)] +#[derive(Args, Clone, Debug, Default)] pub struct OutputArgs { /// Output file path. If not specified, outputs to stdout #[clap(long)] @@ -149,18 +131,6 @@ impl TryFrom<&Path> for SerializationFormat { } } -impl From for NodeSignaturesSol { - fn from(payload: NodeSignatures) -> Self { - Self { - address: payload.address, - bls_vk: payload.bls_vk.to_affine().into(), - bls_signature: payload.bls_signature.into(), - schnorr_vk: payload.schnorr_vk.into(), - schnorr_signature: payload.schnorr_signature.into(), - } - } -} - impl NodeSignatures { /// Create NodeSignatures by signing an Ethereum address with BLS and Schnorr keys pub fn create( @@ -306,13 +276,10 @@ impl TryFrom for NodeSignatures { } } -impl TryFrom<(NodeSignatureArgs, &EthereumWallet)> for NodeSignatureInput { +impl TryFrom<(NodeSignatureArgs, Option
)> for NodeSignatureInput { type Error = anyhow::Error; - fn try_from((args, wallet): (NodeSignatureArgs, &EthereumWallet)) -> anyhow::Result { - let wallet_address = - >::default_signer_address(wallet); - + fn try_from((args, address): (NodeSignatureArgs, Option
)) -> anyhow::Result { if let Some(sig_path) = args.node_signatures { let source = NodeSignatureSource::parse(sig_path, args.format)?; Ok(Self::PreparedPayload(source)) @@ -323,9 +290,12 @@ impl TryFrom<(NodeSignatureArgs, &EthereumWallet)> for NodeSignatureInput { let Some(state_key) = args.state_private_key else { bail!("state_private_key is required when not using node_signatures") }; + let Some(address) = address else { + bail!("address is required when using direct keys") + }; Ok(Self::Keys { - address: wallet_address, + address, bls_key_pair: bls_key.into(), schnorr_key_pair: StateKeyPair::from_sign_key(state_key), }) diff --git a/staking-cli/src/transaction.rs b/staking-cli/src/transaction.rs new file mode 100644 index 00000000000..6232dbe9ad9 --- /dev/null +++ b/staking-cli/src/transaction.rs @@ -0,0 +1,371 @@ +//! State-changing operations for staking. +//! +//! The [`Transaction`] enum provides a single dispatch point for all state-changing operations. +//! Each variant uses the same [`Transaction::calldata`] method for both execute and export modes, +//! ensuring identical calldata generation. + +use alloy::{ + network::Ethereum, + primitives::{Address, Bytes, U256}, + providers::{PendingTransactionBuilder, Provider}, + rpc::types::{TransactionInput, TransactionRequest}, + sol_types::SolCall, +}; +use anyhow::{bail, Context, Result}; +use hotshot_contract_adapter::{ + evm::DecodeRevert, + sol_types::{ + EdOnBN254PointSol, + EspToken::{approveCall, transferCall, EspTokenErrors}, + G1PointSol, G2PointSol, + RewardClaim::{claimRewardsCall, RewardClaimErrors}, + StakeTableV2::{ + claimValidatorExitCall, claimWithdrawalCall, delegateCall, deregisterValidatorCall, + registerValidatorCall, registerValidatorV2Call, undelegateCall, updateCommissionCall, + updateConsensusKeysCall, updateConsensusKeysV2Call, updateMetadataUriCall, + StakeTableV2Errors, + }, + }, + stake_table::{StakeTableContractVersion, StateSignatureSol}, +}; + +use crate::{ + metadata::MetadataUri, output::format_esp, parse::Commission, signature::NodeSignatures, +}; + +#[derive(Clone)] +pub enum Transaction { + Approve { + token: Address, + spender: Address, + amount: U256, + }, + Delegate { + stake_table: Address, + validator: Address, + amount: U256, + }, + Undelegate { + stake_table: Address, + validator: Address, + amount: U256, + }, + ClaimWithdrawal { + stake_table: Address, + validator: Address, + }, + ClaimValidatorExit { + stake_table: Address, + validator: Address, + }, + ClaimRewards { + reward_claim: Address, + lifetime_rewards: U256, + auth_data: Bytes, + }, + RegisterValidator { + stake_table: Address, + commission: Commission, + metadata_uri: MetadataUri, + payload: NodeSignatures, + version: StakeTableContractVersion, + }, + UpdateConsensusKeys { + stake_table: Address, + payload: NodeSignatures, + version: StakeTableContractVersion, + }, + DeregisterValidator { + stake_table: Address, + }, + UpdateCommission { + stake_table: Address, + new_commission: Commission, + }, + UpdateMetadataUri { + stake_table: Address, + metadata_uri: MetadataUri, + }, + Transfer { + token: Address, + to: Address, + amount: U256, + }, +} + +impl Transaction { + /// Returns the contract address and encoded calldata for this state change. + pub fn calldata(self) -> (Address, Bytes) { + match self { + Self::Approve { + token, + spender, + amount, + } => ( + token, + approveCall { + spender, + value: amount, + } + .abi_encode() + .into(), + ), + Self::Delegate { + stake_table, + validator, + amount, + } => ( + stake_table, + delegateCall { validator, amount }.abi_encode().into(), + ), + Self::Undelegate { + stake_table, + validator, + amount, + } => ( + stake_table, + undelegateCall { validator, amount }.abi_encode().into(), + ), + Self::ClaimWithdrawal { + stake_table, + validator, + } => ( + stake_table, + claimWithdrawalCall { validator }.abi_encode().into(), + ), + Self::ClaimValidatorExit { + stake_table, + validator, + } => ( + stake_table, + claimValidatorExitCall { validator }.abi_encode().into(), + ), + Self::ClaimRewards { + reward_claim, + lifetime_rewards, + auth_data, + } => ( + reward_claim, + claimRewardsCall { + lifetimeRewards: lifetime_rewards, + authData: auth_data, + } + .abi_encode() + .into(), + ), + Self::RegisterValidator { + stake_table, + commission, + metadata_uri, + payload, + version, + } => match version { + StakeTableContractVersion::V1 => ( + stake_table, + registerValidatorCall::from(( + G2PointSol::from(payload.bls_vk), + EdOnBN254PointSol::from(payload.schnorr_vk), + G1PointSol::from(payload.bls_signature).into(), + commission.to_evm(), + )) + .abi_encode() + .into(), + ), + StakeTableContractVersion::V2 => ( + stake_table, + registerValidatorV2Call::from(( + G2PointSol::from(payload.bls_vk), + EdOnBN254PointSol::from(payload.schnorr_vk), + G1PointSol::from(payload.bls_signature).into(), + StateSignatureSol::from(payload.schnorr_signature).into(), + commission.to_evm(), + metadata_uri.to_string(), + )) + .abi_encode() + .into(), + ), + }, + Self::UpdateConsensusKeys { + stake_table, + payload, + version, + } => match version { + StakeTableContractVersion::V1 => ( + stake_table, + updateConsensusKeysCall::from(( + G2PointSol::from(payload.bls_vk), + EdOnBN254PointSol::from(payload.schnorr_vk), + G1PointSol::from(payload.bls_signature).into(), + )) + .abi_encode() + .into(), + ), + StakeTableContractVersion::V2 => ( + stake_table, + updateConsensusKeysV2Call::from(( + G2PointSol::from(payload.bls_vk), + EdOnBN254PointSol::from(payload.schnorr_vk), + G1PointSol::from(payload.bls_signature).into(), + StateSignatureSol::from(payload.schnorr_signature).into(), + )) + .abi_encode() + .into(), + ), + }, + Self::DeregisterValidator { stake_table } => { + (stake_table, deregisterValidatorCall {}.abi_encode().into()) + }, + Self::UpdateCommission { + stake_table, + new_commission, + } => ( + stake_table, + updateCommissionCall { + newCommission: new_commission.to_evm(), + } + .abi_encode() + .into(), + ), + Self::UpdateMetadataUri { + stake_table, + metadata_uri, + } => ( + stake_table, + updateMetadataUriCall { + metadataUri: metadata_uri.to_string(), + } + .abi_encode() + .into(), + ), + Self::Transfer { token, to, amount } => ( + token, + transferCall { to, value: amount }.abi_encode().into(), + ), + } + } + + fn to_transaction_request(&self) -> TransactionRequest { + let (to, data) = self.clone().calldata(); + TransactionRequest::default() + .to(to) + .input(TransactionInput::new(data)) + } + + /// Validates the delegate amount against the minimum required by the contract. + /// + /// Currently only validates `Delegate` transactions; other variants pass through. + pub async fn validate_delegate_amount(&self, provider: &impl Provider) -> Result<()> { + if let Self::Delegate { + stake_table, + amount, + .. + } = self + { + use hotshot_contract_adapter::sol_types::StakeTableV2; + let st = StakeTableV2::new(*stake_table, provider); + let version: StakeTableContractVersion = st.getVersion().call().await?.try_into()?; + if let StakeTableContractVersion::V2 = version { + let min_amount = st.minDelegateAmount().call().await?; + if amount < &min_amount { + bail!( + "delegation amount {} is below minimum of {}", + format_esp(*amount), + format_esp(min_amount) + ); + } + } + } + Ok(()) + } + + fn decode_revert(&self, result: impl DecodeRevert) -> Result { + match self { + Self::Approve { .. } | Self::Transfer { .. } => { + result.maybe_decode_revert::() + }, + Self::ClaimRewards { .. } => result.maybe_decode_revert::(), + Self::Delegate { .. } + | Self::Undelegate { .. } + | Self::ClaimWithdrawal { .. } + | Self::ClaimValidatorExit { .. } + | Self::RegisterValidator { .. } + | Self::UpdateConsensusKeys { .. } + | Self::DeregisterValidator { .. } + | Self::UpdateCommission { .. } + | Self::UpdateMetadataUri { .. } => result.maybe_decode_revert::(), + } + } + + pub async fn simulate(&self, provider: &impl Provider, from: Address) -> Result<()> { + let tx = self.to_transaction_request().from(from); + let result = provider.call(tx).await; + self.decode_revert(result) + .context("Transaction simulation failed")?; + Ok(()) + } + + fn log_intent(&self) { + match self { + Self::Approve { + spender, amount, .. + } => { + tracing::info!("approve {} for {}", format_esp(*amount), spender); + }, + Self::Delegate { + validator, amount, .. + } => { + tracing::info!("delegate {} to {}", format_esp(*amount), validator); + }, + Self::Undelegate { + validator, amount, .. + } => { + tracing::info!("undelegate {} from {}", format_esp(*amount), validator); + }, + Self::ClaimWithdrawal { validator, .. } => { + tracing::info!("claiming withdrawal for {}", validator); + }, + Self::ClaimValidatorExit { validator, .. } => { + tracing::info!("claiming validator exit for {}", validator); + }, + Self::ClaimRewards { reward_claim, .. } => { + tracing::info!("claiming rewards from {}", reward_claim); + }, + Self::RegisterValidator { + payload, + commission, + .. + } => { + tracing::info!( + "register validator {} with commission {}", + payload.address, + commission + ); + }, + Self::UpdateConsensusKeys { payload, .. } => { + tracing::info!("updating consensus keys for {}", payload.address); + }, + Self::DeregisterValidator { .. } => { + tracing::info!("deregistering validator"); + }, + Self::UpdateCommission { new_commission, .. } => { + tracing::info!("updating commission to {}", new_commission); + }, + Self::UpdateMetadataUri { .. } => { + tracing::info!("updating metadata URI"); + }, + Self::Transfer { to, amount, .. } => { + tracing::info!("transferring {} to {}", format_esp(*amount), to); + }, + } + } + + pub async fn send( + &self, + provider: impl Provider, + ) -> Result> { + self.log_intent(); + let tx = self.to_transaction_request(); + let pending = provider.send_transaction(tx).await; + self.decode_revert(pending) + } +} diff --git a/staking-cli/tests/cli.rs b/staking-cli/tests/cli.rs index 781891fc06c..026c70c4aff 100644 --- a/staking-cli/tests/cli.rs +++ b/staking-cli/tests/cli.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use alloy::primitives::{ utils::{format_ether, parse_ether}, Address, U256, @@ -25,6 +27,7 @@ mod common; async fn stake_table_versions(#[case] _version: StakeTableContractVersion) {} const TEST_MNEMONIC: &str = "wool upset allow cheap purity craft hat cute below useful reject door"; +const TEST_PRIVATE_KEY: &str = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; #[test_log::test] fn test_cli_version() -> Result<()> { @@ -93,11 +96,23 @@ fn test_cli_create_file_ledger() -> anyhow::Result<()> { Ok(()) } -// TODO: ideally we would test that the decoding works for all the commands -#[test_log::test(rstest_reuse::apply(stake_table_versions))] -async fn test_cli_contract_revert(#[case] version: StakeTableContractVersion) -> Result<()> { - let system = TestSystem::deploy_version(version).await?; - let mut cmd = system.cmd(Signer::Mnemonic); +#[derive(Clone, Copy, Debug)] +enum ExecutionMode { + Simulate, + Execute, +} + +#[test_log::test(rstest::rstest)] +#[case::simulate(ExecutionMode::Simulate)] +#[case::execute(ExecutionMode::Execute)] +#[tokio::test] +async fn test_cli_transfer_error_decoding(#[case] mode: ExecutionMode) -> Result<()> { + let system = TestSystem::deploy().await?; + + let mut cmd = match mode { + ExecutionMode::Simulate => system.export_calldata_cmd(), + ExecutionMode::Execute => system.cmd(Signer::Mnemonic), + }; cmd.arg("transfer") .arg("--to") @@ -110,6 +125,29 @@ async fn test_cli_contract_revert(#[case] version: StakeTableContractVersion) -> Ok(()) } +#[test_log::test(rstest::rstest)] +#[case::simulate(ExecutionMode::Simulate)] +#[case::execute(ExecutionMode::Execute)] +#[tokio::test] +async fn test_cli_delegate_error_decoding(#[case] mode: ExecutionMode) -> Result<()> { + let system = TestSystem::deploy().await?; + + let mut cmd = match mode { + ExecutionMode::Simulate => system.export_calldata_cmd(), + ExecutionMode::Execute => system.cmd(Signer::Mnemonic), + }; + + cmd.arg("delegate") + .arg("--validator-address") + .arg(system.deployer_address.to_string()) + .arg("--amount") + .arg("100") + .assert() + .failure() + .stderr(str::contains("ValidatorInactive")); + Ok(()) +} + #[test_log::test(rstest::rstest)] #[tokio::test] async fn test_cli_register_validator( @@ -148,7 +186,7 @@ async fn test_cli_register_validator( .failure() .stderr(str::contains("zero Ethereum balance")); }, - Signer::Ledger => unreachable!(), + Signer::Ledger | Signer::PrivateKey => unreachable!(), }; Ok(()) @@ -465,7 +503,7 @@ async fn test_cli_balance(#[case] version: StakeTableContractVersion) -> Result< .assert() .success() .stdout(str::contains(system.deployer_address.to_string())) - .stdout(str::contains("3590000000.0")); + .stdout(str::contains("3590000000 ESP")); // Check balance of other address let addr = "0x1111111111111111111111111111111111111111"; @@ -476,7 +514,7 @@ async fn test_cli_balance(#[case] version: StakeTableContractVersion) -> Result< .assert() .success() .stdout(str::contains(addr)) - .stdout(str::contains(" 0.0")); + .stdout(str::contains("0 ESP")); Ok(()) } @@ -503,7 +541,7 @@ async fn test_cli_allowance(#[case] version: StakeTableContractVersion) -> Resul .assert() .success() .stdout(str::contains(system.deployer_address.to_string())) - .stdout(str::contains(format_ether(system.approval_amount))); + .stdout(str::contains("1000000 ESP")); // Check allowance of other address let addr = "0x1111111111111111111111111111111111111111".to_string(); @@ -514,7 +552,7 @@ async fn test_cli_allowance(#[case] version: StakeTableContractVersion) -> Resul .assert() .success() .stdout(str::contains(&addr)) - .stdout(str::contains(" 0.0")); + .stdout(str::contains("0 ESP")); Ok(()) } @@ -539,67 +577,69 @@ async fn test_cli_transfer(#[case] version: StakeTableContractVersion) -> Result Ok(()) } -#[test_log::test(tokio::test(flavor = "multi_thread"))] -async fn test_cli_claim_rewards() -> Result<()> { +#[test_log::test(rstest::rstest)] +#[case::no_balance(None)] +#[case::with_balance(Some(U256::from(1)))] +#[tokio::test(flavor = "multi_thread")] +async fn test_cli_claim_rewards(#[case] reward_balance: Option) -> Result<()> { let system = TestSystem::deploy_version(StakeTableContractVersion::V2).await?; - let reward_balance = U256::from(1000000); let balance_before = system.balance(system.deployer_address).await?; - let espresso_url = system.setup_reward_claim_mock(reward_balance).await?; + let espresso_url = match reward_balance { + Some(balance) => system.setup_reward_claim_mock(balance).await?, + None => system.setup_reward_claim_not_found_mock(), + }; - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + tokio::time::sleep(Duration::from_millis(100)).await; let mut cmd = system.cmd(Signer::Mnemonic); cmd.arg("--espresso-url") .arg(espresso_url.to_string()) - .arg("claim-rewards") - .assert() - .success() - .stdout(str::contains("RewardsClaimed")); + .arg("claim-rewards"); - let balance_after = system.balance(system.deployer_address).await?; - - assert_eq!(balance_after, balance_before + reward_balance,); + match reward_balance { + Some(balance) => { + cmd.assert() + .success() + .stdout(str::contains("RewardsClaimed")); + let balance_after = system.balance(system.deployer_address).await?; + assert_eq!(balance_after, balance_before + balance); + }, + None => { + cmd.assert() + .failure() + .stderr(str::contains("No reward claim data found")); + }, + } Ok(()) } -#[test_log::test(tokio::test(flavor = "multi_thread"))] -async fn test_cli_unclaimed_rewards() -> Result<()> { +#[test_log::test(rstest::rstest)] +#[case::no_balance(None, "0 ESP")] +#[case::with_balance(Some(U256::from(1)), "0.000000000000000001 ESP")] +#[tokio::test(flavor = "multi_thread")] +async fn test_cli_unclaimed_rewards( + #[case] reward_balance: Option, + #[case] expected_output: &str, +) -> Result<()> { let system = TestSystem::deploy_version(StakeTableContractVersion::V2).await?; - let reward_balance = U256::from(1000000); - let espresso_url = system.setup_reward_claim_mock(reward_balance).await?; - - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - - // Check unclaimed rewards before claiming - let mut cmd = system.cmd(Signer::Mnemonic); - cmd.arg("--espresso-url") - .arg(espresso_url.to_string()) - .arg("unclaimed-rewards") - .assert() - .success() - .stdout(str::contains("0.000000000001 ESP")); + let espresso_url = match reward_balance { + Some(balance) => system.setup_reward_claim_mock(balance).await?, + None => system.setup_reward_claim_not_found_mock(), + }; - // Claim the rewards - let mut cmd = system.cmd(Signer::Mnemonic); - cmd.arg("--espresso-url") - .arg(espresso_url.to_string()) - .arg("claim-rewards") - .assert() - .success() - .stdout(str::contains("RewardsClaimed")); + tokio::time::sleep(Duration::from_millis(100)).await; - // Check unclaimed rewards after claiming (should be 0) let mut cmd = system.cmd(Signer::Mnemonic); cmd.arg("--espresso-url") .arg(espresso_url.to_string()) .arg("unclaimed-rewards") .assert() .success() - .stdout(str::contains("0 ESP")); + .stdout(str::contains(expected_output)); Ok(()) } @@ -814,7 +854,7 @@ async fn test_cli_all_operations_manual_inspect( let espresso_url = if matches!(version, StakeTableContractVersion::V2) { let reward_balance = parse_ether("1.234")?; let url = system.setup_reward_claim_mock(reward_balance).await?; - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + tokio::time::sleep(Duration::from_millis(100)).await; Some(url) } else { None @@ -1058,3 +1098,593 @@ async fn test_cli_stake_for_demo_below_minimum() -> Result<()> { Ok(()) } + +#[test_log::test] +fn test_cli_create_config_file_private_key() -> anyhow::Result<()> { + let tmpdir = tempfile::tempdir()?; + let config_path = tmpdir.path().join("config.toml"); + + assert!(!config_path.exists()); + + base_cmd() + .arg("-c") + .arg(&config_path) + .arg("init") + .args(["--private-key", TEST_PRIVATE_KEY]) + .assert() + .success(); + + assert!(config_path.exists()); + + let config: Config = toml::de::from_str(&std::fs::read_to_string(&config_path)?)?; + assert_eq!( + config.signer.private_key, + Some(TEST_PRIVATE_KEY.to_string()) + ); + assert!(config.signer.mnemonic.is_none()); + assert!(!config.signer.ledger); + + Ok(()) +} + +#[test_log::test(tokio::test)] +async fn test_cli_register_validator_private_key() -> Result<()> { + let system = TestSystem::deploy().await?; + + system + .cmd(Signer::PrivateKey) + .arg("register-validator") + .arg("--consensus-private-key") + .arg(system.bls_private_key_str()?) + .arg("--state-private-key") + .arg(system.state_private_key_str()?) + .arg("--commission") + .arg("12.34") + .arg("--metadata-uri") + .arg("https://example.com/metadata") + .assert() + .success() + .stdout(str::contains("ValidatorRegistered")); + + Ok(()) +} + +#[test_log::test(tokio::test)] +async fn test_cli_export_calldata_delegate() -> Result<()> { + let system = TestSystem::deploy().await?; + system.register_validator().await?; + + system + .export_calldata_cmd() + .arg("delegate") + .arg("--validator-address") + .arg(system.deployer_address.to_string()) + .arg("--amount") + .arg("100") + .assert() + .success() + .stdout(str::contains("Target:")) + .stdout(str::contains("Calldata:")) + .stdout(str::contains("Value: 0")); + + Ok(()) +} + +#[test_log::test(tokio::test)] +async fn test_cli_export_calldata_json() -> Result<()> { + let system = TestSystem::deploy().await?; + system.register_validator().await?; + + let output = system + .export_calldata_cmd() + .arg("--format") + .arg("json") + .arg("delegate") + .arg("--validator-address") + .arg(system.deployer_address.to_string()) + .arg("--amount") + .arg("100") + .assert() + .success() + .get_output() + .stdout + .clone(); + + let json: serde_json::Value = serde_json::from_slice(&output)?; + assert!(json.get("to").is_some()); + assert!(json.get("data").is_some()); + assert!(json.get("value").is_some()); + + Ok(()) +} + +#[test_log::test(tokio::test)] +async fn test_cli_export_calldata_toml() -> Result<()> { + let system = TestSystem::deploy().await?; + system.register_validator().await?; + + let output = system + .export_calldata_cmd() + .arg("--format") + .arg("toml") + .arg("delegate") + .arg("--validator-address") + .arg(system.deployer_address.to_string()) + .arg("--amount") + .arg("100") + .assert() + .success() + .get_output() + .stdout + .clone(); + + let output_str = String::from_utf8(output)?; + assert!(output_str.contains("to = ")); + assert!(output_str.contains("data = ")); + assert!(output_str.contains("value = ")); + + Ok(()) +} + +/// Roundtrip test: export calldata, then execute it directly and verify it works. +/// This ensures exported calldata produces the same result as direct execution. +#[test_log::test(tokio::test)] +async fn test_cli_export_calldata_roundtrip() -> Result<()> { + use alloy::{ + primitives::Bytes, + providers::Provider, + rpc::types::{TransactionInput, TransactionRequest}, + sol_types::SolEventInterface, + }; + use hotshot_contract_adapter::sol_types::StakeTableV2::StakeTableV2Events; + + let system = TestSystem::deploy().await?; + system.register_validator().await?; + + let amount = parse_ether("1.5")?; + + let output = system + .export_calldata_cmd() + .arg("--format") + .arg("json") + .arg("delegate") + .arg("--validator-address") + .arg(system.deployer_address.to_string()) + .arg("--amount") + .arg(format_ether(amount)) + .assert() + .success() + .get_output() + .stdout + .clone(); + + // Parse the exported calldata + let json: serde_json::Value = serde_json::from_slice(&output)?; + let to: Address = json["to"].as_str().unwrap().parse()?; + let data: Bytes = json["data"].as_str().unwrap().parse()?; + + // Execute the exported calldata directly + let tx = TransactionRequest::default() + .to(to) + .input(TransactionInput::new(data)); + let receipt = system + .provider + .send_transaction(tx) + .await? + .get_receipt() + .await?; + assert!(receipt.status()); + + // Verify the Delegated event was emitted with correct amount + let delegated = receipt + .inner + .logs() + .iter() + .find_map(|log| { + StakeTableV2Events::decode_log(log.inner.as_ref()) + .ok() + .and_then(|e| match e.data { + StakeTableV2Events::Delegated(d) => Some(d), + _ => None, + }) + }) + .expect("Delegated event not found"); + assert_eq!(delegated.validator, system.deployer_address); + assert_eq!(delegated.amount, amount); + + Ok(()) +} + +#[test_log::test(tokio::test)] +async fn test_cli_export_calldata_no_signer() -> Result<()> { + let system = TestSystem::deploy().await?; + + system + .export_calldata_cmd() + .arg("approve") + .arg("--amount") + .arg("1000") + .assert() + .success() + .stdout(str::contains("Target:")) + .stdout(str::contains("Calldata:")); + + Ok(()) +} + +/// Regression test: export-calldata should work with direct key passing (not just --node-signatures) +#[test_log::test(tokio::test)] +async fn test_cli_export_calldata_register_validator_direct_keys() -> Result<()> { + let system = TestSystem::deploy().await?; + + system + .export_calldata_cmd() + .arg("register-validator") + .arg("--consensus-private-key") + .arg(system.bls_private_key_str()?) + .arg("--state-private-key") + .arg(system.state_private_key_str()?) + .arg("--commission") + .arg("12.34") + .arg("--no-metadata-uri") + .assert() + .success() + .stdout(str::contains("Target:")) + .stdout(str::contains("Calldata:")); + + Ok(()) +} + +/// Regression test: export-calldata should work with direct key passing for update-consensus-keys +#[test_log::test(tokio::test)] +async fn test_cli_export_calldata_update_consensus_keys_direct_keys() -> Result<()> { + let system = TestSystem::deploy().await?; + system.register_validator().await?; + + let mut rng = StdRng::from_seed([43u8; 32]); + let (_, new_bls, new_state) = TestSystem::gen_keys(&mut rng); + + system + .export_calldata_cmd() + .arg("update-consensus-keys") + .arg("--consensus-private-key") + .arg(new_bls.sign_key_ref().to_tagged_base64()?.to_string()) + .arg("--state-private-key") + .arg(new_state.sign_key().to_tagged_base64()?.to_string()) + .assert() + .success() + .stdout(str::contains("Target:")) + .stdout(str::contains("Calldata:")); + + Ok(()) +} + +#[test_log::test(tokio::test)] +async fn test_cli_export_calldata_requires_sender_address_for_simulation() -> Result<()> { + let system = TestSystem::deploy().await?; + system.register_validator().await?; + + base_cmd() + .arg("--rpc-url") + .arg(system.rpc_url.to_string()) + .arg("--stake-table-address") + .arg(system.stake_table.to_string()) + .arg("--export-calldata") + .arg("delegate") + .arg("--validator-address") + .arg(system.deployer_address.to_string()) + .arg("--amount") + .arg("1") + .assert() + .failure() + .stderr(str::contains("--sender-address")) + .stderr(str::contains("--skip-simulation")); + + Ok(()) +} + +#[test_log::test(tokio::test)] +async fn test_cli_skip_simulation_does_not_require_sender_address() -> Result<()> { + let system = TestSystem::deploy().await?; + system.register_validator().await?; + + base_cmd() + .arg("--rpc-url") + .arg(system.rpc_url.to_string()) + .arg("--stake-table-address") + .arg(system.stake_table.to_string()) + .arg("--export-calldata") + .arg("--skip-simulation") + .arg("delegate") + .arg("--validator-address") + .arg(system.deployer_address.to_string()) + .arg("--amount") + .arg("1") + .assert() + .success() + .stdout(str::contains("Calldata:")); + + Ok(()) +} + +#[test_log::test(tokio::test)] +async fn test_cli_claim_rewards_requires_sender_address_even_with_skip_simulation() -> Result<()> { + let system = TestSystem::deploy().await?; + + base_cmd() + .arg("--rpc-url") + .arg(system.rpc_url.to_string()) + .arg("--stake-table-address") + .arg(system.stake_table.to_string()) + .arg("--export-calldata") + .arg("--skip-simulation") + .arg("--espresso-url") + .arg("http://localhost:12345") + .arg("claim-rewards") + .assert() + .failure() + .stderr(str::contains("--sender-address")); + + Ok(()) +} + +#[test_log::test(tokio::test(flavor = "multi_thread"))] +async fn test_cli_export_calldata_claim_rewards() -> Result<()> { + let system = TestSystem::deploy().await?; + let reward_balance = parse_ether("1.0")?; + let espresso_url = system.setup_reward_claim_mock(reward_balance).await?; + tokio::time::sleep(Duration::from_millis(100)).await; + + system + .export_calldata_cmd() + .arg("--espresso-url") + .arg(espresso_url.to_string()) + .arg("claim-rewards") + .assert() + .success() + .stdout(str::contains("Target:")) + .stdout(str::contains("Calldata:")); + + Ok(()) +} + +#[test_log::test(tokio::test)] +async fn test_cli_export_calldata_validation_succeeds() -> Result<()> { + let system = TestSystem::deploy().await?; + system.register_validator().await?; + // Validator registered, delegate validation should pass + + system + .export_calldata_cmd() + .arg("delegate") + .arg("--validator-address") + .arg(system.deployer_address.to_string()) + .arg("--amount") + .arg("100") + .assert() + .success() + .stdout(str::contains("Target:")) + .stdout(str::contains("Calldata:")); + + Ok(()) +} + +/// Test for manual inspection of calldata export output across all operations. +/// This test exercises all export-calldata commands and prints their output +/// for visual verification - there are no automated assertions on the output. +/// Note: V1 export is not supported (deprecated), so this test only runs on V2. +#[tokio::test(flavor = "multi_thread")] +async fn test_cli_export_calldata_all_operations_manual_inspect() -> Result<()> { + let system = TestSystem::deploy().await?; + + // Setup reward claim mock + let reward_balance = parse_ether("1.234")?; + let espresso_url = system.setup_reward_claim_mock(reward_balance).await?; + tokio::time::sleep(Duration::from_millis(100)).await; + + // First, export node signatures to a temp file (needed for register-validator and + // update-consensus-keys calldata export) + let tmpdir = tempfile::tempdir()?; + let signatures_path = tmpdir.path().join("signatures.json"); + system + .export_node_signatures_cmd()? + .arg("--output") + .arg(&signatures_path) + .assert() + .success(); + + println!("\n=== register-validator ==="); + let output = system + .export_calldata_cmd() + .arg("--skip-simulation") + .arg("register-validator") + .arg("--node-signatures") + .arg(&signatures_path) + .arg("--commission") + .arg("12.34") + .arg("--metadata-uri") + .arg("https://example.com/metadata") + .assert() + .success() + .get_output() + .stdout + .clone(); + println!("{}", String::from_utf8_lossy(&output)); + + println!("\n=== transfer ==="); + let output = system + .export_calldata_cmd() + .arg("--skip-simulation") + .arg("transfer") + .arg("--to") + .arg("0x1111111111111111111111111111111111111111") + .arg("--amount") + .arg("100") + .assert() + .success() + .get_output() + .stdout + .clone(); + println!("{}", String::from_utf8_lossy(&output)); + + println!("\n=== approve ==="); + let output = system + .export_calldata_cmd() + .arg("--skip-simulation") + .arg("approve") + .arg("--amount") + .arg("1000") + .assert() + .success() + .get_output() + .stdout + .clone(); + println!("{}", String::from_utf8_lossy(&output)); + + println!("\n=== delegate ==="); + let output = system + .export_calldata_cmd() + .arg("--skip-simulation") + .arg("delegate") + .arg("--validator-address") + .arg(system.deployer_address.to_string()) + .arg("--amount") + .arg("500") + .assert() + .success() + .get_output() + .stdout + .clone(); + println!("{}", String::from_utf8_lossy(&output)); + + println!("\n=== update-commission ==="); + let output = system + .export_calldata_cmd() + .arg("--skip-simulation") + .arg("update-commission") + .arg("--new-commission") + .arg("15.5") + .assert() + .success() + .get_output() + .stdout + .clone(); + println!("{}", String::from_utf8_lossy(&output)); + + println!("\n=== update-metadata-uri ==="); + let output = system + .export_calldata_cmd() + .arg("--skip-simulation") + .arg("update-metadata-uri") + .arg("--metadata-uri") + .arg("https://example.com/updated-metadata") + .assert() + .success() + .get_output() + .stdout + .clone(); + println!("{}", String::from_utf8_lossy(&output)); + + println!("\n=== update-consensus-keys ==="); + // Export new signatures for update-consensus-keys + let mut rng = StdRng::from_seed([99u8; 32]); + let (_, new_bls, new_state) = TestSystem::gen_keys(&mut rng); + let new_signatures_path = tmpdir.path().join("new_signatures.json"); + base_cmd() + .arg("export-node-signatures") + .arg("--address") + .arg(system.deployer_address.to_string()) + .arg("--consensus-private-key") + .arg(new_bls.sign_key_ref().to_tagged_base64()?.to_string()) + .arg("--state-private-key") + .arg(new_state.sign_key().to_tagged_base64()?.to_string()) + .arg("--output") + .arg(&new_signatures_path) + .assert() + .success(); + + let output = system + .export_calldata_cmd() + .arg("--skip-simulation") + .arg("update-consensus-keys") + .arg("--node-signatures") + .arg(&new_signatures_path) + .assert() + .success() + .get_output() + .stdout + .clone(); + println!("{}", String::from_utf8_lossy(&output)); + + println!("\n=== undelegate ==="); + let output = system + .export_calldata_cmd() + .arg("--skip-simulation") + .arg("undelegate") + .arg("--validator-address") + .arg(system.deployer_address.to_string()) + .arg("--amount") + .arg("200") + .assert() + .success() + .get_output() + .stdout + .clone(); + println!("{}", String::from_utf8_lossy(&output)); + + println!("\n=== claim-withdrawal ==="); + let output = system + .export_calldata_cmd() + .arg("--skip-simulation") + .arg("claim-withdrawal") + .arg("--validator-address") + .arg(system.deployer_address.to_string()) + .assert() + .success() + .get_output() + .stdout + .clone(); + println!("{}", String::from_utf8_lossy(&output)); + + println!("\n=== deregister-validator ==="); + let output = system + .export_calldata_cmd() + .arg("--skip-simulation") + .arg("deregister-validator") + .assert() + .success() + .get_output() + .stdout + .clone(); + println!("{}", String::from_utf8_lossy(&output)); + + println!("\n=== claim-validator-exit ==="); + let output = system + .export_calldata_cmd() + .arg("--skip-simulation") + .arg("claim-validator-exit") + .arg("--validator-address") + .arg(system.deployer_address.to_string()) + .assert() + .success() + .get_output() + .stdout + .clone(); + println!("{}", String::from_utf8_lossy(&output)); + + println!("\n=== claim-rewards ==="); + let output = system + .export_calldata_cmd() + .arg("--skip-simulation") + .arg("--espresso-url") + .arg(espresso_url.to_string()) + .arg("claim-rewards") + .assert() + .success() + .get_output() + .stdout + .clone(); + println!("{}", String::from_utf8_lossy(&output)); + + Ok(()) +} diff --git a/staking-cli/tests/common/mod.rs b/staking-cli/tests/common/mod.rs index b27340015b6..c2fff379817 100644 --- a/staking-cli/tests/common/mod.rs +++ b/staking-cli/tests/common/mod.rs @@ -1,6 +1,6 @@ use anyhow::Result; use assert_cmd::Command; -use staking_cli::{deploy::TestSystem, DEV_MNEMONIC}; +use staking_cli::{deploy::TestSystem, DEV_MNEMONIC, DEV_PRIVATE_KEY}; // rstest macro usage isn't detected #[allow(dead_code)] @@ -9,12 +9,17 @@ pub enum Signer { Ledger, Mnemonic, BrokeMnemonic, + PrivateKey, } pub trait TestSystemExt { /// Create a base staking-cli command configured for this test system fn cmd(&self, signer: Signer) -> Command; + #[allow(dead_code)] + /// Create an export-calldata command with sender-address for validation + fn export_calldata_cmd(&self) -> Command; + // method is used, but somehow flagged as unused #[allow(dead_code)] /// Create an export-node-signatures command with system keys and address @@ -47,10 +52,25 @@ impl TestSystemExt for TestSystem { "roast term reopen pave choose high rally trouble upon govern hollow stand", ); }, + Signer::PrivateKey => { + cmd.arg("--private-key").arg(DEV_PRIVATE_KEY); + }, }; cmd } + fn export_calldata_cmd(&self) -> Command { + let mut cmd = base_cmd(); + cmd.arg("--rpc-url") + .arg(self.rpc_url.to_string()) + .arg("--stake-table-address") + .arg(self.stake_table.to_string()) + .arg("--export-calldata") + .arg("--sender-address") + .arg(self.deployer_address.to_string()); + cmd + } + fn export_node_signatures_cmd(&self) -> Result { let mut cmd = base_cmd(); cmd.arg("export-node-signatures")