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