372 lines
13 KiB
Go
372 lines
13 KiB
Go
package binance
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"hyapp/pkg/xerr"
|
|
"hyapp/services/wallet-service/internal/config"
|
|
"hyapp/services/wallet-service/internal/service/wallet/ports"
|
|
)
|
|
|
|
func TestFindUSDTTransferMatchesDepositAndSignedRequest(t *testing.T) {
|
|
var sawDepositRequest bool
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/v3/time":
|
|
_, _ = w.Write([]byte(`{"serverTime":1783345000000}`))
|
|
case "/sapi/v1/capital/deposit/hisrec":
|
|
sawDepositRequest = true
|
|
if r.Header.Get("X-MBX-APIKEY") != "api-lalu" {
|
|
t.Fatalf("X-MBX-APIKEY mismatch")
|
|
}
|
|
values := r.URL.Query()
|
|
signature := values.Get("signature")
|
|
values.Del("signature")
|
|
if signature != signQueryString(values.Encode(), "secret-lalu") {
|
|
t.Fatalf("signature mismatch: query=%s signature=%s", values.Encode(), signature)
|
|
}
|
|
if values.Get("timestamp") != "1783345000000" || values.Get("recvWindow") != "10000" || values.Get("coin") != "USDT" || values.Get("limit") != "1000" {
|
|
t.Fatalf("signed deposit query mismatch: %s", values.Encode())
|
|
}
|
|
if values.Get("endTime") != "1783345000000" || values.Get("startTime") != "1775569000000" {
|
|
t.Fatalf("deposit bounded window mismatch: %s", values.Encode())
|
|
}
|
|
_, _ = w.Write([]byte(`[{
|
|
"id":"deposit-1",
|
|
"amount":"500.00000000",
|
|
"coin":"USDT",
|
|
"network":"TRX",
|
|
"status":1,
|
|
"address":"TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN",
|
|
"txId":"Off-chain transfer 388611987194",
|
|
"transferType":1,
|
|
"insertTime":1783344000000,
|
|
"completeTime":1783344025000,
|
|
"confirmTimes":"1/1",
|
|
"unlockConfirm":0,
|
|
"walletType":0
|
|
}]`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := New(config.BinanceConfig{
|
|
Enabled: true,
|
|
APIBaseURL: server.URL,
|
|
RecvWindow: 10000,
|
|
HTTPTimeout: time.Second,
|
|
Lalu: config.BinanceAccountConfig{
|
|
APIKey: "api-lalu",
|
|
APISecretKey: "secret-lalu",
|
|
},
|
|
})
|
|
record, err := client.FindUSDTTransfer(context.Background(), ports.BinanceTransferLookupRequest{
|
|
AppCode: "lalu",
|
|
ExternalID: "388611987194",
|
|
Coin: "USDT",
|
|
Network: "TRX",
|
|
ToAddress: "TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN",
|
|
AmountMinor: 50000,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("FindUSDTTransfer failed: %v", err)
|
|
}
|
|
if !sawDepositRequest {
|
|
t.Fatalf("deposit history endpoint was not called")
|
|
}
|
|
if record.Source != "deposit_history" || record.TxID != "Off-chain transfer 388611987194" || record.AmountMinor != 50000 || record.Network != "TRX" || !strings.Contains(record.RawJSON, `"txId"`) {
|
|
t.Fatalf("deposit record mismatch: %+v", record)
|
|
}
|
|
}
|
|
|
|
func TestFindUSDTTransferRejectsDepositMismatches(t *testing.T) {
|
|
baseReq := ports.BinanceTransferLookupRequest{
|
|
AppCode: "lalu",
|
|
ExternalID: "388611987194",
|
|
Coin: "USDT",
|
|
Network: "TRX",
|
|
ToAddress: "TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN",
|
|
AmountMinor: 50000,
|
|
}
|
|
baseRecord := depositRecord{
|
|
Amount: "500",
|
|
Coin: "USDT",
|
|
Network: "TRX",
|
|
Status: 1,
|
|
Address: "TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN",
|
|
TxID: "Off-chain transfer 388611987194",
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
mutate func(*depositRecord)
|
|
message string
|
|
}{
|
|
{name: "status", mutate: func(r *depositRecord) { r.Status = 0 }, message: "status is not successful"},
|
|
{name: "network", mutate: func(r *depositRecord) { r.Network = "BSC" }, message: "network mismatch"},
|
|
{name: "address", mutate: func(r *depositRecord) { r.Address = "TDifferentAddress" }, message: "address mismatch"},
|
|
{name: "amount", mutate: func(r *depositRecord) { r.Amount = "499.99" }, message: "amount mismatch"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
record := baseRecord
|
|
tc.mutate(&record)
|
|
_, err := matchDepositRecord(baseReq, []depositRecord{record})
|
|
if !xerr.IsCode(err, xerr.Conflict) || !strings.Contains(err.Error(), tc.message) {
|
|
t.Fatalf("expected conflict %q, got %v", tc.message, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFindUSDTTransferFallsBackToIncomingPayC2C(t *testing.T) {
|
|
const (
|
|
serverTimeMS = int64(1783345000000)
|
|
orderTimeMS = int64(1779713682799)
|
|
orderID = "433527303245463552"
|
|
)
|
|
var sawPayRequest bool
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/v3/time":
|
|
_, _ = w.Write([]byte(`{"serverTime":1783345000000}`))
|
|
case "/sapi/v1/capital/deposit/hisrec":
|
|
_, _ = w.Write([]byte(`[]`))
|
|
case "/sapi/v1/pay/transactions":
|
|
sawPayRequest = true
|
|
if r.Header.Get("X-MBX-APIKEY") != "api-yumi" {
|
|
t.Fatalf("Pay API key mismatch")
|
|
}
|
|
values := r.URL.Query()
|
|
signature := values.Get("signature")
|
|
values.Del("signature")
|
|
if signature != signQueryString(values.Encode(), "secret-yumi") {
|
|
t.Fatalf("Pay signature mismatch: query=%s signature=%s", values.Encode(), signature)
|
|
}
|
|
if values.Get("startTime") != "1779710082799" || values.Get("endTime") != "1779717282799" || values.Get("limit") != "100" || values.Get("timestamp") != strconv.FormatInt(serverTimeMS, 10) {
|
|
t.Fatalf("Pay bounded query mismatch: %s", values.Encode())
|
|
}
|
|
_, _ = w.Write([]byte(`{
|
|
"code":"000000",
|
|
"message":"success",
|
|
"success":true,
|
|
"data":[{
|
|
"orderType":"C2C",
|
|
"orderId":433527303245463552,
|
|
"transactionId":"P_A223BU5M4Y171116",
|
|
"transactionTime":1783344000000,
|
|
"amount":"100.00000000",
|
|
"currency":"USDT",
|
|
"walletType":1,
|
|
"payerInfo":{"name":"must-not-persist","binanceId":"payer-id"},
|
|
"receiverInfo":{"name":"must-not-persist","binanceId":"receiver-id"}
|
|
}]
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := New(config.BinanceConfig{
|
|
Enabled: true,
|
|
APIBaseURL: server.URL,
|
|
RecvWindow: 10000,
|
|
HTTPTimeout: time.Second,
|
|
Yumi: config.BinanceAccountConfig{
|
|
APIKey: "api-yumi",
|
|
APISecretKey: "secret-yumi",
|
|
},
|
|
})
|
|
record, err := client.FindUSDTTransfer(context.Background(), ports.BinanceTransferLookupRequest{
|
|
AppCode: "yumi",
|
|
ExternalID: orderID,
|
|
Coin: "USDT",
|
|
Network: "TRX",
|
|
ToAddress: "TConfiguredChainAddress",
|
|
AmountMinor: 10000,
|
|
OrderTimeMS: orderTimeMS,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("FindUSDTTransfer Pay fallback failed: %v", err)
|
|
}
|
|
if !sawPayRequest {
|
|
t.Fatalf("Pay history endpoint was not called after deposit miss")
|
|
}
|
|
if record.Source != "pay_history" || record.ID != orderID || record.TxID != "P_A223BU5M4Y171116" || record.AmountMinor != 10000 || record.Address != "" {
|
|
t.Fatalf("Pay transfer record mismatch: %+v", record)
|
|
}
|
|
if strings.Contains(record.RawJSON, "payerInfo") || strings.Contains(record.RawJSON, "receiverInfo") || !strings.Contains(record.RawJSON, `"orderId":"`+orderID+`"`) {
|
|
t.Fatalf("Pay audit snapshot must retain evidence without account PII: %s", record.RawJSON)
|
|
}
|
|
}
|
|
|
|
func TestFindUSDTTransferC2CUsesPayAndAcceptsUSDC(t *testing.T) {
|
|
const orderID = "440537037787594752"
|
|
var sawC2C bool
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/v3/time":
|
|
_, _ = w.Write([]byte(`{"serverTime":1784018000000}`))
|
|
case "/sapi/v1/pay/transactions":
|
|
sawC2C = true
|
|
values := r.URL.Query()
|
|
signature := values.Get("signature")
|
|
values.Del("signature")
|
|
if signature != signQueryString(values.Encode(), "secret-lalu") {
|
|
t.Fatalf("C2C signature mismatch: %s", values.Encode())
|
|
}
|
|
if values.Get("limit") != "100" {
|
|
t.Fatalf("C2C query mismatch: %s", values.Encode())
|
|
}
|
|
_, _ = w.Write([]byte(`{"code":"000000","success":true,"data":[{"orderType":"C2C","orderId":"440537037787594752","transactionId":"P_A22V8RE4HQS71114","transactionTime":1782977844437,"amount":"200","currency":"USDC","walletType":1}]}`))
|
|
default:
|
|
t.Fatalf("C2C lookup must not call another history endpoint: %s", r.URL.Path)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
client := New(config.BinanceConfig{Enabled: true, APIBaseURL: server.URL, RecvWindow: 10000, HTTPTimeout: time.Second, Lalu: config.BinanceAccountConfig{APIKey: "api-lalu", APISecretKey: "secret-lalu"}})
|
|
record, err := client.FindUSDTTransfer(context.Background(), ports.BinanceTransferLookupRequest{AppCode: "lalu", ExternalID: orderID, Coin: "USDT", Network: "C2C", AmountMinor: 20000, OrderTimeMS: 1784017560000})
|
|
if err != nil {
|
|
t.Fatalf("C2C lookup failed: %v", err)
|
|
}
|
|
if !sawC2C || record.Source != "pay_history" || record.ID != orderID || record.Coin != "USDC" || record.AmountMinor != 20000 {
|
|
t.Fatalf("C2C record mismatch: %+v", record)
|
|
}
|
|
}
|
|
|
|
func TestFindUSDTTransferDoesNotBypassDepositConflictWithPay(t *testing.T) {
|
|
var payCalls int
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/v3/time":
|
|
_, _ = w.Write([]byte(`{"serverTime":1783345000000}`))
|
|
case "/sapi/v1/capital/deposit/hisrec":
|
|
_, _ = w.Write([]byte(`[{
|
|
"id":"deposit-conflict","amount":"100","coin":"USDT","network":"TRX","status":1,
|
|
"address":"TDifferentAddress","txId":"Off-chain transfer 433527303245463552"
|
|
}]`))
|
|
case "/sapi/v1/pay/transactions":
|
|
payCalls++
|
|
_, _ = w.Write([]byte(`{"code":"000000","data":[]}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := New(config.BinanceConfig{
|
|
APIBaseURL: server.URL,
|
|
Lalu: config.BinanceAccountConfig{APIKey: "api-lalu", APISecretKey: "secret-lalu"},
|
|
})
|
|
_, err := client.FindUSDTTransfer(context.Background(), ports.BinanceTransferLookupRequest{
|
|
AppCode: "lalu", ExternalID: "433527303245463552", Coin: "USDT", Network: "TRX",
|
|
ToAddress: "TExpectedAddress", AmountMinor: 10000,
|
|
})
|
|
if !xerr.IsCode(err, xerr.Conflict) || !strings.Contains(err.Error(), "address mismatch") {
|
|
t.Fatalf("deposit conflict must be preserved, got %v", err)
|
|
}
|
|
if payCalls != 0 {
|
|
t.Fatalf("Pay fallback must not bypass a conflicting deposit, calls=%d", payCalls)
|
|
}
|
|
}
|
|
|
|
func TestMatchPayRecordRejectsOutgoingAndMismatchedTransfers(t *testing.T) {
|
|
baseReq := ports.BinanceTransferLookupRequest{
|
|
ExternalID: "433527303245463552",
|
|
Coin: "USDT",
|
|
AmountMinor: 10000,
|
|
}
|
|
baseRecord := payRecord{
|
|
OrderType: "C2C", OrderID: "433527303245463552", TransactionID: "P_A223BU5M4Y171116",
|
|
Amount: "100", Currency: "USDT",
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
mutate func(*payRecord)
|
|
message string
|
|
}{
|
|
{name: "outgoing", mutate: func(r *payRecord) { r.Amount = "-100" }, message: "not income"},
|
|
{name: "zero", mutate: func(r *payRecord) { r.Amount = "0" }, message: "not income"},
|
|
{name: "order type", mutate: func(r *payRecord) { r.OrderType = "PAY" }, message: "not c2c"},
|
|
{name: "currency", mutate: func(r *payRecord) { r.Currency = "USDC" }, message: "currency mismatch"},
|
|
{name: "amount", mutate: func(r *payRecord) { r.Amount = "99.99" }, message: "amount mismatch"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
record := baseRecord
|
|
tc.mutate(&record)
|
|
_, err := matchPayRecord(baseReq, []payRecord{record})
|
|
if !xerr.IsCode(err, xerr.Conflict) || !strings.Contains(err.Error(), tc.message) {
|
|
t.Fatalf("expected conflict %q, got %v", tc.message, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFindUSDTTransferMissingAccountReturnsConfiguredError(t *testing.T) {
|
|
client := New(config.BinanceConfig{
|
|
Enabled: true,
|
|
APIBaseURL: "https://binance.invalid",
|
|
Lalu: config.BinanceAccountConfig{
|
|
APIKey: "api-lalu",
|
|
APISecretKey: "secret-lalu",
|
|
},
|
|
})
|
|
_, err := client.FindUSDTTransfer(context.Background(), ports.BinanceTransferLookupRequest{
|
|
AppCode: "aslan",
|
|
ExternalID: "388611987194",
|
|
Coin: "USDT",
|
|
Network: "TRX",
|
|
ToAddress: "TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN",
|
|
AmountMinor: 50000,
|
|
})
|
|
if !xerr.IsCode(err, xerr.Unavailable) || !strings.Contains(err.Error(), "binance account is not configured") {
|
|
t.Fatalf("missing account should be explicit, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAccountForAppSupportsAllProductionApps(t *testing.T) {
|
|
client := New(config.BinanceConfig{
|
|
Lalu: config.BinanceAccountConfig{APIKey: "api-lalu", APISecretKey: "secret-lalu"},
|
|
Yumi: config.BinanceAccountConfig{APIKey: "api-yumi", APISecretKey: "secret-yumi"},
|
|
Aslan: config.BinanceAccountConfig{APIKey: "api-aslan", APISecretKey: "secret-aslan"},
|
|
})
|
|
for _, appCode := range []string{"lalu", "yumi", "aslan"} {
|
|
account, ok := client.accountForApp(appCode)
|
|
if !ok || account.apiKey != "api-"+appCode || account.apiSecretKey != "secret-"+appCode {
|
|
t.Fatalf("Binance account mapping mismatch for %s: ok=%v account=%+v", appCode, ok, account)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDepositTxIDMatchesOffchainVariants(t *testing.T) {
|
|
if !depositTxIDMatches("Off-chain transfer 388611987194", "388611987194") {
|
|
t.Fatalf("plain off-chain id should match Binance txId")
|
|
}
|
|
if !depositTxIDMatches("Off-chain transfer 388611987194", "Off-chain transfer 388611987194") {
|
|
t.Fatalf("full off-chain txId should match itself")
|
|
}
|
|
if depositTxIDMatches("Off-chain transfer 388611987194", "388611987195") {
|
|
t.Fatalf("different off-chain id must not match")
|
|
}
|
|
}
|
|
|
|
func TestSignQueryStringStable(t *testing.T) {
|
|
values := url.Values{}
|
|
values.Set("coin", "USDT")
|
|
values.Set("timestamp", "1783345000000")
|
|
signature := signQueryString(values.Encode(), "secret-lalu")
|
|
if signature != signQueryString("coin=USDT×tamp=1783345000000", "secret-lalu") {
|
|
t.Fatalf("signature must be based on encoded query string")
|
|
}
|
|
}
|