全部文章
7 分钟阅读

如何在 TON 上可靠检测入账的 USDT 付款

TON 上的 USDT 付款检测:通过 get_transactions 解析 jetton 的 transfer_notification,用 get_jetton_info 取 decimals,核对金额并保证幂等。

TON USDT付款检测transfer_notificationTEP-74 jetton幂等性get_transactions

凡是在 TON 上做过收款的人,都掉进过同一个坑:钱到账了,后端却“没看见”。客户转了 50 USDT,钱包里明明显示入账,发票却还挂在“等待支付”状态。十分钟后——一张愤怒的客服工单。再过一个小时又发现:同一笔付款被入账了两次,因为轮询器在重试时又触发了一遍。在 TON 上可靠地检测入账的 USDT 付款(incoming USDT payment),不是“每分钟查一次余额”,而是逐条解析 jetton 的内部消息,并配合幂等处理与防伪校验。下面一步步拆解正确做法——用的是 TONNode MCP 服务器的真实工具,本地调用完全免费。

为什么 TON 上的 USDT 付款检测与钱包余额无关

朴素的做法是定期读取钱包余额,只要涨了就算收到付款。这就像一台只会盯着钱箱总额的收银机:进账 +10 USDT——但是谁付的?对应哪张账单?是旧订单还是新订单?如果两个客户同时各付了 10,你只会看到一次 +20。

在 TON 上,这种做法会在好几个层面直接崩掉:

  • USDT 不是原生代币,而是 jetton(TEP-74 标准)。资金不会直接进入主钱包,而是进入它的 jetton 钱包——一个绑定到特定 jetton 主合约的独立合约。整个过程中主钱包的 GRAM 余额根本不会变。
  • 余额差值不会告诉你谁付的、为什么付。 余额本身并不保存与发票的对应关系。
  • 竞态与聚合。 两次轮询之间进来好几笔付款——你看到的是总差值,而不是一个个独立事件。
  • 重复入账。 轮询重试或 worker 重启——同一笔付款就被记了两次。
  • Decimals。 USDT 是 6 位小数,而不是 jetton 常见的 9 位。除数错一次,客户“到账”的金额就差 1000 倍。

正确的路径是:不看余额,而是处理收款方 jetton 钱包的交易流,把每一次转账事件单独解析。所有读取操作都可以本地免费完成:

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

入账 USDT 在链上长什么样:jetton 的 internal_transfer(op 0x178d4519)

当有人给你转 USDT 时,会发生这样一条链路:

  1. 付款方的 jetton 钱包扣掉金额,并向你的 jetton 钱包发送一条 internal 消息 internal_transfer(op 0x178d4519)。
  2. 你的 jetton 钱包记入资金,并且——仅当付款方附带了 forward_ton_amount > 0 时——额外向你的钱包发送内部消息 transfer_notification(op 0x7362d09c)。

最关键的分岔口就在这里。transfer_notification 是一条方便的“您收到了 jetton”通知,但它是发给钱包(owner)的,而且只在 forward_ton_amount > 0 时才会发出。如果付款方把它设成零——jetton 钱包照样入账,但 owner 不会收到任何通知。

而在 jetton 钱包自己的历史里,入账记录永远都在——以入站 internal_transfer 的形式存在,与 forward_ton_amount 无关。所以,可靠的检测方案要建立在轮询收款方 jetton 钱包的历史、并从中解析 internal_transfer 之上。它在 TEP-74 里的结构如下:

internal_transfer#178d4519
  query_id:           uint64
  amount:             (VarUInteger 16)     // jetton 的 raw 单位
  from:               MsgAddress           // 付款人(真人 owner)的地址
  response_address:   MsgAddress
  forward_ton_amount: (VarUInteger 16)
  forward_payload:    (Either Cell ^Cell)  // 备注/memo

两个要点:

  • from 字段是真实付款人(owner 本人)的地址,而不是他的 jetton 钱包地址。要记进日志、当作付款方的,正是这个地址。注意:消息层面的地址(tx.in_msg.source)在这里是付款人的 jetton 钱包,真正的付款人在消息体的 from 字段里。
  • amount 字段里的金额是 raw 单位,换算方法下文再讲。

由此得出的核心架构结论

可靠的检测不是建立在等待主钱包收到 notification 之上,而是建立在轮询收款方 jetton 钱包本身的历史之上。在它的历史里,入账永远可见,与 forward_ton_amount 无关。如果你额外监听主钱包,那里捕获的是 transfer_notification(op 0x7362d09c,付款方在 sender 字段里)——但这只是 forward_ton_amount > 0 场景下的加分项,不是唯一的事实来源。

用 get_transactions 读历史并解析 internal_transfer

首先需要收款方 jetton 钱包的地址。不要离线推导,也不要相信代币符号——去问 USDT 主合约本身:在 USDT 主合约上(通过 run_get_method)调用 get_wallet_address,会确定性地返回你的 USDT jetton 钱包地址,且绑定的是真正的主合约。然后用 get_transactions 轮询这个 jetton 钱包的交易。

轮询循环里发给智能体的提示词:

在 USDT 主合约
EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs 上调用
run_get_method,方法为 get_wallet_address,参数为 owner 地址
UQ...myservice。返回我们的 USDT jetton 钱包地址。然后通过
get_transactions 读取该 jetton 钱包最近的交易。对每一笔入站
internal_transfer(op 0x178d4519),返回 query_id、amount(raw)、
from 字段(付款人地址)、forward_payload 中的文本备注,
以及交易的 lt 和 hash。

处理单笔交易的伪代码:

for (const tx of txs) {
  const body = tx.in_msg?.decoded;          // 解析后的入站消息体
  if (body?.op !== 0x178d4519) continue;    // 不是 internal_transfer——跳过

  const rawAmount = BigInt(body.amount);    // raw 单位,不是人类可读的
  const payer     = body.from;              // 付款人 owner 的地址
  const memo      = parseComment(body.forward_payload);
  const key       = `${tx.lt}:${tx.hash}`;  // 去重用的标识
  // …核对与入账见下文
}

另外,用 get_account_state 单独核对一次主账户的状态(是否 active、最后一笔交易在什么时候)也很有用——它能帮你判断轮询器是不是还“活着”、有没有掉队。

decimals 决定一切:get_jetton_info 与 raw 金额换算(USDT = 6 而不是 9)

转账里的 amount 字段是 jetton 的 raw 单位——一个不带小数点的整数。要把它变成人类可读的金额,需要 decimals。而恰恰在这一步,翻车的集成最多。

大多数 jetton 的 decimals = 9TON 上的 USDT(Tether)——decimals = 6 也就是说:

1 USDT = 1 000 000 raw   (10^6, 而不是 10^9)

如果凭惯性把 USDT 的 raw 金额除以 10^9,客户的 50 USDT 付款在你这边就会变成 0.05——小了 1000 倍。反方向的错误同样会轻松让你多入账 1000 倍。不要硬编码除数——用 get_jetton_info 从链上元数据里取 decimals

const info = await getJettonInfo(USDT_MASTER); // name, symbol, decimals, 发行量
const decimals = info.decimals;                // USDT 为 6
const human = Number(rawAmount) / 10 ** decimals; // 50000000 → 50.0

decimals主合约地址缓存,而不是按代币符号:符号可以伪造,decimals 必须绑定到具体合约。规则很简单:decimals 永远来自 get_jetton_info,金额在展示之前只以 BigInt/raw 形式存在。

核对来源、金额与备注——并挡掉假 jetton

接下来是安全上最重要的部分。没有这些检查就不能入账。

1. 主合约——只能是真正的 USDT。 任何人都可以发行一个名叫“USDT”、符号是“USD₮”的 jetton,然后给你转 1000 个“USDT”。如果你按符号匹配付款——五分钟就会被骗。与真正的 Tether 唯一可靠的绑定方式,是只跟真正的 USDT 主合约为你的 owner 计算出来的那个 jetton 钱包打交道:

Tether USD₮ master: EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs

正因如此,jetton 钱包的地址我们才要通过在这个主合约上的链上调用 get_wallet_address 拿到(见上文),而且只轮询这一个——而非任何别的——合约的历史。任何入账到它上面的 internal_transfer,都必然来自同一主合约体系下合法的兄弟 jetton 钱包:假“USDT”活在另一个主合约的钱包体系里,根本到不了你的地址。所以在这套架构下,无需再拿 tx.in_msg.source 去和什么东西比对——观察点本身的选择就已经提供了防护。还要注意:parse_address 只是 EQ/UQ/raw 格式之间的离线转换,它不会计算 jetton 钱包地址;那必须用链上调用 get_wallet_addressparse_address 适合在比较之前做地址归一化,避免把同一地址的不同表示当成不同地址。

2. 备注(memo)用于匹配发票。 文本备注放在 forward_payload 里,采用标准格式:op 前缀 0x00000000(4 个零字节)+ UTF-8 字符串。你就是靠它把入账转账和具体订单关联起来的。

3. 金额与付款人。 把换算后的金额与发票的期望金额比对;必要时把 from(付款人地址)记录下来,用于留痕和反欺诈。

// 1. 来源有保证:我们读的历史,正是真正的 USDT 主合约
//    通过 get_wallet_address 算出来的那个 jetton 钱包的历史——
//    所以出现在这里的每一笔转账都是真的。

// 2. 备注 → 发票
const invoice = invoices.get(memo);
if (!invoice) continue;

// 3. 金额对得上吗?
if (human < invoice.expectedAmount) markUnderpaid(invoice);

// 4. 付款人——用于日志/反欺诈
log({ payer, memo, human, lt: tx.lt, hash: tx.hash });

幂等性:按 lt + hash 去重,避免同一笔付款入账两次

轮询总会和自己撞车:按计划启动、崩溃、重试,前后两次的查询窗口互相重叠。没有去重,你迟早会把同一笔付款记两次。

在 TON 里,每笔交易由 logical time(lt)+ hash 这一对组合唯一标识。这就是你天然的幂等键:

const key = `${tx.lt}:${tx.hash}`;
if (await seen.has(key)) continue;   // 已经处理过——退出
await creditInvoice(invoice, human); // 入账
await seen.add(key);                 // 在同一个数据库事务里落盘

转账里的 query_id 也可以当键用,但 (lt, hash) 对 jetton 钱包的任何入站交易都有效,包括 forward_ton_amount = 0 的情况。把已处理的键持久化存储,并把入账和唯一性检查放在同一个数据库事务里——这样并行的 worker 也不会造出重复记录。

还应当定期做对账:每 N 分钟把 jetton 钱包的实际余额(用 get_jetton_balance——它在链上计算地址并一次调用返回余额)与所有已入账付款的总和比对一次。一旦出现差额,就说明某笔转账被漏掉或被记了两次——哪怕某个轮询周期中途出过故障,也能这样兜住。

高负载下的稳定 throughput:用自己的 key 替代公共限额

付款轮询是一条持续、平稳的请求流:N 个钱包 × 轮询频率。这时公共基础设施就成了瓶颈。

全局配置里的公共 liteserver 是共享且限流的:负载一上来就返回 not ready 或直接 ADNL 超时。公共 HTTP API(toncenter、tonapi.io)在不带 key 的情况下大约只扛得住每秒 1 个请求,超限后会老老实实返回 HTTP 429 “Too Many Requests”。注意:真正的限流状态码就是 429,而不是在 TON 社区流传的那个梗“228”——它根本不是任何 API 的状态码。对一个每隔几秒就要轮询历史的收款系统来说,这些限额意味着漏账和延迟入账。如何挑选一个能拿自己 key 的服务商——另有专文拆解。

付款检测所需的全部读取工具——get_transactionsrun_get_methodget_jetton_balanceget_jetton_infoparse_addressget_account_state——都可以本地免费使用:npx -y @tonnode/mcp@tonnode/mcp 包是开源的(MIT),走 TON 原生的 ADNL 协议,没有 HTTP 中间层。当轮询上了生产环境、需要有保障的 throughput 时,再接入带自己 key 的 hosted 端点:

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

各套餐只在每分钟请求限额上有区别——全部 16 个工具在每个套餐里都可用:

  • Hobby——永久免费,60 请求/分钟。登录后立即发放 key,无需绑卡。
  • Pro——$29/月,300 请求/分钟。
  • Scale——$199/月,1200 请求/分钟。

对刚起步的付款轮询来说,免费的每分钟 60 个请求已经足以维持稳定的查询节奏,不会撞上 429。如果你要在这之上构建一个自己解析付款的 AI 智能体——它是如何运作的,在 TON 上 AI 智能体的 MCP 指南里有完整拆解。

从免费的 Hobby key 开始——60 req/min,够跑付款轮询,无需绑卡:tonnode.io/dashboard?plan=hobby。当负载涨上来、一个 worker 不够用时——对比 Pro 和 Scale 的限额


TON 上可靠检测 USDT 的清单:

  1. get_transactions 轮询收款方 jetton 钱包的历史,而不是主钱包余额。
  2. 该 jetton 钱包的地址要通过在真正的 USD₮ 主合约EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs)上的链上调用 get_wallet_addressrun_get_method)获得——这样任何转到它上面的转账都必然是真 USDT。
  3. 找入站的 internal_transfer(op 0x178d4519)——无论 forward_ton_amount 是多少,它都一定出现在 jetton 钱包的历史里。transfer_notification0x7362d09c)发给主钱包,且只在 forward_ton_amount > 0 时才有。
  4. 付款人地址取自 from 字段,金额取自 amount(raw)。
  5. decimalsget_jetton_info 取。USDT = 6,不是 9。
  6. forward_payload 里的备注(op 0x00000000 + UTF-8)匹配发票。
  7. (lt, hash) 去重——严格只入账一次。
  8. 用自己的 key 维持稳定 throughput,别依赖公共限额。

让你的智能体接入 TON

16 个 MCP 工具:读取、非托管兑换、跨链与钱包。免费档 60 次/分钟,无需银行卡。