Блокчейн
Solidity: основы языка
В 2024 году на Ethereum заблокировано более 50 миллиардов в смарт-контрактах. Каждый из них написан на одном языке - Solidity. Но вот что поразительно: этот язык, управляющий десятками миллиардов, был создан в 2014 году и уместился бы в одну главу учебника по программированию. У него нет стандартной библиотеки, нет файлового ввода-вывода, нет потоков - только типы, функции и наследование. Почему этот минималистичный язык стал стандартом индустрии, а не превратился в источник бесконечных уязвимостей?
- **ERC20 (OpenZeppelin)** - стандартный контракт токена использует все концепции урока: `mapping(address => uint256)` для балансов, `external` функции для gas-эффективности, `onlyOwner` модификатор для авторизации и наследование от `IERC20` интерфейса. Это шаблон для тысяч токенов с общей капитализацией в сотни миллиардов
- **Uniswap V2 Pair** - контракт DEX-пула наследует сразу от `IUniswapV2Pair`, `UniswapV2ERC20` и использует `nonReentrant` модификатор на функции `swap`. Ошибка в видимости или типах - и ликвидность на миллиарды долларов под угрозой
- **Parity Multisig Hack (2017)** - 150M заморожены навсегда из-за бага в наследовании. Библиотечный контракт не имел защиты, и случайный пользователь вызвал `selfdestruct`, уничтожив реализацию, от которой зависели сотни кошельков
Предварительные знания
Система типов Solidity
Solidity - **статически типизированный** язык, спроектированный специально для EVM. Каждая переменная имеет тип, определённый на этапе компиляции. Типы в Solidity делятся на две большие группы: **value types** (передаются по значению, копируются) и **reference types** (передаются по ссылке, хранятся в storage/memory/calldata).
Размер целых чисел важен для gas-оптимизации. `uint256` - нативный размер EVM (одно слово стека), поэтому операции с ним самые дешёвые. Типы меньшего размера (`uint8`, `uint128`) могут быть полезны при **storage packing** - когда несколько переменных помещаются в один 32-байтный слот.
У `mapping` есть важные ограничения: нельзя получить список всех ключей, нельзя узнать длину, нельзя итерировать. Значение по несуществующему ключу возвращает **дефолтное значение типа** (0 для uint, false для bool, address(0) для address). Если нужна итерация - используйте паттерн `mapping` + `array` ключей.
Ключевой момент для reference types - **data location**. Solidity требует явно указывать, где хранятся данные:
Дефолтные значения в Solidity - **нули**: `uint` = 0, `bool` = false, `address` = 0x0, `string` = "", `enum` = первый элемент. Нет `null` или `undefined`. Это важно учитывать: `mapping(address => bool)` вернёт `false` для любого незаписанного ключа, а не ошибку.
Почему mapping нельзя использовать с data location `memory`, а только в `storage`?
Функции и специальные методы
Функции - основная единица логики в Solidity. Каждый вызов смарт-контракта - это вызов конкретной функции, идентифицированной по **function selector** - первым 4 байтам от `keccak256` сигнатуры. Когда вы вызываете `transfer(address,uint256)`, EVM смотрит на первые 4 байта calldata и находит соответствующую функцию.
**pure** и **view** функции не требуют gas при вызове извне (через `eth_call`), потому что не меняют state и выполняются локально на ноде. Но если `view`-функция вызывается из другой функции в транзакции - gas потратится, потому что EVM всё равно исполняет opcodes.
Solidity имеет три специальные функции, у которых нет имени:
**ABI encoding** - формат кодирования аргументов функций в calldata. Каждый аргумент занимает 32 байта (паддинг слева нулями для uint, справа для bytes/string). Динамические типы кодируются как offset + length + data. Это важно понимать для low-level вызовов и отладки транзакций.
Контракт имеет функцию `transfer(address,uint256)`. Что произойдёт, если отправить транзакцию с calldata, начинающейся с `0xdeadbeef`?
Видимость: public, external, internal, private
Каждая функция и state variable в Solidity имеет **уровень видимости** - правило, определяющее, кто может вызвать функцию или прочитать переменную. Это не просто организация кода, как в обычных языках - в Solidity видимость напрямую влияет на **gas cost** и **безопасность** контракта.
Критическая разница между `external` и `public` связана с тем, как EVM обрабатывает аргументы-массивы:
**`private` не означает секретность на блокчейне!** Все данные в storage доступны для чтения - любой может вызвать `eth_getStorageAt` и прочитать "приватную" переменную. `private` ограничивает доступ только на уровне Solidity-кода. Никогда не храните пароли или секретные ключи в state variables.
Конвенция OpenZeppelin: `_имяСПодчёркиванием` для `internal`/`private` функций, `__имяСДвойным` для internal функций, которые используются только внутри хуков (как `_beforeTokenTransfer`). Это помогает отличить "публичный API" контракта от внутренней реализации.
Функция принимает массив из 200 адресов. Какой уровень видимости оптимален, если функция вызывается только извне (из dApp)?
Модификаторы функций
**Модификаторы** - механизм Solidity для декларативного добавления pre- и post-условий к функциям. Ключевая конструкция - плейсхолдер `_;`, который отмечает место, куда вставляется тело модифицируемой функции. Модификаторы позволяют вынести повторяющиеся проверки (авторизация, валидация, reentrancy guard) в переиспользуемые блоки.
Плейсхолдер `_;` - это не просто маркер "вставь код сюда". Его можно поставить в начале, середине или даже использовать несколько раз:
**ReentrancyGuard** от OpenZeppelin - самый популярный модификатор в экосистеме. Паттерн `lock → execute → unlock` защищает от reentrancy-атаки, при которой злоумышленник вызывает функцию повторно до завершения первого вызова. Именно отсутствие такой защиты привело к взлому The DAO на 60M.
Модификаторы - гибкий инструмент, но их стоит использовать не для любых проверок. Вот практическое правило:
Что произойдёт, если модификатор nonReentrant (locked = true → _; → locked = false) применён к функции, которая вызывает revert внутри `_;`?
Наследование и интерфейсы
Solidity поддерживает **множественное наследование** - контракт может наследовать от нескольких родителей одновременно. Это удобный механизм, но он порождает **diamond problem**: если два родителя определяют одну и ту же функцию, какую версию использовать? Solidity решает это через **C3-линеаризацию** - детерминированный алгоритм, который строит однозначный порядок наследования.
Множественное наследование и C3-линеаризация:
Solidity также поддерживает **abstract contracts** и **interfaces** - два механизма для определения "контрактов" функций без реализации:
На практике интерфейсы - основной инструмент взаимодействия контрактов. `IERC20(tokenAddress).transfer(to, amount)` - так один контракт вызывает другой, зная только его интерфейс, но не реализацию. Это фундамент композиции в DeFi: Uniswap работает с любым ERC20-токеном, не зная его внутренней логики.
private-переменные в Solidity хранят данные в секрете - никто не может их прочитать, кроме самого контракта
private в Solidity ограничивает доступ только на уровне Solidity-кода (наследники и внешние контракты не видят). Но на уровне EVM ВСЕ данные в storage публичны - любой может прочитать их через eth_getStorageAt, зная номер слота. Блокчейн по определению прозрачен
Эта путаница приводит к реальным уязвимостям: разработчики хранят пароли, секретные ключи или ответы к лотерее в private-переменных, думая, что они скрыты. На практике любой может прочитать любой байт storage любого контракта. Для настоящей конфиденциальности нужны commit-reveal схемы или zero-knowledge proofs.
contract D is B, C - оба B и C наследуют A и переопределяют foo(). Что вернёт super.foo() вызванный из D?
Итоги
- **Система типов Solidity** делится на value types (uint, bool, address - копируются) и reference types (arrays, mapping, struct - требуют указания data location: storage/memory/calldata). `mapping` работает только в storage из-за хеш-адресации
- **Функции** определяются с указанием visibility и state mutability (pure/view/payable). EVM маршрутизирует вызовы по **function selector** - первым 4 байтам keccak256 от сигнатуры. Специальные функции: `constructor`, `receive`, `fallback`
- **Видимость** влияет на gas: `external` с `calldata` дешевле `public` с `memory` для массивов, потому что данные не копируются. `private` не скрывает данные на блокчейне - storage всегда читаем
- **Модификаторы** с плейсхолдером `_;` позволяют декларативно добавлять pre/post-условия: `onlyOwner` для авторизации, `nonReentrant` для защиты от reentrancy. Цепочки модификаторов выполняются слева направо
- **Наследование** поддерживает множественное наследование через **C3-линеаризацию**, `virtual`/`override` для переопределения, `abstract` контракты и `interface`. Именно этот минимальный, но строго типизированный набор инструментов позволяет Solidity управлять миллиардами долларов - каждое ограничение языка существует для защиты от уязвимостей
Связанные темы
Solidity - высокоуровневый язык, который компилируется в EVM bytecode и работает в рамках gas-экономики. Понимание основ языка связывает машинный уровень с паттернами безопасной разработки:
- EVM: виртуальная машина — Solidity компилируется в EVM opcodes - типы, видимость и модификаторы превращаются в PUSH/SLOAD/SSTORE/JUMP
- Gas: экономика вычислений — Выбор типов (uint256 vs uint8), data location (calldata vs memory) и visibility (external vs public) напрямую определяет gas cost
- Паттерны Solidity — Основы языка - фундамент для паттернов безопасности: Checks-Effects-Interactions, Proxy, Factory и других
- Стандарты ERC — ERC20, ERC721, ERC1155 - интерфейсы, которые определяют стандарты токенов и реализуются через наследование
Вопросы для размышления
- Почему Solidity не имеет стандартной библиотеки (как Python или Java)? Какие преимущества и недостатки даёт подход "всё явно, ничего скрыто" в контексте контрактов, управляющих реальными деньгами?
- mapping не поддерживает итерацию и не хранит ключи. Какие паттерны можно использовать, если нужно перебрать всех владельцев токена? Какие trade-offs у этих подходов?
- Множественное наследование в Solidity решается через C3-линеаризацию, а в некоторых языках (Java, Rust) - через интерфейсы/traits без множественного наследования реализации. Какой подход безопаснее для смарт-контрактов и почему?