китаев.tech

AccessControl — система ролей

Как устроить контроль доступа через роли в Solidity: DEFAULT_ADMIN_ROLE, grantRole, revokeRole и проверки onlyRole.

AccessControl делит права между ролями

AccessControl — это паттерн, где доступ к функциям выдаётся не одному owner, а ролям. Один адрес может быть MINTER, другой — PAUSER, третий — TREASURER.

Роли подходят, когда в контракте есть несколько независимых прав. Так не нужно давать одному адресу полный admin-доступ ко всему контракту.


Пишем контракт с ролями

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

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

abstract contract SimpleAccessControl {
    bytes32 public constant DEFAULT_ADMIN_ROLE = 0x00;

    mapping(bytes32 => mapping(address => bool)) private roles;

    event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender);
    event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender);

    error MissingRole(bytes32 role, address account);
    error ZeroAddress();

    modifier onlyRole(bytes32 role) {
        if (!hasRole(role, msg.sender)) {
            revert MissingRole(role, msg.sender);
        }

        _;
    }

    function hasRole(bytes32 role, address account) public view returns (bool) {
        return roles[role][account];
    }

    function grantRole(bytes32 role, address account) external onlyRole(DEFAULT_ADMIN_ROLE) {
        if (account == address(0)) {
            revert ZeroAddress();
        }

        _grantRole(role, account);
    }

    function revokeRole(bytes32 role, address account) external onlyRole(DEFAULT_ADMIN_ROLE) {
        if (roles[role][account]) {
            roles[role][account] = false;

            emit RoleRevoked(role, account, msg.sender);
        }
    }

    function _grantRole(bytes32 role, address account) internal {
        if (!roles[role][account]) {
            roles[role][account] = true;

            emit RoleGranted(role, account, msg.sender);
        }
    }
}

contract RoleToken is SimpleAccessControl {
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
    bytes32 public constant TREASURER_ROLE = keccak256("TREASURER_ROLE");

    string public constant name = "Role Token";
    string public constant symbol = "ROLE";
    uint8 public constant decimals = 18;

    uint256 public totalSupply;
    bool public paused;

    mapping(address => uint256) public balanceOf;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event PauseUpdated(bool paused);
    event TreasuryWithdrawn(address indexed to, uint256 amount);

    error Paused();
    error ZeroAmount();
    error NotEnoughBalance(uint256 available, uint256 required);
    error EthTransferFailed();

    constructor(address admin) {
        if (admin == address(0)) {
            revert ZeroAddress();
        }

        _grantRole(DEFAULT_ADMIN_ROLE, admin);
        _grantRole(MINTER_ROLE, admin);
        _grantRole(PAUSER_ROLE, admin);
        _grantRole(TREASURER_ROLE, admin);
    }

    receive() external payable {}

    function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
        if (to == address(0)) {
            revert ZeroAddress();
        }

        if (amount == 0) {
            revert ZeroAmount();
        }

        totalSupply += amount;
        balanceOf[to] += amount;

        emit Transfer(address(0), to, amount);
    }

    function transfer(address to, uint256 amount) external {
        if (paused) {
            revert Paused();
        }

        if (to == address(0)) {
            revert ZeroAddress();
        }

        if (amount == 0) {
            revert ZeroAmount();
        }

        uint256 balance = balanceOf[msg.sender];

        if (balance < amount) {
            revert NotEnoughBalance(balance, amount);
        }

        balanceOf[msg.sender] = balance - amount;
        balanceOf[to] += amount;

        emit Transfer(msg.sender, to, amount);
    }

    function setPaused(bool newPaused) external onlyRole(PAUSER_ROLE) {
        paused = newPaused;

        emit PauseUpdated(newPaused);
    }

    function withdrawTreasury(address payable to) external onlyRole(TREASURER_ROLE) {
        if (to == address(0)) {
            revert ZeroAddress();
        }

        uint256 amount = address(this).balance;

        if (amount == 0) {
            revert ZeroAmount();
        }

        (bool success, ) = to.call{value: amount}("");

        if (!success) {
            revert EthTransferFailed();
        }

        emit TreasuryWithdrawn(to, amount);
    }
}

Этот контракт разделяет полномочия: MINTER_ROLE выпускает токены, PAUSER_ROLE ставит паузу, TREASURER_ROLE выводит ETH, а DEFAULT_ADMIN_ROLE выдаёт и отзывает роли.


Как это работает

bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE") — создаёт стабильный идентификатор роли. В Solidity роли часто хранят как bytes32.

mapping(bytes32 => mapping(address => bool)) private roles — хранит ответ на вопрос: есть ли у адреса конкретная роль.

hasRole(role, account) — публичная проверка роли. Её может вызвать frontend, тест или другой контракт.

modifier onlyRole(bytes32 role) — останавливает вызов, если у msg.sender нет нужной роли.

grantRole(role, account) — выдаёт роль. В примере это может делать только адрес с DEFAULT_ADMIN_ROLE.

revokeRole(role, account) — отзывает роль. После отзыва адрес больше не сможет вызывать функции, защищённые этой ролью.

function mint(...) external onlyRole(MINTER_ROLE) — пример функции, которую может вызывать не admin, а только minter.


DEFAULT_ADMIN_ROLE

DEFAULT_ADMIN_ROLE — роль администратора ролей. Она не обязательно должна выполнять бизнес-действия вроде mint или withdraw.

Хорошая схема: admin выдаёт роли, а операционные адреса выполняют свои узкие действия. Например, backend получает MINTER_ROLE, multisig получает DEFAULT_ADMIN_ROLE, отдельный кошелёк получает TREASURER_ROLE.

Если один адрес имеет все роли, система технически работает, но почти не отличается от Ownable.


AccessControl vs Ownable

Ownable проще: один владелец, один onlyOwner, меньше кода и меньше мест для ошибки.

AccessControl гибче: можно разделить права, отозвать только одну роль и не давать каждому оператору полный контроль над контрактом.

Для маленького контракта с одной admin-функцией обычно достаточно Ownable. Для протокола, токена, DAO-инструмента или backend-операций лучше использовать роли.


Частые ошибки

Дать DEFAULT_ADMIN_ROLE обычному backend-ключу → backend сможет выдать себе любые роли. Admin-роль лучше держать на multisig.

Не разделять admin и операционные роли → если minter одновременно admin, компрометация minter-ключа даст полный контроль над ролями.

Проверять не ту рольonlyRole(DEFAULT_ADMIN_ROLE) на mint заставит admin делать операционную работу. Для mint нужна отдельная MINTER_ROLE.


Что дальше

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