Кастомные ошибки — Custom Errors
Как объявлять custom errors в Solidity, вызывать revert с параметрами и заменять строковые require на типизированные ошибки.
Custom Error описывает причину отката
Custom Error — это типизированная ошибка в Solidity. Она показывает, почему контракт откатил транзакцию, и может передавать параметры: адрес, лимит, доступный баланс или требуемую сумму.
Custom Errors обычно дешевле строковых require(..., "message"), потому что контракту не нужно хранить длинный текст ошибки в bytecode.
Пишем продажу токенов с ошибками
Создайте файл contracts/TokenSale.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract TokenSale {
address public immutable owner;
uint256 public immutable pricePerToken;
uint256 public immutable maxTokensPerWallet;
bool public saleOpen = true;
mapping(address => uint256) public purchasedBy;
event TokensPurchased(address indexed buyer, uint256 tokenAmount, uint256 paid);
event SaleClosed(address indexed owner);
error OnlyOwner(address caller);
error SaleIsClosed();
error ZeroAmount();
error NotEnoughEth(uint256 required, uint256 received);
error ExceedsWalletLimit(uint256 requestedTotal, uint256 maxAllowed);
error EthTransferFailed();
constructor(uint256 initialPricePerToken, uint256 initialMaxTokensPerWallet) {
if (initialPricePerToken == 0 || initialMaxTokensPerWallet == 0) {
revert ZeroAmount();
}
owner = msg.sender;
pricePerToken = initialPricePerToken;
maxTokensPerWallet = initialMaxTokensPerWallet;
}
function buy(uint256 tokenAmount) external payable {
if (!saleOpen) {
revert SaleIsClosed();
}
if (tokenAmount == 0) {
revert ZeroAmount();
}
uint256 newPurchasedTotal = purchasedBy[msg.sender] + tokenAmount;
if (newPurchasedTotal > maxTokensPerWallet) {
revert ExceedsWalletLimit(newPurchasedTotal, maxTokensPerWallet);
}
uint256 requiredPayment = tokenAmount * pricePerToken;
if (msg.value < requiredPayment) {
revert NotEnoughEth(requiredPayment, msg.value);
}
purchasedBy[msg.sender] = newPurchasedTotal;
uint256 refund = msg.value - requiredPayment;
if (refund > 0) {
(bool success, ) = msg.sender.call{value: refund}("");
if (!success) {
revert EthTransferFailed();
}
}
emit TokensPurchased(msg.sender, tokenAmount, requiredPayment);
}
function closeSale() external {
if (msg.sender != owner) {
revert OnlyOwner(msg.sender);
}
saleOpen = false;
emit SaleClosed(msg.sender);
}
function withdraw(address payable to) external {
if (msg.sender != owner) {
revert OnlyOwner(msg.sender);
}
uint256 amount = address(this).balance;
(bool success, ) = to.call{value: amount}("");
if (!success) {
revert EthTransferFailed();
}
}
}Этот контракт продаёт условные токены по фиксированной цене, ограничивает покупку на один адрес и возвращает лишний ETH отправителю.
Как это работает
error NotEnoughEth(uint256 required, uint256 received) — объявляет ошибку с двумя параметрами. В откате будет видно, сколько ETH требовалось и сколько пришло.
revert NotEnoughEth(requiredPayment, msg.value) — останавливает выполнение и возвращает custom error вызывающей стороне.
error OnlyOwner(address caller) — передаёт адрес, который попытался вызвать owner-функцию. Это удобнее, чем строка "not owner".
error SaleIsClosed() — ошибка без параметров. Такой вариант подходит, когда дополнительных данных не нужно.
if (...) { revert ...; } — основной стиль для custom errors. require со строкой остаётся валидным Solidity, но для новых контрактов чаще используют if + revert.
EthTransferFailed() — отдельная ошибка для неудачного ETH-перевода. Она точнее, чем общий require(success).
Custom Error vs require со строкой
Строковая проверка выглядит так:
require(msg.value >= requiredPayment, "not enough eth");Custom error даёт тип и данные:
if (msg.value < requiredPayment) {
revert NotEnoughEth(requiredPayment, msg.value);
}Второй вариант лучше для production-контрактов: меньше bytecode, проще декодировать ошибку в тестах и frontend видит конкретные параметры отката.
Где объявлять ошибки
Ошибки обычно объявляют рядом с events и state-переменными в начале контракта. Так ABI сразу показывает, какие откаты может вернуть контракт.
Названия пишут как действие или состояние: OnlyOwner, ZeroAmount, SaleIsClosed, ExceedsWalletLimit. Если ошибка принимает параметры, их имена должны объяснять смысл: required, received, available, requested.
Частые ошибки
Оставлять только строковые require → код работает, но bytecode становится тяжелее, а тестам сложнее проверять параметры ошибки.
Делать одну ошибку на всё → InvalidInput() не объясняет, что именно не так. Лучше разделить: ZeroAmount(), ZeroAddress(), ExceedsWalletLimit(...).
Не передавать полезные параметры → NotEnoughEth() хуже, чем NotEnoughEth(required, received), потому что caller не видит разницу между ожидаемым и фактическим значением.
Что дальше
- Вызов write-функций — вызовите функцию, которая откатывается, и посмотрите ошибку в ethers v6
- Events — объявление и emit — отделите ошибки от событий успешных действий
- Mapping и вложенные маппинги — храните лимиты и балансы, которые проверяют custom errors