Skip to content

Commit 2ae5712

Browse files
fix(feeder): improve price provider error handling with graceful degradation, remove panics from all price provider constructors, add registry for sources (#87)
* fix(robustness): remove panic paths from all price provider constructors, make invalid price values consistent, and remove switch case maintenance burden using a registry * linter * refactor: add suggestions from coderabbitai * lots of docs as a finishing touch
1 parent bf7922e commit 2ae5712

23 files changed

+424
-157
lines changed

config/config.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ var defaultExchangeSymbolsMap = map[string]map[asset.Pair]types.Symbol{
3131
// },
3232

3333
// https://api-pub.bitfinex.com/v2/conf/pub:list:pair:exchange
34-
sources.SourceBitfinex: {
34+
sources.SourceNameBitfinex: {
3535
"ubtc:uusd": "tBTCUSD",
3636
"ueth:uusd": "tETHUSD",
3737
"uusdc:uusd": "tUDCUSD",
@@ -41,7 +41,7 @@ var defaultExchangeSymbolsMap = map[string]map[asset.Pair]types.Symbol{
4141
},
4242

4343
// https://api.gateio.ws/api/v4/spot/currency_pairs
44-
sources.SourceGateIo: {
44+
sources.SourceNameGateIo: {
4545
"ubtc:uusd": "BTC_USDT",
4646
"ueth:uusd": "ETH_USDT",
4747
"uusdc:uusd": "USDC_USDT",
@@ -52,7 +52,7 @@ var defaultExchangeSymbolsMap = map[string]map[asset.Pair]types.Symbol{
5252
},
5353

5454
// https://www.okx.com/api/v5/market/tickers?instType=SPOT
55-
sources.SourceOkex: {
55+
sources.SourceNameOkex: {
5656
"ubtc:uusd": "BTC-USDT",
5757
"ueth:uusd": "ETH-USDT",
5858
"uusdc:uusd": "USDC-USDT",
@@ -62,7 +62,7 @@ var defaultExchangeSymbolsMap = map[string]map[asset.Pair]types.Symbol{
6262
},
6363

6464
// https://api.bybit.com/v5/market/tickers?category=spot
65-
sources.SourceBybit: {
65+
sources.SourceNameBybit: {
6666
"ubtc:uusd": "BTCUSDT",
6767
"ueth:uusd": "ETHUSDT",
6868
"uusdc:uusd": "USDCUSDT",
@@ -71,19 +71,19 @@ var defaultExchangeSymbolsMap = map[string]map[asset.Pair]types.Symbol{
7171
"usol:uusd": "SOLUSDT",
7272
},
7373

74-
sources.SourceErisProtocol: {
74+
sources.SourceNameErisProtocol: {
7575
"ustnibi:unibi": "ustnibi:unibi", // this is the only pair supported by the Eris Protocol smart contract
7676
},
7777

78-
sources.SourceUniswapV3: {
78+
sources.SourceNameUniswapV3: {
7979
"usda:usd": "USDa:USDT",
8080
},
8181

82-
sources.SourceChainLink: {
82+
sources.SourceNameChainLink: {
8383
"b2btc:btc": "uBTC/BTC",
8484
},
8585

86-
sources.SourceAvalon: {
86+
sources.SourceNameAvalon: {
8787
"susda:usda": "susda:usda",
8888
},
8989
}

feeder/feeder.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ func (f *Feeder) handleVotingPeriod(vp types.VotingPeriod) {
100100
price := f.PriceProvider.GetPrice(p)
101101
if !price.Valid {
102102
f.logger.Err(fmt.Errorf("no valid price")).Str("asset", p.String()).Str("source", price.SourceName)
103-
price.Price = 0
103+
price.Price = types.PriceAbstain
104104
}
105105
prices[i] = price
106106
}

feeder/feeder_test.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ func TestRunPanics(t *testing.T) {
2727

2828
require.Panics(t, func() {
2929
f.Run()
30-
})
30+
}, `expect panic on Run because the mock ParamsUpdate returns an unbuffered
31+
channel. This means that when initParamsOrDie tries to receive from it,
32+
the channel will be empty, and the receive blocks. This means the "Run"
33+
will timeout after InitTimeout.`)
3134
}
3235

3336
func TestParamsUpdate(t *testing.T) {
@@ -62,7 +65,7 @@ func TestVotingPeriod(t *testing.T) {
6265
}
6366

6467
abstainPrice := invalidPrice
65-
abstainPrice.Price = 0.0
68+
abstainPrice.Price = types.PriceAbstain
6669

6770
tf.mockPriceProvider.EXPECT().GetPrice(asset.Registry.Pair(denoms.BTC, denoms.NUSD)).Return(validPrice)
6871
tf.mockPriceProvider.EXPECT().GetPrice(asset.Registry.Pair(denoms.ETH, denoms.NUSD)).Return(invalidPrice)

feeder/integration_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ func (s *IntegrationSuite) SetupSuite() {
9191
logger,
9292
),
9393
feeder.NewPriceProvider(
94-
sources.SourceBitfinex,
94+
sources.SourceNameBitfinex,
9595
map[asset.Pair]types.Symbol{
9696
asset.Registry.Pair(denoms.BTC, denoms.NUSD): "tBTCUSD",
9797
asset.Registry.Pair(denoms.ETH, denoms.NUSD): "tETHUSD",

feeder/priceposter.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,13 @@ func TryUntilDone(
169169
/// SIGNING
170170
/// -------------------------------------------------
171171

172+
// sendTx constructs, signs, and broadcasts a transaction containing the provided
173+
// messages. It retrieves account information (account number and sequence) from
174+
// the chain and sets transaction fees and gas limits. Returns the transaction
175+
// response or an error if the broadcast fails or the transaction is rejected.
176+
//
177+
// Panics on configuration errors (missing key, encoding failures) as these
178+
// indicate programmer errors rather than runtime failures.
172179
func sendTx(
173180
ctx context.Context,
174181
keyBase keyring.Keyring,
@@ -233,7 +240,15 @@ func sendTx(
233240
return resp.TxResponse, nil
234241
}
235242

236-
func getAccount(ctx context.Context, authClient Auth, ir codectypes.InterfaceRegistry, feeder sdk.AccAddress) (uint64, uint64, error) {
243+
// The [getAccount] fn retrieves the account number and sequence for the given feeder
244+
// address from the chain. These values are required for constructing valid
245+
// transactions. Returns an error if the account cannot be found or unpacked.
246+
func getAccount(
247+
ctx context.Context,
248+
authClient Auth,
249+
ir codectypes.InterfaceRegistry,
250+
feeder sdk.AccAddress,
251+
) (uint64, uint64, error) {
237252
accRaw, err := authClient.Account(ctx, &authtypes.QueryAccountRequest{Address: feeder.String()})
238253
if err != nil {
239254
return 0, 0, err // if account not found it's pointless to continue
@@ -257,6 +272,13 @@ func getAccount(ctx context.Context, authClient Auth, ir codectypes.InterfaceReg
257272
// TODO(mercilex): if we used digits + alphanumerics it's more randomized
258273
var MaxSaltNumber = big.NewInt(9999) // NOTE(mercilex): max salt length is 4
259274

275+
// The [vote] fn submits a prevote message to the chain, and optionally a vote message
276+
// if an old prevote exists. The vote is constructed from the old prevote's
277+
// hash and salt. Messages are ordered such that the vote (if present) is sent
278+
// before the new prevote, as the new prevote will overwrite the old one.
279+
//
280+
// oldPrevote may be nil if no previous prevote exists, in which case only
281+
// the new prevote is submitted.
260282
func vote(
261283
ctx context.Context,
262284
newPrevote, oldPrevote *prevote,
@@ -290,6 +312,11 @@ func vote(
290312
)
291313
}
292314

315+
// The [prepareVote] fn constructs a vote message from an existing prevote on the chain.
316+
// It verifies that the local prevote hash matches the chain's prevote hash
317+
// to ensure the prevote hasn't been tampered with or expired. Returns nil
318+
// if no prevote exists on the chain or if the hashes don't match, indicating
319+
// the prevote has expired or been invalidated.
293320
func prepareVote(
294321
ctx context.Context,
295322
oracleClient Oracle,
@@ -323,12 +350,20 @@ func prepareVote(
323350
}, nil
324351
}
325352

353+
// The [prevote] struct contains the data needed for a prevote-vote cycle in the
354+
// oracle. The [prevote] message is sent first with a hash of the vote data,
355+
// followed by the actual vote message in the next voting period. This two-phase
356+
// commit pattern prevents front-running and ensures vote integrity.
326357
type prevote struct {
327358
msg *oracletypes.MsgAggregateExchangeRatePrevote
328359
salt string
329360
vote string
330361
}
331362

363+
// The [newPrevote] fn creates a new prevote message from a list of prices. It generates
364+
// a random salt value, formats the prices as exchange rate tuples, and computes
365+
// the aggregate vote hash. The prevote struct contains both the message to send
366+
// and the data needed to construct the corresponding vote in the next voting period.
332367
func newPrevote(prices []types.Price, validator sdk.ValAddress, feeder sdk.AccAddress) *prevote {
333368
tuple := make(oracletypes.ExchangeRateTuples, len(prices))
334369
for i, price := range prices {

feeder/priceprovider.go

Lines changed: 45 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package feeder
22

33
import (
44
"encoding/json"
5+
"fmt"
56
"sync"
67
"time"
78

@@ -55,31 +56,12 @@ func NewPriceProvider(
5556
symbols.Add(s)
5657
}
5758

58-
switch sourceName {
59-
case sources.SourceBitfinex:
60-
source = sources.NewTickSource(symbols, sources.BitfinexPriceUpdate, logger)
61-
case sources.SourceBinance:
62-
source = sources.NewTickSource(symbols, sources.BinancePriceUpdate, logger)
63-
case sources.SourceCoingecko:
64-
source = sources.NewTickSource(symbols, sources.CoingeckoPriceUpdate(config), logger)
65-
case sources.SourceOkex:
66-
source = sources.NewTickSource(symbols, sources.OkexPriceUpdate, logger)
67-
case sources.SourceGateIo:
68-
source = sources.NewTickSource(symbols, sources.GateIoPriceUpdate, logger)
69-
case sources.SourceCoinMarketCap:
70-
source = sources.NewTickSource(symbols, sources.CoinmarketcapPriceUpdate(config), logger)
71-
case sources.SourceBybit:
72-
source = sources.NewTickSource(symbols, sources.BybitPriceUpdate, logger)
73-
case sources.SourceErisProtocol:
74-
source = sources.NewTickSource(symbols, sources.ErisProtocolPriceUpdate, logger)
75-
case sources.SourceUniswapV3:
76-
source = sources.NewTickSource(symbols, sources.UniswapV3PriceUpdate, logger)
77-
case sources.SourceAvalon:
78-
source = sources.NewTickSource(symbols, sources.AvalonPriceUpdate, logger)
79-
case sources.SourceChainLink:
80-
source = sources.NewTickSource(symbols, sources.ChainlinkPriceUpdate, logger)
81-
default:
82-
panic("unknown price provider: " + sourceName)
59+
source, err := sources.GetRegisteredSource(sourceName, symbols, config, logger)
60+
if err != nil {
61+
logger.
62+
Warn().
63+
Msg(err.Error())
64+
return types.NullPriceProvider{}
8365
}
8466

8567
return newPriceProvider(source, sourceName, pairToSymbolMap, logger)
@@ -105,6 +87,10 @@ func newPriceProvider(source types.Source, sourceName string, pairToSymbolsMap m
10587
return pp
10688
}
10789

90+
// loop runs in a background goroutine and continuously listens for price updates
91+
// from the source. It updates the lastPrices map with new data and handles
92+
// shutdown signals. The loop exits when stopSignal is closed, ensuring proper
93+
// cleanup of the source and done channel.
10894
func (p *PriceProvider) loop() {
10995
defer close(p.done)
11096
defer p.source.Close()
@@ -135,7 +121,7 @@ func (p *PriceProvider) GetPrice(pair asset.Pair) types.Price {
135121
p.logger.Debug().Str("pair", pair.String()).Msg("pair not configured for this pricefeeder")
136122
return types.Price{
137123
Pair: pair,
138-
Price: -1, // abstain
124+
Price: types.PriceAbstain,
139125
SourceName: p.sourceName,
140126
Valid: false,
141127
}
@@ -158,8 +144,9 @@ func (p *PriceProvider) Close() {
158144
<-p.done
159145
}
160146

161-
// isValid is a helper function which asserts if a price is valid given
162-
// if it was found and the time at which it was last updated.
147+
// isValid determines whether a price is valid based on whether it was found and
148+
// whether it was updated within the [types.PriceTimeout] window. Prices that are
149+
// missing or older than [types.PriceTimeout] are considered invalid.
163150
func isValid(price types.RawPrice, found bool) bool {
164151
return found && time.Since(price.UpdateTime) < types.PriceTimeout
165152
}
@@ -171,8 +158,11 @@ func isValid(price types.RawPrice, found bool) bool {
171158
// AggregatePriceProvider aggregates multiple price providers
172159
// and queries them for prices.
173160
type AggregatePriceProvider struct {
161+
// providers: A set of providers implemented using a map to zero size
162+
// empty struct. Using a map gives us random order of iteration, the
163+
// intended behavior (since golang's map range is unordered)
164+
providers map[types.PriceProvider]struct{}
174165
logger zerolog.Logger
175-
providers map[int]types.PriceProvider // we use a map here to provide random ranging (since golang's map range is unordered)
176166
}
177167

178168
// NewAggregatePriceProvider instantiates a new AggregatePriceProvider instance
@@ -182,11 +172,24 @@ func NewAggregatePriceProvider(
182172
sourceConfigMap map[string]json.RawMessage,
183173
logger zerolog.Logger,
184174
) types.PriceProvider {
185-
providers := make(map[int]types.PriceProvider, len(sourcesToPairSymbolMap))
186-
i := 0
175+
providers := make(map[types.PriceProvider]struct{})
176+
invalidSources := []string{}
187177
for sourceName, pairToSymbolMap := range sourcesToPairSymbolMap {
188-
providers[i] = NewPriceProvider(sourceName, pairToSymbolMap, sourceConfigMap[sourceName], logger)
189-
i++
178+
pp := NewPriceProvider(sourceName, pairToSymbolMap, sourceConfigMap[sourceName], logger)
179+
if _, isNull := pp.(types.NullPriceProvider); isNull {
180+
invalidSources = append(invalidSources, sourceName)
181+
continue
182+
}
183+
providers[pp] = struct{}{}
184+
}
185+
186+
if len(providers) != len(sourcesToPairSymbolMap) {
187+
logger.Warn().
188+
Msg(fmt.Sprintf("invalid source names given as key in configuration: { invalidSources: %#v }", invalidSources))
189+
}
190+
if len(providers) == 0 {
191+
logger.Error().
192+
Msg(fmt.Sprintf("no price providers available: { invalidSources: %#v }", invalidSources))
190193
}
191194

192195
return AggregatePriceProvider{
@@ -210,8 +213,8 @@ func (a AggregatePriceProvider) GetPrice(pair asset.Pair) types.Price {
210213
case "ustnibi:uusd":
211214
// fetch unibi:uusd first to calculate the ustnibi:unibi price
212215

213-
unibiUusdPrice := -1.0 // default to -1 to indicate we haven't found a valid price yet
214-
for _, p := range a.providers {
216+
unibiUusdPrice := types.PriceAbstain // default to -1 to indicate we haven't found a valid price yet
217+
for p := range a.providers {
215218
price := p.GetPrice("unibi:uusd")
216219
if !price.Valid {
217220
continue
@@ -228,13 +231,13 @@ func (a AggregatePriceProvider) GetPrice(pair asset.Pair) types.Price {
228231
return types.Price{
229232
SourceName: "missing",
230233
Pair: pair,
231-
Price: 0,
234+
Price: types.PriceAbstain,
232235
Valid: false,
233236
}
234237
}
235238

236239
// now we can calculate the ustnibi:unibi price
237-
for _, p := range a.providers {
240+
for p := range a.providers {
238241
price := p.GetPrice("ustnibi:unibi")
239242
if !price.Valid {
240243
continue
@@ -257,7 +260,7 @@ func (a AggregatePriceProvider) GetPrice(pair asset.Pair) types.Price {
257260
return types.Price{
258261
SourceName: "missing",
259262
Pair: pair,
260-
Price: 0,
263+
Price: types.PriceAbstain,
261264
Valid: false,
262265
}
263266
}
@@ -278,14 +281,14 @@ func (a AggregatePriceProvider) GetPrice(pair asset.Pair) types.Price {
278281
return types.Price{
279282
SourceName: "missing",
280283
Pair: pair,
281-
Price: 0,
284+
Price: types.PriceAbstain,
282285
Valid: false,
283286
}
284287

285288
default:
286289
// for all other price pairs, iterate randomly, if we find a valid price, we return it
287290
// otherwise we go onto the next PriceProvider to ask for prices.
288-
for _, p := range a.providers {
291+
for p := range a.providers {
289292
price := p.GetPrice(pair)
290293
if price.Valid {
291294
aggregatePriceProvider.WithLabelValues(pair.String(), price.SourceName, "true").Inc()
@@ -300,13 +303,13 @@ func (a AggregatePriceProvider) GetPrice(pair asset.Pair) types.Price {
300303
return types.Price{
301304
SourceName: "missing",
302305
Pair: pair,
303-
Price: 0,
306+
Price: types.PriceAbstain,
304307
Valid: false,
305308
}
306309
}
307310

308311
func (a AggregatePriceProvider) Close() {
309-
for _, p := range a.providers {
312+
for p := range a.providers {
310313
p.Close()
311314
}
312315
}

0 commit comments

Comments
 (0)