use std::collections::BTreeMap;
use alloy::primitives::U256;
use degenbot_arbitrage::SimFailure;
use degenbot_executor::composers::{HopInfo, PathInfo};
#[derive(Debug, Clone)]
pub struct BatchOutcome {
pub path_id: u64,
pub block: u64,
pub assembly: AssemblyVerdict,
pub simulate: Option<SimulateVerdict>,
pub submit: Option<SubmitVerdict>,
pub path_info: PathInfoView,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AssemblyVerdict {
Assembled,
SkipEmptyHops,
SkipResolveMiss,
SkipPayloadServed,
SkipSuppressed,
SkipThinMargin,
SkipDivergentPool,
SkipFeeOnTransfer,
}
impl AssemblyVerdict {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Assembled => "assembled",
Self::SkipEmptyHops => "skip-empty-hops",
Self::SkipResolveMiss => "skip-resolve-miss",
Self::SkipPayloadServed => "skip-payload-served",
Self::SkipSuppressed => "skip-suppressed",
Self::SkipThinMargin => "skip-thin-margin",
Self::SkipDivergentPool => "skip-divergent-pool",
Self::SkipFeeOnTransfer => "skip-fee-on-transfer",
}
}
}
#[derive(Debug, Clone)]
pub enum SimulateVerdict {
Profitable(SimReceipt),
GasUnprofitable(SimReceipt),
Failed(Box<FailureDetail>),
Exception,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SimReceipt {
pub gross_profit: U256,
pub net_profit: U256,
pub gas_used: u64,
pub priority_fee: u128,
}
pub type FailureDetail = SimFailure;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SubmitVerdict {
Submitted,
Failed(FailureKind),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FailureKind {
Revert,
NoProfit,
Int128Overflow,
EncodeFailed,
RpcFailed,
Stale,
Other,
}
impl FailureKind {
#[must_use]
pub fn from_bucket(bucket: &str) -> Self {
match bucket {
"no-profit" => Self::NoProfit,
"int128-overflow" => Self::Int128Overflow,
"encode-failed" => Self::EncodeFailed,
"rpc-failed" => Self::RpcFailed,
"stale" => Self::Stale,
"empty" | "numeric-revert" => Self::Revert,
other => {
if other.starts_with("unknown:0x")
|| other.starts_with("short:")
|| other.starts_with("Panic(")
|| other.starts_with("Error(")
|| other.ends_with("NotSettled")
{
Self::Revert
} else {
Self::Other
}
}
}
}
#[must_use]
pub fn from_skip_reason(reason: °enbot_submission::SkipReason) -> Self {
match reason {
degenbot_submission::SkipReason::BroadcastFailed(_) => Self::RpcFailed,
degenbot_submission::SkipReason::PoolsClaimed
| degenbot_submission::SkipReason::DryRun
| degenbot_submission::SkipReason::InjectCode => Self::Other,
}
}
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Revert => "revert",
Self::NoProfit => "no-profit",
Self::Int128Overflow => "int128-overflow",
Self::EncodeFailed => "encode-failed",
Self::RpcFailed => "rpc-failed",
Self::Stale => "stale",
Self::Other => "other",
}
}
}
#[derive(Debug, Clone)]
pub struct PathInfoView {
pub path_type: String,
pub hops: Vec<HopInfo>,
}
impl PathInfoView {
#[must_use]
pub fn empty() -> Self {
Self {
path_type: String::new(),
hops: Vec::new(),
}
}
}
impl From<&PathInfo> for PathInfoView {
fn from(path: &PathInfo) -> Self {
let mut names: Vec<&'static str> = Vec::with_capacity(path.hops.len());
for hop in &path.hops {
names.push(match hop {
HopInfo::V2(_) => "V2",
HopInfo::V3(_) => "V3",
HopInfo::V4(_) => "V4",
});
}
Self {
path_type: names.join("-"),
hops: path.hops.clone(),
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct BatchCounters {
pub candidate_count: usize,
pub profitable_count: usize,
pub gas_unprofitable_count: usize,
pub fail_count: usize,
pub exception_count: usize,
pub suppressed_count: usize,
pub thin_dropped: usize,
pub divergent_dropped: usize,
pub fot_dropped: usize,
pub empty_hop_count: usize,
pub resolve_miss_count: usize,
pub payload_served_count: usize,
pub fail_buckets: BTreeMap<String, usize>,
}
#[must_use]
pub fn fold_counters(batch: &[BatchOutcome]) -> BatchCounters {
let mut c = BatchCounters::default();
for o in batch {
match o.assembly {
AssemblyVerdict::Assembled => {}
AssemblyVerdict::SkipEmptyHops => c.empty_hop_count += 1,
AssemblyVerdict::SkipResolveMiss => c.resolve_miss_count += 1,
AssemblyVerdict::SkipPayloadServed => c.payload_served_count += 1,
AssemblyVerdict::SkipSuppressed => c.suppressed_count += 1,
AssemblyVerdict::SkipThinMargin => c.thin_dropped += 1,
AssemblyVerdict::SkipDivergentPool => c.divergent_dropped += 1,
AssemblyVerdict::SkipFeeOnTransfer => c.fot_dropped += 1,
}
match &o.simulate {
Some(SimulateVerdict::Profitable(_)) => {
c.candidate_count += 1;
c.profitable_count += 1;
}
Some(SimulateVerdict::GasUnprofitable(_)) => {
c.candidate_count += 1;
c.gas_unprofitable_count += 1;
}
Some(SimulateVerdict::Failed(f)) => {
c.candidate_count += 1;
c.fail_count += 1;
*c.fail_buckets.entry(f.bucket.clone()).or_insert(0) += 1;
}
Some(SimulateVerdict::Exception) => {
c.candidate_count += 1;
c.exception_count += 1;
}
None => {}
}
}
c
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
reason = "tests assert on known-valid inputs; parse_address fixtures are valid"
)]
mod tests {
use super::*;
use degenbot_core::address_utils::parse_address;
use degenbot_executor::composers::V2HopInfo;
fn v2_path() -> PathInfo {
let pool = parse_address("0x1111111111111111111111111111111111111111").unwrap();
let t0 = parse_address("0x2222222222222222222222222222222222222222").unwrap();
let t1 = parse_address("0x3333333333333333333333333333333333333333").unwrap();
PathInfo::new(vec![HopInfo::V2(V2HopInfo {
pool_address: pool,
token0_address: t0,
token1_address: t1,
fee: 30,
zfo: true,
})])
}
#[test]
fn assembly_verdict_labels_are_stable() {
assert_eq!(AssemblyVerdict::Assembled.label(), "assembled");
assert_eq!(AssemblyVerdict::SkipEmptyHops.label(), "skip-empty-hops");
assert_eq!(
AssemblyVerdict::SkipResolveMiss.label(),
"skip-resolve-miss"
);
assert_eq!(
AssemblyVerdict::SkipPayloadServed.label(),
"skip-payload-served"
);
assert_eq!(AssemblyVerdict::SkipSuppressed.label(), "skip-suppressed");
assert_eq!(AssemblyVerdict::SkipThinMargin.label(), "skip-thin-margin");
assert_eq!(
AssemblyVerdict::SkipDivergentPool.label(),
"skip-divergent-pool"
);
assert_eq!(
AssemblyVerdict::SkipFeeOnTransfer.label(),
"skip-fee-on-transfer"
);
}
#[test]
fn failure_kind_taxonomy_matches_settlement_bot_precedent() {
assert_eq!(FailureKind::from_bucket("no-profit"), FailureKind::NoProfit);
assert_eq!(
FailureKind::from_bucket("int128-overflow"),
FailureKind::Int128Overflow
);
assert_eq!(
FailureKind::from_bucket("encode-failed"),
FailureKind::EncodeFailed
);
assert_eq!(
FailureKind::from_bucket("rpc-failed"),
FailureKind::RpcFailed
);
assert_eq!(FailureKind::from_bucket("stale"), FailureKind::Stale);
assert_eq!(FailureKind::from_bucket("empty"), FailureKind::Revert);
assert_eq!(
FailureKind::from_bucket("CurrencyNotSettled"),
FailureKind::Revert
);
assert_eq!(FailureKind::from_bucket("other"), FailureKind::Other);
assert_eq!(FailureKind::Revert.label(), "revert");
assert_eq!(FailureKind::RpcFailed.label(), "rpc-failed");
}
#[test]
fn path_info_view_derives_the_type_label_and_keeps_hops() {
let view = PathInfoView::from(&v2_path());
assert_eq!(view.path_type, "V2");
assert_eq!(view.hops.len(), 1);
}
#[test]
fn counters_are_exact_folds_over_records() {
let path = PathInfoView::from(&v2_path());
let receipt = SimReceipt {
gross_profit: U256::from(100_u64),
net_profit: U256::from(90_u64),
gas_used: 300_000,
priority_fee: 2,
};
let failure = FailureDetail {
path_id: 3,
bucket: "no-profit".to_string(),
fail_index: None,
revert_data: alloy::primitives::Bytes::new(),
reverting_frame: None,
captured_swaps: Vec::new(),
log_full_count: 0,
reverted_swaps: Vec::new(),
optimal_input: 0,
hop_outputs: Vec::new(),
call_trace: Vec::new(),
weth_before: 0,
weth_after: 0,
eth_before: 0,
eth_after: 0,
erc6909_before: 0,
erc6909_after: 0,
};
let batch = vec![
BatchOutcome {
path_id: 1,
block: 100,
assembly: AssemblyVerdict::Assembled,
simulate: Some(SimulateVerdict::Profitable(receipt)),
submit: Some(SubmitVerdict::Submitted),
path_info: path.clone(),
},
BatchOutcome {
path_id: 2,
block: 100,
assembly: AssemblyVerdict::SkipSuppressed,
simulate: None,
submit: None,
path_info: path.clone(),
},
BatchOutcome {
path_id: 3,
block: 100,
assembly: AssemblyVerdict::Assembled,
simulate: Some(SimulateVerdict::Failed(Box::new(failure))),
submit: None,
path_info: path,
},
];
let c = fold_counters(&batch);
assert_eq!(c.candidate_count, 2);
assert_eq!(c.profitable_count, 1);
assert_eq!(c.fail_count, 1);
assert_eq!(c.suppressed_count, 1);
assert_eq!(c.fail_buckets.get("no-profit"), Some(&1));
}
}