Using the Ownable and AccessControl contracts by OpenZeppelin
Shivkumar Iyer
Full-Stack Web Developer | 7+ Years of Expertise | React, Node.js, TypeScript, Java | Scalable & User-Centric Solutions | Agile & DevOps Specialist
This is the first of a series of articles on how to use the OpenZeppelin library of smart contracts. I have been using the OpenZeppelin library for a few months now, and as a lover of everything open source, I appreciate the fact that such a rich library has been made open source. In this article, I will get started with two of the simplest contracts in this library – Ownable and AccessControl – which can be used to enforce checking of contract ownership and the authorization for different accounts to perform different actions.
I will take the example of a simple SharedWallet to describe the use of these two smart contracts. The code can be found in the GitHub link:
The purpose of a SharedWallet is to enable authorized members of a team to withdraw funds from a wallet for their expenses. This could be used by a company that uses Ether as its mode of payment, and managers can create and manage these wallets for their employees. The SharedWallet is therefore owned by the ETH account that created it. The owner can assign withdrawers which are other ETH accounts belonging to the employees. Once a withdrawer is assigned, the ETH account can withdraw funds up to a maximum limit.
constructor(address initialOwner) {
if (initialOwner == address(0)) {
revert OwnableInvalidOwner(address(0));
}
_transferOwnership(initialOwner);
}
function _transferOwnership(address newOwner) internal virtual {
address oldOwner = _owner;
_owner = newOwner;
emit OwnershipTransferred(oldOwner, newOwner);
}
The Ownable contract can be called with the constructor having the sole argument as the ETH address of the owner. If this address is a valid address, the _transferOwnership method is called which assigns the account address as the contract owner. This is achieved by calling the SharedWallet constructor as:
constructor(
uint256 _withdrawLimit,
address _walletOwner
) Ownable(_walletOwner) {
_grantRole(DEFAULT_ADMIN_ROLE, _walletOwner);
withdrawLimit = _withdrawLimit;
}
This can be done because the SharedWallet inherits Ownable and AccessControl:
contract SharedWallet is Ownable, AccessControl {
Therefore, the Ownable constructor can be called before the beginning of the SharedWallet constructor function:
constructor(uint256 _withdrawLimit, address _walletOwner) Ownable(_walletOwner) {
Once this Ownable contract is instantiated, it provides a number of functionalities. You can fetch the contract owner with the helper function owner() which merely returns the private variable _owner. You can also enforce a check for owner – a particular function can be called only by the owner using the onlyOwner modifier.
modifier onlyOwner() {
_checkOwner();
_;
}
function _checkOwner() internal view virtual {
if (owner() != _msgSender()) {
revert OwnableUnauthorizedAccount(_msgSender());
}
}
And it can be used in a contract for those functions which only the contract owner should be able to call. For example, in SharedWallet, only the contract owner should be able to assign withdrawers to the contract:
领英推荐
function setWithdrawers(address[] memory _withdrawers) public onlyOwner {
If any other account calls this method, there will be an OwnableUnauthorizedAccount error.
The Ownable contract offers a few other functionalities such as the ability to transfer ownership to another account and to renounce ownership. The transfer ownership however, does not check whether the account that will be the new owner is a functional and active account. Therefore, transferring ownership to an inactive account could end up locking the admin functionalities. Similarly, renouncing ownership will imply that the contract does not have an owner, and therefore, functions with the modifier onlyOwner cannot be called.
OpenZeppelin provides other contracts such as the Ownable2Step which expects the new owner to accept ownership rather than merely transferring ownership to a random account. This will be discussed in a future article. OpenZeppelin also offers advanced modes of managing contracts by establishing a Decentralized Autonomous Organization (DAO) contract. In that case, the owner of a contract will not be an individual, but rather an organization which is managed in a democratic manner through a process of votes. This will also be covered in a future article.
Now that it is clear how to assign a contract owner and enforce checking for contract owner, the next step is to assign roles to different users which will allow them to perform different tasks. As an example, a SharedWallet can have withdrawers and also potentially admins who will approve certain actions. To assign roles, we use the AccessControl contract from OpenZeppelin.
The AccessControl contract allows one to assign a role to any account, revoke a role from an account and also check whether an account has a particular role. A role is a 32-byte hash produced by converting a string identifier of a role using the keccak256 hashing algorithm. As an example:
bytes32 public constant SPENDER = keccak256("SPENDER");
Will convert the string “SPENDER” into a 32-byte hash. The keccak256 hashing algorithm is a one-way algorithm which implies that two different string inputs will not result in the same 32-byte hash. This method is a more gas efficient way of storing identifiers.
An account can be assigned a role using the grantRole method:
function grantRole(bytes32 role, address account) public virtual onlyRole(getRoleAdmin(role)) {
_grantRole(role, account);
}
function _grantRole(bytes32 role, address account) internal virtual returns (bool) {
if (!hasRole(role, account)) {
_roles[role].hasRole[account] = true;
emit RoleGranted(role, account, _msgSender());
return true;
} else {
return false;
}
}
The grantRole function adds the account to a struct roles which assigns role identifiers to a structure RoleData for each identifier.
struct RoleData {
mapping(address account => bool) hasRole;
bytes32 adminRole;
}
mapping(bytes32 role => RoleData) private _roles;
In this manner, the management of user account authorization is delegated to the AccessControl contract rather than maintaining it in the SharedWallet. After assigning roles, we can check if an account has a role or use the onlyRole identifier.
function hasRole(bytes32 role, address account) public view virtual returns (bool) {
return _roles[role].hasRole[account];
}
modifier onlyRole(bytes32 role) {
_checkRole(role);
_;
}
function _checkRole(bytes32 role) internal view virtual {
_checkRole(role, _msgSender());
}
function _checkRole(bytes32 role, address account) internal view virtual {
if (!hasRole(role, account)) {
revert AccessControlUnauthorizedAccount(account, role);
}
}
This can be used in the SharedWallet contract with:
function setWithdrawers(address[] memory _withdrawers) public onlyOwner {
uint256 noOfWithdrawers = _withdrawers.length;
for (uint256 i = 0; i < noOfWithdrawers; i++) {
grantRole(SPENDER, _withdrawers[i]);
}
}
function isWithdrawer(address _account) public view returns (bool) {
return hasRole(SPENDER, _account);
}
function withdraw(uint256 amount) public onlyRole(SPENDER) {
Therefore, as is clear from this article, inheriting contracts from OpenZeppelin results in a much lighter contract as many of the management functions are handled by OpenZeppelin contracts. And since OpenZeppelin contracts are rigorously audited and community maintained, their use would result in contracts that are more secure than contracts written completely from scratch.
Lider de projetos para desenvolvimento de software
1 个月Hi! I created a tutorial on how to create a bug bounty on Bug Buster, an open source bug bounty platform, for a smart contract project using OpenZeppelin. I focused on Ownable contract. :-) Here comes the link in case you get interested: https://hackmd.io/@claudioantonio/openzeppelin-contracts