Блокчейн

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`, уничтожив реализацию, от которой зависели сотни кошельков

Предварительные знания

  • EVM: Ethereum Virtual Machine

Система типов 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 без множественного наследования реализации. Какой подход безопаснее для смарт-контрактов и почему?

Связанные уроки

  • comp-01-intro
Solidity: основы языка

0

1

Войти