Tracking TON Transactions: From Message to Trace
Alright — let’s break down how the hell you’re supposed to track a transaction in TON. If you’ve ever used Ethereum or Bitcoin, you might think: “Yeah, I just grab the hash of the transaction and I’m good.”
Well, surprise: TON doesn’t work that way. Not even close.
If you don’t clearly get what an account, transaction, message, trace, cell, or cell-hash is — or what the wallet actually signs — buckle up and read the glossary.
Glossary (quick refresher)▼
Account – Basic unit of information storage in the blockchain; a record in the workchain state containing metadata, balance, and possibly code and data.
Messages – Data packets exchanged between accounts (internal) or between accounts and entities outside the blockchain (external-in or external-out).
Transactions – Atomic changes to an account’s state. Typically (excluding system transactions), they're reactions to incoming messages. The exact validator processing time can't be predetermined, thus neither the timestamp nor hash can be known in advance.
(Transactions can shoot out internal messages, which go to other accounts, triggering more transactions, and so on – it’s like digital dominoes.)
Trace – It’s a tree. Nodes = transactions. Edges = messages. The root? Usually a response to an external-in.
Want examples? Go check tonviewer.com (opens in a new tab) – some are simple (opens in a new tab), others branch out beautifully (opens in a new tab), and some are just degenerate one-offs (opens in a new tab).
Cell – The atomic unit in TON. Up to 1023 bits and up to 4 refs to other cells. Everything lives in cells – accounts, messages, transactions, even blocks. And each cell has its own hash. That hash is how you track stuff.
Hash – Anytime you see "hash" here, it means the hash of the cell. Don't try to hash the raw bytes like a savage. It won’t work.
You send your buddy 5 TON using Tonkeeper.
Behind the scenes, your wallet puts together an instruction for your account contract, which then runs something like this:
- Make sure the instruction isn't too old (check the TTL).
- Check that the sequence number (
seqno
) is one higher than the last one. - Verify the signature against the public key stored in the account.
- Take the draft of the outgoing internal message (the 5 TON transfer) from the external message body, fill in the actual sender info, and send it out.
This whole thing – the instruction and signature – goes into the body of the external-in message. But here’s the weird part: the message headers (like the recipient address) aren’t signed at all. They just specify which account should receive the message, and off it goes to the blockchain.
In decoded form, it looks something like this:
Once it lands, two transactions and two messages are recorded on-chain. The external-in message from the wallet triggers the sender’s transaction, which emits an internal message. That internal message reaches the recipient and triggers their transaction.
What Has a Unique ID and What Doesn’t?
- Account – It’s globally unique. Workchain + 256-bit address. Easy.
- Transaction – Unique per account and
lt
. SoAccountID + lt
= unique. And that’s in the body, so the hash is globally unique too. - Messages (internal + external-out) – Even in the same transaction, they'll have different
lt
. That plus source account = unique hash.
(Tiny exception: some weird old system messages might break this. Don’t worry about it unless you’re working at the protocol level.) - External-in – This one's a bit tricky. A single external-in message can end up creating multiple transactions – for example, if the wallet didn't set the +2 flag and the account doesn't have enough funds. In such cases, the message will keep being retried until the TTL expires or the account runs out of money. So by default, external-in messages are not globally unique.
BUT – if you’re using a standard wallet contract (like v3r1–v5r1) and sets the correct flags (which most popular wallets do), then the message body + sender address combination becomes unique. That means the message hash is globally unique and can be used as an ID right after signing – even before the blockchain sees it. - Trace – Usually tracked by the root transaction hash. Explorers like tonviewer.com (opens in a new tab) will let you find a trace from any globally unique message or transaction hash.
The TL;DR
Only one identifier exists before the validator touches anything: the hash of the external-in message.
But there are a few things you need to understand and account for:
- The wallet contract that sends the transaction must behave correctly.
- This only works for wallets that support
seqno
andttl
– older or non-standard contracts won't do. - Only the message body is signed – the rest (headers like destination) is not. So relayers or other middleware might change those parts before the message gets into the blockchain, changing the final hash. That’s why normalization is needed.
- You can’t query a message by its hash using a light server – they don’t index that. You’ll need a full blockchain index (like tonapi.io).
How External-in Normalization Works
Because relayers can modify parts of a message before it hits the chain, we normalize the message to get a stable, predictable hash. Here's how it works:
- Extract the original message from the cell.
- Keep only two things: the destination address and the message body.
- Repack the message with the body stored as a reference – this guarantees a consistent structure.
- Store it in a new, empty cell and compute the hash.
function normalizeHash(message: Message): Buffer {
if (message.info.type !== 'external-in') {
return message.body.hash();
}
const cell = beginCell()
.storeUint(2, 2) // external-in
.storeUint(0, 2) // addr_none
.storeAddress(message.info.dest)
.storeUint(0, 4) // import_fee = 0
.storeBit(false) // no StateInit
.storeBit(true) // store body as reference
.storeRef(message.body)
.endCell();
return cell.hash();
}
// Original code: https://github.com/tonkeeper/tonapi-js/blob/4786b2e6bd42c8a3b116e6d234dde7c16cb8426b/examples/track-transaction.ts#L15
For a more detailed, step-by-step example of tracking a transaction using normalized hashes, check out the Cookbook.
Library Usage Examples
package main
import (
"fmt"
"github.com/tonkeeper/tongo/boc"
"github.com/tonkeeper/tongo/tlb"
"os"
)
func main() {
var bocString = "te6cckECBgEAAPYAAeWIAW7m9GMNMJnjJLq86chyLJWpEZh3KHlgHyzaMJzYP4z8A5tLO3P////rPqB8UAAA..."
cell, err := boc.DeserializeSinglRootBase64(bocString)
if err != nil {
os.Exit(1)
}
var msg tlb.Message
err = tlb.Unmarshal(cell, &msg)
if err != nil {
os.Exit(1)
}
hash := msg.Hash(true)
fmt.Println( hash.Hex())
}
Tracking a Transaction by Normalized Hash
A normalized hash can be resolved only by full blockchain indexers (such as tonapi, toncenter, or dton). Lite servers and archive nodes do not understand it.
The examples below use TONAPI
Key REST Endpoints
These are the main endpoints that accept a normalized message hash and allow you to determine whether the operation was accepted on-chain, as well as retrieve related data such as the transaction, execution trace, or event.
Goal | Endpoint |
---|---|
Get the full trace (transaction execution tree) | getTrace (opens in a new tab) |
Get a high-level event aggregating | getEvent (opens in a new tab) |
Get Jetton-related transfers from an event | getJettonsEvents (opens in a new tab) |
Get a root transaction by its hash | getBlockchainTransaction (opens in a new tab) |
Get the transaction that consumed a specific msg | getBlockchainTransactionByMessageHash (opens in a new tab) |
For the purposes of this guide we will focus on getTrace, because it captures the status of the entire transaction chain and therefore lets us determine whether the operation succeeded.
Polling Strategy
Polling interval: 1s → 2s → 4s → 8s → …
(exponential backoff)
- If the external message includes an
expire_time
field — use it as a hard cutoff. Once the current time exceedsexpire_time
, the message will never be accepted by the network. Stop polling and mark the attempt as failed. - If the message has no
expire_time
, use a custom upper limit (e.g. 60–120 seconds) depending on your UX needs.
Trace Lifecycle States
Stage | API response | Meaning |
---|---|---|
Not found | GET /v2/traces/{trace_id} → 404 | The message has not yet reached any validator (it is not even in the mempool). |
Pending | 200 OK with a Trace object, but isFinalized(trace) == false (see function below) | The message is in the mempool or partially executed, but the full transaction chain is not complete. |
Finalized | 200 OK and isFinalized(trace) == true | The entire transaction chain has finished; the result is immutable. |
Tip: cache
{trace_id, expiresAt}
locally. If the trace is still Not found or Pending afterexpiresAt
, mark the operation as failed and prompt the user to retry.
Example of isFinalized
function
A trace is finalized when:
- it is not emulated
- it contains no outgoing internal messages
- all child traces are finalized as well
function isFinalized(trace): Boolean {
if (trace.emulated) {
return false;
}
for (const msg of trace.out_msgs) {
if (msg.msg_type === "int_msg") {
return false;
}
}
for (const child of trace.children) {
if (!isFinalized(child)) {
return false;
}
}
return true;
}
Minimal Integration Flow
- After signing — compute the normalized hash from the BOC the wallet returns.
- Start polling
traces/{hash}
with the back‑off scheme above. - When “pending” appears — present the explorer link or custom “Pending” badge.
- When finalized — parse the trace:
- All transactions
success
→ show confirmation. - Any error → show failure reason (bounce, low balance, etc.).
- All transactions
- Stop once success or definitive failure is recorded.
This keeps the user informed from the moment a message leaves the wallet until the entire domino chain of transactions is settled on‑chain.
If You’re Building a dApp
When the user sends a message through TonConnect, you receive a BOC (Bag of Cells) — an encoded copy of the sent message. Normalize it, calculate the hash, and boom – you can show an explorer link right away or track what happens next.
This BOC contains everything you need to get the exact same normalized hash, even before the message appears on-chain. See how to decode and handle BOC strings in the Cookbook.
If You’re Building Something Else
You should probably go read the full article: original here [ru] (opens in a new tab). It’s got all the gritty, boring details we skipped here.