Spreading harga buat pair token yang sama di pool Uniswap v3 dan Sushiswap itu kejadian yang lumrah banget. Biasanya ini terjadi pas market lagi sangat volatil, di mana ada whale order yang "nge-dump" harga di satu pool, sementara pool sebelah belum sempat penyesuaian. Flash Loan (pinjaman kilat) bikin kita bisa ngambil cuan dari selisih harga ini tanpa perlu modal awal sepeser pun.
Flash loan sendiri adalah transaksi atomik di EVM: kamu minjam $1.000.000 dari protokol lending (Aave, Balancer, Uniswap), beli aset murah di DEX A, jual lebih mahal di DEX B, balikin pokok pinjaman plus fee, terus sisanya masuk kantong sebagai murni profit. Kalau di tengah jalan ada satu langkah aja yang gagal atau saldo akhir kurang buat bayar utang, EVM bakal otomatis membatalkan (revert) seluruh transaksi. Jadi kamu cuma rugi bayar gas fee aja.
1. Mekanisme Pasar & Risiko Tersembunyi dari Arbitrase "Tanpa Risiko"
Istilah "risk-free" alias tanpa risiko itu murni cuma berlaku buat risiko arah harga pasar (directional risk) dan risiko kehilangan modal utama. Tapi kenyataannya di lapangan, trader bakal dihadapkan sama beberapa faktor kritis lainnya.

Jebakan Utamanya
- Slippage (Pergeseran Harga): Makin gede nominal flash loan yang kamu ambil, makin besar juga transaksi kamu ngerusak harga di dalam pool (price impact). Kalau ukuran pinjaman lewat dari batas optimal, profit dari spread bakal habis tergerus slippage.
- MEV & Front-running: Transaksi kamu di mempool publik gampang banget kelihatan sama bot searcher. Mereka bisa nge-front-run transaksi kamu dengan cara nyelipin swap mereka sendiri pake gas fee (Priority Fee) yang lebih tinggi.
- Biaya Gas (Gas Overhead): Manggil smart contract rumit yang butuh beberapa swap eksternal bakal makan 250.000 sampai 450.000 units of gas. Di Ethereum L1 pas posisi gas 30-50 Gwei, biaya transaksi bisa langsung ngehabisin micro-spread yang mau kita incer.
2. Ekonomika Transaksi & Matematika Kalkulasi Ukuran Pinjaman
Biar arbitrase kamu sukses dan cuan maksimal, kamu harus ngitung dengan presisi berapa ukuran pinjaman x yang paling optimal.
Di pool Constant Product (x · y = k, contohnya Uniswap v2), harga sangat bergantung sama cadangan resep Rx dan Ry. Pas nge-swap sebesar Δx, pembeli bakal dapet:
Δy = (Ry · Δx · (1 - γ)) / (Rx + Δx · (1 - γ))
di mana γ adalah fee pool (misalnya, 0.003 buat fee 0.3%).
Model Finansial Profit
Profit(x) = SwapOutDEX2(SwapOutDEX1(x)) - x - FlashLoanFee(x) - GasCost
Skema fee flash loan di beberapa penyedia utama:
- Balancer v2: 0%
- Uniswap v3 (Flash Swaps): tergantung tier pool (0.05%, 0.3%, 1.0%)
- Aave v3: 0.05%
3. Komparasi Provider Likuiditas
| Provider | Fee Pinjaman | Penggunaan Gas | Fitur Khusus |
|---|---|---|---|
| Balancer v2 | 0.00% | Rendah | Butuh single entry point lewat Vault, sangat ideal buat pemula |
| Aave v3 | 0.05% | Sedang | Likuiditas melimpah, mendukung puluhan multichain (Polygon, Arbitrum, Optimism) |
| Uniswap v3 | 0.05% – 0.30% | Tinggi | Bisa eksekusi Flash Swap langsung dari pool tanpa butuh protokol lending eksternal |
4. Alur Langkah Demi Langkah Eksekusi Arbitrase
- Cek Spread: Pantau selisih harga antara DEX A (harga murah) dan DEX B (harga mahal) via koneksi WebSocket ke node atau gRPC stream.
- Hitung Potensi Cuan: Kalkulasi ukuran optimal
xdengan mempertimbangkan fee pool, persentase Balancer/Aave, dan nilai Base Fee + Priority Fee terkini. - Trigger Transaksi: Smart contract nge-request Flash Loan ke Vault Balancer.
- Terima Dana: Vault ngirim token yang diminta ke contract arbitrase terus memanggil callback function
receiveFlashLoan. - Swap 1: Contract ngirim token hasil pinjaman ke DEX A (Uniswap v3), lalu dapet token target.
- Swap 2: Contract ngirim token target tadi ke DEX B (Sushiswap/Uniswap v2), buat ditukar balik ke token awal.
- Bayar Utang: Contract ngelakukan approval (approve) atau langsung ngirim pokok pinjaman balik ke Vault Balancer.
- Validasi Profit: Contract bakal ngetes apakah saldo akhir lebih gede dari saldo awal. Kalau enggak, tinggal lempar revert.
5. Source Code Smart Contract Solidity Siap Pakai (OpenZeppelin + Balancer v2)
Smart contract ini dirancang khusus buat jaringan EVM, memanfaatkannya Balancer v2 sebagai provider flash loan bebas biaya admin (zero fee).
// 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; // Dihitung off-chain buat proteksi swap pertama
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();
// Cek validitas struktur array dari 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];
// Kunci saldo awal SEBELUM eksekusi swap (termasuk dana pinjaman + sisa saldo lama)
uint256 balanceBefore = IERC20(params.tokenA).balanceOf(address(this));
// 1. Swap TokenA -> TokenB di Uniswap v3 (proteksi slippage dinamis)
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 di SushiSwap (patok minimal output = 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. Pastikan kecukupan dana SEBELUM balikin pinjaman
uint256 balanceBeforeRepayment = IERC20(params.tokenA).balanceOf(address(this));
if (balanceBeforeRepayment < repayAmount + params.minProfit) {
revert InsufficientProfit();
}
// 4. Balikin utang ke Vault Balancer
IERC20(params.tokenA).safeTransfer(
address(balancerVault),
repayAmount
);
// 5. Pastikan net profit aman dengan memperhitungkan sisa saldo sebelumnya
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);
}
}Buat token standar ERC-20, urutan swap dan pengembalian pinjaman di kode ini udah sinkron secara logika. Sebelum di-deploy ke mainnet, pastikan lagi alamat router-nya udah bener, cek fee aktual Balancer, dan hitung nilai minAmountOutV3 dengan presisi buat tiap eksekusi.
6. Proteksi dari Pembajakan Transaksi (Proteksi MEV)
Ngempanin transaksi arbitrase lewat public RPC biasa (Infura, Alchemy) ke public mempool itu sama aja kayak ngasih cuma-cuma cuan kamu ke bot sandwich. Para searcher selalu ngintip transaksi masuk dan siap melakukan serangan Front-running / Back-running.
Solusi Praktis: Private RPC (Flashbots Builder)
- Kirim transaksi langsung ke block builder (Flashbots, Beaverbuild, Titan) lewat private endpoint (
https://rpc.flashbots.net). - Kalau kamu developer Node.js / Python, manfaatkan fitur pengiriman bundle (
eth_sendBundle) buat ngatur "tip" ke miner (Priority Fee) yang cuma bakal terbayar kalau transaksi kamu sukses tereksekusi.
7. Interaksi dengan Infrastruktur MEV via Flashbots (Node.js / Viem)
Kirim transaksi arbitrase secara publik lewat eth_sendTransaction standar di tahun 2026 itu cara paling ampuh buat rugi konyol gegara disikat bot MEV. Satu-satunya cara ngamangin transaksi lu adalah dengan ngirim Bundle atomik langsung ke block builder (Flashbots, Titan, Beaverbuild, BuilderNet).
Kalau kondisi di DEX mendadak berubah dan spread-nya keburu hilang sebelum transaksi masuk ke blok, skrip simulasi bakal otomatis ngedrop bundle-nya, dan builder nggak bakal masukin transaksi lu ke blok. Lu nggak bakal buang-buang se-Wei pun buat gas fee.
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] Environment variable wajib belum di-set');
}
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
);
// Pemrosesan bundle secara berurutan dalam satu proses 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] Alamat kontrak arbitrase tidak valid');
}
if (
typeof calldata !== 'string' ||
!utils.isHexString(calldata)
) {
throw new Error('[Flashbots] Calldata tidak valid');
}
if (
!Number.isSafeInteger(targetBlockNumber) ||
targetBlockNumber < 1
) {
throw new Error('[Flashbots] Nomor blok target tidak valid');
}
const network = await provider.getNetwork();
if (network.chainId !== CHAIN_ID) {
throw new Error(
`[Flashbots] Jaringan tidak sesuai: ${network.chainId}; seharusnya 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] Blok target ${targetBlockNumber} sudah terlewat`
);
return;
}
// Sinkronisasi nonce dengan RPC publik.
// Antrean ini cuma melindungi instance Node.js ini saja.
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
};
// Simulasi awal dengan batasan gas awal.
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] Tidak ada hasil transaksi yang berhasil'
);
return;
}
const txGasUsed = utils.BigNumber.from(firstResult.gasUsed);
if (txGasUsed.lte(0)) {
console.error('[Simulation Error] Penggunaan gas tidak valid');
return;
}
// Menaikkan batas gas sebesar 20%.
const adjustedGasLimit = txGasUsed
.mul(GAS_BUFFER_PERCENT)
.div(100);
if (adjustedGasLimit.gt(INITIAL_GAS_LIMIT)) {
console.error(
`[Gas Limit] Hasil estimasi batas ${adjustedGasLimit.toString()} melebihi limit awal ${INITIAL_GAS_LIMIT}`
);
return;
}
transaction.gasLimit = adjustedGasLimit;
// Sign ulang setelah melakukan penyesuaian gasLimit.
signedBundle = await flashbotsProvider.signBundle([
{
signer: executorWallet,
transaction
}
]);
// Simulasi ulang transaksi yang sudah di-sign.
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] Transaksi gagal dalam verifikasi'
);
return;
}
// Pastikan blok target belum terbentuk/terlewat.
const blockBeforeSubmission = await provider.getBlockNumber();
if (targetBlockNumber <= blockBeforeSubmission) {
console.warn(
`[Flashbots] Blok ${targetBlockNumber} sudah terlewat setelah proses simulasi`
);
return;
}
// Kirim bundle khusus ke blok yang ditentukan saja.
const submission = await flashbotsProvider.sendRawBundle(
signedBundle,
targetBlockNumber
);
if ('error' in submission) {
console.error(
`[Submission Error] ${submission.error.message}`
);
return;
}
console.log(
`[Flashbots] Bundle terkirim untuk blok ${targetBlockNumber}; nonce=${nonce}; gasLimit=${adjustedGasLimit.toString()}`
);
const resolution = await submission.wait();
switch (resolution) {
case FlashbotsBundleResolution.BundleIncluded:
console.log(
`[Success] Bundle berhasil masuk ke dalam blok ${targetBlockNumber}`
);
break;
case FlashbotsBundleResolution.BlockPassedWithoutInclusion:
console.log(
`[Missed] Blok ${targetBlockNumber} terbentuk tanpa menyertakan bundle`
);
break;
case FlashbotsBundleResolution.AccountNonceTooHigh:
console.error(
'[Nonce Error] Nonce eksekutor lebih tinggi dari yang diperkirakan'
);
break;
default:
console.warn(
`[Flashbots] Hasil eksekusi: ${resolution}`
);
}
}8. Arsitektur Monitoring Off-Chain & Pencarian Spread
Nungguin update data pake metode polling (setInterval atau REST API) itu ngebawa latency sekitar 500–1500 ms, yang mana bener-bener matiin peluang lu buat dapet arbitrase. Di sistem production, yang dipakai adalah arsitektur reaktif berbasis Node.js, Go, atau Rust.

Komponen Utama Bot
- 1. In-Memory State Terpusat: Menyimpan cadangan saldo terkini (untuk Uniswap v2) dan vektor tick (untuk Uniswap v3) langsung di dalam memori proses Node.js/Rust.
- 2. Langganan Log Pool: Terhubung via WebSocket ke event
Sync(v2) danSwap(v3). - 3. Rekalkulasi Instan: Saat menerima log baru, bot hanya mengalkulasi ulang pair yang mengalami perubahan tanpa perlu melakukan polling ke seluruh smart contract.
// 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();
});
Cara Jalankan:
npm install viemexport ETH_WS_URL='wss://eth-mainnet.g.alchemy.com/v2/API_KEY_ANDA'
export POOLS_JSON='[]'
node arbitrage-monitor.mjsPOOLS_JSON='[]' artinya monitor hanya akan subscribe ke pool yang dibuat setelah skrip berjalan. Kalau mau memantau pool yang sudah ada, tinggal masukkan address dan versinya lewat POOLS_JSON.
9. Checklist Optimasi Gas (Gas Optimization)
Saat mengeksekusi logika smart contract yang rumit, pemenangnya adalah transaksi yang makan units of gas paling sedikit. Tiap hemat 10.000 gas, kamu bisa pasang Priority Fee lebih tinggi buat memenangkan perang lelang transaksi.
- Pakai Custom Error ketimbang Require: Sintaks
require(condition, "error string")membakar minimal 100–200 ekstra gas cuma buat menyimpan string eror. Beralih keif (!condition) revert InsufficientProfit()jauh lebih hemat. - Manfaatkan Variabel immutable dan constant: Address router, Balancer Vault, dan token dasar wajib dideklarasikan pakai flag
immutable. Nilai tersebut bakal langsung di-bake ke bytecode contract, jadi nggak perlu beli opcodes mahal buat baca dari memori storage (SLOADmakan 2100 gas). - Transfer Langsung dibanding transferFrom: Kalau contract kamu mengolah token perantara, langsung kirimkan ke address pool berikutnya menggunakan
transfer. Hindari pemanggilanapprove/transferFromyang cuma bikin boros gas. Transient Storage (TSTORE / TLOAD EIP-1153):
Di jaringan yang sudah mendukung EIP-1153, manfaatkan transient storage untuk flag reentrancy guard. Ini bisa memangkas biaya write state dari yang tadinya 20.000 gas menjadi cuma 100 gas!
10. Panduan Step-by-Step Deploy dan Testing
Langkah 1. Mainnet Forking (Foundry / Hardhat)
Sebelum nge-deploy contract ke live network, wajib hukumnya buat bikin simulasi di lokal Mainnet fork yang menyalin persis seluruh state pool saat ini.
# Jalankan node fork lokal pakai Anvil (Foundry) anvil --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY --fork-block-number 19800000Langkah 2. Deploy Contract di Local Fork
Lakukan deploy contract
FlashArbEnginedengan memasukkan address infrastruktur yang asli:- Balancer Vault v2:
0xBA12222222228d8Ba445958a75a0704d566BF2C8 - Uniswap v3 SwapRouter02:
0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45 - Sushiswap V2 Router:
0xd9e1cE17f2641f24aE83637ab66a2cca9C378804
- Balancer Vault v2:
Langkah 3. Simulasi Spread Harga
Pakai skrip simulasi buat melakukan sell WETH dalam jumlah besar di pool Sushiswap demi memicu imbalance harga dan ketimpangan likuiditas secara manual.
Langkah 4. Panggil executeArbitrage
Panggil fungsi pada contract eksekutor dengan menyertakan parameter pinjaman target yang presisi. Pastikan transaksi sukses, pokok pinjaman balik ke Balancer, dan profit bersih mengendap manis di saldo
FlashArbEngine.
Praktik arbitrase Flash Loan butuh kombinasi skill koding Solidity tingkat rendah, pemahaman mendalam tentang protokol DeFi, serta setup infrastruktur off-chain khusus MEV. Smart contract ciamik tanpa biaya pinjaman via Balancer v2 yang dipadu dengan private transaction relay bakal jadi fondasi solid buat membangun sistem trading otomatis penuh.