Skip to main content
Create your own

Implementing Transfer Restrictions with Identity Checks

Hello! In our last lesson, we designed and built a standalone InvestorRegistry contract. This contract serves as a simple on-chain database, mapping investor addresses to compliance tiers, effectively acting as the source of truth for investor eligibility.

Today, we will build the other half of the compliance mechanism. Our goal is to design a transfer restriction mechanism within a security token contract that checks sender and receiver eligibility against the identity system we just created. This involves making our token "aware" of the registry and programming it to consult the registry before any transfer is allowed to proceed. For someone with your experience in designing data-driven systems, this process is analogous to an application's business logic layer querying a data warehouse to enforce a rule before committing a transaction.

The Professional Approach: Decoupling Rules from the Token

In professional-grade tokenization platforms, a key design principle is the separation of concerns. The token contract should be responsible for tracking balances and ownership, while a separate, dedicated contract handles the complex and often-changing compliance rules. This modular design makes the system more flexible, reusable, and easier to update. This external compliance contract is often referred to as a "Rule Engine."

Let's explore this concept by looking at how it's implemented in the CMTAT standard, a framework used by institutional players.

How to Apply Restrictions with CMTAT and ERC-1404 - Taurus

This article from Taurus provides an excellent overview of using an external RuleEngine to enforce transfer restrictions on a security token. It clearly explains the benefits of this decoupled architecture.

First, read the section "Components" to understand the different parts, paying close attention to the three reasons for using a separate RuleEngine. Next, review the "How it works" section and its accompanying diagram. This shows the exact sequence of events: the token receives a transfer request and then calls the external RuleEngine to validate it before proceeding.

The architecture described in the reading—a token contract calling an external rule engine—is precisely what we aim to design. Our InvestorRegistry will play the role of a simplified RuleEngine.

The Hook: _beforeTokenTransfer

To implement this check, we need a way to intercept every transfer, mint, and burn operation. We could modify the transfer and transferFrom functions directly, but that would be invasive and prone to errors. A much cleaner solution is to use a "hook."

OpenZeppelin's widely-used ERC20 implementation provides a special internal function called _beforeTokenTransfer. It is a virtual function, meaning it's specifically designed to be overridden in a child contract. This hook is automatically called by the _transfer, _mint, and _burn functions before any balance changes are made. It's the perfect place to insert our compliance check.

The transfer flow in a professional standard like CMTAT follows this exact pattern. The diagram below illustrates the sequence of checks that occur during a transfer, including validation against a RuleEngine. Our logic fits perfectly within this flow.

CMTA/CMTAT

The CMTAT documentation includes a clear flow diagram showing the checks performed during a transfer.

Please review the Transfer Flow Diagram. Notice the step "RuleEngine says no?"—this is where our _beforeTokenTransfer logic will execute.

Designing the Interaction

To make our security token communicate with the InvestorRegistry, we need two things:

  1. The Interface: The token contract needs to know what functions are available on the registry contract and what their exact signatures are (i.e., their names, parameters, and return types). An interface in Solidity serves this purpose. It acts as a contract's Application Programming Interface (API) definition, without containing any implementation logic.
  2. The Connection: The token contract must store the address of the deployed InvestorRegistry contract so it knows which contract to call. This address is typically passed to the token's constructor during deployment and stored in a state variable.

Here is the interface for the InvestorRegistry we built in the previous lesson. We'll place this in its own file, IInvestorRegistry.sol.

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

interface IInvestorRegistry {
    /**
     * @notice Checks if an investor has been verified (i.e., tier is not None).
     * @param investor The address of the investor to check.
     * @return bool True if the investor is verified, false otherwise.
     */
    function isVerified(address investor) external view returns (bool);
}

This interface defines the single function, isVerified, that our token will need to call.

Putting It All Together: The Security Token

Now we can design the SecurityToken.sol contract. It inherits from OpenZeppelin's ERC20 contract and uses the IInvestorRegistry interface to enforce compliance rules within the _beforeTokenTransfer hook.

The logic is as follows:

  • During a mint operation, the from address is address(0). We allow this without checking the registry.
  • During a burn operation, the to address is address(0). We also allow this.
  • For a standard transfer, both the from and to addresses must be verified by the registry. The contract calls registry.isVerified() for both parties and reverts the transaction if either check fails.

This entire interaction is beautifully illustrated by the T-REX standard's transaction flow.

This diagram shows a peer initiating a transaction, which triggers the T-REX token to initiate checks with an external validator. The validator returns a status, and only then is the transfer of ownership executed. Our design implements this exact pattern, with our `SecurityToken` being the token, and our `InvestorRegistry` acting as the validator.

Here is the code for our SecurityToken.sol:

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

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "./IInvestorRegistry.sol"; // Import the interface we defined

/**
 * @title SecurityToken
 * @dev An ERC20 token with transfer restrictions controlled by an external registry.
 */
contract SecurityToken is ERC20 {
    IInvestorRegistry public registry;

    /**
     * @dev Sets the registry address upon deployment.
     * @param registryAddress The address of the deployed InvestorRegistry contract.
     */
    constructor(address registryAddress) ERC20("My Security Token", "MST") {
        require(registryAddress != address(0), "SecurityToken: Registry address cannot be zero");
        registry = IInvestorRegistry(registryAddress);
    }

    /**
     * @dev Hook that is called before any token transfer.
     * This is used to enforce compliance rules by checking the investor registry.
     */
    function _beforeTokenTransfer(address from, address to, uint256 amount) internal override {
        // Allow minting (from address(0)) and burning (to address(0)) without checks.
        if (from == address(0) || to == address(0)) {
            // No compliance check needed for minting or burning.
        } else {
            // For regular transfers, both sender and receiver must be verified.
            require(
                registry.isVerified(from) && registry.isVerified(to),
                "Compliance: Sender or receiver not verified"
            );
        }

        // Call the parent implementation to maintain standard ERC20 behavior.
        super._beforeTokenTransfer(from, to, amount);
    }
}

This design is simple, robust, and follows industry best practices. By separating the token logic from the compliance logic, we have created a modular system that is easy to understand and maintain.

Our design mirrors the professional ERC-3643 architecture. The `SecurityToken` is the core component. The `InvestorRegistry` serves as the `Identity Registry`. The logic inside `_beforeTokenTransfer` acts as our `Compliance Module`, enforcing the rules.

Conclusion

In this lesson, we designed the critical link between a security token and its compliance rules. By leveraging a standard hook and a modular architecture, we created a powerful yet clean mechanism for enforcing transfer restrictions.

Key Takeaways:

  • Decoupled Design: Separating the token from its compliance rules (RuleEngine or registry) is a best practice for flexibility and maintainability.
  • The _beforeTokenTransfer Hook: This OpenZeppelin feature is the standard, non-invasive way to add custom logic (like compliance checks) to all token movements (transfer, mint, burn).
  • Interfaces for Communication: Solidity interfaces (IInvestorRegistry) are essential for enabling one contract to securely and reliably call another.
  • Dependency Injection: Passing the registry's address to the token's constructor is a clean pattern for linking the two contracts at deployment time.

We now have the complete design for a compliant security token system: a registry to hold identity data and a token that consults that registry.

In our next lesson, we will formalize the IInvestorRegistry contract into its own file and take a closer look at why defining clear interfaces is a fundamental principle for building the robust, modular, and even upgradeable smart contract systems that are required in professional development.

Can't find a good explanation? Sign up and we'll make it for you

Sign up