USDT TRC20 自动收款:未固化交易、假币合约和 Approval 记录都会把账记错
接了 TRC20 收款之后,典型投诉有三类:用户说付了、链上也能给出 txid,系统里却没有这笔账;同一笔钱被入了两次;金额差 100 倍;或者把别人空投过来的假 USDT 当成真的发了货。这几种错法的根因分别落在接口、确认状态、合约过滤、金额精度、幂等键和出款成本上。下面按排查顺序分六层写。
第 1 层:查错接口——/v1/accounts/{address}/transactions 里看不到金额
USDT 转账在链上不是一个"转账交易",而是一次 TriggerSmartContract,调用 USDT 合约的 transfer()。交易本身的 value 是 0 TRX,token 数量写在合约执行日志里。所以如果去查普通交易列表,只能看到一笔 0 TRX 的合约调用,金额字段当然是空的。
正确的历史接口是 TronGrid 的 v1 索引接口:
GET https://api.trongrid.io/v1/accounts/{address}/transactions/trc20
常用参数:
only_confirmed、only_unconfirmed:互斥,不能同时传limit:默认 20,最大 200fingerprint:分页游标contract_address:合约地址过滤min_timestamp/max_timestamp:毫秒时间戳order_by=block_timestamp,desconly_to=true:只返回收款方向
curl 示例:
curl -H "TRON-PRO-API-KEY: $KEY" \
"https://api.trongrid.io/v1/accounts/TJmmqjb1DK9TTZbQXzRQ2AuA94z4gKAPFh/transactions/trc20?limit=200&only_confirmed=true&only_to=true&contract_address=TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t&order_by=block_timestamp,asc"
文档:
这里有个容易踩的坑:官方文档明确说这是 TronGrid 的索引接口,把同样的路径指到普通 java-tron 节点上不工作。自建节点想拿到同样数据,只能自己解块或解事件,/wallet/* 系列接口不是替代品。
第 2 层:未固化 vs 已固化
不传 only_confirmed 时,接口会把 confirmed 和 unconfirmed 一起返回。unconfirmed 的含义是"已经进了某个块的候选",链发生分叉时这笔可能消失,也可能被替换成另一笔。给 unconfirmed 入账,等于给一笔可能不存在的钱发货。
前端可以显示"确认中",但资金入账必须只认 confirmed —— TronGrid 的 confirmed 对应 solidified 状态。出块时间约 3 秒,凝固时间在 1 分钟量级,会随 SR 数量和出块情况变化,需要自行核实,不要硬编码"3 秒就够"。
更严的做法是入账前再查一次:
/walletsolidity/gettransactioninfobyid
确认 receipt.result == SUCCESS。失败的合约调用(比如转账被 revert)在历史列表里也可能留痕,只看列表不看回执同样会记错账。
第 3 层:官方合约、Approval、假币
官方 USDT 合约地址:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t,decimals = 6。
contract_address 必须做白名单比对,不能只比 symbol 或 name。假币完全可以把自己叫成 USDT,也设 6 位小数。常见骗局是往你的地址空投一个同名假代币,用户拿着这个 txid 说已经付了;只翻 trc20 历史列表、只看 amount 的实现会直接中招。
/transactions/trc20 除了 Transfer,还会返回 Approval(授权)记录。approve 的 amount 可能是极大数字,比如无限授权 2^256-1,把 Approval 当成入账,账面上就会出现一个天文数字的金额。
判断依据是 type 字段,区分 Transfer / Approval / TransferFrom。只处理 to == 你的收款地址 的 Transfer。
第 4 层:金额是字符串,decimals=6,禁用 float
接口返回的 value / amount 是以最小单位表示的字符串。1 USDT = 1000000。用 float64 解析大额会丢精度,落库用 int64 / BIGINT 或 decimal 类型。
PHP 里 (float)$amount / 1e6 在 1e15 量级以上就会掉精度。小额订单看不出来,做大额或者累加对账时就开始漂。
订单匹配不要依赖"金额唯一"以外的弱条件。TRC20 没有 memo 字段(这一点和 EOS、XRP 不同),商户订单号写不进链上,只能用「金额 + 时间窗 + 收款地址」匹配,或者干脆一单一地址。
一单一地址有代价:新地址未激活时需要转 TRX 激活,1 TRX 量级加上资源消耗,具体值以主网为准;地址数量膨胀之后,私钥管理和归集成本都跟着涨。
另外 from 地址不能当用户身份。用户多半从交易所热钱包提币,from 是交易所的地址,N 个人共用同一个。
第 5 层:幂等键要用 txid + log index,游标不要用本地时间
一笔交易可以产生多条 Transfer 事件(合约批量转账、聚合器都会这样)。只按 txid 做唯一键,后面的几条会被漏掉。幂等键用 transaction_id + event_index(或 log_index)。
时间游标不要用本地 now()。min_timestamp 指的是区块时间,本地时钟和链上时间有偏差,再叠加分页延迟,边界那几秒就会被跳过。做法是:游标取上次处理到的最大 block_timestamp 减去 60~120 秒的重叠窗口,重复拉到的数据靠幂等去重。
分页用 limit=200 加 meta.fingerprint 翻页。翻页过程中其他参数必须保持一致,改任何一个 fingerprint 都会失效。
限速方面,TronGrid 免费 key 有 QPS 限制。429 用指数退避加抖动;403 多半是 key 或安全设置的问题,高频重试没有意义。
下面是一段轮询加解析的 Go 骨架(示意,未在本机连主网跑过):
const usdtContract = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
type Trc20Tx struct {
TransactionID string `json:"transaction_id"`
BlockTimestamp int64 `json:"block_timestamp"`
From string `json:"from"`
To string `json:"to"`
Type string `json:"type"`
Value string `json:"value"`
EventIndex int `json:"event_index"` // 若接口未返回,需解交易日志取 log index 补齐
TokenInfo struct {
Address string `json:"address"`
Decimals int `json:"decimals"`
Symbol string `json:"symbol"`
} `json:"token_info"`
}
type trc20Resp struct {
Data []Trc20Tx `json:"data"`
Meta struct {
Fingerprint string `json:"fingerprint"`
} `json:"meta"`
}
// 轮询:sinceMs 为上次处理到的最大 block_timestamp
func poll(ctx context.Context, client *http.Client, addr string, sinceMs int64) ([]Trc20Tx, error) {
var all []Trc20Tx
fingerprint := ""
for {
u, err := url.Parse("https://api.trongrid.io/v1/accounts/" + addr + "/transactions/trc20")
if err != nil {
return nil, err
}
q := u.Query()
q.Set("limit", "200")
q.Set("only_confirmed", "true")
q.Set("only_to", "true")
q.Set("contract_address", usdtContract)
q.Set("min_timestamp", strconv.FormatInt(sinceMs-90_000, 10)) // 90s 重叠窗口
q.Set("order_by", "block_timestamp,asc")
if fingerprint != "" {
q.Set("fingerprint", fingerprint) // 翻页期间其余参数必须不变
}
u.RawQuery = q.Encode()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
req.Header.Set("TRON-PRO-API-KEY", os.Getenv("TRONGRID_KEY"))
resp, err := client.Do(req)
if err != nil {
return nil, err
}
var body trc20Resp
err = json.NewDecoder(resp.Body).Decode(&body)
resp.Body.Close()
if err != nil {
return nil, err
}
all = append(all, body.Data...)
if body.Meta.Fingerprint == "" {
return all, nil
}
fingerprint = body.Meta.Fingerprint
}
}
// 解析:只放行官方合约、Transfer、to 匹配、decimals=6
func toEvent(tx Trc20Tx, myAddr string) (key string, amount int64, ok bool) {
if tx.Type != "Transfer" ||
tx.TokenInfo.Address != usdtContract ||
tx.To != myAddr ||
tx.TokenInfo.Decimals != 6 {
return "", 0, false
}
amt, err := strconv.ParseInt(tx.Value, 10, 64)
if err != nil || amt
## 落地顺序
1. 只读监听 + 幂等表先跑通,不写资金表;
2. 入账只认 `confirmed` + `receipt.result=SUCCESS` + 合约白名单 + `to` 匹配;
3. 订单匹配用「金额 + 时间窗 + 地址」,并记录 `txid` / `event_index`;
4. 超过时间窗的到账(用户下单超时后才付款)进人工队列,不自动发货;
5. 出款私钥与监听服务隔离,别把热钱包私钥放在 Web 进程的环境变量里。
## 不适用的场景
- 需要发票、拒付、争议仲裁的法币业务:TRC20 转账不可撤销,没有 chargeback;
- 只接受境内合规收单的场景;
- 只需要一次性收款、不做自动入账的场景,静态地址加人工核对更省事。
## 口径说明与合规提示
接口路径、参数、`only_confirmed` 与 solidified 的对应关系、TronGrid 索引接口不能指向普通 java-tron 节点这几条,取自 TRON 官方文档。能量与带宽的具体数值是公开资料口径,会随链参数和动态能量模型变化,以 `wallet/getchainparameters` 实时值为准;2025 年 8 月能量单价调整后的 32,000–45,000 区间,不同来源口径本身就不一致。Go 代码片段为示意,未在本机连主网跑过,`event_index` 字段在部分返回中可能缺失,需要解交易日志补 log index。
加密货币收款的合规、税务、外汇风险需自行评估。本文只讨论链上事件解析与入账一致性,不构成任何合规结论。