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 }