Коллекции, события и глобальные переменные
Пора научить контракт помнить вкладчиков, сообщать новости внешнему миру и понимать, кто к нему пришёл и с какими деньгами. Это сердце учёта нашей кампании.
- mapping и динамические массивы - как хранить «кто сколько внёс»;
- события (events) - как сообщать об изменениях интерфейсу и индексаторам;
- msg.sender, msg.value, block.timestamp и другие глобальные значения.
4.1Mapping: таблица «ключ → значение»
Mapping - рабочая лошадка учёта в контрактах. Это таблица соответствий: по ключу мгновенно получаешь значение. Для нас это «адрес вкладчика → сколько он внёс».
// адрес → сумма вклада в вей 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:
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 |
Самый первый отправитель в цепочке вызовов. Для проверки прав не использовать - почему, расскажем ниже. |
Если контракт A вызывает контракт B, то внутри B msg.sender - это A, а tx.origin - исходный пользователь. Проверять права по tx.origin - всё равно что пускать в квартиру любого, кто правильно назвал имя хозяина. Пользователя можно обманом заставить вызвать вредоносный контракт, и тот от его имени дёрнет ваш. Это известная уязвимость, разберём её в аудите. Правило простое: для прав всегда берите msg.sender.
4.4О времени и случайности
block.timestamp удобно, но помните: время блока задаёт тот, кто его производит. В Ethereum после перехода на PoS это время слота с шагом 12 секунд, в других сетях возможен небольшой разброс. Для дедлайна в днях это неважно. А вот строить на нём лотерею нельзя: «случайное» время предсказуемо и управляемо.
Раз все узлы должны получить один результат, источника случайных чисел внутри контракта быть не может. block.timestamp, хеш блока и подобное - псевдослучайность, которую можно предсказать или подтолкнуть. Для честного случая используют внешние оракулы (например, Chainlink VRF). Пока просто запомните: не делайте жребий на блок-данных.
4.5События: как контракт говорит наружу
Фронтенд не может «подписаться» на переменную контракта и узнать, что она изменилась. Поэтому контракт сам рассказывает новости: испускает события (events) - записи в журнал транзакции, которые дёшево хранятся и легко читаются приложениями и индексаторами.
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 нет. Обо всех изменениях контракт сообщает событиями. Наш краудфандинг теперь принимает вклады, ведёт учёт и логирует историю - остаётся самое ответственное: деньги на выход.
В следующем модуле контракт начнёт отдавать эфир. Там же разберём, как две строки, поставленные в неправильном порядке, стоили The DAO около 60 миллионов долларов.