From cfa9beba8c4e86a2aa04ada57c03ee77890e1be0 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 7 Jul 2026 10:12:29 +0700 Subject: [PATCH] feat(coin): allow upstream-supported tickers --- internal/modules/coin/handlers_test.go | 47 +++++++++--- internal/modules/coin/helpers.go | 2 +- internal/modules/coin/price_providers.go | 92 ++++++++++++++++++++++-- internal/modules/coin/prices.go | 1 + internal/modules/coin/prices_test.go | 73 +++++++++++++++++++ internal/modules/coin/symbols.go | 41 +++++++---- 6 files changed, 227 insertions(+), 29 deletions(-) diff --git a/internal/modules/coin/handlers_test.go b/internal/modules/coin/handlers_test.go index 7a05fa4..6af5663 100644 --- a/internal/modules/coin/handlers_test.go +++ b/internal/modules/coin/handlers_test.go @@ -61,9 +61,45 @@ func TestResolveCoinSymbol(t *testing.T) { if err != nil || coin.Symbol != "BTC" || coin.CoinGeckoID != "bitcoin" { t.Fatalf("ResolveCoinSymbol btc = %+v, %v", coin, err) } - if _, err := ResolveCoinSymbol("NOPE"); !errors.Is(err, ErrUnsupportedCoin) { + coin, err = ResolveCoinSymbol("ena") + if err != nil || coin.Symbol != "ENA" || coin.CoinGeckoID != "" { + t.Fatalf("ResolveCoinSymbol ena = %+v, %v", coin, err) + } + for _, input := range []string{"", "123", "BAD-COIN", strings.Repeat("A", maxCoinSymbolLength+1)} { + if _, err := ResolveCoinSymbol(input); !errors.Is(err, ErrUnsupportedCoin) { + t.Fatalf("ResolveCoinSymbol(%q) got %v, want ErrUnsupportedCoin", input, err) + } + } +} + +func TestHandlePriceAllowsUnlistedTicker(t *testing.T) { + s := newTestState(map[string]CoinPrice{"ENA": {USD: 0.08, Source: "Binance"}}, nil) + rb := testutil.NewRecordingBot(t) + if err := s.handlePrice(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/coin_price ena")); err != nil { + t.Fatalf("handlePrice: %v", err) + } + rb.AssertSentText(t, "ENA price: $0.08 (Binance)") +} + +func TestHandlePriceReturnsNoPriceForUnlistedTickerWhenProvidersFail(t *testing.T) { + s := newTestState(nil, nil) + rb := testutil.NewRecordingBot(t) + if err := s.handlePrice(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/coin_price nope")); err != nil { + t.Fatalf("handlePrice: %v", err) + } + rb.AssertSentText(t, "No coin price available") +} + +func TestHandlePriceRejectsMalformedCoinTicker(t *testing.T) { + s := newTestState(nil, nil) + rb := testutil.NewRecordingBot(t) + if err := s.handlePrice(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/coin_price bad-coin")); err != nil { + t.Fatalf("handlePrice: %v", err) + } + if _, err := ResolveCoinSymbol("bad-coin"); !errors.Is(err, ErrUnsupportedCoin) { t.Fatalf("got %v, want ErrUnsupportedCoin", err) } + rb.AssertSentText(t, "Invalid coin ticker") } func TestModuleRegistersExpectedCommands(t *testing.T) { @@ -88,15 +124,6 @@ func TestHandlePrice(t *testing.T) { rb.AssertSentText(t, "BTC price: $67,000.00 (Binance)") } -func TestHandlePriceRejectsUnsupportedCoin(t *testing.T) { - s := newTestState(nil, nil) - rb := testutil.NewRecordingBot(t) - if err := s.handlePrice(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/coin_price nope")); err != nil { - t.Fatalf("handlePrice: %v", err) - } - rb.AssertSentText(t, "Unsupported coin") -} - func TestHandleTopup(t *testing.T) { ctx := context.Background() s := newTestState(nil, nil) diff --git a/internal/modules/coin/helpers.go b/internal/modules/coin/helpers.go index 5bd5ffe..ed1ea8d 100644 --- a/internal/modules/coin/helpers.go +++ b/internal/modules/coin/helpers.go @@ -72,7 +72,7 @@ func isSafeUSD(n float64) bool { func (s *state) replyPriceError(ctx context.Context, b *bot.Bot, update *models.Update, err error) error { if errors.Is(err, ErrUnsupportedCoin) { - return chathelper.Reply(ctx, b, update.Message, "Unsupported coin. Supported: BTC, ETH, SOL, BNB, XRP, ADA, DOGE, TON.") + return chathelper.Reply(ctx, b, update.Message, "Invalid coin ticker. Use 1-20 letters/numbers, with at least one letter.") } if errors.Is(err, ErrNoCoinPrice) { return chathelper.Reply(ctx, b, update.Message, "No coin price available.") diff --git a/internal/modules/coin/price_providers.go b/internal/modules/coin/price_providers.go index 0381d94..685bd1d 100644 --- a/internal/modules/coin/price_providers.go +++ b/internal/modules/coin/price_providers.go @@ -24,8 +24,9 @@ type CoinbaseProvider struct { } type CoinGeckoProvider struct { - HTTP *http.Client - URL string + HTTP *http.Client + URL string + SearchURL string } type binanceResponse struct { @@ -44,6 +45,16 @@ type coinGeckoQuote struct { USD float64 `json:"usd"` } +type coinGeckoSearchResponse struct { + Coins []coinGeckoSearchCoin `json:"coins"` +} + +type coinGeckoSearchCoin struct { + ID string `json:"id"` + Symbol string `json:"symbol"` + MarketCapRank *int `json:"market_cap_rank"` +} + func (p *BinanceProvider) FetchUSD(ctx context.Context, coin CoinSymbol) (CoinPrice, error) { for _, quote := range []string{"USDT", "USD"} { price, err := p.fetchPair(ctx, coin.Symbol, quote) @@ -127,12 +138,34 @@ func (p *CoinbaseProvider) baseURL() string { } func (p *CoinGeckoProvider) FetchUSD(ctx context.Context, coin CoinSymbol) (CoinPrice, error) { + if coin.CoinGeckoID != "" { + return p.fetchID(ctx, coin, coin.CoinGeckoID) + } + price, err := p.fetchID(ctx, coin, strings.ToLower(coin.Symbol)) + if err == nil { + return price, nil + } + if !errors.Is(err, ErrNoCoinPrice) { + return CoinPrice{}, err + } + id, err := p.searchID(ctx, coin.Symbol) + if err != nil { + return CoinPrice{}, err + } + return p.fetchID(ctx, coin, id) +} + +func (p *CoinGeckoProvider) fetchID(ctx context.Context, coin CoinSymbol, id string) (CoinPrice, error) { + id = strings.TrimSpace(id) + if id == "" { + return CoinPrice{}, ErrNoCoinPrice + } endpoint := p.baseURL() if err := validateEndpoint(endpoint); err != nil { return CoinPrice{}, err } q := url.Values{} - q.Set("ids", coin.CoinGeckoID) + q.Set("ids", id) q.Set("vs_currencies", "usd") q.Set("include_last_updated_at", "true") resp, err := getJSON(ctx, p.HTTP, endpoint+"?"+q.Encode()) @@ -147,13 +180,57 @@ func (p *CoinGeckoProvider) FetchUSD(ctx context.Context, coin CoinSymbol) (Coin if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return CoinPrice{}, fmt.Errorf("coin: CoinGecko decode: %w", err) } - quote := body[coin.CoinGeckoID] + quote := body[id] if quote.USD <= 0 { return CoinPrice{}, ErrNoCoinPrice } return CoinPrice{Symbol: coin.Symbol, USD: quote.USD, Source: "CoinGecko"}, nil } +func (p *CoinGeckoProvider) searchID(ctx context.Context, symbol string) (string, error) { + endpoint := p.searchURL() + if err := validateEndpoint(endpoint); err != nil { + return "", err + } + q := url.Values{} + q.Set("query", symbol) + resp, err := getJSON(ctx, p.HTTP, endpoint+"?"+q.Encode()) + if err != nil { + return "", fmt.Errorf("coin: CoinGecko search request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", ErrNoCoinPrice + } + var body coinGeckoSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return "", fmt.Errorf("coin: CoinGecko search decode: %w", err) + } + best := coinGeckoSearchCoin{} + for _, candidate := range body.Coins { + if candidate.ID == "" || !strings.EqualFold(candidate.Symbol, symbol) { + continue + } + if best.ID == "" || betterCoinGeckoSearchMatch(candidate, best) { + best = candidate + } + } + if best.ID == "" { + return "", ErrNoCoinPrice + } + return best.ID, nil +} + +func betterCoinGeckoSearchMatch(candidate, current coinGeckoSearchCoin) bool { + if candidate.MarketCapRank == nil { + return false + } + if current.MarketCapRank == nil { + return true + } + return *candidate.MarketCapRank < *current.MarketCapRank +} + func (p *CoinGeckoProvider) baseURL() string { if strings.TrimSpace(p.URL) != "" { return strings.TrimSpace(p.URL) @@ -161,6 +238,13 @@ func (p *CoinGeckoProvider) baseURL() string { return coinGeckoDefaultURL } +func (p *CoinGeckoProvider) searchURL() string { + if strings.TrimSpace(p.SearchURL) != "" { + return strings.TrimSpace(p.SearchURL) + } + return coinGeckoSearchURL +} + func getJSON(ctx context.Context, client *http.Client, endpoint string) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { diff --git a/internal/modules/coin/prices.go b/internal/modules/coin/prices.go index 0b63f9f..1ad8711 100644 --- a/internal/modules/coin/prices.go +++ b/internal/modules/coin/prices.go @@ -13,6 +13,7 @@ const ( binanceDefaultURL = "https://data-api.binance.vision/api/v3/ticker/price" coinbaseDefaultURL = "https://api.coinbase.com/v2/exchange-rates" coinGeckoDefaultURL = "https://api.coingecko.com/api/v3/simple/price" + coinGeckoSearchURL = "https://api.coingecko.com/api/v3/search" // coinHTTPTimeout caps a single provider call, kept under the handler // deadline so one slow provider cannot starve the Telegram reply budget // (see chathelper.FetchContext). diff --git a/internal/modules/coin/prices_test.go b/internal/modules/coin/prices_test.go index 017429f..57e5a9b 100644 --- a/internal/modules/coin/prices_test.go +++ b/internal/modules/coin/prices_test.go @@ -28,6 +28,24 @@ func TestBinanceProviderFetchUSD(t *testing.T) { } } +func TestBinanceProviderFetchesUnlistedTicker(t *testing.T) { + coin := CoinSymbol{Symbol: "ENA"} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("symbol"); got != "ENAUSDT" { + t.Fatalf("symbol = %q, want ENAUSDT", got) + } + _, _ = w.Write([]byte(`{"symbol":"ENAUSDT","price":"0.077"}`)) + })) + defer srv.Close() + price, err := (&BinanceProvider{URL: srv.URL}).FetchUSD(context.Background(), coin) + if err != nil { + t.Fatalf("FetchUSD: %v", err) + } + if price.USD != 0.077 || price.Source != "Binance" || price.Symbol != "ENA" { + t.Fatalf("price = %+v", price) + } +} + func TestBinanceRateLimitDoesNotTrySecondPair(t *testing.T) { var hits atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -78,6 +96,61 @@ func TestCoinGeckoProviderFetchUSD(t *testing.T) { } } +func TestCoinGeckoProviderFetchesUnmappedTickerByLowercaseID(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("ids"); got != "re" { + t.Fatalf("ids = %q, want re", got) + } + _, _ = w.Write([]byte(`{"re":{"usd":0.64}}`)) + })) + defer srv.Close() + price, err := (&CoinGeckoProvider{URL: srv.URL}).FetchUSD(context.Background(), CoinSymbol{Symbol: "RE"}) + if err != nil { + t.Fatalf("FetchUSD: %v", err) + } + if price.USD != 0.64 || price.Source != "CoinGecko" { + t.Fatalf("price = %+v", price) + } +} + +func TestCoinGeckoProviderSearchesUnmappedTickerWhenIDDiffers(t *testing.T) { + var simpleIDs []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/simple": + id := r.URL.Query().Get("ids") + simpleIDs = append(simpleIDs, id) + if id == "ethena" { + _, _ = w.Write([]byte(`{"ethena":{"usd":0.077}}`)) + return + } + _, _ = w.Write([]byte(`{}`)) + case "/search": + if got := r.URL.Query().Get("query"); got != "ENA" { + t.Fatalf("query = %q, want ENA", got) + } + _, _ = w.Write([]byte(`{"coins":[{"id":"ethena-usde","symbol":"USDE","market_cap_rank":27},{"id":"ethena","symbol":"ENA","market_cap_rank":85}]}`)) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + defer srv.Close() + + price, err := (&CoinGeckoProvider{ + URL: srv.URL + "/simple", + SearchURL: srv.URL + "/search", + }).FetchUSD(context.Background(), CoinSymbol{Symbol: "ENA"}) + if err != nil { + t.Fatalf("FetchUSD: %v", err) + } + if price.USD != 0.077 || price.Source != "CoinGecko" { + t.Fatalf("price = %+v", price) + } + if len(simpleIDs) != 2 || simpleIDs[0] != "ena" || simpleIDs[1] != "ethena" { + t.Fatalf("simple ids = %v, want [ena ethena]", simpleIDs) + } +} + type fakeProvider struct { price CoinPrice err error diff --git a/internal/modules/coin/symbols.go b/internal/modules/coin/symbols.go index 09c6861..02f0298 100644 --- a/internal/modules/coin/symbols.go +++ b/internal/modules/coin/symbols.go @@ -3,6 +3,7 @@ package coin import ( "errors" "strings" + "unicode" ) var ErrUnsupportedCoin = errors.New("coin: unsupported coin") @@ -12,25 +13,37 @@ type CoinSymbol struct { CoinGeckoID string } -var supportedCoins = map[string]CoinSymbol{ - "BTC": {Symbol: "BTC", CoinGeckoID: "bitcoin"}, - "ETH": {Symbol: "ETH", CoinGeckoID: "ethereum"}, - "SOL": {Symbol: "SOL", CoinGeckoID: "solana"}, - "BNB": {Symbol: "BNB", CoinGeckoID: "binancecoin"}, - "XRP": {Symbol: "XRP", CoinGeckoID: "ripple"}, - "ADA": {Symbol: "ADA", CoinGeckoID: "cardano"}, - "DOGE": {Symbol: "DOGE", CoinGeckoID: "dogecoin"}, - "TON": {Symbol: "TON", CoinGeckoID: "the-open-network"}, +const maxCoinSymbolLength = 20 + +var knownCoinGeckoIDs = map[string]string{ + "BTC": "bitcoin", + "ETH": "ethereum", + "SOL": "solana", + "BNB": "binancecoin", + "XRP": "ripple", + "ADA": "cardano", + "DOGE": "dogecoin", + "TON": "the-open-network", } func ResolveCoinSymbol(input string) (CoinSymbol, error) { symbol := strings.ToUpper(strings.TrimSpace(input)) - if symbol == "" { + if !validCoinSymbol(symbol) { return CoinSymbol{}, ErrUnsupportedCoin } - coin, ok := supportedCoins[symbol] - if !ok { - return CoinSymbol{}, ErrUnsupportedCoin + return CoinSymbol{Symbol: symbol, CoinGeckoID: knownCoinGeckoIDs[symbol]}, nil +} + +func validCoinSymbol(symbol string) bool { + if symbol == "" || len(symbol) > maxCoinSymbolLength { + return false } - return coin, nil + hasLetter := false + for _, r := range symbol { + if r > unicode.MaxASCII || (!unicode.IsLetter(r) && !unicode.IsDigit(r)) { + return false + } + hasLetter = hasLetter || unicode.IsLetter(r) + } + return hasLetter }