Как я токены 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: Нормальный газ пришлось подождать. Все было не так плохо, как на момент написания статьи, но все же...