Solidity
February 21, 2023

Solidity | ERC20 без воды

Всем привет. Сегодня пойдет речь о такой теме как стандарт ERC20. Напишем свой ERC 20 токен и сделаем магазин.

Для начала разберемся что из себя представляет ERC20. Это стандарт, который описывает взаимозаменяемые токены, которые напоминают наш эфир. У них есть свой наминал и количество. За них можно что-то покупать или продавать. Грубо говоря это наши токены, которые ведут себя как эфир или биткоин, но не путайте их с сними, потому что токены ERC20 не являются криптовалютой, но работают в блокчейне. Подробнее читайте тут клик

Так ну примерно разобрались. Давайте начнем писать.

На самом деле этот стандарт уже реализован и по хорошему изобретать велосипед нет смысла. Но для понимания работы этого стандарты и всех его функций мы напишем его с нуля. Тут можно посмотреть реализацию тык

Создаем проект hardhat как я показывал в статье про фронтенд.

Пишем интерфейс контракта. Создаем файл IERC20.sol

Первая функция интерфейса это name. Эта функция возвращает название токена:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

interface IERC20 {    
    function name() external view returns(string memory);
}

Вторая функция это symbol и она возвращает символ нашего токена (сокращение названия)

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

interface IERC20 {    
    function name() external view returns(string memory);
    
    function symbol() external view returns(string memory);
}

Третья функция это decimals и она возвращает количество знаков после запятой.
Если например 9 знаков, то это соответствие 1 токена, одному gwei.

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

interface IERC20 {    
    function name() external view returns(string memory);
    
    function symbol() external view returns(string memory);
    
    function decimals() external pure returns(uint); 
}

Четвертая функция это totalSupply. Она возвращает количество токенов в обороте

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

interface IERC20 {    
    function name() external view returns(string memory);
    
    function symbol() external view returns(string memory);
    
    function decimals() external pure returns(uint); 
    
    function totalSupply() external view returns(uint);
}

Пятая функция balanceOf возвращает баланс токенов на аккаунте

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

interface IERC20 {    
    function name() external view returns(string memory);
    
    function symbol() external view returns(string memory);
    
    function decimals() external pure returns(uint); 
    
    function totalSupply() external view returns(uint);
    
    function balanceOf(address account) external view returns(uint);
}

Шестая функция transfer позволяет нам переводить токены на разные аккаунты с магазина.

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

interface IERC20 {    
    function name() external view returns(string memory);
    
    function symbol() external view returns(string memory);
    
    function decimals() external pure returns(uint); 
    
    function totalSupply() external view returns(uint);
    
    function balanceOf(address account) external view returns(uint);
    
    function transfer(address to, uint amount) external;
}

Седьмая функция allowance. В ней мы даем разрешение другому кошельку забрать у нас деньги.

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

interface IERC20 {    
    function name() external view returns(string memory);
    
    function symbol() external view returns(string memory);
    
    function decimals() external pure returns(uint); 
    
    function totalSupply() external view returns(uint);
    
    function balanceOf(address account) external view returns(uint);
    
    function transfer(address to, uint amount) external;
    
    function allowance(address _owner, address spender) external view returns(uint);
}

Восьмая функция approve. Дает нам подтверждение об списание токенов.

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

interface IERC20 {    
    function name() external view returns(string memory);
    
    function symbol() external view returns(string memory);
    
    function decimals() external pure returns(uint); 
    
    function totalSupply() external view returns(uint);
    
    function balanceOf(address account) external view returns(uint);
    
    function transfer(address to, uint amount) external;
    
    function allowance(address _owner, address spender) external view returns(uint);
    
    function approve(address spender, uint amount) external;
}

Девятая функция transferFrom. Перевод токенов из одного кошелька на другой.

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

interface IERC20 {    
    function name() external view returns(string memory);
    
    function symbol() external view returns(string memory);
    
    function decimals() external pure returns(uint); 
    
    function totalSupply() external view returns(uint);
    
    function balanceOf(address account) external view returns(uint);
    
    function transfer(address to, uint amount) external;
    
    function allowance(address _owner, address spender) external view returns(uint);
    
    function approve(address spender, uint amount) external;
    
    function transferFrom(address sender, address recipient, uint amount) external;
}

Дальше мы добавим событие (event) со всеми транзакциями, чтоб потом мы могли найти все переводы одного кошелька на другой. Довольно удобная штука.

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

interface IERC20 {    
    function name() external view returns(string memory);
    
    function symbol() external view returns(string memory);
    
    function decimals() external pure returns(uint); 
    
    function totalSupply() external view returns(uint);
    
    function balanceOf(address account) external view returns(uint);
    
    function transfer(address to, uint amount) external;
    
    function allowance(address _owner, address spender) external view returns(uint);
    
    function approve(address spender, uint amount) external;
    
    function transferFrom(address sender, address recipient, uint amount) external;
    
    event Transfer(address indexed from, address indexed to, uint amount);
}

В целом весь стандарт реализован. Осталось написать эти функции.

Создаем новый файл ERC20.sol

Теперь по порядку.

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "./IERC20.sol";

contract ERC20 is IERC20 {    
    uint totalTokens;    
    address owner;    
    mapping(address => uint) balances;    
    mapping(address => mapping(address => uint)) allowances;    
    string _name;    
    string _symbol;
}

Импортируем наш интерфейс и теперь смотрим:

Первая переменная totalTokens будет отвечать за наш соплай токенов. Address owner это овнер контракта.

Теперь с маппингами.
Первый отвечает за балансы токенов на каждом адресе. Тут все понятно.
А вот второй немного сложнее. Тут мы будем хранить информация адреса кошелька, который разрешил другому адресу передавать то или иное количество токенов. Надеюсь понятно.

Теперь по функциям:

Функция name. Возвращает имя токена.

function name() external view returns(string memory) {        
    return _name;    
}

Функция symbol. Возвращает символ токена.

function symbol() external view returns(string memory) {    
    return _symbol;    
}

Функция decimals. Вернет число знаков после запятой.

function decimals() external pure returns(uint) {    
    return 9; // 1 token = 1 gwei    
}

Функция totalSupply вернет то сколько у нас токенов в обороте.

function totalSupply() external view returns(uint) {    
    return totalTokens;    
}

Дальше два модификатора. enoughTokens и onlyOwner. Первый проверяет, что у нас достаточно токенов иначе revert. Вторая проверка на овнера смарт-контракта

modifier enoughTokens(address _from, uint _amount) {    
    require(balanceOf(_from) >= _amount, "not enough tokens!");        
    _;    
}
modifier onlyOwner() {    
    require(msg.sender == owner, "not an owner!");        
    _;    
} 

Дальше создадим конструктор. Входные аргументы будут задавать наши основные параметры токена и последний аргумент это адрес нашего магазина для покупки продажи токенов. Внутри конструктора мы так же вызываем функцию mint, которая и будет создавать наши токены грубо говоря.

constructor(string memory name_, string memory symbol_, uint initialSupply, address shop) {     
    _name = name_;        
    _symbol = symbol_;        
    owner = msg.sender;        
    mint(initialSupply, shop);    
}

Теперь функция mint. Эта функция будет вызывать только овнер контракта и мы с помощью нее можем вводить токены в оборот. Внутри функции мы увеличиваем количество токенов на балансе магазина и увеличиваем общее количество токенов. Ну и породим событие transfer и отправитель адрес 0, потому что мы только создали токены и грубо говоря у них нет отправителя.

function mint(uint amount, address shop) public onlyOwner {            
    balances[shop] += amount;        
    totalTokens += amount;        
    emit Transfer(address(0), shop, amount);    
}

Дальше функция balanceOf. Она возвращает баланс токенов на аккаунте через наш маппинг.

function balanceOf(address account) public view returns(uint) {   
    return balances[account];    
}

Функция transfer. Мы переводим токены на какой то адрес. Делаем проверку через модификатор на то, что у нас хватает токенов и переводим их на другой аккаунт. Через маппинг balances мы забираем у себя эти токены и добавляем другому кошельку. Ну и породим событие о транзакции.

function transfer(address to, uint amount) external enoughTokens(msg.sender, amount) {              
    balances[msg.sender] -= amount;        
    balances[to] += amount;        
    emit Transfer(msg.sender, to, amount);    
}

Так же можно добавить функцию _beforeTokenTransfer, которая будет вызываться перед переводом и что то делать, но у нас в ней нет необходимости, поэтому без нее.

И можно написать такую же функцию _afterTokenTransfe, соответственно вызов после перевода

Функция allowance. Будем по маппингу узнавать есть ли разрешение на перевод того или иного количества токенов.

function allowance(address _owner, address spender) public view returns(uint) {    
    return allowances[_owner][spender];    
}

Теперь функция approve, которая будет давать нам разрешение на перевод определённого количества токенов. Можно не переопределять функцию _approve, но в openzepplin делают именно так, поэтому делаем как они. Тут мы просто по маппингу записываем какое количество токенов мы разрешаем получателю забрать у нас.

function approve(address spender, uint amount) public {    
    _approve(msg.sender, spender, amount);    
}
function _approve(address sender, address spender, uint amount) internal virtual {    
    allowances[sender][spender] = amount;            
}

Функция transferFrom. Перевод с одного кошелька на другой. Юзаем наш модификатор enoughTokens. Тут я использую функцию unchecked. Она позволяет избежать ошибки unchecked block при операции вычитания, которая может вызвать ошибку если переменная уйдет ниже нуля, так как мы работает с uint, этого быть не может. А так, это тоже самое что и transfer, только мы указываем адрес куда и кому мы переводим токены и еще сбрасываем allowances, которое мы дали перед переводом.

function transferFrom(address sender, address recipient, uint amount) public enoughTokens(sender, amount) {    
    unchecked {       
        allowances[sender][msg.sender] -= amount;             
        balances[sender] -= amount;            
        balances[recipient] += amount;        
    }          
    emit Transfer(sender, recipient, amount);    
}

Еще функция increaseAllowance, которая будет нам добавлять количество токенов которые мы хотим разрешить другому кошельку забрать у нас. И вызываем наш approve с параметрами.

function increaseAllowance(address spender, uint256 addedValue) public virtual returns (bool) {            
    _approve(msg.sender, spender, allowance(msg.sender, spender) + addedValue);        
    return true;    
} 

И функция decreaseAllowance, которая будет убирать какое то количество токенов, которое мы хотим разрешать списать у нас. Проверяем на то что можем ли мы списать то или иное количество токенов, чтоб мы не ушли в минус и вызываем наш approve

function decreaseAllowance(address spender, uint256 subtractedValue) public virtual returns (bool) {        
    uint256 currentAllowance = allowance(msg.sender, spender);        
    require(currentAllowance >= subtractedValue, "ERC20: decreased allowance below zero");        
    unchecked {            
        _approve(msg.sender, spender, currentAllowance - subtractedValue);       
    }
    return true;   
}
Эти две функции можно использовать альтернативе approve, если есть какие то проблемы, но не обязательно. Я их не использую, потому что мне достаточно appove, но рассказать думаю стоит

Ну и последняя функция это burn. Нужна будет, если мы хотим сжигать токены. Делать это может только овнер очевидно.

function burn(address _from, uint amount) public onlyOwner {           
    balances[_from] -= amount;        
    totalTokens -= amount;    
}

Теперь благодаря этому контракту мы можем спокойно создавать наши токены и делать для них что хотим.

Давайте создадим токен!!!

contract DDWorldToken is ERC20 {    
    constructor(address shop) ERC20("DDWorld", "DDW", 10000 , shop) {}
}

Ну вот контракт создания токена. У нас есть свой токен как говориться. В конструкторе мы передаем наш адрес магазина, где будут торговаться наши токены и при помощи такой записи мы находим конструктор из контракта ERC20, где мы принимали и название и символ и соплай и адрес магазина. Вызвав тот конструктор мы задали эти параметры для нашего токена.

Теперь создадим магазин наших токенов, где мы сможем их переводить покупать и продавать!

contract DDWShop {    
    IERC20 public token;    
    address payable public owner;    
    event Bought(uint _amount, address indexed _buyer);    
    event Sold(uint _amount, address indexed _seller);
}

Создаем переменную IERC20, которая будет наследовать все методы ERC20, потому что это интерфейс. Подробно говорил об этом в статье об интерфейсах.

Потом переменная овнер и два события на покупку и продажу

Дальше создаем конструктор. Тут мы развертываем новый контракт нашего токена DDWorldToken, который наследует все функции ERC20 и получается, что наш магазин имеет доступ ко всем функциям ERC20. Ну и овнера задаем.

constructor() {    
    token = new DDWorldToken(address(this));        
    owner = payable(msg.sender);    
}

Так же создаем модификатор onlyOwner

modifier onlyOwner() {    
    require(msg.sender == owner, "not an owner!");        
    _;    
}

Дальше создаем функцию buy. Тут мы будем покупать токены. Делаем проверку на то хватит ли денег у нас на кошельке и хватит ли токенов для перевода на самом магазине. Передаем какое то количество эфира и смотрим сколько мы получим токенов за эту сумму.

Тут важно, что msg.value это наш эфир в wei, а наш токен равен 1 token = 1 gwei
То есть если мы передадим 1000000000 wei = 1 token
function buy() external payable {    
    uint tokensToBuy = msg.value;         
    require(tokensToBuy > 0, "not enough funds!");
    require(tokenBalance() >= tokensToBuy, "not enough tokens!");
    token.transfer(msg.sender, tokensToBuy);        
    emit Bought(tokensToBuy, msg.sender);    
}

Теперь функция продажи токенов. Для того, чтоб продать токены, мы должны проверить, что у нас есть достаточное количество токенов в кошельке. Потом мы проверяем allowance, на то что мы дали разрешение забрать у нас это количество токенов и это число должно быть больше или равно тому числу, сколько мы разрешили забрать у нас токенов.
Дальше мы передаем эти токены на адрес магазина и магазин нам отдает деньги eth за эти токены.

function sell(uint _amountToSell) external {    
    require(           
     _amountToSell > 0 &&            
     token.balanceOf(msg.sender) >= _amountToSell,            
     "incorrect amount!"        
     );
     uint allowance = token.allowance(msg.sender, address(this));        
     require(allowance >= _amountToSell, "check allowance!");
     token.transferFrom(msg.sender, address(this), _amountToSell);
     payable(msg.sender).transfer(_amountToSell);
     emit Sold(_amountToSell, msg.sender);    
}

Теперь функция, которая будет возвращать баланс токенов по адресу.

function _tokenbalance(address member) public view returns(uint) {    
    return token.balanceOf(member);    
}
function tokenBalance() public view returns(uint) {    
    return token.balanceOf(address(this));    
}

Ну и можно создать функцию перевода токенов с одного кошелька на другой. Тут все так же как и в функции продажи токенов sell, но перевод идет на другой адрес, а не на магазин и мы не получаем деньги за перевод.

function transferTo(address to, uint _amountToSell) external {    
    require(            
    _amountToSell > 0 &&            
    token.balanceOf(msg.sender) >= _amountToSell,            
    "incorrect amount!"        
    );
    uint allowance = token.allowance(msg.sender, to);        
    require(allowance >= _amountToSell, "check allowance!");
    token.transferFrom(msg.sender, to, _amountToSell);
}

Весь код магазина токенов

contract DDWShop {    
    IERC20 public token;    
    address payable public owner;    
    event Bought(uint _amount, address indexed _buyer);    
    event Sold(uint _amount, address indexed _seller);
    constructor() {    
        token = new DDWorldToken(address(this));        
        owner = payable(msg.sender);    
    }
    modifier onlyOwner() {   
        require(msg.sender == owner, "not an owner!");        
        _;    
    }
    function sell(uint _amountToSell) external {        
        require(            
        _amountToSell > 0 &&            
        token.balanceOf(msg.sender) >= _amountToSell,            
        "incorrect amount!"        
        );
        uint allowance = token.allowance(msg.sender, address(this));        require(allowance >= _amountToSell, "check allowance!");
        token.transferFrom(msg.sender, address(this), _amountToSell);
        payable(msg.sender).transfer(_amountToSell);
        emit Sold(_amountToSell, msg.sender);    
    }
    function buy() external payable {    
        uint tokensToBuy = msg.value;        
        require(tokensToBuy > 0, "not enough funds!");
        require(tokenBalance() >= tokensToBuy, "not enough tokens!");
        token.transfer(msg.sender, tokensToBuy);        
        emit Bought(tokensToBuy, msg.sender);    
    }
    function tokenBalance() public view returns(uint) {    
        return token.balanceOf(address(this));    
    }
    function _tokenbalance(address member) public view returns(uint) {        
        return token.balanceOf(member);    
    }
    function transferTo(address to, uint _amountToSell) external {    
       require(           
         _amountToSell > 0 &&            
         token.balanceOf(msg.sender) >= _amountToSell,            
         "incorrect amount!"       
       );
       uint allowance = token.allowance(msg.sender, to);        
       require(allowance >= _amountToSell, "check allowance!");
       token.transferFrom(msg.sender, to, _amountToSell);
    }
}

По факту все готово.

Теперь мы хотим проверить работу нашего контракта. По факту я не показывал как тестировать смарт-контракты, поэтому если вы не знаете то советую почитать документацию hardhat

Возможно в бедующем я расскажу подробно как делать тесты наших смарт контрактов, но щас не об этом.

Если вы создавали hardhat проект, как я рассказывал в статье про frontend, то у нас там есть папка test. Заходим в нее и создаем файл DDWorldShop.js и вставляем туда код.

const { expect } = require("chai")
const { ethers } = require("hardhat")
const tokenJSON = require("../artifacts/contracts/Erc.sol/DDWorldToken.json")

describe("DDWordShop", function () {    
    let owner    
    let buyer    
    let shop    
    let erc20
    beforeEach(async function() {      
        [owner, buyer] = await ethers.getSigners()
        const DDWordShop= await ethers.getContractFactory("DDWordShop", owner)      
        shop = await DDWordShop.deploy()      
        await shop.deployed()
        erc20 = new ethers.Contract(await shop.token(), tokenJSON.abi, owner)      
        console.log("Address", await shop.token())    
    })
    it("should have an owner and a token", async function() {      
        expect(await shop.owner()).to.eq(owner.address)
        expect(await shop.token()).to.be.properAddress    
    })
    it("allows to buy", async function() {      
        const tokenAmount = 3
        const txData = {    
            value: tokenAmount,        
            to: shop.address      
        }
        const tx = await buyer.sendTransaction(txData)      
        await tx.wait()
        expect(await erc20.balanceOf(buyer.address)).to.eq(tokenAmount)
        await expect(() => tx).        
        to.changeEtherBalance(shop, tokenAmount)
        await expect(tx)        
        .to.emit(shop, "Bought")        
        .withArgs(tokenAmount, buyer.address)   
    })
    it("allows to sell", async function() {      
        const tx = await buyer.sendTransaction({    
            value: 3,        
            to: shop.address      
        })      
        await tx.wait()
        const sellAmount = 2
        const approval = await erc20.connect(buyer).approve(shop.address, sellAmount)
        await approval.wait()
        const sellTx = await shop.connect(buyer).sell(sellAmount)
        expect(await erc20.balanceOf(buyer.address)).to.eq(1)
        await expect(() => sellTx).        to.changeEtherBalance(shop, -sellAmount)
        await expect(sellTx)        
        .to.emit(shop, "Sold")        
        .withArgs(sellAmount, buyer.address)    
    })    
})

Тут нет ни чего сложного, но объяснять щас не буду. Тут у нас есть проверки на то, что проходит продажа, покупка токенов, что установился owner и адрес токена. Просто берем пока что этот код и заходим в консоль.

В конслоли заходим в наш проект пишем npx hardhat test

Будет примерно так выглядеть если все тесты прошли.

На этом все. Вот мы и реализовали стандарт ERC20 и даже написали свой магазин токенов. Скоро я расскажу как можно сделать свой сайт по продажи и покупке токенов (frontend). Дальше больше. В целом тесты очень важны в создании контрактов, так как после деплоя мы не сможем изменить код контракта. Поэтому расскажу в скором времени по подробнее как их писать.

tg: мой телеграмчик)

GitHub: Этот проект на гит хабе