ERC4626: доли хранилища вместо баланса
- Зачем это нужно
- Хранилище держит актив и выпускает доли
- Доход не раздают — от него растёт цена доли
- Формула работает в обе стороны
- Шестнадцать функций и четыре семейства
- Округление всегда в пользу хранилища
- totalAssets по балансу — открытая дверь
- Атака на первого вкладчика
- Как это лечат
- Типичные ошибки
- Слова главы
- Тренажёр: набрать вслепую
- На чемпионате
- Что дальше
- Углубиться
- Куда дальше: проверенные ресурсы
Зачем это нужно
Конкурсное задание говорит прямо: «Все токены системы, кроме shares token, соответствуют стандарту ERC20. Shares token соответствует стандарту ERC4626».
То есть хранилище из модуля А — не произвольная конструкция, а реализация конкретного стандарта, с заданными именами функций и правилами округления. Оттуда же и формулы задания: sharesToMint = depositAmount / sharePrice, «цена одной доли = 100 % от цены базового токена», «70 % от накопленной комиссии — награда кредиторам».
В этой главе — как устроены доли, почему прибыль зависит от момента входа и где в самой простой реализации прячется дыра, через которую первый вкладчик теряет всё.
Хранилище держит актив и выпускает доли
У хранилища есть базовый актив (asset) — в задании это депозитный токен — и собственный токен долей (shares).
Внёс активы — получил доли. Сжёг доли — забрал активы. Доли сами по себе тоже токен по стандарту ERC20: их можно переводить и хранить в кошельке.
Разница между балансом и долей принципиальная: баланс — это «сколько у меня токенов», доля — «какая часть хранилища мне принадлежит».
Доход не раздают — от него растёт цена доли
Ключевая идея стандарта: число долей у вкладчика не меняется, меняется их цена.
Когда заёмщики вернули проценты и комиссия пришла в хранилище, общий объём активов вырос, а долей осталось столько же. Значит, каждая доля стала стоить дороже — и вкладчик заберёт больше, чем внёс, ничего не делая.
цена доли = totalAssets / totalSupply
Первая модель проводит через это трёх вкладчиков. Каждый вносит по 1000, между вкладами хранилищу трижды начисляется по 300 дохода:
anna вносит 1000.000000 USDC
цена доли до вклада: 1.000000 (хранилище пусто, первый получает один к одному)
получено долей: 1000.000000
начислен доход: +300.000000 USDC
цена доли после: 1.300000
boris вносит 1000.000000 USDC
цена доли до вклада: 1.300000
получено долей: 769.230769
начислен доход: +300.000000 USDC
цена доли после: 1.469565
vera вносит 1000.000000 USDC
цена доли до вклада: 1.469565
получено долей: 680.473372
цена доли после: 1.592028
── Все выводят свои доли ──
в хранилище 3900.000000 USDC на 2449.704141 долей
anna доли 1000.000000 -> 1592.028986 USDC прибыль +592.028986
boris доли 769.230769 -> 1224.637681 USDC прибыль +224.637681
vera доли 680.473372 -> 1083.333333 USDC прибыль +83.333333
осталось в хранилище: 0.000000 USDC, долей 0.000000
Три вывода.
Вклад одинаковый, прибыль разная. Anna заработала 592, vera — 83. Не потому, что кто-то умнее: просто anna вошла, когда доля стоила единицу, а vera — когда почти полтора.
Долей у каждого разное количество. За те же 1000 anna получила 1000 долей, а vera — 680. Доля дорожает, значит за те же деньги их дают меньше.
Сумма сходится в ноль. После вывода всех троих в хранилище не осталось ни активов, ни долей. Это хорошая проверка реализации: если что-то остаётся — где-то ошибка в округлении.
Формула работает в обе стороны
function convertToShares(uint256 assets) public view returns (uint256) {
uint256 supply = totalSupply();
return supply == 0 ? assets : assets * supply / totalAssets();
}
function convertToAssets(uint256 shares) public view returns (uint256) {
uint256 supply = totalSupply();
return supply == 0 ? shares : shares * totalAssets() / supply;
}
Шестнадцать функций и четыре семейства
Стандарт требует шестнадцать функций. Запоминать их по списку не нужно — они складываются в четыре семейства по два действия:
| задаём активы | задаём доли | |
|---|---|---|
| вносим | deposit | mint |
| выводим | withdraw | redeem |
У каждой из четырёх есть спутники: preview… — «сколько получится, если сделать сейчас» — и max… — «сколько вообще можно». Плюс asset, totalAssets, convertToShares, convertToAssets.
Разница внутри пары — в том, какое число вы фиксируете. deposit(1000) означает «вношу тысячу, сколько дадите долей»; mint(769) — «хочу ровно столько долей, спишите сколько нужно».
Округление всегда в пользу хранилища
Целочисленное деление отбрасывает дробь, и стандарт требует, чтобы отбрасывалась она всегда в сторону хранилища. Третья модель показывает это на одних и тех же числах:
── Хранилище: 1300.000000 USDC на 1000.000000 долей, цена доли 1.300000 ──
deposit(1000.000000 USDC) -> долей 769.230769 (округление ВНИЗ)
mint(769.230769 долей) -> активов 1000.000000 (округление ВВЕРХ)
withdraw(1000.000000 USDC) -> сожжёт долей 769.230770 (округление ВВЕРХ)
redeem(769.230769 долей) -> активов 999.999999 (округление ВНИЗ)
цена 769.230769 долей: вверх 1000.000000, вниз 999.999999 — разница 1,
и она достаётся хранилищу
deposit -> redeem без паузы: 1000.000000 -> 999.999999 USDC, минус 1
── Вклад меньше цены доли ──
previewDeposit(1 единица) = 0 долей
после deposit(1): у трейдера долей 0, потеряно 1
── 200 круговых рейсов ──
потеряно трейдером: 200 единиц = 0.000200 USDC
осело в хранилище: 200 единиц
── Что стало с ценой доли, хотя дохода не начислялось ──
цена доли: 1.3000002010 вместо 1.3000000000
Правило простое: что бы вы ни делали, единица уходит хранилищу. Это не жадность, а защита: если бы округление шло в другую сторону, круговым рейсом можно было бы медленно выкачивать хранилище.
Два следствия, которые видно только в прогоне:
Вклад меньше цены доли пропадает. Внесли одну единицу при цене доли 1.3 — получили ноль долей и потеряли единицу. Отсюда требование задания про минимальный депозит: оно защищает не хранилище, а вкладчика.
Пыль накапливается. Двести круговых рейсов оставили в хранилище двести единиц, и цена доли выросла без всякого дохода. На больших числах это незаметно, но при аудите объясняет, почему «активов чуть больше, чем должно».
totalAssets по балансу — открытая дверь
Самая простая реализация «сколько всего активов» возвращает баланс контракта. Это удобно: доход учитывается автоматически, как только деньги пришли.
И это же означает, что любой может изменить цену доли обычным переводом токенов на адрес контракта — не вызывая ни одной функции хранилища. Мы уже видели похожее в главе про интерфейсы: перевод напрямую ломает учёт. Здесь он ломает не учёт, а математику.
Атака на первого вкладчика
Вторая модель проводит атаку целиком:
── Хранилище «в лоб»: NaiveVault ──
шаг 1. злоумышленник внёс 1 единицу, долей у него: 1
шаг 2. перевёл на контракт напрямую 1000 USDC мимо deposit
totalAssets = 1000.000001, totalSupply = 1 долей
цена одной доли: 1000.000001 USDC
шаг 3. жертва вносит 1000 USDC, convertToShares говорит: 0 долей
долей у жертвы: 0
шаг 4. злоумышленник сжёг свою долю
итог злоумышленника: 1000.000000 USDC
итог жертвы: -1000.000000 USDC
Почему жертва получила ноль: её тысяча делится на цену доли, которая теперь равна тысяче с копейками. Целочисленное деление даёт ноль — и округление, честно работающее в пользу хранилища, здесь работает против человека.
Дальше злоумышленник сжигает единственную долю и забирает всё, что в хранилище, — включая чужую тысячу.
Это не выдумка для учебника: такие атаки происходили в реальных протоколах и стоили миллионы. Уязвимость живёт ровно в одном месте — в ветке «хранилище пусто, выдаём один к одному».
Как это лечат
Та же модель прогоняет два способа защиты.
Виртуальные доли. Реализация из библиотеки считает так, будто в хранилище всегда есть один виртуальный актив и некоторое количество виртуальных долей. Делить всегда есть на что, и «пустое хранилище» перестаёт быть особым случаем:
── То же хранилище на OpenZeppelin ERC4626, _decimalsOffset() = 6 ──
шаг 1. злоумышленник внёс 1 единицу, долей у него: 1000000
шаг 3. жертва вносит 1000 USDC, previewDeposit обещает: 1999999 долей
шаг 4. оба вывели всё
итог злоумышленника: -499.999876 USDC
итог жертвы: -0.000249 USDC
Жертва потеряла четверть тысячной доли цента вместо тысячи, а злоумышленник потерял пятьсот — атака стала невыгодной.
Минимальный депозит. Способ без библиотеки, и он же прописан в задании:
── Защита без библиотеки: минимальный депозит 10 USDC ──
первый вклад 10.000000 USDC + донат 1000.000000 USDC
жертва за 1000 USDC получит долей: 9900990
чтобы обнулить тот же вклад, доната нужно ещё: 9999998990.000001 USDC
Требование «минимальный депозит для кредитора — 10 USDC» из задания смотрится формальностью. На деле оно поднимает стоимость атаки с одной тысячи до десяти миллиардов — это и есть защита.
Типичные ошибки
Деление раньше умножения. assets / totalAssets() * supply даёт ноль на любой дробной цене доли.
Первый вкладчик без защиты. Ветка «хранилище пусто» — та самая дыра.
Минимальный депозит не проверяется. Требование задания и одновременно защита.
Округление в сторону пользователя. Круговым рейсом хранилище выкачивается.
Свои имена функций вместо стандартных. Стандарт требует именно deposit, mint, withdraw, redeem и остальные — иначе это не ERC4626, а просто контракт.
preview… не совпадает с реальным результатом. Функция предпросмотра обязана возвращать ровно то, что произойдёт.
Проверка «хватит ли активов» по своим записям, а не по хранилищу. В задании про это сказано отдельно: вывод возможен, если в хранилище есть свободная ликвидность.
Наследник без аргументов базовых конструкторов. Стандартная ошибка наследования: у хранилища их два — токен долей и базовый актив.
Учёт долей за заём и залог перепутан с долями хранилища. В задании это три разных счётчика.
Слова главы
- Базовый актив (asset) — токен, который принимает хранилище.
- Доли (shares) — токен хранилища; сам по себе тоже ERC20.
totalAssets— сколько активов под управлением; цена доли — активы, делённые на доли.convertToShares/convertToAssets— пересчёт в обе стороны.deposit/mint— внести, задав активы или задав доли.withdraw/redeem— вывести, задав активы или задав доли.preview…/max…— что получится и сколько вообще можно.- Округление вниз/вверх — всегда в пользу хранилища.
- Пыль (dust) — единицы, осевшие в хранилище от округлений.
- Атака на первого вкладчика — вклад в одну единицу плюс прямой перевод.
- Виртуальные доли — защита: хранилище считает, будто в нём всегда есть немного активов и долей.
Тренажёр: набрать вслепую
Цель: скорость ≥ 100 зн/мин, точность ≥ 88%
return supply == 0 ? assets : assets * supply / totalAssets();
На чемпионате
Модуль А, 37 баллов. Хранилище и его токен долей обязаны соответствовать стандарту буквально, по именам функций — это прямая формулировка задания. Практические выводы:
- имена
deposit,withdraw,redeem,convertToSharesне переименовывать; - минимальный депозит из задания проверять — это и требование, и защита;
- цена доли считается по формуле, а не хранится отдельной переменной: иначе она разъедется с реальностью после первого же начисления;
- при выводе проверять свободную ликвидность, а не только количество долей.
Демонстрация проходит по шагам с точными суммами: внести 1000, потом ещё 5, потом забрать 50. Каждый шаг «показать изменения всех затронутых балансов» — значит, эксперт увидит и доли, и активы, и цену доли. Если округление сделано в свою сторону, расхождение с ключом появится уже на третьем шаге.
Модуль В, 14 баллов. Готовые пункты: незащищённый первый вкладчик (информационная безопасность), округление в пользу пользователя (логика), цена доли в переменной вместо расчёта (логика), несовпадение предпросмотра с результатом (функционал).
Что дальше
Хранилище — половина протокола из задания. Вторая половина — маркеты, где доли выпускают за заём и за залог. Но прежде чем строить их, нужен ответ на вопрос из демонстрации: «провести обновление логики контракта… показать изменение параметров и отсутствие изменений в адресе контракта». Это следующая глава — прокси и обновляемые контракты.
Углубиться
- Текст стандарта — полный список функций с правилами округления для каждой.
- Виртуальные сдвиги — математика защиты и как выбирают величину сдвига.
- Хранилища с задержкой вывода — зачем нужна и как считается очередь.
- Комиссии хранилища — где их берут: при входе, при выходе или с дохода.
- Доли за заём и залог — как та же идея работает в маркетах задания.
Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- EIP-4626: стандарт хранилищ — все шестнадцать функций и правила округления.
- ERC4626 в OpenZeppelin — разбор атаки на первого вкладчика и математика виртуальных долей.
- Готовая реализация — какие функции можно переопределять.
- EIP-20 — доли остаются обычным токеном, и весь прошлый стандарт для них в силе.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.