diff --git a/src/controller/comm/supported_asset_controller.go b/src/controller/comm/supported_asset_controller.go index e6c35c0..e447e86 100644 --- a/src/controller/comm/supported_asset_controller.go +++ b/src/controller/comm/supported_asset_controller.go @@ -10,6 +10,7 @@ import ( "github.com/GMWalletApp/epusdt/model/data" "github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/model/response" + "github.com/GMWalletApp/epusdt/model/service" "github.com/GMWalletApp/epusdt/util/constant" "github.com/labstack/echo/v4" ) @@ -44,11 +45,7 @@ func buildSupportedAssets() ([]response.NetworkTokenSupport, error) { } symbols := make([]string, 0, len(tokens)) for _, t := range tokens { - contractAddress := strings.TrimSpace(t.ContractAddress) - if network == mdb.NetworkAptos && !aptosPublicPaymentToken(t.Symbol) { - continue - } - if network == mdb.NetworkAptos && contractAddress == "" { + if !service.ChainTokenReadyForPayment(t) { continue } sym := strings.ToUpper(strings.TrimSpace(t.Symbol)) @@ -75,15 +72,6 @@ func buildSupportedAssets() ([]response.NetworkTokenSupport, error) { return supports, nil } -func aptosPublicPaymentToken(symbol string) bool { - switch strings.ToUpper(strings.TrimSpace(symbol)) { - case "USDT", "USDC": - return true - default: - return false - } -} - // GetPublicConfig returns payment/site config consumed by cashier/frontends. // The public route returns brand/site display config plus non-sensitive // epay/okpay knobs, while the authenticated admin route also includes OkPay diff --git a/src/controller/comm/supported_asset_controller_test.go b/src/controller/comm/supported_asset_controller_test.go index 2803077..ad6a017 100644 --- a/src/controller/comm/supported_asset_controller_test.go +++ b/src/controller/comm/supported_asset_controller_test.go @@ -9,7 +9,7 @@ import ( "github.com/GMWalletApp/epusdt/model/mdb" ) -func TestBuildSupportedAssetsSkipsMisconfiguredAptosTokens(t *testing.T) { +func TestBuildSupportedAssetsIncludesConfiguredAptosTokens(t *testing.T) { cleanup := testutil.SetupTestDatabases(t) defer cleanup() @@ -21,6 +21,7 @@ func TestBuildSupportedAssetsSkipsMisconfiguredAptosTokens(t *testing.T) { } for _, row := range []mdb.ChainToken{ {Network: mdb.NetworkAptos, Symbol: "USDT", ContractAddress: "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b", Decimals: 6, Enabled: true}, + {Network: mdb.NetworkAptos, Symbol: "MOVEUSD", ContractAddress: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Decimals: 6, Enabled: true}, {Network: mdb.NetworkAptos, Symbol: "USDC", ContractAddress: "", Decimals: 6, Enabled: true}, } { if err := dao.Mdb.Create(&row).Error; err != nil { @@ -38,7 +39,13 @@ func TestBuildSupportedAssetsSkipsMisconfiguredAptosTokens(t *testing.T) { if supports[0].Network != mdb.NetworkAptos { t.Fatalf("network = %q, want %q", supports[0].Network, mdb.NetworkAptos) } - if len(supports[0].Tokens) != 1 || supports[0].Tokens[0] != "USDT" { - t.Fatalf("tokens = %#v, want [USDT]", supports[0].Tokens) + want := []string{"MOVEUSD", "USDT"} + if len(supports[0].Tokens) != len(want) { + t.Fatalf("tokens = %#v, want %#v", supports[0].Tokens, want) + } + for i := range want { + if supports[0].Tokens[i] != want[i] { + t.Fatalf("tokens = %#v, want %#v", supports[0].Tokens, want) + } } } diff --git a/src/model/service/aptos_task.go b/src/model/service/aptos_task.go index a68c3ba..1db459d 100644 --- a/src/model/service/aptos_task.go +++ b/src/model/service/aptos_task.go @@ -7,7 +7,6 @@ import ( "net/http" "strconv" "strings" - "sync" "github.com/GMWalletApp/epusdt/model/data" "github.com/GMWalletApp/epusdt/model/mdb" @@ -16,15 +15,6 @@ import ( "github.com/tidwall/gjson" ) -const defaultAptosFullnodeURL = "https://aptos-rest.publicnode.com/" - -var aptosFullnodeURL = defaultAptosFullnodeURL -var aptosFixedNodeLogOnce sync.Once - -func AptosFixedFullnodeURL() string { - return strings.TrimSpace(aptosFullnodeURL) -} - func AptosGetLedgerVersion() (int64, error) { body, err := aptosGet("/v1") if err != nil { @@ -37,7 +27,7 @@ func AptosGetLedgerVersion() (int64, error) { func AptosGetTransactionByHash(txID string) ([]byte, error) { txID = normalizeAptosTxID(txID) - log.Sugar.Infof("[APTOS] fetch transaction by hash tx=%s rpc=%s", txID, aptosFullnodeURL) + log.Sugar.Infof("[APTOS] fetch transaction by hash tx=%s", txID) return aptosGet(fmt.Sprintf("/v1/transactions/by_hash/%s", txID)) } @@ -48,7 +38,7 @@ func AptosGetTransactions(start int64, limit int64) ([]byte, error) { if limit <= 0 { limit = 1 } - log.Sugar.Debugf("[APTOS] fetch transactions start=%d limit=%d rpc=%s", start, limit, aptosFullnodeURL) + log.Sugar.Debugf("[APTOS] fetch transactions start=%d limit=%d", start, limit) return aptosGet(fmt.Sprintf("/v1/transactions?start=%d&limit=%d", start, limit)) } @@ -236,16 +226,7 @@ func aptosTransferMatchKey(symbol string, rawAmount *big.Int) string { } func resolveAptosPaymentToken(assetID string, tokens []mdb.ChainToken) *mdb.ChainToken { - token := resolveMoveToken(mdb.NetworkAptos, assetID, tokens) - if token == nil { - return nil - } - switch NormalizeAptosPaymentSymbol(token.Symbol) { - case "USDT", "USDC": - return token - default: - return nil - } + return resolveMoveToken(mdb.NetworkAptos, assetID, tokens) } func NormalizeAptosPaymentSymbol(symbol string) string { @@ -336,17 +317,50 @@ func normalizeAptosTxID(txID string) string { func ValidateManualAptosPayment(order *mdb.Orders, txID string) (string, error) { txID = normalizeAptosTxID(txID) - body, err := AptosGetTransactionByHash(txID) + nodes, err := data.ListManualPaymentRpcCandidates(mdb.NetworkAptos, mdb.RpcNodeTypeHttp) + if err != nil { + return "", err + } + if len(nodes) == 0 { + return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkAptos, mdb.RpcNodeTypeHttp) + } + + var verifyErrors []string + for _, node := range nodes { + if strings.TrimSpace(node.Url) == "" { + continue + } + if _, err = validateManualAptosPaymentWithNode(order, txID, node); err != nil { + verifyErrors = append(verifyErrors, fmt.Sprintf("%s: %v", data.RpcNodeLogLabel(node), err)) + continue + } + return txID, nil + } + if len(verifyErrors) > 0 { + return "", fmt.Errorf("manual Aptos verification failed: %s", strings.Join(verifyErrors, "; ")) + } + return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkAptos, mdb.RpcNodeTypeHttp) +} + +func validateManualAptosPaymentWithNode(order *mdb.Orders, txID string, node mdb.RpcNode) (string, error) { + txID = normalizeAptosTxID(txID) + body, err := aptosGetWithNode(node, fmt.Sprintf("/v1/transactions/by_hash/%s", txID)) if err != nil { return "", fmt.Errorf("fetch aptos transaction: %w", err) } - if err = validateManualAptosTransaction(order, txID, body); err != nil { + if err = validateManualAptosTransactionWithConfirm(order, txID, body, func(version int64) error { + return ensureAptosTransferConfirmedWithNode(version, node) + }); err != nil { return "", err } return txID, nil } func validateManualAptosTransaction(order *mdb.Orders, txID string, body []byte) error { + return validateManualAptosTransactionWithConfirm(order, txID, body, EnsureAptosTransferConfirmed) +} + +func validateManualAptosTransactionWithConfirm(order *mdb.Orders, txID string, body []byte, confirm func(int64) error) error { tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkAptos) if err != nil { return err @@ -360,7 +374,7 @@ func validateManualAptosTransaction(order *mdb.Orders, txID string, body []byte) if !strings.EqualFold(transfer.TxID, txID) { continue } - if err = EnsureAptosTransferConfirmed(transfer.Version); err != nil { + if err = confirm(transfer.Version); err != nil { return err } if err = EnsureMoveTransferMatchesOrder(order, transfer); err == nil { @@ -381,8 +395,58 @@ func validateManualAptosTransaction(order *mdb.Orders, txID string, body []byte) func aptosGet(path string) (body []byte, err error) { network := mdb.NetworkAptos defer recordMoveRPCResult(network, &err) - logAptosFixedNode() - endpoint, err := aptosJoinURL(aptosFullnodeURL, path) + + tried := make([]uint64, 0, 3) + var lastErr error + for attempts := 0; attempts < 3; attempts++ { + node, resolveErr := resolveAptosRpcNode(tried...) + if resolveErr != nil { + if lastErr != nil { + return nil, lastErr + } + return nil, resolveErr + } + if len(tried) > 0 { + log.Sugar.Warnf("[APTOS] trying alternate RPC node path=%s node=%s", path, data.RpcNodeLogLabel(*node)) + } + + body, err = aptosGetWithNode(*node, path) + 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("[APTOS] RPC node reached fail threshold path=%s node=%s failures=%d/%d", path, nodeLabel, failures, data.RpcFailoverThreshold) + } else { + log.Sugar.Warnf("[APTOS] RPC node failed path=%s node=%s failures=%d/%d", path, nodeLabel, failures, data.RpcFailoverThreshold) + } + tried = append(tried, node.ID) + } + return nil, lastErr +} + +func resolveAptosRpcNode(excludeIDs ...uint64) (*mdb.RpcNode, error) { + node, err := data.SelectGeneralRpcNode(mdb.NetworkAptos, mdb.RpcNodeTypeHttp, excludeIDs...) + if err != nil { + return nil, err + } + if node == nil || node.ID == 0 { + return nil, fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkAptos, mdb.RpcNodeTypeHttp) + } + rpcURL := strings.TrimSpace(node.Url) + if rpcURL == "" { + return nil, fmt.Errorf("rpc_nodes id=%d has empty url", node.ID) + } + node.Url = rpcURL + return node, nil +} + +func aptosGetWithNode(node mdb.RpcNode, path string) (body []byte, err error) { + endpoint, err := aptosJoinURL(node.Url, path) if err != nil { return nil, err } @@ -423,12 +487,6 @@ func aptosJoinURL(base, path string) (string, error) { return trimmedBase + path, nil } -func logAptosFixedNode() { - aptosFixedNodeLogOnce.Do(func() { - log.Sugar.Infof("[APTOS] using fixed public fullnode url=%s", AptosFixedFullnodeURL()) - }) -} - func EnsureAptosTransferConfirmed(version int64) error { if version <= 0 { return fmt.Errorf("aptos transaction version missing") @@ -440,6 +498,18 @@ func EnsureAptosTransferConfirmed(version int64) error { return ensureAptosConfirmations(version, latest) } +func ensureAptosTransferConfirmedWithNode(version int64, node mdb.RpcNode) error { + if version <= 0 { + return fmt.Errorf("aptos transaction version missing") + } + body, err := aptosGetWithNode(node, "/v1") + if err != nil { + return fmt.Errorf("fetch aptos latest ledger version: %w", err) + } + latest := gjson.GetBytes(body, "ledger_version").Int() + return ensureAptosConfirmations(version, latest) +} + func ensureAptosConfirmations(version, latest int64) error { chain, err := data.GetChainByNetwork(mdb.NetworkAptos) if err != nil { diff --git a/src/model/service/manual_payment_verify_test.go b/src/model/service/manual_payment_verify_test.go index 26fd95b..69b7702 100644 --- a/src/model/service/manual_payment_verify_test.go +++ b/src/model/service/manual_payment_verify_test.go @@ -155,7 +155,7 @@ func TestValidateManualAptosPayment(t *testing.T) { } })) defer server.Close() - overrideAptosFullnodeURLForTest(t, server.URL) + seedTestAptosRPCNode(t, server.URL, mdb.RpcNodePurposeGeneral, mdb.RpcNodeStatusOk, 100) order := &mdb.Orders{ BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(1699999999000))}, diff --git a/src/model/service/move_rpc_routing_test.go b/src/model/service/move_rpc_routing_test.go index 2e1b25d..56912af 100644 --- a/src/model/service/move_rpc_routing_test.go +++ b/src/model/service/move_rpc_routing_test.go @@ -4,7 +4,6 @@ import ( "net/http" "net/http/httptest" "strings" - "sync" "testing" "github.com/GMWalletApp/epusdt/internal/testutil" @@ -15,26 +14,60 @@ import ( "github.com/dromara/carbon/v2" ) -func overrideAptosFullnodeURLForTest(t *testing.T, url string) { +func seedTestAptosRPCNode(t *testing.T, url, purpose, status string, weight int) mdb.RpcNode { t.Helper() - oldURL := aptosFullnodeURL - aptosFullnodeURL = url - aptosFixedNodeLogOnce = sync.Once{} - t.Cleanup(func() { - aptosFullnodeURL = oldURL - aptosFixedNodeLogOnce = sync.Once{} - }) + node := mdb.RpcNode{ + Network: mdb.NetworkAptos, + Url: url, + Type: mdb.RpcNodeTypeHttp, + Weight: weight, + Enabled: true, + Purpose: purpose, + Status: status, + } + if node.Purpose == "" { + node.Purpose = mdb.RpcNodePurposeGeneral + } + if node.Status == "" { + node.Status = mdb.RpcNodeStatusOk + } + if err := dao.Mdb.Create(&node).Error; err != nil { + t.Fatalf("seed aptos rpc_nodes: %v", err) + } + return node } -func TestAptosGetLedgerVersionUsesFixedPublicNode(t *testing.T) { +func TestAptosGetLedgerVersionUsesRpcNodes(t *testing.T) { cleanup := testutil.SetupTestDatabases(t) defer cleanup() data.ResetRpcFailoverForTest() t.Cleanup(data.ResetRpcFailoverForTest) - var fixedCalls int - fixed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fixedCalls++ + var disabledCalls int + disabled := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + disabledCalls++ + _, _ = w.Write([]byte(`{"ledger_version":"999"}`)) + })) + defer disabled.Close() + disabledNode := mdb.RpcNode{ + Network: mdb.NetworkAptos, + Url: disabled.URL, + Type: mdb.RpcNodeTypeHttp, + Weight: 1000, + Enabled: false, + Purpose: mdb.RpcNodePurposeGeneral, + Status: mdb.RpcNodeStatusOk, + } + if err := dao.Mdb.Create(&disabledNode).Error; err != nil { + t.Fatalf("seed disabled aptos rpc_nodes: %v", err) + } + if err := dao.Mdb.Model(&mdb.RpcNode{}).Where("id = ?", disabledNode.ID).Update("enabled", false).Error; err != nil { + t.Fatalf("disable aptos rpc_nodes: %v", err) + } + + var calls int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ if r.URL.Path != "/v1" { http.NotFound(w, r) return @@ -42,15 +75,8 @@ func TestAptosGetLedgerVersionUsesFixedPublicNode(t *testing.T) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"ledger_version":"123"}`)) })) - defer fixed.Close() - overrideAptosFullnodeURLForTest(t, fixed.URL) - - rows := []mdb.RpcNode{ - {Network: mdb.NetworkAptos, Url: "https://old-aptos-rpc.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeBoth, Status: mdb.RpcNodeStatusOk}, - } - if err := dao.Mdb.Create(&rows).Error; err != nil { - t.Fatalf("seed aptos rpc_nodes: %v", err) - } + defer server.Close() + seedTestAptosRPCNode(t, server.URL, mdb.RpcNodePurposeGeneral, mdb.RpcNodeStatusOk, 100) got, err := AptosGetLedgerVersion() if err != nil { @@ -59,12 +85,53 @@ func TestAptosGetLedgerVersionUsesFixedPublicNode(t *testing.T) { if got != 123 { t.Fatalf("ledger version = %d, want 123", got) } - if fixedCalls != 1 { - t.Fatalf("fixed node calls = %d, want 1", fixedCalls) + if calls != 1 { + t.Fatalf("rpc calls = %d, want 1", calls) + } + if disabledCalls != 0 { + t.Fatalf("disabled rpc calls = %d, want 0", disabledCalls) } } -func TestValidateManualAptosPaymentUsesFixedPublicNode(t *testing.T) { +func TestAptosGetTransactionsFallsBackToAlternateRpcNode(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + data.ResetRpcFailoverForTest() + t.Cleanup(data.ResetRpcFailoverForTest) + + var primaryCalls int + primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + primaryCalls++ + http.Error(w, "temporary failure", http.StatusBadGateway) + })) + defer primary.Close() + var backupCalls int + backup := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + backupCalls++ + if r.URL.Path != "/v1/transactions" || r.URL.Query().Get("start") != "10" || r.URL.Query().Get("limit") != "2" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + })) + defer backup.Close() + seedTestAptosRPCNode(t, primary.URL, mdb.RpcNodePurposeGeneral, mdb.RpcNodeStatusOk, 100) + seedTestAptosRPCNode(t, backup.URL, mdb.RpcNodePurposeGeneral, mdb.RpcNodeStatusOk, 1) + + body, err := AptosGetTransactions(10, 2) + if err != nil { + t.Fatalf("AptosGetTransactions(): %v", err) + } + if string(body) != "[]" { + t.Fatalf("transactions body = %s, want []", string(body)) + } + if primaryCalls != 1 || backupCalls != 1 { + t.Fatalf("rpc calls primary=%d backup=%d, want 1/1", primaryCalls, backupCalls) + } +} + +func TestValidateManualAptosPaymentUsesManualRpcFallback(t *testing.T) { cleanup := testutil.SetupTestDatabases(t) defer cleanup() @@ -93,9 +160,15 @@ func TestValidateManualAptosPaymentUsesFixedPublicNode(t *testing.T) { t.Fatalf("normalize aptos sender store: %v", err) } - var fixedCalls int - fixed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fixedCalls++ + var generalCalls int + general := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + generalCalls++ + http.Error(w, "general unavailable", http.StatusBadGateway) + })) + defer general.Close() + var manualCalls int + manual := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + manualCalls++ w.Header().Set("Content-Type", "application/json") switch { case strings.HasPrefix(r.URL.Path, "/v1/transactions/by_hash/"): @@ -106,16 +179,9 @@ func TestValidateManualAptosPaymentUsesFixedPublicNode(t *testing.T) { http.NotFound(w, r) } })) - defer fixed.Close() - overrideAptosFullnodeURLForTest(t, fixed.URL) - - rows := []mdb.RpcNode{ - {Network: mdb.NetworkAptos, Url: "https://old-both.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeBoth, Status: mdb.RpcNodeStatusOk}, - {Network: mdb.NetworkAptos, Url: "https://old-manual.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk}, - } - if err := dao.Mdb.Create(&rows).Error; err != nil { - t.Fatalf("seed aptos rpc_nodes: %v", err) - } + defer manual.Close() + seedTestAptosRPCNode(t, general.URL, mdb.RpcNodePurposeGeneral, mdb.RpcNodeStatusOk, 100) + seedTestAptosRPCNode(t, manual.URL, mdb.RpcNodePurposeManualVerify, mdb.RpcNodeStatusOk, 1) order := &mdb.Orders{ BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(1699999999000))}, @@ -131,7 +197,7 @@ func TestValidateManualAptosPaymentUsesFixedPublicNode(t *testing.T) { if got != "0xabc" { t.Fatalf("canonical tx id = %q, want %q", got, "0xabc") } - if fixedCalls != 2 { - t.Fatalf("fixed node calls = %d, want 2", fixedCalls) + if generalCalls != 1 || manualCalls != 2 { + t.Fatalf("rpc calls general=%d manual=%d, want 1/2", generalCalls, manualCalls) } } diff --git a/src/model/service/move_task_test.go b/src/model/service/move_task_test.go index d13d7d8..129392e 100644 --- a/src/model/service/move_task_test.go +++ b/src/model/service/move_task_test.go @@ -130,6 +130,45 @@ func TestParseAptosTransfersUSDTAndUSDCFungibleEvents(t *testing.T) { } } +func TestParseAptosTransfersConfiguredCustomFungibleEvent(t *testing.T) { + receive, _ := addressutil.NormalizeMoveAddress("0xa") + custom := "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + store, _ := addressutil.NormalizeMoveAddress("0x21") + senderStore, _ := addressutil.NormalizeMoveAddress("0x22") + body := []byte(`[ + { + "type":"user_transaction", + "success":true, + "hash":"custom", + "version":"201", + "timestamp":"1700000000123456", + "events":[ + {"type":"0x1::fungible_asset::Withdraw","data":{"amount":"7500000","store":"` + senderStore + `"}}, + {"type":"0x1::fungible_asset::Deposit","data":{"amount":"7500000","store":"` + store + `"}} + ], + "changes":[ + {"address":"` + senderStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + custom + `"}}},"type":"write_resource"}, + {"address":"` + store + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + custom + `"}}},"type":"write_resource"}, + {"address":"` + store + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive + `"}},"type":"write_resource"} + ] + } + ]`) + tokens := []mdb.ChainToken{ + {Network: mdb.NetworkAptos, Symbol: "MOVEUSD", ContractAddress: custom, Decimals: 6, Enabled: true}, + } + + got, err := ParseAptosTransfers(body, receive, tokens) + if err != nil { + t.Fatalf("ParseAptosTransfers: %v", err) + } + if len(got) != 1 { + t.Fatalf("transfers len = %d, want 1: %#v", len(got), got) + } + if got[0].Token != "MOVEUSD" || got[0].Amount != 7.5 || got[0].TxID != "0xcustom" { + t.Fatalf("custom transfer = %#v", got[0]) + } +} + func TestParseAptosTransfersSplitDepositsMatchSingleWithdraw(t *testing.T) { receive, _ := addressutil.NormalizeMoveAddress("0xa") receive2, _ := addressutil.NormalizeMoveAddress("0xb") diff --git a/src/model/service/order_service.go b/src/model/service/order_service.go index 70acbd2..1101386 100644 --- a/src/model/service/order_service.go +++ b/src/model/service/order_service.go @@ -72,15 +72,7 @@ func ensureEnabledOrderAsset(network, token string) (*mdb.ChainToken, error) { if tokenRow == nil || tokenRow.ID == 0 { return nil, constant.SupportedAssetNotFound } - if tokenRow.Network == mdb.NetworkAptos { - switch NormalizeAptosPaymentSymbol(tokenRow.Symbol) { - case "USDT", "USDC": - default: - return nil, constant.SupportedAssetNotFound - } - } - contractAddress := strings.TrimSpace(tokenRow.ContractAddress) - if tokenRow.Network == mdb.NetworkAptos && contractAddress == "" { + if !ChainTokenReadyForPayment(*tokenRow) { return nil, constant.SupportedAssetNotFound } return tokenRow, nil diff --git a/src/model/service/order_service_test.go b/src/model/service/order_service_test.go index f1a6799..ab54e1a 100644 --- a/src/model/service/order_service_test.go +++ b/src/model/service/order_service_test.go @@ -496,6 +496,29 @@ func TestCreateTransactionRejectsEnabledMisconfiguredAptosUSDT(t *testing.T) { } } +func TestEnsureEnabledOrderAssetAllowsConfiguredAptosCustomToken(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + if err := dao.Mdb.Create(&mdb.ChainToken{ + Network: mdb.NetworkAptos, + Symbol: "MOVEUSD", + ContractAddress: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Decimals: 6, + Enabled: true, + }).Error; err != nil { + t.Fatalf("create aptos custom token: %v", err) + } + + token, err := ensureEnabledOrderAsset(mdb.NetworkAptos, "MOVEUSD") + if err != nil { + t.Fatalf("ensureEnabledOrderAsset(): %v", err) + } + if token.Symbol != "MOVEUSD" { + t.Fatalf("token symbol = %q, want MOVEUSD", token.Symbol) + } +} + func TestCreateTransactionSupportedTonTokenWithoutRateReturnsRateError(t *testing.T) { cleanup := testutil.SetupTestDatabases(t) defer cleanup() diff --git a/src/model/service/payment_asset.go b/src/model/service/payment_asset.go new file mode 100644 index 0000000..d2facc7 --- /dev/null +++ b/src/model/service/payment_asset.go @@ -0,0 +1,32 @@ +package service + +import ( + "strings" + + "github.com/GMWalletApp/epusdt/model/mdb" +) + +func ChainTokenReadyForPayment(token mdb.ChainToken) bool { + if strings.TrimSpace(token.Symbol) == "" { + return false + } + if chainTokenRequiresAssetID(token) && strings.TrimSpace(token.ContractAddress) == "" { + return false + } + return true +} + +func chainTokenRequiresAssetID(token mdb.ChainToken) bool { + network := strings.ToLower(strings.TrimSpace(token.Network)) + symbol := strings.ToUpper(strings.TrimSpace(token.Symbol)) + switch network { + case mdb.NetworkTron: + return symbol != "TRX" + case mdb.NetworkSolana: + return symbol != "SOL" + case mdb.NetworkTon: + return symbol != TonNativeSymbol + default: + return true + } +} diff --git a/src/task/listen_aptos_scanner.go b/src/task/listen_aptos_scanner.go index 858724c..f2b2070 100644 --- a/src/task/listen_aptos_scanner.go +++ b/src/task/listen_aptos_scanner.go @@ -52,8 +52,7 @@ var ( func StartAptosLedgerScannerListener() { provider := serviceAptosProvider{} cursor := &aptosRuntimeCursor{} - log.Sugar.Infof("[APTOS] ledger scanner starting mode=full_ledger rpc=%s chunk_size=%d workers=%d queue_limit=%d", - service.AptosFixedFullnodeURL(), + log.Sugar.Infof("[APTOS] ledger scanner starting mode=full_ledger chunk_size=%d workers=%d queue_limit=%d", aptosLedgerChunkSize, aptosLedgerWorkerCount, aptosLedgerQueueLimit, @@ -85,9 +84,8 @@ func runAptosLedgerScanner(ctx context.Context, provider aptosChainProvider, cur if err != nil { return err } - state.tokens = filterAptosPaymentTokens(state.tokens) if len(state.wallets) == 0 || len(state.tokens) == 0 { - log.Sugar.Debug("[APTOS] no enabled wallets or USDT/USDC tokens, idling") + log.Sugar.Debug("[APTOS] no enabled wallets or tokens, idling") if !sleepOrDone(ctx, interval) { return nil } @@ -249,17 +247,6 @@ func processAptosLedgerRange(ctx context.Context, provider aptosChainProvider, i return nil } -func filterAptosPaymentTokens(tokens []mdb.ChainToken) []mdb.ChainToken { - out := make([]mdb.ChainToken, 0, len(tokens)) - for _, token := range tokens { - switch service.NormalizeAptosPaymentSymbol(token.Symbol) { - case "USDT", "USDC": - out = append(out, token) - } - } - return out -} - func chooseAptosIdleDelay(catchup bool, interval time.Duration) time.Duration { if catchup { return moveCatchupYield diff --git a/src/task/listen_aptos_scanner_test.go b/src/task/listen_aptos_scanner_test.go index 2378078..c50a8bc 100644 --- a/src/task/listen_aptos_scanner_test.go +++ b/src/task/listen_aptos_scanner_test.go @@ -81,6 +81,7 @@ func seedAptosScannerChain(t *testing.T, wallets ...string) []string { if err := dao.Mdb.Create(&[]mdb.ChainToken{ {Network: mdb.NetworkAptos, Symbol: "USDC", ContractAddress: "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b", Decimals: 6, Enabled: true}, {Network: mdb.NetworkAptos, Symbol: "USDT", ContractAddress: "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b", Decimals: 6, Enabled: true}, + {Network: mdb.NetworkAptos, Symbol: "MOVEUSD", ContractAddress: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Decimals: 6, Enabled: true}, }).Error; err != nil { t.Fatalf("seed Aptos tokens: %v", err) } @@ -101,6 +102,41 @@ func seedAptosScannerChain(t *testing.T, wallets ...string) []string { return out } +func TestProcessAptosLedgerRangePassesConfiguredTokens(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + resetAptosScannerHooks(t) + seedAptosScannerChain(t, "0xa") + + state, err := loadMoveWatchState(mdb.NetworkAptos) + if err != nil { + t.Fatalf("load state: %v", err) + } + var gotSymbols []string + parseAptosTransfersForWallets = func(_ []byte, _ map[string]struct{}, tokens []mdb.ChainToken) ([]service.MoveObservedTransfer, error) { + for _, token := range tokens { + gotSymbols = append(gotSymbols, token.Symbol) + } + sort.Strings(gotSymbols) + return nil, nil + } + provider := &fakeAptosProvider{bodiesByStart: map[int64][]byte{100: []byte("[]")}} + + err = processAptosLedgerRange(context.Background(), provider, aptosLedgerRange{start: 100, limit: 1, state: state}, 100) + if err != nil { + t.Fatalf("processAptosLedgerRange(): %v", err) + } + want := []string{"MOVEUSD", "USDC", "USDT"} + if len(gotSymbols) != len(want) { + t.Fatalf("tokens = %#v, want %#v", gotSymbols, want) + } + for i := range want { + if gotSymbols[i] != want[i] { + t.Fatalf("tokens = %#v, want %#v", gotSymbols, want) + } + } +} + func TestRunAptosLedgerScannerIdlesWhenChainDisabled(t *testing.T) { cleanup := testutil.SetupTestDatabases(t) defer cleanup() @@ -161,7 +197,6 @@ func TestProcessAptosLedgerRoundDoesNotAdvanceUnconfirmedVersion(t *testing.T) { if err != nil { t.Fatalf("load state: %v", err) } - state.tokens = filterAptosPaymentTokens(state.tokens) catchup, err := processAptosLedgerRound(context.Background(), provider, state, cursor, 99) if err != nil { @@ -188,7 +223,6 @@ func TestProcessAptosLedgerRoundAdvancesAfterSuccessfulRange(t *testing.T) { if err != nil { t.Fatalf("load state: %v", err) } - state.tokens = filterAptosPaymentTokens(state.tokens) provider := &fakeAptosProvider{bodiesByStart: map[int64][]byte{100: []byte("[]")}} cursor := &aptosRuntimeCursor{initialized: true, lastSeenVersion: 99} @@ -218,7 +252,6 @@ func TestProcessAptosLedgerRoundDoesNotAdvanceOnRangeFailure(t *testing.T) { if err != nil { t.Fatalf("load state: %v", err) } - state.tokens = filterAptosPaymentTokens(state.tokens) provider := &fakeAptosProvider{errByStart: map[int64]error{100: errors.New("temporary rpc failure")}} cursor := &aptosRuntimeCursor{initialized: true, lastSeenVersion: 99} @@ -240,7 +273,6 @@ func TestProcessAptosLedgerRoundProcessesMultipleChunks(t *testing.T) { if err != nil { t.Fatalf("load state: %v", err) } - state.tokens = filterAptosPaymentTokens(state.tokens) provider := &fakeAptosProvider{ bodiesByStart: map[int64][]byte{ 100: []byte("[]"), @@ -316,7 +348,6 @@ func TestProcessAptosLedgerRoundMarksMatchingUSDTOrderPaid(t *testing.T) { if err != nil { t.Fatalf("load state: %v", err) } - state.tokens = filterAptosPaymentTokens(state.tokens) provider := &fakeAptosProvider{bodiesByStart: map[int64][]byte{101: body}} cursor := &aptosRuntimeCursor{initialized: true, lastSeenVersion: 100} @@ -351,7 +382,6 @@ func TestProcessAptosLedgerRoundDoesNotReprocessDuplicateTransferKey(t *testing. if err != nil { t.Fatalf("load state: %v", err) } - state.tokens = filterAptosPaymentTokens(state.tokens) provider := &fakeAptosProvider{ bodiesByStart: map[int64][]byte{ 101: aptosFungibleTransferBody(t, "0xabc", 101, receive, usdc, "1200000"),