Fungible Token: Burn on Transfer

Fungible Token: Burn on Transfer

Wednesday Feb 21, 2024

Fungible Token with burning on transfer

In this course we will improve the ERC20 token we created in the Fungible Token Burning course.

I'm not going to go over all the burning mechanism details in this course as we have covered it in quite some detail in the previous course linked above.

Goal

  • Improve our ERC20 token to include a burning mechanism on transfer. This means that every time a transfer is made, a percentage of the tokens will be burned.
  • Implement an additional burn function the owner can use to manually burn tokens from their wallet. When the owner buys back tokens from the market, they can burn them to increase the value of the token.

This will create a hyper-deflationary token that will increase in value over time according to the tokenomics.

Setting up our project

Before we start, we need to create a few files.

In the contracts folder:

  1. Create a new file called ERC20BurnOnTransfer.sol.

In the test folder:

  1. Create a new file called ERC20BurnOnTransfer.ts.

Writing our smart contract

In the ERC20BurnOnTransfer.sol file, add the following code and read the comments:

1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.18;
3
4import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
5import "@openzeppelin/contracts/access/Ownable.sol";
6
7contract ERC20TokenBurnOnTransfer is ERC20, Ownable {
8
9  uint8 private _burnPercentage;
10  mapping (address => bool) private _excludedFromBurn;
11
12  /**
13   * Our constructor.
14   * @param owner The owner of the contract
15   * @param name The name of the token
16   * @param symbol The symbol of the token
17   * @param totalSupply The total supply of the token
18   * @param burnPercentage_ The percentage of tokens to burn on transfer (0-100)
19   */
20  constructor(address owner, string memory name, string memory symbol, uint256 totalSupply, uint8 burnPercentage_) ERC20(name, symbol) {
21    require(burnPercentage_ >= 0 && burnPercentage_ <= 100, "ERC20TokenBurn: constructor: invalid burn percentage");
22    _mint(owner, totalSupply);
23    _burnPercentage = burnPercentage_;
24  }
25
26  /**
27   * We include a burn function for the owner to have the ability to buy back tokens and burn them for
28   * even more heat!!! 🔥🔥🔥
29   * @param amount The amount of tokens to burn
30   */
31  function burn(uint256 amount) public onlyOwner {
32    _burn(msg.sender, amount);
33  }
34
35  /**
36   * Here we override the transfer function to include the burn on transfer functionality.
37   * @param recipient The address of the recipient
38   * @param amount The amount of tokens to transfer
39   */
40  function transfer(address recipient, uint256 amount) public override returns (bool) {
41    return _burnTransfer(msg.sender, recipient, amount);
42  }
43
44  /**
45   * Here we override the transferFrom function to include the burn on transfer functionality.
46   * @param sender The address of the sender
47   * @param recipient The address of the recipient
48   * @param amount The amount of tokens to transfer
49   */
50  function transferFrom(address sender, address recipient, uint256 amount) public override returns (bool) {
51    _burnTransfer(sender, recipient, amount);
52    _approve(sender, _msgSender(), allowance(sender, _msgSender()) - amount);
53    return true;
54  }
55
56  /**
57   * This function allows the owner to set the burn percentage.
58   * @param burnPercentage_ The percentage of tokens to burn on transfer (0-100)
59   */
60  function setBurnPercentage(uint8 burnPercentage_) public onlyOwner {
61    require(burnPercentage_ >= 0 && burnPercentage_ <= 100, "ERC20TokenBurn: setBurnPercentage: invalid burn percentage");
62    _burnPercentage = burnPercentage_;
63  }
64
65  /**
66   * This function allows the owner to set the excluded from burn addresses.
67   * @dev This is useful for exchanges and other contracts that need to transfer tokens.
68   * @param _address The address to exclude from burning
69   * @param _excluded Whether or not to exclude the address from burning
70   */
71  function setExcludedFromBurn(address _address, bool _excluded) public onlyOwner {
72    _excludedFromBurn[_address] = _excluded;
73  }
74
75  /**
76   * This is our internal burn transfer function.
77   * We use this function to check if the sender or recipient is excluded from burning.
78   * If they are excluded, we simply transfer the amount without burning.
79   * If they are not excluded, we transfer the amount after burning the percentage of tokens.
80   *
81   * @param sender The address of the sender
82   * @param recipient The address of the recipient
83   * @param amount The amount of tokens to transfer
84   */
85  function _burnTransfer(address sender, address recipient, uint256 amount) internal returns (bool) {
86    // Exclude addresses from burning
87    if (_excludedFromBurn[sender] || _excludedFromBurn[recipient]) {
88      _transfer(sender, recipient, amount);
89      return true;
90    }
91
92    uint256 burnAmount = (amount * _burnPercentage) / 100;
93    uint256 amountAfterBurn = amount - burnAmount;
94    _burn(sender, burnAmount); // Burn the amount of tokens
95    _transfer(sender, recipient, amountAfterBurn); // Transfer amount after burn
96    return true;
97  }
98}

We are doing a whole lot of things here, some of which we have covered in previous courses, so let's look at the notable things we are doing here:

  1. We have created two new variables. _burnPercentage and _excludedFromBurn. The _burnPercentage variable is the percentage of tokens to burn on transfer. The _excludedFromBurn variable is a mapping of addresses that are excluded from burning.

We HAVE to include an exclusion list as we don't want to burn tokens when we transfer tokens to exchanges or other contracts. This will cause a lot of problems. When we transfer tokens TO exchanges, we want to transfer the full amount of tokens. We don't want to burn any tokens.

  1. We created a way for the owner of the project to change the percentage of tokens to burn on transfer.

  2. We created a way for the owner of the project to exclude addresses from burning.

  3. We also overrided the transfer and transferFrom functions to include the burning mechanism.

  4. Finally, we created a new internal function called _burnTransfer. This function is used to check if the sender or recipient is excluded from burning. If they are excluded, we simply transfer the amount without burning. If they are not excluded, we transfer the amount after burning the percentage of tokens.

Writing our tests

In the ERC20BurnOnTransfer.ts file, add the following code:

1import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";
2import { expect } from "chai";
3import { ethers } from "hardhat";
4
5describe("ERC20-burn-on-transfer", function () {
6  // We deploy the contract and mint 100 tokens to the owner
7  async function deployERC20BurnOnTransferFixture() {
8    const [owner, account_1, account_2] = await ethers.getSigners();
9    const ERC20BurnOnTransfer = await ethers.getContractFactory(
10      "ERC20TokenBurnOnTransfer"
11    );
12    const erc20BurnOnTransfer = await ERC20BurnOnTransfer.deploy(
13      owner.address,
14      "Test Token",
15      "TST",
16      ethers.parseEther("100"),
17      BigInt("2")
18    );
19    return { erc20BurnOnTransfer, owner, account_1, account_2 };
20  }
21
22  // We send 10 tokens to account_1
23  async function setupAccount1() {
24    const { erc20BurnOnTransfer, owner, account_1, account_2 } =
25      await loadFixture(deployERC20BurnOnTransferFixture);
26    await erc20BurnOnTransfer.setExcludedFromBurn(owner.address, true);
27    await erc20BurnOnTransfer.transfer(
28      account_1.address,
29      ethers.parseEther("10")
30    );
31    return { erc20BurnOnTransfer, owner, account_1, account_2 };
32  }
33
34  describe("Deployment Tests", function () {
35    it("Should set the right owner", async function () {
36      const { erc20BurnOnTransfer, owner } = await loadFixture(
37        deployERC20BurnOnTransferFixture
38      );
39      expect(await erc20BurnOnTransfer.owner()).to.equal(owner.address);
40    });
41
42    it("Should mint 100 tokens to the owner", async function () {
43      const { erc20BurnOnTransfer, owner } = await loadFixture(
44        deployERC20BurnOnTransferFixture
45      );
46      expect(
47        (await erc20BurnOnTransfer.balanceOf(owner.address)).toString()
48      ).to.equal(ethers.parseEther("100"));
49    });
50  });
51
52  describe("Test Burn Function", function () {
53    it("Should burn 10 tokens from the owner", async function () {
54      const { erc20BurnOnTransfer, owner } = await loadFixture(
55        deployERC20BurnOnTransferFixture
56      );
57      await erc20BurnOnTransfer.burn(ethers.parseEther("10"));
58      expect(
59        (await erc20BurnOnTransfer.balanceOf(owner.address)).toString()
60      ).to.equal(ethers.parseEther("90"));
61    });
62
63    it("Should fail to burn 10 tokens from non-owner", async function () {
64      const { erc20BurnOnTransfer, account_1 } = await loadFixture(
65        setupAccount1
66      );
67      await expect(
68        erc20BurnOnTransfer.connect(account_1).burn(ethers.parseEther("10"))
69      ).to.be.rejectedWith("Ownable: caller is not the owner");
70    });
71  });
72
73  describe("Test burn on transfer (Default 2%)", function () {
74    it("Should transfer tokens from account_1 to account_2 with burn", async function () {
75      const { erc20BurnOnTransfer, account_1, account_2 } = await loadFixture(
76        setupAccount1
77      );
78      await erc20BurnOnTransfer
79        .connect(account_1)
80        .transfer(account_2.address, ethers.parseEther("10"));
81      // Burn amount is 2%, so account_2 should receive 9.8 tokens
82      expect(
83        (await erc20BurnOnTransfer.balanceOf(account_2.address)).toString()
84      ).to.equal(ethers.parseEther("9.8"));
85      expect((await erc20BurnOnTransfer.totalSupply()).toString()).to.equal(
86        ethers.parseEther("99.8")
87      );
88    });
89
90    it("Should exclude from burn if account_1 is excluded", async function () {
91      const { erc20BurnOnTransfer, account_1, account_2 } = await loadFixture(
92        setupAccount1
93      );
94      await erc20BurnOnTransfer.setExcludedFromBurn(account_1.address, true);
95      await erc20BurnOnTransfer
96        .connect(account_1)
97        .transfer(account_2.address, ethers.parseEther("10"));
98      // Account 1 is excluded, so account_2 should receive 10 tokens
99      expect(
100        (await erc20BurnOnTransfer.balanceOf(account_2.address)).toString()
101      ).to.equal(ethers.parseEther("10"));
102      expect((await erc20BurnOnTransfer.totalSupply()).toString()).to.equal(
103        ethers.parseEther("100")
104      );
105    });
106
107    it("Should burn the correct amount if the percentage is changed", async function () {
108      const { erc20BurnOnTransfer, account_1, account_2 } = await loadFixture(
109        setupAccount1
110      );
111      await erc20BurnOnTransfer.setBurnPercentage("5");
112      await erc20BurnOnTransfer
113        .connect(account_1)
114        .transfer(account_2.address, ethers.parseEther("10"));
115      // Burn percentage is 5%, so account_2 should receive 9.5 tokens and 0.5 tokens should be burned
116      expect(
117        (await erc20BurnOnTransfer.balanceOf(account_2.address)).toString()
118      ).to.equal(ethers.parseEther("9.5"));
119      expect((await erc20BurnOnTransfer.totalSupply()).toString()).to.equal(
120        ethers.parseEther("99.5")
121      );
122    });
123  });
124});

Since we implemented quite a few new features, our tests will grow quite a bit. Let's look at what we are doing here:

  1. We are testing the deployment of the contract and making sure the owner is set correctly and that the owner has 100 tokens.

  2. We are testing the burn function and making sure that the owner can burn tokens, while non-owners can not.

  3. We are testing the burn on transfer functionality. We are making sure that when we transfer tokens from one account to another, the correct amount of tokens are burned and the correct amount of tokens are transferred.

  4. We are testing the exclusion functionality. We are making sure that when we exclude an address from burning, the correct amount of tokens are transferred and no tokens are burned.

  5. Finally, we are testing the burn percentage functionality. We are making sure that when we change the burn percentage, the correct amount of tokens are burned and the correct amount of tokens are transferred.

Compiling and running our smart contracts

To run our tests and make sure everything works, run the following command in your terminal:

1npx hardhat test

And that is it! We have successfully created a hyper-deflationary ERC20 token with a burning mechanism! BRING THE HEAT! 🔥🔥🔥

Let's deploy our contract to the test network.

Deploying our smart contract

To deploy our smart contract, we need to create a deployment script. In the scripts folder, create a new file called deploy-erc20BurnOnTransfer.ts and add the following code:

1// We require the Hardhat Runtime Environment explicitly here. This is optional
2// but useful for running the script in a standalone fashion through `node <script>`.
3//
4// You can also run a script with `npx hardhat run <script>`. If you do that, Hardhat
5// will compile your contracts, add the Hardhat Runtime Environment's members to the
6// global scope, and execute the script.
7import hre from "hardhat";
8
9const config = {
10  owner_address: "your address here",
11  token_name: "Test Token",
12  token_symbol: "TT",
13  total_supply: "100000000000000000000", // 100 tokens (18 decimal places)
14  burn_percentage: "2",
15}
16
17async function main() {
18  const {
19    owner_address,
20    token_name,
21    token_symbol,
22    total_supply,
23    burn_percentage,
24  } = config;
25
26  console.log(`Deploying token ${token_name}. Owner: ${owner_address}...`);
27
28  const Token = await hre.ethers.getContractFactory("ERC20TokenBurn");
29  const token = await Token.deploy(
30    owner_address,
31    token_name,
32    token_symbol,
33    total_supply,
34    burn_percentage
35  );
36
37  console.log(`Deployed token. Owner: ${owner_address}`);
38
39  console.log("Waiting 1 minute for Etherscan to index the contract...");
40  await new Promise((r) => setTimeout(r, 60000));
41
42  console.log("Verifying contract...");
43  await hre.run("verify:verify", {
44    address: token.getAddress(),
45    constructorArguments: [
46      owner_address,
47      token_name,
48      token_symbol,
49      total_supply,
50      burn_percentage,
51    ],
52  });
53
54  console.log("Contract verified! 🎉");
55  console.log(
56    `Please find the verified contract on Etherscan: https://sepolia.etherscan.io/address/${token.getAddress()}`
57  );
58}
59
60// We recommend this pattern to be able to use async/await everywhere
61// and properly handle errors.
62main().catch((error) => {
63  console.error(error);
64  process.exitCode = 1;
65});

HAPPY

Good job! You have successfully created an ERC20 token with an automatic burning mechanism on transfer and a manual burning mechanism for the owner! 🔥🔥🔥

To deploy it to the test net, you can run the following command:

1npx hardhat run scripts/deploy-erc20BurnOnTransfer.ts --network sepolia

Conclusion

In this course, we learned how to create an ERC20 token with massive deflationary tokenomics!