Перейти до основного вмісту

Огляд контракту ERC-20

Solidity
erc-20
Для початківців
Орі Померанц
9 березня 2021 р.
24 хвилин на читання

Вступ

Одне з найпоширеніших застосувань Етеріуму — це створення групою людей токена, яким можна торгувати, у певному сенсі їхньої власної валюти. Ці токени зазвичай відповідають стандарту ERC-20. Цей стандарт дає змогу створювати інструменти, такі як пули ліквідності та гаманці, які працюють з усіма токенами ERC-20. У цій статті ми проаналізуємо реалізацію ERC-20 на Solidity від ОупенЗеппелін (opens in a new tab), а також визначення інтерфейсу (opens in a new tab).

Це анотований вихідний код. Якщо ви хочете реалізувати ERC-20, прочитайте цей посібник (opens in a new tab).

Інтерфейс

Мета такого стандарту, як ERC-20, полягає в тому, щоб дозволити багатьом реалізаціям токенів бути інтероперабельними в різних застосунках, таких як гаманці та децентралізовані біржі. Щоб досягти цього, ми створюємо інтерфейс (opens in a new tab). Будь-який код, якому потрібно використовувати контракт токена, може використовувати ті самі визначення в інтерфейсі та бути сумісним з усіма контрактами токенів, які його використовують, незалежно від того, чи це гаманець, як-от МетаМаск, децентралізований застосунок (dapp), як-от Etherscan.io, або інший контракт, як-от пул ліквідності.

Illustration of the ERC-20 interface

Якщо ви досвідчений програміст, ви, ймовірно, пам'ятаєте подібні конструкції в Java (opens in a new tab) або навіть у заголовних файлах C (opens in a new tab).

Це визначення інтерфейсу ERC-20 (opens in a new tab) від ОупенЗеппелін. Це переклад зрозумілого для людини стандарту (opens in a new tab) у код Solidity. Звісно, сам інтерфейс не визначає, як щось робити. Це пояснюється у вихідному коді контракту нижче.

 

// SPDX-License-Identifier: MIT

Файли Solidity повинні містити ідентифікатор ліцензії. Ви можете переглянути список ліцензій тут (opens in a new tab). Якщо вам потрібна інша ліцензія, просто поясніть це в коментарях.

 

pragma solidity >=0.6.0 <0.8.0;

Мова Solidity все ще швидко розвивається, і нові версії можуть бути несумісними зі старим кодом (дивіться тут (opens in a new tab)). Тому доцільно вказувати не лише мінімальну версію мови, але й максимальну — останню, з якою ви тестували код.

 

/**
 * @dev Інтерфейс стандарту ERC-20, як визначено в EIP.
 */

@dev у коментарі є частиною формату NatSpec (opens in a new tab), який використовується для створення документації з вихідного коду.

 

interface IERC20 {

За домовленістю, імена інтерфейсів починаються з I.

 

    /**
     * @dev Повертає кількість існуючих токенів.
     */
    function totalSupply() external view returns (uint256);

Ця функція є external, що означає, що її можна викликати лише ззовні контракту (opens in a new tab). Вона повертає загальну пропозицію токенів у контракті. Це значення повертається з використанням найпоширенішого типу в Етеріумі — 256-бітного цілого числа без знака (256 біт — це власний розмір слова EVM). Ця функція також є view, що означає, що вона не змінює стан, тому її можна виконати на одному вузлі замість того, щоб кожен вузол у блокчейні запускав її. Такий тип функції не генерує транзакцію і не потребує витрат газу.

Примітка: Теоретично може здатися, що творець контракту може схитрувати, повернувши меншу загальну пропозицію, ніж реальне значення, через що кожен токен здаватиметься ціннішим, ніж він є насправді. Однак це побоювання ігнорує справжню природу блокчейну. Усе, що відбувається в блокчейні, може бути перевірено кожним вузлом. Для досягнення цього машинний код і сховище кожного контракту доступні на кожному вузлі. Хоча ви не зобов'язані публікувати код Solidity для свого контракту, ніхто не сприйматиме вас серйозно, якщо ви не опублікуєте вихідний код і версію Solidity, за допомогою якої він був скомпільований, щоб його можна було перевірити на відповідність наданому вами машинному коду. Наприклад, подивіться цей контракт (opens in a new tab).

 

    /**
     * @dev Повертає кількість токенів, що належать `account`.
     */
    function balanceOf(address account) external view returns (uint256);

Як випливає з назви, balanceOf повертає баланс акаунта. Акаунти Етеріуму ідентифікуються в Solidity за допомогою типу address, який містить 160 біт. Вона також є external та view.

 

    /**
     * @dev Переміщує `amount` токенів з акаунта викликаючого до `recipient`.
     *
     * Повертає логічне значення, що вказує, чи була операція успішною.
     *
     * Генерує подію {Transfer}.
     */
    function transfer(address recipient, uint256 amount) external returns (bool);

Функція transfer переказує токени від того, хто її викликає, на іншу адресу. Це передбачає зміну стану, тому вона не є view. Коли користувач викликає цю функцію, вона створює транзакцію і потребує витрат газу. Вона також генерує подію Transfer, щоб повідомити всіх у блокчейні про цю подію.

Функція має два типи виводу для двох різних типів викликачів:

  • Користувачі, які викликають функцію безпосередньо з інтерфейсу користувача. Зазвичай користувач надсилає транзакцію і не чекає на відповідь, що може зайняти невизначений час. Користувач може побачити, що сталося, знайшовши квитанцію транзакції (яка ідентифікується через хеш транзакції) або знайшовши подію Transfer.
  • Інші контракти, які викликають функцію як частину загальної транзакції. Ці контракти отримують результат негайно, оскільки вони виконуються в тій самій транзакції, тому вони можуть використовувати значення, яке повертає функція.

Такий самий тип виводу створюється іншими функціями, які змінюють стан контракту.

 

Дозволи дають змогу акаунту витрачати певну кількість токенів, які належать іншому власнику. Це корисно, наприклад, для контрактів, які діють як продавці. Контракти не можуть відстежувати події, тому якби покупець переказав токени безпосередньо на контракт продавця, цей контракт не дізнався б, що йому заплатили. Натомість покупець дозволяє контракту продавця витратити певну суму, і продавець переказує цю суму. Це робиться через функцію, яку викликає контракт продавця, щоб контракт продавця міг дізнатися, чи була операція успішною.

    /**
     * @dev Повертає кількість токенів, що залишилася, яку `spender` матиме
     * дозвіл витратити від імені `owner` за допомогою {transferFrom}. За замовчуванням
     * це нуль.
     *
     * Це значення змінюється, коли викликаються {approve} або {transferFrom}.
     */
    function allowance(address owner, address spender) external view returns (uint256);

Функція allowance дозволяє будь-кому зробити запит, щоб побачити, який дозвіл одна адреса (owner) надає іншій адресі (spender) для витрачання.

 

Функція approve створює дозвіл. Обов'язково прочитайте повідомлення про те, як цим можна зловживати. В Етеріумі ви контролюєте порядок власних транзакцій, але ви не можете контролювати порядок, у якому будуть виконуватися транзакції інших людей, хіба що ви не надсилатимете власну транзакцію, доки не побачите, що транзакція іншої сторони відбулася.

 

Нарешті, transferFrom використовується витрачальником для фактичного витрачання дозволу.

 

Ці події генеруються, коли змінюється стан контракту ERC-20.

Фактичний контракт

Це фактичний контракт, який реалізує стандарт ERC-20, взятий звідси (opens in a new tab). Він не призначений для використання як є, але ви можете успадкувати (opens in a new tab) його, щоб розширити до чогось придатного для використання.

// SPDX-License-Identifier: MIT
pragma solidity >=0.6.0 <0.8.0;

 

Інструкції імпорту

Окрім визначень інтерфейсу вище, визначення контракту імпортує два інші файли:


import "../../GSN/Context.sol";
import "./IERC20.sol";
import "../../math/SafeMath.sol";
  • GSN/Context.sol — це визначення, необхідні для використання OpenGSN (opens in a new tab), системи, яка дозволяє користувачам без етеру використовувати блокчейн. Зверніть увагу, що це стара версія, якщо ви хочете інтегруватися з OpenGSN, скористайтеся цим посібником (opens in a new tab).
  • Бібліотека SafeMath (opens in a new tab), яка запобігає арифметичним переповненням/втратам значущості для версій Solidity <0.8.0. У Solidity ≥0.8.0 арифметичні операції автоматично скасовуються при переповненні/втраті значущості, що робить SafeMath непотрібною. Цей контракт використовує SafeMath для зворотної сумісності зі старішими версіями компілятора.

 

Цей коментар пояснює мету контракту.

Визначення контракту

contract ERC20 is Context, IERC20 {

Цей рядок визначає успадкування, у цьому випадку від IERC20 з наведеного вище та Context для OpenGSN.

 


    using SafeMath for uint256;

Цей рядок приєднує бібліотеку SafeMath до типу uint256. Ви можете знайти цю бібліотеку тут (opens in a new tab).

Визначення змінних

Ці визначення вказують змінні стану контракту. Ці змінні оголошені як private, але це лише означає, що інші контракти в блокчейні не можуть їх прочитати. У блокчейні немає секретів, програмне забезпечення на кожному вузлі має стан кожного контракту на кожному блоці. За домовленістю, змінні стану називаються _<something>.

Перші дві змінні є відображеннями (mappings) (opens in a new tab), що означає, що вони поводяться приблизно так само, як асоціативні масиви (opens in a new tab), за винятком того, що ключами є числові значення. Сховище виділяється лише для записів, які мають значення, відмінні від значення за замовчуванням (нуля).

    mapping (address => uint256) private _balances;

Перше відображення, _balances, — це адреси та їхні відповідні баланси цього токена. Щоб отримати доступ до балансу, використовуйте цей синтаксис: _balances[<address>].

 

    mapping (address => mapping (address => uint256)) private _allowances;

Ця змінна, _allowances, зберігає дозволи, пояснені раніше. Перший індекс — це власник токенів, а другий — контракт із дозволом. Щоб отримати доступ до суми, яку адреса A може витратити з акаунта адреси B, використовуйте _allowances[B][A].

 

    uint256 private _totalSupply;

Як випливає з назви, ця змінна відстежує загальну пропозицію токенів.

 

    string private _name;
    string private _symbol;
    uint8 private _decimals;

Ці три змінні використовуються для покращення читабельності. Перші дві зрозумілі самі по собі, але _decimals — ні.

З одного боку, Етеріум не має змінних із рухомою комою або дробових змінних. З іншого боку, людям подобається мати можливість ділити токени. Однією з причин, чому люди зупинилися на золоті як валюті, було те, що було важко дати решту, коли хтось хотів купити частину корови за ціною качки.

Рішення полягає в тому, щоб відстежувати цілі числа, але рахувати замість реального токена дробовий токен, який майже нічого не вартий. У випадку з етером дробовий токен називається Wei, і 10^18 Wei дорівнює одному ETH. На момент написання статті 10 000 000 000 000 Wei — це приблизно один цент США або євро.

Застосункам потрібно знати, як відображати баланс токенів. Якщо користувач має 3 141 000 000 000 000 000 Wei, це 3,14 ETH? 31,41 ETH? 3 141 ETH? У випадку з етером визначено 10^18 Wei на ETH, але для вашого токена ви можете вибрати інше значення. Якщо ділення токена не має сенсу, ви можете використовувати значення _decimals рівне нулю. Якщо ви хочете використовувати той самий стандарт, що й ETH, використовуйте значення 18.

Конструктор

Конструктор викликається під час першого створення контракту. За домовленістю, параметри функції називаються <something>_.

Функції інтерфейсу користувача

Ці функції, name, symbol та decimals, допомагають інтерфейсам користувача дізнатися про ваш контракт, щоб вони могли відображати його належним чином.

Тип повернення — string memory, що означає повернення рядка, який зберігається в пам'яті. Змінні, такі як рядки, можуть зберігатися в трьох місцях:

Час життяДоступ контрактуВитрати газу
Пам'ятьВиклик функціїЧитання/ЗаписДесятки або сотні (більше для вищих розташувань)
Дані викликуВиклик функціїЛише читанняНе може використовуватися як тип повернення, лише як тип параметра функції
СховищеДоки не буде зміненоЧитання/ЗаписВисокі (800 для читання, 20 тис. для запису)

У цьому випадку memory є найкращим вибором.

Читання інформації про токен

Це функції, які надають інформацію про токен: або загальну пропозицію, або баланс акаунта.

    /**
     * @dev Див. {IERC20-totalSupply}.
     */
    function totalSupply() public view override returns (uint256) {
        return _totalSupply;
    }

Функція totalSupply повертає загальну пропозицію токенів.

 

    /**
     * @dev Див. {IERC20-balanceOf}.
     */
    function balanceOf(address account) public view override returns (uint256) {
        return _balances[account];
    }

Читання балансу акаунта. Зверніть увагу, що будь-кому дозволено отримувати баланс акаунта будь-кого іншого. Немає сенсу намагатися приховати цю інформацію, оскільки вона все одно доступна на кожному вузлі. У блокчейні немає секретів.

Переказ токенів

Функція transfer викликається для переказу токенів з акаунта відправника на інший. Зверніть увагу, що хоча вона повертає логічне значення, це значення завжди true. Якщо переказ не вдається, контракт скасовує виклик.

 

        _transfer(_msgSender(), recipient, amount);
        return true;
    }

Функція _transfer виконує фактичну роботу. Це приватна функція, яку можуть викликати лише інші функції контракту. За домовленістю, приватні функції називаються _<something>, так само як і змінні стану.

Зазвичай у Solidity ми використовуємо msg.sender для відправника повідомлення. Однак це ламає OpenGSN (opens in a new tab). Якщо ми хочемо дозволити транзакції без етеру з нашим токеном, нам потрібно використовувати _msgSender(). Вона повертає msg.sender для звичайних транзакцій, але для транзакцій без етеру повертає початкового підписанта, а не контракт, який ретранслював повідомлення.

Функції дозволів

Це функції, які реалізують функціональність дозволів: allowance, approve, transferFrom та _approve. Крім того, реалізація ОупенЗеппелін виходить за межі базового стандарту, включаючи деякі функції, які покращують безпеку: increaseAllowance та decreaseAllowance.

Функція allowance

    /**
     * @dev Див. {IERC20-allowance}.
     */
    function allowance(address owner, address spender) public view virtual override returns (uint256) {
        return _allowances[owner][spender];
    }

Функція allowance дозволяє будь-кому перевірити будь-який дозвіл.

Функція approve

    /**
     * @dev Див. {IERC20-approve}.
     *
     * Вимоги:
     *
     * - `spender` не може бути нульовою адресою.
     */
    function approve(address spender, uint256 amount) public virtual override returns (bool) {

Ця функція викликається для створення дозволу. Вона схожа на функцію transfer вище:

  • Функція просто викликає внутрішню функцію (у цьому випадку _approve), яка виконує реальну роботу.
  • Функція або повертає true (у разі успіху), або скасовується (якщо ні).

 

        _approve(_msgSender(), spender, amount);
        return true;
    }

Ми використовуємо внутрішні функції, щоб мінімізувати кількість місць, де відбуваються зміни стану. Будь-яка функція, яка змінює стан, є потенційним ризиком для безпеки, який потрібно перевірити на предмет безпеки. Таким чином у нас менше шансів помилитися.

Функція transferFrom

Це функція, яку викликає витрачальник, щоб витратити дозвіл. Це вимагає двох операцій: переказати суму, що витрачається, і зменшити дозвіл на цю суму.

 

Виклик функції a.sub(b, "message") робить дві речі. По-перше, він обчислює a-b, що є новим дозволом. По-друге, він перевіряє, чи цей результат не є від'ємним. Якщо він від'ємний, виклик скасовується з наданим повідомленням. Зверніть увагу, що коли виклик скасовується, будь-яка обробка, виконана раніше під час цього виклику, ігнорується, тому нам не потрібно скасовувати _transfer.

        _approve(sender, _msgSender(), _allowances[sender][_msgSender()].sub(amount,
             "ERC20: transfer amount exceeds allowance"));
        return true;
    }

Доповнення безпеки від ОупенЗеппелін

Небезпечно встановлювати ненульовий дозвіл на інше ненульове значення, оскільки ви контролюєте лише порядок власних транзакцій, а не чиїхось ще. Уявіть, що у вас є два користувачі: наївна Аліса та нечесний Білл. Аліса хоче отримати певну послугу від Білла, яка, на її думку, коштує п'ять токенів — тому вона дає Біллу дозвіл на п'ять токенів.

Потім щось змінюється, і ціна Білла зростає до десяти токенів. Аліса, яка все ще хоче отримати послугу, надсилає транзакцію, яка встановлює дозвіл Білла на десять. У той момент, коли Білл бачить цю нову транзакцію в пулі транзакцій, він надсилає транзакцію, яка витрачає п'ять токенів Аліси та має набагато вищу ціну газу, щоб вона була видобута швидше. Таким чином Білл може витратити спочатку п'ять токенів, а потім, коли новий дозвіл Аліси буде видобуто, витратити ще десять на загальну суму п'ятнадцять токенів, що більше, ніж Аліса мала намір дозволити. Ця техніка називається випередженням (opens in a new tab)

Транзакція АлісиНонс АлісиТранзакція БіллаНонс БіллаДозвіл БіллаЗагальний дохід Білла від Аліси
approve(Bill, 5)1050
transferFrom(Alice, Bill, 5)10,12305
approve(Bill, 10)11105
transferFrom(Alice, Bill, 10)10,124015

Щоб уникнути цієї проблеми, ці дві функції (increaseAllowance та decreaseAllowance) дозволяють вам змінити дозвіл на певну суму. Отже, якщо Білл уже витратив п'ять токенів, він зможе витратити ще лише п'ять. Залежно від часу, є два способи, як це може спрацювати, і обидва закінчуються тим, що Білл отримує лише десять токенів:

A:

Транзакція АлісиНонс АлісиТранзакція БіллаНонс БіллаДозвіл БіллаЗагальний дохід Білла від Аліси
approve(Bill, 5)1050
transferFrom(Alice, Bill, 5)10,12305
increaseAllowance(Bill, 5)110+5 = 55
transferFrom(Alice, Bill, 5)10,124010

B:

Транзакція АлісиНонс АлісиТранзакція БіллаНонс БіллаДозвіл БіллаЗагальний дохід Білла від Аліси
approve(Bill, 5)1050
increaseAllowance(Bill, 5)115+5 = 100
transferFrom(Alice, Bill, 10)10,124010

Функція a.add(b) — це безпечне додавання. У малоймовірному випадку, коли a+b>=2^256, вона не переповнюється так, як це робить звичайне додавання.

Функції, які змінюють інформацію про токен

Ось чотири функції, які виконують фактичну роботу: _transfer, _mint, _burn та _approve.

Функція _transfer

Ця функція, _transfer, переказує токени з одного акаунта на інший. Її викликають як transfer (для переказів із власного акаунта відправника), так і transferFrom (для використання дозволів на переказ із чужого акаунта).

 

        require(sender != address(0), "ERC20: transfer from the zero address");
        require(recipient != address(0), "ERC20: transfer to the zero address");

Насправді ніхто не володіє нульовою адресою в Етеріумі (тобто ніхто не знає приватного ключа, відповідний відкритий ключ якого перетворюється на нульову адресу). Коли люди використовують цю адресу, це зазвичай програмна помилка — тому ми завершуємося з помилкою, якщо нульова адреса використовується як відправник або одержувач.

 

        _beforeTokenTransfer(sender, recipient, amount);

Є два способи використання цього контракту:

  1. Використовувати його як шаблон для власного коду
  2. Успадкувати від нього (opens in a new tab) та перевизначити лише ті функції, які вам потрібно змінити

Другий метод набагато кращий, оскільки код ERC-20 від ОупенЗеппелін уже пройшов аудит і довів свою безпеку. Коли ви використовуєте успадкування, зрозуміло, які функції ви змінюєте, і щоб довіряти вашому контракту, людям потрібно перевірити лише ці конкретні функції.

Часто буває корисно виконувати функцію щоразу, коли токени переходять з рук у руки. Однак _transfer є дуже важливою функцією, і її можна написати небезпечно (див. нижче), тому краще її не перевизначати. Рішенням є _beforeTokenTransfer, функція-хук (opens in a new tab). Ви можете перевизначити цю функцію, і вона буде викликатися під час кожного переказу.

 

        _balances[sender] = _balances[sender].sub(amount, "ERC20: transfer amount exceeds balance");
        _balances[recipient] = _balances[recipient].add(amount);

Це рядки, які фактично виконують переказ. Зверніть увагу, що між ними нічого немає, і що ми віднімаємо переказану суму від відправника перед тим, як додати її одержувачу. Це важливо, оскільки якби посередині був виклик іншого контракту, його можна було б використати для обману цього контракту. Таким чином переказ є атомарним, нічого не може статися посеред нього.

 

        emit Transfer(sender, recipient, amount);
    }

Нарешті, згенеруйте подію Transfer. Події недоступні для смарт-контрактів, але код, що працює поза блокчейном, може прослуховувати події та реагувати на них. Наприклад, гаманець може відстежувати, коли власник отримує більше токенів.

Функції _mint та _burn

Ці дві функції (_mint та _burn) змінюють загальну пропозицію токенів. Вони є внутрішніми, і в цьому контракті немає функції, яка б їх викликала, тому вони корисні лише в тому випадку, якщо ви успадковуєте контракт і додаєте власну логіку, щоб вирішити, за яких умов карбувати нові токени або спалювати наявні.

ПРИМІТКА: Кожен токен ERC-20 має власну бізнес-логіку, яка диктує управління токенами. Наприклад, контракт із фіксованою пропозицією може викликати _mint лише в конструкторі та ніколи не викликати _burn. Контракт, який продає токени, викликатиме _mint під час оплати, і, ймовірно, викликатиме _burn у певний момент, щоб уникнути неконтрольованої інфляції.

Обов'язково оновлюйте _totalSupply, коли змінюється загальна кількість токенів.

 

Функція _burn майже ідентична _mint, за винятком того, що вона працює у зворотному напрямку.

Функція _approve

Це функція, яка фактично визначає дозволи. Зверніть увагу, що вона дозволяє власнику вказати дозвіл, який перевищує поточний баланс власника. Це нормально, оскільки баланс перевіряється під час переказу, коли він може відрізнятися від балансу на момент створення дозволу.

 

Згенеруйте подію Approval. Залежно від того, як написано застосунок, контракту-витрачальнику може бути повідомлено про схвалення або власником, або сервером, який прослуховує ці події.

        emit Approval(owner, spender, amount);
    }

Зміна змінної Decimals

Ця функція змінює змінну _decimals, яка використовується для того, щоб повідомити інтерфейсам користувача, як інтерпретувати суму. Ви повинні викликати її з конструктора. Було б нечесно викликати її в будь-який наступний момент, і застосунки не призначені для обробки цього.

Хуки

Це функція-хук, яка викликається під час переказів. Тут вона порожня, але якщо вам потрібно, щоб вона щось робила, ви просто перевизначаєте її.

Висновок

Для повторення, ось деякі з найважливіших ідей у цьому контракті (на мою думку, ваша, ймовірно, відрізнятиметься):

  • У блокчейні немає секретів. Будь-яка інформація, до якої може отримати доступ смарт-контракт, доступна всьому світу.
  • Ви можете контролювати порядок власних транзакцій, але не те, коли відбуваються транзакції інших людей. Це причина, чому зміна дозволу може бути небезпечною, оскільки це дозволяє витрачальнику витратити суму обох дозволів.
  • Значення типу uint256 переповнюються. Іншими словами, 0-1=2^256-1. Якщо це не бажана поведінка, ви повинні перевірити це (або використати бібліотеку SafeMath, яка зробить це за вас). Зверніть увагу, що це змінилося в Solidity 0.8.0 (opens in a new tab).
  • Робіть усі зміни стану певного типу в певному місці, оскільки це полегшує аудит. Це причина, чому ми маємо, наприклад, _approve, яка викликається approve, transferFrom, increaseAllowance та decreaseAllowance
  • Зміни стану мають бути атомарними, без будь-яких інших дій посередині (як ви можете бачити в _transfer). Це пов'язано з тим, що під час зміни стану ви маєте неузгоджений стан. Наприклад, між часом, коли ви віднімаєте з балансу відправника, і часом, коли ви додаєте до балансу одержувача, існує менше токенів, ніж має бути. Цим потенційно можна зловживати, якщо між ними є операції, особливо виклики іншого контракту.

Тепер, коли ви побачили, як написаний контракт ERC-20 від ОупенЗеппелін, і особливо як він зроблений більш безпечним, ідіть і пишіть власні безпечні контракти та застосунки.

Дивіться тут більше моїх робіт (opens in a new tab).