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

Kiểm tra số dư USDT trên ví TON chỉ bằng một lời gọi

Cách lấy số dư USDT trên ví TON chỉ bằng một lời gọi get_jetton_balance — địa chỉ jetton wallet được tính on-chain, không cần indexer riêng.

số dư USDT TONget_jetton_balancejetton TONMCP cho TONTONNodedecimals jetton

Ví của agent đã kết nối xong, get_balance trả về đúng vài GRAM — còn USDT thì bằng 0. Dù bạn biết chắc stablecoin đã từng được gửi về địa chỉ này. Nghe quen không? Ví trông như rỗng, trong khi trên đó đang nằm 500 USDT. Đây không phải bug, cũng chẳng phải tiền bốc hơi — bạn chỉ đơn giản là đã hỏi nhầm contract. Và đây chính là chỗ mà phần lớn các lần tích hợp AI agent với TON đổ vỡ ngay từ đầu: agent đọc nhầm địa chỉ.

Vì sao số dư USDT không phải là số dư của ví TON

Trên Ethereum bạn đã quen với chuyện token ERC-20 "nằm ở địa chỉ": contract của token giữ một bảng address → balance, và muốn biết số dư thì bạn hỏi đúng một contract đó. Trên TON, mô hình lại khác, và đó chính là nguồn gốc nhầm lẫn đầu tiên.

Coin native GRAM (Toncoin cũ; mạng lưới thì vẫn gọi là TON) nằm ngay trên smart contract của ví bạn — get_balance đọc nó chỉ bằng một lần truy vấn trạng thái tài khoản. Còn USDT là một jetton — chuẩn của TON dành cho token có thể thay thế lẫn nhau (fungible). Và số dư jetton không nằm trên ví chính của bạn, mà nằm trên một contract nhỏ riêng biệt — jetton wallet.

Ví von thế này: ví TON chính của bạn là chính bạn — một con người. Còn jetton wallet USDT là một tài khoản riêng mang tên bạn, do một ngân hàng cụ thể mở ra (master contract của USDT). Hỏi thẳng con người đó "tôi có bao nhiêu đô" thì vô nghĩa — tiền nằm trong tài khoản chứ không nằm trong túi. Mỗi cặp "chủ sở hữu + jetton" có một jetton wallet riêng: USDT một địa chỉ, NOT một địa chỉ khác, jetton bất kỳ lại là một địa chỉ khác nữa.

Tin tốt: địa chỉ của jetton wallet đó không hề ngẫu nhiên. Nó được suy ra một cách tất định từ hai thứ:

  • địa chỉ chủ sở hữu (ví TON thông thường của bạn, EQ…/UQ…);
  • địa chỉ master contract của jetton (jetton master — với USDT thì đó là một contract cố định duy nhất).

Tin xấu: muốn tính ra địa chỉ đó cho đúng, bạn phải đi vào master contract, gọi get-method của nó, rồi mới đọc được trạng thái của jetton wallet. Làm thủ công thì đó là vài bước, và các tích hợp vấp ngã đúng ở mấy bước này.

get_jetton_balance: một lời gọi thay cho cả một indexer

Thông thường người ta giải bài toán này theo một trong hai cách, và cả hai đều bất tiện:

  • Indexer riêng. Dựng node, index toàn bộ giao dịch chuyển jetton vào database, rồi giữ cho nó luôn cập nhật. Vừa tốn kém vừa mong manh chỉ để lấy một con số, mà dữ liệu thì lúc nào cũng chậm hơn chain một nhịp.
  • HTTP API công cộng. Đụng trần giới hạn rất nhanh: không có key thì chỉ tầm một request mỗi giây, còn khi vượt ngưỡng bạn nhận về đúng HTTP 429 Too Many Requests. Cộng thêm việc bạn phụ thuộc vào phần index của người khác và độ sâu dữ liệu của họ.

Tool get_jetton_balance của TONNode loại bỏ cả hai vấn đề đó. Bạn truyền cho nó:

  • địa chỉ chủ sở hữu — ví TON thông thường của người dùng (EQ…/UQ…);
  • định danh của jetton — ví dụ USDT.

Phần còn lại server tự làm hết, on-chain:

  1. gọi get-method của master contract của jetton — hàm này nhận địa chỉ chủ sở hữu và trả về địa chỉ jetton wallet tương ứng (chính là phép suy ra tất định nói trên);
  2. đọc số dư từ jetton wallet đó rồi trả về cho bạn.

Không indexer bên thứ ba, không database, không lệch đồng bộ — chỉ có việc đọc trực tiếp trạng thái mạng lưới qua các get-method của contract.

TONNode là một MCP server hosted dành cho TON. MCP (Model Context Protocol) là chuẩn để các AI agent (Claude, Cursor, ChatGPT/Codex và mọi MCP client) gọi tool bên ngoài. Nghĩa là get_jetton_balance không phải một dòng trong code của bạn, mà là một tool mà chính agent tự gọi khi người dùng hỏi về số dư. Bên dưới lớp vỏ, gói @tonnode/mcp (open source, MIT) chạy trên giao thức ADNL gốc của TON, không qua lớp HTTP trung gian nào — agent nói chuyện thẳng với mạng lưới chứ không phải qua thêm một REST gateway nữa.

Ví dụ: prompt cho agent và kết quả trả về

Kết nối chạy local, không cần key, không cần thẻ. Thêm vào config của MCP client (Claude Desktop, Cursor, bất kỳ client tương thích MCP nào):

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

Sau đó, chỉ cần một prompt bằng ngôn ngữ tự nhiên là đủ cho agent:

UQAbc…xyz có bao nhiêu USDT? Trả về giá trị ở dạng người đọc được.

Agent sẽ tự chọn tool get_jetton_balance, truyền vào địa chỉ chủ sở hữu cùng jetton USDT, còn server trả về số dư — nhưng không phải theo "đô la" quen thuộc, mà theo đơn vị raw (đơn vị nhỏ nhất, không chia được nữa của jetton). Ngoài bản thân số dư, phản hồi còn kèm luôn địa chỉ jetton wallet mà server đã tính on-chain — rất hữu ích nếu sau đó bạn muốn theo dõi các giao dịch chuyển của đúng contract này. Còn số dư trong ví dụ của chúng ta là một giá trị kiểu 12500000.

Nhưng 12500000 không phải 12.5 triệu USDT. Đây là lúc cái bẫy thứ hai xuất hiện, và rất dễ sập bẫy.

Đơn vị raw và decimals: vì sao USDT là 6 chứ không phải 9

Blockchain không làm việc với số thập phân. Mọi khoản tiền đều được lưu ở đơn vị raw dạng số nguyên, còn giá trị "dành cho người" thì có được bằng cách chia cho 10^decimals, trong đó decimals là thuộc tính của từng jetton cụ thể. Giống như xu và đô la: ở tầng thấp mọi thứ đều tính bằng xu, còn decimals cho biết phải đặt dấu thập phân ở đâu.

Điểm mấu chốt của TON: các jetton khác nhau có số decimals khác nhau.

  • GRAM native và đa số jetton trên TONdecimals = 9.
  • Còn USDT trên TON thì decimals = 6.

Vì vậy cùng một giá trị raw lại tương ứng với những khoản tiền hoàn toàn khác nhau. Tính lại ví dụ của chúng ta cho đúng:

raw      = 12500000
decimals = 6            // đúng cho USDT
human = 12500000 / 10^6 = 12500000 / 1_000_000 = 12.5 USDT

12.5 USDT, chứ không phải 12.5 triệu. Nếu theo quán tính mà chia cho 10^9 (như với một jetton thông thường), bạn sẽ ra 0.0125 — sai lệch một nghìn lần. Cùng một con số, mà chênh nhau ba bậc độ lớn. Tiền trong các tích hợp ngây thơ mất đúng theo kiểu đó: hardcode số chia 10^9 cho mọi thứ. Vì vậy đừng bao giờ hardcode decimals — hãy lấy nó từ metadata của chính jetton đó.

get_jetton_info: lấy decimals và metadata của jetton ở đâu

Để khỏi phải đoán, có một tool đi kèm — get_jetton_info. Nó đọc master contract của jetton và trả về metadata của nó:

  • tên (name);
  • ký hiệu (symbol);
  • decimals — chính con số dùng để tính ra số chia;
  • tổng cung (total supply).

Kịch bản đáng tin cậy cho agent là hai lời gọi: nếu jetton còn lạ, trước hết get_jetton_info để biết decimals, sau đó get_jetton_balance để lấy số dư raw, và chỉ đến lúc đó mới chia raw / 10^decimals để hiển thị.

1) get_jetton_info(USDT)            -> decimals = 6
2) get_jetton_balance(owner, USDT)  -> raw = 12500000
3) human = 12500000 / 10^6          -> 12.5 USDT

Prompt có thể viết sao cho agent tự ghép các bước đó lại:

Lấy decimals của USDT qua get_jetton_info, rồi lấy số dư của ví UQAbc…xyz qua get_jetton_balance và quy raw ra con số người đọc được.

Thứ tự này là bắt buộc nếu bạn làm việc với jetton bất kỳ chứ không riêng USDT: với một token lạ, bạn không thể biết trước nó có decimals bằng 6 hay 9. Với USDT thì decimals = 6 là hằng số, nhưng thói quen kéo nó ra từ get_jetton_info sẽ cứu bạn ngay ở cái jetton phi tiêu chuẩn đầu tiên. Cũng chính thủ thuật này là nền tảng của việc detect các khoản thanh toán USDT đến trên TON — ở đó decimals mang tính sống còn, để không nhầm 1 USDT với một khoản dust tí hon.

parse_address: kiểm tra địa chỉ offline trước khi truy vấn

Một nguyên nhân phổ biến khác của số dư "bằng 0" là địa chỉ chủ sở hữu bị sai. Người dùng gửi địa chỉ ở đủ mọi định dạng: EQ… (bounceable), UQ… (non-bounceable), raw (0:…). Trước khi hỏi số dư, nên chuẩn hóa địa chỉ qua parse_address — tool này chạy hoàn toàn offline (không hề gọi ra mạng), nhận địa chỉ ở bất kỳ định dạng nào, chuyển nó sang cả ba (EQ/UQ/raw) và cho biết nó có hợp lệ hay không.

Cách này rẻ, cho kết quả tức thì và loại bỏ nguyên một lớp lỗi kiểu "số dư 0 vì địa chỉ sai" — nhất là khi địa chỉ đến từ một nguồn không đáng tin, từ input của người dùng hoặc từ chat.

Cách kết nối: miễn phí ở local hoặc dùng hosted

Ba tool — parse_address, get_jetton_info, get_jetton_balance — cho bạn câu trả lời đầy đủ và trung thực cho câu hỏi "ví này có bao nhiêu USDT", mà không cần tới một indexer bên thứ ba nào.

Miễn phí ở local

Gói @tonnode/mcpopen source (MIT), nằm trên npm và GitHub. Vẫn đúng config công khai ở ví dụ phía trên (npx -y @tonnode/mcp), và nó cho bạn trọn bộ tool đọc dữ liệu, gồm cả get_jetton_balance, get_jetton_infoparse_address — không cần key riêng.

Khởi động lại client — và agent đã đọc được số dư jetton qua ADNL. Chuyện vì sao agent lại cần hẳn một MCP server riêng thay vì một gateway công cộng đầy giới hạn, chúng tôi đã mổ xẻ trong bài về MCP cho AI agent trên TON. Còn config công khai miễn phí kết thúc ở đâu và vì sao — có trong bài phân tích giới hạn của các liteserver công cộng trên TON.

Hosted — khi bạn cần throughput

Khi rate ở local không còn đủ (bot, backend, tải production), bạn chuyển sang endpoint hosted với key của riêng mình. Bộ tool ở đâu cũng như nhau — cả 16 tool, gồm cả nhóm đọc dữ liệu; các gói chỉ khác nhau ở throughput được bảo đảm:

  • Hobby — miễn phí vĩnh viễn, 60 request/phút;
  • Pro — $29/tháng, 300 request/phút;
  • Scale — $199/tháng, 1200 request/phút.

Kết nối hosted chỉ khác ở chỗ bạn gọi tới một endpoint chung bằng key của mình:

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

Nếu bạn làm dashboard, bot kiểm tra số dư hay một agent thường xuyên truy vấn địa chỉ — hãy lấy key, để khỏi đụng trần giới hạn của các API công cộng và khỏi dính 429 đúng vào lúc không mong muốn nhất.

Tóm lại

  • USDT trên TON là một jetton, và số dư nằm trên một jetton wallet riêng chứ không phải trên địa chỉ chính.
  • get_jetton_balance tự tính địa chỉ jetton wallet on-chain rồi đọc số dư — không cần indexer, cũng chẳng cần database riêng.
  • Số dư trả về theo đơn vị raw; muốn hiển thị thì chia cho 10^decimals.
  • USDT có decimals = 6 (số chia 1_000_000), còn đa số jetton là 9. Đừng hardcode — hãy lấy từ get_jetton_info.
  • Bạn có thể chạy thử toàn bộ quy trình miễn phí: npx -y @tonnode/mcp, config công khai, trọn bộ tool đọc dữ liệu.

Key Hobby miễn phí — 60 request/phút, không cần thẻ — được cấp ngay sau khi đăng nhập: lấy key. Chừng đó là đủ để chạy parse_address → get_jetton_info → get_jetton_balance trên một ví thật và tự mình xác nhận rằng 12500000 raw quy ra đúng 12.5 USDT — chứ không phải 12.5 triệu, cũng không phải 0.0125.

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ẻ.