283 lines
8.7 KiB
Go
283 lines
8.7 KiB
Go
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 ""
|
|
}
|
|
}
|