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

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

Solidity
erc-20
Початківець
Ori Pomerantz
9 березня 2021 р.
24 читається за хвилину

Вступ

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

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

Інтерфейс

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

Ілюстрація інтерфейсу ERC-20

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

Це визначення інтерфейсу ERC-20 (opens in a new tab) від OpenZeppelin. Це переклад зручного для читання стандарту (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 Інтерфейс стандарту ERC20, як визначено в EIP.
 */

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

 

interface IERC20 {

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

 

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

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

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

 

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

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

 

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

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

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

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

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

 

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

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

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

 

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

 

Нарешті, 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), системи, яка дозволяє користувачам без ether використовувати блокчейн. Зауважте, що це стара версія. Якщо ви хочете інтегруватися з 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, зберігає дозволи, пояснені раніше. Перший індекс — це власник токенів, а другий — контракт із дозволом. Щоб отримати доступ до суми, яку адреса А може витратити з облікового запису адреси Б, використовуйте _allowances[B][A].

 

    uint256 private _totalSupply;

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

 

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

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

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

Рішення полягає в тому, щоб відстежувати цілі числа, але рахувати не справжній токен, а дробовий токен, який майже нічого не вартий. У випадку ether дробовий токен називається 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? 3141 ETH? У випадку ether визначено, що 10^18 wei дорівнює одному ETH, але для вашого токена ви можете вибрати інше значення. Якщо ділення токена не має сенсу, ви можете використовувати значення _decimals, що дорівнює нулю. Якщо ви хочете використовувати той самий стандарт, що і для ETH, використовуйте значення 18.

Конструктор

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

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

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

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

Час життяДоступ до контрактуВартість газу
Пам’ятьВиклик функціїЧитання/ЗаписДесятки або сотні (більше для вищих рівнів)
CalldataВиклик функціїЛише для читанняНе можна використовувати як тип, що повертається, тільки як тип параметра функції
СховищеДо зміниЧитання/ЗаписВисока (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). Якщо ми хочемо дозволити транзакції без ether з нашим токеном, ми повинні використовувати _msgSender(). Вона повертає msg.sender для звичайних транзакцій, але для транзакцій без ether повертає початкового підписанта, а не контракт, що передав повідомлення.

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

Це функції, які реалізують функціонал дозволів: allowance, approve, transferFrom та _approve. Крім того, реалізація OpenZeppelin виходить за рамки базового стандарту, включаючи деякі функції, що покращують безпеку: 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: сума переказу перевищує дозвіл"));
        return true;
    }

Доповнення безпеки від OpenZeppelin

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

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

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

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

A:

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

B:

Транзакція АлісиNonce АлісиТранзакція БіллаNonce БіллаДозвіл для БіллаЗагальний дохід Білла від Аліси
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: переказ із нульової адреси");
        require(recipient != address(0), "ERC20: переказ на нульову адресу");

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

 

        _beforeTokenTransfer(sender, recipient, amount);

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

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

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

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

 

        _balances[sender] = _balances[sender].sub(amount, "ERC20: сума переказу перевищує баланс");
        _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). Це тому, що під час зміни стану ви маєте неузгоджений стан. Наприклад, між моментом, коли ви віднімаєте з балансу відправника, і моментом, коли ви додаєте до балансу одержувача, існує менше токенів, ніж повинно бути. Цим можна потенційно зловживати, якщо між ними є операції, особливо виклики до іншого контракту.

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

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

Останнє оновлення сторінки: 15 квітня 2026 р.

Цей посібник був корисним?