Раздел 30 · Web3 на Rust

On-chain HFT и MEV: когда порядок продают

lead~55 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

On-chain HFT и MEV: когда порядок продают

Весь предыдущий урок мы боролись за наносекунды, потому что на бирже порядок заявок определяет матчер по времени прихода. Прийти раньше значит встать в очередь раньше. Теперь перенесём ту же задачу в цепь и обнаружим, что правило другое: порядок там не определяется временем, а продаётся на аукционе. Это меняет всё, включая то, за что имеет смысл платить инженерам.

Матчер, который продаёт очередь

Сравним два механизма упорядочивания.

Биржа. Матчер получает заявки, ставит на каждую метку времени и обрабатывает в порядке поступления. Правило публичное, механическое и не подлежит покупке. Купить можно только скорость доставки: колокацию, обход ядра, короткий провод. Отсюда весь предыдущий блок.

Блокчейн. Транзакции попадают в блок, а порядок внутри блока определяет тот, кто блок собирает. Он не обязан ни сортировать по времени, ни вообще объяснять свой выбор. Единственное, чем он руководствуется, это собственная выгода. Значит, порядок это товар.

Отсюда прямое следствие, которое ломает интуицию биржевого инженера: прийти первым в цепи не значит исполниться первым. Ты можешь отправить транзакцию за секунду до конкурента и оказаться после него, потому что он заплатил больше. Скорость в цепи нужна не чтобы обогнать, а чтобы успеть к моменту, когда закрывается приём ставок.

Мы называли это MEV в уроке 41 и разбирали как явление. Теперь считаем как инженеры.

Во что обходится чужая настройка

Начнём с самого прямого примера. Пользователь отправляет своп в пул постоянного произведения и указывает терпимость к проскальзыванию: минимум, ниже которого он не согласен получить. Транзакция висит в мемпуле и видна всем.

Что с ней можно сделать, известно: завести свою сделку до неё, дать ей исполниться по ухудшенной цене, и вернуть свою обратно. Три транзакции подряд, атомарно. Вопрос не в том, можно ли, а в том, сколько именно можно, и это задача оптимизации.

/// Чужая сделка, висящая в мемпуле и видимая всем.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Victim {
    /// Сколько `x` он меняет.
    pub amount_in: u128,
    /// Ниже этого он не согласен получить. Это и есть его терпимость
    /// к проскальзыванию, выраженная в токенах.
    pub min_out: u128,
}

/// Прибыль атаки заданного размера, если она вообще проходит.
fn evaluate(pool: &Pool, victim: &Victim, front: u128) -> Option<Sandwich> {
    let mut state = *pool;
    let gained = state.swap_x(front);
    let victim_out = state.swap_x(victim.amount_in);
    if victim_out < victim.min_out {
        // Сделка жертвы откатится, и вместе с ней откатится вся атака.
        return None;
    }
    let back = state.swap_y(gained);
    Some(Sandwich {
        front,
        back,
        profit: back as i128 - front as i128,
        victim_out,
    })
}

Ограничение ровно одно: жертва должна получить не меньше своего минимума, иначе её транзакция откатится и наша прибыль вместе с ней. Внутри ограничения прибыль растёт с размером атаки, а зависимость монотонная, значит оптимум стоит на границе и находится двоичным поиском:

pub fn optimal_sandwich(pool: &Pool, victim: &Victim) -> Option<Sandwich> {
    // Без атаки жертва обязана проходить, иначе считать нечего.
    evaluate(pool, victim, 0)?;

    let mut low = 0u128;
    // Верхняя граница поиска: заводить больше, чем весь резерв, бессмысленно.
    let mut high = pool.x;
    while low < high {
        let middle = low + (high - low).div_ceil(2);
        if evaluate(pool, victim, middle).is_some() {
            low = middle;
        } else {
            high = middle - 1;
        }
    }

    let best = evaluate(pool, victim, low)?;
    // Атака имеет смысл, только если она прибыльна: комиссия пула берётся
    // с обоих наших свопов, и на мелкой жертве она съедает всё.
    (best.profit > 0).then_some(best)
}

Прогоним на пуле миллион на миллион с комиссией 30 базисных пунктов, жертва меняет 10000:

ТерпимостьМинимум жертвыЗавести вперёдПрибыльДоля от свопа
0.01%9870невыгодно
0.1%986154060.06%
0.5%98212588350.35%
1%97725115690.69%
2%9673102781401.40%
5%9377262023473.47%
10%8883545286936.93%

Читай эту таблицу как заявление пользователя. Он не «настроил допуск», он публично объявил, сколько готов отдать любому желающему. Пять процентов терпимости на этой паре стоят ему 347, тогда как комиссия самого пула всего 30: настройка обошлась в двенадцать комиссий.

Отдельно первая строка. При 0.01% атака невыгодна, и не потому, что запрещена, а потому, что комиссия пула берётся с обоих наших свопов и съедает разницу. Комиссия работает как защита, хотя задумана была для другого.

Практический вывод для пользователя простой: терпимость к проскальзыванию это не настройка удобства, а размер разрешённой кражи. Ставить её надо от волатильности пары и глубины пула, а не от того, сколько раз транзакция откатилась.

Атомарность как рабочий инструмент

Три транзакции атаки бесполезны по отдельности. Если первая прошла, а третья нет, атакующий остался с позицией по плохой цене. Значит, нужна гарантия «либо все три подряд, либо ни одной».

В цепи такая гарантия называется связкой. Билдер обязуется включить её целиком, подряд и в заданном порядке, либо не включать вовсе.

Заметь, что тот же самый инструмент решает и обратную задачу. Ликвидатор из урока 50 хочет одной связкой взять флеш-кредит, погасить чужой долг, забрать залог, продать его и вернуть кредит. Без атомарности любая из этих операций по отдельности это открытый риск. Связка это не оружие атакующего, это способ делать в цепи то, что на бирже делает одна транзакция базы данных.

Два способа продать место

Теперь главное. Есть возможность на тысячу, и её видят четверо. Кому она достанется и что останется остальным? Ответ зависит от устройства аукциона, и вариантов исторически два.

/// Способ продать место в блоке.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Auction {
    /// Открытый газовый аукцион. Все шлют транзакции в общий мемпул,
    /// побеждает наибольшая цена газа. Проигравшие всё равно платят за газ:
    /// их транзакция попадает в блок и откатывается.
    Gas { gas_cost: i128 },
    /// Закрытый аукцион у билдера. Ставка это доля прибыли, отданная
    /// за включение. Проигравшая связка просто не попадает в блок
    /// и не стоит ничего.
    Sealed,
}

/// Участник аукциона.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Searcher {
    pub id: u32,
    /// Сколько он заработает, если выиграет.
    pub gross: i128,
    /// Какую долю прибыли готов отдать за включение.
    pub share_bps: u32,
}

/// Разыграть место в блоке.
pub fn settle(auction: Auction, searchers: &[Searcher]) -> Outcome {
    let Some(winner) = searchers.iter().max_by_key(|searcher| searcher.bid()) else {
        return Outcome { winner: None, to_block: 0, to_winner: 0, burned: 0 };
    };

    match auction {
        Auction::Sealed => Outcome {
            winner: Some(winner.id),
            to_block: winner.bid(),
            to_winner: winner.gross - winner.bid(),
            burned: 0,
        },
        Auction::Gas { gas_cost } => {
            let losers = searchers.len() as i128 - 1;
            Outcome {
                winner: Some(winner.id),
                // В газовом аукционе плата за место это газ, и его платят все.
                to_block: gas_cost * searchers.len() as i128,
                to_winner: winner.gross - gas_cost,
                burned: gas_cost * losers,
            }
        }
    }
}

Четверо со ставками 95%, 80%, 60% и 40% при стоимости газа 150:

АукционПобедительДостанется блокуСожжено впустую
закрытый у билдера19500
открытый газовый1600450

Победитель один и тот же. Разница в судьбе проигравших: в газовой войне их транзакции попали в блок, откатились и всё равно оплатили газ. Четыреста пятьдесят единиц сожжены за то, что три человека одновременно попробовали и не успели.

Хуже того, эти проигравшие транзакции занимали место в блоке, за которое конкурировали все остальные пользователи. То есть газовые войны за MEV поднимали цену транзакций для тех, кто вообще не участвовал в гонке. Это и было главной практической причиной, по которой индустрия перешла к закрытым аукционам: не мораль, а мусор в блоках.

Что закрытый аукцион не чинит, надо сказать прямо. Прибыль по-прежнему уходит из системы, просто аккуратно. Посмотри, что происходит с ростом конкуренции:

#[test]
fn competition_squeezes_the_winner_to_nothing() {
    // Чем больше конкурентов, тем выше приходится задирать долю, и тем
    // меньше остаётся победителю. Это проклятие победителя в чистом виде:
    // прибыль от возможности утекает тому, кто продаёт порядок в блоке.
    let mut previous = i128::MAX;
    for share in [5_000u32, 8_000, 9_500, 9_900] {
        let outcome = settle(
            Auction::Sealed,
            &[Searcher { id: 1, gross: 1_000, share_bps: share }],
        );
        assert!(outcome.to_winner < previous);
        previous = outcome.to_winner;
    }
    assert_eq!(previous, 10);
}

При конкуренции доля стремится к сотне процентов, и searcher остаётся с десятью единицами из тысячи. Тысяча ушла тому, кто собирает блок. Именно поэтому MEV в зрелой системе это не заработок ботов, а дополнительный доход валидаторов, и споры вокруг него это споры про то, кому он должен принадлежать.

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.