mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 10:16:15 +00:00
854 lines
24 KiB
Go
854 lines
24 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
|
"github.com/GMWalletApp/epusdt/model/dao"
|
|
"github.com/GMWalletApp/epusdt/model/data"
|
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
|
"github.com/GMWalletApp/epusdt/model/request"
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
func setupSolanaRPCNode(t *testing.T, url string) func() {
|
|
t.Helper()
|
|
|
|
cleanup := testutil.SetupTestDatabases(t)
|
|
node := &mdb.RpcNode{
|
|
Network: mdb.NetworkSolana,
|
|
Url: url,
|
|
Type: mdb.RpcNodeTypeHttp,
|
|
Weight: 1,
|
|
Enabled: true,
|
|
Purpose: mdb.RpcNodePurposeGeneral,
|
|
Status: mdb.RpcNodeStatusOk,
|
|
}
|
|
if err := dao.Mdb.Create(node).Error; err != nil {
|
|
cleanup()
|
|
t.Fatalf("seed solana rpc_node: %v", err)
|
|
}
|
|
return cleanup
|
|
}
|
|
|
|
func TestResolveSolanaRpcURLRequiresRpcNode(t *testing.T) {
|
|
cleanup := testutil.SetupTestDatabases(t)
|
|
defer cleanup()
|
|
|
|
if got, err := resolveSolanaRpcURL(); err == nil {
|
|
t.Fatalf("resolveSolanaRpcURL() = %q, nil; want error", got)
|
|
}
|
|
}
|
|
|
|
func TestResolveSolanaRpcURLWithRow(t *testing.T) {
|
|
cleanup := setupSolanaRPCNode(t, " https://solana.example.com ")
|
|
defer cleanup()
|
|
|
|
got, err := resolveSolanaRpcURL()
|
|
if err != nil {
|
|
t.Fatalf("resolveSolanaRpcURL(): %v", err)
|
|
}
|
|
if got != "https://solana.example.com" {
|
|
t.Fatalf("resolveSolanaRpcURL() = %q, want https://solana.example.com", got)
|
|
}
|
|
}
|
|
|
|
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 TestSolGetBlockHeightDoesNotAffectRpcNodeFailover(t *testing.T) {
|
|
cleanup := testutil.SetupTestDatabases(t)
|
|
defer cleanup()
|
|
data.ResetRpcFailoverForTest()
|
|
data.ResetRpcRuntimeStatsForTest()
|
|
t.Cleanup(data.ResetRpcFailoverForTest)
|
|
t.Cleanup(data.ResetRpcRuntimeStatsForTest)
|
|
|
|
oldRetryCount := solRPCRetryCount
|
|
solRPCRetryCount = 0
|
|
t.Cleanup(func() {
|
|
solRPCRetryCount = oldRetryCount
|
|
})
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "temporary", http.StatusBadGateway)
|
|
}))
|
|
defer server.Close()
|
|
|
|
node := &mdb.RpcNode{
|
|
Network: mdb.NetworkSolana,
|
|
Url: server.URL,
|
|
Type: mdb.RpcNodeTypeHttp,
|
|
Weight: 1,
|
|
Enabled: true,
|
|
Purpose: mdb.RpcNodePurposeGeneral,
|
|
Status: mdb.RpcNodeStatusOk,
|
|
}
|
|
if err := dao.Mdb.Create(node).Error; err != nil {
|
|
t.Fatalf("seed solana rpc_node: %v", err)
|
|
}
|
|
|
|
for i := 0; i < data.RpcFailoverThreshold+1; i++ {
|
|
if _, err := SolGetBlockHeight(); err == nil {
|
|
t.Fatalf("SolGetBlockHeight attempt %d unexpectedly succeeded", i+1)
|
|
}
|
|
}
|
|
|
|
if data.IsRpcNodeCoolingDown(node.ID) {
|
|
t.Fatal("SolGetBlockHeight failures should not affect rpc_node failover cooldown")
|
|
}
|
|
stats := data.SnapshotRpcRuntimeStats()
|
|
if got := stats[mdb.NetworkSolana].FailureCount; got != int64(data.RpcFailoverThreshold+1) {
|
|
t.Fatalf("solana runtime failure_count = %d, want %d", got, data.RpcFailoverThreshold+1)
|
|
}
|
|
}
|
|
|
|
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()
|
|
clearProcessedSignatures()
|
|
t.Cleanup(clearProcessedSignatures)
|
|
|
|
const (
|
|
address = "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
|
|
sig = "retryable-sol-signature"
|
|
tradeID = "sol-retryable-order-001"
|
|
amount = 1.23
|
|
)
|
|
blockTime := time.Now().Add(time.Minute).Unix()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var rpcReq struct {
|
|
Method string `json:"method"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&rpcReq); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch rpcReq.Method {
|
|
case "getSignaturesForAddress":
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"result": []map[string]interface{}{
|
|
{
|
|
"signature": sig,
|
|
"slot": 1,
|
|
"err": nil,
|
|
"blockTime": blockTime,
|
|
},
|
|
},
|
|
})
|
|
case "getTransaction":
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"result": map[string]interface{}{
|
|
"blockTime": blockTime,
|
|
"meta": map[string]interface{}{"err": nil},
|
|
"transaction": map[string]interface{}{
|
|
"message": map[string]interface{}{
|
|
"instructions": []map[string]interface{}{
|
|
{
|
|
"programId": SystemProgramID,
|
|
"parsed": map[string]interface{}{
|
|
"type": "transfer",
|
|
"info": map[string]interface{}{
|
|
"source": "source-wallet",
|
|
"destination": address,
|
|
"lamports": 1_230_000_000,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
default:
|
|
http.Error(w, "unexpected method "+rpcReq.Method, http.StatusBadRequest)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
if err := dao.Mdb.Create(&mdb.RpcNode{
|
|
Network: mdb.NetworkSolana,
|
|
Url: server.URL,
|
|
Type: mdb.RpcNodeTypeHttp,
|
|
Weight: 1,
|
|
Enabled: true,
|
|
Status: mdb.RpcNodeStatusOk,
|
|
}).Error; err != nil {
|
|
t.Fatalf("seed solana rpc_node: %v", err)
|
|
}
|
|
if err := dao.Mdb.Create(&mdb.ChainToken{
|
|
Network: mdb.NetworkSolana,
|
|
Symbol: "SOL",
|
|
ContractAddress: "",
|
|
Decimals: SOL_Decimals,
|
|
Enabled: true,
|
|
}).Error; err != nil {
|
|
t.Fatalf("seed SOL chain token: %v", err)
|
|
}
|
|
order := &mdb.Orders{
|
|
TradeId: tradeID,
|
|
OrderId: "merchant-sol-retryable-001",
|
|
Amount: 100,
|
|
Currency: "CNY",
|
|
ActualAmount: amount,
|
|
ReceiveAddress: address,
|
|
Token: "SOL",
|
|
Network: mdb.NetworkSolana,
|
|
Status: mdb.StatusWaitPay,
|
|
}
|
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
|
t.Fatalf("seed order: %v", err)
|
|
}
|
|
if err := data.LockTransaction(mdb.NetworkSolana, address, "SOL", tradeID, amount, time.Hour); err != nil {
|
|
t.Fatalf("lock transaction: %v", err)
|
|
}
|
|
|
|
oldProcessSolanaOrder := processSolanaOrder
|
|
calls := 0
|
|
processSolanaOrder = func(req *request.OrderProcessingRequest) error {
|
|
calls++
|
|
if req.TradeId != tradeID {
|
|
t.Fatalf("order processing trade_id = %q, want %q", req.TradeId, tradeID)
|
|
}
|
|
return errors.New("temporary database error")
|
|
}
|
|
t.Cleanup(func() {
|
|
processSolanaOrder = oldProcessSolanaOrder
|
|
})
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
SolCallBack(address, &wg)
|
|
wg.Wait()
|
|
|
|
if calls != 1 {
|
|
t.Fatalf("order processing calls = %d, want 1", calls)
|
|
}
|
|
if _, ok := gProcessedSignatures.Load(sig); ok {
|
|
t.Fatalf("signature %s was cached after retryable order processing error", sig)
|
|
}
|
|
}
|
|
|
|
func TestSolClientHealthy(t *testing.T) {
|
|
requireSolanaIntegration(t)
|
|
|
|
bodyData, err := SolRetryClient("getHealth", nil)
|
|
if err != nil {
|
|
t.Fatalf("SolRetryClient failed: %v", err)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
err = json.Unmarshal(bodyData, &result)
|
|
if err != nil {
|
|
t.Fatalf("Failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
status, ok := result["result"].(string)
|
|
if !ok {
|
|
t.Fatalf("Unexpected response format: %v", result)
|
|
}
|
|
|
|
t.Logf("RPC Health Status: %s", status)
|
|
|
|
if status != "ok" {
|
|
t.Errorf("Expected health status 'ok', got '%s'", status)
|
|
}
|
|
}
|
|
|
|
func clearProcessedSignatures() {
|
|
gProcessedSignatures.Range(func(key, value interface{}) bool {
|
|
gProcessedSignatures.Delete(key)
|
|
return true
|
|
})
|
|
}
|
|
|
|
func TestSolClientGetSignaturesForAddress(t *testing.T) {
|
|
requireSolanaIntegration(t)
|
|
|
|
// Example wallet address (replace with actual test address)
|
|
address := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
|
|
|
|
bodyData, err := SolRetryClient("getSignaturesForAddress", []interface{}{address, map[string]interface{}{"commitment": "finalized", "limit": 100}})
|
|
if err != nil {
|
|
t.Fatalf("SolRetryClient failed: %v", err)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
err = json.Unmarshal(bodyData, &result)
|
|
if err != nil {
|
|
t.Fatalf("Failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
signatures, ok := result["result"].([]interface{})
|
|
if !ok {
|
|
t.Fatalf("Unexpected response format: %v", result)
|
|
}
|
|
|
|
t.Logf("Found %d signatures for address %s", len(signatures), address)
|
|
|
|
}
|
|
|
|
func TestSolClientGetTransaction(t *testing.T) {
|
|
requireSolanaIntegration(t)
|
|
|
|
// Example transaction signature (replace with actual test signature)
|
|
sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5"
|
|
|
|
txData, err := SolRetryClient("getTransaction", []interface{}{sig, map[string]interface{}{"encoding": "jsonParsed", "commitment": "finalized"}})
|
|
if err != nil {
|
|
t.Fatalf("SolRetryClient failed: %v", err)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
err = json.Unmarshal(txData, &result)
|
|
if err != nil {
|
|
t.Fatalf("Failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
txInfo, ok := result["result"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("Unexpected response format: %v", result)
|
|
}
|
|
|
|
t.Logf("Transaction Info for signature %s: %v", sig, txInfo)
|
|
}
|
|
|
|
func requireSolanaIntegration(t *testing.T) {
|
|
t.Helper()
|
|
if testing.Short() || os.Getenv("RUN_SOLANA_INTEGRATION_TESTS") == "" {
|
|
t.Skip("set RUN_SOLANA_INTEGRATION_TESTS=1 to run Solana public RPC integration tests")
|
|
}
|
|
cleanup := setupSolanaRPCNode(t, "https://api.mainnet-beta.solana.com")
|
|
t.Cleanup(cleanup)
|
|
}
|
|
|
|
func TestFindATAAddress(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
owner string
|
|
mint string
|
|
want string
|
|
}{
|
|
{
|
|
name: "RAY token ATA",
|
|
owner: "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu",
|
|
mint: "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R",
|
|
want: "GgmJrwuP946uV8qAwsnXxzYrJqEwW6eGnsVnQZFS5rp4",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ata, err := FindATAAddress(tt.owner, tt.mint)
|
|
if err != nil {
|
|
t.Fatalf("FindATAAddress failed: %v", err)
|
|
}
|
|
|
|
t.Logf("Owner: %s", tt.owner)
|
|
t.Logf("Mint: %s", tt.mint)
|
|
t.Logf("ATA: %s", ata)
|
|
|
|
if tt.want != "" && ata != tt.want {
|
|
t.Errorf("Expected ATA %s, got %s", tt.want, ata)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMatchATAAddress(t *testing.T) {
|
|
owner := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
|
|
mint := "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R" // ray token
|
|
expectedATA := "GgmJrwuP946uV8qAwsnXxzYrJqEwW6eGnsVnQZFS5rp4"
|
|
|
|
ok := MatchAtaAddress(owner, mint, expectedATA)
|
|
t.Logf("Owner: %s", owner)
|
|
t.Logf("Mint: %s", mint)
|
|
t.Logf("Expected ATA: %s", expectedATA)
|
|
t.Logf("Match result: %v", ok)
|
|
|
|
if !ok {
|
|
t.Error("Expected ATA to match, but it didn't")
|
|
}
|
|
}
|
|
|
|
func TestMatchUsdtAtaAddress(t *testing.T) {
|
|
// Example wallet address (replace with actual test address)
|
|
owner := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
|
|
|
|
ata, err := FindATAAddress(owner, USDT_Mint)
|
|
if err != nil {
|
|
t.Fatalf("FindATAAddress failed: %v", err)
|
|
}
|
|
|
|
t.Logf("Owner: %s", owner)
|
|
t.Logf("USDT Mint: %s", USDT_Mint)
|
|
t.Logf("USDT ATA: %s", ata)
|
|
|
|
ok := MatchUsdtAtaAddress(owner, ata)
|
|
if !ok {
|
|
t.Error("Expected USDT ATA to match")
|
|
}
|
|
}
|
|
|
|
func TestMatchUsdcAtaAddress(t *testing.T) {
|
|
// Example wallet address (replace with actual test address)
|
|
owner := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
|
|
|
|
ata, err := FindATAAddress(owner, USDC_Mint)
|
|
if err != nil {
|
|
t.Fatalf("FindATAAddress failed: %v", err)
|
|
}
|
|
|
|
t.Logf("Owner: %s", owner)
|
|
t.Logf("USDC Mint: %s", USDC_Mint)
|
|
t.Logf("USDC ATA: %s", ata)
|
|
|
|
ok := MatchUsdcAtaAddress(owner, ata)
|
|
if !ok {
|
|
t.Error("Expected USDC ATA to match")
|
|
}
|
|
}
|
|
|
|
func TestAdjustAmount(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
amount uint64
|
|
decimals int
|
|
want float64
|
|
}{
|
|
{
|
|
name: "USDT amount (6 decimals)",
|
|
amount: 123456789,
|
|
decimals: 6,
|
|
want: 123.456789,
|
|
},
|
|
{
|
|
name: "USDC amount (6 decimals)",
|
|
amount: 1000000,
|
|
decimals: 6,
|
|
want: 1.0,
|
|
},
|
|
{
|
|
name: "SOL amount (9 decimals)",
|
|
amount: 1000000000,
|
|
decimals: 9,
|
|
want: 1.0,
|
|
},
|
|
{
|
|
name: "Zero amount",
|
|
amount: 0,
|
|
decimals: 6,
|
|
want: 0,
|
|
},
|
|
{
|
|
name: "Small amount",
|
|
amount: 1,
|
|
decimals: 6,
|
|
want: 0.000001,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
adjusted := ADJustAmount(tt.amount, tt.decimals)
|
|
t.Logf("Raw amount: %d, Decimals: %d, Adjusted: %.2f", tt.amount, tt.decimals, adjusted)
|
|
|
|
if adjusted != tt.want {
|
|
t.Errorf("Expected %.2f, got %.2f", tt.want, adjusted)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseTransferInfoFromInstruction_SplTransfer(t *testing.T) {
|
|
requireSolanaIntegration(t)
|
|
|
|
// SPL Token "transfer" (no mint in instruction, must look up from postTokenBalances)
|
|
sig := "3tZTwLrvmiZ59h4UzyMHPd7DPux7t9eXZgkUvEfquaoSuERrPSRNzWuSHKQM2fbiCWFDGNqoLpu2kLZnfoegVpqN"
|
|
txData, err := SolGetTransaction(sig)
|
|
if err != nil {
|
|
t.Fatalf("SolGetTransaction failed: %v", err)
|
|
}
|
|
|
|
instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array()
|
|
var found bool
|
|
for _, inst := range instructions {
|
|
info, err := ParseTransferInfoFromInstruction(inst, txData)
|
|
if err != nil {
|
|
t.Logf("parse error (ok to skip): %v", err)
|
|
continue
|
|
}
|
|
if info == nil {
|
|
continue
|
|
}
|
|
found = true
|
|
t.Logf("SPL transfer: source=%s dest=%s mint=%s amount=%.6f raw=%d blockTime=%d",
|
|
info.Source, info.Destination, info.Mint, info.Amount, info.RawAmount, info.BlockTime)
|
|
|
|
if info.Mint == "" {
|
|
t.Error("Expected mint to be resolved from postTokenBalances")
|
|
}
|
|
if info.RawAmount != 50000 {
|
|
t.Errorf("Expected raw amount 50000, got %d", info.RawAmount)
|
|
}
|
|
if info.BlockTime == 0 {
|
|
t.Error("Expected non-zero blockTime")
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("No transfer instruction found in transaction")
|
|
}
|
|
}
|
|
|
|
func TestParseTransferInfoFromInstruction_TransferChecked(t *testing.T) {
|
|
requireSolanaIntegration(t)
|
|
|
|
// SPL Token "transferChecked" (has mint and tokenAmount in instruction)
|
|
sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5"
|
|
txData, err := SolGetTransaction(sig)
|
|
if err != nil {
|
|
t.Fatalf("SolGetTransaction failed: %v", err)
|
|
}
|
|
|
|
instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array()
|
|
var found bool
|
|
for _, inst := range instructions {
|
|
info, err := ParseTransferInfoFromInstruction(inst, txData)
|
|
if err != nil {
|
|
t.Logf("parse error (ok to skip): %v", err)
|
|
continue
|
|
}
|
|
if info == nil {
|
|
continue
|
|
}
|
|
found = true
|
|
t.Logf("TransferChecked: source=%s dest=%s mint=%s amount=%.6f raw=%d blockTime=%d",
|
|
info.Source, info.Destination, info.Mint, info.Amount, info.RawAmount, info.BlockTime)
|
|
|
|
if info.Mint != USDT_Mint {
|
|
t.Errorf("Expected USDT mint %s, got %s", USDT_Mint, info.Mint)
|
|
}
|
|
if info.RawAmount != 300000 {
|
|
t.Errorf("Expected raw amount 300000, got %d", info.RawAmount)
|
|
}
|
|
if info.Amount != 0.3 {
|
|
t.Errorf("Expected amount 0.3, got %f", info.Amount)
|
|
}
|
|
if info.BlockTime == 0 {
|
|
t.Error("Expected non-zero blockTime")
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("No transferChecked instruction found in transaction")
|
|
}
|
|
}
|
|
|
|
func TestParseTransferInfoFromInstruction_SystemTransfer(t *testing.T) {
|
|
requireSolanaIntegration(t)
|
|
|
|
// System program SOL transfer
|
|
sig := "5pNMonUBvLVpxXTmyd5CGVBs49W6781g2ACnrCXhbmtz58KENYA7HSqu6hQkQweg3qQboRd8WAscphNAtiq9UtZZ"
|
|
txData, err := SolGetTransaction(sig)
|
|
if err != nil {
|
|
t.Fatalf("SolGetTransaction failed: %v", err)
|
|
}
|
|
|
|
instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array()
|
|
transferCount := 0
|
|
for _, inst := range instructions {
|
|
info, err := ParseTransferInfoFromInstruction(inst, txData)
|
|
if err != nil {
|
|
t.Logf("parse error (ok to skip): %v", err)
|
|
continue
|
|
}
|
|
if info == nil {
|
|
continue
|
|
}
|
|
transferCount++
|
|
t.Logf("System transfer #%d: source=%s dest=%s mint=%s amount=%.9f raw=%d blockTime=%d",
|
|
transferCount, info.Source, info.Destination, info.Mint, info.Amount, info.RawAmount, info.BlockTime)
|
|
|
|
if info.Mint != "SOL" {
|
|
t.Errorf("Expected mint SOL, got %s", info.Mint)
|
|
}
|
|
if info.RawAmount == 0 {
|
|
t.Error("Expected non-zero raw amount")
|
|
}
|
|
if info.BlockTime == 0 {
|
|
t.Error("Expected non-zero blockTime")
|
|
}
|
|
}
|
|
if transferCount == 0 {
|
|
t.Error("No system transfer instruction found")
|
|
}
|
|
t.Logf("Found %d system transfers", transferCount)
|
|
}
|