USDT TRC20 出款:广播返回 true 但用户没到账,7 个坑与重试设计
凌晨两点,出款服务对一笔 USDT TRC20 提现请求返回 502。值班同学按惯例点了重试,服务端把整笔单子重新构造、重新签名、重新广播。十分钟后用户发来截图:同一笔提现收到两笔到账。
这是最典型的一类事故。广播接口超时,程序当成「没发出去」,于是重建了一笔 txID 不同的新交易。链上只认「哪笔被打包了」,不认「哪笔是重试」。两笔都有效,两笔都执行。
先记住一句:/wallet/broadcasttransaction 返回 result:true 只说明处理这次广播的节点没报错,不代表打包、不代表执行成功、更不代表固化。后面所有坑都从这个语义错位长出来。
一、链路四步,四种「成功」
POST /wallet/triggersmartcontract:构造未签名交易,返回TransactionExtention,含txid、raw_data、raw_data_hex、expiration。这里的txid只是这笔交易的哈希,没上链、没执行。- 估能量:
/wallet/triggerconstantcontract(模拟执行)或/wallet/estimateenergy(需节点开vm.estimateEnergy+vm.supportConstant),拿energy_used/energy_required。 - 本地签名:签名对象是
raw_data_hex的 SHA256,即txID = sha256(raw_data)。signature必须 65–68 字节(130–136 hex 字符),长度超范围直接SIGERROR。签完raw_data一个字都不能动,动了 txID 变、签名作废。 POST /wallet/broadcasttransaction:进池子。- 回执:
/walletsolidity/gettransactioninfobyid是固化视图,/wallet/gettransactioninfobyid是未固化视图。TRX/TRC10 直接转账查gettransactionbyid。
出款到底成没成,只由第 5 步决定。
二、坑 1:fee_limit 单位是 sun,不是 TRX
现象:质押了能量,fee_limit 给了 100,合约直接 OUT_OF_ENERGY。
原因:1 TRX = 1,000,000 sun。fee_limit=100 实际只授权了 0.0001 TRX。而且 fee_limit 是调用方 Energy 预算上限:
maxCallerEnergyFromFeeLimit = fee_limit / EnergyPrice
实际可用 = min(质押能量 + (余额 - callValue) / sunPerEnergy, fee_limit / sunPerEnergy)
fee_limit 太小,就算质押能量充足也会 OUT_OF_ENERGY,因为可用量被 fee_limit / sunPerEnergy 这一项卡死。另外它是上限不是固定费:正常执行和 REVERT 按实际消耗结算,OUT_OF_TIME、非法指令这类异常会把预算吃满。
怎么验:查链参数,别硬编码。
curl -X POST $NODE/wallet/getchainparameters
curl -X POST $NODE/wallet/getenergyfee -d '{}'
主网上限约 15,000 TRX(链参数 getMaxFeeLimit #47),EnergyPrice 文档示例 100 sun/Energy,两者都随 SR 投票变化,生产代码要查 /wallet/getchainparameters。
怎么改:普通 TRC20 部署消耗 20–50 万 Energy,复杂合约可超 100 万。按估出的能量 × EnergyPrice 再留余量算 fee_limit,全程用 sun,代码里禁止出现裸的「100」。超出链上限在下单时就拒,别等链上返回。
三、坑 2:BANDWITH_ERROR 跟能量无关
现象:fee_limit 拉满,还是 BANDWITH_ERROR。(拼写就是少一个 D,协议 enum 原样,别顺手「修正」,会匹配不上。)
原因:带宽不足,或 TRX 不够付带宽费、新账户激活、多签、memo 费。不是 TVM Energy 耗尽。出款到没激活过的地址、带 memo、多签,都会额外要带宽或 TRX,这时 fee_limit 给多大都没用。
怎么验:
curl -X POST $NODE/wallet/getaccountresource \
-d '{"address":"","visible":true}'
看 freeNetUsed / freeNetLimit、EnergyLimit,再对一下账户 TRX 余额。
怎么改:出款到新地址先打 1 TRX 激活;长期方案是给出款热地址质押带宽,或按笔预留带宽费并保证 TRX 覆盖。
四、坑 3:超时 / 5xx / OTHER_ERROR / NOT_ENOUGH_EFFECTIVE_CONNECTION = 结果未知
现象:广播请求超时,服务端判失败,用户收到两笔。
原因:官方原话是,result:true 只说明处理这次广播的节点没报错,请求超时就无法判断节点是否接受。NOT_ENOUGH_EFFECTIVE_CONNECTION 和 OTHER_ERROR 也可能在交易已进入本地 Pending Pool 之后才返回。
怎么验:拿 txid 查固化历史,而不是看这次 HTTP 响应。
怎么改:保留原始已签名 payload,重播同一份签名交易。同一份签名字节 txID 不变,网络去重,不会二次执行。严禁「超时就重建一笔新的」——ref_block、timestamp、expiration 全会变,txID 变,两笔都可能被打包。
// 重播同一份签名字节,绝不重建(示意,未连主网跑过)
func rebroadcast(nodeURL, signedHex string) (*BroadcastResp, error) {
body, _ := json.Marshal(map[string]string{"transaction": signedHex})
resp, err := http.Post(nodeURL+"/wallet/broadcasttransaction",
"application/json", bytes.NewReader(body))
if err != nil {
return nil, err // 超时同样按 UNKNOWN 处理,不重建
}
defer resp.Body.Close()
var out BroadcastResp
return &out, json.NewDecoder(resp.Body).Decode(&out)
}
错误分类:
- 可重播:超时 / 5xx /
OTHER_ERROR/NOT_ENOUGH_EFFECTIVE_CONNECTION/SERVER_BUSY/DUP_TRANSACTION_ERROR - 换节点重播:
TAPOS_ERROR(仅限第一次提交) - 转人工:
SIGERROR/CONTRACT_VALIDATE_ERROR - 修参数:
BANDWITH_ERROR补 TRX,OUT_OF_ENERGY提fee_limit(注意上限)
退避用指数 + 抖动,重播窗口不超过交易有效期(约 60 秒),过期走 EXPIRED 流程。
五、坑 4:DUP_TRANSACTION_ERROR 不代表已上链
现象:重播返回 DUP_TRANSACTION_ERROR,以为成了。
原因:同 txID 通常已在 pending pool 或已进块;开了 node.rpc.trxCacheEnable 时也可能只是 RPC 缓存命中。
怎么验:用原 txID 走固化历史确认。
怎么改:归入「待确认」,不算终态,更不要另建新交易。
六、坑 5:打包成功 ≠ 执行成功
现象:交易上链了,receipt.result 不是 SUCCESS。
原因:执行错误不在广播响应里,在回执里,可能是 OUT_OF_ENERGY / OUT_OF_TIME / REVERT / BAD_JUMP_DESTINATION 等。合约执行失败照样上链、照样烧能量。
怎么验:
curl -X POST $NODE/walletsolidity/gettransactioninfobyid \
-d '{"value":""}'
判定不能只看 receipt.result,还要解 log 里的 Transfer 事件确认收款地址与金额:
topic0 = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3eftopics[1]= from、topics[2]= to(32 字节左补齐,取后 20 字节)data= 金额(最小单位,USDT decimals = 6)
合约地址必须是官方 USDT:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t。
怎么改:账务上记为「出款失败 + 手续费已损失」,不能当没发生;状态落 SOLID_FAIL 转人工,不自动重发。
七、坑 6:60 秒有效期与 TAPOS
现象:TRANSACTION_EXPIRATION_ERROR、TAPOS_ERROR。
原因:节点构造的交易默认有效期约 60 秒(raw_data.expiration);TAPOS_ERROR 是 ref_block_bytes / ref_block_hash 指向的块不在节点认识的近链上。自建节点可调 trx.expiration.timeInMilliseconds,本地构造也能自己设 expiration——但改了 raw_data 就改 txID,必须重签。
怎么改:只有「第一次且唯一一次提交」遇到 TAPOS 可以直接重建。已经提交过、结果未知的,必须先按原 txID 查固化历史再决定。实操上把构造 → 签名 → 广播压到秒级;确认过期(而不是「不知道有没有进池」)才重建,重建换新 txID。
八、坑 7:收款地址未激活,message 是 hex
现象:CONTRACT_VALIDATE_ERROR,message 是一串 hex。
原因:visible=false 时 message 是 hex 编码的 UTF-8 字节;visible=true 可能直接明文。常见 message:account does not exist、Validate ... error, no OwnerAccount、No contract or not a valid smart contract、this node does not support constant。
怎么验:先判断是否偶数长度十六进制,再 decode 成 UTF-8 读原文。
怎么改:出款到新地址先打 1 TRX 激活。
九、幂等表与状态机
表 payout_order:id、out_id(唯一)、to_addr、amount(最小单位 BIGINT)、state、txid、signed_tx(TEXT,签名字节原样存)、fee_limit、created_at、updated_at、error_code、error_msg。
CREATE TABLE payout_order (
id BIGSERIAL PRIMARY KEY,
out_id VARCHAR(64) NOT NULL UNIQUE,
to_addr VARCHAR(64) NOT NULL,
amount BIGINT NOT NULL, -- 最小单位,USDT decimals=6
state VARCHAR(16) NOT NULL,
txid VARCHAR(64),
signed_tx TEXT, -- 原始签名字节,重播只读这一列
fee_limit BIGINT,
error_code VARCHAR(64),
error_msg TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
状态:CREATED → SIGNED → BROADCAST → SOLID_OK / SOLID_FAIL / EXPIRED / UNKNOWN。
- 一个
out_id只能有一份签名交易,重试只重播signed_tx。 - 只有 EXPIRED(确认已过期且不在池中)才允许重建,
out_id不变、attempt+1、写新 txid。 - UNKNOWN 必须有对账任务:按 txid 查固化历史,超过有效期仍未出现才转 EXPIRED。
-- 宽限期按自己的对账节奏定,示意
UPDATE payout_order SET state = 'EXPIRED', updated_at = now()
WHERE state = 'UNKNOWN' AND updated_at < now() - interval '5 minutes';
- SOLID_FAIL 也要落账(手续费损失),并发通知业务方人工处理,不要自动重发。
十、能量预算与归集成本
用 triggerconstantcontract 拿 energy_used(通用),estimateenergy 拿 energy_required(部分节点已关闭)。
保守预算:fee_limit = floor(base_energy × (1 + max_factor)) × EnergyPrice。主网 max_factor 当前返回 34000(即 3.4),EnergyPrice 默认 100 sun/Energy,都要查链参数确认。
动态能量模型(DEM):actual_energy = floor(base_energy × (1 + contract_energy_factor)),energy_factor 每维护周期(主网约 6 小时)更新一次,热点合约惩罚更高,跨周期要重估。
归集场景每个零散地址都得有 TRX 付带宽/能量,成本 = 单笔能量 × 笔数,地址越多越亏。能在链下合并的先合并,比如只归集超过阈值(比如超过 10 USDT)的地址。
十一、这些场景别硬上
- 需要退款、拒付、争议的:TRC20 不可撤销,没有 chargeback。
- 出款额度小、笔数少:人工钱包操作比自建签名服务省事。
- 合规/税务/外汇要求明确的主体:链上出款不替代持牌支付通道。
十二、文档与接口地址
- 合约交互(读写、triggersmartcontract、triggerconstantcontract)
- TriggerSmartContract 参考
- EstimateEnergy 参考
- FeeLimit 与能量成本
- 获取资源(质押/带宽/能量)
- 广播与 RPC 错误码
- 错误分类与 hex message 解码
- 排障索引
- java-tron broadcasttransaction 字段与返回
路径、字段、错误码、fee_limit 语义、DEM、有效期均为官方文档口径;具体能量数值与链参数以节点实时返回为准。Go 片段与 SQL 为示意,未在生产主网实测。