Files
pangolin/pay/internal/tron/tx.go
wangjia 7cf87a764f feat(pay): Phase D 归集 —— 气隙签名 sweep(#34/34A)
动钱代码,按"联网建/广播 + 离线签"气隙流程,离线签名独立验签防篡改:
- wallet: DecodeTronAddress/TronAddressBodyHex(base58check 解码校验)、AddressFromMnemonic(离线校验用)。
- tron/sign: TxID(sha256 raw_data)、SignRawData(secp256k1 → R||S||recid 65B)、RecoverAddressBody。
- tron/abi: ABIEncodeTransferParams(transfer(address,uint256) 参数)、keccak 地址体。
- tron/tx(联网): BuildTransfer(triggersmartcontract 建未签名)、TRC20Balance、Broadcast。
- cmd/sweep: plan/build/sign/broadcast 四段;sign 对每笔独立验:①重算 txid 防篡改 ②收款人+额 ABI
  内嵌 ③USDT 合约内嵌 ④派生地址==owner,任一不符拒签;助记词只经 PAY_SWEEP_MNEMONIC(离线机,不入 arg)。
- 测试:签名可恢复到正确地址、ABI 编码、地址解码 roundtrip 全绿。
- 联网上链部分标注需 Phase E 真链验证;README 补 sweep 用法 + 验签说明 + gas 提醒。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 04:12:04 +08:00

165 lines
4.9 KiB
Go

package tron
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/wangjia/pangolin/pay/internal/wallet"
)
// ⚠️ The online chain ops below (BuildTransfer / TRC20Balance / Broadcast) can
// only be fully validated against a live TronGrid + real funds (Phase E). The
// pure crypto (sign.go / abi.go / address.go) is unit-tested; these are not.
// UnsignedTx is the transaction object TronGrid returns from triggersmartcontract.
// raw_data is kept verbatim so it round-trips unchanged into broadcast.
type UnsignedTx struct {
TxID string `json:"txID"`
RawData json.RawMessage `json:"raw_data"`
RawDataHex string `json:"raw_data_hex"`
Visible bool `json:"visible"`
}
// SignedTx is an UnsignedTx with the signature attached, ready to broadcast.
type SignedTx struct {
TxID string `json:"txID"`
RawData json.RawMessage `json:"raw_data"`
RawDataHex string `json:"raw_data_hex"`
Visible bool `json:"visible"`
Signature []string `json:"signature"`
}
func (c *Client) postJSON(ctx context.Context, path string, body, out any) error {
buf, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+path, bytes.NewReader(buf))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if c.apiKey != "" {
req.Header.Set("TRON-PRO-API-KEY", c.apiKey)
}
resp, err := c.hc.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("tron: %s status %d", path, resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(out)
}
// BuildTransfer asks TronGrid to construct an unsigned TRC20 transfer of amount
// (micro-USDT) from ownerAddr to toAddr. No private key involved — safe online.
// feeLimit is in sun (1e-6 TRX); ~30 TRX (30_000_000) is a safe cap for a TRC20
// transfer.
func (c *Client) BuildTransfer(ctx context.Context, ownerAddr, toAddr string, amount, feeLimit int64) (*UnsignedTx, error) {
ownerBody, err := wallet.DecodeTronAddress(ownerAddr)
if err != nil {
return nil, err
}
contractBody, err := wallet.DecodeTronAddress(c.usdtContract)
if err != nil {
return nil, err
}
param, err := ABIEncodeTransferParams(toAddr, amount)
if err != nil {
return nil, err
}
reqBody := map[string]any{
"owner_address": hex.EncodeToString(ownerBody),
"contract_address": hex.EncodeToString(contractBody),
"function_selector": TransferSelector,
"parameter": param,
"fee_limit": feeLimit,
"call_value": 0,
"visible": false,
}
var resp struct {
Transaction UnsignedTx `json:"transaction"`
Result struct {
Result bool `json:"result"`
Code string `json:"code"`
Message string `json:"message"`
} `json:"result"`
}
if err := c.postJSON(ctx, "/wallet/triggersmartcontract", reqBody, &resp); err != nil {
return nil, err
}
if resp.Transaction.RawDataHex == "" {
return nil, fmt.Errorf("tron: build transfer failed: %s %s", resp.Result.Code, decodeHexMessage(resp.Result.Message))
}
return &resp.Transaction, nil
}
// TRC20Balance returns the address's USDT balance in micro-USDT.
func (c *Client) TRC20Balance(ctx context.Context, addr string) (int64, error) {
u := fmt.Sprintf("/v1/accounts/%s", addr)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+u, nil)
if err != nil {
return 0, err
}
if c.apiKey != "" {
req.Header.Set("TRON-PRO-API-KEY", c.apiKey)
}
resp, err := c.hc.Do(req)
if err != nil {
return 0, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("tron: account status %d", resp.StatusCode)
}
var body struct {
Data []struct {
TRC20 []map[string]string `json:"trc20"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return 0, err
}
if len(body.Data) == 0 {
return 0, nil
}
for _, m := range body.Data[0].TRC20 {
if v, ok := m[c.usdtContract]; ok {
return strconv.ParseInt(v, 10, 64)
}
}
return 0, nil
}
// Broadcast submits a signed transaction. Returns the on-chain txid on success.
func (c *Client) Broadcast(ctx context.Context, tx *SignedTx) (string, error) {
var resp struct {
Result bool `json:"result"`
Txid string `json:"txid"`
Code string `json:"code"`
Message string `json:"message"`
}
if err := c.postJSON(ctx, "/wallet/broadcasttransaction", tx, &resp); err != nil {
return "", err
}
if !resp.Result {
return "", fmt.Errorf("tron: broadcast rejected: %s %s", resp.Code, decodeHexMessage(resp.Message))
}
return resp.Txid, nil
}
// decodeHexMessage best-effort decodes TronGrid's hex-encoded error messages.
func decodeHexMessage(s string) string {
if b, err := hex.DecodeString(s); err == nil && len(b) > 0 {
return string(b)
}
return s
}