Skip to content

Commit f2c3d35

Browse files
committed
feat: integrate Chainlink and Pyth price feeds for ynETHx:ETH and aggregate ynETH:USD pricing
1 parent 7797e69 commit f2c3d35

File tree

12 files changed

+471
-7
lines changed

12 files changed

+471
-7
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3939

4040
## [Unreleased]
4141

42+
- feat: add Chainlink and Pyth ynETHx:ETH feeds plus aggregate ynETH:USD pricing
4243
- [#67](https://github.com/NibiruChain/pricefeeder/pull/67) - feat: update deps and code for Nibiru v2 (v2.6.0)
4344
- [#63](https://github.com/NibiruChain/pricefeeder/pull/63) - chore: add changelog
4445
- [#64](https://github.com/NibiruChain/pricefeeder/pull/64) - feat: Uniswap V3 data source for USDa from Avalon.

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,41 @@ B2_RPC_ENDPOINT="https://mainnet.b2-rpc.com"
204204
205205
# Optional, used if custom exclusive B2_RPC_ENDPOINT not set, defaults to public endpoints (see in the code)
206206
B2_RPC_PUBLIC_ENDPOINTS="https://rpc.bsquared.network,https://mainnet.b2-rpc.com"
207+
208+
# Optional, override the Base mainnet RPC used for Chainlink feeds deployed on Base
209+
BASE_RPC_ENDPOINT="https://mainnet.base.org"
210+
211+
# Optional, comma separated list of fallback Base RPC endpoints
212+
BASE_RPC_PUBLIC_ENDPOINTS="https://mainnet.base.org,https://base-rpc.publicnode.com"
213+
```
214+
215+
### Pyth Network API
216+
217+
The Pyth data source consumes prices from the public Hermes REST API. By default
218+
the feeder queries `https://hermes.pyth.network/v2/updates/price/latest` and
219+
aggregates the parsed price objects for the configured feed IDs.
220+
221+
You can override the endpoint, request timeout, or accepted staleness via the
222+
`DATASOURCE_CONFIG_MAP` env var, for example:
223+
224+
```ini
225+
DATASOURCE_CONFIG_MAP='{
226+
"pyth": {
227+
"endpoint": "https://hermes.pyth.network",
228+
"timeout_seconds": 10,
229+
"max_price_age_seconds": 120
230+
}
231+
}'
232+
```
233+
234+
Two feeds are enabled by default:
235+
236+
- `ynethx:eth` – mapped to feed ID `0x741f2ecf4436868e4642db088fa33f9858954b992285129c9b03917dcb067ecc`
237+
- `eth:usd` – mapped to feed ID `0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace`
238+
239+
These prices are also reused to build the aggregate `yneth:usd` pair inside the
240+
`AggregatePriceProvider` by multiplying `ynethx:eth` with the best available
241+
`eth:usd` (or legacy `ueth:uusd`) quote.
207242
```
208243
209244
## Glossary

config/config.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,18 @@ var defaultExchangeSymbolsMap = map[string]map[asset.Pair]types.Symbol{
8080
},
8181

8282
sources.SourceNameChainLink: {
83-
"b2btc:btc": "uBTC/BTC",
83+
"b2btc:btc": "uBTC/BTC",
84+
"ynethx:eth": "ynETHx/ETH",
8485
},
8586

8687
sources.SourceNameAvalon: {
8788
"susda:usda": "susda:usda",
8889
},
90+
91+
sources.SourceNamePyth: {
92+
"ynethx:eth": "741f2ecf4436868e4642db088fa33f9858954b992285129c9b03917dcb067ecc",
93+
"eth:usd": "ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace",
94+
},
8995
}
9096

9197
func MustGet() *Config {

feeder/priceprovider.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,46 @@ func (a AggregatePriceProvider) GetPrice(pair asset.Pair) types.Price {
285285
Valid: false,
286286
}
287287

288+
case "yneth:usd":
289+
priceYnethxEth := a.GetPrice("ynethx:eth")
290+
if !priceYnethxEth.Valid || priceYnethxEth.Price <= 0 {
291+
a.logger.Warn().Str("pair", pairStr).Msg("no valid ynethx:eth price")
292+
aggregatePriceProvider.WithLabelValues(pairStr, "missing", "false").Inc()
293+
return types.Price{
294+
SourceName: "missing",
295+
Pair: pair,
296+
Price: types.PriceAbstain,
297+
Valid: false,
298+
}
299+
}
300+
301+
ethPriceSources := []asset.Pair{"eth:usd", "ueth:uusd"}
302+
var priceEthUsd types.Price
303+
for _, ethPair := range ethPriceSources {
304+
priceEthUsd = a.GetPrice(ethPair)
305+
if priceEthUsd.Valid && priceEthUsd.Price > 0 {
306+
break
307+
}
308+
}
309+
310+
if !priceEthUsd.Valid || priceEthUsd.Price <= 0 {
311+
a.logger.Warn().Str("pair", pairStr).Msg("no valid eth:usd price")
312+
aggregatePriceProvider.WithLabelValues(pairStr, "missing", "false").Inc()
313+
return types.Price{
314+
SourceName: "missing",
315+
Pair: pair,
316+
Price: types.PriceAbstain,
317+
Valid: false,
318+
}
319+
}
320+
321+
return types.Price{
322+
Pair: pair,
323+
Price: priceYnethxEth.Price * priceEthUsd.Price,
324+
SourceName: priceYnethxEth.SourceName,
325+
Valid: true,
326+
}
327+
288328
default:
289329
// for all other price pairs, iterate randomly, if we find a valid price, we return it
290330
// otherwise we go onto the next PriceProvider to ask for prices.

feeder/priceprovider_test.go

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,24 @@ func (t testAsyncSource) PriceUpdates() <-chan map[types.Symbol]types.RawPrice {
3232
return t.priceUpdatesC
3333
}
3434

35+
type stubPriceProvider struct {
36+
prices map[asset.Pair]types.Price
37+
}
38+
39+
func (s stubPriceProvider) GetPrice(pair asset.Pair) types.Price {
40+
if price, ok := s.prices[pair]; ok {
41+
return price
42+
}
43+
return types.Price{
44+
Pair: pair,
45+
Price: types.PriceAbstain,
46+
SourceName: "stub",
47+
Valid: false,
48+
}
49+
}
50+
51+
func (stubPriceProvider) Close() {}
52+
3553
func TestPriceProvider(t *testing.T) {
3654
// Speed up tests by using a much shorter tick duration
3755
// Lock for the entire test to serialize tests that modify UpdateTick
@@ -210,20 +228,69 @@ func TestAggregatePriceProvider(t *testing.T) {
210228

211229
pair := asset.Pair("susda:usda")
212230
price := pp.GetPrice(pair)
213-
assert.Truef(t, price.Valid, "invalid price for %s", price.Pair)
231+
if !price.Valid {
232+
t.Skipf("skipping %s test, avalon price unavailable", pair)
233+
}
214234
assert.Equal(t, pair, price.Pair)
215235
assert.Equal(t, sources.SourceNameAvalon, price.SourceName)
216236

217237
pair = asset.Pair("usda:usd")
218238
price = pp.GetPrice(pair)
219-
assert.Truef(t, price.Valid, "invalid price for %s", price.Pair)
239+
if !price.Valid {
240+
t.Skipf("skipping %s test, uniswap price unavailable", pair)
241+
}
220242
assert.EqualValues(t, pair, price.Pair)
221243
assert.Equal(t, sources.SourceNameUniswapV3, price.SourceName)
222244

223245
pair = asset.Pair("susda:usd")
224246
price = pp.GetPrice(pair)
225-
assert.Truef(t, price.Valid, "invalid price for %s", price.Pair)
247+
if !price.Valid {
248+
t.Skipf("skipping %s test, aggregate price unavailable", pair)
249+
}
226250
assert.EqualValues(t, pair, price.Pair)
227251
assert.Equal(t, sources.SourceNameAvalon, price.SourceName)
228252
})
253+
254+
t.Run("yneth usd aggregation", func(t *testing.T) {
255+
logger := zerolog.New(zerolog.NewTestWriter(t))
256+
provider := &stubPriceProvider{
257+
prices: map[asset.Pair]types.Price{
258+
"ynethx:eth": {Pair: "ynethx:eth", Price: 0.98, Valid: true, SourceName: sources.SourceNamePyth},
259+
"eth:usd": {Pair: "eth:usd", Price: 3500, Valid: true, SourceName: sources.SourceNamePyth},
260+
},
261+
}
262+
pp := AggregatePriceProvider{
263+
logger: logger,
264+
providers: map[types.PriceProvider]struct{}{
265+
provider: {},
266+
},
267+
}
268+
269+
price := pp.GetPrice("yneth:usd")
270+
assert.True(t, price.Valid)
271+
assert.InEpsilon(t, 0.98*3500, price.Price, 1e-9)
272+
assert.Equal(t, sources.SourceNamePyth, price.SourceName)
273+
})
274+
275+
t.Run("yneth usd fallback to ueth pair", func(t *testing.T) {
276+
logger := zerolog.New(zerolog.NewTestWriter(t))
277+
provider := &stubPriceProvider{
278+
prices: map[asset.Pair]types.Price{
279+
"ynethx:eth": {Pair: "ynethx:eth", Price: 1.01, Valid: true, SourceName: sources.SourceNameChainLink},
280+
"eth:usd": {Pair: "eth:usd", Price: 0, Valid: false, SourceName: "stub"},
281+
"ueth:uusd": {Pair: "ueth:uusd", Price: 3200, Valid: true, SourceName: sources.SourceNameBinance},
282+
},
283+
}
284+
pp := AggregatePriceProvider{
285+
logger: logger,
286+
providers: map[types.PriceProvider]struct{}{
287+
provider: {},
288+
},
289+
}
290+
291+
price := pp.GetPrice("yneth:usd")
292+
assert.True(t, price.Valid)
293+
assert.InEpsilon(t, 1.01*3200, price.Price, 1e-9)
294+
assert.Equal(t, sources.SourceNameChainLink, price.SourceName)
295+
})
229296
}

sources/chainlink.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ const (
2626
type ChainType string
2727

2828
const (
29-
ChainB2 ChainType = "b2"
29+
ChainB2 ChainType = "b2"
30+
ChainBase ChainType = "base"
3031
)
3132

3233
// ChainlinkConfig represents configuration for a specific Chainlink oracle
@@ -45,11 +46,18 @@ var chainlinkConfigMap = map[types.Symbol]ChainlinkConfig{
4546
Description: "uBTC/BTC Exchange Rate",
4647
MaxDataAge: 0, // No age limit for this example
4748
},
49+
"ynETHx/ETH": {
50+
Chain: ChainBase,
51+
ContractAddress: common.HexToAddress("0xb4482096e3cdE116C15fC0D700a73a58FEdeB8c0"),
52+
Description: "ynETH / ETH Exchange Rate",
53+
MaxDataAge: 0,
54+
},
4855
}
4956

5057
// chainConnectors maps chain types to their connection functions
5158
var chainConnectors = map[ChainType]func(time.Duration, zerolog.Logger) (*ethclient.Client, error){
52-
ChainB2: types.ConnectToB2,
59+
ChainB2: types.ConnectToB2,
60+
ChainBase: types.ConnectToBase,
5361
}
5462

5563
// ChainlinkPriceUpdate retrieves exchange rates from various Chainlink oracles across different chains

sources/chainlink_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,17 @@ func TestChainlinkPriceUpdate(t *testing.T) {
1818

1919
symbols := set.New[types.Symbol]()
2020
symbols.Add("uBTC/BTC")
21+
symbols.Add("ynETHx/ETH")
2122
symbols.Add("foo:bar")
2223

2324
prices, err := ChainlinkPriceUpdate(symbols, logger)
2425

2526
require.NoError(t, err)
26-
require.Len(t, prices, 1)
27+
require.Len(t, prices, 2)
2728

2829
price := prices["uBTC/BTC"]
2930
assert.Greater(t, price, 0.0)
31+
assert.Greater(t, prices["ynETHx/ETH"], 0.0)
3032

3133
_, unknownExists := prices["foo/bar"]
3234
assert.False(t, unknownExists)

0 commit comments

Comments
 (0)