Welcome back. In our previous lesson, you built a robust test suite for the administrative functions of the SecurityToken, ensuring that powerful tools like freezing accounts and forced transfers are both effective and strictly controlled. With the token's compliance and administrative features fully tested, we can now turn our attention to its core financial function: distributing profits to its holders.
This lesson focuses on testing the dividend distribution mechanism. For any tokenized security, the integrity of this process is paramount. Your background in investment management software provides a direct parallel; just as asset management systems must flawlessly calculate and allocate returns, our smart contract must do so with verifiable precision on-chain. We will write tests to confirm that every investor receives their exact pro-rata share of profits and that the system is secure against common financial exploits.
1. The Dividend Accounting Model Revisited
Before writing tests, let's briefly recap the dividend model you implemented in Module 9. We are using the "pull-over-push" pattern, where investors actively call a withdrawDividends function to claim their earnings. This is far more gas-efficient and secure than having the contract "push" dividends to thousands of holders.
The core of this model is an accounting system that tracks each investor's entitlement without iterating through all holders. A key article from ChainScore Labs provides a good overview of this industry-standard approach.
How to Implement Dividend Distribution for Tokenized Assets
This article from ChainScore Labs explains the common patterns for on-chain dividend distribution.
Please review the section that begins with a discussion of the "accrued-per-share" accounting system. Pay close attention to the roles of dividendsPerShare and creditsPerShare. This is the logic our tests will validate.
This O(1) complexity mechanism ensures the system can scale to any number of investors. The logic tracks a global totalDividendsPerShare value and, for each user, the creditsPerShare from their last interaction. An investor's claimable dividend is calculated based on the difference between these two values, multiplied by their token balance.
Let's visualize how this works over time with multiple deposits and withdrawals.

This diagram shows the crucial concept we need to test: an investor is only entitled to the dividends deposited while they hold the shares. Alice's first withdrawal correctly gives her 15% of the initial $1000 income. Her second withdrawal only gives her 15% of the new $1000 income that was added after her first withdrawal. Our tests must confirm this logic holds true under various scenarios.
2. Setting Up the Dividend Tests
In your securityToken.test.js file, add a new describe block for our dividend tests. The setup for these tests is slightly more complex than before. We need:
- The
SecurityTokencontract. - A separate ERC-20 token to act as the dividend currency (e.g., a mock USDC).
- Designated roles: a
dividendAdminto deposit dividends, and several investors.
First, you'll need a simple mock ERC-20 contract. In your contracts folder, create a file named MockUSDC.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockUSDC is ERC20 {
constructor() ERC20("Mock USDC", "mUSDC") {
_mint(msg.sender, 1_000_000 * 10**6); // Mint 1M mUSDC (6 decimals)
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
Note: Make sure to adjust the decimals if your dividend token uses a different precision. We'll use 6 for this example, like USDC.
Now, in your securityToken.test.js, we'll expand the beforeEach block to deploy this MockUSDC contract and set up the necessary roles and balances.
// At the top of your test file, with other variables
let mockUSDC, dividendAdmin;
const MOCK_USDC_DECIMALS = 6;
// Inside the main describe block for SecurityToken
describe("Dividend Distribution", function () {
// We can define a specific amount for deposits
const dividendDepositAmount = ethers.utils.parseUnits("10000", MOCK_USDC_DECIMALS);
beforeEach(async function () {
// ... (existing beforeEach setup)
// Add a new signer for the dividend admin
[, , , dividendAdmin] = await ethers.getSigners();
// Deploy the mock dividend token
const MockUSDC = await ethers.getContractFactory("MockUSDC");
mockUSDC = await MockUSDC.deploy();
// Grant the DIVIDEND_ADMIN_ROLE
const dividendAdminRole = await securityToken.DIVIDEND_ADMIN_ROLE();
await securityToken.connect(owner).grantRole(dividendAdminRole, dividendAdmin.address);
// Set the dividend token in the SecurityToken contract
await securityToken.connect(complianceAdmin).setDividendToken(mockUSDC.address);
// Mint some mock USDC to the dividendAdmin
await mockUSDC.mint(dividendAdmin.address, ethers.utils.parseUnits("100000", MOCK_USDC_DECIMALS));
});
// ... tests will go here
});
This setup ensures that for every test, we have a clean state with our contracts deployed, roles assigned, and dividend currency ready.
3. Testing Dividend Deposits and Withdrawals
Now we can write the tests. We'll cover access control, successful deposits, and various withdrawal scenarios.
Test Case 1: Access Control and Deposits
First, let's verify that only the dividendAdmin can deposit dividends and that a successful deposit updates the contract state correctly.
it("should only allow DIVIDEND_ADMIN to deposit dividends", async function () {
// An unauthorized user (investor1) attempts to deposit
await mockUSDC.connect(dividendAdmin).transfer(investor1.address, dividendDepositAmount);
await mockUSDC.connect(investor1).approve(securityToken.address, dividendDepositAmount);
const dividendAdminRole = await securityToken.DIVIDEND_ADMIN_ROLE();
await expect(
securityToken.connect(investor1).depositDividends(dividendDepositAmount)
).to.be.revertedWith(`AccessControl: account ${investor1.address.toLowerCase()} is missing role ${dividendAdminRole}`);
});
it("should handle a successful dividend deposit", async function () {
// dividendAdmin approves the contract to spend its mockUSDC
await mockUSDC.connect(dividendAdmin).approve(securityToken.address, dividendDepositAmount);
// The total supply of the security token is needed for the calculation
const totalSupply = await securityToken.totalSupply();
// The transaction should emit a DividendsDeposited event
await expect(securityToken.connect(dividendAdmin).depositDividends(dividendDepositAmount))
.to.emit(securityToken, "DividendsDeposited")
.withArgs(mockUSDC.address, dividendDepositAmount);
// The contract's mockUSDC balance should increase
expect(await mockUSDC.balanceOf(securityToken.address)).to.equal(dividendDepositAmount);
// The internal totalDividendsPerShare should be updated
// Note: The precision factor (1e18) is hardcoded in the contract for this calculation
const expectedDividendsPerShare = dividendDepositAmount.mul(ethers.utils.parseEther("1")).div(totalSupply);
expect(await securityToken.totalDividendsPerShare()).to.equal(expectedDividendsPerShare);
});
Test Case 2: Simple Withdrawal
Next, let's test the simplest withdrawal case: one investor claims their share after one deposit.
it("should allow an investor to withdraw their correct share of dividends", async function () {
// Initial setup: investor1 holds 10% of the total supply
const investor1Balance = await securityToken.balanceOf(investor1.address);
const totalSupply = await securityToken.totalSupply();
expect(investor1Balance).to.equal(totalSupply.div(10));
// Deposit dividends
await mockUSDC.connect(dividendAdmin).approve(securityToken.address, dividendDepositAmount);
await securityToken.connect(dividendAdmin).depositDividends(dividendDepositAmount);
// Expected dividend for investor1 is 10% of the deposit
const expectedDividend = dividendDepositAmount.div(10);
// Check claimable amount before withdrawal
expect(await securityToken.getClaimableDividends(investor1.address)).to.equal(expectedDividend);
// Act: investor1 withdraws
await expect(() =>
securityToken.connect(investor1).withdrawDividends()
).to.changeTokenBalances(mockUSDC, [investor1], [expectedDividend]);
// Assert: claimable amount is now zero
expect(await securityToken.getClaimableDividends(investor1.address)).to.equal(0);
});
The use of changeTokenBalances from the hardhat-chai-matchers library is extremely useful here, as it asserts the balance change in a single, readable line.
Test Case 3: Complex Withdrawal Scenario
This is the most important test. We'll replicate the logic from the diagram with Alice, involving multiple deposits and a transfer of shares. This verifies that the per-share accounting is working correctly.
it("should correctly calculate dividends after multiple deposits and a transfer", async function () {
// Initial state: investor1 has 200 tokens, investor2 has 0. Total supply is 1000.
// investor1 has 20% of the supply.
// Deposit 1: 10,000 mUSDC
await mockUSDC.connect(dividendAdmin).approve(securityToken.address, ethers.utils.parseUnits("10000", MOCK_USDC_DECIMALS));
await securityToken.connect(dividendAdmin).depositDividends(ethers.utils.parseUnits("10000", MOCK_USDC_DECIMALS));
// investor1's expected share: 20% of 10,000 = 2,000 mUSDC
const investor1Share1 = ethers.utils.parseUnits("2000", MOCK_USDC_DECIMALS);
expect(await securityToken.getClaimableDividends(investor1.address)).to.equal(investor1Share1);
// investor1 transfers half their shares (100 tokens) to investor2
const transferAmount = ethers.utils.parseUnits("100", 18);
await securityToken.connect(investor1).transfer(investor2.address, transferAmount);
// Now investor1 has 100 tokens (10%) and investor2 has 100 tokens (10%)
// The transfer should trigger an update. investor1's 2000 mUSDC is now "banked" as claimable.
// investor2 has no claim from the first deposit.
expect(await securityToken.getClaimableDividends(investor1.address)).to.equal(investor1Share1);
expect(await securityToken.getClaimableDividends(investor2.address)).to.equal(0);
// Deposit 2: 5,000 mUSDC
await mockUSDC.connect(dividendAdmin).approve(securityToken.address, ethers.utils.parseUnits("5000", MOCK_USDC_DECIMALS));
await securityToken.connect(dividendAdmin).depositDividends(ethers.utils.parseUnits("5000", MOCK_USDC_DECIMALS));
// From deposit 2, both investors get 10% each (500 mUSDC)
const shareFromDeposit2 = ethers.utils.parseUnits("500", MOCK_USDC_DECIMALS);
// Total expected for investor1 = 2000 (from D1) + 500 (from D2) = 2500 mUSDC
const investor1TotalShare = investor1Share1.add(shareFromDeposit2);
expect(await securityToken.getClaimableDividends(investor1.address)).to.equal(investor1TotalShare);
// Total expected for investor2 = 0 (from D1) + 500 (from D2) = 500 mUSDC
expect(await securityToken.getClaimableDividends(investor2.address)).to.equal(shareFromDeposit2);
// Withdrawals
await expect(() =>
securityToken.connect(investor1).withdrawDividends()
).to.changeTokenBalances(mockUSDC, [investor1], [investor1TotalShare]);
await expect(() =>
securityToken.connect(investor2).withdrawDividends()
).to.changeTokenBalances(mockUSDC, [investor2], [shareFromDeposit2]);
// Final claimable balances should be zero
expect(await securityToken.getClaimableDividends(investor1.address)).to.equal(0);
expect(await securityToken.getClaimableDividends(investor2.address)).to.equal(0);
});
This test might look complex, but it's a direct translation of the financial logic into code. Each step verifies that the contract's state matches our expected calculations, ensuring the integrity of the system. This process of setting up a scenario, acting, and asserting is the essence of unit testing.
The image below shows a real-world example of a developer modifying a contract's withdrawal logic and its corresponding test in the same commit. This tight feedback loop between implementation and testing is a hallmark of professional smart contract development.
4. Exploring Real-World Implementations
For a deeper dive, you can explore how production-grade dividend contracts are tested in the wild. The indexed-finance/dividends repository is an excellent example of a real-world, open-source dividend distribution system.
indexed-finance/dividends: Solidity contracts for distribution ... - GitHub
This repository contains a suite of contracts for pro-rata dividend distribution. It's a great example of a professional, tested implementation.
First, look at the repository file structure. Notice the test directory, which contains all the unit and integration tests. Next, scroll down to the "Scripts" section and find the description for yarn test. This shows how the project's test suite is executed. Feel free to browse the files inside the test directory on your own to see the complexity and thoroughness of professional tests for financial contracts.
Conclusion
In this lesson, you have written a comprehensive set of tests for the dividend distribution functionality of your security token. You've gone beyond simple checks to validate the complex accounting logic that ensures fairness and accuracy, a process that closely mirrors data validation and reconciliation in traditional financial systems.
Key Takeaways:
- Testing dividend distribution requires validating access control, correct balance changes, and the intricate pro-rata accounting logic.
- Complex scenarios involving multiple users, multiple deposits, and token transfers are essential for ensuring the accounting mechanism (
creditsPerShare) is implemented correctly. - Writing tests step-by-step to mirror a financial timeline is an effective way to isolate and verify each part of the logic.
- Tools like
hardhat-chai-matcherssimplify assertions about token balance changes, making tests cleaner and more readable.
You have now built and thoroughly tested a complete security token, from issuance and compliance to dividend payouts. The contract is robust and ready for interaction. In our next module, "DApp Integration and Automation," we will shift our focus from on-chain logic to off-chain interaction. You will learn how to build Node.js scripts that connect to your deployed contract to automate tasks and query data, bridging the gap between your smart contract and the wider world of applications.