diff --git a/README.md b/README.md index 3d1e77c..76a1e0c 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,11 @@ The configuration is defined by the following spec gatling shoot -c config/default.yaml ``` +For a read test: +```bash +gatling read -c config/default.yaml +``` + ### Output The main output of gomu gomu is the report output location specified in specified in the configuration file. diff --git a/config/read_params/get_events.json b/config/read_params/get_events.json index f6bc91a..a7671e9 100644 --- a/config/read_params/get_events.json +++ b/config/read_params/get_events.json @@ -1,7 +1,7 @@ [ { - "from_block": null, - "to_block": null, + "from_block": {"block_number": null}, + "to_block": {"block_number": null}, "address": null, "keys": [], "continuation_token": null, diff --git a/src/actions/goose.rs b/src/actions/goose.rs index 96d0587..7e70de8 100644 --- a/src/actions/goose.rs +++ b/src/actions/goose.rs @@ -1,6 +1,9 @@ use std::{ mem, - sync::Arc, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, OnceLock, + }, time::{Duration, SystemTime}, }; @@ -9,7 +12,7 @@ use crossbeam_queue::ArrayQueue; use goose::{config::GooseConfiguration, metrics::GooseRequestMetric, prelude::*}; use rand::prelude::SliceRandom; use serde::{de::DeserializeOwned, Serialize}; -use starknet::core::types::{SequencerTransactionStatus, TransactionStatus}; +use starknet::core::types::TransactionReceipt; use starknet::{ accounts::{ Account, Call, ConnectedAccount, ExecutionEncoder, RawExecution, SingleOwnerAccount, @@ -27,11 +30,40 @@ use starknet::{ use crate::{ actions::setup::{GatlingSetup, CHECK_INTERVAL, MAX_FEE}, - config::ParametersFile, + config::{GatlingConfig, ParametersFile}, }; use super::setup::StarknetAccount; +pub fn make_goose_config( + config: &GatlingConfig, + amount: u64, + name: &'static str, +) -> color_eyre::Result { + ensure!( + amount >= config.run.concurrency, + "Too few {name} for the amount of concurrent users" + ); + + // div_euclid will truncate integers when not evenly divisable + let user_iterations = amount.div_euclid(config.run.concurrency); + // this will always be a multiple of concurrency, unlike the provided amount + let total_transactions = user_iterations * config.run.concurrency; + + // If these are not equal that means user_iterations was truncated + if total_transactions != amount { + log::warn!("Number of {name} is not evenly divisble by concurrency, doing {total_transactions} calls instead"); + } + + Ok({ + let mut default = GooseConfiguration::default(); + default.host.clone_from(&config.rpc.url); + default.iterations = user_iterations as usize; + default.users = Some(config.run.concurrency as usize); + default + }) +} + #[derive(Debug, Clone)] pub struct GooseWriteUserState { pub account: StarknetAccount, @@ -88,7 +120,7 @@ pub fn goose_write_user_wait_last_tx() -> TransactionFunction { Box::pin(async move { // If all transactions failed, we can skip this step if let Some(tx) = tx { - wait_for_tx(user, tx).await?; + wait_for_tx_with_goose(user, tx).await?; } Ok(()) @@ -102,32 +134,9 @@ pub async fn read_method( method: JsonRpcMethod, parameters_list: ParametersFile, ) -> color_eyre::Result { - let config = shooter.config(); - - ensure!( - amount >= config.run.concurrency, - "Too few reads for the amount of concurrent users" - ); + let goose_read_config = make_goose_config(shooter.config(), amount, "read calls")?; - // div_euclid will truncate integers when not evenly divisable - let user_iterations = amount.div_euclid(config.run.concurrency); - // this will always be a multiple of concurrency, unlike the provided amount - let total_transactions = user_iterations * config.run.concurrency; - - // If these are not equal that means user_iterations was truncated - if total_transactions != amount { - log::warn!("Number of erc721 mints is not evenly divisble by concurrency, doing {total_transactions} mints instead"); - } - - let goose_get_events_config = { - let mut default = GooseConfiguration::default(); - default.host = config.rpc.url.clone(); - default.iterations = user_iterations as usize; - default.users = Some(config.run.concurrency as usize); - default - }; - - let events: TransactionFunction = Arc::new(move |user| { + let reads: TransactionFunction = Arc::new(move |user| { let mut rng = rand::thread_rng(); let mut params_list = parameters_list.clone(); @@ -146,10 +155,10 @@ pub async fn read_method( }) }); - let metrics = GooseAttack::initialize_with_config(goose_get_events_config)? + let metrics = GooseAttack::initialize_with_config(goose_read_config)? .register_scenario( scenario!("Read Metric") - .register_transaction(Transaction::new(events).set_name("Request")), + .register_transaction(Transaction::new(reads).set_name("Request")), ) .execute() .await?; @@ -157,7 +166,16 @@ pub async fn read_method( Ok(metrics) } -pub async fn verify_transactions(user: &mut GooseUser) -> TransactionResult { +#[derive(Default)] +pub struct TransactionBlocks { + pub first: OnceLock, + pub last: AtomicU64, +} + +pub async fn verify_transactions( + user: &mut GooseUser, + blocks: Arc, +) -> TransactionResult { let transactions = mem::take( &mut user .get_session_data_mut::() @@ -166,24 +184,37 @@ pub async fn verify_transactions(user: &mut GooseUser) -> TransactionResult { ); for tx in transactions { - let (status, mut metrics) = - send_request::(user, JsonRpcMethod::GetTransactionStatus, tx) - .await?; + let (receipt, mut metrics) = + send_request(user, JsonRpcMethod::GetTransactionReceipt, tx).await?; - match status.finality_status() { - SequencerTransactionStatus::Rejected => { - let tag = format!("Transaction {tx:#064x} has been rejected/reverted"); - - return user.set_failure(&tag, &mut metrics, None, None); + match receipt { + MaybePendingTransactionReceipt::Receipt(TransactionReceipt::Invoke(receipt)) => { + match receipt.execution_result { + ExecutionResult::Succeeded => { + let _ = blocks.first.set(receipt.block_number); + blocks.last.store(receipt.block_number, Ordering::Relaxed) + } + ExecutionResult::Reverted { .. } => { + let tag = format!("Transaction {tx:#064x} has been rejected/reverted"); + + return user.set_failure(&tag, &mut metrics, None, None); + } + } + } + MaybePendingTransactionReceipt::Receipt(_) => { + return user.set_failure( + "Receipt is not of type InvokeTransactionReceipt", + &mut metrics, + None, + None, + ); } - SequencerTransactionStatus::Received => { + MaybePendingTransactionReceipt::PendingReceipt(_) => { let tag = format!("Transaction {tx:#064x} is pending when no transactions should be"); return user.set_failure(&tag, &mut metrics, None, None); } - SequencerTransactionStatus::AcceptedOnL1 | SequencerTransactionStatus::AcceptedOnL2 => { - } } } @@ -192,7 +223,8 @@ pub async fn verify_transactions(user: &mut GooseUser) -> TransactionResult { const WAIT_FOR_TX_TIMEOUT: Duration = Duration::from_secs(600); -pub async fn wait_for_tx( +/// This function is different then `crate::utils::wait_for_tx` due to it using the goose requester +pub async fn wait_for_tx_with_goose( user: &mut GooseUser, tx_hash: FieldElement, ) -> Result<(), Box> { diff --git a/src/actions/mod.rs b/src/actions/mod.rs index 23f6a43..a6b0ee6 100644 --- a/src/actions/mod.rs +++ b/src/actions/mod.rs @@ -1,6 +1,6 @@ -use std::sync::Arc; +use std::{fs::File, sync::Arc}; -use starknet::{core::types::BlockId, providers::Provider}; +use log::info; use crate::{ config::GatlingConfig, @@ -9,12 +9,12 @@ use crate::{ use self::{ setup::GatlingSetup, - shooter::{MintShooter, Shooter, TransferShooter}, + shooters::{mint::MintShooter, transfer::TransferShooter, Shooter, ShooterAttack}, }; mod goose; mod setup; -mod shooter; +mod shooters; pub async fn shoot(config: GatlingConfig) -> color_eyre::Result<()> { let total_txs = config.run.num_erc20_transfers + config.run.num_erc721_mints; @@ -27,17 +27,18 @@ pub async fn shoot(config: GatlingConfig) -> color_eyre::Result<()> { let mut global_report = GlobalReport { users: shooter_setup.config().run.concurrency, - all_bench_report: BenchmarkReport::new("".into(), total_txs as usize), + all_bench_report: None, benches: Vec::new(), extra: crate::utils::sysinfo_string(), }; - let start_block = shooter_setup.rpc_client().block_number().await?; + let mut blocks = Option::<(u64, u64)>::None; if shooter_setup.config().run.num_erc20_transfers != 0 { let report = make_report_over_shooter(transfer_shooter, &shooter_setup).await?; - global_report.benches.push(report); + global_report.benches.push(report.0); + blocks.get_or_insert((report.1, report.2)).1 = report.2; } else { log::info!("Skipping erc20 transfers") } @@ -47,55 +48,27 @@ pub async fn shoot(config: GatlingConfig) -> color_eyre::Result<()> { let report = make_report_over_shooter(shooter, &shooter_setup).await?; - global_report.benches.push(report); + global_report.benches.push(report.0); + blocks.get_or_insert((report.1, report.2)).1 = report.2; + } else { log::info!("Skipping erc721 mints") } - let end_block = shooter_setup.rpc_client().block_number().await?; - - for read_bench in &shooter_setup.config().run.read_benches { - let mut params = read_bench.parameters_location.clone(); - - // Look into templating json for these if it becomes more complex to handle - // liquid_json sees like a relatively popular option for this - for parameter in &mut params { - if let Some(from) = parameter.get_mut("from_block") { - if from.is_null() { - *from = serde_json::to_value(BlockId::Number(start_block))?; - } - } - - if let Some(to) = parameter.get_mut("to_block") { - if to.is_null() { - *to = serde_json::to_value(BlockId::Number(end_block))?; - } - } - } - - let metrics = goose::read_method( - &shooter_setup, - read_bench.num_requests, - read_bench.method, - read_bench.parameters_location.clone(), - ) - .await?; - - let mut report = - BenchmarkReport::new(read_bench.name.clone(), metrics.scenarios[0].counter); - - report.with_goose_read_metrics(&metrics)?; + let mut all_bench_report = BenchmarkReport::new("".into(), total_txs as usize); - global_report.benches.push(report); - } + if let Some((start_block, end_block)) = blocks { + info!("Start and End Blocks: {start_block}, {end_block}"); - let rpc_result = global_report - .all_bench_report + let rpc_result = all_bench_report .with_block_range(shooter_setup.rpc_client(), start_block, end_block) .await; - if let Err(error) = rpc_result { - log::error!("Failed to get block range: {error}") + global_report.all_bench_report = Some(all_bench_report); + + if let Err(error) = rpc_result { + log::error!("Failed to get block range: {error}") + } } let report_path = shooter_setup @@ -104,8 +77,7 @@ pub async fn shoot(config: GatlingConfig) -> color_eyre::Result<()> { .output_location .with_extension("json"); - let writer = std::fs::File::create(report_path)?; - serde_json::to_writer_pretty(writer, &global_report)?; + serde_json::to_writer_pretty(File::create(report_path)?, &global_report)?; Ok(()) } @@ -113,21 +85,21 @@ pub async fn shoot(config: GatlingConfig) -> color_eyre::Result<()> { async fn make_report_over_shooter( shooter: S, setup: &GatlingSetup, -) -> color_eyre::Result { +) -> color_eyre::Result<(BenchmarkReport, u64, u64)> { let goose_config = S::get_goose_config(setup.config())?; - let attack = Arc::new(shooter) - .create_goose_attack(goose_config, setup.accounts().to_vec()) + let ShooterAttack { + goose_metrics, + first_block, + last_block, + } = Arc::new(shooter) + .goose_attack(goose_config, setup.accounts().to_vec()) .await?; - let start_block = setup.rpc_client().block_number().await?; - let goose_metrics = attack.execute().await?; - let end_block = setup.rpc_client().block_number().await?; - let mut report = BenchmarkReport::new(S::NAME.to_string(), goose_metrics.scenarios[0].counter); let rpc_result = report - .with_block_range(setup.rpc_client(), start_block + 1, end_block) + .with_block_range(setup.rpc_client(), first_block + 1, last_block) .await; let num_blocks = setup.config().report.num_blocks; @@ -141,5 +113,43 @@ async fn make_report_over_shooter( } report.with_goose_write_metrics(&goose_metrics)?; - Ok(report) + Ok((report, first_block, last_block)) +} + +pub async fn read(config: GatlingConfig) -> color_eyre::Result<()> { + let shooter_setup = GatlingSetup::from_config(config).await?; + + let mut global_report = GlobalReport { + users: shooter_setup.config().run.concurrency, + all_bench_report: None, + benches: Vec::new(), + extra: crate::utils::sysinfo_string(), + }; + + for read_bench in &shooter_setup.config().run.read_benches { + let metrics = goose::read_method( + &shooter_setup, + read_bench.num_requests, + read_bench.method, + read_bench.parameters_location.clone(), + ) + .await?; + + let mut report = + BenchmarkReport::new(read_bench.name.clone(), metrics.scenarios[0].counter); + + report.with_goose_read_metrics(&metrics)?; + + global_report.benches.push(report); + } + + let report_path = shooter_setup + .config() + .report + .output_location + .with_extension("json"); + + serde_json::to_writer_pretty(File::create(report_path)?, &global_report)?; + + Ok(()) } diff --git a/src/actions/shooter.rs b/src/actions/shooter.rs deleted file mode 100644 index 627feb0..0000000 --- a/src/actions/shooter.rs +++ /dev/null @@ -1,381 +0,0 @@ -use std::{collections::HashMap, sync::Arc}; - -use color_eyre::eyre::{bail, ensure}; -use goose::{ - config::GooseConfiguration, - goose::{Scenario, Transaction, TransactionFunction}, - transaction, GooseAttack, -}; -use log::{debug, info, warn}; -use starknet::{ - accounts::{Account, Call, ConnectedAccount}, - contract::ContractFactory, - core::types::{BlockId, BlockTag, FieldElement, InvokeTransactionResult}, - macros::{felt, selector}, - providers::{ - jsonrpc::{HttpTransport, JsonRpcMethod}, - JsonRpcClient, Provider, - }, -}; -use tokio::task::JoinSet; - -use crate::{ - actions::{ - goose::{send_execution, GooseWriteUserState}, - setup::{CHECK_INTERVAL, MAX_FEE}, - }, - config::GatlingConfig, - generators::get_rng, - utils::{compute_contract_address, wait_for_tx}, -}; - -use super::{ - goose::{goose_write_user_wait_last_tx, setup, verify_transactions}, - setup::{GatlingSetup, StarknetAccount}, -}; - -pub trait Shooter { - const NAME: &'static str; - - async fn setup(setup: &mut GatlingSetup) -> color_eyre::Result - where - Self: Sized; - - fn get_goose_config(config: &GatlingConfig) -> color_eyre::Result; - - async fn create_goose_attack( - self: Arc, - config: GooseConfiguration, - accounts: Vec, - ) -> color_eyre::Result - where - Self: Send + Sync + 'static, - { - let setup: TransactionFunction = setup(accounts, config.iterations).await?; - - let submission: TransactionFunction = Self::execute(self.clone()); - - let finalizing: TransactionFunction = goose_write_user_wait_last_tx(); - - let attack = GooseAttack::initialize_with_config(config)?.register_scenario( - Scenario::new(Self::NAME) - .register_transaction(Transaction::new(setup).set_name("Setup").set_on_start()) - .register_transaction( - Transaction::new(submission) - .set_name("Transaction Submission") - .set_sequence(1), - ) - .register_transaction( - Transaction::new(finalizing) - .set_name("Finalizing") - .set_sequence(2) - .set_on_stop(), - ) - .register_transaction( - transaction!(verify_transactions) - .set_name("Verification") - .set_sequence(3) - .set_on_stop(), - ), - ); - - Ok(attack) - } - - fn execute(self: Arc) -> TransactionFunction - where - Self: Send + Sync + 'static, - { - Arc::new(move |user| { - let shooter = self.clone(); - - Box::pin(async move { - let GooseWriteUserState { account, nonce, .. } = user - .get_session_data::() - .expect("Should be in a goose user with GooseUserState session data"); - - let call = shooter.get_execution_data(account); - - let response: InvokeTransactionResult = send_execution( - user, - vec![call], - *nonce, - &account.clone(), - JsonRpcMethod::AddInvokeTransaction, - ) - .await? - .0; - - let GooseWriteUserState { nonce, prev_tx, .. } = - user.get_session_data_mut::().expect( - "Should be successful as we already asserted that the session data is a GooseUserState", - ); - - *nonce += FieldElement::ONE; - - prev_tx.push(response.transaction_hash); - - Ok(()) - }) - }) - } - - fn get_execution_data(&self, account: &StarknetAccount) -> Call; -} - -pub struct TransferShooter { - pub erc20_address: FieldElement, - pub account: StarknetAccount, -} - -pub struct MintShooter { - pub account_to_erc721_addresses: HashMap, - pub recipient: StarknetAccount, -} - -impl Shooter for TransferShooter { - const NAME: &'static str = "Erc20 Transfers"; - - async fn setup(setup: &mut GatlingSetup) -> color_eyre::Result - where - Self: Sized, - { - let class_hash = setup - .declare_contract(&setup.config().setup.erc20_contract.clone()) - .await?; - - let contract_factory = ContractFactory::new(class_hash, setup.deployer_account().clone()); - let nonce = setup.deployer_account().get_nonce().await?; - - let name = selector!("TestToken"); - let symbol = selector!("TT"); - let decimals = felt!("128"); - let (initial_supply_low, initial_supply_high) = - (felt!("0xFFFFFFFFF"), felt!("0xFFFFFFFFF")); - let recipient = setup.deployer_account().address(); - - let constructor_args = vec![ - name, - symbol, - decimals, - initial_supply_low, - initial_supply_high, - recipient, - ]; - let unique = false; - - let address = - compute_contract_address(setup.config().deployer.salt, class_hash, &constructor_args); - - if let Ok(contract_class_hash) = setup - .rpc_client() - .get_class_hash_at(BlockId::Tag(BlockTag::Pending), address) - .await - { - if contract_class_hash == class_hash { - warn!("ERC20 contract already deployed at address {address:#064x}"); - return Ok(TransferShooter { - erc20_address: address, - account: setup.deployer_account().clone(), - }); - } else { - bail!("ERC20 contract {address:#064x} already deployed with a different class hash {contract_class_hash:#064x}, expected {class_hash:#064x}"); - } - } - - let deploy = - contract_factory.deploy(constructor_args, setup.config().deployer.salt, unique); - - info!( - "Deploying ERC20 contract with nonce={}, address={:#064x}", - nonce, address - ); - - let result = deploy.nonce(nonce).max_fee(MAX_FEE).send().await?; - wait_for_tx(setup.rpc_client(), result.transaction_hash, CHECK_INTERVAL).await?; - - debug!( - "Deploy ERC20 transaction accepted {:#064x}", - result.transaction_hash - ); - - info!("ERC20 contract deployed at address {:#064x}", address); - - Ok(TransferShooter { - erc20_address: address, - account: setup.deployer_account().clone(), - }) - } - - fn get_goose_config(config: &GatlingConfig) -> color_eyre::Result { - ensure!( - config.run.num_erc20_transfers >= config.run.concurrency, - "Too few erc20 transfers for the amount of concurrent users" - ); - - // div_euclid will truncate integers when not evenly divisable - let user_iterations = config - .run - .num_erc20_transfers - .div_euclid(config.run.concurrency); - // this will always be a multiple of concurrency, unlike num_erc20_transfers - let total_transactions = user_iterations * config.run.concurrency; - - // If these are not equal that means user_iterations was truncated - if total_transactions != config.run.num_erc20_transfers { - log::warn!("Number of erc20 transfers is not evenly divisble by concurrency, doing {total_transactions} transfers instead"); - } - - { - let mut default = GooseConfiguration::default(); - default.host = config.rpc.url.clone(); - default.iterations = user_iterations as usize; - default.users = Some(config.run.concurrency as usize); - Ok(default) - } - } - - fn get_execution_data(&self, _account: &StarknetAccount) -> Call { - let (amount_low, amount_high) = (felt!("1"), felt!("0")); - - // Hex: 0xdead - // from_hex_be isn't const whereas from_mont is - const VOID_ADDRESS: FieldElement = FieldElement::from_mont([ - 18446744073707727457, - 18446744073709551615, - 18446744073709551615, - 576460752272412784, - ]); - - Call { - to: self.erc20_address, - selector: selector!("transfer"), - calldata: vec![VOID_ADDRESS, amount_low, amount_high], - } - } -} - -impl Shooter for MintShooter { - const NAME: &'static str = "Erc721 Mints"; - - async fn setup(setup: &mut GatlingSetup) -> color_eyre::Result { - let erc721_class_hash = setup - .declare_contract(&setup.config().setup.erc721_contract.clone()) - .await?; - - let deployer_salt = setup.config().deployer.salt; - let mut join_set = JoinSet::new(); - - for account in setup.accounts().iter().cloned() { - let address = account.address(); - let rpc_client = setup.rpc_client().clone(); - join_set.spawn(async move { - let contract = - Self::deploy_erc721(rpc_client, deployer_salt, erc721_class_hash, account) - .await; - - (address, contract) - }); - } - - let mut map = HashMap::with_capacity(setup.accounts().len()); - while let Some((account_address, contract_result)) = - join_set.join_next().await.transpose()? - { - map.insert(account_address, contract_result?); - } - - Ok(Self { - account_to_erc721_addresses: map, - recipient: setup.deployer_account().clone(), - }) - } - - fn get_goose_config(config: &GatlingConfig) -> color_eyre::Result { - ensure!( - config.run.num_erc721_mints >= config.run.concurrency, - "Too few erc721 mints for the amount of concurrent users" - ); - - // div_euclid will truncate integers when not evenly divisable - let user_iterations = config - .run - .num_erc721_mints - .div_euclid(config.run.concurrency); - // this will always be a multiple of concurrency, unlike num_erc721_mints - let total_transactions = user_iterations * config.run.concurrency; - - // If these are not equal that means user_iterations was truncated - if total_transactions != config.run.num_erc721_mints { - log::warn!("Number of erc721 mints is not evenly divisble by concurrency, doing {total_transactions} mints instead"); - } - - { - let mut default = GooseConfiguration::default(); - default.host = config.rpc.url.clone(); - default.iterations = user_iterations as usize; - default.users = Some(config.run.concurrency as usize); - Ok(default) - } - } - - fn get_execution_data(&self, account: &StarknetAccount) -> Call { - let recipient = account.address(); - - let (token_id_low, token_id_high) = (get_rng(), felt!("0x0000")); - - Call { - to: self.account_to_erc721_addresses[&account.address()], - selector: selector!("mint"), - calldata: vec![recipient, token_id_low, token_id_high], - } - } -} - -impl MintShooter { - async fn deploy_erc721( - starknet_rpc: Arc>, - deployer_salt: FieldElement, - class_hash: FieldElement, - recipient: StarknetAccount, - ) -> color_eyre::Result { - let contract_factory = ContractFactory::new(class_hash, &recipient); - - let name = selector!("TestNFT"); - let symbol = selector!("TNFT"); - - let constructor_args = vec![name, symbol, recipient.address()]; - let unique = false; - - let address = compute_contract_address(deployer_salt, class_hash, &constructor_args); - - if let Ok(contract_class_hash) = starknet_rpc - .get_class_hash_at(BlockId::Tag(BlockTag::Pending), address) - .await - { - if contract_class_hash == class_hash { - warn!("ERC721 contract already deployed at address {address:#064x}"); - return Ok(address); - } else { - bail!("ERC721 contract {address:#064x} already deployed with a different class hash {contract_class_hash:#064x}, expected {class_hash:#064x}"); - } - } - - let deploy = contract_factory.deploy(constructor_args, deployer_salt, unique); - - let nonce = recipient.get_nonce().await?; - - info!("Deploying ERC721 with nonce={}, address={address}", nonce); - - let result = deploy.nonce(nonce).max_fee(MAX_FEE).send().await?; - wait_for_tx(&starknet_rpc, result.transaction_hash, CHECK_INTERVAL).await?; - - debug!( - "Deploy ERC721 transaction accepted {:#064x}", - result.transaction_hash - ); - - info!("ERC721 contract deployed at address {:#064x}", address); - Ok(address) - } -} diff --git a/src/actions/shooters.rs b/src/actions/shooters.rs new file mode 100644 index 0000000..c729613 --- /dev/null +++ b/src/actions/shooters.rs @@ -0,0 +1,149 @@ +use std::{boxed::Box, sync::Arc}; + +use color_eyre::eyre::OptionExt; +use goose::{ + config::GooseConfiguration, + goose::{Scenario, Transaction, TransactionFunction}, + metrics::GooseMetrics, + GooseAttack, +}; +use starknet::{ + accounts::Call, + core::types::{FieldElement, InvokeTransactionResult}, + providers::jsonrpc::JsonRpcMethod, +}; + +use crate::{ + actions::goose::{send_execution, GooseWriteUserState}, + config::GatlingConfig, +}; + +use super::{ + goose::{ + goose_write_user_wait_last_tx, make_goose_config, setup, verify_transactions, + TransactionBlocks, + }, + setup::{GatlingSetup, StarknetAccount}, +}; + +pub mod mint; +pub mod transfer; + +pub struct ShooterAttack { + pub goose_metrics: GooseMetrics, + pub first_block: u64, + pub last_block: u64, +} + +pub trait Shooter { + const NAME: &'static str; + + async fn setup(setup: &mut GatlingSetup) -> color_eyre::Result + where + Self: Sized; + + fn get_amount(config: &GatlingConfig) -> u64; + + fn get_goose_config(config: &GatlingConfig) -> color_eyre::Result { + make_goose_config(config, Self::get_amount(config), Self::NAME) + } + + async fn goose_attack( + self: Arc, + config: GooseConfiguration, + accounts: Vec, + ) -> color_eyre::Result + where + Self: Send + Sync + 'static, + { + let setup: TransactionFunction = setup(accounts, config.iterations).await?; + + let submission: TransactionFunction = Self::execute(self.clone()); + + let finalizing: TransactionFunction = goose_write_user_wait_last_tx(); + + let blocks: Arc = Arc::default(); + let blocks_cloned = blocks.clone(); + + let verify_transactions = Transaction::new(Arc::new(move |user| { + Box::pin(verify_transactions(user, blocks_cloned.clone())) + })); + + let goose_attack = GooseAttack::initialize_with_config(config)?.register_scenario( + Scenario::new(Self::NAME) + .register_transaction(Transaction::new(setup).set_name("Setup").set_on_start()) + .register_transaction( + Transaction::new(submission) + .set_name("Transaction Submission") + .set_sequence(1), + ) + .register_transaction( + Transaction::new(finalizing) + .set_name("Finalizing") + .set_sequence(2) + .set_on_stop(), + ) + .register_transaction( + verify_transactions + .set_name("Verification") + .set_sequence(3) + .set_on_stop(), + ), + ); + + let metrics = goose_attack.execute().await?; + + let blocks = Arc::into_inner(blocks).ok_or_eyre( + "Transaction blocks arc has multiple references after goose verification", + )?; + + Ok(ShooterAttack { + goose_metrics: metrics, + first_block: blocks + .first + .into_inner() + .ok_or_eyre("No transactions were verified")?, + last_block: blocks.last.into_inner(), + }) + } + + fn execute(self: Arc) -> TransactionFunction + where + Self: Send + Sync + 'static, + { + Arc::new(move |user| { + let shooter = self.clone(); + + Box::pin(async move { + let GooseWriteUserState { account, nonce, .. } = user + .get_session_data::() + .expect("Should be in a goose user with GooseUserState session data"); + + let call = shooter.get_execution_data(account); + + let response: InvokeTransactionResult = send_execution( + user, + vec![call], + *nonce, + &account.clone(), + JsonRpcMethod::AddInvokeTransaction, + ) + .await? + .0; + + let GooseWriteUserState { nonce, prev_tx, .. } = + user.get_session_data_mut::().expect( + "Should be successful as we already asserted that the session data is a GooseUserState", + ); + + *nonce += FieldElement::ONE; + + prev_tx.push(response.transaction_hash); + + Ok(()) + }) + }) + } + + fn get_execution_data(&self, account: &StarknetAccount) -> Call; +} diff --git a/src/actions/shooters/mint.rs b/src/actions/shooters/mint.rs new file mode 100644 index 0000000..dd81735 --- /dev/null +++ b/src/actions/shooters/mint.rs @@ -0,0 +1,127 @@ +use std::{collections::HashMap, sync::Arc}; + +use color_eyre::eyre::bail; +use log::{debug, info, warn}; +use starknet::{ + accounts::{Account, Call, ConnectedAccount}, + contract::ContractFactory, + core::types::{BlockId, BlockTag, FieldElement}, + macros::{felt, selector}, + providers::{jsonrpc::HttpTransport, JsonRpcClient, Provider}, +}; +use tokio::task::JoinSet; + +use crate::{ + actions::setup::{GatlingSetup, StarknetAccount, CHECK_INTERVAL, MAX_FEE}, + config::GatlingConfig, + generators::get_rng, + utils::{compute_contract_address, wait_for_tx}, +}; + +use super::Shooter; + +pub struct MintShooter { + pub account_to_erc721_addresses: HashMap, + pub recipient: StarknetAccount, +} + +impl Shooter for MintShooter { + const NAME: &'static str = "Erc721 Mints"; + + async fn setup(setup: &mut GatlingSetup) -> color_eyre::Result { + let erc721_class_hash = setup + .declare_contract(&setup.config().setup.erc721_contract.clone()) + .await?; + + let deployer_salt = setup.config().deployer.salt; + let mut join_set = JoinSet::new(); + + for account in setup.accounts().iter().cloned() { + let address = account.address(); + let rpc_client = setup.rpc_client().clone(); + join_set.spawn(async move { + let contract = + Self::deploy_erc721(rpc_client, deployer_salt, erc721_class_hash, account) + .await; + + (address, contract) + }); + } + + let mut map = HashMap::with_capacity(setup.accounts().len()); + while let Some((account_address, contract_result)) = + join_set.join_next().await.transpose()? + { + map.insert(account_address, contract_result?); + } + + Ok(Self { + account_to_erc721_addresses: map, + recipient: setup.deployer_account().clone(), + }) + } + + fn get_amount(config: &GatlingConfig) -> u64 { + config.run.num_erc721_mints + } + + fn get_execution_data(&self, account: &StarknetAccount) -> Call { + let recipient = account.address(); + + let (token_id_low, token_id_high) = (get_rng(), felt!("0x0000")); + + Call { + to: self.account_to_erc721_addresses[&account.address()], + selector: selector!("mint"), + calldata: vec![recipient, token_id_low, token_id_high], + } + } +} + +impl MintShooter { + async fn deploy_erc721( + starknet_rpc: Arc>, + deployer_salt: FieldElement, + class_hash: FieldElement, + recipient: StarknetAccount, + ) -> color_eyre::Result { + let contract_factory = ContractFactory::new(class_hash, &recipient); + + let name = selector!("TestNFT"); + let symbol = selector!("TNFT"); + + let constructor_args = vec![name, symbol, recipient.address()]; + let unique = false; + + let address = compute_contract_address(deployer_salt, class_hash, &constructor_args); + + if let Ok(contract_class_hash) = starknet_rpc + .get_class_hash_at(BlockId::Tag(BlockTag::Pending), address) + .await + { + if contract_class_hash == class_hash { + warn!("ERC721 contract already deployed at address {address:#064x}"); + return Ok(address); + } else { + bail!("ERC721 contract {address:#064x} already deployed with a different class hash {contract_class_hash:#064x}, expected {class_hash:#064x}"); + } + } + + let deploy = contract_factory.deploy(constructor_args, deployer_salt, unique); + + let nonce = recipient.get_nonce().await?; + + info!("Deploying ERC721 with nonce={}, address={address}", nonce); + + let result = deploy.nonce(nonce).max_fee(MAX_FEE).send().await?; + wait_for_tx(&starknet_rpc, result.transaction_hash, CHECK_INTERVAL).await?; + + debug!( + "Deploy ERC721 transaction accepted {:#064x}", + result.transaction_hash + ); + + info!("ERC721 contract deployed at address {:#064x}", address); + Ok(address) + } +} diff --git a/src/actions/shooters/transfer.rs b/src/actions/shooters/transfer.rs new file mode 100644 index 0000000..148b4fa --- /dev/null +++ b/src/actions/shooters/transfer.rs @@ -0,0 +1,120 @@ +use color_eyre::eyre::bail; +use log::{debug, info, warn}; +use starknet::{ + accounts::{Account, Call, ConnectedAccount}, + contract::ContractFactory, + core::types::{BlockId, BlockTag, FieldElement}, + macros::{felt, selector}, + providers::Provider, +}; + +use crate::{ + actions::setup::{GatlingSetup, StarknetAccount, CHECK_INTERVAL, MAX_FEE}, + config::GatlingConfig, + utils::{compute_contract_address, wait_for_tx}, +}; + +use super::Shooter; + +pub struct TransferShooter { + pub erc20_address: FieldElement, + pub account: StarknetAccount, +} + +impl Shooter for TransferShooter { + const NAME: &'static str = "Erc20 Transfers"; + + async fn setup(setup: &mut GatlingSetup) -> color_eyre::Result + where + Self: Sized, + { + let class_hash = setup + .declare_contract(&setup.config().setup.erc20_contract.clone()) + .await?; + + let contract_factory = ContractFactory::new(class_hash, setup.deployer_account().clone()); + let nonce = setup.deployer_account().get_nonce().await?; + + let name = selector!("TestToken"); + let symbol = selector!("TT"); + let decimals = felt!("128"); + let (initial_supply_low, initial_supply_high) = + (felt!("0xFFFFFFFFF"), felt!("0xFFFFFFFFF")); + let recipient = setup.deployer_account().address(); + + let constructor_args = vec![ + name, + symbol, + decimals, + initial_supply_low, + initial_supply_high, + recipient, + ]; + let unique = false; + + let address = + compute_contract_address(setup.config().deployer.salt, class_hash, &constructor_args); + + if let Ok(contract_class_hash) = setup + .rpc_client() + .get_class_hash_at(BlockId::Tag(BlockTag::Pending), address) + .await + { + if contract_class_hash == class_hash { + warn!("ERC20 contract already deployed at address {address:#064x}"); + return Ok(TransferShooter { + erc20_address: address, + account: setup.deployer_account().clone(), + }); + } else { + bail!("ERC20 contract {address:#064x} already deployed with a different class hash {contract_class_hash:#064x}, expected {class_hash:#064x}"); + } + } + + let deploy = + contract_factory.deploy(constructor_args, setup.config().deployer.salt, unique); + + info!( + "Deploying ERC20 contract with nonce={}, address={:#064x}", + nonce, address + ); + + let result = deploy.nonce(nonce).max_fee(MAX_FEE).send().await?; + wait_for_tx(setup.rpc_client(), result.transaction_hash, CHECK_INTERVAL).await?; + + debug!( + "Deploy ERC20 transaction accepted {:#064x}", + result.transaction_hash + ); + + info!("ERC20 contract deployed at address {:#064x}", address); + + Ok(TransferShooter { + erc20_address: address, + account: setup.deployer_account().clone(), + }) + } + + fn get_amount(config: &GatlingConfig) -> u64 { + config.run.num_erc20_transfers + } + + fn get_execution_data(&self, _account: &StarknetAccount) -> Call { + let (amount_low, amount_high) = (felt!("1"), felt!("0")); + + // Hex: 0xdead + // from_hex_be isn't const whereas from_mont is + const VOID_ADDRESS: FieldElement = FieldElement::from_mont([ + 18446744073707727457, + 18446744073709551615, + 18446744073709551615, + 576460752272412784, + ]); + + Call { + to: self.erc20_address, + selector: selector!("transfer"), + calldata: vec![VOID_ADDRESS, amount_low, amount_high], + } + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 04d2f5e..020b3c3 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -25,8 +25,10 @@ pub struct Cli { /// Subcommands #[derive(Subcommand, Debug)] pub enum Command { - /// Trigger a load test. + /// Trigger a write load test. Shoot {}, + // Trigger a read load test + Read {}, } #[derive(Debug, Args)] diff --git a/src/main.rs b/src/main.rs index c3d3643..87d8fbc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,6 +36,9 @@ async fn main() -> Result<()> { Command::Shoot { .. } => { actions::shoot(cfg).await?; } + Command::Read { .. } => { + actions::read(cfg).await?; + } } Ok(()) diff --git a/src/metrics.rs b/src/metrics.rs index b51a767..de80207 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -1,11 +1,8 @@ use crate::utils::get_blocks_with_txs; -use color_eyre::{ - eyre::{bail, OptionExt}, - Result, -}; +use color_eyre::{eyre::OptionExt, Result}; -use goose::metrics::{GooseMetrics, GooseRequestMetricTimingData}; +use goose::metrics::{GooseMetrics, GooseRequestMetricAggregate, GooseRequestMetricTimingData}; use serde_derive::Serialize; use starknet::{ core::types::{ @@ -14,14 +11,15 @@ use starknet::{ }, providers::{jsonrpc::HttpTransport, JsonRpcClient, Provider}, }; -use std::{fmt, sync::Arc}; +use std::{borrow::Cow, fmt, sync::Arc}; pub const BLOCK_TIME: u64 = 6; #[derive(Clone, Debug, Serialize)] pub struct GlobalReport { pub users: u64, - pub all_bench_report: BenchmarkReport, + #[serde(skip_serializing_if = "Option::is_none")] + pub all_bench_report: Option, pub benches: Vec, pub extra: String, } @@ -51,7 +49,7 @@ const GOOSE_TIME_UNIT: &str = "milliseconds"; /// "Average TPS: 1000 transactions/second" #[derive(Debug, Clone, Serialize)] pub struct MetricResult { - pub name: &'static str, + pub name: Cow<'static, str>, pub unit: &'static str, pub value: serde_json::Value, } @@ -128,15 +126,6 @@ impl BenchmarkReport { } pub fn with_goose_write_metrics(&mut self, metrics: &GooseMetrics) -> Result<()> { - let transactions = metrics - .transactions - .first() - .ok_or_eyre("Could no find scenario's transactions")?; - - let [_setup, submission, _finalizing, verification] = transactions.as_slice() else { - bail!("Failed at getting all transaction aggragates") - }; - let submission_requests = metrics .requests .get("POST Transaction Submission") @@ -147,90 +136,8 @@ impl BenchmarkReport { .get("POST Verification") .ok_or_eyre("Found no verification request metrics")?; - const GOOSE_TIME_UNIT: &str = "milliseconds"; - - self.metrics.extend_from_slice(&[ - MetricResult { - name: "Total Submission Time", - unit: GOOSE_TIME_UNIT, - value: submission_requests.raw_data.total_time.into(), - }, - MetricResult { - name: "Total Verification Time", - unit: GOOSE_TIME_UNIT, - value: verification.total_time.into(), - }, - MetricResult { - name: "Failed Transactions Verifications", - unit: "", - value: verification_requests.fail_count.into(), - }, - MetricResult { - name: "Failed Transaction Submissions", - unit: "", - value: submission.fail_count.into(), - }, - MetricResult { - name: "Max Submission Time", - unit: GOOSE_TIME_UNIT, - value: submission_requests.raw_data.maximum_time.into(), - }, - MetricResult { - name: "Min Submission Time", - unit: GOOSE_TIME_UNIT, - value: submission_requests.raw_data.minimum_time.into(), - }, - MetricResult { - name: "Average Submission Time", - unit: GOOSE_TIME_UNIT, - value: transaction_average(&submission_requests.raw_data).into(), - }, - MetricResult { - name: "Max Verification Time", - unit: GOOSE_TIME_UNIT, - value: verification_requests.raw_data.maximum_time.into(), - }, - MetricResult { - name: "Min Verification Time", - unit: GOOSE_TIME_UNIT, - value: verification_requests.raw_data.minimum_time.into(), - }, - MetricResult { - name: "Average Verification Time", - unit: GOOSE_TIME_UNIT, - value: transaction_average(&verification_requests.raw_data).into(), - }, - ]); - - if let Some((sub_p50, sub_90)) = calculate_p50_and_p90(&submission_requests.raw_data) { - self.metrics.extend_from_slice(&[ - MetricResult { - name: "P90 Submission Time", - unit: GOOSE_TIME_UNIT, - value: sub_90.into(), - }, - MetricResult { - name: "P50 Submission Time", - unit: GOOSE_TIME_UNIT, - value: sub_p50.into(), - }, - ]) - } - - if let Some((ver_p50, ver_p90)) = calculate_p50_and_p90(&verification_requests.raw_data) { - self.metrics.extend_from_slice(&[ - MetricResult { - name: "P90 Verification Time", - unit: GOOSE_TIME_UNIT, - value: ver_p90.into(), - }, - MetricResult { - name: "P50 Verification Time", - unit: GOOSE_TIME_UNIT, - value: ver_p50.into(), - }, - ]) - } + self.with_request_metric_aggregate(submission_requests, Some("Submission")); + self.with_request_metric_aggregate(verification_requests, Some("Verifcation")); Ok(()) } @@ -241,29 +148,47 @@ impl BenchmarkReport { .get("POST Request") .ok_or_eyre("Found no read request metrics")?; + self.with_request_metric_aggregate(requests, None); + + Ok(()) + } + + fn with_request_metric_aggregate( + &mut self, + requests: &GooseRequestMetricAggregate, + metric: Option<&str>, + ) { + fn fmt_with_name(template: &'static str, metric: Option<&str>) -> Cow<'static, str> { + if let Some(metric) = metric { + format!("{metric} {template}").into() + } else { + template.into() + } + } + self.metrics.extend_from_slice(&[ MetricResult { - name: "Total Time", + name: fmt_with_name("Total Time", metric), unit: GOOSE_TIME_UNIT, value: requests.raw_data.total_time.into(), }, MetricResult { - name: "Max Time", + name: fmt_with_name("Max Time", metric), unit: GOOSE_TIME_UNIT, value: requests.raw_data.maximum_time.into(), }, MetricResult { - name: "Min Time", + name: fmt_with_name("Min Time", metric), unit: GOOSE_TIME_UNIT, value: requests.raw_data.minimum_time.into(), }, MetricResult { - name: "Average Time", + name: fmt_with_name("Average Time", metric), unit: GOOSE_TIME_UNIT, - value: (requests.raw_data.total_time as f64 / requests.success_count as f64).into(), + value: transaction_average(&requests.raw_data).into(), }, MetricResult { - name: "Failed Requests", + name: fmt_with_name("Failed Requests", metric), unit: "", value: requests.fail_count.into(), }, @@ -272,19 +197,17 @@ impl BenchmarkReport { if let Some((ver_p50, ver_p90)) = calculate_p50_and_p90(&requests.raw_data) { self.metrics.extend_from_slice(&[ MetricResult { - name: "P90 Verification Time", + name: fmt_with_name("P90 Time", metric), unit: GOOSE_TIME_UNIT, value: ver_p90.into(), }, MetricResult { - name: "P50 Verification Time", + name: fmt_with_name("P50 Time", metric), unit: GOOSE_TIME_UNIT, value: ver_p50.into(), }, ]) } - - Ok(()) } } @@ -357,12 +280,12 @@ pub fn compute_node_metrics( let mut metrics = vec![ MetricResult { - name: "Average TPS", + name: "Average TPS".into(), unit: "transactions/second", value: (avg_tpb / BLOCK_TIME as f64).into(), }, MetricResult { - name: "Average Extrinsics per block", + name: "Average Extrinsics per block".into(), unit: "extrinsics/block", value: avg_tpb.into(), }, @@ -387,13 +310,13 @@ pub fn compute_node_metrics( .sum(); metrics.push(MetricResult { - name: "Average UOPS", + name: "Average UOPS".into(), unit: "operations/second", value: (total_uops as f64 / blocks_with_txs.len() as f64 / BLOCK_TIME as f64).into(), }); metrics.push(MetricResult { - name: "Average Steps Per Second", + name: "Average Steps Per Second".into(), unit: "operations/second", value: (total_steps as f64 / blocks_with_txs.len() as f64 / BLOCK_TIME as f64).into(), });