ERC-20: выпуск, сжигание и предел предложения
Первый LibraryCredit имел фиксированный начальный выпуск. Если редакция хочет создавать дополнительные учебные единицы, необходимо определить полномочие mint и предел. Отдельно решается сжигание: кто уменьшает баланс и как это влияет на будущую возможность выпуска.
Введём самостоятельный LimitedLibraryCredit. Его политика ограничивает суммарный выпуск за всё время двумя тысячами отображаемых единиц. Это lifetime-cap, а не только предел текущего totalSupply. Сжигание уменьшает текущее предложение, но не возвращает использованную квоту выпуска.
Две величины количества
TotalSupply сообщает текущие существующие базовые единицы. LifetimeMinted сообщает, сколько единиц когда-либо создано нашей моделью. После burn эти величины расходятся. Если смешать их, можно случайно разрешить повторное создание сверх обещанной суммарной квоты.
Начальный mint: totalSupply=1000 LCR, lifetimeMinted=1000 LCR
Дополнительный mint 300: оба значения 1300 LCR
Burn 200: totalSupply=1100 LCR, lifetimeMinted=1300 LCR
Остаток lifetime-квоты: 700 LCR
Это расчёт политики, не измерение. Количества на цепочке выражаются в базовых единицах с decimals=6. Отдельный обычный cap по текущему предложению допустил бы иной остаток после burn. Оба договора возможны, но нельзя назвать их одинаковыми.
Полный отдельный токен
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract LimitedLibraryCredit is ERC20, Ownable {
error LifetimeCapExceeded();
error InvalidAmount();
uint256 public constant LIFETIME_CAP = 2000 * 10 ** 6;
uint256 public lifetimeMinted;
constructor(address initialOwner, address initialHolder)
ERC20("Limited Library Credit", "LLCR") Ownable(initialOwner)
{
lifetimeMinted = 1000 * 10 ** 6;
_mint(initialHolder, lifetimeMinted);
}
function decimals() public pure override returns (uint8) { return 6; }
function mint(address to, uint256 amount) external onlyOwner {
if (amount == 0) revert InvalidAmount();
uint256 next = lifetimeMinted + amount;
if (next > LIFETIME_CAP) revert LifetimeCapExceeded();
lifetimeMinted = next;
_mint(to, amount);
}
function burn(uint256 amount) external {
if (amount == 0) revert InvalidAmount();
_burn(msg.sender, amount);
}
}
Mint ограничен владельцем. Burn разрешён держателю для собственного баланса. Здесь нет административного метода произвольного сжигания у другого адреса. Также нет burnFrom: allowance само не создаёт такой внешний метод, если модель его не предоставила.
Нулевой получатель mint отклоняется библиотекой. Преждевременно обновлённый lifetimeMinted в откатившемся действии не закрепляется. Проверяемая арифметика языка сохраняется. Мы не отключаем её ради недоказанной экономии газа.
Предел при точном равенстве
Если next равен LIFETIME_CAP, mint допустим по нашему сравнению. Если превышает хотя бы на одну базовую единицу, ожидается LifetimeCapExceeded. Такой граничный случай полезно сформулировать отдельно, поскольку отображаемая сумма может округляться в карточке сайта.
После достижения квоты burn не позволяет новый mint. Поле lifetimeMinted осталось на пределе. Это сознательное требование «всего создано не больше двух тысяч», а не ошибка забывшегося счётчика. В интерфейсе нужно показывать остаток именно этой квоты, а не разность cap-totalSupply.
TotalSupply по-прежнему уменьшается при собственном burn. Поэтому текущие существующие единицы могут быть меньше максимума. Обещание cap не означает, что все две тысячи обязательно будут выпущены или всегда будут существовать.
Управление правом выпуска
OnlyOwner использует передаваемую модель Ownable этой отдельной программы. Передача меняет субъект будущего mint, но не сбрасывает lifetimeMinted. Если новый владелец получил уже использованную квоту, он не начинает с двух тысяч снова.
Отказ от владения делает новые onlyOwner-mint недоступными обычным маршрутом. Держатели сохраняют transfer и собственный burn, поскольку их методы не зависят от owner. Такая комбинация показывает, почему нужно читать каждое действие, а не говорить «токен остановился» при отсутствии владельца.
Если административный ключ скомпрометирован, злоупотребление mint ограничено нашей квотой, но всё равно соответствует опасному прямому полномочию. Cap не является доказательством правильного распределения новых единиц. Субъект может направить весь остаток одному адресу, если это разрешено кодом.
Журнал и количество
Стандартные события Transfer для mint и burn используют предусмотренные нулевые направления. Это журнал изменения количества, а не перевод от нулевого участника, который подписал транзакцию. Не нужно создавать ложного «читателя zero» в серверной базе.
Индексатор может показывать историю выпусков и сжиганий. Для текущего предложения читается totalSupply. Для lifetime-политики читается наше отдельное поле. Перечисление только текущих балансов не объяснит, сколько единиц было создано и уничтожено раньше.
Для нашей модели нет комиссий и callback-переводов. При добавлении расширения, которое переопределяет общий _update, нужно пересмотреть все mint, burn и transfer. В 5.4.0 именно этот механизм относится к общей настройке движений, а старые hooks из других версий нельзя переносить без сверки.
Смена требования
Представим, что редакция хочет разрешить повторный выпуск после burn, пока текущее предложение не выше cap. Это другой договор. Тогда счётчик lifetime может остаться только исторической статистикой, а ограничение проверяется относительно текущего totalSupply. Изменение следует оформить новым типом и явно сообщить пользователям.
Простой перенос старых данных в такую модель тоже требует решения: считается ли прежний суммарный выпуск, какой источник начального состояния и какие обязательства сохраняются. Название токена LLCR не определяет ответы. Они находятся в процедуре миграции и интерфейсе нового контракта.
Дополнительный расчёт
После исходной тысячи выпустите мысленно 800, затем сожгите 500 и попытайтесь выпустить 300. TotalSupply перед последней попыткой равен 1300 отображаемым единицам, но lifetimeMinted уже 1800. Следовательно, последняя попытка превышает квоту и отклоняется. Попытка 200 достигнет точного предела.
Этот пример отделяет две политики численно и не требует запуска. Все количества переводятся в базовый масштаб до сравнения. На следующем этапе перейдём к индивидуальному абонементу ERC-721, где идентификатор и количество решают другую задачу. ERC20 5.4.0 описывает mint, burn и общий механизм движения.
Два предела в разных требованиях
Пусть другая библиотека хочет ограничить именно количество одновременно существующих кредитов. Для неё сравнение totalSupply()+amount с cap может быть подходящим договором: сжигание освобождает место. Но это уже другое требование. Нельзя заменить lifetimeMinted таким выражением и назвать изменение обычной оптимизацией.
Наш предел исторический: каждая успешная дополнительная эмиссия потребляет невозобновляемую квоту. Поэтому отчёт должен показывать оба числа рядом. После большого сжигания маленький current supply может создать ложное впечатление большого свободного выпуска, если lifetime спрятан.
Также начальный выпуск входит в предел. Изменение конструктора с тысячи до трёх тысяч без изменения cap нарушило бы заявленный договор ещё до внешнего mint. Текущая фиксированная величина допустима, а обобщённый конструктор потребовал бы собственной проверки initialAmount<=cap. Рассуждение о будущем параметре не означает, что такой параметр уже существует в показанном исходнике.
Владелец получателя может потерять контроль над адресом, но это не делает токены автоматически сожжёнными. totalSupply отражает учёт контракта, а не предположение о доступности ключей. Предметная библиотека не может восстанавливать квоту на основании неподтверждённой истории о потере кошелька.
Для самостоятельного уточнения: ERC20.sol (5.4.0), Ownable.sol (5.4.0).
Оглавление курса. Листинги, расчёты и наблюдения здесь разобраны как ожидаемое поведение; компиляция, учебные программы и проверки при написании не запускались.