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

ERC-20 із захисними механізмами

erc-20
Початківець
Ori Pomerantz
15 серпня 2022 р.
8 читається за хвилину

Вступ

Однією з чудових особливостей Ethereum є те, що немає центрального органу, який міг би змінити або скасувати ваші транзакції. Однією з найбільших проблем Ethereum є те, що немає центрального органу, який би мав повноваження скасовувати помилки користувачів або незаконні транзакції. У цій статті ви дізнаєтеся про деякі поширені помилки, яких припускаються користувачі з токенами ERC-20, а також про те, як створювати контракти ERC-20, які допомагають користувачам уникнути цих помилок або які надають центральному органу певні повноваження (наприклад, для заморожування рахунків).

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

Якщо ви хочете побачити повний вихідний код:

  1. Відкрийте Remix IDE (opens in a new tab).
  2. Натисніть на іконку клонування з GitHub (clone github icon).
  3. Клонуйте репозиторій GitHub https://github.com/qbzzt/20220815-erc20-safety-rails.
  4. Відкрийте contracts > erc20-safety-rails.sol.

Створення контракту ERC-20

Перш ніж ми зможемо додати функціонал захисних механізмів, нам потрібен контракт ERC-20. У цій статті ми будемо використовувати Майстер контрактів OpenZeppelin (opens in a new tab). Відкрийте його в іншому браузері та дотримуйтесь цих інструкцій:

  1. Виберіть ERC20.

  2. Введіть ці налаштування:

    ПараметрЗначення
    Ім’яSafetyRailsToken
    СимволSAFE
    Premint1000
    ОсобливостіНемає
    Контроль доступуOwnable
    Можливість оновленняНемає
  3. Прокрутіть угору та натисніть Відкрити в Remix (для Remix) або Завантажити, щоб використовувати інше середовище. Я припускатиму, що ви використовуєте Remix. Якщо ви використовуєте щось інше, просто внесіть відповідні зміни.

  4. Тепер у нас є повнофункціональний контракт ERC-20. Ви можете розгорнути .deps > npm, щоб переглянути імпортований код.

  5. Скомпілюйте, розгорніть і пограйтеся з контрактом, щоб переконатися, що він функціонує як контракт ERC-20. Якщо вам потрібно навчитися користуватися Remix, скористайтеся цим посібником (opens in a new tab).

Поширені помилки

Помилки

Користувачі іноді надсилають токени на неправильну адресу. Хоча ми не можемо читати їхні думки, щоб знати, що вони мали намір зробити, є два типи помилок, які трапляються часто і які легко виявити:

  1. Надсилання токенів на власну адресу контракту. Наприклад, токену OP від Optimism (opens in a new tab) вдалося накопичити понад 120 000 (opens in a new tab) токенів OP менш ніж за два місяці. Це значна сума, яку, імовірно, люди просто втратили.

  2. Надсилання токенів на порожню адресу, яка не відповідає зовнішньому обліковому запису або смартконтракту. Хоча я не маю статистики про те, як часто це трапляється, один інцидент міг коштувати 20 000 000 токенів (opens in a new tab).

Запобігання переказам

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

Щоб використати хук, додайте цю функцію після конструктора:

1 function _beforeTokenTransfer(address from, address to, uint256 amount)
2 internal virtual
3 override(ERC20)
4 {
5 super._beforeTokenTransfer(from, to, amount);
6 }

Деякі частини цієї функції можуть бути новими для вас, якщо ви не дуже добре знайомі з Solidity:

1 internal virtual

Ключове слово virtual означає, що так само, як ми успадкували функціонал від ERC20 і перевизначили цю функцію, інші контракти можуть успадковувати від нашого контракту та перевизначати цю функцію.

1 override(ERC20)

Ми маємо явно вказати, що перевизначаємо (opens in a new tab) визначення _beforeTokenTransfer з контракту токена ERC20. Загалом, з точки зору безпеки, явні визначення набагато кращі за неявні — ви не зможете забути, що щось зробили, якщо це прямо перед вами. Це також причина, чому нам потрібно вказувати, _beforeTokenTransfer якого суперкласу ми перевизначаємо.

1 super._beforeTokenTransfer(from, to, amount);

Цей рядок викликає функцію _beforeTokenTransfer контракту або контрактів, від яких ми успадкували її. У цьому випадку це лише ERC20, оскільки Ownable не має цього хука. Хоча наразі ERC20._beforeTokenTransfer нічого не робить, ми викликаємо його на випадок, якщо в майбутньому буде додано новий функціонал (і ми вирішимо повторно розгорнути контракт, оскільки контракти не змінюються після розгортання).

Програмування вимог

Ми хочемо додати до функції такі вимоги:

  • Адреса to не може дорівнювати address(this), тобто адресі самого контракту ERC-20.
  • Адреса to не може бути порожньою, вона має бути одним із таких варіантів:
    • Зовнішній обліковий запис (EOA). Ми не можемо безпосередньо перевірити, чи є адреса EOA, але ми можемо перевірити баланс ETH на цій адресі. EOA майже завжди мають баланс, навіть якщо вони більше не використовуються — їх важко очистити до останнього wei.
    • Смартконтракт. Перевірити, чи є адреса смартконтрактом, трохи складніше. Існує опкод EXTCODESIZE, який перевіряє довжину зовнішнього коду, але він недоступний безпосередньо в Solidity. Для цього нам доведеться використовувати Yul (opens in a new tab), який є асемблером EVM. Існують інші значення, які ми могли б використовувати з Solidity (<address>.code та <address>.codehash (opens in a new tab)), але вони коштують дорожче.

Розгляньмо новий код рядок за рядком:

1 require(to != address(this), "Неможливо надіслати токени на адресу контракту");

Це перша вимога: перевірка, що to і this(address) не є одним і тим самим.

1 bool isToContract;
2 assembly {
3 isToContract := gt(extcodesize(to), 0)
4 }

Таким чином ми перевіряємо, чи є адреса контрактом. Ми не можемо отримати вивід безпосередньо з Yul, тому замість цього ми визначаємо змінну для зберігання результату (у цьому випадку isToContract). Принцип роботи Yul полягає в тому, що кожен опкод розглядається як функція. Отже, спочатку ми викликаємо EXTCODESIZE (opens in a new tab), щоб отримати розмір контракту, а потім використовуємо GT (opens in a new tab), щоб перевірити, що він не дорівнює нулю (ми маємо справу з беззнаковими цілими числами, тому, звичайно, він не може бути від’ємним). Потім ми записуємо результат у isToContract.

1 require(to.balance != 0 || isToContract, "Неможливо надіслати токени на порожню адресу");

І нарешті, ми маємо власне перевірку на порожні адреси.

Адміністративний доступ

Іноді корисно мати адміністратора, який може виправляти помилки. Щоб зменшити потенціал для зловживань, цей адміністратор може бути мультипідписом (multisig) (opens in a new tab), щоб кілька людей мали погодити дію. У цій статті ми розглянемо дві адміністративні функції:

  1. Заморожування та розморожування рахунків. Це може бути корисно, наприклад, якщо обліковий запис скомпрометовано.

  2. Очищення активів.

    Іноді шахраї надсилають шахрайські токени на контракт справжнього токена, щоб отримати легітимність. Наприклад, дивіться тут (opens in a new tab). Легітимний контракт ERC-20 — це 0x4200....0042 (opens in a new tab). Шахрайський контракт, що видає себе за нього, — це 0x234....bbe (opens in a new tab).

    Також можливо, що люди помилково надсилають легітимні токени ERC-20 на наш контракт, що є ще однією причиною мати спосіб їх вивести.

OpenZeppelin надає два механізми для ввімкнення адміністративного доступу:

Для простоти в цій статті ми використовуємо Ownable.

Заморожування та розморожування контрактів

Заморожування та розморожування контрактів вимагає кількох змін:

  • Відображення (mapping) (opens in a new tab) з адрес на булеві значення (opens in a new tab) для відстеження заморожених адрес. Усі значення спочатку дорівнюють нулю, що для булевих значень інтерпретується як false. Це те, чого ми хочемо, оскільки за замовчуванням рахунки не заморожені.

    1 mapping(address => bool) public frozenAccounts;
  • Події (events) (opens in a new tab) для інформування всіх зацікавлених, коли рахунок заморожено або розморожено. Технічно кажучи, події не є обов’язковими для цих дій, але вони допомагають коду поза ланцюжком (offchain) прослуховувати ці події та знати, що відбувається. Вважається хорошим тоном, якщо смартконтракт генерує їх, коли відбувається щось, що може бути важливим для когось іншого.

    Події індексуються, тому можна буде шукати всі випадки заморожування або розморожування рахунку.

    1 // Коли рахунки заморожуються або розморожуються
    2 event AccountFrozen(address indexed _addr);
    3 event AccountThawed(address indexed _addr);
  • Функції для заморожування та розморожування рахунків. Ці дві функції майже ідентичні, тому ми розглянемо лише функцію заморожування.

    1 function freezeAccount(address addr)
    2 public
    3 onlyOwner

    Функції, позначені як public (opens in a new tab), можуть викликатися з інших смартконтрактів або безпосередньо через транзакцію.

    1 {
    2 require(!frozenAccounts[addr], "Рахунок уже заморожено");
    3 frozenAccounts[addr] = true;
    4 emit AccountFrozen(addr);
    5 } // freezeAccount

    Якщо рахунок уже заморожено, операція скасовується. В іншому випадку, заморозьте його та згенеруйте (emit) подію.

  • Змініть _beforeTokenTransfer, щоб запобігти переміщенню грошей із замороженого рахунку. Зауважте, що гроші все ще можна переказувати на заморожений рахунок.

    1 require(!frozenAccounts[from], "Рахунок заморожено");

Очищення активів

Щоб вивільнити токени ERC-20, що зберігаються в цьому контракті, нам потрібно викликати функцію контракту токена, якому вони належать, — або transfer (opens in a new tab), або approve (opens in a new tab). У цьому випадку немає сенсу витрачати газ на дозволи (allowances), ми можемо просто переказати їх безпосередньо.

1 function cleanupERC20(
2 address erc20,
3 address dest
4 )
5 public
6 onlyOwner
7 {
8 IERC20 token = IERC20(erc20);

Це синтаксис для створення об’єкта для контракту, коли ми отримуємо адресу. Ми можемо це зробити, оскільки у нас є визначення для токенів ERC20 як частина вихідного коду (див. рядок 4), і цей файл містить визначення для IERC20 (opens in a new tab), інтерфейсу для контракту ERC-20 від OpenZeppelin.

1 uint balance = token.balanceOf(address(this));
2 token.transfer(dest, balance);
3 }

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

Висновок

Це не ідеальне рішення — не існує ідеального рішення для проблеми «користувач припустився помилки». Однак використання таких перевірок може принаймні запобігти деяким помилкам. Можливість заморожувати рахунки, хоч і небезпечна, може бути використана для обмеження збитків від певних зламів, позбавляючи хакера вкрадених коштів.

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

Останні оновлення сторінки: 4 вересня 2025 р.

Чи була ця інструкція корисною?