Las diferencias de precio para el mismo par entre los pools de Uniswap v3 y Sushiswap pasan seguido. Por lo general, esto ocurre en picos de alta volatilidad, cuando una orden grande desploma el precio en un pool y el otro todavía no alcanza a ajustarse. Un Flash Loan (préstamo instantáneo) te permite agarrar ese spread sin necesidad de poner de tu propio capital para ejecutar la jugada.
Un Flash Loan es una transacción atómica dentro de la EVM: pides $1,000,000 prestados en un protocolo de lending (Aave, Balancer, Uniswap), compras el asset más barato en la DEX A, lo vendes más caro en la DEX B, devuelves el principal del préstamo con su comisión y te quedas con el profit neto. Si cualquier paso de la cadena falla o el balance final queda por debajo del monto a devolver, la EVM le da un revert a toda la transacción. Tu único costo perdido será el gas.
1. Mecánica de mercado y los riesgos ocultos del arbitraje "libre de riesgo"
El término "libre de riesgo" se refiere únicamente a que no tienes riesgo direccional de mercado ni riesgo de perder tu capital inicial. Sin embargo, en el mundo real, te topas con otros factores supercríticos.

Principales trampas
- Slippage (Deslizamiento): Entre mayor sea el volumen del Flash Loan, más mueve tu propia orden el precio dentro del pool (price impact). Si el tamaño del préstamo supera el punto óptimo, el deslizamiento se come el spread por completo.
- MEV y Front-running: Los bots de búsqueda (searchers) ven tus transacciones en el mempool público de volada. Pueden madrugarte la operación metiendo su propio swap con un Priority Fee más alto.
- Gas Overhead: Mandar a llamar un smart contract complejo con múltiples swaps externos consume fácilmente entre 250,000 y 450,000 unidades de gas. En Ethereum L1, con el gas cotizando entre 30 y 50 Gwei, las comisiones de red se devoran los micro-spreads sin despeinarse.
2. Economics de la operación y las matemáticas para calcular el volumen del préstamo
Para armar el arbitraje con éxito, necesitas calcular con precisión quirúrgica el volumen del préstamo x que maximice tu beneficio neto.
En pools de Producto Constante (x · y = k, tipo Uniswap v2), el precio depende de las reservas Rx y Ry. Al hacer un swap por un monto Δx, lo que recibe el comprador es:
Δy = (Ry · Δx · (1 - γ)) / (Rx + Δx · (1 - γ))
donde γ es la comisión del pool (por ejemplo, 0.003 para el 0.3%).
Modelo financiero de profit
Profit(x) = SwapOutDEX2(SwapOutDEX1(x)) - x - FlashLoanFee(x) - GasCost
Comisiones de los principales proveedores de préstamos instantáneos:
- Balancer v2: 0%
- Uniswap v3 (Flash Swaps): depende del tier del pool (0.05%, 0.3%, 1.0%)
- Aave v3: 0.05%
3. Comparativa de proveedores de liquidez
| Proveedor | Fee del préstamo | Costo de gas | Consideraciones clave |
|---|---|---|---|
| Balancer v2 | 0.00% | Bajo | Exige un punto de entrada único a través de su Vault; la mejor opción para arrancar |
| Aave v3 | 0.05% | Medio | Liquidez pesada, soporte para decenas de redes (Polygon, Arbitrum, Optimism) |
| Uniswap v3 | 0.05% – 0.30% | Alto | Te permite armar Flash Swaps directamente contra el pool sin pasar por un protocolo de lending externo |
4. Paso a paso del algoritmo para ejecutar el arbitraje
- Evaluación del spread: Monitorear el desfase de precios entre DEX A (precio bajo) y DEX B (precio alto) mediante conexión WebSocket a un nodo o streams gRPC.
- Cálculo de ganancias: Calcular el valor óptimo de
xtomando en cuenta fees de los pools, el corte de Balancer/Aave y el Base Fee + Priority Fee actual. - Lanzamiento de la transacción: El contrato le pide el Flash Loan al Vault de Balancer.
- Recepción de fondos: El Vault manda los tokens requeridos al contrato de arbitraje y gatilla la función callback
receiveFlashLoan. - Swap 1: El contrato manda el token recibido a la DEX A (Uniswap v3) y recibe a cambio el asset objetivo.
- Swap 2: El contrato envía el asset objetivo a la DEX B (Sushiswap/Uniswap v2) para cambiarlo de vuelta al token original.
- Devolución del préstamo: El contrato aprueba (approve) o transfiere de forma directa el monto principal al Vault de Balancer.
- Validación de profit: El contrato verifica que el balance final sea mayor al inicial. Si no es así, tira un revert.
5. Smart contract listo para usar en Solidity (OpenZeppelin + Balancer v2)
Este contrato está implementado para redes EVM. Utiliza Balancer v2 como proveedor de Flash Loans con fee cero.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IBalancerVault {
function flashLoan(
address recipient,
IERC20[] memory tokens,
uint256[] memory amounts,
bytes memory userData
) external;
}
interface ISwapRouterV3 {
struct ExactInputSingleParams {
address tokenIn;
address tokenOut;
uint24 fee;
address recipient;
uint256 deadline;
uint256 amountIn;
uint256 amountOutMinimum;
uint160 sqrtPriceLimitX96;
}
function exactInputSingle(
ExactInputSingleParams calldata params
) external returns (uint256 amountOut);
}
interface IUniswapV2Router {
function swapExactTokensForTokens(
uint256 amountIn,
uint256 amountOutMin,
address[] calldata path,
address to,
uint256 deadline
) external returns (uint256[] memory amounts);
}
contract FlashArbEngine {
using SafeERC20 for IERC20;
address private immutable owner;
IBalancerVault private immutable balancerVault;
ISwapRouterV3 private immutable uniswapV3Router;
IUniswapV2Router private immutable sushiswapRouter;
bool private arbitrageActive;
error NotOwner();
error ZeroAddress();
error IdenticalTokens();
error InvalidAmount();
error ArbitrageActive();
error InvalidCaller();
error InvalidArrayLength();
error InsufficientProfit();
struct ArbParams {
address tokenA;
address tokenB;
uint24 poolFeeV3;
uint256 loanAmount;
uint256 minAmountOutV3; // Se calcula off-chain para proteger el 1er swap
uint256 minProfit;
}
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}
constructor(
address _balancerVault,
address _uniswapV3Router,
address _sushiswapRouter
) {
if (_balancerVault == address(0) || _uniswapV3Router == address(0) || _sushiswapRouter == address(0)) {
revert ZeroAddress();
}
owner = msg.sender;
balancerVault = IBalancerVault(_balancerVault);
uniswapV3Router = ISwapRouterV3(_uniswapV3Router);
sushiswapRouter = IUniswapV2Router(_sushiswapRouter);
}
function executeArbitrage(ArbParams calldata params) external onlyOwner {
if (arbitrageActive) revert ArbitrageActive();
if (params.tokenA == address(0) || params.tokenB == address(0)) revert ZeroAddress();
if (params.tokenA == params.tokenB) revert IdenticalTokens();
if (params.loanAmount == 0 || params.minProfit == 0 || params.minAmountOutV3 == 0) revert InvalidAmount();
arbitrageActive = true;
IERC20[] memory tokens = new IERC20[](1);
tokens[0] = IERC20(params.tokenA);
uint256[] memory amounts = new uint256[](1);
amounts[0] = params.loanAmount;
balancerVault.flashLoan(
address(this),
tokens,
amounts,
abi.encode(params)
);
arbitrageActive = false;
}
function receiveFlashLoan(
IERC20[] memory tokens,
uint256[] memory amounts,
uint256[] memory feeAmounts,
bytes memory userData
) external {
if (msg.sender != address(balancerVault)) revert InvalidCaller();
if (!arbitrageActive) revert ArbitrageActive();
// Validación de la estructura de arrays enviada por Balancer
if (tokens.length != 1 || amounts.length != 1 || feeAmounts.length != 1) {
revert InvalidArrayLength();
}
ArbParams memory params = abi.decode(userData, (ArbParams));
uint256 loanAmount = amounts[0];
uint256 repayAmount = loanAmount + feeAmounts[0];
// Guardar el balance inicial ANTES de los swaps (incluye el préstamo entrante + sobrantes)
uint256 balanceBefore = IERC20(params.tokenA).balanceOf(address(this));
// 1. Swap TokenA -> TokenB en Uniswap v3 (protección dinámica contra el slippage)
IERC20(params.tokenA).forceApprove(address(uniswapV3Router), loanAmount);
ISwapRouterV3.ExactInputSingleParams memory swapParams =
ISwapRouterV3.ExactInputSingleParams({
tokenIn: params.tokenA,
tokenOut: params.tokenB,
fee: params.poolFeeV3,
recipient: address(this),
deadline: block.timestamp,
amountIn: loanAmount,
amountOutMinimum: params.minAmountOutV3,
sqrtPriceLimitX96: 0
});
uint256 tokenBBalanceBefore = IERC20(params.tokenB).balanceOf(address(this));
uniswapV3Router.exactInputSingle(swapParams);
uint256 tokenBReceived = IERC20(params.tokenB).balanceOf(address(this)) - tokenBBalanceBefore;
// 2. Swap TokenB -> TokenA en SushiSwap (se fija salida mínima = repayAmount + minProfit)
IERC20(params.tokenB).forceApprove(address(sushiswapRouter), tokenBReceived);
address[] memory path = new address[](2);
path[0] = params.tokenB;
path[1] = params.tokenA;
sushiswapRouter.swapExactTokensForTokens(
tokenBReceived,
repayAmount + params.minProfit,
path,
address(this),
block.timestamp
);
// 3. Checar disponibilidad de fondos ANTES de devolver el préstamo
uint256 balanceBeforeRepayment = IERC20(params.tokenA).balanceOf(address(this));
if (balanceBeforeRepayment < repayAmount + params.minProfit) {
revert InsufficientProfit();
}
// 4. Devolver el préstamo al Vault de Balancer
IERC20(params.tokenA).safeTransfer(
address(balancerVault),
repayAmount
);
// 5. Verificar que el profit neto de la operación se mantiene considerando sobrantes previos
uint256 balanceAfter = IERC20(params.tokenA).balanceOf(address(this));
if (balanceAfter < balanceBefore - loanAmount + params.minProfit) {
revert InsufficientProfit();
}
}
function withdrawToken(address token) external onlyOwner {
if (arbitrageActive) revert ArbitrageActive();
uint256 balance = IERC20(token).balanceOf(address(this));
if (balance == 0) revert InvalidAmount();
IERC20(token).safeTransfer(owner, balance);
}
}Para tokens ERC-20 estándar, la secuencia de swaps y devolución del préstamo en este código tiene total coherencia lógica. Antes de tirarlo a mainnet, acuérdate de revisar bien la compatibilidad de las direcciones de los routers, el fee real de Balancer y los valores de minAmountOutV3 para cada trade específico.
6. Protección contra intercepción de trades (Protección MEV)
Mandar una transacción de arbitraje a través de RPCs públicos estándar (Infura, Alchemy) al mempool público es regalarle tus ganancias a los sandwich bots. Los searchers monitorean las órdenes entrantes y usan técnicas de Front-running / Back-running sin piedad.
Solución práctica: Private RPC (Flashbots Builder)
- Manda tus transacciones directo a los builders de bloques (Flashbots, Beaverbuild, Titan) vía endpoints privados (
https://rpc.flashbots.net). - Para devs en Node.js / Python, usen el envío por bundles (
eth_sendBundle), configurando la propina para el validador (Priority Fee) de modo que solo se pague si la transacción se ejecuta con éxito.
7. Interacción con la infraestructura de MEV mediante Flashbots (Node.js / Viem)
Mandar transacciones de arbitraje a la mamporra pública usando el eth_sendTransaction de toda la vida en pleno 2026 es regalarle tu lana a los bots de MEV. La única manera real de blindar tu operación es mandando un Bundle atómico directo a los block builders (Flashbots, Titan, Beaverbuild, BuilderNet).
Si las condiciones en el DEX cambian y el spread se esfuma antes de meter la transacción al bloque, el script de simulación va a mandar a volar el bundle y el builder simplemente no lo va a incluir. No vas a quemar ni un solo Wei en gas.
import {
FlashbotsBundleProvider,
FlashbotsBundleResolution
} from '@flashbots/ethers-provider-bundle';
import { providers, Wallet, utils } from 'ethers';
const CHAIN_ID = 1;
const RELAY_URL = 'https://relay.flashbots.net';
const INITIAL_GAS_LIMIT = 400000;
const GAS_BUFFER_PERCENT = 120;
const PRIORITY_FEE_GWEI = '3';
if (
!process.env.ETH_RPC_URL ||
!process.env.FLASHBOTS_AUTH_KEY ||
!process.env.EXECUTOR_PRIVATE_KEY
) {
throw new Error('[Flashbots] No se configuraron las variables de entorno obligatorias');
}
const provider = new providers.JsonRpcProvider(process.env.ETH_RPC_URL);
const authSigner = new Wallet(process.env.FLASHBOTS_AUTH_KEY);
const executorWallet = new Wallet(
process.env.EXECUTOR_PRIVATE_KEY,
provider
);
// Procesamiento secuencial de bundles dentro del mismo proceso de Node.js.
let executionQueue = Promise.resolve();
export function queueArbitrageBundle(
arbContractAddress,
calldata,
targetBlockNumber
) {
const task = executionQueue.then(() =>
processBundle(arbContractAddress, calldata, targetBlockNumber)
);
executionQueue = task.catch((error) => {
console.error('[Bundle Queue Error]', error);
});
return task;
}
async function processBundle(
arbContractAddress,
calldata,
targetBlockNumber
) {
if (!utils.isAddress(arbContractAddress)) {
throw new Error('[Flashbots] Dirección del contrato de arbitraje inválida');
}
if (
typeof calldata !== 'string' ||
!utils.isHexString(calldata)
) {
throw new Error('[Flashbots] calldata inválido');
}
if (
!Number.isSafeInteger(targetBlockNumber) ||
targetBlockNumber < 1
) {
throw new Error('[Flashbots] Número de bloque objetivo inválido');
}
const network = await provider.getNetwork();
if (network.chainId !== CHAIN_ID) {
throw new Error(
`[Flashbots] Red incorrecta: ${network.chainId}; se esperaba Mainnet (${CHAIN_ID})`
);
}
const flashbotsProvider = await FlashbotsBundleProvider.create(
provider,
authSigner,
RELAY_URL,
CHAIN_ID
);
const latestBlock = await provider.getBlockNumber();
if (targetBlockNumber <= latestBlock) {
console.warn(
`[Flashbots] El bloque objetivo ${targetBlockNumber} ya fue superado`
);
return;
}
// Sincronización de nonce con el RPC público.
// La cola solo protege esta instancia de Node.js.
const nonce = await provider.getTransactionCount(
executorWallet.address,
'pending'
);
const feeData = await provider.getFeeData();
const maxPriorityFeePerGas = utils.parseUnits(
PRIORITY_FEE_GWEI,
'gwei'
);
const maxFeePerGas = feeData.lastBaseFeePerGas
? feeData.lastBaseFeePerGas.mul(2).add(maxPriorityFeePerGas)
: utils.parseUnits('60', 'gwei');
const transaction = {
chainId: CHAIN_ID,
type: 2,
to: arbContractAddress,
data: calldata,
value: 0,
nonce,
gasLimit: INITIAL_GAS_LIMIT,
maxFeePerGas,
maxPriorityFeePerGas
};
// Primera simulación con el límite inicial de gas.
let signedBundle = await flashbotsProvider.signBundle([
{
signer: executorWallet,
transaction
}
]);
let simulation = await flashbotsProvider.simulate(
signedBundle,
targetBlockNumber
);
if ('error' in simulation) {
console.error(
`[Simulation Error] ${simulation.error.message}`
);
return;
}
if (simulation.firstRevert) {
console.warn(
'[Simulation Revert]',
simulation.firstRevert
);
return;
}
const firstResult = simulation.results?.[0];
if (!firstResult || firstResult.error || firstResult.gasUsed == null) {
console.error(
'[Simulation Error] Falta el resultado exitoso de la transacción'
);
return;
}
const txGasUsed = utils.BigNumber.from(firstResult.gasUsed);
if (txGasUsed.lte(0)) {
console.error('[Simulation Error] Consumo de gas inválido');
return;
}
// Aumento del límite de gas en un 20%.
const adjustedGasLimit = txGasUsed
.mul(GAS_BUFFER_PERCENT)
.div(100);
if (adjustedGasLimit.gt(INITIAL_GAS_LIMIT)) {
console.error(
`[Gas Limit] El límite calculado ${adjustedGasLimit.toString()} supera el límite inicial de ${INITIAL_GAS_LIMIT}`
);
return;
}
transaction.gasLimit = adjustedGasLimit;
// Re-firma tras cambiar el gasLimit.
signedBundle = await flashbotsProvider.signBundle([
{
signer: executorWallet,
transaction
}
]);
// Re-simulación de la transacción firmada.
simulation = await flashbotsProvider.simulate(
signedBundle,
targetBlockNumber
);
if ('error' in simulation) {
console.error(
`[Second Simulation Error] ${simulation.error.message}`
);
return;
}
if (simulation.firstRevert) {
console.warn(
'[Second Simulation Revert]',
simulation.firstRevert
);
return;
}
const secondResult = simulation.results?.[0];
if (
!secondResult ||
secondResult.error ||
secondResult.gasUsed == null
) {
console.error(
'[Second Simulation Error] La transacción no pasó la validación'
);
return;
}
// Verificamos que el bloque no se haya minado aún.
const blockBeforeSubmission = await provider.getBlockNumber();
if (targetBlockNumber <= blockBeforeSubmission) {
console.warn(
`[Flashbots] El bloque ${targetBlockNumber} ya fue superado tras la simulación`
);
return;
}
// Enviamos el bundle apuntando estrictamente al bloque indicado.
const submission = await flashbotsProvider.sendRawBundle(
signedBundle,
targetBlockNumber
);
if ('error' in submission) {
console.error(
`[Submission Error] ${submission.error.message}`
);
return;
}
console.log(
`[Flashbots] Bundle enviado para el bloque ${targetBlockNumber}; nonce=${nonce}; gasLimit=${adjustedGasLimit.toString()}`
);
const resolution = await submission.wait();
switch (resolution) {
case FlashbotsBundleResolution.BundleIncluded:
console.log(
`[Success] Bundle incluido en el bloque ${targetBlockNumber}`
);
break;
case FlashbotsBundleResolution.BlockPassedWithoutInclusion:
console.log(
`[Missed] El bloque ${targetBlockNumber} se minó sin el bundle`
);
break;
case FlashbotsBundleResolution.AccountNonceTooHigh:
console.error(
'[Nonce Error] El nonce del ejecutor está por delante del esperado'
);
break;
default:
console.warn(
`[Flashbots] Resultado de ejecución: ${resolution}`
);
}
}8. Arquitectura de monitoreo off-chain y caza de spreads
Quedarse esperando actualizaciones con polling (metiendo setInterval o peticiones a una API REST) mete una latencia de 500–1500 ms, lo que mata por completo tus probabilidades de hacer un arbitraje chingón. En sistemas de producción de verdad se maneja una arquitectura reactiva levantada en Node.js, Go o Rust.

Componentes principales del bot
- 1. State In-Memory unificado: Almacenamiento de reservas actuales (para Uniswap v2) y ticks vectoriales (para Uniswap v3) directamente en la memoria del proceso Node.js/Rust.
- 2. Suscripción a logs de pools: Conexión vía WebSocket a los eventos
Sync(v2) ySwap(v3). - 3. Recálculo instantáneo: Al recibir un nuevo log, solo se recalcula el par afectado por los cambios, evitando hacer polling completo a todos los contratos.
// arbitrage-monitor.mjs
// Reactive off-chain Uniswap V2/V3 pool monitor.
// WebSocket events, unified in-memory state, pair-local recalculation.
// Spread detection only; no transaction execution.
import {
createPublicClient,
webSocket,
parseAbi,
getAddress,
} from 'viem';
import { mainnet } from 'viem/chains';
// -----------------------------------------------------------------------------
// Configuration
// -----------------------------------------------------------------------------
const RPC_URL = process.env.ETH_WS_URL;
if (!RPC_URL) {
throw new Error('ETH_WS_URL is required');
}
const client = createPublicClient({
chain: mainnet,
transport: webSocket(RPC_URL, {
reconnect: true,
retryCount: 10,
retryDelay: 1000,
keepAlive: {
interval: 15_000,
},
}),
});
const V2_FACTORY = getAddress(
'0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f',
);
const V3_FACTORY = getAddress(
'0x1F98431c8aD98523631AE4a59f267346ea31F984',
);
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
// Set POOLS_JSON to a JSON array of known pools.
// Example:
// [{"version":2,"address":"0x..."},
// {"version":3,"address":"0x...","fee":3000}]
const INITIAL_POOLS = JSON.parse(process.env.POOLS_JSON || '[]');
const V2_FEE_PPM = 3000;
const FEE_DENOMINATOR = 1_000_000;
// -----------------------------------------------------------------------------
// ABIs
// -----------------------------------------------------------------------------
const ERC20_ABI = parseAbi([
'function decimals() view returns (uint8)',
'function symbol() view returns (string)',
]);
const V2_FACTORY_ABI = parseAbi([
'event PairCreated(address indexed token0, address indexed token1, address pair, uint256)',
]);
const V2_PAIR_ABI = parseAbi([
'event Sync(uint112 reserve0, uint112 reserve1)',
'function token0() view returns (address)',
'function token1() view returns (address)',
'function getReserves() view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast)',
]);
const V3_FACTORY_ABI = parseAbi([
'event PoolCreated(address indexed token0, address indexed token1, uint24 indexed fee, int24 tickSpacing, address pool)',
]);
const V3_POOL_ABI = parseAbi([
'event Swap(address indexed sender, address indexed recipient, int256 amount0, int256 amount1, uint160 sqrtPriceX96, uint128 liquidity, int24 tick)',
'function token0() view returns (address)',
'function token1() view returns (address)',
'function slot0() view returns (uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, uint8 feeProtocol, bool unlocked)',
'function liquidity() view returns (uint128)',
]);
// -----------------------------------------------------------------------------
// Unified in-memory state
// -----------------------------------------------------------------------------
// pools: checksummed pool address -> pool state
// pairs: normalized token pair -> Set of pool addresses
// unwatchPool: pool address -> unsubscribe function
// initializing: prevents duplicate concurrent initialization
const pools = new Map();
const pairs = new Map();
const unwatchPool = new Map();
const initializing = new Set();
function normalizeAddress(address) {
return getAddress(address);
}
function pairKey(token0, token1) {
const a = normalizeAddress(token0).toLowerCase();
const b = normalizeAddress(token1).toLowerCase();
return a < b ? `${a}:${b}` : `${b}:${a}`;
}
function registerPool(pool) {
pools.set(pool.address, pool);
const key = pairKey(pool.token0, pool.token1);
if (!pairs.has(key)) {
pairs.set(key, new Set());
}
pairs.get(key).add(pool.address);
}
function registerMetadata(pool, token0Metadata, token1Metadata) {
pool.token0Decimals = token0Metadata.decimals;
pool.token1Decimals = token1Metadata.decimals;
pool.token0Symbol = token0Metadata.symbol;
pool.token1Symbol = token1Metadata.symbol;
}
async function readTokenMetadata(address) {
const [decimals, symbol] = await Promise.all([
client.readContract({
address,
abi: ERC20_ABI,
functionName: 'decimals',
}),
client.readContract({
address,
abi: ERC20_ABI,
functionName: 'symbol',
}).catch(() => address.slice(0, 10)),
]);
if (decimals > 36) {
throw new Error(`Unsupported token decimals: ${address}`);
}
return { decimals, symbol };
}
// -----------------------------------------------------------------------------
// Price calculation
// -----------------------------------------------------------------------------
// Prices are token1 per token0, expressed in human-readable token units.
// Number is used only for preliminary ranking, not transaction amounts.
function getV2Price(pool) {
if (pool.reserve0 === 0n || pool.reserve1 === 0n) {
return null;
}
const rawRatio =
Number(pool.reserve1) / Number(pool.reserve0);
return rawRatio *
10 ** (pool.token0Decimals - pool.token1Decimals);
}
function getV3Price(pool) {
if (pool.sqrtPriceX96 === 0n) {
return null;
}
const sqrtRatio =
Number(pool.sqrtPriceX96) / 2 ** 96;
const rawRatio = sqrtRatio * sqrtRatio;
return rawRatio *
10 ** (pool.token0Decimals - pool.token1Decimals);
}
function getPoolPrice(pool) {
if (pool.version === 2) return getV2Price(pool);
if (pool.version === 3) return getV3Price(pool);
return null;
}
function getFeeRate(pool) {
// Uniswap V2: 0.30% swap fee.
// Uniswap V3 fee values are denominated in millionths.
const fee = pool.version === 2 ? V2_FEE_PPM : pool.fee;
return fee / FEE_DENOMINATOR;
}
// -----------------------------------------------------------------------------
// Candidate detection: recalculate only the affected token pair
// -----------------------------------------------------------------------------
function findSpreadCandidates(poolStates) {
const candidates = [];
for (let i = 0; i < poolStates.length; i++) {
for (let j = i + 1; j < poolStates.length; j++) {
const a = poolStates[i];
const b = poolStates[j];
const priceA = getPoolPrice(a);
const priceB = getPoolPrice(b);
if (
priceA === null ||
priceB === null ||
!Number.isFinite(priceA) ||
!Number.isFinite(priceB) ||
priceA <= 0 ||
priceB <= 0
) {
continue;
}
const [buyPool, sellPool, buyPrice, sellPrice] =
priceA <= priceB
? [a, b, priceA, priceB]
: [b, a, priceB, priceA];
const feeBuy = getFeeRate(buyPool);
const feeSell = getFeeRate(sellPool);
if (
feeBuy < 0 || feeBuy >= 1 ||
feeSell < 0 || feeSell >= 1
) {
continue;
}
// Approximate fee-adjusted spread.
// Does not account for price impact or trade size.
const effectiveBuyPrice = buyPrice / (1 - feeBuy);
const effectiveSellPrice = sellPrice * (1 - feeSell);
const grossSpreadPercent =
((sellPrice - buyPrice) / buyPrice) * 100;
const estimatedSpreadPercent =
((effectiveSellPrice - effectiveBuyPrice) /
effectiveBuyPrice) * 100;
if (grossSpreadPercent <= 0) continue;
candidates.push({
token0: a.token0,
token1: a.token1,
token0Symbol: a.token0Symbol,
token1Symbol: a.token1Symbol,
buyPool: buyPool.address,
sellPool: sellPool.address,
buyVersion: buyPool.version,
sellVersion: sellPool.version,
buyPrice,
sellPrice,
grossSpreadPercent,
estimatedSpreadPercent,
});
}
}
return candidates;
}
function onPoolUpdated(pool) {
const addresses = pairs.get(
pairKey(pool.token0, pool.token1),
);
if (!addresses) return;
const states = [];
for (const address of addresses) {
const state = pools.get(address);
if (state) states.push(state);
}
for (const candidate of findSpreadCandidates(states)) {
console.log('[Spread candidate]', {
pair: `${candidate.token0Symbol}/${candidate.token1Symbol}`,
buyPool: candidate.buyPool,
sellPool: candidate.sellPool,
buyVersion: candidate.buyVersion,
sellVersion: candidate.sellVersion,
buyPrice: candidate.buyPrice,
sellPrice: candidate.sellPrice,
grossSpreadPercent: candidate.grossSpreadPercent,
estimatedSpreadPercent: candidate.estimatedSpreadPercent,
});
// Next stage:
// 1. Calculate optimal trade size x*.
// 2. Simulate both swaps against current pool state.
// 3. Deduct gas, flash-loan costs and execution costs.
// 4. Simulate the complete transaction.
// 5. Submit only if expected net profit exceeds the configured threshold.
}
}
// -----------------------------------------------------------------------------
// V2 pool subscription
// -----------------------------------------------------------------------------
async function subscribeV2Pool(rawAddress) {
const address = normalizeAddress(rawAddress);
if (
pools.has(address) ||
initializing.has(address) ||
unwatchPool.has(address)
) {
return;
}
initializing.add(address);
let queuedLogs = [];
let ready = false;
let unwatch;
try {
// Subscribe before reading the snapshot to reduce the initialization gap.
unwatch = client.watchContractEvent({
address,
abi: V2_PAIR_ABI,
eventName: 'Sync',
strict: true,
onLogs(logs) {
if (!ready) {
queuedLogs.push(...logs);
return;
}
processV2Logs(address, logs);
},
onError(error) {
console.error(`[V2 ${address}]`, error.message);
},
});
unwatchPool.set(address, unwatch);
const [token0, token1] = await Promise.all([
client.readContract({
address,
abi: V2_PAIR_ABI,
functionName: 'token0',
}),
client.readContract({
address,
abi: V2_PAIR_ABI,
functionName: 'token1',
}),
]);
const normalizedToken0 = normalizeAddress(token0);
const normalizedToken1 = normalizeAddress(token1);
const [metadata0, metadata1, reserves, blockNumber] =
await Promise.all([
readTokenMetadata(normalizedToken0),
readTokenMetadata(normalizedToken1),
client.readContract({
address,
abi: V2_PAIR_ABI,
functionName: 'getReserves',
}),
client.getBlockNumber(),
]);
const pool = {
address,
version: 2,
token0: normalizedToken0,
token1: normalizedToken1,
reserve0: reserves[0],
reserve1: reserves[1],
fee: V2_FEE_PPM,
lastBlock: blockNumber,
lastLogIndex: -1,
};
registerMetadata(pool, metadata0, metadata1);
registerPool(pool);
ready = true;
// Logs already reflected in the snapshot are discarded.
// Later logs are applied in block/log order.
queuedLogs.sort(compareLogs);
for (const log of queuedLogs) {
if (log.blockNumber > blockNumber) {
processV2Logs(address, [log]);
}
}
queuedLogs = [];
onPoolUpdated(pool);
console.log('[V2 subscribed]', address);
} catch (error) {
unwatch?.();
unwatchPool.delete(address);
console.error(`[V2 init ${address}]`, error.message);
throw error;
} finally {
initializing.delete(address);
}
}
function compareLogs(a, b) {
if (a.blockNumber < b.blockNumber) return -1;
if (a.blockNumber > b.blockNumber) return 1;
return (a.logIndex ?? 0) - (b.logIndex ?? 0);
}
function processV2Logs(address, logs) {
const pool = pools.get(address);
if (!pool) return;
for (const log of [...logs].sort(compareLogs)) {
if (log.removed) {
console.error('[V2 reorg detected]', address);
continue;
}
const block = log.blockNumber ?? 0n;
const index = log.logIndex ?? 0;
if (
block < pool.lastBlock ||
(block === pool.lastBlock && index <= pool.lastLogIndex)
) {
continue;
}
const { reserve0, reserve1 } = log.args;
if (reserve0 === undefined || reserve1 === undefined) {
continue;
}
pool.reserve0 = reserve0;
pool.reserve1 = reserve1;
pool.lastBlock = block;
pool.lastLogIndex = index;
onPoolUpdated(pool);
}
}
// -----------------------------------------------------------------------------
// V3 pool subscription
// -----------------------------------------------------------------------------
async function subscribeV3Pool(rawAddress, fee) {
const address = normalizeAddress(rawAddress);
if (
pools.has(address) ||
initializing.has(address) ||
unwatchPool.has(address)
) {
return;
}
initializing.add(address);
let queuedLogs = [];
let ready = false;
let unwatch;
try {
unwatch = client.watchContractEvent({
address,
abi: V3_POOL_ABI,
eventName: 'Swap',
strict: true,
onLogs(logs) {
if (!ready) {
queuedLogs.push(...logs);
return;
}
processV3Logs(address, logs);
},
onError(error) {
console.error(`[V3 ${address}]`, error.message);
},
});
unwatchPool.set(address, unwatch);
const [token0, token1] = await Promise.all([
client.readContract({
address,
abi: V3_POOL_ABI,
functionName: 'token0',
}),
client.readContract({
address,
abi: V3_POOL_ABI,
functionName: 'token1',
}),
]);
const normalizedToken0 = normalizeAddress(token0);
const normalizedToken1 = normalizeAddress(token1);
const [metadata0, metadata1, slot0, liquidity, blockNumber] =
await Promise.all([
readTokenMetadata(normalizedToken0),
readTokenMetadata(normalizedToken1),
client.readContract({
address,
abi: V3_POOL_ABI,
functionName: 'slot0',
}),
client.readContract({
address,
abi: V3_POOL_ABI,
functionName: 'liquidity',
}),
client.getBlockNumber(),
]);
const pool = {
address,
version: 3,
token0: normalizedToken0,
token1: normalizedToken1,
sqrtPriceX96: slot0[0],
tick: slot0[1],
liquidity,
fee,
lastBlock: blockNumber,
lastLogIndex: -1,
};
registerMetadata(pool, metadata0, metadata1);
registerPool(pool);
ready = true;
queuedLogs.sort(compareLogs);
for (const log of queuedLogs) {
if (log.blockNumber > blockNumber) {
processV3Logs(address, [log]);
}
}
queuedLogs = [];
onPoolUpdated(pool);
console.log('[V3 subscribed]', address, 'fee:', fee);
} catch (error) {
unwatch?.();
unwatchPool.delete(address);
console.error(`[V3 init ${address}]`, error.message);
throw error;
} finally {
initializing.delete(address);
}
}
function processV3Logs(address, logs) {
const pool = pools.get(address);
if (!pool) return;
for (const log of [...logs].sort(compareLogs)) {
if (log.removed) {
console.error('[V3 reorg detected]', address);
continue;
}
const block = log.blockNumber ?? 0n;
const index = log.logIndex ?? 0;
if (
block < pool.lastBlock ||
(block === pool.lastBlock && index <= pool.lastLogIndex)
) {
continue;
}
const { sqrtPriceX96, tick, liquidity } = log.args;
if (
sqrtPriceX96 === undefined ||
tick === undefined ||
liquidity === undefined
) {
continue;
}
pool.sqrtPriceX96 = sqrtPriceX96;
pool.tick = tick;
pool.liquidity = liquidity;
pool.lastBlock = block;
pool.lastLogIndex = index;
onPoolUpdated(pool);
}
}
// -----------------------------------------------------------------------------
// Factory subscriptions: discover newly created pools
// -----------------------------------------------------------------------------
const unwatchV2Factory = client.watchContractEvent({
address: V2_FACTORY,
abi: V2_FACTORY_ABI,
eventName: 'PairCreated',
strict: true,
onLogs(logs) {
for (const log of logs) {
const address = log.args.pair;
if (address && address !== ZERO_ADDRESS) {
subscribeV2Pool(address).catch(() => {});
}
}
},
onError(error) {
console.error('[V2 factory]', error.message);
},
});
const unwatchV3Factory = client.watchContractEvent({
address: V3_FACTORY,
abi: V3_FACTORY_ABI,
eventName: 'PoolCreated',
strict: true,
onLogs(logs) {
for (const log of logs) {
const { pool, fee } = log.args;
if (
pool &&
fee !== undefined &&
pool !== ZERO_ADDRESS
) {
subscribeV3Pool(pool, fee).catch(() => {});
}
}
},
onError(error) {
console.error('[V3 factory]', error.message);
},
});
// -----------------------------------------------------------------------------
// Startup
// -----------------------------------------------------------------------------
async function main() {
for (const entry of INITIAL_POOLS) {
if (!entry.address || ![2, 3].includes(entry.version)) {
throw new Error(
'Each initial pool needs an address and version 2 or 3',
);
}
if (entry.version === 2) {
await subscribeV2Pool(entry.address);
} else {
if (!Number.isInteger(entry.fee)) {
throw new Error(
`V3 pool ${entry.address} requires its fee tier`,
);
}
await subscribeV3Pool(entry.address, entry.fee);
}
}
console.log('[Monitor running] Ethereum mainnet');
}
function shutdown() {
console.log('[Monitor stopping]');
unwatchV2Factory();
unwatchV3Factory();
for (const unwatch of unwatchPool.values()) {
unwatch();
}
unwatchPool.clear();
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
main().catch((error) => {
console.error('[Startup failed]', error);
shutdown();
});
Ejecución:
npm install viemexport ETH_WS_URL='wss://eth-mainnet.g.alchemy.com/v2/TU_API_KEY'
export POOLS_JSON='[]'
node arbitrage-monitor.mjsPOOLS_JSON='[]' significa que el monitor comenzará a suscribirse a los pools creados después del arranque. Para monitorear pools que ya existen, pasa sus direcciones y versiones a través de POOLS_JSON.
9. Checklist de optimización de gas (Gas Optimization)
Cuando estás corriendo lógica pesada de smart contracts, gana el que quema menos units of gas en su transacción. Cada 10,000 de gas que te ahorres te da margen para tirar un Priority Fee más alto y ganarle la carrera al mempool (PGA).
- Usa Custom Errors en lugar de Require: Las estructuras
require(condition, "error string")queman mínimo 100–200 de gas extra solo por guardar el string del error. Pasar aif (!condition) revert InsufficientProfit()sale muchísimo más barato. - Aprovecha variables immutable y constant: Las direcciones de routers, del Balancer Vault y de los tokens base deben declararse con el flag
immutable. Esto "quema" los valores directo en el bytecode del contrato, evitando lecturas pesadas de storage (SLOAD— carísimos 2100 gas). - Transfers directos en lugar de transferFrom: Si tu contrato maneja tokens intermedios, mándalos directo a la dirección del siguiente pool usando
transfer, esquivando llamadas innecesarias deapprove/transferFrom. Transient Storage (TSTORE / TLOAD EIP-1153):
En redes que soportan EIP-1153, usa almacenamiento transitorio para los flags del reentrancy guard. Esto tira el costo de escribir estado de 20,000 a unos miserables 100 gas.
10. Guía paso a paso para deploy y pruebas
Paso 1. Fork de red en Mainnet (Foundry / Hardhat)
Antes de desplegar el contrato a producción, hay que simular todo en un fork local de Mainnet conservando todos los estados actuales de los pools.
# Levantar un nodo fork local con Anvil (Foundry) anvil --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY --fork-block-number 19800000Paso 2. Despliegue del contrato en el fork local
Hacé el deploy del contrato
FlashArbEnginepasando las direcciones reales de infraestructura:- Balancer Vault v2:
0xBA12222222228d8Ba445958a75a0704d566BF2C8 - Uniswap v3 SwapRouter02:
0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45 - Sushiswap V2 Router:
0xd9e1cE17f2641f24aE83637ab66a2cca9C378804
- Balancer Vault v2:
Paso 3. Simulación de spread
Uando un script de simulación, ejecutá una venta grande de WETH en el pool de Sushiswap para romper a mano el balance de precios y forzar una diferencia de liquidez.
Paso 4. Llamado a executeArbitrage
Dispará la función del contrato ejecutor pasándole los parámetros exactos del préstamo. Validá que la transacción pase limpia, que el principal del préstamo vuelva a Balancer y que la ganancia neta quede en el balance de
FlashArbEngine.
Hacer arbitraje con Flash Loans en la vida real exige juntar desarrollo low-level en Solidity, calle con protocolos DeFi y armar una infraestructura off-chain afinada para MEV. Un buen smart contract con comisiones de préstamo en cero gracias a Balancer v2, sumado a canales privados de transacciones, te deja la base sólida para armar un bot de trading 100% automatizado.