In this article, I want to tell you about what smart contracts are in evm-like blockchains. We also implement perhaps the most popular smart contract - the erc20 token. I will show you how to create a simple smart contract, write tests for it and call methods.
Introduction
I think many of you already know what blockchain is. In this article, I want to tell you about what smart contracts are in evm-like blockchains. We also implement perhaps the most popular smart contract - the erc20 token. I will show you how to create a simple smart contract, write tests for it and call methods.
First of all, let's figure out what a smart contract is. A smart contract is essentially a class that has functions and fields. A smart contract published on the blockchain is already an instance of a class, and since you cannot restart the blockchain, you also cannot change the implementation of your smart contract after it has been published. Of course, you can change your code and publish the contract again, but it will be a completely different smart contract.
There are two possible types of operations that you can perform with smart contracts - read and write operations. Read operations are free because they do not change the blockchain, but writing costs money. Because in order to change the blockchain, a transaction must be created, which must be confirmed.
Let's take a closer look at smart contracts using the example of perhaps the most popular of them, namely the erc20 token.
In fact, any smart contract is considered an erc20 token if it implements a special interface specified in EIPS (Ethereum Improvement Proposals) https://eips.ethereum.org/EIPS/eip-20.
Let's look at the composition of the methods of this interface and start implementing our ERC20 token.
function name() public view returns (string)
- a method that returns the name of the token. It is needed exclusively for the UI and should not carry any logical load. Calling this method is usually free because it only reads data from the blockchain. If you call this method on the most popular USDT token (0xdAC17F958D2ee523a2206206994597C13D831ec7), you will get �Tether USD�. function symbol() public view returns (string)
- returns the token character. This value is also needed exclusively for the UI. function decimals() public view returns (uint8)
- This method returns the precision of the token. A very important parameter, for example, to send 1 token with decimals = 2, you will need to pass a value equal to 100, and if you want to send 1 token with decimals = 6, then you must send 1000000 to the send function. function totalSupply() public view returns (uint256)
- returns the total number of issued tokens. function balanceOf(address _owner) public view returns (uint256 balance)
- returns the balance of tokens for a specific blockchain account passed to the _owner
parameter of the contract function transfer(address _to, uint256 _value) public returns (bool success)
- a method of sending tokens from one wallet to another. Of course, this is a paid method because it changes the blockchain. We must store in the blockchain the information that the balance of one account has decreased and the balance of another has increased function transferFrom(address _from, address _to, uint256 _value) public returns (bool success)
- very useful method. Using it, tokens can be debited from your account at the command of another wallet. Of course, you must first approve this write-off function approve(address _spender, uint256 _value) public returns (bool success)
- method for approving the debiting of tokens from your account by another account function allowance(address _owner, address _spender) public view returns (uint256 remaining)
- Using this method, you can get the total amount of funds approved for write-off.
Also, smart contracts implementing the erc20
interface must emit standard events.
event Transfer(address indexed _from, address indexed _to, uint256 _value)
- an event that tokens were transferred from one account to another event Approval(address indexed _owner, address indexed _spender, uint256 _value)
- an event that the debit of funds has been approved
So, we figured out what an ERC20 token should be. Let's get started with implementation. For this, I will use the hardhat package and the typescript language. After creating the project folder, I will install the following packages:
npm i -D hardhat typescript
After that, let's create a file with the IERC20.sol interface in the contracts folder.
pragma solidity ^0.8.20;
interface IERC20 {
function name() external view returns (string memory);
function symbol() external view returns (string memory);
function decimals() external view returns (uint8);
function totalSupply() external view returns (uint256);
function balanceOf(address owner) external view returns (uint256 balance);
function transfer(address to, uint256 value) external returns (bool success);
function transferFrom(address _from, address _to, uint256 _value)
external returns (bool success);
function approve(address spender, uint256 value) external returns (bool success);
function allowance(address owner, address spender) external view returns
(uint256 remaining);
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
}
After that, let's start creating the contract. To do this, we will create a new file ERC20.sol. In it, we will create an ERC20 contract that implements the IERC20 interface. And we will sketch the necessary methods. For our code to compile, let's return NotImplementedError
in each method.
pragma solidity ^0.8.20;
import {IERC20} from "./IERC20.sol";
contract ERC20 is IERC20{
constructor(string memory name, string memory symbol, uint8 decimals){
}
function name() public view returns (string memory){
revert NotImplementedError();
}
function symbol() public view returns (string memory){
revert NotImplementedError();
}
function decimals() public view returns (uint8){
revert NotImplementedError();
}
function totalSupply() public view returns (uint256){
revert NotImplementedError();
}
function balanceOf(address owner) public view returns (uint256){
revert NotImplementedError();
}
function transfer(address to, uint256 value) public returns (bool){
revert NotImplementedError();
}
function transferFrom(address from, address to, uint256 amount) public returns (bool){
revert NotImplementedError();
}
function approve(address spender, uint256 amount) public returns (bool){
revert NotImplementedError();
}
function allowance(address owner, address spender) public view returns (uint256){
revert NotImplementedError();
}
function mint(address account, uint256 amount) public returns (bool success){
revert NotImplementedError();
}
function burn(address account, uint256 amount) public returns (bool success){
revert NotImplementedError();
}
error NotImplementedError();
}
I extended the contract with two methods, mint
and burn
. We will need them to create tokens. After all, someone has to do the initial generation of tokens. Now it's time for tests. But first, we need to make sure that our contract compiles.
To do this, we need to use the npx hardhat compile
command. For this command to work, we need to add the configuration file, hardhat.config.ts.
We also need two more packages to write tests:
npm i -D @nomicfoundation/hardhat-toolbox @nomicfoundation/hardhat-ethers
Next, I created a file test/ERC20.spec.ts and implemented tests in it.
import { expect } from "chai";
import { ethers } from "hardhat";
import {HardhatEthersSigner, SignerWithAddress} from
"@nomicfoundation/hardhat-ethers/signers";
import { ERC20 } from "../typechain-types";
import {ContractFactory} from "ethers";
const ZERO_ADDRESS : string = "0x0000000000000000000000000000000000000000";
describe("Erc20 contract", () => {
let accounts : HardhatEthersSigner[];
let erc20Contract : ERC20;
const name : string = "MyToken";
const symbol : string = "MT";
const decimals : number = 18;
beforeEach(async () =>{
accounts = await ethers.getSigners();
const erc20Factory: ContractFactory = await ethers.getContractFactory('ERC20');
erc20Contract = (await erc20Factory.deploy(name, symbol, decimals)) as ERC20;
});
describe ("deployment", () => {
it("Should set the right name", async () => {
expect(await erc20Contract.name()).to.equal(name);
});
it("Should set the right symbol", async () => {
expect(await erc20Contract.symbol()).to.equal(symbol);
});
it("Should set the right decimals", async () => {
expect(await erc20Contract.decimals()).to.equal(decimals);
});
it("Should set zero total supply", async () => {
expect(await erc20Contract.totalSupply()).to.equal(0);
});
});
describe ("mint", () => {
it("Shouldn't be possible mint to zero address", async () => {
const mintAmount = 1;
await expect(erc20Contract.mint(ZERO_ADDRESS, mintAmount))
.to.be.revertedWith("account shouldn't be zero");
});
it("Shouldn't be possible mint zero amount", async () => {
const mintAmount = 0;
await expect(erc20Contract.mint(accounts[0].address, mintAmount))
.to.be.revertedWith("amount shouldn't be zero");
});
it("Should be change balance", async () =>{
const mintAmount = 10;
await erc20Contract.mint(accounts[0].address, mintAmount);
expect(await erc20Contract.balanceOf(accounts[0].address)).to.equal(mintAmount);
});
it("Should be change total supply", async () =>{
const mintAmount1 = 1;
const mintAmount2 = 2;
await erc20Contract.mint(accounts[0].address, mintAmount1);
await erc20Contract.mint(accounts[1].address, mintAmount2);
expect(await erc20Contract.totalSupply()).to.equal(mintAmount1 + mintAmount2);
});
});
describe("transfer", () => {
it("Shouldn't be possible transfer to zero address", async () =>{
const from : SignerWithAddress = accounts[0];
const toAddress : string = ZERO_ADDRESS;
const transferAmount : number = 1;
await expect(erc20Contract.connect(from).transfer(toAddress, transferAmount))
.to.be.revertedWith("to address shouldn't be zero");
});
it("Shouldn't be possible transfer zero amount", async () =>{
const from : SignerWithAddress = accounts[0];
const toAddress : string = accounts[1].address;
const transferAmount : number = 0;
await expect(erc20Contract.connect(from).transfer(toAddress, transferAmount))
.to.be.revertedWith("amount shouldn't be zero");
});
it("Shouldn't be possible transfer more than account balance", async () =>{
const from : SignerWithAddress = accounts[0];
const toAddress: string = accounts[1].address;
const mintAmount: number = 1;
await erc20Contract.mint(from.address, mintAmount);
await expect(erc20Contract.connect(from).transfer(toAddress, mintAmount + 1))
.to.be.reverted;
});
it("Shouldn't change total supply", async () => {
const from: SignerWithAddress = accounts[0];
const toAddress: string = accounts[1].address;
const mintAmount: number = 1;
await erc20Contract.mint(from.address, mintAmount);
await erc20Contract.connect(from).transfer(toAddress, mintAmount);
expect(await erc20Contract.totalSupply()).to.equal(mintAmount);
});
it("Should increase balance", async () => {
const from : SignerWithAddress = accounts[0];
const toAddress: string = accounts[1].address;
const mintAmount : number = 1;
await erc20Contract.mint(from.address, mintAmount);
await erc20Contract.connect(from).transfer(toAddress, mintAmount);
expect(await erc20Contract.balanceOf(toAddress)).to.equal(mintAmount);
});
it("Should decrease balance", async () => {
const from : SignerWithAddress = accounts[0];
const toAddress : string = accounts[1].address;
const mintAmount : number = 1;
await erc20Contract.mint(from.address, mintAmount);
await erc20Contract.connect(from).transfer(toAddress, mintAmount);
expect(await erc20Contract.balanceOf(from.address)).to.equal(0);
});
});
describe ("approve", () => {
it("Shouldn't be possible to zero address", async () => {
const amount = 1;
await expect(erc20Contract.connect(accounts[0]).approve(ZERO_ADDRESS, amount))
.to.be.revertedWith("spender address shouldn't be zero");
});
it("Shouldn't be possible zero amount", async () => {
const amount = 0;
await expect(erc20Contract.connect(accounts[0]).approve
(accounts[1].address, amount))
.to.be.revertedWith("amount shouldn't be zero");
});
it("Should be change allowance", async () =>{
const amount = 1;
await erc20Contract.connect(accounts[0]).approve(accounts[1].address, amount);
expect(await erc20Contract.allowance
(accounts[0].address, accounts[1].address)).to.equal(amount);
});
});
describe("transferFrom", () => {
it("Shouldn't be possible more than allowance", async () =>{
const allowanceAmount : number = 1;
const transferAmount : number = allowanceAmount + 1;
await erc20Contract.connect(accounts[0]).approve
(accounts[1].address, allowanceAmount);
await expect(erc20Contract.connect(accounts[1]).transferFrom
(accounts[0].address, accounts[2].address, transferAmount))
.to.be.revertedWith("insufficient allowance funds");
});
it("Should spend allowance", async () =>{
const allowanceAmount : number = 2;
const transferAmount : number = allowanceAmount - 1;
await erc20Contract.mint(accounts[0].address, allowanceAmount);
await erc20Contract.connect(accounts[0]).approve
(accounts[1].address, allowanceAmount);
await erc20Contract.connect(accounts[1]).transferFrom
(accounts[0].address, accounts[2].address, transferAmount);
expect(await erc20Contract.allowance
(accounts[0].address, accounts[1].address)).to.equal
(allowanceAmount - transferAmount);
});
it("Should increase balance", async () =>{
const allowanceAmount : number = 2;
const transferAmount : number = allowanceAmount - 1;
await erc20Contract.mint(accounts[0].address, allowanceAmount);
await erc20Contract.connect(accounts[0]).approve(accounts[1].address,
allowanceAmount);
await erc20Contract.connect(accounts[1]).transferFrom
(accounts[0].address, accounts[2].address, transferAmount);
expect(await erc20Contract.balanceOf(accounts[2].address)).to.equal
(transferAmount);
});
it("Should decrease balance", async () =>{
const allowanceAmount : number = 2;
const transferAmount : number = allowanceAmount - 1;
await erc20Contract.mint(accounts[0].address, allowanceAmount);
await erc20Contract.connect(accounts[0]).approve
(accounts[1].address, allowanceAmount);
await erc20Contract.connect(accounts[1]).transferFrom
(accounts[0].address, accounts[2].address, transferAmount);
expect(await erc20Contract.balanceOf(accounts[0].address)).to.equal
(allowanceAmount - transferAmount);
});
});
describe ("burn", () => {
it("Shouldn't be possible burn from zero address", async () => {
const amount = 1;
await expect(erc20Contract.burn(ZERO_ADDRESS, amount))
.to.be.revertedWith("account shouldn't be zero");
});
it("Shouldn't be possible burn zero amount", async () => {
const amount = 0;
await expect(erc20Contract.burn(accounts[0].address, amount))
.to.be.revertedWith("amount shouldn't be zero");
});
it("Should be change balance", async () =>{
const mintAmount : number = 2;
const burnAmount : number = 1;
await erc20Contract.mint(accounts[0].address, mintAmount);
await erc20Contract.burn(accounts[0].address, burnAmount);
expect(await erc20Contract.balanceOf(accounts[0].address)).to.equal
(mintAmount - burnAmount);
});
it("Should be change total supply", async () =>{
const mintAmount : number = 2;
const burnAmount : number = 1;
await erc20Contract.mint(accounts[0].address, mintAmount);
await erc20Contract.burn(accounts[0].address, burnAmount);
expect(await erc20Contract.totalSupply()).to.equal(mintAmount - burnAmount);
});
it("Should burn all balance", async () =>{
const mintAmount : number = 2;
const burnAmount : number = mintAmount + 1;
await erc20Contract.mint(accounts[0].address, mintAmount);
await erc20Contract.burn(accounts[0].address, burnAmount);
expect(await erc20Contract.balanceOf(accounts[0].address)).to.equal(0);
});
});
});
Tests are launched with the npx hardhat test
command, and to see the coverage, you need to run the npx hardhat coverage
command. Now, of course, all the tests are red, because we have not implemented a single method.
In the end, I got this implementation.
pragma solidity ^0.8.20;
import {IERC20} from "./IERC20.sol";
contract ERC20 is IERC20{
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
uint256 private _totalSupply;
string private _name;
string private _symbol;
uint8 private _decimals;
constructor(string memory name, string memory symbol, uint8 decimals){
_name = name;
_symbol = symbol;
_decimals = decimals;
}
function name() public view returns (string memory){
return _name;
}
function symbol() public view returns (string memory){
return _symbol;
}
function decimals() public view returns (uint8){
return _decimals;
}
function totalSupply() public view returns (uint256){
return _totalSupply;
}
function balanceOf(address owner) public view returns (uint256){
return _balances[owner];
}
function transfer(address to, uint256 value) public returns (bool){
_transfer(msg.sender, to, value);
return true;
}
function transferFrom(address from, address to, uint256 amount) public returns (bool){
_spendAllowance(from, msg.sender, amount);
_transfer(from, to, amount);
return true;
}
function approve(address spender, uint256 amount) public returns (bool){
_approve(msg.sender, spender, amount);
return true;
}
function allowance(address owner, address spender) public view returns (uint256){
return _allowances[owner][spender];
}
function mint(address account, uint256 amount) public{
_mint(account, amount);
}
function burn(address account, uint256 amount) public{
_burn(account, amount);
}
function _transfer(address from, address to, uint256 amount) internal {
require(to != address(0), "to address shouldn't be zero");
require(amount != 0, "amount shouldn't be zero");
uint256 fromBalance = _balances[from];
require(fromBalance >= amount, "insufficient funds");
_balances[from] = fromBalance - amount;
_balances[to] += amount;
emit Transfer(from, to, amount);
}
function _mint(address account, uint256 amount) internal {
require(account != address(0), "account shouldn't be zero");
require(amount != 0, "amount shouldn't be zero");
_totalSupply += amount;
_balances[account] += amount;
emit Transfer(address(0), account, amount);
}
function _burn(address account, uint256 amount) internal virtual {
require(account != address(0), "account shouldn't be zero");
require(amount != 0, "amount shouldn't be zero");
uint256 accountBalance = _balances[account];
uint256 burnAmount = amount>accountBalance ? accountBalance : amount;
_balances[account] = accountBalance - burnAmount;
_totalSupply -= burnAmount;
emit Transfer(account, address(0), burnAmount);
}
function _approve(address owner, address spender, uint256 amount) internal {
require(spender != address(0), "spender address shouldn't be zero");
require(amount != 0, "amount shouldn't be zero");
_allowances[owner][spender] = amount;
emit Approval(owner, spender, amount);
}
function _spendAllowance(address owner, address spender, uint256 amount) internal {
uint256 currentAllowance = allowance(owner, spender);
require(currentAllowance >= amount, "insufficient allowance funds");
_approve(owner, spender, currentAllowance - amount);
}
}
If we run the tests, they all turn green, which means our implementation is correct. I hope this article will help you start writing your smart contracts. You can find the source code at https://github.com/waksund/erc20.
History
- 2nd January, 2024: Initial version