package binancerecharge import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" ) const ( defaultBinanceBaseURL = "https://api.binance.com" binanceUSDT = "USDT" ) type HTTPBinanceVerifier struct { baseURL string httpClient *http.Client } func NewHTTPBinanceVerifier(baseURL string, timeout time.Duration) *HTTPBinanceVerifier { baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") if baseURL == "" { baseURL = defaultBinanceBaseURL } if timeout <= 0 { timeout = 10 * time.Second } return &HTTPBinanceVerifier{ baseURL: baseURL, httpClient: &http.Client{ Timeout: timeout, }, } } func (v *HTTPBinanceVerifier) Verify(ctx context.Context, credential BinanceCredential, query BinanceVerifyQuery) (VerifiedTransaction, error) { if v == nil { return VerifiedTransaction{}, NewAppError(http.StatusServiceUnavailable, "binance_verifier_unavailable", "binance verifier is unavailable") } switch query.TransferType { case TransferTypeBinancePay: return v.verifyPayTransaction(ctx, credential, query) case TransferTypeChain: return v.verifyChainDeposit(ctx, credential, query) default: return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "invalid_transfer_type", "transferType must be BINANCE_PAY or CHAIN") } } func (v *HTTPBinanceVerifier) verifyPayTransaction(ctx context.Context, credential BinanceCredential, query BinanceVerifyQuery) (VerifiedTransaction, error) { now := time.Now() values := url.Values{} values.Set("startTime", fmt.Sprintf("%d", now.AddDate(0, 0, -90).UnixMilli())) values.Set("endTime", fmt.Sprintf("%d", now.UnixMilli())) values.Set("limit", "100") values.Set("recvWindow", "5000") raw, err := v.signedGet(ctx, credential, "/sapi/v1/pay/transactions", values) if err != nil { return VerifiedTransaction{}, err } var resp struct { Code string `json:"code"` Message string `json:"message"` Data []json.RawMessage `json:"data"` Success *bool `json:"success"` } if err := json.Unmarshal(raw, &resp); err != nil { return VerifiedTransaction{}, NewAppError(http.StatusBadGateway, "binance_response_invalid", err.Error()) } if resp.Success != nil && !*resp.Success { message := strings.TrimSpace(resp.Message) if message == "" { message = "binance pay query failed" } return VerifiedTransaction{}, NewAppError(http.StatusBadGateway, "binance_query_failed", message) } foundBill := false for _, item := range resp.Data { var record map[string]any if err := json.Unmarshal(item, &record); err != nil { continue } if !binancePayRecordMatchesBill(record, query.BillNo) { continue } foundBill = true if !binancePayRecordMatchesAmount(record, query.Amount) { return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_amount_mismatch", "binance transaction amount does not match") } return VerifiedTransaction{ TransactionID: firstString(record, "transactionId", "orderId", "merchantTradeNo"), OrderType: firstString(record, "orderType"), Amount: query.Amount, RawJSON: string(item), }, nil } if foundBill { return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_amount_mismatch", "binance transaction amount does not match") } return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_transaction_not_found", "binance transaction was not found") } func (v *HTTPBinanceVerifier) verifyChainDeposit(ctx context.Context, credential BinanceCredential, query BinanceVerifyQuery) (VerifiedTransaction, error) { now := time.Now() values := url.Values{} values.Set("coin", binanceUSDT) values.Set("status", "1") values.Set("txId", query.BillNo) values.Set("startTime", fmt.Sprintf("%d", now.AddDate(0, 0, -90).UnixMilli())) values.Set("endTime", fmt.Sprintf("%d", now.UnixMilli())) values.Set("limit", "1000") values.Set("recvWindow", "5000") raw, err := v.signedGet(ctx, credential, "/sapi/v1/capital/deposit/hisrec", values) if err != nil { return VerifiedTransaction{}, err } var rows []json.RawMessage if err := json.Unmarshal(raw, &rows); err != nil { return VerifiedTransaction{}, NewAppError(http.StatusBadGateway, "binance_response_invalid", err.Error()) } foundBill := false for _, item := range rows { var record map[string]any if err := json.Unmarshal(item, &record); err != nil { continue } if !chainDepositRecordMatchesBill(record, query.BillNo) { continue } foundBill = true if !strings.EqualFold(firstString(record, "coin"), binanceUSDT) { continue } if !chainDepositStatusSuccess(record) { continue } if !decimalEqual(firstString(record, "amount"), query.Amount) { return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_amount_mismatch", "binance deposit amount does not match") } return VerifiedTransaction{ TransactionID: firstString(record, "txId", "id"), OrderType: "CHAIN_DEPOSIT", Amount: query.Amount, RawJSON: string(item), }, nil } if foundBill { return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_amount_mismatch", "binance deposit amount does not match") } return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_transaction_not_found", "binance deposit was not found") } func (v *HTTPBinanceVerifier) signedGet(ctx context.Context, credential BinanceCredential, path string, values url.Values) ([]byte, error) { apiKey := strings.TrimSpace(credential.APIKey) apiSecret := strings.TrimSpace(credential.APISecret) if apiKey == "" || apiSecret == "" { return nil, NewAppError(http.StatusServiceUnavailable, "binance_credentials_missing", "binance api key and secret are required") } values.Set("timestamp", fmt.Sprintf("%d", time.Now().UnixMilli())) query := values.Encode() signature := signBinanceQuery(apiSecret, query) endpoint := v.baseURL + path + "?" + query + "&signature=" + url.QueryEscape(signature) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, NewAppError(http.StatusBadGateway, "binance_request_failed", err.Error()) } req.Header.Set("X-MBX-APIKEY", apiKey) resp, err := v.httpClient.Do(req) if err != nil { return nil, NewAppError(http.StatusBadGateway, "binance_request_failed", err.Error()) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, NewAppError(http.StatusBadGateway, "binance_response_read_failed", err.Error()) } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { return nil, NewAppError(http.StatusBadGateway, "binance_query_failed", strings.TrimSpace(string(body))) } return body, nil } func signBinanceQuery(secret string, query string) string { mac := hmac.New(sha256.New, []byte(secret)) _, _ = mac.Write([]byte(query)) return hex.EncodeToString(mac.Sum(nil)) } func binancePayRecordMatchesBill(record map[string]any, billNo string) bool { billNo = strings.TrimSpace(billNo) for _, key := range []string{"transactionId", "orderId", "merchantTradeNo", "prepayId", "id"} { if strings.EqualFold(firstString(record, key), billNo) { return true } } return false } func binancePayRecordMatchesAmount(record map[string]any, expected string) bool { if strings.EqualFold(firstString(record, "currency"), binanceUSDT) && decimalEqual(firstString(record, "amount"), expected) { return true } details, ok := record["fundsDetail"].([]any) if !ok { return false } for _, item := range details { detail, ok := item.(map[string]any) if !ok { continue } if strings.EqualFold(firstString(detail, "currency"), binanceUSDT) && decimalEqual(firstString(detail, "amount"), expected) { return true } } return false } func chainDepositRecordMatchesBill(record map[string]any, billNo string) bool { billNo = strings.TrimSpace(billNo) for _, key := range []string{"txId", "id"} { if strings.EqualFold(firstString(record, key), billNo) { return true } } return false } func chainDepositStatusSuccess(record map[string]any) bool { switch value := record["status"].(type) { case float64: return int(value) == 1 case int: return value == 1 case string: return strings.TrimSpace(value) == "1" default: return false } } func firstString(record map[string]any, keys ...string) string { for _, key := range keys { if value := stringValue(record[key]); strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" } func stringValue(value any) string { switch typed := value.(type) { case string: return typed case json.Number: return typed.String() case float64: return trimDecimalZeros(fmt.Sprintf("%.8f", typed)) case int: return fmt.Sprintf("%d", typed) case int64: return fmt.Sprintf("%d", typed) default: return "" } }