diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e09e3545b..b31d63940 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -47,6 +47,89 @@ jobs: - name: Run tests run: cargo nextest run --profile ci + txgen-e2e-spam: + name: txgen L2 transfer spam + runs-on: depot-ubuntu-latest-16 + timeout-minutes: 45 + permissions: + contents: read + outputs: + comment_body: ${{ steps.spam.outputs.comment_body }} + env: + RUSTC_WRAPPER: "" + TXGEN_DIR: ${{ github.workspace }}/txgen + L1_HTTP_URL: http://127.0.0.1:8545 + COUNT: "5000" + TPS: "1000" + MAX_CONCURRENT: "2000" + DRAIN_TIMEOUT: "240" + SYNC_TIMEOUT: "300" + TXGEN_NONCE_LANES: "1000000" + TXGEN_TRANSFER_ACCOUNTS: "8" + TXGEN_DEPOSIT_AMOUNT: "5000000000" + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + submodules: recursive + + - name: Checkout txgen + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: tempoxyz/txgen + ref: b8776357c3a16d494d2d482b19d19adab1421235 + path: txgen + persist-credentials: false + + - uses: tempoxyz/gh-actions/actions/setup-rust-build@98b1081dcf34361b576aca2ab3041772708582ef + with: + toolchain: stable + sccache: "false" + + - name: Install Foundry + uses: tempoxyz/gh-actions/actions/setup-foundry@98b1081dcf34361b576aca2ab3041772708582ef + with: + version: nightly + + - name: Build and run txgen L2 transfer spam + id: spam + run: scripts/ci-txgen-e2e-spam.sh + + - name: Upload txgen reports and node logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: txgen-e2e-spam-${{ github.run_attempt }} + path: ${{ runner.temp }}/txgen-e2e + if-no-files-found: warn + retention-days: 14 + + txgen-e2e-comment: + name: txgen spam PR comment + runs-on: ubuntu-latest + if: >- + always() && + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository + needs: + - txgen-e2e-spam + timeout-minutes: 5 + permissions: + contents: read + pull-requests: write + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + COMMENT_BODY: ${{ needs.txgen-e2e-spam.outputs.comment_body }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Create or update txgen result comment + run: scripts/ci-txgen-e2e-comment.sh + test-success: name: test success runs-on: ubuntu-latest @@ -54,6 +137,7 @@ jobs: permissions: {} needs: - test + - txgen-e2e-spam timeout-minutes: 30 steps: - name: Decide whether the needed jobs succeeded or failed diff --git a/Cargo.lock b/Cargo.lock index 84ba40655..28d8d7677 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13492,6 +13492,7 @@ dependencies = [ "futures", "k256", "p256", + "proptest", "rand 0.8.7", "reqwest 0.13.4", "reth-chain-state", diff --git a/Cargo.toml b/Cargo.toml index d7ecad336..1398ef141 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -207,6 +207,7 @@ metrics = "0.24.3" k256 = { version = "0.13.4", features = ["arithmetic", "ecdh"] } parking_lot = "0.12.4" p256 = { version = "0.13.2", default-features = false, features = ["ecdsa"] } +proptest = "1.11.0" rand = "0.8.5" sha2 = "0.10.9" rayon = "1.10" diff --git a/Justfile b/Justfile index 0ee9785d1..23d585709 100644 --- a/Justfile +++ b/Justfile @@ -890,6 +890,11 @@ spam-deposits total="20" per-block="10" amount="1000000" encrypted="" token="0x2 fi cargo run -p tempo-xtask -- spam-deposits --private-key "$PK" $ARGS +[group('zone')] +[doc('Spam randomized multi-sender L2 TIP-20 transfers against tempo-zone dev and report sustained TPS.')] +txgen-e2e-spam count="5000" tps="1000": + COUNT="{{count}}" TPS="{{tps}}" scripts/txgen-e2e-spam.sh + [group('zone')] [doc('Runs the full TIP-20 + TIP-403 blacklist demo: creates token, enables on zone, blacklists address, shows deposit bounce, unblacklists, shows deposit success, withdraws. Requires PRIVATE_KEY for the token admin/depositor, L1_PORTAL_ADDRESS, and portal admin authority via ADMIN_KEY or matching generated//zone.json adminKey.')] demo-blacklist amount="500000" rpc=zone_rpc zone-dir="": diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index 605e97f93..85e08d886 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -96,6 +96,7 @@ const-hex.workspace = true commonware-codec.workspace = true commonware-cryptography.workspace = true p256.workspace = true +proptest.workspace = true rand.workspace = true reth-ethereum = { workspace = true, features = ["node", "test-utils"] } reth-node-core.workspace = true diff --git a/crates/node/tests/it/e2e_property.rs b/crates/node/tests/it/e2e_property.rs new file mode 100644 index 000000000..4e6a62fbf --- /dev/null +++ b/crates/node/tests/it/e2e_property.rs @@ -0,0 +1,399 @@ +//! Seeded state-machine properties over the real L1 -> Zone -> L1 path. +//! +//! This test owns correctness: generated actions are applied to a small model +//! and to real in-process Tempo L1 and Zone nodes, then the observable balances, +//! policy decisions, receipts, and bridge settlement are compared after every +//! transition. High-rate randomized transaction generation remains a separate +//! txgen primitive; it does not duplicate these invariants. + +use std::{collections::HashMap, time::Duration}; + +use alloy::{ + network::ReceiptResponse, + primitives::{Address, B256, U256}, + providers::{Provider, ProviderBuilder}, +}; +use eyre::WrapErr; +use proptest::{collection::vec, prelude::*}; +use rand::{SeedableRng, rngs::StdRng, seq::SliceRandom}; +use tempo_alloy::rpc::TempoCallBuilderExt; +use tempo_chainspec::spec::TEMPO_T1_BASE_FEE; +use tempo_contracts::precompiles::{ITIP20, ITIP403Registry}; +use tempo_precompiles::{PATH_USD_ADDRESS, TIP403_REGISTRY_ADDRESS}; +use tempo_zone_contracts::ZONE_TOKEN_ADDRESS; + +use crate::utils::{ + L1TestNode, TIP20_TX_GAS, ZoneAccount, ZoneTestNode, run_e2e_proptest, spawn_sequencer, +}; + +const L1_TIMEOUT: Duration = Duration::from_secs(30); +const WITHDRAWAL_TIMEOUT: Duration = Duration::from_secs(60); +const ACCOUNT_DEPOSIT: u128 = 25_000_000; + +#[derive(Clone, Copy, Debug)] +enum PolicyMode { + RejectAll, + AllowAll, + Whitelist, + Blacklist, + Compound, +} + +#[derive(Clone, Copy, Debug)] +enum RecipientClass { + Allowed, + Denied, +} + +#[derive(Clone, Copy, Debug)] +struct TransferCase { + token_index: usize, + mode: PolicyMode, + recipient: RecipientClass, + amount: u128, +} + +#[derive(Clone, Copy, Debug)] +struct TokenCase { + l1: Address, + l2: Address, +} + +#[derive(Clone, Copy, Debug)] +struct PolicyIds { + whitelist: u64, + blacklist: u64, + compound: u64, +} + +#[derive(Clone, Debug)] +struct PropertyInput { + shuffle_seed: u64, + transfer_amounts: Vec, + withdrawal_token_index: usize, + withdrawal_amount: u128, +} + +impl PolicyIds { + fn for_mode(self, mode: PolicyMode) -> u64 { + match mode { + PolicyMode::RejectAll => 0, + PolicyMode::AllowAll => 1, + PolicyMode::Whitelist => self.whitelist, + PolicyMode::Blacklist => self.blacklist, + PolicyMode::Compound => self.compound, + } + } +} + +fn property_input_strategy() -> impl Strategy { + ( + any::(), + vec(1_000u128..=25_000, 16), + 0usize..2, + 50_000u128..=100_000, + ) + .prop_map( + |(shuffle_seed, transfer_amounts, withdrawal_token_index, withdrawal_amount)| { + PropertyInput { + shuffle_seed, + transfer_amounts, + withdrawal_token_index, + withdrawal_amount, + } + }, + ) +} + +fn generated_cases(input: &PropertyInput, token_count: usize) -> Vec { + let behaviors = [ + (PolicyMode::RejectAll, RecipientClass::Denied), + (PolicyMode::AllowAll, RecipientClass::Allowed), + (PolicyMode::Whitelist, RecipientClass::Allowed), + (PolicyMode::Whitelist, RecipientClass::Denied), + (PolicyMode::Blacklist, RecipientClass::Allowed), + (PolicyMode::Blacklist, RecipientClass::Denied), + (PolicyMode::Compound, RecipientClass::Allowed), + (PolicyMode::Compound, RecipientClass::Denied), + ]; + + let mut cases = Vec::with_capacity(token_count * behaviors.len()); + for token_index in 0..token_count { + for (mode, recipient) in behaviors { + let amount = input.transfer_amounts[cases.len()]; + cases.push(TransferCase { + token_index, + mode, + recipient, + amount, + }); + } + } + cases.shuffle(&mut StdRng::seed_from_u64(input.shuffle_seed)); + cases +} + +#[test] +fn test_bridge_policy_transfer_state_machine_property() { + run_e2e_proptest(property_input_strategy(), |input| { + run_bridge_policy_transfer_property(input) + }); +} + +async fn wait_for_policy( + l1: &L1TestNode, + zone: &ZoneTestNode, + token: TokenCase, + policy_id: u64, +) -> eyre::Result<()> { + let policy_block = l1.change_transfer_policy_id(token.l1, policy_id).await?; + zone.wait_for_l2_tempo_finalized(policy_block, L1_TIMEOUT) + .await?; + let cache = zone.policy_cache().read(); + let cache_height = cache.last_l1_block(); + let observed = cache.get_token_policy(token.l1, cache_height); + eyre::ensure!( + observed == Some(policy_id), + "finalized policy cache has {observed:?} for token {}, expected {policy_id} at L1 height {cache_height}", + token.l1 + ); + Ok(()) +} + +async fn expected_authorization( + zone: &ZoneTestNode, + policy_id: u64, + mode: PolicyMode, + sender: Address, + recipient: Address, +) -> eyre::Result { + let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, zone.provider()); + if matches!(mode, PolicyMode::Compound) { + Ok(registry + .isAuthorizedSender(policy_id, sender) + .call() + .await? + && registry + .isAuthorizedRecipient(policy_id, recipient) + .call() + .await?) + } else { + Ok(registry.isAuthorized(policy_id, sender).call().await? + && registry.isAuthorized(policy_id, recipient).call().await?) + } +} + +async fn transfer_succeeded( + zone: &ZoneTestNode, + signer: alloy::signers::local::PrivateKeySigner, + token: Address, + recipient: Address, + amount: u128, +) -> eyre::Result<(bool, String)> { + let provider = ProviderBuilder::new_with_network::() + .wallet(signer) + .connect_http(zone.http_url().clone()); + let send = ITIP20::new(token, provider) + .transfer(recipient, U256::from(amount)) + .fee_token(PATH_USD_ADDRESS) + .max_fee_per_gas(TEMPO_T1_BASE_FEE as u128) + .max_priority_fee_per_gas(0) + .gas(TIP20_TX_GAS) + .send() + .await; + + match send { + Err(err) => Ok((false, format!("submission rejected: {err:#}"))), + Ok(pending) => { + let receipt = pending.get_receipt().await?; + Ok(( + receipt.status(), + format!( + "receipt status={}, gas_used={}, transaction={}", + receipt.status(), + receipt.gas_used, + receipt.transaction_hash + ), + )) + } + } +} + +/// For every generated transition: +/// +/// - a deposit mints exactly the bridged amount on the Zone; +/// - the Zone observes the latest L1 TIP-403 assignment; +/// - an L2 transfer succeeds iff the active policy authorizes both endpoints; +/// - successful transfers change the modeled recipient balance by exactly the +/// transfer amount, while rejected transfers leave it unchanged; and +/// - an L2 withdrawal reduces the Zone balance and settles the same amount on L1. +/// +/// Set `PROPTEST_RNG_SEED=` to replay a generated input and +/// `ZONE_E2E_PROPERTY_CASES=` to run more complete E2E cases. +async fn run_bridge_policy_transfer_property(input: PropertyInput) -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let case_id = format!("proptest input {input:?}"); + let l1 = L1TestNode::start() + .await + .wrap_err_with(|| case_id.clone())?; + let portal = l1.deploy_zone().await?; + let zone = ZoneTestNode::start_from_l1(l1.http_url(), l1.ws_url(), portal).await?; + zone.wait_for_l2_tempo_finalized(0, L1_TIMEOUT).await?; + eyre::ensure!( + zone.policy_cache() + .read() + .tracked_tokens() + .contains(&PATH_USD_ADDRESS), + "startup policy cache did not seed pathUSD" + ); + let _sequencer = spawn_sequencer(&l1, &zone, portal, l1.dev_signer()).await; + + let custom_token = l1 + .create_tip20("Property USD", "pUSD", B256::with_last_byte(0x43)) + .await?; + l1.enable_token_on_portal(portal, custom_token).await?; + let enable_block = l1.provider().get_block_number().await?; + zone.wait_for_l2_tempo_finalized(enable_block, L1_TIMEOUT) + .await?; + eyre::ensure!( + !zone.provider().get_code_at(custom_token).await?.is_empty(), + "enabled custom token was not initialized on the Zone" + ); + + let sender_signer = l1.user_signer(); + let sender = sender_signer.address(); + let allowed_recipient = l1.signer_at(3).address(); + let denied_recipient = l1.signer_at(4).address(); + + let whitelist = l1.create_whitelist_policy().await?; + l1.whitelist_address(whitelist, sender).await?; + l1.whitelist_address(whitelist, allowed_recipient).await?; + let blacklist = l1.create_blacklist_policy().await?; + l1.blacklist_address(blacklist, denied_recipient).await?; + let compound = l1.create_compound_policy(1, whitelist, 1).await?; + let policy_ids = PolicyIds { + whitelist, + blacklist, + compound, + }; + let policy_block = l1.provider().get_block_number().await?; + zone.wait_for_l2_tempo_finalized(policy_block, L1_TIMEOUT) + .await?; + + let tokens = [ + TokenCase { + l1: PATH_USD_ADDRESS, + l2: ZONE_TOKEN_ADDRESS, + }, + TokenCase { + l1: custom_token, + l2: custom_token, + }, + ]; + + l1.fund_user(sender, ACCOUNT_DEPOSIT).await?; + l1.mint_tip20(custom_token, sender, ACCOUNT_DEPOSIT).await?; + let mut account = ZoneAccount::from_l1_and_zone(&l1, &zone, portal); + let path_balance = account.deposit(ACCOUNT_DEPOSIT, L1_TIMEOUT, &zone).await?; + eyre::ensure!( + path_balance == U256::from(ACCOUNT_DEPOSIT), + "pathUSD deposit minted {path_balance}, expected {ACCOUNT_DEPOSIT}" + ); + let custom_balance = account + .deposit_token( + custom_token, + custom_token, + ACCOUNT_DEPOSIT, + L1_TIMEOUT, + &zone, + ) + .await?; + eyre::ensure!( + custom_balance == U256::from(ACCOUNT_DEPOSIT), + "custom token deposit minted {custom_balance}, expected {ACCOUNT_DEPOSIT}" + ); + + let mut recipient_model = HashMap::<(Address, Address), U256>::new(); + // TIP20s start on builtin policy 1. Reassigning the same policy is a no-op + // and emits no TransferPolicyUpdate, so model that initial state directly. + // A dynamically enabled token will exercise PolicyProvider's L1 fallback + // if its first generated action also uses policy 1. + let mut active_policies = [1u64; 2]; + for case in generated_cases(&input, tokens.len()) { + let token = tokens[case.token_index]; + let policy_id = policy_ids.for_mode(case.mode); + let recipient = match case.recipient { + RecipientClass::Allowed => allowed_recipient, + RecipientClass::Denied => denied_recipient, + }; + if active_policies[case.token_index] != policy_id { + wait_for_policy(&l1, &zone, token, policy_id) + .await + .wrap_err_with(|| format!("{case_id}, case {case:?}: policy sync failed"))?; + active_policies[case.token_index] = policy_id; + } + + let authorized = expected_authorization(&zone, policy_id, case.mode, sender, recipient) + .await + .wrap_err_with(|| format!("{case_id}, case {case:?}: authorization failed"))?; + let (succeeded, transfer_detail) = transfer_succeeded( + &zone, + sender_signer.clone(), + token.l2, + recipient, + case.amount, + ) + .await + .wrap_err_with(|| format!("{case_id}, case {case:?}: transfer failed"))?; + eyre::ensure!( + succeeded == authorized, + "{case_id}, case {case:?}: receipt success={succeeded}, policy authorization={authorized}; {transfer_detail}" + ); + + let modeled = recipient_model + .entry((token.l2, recipient)) + .or_insert(U256::ZERO); + if succeeded { + *modeled += U256::from(case.amount); + } + let observed = zone.balance_of(token.l2, recipient).await?; + eyre::ensure!( + observed == *modeled, + "{case_id}, case {case:?}: recipient balance {observed}, model {modeled}" + ); + } + + for (token_index, token) in tokens.into_iter().enumerate() { + if active_policies[token_index] != 1 { + wait_for_policy(&l1, &zone, token, 1).await?; + } + } + + let withdrawal_token = tokens[input.withdrawal_token_index]; + let withdrawal_amount = input.withdrawal_amount; + let l1_before = l1.balance_of(withdrawal_token.l1, sender).await?; + let l2_before = zone.balance_of(withdrawal_token.l2, sender).await?; + account + .withdraw_token(withdrawal_token.l2, withdrawal_amount) + .await + .wrap_err_with(|| format!("{case_id}: withdrawal request failed"))?; + let l2_after = zone.balance_of(withdrawal_token.l2, sender).await?; + eyre::ensure!( + l2_after <= l2_before - U256::from(withdrawal_amount), + "{case_id}: withdrawal did not reduce the L2 balance by at least {withdrawal_amount}" + ); + l1.wait_for_balance( + withdrawal_token.l1, + sender, + l1_before + U256::from(withdrawal_amount), + WITHDRAWAL_TIMEOUT, + ) + .await + .wrap_err_with(|| format!("{case_id}: withdrawal did not settle on L1"))?; + l1.assert_batch_submitted(portal).await?; + l1.assert_withdrawal_processed(portal, sender, withdrawal_amount) + .await?; + + Ok(()) +} diff --git a/crates/node/tests/it/l1_e2e.rs b/crates/node/tests/it/l1_e2e.rs index 36f1737df..78b741f2d 100644 --- a/crates/node/tests/it/l1_e2e.rs +++ b/crates/node/tests/it/l1_e2e.rs @@ -6,14 +6,14 @@ use crate::utils::{ EncryptedRouterCallbackArgs, L1TestNode, PlaintextRouterCallbackArgs, STABLECOIN_DEX_ADDRESS, - WithdrawalArgs, ZoneAccount, ZoneTestNode, spawn_sequencer, + WithdrawalArgs, ZoneAccount, ZoneTestNode, run_e2e_proptest, spawn_sequencer, }; use alloy::{ primitives::{Address, B256, U256}, providers::Provider, }; use std::time::Duration; -use tempo_precompiles::PATH_USD_ADDRESS; +use tempo_precompiles::{PATH_USD_ADDRESS, stablecoin_dex::MIN_ORDER_AMOUNT}; use tempo_zone_contracts::{TEMPO_STATE_ADDRESS, TempoState, ZONE_TOKEN_ADDRESS}; use zone_node::dev::{ProvisionConfig, provision_zone}; @@ -22,7 +22,6 @@ use zone_node::dev::{ProvisionConfig, provision_zone}; const L1_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); const ROUTER_SWAP_TICK: i16 = 0; const ROUTER_SWAP_AMOUNT: u128 = 100_000_000; -const ROUTER_DEX_LIQUIDITY: u128 = 300_000_000; struct SameZoneSwapFixture { l1: L1TestNode, @@ -35,7 +34,7 @@ struct SameZoneSwapFixture { swap_amount: u128, } -async fn setup_same_zone_swap_fixture() -> eyre::Result { +async fn setup_same_zone_swap_fixture(swap_amount: u128) -> eyre::Result { let l1 = L1TestNode::start().await?; let alpha = l1 @@ -45,7 +44,13 @@ async fn setup_same_zone_swap_fixture() -> eyre::Result { .create_tip20("BetaUSD", "bUSD", B256::with_last_byte(0xB2)) .await?; - let mint_amount = ROUTER_DEX_LIQUIDITY + ROUTER_SWAP_AMOUNT; + let dex_liquidity = swap_amount + .checked_mul(3) + .ok_or_else(|| eyre::eyre!("swap amount overflows DEX liquidity"))? + .max(MIN_ORDER_AMOUNT); + let mint_amount = dex_liquidity + .checked_add(swap_amount) + .ok_or_else(|| eyre::eyre!("swap amount overflows mint amount"))?; l1.mint_tip20(alpha, l1.dev_address(), mint_amount).await?; l1.mint_tip20(beta, l1.dev_address(), mint_amount).await?; @@ -66,23 +71,23 @@ async fn setup_same_zone_swap_fixture() -> eyre::Result { l1.create_dex_pair(alpha).await?; l1.create_dex_pair(beta).await?; - l1.place_dex_bid_order(alpha, ROUTER_DEX_LIQUIDITY, ROUTER_SWAP_TICK) + l1.place_dex_bid_order(alpha, dex_liquidity, ROUTER_SWAP_TICK) .await?; - l1.place_dex_ask_order(beta, ROUTER_DEX_LIQUIDITY, ROUTER_SWAP_TICK) + l1.place_dex_ask_order(beta, dex_liquidity, ROUTER_SWAP_TICK) .await?; let mut account = ZoneAccount::from_l1_and_zone(&l1, &zone, portal_address); l1.fund_user(account.address(), 10_000_000).await?; - l1.fund_user_token(alpha, account.address(), ROUTER_SWAP_AMOUNT) + l1.fund_user_token(alpha, account.address(), swap_amount) .await?; account.deposit(5_000_000, L1_TIMEOUT, &zone).await?; let alpha_minted = account - .deposit_token(alpha, alpha, ROUTER_SWAP_AMOUNT, L1_TIMEOUT, &zone) + .deposit_token(alpha, alpha, swap_amount, L1_TIMEOUT, &zone) .await?; assert_eq!( alpha_minted, - U256::from(ROUTER_SWAP_AMOUNT), + U256::from(swap_amount), "AlphaUSD minted balance should equal the deposited amount" ); @@ -94,7 +99,7 @@ async fn setup_same_zone_swap_fixture() -> eyre::Result { alpha, beta, account, - swap_amount: ROUTER_SWAP_AMOUNT, + swap_amount, }) } @@ -420,15 +425,27 @@ async fn test_cross_zone_withdrawal() -> eyre::Result<()> { /// /// The refund must go to the bounceback recipient encoded in the router payload, /// not to the encrypted recipient and not to the router contract. -#[tokio::test(flavor = "multi_thread")] -async fn test_cross_zone_encrypted_router_bounceback_recipient() -> eyre::Result<()> { +#[test] +fn test_cross_zone_encrypted_bounceback_recipient_property() { + run_e2e_proptest( + (500_000u128..=2_000_000, 5u32..=10), + |(cross_amount, refund_signer_index)| { + run_cross_zone_encrypted_bounceback_recipient(cross_amount, refund_signer_index) + }, + ); +} + +async fn run_cross_zone_encrypted_bounceback_recipient( + cross_amount: u128, + refund_signer_index: u32, +) -> eyre::Result<()> { reth_tracing::init_test_tracing(); let l1 = L1TestNode::start().await?; let seq_a_signer = l1.signer_at(2); let seq_b_signer = l1.signer_at(3); let blacklisted_recipient = l1.signer_at(4).address(); - let refund_burner = l1.signer_at(5).address(); + let refund_burner = l1.signer_at(refund_signer_index).address(); let (portal_a, portal_b, router) = l1 .deploy_two_zones_with_sequencers(seq_a_signer.clone(), seq_b_signer.clone()) @@ -467,8 +484,9 @@ async fn test_cross_zone_encrypted_router_bounceback_recipient() -> eyre::Result } let mut alice = ZoneAccount::from_l1_and_zone(&l1, &zone_a, portal_a); - let deposit_amount: u128 = 2_000_000; - let cross_amount: u128 = 1_000_000; + let deposit_amount = cross_amount + .checked_mul(2) + .ok_or_else(|| eyre::eyre!("cross amount overflows deposit amount"))?; l1.fund_user(alice.address(), deposit_amount * 2).await?; alice.deposit(deposit_amount, L1_TIMEOUT, &zone_a).await?; @@ -545,7 +563,7 @@ async fn test_cross_zone_encrypted_router_bounceback_recipient() -> eyre::Result async fn test_swap_and_deposit_into_same_zone() -> eyre::Result<()> { reth_tracing::init_test_tracing(); - let mut fixture = setup_same_zone_swap_fixture().await?; + let mut fixture = setup_same_zone_swap_fixture(ROUTER_SWAP_AMOUNT).await?; let expected_beta = fixture .l1 .quote_dex_swap_exact_amount_in(fixture.alpha, fixture.beta, fixture.swap_amount) @@ -643,12 +661,17 @@ async fn test_swap_and_deposit_into_same_zone() -> eyre::Result<()> { /// /// Deposits for BetaUSD are paused on the target portal so the router callback /// reverts and the original AlphaUSD withdrawal bounces back to the sender. -#[tokio::test(flavor = "multi_thread")] -async fn test_swap_and_deposit_into_same_zone_bounces_back_on_plaintext_deposit_failure() --> eyre::Result<()> { +#[test] +fn test_swap_and_deposit_plaintext_bounceback_property() { + run_e2e_proptest(1_000_000u128..=100_000_000, |swap_amount| { + run_plaintext_deposit_failure_bounceback(swap_amount) + }); +} + +async fn run_plaintext_deposit_failure_bounceback(swap_amount: u128) -> eyre::Result<()> { reth_tracing::init_test_tracing(); - let mut fixture = setup_same_zone_swap_fixture().await?; + let mut fixture = setup_same_zone_swap_fixture(swap_amount).await?; let expected_beta = fixture .l1 .quote_dex_swap_exact_amount_in(fixture.alpha, fixture.beta, fixture.swap_amount) @@ -742,14 +765,19 @@ async fn test_swap_and_deposit_into_same_zone_bounces_back_on_plaintext_deposit_ /// This pins the callback behavior for `depositEncrypted`: even with a valid /// encrypted payload and key index, a target-portal deposit failure must revert /// the callback and bounce the original token back to the sender. -#[tokio::test(flavor = "multi_thread")] -async fn test_swap_and_deposit_into_same_zone_bounces_back_on_encrypted_deposit_failure() --> eyre::Result<()> { +#[test] +fn test_swap_and_deposit_encrypted_bounceback_property() { + run_e2e_proptest(1_000_000u128..=100_000_000, |swap_amount| { + run_encrypted_deposit_failure_bounceback(swap_amount) + }); +} + +async fn run_encrypted_deposit_failure_bounceback(swap_amount: u128) -> eyre::Result<()> { reth_tracing::init_test_tracing(); use sha2::{Digest, Sha256}; - let mut fixture = setup_same_zone_swap_fixture().await?; + let mut fixture = setup_same_zone_swap_fixture(swap_amount).await?; let expected_beta = fixture .l1 .quote_dex_swap_exact_amount_in(fixture.alpha, fixture.beta, fixture.swap_amount) @@ -1177,8 +1205,20 @@ async fn test_l1_policy_operations_and_zone_advancement() -> eyre::Result<()> { /// /// NOTE: This test validates the builder-level policy check in `build_encrypted_deposit`. /// The zone's policy cache must be pre-populated for the check to trigger. -#[tokio::test(flavor = "multi_thread")] -async fn test_encrypted_deposit_blacklisted_recipient() -> eyre::Result<()> { +#[test] +fn test_encrypted_deposit_policy_bounceback_property() { + run_e2e_proptest( + (500_000u128..=2_000_000, 2u32..=10), + |(deposit_amount, recipient_signer_index)| { + run_encrypted_deposit_policy_bounceback(deposit_amount, recipient_signer_index) + }, + ); +} + +async fn run_encrypted_deposit_policy_bounceback( + deposit_amount: u128, + recipient_signer_index: u32, +) -> eyre::Result<()> { reth_tracing::init_test_tracing(); // --- Step 1: Start L1 + deploy zone --- @@ -1192,7 +1232,7 @@ async fn test_encrypted_deposit_blacklisted_recipient() -> eyre::Result<()> { l1.change_transfer_policy_id(PATH_USD_ADDRESS, policy_id) .await?; - let blacklisted_recipient = l1.signer_at(2).address(); + let blacklisted_recipient = l1.signer_at(recipient_signer_index).address(); l1.blacklist_address(policy_id, blacklisted_recipient) .await?; @@ -1228,7 +1268,6 @@ async fn test_encrypted_deposit_blacklisted_recipient() -> eyre::Result<()> { // --- Step 6: Make an encrypted deposit targeting the blacklisted recipient --- let depositor = ZoneAccount::from_l1_and_zone(&l1, &zone, portal_address); - let deposit_amount: u128 = 1_000_000; l1.fund_user(depositor.address(), deposit_amount).await?; // Make the encrypted deposit on L1 targeting the blacklisted recipient. diff --git a/crates/node/tests/it/main.rs b/crates/node/tests/it/main.rs index 86837388f..d971eaaeb 100644 --- a/crates/node/tests/it/main.rs +++ b/crates/node/tests/it/main.rs @@ -3,6 +3,7 @@ mod demo_cross_zone; mod demo_shield_and_send; mod deposit; mod e2e; +mod e2e_property; mod enable_token; mod l1_e2e; mod precompiles; diff --git a/crates/node/tests/it/utils.rs b/crates/node/tests/it/utils.rs index ccdc2e70b..39ee58b08 100644 --- a/crates/node/tests/it/utils.rs +++ b/crates/node/tests/it/utils.rs @@ -12,6 +12,10 @@ use commonware_cryptography::{Signer as _, ed25519::PrivateKey as Ed25519Private use eyre::WrapErr; use k256::SecretKey; use p256::ecdsa::SigningKey as P256SigningKey; +use proptest::{ + strategy::Strategy, + test_runner::{Config as ProptestConfig, TestCaseError, TestRunner}, +}; use reth_node_api::FullNodeComponents; use reth_node_builder::{NodeBuilder, NodeConfig, NodeHandle, rpc::RethRpcAddOns}; use reth_node_core::{args::RpcServerArgs, exit::NodeExitFuture}; @@ -20,6 +24,7 @@ use reth_rpc_builder::RpcModuleSelection; use reth_tasks::Runtime; use std::{ collections::BTreeMap, + env, future::Future, net::{SocketAddr, TcpListener}, ops::Deref, @@ -766,6 +771,10 @@ impl ZoneTestNode { custom_genesis: Option, sequencer_signer: alloy_signer_local::PrivateKeySigner, ) -> eyre::Result { + // Real-L1 tests must discover the portal's enabled tokens so the + // subscriber tracks their TIP-403 policy events. Dummy-L1 tests have + // no reachable portal and explicitly skip discovery. + let initial_tokens = (l1_ws_url == DUMMY_L1_URL).then(Vec::new); Self::launch_with_genesis_and_withdrawal_batch_interval( l1_ws_url, portal_address, @@ -774,7 +783,7 @@ impl ZoneTestNode { custom_genesis, sequencer_signer, 8, - Some(vec![]), + initial_tokens, None, true, ) @@ -1786,7 +1795,7 @@ impl L1TestNode { &self, token: Address, policy_id: u64, - ) -> eyre::Result<()> { + ) -> eyre::Result { use tempo_contracts::precompiles::ITIP20; let provider = self.dev_provider(); @@ -1797,7 +1806,20 @@ impl L1TestNode { .get_receipt() .await?; eyre::ensure!(receipt.status(), "changeTransferPolicyId failed"); - Ok(()) + let event = receipt + .inner + .logs() + .iter() + .find_map(|log| ITIP20::TransferPolicyUpdate::decode_log(&log.inner).ok()) + .ok_or_else(|| eyre::eyre!("TransferPolicyUpdate event not found"))?; + eyre::ensure!( + event.newPolicyId == policy_id, + "TransferPolicyUpdate emitted policy {}, expected {policy_id}", + event.newPolicyId + ); + receipt + .block_number + .ok_or_else(|| eyre::eyre!("changeTransferPolicyId receipt has no block number")) } /// Create a COMPOUND policy on L1 that delegates to sub-policies by role. @@ -1981,6 +2003,57 @@ where } } +/// Run an expensive async E2E property through proptest. +/// +/// Each generated value receives a fresh Tokio runtime so shrinking does not +/// retain node tasks from the previous attempt. One case is the default because +/// each value launches real L1/Zone nodes and covers a complete behavior matrix. +/// Set `ZONE_E2E_PROPERTY_CASES` to increase it. +pub(crate) fn run_e2e_proptest(strategy: S, property: F) +where + S: Strategy, + S::Value: std::fmt::Debug, + F: Fn(S::Value) -> Fut, + Fut: Future>, +{ + let mut config = ProptestConfig::default(); + // Source-file persistence is unavailable when driving TestRunner directly. + // The failing input and RNG seed are still printed for deterministic replay. + config.failure_persistence = None; + // A single shrink launches a complete L1 and Zone stack, so keep the + // default bounded while retaining proptest's shrinking behavior. + config.max_shrink_iters = match env::var("ZONE_E2E_PROPERTY_SHRINK_ITERS") { + Ok(iterations) => iterations + .parse() + .expect("ZONE_E2E_PROPERTY_SHRINK_ITERS must be an unsigned integer"), + Err(env::VarError::NotPresent) => 4, + Err(err) => panic!("failed to read ZONE_E2E_PROPERTY_SHRINK_ITERS: {err}"), + }; + config.cases = match env::var("ZONE_E2E_PROPERTY_CASES") { + Ok(cases) => cases + .parse() + .expect("ZONE_E2E_PROPERTY_CASES must be an unsigned integer"), + Err(env::VarError::NotPresent) => 1, + Err(err) => panic!("failed to read ZONE_E2E_PROPERTY_CASES: {err}"), + }; + + let mut runner = TestRunner::new(config); + runner + .run(&strategy, |input| { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(8) + .enable_all() + .build() + .map_err(|err| { + TestCaseError::fail(format!("failed to build Tokio runtime: {err:#}")) + })?; + runtime + .block_on(property(input)) + .map_err(|err| TestCaseError::fail(format!("{err:#}"))) + }) + .expect("E2E property failed"); +} + /// Arguments for [`ZoneAccount::withdraw_with`]. /// /// Use [`WithdrawalArgs::new`] for the common case (amount only, self-withdrawal), diff --git a/docs/ZONES.md b/docs/ZONES.md index 68c4fd5ca..8134ff78e 100644 --- a/docs/ZONES.md +++ b/docs/ZONES.md @@ -66,8 +66,11 @@ cargo run --release --bin tempo-zone -- dev \ --l1.rpc-url ws://127.0.0.1:8545 ``` -This provisions a new ZoneFactory and portal, writes the generated zone files to -`/tmp/tempo-zone-dev`, and serves the zone HTTP RPC at `http://127.0.0.1:9545`. +The L1 must already expose TIP-1091's ZoneFactory at its fixed protocol address. +The dev command provisions a new portal, writes the generated zone files to +the platform's temporary directory (`$TMPDIR/tempo-zone-dev` on macOS and +`/tmp/tempo-zone-dev` on Linux), and serves the zone HTTP RPC at +`http://127.0.0.1:9545`. Older Anvil builds are rejected because they mine Ethereum header hashes and only add Tempo fields to the RPC response. Zones require canonical Tempo header hashes @@ -652,3 +655,4 @@ Current deployment: | `just zone-info ` | Fetch zone metadata from ZoneFactory | | `just demo-blacklist [amount] [rpc] [zone-dir]` | End-to-end TIP-20 + TIP-403 blacklist lifecycle demo | | `just spam-deposits [total] [per-block] [amount] [encrypted] [token] [lead-time]` | Send many deposit transactions to measure portal throughput | +| `just txgen-e2e-spam [count] [tps]` | Spam randomized multi-sender L2 TIP-20 transfers and report sustained TPS; see `docs/txgen-e2e-spam.md` | diff --git a/docs/txgen-e2e-spam.md b/docs/txgen-e2e-spam.md new file mode 100644 index 000000000..b87f37a9c --- /dev/null +++ b/docs/txgen-e2e-spam.md @@ -0,0 +1,146 @@ +# Zone E2E Properties and Txgen Spam + +Correctness and load generation are intentionally separate: + +- Rust `proptest` E2E tests own bridge, TIP-403, transfer, withdrawal, and + bounceback invariants against real in-process Tempo L1 and Zone nodes. +- `scripts/txgen-e2e-spam.sh` is one randomized multi-sender L2 TIP-20 transfer + primitive for measuring sustained submission and Zone execution throughput. + +The spammer does not mutate policies or run a deposit/withdrawal sweep. Its +setup deposits only enough pathUSD to fund the generated senders. + +## Build + +Build the Zone node and the sibling txgen checkout: + +```bash +cargo build --release --bin tempo-zone +cargo build --release \ + --manifest-path ../txgen/Cargo.toml \ + -p txgen-tempo \ + -p bench-cli +``` + +The defaults expect `../txgen/target/release/txgen-tempo` and +`../txgen/target/release/bench`. Override `TXGEN_DIR`, `TXGEN_TEMPO_BIN`, or +`BENCH_BIN` when the checkout lives elsewhere. + +## Start a local Zone + +Start a fresh Tempo-mode Anvil in terminal 1: + +```bash +anvil --network tempo --block-time 1 +``` + +Anvil must already expose TIP-1091's ZoneFactory at its protocol address. +The CI lifecycle script installs it into its disposable Anvil instance; the +standalone spammer deliberately does not modify the L1. + +Start the Zone in terminal 2: + +```bash +cargo run --release --bin tempo-zone -- dev \ + --l1.rpc-url ws://127.0.0.1:8545 \ + -- --zone.batch-interval-blocks 10 \ + --txpool.max-account-slots 1024 +``` + +The larger per-account pool allows the default 1,000 TPS attempt to queue more +than the normal 16 pending transactions per sender. The YAML also selects a +random sender from eight mnemonic accounts and a random Tempo nonce lane. + +This starts one sequencer. The result measures single-sequencer admission, +execution, and L1 batch submission; follower replication is not part of this +benchmark. + +## Run the spammer + +In terminal 3: + +```bash +just txgen-e2e-spam +``` + +The default sends 5,000 transfers at a target of 1,000 TPS. A smaller example: + +```bash +just txgen-e2e-spam 1000 250 +``` + +The runner reads the portal, initial token, and Zone RPC from the `zone.json` +written by `tempo-zone dev`. On macOS that defaults to +`$TMPDIR/tempo-zone-dev/zone.json`; set `ZONE_DATADIR` or `ZONE_JSON` if needed. + +Before the measured workload it funds every sender for the worst-case random +selection. The attempt passes when txgen admits every transaction, the dedicated +recipient receives the exact aggregate amount, and the portal submits an L1 +batch at or beyond the workload's final Zone block. The final line reports: + +```text +accepted=5000/5000 target_tps=1000 submission_tps=... zone_tps=... settled_l2_block=... +``` + +Useful controls: + +| Variable | Default | Purpose | +| --- | --- | --- | +| `COUNT` | `5000` | Number of measured L2 transfers | +| `TPS` | `1000` | Submission target; `0` means unlimited | +| `TXGEN_TRANSFER_ACCOUNTS` | `8` | Random sender pool size | +| `TXGEN_NONCE_LANES` | `1000000` | Random parallel nonce-key range | +| `MAX_CONCURRENT` | `2000` | Bench in-flight request limit | +| `TXGEN_TRANSFER_AMOUNT` | `1000` | Amount per transfer | +| `TXGEN_DEPOSIT_AMOUNT` | `5000000000` | Setup deposit size used to fund senders | +| `DRAIN_TIMEOUT` | `240` | Seconds allowed for bench to drain | +| `SYNC_TIMEOUT` | `300` | Seconds allowed for balances and L1 settlement | +| `TXGEN_REPORT_DIR` | `$ZONE_DATADIR/txgen-reports` | JSON report directory | + +The standalone primitive currently requires the transfer token and fee token to +be pathUSD and requires its active L1 transfer policy to be builtin allow-all +policy `1`. Policy behavior is tested in Rust instead of changed by the load +generator. + +## Rust E2E properties + +The model-based property generates amounts and action order while guaranteeing +coverage of reject-all, allow-all, whitelist, blacklist, and compound behavior +for both the initial and a dynamically enabled token. It checks deposits, +policy synchronization, L2 transfer receipts and balances, and L1 withdrawal +settlement. + +Four additional proptests cover every existing Rust E2E bounceback branch: + +- plaintext router callback/deposit failure; +- encrypted router callback/deposit failure; +- encrypted policy rejection refunded to the L1 depositor; and +- cross-zone encrypted policy rejection paid to an explicit bounceback recipient. + +Run one generated case of the state machine with: + +```bash +cargo test -p zone-node --test it \ + e2e_property::test_bridge_policy_transfer_state_machine_property -- --exact +``` + +Run the bounceback properties with the normal integration suite, or filter them: + +```bash +cargo test -p zone-node --test it bounceback_property -- --test-threads=1 +cargo test -p zone-node --test it \ + l1_e2e::test_cross_zone_encrypted_bounceback_recipient_property -- --exact +``` + +`ZONE_E2E_PROPERTY_CASES` increases complete generated cases. The default is one +because each case launches real nodes and the state machine already covers the +full behavior matrix. `ZONE_E2E_PROPERTY_SHRINK_ITERS` defaults to four; set it +to zero for a quick diagnostic run. Proptest prints the failing input and seed +for replay. + +## CI + +The normal Rust test job runs the properties. The separate `txgen L2 transfer +spam` job starts Anvil and one Zone sequencer, sends the default 5,000-transfer +1,000 TPS workload, uploads the report and node logs, and posts one concise PR +comment with accepted count, submission TPS, and observed Zone TPS. diff --git a/scripts/ci-txgen-e2e-comment.sh b/scripts/ci-txgen-e2e-comment.sh new file mode 100755 index 000000000..c4f09149f --- /dev/null +++ b/scripts/ci-txgen-e2e-comment.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +marker='' +body="${COMMENT_BODY:-}" +if [ -z "$body" ]; then + body="### Txgen L2 transfer spam + +❌ The CI job ended before it produced a summary. + +[Workflow run](${RUN_URL:?RUN_URL is required})" +fi +body="$marker +$body" + +comment_id="$(gh api \ + "repos/$GITHUB_REPOSITORY/issues/${PR_NUMBER:?PR_NUMBER is required}/comments" \ + --jq '[.[] | select(.body | contains(""))] | first | .id // empty')" + +if [ -n "$comment_id" ]; then + gh api --method PATCH \ + "repos/$GITHUB_REPOSITORY/issues/comments/$comment_id" \ + -f body="$body" >/dev/null +else + gh api --method POST \ + "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ + -f body="$body" >/dev/null +fi diff --git a/scripts/ci-txgen-e2e-spam.sh b/scripts/ci-txgen-e2e-spam.sh new file mode 100755 index 000000000..63770e269 --- /dev/null +++ b/scripts/ci-txgen-e2e-spam.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +die() { + echo "error: $*" >&2 + exit 1 +} + +lowercase() { + tr '[:upper:]' '[:lower:]' +} + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +for name in RUNNER_TEMP TXGEN_DIR L1_HTTP_URL; do + [ -n "${!name:-}" ] || die "$name is required" +done + +ZONE_DATADIR="${ZONE_DATADIR:-$RUNNER_TEMP/tempo-zone-dev}" +ARTIFACT_DIR="${TXGEN_E2E_ARTIFACT_DIR:-$RUNNER_TEMP/txgen-e2e}" +REPORT_DIR="${TXGEN_REPORT_DIR:-$ARTIFACT_DIR/reports}" +SUMMARY="$ARTIFACT_DIR/summary.md" +ZONE_RPC_URL="${ZONE_RPC_URL:-http://127.0.0.1:9545}" +L1_WS_URL="${L1_WS_URL:-ws://127.0.0.1:8545}" +ANVIL_PID="" +ZONE_PID="" + +mkdir -p "$ARTIFACT_DIR" "$REPORT_DIR" +export TXGEN_REPORT_DIR="$REPORT_DIR" ZONE_DATADIR + +stop_process() { + local pid="$1" + [ -n "$pid" ] || return 0 + kill -0 "$pid" 2>/dev/null || return 0 + kill -INT "$pid" 2>/dev/null || true + for _ in $(seq 1 50); do + if ! kill -0 "$pid" 2>/dev/null; then + wait "$pid" 2>/dev/null || true + return 0 + fi + sleep 0.2 + done + kill -TERM "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true +} + +install_anvil_zone_factory() { + local specs_root="$REPO_ROOT/specs/ref-impls" + local dev_key="${DEV_KEY:-0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80}" + local factory="0x5aF2000000000000000000000000000000000000" + local client_version owner factory_code installed_owner verifier messenger + local verifier_deployment messenger_deployment runtime one three valid_verifier_slot + local observed_verifier observed_messenger observed_owner valid_verifier + + client_version="$(cast rpc web3_clientVersion --rpc-url "$L1_HTTP_URL")" + case "$(printf '%s' "$client_version" | lowercase)" in + *anvil*) ;; + *) die "refusing to modify non-Anvil endpoint $L1_HTTP_URL (client: $client_version)" ;; + esac + + owner="$(cast wallet address "$dev_key")" + factory_code="$(cast code "$factory" --rpc-url "$L1_HTTP_URL")" + if [ "$factory_code" != "0x" ]; then + installed_owner="$(cast call "$factory" 'owner()(address)' --rpc-url "$L1_HTTP_URL")" + verifier="$(cast call "$factory" 'verifier()(address)' --rpc-url "$L1_HTTP_URL")" + messenger="$(cast call "$factory" 'messenger()(address)' --rpc-url "$L1_HTTP_URL")" + [ "$(printf '%s' "$installed_owner" | lowercase)" = "$(printf '%s' "$owner" | lowercase)" ] || \ + die "ZoneFactory owner is $installed_owner; expected $owner" + [ "$verifier" != "0x0000000000000000000000000000000000000000" ] || \ + die "ZoneFactory verifier is unset" + [ "$messenger" != "0x0000000000000000000000000000000000000000" ] || \ + die "ZoneFactory messenger is unset" + return + fi + + forge build --root "$specs_root" --skip test --no-lint >/dev/null + verifier_deployment="$( + forge create --root "$specs_root" src/tempo/Verifier.sol:Verifier \ + --broadcast --json --rpc-url "$L1_HTTP_URL" --private-key "$dev_key" + )" + verifier="$(printf '%s' "$verifier_deployment" | jq -er '.deployedTo')" + messenger_deployment="$( + forge create --root "$specs_root" src/tempo/ZoneMessenger.sol:ZoneMessenger \ + --broadcast --json --rpc-url "$L1_HTTP_URL" --private-key "$dev_key" \ + --constructor-args "$factory" + )" + messenger="$(printf '%s' "$messenger_deployment" | jq -er '.deployedTo')" + + runtime="$(jq -er '.deployedBytecode.object' "$specs_root/out/ZoneFactory.sol/ZoneFactory.json")" + case "$runtime" in 0x*) ;; *) runtime="0x$runtime" ;; esac + one="$(cast pad 0x01)" + three="$(cast pad 0x03)" + valid_verifier_slot="$(cast index address "$verifier" 3)" + + cast rpc anvil_setCode "$factory" "$runtime" --rpc-url "$L1_HTTP_URL" >/dev/null + cast rpc anvil_setNonce "$factory" 0x3 --rpc-url "$L1_HTTP_URL" >/dev/null + cast rpc anvil_setStorageAt "$factory" "$(cast pad 0x00)" "$one" --rpc-url "$L1_HTTP_URL" >/dev/null + cast rpc anvil_setStorageAt "$factory" "$(cast pad "$valid_verifier_slot")" "$one" --rpc-url "$L1_HTTP_URL" >/dev/null + cast rpc anvil_setStorageAt "$factory" "$(cast pad 0x04)" "$(cast pad "$verifier")" --rpc-url "$L1_HTTP_URL" >/dev/null + cast rpc anvil_setStorageAt "$factory" "$(cast pad 0x05)" "$(cast pad "$messenger")" --rpc-url "$L1_HTTP_URL" >/dev/null + cast rpc anvil_setStorageAt "$factory" "$(cast pad 0x06)" "$(cast pad "$owner")" --rpc-url "$L1_HTTP_URL" >/dev/null + cast rpc anvil_setStorageAt "$factory" "$(cast pad 0x07)" "$three" --rpc-url "$L1_HTTP_URL" >/dev/null + + observed_verifier="$(cast call "$factory" 'verifier()(address)' --rpc-url "$L1_HTTP_URL")" + observed_messenger="$(cast call "$factory" 'messenger()(address)' --rpc-url "$L1_HTTP_URL")" + observed_owner="$(cast call "$factory" 'owner()(address)' --rpc-url "$L1_HTTP_URL")" + valid_verifier="$(cast call "$factory" 'isValidVerifier(address)(bool)' "$verifier" --rpc-url "$L1_HTTP_URL")" + [ "$(printf '%s' "$observed_verifier" | lowercase)" = "$(printf '%s' "$verifier" | lowercase)" ] || \ + die "installed ZoneFactory verifier mismatch" + [ "$(printf '%s' "$observed_messenger" | lowercase)" = "$(printf '%s' "$messenger" | lowercase)" ] || \ + die "installed ZoneFactory messenger mismatch" + [ "$(printf '%s' "$observed_owner" | lowercase)" = "$(printf '%s' "$owner" | lowercase)" ] || \ + die "installed ZoneFactory owner mismatch" + [ "$valid_verifier" = "true" ] || die "installed ZoneFactory does not recognize its verifier" +} + +write_summary() { + local status="$1" + local result="❌ Failed" + local report="$REPORT_DIR/l2-tip20-spam.json" + local metrics sent accepted submission_tps zone_tps + local run_link="" + + if [ "$status" -eq 0 ]; then + result="✅ Passed" + fi + + printf '%s\n\n' '### Txgen L2 transfer spam' >"$SUMMARY" + if [ -f "$report" ] && \ + metrics="$(jq -er ' + [ + .sent, + .success, + ((.tps * 100) | round / 100), + ((.run_stats.avg_tps * 100) | round / 100) + ] | @tsv + ' "$report" 2>/dev/null)"; then + IFS=$'\t' read -r sent accepted submission_tps zone_tps <<<"$metrics" + printf '%s — **%s/%s accepted** at a **%s TPS** target; submission **%s TPS**; Zone **%s TPS**.\n' \ + "$result" "$accepted" "$sent" "${TPS:-unknown}" "$submission_tps" "$zone_tps" >>"$SUMMARY" + else + printf '%s — no complete spam report was produced; see the attached logs.\n' \ + "$result" >>"$SUMMARY" + fi + + if [ -n "${GITHUB_SERVER_URL:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ] && [ -n "${GITHUB_RUN_ID:-}" ]; then + run_link="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + printf '\n[Workflow run](%s)\n' "$run_link" >>"$SUMMARY" + fi + + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + cat "$SUMMARY" >>"$GITHUB_STEP_SUMMARY" + fi + if [ -n "${GITHUB_OUTPUT:-}" ]; then + { + echo 'comment_body<>"$GITHUB_OUTPUT" + fi +} + +finish() { + local status="$?" + trap - EXIT + set +e + stop_process "$ZONE_PID" + stop_process "$ANVIL_PID" + if [ -f "$ZONE_DATADIR/zone.json" ]; then + cp "$ZONE_DATADIR/zone.json" "$ARTIFACT_DIR/zone.json" + fi + write_summary "$status" + exit "$status" +} +trap finish EXIT + +cargo build --release --bin tempo-zone +cargo build --release \ + --manifest-path "$TXGEN_DIR/Cargo.toml" \ + -p txgen-tempo \ + -p bench-cli + +anvil --network tempo --block-time 1 --host 127.0.0.1 --port 8545 \ + >"$ARTIFACT_DIR/anvil.log" 2>&1 & +ANVIL_PID=$! + +for _ in $(seq 1 60); do + if cast rpc web3_clientVersion --rpc-url "$L1_HTTP_URL" >/dev/null 2>&1; then + break + fi + kill -0 "$ANVIL_PID" 2>/dev/null || die "Anvil exited before becoming ready" + sleep 1 +done +cast rpc web3_clientVersion --rpc-url "$L1_HTTP_URL" >/dev/null 2>&1 || \ + die "Anvil did not become ready" + +install_anvil_zone_factory + +target/release/tempo-zone dev \ + --l1.rpc-url "$L1_WS_URL" \ + --datadir "$ZONE_DATADIR" \ + --http.port 9545 \ + --private-rpc.port 8544 \ + -- \ + --zone.batch-interval-blocks 10 \ + --txpool.max-account-slots 1024 \ + >"$ARTIFACT_DIR/zone.log" 2>&1 & +ZONE_PID=$! + +for _ in $(seq 1 180); do + if [ -f "$ZONE_DATADIR/zone.json" ] && \ + cast block-number --rpc-url "$ZONE_RPC_URL" >/dev/null 2>&1; then + break + fi + kill -0 "$ZONE_PID" 2>/dev/null || die "tempo-zone dev exited before becoming ready" + sleep 1 +done +[ -f "$ZONE_DATADIR/zone.json" ] && \ + cast block-number --rpc-url "$ZONE_RPC_URL" >/dev/null 2>&1 || \ + die "tempo-zone dev did not become ready" + +scripts/txgen-e2e-spam.sh +[ -f "$REPORT_DIR/l2-tip20-spam.json" ] || die "txgen spam report was not produced" diff --git a/scripts/txgen-e2e-spam.sh b/scripts/txgen-e2e-spam.sh new file mode 100755 index 000000000..edafa4681 --- /dev/null +++ b/scripts/txgen-e2e-spam.sh @@ -0,0 +1,343 @@ +#!/usr/bin/env bash +set -euo pipefail + +export FOUNDRY_DISABLE_NIGHTLY_WARNING="${FOUNDRY_DISABLE_NIGHTLY_WARNING:-1}" + +usage() { + cat <<'EOF' +Usage: scripts/txgen-e2e-spam.sh + +Sends one randomized, multi-sender L2 TIP-20 transfer workload through txgen. +The target Zone must already be running and have written zone.json. Correctness +properties for bridge, policy, transfer, withdrawal, and bounceback behavior live +in the Rust E2E test suite; this script measures sustained submission/execution. + +The main controls are COUNT, TPS, TXGEN_TRANSFER_ACCOUNTS, and TXGEN_NONCE_LANES. +See docs/txgen-e2e-spam.md. +EOF +} + +die() { + echo "error: $*" >&2 + exit 1 +} + +first_word() { + awk 'NR == 1 { print $1; exit }' +} + +lowercase() { + tr '[:upper:]' '[:lower:]' +} + +require_uint() { + local name="$1" + local value="$2" + case "$value" in + ''|*[!0-9]*) die "$name must be an unsigned integer, got '$value'" ;; + esac +} + +uint_ge() { + local lhs="$1" + local rhs="$2" + + while [ "${lhs#0}" != "$lhs" ]; do lhs="${lhs#0}"; done + while [ "${rhs#0}" != "$rhs" ]; do rhs="${rhs#0}"; done + lhs="${lhs:-0}" + rhs="${rhs:-0}" + if [ "${#lhs}" -ne "${#rhs}" ]; then + [ "${#lhs}" -gt "${#rhs}" ] + else + [ "$lhs" = "$rhs" ] || [[ "$lhs" > "$rhs" ]] + fi +} + +if [ "$#" -gt 0 ]; then + case "$1" in + -h|--help) usage; exit 0 ;; + spam|throughput) ;; + *) usage >&2; die "this runner only supports the random L2 transfer spam primitive" ;; + esac +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SYSTEM_TMPDIR="${TMPDIR:-/tmp}" +SYSTEM_TMPDIR="${SYSTEM_TMPDIR%/}" +ZONE_DATADIR="${ZONE_DATADIR:-$SYSTEM_TMPDIR/tempo-zone-dev}" +ZONE_JSON="${ZONE_JSON:-$ZONE_DATADIR/zone.json}" + +L1_HTTP_URL="${L1_HTTP_URL:-${L1_RPC_URL:-http://127.0.0.1:8545}}" +case "$L1_HTTP_URL" in + ws://*) L1_HTTP_URL="http://${L1_HTTP_URL#ws://}" ;; + wss://*) L1_HTTP_URL="https://${L1_HTTP_URL#wss://}" ;; +esac + +TXGEN_DIR="${TXGEN_DIR:-$(cd "$REPO_ROOT/.." && pwd)/txgen}" +TXGEN_TEMPO_BIN="${TXGEN_TEMPO_BIN:-$TXGEN_DIR/target/release/txgen-tempo}" +BENCH_BIN="${BENCH_BIN:-$TXGEN_DIR/target/release/bench}" +TXGEN_REPORT_DIR="${TXGEN_REPORT_DIR:-$ZONE_DATADIR/txgen-reports}" + +COUNT="${COUNT:-5000}" +TPS="${TPS:-1000}" +MAX_CONCURRENT="${MAX_CONCURRENT:-2000}" +DRAIN_TIMEOUT="${DRAIN_TIMEOUT:-240}" +SYNC_TIMEOUT="${SYNC_TIMEOUT:-300}" +TXGEN_NONCE_LANES="${TXGEN_NONCE_LANES:-1000000}" +TXGEN_TRANSFER_ACCOUNTS="${TXGEN_TRANSFER_ACCOUNTS:-8}" +TXGEN_TRANSFER_AMOUNT="${TXGEN_TRANSFER_AMOUNT:-1000}" +TXGEN_TRANSFER_FEE_BUFFER="${TXGEN_TRANSFER_FEE_BUFFER:-100000}" +TXGEN_DEPOSIT_AMOUNT="${TXGEN_DEPOSIT_AMOUNT:-5000000000}" +TXGEN_MAX_FEE_PER_GAS="${TXGEN_MAX_FEE_PER_GAS:-100000000000}" +TXGEN_MAX_PRIORITY_FEE_PER_GAS="${TXGEN_MAX_PRIORITY_FEE_PER_GAS:-100000000000}" +TXGEN_MNEMONIC="${TXGEN_MNEMONIC:-test test test test test test test test test test test junk}" +TXGEN_FEE_TOKEN="${TXGEN_FEE_TOKEN:-0x20C0000000000000000000000000000000000000}" + +for pair in \ + "COUNT:$COUNT" \ + "TPS:$TPS" \ + "MAX_CONCURRENT:$MAX_CONCURRENT" \ + "DRAIN_TIMEOUT:$DRAIN_TIMEOUT" \ + "SYNC_TIMEOUT:$SYNC_TIMEOUT" \ + "TXGEN_NONCE_LANES:$TXGEN_NONCE_LANES" \ + "TXGEN_TRANSFER_ACCOUNTS:$TXGEN_TRANSFER_ACCOUNTS" \ + "TXGEN_TRANSFER_AMOUNT:$TXGEN_TRANSFER_AMOUNT" \ + "TXGEN_TRANSFER_FEE_BUFFER:$TXGEN_TRANSFER_FEE_BUFFER" \ + "TXGEN_DEPOSIT_AMOUNT:$TXGEN_DEPOSIT_AMOUNT"; do + require_uint "${pair%%:*}" "${pair#*:}" +done + +[ "$COUNT" -gt 0 ] || die "COUNT must be at least 1" +[ "$MAX_CONCURRENT" -gt 0 ] || die "MAX_CONCURRENT must be at least 1" +[ "$TXGEN_NONCE_LANES" -gt 1 ] || die "TXGEN_NONCE_LANES must be greater than 1" +[ "$TXGEN_TRANSFER_ACCOUNTS" -gt 0 ] || die "TXGEN_TRANSFER_ACCOUNTS must be at least 1" +[ "$TXGEN_TRANSFER_AMOUNT" -gt 0 ] || die "TXGEN_TRANSFER_AMOUNT must be at least 1" + +for command in cast jq awk tr date sleep mkdir; do + command -v "$command" >/dev/null || die "required command '$command' is not installed" +done +[ -f "$ZONE_JSON" ] || die "zone metadata not found at $ZONE_JSON; start tempo-zone dev first or set ZONE_DATADIR/ZONE_JSON" +[ -x "$TXGEN_TEMPO_BIN" ] || die "txgen-tempo not found at $TXGEN_TEMPO_BIN; build the sibling txgen checkout first" +[ -x "$BENCH_BIN" ] || die "bench not found at $BENCH_BIN; build bench-cli in the sibling txgen checkout first" + +TXGEN_PORTAL="$(jq -er '.portal' "$ZONE_JSON")" +ZONE_RPC_URL="${ZONE_RPC_URL:-$(jq -er '.rpcUrl' "$ZONE_JSON")}" +TXGEN_TOKEN="${TXGEN_TOKEN:-$(jq -er '.initialToken' "$ZONE_JSON")}" +TXGEN_ACCOUNT="$(cast wallet address --mnemonic "$TXGEN_MNEMONIC" --mnemonic-index 0)" +TXGEN_TRANSFER_RECIPIENT="${TXGEN_TRANSFER_RECIPIENT:-$(cast wallet address --mnemonic "$TXGEN_MNEMONIC" --mnemonic-index "$TXGEN_TRANSFER_ACCOUNTS")}" + +cast block-number --rpc-url "$L1_HTTP_URL" >/dev/null +cast block-number --rpc-url "$ZONE_RPC_URL" >/dev/null +[ "$(cast code "$TXGEN_PORTAL" --rpc-url "$L1_HTTP_URL")" != "0x" ] || \ + die "no ZonePortal code at $TXGEN_PORTAL on $L1_HTTP_URL" +[ "$(cast call "$TXGEN_PORTAL" 'isTokenEnabled(address)(bool)' "$TXGEN_TOKEN" --rpc-url "$L1_HTTP_URL")" = "true" ] || \ + die "TXGEN_TOKEN $TXGEN_TOKEN is not enabled on portal $TXGEN_PORTAL" +[ "$(printf '%s' "$TXGEN_TOKEN" | lowercase)" = "$(printf '%s' "$TXGEN_FEE_TOKEN" | lowercase)" ] || \ + die "the standalone spammer currently requires TXGEN_TOKEN and TXGEN_FEE_TOKEN to match" +[ "$(cast call "$TXGEN_TOKEN" 'transferPolicyId()(uint64)' --rpc-url "$L1_HTTP_URL" | first_word)" = "1" ] || \ + die "TXGEN_TOKEN must use allow-all policy 1 before throughput spam" + +L1_CHAIN_ID="$(cast chain-id --rpc-url "$L1_HTTP_URL")" +L2_CHAIN_ID="$(cast chain-id --rpc-url "$ZONE_RPC_URL")" +TXGEN_ACTIVE_ACCOUNTS=1 +ACTIVE_TRANSFER_ACCOUNTS=() +LAST_REPORT="" +SETTLED_L2_BLOCK="" + +mkdir -p "$TXGEN_REPORT_DIR" +export TXGEN_MNEMONIC TXGEN_NONCE_LANES TXGEN_MAX_FEE_PER_GAS +export TXGEN_MAX_PRIORITY_FEE_PER_GAS TXGEN_PORTAL TXGEN_TRANSFER_AMOUNT +export TXGEN_TOKEN TXGEN_FEE_TOKEN TXGEN_ACTIVE_ACCOUNTS TXGEN_TRANSFER_RECIPIENT +export TXGEN_DEPOSIT_AMOUNT + +run_workload() { + local spec="$1" + local rpc="$2" + local chain_id="$3" + local count="$4" + local report="$5" + local label="$6" + local sent accepted failed + + export TXGEN_CHAIN_ID="$chain_id" + LAST_REPORT="$report" + echo "==> $label: count=$count tps=$TPS rpc=$rpc" + "$TXGEN_TEMPO_BIN" generate \ + --spec "$SCRIPT_DIR/txgen/$spec" \ + --count "$count" \ + --rpc "$rpc" \ + | "$BENCH_BIN" send \ + --rpc-url "$rpc" \ + --tps "$TPS" \ + --max-concurrent "$MAX_CONCURRENT" \ + --retries 0 \ + --drain-timeout "$DRAIN_TIMEOUT" \ + --report "json:$report" \ + --metadata "zone-workload=$label" + + sent="$(jq -er '.sent' "$report")" + accepted="$(jq -er '.success' "$report")" + failed="$(jq -er '.failed' "$report")" + [ "$sent" -eq "$count" ] || die "$label generated $sent/$count transactions" + [ "$accepted" -eq "$count" ] && [ "$failed" -eq 0 ] || \ + die "$label accepted=$accepted failed=$failed expected=$count (report: $report)" +} + +zone_balance() { + local token="$1" + local account="$2" + cast call "$token" 'balanceOf(address)(uint256)' "$account" \ + --from "$account" --rpc-url "$ZONE_RPC_URL" | first_word +} + +deposit_to_account0() { + local required="$1" + local balance deposit_fee net_per_deposit missing deposit_count gross l1_balance + + balance="$(zone_balance "$TXGEN_TOKEN" "$TXGEN_ACCOUNT")" + if [ "$balance" -ge "$required" ]; then + return + fi + deposit_fee="$(cast call "$TXGEN_PORTAL" 'calculateDepositFee()(uint128)' --rpc-url "$L1_HTTP_URL" | first_word)" + [ "$TXGEN_DEPOSIT_AMOUNT" -gt "$deposit_fee" ] || \ + die "TXGEN_DEPOSIT_AMOUNT must exceed portal fee $deposit_fee" + net_per_deposit=$((TXGEN_DEPOSIT_AMOUNT - deposit_fee)) + missing=$((required - balance)) + deposit_count=$(((missing + net_per_deposit - 1) / net_per_deposit)) + gross=$((deposit_count * TXGEN_DEPOSIT_AMOUNT)) + l1_balance="$(cast call "$TXGEN_TOKEN" 'balanceOf(address)(uint256)' "$TXGEN_ACCOUNT" --rpc-url "$L1_HTTP_URL" | first_word)" + uint_ge "$l1_balance" "$gross" || \ + die "insufficient L1 token balance: have=$l1_balance need=$gross" + + run_workload l1-deposits.yaml "$L1_HTTP_URL" "$L1_CHAIN_ID" "$deposit_count" \ + "$TXGEN_REPORT_DIR/setup-deposit.json" setup-deposit + + local deadline=$(( $(date +%s) + SYNC_TIMEOUT )) + while :; do + balance="$(zone_balance "$TXGEN_TOKEN" "$TXGEN_ACCOUNT")" + [ "$balance" -ge "$required" ] && break + [ "$(date +%s)" -lt "$deadline" ] || \ + die "timed out waiting for L2 setup deposit: balance=$balance required=$required" + sleep 1 + done +} + +prepare_senders() { + local index=0 account + while [ "$index" -lt "$TXGEN_TRANSFER_ACCOUNTS" ]; do + account="$(cast wallet address --mnemonic "$TXGEN_MNEMONIC" --mnemonic-index "$index")" + [ "$(printf '%s' "$account" | lowercase)" != "$(printf '%s' "$TXGEN_TRANSFER_RECIPIENT" | lowercase)" ] || \ + die "TXGEN_TRANSFER_RECIPIENT must not be a throughput sender" + ACTIVE_TRANSFER_ACCOUNTS[${#ACTIVE_TRANSFER_ACCOUNTS[@]}]="$account" + index=$((index + 1)) + done +} + +fund_senders() { + local per_sender source_required total_missing=0 index account balance missing deadline + local original_amount="$TXGEN_TRANSFER_AMOUNT" + local original_recipient="$TXGEN_TRANSFER_RECIPIENT" + local -a missing_by_index + + # Random selection may choose one sender for the entire run. Fund every + # sender for that worst case so generator skew cannot invalidate the result. + per_sender=$((COUNT * (TXGEN_TRANSFER_AMOUNT + TXGEN_TRANSFER_FEE_BUFFER))) + index=1 + while [ "$index" -lt "${#ACTIVE_TRANSFER_ACCOUNTS[@]}" ]; do + account="${ACTIVE_TRANSFER_ACCOUNTS[$index]}" + balance="$(zone_balance "$TXGEN_TOKEN" "$account")" + if [ "$balance" -lt "$per_sender" ]; then missing=$((per_sender - balance)); else missing=0; fi + missing_by_index[$index]="$missing" + total_missing=$((total_missing + missing)) + index=$((index + 1)) + done + + source_required=$((per_sender + total_missing + TXGEN_TRANSFER_ACCOUNTS * TXGEN_TRANSFER_FEE_BUFFER)) + deposit_to_account0 "$source_required" + + TXGEN_ACTIVE_ACCOUNTS=1 + export TXGEN_ACTIVE_ACCOUNTS + index=1 + while [ "$index" -lt "${#ACTIVE_TRANSFER_ACCOUNTS[@]}" ]; do + missing="${missing_by_index[$index]}" + if [ "$missing" -gt 0 ]; then + account="${ACTIVE_TRANSFER_ACCOUNTS[$index]}" + balance="$(zone_balance "$TXGEN_TOKEN" "$account")" + TXGEN_TRANSFER_AMOUNT="$missing" + TXGEN_TRANSFER_RECIPIENT="$account" + export TXGEN_TRANSFER_AMOUNT TXGEN_TRANSFER_RECIPIENT + run_workload l2-tip20-transfers.yaml "$ZONE_RPC_URL" "$L2_CHAIN_ID" 1 \ + "$TXGEN_REPORT_DIR/setup-sender-$index.json" "setup-sender-$index" + deadline=$(( $(date +%s) + SYNC_TIMEOUT )) + while :; do + [ "$(zone_balance "$TXGEN_TOKEN" "$account")" -eq $((balance + missing)) ] && break + [ "$(date +%s)" -lt "$deadline" ] || die "timed out funding sender $account" + sleep 1 + done + fi + index=$((index + 1)) + done + + TXGEN_TRANSFER_AMOUNT="$original_amount" + TXGEN_TRANSFER_RECIPIENT="$original_recipient" + TXGEN_ACTIVE_ACCOUNTS="$TXGEN_TRANSFER_ACCOUNTS" + export TXGEN_TRANSFER_AMOUNT TXGEN_TRANSFER_RECIPIENT TXGEN_ACTIVE_ACCOUNTS +} + +wait_for_l1_settlement() { + local batch_before="$1" + local end_block deadline batch_after portal_hash l2_head block_number block_hash + + end_block="$(jq -er '.run_stats.end_block' "$LAST_REPORT")" + deadline=$(( $(date +%s) + SYNC_TIMEOUT )) + while :; do + batch_after="$(cast call "$TXGEN_PORTAL" 'withdrawalBatchIndex()(uint64)' --rpc-url "$L1_HTTP_URL" | first_word)" + portal_hash="$(cast call "$TXGEN_PORTAL" 'blockHash()(bytes32)' --rpc-url "$L1_HTTP_URL" | first_word)" + l2_head="$(cast block-number --rpc-url "$ZONE_RPC_URL")" + if [ "$batch_after" -gt "$batch_before" ] && [ "$l2_head" -ge "$end_block" ]; then + block_number="$end_block" + while [ "$block_number" -le "$l2_head" ]; do + block_hash="$(cast block "$block_number" --field hash --rpc-url "$ZONE_RPC_URL")" + if [ "$(printf '%s' "$block_hash" | lowercase)" = "$(printf '%s' "$portal_hash" | lowercase)" ]; then + SETTLED_L2_BLOCK="$block_number" + return + fi + block_number=$((block_number + 1)) + done + fi + [ "$(date +%s)" -lt "$deadline" ] || \ + die "timed out waiting for L1 settlement of spam ending at L2 block $end_block" + sleep 1 + done +} + +prepare_senders +fund_senders + +echo "Zone RPC: $ZONE_RPC_URL (chain $L2_CHAIN_ID)" +echo "Token: $TXGEN_TOKEN" +echo "Senders: ${#ACTIVE_TRANSFER_ACCOUNTS[@]} (${ACTIVE_TRANSFER_ACCOUNTS[*]})" +echo "Recipient: $TXGEN_TRANSFER_RECIPIENT" +echo "Target: $COUNT transactions at $TPS TPS" + +recipient_before="$(zone_balance "$TXGEN_TOKEN" "$TXGEN_TRANSFER_RECIPIENT")" +batch_before="$(cast call "$TXGEN_PORTAL" 'withdrawalBatchIndex()(uint64)' --rpc-url "$L1_HTTP_URL" | first_word)" +main_report="$TXGEN_REPORT_DIR/l2-tip20-spam.json" +run_workload l2-tip20-transfers.yaml "$ZONE_RPC_URL" "$L2_CHAIN_ID" "$COUNT" "$main_report" l2-tip20-spam + +expected_recipient=$((recipient_before + COUNT * TXGEN_TRANSFER_AMOUNT)) +deadline=$(( $(date +%s) + SYNC_TIMEOUT )) +while :; do + recipient_after="$(zone_balance "$TXGEN_TOKEN" "$TXGEN_TRANSFER_RECIPIENT")" + [ "$recipient_after" -eq "$expected_recipient" ] && break + [ "$(date +%s)" -lt "$deadline" ] || \ + die "recipient balance=$recipient_after expected=$expected_recipient after spam" + sleep 1 +done + +wait_for_l1_settlement "$batch_before" +accepted="$(jq -er '.success' "$main_report")" +submission_tps="$(jq -er '.tps' "$main_report")" +zone_tps="$(jq -er '.run_stats.avg_tps' "$main_report")" +echo "txgen spam passed: accepted=$accepted/$COUNT target_tps=$TPS submission_tps=$submission_tps zone_tps=$zone_tps settled_l2_block=$SETTLED_L2_BLOCK report=$main_report" diff --git a/scripts/txgen/abis/tip20.json b/scripts/txgen/abis/tip20.json new file mode 100644 index 000000000..058e467ca --- /dev/null +++ b/scripts/txgen/abis/tip20.json @@ -0,0 +1,29 @@ +[ + { + "type": "function", + "name": "approve", + "stateMutability": "nonpayable", + "inputs": [ + { "name": "spender", "type": "address" }, + { "name": "amount", "type": "uint256" } + ], + "outputs": [{ "name": "", "type": "bool" }] + }, + { + "type": "function", + "name": "changeTransferPolicyId", + "stateMutability": "nonpayable", + "inputs": [{ "name": "newPolicyId", "type": "uint64" }], + "outputs": [] + }, + { + "type": "function", + "name": "transfer", + "stateMutability": "nonpayable", + "inputs": [ + { "name": "to", "type": "address" }, + { "name": "amount", "type": "uint256" } + ], + "outputs": [{ "name": "", "type": "bool" }] + } +] diff --git a/scripts/txgen/abis/zone-portal.json b/scripts/txgen/abis/zone-portal.json new file mode 100644 index 000000000..3a3564404 --- /dev/null +++ b/scripts/txgen/abis/zone-portal.json @@ -0,0 +1,15 @@ +[ + { + "type": "function", + "name": "deposit", + "stateMutability": "nonpayable", + "inputs": [ + { "name": "token", "type": "address" }, + { "name": "to", "type": "address" }, + { "name": "amount", "type": "uint128" }, + { "name": "memo", "type": "bytes32" }, + { "name": "bouncebackRecipient", "type": "address" } + ], + "outputs": [{ "name": "newCurrentDepositQueueHash", "type": "bytes32" }] + } +] diff --git a/scripts/txgen/l1-deposits.yaml b/scripts/txgen/l1-deposits.yaml new file mode 100644 index 000000000..243e55bf5 --- /dev/null +++ b/scripts/txgen/l1-deposits.yaml @@ -0,0 +1,75 @@ +# L1 half of the bridge workload. The runner fills the chain, portal, token, +# and lane-count values from the live `tempo-zone dev` deployment. +chain_id: ${TXGEN_CHAIN_ID} + +gas: + max_fee_per_gas: ${TXGEN_MAX_FEE_PER_GAS} + max_priority_fee_per_gas: ${TXGEN_MAX_PRIORITY_FEE_PER_GAS} + +accounts: + dev: + mnemonic: "${TXGEN_MNEMONIC}" + index: 0 + +artifacts: + tip20: abis/tip20.json + zone_portal: abis/zone-portal.json + +# Approval is setup traffic. `bench send` waits for its receipt before releasing +# the parallel-lane deposit workload. +setup: + steps: + - id: approve_portal + bindings: + user: + account: { pool: dev, select: { index: 0 } } + tx: + type: tempo + from: { var: user.ref } + gas_limit: 500000 + # tempo-zone dev submits L1 batches from this same dev account. An + # expiring nonce avoids racing standard key 0 and is safe on reruns. + expiring_nonce: true + valid_for_secs: 25 + call: + to: "${TXGEN_TOKEN}" + abi: tip20 + function: approve + args: + - "${TXGEN_PORTAL}" + - "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + +templates: + deposit: + type: tempo + gas_limit: 2000000 + nonce_key: + uniform: + - 1 + - ${TXGEN_NONCE_LANES} + call: + to: "${TXGEN_PORTAL}" + abi: zone_portal + function: deposit + args: [] + +sequences: + deposit_to_self: + bindings: + user: + account: { pool: dev, select: { index: 0 } } + steps: + - template: deposit + with: + from: { var: user.ref } + call: + args: + - "${TXGEN_TOKEN}" + - { var: user.address } + - ${TXGEN_DEPOSIT_AMOUNT} + - "0x0000000000000000000000000000000000000000000000000000000000000000" + - { var: user.address } + +mix: + - sequence: deposit_to_self + weight: 1 diff --git a/scripts/txgen/l2-tip20-transfers.yaml b/scripts/txgen/l2-tip20-transfers.yaml new file mode 100644 index 000000000..859c721b0 --- /dev/null +++ b/scripts/txgen/l2-tip20-transfers.yaml @@ -0,0 +1,39 @@ +# Randomized multi-sender L2 TIP-20 transfer spam. Policy correctness belongs +# to the Rust proptests; this primitive only measures sustained throughput. +chain_id: ${TXGEN_CHAIN_ID} + +gas: + max_fee_per_gas: ${TXGEN_MAX_FEE_PER_GAS} + max_priority_fee_per_gas: ${TXGEN_MAX_PRIORITY_FEE_PER_GAS} + +accounts: + dev: + mnemonic: "${TXGEN_MNEMONIC}" + range: + - 0 + - ${TXGEN_ACTIVE_ACCOUNTS} + +artifacts: + tip20: abis/tip20.json + +templates: + transfer: + type: tempo + from: { pool: dev, select: random } + fee_token: "${TXGEN_FEE_TOKEN}" + gas_limit: 1000000 + nonce_key: + uniform: + - 1 + - ${TXGEN_NONCE_LANES} + call: + to: "${TXGEN_TOKEN}" + abi: tip20 + function: transfer + args: + - "${TXGEN_TRANSFER_RECIPIENT}" + - ${TXGEN_TRANSFER_AMOUNT} + +mix: + - template: transfer + weight: 1