feat(cli): wire submitter into process_opportunity behind --execute#305
Merged
feat(cli): wire submitter into process_opportunity behind --execute#305
Conversation
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.
This was referenced Apr 23, 2026
# Conflicts: # crates/charon-cli/src/main.rs # crates/charon-core/src/config.rs # crates/charon-executor/src/builder.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
charon-executorhadSubmitter,NonceManager,GasOracle, andTxBuilderall implemented + unit-tested, but the CLI listen loop only importedSimulator+TxBuilderand terminated atsim.simulate()with a literal "no broadcast" comment.sim -> fees -> estimate -> nonce -> sign -> submitpath behind a--executeflag (default off). Existing scan-only behavior is unchanged.DO NOT ENABLE
--executeON MAINNET YET--executeships OFF by default. It MUST stay off on mainnet until the follow-up "profit accuracy" PR (step 4) lands.process_opportunitycurrently 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.rsbuild_txno longer hits the provider for the nonce. Takesnonce: u64parameter and is now a pure sync function. Rationale:get_transaction_countinsidebuild_txraced againstNonceManager::next()and would hand out duplicate nonces when two opportunities land in the same block.crates/charon-cli/src/main.rsListengains--execute(defaultfalse).ExecHarness { gas_oracle, nonce_manager, submitter, signer_address }built only when every gate passes: signer present, non-zero liquidator,private_rpc_urlset. Any failed gate aborts startup.process_opportunityreturnsProcessOutcome { Dropped, Queued, Broadcast }. Queue push happens before broadcast so submit failure still leaves a ranked candidate on record.broadcast()helper:fetch_params->estimate_gas(30% buffer) ->nonce.next()->build_tx->sign->submitter.submit. Sign errors and everySubmitError::RpcRejectedtrigger a one-shotNonceManager::resync;ConnectionLostleaves the counter alone because the tx may still land.Safety gates when
--execute = trueBOT_SIGNER_KEYparseable (else bail).liquidator.contract_address != 0x0(defence-in-depth withvalidate()).private_rpc_urlset on chain (no public-mempool fallback in CLI).warn!at startup so the operator cannot miss that broadcast is on.Review results
blockchain-code-reviewer: request-changes -> appliedRpcRejected(not just "nonce" substring match)mev-sim-auditor: stood down on live fork replay, static audit passedSubmitterrequireshttps:///wss://(anvil ishttp://); fork harness lives onfeat/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.broadcast()matches spec,build_txpure sync eliminates nonce race, ceiling-trip assertion passes by inspection (bail!fires beforenonce.next()).--executeon mainnet.nonce too high,resyncrestores chain value, signed-but-undelivered opp is lost. Accept one-opp loss, rely on next tick.max_fee_wei / 1e9truncates sub-gwei excess. Fine on BSC, loses precision on L2s.Test plan
cargo build --workspacecleancargo clippy -p charon-cli -p charon-executor -- -D warningscleancargo test -p charon-core -p charon-executor -p charon-cli-> all pass (core 24, executor 19)blockchain-code-reviewer-> APPROVE after applying fixesmev-sim-auditorstatic audit -> wiring correct, live replay deferred to step 4mev-sim-auditoronce Submitter scheme-gate path is unblocked (after cold-wallet port + profit-accuracy PR lands)