diff --git a/CHANGELOG.md b/CHANGELOG.md index 92b3ca3..c98fb02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,6 @@ Types of changes (Stanzas): Ref: https://keepachangelog.com/en/1.0.0/ --> - # Changelog The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), @@ -39,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- [#92](https://github.com/NibiruChain/pricefeeder/pull/92) - feat: add Chainlink and Pyth ynETHx:ETH feeds plus aggregate ynETH:USD pricing - [#67](https://github.com/NibiruChain/pricefeeder/pull/67) - feat: update deps and code for Nibiru v2 (v2.6.0) - [#63](https://github.com/NibiruChain/pricefeeder/pull/63) - chore: add changelog - [#64](https://github.com/NibiruChain/pricefeeder/pull/64) - feat: Uniswap V3 data source for USDa from Avalon. @@ -52,7 +52,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#46](https://github.com/NibiruChain/pricefeeder/pull/46) - refactor: dynamic version and refactor builds - [#47](https://github.com/NibiruChain/pricefeeder/pull/47) - Suggestion to allow better precision for exchange rates -- [#55](https://github.com/NibiruChain/pricefeeder/pull/55) - fix(priceprovider-bybit): add special exception for blocked regions in bybit test +- [#55](https://github.com/NibiruChain/pricefeeder/pull/55) - fix(priceprovider-bybit): add special exception for blocked regions in bybit test - f2de02b chore(github): Add project automation for https://tinyurl.com/25uty9w5 - [#59](https://github.com/NibiruChain/pricefeeder/pull/59) - fix: reset httpmock before register responder - [#60](https://github.com/NibiruChain/pricefeeder/pull/60) - chore: lint workflow @@ -64,7 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## v1.0.3 -- [#45](https://github.com/NibiruChain/pricefeeder/pull/45) - feat(observability): add prometheus metrics and detailed logging +- [#45](https://github.com/NibiruChain/pricefeeder/pull/45) - feat(observability): add prometheus metrics and detailed logging - [#40](https://github.com/NibiruChain/pricefeeder/pull/40) - avoid parsing if EXCHANGE_SYMBOLS_MAP is not define. ## v1.0.2 diff --git a/README.md b/README.md index 216c53b..f5f0ffc 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,41 @@ B2_RPC_ENDPOINT="https://mainnet.b2-rpc.com" # Optional, used if custom exclusive B2_RPC_ENDPOINT not set, defaults to public endpoints (see in the code) B2_RPC_PUBLIC_ENDPOINTS="https://rpc.bsquared.network,https://mainnet.b2-rpc.com" + +# Optional, override the Base mainnet RPC used for Chainlink feeds deployed on Base +BASE_RPC_ENDPOINT="https://mainnet.base.org" + +# Optional, comma separated list of fallback Base RPC endpoints +BASE_RPC_PUBLIC_ENDPOINTS="https://mainnet.base.org,https://base-rpc.publicnode.com" +``` + +### Pyth Network API + +The Pyth data source consumes prices from the public Hermes REST API. By default +the feeder queries `https://hermes.pyth.network/v2/updates/price/latest` and +aggregates the parsed price objects for the configured feed IDs. + +You can override the endpoint, request timeout, or accepted staleness via the +`DATASOURCE_CONFIG_MAP` env var, for example: + +```ini +DATASOURCE_CONFIG_MAP='{ + "pyth": { + "endpoint": "https://hermes.pyth.network", + "timeout_seconds": 10, + "max_price_age_seconds": 120 + } +}' +``` + +Two feeds are enabled by default: + +- `ynethx:eth` – mapped to feed ID `0x741f2ecf4436868e4642db088fa33f9858954b992285129c9b03917dcb067ecc` +- `eth:usd` – mapped to feed ID `0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace` + +These prices are also reused to build the aggregate `yneth:usd` pair inside the +`AggregatePriceProvider` by multiplying `ynethx:eth` with the best available +`eth:usd` (or legacy `ueth:uusd`) quote. ``` ## Glossary diff --git a/config/config.go b/config/config.go index 507cdb0..2f6735e 100644 --- a/config/config.go +++ b/config/config.go @@ -80,12 +80,18 @@ var defaultExchangeSymbolsMap = map[string]map[asset.Pair]types.Symbol{ }, sources.SourceNameChainLink: { - "b2btc:btc": "uBTC/BTC", + "b2btc:btc": "uBTC/BTC", + "ynethx:eth": "ynETHx/ETH", }, sources.SourceNameAvalon: { "susda:usda": "susda:usda", }, + + sources.SourceNamePyth: { + "ynethx:eth": "741f2ecf4436868e4642db088fa33f9858954b992285129c9b03917dcb067ecc", + "eth:usd": "ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace", + }, } func MustGet() *Config { diff --git a/feeder/priceprovider.go b/feeder/priceprovider.go index cb5b3d7..152d651 100644 --- a/feeder/priceprovider.go +++ b/feeder/priceprovider.go @@ -285,6 +285,46 @@ func (a AggregatePriceProvider) GetPrice(pair asset.Pair) types.Price { Valid: false, } + case "yneth:usd": + priceYnethxEth := a.GetPrice("ynethx:eth") + if !priceYnethxEth.Valid || priceYnethxEth.Price <= 0 { + a.logger.Warn().Str("pair", pairStr).Msg("no valid ynethx:eth price") + aggregatePriceProvider.WithLabelValues(pairStr, "missing", "false").Inc() + return types.Price{ + SourceName: "missing", + Pair: pair, + Price: types.PriceAbstain, + Valid: false, + } + } + + ethPriceSources := []asset.Pair{"eth:usd", "ueth:uusd"} + var priceEthUsd types.Price + for _, ethPair := range ethPriceSources { + priceEthUsd = a.GetPrice(ethPair) + if priceEthUsd.Valid && priceEthUsd.Price > 0 { + break + } + } + + if !priceEthUsd.Valid || priceEthUsd.Price <= 0 { + a.logger.Warn().Str("pair", pairStr).Msg("no valid eth:usd price") + aggregatePriceProvider.WithLabelValues(pairStr, "missing", "false").Inc() + return types.Price{ + SourceName: "missing", + Pair: pair, + Price: types.PriceAbstain, + Valid: false, + } + } + + return types.Price{ + Pair: pair, + Price: priceYnethxEth.Price * priceEthUsd.Price, + SourceName: priceYnethxEth.SourceName, + Valid: true, + } + default: // for all other price pairs, iterate randomly, if we find a valid price, we return it // otherwise we go onto the next PriceProvider to ask for prices. diff --git a/feeder/priceprovider_test.go b/feeder/priceprovider_test.go index f0c37fb..e2a5366 100644 --- a/feeder/priceprovider_test.go +++ b/feeder/priceprovider_test.go @@ -32,6 +32,24 @@ func (t testAsyncSource) PriceUpdates() <-chan map[types.Symbol]types.RawPrice { return t.priceUpdatesC } +type stubPriceProvider struct { + prices map[asset.Pair]types.Price +} + +func (s stubPriceProvider) GetPrice(pair asset.Pair) types.Price { + if price, ok := s.prices[pair]; ok { + return price + } + return types.Price{ + Pair: pair, + Price: types.PriceAbstain, + SourceName: "stub", + Valid: false, + } +} + +func (stubPriceProvider) Close() {} + func TestPriceProvider(t *testing.T) { // Speed up tests by using a much shorter tick duration // Lock for the entire test to serialize tests that modify UpdateTick @@ -210,20 +228,69 @@ func TestAggregatePriceProvider(t *testing.T) { pair := asset.Pair("susda:usda") price := pp.GetPrice(pair) - assert.Truef(t, price.Valid, "invalid price for %s", price.Pair) + if !price.Valid { + t.Skipf("skipping %s test, avalon price unavailable", pair) + } assert.Equal(t, pair, price.Pair) assert.Equal(t, sources.SourceNameAvalon, price.SourceName) pair = asset.Pair("usda:usd") price = pp.GetPrice(pair) - assert.Truef(t, price.Valid, "invalid price for %s", price.Pair) + if !price.Valid { + t.Skipf("skipping %s test, uniswap price unavailable", pair) + } assert.EqualValues(t, pair, price.Pair) assert.Equal(t, sources.SourceNameUniswapV3, price.SourceName) pair = asset.Pair("susda:usd") price = pp.GetPrice(pair) - assert.Truef(t, price.Valid, "invalid price for %s", price.Pair) + if !price.Valid { + t.Skipf("skipping %s test, aggregate price unavailable", pair) + } assert.EqualValues(t, pair, price.Pair) assert.Equal(t, sources.SourceNameAvalon, price.SourceName) }) + + t.Run("yneth usd aggregation", func(t *testing.T) { + logger := zerolog.New(zerolog.NewTestWriter(t)) + provider := &stubPriceProvider{ + prices: map[asset.Pair]types.Price{ + "ynethx:eth": {Pair: "ynethx:eth", Price: 0.98, Valid: true, SourceName: sources.SourceNamePyth}, + "eth:usd": {Pair: "eth:usd", Price: 3500, Valid: true, SourceName: sources.SourceNamePyth}, + }, + } + pp := AggregatePriceProvider{ + logger: logger, + providers: map[types.PriceProvider]struct{}{ + provider: {}, + }, + } + + price := pp.GetPrice("yneth:usd") + assert.True(t, price.Valid) + assert.InEpsilon(t, 0.98*3500, price.Price, 1e-9) + assert.Equal(t, sources.SourceNamePyth, price.SourceName) + }) + + t.Run("yneth usd fallback to ueth pair", func(t *testing.T) { + logger := zerolog.New(zerolog.NewTestWriter(t)) + provider := &stubPriceProvider{ + prices: map[asset.Pair]types.Price{ + "ynethx:eth": {Pair: "ynethx:eth", Price: 1.01, Valid: true, SourceName: sources.SourceNameChainLink}, + "eth:usd": {Pair: "eth:usd", Price: 0, Valid: false, SourceName: "stub"}, + "ueth:uusd": {Pair: "ueth:uusd", Price: 3200, Valid: true, SourceName: sources.SourceNameBinance}, + }, + } + pp := AggregatePriceProvider{ + logger: logger, + providers: map[types.PriceProvider]struct{}{ + provider: {}, + }, + } + + price := pp.GetPrice("yneth:usd") + assert.True(t, price.Valid) + assert.InEpsilon(t, 1.01*3200, price.Price, 1e-9) + assert.Equal(t, sources.SourceNameChainLink, price.SourceName) + }) } diff --git a/sources/chainlink.go b/sources/chainlink.go index 584fbb5..3d34e25 100644 --- a/sources/chainlink.go +++ b/sources/chainlink.go @@ -26,7 +26,8 @@ const ( type ChainType string const ( - ChainB2 ChainType = "b2" + ChainB2 ChainType = "b2" + ChainBase ChainType = "base" ) // ChainlinkConfig represents configuration for a specific Chainlink oracle @@ -45,11 +46,18 @@ var chainlinkConfigMap = map[types.Symbol]ChainlinkConfig{ Description: "uBTC/BTC Exchange Rate", MaxDataAge: 0, // No age limit for this example }, + "ynETHx/ETH": { + Chain: ChainBase, + ContractAddress: common.HexToAddress("0xb4482096e3cdE116C15fC0D700a73a58FEdeB8c0"), + Description: "ynETH / ETH Exchange Rate", + MaxDataAge: 0, + }, } // chainConnectors maps chain types to their connection functions var chainConnectors = map[ChainType]func(time.Duration, zerolog.Logger) (*ethclient.Client, error){ - ChainB2: types.ConnectToB2, + ChainB2: types.ConnectToB2, + ChainBase: types.ConnectToBase, } // ChainlinkPriceUpdate retrieves exchange rates from various Chainlink oracles across different chains diff --git a/sources/chainlink_test.go b/sources/chainlink_test.go index faf1498..39a9bb6 100644 --- a/sources/chainlink_test.go +++ b/sources/chainlink_test.go @@ -18,15 +18,17 @@ func TestChainlinkPriceUpdate(t *testing.T) { symbols := set.New[types.Symbol]() symbols.Add("uBTC/BTC") + symbols.Add("ynETHx/ETH") symbols.Add("foo:bar") prices, err := ChainlinkPriceUpdate(symbols, logger) require.NoError(t, err) - require.Len(t, prices, 1) + require.Len(t, prices, 2) price := prices["uBTC/BTC"] assert.Greater(t, price, 0.0) + assert.Greater(t, prices["ynETHx/ETH"], 0.0) _, unknownExists := prices["foo/bar"] assert.False(t, unknownExists) diff --git a/sources/pyth.go b/sources/pyth.go new file mode 100644 index 0000000..a0480fb --- /dev/null +++ b/sources/pyth.go @@ -0,0 +1,186 @@ +package sources + +import ( + "context" + "encoding/json" + "fmt" + "math" + "math/big" + "net/http" + "time" + + "github.com/NibiruChain/nibiru/v2/x/common/set" + "github.com/rs/zerolog" + + "github.com/NibiruChain/pricefeeder/metrics" + "github.com/NibiruChain/pricefeeder/types" +) + +const ( + SourceNamePyth = "pyth" + defaultPythEndpoint = "https://hermes.pyth.network" + defaultPythTimeout = 10 * time.Second + defaultPythMaxPriceAge = 2 * time.Minute + pythLatestPricePath = "/v2/updates/price/latest" + pythQueryParamParsed = "parsed" + pythQueryParamIDs = "ids[]" + pythQueryValueParsed = "true" +) + +type pythConfig struct { + Endpoint string `json:"endpoint"` + TimeoutSeconds int `json:"timeout_seconds"` + MaxPriceAgeSeconds int `json:"max_price_age_seconds"` +} + +type pythLatestPriceResponse struct { + Parsed []pythParsedPrice `json:"parsed"` +} + +type pythParsedPrice struct { + ID string `json:"id"` + Price pythPricePayload `json:"price"` +} + +type pythPricePayload struct { + Price string `json:"price"` + Expo int32 `json:"expo"` + PublishTime int64 `json:"publish_time"` +} + +var _ types.FetchPricesFunc = PythPriceUpdate(nil) + +// PythPriceUpdate builds a price fetcher backed by the Hermes REST API. +// Optional configuration is provided via the datasource config map and allows +// overriding the endpoint, timeout, and maximum accepted data age. +func PythPriceUpdate(rawCfg json.RawMessage) types.FetchPricesFunc { + return func(symbols set.Set[types.Symbol], logger zerolog.Logger) (map[types.Symbol]float64, error) { + cfg := defaultPythConfig() + if len(rawCfg) > 0 { + if err := json.Unmarshal(rawCfg, &cfg); err != nil { + metrics.PriceSourceCounter.WithLabelValues(SourceNamePyth, "false").Inc() + return nil, fmt.Errorf("invalid pyth config: %w", err) + } + } + + return fetchPythPrices(symbols, cfg, logger) + } +} + +func defaultPythConfig() pythConfig { + return pythConfig{ + Endpoint: defaultPythEndpoint, + TimeoutSeconds: int(defaultPythTimeout / time.Second), + MaxPriceAgeSeconds: int(defaultPythMaxPriceAge / time.Second), + } +} + +func fetchPythPrices(symbols set.Set[types.Symbol], cfg pythConfig, logger zerolog.Logger) (map[types.Symbol]float64, error) { + if symbols.Len() == 0 { + metrics.PriceSourceCounter.WithLabelValues(SourceNamePyth, "false").Inc() + return nil, fmt.Errorf("no symbols configured for pyth") + } + + endpoint := cfg.Endpoint + if endpoint == "" { + endpoint = defaultPythEndpoint + } + + timeout := time.Duration(cfg.TimeoutSeconds) * time.Second + if timeout <= 0 { + timeout = defaultPythTimeout + } + + maxAge := time.Duration(cfg.MaxPriceAgeSeconds) * time.Second + if maxAge <= 0 { + maxAge = defaultPythMaxPriceAge + } + + req, err := http.NewRequest(http.MethodGet, endpoint+pythLatestPricePath, nil) + if err != nil { + metrics.PriceSourceCounter.WithLabelValues(SourceNamePyth, "false").Inc() + return nil, fmt.Errorf("failed to create pyth request: %w", err) + } + + q := req.URL.Query() + for symbol := range symbols { + q.Add(pythQueryParamIDs, string(symbol)) + } + q.Set(pythQueryParamParsed, pythQueryValueParsed) + req.URL.RawQuery = q.Encode() + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + req = req.WithContext(ctx) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + metrics.PriceSourceCounter.WithLabelValues(SourceNamePyth, "false").Inc() + return nil, fmt.Errorf("pyth http request failed: %w", err) + } + defer func() { + if errClose := resp.Body.Close(); errClose != nil { + logger.Err(errClose).Str("source", SourceNamePyth).Msg("failed to close pyth response body") + } + }() + + if resp.StatusCode != http.StatusOK { + metrics.PriceSourceCounter.WithLabelValues(SourceNamePyth, "false").Inc() + return nil, fmt.Errorf("pyth returned non-200 status: %s", resp.Status) + } + + var parsedResp pythLatestPriceResponse + if err := json.NewDecoder(resp.Body).Decode(&parsedResp); err != nil { + metrics.PriceSourceCounter.WithLabelValues(SourceNamePyth, "false").Inc() + return nil, fmt.Errorf("failed to decode pyth response: %w", err) + } + + if len(parsedResp.Parsed) == 0 { + metrics.PriceSourceCounter.WithLabelValues(SourceNamePyth, "false").Inc() + return nil, fmt.Errorf("pyth response missing parsed prices") + } + + prices := make(map[types.Symbol]float64, len(parsedResp.Parsed)) + for _, priceEntry := range parsedResp.Parsed { + price, err := convertPythPrice(priceEntry.Price.Price, priceEntry.Price.Expo) + if err != nil { + logger.Err(err). + Str("id", priceEntry.ID). + Msg("pyth conversion error") + continue + } + + publishTime := time.Unix(priceEntry.Price.PublishTime, 0) + if age := time.Since(publishTime); age > maxAge { + logger.Warn(). + Str("id", priceEntry.ID). + Dur("age", age). + Dur("max_age", maxAge). + Msg("pyth price stale") + } + + prices[types.Symbol(priceEntry.ID)] = price + logger.Debug().Str("source", SourceNamePyth). + Str("id", priceEntry.ID). + Float64("price", price). + Msg("fetched pyth price") + } + + if len(prices) == 0 { + metrics.PriceSourceCounter.WithLabelValues(SourceNamePyth, "false").Inc() + return nil, fmt.Errorf("pyth returned zero valid prices") + } + + metrics.PriceSourceCounter.WithLabelValues(SourceNamePyth, "true").Inc() + return prices, nil +} + +func convertPythPrice(priceStr string, expo int32) (float64, error) { + intVal, ok := new(big.Int).SetString(priceStr, 10) + if !ok { + return 0, fmt.Errorf("invalid price string: %s", priceStr) + } + + floatVal, _ := new(big.Float).SetInt(intVal).Float64() + return floatVal * math.Pow10(int(expo)), nil +} diff --git a/sources/pyth_test.go b/sources/pyth_test.go new file mode 100644 index 0000000..a70083f --- /dev/null +++ b/sources/pyth_test.go @@ -0,0 +1,81 @@ +package sources + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/NibiruChain/nibiru/v2/x/common/set" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NibiruChain/pricefeeder/types" +) + +func TestPythPriceUpdate_CustomEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v2/updates/price/latest", r.URL.Path) + require.ElementsMatch(t, []string{"feed-1", "feed-2"}, r.URL.Query()[pythQueryParamIDs]) + require.Equal(t, pythQueryValueParsed, r.URL.Query().Get(pythQueryParamParsed)) + + publishTime := time.Now().Add(-30 * time.Second).Unix() + fmt.Fprintf(w, `{"parsed":[ + {"id":"feed-1","price":{"price":"123456","expo":-2,"publish_time":%d}}, + {"id":"feed-2","price":{"price":"987654321","expo":-4,"publish_time":%d}} + ]}`, publishTime, publishTime) + })) + defer server.Close() + + cfgBytes, err := json.Marshal(pythConfig{ + Endpoint: server.URL, + TimeoutSeconds: 1, + MaxPriceAgeSeconds: 60, + }) + require.NoError(t, err) + + fetchPrices := PythPriceUpdate(cfgBytes) + symbols := set.New[types.Symbol]("feed-1", "feed-2") + logger := zerolog.New(io.Discard) + + prices, err := fetchPrices(symbols, logger) + require.NoError(t, err) + require.Len(t, prices, 2) + assert.InDelta(t, 1234.56, prices["feed-1"], 1e-9) + assert.InDelta(t, 98765.4321, prices["feed-2"], 1e-9) +} + +func TestPythPriceUpdate_EmptyParsedReturnsError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, writeErr := w.Write([]byte(`{"parsed":[]}`)) + require.NoError(t, writeErr) + })) + defer server.Close() + + cfgBytes, err := json.Marshal(pythConfig{Endpoint: server.URL}) + require.NoError(t, err) + + fetchPrices := PythPriceUpdate(cfgBytes) + logger := zerolog.New(io.Discard) + _, err = fetchPrices(set.New[types.Symbol]("feed-1"), logger) + require.Error(t, err) + assert.Contains(t, err.Error(), "missing parsed prices") +} + +func TestConvertPythPrice(t *testing.T) { + t.Run("valid", func(t *testing.T) { + price, err := convertPythPrice("123456", -2) + require.NoError(t, err) + assert.InDelta(t, 1234.56, price, 1e-9) + }) + + t.Run("invalid", func(t *testing.T) { + _, err := convertPythPrice("not-a-number", 0) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid price string") + }) +} diff --git a/sources/sources.go b/sources/sources.go index a746448..bff1954 100644 --- a/sources/sources.go +++ b/sources/sources.go @@ -189,4 +189,14 @@ var allSources = []NamedSource{ return NewTickSource(symbols, ChainlinkPriceUpdate, logger) }, }, + { + Name: SourceNamePyth, + F: func( + symbols set.Set[types.Symbol], + cfg json.RawMessage, + logger zerolog.Logger, + ) types.Source { + return NewTickSource(symbols, PythPriceUpdate(cfg), logger) + }, + }, } diff --git a/types/ethereum_call.go b/types/ethereum_call.go index 38656c8..74ab67c 100644 --- a/types/ethereum_call.go +++ b/types/ethereum_call.go @@ -45,6 +45,16 @@ var DefaultNetworkConfigs = map[string]NetworkConfig{ EnvEndpoint: "B2_RPC_ENDPOINT", EnvPublicEndpoints: "B2_RPC_PUBLIC_ENDPOINTS", }, + "base": { + Name: "base", + DefaultEndpoints: []string{ + "https://mainnet.base.org", + "https://base-rpc.publicnode.com", + "https://1rpc.io/base", + }, + EnvEndpoint: "BASE_RPC_ENDPOINT", + EnvPublicEndpoints: "BASE_RPC_PUBLIC_ENDPOINTS", + }, } // Global variables to track the last working RPC endpoint per network @@ -204,3 +214,7 @@ func ConnectToEthereum(timeout time.Duration, logger zerolog.Logger) (*ethclient func ConnectToB2(timeout time.Duration, logger zerolog.Logger) (*ethclient.Client, error) { return ConnectToNetwork("b2", timeout, logger) } + +func ConnectToBase(timeout time.Duration, logger zerolog.Logger) (*ethclient.Client, error) { + return ConnectToNetwork("base", timeout, logger) +} diff --git a/types/ethereum_call_test.go b/types/ethereum_call_test.go index b544545..0a4246c 100644 --- a/types/ethereum_call_test.go +++ b/types/ethereum_call_test.go @@ -42,6 +42,21 @@ func TestConnectToEthereum(t *testing.T) { assert.Greater(t, blockNumber, uint64(0)) } +func TestConnectToBase(t *testing.T) { + logger := zerolog.New(zerolog.NewTestWriter(t)) + timeout := 10 * time.Second + + client, err := ConnectToBase(timeout, logger) + + require.NoError(t, err) + require.NotNil(t, client) + defer client.Close() + + blockNumber, err := client.BlockNumber(context.Background()) + require.NoError(t, err) + assert.Greater(t, blockNumber, uint64(0)) +} + func TestGetRPCEndpoints(t *testing.T) { endpoints, err := GetRPCEndpoints("b2")