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

Транзакция

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

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

Структура транзакции

TL-B

Перед тем, как погружаться в принципы работы транзакций, нам нужно понять их структуру с помощью TL-B (Type Language – Binary). Стоит отметить, что в TON существует несколько типов транзакций; однако в этом руководстве мы сосредоточимся исключительно на обычной транзакции. Такие транзакции актуальны для обработки платежей и разработки большинства приложений на TON.

trans_ord$0000 credit_first:Bool
storage_ph:(Maybe TrStoragePhase)
credit_ph:(Maybe TrCreditPhase)
compute_ph:TrComputePhase action:(Maybe ^TrActionPhase)
aborted:Bool bounce:(Maybe TrBouncePhase)
destroyed:Bool
= TransactionDescr;

Согласно схеме TL-B, транзакция состоит из следующих полей:

ПолеТипОписание
credit_firstBoolУказывает, должна ли фаза кредита исполняться первой. Это зависит от того, выставлен ли флаг возврата. Это будет объяснено подробнее дальше.
storage_phMaybe TrStoragePhaseФаза хранения, отвечает за обработку комиссий, связанных с постоянным хранилищем аккаунта.
credit_phMaybe TrCreditPhaseФаза кредита, отвечает за передачу средств, доставленных с входящим сообщением, если оно внутреннее (тип #1 или #2).
compute_phTrComputePhaseФаза вычислений, отвечает за исполнение кода смарт-контракта, хранящегося у аккаунта в ячейке code.
actionMaybe ^TrActionPhaseФаза действий, отвечает за обработку любых действий, инициированных в ходе фазе вычислений.
abortedBoolУказывает, была ли транзакция прервана на одном из этапов. Если true, транзакция не была выполнена, и изменения из фаз compute_ph и action не были применены.
bounceMaybe TrBouncePhaseФаза отскока, отвечающая за обработку ошибок, произошедших на этапах compute_ph или action.
destroyedBoolУказывает, был ли аккаунт уничтожен во время выполнения транзакции.
к сведению

Другие типы транзакций, например, trans_storage, trans_tick_tock и trans_split_prepare, используются для внутренних событий, невидимых для конечных пользователей. К ним относятся разделение и слияние шардов, tick-tock-транзакции и т. д.

Поскольку они не имеют отношения к разработке dApp, мы не будем рассматривать их в этом руководстве.

Фаза кредита (credit phase)

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

  credit_phase->credit = msg_balance_remaining;
if (!msg_balance_remaining.is_valid()) {
LOG(ERROR) << "cannot compute the amount to be credited in the credit phase of transaction";
return false;
}
// NB: msg_balance_remaining may be deducted from balance later during bounce phase
balance += msg_balance_remaining;
if (!balance.is_valid()) {
LOG(ERROR) << "cannot credit currency collection to account";
return false;
}

Фаза кредита сериализуется в TL-B следующим образом:

tr_phase_credit$_ due_fees_collected:(Maybe Grams)
credit:CurrencyCollection = TrCreditPhase;

Эта фаза состоит из двух полей:

ПолеТипОписание
due_fees_collectedMaybe GramsСумма взимаемых сборов за хранение. Это поле присутствует, если у аккаунта нет средств на балансе, и накопился долг за оплату хранилища.
creditCurrencyCollectionСумма, зачисленная аккаунту в результате получения сообщения.

Фаза хранения (storage phase)

В этой фазе блокчейн обрабатывает комиссии, связанные с постоянным хранилищем аккаунта. Для начала посмотрим на схему TL-B:

tr_phase_storage$_ storage_fees_collected:Grams
storage_fees_due:(Maybe Grams)
status_change:AccStatusChange
= TrStoragePhase;

Эта фаза включает следующие поля:

ПолеТипОписание
storage_fees_collectedGramsСумма комиссии за хранение данных, взимаемая с аккаунта.
storage_fees_dueMaybe GramsСумма комиссии за хранение данных, которая была начислена, однако не могла быть получена из-за недостаточного баланса. Эта сумма представляет собой накопленный долг.
status_changeAccStatusChangeИзменение статуса аккаунта после исполнения транзакции.

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

Поле AccStatusChange указывает, изменился ли статус аккаунта на этом этапе. Например:

  • Если долг превышает 0,1 TON, аккаунт переходит в статус frozen.
  • Если долг превышает 1 TON, аккаунт удаляется.

Фаза вычислений (compute phase)

Эта фаза — одна из самых сложных в транзакции. Здесь выполняется код смарт-контракта, сохранённый в состоянии аккаунта.

В отличие от предыдущих фаз, определение TL-B для этой включает различные варианты.

tr_phase_compute_skipped$0 reason:ComputeSkipReason
= TrComputePhase;
tr_phase_compute_vm$1 success:Bool msg_state_used:Bool
account_activated:Bool gas_fees:Grams
^[ gas_used:(VarUInteger 7)
gas_limit:(VarUInteger 7) gas_credit:(Maybe (VarUInteger 3))
mode:int8 exit_code:int32 exit_arg:(Maybe int32)
vm_steps:uint32
vm_init_state_hash:bits256 vm_final_state_hash:bits256 ]
= TrComputePhase;
cskip_no_state$00 = ComputeSkipReason;
cskip_bad_state$01 = ComputeSkipReason;
cskip_no_gas$10 = ComputeSkipReason;
cskip_suspended$110 = ComputeSkipReason;

Для начала обратите внимание, что фаза вычислений может быть полностью пропущена. В этом случае причина для пропуска указывается явным образом, и может быть одной из следующих:

Причина пропускаОписание
cskip_no_stateУ смарт-контракта нет состояния, а следовательно, и кода, поэтому его исполнение невозможно.
cskip_bad_stateВозникает в двух случаях: когда поле fixed_prefix_length имеет недопустимое значение или когда StateInit, предоставленный во входящем сообщении, не соответствует адресу аккаунта.
cskip_no_gasВходящее сообщение не предоставило достаточно TON для покрытия газа, необходимого для исполнения смарт-контракта.
cskip_suspendedАккаунт заморожен, так что исполнение его кода недоступно. Этот вариант был использован для заморозки аккаунтов ранних майнеров в ходе стабилизации токеномики TON.
подсказка

Поле fixed_prefix_length можно использовать для указания фиксированного префикса для адреса аккаунта, чтобы быть уверенным, что аккаунт находится в определённом шарде. Эта тема выходит за рамки данного руководства, но дополнительную информацию можно найти [здесь](https://github.com/ton-blockchain/ton/blob/master/doc/GlobalVersions.md#anycast- addresses-and-address-rewrite).

Теперь, когда мы разобрали причины, по которым фаза вычислений может быть пропущена, давайте разберёмся с ситуацией, когда код смарт-контракта всё же выполняется. Для описания результата используются следующие поля:

ПолеТипОписание
successBoolПоказывает, завершилась ли фаза вычислений успешно. Если значение false, то любые изменения состояния, сделанные в ходе этой фазы, признаются недействительными.
msg_state_used, account_activated, mode, vm_init_state_hash, vm_final_state_hash-Эти поля сейчас не используются в блокчейне. Их значения всегда записываются как нули.
gas_feesGramsСумма комиссий, уплаченных за исполнение кода смарт-контракта.
gas_used, gas_limitVarUIntegerФактическое количество использованного газа и лимит, установленный на его расход в ходе выполнения контракта.
gas_creditMaybe (VarUInteger 3)Используется только во внешних сообщениях. Поскольку они не могут содержать TON, выдаётся маленький кредит газа, чтобы смарт-контракт мог начать исполнение и определить, хочет ли он продолжать использовать свой баланс.
exit_codeint32Код возврата виртуальной машины. Значение 0 или 1 (альтернативный успех) означает успешное выполнение. Любое другое означает, что код контракта завершил выполнение с ошибкой — за исключением случаев, где использовалась инструкция commit. Примечание: для удобства разработчики часто называют это кодом возврата смарт-контракта, хотя это технически неточно.
exit_argMaybe int32Виртуальная машина может выбрасывать необязательный аргумент в случае сбоя. Полезно для отладки ошибок смарт-контрактов.
vm_stepsuint32Количество шагов, выполненных виртуальной машиной во время выполнения кода.
подсказка

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

Фаза действий (action phase)

Когда исполнение кода смарт-контракта завершается, начинается фаза действий. Если в ходе фазы вычислений были созданы любые действия, то они обрабатываются на этой стадии.

В TON есть ровно 4 типа возможных действий:

action_send_msg#0ec3c86d mode:(## 8)
out_msg:^(MessageRelaxed Any) = OutAction;
action_set_code#ad4de08e new_code:^Cell = OutAction;
action_reserve_currency#36e6b809 mode:(## 8)
currency:CurrencyCollection = OutAction;
libref_hash$0 lib_hash:bits256 = LibRef;
libref_ref$1 library:^Cell = LibRef;
action_change_library#26fa1dd4 mode:(## 7)
libref:LibRef = OutAction;
ТипОписание
action_send_msgОтправляет сообщение.
action_set_codeОбновляет код смарт-контракта.
action_reserve_currencyРезервирует часть баланса аккаунта. Это особенно полезно для управления газом.
action_change_libraryМеняет библиотеку, используемую смарт-контрактом.

Эти действия выполняются в порядке их создания во время исполнения кода. Всего может быть создано до 255 действий.

Теперь давайте изучим схему TL-B, определяющую фазу действий.

tr_phase_action$_ success:Bool valid:Bool no_funds:Bool
status_change:AccStatusChange
total_fwd_fees:(Maybe Grams) total_action_fees:(Maybe Grams)
result_code:int32 result_arg:(Maybe int32) tot_actions:uint16
spec_actions:uint16 skipped_actions:uint16 msgs_created:uint16
action_list_hash:bits256 tot_msg_size:StorageUsed
= TrActionPhase;

Она включает следующие поля:

ПолеТипОписание
successBoolУказывает, была ли успешно завершена фаза действий. При значении false все изменения, сделанные на этом этапе, отбрасываются. Изменения, сделанные на этапе вычислений, также отменяются.
validBoolУказывает, была ли фаза действий валидной. Значение false означает, что во время выполнения смарт-контракта были созданы невалидные действия. Каждый тип действия имеет свои критерии валидности.
no_fundsBoolУказывает, было ли на счету достаточно средств для выполнения действий. Если значение false, фаза действий была прервана из-за нехватки средств.
status_changeAccStatusChangeИзменение статуса аккаунта после фазы действий. Поскольку удаление аккаунта происходит через действия (через режим 32), это поле может указывать, был ли аккаунт удалён.
total_fwd_feesMaybe GramsОбщая сумма комиссий за пересылку, уплаченных за сообщения, созданные на этапе действий.
total_action_feesMaybe GramsОбщая сумма комиссий, уплаченных за выполнение действий.
result_codeint32Код результата выполнения действий. Значение 0 означает, что все действия были успешно завершены.
result_argMaybe int32Сообщение об ошибке, возвращаемое в случае ошибки. Полезно для отладки кода смарт-контракта.
tot_actionsuint16Общее количество действий, созданных во время выполнения смарт-контракта.
spec_actionsuint16Количество специальных действий (все, кроме action_send_msg).
skipped_actionsuint16Количество действий, пропущенных во время выполнения смарт-контракта. Относится к случаям, когда отправка сообщений завершилась неудачей, но был установлен флаг ignore_errors (значение 2).
msgs_createduint16Количество сообщений, созданных во время выполнения действия.
action_list_hashbits256Хеш списка действий.
tot_msg_sizeStorageUsedСуммарный размер всех сообщений.

Фаза возврата (bounce phase)

Если фаза вычислений или фаза действий завершаются ошибкой, и у входящего сообщения установлен флаг bounce, система вызывает фазу возврата (bounce phase, также можно перевести как «фаза отскока»).

примечание

Чтобы фаза возврата сработала из-за ошибки в фазе действий, у неудачного действия должен быть установлен флаг 16, который позволяет возврат при ошибке.

tr_phase_bounce_negfunds$00 = TrBouncePhase;
tr_phase_bounce_nofunds$01 msg_size:StorageUsed
req_fwd_fees:Grams = TrBouncePhase;
tr_phase_bounce_ok$1 msg_size:StorageUsed
msg_fees:Grams fwd_fees:Grams = TrBouncePhase;

Тип tr_phase_bounce_negfunds не используется в текущей версии блокчейна. Два других типа функционируют следующим образом:

ТипОписание
tr_phase_bounce_nofundsУказывает, что у аккаунта недостаточно средств для обработки сообщения, которое должно быть возвращено отправителю.
tr_phase_bounce_okУказывает, что система успешно обрабатывает возврат и отправляет сообщение обратно отправителю.

На этом этапе msg_fees и fwd_fees рассчитываются на основе общей комиссии за пересылку fwd_fees для сообщения:

  • Одна треть комиссии идет в msg_fees и взимается немедленно.
  • Оставшиеся две трети идут в fwd_fees.

Полное тело транзакции

Теперь, когда мы рассмотрели заголовок транзакции и её описание, мы можем посмотреть, как выглядит полная транзакция в TON. Сначала изучим схему TL-B:

transaction$0111 account_addr:bits256 lt:uint64
prev_trans_hash:bits256 prev_trans_lt:uint64 now:uint32
outmsg_cnt:uint15
orig_status:AccountStatus end_status:AccountStatus
^[ in_msg:(Maybe ^(Message Any)) out_msgs:(HashmapE 15 ^(Message Any)) ]
total_fees:CurrencyCollection state_update:^(HASH_UPDATE Account)
description:^TransactionDescr = Transaction;

Она показывает, что транзакция включает следующие поля:

ПолеТипОписание
account_addrbits256Адрес аккаунта, к которому относится транзакция.
ltuint64Параметр logical time транзакции.
prev_trans_hashbits256Хеш предыдущей транзакции, выполненной на этом аккаунте.
prev_trans_ltuint64Параметр logical time предыдущей транзакции этого аккаунта.
nowuint32Временная метка Unix с временем создания транзакции.
outmsg_cntuint15Количество исходящих сообщений, сгенерированных во время выполнения транзакции.
orig_statusAccountStatusСтатус аккаунта до транзакции.
end_statusAccountStatusСтатус аккаунта после транзакции.
in_msgMaybe ^(Message Any)Входящее сообщение, обработанное во время транзакции. У обычных транзакций это поле всегда присутствует.
out_msgsHashmapE 15 ^(Message Any)Исходящие сообщения, сгенерированные во время транзакции.
total_feesCurrencyCollectionОбщая сумма комиссий, уплаченных за выполнение транзакции.
state_update^(HASH_UPDATE Account)Содержит хеши предыдущего и нового состояний аккаунта.
description^TransactionDescrОписание транзакции, содержащее детали фазы выполнения. Мы рассмотрели это ранее.

Поля orig_status и end_status указывают, как изменяется состояние аккаунта в результате транзакции. Существует 4 возможных статуса:

acc_state_uninit$00 = AccountStatus;
acc_state_frozen$01 = AccountStatus;
acc_state_active$10 = AccountStatus;
acc_state_nonexist$11 = AccountStatus;

Как получить доступ к данным о транзакциях

Как получить транзакцию с помощью api/v2

Среди поддерживаемых API с открытым исходным кодом мы можем использовать TON Center APIv2 и APIv3. APIv2 — это более «сырая» версия, предоставляющая только базовый доступ к данным блокчейна. Для получения транзакции есть два варианта:

  • Использовать эндпоинт /api/v2/getTransactions:
api-v2-get-transaction.ts
import axios from 'axios';

async function main() {
const client = axios.create({
baseURL: 'https://toncenter.com/api/v2',
timeout: 5000,
headers: {
'X-Api-Key': 'put your api key', // you can get an api key from @tonapibot bot in Telegram
},
});

const address = 'UQDKbjIcfM6ezt8KjKJJLshZJJSqX7XOA4ff-W72r5gqPuwA';
const response = await client.get('/getTransactions', {
params: {
address: address,
limit: 1,
to_lt: 0,
archival: false,
},
headers: {
'X-Api-Key': 'put your api key', // you can get an api key from @tonapibot bot in Telegram
},
});
console.log(response.data);
}

main().finally(() => console.log('Exiting...'));
  • Использовать протокол JSON-RPC:
json-rpc-protocol.ts
import { Address, TonClient } from '@ton/ton';

async function main() {
const client = new TonClient({
endpoint: 'https://toncenter.com/api/v2/jsonRPC',
apiKey: 'put your api key', // you can get an api key from @tonapibot bot in Telegram
});

const address = Address.parse('UQDKbjIcfM6ezt8KjKJJLshZJJSqX7XOA4ff-W72r5gqPuwA');
const response = await client.getTransactions(address, {
limit: 1,
});
console.log(response[0]);
}

main().finally(() => console.log('Exiting...'));

Рекомендуемый подход — использовать JSON-RPC, поскольку он интегрируется с существующими SDK, где все поля предопределены и правильно типизированы. Это избавляет от необходимости интерпретировать каждое поле вручную.

примечание

When retrieving transactions, you might encounter the following error: LITE_SERVER_UNKNOWN: cannot compute block with specified transaction: cannot find block (0,ca6e321c7cce9ece) lt=57674065000003: lt not in db.

Это означает, что транзакции аккаунта старые, и блоки, содержащие их, больше не хранятся на LiteServer. В этом случае вы можете использовать опцию archival: true для получения данных с архивного узла.

Как получить транзакцию с помощью api/v3

Вариант APIv3 более продвинут и удобен для получения различных событий из блокчейна. Например, он позволяет получать информацию о переводах NFT, операциях с токенами и даже транзакциях в статусе pending. В этом руководстве мы сосредоточимся только на эндпоинте transactions, который возвращает завершенные транзакции:

APIv3 более продвинут и удобен для доступа к различным типам событий блокчейна. Например, он позволяет получать данные о переводах NFT, перемещениях токенов и даже транзакциях, которые все ещё находятся в состоянии ожидания обработки (pending).

В этом руководстве мы сосредоточимся только на эндпоинте transactions, который возвращает подтвержденные транзакции.

api-v3-get-transaction.ts
import axios from 'axios';

async function main() {
const client = axios.create({
baseURL: 'https://toncenter.com/api/v3',
timeout: 5000,
headers: {
'X-Api-Key': 'put your api key', // you can get an api key from @tonapibot bot in Telegram
},
});

const address = 'UQDKbjIcfM6ezt8KjKJJLshZJJSqX7XOA4ff-W72r5gqPuwA';
const response = await client.get('/transactions', {
params: {
account: address,
limit: 1,
to_lt: 0,
archival: false,
},
});
console.log(response.data.transactions[0]);
}

main().finally(() => console.log('Exiting...'));

Если вы изучите ответ, вы увидите, что он значительно отличается от вывода APIv2. Ключевое отличие заключается в том, что APIv3 индексирует транзакции, в то время как предыдущая версия действует только как обёртка вокруг LiteServer. В API v3 вся информация поступает непосредственно из базы данных сервера.

Это позволяет API возвращать предварительно обработанные данные. Например, при изучении полей account_state_before и account_state_after вы обнаружите, что они включают не только хеш состояния аккаунта, но и полные данные, такие как код, данные, баланс TON и даже баланс ExtraCurrency.

[
account_state_before: {
hash: 'Rljfqi3l3198Fok7x1lyf9OlT5jcVRae7muNhaOyqNQ=',
balance: '235884286762',
extra_currencies: {},
account_status: 'active',
frozen_hash: null,
data_hash: 'uUe+xBA4prK3EyIJ8iBk8unWktT4Grj+abz4LF2opX0=',
code_hash: '/rX/aCDi/w2Ug+fg1iyBfYRniftK5YDIeIZtlZ2r1cA='
},
account_state_after: {
hash: 'asmytWJakUpuVVYtuSMgwjmlZefj5tV5AgnWgGYP+Qo=',
balance: '225825734714',
extra_currencies: {},
account_status: 'active',
frozen_hash: null,
data_hash: '6L0wUi1S55GRvdizozJj2GkCqjKSx8iK7dEHlTOe8d0=',
code_hash: '/rX/aCDi/w2Ug+fg1iyBfYRniftK5YDIeIZtlZ2r1cA='
}
]

Кроме того, ответ включает поле address_book из массива адресов, с которыми аккаунт взаимодействовал во время выполнения транзакции.

Поля транзакций в SDK

При проверке ответа, возвращаемого по протоколу JSON-RPC в @ton/ton@, вы можете заметить два дополнительных поля — hash и raw — которые не являются частью данных транзакции в блокчейне. SDK добавляет эти поля для удобства.

  • Поле hash предоставляет функцию, позволяющую вычислить хеш транзакции.
  • Поле raw содержит BoC транзакции, который вы можете разобрать самостоятельно, используя либо встроенный метод из SDK, либо вручную.
trx-fields-sdk.ts
import { Address, loadTransaction, TonClient } from '@ton/ton';

async function main() {
const client = new TonClient({
endpoint: "https://toncenter.com/api/v2/jsonRPC",
apiKey: "put your api key", // you can get an api key from @tonapibot bot in Telegram
});

const address = Address.parse('UQDKbjIcfM6ezt8KjKJJLshZJJSqX7XOA4ff-W72r5gqPuwA');
const response = await client.getTransactions(address, {
limit: 1,
});

const transaction = response[0];
console.log(loadTransaction(transaction.raw.beginParse()));
console.log(`Transaction hash: ${transaction.hash().toString('hex')}`);
}

main().finally(() => console.log("Exiting..."));

Какой API использовать?

После рассмотрения API v2 и API v3 напрашивается вопрос, какой из них выбрать, и ответ полностью зависит от вашего конкретного сценария использования. В качестве общей рекомендации вы можете использовать протокол JSON-RPC из APIv2, поскольку он позволяет вам полагаться на существующий SDK, который уже предоставляет все необходимые методы и типы.

Если эта функциональность не покрывает все ваши требования, вам следует рассмотреть полный или частичный переход на API v3 или изучить другие API в экосистеме, которые могут предлагать больше данных.

Критерии успешности транзакции

Событие (действие на уровне пользователя)

Прежде чем перейти к пользовательским операциям и их обработке, давайте подытожим то, что мы узнали о транзакциях:

  • Транзакция — это запись, которая фиксирует все изменения, применённые к определённому аккаунту.
  • Транзакция состоит из нескольких фаз, которые обрабатывают входящие сообщения, выполняют код смарт-контракта и обрабатывают сгенерированные действия.
  • Каждая фаза имеет своё описание, которое содержит информацию происходящем во время выполнения.
  • Транзакция может завершиться успешно или неудачно, в зависимости от того, правильно ли выполняются все фазы и созданы ли действительные действия.
  • Обычные транзакции всегда включают входящее сообщение, но могут не включать исходящих сообщений.
  • Данные о транзакции можно извлечь с помощью API v2 или API v3, оба из которых возвращают эти данные в удобном формате.
  • В API v3 транзакции индексируются, что позволяет получать доступ к предварительно обработанным данным, тогда как API v2 действует только как обёртка для связи с LiteServer.
  • SDK может включать для удобства дополнительные поля вроде hash и raw. Они позволяют получить хеш транзакции и BoC соответственно.

Ранее мы обсуждали действия (actions) с технической точки зрения. Однако многие сервисы API используют этот термин для обозначения операций на уровне пользователя. Важно отметить, что TON — это асинхронный блокчейн, что означает, что одна операция может охватывать несколько транзакций. В этом контексте общая последовательность важнее, чем отдельные транзакции.

Например, перевод Jetton с одного кошелька на другой обычно включает как минимум три отдельные транзакции. Различные сервисы называют эти последовательности такими терминами, как действие (action), событие (event) или операция (operation). Чтобы избежать путаницы с ранее определённым техническим термином действие (action), мы используем здесь термин Событие (Event).

подсказка

Note that not every Jetton transfer qualifies as a Jetton Transfer Event. For instance, sending Jettons to a DEX to receive other tokens is classified as a Swap Event.

Классификация во многом зависит от того, как используются Jetton. Сервисы API обычно анализируют forward_payload и проверяют дополнительные параметры, чтобы определить точный тип операции, а затем представляют её в удобном для пользователя формате. Этот подход применим не только к Jetton, но и к NFT и другим операциям в блокчейне.

Обычный перевод TON: определение успешности события

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

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

  1. Кошелёк отправителя получает внешнее сообщение, содержащее подпись для проверки. После проверки этого сообщения смарт-контракт отправителя создаёт исходящие сообщения, как указано в теле внешнего сообщения.
  2. Кошелёк получателя получает входящее сообщение, содержащее указанное количество TON.

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

Предположим, мы хотим отслеживать депозиты, отправленные на наш кошелёк: UQDHkdee26bn3ezu3cpoXxPtWax_V6GtKU80Oc4fqa5brTkL Для простоты используем APIv3 для получения последних 10 транзакций для этого аккаунта.

ton-transfer.ts
import { fromNano } from '@ton/core';
import axios from 'axios';

async function main() {
const client = axios.create({
baseURL: 'https://toncenter.com/api/v3',
timeout: 5000,
headers: {
'X-Api-Key': 'put your api key' // you can get an api key from @tonapibot bot in Telegram
},
});

const address = 'UQDHkdee26bn3ezu3cpoXxPtWax_V6GtKU80Oc4fqa5brTkL';
const response = await client.get('/transactions', {
params: {
account: address,
limit: 10,
to_lt: 0,
archival: false
},
});

for (const transaction of response.data.transactions) {
const description = transaction.description;
if (description.type !== 'ord') {
continue;
}

const inMsg = transaction.in_msg;
if (inMsg.created_lt === null) {
continue; // Skip external messages
}
const bouncePhase = description.bounce;

if (bouncePhase && bouncePhase.type === 'ok') {
console.log(`Fake deposit detected: ${transaction.hash}`);
continue;
}

console.log(`Deposit detected: ${transaction.hash}. Value: ${fromNano(inMsg.value)} TON.`);
}
}

main().finally(() => console.log("Exiting..."));

После получения транзакций нам необходимо выполнить следующие проверки:

  1. Транзакция должна относиться к типу «обычных», то есть description.type должен быть равен ord.
  2. Входящее сообщение должно быть внутренним. Это означает, что поле created_lt должно присутствовать.
  3. Если описание транзакции включает поле bounce (специфичное для эндпоинта transactions в APIv3), это означает, что была запущена фаза возврата. В этом случае мы должны проверить, был ли возврат успешно завершён, что означает, что средства были возвращены отправителю.

Если все эти условия выполнены, мы можем считать депозит успешно зачисленным на кошелёк.

осторожно

Please note that the provided code is a simplified example and does not constitute a complete deposit tracking solution. In real applications, you should not limit the check to just the latest 10 transactions. Instead, you must process all transactions that occurred since the last check.

Кроме того, обратите внимание, что значения полей для поддельных депозитов могут отличаться в зависимости от используемого сервиса API. Этот пример отражает только ответ APIv3 от эндпоинта transactions TON Center.

Если мы запустим этот код, ожидаемым выводом будет:

Fake deposit detected: 4vXGhdvtfgFx8tkkaL17POhOwrUZq3sQDVSdNpW+Duk=

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

примечание

If we query this account’s transactions using APIv2, we don’t see any transactions. This happens because the transaction data exists only in the block, and only the APIv3 indexer captures it.

Поскольку у аккаунта вообще нет состояния — даже ни одного пополнения баланса — с ним формально не связано никаких транзакций. Если есть хотя бы один депозит, состояние меняется с nonexist на uninit, и APIv2 затем возвращает транзакцию.

Перевод Jetton: определение успешности события

Теперь давайте рассмотрим более сложный пример с переводом Jetton. Сначала разберём типичную структуру перевода Jetton:

external_in → User_A → internal (op::jetton_transfer)
internal (op::jetton_transfer) → User_A_Jetton_Wallet → internal (op::internal_transfer)
internal (op::internal_transfer) → User_B_Jetton_Wallet

Как показано, эта операция включает три транзакции, и фактический перевод Jetton завершается только после успешного завершения третьей транзакции. После третьей транзакции могут быть отправлены следующие дополнительные сообщения:

  • internal (op::jetton_transfer_notification) → User_B — если установлен параметр forward_amount
  • internal (op::excesses) → response_destination — если установлен параметр response_destination

Однако для проверки перевода нам нужно проверить только третью транзакцию. Чтобы упростить пример, предположим, что мы хотим отслеживать входящие Jetton для определенного кошелька: EQBkR-F5h4F2sF-b4ZIE59unSvnqefxi2nWm7JBLGhV9FCPX — для Jetton USDT.

Исходный код

jetton-transfer.ts
import { Address, TonClient } from '@ton/ton';

// Changed version of
// https://github.com/ton-org/ton-core/blob/b2e781f67b41958e4fde0440752a27c168602717/src/utils/convert.ts#L69C1-L96C2
export function fromMicro(src: bigint | number | string) {
let v = BigInt(src);
let neg = false;
if (v < 0) {
neg = true;
v = -v;
}

// Convert fraction
let frac = v % 1000000n;
let facStr = frac.toString();
while (facStr.length < 6) {
facStr = '0' + facStr;
}
facStr = facStr.match(/^([0-9]*[1-9]|0)(0*)/)![1];

// Convert whole
let whole = v / 1000000n;
let wholeStr = whole.toString();

// Value
let value = `${wholeStr}${facStr === '0' ? '' : `.${facStr}`}`;
if (neg) {
value = '-' + value;
}

return value;
}

async function main() {
const client = new TonClient({
endpoint: 'https://toncenter.com/api/v2/jsonRPC',
apiKey: 'put your api key', // you can get an api key from @tonapibot bot in Telegram
});

const address = Address.parse('EQBkR-F5h4F2sF-b4ZIE59unSvnqefxi2nWm7JBLGhV9FCPX');
const response = await client.getTransactions(address, {
limit: 10,
archival: true,
});

for (const transaction of response) {
if (transaction.description.type !== 'generic') {
continue;
}

// Check if the compute phase is present and successful
if (transaction.description.computePhase.type !== 'vm') {
continue;
}
if (transaction.description.computePhase.exitCode !== 0) {
continue;
}

// Check if the action phase is present and successful
if (transaction.description.actionPhase && transaction.description.actionPhase.resultCode !== 0) {
continue;
}

if (transaction.description.aborted === true) {
continue;
}

if (!transaction.inMessage || transaction.inMessage.info.type !== 'internal') {
continue;
}

const body = transaction.inMessage.body.beginParse();
try {
/*
internal_transfer query_id:uint64 amount:(VarUInteger 16) from:MsgAddress
response_address:MsgAddress
forward_ton_amount:(VarUInteger 16)
forward_payload:(Either Cell ^Cell)
= InternalMsgBody;
*/

const op = body.loadUint(32);
if (op !== 0x178d4519) {
// op::internal_transfer
continue;
}
const queryId = body.loadUintBig(64);
const amount = body.loadCoins();
const from = body.loadAddress();
const responseAddress = body.loadMaybeAddress();
const forwardTonAmount = body.loadCoins();
const eitherForwardPayload = body.loadBoolean();
const forwardPayload = eitherForwardPayload ? body.loadRef() : body.asCell();

console.log(`Deposit detected:
Transaction hash: ${transaction.hash().toString('hex')}
Query ID: ${queryId}
Amount: ${fromMicro(amount)} USDT
From: ${from.toString({ testOnly: true })}
Response Address: ${responseAddress ? responseAddress.toString({ testOnly: true }) : 'None'}
Forward TON Amount: ${forwardTonAmount.toString()} TON
Forward Payload: ${forwardPayload.toBoc().toString('hex')}`);
} catch (e) {
console.error(`Error processing transaction ${transaction.hash().toString('hex')}:`, e);
}
}
}

main().finally(() => console.log('Exiting...'));

Сначала мы получаем последние 10 транзакций для смарт-контракта кошелька Jetton USDT. Затем мы проверяем следующие условия:

  1. Транзакция должна быть обычной, то есть description.type равен generic.
  2. Фаза вычислений должна быть успешной:
  • Значение description.computePhase.type равно vm
  • Значение description.computePhase.exitCode равно 0
  1. Если присутствует фаза действий, она также должна быть успешной:
  • Значение description.actionPhase.resultCode равно 0
  1. Входящее сообщение должно быть внутренним:
  • Значение inMessage.info.type равно internal
  1. Тело входящего сообщения должно содержать операцию internal_transfer:
  • body.loadUint(32) возвращает 0x178d4519

Если все проверки пройдены, мы считаем депозит успешно зачисленным на кошелёк. Чтобы разобрать тело входящего сообщения, мы используем схему TL-B, определённую в TEP-0074:

internal_transfer  query_id:uint64 amount:(VarUInteger 16) from:MsgAddress
response_address:MsgAddress
forward_ton_amount:(VarUInteger 16)
forward_payload:(Either Cell ^Cell)
= InternalMsgBody;
примечание

Обратите внимание, что это очень упрощённый пример. Реальные приложения могут реализовывать другую логику для обработки депозитов. Для более подробной информации про обработку таких событий обратитесь к специальной статье.

Перевод NFT: определяем успешность события

NFT — это смарт-контракт, который хранит контент. Это может быть либо сам контент, либо ссылка на него, например, URL.

Контракт также хранит адрес владельца. Передача NFT означает обновление этого поля адреса. Чтобы инициировать передачу, владелец должен отправить специальное сообщение, отформатированное в соответствии со схемой TL-B, определённой в TEP-0062:

transfer query_id:uint64 new_owner:MsgAddress response_destination:MsgAddress custom_payload:(Maybe ^Cell)  forward_amount:(VarUInteger 16) forward_payload:(Either Cell ^Cell)  = InternalMsgBody;

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

Если указано значение response_destination, все оставшиеся средства возвращаются на этот адрес:

excesses query_id:uint64 = InternalMsgBody;

Если установлен forward_amount, указанное количество TON и forward_payload отправляются новому владельцу NFT:

ownership_assigned query_id:uint64 prev_owner:MsgAddress forward_payload:(Either Cell ^Cell) = InternalMsgBody;

Поле custom_payload не требуется в стандартных NFT. Оно существует для поддержки более сложных контрактов NFT, которые могут требовать передачи дополнительных данных без нарушения совместимости со стандартом.

Чтобы получить данные текущего владельца NFT, мы используем метод GET get_nft_data:

(int, int, slice, slice, cell) get_nft_data() method_id {
(int init?, int index, slice collection_address, slice owner_address, cell content) = load_data();
return (init?, index, collection_address, owner_address, content);
}

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

nft-transfer.ts
import { Address } from '@ton/core';
import { TonClient } from '@ton/ton';

async function main() {
const client = new TonClient({
endpoint: "https://toncenter.com/api/v2/jsonRPC",
apiKey: "put your api key", // you can get an api key from @tonapibot bot in Telegram
});

const nftAddress = Address.parse('EQB9Jp075VrO2IXDPEqdxGb_3lBOkKXpvRYV1zFvYp-UVMUY');
const result = await client.runMethod(nftAddress, 'get_nft_data', []);
result.stack.skip(3); // init?, index, collection_address
const nftOwner = result.stack.readAddress();
console.log(`NFT owner: ${nftOwner.toString()}`);
}

main().finally(() => console.log("Exiting..."));
примечание

Как и в предыдущих двух примерах, обратите внимание, что это упрощённый сценарий. В реальных приложениях система в целом более сложна. Для получения более подробной информации обратитесь к специальной статье.

Как определить успешность события по коду контракта и TL‑B

В этом разделе мы используем исходный код контракта Jetton wallet и его схему TL-B, чтобы определить, что нужно проверить, чтобы подтвердить, что операция полностью завершилась успешно.

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

  if (op == op::transfer()) { ;; outgoing transfer
send_tokens(in_msg_body, sender_address, msg_value, fwd_fee);
return ();
}

if (op == op::internal_transfer()) { ;; incoming transfer
receive_tokens(in_msg_body, sender_address, my_balance, fwd_fee, msg_value);
return ();
}

В этом коде мы видим две операции, связанные с отправкой и получением Jetton:

  1. op::transfer — операция, используемая при отправке Jetton. Она вызывает функцию send_tokens, которая обрабатывает перевод Jetton на другой кошелёк.
  2. op::internal_transfer — операция, используемая при получении Jetton. Она вызывает функцию receive_tokens, которая обрабатывает приём Jetton в кошелёк.

Начнём с изучения процесса отправки. Сначала посмотрим на структуру входящего сообщения:

transfer query_id:uint64 amount:(VarUInteger 16) destination:MsgAddress
response_destination:MsgAddress custom_payload:(Maybe ^Cell)
forward_ton_amount:(VarUInteger 16) forward_payload:(Either Cell ^Cell)
= InternalMsgBody;

Мы видим, что это сообщение очень похоже на операцию internal_transfer, которую мы рассматривали ранее. Оно указывает получателя, количество Jetton для отправки, адрес для возврата оставшихся средств и необязательную полезную нагрузку для пересылки получателю Jetton.

Теперь посмотрим на функцию send_tokens, которая обрабатывает отправку Jetton:

Функция send_tokens

() send_tokens (slice in_msg_body, slice sender_address, int msg_value, int fwd_fee) impure {
int query_id = in_msg_body~load_uint(64);
int jetton_amount = in_msg_body~load_coins();
slice to_owner_address = in_msg_body~load_msg_addr();
force_chain(to_owner_address);
(int balance, slice owner_address, slice jetton_master_address, cell jetton_wallet_code) = load_data();
balance -= jetton_amount;

throw_unless(705, equal_slices(owner_address, sender_address));
throw_unless(706, balance >= 0);

cell state_init = calculate_jetton_wallet_state_init(to_owner_address, jetton_master_address, jetton_wallet_code);
slice to_wallet_address = calculate_jetton_wallet_address(state_init);
slice response_address = in_msg_body~load_msg_addr();
cell custom_payload = in_msg_body~load_dict();
int forward_ton_amount = in_msg_body~load_coins();
throw_unless(708, slice_bits(in_msg_body) >= 1);
slice either_forward_payload = in_msg_body;
var msg = begin_cell()
.store_uint(0x18, 6)
.store_slice(to_wallet_address)
.store_coins(0)
.store_uint(4 + 2 + 1, 1 + 4 + 4 + 64 + 32 + 1 + 1 + 1)
.store_ref(state_init);
var msg_body = begin_cell()
.store_uint(op::internal_transfer(), 32)
.store_uint(query_id, 64)
.store_coins(jetton_amount)
.store_slice(owner_address)
.store_slice(response_address)
.store_coins(forward_ton_amount)
.store_slice(either_forward_payload)
.end_cell();

msg = msg.store_ref(msg_body);
int fwd_count = forward_ton_amount ? 2 : 1;
throw_unless(709, msg_value >
forward_ton_amount +
;; 3 messages: wal1->wal2, wal2->owner, wal2->response
;; but last one is optional (it is ok if it fails)
fwd_count * fwd_fee +
(2 * gas_consumption() + min_tons_for_storage()));
;; universal message send fee calculation may be activated here
;; by using this instead of fwd_fee
;; msg_fwd_fee(to_wallet, msg_body, state_init, 15)

send_raw_message(msg.end_cell(), 64); ;; revert on errors
save_data(balance, owner_address, jetton_master_address, jetton_wallet_code);
}

Хотя код может показаться сложным на первый взгляд, его логика проста:

  1. Необходимые поля считываются из in_msg_body, который представляет собой тело входящего сообщения.
  2. На основе этих полей контракт проверяет, что отправитель является владельцем токена и что баланс достаточен для перевода.
  3. Используя адрес получателя, он вычисляет адрес Jetton wallet получателя.
  4. Он гарантирует, что входящее сообщение включает достаточно TON для покрытия комиссий за газ для перевода (и для возможного возврата в случае сбоя), а также для доставки forward_payload, если это применимо.
  5. Контракт конструирует сообщение для отправки на кошелек Jetton получателя. Это сообщение включает операцию internal_transfer, которую мы уже видели.
  6. Наконец, контракт обновляет своё хранилище.

Теперь давайте рассмотрим принимающую сторону:

Функция receive_tokens


() receive_tokens (slice in_msg_body, slice sender_address, int my_ton_balance, int fwd_fee, int msg_value) impure {
;; NOTE we can not allow fails in action phase since in that case there will be
;; no bounce. Thus check and throw in computation phase.
(int balance, slice owner_address, slice jetton_master_address, cell jetton_wallet_code) = load_data();
int query_id = in_msg_body~load_uint(64);
int jetton_amount = in_msg_body~load_coins();
balance += jetton_amount;
slice from_address = in_msg_body~load_msg_addr();
slice response_address = in_msg_body~load_msg_addr();
throw_unless(707,
equal_slices(jetton_master_address, sender_address)
|
equal_slices(calculate_user_jetton_wallet_address(from_address, jetton_master_address, jetton_wallet_code), sender_address)
);
int forward_ton_amount = in_msg_body~load_coins();

int ton_balance_before_msg = my_ton_balance - msg_value;
int storage_fee = min_tons_for_storage() - min(ton_balance_before_msg, min_tons_for_storage());
msg_value -= (storage_fee + gas_consumption());
if(forward_ton_amount) {
msg_value -= (forward_ton_amount + fwd_fee);
slice either_forward_payload = in_msg_body;

var msg_body = begin_cell()
.store_uint(op::transfer_notification(), 32)
.store_uint(query_id, 64)
.store_coins(jetton_amount)
.store_slice(from_address)
.store_slice(either_forward_payload)
.end_cell();

var msg = begin_cell()
.store_uint(0x10, 6) ;; we should not bounce here cause receiver can have uninitialized contract
.store_slice(owner_address)
.store_coins(forward_ton_amount)
.store_uint(1, 1 + 4 + 4 + 64 + 32 + 1 + 1)
.store_ref(msg_body);

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

if ((response_address.preload_uint(2) != 0) & (msg_value > 0)) {
var msg = begin_cell()
.store_uint(0x10, 6) ;; nobounce - int_msg_info$0 ihr_disabled:Bool bounce:Bool bounced:Bool src:MsgAddress -> 010000
.store_slice(response_address)
.store_coins(msg_value)
.store_uint(0, 1 + 4 + 4 + 64 + 32 + 1 + 1)
.store_uint(op::excesses(), 32)
.store_uint(query_id, 64);
send_raw_message(msg.end_cell(), 2);
}

save_data(balance, owner_address, jetton_master_address, jetton_wallet_code);
}

В этой функции мы наблюдаем следующее:

  1. Контракт считывает необходимые поля из тела входящего сообщения.
  2. Он увеличивает счётчик баланса Jetton на указанную сумму.
  3. Он проверяет, что отправитель является либо действительным контрактом Jetton wallet, либо контрактом Jetton master.
  4. Он выполняет некоторые расчёты для управления газом для учёта сборов за хранение.
  5. Если сообщение указывает forward_ton_amount, контракт создает сообщение для пересылки указанной суммы TON и полезной нагрузки конечному получателю Jetton.
  6. Если указан response_address, контракт создает сообщение для отправки оставшихся средств на этот адрес.

Из этого мы знаем, что баланс получателя увеличивается только в том случае, если транзакция, инициированная сообщением internal_transfer, завершается успешно. Если возникает ошибка, контракт генерирует сообщение возврата, которое отправляется на Jetton wallet получателя.

Глядя на код контракта, мы видим, что он явно обрабатывает такие сообщения возврата:

slice cs = in_msg_full.begin_parse();
int flags = cs~load_uint(4);
if (flags & 1) {
on_bounce(in_msg_body);
return ();
}

Когда контракт получает сообщение возврата, он вызывает функцию on_bounce.

() 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_uint(32);
throw_unless(709, (op == op::internal_transfer()) | (op == op::burn_notification()));
int query_id = in_msg_body~load_uint(64);
int jetton_amount = in_msg_body~load_coins();
balance += jetton_amount;
save_data(balance, owner_address, jetton_master_address, jetton_wallet_code);
}

В этом случае контракт восстанавливает Jetton обратно на баланс. Нет необходимости проверять отправителя сообщения, поскольку сообщения возврата не могут быть подделаны. Если такое сообщение получено, это означает, что контракт ранее отправил сообщение с указанным содержимым. Поэтому он безопасно считывает сумму Jetton и добавляет ее обратно на баланс.