DeFi-примитивы: AMM, кредитование, стейблкоины, ликвидации, композиция
Предыдущие статьи трека собирали машину: состояние и газ, контракты и их уязвимости, токены, приложение вокруг цепи. Осталась неудобная дыра: почти весь реальный трафик публичных цепей — это финансовые протоколы, и человек, который знает EVM, но не знает, что такое health factor и почему своп на 1 % резерва двигает цену на 2 %, интегрирует их вслепую.
Здесь важно сразу снять два предубеждения. Первое: DeFi — не «финансы с блокчейном», а очень небольшой набор конечных автоматов над балансами, у которых нет права на усмотрение. Банк может отказать в выдаче, отменить перевод, реструктурировать долг по звонку. Контракт не может ничего: он умеет только применить формулу к числам, которые ему передали, и обновить mapping. Из этого вытекает вся конструкция — включая то, почему залог обязан быть избыточным, почему ликвидацию делает случайный посторонний бот, и почему цена приходит извне и оказывается главной точкой отказа.
Второе: сложность DeFi — не в «финансовой математике». Формулы здесь школьного уровня, их четыре штуки. Сложность в том, что каждый примитив — публичный API без аутентификации, который вызывают в одной транзакции вместе с другими такими же, и все инварианты приходится держать не в своей голове, а в коде. Разбираемся по примитивам, а в конце — про их композицию, где и происходят почти все интересные аварии.
1. Пять примитивов, из которых собрано всё остальное
Дальше подробно разбираются первые три — они образуют фундамент, на который опираются остальные. Производные в этой статье затрагиваются вскользь: их риск-модель — это, по сути, кредитование плюс оракул плюс механизм ликвидации, то есть комбинация уже разобранного.
2. Почему в цепи не работает книга заявок
Классическая биржа — это limit order book: отсортированные списки заявок, матчинг лучших встречных. В EVM такая структура ломается об экономику исполнения (газ — не абстракция, а счёт).
- Заявка — это запись в storage. Выставить и отменить — две транзакции с записью и удалением слотов. Маркетмейкер, который в обычной бирже переставляет котировки десятки раз в секунду, здесь платит за каждое движение.
- Матчинг — это цикл по заявкам. Стоимость исполнения зависит от глубины стакана, а лимит газа в блоке конечен: заявка может стать неисполнимой просто потому, что стакан вырос.
- Отмена не приватна. Транзакция отмены висит в мемпуле, её видно, и её можно обогнать. Маркетмейкер обязан рисковать по устаревшей котировке — это классическая проблема stale quote sniping, и в цепи она усиливается.
Ответ, который выжил, — AMM (automated market maker): заменить список заявок формулой. Пул хранит два числа (резервы), а цена — функция от них. Пассивный поставщик ликвидности не переставляет котировки, а один раз вносит активы. Пропускная способность больше не зависит от глубины стакана: своп — это O(1) арифметика и две записи в storage.
Плата за это — качество исполнения. Формула не знает, что происходит на других площадках; она просто движется по кривой. Восстанавливают связь с внешним рынком арбитражёры, и оплачивают эту связь поставщики ликвидности. Это ключ ко всему разделу.
3. AMM с постоянным произведением
3.1. Инвариант и цена
Пул держит резервы двух токенов $x$ и $y$ и обязуется соблюдать инвариант
$$ x \cdot y = k $$
Любой обмен обязан оставить произведение не меньше прежнего. Мгновенная («серединная») цена — это модуль производной кривой:
$$ P = -\frac{dy}{dx} = \frac{y}{x} $$
То есть цена в пуле — это просто отношение резервов. Никакого «источника цены» нет: пул не знает, сколько актив стоит, он знает только, сколько его у него осталось.
3.2. Сколько на самом деле получит трейдер
При обмене $\Delta x$ на $\Delta y$ с комиссией $f$ (доля, остающаяся в пуле) новое произведение не должно упасть:
$$ (x + \Delta x (1 - f)) \cdot (y - \Delta y) \geq x \cdot y \quad\Longrightarrow\quad \Delta y = \frac{y \cdot \Delta x (1 - f)}{x + \Delta x (1 - f)} $$
В целочисленной арифметике EVM это записывают без дробей — ровно так, как в Uniswap V2 с комиссией 0.30 %:
FEE_NUM, FEE_DEN = 997, 1000 # 0.30 % остаётся в пуле
def get_amount_out(dx: int, x: int, y: int) -> int:
"""Выдача пула при внесении dx. Целые числа, округление вниз — в пользу пула.
Сложность: O(1) по времени, O(1) по памяти."""
assert dx > 0 and x > 0 and y > 0, "пустой пул или нулевой ввод"
dx_fee = dx * FEE_NUM
return (dx_fee * y) // (x * FEE_DEN + dx_fee)
x, y = 1_000 * 10**18, 2_000_000 * 10**18 # пул: 1000 X и 2 000 000 Y, цена 2000
dx = 10 * 10**18 # сделка на 1 % резерва
out = get_amount_out(dx, x, y)
print(out / dx, y / x, (y - out) / (x + dx)) # курс сделки, цена до, цена после
Вывод для сделки в 1 % резерва: получено 19 743.16 Y, фактический курс 1974.32 против середины 2000.00 — на 1.28 % хуже; цена в пуле после сделки 1960.65, то есть сдвинулась на 1.97 %.
Разложим 1.28 %: 0.30 % — комиссия, остальные ≈0.98 % — проскальзывание, плата за движение по кривой. Для малых сделок работают приближения $\text{проскальзывание} \approx \Delta x / x$ и $\text{сдвиг цены} \approx 2 \Delta x / x$.
Отсюда практическое правило, которое стоит запомнить наизусть: торгуя долю $\alpha$ резерва, вы теряете примерно $\alpha$ и двигаете цену примерно на $2\alpha$. Пул с резервом 1 млн USD не переварит сделку на 100 тыс. USD без 10 % потерь — и никакой «глубины книги» здесь нет, есть только эта гипербола.
Отдельно стоит понять, что контракт пула не считает выдачу. Uniswap V2 устроен наоборот: маршрутизатор оптимистично забирает токены, а пул лишь проверяет, что инвариант не нарушен:
// Ядро Uniswap V2, упрощённо: пул не вычисляет цену, он проверяет неравенство.
function swap(uint256 amount0Out, uint256 amount1Out, address to) external lock {
require(amount0Out > 0 || amount1Out > 0, "INSUFFICIENT_OUTPUT");
(uint112 r0, uint112 r1) = (reserve0, reserve1);
require(amount0Out < r0 && amount1Out < r1, "INSUFFICIENT_LIQUIDITY");
if (amount0Out > 0) _safeTransfer(token0, to, amount0Out); // выдали заранее
if (amount1Out > 0) _safeTransfer(token1, to, amount1Out);
// сколько реально пришло — считаем по фактическому балансу, а не по аргументам
uint256 b0 = IERC20(token0).balanceOf(address(this));
uint256 b1 = IERC20(token1).balanceOf(address(this));
uint256 in0 = b0 > r0 - amount0Out ? b0 - (r0 - amount0Out) : 0;
uint256 in1 = b1 > r1 - amount1Out ? b1 - (r1 - amount1Out) : 0;
require(in0 > 0 || in1 > 0, "INSUFFICIENT_INPUT");
// инвариант с учётом комиссии 0.30 %: масштаб 1000, вычитаем 3 промилле с входа
uint256 b0adj = b0 * 1000 - in0 * 3;
uint256 b1adj = b1 * 1000 - in1 * 3;
require(b0adj * b1adj >= uint256(r0) * uint256(r1) * 1000**2, "K");
_update(b0, b1);
}
Две мысли, которые из этого следуют. Во-первых, «выдали, потом проверили» — это и есть механизм flash swap: между выдачей и проверкой можно вызвать чужой контракт (см. раздел 6). Во-вторых, факт прихода средств определяется фактическим балансом, а не аргументом функции: иначе токен с комиссией на перевод сломал бы учёт (разбор странных токенов — в статье о стандартах).
3.3. Поставщик ликвидности и непостоянные потери
Внося пару в пропорции текущей цены, поставщик получает LP-токены — долю пула:
$$ \text{доля} = \frac{L_{\text{ваши}}}{L_{\text{всего}}}, \qquad L = \sqrt{x \cdot y} $$
Комиссии не выплачиваются отдельно: они остаются в резервах, поэтому $k$ растёт, и стоимость доли увеличивается сама. Это удобно для газа и неудобно для интуиции — «доход» не виден как транзакция.
Теперь неприятная часть. Арбитраж всегда торгует против пула: когда внешняя цена растёт, у пула забирают подорожавший актив и доливают подешевевший. В результате портфель LP всегда «продавал растущее и покупал падающее». Разница с простым удержанием тех же активов называется непостоянными потерями (impermanent loss). Для пула 50/50 при изменении цены в $r$ раз:
$$ \text{IL}(r) = \frac{2\sqrt{r}}{1 + r} - 1 $$
from math import sqrt
def impermanent_loss(r: float) -> float:
"""Отставание LP-позиции от простого удержания при изменении цены в r раз."""
return 2 * sqrt(r) / (1 + r) - 1
| Изменение цены | 1.25x | 1.5x | 2x | 3x | 5x | 0.5x | 0.25x |
|---|---|---|---|---|---|---|---|
| Отставание от удержания | −0.62 % | −2.02 % | −5.72 % | −13.40 % | −25.46 % | −5.72 % | −20.00 % |
Функция симметрична по $r$ и $1/r$ и всегда неположительна. «Непостоянными» потери называются потому, что при возврате цены они исчезают — но рассчитывать на возврат нельзя, а комиссии за это время могут их и не покрыть. Честная формулировка: LP продаёт волатильность; он в плюсе, когда торговый оборот велик, а цена ходит вокруг одного уровня, и в минусе при направленном тренде. Более точный современный язык — LVR (loss-versus-rebalancing) из работы Милионис, Мозер, Мозер и Ресник, arxiv.org/abs/2208.06046: арбитражёр систематически снимает с пула ту же величину, что маркетмейкер на обычной бирже потерял бы на неблагоприятном отборе.
3.4. Другие кривые: когда постоянного произведения мало
Гипербола универсальна, но неэффективна для активов, которые должны стоить одинаково: рядом с ценой 1:1 она даёт лишнее проскальзывание. Отсюда семейство кривых:
| Кривая | Инвариант | Где применяется | Цена ошибки |
|---|---|---|---|
| Постоянное произведение | $x y = k$ | любые пары | проскальзывание на всём диапазоне |
| Постоянная сумма | $x + y = k$ | теоретический предел | пул опустошается арбитражём при малейшем отклонении |
| StableSwap (Curve) | $A n^n \sum x_i + D = A D n^n + \frac{D^{n+1}}{n^n \prod x_i}$ | стейблкоины, LST | при разрыве пега пул целиком набирает слабый актив |
| Взвешенная (Balancer) | $\prod x_i^{w_i} = k$ | пулы 80/20, индексы | те же непостоянные потери, но асимметричные |
| Концентрированная (Uniswap V3) | $xy=k$ на диапазоне тиков | капиталоёмкие пары | позиция вне диапазона перестаёт торговать |
Инвариант Curve не решается в замкнутой форме — контракт находит $D$ методом Ньютона за несколько итераций прямо в EVM (см. численные методы); отсюда его заметная стоимость в газе. Параметр $A$ («амплификация») задаёт, насколько кривая близка к постоянной сумме внутри пега и как быстро она переходит к гиперболе за его пределами. Концентрированная ликвидность заслуживает отдельной оговорки, потому что меняет профессию LP. В Uniswap V3 поставщик выбирает диапазон цен, а контракт работает с «виртуальными резервами», как если бы в этом диапазоне был обычный x·y=k. Капиталоёмкость растёт в десятки раз, но позиция вне диапазона превращается в 100 % одного актива и перестаёт зарабатывать. Пассивное вложение становится активным управлением — и статистика по кошелькам LP-позиций регулярно показывает, что средний розничный поставщик проигрывает простому удержанию. В Uniswap V4 (2025) к этому добавились хуки — пользовательский код на этапах инициализации, свопа и изменения ликвидности, что превращает пул в платформу и одновременно расширяет поверхность атаки: docs.uniswap.org.
4. Цена в пуле — не оракул
Соблазн понятен: у пула есть getReserves(), отношение резервов — цена, читается одним вызовом. Именно так были потеряны сотни миллионов.
Резервы — мгновенное состояние, которым внутри одной транзакции управляет любой, у кого есть капитал (а с флеш-займом — вообще любой). Контракт, считающий залог по спотовой цене пула, оценивается по цене, которую атакующий назначит сам. Механика этих атак, TWAP, кворум источников и требования к проверке ответов Chainlink (updatedAt, answeredInRound, границы агрегатора) подробно разобраны в безопасности контрактов — здесь достаточно инженерного минимума:
- спотовая цена пула годится для UI и маршрутизации, но не для решений о деньгах; TWAP лучше ровно потому, что стоит атакующему удержания цены на протяжении окна;
- push-оракулы (Chainlink Data Feeds) обновляются по отклонению и heartbeat: между обновлениями ваша цена устарела, и протокол обязан это учитывать;
- pull-оракулы (Pyth, Chainlink Data Streams) требуют, чтобы пользователь принёс подписанное обновление в той же транзакции — свежее, но появляется зависимость от доступности сети публикаторов.
5. Кредитование под залог
5.1. Почему залог обязан быть избыточным
У контракта нет ни личности заёмщика, ни суда, ни коллекторов. Единственный доступный ему рычаг — актив, который он уже держит. Значит, заём выдаётся только против залога, который стоит больше долга, и разница должна покрывать: падение цены за время до ликвидации, премию ликвидатору, ошибку оракула и проскальзывание при продаже залога. Отсюда типичные 120–170 % обеспечения и главный вывод: DeFi-кредит — это не кредит в бытовом смысле, это ломбард с автоматическим сроком продажи залога. Займы без обеспечения существуют лишь в форме флеш-займа, где вопрос доверия снят конструкцией транзакции.
5.2. Ставка как функция утилизации
Пулу нужно одновременно платить вкладчикам, брать с заёмщиков и сохранять ликвидность для вывода. Всё это решается одной кусочно-линейной функцией от утилизации:
$$ U = \frac{B}{C + B} $$
где $B$ — занято, $C$ — свободный остаток. Ставка растёт медленно до перелома (kink) и резко после — так рынок сам выталкивает утилизацию обратно, когда вкладчикам становится нечего выводить.
def borrow_rate(u: float, base=0.00, slope1=0.05, kink=0.80, slope2=1.00) -> float:
"""Годовая ставка заёмщика: пологий участок до перелома, крутой после."""
if u <= kink:
return base + u * slope1
return base + kink * slope1 + (u - kink) * slope2
def supply_rate(u: float, reserve_factor=0.10) -> float:
"""Ставка вкладчика: проценты заёмщиков делятся на всех вкладчиков минус резерв."""
return borrow_rate(u) * u * (1 - reserve_factor)
При утилизации 50 % заёмщик платит 2.5 %, вкладчик получает 1.13 %; при 95 % — 19.0 % и 16.2 %. Разрыв между ставками — не жадность, а арифметика: проценты платит только занятая часть, а делятся они на весь депозит, и часть уходит в резервный фонд протокола (тот самый, из которого потом закрывают плохой долг). Практическое следствие для интеграции: высокая доходность на вкладе почти всегда означает высокую утилизацию, то есть невозможность выйти прямо сейчас — «ликвидный» депозит ликвиден ровно до момента, когда он всем понадобился.
5.3. Индексная бухгалтерия: почему никто не обходит балансы циклом
Проценты капают каждому — но обойти миллион позиций в транзакции нельзя. Решение из мира банковских систем: хранить индекс и пересчитывать баланс лениво.
$$ I_{t+\Delta} = I_t \left(1 + r \cdot \frac{\Delta}{\text{год}}\right), \qquad \text{долг}{\text{сейчас}} = \text{долг}{\text{записанный}} \cdot \frac{I_{\text{сейчас}}}{I_{\text{на момент займа}}} $$
Пользователь хранит «нормализованную» величину, глобально хранится индекс, обновляемый при любом касании рынка. Стоимость начисления процентов всем сразу — O(1). Ровно такая же идея в Compound (borrowIndex, exchangeRate у cToken) и Aave (liquidityIndex, variableBorrowIndex, aToken с растущим балансом).
Обратите внимание на поля supply_cap, liq_threshold и heartbeat: это не украшения, а параметры риска, которыми кто-то управляет. Кто именно — вопрос следующей статьи, и он важнее большинства строк кода.
5.4. Здоровье позиции и ликвидация
Единственная метрика, которой живёт заёмщик:
$$ HF = \frac{\sum_i C_i \cdot P_i \cdot LT_i}{\sum_j D_j \cdot P_j} $$
где $C_i$ — залоги, $LT_i$ — их пороги ликвидации, $D_j$ — долги, $P$ — цены из оракула. Позиция ликвидируема при $HF < 1$.
или капают проценты Рискованная --> Здоровая: довнесли залог
или погасили часть долга Рискованная --> Ликвидируемая: HF < 1 Ликвидируемая --> Здоровая: ликвидатор погасил часть долга,
забрал залог с премией Ликвидируемая --> ПлохойДолг: залог дешевле долга —
ликвидация невыгодна никому ПлохойДолг --> Списание: резервный фонд,
страховой модуль или убыток вкладчиков Здоровая --> [*]: долг погашен, залог забран Списание --> [*]
Ликвидацию делает не протокол, а любой посторонний бот: он гасит часть долга своими деньгами и забирает залог со скидкой. Это и есть весь механизм принуждения — экономический стимул вместо администратора. Ключевые параметры: close factor (какую долю долга можно погасить за раз, типично 50 %) и liquidation bonus (премия, типично 5–10 %).
def health_factor(collateral_qty, collateral_price, lt, debt_usd) -> float:
return collateral_qty * collateral_price * lt / debt_usd
def liquidate(collateral_qty, price, lt, debt_usd, close_factor=0.5, bonus=0.05):
"""Одна ликвидация: гасим долю долга, забираем залог с премией. O(1)."""
repaid = debt_usd * close_factor
seized_usd = repaid * (1 + bonus)
seized_qty = seized_usd / price
return collateral_qty - seized_qty, debt_usd - repaid, seized_usd - repaid
qty, lt, debt = 10.0, 0.825, 15_000.0 # 10 ETH залога, порог 82.5 %, долг 15 000 USD
print([round(health_factor(qty, p, lt, debt), 3) for p in (2000, 1900, 1800)])
print(liquidate(qty, 1800, lt, debt)) # остаток залога, остаток долга, прибыль
Числа: при цене 2000 USD здоровье 1.100, при 1900 — 1.045, при 1800 — 0.990, то есть позиция открыта для ликвидации. Ликвидатор гасит 7500 USD, забирает 4.375 ETH (7875 USD), кладёт в карман 375 USD, а у заёмщика остаётся 5.625 ETH против долга 7500 USD — здоровье 1.114. Система вернулась в норму, «наказание» заёмщика составило те самые 375 USD.
Что здесь важно инженерно:
- Ликвидация — гонка. Прибыльные позиции разбирают боты за один блок, и это классический MEV: та же приоритетная комиссия, тот же приватный поток транзакций (см. раздел о порядке транзакций).
- Премия — не подарок, а цена страховки. Слишком маленькая — ликвидаторы не придут при волатильности и высоком газе, слишком большая — заёмщики платят лишнего.
- Оракул задаёт момент. Push-оракул с порогом отклонения 0.5 % означает, что ликвидация происходит не в момент падения цены, а в момент её публикации. Все окна риска считаются от этого.
5.5. Плохой долг
Если цена проваливается быстрее, чем боты успевают отработать (или ликвидация становится убыточной из-за проскальзывания при продаже залога), у протокола образуется долг без обеспечения. Дальше его закрывают из резервного фонда, из страхового модуля со стейкингом токена протокола, либо он молча размазывается по вкладчикам через невозможность вывести всё.
Хрестоматийный пример — «чёрный четверг» MakerDAO 12 марта 2020: перегруженная сеть, взлетевший газ, аукционы залога, выигранные нулевыми ставками, — и дыра в несколько миллионов USD, закрытая аукционом MKR. Разбор и последующие изменения параметров: blog.makerdao.com и постмортемы в forum.makerdao.com. Урок ровно один: риск-модель протокола проверяется не средним днём, а худшим часом, когда одновременно падает цена, растёт газ и стоит очередь в мемпуле.
6. Флеш-займы: заём длиной в одну транзакцию
Флеш-займ — самое непонятое место в DeFi. Никакого «взлома» в нём нет: это прямое следствие атомарности транзакции. Если весь заём и его возврат происходят внутри одной транзакции, то отказ возврата откатывает и выдачу. Кредитор не рискует ничем, поэтому и залог не нужен.
Стандартный интерфейс — ERC-3156; у Aave V3 своя пара flashLoanSimple/executeOperation с премией порядка 0.05 %; у Uniswap V2 то же самое достигается flash swap (выдача до проверки инварианта, которую мы видели в коде выше).
contract Arbitrageur is IERC3156FlashBorrower {
bytes32 private constant CALLBACK_OK = keccak256("ERC3156FlashBorrower.onFlashLoan");
address private immutable lender;
address private immutable owner;
constructor(address _lender) { lender = _lender; owner = msg.sender; }
function onFlashLoan(
address initiator, address token, uint256 amount, uint256 fee, bytes calldata data
) external returns (bytes32) {
// Две проверки, без которых контракт превращается в бесплатный кошелёк:
require(msg.sender == lender, "callback not from lender"); // зовёт именно кредитор
require(initiator == address(this), "loan not initiated by us"); // заём начали мы
_executeStrategy(token, amount, data); // полезная работа
IERC20(token).approve(lender, amount + fee); // возврат с премией
return CALLBACK_OK;
}
}
Легитимные применения существуют и полезны: арбитраж между площадками (он же выравнивает цены), ликвидация без собственного капитала, рефинансирование позиции без её закрытия, миграция залога между протоколами. Но у флеш-займа есть и второе лицо: он обнуляет требование к капиталу для любой атаки, где нужна временная власть над числами внутри одной транзакции. Отсюда две канонические катастрофы:
- манипуляция оракулом: занять, продавить цену в мелком пуле, взять заём под раздутый залог в чужом протоколе, вернуть (Mango Markets, октябрь 2022, ≈114 млн USD);
- захват управления: занять, купить голоса, провести и мгновенно исполнить своё предложение (Beanstalk, апрель 2022, ≈182 млн USD) — механику этой атаки и защиту от неё разбираем в следующей статье.
Правильный вывод: не «флеш-займы опасны», а любая величина, которую можно изменить внутри одной транзакции, не является метрикой доверия. Спотовая цена, текущий баланс, totalSupply, вес голоса в моменте — всё это мгновенные значения, а не факты о мире.
7. Стейблкоины: три способа держать пег
«Стейблкоин» — не свойство токена, а обещание, которое кто-то поддерживает. Способов ровно три, и они отличаются тем, кому вы доверяете.
Обеспеченные фиатом вне цепи (USDC, USDT). Токен — расписка эмитента; пег держит обещание выкупа 1:1 и арбитраж вокруг него. Технический риск невелик, зато в полный рост встают риски эмитента, банка и цензуры: у контракта есть чёрный список, а активы лежат в обычной банковской системе. В марте 2023 года USDC отклонился до ≈0.88 USD за сутки — не из-за кода, а из-за 3.3 млрд USD резервов в рухнувшем банке. Практический вывод для интегратора: чёрный список означает, что ваш контракт может внезапно потерять способность переводить средства, и это должно быть в модели угроз.
Обеспеченные криптоактивом с избытком (CDP: MakerDAO/Sky, Liquity). Пользователь запирает волатильный залог и печатает стейбл под него — та же схема, что в разделе 5, включая ликвидации. Пег держится процентной ставкой (stability fee), ликвидациями и модулем прямого обмена на USDC (PSM). Здесь тоже стоит быть честным: чем больше PSM, тем ближе такой стейбл к обычной расписке — независимость обменивается на устойчивость.
Алгоритмические — пег без обеспечения, за счёт эмиссии парного токена. Механизм работает ровно до момента, когда рынок перестаёт верить в парный токен: спрос на выкуп разгоняет эмиссию, эмиссия обваливает цену, обвал усиливает спрос на выход. Это петля положительной обратной связи (см. обратные связи в системах). Май 2022 года, Terra/UST: примерно 40 млрд USD стоимости исчезли за несколько дней. Модель считается несостоятельной не потому, что «плохо реализовали», а потому, что её устойчивость опирается на ожидания участников — то есть на самую хрупкую из возможных опор.
| Механизм | Что держит пег | Главный риск | Что проверять интегратору |
|---|---|---|---|
| Фиатное обеспечение | обещание выкупа эмитентом | эмитент, банк, чёрный список | адрес эмитента, право заморозки, отчётность по резервам |
| Избыточный залог | ликвидации и ставка | обвал залога, срыв ликвидаций | коэффициенты, состав залога, доля PSM |
| Алгоритмический | ожидания рынка | спираль эмиссии | — (интеграция такого актива как залога — ошибка) |
| Обёртка с моста | подписанты моста | компрометация моста | кто мостовые подписанты (см. https://courses.digitable.life/post/web3/10-scaling/) |
8. Откуда на самом деле берётся доходность
Полезная привычка: увидев цифру APY, назвать источник денег. Источников конечное число.
- Комиссии и проценты — платят трейдеры и заёмщики. Реальный доход, ограниченный оборотом и утилизацией.
- Награды за консенсус — платит протокол цепи валидаторам, а через ликвидный стейкинг — держателям LST (см. консенсус).
- MEV — платят другие пользователи ухудшением исполнения. Доход реальный, происхождение стоит понимать.
- Эмиссия токена протокола — не доход, а разводнение: вам платят долей в будущем, печатая её сейчас. Проверка простая: останется ли ставка привлекательной без эмиссии.
- Премия за риск — вы продали кому-то страховку (страховой модуль, транш с первым убытком) и получаете деньги, пока событие не наступило.
Отдельно про арифметику: APR — простая годовая ставка, APY — с учётом реинвестирования, $APY = (1 + r/n)^n - 1$. Витрины любят показывать APY при ежеблочном начислении, что при 100 % APR даёт эффектные 171 % — чтобы их получить, нужно реально реинвестировать, платя газ. Стандартная упаковка для «доходной» стратегии — хранилище ERC-4626 с его известной атакой на первого вкладчика; там же разобрано, почему округление в таких контрактах всегда делают в пользу протокола.
9. Композиция: главная сила и главный системный риск
Уникальность DeFi не в отдельных примитивах — банки умеют то же самое лучше. Уникальность в том, что чужой протокол вызывается как библиотека, без договора и разрешения, а вся цепочка исполняется атомарно.
в протоколе кредитования"] C --> D["Забрать залог в ETH с премией 5 %"] D --> E["Продать ETH в AMM
минимум задан minAmountOut"] E --> F{"Хватает на возврат
займа и премии?"} F -->|Да| G["Вернуть заём, прибыль остаётся"] F -->|Нет| H["revert: состояние откатилось,
потрачен только газ"] G --> I["Итог: капитал не требовался,
риск сведён к цене газа"] style H stroke-dasharray: 5 5
Цена этой силы — четыре класса системных рисков, о которых лучше думать заранее.
- Заимствованные инварианты. Ваш протокол корректен, пока корректен чужой. Обновление чужого контракта (или его параметров через голосование) может сломать ваш инвариант без единой строчки изменений у вас.
- Каскадные ликвидации. Падение цены вызывает ликвидации, продажи залога давят цену дальше, что вызывает следующие ликвидации. Та же положительная обратная связь, что в алгоритмическом стейблкоине, только быстрее.
- Рефлексивные оракулы. Если актив A принимается как залог для выпуска актива B, а цена A берётся из пула, ликвидность которого создана на B, — контур замкнулся, и «стоимость» держится сама на себе.
- Общий одиночный отказ. Один популярный оракул, один мост, одна обёртка стейблкоина, один компилятор. Взлом пулов Curve в июле 2023 года был спровоцирован ошибкой в конкретных версиях компилятора Vyper (нерабочая защита от повторного входа), и накрыл сразу несколько независимых протоколов — postmortem.
Отдельная и очень практичная разновидность — read-only reentrancy: чужой контракт читает вашу функцию просмотра в момент, когда ваше состояние временно несогласованно. Механика разобрана в безопасности контрактов; в контексте композиции важно другое — ваши view-функции такой же публичный API, как и обычные, и их согласованность — ваша обязанность.
10. Как интегрировать DeFi-протокол и не потерять деньги
Практический слой, который отделяет «работает на форке» от «работает в мейннете».
/// @notice Обмен через маршрутизатор с честной защитой от проскальзывания.
function swapExactIn(
address tokenIn, address tokenOut, uint256 amountIn,
uint256 minAmountOut, // считается офчейн из котировки и допуска пользователя
uint256 deadline // подпись/намерение протухает
) external returns (uint256 received) {
require(block.timestamp <= deadline, "expired");
require(minAmountOut > 0, "no slippage protection"); // ноль здесь — дверь для сэндвича
IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn);
IERC20(tokenIn).forceApprove(router, amountIn); // ноль-затем-значение для странных токенов
uint256 before = IERC20(tokenOut).balanceOf(address(this));
IRouter(router).swap(tokenIn, tokenOut, amountIn, minAmountOut, address(this));
received = IERC20(tokenOut).balanceOf(address(this)) - before; // факт, а не возвращённое значение
require(received >= minAmountOut, "slippage"); // повторная проверка у себя
IERC20(tokenOut).safeTransfer(msg.sender, received);
}
Чек-лист интеграции:
minAmountOutиdeadlineобязательны. Своп без ограничения снизу — это приглашение к сэндвич-атаке; без дедлайна транзакция может исполниться через сутки по чужой цене.- Проверяйте фактическое изменение баланса, а не возвращаемое значение. Токены с комиссией на перевод, ребейзящие токены и нестандартные реализации — норма жизни.
- Никогда не берите цену из спота пула для решений о залоге и ликвидации. Оракул с проверкой возраста ответа или TWAP.
- Считайте decimals явно. USDC — 6 знаков, WBTC — 8, ETH — 18. Половина ошибок в расчёте позиций живёт здесь.
- Округляйте в пользу протокола. Правило: выдаём — вниз, начисляем долг — вверх. Иначе получается бесплатная эмиссия по копейке за вызов.
- Не давайте бесконечный approve собственным контрактом. Разрешение живёт дольше вашей уверенности в чужом коде.
- Фиксируйте инварианты в тестах и фаззьте их («после любой последовательности операций протокол не должен потерять активы») и прогоняйте сценарии на форке мейннета с реальной ликвидностью, включая «цена ушла на 30 % за блок» — механика invariant-тестов Foundry разобрана в статье о контрактах.
- Читайте параметры риска чужого протокола как код: потолки, пороги, кто их меняет и с какой задержкой.
- Заложите деградацию: что делает ваше приложение, когда оракул устарел, ликвидность иссякла, а стейблкоин отклонился на 5 %. Ответ «ничего не делает и не даёт открывать новые позиции» — совершенно нормальный.
11. Частые заблуждения
- «Пул — это биржа со стаканом». Нет: это формула. Цена в нём вторична и всегда отстаёт от внешнего рынка ровно настолько, насколько арбитражёру невыгодно её выравнивать.
- «Непостоянные потери временные». Они исчезают только при возврате цены; позиция, закрытая после тренда, фиксирует их окончательно.
- «Флеш-займ — это уязвимость». Это конструкция. Уязвимость — использование мгновенных величин как источника истины. Кредита без залога в цепи нет и не может быть без внешней идентичности заёмщика.
- «Стейблкоин стабилен». Стабильно только обещание, и у каждого обещания есть автор с рисками.
- «APY 400 % — это доход». Чаще всего это эмиссия, то есть ваша будущая доля, выданная вам же сегодня.
- «Аудит был — значит безопасно». Композиция создаёт риски, которых не было ни в одном из аудированных контрактов по отдельности.
Мини-итог
- AMM заменяет книгу заявок формулой, потому что в EVM хранение и отмена заявок стоят газа, а мемпул публичен. Цена в пуле — отношение резервов, и ничего больше.
- Торгуя долю $\alpha$ резерва, вы теряете примерно $\alpha$ на проскальзывании и двигаете цену на $2\alpha$. Это единственная формула, которую нужно помнить при оценке исполнения.
- Поставщик ликвидности продаёт волатильность: его отставание от простого удержания равно $2\sqrt{r}/(1+r) - 1$ и покрывается только оборотом.
- Кредитование в цепи — ломбард: избыточный залог, ставка от утилизации, ленивая индексная бухгалтерия, ликвидация посторонним ботом за премию. Health factor ниже единицы — это не предупреждение, а открытый аукцион на вашу позицию.
- Флеш-займ возможен из-за атомарности транзакции и стоит ровно столько, сколько газ. Значит, мгновенные величины (спот, баланс, вес голоса) нельзя использовать как факты.
- Стейблкоин — это чьё-то обещание: эмитента, залоговой механики или рынка. Третий вариант исторически не выдержал ни одной паники.
- Композиция даёт бесплатную интеграцию и бесплатное же распространение чужих аварий. Инварианты, которые вы не проверяете у себя, вам никто не гарантирует.
Источники
- Uniswap V2 Core / V3 / V4 — whitepapers и код: docs.uniswap.org, github.com/Uniswap
- Curve StableSwap whitepaper: classic.curve.fi/files/stableswap-paper.pdf
- Automated Market Making and Loss-Versus-Rebalancing, Milionis et al.: arxiv.org/abs/2208.06046
- Aave V3 Technical Paper — индексы, eMode, изоляция, потолки: github.com/aave/aave-v3-core
- Compound Protocol — модель ставки и cToken: docs.compound.finance
- ERC-3156 Flash Loans: eips.ethereum.org/EIPS/eip-3156
- MakerDAO / Sky — документация и разборы «чёрного четверга»: docs.makerdao.com, forum.makerdao.com
- Vyper reentrancy postmortem (Curve, 2023): hackmd.io/@vyperlang/HJUgNMhs2
- Chainlink Data Feeds и Data Streams: docs.chain.link
- DeFiLlama — TVL, состав протоколов, история взломов: defillama.com
- Rekt News — разборы инцидентов, включая Beanstalk и Mango: rekt.news
Что дальше
Ончейн-управление: DAO, голоса, таймлоки и захват протокола — разбираем последний слой, который в этой статье всплывал постоянно: кто меняет пороги ликвидации, кто может обновить контракт, почему вес голоса берут из прошлого блока и как одна транзакция с флеш-займом уносит казну протокола, не взломав ни одной строчки кода.