ERC-20 із запобіжниками
Вступ
Одна з чудових рис Етеріуму полягає в тому, що не існує центрального органу, який міг би змінювати або скасовувати ваші транзакції. Одна з великих проблем Етеріуму полягає в тому, що не існує центрального органу, який мав би повноваження скасовувати помилки користувачів або незаконні транзакції. У цій статті ви дізнаєтеся про деякі поширені помилки, яких припускаються користувачі з токенами ERC-20, а також про те, як створювати контракти ERC-20, які допомагають користувачам уникати цих помилок або надають центральному органу певні повноваження (наприклад, заморожувати акаунти).
Зверніть увагу, що хоча ми будемо використовувати контракт токена ERC-20 від ОупенЗеппелін (opens in a new tab), ця стаття не пояснює його в усіх деталях. Ви можете знайти цю інформацію тут.
Якщо ви хочете переглянути повний вихідний код:
- Відкрийте Remix IDE (opens in a new tab).
- Натисніть значок клонування GitHub (
). - Склонуйте репозиторій GitHub
https://github.com/qbzzt/20220815-erc20-safety-rails. - Відкрийте contracts > erc20-safety-rails.sol.
Створення контракту ERC-20
Перш ніж ми зможемо додати функціональність запобіжників, нам потрібен контракт ERC-20. У цій статті ми будемо використовувати Майстер контрактів ОупенЗеппелін (opens in a new tab). Відкрийте його в іншому браузері та дотримуйтесь цих інструкцій:
-
Виберіть ERC20.
-
Введіть ці налаштування:
Параметр Значення Назва SafetyRailsToken Символ SAFE Попередній випуск (Premint) 1000 Функції Немає Контроль доступу Ownable Можливість оновлення Немає -
Прокрутіть вгору та натисніть Open in Remix (для Remix) або Download, щоб використовувати інше середовище. Я припускаю, що ви використовуєте Remix, якщо ви використовуєте щось інше, просто внесіть відповідні зміни.
-
Тепер у нас є повністю функціональний контракт ERC-20. Ви можете розгорнути
.deps>npm, щоб побачити імпортований код. -
Скомпілюйте, розгорніть та попрацюйте з контрактом, щоб переконатися, що він функціонує як контракт ERC-20. Якщо вам потрібно дізнатися, як користуватися Remix, скористайтеся цим посібником (opens in a new tab).
Поширені помилки
Помилки
Користувачі іноді надсилають токени на неправильну адресу. Хоча ми не можемо читати їхні думки, щоб дізнатися, що вони мали на увазі, існують два типи помилок, які трапляються часто і які легко виявити:
-
Надсилання токенів на власну адресу контракту. Наприклад, токен OP від Optimism (opens in a new tab) зумів накопичити понад 120 000 (opens in a new tab) токенів OP менш ніж за два місяці. Це значна сума коштів, яку люди, ймовірно, просто втратили.
-
Надсилання токенів на порожню адресу, яка не відповідає зовнішньому акаунту (EOA) або смарт-контракту. Хоча у мене немає статистики про те, як часто це трапляється, один інцидент міг коштувати 20 000 000 токенів (opens in a new tab).
Запобігання переказам
Контракт ERC-20 від ОупенЗеппелін містить хук _beforeTokenTransfer (opens in a new tab), який викликається перед переказом токена. За замовчуванням цей хук нічого не робить, але ми можемо прив'язати до нього власну функціональність, наприклад, перевірки, які скасовують транзакцію у разі виникнення проблеми.
Щоб використати хук, додайте цю функцію після конструктора:
function _beforeTokenTransfer(address from, address to, uint256 amount)
internal virtual
override(ERC20)
{
super._beforeTokenTransfer(from, to, amount);
}
Деякі частини цієї функції можуть бути новими для вас, якщо ви не дуже добре знайомі з Solidity:
internal virtual
Ключове слово virtual означає, що так само, як ми успадкували функціональність від ERC20 і перевизначили цю функцію, інші контракти можуть успадковувати від нас і перевизначати цю функцію.
override(ERC20)
Ми повинні явно вказати, що ми перевизначаємо (opens in a new tab) визначення _beforeTokenTransfer токена ERC20. Загалом, явні визначення набагато кращі з точки зору безпеки, ніж неявні — ви не можете забути, що щось зробили, якщо це прямо перед вами. Це також причина, чому нам потрібно вказати, який саме _beforeTokenTransfer суперкласу ми перевизначаємо.
super._beforeTokenTransfer(from, to, amount);
Цей рядок викликає функцію _beforeTokenTransfer контракту або контрактів, від яких ми успадкували і які її мають. У цьому випадку це лише ERC20, оскільки Ownable не має цього хука. Хоча наразі ERC20._beforeTokenTransfer нічого не робить, ми викликаємо його на випадок, якщо функціональність буде додана в майбутньому (і ми тоді вирішимо повторно розгорнути контракт, оскільки контракти не змінюються після розгортання).
Програмування вимог
Ми хочемо додати до функції такі вимоги:
- Адреса
toне може дорівнюватиaddress(this), адресі самого контракту ERC-20. - Адреса
toне може бути порожньою, вона має бути однією з таких:- Зовнішній акаунт (EOA). Ми не можемо безпосередньо перевірити, чи є адреса EOA, але ми можемо перевірити баланс ETH на адресі. EOA майже завжди мають баланс, навіть якщо вони більше не використовуються — їх важко очистити до останнього Wei.
- Смарт-контракт. Перевірити, чи є адреса смарт-контрактом, трохи складніше. Існує опкод, який перевіряє довжину зовнішнього коду, що називається
EXTCODESIZE(opens in a new tab), але він недоступний безпосередньо в Solidity. Для цього нам потрібно використовувати Yul (opens in a new tab), який є асемблером EVM. Існують інші значення, які ми могли б використовувати з Solidity (<address>.codeта<address>.codehash(opens in a new tab)), але вони коштують дорожче.
Давайте пройдемося по новому коду рядок за рядком:
require(to != address(this), "Can't send tokens to the contract address");
Це перша вимога: перевірити, що to та this(address) не є одним і тим самим.
bool isToContract;
assembly {
isToContract := gt(extcodesize(to), 0)
}
Ось як ми перевіряємо, чи є адреса контрактом. Ми не можемо отримати вивід безпосередньо з Yul, тому замість цього ми визначаємо змінну для зберігання результату (у цьому випадку isToContract). Yul працює таким чином, що кожен опкод розглядається як функція. Тому спочатку ми викликаємо EXTCODESIZE (opens in a new tab), щоб отримати розмір контракту, а потім використовуємо GT (opens in a new tab), щоб перевірити, що він не дорівнює нулю (ми маємо справу з цілими числами без знака, тому, звичайно, воно не може бути від'ємним). Потім ми записуємо результат у isToContract.
require(to.balance != 0 || isToContract, "Can't send tokens to an empty address");
І нарешті, у нас є фактична перевірка на порожні адреси.
Адміністративний доступ
Іноді корисно мати адміністратора, який може скасовувати помилки. Щоб зменшити ймовірність зловживань, цим адміністратором може бути мультипідпис (opens in a new tab), щоб кілька людей мали погодити дію. У цій статті ми розглянемо дві адміністративні функції:
-
Заморожування та розморожування акаунтів. Це може бути корисно, наприклад, коли акаунт може бути скомпрометований.
-
Очищення активів.
Іноді шахраї надсилають шахрайські токени на контракт справжнього токена, щоб отримати легітимність. Наприклад, дивіться тут (opens in a new tab). Легітимний контракт ERC-20 — це 0x4200....0042 (opens in a new tab). Шахрайський контракт, який видає себе за нього, — це 0x234....bbe (opens in a new tab).
Також можливо, що люди помилково надсилають легітимні токени ERC-20 на наш контракт, що є ще однією причиною мати спосіб їх вивести.
ОупенЗеппелін надає два механізми для забезпечення адміністративного доступу:
- Контракти
Ownable(opens in a new tab) мають одного власника. Функції, які мають модифікатор (opens in a new tab)onlyOwner, можуть бути викликані лише цим власником. Власники можуть передати право власності комусь іншому або повністю відмовитися від нього. Права всіх інших акаунтів зазвичай ідентичні. - Контракти
AccessControl(opens in a new tab) мають контроль доступу на основі ролей (RBAC) (opens in a new tab).
Задля простоти у цій статті ми використовуємо Ownable.
Заморожування та розморожування контрактів
Заморожування та розморожування контрактів вимагає кількох змін:
-
Відображення (mapping) (opens in a new tab) адрес на логічні значення (booleans) (opens in a new tab) для відстеження того, які адреси заморожені. Усі значення спочатку дорівнюють нулю, що для логічних значень інтерпретується як false. Це саме те, що нам потрібно, оскільки за замовчуванням акаунти не заморожені.
mapping(address => bool) public frozenAccounts; -
Події (opens in a new tab) для інформування всіх зацікавлених про те, коли акаунт заморожується або розморожується. Технічно кажучи, події не є обов'язковими для цих дій, але це допомагає позамережевому коду слухати ці події та знати, що відбувається. Вважається хорошим тоном для смарт-контракту генерувати їх, коли відбувається щось, що може бути важливим для когось іншого.
Події індексуються, тому можна буде знайти всі випадки, коли акаунт був заморожений або розморожений.
// Коли акаунти заморожено або розморожено event AccountFrozen(address indexed _addr); event AccountThawed(address indexed _addr); -
Функції для заморожування та розморожування акаунтів. Ці дві функції майже ідентичні, тому ми розглянемо лише функцію заморожування.
function freezeAccount(address addr) public onlyOwnerФункції, позначені як
public(opens in a new tab), можуть бути викликані з інших смарт-контрактів або безпосередньо транзакцією.{ require(!frozenAccounts[addr], "Account already frozen"); frozenAccounts[addr] = true; emit AccountFrozen(addr); } // freezeAccountЯкщо акаунт вже заморожений, скасувати транзакцію. В іншому випадку заморозити його та згенерувати (
emit) подію. -
Змінити
_beforeTokenTransfer, щоб запобігти переміщенню коштів із замороженого акаунта. Зверніть увагу, що кошти все ще можна переказувати на заморожений акаунт.require(!frozenAccounts[from], "The account is frozen");
Очищення активів
Щоб вивільнити токени ERC-20, які зберігаються на цьому контракті, нам потрібно викликати функцію в контракті токена, якому вони належать: або transfer (opens in a new tab), або approve (opens in a new tab). У цьому випадку немає сенсу витрачати газ на дозволи (allowances), ми можемо зробити прямий переказ.
function cleanupERC20(
address erc20,
address dest
)
public
onlyOwner
{
IERC20 token = IERC20(erc20);
Це синтаксис для створення об'єкта контракту, коли ми отримуємо адресу. Ми можемо це зробити, оскільки маємо визначення для токенів ERC20 як частину вихідного коду (див. рядок 4), і цей файл містить визначення для IERC20 (opens in a new tab), інтерфейсу для контракту ERC-20 від ОупенЗеппелін.
uint balance = token.balanceOf(address(this));
token.transfer(dest, balance);
}
Це функція очищення, тому, ймовірно, ми не хочемо залишати жодних токенів. Замість того, щоб отримувати баланс від користувача вручну, ми можемо автоматизувати цей процес.
Висновок
Це не ідеальне рішення — не існує ідеального рішення для проблеми «користувач зробив помилку». Однак використання таких перевірок може принаймні запобігти деяким помилкам. Можливість заморожувати акаунти, хоч і є небезпечною, може бути використана для обмеження шкоди від певних зломів шляхом позбавлення хакера доступу до вкрадених коштів.