186 lines
6.0 KiB
Go
186 lines
6.0 KiB
Go
package trongrid
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"io"
|
||
"math/big"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"hyapp/pkg/xerr"
|
||
"hyapp/services/wallet-service/internal/config"
|
||
)
|
||
|
||
// Client 校验用户提交的 USDT-TRC20 tx_hash;它只读链上公开数据,不保存也不接触任何私钥。
|
||
type Client struct {
|
||
httpClient *http.Client
|
||
apiBaseURL string
|
||
apiKey string
|
||
contractAddress string
|
||
}
|
||
|
||
// New 创建 TronGrid 兼容客户端;base_url 可以指向官方 TronGrid、自建节点代理或兼容网关。
|
||
func New(cfg config.TronGridConfig) *Client {
|
||
timeout := cfg.HTTPTimeout
|
||
if timeout <= 0 {
|
||
timeout = 10 * time.Second
|
||
}
|
||
return &Client{
|
||
httpClient: &http.Client{Timeout: timeout},
|
||
apiBaseURL: strings.TrimRight(strings.TrimSpace(cfg.APIBaseURL), "/"),
|
||
apiKey: strings.TrimSpace(cfg.APIKey),
|
||
// Base58Check 区分大小写;保存原始形式,比较时再统一解码为账户 Hex。
|
||
contractAddress: strings.TrimSpace(cfg.USDTContractAddress),
|
||
}
|
||
}
|
||
|
||
// VerifyUSDTTransfer 校验 tx_hash 指向的 confirmed Transfer 是否把足额 USDT 打到平台共享地址。
|
||
func (c *Client) VerifyUSDTTransfer(ctx context.Context, txHash string, toAddress string, amountMinor int64) (string, error) {
|
||
if c == nil || c.apiBaseURL == "" {
|
||
return "", xerr.New(xerr.Unavailable, "tron grid client is not configured")
|
||
}
|
||
txHash = strings.TrimSpace(txHash)
|
||
if txHash == "" || toAddress == "" || amountMinor <= 0 {
|
||
return "", xerr.New(xerr.InvalidArgument, "usdt transfer verification request is incomplete")
|
||
}
|
||
expectedAddress, ok := normalizeTronAddress(toAddress)
|
||
if !ok {
|
||
return "", xerr.New(xerr.InvalidArgument, "tron receive address is invalid")
|
||
}
|
||
// H5 商品金额以 USD cent 保存,USDT 使用 6 位小数;1 cent = 0.01 USDT = 10,000 atomic units。
|
||
requiredAtomicAmount := amountMinor * 10_000
|
||
events, rawJSON, err := c.listTransferEvents(ctx, txHash)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
for _, event := range events {
|
||
if !strings.EqualFold(event.EventName, "Transfer") {
|
||
continue
|
||
}
|
||
if !c.contractMatches(event.ContractAddress) {
|
||
continue
|
||
}
|
||
// TronGrid 的 event result.to 可能返回 0x/41 Hex,而后台保存的是 Base58Check。
|
||
// 两端先归一为去除 TRON 网络前缀的 20-byte Hex,避免同一地址因编码形式不同被误判。
|
||
eventAddress, validAddress := normalizeTronAddress(event.Result.To)
|
||
if !validAddress || eventAddress != expectedAddress {
|
||
continue
|
||
}
|
||
value, err := strconv.ParseInt(strings.TrimSpace(event.Result.Value), 10, 64)
|
||
if err != nil || value < requiredAtomicAmount {
|
||
continue
|
||
}
|
||
return rawJSON, nil
|
||
}
|
||
return rawJSON, xerr.New(xerr.Conflict, "usdt transfer is not confirmed or does not match order")
|
||
}
|
||
|
||
func (c *Client) listTransferEvents(ctx context.Context, txHash string) ([]transferEvent, string, error) {
|
||
endpoint := c.apiBaseURL + "/v1/transactions/" + url.PathEscape(txHash) + "/events?event_name=Transfer&only_confirmed=true&limit=50"
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
if c.apiKey != "" {
|
||
req.Header.Set("TRON-PRO-API-KEY", c.apiKey)
|
||
}
|
||
resp, err := c.httpClient.Do(req)
|
||
if err != nil {
|
||
return nil, "", xerr.New(xerr.Unavailable, "tron grid request failed")
|
||
}
|
||
defer resp.Body.Close()
|
||
body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return nil, string(body), xerr.New(xerr.Unavailable, "tron grid returned non-success")
|
||
}
|
||
var parsed struct {
|
||
Data []transferEvent `json:"data"`
|
||
}
|
||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||
return nil, string(body), err
|
||
}
|
||
return parsed.Data, string(body), nil
|
||
}
|
||
|
||
func (c *Client) contractMatches(value string) bool {
|
||
configured, configuredOK := normalizeTronAddress(c.contractAddress)
|
||
actual, actualOK := normalizeTronAddress(value)
|
||
return configuredOK && actualOK && configured == actual
|
||
}
|
||
|
||
const tronBase58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
||
|
||
// normalizeTronAddress 接受后台常用的 Base58Check,以及 TronGrid 可能返回的
|
||
// 0x + 20-byte、41 + 20-byte 或裸 20-byte Hex,统一输出账户主体的 40 位小写 Hex。
|
||
// Base58 输入必须通过网络前缀和双 SHA-256 checksum 校验,避免把格式正确但已损坏的地址用于放币判断。
|
||
func normalizeTronAddress(value string) (string, bool) {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return "", false
|
||
}
|
||
hexValue := strings.TrimPrefix(strings.ToLower(value), "0x")
|
||
if len(hexValue) == 42 && strings.HasPrefix(hexValue, "41") {
|
||
hexValue = hexValue[2:]
|
||
}
|
||
if len(hexValue) == 40 {
|
||
if _, err := hex.DecodeString(hexValue); err == nil {
|
||
return hexValue, true
|
||
}
|
||
}
|
||
payload, ok := decodeTronBase58Check(value)
|
||
if !ok || len(payload) != 21 || payload[0] != 0x41 {
|
||
return "", false
|
||
}
|
||
return hex.EncodeToString(payload[1:]), true
|
||
}
|
||
|
||
func decodeTronBase58Check(value string) ([]byte, bool) {
|
||
number := new(big.Int)
|
||
base := big.NewInt(58)
|
||
for _, char := range value {
|
||
index := strings.IndexRune(tronBase58Alphabet, char)
|
||
if index < 0 {
|
||
return nil, false
|
||
}
|
||
number.Mul(number, base)
|
||
number.Add(number, big.NewInt(int64(index)))
|
||
}
|
||
decoded := number.Bytes()
|
||
leadingZeroes := 0
|
||
for leadingZeroes < len(value) && value[leadingZeroes] == '1' {
|
||
leadingZeroes++
|
||
}
|
||
if leadingZeroes > 0 {
|
||
decoded = append(make([]byte, leadingZeroes), decoded...)
|
||
}
|
||
if len(decoded) < 5 {
|
||
return nil, false
|
||
}
|
||
payload, checksum := decoded[:len(decoded)-4], decoded[len(decoded)-4:]
|
||
firstHash := sha256.Sum256(payload)
|
||
secondHash := sha256.Sum256(firstHash[:])
|
||
if !bytes.Equal(checksum, secondHash[:4]) {
|
||
return nil, false
|
||
}
|
||
return payload, true
|
||
}
|
||
|
||
type transferEvent struct {
|
||
EventName string `json:"event_name"`
|
||
ContractAddress string `json:"contract_address"`
|
||
Result struct {
|
||
To string `json:"to"`
|
||
Value string `json:"value"`
|
||
} `json:"result"`
|
||
}
|