Skip to main content
Create your own

Testing Compliant Token Transfers for Allowlisted Investors

Hello! In our last session, you built a solid foundation for testing our SecurityToken by verifying its initial state, administrative roles, and issuance logic. You now know how to confirm that only an authorized MINTER_ROLE can create new tokens, a crucial first step in securing our asset.

Today, we move from verifying creation to validating movement. The core value proposition of a security token lies in its ability to enforce compliance rules programmatically. This lesson focuses on testing that very mechanism: the transfer allowlist. You will write tests to simulate and prove that our SecurityToken correctly permits transfers only between eligible, allowlisted investors, and just as importantly, blocks all non-compliant transfers. This is where the abstract concept of on-chain compliance becomes a concrete, verifiable reality.

1. The Logic of a Compliant Transfer

Before we write tests, let's briefly revisit the mechanism that enforces our compliance rules. In a standard ERC-20 token, any address can transfer tokens to any other address. Our SecurityToken is different. We overrode the _beforeTokenTransfer hook, a function that OpenZeppelin's ERC-20 contract executes before any token movement (transfer, mint, or burn).

Our implementation of this hook checks a simple but powerful rule: are both the sender (from) and the receiver (to) present in our on-chain allowlist? This allowlist is managed by the COMPLIANCE_ADMIN_ROLE. If both parties are on the list, the transfer proceeds. If not, the transaction is reverted.

In a real-world tokenized securities platform, this check is often part of a much more complex process. The flowchart below illustrates the multiple layers of validation that might occur, from identity verification to checks against sophisticated rule engines. Our simple allowlist represents a fundamental piece of this compliance puzzle.

This flowchart shows how a token transfer can trigger checks across multiple contracts, including an Identity Registry and a Compliance contract, before being executed or reverted. Our allowlist functions as a simplified version of this compliance layer.

To understand exactly how this is implemented in code, the following resource provides a great explanation of using the _beforeTokenTransfer hook to enforce a whitelist.

How to Implement Transfer Restrictions for Security Tokens ...

This guide from ChainScore Labs details the technical implementation of transfer restrictions. It provides a clear conceptual bridge between the regulatory need for compliance and the specific Solidity functions that enable it.

Focus on the section that begins with "Transfer restrictions are smart contract functions". Please read the two paragraphs starting with the explanation of _beforeTokenTransfer and the subsequent paragraph on how whitelists are implemented using a mapping. This is the exact pattern we have built and are now about to test.

2. Setting Up for Transfer Tests

Our existing test setup from the previous lesson is a great start, but it needs to be extended to handle transfer scenarios. We need to simulate a realistic environment with multiple investors, some of whom are allowlisted and some who are not.

We can achieve this by creating a nested describe block specifically for transfer tests. This block will have its own beforeEach hook that builds upon the main setup.

Here's the plan for our test setup:

  1. Get More Accounts: In addition to owner, minter, and complianceAdmin, we'll define investor1, investor2, and unauthorizedUser.
  2. Prepare the State: Inside the beforeEach hook for our transfer tests, we will:
    • Grant the MINTER_ROLE to the minter account.
    • Use the minter account to mint 1000 tokens to investor1.
    • Grant the COMPLIANCE_ADMIN_ROLE to the complianceAdmin account.
    • Use the complianceAdmin account to add investor1 and investor2 to the allowlist.

This creates the perfect starting point: investor1 is an allowlisted user with tokens, investor2 is an allowlisted user with no tokens, and unauthorizedUser is neither allowlisted nor holds any tokens.

3. Testing the "Happy Path": A Compliant Transfer

The first test should validate the intended behavior: a successful transfer between two allowlisted investors. This is our "happy path". We follow the Arrange-Act-Assert pattern.

  • Arrange: Our beforeEach hook handles the arrangement. We have investor1 with 1000 tokens and investor2 with 0, both allowlisted.
  • Act: investor1 initiates a transfer of 100 tokens to investor2.
  • Assert: We verify that the balances have updated correctly and that a Transfer event was emitted.

Here is what the test code looks like:

describe("Transfers", function () {
    let investor1, investor2, unauthorizedUser;
    const mintAmount = ethers.utils.parseUnits("1000", 18);
    const transferAmount = ethers.utils.parseUnits("100", 18);

    beforeEach(async function () {
        // ... (get signers and assign to investor1, investor2, etc.)
        
        // Setup initial state: mint tokens to investor1
        await securityToken.connect(minter).mint(investor1.address, mintAmount);
        
        // Add investors to the allowlist
        await securityToken.connect(complianceAdmin).addToAllowlist(investor1.address);
        await securityToken.connect(complianceAdmin).addToAllowlist(investor2.address);
    });

    it("should allow a transfer between two allowlisted investors", async function () {
        // Act: investor1 transfers tokens to investor2
        await expect(
            securityToken.connect(investor1).transfer(investor2.address, transferAmount)
        ).to.emit(securityToken, "Transfer")
         .withArgs(investor1.address, investor2.address, transferAmount);

        // Assert: Check the final balances
        const finalBalance1 = await securityToken.balanceOf(investor1.address);
        const finalBalance2 = await securityToken.balanceOf(investor2.address);

        expect(finalBalance1).to.equal(mintAmount.sub(transferAmount));
        expect(finalBalance2).to.equal(transferAmount);
    });
});

Notice we are also testing for the Transfer event using .to.emit(...).withArgs(...). This confirms not only that the state changed correctly but also that our contract is logging its activity as expected.

4. Testing the "Sad Paths": Enforcing the Rules

Verifying that rules are enforced is arguably more important than testing the happy path. We must prove that the contract prevents non-compliant actions. We'll use revertedWith to test these scenarios.

Test Case 1: Sending to a Non-Allowlisted Address

What happens if an allowlisted investor tries to send tokens to someone who isn't on the list? The transaction must fail.

  • Arrange: investor1 is allowlisted and has tokens. unauthorizedUser is not.
  • Act: investor1 attempts to transfer tokens to unauthorizedUser.
  • Assert: The transaction reverts with our specific error message.
it("should prevent a transfer from an allowlisted investor to a non-allowlisted address", async function () {
    await expect(
        securityToken.connect(investor1).transfer(unauthorizedUser.address, transferAmount)
    ).to.be.revertedWith("Compliance: recipient not allowlisted");
});

Test Case 2: Sending from a Non-Allowlisted Address

Now, let's test the other side. What if an address that is not on the allowlist tries to send tokens? This should also be blocked. For this test, we need to imagine a scenario where unauthorizedUser somehow acquired tokens (perhaps they were sent tokens before the allowlist was enforced).

  • Arrange: We mint tokens to unauthorizedUser but do not add them to the allowlist. investor1 is on the allowlist.
  • Act: unauthorizedUser attempts to transfer tokens to investor1.
  • Assert: The transaction reverts.
it("should prevent a transfer from a non-allowlisted address", async function () {
    // We need a specific setup for this test case
    await securityToken.connect(minter).mint(unauthorizedUser.address, mintAmount);
    
    await expect(
        securityToken.connect(unauthorizedUser).transfer(investor1.address, transferAmount)
    ).to.be.revertedWith("Compliance: sender not allowlisted");
});

These tests provide strong evidence that our _beforeTokenTransfer hook is working exactly as designed, acting as a robust gatekeeper for all transfers.

The strategy of creating separate tests for success and failure scenarios is universal in smart contract testing. The following resource, while using a different testing framework (Foundry), perfectly illustrates this pattern.

Writing ERC-20 Tests in Solidity with Foundry

This article demonstrates testing ERC-20 transfers. Although the code is for Foundry, the testing logic and structure are highly relevant. It shows how to organize tests into distinct scenarios for success and failure.

Focus on the two code blocks under the heading "Token Transfer Tests". Notice the WhenAliceHasSufficientFunds contract. This is the "happy path," analogous to our test for a compliant transfer. See how it asserts correct balance changes. Next, look at the WhenAliceHasInsufficientFunds contract. This is a "sad path" test. It uses vm.expectRevert (Foundry's version of revertedWith) in the itRevertsTransfer function to confirm that the transfer fails as expected. This separation of concerns is a best practice we are following in our Hardhat tests.

When you've written these tests and run them with npx hardhat test, your terminal should display a satisfying list of passing checks, confirming your compliance logic is secure.

A successful test run in a Hardhat project, showing multiple checks for compliance logic passing. This is the goal of our testing efforts.

Conclusion

In this lesson, you have moved from testing simple administrative functions to verifying the core compliance logic of our SecurityToken. By simulating transfers between different types of users, you've written tests that prove your contract enforces its on-chain rules immutably.

Key Takeaways:

  • Testing compliance involves verifying both the "happy path" (allowed transfers) and multiple "sad paths" (disallowed transfers).
  • Test setups often require nesting (describe blocks) and specific beforeEach hooks to create the precise conditions needed for each scenario.
  • A successful transfer test should assert both the final state (balances) and the actions taken along the way (event emissions).
  • Using revertedWith is essential to confirm that your contract's security checks correctly prevent invalid transactions.

You have now rigorously tested the primary functions of your security token's lifecycle: issuance and transfer. In the next lesson, we will continue this process by testing the more advanced administrative capabilities, starting with the ability to freeze accounts and perform forced transfers for regulatory or recovery purposes.

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

Sign up