diff --git a/.deny.toml b/.deny.toml index fb1ce3cf..320a9eda 100644 --- a/.deny.toml +++ b/.deny.toml @@ -16,6 +16,7 @@ ignore = [ { id = "RUSTSEC-2026-0097", reason = "`rand` v0.8.5 is present transitively from logos crates, modification may break integration" }, { id = "RUSTSEC-2026-0118", reason = "`hickory-proto` v0.25.0-alpha.5 is present transitively from logos crates, modification may break integration" }, { id = "RUSTSEC-2026-0119", reason = "`hickory-proto` v0.25.0-alpha.5 is present transitively from logos crates, modification may break integration" }, + { id = "RUSTSEC-2026-0145", reason = "`astral-tokio-tar` v0.6.1 is pulled transitively via testcontainers (integration_tests dev/test path); waiting on upstream fix" }, ] yanked = "deny" unused-ignored-advisory = "deny" diff --git a/Cargo.lock b/Cargo.lock index afba784e..6f80b03b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3966,6 +3966,25 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "integration_bench" +version = "0.1.0" +dependencies = [ + "anyhow", + "borsh", + "clap", + "common", + "indexer_service_rpc", + "jsonrpsee", + "nssa", + "sequencer_service_rpc", + "serde", + "serde_json", + "test_fixtures", + "tokio", + "wallet", +] + [[package]] name = "integration_tests" version = "0.1.0" @@ -3975,28 +3994,22 @@ dependencies = [ "authenticated_transfer_core", "bytesize", "common", - "env_logger", "faucet_core", - "futures", "hex", "indexer_ffi", - "indexer_service", "indexer_service_protocol", "indexer_service_rpc", - "jsonrpsee", "key_protocol", "log", "nssa", "nssa_core", "sequencer_core", - "sequencer_service", "sequencer_service_rpc", "serde_json", "tempfile", - "testcontainers", + "test_fixtures", "token_core", "tokio", - "url", "vault_core", "wallet", "wallet-ffi", @@ -9198,6 +9211,34 @@ dependencies = [ "test-case-core", ] +[[package]] +name = "test_fixtures" +version = "0.1.0" +dependencies = [ + "anyhow", + "bytesize", + "common", + "env_logger", + "futures", + "indexer_service", + "jsonrpsee", + "key_protocol", + "log", + "nssa", + "nssa_core", + "sequencer_core", + "sequencer_service", + "sequencer_service_rpc", + "serde", + "serde_json", + "tempfile", + "testcontainers", + "tokio", + "url", + "vault_core", + "wallet", +] + [[package]] name = "test_program_methods" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 27f3564b..b3dd2f2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,8 +41,10 @@ members = [ "examples/program_deployment/methods/guest", "testnet_initial_state", "indexer/ffi", + "test_fixtures", "tools/cycle_bench", "tools/crypto_primitives_bench", + "tools/integration_bench", ] [workspace.dependencies] @@ -75,6 +77,7 @@ faucet_core = { path = "programs/faucet/core" } vault_core = { path = "programs/vault/core" } test_program_methods = { path = "test_program_methods" } testnet_initial_state = { path = "testnet_initial_state" } +test_fixtures = { path = "test_fixtures" } tokio = { version = "1.50", features = [ "net", diff --git a/docs/benchmarks/README.md b/docs/benchmarks/README.md index 9289d2bf..d745f2f3 100644 --- a/docs/benchmarks/README.md +++ b/docs/benchmarks/README.md @@ -6,5 +6,6 @@ Bench tools live under `tools/` with READMEs for how to run each one. This direc |---|---| | cycle_bench | [cycle_bench.md](cycle_bench.md) | | crypto_primitives_bench | [crypto_primitives_bench.md](crypto_primitives_bench.md) | +| integration_bench | [integration_bench.md](integration_bench.md) | All numbers are from a single M2 Pro dev box unless noted otherwise. diff --git a/docs/benchmarks/integration_bench.md b/docs/benchmarks/integration_bench.md new file mode 100644 index 00000000..dd7bfe6e --- /dev/null +++ b/docs/benchmarks/integration_bench.md @@ -0,0 +1,120 @@ +# integration_bench + +End-to-end LEZ scenarios driven through the wallet against a docker-compose Bedrock node + in-process sequencer + indexer (via `test_fixtures::TestContext`). Times each step and records borsh sizes per block, split by tx variant. + +Numbers below are from a single-host docker-compose run on an Apple M2 Pro (CPU only, no GPU acceleration). Absolute wall time and block sizes depend heavily on the bedrock config (block cadence and confirmation depth) and on dev-mode vs real proving; re-run the bench locally to characterise your own setup. + +## Scenarios + +| Scenario | Description | +|---|---| +| token | Sequential public token Send + one shielded recipient setup. | +| amm | Pool create, add liquidity, swap, remove liquidity. All public. | +| fanout | One sender → N recipients, sequential. All public. | +| private | Shielded, deshielded, private→private chained private flow. | +| parallel | N senders submit concurrently into one block. All public. | + +## Dev-mode vs real-proving + +`RISC0_DEV_MODE=1` makes the prover emit stub receipts instead of running the recursive STARK pipeline. The table compares each quantity in dev mode vs real proving for the two classes of scenarios: + +| Quantity | Public-only scenarios (dev → real) | PPE-bearing scenarios (dev → real) | +|---|---|---| +| Wall time per step | same in both modes | real adds ~100 s per PPE step | +| `public_tx_bytes` | same in both modes | same in both modes | +| `ppe_tx_bytes` | n/a | dev ≈ 2 KB stub → real ≈ 225 KB (matches `S_agg` from cycle_bench) | +| `block_bytes` | same in both modes | real adds ~225 KB per PPE tx in the block | +| `bedrock_finality_s` | same in both modes | same in both modes (L1 cadence, not LEZ prover) | +| Blocks captured | similar in both modes | real captures more empty clock-only ticks that fill prove wall-time | + +Tables below report dev-mode for all five scenarios. Real-proving numbers are included for `amm_swap_flow` (representative all-public) and `private_chained_flow` (representative chained-private flow); public-only scenarios converge between modes within run-to-run jitter, so a full real-proving sweep is not run here. + +## Methodology + +Per scenario, every produced block is fetched via `getBlock(BlockId)` and serialized with `borsh::to_vec(&Block)`. Each transaction is serialized individually and counted by variant. Empty clock-only ticks give the per-block fixed-cost baseline. Wall time is captured per step (submit + inclusion + wallet sync) and aggregated to the per-scenario `total_s`. The one-time stack-setup cost (`shared_setup_s` at the run level) and the closing bedrock finality wait (`bedrock_finality_s` per scenario) are reported separately, not folded into `total_s`. + +## Step latencies — dev mode (`RISC0_DEV_MODE=1`) + +Per-scenario wall time and Bedrock L1-finality latency for the closing tip. + +| Scenario | total_s | bedrock_finality_s | +|---|---:|---:| +| token_onboarding | 61.36 | 5.88 | +| amm_swap_flow | 156.50 | 27.99 | +| multi_recipient_fanout | 214.40 | 31.71 | +| private_chained_flow | 109.31 | 8.73 | +| parallel_fanout | 234.42 | 20.29 | + +Shared TestContext setup: 139.80 s (paid once per run). Total dev-mode wall time across all five scenarios: 1010.4 s. + +## Step latencies — real proving (selected scenarios) + +| Scenario | total_s | bedrock_finality_s | Δ vs dev | +|---|---:|---:|---:| +| amm_swap_flow | 156.20 | 26.95 | ~0 (all-public) | +| private_chained_flow | 391.74 | 9.40 | +282.4 s (≈ 94 s per PPE step × 3) | + +Per-step breakdown for `private_chained_flow` in real proving: + +| Step | submit_s | inclusion_s | total_s | +|---|---:|---:|---:| +| token_new_fungible (public) | 0.003 | 10.857 | 11.006 | +| shielded_transfer (PPE) | 125.416 | 0.001 | 125.469 | +| deshielded_transfer (PPE) | 126.261 | 0.001 | 126.311 | +| private_to_private (PPE) | 128.875 | 0.001 | 128.934 | + +PPE steps move the cost from `inclusion_s` (waiting for the next sealed block) to `submit_s` (the wallet itself proving the PPE circuit before sending). Each PPE prove is ≈ 127 s on this CPU. + +## Block + tx sizes (borsh) — dev mode + +Per scenario, every produced block is fetched via `getBlock(BlockId)` and serialized with `borsh::to_vec(&Block)`. Each transaction is serialized individually and counted by variant. The empty clock-only ticks at `min` give the per-block fixed-cost baseline (≈ 334 bytes across all scenarios). + +| Scenario | blocks | block_bytes (mean) | block_bytes (min..max) | public_tx (mean / n) | ppe_tx (mean / n) | +|---|---:|---:|---|---:|---:| +| token_onboarding | 6 | 881 | 334..2,890 | 206 / 8 | 2,556 / 1 | +| amm_swap_flow | 16 | 553 | 334..1,011 | 248 / 24 | n/a | +| multi_recipient_fanout | 22 | 513 | 334..707 | 221 / 33 | n/a | +| private_chained_flow | 10 | 1,186 | 334..3,565 | 173 / 11 | 2,715 / 3 | +| parallel_fanout | 24 | 646 | 334..3,904 | 248 / 45 | n/a | + +## Block + tx sizes (borsh) — real proving + +| Scenario | blocks | block_bytes (mean) | block_bytes (min..max) | public_tx (mean / n) | ppe_tx (mean / n) | +|---|---:|---:|---|---:|---:| +| amm_swap_flow | 16 | 553 | 334..1,011 | 248 / 24 | n/a | +| private_chained_flow | 39 | 17,707 | 334..226,578 | 158 / 40 | 225,728 / 3 | + +`amm_swap_flow` is byte-identical between dev and real (no proof payload). `private_chained_flow`'s `ppe_tx_bytes` matches the cycle_bench `S_agg` measurement (≈ 225 KB borsh InnerReceipt). The `block_bytes` max (226,578) is the block containing the largest PPE transaction. + +## Findings + +- Public-only scenarios converge between dev mode and real proving in both latency and byte counts. Either mode is suitable to characterize them. +- PPE transactions are ≈ 225 KB on the wire in real proving, dominated by the outer succinct proof. Dev mode emits a ≈ 2.7 KB stub that does not represent the L1 payload; fee-model storage gas inputs must come from a real-proving run. +- Per-PPE-step prove cost on this CPU is ≈ 127 s, paid on the wallet side at submit time, not on the sequencer. For a single-program chained flow the cost stacks linearly. +- Empty clock-only ticks set the per-block fixed-cost baseline at ≈ 334 bytes across all scenarios and both modes. +- Bedrock L1 finality varies in the 6 to 32 s range across scenarios, driven by L1 cadence and which tick the closing wait happens to land on, not by the LEZ prover. + +## Reproduce + +Prerequisite: a running local Docker daemon (the `bedrock/docker-compose.yml` is brought up by the bench). + +```sh +# Dev-mode sweep (fast) +RISC0_DEV_MODE=1 cargo run --release -p integration_bench -- --scenario all + +# Real-proving for representative private flow +cargo run --release -p integration_bench -- --scenario private + +# Real-proving for representative public flow +cargo run --release -p integration_bench -- --scenario amm +``` + +JSON output: `target/integration_bench_dev.json` / `target/integration_bench_prove.json` (suffix toggled by `RISC0_DEV_MODE`). + +## Caveats + +- Dev-mode `ppe_tx_bytes` and PPE-step latencies are not representative of production; use real-proving numbers for any fee-model input that touches the storage or prover-cost components. +- Single-host run, no GPU acceleration. Real-proving on production prover hardware will move per-step latencies by orders of magnitude; byte counts will not change. +- Bedrock running locally via docker-compose; no real network latency between sequencer and Bedrock. +- Bedrock L1 finality (`bedrock_finality_s`) is set by the bedrock config in `bedrock/docker-compose.yml` (block cadence × confirmation depth). Different configs will shift `bedrock_finality_s` materially. +- All scenarios share a single TestContext for the run (one bedrock + sequencer + indexer + wallet for the whole run, chain state accumulating across scenarios), which matches how the node runs in production. diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index 536f30bc..82d8ebd1 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -8,15 +8,15 @@ license = { workspace = true } workspace = true [dependencies] +test_fixtures.workspace = true + nssa_core = { workspace = true, features = ["host"] } nssa.workspace = true authenticated_transfer_core.workspace = true sequencer_core = { workspace = true, features = ["default", "testnet"] } -sequencer_service.workspace = true wallet.workspace = true common.workspace = true key_protocol.workspace = true -indexer_service.workspace = true serde_json.workspace = true token_core.workspace = true ata_core.workspace = true @@ -24,18 +24,13 @@ vault_core.workspace = true faucet_core.workspace = true indexer_service_rpc = { workspace = true, features = ["client"] } sequencer_service_rpc = { workspace = true, features = ["client"] } -jsonrpsee = { workspace = true, features = ["ws-client"] } wallet-ffi.workspace = true indexer_ffi.workspace = true indexer_service_protocol.workspace = true -url.workspace = true anyhow.workspace = true -env_logger.workspace = true log.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } hex.workspace = true tempfile.workspace = true bytesize.workspace = true -futures.workspace = true -testcontainers = { version = "0.27.3", features = ["docker-compose"] } diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index 3662e006..d3fa7c64 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -1,441 +1,6 @@ -//! This library contains common code for integration tests. +//! Integration test helpers, re-exported from `test_fixtures` for backwards +//! compatibility. The actual fixtures live in the `test_fixtures` crate so that +//! non-test consumers (e.g. `integration_bench`) can depend on them without +//! pulling in the test files. -use std::{net::SocketAddr, sync::LazyLock}; - -use anyhow::{Context as _, Result}; -use common::{HashType, transaction::NSSATransaction}; -use futures::FutureExt as _; -use indexer_service::IndexerHandle; -use log::{debug, error}; -use nssa::{AccountId, PrivacyPreservingTransaction}; -use nssa_core::Commitment; -use sequencer_core::config::GenesisAction; -use sequencer_service::SequencerHandle; -use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder}; -use tempfile::TempDir; -use testcontainers::compose::DockerCompose; -use wallet::{WalletCore, account::AccountIdWithPrivacy, cli::CliAccountMention}; - -use crate::{ - indexer_client::IndexerClient, - setup::{ - setup_bedrock_node, setup_indexer, setup_private_accounts_with_initial_supply, - setup_public_accounts_with_initial_supply, setup_sequencer, setup_wallet, - }, -}; - -pub mod config; -pub mod indexer_client; -pub mod setup; - -// TODO: Remove this and control time from tests -pub const TIME_TO_WAIT_FOR_BLOCK_SECONDS: u64 = 12; -pub const NSSA_PROGRAM_FOR_TEST_DATA_CHANGER: &str = "data_changer.bin"; -pub const NSSA_PROGRAM_FOR_TEST_NOOP: &str = "noop.bin"; -pub const NSSA_PROGRAM_FOR_TEST_PDA_FUND_SPEND_PROXY: &str = "pda_fund_spend_proxy.bin"; - -const BEDROCK_SERVICE_WITH_OPEN_PORT: &str = "logos-blockchain-node-0"; -const BEDROCK_SERVICE_PORT: u16 = 18080; - -static LOGGER: LazyLock<()> = LazyLock::new(env_logger::init); - -struct IndexerComponents { - indexer_handle: IndexerHandle, - indexer_client: IndexerClient, - _temp_dir: TempDir, -} - -impl Drop for IndexerComponents { - fn drop(&mut self) { - let Self { - indexer_handle, - indexer_client: _, - _temp_dir: _, - } = self; - - if !indexer_handle.is_healthy() { - error!("Indexer handle has unexpectedly stopped before IndexerComponents drop"); - } - } -} - -/// Test context which sets up a sequencer and a wallet for integration tests. -/// -/// It's memory and logically safe to create multiple instances of this struct in parallel tests, -/// as each instance uses its own temporary directories for sequencer and wallet data. -// NOTE: Order of fields is important for proper drop order. -pub struct TestContext { - sequencer_client: SequencerClient, - wallet: WalletCore, - wallet_password: String, - /// Optional to move out value in Drop. - sequencer_handle: Option, - indexer_components: Option, - bedrock_compose: DockerCompose, - bedrock_addr: SocketAddr, - _temp_sequencer_dir: TempDir, - _temp_wallet_dir: TempDir, -} - -impl TestContext { - /// Create new test context. - pub async fn new() -> Result { - Self::builder().build().await - } - - /// Get a builder for the test context to customize its configuration. - #[must_use] - pub const fn builder() -> TestContextBuilder { - TestContextBuilder::new() - } - - /// Get reference to the wallet. - #[must_use] - pub const fn wallet(&self) -> &WalletCore { - &self.wallet - } - - #[must_use] - pub fn wallet_password(&self) -> &str { - &self.wallet_password - } - - /// Get mutable reference to the wallet. - pub const fn wallet_mut(&mut self) -> &mut WalletCore { - &mut self.wallet - } - - /// Get reference to the sequencer client. - #[must_use] - pub const fn sequencer_client(&self) -> &SequencerClient { - &self.sequencer_client - } - - /// Get the Bedrock Node address. - #[must_use] - pub const fn bedrock_addr(&self) -> SocketAddr { - self.bedrock_addr - } - - /// Get reference to the indexer. - /// - /// # Panics - /// - /// Panics if the indexer is not enabled in the test context. See - /// [`TestContextBuilder::disable_indexer()`]. - #[must_use] - pub fn indexer(&self) -> &IndexerHandle { - self.indexer_components - .as_ref() - .map(|components| &components.indexer_handle) - .expect("Called `TestContext::indexer()` on context with disabled indexer") - } - - /// Get reference to the indexer client. - /// - /// # Panics - /// - /// Panics if the indexer is not enabled in the test context. See - /// [`TestContextBuilder::disable_indexer()`]. - #[must_use] - pub fn indexer_client(&self) -> &IndexerClient { - self.indexer_components - .as_ref() - .map(|components| &components.indexer_client) - .expect("Called `TestContext::indexer_client()` on context with disabled indexer") - } - - /// Get existing public account IDs in the wallet. - #[must_use] - pub fn existing_public_accounts(&self) -> Vec { - self.wallet - .storage() - .key_chain() - .public_account_ids() - .map(|(account_id, _idx)| account_id) - .collect() - } - - /// Get existing private account IDs in the wallet. - #[must_use] - pub fn existing_private_accounts(&self) -> Vec { - self.wallet - .storage() - .key_chain() - .private_account_ids() - .map(|(account_id, _idx)| account_id) - .collect() - } -} - -impl Drop for TestContext { - fn drop(&mut self) { - let Self { - sequencer_handle, - bedrock_compose, - bedrock_addr: _, - indexer_components: _, - sequencer_client: _, - wallet: _, - wallet_password: _, - _temp_sequencer_dir: _, - _temp_wallet_dir: _, - } = self; - - let sequencer_handle = sequencer_handle - .take() - .expect("Sequencer handle should be present in TestContext drop"); - if !sequencer_handle.is_healthy() { - let Err(err) = sequencer_handle - .failed() - .now_or_never() - .expect("Sequencer handle should not be running"); - error!( - "Sequencer handle has unexpectedly stopped before TestContext drop with error: {err:#}" - ); - } - - let container = bedrock_compose - .service(BEDROCK_SERVICE_WITH_OPEN_PORT) - .unwrap_or_else(|| { - panic!("Failed to get Bedrock service container `{BEDROCK_SERVICE_WITH_OPEN_PORT}`") - }); - let output = std::process::Command::new("docker") - .args(["inspect", "-f", "{{.State.Running}}", container.id()]) - .output() - .expect("Failed to execute docker inspect command to check if Bedrock container is still running"); - let stdout = String::from_utf8(output.stdout) - .expect("Failed to parse docker inspect output as String"); - if stdout.trim() != "true" { - error!( - "Bedrock container `{}` is not running during TestContext drop, docker inspect output: {stdout}", - container.id() - ); - } - } -} - -pub struct TestContextBuilder { - genesis_transactions: Option>, - sequencer_partial_config: Option, - enable_indexer: bool, -} - -impl TestContextBuilder { - const fn new() -> Self { - Self { - genesis_transactions: None, - sequencer_partial_config: None, - enable_indexer: true, - } - } - - #[must_use] - pub fn with_genesis(mut self, genesis_transactions: Vec) -> Self { - self.genesis_transactions = Some(genesis_transactions); - self - } - - #[must_use] - pub const fn with_sequencer_partial_config( - mut self, - sequencer_partial_config: config::SequencerPartialConfig, - ) -> Self { - self.sequencer_partial_config = Some(sequencer_partial_config); - self - } - - /// Exclude Indexer from test context. - /// Indexer is enabled by default. - /// - /// Methods like [`TestContext::indexer()`] and [`TestContext::indexer_client()`] will panic if - /// called when indexer is disabled. - #[must_use] - pub const fn disable_indexer(mut self) -> Self { - self.enable_indexer = false; - self - } - - pub async fn build(self) -> Result { - let Self { - genesis_transactions, - sequencer_partial_config, - enable_indexer, - } = self; - - // Ensure logger is initialized only once - *LOGGER; - - debug!("Test context setup"); - - let (bedrock_compose, bedrock_addr) = setup_bedrock_node() - .await - .context("Failed to setup Bedrock node")?; - - let indexer_components = if enable_indexer { - let (indexer_handle, temp_indexer_dir) = setup_indexer(bedrock_addr) - .await - .context("Failed to setup Indexer")?; - let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, indexer_handle.addr()) - .context("Failed to convert indexer addr to URL")?; - let indexer_client = IndexerClient::new(&indexer_url) - .await - .context("Failed to create indexer client")?; - Some(IndexerComponents { - indexer_handle, - indexer_client, - _temp_dir: temp_indexer_dir, - }) - } else { - None - }; - - let initial_public_accounts = config::default_public_accounts_for_wallet(); - let initial_private_accounts = config::default_private_accounts_for_wallet(); - let (sequencer_handle, temp_sequencer_dir) = setup_sequencer( - sequencer_partial_config.unwrap_or_default(), - bedrock_addr, - genesis_transactions.unwrap_or_else(|| { - config::genesis_from_accounts(&initial_public_accounts, &initial_private_accounts) - }), - ) - .await - .context("Failed to setup Sequencer")?; - - let (mut wallet, temp_wallet_dir, wallet_password) = setup_wallet( - sequencer_handle.addr(), - &initial_public_accounts, - &initial_private_accounts, - ) - .context("Failed to setup wallet")?; - - setup_public_accounts_with_initial_supply(&wallet, &initial_public_accounts) - .await - .context("Failed to initialize public accounts in wallet")?; - - setup_private_accounts_with_initial_supply(&mut wallet, &initial_private_accounts) - .await - .context("Failed to initialize private accounts in wallet")?; - - let sequencer_url = config::addr_to_url(config::UrlProtocol::Http, sequencer_handle.addr()) - .context("Failed to convert sequencer addr to URL")?; - let sequencer_client = SequencerClientBuilder::default() - .build(sequencer_url) - .context("Failed to create sequencer client")?; - - Ok(TestContext { - sequencer_client, - wallet, - wallet_password, - bedrock_compose, - bedrock_addr, - sequencer_handle: Some(sequencer_handle), - indexer_components, - _temp_sequencer_dir: temp_sequencer_dir, - _temp_wallet_dir: temp_wallet_dir, - }) - } - - pub fn build_blocking(self) -> Result { - let runtime = tokio::runtime::Runtime::new().context("Failed to create Tokio runtime")?; - - let ctx = runtime.block_on(self.build())?; - - Ok(BlockingTestContext { - ctx: Some(ctx), - runtime, - }) - } -} -/// A test context to be used in normal #[test] tests. -pub struct BlockingTestContext { - ctx: Option, - runtime: tokio::runtime::Runtime, -} - -impl BlockingTestContext { - pub fn new() -> Result { - TestContext::builder().build_blocking() - } - - pub const fn ctx(&self) -> &TestContext { - self.ctx.as_ref().expect("TestContext is set") - } - - pub const fn runtime(&self) -> &tokio::runtime::Runtime { - &self.runtime - } - - pub fn block_on<'ctx, F>(&'ctx self, f: impl FnOnce(&'ctx TestContext) -> F) -> F::Output - where - F: std::future::Future + 'ctx, - { - let future = f(self.ctx()); - self.runtime.block_on(future) - } - - pub fn block_on_mut<'ctx, F>( - &'ctx mut self, - f: impl FnOnce(&'ctx mut TestContext) -> F, - ) -> F::Output - where - F: std::future::Future + 'ctx, - { - let ctx_mut = self.ctx.as_mut().expect("TestContext is set"); - let future = f(ctx_mut); - self.runtime.block_on(future) - } -} - -impl Drop for BlockingTestContext { - fn drop(&mut self) { - let Self { ctx, runtime } = self; - - // Ensure async cleanup of TestContext by blocking on its drop in the runtime. - runtime.block_on(async { - if let Some(ctx) = ctx.take() { - drop(ctx); - } - }); - } -} - -#[must_use] -pub const fn public_mention(account_id: AccountId) -> CliAccountMention { - CliAccountMention::Id(AccountIdWithPrivacy::Public(account_id)) -} - -#[must_use] -pub const fn private_mention(account_id: AccountId) -> CliAccountMention { - CliAccountMention::Id(AccountIdWithPrivacy::Private(account_id)) -} - -#[expect( - clippy::wildcard_enum_match_arm, - reason = "We want the code to panic if the transaction type is not PrivacyPreserving" -)] -pub async fn fetch_privacy_preserving_tx( - seq_client: &SequencerClient, - tx_hash: HashType, -) -> PrivacyPreservingTransaction { - let tx = seq_client.get_transaction(tx_hash).await.unwrap().unwrap(); - - match tx { - NSSATransaction::PrivacyPreserving(privacy_preserving_transaction) => { - privacy_preserving_transaction - } - _ => panic!("Invalid tx type"), - } -} - -pub async fn verify_commitment_is_in_state( - commitment: Commitment, - seq_client: &SequencerClient, -) -> bool { - seq_client - .get_proof_for_commitment(commitment) - .await - .ok() - .flatten() - .is_some() -} +pub use test_fixtures::*; diff --git a/test_fixtures/Cargo.toml b/test_fixtures/Cargo.toml new file mode 100644 index 00000000..1bfd2284 --- /dev/null +++ b/test_fixtures/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "test_fixtures" +version = "0.1.0" +edition = "2024" +license = { workspace = true } +publish = false + +[lints] +workspace = true + +[dependencies] +common.workspace = true +indexer_service.workspace = true +key_protocol.workspace = true +nssa.workspace = true +nssa_core = { workspace = true, features = ["host"] } +sequencer_core = { workspace = true, features = ["default", "testnet"] } +sequencer_service.workspace = true +sequencer_service_rpc = { workspace = true, features = ["client"] } +vault_core.workspace = true +wallet.workspace = true + +anyhow.workspace = true +bytesize.workspace = true +env_logger.workspace = true +futures.workspace = true +jsonrpsee = { workspace = true, features = ["ws-client"] } +log.workspace = true +serde.workspace = true +serde_json.workspace = true +tempfile.workspace = true +testcontainers = { version = "0.27.3", features = ["docker-compose"] } +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +url.workspace = true diff --git a/integration_tests/src/config.rs b/test_fixtures/src/config.rs similarity index 100% rename from integration_tests/src/config.rs rename to test_fixtures/src/config.rs diff --git a/integration_tests/src/indexer_client.rs b/test_fixtures/src/indexer_client.rs similarity index 100% rename from integration_tests/src/indexer_client.rs rename to test_fixtures/src/indexer_client.rs diff --git a/test_fixtures/src/lib.rs b/test_fixtures/src/lib.rs new file mode 100644 index 00000000..2c9dfb3a --- /dev/null +++ b/test_fixtures/src/lib.rs @@ -0,0 +1,496 @@ +//! Shared test/bench fixtures: spins up bedrock + sequencer + indexer + wallet +//! end-to-end against docker-compose, exposes a `TestContext` callers can drive. + +use std::{net::SocketAddr, path::Path, sync::LazyLock}; + +use anyhow::{Context as _, Result}; +use common::{HashType, transaction::NSSATransaction}; +use futures::FutureExt as _; +use indexer_service::IndexerHandle; +use log::{debug, error}; +use nssa::{AccountId, PrivacyPreservingTransaction}; +use nssa_core::Commitment; +use sequencer_core::config::GenesisAction; +use sequencer_service::SequencerHandle; +use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder}; +use serde::Serialize; +use tempfile::TempDir; +use testcontainers::compose::DockerCompose; +use wallet::{WalletCore, account::AccountIdWithPrivacy, cli::CliAccountMention}; + +use crate::{ + indexer_client::IndexerClient, + setup::{ + setup_bedrock_node, setup_indexer, setup_private_accounts_with_initial_supply, + setup_public_accounts_with_initial_supply, setup_sequencer, setup_wallet, + }, +}; + +pub mod config; +pub mod indexer_client; +pub mod setup; + +// TODO: Remove this and control time from tests +pub const TIME_TO_WAIT_FOR_BLOCK_SECONDS: u64 = 12; +pub const NSSA_PROGRAM_FOR_TEST_DATA_CHANGER: &str = "data_changer.bin"; +pub const NSSA_PROGRAM_FOR_TEST_NOOP: &str = "noop.bin"; +pub const NSSA_PROGRAM_FOR_TEST_PDA_FUND_SPEND_PROXY: &str = "pda_fund_spend_proxy.bin"; + +pub(crate) const BEDROCK_SERVICE_WITH_OPEN_PORT: &str = "logos-blockchain-node-0"; +pub(crate) const BEDROCK_SERVICE_PORT: u16 = 18080; + +static LOGGER: LazyLock<()> = LazyLock::new(env_logger::init); + +struct IndexerComponents { + indexer_handle: IndexerHandle, + indexer_client: IndexerClient, + temp_dir: TempDir, +} + +impl Drop for IndexerComponents { + fn drop(&mut self) { + let Self { + indexer_handle, + indexer_client: _, + temp_dir: _, + } = self; + + if !indexer_handle.is_healthy() { + error!("Indexer handle has unexpectedly stopped before IndexerComponents drop"); + } + } +} + +/// Recursively-sized bytes on disk for sequencer / indexer / wallet tempdirs. +#[derive(Debug, Clone, Copy, Default, Serialize)] +pub struct DiskSizes { + pub sequencer_bytes: u64, + pub indexer_bytes: u64, + pub wallet_bytes: u64, +} + +/// Test context which sets up a sequencer and a wallet for integration tests. +/// +/// It's memory and logically safe to create multiple instances of this struct in parallel tests, +/// as each instance uses its own temporary directories for sequencer and wallet data. +// NOTE: Order of fields is important for proper drop order. +pub struct TestContext { + sequencer_client: SequencerClient, + wallet: WalletCore, + wallet_password: String, + /// Optional to move out value in Drop. + sequencer_handle: Option, + indexer_components: Option, + bedrock_compose: DockerCompose, + bedrock_addr: SocketAddr, + temp_sequencer_dir: TempDir, + temp_wallet_dir: TempDir, +} + +impl TestContext { + /// Create new test context. + pub async fn new() -> Result { + Self::builder().build().await + } + + /// Get a builder for the test context to customize its configuration. + #[must_use] + pub const fn builder() -> TestContextBuilder { + TestContextBuilder::new() + } + + /// Get reference to the wallet. + #[must_use] + pub const fn wallet(&self) -> &WalletCore { + &self.wallet + } + + #[must_use] + pub fn wallet_password(&self) -> &str { + &self.wallet_password + } + + /// Get mutable reference to the wallet. + pub const fn wallet_mut(&mut self) -> &mut WalletCore { + &mut self.wallet + } + + /// Get reference to the sequencer client. + #[must_use] + pub const fn sequencer_client(&self) -> &SequencerClient { + &self.sequencer_client + } + + /// Get the Bedrock Node address. + #[must_use] + pub const fn bedrock_addr(&self) -> SocketAddr { + self.bedrock_addr + } + + /// Get reference to the indexer. + /// + /// # Panics + /// + /// Panics if the indexer is not enabled in the test context. See + /// [`TestContextBuilder::disable_indexer()`]. + #[must_use] + pub fn indexer(&self) -> &IndexerHandle { + self.indexer_components + .as_ref() + .map(|components| &components.indexer_handle) + .expect("Called `TestContext::indexer()` on context with disabled indexer") + } + + /// Get the indexer's bound socket address. + /// + /// # Panics + /// + /// Panics if the indexer is not enabled in the test context. + #[must_use] + pub fn indexer_addr(&self) -> SocketAddr { + self.indexer().addr() + } + + /// Get reference to the indexer client. + /// + /// # Panics + /// + /// Panics if the indexer is not enabled in the test context. See + /// [`TestContextBuilder::disable_indexer()`]. + #[must_use] + pub fn indexer_client(&self) -> &IndexerClient { + self.indexer_components + .as_ref() + .map(|components| &components.indexer_client) + .expect("Called `TestContext::indexer_client()` on context with disabled indexer") + } + + /// Recursively-sized bytes on disk for sequencer + indexer + wallet tempdirs. + /// Indexer bytes are zero if the indexer is disabled. + #[must_use] + pub fn disk_sizes(&self) -> DiskSizes { + DiskSizes { + sequencer_bytes: dir_size_bytes(self.temp_sequencer_dir.path()), + indexer_bytes: self + .indexer_components + .as_ref() + .map_or(0, |c| dir_size_bytes(c.temp_dir.path())), + wallet_bytes: dir_size_bytes(self.temp_wallet_dir.path()), + } + } + + /// Get existing public account IDs in the wallet. + #[must_use] + pub fn existing_public_accounts(&self) -> Vec { + self.wallet + .storage() + .key_chain() + .public_account_ids() + .map(|(account_id, _idx)| account_id) + .collect() + } + + /// Get existing private account IDs in the wallet. + #[must_use] + pub fn existing_private_accounts(&self) -> Vec { + self.wallet + .storage() + .key_chain() + .private_account_ids() + .map(|(account_id, _idx)| account_id) + .collect() + } +} + +impl Drop for TestContext { + fn drop(&mut self) { + let Self { + sequencer_handle, + bedrock_compose, + bedrock_addr: _, + indexer_components: _, + sequencer_client: _, + wallet: _, + wallet_password: _, + temp_sequencer_dir: _, + temp_wallet_dir: _, + } = self; + + let sequencer_handle = sequencer_handle + .take() + .expect("Sequencer handle should be present in TestContext drop"); + if !sequencer_handle.is_healthy() { + let Err(err) = sequencer_handle + .failed() + .now_or_never() + .expect("Sequencer handle should not be running"); + error!( + "Sequencer handle has unexpectedly stopped before TestContext drop with error: {err:#}" + ); + } + + let container = bedrock_compose + .service(BEDROCK_SERVICE_WITH_OPEN_PORT) + .unwrap_or_else(|| { + panic!("Failed to get Bedrock service container `{BEDROCK_SERVICE_WITH_OPEN_PORT}`") + }); + let output = std::process::Command::new("docker") + .args(["inspect", "-f", "{{.State.Running}}", container.id()]) + .output() + .expect("Failed to execute docker inspect command to check if Bedrock container is still running"); + let stdout = String::from_utf8(output.stdout) + .expect("Failed to parse docker inspect output as String"); + if stdout.trim() != "true" { + error!( + "Bedrock container `{}` is not running during TestContext drop, docker inspect output: {stdout}", + container.id() + ); + } + } +} + +pub struct TestContextBuilder { + genesis_transactions: Option>, + sequencer_partial_config: Option, + enable_indexer: bool, +} + +impl TestContextBuilder { + const fn new() -> Self { + Self { + genesis_transactions: None, + sequencer_partial_config: None, + enable_indexer: true, + } + } + + #[must_use] + pub fn with_genesis(mut self, genesis_transactions: Vec) -> Self { + self.genesis_transactions = Some(genesis_transactions); + self + } + + #[must_use] + pub const fn with_sequencer_partial_config( + mut self, + sequencer_partial_config: config::SequencerPartialConfig, + ) -> Self { + self.sequencer_partial_config = Some(sequencer_partial_config); + self + } + + /// Exclude Indexer from test context. + /// Indexer is enabled by default. + /// + /// Methods like [`TestContext::indexer()`] and [`TestContext::indexer_client()`] will panic if + /// called when indexer is disabled. + #[must_use] + pub const fn disable_indexer(mut self) -> Self { + self.enable_indexer = false; + self + } + + pub async fn build(self) -> Result { + let Self { + genesis_transactions, + sequencer_partial_config, + enable_indexer, + } = self; + + // Ensure logger is initialized only once + *LOGGER; + + debug!("Test context setup"); + + let (bedrock_compose, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to setup Bedrock node")?; + + let indexer_components = if enable_indexer { + let (indexer_handle, temp_indexer_dir) = setup_indexer(bedrock_addr) + .await + .context("Failed to setup Indexer")?; + let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, indexer_handle.addr()) + .context("Failed to convert indexer addr to URL")?; + let indexer_client = IndexerClient::new(&indexer_url) + .await + .context("Failed to create indexer client")?; + Some(IndexerComponents { + indexer_handle, + indexer_client, + temp_dir: temp_indexer_dir, + }) + } else { + None + }; + + let initial_public_accounts = config::default_public_accounts_for_wallet(); + let initial_private_accounts = config::default_private_accounts_for_wallet(); + let (sequencer_handle, temp_sequencer_dir) = setup_sequencer( + sequencer_partial_config.unwrap_or_default(), + bedrock_addr, + genesis_transactions.unwrap_or_else(|| { + config::genesis_from_accounts(&initial_public_accounts, &initial_private_accounts) + }), + ) + .await + .context("Failed to setup Sequencer")?; + + let (mut wallet, temp_wallet_dir, wallet_password) = setup_wallet( + sequencer_handle.addr(), + &initial_public_accounts, + &initial_private_accounts, + ) + .context("Failed to setup wallet")?; + + setup_public_accounts_with_initial_supply(&wallet, &initial_public_accounts) + .await + .context("Failed to initialize public accounts in wallet")?; + + setup_private_accounts_with_initial_supply(&mut wallet, &initial_private_accounts) + .await + .context("Failed to initialize private accounts in wallet")?; + + let sequencer_url = config::addr_to_url(config::UrlProtocol::Http, sequencer_handle.addr()) + .context("Failed to convert sequencer addr to URL")?; + let sequencer_client = SequencerClientBuilder::default() + .build(sequencer_url) + .context("Failed to create sequencer client")?; + + Ok(TestContext { + sequencer_client, + wallet, + wallet_password, + bedrock_compose, + bedrock_addr, + sequencer_handle: Some(sequencer_handle), + indexer_components, + temp_sequencer_dir, + temp_wallet_dir, + }) + } + + pub fn build_blocking(self) -> Result { + let runtime = tokio::runtime::Runtime::new().context("Failed to create Tokio runtime")?; + + let ctx = runtime.block_on(self.build())?; + + Ok(BlockingTestContext { + ctx: Some(ctx), + runtime, + }) + } +} +/// A test context to be used in normal #[test] tests. +pub struct BlockingTestContext { + ctx: Option, + runtime: tokio::runtime::Runtime, +} + +impl BlockingTestContext { + pub fn new() -> Result { + TestContext::builder().build_blocking() + } + + pub const fn ctx(&self) -> &TestContext { + self.ctx.as_ref().expect("TestContext is set") + } + + pub const fn runtime(&self) -> &tokio::runtime::Runtime { + &self.runtime + } + + pub fn block_on<'ctx, F>(&'ctx self, f: impl FnOnce(&'ctx TestContext) -> F) -> F::Output + where + F: std::future::Future + 'ctx, + { + let future = f(self.ctx()); + self.runtime.block_on(future) + } + + pub fn block_on_mut<'ctx, F>( + &'ctx mut self, + f: impl FnOnce(&'ctx mut TestContext) -> F, + ) -> F::Output + where + F: std::future::Future + 'ctx, + { + let ctx_mut = self.ctx.as_mut().expect("TestContext is set"); + let future = f(ctx_mut); + self.runtime.block_on(future) + } +} + +impl Drop for BlockingTestContext { + fn drop(&mut self) { + let Self { ctx, runtime } = self; + + // Ensure async cleanup of TestContext by blocking on its drop in the runtime. + runtime.block_on(async { + if let Some(ctx) = ctx.take() { + drop(ctx); + } + }); + } +} + +#[must_use] +pub const fn public_mention(account_id: AccountId) -> CliAccountMention { + CliAccountMention::Id(AccountIdWithPrivacy::Public(account_id)) +} + +#[must_use] +pub const fn private_mention(account_id: AccountId) -> CliAccountMention { + CliAccountMention::Id(AccountIdWithPrivacy::Private(account_id)) +} + +#[expect( + clippy::wildcard_enum_match_arm, + reason = "We want the code to panic if the transaction type is not PrivacyPreserving" +)] +pub async fn fetch_privacy_preserving_tx( + seq_client: &SequencerClient, + tx_hash: HashType, +) -> PrivacyPreservingTransaction { + let tx = seq_client.get_transaction(tx_hash).await.unwrap().unwrap(); + + match tx { + NSSATransaction::PrivacyPreserving(privacy_preserving_transaction) => { + privacy_preserving_transaction + } + _ => panic!("Invalid tx type"), + } +} + +pub async fn verify_commitment_is_in_state( + commitment: Commitment, + seq_client: &SequencerClient, +) -> bool { + seq_client + .get_proof_for_commitment(commitment) + .await + .ok() + .flatten() + .is_some() +} + +fn dir_size_bytes(path: &Path) -> u64 { + let mut total = 0_u64; + let Ok(entries) = std::fs::read_dir(path) else { + return 0; + }; + for entry in entries.flatten() { + let Ok(metadata) = entry.metadata() else { + continue; + }; + if metadata.is_file() { + total = total.saturating_add(metadata.len()); + } else if metadata.is_dir() { + total = total.saturating_add(dir_size_bytes(&entry.path())); + } else { + // Sockets, FIFOs, block/char devices: ignore. Symlinks are + // already followed by `is_file()` / `is_dir()`. + } + } + total +} diff --git a/integration_tests/src/setup.rs b/test_fixtures/src/setup.rs similarity index 100% rename from integration_tests/src/setup.rs rename to test_fixtures/src/setup.rs diff --git a/tools/integration_bench/Cargo.toml b/tools/integration_bench/Cargo.toml new file mode 100644 index 00000000..0829a7f0 --- /dev/null +++ b/tools/integration_bench/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "integration_bench" +version = "0.1.0" +edition = "2024" +license = { workspace = true } +publish = false + +[lints] +workspace = true + +[dependencies] +common.workspace = true +indexer_service_rpc = { workspace = true, features = ["client"] } +nssa.workspace = true +sequencer_service_rpc = { workspace = true, features = ["client"] } +test_fixtures.workspace = true +wallet.workspace = true + +anyhow.workspace = true +borsh.workspace = true +clap.workspace = true +jsonrpsee = { workspace = true, features = ["ws-client"] } +serde.workspace = true +serde_json.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } diff --git a/tools/integration_bench/README.md b/tools/integration_bench/README.md new file mode 100644 index 00000000..f6e2ee04 --- /dev/null +++ b/tools/integration_bench/README.md @@ -0,0 +1,27 @@ +# integration_bench + +End-to-end LEZ scenarios driven through the wallet against a docker-compose Bedrock node + in-process sequencer + indexer (via `test_fixtures::TestContext`). Times each step (submit, inclusion, wallet sync) and records borsh sizes for every block produced, split into per-tx-variant counts. + +## Run + +Prerequisite: a running local Docker daemon. The Bedrock service comes up via the same `bedrock/docker-compose.yml` that integration tests use, so no host-side binary or env vars are required. + +```sh +# All scenarios, dev-mode proving (fast) +RISC0_DEV_MODE=1 cargo run --release -p integration_bench -- --scenario all + +# One scenario, real proving (slow) +cargo run --release -p integration_bench -- --scenario amm +``` + +Scenarios: `token`, `amm`, `fanout`, `private`, `parallel`, `all`. + +All scenarios share a single TestContext for the run (one Bedrock + sequencer + indexer + wallet across the whole run, chain state accumulating), which matches how the node runs in production. + +## What you'll see + +Per scenario: a step table (`submit_s`, `inclusion_s`, `sync_s`, `total_s`) and a size summary covering every block captured during the scenario (block_bytes total/mean/min/max; per-tx-variant sizes for public, PPE, and program-deployment transactions). + +The fanout, parallel, and private scenarios are the most representative for L1-payload-size measurements since they put multiple txs per block. + +JSON output is written to `target/integration_bench_dev.json` (dev mode) or `target/integration_bench_prove.json` (real proving). diff --git a/tools/integration_bench/src/harness.rs b/tools/integration_bench/src/harness.rs new file mode 100644 index 00000000..fb9d4d5c --- /dev/null +++ b/tools/integration_bench/src/harness.rs @@ -0,0 +1,331 @@ +//! Step / scenario timing primitives shared across scenarios. + +#![allow( + clippy::ref_option, + reason = "serde::serialize_with requires fn(&Option, S) -> Result<...>" +)] + +use std::time::{Duration, Instant}; + +use anyhow::{Result, bail}; +use common::transaction::NSSATransaction; +use sequencer_service_rpc::RpcClient as _; +use serde::{Serialize, Serializer}; +use test_fixtures::{DiskSizes, TestContext}; +use wallet::cli::SubcommandReturnValue; + +const TX_INCLUSION_POLL_INTERVAL: Duration = Duration::from_millis(250); +const TX_INCLUSION_TIMEOUT: Duration = Duration::from_secs(120); + +/// Borsh-serialized sizes for one zone block fetched after a step. `block_bytes` +/// is the full Block (header + body + bedrock metadata) and is the closest +/// proxy we have to the L1 payload posted per block. `tx_bytes` is each contained +/// transaction split by variant, which is what the fee model's `S_tx` slot covers. +#[derive(Debug, Serialize, Clone, Default)] +pub struct BlockSize { + pub block_id: u64, + pub block_bytes: usize, + pub public_tx_bytes: Vec, + pub ppe_tx_bytes: Vec, + pub deploy_tx_bytes: Vec, +} + +#[derive(Debug, Serialize, Clone)] +pub struct StepResult { + pub label: String, + #[serde(serialize_with = "ser_duration_secs", rename = "submit_s")] + pub submit: Duration, + #[serde(serialize_with = "ser_opt_duration_secs", rename = "inclusion_s")] + pub inclusion: Option, + #[serde(serialize_with = "ser_opt_duration_secs", rename = "wallet_sync_s")] + pub wallet_sync: Option, + #[serde(serialize_with = "ser_duration_secs", rename = "total_s")] + pub total: Duration, + pub tx_hash: Option, + /// Borsh sizes for every zone block produced during this step. + /// Empty for steps that don't advance the chain (e.g. `RegisterAccount`). + pub blocks: Vec, +} + +#[derive(Debug, Serialize, Default)] +pub struct ScenarioOutput { + pub name: String, + pub steps: Vec, + #[serde(serialize_with = "ser_duration_secs", rename = "total_s")] + pub total: Duration, + /// Disk sizes (sequencer / indexer / wallet tempdirs) sampled at scenario start. + pub disk_before: Option, + /// Disk sizes sampled at scenario end. + pub disk_after: Option, + /// Bedrock-finality latency: time from final-step inclusion to the indexer + /// reporting the sequencer tip as L1-finalised. Effectively measures the + /// sequencer→Bedrock posting + Bedrock finalisation + indexer L1 ingest path. + /// A value at the timeout (60s) means finalisation did not happen within the bench window. + #[serde( + serialize_with = "ser_opt_duration_secs", + rename = "bedrock_finality_s" + )] + pub bedrock_finality: Option, +} + +impl ScenarioOutput { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + ..Default::default() + } + } + + pub fn push(&mut self, step: StepResult) { + self.total = self.total.saturating_add(step.total); + self.steps.push(step); + } + + /// Run a single timed step against `ctx`: capture pre-block, run `submit`, + /// finalize timings, push a `StepResult` onto `self.steps`. Returns the + /// `SubcommandReturnValue` from `submit` so the caller can match on it. + pub async fn step( + &mut self, + ctx: &mut TestContext, + label: impl Into, + submit: impl AsyncFnOnce(&mut TestContext) -> Result, + ) -> Result { + let pre_block = begin_step(ctx).await?; + let started = Instant::now(); + let ret = submit(ctx).await?; + let step = finalize_step(label, started, pre_block, &ret, ctx).await?; + self.push(step); + Ok(ret) + } +} + +/// Begin a timed step. Capture this *before* submitting the wallet operation +/// so we can later subtract it from the post-submit block height to detect +/// when the chain has advanced past the tx's block. +async fn begin_step(ctx: &TestContext) -> Result { + Ok(ctx.sequencer_client().get_last_block_id().await?) +} + +/// Finish a timed wallet step. Records submit (the time between `started` +/// being captured and `ret` being received) and, if `ret` is a +/// [`SubcommandReturnValue::PrivacyPreservingTransfer`], polls the sequencer +/// for inclusion and records the inclusion latency. Returns a [`StepResult`]. +async fn finalize_step( + label: impl Into, + started: Instant, + pre_block_id: u64, + ret: &SubcommandReturnValue, + ctx: &mut TestContext, +) -> Result { + let label = label.into(); + let submit = started.elapsed(); + + let mut tx_hash_str = None; + let mut inclusion = None; + let mut wallet_sync = None; + let mut blocks: Vec = Vec::new(); + + // For non-account-create steps (anything that produces a tx_hash, or even + // `Empty` for public Token Send), wait for the chain to advance past the + // submission block so state is applied before the next step. We use + // get_last_block_id as the canonical "block has been produced and + // recorded" signal. + let should_wait_for_chain = !matches!(ret, SubcommandReturnValue::RegisterAccount { .. }); + if should_wait_for_chain { + if let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash } = ret { + tx_hash_str = Some(format!("{tx_hash}")); + } + let started_inclusion = Instant::now(); + wait_for_chain_advance(ctx, pre_block_id, 2).await?; + inclusion = Some(started_inclusion.elapsed()); + + let started_sync = Instant::now(); + sync_wallet_to_tip(ctx).await?; + wallet_sync = Some(started_sync.elapsed()); + + // Capture block-byte and per-tx-byte sizes for every block produced + // during this step. We intentionally capture all blocks, including + // empty clock-only ticks: the empty-block baseline lets the fee model + // back out the per-tx contribution. + let tip = ctx.sequencer_client().get_last_block_id().await?; + for block_id in (pre_block_id.saturating_add(1))..=tip { + if let Some(block) = ctx.sequencer_client().get_block(block_id).await? { + let block_bytes = borsh::to_vec(&block).map_or(0, |v| v.len()); + let mut sz = BlockSize { + block_id, + block_bytes, + public_tx_bytes: Vec::new(), + ppe_tx_bytes: Vec::new(), + deploy_tx_bytes: Vec::new(), + }; + for tx in &block.body.transactions { + let n = borsh::to_vec(tx).map_or(0, |v| v.len()); + match tx { + NSSATransaction::Public(_) => sz.public_tx_bytes.push(n), + NSSATransaction::PrivacyPreserving(_) => sz.ppe_tx_bytes.push(n), + NSSATransaction::ProgramDeployment(_) => sz.deploy_tx_bytes.push(n), + } + } + blocks.push(sz); + } + } + } + + Ok(StepResult { + label, + submit, + inclusion, + wallet_sync, + total: started.elapsed(), + tx_hash: tx_hash_str, + blocks, + }) +} + +/// Wait for `get_last_block_id` to advance by at least `min_blocks` from `from_block_id`. +pub async fn wait_for_chain_advance( + ctx: &TestContext, + from_block_id: u64, + min_blocks: u64, +) -> Result<()> { + let target = from_block_id.saturating_add(min_blocks); + let poll = async { + loop { + match ctx.sequencer_client().get_last_block_id().await { + Ok(current) if current >= target => return, + Ok(_) => {} + Err(err) => eprintln!("get_last_block_id error (continuing poll): {err:#}"), + } + tokio::time::sleep(TX_INCLUSION_POLL_INTERVAL).await; + } + }; + match tokio::time::timeout(TX_INCLUSION_TIMEOUT, poll).await { + Ok(()) => Ok(()), + Err(_) => bail!( + "chain did not advance from {from_block_id} to at least {target} within {TX_INCLUSION_TIMEOUT:?}" + ), + } +} + +async fn sync_wallet_to_tip(ctx: &mut TestContext) -> Result<()> { + let last_block = ctx.sequencer_client().get_last_block_id().await?; + ctx.wallet_mut().sync_to_block(last_block).await?; + Ok(()) +} + +pub fn print_table(output: &ScenarioOutput) { + let label_width = output + .steps + .iter() + .map(|s| s.label.len()) + .max() + .unwrap_or(0) + .max("step".len()); + + println!( + "\nScenario: {} (total {:.2}s)", + output.name, + output.total.as_secs_f64(), + ); + println!( + "{:10} {:>12} {:>10} {:>10}", + "step", + "submit_s", + "inclusion_s", + "sync_s", + "total_s", + lw = label_width, + ); + println!("{}", "-".repeat(label_width.saturating_add(50))); + for s in &output.steps { + let inclusion = s + .inclusion + .map_or_else(|| "-".to_owned(), |v| format!("{:.3}", v.as_secs_f64())); + let sync = s + .wallet_sync + .map_or_else(|| "-".to_owned(), |v| format!("{:.3}", v.as_secs_f64())); + println!( + "{:10.3} {:>12} {:>10} {:>10.3}", + s.label, + s.submit.as_secs_f64(), + inclusion, + sync, + s.total.as_secs_f64(), + lw = label_width, + ); + } + + print_size_summary(output); +} + +/// Aggregate borsh sizes per scenario: total/mean/min/max block bytes, and +/// per-tx bytes split by variant. Empty if no blocks were captured. +fn print_size_summary(output: &ScenarioOutput) { + let blocks: Vec<&BlockSize> = output.steps.iter().flat_map(|s| s.blocks.iter()).collect(); + if blocks.is_empty() { + return; + } + + let block_bytes: Vec = blocks.iter().map(|b| b.block_bytes).collect(); + let total_block_bytes: usize = block_bytes.iter().sum(); + let mean_block = mean_usize(&block_bytes); + let min_block = block_bytes.iter().copied().min().unwrap_or(0); + let max_block = block_bytes.iter().copied().max().unwrap_or(0); + + let public: Vec = blocks + .iter() + .flat_map(|b| b.public_tx_bytes.iter().copied()) + .collect(); + let ppe: Vec = blocks + .iter() + .flat_map(|b| b.ppe_tx_bytes.iter().copied()) + .collect(); + let deploy: Vec = blocks + .iter() + .flat_map(|b| b.deploy_tx_bytes.iter().copied()) + .collect(); + + println!( + "\nBlock + tx size summary ({} blocks captured):", + blocks.len() + ); + println!( + " block_bytes: total={total_block_bytes}, mean={mean_block}, min={min_block}, max={max_block}", + ); + print_tx_line("public_tx_bytes ", &public); + print_tx_line("ppe_tx_bytes ", &ppe); + print_tx_line("deploy_tx_bytes ", &deploy); +} + +fn print_tx_line(label: &str, samples: &[usize]) { + if samples.is_empty() { + println!(" {label}: (none)"); + return; + } + let total: usize = samples.iter().sum(); + let mean = mean_usize(samples); + let min = samples.iter().copied().min().unwrap_or(0); + let max = samples.iter().copied().max().unwrap_or(0); + println!( + " {label}: n={}, total={total}, mean={mean}, min={min}, max={max}", + samples.len() + ); +} + +fn mean_usize(xs: &[usize]) -> usize { + xs.iter().sum::().checked_div(xs.len()).unwrap_or(0) +} + +fn ser_duration_secs(d: &Duration, s: S) -> std::result::Result { + s.serialize_f64(d.as_secs_f64()) +} + +fn ser_opt_duration_secs( + d: &Option, + s: S, +) -> std::result::Result { + match d { + Some(d) => s.serialize_f64(d.as_secs_f64()), + None => s.serialize_none(), + } +} diff --git a/tools/integration_bench/src/main.rs b/tools/integration_bench/src/main.rs new file mode 100644 index 00000000..ccf7058e --- /dev/null +++ b/tools/integration_bench/src/main.rs @@ -0,0 +1,200 @@ +//! End-to-end LEZ scenario bench. +//! +//! Spins up the full stack via `test_fixtures::TestContext` (docker-compose +//! Bedrock + in-process sequencer + indexer + wallet) once for the whole run, +//! then drives the wallet through each requested scenario against that single +//! shared stack. Times each step and records borsh-serialized block + tx sizes +//! per scenario. +//! +//! Prerequisite: a working local Docker daemon. The Bedrock service is brought +//! up via the same `bedrock/docker-compose.yml` the integration tests use, so +//! no host-side binary or env vars are required. +//! +//! Run examples: +//! `RISC0_DEV_MODE=1 cargo run --release -p integration_bench -- --scenario all`. +//! `cargo run --release -p integration_bench -- --scenario amm`. +//! +//! `RISC0_DEV_MODE=1` skips proving and produces latency-only numbers in +//! ~minutes; omitting it produces realistic proving-inclusive numbers but +//! the run takes much longer. + +#![allow( + clippy::arithmetic_side_effects, + clippy::print_stderr, + clippy::print_stdout, + clippy::shadow_unrelated, + clippy::wildcard_enum_match_arm, + reason = "Bench tool: stderr/stdout output is the deliverable; small Duration / iterator-sum \ + arithmetic is safe at bench scale; bench scenarios bail loudly on any unexpected \ + return variant, which is preferable to maintaining an exhaustive list in five files; \ + the step() closure helper canonically rebinds `ctx` inside the closure body." +)] + +use std::{path::PathBuf, time::Duration}; + +use anyhow::{Context as _, Result}; +use clap::{Parser, ValueEnum}; +use harness::ScenarioOutput; +use serde::Serialize; +use test_fixtures::TestContext; + +mod harness; +mod scenarios; + +#[derive(Copy, Clone, Debug, ValueEnum)] +enum ScenarioName { + Token, + Amm, + Fanout, + Private, + Parallel, + All, +} + +#[derive(Parser, Debug)] +#[command(about = "End-to-end LEZ scenario bench")] +struct Cli { + /// Which scenario(s) to run. + #[arg(long, value_enum, default_value_t = ScenarioName::All)] + scenario: ScenarioName, + + /// Optional JSON output path. Defaults to `/target/integration_bench.json`. + #[arg(long)] + json_out: Option, +} + +#[derive(Debug, Serialize)] +struct BenchRunReport { + risc0_dev_mode: bool, + /// Time to bring up the shared `TestContext` (docker-compose Bedrock + + /// sequencer + indexer + wallet). Paid once per run regardless of how many + /// scenarios are exercised. + shared_setup_s: f64, + scenarios: Vec, + total_wall_s: f64, +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() -> Result<()> { + // test_fixtures initializes env_logger via a LazyLock, so we leave logger + // setup to it. Set RUST_LOG=info before running to see logs. + + let cli = Cli::parse(); + let risc0_dev_mode = std::env::var("RISC0_DEV_MODE").is_ok_and(|v| !v.is_empty() && v != "0"); + + eprintln!( + "integration_bench: scenario={:?}, RISC0_DEV_MODE={}", + cli.scenario, + if risc0_dev_mode { "1" } else { "unset/0" } + ); + + let to_run: Vec = match cli.scenario { + ScenarioName::All => vec![ + ScenarioName::Token, + ScenarioName::Amm, + ScenarioName::Fanout, + ScenarioName::Private, + ScenarioName::Parallel, + ], + other => vec![other], + }; + + let overall_started = std::time::Instant::now(); + + // One shared stack for the entire run: docker-compose Bedrock + sequencer + + // indexer + wallet. Scenarios share chain state, which matches how the node + // runs in production (long-lived, accumulating). + let setup_started = std::time::Instant::now(); + let mut ctx = TestContext::new() + .await + .context("failed to setup TestContext")?; + let shared_setup = setup_started.elapsed(); + eprintln!("setup: {:.2}s", shared_setup.as_secs_f64()); + + let mut all_outputs = Vec::with_capacity(to_run.len()); + + for name in to_run { + eprintln!("\n=== running scenario: {name:?} ==="); + let disk_before = ctx.disk_sizes(); + let mut output = run_scenario(name, &mut ctx).await?; + output.disk_before = Some(disk_before); + output.disk_after = Some(ctx.disk_sizes()); + output.bedrock_finality = Some(measure_bedrock_finality(&ctx).await?); + harness::print_table(&output); + all_outputs.push(output); + } + + let total_wall_s = overall_started.elapsed().as_secs_f64(); + eprintln!("\nTotal wall time: {total_wall_s:.1}s"); + + let report = BenchRunReport { + risc0_dev_mode, + shared_setup_s: shared_setup.as_secs_f64(), + scenarios: all_outputs, + total_wall_s, + }; + + let out_path = if let Some(p) = cli.json_out { + p + } else { + let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .canonicalize()?; + let suffix = if risc0_dev_mode { "dev" } else { "prove" }; + workspace_root + .join("target") + .join(format!("integration_bench_{suffix}.json")) + }; + if let Some(parent) = out_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&out_path, serde_json::to_string_pretty(&report)?)?; + eprintln!("\nJSON written to {}", out_path.display()); + + Ok(()) +} + +async fn run_scenario(name: ScenarioName, ctx: &mut TestContext) -> Result { + match name { + ScenarioName::Token => scenarios::token::run(ctx).await, + ScenarioName::Amm => scenarios::amm::run(ctx).await, + ScenarioName::Fanout => scenarios::fanout::run(ctx).await, + ScenarioName::Private => scenarios::private::run(ctx).await, + ScenarioName::Parallel => scenarios::parallel::run(ctx).await, + ScenarioName::All => unreachable!("dispatched above"), + } +} + +/// Poll the indexer's L1-finalised block id until it catches up with the +/// sequencer's last block id. This is effectively the sequencer→Bedrock posting +/// plus Bedrock finalisation plus indexer ingest latency. +async fn measure_bedrock_finality(ctx: &TestContext) -> Result { + use indexer_service_rpc::RpcClient as _; + use jsonrpsee::ws_client::WsClientBuilder; + use sequencer_service_rpc::RpcClient as _; + + let indexer_url = format!("ws://{}", ctx.indexer_addr()); + let indexer_ws = WsClientBuilder::default() + .build(&indexer_url) + .await + .context("connect indexer WS")?; + let sequencer_tip = ctx.sequencer_client().get_last_block_id().await?; + + let timeout = Duration::from_secs(60); + let started = std::time::Instant::now(); + let poll = async { + loop { + match indexer_ws.get_last_finalized_block_id().await { + Ok(Some(b)) if b >= sequencer_tip => return, + Ok(_) => {} + Err(err) => eprintln!("indexer last_synced poll error: {err:#}"), + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + }; + if tokio::time::timeout(timeout, poll).await.is_err() { + eprintln!("indexer did not catch up to {sequencer_tip} within {timeout:?}"); + } + Ok(started.elapsed()) +} diff --git a/tools/integration_bench/src/scenarios/amm.rs b/tools/integration_bench/src/scenarios/amm.rs new file mode 100644 index 00000000..79100c42 --- /dev/null +++ b/tools/integration_bench/src/scenarios/amm.rs @@ -0,0 +1,191 @@ +//! AMM swap flow: setup two tokens, create pool, swap, add liquidity, remove liquidity. + +use anyhow::{Result, bail}; +use test_fixtures::{TestContext, public_mention}; +use wallet::cli::{ + Command, SubcommandReturnValue, + account::{AccountSubcommand, NewSubcommand}, + programs::{amm::AmmProgramAgnosticSubcommand, token::TokenProgramAgnosticSubcommand}, +}; + +use crate::harness::ScenarioOutput; + +pub async fn run(ctx: &mut TestContext) -> Result { + let mut output = ScenarioOutput::new("amm_swap_flow"); + + let def_a = new_public_account(ctx, &mut output, "create_acc_def_a").await?; + let supply_a = new_public_account(ctx, &mut output, "create_acc_supply_a").await?; + let user_a = new_public_account(ctx, &mut output, "create_acc_user_a").await?; + + let def_b = new_public_account(ctx, &mut output, "create_acc_def_b").await?; + let supply_b = new_public_account(ctx, &mut output, "create_acc_supply_b").await?; + let user_b = new_public_account(ctx, &mut output, "create_acc_user_b").await?; + + let user_lp = new_public_account(ctx, &mut output, "create_acc_user_lp").await?; + + timed_token_new(ctx, &mut output, "token_a_new", def_a, supply_a, "TokA").await?; + timed_token_send( + ctx, + &mut output, + "token_a_fund_user", + supply_a, + user_a, + 1_000, + ) + .await?; + + timed_token_new(ctx, &mut output, "token_b_new", def_b, supply_b, "TokB").await?; + timed_token_send( + ctx, + &mut output, + "token_b_fund_user", + supply_b, + user_b, + 1_000, + ) + .await?; + + output + .step(ctx, "amm_new_pool", async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::AMM(AmmProgramAgnosticSubcommand::New { + user_holding_a: public_mention(user_a), + user_holding_b: public_mention(user_b), + user_holding_lp: public_mention(user_lp), + balance_a: 300, + balance_b: 300, + }), + ) + .await + }) + .await?; + + output + .step(ctx, "amm_swap_exact_input", async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::AMM(AmmProgramAgnosticSubcommand::SwapExactInput { + user_holding_a: public_mention(user_a), + user_holding_b: public_mention(user_b), + amount_in: 50, + min_amount_out: 1, + token_definition: def_a, + }), + ) + .await + }) + .await?; + + output + .step(ctx, "amm_add_liquidity", async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::AMM(AmmProgramAgnosticSubcommand::AddLiquidity { + user_holding_a: public_mention(user_a), + user_holding_b: public_mention(user_b), + user_holding_lp: public_mention(user_lp), + min_amount_lp: 1, + max_amount_a: 100, + max_amount_b: 100, + }), + ) + .await + }) + .await?; + + output + .step(ctx, "amm_remove_liquidity", async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::AMM(AmmProgramAgnosticSubcommand::RemoveLiquidity { + user_holding_a: public_mention(user_a), + user_holding_b: public_mention(user_b), + user_holding_lp: public_mention(user_lp), + balance_lp: 50, + min_amount_a: 1, + min_amount_b: 1, + }), + ) + .await + }) + .await?; + + Ok(output) +} + +async fn new_public_account( + ctx: &mut TestContext, + output: &mut ScenarioOutput, + label: &str, +) -> Result { + let ret = output + .step(ctx, label, async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Account(AccountSubcommand::New(NewSubcommand::Public { + cci: None, + label: None, + })), + ) + .await + }) + .await?; + match ret { + SubcommandReturnValue::RegisterAccount { account_id } => Ok(account_id), + other => bail!("expected RegisterAccount, got {other:?}"), + } +} + +async fn timed_token_new( + ctx: &mut TestContext, + output: &mut ScenarioOutput, + label: &str, + def_id: nssa::AccountId, + supply_id: nssa::AccountId, + name: &str, +) -> Result<()> { + let name = name.to_owned(); + output + .step(ctx, label, async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::New { + definition_account_id: public_mention(def_id), + supply_account_id: public_mention(supply_id), + name, + total_supply: 10_000, + }), + ) + .await + }) + .await?; + Ok(()) +} + +async fn timed_token_send( + ctx: &mut TestContext, + output: &mut ScenarioOutput, + label: &str, + from_id: nssa::AccountId, + to_id: nssa::AccountId, + amount: u128, +) -> Result<()> { + output + .step(ctx, label, async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::Send { + from: public_mention(from_id), + to: Some(public_mention(to_id)), + to_npk: None, + to_vpk: None, + to_identifier: Some(0), + amount, + }), + ) + .await + }) + .await?; + Ok(()) +} diff --git a/tools/integration_bench/src/scenarios/fanout.rs b/tools/integration_bench/src/scenarios/fanout.rs new file mode 100644 index 00000000..d03adf83 --- /dev/null +++ b/tools/integration_bench/src/scenarios/fanout.rs @@ -0,0 +1,86 @@ +//! Multi-recipient fanout: one funded supply pays 10 distinct recipients. + +use anyhow::{Result, bail}; +use test_fixtures::{TestContext, public_mention}; +use wallet::cli::{ + Command, SubcommandReturnValue, + account::{AccountSubcommand, NewSubcommand}, + programs::token::TokenProgramAgnosticSubcommand, +}; + +use crate::harness::ScenarioOutput; + +const FANOUT_COUNT: usize = 10; +const AMOUNT_PER_TRANSFER: u128 = 100; + +pub async fn run(ctx: &mut TestContext) -> Result { + let mut output = ScenarioOutput::new("multi_recipient_fanout"); + + let def_id = new_public_account(ctx, &mut output, "create_acc_def").await?; + let supply_id = new_public_account(ctx, &mut output, "create_acc_supply").await?; + + output + .step(ctx, "token_new_fungible", async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::New { + definition_account_id: public_mention(def_id), + supply_account_id: public_mention(supply_id), + name: "FanoutToken".to_owned(), + total_supply: 10_000_000, + }), + ) + .await + }) + .await?; + + let mut recipients = Vec::with_capacity(FANOUT_COUNT); + for i in 0..FANOUT_COUNT { + let id = new_public_account(ctx, &mut output, &format!("create_recipient_{i:02}")).await?; + recipients.push(id); + } + + for (i, recipient_id) in recipients.iter().copied().enumerate() { + output + .step(ctx, format!("transfer_{i:02}"), async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::Send { + from: public_mention(supply_id), + to: Some(public_mention(recipient_id)), + to_npk: None, + to_vpk: None, + to_identifier: Some(0), + amount: AMOUNT_PER_TRANSFER, + }), + ) + .await + }) + .await?; + } + + Ok(output) +} + +async fn new_public_account( + ctx: &mut TestContext, + output: &mut ScenarioOutput, + label: &str, +) -> Result { + let ret = output + .step(ctx, label, async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Account(AccountSubcommand::New(NewSubcommand::Public { + cci: None, + label: None, + })), + ) + .await + }) + .await?; + match ret { + SubcommandReturnValue::RegisterAccount { account_id } => Ok(account_id), + other => bail!("expected RegisterAccount, got {other:?}"), + } +} diff --git a/tools/integration_bench/src/scenarios/mod.rs b/tools/integration_bench/src/scenarios/mod.rs new file mode 100644 index 00000000..0536a250 --- /dev/null +++ b/tools/integration_bench/src/scenarios/mod.rs @@ -0,0 +1,7 @@ +//! Scenarios driven by the e2e bench. + +pub mod amm; +pub mod fanout; +pub mod parallel; +pub mod private; +pub mod token; diff --git a/tools/integration_bench/src/scenarios/parallel.rs b/tools/integration_bench/src/scenarios/parallel.rs new file mode 100644 index 00000000..c6a265b9 --- /dev/null +++ b/tools/integration_bench/src/scenarios/parallel.rs @@ -0,0 +1,188 @@ +//! Parallel-fanout throughput scenario. N distinct senders each transfer one token +//! to one recipient. Submission is serialised through the single wallet but does +//! not wait for chain advance between submits, so all N txs land in the same +//! block (up to `max_num_tx_in_block`). Measures observed throughput. + +use std::time::Instant; + +use anyhow::{Result, bail}; +use common::transaction::NSSATransaction; +use sequencer_service_rpc::RpcClient as _; +use test_fixtures::{TestContext, public_mention}; +use wallet::cli::{ + Command, SubcommandReturnValue, + account::{AccountSubcommand, NewSubcommand}, + programs::token::TokenProgramAgnosticSubcommand, +}; + +use crate::harness::{BlockSize, ScenarioOutput, StepResult}; + +const PARALLEL_FANOUT_N: usize = 10; +const AMOUNT_PER_TRANSFER: u128 = 100; + +pub async fn run(ctx: &mut TestContext) -> Result { + let mut output = ScenarioOutput::new("parallel_fanout"); + + // Setup: definition, master supply, N parallel supplies, N recipients. + let def_id = new_public_account(ctx, &mut output, "create_acc_def").await?; + let master_id = new_public_account(ctx, &mut output, "create_acc_master").await?; + + let mut senders = Vec::with_capacity(PARALLEL_FANOUT_N); + for i in 0..PARALLEL_FANOUT_N { + let id = new_public_account(ctx, &mut output, &format!("create_sender_{i:02}")).await?; + senders.push(id); + } + let mut recipients = Vec::with_capacity(PARALLEL_FANOUT_N); + for i in 0..PARALLEL_FANOUT_N { + let id = new_public_account(ctx, &mut output, &format!("create_recipient_{i:02}")).await?; + recipients.push(id); + } + + // Mint full supply into master. + let total_mint = u128::try_from(PARALLEL_FANOUT_N) + .expect("usize fits u128") + .saturating_mul(AMOUNT_PER_TRANSFER) + .saturating_mul(10); + output + .step(ctx, "token_new_fungible", async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::New { + definition_account_id: public_mention(def_id), + supply_account_id: public_mention(master_id), + name: "ParToken".to_owned(), + total_supply: total_mint, + }), + ) + .await + }) + .await?; + + // Fund each sender from master. Serial; this is setup, not measured throughput. + for (i, sender_id) in senders.iter().copied().enumerate() { + output + .step(ctx, format!("fund_sender_{i:02}"), async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::Send { + from: public_mention(master_id), + to: Some(public_mention(sender_id)), + to_npk: None, + to_vpk: None, + to_identifier: Some(0), + amount: AMOUNT_PER_TRANSFER * 5, + }), + ) + .await + }) + .await?; + } + + // The measured phase: submit N transfers as fast as possible, do not wait + // for chain advance between submits. The sequencer batches whatever lands in + // its mempool before block_create_timeout. The burst step is captured + // manually rather than via the `step()` helper because we need to time + // submit-and-inclusion as two separate intervals over a synthesised batch + // rather than per-tx. + let pre_block_burst = ctx.sequencer_client().get_last_block_id().await?; + let burst_started = Instant::now(); + + // Submit all N back-to-back. Wallet serialises through `wallet_mut()`, but + // each sender has its own nonce so there are no collisions. + for (sender_id, recipient_id) in senders.iter().zip(recipients.iter()) { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::Send { + from: public_mention(*sender_id), + to: Some(public_mention(*recipient_id)), + to_npk: None, + to_vpk: None, + to_identifier: Some(0), + amount: AMOUNT_PER_TRANSFER, + }), + ) + .await?; + } + let all_submitted_at = Instant::now(); + let submit_duration = all_submitted_at.saturating_duration_since(burst_started); + + // Wait for the chain to advance by at least 2 blocks past pre_block_burst. + // That guarantees the block holding our burst is sealed and applied. + crate::harness::wait_for_chain_advance(ctx, pre_block_burst, 2).await?; + let inclusion_done_at = Instant::now(); + let inclusion_after_submit = inclusion_done_at.saturating_duration_since(all_submitted_at); + let burst_total = inclusion_done_at.saturating_duration_since(burst_started); + + eprintln!( + "parallel_fanout: submitted {} txs in {:.3}s, inclusion in {:.3}s, total {:.3}s", + senders.len(), + submit_duration.as_secs_f64(), + inclusion_after_submit.as_secs_f64(), + burst_total.as_secs_f64(), + ); + + // Capture every block produced during the burst window. This is the + // scenario where one block holds many txs, so block_bytes here is the + // most representative L1-payload-equivalent measurement we have. + let tip = ctx.sequencer_client().get_last_block_id().await?; + let mut blocks: Vec = Vec::new(); + for block_id in (pre_block_burst.saturating_add(1))..=tip { + if let Some(block) = ctx.sequencer_client().get_block(block_id).await? { + let block_bytes = borsh::to_vec(&block).map_or(0, |v| v.len()); + let mut sz = BlockSize { + block_id, + block_bytes, + public_tx_bytes: Vec::new(), + ppe_tx_bytes: Vec::new(), + deploy_tx_bytes: Vec::new(), + }; + for tx in &block.body.transactions { + let n = borsh::to_vec(tx).map_or(0, |v| v.len()); + match tx { + NSSATransaction::Public(_) => sz.public_tx_bytes.push(n), + NSSATransaction::PrivacyPreserving(_) => sz.ppe_tx_bytes.push(n), + NSSATransaction::ProgramDeployment(_) => sz.deploy_tx_bytes.push(n), + } + } + blocks.push(sz); + } + } + + // Synthesise a single summary "step" for the burst. Use the submit time + // for `submit` and the inclusion-wait time for `inclusion`. + let burst_step = StepResult { + label: format!("burst_{}_transfers", senders.len()), + submit: submit_duration, + inclusion: Some(inclusion_after_submit), + wallet_sync: None, + total: burst_total, + tx_hash: None, + blocks, + }; + output.push(burst_step); + + Ok(output) +} + +async fn new_public_account( + ctx: &mut TestContext, + output: &mut ScenarioOutput, + label: &str, +) -> Result { + let ret = output + .step(ctx, label, async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Account(AccountSubcommand::New(NewSubcommand::Public { + cci: None, + label: None, + })), + ) + .await + }) + .await?; + match ret { + SubcommandReturnValue::RegisterAccount { account_id } => Ok(account_id), + other => bail!("expected RegisterAccount, got {other:?}"), + } +} diff --git a/tools/integration_bench/src/scenarios/private.rs b/tools/integration_bench/src/scenarios/private.rs new file mode 100644 index 00000000..2be8c43c --- /dev/null +++ b/tools/integration_bench/src/scenarios/private.rs @@ -0,0 +1,140 @@ +//! Private chained flow: shielded, deshielded, and private-to-private transfers. + +use anyhow::{Result, bail}; +use test_fixtures::{TestContext, private_mention, public_mention}; +use wallet::cli::{ + Command, SubcommandReturnValue, + account::{AccountSubcommand, NewSubcommand}, + programs::token::TokenProgramAgnosticSubcommand, +}; + +use crate::harness::ScenarioOutput; + +pub async fn run(ctx: &mut TestContext) -> Result { + let mut output = ScenarioOutput::new("private_chained_flow"); + + let def_id = new_public_account(ctx, &mut output, "create_acc_def").await?; + let supply_id = new_public_account(ctx, &mut output, "create_acc_supply").await?; + let public_recipient_id = + new_public_account(ctx, &mut output, "create_acc_pub_recipient").await?; + let private_a = new_private_account(ctx, &mut output, "create_acc_priv_a").await?; + let private_b = new_private_account(ctx, &mut output, "create_acc_priv_b").await?; + + // Mint into public supply. + output + .step(ctx, "token_new_fungible", async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::New { + definition_account_id: public_mention(def_id), + supply_account_id: public_mention(supply_id), + name: "PrivToken".to_owned(), + total_supply: 1_000_000, + }), + ) + .await + }) + .await?; + + // Shielded transfer: public supply -> private_a. + output + .step(ctx, "shielded_transfer", async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::Send { + from: public_mention(supply_id), + to: Some(private_mention(private_a)), + to_npk: None, + to_vpk: None, + to_identifier: Some(0), + amount: 1_000, + }), + ) + .await + }) + .await?; + + // Deshielded transfer: private_a -> public_recipient. + output + .step(ctx, "deshielded_transfer", async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::Send { + from: private_mention(private_a), + to: Some(public_mention(public_recipient_id)), + to_npk: None, + to_vpk: None, + to_identifier: Some(0), + amount: 100, + }), + ) + .await + }) + .await?; + + // Private-to-private transfer: private_a -> private_b. + output + .step(ctx, "private_to_private", async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::Send { + from: private_mention(private_a), + to: Some(private_mention(private_b)), + to_npk: None, + to_vpk: None, + to_identifier: Some(0), + amount: 200, + }), + ) + .await + }) + .await?; + + Ok(output) +} + +async fn new_public_account( + ctx: &mut TestContext, + output: &mut ScenarioOutput, + label: &str, +) -> Result { + let ret = output + .step(ctx, label, async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Account(AccountSubcommand::New(NewSubcommand::Public { + cci: None, + label: None, + })), + ) + .await + }) + .await?; + match ret { + SubcommandReturnValue::RegisterAccount { account_id } => Ok(account_id), + other => bail!("expected RegisterAccount, got {other:?}"), + } +} + +async fn new_private_account( + ctx: &mut TestContext, + output: &mut ScenarioOutput, + label: &str, +) -> Result { + let ret = output + .step(ctx, label, async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Account(AccountSubcommand::New(NewSubcommand::Private { + cci: None, + label: None, + })), + ) + .await + }) + .await?; + match ret { + SubcommandReturnValue::RegisterAccount { account_id } => Ok(account_id), + other => bail!("expected RegisterAccount, got {other:?}"), + } +} diff --git a/tools/integration_bench/src/scenarios/token.rs b/tools/integration_bench/src/scenarios/token.rs new file mode 100644 index 00000000..d1dfdef3 --- /dev/null +++ b/tools/integration_bench/src/scenarios/token.rs @@ -0,0 +1,119 @@ +//! Token onboarding scenario: create accounts, mint, public transfer, private transfer. + +use anyhow::{Result, bail}; +use test_fixtures::{TestContext, private_mention, public_mention}; +use wallet::cli::{ + Command, SubcommandReturnValue, + account::{AccountSubcommand, NewSubcommand}, + programs::token::TokenProgramAgnosticSubcommand, +}; + +use crate::harness::ScenarioOutput; + +pub async fn run(ctx: &mut TestContext) -> Result { + let mut output = ScenarioOutput::new("token_onboarding"); + + let definition_id = new_public_account(ctx, &mut output, "create_pub_definition").await?; + let supply_id = new_public_account(ctx, &mut output, "create_pub_supply").await?; + let recipient_id = new_public_account(ctx, &mut output, "create_pub_recipient").await?; + + output + .step(ctx, "token_new_fungible", async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::New { + definition_account_id: public_mention(definition_id), + supply_account_id: public_mention(supply_id), + name: "BenchToken".to_owned(), + total_supply: 1_000_000, + }), + ) + .await + }) + .await?; + + output + .step(ctx, "token_public_transfer", async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::Send { + from: public_mention(supply_id), + to: Some(public_mention(recipient_id)), + to_npk: None, + to_vpk: None, + to_identifier: Some(0), + amount: 1_000, + }), + ) + .await + }) + .await?; + + let private_recipient_id = + new_private_account(ctx, &mut output, "create_priv_recipient").await?; + + output + .step(ctx, "token_shielded_transfer", async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::Send { + from: public_mention(supply_id), + to: Some(private_mention(private_recipient_id)), + to_npk: None, + to_vpk: None, + to_identifier: Some(0), + amount: 500, + }), + ) + .await + }) + .await?; + + Ok(output) +} + +async fn new_public_account( + ctx: &mut TestContext, + output: &mut ScenarioOutput, + label: &str, +) -> Result { + let ret = output + .step(ctx, label, async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Account(AccountSubcommand::New(NewSubcommand::Public { + cci: None, + label: None, + })), + ) + .await + }) + .await?; + match ret { + SubcommandReturnValue::RegisterAccount { account_id } => Ok(account_id), + other => bail!("expected RegisterAccount, got {other:?}"), + } +} + +async fn new_private_account( + ctx: &mut TestContext, + output: &mut ScenarioOutput, + label: &str, +) -> Result { + let ret = output + .step(ctx, label, async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Account(AccountSubcommand::New(NewSubcommand::Private { + cci: None, + label: None, + })), + ) + .await + }) + .await?; + match ret { + SubcommandReturnValue::RegisterAccount { account_id } => Ok(account_id), + other => bail!("expected RegisterAccount, got {other:?}"), + } +}