代码 USDT TRC20 出款:广播返回 true 但用户没到账,7 个坑与重试设计

2026-11-13 09:02:31

USDT TRC20 出款:广播返回 true 但用户没到账,7 个坑与重试设计

凌晨两点,出款服务对一笔 USDT TRC20 提现请求返回 502。值班同学按惯例点了重试,服务端把整笔单子重新构造、重新签名、重新广播。十分钟后用户发来截图:同一笔提现收到两笔到账。

这是最典型的一类事故。广播接口超时,程序当成「没发出去」,于是重建了一笔 txID 不同的新交易。链上只认「哪笔被打包了」,不认「哪笔是重试」。两笔都有效,两笔都执行。

先记住一句:/wallet/broadcasttransaction 返回 result:true 只说明处理这次广播的节点没报错,不代表打包、不代表执行成功、更不代表固化。后面所有坑都从这个语义错位长出来。

一、链路四步,四种「成功」

  1. POST /wallet/triggersmartcontract:构造未签名交易,返回 TransactionExtention,含 txid、raw_data、raw_data_hex、expiration。这里的 txid 只是这笔交易的哈希,没上链、没执行。
  2. 估能量:/wallet/triggerconstantcontract(模拟执行)或 /wallet/estimateenergy(需节点开 vm.estimateEnergy + vm.supportConstant),拿 energy_used / energy_required。
  3. 本地签名:签名对象是 raw_data_hex 的 SHA256,即 txID = sha256(raw_data)。signature 必须 65–68 字节(130–136 hex 字符),长度超范围直接 SIGERROR。签完 raw_data 一个字都不能动,动了 txID 变、签名作废。
  4. POST /wallet/broadcasttransaction:进池子。
  5. 回执:/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 = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
  • topics[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。
  • 出款额度小、笔数少:人工钱包操作比自建签名服务省事。
  • 合规/税务/外汇要求明确的主体:链上出款不替代持牌支付通道。

十二、文档与接口地址

路径、字段、错误码、fee_limit 语义、DEM、有效期均为官方文档口径;具体能量数值与链参数以节点实时返回为准。Go 片段与 SQL 为示意,未在生产主网实测。

复制全文 生成海报 USDT TRC20 出款 波场 链上对账 支付接口

推荐文章

程序员茄子在线接单