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.
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:
- Get More Accounts: In addition to
owner,minter, andcomplianceAdmin, we'll defineinvestor1,investor2, andunauthorizedUser. - Prepare the State: Inside the
beforeEachhook for our transfer tests, we will:- Grant the
MINTER_ROLEto theminteraccount. - Use the
minteraccount to mint 1000 tokens toinvestor1. - Grant the
COMPLIANCE_ADMIN_ROLEto thecomplianceAdminaccount. - Use the
complianceAdminaccount to addinvestor1andinvestor2to the allowlist.
- Grant the
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
beforeEachhook handles the arrangement. We haveinvestor1with 1000 tokens andinvestor2with 0, both allowlisted. - Act:
investor1initiates a transfer of 100 tokens toinvestor2. - Assert: We verify that the balances have updated correctly and that a
Transferevent 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:
investor1is allowlisted and has tokens.unauthorizedUseris not. - Act:
investor1attempts to transfer tokens tounauthorizedUser. - 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
unauthorizedUserbut do not add them to the allowlist.investor1is on the allowlist. - Act:
unauthorizedUserattempts to transfer tokens toinvestor1. - 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.

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 (
describeblocks) and specificbeforeEachhooks 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
revertedWithis 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.