Skip to content

Ограничение работы сеттлмента за блок (#432)

Статус: фикс A реализован (default подтверждён 1.000 VIZ) и фикс D завершён — garbage collection, сеттлмент и void-рефанды работают на метрированном row-бюджете. Эта заметка фиксирует проблему, взвешенные варианты и почему цепь берёт оба.

Вместе с доработками §4.1–§4.12 эти лимиты закрывают единый вектор атаки: спам, неограниченно наращивающий работу PM-крона в каждом блоке. Каждый свип крона теперь работает на метрированном row-бюджете (pm_settle_rows_per_block), либо ограничен экономическим полом (строка стоит не меньше pm_min_liquidity = 100 VIZ), либо задан жёстким per-market капом — а единственный обход, ограниченный пока только экономикой, эмитит громкий log-сигнал вместо тихой деградации. §7 подводит итог закрытой поверхности.

Соседние внутренние спеки: early-exit-deferred-claim, specification §5 (crons).

1. Дыра

pm_processing_cap_per_block (median-voted, default 200) — единственный ограничитель PM-крона. Он считает рынки, а не работу:

while (it != idx.end() && ... && done < cap) {   // §5 auto-payouts
    settle_market(*this, mkt);                    // трогает КАЖДУЮ строку ставки рынка
    ++done;                                       // ...и стоит ровно ОДНУ единицу cap
}

settle_market() (libraries/chain/pm_process_markets.cpp) обходит весь by_market-диапазон ставок, строит векторы победителей/проигравших, платит каждую строку через adjust_balance, флипает её статус и пушит одну virtual op на строку. Нет курсора и нет resume: рынок сеттлится целиком внутри одного блока или никак. Та же форма в gc_market() (сносит весь кластер объектов рынка за один блок) и в void/no-contest-ветке.

Ничто не ограничивало число строк, которое может нести рынок:

  • каждый pm_place_bet создаёт новый pm_bet_object — нет агрегации по (account, market, outcome);
  • у instant-пути вообще не было минимума ставки (amount > 0 и tokens_out > 0 были единственными гейтами), поэтому строка стоила 1 raw = 0.001 VIZ. Проверено живым бродкастом на тестнете, с reject-контролем и accept-контролем, на 0.001 / 0.002 / 0.010 VIZ;
  • частичный pm_transfer_position делит одну строку на две вообще без стоимости стейка — дешевле, чем беттинг, и обходит любой bet-side пол;
  • тот же класс cap уже существовал повсюду — MAX_PM_DEFERRED_CLAIMS_PER_MARKET, pm_dispute_votes_per_market, MAX_PM_OPEN_COMMITS_PER_MARKET, все по 10 000. Bet-строки были единственным членом класса, оставленным открытым. (pm_dispute_votes_per_market позже поднят до 100 000 с vesting-полом, см. §4.6.)

Для этого не нужен атакующий. Достаточно популярного рынка: на тестнете игрушечный бот, ставящий с трёх аккаунтов каждые десять минут, уже накопил 734 строки на одном рынке (571 и 552 на двух других). Мейннет-рынок с тысячами участников на порядки больше, и вся эта работа ложится в один блок, где истекает dispute grace.

2. Варианты

ФиксОграничивает работу?Цена
Aминимум ставки на instant-пути (зеркало pm_min_batch_bet)нет — только ценит строкиодин assert, median-tunable
Bжёсткий кап на строки per marketдапопулярный рынок перестаёт принимать ставки: цензура / сломанный UX
Cагрегировать ставки по (account, market, outcome)ограничен аккаунтамиинвазивно: ломает per-bet weight, time_penalty, entry_liquidity, transfer_position, F1 claims
Dинкрементальный сеттлмент: ограниченные строки на блок, курсор на рынкедасамый большой consensus-дифф

Выбрано: A + D.

  • A один — не фикс. Он поднимает цену строки на три порядка (0.001 → 1.000 VIZ) и он votable, но даёт экономическую, а не структурную границу: 1 000 000 строк по 1 VIZ = 1 000 000 VIZ, это много денег, но не невозможная сумма — притом она застейкана, а не потрачена, так что большая доля вернётся на выплате. Важнее: A ничего не делает с легитимным случаем — действительно популярный рынок это не спам и не должен наказываться, но это та же блок-тайм проблема.
  • D один тоже недостаточен. Он делает работу за блок конечной, но оставляет строки бесплатными, так что спамер всё равно может растянуть сеттлмент одного рынка на тысячи блоков и заставить каждую ноду нести state. A — дешёвый экономический гард, который держит очередь D короткой.
  • B отвергнут: отказ в ставках на рынке, который хорошо идёт, — видимый пользователю провал продукта, а значение капа пришлось бы угадывать.
  • C отвергнут: это более широкий и рискованный дифф, чем D, за ту же выгоду, и он уничтожает per-bet свойства, от которых зависят сеттлмент-математика и early-exit claims.

3. Фикс A — минимум ставки (реализован)

В chain_properties_pm добавлены два median-voted параметра:

  • pm_min_bet — default 1.000 VIZ, governance floor 0.1 VIZ (validate()), зеркало pm_min_batch_bet;
  • pm_settle_rows_per_block — default 2000, диапазон [100, 100000], потребляется фиксом D.

Оба вплетены в median loop в database.cpp. PM-параметр, который объявлен, отражён и провалидирован, но никогда не входит в этот loop, молча un-votable и заморожен на code-default — такое уже случилось один раз с pm_closed_market_retention_sec.

Точки enforcement (libraries/chain/pm_evaluator.cpp):

  1. pm_place_bet, mode == 0 (instant) → amount >= pm_min_bet;

  2. pm_place_bet, mode == 1 (queued batch) → amount >= pm_min_batch_bet. Этот путь тоже создаёт строки и тоже не имел пола — покрыт был только commit-путь;

  3. pm_transfer_position, частичный → и переданная часть, и остаток должны оставаться на уровне pm_min_bet или выше. Позиция ниже пола не заперта: её всё ещё можно передать целиком, что двигает строку вместо её деления.

  4. pm_add_liquiditypm_min_liquidity. Ликвидность — четвёртый источник строк и тот, что изначально ускользнул: каждый вызов минтит свой pm_liquidity_object (взносы не сливаются по провайдеру), а эвалюатор ассертил только amount > 0, поэтому строки можно было минтить по 1 raw за штуку, пока bet-пути были полованы. Пол тот же, что уже гейтит создание рынка, так что минимальный билет для внесения ликвидности не зависит от того, открываешь ты рынок или докладываешь позже — продуктовое решение, принятое осознанно, а не по умолчанию.

4. Фикс D — инкрементальный сеттлмент (дизайн)

Рынок несёт собственный курсор сеттлмента, а крон тратит глобальный per-block row-бюджет (pm_settle_rows_per_block, общий для всех сеттлящихся рынков, самые старые рынки первыми). settle_market становится phase-machine, возобновляемой блок за блоком:

фазаработа на строкубюджетируется
1 force-closeзакрыть плечевые позиции, ещё открытые на сеттленет — см. ниже
2 aggregateрефанд queued-строк (status 5/6), суммировать losers_sum и вес победителейда
3 claimsоплатить outcome-contingent early-exit claims из ограниченного ведрада
4 payoutзаплатить победителям / флипнуть проигравших, одна virtual op на строкуда
5 finalizeкомиссии, сеттл LP, пыль, payout_status = 3, finalized_timeнет — см. ниже

Метрируется только bet-проход, потому что только bet-строка дешёвая. Фазы 1 и 5 итерируют плечевые позиции и строки ликвидности, а каждая из них стоит pm_min_liquidity (100 VIZ) при создании — в сто раз дороже bet-строки. Их работа ограничена экономически, тем, что атакующему пришлось бы застейкать, чтобы создать строки, поэтому метрирование добавило бы курсоры и resume-state ради угрозы, которую фикс A уже выценил. Если это когда-нибудь изменится (появится дешёвый способ минтить LP или leverage-строку), эти фазы потребуют той же обработки и той же дисциплины escrow.

Где живёт resume-state

Проход текущего settle_market() от начала до конца даёт точный state, который должен нести приостановленный сеттлмент, и это больше, чем курсор: сплит денег — двухпроходный алгоритм. Проход один производит агрегаты (losers_sum, суммарный вес победителей), проход два превращает их в per-row выплаты. Разрежь функцию на любой границе блока — обоим проходам нужны сохранённые частичные результаты, плюс running totals, которые нужны финализации для пыли.

Этот state не вешается на pm_market_object. Это двенадцать полей, которые несли бы каждый рынок, когда-либо существовавший, при том что использовать их могут лишь единицы сейчас сеттлящихся — объект уже ~40 полей в ширину, а рынки — самый многочисленный объект на цепи. Вместо этого один pm_settlement_object создаётся при входе рынка в сеттлмент, ключуется уникально по рынку и удаляется на финализации, так что стоимость пропорциональна сеттлментам в полёте:

полефазасмысл
phase, cursorвсетекущая фаза и следующий id строки для обработки в ней
stake_total2losers_sum (норма) / суммарный активный стейк (void)
weight_total2Σ curve-вес победителей, 128-бит как в compute_settlement
winners_pool, uncoveredставятся на 3→4константы сплита, когда claims финальны
distributed4Σ выплаченной прибыли — финализация роутит winners_pool − distributed как пыль
lp_bonus4Σ time-penalty, снятого с победителей
paid_claims3взято из ограниченного early-exit ведра
escrowвсезнаковый аккумулятор консервации (ниже)

Per-winner выплата зависит только от winners_pool, weight_total и собственных полей строки, поэтому фазе 4 не нужна память о строках, которые она уже оплатила — именно это делает разрез чистым. Void-путь переиспользует те же поля (его два pro-rata распределения накапливаются в distributed и lp_bonus), сохраняя округление «последний участник забирает остаток» идентичным сегодняшнему.

Поскольку объект удаляется на финализации, у рынка, который не сеттлится, строки сеттлмента нет вовсе, и garbage collection сносит его вместе с остальным кластером.

Правила, которые делают это безопасным:

  • Детерминизм. Фаза, курсор и аккумуляторы живут в объекте сеттлмента; бюджет — median-voted параметр. Каждая нода, следовательно, обрабатывает ровно те же строки в тех же блоках.
  • Рынок закрыт для всего остального, пока сеттлится. payout_status = 4 («settling») не даёт §5 войти повторно, не пускает pm_dispute_create (он требует payout_status == 1), а GC не может сработать, потому что finalized_time штампуется только в фазе 5.
  • Консервация на каждой границе блока. Деньги, освобождённые со строки, но ещё не выплаченные, держатся в явном аккумуляторе escrow: += amount при освобождении строки, -= payout при оплате. PM supply-инвариант считает их PM-held, так что снапшот, снятый посреди сеттлмента, балансируется точно; финализация ассертит достижение нуля. Аккумулятор знаковый: фаза 3 платит early-exit claims из проигравшего пула, чьи строки ещё стоят, поэтому он легитимно уходит в минус до того, как фаза 4 их освободит. Это не дефицит — токены в реальных балансах аккаунтов, а строки, которые их профинансируют, всё ещё считаются PM-held, так что обе стороны инварианта двигаются вместе в любую сторону.
  • Прогресс. Рынок с N строками финиширует примерно за N / budget блоков; пол 100 на бюджете делает голодание невозможным.
  • Сами строки не могут двигаться. Каждая операция, создающая, делящая или удаляющая bet-строку — pm_place_bet, pm_commit_bet, pm_reveal_bet, pm_cancel_bet, pm_transfer_position — ассертит mkt.status == 1, а сеттлящийся рынок на status 3. Поэтому набор, пройденный в фазе 2, ровно тот же, что оплачен в фазе 4, без добавления гейта.

Одно cross-section взаимодействие не держится автоматически, и реализация должна его закрыть: крон §1 (forfeit коммитов, которые так и не раскрыли) добавляет штраф в forfeit_pool какого угодно рынка, которому принадлежит коммит, не глядя на его статус. Сегодня это безвредно — §1 работает раньше в том же блоке, чем §5, так что сеттлмент читает финальный forfeit_pool — но сеттлмент, растянутый на блоки, может получить forfeit_pool, выросший после того, как фаза 3 уже свернула его в winners_pool, и эти токены тогда не принадлежат никому (осиротели до GC-сжигания, т.е. снова failure-режим drift-400). Тайминг делает это маловероятным на практике — reveal-дедлайн сидит на закрытии беттинга, задолго до result_expiration + grace — но «маловероятно на практике» ровно то рассуждение, которое породило прежние drift'ы. Фаза 5 поэтому должна роутить любой forfeit_pool, появившийся в полёте, вместо предположения о нуле, а escrow-ассерт на финализации должен это учитывать.

4.1 Поставлено: ограниченный garbage collection

Collection пошёл первым — это тот же unbounded-проход без сеттлмент-арифметики, так что он валидирует бюджетную сантехнику сам по себе. gc_market() стал gc_market_step(db, mkt, budget): сносит не более budget объектов, декрементит его на месте и возвращает true только когда весь кластер (включая объект рынка) исчез. Рынок, слишком большой для одного блока, сохраняет своё место в голове свипа — finalized_time никогда не меняется — и продолжает на следующем блоке.

Курсор не нужен, в отличие от сеттлмента: каждый диапазон входится заново на своём lower_bound, а уже удалённые строки исчезли, так что свип возобновляется ровно там, где остановился. Две детали делают паузу безопасной:

  • сжигание forfeit_pool теперь обнуляется в том же шаге, иначе повторный вход сжигал бы те же токены заново на каждом блоке и толкал current_supply ниже учтённой суммы;
  • наполовину собранный рынок инертен — терминальный (status 3 / payout_status 3), так что ни одна операция не может до него дотянуться, а сносимые строки не держат денег (ставки 2/3, LP 3, плечо терминальное), так что supply-инвариант плоский поперёк паузы.

Покрыто gc_row_budget_spans_blocks (consensus_sim): кластер в 143 строки с бюджетом на его полу 100 должен занять больше одного блока и никогда не терять больше 100 строк за блок. Проверено против намеренно unbounded-контроля — с обойдённым бюджетом тот же тест сообщает «один блок удалил 143 строки» и «собран за 1 блок», т.е. до-фиксовое поведение.

Сеттлмент обслуживается раньше collection в блоке, так что тяжёлый сеттлмент-бэклог может отложить GC. Это безвредно: лишь растягивает retention, а сеттлмент конечен.

4.2 Поставлено: инкрементальный сеттлмент

settle_market() стал settle_market_step(db, mkt, budget), управляется pm_settlement_object, описанным выше, и тратит тот же глобальный row-бюджет, что и collection. Три вещи, которые реализация должна была сделать правильно, ни одна не видна из скетча дизайна:

  • «Я последняя строка?» нельзя ответить, заглядывая вперёд. Сегодняшний код отдаёт остаток округления последнему участнику, которого опознаёт, подглядывая в остаток рынка. Под resume этот подгляд и неверен (уже оплаченные строки всё ещё в диапазоне, просто терминальные), и квадратичен. Фаза 2 поэтому считает агрегируемые строки в rows_total, а фаза 4 считает оплаченное в rows_done; последняя строка — это rows_done == rows_total, за O(1) и стабильно поперёк паузы.
  • Void-ветка должна освобождать то, что сжигает. Когда у void-рынка не осталось участников, чтобы поглотить остаточные пулы, остаток сжигается — и сжигание без вычитания из escrow триггерит финализация-ассерт. Учёт консервации должен покрывать путь уничтожения, а не только пути оплаты.
  • Per-block работа не должна повторять one-shot side effects. Workload-гейдж markets_in_dispute_window декрементился на call-site, который теперь работает на каждом блоке полёта; он переехал в ветку, которая выполняется один раз, когда рынок впервые входит в сеттлмент (payout_status != 4). Тот же класс бага, что у сжигания forfeit_pool в collection.

Покрыто settle_row_budget_spans_blocks (consensus_sim): рынок в 140 строк сеттлится с бюджетом на его полу 100, так что должен занять больше одного блока, никогда не терминировать больше 100 строк в блоке, показывать payout_status = 4 в полёте и закончить с каждой строкой оплаченной, объектом сеттлмента снесённым и балансами бетторов, сдвинутыми ровно на сумму, записанную строками. Проверено против намеренно unbounded-контроля — с обойдённым бюджетом тот же тест сообщает «один блок оплатил 140 строк» и «сеттлился за 1 блок», т.е. до-фиксовое поведение.

4.3 Поставлено: инкрементальные void-рефанды

Два void-пути — крон §2 (missed resolution) и §3 (dispute auto-close) — имели ту же дыру со вторым ребром: refund_all_bets() обходил каждую строку рынка за один блок и строил in-memory вектор всех участников, потому что forfeit-пул делится pro-rata, а знаменатель известен только в конце прохода.

refund_market_step(db, mkt, budget) заменяет оба. Он выполняет два метрированных прохода по одному предикату (status 0/5/6): проход один только меряет (сумма стейков, число строк), проход два возвращает стейк и платит каждой строке её долю forfeit-пула. Поскольку проход один ничего не меняет, проход два переобходит ровно тот набор, что посчитал проход один — так «кого рефандит этот void» остаётся ответимым поперёк паузы без меток строк или удержания вектора.

Рынок носит payout_status = 4 с первого блока полёта, и этот флаг теперь гейт, а не просто display-значение:

  • pm_resolve_market, pm_no_contest и pm_transfer_position отказывают ему, так что поздний оракул-вызов не может обогнать рефанд, прошедший половину рынка;
  • крон §4 (финализация голосования диспута) перешагивает диспуты, чей рынок уже воидится §3;
  • крон §6 (batch epoch settle) пропускает его, так что queued-строки не могут сдвинуться между двумя проходами.

Один баг порядка выпал из написания этого, и он предшествует изменению: старый путь сливал forfeit_pool до return_liquidity(), который force-закрывает плечевые позиции и роутит их curve-residual прямиком обратно в forfeit_pool. Эти токены затем ехали на строке рынка, пока GC её не сносил — stranded в current_supply без владельца, ровно та утечка, которую void-роутинг существует предотвращать. Ликвидность теперь возвращается первой, а остаток учитывается после.

Покрыто void_refund_row_budget_spans_blocks: 140 строк, бюджет на его полу 100, никакой оракул не резолвит; void должен растянуться на блоки, оставаться под бюджетом за блок, закончить с status = 3, resolved_outcome = -1, каждой строкой отрефанденной и балансами бетторов, поднятыми ровно на стейк.

4.4 Батч-исполнитель (крон §6)

Крон §6 заполнял каждую строку, поставленную в очередь текущей эпохи рынка, внутри одного блока, по цене одной единицы считающего рынки капа — та же форма, что у бага сеттлмента, а после фикса A queued-строка стоит pm_min_bet (1 VIZ), ровно столько же, сколько bet-строка. На LMSR-рынке каждая строка дополнительно платит за котировку кривой, что делает её дороже на строку, чем сеттлмент.

Теперь он черпает из того же общего pm_settle_rows_per_block-бюджета, чарджится за каждую посещённую строку (не только исполненную — визит это работа, которую делает блок). Два следствия вытекают из того, что оставшиеся строки матчатся по эпохе:

  • счётчик эпохи инкрементится только когда очередь вычерпана; бамп посреди слива оставил бы оставшиеся строки недостижимыми с уже списанным стейком;
  • исполнитель поэтому также работает вне границы эпохи, пока проход в полёте (pm_batch_settle_bet_cursor != 0), вместо того чтобы заставлять уже раскрытые стейки ждать целое окно эпохи до следующей границы.

Точка resume — второй курсор в dynamic global properties, pm_batch_settle_bet_cursor, потребляемый первым рынком, который посещает round-robin скан (это pm_batch_settle_cursor by construction). Потеря его — например, старый снапшот без поля — безопасна: проход рестартует с головы эпохи и пропускает уже исполненные строки по статусу, ценой одного idle-прохода и нуля денег. Покрыто batch_queue_row_budget_spans_blocks.

Один гард несущий, а не косметический: §6 входится только при row_budget > 0. Он работает последним, а бюджет общий, так что settle-тяжёлый блок может дойти до него с нулём; войдя всё равно, он выполнил бы ноль итераций и провалился в persist-шаг, который — не видя mid-market-остановки — записал бы нулевой курсор строки поверх припаркованного. Следующий проход рестартовал бы с головы эпохи и потратил бюджет на повторное посещение уже исполненных строк.

4.5 Deadline-свипы перечитывали settled-рынки (найдено 2026-08-19, исправлено)

by_result_expiration был ключован (status, result_expiration, id). Settled-рынок держит status == 3, а его result_expiration остаётся в прошлом, так что он сидел в голове диапазона, который проходит §5 settle-свип — и скип его не стоит cap, так что цикл никогда не останавливался на нём рано. Каждый блок поэтому перечитывал весь settled-бэклог до реальной работы: замерено на тестнет-снапшоте блока 82641602 — 48 971 итерация, из них 48 942 чистых continue, при только 29 рынках, реально ждущих сеттлмента. Бэклог ограничен GC-retention (default 5 д), так что это не утечка — но он пропорционален обороту, и атакующий может раздуть его напрямую, создавая и резолвя рынки.

Фикс ключует индекс (status, finalized_time, result_expiration, id). finalized_time штампуется ровно один раз, на финализации, так что finalized_time == 0 означает «ещё должен работу»; оба свипа (§2 missed resolution, §5 settle) делают lower_bound в эту группу, и рынок покидает её в момент сеттла. payout_status намеренно не в ключе — settle-свип флипает его 1 → 4 в полёте и не должен двигать строку, которую продолжает. Тот же трюк, что у by_oracle_finalized. Ключи индексов не сериализуются, так что миграция снапшота не нужна.

4.6 Тэлли диспута (крон §4, найдено 2026-08-19, исправлено)

Та же форма ещё раз, в том единственном свипе, куда прошлые проходы не заглядывали. Крон §4 финализирует диспуты, чьё окно голосования закрылось; для каждого обходит каждый бюллетень спорного рынка, чтобы построить stake-weighted тэлли, и чарджит рынку одну единицу считающего рынки cap. Бюллетень тоже не дешёвая строка — каждый стоит account lookup плюс lazy-pool deposit lookup, того же порядка, что и строка сеттлмента, замеренная в §5 ниже.

M3 уже капает бюллетени на pm_dispute_votes_per_market на рынок — 10 000 как был добавлен, поднят до 100 000 в 2026-08 с vesting-полом (см. ниже) — и коммент там рассуждал, что это делает финализация-проход безопасным. Не делает: кап ограничивает один рынок, а §4 может финализировать cap таких в блоке, так что потолок был cap × 10 000 = 2 000 000 строк — на три порядка выше бюджета, который теперь уважает любой другой свип. Заполнить его медленно (бюллетеню нужен отдельный аккаунт на рынок, а 200 диспутов стоят 200 × pm_dispute_fee в escrow), но бюллетени — durable state: стоимость размазана по часам chain-времени, а работа реплеится в одном блоке, где истекают окна голосования.

В отличие от сеттлмента, тэлли нельзя возобновить: вердикту нужны все бюллетени сразу, а парковка частичных per-outcome сумм означала бы держать вектор на строке диспута. Поэтому бюджет enforced между диспутами — диспут стартует только пока остался бюджет, а затем чарджится за пройденные бюллетени. Worst case за блок становится row_budget + кап бюллетеней одного рынка вместо cap × кап бюллетеней. Отложить финализацию на блок экономически инертно: pm_dispute_vote отвергает бюллетени после voting_end_time, так что электорат уже финален, когда §4 добирается. Покрыто dispute_tally_row_budget_defers_next.

Кап строк ограничивал сколько бюллетеней рынок мог держать, но бюллетень всё ещё был бесплатен, так что Sybil-атакующий мог забить кап пыль-аккаунтами и залочить легитимных голосующих. В 2026-08 кап поднят до 100 000, и добавлен медианно-голосуемый vesting-пол pm_dispute_vote_min_vesting = 1000.000 VIZ в pm_dispute_vote: подача бюллетеня теперь требует effective_vesting_shares минимум 1000 VIZ в момент голоса — бюллетень прайсится так же, как pm_min_bet прайсит bet-строку. Пол не обходится делегированием — отзыв делегирования держит delegated_vesting_shares делегатора 5 дней (CHAIN_ENERGY_REGENERATION_SECONDS через vesting_delegation_expiration_object), так что одна тысяча VIZ может голосовать не чаще раза в 5 дней за один аккаунт. Заполнение капа 100 000 требует ~100 000 × 1000 VIZ = 100 000 000 VIZ ≈ 91% эмиссии (~110M VIZ), так что worst case row_budget + кап бюллетеней выше — теоретический потолок, а не достижимый. Вес тэлли читается в момент финализации из текущего effective vesting голосующего, поэтому бюллетень, чей стейк ушёл после голоса, таллируется ~0 — намеренно: это держит сумму весов ограниченной фактической эмиссией и не даёт weight-multiplication через recycle делегирования.

Свойство порядка, общее у этого с §5 и §6, стоит проговорить один раз: бюджет тратится в порядке секций, так что блок, насыщенный void-путями, может не оставить ничего свипам позади. Это намеренно — бэклоги это конечная работа, которая сливается — но значит, что «когда финализируется мой диспут» ограничено суммарной PM-работой в полёте, а не только §4.

Связанная per-transaction стоимость пофикшена рядом. pm_dispute_vote раньше enforcing кап бюллетеней пересчётом существующих бюллетеней рынка на каждый новый бюллетень (проход ограничен cap+1): ограниченно на транзакцию, но O(n) на бюллетень, O(n²) на заполнение рынка, и работа, которую не покрывает никакой бюджет крона — тот же антипаттерн, что M4 убрал из commit-пути с open_commits. Счёт теперь живёт на pm_dispute_object.ballots, инкрементится при создании строки бюллетеня и не трогается, когда голосующий ревизует её (ревизия перезаписывает строку, так что счётчик считает строки, а не голоса). Бюллетени никогда не удаляются по одному — GC сносит весь кластер — так что счётчик только растёт.

Снапшотам здесь нужен один лишний шаг, которого не было у open_commits. Диспуты импортируются раньше своих бюллетеней, так что contains-гардованное чтение ключа не может само починить pre-field снапшот: reconcile_pm_dispute_ballots() выполняется после импорта бюллетеней и приводит каждый счётчик в согласие с реально присутствующими строками. Это и сидит старые снапшоты (ключ отсутствует → 0 → перестроено), и ловит drift в новых, ценой одного прохода по индексу, который импорт и так только что прошёл. Покрыто dispute_ballot_counter_matches_rows, который сверяет счётчик с живым числом строк после каждого бюллетеня и пинит путь ревизии.

4.7 Порядок секций — это порядок приоритета (крон §8, найдено 2026-08-19, исправлено)

Каждая секция process_pm_markets() чарджит один и тот же счётчик done против одного pm_processing_cap_per_block. Это делает порядок секций порядком приоритета, что и задумано для свипов, делающих реальную работу — но это также значит, что секция, которая надёжно выедает бюджет, превращает всё за собой в мёртвый код.

Секция 7, шаг recall ленивого пула, ровно такая секция. Она проходит индекс status-0 аллокаций с головы каждый блок и чарджит done за каждую посещённую строку, включая те, что только инспектирует и оставляет нетронутыми (idle, steps remain, but this step isn't due yet). Чардж за инспекцию намеренный — это то, что держит секцию ограниченной — но рабочий набор велик и долгоживущ: на тестнете на блоке 82646702 было 34 548 status-0 аллокаций против капа 200. Цикл поэтому всегда крутится до done == cap.

За ней сидела секция 8, свип истечения банов. Он не исполнялся никогда. Временные баны оракулов и создателей держали стухший banned_until вечно, и pm_ban_expired никогда не эмитился. Ущерб ограничен: enforcement сравнивает banned_until с now, а не тестирует поле на пустоту, так что ни один аккаунт не оставался заблокированным дольше срока — сломалось хранимое состояние и событие истории, и любой клиент, читающий «забанен» как «поле непустое». На тестнете банов не было, пока это было правдой, так что ничего наблюдаемо не залипало; дефект в том, что секция вообще не могла отработать.

Фикс даёт свипу собственный счётчик (ban_done), а не переносит его или раздувает общий кап. Это безопасно, потому что свип самоочищающийся: визит ставит banned_until в 0, что навсегда убирает строку из свипаемого диапазона. Per-block работа поэтому — число банов, которые только что истекли, а приватный кап ограничивает даже синхронный залп.

ban_expiry_survives_saturated_cron_budget воспроизводит голодание в миниатюре — кап 2, три живые аллокации для насыщения, один короткий бан создателя, который всё равно должен истечь. Со свипом обратно на общем счётчике тест падает ровно на этом ассерте.

Общее правило, которое это оставляет: новая секция, добавленная в этот крон, мертва по прибытии, если она не сидит перед секцией 7 или не несёт собственный бюджет.

4.8 Очередь вывода ленивого пула (per-tx, найдено 2026-08-19, исправлено 2026-08-20)

service_lazy_withdraw_queue() раньше сливал FIFO-очередь вывода пула целиком на каждом вызове — цикл до исчерпания free_balance — и вызывается из шести мест, четыре внутри эвалюаторов (депозит, вывод, leverage close, leverage convert) плюс два пути возврата капитала в кроне. Пола на строку очереди нет: pm_lazy_withdraw создаёт новый request-объект на каждый частичный вывод, пока owed > 0 (одного raw достаточно), а строки одного аккаунта никогда не сливаются. Асимметрия в том, что очередь наполняется по одной транзакции на строку, а сливает её одна невиновная транзакция позже — при 150 k VIZ free balance тестнета один вызов мог выплатить до 150 миллионов строк.

Фикс (владелец q#678=A): слив теперь бюджетирован. service_lazy_withdraw_queue(db, row_limit) возвращает, сколько строк обработал; per-transaction call-sites передают 1 (плати только FIFO-голову — массу подбирает крон), а новая секция крона 9 сливает остаток до общего per-block pm_settle_rows_per_block-бюджета, пока pending_withdrawals > 0. Эта секция — liveness backstop: очередь продолжает продвигаться до row_budget строк за блок, даже когда капитал не возвращается в free_balance, так что она не может застрять. Строки, которые платит крон, всё равно честно чарджят общий бюджет (возвращаемое значение вычитается из row_budget).

lazy_withdraw_queue_row_budget_spans_blocks воспроизводит старое поведение в миниатюре — 250 one-raw строк против 100-строкового бюджета — и ассертит, что очередь растягивается на несколько блоков, ≤ row_budget за блок, FIFO-порядок, free_balance ≥ 0 и pending_withdrawals → 0. С до-фиксовым unbounded-сливом контроль сливает все 250 в первом блоке и падает на per-block границе. Без изменения layout → тестнету передеплой не нужен.

4.9 Каскад ликвидаций работает per transaction (найдено 2026-08-20, открыто)

Всё выше ограничивает работу за блок. cascade_liquidate() ломает эту рамку, потому что достигается из эвалюаторов — pm_place_bet (обе бинарные ветки), pm_cancel_bet и pm_withdraw_liquidity — так что его стоимость платится за транзакцию, а блок держит столько транзакций, сколько в него влезло.

Сам скан неизбежен по форме: за каждый раунд он обходит status-0 плечевые позиции рынка и оценивает cancel_value() против порога каждой, останавливаясь на первой жертве. Когда ликвидировать некого — нормальный случай, тот, что коммент описывает как «cheap index probe» — он всё равно посещает каждую открытую позицию на этом рынке, прежде чем заключить, что работы нет. pm_min_bet (1 VIZ) — всё, что нужно для запуска одного такого свипа, и ничто не капает, сколько ставок может нести блок.

Насколько велик может вырасти свипаемый набор, фиксируется экономикой пула, а не явным капом. Каждая открытая позиция лочит как минимум pm_min_liquidity (100 VIZ) leverage-фонда (пол #536), фонд — pm_leverage_fund_percent от free balance пула, а сам free_balance тает по мере выдачи займов, так что неподвижная точка — примерно N ≤ free / 1100 при сегодняшних 10 %. Пул, держащий ~11 M VIZ, следовательно, поддерживает ~10 000 открытых позиций, а constraint 3 капит только размер отдельной позиции, а не то, сколько их делит один рынок. При замеренных ~1.7 µs на посещённую строку это ~17 ms работы, купленных одной ставкой в 1 VIZ, повторённой для каждой ставки блока.

Стоит отметить, откуда взялся этот пол: pm_min_liquidity был наложен на займ фиксом аудита #536, и его собственный коммент формулирует намерение — «ограничивает глобальное число открытых позиций до fund_total / pm_min_liquidity». Это рассуждение корректно для работы, меряемой за блок, что и есть каждый свип выше. Оно не переносится на скан, работающий раз за транзакцию: ограничение набора ничего не говорит о том, сколько раз набор переобходится, и ничто не капает пере-проходы.

Две честные оговорки. Во-первых, внешний цикл пересканирует с головы диапазона после каждой ликвидации (O(K·N) для K ликвидаций), но K само-затухает: ликвидация продаёт токены позиции обратно в кривую, что двигает цену в сторону оставшихся позиций той же стороны и делает их безопаснее, так что массовые каскады — не ожидаемая форма. Per-bet O(N)-скан — та часть, что не зависит от того, пошло ли что-то не так. Во-вторых, на тестнете сейчас ничто из этого недостижимо: pm_leverage_max_per_position_bp (20 bp) против текущего фонда делает per-position кап (~30 VIZ) меньше, чем пол займа 100 VIZ, так что позицию вообще не открыть — известный конфликт #536, оставленный как есть владельцем (q#568). Развязка этого конфликта в пользу меньших займов пропорционально расширит этот скан; два решения связаны.

Решение (владелец q#679=D, 2026-08-20): не чинить. Плечо — нишевый продукт, выставленный набор экономически капнут (граница fund/pool выше), а атакующий и так платит залог и funding за каждую позицию, которую каскад должен сканировать. Цель #440 закрыта.

4.10 Проверено и отвергнуто: свип открытых позиций (крон §2c)

Секция, которая force-закрывает позиции после окончания беттинга, обходит все status-0 позиции каждый блок через by_lev_funding_due и чарджит done только за закрытия, так что на первый взгляд выглядит как §4.5 снова: полный скан, чьи скипнутые строки не стоят бюджета.

Это не тот же дефект, и различие стоит проговорить, потому что это граница между двумя семействами. В §4.5 голова диапазона набивалась рынками, которым работа никогда больше не понадобится, так что idle-скан рос с оборотом без границы. Здесь каждая сканируемая строка — живое обязательство — открытый займ, который цепь обязана в конце закрыть — и строка покидает диапазон навсегда в момент закрытия (status 0 → 1). Рабочий набор поэтому тот же экономически капнутый N, что в §4.9, а не бэклог трупов. Сделать это дешевле значило бы хранить дедлайн force-close на позиции и ключовать по нему индекс: изменение layout и миграция снапшота ради constant-factor выигрыша на наборе, который уже ограничен. Не стоит того; записано, чтобы следующий аудит не открывал заново. Цель #441 закрыта на этом рассуждении.

4.11 Idle fast-path батч-исполнителя (крон §6, найдено 2026-08-20, исправлено)

§4.4 поставил батч-исполнителя, чарджящего row-бюджет за каждую посещённую строку, но внешний проход всё ещё был O(active batch markets) на границу и не метрирован: idle-рынок (ничего в очереди на текущей эпохе) выскакивал до LMSR q-vector снапшота и не платил ничего — ни done, ни row-бюджет — так что per-boundary скан рос линейно с числом рынков независимо от того, сколько очереди реально существовало. Спамер мог создать тысячи allow_batch рынков и превратить каждую границу эпохи в полный idle-проход.

Фикс (P0): индекс by_status_market на pm_bet(status, market, id) — и скан теперь едет прямо от queued (status=5) строк. Только рынки, которые реально держат очередь, вообще посещаются; idle- рынки никогда не входят в диапазон. Resume-семантика не меняется: исполненные строки флипают 5 → 0/2 и покидают индекс, так что следующий lower_bound((5, market, bet)) естественно приземляется на следующей неисполненной строке, а два курсора dynamic-properties (pm_batch_settle_cursor, pm_batch_settle_bet_cursor) сохраняют смысл (market / parked-row). Защитный скип перешагивает status-5 строки, которые когда-либо протекут на неактивный рынок — §2/H2 рефанд уже платит их, а проход не должен двигать их между проходами меряния и оплаты. Цель #445 закрыта.

4.12 Purge deferred claims и loud-сигналы фаз 1/5 (2026-08-20, исправлено)

Два меньших остатка того же ревью, взятые вместе с P0 (владелец выбрал C):

  • P1 — purge_deferred_claims строил in-memory вектор каждого deferred claim перед удалением, тот же participant-vector паттерн, что нёс refund_all_bets (и который §4.3 убрал). Claims уже капнуты MAX_PM_DEFERRED_CLAIMS_PER_MARKET (10 000), так что это никогда не было проблемой границы — но вектор бесплатно сбрасывается: теперь он удаляет на месте, продвигая итератор до того, как db.remove его инвалидирует. Цель #446 закрыта.

  • P2 — неметрированные фазы сеттлмента 1 и 5. settle_market_step бюджетирует только фазы 2–4 (bet-строки). Фаза 1 (force_close_positions) и фаза 5 (settle_liquidity) обходят каждую открытую позицию / LP-строку рынка целиком; они ограничены только экономически (каждая строка ≥ pm_min_liquidity = 100 VIZ). Вместо метрирования их — курсоры и resume ради угрозы, которую пол 100 VIZ уже выценивает — код теперь эмитит loud log-only сигнал (зеркаля сигнал F1 uncovered), когда один из этих проходов перешагивает pm_settle_rows_per_block за один шаг. Он детерминирован и не имеет консенсус-эффекта; существует, чтобы будущее изменение, снижающее залоговый пол (например, развязка #536 в сторону мелких займов), сделало фазу явно дорогой вместо молчаливой деградации блок-тайма. Цель #448 закрыта.

5. Что реально стоит строка

Замерено tests/consensus_sim/bench/settle_bench.cpp (make pm_settle_bench), который гонит реальные рынки растущего числа строк через реальную цепь и таймит блок, который их сеттлит. Release-сборка, без санитайзеров, без плагина account_history:

строкиidle блоксеттлящий блокна строкуgc блокgc на строку
5000.28 ms1.24 ms1.93 µs0.59 ms0.62 µs
2 0000.29 ms3.61 ms1.66 µs1.54 ms0.62 µs
8 0000.29 ms13.95 ms1.71 µs5.50 ms0.65 µs

Сеттлмент линеен по строкам на ~1.7 µs/строку, garbage collection на ~0.62 µs/строку. Экстраполируя: примерно 600 000 строк заполняют одну секунду блок-тайма и ~1.7 M строк заполняют весь трёхсекундный интервал. Считай это нижней границей — бенчмарк ставит с десяти аккаунтов (у реального рынка их тысячи, так что лукапы менее cache-friendly), а симулированная нода не гонит плагин account_history, так что virtual op, пушимая на строку, там стоит почти ничего, тогда как API-нода платит за её индексацию.

Такова форма риска при одном фиксе A: органический рынок нигде рядом с лимитом (тестнет-рынок в 734 строки сеттлится за ~1.2 ms), но спамер, готовый стейкать 1 VIZ на строку, может купить ~1.7 секунды сеттлмент-работы в одном блоке примерно за миллион VIZ. Достаточно дёшево, чтобы стоило закрыть — это и есть фикс D.

6. Тюнинг

pm_settle_rows_per_block меняет латентность сеттлмента на блок-тайм, и замер выше — то, от чего его следует задавать:

  • default 2 000 стоит ~3.4 ms сеттлмент-работы на блок (около 0.1 % интервала) и сливает рынок в миллион строк за ~500 блоков, т.е. менее чем за полчаса;
  • поднять до 10 000 стоит ~17 ms/блок и сливает тот же рынок за ~100 блоков;
  • пол 100 существует, чтобы прогресс был гарантирован всегда.

Поднять pm_min_bet вместо этого укорачивает очередь ценой исключения мелких бетторов — предпочти сначала тюнинг бюджета.

7. Закрытый вектор атаки

Вектор был такой: спам → неограниченная работа на блок. Пул действий блока конечен, а PM-крон — единственное место, где одна дешёвая операция могла наращивать работу, которую блок обязан выполнить, без какого-либо per-block лимита. Каждый путь, способный нарастить эту работу, теперь ограничен:

  • Создание строки стоит денег. pm_place_bet требует pm_min_bet (1 VIZ), пополнение ликвидности — pm_min_liquidity (100 VIZ), а частичный pm_transfer_position больше не делит строку бесплатно.
  • Каждый свип крона метрирован. Сеттл (фазы 2–4), garbage collection, void-рефанды, батч-исполнитель (§6), тэлли диспута (§4), истечение банов (§8) и очередь ленивых выводов (§9) — все списывают общий бюджет pm_settle_rows_per_block.
  • Свипы ходят по индексам, а не сканированием. Свип дедлайнов (§4.5) и idle-путь батч-исполнителя (§4.11) идут от индексов со статусом в ключе, поэтому простаивающий или уже рассчитанный рынок ничего не стоит.
  • Оставшиеся не-метерённые обходы либо оценены, либо сигналят. Фазы сеттла 1 и 5 (force_close_positions, settle_liquidity) обходят рынки целиком, но каждая строка в них стоит не меньше pm_min_liquidity (100 VIZ); при превышении бюджета за один шаг они эмитят громкий log-сигнал (§4.12). Каскад ликвидаций (§4.9) идёт per-tx, его размер задан фондом плеча, а не капом, и сегодня недостижим из-за конфликта полов #536.
  • Жёсткие капы закрывают остальное. MAX_PM_DEFERRED_CLAIMS_PER_MARKET (10 000), pm_dispute_votes_per_market (100 000, с vesting-полом 1000 VIZ), MAX_PM_OPEN_COMMITS_PER_MARKET (10 000) ограничивают вектора участников, переживающие время жизни рынка.

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