Skip to main content
Create your own

Testing Account Freezing and Forced Transfers

Welcome back. In the previous lessons, you've built a robust testing suite for the core user-facing functionality of your SecurityToken: issuance and transfers. You've confirmed not only that compliant transfers work but also that non-compliant ones are correctly rejected. We now move from testing user actions to validating administrative powers.

This lesson focuses on testing two critical administrative functions: the ability to freeze an account and the power to execute a forced transfer. In the world of tokenized securities, these are not just technical features; they are essential tools for compliance and risk management, mirroring actions taken in traditional finance under legal or regulatory orders. Your background in asset management will give you a direct appreciation for the importance of ensuring these powerful tools work precisely as intended and are strictly controlled.

1. The Real-World Context of Administrative Functions

Before writing the tests, it's vital to understand why these functions exist. Standard tokens like a simple ERC-20 are designed to be censorship-resistant. However, security tokens represent regulated assets and must operate within a legal framework. This necessitates granting certain powers to a designated administrator.

Frameworks like the CMTA Token (CMTAT) and ERC-3643 are specifically designed for this purpose. They provide standardized interfaces for the compliance features required by real-world financial assets.

CMTA/CMTAT

This document from the Capital Market and Technology Association (CMTA) introduces a standard for security tokens. It provides an excellent overview of the compliance features expected in a regulated token.

First, read the brief Introduction to understand the purpose of the standard. Then, carefully review the table under the heading "What Makes CMTAT Different from a Regular ERC-20 Token?". Pay close attention to the definitions provided for Account Freeze and Forced Transfer.

As you can see, these are not arbitrary features but industry-recognized requirements for bringing financial assets on-chain. Our task is to test our implementation of these powers with the rigor they demand.

2. Testing Account Freezing

The ability to freeze an account is a primary tool for a compliance officer. It can be used to halt activity on an account linked to illicit activities, under sanction, or subject to a court order. When an account is frozen, it should be unable to send or receive tokens.

The logic for this is typically handled within a transfer hook, similar to our allowlist check. A transfer function in a token with a freezing mechanism might look something like this:

This image shows sample Solidity functions from the ERC-3643 standard. The `transfer` function includes a `require` statement checking that neither the sender (`msg.sender`) nor the recipient (`_to`) are frozen.

Now, let's write the tests to ensure our freezing mechanism is working correctly. In your securityToken.test.js file, add a new describe block for these tests.

describe("Administrative Functions: Freezing", function () {
    // We will add our freezing tests here
});

Test Case 1: Verifying Access Control

First and foremost, only the designated administrator should be able to freeze or unfreeze accounts.

  • Arrange: The beforeEach hook gives us our accounts. complianceAdmin has the necessary role, while investor1 does not.
  • Act & Assert: We will test that complianceAdmin can call freeze and unfreeze, but the same calls from investor1 are reverted.

Add the following tests inside your describe("Administrative Functions: Freezing", ...) block:

it("should allow compliance admin to freeze and unfreeze an account", async function () {
    // Freeze
    await expect(securityToken.connect(complianceAdmin).freeze(investor1.address))
        .to.emit(securityToken, "AccountFrozen")
        .withArgs(investor1.address, true);
    expect(await securityToken.isFrozen(investor1.address)).to.be.true;

    // Unfreeze
    await expect(securityToken.connect(complianceAdmin).unfreeze(investor1.address))
        .to.emit(securityToken, "AccountFrozen")
        .withArgs(investor1.address, false);
    expect(await securityToken.isFrozen(investor1.address)).to.be.false;
});

it("should prevent a non-admin from freezing an account", async function () {
    // The error message depends on OpenZeppelin's AccessControl contract.
    // It includes the account address and the role hash.
    const complianceRole = await securityToken.COMPLIANCE_ADMIN_ROLE();
    await expect(
        securityToken.connect(investor1).freeze(investor2.address)
    ).to.be.revertedWith(`AccessControl: account ${investor1.address.toLowerCase()} is missing role ${complianceRole}`);
});

Test Case 2: Verifying Transfer Restrictions

Next, we must verify that a frozen account is truly locked down.

  • Arrange: The complianceAdmin freezes investor1's account.
  • Act & Assert: We will test that any attempt to transfer tokens from or to investor1 fails with the correct error message. We will then unfreeze the account and confirm that transfers can resume.

Add these tests to the same describe block:

it("should prevent transfers from a frozen account", async function () {
    await securityToken.connect(complianceAdmin).freeze(investor1.address);
    await expect(
        securityToken.connect(investor1).transfer(investor2.address, transferAmount)
    ).to.be.revertedWith("Compliance: sender is frozen");
});

it("should prevent transfers to a frozen account", async function () {
    await securityToken.connect(complianceAdmin).freeze(investor2.address);
    await expect(
        securityToken.connect(investor1).transfer(investor2.address, transferAmount)
    ).to.be.revertedWith("Compliance: recipient is frozen");
});

it("should allow transfers once an account is unfrozen", async function () {
    // Freeze the account first
    await securityToken.connect(complianceAdmin).freeze(investor1.address);
    
    // Ensure transfer fails while frozen
    await expect(
        securityToken.connect(investor1).transfer(investor2.address, transferAmount)
    ).to.be.revertedWith("Compliance: sender is frozen");

    // Unfreeze the account
    await securityToken.connect(complianceAdmin).unfreeze(investor1.address);

    // Now, the transfer should succeed
    await expect(() => 
        securityToken.connect(investor1).transfer(investor2.address, transferAmount)
    ).to.changeTokenBalances(securityToken, [investor1, investor2], [-transferAmount, transferAmount]);
});

3. Testing Forced Transfers

The forcedTransfer function is even more powerful. It allows an admin to move tokens between any two accounts, bypassing most standard transfer rules, including the frozen status. This is a necessary tool for situations like asset seizure by law enforcement, inheritance processing, or correcting a catastrophic error.

Given its power, testing forcedTransfer is paramount. A bug here could have devastating consequences. Real-world audit reports often highlight issues in such sensitive functions.

Next Generation - Code4rena

This is a professional security audit report from Code4rena. Audit findings provide invaluable insight into common pitfalls and the importance of rigorous testing.

Read the finding titled forceTransfer function implementation deviates from technical specification. The auditors found that the forceTransfer function skipped required checks for a paused contract and a blacklisted sender. This demonstrates a real case where an administrative function did not behave as specified, underscoring why we must write explicit tests for every condition.

This finding highlights that even functions designed to bypass some rules must still adhere to others. Our tests will confirm our forcedTransfer function behaves exactly as we designed it.

Let's create a new describe block for these tests.

describe("Administrative Functions: Forced Transfer", function () {
    // We will add our forced transfer tests here
});

Test Case 3: Verifying Functionality and Access Control

We'll test the successful path and the access control in one go.

  • Arrange: investor1 has tokens. complianceAdmin is the admin. investor2 is just another user.
  • Act & Assert: The complianceAdmin executes a forced transfer from investor1 to investor2. We check for correct balance changes and event emission. We also test that investor2 cannot initiate a forced transfer, confirming access control.

Add the following tests:

it("should allow compliance admin to force a transfer", async function () {
    const from = investor1.address;
    const to = investor2.address;

    await expect(() => 
        securityToken.connect(complianceAdmin).forcedTransfer(from, to, transferAmount)
    ).to.changeTokenBalances(securityToken, [investor1, investor2], [-transferAmount, transferAmount]);

    await expect(securityToken.connect(complianceAdmin).forcedTransfer(from, to, transferAmount))
        .to.emit(securityToken, "ForcedTransfer")
        .withArgs(from, to, transferAmount);
});

it("should prevent a non-admin from forcing a transfer", async function () {
    const forcedTransferAdminRole = await securityToken.FORCED_TRANSFER_ADMIN_ROLE();
    await expect(
        securityToken.connect(investor2).forcedTransfer(investor1.address, investor2.address, transferAmount)
    ).to.be.revertedWith(`AccessControl: account ${investor2.address.toLowerCase()} is missing role ${forcedTransferAdminRole}`);
});

Test Case 4: Verifying Bypass of Frozen Status

A key feature of forcedTransfer is its ability to operate on frozen accounts. Let's test this explicitly.

it("should allow a forced transfer from a frozen account", async function () {
    // Freeze investor1's account
    await securityToken.connect(complianceAdmin).freeze(investor1.address);
    expect(await securityToken.isFrozen(investor1.address)).to.be.true;

    // A normal transfer should fail
    await expect(
        securityToken.connect(investor1).transfer(investor2.address, transferAmount)
    ).to.be.revertedWith("Compliance: sender is frozen");
    
    // But a forced transfer should succeed
    await expect(() => 
        securityToken.connect(complianceAdmin).forcedTransfer(investor1.address, investor2.address, transferAmount)
    ).to.changeTokenBalances(securityToken, [investor1, investor2], [-transferAmount, transferAmount]);
});

With these tests in place, run npx hardhat test to confirm that all your administrative functions are secure and behave as expected.

Conclusion

In this lesson, you have extended your test suite to cover critical administrative powers. You have validated not only that these functions work correctly but also that they are protected by strict access control, and that they interact with other compliance features (like freezing) in the intended manner.

Key Takeaways:

  • Administrative functions like freeze and forcedTransfer are essential for regulated digital assets.
  • Testing these functions requires verifying three aspects: correct execution by an authorized admin, strict rejection of calls from unauthorized accounts, and proper interaction with other contract states (e.g., performing a forced transfer from a frozen account).
  • Real-world security audits frequently find flaws in administrative functions, highlighting the critical importance of comprehensive testing.

You have now thoroughly tested the core issuance, transfer, and compliance logic of your security token. The final piece of the puzzle is managing shareholder payouts. In the next lesson, you will test the dividend distribution mechanism you implemented, focusing on the security patterns that protect against common vulnerabilities.

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

Sign up