Фронтраннинг — как работает и защита
Как работает фронтраннинг в Ethereum, почему публичные параметры транзакции опасны и как защитить аукцион через commit-reveal.
Что такое фронтраннинг
Фронтраннинг — это атака, при которой другой участник видит вашу транзакцию до включения в блок и отправляет свою транзакцию с более высоким приоритетом.
В Ethereum транзакция до подтверждения попадает в mempool. Если она раскрывает выгодное действие — ставку, цену, swap, liquidation — бот может скопировать идею и попасть в блок раньше.
Пишем уязвимый и защищённый аукцион
Создайте файл contracts/FrontrunningDemo.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract PublicAuction {
address public leader;
uint256 public highestBid;
error BidTooLow();
function bid() external payable {
if (msg.value <= highestBid) {
revert BidTooLow();
}
leader = msg.sender;
highestBid = msg.value;
}
}
contract CommitRevealAuction {
struct Bid {
bytes32 commitment;
uint256 deposit;
bool revealed;
}
mapping(address => Bid) public bids;
address public leader;
uint256 public highestBid;
error AlreadyCommitted();
error NoCommitment();
error InvalidReveal();
error BidAlreadyRevealed();
error DepositTooSmall();
error BidTooLow();
function commit(bytes32 commitment) external payable {
if (bids[msg.sender].commitment != bytes32(0)) {
revert AlreadyCommitted();
}
bids[msg.sender] = Bid({
commitment: commitment,
deposit: msg.value,
revealed: false
});
}
function reveal(uint256 amount, bytes32 salt) external {
Bid storage userBid = bids[msg.sender];
if (userBid.commitment == bytes32(0)) {
revert NoCommitment();
}
if (userBid.revealed) {
revert BidAlreadyRevealed();
}
bytes32 expectedCommitment = keccak256(
abi.encodePacked(msg.sender, amount, salt)
);
if (userBid.commitment != expectedCommitment) {
revert InvalidReveal();
}
if (userBid.deposit < amount) {
revert DepositTooSmall();
}
if (amount <= highestBid) {
revert BidTooLow();
}
userBid.revealed = true;
leader = msg.sender;
highestBid = amount;
}
}Создайте файл scripts/frontrunning.ts:
import { network } from "hardhat";
const { ethers } = await network.create();
const [alice, bot] = await ethers.getSigners();
const publicAuction = await ethers.deployContract("PublicAuction");
await publicAuction.waitForDeployment();
await publicAuction.connect(alice).bid({
value: ethers.parseEther("1"),
});
// Бот видит ставку Alice и перебивает её до попадания в блок.
await publicAuction.connect(bot).bid({
value: ethers.parseEther("1.01"),
});
console.log("PublicAuction leader:", await publicAuction.leader());
console.log("PublicAuction highestBid:", ethers.formatEther(await publicAuction.highestBid()));
const sealedAuction = await ethers.deployContract("CommitRevealAuction");
await sealedAuction.waitForDeployment();
const aliceSalt = ethers.encodeBytes32String("alice-secret");
const botSalt = ethers.encodeBytes32String("bot-secret");
const aliceAmount = ethers.parseEther("1");
const botAmount = ethers.parseEther("0.9");
const aliceCommitment = ethers.solidityPackedKeccak256(
["address", "uint256", "bytes32"],
[alice.address, aliceAmount, aliceSalt],
);
const botCommitment = ethers.solidityPackedKeccak256(
["address", "uint256", "bytes32"],
[bot.address, botAmount, botSalt],
);
await sealedAuction.connect(alice).commit(aliceCommitment, {
value: aliceAmount,
});
await sealedAuction.connect(bot).commit(botCommitment, {
value: botAmount,
});
await sealedAuction.connect(bot).reveal(botAmount, botSalt);
await sealedAuction.connect(alice).reveal(aliceAmount, aliceSalt);
console.log("CommitRevealAuction leader:", await sealedAuction.leader());
console.log("CommitRevealAuction highestBid:", ethers.formatEther(await sealedAuction.highestBid()));Запуск:
npx hardhat run scripts/frontrunning.tsВ PublicAuction бот видит сумму и перебивает её. В CommitRevealAuction сумма скрыта хешем до reveal-фазы, поэтому бот не может узнать ставку из commit-транзакции.
Как это работает
mempool — очередь неподтверждённых транзакций. Валидаторы и боты видят calldata, msg.value, gas-параметры и адрес вызываемого контракта.
bid() в PublicAuction — уязвимая функция. Она сразу раскрывает размер ставки через msg.value.
commit(bytes32 commitment) — сохраняет только хеш ставки. По нему нельзя восстановить amount и salt, если salt достаточно случайный.
reveal(uint256 amount, bytes32 salt) — раскрывает ставку позже. Контракт заново считает хеш и сравнивает его с сохранённым commitment.
abi.encodePacked(msg.sender, amount, salt) — привязывает commitment к конкретному адресу. Без msg.sender другой участник мог бы раскрыть чужую ставку.
Защита в разных сценариях
Для аукционов и голосований используйте commit-reveal: сначала скрытое обязательство, потом раскрытие.
Для swap и DeFi-операций задавайте slippage, deadline и минимальный результат. Это не убирает фронтраннинг полностью, но ограничивает ущерб.
Для крупных сделок используйте private transaction relays или RFQ-механики, где транзакция не попадает в публичный mempool до включения в блок.
Частые ошибки
Передавать цену или ставку открытым параметром → если значение даёт преимущество, скрывайте его до момента исполнения.
Использовать слабый salt → salt = bytes32("1") можно перебрать. Генерируйте случайный bytes32 вне сети.
Думать, что высокий gas защищает → высокий priority fee помогает попасть раньше, но не скрывает содержание транзакции.
Что дальше
- EIP-712 typed data — как подписывать структурированные данные вне сети
- EIP-1559 и gas — как priority fee влияет на порядок включения транзакций
- Batch транзакции через Multicall — как атомарно выполнять несколько действий в одной транзакции