Перейти к основному содержимому
Блокчейн04ГЛАВА 04Типы данных: uint, address, bool, string и деньгиuint25678 знаковaddress20 байт1 ether10¹⁸ weiдробей неттолько целые

Типы данных: uint, address, bool, string и деньги

Прочитано0%
Квизы0/6
Тренажёрыне пройден0/1
Уровень 1 · Новичок

Зачем это нужно​

В прошлой главе была одна переменная одного типа — 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 газа
255 + 1 В uint8: ДВА ПОВЕДЕНИЯ ОДНОГО КОДАобычный код (с 0.8 — проверка)ОТКАТ · panic code 0x11транзакция отменена целиком,состояние не изменилосьunchecked { } — старое поведение255 + 1 = 0 · 250 + 10 = 40 − 1 = 255как одометр: перевалило и пошло с нуляцена проверки измерена: 22 403 против 22 143 газа — 260 газа за операциюдо 0.8 заворачивание было тихим — на нём строились настоящие взломы; сейчас откат по умолчанию
Один и тот же код: с проверкой — откат с кодом 0x11, внутри unchecked — заворачивание в ноль; проверка стоит 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 года.

Отвечено вопросов: 0 из 2
Переменная uint8 равна 255, к ней прибавляют 1. Что произойдёт в Solidity 0.8?
Зачем в Solidity вообще нет дробных чисел?
Отвечено верно: 0 из 2

Деление обрубает дробь, поэтому порядок операций решает всё​

Деление в 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 / 1007 → 2 · дробь теряется один разумножение первым — так в эталоне КЗamount * (30 / 100)7 → 0 · 30 / 100 уже ноль0,5 токена стоит 0 wei — покупка бесплатнапроверка из задания: require(_amount / dec <= 5000)5000 токенов ровноtrue5000 токенов + 1 мелкая единицаtrue5000,999999999999 токенаtrueлишних почтина целый токен
Умножение первым теряет дробь один раз в конце; деление первым обнуляет всё — а проверка «не больше 5000» пропускает почти лишний токен

Три вывода, каждый из которых стоит баллов.

Порядок скобок меняет результат в ноль. 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 секунд
СУФФИКС — ЭТО МНОЖИТЕЛЬ, А НЕ ТИП1 ether1 000 000 000 000 000 000 wei10¹⁸0.00075 ether750 000 000 000 000 weiцена из эталона КЗ10 minutes600просто секунды1 days86 400сравнивают с block.timestampв коде и в памяти лежат только целые числа — «эфиров» и «минут» внутри контракта не существуетсуффикс всегда во множественном числе: 1 minutes, не 1 minute — иначе компилятор не поймёт
Ни «эфиров», ни «минут» внутри контракта нет — только целые числа: wei и секунды

ether, gwei, wei — не типы денег, а множители. Запись 0.00075 ether компилятор превращает в целое 750000000000000, потому что после умножения на 10¹⁸ дробь исчезает. А 0.5 wei уже не скомпилируется: делить wei не на что.

С временем так же: 10 minutes — это число 600, и сравнивается оно с block.timestamp — временем блока в секундах. Суффиксы всегда во множественном числе, даже для единицы: 1 minutes. Написанное в эталоне 1 minute — синтаксическая ошибка, одна из тех, что ищут в отчёте по синтаксису.

Отвечено вопросов: 0 из 2
Почему balance * (30 / 100) даёт ноль?
Что такое 0.00075 ether внутри контракта?
Отвечено верно: 0 из 2

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.

Отвечено вопросов: 0 из 2
Как сравнить две строки в Solidity?
Функция принимает деньги и хочет отправить их владельцу: owner.transfer(msg.value) при address owner. Что скажет компилятор?
Отвечено верно: 0 из 2

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 газа на каждое чтение.

Отвечено вопросов: 0 из 2
Проверка require(_amount / dec <= 5_000) — что с ней не так?
Почему хранить пароль в контракте — плохая идея?
Отвечено верно: 0 из 2

Что дальше​

Следующая глава — проверки: require с сообщением, revert, собственные ошибки. Тип bool из этой главы станет условием проверки, а слово «откат», которое здесь появилось как поведение арифметики, станет главным инструментом контракта: так он отказывается делать неправильное. Заодно допишем те самые сообщения, которые в эталонном контракте стоят пустыми — это прямой пункт задания.

Отвечено вопросов: 0 из 1
Что даст bytes("Привет").length?
Отвечено верно: 0 из 1

Углубиться​

  • Типы фиксированной точности — в языке они объявлены, но не реализованы; вместо них используют целые с множителем, как decimals.
  • type(uint256).max и type(int8).min — как узнать границы типа прямо в коде.
  • bytes против bytes32 — динамическая последовательность байтов и фиксированная.
  • Упаковка переменных (storage packing) — несколько маленьких типов в одной 32-байтовой ячейке; экономит газ, но только если объявлены подряд.
  • abi.encode и abi.encodePacked — два способа склеить данные перед хешированием; у второго есть ловушка с одинаковыми хешами, разберём в главе про библиотеки.

Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоSolidity и смарт-контракты Ethereum, урок #2 | Типы данных bool, uint, intIlya Krukowski
ВидеоSolidity и смарт-контракты Ethereum, урок #3 | Типы данных string, address, mappIlya Krukowski
ВидеоТипы данных в Solidity (uint, address, bool, string, bytes) / Data types in SoliAlex Klimov
ВидеоUnchecked Math | Solidity 0.8Smart Contract Programmer
ВидеоSolidity Primitives Explained – bool, uint, int, address & bytes | Solidity Tutothebasicsdotacademy

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.