Типы данных: uint, address, bool, string и деньги
- Зачем это нужно
- Тип — правило, что можно положить в ячейку
- В Solidity нет дробей — и это решение, а не недоработка
- uint256 — 256 бит, то есть 78 десятичных знаков
- Переполнение: с версии 0.8 транзакция откатывается
- Деление обрубает дробь, поэтому порядок операций решает всё
- decimals: почему один токен — это триллион
- Деньги и время: суффикс — это множитель
- address — 20 байт и тот, кто прислал транзакцию
- address payable — адрес, которому можно отправить эфир
- bool: две буквы истины
- string и bytes32: почему строки нельзя сравнить знаком равенства
- constant и immutable: числа, за которые не платят
- Типичные ошибки
- Слова главы
- Тренажёр: набрать вслепую
- На чемпионате
- Что дальше
- Углубиться
- Куда дальше: проверенные ресурсы
Зачем это нужно
В прошлой главе была одна переменная одного типа — uint256 public count. Этого хватило, чтобы посчитать нажатия. Но контракт из конкурсного задания считает деньги: цену токена 0.00075 ether, лимит покупки 5_000, доли 30 % и 60 %, фазы продаж по 10 minutes, адреса пяти участников.
Каждое из этих значений — свой тип, и почти у каждого есть ловушка, которая не видна в коде. Целые числа переполняются. Деление отбрасывает дробь — и 7 * (30 / 100) даёт ноль, из-за чего покупка становится бесплатной. Проверка «не больше 5000 токенов» пропускает 5000,999999999999. Строку нельзя сравнить знаком ==.
Эта глава — про то, что лежит в ячейках памяти контракта и как оно себя ведёт.
Тип — правило, что можно положить в ячейку
Переменная — ячейка памяти с именем. Тип — правило: что в неё можно положить и сколько места она займёт. Тип пишется перед именем:
uint256 public startTime;
Читается так: «ячейка startTime для целого числа без знака, видна снаружи». Тип нельзя поменять после объявления — положить строку в ячейку для числа не выйдет, компилятор откажет.
Типов в Solidity немного, и в этой главе разберём все, которые встречаются в конкурсном задании: числа, адреса, логические значения, строки и байты.
В Solidity нет дробей — и это решение, а не недоработка
Тип uint (unsigned integer — целое без знака) хранит 0, 1, 2 и так далее. Никаких 0.5 и 2.1:
uint256 public c = 0.5;
TypeError: Type rational_const 1 / 2 is not implicitly convertible
to expected type uint256.
Причина не в лени авторов языка. Дробные вычисления дают чуть разный результат на разных процессорах, а блокчейн требует, чтобы тысячи узлов получили ответ, совпадающий байт в байт. Поэтому дробей в языке нет вообще — и все «дробные» величины изображаются целыми числами в мелких единицах. Как именно — увидим на деньгах и на токенах.
uint256 — 256 бит, то есть 78 десятичных знаков
Бит — ячейка с двумя состояниями. Восемь бит (uint8) дают 256 комбинаций: значения от 0 до 255. Двести пятьдесят шесть бит (uint256) дают число из 78 знаков:
uint8: 0 … 255
int8: -128 … 127
uint256: 0 … 115792089237316195423570985008687907853269984665640564039457584007913129639935
Слово uint без числа — это то же самое, что uint256. Тип int (signed integer — целое со знаком) отдаёт один бит под знак: int8 — от −128 до 127.
В контрактах почти всё — uint256: балансы и суммы отрицательными не бывают, а 10 000 000 токенов при принятой в задании точности занимают двадцать знаков. Минус в uint не пролезет даже как литерал: uint256 public b = -1; даёт TypeError: … Cannot implicitly convert signed literal to unsigned type.
Переполнение: с версии 0.8 транзакция откатывается
uint8 хранит максимум 255. Что будет при 255 + 1? Первая модель главы отвечает числами: один и тот же код запускается в обычном виде и внутри блока unchecked.
── границы типов ──
uint8: 0 … 255
int8: -128 … 127
uint256: 0 … 115792089237316195423570985008687907853269984665640564039457584007913129639935
в uint256 78 десятичных знаков; 2**256-1 = true
── 255 + 1 в uint8 ──
addChecked(255, 1) → ОТКАТ: reverted with panic code 0x11
(Arithmetic operation overflowed outside of an unchecked block)
addUnchecked(255, 1) = 0
addUnchecked(250, 10) = 4
── 0 - 1 в uint8 ──
subChecked(0, 1) → ОТКАТ: reverted with panic code 0x11
subUnchecked(0, 1) = 255
── цена проверки: газ на один вызов ──
addChecked(1, 2) 22403 газа
addUnchecked(1, 2) 22143 газа
проверка переполнения стоит 260 газа
До версии 0.8 (декабрь 2020) результат 255 + 1 молча становился нулём — как одометр после 999999. На этом строились настоящие взломы, и все подключали внешнюю библиотеку для «безопасной арифметики». С 0.8 компилятор сам вставляет проверку в каждое +, -, *, и при выходе за границы транзакция откатывается целиком: состояние не меняется.
Слово откат (revert) будет встречаться постоянно: это отмена всей транзакции. Код panic code 0x11 — служебный номер, означающий «переполнение». У паники нет текста от автора контракта, в отличие от проверок, которые разберём в следующей главе.
Блок unchecked { … } возвращает старое поведение. Он нужен там, где переполнение доказуемо невозможно, и экономит 260 газа на операцию. Пока правило простое: не использовать, пока не научитесь доказывать невозможность.
Старые уроки в интернете подключают библиотеку SafeMath и пишут pragma ^0.7. С 0.8 она не нужна: count = count + 1 безопасен сам по себе. Если урок начинается с установки SafeMath — он написан до 2021 года.
Деление обрубает дробь, поэтому порядок операций решает всё
Деление в uint отбрасывает остаток: 210 / 100 = 2, а 30 / 100 = 0. Отсюда правило, которое стоит запомнить дословно: сначала все умножения, деление — последним.
Вторая модель считает то же, что считает контракт из задания, на его же числах (точность токена — 12 знаков после «запятой»):
── единица токена при decimals = 12 ──
ONE = 10**12 = 1000000000000
10_000_000 токенов = 10000000000000000000 мелких единиц (20 знаков)
── порядок операций: 7 * 30 / 100 ──
7 * 30 / 100 = 2 (в математике 2.1)
7 * (30 / 100) = 0 (30 / 100 = 0, дробей нет)
── то же на сумме токена ──
7 токенов = 7000000000000 единиц
* 30 / 100 = 2100000000000 единиц = 2.1 токена — дробь выжила
── проверка КЗ: _amount / dec <= 5_000 ──
5000 токенов ровно: true
5000 токенов + 1 единица: true
5000.999999999999 токенов: true
5001 токенов: false
пролезает лишних 999999999999 единиц — почти целый токен
── стоимость покупки, цена 0.001 ether ──
0.5 токена: умножить, потом делить = 500000000000000 wei = 0.0005 ether
0.5 токена: делить, потом умножить = 0 wei — покупка бесплатна
5000 токенов = 5.0 ether в обоих порядках: true
Три вывода, каждый из которых стоит баллов.
Порядок скобок меняет результат в ноль. amount * 30 / 100 считает слева направо и теряет дробь один раз. amount * (30 / 100) сначала получает 0 и умножает на него. Именно поэтому в эталонном контракте написано balanceOf(owner) * 30 / 100, а не наоборот.
Ошибку не поймать тестом на круглом числе. На 5000 токенах оба порядка дают одинаковые 5 ether. Разница вылезает на 0,5 токена: правильный порядок — 0,0005 ether, неправильный — ноль, покупка бесплатна.
Проверка «не больше 5000» пропускает 5000,999999999999. Целочисленное деление округляет вниз, поэтому _amount / dec <= 5_000 разрешает почти целый лишний токен. Строгая проверка сравнивает мелкие единицы: _amount <= 5_000 * dec.
Отдельная неожиданность: return 7 * 30 / 100; не скомпилируется. Числа, написанные прямо в коде (литералы), компилятор считает заранее и с дробью — он видит 2.1 и отказывается класть её в uint256. Обрубание дроби происходит только тогда, когда хотя бы одно значение приходит переменной во время работы.
decimals: почему один токен — это триллион
У токена нет дробей, но кошелёк показывает «2.1 CMON». Работает это так: все балансы хранятся в мелких единицах, а функция decimals() говорит, сколько нулей отрезать при показе.
В эталонном контракте decimals() возвращает 12. Значит, одна мелкая единица — это 10⁻¹² токена, а один токен — 10**12 = 1 000 000 000 000 единиц. Отсюда строка uint256 dec = 10**decimals(); и запись 10_000_000 * dec для десяти миллионов токенов.
Подчёркивания в числе — просто разделители для глаза: 10_000_000 и 10000000 для компилятора одно и то же.
У эфира точность 18: один ETH — это 10¹⁸ wei. Правило общее для всех токенов: внутри контракта считают в мелких единицах, красивую запись делает интерфейс.
Деньги и время: суффикс — это множитель
Третья модель показывает, во что превращаются 0.00075 ether и 10 minutes из эталонного контракта:
── деньги: суффикс — это множитель ──
1 ether = 1000000000000000000 wei (10**18)
1 gwei = 1000000000 wei (10**9)
0.00075 ether = 750000000000000 wei
0.001 ether = 1000000000000000 wei
за 100_000 токенов по 0.00075 ether: 75.0 ether
── время: тоже множитель, в секундах ──
10 minutes = 600
5 minutes = 300
1 days = 86400
1 weeks = 604800
seedPhase + privPhase = 900 секунд
ether, gwei, wei — не типы денег, а множители. Запись 0.00075 ether компилятор превращает в целое 750000000000000, потому что после умножения на 10¹⁸ дробь исчезает. А 0.5 wei уже не скомпилируется: делить wei не на что.
С временем так же: 10 minutes — это число 600, и сравнивается оно с block.timestamp — временем блока в секундах. Суффиксы всегда во множественном числе, даже для единицы: 1 minutes. Написанное в эталоне 1 minute — синтаксическая ошибка, одна из тех, что ищут в отчёте по синтаксису.
address — 20 байт и тот, кто прислал транзакцию
Тип address хранит 20 байт — 40 шестнадцатеричных знаков после 0x:
owner = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
address(0) = 0x0000000000000000000000000000000000000000
isEmpty(address(0)) = true
whoAmI() от deployer = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
whoAmI() от alice = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8
адрес одинаковый = false
msg.sender — встроенная переменная: адрес того, кто вызвал функцию. Одна и та же функция от двух разных вызывающих видит два разных адреса — на этом строится вся проверка прав в контрактах. Строка owner = msg.sender в конструкторе запоминает того, кто развернул контракт.
address(0) — сорок нулей, «никто». Сравнение с ним — стандартный способ проверить, что адрес не задан: в эталонном контракте так проверяют, что пользователь ещё не зарегистрирован.
Регистр букв в адресе — не украшение, а контрольная сумма. Адрес, скопированный в нижнем регистре, компилятор не примет: SyntaxError: This looks like an address but has an invalid checksum — и тут же подскажет правильное написание.
address payable — адрес, которому можно отправить эфир
Обычному address деньги отправить нельзя:
msg.sender.transfer(1 ether);
TypeError: "send" and "transfer" are only available for objects
of type "address payable", not "address".
Решение — явное преобразование: payable(msg.sender).transfer(1 ether). Ровно так написано в эталонном контракте: payable(owner).transfer(...). Разделение введено специально, чтобы деньги нельзя было отправить случайно.
Модель показывает, что происходит при переводе:
── баланс в wei ──
контракт до перевода: 0 wei
после перевода 1.5 ether: контракт 1500000000000000000 wei = 1.5 ether
у вызывающего убыло больше 1.5 ether (ещё и газ): true
У отправителя убыло больше переведённого: сверху газ за транзакцию. Это стоит помнить, когда балансы в демонстрации «не сходятся на копейку».
Документация помечает transfer как устаревший: он передаёт получателю жёстко 2300 газа, и если получатель — контракт со сложной логикой приёма, перевод сорвётся. Рекомендуемый способ — call. В отчёте по информационной безопасности это готовый пункт; подробно — в главе про паттерны безопасности.
bool: две буквы истины
Тип bool хранит true или false, ничего третьего. Число в него не превращается: bool public b = 1; даёт TypeError: Type int_const 1 is not implicitly convertible to expected type bool.
Знак ! переворачивает значение: !true — это false. Сравнения ==, !=, <, >= и связки && (и), || (или) возвращают именно bool. Тип пригодится в следующей главе: проверка require(условие, "сообщение") ждёт на входе bool.
В эталонном контракте bool — это поле whitelist у пользователя: он либо в списке, либо нет.
string и bytes32: почему строки нельзя сравнить знаком равенства
Строка (string) — последовательность байтов в кодировке UTF-8. Отсюда неожиданность:
── string и bytes32 ──
bytes("CMON").length = 4
bytes("Привет").length = 12 (6 букв, по 2 байта каждая)
keccak256("123") = 0xbb82b6f0c11c99b8230891e5275d2bdb7f6244847ad3b9fc37ab67b1d32e7c03
длина хеша: 64 шестнадцатеричных знаков = 32 байта, для любой строки
sameString("123", "123") = true
sameString("123", "124") = false
Русская буква занимает два байта, поэтому «Привет» — это 12 байт, а не 6. Кириллица в тексте требует префикса unicode"…" — как в прошлой главе.
Сравнить две строки знаком == нельзя, компилятор откажет. Строки сравнивают по хешу: keccak256 от любой строки даёт ровно 32 байта (bytes32), и эти 32 байта уже сравниваются знаком равенства. Хеш — «отпечаток» данных: одинаковые данные дают одинаковый отпечаток, разные — разный, а восстановить данные из отпечатка нельзя.
bytes32 — ровно 32 байта, тип фиксированной длины: он дешевле string и умеет сравниваться напрямую. Если строка короткая и известна заранее — обычно берут bytes32.
constant и immutable: числа, за которые не платят
Обычная переменная состояния живёт в хранилище сети, и каждое чтение стоит газ. Для значений, которые никогда не меняются, есть два слова:
uint256 public constant MAX_BUY = 5_000; // известно до развёртывания
uint256 public immutable startTime; // задаётся один раз в конструкторе
Разница измерена в модели:
── цена чтения и записи: газ на вызов ──
чтение обычной переменной 23591 газа
чтение constant 21533 газа (на 2058 дешевле)
чтение immutable 21512 газа (на 2079 дешевле)
запись в пустую ячейку (0 → 7) 43748 газа
запись в занятую ячейку (7 → 8) 26648 газа — заполнить ноль дороже на 17100
constant подставляется прямо в байткод: значение известно до развёртывания. immutable присваивается один раз в конструкторе — так удобно запоминать block.timestamp или адрес владельца. Ни то ни другое не занимает хранилище, поэтому чтение обходится примерно на две тысячи газа дешевле.
Последние две строки — знакомая разница из прошлой главы: заполнить нулевую ячейку дороже, чем изменить занятую, на 17 100 газа. Для отчёта по оптимизации это готовый аргумент с числами.
Типичные ошибки
Все сообщения — из живого прогона.
Литерал не по размеру или со знаком. uint8 a = 256 → Literal is too large to fit in uint8; uint256 b = -1 → Cannot implicitly convert signed literal to unsigned type; uint256 c = 0.5 → Type rational_const 1 / 2 ….
Скобки вокруг деления. balance * (30 / 100) → TypeError: Built-in binary operator * cannot be applied to types uint256 and rational_const 3 / 10. А если доля придёт переменной — скомпилируется и молча даст ноль.
Голые числа с делением. return 7 * 30 / 100; → Return argument type rational_const 21 / 10 is not implicitly convertible… Литералы считаются заранее и с дробью.
Отправка эфира с обычного адреса. owner.transfer(value) при address owner → "send" and "transfer" are only available for objects of type "address payable". Нужно payable(owner).
Сравнение строк и хеш вместо строки. passwordMap[_login] != _password при mapping(string => string) → Built-in binary operator != cannot be applied to types string storage ref and string memory. Обе ошибки есть в эталонном контракте.
Строковый параметр без места хранения. function signUp(string _login, …) → Data location must be "memory" or "calldata" for parameter in function, but none was given. Нужно string memory _login.
Единица времени в единственном числе. Time_dif += 1 minute; → ParserError: Expected ';' but got identifier на слове minute. Только seconds, minutes, hours, days, weeks — всегда во множественном числе.
Адрес в нижнем регистре. SyntaxError: This looks like an address but has an invalid checksum. — компилятор тут же печатает правильное написание, его и вставить.
Паники во время работы. 0x11 — переполнение, 0x12 — деление на ноль, 0x32 — выход за границу массива. У паники нет текста от автора, в отличие от проверок из следующей главы.
Слова главы
uint/int— целое без знака / со знаком;uint256— умолчание.- Бит, переполнение (overflow), заворачивание (wrapping) — что происходило до версии 0.8.
- Откат (revert) — отмена всей транзакции; состояние не меняется.
- Panic 0x11 / 0x12 / 0x32 — переполнение, деление на ноль, выход за границу.
unchecked { }— выключить проверку арифметики; экономит 260 газа за операцию.- Целочисленное деление — отбрасывает остаток; умножение первым, деление последним.
- Литерал (literal) — число, написанное прямо в коде; считается компилятором заранее.
decimals— сколько мелких единиц в одном токене; 12 в эталонном контракте, 18 у эфира.- wei / gwei / ether — множители: 1 ether = 10¹⁸ wei.
seconds/minutes/days— множители в секундах; всегда во множественном числе.address— 20 байт;msg.sender— кто вызвал;address(0)— «никто».address payable— адрес, которому можно отправить эфир.- Контрольная сумма адреса (checksum) — регистр букв защищает от опечаток.
bool—trueилиfalse;string— байты UTF-8;bytes32— ровно 32 байта.keccak256— хеш; 32 байта для любых данных; так сравнивают строки.constant/immutable— значения вне хранилища: чтение дешевле примерно на 2000 газа.
Тренажёр: набрать вслепую
Цель: скорость ≥ 100 зн/мин, точность ≥ 88%
uint256 public constant MAX_BUY = 5_000;
На чемпионате
Модуль В, аудит — 14 баллов. Задание прямо запрещает менять описание структур данных: значит, отчёт по логике пишется про то, как с этими типами работают. Три готовых пункта из этой главы, каждый есть в эталонном контракте:
require(_amount / dec <= 5_000, …)пропускает почти лишний токен — правильно сравнивать мелкие единицы.1 minuteвместо1 minutes— синтаксическая ошибка, строка идёт в отчёт по синтаксису.- Пароли хранятся как
mapping(string => string), а присваивается тудаkeccak256(...)— не компилируется, и сама идея хранить пароль в блокчейне идёт в отчёт по информационной безопасности: в сети всё публично.
Модуль А, 37 баллов. Демонстрация проверяет числа: «внести депозит в размере 1000 USDC… показать изменения всех затронутых балансов». Если порядок операций в расчёте долей перепутан, балансы сойдутся на круглых суммах и разъедутся на дробных — а эксперт считает по своему ключу.
Отчёт по оптимизации — отдельный пункт модуля В. Числа из этой главы туда годятся напрямую: constant/immutable вместо переменной хранилища экономят около 2000 газа на каждое чтение.
Что дальше
Следующая глава — проверки: require с сообщением, revert, собственные ошибки. Тип bool из этой главы станет условием проверки, а слово «откат», которое здесь появилось как поведение арифметики, станет главным инструментом контракта: так он отказывается делать неправильное. Заодно допишем те самые сообщения, которые в эталонном контракте стоят пустыми — это прямой пункт задания.
Углубиться
- Типы фиксированной точности — в языке они объявлены, но не реализованы; вместо них используют целые с множителем, как
decimals. type(uint256).maxиtype(int8).min— как узнать границы типа прямо в коде.bytesпротивbytes32— динамическая последовательность байтов и фиксированная.- Упаковка переменных (storage packing) — несколько маленьких типов в одной 32-байтовой ячейке; экономит газ, но только если объявлены подряд.
abi.encodeиabi.encodePacked— два способа склеить данные перед хешированием; у второго есть ловушка с одинаковыми хешами, разберём в главе про библиотеки.
Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- Типы значений — docs.soliditylang.org — таблица размеров, преобразования,
address payable. - Единицы и встроенные переменные — все суффиксы,
msg.sender,block.timestamp. - Изменения версии 0.8.0 — арифметика с проверкой и почему SafeMath больше не нужен.
- Ошибки и паники — список кодов паники: 0x11, 0x12, 0x32.
- Стандарт токена ERC-20 — откуда взялось
decimalsи почему это только договорённость об отображении.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.