Welcome back! In our last lesson, you built the core mint and burn functions for our stablecoin. However, we left a critical security vulnerability exposed: the mint function was public, allowing anyone to create new tokens at will. In the world of finance and asset management, this is equivalent to leaving the vault door wide open.
Today, we will secure our stablecoin by implementing a robust access control system. Your goal is to restrict the minting and burning capabilities to an authorized administrator role. We will leverage OpenZeppelin's AccessControl contract, a standard for creating granular, role-based permissions. This is a crucial step toward building a production-ready smart contract for tokenized money.
From Simple Ownership to Granular Roles
In many simple contracts, a single owner account manages all administrative tasks. This is known as the Ownable pattern. However, for a system as critical as a stablecoin, you often need more sophisticated permissions. Different entities or automated systems might be responsible for different tasks. For example:
- A treasury management team might have the authority to mint and burn tokens.
- A risk management team might have the ability to pause the contract in an emergency.
- A development team, governed by a multi-signature wallet, might have the power to upgrade the contract.
A single owner role for all these functions violates the principle of least privilege, a fundamental security concept you're likely familiar with from managing access to sensitive data warehouses and financial software. We need a way to assign specific permissions to specific roles.
The video below from OpenZeppelin introduces this exact challenge using a stablecoin as an example, motivating the need for a more granular approach than simple ownership.
Setting Up Access Control for Smart Contracts
This video, "Setting Up Access Control for Smart Contracts" from the OpenZeppelin channel, explains the limitations of Ownable and introduces the concept of granular access control with roles.
Please watch the section from 06:34 to 09:33. Pay attention to how the stablecoin example differentiates between the need to set fees (a DAO's role) and trigger an emergency shutdown (a team's role), demonstrating why a single owner is insufficient.
Implementing Role-Based Access Control (RBAC)
The solution to this is Role-Based Access Control (RBAC), which is provided by OpenZeppelin's AccessControl contract. This contract allows us to define multiple roles and assign them to different accounts.
Before we modify our code, let's get a clear, practical overview of how to use AccessControl. The following reading provides concise code examples that we will be adapting for our USDStablecoin.
This guide from Ethereum Blockchain Developer provides a very clear and hands-on explanation of implementing access control on an ERC-20 token using OpenZeppelin.
Please read the section Ownership and Access Control. Focus on these key steps which you will replicate shortly: How roles like MINTER_ROLE are defined using keccak256. How roles are assigned in the constructor using _grantRole. How the onlyRole modifier is used to protect a function. The complete code examples for both a mint and a burn function protected by roles.
Securing the USDStablecoin Contract
Now, let's apply these concepts to our USDStablecoin.sol contract. We will perform the following steps:
- Inherit from
AccessControl. - Define
MINTER_ROLEandBURNER_ROLE. - Set up the roles in the constructor.
- Secure the
mintfunction. - Replace the public
burnfromERC20Burnablewith our own role-protected version.
Step 1 & 2: Import and Inherit AccessControl, Define Roles
First, add the import statement for AccessControl at the top of your file. Then, add it to your contract's inheritance list. We will also define our roles as bytes32 constants. Using the hash of a string for the role name is a best practice that ensures uniqueness and gas efficiency.
// Add this import statement
import "@openzeppelin/contracts/access/AccessControl.sol";
// Modify the contract declaration and add role definitions
contract USDStablecoin is ERC20, AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");
// ... rest of the contract
}
Step 3: Set Up Roles in the Constructor
The constructor is the perfect place to set up the initial permissions. We need to grant the account that deploys the contract (msg.sender) the ability to manage roles and to mint/burn tokens.
AccessControl has a special role called DEFAULT_ADMIN_ROLE. An account with this role can grant and revoke any other role. It's the "super admin." We will grant this role to the deployer. For simplicity in our current setup, we'll also grant the deployer the MINTER_ROLE and BURNER_ROLE directly.
Modify your constructor like this:
constructor() ERC20("USD Stablecoin", "USDS") {
// Grant the contract deployer the default admin role, as well as the
// minter and burner roles. This allows the deployer to grant these
// roles to other accounts and to perform minting and burning.
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(MINTER_ROLE, msg.sender);
_grantRole(BURNER_ROLE, msg.sender);
}
Step 4 & 5: Secure the Mint and Burn Functions
Now for the most important part. We will add the onlyRole modifier to our mint function. This acts as a gateway, ensuring only accounts with MINTER_ROLE can execute it.
Additionally, in the previous lesson, we used ERC20Burnable. This extension gives a public burn function, allowing any token holder to burn their own tokens. For a fiat-backed stablecoin, we want the issuer to have exclusive control over burning tokens (after receiving them from a user for redemption). Therefore, we will remove ERC20Burnable and create our own burn function protected by the BURNER_ROLE.
Make the following changes:
- Remove the
ERC20Burnableimport and inheritance. - Add the
onlyRole(MINTER_ROLE)modifier to yourmintfunction. - Add a new
burnfunction that is protected byonlyRole(BURNER_ROLE).
// Change this function declaration
function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) {
_mint(to, amount);
}
/**
* @dev Destroys `amount` tokens from the caller.
* This is restricted to accounts with the BURNER_ROLE. In the stablecoin
* model, the issuer would call this after a user has sent tokens to
* the issuer's address for redemption.
*/
function burn(uint256 amount) public onlyRole(BURNER_ROLE) {
_burn(msg.sender, amount);
}
The Final, Secured Contract
Congratulations! You have now secured the core functions of the stablecoin. The ability to alter the token supply is no longer public but is restricted to specific, designated roles.
Here is the complete, updated USDStablecoin.sol contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
/**
* @title USDStablecoin
* @dev An ERC-20 stablecoin with role-based access control for minting and burning.
*/
contract USDStablecoin is ERC20, AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");
constructor() ERC20("USD Stablecoin", "USDS") {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(MINTER_ROLE, msg.sender);
_grantRole(BURNER_ROLE, msg.sender);
}
function decimals() public pure override returns (uint8) {
return 6;
}
/**
* @dev Creates `amount` new tokens and assigns them to `to`.
* Can only be called by accounts with the MINTER_ROLE.
*/
function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) {
_mint(to, amount);
}
/**
* @dev Destroys `amount` tokens from the caller's balance.
* Can only be called by accounts with the BURNER_ROLE.
*/
function burn(uint256 amount) public onlyRole(BURNER_ROLE) {
_burn(msg.sender, amount);
}
}
The image below shows a similar implementation pattern, reinforcing the structure you've just built.

To deepen your understanding of the AccessControl contract's mechanics, the official OpenZeppelin documentation is an excellent resource.
This is the official documentation for OpenZeppelin's AccessControl contract. It provides the most detailed explanation of the pattern.
As you review this, focus on these sections: Start with Role-Based Access Control to reinforce the "why". Read the section with the AccessControlERC20Mint example, specifically the text that discusses the principle of least privilege. Finally, read about Granting and Revoking Roles to understand the power of the DEFAULT_ADMIN_ROLE you assigned in the constructor.
Conclusion
In this lesson, you have taken a massive step forward in securing your stablecoin. By moving from a public, insecure mint function to a system governed by Role-Based Access Control, you have implemented a pattern that is essential for any real-world financial application on the blockchain.
Key Takeaways:
- RBAC is Essential: For complex systems like stablecoins, granular Role-Based Access Control is superior to a simple
Ownablepattern, aligning with the principle of least privilege. AccessControlImplementation: You've learned how to use OpenZeppelin'sAccessControlcontract by defining roles (e.g.,MINTER_ROLE), granting them in the constructor, and protecting functions with theonlyRolemodifier.- Admin Hierarchy: You now understand that the
DEFAULT_ADMIN_ROLEis a powerful role capable of managing all other roles, and it's critical to secure the account that holds it. - Controlled Burning: You replaced the public
burnfunctionality with a role-protected one, giving the issuer complete control over the token supply's contraction, which mirrors its control over expansion.
In our next lesson, we will add another layer of administrative control that's vital for risk management: the ability to pause all token transfers in an emergency, using OpenZeppelin's Pausable utility.