diff --git a/client/chain/chain_test.go b/client/chain/chain_test.go index ed55ed2a..f3845e06 100644 --- a/client/chain/chain_test.go +++ b/client/chain/chain_test.go @@ -39,6 +39,8 @@ func createClient(senderAddress cosmtypes.AccAddress, cosmosKeyring keyring.Keyr } clientCtx = clientCtx.WithNodeURI(network.TmEndpoint).WithClient(tmClient) + // configure Keyring as nil to avoid the account initialization request when running unit tests + clientCtx.Keyring = nil chainClient, err := NewChainClient( clientCtx, diff --git a/client/chain/markets_assistant.go b/client/chain/markets_assistant.go index 10b2b188..5db6ae8a 100644 --- a/client/chain/markets_assistant.go +++ b/client/chain/markets_assistant.go @@ -21,6 +21,15 @@ import ( var legacyMarketAssistantLazyInitialization sync.Once var legacyMarketAssistant MarketsAssistant +type TokenMetadata interface { + GetName() string + GetAddress() string + GetSymbol() string + GetLogo() string + GetDecimals() int32 + GetUpdatedAt() int64 +} + type MarketsAssistant struct { tokensBySymbol map[string]core.Token tokensByDenom map[string]core.Token @@ -140,6 +149,17 @@ func NewMarketsAssistant(networkName string) (MarketsAssistant, error) { func NewMarketsAssistantInitializedFromChain(ctx context.Context, exchangeClient exchange.ExchangeClient) (MarketsAssistant, error) { assistant := newMarketsAssistant() + + officialTokens, err := core.LoadTokens(exchangeClient.GetNetwork().OfficialTokensListUrl) + if err == nil { + for _, tokenMetadata := range officialTokens { + if tokenMetadata.Denom != "" { + // add tokens to the assistant ensuring all of them get assigned a unique symbol + tokenRepresentation(tokenMetadata.GetSymbol(), tokenMetadata, tokenMetadata.Denom, &assistant) + } + } + } + spotMarketsRequest := spotExchangePB.MarketsRequest{ MarketStatus: "active", } @@ -160,8 +180,8 @@ func NewMarketsAssistantInitializedFromChain(ctx context.Context, exchangeClient quoteTokenSymbol = marketInfo.GetQuoteTokenMeta().GetSymbol() } - baseToken := spotTokenRepresentation(baseTokenSymbol, marketInfo.GetBaseTokenMeta(), marketInfo.GetBaseDenom(), &assistant) - quoteToken := spotTokenRepresentation(quoteTokenSymbol, marketInfo.GetQuoteTokenMeta(), marketInfo.GetQuoteDenom(), &assistant) + baseToken := tokenRepresentation(baseTokenSymbol, marketInfo.GetBaseTokenMeta(), marketInfo.GetBaseDenom(), &assistant) + quoteToken := tokenRepresentation(quoteTokenSymbol, marketInfo.GetQuoteTokenMeta(), marketInfo.GetQuoteDenom(), &assistant) makerFeeRate := decimal.RequireFromString(marketInfo.GetMakerFeeRate()) takerFeeRate := decimal.RequireFromString(marketInfo.GetTakerFeeRate()) @@ -199,7 +219,7 @@ func NewMarketsAssistantInitializedFromChain(ctx context.Context, exchangeClient if len(marketInfo.GetQuoteTokenMeta().GetSymbol()) > 0 { quoteTokenSymbol := marketInfo.GetQuoteTokenMeta().GetSymbol() - quoteToken := derivativeTokenRepresentation(quoteTokenSymbol, marketInfo.GetQuoteTokenMeta(), marketInfo.GetQuoteDenom(), &assistant) + quoteToken := tokenRepresentation(quoteTokenSymbol, marketInfo.GetQuoteTokenMeta(), marketInfo.GetQuoteDenom(), &assistant) initialMarginRatio := decimal.RequireFromString(marketInfo.GetInitialMarginRatio()) maintenanceMarginRatio := decimal.RequireFromString(marketInfo.GetMaintenanceMarginRatio()) @@ -265,30 +285,7 @@ func uniqueSymbol(symbol string, denom string, tokenMetaSymbol string, tokenMeta return uniqueSymbol } -func spotTokenRepresentation(symbol string, tokenMeta *spotExchangePB.TokenMeta, denom string, assistant *MarketsAssistant) core.Token { - _, isPresent := assistant.tokensByDenom[denom] - - if !isPresent { - uniqueSymbol := uniqueSymbol(symbol, denom, tokenMeta.GetSymbol(), tokenMeta.GetName(), *assistant) - - newToken := core.Token{ - Name: tokenMeta.GetName(), - Symbol: symbol, - Denom: denom, - Address: tokenMeta.GetAddress(), - Decimals: tokenMeta.GetDecimals(), - Logo: tokenMeta.GetLogo(), - Updated: tokenMeta.GetUpdatedAt(), - } - - assistant.tokensByDenom[denom] = newToken - assistant.tokensBySymbol[uniqueSymbol] = newToken - } - - return assistant.tokensByDenom[denom] -} - -func derivativeTokenRepresentation(symbol string, tokenMeta *derivativeExchangePB.TokenMeta, denom string, assistant *MarketsAssistant) core.Token { +func tokenRepresentation(symbol string, tokenMeta TokenMetadata, denom string, assistant *MarketsAssistant) core.Token { _, isPresent := assistant.tokensByDenom[denom] if !isPresent { diff --git a/client/chain/markets_assistant_test.go b/client/chain/markets_assistant_test.go index 12ffb0bd..31778e70 100644 --- a/client/chain/markets_assistant_test.go +++ b/client/chain/markets_assistant_test.go @@ -2,6 +2,9 @@ package chain import ( "context" + "github.com/InjectiveLabs/sdk-go/client/common" + "net/http" + "net/http/httptest" "strings" "testing" @@ -13,7 +16,17 @@ import ( ) func TestMarketAssistantCreationUsingMarketsFromExchange(t *testing.T) { + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("[]")) + })) + defer httpServer.Close() + + network := common.NewNetwork() + network.OfficialTokensListUrl = httpServer.URL + mockExchange := exchange.MockExchangeClient{} + mockExchange.Network = network var spotMarketInfos []*spotExchangePB.SpotMarketInfo var derivativeMarketInfos []*derivativeExchangePB.DerivativeMarketInfo injUsdtSpotMarketInfo := createINJUSDTSpotMarketInfo() @@ -74,7 +87,17 @@ func TestMarketAssistantCreationUsingMarketsFromExchange(t *testing.T) { } func TestMarketAssistantCreationWithAllTokens(t *testing.T) { + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("[]")) + })) + defer httpServer.Close() + + network := common.NewNetwork() + network.OfficialTokensListUrl = httpServer.URL + mockExchange := exchange.MockExchangeClient{} + mockExchange.Network = network mockChain := MockChainClient{} smartDenomMetadata := createSmartDenomMetadata() diff --git a/client/common/network.go b/client/common/network.go index d2a5184a..5cef0b2a 100644 --- a/client/common/network.go +++ b/client/common/network.go @@ -15,7 +15,9 @@ import ( ) const ( - SessionRenewalOffset = 2 * time.Minute + MainnetTokensListUrl = "https://github.com/InjectiveLabs/injective-lists/raw/master/tokens/mainnet.json" + TestnetTokensListUrl = "https://github.com/InjectiveLabs/injective-lists/raw/master/tokens/testnet.json" + DevnetTokensListUrl = "https://github.com/InjectiveLabs/injective-lists/raw/master/tokens/devnet.json" ) func cookieByName(cookies []*http.Cookie, key string) *http.Cookie { @@ -199,6 +201,7 @@ type Network struct { ChainCookieAssistant CookieAssistant ExchangeCookieAssistant CookieAssistant ExplorerCookieAssistant CookieAssistant + OfficialTokensListUrl string } func LoadNetwork(name string, node string) Network { @@ -218,6 +221,7 @@ func LoadNetwork(name string, node string) Network { ChainCookieAssistant: &DisabledCookieAssistant{}, ExchangeCookieAssistant: &DisabledCookieAssistant{}, ExplorerCookieAssistant: &DisabledCookieAssistant{}, + OfficialTokensListUrl: MainnetTokensListUrl, } case "devnet-1": @@ -234,6 +238,7 @@ func LoadNetwork(name string, node string) Network { ChainCookieAssistant: &DisabledCookieAssistant{}, ExchangeCookieAssistant: &DisabledCookieAssistant{}, ExplorerCookieAssistant: &DisabledCookieAssistant{}, + OfficialTokensListUrl: DevnetTokensListUrl, } case "devnet": return Network{ @@ -249,6 +254,7 @@ func LoadNetwork(name string, node string) Network { ChainCookieAssistant: &DisabledCookieAssistant{}, ExchangeCookieAssistant: &DisabledCookieAssistant{}, ExplorerCookieAssistant: &DisabledCookieAssistant{}, + OfficialTokensListUrl: DevnetTokensListUrl, } case "testnet": validNodes := []string{"lb", "sentry"} @@ -303,6 +309,7 @@ func LoadNetwork(name string, node string) Network { ChainCookieAssistant: chainCookieAssistant, ExchangeCookieAssistant: exchangeCookieAssistant, ExplorerCookieAssistant: explorerCookieAssistant, + OfficialTokensListUrl: TestnetTokensListUrl, } case "mainnet": validNodes := []string{"lb"} @@ -342,6 +349,7 @@ func LoadNetwork(name string, node string) Network { ChainCookieAssistant: chainCookieAssistant, ExchangeCookieAssistant: exchangeCookieAssistant, ExplorerCookieAssistant: explorerCookieAssistant, + OfficialTokensListUrl: MainnetTokensListUrl, } } @@ -355,6 +363,7 @@ func NewNetwork() Network { ChainCookieAssistant: &DisabledCookieAssistant{}, ExchangeCookieAssistant: &DisabledCookieAssistant{}, ExplorerCookieAssistant: &DisabledCookieAssistant{}, + OfficialTokensListUrl: MainnetTokensListUrl, } } diff --git a/client/core/tokens_file_loader.go b/client/core/tokens_file_loader.go new file mode 100644 index 00000000..c56f108e --- /dev/null +++ b/client/core/tokens_file_loader.go @@ -0,0 +1,67 @@ +package core + +import ( + "encoding/json" + "fmt" + "net/http" +) + +type TokenMetadata struct { + Address string `json:"address"` + IsNative bool `json:"isNative"` + TokenVerification string `json:"tokenVerification"` + Decimals int32 `json:"decimals"` + CoinGeckoId string `json:"coinGeckoId"` + Name string `json:"name"` + Symbol string `json:"symbol"` + Logo string `json:"logo"` + Creator string `json:"creator"` + Denom string `json:"denom"` + TokenType string `json:"tokenType"` + ExternalLogo string `json:"externalLogo"` +} + +func (tm TokenMetadata) GetName() string { + return tm.Name +} + +func (tm TokenMetadata) GetAddress() string { + return tm.Address +} + +func (tm TokenMetadata) GetSymbol() string { + return tm.Symbol +} + +func (tm TokenMetadata) GetLogo() string { + return tm.Logo +} + +func (tm TokenMetadata) GetDecimals() int32 { + return tm.Decimals +} + +func (tm TokenMetadata) GetUpdatedAt() int64 { + return -1 +} + +// LoadTokens loads tokens from the given file URL +func LoadTokens(tokensFileUrl string) ([]TokenMetadata, error) { + var tokensMetadata []TokenMetadata + response, err := http.Get(tokensFileUrl) + if err != nil { + return tokensMetadata, err + } + if 400 <= response.StatusCode { + return tokensMetadata, fmt.Errorf("failed to load tokens from %s: %s", tokensFileUrl, response.Status) + } + defer response.Body.Close() + + decoder := json.NewDecoder(response.Body) + err = decoder.Decode(&tokensMetadata) + if err != nil { + return make([]TokenMetadata, 0), err + } + + return tokensMetadata, nil +} diff --git a/client/core/tokens_file_loader_test.go b/client/core/tokens_file_loader_test.go new file mode 100644 index 00000000..dd08d601 --- /dev/null +++ b/client/core/tokens_file_loader_test.go @@ -0,0 +1,73 @@ +package core + +import ( + "encoding/json" + "fmt" + "gotest.tools/v3/assert" + "net/http" + "net/http/httptest" + "testing" +) + +func TestLoadTokensFromUrl(t *testing.T) { + tokensMetadata := make([]TokenMetadata, 2) + tokensMetadata = append(tokensMetadata, TokenMetadata{ + Address: "", + IsNative: true, + Decimals: 9, + Symbol: "SOL", + Name: "Solana", + Logo: "https://imagedelivery.net/DYKOWp0iCc0sIkF-2e4dNw/2aa4deed-fa31-4d1a-ba0a-d698b84f3800/public", + Creator: "inj15jeczm4mqwtc9lk4c0cyynndud32mqd4m9xnmu", + CoinGeckoId: "solana", + Denom: "", + TokenType: "spl", + TokenVerification: "verified", + ExternalLogo: "solana.png", + }, + ) + tokensMetadata = append(tokensMetadata, TokenMetadata{ + Address: "0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270", + IsNative: false, + Decimals: 18, + Symbol: "WMATIC", + Name: "Wrapped Matic", + Logo: "https://imagedelivery.net/DYKOWp0iCc0sIkF-2e4dNw/0d061e1e-a746-4b19-1399-8187b8bb1700/public", + Creator: "inj169ed97mcnf8ay6rgvskn95n6tyt46uwvy5qgs0", + CoinGeckoId: "wmatic", + Denom: "0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270", + TokenType: "evm", + TokenVerification: "verified", + ExternalLogo: "polygon.png", + }, + ) + + metadataString, err := json.Marshal(tokensMetadata) + assert.NilError(t, err) + + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write(metadataString) + })) + defer httpServer.Close() + + loadedTokens, err := LoadTokens(httpServer.URL) + assert.NilError(t, err) + + assert.Equal(t, len(loadedTokens), len(tokensMetadata)) + + for i, metadata := range tokensMetadata { + assert.Equal(t, loadedTokens[i], metadata) + } +} + +func TestLoadTokensFromUrlReturnsNoTokensWhenRequestErrorHappens(t *testing.T) { + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer httpServer.Close() + + loadedTokens, err := LoadTokens(httpServer.URL) + assert.Error(t, err, fmt.Sprintf("failed to load tokens from %s: %v %s", httpServer.URL, http.StatusNotFound, http.StatusText(http.StatusNotFound))) + assert.Equal(t, len(loadedTokens), 0) +} diff --git a/client/exchange/exchange.go b/client/exchange/exchange.go index f0ec7892..a46c123b 100644 --- a/client/exchange/exchange.go +++ b/client/exchange/exchange.go @@ -90,6 +90,7 @@ type ExchangeClient interface { GetInfo(ctx context.Context, req *metaPB.InfoRequest) (*metaPB.InfoResponse, error) GetVersion(ctx context.Context, req *metaPB.VersionRequest) (*metaPB.VersionResponse, error) Ping(ctx context.Context, req *metaPB.PingRequest) (*metaPB.PingResponse, error) + GetNetwork() common.Network Close() } @@ -961,6 +962,10 @@ func (c *exchangeClient) StreamAccountPortfolio(ctx context.Context, accountAddr return stream, nil } +func (c *exchangeClient) GetNetwork() common.Network { + return c.network +} + func (c *exchangeClient) Close() { c.conn.Close() } diff --git a/client/exchange/exchange_test_support.go b/client/exchange/exchange_test_support.go index 2b37623a..ce5822cc 100644 --- a/client/exchange/exchange_test_support.go +++ b/client/exchange/exchange_test_support.go @@ -3,6 +3,7 @@ package exchange import ( "context" "errors" + "github.com/InjectiveLabs/sdk-go/client/common" accountPB "github.com/InjectiveLabs/sdk-go/exchange/accounts_rpc/pb" auctionPB "github.com/InjectiveLabs/sdk-go/exchange/auction_rpc/pb" @@ -16,6 +17,7 @@ import ( ) type MockExchangeClient struct { + Network common.Network SpotMarketsResponses []*spotExchangePB.MarketsResponse DerivativeMarketsResponses []*derivativeExchangePB.MarketsResponse } @@ -303,6 +305,10 @@ func (e *MockExchangeClient) Ping(ctx context.Context, req *metaPB.PingRequest) return &metaPB.PingResponse{}, nil } +func (e *MockExchangeClient) GetNetwork() common.Network { + return e.Network +} + func (e *MockExchangeClient) Close() { } diff --git a/go.mod b/go.mod index 79c9c338..bebb0fe0 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,7 @@ require ( google.golang.org/protobuf v1.31.0 gopkg.in/ini.v1 v1.67.0 gopkg.in/yaml.v2 v2.4.0 + gotest.tools/v3 v3.5.0 ) require ( diff --git a/go.sum b/go.sum index 47472ee8..d034f1ae 100644 --- a/go.sum +++ b/go.sum @@ -1039,8 +1039,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools/v3 v3.5.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY= +gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=