mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-08 02:36:16 +00:00
feat: isolate manual verification RPC usage
- add rpc node purpose support for general/manual_verify/both - keep scan, WSS and health checks away from manual_verify nodes - fallback manual hash verification from general RPC to manual_verify RPC - add public cashier hash submission path and errno responses - update tests for RPC selection, failover and manual payment flows
This commit is contained in:
@@ -54,7 +54,9 @@ func SetManualOrderPaymentValidatorForTest(fn func(*mdb.Orders, string) (string,
|
||||
if fn == nil {
|
||||
manualOrderPaymentValidator = validateManualOrderPaymentDefault
|
||||
} else {
|
||||
manualOrderPaymentValidator = fn
|
||||
manualOrderPaymentValidator = func(order *mdb.Orders, blockTransactionID string) (string, error) {
|
||||
return fn(order, blockTransactionID)
|
||||
}
|
||||
}
|
||||
manualOrderPaymentValidatorMu.Unlock()
|
||||
return func() {
|
||||
@@ -282,25 +284,25 @@ func validateManualEvmPaymentWithClient(ctx context.Context, client evmChainRead
|
||||
func dialManualEvmClients(ctx context.Context, network string) ([]manualEvmClient, error) {
|
||||
var clients []manualEvmClient
|
||||
var connectErrors []string
|
||||
for _, nodeType := range []string{mdb.RpcNodeTypeWs, mdb.RpcNodeTypeHttp} {
|
||||
node, err := data.SelectRpcNode(network, nodeType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if node == nil || node.ID == 0 || strings.TrimSpace(node.Url) == "" {
|
||||
|
||||
nodes, err := listManualEvmRpcCandidates(network)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, node := range nodes {
|
||||
if strings.TrimSpace(node.Url) == "" {
|
||||
continue
|
||||
}
|
||||
rpcURL := strings.TrimSpace(node.Url)
|
||||
client, err := ethclient.DialContext(ctx, rpcURL)
|
||||
client, err := dialManualEvmClient(ctx, node)
|
||||
if err == nil {
|
||||
clients = append(clients, manualEvmClient{
|
||||
label: fmt.Sprintf("%s %s", nodeType, rpcURL),
|
||||
label: manualRpcNodeLabel(node),
|
||||
reader: client,
|
||||
close: client.Close,
|
||||
})
|
||||
continue
|
||||
}
|
||||
connectErrors = append(connectErrors, fmt.Sprintf("%s %s: %v", nodeType, rpcURL, err))
|
||||
connectErrors = append(connectErrors, fmt.Sprintf("%s: %v", manualRpcNodeLabel(node), err))
|
||||
}
|
||||
if len(clients) > 0 {
|
||||
return clients, nil
|
||||
@@ -311,6 +313,49 @@ func dialManualEvmClients(ctx context.Context, network string) ([]manualEvmClien
|
||||
return nil, fmt.Errorf("no enabled %s WS/HTTP RPC node configured", network)
|
||||
}
|
||||
|
||||
func listManualEvmRpcCandidates(network string) ([]mdb.RpcNode, error) {
|
||||
buckets := make([][]mdb.RpcNode, 4)
|
||||
for _, nodeType := range []string{mdb.RpcNodeTypeHttp, mdb.RpcNodeTypeWs} {
|
||||
nodes, err := data.ListManualPaymentRpcCandidates(network, nodeType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, node := range nodes {
|
||||
node.Purpose = data.NormalizeRpcNodePurpose(node.Purpose)
|
||||
switch node.Purpose {
|
||||
case mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeBoth:
|
||||
if node.Status == mdb.RpcNodeStatusOk {
|
||||
buckets[0] = append(buckets[0], node)
|
||||
} else {
|
||||
buckets[1] = append(buckets[1], node)
|
||||
}
|
||||
case mdb.RpcNodePurposeManualVerify:
|
||||
if node.Status == mdb.RpcNodeStatusOk {
|
||||
buckets[2] = append(buckets[2], node)
|
||||
} else {
|
||||
buckets[3] = append(buckets[3], node)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]mdb.RpcNode, 0)
|
||||
for _, bucket := range buckets {
|
||||
out = append(out, bucket...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func dialManualEvmClient(ctx context.Context, node mdb.RpcNode) (*ethclient.Client, error) {
|
||||
rpcURL := strings.TrimSpace(node.Url)
|
||||
return ethclient.DialContext(ctx, rpcURL)
|
||||
}
|
||||
|
||||
func manualRpcNodeLabel(node mdb.RpcNode) string {
|
||||
purpose := data.NormalizeRpcNodePurpose(node.Purpose)
|
||||
return fmt.Sprintf("%s purpose=%s", data.RpcNodeLogLabel(node), purpose)
|
||||
}
|
||||
|
||||
func ensureEvmTransactionNotBeforeOrder(blockTime uint64, order *mdb.Orders) error {
|
||||
if order == nil {
|
||||
return fmt.Errorf("order not found")
|
||||
@@ -398,55 +443,77 @@ func validateManualTronPayment(order *mdb.Orders, txID string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
baseURL, apiKey, err := ResolveTronNode()
|
||||
nodes, err := data.ListManualPaymentRpcCandidates(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var tx manualTronTransaction
|
||||
if err = tronPostJSON(baseURL, apiKey, "/wallet/gettransactionbyid", map[string]interface{}{"value": normalizedTxID}, &tx); err != nil {
|
||||
return "", fmt.Errorf("fetch tron transaction: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(tx.TxID) == "" {
|
||||
return "", fmt.Errorf("transaction not found")
|
||||
}
|
||||
if strings.TrimSpace(tx.TxID) != "" && !strings.EqualFold(strings.TrimSpace(tx.TxID), normalizedTxID) {
|
||||
return "", fmt.Errorf("transaction id mismatch")
|
||||
}
|
||||
if len(tx.Ret) > 0 && tx.Ret[0].ContractRet != "" && tx.Ret[0].ContractRet != "SUCCESS" {
|
||||
return "", fmt.Errorf("transaction is not successful: %s", tx.Ret[0].ContractRet)
|
||||
if len(nodes) == 0 {
|
||||
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
}
|
||||
|
||||
var info manualTronTxInfo
|
||||
if err = tronPostJSON(baseURL, apiKey, "/wallet/gettransactioninfobyid", map[string]interface{}{"value": normalizedTxID}, &info); err != nil {
|
||||
return "", fmt.Errorf("fetch tron transaction info: %w", err)
|
||||
}
|
||||
if info.BlockNumber <= 0 {
|
||||
return "", fmt.Errorf("transaction is not confirmed")
|
||||
}
|
||||
if info.Receipt.Result != "" && info.Receipt.Result != "SUCCESS" {
|
||||
return "", fmt.Errorf("transaction is not successful: %s", info.Receipt.Result)
|
||||
}
|
||||
if err = ensureTronConfirmations(baseURL, apiKey, order.Network, info.BlockNumber); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if info.BlockTimeStamp <= 0 {
|
||||
return "", fmt.Errorf("transaction block timestamp missing")
|
||||
}
|
||||
if info.BlockTimeStamp < order.CreatedAt.TimestampMilli() {
|
||||
return "", fmt.Errorf("transaction predates the order")
|
||||
}
|
||||
|
||||
if strings.EqualFold(strings.TrimSpace(order.Token), "TRX") {
|
||||
if err = validateManualTronNativeTransfer(order, &tx); err != nil {
|
||||
return "", err
|
||||
var verifyErrors []string
|
||||
for _, node := range nodes {
|
||||
if strings.TrimSpace(node.Url) == "" {
|
||||
continue
|
||||
}
|
||||
if err = validateManualTronPaymentWithNode(order, normalizedTxID, node); err != nil {
|
||||
verifyErrors = append(verifyErrors, fmt.Sprintf("%s: %v", manualRpcNodeLabel(node), err))
|
||||
continue
|
||||
}
|
||||
return normalizedTxID, nil
|
||||
}
|
||||
if err = validateManualTronTRC20Transfer(order, &tx, &info); err != nil {
|
||||
return "", err
|
||||
if len(verifyErrors) > 0 {
|
||||
return "", fmt.Errorf("manual TRON verification failed: %s", strings.Join(verifyErrors, "; "))
|
||||
}
|
||||
return normalizedTxID, nil
|
||||
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
}
|
||||
|
||||
func validateManualTronPaymentWithNode(order *mdb.Orders, normalizedTxID string, node mdb.RpcNode) error {
|
||||
baseURL := strings.TrimSpace(node.Url)
|
||||
var tx manualTronTransaction
|
||||
if err := tronPostJSON(baseURL, node.ApiKey, "/wallet/gettransactionbyid", map[string]interface{}{"value": normalizedTxID}, &tx); err != nil {
|
||||
return fmt.Errorf("fetch tron transaction: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(tx.TxID) == "" {
|
||||
return fmt.Errorf("transaction not found")
|
||||
}
|
||||
if strings.TrimSpace(tx.TxID) != "" && !strings.EqualFold(strings.TrimSpace(tx.TxID), normalizedTxID) {
|
||||
return fmt.Errorf("transaction id mismatch")
|
||||
}
|
||||
if len(tx.Ret) > 0 && tx.Ret[0].ContractRet != "" && tx.Ret[0].ContractRet != "SUCCESS" {
|
||||
return fmt.Errorf("transaction is not successful: %s", tx.Ret[0].ContractRet)
|
||||
}
|
||||
|
||||
var info manualTronTxInfo
|
||||
if err := tronPostJSON(baseURL, node.ApiKey, "/wallet/gettransactioninfobyid", map[string]interface{}{"value": normalizedTxID}, &info); err != nil {
|
||||
return fmt.Errorf("fetch tron transaction info: %w", err)
|
||||
}
|
||||
if info.BlockNumber <= 0 {
|
||||
return fmt.Errorf("transaction is not confirmed")
|
||||
}
|
||||
if info.Receipt.Result != "" && info.Receipt.Result != "SUCCESS" {
|
||||
return fmt.Errorf("transaction is not successful: %s", info.Receipt.Result)
|
||||
}
|
||||
if err := ensureTronConfirmations(baseURL, node.ApiKey, order.Network, info.BlockNumber); err != nil {
|
||||
return err
|
||||
}
|
||||
if info.BlockTimeStamp <= 0 {
|
||||
return fmt.Errorf("transaction block timestamp missing")
|
||||
}
|
||||
if info.BlockTimeStamp < order.CreatedAt.TimestampMilli() {
|
||||
return fmt.Errorf("transaction predates the order")
|
||||
}
|
||||
|
||||
if strings.EqualFold(strings.TrimSpace(order.Token), "TRX") {
|
||||
if err := validateManualTronNativeTransfer(order, &tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := validateManualTronTRC20Transfer(order, &tx, &info); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateManualTronNativeTransfer(order *mdb.Orders, tx *manualTronTransaction) error {
|
||||
@@ -571,20 +638,48 @@ func tronPostJSON(baseURL, apiKey, path string, body interface{}, out interface{
|
||||
|
||||
func validateManualSolanaPayment(order *mdb.Orders, sig string) (string, error) {
|
||||
sig = strings.TrimSpace(sig)
|
||||
txData, err := SolGetTransaction(sig)
|
||||
nodes, err := data.ListManualPaymentRpcCandidates(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetch solana transaction: %w", err)
|
||||
return "", err
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
||||
}
|
||||
|
||||
var verifyErrors []string
|
||||
for _, node := range nodes {
|
||||
rpcURL := strings.TrimSpace(node.Url)
|
||||
if rpcURL == "" {
|
||||
continue
|
||||
}
|
||||
if err = validateManualSolanaPaymentWithRPC(order, sig, node); err != nil {
|
||||
verifyErrors = append(verifyErrors, fmt.Sprintf("%s: %v", manualRpcNodeLabel(node), err))
|
||||
continue
|
||||
}
|
||||
return sig, nil
|
||||
}
|
||||
if len(verifyErrors) > 0 {
|
||||
return "", fmt.Errorf("manual Solana verification failed: %s", strings.Join(verifyErrors, "; "))
|
||||
}
|
||||
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
||||
}
|
||||
|
||||
func validateManualSolanaPaymentWithRPC(order *mdb.Orders, sig string, node mdb.RpcNode) error {
|
||||
rpcURL := strings.TrimSpace(node.Url)
|
||||
txData, err := solGetTransactionWithURL(rpcURL, sig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch solana transaction: %w", err)
|
||||
}
|
||||
if !gjson.GetBytes(txData, "result").Exists() || gjson.GetBytes(txData, "result").Type == gjson.Null {
|
||||
return "", fmt.Errorf("transaction not found")
|
||||
return fmt.Errorf("transaction not found")
|
||||
}
|
||||
if err = ensureSolanaConfirmations(order.Network, sig); err != nil {
|
||||
return "", err
|
||||
if err = ensureSolanaConfirmationsWithRPC(order.Network, sig, rpcURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkSolana)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return err
|
||||
}
|
||||
mintTokens := make(map[string]*mdb.ChainToken, len(tokens))
|
||||
var nativeSolToken *mdb.ChainToken
|
||||
@@ -615,17 +710,17 @@ func validateManualSolanaPayment(order *mdb.Orders, sig string) (string, error)
|
||||
continue
|
||||
}
|
||||
if err = ensureSolanaTransferNotBeforeOrder(transferInfo.BlockTime, order); err != nil {
|
||||
return "", err
|
||||
return err
|
||||
}
|
||||
if amountMatchesFloat(order.ActualAmount, amount) {
|
||||
return sig, nil
|
||||
return nil
|
||||
}
|
||||
amountMismatch = true
|
||||
}
|
||||
if amountMismatch {
|
||||
return "", fmt.Errorf("transaction amount mismatch")
|
||||
return fmt.Errorf("transaction amount mismatch")
|
||||
}
|
||||
return "", fmt.Errorf("matching solana transfer to order address not found")
|
||||
return fmt.Errorf("matching solana transfer to order address not found")
|
||||
}
|
||||
|
||||
func ensureSolanaTransferNotBeforeOrder(blockTime int64, order *mdb.Orders) error {
|
||||
@@ -642,6 +737,14 @@ func ensureSolanaTransferNotBeforeOrder(blockTime int64, order *mdb.Orders) erro
|
||||
}
|
||||
|
||||
func ensureSolanaConfirmations(network, sig string) error {
|
||||
rpcURL, err := resolveSolanaRpcURL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureSolanaConfirmationsWithRPC(network, sig, rpcURL)
|
||||
}
|
||||
|
||||
func ensureSolanaConfirmationsWithRPC(network, sig string, rpcURL string) error {
|
||||
chain, err := data.GetChainByNetwork(network)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -654,7 +757,7 @@ func ensureSolanaConfirmations(network, sig string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
body, err := SolRetryClient("getSignatureStatuses", []interface{}{
|
||||
body, err := solRetryClientWithURL(rpcURL, "getSignatureStatuses", []interface{}{
|
||||
[]string{sig},
|
||||
map[string]interface{}{"searchTransactionHistory": true},
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -117,6 +118,154 @@ func TestManualVerifyDialEvmClientsIncludesHTTPNode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyEvmCandidatesPreferGeneralAcrossNodeTypes(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
rows := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkEthereum, Type: mdb.RpcNodeTypeHttp, Url: "http://manual.example.com", Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
|
||||
{Network: mdb.NetworkEthereum, Type: mdb.RpcNodeTypeWs, Url: "ws://general.example.com", Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
}
|
||||
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed rpc_nodes: %v", err)
|
||||
}
|
||||
|
||||
got, err := listManualEvmRpcCandidates(mdb.NetworkEthereum)
|
||||
if err != nil {
|
||||
t.Fatalf("listManualEvmRpcCandidates(): %v", err)
|
||||
}
|
||||
urls := make([]string, 0, len(got))
|
||||
for _, node := range got {
|
||||
urls = append(urls, node.Url)
|
||||
}
|
||||
want := []string{"ws://general.example.com", "http://manual.example.com"}
|
||||
if len(urls) != len(want) {
|
||||
t.Fatalf("candidate urls = %#v, want %#v", urls, want)
|
||||
}
|
||||
for i := range want {
|
||||
if urls[i] != want[i] {
|
||||
t.Fatalf("candidate urls = %#v, want %#v", urls, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualRpcNodeLabelHidesSensitiveURLParts(t *testing.T) {
|
||||
label := manualRpcNodeLabel(mdb.RpcNode{
|
||||
BaseModel: mdb.BaseModel{ID: 9},
|
||||
Network: mdb.NetworkEthereum,
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Url: "https://rpc.example.com/v3/secret-key?token=secret",
|
||||
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||
})
|
||||
if strings.Contains(label, "secret") || strings.Contains(label, "/v3/") {
|
||||
t.Fatalf("manualRpcNodeLabel() leaked sensitive URL parts: %s", label)
|
||||
}
|
||||
if !strings.Contains(label, "endpoint=https://rpc.example.com") {
|
||||
t.Fatalf("manualRpcNodeLabel() = %s, want sanitized endpoint", label)
|
||||
}
|
||||
if !strings.Contains(label, "purpose=manual_verify") {
|
||||
t.Fatalf("manualRpcNodeLabel() = %s, want purpose", label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyDialEvmClientDoesNotForwardUserIPHeader(t *testing.T) {
|
||||
var headerCalls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-USER-IP"); got != "" {
|
||||
t.Errorf("X-USER-IP header = %q, want empty", got)
|
||||
http.Error(w, "unexpected user ip header", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
atomic.AddInt32(&headerCalls, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"error": map[string]interface{}{
|
||||
"code": -32000,
|
||||
"message": "not found",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
client, err := dialManualEvmClient(ctx, mdb.RpcNode{
|
||||
Network: mdb.NetworkEthereum,
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Url: server.URL,
|
||||
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dialManualEvmClient(): %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
_, _ = client.TransactionReceipt(ctx, common.HexToHash("0x"+strings.Repeat("a", 64)))
|
||||
if got := atomic.LoadInt32(&headerCalls); got == 0 {
|
||||
t.Fatal("manual verify EVM request did not reach test RPC")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyDialEvmClientDoesNotForwardUserIPForGeneralNode(t *testing.T) {
|
||||
var calls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-USER-IP"); got != "" {
|
||||
t.Errorf("X-USER-IP header = %q, want empty", got)
|
||||
http.Error(w, "unexpected user ip header", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
atomic.AddInt32(&calls, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"error": map[string]interface{}{
|
||||
"code": -32000,
|
||||
"message": "not found",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
client, err := dialManualEvmClient(ctx, mdb.RpcNode{
|
||||
Network: mdb.NetworkEthereum,
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Url: server.URL,
|
||||
Purpose: mdb.RpcNodePurposeGeneral,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dialManualEvmClient(): %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
_, _ = client.TransactionReceipt(ctx, common.HexToHash("0x"+strings.Repeat("a", 64)))
|
||||
if got := atomic.LoadInt32(&calls); got == 0 {
|
||||
t.Fatal("general EVM request did not reach test RPC")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTronPostJSONDoesNotForwardUserIPWithoutHeaders(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-USER-IP"); got != "" {
|
||||
t.Errorf("X-USER-IP header = %q, want empty", got)
|
||||
http.Error(w, "unexpected user ip header", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var out map[string]interface{}
|
||||
if err := tronPostJSON(server.URL, "", "/wallet/getnowblock", map[string]interface{}{}, &out); err != nil {
|
||||
t.Fatalf("tronPostJSON(): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyEvmRejectsTransactionBeforeOrder(t *testing.T) {
|
||||
order := &mdb.Orders{BaseModel: mdb.BaseModel{CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().UnixMilli()))}}
|
||||
txTime := uint64(time.Now().Add(-time.Hour).Unix())
|
||||
@@ -271,7 +420,12 @@ func TestManualVerifyTronPaymentHTTPFlow(t *testing.T) {
|
||||
|
||||
rawAmount := big.NewInt(1230000)
|
||||
blockTimeMs := time.Now().Add(time.Minute).UnixMilli()
|
||||
requestCalls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-USER-IP"); got != "" {
|
||||
t.Errorf("X-USER-IP header = %q, want empty", got)
|
||||
}
|
||||
requestCalls++
|
||||
var req map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Errorf("decode tron request: %v", err)
|
||||
@@ -333,6 +487,7 @@ func TestManualVerifyTronPaymentHTTPFlow(t *testing.T) {
|
||||
Url: server.URL,
|
||||
Enabled: true,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create tron rpc node: %v", err)
|
||||
}
|
||||
@@ -366,6 +521,9 @@ func TestManualVerifyTronPaymentHTTPFlow(t *testing.T) {
|
||||
if got != txID {
|
||||
t.Fatalf("canonical tx id = %q, want %q", got, txID)
|
||||
}
|
||||
if requestCalls != 3 {
|
||||
t.Fatalf("manual verify request calls = %d, want 3", requestCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifySolanaRejectsMissingBlockTime(t *testing.T) {
|
||||
|
||||
@@ -60,7 +60,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
|
||||
network := strings.ToLower(strings.TrimSpace(req.Network))
|
||||
notifyURL := strings.TrimSpace(req.NotifyUrl)
|
||||
if err := security.ValidatePublicHTTPURL(notifyURL); err != nil {
|
||||
return nil, err
|
||||
return nil, constant.NotifyURLErr
|
||||
}
|
||||
|
||||
gCreateTransactionLock.Lock()
|
||||
@@ -351,16 +351,43 @@ func SubmitManualPayment(tradeId, blockTransactionId string) (*response.ManualPa
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if order.Status != mdb.StatusWaitPay {
|
||||
return nil, constant.OrderNotWaitPay
|
||||
return submitManualPaymentForOrder(order, blockTransactionId)
|
||||
}
|
||||
|
||||
// SubmitCashierManualPayment is the public cashier variant. It rejects hashes
|
||||
// already stored on any order before touching RPC so repeated/public probes do
|
||||
// not spend RPC quota. Admin mark-paid intentionally keeps using
|
||||
// SubmitManualPayment and the existing verification path.
|
||||
func SubmitCashierManualPayment(tradeId, blockTransactionId string) (*response.ManualPaymentResponse, error) {
|
||||
tradeId = strings.TrimSpace(tradeId)
|
||||
blockTransactionId = strings.TrimSpace(blockTransactionId)
|
||||
|
||||
order, err := GetOrderInfoByTradeId(tradeId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !isOnChainOrder(order.PayProvider) {
|
||||
return nil, errors.New("order is not an on-chain payment order")
|
||||
if err = validateManualPaymentOrder(order); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = ensureManualBlockTransactionUnused(order, blockTransactionId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return submitManualPaymentForOrder(order, blockTransactionId)
|
||||
}
|
||||
|
||||
func submitManualPaymentForOrder(order *mdb.Orders, blockTransactionId string) (*response.ManualPaymentResponse, error) {
|
||||
blockTransactionId = strings.TrimSpace(blockTransactionId)
|
||||
if err := validateManualPaymentOrder(order); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
verifiedBlockTransactionID, err := ValidateManualOrderPayment(order, blockTransactionId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var rspErr *constant.RspError
|
||||
if errors.As(err, &rspErr) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, constant.ManualPaymentVerifyErr
|
||||
}
|
||||
if err = OrderProcessing(&request.OrderProcessingRequest{
|
||||
ReceiveAddress: order.ReceiveAddress,
|
||||
@@ -385,6 +412,16 @@ func SubmitManualPayment(tradeId, blockTransactionId string) (*response.ManualPa
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateManualPaymentOrder(order *mdb.Orders) error {
|
||||
if order.Status != mdb.StatusWaitPay {
|
||||
return constant.OrderNotWaitPay
|
||||
}
|
||||
if !isOnChainOrder(order.PayProvider) {
|
||||
return constant.ManualPaymentProviderErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isOnChainOrder(payProvider string) bool {
|
||||
payProvider = strings.TrimSpace(payProvider)
|
||||
return payProvider == "" || payProvider == mdb.PaymentProviderOnChain
|
||||
@@ -645,7 +682,7 @@ func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterR
|
||||
if err != nil {
|
||||
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
||||
_ = data.ExpireOrderByTradeID(subTradeID)
|
||||
return nil, err
|
||||
return nil, constant.PaymentProviderCreateErr
|
||||
}
|
||||
if err = data.UpdateProviderOrderCreated(subTradeID, mdb.PaymentProviderOkPay, okpayOrder.ProviderOrderID, okpayOrder.PayURL); err != nil {
|
||||
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
||||
|
||||
@@ -80,8 +80,8 @@ func TestCreateTransactionRejectsPrivateNotifyURL(t *testing.T) {
|
||||
req := newCreateTransactionRequest("order_private_notify_url", 1)
|
||||
req.NotifyUrl = "http://127.0.0.1/notify"
|
||||
|
||||
if _, err := CreateTransaction(req, nil); err == nil {
|
||||
t.Fatal("CreateTransaction returned nil error for private notify_url")
|
||||
if _, err := CreateTransaction(req, nil); err != constant.NotifyURLErr {
|
||||
t.Fatalf("CreateTransaction error = %v, want %v", err, constant.NotifyURLErr)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+110
-18
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -28,6 +29,12 @@ var gProcessedSignatures sync.Map // sig -> unix timestamp
|
||||
|
||||
var processSolanaOrder = OrderProcessing
|
||||
|
||||
var (
|
||||
solRPCRetryCount = 5
|
||||
solRPCRetryWait = 2 * time.Second
|
||||
solRPCRetryMaxWait = 10 * time.Second
|
||||
)
|
||||
|
||||
type TransferInfo struct {
|
||||
Source string // Source address (for SOL) or source ATA (for SPL tokens)
|
||||
Destination string // Destination address (for SOL) or destination ATA (for SPL tokens)
|
||||
@@ -293,26 +300,74 @@ type solSignatureResult struct {
|
||||
}
|
||||
|
||||
func resolveSolanaRpcURL() (string, error) {
|
||||
node, err := data.SelectRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
||||
node, err := resolveSolanaRpcNode()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rpcURL := strings.TrimSpace(node.Url)
|
||||
return rpcURL, nil
|
||||
}
|
||||
|
||||
func resolveSolanaRpcNode(excludeIDs ...uint64) (*mdb.RpcNode, error) {
|
||||
node, err := data.SelectGeneralRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp, excludeIDs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if node == nil || node.ID == 0 {
|
||||
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
||||
return nil, fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
||||
}
|
||||
rpcURL := strings.TrimSpace(node.Url)
|
||||
if rpcURL == "" {
|
||||
return "", fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
|
||||
return nil, fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
|
||||
}
|
||||
return rpcURL, nil
|
||||
node.Url = rpcURL
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// SolRetryClient 发送 Solana JSON-RPC 请求,自动重试
|
||||
func SolRetryClient(method string, params []interface{}) ([]byte, error) {
|
||||
tried := make([]uint64, 0, 3)
|
||||
var lastErr error
|
||||
for attempts := 0; attempts < 3; attempts++ {
|
||||
node, err := resolveSolanaRpcNode(tried...)
|
||||
if err != nil {
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(tried) > 0 {
|
||||
log.Sugar.Warnf("[SOL] trying alternate RPC node method=%s node=%s", method, data.RpcNodeLogLabel(*node))
|
||||
}
|
||||
|
||||
body, err := solRetryClientWithURL(node.Url, method, params)
|
||||
if err == nil {
|
||||
data.RecordRpcNodeSuccess(node.ID)
|
||||
return body, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
failures, cooling := data.RecordRpcNodeFailure(node.ID)
|
||||
nodeLabel := data.RpcNodeLogLabel(*node)
|
||||
if !cooling {
|
||||
log.Sugar.Warnf("[SOL] RPC node failed method=%s node=%s failures=%d/%d", method, nodeLabel, failures, data.RpcFailoverThreshold)
|
||||
return nil, err
|
||||
}
|
||||
log.Sugar.Warnf("[SOL] RPC node reached fail threshold method=%s node=%s, trying another node", method, nodeLabel)
|
||||
tried = append(tried, node.ID)
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func solRetryClientWithURL(rpcUrl string, method string, params []interface{}) ([]byte, error) {
|
||||
return solRetryClientWithURLHeaders(rpcUrl, method, params, nil)
|
||||
}
|
||||
|
||||
func solRetryClientWithURLHeaders(rpcUrl string, method string, params []interface{}, headers http.Header) ([]byte, error) {
|
||||
client := resty.New()
|
||||
client.SetRetryCount(5)
|
||||
client.SetRetryWaitTime(2 * time.Second)
|
||||
client.SetRetryMaxWaitTime(10 * time.Second)
|
||||
client.SetRetryCount(solRPCRetryCount)
|
||||
client.SetRetryWaitTime(solRPCRetryWait)
|
||||
client.SetRetryMaxWaitTime(solRPCRetryMaxWait)
|
||||
client.AddRetryCondition(func(r *resty.Response, err error) bool {
|
||||
if err != nil {
|
||||
return true
|
||||
@@ -323,25 +378,35 @@ func SolRetryClient(method string, params []interface{}) ([]byte, error) {
|
||||
return false
|
||||
})
|
||||
|
||||
rpcUrl, err := resolveSolanaRpcURL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
req := client.R().SetHeader("Content-Type", "application/json")
|
||||
for key, values := range headers {
|
||||
for _, value := range values {
|
||||
req.SetHeader(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := client.R().
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetBody(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": method,
|
||||
"params": params,
|
||||
}).
|
||||
resp, err := req.SetBody(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": method,
|
||||
"params": params,
|
||||
}).
|
||||
Post(rpcUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode() >= http.StatusBadRequest {
|
||||
return nil, fmt.Errorf("solana rpc HTTP %d", resp.StatusCode())
|
||||
}
|
||||
|
||||
respBody := resp.Body()
|
||||
if errValue := gjson.GetBytes(respBody, "error"); errValue.Exists() && errValue.Type != gjson.Null {
|
||||
code := errValue.Get("code").String()
|
||||
if code == "" {
|
||||
code = "unknown"
|
||||
}
|
||||
return nil, fmt.Errorf("solana rpc error code=%s", code)
|
||||
}
|
||||
|
||||
return respBody, nil
|
||||
}
|
||||
@@ -401,6 +466,33 @@ func SolGetTransaction(sig string) ([]byte, error) {
|
||||
return txData, nil
|
||||
}
|
||||
|
||||
func solGetTransactionWithURL(rpcURL string, sig string) ([]byte, error) {
|
||||
return solGetTransactionWithURLHeaders(rpcURL, sig, nil)
|
||||
}
|
||||
|
||||
func solGetTransactionWithURLHeaders(rpcURL string, sig string, headers http.Header) ([]byte, error) {
|
||||
txData, err := solRetryClientWithURLHeaders(rpcURL, "getTransaction", []interface{}{
|
||||
sig,
|
||||
map[string]interface{}{
|
||||
"encoding": "jsonParsed",
|
||||
"commitment": "confirmed",
|
||||
"maxSupportedTransactionVersion": 0, // suport
|
||||
},
|
||||
}, headers)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("SolRetryClient failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
errField := gjson.GetBytes(txData, "result.meta.err")
|
||||
if errField.Exists() && errField.Type != gjson.Null {
|
||||
log.Sugar.Warnf("Transaction failed: %v", errField.String())
|
||||
return nil, fmt.Errorf("transaction failed: %s", errField.String())
|
||||
}
|
||||
|
||||
return txData, nil
|
||||
}
|
||||
|
||||
// isTransferToAddress 判断转账目标是否为指定钱包地址
|
||||
func isTransferToAddress(transfer *TransferInfo, targetAddress string) bool {
|
||||
// Native SOL transfer - check destination directly
|
||||
|
||||
@@ -28,6 +28,7 @@ func setupSolanaRPCNode(t *testing.T, url string) func() {
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Weight: 1,
|
||||
Enabled: true,
|
||||
Purpose: mdb.RpcNodePurposeGeneral,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}
|
||||
if err := dao.Mdb.Create(node).Error; err != nil {
|
||||
@@ -59,6 +60,263 @@ func TestResolveSolanaRpcURLWithRow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSolanaRpcURLIgnoresManualVerifyOnly(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||
Network: mdb.NetworkSolana,
|
||||
Url: "https://paid-solana.example.com",
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Weight: 100,
|
||||
Enabled: true,
|
||||
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed manual rpc_node: %v", err)
|
||||
}
|
||||
|
||||
if got, err := resolveSolanaRpcURL(); err == nil {
|
||||
t.Fatalf("resolveSolanaRpcURL() = %q, nil; want error", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSolanaRpcURLUsesGeneralWhenManualVerifyExists(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
rows := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkSolana, Url: "https://paid-solana.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
|
||||
{Network: mdb.NetworkSolana, Url: " https://general-solana.example.com ", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
}
|
||||
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed rpc_nodes: %v", err)
|
||||
}
|
||||
|
||||
got, err := resolveSolanaRpcURL()
|
||||
if err != nil {
|
||||
t.Fatalf("resolveSolanaRpcURL(): %v", err)
|
||||
}
|
||||
if got != "https://general-solana.example.com" {
|
||||
t.Fatalf("resolveSolanaRpcURL() = %q, want general node", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolRetryClientSwitchesAfterFailureThreshold(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
data.ResetRpcFailoverForTest()
|
||||
t.Cleanup(data.ResetRpcFailoverForTest)
|
||||
oldRetryCount := solRPCRetryCount
|
||||
oldRetryWait := solRPCRetryWait
|
||||
oldRetryMaxWait := solRPCRetryMaxWait
|
||||
solRPCRetryCount = 0
|
||||
solRPCRetryWait = time.Millisecond
|
||||
solRPCRetryMaxWait = time.Millisecond
|
||||
t.Cleanup(func() {
|
||||
solRPCRetryCount = oldRetryCount
|
||||
solRPCRetryWait = oldRetryWait
|
||||
solRPCRetryMaxWait = oldRetryMaxWait
|
||||
})
|
||||
|
||||
var primaryCalls int
|
||||
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
primaryCalls++
|
||||
http.Error(w, "temporary", http.StatusBadGateway)
|
||||
}))
|
||||
defer primary.Close()
|
||||
|
||||
var backupCalls int
|
||||
backup := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
backupCalls++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": "ok",
|
||||
})
|
||||
}))
|
||||
defer backup.Close()
|
||||
|
||||
rows := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkSolana, Url: primary.URL, Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
{Network: mdb.NetworkSolana, Url: backup.URL, Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
}
|
||||
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed rpc_nodes: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < data.RpcFailoverThreshold-1; i++ {
|
||||
if _, err := SolRetryClient("getHealth", nil); err == nil {
|
||||
t.Fatalf("SolRetryClient attempt %d unexpectedly succeeded", i+1)
|
||||
}
|
||||
}
|
||||
if backupCalls != 0 {
|
||||
t.Fatalf("backup calls before threshold = %d, want 0", backupCalls)
|
||||
}
|
||||
|
||||
body, err := SolRetryClient("getHealth", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("SolRetryClient after threshold: %v", err)
|
||||
}
|
||||
if gjson.GetBytes(body, "result").String() != "ok" {
|
||||
t.Fatalf("response body = %s, want result ok", string(body))
|
||||
}
|
||||
if backupCalls == 0 {
|
||||
t.Fatal("backup RPC was not called after threshold")
|
||||
}
|
||||
if primaryCalls == 0 {
|
||||
t.Fatal("primary RPC was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolRetryClientCountsJSONRPCErrorAsNodeFailure(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
data.ResetRpcFailoverForTest()
|
||||
t.Cleanup(data.ResetRpcFailoverForTest)
|
||||
oldRetryCount := solRPCRetryCount
|
||||
oldRetryWait := solRPCRetryWait
|
||||
oldRetryMaxWait := solRPCRetryMaxWait
|
||||
solRPCRetryCount = 0
|
||||
solRPCRetryWait = time.Millisecond
|
||||
solRPCRetryMaxWait = time.Millisecond
|
||||
t.Cleanup(func() {
|
||||
solRPCRetryCount = oldRetryCount
|
||||
solRPCRetryWait = oldRetryWait
|
||||
solRPCRetryMaxWait = oldRetryMaxWait
|
||||
})
|
||||
|
||||
var primaryCalls int
|
||||
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
primaryCalls++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"error": map[string]interface{}{
|
||||
"code": -32005,
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer primary.Close()
|
||||
|
||||
var backupCalls int
|
||||
backup := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
backupCalls++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": "ok",
|
||||
})
|
||||
}))
|
||||
defer backup.Close()
|
||||
|
||||
rows := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkSolana, Url: primary.URL, Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
{Network: mdb.NetworkSolana, Url: backup.URL, Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
}
|
||||
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed rpc_nodes: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < data.RpcFailoverThreshold-1; i++ {
|
||||
if _, err := SolRetryClient("getHealth", nil); err == nil {
|
||||
t.Fatalf("SolRetryClient attempt %d unexpectedly succeeded", i+1)
|
||||
}
|
||||
}
|
||||
body, err := SolRetryClient("getHealth", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("SolRetryClient after JSON-RPC error threshold: %v", err)
|
||||
}
|
||||
if gjson.GetBytes(body, "result").String() != "ok" {
|
||||
t.Fatalf("response body = %s, want result ok", string(body))
|
||||
}
|
||||
if primaryCalls < data.RpcFailoverThreshold {
|
||||
t.Fatalf("primary calls = %d, want at least threshold", primaryCalls)
|
||||
}
|
||||
if backupCalls == 0 {
|
||||
t.Fatal("backup RPC was not called after JSON-RPC error threshold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolRetryClientWithURLHeadersForwardsCustomHeaders(t *testing.T) {
|
||||
oldRetryCount := solRPCRetryCount
|
||||
oldRetryWait := solRPCRetryWait
|
||||
oldRetryMaxWait := solRPCRetryMaxWait
|
||||
solRPCRetryCount = 0
|
||||
solRPCRetryWait = time.Millisecond
|
||||
solRPCRetryMaxWait = time.Millisecond
|
||||
t.Cleanup(func() {
|
||||
solRPCRetryCount = oldRetryCount
|
||||
solRPCRetryWait = oldRetryWait
|
||||
solRPCRetryMaxWait = oldRetryMaxWait
|
||||
})
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-Test-Header"); got != "test-value" {
|
||||
t.Errorf("X-Test-Header = %q, want test-value", got)
|
||||
http.Error(w, "bad custom header", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": "ok",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
headers := http.Header{}
|
||||
headers.Set("X-Test-Header", "test-value")
|
||||
body, err := solRetryClientWithURLHeaders(server.URL, "getHealth", nil, headers)
|
||||
if err != nil {
|
||||
t.Fatalf("solRetryClientWithURLHeaders(): %v", err)
|
||||
}
|
||||
if gjson.GetBytes(body, "result").String() != "ok" {
|
||||
t.Fatalf("response body = %s, want result ok", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolRetryClientWithURLDoesNotForwardUserIPByDefault(t *testing.T) {
|
||||
oldRetryCount := solRPCRetryCount
|
||||
oldRetryWait := solRPCRetryWait
|
||||
oldRetryMaxWait := solRPCRetryMaxWait
|
||||
solRPCRetryCount = 0
|
||||
solRPCRetryWait = time.Millisecond
|
||||
solRPCRetryMaxWait = time.Millisecond
|
||||
t.Cleanup(func() {
|
||||
solRPCRetryCount = oldRetryCount
|
||||
solRPCRetryWait = oldRetryWait
|
||||
solRPCRetryMaxWait = oldRetryMaxWait
|
||||
})
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-USER-IP"); got != "" {
|
||||
t.Errorf("X-USER-IP header = %q, want empty", got)
|
||||
http.Error(w, "unexpected user ip header", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": "ok",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
body, err := solRetryClientWithURL(server.URL, "getHealth", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("solRetryClientWithURL(): %v", err)
|
||||
}
|
||||
if gjson.GetBytes(body, "result").String() != "ok" {
|
||||
t.Fatalf("response body = %s, want result ok", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolCallBackDoesNotCacheSignatureAfterRetryableOrderProcessingError(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -18,18 +18,28 @@ import (
|
||||
)
|
||||
|
||||
func resolveTronNode() (string, string, error) {
|
||||
node, err := data.SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
node, err := ResolveTronRpcNode()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
rpcURL := strings.TrimRight(strings.TrimSpace(node.Url), "/")
|
||||
return rpcURL, node.ApiKey, nil
|
||||
}
|
||||
|
||||
func ResolveTronRpcNode(excludeIDs ...uint64) (*mdb.RpcNode, error) {
|
||||
node, err := data.SelectGeneralRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp, excludeIDs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if node == nil || node.ID == 0 {
|
||||
return "", "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
return nil, fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
}
|
||||
rpcURL := strings.TrimRight(strings.TrimSpace(node.Url), "/")
|
||||
if rpcURL == "" {
|
||||
return "", "", fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
|
||||
return nil, fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
|
||||
}
|
||||
return rpcURL, node.ApiKey, nil
|
||||
node.Url = rpcURL
|
||||
return node, nil
|
||||
}
|
||||
|
||||
func ResolveTronNode() (string, string, error) {
|
||||
|
||||
@@ -51,6 +51,49 @@ func TestResolveTronNode_WithRow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTronNodeIgnoresManualVerifyOnly(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||
Network: mdb.NetworkTron,
|
||||
Url: "https://paid-tron.example.com",
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
ApiKey: "paid-key",
|
||||
Weight: 100,
|
||||
Enabled: true,
|
||||
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed manual rpc_node: %v", err)
|
||||
}
|
||||
|
||||
if gotURL, gotKey, err := resolveTronNode(); err == nil {
|
||||
t.Fatalf("resolveTronNode() = (%q, %q, nil), want error", gotURL, gotKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTronNodeUsesGeneralWhenManualVerifyExists(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
rows := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkTron, Url: "https://paid-tron.example.com", Type: mdb.RpcNodeTypeHttp, ApiKey: "paid-key", Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
|
||||
{Network: mdb.NetworkTron, Url: "https://general-tron.example.com", Type: mdb.RpcNodeTypeHttp, ApiKey: "general-key", Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
}
|
||||
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed rpc_nodes: %v", err)
|
||||
}
|
||||
|
||||
gotURL, gotKey, err := resolveTronNode()
|
||||
if err != nil {
|
||||
t.Fatalf("resolveTronNode(): %v", err)
|
||||
}
|
||||
if gotURL != "https://general-tron.example.com" || gotKey != "general-key" {
|
||||
t.Fatalf("resolveTronNode() = (%q, %q), want general node", gotURL, gotKey)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveTronNode_DisabledRow verifies that a disabled row is ignored.
|
||||
func TestResolveTronNode_DisabledRow(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
|
||||
Reference in New Issue
Block a user