Перейти к основному содержимому

Программирование безопасных смарт-контрактов

warning

Эта страница переведена сообществом на русский язык, но нуждается в улучшениях. Если вы хотите принять участие в переводе свяжитесь с @alexgton.

В этом разделе мы рассмотрим несколько наиболее интересных особенностей блокчейна TON, а затем пройдемся по списку лучших практик для разработчиков, программирующих смарт-контракты на FunC.

Разделение контрактов

При разработке контрактов для EVM вы обычно разбиваете проект на несколько контрактов для удобства. В некоторых случаях можно реализовать всю функциональность в одном контракте, и даже там, где разделение контрактов было необходимо (например, пары ликвидности в Automated Market Maker), это не привело к каким-либо особым трудностям. Транзакции выполняются в полном объеме: либо всё работает, либо всё возвращается.

В TON настоятельно рекомендуется избегать "неограниченных структур данных" и разделять один логический контракт на небольшие части, каждая из которых управляет небольшим объёмом данных. Основным примером является реализация TON Jettons. Это версия TON стандарта токенов ERC-20 от Ethereum. Вкратце, у нас есть:

  1. Один jetton-minter, который хранит total_supply, minter_address и пару ссылок: описание токена (метаданные) и jetton_wallet_code.
  2. И множество jetton-кошельков, по одному на каждого владельца этих жетонов. В каждом таком кошельке хранится только адрес владельца, его баланс, адрес jetton-minter и ссылка на jetton_wallet_code.

Это необходимо для того, чтобы передача жетонов происходила напрямую между кошельками и не затрагивала высоконагруженные адреса, что является основой для параллельной обработки транзакций.

То есть, приготовьтесь к тому, что ваш контракт станет "группой контрактов", и они будут активно взаимодействовать друг с другом.

Возможно частичное выполнение транзакций

В логике вашего контракта появляется новое уникальное свойство: частичное выполнение транзакций.

Например, рассмотрим поток сообщений стандартного TON Jetton:

smart1.png

Как следует из диаграммы:

  1. отправитель посылает сообщение op::transfer на свой кошелек (sender_wallet);
  2. sender_wallet уменьшает баланс токенов;
  3. sender_wallet отправляет сообщение op::internal_transfer на кошелек получателя (destination_wallet);
  4. destination_wallet увеличивает свой баланс токенов;
  5. destination_wallet отправляет op::transfer_notification своему владельцу (destination);
  6. destination_wallet возвращает избыточный газ с сообщением op::excesses на response_destination (обычно sender).

Обратите внимание, если destination_wallet не смог обработать сообщение op::internal_transfer (произошло исключение или закончился газ), то этот этап и все последующие не будут выполнены. Однако первый шаг (уменьшение баланса в sender_wallet) будет выполнен. Результатом будет частичное выполнение транзакции, что приводит к некорректному состоянию Jetton и, в данном случае, потеря средств.

В худшем случае все токены могут быть украдены таким образом. Представьте, что вы сначала начисляете бонусы пользователю, а затем отправляете сообщение op::burn на его кошелёк Jetton, но не можете гарантировать, что сообщение op::burn будет успешно обработано.

Разработчик смарт-контрактов TON должен контролировать газ

В Solidity газ не представляет особой проблемы для разработчиков контрактов. Если пользователь предоставит слишком мало газа, все будет отменено, как будто ничего не произошло (но газ не будет возвращён). Если же пользователь предоставит достаточно, то фактические расходы будут автоматически рассчитаны и вычтены из его баланса.

В TON ситуация иная:

  1. Если газа недостаточно, транзакция будет выполнена частично;
  2. Если газа слишком много, его излишки должны быть возвращены. Это входит в обязанности разработчика;
  3. Если "группа контрактов" обменивается сообщениями, то контроль и расчёты должны производиться в каждом сообщении.

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), где количество сообщений в одном рабочем процессе может превышать десять.

smart2.png

В 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 не предусмотрена цепочка переходов. Это означает, что отскочившее сообщение не может быть отправлено повторно. Например, если второе сообщение отправляется после входного сообщения, а второе инициирует третье, то входной контракт не будет знать о сбое обработки третьего сообщения. Аналогично, если обработка первого сообщения вызывает второе и третье, то сбой второго не повлияет на обработку третьего.

3. Ожидайте "человека посередине" в потоке сообщений

Каскад сообщений может обрабатываться в нескольких блоках. Предположим, что пока выполняется один поток сообщений, злоумышленник может параллельно инициировать второй. То есть, если какое-то условие было проверено в начале (например, достаточно ли у пользователя токенов), не следует предполагать, что на третьем этапе в том же контракте это условие все еще будет выполняться.

4. Используйте шаблон переноса значения

Из предыдущего параграфа следует, что сообщения между контрактами должны переносить ценности.

В том же TON Jetton это демонстрируется: sender_wallet вычитает баланс и отправляет его с сообщением op::internal_transfer на destination_wallet, а тот, в свою очередь, получает баланс вместе с сообщением и добавляет его к своему балансу (или возвращает обратно).

А вот пример неправильной реализации. Почему вы не можете узнать свой баланс Jetton on-chain? Потому что такой запрос не соответствует шаблону. К тому времени, когда ответ на сообщение op::get_balance дойдет до запрашивающего, этот баланс уже может быть потрачен кем-то другим.

В этом случае вы можете реализовать альтернативу: smart3.png

  1. master отправляет сообщение op::provide_balance на кошелёк;
  2. кошелёк обнуляет свой баланс и отправляет обратно op::take_balance;
  3. master получает средства, решает, достаточно ли их, и либо использует (списывает что-то в ответ), либо отправляет обратно на кошелёк.

5. Возвращайте значение вместо отклонения

Из предыдущего наблюдения следует, что ваша группа контрактов часто будет получать не просто запрос, а запрос вместе со значением. Поэтому нельзя просто отказать в выполнении запроса (с помощью throw_unless()), нужно отправить жетоны обратно отправителю.

Например, обычное начало потока (см. поток сообщений TON Jetton):

  1. sender отправляет сообщение op::transfer через sender_wallet на your_contract_wallet, указывая forward_ton_amount и forward_payload для вашего контракта;
  2. sender_wallet отправляет сообщение op::internal_transfer на your_contract_wallet;
  3. your_contract_wallet отправляет сообщение op::transfer_notification на your_contract, передавая forward_ton_amount, forward_payload, а также sender_address и jetton_amount;
  4. И вот в вашем контракте в handle_transfer_notification() начинается поток.

Здесь необходимо определить, какой был запрос, достаточно ли газа для его выполнения и все ли корректно в полезной нагрузке. На этом этапе не следует использовать throw_if()/throw_unless(), потому что тогда жетоны просто потеряются, а запрос не будет выполнен. Стоит использовать операторы try-catch [доступны начиная с FunC v0.4.0] (/v3/documentation/smart-contracts/func/docs/statements#try-catch-statements).

Если что-то не соответствует ожиданиям вашего контракта, жетоны должны быть возвращены.

Мы обнаружили пример такой уязвимой реализации в ходе недавнего аудита.

() handle_transfer_notification(...) impure {
...
int jetton_amount = in_msg_body~load_coins();
slice from_address = in_msg_body~load_msg_addr();
slice from_jetton_address = in_msg_body~load_msg_addr();

if (msg_value < gas_consumption) { ;; not enough gas provided
if (equal_slices(from_jetton_address, jettonA_address)) {
var msg = begin_cell()
.store_uint(0x18, 6)
.store_slice(jettonA_wallet_address)
.store_coins(0)
.store_uint(1, 1 + 4 + 4 + 64 + 32 + 1 + 1)
...
}
...
}

Как видно, здесь "возврат" отправляется на jettonA_wallet_address, а не на sender_address. Поскольку все решения принимаются на основе анализа in_msg_body, злоумышленник может подделать сообщение и вывести деньги. Всегда отправляйте возврат на sender_address.

Если ваш контракт принимает жетоны, невозможно узнать, действительно ли пришел ожидаемый жетон или просто op::transfer_notification от кого-то.

Если в ваш контракт попали неожиданные или неизвестные жетоны, их также необходимо вернуть.

6. Рассчитайте газ и проверьте значение msg_value

Согласно нашей диаграмме потока сообщений, мы можем оценить стоимость каждого обработчика в каждом из сценариев и вставить проверку на достаточность msg_value.

Вы не можете запрашивать газ с запасом, скажем, 1 TON (это gas_limit в mainnet на дату написания статьи), потому что этот газ должен быть разделен между "последствиями". Допустим, ваш контракт отправляет три сообщения, тогда каждому можно выделить только 0,33 TON. Это означает, что они должны "запрашивать" меньше. Важно тщательно рассчитать потребление газа для всего контракта.

Все усложняется, если во время разработки ваш код начинает отправлять больше сообщений. Требования к газу необходимо перепроверять и обновлять.

7. Осторожно возвращайте избыточный газ

Если избыточный газ не возвращаются отправителю, средства со временем накапливаются в ваших контрактах. В принципе, это не критично, но это неоптимальная практика. Вы можете добавить функцию для очистки излишков, но такие популярные контракты, как TON Jetton, все равно возвращаются их отправителю с сообщением op::excesses.

В TON есть полезный механизм: SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE = 64. При использовании этого режима в send_raw_message(), остаток газа будет передан дальше вместе с сообщением (или обратно) новому получателю. Это удобно, если поток сообщений линеен: каждый обработчик сообщений отправляет только одно сообщение. Но есть случаи, когда использовать этот механизм не рекомендуется:

  1. Если в вашем контракте нет других нелинейных обработчиков. storage_fee вычитается из баланса контракта, а не из входящего газа. Это означает, что со временем storage_fee может полностью "съесть" баланс, так как все входящие средства должны быть отправлены дальше;
  2. Если ваш контракт генерирует события, то есть отправляет сообщение на внешний адрес. Стоимость этого действия вычитается из баланса контракта, а не из msg_value.
() emit_log_simple (int event_id, int query_id) impure inline {
var msg = begin_cell()
.store_uint (12, 4) ;; ext_out_msg_info$11 addr$00
.store_uint (1, 2) ;; addr_extern$01
.store_uint (256, 9) ;; len:(## 9)
.store_uint(event_id, 256); ;; external_address:(bits len)
.store_uint(0, 64 + 32 + 1 + 1) ;; lt, at, init, body
.store_query_id(query_id)
.end_cell();

send_raw_message(msg, SEND_MODE_REGULAR);
}
  1. Если ваш контракт присваивает значение при отправке сообщений или использует SEND_MODE_PAY_FEES_SEPARETELY = 1. Эти действия вычитают из баланса контракта, что означает, что возврат неиспользованного - это "работа в убыток".

В указанных случаях используется ручной приблизительный расчет излишка:

int ton_balance_before_msg = my_ton_balance - msg_value;
int storage_fee = const::min_tons_for_storage - min(ton_balance_before_msg, const::min_tons_for_storage);
msg_value -= storage_fee + const::gas_consumption;

if(forward_ton_amount) {
msg_value -= (forward_ton_amount + fwd_fee);
...
}

if (msg_value > 0) { ;; there is still something to return

var msg = begin_cell()
.store_uint(0x10, 6)
.store_slice(response_address)
.store_coins(msg_value)
...
}

Помните, если баланс контракта закончится, транзакция будет выполнена частично, а этого допустить нельзя.

8. Используйте вложенное хранилище

Мы рекомендуем следующий подход к организации хранения:

() handle_something(...) impure {
(slice swap_data, cell liquidity_data, cell mining_data, cell discovery_data) = load_data();
(int total_supply, int swap_fee, int min_amount, int is_stopped) = swap_data.parse_swap_data();

swap_data = pack_swap_data(total_supply + lp_amount, swap_fee, min_amount, is_stopped);
save_data(swap_data, liquidity_data, mining_data, discovery_data);
}

Хранилище состоит из блоков связанных данных. Если параметр используется в каждой функции, например, is_paused, то он должен сразу загружаться через функцию load_data(). Если группа параметров нужна только в одном сценарии, то ее не нужно распаковывать, она не требует упаковки и не засоряет пространство имен.

Если структура хранилища требует изменений (обычно добавление нового поля), то придётся вносить гораздо меньше правок.

Более того, этот подход можно повторять. Если в контракте 30 полей хранения, то изначально можно разбить их на четыре группы, а затем выделить пару переменных и еще одну подгруппу из первой группы. Главное - не переусердствовать.

Обратите внимание, поскольку ячейка может хранить до 1023 бит данных и до 4 ссылок, вам все равно придётся разделить данные по разным ячейкам.

Иерархические данные - одна из главных особенностей TON, нужно использовать её по назначению.

Глобальные переменные можно использовать, особенно на этапе создания прототипа, когда не совсем очевидно, что будет храниться в контракте.

global int var1;
global cell var2;
global slice var3;

() load_data() impure {
var cs = get_data().begin_parse();
var1 = cs~load_coins();
var2 = cs~load_ref();
var3 = cs~load_bits(512);
}

() save_data() impure {
set_data(
begin_cell()
.store_coins(var1)
.store_ref(var2)
.store_bits(var3)
.end_cell()
);
}

Таким образом, если обнаружите, что вам нужна еще одна переменная, просто добавьте новую глобальную переменную и измените load_data() и save_data(). Никаких изменений во всем контракте не потребуется. Однако, поскольку существует ограничение на количество глобальных переменных (не более 31), этот подход можно совместить с "вложенным хранилищем", рекомендованным выше.

Глобальные переменные также часто обходятся дороже, чем хранение всего в стеке. Однако это зависит от количества перестановок стека, поэтому хорошей идеей будет создать прототип с глобальными переменными и, когда структура хранения будет полностью ясна, перейти к переменным на стеке с "вложенным" шаблоном.

9. Используйте функцию end_parse()

Используйте end_parse() везде, где это возможно, при чтении данных из хранилища и из полезной нагрузки сообщения. Поскольку TON использует битовые потоки с переменным форматом данных, важно убедиться, что вы читаете столько же, сколько и записываете. Это может сэкономить вам часы отладки.

10. Используйте больше вспомогательных функций и избегайте магических чисел

Этот совет не является уникальным для FunC, но здесь он особенно уместен. Пишите больше обёрток, вспомогательных функций и объявляйте больше констант.

В FunC изначально заложено невероятное количество магических чисел. Если разработчик не приложит никаких усилий, чтобы ограничить их использование, результат будет примерно таким:

var msg = begin_cell()
.store_uint(0xc4ff, 17) ;; 0 11000100 0xff
.store_uint(config_addr, 256)
.store_grams(1 << 30) ;; ~1 gram of value
.store_uint(0, 107)
.store_uint(0x4e565354, 32)
.store_uint(query_id, 64)
.store_ref(vset);

send_raw_message(msg.end_cell(), 1);

Это код из реального проекта, и он пугает новичков.

К счастью, в последних версиях FunC пара стандартных деклараций может сделать код более понятным и выразительным. Например:

const int SEND_MODE_REGULAR = 0;
const int SEND_MODE_PAY_FEES_SEPARETELY = 1;
const int SEND_MODE_IGNORE_ERRORS = 2;
const int SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE = 64;

builder store_msgbody_prefix_stateinit(builder b) inline {
return b.store_uint(4 + 2 + 1, 1 + 4 + 4 + 64 + 32 + 1 + 1 + 1);
}

builder store_body_header(builder b, int op, int query_id) inline {
return b.store_uint(op, 32).store_uint(query_id, 64);
}

() mint_tokens(slice to_address, cell jetton_wallet_code, int amount, cell master_msg) impure {
cell state_init = calculate_jetton_wallet_state_init(to_address, my_address(), jetton_wallet_code);
slice to_wallet_address = calculate_address_by_state_init(state_init);

var msg = begin_cell()
.store_msg_flags(BOUNCEABLE)
.store_slice(to_wallet_address)
.store_coins(amount)
.store_msgbody_prefix_stateinit()
.store_ref(state_init)
.store_ref(master_msg);

send_raw_message(msg.end_cell(), SEND_MODE_REGULAR);
}

Ссылки

Автор оригинальной статьи: CertiK