7cf87a764f
动钱代码,按"联网建/广播 + 离线签"气隙流程,离线签名独立验签防篡改: - 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>
165 lines
4.9 KiB
Go
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
|
|
}
|