Skip to main content
Create your own

Simulating Multi-User Transactions and Access Control

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.

A terminal displaying the output of a Hardhat test suite, with the corresponding test file open in a code editor. Such tests are crucial for verifying contract behavior, including multi-user interactions.

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.

  1. Modify Box.sol. We will add an owner variable, set it to the deployer's address in the constructor, and add a function to transfer ownership. The store function will now require the caller to be the owner.

    Update your contracts/Box.sol file with the following code. Note the new owner variable, the constructor, the require statement in store, and the new transferOwnership function.

    // 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.

  2. Update Box.test.ts. Now, let's write tests for this new logic. We'll create a new describe block for "Ownership and Access Control". Replace the content of your test/Box.test.ts file with this comprehensive test suite.

    Read through the code carefully. Notice how we get owner and otherAccount from ethers.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);
        });
      });
    });
    
  3. Run your tests. Execute npx hardhat test in 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.

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

Sign up