Recommended resources
The freely available resources this lesson will direct you to.
- Deposit and withdraw ERC20 tokens (USDT, SHIB, MATIC) from a Solidity contract (Hardhat, Ethers.js)www.youtube.com
- Hardhat Testing Tutorial | Solidity Smart Contract Testing Developer | Hardhat Testing Coursewww.youtube.com
- Building and Testing Your First ERC20 Token: A Hands-On ...blog.blockmagnates.com
- End-to-end guide to creating an ERC-20 token-specific DAppwww.albertobas.com
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.,
addr2or a Uniswap router). - Token Contract: The ERC-20 contract that enforces the rules.
The process happens in two main steps: Approval and Transfer.
-
approve(spender, amount): The Token Owner calls this function. It tells the token contract, "I authorize thespenderto withdraw up to thisamountof my tokens." The contract then records this permission, which is called an allowance. -
transferFrom(owner, recipient, amount): The Spender calls this function. It tells the contract, "I want to use my allowance to moveamounttokens from theowner's balance to therecipient's balance."
The contract then verifies:
- Does the
spenderhave a sufficientallowancefrom theowner? - Does the
ownerhave enough tokens to cover theamount?
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:
- We call
myToken.approve(...)from the defaultowneraccount. This sets an allowance foraddr1. - We then call
myToken.allowance(owner, spender)to read the permission we just set from the contract's state. - Finally, we use
expectto assert that the returnedallowancematches theamountToApprove.
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:
- The token owner's balance has decreased.
- The recipient's balance has increased.
- 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:
- Approve: The
ownerapprovesaddr1to spend200tokens. - TransferFrom: We use
myToken.connect(addr1)to switch the context to the spender.addr1then callstransferFrom, moving50tokens fromownertoaddr2. - Check Balances: We assert that the
owner's balance has decreased andaddr2's balance has increased correctly. We use the.sub()method forBigNumberarithmetic, just like in the previous lesson's exercise. - Check Allowance: This is a crucial step. We verify that
addr1's allowance fromownerhas been correctly reduced from200to150.
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
approveandtransferFromfunctions 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
transferFrommust 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.