
В начале ноября 2025 года децентрализованный протокол Balancer V2 (composable stable pools) подвергся атаке, суммарные потери по разным сетям превысили 128 млн долларов. Причиной стала не ошибка доступа, не реентерабельность и не баг в проверках прав, а потеря точности при расчете инварианта из-за округления. Формально проблема сводилась к округлению при масштабировании значений и выглядела как допустимый технический компромисс. Экономический эффект, однако, оказался значительным.
Взлом Balancer, как и многие другие инциденты, уже подробно разобран в формате post-mortem — с реконструкцией того, как именно была проведена атака. В этой статье подход другой: мы посмотрим на тот же код глазами аудитора, который задается вопросом «что здесь может пойти не так?».
Краткое описание проекта
Balancer — это один из крупнейших децентрализованных протоколов для обмена токенов и управления ликвидностью. Его суть в том, чтобы дать пользователям возможность создавать пулы из нескольких токенов с гибкой логикой ценообразования, а провайдерам ликвидности — зарабатывать комиссию, владея долей в таком пуле. В версии V2 архитектура была усложнена и оптимизирована: хранение средств вынесено в единый смарт-контракт Vault, а математика расчетов — в отдельные контракты пулов.
Если упростить, Balancer V2 устроен так:
Один контракт (Vault) хранит деньги. Это «бухгалтерия», которая знает, у кого сколько лежит.
Другой контракт (Pool) считает математику: сколько токенов кому положено и по какому курсу. Это калькулятор, в котором определяется, сколько вы получите при обмене и выводе токенов.
Что такое BPT
У каждого пула есть собственный токен — BPT (Balancer Pool Token). Это пай в общем котле. Если у вас 10% BPT, значит, вам принадлежит 10% ликвидности пула. Цена BPT зависит от внутреннего расчета пула. Упрощенно:
Балансы токенов → Внутренний расчет → Цена BPT
Если внутренний расчет меняется, меняется и цена пая, даже если балансы визуально почти не изменились.
Про масштабирование (scalingFactor)
Токены имеют разное количество знаков после запятой (decimals): например, у USDC их 6, а у ETH — 18. Чтобы математика работала корректно, пул приводит все к единой «внутренней» точности — 18 знаков.
Но это не просто выравнивание decimals. В большинстве пулов используется еще и внутренний курс токена (rate). Фактически пул учитывает не только точность токена, но и его текущую стоимость относительно базового актива. В stable‑пулах такие курсы обычно колеблются вокруг единицы (например, 1,01 или 0,99), поскольку активы предполагаются похожими по стоимости.
На уровне кода перевод во внутренний масштаб выполняется функциями upscaleArray и upscale. Именно их мы увидим в следующем разделе при разборе свопа (обмена активами).
Где появляется округление
При переводе во внутреннюю модель значения умножаются на коэффициент масштабирования и курс токенов, после чего результат приводится к фиксированной точности. Именно в этот момент и возникает необходимость округления. С технической точки зрения это fixed‑point-арифметика с масштабом 1e18. Упрощенно внутреннее значение считается так:
внутреннее значение = (внешний баланс × scalingFactor) / 1e18
В коде это реализовано через mulDown — умножение с последующим делением на 1e18 и округлением в меньшую сторону:
function mulDown(uint256 a, uint256 b) returns (uint256) { return (a * b) / 1e18; // дробная часть отбрасывается }
Каждый своп проходит несколько этапов:
вычитание комиссии (
_subtractSwapFeeAmount);перевод балансов пула во внутренний масштаб (
_upscaleArray);перевод входного значения во внутренний масштаб (
_upscale);расчет обмена через onSwapGivenIn или onSwapGivenOut — здесь по инварианту (внутреннему расчету) пула вычисляется, сколько токенов пользователь получит или должен заплатить;
обратный перевод результата во внешний масштаб (
_downscaleUp) или_downscaleDown).
В переходах между внешними и внутренними значениями и появляется направленное округление, при котором дробная часть всегда отбрасывается, а число округляется в одну и ту же сторону: всегда в меньшую (floor) или всегда в большую (ceil):
function swapGivenIn( SwapRequest memory swapRequest, uint256[] memory balances, uint256 indexIn, uint256 indexOut, uint256[] memory scalingFactors ) internal returns (uint256) { swapRequest.amount = subtractSwapFeeAmount(swapRequest.amount); upscaleArray(balances, scalingFactors); swapRequest.amount = upscale(swapRequest.amount, scalingFactors[indexIn]); uint256 amountOut = onSwapGivenIn(swapRequest, balances, indexIn, indexOut); return downscaleDown(amountOut, scalingFactors[indexOut]); }
Ключевая функция масштабирования — та часть кода, где разработчики отдельно прокомментировали поведение округления:
…the impact of this rounding is expected to be minimal
То есть авторы протокола прямо зафиксировали свое предположение: эффект такого округления должен быть минимальным.
// Upscale rounding wouldn't necessarily always go in the same direction: in a swap for example the balance of // token in should be rounded up, and that of token out rounded down. This is the only place where we round in // the same direction for all amounts, as the impact of this rounding is expected to be minimal. function _upscale(uint256 amount, uint256 scalingFactor) internal pure returns (uint256) { return amount.mulDown(scalingFactor); }
Здесь используется одностороннее округление вниз (mulDown). На выходе применяется downscaleDown или downscaleUp в зависимости от направления свопа.
Почему проблема не выглядит опасной
Если смотреть на _upscale изолированно, все кажется безобидным. Мы умножили число, поделили на 1e18 и отбросили дробную часть. Потеряли долю порядка 10⁻¹⁸ от величины. В обычном учете такую погрешность можно игнорировать — даже миллионы операций не дадут заметного эффекта. Поэтому комментарий разработчиков о minimal impact кажется обоснованным. Но ключевой момент не в размере ошибки, а в том, на что она влияет.
В пуле результат после округления участвует в пересчете внутреннего состояния, которое определяет цену BPT. А цена BPT — это фактически внутренний курс пая. То есть маленькая потеря точности начинает влиять не просто на одно число, а на внутренний курс пула.
В комментариях к реализации StableSwap сама идея формулы описывается упрощенно так: инвариант D — это величина, вычисляемая из балансов пула и отражающая его общую стоимость в рамках математической модели. Схематично:
D = f(balance₁, balance₂, ..., balanceₙ)
А цена BPT зависит от D:
price_BPT ≈ D / totalSupply
Если одно из внутренних значений баланса меняется даже на небольшую величину (в том числе из‑за округления), пересчитывается D, а значит — и цена пая. Схематично:
Маленькое округление → Изменение внутреннего расчета → Изменение цены BPT
И если цена пая чуть сдвигается без реального изменения активов, возникает экономический перекос. Размер погрешности мал, но точка ее приложения чувствительна.
Граничные значения
Важно понимать разницу между двумя типами изменений. Когда пользователь делает своп, цена в пуле должна измениться. Это нормально, если вы покупаете один токен за другой. Пул как бы двигается по своей ценовой кривой. Но при этом саму оценку пула операция изменять не должна. Обмен — это перераспределение активов внутри системы, а не создание или уничтожение стоимости.
Проблема начинается тогда, когда округление влияет не просто на текущий обмен, а на внутренний расчет, который лежит в основе этой кривой. В этом случае операция уже не только двигает состояние по кривой, но и слегка сдвигает саму кривую. Это аналогично тому, как если бы обмен валюты в банке влиял на размеры депозитов.
Реально это заметно только при граничных условиях — при малых значениях или рядом с порогом округления. Например, если внутреннее значение после масштабирования должно быть 8,918, оно станет 8, если 9,002 — 9.
8.918 → 8
9.002 → 9
Если такие шаги повторяются, а каждый из них участвует в пересчете общей оценки пула, возникает систематический перекос. Атаки на подобные уязвимости обычно состоят из последовательных операций, которые не обходят ни одного ожидания в коде, но постепенно смещают внутреннюю оценку активов.
Что это значит для аудита
Округление само по себе не является багом. Но оно перестает быть «технической мелочью» и становится экономическим фактором, если:
участвует в пересчете внутреннего состояния пула,
влияет на оценку долей инвесторов (LP‑доли),
может накапливаться при повторении операций,
проявляется на граничных значениях.
Минимальный набор проверок, который необходимо выполнить при аудите подобного кода:
Проходит ли округленное значение через расчет инварианта или внутреннего состояния.
Возможна ли roundtrip‑асимметрия (обмен A → B → A возвращает не исходное значение, а систематически большее или меньшее из-за округлений).
Что происходит на минимальных значениях и рядом с порогом масштабирования.
Можно ли повторить операцию много раз в одной транзакции.
Меняется ли цена LP‑доли без реального притока или оттока активов.
Если хотя бы одна из проверок дает тревожный сигнал, комментария expected to be minimal уже недостаточно. Это гипотеза, которую нужно подтверждать тестами на краях и в композиции.
Кейс Balancer показал важную вещь: локально корректная арифметика не гарантирует глобальную устойчивость модели. Погрешность может оказаться незначительной по размеру, но критичной по точке приложения.
Если тема качества смарт-контрактов интересна, я подробно рассказал о том, как аудиторы смотрят на код и типичные источники проблем, в интервью.
