feat(coin): allow upstream-supported tickers

This commit is contained in:
2026-07-07 10:12:29 +07:00
parent beb263318a
commit cfa9beba8c
6 changed files with 227 additions and 29 deletions
+37 -10
View File
@@ -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)
+1 -1
View File
@@ -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.")
+88 -4
View File
@@ -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 {
+1
View File
@@ -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).
+73
View File
@@ -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
+27 -14
View File
@@ -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
}