Skip to content

Commit 94dd057

Browse files
committed
fix(detect): exclure quote tokens du SniperCluster + alimenter BotRepetition sur UR envelope
Deux bugs identifies en analysant un run live de ~7min sur Ethereum mainnet (8065 pending tx, 83 router hits, 2 SniperCluster fires). PROBLEME 1 (faux positif SniperCluster) : - 11/29 swaps decodes avaient token_out=WETH (38%) - Les 2 detections SniperCluster fires etaient TOUTES sur WETH - Semantique reelle : un sniper cluster = N bots qui rushent un NOUVEAU token (WETH -> MEMECOIN_X), pas N personnes qui revendent en WETH - WETH/USDC/USDT/DAI sont des QUOTE tokens du marche, exclus Fix : ajout const QUOTE_TOKENS [WETH, USDC, USDT, DAI] et is_quote_token(). SniperCluster check : if token_out is quote -> skip. PROBLEME 2 (BotRepetition aveugle sur 60% du flow) : - 3 addresses bot tres visibles dans le log : 9 hits 0x8ca0A5d199... 7 hits 0x295fc34f... 6 hits 0x0dCfbEf3... - Aucune detection BotRepetition fired sur eux - Cause : ces bots passent par Universal Router, decode en envelope, et observation_from_decoded() retournait None -> detector ne les voyait jamais. Fix : refactor Observation pour avoir swap: Option<SwapDetails>. - ExactInput / ExactInputPath -> Observation { swap: Some(...) } - UR envelope / Multicall -> Observation { swap: None } (= from-only) - SniperCluster et LargeWethSwap requierent swap=Some, BotRepetition fonctionne sur from-only obs. main.rs : observation_from_decoded() retourne Some(Observation { swap: None }) pour UR envelope et Multicall (au lieu de None tout court). Tests ajoutes (4) : - sniper_cluster_excludes_weth_as_token_out - sniper_cluster_excludes_usdc_as_token_out - bot_repetition_fires_on_envelope_only - sniper_cluster_skips_envelope_observations Total : 14/14 tests verts. Clippy -D warnings clean. Effet attendu sur le prochain run live : - 0 SniperCluster sur WETH/USDC/USDT/DAI (vrais clusters sur memecoins seulement) - BotRepetition fire enfin sur les ~3 bots Universal Router recurrents
1 parent ffe0e37 commit 94dd057

2 files changed

Lines changed: 193 additions & 37 deletions

File tree

src/detect.rs

Lines changed: 172 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,25 @@ use alloy::primitives::{Address, B256, U256, address};
1616
use std::collections::{HashMap, VecDeque};
1717
use std::time::{Duration, Instant};
1818

19-
/// WETH canonique Ethereum mainnet (utilise pour la regle large-swap).
19+
/// WETH canonique Ethereum mainnet.
2020
pub const WETH: Address = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
21+
/// USDC natif Circle.
22+
pub const USDC: Address = address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
23+
/// USDT (Tether).
24+
pub const USDT: Address = address!("dAC17F958D2ee523a2206206994597C13D831ec7");
25+
/// DAI (MakerDAO).
26+
pub const DAI: Address = address!("6B175474E89094C44Da98b954EedeAC495271d0F");
2127

22-
// --- Seuils v0 (tweakable via Detector::with_thresholds) ---
28+
/// Tokens "quote" du marche : utilises comme reference de prix, pas comme
29+
/// cible de sniping. Exclus du `token_out` de SniperCluster pour eviter les
30+
/// faux-positifs (tout vendeur de memecoin pour WETH/stables compterait sinon).
31+
pub const QUOTE_TOKENS: [Address; 4] = [WETH, USDC, USDT, DAI];
32+
33+
fn is_quote_token(a: Address) -> bool {
34+
QUOTE_TOKENS.contains(&a)
35+
}
36+
37+
// --- Seuils v0 ---
2338

2439
const CLUSTER_MIN_SWAPS: usize = 3;
2540
const CLUSTER_WINDOW: Duration = Duration::from_secs(30);
@@ -30,15 +45,24 @@ const REPETITION_WINDOW: Duration = Duration::from_secs(10);
3045
/// 0.5 ETH = 5 * 10^17 wei.
3146
const LARGE_SWAP_WETH_WEI: u128 = 500_000_000_000_000_000;
3247

33-
/// Tout swap suffisamment decode pour alimenter la detection.
48+
/// Details d'un swap decode (token_in/out + amount). Absent pour les
49+
/// observations "from-only" issues d'envelopes (Universal Router, multicall)
50+
/// qui permettent BotRepetition mais ni SniperCluster ni LargeWethSwap.
3451
#[derive(Clone, Debug)]
35-
pub struct Observation {
36-
pub from: Address,
52+
pub struct SwapDetails {
3753
pub token_in: Address,
3854
pub token_out: Address,
39-
/// Quantite d'entree (en wei pour WETH, en raw token units sinon).
55+
/// Quantite d'entree (wei pour WETH-in, raw token units sinon).
4056
pub amount_in: U256,
57+
}
58+
59+
/// Une observation a alimenter au [`Detector`].
60+
#[derive(Clone, Debug)]
61+
pub struct Observation {
62+
pub from: Address,
4163
pub hash: B256,
64+
/// `Some(...)` quand on a decode le swap, `None` pour UR/multicall envelope.
65+
pub swap: Option<SwapDetails>,
4266
}
4367

4468
/// Une detection emise par [`Detector::observe`].
@@ -109,35 +133,49 @@ impl Detector {
109133

110134
let mut detections = Vec::new();
111135

112-
// 2. Large WETH swap : detection unique sur l'observation entrante.
113-
if obs.token_in == WETH && obs.amount_in >= U256::from(LARGE_SWAP_WETH_WEI) {
136+
// 2. Large WETH swap : seulement si on a les details du swap.
137+
if let Some(s) = &obs.swap
138+
&& s.token_in == WETH
139+
&& s.amount_in >= U256::from(LARGE_SWAP_WETH_WEI)
140+
{
114141
detections.push(Detection::LargeWethSwap {
115142
from: obs.from,
116-
token_out: obs.token_out,
117-
amount_in_wei: obs.amount_in,
143+
token_out: s.token_out,
144+
amount_in_wei: s.amount_in,
118145
hash: obs.hash,
119146
});
120147
}
121148

122149
// 3. On ajoute apres pour que la detection prenne en compte l'entrante.
123150
self.history.push_back((now, obs.clone()));
124151

125-
// 4. Sniper cluster : compter les obs vers obs.token_out dans CLUSTER_WINDOW.
126-
let cluster_cutoff = now - CLUSTER_WINDOW;
127-
let cluster: Vec<&(Instant, Observation)> = self
128-
.history
129-
.iter()
130-
.filter(|(t, o)| *t >= cluster_cutoff && o.token_out == obs.token_out)
131-
.collect();
132-
if cluster.len() >= CLUSTER_MIN_SWAPS {
133-
detections.push(Detection::SniperCluster {
134-
token_out: obs.token_out,
135-
n_swaps: cluster.len(),
136-
sample_hashes: cluster.iter().rev().take(3).map(|(_, o)| o.hash).collect(),
137-
});
152+
// 4. Sniper cluster : seulement si le token_out n'est PAS un quote token
153+
// (= eviter les faux-positifs ou tout le monde vend pour WETH/USDC).
154+
if let Some(s_in) = &obs.swap
155+
&& !is_quote_token(s_in.token_out)
156+
{
157+
let target_token_out = s_in.token_out;
158+
let cluster_cutoff = now - CLUSTER_WINDOW;
159+
let cluster: Vec<&(Instant, Observation)> = self
160+
.history
161+
.iter()
162+
.filter(|(t, o)| {
163+
*t >= cluster_cutoff
164+
&& o.swap
165+
.as_ref()
166+
.is_some_and(|s| s.token_out == target_token_out)
167+
})
168+
.collect();
169+
if cluster.len() >= CLUSTER_MIN_SWAPS {
170+
detections.push(Detection::SniperCluster {
171+
token_out: target_token_out,
172+
n_swaps: cluster.len(),
173+
sample_hashes: cluster.iter().rev().take(3).map(|(_, o)| o.hash).collect(),
174+
});
175+
}
138176
}
139177

140-
// 5. Bot repetition : compter par `from` dans REPETITION_WINDOW.
178+
// 5. Bot repetition : ne depend que de `from`, marche meme sans swap details.
141179
let rep_cutoff = now - REPETITION_WINDOW;
142180
let mut per_from: HashMap<Address, Vec<&Observation>> = HashMap::new();
143181
for (t, o) in &self.history {
@@ -174,10 +212,21 @@ mod tests {
174212
fn obs(from: u8, token_in: Address, token_out: u8, amount: u128, h: u8) -> Observation {
175213
Observation {
176214
from: addr(from),
177-
token_in,
178-
token_out: addr(token_out),
179-
amount_in: U256::from(amount),
180215
hash: hash(h),
216+
swap: Some(SwapDetails {
217+
token_in,
218+
token_out: addr(token_out),
219+
amount_in: U256::from(amount),
220+
}),
221+
}
222+
}
223+
224+
/// Obs "from-only" (cas UR envelope) : pas de swap details.
225+
fn obs_no_swap(from: u8, h: u8) -> Observation {
226+
Observation {
227+
from: addr(from),
228+
hash: hash(h),
229+
swap: None,
181230
}
182231
}
183232

@@ -281,4 +330,100 @@ mod tests {
281330
"got {det:?}"
282331
);
283332
}
333+
334+
// --- Tests des fixes post-live-run --------------------------------------
335+
336+
fn obs_full(
337+
from: u8,
338+
token_in: Address,
339+
token_out: Address,
340+
amount: u128,
341+
h: u8,
342+
) -> Observation {
343+
Observation {
344+
from: addr(from),
345+
hash: hash(h),
346+
swap: Some(SwapDetails {
347+
token_in,
348+
token_out,
349+
amount_in: U256::from(amount),
350+
}),
351+
}
352+
}
353+
354+
/// FIX FAUX-POSITIF : 3 swaps vers WETH ne doivent PAS declencher SniperCluster
355+
/// (WETH est quote token : "3 personnes vendent pour WETH" = bruit, pas sniping).
356+
#[test]
357+
fn sniper_cluster_excludes_weth_as_token_out() {
358+
let mut d = Detector::new();
359+
let t = Instant::now();
360+
let _ = d.observe_at(obs_full(1, addr(0xAB), WETH, 1, 1), t);
361+
let _ = d.observe_at(
362+
obs_full(2, addr(0xCD), WETH, 1, 2),
363+
t + Duration::from_secs(5),
364+
);
365+
let det = d.observe_at(
366+
obs_full(3, addr(0xEF), WETH, 1, 3),
367+
t + Duration::from_secs(10),
368+
);
369+
assert!(
370+
!det.iter()
371+
.any(|d| matches!(d, Detection::SniperCluster { .. })),
372+
"WETH comme token_out doit etre exclu, got {det:?}"
373+
);
374+
}
375+
376+
/// Idem USDC : exclu.
377+
#[test]
378+
fn sniper_cluster_excludes_usdc_as_token_out() {
379+
let mut d = Detector::new();
380+
let t = Instant::now();
381+
let _ = d.observe_at(obs_full(1, addr(0xAB), USDC, 1, 1), t);
382+
let _ = d.observe_at(
383+
obs_full(2, addr(0xCD), USDC, 1, 2),
384+
t + Duration::from_secs(5),
385+
);
386+
let det = d.observe_at(
387+
obs_full(3, addr(0xEF), USDC, 1, 3),
388+
t + Duration::from_secs(10),
389+
);
390+
assert!(
391+
!det.iter()
392+
.any(|d| matches!(d, Detection::SniperCluster { .. })),
393+
"USDC comme token_out doit etre exclu, got {det:?}"
394+
);
395+
}
396+
397+
/// FIX BOT-REPETITION : 2 obs from-only (UR envelope) doivent declencher
398+
/// BotRepetition meme sans swap details.
399+
#[test]
400+
fn bot_repetition_fires_on_envelope_only() {
401+
let mut d = Detector::new();
402+
let t = Instant::now();
403+
let _ = d.observe_at(obs_no_swap(42, 1), t);
404+
let det = d.observe_at(obs_no_swap(42, 2), t + Duration::from_secs(3));
405+
let rep = det
406+
.iter()
407+
.find(|d| matches!(d, Detection::BotRepetition { .. }))
408+
.expect("BotRepetition doit fire sur from-only");
409+
if let Detection::BotRepetition { n_swaps, from, .. } = rep {
410+
assert_eq!(*n_swaps, 2);
411+
assert_eq!(*from, addr(42));
412+
}
413+
}
414+
415+
/// SniperCluster ne fire jamais sur des obs from-only (pas de token_out).
416+
#[test]
417+
fn sniper_cluster_skips_envelope_observations() {
418+
let mut d = Detector::new();
419+
let t = Instant::now();
420+
let _ = d.observe_at(obs_no_swap(1, 1), t);
421+
let _ = d.observe_at(obs_no_swap(2, 2), t + Duration::from_secs(1));
422+
let det = d.observe_at(obs_no_swap(3, 3), t + Duration::from_secs(2));
423+
assert!(
424+
!det.iter()
425+
.any(|d| matches!(d, Detection::SniperCluster { .. })),
426+
"envelope obs ne devraient pas creer de cluster, got {det:?}"
427+
);
428+
}
284429
}

src/main.rs

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use alloy::consensus::Transaction;
1111
use alloy::primitives::{Address, U256};
1212
use alloy::providers::{Provider, ProviderBuilder, WsConnect};
1313
use eth_mempool_watcher::decode::{DecodedSwap, decode as decode_swap};
14-
use eth_mempool_watcher::detect::{Detection, Detector, Observation};
14+
use eth_mempool_watcher::detect::{Detection, Detector, Observation, SwapDetails};
1515
use eth_mempool_watcher::routers::{Router, lookup};
1616
use eyre::Result;
1717
use futures_util::StreamExt;
@@ -216,10 +216,12 @@ fn observation_from_decoded(
216216
..
217217
} => Some(Observation {
218218
from,
219-
token_in: *token_in,
220-
token_out: *token_out,
221-
amount_in: *amount_in,
222219
hash,
220+
swap: Some(SwapDetails {
221+
token_in: *token_in,
222+
token_out: *token_out,
223+
amount_in: *amount_in,
224+
}),
223225
}),
224226
DecodedSwap::ExactInputPath {
225227
path, amount_in, ..
@@ -234,15 +236,24 @@ fn observation_from_decoded(
234236
};
235237
Some(Observation {
236238
from,
237-
token_in,
238-
token_out,
239-
amount_in: amount_in_effective,
240239
hash,
240+
swap: Some(SwapDetails {
241+
token_in,
242+
token_out,
243+
amount_in: amount_in_effective,
244+
}),
245+
})
246+
}
247+
// Envelopes : on alimente une obs "from-only" pour permettre
248+
// BotRepetition. SniperCluster / LargeWethSwap sont skip cote detector.
249+
DecodedSwap::UniversalRouterEnvelope { .. } | DecodedSwap::Multicall { .. } => {
250+
Some(Observation {
251+
from,
252+
hash,
253+
swap: None,
241254
})
242255
}
243-
DecodedSwap::UniversalRouterEnvelope { .. }
244-
| DecodedSwap::Multicall { .. }
245-
| DecodedSwap::Unknown { .. } => None,
256+
DecodedSwap::Unknown { .. } => None,
246257
}
247258
}
248259

0 commit comments

Comments
 (0)