Транзакция
В блокчейне 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_first | Bool | Указывает, должна ли фаза кредита исполняться первой. Это зависит от того, выставлен ли флаг возврата. Это будет объяснено подробнее дальше. |
storage_ph | Maybe TrStoragePhase | Фаза хранения, отвечает за обработку комиссий, связанных с постоянным хранилищем аккаунта. |
credit_ph | Maybe TrCreditPhase | Фаза кредита, отвечает за передачу средств, доставленных с входящим сообщением, если оно внутреннее (тип #1 или #2). |
compute_ph | TrComputePhase | Фаза вычислений, отвечает за исполнение кода смарт-контракта, хранящегося у аккаунта в ячейке code . |
action | Maybe ^TrActionPhase | Фаза действий, отвечает за обработку любых действий, инициированных в ходе фазе вычислений. |
aborted | Bool | Указывает, была ли транзакция прервана на одном из этапов. Если true , транзакция не была выполнена, и изменения из фаз compute_ph и action не были применены. |
bounce | Maybe TrBouncePhase | Фаза отскока, отвечающая за обработку ошибок, произошедших на этапах compute_ph или action . |
destroyed | Bool | Указывает, был ли аккаунт уничтожен во время выполнения транзакции. |
Другие типы транзакций, например, 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_collected | Maybe Grams | Сумма взимаемых сборов за хранение. Это поле присутствует, если у аккаунта нет средств на балансе, и накопился долг за оплату хранилища. |
credit | CurrencyCollection | Сумма, зачисленная аккаунту в результате получения сообщения. |
Фаза хранения (storage phase)
В этой фазе блокчейн обрабатывает комиссии, связанные с постоянным хранилищем аккаунта. Для начала посмотрим на схему TL-B:
tr_phase_storage$_ storage_fees_collected:Grams
storage_fees_due:(Maybe Grams)
status_change:AccStatusChange
= TrStoragePhase;
Эта фаза включает следующие поля:
Поле | Тип | Описание |
---|---|---|
storage_fees_collected | Grams | Сумма комиссии за хранение данных, взимаемая с аккаунта. |
storage_fees_due | Maybe Grams | Сумма комиссии за хранение данных, которая была начислена, однако не могла быть получена из-за недостаточного баланса. Эта сумма представляет собой накопленный долг. |
status_change | AccStatusChange | Изменение статуса аккаунта после исполнения транзакции. |
Поле 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).
Теперь, когда мы разобрали причины, по которым фаза вычислений может быть пропущена, давайте разберёмся с ситуацией, когда код смарт-контракта всё же выполняется. Для описания результата используются следующие поля:
Поле | Тип | Описание |
---|---|---|
success | Bool | Показывает, завершилась ли фаза вычислений успешно. Если значение false , то любые изменения состояния, сделанные в ходе этой фазы, признаются недействительными. |
msg_state_used , account_activated , mode , vm_init_state_hash , vm_final_state_hash | - | Эти поля сейчас не используются в блокчейне. Их значения всегда записываются как нули. |
gas_fees | Grams | Сумма комиссий, уплаченных за исполнение кода смарт-контракта. |
gas_used , gas_limit | VarUInteger | Фактическое количество использованного газа и лимит, установленный на его расход в ходе выполнения контракта. |
gas_credit | Maybe (VarUInteger 3) | Используется только во внешних сообщени ях. Поскольку они не могут содержать TON, выдаётся маленький кредит газа, чтобы смарт-контракт мог начать исполнение и определить, хочет ли он продолжать использовать свой баланс. |
exit_code | int32 | Код возврата виртуальной машины. Значение 0 или 1 (альтернативный успех) означает успешное выполнение. Любое другое означает, что код контракта завершил выполнение с ошибкой — за исключением случаев, где использовалась инструкция commit . Примечание: для удобства разработчики часто называют это кодом возврата смарт-контракта, хотя это технически неточно. |
exit_arg | Maybe int32 | Виртуальная машина может выбрасывать необязательный аргумент в случае сбоя. Полезно для отладки ошибок смарт-контрактов. |
vm_steps | uint32 | Количество шагов, выполненных виртуальной машиной во время выполнения кода. |
Инструкция 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;
Она включает следующие поля:
Поле | Тип | Описание |
---|---|---|
success | Bool | Указывает, была ли успешно завершена фаза действий. При значении false все изменения, сделанные на этом этапе, отбрасываются. Изменения, сделанные на э тапе вычислений, также отменяются. |
valid | Bool | Указывает, была ли фаза действий валидной. Значение false означает, что во время выполнения смарт-контракта были созданы невалидные действия. Каждый тип действия имеет свои критерии валидности. |
no_funds | Bool | Указывает, было ли на счету достаточно средств для выполнения действий. Если значение false , фаза действий была прервана из-за нехватки средств. |
status_change | AccStatusChange | Изменение статуса аккаунта после фазы действий. Поскольку удаление аккаунта происходит через действия (через режим 32), это поле может указывать, был ли аккаунт удалён. |
total_fwd_fees | Maybe Grams | Общая сумма комиссий за пересылку, уплаченных за сообщения, созданные на этапе действий. |
total_action_fees | Maybe Grams | Общая сумма комиссий, уплаченных за выполнение действий. |
result_code | int32 | Код результата выполнения действий. Значение 0 означает, что все действия были успешно завершены. |
result_arg | Maybe int32 | Сообще ние об ошибке, возвращаемое в случае ошибки. Полезно для отладки кода смарт-контракта. |
tot_actions | uint16 | Общее количество действий, созданных во время выполнения смарт-контракта. |
spec_actions | uint16 | Количество специальных действий (все, кроме action_send_msg ). |
skipped_actions | uint16 | Количество действий, пропущенных во время выполнения смарт-контракта. Относится к случаям, когда отправка сообщений завершилась неудачей, но был установлен флаг ignore_errors (значение 2). |
msgs_created | uint16 | Количество сообщений, созданных во время выполнения действия. |
action_list_hash | bits256 | Хеш списка действий. |
tot_msg_size | StorageUsed | Суммарный размер всех сообщений. |
Фаза возврата (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_addr | bits256 | Адрес аккаунта, к которому относится транзакция. |
lt | uint64 | Параметр logical time транзакции. |
prev_trans_hash | bits256 | Хеш предыдущей транзакции, выполненной на этом аккаунте. |
prev_trans_lt | uint64 | Параметр logical time предыдущей транзакции этого аккаунта. |
now | uint32 | Временная метка Unix с временем создания транзакции. |
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 | Описание транзакции, содержащее детали фазы выполнения. Мы рассмотрели это ранее. |
Поля 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
:
- JavaScript
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
:
- JavaScript
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
, который возвращает подтвержденные транзакции.
- JavaScript
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, либо вручную.
- JavaScript
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 с одного кошелька на другой. Эта операция включает две транзакции:
- Кошелёк отправителя получает внешнее сообщение, содержащее подпись для проверки. После проверки этого сообщения смарт-контракт отправителя создаёт исходящие сообщения, как указано в теле внешнего сообщения.
- Кошелёк получателя получает входящее сообщение, содержащее указанное количество TON.
Чтобы определить, был ли перевод успешным, мы должны сосредоточиться на второй транзакции. Если на счету получателя есть транзакция, инициированная входящим сообщением из нашего кошелька, и средства впоследствии не были возвращены (этот сценарий будет рассмотрен ниже), мы можем считать перевод успешным.
Предположим, мы хотим отслеживать депозиты, отправленные на наш кошелёк:
UQDHkdee26bn3ezu3cpoXxPtWax_V6GtKU80Oc4fqa5brTkL
Для про стоты используем APIv3 для получения последних 10 транзакций для этого аккаунта.
- JavaScript
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..."));
После получения транзакций нам необходимо выполнить следующие проверки:
- Транзакция должна относиться к типу «обычных», то есть
description.type
должен быть равенord
. - Входящее сообщение должно быть внутренним. Это означает, что поле
created_lt
должно присутствовать. - Если описание транзакции включает поле
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.
- JavaScript
Исходный код
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. Затем мы проверяем следующие условия:
- Транзакция должна быть обычной, то есть
description.type
равенgeneric
. - Фаза вычислений должна быть успешной:
- Значение
description.computePhase.type
равноvm
- Значение
description.computePhase.exitCode
равно0
- Если присутствует фаза действий, она также должна быть успешной:
- Значение
description.actionPhase.resultCode
равно0
- Входящее сообщение должно быть внутренним:
- Значение
inMessage.info.type
равноinternal
- Тело входящего сообщения должно содержать операцию
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);
}
Это означает, что нам не нужно разбирать транзакции или выполнять сложную проверку. Мы вызываем метод и проверяем адрес владельца.
- JavaScript
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:
op::transfer
— операция, используемая при отправке Jetton. Она вызывает функциюsend_tokens
, которая обрабатывает перевод Jetton на другой кошелёк.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
() 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);
}
Хотя код может показаться сложным на первый взгляд, его логика проста:
- Необ ходимые поля считываются из
in_msg_body
, который представляет собой тело входящего сообщения. - На основе этих полей контракт проверяет, что отправитель является владельцем токена и что баланс достаточен для перевода.
- Используя адрес получателя, он вычисляет адрес Jetton wallet получателя.
- Он гарантирует, что входящее сообщение включает достаточно TON для покрытия комиссий за газ для перевода (и для возможного возврата в случае сбоя), а также для доставки
forward_payload
, если это применимо. - Контракт конструирует сообщение для отправки на кошелек Jetton получателя. Это сообщение включает операцию
internal_transfer
, которую мы уже видели. - Наконец, контракт обновляет своё хранилище.
Теперь давайте рассмотрим принимающую сторону:
Функция receive_tokens
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);
}
В этой функции мы наблюдаем следующее:
- Контракт считывает необходимые поля из тела входящего сообщения.
- Он увеличивает счётчик баланса Jetton на указанную сумму.
- Он проверяет, что отправитель является либо действительным контрактом Jetton wallet, либо контрактом Jetton master.
- Он выполняет некоторые расчёты для управления газом для учёта сборов за хранение.
- Если сообщение указывает
forward_ton_amount
, контракт создает сообщение для пересылки указанной суммы TON и полезной нагрузки конечному получателю Jetton. - Если указан
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 и добавляет ее обратно на баланс.