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
+25 -8
View File
@@ -46,6 +46,7 @@ func MdbTableInit() {
}
seedChains()
backfillRpcNodePurpose()
seedRpcNodes()
seedChainTokens()
seedDefaultSettings()
@@ -106,14 +107,7 @@ func seedChainTokens() {
// seedRpcNodes inserts default RPC endpoints for each chain. Checks per
// (network, url) so re-runs are idempotent and admin edits persist.
func seedRpcNodes() {
defaults := []mdb.RpcNode{
{Network: mdb.NetworkTron, Url: "https://api.trongrid.io", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkEthereum, Url: "wss://ethereum.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkSolana, Url: "https://api.mainnet-beta.solana.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkBsc, Url: "wss://bsc.drpc.org", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkPolygon, Url: "wss://polygon-bor-rpc.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkPlasma, Url: "wss://rpc.plasma.to", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
}
defaults := defaultRpcNodes()
for _, d := range defaults {
var count int64
if err := Mdb.Model(&mdb.RpcNode{}).
@@ -131,6 +125,29 @@ func seedRpcNodes() {
}
}
func defaultRpcNodes() []mdb.RpcNode {
defaults := []mdb.RpcNode{
{Network: mdb.NetworkTron, Url: "https://api.trongrid.io", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkEthereum, Url: "wss://ethereum.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkSolana, Url: "https://api.mainnet-beta.solana.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkBsc, Url: "wss://bsc.drpc.org", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkPolygon, Url: "wss://polygon-bor-rpc.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkPlasma, Url: "wss://rpc.plasma.to", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkEthereum, Url: "https://rpc.epusdt.com/ethereum", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkBsc, Url: "https://rpc.epusdt.com/binance", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkPolygon, Url: "https://rpc.epusdt.com/polygon", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
}
return defaults
}
func backfillRpcNodePurpose() {
if err := Mdb.Model(&mdb.RpcNode{}).
Where("purpose = '' OR purpose IS NULL").
Update("purpose", mdb.RpcNodePurposeGeneral).Error; err != nil {
color.Red.Printf("[store_db] backfill rpc_nodes purpose err=%s\n", err)
}
}
// seedDefaultSettings inserts built-in default settings.
// Uses ON CONFLICT DO NOTHING so admin edits persist across restarts.
func seedDefaultSettings() {
+43
View File
@@ -0,0 +1,43 @@
package dao
import (
"testing"
"github.com/GMWalletApp/epusdt/model/mdb"
)
func TestDefaultRpcNodesIncludesManualVerifyEpusdtEvmNodes(t *testing.T) {
want := map[string]string{
mdb.NetworkEthereum: "https://rpc.epusdt.com/ethereum",
mdb.NetworkBsc: "https://rpc.epusdt.com/binance",
mdb.NetworkPolygon: "https://rpc.epusdt.com/polygon",
}
got := make(map[string]mdb.RpcNode)
for _, node := range defaultRpcNodes() {
if node.Purpose != mdb.RpcNodePurposeManualVerify {
continue
}
if _, ok := want[node.Network]; ok {
got[node.Network] = node
}
}
for network, url := range want {
node, ok := got[network]
if !ok {
t.Fatalf("missing manual_verify seed rpc node for %s", network)
}
if node.Url != url {
t.Fatalf("%s manual_verify seed url = %q, want %q", network, node.Url, url)
}
if node.Type != mdb.RpcNodeTypeHttp {
t.Fatalf("%s manual_verify seed type = %q, want %q", network, node.Type, mdb.RpcNodeTypeHttp)
}
if !node.Enabled {
t.Fatalf("%s manual_verify seed enabled = false, want true", network)
}
if node.Status != mdb.RpcNodeStatusUnknown {
t.Fatalf("%s manual_verify seed status = %q, want %q", network, node.Status, mdb.RpcNodeStatusUnknown)
}
}
}
+253 -1
View File
@@ -1,14 +1,44 @@
package data
import (
"fmt"
"math/rand"
"net/url"
"sort"
"strings"
"sync"
"time"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/dromara/carbon/v2"
)
const (
RpcFailoverThreshold = 3
RpcFailoverCooldown = 60 * time.Second
)
var gRpcFailover = struct {
sync.Mutex
failures map[uint64]int
cooldownUntil map[uint64]time.Time
}{
failures: make(map[uint64]int),
cooldownUntil: make(map[uint64]time.Time),
}
func NormalizeRpcNodePurpose(purpose string) string {
switch strings.ToLower(strings.TrimSpace(purpose)) {
case mdb.RpcNodePurposeManualVerify:
return mdb.RpcNodePurposeManualVerify
case mdb.RpcNodePurposeBoth:
return mdb.RpcNodePurposeBoth
default:
return mdb.RpcNodePurposeGeneral
}
}
// ListRpcNodes returns rows optionally filtered by network.
func ListRpcNodes(network string) ([]mdb.RpcNode, error) {
var rows []mdb.RpcNode
@@ -20,6 +50,84 @@ func ListRpcNodes(network string) ([]mdb.RpcNode, error) {
return rows, err
}
// ListRpcNodesForHealth returns nodes that should be probed by the periodic
// health job. Manual verification nodes are intentionally excluded because
// they may be paid/high-limit endpoints that should only be used on demand.
func ListRpcNodesForHealth() ([]mdb.RpcNode, error) {
var rows []mdb.RpcNode
err := dao.Mdb.Model(&mdb.RpcNode{}).
Where("(purpose IN ? OR purpose = '' OR purpose IS NULL)", []string{mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeBoth}).
Order("id ASC").
Find(&rows).Error
return rows, err
}
// ListGeneralRpcCandidates returns enabled non-manual nodes for ordinary
// scanners/listeners. Down nodes are skipped; ok nodes are ordered before
// unknown/bootstrap nodes.
func ListGeneralRpcCandidates(network, nodeType string) ([]mdb.RpcNode, error) {
var rows []mdb.RpcNode
err := dao.Mdb.Model(&mdb.RpcNode{}).
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
Where("type = ?", strings.ToLower(strings.TrimSpace(nodeType))).
Where("enabled = ?", true).
Where("(purpose IN ? OR purpose = '' OR purpose IS NULL)", []string{mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeBoth}).
Where("(status IN ? OR status = '' OR status IS NULL)", []string{mdb.RpcNodeStatusOk, mdb.RpcNodeStatusUnknown}).
Find(&rows).Error
if err != nil {
return nil, err
}
buckets := make([][]mdb.RpcNode, 2)
for _, row := range rows {
row.Purpose = NormalizeRpcNodePurpose(row.Purpose)
if row.Status == mdb.RpcNodeStatusOk {
buckets[0] = append(buckets[0], row)
} else {
buckets[1] = append(buckets[1], row)
}
}
out := make([]mdb.RpcNode, 0, len(rows))
for _, bucket := range buckets {
sortRpcNodes(bucket)
out = append(out, bucket...)
}
return out, nil
}
func SelectGeneralRpcNode(network, nodeType string, excludeIDs ...uint64) (*mdb.RpcNode, error) {
rows, err := ListGeneralRpcCandidates(network, nodeType)
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, nil
}
excluded := make(map[uint64]struct{}, len(excludeIDs))
for _, id := range excludeIDs {
if id > 0 {
excluded[id] = struct{}{}
}
}
for i := range rows {
if _, ok := excluded[rows[i].ID]; ok {
continue
}
if IsRpcNodeCoolingDown(rows[i].ID) {
continue
}
return &rows[i], nil
}
if len(excluded) == 0 {
for i := range rows {
return &rows[i], nil
}
}
return nil, nil
}
// GetRpcNodeByID fetches one row.
func GetRpcNodeByID(id uint64) (*mdb.RpcNode, error) {
row := new(mdb.RpcNode)
@@ -29,6 +137,7 @@ func GetRpcNodeByID(id uint64) (*mdb.RpcNode, error) {
// CreateRpcNode inserts a row.
func CreateRpcNode(row *mdb.RpcNode) error {
row.Purpose = NormalizeRpcNodePurpose(row.Purpose)
return dao.Mdb.Create(row).Error
}
@@ -37,6 +146,9 @@ func UpdateRpcNodeFields(id uint64, fields map[string]interface{}) error {
if len(fields) == 0 {
return nil
}
if purpose, ok := fields["purpose"].(string); ok {
fields["purpose"] = NormalizeRpcNodePurpose(purpose)
}
return dao.Mdb.Model(&mdb.RpcNode{}).Where("id = ?", id).Updates(fields).Error
}
@@ -48,13 +160,15 @@ func DeleteRpcNodeByID(id uint64) error {
// SelectRpcNode picks a healthy RPC endpoint for a (network, type) pair.
// Strategy: weighted random among rows where enabled=true AND status=ok.
// Falls back to enabled rows with status=unknown when no health check has
// run yet. Explicitly down rows are not selected.
// run yet. Explicitly down rows are not selected. Manual verification nodes
// are excluded so paid/manual-only RPCs are never used by scanners/listeners.
func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) {
var rows []mdb.RpcNode
err := dao.Mdb.Model(&mdb.RpcNode{}).
Where("network = ?", strings.ToLower(network)).
Where("type = ?", strings.ToLower(nodeType)).
Where("enabled = ?", true).
Where("(purpose IN ? OR purpose = '' OR purpose IS NULL)", []string{mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeBoth}).
Find(&rows).Error
if err != nil {
return nil, err
@@ -82,6 +196,144 @@ func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) {
return pickWeighted(candidates), nil
}
// ListManualPaymentRpcCandidates returns enabled RPC nodes for manual payment
// verification. Ordinary RPCs are tried first to avoid paid endpoint usage;
// manual_verify nodes are used only as a fallback.
func ListManualPaymentRpcCandidates(network, nodeType string) ([]mdb.RpcNode, error) {
var rows []mdb.RpcNode
err := dao.Mdb.Model(&mdb.RpcNode{}).
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
Where("type = ?", strings.ToLower(strings.TrimSpace(nodeType))).
Where("enabled = ?", true).
Where("(status IN ? OR status = '' OR status IS NULL)", []string{mdb.RpcNodeStatusOk, mdb.RpcNodeStatusUnknown}).
Find(&rows).Error
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, nil
}
buckets := make([][]mdb.RpcNode, 4)
for _, row := range rows {
row.Purpose = NormalizeRpcNodePurpose(row.Purpose)
switch row.Purpose {
case mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeBoth:
if row.Status == mdb.RpcNodeStatusOk {
buckets[0] = append(buckets[0], row)
} else {
buckets[1] = append(buckets[1], row)
}
case mdb.RpcNodePurposeManualVerify:
if row.Status == mdb.RpcNodeStatusOk {
buckets[2] = append(buckets[2], row)
} else {
buckets[3] = append(buckets[3], row)
}
}
}
out := make([]mdb.RpcNode, 0, len(rows))
for _, bucket := range buckets {
sortRpcNodes(bucket)
out = append(out, bucket...)
}
return out, nil
}
func sortRpcNodes(rows []mdb.RpcNode) {
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].Weight != rows[j].Weight {
return rows[i].Weight > rows[j].Weight
}
return rows[i].ID < rows[j].ID
})
}
func RecordRpcNodeSuccess(nodeID uint64) {
if nodeID == 0 {
return
}
gRpcFailover.Lock()
defer gRpcFailover.Unlock()
delete(gRpcFailover.failures, nodeID)
delete(gRpcFailover.cooldownUntil, nodeID)
}
func RecordRpcNodeFailure(nodeID uint64) (int, bool) {
if nodeID == 0 {
return 0, false
}
now := time.Now()
gRpcFailover.Lock()
defer gRpcFailover.Unlock()
if until, ok := gRpcFailover.cooldownUntil[nodeID]; ok {
if now.Before(until) {
return gRpcFailover.failures[nodeID], true
}
delete(gRpcFailover.cooldownUntil, nodeID)
delete(gRpcFailover.failures, nodeID)
}
gRpcFailover.failures[nodeID]++
failures := gRpcFailover.failures[nodeID]
if failures >= RpcFailoverThreshold {
gRpcFailover.failures[nodeID] = 0
gRpcFailover.cooldownUntil[nodeID] = now.Add(RpcFailoverCooldown)
return failures, true
}
return failures, false
}
func IsRpcNodeCoolingDown(nodeID uint64) bool {
if nodeID == 0 {
return false
}
now := time.Now()
gRpcFailover.Lock()
defer gRpcFailover.Unlock()
until, ok := gRpcFailover.cooldownUntil[nodeID]
if !ok {
return false
}
if now.Before(until) {
return true
}
delete(gRpcFailover.cooldownUntil, nodeID)
delete(gRpcFailover.failures, nodeID)
return false
}
func RpcNodeLogLabel(node mdb.RpcNode) string {
return fmt.Sprintf("id=%d network=%s type=%s endpoint=%s", node.ID, node.Network, node.Type, safeRpcEndpoint(node.Url))
}
func safeRpcEndpoint(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
parsed, err := url.Parse(raw)
if err == nil && parsed.Host != "" {
if parsed.Scheme != "" {
return parsed.Scheme + "://" + parsed.Host
}
return parsed.Host
}
cut := strings.IndexAny(raw, "/?")
if cut >= 0 {
return raw[:cut]
}
return raw
}
func ResetRpcFailoverForTest() {
gRpcFailover.Lock()
defer gRpcFailover.Unlock()
gRpcFailover.failures = make(map[uint64]int)
gRpcFailover.cooldownUntil = make(map[uint64]time.Time)
}
// pickWeighted chooses one row using the Weight column. Weights < 1 are
// coerced to 1 so an admin-zeroed row still has a chance (use Enabled=false
// to truly disable).
+304
View File
@@ -1,6 +1,7 @@
package data
import (
"strings"
"testing"
"github.com/GMWalletApp/epusdt/internal/testutil"
@@ -99,3 +100,306 @@ func TestSelectRpcNodeIgnoresDownRows(t *testing.T) {
t.Fatalf("SelectRpcNode() = %#v, want nil", got)
}
}
func TestSelectRpcNodeExcludesManualVerifyRows(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkTron,
Url: "https://manual.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 err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkTron,
Url: "https://general.example.com",
Type: mdb.RpcNodeTypeHttp,
Weight: 1,
Enabled: true,
Purpose: mdb.RpcNodePurposeGeneral,
Status: mdb.RpcNodeStatusUnknown,
}).Error; err != nil {
t.Fatalf("seed general rpc_node: %v", err)
}
got, err := SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
if err != nil {
t.Fatalf("SelectRpcNode(): %v", err)
}
if got == nil || got.Url != "https://general.example.com" {
t.Fatalf("SelectRpcNode() = %#v, want general row", got)
}
}
func TestSelectRpcNodePurposeFilterAppliesToAnyNodeType(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
const nodeType = "grpc"
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkTron,
Url: "grpc://manual.example.com",
Type: nodeType,
Weight: 100,
Enabled: true,
Purpose: mdb.RpcNodePurposeManualVerify,
Status: mdb.RpcNodeStatusOk,
}).Error; err != nil {
t.Fatalf("seed manual rpc_node: %v", err)
}
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkTron,
Url: "grpc://general.example.com",
Type: nodeType,
Weight: 1,
Enabled: true,
Purpose: mdb.RpcNodePurposeGeneral,
Status: mdb.RpcNodeStatusOk,
}).Error; err != nil {
t.Fatalf("seed general rpc_node: %v", err)
}
got, err := SelectRpcNode(mdb.NetworkTron, nodeType)
if err != nil {
t.Fatalf("SelectRpcNode(): %v", err)
}
if got == nil || got.Url != "grpc://general.example.com" {
t.Fatalf("SelectRpcNode() = %#v, want general grpc row", got)
}
}
func TestSelectRpcNodeManualVerifyOnlyReturnsNil(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkTron,
Url: "https://manual-only.example.com",
Type: mdb.RpcNodeTypeHttp,
Weight: 1,
Enabled: true,
Purpose: mdb.RpcNodePurposeManualVerify,
Status: mdb.RpcNodeStatusOk,
}).Error; err != nil {
t.Fatalf("seed manual rpc_node: %v", err)
}
got, err := SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
if err != nil {
t.Fatalf("SelectRpcNode(): %v", err)
}
if got != nil {
t.Fatalf("SelectRpcNode() = %#v, want nil", got)
}
}
func TestListManualPaymentRpcCandidatesGeneralBeforeManualVerify(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
rows := []mdb.RpcNode{
{Network: mdb.NetworkTron, Url: "https://manual-ok.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
{Network: mdb.NetworkTron, Url: "https://general-unknown.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkTron, Url: "https://general-ok.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
{Network: mdb.NetworkTron, Url: "https://manual-unknown.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkTron, Url: "https://down.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusDown},
}
if err := dao.Mdb.Create(&rows).Error; err != nil {
t.Fatalf("seed rpc_nodes: %v", err)
}
got, err := ListManualPaymentRpcCandidates(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
if err != nil {
t.Fatalf("ListManualPaymentRpcCandidates(): %v", err)
}
var urls []string
for _, row := range got {
urls = append(urls, row.Url)
}
want := []string{
"https://general-ok.example.com",
"https://general-unknown.example.com",
"https://manual-ok.example.com",
"https://manual-unknown.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 TestListManualPaymentRpcCandidatesAllowsManualVerifyFallbackOnly(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
rows := []mdb.RpcNode{
{Network: mdb.NetworkTron, Url: "https://manual-ok.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
{Network: mdb.NetworkTron, Url: "https://manual-down.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusDown},
}
if err := dao.Mdb.Create(&rows).Error; err != nil {
t.Fatalf("seed rpc_nodes: %v", err)
}
got, err := ListManualPaymentRpcCandidates(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
if err != nil {
t.Fatalf("ListManualPaymentRpcCandidates(): %v", err)
}
if len(got) != 1 || got[0].Url != "https://manual-ok.example.com" {
t.Fatalf("candidate rows = %#v, want only manual ok fallback", got)
}
}
func TestSelectGeneralRpcNodeSkipsCoolingNode(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
ResetRpcFailoverForTest()
t.Cleanup(ResetRpcFailoverForTest)
rows := []mdb.RpcNode{
{Network: mdb.NetworkTron, Url: "https://primary.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
{Network: mdb.NetworkTron, Url: "https://backup.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)
}
first, err := SelectGeneralRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
if err != nil {
t.Fatalf("SelectGeneralRpcNode(): %v", err)
}
if first == nil || first.Url != "https://primary.example.com" {
t.Fatalf("first node = %#v, want primary", first)
}
for i := 0; i < RpcFailoverThreshold; i++ {
RecordRpcNodeFailure(first.ID)
}
got, err := SelectGeneralRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
if err != nil {
t.Fatalf("SelectGeneralRpcNode(): %v", err)
}
if got == nil || got.Url != "https://backup.example.com" {
t.Fatalf("node after cooldown = %#v, want backup", got)
}
}
func TestSelectGeneralRpcNodeFallsBackWhenAllNodesCooling(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
ResetRpcFailoverForTest()
t.Cleanup(ResetRpcFailoverForTest)
rows := []mdb.RpcNode{
{Network: mdb.NetworkTron, Url: "https://primary.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
{Network: mdb.NetworkTron, Url: "https://backup.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)
}
for i := range rows {
for n := 0; n < RpcFailoverThreshold; n++ {
RecordRpcNodeFailure(rows[i].ID)
}
}
got, err := SelectGeneralRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
if err != nil {
t.Fatalf("SelectGeneralRpcNode(): %v", err)
}
if got == nil || got.Url != "https://primary.example.com" {
t.Fatalf("SelectGeneralRpcNode() = %#v, want primary when every node is cooling", got)
}
}
func TestSelectGeneralRpcNodeReturnsNilWhenExcludedAlternatesCooling(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
ResetRpcFailoverForTest()
t.Cleanup(ResetRpcFailoverForTest)
rows := []mdb.RpcNode{
{Network: mdb.NetworkTron, Url: "https://primary.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
{Network: mdb.NetworkTron, Url: "https://backup.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)
}
for i := 0; i < RpcFailoverThreshold; i++ {
RecordRpcNodeFailure(rows[1].ID)
}
got, err := SelectGeneralRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp, rows[0].ID)
if err != nil {
t.Fatalf("SelectGeneralRpcNode(): %v", err)
}
if got != nil {
t.Fatalf("SelectGeneralRpcNode() = %#v, want nil when only alternate is cooling", got)
}
}
func TestRecordRpcNodeSuccessClearsCooldown(t *testing.T) {
ResetRpcFailoverForTest()
t.Cleanup(ResetRpcFailoverForTest)
const nodeID uint64 = 42
for i := 0; i < RpcFailoverThreshold; i++ {
RecordRpcNodeFailure(nodeID)
}
if !IsRpcNodeCoolingDown(nodeID) {
t.Fatal("node should be cooling after threshold")
}
RecordRpcNodeSuccess(nodeID)
if IsRpcNodeCoolingDown(nodeID) {
t.Fatal("node should not be cooling after success")
}
failures, cooling := RecordRpcNodeFailure(nodeID)
if cooling || failures != 1 {
t.Fatalf("failure state after success = failures=%d cooling=%v, want failures=1 cooling=false", failures, cooling)
}
}
func TestRpcNodeLogLabelHidesPathAndQuery(t *testing.T) {
label := RpcNodeLogLabel(mdb.RpcNode{
BaseModel: mdb.BaseModel{ID: 7},
Network: mdb.NetworkEthereum,
Type: mdb.RpcNodeTypeWs,
Url: "wss://rpc.example.com/v3/secret-key?token=secret",
})
if strings.Contains(label, "secret") || strings.Contains(label, "/v3/") {
t.Fatalf("RpcNodeLogLabel() leaked sensitive URL parts: %s", label)
}
if !strings.Contains(label, "wss://rpc.example.com") {
t.Fatalf("RpcNodeLogLabel() = %s, want host", label)
}
}
func TestListRpcNodesForHealthExcludesManualVerify(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
rows := []mdb.RpcNode{
{Network: mdb.NetworkTron, Url: "https://general.example.com", Type: mdb.RpcNodeTypeHttp, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkTron, Url: "https://manual.example.com", Type: mdb.RpcNodeTypeHttp, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
}
if err := dao.Mdb.Create(&rows).Error; err != nil {
t.Fatalf("seed rpc_nodes: %v", err)
}
got, err := ListRpcNodesForHealth()
if err != nil {
t.Fatalf("ListRpcNodesForHealth(): %v", err)
}
if len(got) != 1 || got[0].Url != "https://general.example.com" {
t.Fatalf("ListRpcNodesForHealth() = %#v, want only general row", got)
}
}
+17 -10
View File
@@ -13,19 +13,26 @@ const (
RpcNodeStatusDown = "down"
)
// RpcNode records one RPC endpoint for a network. Clients select a node
// by weight among rows where Enabled=true AND Status=ok. A periodic
// task/rpc_health_job writes Status/LastLatencyMs/LastCheckedAt.
const (
RpcNodePurposeGeneral = "general"
RpcNodePurposeManualVerify = "manual_verify"
RpcNodePurposeBoth = "both"
)
// RpcNode records one RPC endpoint for a network. Runtime selection filters by
// Type, Status and Purpose so paid manual-verification nodes stay isolated.
type RpcNode struct {
Network string `gorm:"column:network;size:32;index:rpc_nodes_network_status_index,priority:1" json:"network" example:"tron"`
Url string `gorm:"column:url;size:512" json:"url" example:"https://api.trongrid.io"`
Network string `gorm:"column:network;size:32;index:rpc_nodes_network_status_index,priority:1" json:"network" example:"tron"`
Url string `gorm:"column:url;size:512" json:"url" example:"https://api.trongrid.io"`
// 连接类型 http=HTTP请求 ws=WebSocket长连接
Type string `gorm:"column:type;size:16" json:"type" enums:"http,ws" example:"http"`
Weight int `gorm:"column:weight;default:1" json:"weight" example:"1"`
ApiKey string `gorm:"column:api_key;size:255" json:"api_key" example:"your-api-key"`
Enabled bool `gorm:"column:enabled;default:true" json:"enabled" example:"true"`
Type string `gorm:"column:type;size:16" json:"type" enums:"http,ws" example:"http"`
Weight int `gorm:"column:weight;default:1" json:"weight" example:"1"`
ApiKey string `gorm:"column:api_key;size:255" json:"api_key" example:"your-api-key"`
Enabled bool `gorm:"column:enabled;default:true" json:"enabled" example:"true"`
// 用途 general=通用 manual_verify=补单专用 both=通用+补单
Purpose string `gorm:"column:purpose;size:32;default:general;index:rpc_nodes_purpose_index" json:"purpose" enums:"general,manual_verify,both" example:"general"`
// 健康状态 unknown=未知 ok=正常 down=异常
Status string `gorm:"column:status;size:16;default:unknown;index:rpc_nodes_network_status_index,priority:2" json:"status" enums:"unknown,ok,down" example:"ok"`
Status string `gorm:"column:status;size:16;default:unknown;index:rpc_nodes_network_status_index,priority:2" json:"status" enums:"unknown,ok,down" example:"ok"`
LastLatencyMs int `gorm:"column:last_latency_ms;default:-1" json:"last_latency_ms" example:"120"`
LastCheckedAt carbon.Time `gorm:"column:last_checked_at" json:"last_checked_at" example:"2026-04-16 12:00:00"`
BaseModel
+7 -4
View File
@@ -4,8 +4,11 @@ package response
// Used for swagger documentation only — the actual response is built
// by util/http.Resp.
type ApiResponse struct {
StatusCode int `json:"status_code" example:"200"`
Message string `json:"message" example:"success"`
Data interface{} `json:"data"`
RequestID string `json:"request_id" example:"req-123456"`
// Stable errno used by the frontend for i18n. Success is 200; failures use codes from util/constant.Errno.
StatusCode int `json:"status_code" example:"200"`
// Default server-side errno text. Frontend should prefer status_code for display translation.
Message string `json:"message" example:"success"`
Data interface{} `json:"data"`
// Request id echoed from X-Request-ID when present.
RequestID string `json:"request_id" example:"req-123456"`
}
+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)