diff --git a/api/api_test.go b/api/api_test.go index 75ff277c..cf77b6cb 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -19,6 +19,7 @@ import ( "go.sia.tech/explored/api" "go.sia.tech/explored/build" "go.sia.tech/explored/config" + "go.sia.tech/explored/exchangerates" "go.sia.tech/explored/explorer" "go.sia.tech/explored/internal/testutil" "go.sia.tech/explored/persist/sqlite" @@ -69,7 +70,12 @@ func newExplorer(t *testing.T, network *consensus.Network, genesisBlock types.Bl } func newServer(t *testing.T, cm *chain.Manager, e *explorer.Explorer, listenAddr string) (*http.Server, error) { - api := api.NewServer(e, cm, &syncer.Syncer{}) + ctx, cancel := context.WithCancel(context.Background()) + ex := exchangerates.NewKraken(map[string]string{ + exchangerates.CurrencyUSD: exchangerates.KrakenPairSiacoinUSD}, time.Second) + go ex.Start(ctx) + + api := api.NewServer(e, cm, &syncer.Syncer{}, ex) server := &http.Server{ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/api") { @@ -90,6 +96,7 @@ func newServer(t *testing.T, cm *chain.Manager, e *explorer.Explorer, listenAddr t.Cleanup(func() { server.Close() httpListener.Close() + cancel() }) go func() { server.Serve(httpListener) @@ -521,6 +528,16 @@ func TestAPI(t *testing.T) { } testutil.Equal(t, "search type", explorer.SearchTypeContract, resp) }}, + {"Exchange rate", func(t *testing.T) { + resp, err := client.ExchangeRate("USD") + if err != nil { + t.Fatal(err) + } + if resp <= 0 { + t.Fatal("exchange rate should be positive") + } + t.Logf("Exchange rate: %f", resp) + }}, } for _, subtest := range subtests { diff --git a/api/client.go b/api/client.go index f289c9b2..0cc8aed8 100644 --- a/api/client.go +++ b/api/client.go @@ -289,3 +289,9 @@ func (c *Client) HostsList(params explorer.HostQuery, sortBy explorer.HostSortCo err = c.c.POST("/hosts?"+v.Encode(), params, &resp) return } + +// ExchangeRate returns the value of 1 SC in the specified currency. +func (c *Client) ExchangeRate(currency string) (resp float64, err error) { + err = c.c.GET(fmt.Sprintf("/exchangerate?currency=%s", currency), &resp) + return +} diff --git a/api/server.go b/api/server.go index a853181a..1264e4be 100644 --- a/api/server.go +++ b/api/server.go @@ -17,6 +17,7 @@ import ( "go.sia.tech/core/types" "go.sia.tech/coreutils/syncer" "go.sia.tech/explored/build" + "go.sia.tech/explored/exchangerates" "go.sia.tech/explored/explorer" ) @@ -117,6 +118,7 @@ type server struct { cm ChainManager e Explorer s Syncer + ex exchangerates.Source startTime time.Time } @@ -739,12 +741,31 @@ func (s *server) searchIDHandler(jc jape.Context) { jc.Encode(result) } +func (s *server) exchangeRateHandler(jc jape.Context) { + var currency string + if jc.DecodeForm("currency", ¤cy) != nil { + return + } + if currency == "" { + jc.Error(errors.New("provide a currency value such as USD or EUR"), http.StatusNotFound) + return + } + + currency = strings.ToUpper(currency) + price, err := s.ex.Last(currency) + if jc.Check("failed to get exchange rate", err) != nil { + return + } + jc.Encode(price) +} + // NewServer returns an HTTP handler that serves the explored API. -func NewServer(e Explorer, cm ChainManager, s Syncer) http.Handler { +func NewServer(e Explorer, cm ChainManager, s Syncer, ex exchangerates.Source) http.Handler { srv := server{ cm: cm, e: e, s: s, + ex: ex, startTime: time.Now().UTC(), } return jape.Mux(map[string]jape.Handler{ @@ -805,5 +826,7 @@ func NewServer(e Explorer, cm ChainManager, s Syncer) http.Handler { "POST /hosts": srv.hostsHandler, "GET /search/:id": srv.searchIDHandler, + + "GET /exchangerate": srv.exchangeRateHandler, }) } diff --git a/cmd/explored/main.go b/cmd/explored/main.go index 996cfec5..c12cdb44 100644 --- a/cmd/explored/main.go +++ b/cmd/explored/main.go @@ -24,6 +24,7 @@ import ( "go.sia.tech/explored/api" "go.sia.tech/explored/build" "go.sia.tech/explored/config" + "go.sia.tech/explored/exchangerates" "go.sia.tech/explored/explorer" "go.sia.tech/explored/internal/syncerutil" "go.sia.tech/explored/persist/sqlite" @@ -49,6 +50,9 @@ var cfg = config.Config{ MaxLastScan: 3 * time.Hour, MinLastAnnouncement: 90 * 24 * time.Hour, }, + ExchangeRates: config.ExchangeRates{ + Refresh: 3 * time.Second, + }, Consensus: config.Consensus{ Network: "mainnet", }, @@ -262,7 +266,33 @@ func runRootCmd(ctx context.Context, log *zap.Logger) error { defer timeoutCancel() defer e.Shutdown(timeoutCtx) - api := api.NewServer(e, cm, s) + var sources []exchangerates.Source + sources = append(sources, exchangerates.NewKraken(map[string]string{ + exchangerates.CurrencyUSD: exchangerates.KrakenPairSiacoinUSD, + exchangerates.CurrencyEUR: exchangerates.KrakenPairSiacoinEUR, + exchangerates.CurrencyBTC: exchangerates.KrakenPairSiacoinBTC, + }, cfg.ExchangeRates.Refresh)) + if apiKey := os.Getenv("COINGECKO_API_KEY"); apiKey != "" { + sources = append(sources, exchangerates.NewCoinGecko(apiKey, map[string]string{ + exchangerates.CurrencyUSD: exchangerates.CoinGeckoCurrencyUSD, + exchangerates.CurrencyEUR: exchangerates.CoinGeckoCurrencyEUR, + exchangerates.CurrencyCAD: exchangerates.CoinGeckoCurrencyCAD, + exchangerates.CurrencyAUD: exchangerates.CoinGeckoCurrencyAUD, + exchangerates.CurrencyGBP: exchangerates.CoinGeckoCurrencyGBP, + exchangerates.CurrencyJPY: exchangerates.CoinGeckoCurrencyJPY, + exchangerates.CurrencyCNY: exchangerates.CoinGeckoCurrencyCNY, + exchangerates.CurrencyETH: exchangerates.CoinGeckoCurrencyETH, + exchangerates.CurrencyBTC: exchangerates.CoinGeckoCurrencyBTC, + }, exchangerates.CoinGeckoTokenSiacoin, cfg.ExchangeRates.Refresh)) + } + + ex, err := exchangerates.NewAverager(true, sources...) + if err != nil { + return fmt.Errorf("failed to create exchange rate source: %w", err) + } + go ex.Start(ctx) + + api := api.NewServer(e, cm, s, ex) server := &http.Server{ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/api") { diff --git a/config/config.go b/config/config.go index fa14b24b..61d6fa84 100644 --- a/config/config.go +++ b/config/config.go @@ -34,6 +34,12 @@ type ( BatchSize int `yaml:"batchSize,omitempty"` } + // ExchangeRates contains the configuration for the exchange rate clients. + ExchangeRates struct { + // refresh exchange rates this often + Refresh time.Duration + } + // LogFile configures the file output of the logger. LogFile struct { Enabled bool `yaml:"enabled,omitempty"` @@ -63,11 +69,12 @@ type ( Directory string `yaml:"directory,omitempty"` AutoOpenWebUI bool `yaml:"autoOpenWebUI,omitempty"` - HTTP HTTP `yaml:"http,omitempty"` - Consensus Consensus `yaml:"consensus,omitempty"` - Syncer Syncer `yaml:"syncer,omitempty"` - Scanner Scanner `yaml:"scanner,omitempty"` - Log Log `yaml:"log,omitempty"` - Index Index `yaml:"index,omitempty"` + HTTP HTTP `yaml:"http,omitempty"` + Consensus Consensus `yaml:"consensus,omitempty"` + Syncer Syncer `yaml:"syncer,omitempty"` + Scanner Scanner `yaml:"scanner,omitempty"` + ExchangeRates ExchangeRates `yaml:"exchangeRates,omitempty"` + Log Log `yaml:"log,omitempty"` + Index Index `yaml:"index,omitempty"` } ) diff --git a/exchangerates/coingecko.go b/exchangerates/coingecko.go new file mode 100644 index 00000000..cf7a2564 --- /dev/null +++ b/exchangerates/coingecko.go @@ -0,0 +1,149 @@ +package exchangerates + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" +) + +const ( + // CoinGeckoTokenSiacoin is the token ID of Siacoin in CoinGecko + CoinGeckoTokenSiacoin = "siacoin" +) + +const ( + // CoinGeckoCurrencyUSD is the name of US dollars in CoinGecko. + CoinGeckoCurrencyUSD = "usd" + // CoinGeckoCurrencyEUR is the name of euros in CoinGecko. + CoinGeckoCurrencyEUR = "eur" + // CoinGeckoCurrencyCAD is the name of Canadian dollars in CoinGecko. + CoinGeckoCurrencyCAD = "cad" + // CoinGeckoCurrencyAUD is the name of Australian dollars in CoinGecko. + CoinGeckoCurrencyAUD = "aud" + // CoinGeckoCurrencyGBP is the name of British pounds in CoinGecko. + CoinGeckoCurrencyGBP = "gbp" + // CoinGeckoCurrencyJPY is the name of Japanese yen in CoinGecko. + CoinGeckoCurrencyJPY = "jpy" + // CoinGeckoCurrencyCNY is the name of Chinese yuan in CoinGecko. + CoinGeckoCurrencyCNY = "cny" + // CoinGeckoCurrencyBTC is the name of Bitcoin in CoinGecko. + CoinGeckoCurrencyBTC = "btc" + // CoinGeckoCurrencyETH is the name of Ethereum in CoinGecko. + CoinGeckoCurrencyETH = "eth" +) + +type coinGeckoAPI struct { + apiKey string + client http.Client +} + +func newCoinGeckoAPI(apiKey string) *coinGeckoAPI { + return &coinGeckoAPI{apiKey: apiKey} +} + +type coinGeckoPriceResponse map[string]map[string]float64 + +// See https://docs.coingecko.com/reference/simple-price +func (c *coinGeckoAPI) tickers(ctx context.Context, currencies []string, token string) (map[string]float64, error) { + vsCurrencies := strings.ToLower(strings.Join(currencies, ",")) + token = strings.ToLower(token) + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf( + "https://api.coingecko.com/api/v3/simple/price?vs_currencies=%s&ids=%s", + vsCurrencies, token), nil) + if err != nil { + return nil, err + } + request.Header.Set("accept", "application/json") + request.Header.Set("x-cg-demo-api-key", c.apiKey) + + response, err := c.client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + var parsed coinGeckoPriceResponse + if err := json.NewDecoder(response.Body).Decode(&parsed); err != nil { + return nil, err + } + + asset, ok := parsed[token] + if !ok { + return nil, fmt.Errorf("no asset %s", token) + } + + return asset, nil +} + +type coinGecko struct { + token string + pairMap map[string]string // User-specified currency -> CoinGecko currency + refresh time.Duration + client *coinGeckoAPI + + mu sync.Mutex + rates map[string]float64 // CoinGecko currency -> rate + err error +} + +// NewCoinGecko creates an Source with user-specified mappings +func NewCoinGecko(apiKey string, pairMap map[string]string, token string, refresh time.Duration) Source { + return &coinGecko{ + token: token, + pairMap: pairMap, + refresh: refresh, + client: newCoinGeckoAPI(apiKey), + rates: make(map[string]float64), + } +} + +// Start implements Source. +func (c *coinGecko) Start(ctx context.Context) { + ticker := time.NewTicker(c.refresh) + defer ticker.Stop() + + var currencies []string + for _, coinGeckoCurrency := range c.pairMap { + currencies = append(currencies, coinGeckoCurrency) + } + + c.mu.Lock() + c.rates, c.err = c.client.tickers(ctx, currencies, c.token) + c.mu.Unlock() + + for { + select { + case <-ticker.C: + c.mu.Lock() + c.rates, c.err = c.client.tickers(ctx, currencies, c.token) + c.mu.Unlock() + case <-ctx.Done(): + c.mu.Lock() + c.err = ctx.Err() + c.mu.Unlock() + return + } + } +} + +// Last implements Source. +func (c *coinGecko) Last(currency string) (float64, error) { + c.mu.Lock() + defer c.mu.Unlock() + + coinGeckoCurrency, exists := c.pairMap[currency] + if !exists { + return 0, fmt.Errorf("currency %s not mapped to a CoinGecko currency", currency) + } + + rate, ok := c.rates[coinGeckoCurrency] + if !ok { + return 0, fmt.Errorf("rate for currency %s not available", currency) + } + return rate, c.err +} diff --git a/exchangerates/exchangerates.go b/exchangerates/exchangerates.go new file mode 100644 index 00000000..cc344136 --- /dev/null +++ b/exchangerates/exchangerates.go @@ -0,0 +1,77 @@ +package exchangerates + +import ( + "context" + "errors" +) + +const ( + // CurrencyUSD represents US dollars. + CurrencyUSD = "USD" + // CurrencyEUR represents euros. + CurrencyEUR = "EUR" + // CurrencyCAD represents Canadian dollars. + CurrencyCAD = "CAD" + // CurrencyAUD represents Australian dollars. + CurrencyAUD = "AUD" + // CurrencyGBP represents British pounds. + CurrencyGBP = "GBP" + // CurrencyJPY represents Japanese yen. + CurrencyJPY = "JPY" + // CurrencyCNY represents Chinese yuan. + CurrencyCNY = "CNY" + // CurrencyBTC represents Bitcoin. + CurrencyBTC = "BTC" + // CurrencyETH represents Ethereum. + CurrencyETH = "ETH" +) + +// An Source returns the price of 1 unit of an asset in USD. +type Source interface { + Last(currency string) (float64, error) + Start(ctx context.Context) +} + +type averager struct { + ignoreOnError bool + sources []Source +} + +// NewAverager returns an exchange rate source that averages multiple exchange +// rates. +func NewAverager(ignoreOnError bool, sources ...Source) (Source, error) { + if len(sources) == 0 { + return nil, errors.New("no sources provided") + } + return &averager{ + ignoreOnError: ignoreOnError, + sources: sources, + }, nil +} + +// Start implements Source. +func (a *averager) Start(ctx context.Context) { + for i := range a.sources { + go a.sources[i].Start(ctx) + } +} + +// Last implements Source. +func (a *averager) Last(currency string) (float64, error) { + sum, count := 0.0, 0.0 + for i := range a.sources { + if v, err := a.sources[i].Last(currency); err == nil { + if v != 0 { + sum += v + count++ + } + } else if !a.ignoreOnError { + return 0, err + } + } + + if count == 0 { + return 0, errors.New("no sources working") + } + return sum / count, nil +} diff --git a/exchangerates/exchangerates_test.go b/exchangerates/exchangerates_test.go new file mode 100644 index 00000000..4b976611 --- /dev/null +++ b/exchangerates/exchangerates_test.go @@ -0,0 +1,144 @@ +package exchangerates + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +type constantExchangeRateSource struct { + x float64 + + mu sync.Mutex + rate float64 +} + +func (c *constantExchangeRateSource) Start(ctx context.Context) { + c.mu.Lock() + c.rate = c.x + c.mu.Unlock() +} + +func (c *constantExchangeRateSource) Last(string) (rate float64, err error) { + c.mu.Lock() + rate, err = c.rate, nil + c.mu.Unlock() + return +} + +func newConstantExchangeRateSource(x float64) *constantExchangeRateSource { + return &constantExchangeRateSource{x: x} +} + +type errorExchangeRateSource struct{} + +func (c *errorExchangeRateSource) Start(ctx context.Context) {} + +func (c *errorExchangeRateSource) Last(string) (float64, error) { + return -1, errors.New("error") +} + +func TestAveragerLastBeforeStart(t *testing.T) { + averager, err := NewAverager(false, newConstantExchangeRateSource(1.0)) + if err != nil { + t.Fatal(err) + } + if _, err := averager.Last(CurrencyUSD); err == nil { + t.Fatal("should be error if we call Last before Start") + } +} + +func TestAverager(t *testing.T) { + const interval = time.Second + + const ( + p1 = 1.0 + p2 = 10.0 + p3 = 100.0 + ) + s1 := newConstantExchangeRateSource(p1) + s2 := newConstantExchangeRateSource(p2) + s3 := newConstantExchangeRateSource(p3) + errorSource := &errorExchangeRateSource{} + + tests := []struct { + name string + ignoreOnError bool + sources []Source + expectedPrice float64 + expectError bool + errorMessage string + }{ + { + name: "No sources provided", + ignoreOnError: true, + sources: nil, + expectError: true, + errorMessage: "Should have gotten error for averager with no sources", + }, + { + name: "All sources fail", + ignoreOnError: true, + sources: []Source{errorSource, errorSource, errorSource}, + expectError: true, + errorMessage: "Should have gotten error for averager with no working sources", + }, + { + name: "Valid sources without errors", + ignoreOnError: false, + sources: []Source{s1, s2, s3}, + expectedPrice: (p1 + p2 + p3) / 3, + expectError: false, + }, + { + name: "One error source without ignoreOnError", + ignoreOnError: false, + sources: []Source{s1, s2, s3, errorSource}, + expectError: true, + errorMessage: "Should have gotten error for averager with error source and ignoreOnError disabled", + }, + { + name: "One error source with ignoreOnError", + ignoreOnError: true, + sources: []Source{s1, s2, s3, errorSource}, + expectedPrice: (p1 + p2 + p3) / 3, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + averager, err := NewAverager(tt.ignoreOnError, tt.sources...) + if err != nil { + if !tt.expectError { + t.Fatal(err) + } + return + } + go averager.Start(ctx) + + time.Sleep(2 * interval) + + price, err := averager.Last(CurrencyUSD) + if tt.expectError { + if err == nil { + t.Fatal(tt.errorMessage) + } + return + } + + if err != nil { + t.Fatal(err) + } + + if price != tt.expectedPrice { + t.Fatalf("wrong price, got %v, expected %v", price, tt.expectedPrice) + } + }) + } +} diff --git a/exchangerates/kraken.go b/exchangerates/kraken.go new file mode 100644 index 00000000..e96a2d10 --- /dev/null +++ b/exchangerates/kraken.go @@ -0,0 +1,136 @@ +package exchangerates + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +const ( + // KrakenPairSiacoinUSD is the ID of SC/USD pair in Kraken + KrakenPairSiacoinUSD = "SCUSD" + // KrakenPairSiacoinEUR is the ID of SC/EUR pair in Kraken + KrakenPairSiacoinEUR = "SCEUR" + // KrakenPairSiacoinBTC is the ID of SC/BTC pair in Kraken + KrakenPairSiacoinBTC = "SCXBT" +) + +type krakenAPI struct { + client http.Client +} + +type krakenPriceResponse struct { + Error []any `json:"error"` + Result map[string]struct { + B []string `json:"b"` + } `json:"result"` +} + +func newKrakenAPI() *krakenAPI { + return &krakenAPI{} +} + +func (k *krakenAPI) tickers(ctx context.Context, pairs []string) (map[string]float64, error) { + pairParam := strings.Join(pairs, ",") + request, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.kraken.com/0/public/Ticker?pair="+url.PathEscape(pairParam), nil) + if err != nil { + return nil, err + } + + response, err := k.client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + var parsed krakenPriceResponse + if err := json.NewDecoder(response.Body).Decode(&parsed); err != nil { + return nil, err + } + + rates := make(map[string]float64) + for pair, data := range parsed.Result { + if len(data.B) == 0 { + continue + } + price, err := strconv.ParseFloat(data.B[0], 64) + if err != nil { + return nil, err + } + rates[pair] = price + } + + return rates, nil +} + +type kraken struct { + pairMap map[string]string // User-specified currency -> Kraken pair + refresh time.Duration + client *krakenAPI + + mu sync.Mutex + rates map[string]float64 // Kraken pair -> rate + err error +} + +// NewKraken returns an Source that gets data from Kraken. +func NewKraken(pairMap map[string]string, refresh time.Duration) Source { + return &kraken{ + pairMap: pairMap, + refresh: refresh, + client: newKrakenAPI(), + rates: make(map[string]float64), + } +} + +// Start implements Source. +func (k *kraken) Start(ctx context.Context) { + ticker := time.NewTicker(k.refresh) + defer ticker.Stop() + + var krakenPairs []string + for _, krakenPair := range k.pairMap { + krakenPairs = append(krakenPairs, krakenPair) + } + + k.mu.Lock() + k.rates, k.err = k.client.tickers(ctx, krakenPairs) + k.mu.Unlock() + + for { + select { + case <-ticker.C: + k.mu.Lock() + k.rates, k.err = k.client.tickers(ctx, krakenPairs) + k.mu.Unlock() + case <-ctx.Done(): + k.mu.Lock() + k.err = ctx.Err() + k.mu.Unlock() + return + } + } +} + +// Last implements Source. +func (k *kraken) Last(currency string) (float64, error) { + k.mu.Lock() + defer k.mu.Unlock() + + krakenPair, exists := k.pairMap[currency] + if !exists { + return 0, fmt.Errorf("currency %s not mapped to a Kraken pair", currency) + } + + rate, ok := k.rates[krakenPair] + if !ok { + return 0, fmt.Errorf("rate for pair %s not available", krakenPair) + } + return rate, k.err +} diff --git a/exchangerates/kraken_test.go b/exchangerates/kraken_test.go new file mode 100644 index 00000000..0d2f27ee --- /dev/null +++ b/exchangerates/kraken_test.go @@ -0,0 +1,37 @@ +package exchangerates + +import ( + "context" + "testing" + "time" +) + +func TestKraken(t *testing.T) { + const interval = time.Second + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + kraken := NewKraken(map[string]string{ + CurrencyUSD: KrakenPairSiacoinUSD, + CurrencyEUR: KrakenPairSiacoinEUR, + }, interval) + go kraken.Start(ctx) + + time.Sleep(2 * interval) + if price, err := kraken.Last("USD"); err != nil { + t.Fatal(err) + } else if price <= 0.0 { + t.Fatalf("invalid price: %f", price) + } + + if price, err := kraken.Last("EUR"); err != nil { + t.Fatal(err) + } else if price <= 0.0 { + t.Fatalf("invalid price: %f", price) + } + + if _, err := kraken.Last("UNK"); err == nil { + t.Fatal("should fail for unmapped currency") + } +}