$ sudo teach IT
Модуль 4 из 7

Коллекции, события и глобальные переменные

Пора научить контракт помнить вкладчиков, сообщать новости внешнему миру и понимать, кто к нему пришёл и с какими деньгами. Это сердце учёта нашей кампании.

Вы узнаете
  • mapping и динамические массивы - как хранить «кто сколько внёс»;
  • события (events) - как сообщать об изменениях интерфейсу и индексаторам;
  • msg.sender, msg.value, block.timestamp и другие глобальные значения.

4.1Mapping: таблица «ключ → значение»

Mapping - рабочая лошадка учёта в контрактах. Это таблица соответствий: по ключу мгновенно получаешь значение. Для нас это «адрес вкладчика → сколько он внёс».

solidity
// адрес → сумма вклада в вей
mapping(address => uint256) public contributions;

function contribute() external payable {
    contributions[msg.sender] += msg.value;  // накапливаем вклад
}

У mapping есть особенности, вытекающие из устройства хранилища:

  • Все ключи «уже существуют». Незаписанный ключ возвращает нулевое значение типа (для uint256 - 0). Поэтому «нет вклада» и «вклад 0» неотличимы.
  • Нельзя перебрать содержимое. У mapping нет длины и нельзя пройтись по всем ключам - он не хранит список ключей. Нужен перебор - ведите отдельный массив ключей (урок 4.2).
  • Нельзя вернуть целиком. Функция не может отдать весь mapping - только значение по конкретному ключу.

- Как это «нельзя перебрать»? А если я хочу показать всех вкладчиков?

- Тогда придётся завести рядом обычный список адресов (об этом следующий урок). Mapping похож на гардероб с номерками: по номерку куртку выдадут мгновенно, а вот на вопрос «какие куртки у вас вообще висят?» гардеробщик только разведёт руками.

Вложенные таблицы

Mapping бывает вложенным: mapping(address => mapping(address => uint256)) - «кто → кому → сколько». Так, например, в токенах ERC-20 хранят разрешения (продвинутый курс).

Для краудфандинга хватит простого одноуровневого.

4.2Массивы: когда нужен перечень

Если список вкладчиков всё-таки нужно перебирать (например, показать всех жертвователей), заводят динамический массив их адресов рядом с mapping:

solidity
address[] public contributors;   // список уникальных вкладчиков
function contribute() external payable {
    if (contributions[msg.sender] == 0) {
        contributors.push(msg.sender);   // первый вклад - добавим в список
    }
    contributions[msg.sender] += msg.value;
}
Операция Смысл
arr.push(x) Добавить элемент в конец (только у storage-массивов).
arr.length Текущая длина.
arr[i] Доступ по индексу. Выход за границы - откат.
arr.pop() Удалить последний элемент.
Циклы по массивам - источник проблем

Перебор массива в цикле стоит газа пропорционально его длине. Пока вкладчиков десять, всё прекрасно. Когда их станет несколько тысяч, функция с циклом может перестать помещаться в лимит газа - и застрять навсегда, вместе с деньгами. Это целый класс уязвимостей (отказ в обслуживании), его мы разберём в курсе по аудиту. Правило наперёд: избегайте неограниченных циклов по данным, которые наполняют посторонние.

4.3Глобальные переменные: msg, block, tx

Внутри любой функции доступны специальные значения о текущем вызове и блоке. Вот самые нужные:

Значение Что в нём
msg.sender Адрес того, кто вызвал функцию. Основа всех проверок прав.
msg.value Сколько вей прислано с вызовом. Доступно только в payable-функциях.
block.timestamp Время текущего блока в секундах (Unix-время). Им проверяем дедлайн.
block.number Номер текущего блока.
address(this).balance Текущий баланс самого контракта в вей.
tx.origin Самый первый отправитель в цепочке вызовов. Для проверки прав не использовать - почему, расскажем ниже.
Msg.sender против tx.origin

Если контракт A вызывает контракт B, то внутри B msg.sender - это A, а tx.origin - исходный пользователь. Проверять права по tx.origin - всё равно что пускать в квартиру любого, кто правильно назвал имя хозяина. Пользователя можно обманом заставить вызвать вредоносный контракт, и тот от его имени дёрнет ваш. Это известная уязвимость, разберём её в аудите. Правило простое: для прав всегда берите msg.sender.

4.4О времени и случайности

block.timestamp удобно, но помните: время блока задаёт тот, кто его производит. В Ethereum после перехода на PoS это время слота с шагом 12 секунд, в других сетях возможен небольшой разброс. Для дедлайна в днях это неважно. А вот строить на нём лотерею нельзя: «случайное» время предсказуемо и управляемо.

Настоящей случайности в evm нет

Раз все узлы должны получить один результат, источника случайных чисел внутри контракта быть не может. block.timestamp, хеш блока и подобное - псевдослучайность, которую можно предсказать или подтолкнуть. Для честного случая используют внешние оракулы (например, Chainlink VRF). Пока просто запомните: не делайте жребий на блок-данных.

4.5События: как контракт говорит наружу

Фронтенд не может «подписаться» на переменную контракта и узнать, что она изменилась. Поэтому контракт сам рассказывает новости: испускает события (events) - записи в журнал транзакции, которые дёшево хранятся и легко читаются приложениями и индексаторами.

solidity
event Contributed(address indexed who, uint256 amount, uint256 total);
event Refunded(address indexed who, uint256 amount);

function contribute() external payable {
    require(block.timestamp < deadline, "campaign ended");
    require(msg.value >= MIN_CONTRIBUTION, "too small");

    if (contributions[msg.sender] == 0) contributors.push(msg.sender);
    contributions[msg.sender] += msg.value;

    emit Contributed(msg.sender, msg.value, address(this).balance);
}
Деталь Смысл
event … emit Сначала объявляют событие, затем emit в коде его испускает.
indexed До трёх полей можно пометить indexed - по ним потом удобно фильтровать («все вклады от этого адреса»).
Дёшево Журнал стоит газа заметно меньше, чем хранение. События - предпочтительный способ логировать историю.
Только наружу Контракт не может прочитать собственные события. Это канал связи с внешним миром, а не хранилище.
Вывод модуля

Mapping хранит «адрес → сумма» и служит главным учётом; массив рядом нужен, когда требуется перечень (но берегитесь циклов). Права проверяем по msg.sender, приём денег - через msg.value, дедлайн - через block.timestamp; случайности в EVM нет. Обо всех изменениях контракт сообщает событиями. Наш краудфандинг теперь принимает вклады, ведёт учёт и логирует историю - остаётся самое ответственное: деньги на выход.

Дальше · модуль 5

В следующем модуле контракт начнёт отдавать эфир. Там же разберём, как две строки, поставленные в неправильном порядке, стоили The DAO около 60 миллионов долларов.

Коллекции, события и глобальные переменные — Solifity lite - просто о сложном | $ sudo teach IT