Skip to content

feat(cli): wire submitter into process_opportunity behind --execute#305

Merged
obchain merged 3 commits intomainfrom
feat/wire-submitter-cli
Apr 24, 2026
Merged

feat(cli): wire submitter into process_opportunity behind --execute#305
obchain merged 3 commits intomainfrom
feat/wire-submitter-cli

Conversation

@obchain
Copy link
Copy Markdown
Owner

@obchain obchain commented Apr 23, 2026

Summary

  • Local-mainnet validator flagged that the branch's headline feature was unreachable at runtime: charon-executor had Submitter, NonceManager, GasOracle, and TxBuilder all implemented + unit-tested, but the CLI listen loop only imported Simulator + TxBuilder and terminated at sim.simulate() with a literal "no broadcast" comment.
  • This PR wires the full sim -> fees -> estimate -> nonce -> sign -> submit path behind a --execute flag (default off). Existing scan-only behavior is unchanged.

DO NOT ENABLE --execute ON MAINNET YET

--execute ships OFF by default. It MUST stay off on mainnet until the follow-up "profit accuracy" PR (step 4) lands. process_opportunity currently uses:

  • repay_to_usd_cents_placeholder (treats every token as 18-dec $1)
  • PLACEHOLDER_GAS_USD_CENTS = 50 (fixed constant)

For non-stablecoin debt (BTCB / ETH / BNB borrowers) the placeholder underprices by ~10^4, so the profit gate passes unprofitable txs and the signer bleeds gas on every broadcast. This PR's scope is wiring only — the econ math fix is tracked as the next PR in the config-unblock series.

Stacking

Base: fix/validate-zero-addr-liquidator (PR #304). Must merge #303 + #304 first.

Key changes

crates/charon-executor/src/builder.rs

  • build_tx no longer hits the provider for the nonce. Takes nonce: u64 parameter and is now a pure sync function. Rationale: get_transaction_count inside build_tx raced against NonceManager::next() and would hand out duplicate nonces when two opportunities land in the same block.

crates/charon-cli/src/main.rs

  • Listen gains --execute (default false).
  • ExecHarness { gas_oracle, nonce_manager, submitter, signer_address } built only when every gate passes: signer present, non-zero liquidator, private_rpc_url set. Any failed gate aborts startup.
  • process_opportunity returns ProcessOutcome { Dropped, Queued, Broadcast }. Queue push happens before broadcast so submit failure still leaves a ranked candidate on record.
  • New broadcast() helper: fetch_params -> estimate_gas (30% buffer) -> nonce.next() -> build_tx -> sign -> submitter.submit. Sign errors and every SubmitError::RpcRejected trigger a one-shot NonceManager::resync; ConnectionLost leaves the counter alone because the tx may still land.

Safety gates when --execute = true

  • BOT_SIGNER_KEY parseable (else bail).
  • liquidator.contract_address != 0x0 (defence-in-depth with validate()).
  • private_rpc_url set on chain (no public-mempool fallback in CLI).
  • Loud warn! at startup so the operator cannot miss that broadcast is on.

Review results

blockchain-code-reviewer: request-changes -> applied

  • Resync on every RpcRejected (not just "nonce" substring match)
  • Resync on sign failure (nonce consumed, tx never signed)
  • Gas buffer 20% -> 30%

mev-sim-auditor: stood down on live fork replay, static audit passed

  • Live replay blocked by: Submitter requires https:///wss:// (anvil is http://); fork harness lives on feat/25; cold-wallet sweep ([PR #37] p0-blocker: profit swept to hot-wallet owner, not cold wallet — violates CLAUDE.md safety invariant #120) not on this branch's contract.
  • Static audit confirmed: safety gates correct, queue-before-broadcast order correct, broadcast() matches spec, build_tx pure sync eliminates nonce race, ceiling-trip assertion passes by inspection (bail! fires before nonce.next()).
  • Foot-guns uncovered (all tracked as follow-ups):
    • P1 econ bug — the placeholder profit math documented above. Blocks --execute on mainnet.
    • P2 private-relay 200-then-drop — bloxroute occasionally acks then drops, local counter sits ahead of chain, next opp hits nonce too high, resync restores chain value, signed-but-undelivered opp is lost. Accept one-opp loss, rely on next tick.
    • P3 gas ceiling sub-gwei roundingmax_fee_wei / 1e9 truncates sub-gwei excess. Fine on BSC, loses precision on L2s.

Test plan

  • cargo build --workspace clean
  • cargo clippy -p charon-cli -p charon-executor -- -D warnings clean
  • cargo test -p charon-core -p charon-executor -p charon-cli -> all pass (core 24, executor 19)
  • blockchain-code-reviewer -> APPROVE after applying fixes
  • mev-sim-auditor static audit -> wiring correct, live replay deferred to step 4
  • Live fork replay via mev-sim-auditor once Submitter scheme-gate path is unblocked (after cold-wallet port + profit-accuracy PR lands)

obchain added 2 commits April 23, 2026 23:56
eth_call to address(0) returns empty bytes (no revert), so a config
that ships with liquidator.contract_address = 0x0 let the simulator
silently "pass" for any calldata, producing a false-positive gate to
live submission. Add ConfigError::ZeroAddressLiquidator, gated on
!allow_public_mempool so local anvil / testnet runs before a real
deploy still work.

Refactor test helper base_config(_, bool) -> base_config(_, Option<Address>)
plus a nonzero_liquidator sentinel so existing tests stay focused on
the rule they actually exercise.
Before this change, charon-executor carried Submitter, NonceManager,
GasOracle, and friends as library code but the CLI listen loop only
imported Simulator and TxBuilder, terminating after sim.simulate()
with a literal "no broadcast" comment. Local-mainnet validation
flagged the branch's headline feature as unreachable at runtime.

Changes:
- charon-executor builder.build_tx no longer hits the provider for the
  nonce. It takes nonce: u64 as a parameter and is now a pure sync
  function. Rationale: calling get_transaction_count inside build_tx
  would race against NonceManager::next() and hand out duplicate
  nonces when two opportunities land in the same block.
- charon-cli Listen subcommand gains --execute (default off). When
  set, run_listen builds an ExecHarness (GasOracle + NonceManager +
  Submitter + signer address) only after checking signer presence,
  non-zero liquidator, and a present private_rpc_url. Any failed gate
  aborts startup rather than silently degrading to scan-only.
- process_opportunity now returns a ProcessOutcome { Dropped, Queued,
  Broadcast } trichotomy. The queue push happens before broadcast so
  a submit failure still leaves a ranked candidate on record.
- New broadcast() helper: fetch EIP-1559 fees, eth_estimateGas with a
  30% buffer, claim a local nonce, build + sign + submit. Sign errors
  and every SubmitError::RpcRejected trigger a one-shot
  NonceManager::resync so an already-consumed nonce does not poison
  subsequent blocks; ConnectionLost leaves the counter alone because
  the tx may still land.

--execute stays off by default; existing scan-only behavior is
unchanged. Fork-level validation (anvil BSC, real underwater borrower,
hot-wallet balance delta assertion) follows in a separate
mev-sim-auditor run before merge.
@obchain obchain changed the base branch from fix/validate-zero-addr-liquidator to main April 24, 2026 18:13
# Conflicts:
#	crates/charon-cli/src/main.rs
#	crates/charon-core/src/config.rs
#	crates/charon-executor/src/builder.rs
@obchain obchain merged commit 772e240 into main Apr 24, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant