Welcome back. In our previous lesson, we laid the groundwork for our SecurityToken by creating a contract that inherits from ERC20 and AccessControl. We now have a standard token with a flexible, role-based system for managing administrative functions like minting.
This lesson's objective is to override the _beforeTokenTransfer hook to enforce transfer restrictions based on the allowlist. We will connect our SecurityToken to the InvestorRegistry we built earlier, effectively creating a closed ecosystem where only verified investors can transact. This is the heart of on-chain compliance for a security token and a crucial step in bridging the gap from a generic token to a regulated financial instrument.
The Power of Hooks: _beforeTokenTransfer
In software engineering, a "hook" is a mechanism that allows you to insert custom code into the standard execution flow of a system. This is precisely what OpenZeppelin's ERC20 contract provides with the _beforeTokenTransfer function.
This function is intentionally left empty in the base ERC20 contract, designed for developers like us to override. It is automatically called from within the internal _transfer function every time a token movement is initiated, either by transfer or transferFrom.

Placing our compliance check here perfectly follows the Checks-Effects-Interactions security pattern. We perform our checks before any state-changing effects (the balance updates) occur. For those with a background in database systems, this is conceptually similar to a BEFORE INSERT or BEFORE UPDATE trigger that validates data before it's committed to a table.
The _beforeTokenTransfer function receives three arguments: address from, address to, and uint256 amount. These represent the true source and destination of the tokens, which is exactly what our compliance logic needs to validate.
Integrating the Investor Registry
To enforce our allowlist, the SecurityToken contract needs to be aware of the InvestorRegistry contract. We will achieve this through the following steps:
- Update the Constructor: We'll modify the
SecurityTokenconstructor to accept the address of the deployedInvestorRegistrycontract and store it in a state variable. - Import the Interface: We need to import the
IInvestorRegistryinterface we defined two lessons ago so that ourSecurityTokencontract knows how to call theisVerifiedfunction. - Implement the Hook Logic: We will override
_beforeTokenTransferto callisVerifiedon the registry for both the sender and the receiver.
The following guide from ChainScore Labs provides excellent, practical code snippets illustrating this exact pattern.
How to Set Up Security Token Transfer Restrictions and Whitelists
This article explains the core patterns for on-chain compliance. It clearly demonstrates how the _beforeTokenTransfer hook is used to enforce whitelist rules.
First, review the simplified code example showing the basic implementation. This shows the core idea of overriding the hook. Next, it's crucial to understand which addresses to check. Read the section on common failure points, which correctly explains why you must validate from and to, not msg.sender. The corrected code snippet reinforces this critical security detail.
Handling Special Cases: Minting and Burning
A crucial aspect of implementing this hook is handling the special cases of token creation (minting) and destruction (burning).
- Minting: When new tokens are created via
_mint, thefromaddress isaddress(0). The zero address will, of course, not be on our allowlist. - Burning: When tokens are destroyed via
_burn, thetoaddress isaddress(0).
Our compliance logic must account for this. A transfer should only be checked if the address is not the zero address. Therefore, the logic inside _beforeTokenTransfer will be:
- If
fromis notaddress(0), check iffromis verified in the registry. - If
tois notaddress(0), check iftois verified in the registry.
This ensures that our compliance rules apply strictly to transfers between actual user accounts, while still allowing the administrative functions of minting and burning to operate correctly.
The Updated SecurityToken Contract
Let's bring these pieces together. We will now update SecurityToken.sol to include the connection to the InvestorRegistry and the overridden hook.
Here is the complete implementation. Pay close attention to the new constructor parameter, the state variable for the registry, and the logic within _beforeTokenTransfer.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
import "./IInvestorRegistry.sol";
/**
* @title SecurityToken
* @dev An ERC-20 token with role-based access control and transfer restrictions.
*/
contract SecurityToken is ERC20, AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
// We will define more roles later.
// The address of the investor registry contract for compliance checks.
IInvestorRegistry public investorRegistry;
/**
* @dev Sets up the token name, symbol, initial roles, and registry address.
* @param name The name of the token.
** @param symbol The symbol of the token.
* @param registryAddress The address of the investor registry contract.
*/
constructor(
string memory name,
string memory symbol,
address registryAddress
) ERC20(name, symbol) {
require(registryAddress != address(0), "SecurityToken: Registry address cannot be zero");
investorRegistry = IInvestorRegistry(registryAddress);
// Grant the contract deployer the default admin role and the minter role.
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(MINTER_ROLE, msg.sender);
}
/**
* @notice Creates `amount` new tokens and assigns them to `to`.
* @dev Can only be called by an account with the MINTER_ROLE.
* The recipient must be on the allowlist.
* @param to The address that will receive the minted tokens.
* @param amount The amount of tokens to mint.
*/
function mint(address to, uint256 amount) public virtual onlyRole(MINTER_ROLE) {
_mint(to, amount);
}
/**
* @dev Hook that is called before any token transfer, including minting and burning.
* Overridden to enforce that both sender and receiver are verified investors.
*/
function _beforeTokenTransfer(
address from,
address to,
uint256 amount
) internal virtual override {
// Always call the parent contract's hook.
super._beforeTokenTransfer(from, to, amount);
// For a transfer between users (not minting), check the sender.
if (from != address(0)) {
require(investorRegistry.isVerified(from), "SecurityToken: Sender not on allowlist");
}
// For any transfer to a user (not burning), check the receiver.
if (to != address(0)) {
require(investorRegistry.isVerified(to), "SecurityToken: Receiver not on allowlist");
}
}
}
With this implementation, our SecurityToken is no longer a permissionless asset. It is now fundamentally tied to our on-chain identity system, automatically enforcing the rule that only verified participants can hold and trade the token.
Conclusion
In this lesson, you have implemented a critical piece of infrastructure for any regulated token. By overriding the _beforeTokenTransfer hook, you have woven compliance directly into the fabric of the token itself. This automated, on-chain enforcement is a significant advantage of tokenized assets over traditional financial systems.
Key Takeaways:
- The
_beforeTokenTransferfunction is an OpenZeppelin hook designed for implementing pre-transfer validation logic like allowlisting. - By connecting the
SecurityTokento an externalInvestorRegistryvia an interface, we create a modular and clean architecture for compliance. - The logic must account for minting (
from == address(0)) and burning (to == address(0)) to avoid blocking administrative actions. - This implementation enforces that any address sending or receiving tokens must be on the allowlist, effectively creating a "walled garden" required for many security tokens.
Our token now has its primary compliance mechanism in place. However, real-world regulation often requires more granular control. In the next lesson, we will build on this foundation by implementing functions to allow a compliance administrator to freeze and unfreeze specific accounts, adding another essential tool to our token's administrative capabilities.