Программирование безопасных смарт-контрактов
Эта страница переведена сообществом на русский язык, но нуждается в улучшениях. Если вы хотите принять участие в переводе свяжитесь с @alexgton.
В этом разделе мы рассмотрим несколько наиболее интересных особенностей блокчейна TON, а затем пройдемся по списку лучших практик для разработчиков, программирующих смарт-контракты на FunC.
Разделение контрактов
При разработке контрактов для EVM вы обычно разбиваете проект на несколько контрактов для удобства. В некоторых случаях можно реализовать всю функциональность в одном контракте, и даже там, где разделение контрактов было необходимо (например, пары ликвидности в Automated Market Maker), это не привело к каким-либо особым трудностям. Транзакции выполняются в полном объеме: либо всё работает, либо всё возвращается.
В TON настоятельно рекомендуется избегать "неограниченных структур данных" и разделять один логический контракт на небольшие част и, каждая из которых управляет небольшим объёмом данных. Основным примером является реализация TON Jettons. Это версия TON стандарта токенов ERC-20 от Ethereum. Вкратце, у нас есть:
- Один
jetton-minter
, который хранитtotal_supply
,minter_address
и пару ссылок: описание токена (метаданные) иjetton_wallet_code
. - И множество jetton-кошельков, по одному на каждого владельца этих жетонов. В каждом таком кошельке хранится только адрес владельца, его баланс, адрес jetton-minter и ссылка на jetton_wallet_code.
Это необходимо для того, чтобы передача жетонов происходила напрямую между кошельками и не затрагивала высоконагруженные адреса, что является основой для параллельной обработки транзакций.
То есть, приготовьтесь к тому, что ваш контракт станет "группой контрактов", и они будут активно взаимодействовать друг с другом.
Возможно частичное выполнение транзакций
В логике вашего контракта появляется новое уникальное свойство: частичное выполнение транзакций.
Например, рассмотрим поток сообщений стандартного TON Jetton:
Как следует из диаграммы:
- отправитель посылает сообщение
op::transfer
на свой кошелек (sender_wallet
); sender_wallet
уменьшает баланс токенов;sender_wallet
отправляет сообщениеop::internal_transfer
на кошелек получателя (destination_wallet
);destination_wallet
увеличивает свой баланс токенов;destination_wallet
отправляетop::transfer_notification
своему владельцу (destination
);destination_wallet
возвращает избыточный газ с сообщениемop::excesses
наresponse_destination
(обычноsender
).
Обратите внимание, если destination_wallet
не смог обработать сообщение op::internal_transfer
(произошло исключение или закончился газ), то этот этап и все последующие не будут выполнены. Однако первый шаг (уменьшение баланса в sender_wallet
) будет выполнен. Результатом будет частичное выполнение транзакции, что приводит к некорректному состоянию Jetton
и, в данном случае, потеря средств.
В худшем случае все токены могут быть украдены таким образом. Представьте, что вы сначала начисляете бонусы пользователю, а затем отправляете сообщение op::burn
на его кошелёк Jetton, но не можете гарантировать, что сообщение op::burn
будет успешно обработано.
Разработчик смарт-контрактов TON должен контролировать газ
В Solidity газ не представляет особой проблемы для разработчиков контрактов. Если пользователь предоставит слишком мало газа, все будет отменено, как будто ничего не произошло (но газ не будет возвращён). Если же пользователь предоставит достаточно, то фактические расходы будут автоматически рассчитаны и вычтены из его баланса.
В TON ситуация иная:
- Если газа недостаточно, транзакция будет выполнена частично;
- Если газа слишком много, его излишки должны быть возвращены. Это входит в обязанности разработчика;
- Если "группа контрактов" обменивается сообщениями, то контроль и расчёты должны производиться в каждом сообщении.
TON не может автоматически рассчитать газ. Полное выполнение транзакции со всеми последствиями может занять много времени, и к моменту завершения у пользователя может не оказаться достаточного количества тонкоинов на кошельке. Здесь снова используется принцип переноса значения (carry-value principle).
Разработчик смарт-контрактов TON должен управлять хранилищем
Типичный обработчик сообщений в TON следует этому подходу:
() handle_something(...) impure {
(int total_supply, <a lot of vars>) = load_data();
... ;; do something, change data
save_data(total_supply, <a lot of vars>);
}
К сожалению, мы замечаем тенденцию: <a lot of vars>
- это настоящее перечисление всех полей данных контракта. Например:
(
int total_supply, int swap_fee, int min_amount, int is_stopped, int user_count, int max_user_count,
slice admin_address, slice router_address, slice jettonA_address, slice jettonA_wallet_address,
int jettonA_balance, int jettonA_pending_balance, slice jettonB_address, slice jettonB_wallet_address,
int jettonB_balance, int jettonB_pending_balance, int mining_amount, int datetime_amount, int minable_time,
int half_life, int last_index, int last_mined, cell mining_rate_cell, cell user_info_dict, cell operation_gas,
cell content, cell lp_wallet_code
) = load_data();
Этот подход имеет ряд недостатков.
Во-первых, если вы решите добавить ещё одно поле, скажем, is_paused
, вам нужно обновить все выражения load_data()/save_data()
в контракте. Это не только трудоёмко, но и приводит к трудноуловимым ошибкам.
Во время недавнего аудита CertiK мы заметили, что разработчик перепутал два аргумента местами и написал:
save_data(total_supply, min_amount, swap_fee, ...)
Без внешнего аудита, проведённого командой экспертов, обнаружить такую ошибку очень сложно. Функция использовалась редко, и оба перепутанных параметра обычно имели нулевое значение. Чтобы заметить подобную ошибку, нужно знать, что искать.
Во-вторых, существу ет проблема "загрязнения пространства имён". Давайте разберёмся, в чем она заключается, на другом примере из аудита. В середине функции входной параметр читается:
int min_amount = in_msg_body~load_coins();
То есть произошло затенение поля хранения локальной переменной, и в конце функции это измененнёное значение было записано в хранилище. У злоумышленника была возможность перезаписать состояние контракта. Ситуация усугубляется тем, что FunC допускает переобъявление переменных: "Это не объявление, а просто проверка во время компиляции, что min_amount имеет тип int".
И, наконец, разбор всего хранилища и его повторная упаковка при каждом вызове любой функции увеличивает стоимость газа.
Советы
1. Всегда рисуйте диаграммы потока сообщений
Даже в таком простом контракте, как TON Jetton, уже есть довольно много сообщений, отправителей, получателей и фрагментов данных, с одержащихся в сообщениях. А теперь представьте, как это выглядит, когда Вы разрабатываете что-то более сложное, например, децентрализованную биржу (DEX), где количество сообщений в одном рабочем процессе может превышать десять.
В CertiK мы используем язык DOT для описания и обновления таких диаграмм в ходе аудита. Наши аудиторы считают, что это помогает им визуализировать и понимать сложные взаимодействия внутри и между контрактами.
2. Избегайте сбоев и обрабатывайте отскочившие сообщения
Используя поток сообщений, сначала определите точку входа. Это сообщение, которое начинает каскад сообщений в вашей группе контрактов ("последствия"). Именно здесь необходимо проверить все (полезную нагрузку, запас газа и т. д.), чтобы свести к минимуму вероятность сбоя на последующих этапах.
Если вы не уверены, что сможете выполнить все свои планы (например, достаточно ли у пользователя токенов для завершения сделки), значит, поток сообщений, скорее всего, построен неправильно.
В последующих сообщениях (последствия) все throw_if()/throw_unless()
будут выполнять роль утверждений, а не фактической проверки чего-либо.
Многие контракты также обрабатывают отскочившие сообщения на всякий случай.
Например, в TON Jetton, если кошелёк получателя не может принять токены (это зависит от логики получения), то кошелёк отправителя обработает отскочившее сообщение и вернёт токены на свой баланс.
() on_bounce (slice in_msg_body) impure {
in_msg_body~skip_bits(32); ;;0xFFFFFFFF
(int balance, slice owner_address, slice jetton_master_address, cell jetton_wallet_code) = load_data();
int op = in_msg_body~load_op();
throw_unless(error::unknown_op, (op == op::internal_transfer) | (op == op::burn_notification));
int query_id = in_msg_body~load_query_id();
int jetton_amount = in_msg_body~load_coins();
balance += jetton_amount;
save_data(balance, owner_address, jetton_master_address, jetton_wallet_code);
}
В основном, мы рекомендуем обрабатывать отскочившие сообщения, однако их нельзя использовать в качестве средства полной защиты от неудачной обработки сообщений и неполного выполнения.
Для отправки отскочившего сообщения и его обработки требуется газ, и если отправитель не предоставил достаточного количества газа, то отскока не будет.
Во-вторых, в TON не предусмотрена цепочка переходов. Это означает, что отскочившее сообщение не может быть отправлено повторно. Например, если второе сообщение отправляется после входного сообщения, а второе инициирует третье, то входной контракт не будет знать о сбое обработки третьего сообщения. Аналогично, если обработка первого сообщения вызывает второе и третье, то сбой второго не повлияет на обработку третьего.