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); }
В целом весь стандарт реализован. Осталось написать эти функции.
// 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: Этот проект на гит хабе