From 16ac6b53d9713c5acd90815e4d477ebf368387a1 Mon Sep 17 00:00:00 2001 From: Christopher Tarry Date: Sun, 12 Jan 2025 16:27:05 -0500 Subject: [PATCH 01/16] add exchange rate interface and kraken price retriever --- internal/exchangerates/exchangerates.go | 46 ++++++++ internal/exchangerates/exchangerates_test.go | 86 +++++++++++++++ internal/exchangerates/kraken.go | 104 +++++++++++++++++++ internal/exchangerates/kraken_test.go | 26 +++++ 4 files changed, 262 insertions(+) create mode 100644 internal/exchangerates/exchangerates.go create mode 100644 internal/exchangerates/exchangerates_test.go create mode 100644 internal/exchangerates/kraken.go create mode 100644 internal/exchangerates/kraken_test.go diff --git a/internal/exchangerates/exchangerates.go b/internal/exchangerates/exchangerates.go new file mode 100644 index 0000000..8991411 --- /dev/null +++ b/internal/exchangerates/exchangerates.go @@ -0,0 +1,46 @@ +package exchangerates + +import ( + "context" + "errors" +) + +type ExchangeRateSource interface { + Last() (float64, error) + Start(ctx context.Context) +} + +type averager struct { + sources []ExchangeRateSource +} + +func NewAverager(sources ...ExchangeRateSource) ExchangeRateSource { + return &averager{ + sources: sources, + } +} + +// Start implements ExchangeRateSource. +func (a *averager) Start(ctx context.Context) { + for i := range a.sources { + go a.sources[i].Start(ctx) + } +} + +// Last implements ExchangeRateSource. +func (a *averager) Last() (float64, error) { + sum, count := 0.0, 0.0 + for i := range a.sources { + if v, err := a.sources[i].Last(); err == nil { + sum += v + count++ + } else { + return 0, err + } + } + + if count == 0 { + return 0, errors.New("no sources working") + } + return sum / count, nil +} diff --git a/internal/exchangerates/exchangerates_test.go b/internal/exchangerates/exchangerates_test.go new file mode 100644 index 0000000..c7984f0 --- /dev/null +++ b/internal/exchangerates/exchangerates_test.go @@ -0,0 +1,86 @@ +package exchangerates + +import ( + "context" + "testing" + "time" +) + +type constantPriceSource struct { + x float64 +} + +func (c *constantPriceSource) Start(ctx context.Context) {} + +func (c *constantPriceSource) Last() (float64, error) { + return c.x, nil +} + +func newConstantPriceSource(x float64) *constantPriceSource { + return &constantPriceSource{x: x} +} + +func TestAverager(t *testing.T) { + const interval = time.Second + + const ( + p1 = 1.0 + p2 = 10.0 + p3 = 100.0 + ) + s1 := newConstantPriceSource(p1) + s2 := newConstantPriceSource(p2) + s3 := newConstantPriceSource(p3) + + { + ctx, cancel := context.WithCancel(context.Background()) + averager := NewAverager() + go averager.Start(ctx) + + time.Sleep(2 * interval) + _, err := averager.Last() + // should get error: "no sources working" + if err == nil { + t.Fatal("should have gotten error for averager with no sources") + } + cancel() + } + + { + ctx, cancel := context.WithCancel(context.Background()) + averager := NewAverager(s1) + go averager.Start(ctx) + + time.Sleep(2 * interval) + + price, err := averager.Last() + if err != nil { + t.Fatal(err) + } + + const expect = p1 + if price != expect { + t.Fatalf("wrong price, got %v, expected %v", price, expect) + } + cancel() + } + + { + ctx, cancel := context.WithCancel(context.Background()) + averager := NewAverager(s1, s2, s3) + go averager.Start(ctx) + + time.Sleep(2 * interval) + + price, err := averager.Last() + if err != nil { + t.Fatal(err) + } + + const expect = ((p1 + p2 + p3) / 3) + if price != expect { + t.Fatalf("wrong price, got %v, expected %v", price, expect) + } + cancel() + } +} diff --git a/internal/exchangerates/kraken.go b/internal/exchangerates/kraken.go new file mode 100644 index 0000000..503b6ca --- /dev/null +++ b/internal/exchangerates/kraken.go @@ -0,0 +1,104 @@ +package exchangerates + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +type krakenAPI struct { + client http.Client +} + +type krakenPriceResponse struct { + Error []any `json:"error"` + Result map[string]struct { + A []string `json:"a"` + B []string `json:"b"` + C []string `json:"c"` + V []string `json:"v"` + P []string `json:"p"` + T []int `json:"t"` + L []string `json:"l"` + H []string `json:"h"` + O string `json:"o"` + } `json:"result"` +} + +func newKrakenAPI() *krakenAPI { + return &krakenAPI{} +} + +// See https://docs.kraken.com/api/docs/rest-api/get-ticker-information +func (k *krakenAPI) ticker(pair string) (float64, error) { + pair = strings.ToUpper(pair) + response, err := k.client.Get("https://api.kraken.com/0/public/Ticker?pair=" + url.PathEscape(pair)) + if err != nil { + return 0, err + } + + var parsed krakenPriceResponse + if err := json.NewDecoder(response.Body).Decode(&parsed); err != nil { + return 0, err + } + + p := parsed.Result[pair] + if len(p.B) == 0 { + return 0, fmt.Errorf("no asset %s", pair) + } + price, err := strconv.ParseFloat(p.B[0], 64) + if err != nil { + return 0, err + } + + return price, nil +} + +type kraken struct { + pair string + refresh time.Duration + client *krakenAPI + + mu sync.Mutex + rate float64 + err error +} + +func NewKraken(pair string, refresh time.Duration) ExchangeRateSource { + return &kraken{ + pair: pair, + refresh: refresh, + client: newKrakenAPI(), + } +} + +// Start implements ExchangeRateSource. +func (k *kraken) Start(ctx context.Context) { + ticker := time.NewTicker(k.refresh) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + k.mu.Lock() + k.rate, k.err = k.client.ticker(k.pair) + k.mu.Unlock() + case <-ctx.Done(): + return + } + } +} + +// Last implements ExchangeRateSource +func (k *kraken) Last() (rate float64, err error) { + k.mu.Lock() + rate, err = k.rate, k.err + k.mu.Unlock() + return +} diff --git a/internal/exchangerates/kraken_test.go b/internal/exchangerates/kraken_test.go new file mode 100644 index 0000000..bfe9785 --- /dev/null +++ b/internal/exchangerates/kraken_test.go @@ -0,0 +1,26 @@ +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("SCUSD", interval) + go kraken.Start(ctx) + + time.Sleep(2 * interval) + price, err := kraken.Last() + if err != nil { + t.Fatal(err) + } + if price <= 0.0 { + t.Fatalf("invalid price: %f", price) + } +} From ee5835488a5e22c7941ae32b7caefb55c05af491 Mon Sep 17 00:00:00 2001 From: Christopher Tarry Date: Sun, 12 Jan 2025 16:47:35 -0500 Subject: [PATCH 02/16] add coingecko --- internal/exchangerates/coingecko.go | 104 ++++++++++++++++++++++++++ internal/exchangerates/kraken.go | 8 ++ internal/exchangerates/kraken_test.go | 2 +- 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 internal/exchangerates/coingecko.go diff --git a/internal/exchangerates/coingecko.go b/internal/exchangerates/coingecko.go new file mode 100644 index 0000000..19ed5f9 --- /dev/null +++ b/internal/exchangerates/coingecko.go @@ -0,0 +1,104 @@ +package exchangerates + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +const ( + // CoinGeckoPair is the ID of Siacoin in CoinGecko + CoinGeckoSicaoinPair = "siacoin" +) + +type coinGeckoAPI struct { + apiKey string + + client http.Client +} + +func newcoinGeckoAPI(apiKey string) *coinGeckoAPI { + return &coinGeckoAPI{apiKey: apiKey} +} + +type coinGeckoPriceResponse map[string]struct { + USD float64 `json:"usd"` +} + +// See https://docs.coingecko.com/reference/simple-price +func (k *coinGeckoAPI) ticker(pair string) (float64, error) { + pair = strings.ToLower(pair) + + request, err := http.NewRequest(http.MethodGet, "https://api.coingecko.com/api/v3/simple/price?vs_currencies=usd&ids="+url.PathEscape(pair), nil) + if err != nil { + return 0, err + } + request.Header.Set("accept", "application/json") + request.Header.Set("x-cg-demo-api-key", k.apiKey) + + response, err := k.client.Do(request) + if err != nil { + return 0, err + } + var parsed coinGeckoPriceResponse + if err := json.NewDecoder(response.Body).Decode(&parsed); err != nil { + return 0, err + } + + price, ok := parsed[pair] + if !ok { + return 0, fmt.Errorf("no asset %s", pair) + } + return price.USD, nil +} + +type coinGecko struct { + pair string + refresh time.Duration + client *coinGeckoAPI + + mu sync.Mutex + rate float64 + err error +} + +func NewCoinGecko(apiKey string, pair string, refresh time.Duration) ExchangeRateSource { + return &coinGecko{ + pair: pair, + refresh: refresh, + client: newcoinGeckoAPI(apiKey), + } +} + +// Start implements ExchangeRateSource. +func (c *coinGecko) Start(ctx context.Context) { + ticker := time.NewTicker(c.refresh) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + c.mu.Lock() + c.rate, c.err = c.client.ticker(c.pair) + c.mu.Unlock() + case <-ctx.Done(): + c.mu.Lock() + c.err = ctx.Err() + c.mu.Unlock() + return + } + } +} + +// Last implements ExchangeRateSource +func (c *coinGecko) Last() (rate float64, err error) { + c.mu.Lock() + rate, err = c.rate, c.err + c.mu.Unlock() + return +} diff --git a/internal/exchangerates/kraken.go b/internal/exchangerates/kraken.go index 503b6ca..7631b04 100644 --- a/internal/exchangerates/kraken.go +++ b/internal/exchangerates/kraken.go @@ -12,6 +12,11 @@ import ( "time" ) +const ( + // KrakenSiacoinPair is the ID of Siacoin in Kraken + KrakenSiacoinPair = "SCUSD" +) + type krakenAPI struct { client http.Client } @@ -90,6 +95,9 @@ func (k *kraken) Start(ctx context.Context) { k.rate, k.err = k.client.ticker(k.pair) k.mu.Unlock() case <-ctx.Done(): + k.mu.Lock() + k.err = ctx.Err() + k.mu.Unlock() return } } diff --git a/internal/exchangerates/kraken_test.go b/internal/exchangerates/kraken_test.go index bfe9785..5aab509 100644 --- a/internal/exchangerates/kraken_test.go +++ b/internal/exchangerates/kraken_test.go @@ -12,7 +12,7 @@ func TestKraken(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - kraken := NewKraken("SCUSD", interval) + kraken := NewKraken(KrakenSiacoinPair, interval) go kraken.Start(ctx) time.Sleep(2 * interval) From 89a268e50541008289a834207257e05713b23191 Mon Sep 17 00:00:00 2001 From: Christopher Tarry Date: Sun, 12 Jan 2025 17:07:04 -0500 Subject: [PATCH 03/16] add API endpoint --- api/api_test.go | 18 +++++++++- api/client.go | 6 ++++ api/server.go | 15 ++++++++- cmd/explored/main.go | 11 +++++- internal/exchangerates/exchangerates.go | 10 +++--- internal/exchangerates/exchangerates_test.go | 35 +++++++++++++++++--- 6 files changed, 84 insertions(+), 11 deletions(-) diff --git a/api/api_test.go b/api/api_test.go index e46273d..557a5fe 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -20,6 +20,7 @@ import ( "go.sia.tech/explored/build" "go.sia.tech/explored/config" "go.sia.tech/explored/explorer" + "go.sia.tech/explored/internal/exchangerates" "go.sia.tech/explored/internal/testutil" "go.sia.tech/explored/persist/sqlite" "go.uber.org/zap/zaptest" @@ -69,7 +70,11 @@ 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(exchangerates.KrakenSiacoinPair, 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 +95,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) @@ -467,6 +473,16 @@ func TestAPI(t *testing.T) { } testutil.Equal(t, "search type", explorer.SearchTypeContract, resp) }}, + {"Exchange rate", func(t *testing.T) { + resp, err := client.ExchangeRate() + 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 c9e0622..1041d6e 100644 --- a/api/client.go +++ b/api/client.go @@ -277,3 +277,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 USD. +func (c *Client) ExchangeRate() (resp float64, err error) { + err = c.c.GET("/exchangerate", &resp) + return +} diff --git a/api/server.go b/api/server.go index 3238eb0..6257115 100644 --- a/api/server.go +++ b/api/server.go @@ -18,6 +18,7 @@ import ( "go.sia.tech/coreutils/syncer" "go.sia.tech/explored/build" "go.sia.tech/explored/explorer" + "go.sia.tech/explored/internal/exchangerates" ) type ( @@ -111,6 +112,7 @@ type server struct { cm ChainManager e Explorer s Syncer + ex exchangerates.ExchangeRateSource startTime time.Time } @@ -690,12 +692,21 @@ func (s *server) searchIDHandler(jc jape.Context) { jc.Encode(result) } +func (s *server) exchangeRateHandler(jc jape.Context) { + price, err := s.ex.Last() + 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.ExchangeRateSource) http.Handler { srv := server{ cm: cm, e: e, s: s, + ex: ex, startTime: time.Now().UTC(), } return jape.Mux(map[string]jape.Handler{ @@ -753,5 +764,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 996cfec..920d690 100644 --- a/cmd/explored/main.go +++ b/cmd/explored/main.go @@ -25,6 +25,7 @@ import ( "go.sia.tech/explored/build" "go.sia.tech/explored/config" "go.sia.tech/explored/explorer" + "go.sia.tech/explored/internal/exchangerates" "go.sia.tech/explored/internal/syncerutil" "go.sia.tech/explored/persist/sqlite" "go.uber.org/zap" @@ -262,7 +263,15 @@ func runRootCmd(ctx context.Context, log *zap.Logger) error { defer timeoutCancel() defer e.Shutdown(timeoutCtx) - api := api.NewServer(e, cm, s) + var sources []exchangerates.ExchangeRateSource + sources = append(sources, exchangerates.NewKraken(exchangerates.KrakenSiacoinPair, 3*time.Second)) + if apiKey := os.Getenv("COINGECKO_API_KEY"); apiKey != "" { + sources = append(sources, exchangerates.NewCoinGecko(apiKey, exchangerates.CoinGeckoSicaoinPair, 3*time.Second)) + } + ex := exchangerates.NewAverager(false, sources...) + 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/internal/exchangerates/exchangerates.go b/internal/exchangerates/exchangerates.go index 8991411..4da79b5 100644 --- a/internal/exchangerates/exchangerates.go +++ b/internal/exchangerates/exchangerates.go @@ -11,12 +11,14 @@ type ExchangeRateSource interface { } type averager struct { - sources []ExchangeRateSource + ignoreOnError bool + sources []ExchangeRateSource } -func NewAverager(sources ...ExchangeRateSource) ExchangeRateSource { +func NewAverager(ignoreOnError bool, sources ...ExchangeRateSource) ExchangeRateSource { return &averager{ - sources: sources, + ignoreOnError: ignoreOnError, + sources: sources, } } @@ -34,7 +36,7 @@ func (a *averager) Last() (float64, error) { if v, err := a.sources[i].Last(); err == nil { sum += v count++ - } else { + } else if !a.ignoreOnError { return 0, err } } diff --git a/internal/exchangerates/exchangerates_test.go b/internal/exchangerates/exchangerates_test.go index c7984f0..e168aee 100644 --- a/internal/exchangerates/exchangerates_test.go +++ b/internal/exchangerates/exchangerates_test.go @@ -2,6 +2,7 @@ package exchangerates import ( "context" + "errors" "testing" "time" ) @@ -20,6 +21,14 @@ func newConstantPriceSource(x float64) *constantPriceSource { return &constantPriceSource{x: x} } +type errorPriceSource struct{} + +func (c *errorPriceSource) Start(ctx context.Context) {} + +func (c *errorPriceSource) Last() (float64, error) { + return -1, errors.New("error") +} + func TestAverager(t *testing.T) { const interval = time.Second @@ -31,10 +40,11 @@ func TestAverager(t *testing.T) { s1 := newConstantPriceSource(p1) s2 := newConstantPriceSource(p2) s3 := newConstantPriceSource(p3) + errorSource := &errorPriceSource{} { ctx, cancel := context.WithCancel(context.Background()) - averager := NewAverager() + averager := NewAverager(false) go averager.Start(ctx) time.Sleep(2 * interval) @@ -48,7 +58,7 @@ func TestAverager(t *testing.T) { { ctx, cancel := context.WithCancel(context.Background()) - averager := NewAverager(s1) + averager := NewAverager(false, s1, s2, s3) go averager.Start(ctx) time.Sleep(2 * interval) @@ -58,7 +68,7 @@ func TestAverager(t *testing.T) { t.Fatal(err) } - const expect = p1 + const expect = ((p1 + p2 + p3) / 3) if price != expect { t.Fatalf("wrong price, got %v, expected %v", price, expect) } @@ -67,11 +77,28 @@ func TestAverager(t *testing.T) { { ctx, cancel := context.WithCancel(context.Background()) - averager := NewAverager(s1, s2, s3) + averager := NewAverager(false, s1, s2, s3, errorSource) + go averager.Start(ctx) + + time.Sleep(2 * interval) + + _, err := averager.Last() + // should get error because errorsource will fail + if err == nil { + t.Fatal("should have gotten error for averager with error source") + } + cancel() + } + + { + ctx, cancel := context.WithCancel(context.Background()) + averager := NewAverager(true, s1, s2, s3, errorSource) go averager.Start(ctx) time.Sleep(2 * interval) + // should not get an error because the errorsource will just be ignored + // if at least one other source works price, err := averager.Last() if err != nil { t.Fatal(err) From 9777bcbf9879d44dc6f8ab4032f2fb41b7aa7bee Mon Sep 17 00:00:00 2001 From: Christopher Tarry Date: Sun, 12 Jan 2025 17:08:18 -0500 Subject: [PATCH 04/16] update test comments --- internal/exchangerates/exchangerates_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/exchangerates/exchangerates_test.go b/internal/exchangerates/exchangerates_test.go index e168aee..379e764 100644 --- a/internal/exchangerates/exchangerates_test.go +++ b/internal/exchangerates/exchangerates_test.go @@ -83,7 +83,8 @@ func TestAverager(t *testing.T) { time.Sleep(2 * interval) _, err := averager.Last() - // should get error because errorsource will fail + // Should get an error because the errorSource will fail + // (ignoreOnError = false) if err == nil { t.Fatal("should have gotten error for averager with error source") } @@ -97,8 +98,8 @@ func TestAverager(t *testing.T) { time.Sleep(2 * interval) - // should not get an error because the errorsource will just be ignored - // if at least one other source works + // Should not get an error because the errorSource will just be ignored + // if at least one other source works (ignoreOnError = true) price, err := averager.Last() if err != nil { t.Fatal(err) From 8840b66ebeafe34482c75eb229cd2165312a1cd2 Mon Sep 17 00:00:00 2001 From: Christopher Tarry Date: Sun, 12 Jan 2025 17:16:45 -0500 Subject: [PATCH 05/16] fix lint --- internal/exchangerates/coingecko.go | 3 ++- internal/exchangerates/exchangerates.go | 3 +++ internal/exchangerates/kraken.go | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/exchangerates/coingecko.go b/internal/exchangerates/coingecko.go index 19ed5f9..79dfc5b 100644 --- a/internal/exchangerates/coingecko.go +++ b/internal/exchangerates/coingecko.go @@ -12,7 +12,7 @@ import ( ) const ( - // CoinGeckoPair is the ID of Siacoin in CoinGecko + // CoinGeckoSicaoinPair is the ID of Siacoin in CoinGecko CoinGeckoSicaoinPair = "siacoin" ) @@ -67,6 +67,7 @@ type coinGecko struct { err error } +// NewCoinGecko returns an ExchangeRateSource that gets data from CoinGecko. func NewCoinGecko(apiKey string, pair string, refresh time.Duration) ExchangeRateSource { return &coinGecko{ pair: pair, diff --git a/internal/exchangerates/exchangerates.go b/internal/exchangerates/exchangerates.go index 4da79b5..471208c 100644 --- a/internal/exchangerates/exchangerates.go +++ b/internal/exchangerates/exchangerates.go @@ -5,6 +5,7 @@ import ( "errors" ) +// An ExchangeRateSource returns the price of 1 unit of an asset in USD. type ExchangeRateSource interface { Last() (float64, error) Start(ctx context.Context) @@ -15,6 +16,8 @@ type averager struct { sources []ExchangeRateSource } +// NewAverager returns an exchange rate source that averages multiple exchange +// rates. func NewAverager(ignoreOnError bool, sources ...ExchangeRateSource) ExchangeRateSource { return &averager{ ignoreOnError: ignoreOnError, diff --git a/internal/exchangerates/kraken.go b/internal/exchangerates/kraken.go index 7631b04..5a8d319 100644 --- a/internal/exchangerates/kraken.go +++ b/internal/exchangerates/kraken.go @@ -75,6 +75,7 @@ type kraken struct { err error } +// NewKraken returns an ExchangeRateSource that gets data from Kraken. func NewKraken(pair string, refresh time.Duration) ExchangeRateSource { return &kraken{ pair: pair, From 961555f2d80afbe0ee6f3b1af2e0136d9ad137be Mon Sep 17 00:00:00 2001 From: Christopher Tarry Date: Sun, 12 Jan 2025 18:30:00 -0500 Subject: [PATCH 06/16] make exchangerates package public --- api/api_test.go | 2 +- api/server.go | 2 +- cmd/explored/main.go | 2 +- {internal/exchangerates => exchangerates}/coingecko.go | 0 {internal/exchangerates => exchangerates}/exchangerates.go | 0 {internal/exchangerates => exchangerates}/exchangerates_test.go | 0 {internal/exchangerates => exchangerates}/kraken.go | 0 {internal/exchangerates => exchangerates}/kraken_test.go | 0 8 files changed, 3 insertions(+), 3 deletions(-) rename {internal/exchangerates => exchangerates}/coingecko.go (100%) rename {internal/exchangerates => exchangerates}/exchangerates.go (100%) rename {internal/exchangerates => exchangerates}/exchangerates_test.go (100%) rename {internal/exchangerates => exchangerates}/kraken.go (100%) rename {internal/exchangerates => exchangerates}/kraken_test.go (100%) diff --git a/api/api_test.go b/api/api_test.go index 557a5fe..97888ce 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -19,8 +19,8 @@ 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/exchangerates" "go.sia.tech/explored/internal/testutil" "go.sia.tech/explored/persist/sqlite" "go.uber.org/zap/zaptest" diff --git a/api/server.go b/api/server.go index 6257115..e778e51 100644 --- a/api/server.go +++ b/api/server.go @@ -17,8 +17,8 @@ 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" - "go.sia.tech/explored/internal/exchangerates" ) type ( diff --git a/cmd/explored/main.go b/cmd/explored/main.go index 920d690..c47eafe 100644 --- a/cmd/explored/main.go +++ b/cmd/explored/main.go @@ -24,8 +24,8 @@ 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/exchangerates" "go.sia.tech/explored/internal/syncerutil" "go.sia.tech/explored/persist/sqlite" "go.uber.org/zap" diff --git a/internal/exchangerates/coingecko.go b/exchangerates/coingecko.go similarity index 100% rename from internal/exchangerates/coingecko.go rename to exchangerates/coingecko.go diff --git a/internal/exchangerates/exchangerates.go b/exchangerates/exchangerates.go similarity index 100% rename from internal/exchangerates/exchangerates.go rename to exchangerates/exchangerates.go diff --git a/internal/exchangerates/exchangerates_test.go b/exchangerates/exchangerates_test.go similarity index 100% rename from internal/exchangerates/exchangerates_test.go rename to exchangerates/exchangerates_test.go diff --git a/internal/exchangerates/kraken.go b/exchangerates/kraken.go similarity index 100% rename from internal/exchangerates/kraken.go rename to exchangerates/kraken.go diff --git a/internal/exchangerates/kraken_test.go b/exchangerates/kraken_test.go similarity index 100% rename from internal/exchangerates/kraken_test.go rename to exchangerates/kraken_test.go From 4576b705c49c5ccc61cda623986504ee13cd8b33 Mon Sep 17 00:00:00 2001 From: Christopher Tarry Date: Tue, 14 Jan 2025 16:27:02 -0500 Subject: [PATCH 07/16] add euro support --- api/api_test.go | 8 ++++-- api/client.go | 6 ++-- api/server.go | 26 +++++++++++++----- cmd/explored/main.go | 24 ++++++++++++---- config/config.go | 19 +++++++++---- exchangerates/coingecko.go | 53 ++++++++++++++++++++++-------------- exchangerates/kraken.go | 7 +++-- exchangerates/kraken_test.go | 2 +- 8 files changed, 97 insertions(+), 48 deletions(-) diff --git a/api/api_test.go b/api/api_test.go index 4ae1303..66fe6db 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -71,10 +71,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) { ctx, cancel := context.WithCancel(context.Background()) - ex := exchangerates.NewKraken(exchangerates.KrakenSiacoinPair, time.Second) + ex := exchangerates.NewKraken(exchangerates.KrakenPairSiacoinUSD, time.Second) go ex.Start(ctx) - api := api.NewServer(e, cm, &syncer.Syncer{}, ex) + api := api.NewServer(e, cm, &syncer.Syncer{}, map[string]exchangerates.ExchangeRateSource{ + "USD": ex, + }) server := &http.Server{ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/api") { @@ -528,7 +530,7 @@ func TestAPI(t *testing.T) { testutil.Equal(t, "search type", explorer.SearchTypeContract, resp) }}, {"Exchange rate", func(t *testing.T) { - resp, err := client.ExchangeRate() + resp, err := client.ExchangeRate("USD") if err != nil { t.Fatal(err) } diff --git a/api/client.go b/api/client.go index 2ab00b4..0cc8aed 100644 --- a/api/client.go +++ b/api/client.go @@ -290,8 +290,8 @@ func (c *Client) HostsList(params explorer.HostQuery, sortBy explorer.HostSortCo return } -// ExchangeRate returns the value of 1 SC in USD. -func (c *Client) ExchangeRate() (resp float64, err error) { - err = c.c.GET("/exchangerate", &resp) +// 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 cd8f379..834a631 100644 --- a/api/server.go +++ b/api/server.go @@ -115,10 +115,10 @@ var ( ) type server struct { - cm ChainManager - e Explorer - s Syncer - ex exchangerates.ExchangeRateSource + cm ChainManager + e Explorer + s Syncer + exs map[string]exchangerates.ExchangeRateSource startTime time.Time } @@ -742,7 +742,19 @@ func (s *server) searchIDHandler(jc jape.Context) { } func (s *server) exchangeRateHandler(jc jape.Context) { - price, err := s.ex.Last() + var currency string + if jc.DecodeForm("currency", ¤cy) != nil { + return + } + + currency = strings.ToUpper(currency) + ex, ok := s.exs[currency] + if !ok { + jc.Error(fmt.Errorf("currency not supported: %s", currency), http.StatusNotFound) + return + } + + price, err := ex.Last() if jc.Check("failed to get exchange rate", err) != nil { return } @@ -750,12 +762,12 @@ func (s *server) exchangeRateHandler(jc jape.Context) { } // NewServer returns an HTTP handler that serves the explored API. -func NewServer(e Explorer, cm ChainManager, s Syncer, ex exchangerates.ExchangeRateSource) http.Handler { +func NewServer(e Explorer, cm ChainManager, s Syncer, exs map[string]exchangerates.ExchangeRateSource) http.Handler { srv := server{ cm: cm, e: e, s: s, - ex: ex, + exs: exs, startTime: time.Now().UTC(), } return jape.Mux(map[string]jape.Handler{ diff --git a/cmd/explored/main.go b/cmd/explored/main.go index c47eafe..b5f3d5d 100644 --- a/cmd/explored/main.go +++ b/cmd/explored/main.go @@ -50,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", }, @@ -263,15 +266,24 @@ func runRootCmd(ctx context.Context, log *zap.Logger) error { defer timeoutCancel() defer e.Shutdown(timeoutCtx) - var sources []exchangerates.ExchangeRateSource - sources = append(sources, exchangerates.NewKraken(exchangerates.KrakenSiacoinPair, 3*time.Second)) + var sourcesUSD, sourcesEUR []exchangerates.ExchangeRateSource + sourcesUSD = append(sourcesUSD, exchangerates.NewKraken(exchangerates.KrakenPairSiacoinUSD, cfg.ExchangeRates.Refresh)) + sourcesEUR = append(sourcesEUR, exchangerates.NewKraken(exchangerates.KrakenPairSiacoinEUR, cfg.ExchangeRates.Refresh)) if apiKey := os.Getenv("COINGECKO_API_KEY"); apiKey != "" { - sources = append(sources, exchangerates.NewCoinGecko(apiKey, exchangerates.CoinGeckoSicaoinPair, 3*time.Second)) + sourcesUSD = append(sourcesUSD, exchangerates.NewCoinGecko(apiKey, exchangerates.CoinGeckoCurrencyUSD, exchangerates.CoinGeckoTokenSiacoin, cfg.ExchangeRates.Refresh)) + sourcesEUR = append(sourcesEUR, exchangerates.NewCoinGecko(apiKey, exchangerates.CoinGeckoCurrencyEUR, exchangerates.CoinGeckoTokenSiacoin, cfg.ExchangeRates.Refresh)) + } + + exUSD := exchangerates.NewAverager(false, sourcesUSD...) + exEUR := exchangerates.NewAverager(false, sourcesEUR...) + go exUSD.Start(ctx) + go exEUR.Start(ctx) + exs := map[string]exchangerates.ExchangeRateSource{ + "USD": exUSD, + "EUR": exEUR, } - ex := exchangerates.NewAverager(false, sources...) - go ex.Start(ctx) - api := api.NewServer(e, cm, s, ex) + api := api.NewServer(e, cm, s, exs) 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 fa14b24..61d6fa8 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 index 79dfc5b..82d834e 100644 --- a/exchangerates/coingecko.go +++ b/exchangerates/coingecko.go @@ -5,15 +5,21 @@ import ( "encoding/json" "fmt" "net/http" - "net/url" "strings" "sync" "time" ) const ( - // CoinGeckoSicaoinPair is the ID of Siacoin in CoinGecko - CoinGeckoSicaoinPair = "siacoin" + // 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" ) type coinGeckoAPI struct { @@ -26,15 +32,14 @@ func newcoinGeckoAPI(apiKey string) *coinGeckoAPI { return &coinGeckoAPI{apiKey: apiKey} } -type coinGeckoPriceResponse map[string]struct { - USD float64 `json:"usd"` -} +type coinGeckoPriceResponse map[string]map[string]float64 // See https://docs.coingecko.com/reference/simple-price -func (k *coinGeckoAPI) ticker(pair string) (float64, error) { - pair = strings.ToLower(pair) +func (k *coinGeckoAPI) ticker(currency, token string) (float64, error) { + currency = strings.ToLower(currency) + token = strings.ToLower(token) - request, err := http.NewRequest(http.MethodGet, "https://api.coingecko.com/api/v3/simple/price?vs_currencies=usd&ids="+url.PathEscape(pair), nil) + request, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://api.coingecko.com/api/v3/simple/price?vs_currencies=%s&ids=%s", currency, token), nil) if err != nil { return 0, err } @@ -45,22 +50,29 @@ func (k *coinGeckoAPI) ticker(pair string) (float64, error) { if err != nil { return 0, err } + defer response.Body.Close() + var parsed coinGeckoPriceResponse if err := json.NewDecoder(response.Body).Decode(&parsed); err != nil { return 0, err } - price, ok := parsed[pair] + asset, ok := parsed[token] + if !ok { + return 0, fmt.Errorf("no asset %s", token) + } + price, ok := asset[currency] if !ok { - return 0, fmt.Errorf("no asset %s", pair) + return 0, fmt.Errorf("no currency %s", currency) } - return price.USD, nil + return price, nil } type coinGecko struct { - pair string - refresh time.Duration - client *coinGeckoAPI + currency string + token string + refresh time.Duration + client *coinGeckoAPI mu sync.Mutex rate float64 @@ -68,11 +80,12 @@ type coinGecko struct { } // NewCoinGecko returns an ExchangeRateSource that gets data from CoinGecko. -func NewCoinGecko(apiKey string, pair string, refresh time.Duration) ExchangeRateSource { +func NewCoinGecko(apiKey, currency, token string, refresh time.Duration) ExchangeRateSource { return &coinGecko{ - pair: pair, - refresh: refresh, - client: newcoinGeckoAPI(apiKey), + currency: currency, + token: token, + refresh: refresh, + client: newcoinGeckoAPI(apiKey), } } @@ -85,7 +98,7 @@ func (c *coinGecko) Start(ctx context.Context) { select { case <-ticker.C: c.mu.Lock() - c.rate, c.err = c.client.ticker(c.pair) + c.rate, c.err = c.client.ticker(c.currency, c.token) c.mu.Unlock() case <-ctx.Done(): c.mu.Lock() diff --git a/exchangerates/kraken.go b/exchangerates/kraken.go index 5a8d319..eaf2aec 100644 --- a/exchangerates/kraken.go +++ b/exchangerates/kraken.go @@ -13,8 +13,10 @@ import ( ) const ( - // KrakenSiacoinPair is the ID of Siacoin in Kraken - KrakenSiacoinPair = "SCUSD" + // KrakenSiacoinUSDPair is the ID of SC/USD pair in Kraken + KrakenPairSiacoinUSD = "SCUSD" + // KrakenSiacoinEURPair is the ID of SC/EUR pair in Kraken + KrakenPairSiacoinEUR = "SCEUR" ) type krakenAPI struct { @@ -47,6 +49,7 @@ func (k *krakenAPI) ticker(pair string) (float64, error) { if err != nil { return 0, err } + defer response.Body.Close() var parsed krakenPriceResponse if err := json.NewDecoder(response.Body).Decode(&parsed); err != nil { diff --git a/exchangerates/kraken_test.go b/exchangerates/kraken_test.go index 5aab509..ddad659 100644 --- a/exchangerates/kraken_test.go +++ b/exchangerates/kraken_test.go @@ -12,7 +12,7 @@ func TestKraken(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - kraken := NewKraken(KrakenSiacoinPair, interval) + kraken := NewKraken(KrakenPairSiacoinUSD, interval) go kraken.Start(ctx) time.Sleep(2 * interval) From 9fcfed50b35fe9269b30be4bdef2cb926aa66651 Mon Sep 17 00:00:00 2001 From: Christopher Tarry Date: Tue, 14 Jan 2025 16:29:03 -0500 Subject: [PATCH 08/16] add warning if currency is not specified on exchange rate endpoint --- api/server.go | 4 ++++ exchangerates/kraken.go | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/api/server.go b/api/server.go index 834a631..458e590 100644 --- a/api/server.go +++ b/api/server.go @@ -746,6 +746,10 @@ func (s *server) exchangeRateHandler(jc jape.Context) { 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) ex, ok := s.exs[currency] diff --git a/exchangerates/kraken.go b/exchangerates/kraken.go index eaf2aec..4c920b2 100644 --- a/exchangerates/kraken.go +++ b/exchangerates/kraken.go @@ -13,9 +13,9 @@ import ( ) const ( - // KrakenSiacoinUSDPair is the ID of SC/USD pair in Kraken + // KrakenPairSiacoinUSD is the ID of SC/USD pair in Kraken KrakenPairSiacoinUSD = "SCUSD" - // KrakenSiacoinEURPair is the ID of SC/EUR pair in Kraken + // KrakenPairSiacoinEUR is the ID of SC/EUR pair in Kraken KrakenPairSiacoinEUR = "SCEUR" ) From cc46fe95b66152ca79a726b9197f27b24f8f905f Mon Sep 17 00:00:00 2001 From: Christopher Tarry Date: Wed, 15 Jan 2025 15:38:57 -0500 Subject: [PATCH 09/16] sanity check that at least one source is provided --- cmd/explored/main.go | 10 ++++++++-- exchangerates/exchangerates.go | 7 +++++-- exchangerates/exchangerates_test.go | 31 +++++++++++++++++++++++------ 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/cmd/explored/main.go b/cmd/explored/main.go index b5f3d5d..ff746fd 100644 --- a/cmd/explored/main.go +++ b/cmd/explored/main.go @@ -274,8 +274,14 @@ func runRootCmd(ctx context.Context, log *zap.Logger) error { sourcesEUR = append(sourcesEUR, exchangerates.NewCoinGecko(apiKey, exchangerates.CoinGeckoCurrencyEUR, exchangerates.CoinGeckoTokenSiacoin, cfg.ExchangeRates.Refresh)) } - exUSD := exchangerates.NewAverager(false, sourcesUSD...) - exEUR := exchangerates.NewAverager(false, sourcesEUR...) + exUSD, err := exchangerates.NewAverager(false, sourcesUSD...) + if err != nil { + return fmt.Errorf("failed to create USD source: %w", err) + } + exEUR, err := exchangerates.NewAverager(false, sourcesEUR...) + if err != nil { + return fmt.Errorf("failed to create EUR source: %w", err) + } go exUSD.Start(ctx) go exEUR.Start(ctx) exs := map[string]exchangerates.ExchangeRateSource{ diff --git a/exchangerates/exchangerates.go b/exchangerates/exchangerates.go index 471208c..221e396 100644 --- a/exchangerates/exchangerates.go +++ b/exchangerates/exchangerates.go @@ -18,11 +18,14 @@ type averager struct { // NewAverager returns an exchange rate source that averages multiple exchange // rates. -func NewAverager(ignoreOnError bool, sources ...ExchangeRateSource) ExchangeRateSource { +func NewAverager(ignoreOnError bool, sources ...ExchangeRateSource) (ExchangeRateSource, error) { + if len(sources) == 0 { + return nil, errors.New("no sources provided") + } return &averager{ ignoreOnError: ignoreOnError, sources: sources, - } + }, nil } // Start implements ExchangeRateSource. diff --git a/exchangerates/exchangerates_test.go b/exchangerates/exchangerates_test.go index 379e764..5f9297e 100644 --- a/exchangerates/exchangerates_test.go +++ b/exchangerates/exchangerates_test.go @@ -42,13 +42,23 @@ func TestAverager(t *testing.T) { s3 := newConstantPriceSource(p3) errorSource := &errorPriceSource{} + { + _, err := NewAverager(true) + if err == nil { + t.Fatal("should have gotten error for averager with no sources") + } + } + { ctx, cancel := context.WithCancel(context.Background()) - averager := NewAverager(false) + averager, err := NewAverager(true, errorSource, errorSource, errorSource) + if err != nil { + t.Fatal(err) + } go averager.Start(ctx) time.Sleep(2 * interval) - _, err := averager.Last() + _, err = averager.Last() // should get error: "no sources working" if err == nil { t.Fatal("should have gotten error for averager with no sources") @@ -58,7 +68,10 @@ func TestAverager(t *testing.T) { { ctx, cancel := context.WithCancel(context.Background()) - averager := NewAverager(false, s1, s2, s3) + averager, err := NewAverager(false, s1, s2, s3) + if err != nil { + t.Fatal(err) + } go averager.Start(ctx) time.Sleep(2 * interval) @@ -77,12 +90,15 @@ func TestAverager(t *testing.T) { { ctx, cancel := context.WithCancel(context.Background()) - averager := NewAverager(false, s1, s2, s3, errorSource) + averager, err := NewAverager(false, s1, s2, s3, errorSource) + if err != nil { + t.Fatal(err) + } go averager.Start(ctx) time.Sleep(2 * interval) - _, err := averager.Last() + _, err = averager.Last() // Should get an error because the errorSource will fail // (ignoreOnError = false) if err == nil { @@ -93,7 +109,10 @@ func TestAverager(t *testing.T) { { ctx, cancel := context.WithCancel(context.Background()) - averager := NewAverager(true, s1, s2, s3, errorSource) + averager, err := NewAverager(true, s1, s2, s3, errorSource) + if err != nil { + t.Fatal(err) + } go averager.Start(ctx) time.Sleep(2 * interval) From 7eb11c48bb008d6c301c42f9d77302495a3a2410 Mon Sep 17 00:00:00 2001 From: Christopher Tarry Date: Wed, 15 Jan 2025 15:42:35 -0500 Subject: [PATCH 10/16] ignore exchange rate in averager when it is 0 --- exchangerates/exchangerates.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/exchangerates/exchangerates.go b/exchangerates/exchangerates.go index 221e396..b2b7c8a 100644 --- a/exchangerates/exchangerates.go +++ b/exchangerates/exchangerates.go @@ -40,8 +40,10 @@ func (a *averager) Last() (float64, error) { sum, count := 0.0, 0.0 for i := range a.sources { if v, err := a.sources[i].Last(); err == nil { - sum += v - count++ + if v != 0 { + sum += v + count++ + } } else if !a.ignoreOnError { return 0, err } From 1b2248ca948f3ced1b75923781fc275271b6669f Mon Sep 17 00:00:00 2001 From: Christopher Tarry Date: Wed, 15 Jan 2025 15:46:10 -0500 Subject: [PATCH 11/16] use context for http requests in coingecko/kraken --- exchangerates/coingecko.go | 6 +++--- exchangerates/kraken.go | 11 ++++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/exchangerates/coingecko.go b/exchangerates/coingecko.go index 82d834e..7817216 100644 --- a/exchangerates/coingecko.go +++ b/exchangerates/coingecko.go @@ -35,11 +35,11 @@ func newcoinGeckoAPI(apiKey string) *coinGeckoAPI { type coinGeckoPriceResponse map[string]map[string]float64 // See https://docs.coingecko.com/reference/simple-price -func (k *coinGeckoAPI) ticker(currency, token string) (float64, error) { +func (k *coinGeckoAPI) ticker(ctx context.Context, currency, token string) (float64, error) { currency = strings.ToLower(currency) token = strings.ToLower(token) - request, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://api.coingecko.com/api/v3/simple/price?vs_currencies=%s&ids=%s", currency, token), nil) + request, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("https://api.coingecko.com/api/v3/simple/price?vs_currencies=%s&ids=%s", currency, token), nil) if err != nil { return 0, err } @@ -98,7 +98,7 @@ func (c *coinGecko) Start(ctx context.Context) { select { case <-ticker.C: c.mu.Lock() - c.rate, c.err = c.client.ticker(c.currency, c.token) + c.rate, c.err = c.client.ticker(ctx, c.currency, c.token) c.mu.Unlock() case <-ctx.Done(): c.mu.Lock() diff --git a/exchangerates/kraken.go b/exchangerates/kraken.go index 4c920b2..fd60228 100644 --- a/exchangerates/kraken.go +++ b/exchangerates/kraken.go @@ -43,9 +43,14 @@ func newKrakenAPI() *krakenAPI { } // See https://docs.kraken.com/api/docs/rest-api/get-ticker-information -func (k *krakenAPI) ticker(pair string) (float64, error) { +func (k *krakenAPI) ticker(ctx context.Context, pair string) (float64, error) { pair = strings.ToUpper(pair) - response, err := k.client.Get("https://api.kraken.com/0/public/Ticker?pair=" + url.PathEscape(pair)) + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.kraken.com/0/public/Ticker?pair="+url.PathEscape(pair), nil) + if err != nil { + return 0, err + } + response, err := k.client.Do(request) if err != nil { return 0, err } @@ -96,7 +101,7 @@ func (k *kraken) Start(ctx context.Context) { select { case <-ticker.C: k.mu.Lock() - k.rate, k.err = k.client.ticker(k.pair) + k.rate, k.err = k.client.ticker(ctx, k.pair) k.mu.Unlock() case <-ctx.Done(): k.mu.Lock() From 1fc8b563a62f0258031e706f1edeeccf12f95fcd Mon Sep 17 00:00:00 2001 From: Christopher Tarry Date: Wed, 15 Jan 2025 15:48:24 -0500 Subject: [PATCH 12/16] retrieve exchange rate immediately with Kraken/CoinGecko sources so we don't have to wait for refresh delay to happen to start getting prices --- exchangerates/coingecko.go | 3 +++ exchangerates/kraken.go | 3 +++ 2 files changed, 6 insertions(+) diff --git a/exchangerates/coingecko.go b/exchangerates/coingecko.go index 7817216..fc5deb9 100644 --- a/exchangerates/coingecko.go +++ b/exchangerates/coingecko.go @@ -94,6 +94,9 @@ func (c *coinGecko) Start(ctx context.Context) { ticker := time.NewTicker(c.refresh) defer ticker.Stop() + c.mu.Lock() + c.rate, c.err = c.client.ticker(ctx, c.currency, c.token) + c.mu.Unlock() for { select { case <-ticker.C: diff --git a/exchangerates/kraken.go b/exchangerates/kraken.go index fd60228..6b4b481 100644 --- a/exchangerates/kraken.go +++ b/exchangerates/kraken.go @@ -97,6 +97,9 @@ func (k *kraken) Start(ctx context.Context) { ticker := time.NewTicker(k.refresh) defer ticker.Stop() + k.mu.Lock() + k.rate, k.err = k.client.ticker(ctx, k.pair) + k.mu.Unlock() for { select { case <-ticker.C: From 812ee410a04f9e40e68e6b573b06cd808154d6a6 Mon Sep 17 00:00:00 2001 From: Christopher Tarry Date: Wed, 15 Jan 2025 17:20:30 -0500 Subject: [PATCH 13/16] fetch multiple rates at once and allow retrieving different rates for currencies in the same kraken/coingecko ExchangeRateSource --- api/api_test.go | 7 +-- api/server.go | 20 +++---- cmd/explored/main.go | 33 +++++------ exchangerates/coingecko.go | 90 +++++++++++++++++------------ exchangerates/exchangerates.go | 15 ++++- exchangerates/exchangerates_test.go | 13 +++-- exchangerates/kraken.go | 86 +++++++++++++++------------ exchangerates/kraken_test.go | 19 ++++-- 8 files changed, 161 insertions(+), 122 deletions(-) diff --git a/api/api_test.go b/api/api_test.go index 66fe6db..cf77b6c 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -71,12 +71,11 @@ 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) { ctx, cancel := context.WithCancel(context.Background()) - ex := exchangerates.NewKraken(exchangerates.KrakenPairSiacoinUSD, time.Second) + ex := exchangerates.NewKraken(map[string]string{ + exchangerates.CurrencyUSD: exchangerates.KrakenPairSiacoinUSD}, time.Second) go ex.Start(ctx) - api := api.NewServer(e, cm, &syncer.Syncer{}, map[string]exchangerates.ExchangeRateSource{ - "USD": ex, - }) + 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") { diff --git a/api/server.go b/api/server.go index 458e590..8e5344f 100644 --- a/api/server.go +++ b/api/server.go @@ -115,10 +115,10 @@ var ( ) type server struct { - cm ChainManager - e Explorer - s Syncer - exs map[string]exchangerates.ExchangeRateSource + cm ChainManager + e Explorer + s Syncer + ex exchangerates.ExchangeRateSource startTime time.Time } @@ -752,13 +752,7 @@ func (s *server) exchangeRateHandler(jc jape.Context) { } currency = strings.ToUpper(currency) - ex, ok := s.exs[currency] - if !ok { - jc.Error(fmt.Errorf("currency not supported: %s", currency), http.StatusNotFound) - return - } - - price, err := ex.Last() + price, err := s.ex.Last(currency) if jc.Check("failed to get exchange rate", err) != nil { return } @@ -766,12 +760,12 @@ func (s *server) exchangeRateHandler(jc jape.Context) { } // NewServer returns an HTTP handler that serves the explored API. -func NewServer(e Explorer, cm ChainManager, s Syncer, exs map[string]exchangerates.ExchangeRateSource) http.Handler { +func NewServer(e Explorer, cm ChainManager, s Syncer, ex exchangerates.ExchangeRateSource) http.Handler { srv := server{ cm: cm, e: e, s: s, - exs: exs, + ex: ex, startTime: time.Now().UTC(), } return jape.Mux(map[string]jape.Handler{ diff --git a/cmd/explored/main.go b/cmd/explored/main.go index ff746fd..4cd6b8e 100644 --- a/cmd/explored/main.go +++ b/cmd/explored/main.go @@ -266,30 +266,27 @@ func runRootCmd(ctx context.Context, log *zap.Logger) error { defer timeoutCancel() defer e.Shutdown(timeoutCtx) - var sourcesUSD, sourcesEUR []exchangerates.ExchangeRateSource - sourcesUSD = append(sourcesUSD, exchangerates.NewKraken(exchangerates.KrakenPairSiacoinUSD, cfg.ExchangeRates.Refresh)) - sourcesEUR = append(sourcesEUR, exchangerates.NewKraken(exchangerates.KrakenPairSiacoinEUR, cfg.ExchangeRates.Refresh)) + var sources []exchangerates.ExchangeRateSource + 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 != "" { - sourcesUSD = append(sourcesUSD, exchangerates.NewCoinGecko(apiKey, exchangerates.CoinGeckoCurrencyUSD, exchangerates.CoinGeckoTokenSiacoin, cfg.ExchangeRates.Refresh)) - sourcesEUR = append(sourcesEUR, exchangerates.NewCoinGecko(apiKey, exchangerates.CoinGeckoCurrencyEUR, exchangerates.CoinGeckoTokenSiacoin, cfg.ExchangeRates.Refresh)) + sources = append(sources, exchangerates.NewCoinGecko(apiKey, map[string]string{ + exchangerates.CurrencyUSD: exchangerates.CoinGeckoCurrencyUSD, + exchangerates.CurrencyEUR: exchangerates.CoinGeckoCurrencyEUR, + exchangerates.CurrencyBTC: exchangerates.CoinGeckoCurrencyBTC, + }, exchangerates.CoinGeckoTokenSiacoin, cfg.ExchangeRates.Refresh)) } - exUSD, err := exchangerates.NewAverager(false, sourcesUSD...) + ex, err := exchangerates.NewAverager(false, sources...) if err != nil { - return fmt.Errorf("failed to create USD source: %w", err) - } - exEUR, err := exchangerates.NewAverager(false, sourcesEUR...) - if err != nil { - return fmt.Errorf("failed to create EUR source: %w", err) - } - go exUSD.Start(ctx) - go exEUR.Start(ctx) - exs := map[string]exchangerates.ExchangeRateSource{ - "USD": exUSD, - "EUR": exEUR, + return fmt.Errorf("failed to create exchange rate source: %w", err) } + go ex.Start(ctx) - api := api.NewServer(e, cm, s, exs) + 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/exchangerates/coingecko.go b/exchangerates/coingecko.go index fc5deb9..ace68b8 100644 --- a/exchangerates/coingecko.go +++ b/exchangerates/coingecko.go @@ -20,72 +20,73 @@ const ( CoinGeckoCurrencyUSD = "usd" // CoinGeckoCurrencyEUR is the name of euros in CoinGecko. CoinGeckoCurrencyEUR = "eur" + // CoinGeckoCurrencyBTC is the name of bitcoin in CoinGecko. + CoinGeckoCurrencyBTC = "btc" ) type coinGeckoAPI struct { apiKey string - client http.Client } -func newcoinGeckoAPI(apiKey string) *coinGeckoAPI { +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 (k *coinGeckoAPI) ticker(ctx context.Context, currency, token string) (float64, error) { - currency = strings.ToLower(currency) +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", currency, token), nil) + 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 0, err + return nil, err } request.Header.Set("accept", "application/json") - request.Header.Set("x-cg-demo-api-key", k.apiKey) + request.Header.Set("x-cg-demo-api-key", c.apiKey) - response, err := k.client.Do(request) + response, err := c.client.Do(request) if err != nil { - return 0, err + return nil, err } defer response.Body.Close() var parsed coinGeckoPriceResponse if err := json.NewDecoder(response.Body).Decode(&parsed); err != nil { - return 0, err + return nil, err } asset, ok := parsed[token] if !ok { - return 0, fmt.Errorf("no asset %s", token) - } - price, ok := asset[currency] - if !ok { - return 0, fmt.Errorf("no currency %s", currency) + return nil, fmt.Errorf("no asset %s", token) } - return price, nil + + return asset, nil } type coinGecko struct { - currency string - token string - refresh time.Duration - client *coinGeckoAPI - - mu sync.Mutex - rate float64 - err error + 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 returns an ExchangeRateSource that gets data from CoinGecko. -func NewCoinGecko(apiKey, currency, token string, refresh time.Duration) ExchangeRateSource { +// NewCoinGecko creates an ExchangeRateSource with user-specified mappings +func NewCoinGecko(apiKey string, pairMap map[string]string, token string, refresh time.Duration) ExchangeRateSource { return &coinGecko{ - currency: currency, - token: token, - refresh: refresh, - client: newcoinGeckoAPI(apiKey), + token: token, + pairMap: pairMap, + refresh: refresh, + client: newCoinGeckoAPI(apiKey), + rates: make(map[string]float64), } } @@ -94,14 +95,20 @@ 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.rate, c.err = c.client.ticker(ctx, c.currency, c.token) + c.rates, c.err = c.client.tickers(ctx, currencies, c.token) c.mu.Unlock() + for { select { case <-ticker.C: c.mu.Lock() - c.rate, c.err = c.client.ticker(ctx, c.currency, c.token) + c.rates, c.err = c.client.tickers(ctx, currencies, c.token) c.mu.Unlock() case <-ctx.Done(): c.mu.Lock() @@ -112,10 +119,19 @@ func (c *coinGecko) Start(ctx context.Context) { } } -// Last implements ExchangeRateSource -func (c *coinGecko) Last() (rate float64, err error) { +// Last implements ExchangeRateSource. +func (c *coinGecko) Last(currency string) (float64, error) { c.mu.Lock() - rate, err = c.rate, c.err - c.mu.Unlock() - return + 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 index b2b7c8a..898f0ae 100644 --- a/exchangerates/exchangerates.go +++ b/exchangerates/exchangerates.go @@ -5,9 +5,18 @@ import ( "errors" ) +const ( + // CurrencyUSD represents US dollars + CurrencyUSD = "USD" + // CurrencyEUR represents euros + CurrencyEUR = "EUR" + // CurrencyBTC represents bitcoin + CurrencyBTC = "BTC" +) + // An ExchangeRateSource returns the price of 1 unit of an asset in USD. type ExchangeRateSource interface { - Last() (float64, error) + Last(currency string) (float64, error) Start(ctx context.Context) } @@ -36,10 +45,10 @@ func (a *averager) Start(ctx context.Context) { } // Last implements ExchangeRateSource. -func (a *averager) Last() (float64, error) { +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(); err == nil { + if v, err := a.sources[i].Last(currency); err == nil { if v != 0 { sum += v count++ diff --git a/exchangerates/exchangerates_test.go b/exchangerates/exchangerates_test.go index 5f9297e..d730e63 100644 --- a/exchangerates/exchangerates_test.go +++ b/exchangerates/exchangerates_test.go @@ -13,7 +13,7 @@ type constantPriceSource struct { func (c *constantPriceSource) Start(ctx context.Context) {} -func (c *constantPriceSource) Last() (float64, error) { +func (c *constantPriceSource) Last(string) (float64, error) { return c.x, nil } @@ -25,12 +25,13 @@ type errorPriceSource struct{} func (c *errorPriceSource) Start(ctx context.Context) {} -func (c *errorPriceSource) Last() (float64, error) { +func (c *errorPriceSource) Last(string) (float64, error) { return -1, errors.New("error") } func TestAverager(t *testing.T) { const interval = time.Second + const usd = "USD" const ( p1 = 1.0 @@ -58,7 +59,7 @@ func TestAverager(t *testing.T) { go averager.Start(ctx) time.Sleep(2 * interval) - _, err = averager.Last() + _, err = averager.Last(usd) // should get error: "no sources working" if err == nil { t.Fatal("should have gotten error for averager with no sources") @@ -76,7 +77,7 @@ func TestAverager(t *testing.T) { time.Sleep(2 * interval) - price, err := averager.Last() + price, err := averager.Last(usd) if err != nil { t.Fatal(err) } @@ -98,7 +99,7 @@ func TestAverager(t *testing.T) { time.Sleep(2 * interval) - _, err = averager.Last() + _, err = averager.Last(usd) // Should get an error because the errorSource will fail // (ignoreOnError = false) if err == nil { @@ -119,7 +120,7 @@ func TestAverager(t *testing.T) { // Should not get an error because the errorSource will just be ignored // if at least one other source works (ignoreOnError = true) - price, err := averager.Last() + price, err := averager.Last(usd) if err != nil { t.Fatal(err) } diff --git a/exchangerates/kraken.go b/exchangerates/kraken.go index 6b4b481..9dd72ce 100644 --- a/exchangerates/kraken.go +++ b/exchangerates/kraken.go @@ -17,6 +17,8 @@ const ( 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 { @@ -26,15 +28,7 @@ type krakenAPI struct { type krakenPriceResponse struct { Error []any `json:"error"` Result map[string]struct { - A []string `json:"a"` B []string `json:"b"` - C []string `json:"c"` - V []string `json:"v"` - P []string `json:"p"` - T []int `json:"t"` - L []string `json:"l"` - H []string `json:"h"` - O string `json:"o"` } `json:"result"` } @@ -42,53 +36,56 @@ func newKrakenAPI() *krakenAPI { return &krakenAPI{} } -// See https://docs.kraken.com/api/docs/rest-api/get-ticker-information -func (k *krakenAPI) ticker(ctx context.Context, pair string) (float64, error) { - pair = strings.ToUpper(pair) - - request, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.kraken.com/0/public/Ticker?pair="+url.PathEscape(pair), nil) +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 0, err + return nil, err } + response, err := k.client.Do(request) if err != nil { - return 0, err + return nil, err } defer response.Body.Close() var parsed krakenPriceResponse if err := json.NewDecoder(response.Body).Decode(&parsed); err != nil { - return 0, err + return nil, err } - p := parsed.Result[pair] - if len(p.B) == 0 { - return 0, fmt.Errorf("no asset %s", pair) - } - price, err := strconv.ParseFloat(p.B[0], 64) - if err != nil { - return 0, 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 price, nil + return rates, nil } type kraken struct { - pair string + pairMap map[string]string // User-specified currency -> Kraken pair refresh time.Duration client *krakenAPI - mu sync.Mutex - rate float64 - err error + mu sync.Mutex + rates map[string]float64 // Kraken pair -> rate + err error } // NewKraken returns an ExchangeRateSource that gets data from Kraken. -func NewKraken(pair string, refresh time.Duration) ExchangeRateSource { +func NewKraken(pairMap map[string]string, refresh time.Duration) ExchangeRateSource { return &kraken{ - pair: pair, + pairMap: pairMap, refresh: refresh, client: newKrakenAPI(), + rates: make(map[string]float64), } } @@ -97,14 +94,20 @@ 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.rate, k.err = k.client.ticker(ctx, k.pair) + k.rates, k.err = k.client.tickers(ctx, krakenPairs) k.mu.Unlock() + for { select { case <-ticker.C: k.mu.Lock() - k.rate, k.err = k.client.ticker(ctx, k.pair) + k.rates, k.err = k.client.tickers(ctx, krakenPairs) k.mu.Unlock() case <-ctx.Done(): k.mu.Lock() @@ -115,10 +118,19 @@ func (k *kraken) Start(ctx context.Context) { } } -// Last implements ExchangeRateSource -func (k *kraken) Last() (rate float64, err error) { +// Last implements ExchangeRateSource. +func (k *kraken) Last(currency string) (float64, error) { k.mu.Lock() - rate, err = k.rate, k.err - k.mu.Unlock() - return + 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 index ddad659..0d2f27e 100644 --- a/exchangerates/kraken_test.go +++ b/exchangerates/kraken_test.go @@ -12,15 +12,26 @@ func TestKraken(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - kraken := NewKraken(KrakenPairSiacoinUSD, interval) + kraken := NewKraken(map[string]string{ + CurrencyUSD: KrakenPairSiacoinUSD, + CurrencyEUR: KrakenPairSiacoinEUR, + }, interval) go kraken.Start(ctx) time.Sleep(2 * interval) - price, err := kraken.Last() - if err != nil { + if price, err := kraken.Last("USD"); err != nil { t.Fatal(err) + } else if price <= 0.0 { + t.Fatalf("invalid price: %f", price) } - if price <= 0.0 { + + 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") + } } From b7376f16ce08e254918bd7a34acbe226fe4b860e Mon Sep 17 00:00:00 2001 From: Christopher Tarry Date: Wed, 15 Jan 2025 17:30:23 -0500 Subject: [PATCH 14/16] add coingecko only currencies --- cmd/explored/main.go | 8 +++++++- exchangerates/coingecko.go | 14 +++++++++++++- exchangerates/exchangerates.go | 18 +++++++++++++++--- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/cmd/explored/main.go b/cmd/explored/main.go index 4cd6b8e..213bf29 100644 --- a/cmd/explored/main.go +++ b/cmd/explored/main.go @@ -276,11 +276,17 @@ func runRootCmd(ctx context.Context, log *zap.Logger) error { 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(false, sources...) + ex, err := exchangerates.NewAverager(true, sources...) if err != nil { return fmt.Errorf("failed to create exchange rate source: %w", err) } diff --git a/exchangerates/coingecko.go b/exchangerates/coingecko.go index ace68b8..ed2405d 100644 --- a/exchangerates/coingecko.go +++ b/exchangerates/coingecko.go @@ -20,8 +20,20 @@ const ( CoinGeckoCurrencyUSD = "usd" // CoinGeckoCurrencyEUR is the name of euros in CoinGecko. CoinGeckoCurrencyEUR = "eur" - // CoinGeckoCurrencyBTC is the name of bitcoin in CoinGecko. + // 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 { diff --git a/exchangerates/exchangerates.go b/exchangerates/exchangerates.go index 898f0ae..7fb4fec 100644 --- a/exchangerates/exchangerates.go +++ b/exchangerates/exchangerates.go @@ -6,12 +6,24 @@ import ( ) const ( - // CurrencyUSD represents US dollars + // CurrencyUSD represents US dollars. CurrencyUSD = "USD" - // CurrencyEUR represents euros + // CurrencyEUR represents euros. CurrencyEUR = "EUR" - // CurrencyBTC represents bitcoin + // 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 ExchangeRateSource returns the price of 1 unit of an asset in USD. From 069915067570fec88e7c03ba99f0aa99f0f973de Mon Sep 17 00:00:00 2001 From: Christopher Tarry Date: Thu, 16 Jan 2025 10:21:45 -0500 Subject: [PATCH 15/16] refactor tests --- exchangerates/exchangerates_test.go | 210 +++++++++++++++------------- 1 file changed, 110 insertions(+), 100 deletions(-) diff --git a/exchangerates/exchangerates_test.go b/exchangerates/exchangerates_test.go index d730e63..b1f0ce4 100644 --- a/exchangerates/exchangerates_test.go +++ b/exchangerates/exchangerates_test.go @@ -3,132 +3,142 @@ package exchangerates import ( "context" "errors" + "sync" "testing" "time" ) -type constantPriceSource struct { +type constantExchangeRateSource struct { x float64 + + mu sync.Mutex + rate float64 } -func (c *constantPriceSource) Start(ctx context.Context) {} +func (c *constantExchangeRateSource) Start(ctx context.Context) { + c.mu.Lock() + c.rate = c.x + c.mu.Unlock() +} -func (c *constantPriceSource) Last(string) (float64, error) { - return c.x, nil +func (c *constantExchangeRateSource) Last(string) (rate float64, err error) { + c.mu.Lock() + rate, err = c.rate, nil + c.mu.Unlock() + return } -func newConstantPriceSource(x float64) *constantPriceSource { - return &constantPriceSource{x: x} +func newConstantExchangeRateSource(x float64) *constantExchangeRateSource { + return &constantExchangeRateSource{x: x} } -type errorPriceSource struct{} +type errorExchangeRateSource struct{} -func (c *errorPriceSource) Start(ctx context.Context) {} +func (c *errorExchangeRateSource) Start(ctx context.Context) {} -func (c *errorPriceSource) Last(string) (float64, error) { +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 usd = "USD" const ( p1 = 1.0 p2 = 10.0 p3 = 100.0 ) - s1 := newConstantPriceSource(p1) - s2 := newConstantPriceSource(p2) - s3 := newConstantPriceSource(p3) - errorSource := &errorPriceSource{} - - { - _, err := NewAverager(true) - if err == nil { - t.Fatal("should have gotten error for averager with no sources") - } - } - - { - ctx, cancel := context.WithCancel(context.Background()) - averager, err := NewAverager(true, errorSource, errorSource, errorSource) - if err != nil { - t.Fatal(err) - } - go averager.Start(ctx) - - time.Sleep(2 * interval) - _, err = averager.Last(usd) - // should get error: "no sources working" - if err == nil { - t.Fatal("should have gotten error for averager with no sources") - } - cancel() - } - - { - ctx, cancel := context.WithCancel(context.Background()) - averager, err := NewAverager(false, s1, s2, s3) - if err != nil { - t.Fatal(err) - } - go averager.Start(ctx) - - time.Sleep(2 * interval) - - price, err := averager.Last(usd) - if err != nil { - t.Fatal(err) - } - - const expect = ((p1 + p2 + p3) / 3) - if price != expect { - t.Fatalf("wrong price, got %v, expected %v", price, expect) - } - cancel() - } - - { - ctx, cancel := context.WithCancel(context.Background()) - averager, err := NewAverager(false, s1, s2, s3, errorSource) - if err != nil { - t.Fatal(err) - } - go averager.Start(ctx) - - time.Sleep(2 * interval) - - _, err = averager.Last(usd) - // Should get an error because the errorSource will fail - // (ignoreOnError = false) - if err == nil { - t.Fatal("should have gotten error for averager with error source") - } - cancel() + s1 := newConstantExchangeRateSource(p1) + s2 := newConstantExchangeRateSource(p2) + s3 := newConstantExchangeRateSource(p3) + errorSource := &errorExchangeRateSource{} + + tests := []struct { + name string + ignoreOnError bool + sources []ExchangeRateSource + 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: []ExchangeRateSource{errorSource, errorSource, errorSource}, + expectError: true, + errorMessage: "Should have gotten error for averager with no working sources", + }, + { + name: "Valid sources without errors", + ignoreOnError: false, + sources: []ExchangeRateSource{s1, s2, s3}, + expectedPrice: (p1 + p2 + p3) / 3, + expectError: false, + }, + { + name: "One error source without ignoreOnError", + ignoreOnError: false, + sources: []ExchangeRateSource{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: []ExchangeRateSource{s1, s2, s3, errorSource}, + expectedPrice: (p1 + p2 + p3) / 3, + expectError: false, + }, } - { - ctx, cancel := context.WithCancel(context.Background()) - averager, err := NewAverager(true, s1, s2, s3, errorSource) - if err != nil { - t.Fatal(err) - } - go averager.Start(ctx) - - time.Sleep(2 * interval) - - // Should not get an error because the errorSource will just be ignored - // if at least one other source works (ignoreOnError = true) - price, err := averager.Last(usd) - if err != nil { - t.Fatal(err) - } - - const expect = ((p1 + p2 + p3) / 3) - if price != expect { - t.Fatalf("wrong price, got %v, expected %v", price, expect) - } - cancel() + 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) + } + }) } } From 7787a2dce91adf4e5889107b89838ce499f9ab15 Mon Sep 17 00:00:00 2001 From: Christopher Tarry Date: Tue, 21 Jan 2025 11:42:19 -0500 Subject: [PATCH 16/16] rename ExchangeRateSource -> Source --- api/server.go | 4 ++-- cmd/explored/main.go | 2 +- exchangerates/coingecko.go | 8 ++++---- exchangerates/exchangerates.go | 12 ++++++------ exchangerates/exchangerates_test.go | 10 +++++----- exchangerates/kraken.go | 8 ++++---- 6 files changed, 22 insertions(+), 22 deletions(-) diff --git a/api/server.go b/api/server.go index 8e5344f..1264e4b 100644 --- a/api/server.go +++ b/api/server.go @@ -118,7 +118,7 @@ type server struct { cm ChainManager e Explorer s Syncer - ex exchangerates.ExchangeRateSource + ex exchangerates.Source startTime time.Time } @@ -760,7 +760,7 @@ func (s *server) exchangeRateHandler(jc jape.Context) { } // NewServer returns an HTTP handler that serves the explored API. -func NewServer(e Explorer, cm ChainManager, s Syncer, ex exchangerates.ExchangeRateSource) http.Handler { +func NewServer(e Explorer, cm ChainManager, s Syncer, ex exchangerates.Source) http.Handler { srv := server{ cm: cm, e: e, diff --git a/cmd/explored/main.go b/cmd/explored/main.go index 213bf29..c12cdb4 100644 --- a/cmd/explored/main.go +++ b/cmd/explored/main.go @@ -266,7 +266,7 @@ func runRootCmd(ctx context.Context, log *zap.Logger) error { defer timeoutCancel() defer e.Shutdown(timeoutCtx) - var sources []exchangerates.ExchangeRateSource + var sources []exchangerates.Source sources = append(sources, exchangerates.NewKraken(map[string]string{ exchangerates.CurrencyUSD: exchangerates.KrakenPairSiacoinUSD, exchangerates.CurrencyEUR: exchangerates.KrakenPairSiacoinEUR, diff --git a/exchangerates/coingecko.go b/exchangerates/coingecko.go index ed2405d..cf7a256 100644 --- a/exchangerates/coingecko.go +++ b/exchangerates/coingecko.go @@ -91,8 +91,8 @@ type coinGecko struct { err error } -// NewCoinGecko creates an ExchangeRateSource with user-specified mappings -func NewCoinGecko(apiKey string, pairMap map[string]string, token string, refresh time.Duration) ExchangeRateSource { +// 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, @@ -102,7 +102,7 @@ func NewCoinGecko(apiKey string, pairMap map[string]string, token string, refres } } -// Start implements ExchangeRateSource. +// Start implements Source. func (c *coinGecko) Start(ctx context.Context) { ticker := time.NewTicker(c.refresh) defer ticker.Stop() @@ -131,7 +131,7 @@ func (c *coinGecko) Start(ctx context.Context) { } } -// Last implements ExchangeRateSource. +// Last implements Source. func (c *coinGecko) Last(currency string) (float64, error) { c.mu.Lock() defer c.mu.Unlock() diff --git a/exchangerates/exchangerates.go b/exchangerates/exchangerates.go index 7fb4fec..cc34413 100644 --- a/exchangerates/exchangerates.go +++ b/exchangerates/exchangerates.go @@ -26,20 +26,20 @@ const ( CurrencyETH = "ETH" ) -// An ExchangeRateSource returns the price of 1 unit of an asset in USD. -type ExchangeRateSource interface { +// 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 []ExchangeRateSource + sources []Source } // NewAverager returns an exchange rate source that averages multiple exchange // rates. -func NewAverager(ignoreOnError bool, sources ...ExchangeRateSource) (ExchangeRateSource, error) { +func NewAverager(ignoreOnError bool, sources ...Source) (Source, error) { if len(sources) == 0 { return nil, errors.New("no sources provided") } @@ -49,14 +49,14 @@ func NewAverager(ignoreOnError bool, sources ...ExchangeRateSource) (ExchangeRat }, nil } -// Start implements ExchangeRateSource. +// Start implements Source. func (a *averager) Start(ctx context.Context) { for i := range a.sources { go a.sources[i].Start(ctx) } } -// Last implements ExchangeRateSource. +// Last implements Source. func (a *averager) Last(currency string) (float64, error) { sum, count := 0.0, 0.0 for i := range a.sources { diff --git a/exchangerates/exchangerates_test.go b/exchangerates/exchangerates_test.go index b1f0ce4..4b97661 100644 --- a/exchangerates/exchangerates_test.go +++ b/exchangerates/exchangerates_test.go @@ -66,7 +66,7 @@ func TestAverager(t *testing.T) { tests := []struct { name string ignoreOnError bool - sources []ExchangeRateSource + sources []Source expectedPrice float64 expectError bool errorMessage string @@ -81,28 +81,28 @@ func TestAverager(t *testing.T) { { name: "All sources fail", ignoreOnError: true, - sources: []ExchangeRateSource{errorSource, errorSource, errorSource}, + 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: []ExchangeRateSource{s1, s2, s3}, + sources: []Source{s1, s2, s3}, expectedPrice: (p1 + p2 + p3) / 3, expectError: false, }, { name: "One error source without ignoreOnError", ignoreOnError: false, - sources: []ExchangeRateSource{s1, s2, s3, errorSource}, + 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: []ExchangeRateSource{s1, s2, s3, errorSource}, + sources: []Source{s1, s2, s3, errorSource}, expectedPrice: (p1 + p2 + p3) / 3, expectError: false, }, diff --git a/exchangerates/kraken.go b/exchangerates/kraken.go index 9dd72ce..e96a2d1 100644 --- a/exchangerates/kraken.go +++ b/exchangerates/kraken.go @@ -79,8 +79,8 @@ type kraken struct { err error } -// NewKraken returns an ExchangeRateSource that gets data from Kraken. -func NewKraken(pairMap map[string]string, refresh time.Duration) ExchangeRateSource { +// 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, @@ -89,7 +89,7 @@ func NewKraken(pairMap map[string]string, refresh time.Duration) ExchangeRateSou } } -// Start implements ExchangeRateSource. +// Start implements Source. func (k *kraken) Start(ctx context.Context) { ticker := time.NewTicker(k.refresh) defer ticker.Stop() @@ -118,7 +118,7 @@ func (k *kraken) Start(ctx context.Context) { } } -// Last implements ExchangeRateSource. +// Last implements Source. func (k *kraken) Last(currency string) (float64, error) { k.mu.Lock() defer k.mu.Unlock()