Автор Джоэл Адеволе✏️
В связи с продолжающимся ростом популярности блокчейна и DApps (децентрализованных приложений), DApps с открытым исходным кодом становятся все более популярными среди широкого круга разработчиков. Сердцем большинства DApps и блокчейн-приложений являются смарт-контракты, разработанные с использованием Solidity.
Участие в проектах с открытым исходным кодом вызывает обеспокоенность в сообществе Solidity, поскольку эти проекты имеют реальные последствия для денег людей, а когда разработчики из разных стран сотрудничают над проектом, почти наверняка в приложениях будут ошибки и конфликты кода. Вот почему соблюдение надлежащих стандартов для DApps так важно.
Чтобы поддерживать отличные стандарты, устранять риски, смягчать конфликты и строить масштабируемые и безопасные смарт-контракты, необходимо изучить и использовать правильную реализацию паттернов проектирования и стилей в Solidity.
В этой статье мы рассмотрим паттерн проектирования Solidity; чтобы следовать этому, вы должны быть знакомы с Solidity.
- Содержание
- Что такое паттерн проектирования Solidity?
- Поведенческие паттерны
- Охранная проверка
- Машина состояний
- Oracle
- Случайность
- Шаблоны безопасности
- Ограничение доступа
- Проверка взаимодействия эффектов
- Безопасный перевод Эфира
- Pull-over-push
- Аварийная остановка
- Модели обновляемости
- Прокси-делегат
- Построение массива памяти
- Вечное хранение
- Память против хранилища
- Заключительные мысли
- WazirX, Bitso и Coinsquare используют LogRocket для проактивного мониторинга своих Web3-приложений
Содержание
- Что такое паттерн проектирования Solidity?
- Поведенческие паттерны
- Охранная проверка
- Машина состояний
- Oracle
- Случайность
- Модели безопасности
- Ограничение доступа
- Взаимодействие эффектов проверки
- Безопасная передача эфира
- Перетаскивание
- Аварийная остановка
- Модели обновляемости
- Прокси-делегат
- Построение массива памяти
- Вечное хранение
- Память против хранилища
Что такое паттерн проектирования Solidity?
Как разработчик, вы можете научиться использовать Solidity на различных ресурсах в Интернете, но эти материалы не одинаковы, потому что существует множество различных способов и стилей реализации вещей в Solidity.
Шаблоны проектирования — это многоразовые, стандартные решения, используемые для устранения повторяющихся недостатков проектирования. Перевод с одного адреса на другой — это практический пример часто встречающейся проблемы в Solidity, которую можно регулировать с помощью паттернов проектирования.
При передаче Ether в Solidity мы используем методы Send
, Transfer
или Call
. Эти три метода имеют одну и ту же единственную цель: отправить Эфир из смарт-контракта. Давайте посмотрим, как использовать методы Transfer
и Call
для этой цели. Следующие примеры кода демонстрируют различные реализации.
Первым является метод Transfer
. При использовании этого метода все принимающие смарт-контракты должны определить функцию обратного действия, иначе транзакция передачи не состоится. Имеется лимит газа в 2300 единиц, что достаточно для завершения транзакции передачи и помогает предотвратить повторные нападения:
function Transfer(address payable _to) public payable {
_to.transfer(msg.value);
}
Приведенный выше фрагмент кода определяет функцию Transfer
, которая принимает адрес приема как _to
и использует метод _to.transfer
для инициирования передачи Эфира, указанного как msg.value
.
Далее следует метод Call
. С помощью этого метода можно инициировать другие функции в контракте и, по желанию, установить плату за газ, которая будет использоваться при выполнении функции:
function Call(address payable _to) public payable {
(bool sent) = _to.call.gas(1000){value: msg.value}("");
require("Sent, Ether not sent");
}
Приведенный выше фрагмент кода определяет функцию Call
, которая принимает адрес приема как _to
, устанавливает статус транзакции как boolean, а возвращаемый результат предоставляется в переменной data. Если msg.data
пуста, функция receive
выполняется сразу после метода Call
. Функция fallback выполняется там, где нет реализации функции receive.
Наиболее предпочтительным способом передачи Эфира между смарт-контрактами является использование метода Call
.
В приведенных выше примерах мы использовали два разных метода для передачи Ether. Вы можете указать, сколько газа вы хотите потратить, используя Call
, в то время как Transfer
по умолчанию имеет фиксированное количество газа.
Эти приемы являются шаблонами, практикуемыми в Solidity для реализации повторяющегося появления Transfer
.
Чтобы не отрываться от контекста, в следующих разделах приведены некоторые паттерны проектирования, которые регулируются Solidity.
Поведенческие паттерны
Охранная проверка
Основная функция смарт-контрактов заключается в обеспечении выполнения требований транзакций. Если какое-либо условие не выполняется, контракт возвращается к своему предыдущему состоянию. Solidity достигает этого, используя механизм обработки ошибок EVM, чтобы бросать исключения и восстанавливать контракт в рабочее состояние, предшествующее исключению.
Приведенный ниже смарт-контракт показывает, как реализовать паттерн проверки защиты, используя все три техники:
contract Contribution {
function contribute (address _from) payable public {
require(msg.value != 0);
require(_from != address(0));
unit prevBalance = this.balance;
unit amount;
if(_from.balance == 0) {
amount = msg.value;
} else if (_from.balance < msg.sender.balance) {
amount = msg.value / 2;
} else {
revert("Insufficent Balance!!!");
}
_from.transfer(amount);
assert(this.balance == prevBalance - amount);
}
}
В приведенном выше фрагменте кода Solidity обрабатывает исключения ошибок следующим образом:
require()
объявляет условия, при которых выполняется функция. Она принимает одно условие в качестве аргумента и выбрасывает исключение, если условие оценивается как false, прекращая выполнение функции без сжигания газа.
assert()
оценивает условия для функции, затем выбрасывает исключение, возвращает контракт в предыдущее состояние и расходует запас газа, если требования не выполняются после выполнения.
revert()
выбрасывает исключение, возвращает весь поставленный газ и возвращает вызов функции в исходное состояние контракта, если требование для функции не выполнено. Метод revert()
не оценивает и не требует никаких условий.
Машина состояний
Модель машины состояний моделирует поведение системы на основе ее предыдущих и текущих входов. Разработчики используют этот подход для разбиения больших проблем на простые этапы и переходы, которые затем используются для представления и управления потоком выполнения приложения.
Модель машины состояний также может быть реализована в смарт-контрактах, как показано в приведенном ниже фрагменте кода:
contract Safe {
Stages public stage = Stages.AcceptingDeposits;
uint public creationTime = now;
mapping (address => uint) balances;
modifier atStage(Stages _stage) {
require(stage == _stage);
_;
}
modifier timedTransitions() {
if (stage == Stages.AcceptingDeposits && now >=
creationTime + 1 days)
nextStage();
if (stage == Stages.FreezingDeposits && now >=
creationTime + 4 days)
nextStage();
_;
}
function nextStage() internal {
stage = Stages(uint(stage) + 1);
}
function deposit() public payable timedTransitions atStage(Stages.AcceptingDeposits) {
balances[msg.sender] += msg.value;
}
function withdraw() public timedTransitions atStage(Stages.ReleasingDeposits) {
uint amount = balances[msg.sender];
balances[msg.sender] = 0;
msg.sender.transfer(amount);
}
}
В приведенном фрагменте кода контракт Safe
использует модификаторы для обновления состояния контракта между различными этапами. Этапы определяют, когда можно вносить и снимать средства. Если текущее состояние контракта не AcceptingDeposit
, пользователи не могут вносить депозиты в контракт, а если текущее состояние не ReleasingDeposit
, пользователи не могут выводить средства из контракта.
Oracle
Контракты Ethereum имеют свою собственную экосистему, в которой они общаются. Система может импортировать внешние данные только через транзакцию (передавая данные методу), что является недостатком, поскольку многие случаи использования контрактов предполагают получение знаний из источников, отличных от блокчейна (например, фондового рынка).
Одним из решений этой проблемы является использование паттерна оракула с подключением к внешнему миру. Когда служба оракула и смарт-контракт взаимодействуют асинхронно, служба оракула служит в качестве API. Транзакция начинается с вызова функции смарт-контракта, которая содержит инструкцию по отправке запроса к оракулу.
На основе параметров такого запроса оракул получит результат и вернет его, выполнив функцию обратного вызова в основном контракте. Контракты на основе Oracle несовместимы с концепцией блокчейна как децентрализованной сети, поскольку они полагаются на честность одной организации или группы.
Сервисы Oracle 21 и 22 устраняют этот недостаток, обеспечивая проверку достоверности предоставляемых данных. Обратите внимание, что оракул должен заплатить за вызов обратного вызова. Поэтому плата за оракула взимается вместе с Эфиром, необходимым для вызова обратного вызова.
Приведенный ниже фрагмент кода показывает транзакцию между контрактом оракула и его потребительским контрактом:
contract API {
address trustedAccount = 0x000...; //Account address
struct Request {
bytes data;
function(bytes memory) external callback;
}
Request[] requests;
event NewRequest(uint);
modifier onlyowner(address account) {
require(msg.sender == account);
_;
}
function query(bytes data, function(bytes memory) external callback) public {
requests.push(Request(data, callback));
NewRequest(requests.length - 1);
}
// invoked by outside world
function reply(uint requestID, bytes response) public
onlyowner(trustedAccount) {
requests[requestID].callback(response);
}
}
В приведенном выше фрагменте кода смарт-контракт API
отправляет запрос на knownSource
с помощью функции query
, которая выполняет функцию external callback
и использует функцию reply
для сбора ответных данных из внешнего источника.
Случайность
Несмотря на то, насколько сложно генерировать случайные и уникальные значения в Solidity, это очень востребовано. Метки времени блока являются источником случайности в Ethereum, но они рискованны, поскольку майнер может их подделать. Чтобы предотвратить эту проблему, были созданы такие решения, как блок-хэш PRNG и Oracle RNG.
В следующем фрагменте кода показана базовая реализация этого шаблона с использованием хэша последнего блока:
// This method is predicatable. Use with care!
function random() internal view returns (uint) {
return uint(blockhash(block.number - 1));
}
Приведенная выше функция randomNum()
генерирует случайное и уникальное целое число путем хэширования номера блока (block.number
, который является переменной в блокчейне).
Шаблоны безопасности
Ограничение доступа
Поскольку в Solidity нет встроенных средств для управления привилегиями выполнения, одной из распространенных тенденций является ограничение выполнения функций. Выполнение функций должно происходить только при определенных условиях, таких как время, информация о вызывающей стороне или транзакции, а также другие критерии.
Вот пример кондиционирования функции:
contract RestrictPayment {
uint public date_time = now;
modifier only(address account) {
require(msg.sender == account);
_;
}
function f() payable onlyowner(date_time + 1 minutes){
//code comes here
}
}
Приведенный выше контракт Restrict не позволяет любому account
, отличному от msg.sender
, выполнить функцию payable
. Если требования для функции payable
не выполняются, используется require
, чтобы выбросить исключение перед выполнением функции.
Проверка взаимодействия эффектов
Модель взаимодействия с эффектами проверки снижает риск того, что вредоносные контракты попытаются завладеть потоком управления после внешнего вызова. Контракт, скорее всего, передает поток управления внешнему субъекту во время процедуры передачи Эфира. Если внешний контракт является вредоносным, он может нарушить поток управления и привести отправителя к нежелательному состоянию.
Чтобы использовать этот паттерн, мы должны знать, какие части нашей функции являются уязвимыми, чтобы мы могли отреагировать, как только найдем возможный источник уязвимости.
Ниже приведен пример использования этого паттерна:
contract CheckedTransactions {
mapping(address => uint) balances;
function deposit() public payable {
balances[msg.sender] = msg.value;
}
function withdraw(uint amount) public {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
msg.sender.transfer(amount);
}
}
В приведенном выше фрагменте кода используется метод require()
, который выбрасывает исключение, если условие balances[msg.sender] >= amount
не выполняется. Это означает, что пользователь не может снять сумму
, превышающую баланс msg.sender
.
Безопасный перевод Эфира
Хотя криптовалютные переводы не являются основной функцией Solidity, они происходят часто. Как мы обсуждали ранее, Transfer
, Call
и Send
— это три основных метода передачи Эфира в Solidity. Невозможно решить, какой метод использовать, если не знать их различий.
В дополнение к двум методам (Transfer
и Call
), рассмотренным ранее в этой статье, передача Эфира в Solidity может осуществляться с помощью метода Send
.
Send
похож на Transfer
тем, что он стоит столько же газа, сколько и метод по умолчанию (2300). Однако, в отличие от Transfer
, она возвращает булев результат, показывающий, была ли Send
успешной или нет. Большинство проектов Solidity больше не используют метод Send
.
Ниже приведена реализация метода Send
:
function send(address payable _to) external payable{
bool sent = _to.send(123);
require(sent, "send failed");
}
Функция send
, приведенная выше, использует функцию require()
, чтобы выбросить исключение, если Boolean
значение sent, возвращаемое из _to.send(123)
равно false
.
Pull-over-push
Этот шаблон проектирования переносит риск передачи Эфира с контракта на пользователей. Во время передачи Эфира несколько вещей могут пойти не так, что приведет к неудаче транзакции. В шаблоне pull-over-push участвуют три стороны: организация, инициирующая передачу (автор контракта), смарт-контракт и получатель.
Эта схема включает в себя отображение, что помогает отслеживать остатки средств на счетах пользователей. Вместо того чтобы передать Эфир из контракта получателю, пользователь вызывает функцию для снятия выделенного ему Эфира. Любая неточность в одном из переводов не влияет на другие транзакции.
Ниже приведен пример pull-over-pull:
contract ProfitsWithdrawal {
mapping(address => uint) profits;
function allowPull(address owner, uint amount) private {
profits[owner] += amount;
}
function withdrawProfits() public {
uint amount = profits[msg.sender];
require(amount != 0);
require(address(this).balance >= amount);
profits[msg.sender] = 0;
msg.sender.transfer(amount);
}
}
В приведенном выше контракте ProfitsWithdrawal
позволяет пользователям снимать прибыль, сопоставленную с их адресом
, если баланс пользователя больше или равен прибыли, выделенной пользователю.
Аварийная остановка
Проверенные смарт-контракты могут содержать ошибки, которые не обнаруживаются до тех пор, пока они не будут вовлечены в кибер-инцидент. Ошибки, обнаруженные после запуска контракта, будет сложно исправить. С помощью этой конструкции мы можем остановить контракт, заблокировав вызовы критических функций, не позволяя злоумышленникам до исправления смарт-контракта.
Чтобы предотвратить злоупотребления функцией остановки, она должна быть разрешена только авторизованным пользователям. Переменная состояния устанавливается от false
до true
для определения прекращения действия контракта. После прекращения действия контракта можно использовать шаблон ограничения доступа, чтобы гарантировать отсутствие выполнения какой-либо критической функции.
Для этого можно использовать модификацию функции, которая выбрасывает исключение, если переменная состояния указывает на инициирование аварийной остановки, как показано ниже:
contract EmergencyStop {
bool Running = true;
address trustedAccount = 0x000...; //Account address
modifier stillRunning {
require(Running);
_;
}
modifier NotRunning {
require(¡Running!);
_;
}
modifier onlyAuthorized(address account) {
require(msg.sender == account);
_;
}
function stopContract() public onlyAuthorized(trustedAccount) {
Running = false;
}
function resumeContract() public onlyAuthorized(trustedAccount) {
Running = true;
}
}
Приведенный выше контракт EmergencyStop
использует модификаторы для проверки условий и выбрасывает исключения, если одно из этих условий выполнено.
Контракт использует функции stopContract()
и resumeContract()
для обработки аварийных ситуаций. Контракт может быть возобновлен путем сброса переменной state в false
. Этот метод должен быть защищен от несанкционированных вызовов так же, как и функция аварийной остановки.
Модели обновляемости
Прокси-делегат
Этот паттерн позволяет обновлять смарт-контракты, не нарушая ни одного из их компонентов. При использовании этого метода используется специальное сообщение Delegatecall
. Оно передает вызов функции делегату без раскрытия подписи функции.
Функция fallback контракта прокси использует его для инициирования механизма пересылки для каждого вызова функции. Единственное, что возвращает Delegatecall
— это булево значение, которое указывает, было ли выполнение успешным или нет. Нас больше интересует возвращаемое значение вызова функции. Помните, что при обновлении контракта последовательность хранения не должна меняться; допускается только добавление.
Вот пример реализации этого паттерна:
contract UpgradeProxy {
address delegate;
address owner = msg.sender;
function upgradeDelegate(address newDelegateAddress) public {
require(msg.sender == owner);
delegate = newDelegateAddress;
}
function() external payable {
assembly {
let _target := sload(0)
calldatacopy(0x01, 0x01, calldatasize)
let result := delegatecall(gas, _target, 0x01, calldatasize, 0x01, 0)
returndatacopy(0x01, 0x01, returndatasize)
switch result case 0 {revert(0, 0)} default {return (0, returndatasize)}
}
}
}
В приведенном выше фрагменте кода UpgradeProxy
обрабатывает механизм, позволяющий обновить контракт delegate
после того, как owner
выполнит контракт, вызывая функцию fallback, которая передает копию данных контракта delegate
в новую версию.
Построение массива памяти
Этот метод быстро и эффективно объединяет и извлекает данные из хранилища контрактов. Взаимодействие с памятью контракта — одно из самых дорогостоящих действий в EVM. Обеспечение удаления избыточных данных и хранение только необходимых данных может помочь минимизировать затраты.
Мы можем агрегировать и считывать данные из хранилища контрактов без дополнительных затрат, используя модификацию функции view. Вместо того чтобы хранить массив в хранилище, он воссоздается в памяти каждый раз, когда требуется выполнить поиск.
Для упрощения поиска данных используется легко итерируемая структура данных, например массив. При работе с данными, имеющими несколько атрибутов, мы объединяем их с помощью пользовательского типа данных, например struct.
Сопоставление также необходимо для отслеживания ожидаемого количества вводимых данных для каждого экземпляра агрегата. Приведенный ниже код иллюстрирует эту схему:
contract Store {
struct Item {
string name;
uint32 price;
address owner;
}
Item[] public items;
mapping(address => uint) public itemsOwned;
function getItems(address _owner) public view returns (uint[] memory) {
uint[] memory result = new uint[](itemsOwned[_owner]);
uint counter = 0;
for (uint i = 0; i < items.length; i++) {
if (items[i].owner == _owner) {
result[counter] = i;
counter++;
}
}
return result;
}
}
В приведенном выше контракте Store
мы использовали struct
для создания структуры данных элементов в списке, затем мы сопоставили элементы с адресами их владельцев address
. Чтобы получить элементы, принадлежащие адресу, мы используем функцию getItems
для создания памяти под названием result
.
Вечное хранение
Этот паттерн сохраняет память обновленного смарт-контракта. Поскольку старый контракт и новый контракт разворачиваются на блокчейне отдельно, накопленное хранилище остается на старом месте, где хранится информация о пользователях, остатки на счетах и ссылки на другую ценную информацию.
Вечное хранилище должно быть максимально независимым, чтобы предотвратить модификацию хранилища данных путем реализации нескольких отображений хранилища данных, по одному для каждого типа данных. Преобразование абстрактного значения в карту хэша sha3 служит в качестве хранилища ключей-значений.
Поскольку предлагаемое решение сложнее, чем обычное хранилище значений, обертки могут уменьшить сложность и сделать код более понятным. В обновляемом контракте, использующем вечное хранение, обертки облегчают работу с незнакомым синтаксисом и ключами с хэшами.
Приведенные ниже фрагменты кода показывают, как использовать обертки для реализации вечного хранения:
function getBalance(address account) public view returns(uint) {
return eternalStorageAdr.getUint(keccak256("balances", account));
}
function setBalance(address account, uint amount) internal {
eternalStorageAdr.setUint(keccak256("balances", account), amount);
}
function addBalance(address account, uint amount) internal {
setBalance(account, getBalance(account) + amount);
}
В приведенном выше фрагменте кода мы получили баланс счета
из вечного хранилища с помощью хэш-функции keccak256
в enternalStorageAdr.getUint()
, и аналогичным образом установили баланс счета.
Память против хранилища
Storage
, memory
или calldata
— это методы, используемые при объявлении местоположения динамического типа данных в виде переменной, но мы пока сосредоточимся на memory
и storage
. Термин storage
относится к переменной состояния, общей для всех экземпляров смарт-контракта, тогда как memory
относится к временному месту хранения данных в каждом экземпляре исполнения смарт-контракта. Давайте рассмотрим пример кода ниже, чтобы увидеть, как это работает:
Пример использования storage
:
contract BudgetPlan {
struct Expense {
uint price;
string item;
}
mapping(address => Expense) public Expenses;
function purchase() external {
Expense storage cart = Expenses[msg.sender]
cart.string = "Strawberry"
cart.price = 12
}
}
В приведенном выше контракте BudgetPlan
мы создали структуру данных для расходов счета, где каждый расход (Expense
) является структурой, содержащей price
и item
. Затем мы объявили функцию purchase
для добавления нового Expense
в storage
.
Пример с использованием памяти
:
contract BudgetPlan {
struct Expense {
uint price;
string item;
}
mapping(address => Expense) public Expenses;
function purchase() external {
Expense memory cart = Expenses[msg.sender]
cart.string = "Strawberry"
cart.price = 12
}
}
Почти как в примере с использованием storage
, все то же самое, но во фрагменте кода мы добавляем новый Expense
в память, когда выполняется функция purchase
.
Заключительные мысли
Разработчикам следует придерживаться паттернов проектирования, поскольку существуют различные методы достижения конкретных целей или реализации определенных концепций.
Вы заметите существенные изменения в своих приложениях, если будете использовать эти шаблоны проектирования Solidity. Ваше приложение станет проще в разработке, чище и безопаснее.
Я рекомендую вам использовать хотя бы один из этих паттернов в вашем следующем проекте Solidity, чтобы проверить ваше понимание этой темы.
Не стесняйтесь задавать любые вопросы по этой теме или оставляйте комментарии в разделе комментариев ниже.
WazirX, Bitso и Coinsquare используют LogRocket для проактивного мониторинга своих Web3-приложений
Проблемы на стороне клиента, которые влияют на способность пользователей активировать и совершать транзакции в ваших приложениях, могут существенно повлиять на вашу прибыль. Если вы заинтересованы в мониторинге проблем UX, автоматическом выявлении ошибок JavaScript, отслеживании медленных сетевых запросов и времени загрузки компонентов, попробуйте LogRocket.
LogRocket — это как видеорегистратор для веб-приложений и мобильных приложений, записывающий все, что происходит в вашем веб-приложении или на сайте. Вместо того, чтобы гадать, почему возникают проблемы, вы можете собирать и предоставлять отчеты по ключевым показателям производительности фронтенда, воспроизводить сеансы пользователей вместе с состоянием приложения, регистрировать сетевые запросы и автоматически выявлять все ошибки.
Модернизируйте способы отладки веб-приложений и мобильных приложений — начните мониторинг бесплатно.