Tất cả bài viết
11 phút đọc

Detect thanh toán USDT trên TON một cách đáng tin cậy

Detect thanh toán USDT trên TON: phân tích transfer_notification của jetton qua get_transactions, lấy decimals từ get_jetton_info, khớp số tiền và idempotency.

USDT trên TONdetect thanh toántransfer_notificationjetton TEP-74idempotencyget_transactions

Bất kỳ ai từng dựng hệ thống nhận thanh toán trên TON đều từng vấp phải cùng một cái bẫy: tiền đã về, mà backend thì "không nhìn thấy". Khách gửi 50 USDT, ví thì báo đã nhận, còn invoice vẫn treo ở trạng thái "đang chờ". Mười phút sau — một ticket giận dữ bay vào bộ phận hỗ trợ. Và một giờ sau nữa thì hóa ra bạn đã ghi có khoản đó hai lần, vì poller chạy lại ở một lần retry. Detect đáng tin cậy một khoản thanh toán USDT đến (incoming USDT payment) trên TON không phải là "cứ mỗi phút kiểm tra số dư một lần", mà là phân tích những internal message cụ thể của jetton, kèm idempotency và chống giả mạo. Hãy cùng đi từng bước xem làm cho đúng là làm thế nào — bằng chính các tool của MCP server TONNode, những tool bạn có thể gọi miễn phí ngay trên máy mình.

Vì sao detect thanh toán USDT trên TON không phải là chuyện số dư ví

Cách tiếp cận ngây thơ là định kỳ đọc số dư ví, và nếu nó tăng thì coi như đã nhận được tiền. Chẳng khác gì một quầy thu ngân chỉ biết nhìn tổng số tiền trong ngăn kéo: vừa cộng thêm 10 USDT — nhưng của ai, cho hóa đơn nào, cho đơn cũ hay đơn mới? Còn nếu hai khách cùng trả 10 một lúc, bạn chỉ thấy +20 trong một nhịp.

Trên TON, cách làm này sụp đổ vì mấy lý do cùng lúc:

  • USDT không phải đồng coin native mà là một jetton (chuẩn TEP-74). Tiền không về thẳng ví chính, mà về jetton wallet của nó — một contract riêng, gắn với đúng master contract của jetton đó. Số dư GRAM của ví chính lúc đó hoàn toàn không thay đổi.
  • Chênh lệch số dư không cho biết ai trả và trả cho cái gì. Số dư không lưu mối liên kết với invoice.
  • Race condition và gộp chung. Giữa hai lần poll có vài khoản thanh toán cùng về — bạn chỉ thấy tổng chênh lệch, chứ không thấy từng sự kiện riêng.
  • Ghi có hai lần. Poller retry hay worker restart — thế là một khoản thanh toán bị tính hai lượt.
  • Decimals. USDT có 6, chứ không phải 9 như đa số jetton quen thuộc. Chỉ cần một số chia sai là khoản "ghi có" cho khách lệch đi 1000 lần.

Cách đúng là làm việc không phải với số dư, mà với luồng giao dịch của jetton wallet bên nhận, rồi phân tích riêng từng sự kiện chuyển tiền. Toàn bộ phần đọc dữ liệu đều dùng được miễn phí ngay trên máy:

{ "mcpServers": { "ton": { "command": "npx", "args": ["-y", "@tonnode/mcp"] } } }

Bên trong một khoản USDT đến trông ra sao: internal_transfer của jetton (op 0x178d4519)

Khi ai đó chuyển USDT cho bạn, một chuỗi việc sẽ diễn ra:

  1. Jetton wallet của người gửi trừ đi số tiền rồi gửi tới jetton wallet của bạn một internal message internal_transfer (op 0x178d4519).
  2. Jetton wallet của bạn ghi có số tiền — chỉ khi người gửi có kèm forward_ton_amount > 0 — còn gửi thêm tới ví chính của bạn một internal message transfer_notification (op 0x7362d09c).

Ngã rẽ quan trọng nhất nằm ở đây. transfer_notification là một thông báo tiện lợi kiểu "bạn vừa nhận được jetton", nhưng nó đi tới ví chính của chủ sở hữu và chỉ khi forward_ton_amount > 0. Nếu người gửi để số 0 — jetton wallet vẫn ghi có tiền, nhưng chủ sở hữu sẽ không nhận được thông báo nào.

Còn trong lịch sử của chính jetton wallet thì khoản ghi có luôn luôn có mặt — dưới dạng một internal_transfer đến, bất kể forward_ton_amount bằng bao nhiêu. Vì thế, việc detect đáng tin cậy phải dựa trên việc poll lịch sử jetton wallet bên nhận và phân tích đúng internal_transfer đó. Cấu trúc của nó theo TEP-74:

internal_transfer#178d4519
  query_id:           uint64
  amount:             (VarUInteger 16)     // đơn vị raw của jetton
  from:               MsgAddress           // địa chỉ chủ sở hữu NGƯỜI TRẢ (con người)
  response_address:   MsgAddress
  forward_ton_amount: (VarUInteger 16)
  forward_payload:    (Either Cell ^Cell)  // comment/memo

Hai điểm quan trọng:

  • Trường from là địa chỉ của người trả tiền thật sự (chủ sở hữu là con người), chứ không phải địa chỉ jetton wallet của người đó. Chính địa chỉ này mới là thứ cần ghi vào log như người gửi. Lưu ý: địa chỉ ở tầng message (tx.in_msg.source) ở đây là jetton wallet của người trả, còn con người trả tiền thì nằm trong trường from của phần body message.
  • Số tiền ở trường amount nằm ở đơn vị raw; phần quy đổi ta sẽ quay lại bên dưới.

Kết luận chính về mặt kiến trúc

Detect đáng tin cậy không dựa trên việc ngồi chờ notification ở ví chính, mà dựa trên việc poll lịch sử của chính jetton wallet bên nhận. Trong lịch sử đó, khoản ghi có luôn nhìn thấy được, bất kể forward_ton_amount. Nếu bạn nghe thêm cả ví chính thì ở đó bạn bắt transfer_notification (op 0x7362d09c, người gửi nằm ở trường sender) — nhưng đó chỉ là phần có thêm khi forward_ton_amount > 0, chứ không phải nguồn sự thật duy nhất.

Đọc lịch sử qua get_transactions và phân tích internal_transfer

Trước hết bạn cần địa chỉ jetton wallet của bên nhận. Đừng tự suy ra nó offline và cũng đừng tin vào ticker — hãy hỏi thẳng master contract của USDT: lời gọi get_wallet_address (qua run_get_method) trên master USDT sẽ trả về địa chỉ USDT jetton wallet của bạn một cách tất định, gắn chặt với master thật. Sau đó ta poll giao dịch của jetton wallet này qua get_transactions.

Prompt gửi cho agent trong vòng lặp polling:

Gọi run_get_method trên master USDT
EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs với method
get_wallet_address và tham số là địa chỉ chủ sở hữu UQ...myservice.
Trả về địa chỉ USDT jetton wallet của chúng ta. Sau đó dùng
get_transactions đọc các giao dịch gần nhất của jetton wallet đó.
Với mỗi internal_transfer đến (op 0x178d4519), trả về query_id,
amount (raw), trường from (địa chỉ người trả), comment dạng text
lấy từ forward_payload, cùng với lt và hash của giao dịch.

Pseudocode xử lý một giao dịch:

for (const tx of txs) {
  const body = tx.in_msg?.decoded;          // body đã giải mã của message đến
  if (body?.op !== 0x178d4519) continue;    // không phải internal_transfer — bỏ qua

  const rawAmount = BigInt(body.amount);    // đơn vị raw, KHÔNG dành cho người đọc
  const payer     = body.from;              // địa chỉ chủ sở hữu của người trả
  const memo      = parseComment(body.forward_payload);
  const key       = `${tx.lt}:${tx.hash}`;  // định danh để dedup
  // …khớp và ghi có ở bên dưới
}

Cũng nên kiểm tra một lượt trạng thái của tài khoản chính qua get_account_state (đã active chưa, giao dịch cuối là khi nào) — nó giúp bạn biết poller còn "sống" không và có bị tụt lại phía sau hay không.

decimals quyết định tất cả: get_jetton_info và quy đổi số raw (USDT = 6, không phải 9)

Trường amount trong lệnh chuyển nằm ở đơn vị raw của jetton — một số nguyên, không có dấu thập phân. Muốn biến nó thành con số cho người đọc, bạn cần decimals. Và đây chính là chỗ nhiều tích hợp gãy nhất.

Đa số jetton có decimals = 9. USDT (Tether) trên TON thì có decimals = 6. Tức là:

1 USDT = 1 000 000 raw   (10^6, không phải 10^9)

Nếu theo quán tính mà chia số raw của USDT cho 10^9, khoản 50 USDT của khách sẽ biến thành 0.05 ở phía bạn — nhỏ hơn 1000 lần. Sai theo chiều ngược lại thì cũng dễ dàng ghi có nhiều hơn 1000 lần. Đừng hardcode số chia — hãy lấy decimals từ metadata on-chain qua get_jetton_info:

const info = await getJettonInfo(USDT_MASTER); // name, symbol, decimals, tổng cung
const decimals = info.decimals;                // với USDT = 6
const human = Number(rawAmount) / 10 ** decimals; // 50000000 → 50.0

Hãy cache decimals theo địa chỉ master, chứ không theo ticker: ticker thì giả mạo được, còn decimals phải gắn với một contract cụ thể. Quy tắc rất đơn giản: decimals luôn lấy từ get_jetton_info, còn số tiền thì giữ nguyên ở dạng BigInt/raw cho tới đúng lúc hiển thị.

Đối chiếu nguồn gốc, số tiền và comment — và loại bỏ jetton giả

Giờ đến phần quan trọng nhất về mặt an toàn. Đây là những kiểm tra mà nếu thiếu thì tuyệt đối không được ghi có.

1. Master contract — chỉ USDT thật. Ai cũng có thể phát hành một jetton tên là "USDT" với ký hiệu "USD₮" rồi gửi cho bạn một lệnh chuyển 1000 "USDT". Nếu bạn khớp thanh toán theo ticker thì bạn sẽ bị lừa trong vòng năm phút. Mối ràng buộc đáng tin cậy duy nhất với Tether thật là làm việc với đúng cái jetton wallet mà master contract USDT thật đã tính ra cho chủ sở hữu của bạn:

Tether USD₮ master: EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs

Chính vì vậy mà địa chỉ jetton wallet ta lấy từ lời gọi on-chain get_wallet_address trên master này (xem ở trên), còn lịch sử thì chỉ poll của đúng contract đó — và không của bất kỳ contract nào khác. Mọi internal_transfer được ghi có vào nó đều chắc chắn đến từ một jetton wallet anh em hợp lệ của cùng master ấy: "USDT" giả sống trên các ví của một master khác và đơn giản là không bay tới được địa chỉ của bạn. Vì thế với kiến trúc này, bạn không cần đối chiếu tx.in_msg.source với bất cứ thứ gì — sự bảo vệ đã đến từ chính việc chọn điểm quan sát. Và lưu ý: parse_address chỉ là công cụ chuyển đổi định dạng EQ/UQ/raw ở phía offline, nó không tính được địa chỉ jetton wallet; muốn vậy phải dùng đúng lời gọi on-chain get_wallet_address. Bù lại, parse_address rất tiện để chuẩn hóa địa chỉ trước khi so sánh, tránh việc coi hai cách biểu diễn của cùng một địa chỉ là hai địa chỉ khác nhau.

2. Comment (memo) để khớp với invoice. Comment dạng text nằm trong forward_payload theo định dạng chuẩn: tiền tố op 0x00000000 (4 byte 0) + chuỗi UTF-8. Nhờ nó bạn gắn được lệnh chuyển đến với một đơn hàng cụ thể.

3. Số tiền và người trả. Hãy so số tiền đã quy đổi với số mà invoice mong đợi; nếu cần thì ghi lại from (địa chỉ người trả) để lưu lịch sử và chống gian lận.

// 1. Nguồn gốc đã được bảo đảm: ta đọc lịch sử của ĐÚNG cái
//    jetton wallet mà get_wallet_address của master USDT thật
//    đã trả về — nghĩa là mọi lệnh chuyển ở đây đều là thật.

// 2. Comment → invoice
const invoice = invoices.get(memo);
if (!invoice) continue;

// 3. Số tiền có khớp không?
if (human < invoice.expectedAmount) markUnderpaid(invoice);

// 4. Người trả — để log/chống gian lận
log({ payer, memo, human, lt: tx.lt, hash: tx.hash });

Idempotency: dedup theo lt + hash để không ghi có một khoản hai lần

Polling luôn tự chồng lên chính nó: chạy theo lịch, sập, retry, và các cửa sổ poll đè lên nhau. Không có dedup thì sớm muộn gì bạn cũng ghi có một khoản thanh toán hai lần.

Trên TON, mỗi giao dịch được định danh duy nhất bằng cặp logical time (lt) + hash. Đó chính là khóa idempotency tự nhiên của bạn:

const key = `${tx.lt}:${tx.hash}`;
if (await seen.has(key)) continue;   // đã xử lý rồi — thoát
await creditInvoice(invoice, human); // ghi có
await seen.add(key);                 // lưu trong cùng transaction DB

Bạn cũng có thể dùng query_id của lệnh chuyển làm khóa, nhưng (lt, hash) thì hoạt động với mọi giao dịch đến của jetton wallet, kể cả trường hợp forward_ton_amount = 0. Hãy lưu các khóa đã xử lý một cách bền vững và thực hiện việc ghi có trong cùng một transaction DB kèm ràng buộc unique — khi đó các worker chạy song song sẽ không tạo ra bản ghi trùng.

Định kỳ nên chạy đối soát: cứ mỗi N phút, so số dư thực tế của jetton wallet (qua get_jetton_balance — nó tự tính địa chỉ on-chain và trả về số dư chỉ trong một lời gọi) với tổng tất cả các khoản đã ghi có. Chênh lệch là tín hiệu cho thấy ở đâu đó có lệnh chuyển bị bỏ sót hoặc bị tính hai lần, kể cả khi chỉ một chu kỳ polling gặp sự cố.

Throughput ổn định khi tải cao: key riêng thay cho giới hạn công cộng

Polling thanh toán là một dòng request đều đặn và liên tục: N ví × tần suất poll. Và đây là lúc hạ tầng công cộng trở thành nút thắt cổ chai.

Các liteserver công cộng trong config toàn cục là dùng chung và bị giới hạn: khi tải cao chúng trả về not ready hoặc rơi vào timeout ADNL. Các HTTP API công cộng (toncenter, tonapi.io) khi không có key chỉ chịu được khoảng 1 request mỗi giây, và khi vượt ngưỡng thì trả về đúng HTTP 429 "Too Many Requests". Để ý nhé: mã giới hạn thật đúng là 429, chứ không phải cái meme "228" đang lan truyền trong cộng đồng TON và vốn không phải mã API nào cả. Với một quầy thu ngân phải poll lịch sử vài giây một lần, những giới hạn này đồng nghĩa với các khoản ghi có bị bỏ sót và bị trễ. Còn chuyện chọn nhà cung cấp có key riêng thì đã có bài phân tích riêng.

Tất cả các tool đọc dữ liệu cần cho việc detect thanh toán — get_transactions, run_get_method, get_jetton_balance, get_jetton_info, parse_address, get_account_state — đều dùng được miễn phí ngay trên máy: npx -y @tonnode/mcp. Gói @tonnode/mcp là open source (MIT) và chạy trên giao thức ADNL gốc của TON, không qua lớp HTTP trung gian nào. Khi polling lên production và bạn cần throughput được bảo đảm, hãy gắn vào endpoint hosted có key riêng:

{
  "mcpServers": {
    "ton": {
      "type": "http",
      "url": "https://mcp.tonnode.io/mcp",
      "headers": { "Authorization": "Bearer tn_live_…" }
    }
  }
}

Các gói chỉ khác nhau ở giới hạn số request mỗi phút — cả 16 tool đều có mặt ở mọi gói:

  • Hobby — miễn phí vĩnh viễn, 60 request/phút. Key được cấp ngay sau khi đăng nhập, không cần thẻ.
  • Pro — $29/tháng, 300 request/phút.
  • Scale — $199/tháng, 1200 request/phút.

Để khởi động polling thanh toán thì 60 request mỗi phút miễn phí là đủ để giữ nhịp poll ổn định mà không dính 429. Còn nếu bạn dựng trên nền đó một AI agent tự phân tích thanh toán — cách nó vận hành đã được mổ xẻ trong hướng dẫn MCP cho AI agent trên TON.

Hãy bắt đầu bằng key Hobby miễn phí — 60 req/min cho polling thanh toán, không cần thẻ: tonnode.io/dashboard?plan=hobby. Khi tải tăng lên và một worker không còn đủ — hãy so sánh giới hạn của Pro và Scale.


Checklist detect USDT trên TON một cách đáng tin cậy:

  1. Hãy poll lịch sử của jetton wallet bên nhận qua get_transactions, chứ không phải số dư của ví chính.
  2. Địa chỉ jetton wallet đó hãy lấy từ lời gọi on-chain get_wallet_address (run_get_method) trên master USD₮ thật (EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs) — khi đó mọi lệnh chuyển tới nó chắc chắn là USDT thật.
  3. Hãy tìm các internal_transfer đến với op 0x178d4519 — chính chúng mới luôn có mặt trong lịch sử của jetton wallet, bất kể forward_ton_amount. Còn transfer_notification (0x7362d09c) thì đi tới ví chính và chỉ khi forward_ton_amount > 0.
  4. Địa chỉ người trả lấy từ trường from, còn số tiền lấy từ amount (raw).
  5. Hãy lấy decimals từ get_jetton_info. USDT = 6, không phải 9.
  6. Khớp invoice theo comment lấy từ forward_payload (op 0x00000000 + UTF-8).
  7. Dedup theo (lt, hash) — chỉ ghi có đúng một lần duy nhất.
  8. Hãy giữ throughput ổn định bằng key riêng của mình, chứ không phải bằng giới hạn công cộng.

Cho agent của bạn quyền truy cập TON

16 công cụ MCP: đọc, swap phi lưu ký, cross-chain và ví. Gói miễn phí — 60 req/phút, không cần thẻ.