Welcome to the next lesson in our journey through smart contract testing. So far, you've learned to verify a contract's state, ensure it fails correctly when it should, and confirm that it communicates its actions through events. These tests have primarily operated from a single perspective: the account that deploys the contract.
In the world of finance and asset management, systems rarely involve just one actor. You have issuers, investors, custodians, and regulators, each with distinct roles and permissions. Smart contracts for tokenized money are no different. They are multi-user systems by nature. This lesson tackles how to test these complex interactions by simulating transactions from different accounts. We will cover how to test your contract's access control logic, ensuring that only authorized users can perform sensitive actions.
By the end of this lesson, you will be able to simulate transactions from multiple accounts to rigorously test your contract's permissions and multi-user workflows. This is a critical skill for building secure and reliable systems for tokenized assets.
1. The Cast of Characters: Getting Multiple Accounts
To test a multi-user system, you need multiple users. The Hardhat Network provides a set of virtual accounts, or Signers in ethers.js terminology, for exactly this purpose. A Signer is an object that represents an Ethereum account and can be used to sign and send transactions.
You can access these accounts using the ethers.getSigners() function. It returns a promise that resolves to an array of all available signers. Conventionally, the first account in this array is the one that deploys the contract, so we often label it owner or deployer. The subsequent accounts can represent other users.
You typically retrieve these accounts at the beginning of your test file or within a describe block, like this:
const [owner, addr1, addr2] = await ethers.getSigners();
This line uses JavaScript's array destructuring to conveniently assign the first three signers to the variables owner, addr1, and addr2. For the tokenization use cases you're interested in, you might use more descriptive names like issuer, investor1, and complianceAdmin.
2. Changing Hats: The .connect() Method
By default, any transaction you send from your contract instance in a test is initiated by the first signer (owner). To simulate a transaction from a different user, you need to explicitly "connect" the contract instance to another signer.
This is done using the .connect() method. It takes a Signer object as an argument and returns a new contract instance that is connected to that signer. All subsequent calls on this new instance will originate from that account.
The official Hardhat documentation has a clear and concise guide on this. Please read the following sections to see how to get multiple signers and use .connect().
5. Testing contracts | Ethereum development environment ...
This part of the Hardhat tutorial explains the fundamental concepts of getting accounts and simulating transactions from different users.
First, review the explanation of ethers.getSigners() under the "Writing tests" heading. You can find it by searching for this paragraph. Next, read the section Using a different account. This is the core of today's lesson. It shows the syntax contract.connect(signer).function() and provides a clear example of transferring tokens between owner, addr1, and addr2.
As you can see, the pattern is straightforward: contract.connect(addr1).transfer(...). This simple but powerful technique is the key to testing all multi-user logic.
3. Your Turn: Testing Access Control
Let's apply this to our Box.sol contract. We'll add simple ownership and access control, then write tests to verify it.
-
Modify
Box.sol. We will add anownervariable, set it to the deployer's address in theconstructor, and add a function to transfer ownership. Thestorefunction will now require the caller to be the owner.Update your
contracts/Box.solfile with the following code. Note the newownervariable, theconstructor, therequirestatement instore, and the newtransferOwnershipfunction.// contracts/Box.sol // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; contract Box { uint256 private value; address public owner; event ValueChanged(uint256 newValue); event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); constructor() { owner = msg.sender; } function store(uint256 newValue) public { require(msg.sender == owner, "Caller is not the owner"); require(newValue != 0, "Value cannot be zero"); value = newValue; emit ValueChanged(newValue); } function retrieve() public view returns (uint256) { return value; } function transferOwnership(address newOwner) public { require(msg.sender == owner, "Caller is not the owner"); require(newOwner != address(0), "New owner is the zero address"); address oldOwner = owner; owner = newOwner; emit OwnershipTransferred(oldOwner, newOwner); } }After saving, re-compile your contract with
npx hardhat compile. -
Update
Box.test.ts. Now, let's write tests for this new logic. We'll create a newdescribeblock for "Ownership and Access Control". Replace the content of yourtest/Box.test.tsfile with this comprehensive test suite.Read through the code carefully. Notice how we get
ownerandotherAccountfromethers.getSigners()and how.connect(otherAccount)is used to test the access control rules.// test/Box.test.ts import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; import { expect } from "chai"; import { ethers } from "hardhat"; describe("Box", function () { // We define a fixture to reuse the same setup in every test. async function deployBoxFixture() { // Contracts are deployed using the first signer/account by default const [owner, otherAccount] = await ethers.getSigners(); const Box = await ethers.getContractFactory("Box"); const box = await Box.deploy(); return { box, owner, otherAccount }; } describe("Deployment", function () { it("Should set the right owner", async function () { const { box, owner } = await loadFixture(deployBoxFixture); expect(await box.owner()).to.equal(owner.address); }); }); describe("Ownership and Access Control", function () { it("Should allow the owner to store a value", async function () { const { box } = await loadFixture(deployBoxFixture); const testValue = 42; await expect(box.store(testValue)).to.not.be.reverted; expect(await box.retrieve()).to.equal(testValue); }); it("Should revert if a non-owner tries to store a value", async function () { const { box, otherAccount } = await loadFixture(deployBoxFixture); const testValue = 42; await expect(box.connect(otherAccount).store(testValue)) .to.be.revertedWith("Caller is not the owner"); }); it("Should allow the owner to transfer ownership", async function () { const { box, owner, otherAccount } = await loadFixture(deployBoxFixture); // Transfer ownership from owner to otherAccount await expect(box.transferOwnership(otherAccount.address)) .to.emit(box, "OwnershipTransferred") .withArgs(owner.address, otherAccount.address); expect(await box.owner()).to.equal(otherAccount.address); }); it("Should prevent the old owner from using owner-only functions", async function () { const { box, otherAccount } = await loadFixture(deployBoxFixture); // Transfer ownership await box.transferOwnership(otherAccount.address); // Try to call store() from the old owner's account await expect(box.store(100)) .to.be.revertedWith("Caller is not the owner"); }); it("Should allow the new owner to use owner-only functions", async function () { const { box, otherAccount } = await loadFixture(deployBoxFixture); // Transfer ownership await box.transferOwnership(otherAccount.address); const testValue = 123; // Try to call store() from the new owner's account await expect(box.connect(otherAccount).store(testValue)).to.not.be.reverted; expect(await box.retrieve()).to.equal(testValue); }); }); }); -
Run your tests. Execute
npx hardhat testin your terminal. All tests should pass, confirming your access control logic works as expected.
4. A More Complex Example: KYC Contract
The Box contract is simple. In a real-world financial application, you'd have more granular roles, like an operator who can whitelist investors. The following article provides an excellent example of testing a KYC (Know Your Customer) contract with owner and operator roles. This should resonate with the compliance aspects of tokenized securities.
How to test Smart Contracts. 7th Episode — Utilizing Mocha and Chai
This article provides a practical guide to testing role-based access control.
First, read the section on Using different ethereum accounts. It summarizes the .connect() method again. Next, study the code examples in the section Unit testing the KYC Contract. Pay close attention to how the tests are structured: A test confirms an unauthorizedOperator cannot call setAuthorizedOperator. The next line shows the owner can call it successfully. Later, tests show an unauthorizedOperator cannot whitelist an investor, while the correctly authorized operator can. This pattern of testing both failure and success cases for different roles is a best practice.
The video below also provides a full walkthrough of building and testing a contract with access control. Watching the instructor code this can help solidify your understanding.
Hardhat Testing Tutorial | Solidity Smart Contract Testing Developer | Hardhat Testing Course
This segment from the "Hardhat Testing Tutorial" by Daulat Hussain demonstrates testing owner-only functionality.
Watch from this section where the instructor writes a test to ensure only the owner can withdraw funds. The key steps are: Getting the otherAccount signer from the test setup. Advancing the blockchain's time to satisfy the time-lock condition (a useful technique we'll revisit). Executing the core assertion: await expect(myTest.connect(otherAccount).withdraw()).to.be.revertedWith(...). This is the exact pattern you used for the Box contract.
Conclusion
You have now added a critical tool to your testing arsenal: the ability to simulate interactions from multiple perspectives. This moves you from testing a contract in isolation to testing it as a dynamic, multi-user system.
Here are the key takeaways:
ethers.getSigners()provides an array of test accounts (signers) for your use.- By default, all contract interactions in tests are from the first signer (
owner). - The
.connect(signer)method allows you to execute a contract function from the perspective of any account. - This technique is essential for verifying access control logic, ensuring that functions are protected and only callable by authorized roles.
This was the final major concept in our testing module. The skills you've built—testing state, reverts, events, and now multi-user access—form the bedrock of secure smart contract development.
In the next lesson, we will cover one last, but very practical, topic in this module: using console.log from within your Solidity code to help you debug during development.