Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Testing Token Allowances

Hello! Welcome back to our journey in building your own cryptocurrency token.

In our last lesson, we laid the foundation for professional-grade smart contract development by writing our first unit tests for the balanceOf and transfer functions. You learned how to use Hardhat, ethers.js, and Chai to create an automated testing suite that verifies the core behavior of our MyToken contract.

Today, we're moving on to a slightly more complex but fundamental concept in the ERC-20 standard: delegated transfers. We will learn how to test the approve and transferFrom functions. This two-step mechanism is what allows other smart contracts, like decentralized exchanges (DEXs) or the staking contracts we'll build later, to move tokens on a user's behalf. Mastering this is essential for your token to integrate with the wider DeFi ecosystem.

By the end of this 60-minute lesson, you will be able to write and execute tests for the approve and transferFrom mechanism using multiple simulated accounts to ensure your token is ready for interaction with other dApps.


1. Understanding the Delegated Transfer Flow

Before we write any code, let's understand the process. A direct transfer is simple: Alice sends tokens to Bob. A delegated transfer, however, involves three parties:

  • Token Owner: The user who owns the tokens (e.g., addr1).
  • Spender: The user or contract who is given permission to spend the tokens (e.g., addr2 or a Uniswap router).
  • Token Contract: The ERC-20 contract that enforces the rules.

The process happens in two main steps: Approval and Transfer.

ERC-20 Approve and TransferFrom Mechanism
This diagram illustrates the two-step process. First, the token owner approves a spender for a certain amount. Second, the spender uses `transferFrom` to execute the transfer on the owner's behalf.
  1. approve(spender, amount): The Token Owner calls this function. It tells the token contract, "I authorize the spender to withdraw up to this amount of my tokens." The contract then records this permission, which is called an allowance.

  2. transferFrom(owner, recipient, amount): The Spender calls this function. It tells the contract, "I want to use my allowance to move amount tokens from the owner's balance to the recipient's balance."

The contract then verifies:

  • Does the spender have a sufficient allowance from the owner?
  • Does the owner have enough tokens to cover the amount?

If both are true, the transfer happens, and the spender's allowance is reduced by the amount transferred. Given your backend experience, you can think of the approve function as creating a scoped, limited-use permission for another service to act on a user's behalf, much like an OAuth token with specific permissions.


2. Setting Up the Test Scenario

We'll continue working in your test/MyToken.test.ts file. Our tests will simulate a realistic scenario with multiple users. Let's add a new describe block called "Allowances and Delegated Transfers" to keep our new tests organized.

First, let's look at a clear, practical guide for testing this exact flow.

Building and Testing Your First ERC20 Token: A Hands-On ...

The article "Building and Testing Your First ERC20 Token" provides a concise and practical example of how to structure these tests. We will follow its logic closely.

Please read through the 'Testing' section of the article. Focus on the it('Transfer tests', ...) block. Notice how it first uses .connect() to have user3 approve user1, then checks the allowance, and finally has user1 execute the transferFrom.

3. Testing approve and allowance

Our first test will verify that a user can correctly approve another user and that the contract's allowance function reports the correct value.

Add the following code inside your main describe("MyToken contract", ...) block, after the "Transactions" block. Remember that the beforeEach hook you wrote in the last lesson will provide us with a fresh contract and accounts (owner, addr1, addr2) for each test.

// test/MyToken.test.ts

  // ... (keep the existing beforeEach, Deployment, and Transactions describe blocks)

  describe("Allowances and Delegated Transfers", function () {
    it("Should approve another account to spend tokens", async function () {
      const amountToApprove = 100;
      
      // The owner approves addr1 to spend 100 tokens on their behalf.
      // Note: we are not using .connect() here, so the call is from `owner`.
      await myToken.approve(await addr1.getAddress(), amountToApprove);
      
      // Check the allowance that addr1 has from the owner.
      const allowance = await myToken.allowance(await owner.getAddress(), await addr1.getAddress());
      
      // Assert that the allowance is set correctly.
      expect(allowance).to.equal(amountToApprove);
    });
  });

Let's break down this test:

  1. We call myToken.approve(...) from the default owner account. This sets an allowance for addr1.
  2. We then call myToken.allowance(owner, spender) to read the permission we just set from the contract's state.
  3. Finally, we use expect to assert that the returned allowance matches the amountToApprove.

Run the test to confirm it passes:

npx hardhat test

You should see your new test pass along with the previous ones.


4. Testing transferFrom

Now for the second part of the flow: the spender uses their allowance. A complete test needs to check three things after a transferFrom call:

  1. The token owner's balance has decreased.
  2. The recipient's balance has increased.
  3. The spender's allowance has been reduced.

Let's add a new test case inside the describe("Allowances and Delegated Transfers", ...) block.

// test/MyToken.test.ts (inside the "Allowances..." describe block)

    it("Should handle delegated token transfers (transferFrom)", async function () {
      const initialOwnerBalance = await myToken.balanceOf(await owner.getAddress());
      const amountToApprove = 200;
      const amountToTransfer = 50;

      // 1. Owner approves addr1 to spend 200 tokens
      await myToken.approve(await addr1.getAddress(), amountToApprove);

      // 2. addr1 (the spender) transfers 50 tokens from owner to addr2 (the recipient)
      await myToken.connect(addr1).transferFrom(
        await owner.getAddress(),
        await addr2.getAddress(),
        amountToTransfer
      );

      // 3. Check the balances
      const finalOwnerBalance = await myToken.balanceOf(await owner.getAddress());
      expect(finalOwnerBalance).to.equal(initialOwnerBalance.sub(amountToTransfer));

      const addr2Balance = await myToken.balanceOf(await addr2.getAddress());
      expect(addr2Balance).to.equal(amountToTransfer);

      // 4. Check the remaining allowance
      const remainingAllowance = await myToken.allowance(await owner.getAddress(), await addr1.getAddress());
      expect(remainingAllowance).to.equal(amountToApprove - amountToTransfer);
    });

Here's the play-by-play:

  1. Approve: The owner approves addr1 to spend 200 tokens.
  2. TransferFrom: We use myToken.connect(addr1) to switch the context to the spender. addr1 then calls transferFrom, moving 50 tokens from owner to addr2.
  3. Check Balances: We assert that the owner's balance has decreased and addr2's balance has increased correctly. We use the .sub() method for BigNumber arithmetic, just like in the previous lesson's exercise.
  4. Check Allowance: This is a crucial step. We verify that addr1's allowance from owner has been correctly reduced from 200 to 150.

Run your tests again. Everything should pass, confirming your token's delegated transfer logic is working perfectly.

Test your understanding!

What happens if a spender tries to transferFrom an amount greater than their allowance? For example, if addr1 has an allowance of 100 tokens from owner but tries to transfer 101 tokens.

Add a new test case to explore this. You don't need to make it pass yet; just write the code and observe the error message Hardhat gives you when you run npx hardhat test. This will give you a preview of what we'll cover in the next lesson.

Show answer

Here is the test case you could write.

it("Should fail if spender tries to transfer more than their allowance", async function () {
  const amountToApprove = 100;
  
  // Owner approves addr1 for 100 tokens
  await myToken.approve(await addr1.getAddress(), amountToApprove);

  // addr1 tries to transfer 101 tokens from owner to addr2
  // This transaction is expected to fail!
  await myToken.connect(addr1).transferFrom(
    await owner.getAddress(),
    await addr2.getAddress(),
    amountToApprove + 1
  );
});

When you run this, the test will fail, and Hardhat will show an error message similar to: Error: transaction reverted: ERC20: insufficient allowance. This is the contract correctly enforcing its rules. In our next lesson, we will learn how to write a test that expects this failure, turning this failing test into a passing one.


Conclusion

Congratulations! You have now successfully written tests for the complete set of core ERC-20 transfer functionalities. By simulating interactions between multiple accounts, you've verified the critical approve and transferFrom mechanism, ensuring your token can be safely used by other smart contracts and dApps.

Key Takeaways:

  • The approve and transferFrom functions enable delegated transfers, a cornerstone of the ERC-20 standard and DeFi interoperability.
  • Testing this flow requires simulating at least three roles: token owner, spender, and recipient.
  • A robust test for transferFrom must verify three outcomes: the owner's balance change, the recipient's balance change, and the reduction in the spender's allowance.
  • Using contract.connect(signer) is the standard way to execute transactions from different accounts in your tests.

Preview of the Next Lesson:
As you saw in the exercise, contracts can and should fail when rules are broken. A secure contract is one that predictably rejects invalid actions. In the next lesson, we will learn how to formally test for these failures. You'll write tests that verify your contract correctly reverts transactions with specific error messages for invalid inputs or conditions, a crucial skill for security-focused development.

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

Sign up