├── .dockerignore ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── lint.yml │ ├── manual-publish.yml │ ├── publish.yml │ └── test.yml ├── .gitignore ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── README.md ├── crates ├── bundle_provider │ ├── CHANGELOG.md │ ├── Cargo.toml │ └── src │ │ ├── bundle.rs │ │ ├── bundle_provider.rs │ │ ├── error.rs │ │ ├── lib.rs │ │ └── revert_bundle.rs ├── cli │ ├── .gitignore │ ├── CHANGELOG.md │ ├── Cargo.lock │ ├── Cargo.toml │ └── src │ │ ├── commands │ │ ├── admin.rs │ │ ├── common.rs │ │ ├── contender_subcommand.rs │ │ ├── db.rs │ │ ├── mod.rs │ │ ├── setup.rs │ │ ├── spam.rs │ │ └── spamd.rs │ │ ├── default_scenarios │ │ ├── builtin.rs │ │ ├── bytecode.rs │ │ ├── contracts │ │ │ └── SpamMe.hex │ │ ├── fill_block.rs │ │ └── mod.rs │ │ ├── main.rs │ │ └── util │ │ ├── mod.rs │ │ ├── provider.rs │ │ └── utils.rs ├── core │ ├── CHANGELOG.md │ ├── Cargo.toml │ └── src │ │ ├── agent_controller.rs │ │ ├── buckets.rs │ │ ├── db │ │ ├── mock.rs │ │ └── mod.rs │ │ ├── error.rs │ │ ├── generator │ │ ├── mod.rs │ │ ├── named_txs.rs │ │ ├── seeder │ │ │ ├── mod.rs │ │ │ ├── rand_seed.rs │ │ │ └── trait.rs │ │ ├── templater.rs │ │ ├── trait.rs │ │ ├── types.rs │ │ └── util.rs │ │ ├── lib.rs │ │ ├── provider.rs │ │ ├── spammer │ │ ├── blockwise.rs │ │ ├── mod.rs │ │ ├── spammer_trait.rs │ │ ├── timed.rs │ │ ├── tx_actor.rs │ │ ├── tx_callback.rs │ │ ├── types.rs │ │ └── util.rs │ │ └── test_scenario.rs ├── engine_provider │ ├── CHANGELOG.md │ ├── Cargo.toml │ ├── README.md │ └── src │ │ ├── auth_provider.rs │ │ ├── auth_transport.rs │ │ ├── engine.rs │ │ ├── error.rs │ │ ├── lib.rs │ │ ├── traits.rs │ │ ├── util.rs │ │ └── valid_payload.rs ├── report │ ├── .gitignore │ ├── CHANGELOG.md │ ├── Cargo.toml │ └── src │ │ ├── block_trace.rs │ │ ├── cache.rs │ │ ├── chart │ │ ├── gas_per_block.rs │ │ ├── heatmap.rs │ │ ├── mod.rs │ │ ├── pending_txs.rs │ │ ├── rpc_latency.rs │ │ ├── time_to_inclusion.rs │ │ └── tx_gas_used.rs │ │ ├── command.rs │ │ ├── gen_html.rs │ │ ├── lib.rs │ │ ├── template.html.handlebars │ │ └── util.rs ├── sqlite_db │ ├── CHANGELOG.md │ ├── Cargo.toml │ └── src │ │ └── lib.rs └── testfile │ ├── CHANGELOG.md │ ├── Cargo.toml │ ├── src │ ├── lib.rs │ └── test_config.rs │ └── testConfig.toml ├── docs └── creating_scenarios.md ├── release-plz.toml ├── scenarios ├── bundles.toml ├── mempool.toml ├── op-interop │ └── l2MintAndSend.toml ├── precompiles │ ├── modexp.toml │ └── precompileStress.toml ├── reverts.toml ├── simple.toml ├── simpler.toml ├── slotContention.toml ├── stress.toml ├── uniV2.toml └── uniV3.toml ├── scripts └── test-report.sh └── test_fixtures ├── contender.db └── debug_trace.json /.dockerignore: -------------------------------------------------------------------------------- 1 | target 2 | docs 3 | .github 4 | Dockerfile 5 | scripts 6 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # These owners will be the default owners for everything in 2 | # the repo. Unless a later match takes precedence, 3 | # they will be requested for review when someone opens a pull request. 4 | * @zeroXbrock 5 | /.github/ @zeroXbrock @sukoneck 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | <-- if sensitive information is used (such as RPC URLs or private keys), omit them and make a note of their nature; e.g. "MY_PRIVATE_KEY on $LIVE_RPC_URL" --> 17 | 18 | **Expected behavior** 19 | A clear and concise description of what you expected to happen. 20 | 21 | **Screenshots** 22 | If applicable, add screenshots to help explain your problem. 23 | 24 | **Additional context** 25 | Add any other context about the problem here. 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 15 | ## Motivation 16 | 17 | 22 | 23 | ## Solution 24 | 25 | 29 | 30 | ## PR Checklist 31 | 32 | - [ ] Added Tests 33 | - [ ] Added Documentation 34 | - [ ] Breaking changes -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | clippy: 14 | name: clippy 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 30 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: dtolnay/rust-toolchain@nightly 20 | with: 21 | components: clippy 22 | - uses: Swatinem/rust-cache@v2 23 | with: 24 | cache-on-failure: true 25 | - name: Install deps 26 | run: sudo apt-get install -y fontconfig libfontconfig1-dev libfontconfig 27 | - run: cargo clippy --workspace --lib --examples --tests --benches --all-features --locked 28 | env: 29 | RUSTFLAGS: -D warnings 30 | 31 | fmt: 32 | name: fmt 33 | runs-on: ubuntu-latest 34 | timeout-minutes: 30 35 | steps: 36 | - uses: actions/checkout@v4 37 | - uses: dtolnay/rust-toolchain@nightly 38 | with: 39 | components: rustfmt 40 | - name: Run fmt 41 | run: cargo fmt --all --check 42 | 43 | udeps: 44 | name: udeps 45 | runs-on: ubuntu-latest 46 | timeout-minutes: 30 47 | steps: 48 | - uses: actions/checkout@v4 49 | - uses: dtolnay/rust-toolchain@nightly 50 | - uses: Swatinem/rust-cache@v2 51 | with: 52 | cache-on-failure: true 53 | - name: Install deps 54 | run: sudo apt-get install -y fontconfig libfontconfig1-dev libfontconfig 55 | - uses: taiki-e/install-action@cargo-udeps 56 | - run: cargo udeps --workspace --lib --examples --tests --benches --all-features --locked 57 | -------------------------------------------------------------------------------- /.github/workflows/manual-publish.yml: -------------------------------------------------------------------------------- 1 | name: Manual Release 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - 'v*' 8 | 9 | permissions: 10 | contents: write 11 | 12 | env: 13 | REGISTRY_IMAGE: flashbots/contender 14 | 15 | jobs: 16 | release: 17 | name: Publish Docker Image 18 | strategy: 19 | matrix: 20 | config: 21 | - platform: linux/amd64 22 | runner: warp-ubuntu-latest-x64-16x 23 | - platform: linux/arm64 24 | runner: warp-ubuntu-latest-arm64-16x 25 | runs-on: ${{ matrix.config.runner }} 26 | steps: 27 | - name: Checkout sources 28 | uses: actions/checkout@v2 29 | 30 | - name: Set env 31 | run: | 32 | platform=${{ matrix.config.platform }} 33 | echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV 34 | echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 35 | 36 | - name: Print version 37 | run: | 38 | echo $RELEASE_VERSION 39 | echo ${{ env.RELEASE_VERSION }} 40 | 41 | - name: Extract metadata (tags, labels) for Docker images 42 | id: meta 43 | uses: docker/metadata-action@v4 44 | with: 45 | images: ${{ env.REGISTRY_IMAGE }} 46 | 47 | - name: Set up QEMU 48 | uses: docker/setup-qemu-action@v3 49 | 50 | - name: Set up Docker Buildx 51 | uses: docker/setup-buildx-action@v3 52 | 53 | - name: Login to DockerHub 54 | uses: docker/login-action@v3 55 | with: 56 | username: ${{ secrets.FLASHBOTS_DOCKERHUB_USERNAME }} 57 | password: ${{ secrets.FLASHBOTS_DOCKERHUB_TOKEN }} 58 | 59 | - name: Build and push 60 | id: build 61 | uses: docker/build-push-action@v6 62 | with: 63 | cache-from: type=gha 64 | cache-to: type=gha,mode=max 65 | context: . 66 | build-args: | 67 | VERSION=${{ env.RELEASE_VERSION }} 68 | platforms: ${{ matrix.config.platform }} 69 | labels: ${{ steps.meta.outputs.labels }} 70 | outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true 71 | 72 | - name: Export digest 73 | run: | 74 | mkdir -p /tmp/digests 75 | digest="${{ steps.build.outputs.digest }}" 76 | touch "/tmp/digests/${digest#sha256:}" 77 | 78 | - name: Upload digest 79 | uses: actions/upload-artifact@v4 80 | with: 81 | name: digests-${{ env.PLATFORM_PAIR }} 82 | path: /tmp/digests/* 83 | if-no-files-found: error 84 | retention-days: 1 85 | 86 | merge: 87 | runs-on: ubuntu-latest 88 | needs: 89 | - release 90 | steps: 91 | - name: Download digests 92 | uses: actions/download-artifact@v4 93 | with: 94 | path: /tmp/digests 95 | pattern: digests-* 96 | merge-multiple: true 97 | 98 | - name: Login to Docker Hub 99 | uses: docker/login-action@v3 100 | with: 101 | username: ${{ secrets.FLASHBOTS_DOCKERHUB_USERNAME }} 102 | password: ${{ secrets.FLASHBOTS_DOCKERHUB_TOKEN }} 103 | 104 | - name: Set up Docker Buildx 105 | uses: docker/setup-buildx-action@v3 106 | 107 | - name: Docker meta 108 | id: meta 109 | uses: docker/metadata-action@v5 110 | with: 111 | images: ${{ env.REGISTRY_IMAGE }} 112 | tags: | 113 | type=sha 114 | type=pep440,pattern={{version}} 115 | type=pep440,pattern={{major}}.{{minor}} 116 | type=raw,value=latest,enable=${{ !contains(env.RELEASE_VERSION, '-') }} 117 | 118 | - name: Create manifest list and push 119 | working-directory: /tmp/digests 120 | run: | 121 | docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ 122 | $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *) 123 | 124 | - name: Inspect image 125 | run: | 126 | docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }} 127 | 128 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Crates 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | 10 | # Release unpublished packages. 11 | release-plz-release: 12 | name: Release-plz release 13 | runs-on: ubuntu-latest 14 | if: ${{ github.repository_owner == 'flashbots' }} 15 | permissions: 16 | contents: write 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | - name: Install Rust toolchain 23 | uses: dtolnay/rust-toolchain@stable 24 | - name: Run release-plz 25 | uses: release-plz/action@v0.5 26 | with: 27 | command: release 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 31 | 32 | # Create a PR with the new versions and changelog, preparing the next release. 33 | release-plz-pr: 34 | name: Release-plz PR 35 | runs-on: ubuntu-latest 36 | permissions: 37 | contents: write 38 | pull-requests: write 39 | issues: write 40 | concurrency: 41 | group: release-plz-${{ github.ref }} 42 | cancel-in-progress: false 43 | steps: 44 | - name: Checkout repository 45 | uses: actions/checkout@v4 46 | with: 47 | fetch-depth: 0 48 | - name: Install Rust toolchain 49 | uses: dtolnay/rust-toolchain@stable 50 | - name: Run release-plz 51 | uses: release-plz/action@v0.5 52 | with: 53 | command: release-pr 54 | env: 55 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 56 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} # not used for now 57 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | name: Build on ${{ matrix.configs.runner }} 15 | runs-on: ${{ matrix.configs.runner }} 16 | strategy: 17 | matrix: 18 | configs: 19 | - runner: warp-ubuntu-latest-x64-32x 20 | - runner: warp-ubuntu-latest-arm64-32x 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: dtolnay/rust-toolchain@stable 25 | - uses: Swatinem/rust-cache@v2 26 | with: 27 | cache-on-failure: true 28 | - name: Install deps 29 | run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev fontconfig libfontconfig1-dev libfontconfig 30 | - name: Build 31 | run: cargo build --verbose --workspace 32 | 33 | test: 34 | name: Test on ${{ matrix.configs.runner }} 35 | runs-on: ${{ matrix.configs.runner }} 36 | strategy: 37 | matrix: 38 | configs: 39 | - runner: warp-ubuntu-latest-x64-16x 40 | - runner: warp-ubuntu-latest-arm64-16x 41 | 42 | steps: 43 | - uses: actions/checkout@v4 44 | with: 45 | submodules: recursive 46 | - name: Install Foundry 47 | uses: foundry-rs/foundry-toolchain@v1 48 | - uses: dtolnay/rust-toolchain@stable 49 | - uses: Swatinem/rust-cache@v2 50 | with: 51 | cache-on-failure: true 52 | - name: Install deps 53 | run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev fontconfig libfontconfig1-dev libfontconfig 54 | - name: Run tests 55 | run: cargo test --verbose --workspace 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | cargotest.toml 3 | *.db 4 | !test_fixtures/*.db 5 | report.csv 6 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "crates/bundle_provider", 4 | "crates/cli/", 5 | "crates/core/", 6 | "crates/engine_provider", 7 | "crates/report", 8 | "crates/sqlite_db/", 9 | "crates/testfile/", 10 | ] 11 | 12 | resolver = "2" 13 | 14 | [workspace.package] 15 | edition = "2021" 16 | rust-version = "1.86" 17 | authors = ["Flashbots"] 18 | license = "MIT OR Apache-2.0" 19 | homepage = "https://github.com/flashbots/contender" 20 | repository = "https://github.com/flashbots/contender" 21 | 22 | [workspace.dependencies] 23 | contender_core = { path = "crates/core/" } 24 | contender_sqlite = { path = "crates/sqlite_db/" } 25 | contender_testfile = { path = "crates/testfile/" } 26 | contender_bundle_provider = { path = "crates/bundle_provider/" } 27 | contender_engine_provider = { path = "crates/engine_provider/" } 28 | contender_report = { path = "crates/report/" } 29 | 30 | eyre = "0.6.12" 31 | tokio = { version = "1.40.0" } 32 | tokio-util = "0.7" 33 | alloy = { version = "1.0.3" } 34 | alloy-signer = { version = "0.14.0", features = ["wallet"] } 35 | serde = "1.0.209" 36 | rand = "0.8.5" 37 | tracing = "0.1.41" 38 | tracing-subscriber = { version = "0.3" } 39 | prometheus = "0.14" 40 | 41 | ## cli 42 | ansi_term = "0.12.1" 43 | clap = { version = "4.5.16" } 44 | csv = "1.3.0" 45 | 46 | ## core 47 | futures = "0.3.30" 48 | async-trait = "0.1.82" 49 | jsonrpsee = { version = "0.24" } 50 | alloy-serde = "0.5.4" 51 | serde_json = "1.0.132" 52 | thiserror = "2.0.12" 53 | tower = "0.5.2" 54 | alloy-rpc-types-engine = { version = "1.0.3", default-features = false } 55 | alloy-json-rpc = { version = "1.0.3", default-features = false } 56 | alloy-chains = { version = "0.1.64", default-features = false } 57 | reth-node-api = { git = "https://github.com/paradigmxyz/reth", tag = "v1.4.0", default-features = false } 58 | reth-rpc-layer = { git = "https://github.com/paradigmxyz/reth", tag = "v1.4.0", default-features = false } 59 | reth-optimism-node = { git = "https://github.com/paradigmxyz/reth", tag = "v1.4.0" } 60 | reth-optimism-primitives = { git = "https://github.com/paradigmxyz/reth", tag = "v1.4.0" } 61 | op-alloy-consensus = { version = "0.16.0", default-features = false } 62 | op-alloy-network = { version = "0.16.0", default-features = false } 63 | op-alloy-rpc-types = { version = "0.16.0", default-features = false } 64 | 65 | ## sqlite 66 | r2d2_sqlite = "0.25.0" 67 | rusqlite = "0.32.1" 68 | r2d2 = "0.8.10" 69 | 70 | ## testfile 71 | toml = "0.8.19" 72 | 73 | ## report 74 | chrono = "0.4.39" 75 | handlebars = "6.3.0" 76 | regex = "1.11.1" 77 | webbrowser = "1.0.3" 78 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # --- Builder stage --- 2 | FROM rust:slim AS builder 3 | 4 | # Install build dependencies 5 | RUN apt-get update && \ 6 | apt-get install -y make curl git libsqlite3-dev fontconfig libfontconfig1-dev libfontconfig libssl-dev libclang-dev 7 | 8 | # Copy in project files 9 | COPY . /app 10 | WORKDIR /app 11 | 12 | # Build contender cli from source 13 | RUN cargo install --path ./crates/cli --root /app/contender-dist 14 | 15 | # Install anvil (foundry) 16 | RUN curl -L https://foundry.paradigm.xyz | bash && \ 17 | /root/.foundry/bin/foundryup && \ 18 | cp /root/.foundry/bin/anvil /app/contender-dist/bin/ 19 | 20 | # --- Runtime stage --- 21 | FROM debian:bookworm-slim 22 | 23 | # Install runtime dependencies 24 | RUN apt-get update && \ 25 | apt-get install -y libsqlite3-0 fontconfig libfontconfig1 libssl3 clang && \ 26 | rm -rf /var/lib/apt/lists/* 27 | 28 | # Copy built binary and test fixtures from builder 29 | COPY --from=builder /app/contender-dist /root/.cargo 30 | 31 | # Set permissions 32 | RUN mkdir -p /root/.contender 33 | 34 | # Import test fixtures into .contender 35 | COPY ./test_fixtures/* /root/.contender/ 36 | 37 | # prevent contender from trying to open a browser 38 | ENV BROWSER=none 39 | # use cached test data for reports 40 | ENV DEBUG_USEFILE=true 41 | 42 | ENV PATH="/root/.cargo/bin:${PATH}" 43 | 44 | # to override test data or persist results, mount host directory to: 45 | # /root/.contender[/reports] 46 | 47 | ENTRYPOINT ["contender"] 48 | CMD ["--help"] 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright 2024 Flashbots 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /crates/bundle_provider/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.2.1](https://github.com/flashbots/contender/releases/tag/contender_bundle_provider-v0.2.1) - 2025-05-14 11 | 12 | ### Other 13 | 14 | - ci publish ([#215](https://github.com/flashbots/contender/pull/215)) 15 | - bugfix/tokio task panics ([#187](https://github.com/flashbots/contender/pull/187)) 16 | - Merge branch 'main' into update-alloy 17 | - update alloy 18 | - remove unused deps 19 | - *(bundle_provider)* rewrite BundleClient to use alloy 20 | - before spamming, error if acct balance l.t. total spam cost 21 | - add new Spammer trait, replace blockwise spammer 22 | - move bundle_provider to its own crate (should be replaced entirely, anyhow) 23 | -------------------------------------------------------------------------------- /crates/bundle_provider/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "contender_bundle_provider" 3 | version = "0.2.1" 4 | edition.workspace = true 5 | rust-version.workspace = true 6 | authors.workspace = true 7 | license.workspace = true 8 | homepage.workspace = true 9 | repository.workspace = true 10 | description = "Contender bundle provider" 11 | 12 | [dependencies] 13 | alloy = { workspace = true, features = ["node-bindings", "rpc-types-mev"] } 14 | serde = { workspace = true, features = ["derive"] } 15 | tracing = { workspace = true } 16 | -------------------------------------------------------------------------------- /crates/bundle_provider/src/bundle.rs: -------------------------------------------------------------------------------- 1 | use alloy::rpc::types::mev::{EthBundleHash, EthSendBundle}; 2 | use tracing::debug; 3 | 4 | use crate::{ 5 | error::BundleProviderError, 6 | revert_bundle::{BundlesFromRequest, RevertProtectBundleRequest}, 7 | BundleClient, 8 | }; 9 | 10 | #[derive(Clone, Copy, Debug, Default)] 11 | pub enum BundleType { 12 | #[default] 13 | L1, 14 | RevertProtected, 15 | } 16 | 17 | #[derive(Clone, Debug)] 18 | pub enum TypedBundle { 19 | L1(EthSendBundle), 20 | RevertProtected(RevertProtectBundleRequest), 21 | } 22 | 23 | impl TypedBundle { 24 | pub async fn send(&self, client: &BundleClient) -> Result<(), BundleProviderError> { 25 | match self { 26 | TypedBundle::L1(b) => { 27 | let res = client.send_bundle::<_, EthBundleHash>(b).await?; 28 | debug!("{:?} bundle sent, response: {:?}", b, res); 29 | } 30 | TypedBundle::RevertProtected(b) => { 31 | // make a RevertProtectBundle from each tx in the bundle 32 | // and send it to the client 33 | for bundle in b.to_bundles() { 34 | let res = client.send_bundle::<_, EthBundleHash>(&bundle).await?; 35 | debug!( 36 | "{:?} Revert protected bundle sent, response: {:?}", 37 | bundle, res 38 | ); 39 | } 40 | } 41 | } 42 | Ok(()) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /crates/bundle_provider/src/bundle_provider.rs: -------------------------------------------------------------------------------- 1 | use alloy::{ 2 | network::AnyNetwork, 3 | primitives::Bytes, 4 | providers::{Provider, ProviderBuilder, RootProvider}, 5 | rpc::{ 6 | json_rpc::{RpcRecv, RpcSend}, 7 | types::mev::EthSendBundle, 8 | }, 9 | transports::http::reqwest::IntoUrl, 10 | }; 11 | 12 | use crate::{bundle::TypedBundle, error::BundleProviderError}; 13 | 14 | /// A helper wrapper around a RPC client that can be used to call `eth_sendBundle`. 15 | #[derive(Debug)] 16 | pub struct BundleClient { 17 | client: RootProvider, 18 | } 19 | 20 | impl BundleClient { 21 | /// Creates a new [`BundleClient`] with the given URL. 22 | pub fn new(url: impl IntoUrl) -> Result { 23 | let provider = ProviderBuilder::default().connect_http( 24 | url.into_url() 25 | .map_err(|_| BundleProviderError::InvalidUrl)?, 26 | ); 27 | Ok(Self { client: provider }) 28 | } 29 | 30 | /// Sends a bundle using `eth_sendBundle`, discarding the response. 31 | pub async fn send_bundle( 32 | &self, 33 | bundle: Bundle, 34 | ) -> Result, BundleProviderError> { 35 | // Result contents optional because some endpoints don't return this response 36 | self.client 37 | .raw_request::<_, Option>("eth_sendBundle".into(), [bundle]) 38 | .await 39 | .map_err(|e| BundleProviderError::SendBundleError(e.into())) 40 | } 41 | } 42 | 43 | /// Creates a new bundle with the given transactions and block number, setting the rest of the 44 | /// fields to default values. 45 | #[inline] 46 | pub fn new_basic_bundle(txs: Vec, block_number: u64) -> TypedBundle { 47 | TypedBundle::L1(EthSendBundle { 48 | txs, 49 | block_number, 50 | ..Default::default() 51 | }) 52 | } 53 | -------------------------------------------------------------------------------- /crates/bundle_provider/src/error.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | #[derive(Debug)] 4 | pub enum BundleProviderError { 5 | InvalidUrl, 6 | SendBundleError(Box), 7 | } 8 | 9 | impl Display for BundleProviderError { 10 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 11 | match self { 12 | BundleProviderError::InvalidUrl => write!(f, "Invalid builder URL"), 13 | BundleProviderError::SendBundleError(e) => write!(f, "Failed to send bundle: {e:?}"), 14 | } 15 | } 16 | } 17 | 18 | impl std::error::Error for BundleProviderError { 19 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 20 | match self { 21 | BundleProviderError::InvalidUrl => None, 22 | BundleProviderError::SendBundleError(e) => Some(e.as_ref()), 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /crates/bundle_provider/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod bundle_provider; 2 | pub use bundle_provider::BundleClient; 3 | pub mod bundle; 4 | pub mod error; 5 | pub mod revert_bundle; 6 | -------------------------------------------------------------------------------- /crates/bundle_provider/src/revert_bundle.rs: -------------------------------------------------------------------------------- 1 | use alloy::primitives::Bytes; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use crate::bundle::TypedBundle; 5 | 6 | #[derive(Clone, Debug, Deserialize, Serialize)] 7 | pub struct RevertProtectBundle { 8 | #[serde(rename = "txs")] 9 | transaction: Vec, 10 | block_number_max: Option, 11 | } 12 | 13 | #[derive(Clone, Debug, Default)] 14 | pub struct RevertProtectBundleRequest { 15 | pub txs: Vec, 16 | pub block_number_max: Option, 17 | } 18 | 19 | impl RevertProtectBundleRequest { 20 | pub fn new() -> Self { 21 | Self::default() 22 | } 23 | 24 | pub fn with_txs(self, txs: Vec) -> Self { 25 | Self { 26 | txs, 27 | block_number_max: self.block_number_max, 28 | } 29 | } 30 | 31 | pub fn with_block_max(self, max_block: u64) -> Self { 32 | Self { 33 | txs: self.txs, 34 | block_number_max: Some(max_block), 35 | } 36 | } 37 | 38 | pub fn prepare(self) -> TypedBundle { 39 | TypedBundle::RevertProtected(self) 40 | } 41 | } 42 | 43 | impl AsRef for RevertProtectBundleRequest { 44 | fn as_ref(&self) -> &RevertProtectBundleRequest { 45 | self 46 | } 47 | } 48 | 49 | impl> From for RevertProtectBundle { 50 | fn from(req: T) -> Self { 51 | let RevertProtectBundleRequest { 52 | txs, 53 | block_number_max, 54 | } = req.as_ref(); 55 | 56 | if txs.is_empty() { 57 | panic!("RevertProtectBundleRequest must have at least one transaction"); 58 | } 59 | // temporary until revert-protect bundles support multiple transactions 60 | if txs.len() > 1 { 61 | panic!("RevertProtectBundleRequest can only contain one transaction"); 62 | } 63 | 64 | Self { 65 | transaction: txs.to_owned(), 66 | block_number_max: block_number_max.to_owned(), 67 | } 68 | } 69 | } 70 | 71 | /// Temporary until revert-protect bundles support multiple transactions. 72 | /// Once that is supported, this trait can be removed and `RevertProtectBundleRequest::into::()` can be used instead. 73 | pub trait BundlesFromRequest { 74 | fn to_bundles(&self) -> Vec; 75 | } 76 | 77 | impl BundlesFromRequest for RevertProtectBundleRequest { 78 | /// Converts a RevertProtectBundleRequest into Vec. 79 | fn to_bundles(&self) -> Vec { 80 | self.txs 81 | .iter() 82 | .map(|tx| RevertProtectBundle { 83 | transaction: vec![tx.to_owned()], 84 | block_number_max: self.block_number_max, 85 | }) 86 | .collect() 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /crates/cli/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | -------------------------------------------------------------------------------- /crates/cli/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.2.1](https://github.com/flashbots/contender/releases/tag/contender_cli-v0.2.1) - 2025-05-14 11 | 12 | ### Added 13 | 14 | - add timer warning on contract deployment ([#179](https://github.com/flashbots/contender/pull/179)) 15 | 16 | ### Fixed 17 | 18 | - fix spam cost estimate bug ([#188](https://github.com/flashbots/contender/pull/188)) 19 | - fix ugly casts 20 | - fix warnings 21 | - fix 22 | - fix providers in tests 23 | - fix erroneous clone 24 | - fix subtraction underflow in heatmap 25 | - fix broken test 26 | - fix util test 27 | - fix slot index bug in heatmap 28 | - fix erroneous panic, improve funding error logs 29 | 30 | ### Other 31 | 32 | - ci publish ([#215](https://github.com/flashbots/contender/pull/215)) 33 | - Feat/reports w runtime params ([#213](https://github.com/flashbots/contender/pull/213)) 34 | - Build other charts even w ([#214](https://github.com/flashbots/contender/pull/214)) 35 | - Feat/runtime param help ([#204](https://github.com/flashbots/contender/pull/204)) 36 | - consolidate spamd ([#211](https://github.com/flashbots/contender/pull/211)) 37 | - Adding remote scenarios ([#202](https://github.com/flashbots/contender/pull/202)) 38 | - add debug log for failed provider calls ([#200](https://github.com/flashbots/contender/pull/200)) 39 | - Feat/env vars as cli args ([#189](https://github.com/flashbots/contender/pull/189)) 40 | - Feature/174 admin command ([#180](https://github.com/flashbots/contender/pull/180)) 41 | - Added default RPC value as http://localhost:8545 ([#196](https://github.com/flashbots/contender/pull/196)) 42 | - bugfix/tokio task panics ([#187](https://github.com/flashbots/contender/pull/187)) 43 | - Feat/more metrics ([#181](https://github.com/flashbots/contender/pull/181)) 44 | - refactor faulty conditional preventing percentages > 100 ([#186](https://github.com/flashbots/contender/pull/186)) 45 | - build example report in CI ([#185](https://github.com/flashbots/contender/pull/185)) 46 | - engine_ calls to advance chain manually ([#165](https://github.com/flashbots/contender/pull/165)) 47 | - quality-of-life fixes ([#178](https://github.com/flashbots/contender/pull/178)) 48 | - gas price adder & priority fee bugfix ([#176](https://github.com/flashbots/contender/pull/176)) 49 | - drop stalled txs ([#175](https://github.com/flashbots/contender/pull/175)) 50 | - bugfixes & code organization ([#173](https://github.com/flashbots/contender/pull/173)) 51 | - upgrade alloy ([#172](https://github.com/flashbots/contender/pull/172)) 52 | - simplify util functions ([#171](https://github.com/flashbots/contender/pull/171)) 53 | - spamd ([#170](https://github.com/flashbots/contender/pull/170)) 54 | - tx observability, DB upgrades ([#167](https://github.com/flashbots/contender/pull/167)) 55 | - simple scenario + code touchups ([#164](https://github.com/flashbots/contender/pull/164)) 56 | - log request id w/ hash when calling sendRawTransaction ([#161](https://github.com/flashbots/contender/pull/161)) 57 | - update slot-conflict scenario's fn params, more verbose logs 58 | - clippy 59 | - parallelize block retrieval in report 60 | - parallelize trace retrieval in report command 61 | - switch block type in report to Any 62 | - improve log for funding txs 63 | - add estimate test & general cleanup 64 | - estimate setup cost using anvil 65 | - nitpicking verbiage 66 | - clippy 67 | - various refactors 68 | - add TxType for scenario txs 69 | - clippy + cleanup 70 | - clippy 71 | - add flag to skip deploy prompt in 'run' command 72 | - implement gas_limit override in generator & testfile 73 | - remove unnecessary typecasts 74 | - fetch report fonts from CDN, delete font files 75 | - update header styles 76 | - add fonts 77 | - Change background color 78 | - make charts white 79 | - add deadpine styles to html template 80 | - cleanup nits 81 | - prevent divide-by-zero errors before they happen in spammer 82 | - fund accounts before creating scenario 83 | - add scenario_name to runs table, use in report 84 | - add metadata to report command args 85 | - limit # axis labels to prevent crowded text 86 | - remove default trace decoder (unnecessary & not always supported), add page breaks in report template 87 | - clippy 88 | - make tests parallelizable, take db path as args in db functions 89 | - Merge branch 'main' into feat/db-cli 90 | - error before returning from heatmap.build if no trace data collected 91 | - improve ContenderError::with_err, handle trace failure 92 | - reorganize report module into submodules 93 | - clippy 94 | - update heatmap title 95 | - update template title 96 | - clean up chart styling 97 | - open repot in web browser when it's finished 98 | - generate simple HTML report 99 | - update chart bg colors 100 | - add tx-gas-used chart, cleanup logs 101 | - add time-to-inclusion chart 102 | - put charts in chart module, add gasUsedPerBlock chart 103 | - DRY filenames for charts in report 104 | - simplify & improve cache file handling in report 105 | - save heatmap to reports dir 106 | - DRY data file paths 107 | - cleanup heatmap margins 108 | - add axis labels 109 | - properly label axes 110 | - add legend title to heatmap 111 | - add color legend to heatmap 112 | - cleanup logs, improve heatmap color 113 | - draw simple heatmap (WIP; needs appropriate labels) 114 | - convert heatmap data into matrix (for plotting later) 115 | - cleanup 116 | - add heatmap builder (WIP; collects data but doesn't render) 117 | - simplify args 118 | - add tx tracing to report 119 | - support multiple run_ids in report command 120 | - simplify report further (remove filename option) 121 | - simplify 'report' command 122 | - factor out duration from get_max_spam_cost 123 | - before spamming, error if acct balance l.t. total spam cost 124 | - add post-setup log 125 | - remove unnecessary vec 126 | - add test for fund_accounts: disallows funding early if sender has insufficient balance 127 | - check funder balance is sufficient to fund all accounts before funding any 128 | - clippy 129 | - remove "signers per pool" from setup 130 | - num_accounts = txs_per_period / agents.len() 131 | - associate RPC_URL with named txs for chain-specific deployments 132 | - improve logs from common errors in spam & setup 133 | - add -r flag to spam; runs report and saves to filename passed by -r 134 | - create, save, and load user seed: ~/.contender/seed 135 | - clippy 136 | - save DB to ~/.contender/ 137 | - save report to ~/.contender/ 138 | - export report to report.csv by default 139 | - cleanup 140 | - cleanup 141 | - support from_pool in create steps 142 | - remove debug log 143 | - clean up logs 144 | - use same default seed for both setup & spam 145 | - (WIP) support from_pool in setup steps; TODO: scale requests by #accts 146 | - cleanup db invocations 147 | - clippy 148 | - move subcommand definitions out of main.rs, into individual mods 149 | - remove timeout, add env var to fill blocks up to a percent 150 | - make clippy happy 151 | - replace Into impl with From 152 | - cleanup doc comments, fix num_txs bug in run db 153 | - add --num-txs to run command 154 | - add termcolor to cli, make prompt orange 155 | - organize db, modify templater return types, prompt user to redeploy on fill-blocks 156 | - read block gas limit from rpc 157 | - spam many txs in fill-block 158 | - rename file 159 | - add 'run' command; runs builtin scenarios 160 | - Merge branch 'main' into add-fmt-clippy-workflows 161 | - remove unnecessary struct member; more dry 162 | - scale EOAs for timed spammer as well 163 | - DRY off 164 | - drop the '2' suffix from new spammers; old ones deleted 165 | - add new timedSpammer using Spammer trait 166 | - add new Spammer trait, replace blockwise spammer 167 | - differentiate seed using pool name, fix account index bug 168 | - cleanup comments & clones 169 | - use RandSeed to generate agent signer keys 170 | - fund pool accounts w/ user account at spam startup 171 | - inject pool signers with generator (TODO: fund them) 172 | - idiomatic workspace structure 173 | -------------------------------------------------------------------------------- /crates/cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "contender_cli" 3 | version = "0.2.1" 4 | edition.workspace = true 5 | rust-version.workspace = true 6 | authors.workspace = true 7 | license.workspace = true 8 | homepage.workspace = true 9 | repository.workspace = true 10 | description = "Contender CLI" 11 | 12 | [[bin]] 13 | name = "contender" 14 | path = "src/main.rs" 15 | 16 | [dependencies] 17 | contender_core = { workspace = true } 18 | contender_sqlite = { workspace = true } 19 | contender_testfile = { workspace = true } 20 | contender_engine_provider = { workspace = true } 21 | contender_report = { workspace = true } 22 | 23 | ansi_term = { workspace = true } 24 | serde = { workspace = true } 25 | tokio = { workspace = true, features = ["rt-multi-thread"] } 26 | tokio-util = { workspace = true } 27 | alloy = { workspace = true, features = [ 28 | "full", 29 | "node-bindings", 30 | "rpc-types-debug", 31 | "rpc-types-trace", 32 | ] } 33 | clap = { workspace = true, features = ["derive"] } 34 | futures.workspace = true 35 | handlebars = { workspace = true } 36 | prometheus = { workspace = true } 37 | rand.workspace = true 38 | serde_json = { workspace = true } 39 | tracing = { workspace = true } 40 | tracing-subscriber = { workspace = true } 41 | webbrowser = { workspace = true } 42 | op-alloy-network = { workspace = true } 43 | async-trait = { workspace = true } 44 | 45 | [dev-dependencies] 46 | tempfile = "3.15.0" 47 | -------------------------------------------------------------------------------- /crates/cli/src/commands/admin.rs: -------------------------------------------------------------------------------- 1 | use crate::util::data_dir; 2 | use alloy::hex; 3 | use clap::Subcommand; 4 | use contender_core::{ 5 | agent_controller::SignerStore, db::DbOps, error::ContenderError, generator::RandSeed, 6 | }; 7 | use tracing::info; 8 | 9 | #[derive(Debug, Subcommand)] 10 | pub enum AdminCommand { 11 | #[command( 12 | name = "accounts", 13 | about = "Print addresses generated by RandSeed for a given from_pool" 14 | )] 15 | Accounts { 16 | /// From pool to generate accounts for 17 | #[arg(short = 'f', long)] 18 | from_pool: String, 19 | 20 | /// Number of signers to generate 21 | #[arg(short = 'n', long, default_value = "10")] 22 | num_signers: usize, 23 | }, 24 | 25 | #[command(name = "latest-run-id", about = "Print the max run id in the DB")] 26 | LatestRunId, 27 | 28 | #[command(name = "seed", about = "Print the contents of ~/.contender/seed")] 29 | Seed, 30 | } 31 | 32 | /// Reads and validates the seed file 33 | fn read_seed_file() -> Result, ContenderError> { 34 | let data_dir = data_dir() 35 | .map_err(|e| ContenderError::GenericError("Failed to get data dir", e.to_string()))?; 36 | let seed_path = format!("{data_dir}/seed"); 37 | let seed_hex = std::fs::read_to_string(&seed_path).map_err(|e| { 38 | ContenderError::AdminError("Failed to read seed file", format!("at {seed_path}: {e}")) 39 | })?; 40 | let decoded = hex::decode(seed_hex.trim()).map_err(|_| { 41 | ContenderError::AdminError("Invalid hex data in seed file", format!("at {seed_path}")) 42 | })?; 43 | if decoded.is_empty() { 44 | return Err(ContenderError::AdminError( 45 | "Empty seed file", 46 | format!("at {seed_path}"), 47 | )); 48 | } 49 | Ok(decoded) 50 | } 51 | 52 | /// Prompts for confirmation before displaying sensitive information 53 | fn confirm_sensitive_operation(_operation: &str) -> Result<(), ContenderError> { 54 | println!("WARNING: This command will display sensitive information."); 55 | println!("This information should not be shared or exposed in CI environments."); 56 | println!("Press Enter to continue or Ctrl+C to cancel..."); 57 | let mut input = String::new(); 58 | std::io::stdin() 59 | .read_line(&mut input) 60 | .map_err(|e| ContenderError::AdminError("Failed to read input", format!("{e}")))?; 61 | Ok(()) 62 | } 63 | 64 | /// Handles the accounts subcommand 65 | fn handle_accounts( 66 | from_pool: String, 67 | num_signers: usize, 68 | ) -> Result<(), Box> { 69 | let seed_bytes = read_seed_file()?; 70 | let seed = RandSeed::seed_from_bytes(&seed_bytes); 71 | print_accounts_for_pool(&from_pool, num_signers, &seed)?; 72 | Ok(()) 73 | } 74 | 75 | /// Prints accounts for a specific pool 76 | fn print_accounts_for_pool( 77 | pool: &str, 78 | num_signers: usize, 79 | seed: &RandSeed, 80 | ) -> Result<(), ContenderError> { 81 | info!("Generating addresses for pool: {}", pool); 82 | let agent = SignerStore::new(num_signers, seed, pool); 83 | for (i, address) in agent.all_addresses().iter().enumerate() { 84 | println!("Signer {i}: {address}"); 85 | } 86 | Ok(()) 87 | } 88 | 89 | /// Handles the seed subcommand 90 | fn handle_seed() -> Result<(), Box> { 91 | confirm_sensitive_operation("displaying seed value")?; 92 | let seed_bytes = read_seed_file()?; 93 | println!("{}", hex::encode(seed_bytes)); 94 | Ok(()) 95 | } 96 | 97 | pub fn handle_admin_command( 98 | command: AdminCommand, 99 | db: impl DbOps, 100 | ) -> Result<(), Box> { 101 | match command { 102 | AdminCommand::Accounts { 103 | from_pool, 104 | num_signers, 105 | } => handle_accounts(from_pool, num_signers), 106 | AdminCommand::LatestRunId => { 107 | let num_runs = db.num_runs()?; 108 | info!("Latest run ID: {num_runs}"); 109 | println!("{num_runs}"); 110 | Ok(()) 111 | } 112 | AdminCommand::Seed => handle_seed(), 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /crates/cli/src/commands/contender_subcommand.rs: -------------------------------------------------------------------------------- 1 | use clap::Subcommand; 2 | use std::path::PathBuf; 3 | 4 | use crate::default_scenarios::BuiltinScenarioCli; 5 | 6 | use super::admin::AdminCommand; 7 | use super::setup::SetupCliArgs; 8 | use super::spam::SpamCliArgs; 9 | 10 | #[derive(Debug, Subcommand)] 11 | pub enum ContenderSubcommand { 12 | #[command(name = "admin", about = "Admin commands")] 13 | Admin { 14 | #[command(subcommand)] 15 | command: AdminCommand, 16 | }, 17 | 18 | #[command(name = "db", about = "Database management commands")] 19 | Db { 20 | #[command(subcommand)] 21 | command: DbCommand, 22 | }, 23 | 24 | #[command( 25 | name = "spam", 26 | long_about = "Spam the RPC with tx requests as designated in the given testfile." 27 | )] 28 | Spam { 29 | #[command(flatten)] 30 | args: SpamCliArgs, 31 | 32 | #[command(subcommand, name = "builtin-scenario")] 33 | builtin_scenario_config: Option, 34 | }, 35 | 36 | #[command( 37 | name = "setup", 38 | long_about = "Deploy contracts and run the setup step(s) in the given testfile." 39 | )] 40 | Setup { 41 | #[command(flatten)] 42 | args: SetupCliArgs, 43 | }, 44 | 45 | #[command( 46 | name = "report", 47 | long_about = "Export chain performance report for a spam run." 48 | )] 49 | Report { 50 | /// The run ID to include in the report. 51 | #[arg( 52 | short = 'i', 53 | long, 54 | long_help = "The first run to include in the report. If not provided, the latest run is used." 55 | )] 56 | last_run_id: Option, 57 | 58 | /// The number of runs preceding `last_run_id` to include in the report. 59 | /// Only runs with rpc_url matching the one in the last run are included. 60 | #[arg( 61 | short, 62 | long, 63 | long_help = "The number of runs preceding `last_run_id` to include in the report. Only runs with a RPC URL matching the last run will be included.", 64 | default_value = "0" 65 | )] 66 | preceding_runs: u64, 67 | }, 68 | } 69 | 70 | #[derive(Debug, Subcommand)] 71 | pub enum DbCommand { 72 | #[command(name = "drop", about = "Delete the database file")] 73 | Drop, 74 | 75 | #[command(name = "reset", about = "Drop and re-initialize the database")] 76 | Reset, 77 | 78 | #[command(name = "export", about = "Save database to a new file")] 79 | Export { 80 | /// Path where to save the database file 81 | #[arg(help = "Path where to save the database file")] 82 | out_path: PathBuf, 83 | }, 84 | 85 | #[command(name = "import", about = "Import database from a file")] 86 | Import { 87 | /// Path to the database file to import 88 | #[arg(help = "Path to the database file to import")] 89 | src_path: PathBuf, 90 | }, 91 | } 92 | -------------------------------------------------------------------------------- /crates/cli/src/commands/db.rs: -------------------------------------------------------------------------------- 1 | use contender_core::{db::DbOps, error::ContenderError, Result}; 2 | use contender_sqlite::SqliteDb; 3 | use std::{fs, path::PathBuf}; 4 | use tracing::info; 5 | 6 | /// Delete the database file 7 | pub async fn drop_db(db_path: &str) -> Result<()> { 8 | // Check if file exists before attempting to remove 9 | if fs::metadata(db_path).is_ok() { 10 | fs::remove_file(db_path).map_err(|e| { 11 | ContenderError::DbError("Failed to delete database file", Some(e.to_string())) 12 | })?; 13 | info!("Database file '{db_path}' has been deleted."); 14 | } else { 15 | info!("Database file '{db_path}' does not exist."); 16 | } 17 | Ok(()) 18 | } 19 | 20 | /// Reset the database by dropping it and recreating tables 21 | pub async fn reset_db(db_path: &str) -> Result<()> { 22 | // Drop the database 23 | drop_db(db_path).await?; 24 | 25 | // create a new empty file at db_path (to avoid errors when creating tables) 26 | let db = SqliteDb::from_file(db_path).expect("failed to open contender DB file"); 27 | 28 | // Recreate tables 29 | db.create_tables()?; 30 | info!("Database has been reset and tables recreated."); 31 | Ok(()) 32 | } 33 | 34 | /// Export the database to a file 35 | pub async fn export_db(src_path: &str, target_path: PathBuf) -> Result<()> { 36 | // Ensure source database exists 37 | if fs::metadata(src_path).is_err() { 38 | return Err(ContenderError::DbError( 39 | "Source database file does not exist", 40 | None, 41 | )); 42 | } 43 | 44 | // Copy the database file to the target location 45 | fs::copy(src_path, &target_path) 46 | .map_err(|e| ContenderError::DbError("Failed to export database", Some(e.to_string())))?; 47 | info!("Database exported to '{}'", target_path.display()); 48 | Ok(()) 49 | } 50 | 51 | /// Import the database from a file 52 | pub async fn import_db(src_path: PathBuf, target_path: &str) -> Result<()> { 53 | // Ensure source file exists 54 | if !src_path.exists() { 55 | return Err(ContenderError::DbError( 56 | "Source database file does not exist", 57 | None, 58 | )); 59 | } 60 | 61 | // If target exists, create a backup 62 | if fs::metadata(target_path).is_ok() { 63 | let backup_path = format!("{target_path}.backup"); 64 | fs::copy(target_path, &backup_path) 65 | .map_err(|e| ContenderError::DbError("Failed to create backup", Some(e.to_string())))?; 66 | info!("Created backup of existing database at '{target_path}.backup'"); 67 | } 68 | 69 | // Copy the source database to the target location 70 | fs::copy(&src_path, target_path) 71 | .map_err(|e| ContenderError::DbError("Failed to import database", Some(e.to_string())))?; 72 | info!("Database imported from '{}'", src_path.display()); 73 | Ok(()) 74 | } 75 | 76 | #[cfg(test)] 77 | mod tests { 78 | use super::*; 79 | use tempfile::TempDir; 80 | 81 | /// Creates a temp directory containing a database file with the given name. 82 | /// 83 | /// Returns the temp directory and the full path to the database file. 84 | fn setup_test_env(name: &str) -> (TempDir, String) { 85 | let temp_dir = TempDir::new().expect("Failed to create temp directory"); 86 | let db_path = temp_dir 87 | .path() 88 | .join(format!("test_{name}.db")) 89 | .to_str() 90 | .unwrap() 91 | .to_string(); 92 | 93 | (temp_dir, db_path) 94 | } 95 | 96 | #[tokio::test] 97 | async fn test_drop_db() { 98 | let (_temp_dir, db_path) = setup_test_env("drop"); 99 | 100 | // Create a dummy file 101 | fs::write(&db_path, "test data").expect("Failed to write test file"); 102 | assert!(fs::metadata(&db_path).is_ok()); 103 | 104 | // Test dropping the database 105 | drop_db(&db_path).await.expect("Failed to drop database"); 106 | assert!(fs::metadata(&db_path).is_err()); 107 | } 108 | 109 | #[tokio::test] 110 | async fn test_reset_db() { 111 | let (_temp_dir, db_path) = setup_test_env("reset"); 112 | 113 | // Create a mock database 114 | fs::write(&db_path, "testing").expect("Failed to write test file"); 115 | 116 | // Test resetting the database 117 | reset_db(&db_path).await.expect("Failed to reset database"); 118 | assert!(fs::metadata(&db_path).is_ok()); // DB file should exist again 119 | } 120 | 121 | #[tokio::test] 122 | async fn test_export_import_db() { 123 | let (temp_dir, db_path) = setup_test_env("export_import"); 124 | 125 | // Create a dummy database file 126 | fs::write(&db_path, "test database content").expect("Failed to write test file"); 127 | 128 | // Test export 129 | let exported_path = temp_dir.path().join("export.db"); 130 | export_db(&db_path, exported_path.clone()) 131 | .await 132 | .expect("Failed to export database"); 133 | assert!(exported_path.exists()); 134 | 135 | // Test import 136 | fs::remove_file(&db_path).expect("Failed to remove original db"); 137 | import_db(exported_path, &db_path) 138 | .await 139 | .expect("Failed to import database"); 140 | assert!(fs::metadata(&db_path).is_ok()); 141 | 142 | // Verify content 143 | let content = fs::read_to_string(&db_path).expect("Failed to read imported db"); 144 | assert_eq!(content, "test database content"); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /crates/cli/src/commands/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod admin; 2 | pub mod common; 3 | mod contender_subcommand; 4 | pub mod db; 5 | mod setup; 6 | mod spam; 7 | mod spamd; 8 | 9 | use clap::Parser; 10 | 11 | pub use contender_subcommand::{ContenderSubcommand, DbCommand}; 12 | pub use setup::{setup, SetupCliArgs, SetupCommandArgs}; 13 | pub use spam::{spam, EngineArgs, SpamCliArgs, SpamCommandArgs, SpamScenario}; 14 | pub use spamd::spamd; 15 | 16 | #[derive(Parser, Debug)] 17 | #[command( 18 | name = "contender", 19 | version, 20 | author = "Flashbots", 21 | about = "A flexible JSON-RPC spammer for EVM chains." 22 | )] 23 | pub struct ContenderCli { 24 | #[command(subcommand)] 25 | pub command: ContenderSubcommand, 26 | } 27 | 28 | impl ContenderCli { 29 | pub fn parse_args() -> Self { 30 | Self::parse() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /crates/cli/src/commands/spamd.rs: -------------------------------------------------------------------------------- 1 | use super::SpamCommandArgs; 2 | use crate::{ 3 | commands::{self}, 4 | util::data_dir, 5 | }; 6 | use contender_core::{db::DbOps, error::ContenderError}; 7 | use std::{ 8 | ops::Deref, 9 | sync::{ 10 | atomic::{AtomicBool, Ordering}, 11 | Arc, 12 | }, 13 | }; 14 | use tracing::{error, info, warn}; 15 | 16 | /// Runs spam in a loop, potentially executing multiple spam runs. 17 | /// 18 | /// If `limit_loops` is `None`, it will run indefinitely. 19 | /// 20 | /// If `limit_loops` is `Some(n)`, it will run `n` times. 21 | /// 22 | /// If `gen_report` is `true`, it will generate a report at the end. 23 | pub async fn spamd( 24 | db: &(impl DbOps + Clone + Send + Sync + 'static), 25 | args: SpamCommandArgs, 26 | gen_report: bool, 27 | limit_loops: Option, 28 | ) -> Result<(), ContenderError> { 29 | let is_done = Arc::new(AtomicBool::new(false)); 30 | let mut scenario = args.init_scenario(db).await?; 31 | 32 | // collects run IDs from the spam command 33 | let mut run_ids = vec![]; 34 | 35 | // if CTRL-C signal is received, set `is_done` to true 36 | { 37 | let is_done = is_done.clone(); 38 | tokio::task::spawn(async move { 39 | tokio::signal::ctrl_c() 40 | .await 41 | .expect("Failed to listen for CTRL-C"); 42 | info!( 43 | "CTRL-C received. Spam daemon will shut down as soon as current batch finishes..." 44 | ); 45 | is_done.store(true, Ordering::SeqCst); 46 | }); 47 | } 48 | 49 | // runs spam command in a loop 50 | let mut i = 0; 51 | loop { 52 | let mut do_finish = false; 53 | if let Some(loops) = &limit_loops { 54 | if i >= *loops { 55 | do_finish = true; 56 | } 57 | i += 1; 58 | } 59 | if is_done.load(Ordering::SeqCst) { 60 | do_finish = true; 61 | } 62 | if do_finish { 63 | info!("Spam loop finished."); 64 | break; 65 | } 66 | 67 | let db = db.clone(); 68 | let spam_res = commands::spam(&db, &args, &mut scenario).await; 69 | if let Err(e) = spam_res { 70 | error!("spam run failed: {e:?}"); 71 | break; 72 | } else { 73 | let run_id = spam_res.expect("spam"); 74 | if let Some(run_id) = run_id { 75 | run_ids.push(run_id); 76 | } 77 | } 78 | } 79 | 80 | // generate a report if requested; in closure for tokio::select to handle CTRL-C 81 | let run_report = || async move { 82 | if gen_report { 83 | if run_ids.is_empty() { 84 | warn!("No runs found, exiting."); 85 | return Ok::<_, ContenderError>(()); 86 | } 87 | let first_run_id = run_ids.iter().min().expect("no run IDs found"); 88 | let last_run_id = *run_ids.iter().max().expect("no run IDs found"); 89 | contender_report::command::report( 90 | Some(last_run_id), 91 | last_run_id - first_run_id, 92 | db, 93 | &data_dir() 94 | .map_err(|e| ContenderError::with_err(e.deref(), "failed to load data dir"))?, 95 | ) 96 | .await 97 | .map_err(|e| { 98 | ContenderError::GenericError("failed to generate report", e.to_string()) 99 | })?; 100 | } 101 | Ok(()) 102 | }; 103 | 104 | tokio::select! { 105 | _ = run_report() => {}, 106 | _ = tokio::signal::ctrl_c() => { 107 | info!("CTRL-C received, cancelling report..."); 108 | } 109 | } 110 | 111 | Ok(()) 112 | } 113 | -------------------------------------------------------------------------------- /crates/cli/src/default_scenarios/builtin.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{Display, Formatter}; 2 | 3 | use clap::Subcommand; 4 | use contender_core::generator::types::AnyProvider; 5 | use contender_testfile::TestConfig; 6 | 7 | use crate::commands::common::SendSpamCliArgs; 8 | 9 | use super::fill_block::{fill_block, fill_block_config, FillBlockArgs, FillBlockCliArgs}; 10 | 11 | #[derive(Subcommand, Debug)] 12 | /// User-facing subcommands for builtin scenarios. 13 | pub enum BuiltinScenarioCli { 14 | /// Fill blocks with simple gas-consuming transactions. 15 | FillBlock(FillBlockCliArgs), 16 | } 17 | 18 | #[derive(Clone, Debug)] 19 | pub enum BuiltinScenario { 20 | FillBlock(FillBlockArgs), 21 | } 22 | 23 | impl BuiltinScenarioCli { 24 | pub async fn to_builtin_scenario( 25 | &self, 26 | provider: &AnyProvider, 27 | spam_args: &SendSpamCliArgs, 28 | ) -> Result> { 29 | match self { 30 | BuiltinScenarioCli::FillBlock(args) => fill_block(provider, spam_args, args).await, 31 | } 32 | } 33 | } 34 | 35 | impl Display for BuiltinScenario { 36 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 37 | match self { 38 | BuiltinScenario::FillBlock(_) => { 39 | write!(f, "fill-block",) 40 | } 41 | } 42 | } 43 | } 44 | 45 | impl From for TestConfig { 46 | fn from(scenario: BuiltinScenario) -> Self { 47 | match scenario { 48 | BuiltinScenario::FillBlock(args) => fill_block_config(args), 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /crates/cli/src/default_scenarios/bytecode.rs: -------------------------------------------------------------------------------- 1 | pub const SPAM_ME: &str = include_str!("./contracts/SpamMe.hex"); 2 | -------------------------------------------------------------------------------- /crates/cli/src/default_scenarios/contracts/SpamMe.hex: -------------------------------------------------------------------------------- 1 | 0x6080604052348015600f57600080fd5b506105f98061001f6000396000f3fe60806040526004361061004a5760003560e01c806369f86ec81461004f5780639402c00414610066578063a329e8de14610086578063c5eeaf17146100a6578063fb0e722b146100ae575b600080fd5b34801561005b57600080fd5b506100646100d9565b005b34801561007257600080fd5b50610064610081366004610284565b6100e4565b34801561009257600080fd5b506100646100a136600461033d565b610119565b6100646101b1565b3480156100ba57600080fd5b506100c36101e0565b6040516100d0919061037a565b60405180910390f35b5b60325a116100da57565b6000816040516020016100f89291906103e7565b6040516020818303038152906040526000908161011591906104bb565b5050565b6000811161016d5760405162461bcd60e51b815260206004820152601a60248201527f476173206d7573742062652067726561746572207468616e2030000000000000604482015260640160405180910390fd5b6000609561017d610a288461057a565b61018791906105a1565b905080600003610195575060015b60005b818110156101ac5760008055600101610198565b505050565b60405141903480156108fc02916000818181858888f193505050501580156101dd573d6000803e3d6000fd5b50565b600080546101ed906103ad565b80601f0160208091040260200160405190810160405280929190818152602001828054610219906103ad565b80156102665780601f1061023b57610100808354040283529160200191610266565b820191906000526020600020905b81548152906001019060200180831161024957829003601f168201915b505050505081565b634e487b7160e01b600052604160045260246000fd5b60006020828403121561029657600080fd5b813567ffffffffffffffff8111156102ad57600080fd5b8201601f810184136102be57600080fd5b803567ffffffffffffffff8111156102d8576102d861026e565b604051601f8201601f19908116603f0116810167ffffffffffffffff811182821017156103075761030761026e565b60405281815282820160200186101561031f57600080fd5b81602084016020830137600091810160200191909152949350505050565b60006020828403121561034f57600080fd5b5035919050565b60005b83811015610371578181015183820152602001610359565b50506000910152565b6020815260008251806020840152610399816040850160208701610356565b601f01601f19169190910160400192915050565b600181811c908216806103c157607f821691505b6020821081036103e157634e487b7160e01b600052602260045260246000fd5b50919050565b60008084546103f5816103ad565b60018216801561040c576001811461042157610451565b60ff1983168652811515820286019350610451565b87600052602060002060005b838110156104495781548882015260019091019060200161042d565b505081860193505b5050508351610464818360208801610356565b01949350505050565b601f8211156101ac57806000526020600020601f840160051c810160208510156104945750805b601f840160051c820191505b818110156104b457600081556001016104a0565b5050505050565b815167ffffffffffffffff8111156104d5576104d561026e565b6104e9816104e384546103ad565b8461046d565b6020601f82116001811461051d57600083156105055750848201515b600019600385901b1c1916600184901b1784556104b4565b600084815260208120601f198516915b8281101561054d578785015182556020948501946001909201910161052d565b508482101561056b5786840151600019600387901b60f8161c191681555b50505050600190811b01905550565b8181038181111561059b57634e487b7160e01b600052601160045260246000fd5b92915050565b6000826105be57634e487b7160e01b600052601260045260246000fd5b50049056fea264697066735822122045a1a87948aab5d390113cacf93d9eb435038ea2c95e18140c4d0e3e2604afca64736f6c634300081b0033 -------------------------------------------------------------------------------- /crates/cli/src/default_scenarios/fill_block.rs: -------------------------------------------------------------------------------- 1 | use super::{bytecode, BuiltinScenario}; 2 | use crate::commands::common::SendSpamCliArgs; 3 | use alloy::providers::Provider; 4 | use clap::{arg, Parser}; 5 | use contender_core::generator::types::{ 6 | AnyProvider, CreateDefinition, FunctionCallDefinition, SpamRequest, 7 | }; 8 | use contender_testfile::TestConfig; 9 | use tracing::{info, warn}; 10 | 11 | #[derive(Parser, Clone, Debug)] 12 | /// Taken from the CLI, this is used to fill a block with transactions. 13 | pub struct FillBlockCliArgs { 14 | #[arg(short = 'g', long, long_help = "Override gas used per block. By default, the block limit is used.", visible_aliases = ["gas"])] 15 | pub max_gas_per_block: Option, 16 | } 17 | 18 | #[derive(Clone, Debug)] 19 | /// Full arguments for the fill-block scenario. 20 | pub struct FillBlockArgs { 21 | pub max_gas_per_block: u64, 22 | pub num_txs: u64, 23 | } 24 | 25 | pub async fn fill_block( 26 | provider: &AnyProvider, 27 | spam_args: &SendSpamCliArgs, 28 | args: &FillBlockCliArgs, 29 | ) -> Result> { 30 | let SendSpamCliArgs { 31 | txs_per_block, 32 | txs_per_second, 33 | .. 34 | } = spam_args.to_owned(); 35 | 36 | // determine gas limit 37 | let gas_limit = if let Some(max_gas) = args.max_gas_per_block { 38 | max_gas 39 | } else { 40 | let block_gas_limit = provider 41 | .get_block_by_number(alloy::eips::BlockNumberOrTag::Latest) 42 | .await? 43 | .map(|b| b.header.gas_limit); 44 | if block_gas_limit.is_none() { 45 | warn!("Could not get block gas limit from provider, using default 30M"); 46 | } 47 | block_gas_limit.unwrap_or(30_000_000) 48 | }; 49 | 50 | let num_txs = txs_per_block.unwrap_or(txs_per_second.unwrap_or_default()); 51 | let gas_per_tx = gas_limit / num_txs; 52 | 53 | info!("Attempting to fill blocks with {gas_limit} gas; sending {num_txs} txs, each with gas limit {gas_per_tx}."); 54 | Ok(BuiltinScenario::FillBlock(FillBlockArgs { 55 | max_gas_per_block: gas_limit, 56 | num_txs, 57 | })) 58 | } 59 | 60 | pub fn fill_block_config(args: FillBlockArgs) -> TestConfig { 61 | let FillBlockArgs { 62 | max_gas_per_block, 63 | num_txs, 64 | } = args; 65 | let gas_per_tx = max_gas_per_block / num_txs; 66 | let spam_txs = (0..num_txs) 67 | .map(|_| { 68 | SpamRequest::Tx(FunctionCallDefinition { 69 | to: "{SpamMe5}".to_owned(), 70 | from: None, 71 | signature: "consumeGas()".to_owned(), 72 | from_pool: Some("spammers".to_owned()), 73 | args: None, 74 | value: None, 75 | fuzz: None, 76 | kind: Some("fill-block".to_owned()), 77 | gas_limit: Some(gas_per_tx), 78 | }) 79 | }) 80 | .collect::>(); 81 | 82 | TestConfig { 83 | env: None, 84 | create: Some(vec![CreateDefinition { 85 | name: "SpamMe5".to_owned(), 86 | bytecode: bytecode::SPAM_ME.to_owned(), 87 | from: None, 88 | from_pool: Some("admin".to_owned()), 89 | }]), 90 | setup: None, 91 | spam: Some(spam_txs), 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /crates/cli/src/default_scenarios/mod.rs: -------------------------------------------------------------------------------- 1 | mod builtin; 2 | mod bytecode; 3 | pub mod fill_block; 4 | 5 | pub use builtin::{BuiltinScenario, BuiltinScenarioCli}; 6 | -------------------------------------------------------------------------------- /crates/cli/src/util/mod.rs: -------------------------------------------------------------------------------- 1 | mod utils; 2 | pub use utils::*; 3 | pub mod provider; 4 | -------------------------------------------------------------------------------- /crates/cli/src/util/provider.rs: -------------------------------------------------------------------------------- 1 | //! Contains a wrapper for auth_provider to handle errors in the cli context. 2 | 3 | use async_trait::async_trait; 4 | use contender_engine_provider::{error::AuthProviderError, AdvanceChain, AuthResult}; 5 | use tracing::error; 6 | 7 | pub struct AuthClient { 8 | auth_provider: Box, 9 | } 10 | 11 | impl AuthClient { 12 | pub fn new(auth_provider: Box) -> Self { 13 | Self { auth_provider } 14 | } 15 | } 16 | 17 | #[async_trait] 18 | impl AdvanceChain for AuthClient { 19 | async fn advance_chain(&self, block_time: u64) -> AuthResult<()> { 20 | self.auth_provider 21 | .advance_chain(block_time) 22 | .await 23 | .map_err(|e| { 24 | match e { 25 | AuthProviderError::InternalError(_, _) => { 26 | error!("AuthClient encountered an internal error. Please check contender_engine_provider debug logs for more details."); 27 | } 28 | AuthProviderError::ConnectionFailed(_) => { 29 | error!("Please check the auth provider connection."); 30 | } 31 | AuthProviderError::ExtraDataTooShort => { 32 | error!("You may need to remove the --op flag to target this node."); 33 | } 34 | AuthProviderError::GasLimitRequired => { 35 | error!("You may need to pass the --op flag to target this node."); 36 | } 37 | } 38 | e 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /crates/core/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.2.1](https://github.com/flashbots/contender/releases/tag/contender_core-v0.2.1) - 2025-05-14 11 | 12 | ### Added 13 | 14 | - feat/revert toggle ([#177](https://github.com/flashbots/contender/pull/177)) 15 | 16 | ### Fixed 17 | 18 | - fix ugly casts 19 | - fix warnings 20 | - fix 21 | - fix providers in tests 22 | - fix merge bug in test_scenario 23 | - fix arg replacement index bug in templater 24 | - fix early-to-address parse bug, re-enable bundles in spamBundles scenario 25 | - fix templater '{_sender} not in DB' bug 26 | - fix ms-sec logging in report 27 | - fix broken tests & faulty logic 28 | - fix erroneous log 29 | - fix agent-less generator behavior 30 | - fix account index bug properly 31 | - fix invalid index bug 32 | 33 | ### Other 34 | 35 | - ci publish ([#215](https://github.com/flashbots/contender/pull/215)) 36 | - Feat/reports w runtime params ([#213](https://github.com/flashbots/contender/pull/213)) 37 | - Feat/runtime param help ([#204](https://github.com/flashbots/contender/pull/204)) 38 | - consolidate spamd ([#211](https://github.com/flashbots/contender/pull/211)) 39 | - add debug log for failed provider calls ([#200](https://github.com/flashbots/contender/pull/200)) 40 | - Feature/174 admin command ([#180](https://github.com/flashbots/contender/pull/180)) 41 | - bugfix/tokio task panics ([#187](https://github.com/flashbots/contender/pull/187)) 42 | - Feat/more metrics ([#181](https://github.com/flashbots/contender/pull/181)) 43 | - engine_ calls to advance chain manually ([#165](https://github.com/flashbots/contender/pull/165)) 44 | - quality-of-life fixes ([#178](https://github.com/flashbots/contender/pull/178)) 45 | - gas price adder & priority fee bugfix ([#176](https://github.com/flashbots/contender/pull/176)) 46 | - drop stalled txs ([#175](https://github.com/flashbots/contender/pull/175)) 47 | - bugfixes & code organization ([#173](https://github.com/flashbots/contender/pull/173)) 48 | - upgrade alloy ([#172](https://github.com/flashbots/contender/pull/172)) 49 | - simplify util functions ([#171](https://github.com/flashbots/contender/pull/171)) 50 | - spamd ([#170](https://github.com/flashbots/contender/pull/170)) 51 | - tx observability, DB upgrades ([#167](https://github.com/flashbots/contender/pull/167)) 52 | - simple scenario + code touchups ([#164](https://github.com/flashbots/contender/pull/164)) 53 | - log request id w/ hash when calling sendRawTransaction ([#161](https://github.com/flashbots/contender/pull/161)) 54 | - update slot-conflict scenario's fn params, more verbose logs 55 | - remove redundant param names 56 | - move AgentStore & TestConfig utils into respective impls, fix broken test 57 | - use destructure syntax on TestScenarioParams, fix log verbiage 58 | - accept pre-set gas_limit in setup steps 59 | - use 1 for prio fee 60 | - tighten up setup_cost test 61 | - add estimate test & general cleanup 62 | - improve logs, prevent u256 underflow 63 | - estimate setup cost using anvil 64 | - improve log copypastability 65 | - clippy 66 | - prevent priority-fee error (never higher than gasprice) 67 | - various refactors 68 | - add TxType for scenario txs 69 | - show run_id after terminated spam runs 70 | - Merge branch 'main' into bugfix/ctrl-c-handling 71 | - clippy + cleanup 72 | - add test for gas override 73 | - implement gas_limit override in generator & testfile 74 | - update alloy 75 | - remove unused dep from core 76 | - Merge branch 'main' into dan/add-eth-sendbundle-from-alloy 77 | - Merge branch 'main' into bugfix/spam-funding 78 | - cleanup nits 79 | - prevent divide-by-zero errors before they happen in spammer 80 | - add scenario_name to runs table, use in report 81 | - improve ContenderError::with_err, handle trace failure 82 | - clippy 83 | - remove erroneous parenthesis-removal, replace bad error types in generator::util 84 | - remove erroneous parenthesis-removal, replace bad error types in generator::util 85 | - better debug errors 86 | - better setup failure logs 87 | - flatten struct (tuple) args in fn sig to parse correctly 88 | - before spamming, error if acct balance l.t. total spam cost 89 | - support {_sender} in 'to' address, rename scenarios, use from_pool in spamBundles (prev. spamMe) 90 | - fmt 91 | - Update rand_seed.rs 92 | - fund accounts in blockwise spam test 93 | - remove unnecessary casts 94 | - add test to check number of agent accounts used by spammer 95 | - better error message for missing contract deployments 96 | - associate RPC_URL with named txs for chain-specific deployments 97 | - inject {_sender} with/without 0x prefix depending on whether it's the whole word 98 | - inject {_sender} placeholder with from address 99 | - improve logs from common errors in spam & setup 100 | - add test for agent usage in create steps 101 | - group spam txs by spam step, not account 102 | - support from_pool in create steps 103 | - use eip1559 txs to fund test scenario in tests 104 | - add test for agent signers in setup step 105 | - remove debug log 106 | - use scaled from_pool accounts in setup generator 107 | - (WIP) support from_pool in setup steps; TODO: scale requests by #accts 108 | - clippy 109 | - make CTRL-C handling extra-graceful (2-stage spam termination) 110 | - remove redundant data in gas_limits map key 111 | - clippy 112 | - accurately account gas usage 113 | - make clippy happy 114 | - log gas_used & block_num for each landed tx 115 | - log failed tx hashes 116 | - log gas limit 117 | - intercept CTRL-C to exit gracefully 118 | - don't crash on failed task 119 | - add stress.toml, tweak mempool.toml, remove # from blocknum log 120 | - remove timeout, add env var to fill blocks up to a percent 121 | - organize db, modify templater return types, prompt user to redeploy on fill-blocks 122 | - spam many txs in fill-block 123 | - add 'run' command; runs builtin scenarios 124 | - comment out unused dep (will use soon) 125 | - add default impl for blockwise spammer 126 | - Merge branch 'main' into add-fmt-clippy-workflows 127 | - remove unnecessary struct member; more dry 128 | - remove unused varc 129 | - extend timeout to num_reqs 130 | - scale EOAs for timed spammer as well 131 | - DRY off 132 | - relax timeout, don't crash on error when waiting for callbacks to finish 133 | - cleanup 134 | - drop the '2' suffix from new spammers; old ones deleted 135 | - delete old spammers 136 | - add new timedSpammer using Spammer trait 137 | - add new Spammer trait, replace blockwise spammer 138 | - improve spamMe scenario & blockwise spammer UX 139 | - differentiate seed using pool name, fix account index bug 140 | - cleanup comments & clones 141 | - cleanup logs 142 | - use RandSeed to generate agent signer keys 143 | - fund pool accounts w/ user account at spam startup 144 | - inject pool signers with generator (TODO: fund them) 145 | - db/mod.rs => db.rs 146 | - move bundle_provider to its own crate (should be replaced entirely, anyhow) 147 | - syntax cleanups 148 | - add simple wallet store (unimplemented) 149 | - remove errant panic, improve logs for bad config 150 | - remove unused import 151 | - cleanup, remove unneeded field in example config 152 | - allow tx 'value' field to be fuzzed 153 | - idiomatic workspace structure 154 | -------------------------------------------------------------------------------- /crates/core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "contender_core" 3 | version = "0.2.1" 4 | edition.workspace = true 5 | rust-version.workspace = true 6 | authors.workspace = true 7 | license.workspace = true 8 | homepage.workspace = true 9 | repository.workspace = true 10 | description = "Contender core library" 11 | 12 | [dependencies] 13 | contender_bundle_provider = { workspace = true } 14 | contender_engine_provider = { workspace = true } 15 | 16 | alloy = { workspace = true, features = ["full", "node-bindings"] } 17 | async-trait.workspace = true 18 | eyre = { workspace = true } 19 | futures = { workspace = true } 20 | prometheus = { workspace = true } 21 | rand = { workspace = true } 22 | serde = { workspace = true, features = ["derive"] } 23 | serde_json = { workspace = true } 24 | tokio = { workspace = true, features = ["signal"] } 25 | tokio-util = { workspace = true } 26 | tower = { workspace = true } 27 | tracing = { workspace = true } 28 | tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } 29 | -------------------------------------------------------------------------------- /crates/core/src/agent_controller.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use alloy::{ 4 | primitives::{Address, FixedBytes, U256}, 5 | signers::local::PrivateKeySigner, 6 | }; 7 | 8 | use crate::generator::seeder::{rand_seed::SeedGenerator, SeedValue}; 9 | 10 | pub trait SignerRegistry { 11 | fn get_signer(&self, idx: Index) -> Option<&PrivateKeySigner>; 12 | fn get_address(&self, idx: Index) -> Option
; 13 | } 14 | 15 | pub trait AgentRegistry { 16 | fn get_agent(&self, idx: Index) -> Option<&Address>; 17 | } 18 | 19 | #[derive(Clone, Debug, Default)] 20 | pub struct SignerStore { 21 | pub signers: Vec, 22 | } 23 | 24 | #[derive(Clone, Debug)] 25 | pub struct AgentStore { 26 | agents: HashMap, 27 | } 28 | 29 | impl Default for AgentStore { 30 | fn default() -> Self { 31 | Self::new() 32 | } 33 | } 34 | 35 | impl AgentStore { 36 | pub fn new() -> Self { 37 | AgentStore { 38 | agents: HashMap::new(), 39 | } 40 | } 41 | 42 | pub fn init( 43 | &mut self, 44 | agent_names: &[impl AsRef], 45 | signers_per_agent: usize, 46 | seed: &impl SeedGenerator, 47 | ) { 48 | for agent in agent_names { 49 | if self.has_agent(agent) { 50 | continue; 51 | } 52 | self.add_new_agent(agent, signers_per_agent, seed); 53 | } 54 | } 55 | 56 | pub fn add_agent(&mut self, name: impl AsRef, signers: SignerStore) { 57 | self.agents.insert(name.as_ref().to_owned(), signers); 58 | } 59 | 60 | pub fn add_new_agent( 61 | &mut self, 62 | name: impl AsRef, 63 | num_signers: usize, 64 | rand_seeder: &impl SeedGenerator, 65 | ) { 66 | let signers = SignerStore::new(num_signers, rand_seeder, name.as_ref()); 67 | self.add_agent(name, signers); 68 | } 69 | 70 | pub fn get_agent(&self, name: impl AsRef) -> Option<&SignerStore> { 71 | self.agents.get(name.as_ref()) 72 | } 73 | 74 | pub fn all_agents(&self) -> impl Iterator { 75 | self.agents.iter() 76 | } 77 | 78 | pub fn has_agent(&self, name: impl AsRef) -> bool { 79 | self.agents.contains_key(name.as_ref()) 80 | } 81 | 82 | pub fn remove_agent(&mut self, name: impl AsRef) { 83 | self.agents.remove(name.as_ref()); 84 | } 85 | 86 | pub fn all_signers(&self) -> Vec<&PrivateKeySigner> { 87 | self.agents 88 | .values() 89 | .flat_map(|s| s.signers.iter()) 90 | .collect() 91 | } 92 | 93 | pub fn all_signer_addresses(&self) -> Vec
{ 94 | self.all_signers().iter().map(|s| s.address()).collect() 95 | } 96 | } 97 | 98 | impl SignerRegistry for SignerStore 99 | where 100 | Idx: Ord + Into, 101 | { 102 | fn get_signer(&self, idx: Idx) -> Option<&PrivateKeySigner> { 103 | self.signers.get::(idx.into()) 104 | } 105 | 106 | fn get_address(&self, idx: Idx) -> Option
{ 107 | self.signers.get::(idx.into()).map(|s| s.address()) 108 | } 109 | } 110 | 111 | impl SignerStore { 112 | pub fn new(num_signers: usize, rand_seeder: &S, acct_seed: &str) -> Self { 113 | // add numerical value of acct_seed to given seed 114 | let new_seed = rand_seeder.as_u256() + U256::from_be_slice(acct_seed.as_bytes()); 115 | let rand_seeder = S::seed_from_u256(new_seed); 116 | 117 | // generate random private keys with new seed 118 | let prv_keys = rand_seeder 119 | .seed_values(num_signers, None, None) 120 | .map(|sv| sv.as_bytes().to_vec()) 121 | .collect::>(); 122 | let signers: Vec = prv_keys 123 | .into_iter() 124 | .map(|s| FixedBytes::from_slice(&s)) 125 | .map(|b| PrivateKeySigner::from_bytes(&b).expect("Failed to create random seed signer")) 126 | .collect(); 127 | SignerStore { signers } 128 | } 129 | 130 | pub fn add_signer(&mut self, signer: PrivateKeySigner) { 131 | self.signers.push(signer); 132 | } 133 | 134 | pub fn remove_signer(&mut self, idx: usize) { 135 | self.signers.remove(idx); 136 | } 137 | 138 | pub fn all_addresses(&self) -> Vec
{ 139 | self.signers.iter().map(|s| s.address()).collect() 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /crates/core/src/buckets.rs: -------------------------------------------------------------------------------- 1 | //! This module provides functionality for estimating quantiles from a set of buckets. 2 | //! It includes a `Bucket` struct representing a single bucket with an upper bound and cumulative count, 3 | //! and a `BucketsExt` trait that provides an extension method for estimating quantiles from a vector of buckets. 4 | 5 | #[derive(Debug, Clone)] 6 | pub struct Bucket { 7 | pub upper_bound: f64, 8 | pub cumulative_count: u64, 9 | } 10 | 11 | impl Bucket { 12 | fn new(upper_bound: f64, cumulative_count: u64) -> Self { 13 | Self { 14 | upper_bound, 15 | cumulative_count, 16 | } 17 | } 18 | } 19 | 20 | impl From<(f64, u64)> for Bucket { 21 | fn from((upper_bound, cumulative_count): (f64, u64)) -> Self { 22 | Self::new(upper_bound, cumulative_count) 23 | } 24 | } 25 | 26 | pub trait BucketsExt { 27 | fn estimate_quantile(&self, quantile: f64) -> f64; 28 | } 29 | 30 | impl BucketsExt for Vec { 31 | fn estimate_quantile(&self, quantile: f64) -> f64 { 32 | if self.is_empty() { 33 | return 0.0; 34 | } 35 | 36 | let total = self.last().expect("empty buckets").cumulative_count; 37 | let target = (quantile * total as f64).ceil() as u64; 38 | 39 | for i in 0..self.len() { 40 | if self[i].cumulative_count >= target { 41 | let lower_bound = if i == 0 { 0.0 } else { self[i - 1].upper_bound }; 42 | let lower_count = if i == 0 { 43 | 0 44 | } else { 45 | self[i - 1].cumulative_count 46 | }; 47 | let upper_bound = self[i].upper_bound; 48 | let upper_count = self[i].cumulative_count; 49 | 50 | let range = (upper_count - lower_count).max(1); 51 | let position = (target - lower_count) as f64 / range as f64; 52 | return lower_bound + (upper_bound - lower_bound) * position; 53 | } 54 | } 55 | 56 | self.last().unwrap().upper_bound 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /crates/core/src/db/mock.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeMap; 2 | 3 | use alloy::primitives::{Address, TxHash}; 4 | 5 | use super::{DbOps, NamedTx, RunTx, SpamRunRequest}; 6 | use crate::{buckets::Bucket, Result}; 7 | 8 | pub struct MockDb; 9 | 10 | impl DbOps for MockDb { 11 | fn version(&self) -> u64 { 12 | u64::MAX 13 | } 14 | 15 | fn create_tables(&self) -> Result<()> { 16 | Ok(()) 17 | } 18 | 19 | fn insert_run(&self, _run: &SpamRunRequest) -> Result { 20 | Ok(0) 21 | } 22 | 23 | fn get_run(&self, _run_id: u64) -> Result> { 24 | Ok(None) 25 | } 26 | 27 | fn num_runs(&self) -> Result { 28 | Ok(0) 29 | } 30 | 31 | fn insert_named_txs(&self, _named_txs: &[NamedTx], _rpc_url: &str) -> Result<()> { 32 | Ok(()) 33 | } 34 | 35 | fn get_named_tx(&self, _name: &str, _rpc_url: &str) -> Result> { 36 | Ok(Some(NamedTx::new( 37 | String::default(), 38 | TxHash::default(), 39 | None, 40 | ))) 41 | } 42 | 43 | fn get_named_tx_by_address(&self, address: &Address) -> Result> { 44 | Ok(Some(NamedTx::new( 45 | String::default(), 46 | TxHash::default(), 47 | Some(*address), 48 | ))) 49 | } 50 | 51 | fn get_latency_metrics(&self, _run_id: u64, _method: &str) -> Result> { 52 | Ok(vec![(0.0, 1).into()]) 53 | } 54 | 55 | fn insert_run_txs(&self, _run_id: u64, _run_txs: &[RunTx]) -> Result<()> { 56 | Ok(()) 57 | } 58 | 59 | fn get_run_txs(&self, _run_id: u64) -> Result> { 60 | Ok(vec![]) 61 | } 62 | 63 | fn insert_latency_metrics( 64 | &self, 65 | _run_id: u64, 66 | _latency_metrics: &BTreeMap>, 67 | ) -> Result<()> { 68 | Ok(()) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /crates/core/src/db/mod.rs: -------------------------------------------------------------------------------- 1 | mod mock; 2 | use std::{collections::BTreeMap, fmt::Display}; 3 | 4 | pub use mock::MockDb; 5 | 6 | use crate::{buckets::Bucket, Result}; 7 | use alloy::primitives::{Address, TxHash}; 8 | use serde::Serialize; 9 | 10 | #[derive(Debug, Serialize, Clone)] 11 | pub struct RunTx { 12 | pub tx_hash: TxHash, 13 | #[serde(rename = "start_time")] 14 | pub start_timestamp_secs: u64, 15 | #[serde(rename = "end_time")] 16 | pub end_timestamp_secs: Option, 17 | pub block_number: Option, 18 | pub gas_used: Option, 19 | pub kind: Option, 20 | pub error: Option, 21 | } 22 | 23 | #[derive(Debug, Serialize, Clone)] 24 | pub struct NamedTx { 25 | pub name: String, 26 | pub tx_hash: TxHash, 27 | pub address: Option
, 28 | } 29 | 30 | impl NamedTx { 31 | pub fn new(name: String, tx_hash: TxHash, address: Option
) -> Self { 32 | Self { 33 | name, 34 | tx_hash, 35 | address, 36 | } 37 | } 38 | } 39 | 40 | pub enum SpamDuration { 41 | Seconds(u64), 42 | Blocks(u64), 43 | } 44 | 45 | impl SpamDuration { 46 | pub fn value(&self) -> u64 { 47 | match self { 48 | SpamDuration::Seconds(v) => *v, 49 | SpamDuration::Blocks(v) => *v, 50 | } 51 | } 52 | 53 | pub fn unit(&self) -> &'static str { 54 | match self { 55 | SpamDuration::Seconds(_) => "seconds", 56 | SpamDuration::Blocks(_) => "blocks", 57 | } 58 | } 59 | 60 | pub fn is_seconds(&self) -> bool { 61 | matches!(self, SpamDuration::Seconds(_)) 62 | } 63 | 64 | pub fn is_blocks(&self) -> bool { 65 | matches!(self, SpamDuration::Blocks(_)) 66 | } 67 | } 68 | 69 | impl Display for SpamDuration { 70 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 71 | match self { 72 | SpamDuration::Seconds(v) => write!(f, "{v} seconds"), 73 | SpamDuration::Blocks(v) => write!(f, "{v} blocks"), 74 | } 75 | } 76 | } 77 | 78 | impl From for SpamDuration { 79 | fn from(value: String) -> Self { 80 | let value = value.trim(); 81 | if let Some(stripped) = value.strip_suffix(" seconds") { 82 | if let Ok(seconds) = stripped.trim().parse::() { 83 | return SpamDuration::Seconds(seconds); 84 | } 85 | } else if let Some(stripped) = value.strip_suffix(" blocks") { 86 | if let Ok(blocks) = stripped.trim().parse::() { 87 | return SpamDuration::Blocks(blocks); 88 | } 89 | } 90 | panic!("Invalid format for SpamDuration: {value}"); 91 | } 92 | } 93 | 94 | pub struct SpamRun { 95 | pub id: u64, 96 | pub timestamp: usize, 97 | pub tx_count: usize, 98 | pub scenario_name: String, 99 | pub rpc_url: String, 100 | pub txs_per_duration: u64, 101 | pub duration: SpamDuration, 102 | pub timeout: u64, 103 | } 104 | 105 | pub struct SpamRunRequest { 106 | pub timestamp: usize, 107 | pub tx_count: usize, 108 | pub scenario_name: String, 109 | pub rpc_url: String, 110 | pub txs_per_duration: u64, 111 | pub duration: SpamDuration, 112 | pub timeout: u64, 113 | } 114 | 115 | pub trait DbOps { 116 | fn create_tables(&self) -> Result<()>; 117 | 118 | fn get_latency_metrics(&self, run_id: u64, method: &str) -> Result>; 119 | 120 | fn get_named_tx(&self, name: &str, rpc_url: &str) -> Result>; 121 | 122 | fn get_named_tx_by_address(&self, address: &Address) -> Result>; 123 | 124 | fn get_run(&self, run_id: u64) -> Result>; 125 | 126 | fn get_run_txs(&self, run_id: u64) -> Result>; 127 | 128 | /// Insert a new named tx into the database. Used for named contracts. 129 | fn insert_named_txs(&self, named_txs: &[NamedTx], rpc_url: &str) -> Result<()>; 130 | 131 | /// Insert a new run into the database. Returns run_id. 132 | fn insert_run(&self, run: &SpamRunRequest) -> Result; 133 | 134 | /// Insert txs from a spam run into the database. 135 | fn insert_run_txs(&self, run_id: u64, run_txs: &[RunTx]) -> Result<()>; 136 | 137 | /// Insert latency metrics into the database. 138 | /// 139 | /// `latency_metrics` maps upper_bound latency (in ms) to the number of txs that received a response within that duration. 140 | /// Meant to be used as input to a histogram. 141 | fn insert_latency_metrics( 142 | &self, 143 | run_id: u64, 144 | latency_metrics: &BTreeMap>, 145 | ) -> Result<()>; 146 | 147 | fn num_runs(&self) -> Result; 148 | 149 | fn version(&self) -> u64; 150 | } 151 | -------------------------------------------------------------------------------- /crates/core/src/error.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Deref; 2 | use std::{error::Error, fmt::Display}; 3 | 4 | use alloy::primitives::Address; 5 | use alloy::transports::{RpcError, TransportErrorKind}; 6 | use contender_bundle_provider::error::BundleProviderError; 7 | 8 | pub enum ContenderError { 9 | DbError(&'static str, Option), 10 | SpamError(&'static str, Option), 11 | SetupError(&'static str, Option), 12 | GenericError(&'static str, String), 13 | AdminError(&'static str, String), 14 | InvalidRuntimeParams(RuntimeParamErrorKind), 15 | RpcError(RpcErrorKind, RpcError), 16 | } 17 | 18 | // #[derive(Debug)] 19 | pub enum RpcErrorKind { 20 | TxAlreadyKnown, 21 | InsufficientFunds(Address), 22 | ReplacementTransactionUnderpriced, 23 | GenericSendTxError, 24 | } 25 | 26 | impl RpcErrorKind { 27 | pub fn to_error(self, e: RpcError) -> ContenderError { 28 | ContenderError::RpcError(self, e) 29 | } 30 | } 31 | 32 | #[derive(Debug)] 33 | pub enum RuntimeParamErrorKind { 34 | BuilderUrlRequired, 35 | BuilderUrlInvalid, 36 | BundleTypeInvalid, 37 | } 38 | 39 | impl std::fmt::Debug for RpcErrorKind { 40 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 41 | match self { 42 | RpcErrorKind::TxAlreadyKnown => write!(f, "Transaction already known. You may be using the same seed (or private key) as another spammer."), 43 | RpcErrorKind::InsufficientFunds(address) => write!(f, "Insufficient funds for transaction (from {address})."), 44 | RpcErrorKind::ReplacementTransactionUnderpriced => { 45 | write!(f, "Replacement transaction underpriced. You may have to wait, or replace the currently-pending transactions manually.") 46 | } 47 | RpcErrorKind::GenericSendTxError => write!(f, "Failed to send transaction. This may be due to a network issue or the transaction being invalid."), 48 | } 49 | } 50 | } 51 | 52 | impl Display for RuntimeParamErrorKind { 53 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 54 | match self { 55 | RuntimeParamErrorKind::BuilderUrlRequired => { 56 | write!(f, "builder URL is required") 57 | } 58 | RuntimeParamErrorKind::BuilderUrlInvalid => { 59 | write!(f, "invalid builder URL") 60 | } 61 | RuntimeParamErrorKind::BundleTypeInvalid => { 62 | write!(f, "invalid bundle type") 63 | } 64 | } 65 | } 66 | } 67 | 68 | impl From for ContenderError { 69 | fn from(err: RuntimeParamErrorKind) -> ContenderError { 70 | ContenderError::InvalidRuntimeParams(err) 71 | } 72 | } 73 | 74 | impl From for ContenderError { 75 | fn from(err: BundleProviderError) -> ContenderError { 76 | match err { 77 | BundleProviderError::InvalidUrl => { 78 | ContenderError::InvalidRuntimeParams(RuntimeParamErrorKind::BuilderUrlInvalid) 79 | } 80 | BundleProviderError::SendBundleError(e) => { 81 | if e.to_string() 82 | .contains("bundle must contain exactly one transaction") 83 | { 84 | return RuntimeParamErrorKind::BundleTypeInvalid.into(); 85 | } 86 | ContenderError::with_err(e.deref(), "failed to send bundle") 87 | } 88 | } 89 | } 90 | } 91 | 92 | impl ContenderError { 93 | pub fn with_err(err: impl Error, msg: &'static str) -> Self { 94 | ContenderError::GenericError(msg, format!("{err:?}")) 95 | } 96 | } 97 | 98 | impl std::fmt::Display for ContenderError { 99 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 100 | match self { 101 | ContenderError::AdminError(msg, e) => write!(f, "AdminError: {msg} - {e}"), 102 | ContenderError::DbError(msg, _) => write!(f, "DatabaseError: {msg}"), 103 | ContenderError::GenericError(msg, e) => { 104 | write!(f, "{} {}", msg, e.to_owned()) 105 | } 106 | ContenderError::InvalidRuntimeParams(kind) => { 107 | write!(f, "InvalidRuntimeParams: {kind}") 108 | } 109 | ContenderError::RpcError(kind, e) => { 110 | write!(f, "RpcError: {kind:?}: {e}") 111 | } 112 | ContenderError::SetupError(msg, _) => write!(f, "SetupError: {msg}"), 113 | ContenderError::SpamError(msg, _) => write!(f, "SpamError: {msg}"), 114 | } 115 | } 116 | } 117 | 118 | impl std::fmt::Debug for ContenderError { 119 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 120 | let err = |e: Option| e.unwrap_or_default(); 121 | match self { 122 | ContenderError::AdminError(msg, e) => write!(f, "AdminError: {msg} - {e}"), 123 | ContenderError::DbError(msg, e) => { 124 | write!(f, "DatabaseError: {} {}", msg, err(e.to_owned())) 125 | } 126 | ContenderError::GenericError(msg, e) => write!(f, "{msg} {e}"), 127 | ContenderError::InvalidRuntimeParams(kind) => { 128 | write!(f, "InvalidRuntimeParams: {kind}") 129 | } 130 | ContenderError::RpcError(kind, e) => { 131 | write!(f, "RpcError: {kind:?}: {e:?}") 132 | } 133 | ContenderError::SetupError(msg, e) => { 134 | write!(f, "SetupError: {} {}", msg, err(e.to_owned())) 135 | } 136 | ContenderError::SpamError(msg, e) => { 137 | write!(f, "SpamError: {} {}", msg, err(e.to_owned())) 138 | } 139 | } 140 | } 141 | } 142 | 143 | impl Error for ContenderError {} 144 | -------------------------------------------------------------------------------- /crates/core/src/generator/mod.rs: -------------------------------------------------------------------------------- 1 | mod r#trait; 2 | 3 | /// Defines named tx requests, which are used to store transaction requests with optional names and kinds. 4 | /// Used for tracking transactions in a test scenario. 5 | pub mod named_txs; 6 | 7 | /// Generates values for fuzzed parameters. 8 | /// Contains the Seeder trait and an implementation. 9 | pub mod seeder; 10 | 11 | /// Provides templating for transaction requests, etc. 12 | /// Contains the Templater trait and an implementation. 13 | pub mod templater; 14 | 15 | /// Contains types used by the generator module. 16 | pub mod types; 17 | 18 | /// Utility functions used in the generator module. 19 | pub mod util; 20 | 21 | pub use named_txs::NamedTxRequestBuilder; 22 | pub use r#trait::{Generator, PlanConfig}; 23 | pub use seeder::rand_seed::RandSeed; 24 | pub use types::{CallbackResult, NamedTxRequest, PlanType}; 25 | -------------------------------------------------------------------------------- /crates/core/src/generator/named_txs.rs: -------------------------------------------------------------------------------- 1 | use alloy::rpc::types::TransactionRequest; 2 | 3 | /// Wrapper for [`TransactionRequest`](alloy::rpc::types::TransactionRequest) that includes optional name and kind fields. 4 | #[derive(Clone, Debug)] 5 | pub struct NamedTxRequest { 6 | pub name: Option, 7 | pub kind: Option, 8 | pub tx: TransactionRequest, 9 | } 10 | 11 | /// Syntactical sugar for creating a [`NamedTxRequest`]. 12 | /// 13 | /// This is useful for imperatively assigning optional fields to a tx. 14 | /// It is _not_ useful when you're dynamically assigning these fields (i.e. you have an Option to check first). 15 | /// 16 | /// ### Example: 17 | /// ``` 18 | /// use alloy::rpc::types::TransactionRequest; 19 | /// # use contender_core::generator::NamedTxRequestBuilder; 20 | /// 21 | /// let tx_req = TransactionRequest::default(); 22 | /// let named_tx_req = NamedTxRequestBuilder::new(tx_req) 23 | /// .with_name("unique_tx_name") 24 | /// .with_kind("tx_kind") 25 | /// .build(); 26 | /// assert_eq!(named_tx_req.name, Some("unique_tx_name".to_owned())); 27 | /// assert_eq!(named_tx_req.kind, Some("tx_kind".to_owned())); 28 | /// ``` 29 | pub struct NamedTxRequestBuilder { 30 | name: Option, 31 | kind: Option, 32 | tx: TransactionRequest, 33 | } 34 | 35 | #[derive(Clone, Debug)] 36 | pub enum ExecutionRequest { 37 | Tx(Box), 38 | Bundle(Vec), 39 | } 40 | 41 | impl From for ExecutionRequest { 42 | fn from(tx: NamedTxRequest) -> Self { 43 | Self::Tx(Box::new(tx)) 44 | } 45 | } 46 | 47 | impl From> for ExecutionRequest { 48 | fn from(txs: Vec) -> Self { 49 | Self::Bundle(txs) 50 | } 51 | } 52 | 53 | impl NamedTxRequestBuilder { 54 | pub fn new(tx: TransactionRequest) -> Self { 55 | Self { 56 | name: None, 57 | kind: None, 58 | tx, 59 | } 60 | } 61 | 62 | pub fn with_name(&mut self, name: &str) -> &mut Self { 63 | self.name = Some(name.to_owned()); 64 | self 65 | } 66 | 67 | pub fn with_kind(&mut self, kind: &str) -> &mut Self { 68 | self.kind = Some(kind.to_owned()); 69 | self 70 | } 71 | 72 | pub fn build(&self) -> NamedTxRequest { 73 | NamedTxRequest::new( 74 | self.tx.to_owned(), 75 | self.name.to_owned(), 76 | self.kind.to_owned(), 77 | ) 78 | } 79 | } 80 | 81 | impl NamedTxRequest { 82 | pub fn new(tx: TransactionRequest, name: Option, kind: Option) -> Self { 83 | Self { name, kind, tx } 84 | } 85 | } 86 | 87 | impl From for NamedTxRequest { 88 | fn from(tx: TransactionRequest) -> Self { 89 | Self { 90 | name: None, 91 | kind: None, 92 | tx, 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /crates/core/src/generator/seeder/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod rand_seed; 2 | mod r#trait; 3 | 4 | pub use r#trait::*; 5 | -------------------------------------------------------------------------------- /crates/core/src/generator/seeder/rand_seed.rs: -------------------------------------------------------------------------------- 1 | use super::{SeedValue, Seeder}; 2 | use alloy::primitives::{keccak256, U256}; 3 | use rand::Rng; 4 | 5 | /// Default seed generator, using a random 32-byte seed. 6 | #[derive(Debug, Clone)] 7 | pub struct RandSeed { 8 | seed: [u8; 32], 9 | } 10 | 11 | /// Copies `seed` into `target` and right-pads with `0x01` to 32 bytes. 12 | fn fill_bytes(seed: &[u8], target: &mut [u8; 32]) { 13 | if seed.len() < 32 { 14 | target[0..seed.len()].copy_from_slice(seed); 15 | target[seed.len()..32].fill(0x01); 16 | } else { 17 | target.copy_from_slice(&seed[0..32]); 18 | } 19 | } 20 | 21 | impl RandSeed { 22 | pub fn new() -> Self { 23 | let mut rng = rand::thread_rng(); 24 | let mut seed = [0u8; 32]; 25 | rng.fill(&mut seed); 26 | Self { seed } 27 | } 28 | 29 | /// Interprets `seed` as a byte array. 30 | /// - If `seed` is less than 32 bytes, it is right-padded with 0x01. 31 | /// - If `seed` is more than 32 bytes, only the first 32 bytes are used. 32 | /// - Number types created from these bytes are interpreted as big-endian. 33 | pub fn seed_from_bytes(seed_bytes: &[u8]) -> Self { 34 | let mut seed_arr = [0u8; 32]; 35 | fill_bytes(seed_bytes, &mut seed_arr); 36 | Self { seed: seed_arr } 37 | } 38 | 39 | /// Interprets seed as a number in base 10 or 16. 40 | pub fn seed_from_str(seed: &str) -> Self { 41 | let (radix, seed) = if seed.starts_with("0x") { 42 | (16u64, seed.split_at(2).1) 43 | } else { 44 | (10u64, seed) 45 | }; 46 | let n = 47 | U256::from_str_radix(seed, radix).expect("invalid seed number; must fit in 32 bytes"); 48 | Self::seed_from_u256(n) 49 | } 50 | 51 | /// Interprets seed as a U256. 52 | pub fn seed_from_u256(seed: U256) -> Self { 53 | Self { 54 | seed: seed.to_be_bytes(), 55 | } 56 | } 57 | } 58 | 59 | impl SeedValue for RandSeed { 60 | fn as_bytes(&self) -> &[u8] { 61 | &self.seed 62 | } 63 | 64 | fn as_u64(&self) -> u64 { 65 | let mut seed: [u8; 8] = [0; 8]; 66 | seed.copy_from_slice(&self.seed[24..32]); 67 | u64::from_be_bytes(seed) 68 | } 69 | 70 | fn as_u128(&self) -> u128 { 71 | let mut seed: [u8; 16] = [0; 16]; 72 | seed.copy_from_slice(&self.seed[16..32]); 73 | u128::from_be_bytes(seed) 74 | } 75 | 76 | fn as_u256(&self) -> U256 { 77 | U256::from_be_bytes::<32>(self.seed) 78 | } 79 | } 80 | 81 | impl Seeder for RandSeed { 82 | fn seed_values( 83 | &self, 84 | amount: usize, 85 | min: Option, 86 | max: Option, 87 | ) -> Box> { 88 | let min = min.unwrap_or(U256::ZERO); 89 | let max = max.unwrap_or(U256::MAX); 90 | assert!(min < max, "min must be less than max"); 91 | let vals = (0..amount).map(move |i| { 92 | // generate random-looking value between min and max from seed 93 | let seed_num = self.as_u256() + U256::from(i); 94 | let val = keccak256(seed_num.as_le_slice()); 95 | let val = U256::from_be_bytes(val.0); 96 | let val = val % (max - min) + min; 97 | RandSeed::seed_from_u256(val) 98 | }); 99 | Box::new(vals) 100 | } 101 | 102 | fn seed_from_u256(seed: U256) -> Self { 103 | Self::seed_from_u256(seed) 104 | } 105 | 106 | fn seed_from_bytes(seed: &[u8]) -> Self { 107 | Self::seed_from_bytes(seed) 108 | } 109 | 110 | fn seed_from_str(seed: &str) -> Self { 111 | Self::seed_from_str(seed) 112 | } 113 | } 114 | 115 | pub trait SeedGenerator: Seeder + SeedValue {} 116 | 117 | impl SeedGenerator for T where T: Seeder + SeedValue + Clone + Send + Sync + 'static {} 118 | 119 | impl Default for RandSeed { 120 | fn default() -> Self { 121 | Self::new() 122 | } 123 | } 124 | 125 | #[cfg(test)] 126 | mod tests { 127 | use alloy::hex::ToHexExt; 128 | use tracing::debug; 129 | 130 | use super::U256; 131 | use crate::generator::seeder::{SeedValue, Seeder}; 132 | 133 | #[test] 134 | fn encodes_seed_bytes() { 135 | let mut seed_bytes = [0u8; 32]; 136 | seed_bytes[seed_bytes.len() - 1] = 0x01; 137 | debug!("{}", seed_bytes.encode_hex()); 138 | let seed = super::RandSeed::seed_from_bytes(&seed_bytes); 139 | debug!("{}", seed.as_bytes().encode_hex()); 140 | assert_eq!(seed.as_bytes().len(), 32); 141 | assert_eq!(seed.as_u64(), 1); 142 | assert_eq!(seed.as_u128(), 1); 143 | assert_eq!(seed.as_u256(), U256::from(1)); 144 | } 145 | 146 | #[test] 147 | fn encodes_seed_string() { 148 | let seed = super::RandSeed::seed_from_str("0x01"); 149 | assert_eq!(seed.as_u64(), 1); 150 | assert_eq!(seed.as_u128(), 1); 151 | assert_eq!(seed.as_u256(), U256::from(1)); 152 | } 153 | 154 | #[test] 155 | fn encodes_seed_u256() { 156 | let n = U256::MAX; 157 | let seed = super::RandSeed::seed_from_u256(n); 158 | assert_eq!(seed.as_u256(), n); 159 | } 160 | 161 | #[test] 162 | fn seed_strings_yield_unique_values() { 163 | let seed1 = super::RandSeed::seed_from_str("0x01"); 164 | let seed2 = super::RandSeed::seed_from_str("0x02"); 165 | assert_ne!(seed1.as_u256(), seed2.as_u256()); 166 | 167 | let num_vals = 100; 168 | let seed1_values = seed1.seed_values(num_vals, None, None).collect::>(); 169 | let seed2_values = seed2.seed_values(num_vals, None, None).collect::>(); 170 | assert_eq!(seed1_values.len(), num_vals); 171 | assert_eq!(seed2_values.len(), num_vals); 172 | for i in 0..num_vals { 173 | assert_ne!(seed1_values[i].as_u256(), seed2_values[i].as_u256()); 174 | } 175 | } 176 | 177 | #[test] 178 | fn seed_values_yield_deterministic_values() { 179 | let seed1 = super::RandSeed::seed_from_str("0x01"); 180 | let seed2 = super::RandSeed::seed_from_str("0x01"); 181 | assert_eq!(seed1.as_u256(), seed2.as_u256()); 182 | 183 | let num_vals = 100; 184 | let seed1_values = seed1.seed_values(num_vals, None, None).collect::>(); 185 | let seed2_values = seed2.seed_values(num_vals, None, None).collect::>(); 186 | 187 | for i in 0..num_vals { 188 | assert_eq!(seed1_values[i].as_u256(), seed2_values[i].as_u256()); 189 | } 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /crates/core/src/generator/seeder/trait.rs: -------------------------------------------------------------------------------- 1 | use alloy::primitives::U256; 2 | 3 | pub trait Seeder { 4 | fn seed_values( 5 | &self, 6 | amount: usize, 7 | min: Option, 8 | max: Option, 9 | ) -> Box>; 10 | 11 | fn seed_from_u256(seed: U256) -> Self; 12 | fn seed_from_bytes(seed: &[u8]) -> Self; 13 | fn seed_from_str(seed: &str) -> Self; 14 | } 15 | 16 | pub trait SeedValue { 17 | fn as_bytes(&self) -> &[u8]; 18 | fn as_u64(&self) -> u64; 19 | fn as_u128(&self) -> u128; 20 | fn as_u256(&self) -> U256; 21 | } 22 | -------------------------------------------------------------------------------- /crates/core/src/generator/templater.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | db::DbOps, 3 | error::ContenderError, 4 | generator::{types::FunctionCallDefinition, util::encode_calldata}, 5 | Result, 6 | }; 7 | use alloy::{ 8 | hex::{FromHex, ToHexExt}, 9 | primitives::{Address, Bytes, TxKind, U256}, 10 | rpc::types::TransactionRequest, 11 | }; 12 | use std::collections::HashMap; 13 | 14 | use super::types::{CreateDefinitionStrict, FunctionCallDefinitionStrict}; 15 | 16 | pub trait Templater 17 | where 18 | K: Eq + std::hash::Hash + ToString + std::fmt::Debug + Send + Sync, 19 | { 20 | fn replace_placeholders(&self, input: &str, placeholder_map: &HashMap) -> String; 21 | fn terminator_start(&self, input: &str) -> Option; 22 | fn terminator_end(&self, input: &str) -> Option; 23 | fn copy_end(&self, input: &str, last_end: usize) -> String; 24 | fn num_placeholders(&self, input: &str) -> usize; 25 | fn find_key(&self, input: &str) -> Option<(K, usize)>; 26 | 27 | /// Looks for {placeholders} in `arg` and updates `env` with the values found by querying the DB. 28 | fn find_placeholder_values( 29 | &self, 30 | arg: &str, 31 | placeholder_map: &mut HashMap, 32 | db: &impl DbOps, 33 | rpc_url: &str, 34 | ) -> Result<()> { 35 | // count number of placeholders (by left brace) in arg 36 | let num_template_vals = self.num_placeholders(arg); 37 | let mut last_end = 0; 38 | let mut template_input = arg.to_owned(); 39 | 40 | for _ in 0..num_template_vals { 41 | template_input = self.copy_end(&template_input, last_end); 42 | let (template_key, template_end) = 43 | self.find_key(&template_input) 44 | .ok_or(ContenderError::SpamError( 45 | "failed to find placeholder key", 46 | Some(arg.to_string()), 47 | ))?; 48 | last_end = template_end + 1; 49 | 50 | // ignore {_sender} placeholder; it's handled outside the templater 51 | if template_key.to_string() == "_sender" { 52 | continue; 53 | } 54 | 55 | // skip if value in map, else look up in DB 56 | if placeholder_map.contains_key(&template_key) { 57 | continue; 58 | } 59 | 60 | let template_value = db 61 | .get_named_tx(&template_key.to_string(), rpc_url) 62 | .map_err(|e| { 63 | ContenderError::SpamError( 64 | "Failed to get named tx from DB. There may be an issue with your database.", 65 | Some(format!("value={template_key:?} ({e})")), 66 | ) 67 | })?; 68 | if let Some(template_value) = template_value { 69 | placeholder_map.insert( 70 | template_key, 71 | template_value 72 | .address 73 | .map(|a| a.encode_hex()) 74 | .unwrap_or_default(), 75 | ); 76 | } else { 77 | return Err(ContenderError::SpamError( 78 | "Address for named contract not found in DB. You may need to run setup steps first.", 79 | Some(template_key.to_string()), 80 | )); 81 | } 82 | } 83 | Ok(()) 84 | } 85 | 86 | /// Finds {placeholders} in `fncall` and looks them up in `db`, 87 | /// then inserts the values it finds into `placeholder_map`. 88 | /// NOTE: only finds placeholders in `args` and `to` fields. 89 | fn find_fncall_placeholders( 90 | &self, 91 | fncall: &FunctionCallDefinition, 92 | db: &impl DbOps, 93 | placeholder_map: &mut HashMap, 94 | rpc_url: &str, 95 | ) -> Result<()> { 96 | // find templates in fn args & `to` 97 | let fn_args = fncall.args.to_owned().unwrap_or_default(); 98 | for arg in fn_args.iter() { 99 | self.find_placeholder_values(arg, placeholder_map, db, rpc_url)?; 100 | } 101 | self.find_placeholder_values(&fncall.to, placeholder_map, db, rpc_url)?; 102 | Ok(()) 103 | } 104 | 105 | /// Returns a transaction request for a given function call definition with all 106 | /// {placeholders} filled in using corresponding values from `placeholder_map`. 107 | fn template_function_call( 108 | &self, 109 | funcdef: &FunctionCallDefinitionStrict, 110 | placeholder_map: &HashMap, 111 | ) -> Result { 112 | let mut args = Vec::new(); 113 | 114 | for arg in funcdef.args.iter() { 115 | let val = self.replace_placeholders(arg, placeholder_map); 116 | args.push(val); 117 | } 118 | let input = encode_calldata(&args, &funcdef.signature)?; 119 | let to = self.replace_placeholders(&funcdef.to, placeholder_map); 120 | let to = to 121 | .parse::
() 122 | .map_err(|e| ContenderError::with_err(e, "failed to parse address"))?; 123 | let value = funcdef 124 | .value 125 | .as_ref() 126 | .map(|s| self.replace_placeholders(s, placeholder_map)) 127 | .and_then(|s| s.parse::().ok()); 128 | 129 | Ok(TransactionRequest { 130 | to: Some(TxKind::Call(to)), 131 | input: alloy::rpc::types::TransactionInput::both(input.into()), 132 | from: Some(funcdef.from), 133 | value, 134 | gas: funcdef.gas_limit, 135 | ..Default::default() 136 | }) 137 | } 138 | 139 | fn template_contract_deploy( 140 | &self, 141 | createdef: &CreateDefinitionStrict, 142 | placeholder_map: &HashMap, 143 | ) -> Result { 144 | let full_bytecode = self.replace_placeholders(&createdef.bytecode, placeholder_map); 145 | let tx = alloy::rpc::types::TransactionRequest { 146 | from: Some(createdef.from), 147 | to: Some(alloy::primitives::TxKind::Create), 148 | input: alloy::rpc::types::TransactionInput::both( 149 | Bytes::from_hex(&full_bytecode).expect("invalid bytecode hex"), 150 | ), 151 | ..Default::default() 152 | }; 153 | Ok(tx) 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /crates/core/src/generator/types.rs: -------------------------------------------------------------------------------- 1 | use super::named_txs::ExecutionRequest; 2 | use alloy::{ 3 | network::AnyNetwork, 4 | primitives::{Address, U256}, 5 | providers::DynProvider, 6 | }; 7 | use serde::{Deserialize, Serialize}; 8 | use std::collections::HashMap; 9 | use tokio::task::JoinHandle; 10 | 11 | // -- re-exports 12 | pub use crate::generator::named_txs::NamedTxRequest; 13 | 14 | // -- convenience 15 | pub type AnyProvider = DynProvider; 16 | 17 | // -- core types for test scenarios 18 | 19 | /// User-facing definition of a function call to be executed. 20 | #[derive(Clone, Deserialize, Debug, Serialize)] 21 | pub struct FunctionCallDefinition { 22 | /// Address of the contract to call. 23 | pub to: String, 24 | /// Address of the tx sender. 25 | pub from: Option, 26 | /// Get a `from` address from the pool of signers specified here. 27 | pub from_pool: Option, 28 | /// Name of the function to call. 29 | pub signature: String, 30 | /// Parameters to pass to the function. 31 | pub args: Option>, 32 | /// Value in wei to send with the tx. 33 | pub value: Option, 34 | /// Parameters to fuzz during the test. 35 | pub fuzz: Option>, 36 | /// Optional type of the spam transaction for categorization. 37 | pub kind: Option, 38 | /// Optional gas limit, which will skip gas estimation. This allows reverting txs to be sent. 39 | pub gas_limit: Option, 40 | } 41 | 42 | pub struct FunctionCallDefinitionStrict { 43 | pub to: String, // may be a placeholder, so we can't use Address 44 | pub from: Address, 45 | pub signature: String, 46 | pub args: Vec, 47 | pub value: Option, 48 | pub fuzz: Vec, 49 | pub kind: Option, 50 | pub gas_limit: Option, 51 | } 52 | 53 | /// User-facing definition of a function call to be executed. 54 | #[derive(Clone, Deserialize, Debug, Serialize)] 55 | pub struct BundleCallDefinition { 56 | #[serde(rename = "tx")] 57 | pub txs: Vec, 58 | } 59 | 60 | /// Definition of a spam request template. 61 | /// TestConfig uses this for TOML parsing. 62 | #[derive(Clone, Deserialize, Debug, Serialize)] 63 | pub enum SpamRequest { 64 | #[serde(rename = "tx")] 65 | Tx(FunctionCallDefinition), 66 | #[serde(rename = "bundle")] 67 | Bundle(BundleCallDefinition), 68 | } 69 | 70 | impl SpamRequest { 71 | pub fn is_bundle(&self) -> bool { 72 | matches!(self, SpamRequest::Bundle(_)) 73 | } 74 | } 75 | 76 | #[derive(Clone, Deserialize, Debug, Serialize)] 77 | pub struct CreateDefinition { 78 | /// Bytecode of the contract to deploy. 79 | pub bytecode: String, 80 | /// Name to identify the contract later. 81 | pub name: String, 82 | /// Address of the tx sender. 83 | pub from: Option, 84 | /// Get a `from` address from the pool of signers specified here. 85 | pub from_pool: Option, 86 | } 87 | 88 | pub struct CreateDefinitionStrict { 89 | pub bytecode: String, 90 | pub name: String, 91 | pub from: Address, 92 | } 93 | 94 | #[derive(Clone, Deserialize, Debug, Serialize)] 95 | pub struct FuzzParam { 96 | /// Name of the parameter to fuzz. 97 | pub param: Option, 98 | /// Fuzz the `value` field of the tx (ETH sent with the tx). 99 | pub value: Option, 100 | /// Minimum value fuzzer will use. 101 | pub min: Option, 102 | /// Maximum value fuzzer will use. 103 | pub max: Option, 104 | } 105 | 106 | #[derive(Debug)] 107 | pub struct Plan { 108 | pub env: HashMap, 109 | pub create_steps: Vec, 110 | pub setup_steps: Vec, 111 | pub spam_steps: Vec, 112 | } 113 | 114 | pub type CallbackResult = crate::Result>>; 115 | 116 | /// Defines the type of plan to be executed. 117 | pub enum PlanType CallbackResult> { 118 | /// Run contract deployments, triggering a callback after each tx is processed. 119 | Create(F), 120 | /// Run setup steps, triggering a callback after each tx is processed. 121 | Setup(F), 122 | /// Spam with a number of txs and trigger a callback after each one is processed. 123 | Spam(u64, F), 124 | } 125 | -------------------------------------------------------------------------------- /crates/core/src/generator/util.rs: -------------------------------------------------------------------------------- 1 | use crate::{error::ContenderError, Result}; 2 | use alloy::{ 3 | consensus::TxType, 4 | dyn_abi::{DynSolType, DynSolValue, JsonAbiExt}, 5 | json_abi, 6 | rpc::types::TransactionRequest, 7 | }; 8 | use tracing::info; 9 | 10 | /// Encode the calldata for a function signature given an array of string arguments. 11 | /// 12 | /// ## Example 13 | /// ``` 14 | /// use contender_core::generator::util::encode_calldata; 15 | /// use alloy::hex::ToHexExt; 16 | /// 17 | /// let args = vec!["0x12345678"]; 18 | /// let sig = "set(uint256 x)"; 19 | /// let calldata = encode_calldata(&args, sig).unwrap(); 20 | /// assert_eq!(calldata.encode_hex(), "60fe47b10000000000000000000000000000000000000000000000000000000012345678"); 21 | /// ``` 22 | pub fn encode_calldata(args: &[impl AsRef], sig: &str) -> Result> { 23 | let func = json_abi::Function::parse(sig) 24 | .map_err(|e| ContenderError::with_err(e, "failed to parse function signature"))?; 25 | let values: Vec = args 26 | .iter() 27 | .enumerate() 28 | .map(|(idx, arg)| { 29 | let mut argtype = String::new(); 30 | func.inputs[idx].full_selector_type_raw(&mut argtype); 31 | let r#type = DynSolType::parse(&argtype) 32 | .map_err(|e| ContenderError::with_err(e, "failed to parse function type"))?; 33 | r#type.coerce_str(arg.as_ref()).map_err(|e| { 34 | ContenderError::SpamError( 35 | "failed to coerce arg to DynSolValue", 36 | Some(e.to_string()), 37 | ) 38 | }) 39 | }) 40 | .collect::>()?; 41 | let input = func 42 | .abi_encode_input(&values) 43 | .map_err(|e| ContenderError::with_err(e, "failed to encode function arguments"))?; 44 | Ok(input) 45 | } 46 | 47 | /// Sets eip-specific fields on a `&mut TransactionRequest`. 48 | /// `chain_id` is ignored for Legacy transactions. 49 | pub fn complete_tx_request( 50 | tx_req: &mut TransactionRequest, 51 | tx_type: TxType, 52 | gas_price: u128, 53 | priority_fee: u128, 54 | gas_limit: u64, 55 | chain_id: u64, 56 | ) { 57 | match tx_type { 58 | TxType::Legacy => { 59 | tx_req.gas_price = Some(gas_price + 4_200_000_000); 60 | } 61 | TxType::Eip1559 => { 62 | tx_req.max_priority_fee_per_gas = Some(priority_fee); 63 | tx_req.max_fee_per_gas = Some(gas_price + (gas_price / 5)); 64 | tx_req.chain_id = Some(chain_id); 65 | } 66 | _ => { 67 | info!("Unsupported tx type: {tx_type:?}, defaulting to legacy"); 68 | complete_tx_request( 69 | tx_req, 70 | TxType::Legacy, 71 | gas_price, 72 | priority_fee, 73 | gas_limit, 74 | chain_id, 75 | ); 76 | } 77 | }; 78 | tx_req.gas = Some(gas_limit); 79 | } 80 | 81 | #[cfg(test)] 82 | pub mod test { 83 | use alloy::node_bindings::{Anvil, AnvilInstance}; 84 | 85 | pub fn spawn_anvil() -> AnvilInstance { 86 | Anvil::new().block_time(1).try_spawn().unwrap() 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /crates/core/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod agent_controller; 2 | pub mod buckets; 3 | pub mod db; 4 | pub mod error; 5 | pub mod generator; 6 | pub mod provider; 7 | pub mod spammer; 8 | pub mod test_scenario; 9 | 10 | pub type Result = std::result::Result; 11 | 12 | pub use alloy; 13 | pub use contender_bundle_provider::bundle::BundleType; 14 | pub use tokio::task as tokio_task; 15 | -------------------------------------------------------------------------------- /crates/core/src/provider.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt::Debug, 3 | future::Future, 4 | pin::Pin, 5 | task::{Context, Poll}, 6 | }; 7 | 8 | use alloy::{ 9 | rpc::json_rpc::{RequestPacket, ResponsePacket}, 10 | transports::TransportError, 11 | }; 12 | use eyre::Result; 13 | use prometheus::{HistogramOpts, HistogramVec, Registry}; 14 | use tokio::sync::OnceCell; 15 | use tower::{Layer, Service}; 16 | use tracing::debug; 17 | 18 | pub const RPC_REQUEST_LATENCY_ID: &str = "rpc_request_latency_seconds"; 19 | 20 | /// A layer to be used with `ClientBuilder::layer` that logs request id with tx hash when calling eth_sendRawTransaction. 21 | pub struct LoggingLayer { 22 | latency_histogram: &'static OnceCell, 23 | } 24 | 25 | impl LoggingLayer { 26 | /// Creates a new `LoggingLayer` and initialize metrics. 27 | pub async fn new( 28 | registry: &OnceCell, 29 | latency_histogram: &'static OnceCell, 30 | ) -> Self { 31 | init_metrics(registry, latency_histogram).await; 32 | Self { latency_histogram } 33 | } 34 | } 35 | 36 | // Implement tower::Layer for LoggingLayer. 37 | impl Layer for LoggingLayer { 38 | type Service = LoggingService; 39 | 40 | fn layer(&self, inner: S) -> Self::Service { 41 | LoggingService { 42 | inner, 43 | latency_histogram: self.latency_histogram, 44 | } 45 | } 46 | } 47 | 48 | #[derive(Debug, Clone)] 49 | pub struct LoggingService { 50 | inner: S, 51 | latency_histogram: &'static OnceCell, 52 | } 53 | 54 | impl Service for LoggingService 55 | where 56 | // Constraints on the service. 57 | S: Service, 58 | S::Future: Send + 'static, 59 | S::Response: Send + 'static + Debug, 60 | S::Error: Send + 'static + Debug, 61 | { 62 | type Response = S::Response; 63 | type Error = S::Error; 64 | type Future = Pin> + Send>>; 65 | 66 | fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { 67 | self.inner.poll_ready(cx) 68 | } 69 | 70 | fn call(&mut self, req: RequestPacket) -> Self::Future { 71 | let mut id = 0; 72 | let mut log_req = false; 73 | let mut method = String::new(); 74 | match &req { 75 | RequestPacket::Single(inner_req) => { 76 | method = inner_req.method().to_string(); 77 | id = inner_req.id().as_number().unwrap_or_default(); 78 | if inner_req.method() == "eth_sendRawTransaction" { 79 | log_req = true; 80 | } 81 | } 82 | RequestPacket::Batch(_) => {} 83 | } 84 | 85 | let start_time = tokio::time::Instant::now(); 86 | let fut = self.inner.call(req); 87 | let latency_histogram = self.latency_histogram.get(); 88 | 89 | Box::pin(async move { 90 | let res = fut.await; 91 | if let Ok(res) = &res { 92 | let elapsed = start_time.elapsed().as_secs_f64(); 93 | if let Some(h) = latency_histogram { 94 | h.with_label_values(&[method.as_str()]).observe(elapsed); 95 | } 96 | if log_req { 97 | match res { 98 | ResponsePacket::Single(inner_res) => { 99 | if let Some(payload) = inner_res.payload.as_success() { 100 | debug!("tx delivered. hash: {}, id: {id}", payload.get()); 101 | } 102 | } 103 | ResponsePacket::Batch(_) => {} 104 | } 105 | } 106 | } else if let Err(err) = &res { 107 | debug!("[{method}] RPC Error (id: {id}): {err}"); 108 | } 109 | 110 | res 111 | }) 112 | } 113 | } 114 | 115 | async fn init_metrics(registry: &OnceCell, latency_hist: &OnceCell) { 116 | let reg = Registry::new(); 117 | 118 | let histogram_vec = HistogramVec::new( 119 | HistogramOpts::new(RPC_REQUEST_LATENCY_ID, "Latency of requests in seconds") 120 | .buckets(vec![0.0001, 0.001, 0.01, 0.05, 0.1, 0.25, 0.5]), 121 | &["rpc_method"], 122 | ) 123 | .expect("histogram_vec"); 124 | reg.register(Box::new(histogram_vec.clone())) 125 | .expect("histogram registered"); 126 | 127 | registry.set(reg).unwrap_or(()); 128 | latency_hist.set(histogram_vec).unwrap_or(()); 129 | } 130 | 131 | #[cfg(test)] 132 | pub mod tests { 133 | use alloy::rpc::json_rpc::Id; 134 | use tracing_subscriber::FmtSubscriber; 135 | 136 | use super::*; 137 | 138 | static PROM: OnceCell = OnceCell::const_new(); 139 | static HIST: OnceCell = OnceCell::const_new(); 140 | static TRACING_INIT: std::sync::Once = std::sync::Once::new(); 141 | 142 | #[derive(Clone)] 143 | struct FailingService; 144 | 145 | impl Service for FailingService { 146 | type Response = ResponsePacket; 147 | type Error = TransportError; 148 | type Future = Pin> + Send>>; 149 | 150 | fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { 151 | Poll::Ready(Ok(())) 152 | } 153 | 154 | fn call(&mut self, _req: RequestPacket) -> Self::Future { 155 | let err = TransportError::Transport(alloy::transports::TransportErrorKind::Custom( 156 | "bummer".into(), 157 | )); 158 | Box::pin(async move { Err(err) }) 159 | } 160 | } 161 | 162 | #[tokio::test] 163 | async fn bad_request_logs_error() -> Result<()> { 164 | TRACING_INIT.call_once(|| { 165 | let subscriber = FmtSubscriber::builder() 166 | .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) 167 | .finish(); 168 | tracing::subscriber::set_global_default(subscriber) 169 | .expect("setting default subscriber failed"); 170 | }); 171 | 172 | let layer = LoggingLayer::new(&PROM, &HIST).await; 173 | let mut service = layer.layer(FailingService); 174 | let req = RequestPacket::Single( 175 | alloy::rpc::json_rpc::Request::>::new( 176 | "eth_sendRawTransaction", 177 | Id::Number(1), 178 | vec![], 179 | ) 180 | .serialize() 181 | .unwrap(), 182 | ); 183 | let res = service.call(req).await; 184 | assert!(res.is_err()); 185 | if let Err(TransportError::Transport(err)) = res { 186 | assert_eq!(err.to_string(), "bummer"); 187 | } else { 188 | panic!("Expected a transport error"); 189 | } 190 | 191 | Ok(()) 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /crates/core/src/spammer/blockwise.rs: -------------------------------------------------------------------------------- 1 | use std::pin::Pin; 2 | 3 | use alloy::providers::Provider; 4 | use futures::{Stream, StreamExt}; 5 | use tracing::info; 6 | 7 | use crate::{ 8 | db::DbOps, 9 | error::ContenderError, 10 | generator::{seeder::Seeder, templater::Templater, PlanConfig}, 11 | test_scenario::TestScenario, 12 | }; 13 | 14 | use super::{ 15 | spammer_trait::SpamRunContext, tx_callback::OnBatchSent, OnTxSent, SpamTrigger, Spammer, 16 | }; 17 | 18 | #[derive(Default)] 19 | pub struct BlockwiseSpammer { 20 | context: SpamRunContext, 21 | } 22 | 23 | impl BlockwiseSpammer { 24 | pub fn new() -> Self { 25 | Self { 26 | context: SpamRunContext::new(), 27 | } 28 | } 29 | } 30 | 31 | impl Spammer for BlockwiseSpammer 32 | where 33 | F: OnTxSent + OnBatchSent + Send + Sync + 'static, 34 | D: DbOps + Send + Sync + 'static, 35 | S: Seeder + Send + Sync + Clone, 36 | P: PlanConfig + Templater + Send + Sync + Clone, 37 | { 38 | async fn on_spam( 39 | &self, 40 | scenario: &mut TestScenario, 41 | ) -> crate::Result + Send>>> { 42 | let poller = scenario 43 | .rpc_client 44 | .watch_blocks() 45 | .await 46 | .map_err(|e| ContenderError::with_err(e, "failed to get block stream"))?; 47 | Ok(poller 48 | .into_stream() 49 | .flat_map(futures::stream::iter) 50 | .map(|b| { 51 | info!("new block detected: {b:?}"); 52 | SpamTrigger::BlockHash(b) 53 | }) 54 | .boxed()) 55 | } 56 | 57 | fn context(&self) -> &SpamRunContext { 58 | &self.context 59 | } 60 | } 61 | 62 | #[cfg(test)] 63 | mod tests { 64 | use alloy::{ 65 | consensus::constants::ETH_TO_WEI, 66 | network::AnyNetwork, 67 | primitives::U256, 68 | providers::{DynProvider, ProviderBuilder}, 69 | }; 70 | use contender_bundle_provider::bundle::BundleType; 71 | use tokio::sync::OnceCell; 72 | 73 | use crate::{ 74 | agent_controller::{AgentStore, SignerStore}, 75 | db::MockDb, 76 | generator::util::test::spawn_anvil, 77 | spammer::util::test::{fund_account, get_test_signers, MockCallback}, 78 | test_scenario::{tests::MockConfig, TestScenarioParams}, 79 | }; 80 | use std::collections::HashSet; 81 | use std::sync::Arc; 82 | 83 | use super::*; 84 | 85 | // separate prometheus registry for simulations; anvil doesn't count! 86 | static PROM: OnceCell = OnceCell::const_new(); 87 | static HIST: OnceCell = OnceCell::const_new(); 88 | 89 | #[tokio::test] 90 | async fn watches_blocks_and_spams_them() { 91 | let anvil = spawn_anvil(); 92 | let provider = DynProvider::new( 93 | ProviderBuilder::new() 94 | .network::() 95 | .connect_http(anvil.endpoint_url().to_owned()), 96 | ); 97 | println!("anvil url: {}", anvil.endpoint_url()); 98 | let seed = crate::generator::RandSeed::seed_from_str("444444444444"); 99 | let mut agents = AgentStore::new(); 100 | let txs_per_period = 10u64; 101 | let periods = 3u64; 102 | let tx_type = alloy::consensus::TxType::Legacy; 103 | let num_signers = (txs_per_period / periods) as usize; 104 | agents.add_agent("pool1", SignerStore::new(num_signers, &seed, "eeeeeeee")); 105 | agents.add_agent("pool2", SignerStore::new(num_signers, &seed, "11111111")); 106 | 107 | let user_signers = get_test_signers(); 108 | let mut nonce = provider 109 | .get_transaction_count(user_signers[0].address()) 110 | .await 111 | .unwrap(); 112 | 113 | for (_pool_name, agent) in agents.all_agents() { 114 | for signer in &agent.signers { 115 | let res = fund_account( 116 | &user_signers[0], 117 | signer.address(), 118 | U256::from(ETH_TO_WEI), 119 | &provider, 120 | Some(nonce), 121 | tx_type, 122 | ) 123 | .await 124 | .unwrap(); 125 | println!("funded signer: {res:?}"); 126 | provider.watch_pending_transaction(res).await.unwrap(); 127 | nonce += 1; 128 | } 129 | } 130 | 131 | let mut scenario = TestScenario::new( 132 | MockConfig, 133 | MockDb.into(), 134 | seed, 135 | TestScenarioParams { 136 | rpc_url: anvil.endpoint_url(), 137 | builder_rpc_url: None, 138 | signers: user_signers, 139 | agent_store: agents, 140 | tx_type, 141 | bundle_type: BundleType::default(), 142 | pending_tx_timeout_secs: 12, 143 | extra_msg_handles: None, 144 | }, 145 | None, 146 | (&PROM, &HIST).into(), 147 | ) 148 | .await 149 | .unwrap(); 150 | 151 | let start_block = provider.get_block_number().await.unwrap(); 152 | 153 | let callback_handler = MockCallback; 154 | let spammer = BlockwiseSpammer::new(); 155 | let result = spammer 156 | .spam_rpc( 157 | &mut scenario, 158 | txs_per_period, 159 | periods, 160 | None, 161 | Arc::new(callback_handler), 162 | ) 163 | .await; 164 | assert!(result.is_ok()); 165 | 166 | let mut unique_addresses = HashSet::new(); 167 | let mut n_block = start_block; 168 | let current_block = provider.get_block_number().await.unwrap(); 169 | 170 | while n_block <= current_block { 171 | let receipts = provider.get_block_receipts(n_block.into()).await.unwrap(); 172 | if let Some(receipts) = receipts { 173 | for tx in receipts { 174 | unique_addresses.insert(tx.from); 175 | } 176 | } 177 | n_block += 1; 178 | } 179 | 180 | for addr in unique_addresses.iter() { 181 | println!("unique address: {addr}"); 182 | } 183 | 184 | assert!(unique_addresses.len() >= (txs_per_period / periods) as usize); 185 | assert!(unique_addresses.len() <= txs_per_period as usize); 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /crates/core/src/spammer/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod blockwise; 2 | mod spammer_trait; 3 | pub mod timed; 4 | pub mod tx_actor; 5 | mod tx_callback; 6 | mod types; 7 | pub mod util; 8 | 9 | pub use blockwise::BlockwiseSpammer; 10 | pub use spammer_trait::{SpamRunContext, Spammer}; 11 | pub use timed::TimedSpammer; 12 | pub use tx_callback::{ 13 | LogCallback, NilCallback, OnBatchSent, OnTxSent, RuntimeTxInfo, SpamCallback, 14 | }; 15 | pub use types::{ExecutionPayload, SpamTrigger}; 16 | -------------------------------------------------------------------------------- /crates/core/src/spammer/spammer_trait.rs: -------------------------------------------------------------------------------- 1 | use std::sync::atomic::AtomicBool; 2 | use std::{pin::Pin, sync::Arc}; 3 | 4 | use alloy::providers::Provider; 5 | use contender_engine_provider::DEFAULT_BLOCK_TIME; 6 | use futures::Stream; 7 | use futures::StreamExt; 8 | use tracing::{info, warn}; 9 | 10 | use crate::{ 11 | db::DbOps, 12 | error::ContenderError, 13 | generator::{seeder::Seeder, templater::Templater, types::AnyProvider, PlanConfig}, 14 | test_scenario::TestScenario, 15 | Result, 16 | }; 17 | 18 | use super::tx_callback::OnBatchSent; 19 | use super::SpamTrigger; 20 | use super::{tx_actor::TxActorHandle, OnTxSent}; 21 | 22 | #[derive(Clone)] 23 | pub struct SpamRunContext { 24 | done_sending: Arc, 25 | done_fcu: Arc, 26 | do_quit: tokio_util::sync::CancellationToken, 27 | } 28 | 29 | impl SpamRunContext { 30 | pub fn new() -> Self { 31 | Self::default() 32 | } 33 | } 34 | 35 | impl Default for SpamRunContext { 36 | fn default() -> Self { 37 | Self { 38 | done_sending: Arc::new(AtomicBool::new(false)), 39 | done_fcu: Arc::new(AtomicBool::new(false)), 40 | do_quit: tokio_util::sync::CancellationToken::new(), 41 | } 42 | } 43 | } 44 | 45 | pub trait Spammer 46 | where 47 | F: OnTxSent + OnBatchSent + Send + Sync + 'static, 48 | D: DbOps + Send + Sync + 'static, 49 | S: Seeder + Send + Sync + Clone, 50 | P: PlanConfig + Templater + Send + Sync + Clone, 51 | { 52 | fn get_msg_handler(&self, db: Arc, rpc_client: Arc) -> TxActorHandle { 53 | TxActorHandle::new(12, db.clone(), rpc_client.clone()) 54 | } 55 | 56 | fn context(&self) -> &SpamRunContext; 57 | 58 | fn on_spam( 59 | &self, 60 | scenario: &mut TestScenario, 61 | ) -> impl std::future::Future + Send>>>>; 62 | 63 | fn spam_rpc( 64 | &self, 65 | scenario: &mut TestScenario, 66 | txs_per_period: u64, 67 | num_periods: u64, 68 | run_id: Option, 69 | sent_tx_callback: Arc, 70 | ) -> impl std::future::Future> { 71 | async move { 72 | let is_fcu_done = self.context().done_fcu.clone(); 73 | let is_sending_done = self.context().done_sending.clone(); 74 | let auth_provider = scenario.auth_provider.clone(); 75 | let (error_sender, mut error_receiver) = tokio::sync::mpsc::channel::(1); 76 | // run loop in background to call fcu when spamming is done 77 | let error_sender = Arc::new(error_sender); 78 | { 79 | let error_sender = error_sender.clone(); 80 | tokio::task::spawn(async move { 81 | if let Some(auth_client) = &auth_provider { 82 | loop { 83 | let is_fcu_done = is_fcu_done.load(std::sync::atomic::Ordering::SeqCst); 84 | let is_sending_done = 85 | is_sending_done.load(std::sync::atomic::Ordering::SeqCst); 86 | if is_fcu_done { 87 | info!("FCU is done, stopping block production..."); 88 | break; 89 | } 90 | if is_sending_done { 91 | let res = auth_client.advance_chain(DEFAULT_BLOCK_TIME).await; 92 | let mut err = String::new(); 93 | res.unwrap_or_else(|e| { 94 | err = e.to_string(); 95 | }); 96 | if !err.is_empty() { 97 | error_sender 98 | .send(err) 99 | .await 100 | .expect("failed to send error from task"); 101 | } 102 | } 103 | } 104 | } 105 | }); 106 | } 107 | 108 | let tx_req_chunks = scenario 109 | .get_spam_tx_chunks(txs_per_period, num_periods) 110 | .await?; 111 | let start_block = scenario 112 | .rpc_client 113 | .get_block_number() 114 | .await 115 | .map_err(|e| ContenderError::with_err(e, "failed to get block number"))?; 116 | let mut cursor = self.on_spam(scenario).await?.take(num_periods as usize); 117 | scenario.sync_nonces().await?; 118 | 119 | // calling cancel() on cancel_token should stop all running tasks 120 | // (as long as each task checks for it) 121 | let cancel_token = self.context().do_quit.clone(); 122 | 123 | // run spammer within tokio::select! to allow for graceful shutdown 124 | let spam_finished: bool = tokio::select! { 125 | _ = tokio::signal::ctrl_c() => { 126 | warn!("CTRL-C received, stopping spamming..."); 127 | cancel_token.cancel(); 128 | 129 | false 130 | }, 131 | Some(err) = error_receiver.recv() => { 132 | return Err(ContenderError::SpamError( 133 | "Spammer encountered a critical error.", 134 | Some(err), 135 | )); 136 | } 137 | res = scenario.execute_spammer(&mut cursor, &tx_req_chunks, sent_tx_callback) => { 138 | if res.as_ref().is_err() { 139 | return res; 140 | } 141 | true 142 | } 143 | }; 144 | if !spam_finished { 145 | warn!("Spammer terminated. Press CTRL-C again to stop result collection..."); 146 | } 147 | self.context() 148 | .done_sending 149 | .store(true, std::sync::atomic::Ordering::SeqCst); 150 | 151 | // collect results from cached pending txs 152 | let flush_finished: bool = tokio::select! { 153 | _ = tokio::signal::ctrl_c() => { 154 | warn!("CTRL-C received, stopping result collection..."); 155 | for msg_handle in scenario.msg_handles.values() { 156 | let _ = msg_handle.stop().await; 157 | } 158 | cancel_token.cancel(); 159 | self.context().done_fcu.store(true, std::sync::atomic::Ordering::SeqCst); 160 | false 161 | }, 162 | _ = scenario.flush_tx_cache(start_block, run_id.unwrap_or(0)) => { 163 | true 164 | } 165 | }; 166 | if !flush_finished { 167 | warn!("Result collection terminated. Some pending txs may not have been saved to the database."); 168 | } else { 169 | self.context() 170 | .done_fcu 171 | .store(true, std::sync::atomic::Ordering::SeqCst); 172 | } 173 | 174 | // clear out unconfirmed txs from the cache 175 | let dump_finished: bool = tokio::select! { 176 | _ = tokio::signal::ctrl_c() => { 177 | warn!("CTRL-C received, stopping tx cache dump..."); 178 | cancel_token.cancel(); 179 | false 180 | }, 181 | _ = scenario.dump_tx_cache(run_id.unwrap_or(0)) => { 182 | true 183 | } 184 | }; 185 | if !dump_finished { 186 | warn!("Tx cache dump terminated. Some unconfirmed txs may not have been saved to the database."); 187 | } 188 | 189 | self.context() 190 | .done_fcu 191 | .store(true, std::sync::atomic::Ordering::SeqCst); 192 | 193 | if let Some(run_id) = run_id { 194 | let latency_metrics = scenario.collect_latency_metrics(); 195 | scenario 196 | .db 197 | .insert_latency_metrics(run_id, &latency_metrics)?; 198 | } 199 | 200 | info!( 201 | "done. {}", 202 | run_id.map(|id| format!("run_id: {id}")).unwrap_or_default() 203 | ); 204 | Ok(()) 205 | } 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /crates/core/src/spammer/timed.rs: -------------------------------------------------------------------------------- 1 | use std::pin::Pin; 2 | use std::time::Duration; 3 | 4 | use futures::Stream; 5 | use futures::StreamExt; 6 | 7 | use crate::{ 8 | db::DbOps, 9 | generator::{seeder::Seeder, templater::Templater, PlanConfig}, 10 | test_scenario::TestScenario, 11 | }; 12 | 13 | use super::spammer_trait::SpamRunContext; 14 | use super::tx_callback::OnBatchSent; 15 | use super::{OnTxSent, SpamTrigger, Spammer}; 16 | 17 | pub struct TimedSpammer { 18 | wait_interval: Duration, 19 | context: SpamRunContext, 20 | } 21 | 22 | impl TimedSpammer { 23 | pub fn new(wait_interval: Duration) -> Self { 24 | Self { 25 | wait_interval, 26 | context: SpamRunContext::new(), 27 | } 28 | } 29 | } 30 | 31 | impl Spammer for TimedSpammer 32 | where 33 | F: OnTxSent + OnBatchSent + Send + Sync + 'static, 34 | D: DbOps + Send + Sync + 'static, 35 | S: Seeder + Send + Sync + Clone, 36 | P: PlanConfig + Templater + Send + Sync + Clone, 37 | { 38 | fn on_spam( 39 | &self, 40 | _scenario: &mut TestScenario, 41 | ) -> impl std::future::Future + Send>>>> 42 | { 43 | let interval = self.wait_interval; 44 | async move { 45 | let do_poll = move |tick| async move { 46 | tokio::time::sleep(interval).await; 47 | tick 48 | }; 49 | Ok( 50 | futures::stream::unfold(0, move |t| async move { Some((do_poll(t).await, t + 1)) }) 51 | .map(SpamTrigger::Tick) 52 | .boxed(), 53 | ) 54 | } 55 | } 56 | 57 | fn context(&self) -> &SpamRunContext { 58 | &self.context 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /crates/core/src/spammer/tx_callback.rs: -------------------------------------------------------------------------------- 1 | use super::tx_actor::{CacheTx, TxActorHandle}; 2 | use crate::generator::{types::AnyProvider, NamedTxRequest}; 3 | use alloy::providers::PendingTransactionConfig; 4 | use contender_engine_provider::{AdvanceChain, DEFAULT_BLOCK_TIME}; 5 | use std::{collections::HashMap, sync::Arc}; 6 | use tokio::task::JoinHandle; 7 | use tracing::debug; 8 | 9 | pub trait OnTxSent 10 | where 11 | K: Eq + std::hash::Hash + AsRef, 12 | V: AsRef, 13 | { 14 | fn on_tx_sent( 15 | &self, 16 | tx_response: PendingTransactionConfig, 17 | req: &NamedTxRequest, 18 | extra: RuntimeTxInfo, 19 | tx_handlers: Option>>, 20 | ) -> Option>; 21 | } 22 | 23 | #[derive(Clone, Debug)] 24 | pub struct RuntimeTxInfo { 25 | start_timestamp_ms: u128, 26 | kind: Option, 27 | error: Option, 28 | } 29 | 30 | impl RuntimeTxInfo { 31 | pub fn new(start_timestamp_ms: u128, kind: Option, error: Option) -> Self { 32 | Self { 33 | start_timestamp_ms, 34 | kind, 35 | error, 36 | } 37 | } 38 | 39 | pub fn with_kind(mut self, kind: String) -> Self { 40 | self.kind = Some(kind); 41 | self 42 | } 43 | 44 | pub fn with_error(mut self, error: String) -> Self { 45 | self.error = Some(error); 46 | self 47 | } 48 | 49 | pub fn with_start_timestamp(mut self, start_timestamp_ms: u128) -> Self { 50 | self.start_timestamp_ms = start_timestamp_ms; 51 | self 52 | } 53 | 54 | pub fn start_timestamp_ms(&self) -> u128 { 55 | self.start_timestamp_ms 56 | } 57 | 58 | pub fn kind(&self) -> Option<&String> { 59 | self.kind.as_ref() 60 | } 61 | 62 | pub fn error(&self) -> Option<&String> { 63 | self.error.as_ref() 64 | } 65 | } 66 | 67 | impl Default for RuntimeTxInfo { 68 | fn default() -> Self { 69 | let now = std::time::SystemTime::now() 70 | .duration_since(std::time::UNIX_EPOCH) 71 | .unwrap_or_default() 72 | .as_millis(); 73 | Self { 74 | start_timestamp_ms: now, 75 | kind: None, 76 | error: None, 77 | } 78 | } 79 | } 80 | 81 | pub trait OnBatchSent { 82 | fn on_batch_sent(&self) -> Option>>; 83 | } 84 | 85 | pub trait SpamCallback: OnTxSent + OnBatchSent + Send + Sync {} 86 | 87 | impl SpamCallback for T {} 88 | 89 | #[derive(Clone)] 90 | pub struct NilCallback; 91 | 92 | pub struct LogCallback { 93 | pub rpc_provider: Arc, 94 | pub auth_provider: Option>, 95 | pub send_fcu: bool, 96 | pub cancel_token: tokio_util::sync::CancellationToken, 97 | } 98 | 99 | impl LogCallback { 100 | pub fn new( 101 | rpc_provider: Arc, 102 | auth_provider: Option>, 103 | send_fcu: bool, 104 | cancel_token: tokio_util::sync::CancellationToken, 105 | ) -> Self { 106 | Self { 107 | rpc_provider, 108 | auth_provider, 109 | send_fcu, 110 | cancel_token, 111 | } 112 | } 113 | } 114 | 115 | impl OnTxSent for NilCallback { 116 | fn on_tx_sent( 117 | &self, 118 | _tx_res: PendingTransactionConfig, 119 | _req: &NamedTxRequest, 120 | _extra: RuntimeTxInfo, 121 | _tx_handlers: Option>>, 122 | ) -> Option> { 123 | // do nothing 124 | None 125 | } 126 | } 127 | 128 | impl OnTxSent for LogCallback { 129 | fn on_tx_sent( 130 | &self, 131 | tx_response: PendingTransactionConfig, 132 | _req: &NamedTxRequest, 133 | extra: RuntimeTxInfo, 134 | tx_actors: Option>>, 135 | ) -> Option> { 136 | let cancel_token = self.cancel_token.clone(); 137 | let handle = tokio::task::spawn(async move { 138 | if let Some(tx_actors) = tx_actors { 139 | let tx_actor = tx_actors["default"].clone(); 140 | let tx = CacheTx { 141 | tx_hash: *tx_response.tx_hash(), 142 | start_timestamp_ms: extra.start_timestamp_ms, 143 | kind: extra.kind, 144 | error: extra.error, 145 | }; 146 | tokio::select! { 147 | _ = cancel_token.cancelled() => {} 148 | _ = tx_actor.cache_run_tx(tx) => {} 149 | }; 150 | } 151 | }); 152 | Some(handle) 153 | } 154 | } 155 | 156 | impl OnBatchSent for LogCallback { 157 | fn on_batch_sent(&self) -> Option>> { 158 | debug!("on_batch_sent called"); 159 | if !self.send_fcu { 160 | // maybe do something metrics-related here 161 | return None; 162 | } 163 | if let Some(provider) = &self.auth_provider { 164 | let provider = provider.clone(); 165 | return Some(tokio::task::spawn(async move { 166 | let res = provider.advance_chain(DEFAULT_BLOCK_TIME).await; 167 | res.map_err(|e| e.to_string()) 168 | })); 169 | } 170 | None 171 | } 172 | } 173 | 174 | impl OnBatchSent for NilCallback { 175 | fn on_batch_sent(&self) -> Option>> { 176 | // do nothing 177 | None 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /crates/core/src/spammer/types.rs: -------------------------------------------------------------------------------- 1 | use crate::generator::NamedTxRequest; 2 | use alloy::{consensus::TxEnvelope, primitives::FixedBytes}; 3 | 4 | #[derive(Clone, Debug)] 5 | pub enum ExecutionPayload { 6 | SignedTx(Box, Box), 7 | SignedTxBundle(Vec, Vec), 8 | } 9 | 10 | #[derive(Clone, Copy, Debug)] 11 | pub enum SpamTrigger { 12 | Nil, 13 | BlockNumber(u64), 14 | Tick(u64), 15 | BlockHash(FixedBytes<32>), 16 | } 17 | -------------------------------------------------------------------------------- /crates/core/src/spammer/util.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | pub mod test { 3 | use std::{collections::HashMap, str::FromStr, sync::Arc}; 4 | 5 | use alloy::{ 6 | consensus::TxType, 7 | network::{AnyTxEnvelope, EthereumWallet, TransactionBuilder}, 8 | primitives::{Address, U256}, 9 | providers::{PendingTransactionConfig, Provider}, 10 | rpc::types::TransactionRequest, 11 | signers::local::PrivateKeySigner, 12 | }; 13 | use tokio::task::JoinHandle; 14 | use tracing::{debug, info}; 15 | 16 | use crate::{ 17 | generator::{types::AnyProvider, util::complete_tx_request, NamedTxRequest}, 18 | spammer::{tx_actor::TxActorHandle, tx_callback::OnBatchSent, OnTxSent, RuntimeTxInfo}, 19 | }; 20 | 21 | pub struct MockCallback; 22 | impl OnTxSent for MockCallback { 23 | fn on_tx_sent( 24 | &self, 25 | _tx_res: PendingTransactionConfig, 26 | _req: &NamedTxRequest, 27 | _extra: RuntimeTxInfo, 28 | _tx_handler: Option>>, 29 | ) -> Option> { 30 | info!("MockCallback::on_tx_sent: tx_hash={}", _tx_res.tx_hash()); 31 | None 32 | } 33 | } 34 | 35 | impl OnBatchSent for MockCallback { 36 | fn on_batch_sent(&self) -> Option>> { 37 | info!("MockCallback::on_batch_sent"); 38 | None 39 | } 40 | } 41 | 42 | pub fn get_test_signers() -> Vec { 43 | [ 44 | "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", 45 | "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", 46 | "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a", 47 | ] 48 | .iter() 49 | .map(|s| PrivateKeySigner::from_str(s).unwrap()) 50 | .collect::>() 51 | } 52 | 53 | pub async fn fund_account( 54 | sender: &PrivateKeySigner, 55 | recipient: Address, 56 | amount: U256, 57 | rpc_client: &AnyProvider, 58 | nonce: Option, 59 | tx_type: TxType, 60 | ) -> Result> { 61 | debug!( 62 | "funding account {} with user account {}", 63 | recipient, 64 | sender.address() 65 | ); 66 | 67 | let gas_price = rpc_client.get_gas_price().await?; 68 | let nonce = nonce.unwrap_or(rpc_client.get_transaction_count(sender.address()).await?); 69 | let chain_id = rpc_client.get_chain_id().await?; 70 | let mut tx_req = TransactionRequest { 71 | from: Some(sender.address()), 72 | to: Some(alloy::primitives::TxKind::Call(recipient)), 73 | value: Some(amount), 74 | nonce: Some(nonce), 75 | ..Default::default() 76 | }; 77 | 78 | complete_tx_request( 79 | &mut tx_req, 80 | tx_type, 81 | gas_price, 82 | gas_price / 10, 83 | 21000, 84 | chain_id, 85 | ); 86 | 87 | let eth_wallet = EthereumWallet::from(sender.to_owned()); 88 | let tx = tx_req.build(ð_wallet).await?; 89 | let res = rpc_client 90 | .send_tx_envelope(AnyTxEnvelope::Ethereum(tx)) 91 | .await?; 92 | 93 | Ok(res.into_inner()) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /crates/engine_provider/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.2.1](https://github.com/flashbots/contender/releases/tag/contender_engine_provider-v0.2.1) - 2025-05-14 11 | 12 | ### Other 13 | 14 | - ci publish ([#215](https://github.com/flashbots/contender/pull/215)) 15 | - engine_ calls to advance chain manually ([#165](https://github.com/flashbots/contender/pull/165)) 16 | -------------------------------------------------------------------------------- /crates/engine_provider/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "contender_engine_provider" 3 | version = "0.2.1" 4 | edition.workspace = true 5 | rust-version.workspace = true 6 | authors.workspace = true 7 | license.workspace = true 8 | homepage.workspace = true 9 | repository.workspace = true 10 | description = "Contender engine_ API provider" 11 | 12 | [lib] 13 | name = "contender_engine_provider" 14 | path = "src/lib.rs" 15 | 16 | [dependencies] 17 | alloy = { workspace = true, features = [ 18 | "full", 19 | "node-bindings", 20 | "rpc-types-mev", 21 | "json-rpc", 22 | "provider-engine-api", 23 | ] } 24 | alloy-rpc-types-engine = { workspace = true, features = ["std", "jwt"] } 25 | alloy-json-rpc = { workspace = true } 26 | tracing = { workspace = true } 27 | futures = { workspace = true } 28 | async-trait.workspace = true 29 | tokio = { workspace = true } 30 | thiserror = { workspace = true } 31 | tower = { workspace = true } 32 | eyre = { workspace = true } 33 | 34 | # engine provider 35 | reth-node-api = { workspace = true } 36 | reth-rpc-layer = { workspace = true } 37 | reth-optimism-node = { workspace = true } 38 | reth-optimism-primitives = { workspace = true } 39 | op-alloy-consensus = { workspace = true } 40 | op-alloy-network = { workspace = true } 41 | op-alloy-rpc-types = { workspace = true } 42 | secp256k1 = { version = "0.30" } 43 | -------------------------------------------------------------------------------- /crates/engine_provider/README.md: -------------------------------------------------------------------------------- 1 | # this crate was stolen 2 | 3 | most of the credit goes to: 4 | - [reth-bench](https://github.com/paradigmxyz/reth/tree/main/bin/reth-bench); @rjected and reth contributors. 5 | - [op-rbuilder tester](https://github.com/flashbots/rbuilder/tree/develop/crates/op-rbuilder/src/tester); @ferranbt & rbuilder contributors 6 | -------------------------------------------------------------------------------- /crates/engine_provider/src/error.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use alloy::transports::TransportErrorKind; 4 | use alloy_json_rpc::RpcError; 5 | 6 | #[derive(Debug)] 7 | pub enum AuthProviderError { 8 | InternalError(Option<&'static str>, Box), 9 | ConnectionFailed(Box), 10 | ExtraDataTooShort, 11 | GasLimitRequired, 12 | } 13 | 14 | impl std::error::Error for AuthProviderError {} 15 | 16 | impl Display for AuthProviderError { 17 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 18 | match self { 19 | AuthProviderError::InternalError(m, e) => { 20 | if let Some(m) = m { 21 | write!(f, "internal error ({m}): {e}") 22 | } else { 23 | write!(f, "internal error: {e}") 24 | } 25 | } 26 | AuthProviderError::ConnectionFailed(e) => { 27 | write!(f, "failed to connect to auth provider: {e}") 28 | } 29 | AuthProviderError::ExtraDataTooShort => { 30 | write!(f, "extra_data of genesis block is too short") 31 | } 32 | AuthProviderError::GasLimitRequired => write!(f, "gasLimit parameter is required"), 33 | } 34 | } 35 | } 36 | 37 | fn parse_err_str(error: &str) -> AuthProviderError { 38 | if error.contains(&AuthProviderError::GasLimitRequired.to_string()) { 39 | return AuthProviderError::GasLimitRequired; 40 | } 41 | if error.contains(&AuthProviderError::ExtraDataTooShort.to_string()) { 42 | return AuthProviderError::ExtraDataTooShort; 43 | } 44 | // If the error is not one of the above, we assume it's an internal error 45 | AuthProviderError::InternalError(None, error.into()) 46 | } 47 | 48 | fn parse_err(err: Box) -> AuthProviderError { 49 | let error = err.to_string(); 50 | parse_err_str(&error) 51 | } 52 | 53 | impl From for AuthProviderError { 54 | fn from(err: String) -> Self { 55 | parse_err_str(&err) 56 | } 57 | } 58 | 59 | impl From> for AuthProviderError { 60 | fn from(err: Box) -> Self { 61 | parse_err(err) 62 | } 63 | } 64 | 65 | impl From> for AuthProviderError { 66 | fn from(err: RpcError) -> Self { 67 | parse_err(Box::new(err)) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /crates/engine_provider/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod auth_provider; 2 | mod auth_transport; 3 | pub mod engine; 4 | pub mod error; 5 | mod traits; 6 | mod util; 7 | mod valid_payload; 8 | 9 | pub use auth_provider::{AuthProvider, AuthResult, ProviderExt}; 10 | pub use traits::AdvanceChain; 11 | pub use util::*; 12 | -------------------------------------------------------------------------------- /crates/engine_provider/src/traits.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | 3 | use crate::auth_provider::AuthResult; 4 | 5 | #[async_trait] 6 | pub trait AdvanceChain { 7 | /// Advance the chain by calling `engine_forkchoiceUpdated` (FCU) and `engine_newPayload` methods. 8 | async fn advance_chain(&self, block_time_secs: u64) -> AuthResult<()>; 9 | } 10 | -------------------------------------------------------------------------------- /crates/engine_provider/src/util.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use alloy_rpc_types_engine::JwtSecret; 4 | 5 | pub const DEFAULT_BLOCK_TIME: u64 = 1; 6 | 7 | pub fn read_jwt_file(jwt_secret_file: &PathBuf) -> Result> { 8 | if !jwt_secret_file.is_file() { 9 | return Err(format!( 10 | "JWT secret file not found: {}", 11 | jwt_secret_file.to_string_lossy(), 12 | ) 13 | .into()); 14 | } 15 | let jwt = std::fs::read_to_string(jwt_secret_file)?; 16 | Ok(JwtSecret::from_hex(jwt)?) 17 | } 18 | -------------------------------------------------------------------------------- /crates/report/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /crates/report/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.2.1](https://github.com/flashbots/contender/releases/tag/contender_report-v0.2.1) - 2025-05-14 11 | 12 | ### Added 13 | 14 | - moved `report` from cli to its own crate 15 | - can now be used as a lib by other projects 16 | -------------------------------------------------------------------------------- /crates/report/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "contender_report" 3 | version = "0.2.1" 4 | edition.workspace = true 5 | rust-version.workspace = true 6 | authors.workspace = true 7 | license.workspace = true 8 | homepage.workspace = true 9 | repository.workspace = true 10 | 11 | [dependencies] 12 | contender_core = { workspace = true } 13 | 14 | alloy = { workspace = true } 15 | futures = { workspace = true } 16 | serde = { workspace = true } 17 | tokio = { workspace = true } 18 | tracing = { workspace = true } 19 | serde_json = { workspace = true } 20 | csv = { workspace = true } 21 | chrono = { workspace = true } 22 | handlebars = { workspace = true } 23 | webbrowser = { workspace = true } 24 | regex = { workspace = true } 25 | -------------------------------------------------------------------------------- /crates/report/src/block_trace.rs: -------------------------------------------------------------------------------- 1 | use alloy::network::{AnyRpcBlock, AnyTransactionReceipt}; 2 | use alloy::providers::ext::DebugApi; 3 | use alloy::{ 4 | providers::Provider, 5 | rpc::types::trace::geth::{ 6 | GethDebugBuiltInTracerType, GethDebugTracerConfig, GethDebugTracerType, 7 | GethDebugTracingOptions, GethDefaultTracingOptions, GethTrace, 8 | }, 9 | }; 10 | use contender_core::db::RunTx; 11 | use contender_core::error::ContenderError; 12 | use contender_core::generator::types::AnyProvider; 13 | use serde::{Deserialize, Serialize}; 14 | use std::sync::Arc; 15 | use tracing::{debug, info}; 16 | 17 | #[derive(Clone, Debug, Deserialize, Serialize)] 18 | pub struct TxTraceReceipt { 19 | pub trace: GethTrace, 20 | pub receipt: AnyTransactionReceipt, 21 | } 22 | 23 | impl TxTraceReceipt { 24 | pub fn new(trace: GethTrace, receipt: AnyTransactionReceipt) -> Self { 25 | Self { trace, receipt } 26 | } 27 | } 28 | 29 | pub async fn get_block_data( 30 | txs: &[RunTx], 31 | rpc_client: &AnyProvider, 32 | ) -> Result, Box> { 33 | // filter out txs with no block number 34 | let txs: Vec = txs 35 | .iter() 36 | .filter(|tx| tx.block_number.is_some()) 37 | .cloned() 38 | .collect(); 39 | 40 | // find block range of txs 41 | let (min_block, max_block) = txs.iter().fold((u64::MAX, 0), |(min, max), tx| { 42 | let bn = tx.block_number.expect("tx has no block number"); 43 | (min.min(bn), max.max(bn)) 44 | }); 45 | 46 | // pad block range on each side 47 | let block_padding = 3; 48 | let min_block = min_block.saturating_sub(block_padding); 49 | let max_block = max_block.saturating_add(block_padding); 50 | 51 | let rpc_client = Arc::new(rpc_client.clone()); 52 | 53 | // get block data 54 | let mut all_blocks: Vec = vec![]; 55 | let (sender, mut receiver) = 56 | tokio::sync::mpsc::channel::((max_block - min_block) as usize + 1); 57 | 58 | let mut handles = vec![]; 59 | for block_num in min_block..=max_block { 60 | let rpc_client = rpc_client.clone(); 61 | let sender = sender.clone(); 62 | let handle = tokio::task::spawn(async move { 63 | info!("getting block {block_num}..."); 64 | let block = rpc_client 65 | .get_block_by_number(block_num.into()) 66 | .full() 67 | .await 68 | .expect("failed to get block"); 69 | if let Some(block) = block { 70 | debug!("read block {}", block.header.number); 71 | sender.send(block).await.expect("failed to cache block"); 72 | } 73 | }); 74 | handles.push(handle); 75 | } 76 | futures::future::join_all(handles).await; 77 | receiver.close(); 78 | 79 | while let Some(res) = receiver.recv().await { 80 | all_blocks.push(res); 81 | } 82 | 83 | Ok(all_blocks) 84 | } 85 | 86 | pub async fn get_block_traces( 87 | full_blocks: &[AnyRpcBlock], 88 | rpc_client: &AnyProvider, 89 | ) -> Result, Box> { 90 | // get tx traces for all txs in all_blocks 91 | let mut all_traces = vec![]; 92 | if full_blocks.is_empty() { 93 | return Ok(all_traces); 94 | } 95 | let (sender, mut receiver) = tokio::sync::mpsc::channel::( 96 | full_blocks.iter().map(|b| b.transactions.len()).sum(), 97 | ); 98 | 99 | for block in full_blocks { 100 | let mut tx_tasks = vec![]; 101 | for tx_hash in block.transactions.hashes() { 102 | let rpc_client = rpc_client.clone(); 103 | let sender = sender.clone(); 104 | let task = tokio::task::spawn(async move { 105 | debug!("tracing tx {tx_hash:?}"); 106 | let trace = rpc_client 107 | .debug_trace_transaction( 108 | tx_hash, 109 | GethDebugTracingOptions { 110 | config: GethDefaultTracingOptions::default(), 111 | tracer: Some(GethDebugTracerType::BuiltInTracer( 112 | GethDebugBuiltInTracerType::PreStateTracer, 113 | )), 114 | tracer_config: GethDebugTracerConfig::default(), 115 | timeout: None, 116 | }, 117 | ) 118 | .await 119 | .map_err(|e| { 120 | ContenderError::with_err( 121 | e, 122 | "debug_traceTransaction failed. Make sure geth-style tracing is enabled on your node.", 123 | ) 124 | }).unwrap(); 125 | 126 | // receipt might fail if we target a non-ETH chain 127 | // so if it does fail, we just ignore it 128 | let receipt = rpc_client.get_transaction_receipt(tx_hash).await; 129 | if let Ok(receipt) = receipt { 130 | if let Some(receipt) = receipt { 131 | info!("got receipt for tx {tx_hash:?}"); 132 | sender 133 | .send(TxTraceReceipt::new(trace, receipt)) 134 | .await 135 | .unwrap(); 136 | } else { 137 | info!("no receipt for tx {tx_hash:?}"); 138 | } 139 | } else { 140 | info!("ignored receipt for tx {tx_hash:?} (failed to decode)"); 141 | } 142 | }); 143 | tx_tasks.push(task); 144 | } 145 | info!("waiting for traces from block {}...", block.header.number); 146 | futures::future::join_all(tx_tasks).await; 147 | info!("finished tracing block {}", block.header.number); 148 | } 149 | 150 | receiver.close(); 151 | 152 | while let Some(res) = receiver.recv().await { 153 | debug!("received trace for {}", res.receipt.transaction_hash); 154 | all_traces.push(res); 155 | } 156 | 157 | Ok(all_traces) 158 | } 159 | -------------------------------------------------------------------------------- /crates/report/src/cache.rs: -------------------------------------------------------------------------------- 1 | use alloy::network::AnyRpcBlock; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use super::block_trace::TxTraceReceipt; 5 | 6 | static CACHE_FILENAME: &str = "debug_trace.json"; 7 | 8 | #[derive(Serialize, Deserialize)] 9 | pub struct CacheFile { 10 | pub traces: Vec, 11 | pub blocks: Vec, 12 | pub data_dir: String, 13 | } 14 | 15 | impl CacheFile { 16 | pub fn new(traces: Vec, blocks: Vec, data_dir: &str) -> Self { 17 | Self { 18 | traces, 19 | blocks, 20 | data_dir: data_dir.to_string(), 21 | } 22 | } 23 | 24 | pub fn load(data_dir: &str) -> Result> { 25 | let file = std::fs::File::open(cache_path(data_dir))?; 26 | let cache_data: CacheFile = serde_json::from_reader(file)?; 27 | Ok(cache_data) 28 | } 29 | 30 | pub fn save(&self) -> Result<(), Box> { 31 | let file = std::fs::File::create(cache_path(&self.data_dir))?; 32 | serde_json::to_writer(file, self)?; 33 | Ok(()) 34 | } 35 | } 36 | 37 | /// Returns the fully-qualified path to the cache file. 38 | fn cache_path(data_dir: &str) -> String { 39 | format!("{data_dir}/{CACHE_FILENAME}") 40 | } 41 | -------------------------------------------------------------------------------- /crates/report/src/chart/gas_per_block.rs: -------------------------------------------------------------------------------- 1 | use alloy::network::AnyRpcBlock; 2 | use serde::{Deserialize, Serialize}; 3 | use std::collections::BTreeMap; 4 | 5 | pub struct GasPerBlockChart { 6 | /// Maps `block_num` to `gas_used` 7 | gas_used_per_block: BTreeMap, 8 | } 9 | 10 | #[derive(Clone, Debug, Deserialize, Serialize)] 11 | pub struct GasPerBlockData { 12 | pub blocks: Vec, 13 | pub gas_used: Vec, 14 | pub max_gas_used: u64, 15 | } 16 | 17 | impl GasPerBlockChart { 18 | pub fn new(blocks: &[AnyRpcBlock]) -> Self { 19 | Self { 20 | gas_used_per_block: blocks 21 | .iter() 22 | .map(|block| (block.header.number, block.header.gas_used)) 23 | .collect(), 24 | } 25 | } 26 | 27 | fn block_nums(&self) -> Vec { 28 | self.gas_used_per_block.keys().cloned().collect() 29 | } 30 | 31 | fn gas_values(&self) -> Vec { 32 | self.gas_used_per_block.values().cloned().collect() 33 | } 34 | 35 | pub fn echart_data(&self) -> GasPerBlockData { 36 | GasPerBlockData { 37 | blocks: self.block_nums(), 38 | gas_used: self.gas_values(), 39 | max_gas_used: self 40 | .gas_used_per_block 41 | .values() 42 | .max() 43 | .copied() 44 | .unwrap_or_default(), 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /crates/report/src/chart/heatmap.rs: -------------------------------------------------------------------------------- 1 | use crate::block_trace::TxTraceReceipt; 2 | use alloy::hex::ToHexExt; 3 | use alloy::primitives::FixedBytes; 4 | use contender_core::error::ContenderError; 5 | use serde::{Deserialize, Serialize}; 6 | use std::collections::BTreeMap; 7 | use tracing::warn; 8 | 9 | pub struct HeatMapChart { 10 | updates_per_slot_per_block: BTreeMap, u64>>, 11 | } 12 | 13 | impl TxTraceReceipt { 14 | pub fn copy_slot_access_map( 15 | &self, 16 | updates_per_slot_per_block: &mut BTreeMap, u64>>, 17 | ) -> Result<(), ContenderError> { 18 | let block_num = self 19 | .receipt 20 | .block_number 21 | .ok_or(ContenderError::GenericError( 22 | "Block number not found in receipt.", 23 | "".to_string(), 24 | ))?; 25 | let trace_frame = self 26 | .trace 27 | .to_owned() 28 | .try_into_pre_state_frame() 29 | .map_err(|e| ContenderError::with_err(e, "failed to decode frame (preState mode)"))?; 30 | let account_map = &trace_frame 31 | .as_default() 32 | .ok_or(ContenderError::GenericError( 33 | "failed to decode PreStateMode", 34 | format!("{trace_frame:?}"), 35 | ))? 36 | .0; 37 | 38 | // "for each account in this transaction trace" 39 | for key in account_map.keys() { 40 | let update = account_map 41 | .get(key) 42 | .expect("invalid key; this should never happen"); 43 | // for every storage slot in this frame, increment the count for the slot at this block number 44 | update.storage.iter().for_each(|(slot, _)| { 45 | if let Some(slot_map) = updates_per_slot_per_block.get_mut(&block_num) { 46 | let value = slot_map.get(slot).map(|v| v + 1).unwrap_or(1); 47 | slot_map.insert(*slot, value); 48 | } else { 49 | let mut slot_map = BTreeMap::new(); 50 | slot_map.insert(*slot, 1); 51 | updates_per_slot_per_block.insert(block_num, slot_map); 52 | } 53 | }); 54 | } 55 | Ok(()) 56 | } 57 | } 58 | 59 | #[derive(Clone, Debug, Deserialize, Serialize)] 60 | pub struct HeatmapData { 61 | pub blocks: Vec, 62 | pub slots: Vec, 63 | pub matrix: Vec<[u64; 3]>, 64 | pub max_accesses: u64, 65 | } 66 | 67 | /// Represents data as a mapping of block_num => slot => count. 68 | impl HeatMapChart { 69 | pub fn new(trace_data: &[TxTraceReceipt]) -> Result> { 70 | let mut updates_per_slot_per_block: BTreeMap, u64>> = 71 | Default::default(); 72 | 73 | for t in trace_data { 74 | t.copy_slot_access_map(&mut updates_per_slot_per_block)?; 75 | } 76 | 77 | if updates_per_slot_per_block.is_empty() { 78 | warn!("No trace data was collected. If transactions from the specified run landed, your target node may not support geth-style preState traces"); 79 | } 80 | 81 | Ok(Self { 82 | updates_per_slot_per_block, 83 | }) 84 | } 85 | 86 | fn get_block_numbers(&self) -> Vec { 87 | self.updates_per_slot_per_block.keys().cloned().collect() 88 | } 89 | 90 | fn get_slot_map(&self, block_num: u64) -> Option<&BTreeMap, u64>> { 91 | self.updates_per_slot_per_block.get(&block_num) 92 | } 93 | 94 | pub fn echart_data(&self) -> HeatmapData { 95 | let blocks = self.get_block_numbers(); 96 | let slots = self.get_hex_slots(); 97 | let mut matrix = vec![]; 98 | let mut max_accesses = 0; 99 | for (i, block) in blocks.iter().enumerate() { 100 | for (j, slot) in slots.iter().enumerate() { 101 | let count = self 102 | .get_slot_map(*block) 103 | .and_then(|slot_map| slot_map.get(slot)) 104 | .cloned() 105 | .unwrap_or(0); 106 | if count > max_accesses { 107 | max_accesses = count; 108 | } 109 | matrix.push([i as u64, j as u64, count]); 110 | } 111 | } 112 | HeatmapData { 113 | blocks, 114 | slots: slots.iter().map(|h| h.encode_hex()).collect(), 115 | matrix, 116 | max_accesses, 117 | } 118 | } 119 | 120 | /// returns all slots in the heatmap 121 | fn get_hex_slots(&self) -> Vec> { 122 | let mut slots = self 123 | .updates_per_slot_per_block 124 | .values() 125 | .flat_map(|slot_map| slot_map.keys()) 126 | // filter out duplicates 127 | .collect::>() 128 | .into_iter() 129 | .map(|h| h.to_owned()) 130 | .collect::>(); 131 | slots.sort(); 132 | slots 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /crates/report/src/chart/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod gas_per_block; 2 | pub mod heatmap; 3 | pub mod pending_txs; 4 | pub mod rpc_latency; 5 | pub mod time_to_inclusion; 6 | pub mod tx_gas_used; 7 | -------------------------------------------------------------------------------- /crates/report/src/chart/pending_txs.rs: -------------------------------------------------------------------------------- 1 | use contender_core::db::RunTx; 2 | use serde::{Deserialize, Serialize}; 3 | use std::collections::BTreeMap; 4 | 5 | pub struct PendingTxsChart { 6 | /// Maps timestamp to number of pending txs 7 | pending_txs_per_second: BTreeMap, 8 | } 9 | 10 | #[derive(Clone, Debug, Deserialize, Serialize)] 11 | pub struct PendingTxsData { 12 | pub timestamps: Vec, 13 | pub pending_txs: Vec, 14 | } 15 | 16 | impl PendingTxsChart { 17 | pub fn new(run_txs: &[RunTx]) -> Self { 18 | let mut pending_txs_per_second = BTreeMap::new(); 19 | // get min/max timestamps from run_txs; evaluate min start_timestamp and max end_timestamp 20 | let (min_timestamp, max_timestamp) = 21 | run_txs.iter().fold((u64::MAX, 0), |(min, max), tx| { 22 | let start_timestamp = tx.start_timestamp_secs; 23 | let end_timestamp = tx.end_timestamp_secs.unwrap_or_default(); 24 | (min.min(start_timestamp), max.max(end_timestamp)) 25 | }); 26 | 27 | // find pending txs for each second, with 1s padding 28 | for t in min_timestamp - 1..max_timestamp + 1 { 29 | let pending_txs = run_txs 30 | .iter() 31 | .filter(|tx| { 32 | let start_timestamp = tx.start_timestamp_secs; 33 | let end_timestamp = tx.end_timestamp_secs.unwrap_or(u64::MAX); 34 | start_timestamp <= t && t < end_timestamp 35 | }) 36 | .count() as u64; 37 | pending_txs_per_second.insert(t, pending_txs); 38 | } 39 | 40 | Self { 41 | pending_txs_per_second, 42 | } 43 | } 44 | 45 | pub fn echart_data(&self) -> PendingTxsData { 46 | let mut timestamps = vec![]; 47 | let mut pending_txs = vec![]; 48 | 49 | for (timestamp, count) in &self.pending_txs_per_second { 50 | timestamps.push(*timestamp); 51 | pending_txs.push(*count); 52 | } 53 | 54 | PendingTxsData { 55 | timestamps, 56 | pending_txs, 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /crates/report/src/chart/rpc_latency.rs: -------------------------------------------------------------------------------- 1 | use contender_core::buckets::{Bucket, BucketsExt}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | pub struct LatencyChart { 5 | buckets: Vec, 6 | } 7 | 8 | #[derive(Debug, Clone, Serialize, Deserialize)] 9 | pub struct LatencyQuantiles { 10 | pub p50: f64, 11 | pub p90: f64, 12 | pub p99: f64, 13 | } 14 | 15 | #[derive(Debug, Clone, Serialize, Deserialize)] 16 | pub struct LatencyData { 17 | pub buckets: Vec, 18 | pub counts: Vec, 19 | pub quantiles: LatencyQuantiles, 20 | } 21 | 22 | impl LatencyChart { 23 | pub fn new(buckets: Vec) -> Self { 24 | Self { buckets } 25 | } 26 | 27 | pub fn echart_data(&self) -> LatencyData { 28 | let buckets: Vec = self 29 | .buckets 30 | .iter() 31 | .enumerate() 32 | .map(|(i, b)| { 33 | let upper_ms = b.upper_bound; 34 | let lower_ms = if i == 0 { 35 | 0.0 36 | } else { 37 | self.buckets[i - 1].upper_bound 38 | }; 39 | 40 | format!("{} - {}", lower_ms * 1000.0, upper_ms * 1000.0) 41 | }) 42 | .collect(); 43 | let counts: Vec = self 44 | .buckets 45 | .iter() 46 | .enumerate() 47 | .map(|(i, b)| { 48 | if i == 0 { 49 | b.cumulative_count 50 | } else { 51 | // subtract the cumulative count of the previous bucket to get the count for the current bucket 52 | // buckets are guaranteed to be monotonically increasing so subtraction underflow isn't a concern 53 | b.cumulative_count - self.buckets[i - 1].cumulative_count 54 | } 55 | }) 56 | .collect(); 57 | let quantiles = LatencyQuantiles { 58 | p50: self.buckets.estimate_quantile(0.5) * 1000.0, // convert to ms 59 | p90: self.buckets.estimate_quantile(0.9) * 1000.0, 60 | p99: self.buckets.estimate_quantile(0.99) * 1000.0, 61 | }; 62 | 63 | LatencyData { 64 | buckets, 65 | counts, 66 | quantiles, 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /crates/report/src/chart/time_to_inclusion.rs: -------------------------------------------------------------------------------- 1 | use contender_core::db::RunTx; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | pub struct TimeToInclusionChart { 5 | /// Contains each tx's time to inclusion in seconds. 6 | inclusion_times: Vec, 7 | } 8 | 9 | #[derive(Clone, Debug, Deserialize, Serialize)] 10 | pub struct TimeToInclusionData { 11 | pub buckets: Vec, 12 | pub counts: Vec, 13 | pub max_count: u64, 14 | } 15 | 16 | impl TimeToInclusionChart { 17 | pub fn new(run_txs: &[RunTx]) -> Self { 18 | let mut inclusion_times = vec![]; 19 | for tx in run_txs { 20 | let mut dumb_base = 0; 21 | if let Some(end_timestamp) = tx.end_timestamp_secs { 22 | // dumb_base prevents underflow in case system time doesn't match block timestamps 23 | if dumb_base == 0 && end_timestamp < tx.start_timestamp_secs { 24 | dumb_base += tx.start_timestamp_secs - end_timestamp; 25 | } 26 | let end_timestamp = end_timestamp + dumb_base; 27 | let tti = end_timestamp - tx.start_timestamp_secs; 28 | inclusion_times.push(tti); 29 | } 30 | } 31 | Self { inclusion_times } 32 | } 33 | 34 | pub fn echart_data(&self) -> TimeToInclusionData { 35 | let mut buckets = vec![]; 36 | let mut counts = vec![]; 37 | let mut max_count = 0; 38 | 39 | for &tti in &self.inclusion_times { 40 | let bucket_index = tti as usize; // 1 bucket per second 41 | if bucket_index >= buckets.len() { 42 | buckets.resize(bucket_index + 1, "0".to_string()); 43 | counts.resize(bucket_index + 1, 0); 44 | } 45 | counts[bucket_index] += 1; 46 | if counts[bucket_index] > max_count { 47 | max_count = counts[bucket_index]; 48 | } 49 | buckets[bucket_index] = format!("{bucket_index} - {} s", bucket_index + 1); 50 | } 51 | 52 | TimeToInclusionData { 53 | buckets, 54 | counts, 55 | max_count, 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /crates/report/src/chart/tx_gas_used.rs: -------------------------------------------------------------------------------- 1 | use crate::{block_trace::TxTraceReceipt, util::abbreviate_num}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | pub struct TxGasUsedChart { 5 | gas_used: Vec, 6 | bucket_width: u64, 7 | } 8 | 9 | #[derive(Clone, Debug, Deserialize, Serialize)] 10 | pub struct TxGasUsedData { 11 | pub buckets: Vec, 12 | pub counts: Vec, 13 | pub max_count: u64, 14 | } 15 | 16 | impl TxGasUsedChart { 17 | pub fn new(trace_data: &[TxTraceReceipt], bucket_width: u64) -> Self { 18 | let mut gas_used = vec![]; 19 | for t in trace_data { 20 | gas_used.push(t.receipt.gas_used); 21 | } 22 | Self { 23 | gas_used, 24 | bucket_width, 25 | } 26 | } 27 | 28 | pub fn echart_data(&self) -> TxGasUsedData { 29 | let mut buckets = vec![]; 30 | let mut counts = vec![]; 31 | let mut max_count = 0; 32 | 33 | for &gas in &self.gas_used { 34 | let gas = gas + (self.bucket_width - (gas % self.bucket_width)); 35 | let bucket_index = (gas / self.bucket_width) as usize; 36 | if bucket_index >= buckets.len() { 37 | buckets.resize(bucket_index + 1, "0".to_string()); 38 | counts.resize(bucket_index + 1, 0); 39 | } 40 | counts[bucket_index] += 1; 41 | if counts[bucket_index] > max_count { 42 | max_count = counts[bucket_index]; 43 | } 44 | buckets[bucket_index] = format!( 45 | "{} - {}", 46 | abbreviate_num(bucket_index as u64 * self.bucket_width), 47 | abbreviate_num((bucket_index + 1) as u64 * self.bucket_width) 48 | ); 49 | } 50 | let (buckets, counts): (Vec<_>, Vec<_>) = buckets 51 | .into_iter() 52 | .zip(counts) 53 | .filter(|(_, count)| *count != 0) 54 | .unzip(); 55 | 56 | TxGasUsedData { 57 | buckets, 58 | counts, 59 | max_count, 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /crates/report/src/gen_html.rs: -------------------------------------------------------------------------------- 1 | use crate::chart::pending_txs::PendingTxsData; 2 | use crate::chart::rpc_latency::LatencyData; 3 | use crate::chart::time_to_inclusion::TimeToInclusionData; 4 | use crate::chart::tx_gas_used::TxGasUsedData; 5 | use crate::chart::{gas_per_block::GasPerBlockData, heatmap::HeatmapData}; 6 | use crate::command::SpamRunMetrics; 7 | use serde::{Deserialize, Serialize}; 8 | use std::collections::HashMap; 9 | use tracing::info; 10 | 11 | pub struct ReportMetadata { 12 | pub scenario_name: String, 13 | pub start_run_id: u64, 14 | pub end_run_id: u64, 15 | pub start_block: u64, 16 | pub end_block: u64, 17 | pub rpc_url: String, 18 | pub metrics: SpamRunMetrics, 19 | pub chart_data: ChartData, 20 | } 21 | 22 | #[derive(Clone, Debug, Deserialize, Serialize)] 23 | pub struct ChartData { 24 | pub gas_per_block: GasPerBlockData, 25 | pub heatmap: HeatmapData, 26 | pub time_to_inclusion: TimeToInclusionData, 27 | pub tx_gas_used: TxGasUsedData, 28 | pub pending_txs: PendingTxsData, 29 | pub latency_data_sendrawtransaction: LatencyData, 30 | } 31 | 32 | #[derive(Deserialize, Serialize)] 33 | struct TemplateData { 34 | scenario_name: String, 35 | date: String, 36 | rpc_url: String, 37 | start_block: String, 38 | end_block: String, 39 | metrics: SpamRunMetrics, 40 | chart_data: ChartData, 41 | } 42 | 43 | impl TemplateData { 44 | pub fn new(meta: &ReportMetadata) -> Self { 45 | Self { 46 | scenario_name: meta.scenario_name.to_owned(), 47 | date: chrono::Local::now().to_rfc2822(), 48 | rpc_url: meta.rpc_url.to_owned(), 49 | start_block: meta.start_block.to_string(), 50 | end_block: meta.end_block.to_string(), 51 | metrics: meta.metrics.to_owned(), 52 | chart_data: meta.chart_data.to_owned(), 53 | } 54 | } 55 | } 56 | 57 | /// Builds an HTML report for the given run IDs. Returns the path to the report. 58 | pub fn build_html_report( 59 | meta: ReportMetadata, 60 | reports_dir: &str, 61 | ) -> Result> { 62 | let template = include_str!("template.html.handlebars"); 63 | 64 | let mut data = HashMap::new(); 65 | let template_data = TemplateData::new(&meta); 66 | data.insert("data", template_data); 67 | let html = handlebars::Handlebars::new().render_template(template, &data)?; 68 | 69 | let path = format!( 70 | "{}/report-{}-{}.html", 71 | reports_dir, meta.start_run_id, meta.end_run_id 72 | ); 73 | std::fs::write(&path, html)?; 74 | info!("saved report to {path}"); 75 | 76 | Ok(path) 77 | } 78 | -------------------------------------------------------------------------------- /crates/report/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod block_trace; 2 | pub mod cache; 3 | pub mod chart; 4 | pub mod command; 5 | pub mod gen_html; 6 | pub mod util; 7 | -------------------------------------------------------------------------------- /crates/report/src/util.rs: -------------------------------------------------------------------------------- 1 | use contender_core::db::RunTx; 2 | 3 | /// Abbreviates a number to a human-readable format. 4 | pub fn abbreviate_num(num: u64) -> String { 5 | if num >= 1_000_000 { 6 | format!("{:.1}M", num as f64 / 1_000_000.0) 7 | } else if num >= 1_000 { 8 | format!("{}k", num / 1_000) 9 | } else { 10 | format!("{num}") 11 | } 12 | } 13 | 14 | pub fn mean(data: &[u64]) -> Option { 15 | let sum: f64 = data.iter().map(|d| *d as f64).sum(); 16 | let count = data.len(); 17 | 18 | match count { 19 | positive if positive > 0 => Some(sum / count as f64), 20 | _ => None, 21 | } 22 | } 23 | 24 | pub fn std_deviation(data: &[u64]) -> Option { 25 | match (mean(data), data.len()) { 26 | (Some(data_mean), count) if count > 0 => { 27 | let variance = data 28 | .iter() 29 | .map(|value| { 30 | let diff = data_mean - *value as f64; 31 | 32 | diff * diff 33 | }) 34 | .sum::() 35 | / count as f64; 36 | 37 | Some(variance.sqrt()) 38 | } 39 | _ => None, 40 | } 41 | } 42 | 43 | pub fn write_run_txs( 44 | writer: &mut csv::Writer, 45 | txs: &[RunTx], 46 | ) -> Result<(), Box> { 47 | for tx in txs { 48 | writer.serialize(tx)?; 49 | } 50 | writer.flush()?; 51 | Ok(()) 52 | } 53 | 54 | #[cfg(test)] 55 | mod test { 56 | use super::*; 57 | 58 | #[test] 59 | fn test_abbreviate_num() { 60 | assert_eq!(abbreviate_num(1_000), "1k"); 61 | assert_eq!(abbreviate_num(1_000_000), "1.0M"); 62 | assert_eq!(abbreviate_num(1_234_567), "1.2M"); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /crates/sqlite_db/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.2.1](https://github.com/flashbots/contender/releases/tag/contender_sqlite-v0.2.1) - 2025-05-14 11 | 12 | ### Other 13 | 14 | - ci publish ([#215](https://github.com/flashbots/contender/pull/215)) 15 | - Feat/reports w runtime params ([#213](https://github.com/flashbots/contender/pull/213)) 16 | - bugfix/tokio task panics ([#187](https://github.com/flashbots/contender/pull/187)) 17 | - Feat/more metrics ([#181](https://github.com/flashbots/contender/pull/181)) 18 | - engine_ calls to advance chain manually ([#165](https://github.com/flashbots/contender/pull/165)) 19 | - quality-of-life fixes ([#178](https://github.com/flashbots/contender/pull/178)) 20 | - tx observability, DB upgrades ([#167](https://github.com/flashbots/contender/pull/167)) 21 | - add scenario_name to runs table, use in report 22 | - remove redundant & 23 | - add test assertion for wrong named_tx url 24 | - associate RPC_URL with named txs for chain-specific deployments 25 | - organize db, modify templater return types, prompt user to redeploy on fill-blocks 26 | - make clippy happy 27 | - idiomatic workspace structure 28 | -------------------------------------------------------------------------------- /crates/sqlite_db/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors.workspace = true 3 | edition.workspace = true 4 | homepage.workspace = true 5 | license.workspace = true 6 | name = "contender_sqlite" 7 | description = "SQLite database for contender" 8 | repository.workspace = true 9 | rust-version.workspace = true 10 | version = "0.2.1" 11 | 12 | [dependencies] 13 | alloy = {workspace = true} 14 | contender_core = {workspace = true} 15 | r2d2 = {workspace = true} 16 | r2d2_sqlite = {workspace = true} 17 | rusqlite = {workspace = true} 18 | serde = {workspace = true} 19 | -------------------------------------------------------------------------------- /crates/testfile/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.2.1](https://github.com/flashbots/contender/releases/tag/contender_testfile-v0.2.1) - 2025-05-14 11 | 12 | ### Fixed 13 | 14 | - placeholder logic for > 2 placeholders 15 | 16 | ### Other 17 | 18 | - ci publish ([#215](https://github.com/flashbots/contender/pull/215)) 19 | - Adding remote scenarios ([#202](https://github.com/flashbots/contender/pull/202)) 20 | - bugfix/tokio task panics ([#187](https://github.com/flashbots/contender/pull/187)) 21 | - Feat/more metrics ([#181](https://github.com/flashbots/contender/pull/181)) 22 | - engine_ calls to advance chain manually ([#165](https://github.com/flashbots/contender/pull/165)) 23 | - drop stalled txs ([#175](https://github.com/flashbots/contender/pull/175)) 24 | - op interop scenario ([#136](https://github.com/flashbots/contender/pull/136)) 25 | - bugfixes & code organization ([#173](https://github.com/flashbots/contender/pull/173)) 26 | - simple scenario + code touchups ([#164](https://github.com/flashbots/contender/pull/164)) 27 | - de-duplicate from_pools in TestConfig util fns 28 | - move AgentStore & TestConfig utils into respective impls, fix broken test 29 | - clippy 30 | - various refactors 31 | - add TxType for scenario txs 32 | - implement gas_limit override in generator & testfile 33 | - remove println from unit test 34 | - fmt 35 | - associate RPC_URL with named txs for chain-specific deployments 36 | - support from_pool in create steps 37 | - Merge branch 'main' into add-fmt-clippy-workflows 38 | - make clippy happy 39 | - inject pool signers with generator (TODO: fund them) 40 | - allow tx 'value' field to be fuzzed 41 | - move testfiles into scenarios/ 42 | - idiomatic workspace structure 43 | -------------------------------------------------------------------------------- /crates/testfile/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "contender_testfile" 3 | version = "0.2.1" 4 | edition.workspace = true 5 | rust-version.workspace = true 6 | authors.workspace = true 7 | license.workspace = true 8 | homepage.workspace = true 9 | repository.workspace = true 10 | description = "Definitions for contender scenarios to be encoded as TOML files." 11 | 12 | [dependencies] 13 | toml = { workspace = true } 14 | alloy = { workspace = true } 15 | serde = { workspace = true } 16 | tokio = { workspace = true } 17 | contender_core = { workspace = true } 18 | prometheus = { workspace = true } 19 | -------------------------------------------------------------------------------- /crates/testfile/src/test_config.rs: -------------------------------------------------------------------------------- 1 | use alloy::transports::http::reqwest; 2 | use contender_core::{ 3 | error::ContenderError, 4 | generator::{ 5 | templater::Templater, 6 | types::{CreateDefinition, FunctionCallDefinition, SpamRequest}, 7 | PlanConfig, 8 | }, 9 | }; 10 | use serde::{Deserialize, Serialize}; 11 | use std::collections::HashMap; 12 | use std::fs::read; 13 | 14 | /// Configuration to run a test scenario; used to generate PlanConfigs. 15 | /// Defines TOML schema for scenario files. 16 | #[derive(Clone, Deserialize, Debug, Serialize, Default)] 17 | pub struct TestConfig { 18 | /// Template variables 19 | pub env: Option>, 20 | 21 | /// Contract deployments; array of hex-encoded bytecode strings. 22 | pub create: Option>, 23 | 24 | /// Setup steps to run before spamming. 25 | pub setup: Option>, 26 | 27 | /// Function to call in spam txs. 28 | pub spam: Option>, 29 | } 30 | 31 | impl TestConfig { 32 | pub async fn from_remote_url(url: &str) -> Result> { 33 | let file_contents = reqwest::get(url) 34 | .await 35 | .map_err(|_err| format!("Error occurred while fetching URL {url}"))? 36 | .text() 37 | .await 38 | .map_err(|_err| "Cannot convert the contents of the file into text.")?; 39 | let test_file: TestConfig = toml::from_str(&file_contents)?; 40 | Ok(test_file) 41 | } 42 | 43 | pub fn from_file(file_path: &str) -> Result> { 44 | let file_contents_str = String::from_utf8_lossy(&read(file_path)?).to_string(); 45 | let test_file: TestConfig = toml::from_str(&file_contents_str)?; 46 | Ok(test_file) 47 | } 48 | 49 | pub fn encode_toml(&self) -> Result> { 50 | let encoded = toml::to_string(self)?; 51 | Ok(encoded) 52 | } 53 | 54 | pub fn save_toml(&self, file_path: &str) -> Result<(), Box> { 55 | let encoded = self.encode_toml()?; 56 | std::fs::write(file_path, encoded)?; 57 | Ok(()) 58 | } 59 | } 60 | 61 | impl PlanConfig for TestConfig { 62 | fn get_spam_steps(&self) -> Result, ContenderError> { 63 | Ok(self.spam.to_owned().unwrap_or_default()) 64 | } 65 | 66 | fn get_setup_steps(&self) -> Result, ContenderError> { 67 | Ok(self.setup.to_owned().unwrap_or_default()) 68 | } 69 | 70 | fn get_create_steps(&self) -> Result, ContenderError> { 71 | Ok(self.create.to_owned().unwrap_or_default()) 72 | } 73 | 74 | fn get_env(&self) -> Result, ContenderError> { 75 | Ok(self.env.to_owned().unwrap_or_default()) 76 | } 77 | } 78 | 79 | impl Templater for TestConfig { 80 | /// Find values wrapped in brackets in a string and replace them with values from a hashmap whose key match the value in the brackets. 81 | /// example: "hello {world}" with hashmap {"world": "earth"} will return "hello earth" 82 | fn replace_placeholders(&self, input: &str, template_map: &HashMap) -> String { 83 | let mut output = input.to_owned(); 84 | for (key, value) in template_map.iter() { 85 | let template = format!("{{{key}}}"); 86 | output = output.replace(&template, value); 87 | } 88 | output 89 | } 90 | 91 | fn terminator_start(&self, input: &str) -> Option { 92 | input.find("{") 93 | } 94 | 95 | fn terminator_end(&self, input: &str) -> Option { 96 | input.find("}") 97 | } 98 | 99 | fn num_placeholders(&self, input: &str) -> usize { 100 | input.chars().filter(|&c| c == '{').count() 101 | } 102 | 103 | fn copy_end(&self, input: &str, last_end: usize) -> String { 104 | input.split_at(last_end).1.to_owned() 105 | } 106 | 107 | fn find_key(&self, input: &str) -> Option<(String, usize)> { 108 | if let Some(template_start) = self.terminator_start(input) { 109 | let template_end = self.terminator_end(input); 110 | if let Some(template_end) = template_end { 111 | let template_name = &input[template_start + 1..template_end]; 112 | return Some((template_name.to_owned(), template_end)); 113 | } 114 | } 115 | None 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /crates/testfile/testConfig.toml: -------------------------------------------------------------------------------- 1 | [env] 2 | env1 = "env1" 3 | env2 = "env2" 4 | 5 | [[setup]] 6 | to = "0xE46CcF40134e7ad524529B25Ce04e39BC2B51cDc" 7 | from = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" 8 | signature = "function deposit() public payable" 9 | value = "1234" 10 | 11 | # the spam step will be repeated 12 | [[spam]] 13 | 14 | # specify a single tx to spam 15 | [spam.tx] 16 | kind = "test" 17 | to = "0xE46CcF40134e7ad524529B25Ce04e39BC2B51cDc" 18 | from = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" 19 | signature = "test(uint256 amountIn, address to) external returns (uint256[] memory)" 20 | args = [ 21 | "1000000000000000000", 22 | "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", 23 | ] 24 | 25 | # each tx can have multiple fuzzed params 26 | [[spam.tx.fuzz]] 27 | param = "amountIn" 28 | min = "1" 29 | max = "100000000000000000" 30 | -------------------------------------------------------------------------------- /release-plz.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | allow_dirty = true # allow updating repositories with uncommitted changes 3 | changelog_update = true # enable changelog updates 4 | dependencies_update = true # update dependencies with `cargo update` 5 | git_release_enable = true # enable GitHub/Gitea releases 6 | pr_branch_prefix = "release-plz-" # PR branch prefix 7 | pr_labels = ["release"] # add the `release` label to the release Pull Request 8 | publish_allow_dirty = true # add `--allow-dirty` to `cargo publish` 9 | semver_check = true # enable API breaking changes checks 10 | publish_timeout = "10m" # set a timeout for `cargo publish` 11 | publish = false # disable publishing to crates.io 12 | 13 | [changelog] 14 | protect_breaking_commits = true # always include commits with breaking changes in the changelog 15 | -------------------------------------------------------------------------------- /scenarios/bundles.toml: -------------------------------------------------------------------------------- 1 | [[create]] 2 | bytecode = "0x6080604052348015600f57600080fd5b506105668061001f6000396000f3fe60806040526004361061004a5760003560e01c806369f86ec81461004f5780639402c00414610066578063a329e8de14610086578063c5eeaf17146100a6578063fb0e722b146100ae575b600080fd5b34801561005b57600080fd5b506100646100d9565b005b34801561007257600080fd5b50610064610081366004610218565b6100e4565b34801561009257600080fd5b506100646100a13660046102d1565b610119565b610064610145565b3480156100ba57600080fd5b506100c3610174565b6040516100d0919061030e565b60405180910390f35b5b60325a116100da57565b6000816040516020016100f892919061037b565b60405160208183030381529060405260009081610115919061044f565b5050565b600061012660d98361050e565b905060005b8181101561014057600160008190550161012b565b505050565b60405141903480156108fc02916000818181858888f19350505050158015610171573d6000803e3d6000fd5b50565b6000805461018190610341565b80601f01602080910402602001604051908101604052809291908181526020018280546101ad90610341565b80156101fa5780601f106101cf576101008083540402835291602001916101fa565b820191906000526020600020905b8154815290600101906020018083116101dd57829003601f168201915b505050505081565b634e487b7160e01b600052604160045260246000fd5b60006020828403121561022a57600080fd5b813567ffffffffffffffff81111561024157600080fd5b8201601f8101841361025257600080fd5b803567ffffffffffffffff81111561026c5761026c610202565b604051601f8201601f19908116603f0116810167ffffffffffffffff8111828210171561029b5761029b610202565b6040528181528282016020018610156102b357600080fd5b81602084016020830137600091810160200191909152949350505050565b6000602082840312156102e357600080fd5b5035919050565b60005b838110156103055781810151838201526020016102ed565b50506000910152565b602081526000825180602084015261032d8160408501602087016102ea565b601f01601f19169190910160400192915050565b600181811c9082168061035557607f821691505b60208210810361037557634e487b7160e01b600052602260045260246000fd5b50919050565b600080845461038981610341565b6001821680156103a057600181146103b5576103e5565b60ff19831686528115158202860193506103e5565b87600052602060002060005b838110156103dd578154888201526001909101906020016103c1565b505081860193505b50505083516103f88183602088016102ea565b01949350505050565b601f82111561014057806000526020600020601f840160051c810160208510156104285750805b601f840160051c820191505b818110156104485760008155600101610434565b5050505050565b815167ffffffffffffffff81111561046957610469610202565b61047d816104778454610341565b84610401565b6020601f8211600181146104b157600083156104995750848201515b600019600385901b1c1916600184901b178455610448565b600084815260208120601f198516915b828110156104e157878501518255602094850194600190920191016104c1565b50848210156104ff5786840151600019600387901b60f8161c191681555b50505050600190811b01905550565b60008261052b57634e487b7160e01b600052601260045260246000fd5b50049056fea264697066735822122043ea7522db98264cdc5157a0f2d3f9fc75e28c6078f917dfe9a946bf9b21af7f64736f6c634300081b0033" 3 | name = "SpamMe" 4 | from_pool = "admin" 5 | 6 | # spam bundle 7 | [[spam]] 8 | 9 | [[spam.bundle.tx]] 10 | to = "{SpamMe}" 11 | from_pool = "bluepool" 12 | signature = "consumeGas(uint256 gasAmount)" 13 | args = ["61000"] 14 | gas_limit = 70000 15 | 16 | [[spam.bundle.tx]] 17 | to = "{SpamMe}" 18 | from_pool = "bluepool" 19 | signature = "tipCoinbase()" 20 | value = "10000000000000000" 21 | gas_limit = 42000 22 | -------------------------------------------------------------------------------- /scenarios/mempool.toml: -------------------------------------------------------------------------------- 1 | [[create]] 2 | bytecode = "0x6080604052348015600f57600080fd5b506105668061001f6000396000f3fe60806040526004361061004a5760003560e01c806369f86ec81461004f5780639402c00414610066578063a329e8de14610086578063c5eeaf17146100a6578063fb0e722b146100ae575b600080fd5b34801561005b57600080fd5b506100646100d9565b005b34801561007257600080fd5b50610064610081366004610218565b6100e4565b34801561009257600080fd5b506100646100a13660046102d1565b610119565b610064610145565b3480156100ba57600080fd5b506100c3610174565b6040516100d0919061030e565b60405180910390f35b5b60325a116100da57565b6000816040516020016100f892919061037b565b60405160208183030381529060405260009081610115919061044f565b5050565b600061012660d98361050e565b905060005b8181101561014057600160008190550161012b565b505050565b60405141903480156108fc02916000818181858888f19350505050158015610171573d6000803e3d6000fd5b50565b6000805461018190610341565b80601f01602080910402602001604051908101604052809291908181526020018280546101ad90610341565b80156101fa5780601f106101cf576101008083540402835291602001916101fa565b820191906000526020600020905b8154815290600101906020018083116101dd57829003601f168201915b505050505081565b634e487b7160e01b600052604160045260246000fd5b60006020828403121561022a57600080fd5b813567ffffffffffffffff81111561024157600080fd5b8201601f8101841361025257600080fd5b803567ffffffffffffffff81111561026c5761026c610202565b604051601f8201601f19908116603f0116810167ffffffffffffffff8111828210171561029b5761029b610202565b6040528181528282016020018610156102b357600080fd5b81602084016020830137600091810160200191909152949350505050565b6000602082840312156102e357600080fd5b5035919050565b60005b838110156103055781810151838201526020016102ed565b50506000910152565b602081526000825180602084015261032d8160408501602087016102ea565b601f01601f19169190910160400192915050565b600181811c9082168061035557607f821691505b60208210810361037557634e487b7160e01b600052602260045260246000fd5b50919050565b600080845461038981610341565b6001821680156103a057600181146103b5576103e5565b60ff19831686528115158202860193506103e5565b87600052602060002060005b838110156103dd578154888201526001909101906020016103c1565b505081860193505b50505083516103f88183602088016102ea565b01949350505050565b601f82111561014057806000526020600020601f840160051c810160208510156104285750805b601f840160051c820191505b818110156104485760008155600101610434565b5050505050565b815167ffffffffffffffff81111561046957610469610202565b61047d816104778454610341565b84610401565b6020601f8211600181146104b157600083156104995750848201515b600019600385901b1c1916600184901b178455610448565b600084815260208120601f198516915b828110156104e157878501518255602094850194600190920191016104c1565b50848210156104ff5786840151600019600387901b60f8161c191681555b50505050600190811b01905550565b60008261052b57634e487b7160e01b600052601260045260246000fd5b50049056fea264697066735822122043ea7522db98264cdc5157a0f2d3f9fc75e28c6078f917dfe9a946bf9b21af7f64736f6c634300081b0033" 3 | name = "SpamMe2" 4 | from_pool = "admin" 5 | 6 | [[spam]] 7 | 8 | [spam.tx] 9 | to = "{SpamMe2}" 10 | from_pool = "redpool" 11 | signature = "consumeGas(uint256 gasAmount)" 12 | args = ["3000000"] 13 | fuzz = [{ param = "gasAmount", min = "1000000", max = "3000000" }] 14 | 15 | [[spam]] 16 | 17 | [spam.tx] 18 | to = "{SpamMe2}" 19 | from_pool = "bluepool" 20 | signature = "consumeGas(uint256 gasAmount)" 21 | args = ["1350000"] 22 | -------------------------------------------------------------------------------- /scenarios/op-interop/l2MintAndSend.toml: -------------------------------------------------------------------------------- 1 | [env] 2 | SuperchainTokenBridge = "0x4200000000000000000000000000000000000028" 3 | L2NativeSuperchainERC20 = "0x420beeF000000000000000000000000000000001" 4 | # sourceChainId = "901" 5 | targetChainId = "902" 6 | 7 | # mint tokens on source L2 8 | [[spam]] 9 | [spam.tx] 10 | to = "{L2NativeSuperchainERC20}" 11 | from_pool = "spammers" 12 | signature = "mint(address _to, uint256 _amount)" 13 | args = [ 14 | "{_sender}", 15 | "1000" 16 | ] 17 | 18 | # send tokens to other L2 19 | [[spam]] 20 | [spam.tx] 21 | to = "{SuperchainTokenBridge}" 22 | from_pool = "spammers" 23 | signature = "sendERC20(address _token, address _to, uint256 _amount, uint256 _chainId)" 24 | args = [ 25 | "{L2NativeSuperchainERC20}", 26 | "{_sender}", 27 | "1000", 28 | "{targetChainId}" 29 | ] 30 | gas_limit = 100000 31 | -------------------------------------------------------------------------------- /scenarios/simpler.toml: -------------------------------------------------------------------------------- 1 | [[spam]] 2 | [spam.tx] 3 | to = "{_sender}" 4 | from_pool = "spammers" 5 | signature = "hey()" 6 | args = [] 7 | value = "1" 8 | -------------------------------------------------------------------------------- /scenarios/slotContention.toml: -------------------------------------------------------------------------------- 1 | [[create]] 2 | # https://github.com/zeroXbrock/slot-conflict-sol 3 | name = "slotContention" 4 | bytecode = "0x608060405234801561001057600080fd5b50610bb5806100206000396000f3fe60806040526004361061008a5760003560e01c80633b45d703116100595780633b45d70314610137578063b60ab2a814610157578063b6a324e014610177578063b82a721b1461017f578063d252124f1461019f57600080fd5b806311d5d1f4146100965780631b5ac4b5146100b85780632123c06a146100ea57806326ccecfd1461011757600080fd5b3661009157005b600080fd5b3480156100a257600080fd5b506100b66100b13660046107b5565b6101bf565b005b3480156100c457600080fd5b506100d86100d3366004610801565b610279565b60405190815260200160405180910390f35b3480156100f657600080fd5b506100d8610105366004610801565b60009081526020819052604090205490565b34801561012357600080fd5b506100b6610132366004610866565b610297565b34801561014357600080fd5b506100b6610152366004610866565b6103c9565b34801561016357600080fd5b506100b6610172366004610957565b6104ee565b6100b6610623565b34801561018b57600080fd5b506100b661019a366004610957565b610675565b3480156101ab57600080fd5b506100b66101ba366004610992565b610749565b413185810361026257604051600090419089908381818185875af1925050503d806000811461020a576040519150601f19603f3d011682016040523d82523d6000602084013e61020f565b606091505b505090508061025c5760405162461bcd60e51b81526020600482015260146024820152732330b4b632b2103a379039b2b7321022ba3432b960611b60448201526064015b60405180910390fd5b5061026f565b61026f8886868686610675565b5050505050505050565b60008082126102885781610291565b610291826109ca565b92915050565b8883146102e65760405162461bcd60e51b815260206004820181905260248201527f536c6f747320616e642076616c756573206c656e677468206d69736d617463686044820152606401610253565b8887146103055760405162461bcd60e51b8152600401610253906109e6565b8885146103245760405162461bcd60e51b815260040161025390610a2c565b60005b898110156103bc576103b48b8b8381811061034457610344610a72565b905060200201358a8a8481811061035d5761035d610a72565b9050602002013589898581811061037657610376610a72565b9050602002013588888681811061038f5761038f610a72565b905060200201358787878181106103a8576103a8610a72565b90506020020135610675565b600101610327565b5050505050505050505050565b8883146104185760405162461bcd60e51b815260206004820181905260248201527f536c6f747320616e642076616c756573206c656e677468206d69736d617463686044820152606401610253565b8887146104375760405162461bcd60e51b8152600401610253906109e6565b8885146104565760405162461bcd60e51b815260040161025390610a2c565b60005b898110156103bc576104e68b8b8381811061047657610476610a72565b905060200201358a8a8481811061048f5761048f610a72565b905060200201358989858181106104a8576104a8610a72565b905060200201358888868181106104c1576104c1610a72565b905060200201358787878181106104da576104da610a72565b905060200201356104ee565b600101610459565b60008581526020819052604090205484811080159061050d5750838111155b6105295760405162461bcd60e51b815260040161025390610a88565b60008681526020818152604080832086905580518381529182019052419084906040516105569190610add565b60006040518083038185875af1925050503d8060008114610593576040519150601f19603f3d011682016040523d82523d6000602084013e610598565b606091505b50509050806105e05760405162461bcd60e51b81526020600482015260146024820152732330b4b632b2103a379039b2b7321022ba3432b960611b6044820152606401610253565b60408051888152602081018690527f1224c0b1b1fc9b317194a91a14e45a82b4a46b12e44848ddf8f20f5f69567fcd91015b60405180910390a150505050505050565b600034116106735760405162461bcd60e51b815260206004820152601b60248201527f596f75206e65656420746f2073656e6420736f6d6520457468657200000000006044820152606401610253565b565b600085815260208190526040812054906106926100d38584610b0c565b90508582101580156106a45750848211155b6106c05760405162461bcd60e51b815260040161025390610a88565b6000878152602081905260409020849055416108fc6106e0836001610b33565b6106ea9086610b55565b6040518115909202916000818181858888f19350505050158015610712573d6000803e3d6000fd5b5060408051888152602081018690527f1224c0b1b1fc9b317194a91a14e45a82b4a46b12e44848ddf8f20f5f69567fcd9101610612565b600082815260208190526040812054906107638383610b6c565b6000858152602081815260409182902083905581518781529081018390529192507f1224c0b1b1fc9b317194a91a14e45a82b4a46b12e44848ddf8f20f5f69567fcd910160405180910390a150505050565b600080600080600080600060e0888a0312156107d057600080fd5b505085359760208701359750604087013596606081013596506080810135955060a0810135945060c0013592509050565b60006020828403121561081357600080fd5b5035919050565b60008083601f84011261082c57600080fd5b50813567ffffffffffffffff81111561084457600080fd5b6020830191508360208260051b850101111561085f57600080fd5b9250929050565b60008060008060008060008060008060a08b8d03121561088557600080fd5b8a3567ffffffffffffffff8082111561089d57600080fd5b6108a98e838f0161081a565b909c509a5060208d01359150808211156108c257600080fd5b6108ce8e838f0161081a565b909a50985060408d01359150808211156108e757600080fd5b6108f38e838f0161081a565b909850965060608d013591508082111561090c57600080fd5b6109188e838f0161081a565b909650945060808d013591508082111561093157600080fd5b5061093e8d828e0161081a565b915080935050809150509295989b9194979a5092959850565b600080600080600060a0868803121561096f57600080fd5b505083359560208501359550604085013594606081013594506080013592509050565b600080604083850312156109a557600080fd5b50508035926020909101359150565b634e487b7160e01b600052601160045260246000fd5b6000600160ff1b82016109df576109df6109b4565b5060000390565b60208082526026908201527f536c6f747320616e64206c6f77657220626f756e6473206c656e677468206d696040820152650e6dac2e8c6d60d31b606082015260800190565b60208082526026908201527f536c6f747320616e6420757070657220626f756e6473206c656e677468206d696040820152650e6dac2e8c6d60d31b606082015260800190565b634e487b7160e01b600052603260045260246000fd5b60208082526035908201527f43757272656e742076616c756520646f6573206e6f742066616c6c2077697468604082015274696e207468652065787065637465642072616e676560581b606082015260800190565b6000825160005b81811015610afe5760208186018101518583015201610ae4565b506000920191825250919050565b8181036000831280158383131683831282161715610b2c57610b2c6109b4565b5092915050565b600082610b5057634e487b7160e01b600052601260045260246000fd5b500490565b8082028115828204841417610291576102916109b4565b80820180821115610291576102916109b456fea264697066735822122048754416ad62e6a7bc959c3873309e88ede22ff101252b1d76c4fe1feb3a0a2a64736f6c63430008180033" 5 | from_pool = "admin" 6 | 7 | [[setup]] 8 | to = "{slotContention}" 9 | kind = "fund_contract" 10 | from_pool = "admin" 11 | signature = "fundMe()" 12 | args = [] 13 | value = "1000000000000000000" 14 | 15 | # more likely to fail over time 16 | [[spam]] 17 | [spam.tx] 18 | to = "{slotContention}" 19 | from_pool = "spammer" 20 | signature = "function writeToSlot(uint256 slot, uint256 lower, uint256 upper, uint256 newValue, uint256 payment) public" 21 | args = ["0", "0", "1000", "420", "10000000000000"] 22 | gas_limit = 151000 23 | fuzz = [ 24 | { param = "slot", min = "0", max = "0x20" }, 25 | # { param = "lower", min = "0", max = "500" }, 26 | { param = "upper", min = "0", max = "500" }, 27 | { param = "newValue", min = "0", max = "500" }, 28 | { param = "payment", min = "0", max = "100000000000000" }, 29 | ] 30 | 31 | # unlikely to fail for a long time 32 | [[spam]] 33 | [spam.tx] 34 | to = "{slotContention}" 35 | from_pool = "spammer" 36 | signature = "function writeToSlot(uint256 slot, uint256 lower, uint256 upper, uint256 newValue, uint256 payment) public" 37 | args = ["0", "0", "1000", "420", "10000000000000"] 38 | gas_limit = 151000 39 | fuzz = [ 40 | { param = "slot", min = "0", max = "0xffffffffffffffffffffffffffffffff" }, 41 | # { param = "lower", min = "0", max = "500" }, 42 | { param = "upper", min = "0", max = "500" }, 43 | { param = "newValue", min = "0", max = "500" }, 44 | { param = "payment", min = "0", max = "100000000000000" }, 45 | ] 46 | -------------------------------------------------------------------------------- /scenarios/stress.toml: -------------------------------------------------------------------------------- 1 | [[create]] 2 | bytecode = "0x608060405234801561001057600080fd5b5060408051808201909152600d81526c48656c6c6f2c20576f726c642160981b602082015260009061004290826100e7565b506101a5565b634e487b7160e01b600052604160045260246000fd5b600181811c9082168061007257607f821691505b60208210810361009257634e487b7160e01b600052602260045260246000fd5b50919050565b601f8211156100e257806000526020600020601f840160051c810160208510156100bf5750805b601f840160051c820191505b818110156100df57600081556001016100cb565b50505b505050565b81516001600160401b0381111561010057610100610048565b6101148161010e845461005e565b84610098565b6020601f82116001811461014857600083156101305750848201515b600019600385901b1c1916600184901b1784556100df565b600084815260208120601f198516915b828110156101785787850151825560209485019460019092019101610158565b50848210156101965786840151600019600387901b60f8161c191681555b50505050600190811b01905550565b610a72806101b46000396000f3fe6080604052600436106100555760003560e01c806369f86ec81461005a5780638199ba20146100715780639402c00414610091578063a329e8de146100b1578063c5eeaf17146100d1578063fb0e722b146100d9575b600080fd5b34801561006657600080fd5b5061006f610104565b005b34801561007d57600080fd5b5061006f61008c3660046106f6565b61010f565b34801561009d57600080fd5b5061006f6100ac36600461074f565b610413565b3480156100bd57600080fd5b5061006f6100cc3660046107a0565b610444565b61006f6104d7565b3480156100e557600080fd5b506100ee610506565b6040516100fb91906107dd565b60405180910390f35b5b60325a1161010557565b6040805180820190915260068152657373746f726560d01b6020820152610137908390610594565b1561015d5760005b818110156101585761015060008055565b60010161013f565b505050565b6040805180820190915260058152641cdb1bd85960da1b6020820152610184908390610594565b1561019c5760005b818110156101585760010161018c565b6040805180820190915260068152656d73746f726560d01b60208201526101c4908390610594565b156101e55760005b81811015610158576101dd60008052565b6001016101cc565b6040805180820190915260058152641b5b1bd85960da1b602082015261020c908390610594565b1561022157600081156101585760010161018c565b60408051808201909152600381526218591960ea1b6020820152610246908390610594565b1561025b57600081156101585760010161018c565b60408051808201909152600381526239bab160e91b6020820152610280908390610594565b1561029557600081156101585760010161018c565b6040805180820190915260038152621b5d5b60ea1b60208201526102ba908390610594565b156102cf57600081156101585760010161018c565b6040805180820190915260038152623234bb60e91b60208201526102f4908390610594565b1561030957600081156101585760010161018c565b60408051808201909152600981526832b1b932b1b7bb32b960b91b6020820152610334908390610594565b156103545760005b818110156101585761034c6105ee565b60010161033c565b60408051808201909152600981526835b2b1b1b0b5991a9b60b91b602082015261037f908390610594565b1561039457600081156101585760010161018c565b60408051808201909152600781526662616c616e636560c81b60208201526103bd908390610594565b156103d257600081156101585760010161018c565b60408051808201909152600681526531b0b63632b960d11b60208201526103fa908390610594565b1561040f57600081156101585760010161018c565b5050565b60008160405160200161042792919061084a565b6040516020818303038152906040526000908161040f919061091e565b600081116104985760405162461bcd60e51b815260206004820152601a60248201527f476173206d7573742062652067726561746572207468616e2030000000000000604482015260640160405180910390fd5b600060956104a8610a28846109dd565b6104b291906109fe565b9050806000036104c0575060015b60005b8181101561015857600080556001016104c3565b60405141903480156108fc02916000818181858888f19350505050158015610503573d6000803e3d6000fd5b50565b6000805461051390610810565b80601f016020809104026020016040519081016040528092919081815260200182805461053f90610810565b801561058c5780601f106105615761010080835404028352916020019161058c565b820191906000526020600020905b81548152906001019060200180831161056f57829003601f168201915b505050505081565b6000816040516020016105a79190610a20565b60405160208183030381529060405280519060200120836040516020016105ce9190610a20565b604051602081830303815290604052805190602001201490505b92915050565b604080516000808252602082018084527f7b05e003631381b3ecd0222e748a7900c262a008c4b7f002ce4a9f0a190619539052604292820183905260608201839052608082019290925260019060a0016020604051602081039080840390855afa158015610660573d6000803e3d6000fd5b50505050565b634e487b7160e01b600052604160045260246000fd5b60008067ffffffffffffffff84111561069757610697610666565b50604051601f19601f85018116603f0116810181811067ffffffffffffffff821117156106c6576106c6610666565b6040528381529050808284018510156106de57600080fd5b83836020830137600060208583010152509392505050565b6000806040838503121561070957600080fd5b823567ffffffffffffffff81111561072057600080fd5b8301601f8101851361073157600080fd5b6107408582356020840161067c565b95602094909401359450505050565b60006020828403121561076157600080fd5b813567ffffffffffffffff81111561077857600080fd5b8201601f8101841361078957600080fd5b6107988482356020840161067c565b949350505050565b6000602082840312156107b257600080fd5b5035919050565b60005b838110156107d45781810151838201526020016107bc565b50506000910152565b60208152600082518060208401526107fc8160408501602087016107b9565b601f01601f19169190910160400192915050565b600181811c9082168061082457607f821691505b60208210810361084457634e487b7160e01b600052602260045260246000fd5b50919050565b600080845461085881610810565b60018216801561086f5760018114610884576108b4565b60ff19831686528115158202860193506108b4565b87600052602060002060005b838110156108ac57815488820152600190910190602001610890565b505081860193505b50505083516108c78183602088016107b9565b01949350505050565b601f82111561015857806000526020600020601f840160051c810160208510156108f75750805b601f840160051c820191505b818110156109175760008155600101610903565b5050505050565b815167ffffffffffffffff81111561093857610938610666565b61094c816109468454610810565b846108d0565b6020601f82116001811461098057600083156109685750848201515b600019600385901b1c1916600184901b178455610917565b600084815260208120601f198516915b828110156109b05787850151825560209485019460019092019101610990565b50848210156109ce5786840151600019600387901b60f8161c191681555b50505050600190811b01905550565b818103818111156105e857634e487b7160e01b600052601160045260246000fd5b600082610a1b57634e487b7160e01b600052601260045260246000fd5b500490565b60008251610a328184602087016107b9565b919091019291505056fea264697066735822122040db52b9a7c8a77f16a18198a6085a3ff5f3e5c378e4a9cd497037d20f775eb864736f6c634300081b0033" 3 | name = "SpamMe3" 4 | from_pool = "admin" 5 | 6 | [[spam]] 7 | 8 | [spam.tx] 9 | to = "{SpamMe3}" 10 | from_pool = "pool1" 11 | signature = "consumeGas(string memory method, uint256 iterations)" 12 | args = ["sstore", "8000"] 13 | # note: the `fuzz` field will spam the network with `estimateGas` calls; every unique calldata requires a new call 14 | # fuzz = [{ param = "iterations", min = "3000", max = "8000" }] 15 | 16 | [[spam]] 17 | 18 | [spam.tx] 19 | to = "{SpamMe3}" 20 | from_pool = "pool2" 21 | signature = "consumeGas(string memory method, uint256 iterations)" 22 | args = ["sload", "8000"] 23 | # fuzz = [{ param = "iterations", min = "3000", max = "8000" }] 24 | 25 | [[spam]] 26 | 27 | [spam.tx] 28 | to = "{SpamMe3}" 29 | from_pool = "pool3" 30 | signature = "consumeGas(string memory method, uint256 iterations)" 31 | args = ["mload", "8000"] 32 | # fuzz = [{ param = "iterations", min = "3000", max = "8000" }] 33 | 34 | [[spam]] 35 | 36 | [spam.tx] 37 | to = "{SpamMe3}" 38 | from_pool = "pool4" 39 | signature = "consumeGas(string memory method, uint256 iterations)" 40 | args = ["mstore", "8000"] 41 | # fuzz = [{ param = "iterations", min = "3000", max = "8000" }] 42 | -------------------------------------------------------------------------------- /scripts/test-report.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DOCKERFILE_DIR=$(dirname "$(readlink -f "$0")")/.. 4 | 5 | URL="http://localhost:8545" 6 | 7 | docker build -t contender "$DOCKERFILE_DIR" 8 | if [[ $? -ne 0 ]]; then 9 | echo "Docker build failed." 10 | exit 1 11 | fi 12 | 13 | CONTEXT=/tmp/contender-report-test 14 | mkdir -p $CONTEXT 15 | chmod 777 $CONTEXT 16 | 17 | docker run -v "$CONTEXT:/home/appuser/.contender/reports" \ 18 | contender report $URL 19 | 20 | sed -i "s|/home/appuser/.contender/reports|$CONTEXT|g" "$CONTEXT/report-2-2.html" 21 | echo "Report available at file://$CONTEXT/report-2-2.html" 22 | -------------------------------------------------------------------------------- /test_fixtures/contender.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flashbots/contender/862351c63db8a1f6cc1121f49dad0cf7f351f071/test_fixtures/contender.db --------------------------------------------------------------------------------