AccessControl — система ролей
Как устроить контроль доступа через роли в Solidity: DEFAULT_ADMIN_ROLE, grantRole, revokeRole и проверки onlyRole.
AccessControl делит права между ролями
AccessControl — это паттерн, где доступ к функциям выдаётся не одному owner, а ролям. Один адрес может быть MINTER, другой — PAUSER, третий — TREASURER.
Роли подходят, когда в контракте есть несколько независимых прав. Так не нужно давать одному адресу полный admin-доступ ко всему контракту.
Пишем контракт с ролями
Создайте файл contracts/RoleToken.sol:
// 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.
Что дальше
- Ownable — контроль доступа — сравните роли с одним владельцем
- Модификаторы — modifiers — разберите, как
onlyRoleограничивает функции - Events — объявление и emit — записывайте выдачу и отзыв ролей в logs