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"` }