Crypto Development
November 18, 2024

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