Skip to main content
Create your own

Account Freeze/Unfreeze Function Development

In our last session, we transformed our SecurityToken from a generic ERC-20 into a permissioned asset by integrating an on-chain allowlist via the _beforeTokenTransfer hook. This was a major step towards building a compliant digital security. Today, we will add another crucial layer of administrative control required in regulated environments.

This lesson focuses on implementing functions that allow a designated compliance administrator to freeze and unfreeze specific investor accounts. This capability is not just a technical feature; it's a fundamental requirement for legal enforceability, allowing issuers to comply with court orders, sanctions lists, or internal fraud investigations. It directly addresses the real-world operational needs of managing tokenized securities, bridging the gap between blockchain's immutability and the practical demands of financial regulation.

The "Why": Legal Enforceability and Compliance Workflows

In traditional finance, asset custodians or transfer agents have the legal authority and technical means to freeze accounts. For a security token to be a viable replacement, it must offer equivalent control mechanisms.

As this diagram illustrates, the ability for a licensed administrator to "Lawfully Freeze Assets" is a cornerstone of a legally enforceable security token platform. It ensures the digital asset remains compliant with off-chain legal directives.

This freezing action is often the first step in a broader compliance process.

This workflow shows that an "Immediate Transfer Freeze" is the typical first response to a detected compliance failure. It acts as a "soft lock" to prevent further movement of the asset while the issue is investigated.

Our task is to build this "soft lock" capability directly into our SecurityToken contract.

Defining a New Administrative Role

To implement this powerful feature responsibly, we must adhere to the principle of least privilege. The entity that can freeze accounts should be distinct from the one that mints tokens. We will use the AccessControl contract we've already imported to create a new, specific role for this purpose: the COMPLIANCE_ROLE.

This ensures a separation of duties. An address with the MINTER_ROLE can create new tokens, but cannot interfere with existing token holders. Conversely, an address with the COMPLIANCE_ROLE can enforce compliance actions but cannot inflate the token supply.

First, let's define this new role in our contract, just as we did for the MINTER_ROLE:

// Add this alongside your MINTER_ROLE definition
bytes32 public constant COMPLIANCE_ROLE = keccak256("COMPLIANCE_ROLE");

We also need to grant this role to an administrator. For now, we will grant it to the contract deployer in the constructor for simplicity. In a production scenario, this role would likely be granted to a multi-signature wallet controlled by the company's compliance department.

// Add this line in the constructor
_grantRole(COMPLIANCE_ROLE, msg.sender);

For a deeper understanding of how AccessControl manages roles, including the concept of admin roles that can grant or revoke other roles, the following video from OpenZeppelin is an excellent resource.

Setting Up Access Control for Smart Contracts

This video from the OpenZeppelin team, "Setting Up Access Control for Smart Contracts," provides a clear explanation of the role-based pattern we are using. It's a great refresher on why we use roles and how they are managed.

You can focus on these key segments: Role Concept: Explains why roles are superior to a single owner for managing different permissions. AccessControl Interface: Introduces the key functions like hasRole and grantRole. Admin Roles: Describes the powerful concept of a default admin role that governs other roles.

Implementing the Freeze Mechanism

Now, let's build the core components for the freeze functionality. The logic is straightforward and involves three parts: a state variable to track frozen accounts, events to log the actions, and functions to perform the freeze/unfreeze operations.

The following resources provide excellent patterns for this implementation. We will draw from both to construct our solution.

ERC-20 with Safety Rails - Ethereum.org

This ethereum.org tutorial clearly outlines the essential components for a freeze/thaw mechanism. We'll use its structure for our state variable and events.

In the section "Freezing and thawing contracts," focus on the first three items: Read the description of the mapping used to track frozen status. Review the explanation of the Frozen and Thawed events. Examine the logic in the freezeAccount function, noting how it checks for an existing state, updates the mapping, and emits the event.

Intelligence Contracts | Seismic Docs

This document provides a full contract example that uses AccessControl to protect its freeze functions, which aligns perfectly with our SecurityToken design.

Within the large code block, find the functions complianceFreeze and complianceUnfreeze. Read their implementations to see how they use require(hasRole(...)) to enforce access control. This is the security model we will use. complianceFreeze complianceUnfreeze

Based on these guides, we'll add the following to our SecurityToken.sol file:

  1. State Variable: A mapping to store the frozen status of each address. We'll make it private to prevent direct external access.

    // Mapping from an address to its frozen status
    mapping(address => bool) private _frozenAccounts;
    
  2. Events: To create a transparent, on-chain audit trail of compliance actions.

    event AccountFrozen(address indexed account);
    event AccountUnfrozen(address indexed account);
    
  3. Public "Getter" Function: A view function to allow anyone to check if an account is frozen.

    function isFrozen(address account) public view returns (bool) {
        return _frozenAccounts[account];
    }
    
  4. Administrative Functions: The core functions, secured by our new COMPLIANCE_ROLE.

    function freezeAccount(address account) public onlyRole(COMPLIANCE_ROLE) {
        require(account != address(0), "SecurityToken: Cannot freeze the zero address");
        require(!_frozenAccounts[account], "SecurityToken: Account is already frozen");
    
        _frozenAccounts[account] = true;
        emit AccountFrozen(account);
    }
    
    function unfreezeAccount(address account) public onlyRole(COMPLIANCE_ROLE) {
        require(account != address(0), "SecurityToken: Cannot unfreeze the zero address");
        require(_frozenAccounts[account], "SecurityToken: Account is not frozen");
    
        _frozenAccounts[account] = false;
        emit AccountUnfrozen(account);
    }
    

    Note the use of the onlyRole modifier, which elegantly handles the require(hasRole(...)) check for us.

Enforcing the Freeze

Having functions to freeze an account is useless if the contract doesn't enforce the restriction. The logical place to enforce this is the same _beforeTokenTransfer hook we used for our allowlist. We can simply add checks to ensure that neither the sender nor the recipient is frozen.

This enhances our hook, layering compliance checks in a single, efficient location.

function _beforeTokenTransfer(
    address from,
    address to,
    uint256 amount
) internal virtual override {
    super._beforeTokenTransfer(from, to, amount);

    // Enforce account freeze
    if (from != address(0)) {
        require(!_frozenAccounts[from], "SecurityToken: Sender account is frozen");
    }
    if (to != address(0)) {
        require(!_frozenAccounts[to], "SecurityToken: Receiver account is frozen");
    }

    // Enforce allowlist (from previous lesson)
    if (from != address(0)) {
        require(investorRegistry.isVerified(from), "SecurityToken: Sender not on allowlist");
    }
    if (to != address(0)) {
        require(investorRegistry.isVerified(to), "SecurityToken: Receiver not on allowlist");
    }
}

We've added our new freeze checks at the top of the hook. We check both from and to to completely immobilize assets associated with a frozen account, preventing both outgoing and incoming transfers.

Updated SecurityToken Contract

Here is the complete SecurityToken.sol contract with all the additions from this lesson.

// 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";

contract SecurityToken is ERC20, AccessControl {
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    bytes32 public constant COMPLIANCE_ROLE = keccak256("COMPLIANCE_ROLE");
    
    IInvestorRegistry public investorRegistry;

    // Mapping from an address to its frozen status
    mapping(address => bool) private _frozenAccounts;

    event AccountFrozen(address indexed account);
    event AccountUnfrozen(address indexed account);

    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);
        
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _grantRole(MINTER_ROLE, msg.sender);
        _grantRole(COMPLIANCE_ROLE, msg.sender);
    }

    function mint(address to, uint256 amount) public virtual onlyRole(MINTER_ROLE) {
        _mint(to, amount);
    }
    
    // --- Freeze Functionality ---

    function isFrozen(address account) public view returns (bool) {
        return _frozenAccounts[account];
    }

    function freezeAccount(address account) public onlyRole(COMPLIANCE_ROLE) {
        require(account != address(0), "SecurityToken: Cannot freeze the zero address");
        require(!_frozenAccounts[account], "SecurityToken: Account is already frozen");
        _frozenAccounts[account] = true;
        emit AccountFrozen(account);
    }

    function unfreezeAccount(address account) public onlyRole(COMPLIANCE_ROLE) {
        require(account != address(0), "SecurityToken: Cannot unfreeze the zero address");
        require(_frozenAccounts[account], "SecurityToken: Account is not frozen");
        _frozenAccounts[account] = false;
        emit AccountUnfrozen(account);
    }
    
    // --- Transfer Hook ---

    function _beforeTokenTransfer(
        address from,
        address to,
        uint256 amount
    ) internal virtual override {
        super._beforeTokenTransfer(from, to, amount);

        if (from != address(0)) {
            require(!_frozenAccounts[from], "SecurityToken: Sender account is frozen");
            require(investorRegistry.isVerified(from), "SecurityToken: Sender not on allowlist");
        }

        if (to != address(0)) {
            require(!_frozenAccounts[to], "SecurityToken: Receiver account is frozen");
            require(investorRegistry.isVerified(to), "SecurityToken: Receiver not on allowlist");
        }
    }
}

Conclusion

In this lesson, you have successfully implemented an essential administrative feature for any security token. By adding the ability for a COMPLIANCE_ROLE to freeze and unfreeze accounts, you've equipped the token with a crucial tool for regulatory adherence and risk management.

Key Takeaways:

  • A COMPLIANCE_ROLE was introduced to separate compliance duties from other administrative tasks, following the principle of least privilege.
  • A private mapping(address => bool) is an efficient way to store the frozen status of accounts.
  • Events like AccountFrozen and AccountUnfrozen provide a critical on-chain audit trail for administrative actions.
  • The _beforeTokenTransfer hook is the ideal place to enforce multiple compliance rules, such as both allowlisting and account freezes, in a clean and gas-efficient manner.

Our SecurityToken is becoming increasingly robust. However, sometimes a compliance action requires more than just freezing an account. In our next lesson, we will implement a forced transfer function, giving an administrator the ability to move tokens on behalf of an investor, a powerful and necessary tool for asset recovery or executing legal judgments.

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

Sign up