L'économie pure du MEV se résume à une lutte acharnée pour l'espace de bloc, où la moindre milliseconde de latence réseau réduit à néant tout potentiel de profit. Lancer son propre bot sur Ethereum ou sur des réseaux Layer 2 EVM relève depuis longtemps du parcours du combattant. Le marché est verrouillé par des searchers spécialisés qui louent des serveurs en colocation dans des datacenters à Francfort et Tokyo, littéralement à côté des nœuds de validation.
Chiffres clés et rentabilité
Un bot solo hébergé sur un serveur perso ou un VPS cloud standard finit dans le rouge 95 % du temps à cause des enchères de gas. La rentabilité dépend entièrement de la stratégie adoptée :
- Arbitrage atomique (Atomic Arbitrage) : Les marges se sont tassées à des fractions de pourcent. Le profit net quotidien d'un bot performant oscille entre 100 $ et 3 000 $ en période de volatilité. Le risque de perte en capital est nul puisque la transaction est encapsulée dans un unique smart contract atomique : si les conditions ne sont pas réunies, tout rollback.
- Attaques sandwich (Sandwich Attacks) : Les rendements théoriques sont plus élevés, de 500 $ à 10 000 $ par jour sur des paires à faible liquidité sur Uniswap v2 ou des AMM similaires. En pratique, c'est la jungle. Les bots concurrents s'organisent via des mempools privés en surenchérissant massivement sur les pots-de-vin versés aux validateurs.
- Liquidations : Le marché spot des protocoles de prêt comme Aave rapporte gros, mais exige des millions de dollars de liquidités pour combler les dettes et solder les positions instantanément. Inutile d'essayer en solo.
Structure des frais de gas et passage par les builders
Votre principal poste de dépense en lançant un bot MEV ne vient pas des transactions réussies, mais des tentatives ratées et des enchères de priorité de gas (GPA).
- Mempool public : Envoyer une transaction en contournant les relais privés garantit la perte. Un bot basique repère la tx d'une victime, balance la sienne avec un gas plus élevé, mais les bots rivaux se l'approprient d'office via des RPC privés. Résultat : votre transaction s'exécute au pire prix ou s'annule, brûlant du vrai gas sur la chaîne.
- Block builders et Flashbots : L'arbitrage moderne repose sur eth_sendBundle. On groupe plusieurs transactions en un bundle envoyé directement aux block builders (Beaverbuild, Titan, Flashbots).
- Coût de l'échec : L'avantage des bundles privés, c'est que si l'arbitrage capote, la transaction n'atterrit tout simplement pas dans le bloc. Vous ne payez pas de frais de réseau pour un tir raté. L'inconvénient ? Le validateur empoche la part du lion sous forme de pourboire direct (coinbase.transfer), ne laissant que 5 % à 15 % du profit brut au développeur.
Tableau des métriques par stratégie MEV
| Stratégie | Capital de départ | Profit net journalier moyen | Taux de réussite | Principale cause de perte |
|---|---|---|---|---|
| Arbitrage atomique | à partir de 5 000 $ | 50 $ - 500 $ | 15-25 % | Latence réseau face à la concurrence |
| Attaques sandwich | à partir de 20 000 $ | 200 $ - 3 000 $ | 5-10 % | Surenchère excessive du gas auprès des validateurs |
| Liquidations | à partir de 100 000 $+ | 1 000 $ - 10 000 $+ | < 2 % | Insuffisance de capital pour couvrir les emprunts |
Exemple d'envoi de bundle privé en Python
Voici un squelette de script Python fonctionnel utilisant web3.py pour soumettre un bundle via un fournisseur compatible Flashbots. Le code a été nettoyé de toute abstraction superflue.
import os
from eth_account import Account
from eth_account.signers.local import LocalAccount
from web3 import Web3
from flashbots import flashbot
# Clé de signature pour les requêtes au relay (réputation Flashbots).
# N'utilisez SURTOUT PAS une clé contenant des fonds réels.
signature_account: LocalAccount = Account.from_key(os.environ["FLASHBOTS_SIGNER_KEY"])
# Clé privée de l'expéditeur de la transaction
sender: LocalAccount = Account.from_key(os.environ["SENDER_PRIVATE_KEY"])
w3 = Web3(Web3.HTTPProvider(os.environ["ETH_NODE_URL"]))
flashbot(w3, signature_account, "https://relay-sepolia.flashbots.net")
def build_signed_tx(nonce: int) -> bytes:
base_fee = w3.eth.get_block("latest")["baseFeePerGas"]
tx = {
"to": sender.address, # exemple : auto-transfert
"value": 0,
"gas": 21_000,
"maxFeePerGas": base_fee * 2 + Web3.to_wei(2, "gwei"),
"maxPriorityFeePerGas": Web3.to_wei(2, "gwei"),
"nonce": nonce,
"chainId": w3.eth.chain_id,
"type": 2,
}
return sender.sign_transaction(tx).rawTransaction # versions récentes : .raw_transaction
def send_private_bundle(target_block: int):
nonce = w3.eth.get_transaction_count(sender.address)
bundle = [{"signed_transaction": build_signed_tx(nonce)}]
# Simulation avant l'envoi
w3.flashbots.simulate(bundle, target_block)
result = w3.flashbots.send_bundle(bundle, target_block_number=target_block)
result.wait() # attend que le bloc target_block soit miné
try:
return result.receipts()
except Exception:
return None # bundle non inclus dans le bloc : à retenter sur le bloc suivant
if __name__ == "__main__":
target = w3.eth.block_number + 1
receipts = send_private_bundle(target)
print("Inclus dans le bloc" if receipts else "Bundle non inclus, nouvelle tentative au bloc suivant")Un bundle n'est valide que pour un seul bloc cible. En production, il faut donc automatiser l'envoi en boucle sur plusieurs blocs consécutifs. Ne pas être inclus ne constitue pas une erreur : la bataille se joue au montant du pourboire versé au builder.
La rentabilité d'un bot dépend avant tout de l'infrastructure. Louer un serveur dédié en rack juste à côté d'un validateur en datacenter coûte au bas mot 500 $ par mois. Sans ce boost matériel, votre bot est physiquement incapable de calculer l'état du pool plus vite que ses rivaux. Le vainqueur est celui qui possède le câble le plus court vers le validateur et l'algorithme de détection de spread le plus affûté.
Les flash loans permettent d'exécuter des opérations d'arbitrage à grande échelle sans posséder de capital initial conséquent, en ayant recours à un emprunt sans garantie au sein d'une seule et même transaction, avec un rollback garanti en cas de perte.
Flash loans et architecture des smart contracts
Pas besoin de bloquer des centaines de milliers de dollars de fonds propres pour faire de l'arbitrage. Les contrats d'intégration avec les pools Aave v3 ou Balancer permettent d'emprunter via flash loans sans collatéral sur une seule et unique transaction. Votre bot emprunte l'actif, l'injecte à travers des paires DEX, rembourse le prêt majoré de la commission de 0,09 % et empoche le solde net sur le compte du créateur. Si le calcul final est déficitaire, toute la transaction s'annule via un revert, et vous ne perdez que les frais de gas de l'appel.
Développer un contrat d'arbitrage en Solidity exige une optimisation drastique du bytecode EVM. L'utilisation des interfaces ERC-20 standard via SafeERC20 ajoute des vérifications superflues qui grignotent de précieux gas units lors du contrôle des limites de transaction. Les devs aguerris privilégient des appels de bas niveau directs via call, réduisant au minimum les opérations de lecture et d'écriture en mémoire persistante (SLOAD/SSTORE).
Exemple de template Solidity
Voici un template basé sur un flash loan Aave V3 pour des pools Uniswap V2, débarrassé de toute bibliothèque superflue :
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
interface IERC20 {
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 amount) external returns (bool);
function approve(address spender, uint256 amount) external returns (bool);
}
/// Aave V3 : flash loan d'un seul actif
interface IPool {
function flashLoanSimple(
address receiverAddress,
address asset,
uint256 amount,
bytes calldata params,
uint16 referralCode
) external;
}
interface IUniswapV2Pair {
function token0() external view returns (address);
function getReserves() external view returns (uint112, uint112, uint32);
function swap(uint256 amount0Out, uint256 amount1Out, address to, bytes calldata data) external;
}
/// Arbitrage via flash loan Aave V3 : asset -> tokenB (pair1) -> asset (pair2).
/// Exemple pour pools de type Uniswap V2. Non audité.
contract FlashArbitrage {
error Unauthorized();
error InvalidCaller();
error InvalidInitiator();
error TransferFailed();
error ApproveFailed();
error InsufficientProfit(uint256 balanceAfter, uint256 required);
address private immutable OWNER;
IPool private immutable POOL;
constructor(address aavePool) {
OWNER = msg.sender;
POOL = IPool(aavePool); // Récupérer l'adresse Pool depuis le PoolAddressesProvider du réseau cible
}
modifier onlyOwner() {
if (msg.sender != OWNER) revert Unauthorized();
_;
}
/// Point d'entrée. Emprunte `amount` du token `asset` et déclenche la boucle executeOperation.
function executeArbitrage(
address asset,
uint256 amount,
address tokenB,
address pair1,
address pair2,
uint256 minProfit
) external onlyOwner {
bytes memory params = abi.encode(tokenB, pair1, pair2, minProfit);
POOL.flashLoanSimple(address(this), asset, amount, params, 0);
}
/// Callback Aave V3. Appelé par le pool après décaissement du prêt.
function executeOperation(
address asset,
uint256 amount,
uint256 premium,
address initiator,
bytes calldata params
) external returns (bool) {
// 1. Le callback ne peut être appelé que par le pool Aave...
if (msg.sender != address(POOL)) revert InvalidCaller();
// 2. ...et uniquement à la demande de ce contrat
if (initiator != address(this)) revert InvalidInitiator();
(address tokenB, address pair1, address pair2, uint256 minProfit) =
abi.decode(params, (address, address, address, uint256));
// Le solde inclut déjà les fonds empruntés
uint256 balanceBefore = IERC20(asset).balanceOf(address(this));
uint256 amountB = _swap(pair1, asset, amount);
_swap(pair2, tokenB, amountB);
// Profit = balanceAfter - balanceBefore - premium (frais du prêt)
uint256 balanceAfter = IERC20(asset).balanceOf(address(this));
uint256 required = balanceBefore + premium + minProfit;
if (balanceAfter < required) revert InsufficientProfit(balanceAfter, required);
// Aave prélève la dette via transferFrom, une approbation (approve) est donc requise
_safeApprove(asset, address(POOL), amount + premium);
return true;
}
function withdraw(address token, uint256 amount) external onlyOwner {
_safeTransfer(token, OWNER, amount);
}
function _swap(address pair, address tokenIn, uint256 amountIn) private returns (uint256 amountOut) {
(uint256 r0, uint256 r1, ) = IUniswapV2Pair(pair).getReserves();
bool zeroForOne = tokenIn == IUniswapV2Pair(pair).token0();
(uint256 reserveIn, uint256 reserveOut) = zeroForOne ? (r0, r1) : (r1, r0);
// Formule Uniswap V2 (frais de 0,3 %)
uint256 amountInWithFee = amountIn * 997;
amountOut = (amountInWithFee * reserveOut) / (reserveIn * 1000 + amountInWithFee);
_safeTransfer(tokenIn, pair, amountIn);
IUniswapV2Pair(pair).swap(
zeroForOne ? 0 : amountOut,
zeroForOne ? amountOut : 0,
address(this),
"" // data vide : swap classique, pas un flash swap
);
}
function _safeTransfer(address token, address to, uint256 amount) private {
if (token.code.length == 0) revert TransferFailed();
(bool ok, bytes memory data) = token.call(abi.encodeCall(IERC20.transfer, (to, amount)));
if (!ok || (data.length != 0 && !abi.decode(data, (bool)))) revert TransferFailed();
}
function _safeApprove(address token, address spender, uint256 amount) private {
if (token.code.length == 0) revert ApproveFailed();
(bool ok, bytes memory data) = token.call(abi.encodeCall(IERC20.approve, (spender, amount)));
if (!ok || (data.length != 0 && !abi.decode(data, (bool)))) revert ApproveFailed();
}
}
L'impasse infrastructurelle d'AWS
Héberger un bot de recherche sur des serveurs virtuels Amazon Web Services ou Google Cloud, c'est l'assurance de cramer son budget pour rien. La virtualisation introduit de la latence (jitter) dans la pile réseau, et les liaisons mutualisées subissent des pertes de paquets lors des pics de congestion du mempool.
Une configuration compétitive exige un serveur dédié physique (bare-metal) doté d'une fréquence CPU monocœur élevée, de disques NVMe ultra-rapides pour faire tourner un nœud local Geth ou Reth, ainsi qu'une liaison gigabit directe vers les datacenters de Francfort. Faire tourner son propre nœud lightweight est indispensable, car les fournisseurs RPC publics comme Infura ou Alchemy brident artificiellement la vitesse de diffusion des nouveaux blocs et retardent les notifications de transactions en pending de 100 à 300 millisecondes fatales.
Le profit en MEV ne revient pas à celui qui a le modèle mathématique le plus complexe, mais à celui qui se trouve physiquement le plus près du validateur et qui sait optimiser ses coûts en gas jusqu'à la moindre instruction assembleur dans le bytecode.