feat(bot): simulate-before-send + pluggable Signer + fan-out submit (WP 7912, 7914) #20
No reviewers
Labels
No labels
auto-merge
auto-merge
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
fortiblox/arc-mev-bot!20
Loading…
Reference in a new issue
No description provided.
Delete branch "perf/submit-signer"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 (optionalOpportunity.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 ifhead - opp.block >= 2(§4.6) or retries the next read endpoint if that endpoint's state is behindopp.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 everysubmitendpoint in parallel, first hash wins → receipt watcher fillsincluded_block/status/gas_usedand drives the backoff.Signer interface:
LocalSigner(in-process,bot/vendor) andCastSigner(cast mktx --keystore --password-file, key never on argv/env/log). Both are bit-exact againstcast mktxfixtures (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 mismatchedcastinvocation can never broadcast.executor_address/key_pathfall back to the already-committed[executor].address/hot_key_file(no duplicate source of truth with security-director's deployment record).dry_run = trueis the default and the config refusesdry_run=falsewithout a real signer configured.python3 -m bot.submit --measure Nis 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:
LocalSignerp50 48.6 ms / p90 78 ms (n=50) vsCastSignerp50 222 ms / p90 374 ms (n=10) — both produced byte-identical raw transactions for the same key/tx.simulate()against the real deployedArcCycleExecutor(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 — nosearch.pyexists 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 testgreen — 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 stubcastrunner forCastSigner): 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 lowresync, config validation (dry_run=falseneeds a real signer, executor/key fallback to[executor])🤖 Generated with Claude Code
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]>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.
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:buddysanviafast-forward-only(NOT adminforce_merge). Criteria passed:mergeable=true,label=auto-merge,author=buddysan,identity=ok,status_checks=not_required,signatures=upstream_BP_only,opt_in=repo_marker. Branchbc35271903merged ontomainand feature branch deleted. Seeproject_forgejo_automergermemory for rationale.