Как я токены Eigen раздавал. Часть вторая - контракт.
Привет, мой дорогой читатель. В прошлый раз я рассказал тебе о том, как я считал поинты для эйрдропа от 0y. В этот раз - я расскажу тебе о том, как писал контракт. Я изначально предполагаю, что ты немного шаришь в блокчейне, и понимаешь что такое газ (о нем сегодня будет много), в частности.
Давай, сначала, попробуем определиться с той проблемой, которая есть. И так, у нас есть n токенов Eigen и надо каким-то образом из раздать. Как это можно сделать? Напрашивается несколько вариантов:
- Разослать в ручную. Просто, долго и дорого - по итогу. Запираем одного человека в комнате с компуктером, даем ему список адресов и ждем. Просто - да. Долго - о, да! А еще надо насыпать ему эфира на оплату газа. Даже если предположить, что 1 трансфер будет стоить в районе 3 USDT в газе - трансфер на все 264 аккаунта, обойдется уже под 1К. И это на низком газе, что в последнее время - редкость. Так что, это еще и дорого.
- Мультисенд. Вариант. Но тут тоже есть нюансы. Вопервых, надо запариться с контрактом. Если нет подходящего, придется писать. А если есть, то платить за газ, ведь нам все равно надо выполнить почти 300 трансферов, а это в любом случае затратно. И еще вот какой психологический момент, не то что бы так не принято делать, но пользователь не сможет почувствовать свою принадлежность сообществу, если просто получит свои токены, никуда не нажав.
- Контракт. Это самый крутой вариант. Мы можем написать контракт, и каждый пользователь, которого мы укажем - сможет в интерфейсе нажать на кнопку, отправить транзакцию и получить свои токены. Мне нравится. 0Y нравится. Пользователям тоже должно понравиться. Ну и еще момент, на себя бы берем только оплату деплоя контракта, а за дроп - платит уже пользователь. Вполне справедливо.
2 варианта рассылки токенов
Стандарт ERC-20 предполагает удобный интерфейс апрувов, который говорит о том, что я как пользователь могу позволить любому другому адресу в блокчейне использовать любое, указанное мной количество токенов, с моего аккаунта. То есть, написав контракт мы могли бы не закидывать на него токены, а просто выдать апрув на их использование. Мы так делать не стали.
Тут нет какой-то идеологии, просто лично мне показалось чуть менее затратным по времени разработки хранение токенов на самом контракте. Ну и еще момент: в идеале тогда надо было бы в метод airdrop добавлять проверку на allowance, что привело бы к большим затратам газа, при выполнении транзакции. Оно нам надо? Оно нам не надо.
7 раз отмерь - 1 раз отрежь
Еще перед тем, как написать первую строчку я задумался на тему того, какие могут быть уязвимости у такого контракта. И как мне из избежать. Ну во первых у контракта должно быть какое-то управление. То есть должен быть владелец, который мог бы принимать какие-то решения. Окей, как-будто Ownable
, чуть позже расскажу тебе про это. Возможно дроп надо будет поставить на паузу, звучит как Pausable
. Окей, что еще...
Атака повторного входа.
Атака повторного входа (reentrancy attack) – это уязвимость в смарт-контрактах, которая позволяет злоумышленнику вызвать функцию контракта повторно до завершения предыдущего вызова. Эта уязвимость обычно возникает, когда контракт отправляет средства на адрес, который может выполнять произвольный код (например, другой контракт), и при этом не обновляет свое состояние перед переводом средств.
Суть атаки заключается в том, что злоумышленник может использовать возможность многократно вызывать одну и ту же функцию, например, для получения средств, пока состояние контракта не обновлено.
Окей, как обезопаситься? Умные дядьки уже все придумали за нас. ReentrancyGuard из OpenZeppelin спешит на помощь. Забираем модификатор nonReentrant
и пишем код. Код, конечно, лучше писать тоже обдумано, к этому как раз и переходим.
Time to code
Окей, открываем единственный нормальный редактор для Solidity - Remix IDE, создаем пустой шаблон и погнали писать. Первым делом забираем все необходимое из OpenZeppelin.
OpenZeppelin — это набор инструментов и библиотек на Solidity для разработки безопасных и стандартизированных смарт-контрактов. В основном используется для разработки на блокчейне Ethereum, но также поддерживает другие сети, совместимые с EVM.
Основные компоненты OpenZeppelin:
- Контракты: Библиотека стандартизированных смарт-контрактов, включая токены ERC-20, ERC-721 (NFT), ERC-1155 и другие. Включает проверенные временем реализации популярных стандартов, что снижает вероятность ошибок и уязвимостей.
- Безопасность: Контракты включают защитные механизмы против распространенных атак, таких как повторный вход (ReentrancyGuard), защита от превышения и переполнения (SafeMath), а также функции контроля доступа (Ownable, AccessControl).
- Прокси-контракты: Инструменты для разработки апгрейдируемых смарт-контрактов, что позволяет обновлять контракт без изменения его адреса и состояния.
OpenZeppelin стал де-факто стандартом для создания безопасных смарт-контрактов и широко используется проектами в Ethereum-сообществе.
import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; import "@openzeppelin/contracts/security/Pausable.sol";
Ownable и Pausable - реализуют логику владения и управления контрактом, ReentrancyGuard - защитит от атаки повторного входа, IERC20 - интерфейс токена.
contract EigenAirdrop0y is Ownable, ReentrancyGuard, Pausable { // наследуемся IERC20 public immutable token; // объявляем переменную токена constructor(address _token) Ownable(msg.sender) { // инициализируем овнера require(_token != address(0), "Invalid token address"); token = IERC20(_token); // определяем переменную токена } }
Пока что изи. Давай перегрузим пару методов, отвечающих за состояние контракта, добавив к ним модификатор onlyOwner.
Модификаторы в Solidity — это специальные конструкции, которые позволяют изменять поведение функций, добавляя условия или проверки до выполнения основной логики функции. Воспринимай модификатор, как декоратор. Модификатор onlyOwner — один из самых популярных и часто используется для ограничения доступа к функциям только владельцу контракта.
pragma solidity ^0.8.0; contract Ownable { address public owner; constructor() { owner = msg.sender; // Устанавливаем владельцем создателя контракта } modifier onlyOwner() { require(msg.sender == owner, "Not the owner"); _; // Продолжить выполнение функции } function restrictedFunction() public onlyOwner { // Логика функции, доступная только владельцу } }
function pause() external onlyOwner { _pause(); // вызов родительского _pause(); } function unpause() external onlyOwner { _unpause(); // вызов родительского _unpause(); }
Го некст! Надо определить кому и сколько будем дропать. Значение и состояние будем хранить в маппингах <address => uint256> и <address => bool>.
mapping(address => uint256) public airdropList; mapping(address => bool) public done;
Пишем view функцию, которая вернет значение, может ли аккаунт получить дроп.
function canGetAirdrop(address account) public view returns(bool) { return airdropList[account] > 0 && done[account] == false; }
// внешняя, если unpaused, если не повторный вход function airdrop() external nonReentrant whenNotPaused { // проверяем, заклеймли ли уже - если да, ревертим выполнение require(!done[msg.sender], "Airdrop is already claimed"); // забираем сколько отправлять uint256 amount = airdropList[msg.sender]; // проверяем require(amount > 0, "No airdrop available for this address"); // помечаем, что чел получил дроп еще до ктого, как мы отправили // я же говорил, что безопасность - это не только использовать // готовые решения, но и самому головой думать done[msg.sender] = true; // забираем баланс ДО uint256 balanceBefore = token.balanceOf(address(this)); // выполняем трансфер // если валится - откатываемся require(token.transfer(msg.sender, amount), "Token transfer failed"); // забираем баланс ПОСЛЕ uint256 balanceAfter = token.balanceOf(address(this)); // если проверка не проходит - откатываемся require(balanceBefore - balanceAfter == amount, "Transfer amount mismatch"); }
Добавим, функцию вывода средств с контракта.
// только владелец может ее вызвать function cancel() external onlyOwner { // забираем баланс uint256 balance = token.balanceOf(address(this)); // выполняем трансфер require(token.transfer(msg.sender, balance), "cancel: Token transfer failed"); }
Ну, и напоследок - закроем получение ETH контрактом.
receive() external payable { revert("This contract does not accept ETH"); }
Осталось только в конструкторе инициализировать сам список airdropList.
// Test airdropList[0x3877fbDe425d21f29F4cB3e739Cf75CDECf8EdCE] = 100000000000000000; airdropList[0x187F087EC07511A0D77EDA2cF6f137eE49d12389] = 200000000000000000; airdropList[0x96e4Ba33113319a67AC5eAb899579351aBf9b1c1] = 100000000000000000;
Time to test
Надо бы все это дело протестировать... Создаем свой токен, кладем рядом контракт.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract EigenTest is ERC20 { constructor(uint256 initialSupply) ERC20("EigenTest", "tEigen") { _mint(msg.sender, initialSupply * 10**18); } function mint(uint256 supply) public { _mint(msg.sender, supply); } }
Идем в кран, получаем токены в Sepolia. Деплоим токен, получаем адрес, деплоим контракт, перебрасываем на контракт токены. Все, можно тыкать...
Глазами и руками - это конечно хорошо, но надо бы все это дело автоматизировать... Давай попробуем написать тест для среды hardhat. Только сначала надо обновить контракт, добавить функцию добавления адреса в airdropList.
function addToAirdropList(address account, uint256 amount) external onlyOwner { require(account != address(0), "Invalid address"); require(amount > 0, "Amount must be greater than 0"); airdropList[account] = amount; }
const { expect } = require("chai"); describe("EigenAirdrop0y Contract", function () { let EigenAirdrop0y, airdrop, token, owner, addr1, addr2, addr3; beforeEach(async function () { // Загружаем контракт токена ERC20 и создаем экземпляр const ERC20 = await ethers.getContractFactory("MockERC20"); // создайте тестовый ERC20 контракт token = await ERC20.deploy("TestToken", "TT", 18); await token.deployed(); // Загружаем и развертываем контракт airdrop EigenAirdrop0y = await ethers.getContractFactory("EigenAirdrop0y"); [owner, addr1, addr2, addr3] = await ethers.getSigners(); airdrop = await EigenAirdrop0y.deploy(token.address); await airdrop.deployed(); // Переводим токены на контракт airdrop для распределения await token.transfer(airdrop.address, ethers.utils.parseEther("1.0")); }); it("Should add address to airdrop list", async function () { await airdrop.addToAirdropList(addr1.address, ethers.utils.parseEther("0.1")); expect(await airdrop.airdropList(addr1.address)).to.equal(ethers.utils.parseEther("0.1")); }); it("Should check if an address can get airdrop", async function () { await airdrop.addToAirdropList(addr1.address, ethers.utils.parseEther("0.1")); expect(await airdrop.canGetAirdrop(addr1.address)).to.be.true; }); it("Should not allow claiming airdrop twice", async function () { await airdrop.addToAirdropList(addr1.address, ethers.utils.parseEther("0.1")); await airdrop.connect(addr1).airdrop(); expect(await airdrop.done(addr1.address)).to.be.true; await expect(airdrop.connect(addr1).airdrop()).to.be.revertedWith("Airdrop is already claimed"); }); it("Should distribute tokens correctly", async function () { await airdrop.addToAirdropList(addr1.address, ethers.utils.parseEther("0.1")); const balanceBefore = await token.balanceOf(addr1.address); await airdrop.connect(addr1).airdrop(); const balanceAfter = await token.balanceOf(addr1.address); expect(balanceAfter.sub(balanceBefore)).to.equal(ethers.utils.parseEther("0.1")); }); it("Should allow the owner to cancel and withdraw remaining tokens", async function () { const contractBalance = await token.balanceOf(airdrop.address); await airdrop.cancel(); expect(await token.balanceOf(owner.address)).to.equal(contractBalance); expect(await token.balanceOf(airdrop.address)).to.equal(0); }); it("Should revert if address tries to get airdrop without being on the list", async function () { await expect(airdrop.connect(addr2).airdrop()).to.be.revertedWith("No airdrop available for this address"); }); });
Шикарно. Давай убедимся, что мы защищены от "повторного входа". Для этого напишем атакующий контракт:
// SPDX-License-Identifier: GPL-3.0 pragma solidity ^0.8.0; import "./EigenAirdrop0y.sol"; contract ReentrancyAttack { EigenAirdrop0y public airdrop; bool public attacked = false; constructor(EigenAirdrop0y _airdrop) { airdrop = _airdrop; } // Функция для начала атаки function attack() external { // Начало первой попытки вызова airdrop.airdrop(); } // Функция обратного вызова для повторного входа fallback() external { if (!attacked) { attacked = true; // Повторная попытка вызова `airdrop` в ходе первой попытки airdrop.airdrop(); } } }
const { expect } = require("chai"); describe("Reentrancy Protection in EigenAirdrop0y Contract", function () { let EigenAirdrop0y, airdrop, token, owner, attacker, addr1; beforeEach(async function () { // Загружаем контракт токена ERC20 и создаем экземпляр const ERC20 = await ethers.getContractFactory("MockERC20"); // создайте тестовый ERC20 контракт token = await ERC20.deploy("TestToken", "TT", 18); await token.deployed(); // Загружаем и развертываем контракт airdrop EigenAirdrop0y = await ethers.getContractFactory("EigenAirdrop0y"); [owner, attacker, addr1] = await ethers.getSigners(); airdrop = await EigenAirdrop0y.deploy(token.address); await airdrop.deployed(); // Переводим токены на контракт airdrop для распределения await token.transfer(airdrop.address, ethers.utils.parseEther("1.0")); // Добавляем адрес атакующего в список для airdrop await airdrop.addToAirdropList(attacker.address, ethers.utils.parseEther("0.1")); }); it("Should prevent reentrancy attack", async function () { // Разворачиваем атакующий контракт const ReentrancyAttack = await ethers.getContractFactory("ReentrancyAttack"); const reentrancyAttack = await ReentrancyAttack.deploy(airdrop.address); await reentrancyAttack.deployed(); // Переводим часть токенов атакующему контракту await airdrop.addToAirdropList(reentrancyAttack.address, ethers.utils.parseEther("0.1")); // Проверяем, что повторный вход не удается await expect(reentrancyAttack.attack()).to.be.revertedWith("ReentrancyGuard: reentrant call"); }); });
Что можно улучшить?
- Хранение массива получивших дроп. Если бы не газ - я бы складывал всех, кто получил дроп в отдельный массив. В эфире нельзя вытащить весь маппинг из контракта, только по ключу. Это неудобно. В дальнейшем придется писать больше кода. Но опять же - я старался сэкономить на газе при потенциальном вызове эйрдропа.
- События. Можно было бы эмитить событие при каждом успешном дропе, чтоб потом вытаскивать их из ноды. Но, так как тут нет особо сложной логики, я буду вытаскивать потом эвент трансфера, который эмитится из контракта токена. Тоже пойдет.
Эти 2 пункта еще создадут мне головную боль, но это будет потом. В следующей статье я расскажу тебе о том, как все это дело мы в фронт завернули, и как я выводил аналитику по всей этой истории. Но это потом, а пока - пока.
PS: Нормальный газ пришлось подождать. Все было не так плохо, как на момент написания статьи, но все же...