Skip to content

Commit dd5f52a

Browse files
feat: add stNIBI price discovery (#62)
* add USDR * feat: add stNIBI price discovery * remove USDR * fix: eris protocol price provider test * Update feeder/priceprovider/sources/eris_protocol.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * move connection close to a defer function * Update feeder/priceprovider/sources/eris_protocol.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * remove ErisResponse * Update eris_protocol.go * check for negative exchangeRate --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent ab6681f commit dd5f52a

File tree

7 files changed

+236
-1
lines changed

7 files changed

+236
-1
lines changed

config/config.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ var defaultExchangeSymbolsMap = map[string]map[asset.Pair]types.Symbol{
5050
"unibi:uusd": "NIBI_USDT",
5151
"usol:uusd": "SOL_USDT",
5252
},
53+
5354
// https://www.okx.com/api/v5/market/tickers?instType=SPOT
5455
sources.Okex: {
5556
"ubtc:uusd": "BTC-USDT",
@@ -59,6 +60,7 @@ var defaultExchangeSymbolsMap = map[string]map[asset.Pair]types.Symbol{
5960
"uatom:uusd": "ATOM-USDT",
6061
"usol:uusd": "SOL-USD",
6162
},
63+
6264
// https://api.bybit.com/v5/market/tickers?category=spot
6365
sources.Bybit: {
6466
"ubtc:uusd": "BTCUSDT",
@@ -68,6 +70,10 @@ var defaultExchangeSymbolsMap = map[string]map[asset.Pair]types.Symbol{
6870
"unibi:uusd": "NIBIUSDT",
6971
"usol:uusd": "SOLUSDT",
7072
},
73+
74+
sources.ErisProtocol: {
75+
"ustnibi:unibi": "ustnibi:unibi", // this is the only pair supported by the Eris Protocol smart contract
76+
},
7177
}
7278

7379
func MustGet() *Config {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package priceprovider
2+
3+
import (
4+
"encoding/json"
5+
"io"
6+
"testing"
7+
"time"
8+
9+
"github.com/NibiruChain/nibiru/x/common/asset"
10+
"github.com/NibiruChain/nibiru/x/common/denoms"
11+
"github.com/NibiruChain/pricefeeder/feeder/priceprovider/sources"
12+
"github.com/NibiruChain/pricefeeder/types"
13+
"github.com/rs/zerolog"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
func TestAggregatePriceProvider(t *testing.T) {
18+
t.Run("eris protocol success", func(t *testing.T) {
19+
pp := NewAggregatePriceProvider(
20+
map[string]map[asset.Pair]types.Symbol{
21+
sources.ErisProtocol: {
22+
asset.NewPair("ustnibi", denoms.NIBI): "ustnibi:unibi",
23+
},
24+
sources.GateIo: {
25+
asset.NewPair("unibi", denoms.USD): "NIBI_USDT",
26+
},
27+
},
28+
map[string]json.RawMessage{},
29+
zerolog.New(io.Discard),
30+
)
31+
defer pp.Close()
32+
<-time.After(sources.UpdateTick + 2*time.Second)
33+
34+
price := pp.GetPrice("ustnibi:uusd")
35+
require.True(t, price.Valid)
36+
require.Equal(t, asset.NewPair("ustnibi", denoms.USD), price.Pair)
37+
require.Equal(t, sources.ErisProtocol, price.SourceName)
38+
})
39+
}

feeder/priceprovider/aggregateprovider.go

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,49 @@ var aggregatePriceProvider = promauto.NewCounterVec(prometheus.CounterOpts{
5050
// Iteration is exhaustive and random.
5151
// If no correct PriceResponse is found, then an invalid PriceResponse is returned.
5252
func (a AggregatePriceProvider) GetPrice(pair asset.Pair) types.Price {
53-
// iterate randomly, if we find a valid price, we return it
53+
// SPECIAL CASE FOR stNIBI
54+
// fetch unibi:uusd first to calculate the ustnibi:unibi price
55+
if pair.String() == "ustnibi:uusd" {
56+
unibiUusdPrice := -1.0 // default to -1 to indicate we haven't found a valid price yet
57+
for _, p := range a.providers {
58+
price := p.GetPrice("unibi:uusd")
59+
if !price.Valid {
60+
continue
61+
}
62+
63+
unibiUusdPrice = price.Price
64+
break
65+
}
66+
67+
if unibiUusdPrice <= 0 {
68+
// if we can't find a valid unibi:uusd price, return an invalid price
69+
a.logger.Warn().Str("pair", "ustnibi:uusd").Msg("no valid price found for unibi:uusd")
70+
aggregatePriceProvider.WithLabelValues("ustnibi:uusd", "missing", "false").Inc()
71+
return types.Price{
72+
SourceName: "missing",
73+
Pair: pair,
74+
Price: 0,
75+
Valid: false,
76+
}
77+
}
78+
79+
// now we can calculate the ustnibi:unibi price
80+
for _, p := range a.providers {
81+
price := p.GetPrice("ustnibi:unibi")
82+
if !price.Valid {
83+
continue
84+
}
85+
86+
return types.Price{
87+
Pair: pair,
88+
Price: price.Price * unibiUusdPrice, // ustnibi:uusd = ustnibi:unibi * unibi:uusd
89+
SourceName: price.SourceName, // use the source of ustnibi
90+
Valid: true,
91+
}
92+
}
93+
}
94+
95+
// for all other price pairs, iterate randomly, if we find a valid price, we return it
5496
// otherwise we go onto the next PriceProvider to ask for prices.
5597
for _, p := range a.providers {
5698
price := p.GetPrice(pair)

feeder/priceprovider/priceprovider.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ func NewPriceProvider(
5252
source = sources.NewTickSource(mapValues(pairToSymbolMap), sources.CoinmarketcapPriceUpdate(config), logger)
5353
case sources.Bybit:
5454
source = sources.NewTickSource(mapValues(pairToSymbolMap), sources.BybitPriceUpdate, logger)
55+
case sources.ErisProtocol:
56+
source = sources.NewTickSource(mapValues(pairToSymbolMap), sources.ErisProtocolPriceUpdate, logger)
5557
default:
5658
panic("unknown price provider: " + sourceName)
5759
}

feeder/priceprovider/priceprovider_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,22 @@ func TestPriceProvider(t *testing.T) {
4343
require.Equal(t, sources.Bitfinex, price.SourceName)
4444
})
4545

46+
t.Run("eris protocol success", func(t *testing.T) {
47+
pp := NewPriceProvider(
48+
sources.ErisProtocol,
49+
map[asset.Pair]types.Symbol{asset.NewPair("ustnibi", denoms.NIBI): "ustnibi:unibi"},
50+
json.RawMessage{},
51+
zerolog.New(io.Discard),
52+
)
53+
defer pp.Close()
54+
<-time.After(sources.UpdateTick + 2*time.Second)
55+
56+
price := pp.GetPrice(asset.NewPair("ustnibi", denoms.NIBI))
57+
require.True(t, price.Valid)
58+
require.Equal(t, asset.NewPair("ustnibi", denoms.NIBI), price.Pair)
59+
require.Equal(t, sources.ErisProtocol, price.SourceName)
60+
})
61+
4662
t.Run("panics on unknown price source", func(t *testing.T) {
4763
require.Panics(t, func() {
4864
NewPriceProvider(
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package sources
2+
3+
import (
4+
"context"
5+
"crypto/tls"
6+
"encoding/json"
7+
"fmt"
8+
"strconv"
9+
10+
wasmtypes "github.com/CosmWasm/wasmd/x/wasm/types"
11+
"github.com/NibiruChain/nibiru/x/common/set"
12+
"github.com/NibiruChain/pricefeeder/metrics"
13+
"github.com/NibiruChain/pricefeeder/types"
14+
"github.com/rs/zerolog"
15+
"google.golang.org/grpc"
16+
"google.golang.org/grpc/credentials"
17+
)
18+
19+
const (
20+
ErisProtocol = "eris_protocol"
21+
// Configuration constants
22+
grpcEndpoint = "grpc.nibiru.fi:443"
23+
contractAddr = "nibi1udqqx30cw8nwjxtl4l28ym9hhrp933zlq8dqxfjzcdhvl8y24zcqpzmh8m"
24+
stateQuery = `{"state": {}}`
25+
)
26+
27+
var _ types.FetchPricesFunc = ErisProtocolPriceUpdate
28+
29+
// newGRPCConnection creates a new gRPC connection with TLS
30+
func newGRPCConnection() (*grpc.ClientConn, error) {
31+
transportDialOpt := grpc.WithTransportCredentials(
32+
credentials.NewTLS(
33+
&tls.Config{
34+
InsecureSkipVerify: false,
35+
},
36+
),
37+
)
38+
return grpc.Dial(grpcEndpoint, transportDialOpt)
39+
}
40+
41+
// ErisProtocolPriceUpdate retrieves the exchange rate for stNIBI to NIBI (ustnibi:unibi) from the Eris Protocol smart contract.
42+
// Note: This function ignores the input symbols and always returns the exchange rate for "ustnibi:unibi".
43+
func ErisProtocolPriceUpdate(symbols set.Set[types.Symbol], logger zerolog.Logger) (rawPrices map[types.Symbol]float64, err error) {
44+
conn, err := newGRPCConnection()
45+
if err != nil {
46+
logger.Err(err).Msgf("failed to connect to gRPC endpoint %s", grpcEndpoint)
47+
metrics.PriceSourceCounter.WithLabelValues(ErisProtocol, "false").Inc()
48+
return nil, fmt.Errorf("failed to connect to gRPC endpoint %s: %w", grpcEndpoint, err)
49+
}
50+
defer func() {
51+
if closeErr := conn.Close(); closeErr != nil {
52+
logger.Err(closeErr).Msg("failed to close gRPC connection")
53+
}
54+
}()
55+
56+
wasmClient := wasmtypes.NewQueryClient(conn)
57+
query := wasmtypes.QuerySmartContractStateRequest{
58+
Address: contractAddr,
59+
QueryData: []byte(stateQuery),
60+
}
61+
62+
resp, err := wasmClient.SmartContractState(context.Background(), &query)
63+
if err != nil {
64+
logger.Err(err).Msg("failed to query SmartContractState")
65+
metrics.PriceSourceCounter.WithLabelValues(ErisProtocol, "false").Inc()
66+
return nil, fmt.Errorf("failed to query SmartContractState: %w", err)
67+
}
68+
69+
if resp == nil || len(resp.Data) == 0 {
70+
logger.Error().Msg("received nil or empty response from SmartContractState")
71+
metrics.PriceSourceCounter.WithLabelValues(ErisProtocol, "false").Inc()
72+
return nil, fmt.Errorf("nil response from SmartContractState")
73+
}
74+
75+
var responseObj struct {
76+
ExchangeRate string `json:"exchange_rate"`
77+
}
78+
if err := json.Unmarshal(resp.Data, &responseObj); err != nil {
79+
logger.Err(err).Msg("failed to unmarshal SmartContractState response data")
80+
metrics.PriceSourceCounter.WithLabelValues(ErisProtocol, "false").Inc()
81+
return nil, fmt.Errorf("failed to unmarshal SmartContractState response data: %w", err)
82+
}
83+
84+
exchangeRate, err := strconv.ParseFloat(responseObj.ExchangeRate, 64)
85+
if err != nil {
86+
logger.Err(err).Msg("failed to convert exchange_rate to float")
87+
metrics.PriceSourceCounter.WithLabelValues(ErisProtocol, "false").Inc()
88+
return nil, fmt.Errorf("failed to convert exchange_rate to float: %w", err)
89+
}
90+
91+
// Validate the exchange rate
92+
if exchangeRate <= 0 {
93+
errMsg := "received invalid exchange rate: value must be positive"
94+
logger.Error().Float64("exchange_rate", exchangeRate).Msg(errMsg)
95+
metrics.PriceSourceCounter.WithLabelValues(ErisProtocol, "false").Inc()
96+
return nil, fmt.Errorf(errMsg)
97+
}
98+
99+
rawPrices = make(map[types.Symbol]float64)
100+
rawPrices[types.Symbol("ustnibi:unibi")] = exchangeRate
101+
102+
logger.Debug().
103+
Str("symbols", fmt.Sprint(symbols)).
104+
Str("source", ErisProtocol).
105+
Float64("exchange_rate", exchangeRate).
106+
Msg("fetched prices")
107+
108+
metrics.PriceSourceCounter.WithLabelValues(ErisProtocol, "true").Inc()
109+
return rawPrices, nil
110+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package sources
2+
3+
import (
4+
"io"
5+
"testing"
6+
7+
"github.com/NibiruChain/nibiru/x/common/set"
8+
"github.com/NibiruChain/pricefeeder/types"
9+
"github.com/rs/zerolog"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestErisProtocolPriceUpdate(t *testing.T) {
14+
t.Run("success", func(t *testing.T) {
15+
rawPrices, err := ErisProtocolPriceUpdate(set.New[types.Symbol](), zerolog.New(io.Discard))
16+
require.NoError(t, err)
17+
require.Equal(t, 1, len(rawPrices))
18+
require.NotZero(t, rawPrices["ustnibi:unibi"])
19+
})
20+
}

0 commit comments

Comments
 (0)