diff --git a/Cargo.lock b/Cargo.lock index 46d0cb40cf..d8f3591d38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11131,6 +11131,7 @@ dependencies = [ "jf-signature 0.4.0 (git+https://github.com/EspressoSystems/jellyfish?tag=jf-signature-v0.4.0)", "portpicker", "predicates", + "pretty_assertions", "rand 0.8.5", "rand_chacha 0.3.1", "rstest", diff --git a/sequencer/src/api.rs b/sequencer/src/api.rs index ed92f1b01a..9d86471b15 100644 --- a/sequencer/src/api.rs +++ b/sequencer/src/api.rs @@ -980,7 +980,7 @@ pub mod test_helpers { use itertools::izip; use jf_merkle_tree_compat::{MerkleCommitment, MerkleTreeScheme}; use portpicker::pick_unused_port; - use staking_cli::demo::{setup_stake_table_contract_for_test, DelegationConfig}; + use staking_cli::demo::{DelegationConfig, StakingTransactions}; use surf_disco::Client; use tempfile::TempDir; use tide_disco::{error::ServerError, Api, App, Error, StatusCode}; @@ -1217,7 +1217,7 @@ pub mod test_helpers { let stake_table_address = contracts .address(Contract::StakeTableProxy) .expect("StakeTableProxy address not found"); - setup_stake_table_contract_for_test( + StakingTransactions::create( l1_url.clone(), &deployer, stake_table_address, @@ -1225,7 +1225,10 @@ pub mod test_helpers { delegation_config, ) .await - .expect("stake table setup failed"); + .expect("stake table setup failed") + .apply_all() + .await + .expect("send all txns failed"); // enable interval mining with a 1s interval. // This ensures that blocks are finalized every second, even when there are no transactions. @@ -3544,6 +3547,7 @@ mod test { let mut target_bh = 0; while let Some(header) = headers.next().await { let header = header.unwrap(); + println!("got header with height {}", header.height()); if header.height() == 0 { continue; } @@ -5825,7 +5829,10 @@ mod test { .try_into()?; commissions.push((validator, commission, new_commission)); tracing::info!(%validator, %commission, %new_commission, "Update commission"); - update_commission(provider, st_addr, new_commission).await?; + update_commission(provider, st_addr, new_commission) + .await? + .get_receipt() + .await?; } // wait until new stake table takes effect diff --git a/sequencer/src/bin/espresso-dev-node.rs b/sequencer/src/bin/espresso-dev-node.rs index 4e6f3ab1e2..d329ec2c5a 100644 --- a/sequencer/src/bin/espresso-dev-node.rs +++ b/sequencer/src/bin/espresso-dev-node.rs @@ -51,7 +51,7 @@ use sequencer::{ }; use sequencer_utils::logging; use serde::{Deserialize, Serialize}; -use staking_cli::demo::{setup_stake_table_contract_for_test, DelegationConfig}; +use staking_cli::demo::{DelegationConfig, StakingTransactions}; use tempfile::NamedTempFile; use tide_disco::{error::ServerError, method::ReadState, Api, Error, StatusCode}; use tokio::spawn; @@ -536,7 +536,7 @@ async fn main() -> anyhow::Result<()> { } let staking_priv_keys = network_config.staking_priv_keys(); - setup_stake_table_contract_for_test( + StakingTransactions::create( l1_url.clone(), &provider, l1_contracts @@ -545,6 +545,8 @@ async fn main() -> anyhow::Result<()> { staking_priv_keys, DelegationConfig::default(), ) + .await? + .apply_all() .await?; } } diff --git a/sequencer/src/lib.rs b/sequencer/src/lib.rs index 48f11eb339..382dc4419e 100644 --- a/sequencer/src/lib.rs +++ b/sequencer/src/lib.rs @@ -695,7 +695,7 @@ pub mod testing { use portpicker::pick_unused_port; use rand::SeedableRng as _; use rand_chacha::ChaCha20Rng; - use staking_cli::demo::{setup_stake_table_contract_for_test, DelegationConfig}; + use staking_cli::demo::{DelegationConfig, StakingTransactions}; use tokio::spawn; use vbs::version::Version; @@ -938,7 +938,7 @@ pub mod testing { let st_addr = contracts .address(Contract::StakeTableProxy) .expect("StakeTableProxy address not found"); - setup_stake_table_contract_for_test( + StakingTransactions::create( self.l1_url.clone(), &deployer, st_addr, @@ -946,7 +946,10 @@ pub mod testing { DelegationConfig::default(), ) .await - .expect("stake table setup failed"); + .expect("stake table setup failed") + .apply_all() + .await + .expect("send all txns failed"); Upgrade::pos_view_based(st_addr) }, @@ -1051,7 +1054,9 @@ pub mod testing { drb_upgrade_difficulty: 20, }; - let anvil = Anvil::new().args(["--slots-in-an-epoch", "0"]).spawn(); + let anvil = Anvil::new() + .args(["--slots-in-an-epoch", "0", "--balance", "1000000"]) + .spawn(); let l1_client = L1Client::anvil(&anvil).expect("failed to create l1 client"); let anvil_provider = AnvilProvider::new(l1_client.provider, Arc::new(anvil)); diff --git a/sequencer/src/persistence.rs b/sequencer/src/persistence.rs index bda85007c3..7192133cb3 100644 --- a/sequencer/src/persistence.rs +++ b/sequencer/src/persistence.rs @@ -79,7 +79,7 @@ mod tests { }; use indexmap::IndexMap; use portpicker::pick_unused_port; - use staking_cli::demo::{setup_stake_table_contract_for_test, DelegationConfig}; + use staking_cli::demo::{DelegationConfig, StakingTransactions}; use surf_disco::Client; use tide_disco::error::ServerError; use tokio::{spawn, time::sleep}; @@ -1438,14 +1438,14 @@ mod tests { ) .unwrap(); - let (_, priv_keys): (Vec<_>, Vec<_>) = (0..200) + let (_, priv_keys): (Vec<_>, Vec<_>) = (0..20) .map(|i| ::generated_from_seed_indexed([1; 32], i as u64)) .unzip(); - let state_key_pairs = (0..200) + let state_key_pairs = (0..20) .map(|i| StateKeyPair::generate_from_seed_indexed([2; 32], i as u64)) .collect::>(); - let validators = staking_priv_keys(&priv_keys, &state_key_pairs, 1000); + let validators = staking_priv_keys(&priv_keys, &state_key_pairs, 20); let deployer = ProviderBuilder::new() .wallet(EthereumWallet::from(network_config.signer().clone())) @@ -1486,6 +1486,24 @@ mod tests { .expect("StakeTableProxy deployed"); let l1_url = network_config.l1_url().clone(); + let mut planned_txns = StakingTransactions::create( + l1_url.clone(), + &deployer, + st_addr, + validators, + DelegationConfig::MultipleDelegators, + ) + .await + .expect("stake table setup failed"); + + planned_txns + .apply_prerequisites() + .await + .expect("prerequisites failed"); + + // Ensure we have at least one stake table affecting transaction + planned_txns.apply_one().await.expect("send tx failed"); + // new block every 1s anvil_provider .anvil_set_interval_mining(1) @@ -1496,18 +1514,13 @@ mod tests { // this is going to keep registering validators and multiple delegators // the interval mining is set to 1s so each transaction finalization would take atleast 1s spawn({ - let l1_url = l1_url.clone(); async move { { - setup_stake_table_contract_for_test( - l1_url, - &deployer, - st_addr, - validators, - DelegationConfig::MultipleDelegators, - ) - .await - .expect("stake table setup failed"); + while let Some(receipt) = + planned_txns.apply_one().await.expect("send tx failed") + { + tracing::debug!(?receipt, "transaction finalized"); + } } } }); diff --git a/sequencer/src/restart_tests.rs b/sequencer/src/restart_tests.rs index 9427f88442..67a404059e 100755 --- a/sequencer/src/restart_tests.rs +++ b/sequencer/src/restart_tests.rs @@ -58,7 +58,7 @@ use itertools::Itertools; use options::Modules; use portpicker::pick_unused_port; use run::init_with_storage; -use staking_cli::demo::{setup_stake_table_contract_for_test, DelegationConfig}; +use staking_cli::demo::{DelegationConfig, StakingTransactions}; use surf_disco::{error::ClientError, Url}; use tempfile::TempDir; use tokio::{ @@ -867,7 +867,7 @@ impl TestNetwork { tracing::info!(?stake_table_address, ?token_addr); - setup_stake_table_contract_for_test( + StakingTransactions::create( l1_url.clone(), &deployer, stake_table_address, @@ -875,7 +875,10 @@ impl TestNetwork { delegation_config, ) .await - .expect("stake table setup failed"); + .expect("stake table setup failed") + .apply_all() + .await + .expect("send all txns failed"); self.anvil .anvil_set_interval_mining(1) diff --git a/staking-cli/Cargo.toml b/staking-cli/Cargo.toml index 370bb6bc2f..ceafeb529a 100644 --- a/staking-cli/Cargo.toml +++ b/staking-cli/Cargo.toml @@ -44,6 +44,7 @@ url = { workspace = true } [dev-dependencies] assert_cmd = "2.0.17" predicates = "3.1.3" +pretty_assertions.workspace = true tempfile = { workspace = true } test-log = { workspace = true } diff --git a/staking-cli/src/claim.rs b/staking-cli/src/claim.rs index 19dd35ad35..3726ab4191 100644 --- a/staking-cli/src/claim.rs +++ b/staking-cli/src/claim.rs @@ -1,4 +1,8 @@ -use alloy::{primitives::Address, providers::Provider, rpc::types::TransactionReceipt}; +use alloy::{ + network::Ethereum, + primitives::Address, + providers::{PendingTransactionBuilder, Provider}, +}; use anyhow::Result; use hotshot_contract_adapter::{ evm::DecodeRevert as _, @@ -9,31 +13,24 @@ pub async fn claim_withdrawal( provider: impl Provider, stake_table: Address, validator_address: Address, -) -> Result { +) -> Result> { let st = StakeTable::new(stake_table, provider); - // See if there are any logs - Ok(st - .claimWithdrawal(validator_address) + st.claimWithdrawal(validator_address) .send() .await - .maybe_decode_revert::()? - .get_receipt() - .await?) + .maybe_decode_revert::() } pub async fn claim_validator_exit( provider: impl Provider, stake_table: Address, validator_address: Address, -) -> Result { +) -> Result> { let st = StakeTable::new(stake_table, provider); - Ok(st - .claimValidatorExit(validator_address) + st.claimValidatorExit(validator_address) .send() .await - .maybe_decode_revert::()? - .get_receipt() - .await?) + .maybe_decode_revert::() } #[cfg(test)] @@ -41,7 +38,7 @@ mod test { use alloy::primitives::U256; use super::*; - use crate::deploy::TestSystem; + use crate::{deploy::TestSystem, receipt::ReceiptExt}; #[tokio::test] async fn test_claim_withdrawal() -> Result<()> { @@ -53,9 +50,10 @@ 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!(receipt.status()); + let receipt = claim_withdrawal(&system.provider, system.stake_table, validator_address) + .await? + .assert_success() + .await?; let event = receipt.decoded_log::().unwrap(); assert_eq!(event.amount, amount); @@ -73,9 +71,10 @@ 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!(receipt.status()); + let receipt = claim_validator_exit(&system.provider, system.stake_table, validator_address) + .await? + .assert_success() + .await?; let event = receipt.decoded_log::().unwrap(); assert_eq!(event.amount, amount); diff --git a/staking-cli/src/delegation.rs b/staking-cli/src/delegation.rs index 93edebedf5..76828abc53 100644 --- a/staking-cli/src/delegation.rs +++ b/staking-cli/src/delegation.rs @@ -1,7 +1,7 @@ use alloy::{ - primitives::{Address, U256}, - providers::Provider, - rpc::types::TransactionReceipt, + network::Ethereum, + primitives::{utils::format_ether, Address, U256}, + providers::{PendingTransactionBuilder, Provider}, }; use anyhow::Result; use hotshot_contract_adapter::{ @@ -17,15 +17,17 @@ pub async fn approve( token_addr: Address, stake_table_address: Address, amount: U256, -) -> Result { - let token = EspToken::new(token_addr, &provider); - Ok(token +) -> 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::()? - .get_receipt() - .await?) + .maybe_decode_revert::() } pub async fn delegate( @@ -33,15 +35,16 @@ pub async fn delegate( stake_table: Address, validator_address: Address, amount: U256, -) -> Result { +) -> Result> { + tracing::info!( + "delegate {} ESP to {validator_address}", + format_ether(amount) + ); let st = StakeTable::new(stake_table, provider); - Ok(st - .delegate(validator_address, amount) + st.delegate(validator_address, amount) .send() .await - .maybe_decode_revert::()? - .get_receipt() - .await?) + .maybe_decode_revert::() } pub async fn undelegate( @@ -49,21 +52,22 @@ pub async fn undelegate( stake_table: Address, validator_address: Address, amount: U256, -) -> Result { +) -> Result> { + tracing::info!( + "undelegate {} ESP from {validator_address}", + format_ether(amount) + ); let st = StakeTable::new(stake_table, provider); - Ok(st - .undelegate(validator_address, amount) + st.undelegate(validator_address, amount) .send() .await - .maybe_decode_revert::()? - .get_receipt() - .await?) + .maybe_decode_revert::() } #[cfg(test)] mod test { use super::*; - use crate::deploy::TestSystem; + use crate::{deploy::TestSystem, receipt::ReceiptExt}; #[tokio::test] async fn test_delegate() -> Result<()> { @@ -78,8 +82,9 @@ mod test { validator_address, amount, ) + .await? + .assert_success() .await?; - assert!(receipt.status()); let event = receipt.decoded_log::().unwrap(); assert_eq!(event.validator, validator_address); @@ -102,8 +107,9 @@ mod test { validator_address, amount, ) + .await? + .assert_success() .await?; - assert!(receipt.status()); let event = receipt.decoded_log::().unwrap(); assert_eq!(event.validator, validator_address); diff --git a/staking-cli/src/demo.rs b/staking-cli/src/demo.rs index 981a06e1be..4a226ddd81 100644 --- a/staking-cli/src/demo.rs +++ b/staking-cli/src/demo.rs @@ -1,36 +1,72 @@ -use std::fmt; +use std::{ + collections::{HashMap, HashSet, VecDeque}, + fmt, +}; use alloy::{ - network::{EthereumWallet, TransactionBuilder as _}, + contract::Error as ContractError, + network::{Ethereum, EthereumWallet}, primitives::{ utils::{format_ether, parse_ether}, Address, U256, }, - providers::{Provider, ProviderBuilder, WalletProvider}, - rpc::types::TransactionRequest, + providers::{PendingTransactionBuilder, Provider, ProviderBuilder, WalletProvider}, + rpc::types::TransactionReceipt, signers::local::PrivateKeySigner, + transports::TransportError, }; use anyhow::Result; use clap::ValueEnum; -use espresso_contract_deployer::{build_provider, build_random_provider, build_signer}; -use hotshot_contract_adapter::{ - evm::DecodeRevert, - sol_types::EspToken::{self, EspTokenErrors}, -}; +use espresso_contract_deployer::{build_provider, build_signer, HttpProviderWithWallet}; +use futures_util::future; +use hotshot_contract_adapter::sol_types::EspToken; use hotshot_types::{light_client::StateKeyPair, signature_key::BLSKeyPair}; use rand::{Rng, SeedableRng}; use rand_chacha::ChaCha20Rng; +use thiserror::Error; use url::Url; use crate::{ - delegation::delegate, + delegation::{approve, delegate}, + funding::{send_esp, send_eth}, info::fetch_token_address, - parse::{parse_bls_priv_key, parse_state_priv_key, Commission}, + parse::{parse_bls_priv_key, parse_state_priv_key, Commission, ParseCommissionError}, + receipt::ReceiptExt as _, registration::register_validator, signature::NodeSignatures, Config, }; +#[derive(Debug, Error)] +pub enum CreateTransactionsError { + #[error( + "insufficient ESP balance: have {have} ESP, need {need} ESP to fund {delegators} \ + delegators" + )] + InsufficientEsp { + have: String, + need: String, + delegators: usize, + }, + #[error( + "insufficient ETH balance: have {have} ETH, need {need} ETH (including gas buffer) to \ + fund {recipients} recipients" + )] + InsufficientEth { + have: String, + need: String, + recipients: usize, + }, + #[error(transparent)] + Transport(#[from] TransportError), + #[error(transparent)] + Contract(#[from] ContractError), + #[error(transparent)] + Commission(#[from] ParseCommissionError), + #[error(transparent)] + Other(#[from] anyhow::Error), +} + #[derive(Debug, Clone, Copy, Default, ValueEnum)] pub enum DelegationConfig { EqualAmounts, @@ -53,216 +89,434 @@ impl fmt::Display for DelegationConfig { } } -/// Setup validator by sending them tokens and ethers, and registering them on stake table -pub async fn setup_stake_table_contract_for_test( - rpc_url: Url, - token_holder: &(impl Provider + WalletProvider), - stake_table_address: Address, - validators: Vec<(PrivateKeySigner, BLSKeyPair, StateKeyPair)>, - config: DelegationConfig, -) -> Result<()> { - tracing::info!(%stake_table_address, "staking to stake table contract for demo"); +#[derive(Clone, Debug)] +enum StakeTableTx { + SendEth { + to: Address, + amount: U256, + }, + SendEsp { + to: Address, + amount: U256, + }, + RegisterValidator { + from: Address, + commission: Commission, + payload: Box, + }, + Approve { + from: Address, + amount: U256, + }, + Delegate { + from: Address, + validator: Address, + amount: U256, + }, +} - let token_holder_addr = token_holder.default_signer_address(); - let token_address = fetch_token_address(rpc_url.clone(), stake_table_address).await?; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SetupPhase { + Funding, + Approval, + Registration, + Delegation, +} - tracing::info!("ESP token address: {token_address}"); - let token = EspToken::new(token_address, token_holder); - let token_balance = token.balanceOf(token_holder_addr).call().await?; - tracing::info!( - "token distributor account {} balance: {} ESP", - token_holder_addr, - format_ether(token_balance) - ); - if token_balance.is_zero() { - panic!("grant recipient has no ESP tokens, funding won't work"); +impl SetupPhase { + fn next(self) -> Option { + match self { + Self::Funding => Some(Self::Approval), + Self::Approval => Some(Self::Registration), + Self::Registration => Some(Self::Delegation), + Self::Delegation => None, + } } +} - let fund_amount_esp = parse_ether("1000")?; - let fund_amount_eth = parse_ether("10")?; +struct ValidatorConfig { + signer: PrivateKeySigner, + commission: Commission, + bls_key_pair: BLSKeyPair, + state_key_pair: StateKeyPair, + index: usize, +} - // Set up deterministic rng - let seed = [42u8; 32]; - let mut rng = ChaCha20Rng::from_seed(seed); +struct DelegatorConfig { + validator: Address, + signer: PrivateKeySigner, + delegate_amount: U256, +} - for (val_index, (signer, bls_key_pair, state_key_pair)) in validators.into_iter().enumerate() { - let validator_address = signer.address(); - let validator_wallet: EthereumWallet = EthereumWallet::from(signer); - let validator_provider = ProviderBuilder::new() - .wallet(validator_wallet) - .connect_http(rpc_url.clone()); +#[derive(Clone, Debug)] +struct TransactionQueues { + funding: VecDeque, + approvals: VecDeque, + registration: VecDeque, + delegations: VecDeque, + current_phase: SetupPhase, +} - tracing::info!("fund val {val_index} address: {validator_address}, {fund_amount_eth} ETH"); - let tx = TransactionRequest::default() - .with_to(validator_address) - .with_value(fund_amount_eth); - let receipt = token_holder - .send_transaction(tx) - .await? - .get_receipt() - .await?; - assert!(receipt.status()); +impl TransactionQueues { + fn current_group_mut(&mut self) -> &mut VecDeque { + match self.current_phase { + SetupPhase::Funding => &mut self.funding, + SetupPhase::Approval => &mut self.approvals, + SetupPhase::Registration => &mut self.registration, + SetupPhase::Delegation => &mut self.delegations, + } + } - let bal = validator_provider.get_balance(validator_address).await?; + fn pop_next(&mut self) -> Option { + loop { + if let Some(tx) = self.current_group_mut().pop_front() { + return Some(tx); + } + self.current_phase = self.current_phase.next()?; + } + } +} - // 1% commission and more - let commission = Commission::try_from(100u64 + 10u64 * val_index as u64)?; +#[derive(Clone, Debug)] +pub struct StakingTransactions

{ + providers: HashMap, + funder: P, + stake_table: Address, + token: Address, + queues: TransactionQueues, +} - // delegate 100 to 500 ESP - let delegate_amount = match config { - DelegationConfig::EqualAmounts => Some(parse_ether("100")?), - DelegationConfig::MultipleDelegators | DelegationConfig::VariableAmounts => { - Some(parse_ether("100")? * U256::from(val_index % 5 + 1)) - }, - DelegationConfig::NoSelfDelegation => None, - }; - let delegate_amount_esp = delegate_amount.map(format_ether).unwrap_or_default(); +impl StakingTransactions

{ + fn provider(&self, address: Address) -> Result<&P> { + self.providers + .get(&address) + .ok_or_else(|| anyhow::anyhow!("provider not found for {address}")) + } - tracing::info!("validator {val_index} address: {validator_address}, balance: {bal}"); + async fn send_next(&self, tx: StakeTableTx) -> Result> { + match tx { + StakeTableTx::SendEth { to, amount } => send_eth(&self.funder, to, amount).await, + StakeTableTx::SendEsp { to, amount } => { + send_esp(&self.funder, self.token, to, amount).await + }, + StakeTableTx::RegisterValidator { + from, + commission, + payload, + } => { + register_validator(self.provider(from)?, self.stake_table, commission, *payload) + .await + }, + StakeTableTx::Approve { from, amount } => { + approve(self.provider(from)?, self.token, self.stake_table, amount).await + }, + StakeTableTx::Delegate { + from, + validator, + amount, + } => delegate(self.provider(from)?, self.stake_table, validator, amount).await, + } + } - tracing::info!("transfer {fund_amount_esp} ESP to {validator_address}",); - let receipt = token - .transfer(validator_address, fund_amount_esp) - .send() - .await - .maybe_decode_revert::()? - .get_receipt() - .await?; - assert!(receipt.status()); - - tracing::info!("approve {fund_amount_esp} ESP for {stake_table_address}",); - let validator_token = EspToken::new(token_address, validator_provider.clone()); - let receipt = validator_token - .approve(stake_table_address, fund_amount_esp) - .send() - .await - .maybe_decode_revert::()? - .get_receipt() - .await?; - assert!(receipt.status()); - - tracing::info!("deploy validator {val_index} with commission {commission}"); - let payload = NodeSignatures::create(validator_address, &bls_key_pair, &state_key_pair); - let receipt = register_validator( - &validator_provider, - stake_table_address, - commission, - payload, - ) - .await?; - assert!(receipt.status()); - - if let Some(delegate_amount) = delegate_amount { - tracing::info!( - "delegate {delegate_amount_esp} ESP for validator {val_index} from \ - {validator_address}" - ); - let receipt = delegate( - &validator_provider, - stake_table_address, - validator_address, - delegate_amount, - ) - .await?; - assert!(receipt.status()); + async fn process_group( + &self, + txs: &mut VecDeque, + ) -> Result> { + let mut pending = vec![]; + while let Some(tx) = txs.pop_front() { + pending.push(self.send_next(tx).await?); } + future::try_join_all( + pending + .into_iter() + .map(|p| async move { p.assert_success().await }), + ) + .await + } - match config { - DelegationConfig::EqualAmounts | DelegationConfig::VariableAmounts => { - tracing::debug!("not adding extra delegators"); - }, - DelegationConfig::MultipleDelegators | DelegationConfig::NoSelfDelegation => { - tracing::info!("adding multiple delegators for validator {val_index} "); - let num_delegators = rng.gen_range(2..=5); - add_multiple_delegators( - &rpc_url, - validator_address, - token_holder, - stake_table_address, - token_address, - &mut rng, - num_delegators, - ) - .await?; - }, + /// Sends and awaits all transactions with high concurrency. + /// + /// This is the preferred way to make the changes to the stake table + /// contract as quickly as possible, while still allowing alloy's implicit + /// estimateGas calls to succeed. + /// + /// Ensures that dependent transactions are finalized before + /// continuing. + /// + /// The synchronization points are after + /// + /// 1. Ether + token funding + /// 2. Approvals + /// 3. Registrations + /// 4. Delegations + /// + /// For each them at least one L1 block will be required. + pub async fn apply_all(&mut self) -> Result> { + let mut funding = std::mem::take(&mut self.queues.funding); + let r1 = self.process_group(&mut funding).await?; + + let mut approvals = std::mem::take(&mut self.queues.approvals); + let r2 = self.process_group(&mut approvals).await?; + + let mut registration = std::mem::take(&mut self.queues.registration); + let r3 = self.process_group(&mut registration).await?; + + let mut delegations = std::mem::take(&mut self.queues.delegations); + let r4 = self.process_group(&mut delegations).await?; + + tracing::info!("completed all staking transactions"); + + Ok([r1, r2, r3, r4].concat()) + } + + /// Sends and awaits receipts on all funding and approval transactions + /// + /// If the caller wants more control but quickly get to a point where actual + /// changes are made to the stake table it is useful to call this function + /// first. + /// + /// This processes funding and approvals with a synchronization point between them. + pub async fn apply_prerequisites(&mut self) -> Result> { + if !matches!(self.queues.current_phase, SetupPhase::Funding) { + return Err(anyhow::anyhow!("apply_prerequisites must be called first")); } + + let mut funding = std::mem::take(&mut self.queues.funding); + let r1 = self.process_group(&mut funding).await?; + + let mut approvals = std::mem::take(&mut self.queues.approvals); + let r2 = self.process_group(&mut approvals).await?; + + self.queues.current_phase = SetupPhase::Registration; + + Ok([r1, r2].concat()) + } + + /// Sends and awaits one transaction + /// + /// The caller can use this function to rate limit changes to the L1 stake + /// table contract during setup. + pub async fn apply_one(&mut self) -> Result> { + let Some(tx) = self.queues.pop_next() else { + return Ok(None); + }; + let pending = self.send_next(tx).await?; + Ok(Some(pending.assert_success().await?)) } - tracing::info!("completed staking for demo"); - Ok(()) } -#[allow(clippy::too_many_arguments)] -async fn add_multiple_delegators( - rpc_url: &Url, - validator_address: Address, - token_holder: &(impl Provider + WalletProvider), - stake_table_address: Address, - token_address: Address, - rng: &mut ChaCha20Rng, - num_delegators: u64, -) -> Result<()> { - let token = EspToken::new(token_address, token_holder); - let fund_amount_esp = parse_ether("1000")?; - let fund_amount_eth = parse_ether("10")?; - - for delegator_index in 0..num_delegators { - let delegator_provider = build_random_provider(rpc_url.clone()); - let delegator_address = delegator_provider.default_signer_address(); - - tracing::info!("delegator {delegator_index}: address {delegator_address}"); - - let tx = TransactionRequest::default() - .with_to(delegator_address) - .with_value(fund_amount_eth); - let receipt = token_holder - .send_transaction(tx) - .await? - .get_receipt() - .await?; - assert!(receipt.status()); +impl StakingTransactions { + /// Create staking transactions for test setup + /// + /// Prepares all transactions needed to setup the stake table with validators and delegations. + /// The transactions can be applied with different levels of concurrency using the methods on + /// the returned instance. + /// + /// Amounts used for funding, delegations, number of delegators are chosen somewhat arbitrarily. + /// + /// Assumptions: + /// + /// - Full control of Validators Ethereum wallets and the Ethereum node. Transactions are + /// constructed in a way that they should always apply, if some (but not all) transactions + /// fail to apply the easiest fix is probably to re-deploy the Ethereum network. Recovery, + /// replacing of transactions is not implemented. + /// + /// - Nobody else is using the Ethereum accounts for anything else between calling this function + /// and applying the returned transactions. + /// + /// Requirements: + /// + /// - token_holder: Requires Eth to fund validators and delegators, ESP tokens to fund delegators. + /// + /// Errors: + /// + /// - If Eth or ESP balances of the token_holder are insufficient. + /// - If any RPC request to the Ethereum node or contract calls fail. + pub async fn create( + rpc_url: Url, + token_holder: &(impl Provider + WalletProvider), + stake_table: Address, + validators: Vec<(PrivateKeySigner, BLSKeyPair, StateKeyPair)>, + config: DelegationConfig, + ) -> Result { + tracing::info!(%stake_table, "staking to stake table contract for demo"); - tracing::info!("delegator {delegator_index}: funded with {fund_amount_eth} ETH"); + let token = fetch_token_address(rpc_url.clone(), stake_table).await?; - let random_amount: u64 = rng.gen_range(100..=500); - let delegate_amount = parse_ether(&random_amount.to_string())?; - let delegate_amount_esp = format_ether(delegate_amount); + let token_holder_provider = ProviderBuilder::new() + .wallet(token_holder.wallet().clone()) + .connect_http(rpc_url.clone()); - let receipt = token - .transfer(delegator_address, fund_amount_esp) - .send() - .await? - .get_receipt() + tracing::info!("ESP token address: {token}"); + let token_holder_addr = token_holder.default_signer_address(); + let token_balance = EspToken::new(token, &token_holder_provider) + .balanceOf(token_holder_addr) + .call() .await?; - assert!(receipt.status()); + tracing::info!( + "token distributor account {} balance: {} ESP", + token_holder_addr, + format_ether(token_balance) + ); - tracing::info!("delegator {delegator_index}: received {fund_amount_esp} ESP"); + let fund_amount_esp = parse_ether("1000").unwrap(); + let fund_amount_eth = parse_ether("10").unwrap(); + + let seed = [42u8; 32]; + let mut rng = ChaCha20Rng::from_seed(seed); + + let mut validator_info = vec![]; + for (val_index, (signer, bls_key_pair, state_key_pair)) in + validators.into_iter().enumerate() + { + let commission = Commission::try_from(100u64 + 10u64 * val_index as u64)?; + + validator_info.push(ValidatorConfig { + signer, + commission, + bls_key_pair, + state_key_pair, + index: val_index, + }); + } - let validator_token = EspToken::new(token_address, &delegator_provider); - let receipt = validator_token - .approve(stake_table_address, delegate_amount) - .send() - .await? - .get_receipt() - .await?; - assert!(receipt.status()); + let mut delegator_info = vec![]; + + for validator in &validator_info { + let delegate_amount = match config { + DelegationConfig::EqualAmounts => Some(parse_ether("100").unwrap()), + DelegationConfig::MultipleDelegators | DelegationConfig::VariableAmounts => { + Some(parse_ether("100").unwrap() * U256::from(validator.index % 5 + 1)) + }, + DelegationConfig::NoSelfDelegation => None, + }; + + if let Some(amount) = delegate_amount { + delegator_info.push(DelegatorConfig { + validator: validator.signer.address(), + signer: validator.signer.clone(), + delegate_amount: amount, + }); + } + } - tracing::info!( - "delegator {delegator_index}: approved {delegate_amount_esp} ESP to stake table" - ); + if matches!( + config, + DelegationConfig::MultipleDelegators | DelegationConfig::NoSelfDelegation + ) { + for validator in &validator_info { + for _ in 0..rng.gen_range(2..=5) { + let random_amount: u64 = rng.gen_range(100..=500); + delegator_info.push(DelegatorConfig { + validator: validator.signer.address(), + signer: PrivateKeySigner::random(), + delegate_amount: parse_ether(&random_amount.to_string()).unwrap(), + }); + } + } + } - let receipt = delegate( - &delegator_provider, - stake_table_address, - validator_address, - delegate_amount, - ) - .await?; - assert!(receipt.status()); + let mut funding = VecDeque::new(); - tracing::info!("delegator {delegator_index}: delegation complete"); - } + let eth_recipients: HashSet

= validator_info + .iter() + .map(|v| v.signer.address()) + .chain(delegator_info.iter().map(|d| d.signer.address())) + .collect(); - Ok(()) + for &address in ð_recipients { + funding.push_back(StakeTableTx::SendEth { + to: address, + amount: fund_amount_eth, + }); + } + + for delegator in &delegator_info { + funding.push_back(StakeTableTx::SendEsp { + to: delegator.signer.address(), + amount: fund_amount_esp, + }); + } + + // Only create one provider per address to avoid nonce errors. + let mut providers: HashMap = HashMap::new(); + + let mut registration = VecDeque::new(); + + for validator in &validator_info { + let address = validator.signer.address(); + providers.entry(address).or_insert_with(|| { + ProviderBuilder::new() + .wallet(EthereumWallet::from(validator.signer.clone())) + .connect_http(rpc_url.clone()) + }); + + let payload = + NodeSignatures::create(address, &validator.bls_key_pair, &validator.state_key_pair); + registration.push_back(StakeTableTx::RegisterValidator { + from: address, + commission: validator.commission, + payload: Box::new(payload), + }); + } + + let mut approvals = VecDeque::new(); + let mut delegations = VecDeque::new(); + + for delegator in &delegator_info { + let address = delegator.signer.address(); + providers.entry(address).or_insert_with(|| { + ProviderBuilder::new() + .wallet(EthereumWallet::from(delegator.signer.clone())) + .connect_http(rpc_url.clone()) + }); + + approvals.push_back(StakeTableTx::Approve { + from: address, + amount: delegator.delegate_amount, + }); + + delegations.push_back(StakeTableTx::Delegate { + from: address, + validator: delegator.validator, + amount: delegator.delegate_amount, + }); + } + + let esp_required = fund_amount_esp * U256::from(delegator_info.len()); + let eth_required = fund_amount_eth * U256::from(eth_recipients.len()) * U256::from(2); + + if token_balance < esp_required { + return Err(CreateTransactionsError::InsufficientEsp { + have: format_ether(token_balance), + need: format_ether(esp_required), + delegators: delegator_info.len(), + }); + } + + let eth_balance = token_holder_provider.get_balance(token_holder_addr).await?; + if eth_balance < eth_required { + return Err(CreateTransactionsError::InsufficientEth { + have: format_ether(eth_balance), + need: format_ether(eth_required), + recipients: eth_recipients.len(), + }); + } + + Ok(StakingTransactions { + providers, + funder: token_holder_provider, + stake_table, + token, + queues: TransactionQueues { + funding, + approvals, + registration, + delegations, + current_phase: SetupPhase::Funding, + }, + }) + } } /// Register validators, and delegate to themselves for demo purposes. @@ -318,23 +572,26 @@ pub async fn stake_for_demo( )); } - setup_stake_table_contract_for_test( + StakingTransactions::create( config.rpc_url.clone(), &grant_recipient, config.stake_table_address, validator_keys, delegation_config, ) + .await? + .apply_all() .await?; - tracing::info!("completed staking for demo"); Ok(()) } #[cfg(test)] mod test { + use alloy::providers::ext::AnvilApi as _; use espresso_types::v0_3::Validator; use hotshot_types::signature_key::BLSPubKey; + use pretty_assertions::assert_matches; use rand::rngs::StdRng; use super::*; @@ -351,15 +608,16 @@ mod test { TestSystem::gen_keys(&mut rng), ]; - setup_stake_table_contract_for_test( + StakingTransactions::create( system.rpc_url.clone(), &system.provider, system.stake_table, keys, config, ) + .await? + .apply_all() .await?; - let l1_block_number = system.provider.get_block_number().await?; let st = stake_table_info(system.rpc_url, system.stake_table, l1_block_number).await?; @@ -445,4 +703,99 @@ mod test { Ok(()) } + + enum Failure { + Esp, + Eth, + } + + #[rstest::rstest] + #[case::esp(Failure::Esp)] + #[case::eth(Failure::Eth)] + #[test_log::test(tokio::test)] + async fn test_insufficient_balance(#[case] case: Failure) -> Result<()> { + let system = TestSystem::deploy().await?; + + let drain_address = PrivateKeySigner::random().address(); + + match case { + Failure::Esp => { + let balance = system + .balance(system.provider.default_signer_address()) + .await?; + system.transfer(drain_address, balance).await?; + }, + Failure::Eth => { + let eth_balance = system + .provider + .get_balance(system.provider.default_signer_address()) + .await?; + // keep a bit for estimateGas calls to succeed + let drain_amount = eth_balance - parse_ether("1").unwrap(); + system.transfer_eth(drain_address, drain_amount).await?; + }, + } + + let mut rng = StdRng::from_seed([42u8; 32]); + let keys = vec![TestSystem::gen_keys(&mut rng)]; + + let result = StakingTransactions::create( + system.rpc_url.clone(), + &system.provider, + system.stake_table, + keys, + DelegationConfig::EqualAmounts, + ) + .await; + + let err = result.expect_err("should fail with insufficient balance"); + match case { + Failure::Esp => assert_matches!(err, CreateTransactionsError::InsufficientEsp { .. }), + Failure::Eth => assert_matches!(err, CreateTransactionsError::InsufficientEth { .. }), + }; + + Ok(()) + } + + #[rstest::rstest] + #[case::equal_amounts(DelegationConfig::EqualAmounts)] + #[case::variable_amounts(DelegationConfig::VariableAmounts)] + #[case::multiple_delegators(DelegationConfig::MultipleDelegators)] + #[case::no_self_delegation(DelegationConfig::NoSelfDelegation)] + #[test_log::test(tokio::test)] + async fn test_setup_with_slow_blocks(#[case] config: DelegationConfig) -> Result<()> { + let system = TestSystem::deploy().await?; + system.provider.anvil_set_auto_mine(false).await?; + system.provider.anvil_set_interval_mining(1).await?; + + let mut rng = StdRng::from_seed([42u8; 32]); + let keys = vec![ + TestSystem::gen_keys(&mut rng), + TestSystem::gen_keys(&mut rng), + ]; + + StakingTransactions::create( + system.rpc_url.clone(), + &system.provider, + system.stake_table, + keys, + config, + ) + .await? + .apply_all() + .await?; + let l1_block_number = system.provider.get_block_number().await?; + let st = stake_table_info(system.rpc_url, system.stake_table, l1_block_number).await?; + + assert_eq!(st.len(), 2); + assert!(st[0].stake > U256::ZERO); + assert!(st[1].stake > U256::ZERO); + + if let DelegationConfig::NoSelfDelegation = config { + assert!(!st[0].delegators.contains_key(&st[0].account)); + assert!(!st[1].delegators.contains_key(&st[1].account)); + } + + Ok(()) + } } diff --git a/staking-cli/src/deploy.rs b/staking-cli/src/deploy.rs index 0237c1ff03..3857a94da3 100644 --- a/staking-cli/src/deploy.rs +++ b/staking-cli/src/deploy.rs @@ -1,16 +1,15 @@ use std::time::Duration; use alloy::{ - network::{Ethereum, EthereumWallet, TransactionBuilder as _}, + network::{Ethereum, EthereumWallet}, primitives::{utils::parse_ether, Address, U256}, providers::{ ext::AnvilApi as _, fillers::{FillProvider, JoinFill, WalletFiller}, layers::AnvilProvider, utils::JoinedRecommendedFillers, - Provider as _, ProviderBuilder, RootProvider, WalletProvider, + ProviderBuilder, RootProvider, WalletProvider, }, - rpc::types::TransactionRequest, signers::local::PrivateKeySigner, }; use anyhow::Result; @@ -21,7 +20,7 @@ use espresso_contract_deployer::{ use hotshot_contract_adapter::{ sol_types::{ EspToken::{self, EspTokenInstance}, - StakeTable, StakeTableV2, + StakeTableV2, }, stake_table::StakeTableContractVersion, }; @@ -31,8 +30,11 @@ use rand::{rngs::StdRng, CryptoRng, Rng as _, RngCore, SeedableRng as _}; use url::Url; use crate::{ + delegation::{approve, delegate, undelegate}, + funding::{send_esp, send_eth}, parse::Commission, - registration::{fetch_commission, register_validator}, + receipt::ReceiptExt as _, + registration::{deregister_validator, fetch_commission, register_validator}, signature::NodeSignatures, BLSKeyPair, DEV_MNEMONIC, }; @@ -147,13 +149,12 @@ impl TestSystem { let approval_amount = parse_ether("1000000")?; // Approve the stake table contract so it can transfer tokens to itself - let receipt = EspTokenInstance::new(token, &provider) + EspTokenInstance::new(token, &provider) .approve(stake_table, approval_amount) .send() .await? - .get_receipt() + .assert_success() .await?; - assert!(receipt.status()); let mut rng = StdRng::from_seed([42u8; 32]); let (_, bls_key_pair, state_key_pair) = Self::gen_keys(&mut rng); @@ -190,67 +191,59 @@ impl TestSystem { &self.bls_key_pair.clone(), &self.state_key_pair.clone(), ); - let receipt = - register_validator(&self.provider, self.stake_table, self.commission, payload).await?; - assert!(receipt.status()); + register_validator(&self.provider, self.stake_table, self.commission, payload) + .await? + .assert_success() + .await?; Ok(()) } pub async fn deregister_validator(&self) -> Result<()> { - let stake_table = StakeTable::new(self.stake_table, &self.provider); - let receipt = stake_table - .deregisterValidator() - .send() + deregister_validator(&self.provider, self.stake_table) .await? - .get_receipt() + .assert_success() .await?; - assert!(receipt.status()); Ok(()) } pub async fn delegate(&self, amount: U256) -> Result<()> { - let stake_table = StakeTable::new(self.stake_table, &self.provider); - let receipt = stake_table - .delegate(self.deployer_address, amount) - .send() - .await? - .get_receipt() - .await?; - assert!(receipt.status()); + delegate( + &self.provider, + self.stake_table, + self.deployer_address, + amount, + ) + .await? + .assert_success() + .await?; Ok(()) } pub async fn undelegate(&self, amount: U256) -> Result<()> { - let stake_table = StakeTable::new(self.stake_table, &self.provider); - let receipt = stake_table - .undelegate(self.deployer_address, amount) - .send() - .await? - .get_receipt() - .await?; - assert!(receipt.status()); + undelegate( + &self.provider, + self.stake_table, + self.deployer_address, + amount, + ) + .await? + .assert_success() + .await?; Ok(()) } pub async fn transfer_eth(&self, to: Address, amount: U256) -> Result<()> { - let tx = TransactionRequest::default().with_to(to).with_value(amount); - let receipt = self - .provider - .send_transaction(tx) + send_eth(&self.provider, to, amount) .await? - .get_receipt() + .assert_success() .await?; - assert!(receipt.status()); Ok(()) } pub async fn transfer(&self, to: Address, amount: U256) -> Result<()> { - let token = EspToken::new(self.token, &self.provider); - token - .transfer(to, amount) - .send() + send_esp(&self.provider, self.token, to, amount) .await? - .get_receipt() + .assert_success() .await?; Ok(()) } @@ -290,12 +283,9 @@ impl TestSystem { } pub async fn approve(&self, amount: U256) -> Result<()> { - let token = EspToken::new(self.token, &self.provider); - token - .approve(self.stake_table, amount) - .send() + approve(&self.provider, self.token, self.stake_table, amount) .await? - .get_receipt() + .assert_success() .await?; assert!(self.allowance(self.deployer_address).await? == amount); Ok(()) @@ -309,7 +299,7 @@ mod test { #[tokio::test] async fn test_deploy() -> Result<()> { let system = TestSystem::deploy().await?; - let stake_table = StakeTable::new(system.stake_table, &system.provider); + let stake_table = StakeTableV2::new(system.stake_table, &system.provider); // sanity check that we can fetch the exit escrow period assert_eq!( stake_table.exitEscrowPeriod().call().await?, diff --git a/staking-cli/src/funding.rs b/staking-cli/src/funding.rs new file mode 100644 index 0000000000..62e97bd930 --- /dev/null +++ b/staking-cli/src/funding.rs @@ -0,0 +1,36 @@ +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/lib.rs b/staking-cli/src/lib.rs index 35f0c91236..cfca90eed6 100644 --- a/staking-cli/src/lib.rs +++ b/staking-cli/src/lib.rs @@ -19,9 +19,11 @@ use url::Url; pub mod claim; pub mod delegation; pub mod demo; +pub mod funding; pub mod info; pub mod l1; pub mod parse; +pub mod receipt; pub mod registration; pub mod signature; diff --git a/staking-cli/src/main.rs b/staking-cli/src/main.rs index 64ee3f96cb..9168422650 100644 --- a/staking-cli/src/main.rs +++ b/staking-cli/src/main.rs @@ -6,15 +6,23 @@ use alloy::{ eips::BlockId, primitives::{utils::format_ether, Address}, 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}, + sol_types::{ + EspToken::{self, EspTokenErrors, EspTokenEvents}, + StakeTableV2::StakeTableV2Events, + }, +}; +use hotshot_types::{ + light_client::{StateKeyPair, StateVerKey}, + signature_key::BLSPubKey, }; -use hotshot_types::light_client::StateKeyPair; use staking_cli::{ claim::{claim_validator_exit, claim_withdrawal}, delegation::{approve, delegate, undelegate}, @@ -87,14 +95,81 @@ impl Args { } } -fn exit_err(msg: impl AsRef, err: impl core::fmt::Display) -> ! { - tracing::error!("{}: {err}", msg.as_ref()); +fn output_success(msg: impl AsRef) { + if std::env::var("RUST_LOG_FORMAT") == Ok("json".to_string()) { + tracing::info!("{}", msg.as_ref()); + } else { + println!("{}", msg.as_ref()); + } +} + +fn output_error(msg: impl AsRef) -> ! { + if std::env::var("RUST_LOG_FORMAT") == Ok("json".to_string()) { + tracing::error!("{}", msg.as_ref()); + } else { + eprintln!("{}", msg.as_ref()); + } std::process::exit(1); } +fn exit_err(msg: impl AsRef, err: impl core::fmt::Display) -> ! { + output_error(format!("{}: {err}", msg.as_ref())) +} + fn exit(msg: impl AsRef) -> ! { - tracing::error!("Error: {}", msg.as_ref()); - std::process::exit(1); + 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: {} }}", + e.account, + BLSPubKey::from(e.blsVK), + StateVerKey::from(e.schnorrVK), + e.commission + )), + StakeTableV2Events::Delegated(e) => output_success(format!("event: {e:?}")), + StakeTableV2Events::Undelegated(e) => output_success(format!("event: {e:?}")), + StakeTableV2Events::ValidatorExit(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::Withdrawal(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:?}")), + _ => {}, + } + } + } } #[tokio::main] @@ -294,58 +369,47 @@ pub async fn main() -> Result<()> { } // Commands that require a signer - let result = match config.commands { + let pending_tx = match config.commands { Commands::RegisterValidator { signature_args, commission, } => { - tracing::info!("Registering validator {account} with commission {commission}"); let input = NodeSignatureInput::try_from((signature_args, &wallet))?; let payload = NodeSignatures::try_from((input, &wallet))?; - register_validator(&provider, stake_table_addr, commission, payload).await + register_validator(&provider, stake_table_addr, commission, 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 + update_consensus_keys(&provider, stake_table_addr, payload).await? }, Commands::DeregisterValidator {} => { tracing::info!("Deregistering validator {account}"); - deregister_validator(&provider, stake_table_addr).await + 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 + update_commission(&provider, stake_table_addr, new_commission).await? }, Commands::Approve { amount } => { - tracing::info!( - "Approving stake table {} to spend {amount}", - config.stake_table_address - ); - approve(&provider, token_addr, stake_table_addr, amount).await + approve(&provider, token_addr, stake_table_addr, amount).await? }, Commands::Delegate { validator_address, amount, - } => { - tracing::info!("Delegating {amount} to {validator_address}"); - delegate(&provider, stake_table_addr, validator_address, amount).await - }, + } => delegate(&provider, stake_table_addr, validator_address, amount).await?, Commands::Undelegate { validator_address, amount, - } => { - tracing::info!("Undelegating {amount} from {validator_address}"); - undelegate(&provider, stake_table_addr, validator_address, amount).await - }, + } => 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 + 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 + claim_validator_exit(&provider, stake_table_addr, validator_address).await? }, Commands::StakeForDemo { num_validators, @@ -362,21 +426,24 @@ pub async fn main() -> Result<()> { Commands::Transfer { amount, to } => { let amount_esp = format_ether(amount); tracing::info!("Transferring {amount_esp} ESP to {to}"); - Ok(token + token .transfer(to, amount) .send() .await .maybe_decode_revert::()? - .get_receipt() - .await?) }, _ => unreachable!(), }; - match result { - Ok(receipt) => tracing::info!("Success! transaction hash: {}", receipt.transaction_hash), - Err(err) => exit_err("Failed:", err), - }; - - Ok(()) + 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/receipt.rs b/staking-cli/src/receipt.rs new file mode 100644 index 0000000000..3de42f947a --- /dev/null +++ b/staking-cli/src/receipt.rs @@ -0,0 +1,18 @@ +use alloy::{ + network::Ethereum, providers::PendingTransactionBuilder, rpc::types::TransactionReceipt, +}; +use anyhow::{bail, Result}; + +pub(crate) trait ReceiptExt { + async fn assert_success(self) -> Result; +} + +impl ReceiptExt for PendingTransactionBuilder { + async fn assert_success(self) -> Result { + let receipt = self.get_receipt().await?; + if !receipt.status() { + bail!("transaction failed: hash={:?}", receipt.transaction_hash); + } + Ok(receipt) + } +} diff --git a/staking-cli/src/registration.rs b/staking-cli/src/registration.rs index bf65ca45dd..4c1cf57d5e 100644 --- a/staking-cli/src/registration.rs +++ b/staking-cli/src/registration.rs @@ -1,4 +1,8 @@ -use alloy::{primitives::Address, providers::Provider, rpc::types::TransactionReceipt}; +use alloy::{ + network::Ethereum, + primitives::Address, + providers::{PendingTransactionBuilder, Provider}, +}; use anyhow::Result; use hotshot_contract_adapter::{ evm::DecodeRevert as _, @@ -16,11 +20,15 @@ pub async fn register_validator( stake_table_addr: Address, commission: Commission, payload: NodeSignatures, -) -> Result { +) -> 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 stake_table = StakeTableV2::new(stake_table_addr, provider); let sol_payload = NodeSignaturesSol::from(payload); let version = stake_table.getVersion().call().await?.try_into()?; @@ -28,35 +36,27 @@ pub async fn register_validator( // 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::()? - .get_receipt() - .await? - }, - 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(), - ) - .send() - .await - .maybe_decode_revert::()? - .get_receipt() - .await? - }, + 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(), + ) + .send() + .await + .maybe_decode_revert::()?, }) } @@ -64,11 +64,11 @@ pub async fn update_consensus_keys( provider: impl Provider, stake_table_addr: Address, payload: NodeSignatures, -) -> Result { +) -> 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 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 @@ -76,63 +76,51 @@ pub async fn update_consensus_keys( // 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::()? - .get_receipt() - .await? - }, - 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::()? - .get_receipt() - .await? - }, + 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); - Ok(stake_table +) -> Result> { + let stake_table = StakeTableV2::new(stake_table_addr, provider); + stake_table .deregisterValidator() .send() .await - .maybe_decode_revert::()? - .get_receipt() - .await?) + .maybe_decode_revert::() } pub async fn update_commission( provider: impl Provider, stake_table_addr: Address, new_commission: Commission, -) -> Result { - let stake_table = StakeTableV2::new(stake_table_addr, &provider); - Ok(stake_table +) -> Result> { + let stake_table = StakeTableV2::new(stake_table_addr, provider); + stake_table .updateCommission(new_commission.to_evm()) .send() .await - .maybe_decode_revert::()? - .get_receipt() - .await?) + .maybe_decode_revert::() } pub async fn fetch_commission( @@ -140,7 +128,7 @@ pub async fn fetch_commission( stake_table_addr: Address, validator: Address, ) -> Result { - let stake_table = StakeTableV2::new(stake_table_addr, &provider); + let stake_table = StakeTableV2::new(stake_table_addr, provider); let version: StakeTableContractVersion = stake_table.getVersion().call().await?.try_into()?; if matches!(version, StakeTableContractVersion::V1) { anyhow::bail!("fetching commission is not supported with stake table V1"); @@ -168,7 +156,7 @@ mod test { use rand::{rngs::StdRng, SeedableRng as _}; use super::*; - use crate::deploy::TestSystem; + use crate::{deploy::TestSystem, receipt::ReceiptExt}; #[tokio::test] async fn test_register_validator() -> Result<()> { @@ -186,8 +174,9 @@ mod test { system.commission, payload, ) + .await? + .assert_success() .await?; - assert!(receipt.status()); let event = receipt .decoded_log::() @@ -207,8 +196,10 @@ mod test { let system = TestSystem::deploy().await?; system.register_validator().await?; - let receipt = deregister_validator(&system.provider, system.stake_table).await?; - assert!(receipt.status()); + let receipt = deregister_validator(&system.provider, system.stake_table) + .await? + .assert_success() + .await?; let event = receipt .decoded_log::() @@ -227,8 +218,10 @@ 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!(receipt.status()); + let receipt = update_consensus_keys(&system.provider, system.stake_table, payload) + .await? + .assert_success() + .await?; let event = receipt .decoded_log::() @@ -249,13 +242,12 @@ mod test { // Set commission update interval to 1 second for testing let stake_table = StakeTableV2::new(system.stake_table, &system.provider); - let receipt = stake_table + stake_table .setMinCommissionUpdateInterval(U256::from(1)) // 1 second .send() .await? - .get_receipt() + .assert_success() .await?; - assert!(receipt.status()); system.register_validator().await?; let validator_address = system.deployer_address; @@ -264,9 +256,10 @@ 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!(receipt.status()); + let receipt = update_commission(&system.provider, system.stake_table, new_commission) + .await? + .assert_success() + .await?; let event = receipt .decoded_log::() @@ -329,9 +322,8 @@ mod test { .send() .await .maybe_decode_revert::()? - .get_receipt() + .assert_success() .await?; - assert!(receipt.status()); let l1 = L1Client::new(vec![system.rpc_url])?; let events = Fetcher::fetch_events_from_contract( @@ -390,9 +382,8 @@ mod test { .send() .await .maybe_decode_revert::()? - .get_receipt() + .assert_success() .await?; - assert!(receipt.status()); let l1 = L1Client::new(vec![system.rpc_url])?; let events = Fetcher::fetch_events_from_contract( diff --git a/staking-cli/tests/cli.rs b/staking-cli/tests/cli.rs index 0cda76fad7..ac80681aec 100644 --- a/staking-cli/tests/cli.rs +++ b/staking-cli/tests/cli.rs @@ -129,7 +129,8 @@ async fn test_cli_register_validator( .arg("--commission") .arg("12.34") .assert() - .success(); + .success() + .stdout(str::contains("ValidatorRegistered")); }, Signer::BrokeMnemonic => { cmd.arg("register-validator") @@ -141,7 +142,7 @@ async fn test_cli_register_validator( .arg("12.34") .assert() .failure() - .stdout(str::contains("zero Ethereum balance")); + .stderr(str::contains("zero Ethereum balance")); }, Signer::Ledger => unreachable!(), }; @@ -164,7 +165,8 @@ async fn test_cli_update_consensus_keys(#[case] version: StakeTableContractVersi .arg("--state-private-key") .arg(new_state.sign_key().to_tagged_base64()?.to_string()) .assert() - .success(); + .success() + .stdout(str::contains("ConsensusKeysUpdated")); Ok(()) } @@ -183,7 +185,8 @@ async fn test_cli_update_commission() -> Result<()> { .arg("--new-commission") .arg("8.5") .assert() - .success(); + .success() + .stdout(str::contains("CommissionUpdated")); assert_eq!(system.fetch_commission().await?, new_commission); Ok(()) @@ -204,7 +207,8 @@ async fn test_cli_increase_commission_too_soon() -> Result<()> { .arg("--new-commission") .arg(first_update.to_string()) .assert() - .success(); + .success() + .stdout(str::contains("CommissionUpdated")); assert_eq!(system.fetch_commission().await?, first_update); let interval = system.get_min_commission_increase_interval().await?; @@ -219,7 +223,7 @@ async fn test_cli_increase_commission_too_soon() -> Result<()> { .arg(second_update.to_string()) .assert() .failure() - .stdout(str::contains("TooSoon")); + .stderr(str::contains("TooSoon")); assert_eq!(system.fetch_commission().await?, first_update); system.anvil_increase_time(U256::from(5)).await?; @@ -229,7 +233,8 @@ async fn test_cli_increase_commission_too_soon() -> Result<()> { .arg("--new-commission") .arg(second_update.to_string()) .assert() - .success(); + .success() + .stdout(str::contains("CommissionUpdated")); assert_eq!(system.fetch_commission().await?, second_update); Ok(()) @@ -247,7 +252,8 @@ async fn test_cli_delegate(#[case] version: StakeTableContractVersion) -> Result .arg("--amount") .arg("123") .assert() - .success(); + .success() + .stdout(str::contains("Delegated")); Ok(()) } @@ -257,7 +263,10 @@ async fn test_cli_deregister_validator(#[case] version: StakeTableContractVersio system.register_validator().await?; let mut cmd = system.cmd(Signer::Mnemonic); - cmd.arg("deregister-validator").assert().success(); + cmd.arg("deregister-validator") + .assert() + .success() + .stdout(str::contains("ValidatorExit")); Ok(()) } @@ -275,7 +284,8 @@ async fn test_cli_undelegate(#[case] version: StakeTableContractVersion) -> Resu .arg("--amount") .arg(amount) .assert() - .success(); + .success() + .stdout(str::contains("Undelegated")); Ok(()) } @@ -293,7 +303,8 @@ async fn test_cli_claim_withdrawal(#[case] version: StakeTableContractVersion) - .arg("--validator-address") .arg(system.deployer_address.to_string()) .assert() - .success(); + .success() + .stdout(str::contains("Withdrawal")); Ok(()) } @@ -311,7 +322,8 @@ async fn test_cli_claim_validator_exit(#[case] version: StakeTableContractVersio .arg("--validator-address") .arg(system.deployer_address.to_string()) .assert() - .success(); + .success() + .stdout(str::contains("Withdrawal")); Ok(()) } @@ -374,7 +386,8 @@ async fn test_cli_approve(#[case] version: StakeTableContractVersion) -> Result< .arg("--amount") .arg(amount) .assert() - .success(); + .success() + .stdout(str::contains("Approval")); assert!(system.allowance(system.deployer_address).await? == parse_ether(amount)?); @@ -457,7 +470,8 @@ async fn test_cli_transfer(#[case] version: StakeTableContractVersion) -> Result .arg("--amount") .arg(format_ether(amount)) .assert() - .success(); + .success() + .stdout(str::contains("Transfer")); assert_eq!(system.balance(addr).await?, amount); @@ -546,7 +560,8 @@ async fn test_cli_transfer_ledger() -> Result<()> { .arg("--amount") .arg(format_ether(amount)) .assert() - .success(); + .success() + .stdout(str::contains("Transfer")); // Make a token transfer with the ledger println!("Sign the transaction in the ledger"); @@ -558,7 +573,8 @@ async fn test_cli_transfer_ledger() -> Result<()> { .arg("--amount") .arg(format_ether(amount)) .assert() - .success(); + .success() + .stdout(str::contains("Transfer")); assert_eq!(system.balance(addr).await?, amount); @@ -585,7 +601,8 @@ async fn test_cli_delegate_ledger() -> Result<()> { .arg("--amount") .arg(format_ether(amount)) .assert() - .success(); + .success() + .stdout(str::contains("Approval")); println!("Sign the transaction in the ledger (again)"); let mut cmd = system.cmd(Signer::Ledger); @@ -595,7 +612,156 @@ async fn test_cli_delegate_ledger() -> Result<()> { .arg("--amount") .arg(format_ether(amount)) .assert() - .success(); + .success() + .stdout(str::contains("Delegated")); + + Ok(()) +} + +#[test_log::test(rstest_reuse::apply(stake_table_versions))] +async fn test_cli_all_operations_manual_inspect( + #[case] version: StakeTableContractVersion, +) -> Result<()> { + let system = TestSystem::deploy_version(version).await?; + + let output = system + .cmd(Signer::Mnemonic) + .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") + .assert() + .success() + .get_output() + .stdout + .clone(); + println!("{}", String::from_utf8_lossy(&output)); + + let addr = "0x1111111111111111111111111111111111111111"; + let output = system + .cmd(Signer::Mnemonic) + .arg("transfer") + .arg("--to") + .arg(addr) + .arg("--amount") + .arg("100") + .assert() + .success() + .get_output() + .stdout + .clone(); + println!("{}", String::from_utf8_lossy(&output)); + + let output = system + .cmd(Signer::Mnemonic) + .arg("approve") + .arg("--amount") + .arg("1000") + .assert() + .success() + .get_output() + .stdout + .clone(); + println!("{}", String::from_utf8_lossy(&output)); + + let output = system + .cmd(Signer::Mnemonic) + .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)); + + if matches!(version, StakeTableContractVersion::V2) { + let output = system + .cmd(Signer::Mnemonic) + .arg("update-commission") + .arg("--new-commission") + .arg("15.5") + .assert() + .success() + .get_output() + .stdout + .clone(); + println!("{}", String::from_utf8_lossy(&output)); + } + + let mut rng = StdRng::from_seed([99u8; 32]); + let (_, new_bls, new_state) = TestSystem::gen_keys(&mut rng); + let output = system + .cmd(Signer::Mnemonic) + .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() + .get_output() + .stdout + .clone(); + println!("{}", String::from_utf8_lossy(&output)); + + let output = system + .cmd(Signer::Mnemonic) + .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)); + + system.warp_to_unlock_time().await?; + + let output = system + .cmd(Signer::Mnemonic) + .arg("claim-withdrawal") + .arg("--validator-address") + .arg(system.deployer_address.to_string()) + .assert() + .success() + .get_output() + .stdout + .clone(); + println!("{}", String::from_utf8_lossy(&output)); + + let output = system + .cmd(Signer::Mnemonic) + .arg("deregister-validator") + .assert() + .success() + .get_output() + .stdout + .clone(); + println!("{}", String::from_utf8_lossy(&output)); + + system.warp_to_unlock_time().await?; + + let output = system + .cmd(Signer::Mnemonic) + .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)); Ok(()) }