diff --git a/CHANGELOG.md b/CHANGELOG.md index e8a31ecc19..6fb4f9c56d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Ref: https://keepachangelog.com/en/1.0.0/ ### Fixes +- [1812](https://github.com/umee-network/umee/pull/1812) MaxCollateralShare now works during partial oracle outages when certain conditions are safe. - [1736](https://github.com/umee-network/umee/pull/1736) Blacklisted tokens no longer add themselves back to the oracle accept list. ## [v4.0.1](https://github.com/umee-network/umee/releases/tag/v4.0.1) - 2023-02-10 diff --git a/x/leverage/keeper/collateral.go b/x/leverage/keeper/collateral.go index decc4291ad..efe78e335a 100644 --- a/x/leverage/keeper/collateral.go +++ b/x/leverage/keeper/collateral.go @@ -78,6 +78,38 @@ func (k Keeper) CalculateCollateralValue(ctx sdk.Context, collateral sdk.Coins) return total, nil } +// VisibleCollateralValue uses the price oracle to determine the value (in USD) provided by +// collateral sdk.Coins, using each token's uToken exchange rate. Always uses spot price. +// Unlike CalculateCollateralValue, this function will not return an error if value calculation +// fails on a token - instead, that token will contribute zero value to the total. +func (k Keeper) VisibleCollateralValue(ctx sdk.Context, collateral sdk.Coins) (sdk.Dec, error) { + total := sdk.ZeroDec() + + for _, coin := range collateral { + // convert uToken collateral to base assets + baseAsset, err := k.ExchangeUToken(ctx, coin) + if err != nil { + return sdk.ZeroDec(), err + } + + // get USD value of base assets + v, err := k.TokenValue(ctx, baseAsset, types.PriceModeSpot) + if err != nil { + k.Logger(ctx).Info( + "collateral value skipped", + "uToken", coin.String(), + "error", err.Error(), + ) + continue + } + + // for coins that did not error, add their value to the total + total = total.Add(v) + } + + return total, nil +} + // GetAllTotalCollateral returns total collateral across all uTokens. func (k Keeper) GetAllTotalCollateral(ctx sdk.Context) sdk.Coins { total := sdk.NewCoins() @@ -112,14 +144,16 @@ func (k Keeper) CollateralLiquidity(ctx sdk.Context, denom string) sdk.Dec { return sdk.MinDec(collateralLiquidity, sdk.OneDec()) } -// CollateralShare calculates the portion of overall collateral -// (measured in USD value) that a given uToken denom represents. -func (k *Keeper) CollateralShare(ctx sdk.Context, denom string) (sdk.Dec, error) { +// VisibleCollateralShare calculates the portion of overall collateral (measured in USD value) that a +// given uToken denom represents. If an asset other than the denom requested is missing an oracle +// price, it ignores that asset's contribution to the system's overall collateral, thus potentially +// overestimating the requested denom's collateral share while improving availability. +func (k *Keeper) VisibleCollateralShare(ctx sdk.Context, denom string) (sdk.Dec, error) { systemCollateral := k.GetAllTotalCollateral(ctx) thisCollateral := sdk.NewCoins(sdk.NewCoin(denom, systemCollateral.AmountOf(denom))) - // get USD collateral value for all uTokens combined - totalValue, err := k.CalculateCollateralValue(ctx, systemCollateral) + // get USD collateral value for all uTokens combined, except those experiencing price outages + totalValue, err := k.VisibleCollateralValue(ctx, systemCollateral) if err != nil { return sdk.ZeroDec(), err } @@ -152,13 +186,19 @@ func (k Keeper) checkCollateralLiquidity(ctx sdk.Context, denom string) error { } // checkCollateralShare returns an error if a given uToken is above its collateral share +// as calculated using only tokens whose oracle prices exist func (k *Keeper) checkCollateralShare(ctx sdk.Context, denom string) error { token, err := k.GetTokenSettings(ctx, types.ToTokenDenom(denom)) if err != nil { return err } - share, err := k.CollateralShare(ctx, denom) + if token.MaxCollateralShare.Equal(sdk.OneDec()) { + // skip computation when collateral share is unrestricted + return nil + } + + share, err := k.VisibleCollateralShare(ctx, denom) if err != nil { return err } diff --git a/x/leverage/keeper/msg_server_test.go b/x/leverage/keeper/msg_server_test.go index 4793d473c0..6718089c32 100644 --- a/x/leverage/keeper/msg_server_test.go +++ b/x/leverage/keeper/msg_server_test.go @@ -813,27 +813,32 @@ func (s *IntegrationTestSuite) TestMsgDecollateralize() { supplier, coin.New(umeeDenom, 80_000000), types.ErrNotUToken, - }, { + }, + { "no collateral", supplier, coin.New("u/"+atomDenom, 40_000000), types.ErrInsufficientCollateral, - }, { + }, + { "valid decollateralize", supplier, coin.New("u/"+umeeDenom, 80_000000), nil, - }, { + }, + { "additional decollateralize", supplier, coin.New("u/"+umeeDenom, 10_000000), nil, - }, { + }, + { "insufficient collateral", supplier, coin.New("u/"+umeeDenom, 40_000000), types.ErrInsufficientCollateral, - }, { + }, + { "above borrow limit", borrower, coin.New("u/"+atomDenom, 100_000000), @@ -845,17 +850,20 @@ func (s *IntegrationTestSuite) TestMsgDecollateralize() { dumpborrower, coin.New("u/"+pumpDenom, 20_000000), nil, - }, { + }, + { "above borrow limit (undercollateralized under historic prices but ok with current prices)", dumpborrower, coin.New("u/"+pumpDenom, 20_000000), types.ErrUndercollaterized, - }, { + }, + { "acceptable decollateralize (pump borrower)", pumpborrower, coin.New("u/"+dumpDenom, 20_000000), nil, - }, { + }, + { "above borrow limit (undercollateralized under current prices but ok with historic prices)", pumpborrower, coin.New("u/"+dumpDenom, 20_000000), @@ -1684,8 +1692,25 @@ func (s *IntegrationTestSuite) TestMaxCollateralShare() { // so ATOM's collateral share ($46.46 / $467.46) is barely below 10% s.collateralize(atomSupplier, coin.New("u/"+atomDenom, 1_180000)) - // attempt to collateralize another 0.01 ATOM, which would result in too much collateral share for ATOM + // kill the oracle's ability to return UMEE price + s.mockOracle.Clear("UMEE") + + // now ATOM's (visible) collateral share is 100% and even the smallest collateralize will fail msg := &types.MsgCollateralize{ + Borrower: atomSupplier.String(), + Asset: coin.New("u/"+atomDenom, 1), + } + _, err = srv.Collateralize(ctx, msg) + require.ErrorIs(err, types.ErrMaxCollateralShare) + + // return the oracle to normal + s.mockOracle.Reset() + + // ensure the previous collateralize would have worked + s.collateralize(atomSupplier, coin.New("u/"+atomDenom, 1)) + + // attempt to collateralize another 0.01 ATOM, which would result in too much collateral share for ATOM + msg = &types.MsgCollateralize{ Borrower: atomSupplier.String(), Asset: coin.New("u/"+atomDenom, 10000), } diff --git a/x/leverage/keeper/oracle_test.go b/x/leverage/keeper/oracle_test.go index 3befcdf5b5..e24a2ab148 100644 --- a/x/leverage/keeper/oracle_test.go +++ b/x/leverage/keeper/oracle_test.go @@ -47,15 +47,13 @@ func (m *mockOracleKeeper) GetExchangeRate(_ sdk.Context, denom string) (sdk.Dec return p, nil } -func (m *mockOracleKeeper) GetExchangeRateBase(ctx sdk.Context, denom string) (sdk.Dec, error) { - p, ok := m.baseExchangeRates[denom] - if !ok { - return sdk.ZeroDec(), fmt.Errorf("invalid denom: %s", denom) - } - - return p, nil +// Clear clears a denom from the mock oracle, simulating an outage. +func (m *mockOracleKeeper) Clear(denom string) { + delete(m.symbolExchangeRates, denom) + delete(m.historicExchangeRates, denom) } +// Reset restores the mock oracle's prices to its default values. func (m *mockOracleKeeper) Reset() { m.symbolExchangeRates = map[string]sdk.Dec{ "UMEE": sdk.MustNewDecFromStr("4.21"), @@ -64,13 +62,6 @@ func (m *mockOracleKeeper) Reset() { "DUMP": sdk.MustNewDecFromStr("0.50"), // A token which has recently halved in price "PUMP": sdk.MustNewDecFromStr("2.00"), // A token which has recently doubled in price } - m.baseExchangeRates = map[string]sdk.Dec{ - appparams.BondDenom: sdk.MustNewDecFromStr("0.00000421"), - atomDenom: sdk.MustNewDecFromStr("0.00003938"), - daiDenom: sdk.MustNewDecFromStr("0.000000000000000001"), - dumpDenom: sdk.MustNewDecFromStr("0.0000005"), - pumpDenom: sdk.MustNewDecFromStr("0.0000020"), - } m.historicExchangeRates = map[string]sdk.Dec{ "UMEE": sdk.MustNewDecFromStr("4.21"), "ATOM": sdk.MustNewDecFromStr("39.38"), diff --git a/x/leverage/keeper/suite_test.go b/x/leverage/keeper/suite_test.go index 2fc42fe544..29024cec69 100644 --- a/x/leverage/keeper/suite_test.go +++ b/x/leverage/keeper/suite_test.go @@ -39,6 +39,8 @@ type IntegrationTestSuite struct { setupAccountCounter sdkmath.Int addrs []sdk.AccAddress msgSrvr types.MsgServer + + mockOracle *mockOracleKeeper } func TestKeeperTestSuite(t *testing.T) { @@ -54,6 +56,8 @@ func (s *IntegrationTestSuite) SetupTest() { Time: time.Unix(0, 0), }) + s.mockOracle = newMockOracleKeeper() + // we only override the Leverage keeper so we can supply a custom mock oracle k, tk := keeper.NewTestKeeper( s.Require(), @@ -61,7 +65,7 @@ func (s *IntegrationTestSuite) SetupTest() { app.GetKey(types.ModuleName), app.GetSubspace(types.ModuleName), app.BankKeeper, - newMockOracleKeeper(), + s.mockOracle, true, ) diff --git a/x/leverage/types/expected_types.go b/x/leverage/types/expected_types.go index f408edc9fe..947383f288 100644 --- a/x/leverage/types/expected_types.go +++ b/x/leverage/types/expected_types.go @@ -32,6 +32,5 @@ type BankKeeper interface { // OracleKeeper defines the expected x/oracle keeper interface. type OracleKeeper interface { GetExchangeRate(ctx sdk.Context, denom string) (sdk.Dec, error) - GetExchangeRateBase(ctx sdk.Context, denom string) (sdk.Dec, error) MedianOfHistoricMedians(ctx sdk.Context, denom string, numStamps uint64) (sdk.Dec, uint32, error) }