В продолжении темы блокчейна и криптовалюты поговорим о языках, которые применяются в этой отрасли. Большинство из них — общеизвестные представители: Java, C/C++, Python, Ruby и т.д. С их помощью описываются общие интерфейсы и протоколы. Однако есть узкоспециальные задачи, где использование популярных языков неэффективно. Примером являются смарт-контракты и язык Solidity для платформы Ethereum.
Основы Solidity
Типы памяти
Важная вещь, которую вам нужно понять перед началом разработки на Solidity — это где и как хранятся все данные? От этого будет зависеть какие методы работы с памятью вы будите использовать и какое количество газа за это заплатит конечный пользователь вашего смарт-контракта!
- Storage — это постоянная память смарт-контракта, которых хранит в ней все глобальные переменные. Это самая дорогая память! Потому что данные, хранящиеся в ней, записываются в блокчейн навсегда. Это своего рода хранилище (база данных) вашего смарт-контракта.
- Memory — это временная память, которая выделятся только на момент вызова функций смарт-контракта. Это своего рода оперативная память необходимая для вычислений. Например, в ней хранятся значения переменных, которые вы передаете в локальные функции или значение переменных которые возвращают эти функции. Стоит она намного дешевле чем storage.
- Stack — эта память работает по принципам LIFO («последним пришел, первым вышел»). Главным образом ее использует EVM для всех вычислений. Стоит она также как memory. Использование данного вида памяти лучше оставить на усмотрение компилятора.
Местоположение данных в памяти
Для массивов и структур Solidity автоматически определяет, в зависимости от контекста, где должны располагаться эти данные — в storage или в memory. Например, переменные которые передаются в функцию, объявляются в функции и возвращаются функцией — хранятся в memory, а глобальные переменных хранятся в storage.
Однако, при помощи ключевых слов storage и memory вы можете переопределить это поведение и это повлияет на то, как будет работать оператор присваивания.
- Когда существующую переменную с типом memory мы присваиваем в переменную с типом storage, то мы копируем данные из memory в storage. При этом новое хранилище storage не создается (т.к. оно уже было выделено при создании контракта)!
- Когда существующую переменную с типом storage мы присваиваем в переменную с типом memory, то мы копируем данные из storage в memory. При этом выделяется новая память!
- Когда переменная storage создается внутри функции локально и ей присваивается объект путем поиска из списка глобальных переменных, то она просто ссылается на данные уже размещенные в хранилище storage. При этом новое хранилище не создается!
Мы можем переопределять местоположение данных в памяти только для параметров функции и ее локальных переменных. Всякий раз, когда мы переменную с типом storage присваиваем в переменную с типом memory, у нас создается копия и ее дальнейшая модификация не влияет на состояние контракта.
contract ExampleStore { /** * @dev Определяем структуру объекта */ struct Item { uint price; uint units; } /** * @dev При создании контракта выделяется storage память * для хранения массива наших структур Item */ Item[] public items; /** * @dev Создаем экземпляр объекта в memory памяти * и добавляем его в массиив хранящиися в storage */ function newItem(uint _price, uint _units) public { Item memory item = Item(_price, _units); items.push(item); } /** * @dev Возвращаем ссылку на конкретный объект * из существующего массива живущего в storage. */ function getUsingStorage(uint _itemIdx) public returns (uint) { Item storage item = items[_itemIdx]; return item.units; } /** * @dev Возвращаем копию конкретного объекта * из существующего массива живущего в storage. */ function getUsingMemory(uint _itemIdx) public returns (uint) { Item memory item = items[_itemIdx]; return item.units; } /** * @dev Берем ссылку на конкретный объект из существующего массива в storage * и изменяем его значение (значение в storage меняется) */ function addItemUsingStorage(uint _itemIdx, uint _units) public { Item storage item = items[_itemIdx]; item.units += _units; } /** * @dev Берем копию конкретного объекта из существующего массива в storage * и изменяем его значение (значение в storage НЕ меняется) */ function addItemUsingMemory(uint _itemIdx, uint _units) public { Item memory item = items[_itemIdx]; item.units += _units; } }
Курс по языку программирования Solidity – обучение с нуля, уроки
На август 2022 года в блокчейне Ethereum насчитывается почти 166 миллионов уникальных адресов. Адрес в Ethereum имеет схожие характеристики с почтовыми адресами. Благодаря использованию криптографии с открытым ключом.
Уникальность адреса
Ethereum адрес — это последние 20 байт хэша (хэш функция keccak-256) открытого ключа.
Поскольку функции хэширования детерминированы, это значит, что для разных входных данных будет разный хэш, при этом для одних и тех же входных данных хэш функция будет возвращать всегда единообразный хэш. Поэтому для уникального закрытого ключа => создаётся уникальный хэш.
Личное и секретное
Ключ вашего почтового ящика не только уникален, но также личный и секретный. С уникальностью мы уже познакомились, теперь давайте рассмотрим «личный» и «секретный».
Личный — только вы владеете ключом, который открывает ваш почтовый ящик. Вы храните ключ в тайне, прикрепив его к кольцу для ключей вместе со всеми остальными ключами.
Секретный (закрытый) — вы и только вы знаете, для чего может быть использован данный физический ключ. Если я дам вам свой набор ключей, вы не будете знать, каким именно ключом вам открыть мой почтовый ящик.
Аналогично в Ethereum, ваш закрытый ключ хранится в вашем кошельке. Только вы должны знать его и никогда не делиться им.
Управление секретным (закрытым) ключом
В реальном мире вы можете открыть свой почтовый ящик уникальным физическим ключом. Ваш почтовый ящик имеет встроенный замок, к которому привязан уникальный секретный ключ для его открытия.
В Ethereum вы можете использовать свой аккаунт с уникальным закрытым ключом.
Заметка: закрытый или секретный ключ? По сути это одно и тоже. Если вы говорите про “открытый ключ”, то в пару к нему говорите “закрытый ключ”. Если говорите “публичный ключ”, то говорите “секретный ключ”. Зависит от ваших предпочтений как использовать русский язык. Далее по тексту будет использоваться “закрытый” ключ.
В мире криптографии «личный» и «закрытый» ключ являются взаимозаменяемыми терминами. Открытый ключ является производным от закрытого ключа, поэтому они связаны между собой.
Закрытые ключи в Ethereum позволяют отправлять ether путем подписания транзакций. Единственным исключением являются смарт-контракты, как мы увидим позже.
Различные типы адресов
Ethereum адрес — это то же самое, что и почтовый адрес: он представляет собой адресата сообщения.
Адрес в платежной части транзакции Ethereum — это то же самое, что и счет получателя при банковском переводе.
Виды адресов в Ethereum: Externally owned accounts, contract accounts
External owned accounts (учетные записи, принадлежащие внешним пользователям, EOA): контролируются закрытыми ключами.
Закрытый ключ даёт контроль над ether на счёте и над процессами аутентификации, необходимой счёту при взаимодействии со смарт-контрактами. Они (закрытые ключи) используются для создания цифровых подписей, которые требуются для транзакций по расходованию любых средств на счете.
Contract accounts (учетные записи смарт-контрактов, CA): самоуправляемые своим своим кодом.
В отличие от EOA, у смарт-контрактов нет открытых или закрытых ключей. Смарт-контракты поддерживаются не закрытым ключом, а присущим им кодом. Можно сказать, что они «владеют собой».
Адрес каждого смарт-контракта определяется в ходе выполнения транзакции по созданию смарт-контракта, как результат функции от источника транзакции и nonce. Ethereum адрес смарт-контракта можно использовать в транзакции в качестве получателя, отправляя средства на смарт-контракт или вызывая одну из функций смарт-контракта.
Импорт файлов с исходным кодом
Когда ваш смарт-контракт состоит из нескольких файлов и в одном из этих файлов вы используете функции другого, то в нем важно указать, какой именно файл с исходным кодом вы используете. Это делается при помощи управляющей конструкции import. Обычно она указывается сразу между указанием версии смарт-контракта и блоком с описанием самого контракта. Причем вы можете указать как URL ссылку на смарт-контракт так и путь к файлу на вашем компьютере.
// Importing OpenZeppelin’s ERC-721 Implementation import «https://github.com/OpenZeppelin/openzeppelin-solidity/contracts/token/ERC721/ERC721.sol»; // Importing OpenZeppelin’s SafeMath Implementation import «https://github.com/OpenZeppelin/openzeppelin-solidity/contracts/utils/math/SafeMath.sol»;
Работа в Ambisafe Software
Мой интерес к Solidity также связан с желанием работать в Ambisafe Software, ведь идеи компании резонируют с моим мировоззрением. Ребята уже меняет мировую экономику, а мне с детства хочется участвовать в чем-то глобальном и значимом. Я хочу жить в мире, где люди могут открыть свой бизнес без взяток, бюрократии и кумовства. И сейчас Ambisafe Software приближает мир к этому идеалу. Мне хочется, чтобы это все продолжало существовать.
Сейчас я помогаю писать контракты для проекта, который занимается продажей недвижимости через блокчейн. В Ambisafe Software собраны гениальные умы и я думаю, что на самом деле не так важно, над каким проектом ты работаешь, а важно с кем именно ты работаешь: с такими людьми мы любой проект сделаем выдающимся.
Темы: Solidity, карьера
Структуры контракта
Функции
Объявление функций
Весь код смарт-контракта должен быть разделен на логические блоки, каждый из которых выполняет конкретную задачу. Такие логические блоки принято называть функциями и обозначать в коде ключевым словом function.
Функции могут располагаться:
- внутри контракта (блока contract),
- внутри библиотеки (блока library),
- на верхнем уровне, вне контракта и блиблиотек.
Видимость функций
- external — внешние функции являются частью контракта, что означает, что их можно вызывать из других контрактов и через транзакции. Чтение идет напрямую из calldata. Внешняя функция f не может быть вызвана изнутри (т.е. f () не работает, но this.f () работает).
- internal — доступ к этим функциям и переменным состояния можно получить только из текущего контракта или его наследников без использования this. Это уровень видимости по умолчанию для переменных состояния.
- public — публичные функции являются частью интерфейса контракта и могут вызываться либо внутри контракта как internal, либо вызываться внешне как external. При этом происходит копирование массива в memory, тем самым требуя больше памяти чем использование internal или external.
- private — частные функции и переменные состояния видны только для контрактах, в котором они определены. Но не видны в наследуемых от них контрактах.
Модификаторы функций
Кроме того, в Solidity есть такое понятие, как модификатор функции. Это своего рода заранее выделенное в отдельный блок кода именованное предусловие. Они обозначаются ключевым словом modifier . Если к вашей функции применить один из таких предопределенных модификаторов, то ваша фукнция будет выполняться только в том случае, если в ее модификаторе заданные в нем условия успешно прошли проверки.
Ниже пример функции и модификатора:
pragma solidity >=0.7.0 <0.9.0; contract MyContractName { address public seller; modifier onlySeller() { // Modifier require( msg.sender == seller, «Only seller can call this.» ); _; } function abort() public onlySeller { // Modifier usage // … } }
Типы функций
Функции бывают внутренние и внешние. Внутренние функции могут быть вызваны только из текущего контракта, в котором объявлена эта функция. Внешние функции состоят из адреса и сигнатуры, которые могут быть переданы через возвращаемое значение вызова внешней функции.
function () {internal|external} [pure|view|payable]
- — это параметры, которые вы передаете в функцию. Если вы ничего не передаете в функцию, то параметры не указываются.
- {internal|external} — это указание на то, является ли функция внутренней или внешней. Если не указывать, то по умолчанию, функция будет считаться внутренней (internal). Если же функция внешняя, то вы должны явно указать ей значение external.
- [pure|view|payable] — это определение характера функции, они могут быть следующими: pure — функция, которая делает расчет только на основе переданных в нее аргументов, при этом она не читает и не изменяет переменные состояния самого контракта.
- view — функция доступная только для чтения данных, что дает гарантию о том, что переменные состояния в ней не изменятся (ставится по умолчанию, если не задан другой характер функции).
- payable — позволяет функции получать эфир при её вызове.
Есть 2 способа вызвать функцию, — либо по ее имени f(), либо используя this.f(). При этом, если вы не объявили функцию с таким именем, либо удалили ее при помощи ключевого слова delete до ее вызова, то это вызовет исключение. Как правило, если происходит вызов внутренней функции, то используется формат f(), а если внешней, — то используется формат this.f().
Встроенные методы внешних функций
Для внешних функций вы можете использовать 2 встроенных метода:
- .address — возвращает адрес контракта функции.
- .selector — возвращает селектор функций ABI.
Ранее использовалось еще 2 метода .gas (uint) и .value (uint). Они были объявлены устаревшими в Solidity 0.6.2 и удалены в Solidity 0.7.0. Вместо этого используйте {gas: …} и {value: …}, чтобы указать количество газа или количество wei, отправленных функции, соответственно.
pragma solidity >=0.6.4 <0.9.0; contract Example { function f() public payable returns (bytes4) { assert(this.f.address == address(this)); return this.f.selector; } function g() public { this.f{gas: 10, value: 800}(); } }
Специальные функции fallback и receive
- fallback функция исполняется при вызове контракта, если ни одна из других его функций не соответствует заданной сигнатуре, т.е. по сути если вызывается несуществующая в контракте функция. Также fallback исполняется если вызвали контракт без передачи каких-либо данных либо перевели эфир, но в контракте нет функции receive, которая его бы обработала. Fallback функция всегда по умолчанию принимает любые данные, но чтобы принимать эфир, она должна быть помечена как payable.
- receive функция исполняется при вызове контракта с пустым значением calldata. Это функция, которая вызывается при передаче простого эфира на адрес контракта, например, при помощи функций send и transfer. Если при передаче эфира receive функции не существует, но в контракте определена fallback функция, то будет вызываться она. Если же не будет определена и fallback функция, то контракт не сможет принимать эфир, а все попытки передать эфир на данный контракт будут вызывать исключение.
contract TestPayable { uint x; uint y; // Эта функция исполняется при отправке любого сообщения на этот контракт // включая прием отправленного эфира (когда не определена функция receive). // Любой вызов с не пустой calldata на этот контракт будет исполнять // fallback функцию (даже если вместе с вызовом отправлеяется эфир). fallback() external payable { x = 1; y = msg.value; } // Эта функция исполняется при отправке эфира на этот контракт, // т.е. для всех вызовов контрактк с пустым calldata. receive() external payable { x = 2; y = msg.value; } }
Функции virtual и override
Базовые функции можно переопределить в унаследованных контрактах, чтобы изменить их поведение. Такие функции должны быть помечены как virtual.
Переопределяемая virtual функция должна быть помечена ключевым словом override.
Правила переопределения override функции:
- видимость может быть изменена только с external на public,
- если virtual функция не была payable, то она может быть перепоределена в pure и view,
- view может быть переопределена в pure,
- payable не может быть переопределена в какой-либо другой модификатор видимости.
// SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.7.0 <0.9.0; contract Base { function foo() virtual external view {} } contract Inherited is Base { function foo() override public pure {} }
События
События позволяют вам удобным образом отображать в логах виртуальной машины Ethereum необходимую информацию о наступлении какого-либо события.
Чтобы определить событие в коде, используется ключевое слово event. А чтобы вызвать это событие, используется ключевое слово emit.
pragma solidity >=0.7.0 <0.9.0; contract MyContractName { event HighestBidIncreased(address bidder, uint amount); // Event function bid() public payable { // … emit HighestBidIncreased(msg.sender, msg.value); // Triggering event } }
Ошибки
Вы можете заранее описать возможные ошибки и присвоить им имена используя ключевое слово error. При возникновении нетипичной ситуации в коде, вы можете вызвать ошибку по ее имени так же, как и функцию используя ключевое слово revert. Это намного дешевле, чем использовать строковые описания ошибок и дает возможность передававать дополнительные данные.
pragma solidity >=0.7.0 <0.9.0; /// Not enough funds for transfer. Requested `requested`, /// but only `available` available. error NotEnoughFunds(uint requested, uint available); contract Token { mapping(address => uint) balances; function transfer(address to, uint amount) public { uint balance = balances[msg.sender]; if (balance < amount) revert NotEnoughFunds(amount, balance); balances[msg.sender] -= amount; balances[to] += amount; // … } }
Cтруктуры
Структура это по сути описание какого-либо объекта с набором его свойств, которая задается ключевым словом struct. Например, вы хотите описать характеристики персонажа в вашей игре. Для этого вы создаете структуру Human и внутри него описываете его характеристики в виде переменных. Структуры разрешено размещать как вне контракта, так и внутри него.
pragma solidity >=0.7.0 <0.9.0; contract Game { struct Human { // Struct uint weight; uint age; bool isMerried; address delegate; } }
Перечисления
Это объект, который может хранить в себе только заранее предопределенные в нем значения. Он задается ключевым словом enum. Например, определим в нашей программе объект enum, в котором будем хранить набор 3 цветов. А затем создадим переменную с типом данного объекта и присвоим ей одно из значений данного набора.
pragma solidity >=0.7.0 <0.9.0; contract MyContractName { // define enum colors enum Color { Red, Green, Yellow } // Enum // set enum value Color constant defaultColor = Color.Green; }
Переменные
Типы переменных
- State Variable — переменная состояния, значения которых хранятся в хранилище контракта.
- Local Variable — переменные, которые существуют до выполнения функции.
- Global Variable — глобальные переменные, которые позволяют получать информацию о блокчейне.
Типы значений
Boolean
Булевая переменная, которая может хранить в себе только 2 состояния: true и false. Пример:
bool public paused = false;
Применяемые операторы: !, &&, ||, ==, !=
Integers
int / uint — целочисленный тип данных со знаком и без знака. Чаще всего указывается с количеством зарезервированных байт под объявляемую переменную. Например, int / uint является аналогом int256 / uint256 м
int / uint — целочисленный тип данных со знаком и без знака. Чаще всего указывается с количеством зарезервированных битов от 8 до 256 под объявляемую переменную. Например, int / uint является аналогом int256 / uint256.
// переменные int* int8 = От -128 до 127 int16 = От -32 768 до 32 767 int32 = От -2 147 483 648 до 2 147 483 647 int64 = От -9 223 372 036 854 775 808 до 9 223 372 036 854 775 807 int128 = От -170141183460469231731687303715884105728 до 170141183460469231731687303715884105727 int256 = От -57896044618658097711785492504343953926634992332820282019728792003956564819968 до 57896044618658097711785492504343953926634992332820282019728792003956564819967 // переменные uint* uint8 = От 0 до 255 uint16 = От 0 до 65 535 uint32 = От 0 до 4 294 967 295 uint64 = От 0 до 18 446 744 073 709 551 615 uint256 = От 0 до 115792089237316195423570985008687907853269984665640564039457584007913129639935
Применяемые операторы: — Сравнение: <=, <, ==, !=, >=, > — Битовые: &, |, ^, ~ — Сдвига: <<, >> — Арифметические: +, —, унарный —, *, /, %, **
Fixed Point Numbers
Числа с фиксированной точкой сейчас имеют еще не полную поддержку в Solidity. Это значит, что вы можете объявлять этот тип, но не можете его назначить или не можете назначать другие переменных от значения данного типа.
Определяются они ключевыми словами fixedMxN и ufixedMxN соответственно для чисел со знаком и без знака. Где вместо М проставляется количество битов занятых хранимым значением от 8 до 256, а N — количество доступных десятичных знаков от 0 до 80. Причем ufixed и fixed — является аналогом для ufixed128x18 и fixed128x18 соответственно.
Применяемые операторы: — Сравнение: <=, <, ==, !=, >=, > — Арифметические: +, —, унарный —, *, /, %
Address
Это фиксированное 20 байтовое значение (длинна адреса в Ethereum), которое является основой во всех контрактах. При помощи данного типа вы можете указывать участников, адреса смарт-контактов и адреса собственных токенов.
address nameReg = 0xdfc4bccf1aec515932c2d1ae499f92bb4ce04113;
Адрес также может быть объявлен как payable, в таком случае на этот адрес появляется возможность отправлять эфир при помощи двух дополнительных методов, которые он приобретает: transfer и send.
address payable nameReg = 0xdfc4bccf1aec515932c2d1ae499f92bb4ce04113;
Если вы планируете объявить переменную с типом address на который хотите переводить эфир, то сделайте эту переменную с типом address payable.
Применяемые операторы: <=, <, ==, !=, >=, >
Тип контракта
Каждый контракт имеет свой тип. Контракты могут быть преобразованы в тип адреса и обратно. Причем, преобразование контракта в тип address payable можно только в том случае, если сам контракт имеет функцию «получения и возврата» (receive or payable fallback function).
Если вы объявите локальную переменную контракта, то можете вызывать функции этого контракта. Также вы можете создавать экземпляры контракта при помощи слова new, это значит что контракт будет создаваться заново. Передав переменную контракта C в функцию type(C), вы сможете увидеть информацию о контракте.
Массивы
Массивы могут иметь статический или динамический размер. Массив Т фиксированного размера k записывается как T[k], а динамический массив записывается как T[]. Например, массив из 5 динамических массивов записывается как uint[][5]. Обратите внимание, что это обратная нотация по сравнению с большинством других языков (в них было бы наоборот).
Элементами массива могут быть переменные любого типа, включая mapping и struct. Если пометить массивы как public то Solidity создаст для них геттер (функцию получения из него элементов). Числовой индекс будет являться обязательным параметром для этого геттера. Доступ к несуществующему элементу массива вызывает ошибку. При помощи методов .push() и .push(value) вы можете добавить новый элемент в конец массива, причем вызов метода .push() добавит пустой элемент и возвратит ссылку на него.
Массивы типа bytes и string.
Массивы с типом bytes и string представляют из себя специальные массивы. Например, bytes похож на byte[], но плотно упакован в памяти и функции обратного вызова, а строка равна байтам, но не разрешает доступа к длине или индексу.
Вы можете использовать bytes вместо byte[], т.к. это дешевле, поскольку byte[] добавляет 31 байт между элементами. Используйте bytes для необработанных байтовых данных произвольной длинны (UTF-8). А если вы можете ограничит длину произвольным количеством байтов от 1 до 32 то используйте типы bytes1 .. bytes32, так как это намного дешевле. Если вы хотите объединить несколько переменных типа bytes, то используйте встроенную функцию bytes.concat.
bytes s = «Storage»; function f(bytes calldata c, string memory m, bytes16 b) public view { bytes memory a = bytes.concat(s, c, c[:2], «Literal», bytes(m), b); assert((s.length + c.length + 2 + 7 + bytes(m).length + 16) == a.length); }
Выделение массивов Memory
Динамические массивы memory можно создать при помощи оператора new. В отличие от storage массивов, у них нельзя изменить размер, поэтому вы должны заранее рассчитать под них требуемый размер.
uint[] memory a = new uint[](7); bytes memory b = new bytes(len);
Методы массива
- length — позволяет понять количество элементов в массиве,
- push() — позволяет добавить новый пустой элемент в конец массива, возвращает ссылку на добавленный элемент,
- push(x) — позволять добавить новый элемент «х» в конец массива, ничего не возвращает,
- pop — удаляет последний элемент массива.
Срезы массивов
Это представление непрерывной части массива начиная и заканчивая определенным его индексом.
Вызывается это как x[start:end], где start это начальный индекс, а end — конечный, которым заканчивается представление части массива. Причем, указание start и end носит опциональный характер, т.е. если не указывать start то это будет считаться значением 0, а если не указывать end, то это будет считаться последним элементом массива.
Строки
В переменных типа string вы можете хранить строковые значения, которые заключаются в одинарные или двойные кавычки. В них также есть поддержка escape символов, таких как \n, \xNN и т.д.
string public constant name = «This is my string value;
Solidity не имеет возможности манипулирования строками, но есть сторонние строковые библиотеки. Например, вы можете сравнить две строки по их хэшу: keccak256(abi.encodePacked(s1)) == keccak256(abi.encodePacked(s2)) либо объединить две строки при помощи bytes.concat(bytes(s1), bytes(s2)).
Unicode литералы
Обычные строки задаются в кодировке ASCII, но если в строке вы планируете использовать UTF-8, то нужно перед двойными или одинарными кавычками указать ключевое слово unicode.
string memory a = unicode»Hello