361 lines
12 KiB
Go
361 lines
12 KiB
Go
package binance
|
||
|
||
import (
|
||
"context"
|
||
"crypto/hmac"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"hyapp/pkg/xerr"
|
||
"hyapp/services/wallet-service/internal/config"
|
||
"hyapp/services/wallet-service/internal/service/wallet/ports"
|
||
)
|
||
|
||
const (
|
||
defaultDepositLookback = 90 * 24 * time.Hour
|
||
depositHistoryLimit = 1000
|
||
)
|
||
|
||
// Client 是 Binance 只读查单适配器;它只使用 signed read API,不保存、不生成任何链上私钥。
|
||
type Client struct {
|
||
httpClient *http.Client
|
||
apiBaseURL string
|
||
recvWindow int64
|
||
accounts map[string]account
|
||
}
|
||
|
||
type account struct {
|
||
apiKey string
|
||
apiSecretKey string
|
||
}
|
||
|
||
// New 从 wallet-service 配置创建 Binance client;具体 App 是否有账号在请求时按 appCode 决定。
|
||
func New(cfg config.BinanceConfig) *Client {
|
||
timeout := cfg.HTTPTimeout
|
||
if timeout <= 0 {
|
||
timeout = 10 * time.Second
|
||
}
|
||
return &Client{
|
||
httpClient: &http.Client{Timeout: timeout},
|
||
apiBaseURL: strings.TrimRight(strings.TrimSpace(firstNonEmpty(cfg.APIBaseURL, "https://api.binance.com")), "/"),
|
||
recvWindow: firstPositiveInt64(cfg.RecvWindow, 10000),
|
||
accounts: map[string]account{
|
||
"lalu": newAccount(cfg.Lalu),
|
||
"aslan": newAccount(cfg.Aslan),
|
||
"yumi": newAccount(cfg.Yumi),
|
||
},
|
||
}
|
||
}
|
||
|
||
func newAccount(cfg config.BinanceAccountConfig) account {
|
||
return account{
|
||
apiKey: strings.TrimSpace(cfg.APIKey),
|
||
apiSecretKey: strings.TrimSpace(cfg.APISecretKey),
|
||
}
|
||
}
|
||
|
||
// FindUSDTDeposit 查询 Binance deposit history,并按后台订单输入校验币种、网络、状态、地址和金额。
|
||
func (c *Client) FindUSDTDeposit(ctx context.Context, req ports.BinanceDepositLookupRequest) (ports.BinanceDepositRecord, error) {
|
||
if c == nil || c.apiBaseURL == "" {
|
||
return ports.BinanceDepositRecord{}, xerr.New(xerr.Unavailable, "binance client is not configured")
|
||
}
|
||
account, ok := c.accountForApp(req.AppCode)
|
||
if !ok {
|
||
return ports.BinanceDepositRecord{}, xerr.New(xerr.Unavailable, "binance account is not configured")
|
||
}
|
||
req.ExternalID = strings.TrimSpace(req.ExternalID)
|
||
req.Coin = strings.ToUpper(strings.TrimSpace(req.Coin))
|
||
req.Network = strings.ToUpper(strings.TrimSpace(req.Network))
|
||
req.ToAddress = strings.TrimSpace(req.ToAddress)
|
||
if req.ExternalID == "" || req.Coin == "" || req.Network == "" || req.ToAddress == "" || req.AmountMinor <= 0 {
|
||
return ports.BinanceDepositRecord{}, xerr.New(xerr.InvalidArgument, "binance deposit verification request is incomplete")
|
||
}
|
||
serverTimeMS, err := c.serverTime(ctx)
|
||
if err != nil {
|
||
return ports.BinanceDepositRecord{}, err
|
||
}
|
||
// Binance 的 deposit history 查询窗口有限;后台凭证校验只做近期有界查询,避免一次手工校验变成无界扫描。
|
||
records, err := c.depositHistory(ctx, account, req.Coin, serverTimeMS-int64(defaultDepositLookback/time.Millisecond), serverTimeMS)
|
||
if err != nil {
|
||
return ports.BinanceDepositRecord{}, err
|
||
}
|
||
return matchDepositRecord(req, records)
|
||
}
|
||
|
||
func (c *Client) accountForApp(appCode string) (account, bool) {
|
||
value, ok := c.accounts[strings.ToLower(strings.TrimSpace(appCode))]
|
||
if !ok || strings.TrimSpace(value.apiKey) == "" || strings.TrimSpace(value.apiSecretKey) == "" {
|
||
return account{}, false
|
||
}
|
||
return value, true
|
||
}
|
||
|
||
func (c *Client) serverTime(ctx context.Context) (int64, error) {
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.apiBaseURL+"/api/v3/time", nil)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
body, err := c.do(req)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
var parsed struct {
|
||
ServerTime int64 `json:"serverTime"`
|
||
}
|
||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||
return 0, err
|
||
}
|
||
if parsed.ServerTime <= 0 {
|
||
return 0, xerr.New(xerr.Unavailable, "binance server time is invalid")
|
||
}
|
||
return parsed.ServerTime, nil
|
||
}
|
||
|
||
func (c *Client) depositHistory(ctx context.Context, account account, coin string, startTimeMS int64, endTimeMS int64) ([]depositRecord, error) {
|
||
query := url.Values{}
|
||
query.Set("coin", strings.ToUpper(strings.TrimSpace(coin)))
|
||
query.Set("startTime", strconv.FormatInt(startTimeMS, 10))
|
||
query.Set("endTime", strconv.FormatInt(endTimeMS, 10))
|
||
query.Set("limit", strconv.Itoa(depositHistoryLimit))
|
||
body, err := c.signedGET(ctx, account, "/sapi/v1/capital/deposit/hisrec", query, endTimeMS)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var raws []json.RawMessage
|
||
if err := json.Unmarshal(body, &raws); err != nil {
|
||
return nil, err
|
||
}
|
||
records := make([]depositRecord, 0, len(raws))
|
||
for _, raw := range raws {
|
||
var wire depositRecordWire
|
||
if err := json.Unmarshal(raw, &wire); err != nil {
|
||
return nil, err
|
||
}
|
||
record := wire.toRecord()
|
||
record.RawJSON = string(raw)
|
||
records = append(records, record)
|
||
}
|
||
return records, nil
|
||
}
|
||
|
||
func (c *Client) signedGET(ctx context.Context, account account, path string, values url.Values, timestampMS int64) ([]byte, error) {
|
||
values.Set("recvWindow", strconv.FormatInt(c.recvWindow, 10))
|
||
values.Set("timestamp", strconv.FormatInt(timestampMS, 10))
|
||
queryString := values.Encode()
|
||
signature := signQueryString(queryString, account.apiSecretKey)
|
||
endpoint := c.apiBaseURL + path + "?" + queryString + "&signature=" + signature
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.Header.Set("X-MBX-APIKEY", account.apiKey)
|
||
return c.do(req)
|
||
}
|
||
|
||
func (c *Client) do(req *http.Request) ([]byte, error) {
|
||
resp, err := c.httpClient.Do(req)
|
||
if err != nil {
|
||
return nil, xerr.New(xerr.Unavailable, "binance 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, xerr.New(xerr.Unavailable, "binance returned non-success")
|
||
}
|
||
return body, nil
|
||
}
|
||
|
||
func matchDepositRecord(req ports.BinanceDepositLookupRequest, records []depositRecord) (ports.BinanceDepositRecord, error) {
|
||
var candidate *depositRecord
|
||
for index := range records {
|
||
if depositTxIDMatches(records[index].TxID, req.ExternalID) {
|
||
candidate = &records[index]
|
||
break
|
||
}
|
||
}
|
||
if candidate == nil {
|
||
return ports.BinanceDepositRecord{}, xerr.New(xerr.NotFound, "binance deposit not found")
|
||
}
|
||
if !strings.EqualFold(candidate.Coin, req.Coin) {
|
||
return ports.BinanceDepositRecord{}, xerr.New(xerr.Conflict, "binance deposit coin mismatch")
|
||
}
|
||
if !strings.EqualFold(candidate.Network, req.Network) {
|
||
return ports.BinanceDepositRecord{}, xerr.New(xerr.Conflict, "binance deposit network mismatch")
|
||
}
|
||
if candidate.Status != 1 {
|
||
return ports.BinanceDepositRecord{}, xerr.New(xerr.Conflict, "binance deposit status is not successful")
|
||
}
|
||
if !strings.EqualFold(strings.TrimSpace(candidate.Address), strings.TrimSpace(req.ToAddress)) {
|
||
return ports.BinanceDepositRecord{}, xerr.New(xerr.Conflict, "binance deposit address mismatch")
|
||
}
|
||
amountMinor, err := parseUSDTAmountMinor(candidate.Amount)
|
||
if err != nil {
|
||
return ports.BinanceDepositRecord{}, err
|
||
}
|
||
if amountMinor != req.AmountMinor {
|
||
return ports.BinanceDepositRecord{}, xerr.New(xerr.Conflict, "binance deposit amount mismatch")
|
||
}
|
||
return ports.BinanceDepositRecord{
|
||
ID: candidate.ID,
|
||
TxID: candidate.TxID,
|
||
Coin: candidate.Coin,
|
||
Network: candidate.Network,
|
||
Status: candidate.Status,
|
||
Address: candidate.Address,
|
||
Amount: candidate.Amount,
|
||
AmountMinor: amountMinor,
|
||
TransferType: candidate.TransferType,
|
||
InsertTimeMS: candidate.InsertTimeMS,
|
||
CompleteTimeMS: candidate.CompleteTimeMS,
|
||
ConfirmTimes: candidate.ConfirmTimes,
|
||
UnlockConfirm: candidate.UnlockConfirm,
|
||
WalletType: candidate.WalletType,
|
||
RawJSON: strings.TrimSpace(candidate.RawJSON),
|
||
}, nil
|
||
}
|
||
|
||
func depositTxIDMatches(txID string, externalID string) bool {
|
||
normalizedExternalID := normalizeOffchainExternalID(externalID)
|
||
if normalizedExternalID == "" {
|
||
return false
|
||
}
|
||
normalizedTxID := strings.ToLower(strings.TrimSpace(txID))
|
||
return strings.Contains(normalizedTxID, normalizedExternalID) || normalizeOffchainExternalID(txID) == normalizedExternalID
|
||
}
|
||
|
||
func normalizeOffchainExternalID(value string) string {
|
||
value = strings.ToLower(strings.TrimSpace(value))
|
||
value = strings.TrimPrefix(value, "off-chain transfer")
|
||
value = strings.TrimSpace(strings.Trim(value, ":#"))
|
||
return value
|
||
}
|
||
|
||
func parseUSDTAmountMinor(value string) (int64, error) {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" || strings.HasPrefix(value, "-") {
|
||
return 0, xerr.New(xerr.InvalidArgument, "binance deposit amount is invalid")
|
||
}
|
||
parts := strings.Split(value, ".")
|
||
if len(parts) > 2 {
|
||
return 0, xerr.New(xerr.InvalidArgument, "binance deposit amount is invalid")
|
||
}
|
||
whole, err := strconv.ParseInt(firstNonEmpty(parts[0], "0"), 10, 64)
|
||
if err != nil {
|
||
return 0, xerr.New(xerr.InvalidArgument, "binance deposit amount is invalid")
|
||
}
|
||
frac := ""
|
||
if len(parts) == 2 {
|
||
frac = parts[1]
|
||
}
|
||
if len(frac) > 2 && strings.Trim(frac[2:], "0") != "" {
|
||
return 0, xerr.New(xerr.Conflict, "binance deposit amount has sub-cent precision")
|
||
}
|
||
if len(frac) > 2 {
|
||
frac = frac[:2]
|
||
}
|
||
for len(frac) < 2 {
|
||
frac += "0"
|
||
}
|
||
minorFrac, err := strconv.ParseInt(firstNonEmpty(frac, "0"), 10, 64)
|
||
if err != nil {
|
||
return 0, xerr.New(xerr.InvalidArgument, "binance deposit amount is invalid")
|
||
}
|
||
return whole*100 + minorFrac, nil
|
||
}
|
||
|
||
func signQueryString(queryString string, secret string) string {
|
||
mac := hmac.New(sha256.New, []byte(secret))
|
||
_, _ = mac.Write([]byte(queryString))
|
||
return hex.EncodeToString(mac.Sum(nil))
|
||
}
|
||
|
||
func firstNonEmpty(values ...string) string {
|
||
for _, value := range values {
|
||
if strings.TrimSpace(value) != "" {
|
||
return strings.TrimSpace(value)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func firstPositiveInt64(values ...int64) int64 {
|
||
for _, value := range values {
|
||
if value > 0 {
|
||
return value
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
type depositRecord struct {
|
||
ID string
|
||
Amount string
|
||
Coin string
|
||
Network string
|
||
Status int64
|
||
Address string
|
||
TxID string
|
||
TransferType int64
|
||
InsertTimeMS int64
|
||
CompleteTimeMS int64
|
||
ConfirmTimes string
|
||
UnlockConfirm int64
|
||
WalletType int64
|
||
RawJSON string
|
||
}
|
||
|
||
type depositRecordWire struct {
|
||
ID json.RawMessage `json:"id"`
|
||
Amount string `json:"amount"`
|
||
Coin string `json:"coin"`
|
||
Network string `json:"network"`
|
||
Status int64 `json:"status"`
|
||
Address string `json:"address"`
|
||
TxID string `json:"txId"`
|
||
TransferType int64 `json:"transferType"`
|
||
InsertTimeMS int64 `json:"insertTime"`
|
||
CompleteTimeMS int64 `json:"completeTime"`
|
||
ConfirmTimes string `json:"confirmTimes"`
|
||
UnlockConfirm int64 `json:"unlockConfirm"`
|
||
WalletType int64 `json:"walletType"`
|
||
}
|
||
|
||
func (wire depositRecordWire) toRecord() depositRecord {
|
||
return depositRecord{
|
||
ID: rawScalarString(wire.ID),
|
||
Amount: strings.TrimSpace(wire.Amount),
|
||
Coin: strings.ToUpper(strings.TrimSpace(wire.Coin)),
|
||
Network: strings.ToUpper(strings.TrimSpace(wire.Network)),
|
||
Status: wire.Status,
|
||
Address: strings.TrimSpace(wire.Address),
|
||
TxID: strings.TrimSpace(wire.TxID),
|
||
TransferType: wire.TransferType,
|
||
InsertTimeMS: wire.InsertTimeMS,
|
||
CompleteTimeMS: wire.CompleteTimeMS,
|
||
ConfirmTimes: strings.TrimSpace(wire.ConfirmTimes),
|
||
UnlockConfirm: wire.UnlockConfirm,
|
||
WalletType: wire.WalletType,
|
||
}
|
||
}
|
||
|
||
func rawScalarString(raw json.RawMessage) string {
|
||
if len(raw) == 0 || string(raw) == "null" {
|
||
return ""
|
||
}
|
||
var text string
|
||
if err := json.Unmarshal(raw, &text); err == nil {
|
||
return strings.TrimSpace(text)
|
||
}
|
||
return strings.TrimSpace(string(raw))
|
||
}
|