diff --git a/.github/workflows/checks_docker.yaml b/.github/workflows/checks_docker.yaml index c10910c5f..302faaf0d 100644 --- a/.github/workflows/checks_docker.yaml +++ b/.github/workflows/checks_docker.yaml @@ -15,6 +15,8 @@ jobs: steps: - name: Checkout sources uses: actions/checkout@v4 + with: + submodules: recursive # required: mevcommit-proto reads vendor/mev-commit at build time - name: Docker QEMU uses: docker/setup-qemu-action@v3 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index f4a152475..7b79b0327 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -114,6 +114,8 @@ jobs: - uses: actions/checkout@v4 if: steps.platform-check.outputs.skip != 'true' + with: + submodules: recursive # required: mevcommit-proto reads vendor/mev-commit at build time # Linux: Use Docker for reproducible builds - name: Set up Docker Buildx @@ -283,6 +285,8 @@ jobs: steps: - name: checkout sources uses: actions/checkout@v4 + with: + submodules: recursive # required: mevcommit-proto reads vendor/mev-commit at build time - name: Download linux rbuilder binary id: download-binary @@ -376,6 +380,8 @@ jobs: steps: - name: checkout sources uses: actions/checkout@v4 + with: + submodules: recursive # required: mevcommit-proto reads vendor/mev-commit at build time - name: docker buildx uses: docker/setup-buildx-action@v3 diff --git a/.gitmodules b/.gitmodules index c59445991..af8915daf 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "mev-test-contract/lib/forge-std"] path = mev-test-contract/lib/forge-std url = https://github.com/foundry-rs/forge-std.git +[submodule "vendor/mev-commit"] + path = vendor/mev-commit + url = https://github.com/primev/mev-commit.git diff --git a/Cargo.lock b/Cargo.lock index 0c4bcf664..6ad701e28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7127,6 +7127,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "mevcommit-proto" +version = "0.1.0" +dependencies = [ + "prost 0.13.5", + "tonic 0.13.1", + "tonic-build", +] + [[package]] name = "mime" version = "0.3.17" @@ -9502,6 +9511,7 @@ dependencies = [ "mempool-dumpster", "metrics_macros", "mev-share-sse", + "mevcommit-proto", "mockall", "parking_lot", "priority-queue", diff --git a/Cargo.toml b/Cargo.toml index c8fdfc229..a64f407bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "crates/rbuilder/src/test_utils", "crates/rbuilder/src/telemetry/metrics_macros", "crates/eth-sparse-mpt", + "crates/mevcommit-proto", "crates/sysperf", "crates/test-relay", "crates/bid-scraper", @@ -175,6 +176,7 @@ proptest = "1.5" eth-sparse-mpt = { path = "crates/eth-sparse-mpt" } bid-scraper = { path = "crates/bid-scraper" } +mevcommit-proto = { path = "crates/mevcommit-proto" } rbuilder = { path = "crates/rbuilder" } rbuilder-primitives = { path = "crates/rbuilder-primitives" } rbuilder-utils = { path = "crates/rbuilder-utils" } diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 000000000..b7323bf02 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,306 @@ +# rbuilder — Deployment Guide + +Deploying rbuilder is identical to upstream Flashbots rbuilder with two additions: + +1. The `vendor/mev-commit` submodule must be initialized before building. +2. The config requires a `[mevcommit]` section pointing at a running mev-commit provider sidecar (**MEV-Commit release `v1.3.0-rc1` required**). + +--- + +## Prerequisites + +The following must already be running and reachable before rbuilder starts: + + +| Dependency | Purpose | Notes | +| ------------------------------- | ------------------------------------ | ------------------------------------- | +| **Reth** (EL node) | Chain state, mempool | IPC socket at `/tmp/reth.ipc` | +| **Beacon node** (CL node) | Slot events, validator registrations | HTTP, typically port 3500 | +| **MEV-Boost relay(s)** | Block submission | HTTPS endpoints from relay operators | +| **mev-commit provider sidecar** | Preconfirmation bid intake | gRPC on port 13524; deploy separately (requires MEV-Commit `v1.3.0-rc1`) | + + +rbuilder will start without the sidecar but will emit reconnect warnings and accept no preconf bids until it connects. If you do not have a provider running yet, see [MEV-Commit provider setup](#mev-commit-provider-sidecar-setup) at the end of this guide. + +--- + +## Step 1 — Get the code + +```bash +git clone https://github.com/shutter-network/rbuilder.git +cd rbuilder +git checkout mevcommit-integration + +# Required: populate the mev-commit submodule before building +git submodule update --init --recursive +``` + +--- + +## Step 2 — Build the Docker image + +```bash +docker build \ + --target rbuilder-runtime \ + -f docker/Dockerfile.rbuilder \ + -t rbuilder-mev-commit:latest \ + . +``` + +Build args (both optional): + + +| Arg | Default | Notes | +| -------------- | ---------- | ----------------------------------------------------------------------- | +| `FEATURES` | (none) | Comma-separated Cargo features, e.g. `redact-sensitive` | +| `RBUILDER_BIN` | `rbuilder` | Binary to build; change to `rbuilder-operator` for the operator variant | + + +The build uses `cargo-chef` for layer caching. First build takes ~10–20 minutes; subsequent builds are much faster if dependencies haven't changed. + +--- + +## Step 3 — Create the config file + +Copy the example and edit it: + +```bash +cp examples/config/rbuilder/config-live-example.toml config.toml +``` + +The table below covers every section in the example. Work through it top-to-bottom before running. + +### Top-level fields + +| Field | Action | Notes | +|---|---|---| +| `log_json`, `log_level` | Keep | Leave as-is | +| `redacted_telemetry_server_port` / `full_telemetry_server_port` | Keep | 6061 (redacted, safe to expose) / 6060 (full, internal only) | +| `chain` | **Set** | `"mainnet"`, `"holesky"`, or `"hoodi"` | +| `reth_datadir` | **Set** | Path to your reth data dir, e.g. `/mnt/data/reth` | +| `coinbase_secret_key` | Keep as `"env:COINBASE_SECRET_KEY"` | Value comes from environment | +| `relay_secret_key` | Keep as `"env:RELAY_SECRET_KEY"` | Value comes from environment | +| `cl_node_url` | Keep as `["env:CL_NODE_URL"]` | Value comes from environment | +| `jsonrpc_server_port` | Keep | Orderflow port (8645) | +| `el_node_ipc_path` | **Set** | Path to reth IPC socket, e.g. `/tmp/reth.ipc` | +| `extra_data` | Optional | Builder tag embedded in blocks | +| `blocklist_file_path` | **Remove or comment out** | The example sets `"./blocklist.json"` — this file does NOT exist in the container and will crash rbuilder at startup. Remove the line entirely (no blocklist = all addresses allowed). | +| `ignore_cancellable_orders` | Keep | Leave as-is | +| `live_builders` | Keep | Leave as-is | +| `enabled_relays` | **Remove or update** | `["flashbots"]` enables the hardcoded Flashbots mainnet relay. Remove this line — all `[[relays]]` blocks you define below are always enabled automatically. | +| `subsidy` | Optional | Remove if not using subsidy logic | + +### `[[subsidy_overrides]]` + +**Remove** — references `"flashbots_test2"` which is a test relay. Delete this block. + +### `[[relays]]` + +**Remove both test blocks** (`flashbots_test`, `flashbots_test2`) — they point at `http://localhost:80` and will cause submission errors in production. + +**Add your real relay(s):** + +```toml +[[relays]] +name = "your-relay-name" +url = "https://@" +mode = "full" +``` + +Built-in relay names you can enable via `enabled_relays` without defining a block (already hardcoded in the binary): `flashbots`, `ultrasound-us`, `ultrasound-eu`, and others. If you use one of these names in `[[relays]]`, your config overrides the built-in URL. + +### `[[builders]]` + +Keep all three blocks (`mgp-ordering`, `mp-ordering`, `parallel`) as-is. + +### `[[relay_bid_scrapers]]` + +Keep or remove the two Ultrasound scraper entries — they provide competitive bid data but are not required to submit blocks. + +### `[mevcommit]` + +**Set these two fields:** + +```toml +[mevcommit] +enabled = true +provider_grpc_addr = "http://:13524" +# The sidecar can run on any host reachable from this machine: +# same host: "http://127.0.0.1:13524" +# separate VM: "http://10.0.0.5:13524" +# DNS name: "http://mev-commit-provider.internal:13524" +``` + +Leave all other tuning fields commented out (defaults are production-safe). + +--- + +The `"env:VAR"` syntax is resolved at startup from the process environment. Never put raw private key values in the file. + +--- + +## Step 4 — Set environment variables + +Export these in your shell, systemd unit, or container environment before running: + +```bash +export COINBASE_SECRET_KEY= # builder signing key +export RELAY_SECRET_KEY= # relay submission key +export CL_NODE_URL=http://:3500 +``` + +Optional: + +```bash +export RUST_LOG=info,rbuilder=debug # log verbosity +``` + +--- + +## Step 5 — Run + +```bash +docker run \ + --name rbuilder \ + --restart unless-stopped \ + --network host \ + -e COINBASE_SECRET_KEY \ + -e RELAY_SECRET_KEY \ + -e CL_NODE_URL \ + -e RUST_LOG \ + -v $(pwd)/config.toml:/config/config.toml:ro \ + -v /mnt/data/reth:/mnt/data/reth:ro \ + -v /tmp/reth.ipc:/tmp/reth.ipc \ + rbuilder-mev-commit:latest \ + run --config /config/config.toml +``` + +Port reference (all on host network): + +| Port | Expose? | Purpose | +| ------ | ------------- | ------------------------------------------------- | +| `6060` | Internal only | Full Prometheus metrics (contains sensitive data) | +| `6061` | Optional | Redacted Prometheus metrics | +| `8645` | Yes | JSON-RPC orderflow (searchers/bundlers) | +| `6071` | Yes | Optimistic V3 relay submission | + + +--- + +## Step 6 — Verify startup + +Watch the logs: + +```bash +docker logs -f rbuilder +``` + +Expected sequence: + +``` +# rbuilder core started — keys and relay loaded +INFO rbuilder: Builder coinbase address: 0x... +INFO rbuilder: Builder mev boost relay pubkey: ... +INFO rbuilder: Optimistic V3 is enabled for at least one relay, spawning server ... + +# mev-commit integration started +INFO mevcommit: Starting mev-commit provider integration grpc_addr=http://... shutter_fetch_timeout_ms=2000 + +# sidecar handshake succeeded +INFO mevcommit: mev-commit sidecar connected grpc_addr=http://... +``` + +If the sidecar is not yet reachable (non-fatal, retries automatically): + +``` +WARN mevcommit: mev-commit bid stream session error error=... ; reconnecting +``` + +To see per-slot mev-commit state (debug level — requires `RUST_LOG=info,rbuilder=debug`): + +``` +DEBUG mevcommit: mev-commit slot state rotated slot=... target_block=... is_primev=true block_gas_limit=... +``` + +--- + +## Step 7 — Verify mev-commit integration + +Check Prometheus metrics (redacted endpoint): + +```bash +curl -s http://localhost:6061/metrics | grep mevcommit_ +``` + +Expected in normal operation: + +``` +mevcommit_grpc_reconnects_total 0 # sidecar is stable +mevcommit_bids_received_total N # bids flowing (N > 0 once bidders connect) +mevcommit_blocks_suppressed_total 0 # slash protection not firing +``` + +--- + +## Updating + +To deploy a new version: + +```bash +# Pull latest code +git pull +git submodule update --recursive + +# Rebuild +docker build --target rbuilder-runtime -f docker/Dockerfile.rbuilder -t rbuilder-mev-commit:latest . + +# Replace running container +docker stop rbuilder && docker rm rbuilder +# re-run Step 5 +``` + +--- + +## Rollback / disabling mev-commit + +To disable the mev-commit integration without changing the binary: + +```toml +[mevcommit] +enabled = false +``` + +Restart rbuilder. No sidecar connection is made; all `mevcommit_*` metrics stay at 0. rbuilder behaves identically to upstream Flashbots rbuilder. + +--- + +## MEV-Commit Provider Sidecar Setup + +The MEV-Commit provider runs as a **separate service**. Before enabling `[mevcommit]` in rbuilder, you must have a running, registered MEV-Commit provider node. rbuilder connects to it via `provider_grpc_addr` (gRPC, default port 13524). + +### Install the provider node + +Follow the official setup guide: [Manual start — mev-commit](https://docs.primev.xyz/v1.1.0/developers/manual-start-mev-commit). + +**Important:** that guide downloads **v1.1.0**, which is **not compatible** with Shutterised Bids. Use **v1.3.0-rc1** instead: + +- Release: [v1.3.0-rc1](https://github.com/primev/mev-commit/releases/tag/v1.3.0-rc1) + +```bash +FILENAME="mev-commit_1.3.0-rc1_$(uname -s)_$(uname -m).tar.gz" +curl -L \ + "https://github.com/primev/mev-commit/releases/download/v1.3.0-rc1/$FILENAME" \ + | tar -xz -C . +``` + +The archive has no top-level directory — it extracts `mev-commit`, `LICENSE`, and `README.md` directly into the current directory. + +After installing the correct binary, follow the remainder of the official guide unchanged (wallet setup, chain config, starting the provider node). + +### Register and stake as a provider + +Running the provider node alone is not sufficient. You must register and stake as a provider per the Primev docs: + +[Registering a provider](https://docs.primev.xyz/v1.1.0/get-started/providers/registering-a-provider) + +Once the provider is registered, running, and reachable, set `provider_grpc_addr` in rbuilder's `[mevcommit]` config to its gRPC endpoint. \ No newline at end of file diff --git a/README.md b/README.md index 0291fcc88..f3c798971 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,69 @@ You can query the local relay for proposed blocks like this: curl http://localhost:5555/relay/v1/data/bidtraces/proposer_payload_delivered ``` +### mev-commit (primev) integration + +rbuilder can run as a mev-commit provider. A co-located mev-commit Go sidecar handles libp2p transport, NIKE decryption, deposit/signature verification, preconfirmation signing, and on-chain commitment storage; rbuilder owns the build-side business logic over gRPC. See `mevcommit-integration-plan.md` for the architecture. + +The Shutterised bid path is the current test target. Plaintext (RLP-in-bid) bids are not yet supported. + +**One-time setup** — the upstream protos are pulled in as a git submodule pinned to `v1.3.0-rc1`: + +```bash +git submodule update --init --recursive +``` + +The repo also requires `cmake` (transitive dep). On macOS with Homebrew: + +```bash +brew install cmake +# cmake 4.x dropped compat with the cmake_minimum_required version that the +# transitive `runng-sys` dep uses, so export this in your shell: +export CMAKE_POLICY_VERSION_MINIMUM=3.5 +``` + +**Sample config** — add the `[mevcommit]` section to your rbuilder TOML: + +```toml +[mevcommit] +enabled = true +provider_grpc_addr = "http://127.0.0.1:13524" +bid_eval_timeout_ms = 150 # currently unused; reserved for future async evaluator +shutter_fetch_timeout_ms = 2000 +max_preconf_gas_fraction = 0.5 +max_preconf_bids_per_slot = 20 +shutter_assumed_gas_per_bid = 200000 +enable_verify_gate = true # pre-relay slash-protection gate; only disable for Shutter-only deployments without PositionConstraints +``` + +The integration is fully dormant when `enabled = false` or the section is omitted. + +**Running** — start the mev-commit Go sidecar in provider mode (configured to point at the deployed Shutter sequencer), then start rbuilder. The bidirectional gRPC stream connects lazily; the sidecar can be unreachable at boot without blocking startup. + +**Smoke testing.** A manual harness is checked in at `scripts/mevcommit_smoke.sh` that drives the upstream `provideremulator` from the vendored mev-commit submodule. Two terminals: + +```bash +# Terminal A: emulator on :13524 +./scripts/mevcommit_smoke.sh emulator + +# Terminal B: rbuilder pointing at it +./scripts/mevcommit_smoke.sh rbuilder /path/to/config.toml +``` + +Run `./scripts/mevcommit_smoke.sh help` for the full procedure, including expected log lines and metric checks. This is not in CI — CI doesn't carry a sidecar process; the script is for manual validation against new mev-commit releases or before deployment cuts. + +**Operational metrics** (Prometheus): + +- Bid flow: `mevcommit_bids_received_total`, `mevcommit_bids_accepted_total`, `mevcommit_bids_rejected_total{reason}` +- Slot state: `mevcommit_slot_preconf_count`, `mevcommit_slot_preconf_gas_reserved` +- Shutter fetch (build-time): `mevcommit_shutter_fetch_success_total`, `mevcommit_shutter_fetch_failures_total{reason}`, `mevcommit_shutter_fetch_duration_seconds` (histogram) +- Prefetch (background): `mevcommit_prefetch_success_total`, `mevcommit_prefetch_failure_total`, `mevcommit_prefetch_cache_hits_total`, `mevcommit_prefetch_cache_misses_total` +- Latency: `mevcommit_bid_eval_duration_seconds` (histogram) +- Transport: `mevcommit_grpc_reconnects_total` +- Slash guard: `mevcommit_blocks_suppressed_total{reason}` — **paging-level alert**: each increment is a slot deliberately lost to avoid an on-chain slash. Reasons: `missing_committed_txs`, `shutter_not_at_top_of_block`, `wrong_position`. + +--- + ### Reproducible builds You only need to set the `SOURCE_DATE_EPOCH` environment variable to ensure that the build is reproducible: diff --git a/crates/mevcommit-proto/Cargo.toml b/crates/mevcommit-proto/Cargo.toml new file mode 100644 index 000000000..2a26a63c3 --- /dev/null +++ b/crates/mevcommit-proto/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "mevcommit-proto" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +build = "build.rs" +description = "Rust gRPC bindings generated from upstream mev-commit (primev) protos. Pinned to v1.3.0-rc1 via the vendor/mev-commit git submodule." + +[dependencies] +tonic.workspace = true +prost.workspace = true + +[build-dependencies] +tonic-build = "0.13" diff --git a/crates/mevcommit-proto/build.rs b/crates/mevcommit-proto/build.rs new file mode 100644 index 000000000..742dc3e6b --- /dev/null +++ b/crates/mevcommit-proto/build.rs @@ -0,0 +1,270 @@ +use std::{fs, path::PathBuf}; + +const UPSTREAM_PROVIDER_PROTO: &str = + "../../vendor/mev-commit/p2p/rpc/providerapi/v1/providerapi.proto"; + +fn main() -> Result<(), Box> { + println!("cargo:rerun-if-changed={UPSTREAM_PROVIDER_PROTO}"); + println!("cargo:rerun-if-changed=build.rs"); + + let upstream = fs::read_to_string(UPSTREAM_PROVIDER_PROTO).map_err(|e| { + format!( + "failed to read {UPSTREAM_PROVIDER_PROTO}: {e}. \ + Did you run `git submodule update --init --recursive`?" + ) + })?; + + let stripped = strip_annotations(&upstream); + + let out_dir = PathBuf::from(std::env::var("OUT_DIR")?); + let proto_root = out_dir.join("proto"); + let proto_dir = proto_root.join("providerapi").join("v1"); + fs::create_dir_all(&proto_dir)?; + let proto_path = proto_dir.join("providerapi.proto"); + fs::write(&proto_path, &stripped)?; + + tonic_build::configure() + .build_server(false) + .compile_protos(&[proto_path.as_path()], &[proto_root.as_path()])?; + + Ok(()) +} + +/// Strip buf/openapi/google-api annotations from a `.proto` so it can be +/// compiled by `tonic-build` without requiring the upstream buf-managed +/// dependency tree. +/// +/// The annotations are only used for openapi docs and HTTP gateway codegen — +/// neither of which we need on the Rust gRPC client side. Service/method/ +/// message/enum/field shapes are preserved verbatim. +/// +/// Strips: +/// 1. `import "buf/..."`, `import "google/api/..."`, +/// `import "protoc-gen-openapiv2/..."` lines +/// 2. `option (qualified.name) = ...;` statements (file/message/method scope) +/// 3. Field-option `[ ... ]` blocks that contain any `(qualified.name)` token +fn strip_annotations(src: &str) -> String { + let no_imports: String = src + .lines() + .filter(|line| { + let t = line.trim_start(); + !(t.starts_with(r#"import "buf/"#) + || t.starts_with(r#"import "google/api/"#) + || t.starts_with(r#"import "protoc-gen-openapiv2/"#)) + }) + .collect::>() + .join("\n"); + + strip_extension_blocks(&no_imports) +} + +/// Char-level state machine. Walks `src`, tracks string/comment context so +/// braces/brackets inside literals don't confuse counting, and removes: +/// - `option (` ... `;` statements +/// - `[ ... ]` field-option blocks that contain a `(` token +fn strip_extension_blocks(src: &str) -> String { + let bytes = src.as_bytes(); + let mut out = String::with_capacity(src.len()); + let mut i = 0; + while i < bytes.len() { + // Pass through line comments + if bytes[i] == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' { + while i < bytes.len() && bytes[i] != b'\n' { + out.push(bytes[i] as char); + i += 1; + } + continue; + } + // Pass through block comments + if bytes[i] == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' { + out.push_str("/*"); + i += 2; + while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + out.push(bytes[i] as char); + i += 1; + } + if i + 1 < bytes.len() { + out.push_str("*/"); + i += 2; + } + continue; + } + // Pass through string literals + if bytes[i] == b'"' { + out.push('"'); + i += 1; + while i < bytes.len() && bytes[i] != b'"' { + if bytes[i] == b'\\' && i + 1 < bytes.len() { + out.push(bytes[i] as char); + out.push(bytes[i + 1] as char); + i += 2; + continue; + } + out.push(bytes[i] as char); + i += 1; + } + if i < bytes.len() { + out.push('"'); + i += 1; + } + continue; + } + + // `option (` — strip the whole statement up to and including the + // terminating `;`. Track brace nesting so embedded `{...}` is handled. + if starts_with_token_option_paren(bytes, i) { + i = skip_option_statement(bytes, i); + continue; + } + + // `[` — peek to see if this bracket block contains any `(`. If so, + // strip the whole `[...]`. Otherwise emit as-is. + if bytes[i] == b'[' && bracket_block_has_extension(bytes, i) { + i = skip_bracket_block(bytes, i); + continue; + } + + out.push(bytes[i] as char); + i += 1; + } + // Clean up empty-line runs created by stripping + collapse_blank_lines(&out) +} + +fn starts_with_token_option_paren(bytes: &[u8], i: usize) -> bool { + if i + 7 > bytes.len() { + return false; + } + if &bytes[i..i + 6] != b"option" { + return false; + } + if i > 0 { + let prev = bytes[i - 1]; + if prev.is_ascii_alphanumeric() || prev == b'_' { + return false; + } + } + let mut j = i + 6; + while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t') { + j += 1; + } + j < bytes.len() && bytes[j] == b'(' +} + +/// Returns the index after the terminating `;` of an `option (...) = ...;` +/// statement starting at `i`. +fn skip_option_statement(bytes: &[u8], mut i: usize) -> usize { + let mut brace_depth: i32 = 0; + let mut in_string = false; + while i < bytes.len() { + let c = bytes[i]; + if in_string { + if c == b'\\' && i + 1 < bytes.len() { + i += 2; + continue; + } + if c == b'"' { + in_string = false; + } + i += 1; + continue; + } + match c { + b'"' => in_string = true, + b'{' => brace_depth += 1, + b'}' => brace_depth -= 1, + b';' if brace_depth == 0 => return i + 1, + _ => {} + } + i += 1; + } + i +} + +/// Returns true if the `[...]` block starting at `i` (where `bytes[i] == '['`) +/// contains an extension-option reference (`(` before the matching `]`). +fn bracket_block_has_extension(bytes: &[u8], i: usize) -> bool { + let mut depth: i32 = 0; + let mut j = i; + let mut in_string = false; + let mut saw_paren = false; + while j < bytes.len() { + let c = bytes[j]; + if in_string { + if c == b'\\' && j + 1 < bytes.len() { + j += 2; + continue; + } + if c == b'"' { + in_string = false; + } + j += 1; + continue; + } + match c { + b'"' => in_string = true, + b'[' => depth += 1, + b']' => { + depth -= 1; + if depth == 0 { + return saw_paren; + } + } + b'(' if depth == 1 => saw_paren = true, + _ => {} + } + j += 1; + } + saw_paren +} + +/// Returns the index after the matching `]` of a `[...]` block starting at `i`. +fn skip_bracket_block(bytes: &[u8], mut i: usize) -> usize { + let mut depth: i32 = 0; + let mut in_string = false; + while i < bytes.len() { + let c = bytes[i]; + if in_string { + if c == b'\\' && i + 1 < bytes.len() { + i += 2; + continue; + } + if c == b'"' { + in_string = false; + } + i += 1; + continue; + } + match c { + b'"' => in_string = true, + b'[' => depth += 1, + b']' => { + depth -= 1; + if depth == 0 { + return i + 1; + } + } + _ => {} + } + i += 1; + } + i +} + +fn collapse_blank_lines(src: &str) -> String { + let mut out = String::with_capacity(src.len()); + let mut blank_run = 0; + for line in src.lines() { + if line.trim().is_empty() { + blank_run += 1; + if blank_run <= 1 { + out.push('\n'); + } + } else { + blank_run = 0; + out.push_str(line); + out.push('\n'); + } + } + out +} diff --git a/crates/mevcommit-proto/src/lib.rs b/crates/mevcommit-proto/src/lib.rs new file mode 100644 index 000000000..552077be4 --- /dev/null +++ b/crates/mevcommit-proto/src/lib.rs @@ -0,0 +1,16 @@ +//! Generated gRPC bindings for the mev-commit provider API. +//! +//! Source protos: `vendor/mev-commit/p2p/rpc/providerapi/v1/providerapi.proto` +//! (pinned via git submodule to upstream tag `v1.3.0-rc1`). +//! +//! The build script strips buf/openapi/google.api annotations before invoking +//! `tonic-build` so we don't need to vendor the buf-managed dependency tree +//! — annotations are openapi/HTTP-gateway metadata, not part of the gRPC ABI. + +pub mod providerapi { + pub mod v1 { + tonic::include_proto!("providerapi.v1"); + } +} + +pub use providerapi::v1 as provider; diff --git a/crates/rbuilder-primitives/src/lib.rs b/crates/rbuilder-primitives/src/lib.rs index 49848a74f..b998d8921 100644 --- a/crates/rbuilder-primitives/src/lib.rs +++ b/crates/rbuilder-primitives/src/lib.rs @@ -53,6 +53,12 @@ pub struct Metadata { pub refund_identity: Option
, /// `RawBundle` field, round-tripped through `Bundle`. Not consumed by rbuilder. pub disable_cross_region_sharing: bool, + /// Marks a transaction the builder has committed to including via a + /// mev-commit preconfirmation. Block builders bypass the + /// `simulation_too_low` profit filter for these so an unprofitable but + /// committed tx still lands in the block — losing a bit of MEV is + /// strictly better than the slash for omitting it. + pub is_preconf: bool, } impl Default for Metadata { @@ -74,6 +80,7 @@ impl Metadata { is_system: false, refund_identity: None, disable_cross_region_sharing: false, + is_preconf: false, } } @@ -88,6 +95,12 @@ impl Metadata { self.is_system = is_system; } + /// Set the `is_preconf` flag and return the metadata. + pub fn with_preconf(mut self, is_preconf: bool) -> Self { + self.is_preconf = is_preconf; + self + } + /// Set the refund identity and return the metadata. pub fn with_refund_identity(mut self, refund_identity: Option
) -> Self { self.set_refund_identity(refund_identity); @@ -110,7 +123,8 @@ impl InMemorySize for Metadata { mem::size_of::() + // received_at_timestamp mem::size_of::>() + // refund_identity mem::size_of::() + // is_system - mem::size_of::() // disable_cross_region_sharing + mem::size_of::() + // disable_cross_region_sharing + mem::size_of::() // is_preconf } } diff --git a/crates/rbuilder-primitives/src/mev_boost/mod.rs b/crates/rbuilder-primitives/src/mev_boost/mod.rs index 158412b8c..01d739670 100644 --- a/crates/rbuilder-primitives/src/mev_boost/mod.rs +++ b/crates/rbuilder-primitives/src/mev_boost/mod.rs @@ -218,6 +218,15 @@ impl ValidatorPreferences { None } } + + /// Returns true if the validator has opted into the Primev (mev-commit) + /// relay path. Returns false for any variant that doesn't carry the flag. + pub fn is_primev(&self) -> bool { + match self { + Self::Ultrasound(prefs) => prefs.is_primev(), + Self::Titan(_) => false, + } + } } /// Ultrasound validator preferences. @@ -231,6 +240,12 @@ pub struct UltrasoundValidatorPreferences { is_primev: bool, } +impl UltrasoundValidatorPreferences { + pub fn is_primev(&self) -> bool { + self.is_primev + } +} + /// Titan validator preferences. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] pub struct TitanValidatorPreferences { diff --git a/crates/rbuilder/Cargo.toml b/crates/rbuilder/Cargo.toml index 268a7b711..08bef7de0 100644 --- a/crates/rbuilder/Cargo.toml +++ b/crates/rbuilder/Cargo.toml @@ -116,6 +116,7 @@ shellexpand = "3.1.0" async-trait = "0.1.80" eth-sparse-mpt.workspace = true bid-scraper.workspace = true +mevcommit-proto.workspace = true sysperf.workspace = true crossbeam = "0.8.4" parking_lot.workspace = true diff --git a/crates/rbuilder/examples/mevcommit_runner.rs b/crates/rbuilder/examples/mevcommit_runner.rs new file mode 100644 index 000000000..197fd3136 --- /dev/null +++ b/crates/rbuilder/examples/mevcommit_runner.rs @@ -0,0 +1,208 @@ +//! Standalone runner for the mev-commit integration's runtime, with no +//! reth/CL/relay/proposer dependencies. Drives the gRPC bid stream + slot +//! rotation + (optionally) a synthetic slot so the evaluator's ACCEPT path +//! can fire without the full block-building pipeline. +//! +//! See `mevcommit-testing.md` at the repo root for the tier walkthrough. +//! +//! Tier 1 — connectivity (sidecar reachable): +//! cargo run --example mevcommit_runner -- \ +//! --grpc-addr http://127.0.0.1:13524 +//! +//! Tier 2 — same command + a bidder sending bids in another terminal. +//! Every bid will reject with `slot_not_primev` because no slot is injected. +//! +//! Tier 3 — inject a synthetic primev-flagged slot so bids whose block +//! number matches `--target-block` can ACCEPT: +//! cargo run --example mevcommit_runner -- \ +//! --grpc-addr http://127.0.0.1:13524 \ +//! --inject-slot --target-block 100 +//! +//! `--target-block` accepts either a single block (`100`) or an inclusive +//! range (`100-110`). In range mode, any bid whose `block_number` falls +//! inside [low, high] passes the block-match check. Useful when you don't +//! know exactly which block the bidder is targeting and want to sweep +//! several at once. +//! +//! Press Ctrl-C to shut down cleanly. + +use std::time::Duration; + +use clap::Parser; +use rbuilder::{ + live_builder::payload_events::MevBoostSlotData, + mevcommit::{ + slot_state::{self, SlotMeta}, + MevCommitConfig, MevCommitService, + }, +}; +use tokio::sync::broadcast; +use tokio_util::sync::CancellationToken; +use tracing::info; +use tracing_subscriber::EnvFilter; + +/// Either a single block (`Single(N)`) or an inclusive range +/// (`Range(low, high)`). Used by `--target-block` so the runner accepts +/// both forms. +#[derive(Clone, Debug)] +enum BlockSpec { + Single(u64), + Range(u64, u64), +} + +impl BlockSpec { + fn as_range(&self) -> (u64, u64) { + match self { + Self::Single(n) => (*n, *n), + Self::Range(low, high) => (*low, *high), + } + } + + fn describe(&self) -> String { + match self { + Self::Single(n) => format!("{n}"), + Self::Range(low, high) => format!("{low}..={high}"), + } + } +} + +fn parse_block_spec(s: &str) -> Result { + if let Some((low_s, high_s)) = s.split_once('-') { + let low: u64 = low_s + .trim() + .parse() + .map_err(|e| format!("invalid range low '{low_s}': {e}"))?; + let high: u64 = high_s + .trim() + .parse() + .map_err(|e| format!("invalid range high '{high_s}': {e}"))?; + if low > high { + return Err(format!("range low ({low}) must be <= high ({high})")); + } + Ok(BlockSpec::Range(low, high)) + } else { + let n: u64 = s + .trim() + .parse() + .map_err(|e| format!("invalid block number '{s}': {e}"))?; + Ok(BlockSpec::Single(n)) + } +} + +#[derive(Parser, Debug)] +#[command(about = "Drive the rbuilder mev-commit integration in isolation (no reth/CL/relay).")] +struct Args { + /// gRPC address of the co-located mev-commit provider sidecar. + #[arg(long, default_value = "http://127.0.0.1:13524")] + grpc_addr: String, + + /// `shutter_fetch_timeout_ms` to use for the background prefetch task. + #[arg(long, default_value_t = 2000)] + shutter_fetch_timeout_ms: u64, + + /// Inject a synthetic slot so the evaluator can ACCEPT bids. Without + /// this, `SlotPreconfState` stays at the empty placeholder (is_primev + /// = false) and every bid rejects as `slot_not_primev` (Tier 2). + #[arg(long)] + inject_slot: bool, + + /// Target block — either a single block number (`100`) or an inclusive + /// range (`100-110`). Bids whose `block_number` falls inside the + /// (range or single-element) interval pass the block-match check. + #[arg(long, default_value = "100", value_parser = parse_block_spec)] + target_block: BlockSpec, + + /// Block gas limit for the injected slot. + #[arg(long, default_value_t = 30_000_000)] + block_gas_limit: u64, + + /// Periodic status print interval in seconds. + #[arg(long, default_value_t = 5)] + status_interval_secs: u64, +} + +#[tokio::main] +async fn main() -> eyre::Result<()> { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| { + // Default to info,rbuilder=debug so the bid stream / prefetch + // logs are visible. + EnvFilter::new("info,rbuilder=debug") + })) + .init(); + + let args = Args::parse(); + info!(?args, "starting mev-commit runner"); + + let config = MevCommitConfig { + enabled: true, + provider_grpc_addr: args.grpc_addr.clone(), + shutter_fetch_timeout_ms: args.shutter_fetch_timeout_ms, + ..MevCommitConfig::default() + }; + + let cancel = CancellationToken::new(); + let slot_state = slot_state::shared_empty(); + + // The rotation loop consumes this receiver. We never broadcast on the + // sender in this runner — slot state is mutated manually via + // `slot_state::rotate` below (Tier 3 path). + let (_slot_tx, slot_rx) = broadcast::channel::(8); + + let runtime = + MevCommitService::new(config).start(slot_state.clone(), cancel.clone(), slot_rx)?; + + if args.inject_slot { + let block_range = args.target_block.as_range(); + slot_state::rotate( + &slot_state, + SlotMeta::block_range(1, block_range, args.block_gas_limit, true), + ); + info!( + target_block = %args.target_block.describe(), + block_gas_limit = args.block_gas_limit, + "injected synthetic primev slot — evaluator ACCEPT path is reachable" + ); + } else { + info!( + "no slot injected — every bid will reject as `slot_not_primev` (Tier 1/2 mode). \ + Pass --inject-slot to enable ACCEPT path." + ); + } + + // Periodic status snapshot for human-readable progress without + // hitting the Prometheus endpoint. + let status_state = slot_state.clone(); + let status_cancel = cancel.clone(); + let status_interval = Duration::from_secs(args.status_interval_secs); + tokio::spawn(async move { + let mut tick = tokio::time::interval(status_interval); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + tokio::select! { + _ = status_cancel.cancelled() => return, + _ = tick.tick() => { + let snap = status_state.read(); + info!( + slot = snap.slot(), + target_block = snap.target_block(), + is_primev = snap.is_primev(), + commitments = snap.commitment_count(), + reserved_gas = snap.reserved_gas(), + "slot state snapshot" + ); + } + } + } + }); + + tokio::signal::ctrl_c().await?; + info!("received Ctrl-C; cancelling tasks"); + cancel.cancel(); + + for handle in runtime.tasks { + let _ = handle.await; + } + info!("shutdown complete"); + Ok(()) +} diff --git a/crates/rbuilder/src/backtest/build_block/synthetic_orders.rs b/crates/rbuilder/src/backtest/build_block/synthetic_orders.rs index 3f337eef7..fba730d82 100644 --- a/crates/rbuilder/src/backtest/build_block/synthetic_orders.rs +++ b/crates/rbuilder/src/backtest/build_block/synthetic_orders.rs @@ -99,6 +99,7 @@ impl SyntheticOrdersSource { is_system: false, refund_identity: None, disable_cross_region_sharing: false, + is_preconf: false, }, dropping_tx_hashes: Default::default(), refund: None, diff --git a/crates/rbuilder/src/building/block_orders/order_priority.rs b/crates/rbuilder/src/building/block_orders/order_priority.rs index 5d1dcf356..37c5804ad 100644 --- a/crates/rbuilder/src/building/block_orders/order_priority.rs +++ b/crates/rbuilder/src/building/block_orders/order_priority.rs @@ -228,6 +228,30 @@ impl OrderLengthThreeCmp { } } +/// mev-commit preconf orders MUST come out of the priority queue first so +/// the OrderingBuilder commits them before any other order. This honors the +/// Shutter protocol invariant that decrypted txs occupy the top of the +/// block — otherwise the builder could observe a decrypted tx and reorder +/// other orders around it, undermining the anti-MEV property the bidder +/// paid for. +struct PreconfFirstCmp {} +impl PreconfFirstCmp { + #[inline] + fn eq(a: &SimulatedOrder, b: &SimulatedOrder) -> bool { + a.order.metadata().is_preconf == b.order.metadata().is_preconf + } + + #[inline] + fn cmp(a: &SimulatedOrder, b: &SimulatedOrder) -> Ordering { + // `true > false` ⇒ preconf orders are Greater ⇒ popped first by the + // max-heap priority queue. + a.order + .metadata() + .is_preconf + .cmp(&b.order.metadata().is_preconf) + } +} + /// Breaks ties deterministically if all other orderings gave the same result struct OrderIDCmp {} impl OrderIDCmp { @@ -241,11 +265,15 @@ impl OrderIDCmp { } } -create_order_priority!(OrderMevGasPricePriority((OrderMevGasPricePriorityCmp,uses_getter))); -create_order_priority!(OrderMaxProfitPriority((OrderMaxProfitPriorityCmp,uses_getter))); -create_order_priority!(OrderTypePriority((OrderTypeCmp,plain),(OrderMaxProfitPriorityCmp,uses_getter))); -create_order_priority!(OrderLengthThreeMaxProfitPriority((OrderLengthThreeCmp,plain),(OrderMaxProfitPriorityCmp,uses_getter))); -create_order_priority!(OrderLengthThreeMevGasPricePriority((OrderLengthThreeCmp,plain),(OrderMevGasPricePriorityCmp,uses_getter))); +// PreconfFirstCmp is chained as the FIRST tier in every OrderPriority so +// mev-commit preconfirmed orders always outrank non-preconf ones regardless +// of which downstream priority strategy is configured. See PreconfFirstCmp +// above for rationale. +create_order_priority!(OrderMevGasPricePriority((PreconfFirstCmp,plain),(OrderMevGasPricePriorityCmp,uses_getter))); +create_order_priority!(OrderMaxProfitPriority((PreconfFirstCmp,plain),(OrderMaxProfitPriorityCmp,uses_getter))); +create_order_priority!(OrderTypePriority((PreconfFirstCmp,plain),(OrderTypeCmp,plain),(OrderMaxProfitPriorityCmp,uses_getter))); +create_order_priority!(OrderLengthThreeMaxProfitPriority((PreconfFirstCmp,plain),(OrderLengthThreeCmp,plain),(OrderMaxProfitPriorityCmp,uses_getter))); +create_order_priority!(OrderLengthThreeMevGasPricePriority((PreconfFirstCmp,plain),(OrderLengthThreeCmp,plain),(OrderMevGasPricePriorityCmp,uses_getter))); #[cfg(test)] mod test { @@ -331,6 +359,25 @@ mod test { None, )) } + + /// Same as `create_sim_order` but stamps `is_preconf = true` on the + /// inner tx metadata. Only valid for `OrderType::MempoolTx` (preconfs + /// are always single-tx mempool orders in the Shutter v1 path). + fn create_preconf_sim_order( + &mut self, + full_profit: u64, + non_mempool_profit: u64, + gas: u64, + ) -> Arc { + let nonce = self.create_nonce(); + let mut mempool_tx = self.data_gen.create_mempool_tx(nonce); + mempool_tx.tx_with_blobs.metadata.is_preconf = true; + Arc::new(SimulatedOrder::new( + Arc::new(rbuilder_primitives::Order::Tx(mempool_tx)), + SimValue::new_test(U256::from(full_profit), U256::from(non_mempool_profit), gas), + None, + )) + } } const HIGH_PROFIT: u64 = 10_000; @@ -513,4 +560,50 @@ mod test { &sim_size_1_high, ); } + + /// is_preconf orders must outrank non-preconf regardless of profit. This + /// encodes the Shutter top-of-block protocol invariant — the priority + /// queue must pop preconf orders first so the OrderingBuilder commits + /// them before any non-preconf order. + #[test] + fn preconf_outranks_high_profit_under_max_profit_priority() { + let mut ctx = TestContext::default(); + let preconf_low_profit = ctx.create_preconf_sim_order(LOW_PROFIT, LOW_PROFIT, NORMAL_GAS); + let non_preconf_high_profit = + ctx.create_sim_order(HIGH_PROFIT, HIGH_PROFIT, NORMAL_GAS, OrderType::MempoolTx); + // non_preconf < preconf ⇒ preconf pops first from the max-heap. + assert_is_less::>( + &non_preconf_high_profit, + &preconf_low_profit, + ); + } + + #[test] + fn preconf_outranks_high_gas_price_under_mev_gas_price_priority() { + let mut ctx = TestContext::default(); + let preconf_low = ctx.create_preconf_sim_order(LOW_PROFIT, LOW_PROFIT, NORMAL_GAS); + let non_preconf_high_gas_price = + ctx.create_sim_order(LOW_PROFIT, LOW_PROFIT, SUPER_LOW_GAS, OrderType::MempoolTx); + assert_is_less::>( + &non_preconf_high_gas_price, + &preconf_low, + ); + } + + #[test] + fn preconf_outranks_bundle_under_order_type_priority() { + // Bundles normally outrank mempool txs; preconf must override that. + let mut ctx = TestContext::default(); + let preconf_mempool = ctx.create_preconf_sim_order(LOW_PROFIT, LOW_PROFIT, NORMAL_GAS); + let non_preconf_bundle = ctx.create_sim_order( + HIGH_PROFIT, + HIGH_PROFIT, + NORMAL_GAS, + OrderType::BundleLength1, + ); + assert_is_less::>( + &non_preconf_bundle, + &preconf_mempool, + ); + } } diff --git a/crates/rbuilder/src/building/builders/ordering_builder.rs b/crates/rbuilder/src/building/builders/ordering_builder.rs index a44a30379..3a7a2ae81 100644 --- a/crates/rbuilder/src/building/builders/ordering_builder.rs +++ b/crates/rbuilder/src/building/builders/ordering_builder.rs @@ -412,7 +412,12 @@ impl OrderingBuilderContext { &sim_order, #[allow(clippy::result_large_err)] &|sim_result| { - if !sim_order.order.metadata().is_system { + let meta = sim_order.order.metadata(); + // System orders and mev-commit preconfirmed txs bypass + // the `simulation_too_low` profit filter — the former + // are payouts, the latter must be included to avoid an + // on-chain slash. + if !meta.is_system && !meta.is_preconf { simulation_too_low::(&sim_order.sim_value, sim_result) } else { Ok(()) diff --git a/crates/rbuilder/src/lib.rs b/crates/rbuilder/src/lib.rs index 02708706f..0fb2b6daa 100644 --- a/crates/rbuilder/src/lib.rs +++ b/crates/rbuilder/src/lib.rs @@ -4,6 +4,7 @@ pub mod building; pub mod integration; pub mod live_builder; pub mod mev_boost; +pub mod mevcommit; pub mod provider; pub mod roothash; pub mod telemetry; diff --git a/crates/rbuilder/src/live_builder/base_config.rs b/crates/rbuilder/src/live_builder/base_config.rs index 2cc0a26c2..820421667 100644 --- a/crates/rbuilder/src/live_builder/base_config.rs +++ b/crates/rbuilder/src/live_builder/base_config.rs @@ -280,6 +280,8 @@ impl BaseConfig { order_journal_observer_factory: Box::new(NullOrderJournalObserverFactory {}), mempool_detector: Arc::new(MempoolTxsDetector::new()), + + mevcommit_slot_data_tx: None, }) } diff --git a/crates/rbuilder/src/live_builder/block_output/relay_submit.rs b/crates/rbuilder/src/live_builder/block_output/relay_submit.rs index 5937f17bf..9308123dc 100644 --- a/crates/rbuilder/src/live_builder/block_output/relay_submit.rs +++ b/crates/rbuilder/src/live_builder/block_output/relay_submit.rs @@ -628,6 +628,12 @@ pub struct RelaySubmitSinkFactory { /// We expect to get bids only for this specific relay sets. relay_sets: Vec, submission_policy: Box, + /// Optional pre-relay verify gate. Installed by the mev-commit + /// integration to suppress blocks that would violate an outstanding + /// preconfirmation commitment (slash protection). `None` for builds + /// without mev-commit, in which case `create_builder_sink` behaves + /// exactly as before. + commitment_gate: Option>, } impl RelaySubmitSinkFactory { @@ -642,9 +648,19 @@ impl RelaySubmitSinkFactory { relays, relay_sets, submission_policy, + commitment_gate: None, } } + /// Install the mev-commit pre-relay verify gate. After this returns, every + /// `create_builder_sink` will wrap the resulting sink with the gate. + pub fn set_commitment_gate( + &mut self, + gate: Arc, + ) { + self.commitment_gate = Some(gate); + } + /// Creates a run_submit_to_relays_job task per RelaySet and returns a RelaySetDispatcher that will submit the blocks to associated task. /// Filters out RelaySet with no registered relays. pub fn create_builder_sink( @@ -706,7 +722,15 @@ impl RelaySubmitSinkFactory { ); } } - Box::new(RelaySetDispatcher::new(sinks)) + let dispatcher: Box = + Box::new(RelaySetDispatcher::new(sinks)); + match &self.commitment_gate { + Some(gate) => Box::new(crate::mevcommit::verify_gate::GatedRelaySink::new( + dispatcher, + gate.clone(), + )), + None => dispatcher, + } } } diff --git a/crates/rbuilder/src/live_builder/block_output/unfinished_block_processing.rs b/crates/rbuilder/src/live_builder/block_output/unfinished_block_processing.rs index 7fe3ba2fc..b4df80596 100644 --- a/crates/rbuilder/src/live_builder/block_output/unfinished_block_processing.rs +++ b/crates/rbuilder/src/live_builder/block_output/unfinished_block_processing.rs @@ -100,6 +100,16 @@ impl UnfinishedBuiltBlocksInputFactory

{ } } + /// Install the mev-commit pre-relay verify gate on the inner sink factory. + /// Every block produced by `create_sink` for the lifetime of this factory + /// will pass through the gate. + pub fn set_commitment_gate( + &mut self, + gate: Arc, + ) { + self.block_sink_factory.set_commitment_gate(gate); + } + pub fn create_sink( &mut self, slot_data: MevBoostSlotData, diff --git a/crates/rbuilder/src/live_builder/config.rs b/crates/rbuilder/src/live_builder/config.rs index 5de658107..a62d94429 100644 --- a/crates/rbuilder/src/live_builder/config.rs +++ b/crates/rbuilder/src/live_builder/config.rs @@ -144,6 +144,11 @@ pub struct Config { #[serde(flatten)] pub true_block_value_bidding_service_config: TrueBlockValueBiddingServiceConfigToml, + + /// mev-commit (primev) provider integration config. Optional — when + /// absent or `enabled = false`, the integration is dormant. + #[serde(default)] + pub mevcommit: crate::mevcommit::MevCommitConfig, } const DEFAULT_SLOT_DELTA_TO_START_BIDDING_MS: i64 = -8000; @@ -530,7 +535,7 @@ impl LiveBuilderConfig for Config { create_wallet_balance_watcher(provider.clone(), &self.base_config).await?; let ( - sink_factory, + mut sink_factory, slot_info_provider, adjustment_fee_payers, optimistic_v3_server_join_handle, @@ -546,6 +551,29 @@ impl LiveBuilderConfig for Config { ) .await?; + // Pre-build the mev-commit slot state if enabled, so we can install + // the pre-relay verify gate on `sink_factory` before it's consumed + // by `create_builder_from_sink`. The same slot state is later + // threaded into MevCommitService::start, so the gate and the + // bid-stream / rotation tasks share one source of truth. + let mevcommit_slot_state = if self.mevcommit.enabled { + let st = crate::mevcommit::slot_state::shared_empty(); + if self.mevcommit.enable_verify_gate { + sink_factory.set_commitment_gate(Arc::new(crate::mevcommit::SlotStateGate::new( + st.clone(), + ))); + } else { + tracing::warn!( + "mevcommit.enable_verify_gate = false — pre-relay slash protection is OFF. \ + Only safe for Shutter-only deployments without PositionConstraints; \ + plaintext bids or any explicit PositionConstraint can slash." + ); + } + Some(st) + } else { + None + }; + let mut live_builder = create_builder_from_sink( &self.base_config, &self.l1_config, @@ -557,13 +585,67 @@ impl LiveBuilderConfig for Config { abort_token.clone(), ) .await?; + let builder_configs = self.live_builders()?; let builders = create_builders( - self.live_builders()?, + builder_configs.clone(), self.base_config.max_order_execution_duration_warning(), ); if let Some(optimistic_v3_server_join_handle) = optimistic_v3_server_join_handle { live_builder.add_critical_task(optimistic_v3_server_join_handle); } + let builders = if let Some(slot_state) = mevcommit_slot_state { + // Broadcast capacity: small. Slot events fire roughly once per + // proposal; lagged receivers degrade gracefully (best-effort tap). + let (slot_tx, slot_rx) = + tokio::sync::broadcast::channel::(8); + live_builder.mevcommit_slot_data_tx = Some(slot_tx); + let runtime = crate::mevcommit::MevCommitService::new(self.mevcommit.clone()).start( + slot_state, + live_builder.global_cancellation.clone(), + slot_rx, + )?; + for handle in runtime.tasks { + live_builder.add_critical_task(handle); + } + // Wrap each builder with the preconf enforcer so build-time + // Shutter fetches happen for accepted commitments. The wrapper + // is a no-op when no commitments are accepted for the current + // block. + // + // ParallelBuilder is intentionally skipped: its multi-group + // scheduling doesn't yet thread preconf orders across groups, + // and the wrap would only partially honor the top-of-block + // invariant. See `mevcommit-integration-plan.md` §13. The + // verify gate still applies to its output, so any violation + // gets suppressed pre-relay. + let orderpool_sender = live_builder.orderpool_sender.clone(); + let cfg = self.mevcommit.clone(); + builders + .into_iter() + .zip(builder_configs.iter()) + .map(|(inner, builder_cfg)| { + if matches!(builder_cfg.builder, SpecificBuilderConfig::ParallelBuilder(_)) { + warn!( + builder_name = %builder_cfg.name, + "ParallelBuilder is not wrapped by PreconfEnforcingAlgorithm in v1; \ + preconf orders may not be force-included or placed top-of-block for this builder. \ + The pre-relay verify gate will still suppress any violations." + ); + inner + } else { + Arc::new(crate::mevcommit::PreconfEnforcingAlgorithm::new( + inner, + cfg.clone(), + runtime.slot_state.clone(), + runtime.shutter.clone(), + orderpool_sender.clone(), + )) as Arc> + } + }) + .collect() + } else { + builders + }; Ok(live_builder.with_builders(builders)) } @@ -753,6 +835,7 @@ impl Default for Config { ], true_block_value_bidding_service_config: TrueBlockValueBiddingServiceConfigToml::default(), + mevcommit: crate::mevcommit::MevCommitConfig::default(), } } } diff --git a/crates/rbuilder/src/live_builder/mod.rs b/crates/rbuilder/src/live_builder/mod.rs index f18e9fac7..fdcb5d217 100644 --- a/crates/rbuilder/src/live_builder/mod.rs +++ b/crates/rbuilder/src/live_builder/mod.rs @@ -149,6 +149,14 @@ where pub order_journal_observer_factory: Box, pub mempool_detector: Arc, + + /// Optional broadcast tap for `MevBoostSlotData`. When the mev-commit + /// integration is enabled, `Config::new_builder` installs a sender here + /// whose subscribers (e.g. `MevCommitService`) receive each slot's data + /// at the same moment the main building loop does. `None` means no + /// subscribers are interested. + pub mevcommit_slot_data_tx: + Option>, } impl

LiveBuilder

@@ -269,6 +277,11 @@ where ready_to_build.store(true, Ordering::Relaxed); while let Some(payload) = payload_events_channel.recv().await { + if let Some(tx) = &self.mevcommit_slot_data_tx { + // No subscribers and lagged-receiver errors are both + // non-fatal: mev-commit is an optional sidecar consumer. + let _ = tx.send(payload.clone()); + } let blocklist = self.blocklist_provider.get_blocklist()?; if blocklist.contains(&payload.fee_recipient()) { warn!( diff --git a/crates/rbuilder/src/mevcommit/bid_evaluator.rs b/crates/rbuilder/src/mevcommit/bid_evaluator.rs new file mode 100644 index 000000000..2b9052522 --- /dev/null +++ b/crates/rbuilder/src/mevcommit/bid_evaluator.rs @@ -0,0 +1,523 @@ +//! Shutterised bid evaluator. +//! +//! Five structural checks (the only checks possible while the tx body is +//! still Shutter-encrypted). Decision and reservation happen under the +//! `SlotPreconfState` write lock so V5 (double-commitment) and V6 +//! (slot-boundary race) cannot interleave. +//! +//! Plan reference: `mevcommit-integration-plan.md` §5.2 / §10. +//! +//! Eon-id validation is intentionally skipped in v1 — we trust the sidecar +//! to filter eons it doesn't know about. + +use std::time::Instant; + +use mevcommit_proto::provider::{position_constraint::Basis as ProtoBasis, PositionConstraint}; + +use super::{ + config::MevCommitConfig, + proto_conv::IncomingBid, + slot_state::{ + pending_cache, CommitmentKind, PreconfCommitment, ReservationError, SharedDecryptedCache, + SharedSlotState, SlotPreconfState, + }, +}; + +/// Outcome of evaluating one bid. +#[derive(Debug, Clone)] +pub enum BidDecision { + /// Bid accepted; the returned handle is the cache slot the background + /// prefetch task should write into, and the build-time enforcer reads + /// from. Shared with the just-stored `PreconfCommitment.decrypted`. + Accept(SharedDecryptedCache), + Reject(RejectReason), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RejectReason { + /// The slot's validator preferences don't include primev. We can't honor + /// a commitment in a slot we won't be allowed to deliver. + SlotNotPrimev, + /// Bid targets a different block than the current slot. + InvalidTargetBlock, + /// Bid count cap for the slot reached. + BidCountCap, + /// Cumulative reserved gas would exceed the slot's preconf budget. + GasBudgetExceeded, + /// An absolute position constraint conflicts with an already-accepted + /// commitment. + PositionInfeasible, + /// A commitment with the same `committed_tx_hash` was already accepted + /// this slot. Likely a sidecar retry; bid is dropped at this level so we + /// don't double-reserve gas or stamp a slot twice. + DuplicateCommitment, +} + +impl RejectReason { + /// Stable label used by metrics / structured logs. + pub fn label(&self) -> &'static str { + match self { + Self::SlotNotPrimev => "slot_not_primev", + Self::InvalidTargetBlock => "invalid_target_block", + Self::BidCountCap => "bid_count_cap", + Self::GasBudgetExceeded => "gas_budget_exceeded", + Self::PositionInfeasible => "position_infeasible", + Self::DuplicateCommitment => "duplicate_commitment", + } + } +} + +impl From for RejectReason { + fn from(value: ReservationError) -> Self { + match value { + ReservationError::SlotBoundaryCrossed { .. } => Self::InvalidTargetBlock, + ReservationError::BidCountCap { .. } => Self::BidCountCap, + ReservationError::GasBudgetExceeded { .. } => Self::GasBudgetExceeded, + ReservationError::DuplicateCommitment { .. } => Self::DuplicateCommitment, + } + } +} + +pub struct BidEvaluator { + cfg: MevCommitConfig, +} + +impl BidEvaluator { + pub fn new(cfg: MevCommitConfig) -> Self { + Self { cfg } + } + + /// Atomic check-and-reserve. Takes the `SlotPreconfState` write lock for + /// the full sequence — this is intentional, see V5/V6 in the plan. + /// + /// The window is short: 5 structural checks, no I/O. The lock is also + /// held by the slot-rotation task during atomic replacement, so a bid + /// that observed the old state cannot leak into the new slot. + pub fn evaluate(&self, bid: &IncomingBid, slot_state: &SharedSlotState) -> BidDecision { + let mut state = slot_state.write(); + + // Check 1 — slot eligibility + if !state.is_primev() { + return BidDecision::Reject(RejectReason::SlotNotPrimev); + } + + // Check 2 — block number is within the slot's accepted range. In + // production the range collapses to `(target_block, target_block)` + // so this is strict equality; the runner widens it for multi-block + // sweeps. + if !state.is_valid_block(bid.block_number) { + return BidDecision::Reject(RejectReason::InvalidTargetBlock); + } + + // Check 3 — bid count cap (also re-checked by try_reserve, but giving + // a clean reason here improves metric quality) + if state.commitment_count() >= self.cfg.max_preconf_bids_per_slot { + return BidDecision::Reject(RejectReason::BidCountCap); + } + + // Check 4 — gas budget + let gas_cap = preconf_gas_cap(&state, &self.cfg); + if state + .reserved_gas() + .saturating_add(self.cfg.shutter_assumed_gas_per_bid) + > gas_cap + { + return BidDecision::Reject(RejectReason::GasBudgetExceeded); + } + + // Check 5 — position feasibility + if let Some(new_pc) = bid.position_constraint.as_ref() { + if !can_satisfy_position(new_pc, state.commitments()) { + return BidDecision::Reject(RejectReason::PositionInfeasible); + } + } + + // All checks passed — reserve the commitment under the same lock. + let cache = pending_cache(); + let commitment = PreconfCommitment { + kind: CommitmentKind::Shutterised { + eon_id: bid.shutter.eon_id, + identity_prefix: bid.shutter.identity_prefix.clone(), + }, + bid_digest: bid.bid_digest.clone(), + committed_tx_hash: bid.committed_tx_hash, + bid_amount: bid.bid_amount, + block_number: bid.block_number, + position_constraint: bid.position_constraint.clone(), + bidder: bid.bidder_address, + dispatch_timestamp: now_unix_nanos(), + accepted_at: Instant::now(), + reserved_gas: self.cfg.shutter_assumed_gas_per_bid, + decrypted: cache.clone(), + }; + + match state.try_reserve(commitment, self.cfg.max_preconf_bids_per_slot, gas_cap) { + Ok(()) => BidDecision::Accept(cache), + Err(err) => BidDecision::Reject(err.into()), + } + } +} + +fn preconf_gas_cap(state: &SlotPreconfState, cfg: &MevCommitConfig) -> u64 { + let block_gas_limit = state.block_gas_limit() as f64; + let cap = block_gas_limit * cfg.max_preconf_gas_fraction; + cap.max(0.0) as u64 +} + +/// Position-feasibility check for ABSOLUTE positions only. +/// +/// Two `Basis::Absolute` commitments with the same `(anchor, value)` cannot +/// both be honored (they'd occupy the same slot). Percentile / gas-percentile +/// constraints can in principle accommodate multiple bids at the same value +/// (rounding semantics differ); their real feasibility is decided at block +/// build time by the enforcement gate, so we accept them optimistically here. +fn can_satisfy_position(new_pc: &PositionConstraint, existing: &[PreconfCommitment]) -> bool { + if !is_absolute(new_pc) { + return true; + } + !existing + .iter() + .any(|c| match c.position_constraint.as_ref() { + Some(epc) => { + is_absolute(epc) && epc.anchor == new_pc.anchor && epc.value == new_pc.value + } + None => false, + }) +} + +fn is_absolute(pc: &PositionConstraint) -> bool { + pc.basis == ProtoBasis::Absolute as i32 +} + +fn now_unix_nanos() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mevcommit::{ + proto_conv::{IncomingBid, ShutterPayload}, + slot_state::{self, SharedSlotState, SlotMeta}, + }; + use alloy_primitives::{Address, Bytes, TxHash, U256}; + use mevcommit_proto::provider::{ + position_constraint::{Anchor, Basis}, + PositionConstraint, + }; + + /// Block gas limit chosen so that with the default 50% fraction the + /// preconf gas cap is exactly 10M — easy to reason about in tests. + const TEST_BLOCK_GAS_LIMIT: u64 = 20_000_000; + const TARGET_BLOCK: u64 = 12_345_678; + + fn primev_state() -> SharedSlotState { + let state = slot_state::shared_empty(); + slot_state::rotate( + &state, + SlotMeta::single_block(1, TARGET_BLOCK, TEST_BLOCK_GAS_LIMIT, true), + ); + state + } + + fn non_primev_state() -> SharedSlotState { + let state = slot_state::shared_empty(); + slot_state::rotate( + &state, + SlotMeta::single_block(1, TARGET_BLOCK, TEST_BLOCK_GAS_LIMIT, false), + ); + state + } + + fn primev_range_state(low: u64, high: u64) -> SharedSlotState { + let state = slot_state::shared_empty(); + slot_state::rotate( + &state, + SlotMeta::block_range(1, (low, high), TEST_BLOCK_GAS_LIMIT, true), + ); + state + } + + fn fixture_bid(block_number: u64, hash_byte: u8) -> IncomingBid { + IncomingBid { + bid_digest: Bytes::from(vec![0xab; 32]), + committed_tx_hash: TxHash::from([hash_byte; 32]), + bid_amount: U256::from(1u64), + block_number, + bidder_address: Address::from([hash_byte; 20]), + decay_start_timestamp: 1, + decay_end_timestamp: 2, + reverting_tx_hashes: vec![], + shutter: ShutterPayload { + eon_id: 7, + identity_prefix: Bytes::from(vec![0u8; 32]), + encrypted_tx: Bytes::from(vec![0xff]), + }, + position_constraint: None, + } + } + + fn default_cfg() -> MevCommitConfig { + MevCommitConfig { + enabled: true, + ..MevCommitConfig::default() + } + } + + fn pc(anchor: Anchor, basis: Basis, value: i32) -> PositionConstraint { + PositionConstraint { + anchor: anchor as i32, + basis: basis as i32, + value, + } + } + + #[test] + fn accept_populates_slot_state() { + let evaluator = BidEvaluator::new(default_cfg()); + let state = primev_state(); + let bid = fixture_bid(TARGET_BLOCK, 0x01); + + let decision = evaluator.evaluate(&bid, &state); + assert!(matches!(decision, BidDecision::Accept(_))); + + let snap = state.read(); + assert_eq!(snap.commitment_count(), 1); + assert_eq!( + snap.reserved_gas(), + default_cfg().shutter_assumed_gas_per_bid + ); + } + + #[test] + fn rejects_non_primev_slot() { + let evaluator = BidEvaluator::new(default_cfg()); + let state = non_primev_state(); + let bid = fixture_bid(TARGET_BLOCK, 0x01); + + let decision = evaluator.evaluate(&bid, &state); + assert!(matches!( + decision, + BidDecision::Reject(RejectReason::SlotNotPrimev) + )); + assert_eq!(state.read().commitment_count(), 0); + } + + #[test] + fn rejects_wrong_target_block() { + let evaluator = BidEvaluator::new(default_cfg()); + let state = primev_state(); + let bid = fixture_bid(TARGET_BLOCK + 1, 0x01); + + let decision = evaluator.evaluate(&bid, &state); + assert!(matches!( + decision, + BidDecision::Reject(RejectReason::InvalidTargetBlock) + )); + assert_eq!(state.read().commitment_count(), 0); + } + + #[test] + fn rejects_when_bid_count_cap_reached() { + let mut cfg = default_cfg(); + cfg.max_preconf_bids_per_slot = 1; + let evaluator = BidEvaluator::new(cfg); + let state = primev_state(); + + // First bid lands fine. + assert!(matches!( + evaluator.evaluate(&fixture_bid(TARGET_BLOCK, 0x01), &state), + BidDecision::Accept(_) + )); + // Second bid hits the cap. + let decision = evaluator.evaluate(&fixture_bid(TARGET_BLOCK, 0x02), &state); + assert!(matches!( + decision, + BidDecision::Reject(RejectReason::BidCountCap) + )); + assert_eq!(state.read().commitment_count(), 1); + } + + #[test] + fn rejects_when_gas_budget_exceeded() { + let mut cfg = default_cfg(); + // Cap = 20_000_000 * 0.5 = 10_000_000. Each bid reserves 200_000. + // 50 bids fit exactly; the 51st blows the cap. + cfg.max_preconf_bids_per_slot = 100; // ensure the count cap isn't the limit + let evaluator = BidEvaluator::new(cfg.clone()); + let state = primev_state(); + + for i in 0..50_u8 { + let bid = fixture_bid(TARGET_BLOCK, i); + assert!(matches!( + evaluator.evaluate(&bid, &state), + BidDecision::Accept(_) + )); + } + let overflow_bid = fixture_bid(TARGET_BLOCK, 200); + let decision = evaluator.evaluate(&overflow_bid, &state); + assert!(matches!( + decision, + BidDecision::Reject(RejectReason::GasBudgetExceeded) + )); + assert_eq!(state.read().commitment_count(), 50); + assert_eq!( + state.read().reserved_gas(), + 50 * cfg.shutter_assumed_gas_per_bid, + ); + } + + #[test] + fn rejects_position_infeasible_for_duplicate_absolute_top_zero() { + let evaluator = BidEvaluator::new(default_cfg()); + let state = primev_state(); + + let mut bid_a = fixture_bid(TARGET_BLOCK, 0x01); + bid_a.position_constraint = Some(pc(Anchor::Top, Basis::Absolute, 0)); + assert!(matches!( + evaluator.evaluate(&bid_a, &state), + BidDecision::Accept(_) + )); + + let mut bid_b = fixture_bid(TARGET_BLOCK, 0x02); + bid_b.position_constraint = Some(pc(Anchor::Top, Basis::Absolute, 0)); + let decision = evaluator.evaluate(&bid_b, &state); + assert!(matches!( + decision, + BidDecision::Reject(RejectReason::PositionInfeasible) + )); + assert_eq!(state.read().commitment_count(), 1); + } + + /// Two Absolute commitments at the same anchor but different values are + /// both honorable (different positions). Evaluator must accept. + #[test] + fn allows_distinct_absolute_positions() { + let evaluator = BidEvaluator::new(default_cfg()); + let state = primev_state(); + + let mut bid_a = fixture_bid(TARGET_BLOCK, 0x01); + bid_a.position_constraint = Some(pc(Anchor::Top, Basis::Absolute, 0)); + let mut bid_b = fixture_bid(TARGET_BLOCK, 0x02); + bid_b.position_constraint = Some(pc(Anchor::Top, Basis::Absolute, 1)); + + assert!(matches!( + evaluator.evaluate(&bid_a, &state), + BidDecision::Accept(_) + )); + assert!(matches!( + evaluator.evaluate(&bid_b, &state), + BidDecision::Accept(_) + )); + assert_eq!(state.read().commitment_count(), 2); + } + + /// In range mode the evaluator accepts any bid whose block_number is + /// in [low, high]. Exercises the runner's --target-block "N-M" path. + #[test] + fn accepts_bid_anywhere_in_block_range() { + let evaluator = BidEvaluator::new(default_cfg()); + let state = primev_range_state(TARGET_BLOCK, TARGET_BLOCK + 5); + + // Low end of range. + let mut bid_lo = fixture_bid(TARGET_BLOCK, 0x01); + bid_lo.committed_tx_hash = TxHash::from([0x01; 32]); + assert!(matches!( + evaluator.evaluate(&bid_lo, &state), + BidDecision::Accept(_) + )); + + // Middle of range. + let mut bid_mid = fixture_bid(TARGET_BLOCK + 2, 0x02); + bid_mid.committed_tx_hash = TxHash::from([0x02; 32]); + assert!(matches!( + evaluator.evaluate(&bid_mid, &state), + BidDecision::Accept(_) + )); + + // High end (inclusive). + let mut bid_hi = fixture_bid(TARGET_BLOCK + 5, 0x03); + bid_hi.committed_tx_hash = TxHash::from([0x03; 32]); + assert!(matches!( + evaluator.evaluate(&bid_hi, &state), + BidDecision::Accept(_) + )); + + assert_eq!(state.read().commitment_count(), 3); + } + + #[test] + fn rejects_bid_outside_block_range() { + let evaluator = BidEvaluator::new(default_cfg()); + let state = primev_range_state(TARGET_BLOCK, TARGET_BLOCK + 5); + + // One below the low end. + let bid_below = fixture_bid(TARGET_BLOCK - 1, 0x01); + assert!(matches!( + evaluator.evaluate(&bid_below, &state), + BidDecision::Reject(RejectReason::InvalidTargetBlock) + )); + + // One above the high end. + let bid_above = fixture_bid(TARGET_BLOCK + 6, 0x02); + assert!(matches!( + evaluator.evaluate(&bid_above, &state), + BidDecision::Reject(RejectReason::InvalidTargetBlock) + )); + + assert_eq!(state.read().commitment_count(), 0); + } + + /// Sidecar replays / retries land twice on the wire. The evaluator's + /// downstream `try_reserve` catches this via `DuplicateCommitment`, + /// which surfaces as `RejectReason::DuplicateCommitment`. The second + /// accept must NOT reserve additional gas. + #[test] + fn rejects_duplicate_tx_hash_on_replay() { + let evaluator = BidEvaluator::new(default_cfg()); + let state = primev_state(); + + let bid = fixture_bid(TARGET_BLOCK, 0x77); + assert!(matches!( + evaluator.evaluate(&bid, &state), + BidDecision::Accept(_) + )); + let gas_after_first = state.read().reserved_gas(); + + // Same tx hash again. + let decision = evaluator.evaluate(&bid, &state); + assert!(matches!( + decision, + BidDecision::Reject(RejectReason::DuplicateCommitment) + )); + assert_eq!(state.read().commitment_count(), 1); + assert_eq!(state.read().reserved_gas(), gas_after_first); + } + + /// Non-absolute (percentile) constraints don't conflict on duplicates — + /// build-time enforcement handles the actual feasibility. + #[test] + fn allows_duplicate_percentile_constraints() { + let evaluator = BidEvaluator::new(default_cfg()); + let state = primev_state(); + + let mut bid_a = fixture_bid(TARGET_BLOCK, 0x01); + bid_a.position_constraint = Some(pc(Anchor::Top, Basis::Percentile, 25)); + let mut bid_b = fixture_bid(TARGET_BLOCK, 0x02); + bid_b.position_constraint = Some(pc(Anchor::Top, Basis::Percentile, 25)); + + assert!(matches!( + evaluator.evaluate(&bid_a, &state), + BidDecision::Accept(_) + )); + assert!(matches!( + evaluator.evaluate(&bid_b, &state), + BidDecision::Accept(_) + )); + assert_eq!(state.read().commitment_count(), 2); + } +} diff --git a/crates/rbuilder/src/mevcommit/bid_stream.rs b/crates/rbuilder/src/mevcommit/bid_stream.rs new file mode 100644 index 000000000..8697f66c3 --- /dev/null +++ b/crates/rbuilder/src/mevcommit/bid_stream.rs @@ -0,0 +1,478 @@ +//! Bidirectional gRPC bridge between rbuilder and the co-located mev-commit +//! Go sidecar. +//! +//! Drives two streams concurrently: +//! - `ReceiveBids` — server stream, sidecar → rbuilder +//! - `SendProcessedBids` — client stream, rbuilder → sidecar +//! +//! Phase 2 scope: connect, parse incoming bids structurally, and respond with +//! `STATUS_REJECTED` to every bid. The real evaluator lands in Phase 3. + +use std::{sync::Arc, time::Duration}; + +use exponential_backoff::Backoff; +use mevcommit_proto::provider::{ + bid_response::Status as BidStatus, provider_client::ProviderClient, BidResponse, EmptyMessage, +}; +use tokio::sync::mpsc; +use tokio_stream::{wrappers::ReceiverStream, StreamExt}; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info, warn}; + +use super::{ + bid_evaluator::{BidDecision, BidEvaluator}, + config::MevCommitConfig, + metrics, + proto_conv::{parse_incoming_bid, IncomingBid, Malformed}, + shutter_fetcher::ShutterFetcher, + slot_state::{DecryptedCacheState, SharedDecryptedCache, SharedSlotState}, +}; +use alloy_primitives::TxHash; + +/// In-flight channel capacity for outbound `BidResponse`s. Bounded so that a +/// slow upstream `SendProcessedBids` naturally backpressures `ReceiveBids`. +const DECISION_CHANNEL_CAPACITY: usize = 64; + +/// Reconnect backoff bounds. +const RECONNECT_BACKOFF_MIN: Duration = Duration::from_millis(100); +const RECONNECT_BACKOFF_MAX: Duration = Duration::from_secs(10); + +pub struct BidStream { + config: MevCommitConfig, + slot_state: SharedSlotState, + evaluator: Arc, + /// Used to background-prefetch the decrypted tx for each accepted bid + /// so the build-time enforcer hits a cache instead of a gRPC round-trip. + shutter: Arc, + cancel: CancellationToken, +} + +impl BidStream { + pub fn new( + config: MevCommitConfig, + slot_state: SharedSlotState, + shutter: Arc, + cancel: CancellationToken, + ) -> Self { + let evaluator = Arc::new(BidEvaluator::new(config.clone())); + Self { + config, + slot_state, + evaluator, + shutter, + cancel, + } + } + + /// Run forever (until cancelled). Reconnects on any transport / stream + /// error with exponential backoff. + pub async fn run(self) { + let backoff = Backoff::new(u32::MAX, RECONNECT_BACKOFF_MIN, RECONNECT_BACKOFF_MAX); + let mut backoff = backoff.into_iter(); + loop { + if self.cancel.is_cancelled() { + info!("mev-commit bid stream shutting down"); + return; + } + match self.run_session().await { + Ok(()) => { + info!("mev-commit bid stream session ended cleanly; reconnecting"); + } + Err(e) => { + warn!(error = %e, "mev-commit bid stream session error; reconnecting"); + } + } + metrics::record_grpc_reconnect(); + let Some(delay) = backoff.next() else { + return; + }; + tokio::select! { + _ = self.cancel.cancelled() => return, + _ = tokio::time::sleep(delay) => {} + } + } + } + + /// One connection lifecycle. Returns on stream end, transport error, or + /// cancellation. + async fn run_session(&self) -> eyre::Result<()> { + let addr = self.config.provider_grpc_addr.clone(); + debug!(grpc_addr = %addr, "connecting to mev-commit sidecar"); + let mut bid_client = ProviderClient::connect(addr.clone()).await?; + let mut decision_client = bid_client.clone(); + info!(grpc_addr = %addr, "mev-commit sidecar connected"); + + let inbound = bid_client.receive_bids(EmptyMessage {}).await?.into_inner(); + + let (decision_tx, decision_rx) = mpsc::channel::(DECISION_CHANNEL_CAPACITY); + let cancel = self.cancel.clone(); + let outbound_handle = tokio::spawn(async move { + let stream = ReceiverStream::new(decision_rx); + tokio::select! { + _ = cancel.cancelled() => Ok(()), + result = decision_client.send_processed_bids(stream) => { + result.map(|_| ()).map_err(eyre::Report::from) + } + } + }); + + let inbound_result = run_inbound_loop(inbound, decision_tx, self.cancel.clone(), |bid| { + self.process_bid(bid) + }) + .await; + + // Don't let an outbound error mask an inbound error. + let outbound_result = outbound_handle.await?; + inbound_result?; + outbound_result + } +} + +/// Drive one inbound stream of bids through `process` and forward the +/// resulting `BidResponse`s to `decision_tx`. Exits on: +/// - cancellation +/// - inbound stream returns `Some(Err(_))` → bubbles as session error +/// - inbound stream returns `None` (clean server close) +/// - `decision_tx.send()` errors (outbound stream closed by peer) +/// +/// Split out from `run_session` so tests can drive it with hand-built +/// streams + closures without standing up a tonic server. +pub(super) async fn run_inbound_loop( + mut inbound: S, + decision_tx: mpsc::Sender, + cancel: CancellationToken, + process: F, +) -> eyre::Result<()> +where + S: futures::Stream> + Unpin, + F: Fn(&mevcommit_proto::provider::Bid) -> BidResponse, +{ + loop { + tokio::select! { + _ = cancel.cancelled() => return Ok(()), + next = inbound.next() => match next { + Some(Ok(bid)) => { + metrics::record_bid_received(); + let eval_start = std::time::Instant::now(); + let response = process(&bid); + metrics::observe_bid_eval_duration(eval_start.elapsed()); + if decision_tx.send(response).await.is_err() { + // outbound stream closed; exit so the outer + // loop can reconnect. + return Ok(()); + } + } + Some(Err(status)) => { + return Err(eyre::eyre!("ReceiveBids stream error: {status}")); + } + None => { + // Server closed the stream. + return Ok(()); + } + } + } + } +} + +impl BidStream { + /// Convert one proto `Bid` into a `BidResponse`: structural parse + + /// evaluator + metrics + structured logging. Pure on inputs, mutates + /// `slot_state` (via the evaluator) and the metrics registry. + fn process_bid(&self, bid: &mevcommit_proto::provider::Bid) -> BidResponse { + let bid_digest = format_bid_digest(&bid.bid_digest); + let parsed = match parse_incoming_bid(bid) { + Ok(parsed) => parsed, + Err(err) => { + let reason = malformed_reason(&err); + metrics::record_bid_rejected(reason); + debug!( + bid_digest = %bid_digest, + block_number = bid.block_number, + error = %err, + reason, + "rejecting bid (malformed/unsupported)" + ); + return reject_response(bid); + } + }; + + match self.evaluator.evaluate(&parsed, &self.slot_state) { + BidDecision::Accept(cache) => { + let snapshot = self.slot_state.read(); + let count = snapshot.commitment_count(); + let gas = snapshot.reserved_gas(); + drop(snapshot); + metrics::record_bid_accepted(count, gas); + log_accept(&parsed, &bid_digest, count, gas); + spawn_prefetch( + self.shutter.clone(), + parsed.committed_tx_hash, + cache, + self.cancel.clone(), + ); + accept_response(bid) + } + BidDecision::Reject(reason) => { + metrics::record_bid_rejected(reason.label()); + log_reject(&parsed, &bid_digest, reason.label()); + reject_response(bid) + } + } + } +} + +/// Background-prefetch a decrypted tx for an accepted commitment. +/// +/// Spawns a one-shot tokio task. Retries the gRPC fetch a few times with +/// linear backoff so transient "Shutter keypers haven't revealed yet" +/// errors don't immediately mark the cache `Failed`. On success the cache +/// becomes `Cached(tx)`; on retry exhaustion it becomes `Failed`. The +/// build-time enforcer treats both `Pending` and `Failed` as cache misses +/// and falls back to a live fetch. +const PREFETCH_MAX_ATTEMPTS: u32 = 3; +const PREFETCH_RETRY_DELAY: Duration = Duration::from_millis(500); + +fn spawn_prefetch( + shutter: Arc, + tx_hash: TxHash, + cache: SharedDecryptedCache, + cancel: CancellationToken, +) { + tokio::spawn(async move { + let started_at = std::time::Instant::now(); + for attempt in 0..PREFETCH_MAX_ATTEMPTS { + if cancel.is_cancelled() { + return; + } + match shutter.fetch_and_decode(tx_hash).await { + Ok(tx) => { + metrics::record_prefetch_success(); + info!( + ?tx_hash, + attempt, + elapsed_ms = started_at.elapsed().as_millis() as u64, + "Shutter prefetch succeeded — decrypted tx cached for build time" + ); + *cache.lock() = DecryptedCacheState::Cached(tx); + return; + } + Err(e) => { + debug!( + ?tx_hash, + attempt, + error = %e, + "Shutter prefetch attempt failed (will retry if attempts remain)" + ); + if attempt + 1 < PREFETCH_MAX_ATTEMPTS { + tokio::select! { + _ = cancel.cancelled() => return, + _ = tokio::time::sleep(PREFETCH_RETRY_DELAY) => {} + } + } + } + } + } + metrics::record_prefetch_failure(); + warn!( + ?tx_hash, + attempts = PREFETCH_MAX_ATTEMPTS, + elapsed_ms = started_at.elapsed().as_millis() as u64, + "Shutter prefetch gave up after retries; cache marked Failed (build-time live fetch will retry)" + ); + *cache.lock() = DecryptedCacheState::Failed; + }); +} + +fn malformed_reason(err: &Malformed) -> &'static str { + match err { + Malformed::ShutterisedTxHashCount(_) => "malformed_tx_hashes_arity", + Malformed::InvalidTxHash(_) => "malformed_tx_hash", + Malformed::InvalidBidderAddress(_) => "malformed_bidder_address", + Malformed::InvalidBidAmount(_) => "malformed_bid_amount", + Malformed::InvalidBlockNumber(_) => "malformed_block_number", + Malformed::PlaintextNotSupported => "plaintext_not_supported", + Malformed::EmptyShutterField(_) => "malformed_shutter_field", + } +} + +fn accept_response(bid: &mevcommit_proto::provider::Bid) -> BidResponse { + BidResponse { + bid_digest: bid.bid_digest.clone(), + status: BidStatus::Accepted as i32, + dispatch_timestamp: now_unix_nanos(), + } +} + +fn reject_response(bid: &mevcommit_proto::provider::Bid) -> BidResponse { + BidResponse { + bid_digest: bid.bid_digest.clone(), + status: BidStatus::Rejected as i32, + dispatch_timestamp: now_unix_nanos(), + } +} + +fn log_accept(parsed: &IncomingBid, bid_digest: &str, count: usize, gas_reserved: u64) { + info!( + bid_digest, + bidder_address = %parsed.bidder_address, + block_number = parsed.block_number, + bid_amount = %parsed.bid_amount, + eon_id = parsed.shutter.eon_id, + commitments_in_slot = count, + slot_gas_reserved = gas_reserved, + "bid accepted" + ); +} + +fn log_reject(parsed: &IncomingBid, bid_digest: &str, reason: &str) { + debug!( + bid_digest, + bidder_address = %parsed.bidder_address, + block_number = parsed.block_number, + bid_amount = %parsed.bid_amount, + reason, + "bid rejected" + ); +} + +fn format_bid_digest(digest: &[u8]) -> String { + use alloy_primitives::hex; + let preview_len = digest.len().min(16); + hex::encode(&digest[..preview_len]) +} + +fn now_unix_nanos() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use mevcommit_proto::provider::Bid as ProtoBid; + use std::time::Duration; + use tokio_stream::wrappers::ReceiverStream; + + fn dummy_bid(digest_byte: u8) -> ProtoBid { + ProtoBid { + tx_hashes: vec![], + bid_amount: "0".into(), + block_number: 0, + bid_digest: vec![digest_byte; 32], + decay_start_timestamp: 0, + decay_end_timestamp: 0, + reverting_tx_hashes: vec![], + raw_transactions: vec![], + slash_amount: "0".into(), + bid_options: None, + bidder_address: "".into(), + } + } + + fn rejected_for(bid: &ProtoBid) -> BidResponse { + BidResponse { + bid_digest: bid.bid_digest.clone(), + status: BidStatus::Rejected as i32, + dispatch_timestamp: 0, + } + } + + /// Build an inbound stream from a Vec of inbound items. + fn inbound_from( + items: Vec>, + ) -> ReceiverStream> { + let (tx, rx) = mpsc::channel(items.len().max(1)); + for item in items { + tx.try_send(item).expect("test channel filled"); + } + drop(tx); // close so the stream ends with None after items drain + ReceiverStream::new(rx) + } + + #[tokio::test] + async fn happy_path_processes_all_bids_then_exits_on_stream_close() { + let inbound = inbound_from(vec![Ok(dummy_bid(0x01)), Ok(dummy_bid(0x02))]); + let (decision_tx, mut decision_rx) = mpsc::channel(8); + let cancel = CancellationToken::new(); + + let result = run_inbound_loop(inbound, decision_tx, cancel, |bid| rejected_for(bid)).await; + assert!(result.is_ok(), "expected clean exit, got {result:?}"); + + let r1 = decision_rx.recv().await.expect("first response"); + assert_eq!(r1.bid_digest, vec![0x01; 32]); + let r2 = decision_rx.recv().await.expect("second response"); + assert_eq!(r2.bid_digest, vec![0x02; 32]); + assert!(decision_rx.recv().await.is_none(), "channel should close"); + } + + #[tokio::test] + async fn cancellation_exits_cleanly() { + let (inbound_tx, inbound_rx) = mpsc::channel::>(1); + let inbound = ReceiverStream::new(inbound_rx); + let (decision_tx, _decision_rx) = mpsc::channel(8); + let cancel = CancellationToken::new(); + let cancel_clone = cancel.clone(); + + let handle = tokio::spawn(async move { + run_inbound_loop(inbound, decision_tx, cancel_clone, |bid| rejected_for(bid)).await + }); + + // Give the loop a moment to enter the select, then cancel. + tokio::time::sleep(Duration::from_millis(20)).await; + cancel.cancel(); + drop(inbound_tx); + + let result = tokio::time::timeout(Duration::from_secs(1), handle) + .await + .expect("loop should exit promptly on cancel") + .expect("task panicked"); + assert!( + result.is_ok(), + "cancellation should be a clean Ok exit, got {result:?}" + ); + } + + #[tokio::test] + async fn inbound_error_propagates_as_session_error() { + let inbound = inbound_from(vec![ + Ok(dummy_bid(0x01)), + Err(tonic::Status::unavailable("peer gone")), + ]); + let (decision_tx, mut decision_rx) = mpsc::channel(8); + let cancel = CancellationToken::new(); + + let result = run_inbound_loop(inbound, decision_tx, cancel, |bid| rejected_for(bid)).await; + assert!(result.is_err(), "expected stream-error to bubble"); + + // The pre-error bid still produced a response. + let r1 = decision_rx.recv().await.expect("first response"); + assert_eq!(r1.bid_digest, vec![0x01; 32]); + } + + #[tokio::test] + async fn empty_inbound_closes_immediately() { + let inbound = inbound_from(vec![]); + let (decision_tx, mut decision_rx) = mpsc::channel(8); + let cancel = CancellationToken::new(); + + let result = run_inbound_loop(inbound, decision_tx, cancel, |bid| rejected_for(bid)).await; + assert!(result.is_ok(), "empty inbound should be a clean Ok exit"); + assert!(decision_rx.recv().await.is_none()); + } + + #[tokio::test] + async fn decision_sink_closed_exits_cleanly() { + // Outbound sink is dropped before processing — first send errors, + // session should exit Ok so the outer reconnect loop runs. + let inbound = inbound_from(vec![Ok(dummy_bid(0x01)), Ok(dummy_bid(0x02))]); + let (decision_tx, decision_rx) = mpsc::channel(8); + drop(decision_rx); // pretend the upstream `send_processed_bids` task died + + let cancel = CancellationToken::new(); + let result = run_inbound_loop(inbound, decision_tx, cancel, |bid| rejected_for(bid)).await; + assert!(result.is_ok(), "should be a clean Ok exit, got {result:?}"); + } +} diff --git a/crates/rbuilder/src/mevcommit/commitment_enforcer.rs b/crates/rbuilder/src/mevcommit/commitment_enforcer.rs new file mode 100644 index 000000000..a389300f1 --- /dev/null +++ b/crates/rbuilder/src/mevcommit/commitment_enforcer.rs @@ -0,0 +1,440 @@ +//! Preconfirmation enforcement wrapper around any `BlockBuildingAlgorithm`. +//! +//! Phase 4 scope: on the first `build_blocks` call for a given target block, +//! spawn a one-shot task that (a) fetches the Shutter-decrypted plaintext for +//! every accepted commitment via the sidecar and (b) submits each decoded tx +//! into the orderpool. The inner algorithm then picks them up via the normal +//! simulated-order broadcast. +//! +//! The wrapper delegates `build_blocks` to the inner algorithm immediately, +//! synchronously. The fetch+inject task is deliberately fire-and-forget: if a +//! tx arrives in the orderpool after the inner algorithm has already started +//! building, the next build iteration in the same slot will pick it up. +//! +//! **Not yet implemented (Phase 5):** +//! - Force-include bypass in `ordering_builder::result_filter` (today the +//! tx must be profitable enough that the inner algorithm includes it). +//! - Pre-relay verify gate (today a missing commitment lets the block go +//! out unmodified — slash risk). Operators running Phase 4 should set +//! bid amounts high enough to make natural inclusion the common case +//! and monitor `mevcommit_bids_accepted_total` against actual inclusion. + +use std::{ + fmt::Debug, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }, +}; + +use rbuilder_primitives::{MempoolTx, Order, TransactionSignedEcRecoveredWithBlobs}; +use tokio::sync::mpsc; +use tracing::{debug, info, warn}; + +use crate::{ + building::builders::{BlockBuildingAlgorithm, BlockBuildingAlgorithmInput}, + live_builder::order_input::ReplaceableOrderPoolCommand, + provider::StateProviderFactory, +}; + +use super::{ + config::MevCommitConfig, + metrics, + shutter_fetcher::{FetchError, ShutterFetcher}, + slot_state::{CommitmentKind, DecryptedCacheState, PreconfCommitment, SharedSlotState}, +}; + +pub struct PreconfEnforcingAlgorithm

+where + P: StateProviderFactory, +{ + inner: Arc>, + config: MevCommitConfig, + slot_state: SharedSlotState, + shutter: Arc, + orderpool_sender: mpsc::Sender, + /// Last target block we've kicked a fetch task for. Block numbers are + /// monotonic so a simple swap-and-compare deduplicates per slot. + last_fetched_block: Arc, +} + +impl

PreconfEnforcingAlgorithm

+where + P: StateProviderFactory, +{ + pub fn new( + inner: Arc>, + config: MevCommitConfig, + slot_state: SharedSlotState, + shutter: Arc, + orderpool_sender: mpsc::Sender, + ) -> Self { + Self { + inner, + config, + slot_state, + shutter, + orderpool_sender, + last_fetched_block: Arc::new(AtomicU64::new(0)), + } + } +} + +impl

Debug for PreconfEnforcingAlgorithm

+where + P: StateProviderFactory, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PreconfEnforcingAlgorithm") + .field("inner_name", &self.inner.name()) + .finish() + } +} + +impl

BlockBuildingAlgorithm

for PreconfEnforcingAlgorithm

+where + P: StateProviderFactory + Clone + 'static, +{ + fn name(&self) -> String { + format!("preconf-{}", self.inner.name()) + } + + fn build_blocks(&self, input: BlockBuildingAlgorithmInput

) { + let block = input.ctx.block(); + // Atomically claim "I am the one to trigger fetch for this block". + // `swap` returns the previous value; if it equals `block` we've + // already triggered, so skip. + let prev = self.last_fetched_block.swap(block, Ordering::AcqRel); + if prev != block { + self.spawn_fetch_and_inject(block); + } + self.inner.build_blocks(input); + } +} + +impl

PreconfEnforcingAlgorithm

+where + P: StateProviderFactory, +{ + fn spawn_fetch_and_inject(&self, target_block: u64) { + let snapshot = pending_commitments_for(&self.slot_state, target_block); + if snapshot.is_empty() { + return; + } + info!( + target_block, + commitments = snapshot.len(), + "kicking off Shutter fetch + orderpool inject" + ); + + let shutter = Arc::clone(&self.shutter); + let orderpool = self.orderpool_sender.clone(); + let _cfg = self.config.clone(); + + tokio::spawn(async move { + let mut tasks = Vec::with_capacity(snapshot.len()); + for commitment in snapshot { + let shutter = Arc::clone(&shutter); + let orderpool = orderpool.clone(); + tasks.push(tokio::spawn(async move { + fetch_one_and_inject(commitment, shutter, orderpool).await; + })); + } + for t in tasks { + // Tasks log their own errors; we just await completion. + let _ = t.await; + } + }); + } +} + +/// Snapshot the slot state, filter to the requested block, and clone out a +/// minimal `Vec` so we can release the read lock before +/// doing any I/O. +fn pending_commitments_for( + slot_state: &SharedSlotState, + target_block: u64, +) -> Vec { + let state = slot_state.read(); + if state.target_block() != target_block { + // The slot has already rotated past the block we were asked to build. + return Vec::new(); + } + state + .commitments() + .iter() + .filter(|c| matches!(c.kind, CommitmentKind::Shutterised { .. })) + .cloned() + .collect() +} + +async fn fetch_one_and_inject( + commitment: PreconfCommitment, + shutter: Arc, + orderpool: mpsc::Sender, +) { + let tx_hash = commitment.committed_tx_hash; + let bidder = commitment.bidder; + + // Cache hit path: prefetch task already populated the decrypted tx. + // Clone-out under the lock so we don't hold it across the inject await. + let cached_tx = { + match &*commitment.decrypted.lock() { + DecryptedCacheState::Cached(tx) => Some(tx.clone()), + DecryptedCacheState::Pending | DecryptedCacheState::Failed => None, + } + }; + if let Some(tx) = cached_tx { + metrics::record_prefetch_cache_hit(); + inject_into_orderpool(tx, &orderpool).await; + debug!(?tx_hash, ?bidder, "Shutter tx injected from prefetch cache"); + return; + } + + // Cache miss path: prefetch hasn't completed (or failed). Live fetch. + metrics::record_prefetch_cache_miss(); + match shutter.fetch_and_decode(tx_hash).await { + Ok(tx) => { + metrics::record_shutter_fetch_success(); + inject_into_orderpool(tx, &orderpool).await; + debug!( + ?tx_hash, + ?bidder, + "Shutter tx injected via live fetch (cache miss)" + ); + } + Err(e) => { + let label = fetch_error_label(&e); + metrics::record_shutter_fetch_failure(label); + warn!( + ?tx_hash, + ?bidder, + error = %e, + "Shutter fetch failed; commitment will not be injected" + ); + } + } +} + +async fn inject_into_orderpool( + tx: TransactionSignedEcRecoveredWithBlobs, + orderpool: &mpsc::Sender, +) { + let order = Arc::new(Order::Tx(MempoolTx::new(tx))); + if let Err(e) = orderpool + .send(ReplaceableOrderPoolCommand::Order(order)) + .await + { + warn!(error = %e, "orderpool sender closed; cannot inject Shutter tx"); + } +} + +pub(super) fn fetch_error_label(e: &FetchError) -> &'static str { + match e { + FetchError::Grpc(_) => "grpc", + FetchError::Timeout(_) => "timeout", + FetchError::InvalidHex => "invalid_hex", + FetchError::RlpDecode(_) => "rlp_decode", + FetchError::SignerRecovery(_) => "signer_recovery", + FetchError::HashMismatch { .. } => "hash_mismatch", + FetchError::BlobTxUnsupported => "blob_tx_unsupported", + FetchError::Wrap(_) => "wrap_failure", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mevcommit::slot_state::{ + self, pending_cache, CommitmentKind, DecryptedCacheState, SlotMeta, + }; + use alloy_primitives::{Address, Bytes, TxHash, U256}; + use mevcommit_proto::provider::provider_client::ProviderClient; + use rbuilder_primitives::{Order, TestDataGenerator}; + use std::time::{Duration, Instant}; + + /// Build a ShutterFetcher pointed at an unreachable lazy channel. Tests + /// that don't hit the live-fetch path never trigger a connect; tests + /// that DO will see a `Grpc` error within the configured timeout. + fn dummy_shutter_fetcher() -> Arc { + let channel = tonic::transport::Endpoint::from_static("http://127.0.0.1:1").connect_lazy(); + Arc::new(ShutterFetcher::new( + ProviderClient::new(channel), + Duration::from_millis(50), + )) + } + + fn fixture_commitment(block: u64, tx_hash: TxHash) -> PreconfCommitment { + PreconfCommitment { + kind: CommitmentKind::Shutterised { + eon_id: 1, + identity_prefix: Bytes::from(vec![0u8; 32]), + }, + bid_digest: Bytes::from(vec![0xab; 32]), + committed_tx_hash: tx_hash, + bid_amount: U256::from(1u64), + block_number: block, + position_constraint: None, + bidder: Address::ZERO, + dispatch_timestamp: 0, + accepted_at: Instant::now(), + reserved_gas: 200_000, + decrypted: pending_cache(), + } + } + + fn fixture_slot_state(target_block: u64) -> super::SharedSlotState { + let st = slot_state::shared_empty(); + slot_state::rotate( + &st, + SlotMeta::single_block(1, target_block, 30_000_000, true), + ); + st + } + + #[test] + fn fetch_error_label_maps_every_variant() { + // One assertion per variant to keep this future-proof if a new + // FetchError lands without a label. + assert_eq!(fetch_error_label(&FetchError::Grpc("x".into())), "grpc"); + assert_eq!( + fetch_error_label(&FetchError::Timeout(Duration::from_millis(1))), + "timeout" + ); + assert_eq!(fetch_error_label(&FetchError::InvalidHex), "invalid_hex"); + assert_eq!( + fetch_error_label(&FetchError::RlpDecode("x".into())), + "rlp_decode" + ); + assert_eq!( + fetch_error_label(&FetchError::SignerRecovery("x".into())), + "signer_recovery" + ); + assert_eq!( + fetch_error_label(&FetchError::HashMismatch { + got: TxHash::ZERO, + expected: TxHash::ZERO, + }), + "hash_mismatch" + ); + assert_eq!( + fetch_error_label(&FetchError::BlobTxUnsupported), + "blob_tx_unsupported" + ); + assert_eq!( + fetch_error_label(&FetchError::Wrap("x".into())), + "wrap_failure" + ); + } + + #[test] + fn pending_commitments_for_returns_empty_when_block_mismatched() { + let state = fixture_slot_state(100); + // Slot is for block 100 but we ask for block 101. + let result = pending_commitments_for(&state, 101); + assert!(result.is_empty()); + } + + #[test] + fn pending_commitments_for_returns_shutterised() { + let state = fixture_slot_state(100); + { + let mut s = state.write(); + let c1 = fixture_commitment(100, TxHash::from([0x01; 32])); + let c2 = fixture_commitment(100, TxHash::from([0x02; 32])); + s.try_reserve(c1, 10, 10_000_000).unwrap(); + s.try_reserve(c2, 10, 10_000_000).unwrap(); + } + let result = pending_commitments_for(&state, 100); + assert_eq!(result.len(), 2); + let mut hashes: Vec<_> = result.iter().map(|c| c.committed_tx_hash).collect(); + hashes.sort(); + assert_eq!( + hashes, + vec![TxHash::from([0x01; 32]), TxHash::from([0x02; 32])] + ); + } + + #[tokio::test] + async fn inject_into_orderpool_emits_order_tx_command() { + let mut data_gen = TestDataGenerator::default(); + let tx_with_blobs = data_gen + .create_mempool_tx(rbuilder_primitives::AccountNonce { + nonce: 0, + account: Address::ZERO, + }) + .tx_with_blobs; + let expected_hash = tx_with_blobs.hash(); + + let (tx, mut rx) = mpsc::channel::(4); + inject_into_orderpool(tx_with_blobs, &tx).await; + + let cmd = rx.recv().await.expect("orderpool should receive a command"); + match cmd { + ReplaceableOrderPoolCommand::Order(order) => match &*order { + Order::Tx(mempool_tx) => { + assert_eq!(mempool_tx.tx_with_blobs.hash(), expected_hash); + } + Order::Bundle(_) => panic!("expected Order::Tx"), + }, + ReplaceableOrderPoolCommand::CancelBundle(_) => panic!("expected Order"), + } + } + + #[tokio::test] + async fn fetch_one_and_inject_uses_cache_when_populated() { + // Build a tx + commitment with the matching tx_hash, then pre-fill + // the cache. fetch_one_and_inject must NOT hit the lazy channel + // (it's not connected) — if it did, the test would hang or time + // out instead of receiving a command on the orderpool channel. + let mut data_gen = TestDataGenerator::default(); + let cached_tx = data_gen + .create_mempool_tx(rbuilder_primitives::AccountNonce { + nonce: 0, + account: Address::ZERO, + }) + .tx_with_blobs; + let tx_hash = cached_tx.hash(); + + let commitment = fixture_commitment(100, tx_hash); + *commitment.decrypted.lock() = DecryptedCacheState::Cached(cached_tx.clone()); + + let (orderpool_tx, mut orderpool_rx) = mpsc::channel::(4); + + fetch_one_and_inject(commitment, dummy_shutter_fetcher(), orderpool_tx).await; + + let cmd = tokio::time::timeout(Duration::from_secs(1), orderpool_rx.recv()) + .await + .expect("inject should reach orderpool quickly via cache hit") + .expect("channel should produce a command"); + match cmd { + ReplaceableOrderPoolCommand::Order(order) => match &*order { + Order::Tx(mempool_tx) => { + assert_eq!(mempool_tx.tx_with_blobs.hash(), tx_hash); + } + Order::Bundle(_) => panic!("expected Order::Tx"), + }, + ReplaceableOrderPoolCommand::CancelBundle(_) => panic!("expected Order"), + } + } + + #[tokio::test] + async fn fetch_one_and_inject_silently_drops_on_live_fetch_failure() { + // Cache is Pending (default), so we fall through to the live + // fetch. The lazy channel is unreachable, so the fetch should + // either time out or error, and nothing should reach the + // orderpool. + let commitment = fixture_commitment(100, TxHash::from([0x11; 32])); + let (orderpool_tx, mut orderpool_rx) = mpsc::channel::(4); + + fetch_one_and_inject(commitment, dummy_shutter_fetcher(), orderpool_tx).await; + + // Give the channel a beat to confirm nothing arrives. + let recv = tokio::time::timeout(Duration::from_millis(50), orderpool_rx.recv()).await; + match recv { + Err(_) => { /* nothing arrived within timeout — correct */ } + Ok(None) => { /* channel closed because we dropped the sender — also correct */ } + Ok(Some(_)) => panic!("orderpool received a command on a failed live fetch"), + } + } +} diff --git a/crates/rbuilder/src/mevcommit/config.rs b/crates/rbuilder/src/mevcommit/config.rs new file mode 100644 index 000000000..a4f782f4b --- /dev/null +++ b/crates/rbuilder/src/mevcommit/config.rs @@ -0,0 +1,131 @@ +use serde::Deserialize; + +/// Default gRPC address of the co-located mev-commit provider sidecar. +pub const DEFAULT_PROVIDER_GRPC_ADDR: &str = "http://127.0.0.1:13524"; + +/// Default per-bid evaluation budget in milliseconds. +pub const DEFAULT_BID_EVAL_TIMEOUT_MS: u64 = 150; + +/// Default per-fetch budget for `GetDecryptedTransaction` calls at build time. +/// Fetches run concurrently across commitments, so this is also roughly the +/// total wall-clock cost of the Shutter-fetch pass. +pub const DEFAULT_SHUTTER_FETCH_TIMEOUT_MS: u64 = 2000; + +/// Default cap on the fraction of the block gas limit reservable by preconfs. +pub const DEFAULT_MAX_PRECONF_GAS_FRACTION: f64 = 0.5; + +/// Default hard cap on accepted bids per slot. +pub const DEFAULT_MAX_PRECONF_BIDS_PER_SLOT: usize = 20; + +/// Default conservative gas assumption per Shutterised bid at accept time +/// (the real tx is still encrypted, so we cannot simulate to derive gas usage). +pub const DEFAULT_SHUTTER_ASSUMED_GAS_PER_BID: u64 = 200_000; + +/// Default for `enable_verify_gate`. The gate is on by default — operators +/// running with explicit `PositionConstraint` bids or with non-Shutter bid +/// shapes in the future need the slash protection it provides. +pub const DEFAULT_ENABLE_VERIFY_GATE: bool = true; + +/// TOML-deserialisable mev-commit integration config. +/// +/// Lives under `[mevcommit]` in the rbuilder config file. Optional — when +/// `enabled = false` (or the section is absent), the integration is fully +/// dormant and rbuilder runs identically to a non-primev build. +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(default, deny_unknown_fields)] +pub struct MevCommitConfig { + /// Master switch for the integration. When false, no gRPC connection is + /// established and no preconf state is tracked. + pub enabled: bool, + + /// gRPC address of the co-located mev-commit provider sidecar. + pub provider_grpc_addr: String, + + /// Per-bid evaluation budget. Currently **unused** — the evaluator is a + /// fully synchronous CPU pass under a write lock (microseconds-to-low- + /// milliseconds) and doesn't need a timeout. Kept in the config for + /// forward compatibility in case the evaluator ever grows async I/O. + pub bid_eval_timeout_ms: u64, + + /// Per-fetch timeout for `GetDecryptedTransaction` calls during block + /// building. Tunable per slot-budget; the sidecar relays the request to a + /// deployed Shutter sequencer whose endpoint is configured on the sidecar. + pub shutter_fetch_timeout_ms: u64, + + /// Cap on the fraction of the block gas limit reservable by preconfs. + pub max_preconf_gas_fraction: f64, + + /// Hard cap on accepted bids per slot. + pub max_preconf_bids_per_slot: usize, + + /// Conservative gas budget per Shutterised bid when the tx is still + /// encrypted (no simulation possible at accept time). + pub shutter_assumed_gas_per_bid: u64, + + /// Whether to install the pre-relay verify gate that suppresses blocks + /// violating an outstanding preconfirmation. Defaults to `true` and + /// should stay that way for any deployment that accepts bids with an + /// explicit `PositionConstraint` (those slash) or any future + /// non-Shutterised bid shape. Setting this to `false` is only safe for + /// Shutter-only deployments with no position constraints — the upstream + /// Oracle skips slash for that combination, so the gate's + /// `MissingCommittedTxs` arm would only cost unnecessary slot losses. + pub enable_verify_gate: bool, +} + +impl MevCommitConfig { + /// Log non-fatal warnings for footgun configurations. Called once when + /// the service starts. Operators can leave the config as-is; these are + /// hints, not errors. + pub fn warn_on_footguns(&self) { + use tracing::warn; + if self.max_preconf_gas_fraction > 0.8 { + warn!( + value = self.max_preconf_gas_fraction, + "max_preconf_gas_fraction > 0.8 — preconfs may dominate the block, \ + starving free orders. Consider 0.5 or lower for a comfortable margin." + ); + } + if !(0.0..=1.0).contains(&self.max_preconf_gas_fraction) { + warn!( + value = self.max_preconf_gas_fraction, + "max_preconf_gas_fraction outside [0.0, 1.0] — values will be clamped \ + implicitly via the gas-cap arithmetic; this is almost certainly a typo." + ); + } + if self.max_preconf_bids_per_slot == 0 { + warn!( + "max_preconf_bids_per_slot == 0 — the evaluator will reject every bid \ + with BidCountCap. mev-commit will appear connected but accept nothing." + ); + } + if self.shutter_assumed_gas_per_bid == 0 { + warn!( + "shutter_assumed_gas_per_bid == 0 — gas-budget reservations are no-ops, \ + so the slot can over-commit. Set to a realistic per-tx estimate (default 200_000)." + ); + } + if self.shutter_fetch_timeout_ms < 100 { + warn!( + value_ms = self.shutter_fetch_timeout_ms, + "shutter_fetch_timeout_ms is very low — Shutter sequencer round-trips \ + typically take 100ms+. Expect frequent timeout-driven block suppressions." + ); + } + } +} + +impl Default for MevCommitConfig { + fn default() -> Self { + Self { + enabled: false, + provider_grpc_addr: DEFAULT_PROVIDER_GRPC_ADDR.to_string(), + bid_eval_timeout_ms: DEFAULT_BID_EVAL_TIMEOUT_MS, + shutter_fetch_timeout_ms: DEFAULT_SHUTTER_FETCH_TIMEOUT_MS, + max_preconf_gas_fraction: DEFAULT_MAX_PRECONF_GAS_FRACTION, + max_preconf_bids_per_slot: DEFAULT_MAX_PRECONF_BIDS_PER_SLOT, + shutter_assumed_gas_per_bid: DEFAULT_SHUTTER_ASSUMED_GAS_PER_BID, + enable_verify_gate: DEFAULT_ENABLE_VERIFY_GATE, + } + } +} diff --git a/crates/rbuilder/src/mevcommit/metrics.rs b/crates/rbuilder/src/mevcommit/metrics.rs new file mode 100644 index 000000000..6280011d4 --- /dev/null +++ b/crates/rbuilder/src/mevcommit/metrics.rs @@ -0,0 +1,224 @@ +//! Prometheus metrics for the mev-commit integration. Registered into the +//! shared `crate::telemetry::metrics::REGISTRY` via the existing +//! `register_metrics!` macro, so they're picked up by the standard +//! `/metrics` endpoint without extra wiring. + +use std::time::Duration; + +use ctor::ctor; +use lazy_static::lazy_static; +use metrics_macros::register_metrics; +use prometheus::{Histogram, HistogramOpts, IntCounter, IntCounterVec, IntGauge, Opts}; + +use crate::telemetry::REGISTRY; + +register_metrics! { + /// Every Bid that arrives from the sidecar increments this once. A gap + /// vs. `accepted + rejected` indicates parsing errors. + pub static MEVCOMMIT_BIDS_RECEIVED: IntCounter = + IntCounter::new( + "mevcommit_bids_received_total", + "Bids streamed in from the mev-commit sidecar", + ) + .unwrap(); + + pub static MEVCOMMIT_BIDS_ACCEPTED: IntCounter = + IntCounter::new( + "mevcommit_bids_accepted_total", + "Bids accepted (Shutterised-only in v1)", + ) + .unwrap(); + + pub static MEVCOMMIT_BIDS_REJECTED: IntCounterVec = + IntCounterVec::new( + Opts::new( + "mevcommit_bids_rejected_total", + "Bids rejected, labeled by reason", + ), + &["reason"], + ) + .unwrap(); + + /// Reset to the new slot's count on every rotation; updated on each ACCEPT. + pub static MEVCOMMIT_SLOT_PRECONF_COUNT: IntGauge = + IntGauge::new( + "mevcommit_slot_preconf_count", + "Accepted commitments in the current slot", + ) + .unwrap(); + + /// Reset to 0 on every rotation; updated on each ACCEPT. + pub static MEVCOMMIT_SLOT_PRECONF_GAS_RESERVED: IntGauge = + IntGauge::new( + "mevcommit_slot_preconf_gas_reserved", + "Cumulative gas reserved by accepted commitments in the current slot", + ) + .unwrap(); + + pub static MEVCOMMIT_SHUTTER_FETCH_SUCCESS: IntCounter = + IntCounter::new( + "mevcommit_shutter_fetch_success_total", + "Successful GetDecryptedTransaction fetches at slot build time", + ) + .unwrap(); + + pub static MEVCOMMIT_SHUTTER_FETCH_FAILURES: IntCounterVec = + IntCounterVec::new( + Opts::new( + "mevcommit_shutter_fetch_failures_total", + "Failed GetDecryptedTransaction fetches, labeled by reason", + ), + &["reason"], + ) + .unwrap(); + + /// Every increment is a slot we deliberately lost to avoid an on-chain + /// slash. Treat as a paging-level alert. + pub static MEVCOMMIT_BLOCKS_SUPPRESSED: IntCounterVec = + IntCounterVec::new( + Opts::new( + "mevcommit_blocks_suppressed_total", + "Built blocks suppressed at the verify gate (would have been a slash)", + ), + &["reason"], + ) + .unwrap(); + + /// Per-bid evaluator wall-clock time. The evaluator is sync CPU under a + /// write lock — expect microseconds-to-low-milliseconds. Outliers + /// signal lock contention with the slot-rotation task or with another + /// bid being evaluated. + pub static MEVCOMMIT_BID_EVAL_DURATION_SECONDS: Histogram = + Histogram::with_opts( + HistogramOpts::new( + "mevcommit_bid_eval_duration_seconds", + "Wall-clock time spent evaluating one bid", + ) + .buckets(vec![ + 0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, + ]), + ) + .unwrap(); + + /// `GetDecryptedTransaction` round-trip latency. Bounded by + /// `shutter_fetch_timeout_ms` (2s default); buckets target that ceiling. + /// The p99 here is the primary signal for whether the deployed Shutter + /// sequencer is healthy. + pub static MEVCOMMIT_SHUTTER_FETCH_DURATION_SECONDS: Histogram = + Histogram::with_opts( + HistogramOpts::new( + "mevcommit_shutter_fetch_duration_seconds", + "Round-trip time of GetDecryptedTransaction", + ) + .buckets(vec![0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0]), + ) + .unwrap(); + + /// Sidecar gRPC reconnect attempts. A non-zero rate means the sidecar + /// is flapping or unreachable; if it climbs while there are in-flight + /// commitments, expect `blocks_suppressed{reason=missing_committed_txs}` + /// to spike. + pub static MEVCOMMIT_GRPC_RECONNECTS: IntCounter = + IntCounter::new( + "mevcommit_grpc_reconnects_total", + "Reconnect attempts against the mev-commit sidecar gRPC endpoint", + ) + .unwrap(); + + /// Background-prefetch outcomes. `success` increments when the prefetch + /// task populated the cache before the build-time enforcer needed it; + /// `failure` increments when it gave up. + pub static MEVCOMMIT_PREFETCH_SUCCESS: IntCounter = + IntCounter::new( + "mevcommit_prefetch_success_total", + "Background prefetch tasks that populated the decrypted-tx cache", + ) + .unwrap(); + + pub static MEVCOMMIT_PREFETCH_FAILURE: IntCounter = + IntCounter::new( + "mevcommit_prefetch_failure_total", + "Background prefetch tasks that gave up (retries exhausted)", + ) + .unwrap(); + + /// Build-time cache lookup outcomes. `hit` means the prefetch already + /// populated the cache; `miss` means we had to fall back to a live + /// `GetDecryptedTransaction` call. + pub static MEVCOMMIT_PREFETCH_CACHE_HITS: IntCounter = + IntCounter::new( + "mevcommit_prefetch_cache_hits_total", + "Build-time cache lookups served from the prefetch cache", + ) + .unwrap(); + + pub static MEVCOMMIT_PREFETCH_CACHE_MISSES: IntCounter = + IntCounter::new( + "mevcommit_prefetch_cache_misses_total", + "Build-time cache lookups that fell back to a live gRPC fetch", + ) + .unwrap(); +} + +pub fn record_bid_received() { + MEVCOMMIT_BIDS_RECEIVED.inc(); +} + +pub fn record_bid_accepted(commitment_count: usize, gas_reserved: u64) { + MEVCOMMIT_BIDS_ACCEPTED.inc(); + MEVCOMMIT_SLOT_PRECONF_COUNT.set(commitment_count as i64); + MEVCOMMIT_SLOT_PRECONF_GAS_RESERVED.set(gas_reserved as i64); +} + +pub fn record_bid_rejected(reason: &str) { + MEVCOMMIT_BIDS_REJECTED.with_label_values(&[reason]).inc(); +} + +pub fn record_slot_rotation() { + MEVCOMMIT_SLOT_PRECONF_COUNT.set(0); + MEVCOMMIT_SLOT_PRECONF_GAS_RESERVED.set(0); +} + +pub fn record_shutter_fetch_success() { + MEVCOMMIT_SHUTTER_FETCH_SUCCESS.inc(); +} + +pub fn record_shutter_fetch_failure(reason: &str) { + MEVCOMMIT_SHUTTER_FETCH_FAILURES + .with_label_values(&[reason]) + .inc(); +} + +pub fn record_block_suppressed(reason: &str) { + MEVCOMMIT_BLOCKS_SUPPRESSED + .with_label_values(&[reason]) + .inc(); +} + +pub fn observe_bid_eval_duration(d: Duration) { + MEVCOMMIT_BID_EVAL_DURATION_SECONDS.observe(d.as_secs_f64()); +} + +pub fn observe_shutter_fetch_duration(d: Duration) { + MEVCOMMIT_SHUTTER_FETCH_DURATION_SECONDS.observe(d.as_secs_f64()); +} + +pub fn record_grpc_reconnect() { + MEVCOMMIT_GRPC_RECONNECTS.inc(); +} + +pub fn record_prefetch_success() { + MEVCOMMIT_PREFETCH_SUCCESS.inc(); +} + +pub fn record_prefetch_failure() { + MEVCOMMIT_PREFETCH_FAILURE.inc(); +} + +pub fn record_prefetch_cache_hit() { + MEVCOMMIT_PREFETCH_CACHE_HITS.inc(); +} + +pub fn record_prefetch_cache_miss() { + MEVCOMMIT_PREFETCH_CACHE_MISSES.inc(); +} diff --git a/crates/rbuilder/src/mevcommit/mod.rs b/crates/rbuilder/src/mevcommit/mod.rs new file mode 100644 index 000000000..4dbaa446f --- /dev/null +++ b/crates/rbuilder/src/mevcommit/mod.rs @@ -0,0 +1,175 @@ +//! mev-commit (primev) provider integration. +//! +//! This module makes rbuilder act as a provider on the mev-commit network. +//! In the production target flow it: +//! 1. Receives bids from a co-located mev-commit Go sidecar via gRPC. +//! 2. Evaluates each bid (most checks are structural for the Shutterised +//! path, since the tx body is still Shutter-encrypted at bid time). +//! 3. Sends ACCEPT/REJECT decisions back; sidecar handles preconf signing +//! and on-chain commitment storage. +//! 4. At block-building time, fetches the Shutter-decrypted transaction +//! from the sidecar and force-includes it at the committed position. +//! +//! See `mevcommit-integration-plan.md` at the repo root for the full plan. +//! +//! Phase 2 scope: real gRPC streaming + slot rotation. Decisions are +//! REJECT-everything; Phase 3 brings the evaluator and ACCEPT path. + +pub mod bid_evaluator; +pub mod bid_stream; +pub mod commitment_enforcer; +pub mod config; +pub mod metrics; +pub mod proto_conv; +pub mod shutter_fetcher; +pub mod slot_state; +pub mod verify_gate; + +pub use commitment_enforcer::PreconfEnforcingAlgorithm; +pub use config::MevCommitConfig; +pub use shutter_fetcher::ShutterFetcher; +pub use slot_state::SharedSlotState; +pub use verify_gate::{CommitmentGate, SlotStateGate}; + +use std::{sync::Arc, time::Duration}; + +use mevcommit_proto::provider::provider_client::ProviderClient; +use tokio::{sync::broadcast, task::JoinHandle}; +use tokio_util::sync::CancellationToken; +use tracing::{debug, error, info, warn}; + +use crate::live_builder::payload_events::MevBoostSlotData; + +/// Top-level service handle for the mev-commit integration. +pub struct MevCommitService { + config: MevCommitConfig, +} + +/// Runtime artefacts produced by `MevCommitService::start`. Threaded through +/// `LiveBuilder` so the algorithm wrappers can read commitments and call the +/// sidecar's `GetDecryptedTransaction` at build time. +pub struct MevCommitRuntime { + pub slot_state: SharedSlotState, + pub shutter: Arc, + pub tasks: Vec>, +} + +impl MevCommitService { + pub fn new(config: MevCommitConfig) -> Self { + Self { config } + } + + /// Spawn the gRPC + slot-rotation tasks. Caller pre-builds the + /// `slot_state` so it can be shared with the relay-submit verify gate. + /// + /// Errors only if the gRPC endpoint URL is malformed; the underlying + /// `Channel` connects lazily so the sidecar can be down at startup + /// without preventing rbuilder from booting. + pub fn start( + self, + slot_state: SharedSlotState, + cancel: CancellationToken, + slot_data_rx: broadcast::Receiver, + ) -> eyre::Result { + let cfg = self.config; + info!( + grpc_addr = %cfg.provider_grpc_addr, + shutter_fetch_timeout_ms = cfg.shutter_fetch_timeout_ms, + "Starting mev-commit provider integration" + ); + cfg.warn_on_footguns(); + + // Build a lazily-connecting Channel for the ShutterFetcher. Reuses + // one TCP connection across all build-time fetches via tonic's + // internal Channel pool. + let endpoint = match tonic::transport::Endpoint::try_from(cfg.provider_grpc_addr.clone()) { + Ok(ep) => ep, + Err(e) => { + error!(grpc_addr = %cfg.provider_grpc_addr, error = %e, "invalid sidecar URL"); + return Err(eyre::eyre!("invalid mev-commit provider_grpc_addr: {e}")); + } + }; + let channel = endpoint.connect_lazy(); + let shutter = Arc::new(ShutterFetcher::new( + ProviderClient::new(channel), + Duration::from_millis(cfg.shutter_fetch_timeout_ms), + )); + + let bid_stream = bid_stream::BidStream::new( + cfg.clone(), + slot_state.clone(), + shutter.clone(), + cancel.clone(), + ); + let bid_handle = tokio::spawn(bid_stream.run()); + + let rotation_handle = tokio::spawn(slot_rotation_loop( + slot_state.clone(), + slot_data_rx, + cancel.clone(), + )); + + Ok(MevCommitRuntime { + slot_state, + shutter, + tasks: vec![bid_handle, rotation_handle], + }) + } +} + +/// Listen on the broadcast tap and replace `SlotPreconfState` on every slot. +async fn slot_rotation_loop( + slot_state: slot_state::SharedSlotState, + mut slot_data_rx: broadcast::Receiver, + cancel: CancellationToken, +) { + loop { + tokio::select! { + _ = cancel.cancelled() => { + info!("mev-commit slot rotation loop shutting down"); + return; + } + recv = slot_data_rx.recv() => match recv { + Ok(data) => { + let meta = derive_slot_meta(&data); + slot_state::rotate(&slot_state, meta); + metrics::record_slot_rotation(); + debug!( + slot = meta.slot, + target_block = meta.target_block, + block_gas_limit = meta.block_gas_limit, + is_primev = meta.is_primev, + "mev-commit slot state rotated" + ); + } + Err(broadcast::error::RecvError::Lagged(n)) => { + warn!( + skipped = n, + "mev-commit slot rotation lagged behind broadcast" + ); + } + Err(broadcast::error::RecvError::Closed) => { + info!("mev-commit slot data channel closed; exiting rotation loop"); + return; + } + } + } + } +} + +/// A slot is primev-eligible if any relay registration for it carries +/// `is_primev = true`. The bid evaluator gates on this — if no registered +/// relay opted into mev-commit, we cannot honor any commitment. +fn derive_slot_meta(data: &MevBoostSlotData) -> slot_state::SlotMeta { + let is_primev = data + .relay_registrations + .values() + .filter_map(|r| r.registration.preferences.as_ref()) + .any(|prefs| prefs.is_primev()); + slot_state::SlotMeta::single_block( + data.slot(), + data.block(), + data.suggested_gas_limit, + is_primev, + ) +} diff --git a/crates/rbuilder/src/mevcommit/proto_conv.rs b/crates/rbuilder/src/mevcommit/proto_conv.rs new file mode 100644 index 000000000..683cb6ea7 --- /dev/null +++ b/crates/rbuilder/src/mevcommit/proto_conv.rs @@ -0,0 +1,274 @@ +//! Conversion from proto `Bid` to an internal, type-safe `IncomingBid`. +//! +//! Phase 2 scope: cover the Shutterised path (the test target). Plaintext +//! bids — those that carry RLP txs in `raw_transactions` — are deferred to a +//! later phase and rejected here as `Malformed::PlaintextNotSupported`. + +use alloy_primitives::{hex, Address, Bytes, TxHash, U256}; +use mevcommit_proto::provider::{ + bid_option::Opt as ProtoBidOpt, Bid as ProtoBid, PositionConstraint, ShutterisedBidOption, +}; + +/// Reasons a proto `Bid` is structurally invalid and should be REJECTed +/// before the evaluator even sees it. +#[derive(Debug, Clone, thiserror::Error)] +pub enum Malformed { + #[error("tx_hashes must have exactly 1 entry for Shutterised bids, got {0}")] + ShutterisedTxHashCount(usize), + #[error("tx_hash {0:?} is not a valid 32-byte hex string")] + InvalidTxHash(String), + #[error("bidder_address {0:?} is not a valid 20-byte hex string")] + InvalidBidderAddress(String), + #[error("bid_amount {0:?} is not a valid decimal integer")] + InvalidBidAmount(String), + #[error("block_number must be positive, got {0}")] + InvalidBlockNumber(i64), + #[error( + "missing ShutterisedBidOption; plaintext (raw_transactions) bids are not yet supported" + )] + PlaintextNotSupported, + #[error("ShutterisedBidOption has empty {0}")] + EmptyShutterField(&'static str), +} + +/// Internal representation of one accepted-from-the-wire bid, after structural +/// validation and field-shape parsing. The evaluator operates on this type. +#[derive(Debug, Clone)] +pub struct IncomingBid { + pub bid_digest: Bytes, + pub committed_tx_hash: TxHash, + pub bid_amount: U256, + pub block_number: u64, + pub bidder_address: Address, + pub decay_start_timestamp: i64, + pub decay_end_timestamp: i64, + pub reverting_tx_hashes: Vec, + pub shutter: ShutterPayload, + pub position_constraint: Option, +} + +#[derive(Debug, Clone)] +pub struct ShutterPayload { + pub eon_id: i64, + pub identity_prefix: Bytes, + pub encrypted_tx: Bytes, +} + +/// Structural conversion. Pure function; no I/O. +pub fn parse_incoming_bid(bid: &ProtoBid) -> Result { + // Phase 2 target is Shutterised bids only. + let (shutter, position_constraint) = split_bid_options(&bid.bid_options)?; + let shutter = shutter.ok_or(Malformed::PlaintextNotSupported)?; + + if bid.tx_hashes.len() != 1 { + return Err(Malformed::ShutterisedTxHashCount(bid.tx_hashes.len())); + } + let committed_tx_hash = parse_tx_hash(&bid.tx_hashes[0])?; + let bidder_address = parse_address(&bid.bidder_address)?; + let bid_amount = parse_decimal_u256(&bid.bid_amount)?; + if bid.block_number <= 0 { + return Err(Malformed::InvalidBlockNumber(bid.block_number)); + } + let block_number = bid.block_number as u64; + + let mut reverting_tx_hashes = Vec::with_capacity(bid.reverting_tx_hashes.len()); + for h in &bid.reverting_tx_hashes { + reverting_tx_hashes.push(parse_tx_hash(h)?); + } + + let shutter = parse_shutter(shutter)?; + + Ok(IncomingBid { + bid_digest: Bytes::from(bid.bid_digest.clone()), + committed_tx_hash, + bid_amount, + block_number, + bidder_address, + decay_start_timestamp: bid.decay_start_timestamp, + decay_end_timestamp: bid.decay_end_timestamp, + reverting_tx_hashes, + shutter, + position_constraint, + }) +} + +/// Walk `bid_options.options[]` once, extracting at most one Shutterised +/// option and at most one position constraint. Extra options of either kind +/// silently lose to the first occurrence — the mev-commit node is the +/// authority on what BidOptions are well-formed. +fn split_bid_options( + bid_options: &Option, +) -> Result<(Option, Option), Malformed> { + let Some(opts) = bid_options else { + return Ok((None, None)); + }; + let mut shutter: Option = None; + let mut pos: Option = None; + for o in &opts.options { + match &o.opt { + Some(ProtoBidOpt::ShutterisedBidOption(s)) if shutter.is_none() => { + shutter = Some(s.clone()); + } + Some(ProtoBidOpt::PositionConstraint(p)) if pos.is_none() => { + pos = Some(p.clone()); + } + _ => {} + } + } + Ok((shutter, pos)) +} + +fn parse_shutter(s: ShutterisedBidOption) -> Result { + if s.identity_prefix.is_empty() { + return Err(Malformed::EmptyShutterField("identity_prefix")); + } + if s.encrypted_tx.is_empty() { + return Err(Malformed::EmptyShutterField("encrypted_tx")); + } + Ok(ShutterPayload { + eon_id: s.eon_id, + identity_prefix: hex_to_bytes(&s.identity_prefix) + .ok_or(Malformed::EmptyShutterField("identity_prefix"))?, + encrypted_tx: hex_to_bytes(&s.encrypted_tx) + .ok_or(Malformed::EmptyShutterField("encrypted_tx"))?, + }) +} + +fn parse_tx_hash(s: &str) -> Result { + let stripped = s.strip_prefix("0x").unwrap_or(s); + let raw = hex::decode(stripped).map_err(|_| Malformed::InvalidTxHash(s.to_string()))?; + if raw.len() != 32 { + return Err(Malformed::InvalidTxHash(s.to_string())); + } + Ok(TxHash::from_slice(&raw)) +} + +fn parse_address(s: &str) -> Result { + let stripped = s.strip_prefix("0x").unwrap_or(s); + let raw = hex::decode(stripped).map_err(|_| Malformed::InvalidBidderAddress(s.to_string()))?; + if raw.len() != 20 { + return Err(Malformed::InvalidBidderAddress(s.to_string())); + } + Ok(Address::from_slice(&raw)) +} + +fn parse_decimal_u256(s: &str) -> Result { + U256::from_str_radix(s, 10).map_err(|_| Malformed::InvalidBidAmount(s.to_string())) +} + +fn hex_to_bytes(s: &str) -> Option { + let stripped = s.strip_prefix("0x").unwrap_or(s); + hex::decode(stripped).ok().map(Bytes::from) +} + +#[cfg(test)] +mod tests { + use super::*; + use mevcommit_proto::provider::{BidOption, BidOptions, ShutterisedBidOption}; + + fn well_formed_shutterised_bid() -> ProtoBid { + ProtoBid { + tx_hashes: vec![ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), + ], + bid_amount: "1000000000000000000".into(), + block_number: 12_345_678, + bid_digest: vec![0x42; 32], + decay_start_timestamp: 1, + decay_end_timestamp: 2, + reverting_tx_hashes: vec![], + raw_transactions: vec![], + slash_amount: "0".into(), + bid_options: Some(BidOptions { + options: vec![BidOption { + opt: Some(ProtoBidOpt::ShutterisedBidOption(ShutterisedBidOption { + identity_prefix: + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + .into(), + eon_id: 7, + encrypted_tx: "ff".into(), + })), + }], + }), + bidder_address: "1111111111111111111111111111111111111111".into(), + } + } + + #[test] + fn parses_well_formed_shutterised_bid() { + let parsed = parse_incoming_bid(&well_formed_shutterised_bid()).expect("should parse"); + assert_eq!(parsed.block_number, 12_345_678); + assert_eq!(parsed.shutter.eon_id, 7); + assert_eq!(parsed.bid_digest.len(), 32); + } + + #[test] + fn rejects_zero_tx_hashes() { + let mut bid = well_formed_shutterised_bid(); + bid.tx_hashes.clear(); + assert!(matches!( + parse_incoming_bid(&bid), + Err(Malformed::ShutterisedTxHashCount(0)) + )); + } + + #[test] + fn rejects_multiple_tx_hashes() { + let mut bid = well_formed_shutterised_bid(); + bid.tx_hashes.push(bid.tx_hashes[0].clone()); + assert!(matches!( + parse_incoming_bid(&bid), + Err(Malformed::ShutterisedTxHashCount(2)) + )); + } + + #[test] + fn rejects_plaintext_only_bid() { + let mut bid = well_formed_shutterised_bid(); + bid.bid_options = None; + assert!(matches!( + parse_incoming_bid(&bid), + Err(Malformed::PlaintextNotSupported) + )); + } + + #[test] + fn rejects_invalid_tx_hash_hex() { + let mut bid = well_formed_shutterised_bid(); + bid.tx_hashes = vec!["not-hex".into()]; + assert!(matches!( + parse_incoming_bid(&bid), + Err(Malformed::InvalidTxHash(_)) + )); + } + + #[test] + fn rejects_invalid_bidder_address() { + let mut bid = well_formed_shutterised_bid(); + bid.bidder_address = "deadbeef".into(); + assert!(matches!( + parse_incoming_bid(&bid), + Err(Malformed::InvalidBidderAddress(_)) + )); + } + + #[test] + fn rejects_nonpositive_block_number() { + let mut bid = well_formed_shutterised_bid(); + bid.block_number = 0; + assert!(matches!( + parse_incoming_bid(&bid), + Err(Malformed::InvalidBlockNumber(0)) + )); + } + + #[test] + fn rejects_invalid_bid_amount() { + let mut bid = well_formed_shutterised_bid(); + bid.bid_amount = "abc".into(); + assert!(matches!( + parse_incoming_bid(&bid), + Err(Malformed::InvalidBidAmount(_)) + )); + } +} diff --git a/crates/rbuilder/src/mevcommit/shutter_fetcher.rs b/crates/rbuilder/src/mevcommit/shutter_fetcher.rs new file mode 100644 index 000000000..0d2f655c7 --- /dev/null +++ b/crates/rbuilder/src/mevcommit/shutter_fetcher.rs @@ -0,0 +1,214 @@ +//! Client-side wrapper for the sidecar's `GetDecryptedTransaction` RPC. +//! +//! At slot time the sidecar's Shutter sequencer reveals the decryption key, +//! making each accepted Shutterised commitment's plaintext tx fetchable by +//! `tx_hash`. This module: +//! +//! 1. Calls `Provider::GetDecryptedTransaction(tx_hash)` with a bounded +//! `shutter_fetch_timeout_ms` per request. +//! 2. RLP-decodes the returned hex-encoded transaction. +//! 3. Recovers the signer. +//! 4. **Mandatory hash check** — the computed tx hash must equal the +//! committed `tx_hash`. A mismatch is a fetch failure (V-S5 in the +//! plan): either a sidecar bug or hostile sequencer. + +use std::time::Duration; + +use alloy_consensus::Transaction as _; +use alloy_eips::eip2718::Decodable2718; +use alloy_primitives::{hex, TxHash}; +use mevcommit_proto::provider::{provider_client::ProviderClient, GetDecryptedTransactionRequest}; +use rbuilder_primitives::{Metadata, TransactionSignedEcRecoveredWithBlobs}; +use reth_primitives::{Recovered, TransactionSigned}; +use reth_primitives_traits::SignerRecoverable; +use tonic::transport::Channel; + +#[derive(Debug, Clone, thiserror::Error)] +pub enum FetchError { + #[error("gRPC error: {0}")] + Grpc(String), + #[error("fetch timed out after {0:?}")] + Timeout(Duration), + #[error("response decrypted_transaction is not valid hex")] + InvalidHex, + #[error("RLP decode failed: {0}")] + RlpDecode(String), + #[error("could not recover signer: {0}")] + SignerRecovery(String), + #[error("hash mismatch: sidecar returned tx with hash {got:?}, expected {expected:?}")] + HashMismatch { got: TxHash, expected: TxHash }, + #[error( + "transaction has blob versioned hashes (4844) — Shutterised path does not support blob txs in v1" + )] + BlobTxUnsupported, + #[error("transaction wrap failed: {0}")] + Wrap(String), +} + +#[derive(Clone)] +pub struct ShutterFetcher { + client: ProviderClient, + timeout: Duration, +} + +impl ShutterFetcher { + pub fn new(client: ProviderClient, timeout: Duration) -> Self { + Self { client, timeout } + } + + /// Fetch + decode + verify in one shot. Idempotent. + pub async fn fetch_and_decode( + &self, + committed_tx_hash: TxHash, + ) -> Result { + let hex_payload = self.fetch_raw(committed_tx_hash).await?; + decode_hex_payload(&hex_payload, committed_tx_hash) + } + + async fn fetch_raw(&self, tx_hash: TxHash) -> Result { + // Each call needs &mut on the client; clone the inner Channel. + let mut client = self.client.clone(); + let req = GetDecryptedTransactionRequest { + tx_hash: hex::encode(tx_hash.as_slice()), + }; + let call = client.get_decrypted_transaction(req); + let start = std::time::Instant::now(); + let result = tokio::time::timeout(self.timeout, call).await; + super::metrics::observe_shutter_fetch_duration(start.elapsed()); + match result { + Ok(Ok(resp)) => Ok(resp.into_inner().decrypted_transaction), + Ok(Err(status)) => Err(FetchError::Grpc(format!("{status}"))), + Err(_) => Err(FetchError::Timeout(self.timeout)), + } + } +} + +/// Pure post-fetch path: hex-decode → RLP-decode → signature recovery → +/// mandatory hash check → blob-unsupported guard → metadata stamping. Split +/// from `fetch_and_decode` so it can be unit-tested without a gRPC mock. +pub fn decode_hex_payload( + hex_payload: &str, + committed_tx_hash: TxHash, +) -> Result { + let bytes = hex_decode_strip_0x(hex_payload).map_err(|_| FetchError::InvalidHex)?; + let recovered = decode_and_recover(&bytes)?; + + let computed_hash = *recovered.inner().hash(); + if computed_hash != committed_tx_hash { + return Err(FetchError::HashMismatch { + got: computed_hash, + expected: committed_tx_hash, + }); + } + + // Shutterised path does not support 4844 in v1: blob sidecars are + // distributed separately and the Shutter sequencer only returns + // the canonical tx envelope. + if recovered.inner().blob_versioned_hashes().is_some() { + return Err(FetchError::BlobTxUnsupported); + } + + // Stamp `is_preconf = true` so the OrderingBuilder bypasses + // `simulation_too_low` for this tx — committed txs must land even + // when they don't pull their own weight in profit. + let metadata = Metadata::new_received_now().with_preconf(true); + TransactionSignedEcRecoveredWithBlobs::new(recovered, None, Some(metadata)) + .map_err(|e| FetchError::Wrap(format!("{e:?}"))) +} + +fn hex_decode_strip_0x(s: &str) -> Result, hex::FromHexError> { + let stripped = s.strip_prefix("0x").unwrap_or(s); + hex::decode(stripped) +} + +fn decode_and_recover(bytes: &[u8]) -> Result, FetchError> { + let mut slice = bytes; + let decoded = TransactionSigned::decode_2718(&mut slice) + .map_err(|e| FetchError::RlpDecode(format!("{e:?}")))?; + let signer = decoded + .recover_signer() + .map_err(|e| FetchError::SignerRecovery(format!("{e:?}")))?; + Ok(Recovered::new_unchecked(decoded, signer)) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_consensus::TxLegacy; + use alloy_eips::eip2718::Encodable2718; + use alloy_primitives::{Signature, U256}; + use reth_primitives::Transaction; + + /// Build a legacy tx with a deterministic but invalid signature. The + /// `recover_signer` path will yield a (degenerate) address; the tx hash + /// is fully determined by these bytes. + fn build_tx_2718() -> (Vec, TxHash) { + let tx = Transaction::Legacy(TxLegacy { + chain_id: Some(1), + nonce: 0, + gas_price: 1_000_000_000, + gas_limit: 21_000, + to: alloy_primitives::TxKind::Call(alloy_primitives::Address::ZERO), + value: U256::from(1u64), + input: Default::default(), + }); + // A signature that decodes but isn't necessarily for a real signer. + let sig = Signature::from_scalars_and_parity( + alloy_primitives::B256::from([1u8; 32]), + alloy_primitives::B256::from([2u8; 32]), + false, + ); + let signed = reth_primitives::TransactionSigned::new_unhashed(tx, sig); + let hash = *signed.hash(); + let mut buf = Vec::new(); + signed.encode_2718(&mut buf); + (buf, hash) + } + + #[test] + fn rejects_invalid_hex() { + let dummy_hash = TxHash::from([0u8; 32]); + let result = decode_hex_payload("not-hex!!", dummy_hash); + assert!(matches!(result, Err(FetchError::InvalidHex))); + } + + #[test] + fn rejects_rlp_decode_failure() { + // Valid hex but not a valid 2718-encoded tx envelope. + let dummy_hash = TxHash::from([0u8; 32]); + let result = decode_hex_payload("00deadbeef", dummy_hash); + assert!(matches!(result, Err(FetchError::RlpDecode(_)))); + } + + #[test] + fn detects_hash_mismatch() { + let (raw, real_hash) = build_tx_2718(); + let hex_payload = alloy_primitives::hex::encode(&raw); + // Caller claims the tx hash is something different. + let wrong = TxHash::from([0xff; 32]); + let result = decode_hex_payload(&hex_payload, wrong); + match result { + Err(FetchError::HashMismatch { got, expected }) => { + assert_eq!(got, real_hash); + assert_eq!(expected, wrong); + } + // `recover_signer` may legitimately fail on the synthetic + // signature — that's an acceptable earlier exit because we hit + // the same failure before computing the hash; the test only + // cares that we don't silently accept the wrong hash. + Err(FetchError::SignerRecovery(_)) => {} + other => panic!("expected HashMismatch, got {other:?}"), + } + } + + #[test] + fn accepts_0x_prefix_in_hex_payload() { + // Whether the rest of the path succeeds doesn't matter — we just + // need to confirm the 0x stripper doesn't reject the input. + let dummy_hash = TxHash::from([0u8; 32]); + let result = decode_hex_payload("0x00deadbeef", dummy_hash); + // We expect a downstream RlpDecode failure (the bytes after + // stripping aren't a valid envelope), NOT InvalidHex. + assert!(matches!(result, Err(FetchError::RlpDecode(_)))); + } +} diff --git a/crates/rbuilder/src/mevcommit/slot_state.rs b/crates/rbuilder/src/mevcommit/slot_state.rs new file mode 100644 index 000000000..6287bc39d --- /dev/null +++ b/crates/rbuilder/src/mevcommit/slot_state.rs @@ -0,0 +1,459 @@ +//! Per-slot accepted-commitment store. +//! +//! Owned by `MevCommitService`, shared with `BidStream` (writes acceptances) +//! and — in later phases — the `PreconfEnforcingAlgorithm` (reads to inject +//! pinned txs at build time). Phase 2 only exercises the read/rotate paths +//! plus the V6 slot-boundary race protection in `try_reserve`. + +use alloy_primitives::{Address, Bytes, TxHash, U256}; +use mevcommit_proto::provider::PositionConstraint; +use parking_lot::{Mutex, RwLock}; +use rbuilder_primitives::TransactionSignedEcRecoveredWithBlobs; +use std::{sync::Arc, time::Instant}; + +/// What kind of bid we accepted. Phase 1/2 scope is Shutterised-only; the +/// enum is open so plaintext bids can be added without rewiring callers. +#[derive(Debug, Clone)] +pub enum CommitmentKind { + Shutterised { eon_id: i64, identity_prefix: Bytes }, +} + +/// Background-prefetch cache for a single commitment's decrypted tx. +/// +/// Set by a one-shot task spawned at ACCEPT time so that, by the time the +/// `PreconfEnforcingAlgorithm` fires at build-time, the decrypted tx is +/// already in-process and inject is a hash lookup instead of a 2s gRPC +/// round-trip. +#[derive(Debug, Clone)] +pub enum DecryptedCacheState { + /// Prefetch hasn't reached a terminal state yet (still in-flight or + /// just hasn't been scheduled). Enforcer falls back to a live fetch. + Pending, + /// Prefetch succeeded; enforcer uses this tx directly. + Cached(TransactionSignedEcRecoveredWithBlobs), + /// Prefetch terminated unsuccessfully (e.g. retries exhausted before + /// keypers revealed). Enforcer falls back to a live fetch. + Failed, +} + +/// Cheap-clone handle so the prefetch task and the enforcer share one +/// cache slot per commitment. +pub type SharedDecryptedCache = Arc>; + +pub fn pending_cache() -> SharedDecryptedCache { + Arc::new(Mutex::new(DecryptedCacheState::Pending)) +} + +/// One accepted preconfirmation. Stored in `SlotPreconfState.commitments` in +/// acceptance order. `committed_tx_hash` is the plaintext hash the sidecar +/// will return via `GetDecryptedTransaction` at slot time. +#[derive(Debug, Clone)] +pub struct PreconfCommitment { + pub kind: CommitmentKind, + pub bid_digest: Bytes, + pub committed_tx_hash: TxHash, + pub bid_amount: U256, + pub block_number: u64, + pub position_constraint: Option, + pub bidder: Address, + pub dispatch_timestamp: i64, + pub accepted_at: Instant, + /// Gas reserved against the slot budget. For Shutterised bids this is the + /// conservative `shutter_assumed_gas_per_bid` until build-time simulation + /// replaces it with the real value. + pub reserved_gas: u64, + /// Cache slot for the background-prefetch task. See `DecryptedCacheState`. + pub decrypted: SharedDecryptedCache, +} + +/// Reasons `try_reserve` may decline an accepted bid even after the evaluator +/// said ACCEPT. +#[derive(Debug, Clone, thiserror::Error)] +pub enum ReservationError { + #[error("slot boundary crossed: bid block_number {bid_block} != state target_block {target}")] + SlotBoundaryCrossed { bid_block: u64, target: u64 }, + #[error("bid count cap reached ({cap})")] + BidCountCap { cap: usize }, + #[error("gas budget exceeded (reserved {reserved} + {requested} > cap {cap})")] + GasBudgetExceeded { + reserved: u64, + requested: u64, + cap: u64, + }, + /// A commitment with the same `committed_tx_hash` is already accepted + /// for this slot. Closes the V-S5 (double-commitment) window: if the + /// sidecar ever re-streams the same `Bid` (transient retry, reconnect, + /// upstream bug) we don't reserve double gas or stamp a second slot. + #[error("duplicate commitment for tx hash {tx_hash:?}")] + DuplicateCommitment { tx_hash: TxHash }, +} + +/// Per-slot data flowed in from `MevBoostSlotData` at slot boundary. Owned by +/// the rotation loop and copied into each `SlotPreconfState`. +#[derive(Debug, Clone, Copy)] +pub struct SlotMeta { + pub slot: u64, + /// The canonical block we're building for. Verify-gate and structured + /// logs reference this. + pub target_block: u64, + pub block_gas_limit: u64, + pub is_primev: bool, + /// Inclusive `[low, high]` of block numbers accepted by the evaluator. + /// In production this is always `(target_block, target_block)` — single + /// block; the `mevcommit_runner` example widens it for multi-block + /// sweeps in Tier 3 testing without a real CL/relay driving rotation. + pub valid_block_range: (u64, u64), +} + +impl SlotMeta { + /// Production constructor: bids must target `target_block` exactly. + pub fn single_block( + slot: u64, + target_block: u64, + block_gas_limit: u64, + is_primev: bool, + ) -> Self { + Self { + slot, + target_block, + block_gas_limit, + is_primev, + valid_block_range: (target_block, target_block), + } + } + + /// Test-harness constructor: bids whose `block_number` falls anywhere + /// inside the inclusive `[low, high]` range are accepted. `target_block` + /// is set to `low` so downstream consumers that still need a single + /// "primary" block get a sane value. + pub fn block_range( + slot: u64, + range: (u64, u64), + block_gas_limit: u64, + is_primev: bool, + ) -> Self { + let (low, high) = range; + debug_assert!( + low <= high, + "block_range: low ({low}) must be <= high ({high})" + ); + Self { + slot, + target_block: low, + block_gas_limit, + is_primev, + valid_block_range: range, + } + } +} + +/// Per-slot state. A fresh instance is installed on every slot boundary. +#[derive(Debug)] +pub struct SlotPreconfState { + meta: SlotMeta, + commitments: Vec, + reserved_gas: u64, +} + +impl SlotPreconfState { + /// Empty placeholder used before the first slot arrives. `is_primev` is + /// false, so the evaluator will reject any bids that land before slot + /// rotation has happened. + pub fn empty() -> Self { + Self { + meta: SlotMeta { + slot: 0, + target_block: 0, + block_gas_limit: 0, + is_primev: false, + valid_block_range: (0, 0), + }, + commitments: Vec::new(), + reserved_gas: 0, + } + } + + pub fn new(meta: SlotMeta) -> Self { + Self { + meta, + commitments: Vec::new(), + reserved_gas: 0, + } + } + + pub fn slot(&self) -> u64 { + self.meta.slot + } + + pub fn target_block(&self) -> u64 { + self.meta.target_block + } + + /// True iff `block_number` is accepted by the current slot's + /// `valid_block_range`. In production the range is always + /// `(target_block, target_block)` so this collapses to strict + /// equality. In runner-test mode it's a real range check. + pub fn is_valid_block(&self, block_number: u64) -> bool { + let (lo, hi) = self.meta.valid_block_range; + block_number >= lo && block_number <= hi + } + + pub fn block_gas_limit(&self) -> u64 { + self.meta.block_gas_limit + } + + pub fn is_primev(&self) -> bool { + self.meta.is_primev + } + + pub fn commitments(&self) -> &[PreconfCommitment] { + &self.commitments + } + + pub fn commitment_count(&self) -> usize { + self.commitments.len() + } + + pub fn reserved_gas(&self) -> u64 { + self.reserved_gas + } + + /// Atomic check-and-reserve. Caller must hold the write lock for the + /// duration; the lock is what blocks the slot-rotation racer (V6). + /// + /// Phase 2 unused (we REJECT everything); Phase 3 will call this. + pub fn try_reserve( + &mut self, + commitment: PreconfCommitment, + bid_count_cap: usize, + gas_cap: u64, + ) -> Result<(), ReservationError> { + if !self.is_valid_block(commitment.block_number) { + return Err(ReservationError::SlotBoundaryCrossed { + bid_block: commitment.block_number, + target: self.target_block(), + }); + } + // Dedup by tx hash. Closes V-S5 — if the sidecar re-streams the + // same Bid we'd otherwise double-reserve gas and double-stamp a + // block slot. Linear scan is fine: bounded by + // `max_preconf_bids_per_slot` (default 20). + if self + .commitments + .iter() + .any(|c| c.committed_tx_hash == commitment.committed_tx_hash) + { + return Err(ReservationError::DuplicateCommitment { + tx_hash: commitment.committed_tx_hash, + }); + } + if self.commitments.len() >= bid_count_cap { + return Err(ReservationError::BidCountCap { cap: bid_count_cap }); + } + let requested = commitment.reserved_gas; + if self.reserved_gas.saturating_add(requested) > gas_cap { + return Err(ReservationError::GasBudgetExceeded { + reserved: self.reserved_gas, + requested, + cap: gas_cap, + }); + } + self.reserved_gas = self.reserved_gas.saturating_add(requested); + self.commitments.push(commitment); + Ok(()) + } +} + +/// Convenience handle bundling the lock and helpers used by callers. +pub type SharedSlotState = Arc>; + +pub fn shared_empty() -> SharedSlotState { + Arc::new(RwLock::new(SlotPreconfState::empty())) +} + +/// Atomically replace the slot state. Called from the slot-rotation task on +/// every `MevBoostSlotData` arrival. +pub fn rotate(state: &SharedSlotState, meta: SlotMeta) { + *state.write() = SlotPreconfState::new(meta); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Instant; + + fn fixture_meta(block: u64) -> SlotMeta { + SlotMeta::single_block(1, block, 30_000_000, true) + } + + fn fixture_commitment(block: u64, gas: u64, tx_hash_byte: u8) -> PreconfCommitment { + PreconfCommitment { + kind: CommitmentKind::Shutterised { + eon_id: 1, + identity_prefix: Bytes::from(vec![0u8; 32]), + }, + bid_digest: Bytes::from(vec![0u8; 32]), + committed_tx_hash: TxHash::from([tx_hash_byte; 32]), + bid_amount: U256::from(1u64), + block_number: block, + position_constraint: None, + bidder: Address::ZERO, + dispatch_timestamp: 0, + accepted_at: Instant::now(), + reserved_gas: gas, + decrypted: pending_cache(), + } + } + + #[test] + fn try_reserve_happy_path() { + let mut state = SlotPreconfState::new(fixture_meta(100)); + assert!(state + .try_reserve(fixture_commitment(100, 200_000, 0xaa), 5, 1_000_000) + .is_ok()); + assert_eq!(state.commitment_count(), 1); + assert_eq!(state.reserved_gas(), 200_000); + } + + #[test] + fn try_reserve_rejects_slot_boundary_mismatch() { + let mut state = SlotPreconfState::new(fixture_meta(100)); + let result = state.try_reserve(fixture_commitment(101, 200_000, 0xaa), 5, 1_000_000); + match result { + Err(ReservationError::SlotBoundaryCrossed { + bid_block: 101, + target: 100, + }) => {} + other => panic!("expected SlotBoundaryCrossed, got {other:?}"), + } + assert_eq!(state.commitment_count(), 0); + } + + #[test] + fn try_reserve_rejects_when_bid_count_cap_reached() { + let mut state = SlotPreconfState::new(fixture_meta(100)); + assert!(state + .try_reserve(fixture_commitment(100, 200_000, 0x01), 1, 10_000_000) + .is_ok()); + let result = state.try_reserve(fixture_commitment(100, 200_000, 0x02), 1, 10_000_000); + assert!(matches!( + result, + Err(ReservationError::BidCountCap { cap: 1 }) + )); + assert_eq!(state.commitment_count(), 1); + } + + #[test] + fn is_valid_block_single_block_state() { + // single_block constructor sets the range to (N, N). + let state = SlotPreconfState::new(SlotMeta::single_block(1, 100, 30_000_000, true)); + assert!(state.is_valid_block(100)); + assert!(!state.is_valid_block(99)); + assert!(!state.is_valid_block(101)); + } + + #[test] + fn is_valid_block_range_state() { + let state = SlotPreconfState::new(SlotMeta::block_range(1, (100, 105), 30_000_000, true)); + for b in 100..=105 { + assert!(state.is_valid_block(b), "{b} should be inside [100, 105]"); + } + assert!(!state.is_valid_block(99)); + assert!(!state.is_valid_block(106)); + // `target_block` is the low end of the range. + assert_eq!(state.target_block(), 100); + } + + #[test] + fn try_reserve_accepts_any_block_in_range() { + let mut state = + SlotPreconfState::new(SlotMeta::block_range(1, (100, 105), 30_000_000, true)); + // Two distinct tx_hashes targeting different blocks both within the + // range. + state + .try_reserve(fixture_commitment(100, 200_000, 0x01), 10, 10_000_000) + .expect("low end of range"); + state + .try_reserve(fixture_commitment(105, 200_000, 0x02), 10, 10_000_000) + .expect("high end of range"); + assert_eq!(state.commitment_count(), 2); + + // Out-of-range bid still rejects as SlotBoundaryCrossed. + let result = state.try_reserve(fixture_commitment(106, 200_000, 0x03), 10, 10_000_000); + assert!(matches!( + result, + Err(ReservationError::SlotBoundaryCrossed { bid_block: 106, .. }) + )); + } + + #[test] + fn try_reserve_rejects_duplicate_tx_hash() { + let mut state = SlotPreconfState::new(fixture_meta(100)); + // First reservation lands fine. + state + .try_reserve(fixture_commitment(100, 200_000, 0x42), 10, 10_000_000) + .expect("first reserve should succeed"); + // Same tx hash, different gas — still a duplicate. + let result = state.try_reserve(fixture_commitment(100, 300_000, 0x42), 10, 10_000_000); + match result { + Err(ReservationError::DuplicateCommitment { tx_hash }) => { + assert_eq!(tx_hash, TxHash::from([0x42; 32])); + } + other => panic!("expected DuplicateCommitment, got {other:?}"), + } + // Gas budget was NOT touched by the rejected dup. + assert_eq!(state.commitment_count(), 1); + assert_eq!(state.reserved_gas(), 200_000); + } + + #[test] + fn try_reserve_rejects_when_gas_budget_exceeded() { + let mut state = SlotPreconfState::new(fixture_meta(100)); + assert!(state + .try_reserve(fixture_commitment(100, 600_000, 0x01), 5, 1_000_000) + .is_ok()); + let result = state.try_reserve(fixture_commitment(100, 500_000, 0x02), 5, 1_000_000); + assert!(matches!( + result, + Err(ReservationError::GasBudgetExceeded { .. }) + )); + assert_eq!(state.commitment_count(), 1); + assert_eq!(state.reserved_gas(), 600_000); + } + + /// Newly-reserved commitments start with a Pending prefetch cache. The + /// cache is shared via Arc/Mutex so the prefetch task and the build- + /// time enforcer see the same slot. + #[test] + fn fresh_commitment_has_pending_cache() { + let c = fixture_commitment(100, 200_000, 0xaa); + assert!(matches!(*c.decrypted.lock(), DecryptedCacheState::Pending,)); + } + + /// Clones of a `PreconfCommitment` share the same cache slot via + /// Arc — writing through one handle is visible to all others. + #[test] + fn commitment_cache_is_shared_across_clones() { + let c = fixture_commitment(100, 200_000, 0xaa); + let c2 = c.clone(); + *c.decrypted.lock() = DecryptedCacheState::Failed; + assert!(matches!(*c2.decrypted.lock(), DecryptedCacheState::Failed,)); + } + + #[test] + fn rotate_resets_state() { + let state = shared_empty(); + rotate(&state, fixture_meta(100)); + { + let mut guard = state.write(); + guard + .try_reserve(fixture_commitment(100, 200_000, 0x01), 5, 10_000_000) + .unwrap(); + } + rotate(&state, fixture_meta(101)); + let guard = state.read(); + assert_eq!(guard.commitment_count(), 0); + assert_eq!(guard.reserved_gas(), 0); + assert_eq!(guard.target_block(), 101); + } +} diff --git a/crates/rbuilder/src/mevcommit/verify_gate.rs b/crates/rbuilder/src/mevcommit/verify_gate.rs new file mode 100644 index 000000000..9d537cabe --- /dev/null +++ b/crates/rbuilder/src/mevcommit/verify_gate.rs @@ -0,0 +1,974 @@ +//! Pre-relay verify gate (slash protection). +//! +//! Plan reference: `mevcommit-integration-plan.md` §7 and §10 (V-S1, V-S2, +//! V-S3, V-S5). At slot time we own a `SlotPreconfState` with the set of +//! Shutterised tx hashes the sidecar has issued commitments for. Before +//! shipping a built block to any relay, every committed tx hash must be +//! present in the block's transaction list. If any are missing the block +//! is **suppressed** (not submitted) — losing the slot is strictly +//! preferable to facing an on-chain slash. +//! +//! The check runs as a `MultiRelayBlockBuildingSink` wrapper installed by +//! `RelaySubmitSinkFactory` when the mev-commit integration is enabled. + +use std::{collections::HashMap, sync::Arc}; + +use alloy_primitives::{hex, Address, TxHash}; +use mevcommit_proto::provider::{ + position_constraint::{Anchor as ProtoAnchor, Basis as ProtoBasis}, + PositionConstraint, +}; +use tracing::{error, warn}; + +use crate::{ + building::builders::Block, + live_builder::block_output::{ + bidding_service_interface::RelaySet, relay_submit::MultiRelayBlockBuildingSink, + }, +}; + +use super::{ + metrics, + slot_state::{CommitmentKind, SharedSlotState}, +}; + +/// Reason a block was blocked from going to the relays. Used as a metric +/// label (`mevcommit_blocks_suppressed_total{reason}`) so each variant maps +/// to a stable string. +#[derive(Debug, Clone, Copy)] +pub enum GateViolationReason { + /// One or more committed tx hashes are not in the built block. + MissingCommittedTxs, + /// At least one Shutterised tx is in the block but not in the top-of- + /// block region. Protocol invariant: Shutter-decrypted txs must occupy + /// positions 0..K where K = total Shutterised commitments for the slot + /// — otherwise the builder could front-run the decrypted contents. + ShutterNotAtTopOfBlock, + /// At least one committed tx is in the block but at a position that + /// violates its explicitly-attached `PositionConstraint`. + WrongPosition, +} + +impl GateViolationReason { + fn label(self) -> &'static str { + match self { + Self::MissingCommittedTxs => "missing_committed_txs", + Self::ShutterNotAtTopOfBlock => "shutter_not_at_top_of_block", + Self::WrongPosition => "wrong_position", + } + } +} + +#[derive(Debug)] +pub struct GateViolation { + pub reason: GateViolationReason, + pub target_block: u64, + /// Missing commitments (only populated when reason == MissingCommittedTxs). + pub missing: Vec, + /// Shutter txs that ended up outside the top-of-block region (only + /// populated when reason == ShutterNotAtTopOfBlock). + pub shutter_misplaced: Vec, + /// Position violations (only populated when reason == WrongPosition). + pub position_violations: Vec, +} + +#[derive(Debug)] +pub struct ShutterMisplacement { + pub tx_hash: TxHash, + pub bidder: Address, + pub bid_digest_preview: String, + /// 0-indexed position in the block where the tx landed. + pub actual_index: usize, + /// The largest valid index for a Shutter tx given the active commitment + /// count K — txs must satisfy `actual_index < K`. + pub max_allowed_index: usize, +} + +#[derive(Debug)] +pub struct MissingCommitment { + pub tx_hash: TxHash, + pub bidder: Address, + pub bid_digest_preview: String, +} + +#[derive(Debug)] +pub struct PositionViolation { + pub tx_hash: TxHash, + pub bidder: Address, + pub bid_digest_preview: String, + pub actual_index: usize, + pub block_tx_count: usize, + pub constraint: PositionConstraint, +} + +/// Predicate trait so the relay-submit factory has no direct dependency on +/// the mev-commit module. The mevcommit module provides the concrete impl. +pub trait CommitmentGate: Send + Sync + std::fmt::Debug { + fn verify(&self, block: &Block) -> Result<(), GateViolation>; +} + +/// Wraps an inner [`MultiRelayBlockBuildingSink`]. Every block must pass the +/// `gate` before it's forwarded to the inner sink. +#[derive(Debug)] +pub struct GatedRelaySink { + inner: Box, + gate: Arc, +} + +impl GatedRelaySink { + pub fn new(inner: Box, gate: Arc) -> Self { + Self { inner, gate } + } +} + +impl MultiRelayBlockBuildingSink for GatedRelaySink { + fn new_block(&self, relay_set: RelaySet, block: Block) { + match self.gate.verify(&block) { + Ok(()) => self.inner.new_block(relay_set, block), + Err(violation) => { + let reason_label = violation.reason.label(); + metrics::record_block_suppressed(reason_label); + error!( + block_number = violation.target_block, + reason = reason_label, + missing_count = violation.missing.len(), + shutter_misplaced_count = violation.shutter_misplaced.len(), + position_violations_count = violation.position_violations.len(), + missing = ?violation.missing, + shutter_misplaced = ?violation.shutter_misplaced, + position_violations = ?violation.position_violations, + ?relay_set, + "mev-commit verify gate suppressed block — would have been a slash" + ); + } + } + } +} + +/// Concrete `CommitmentGate` backed by the per-slot `SlotPreconfState`. +#[derive(Debug)] +pub struct SlotStateGate { + slot_state: SharedSlotState, +} + +impl SlotStateGate { + pub fn new(slot_state: SharedSlotState) -> Self { + Self { slot_state } + } +} + +/// Data the verify gate needs about one accepted commitment. Pub because +/// `verify_passes` (the testable pure helper) takes a map of these and +/// callers without a `SlotPreconfState` (e.g. unit tests) need to build +/// them directly. +#[derive(Debug, Clone)] +pub struct RequiredCommitment { + pub bidder: Address, + pub bid_digest_preview: String, + pub position_constraint: Option, + pub is_shutterised: bool, +} + +impl CommitmentGate for SlotStateGate { + fn verify(&self, block: &Block) -> Result<(), GateViolation> { + let target_block = block.sealed_block.number; + + // Snapshot required commitments under a short read lock — never + // hold the lock during the block scan. + let required: HashMap = { + let state = self.slot_state.read(); + if state.target_block() != target_block { + // The state has rotated past this block (or hasn't reached + // it). No active commitments to verify against. + return Ok(()); + } + state + .commitments() + .iter() + .map(|c| { + let preview_len = c.bid_digest.len().min(16); + let preview = hex::encode(&c.bid_digest[..preview_len]); + let is_shutterised = matches!(c.kind, CommitmentKind::Shutterised { .. }); + ( + c.committed_tx_hash, + RequiredCommitment { + bidder: c.bidder, + bid_digest_preview: preview, + position_constraint: c.position_constraint.clone(), + is_shutterised, + }, + ) + }) + .collect() + }; + + if required.is_empty() { + return Ok(()); + } + + // Materialize the block-side inputs that `verify_passes` needs. + let block_tx_hashes: Vec = block + .sealed_block + .body() + .transactions + .iter() + .map(|tx| *tx.hash()) + .collect(); + + // Build a per-tx gas-used map from `block.trace.included_orders`, + // then project into block order so the helper can index by position. + let (per_tx_gas, _trace_total_gas) = build_per_tx_gas_map(block); + let per_tx_gas_used: Vec = block_tx_hashes + .iter() + .map(|h| per_tx_gas.get(h).copied().unwrap_or(0)) + .collect(); + + verify_passes(target_block, &block_tx_hashes, &per_tx_gas_used, &required) + } +} + +/// Pure three-pass verifier. No `Block`, no `SlotPreconfState` — just the +/// arrays/maps the gate logic actually consumes. Public so unit tests can +/// exercise the pass interplay without standing up a real `Block` fixture. +/// +/// `block_tx_hashes[i]` is the tx hash at block index `i`. +/// `per_tx_gas_used[i]` is its `gas_used` (parallel to `block_tx_hashes`). +/// `required` is the set of accepted commitments keyed by their committed +/// tx hash. +pub fn verify_passes( + target_block: u64, + block_tx_hashes: &[TxHash], + per_tx_gas_used: &[u64], + required: &HashMap, +) -> Result<(), GateViolation> { + debug_assert_eq!( + block_tx_hashes.len(), + per_tx_gas_used.len(), + "verify_passes: per_tx_gas_used must be parallel to block_tx_hashes", + ); + + let tx_indices: HashMap = block_tx_hashes + .iter() + .enumerate() + .map(|(i, h)| (*h, i)) + .collect(); + let block_tx_count = tx_indices.len(); + + // Pass 1: missing-tx check. Slash protection for V-S1. + let missing: Vec = required + .iter() + .filter(|(h, _)| !tx_indices.contains_key(*h)) + .map(|(tx_hash, r)| MissingCommitment { + tx_hash: *tx_hash, + bidder: r.bidder, + bid_digest_preview: r.bid_digest_preview.clone(), + }) + .collect(); + if !missing.is_empty() { + return Err(GateViolation { + reason: GateViolationReason::MissingCommittedTxs, + target_block, + missing, + shutter_misplaced: Vec::new(), + position_violations: Vec::new(), + }); + } + + // Pass 2: Shutter top-of-block invariant. + let shutter_count = required.values().filter(|r| r.is_shutterised).count(); + if shutter_count > 0 { + let max_allowed_index = shutter_top_max_index(shutter_count); + let shutter_misplaced: Vec = required + .iter() + .filter(|(_, r)| r.is_shutterised) + .filter_map(|(tx_hash, r)| { + let idx = tx_indices[tx_hash]; + if shutter_position_ok(idx, shutter_count) { + None + } else { + Some(ShutterMisplacement { + tx_hash: *tx_hash, + bidder: r.bidder, + bid_digest_preview: r.bid_digest_preview.clone(), + actual_index: idx, + max_allowed_index, + }) + } + }) + .collect(); + if !shutter_misplaced.is_empty() { + return Err(GateViolation { + reason: GateViolationReason::ShutterNotAtTopOfBlock, + target_block, + missing: Vec::new(), + shutter_misplaced, + position_violations: Vec::new(), + }); + } + } + + // Pass 3: explicit position-constraint check. + let total_gas: u64 = per_tx_gas_used.iter().sum(); + let mut position_violations = Vec::new(); + for (tx_hash, r) in required { + let Some(constraint) = r.position_constraint.as_ref() else { + continue; + }; + let index = tx_indices[tx_hash]; + let cumulative_gas_before: u64 = per_tx_gas_used.iter().take(index).sum(); + let tx_gas_used = per_tx_gas_used[index]; + let gas_info = Some(GasPositionInfo { + cumulative_gas_before, + tx_gas_used, + total_gas, + }); + match check_position(constraint, index, block_tx_count, gas_info) { + PositionCheck::Ok => {} + PositionCheck::Violated => { + position_violations.push(PositionViolation { + tx_hash: *tx_hash, + bidder: r.bidder, + bid_digest_preview: r.bid_digest_preview.clone(), + actual_index: index, + block_tx_count, + constraint: constraint.clone(), + }); + } + PositionCheck::Unenforceable => { + warn!( + ?tx_hash, + bidder = ?r.bidder, + constraint = ?constraint, + "mev-commit verify gate cannot enforce this position constraint; skipping (may be slash exposure)" + ); + } + } + } + if !position_violations.is_empty() { + return Err(GateViolation { + reason: GateViolationReason::WrongPosition, + target_block, + missing: Vec::new(), + shutter_misplaced: Vec::new(), + position_violations, + }); + } + + Ok(()) +} + +/// Walk `Block.trace.included_orders` and flatten to per-tx gas-used. Each +/// `ExecutionResult` carries a `tx_infos: Vec` +/// where every entry holds both the tx (via `.tx.hash()`) and the actual +/// gas it consumed in `space_used.gas`. Bundle orders contribute one entry +/// per bundle tx — granularity is per-tx, not per-order. +/// +/// Returns `(tx_hash → gas_used, total_gas_used)`. `total_gas_used` is the +/// sum across the map — should equal `block.sealed_block.gas_used` modulo +/// payout tx accounting (the gate doesn't care which: the percentile is +/// self-consistent as long as we use the same denominator we're summing +/// from). +fn build_per_tx_gas_map(block: &Block) -> (HashMap, u64) { + let mut per_tx_gas = HashMap::new(); + let mut total_gas: u64 = 0; + for execution_result in &block.trace.included_orders { + for tx_info in &execution_result.tx_infos { + let gas = tx_info.space_used.gas; + per_tx_gas.insert(tx_info.tx.hash(), gas); + total_gas = total_gas.saturating_add(gas); + } + } + (per_tx_gas, total_gas) +} + +/// Largest 0-indexed block position a Shutter tx may occupy given there +/// are `shutter_count` Shutterised commitments active for the slot. +/// Caller must guarantee `shutter_count >= 1`. +pub fn shutter_top_max_index(shutter_count: usize) -> usize { + debug_assert!(shutter_count >= 1); + shutter_count - 1 +} + +/// True iff a Shutter tx at `actual_index` satisfies the top-of-block +/// invariant given `shutter_count` Shutterised commitments active. +/// `shutter_count == 0` is treated as "no constraint" and returns true. +pub fn shutter_position_ok(actual_index: usize, shutter_count: usize) -> bool { + if shutter_count == 0 { + return true; + } + actual_index <= shutter_top_max_index(shutter_count) +} + +/// Outcome of checking one tx's actual position against its commitment. +#[derive(Debug, PartialEq, Eq)] +pub enum PositionCheck { + /// Constraint is satisfied (or no constraint applies). + Ok, + /// Position is provably wrong → suppress the block. + Violated, + /// We cannot verify this constraint at the gate (callers without + /// `GasPositionInfo` cannot verify `BASIS_GAS_PERCENTILE`). Logged as + /// a WARN; block is NOT suppressed on this alone. The production + /// `SlotStateGate` always supplies gas info, so this only fires for + /// callers that lack it (e.g. unit tests that opt out). + Unenforceable, +} + +/// Per-tx gas accounting needed to verify `BASIS_GAS_PERCENTILE`. +/// Mirrors what the Oracle uses (`updater.go:215-227`). +#[derive(Debug, Clone, Copy)] +pub struct GasPositionInfo { + /// Sum of `gas_used` for every tx at a position strictly less than the + /// tx under inspection. Matches `gasUsedUntil(PosInBlock, ...)`. + pub cumulative_gas_before: u64, + /// `gas_used` of the tx under inspection. + pub tx_gas_used: u64, + /// Sum of `gas_used` across the whole block. Matches Oracle's + /// `TotalGas`. + pub total_gas: u64, +} + +/// Pure verification of a single position constraint against a tx's actual +/// position in the block. Public so it can be unit-tested without building a +/// full `Block` fixture. +/// +/// Mirrors the upstream Oracle's arithmetic exactly +/// (`vendor/mev-commit/oracle/pkg/updater/updater.go:187-231`). Anything we +/// accept here, the Oracle accepts; anything we suppress, the Oracle would +/// have slashed. The Top and Bottom anchor semantics are deliberately +/// asymmetric to match the Oracle: +/// +/// - `Absolute Top, value=N` ⇒ any position in `0..=N` (inclusive band) +/// - `Absolute Bottom, value=N` ⇒ any of the last `N` positions, i.e. +/// `actual_index >= block_tx_count - N`. `N = 0` admits no positions. +/// - `Percentile Top, value=V` ⇒ `actual_index <= floor(len * V / 100)` +/// - `Percentile Bottom, value=V` ⇒ +/// `actual_index >= floor(len * (100 - V) / 100)` +pub fn check_position( + constraint: &PositionConstraint, + actual_index: usize, + block_tx_count: usize, + gas_info: Option, +) -> PositionCheck { + if block_tx_count == 0 { + return PositionCheck::Violated; + } + + let anchor = ProtoAnchor::try_from(constraint.anchor).unwrap_or(ProtoAnchor::Unspecified); + let basis = ProtoBasis::try_from(constraint.basis).unwrap_or(ProtoBasis::Unspecified); + + // Unspecified anchor or basis = no enforceable constraint. + if matches!(anchor, ProtoAnchor::Unspecified) || matches!(basis, ProtoBasis::Unspecified) { + return PositionCheck::Ok; + } + if constraint.value < 0 { + return PositionCheck::Violated; + } + let value = constraint.value as usize; + + match (basis, anchor) { + (ProtoBasis::Absolute, ProtoAnchor::Top) => { + // Oracle: PosInBlock <= value + if actual_index <= value { + PositionCheck::Ok + } else { + PositionCheck::Violated + } + } + (ProtoBasis::Absolute, ProtoAnchor::Bottom) => { + // Oracle: PosInBlock >= len - value + // `value > block_tx_count` would underflow and would also be a + // logically impossible constraint ("last N positions" where N + // exceeds block size). + if value > block_tx_count { + return PositionCheck::Violated; + } + if actual_index >= block_tx_count - value { + PositionCheck::Ok + } else { + PositionCheck::Violated + } + } + (ProtoBasis::Percentile, _) => { + if value > 100 { + return PositionCheck::Violated; + } + match anchor { + ProtoAnchor::Top => { + // Oracle: PosInBlock <= floor(len * value / 100) + let threshold = (block_tx_count * value) / 100; + if actual_index <= threshold { + PositionCheck::Ok + } else { + PositionCheck::Violated + } + } + ProtoAnchor::Bottom => { + // Oracle: PosInBlock >= floor(len * (100 - value) / 100) + let threshold = (block_tx_count * (100 - value)) / 100; + if actual_index >= threshold { + PositionCheck::Ok + } else { + PositionCheck::Violated + } + } + ProtoAnchor::Unspecified => unreachable!(), + } + } + (ProtoBasis::GasPercentile, _) => { + if value > 100 { + return PositionCheck::Violated; + } + let Some(gas) = gas_info else { + return PositionCheck::Unenforceable; + }; + if gas.total_gas == 0 { + return PositionCheck::Violated; + } + // Integer arithmetic mirrors the Oracle exactly + // (`updater.go:215-227`). + let cumulative_pct = (gas.cumulative_gas_before * 100) / gas.total_gas; + match anchor { + ProtoAnchor::Top => { + // Oracle: gasPercentile <= value + if cumulative_pct <= value as u64 { + PositionCheck::Ok + } else { + PositionCheck::Violated + } + } + ProtoAnchor::Bottom => { + // Oracle: gasPercentile + txnGasPercentile >= 100 - value + let tx_pct = (gas.tx_gas_used * 100) / gas.total_gas; + if cumulative_pct + tx_pct >= (100 - value as u64) { + PositionCheck::Ok + } else { + PositionCheck::Violated + } + } + ProtoAnchor::Unspecified => unreachable!(), + } + } + (ProtoBasis::Unspecified, _) | (_, ProtoAnchor::Unspecified) => unreachable!(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pc(anchor: ProtoAnchor, basis: ProtoBasis, value: i32) -> PositionConstraint { + PositionConstraint { + anchor: anchor as i32, + basis: basis as i32, + value, + } + } + + /// Shorthand for `check_position` with no `GasPositionInfo`. Used by + /// the Absolute / Percentile tests where gas isn't relevant. + fn chk(c: &PositionConstraint, idx: usize, count: usize) -> PositionCheck { + check_position(c, idx, count, None) + } + + #[test] + fn absolute_top_value_zero_only_accepts_position_zero() { + let c = pc(ProtoAnchor::Top, ProtoBasis::Absolute, 0); + assert_eq!(chk(&c, 0, 10), PositionCheck::Ok); + assert_eq!(chk(&c, 1, 10), PositionCheck::Violated); + } + + /// Oracle uses an inclusive band: `value=N` ⇒ any of positions 0..=N. + #[test] + fn absolute_top_value_three_accepts_top_four_positions() { + let c = pc(ProtoAnchor::Top, ProtoBasis::Absolute, 3); + for idx in 0..=3 { + assert_eq!( + chk(&c, idx, 10), + PositionCheck::Ok, + "position {idx} should be within top-4 band", + ); + } + assert_eq!(chk(&c, 4, 10), PositionCheck::Violated); + } + + /// Oracle's Bottom anchor uses "count of last N positions" semantics, + /// asymmetric with Top. `value=2` ⇒ the last two positions only. + #[test] + fn absolute_bottom_value_two_accepts_last_two_positions() { + let c = pc(ProtoAnchor::Bottom, ProtoBasis::Absolute, 2); + assert_eq!(chk(&c, 9, 10), PositionCheck::Ok); + assert_eq!(chk(&c, 8, 10), PositionCheck::Ok); + assert_eq!(chk(&c, 7, 10), PositionCheck::Violated); + assert_eq!(chk(&c, 0, 10), PositionCheck::Violated); + } + + /// Quirk: Oracle's Bottom anchor with `value=0` admits zero positions + /// (the inequality `PosInBlock >= len` is unsatisfiable). Useless + /// constraint but documented so a bidder can't accidentally rely on it + /// meaning "last position". + #[test] + fn absolute_bottom_value_zero_is_degenerate() { + let c = pc(ProtoAnchor::Bottom, ProtoBasis::Absolute, 0); + for idx in 0..10 { + assert_eq!(chk(&c, idx, 10), PositionCheck::Violated); + } + } + + /// Value larger than block length is impossible to satisfy. + #[test] + fn absolute_bottom_value_exceeding_block_size_violated() { + let c = pc(ProtoAnchor::Bottom, ProtoBasis::Absolute, 100); + assert_eq!(chk(&c, 9, 10), PositionCheck::Violated); + } + + /// 10 txs * 50% = 500, floor(500/100) = 5. Inclusive: positions 0..=5. + /// Position 5 used to be the disputed boundary (over-strict ceil + `<` + /// rejected it); the Oracle accepts it, and so do we now. + #[test] + fn percentile_top_inclusive_boundary() { + let c = pc(ProtoAnchor::Top, ProtoBasis::Percentile, 50); + for idx in 0..=5 { + assert_eq!( + chk(&c, idx, 10), + PositionCheck::Ok, + "position {idx} should be within top-50% band", + ); + } + assert_eq!(chk(&c, 6, 10), PositionCheck::Violated); + } + + #[test] + fn percentile_bottom_within_band() { + // bottom 30% of 10 txs: threshold = floor(10*70/100) = 7. + // actual_index >= 7 ⇒ positions 7, 8, 9. + let c = pc(ProtoAnchor::Bottom, ProtoBasis::Percentile, 30); + assert_eq!(chk(&c, 9, 10), PositionCheck::Ok); + assert_eq!(chk(&c, 7, 10), PositionCheck::Ok); + assert_eq!(chk(&c, 6, 10), PositionCheck::Violated); + } + + #[test] + fn percentile_over_100_violated() { + let c = pc(ProtoAnchor::Top, ProtoBasis::Percentile, 150); + assert_eq!(chk(&c, 0, 10), PositionCheck::Violated); + } + + #[test] + fn unspecified_anchor_is_ok() { + let c = pc(ProtoAnchor::Unspecified, ProtoBasis::Absolute, 0); + assert_eq!(chk(&c, 5, 10), PositionCheck::Ok); + } + + #[test] + fn unspecified_basis_is_ok() { + let c = pc(ProtoAnchor::Top, ProtoBasis::Unspecified, 0); + assert_eq!(chk(&c, 5, 10), PositionCheck::Ok); + } + + /// Without `GasPositionInfo` we cannot evaluate `BASIS_GAS_PERCENTILE`. + /// Production callers always supply it; this guards the fallback. + #[test] + fn gas_percentile_without_info_returns_unenforceable() { + let c = pc(ProtoAnchor::Top, ProtoBasis::GasPercentile, 50); + assert_eq!(chk(&c, 0, 10), PositionCheck::Unenforceable); + } + + fn gas(cumulative_before: u64, tx: u64, total: u64) -> GasPositionInfo { + GasPositionInfo { + cumulative_gas_before: cumulative_before, + tx_gas_used: tx, + total_gas: total, + } + } + + /// Top 25%: tx must start within the first 25% of the block's total + /// gas. cumulative_pct = (cumulative_before * 100) / total. + #[test] + fn gas_percentile_top_ok_when_starts_in_band() { + let c = pc(ProtoAnchor::Top, ProtoBasis::GasPercentile, 25); + // cumulative_before = 200, total = 1000 → 20% ≤ 25% ⇒ Ok + let g = gas(200, 50, 1000); + assert_eq!(check_position(&c, 5, 100, Some(g)), PositionCheck::Ok); + } + + #[test] + fn gas_percentile_top_violated_when_starts_past_band() { + let c = pc(ProtoAnchor::Top, ProtoBasis::GasPercentile, 25); + // cumulative_before = 300, total = 1000 → 30% > 25% ⇒ Violated + let g = gas(300, 50, 1000); + assert_eq!(check_position(&c, 5, 100, Some(g)), PositionCheck::Violated); + } + + /// Boundary: cumulative_pct == value is inclusive (Oracle uses `<=`). + #[test] + fn gas_percentile_top_inclusive_at_boundary() { + let c = pc(ProtoAnchor::Top, ProtoBasis::GasPercentile, 25); + // cumulative_before = 250, total = 1000 → exactly 25% ⇒ Ok + let g = gas(250, 50, 1000); + assert_eq!(check_position(&c, 5, 100, Some(g)), PositionCheck::Ok); + } + + /// Bottom 25%: cumulative-including-this-tx percentile must be ≥ 75%. + /// Oracle: gasPercentile + txnGasPercentile >= 100 - value. + #[test] + fn gas_percentile_bottom_ok_when_ends_in_band() { + let c = pc(ProtoAnchor::Bottom, ProtoBasis::GasPercentile, 25); + // cumulative_before = 800, tx = 50, total = 1000 + // → cumulative_pct = 80%, tx_pct = 5% + // → 80 + 5 = 85 ≥ 75 ⇒ Ok + let g = gas(800, 50, 1000); + assert_eq!(check_position(&c, 95, 100, Some(g)), PositionCheck::Ok); + } + + #[test] + fn gas_percentile_bottom_violated_when_too_early() { + let c = pc(ProtoAnchor::Bottom, ProtoBasis::GasPercentile, 25); + // cumulative_before = 600, tx = 50, total = 1000 + // → cumulative_pct = 60%, tx_pct = 5% + // → 60 + 5 = 65 < 75 ⇒ Violated + let g = gas(600, 50, 1000); + assert_eq!( + check_position(&c, 50, 100, Some(g)), + PositionCheck::Violated + ); + } + + #[test] + fn gas_percentile_value_over_100_violated() { + let c = pc(ProtoAnchor::Top, ProtoBasis::GasPercentile, 150); + let g = gas(0, 50, 1000); + assert_eq!(check_position(&c, 0, 100, Some(g)), PositionCheck::Violated); + } + + /// Zero total gas (impossible in practice but worth guarding). + #[test] + fn gas_percentile_zero_total_gas_violated() { + let c = pc(ProtoAnchor::Top, ProtoBasis::GasPercentile, 50); + let g = gas(0, 0, 0); + assert_eq!(check_position(&c, 0, 100, Some(g)), PositionCheck::Violated); + } + + #[test] + fn negative_value_is_violation() { + let c = pc(ProtoAnchor::Top, ProtoBasis::Absolute, -1); + assert_eq!(chk(&c, 0, 10), PositionCheck::Violated); + } + + #[test] + fn empty_block_is_violation() { + let c = pc(ProtoAnchor::Top, ProtoBasis::Absolute, 0); + assert_eq!(chk(&c, 0, 0), PositionCheck::Violated); + } + + #[test] + fn shutter_position_ok_within_top_band() { + // 3 shutter commitments → positions 0, 1, 2 are valid. + assert!(shutter_position_ok(0, 3)); + assert!(shutter_position_ok(1, 3)); + assert!(shutter_position_ok(2, 3)); + } + + #[test] + fn shutter_position_ok_rejects_outside_top_band() { + // 3 shutter commitments → position 3 onwards violates. + assert!(!shutter_position_ok(3, 3)); + assert!(!shutter_position_ok(99, 3)); + } + + #[test] + fn shutter_position_ok_single_commitment_must_be_at_zero() { + assert!(shutter_position_ok(0, 1)); + assert!(!shutter_position_ok(1, 1)); + } + + #[test] + fn shutter_position_ok_zero_count_is_unconstrained() { + // No shutter commitments → no constraint to enforce. + assert!(shutter_position_ok(0, 0)); + assert!(shutter_position_ok(42, 0)); + } + + // ----- verify_passes (integrated three-pass flow) ----- + + fn hash(b: u8) -> TxHash { + TxHash::from([b; 32]) + } + + fn shutter_required() -> RequiredCommitment { + RequiredCommitment { + bidder: alloy_primitives::Address::ZERO, + bid_digest_preview: "abcd".into(), + position_constraint: None, + is_shutterised: true, + } + } + + fn plaintext_required() -> RequiredCommitment { + RequiredCommitment { + bidder: alloy_primitives::Address::ZERO, + bid_digest_preview: "abcd".into(), + position_constraint: None, + // For now there's only Shutterised, but the gate's flow allows + // non-shutter commitments — we synthesise one here just to + // confirm the gate logic doesn't special-case shutter for + // missing-check. + is_shutterised: false, + } + } + + #[test] + fn verify_passes_ok_with_no_required() { + let required: HashMap = HashMap::new(); + let result = verify_passes(100, &[hash(0x01), hash(0x02)], &[21_000, 21_000], &required); + assert!(result.is_ok()); + } + + #[test] + fn verify_passes_ok_when_shutter_at_top() { + // 3 shutter commitments occupying positions 0,1,2 of a 5-tx block. + let mut required = HashMap::new(); + required.insert(hash(0x01), shutter_required()); + required.insert(hash(0x02), shutter_required()); + required.insert(hash(0x03), shutter_required()); + let block_tx = vec![hash(0x01), hash(0x02), hash(0x03), hash(0x04), hash(0x05)]; + let gas = vec![21_000, 21_000, 21_000, 50_000, 50_000]; + + let result = verify_passes(100, &block_tx, &gas, &required); + assert!(result.is_ok(), "got {result:?}"); + } + + #[test] + fn verify_passes_missing_when_commitment_absent_from_block() { + let mut required = HashMap::new(); + required.insert(hash(0xff), shutter_required()); + let block_tx = vec![hash(0x01), hash(0x02)]; + let gas = vec![21_000, 21_000]; + + let err = verify_passes(100, &block_tx, &gas, &required).expect_err("should fail"); + assert!(matches!( + err.reason, + GateViolationReason::MissingCommittedTxs + )); + assert_eq!(err.missing.len(), 1); + assert_eq!(err.missing[0].tx_hash, hash(0xff)); + assert!(err.shutter_misplaced.is_empty()); + assert!(err.position_violations.is_empty()); + } + + #[test] + fn verify_passes_shutter_at_wrong_index() { + // 1 shutter commitment but it lands at index 2 instead of 0. + let mut required = HashMap::new(); + required.insert(hash(0xaa), shutter_required()); + let block_tx = vec![hash(0x01), hash(0x02), hash(0xaa)]; + let gas = vec![21_000, 21_000, 21_000]; + + let err = verify_passes(100, &block_tx, &gas, &required).expect_err("should fail"); + assert!(matches!( + err.reason, + GateViolationReason::ShutterNotAtTopOfBlock + )); + assert_eq!(err.shutter_misplaced.len(), 1); + assert_eq!(err.shutter_misplaced[0].tx_hash, hash(0xaa)); + assert_eq!(err.shutter_misplaced[0].actual_index, 2); + assert_eq!(err.shutter_misplaced[0].max_allowed_index, 0); + } + + #[test] + fn verify_passes_explicit_position_violation_for_non_shutter_commitment() { + // A non-shutter commitment carrying `Absolute Top, value=0` — must + // be at position 0. Build a block where it lands at position 1. + let mut req = plaintext_required(); + req.position_constraint = Some(pc(ProtoAnchor::Top, ProtoBasis::Absolute, 0)); + let mut required = HashMap::new(); + required.insert(hash(0xaa), req); + let block_tx = vec![hash(0x01), hash(0xaa)]; + let gas = vec![21_000, 21_000]; + + let err = verify_passes(100, &block_tx, &gas, &required).expect_err("should fail"); + assert!(matches!(err.reason, GateViolationReason::WrongPosition)); + assert_eq!(err.position_violations.len(), 1); + assert_eq!(err.position_violations[0].tx_hash, hash(0xaa)); + assert_eq!(err.position_violations[0].actual_index, 1); + } + + /// Pass 1 (missing) wins over Pass 3 (position): a commitment that + /// is BOTH missing AND has a position constraint reports as missing, + /// not wrong-position. The gate short-circuits. + #[test] + fn verify_passes_missing_takes_priority_over_wrong_position() { + let mut req = shutter_required(); + req.position_constraint = Some(pc(ProtoAnchor::Top, ProtoBasis::Absolute, 0)); + let mut required = HashMap::new(); + required.insert(hash(0xaa), req); + // Block has no 0xaa. + let block_tx = vec![hash(0x01), hash(0x02)]; + let gas = vec![21_000, 21_000]; + + let err = verify_passes(100, &block_tx, &gas, &required).expect_err("should fail"); + assert!(matches!( + err.reason, + GateViolationReason::MissingCommittedTxs + )); + assert_eq!(err.missing.len(), 1); + // Position violations are NOT reported alongside missing. + assert!(err.position_violations.is_empty()); + } + + /// Pass 2 (shutter-at-top) wins over Pass 3 (position). + #[test] + fn verify_passes_shutter_misplacement_takes_priority_over_wrong_position() { + let mut shutter_with_pc = shutter_required(); + // This constraint would FAIL position check at index 2. + shutter_with_pc.position_constraint = Some(pc(ProtoAnchor::Top, ProtoBasis::Absolute, 0)); + let mut required = HashMap::new(); + required.insert(hash(0xaa), shutter_with_pc); + // Block has 0xaa at index 2 (violates BOTH shutter-top and absolute-0). + let block_tx = vec![hash(0x01), hash(0x02), hash(0xaa)]; + let gas = vec![21_000, 21_000, 21_000]; + + let err = verify_passes(100, &block_tx, &gas, &required).expect_err("should fail"); + // ShutterNotAtTopOfBlock comes first (Pass 2 runs before Pass 3). + assert!(matches!( + err.reason, + GateViolationReason::ShutterNotAtTopOfBlock + )); + assert_eq!(err.shutter_misplaced.len(), 1); + assert!(err.position_violations.is_empty()); + } + + /// GasPercentile check propagates through verify_passes. A commitment + /// at position 0 with all the gas used by later txs: it's in the top + /// 25% by gas, so it passes. + #[test] + fn verify_passes_gas_percentile_ok() { + let mut req = plaintext_required(); + req.position_constraint = Some(pc(ProtoAnchor::Top, ProtoBasis::GasPercentile, 25)); + let mut required = HashMap::new(); + required.insert(hash(0xaa), req); + // 4 txs, total gas = 1000. tx 0xaa uses 100, others use 300 each. + // cumulative_before(0xaa@idx0) = 0 → 0% ≤ 25% → Ok. + let block_tx = vec![hash(0xaa), hash(0x01), hash(0x02), hash(0x03)]; + let gas = vec![100, 300, 300, 300]; + + assert!(verify_passes(100, &block_tx, &gas, &required).is_ok()); + } + + /// Same setup, but with the commitment at index 3 (after most of the + /// gas) — should violate. + #[test] + fn verify_passes_gas_percentile_violated() { + let mut req = plaintext_required(); + req.position_constraint = Some(pc(ProtoAnchor::Top, ProtoBasis::GasPercentile, 25)); + let mut required = HashMap::new(); + required.insert(hash(0xaa), req); + let block_tx = vec![hash(0x01), hash(0x02), hash(0x03), hash(0xaa)]; + let gas = vec![300, 300, 300, 100]; + // cumulative_before(0xaa@idx3) = 900, total = 1000 → 90% > 25% → Violated. + + let err = verify_passes(100, &block_tx, &gas, &required).expect_err("should fail"); + assert!(matches!(err.reason, GateViolationReason::WrongPosition)); + assert_eq!(err.position_violations[0].actual_index, 3); + } +} diff --git a/examples/config/rbuilder/config-live-example.toml b/examples/config/rbuilder/config-live-example.toml index ba5f6770d..f9f6900f2 100644 --- a/examples/config/rbuilder/config-live-example.toml +++ b/examples/config/rbuilder/config-live-example.toml @@ -92,3 +92,26 @@ name = "ultrasound-ws-us" ultrasound_url = "ws://relay-builders-us.ultrasound.money/ws/v1/top_bid" relay_name = "ultrasound-money-us" +# ── mev-commit (primev) integration ───────────────────────────────────────── +# Remove this section or set enabled = false to run without mev-commit. +# When enabled = false (or the section is absent), rbuilder behaves identically +# to the upstream Flashbots binary — no gRPC connection, no preconf state. +# +# Prerequisites: +# - mev-commit provider sidecar must be running and listening on provider_grpc_addr +# - Provider must be staked on-chain (minimum 10 ETH on testnet) +# - See deployment-migration-plan.md Phase 2 for sidecar setup steps +[mevcommit] +enabled = false # set to true to activate +# provider_grpc_addr depends on how the sidecar is deployed: +# Docker Compose (recommended): "http://mev-commit-provider:13524" ← use service name +# Bare metal / same host: "http://127.0.0.1:13524" +provider_grpc_addr = "http://mev-commit-provider:13524" + +# Optional tuning (defaults shown — only override if needed): +# shutter_fetch_timeout_ms = 2000 # timeout per GetDecryptedTransaction call +# max_preconf_gas_fraction = 0.5 # max fraction of block gas reservable by preconfs +# max_preconf_bids_per_slot = 20 # hard cap on accepted bids per slot +# shutter_assumed_gas_per_bid = 200000 # conservative gas estimate per Shutterised bid +# enable_verify_gate = true # pre-relay slash protection — keep true in production + diff --git a/scripts/mevcommit_smoke.sh b/scripts/mevcommit_smoke.sh new file mode 100755 index 000000000..ee3d8a966 --- /dev/null +++ b/scripts/mevcommit_smoke.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# +# Manual smoke harness for the mev-commit (primev) integration. +# +# Brings up the upstream `provideremulator` from the vendored mev-commit +# submodule and runs rbuilder against it with `[mevcommit] enabled = true`. +# Used to confirm bid streaming, ACCEPT/REJECT decisions, prefetch, and the +# verify gate end-to-end. NOT a CI test — CI doesn't carry a mev-commit +# sidecar (see plan §13). +# +# Prerequisites on this host: +# - Go ≥ 1.21, cargo, cmake +# - `git submodule update --init --recursive` has been run +# - rbuilder config TOML with a [mevcommit] section +# +# Layout: +# - Terminal A: `./scripts/mevcommit_smoke.sh emulator` +# — runs vendor/mev-commit/p2p/examples/provideremulator on :13524 +# - Terminal B: `./scripts/mevcommit_smoke.sh rbuilder ` +# — runs rbuilder pointing at the emulator +# +# Expected log lines (search rbuilder stdout): +# - "Starting mev-commit provider integration" +# - "mev-commit sidecar connected" +# - For each emitted emulator bid: +# - "bid received" (when receiving) +# - "bid accepted" or "bid rejected" (with reason) +# +# Expected metric values after a successful run (curl :/metrics): +# - mevcommit_bids_received_total > 0 +# - mevcommit_bids_accepted_total > 0 (Shutterised bids only) +# - mevcommit_bids_rejected_total{reason="plaintext_not_supported"} > 0 +# (emulator may emit plaintext bids too) +# - mevcommit_grpc_reconnects_total == 0 (no disconnects during smoke) +# - mevcommit_prefetch_success_total or _failure_total bumps as txs are +# accepted (depends on whether the emulator wires a Shutter sequencer) +# +# Slash-protection sanity check (optional): +# - Suspend the emulator mid-slot (Ctrl-Z), wait for slot building to +# occur, then resume. Expect: +# - mevcommit_blocks_suppressed_total{reason="missing_committed_txs"} +# to increment if any accepted bid was missing the inject path. +# +set -euo pipefail + +cmd="${1:-help}" + +case "$cmd" in + emulator) + EMULATOR_DIR="$(dirname "$0")/../vendor/mev-commit/p2p/examples/provideremulator" + if [[ ! -d "$EMULATOR_DIR" ]]; then + echo "error: emulator not found at $EMULATOR_DIR" >&2 + echo "did you run \`git submodule update --init --recursive\`?" >&2 + exit 1 + fi + cd "$EMULATOR_DIR" + echo "Building provideremulator..." + go build -o /tmp/provideremulator . + echo "Starting provideremulator on :13524 (the rbuilder sidecar gRPC default)" + exec /tmp/provideremulator -server-addr 127.0.0.1:13524 + ;; + + rbuilder) + config="${2:-}" + if [[ -z "$config" ]]; then + echo "usage: $0 rbuilder " >&2 + exit 1 + fi + if ! grep -q "^\[mevcommit\]" "$config"; then + echo "warning: $config has no [mevcommit] section — integration will be dormant" >&2 + fi + export CMAKE_POLICY_VERSION_MINIMUM=3.5 + exec cargo run --release --bin rbuilder -- run --config "$config" + ;; + + help|*) + sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; +esac diff --git a/vendor/mev-commit b/vendor/mev-commit new file mode 160000 index 000000000..c95c50bd2 --- /dev/null +++ b/vendor/mev-commit @@ -0,0 +1 @@ +Subproject commit c95c50bd2cc7777f6af9389413bcea5a819effa4