Пользователь переводит деньги на смарт-контракт и дальше полагается только на его код. Поэтому мы пишем контракты так, чтобы средства нельзя было потерять или вывести дважды, чтобы они не зависали навсегда и чтобы правила нельзя было поменять после запуска.
Ниже разбираем, как это устроено, на трёх наших контрактах: эскроу для пари, копилке, которая делит доход между участниками, и зашифрованной записке. Все они написаны на Tolk, собираются и тестируются в Acton.
Правила фиксируются при запуске
У наших контрактов нет функции обновления кода и нет админского ключа. Какие правила пользователь увидел в момент депозита, по таким контракт и отработает до последней выплаты.
Владельца мы оставляем только там, где без него никак. Копилка создаётся вообще без владельца: участники и их доли задаются при деплое, и поменять состав можно только новым контрактом. В эскроу действовать могут лишь две стороны спора и арбитр, если они его назначили. У записки владелец есть, но менять он может только содержимое, логика контракта ему недоступна.
Если контракт нужно доработать, мы выпускаем новую версию. У неё другой код, а значит и другой адрес, а уже созданные контракты продолжают работать по старым правилам. Приложение хранит реестр версий и опознаёт контракт по хешу кода, дополнительно сверяя номер, который контракт возвращает через codeVersion(). Контракт с незнакомым кодом приложение просто не признает своим.
Как не потерять деньги в асинхронной сети
В TON контракты общаются сообщениями, и каждое сообщение обрабатывается в своей транзакции. Если цепочка оборвалась на середине, общего отката не будет. На практике деньги чаще всего теряются именно так: сообщение обработали иначе, чем рассчитывал отправитель. Поэтому во всех наших контрактах действуют одни и те же правила.
- Обычный перевод без команды контракт принимает всегда. Такой перевод может прийти без возможности отскока (non-bounceable), и если обработчик упадёт, деньги просто сгорят. Поэтому
receiveна таких сообщениях исключений не бросает. В копилке такой перевод становится доходом участников, в записке идёт на оплату хранения. - Команды приложение отправляет с возможностью отскока. Если проверка не пройдена (чужой адрес, окно уже закрыто, ставка меньше нужной), сеть вернёт перевод отправителю.
- Контракт сначала записывает новое состояние и только после этого отправляет деньги. Если то же сообщение придёт повторно или с опозданием, оно увидит закрытый статус и будет отклонено.
- За хранение данных TON берёт плату, и контракт с пустым балансом сеть может заморозить. Чтобы этого не случилось, при каждой выплате контракт оставляет на балансе небольшой резерв. В эскроу это 0,01 TON.
Почему одну долю нельзя вывести дважды
Копилка не рассылает деньги участникам сама. Она их накапливает, а каждый забирает свою часть, когда ему удобно. Рассылка по списку в асинхронной сети ломалась бы на первом же недоступном получателе и дорожала бы с каждым новым участником. А так вывод стоит одинаково, будь участников 2 или 64: около 7 560 единиц газа.
Сколько можно вывести, контракт считает в момент запроса по двум числам: сколько всего пришло в копилку и сколько участник уже забрал. Отсюда три следствия.
- Сумма выведенного только растёт. Повторный запрос увидит, что доля уже получена, и ничего не выплатит.
- При делении контракт округляет вниз. Поэтому участникам в сумме никогда не причитается больше, чем пришло, и последнему из них хватит денег.
- Газ оплачивает тот, кто совершает операцию. Комиссия за обработку депозита вычитается из самого депозита, а пересылку выплаты оплачивает тот, кто выводит. Общий баланс от операций не уменьшается.
В эскроу ту же задачу решает статус. После расчёта пари переходит в конечное состояние, и рассчитать его второй раз уже нельзя.
Почему деньги не зависнут
Из любого состояния эскроу есть выход по сроку. Если одна из сторон пропала, ставки остальных не застрянут. Пари проходит этапы pending, active и resolving и заканчивается одним из трёх способов: выплатой победителю, спором или возвратом.
Если соперник так и не принял пари, создатель может отменить его сам. Когда окно принятия закрыто, вернуть ставку командой Expire может уже кто угодно.
Если голоса разошлись, одна сторона предлагает арбитра, другая соглашается, и арбитр решает спор. Назначить его можно только до дедлайна пари, иначе стороны могли бы продлевать спор задним числом. А если арбитр так и не ответил, после окна арбитража пари закрывается возвратом без удержаний. Участники не платят за чужую безответственность.
Запустить возврат по сроку может любой, и это сделано намеренно: деньги не зависят ни от нашего сервера, ни от того, будет ли работать приложение. В копилке зависать нечему: каждый выводит свою часть сам и никого не ждёт.
Как мы это проверяем
Исправить неизменяемый контракт после деплоя нельзя, поэтому каждое из этих правил автоматически проверяется в CI. Пока проверки не пройдены, изменение в основную ветку не попадёт.
| Проверка | Что ловит |
|---|---|
| Тесты контракта в Acton | Нарушенные инварианты и неверные переходы между состояниями. Для копилки есть отдельный стресс-тест на 400 выводов |
| Газовый снимок | Любое отличие расхода газа от эталона. Газовые константы зашиты в код, и рост расхода нужно заметить до сети |
| Свежесть обёртки | Код контракта разошёлся с тем, по которому приложение считает адрес. Без этой проверки деньги могли бы уйти на адрес старой версии |
Статический анализ (acton check) | Типовые ошибки в Tolk. Замечания видны прямо в merge request |
| Сверка задеплоенного кода | Ситуацию, когда проверяли одно, а в сеть выложили другое. Хеш кода по адресу сравнивается с реестром версий |
В копилке газовые константы заложены с запасом 25–50% к замерам. Цены на газ контракт берёт из конфигурации сети во время выполнения. Если сеть изменит тарифы, комиссии останутся правильными и заново разворачивать контракт не придётся.
Где записаны эти правила
Все описанные правила перечислены в шапке каждого контракта как список инвариантов и покрыты тестами. Если правка нарушает одно из них, тест падает, и изменение не проходит CI.
Такой список упрощает и внешнюю проверку. Аудитору не нужно восстанавливать замысел по коду: он берёт этот перечень и проверяет свойства по одному.