Перейти к основному содержимому
Блокчейн16ГЛАВА 16ERC4626: доли хранилища вместо баланса1.00 → 1.30 → 1.47цена доли

ERC4626: доли хранилища вместо баланса

Прочитано0%
Квизы0/5
Тренажёрыне пройден0/1
Уровень 1 · Новичок

Зачем это нужно​

Конкурсное задание говорит прямо: «Все токены системы, кроме 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 внесла 1000 USDCцена доли: 1.000000долей: 1000.000000заберёт: 1592.028986прибыль +592 USDCboris внесла 1000 USDCцена доли: 1.300000долей: 769.230769заберёт: 1224.637681прибыль +225 USDCvera внесла 1000 USDCцена доли: 1.469565долей: 680.473372заберёт: 1083.333333прибыль +83 USDCчисло долей у вкладчика не меняется — меняется их цена: 1.000000 → 1.300000 → 1.469565 → 1.592028доход не раздают: он поднимает общий объём активов, а долей остаётся столько жепосле вывода всех троих в хранилище остаётся ровно 0 — сумма долей сходитсяпервый вкладчик получает доли один к одному: делить ещё не на что
Вклад у всех одинаковый, прибыль разная: она зависит только от момента входа

Три вывода.

Вклад одинаковый, прибыль разная. 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;
}
Нажми на часть выражения

Шестнадцать функций и четыре семейства​

Стандарт требует шестнадцать функций. Запоминать их по списку не нужно — они складываются в четыре семейства по два действия:

задаём активызадаём доли
вносимdepositmint
выводимwithdrawredeem

У каждой из четырёх есть спутники: 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
ОКРУГЛЕНИЕ ВСЕГДА В ПОЛЬЗУ ХРАНИЛИЩАdeposit(1000.000000 USDC)769.230769 долейокругление внизmint(769.230769 долей)1000.000000 USDCокругление вверхwithdraw(1000.000000 USDC)сожжёт 769.230770 долейокругление вверхredeem(769.230769 долей)999.999999 USDCокругление внизкруговой рейс без паузы: внесли 1000.000000, забрали 999.999999 — минус одна единица200 таких рейсов оставили в хранилище 200 единиц: цена доли выросла без всякого дохода
Внесли 1000.000000 — забрали 999.999999: круговой рейс никогда не бесплатен

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

Два следствия, которые видно только в прогоне:

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

Пыль накапливается. Двести круговых рейсов оставили в хранилище двести единиц, и цена доли выросла без всякого дохода. На больших числах это незаметно, но при аудите объясняет, почему «активов чуть больше, чем должно».

Отвечено вопросов: 0 из 2
Трое внесли по 1000, прибыль вышла 592, 225 и 83. Почему разная?
Внесли 1000.000000 и сразу вывели. Сколько вернётся?
Отвечено верно: 0 из 2

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
АТАКА НА ПЕРВОГО ВКЛАДЧИКА: 1 ЕДИНИЦА И ОДИН ПЕРЕВОД1. злоумышленник вносит 1 единицу в пустое хранилищеу него 1 доля2. переводит 1000 USDC прямо на адрес контракта, мимо вкладацена доли: 1000.0000013. жертва вносит 1000 USDCполучает 0 долей4. злоумышленник сжигает свою единственную долюзабирает 1000.000000 USDCс виртуальными долями жертва получает 1 999 999 долей и теряет 0.000249 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» из задания смотрится формальностью. На деле оно поднимает стоимость атаки с одной тысячи до десяти миллиардов — это и есть защита.

Отвечено вопросов: 0 из 2
Злоумышленник внёс 1 единицу и перевёл на контракт 1000 напрямую. Что получит следующий вкладчик за свою тысячу?
Зачем в задании минимальный депозит 10 USDC?
Отвечено верно: 0 из 2

Типичные ошибки​

Деление раньше умножения. 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 баллов. Готовые пункты: незащищённый первый вкладчик (информационная безопасность), округление в пользу пользователя (логика), цена доли в переменной вместо расчёта (логика), несовпадение предпросмотра с результатом (функционал).

Отвечено вопросов: 0 из 2
Почему цену доли не хранят отдельной переменной?
Демонстрация идёт по шагам с точными суммами. Где всплывёт неверное округление?
Отвечено верно: 0 из 2

Что дальше​

Хранилище — половина протокола из задания. Вторая половина — маркеты, где доли выпускают за заём и за залог. Но прежде чем строить их, нужен ответ на вопрос из демонстрации: «провести обновление логики контракта… показать изменение параметров и отсутствие изменений в адресе контракта». Это следующая глава — прокси и обновляемые контракты.

Отвечено вопросов: 0 из 1
Чем deposit отличается от mint?
Отвечено верно: 0 из 1

Углубиться​

  • Текст стандарта — полный список функций с правилами округления для каждой.
  • Виртуальные сдвиги — математика защиты и как выбирают величину сдвига.
  • Хранилища с задержкой вывода — зачем нужна и как считается очередь.
  • Комиссии хранилища — где их берут: при входе, при выходе или с дохода.
  • Доли за заём и залог — как та же идея работает в маркетах задания.

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

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоSolidity и смарт-контракты Ethereum, урок #49 | ERC4626: Tokenized vaults (храниIlya Krukowski
ВидеоSolidity и смарт-контракты Ethereum, урок #48 | ERC2612, ERC20Permit, аппрув безIlya Krukowski
ВидеоSolidity и смарт-контракты Ethereum, урок #12 | Интерфейсы, библиотеки, тестыIlya Krukowski
ВидеоVault Math - How much shares to mint? How much token to withdraw? | DeFiSmart Contract Programmer
ВидеоDeFi Primitives in Solidity: ERC4626 (Part1)Layers

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.