OUT_OF_ENERGY: ошибка TRON, которая тратит комиссию и ничего не отправляет
Перевод USDT ушёл, кошелёк показывает списанную комиссию, а у получателя ничего нет. Транзакция лежит в блоке — её не отклонили, и ни одна нода не падала, — и там, где у прошедшего перевода стоит SUCCESS, у этого стоит OUT_OF_ENERGY. В TRON эта ошибка означает ровно то, что написано: на середине вызова кончилась энергия, и он остановился. Дальше — механизм, три способа попасть в него на прогоне выплат и способы починки, от самого быстрого до того, который на самом деле обходится дешевле всех.
Что на самом деле говорит результат
Отправка USDT — это вызов смарт-контракта, а исполнение контракта в TRON меряется энергией, а не берётся плоской комиссией (что такое энергия). Сначала тратится собственная энергия отправителя. Когда её на вызов не хватает, сеть на этом не останавливается: остаток она докупает, сжигая TRX отправителя по протокольной ставке 100 sun за единицу, — но ровно настолько, насколько это позволяет собственный fee_limit транзакции. Это поле не комиссия, которую кто-то берёт. Это потолок: сколько TRX отправителя разрешено сжечь этому одному вызову.
Упёрлись в потолок, а вызов не закончен — исполнение встаёт. Все изменения состояния, которые он успел сделать, откатываются, поэтому токены никуда не двигаются, а сеть пишет свой вердикт на транзакции в том самом месте, где у прошедшего вызова стоит SUCCESS. Этот вердикт и есть то слово, которое обозреватель показывает рядом с неудавшейся транзакцией, и то поле, которое нода отдаёт как contractRet. До сети транзакция дошла полностью. Она дошла до сети и исполнялась ровно до тех пор, пока на неё не кончились деньги.
TRX потрачены в любом случае
Откат отменяет перевод. Сжигание он не отменяет: потреблённая энергия — это оплаченная энергия, и у отправителя не хватает ровно того, что попытка сожгла по дороге к потолку; токены никуда не ушли, а хеш транзакции скрипт сверки с удовольствием запишет как платёж.
Отсюда и то, что рефлекторный повтор — самый дорогой ход. Тот же перевод с тем же лимитом тратит те же TRX ради того же результата, а цикл выплат, который повторяет свои отказы автоматически, успевает сделать это несколько раз, пока человек дочитает до строки результата. Большинство отказов, с которыми встречается служба выплат, стоят денег тихо. Этот стоит денег громко — и его всё равно пропускают, потому что кошелёк сообщает о комиссии, а денег нигде нет.
Почему лимит, работавший вчера, перестаёт работать
Получатель сменился, а лимит нет. С этим прогон выплат сталкивается первым. Перевод на адрес, где USDT уже есть, забирает около 65,000 единиц энергии; на адрес, где их никогда не было, перевод должен ещё и завести получателю счёт токена, а это около 131,000 — вдвое больший аппетит при том же объёме перемещённых денег. Сжигание за первое обойдётся в 6.5 TRX по протокольной ставке, за второе — в 13.1 TRX. Потолок, отмеренный по обычному случаю, оказывается вдвое коротким в первый же раз, когда платят новому клиенту, и в пакете это ничем не выделяется: это свойство получателя, а не перевода.
Энергия была и кончилась. Делегация — это количество на срок, а не подписка. Что потратил первый перевод, того у второго уже нет, а с концом срока остаток уходит обратно владельцу. У кошелька, который в том же окне уже отправлял, энергии меньше, чем обещает заглавная цифра делегации, и встаёт именно тот перевод, что позже, — а со стороны это читается как один и тот же перевод, отказывающий случайным образом.
Лимит равен нулю. Ноль — правильное значение, когда энергия гарантирована, и в mode B, где энергию доставляем и транзакцию отправляем мы, fee_limit мы просим не задавать вовсе: лимит здесь был бы не чем иным, как постоянным разрешением сжечь TRX отправителя, если что-то пойдёт не так. Он же и жёсткий стоп. Нулевое разрешение означает, что вызов, которому не хватило одной-единственной единицы, встанет, а не доплатит за неё, — поэтому оставленный нулевым лимит это решение, которое держится ровно столько, сколько держится энергия.
Как увидеть это до подписи транзакции
Вопрос, который стоит за первой причиной, — сколько энергии стоит этот получатель — имеет ответ ещё до того, как что-то подписано: его дают два вызова, которые ничего не резервируют и ничего не списывают.
GET /v1/address-check отвечает по одному адресу: activated, holds_usdt, blacklisted, is_contract и expected_kind, который из них следует. Размер вызова задаёт именно holds_usdt.
curl -s -H "Authorization: Bearer $KEY" \ "https://api.nrg.market/v1/address-check?address=TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9" # expected_kind — это "single", "double" или "custom"
POST /v1/estimate отвечает по пакету — до 500 адресатов за один вызов, у каждого свой kind, стоящие за ним energy_units и, если есть, warnings. single — обычный перевод, double — тот, что открывает счёт токена. custom — позиция, которую не покрывает никакое правило большого пальца: получатель оказывается контрактом с собственной логикой transfer, поэтому ни одна из двух стандартных цифр не подходит, и energy_units берётся из dry-run настоящего вызова. Лимит под такие позиции отмеряют по одной, а стоящее рядом предупреждение contract_recipient часто и есть первый признак того, что адрес выплаты вообще не кошелёк.
Что с этим делать, от простого к сложному
Поднять лимит. Одно поле, никакой инфраструктуры, и остановка уходит: вызов доходит до конца, а сеть берёт сколько ей нужно. Стоимость вместе с ней не уходит. Лимит — это разрешение жечь, поэтому щедрый лимит означает щедрое сжигание: перевод, который раньше вставал, теперь проходит за 13.1 TRX. Годится как подстраховка под прогоном выплат, не годится как план.
Дать энергию отправителю. Тогда лимиту нечего разрешать. Энергия берётся из стейкинга TRX — а это замороженный рабочий баланс и присмотр за расходом ресурса — или из аренды делегации на те несколько минут, которых требует перевод (сколько это стоит прямо сейчас). И там и там вызов оплачен энергией, и никакого сжигания не происходит вовсе.
Отправлять внутри окна. Арендованная энергия стоит на кошельке срок, а в ответе заказа приходит send_before — момент, когда окно доставки закрывается, за три секунды до конца купленного срока. Следите за появлением этого поля, а не за словом ready: заказ закрывается доставкой энергии, поэтому completed идёт сразу за ней. Отправка после send_before кладёт перевод в сеть тогда, когда энергия, возможно, уже ушла домой, — а это ровно та нехватка, о которой вся эта статья.
Отдать подписанную транзакцию. Mode B снимает окно с вашей стороны целиком: вы отправляете уже подписанный перевод, мы его держим и сами кладём в сеть, как только энергия подтверждена on-chain. Если транзакция, отправленная нами, всё равно доходит до блока и не исполняется, позиция закрывается как failed с transaction_reverted, а резерв снимается целиком — за эту причину, как и за любую другую в списке, денег не берут никогда. Читайте название буквально: transaction_reverted — это наше слово для «дошло до блока и не исполнилось», каким бы ни было собственное слово сети для этого отказа, поэтому и OUT_OF_ENERGY, и собственный REVERT контракта приходят под одним статусом. TRX, которые такая транзакция сожгла по дороге к этому вердикту, принадлежат отправителю, а не нам, — и это довод в пользу оценки из начала этого раздела, а не цикла повторов в его конце.