китаев.tech

Кастомные ошибки — Custom Errors

Как объявлять custom errors в Solidity, вызывать revert с параметрами и заменять строковые require на типизированные ошибки.

Custom Error описывает причину отката

Custom Error — это типизированная ошибка в Solidity. Она показывает, почему контракт откатил транзакцию, и может передавать параметры: адрес, лимит, доступный баланс или требуемую сумму.

Custom Errors обычно дешевле строковых require(..., "message"), потому что контракту не нужно хранить длинный текст ошибки в bytecode.


Пишем продажу токенов с ошибками

Создайте файл contracts/TokenSale.sol:

solidity
// 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 со строкой

Строковая проверка выглядит так:

solidity
require(msg.value >= requiredPayment, "not enough eth");

Custom error даёт тип и данные:

solidity
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 не видит разницу между ожидаемым и фактическим значением.


Что дальше

Материалы китаev.tech публикуются в образовательных целях и не являются инвестиционной рекомендацией. Примеры кода и описания протоколов — для обучения; использование в продакшне на ваш собственный риск. Дисклеймер и политика конфиденциальности.