2026-07-07 18:05:02 +08:00

179 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package bscusdt
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"io"
"math/big"
"net/http"
"strings"
"time"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/config"
)
const transferEventTopic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
// Client 通过 BSC JSON-RPC 校验 USDT-BEP20 Transfer 事件;它只读取公开 receipt不触碰私钥。
type Client struct {
httpClient *http.Client
rpcURL string
contractAddress string
}
// New 创建 BSC USDT 只读客户端。RPCURL 可以是自建节点、BscScan proxy 或兼容网关。
func New(cfg config.BSCUSDTConfig) *Client {
timeout := cfg.HTTPTimeout
if timeout <= 0 {
timeout = 10 * time.Second
}
return &Client{
httpClient: &http.Client{Timeout: timeout},
rpcURL: strings.TrimSpace(cfg.RPCURL),
contractAddress: normalizeEVMAddress(cfg.USDTContractAddress),
}
}
// VerifyUSDTTransfer 校验 tx_hash 已成功执行,且 USDT Transfer 足额转入平台共享地址。
func (c *Client) VerifyUSDTTransfer(ctx context.Context, txHash string, toAddress string, amountMinor int64) (string, error) {
if c == nil || c.rpcURL == "" {
return "", xerr.New(xerr.Unavailable, "bsc rpc client is not configured")
}
txHash = strings.TrimSpace(txHash)
toAddress = normalizeEVMAddress(toAddress)
if txHash == "" || toAddress == "" || amountMinor <= 0 {
return "", xerr.New(xerr.InvalidArgument, "bsc usdt transfer verification request is incomplete")
}
// 订单金额以 USD cent 保存USDT 使用 6 位精度1 cent = 10,000 atomic units。
requiredAtomicAmount := big.NewInt(amountMinor)
requiredAtomicAmount.Mul(requiredAtomicAmount, big.NewInt(10_000))
receipt, rawJSON, err := c.transactionReceipt(ctx, txHash)
if err != nil {
return rawJSON, err
}
if !strings.EqualFold(strings.TrimSpace(receipt.Status), "0x1") {
return rawJSON, xerr.New(xerr.Conflict, "bsc transaction is not successful")
}
for _, log := range receipt.Logs {
if !strings.EqualFold(normalizeEVMAddress(log.Address), c.contractAddress) {
continue
}
if len(log.Topics) < 3 || !strings.EqualFold(strings.TrimSpace(log.Topics[0]), transferEventTopic) {
continue
}
if topicAddress(log.Topics[2]) != toAddress {
continue
}
amount, ok := parseHexBigInt(log.Data)
if !ok || amount.Cmp(requiredAtomicAmount) < 0 {
continue
}
return rawJSON, nil
}
return rawJSON, xerr.New(xerr.Conflict, "bsc usdt transfer does not match order")
}
func (c *Client) transactionReceipt(ctx context.Context, txHash string) (ethReceipt, string, error) {
body, err := json.Marshal(jsonRPCRequest{
JSONRPC: "2.0",
ID: 1,
Method: "eth_getTransactionReceipt",
Params: []string{txHash},
})
if err != nil {
return ethReceipt{}, "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.rpcURL, bytes.NewReader(body))
if err != nil {
return ethReceipt{}, "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return ethReceipt{}, "", xerr.New(xerr.Unavailable, "bsc rpc request failed")
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
return ethReceipt{}, "", err
}
rawJSON := string(raw)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return ethReceipt{}, rawJSON, xerr.New(xerr.Unavailable, "bsc rpc returned non-success")
}
var parsed struct {
Result *ethReceipt `json:"result"`
Error *struct {
Code int64 `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return ethReceipt{}, rawJSON, err
}
if parsed.Error != nil {
return ethReceipt{}, rawJSON, xerr.New(xerr.Unavailable, "bsc rpc error: "+strings.TrimSpace(parsed.Error.Message))
}
if parsed.Result == nil {
return ethReceipt{}, rawJSON, xerr.New(xerr.Conflict, "bsc transaction receipt not found")
}
return *parsed.Result, rawJSON, nil
}
func normalizeEVMAddress(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if strings.HasPrefix(value, "0x") && len(value) == 42 {
return value
}
return ""
}
func topicAddress(topic string) string {
topic = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(topic)), "0x")
if len(topic) < 40 {
return ""
}
return "0x" + topic[len(topic)-40:]
}
func parseHexBigInt(value string) (*big.Int, bool) {
value = strings.TrimPrefix(strings.TrimSpace(value), "0x")
if value == "" {
return nil, false
}
if _, err := hex.DecodeString(padEvenHex(value)); err != nil {
return nil, false
}
amount := new(big.Int)
if _, ok := amount.SetString(value, 16); !ok {
return nil, false
}
return amount, true
}
func padEvenHex(value string) string {
if len(value)%2 == 0 {
return value
}
return "0" + value
}
type jsonRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id"`
Method string `json:"method"`
Params []string `json:"params"`
}
type ethReceipt struct {
Status string `json:"status"`
Logs []struct {
Address string `json:"address"`
Topics []string `json:"topics"`
Data string `json:"data"`
} `json:"logs"`
}