feat(core): gas-aware profit calculator + profit-ordered OpportunityQueue#40
Merged
feat(core): gas-aware profit calculator + profit-ordered OpportunityQueue#40
Conversation
Shared decision point for the scanner → router → executor handoff:
given a candidate liquidation priced entirely in USD cents, decide
whether its net beats the configured `min_profit_usd` threshold.
Formula:
gross = repay × liquidation_bonus_bps / 10_000
slippage = gross × slippage_bps / 10_000
net = gross − flash_fee − gas − slippage
Design notes:
- Integer USD cents throughout. Caller converts once at the boundary
using the Chainlink price cache; no float drift on the hot path
- `ProfitInputs` / `NetProfit` structs make the interface auditable
- Overflow-safe: `checked_mul` + `checked_add` on every arithmetic
step; bogus bps values (> 10_000) are rejected up front
- Errors carry the itemised cost breakdown so logs show exactly why a
candidate was dropped
Five unit tests cover: healthy-path numbers, below-threshold
rejection, cost-eats-gross rejection, bogus bps validation, zero
threshold acceptance.
Final Day-3 piece: a priority queue the executor drains one per block, always taking the highest-net-profit liquidation that hasn't aged out. - `BinaryHeap<QueueEntry>` under the hood; ordering keyed on `net_profit_usd_cents` so the heap root is the richest opportunity - Private `QueueEntry` wrapper keeps `Ord` off the public `LiquidationOpportunity` — the type stays free of ordering semantics in unrelated contexts - TTL in blocks (default 2, ≈ 6 s on BSC). Stale entries get silently dropped on `pop` and can be swept via `prune_stale` - `push` / `pop` / `prune_stale` / `len` / `is_empty` surface; callers track the current block themselves so the queue has no concept of time beyond what the caller feeds it - Five unit tests: ordering, TTL boundary, stale drop, prune count, default TTL
This was referenced Apr 22, 2026
Closed
Closed
Closed
Rebuild the profit path and opportunity queue end-to-end around integer units and typed errors so the executor can reason about failure modes without anyhow string matching. profit.rs: - replace anyhow::Result with thiserror ProfitError enum covering InvalidBps, InvalidPrice, Overflow, UnsupportedDecimals, Unprofitable, BelowMinThreshold - drop all f64 from the profit path; min_profit threshold is now u64 micro-USD matching BotConfig - add Price (1e8) and from_opportunity constructor that converts wei amounts through U256 into USD cents via a per-token Chainlink price; collateral and debt priced independently so the formula works for non-stable/non-matching markets - charge slippage against expected_swap_output_cents, not the gross bonus, so the budget reflects the swap the bot actually performs - ProfitInputs, NetProfit, ProfitError all #[non_exhaustive] - field-level rustdoc documents every unit + conversion path - tests: u64::MAX overflow on slippage mul, u64::MAX overflow on total-cost add, 10_000 bps boundary valid, 10_001 rejected, cost > gross, min_profit == 0, zero price rejected, decimals > 18 rejected, realistic BSC case (1 BNB @ $600 with 10% bonus) queue.rs: - wrap BinaryHeap in Arc<tokio::sync::Mutex<_>>; OpportunityQueue is Clone + Send + Sync; push/pop/prune/len/is_empty are async - QueueEntry Ord uses (net_profit_cents, inserted_at_block) lexicographic with fresher-first tie-break; manual PartialEq mirrors Ord exactly - saturating_sub on the staleness check keeps entries alive across a reorg rewind instead of wrapping to a massive age - QueueEntry gains #[non_exhaustive] - tests: tie-break (fresher wins), reorg 105->104 rewind keeps entry, pruned entry stays dropped across rewind, 16 concurrent producers / 1 consumer with ordering invariant asserted types.rs: - LiquidationOpportunity::with_profit(position, .., net_profit) constructor that copies net_profit.net_usd_cents into the opportunity, eliminating dual representation drift config.rs + config/default.toml: - BotConfig.min_profit_usd (f64) -> min_profit_usd_1e6 (u64) stored in micro-USD so TOML stays integer-only; executor converts to cents internally (/ 10_000) Closes #146 #147 #148 #149 #150 #151 #152 #153 #154 #155 #156 #157
…nd-queue # Conflicts: # Cargo.lock # Cargo.toml # config/default.toml # crates/charon-core/src/config.rs # crates/charon-core/src/lib.rs # crates/charon-core/src/types.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.
Two bundled changes that sit between router and executor.
Profit calculator (
profit.rs):ProfitInputs/NetProfitstructs — integer USD cents throughout, no float driftcalculate_profit(inputs, min_profit_usd)formula:checked_mul/checked_add; bogus bps (> 10_000) rejectedOpportunityQueue (
queue.rs):BinaryHeap<QueueEntry>keyed onnet_profit_usd_cents(max-heap root = most profitable)QueueEntrywrapper keepsOrdoff publicLiquidationOpportunitypop, sweepable viaprune_staleDepends on #14 (
feat/14-flashloan-router).