diff --git a/CHANGELOG.md b/CHANGELOG.md index a46ea81b20..09c4a518ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Ref: https://keepachangelog.com/en/1.0.0/ ### Features +- [2114](https://github.com/umee-network/umee/pull/2114) Add borrow factor to `x/leverage` - [2102](https://github.com/umee-network/umee/pull/2102) and [2106](https://github.com/umee-network/umee/pull/2106) Add `MsgLeveragedLiquidate` to `x/leverage` - [2085](https://github.com/umee-network/umee/pull/2085) Add `inspect` query to leverage module, which msut be enabled on a node by running with `-l` liquidator query flag. - [1952](https://github.com/umee-network/umee/pull/1952) Add `x/incentive` module. diff --git a/x/leverage/README.md b/x/leverage/README.md index 7b59cc6dc4..dfc97a437f 100644 --- a/x/leverage/README.md +++ b/x/leverage/README.md @@ -20,6 +20,7 @@ The leverage module depends directly on `x/oracle` for asset prices, and interac - [uToken Exchange Rate](#utoken-exchange-rate) - [Supply Utilization](#supply-utilization) - [Borrow Limit](#borrow-limit) + - [Borrow Factor](#borrow-factor) - [Liquidation Threshold](#liquidation-threshold) - [Borrow APY](#borrow-apy) - [Supplying APY](#supplying-apy) @@ -168,6 +169,21 @@ A user's borrow limit is the sum of the contributions from each denomination of For tokens with hith historic prices enabled (indicated by a `HistoricMedians` parameter greater than zero), each collateral `TokenValue` is computed with `PriceModeLow`, i.e. the lower of either spot price or historic price is used. +#### Borrow Factor + +Each token in the `Token Registry` has a parameter called `CollateralWeight`, always less than 1, which determines the portion of the token's value that goes towards a user's borrow limit, when the token is used as collateral. + +An implied parameter `BorrowFactor` is derived from `CollateralWeight` - specifically, it is the minimum of `2.0` and `1/CollateralWeight`. +The maximum borrow factor of `2.0` allows risky or non-collateral assets (`0 <= CollateralWeight < 0.5`) to be borrowed to a certain minimum degree. + +When a user is borrowing, their borrow limit is whichever is more restrictive of the following two rules: + +- Borrowed value must be less than collateral value times `CollateralWeight` (sum over each collateral asset) +- Borrowed value times `BorrowFactor` (sum over each borrowed asset) must be less than collateral value. + +This means that when the original borrow limit based on collateral weight would allow a higher quality collateral to borrow a risky asset with a small margin of safety, the user's effective collateral weight is reduced to that of the riskier asset. +(Or `0.5` at the minimum.) + #### Historic Borrow Limit, Value The leverage module also makes use of the oracle's historic prices to enforce an additional restriction on borrowing. diff --git a/x/leverage/keeper/borrows.go b/x/leverage/keeper/borrows.go index de73228f12..dcf3419654 100644 --- a/x/leverage/keeper/borrows.go +++ b/x/leverage/keeper/borrows.go @@ -8,7 +8,8 @@ import ( ) // assertBorrowerHealth returns an error if a borrower is currently above their borrow limit, -// under either recent (historic median) or current prices. It returns an error if +// under either recent (historic median) or current prices. Checks using borrow limit based +// on collateral weight, then check separately for borrow limit using borrow factor. Error if // borrowed asset prices cannot be calculated, but will try to treat collateral whose prices are // unavailable as having zero value. This can still result in a borrow limit being too low, // unless the remaining collateral is enough to cover all borrows. @@ -21,18 +22,34 @@ func (k Keeper) assertBorrowerHealth(ctx sdk.Context, borrowerAddr sdk.AccAddres borrowed := k.GetBorrowerBorrows(ctx, borrowerAddr) collateral := k.GetBorrowerCollateral(ctx, borrowerAddr) - value, err := k.TotalTokenValue(ctx, borrowed, types.PriceModeHigh) + // check health using collateral weight + borrowValue, err := k.TotalTokenValue(ctx, borrowed, types.PriceModeHigh) if err != nil { return err } - limit, err := k.VisibleBorrowLimit(ctx, collateral) + borrowLimit, err := k.VisibleBorrowLimit(ctx, collateral) if err != nil { return err } - if value.GT(limit.Mul(maxUsage)) { + if borrowValue.GT(borrowLimit.Mul(maxUsage)) { return types.ErrUndercollaterized.Wrapf( - "borrowed: %s, limit: %s, max usage %s", value, limit, maxUsage) + "borrowed: %s, limit: %s, max usage %s", borrowValue, borrowLimit, maxUsage) } + + // check health using borrow factor + weightedBorrowValue, err := k.ValueWithBorrowFactor(ctx, borrowed, types.PriceModeHigh) + if err != nil { + return err + } + collateralValue, err := k.VisibleUTokensValue(ctx, collateral, types.PriceModeLow) + if err != nil { + return err + } + if weightedBorrowValue.GT(collateralValue.Mul(maxUsage)) { + return types.ErrUndercollaterized.Wrapf( + "weighted borrow: %s, collateral value: %s, max usage %s", weightedBorrowValue, collateralValue, maxUsage) + } + return nil } diff --git a/x/leverage/keeper/collateral.go b/x/leverage/keeper/collateral.go index 018bf7b52f..200fc78494 100644 --- a/x/leverage/keeper/collateral.go +++ b/x/leverage/keeper/collateral.go @@ -73,9 +73,9 @@ func (k Keeper) GetTotalCollateral(ctx sdk.Context, denom string) sdk.Coin { } // CalculateCollateralValue 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. +// collateral sdk.Coins, using each token's uToken exchange rate. // An error is returned if any input coins are not uTokens or if value calculation fails. -func (k Keeper) CalculateCollateralValue(ctx sdk.Context, collateral sdk.Coins) (sdk.Dec, error) { +func (k Keeper) CalculateCollateralValue(ctx sdk.Context, collateral sdk.Coins, mode types.PriceMode) (sdk.Dec, error) { total := sdk.ZeroDec() for _, coin := range collateral { @@ -86,12 +86,12 @@ func (k Keeper) CalculateCollateralValue(ctx sdk.Context, collateral sdk.Coins) } // get USD value of base assets - v, err := k.TokenValue(ctx, baseAsset, types.PriceModeSpot) + v, err := k.TokenValue(ctx, baseAsset, mode) if err != nil { return sdk.ZeroDec(), err } - // add each collateral coin's weighted value to borrow limit + // add each collateral coin's value to borrow limit total = total.Add(v) } @@ -99,10 +99,10 @@ func (k Keeper) CalculateCollateralValue(ctx sdk.Context, collateral sdk.Coins) } // 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. +// collateral sdk.Coins, using each token's uToken exchange rate. // 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) { +func (k Keeper) VisibleCollateralValue(ctx sdk.Context, collateral sdk.Coins, mode types.PriceMode) (sdk.Dec, error) { total := sdk.ZeroDec() for _, coin := range collateral { @@ -113,7 +113,7 @@ func (k Keeper) VisibleCollateralValue(ctx sdk.Context, collateral sdk.Coins) (s } // get USD value of base assets - v, err := k.TokenValue(ctx, baseAsset, types.PriceModeSpot) + v, err := k.TokenValue(ctx, baseAsset, mode) if err == nil { // for coins that did not error, add their value to the total total = total.Add(v) @@ -169,13 +169,13 @@ func (k *Keeper) VisibleCollateralShare(ctx sdk.Context, denom string) (sdk.Dec, thisCollateral := sdk.NewCoins(sdk.NewCoin(denom, systemCollateral.AmountOf(denom))) // get USD collateral value for all uTokens combined, except those experiencing price outages - totalValue, err := k.VisibleCollateralValue(ctx, systemCollateral) + totalValue, err := k.VisibleCollateralValue(ctx, systemCollateral, types.PriceModeSpot) if err != nil { return sdk.ZeroDec(), err } // get USD collateral value for this uToken only - thisValue, err := k.CalculateCollateralValue(ctx, thisCollateral) + thisValue, err := k.CalculateCollateralValue(ctx, thisCollateral, types.PriceModeSpot) if err != nil { return sdk.ZeroDec(), err } diff --git a/x/leverage/keeper/grpc_query.go b/x/leverage/keeper/grpc_query.go index 3d63a4c639..16ad0d4621 100644 --- a/x/leverage/keeper/grpc_query.go +++ b/x/leverage/keeper/grpc_query.go @@ -228,7 +228,7 @@ func (q Querier) AccountSummary( } // collateral value always uses spot prices, and this line skips assets that are missing prices - collateralValue, err := q.Keeper.VisibleCollateralValue(ctx, collateral) + collateralValue, err := q.Keeper.VisibleCollateralValue(ctx, collateral, types.PriceModeSpot) if err != nil { return nil, err } diff --git a/x/leverage/keeper/grpc_query_test.go b/x/leverage/keeper/grpc_query_test.go index 28119e7db7..6c984c7cba 100644 --- a/x/leverage/keeper/grpc_query_test.go +++ b/x/leverage/keeper/grpc_query_test.go @@ -25,7 +25,7 @@ func (s *IntegrationTestSuite) TestQuerier_RegisteredTokens() { "valid: get the all registered tokens", "", types.QueryRegisteredTokens{}, - 5, + 6, }, { "valid: get the registered token info by base_denom", diff --git a/x/leverage/keeper/inspector.go b/x/leverage/keeper/inspector.go index d3058e6792..5a5f113f5f 100644 --- a/x/leverage/keeper/inspector.go +++ b/x/leverage/keeper/inspector.go @@ -68,7 +68,7 @@ func (q Querier) Inspect( borrowed := k.GetBorrowerBorrows(ctx, addr) borrowedValue, _ := k.TotalTokenValue(ctx, borrowed, types.PriceModeSpot) collateral := k.GetBorrowerCollateral(ctx, addr) - collateralValue, _ := k.CalculateCollateralValue(ctx, collateral) + collateralValue, _ := k.CalculateCollateralValue(ctx, collateral, types.PriceModeSpot) liquidationThreshold, _ := k.CalculateLiquidationThreshold(ctx, collateral) account := types.InspectAccount{ diff --git a/x/leverage/keeper/inspector_test.go b/x/leverage/keeper/inspector_test.go index e9bf57df06..e13d2c5a05 100644 --- a/x/leverage/keeper/inspector_test.go +++ b/x/leverage/keeper/inspector_test.go @@ -25,13 +25,17 @@ func TestNeat(t *testing.T) { "-12.555": -12.55, // truncates default to cent "-0.00123456789": -0.001234, // truncates <0.01 to millionth "-0.000000987654321": -0.000000987654321, // <0.000001 gets maximum precision - // edge case: >2^64 displays incorrectly - // this should be fine, since this is a display-only function (not used in transactions) - // which is used on dollar (not token) amounts - "123456789123456789123456789.123456789": -9.223372036854776e+21, } for s, f := range cases { assert.Equal(f, neat(sdk.MustNewDecFromStr(s))) } + + // edge case: >2^64 displays incorrectly + // this should be fine, since this is a display-only function (not used in transactions) + // which is used on dollar (not token) amounts + assert.NotEqual( + 123456789123456789123456789.123456789, + neat(sdk.MustNewDecFromStr("123456789123456789123456789.123456789")), + ) } diff --git a/x/leverage/keeper/limits.go b/x/leverage/keeper/limits.go index 5b0e28185e..16cb9030ba 100644 --- a/x/leverage/keeper/limits.go +++ b/x/leverage/keeper/limits.go @@ -7,9 +7,9 @@ import ( ) // userMaxWithdraw calculates the maximum amount of uTokens an account can currently withdraw and the amount of -// these uTokens is non-collateral. Input denom should be a base token. If oracle prices are missing for some of the -// borrower's collateral (other than the denom being withdrawn), computes the maximum safe withdraw allowed by only -// the collateral whose prices are known. +// these uTokens which are non-collateral. Input denom should be a base token. If oracle prices are missing for +// some of the borrower's collateral (other than the denom being withdrawn), computes the maximum safe withdraw +// allowed by only the collateral whose prices are known. func (k *Keeper) userMaxWithdraw(ctx sdk.Context, addr sdk.AccAddress, denom string) (sdk.Coin, sdk.Coin, error) { uDenom := types.ToUTokenDenom(denom) availableTokens := sdk.NewCoin(denom, k.AvailableLiquidity(ctx, denom)) @@ -37,6 +37,27 @@ func (k *Keeper) userMaxWithdraw(ctx sdk.Context, addr sdk.AccAddress, denom str return sdk.NewCoin(uDenom, withdrawAmount), sdk.NewCoin(uDenom, walletUtokens), nil } + // calculate collateral value for the account, using the lower of spot or historic prices for each token + // will count collateral with missing prices as zero value without returning an error + collateralValue, err := k.VisibleCollateralValue(ctx, totalCollateral, types.PriceModeLow) + if err != nil { + // for errors besides a missing price, the whole transaction fails + return sdk.Coin{}, sdk.Coin{}, err + } + + // calculate weighted borrowed value - used by the borrow factor limit + weightedBorrowValue, err := k.ValueWithBorrowFactor(ctx, totalBorrowed, types.PriceModeHigh) + if nonOracleError(err) { + // for errors besides a missing price, the whole transaction fails + return sdk.Coin{}, sdk.Coin{}, err + } + if err != nil { + // for missing prices on borrowed assets, we can't withdraw any collateral + // but can withdraw non-collateral uTokens + withdrawAmount := sdk.MinInt(walletUtokens, availableUTokens.Amount) + return sdk.NewCoin(uDenom, withdrawAmount), sdk.NewCoin(uDenom, walletUtokens), nil + } + // if no non-blacklisted tokens are borrowed, withdraw the maximum available amount if borrowedValue.IsZero() { withdrawAmount := walletUtokens.Add(unbondedCollateral.Amount) @@ -46,15 +67,24 @@ func (k *Keeper) userMaxWithdraw(ctx sdk.Context, addr sdk.AccAddress, denom str // compute the borrower's borrow limit using all their collateral // except the denom being withdrawn (also excluding collateral missing oracle prices) - otherBorrowLimit, err := k.VisibleBorrowLimit(ctx, otherCollateral) + otherCollateralBorrowLimit, err := k.VisibleBorrowLimit(ctx, otherCollateral) if err != nil { return sdk.Coin{}, sdk.Coin{}, err } // if their other collateral fully covers all borrows, withdraw the maximum available amount - if borrowedValue.LT(otherBorrowLimit) { - withdrawAmount := walletUtokens.Add(unbondedCollateral.Amount) - withdrawAmount = sdk.MinInt(withdrawAmount, availableUTokens.Amount) - return sdk.NewCoin(uDenom, withdrawAmount), sdk.NewCoin(uDenom, walletUtokens), nil + if borrowedValue.LT(otherCollateralBorrowLimit) { + // also check collateral value vs weighted borrow (borrow factor limit) + otherCollateralValue, err := k.VisibleCollateralValue(ctx, otherCollateral, types.PriceModeLow) + if err != nil { + return sdk.Coin{}, sdk.Coin{}, err + } + // if weighted borrow does not exceed other collateral value, this collateral can be fully withdrawn + if otherCollateralValue.GTE(weightedBorrowValue) { + // in this case, both borrow limits will not be exceeded even if all collateral is withdrawn + withdrawAmount := walletUtokens.Add(unbondedCollateral.Amount) + withdrawAmount = sdk.MinInt(withdrawAmount, availableUTokens.Amount) + return sdk.NewCoin(uDenom, withdrawAmount), sdk.NewCoin(uDenom, walletUtokens), nil + } } // for nonzero borrows, calculations are based on unused borrow limit @@ -64,8 +94,8 @@ func (k *Keeper) userMaxWithdraw(ctx sdk.Context, addr sdk.AccAddress, denom str if err != nil { return sdk.Coin{}, sdk.Coin{}, err } - // borrowers above their borrow limit cannot withdraw collateral, but can withdraw wallet uTokens - if borrowLimit.LTE(borrowedValue) { + // borrowers above either of their borrow limits cannot withdraw collateral, but can withdraw wallet uTokens + if borrowLimit.LTE(borrowedValue) || collateralValue.LTE(weightedBorrowValue) { withdrawAmount := sdk.MinInt(walletUtokens, availableUTokens.Amount) return sdk.NewCoin(uDenom, withdrawAmount), sdk.NewCoin(uDenom, walletUtokens), nil } @@ -81,8 +111,19 @@ func (k *Keeper) userMaxWithdraw(ctx sdk.Context, addr sdk.AccAddress, denom str return sdk.Coin{}, sdk.Coin{}, err } - // if only a portion of collateral is unused, withdraw only that portion + // if only a portion of collateral is unused, withdraw only that portion (regular borrow limit) unusedCollateralFraction := unusedBorrowLimit.Quo(specificBorrowLimit) + + // calculate value of this collateral specifically, which is used in borrow factor's borrow limit + specificCollateralValue, err := k.CalculateCollateralValue(ctx, sdk.NewCoins(thisCollateral), types.PriceModeLow) + if err != nil { + return sdk.Coin{}, sdk.Coin{}, err + } + unusedCollateralValue := collateralValue.Sub(weightedBorrowValue) + // Find the more restrictive of either borrow factor limit or borrow limit + unusedCollateralFraction = sdk.MinDec(unusedCollateralFraction, unusedCollateralValue.Quo(specificCollateralValue)) + + // Both borrow limits are satisfied by this withdrawal amount. The restrictions below relate to neither. unusedCollateral := unusedCollateralFraction.MulInt(thisCollateral.Amount).TruncateInt() // find the minimum of unused collateral (due to borrows) or unbonded collateral (incentive module) @@ -101,11 +142,16 @@ func (k *Keeper) userMaxWithdraw(ctx sdk.Context, addr sdk.AccAddress, denom str // userMaxBorrow calculates the maximum amount of a given token an account can currently borrow. // input denom should be a base token. If oracle prices are missing for some of the borrower's -// collateral, computes the maximum safe borrow allowed by only the collateral whose prices are known +// collateral, computes the maximum safe borrow allowed by only the collateral whose prices are known. func (k *Keeper) userMaxBorrow(ctx sdk.Context, addr sdk.AccAddress, denom string) (sdk.Coin, error) { if types.HasUTokenPrefix(denom) { return sdk.Coin{}, types.ErrUToken } + token, err := k.GetTokenSettings(ctx, denom) + if err != nil { + return sdk.Coin{}, err + } + availableTokens := k.AvailableLiquidity(ctx, denom) totalBorrowed := k.GetBorrowerBorrows(ctx, addr) @@ -122,6 +168,17 @@ func (k *Keeper) userMaxBorrow(ctx sdk.Context, addr sdk.AccAddress, denom strin return sdk.NewCoin(denom, sdk.ZeroInt()), nil } + // calculate weighted borrowed value for the account, using the higher of spot or historic prices + weightedBorrowedValue, err := k.ValueWithBorrowFactor(ctx, totalBorrowed, types.PriceModeHigh) + if nonOracleError(err) { + // non-oracle errors fail the transaction (or query) + return sdk.Coin{}, err + } + if err != nil { + // oracle errors cause max borrow to be zero + return sdk.NewCoin(denom, sdk.ZeroInt()), nil + } + // calculate borrow limit for the account, using only collateral whose price is known borrowLimit, err := k.VisibleBorrowLimit(ctx, totalCollateral) if err != nil { @@ -132,11 +189,27 @@ func (k *Keeper) userMaxBorrow(ctx sdk.Context, addr sdk.AccAddress, denom strin return sdk.NewCoin(denom, sdk.ZeroInt()), nil } + // calculate collateral value limit for the account, using only collateral whose price is known + collateralValue, err := k.VisibleCollateralValue(ctx, totalCollateral, types.PriceModeLow) + if err != nil { + return sdk.Coin{}, err + } + // borrowers above their borrow factor borrow limit cannot borrow + if collateralValue.LTE(weightedBorrowedValue) { + return sdk.NewCoin(denom, sdk.ZeroInt()), nil + } + // determine the USD amount of borrow limit that is currently unused unusedBorrowLimit := borrowLimit.Sub(borrowedValue) + // determine the USD amount that can be borrowed according to borrow factor limit + maxBorrowValueIncrease := collateralValue.Sub(weightedBorrowedValue).Quo(token.BorrowFactor()) + + // finds the most restrictive of regular borrow limit and borrow factor limit + valueToBorrow := sdk.MinDec(unusedBorrowLimit, maxBorrowValueIncrease) + // determine max borrow, using the higher of spot or historic prices for the token to borrow - maxBorrow, err := k.TokenWithValue(ctx, denom, unusedBorrowLimit, types.PriceModeHigh) + maxBorrow, err := k.TokenWithValue(ctx, denom, valueToBorrow, types.PriceModeHigh) if nonOracleError(err) { // non-oracle errors fail the transaction (or query) return sdk.Coin{}, err @@ -177,7 +250,11 @@ func (k *Keeper) maxCollateralFromShare(ctx sdk.Context, denom string) (sdkmath. thisDenomCollateral := sdk.NewCoin(denom, systemCollateral.AmountOf(denom)) // get USD collateral value for all other denoms, skipping those which are missing oracle prices - otherDenomsValue, err := k.VisibleCollateralValue(ctx, systemCollateral.Sub(thisDenomCollateral)) + otherDenomsValue, err := k.VisibleCollateralValue( + ctx, + systemCollateral.Sub(thisDenomCollateral), + types.PriceModeSpot, + ) if err != nil { return sdk.ZeroInt(), err } diff --git a/x/leverage/keeper/liquidate.go b/x/leverage/keeper/liquidate.go index 54100681e7..c346fec0e5 100644 --- a/x/leverage/keeper/liquidate.go +++ b/x/leverage/keeper/liquidate.go @@ -33,7 +33,7 @@ func (k Keeper) getLiquidationAmounts( if err != nil { return sdk.Coin{}, sdk.Coin{}, sdk.Coin{}, err } - collateralValue, err := k.CalculateCollateralValue(ctx, borrowerCollateral) + collateralValue, err := k.CalculateCollateralValue(ctx, borrowerCollateral, types.PriceModeSpot) if err != nil { return sdk.Coin{}, sdk.Coin{}, sdk.Coin{}, err } diff --git a/x/leverage/keeper/msg_server_test.go b/x/leverage/keeper/msg_server_test.go index 628a4b343c..859e73995a 100644 --- a/x/leverage/keeper/msg_server_test.go +++ b/x/leverage/keeper/msg_server_test.go @@ -98,7 +98,7 @@ func (s *IntegrationTestSuite) TestAddTokensToRegistry() { s.Require().NoError(err) // no tokens should have been deleted tokens := s.app.LeverageKeeper.GetAllRegisteredTokens(s.ctx) - s.Require().Len(tokens, 6) + s.Require().Len(tokens, 7) token, err := s.app.LeverageKeeper.GetTokenSettings(s.ctx, ntA.BaseDenom) s.Require().NoError(err) @@ -170,7 +170,7 @@ func (s *IntegrationTestSuite) TestUpdateRegistry() { s.Require().NoError(err) // no tokens should have been deleted tokens := s.app.LeverageKeeper.GetAllRegisteredTokens(s.ctx) - s.Require().Len(tokens, 5) + s.Require().Len(tokens, 6) token, err := s.app.LeverageKeeper.GetTokenSettings(s.ctx, "uumee") s.Require().NoError(err) @@ -350,6 +350,13 @@ func (s *IntegrationTestSuite) TestMsgWithdraw() { // borrowed value is $10 (current) or $5 (historic) // collateral weights are always 0.25 in testing + // create an UMEE borrower using STABLE collateral + stableUmeeBorrower := s.newAccount(coin.New(stableDenom, 100_000000)) + s.supply(stableUmeeBorrower, coin.New(stableDenom, 100_000000)) + s.collateralize(stableUmeeBorrower, coin.New("u/"+stableDenom, 100_000000)) + s.borrow(stableUmeeBorrower, coin.New(umeeDenom, 30_000000)) + // UMEE and STABLE have the same price but different collateral weights + tcs := []struct { msg string addr sdk.AccAddress @@ -455,6 +462,14 @@ func (s *IntegrationTestSuite) TestMsgWithdraw() { nil, sdk.Coin{}, types.ErrUndercollaterized, + }, { + "borrow limit (undercollateralized due to borrow factor but not collateral weight)", + stableUmeeBorrower, + coin.New("u/"+stableDenom, 50_000000), + nil, + nil, + sdk.Coin{}, + types.ErrUndercollaterized, }, } @@ -555,6 +570,13 @@ func (s *IntegrationTestSuite) TestMsgMaxWithdraw() { // borrowed value is $10 (current) or $5 (historic) // collateral weights are always 0.25 in testing + // create an UMEE borrower using STABLE collateral + stableUmeeBorrower := s.newAccount(coin.New(stableDenom, 100_000000)) + s.supply(stableUmeeBorrower, coin.New(stableDenom, 100_000000)) + s.collateralize(stableUmeeBorrower, coin.New("u/"+stableDenom, 100_000000)) + s.borrow(stableUmeeBorrower, coin.New(umeeDenom, 30_000000)) + // UMEE and STABLE have the same price but different collateral weights + zeroUmee := coin.Zero(umeeDenom) zeroUUmee := coin.New("u/"+umeeDenom, 0) tcs := []struct { @@ -574,7 +596,8 @@ func (s *IntegrationTestSuite) TestMsgMaxWithdraw() { sdk.Coin{}, sdk.Coin{}, types.ErrNotRegisteredToken, - }, { + }, + { "can't borrow uToken", supplier, "u/" + umeeDenom, @@ -582,7 +605,8 @@ func (s *IntegrationTestSuite) TestMsgMaxWithdraw() { sdk.Coin{}, sdk.Coin{}, types.ErrUToken, - }, { + }, + { "max withdraw umee", supplier, umeeDenom, @@ -590,7 +614,8 @@ func (s *IntegrationTestSuite) TestMsgMaxWithdraw() { coin.New("u/"+umeeDenom, 75_000000), coin.New(umeeDenom, 100_000000), nil, - }, { + }, + { "duplicate max withdraw umee", supplier, umeeDenom, @@ -598,7 +623,8 @@ func (s *IntegrationTestSuite) TestMsgMaxWithdraw() { zeroUUmee, zeroUmee, nil, - }, { + }, + { "max withdraw with borrow", other, umeeDenom, @@ -606,7 +632,8 @@ func (s *IntegrationTestSuite) TestMsgMaxWithdraw() { coin.New("u/"+umeeDenom, 60_000000), coin.New(umeeDenom, 60_000000), nil, - }, { + }, + { "max withdrawal (dump borrower)", dumpborrower, pumpDenom, @@ -614,7 +641,8 @@ func (s *IntegrationTestSuite) TestMsgMaxWithdraw() { coin.New("u/"+pumpDenom, 20_000000), coin.New(pumpDenom, 20_000000), nil, - }, { + }, + { "max withdrawal (pump borrower)", pumpborrower, dumpDenom, @@ -623,6 +651,15 @@ func (s *IntegrationTestSuite) TestMsgMaxWithdraw() { coin.New(dumpDenom, 20_000000), nil, }, + { + "max withdrawal (borrow factor 2 with stablecoin collateral)", + stableUmeeBorrower, + stableDenom, + coin.New("u/"+stableDenom, 40_000000), + coin.New("u/"+stableDenom, 40_000000), + coin.New(stableDenom, 40_000000), + nil, + }, } for _, tc := range tcs { @@ -1273,6 +1310,13 @@ func (s *IntegrationTestSuite) TestMsgBorrow() { // collateral value is $50 (current) or $100 (historic) // collateral weights are always 0.25 in testing + // create an UMEE borrower using STABLE collateral + stableUmeeBorrower := s.newAccount(coin.New(stableDenom, 40_000000)) + s.supply(stableUmeeBorrower, coin.New(stableDenom, 40_000000)) + s.collateralize(stableUmeeBorrower, coin.New("u/"+stableDenom, 40_000000)) + s.borrow(stableUmeeBorrower, coin.New(umeeDenom, 3_000000)) + // UMEE and STABLE have the same price but different collateral weights + tcs := []testCase{ { "uToken", @@ -1295,14 +1339,19 @@ func (s *IntegrationTestSuite) TestMsgBorrow() { coin.New(umeeDenom, 70_000000), nil, }, { - "additional borrow", - borrower, - coin.New(umeeDenom, 20_000000), + "stable umee borrower (acceptable)", + stableUmeeBorrower, + coin.New(umeeDenom, 17_000000), nil, + }, { + "stable umee borrower (borrow factor limit)", + stableUmeeBorrower, + coin.New(umeeDenom, 1_000000), + types.ErrUndercollaterized, }, { "max supply utilization", borrower, - coin.New(umeeDenom, 10_000000), + coin.New(umeeDenom, 9_000000), types.ErrMaxSupplyUtilization, }, { "atom borrow", @@ -1420,6 +1469,13 @@ func (s *IntegrationTestSuite) TestMsgMaxBorrow() { // collateral value is $50 (current) or $100 (historic) // collateral weights are always 0.25 in testing + // create an UMEE borrower using STABLE collateral + stableUmeeBorrower := s.newAccount(coin.New(stableDenom, 100_000000)) + s.supply(stableUmeeBorrower, coin.New(stableDenom, 100_000000)) + s.collateralize(stableUmeeBorrower, coin.New("u/"+stableDenom, 100_000000)) + s.borrow(stableUmeeBorrower, coin.New(umeeDenom, 30_000000)) + // UMEE and STABLE have the same price but different collateral weights + tcs := []struct { msg string addr sdk.AccAddress @@ -1461,6 +1517,11 @@ func (s *IntegrationTestSuite) TestMsgMaxBorrow() { pumpborrower, coin.New(pumpDenom, 6_250000), nil, + }, { + "stable umee borrower", + stableUmeeBorrower, + coin.New(umeeDenom, 20_000000), + nil, }, } diff --git a/x/leverage/keeper/oracle.go b/x/leverage/keeper/oracle.go index 3ce0aaf0a1..7b27072b2e 100644 --- a/x/leverage/keeper/oracle.go +++ b/x/leverage/keeper/oracle.go @@ -119,6 +119,29 @@ func (k Keeper) TotalTokenValue(ctx sdk.Context, coins sdk.Coins, mode types.Pri return total, nil } +// ValueWithBorrowFactor returns the total value of all input tokens, each multiplied +// by borrow factor (which is the minimum of 2.0 and 1/collateral weight). It +// ignores unregistered and blacklisted tokens instead of returning an error, but +// will error on unavailable prices. +func (k Keeper) ValueWithBorrowFactor(ctx sdk.Context, coins sdk.Coins, mode types.PriceMode) (sdk.Dec, error) { + total := sdk.ZeroDec() + + for _, c := range coins { + token, err := k.GetTokenSettings(ctx, c.Denom) + if err != nil { + continue + } + v, err := k.TokenValue(ctx, c, mode) + if err != nil { + return sdk.ZeroDec(), err + } + + total = total.Add(v.Mul(token.BorrowFactor())) + } + + return total, nil +} + // VisibleTokenValue functions like TotalTokenValue, but interprets missing oracle prices // as zero value instead of returning an error. func (k Keeper) VisibleTokenValue(ctx sdk.Context, coins sdk.Coins, mode types.PriceMode) (sdk.Dec, error) { @@ -139,6 +162,21 @@ func (k Keeper) VisibleTokenValue(ctx sdk.Context, coins sdk.Coins, mode types.P return total, nil } +// VisibleUTokensValue converts uTokens to tokens and calls VisibleTokenValue. Errors on non-uTokens. +func (k Keeper) VisibleUTokensValue(ctx sdk.Context, uTokens sdk.Coins, mode types.PriceMode) (sdk.Dec, error) { + tokens := sdk.NewCoins() + + for _, u := range uTokens { + t, err := k.ExchangeUToken(ctx, u) + if err != nil { + return sdk.ZeroDec(), err + } + tokens = tokens.Add(t) + } + + return k.VisibleTokenValue(ctx, tokens, mode) +} + // TokenWithValue creates a token of a given denom with an given USD value. // Returns an error on invalid price or denom. Rounds down, i.e. the // value of the token returned may be slightly less than the requested value. diff --git a/x/leverage/keeper/oracle_test.go b/x/leverage/keeper/oracle_test.go index d594617c25..14f0281552 100644 --- a/x/leverage/keeper/oracle_test.go +++ b/x/leverage/keeper/oracle_test.go @@ -62,18 +62,20 @@ func (m *mockOracleKeeper) Clear(denom string) { // 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"), - "ATOM": sdk.MustNewDecFromStr("39.38"), - "DAI": sdk.MustNewDecFromStr("1.00"), - "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 + "UMEE": sdk.MustNewDecFromStr("4.21"), + "ATOM": sdk.MustNewDecFromStr("39.38"), + "DAI": sdk.MustNewDecFromStr("1.00"), + "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 + "STABLE": sdk.MustNewDecFromStr("4.21"), // Same price as umee } m.historicExchangeRates = map[string]sdk.Dec{ - "UMEE": sdk.MustNewDecFromStr("4.21"), - "ATOM": sdk.MustNewDecFromStr("39.38"), - "DAI": sdk.MustNewDecFromStr("1.00"), - "DUMP": sdk.MustNewDecFromStr("1.00"), - "PUMP": sdk.MustNewDecFromStr("1.00"), + "UMEE": sdk.MustNewDecFromStr("4.21"), + "ATOM": sdk.MustNewDecFromStr("39.38"), + "DAI": sdk.MustNewDecFromStr("1.00"), + "DUMP": sdk.MustNewDecFromStr("1.00"), + "PUMP": sdk.MustNewDecFromStr("1.00"), + "STABLE": sdk.MustNewDecFromStr("4.21"), } } diff --git a/x/leverage/keeper/suite_test.go b/x/leverage/keeper/suite_test.go index 48426aa23c..d35bcba1f8 100644 --- a/x/leverage/keeper/suite_test.go +++ b/x/leverage/keeper/suite_test.go @@ -22,11 +22,12 @@ import ( ) const ( - umeeDenom = appparams.BondDenom - atomDenom = fixtures.AtomDenom - daiDenom = fixtures.DaiDenom - pumpDenom = "upump" - dumpDenom = "udump" + umeeDenom = appparams.BondDenom + atomDenom = fixtures.AtomDenom + daiDenom = fixtures.DaiDenom + pumpDenom = "upump" + dumpDenom = "udump" + stableDenom = "stable" ) type IntegrationTestSuite struct { @@ -82,6 +83,11 @@ func (s *IntegrationTestSuite) SetupTest() { // additional tokens for historacle testing require.NoError(app.LeverageKeeper.SetTokenSettings(ctx, newToken(dumpDenom, "DUMP", 6))) require.NoError(app.LeverageKeeper.SetTokenSettings(ctx, newToken(pumpDenom, "PUMP", 6))) + // additional tokens for borrow factor testing + stable := newToken(stableDenom, "STABLE", 6) + stable.CollateralWeight = sdk.MustNewDecFromStr("0.8") + stable.LiquidationThreshold = sdk.MustNewDecFromStr("0.9") + require.NoError(app.LeverageKeeper.SetTokenSettings(ctx, stable)) // override DefaultGenesis params with fixtures.Params app.LeverageKeeper.SetParams(ctx, fixtures.Params()) diff --git a/x/leverage/types/token.go b/x/leverage/types/token.go index ae27e696d5..8020ab7f61 100644 --- a/x/leverage/types/token.go +++ b/x/leverage/types/token.go @@ -15,6 +15,8 @@ const ( UTokenPrefix = "u/" ) +var halfDec = sdk.MustNewDecFromStr("0.5") + // HasUTokenPrefix detects the uToken prefix on a denom. func HasUTokenPrefix(denom string) bool { return strings.HasPrefix(denom, UTokenPrefix) @@ -167,6 +169,14 @@ func (t Token) AssertNotBlacklisted() error { return nil } +// BorrowFactor returns the minimum of 2.0 or 1 / collateralWeight. +func (t Token) BorrowFactor() sdk.Dec { + if t.CollateralWeight.LTE(halfDec) { + return sdk.MustNewDecFromStr("2.0") + } + return sdk.OneDec().Quo(t.CollateralWeight) +} + func defaultUmeeToken() Token { return Token{ BaseDenom: appparams.BondDenom,