179 lines
5.1 KiB
Go
179 lines
5.1 KiB
Go
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"`
|
||
}
|