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:
line-6000
2026-05-27 19:36:56 +08:00
parent 13c81ee560
commit c69b0d6c0c
185 changed files with 2525 additions and 483 deletions
+165 -62
View File
@@ -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) {
+44 -7
View File
@@ -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)
+2 -2
View File
@@ -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
View File
@@ -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
+258
View File
@@ -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()
+14 -4
View File
@@ -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) {
+43
View File
@@ -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)