feat(bot): simulate-before-send + pluggable Signer + fan-out submit (WP 7912, 7914) #20

Merged
buddysan merged 1 commit from perf/submit-signer into main 2026-09-16 15:02:50 +00:00
Owner

Summary

Phase 4 lane, third and final PR for this wave (WP 7912 + the multi-path fan-out half of 7914).

bot/submit.py — the thesis module. Pipeline: freshness (§4.8: Bid/Opportunity/calldata must describe the same block + registry_hash + route, refused otherwise) → contention gate (optional Opportunity.contention) → route revert-streak backoff (4 × 3^(n-1) blocks, cap 4800, success resets, sim reverts never count — learnings §4.4) → sign concurrently with simulatesimulate(): one [eth_blockNumber, eth_call] batch on the best read endpoint; drops if head - opp.block >= 2 (§4.6) or retries the next read endpoint if that endpoint's state is behind opp.block, rather than false-failing (measured: local node imports a WS-announced head ~120 ms after the fastest public WS, PR #15) → dry-run stops here (default, and this wave's only mode) → fan-out the identical raw tx to every submit endpoint in parallel, first hash wins → receipt watcher fills included_block/status/gas_used and drives the backoff.

Signer interface: LocalSigner (in-process, bot/vendor) and CastSigner (cast mktx --keystore --password-file, key never on argv/env/log). Both are bit-exact against cast mktx fixtures (3 raw-tx cases + the EIP-155 spec vector). Every signature from either signer is verified by public-key recovery against the exact requested tx fields before it can leave the process (_verify_raw) — a broken primitive or a mismatched cast invocation can never broadcast. executor_address/key_path fall back to the already-committed [executor].address/hot_key_file (no duplicate source of truth with security-director's deployment record).

dry_run = true is the default and the config refuses dry_run=false without a real signer configured. python3 -m bot.submit --measure N is a dry-run latency harness (a throwaway unfunded key, address only ever printed).

Measured on LXC 140, throwaway keystore, load ~10–19 on 4 vCPU:

  • Signing: LocalSigner p50 48.6 ms / p90 78 ms (n=50) vs CastSigner p50 222 ms / p90 374 ms (n=10) — both produced byte-identical raw transactions for the same key/tx.
  • simulate() against the real deployed ArcCycleExecutor (0x1c28…7e02, funded with 2 USDC): p50 59.6 ms / p90 115 ms / p99 261 ms / max 385 ms over 182 real on-chain reverts (the harness's toy route legitimately reverts against the funded contract — no search.py exists yet to feed it real opportunities; documented as an open item).

Full write-up + every number (head arrival, signer timing, simulate latency, the owner-key rule) in docs/execution.md.

Test plan

  • make lint test green — 238 tests, whole repo, on this host and on LXC 140 (Python 3.12.3)
  • bot/tests/test_submit.py (offline, fake JSON-RPC nodes on two distinct loopback hosts for the submit fan-out, a stub cast runner for CastSigner): bit-exact signing, freshness refusal (block/hash/route mismatch), contention + backoff gates, state-behind-opp.block retry to the next read endpoint, sim_fail/sim_unprofitable/dropped_stale, dry-run never signs, live fan-out first-hash-wins + receipt watcher + route backoff on a status-0 receipt, nonce too low resync, config validation (dry_run=false needs a real signer, executor/key fallback to [executor])

🤖 Generated with Claude Code

## Summary Phase 4 lane, third and final PR for this wave (WP 7912 + the multi-path fan-out half of 7914). **`bot/submit.py`** — the thesis module. Pipeline: freshness (§4.8: Bid/Opportunity/calldata must describe the same block + `registry_hash` + route, refused otherwise) → contention gate (optional `Opportunity.contention`) → route revert-streak backoff (`4 × 3^(n-1)` blocks, cap 4800, success resets, sim reverts never count — learnings §4.4) → **sign concurrently with simulate** → `simulate()`: one `[eth_blockNumber, eth_call]` batch on the best read endpoint; drops if `head - opp.block >= 2` (§4.6) **or** retries the next read endpoint if that endpoint's state is behind `opp.block`, rather than false-failing (measured: local node imports a WS-announced head ~120 ms after the fastest public WS, PR #15) → dry-run stops here (default, and this wave's only mode) → fan-out the identical raw tx to every `submit` endpoint in parallel, first hash wins → receipt watcher fills `included_block/status/gas_used` and drives the backoff. **Signer interface**: `LocalSigner` (in-process, `bot/vendor`) and `CastSigner` (`cast mktx --keystore --password-file`, key never on argv/env/log). Both are bit-exact against `cast mktx` fixtures (3 raw-tx cases + the EIP-155 spec vector). Every signature from either signer is verified by public-key **recovery** against the exact requested tx fields before it can leave the process (`_verify_raw`) — a broken primitive or a mismatched `cast` invocation can never broadcast. `executor_address`/`key_path` fall back to the already-committed `[executor].address`/`hot_key_file` (no duplicate source of truth with security-director's deployment record). `dry_run = true` is the default and the config refuses `dry_run=false` without a real signer configured. `python3 -m bot.submit --measure N` is a dry-run latency harness (a throwaway unfunded key, address only ever printed). **Measured on LXC 140**, throwaway keystore, load ~10–19 on 4 vCPU: - Signing: `LocalSigner` p50 **48.6 ms** / p90 78 ms (n=50) vs `CastSigner` p50 **222 ms** / p90 374 ms (n=10) — both produced byte-identical raw transactions for the same key/tx. - `simulate()` against the **real deployed `ArcCycleExecutor`** (`0x1c28…7e02`, funded with 2 USDC): p50 **59.6 ms** / p90 115 ms / p99 261 ms / max 385 ms over 182 real on-chain reverts (the harness's toy route legitimately reverts against the funded contract — no `search.py` exists yet to feed it real opportunities; documented as an open item). Full write-up + every number (head arrival, signer timing, simulate latency, the owner-key rule) in `docs/execution.md`. ## Test plan - [x] `make lint test` green — 238 tests, whole repo, on this host and on LXC 140 (Python 3.12.3) - [x] `bot/tests/test_submit.py` (offline, fake JSON-RPC nodes on two distinct loopback hosts for the submit fan-out, a stub `cast` runner for `CastSigner`): bit-exact signing, freshness refusal (block/hash/route mismatch), contention + backoff gates, state-behind-opp.block retry to the next read endpoint, `sim_fail`/`sim_unprofitable`/`dropped_stale`, dry-run never signs, live fan-out first-hash-wins + receipt watcher + route backoff on a status-0 receipt, `nonce too low` resync, config validation (`dry_run=false` needs a real signer, executor/key fallback to `[executor]`) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(bot): simulate-before-send + pluggable Signer + fan-out submit (WP 7912, 7914)
Some checks failed
ci / forge-tests (pull_request) Successful in 7s
ci / python-tests (pull_request) Successful in 45s
ci / forge-tests (push) Failing after 0s
ci / python-tests (push) Successful in 46s
bc35271903
bot/submit.py — the thesis module.
- Pipeline: freshness (§4.8: Bid/Opportunity/calldata must describe the same block+registry_hash+
  route) → contention gate (optional Opportunity.contention) → route revert-streak backoff (4×3^(n-1)
  blocks, cap 4800, success resets; sim reverts never count — learnings §4.4) → [sign concurrently with]
  → simulate (one eth_blockNumber+eth_call batch on the best read endpoint; drop if head-opp.block>=2
  or if the read endpoint's state is behind opp.block — a fast WS head is imported by the local node
  ~120 ms later, so state_behind tries the next read endpoint rather than false-failing) → dry-run stops
  here (default) → fan-out the identical raw tx to every submit endpoint in parallel, first hash wins →
  receipt watcher (fills included_block/status/gas_used, drives the backoff).
- Signer interface + CastSigner (shells out to `cast mktx --keystore --password-file`, key never on
  argv/env/log) + LocalSigner (bot/vendor, in-process). Every signature — either signer — is verified
  by public-key recovery against the exact requested tx fields before it can be broadcast
  (`_verify_raw`); CastSigner's output gets the same check, so a mismatched invocation can't ship either.
  LocalSigner self-tests sign+recover at construction. Both signers bit-exact vs `cast mktx` fixtures
  (Foundry 1.8.1, 3 raw-tx cases spanning nonce/gas/fee/data extremes + the EIP-155 spec vector).
- `executor_address` / `key_path` fall back to the shared `[executor].address` / `hot_key_file` (no
  duplicate source of truth with the security lane's deployment record).
- `dry_run = true` is the default and this wave's only mode (config refuses `dry_run=false` without a
  real signer). NonceTracker (single lane, resync on "nonce too low"), RouteBackoff, SkipCounters
  (`arcmev_head_skipped_total{reason}`), all secrets redacted before any log/metric/exception string.
- `python3 -m bot.submit --measure N` : dry-run simulate-latency harness against a real registry route
  and a throwaway unfunded key (address only ever printed).

config/bot.example.toml — `[submit]` section (reuses `[executor]` for the address/key path).
docs/execution.md — design + every measured number (rpc_pool head arrival, signer timing, simulate
latency against the real deployed ArcCycleExecutor on LXC 140, the owner-key rule).

Tests: bot/tests/test_submit.py — offline, fake JSON-RPC nodes (two distinct loopback hosts for the
submit-fan-out paths), stub `cast` runner for CastSigner. Covers: bit-exact signing, freshness refusal,
contention/backoff gates, state-behind-opp.block retry, sim_fail/sim_unprofitable/dropped_stale,
dry-run never signs, live fan-out first-hash-wins + receipt watcher + route backoff on a status-0
receipt, nonce-too-low resync, config validation (dry_run=false needs a real signer).

Measured on LXC 140 (Foundry 1.8.1, throwaway unfunded keystore, real deployed executor
0x1c28…7e02): LocalSigner p50 48.6 ms / p90 78 ms (n=50, loaded box) vs CastSigner p50 222 ms / p90
374 ms (n=10); simulate() against the real contract (genuine on-chain revert) p50 59.6 ms / p90 115 ms
/ p99 261 ms / max 385 ms over 182 samples.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Signed-off-by: BuddySan <[email protected]>
fortiblox-dev left a comment

automerger v2 auto-approved: all 7 gates pass. Approver: fortiblox-dev (opposite-identity rule). Gates: mergeable=true,label=auto-merge,author=buddysan,identity=ok,status_checks=not_required,signatures=upstream_BP_only,opt_in=repo_marker.

automerger v2 auto-approved: all 7 gates pass. Approver: fortiblox-dev (opposite-identity rule). Gates: mergeable=true,label=auto-merge,author=buddysan,identity=ok,status_checks=not_required,signatures=upstream_BP_only,opt_in=repo_marker.
buddysan deleted branch perf/submit-signer 2026-09-16 15:02:50 +00:00
Author
Owner

Auto-merged by forgejo-automerger at 2026-09-16 15:02:50 UTC. Approver: fortiblox-dev (opposite-identity rule, Option C upgrade 2026-05-13). Merger: buddysan via fast-forward-only (NOT admin force_merge). Criteria passed: mergeable=true,label=auto-merge,author=buddysan,identity=ok,status_checks=not_required,signatures=upstream_BP_only,opt_in=repo_marker. Branch bc35271903 merged onto main and feature branch deleted. See project_forgejo_automerger memory for rationale.

Auto-merged by **forgejo-automerger** at 2026-09-16 15:02:50 UTC. **Approver:** `fortiblox-dev` (opposite-identity rule, Option C upgrade 2026-05-13). **Merger:** `buddysan` via `fast-forward-only` (NOT admin `force_merge`). **Criteria passed:** `mergeable=true,label=auto-merge,author=buddysan,identity=ok,status_checks=not_required,signatures=upstream_BP_only,opt_in=repo_marker`. Branch `bc35271903` merged onto `main` and feature branch deleted. See `project_forgejo_automerger` memory for rationale.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
fortiblox/arc-mev-bot!20
No description provided.