Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add borrow factor #2114

Merged
merged 26 commits into from
Jun 28, 2023
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions x/leverage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -168,6 +169,20 @@ 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`.

When a user is borrowing, their borrow limit is whichever is more restrictive of these two rules:
toteki marked this conversation as resolved.
Show resolved Hide resolved

- Borrowed value must be less than collateral value times the weighted average of collateral assets' `CollateralWeight`
toteki marked this conversation as resolved.
Show resolved Hide resolved
- Borrowed value times the weighted average of borrowed assets' `BorrowFactor` 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.
Expand Down
27 changes: 22 additions & 5 deletions x/leverage/keeper/borrows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
robert-zaremba marked this conversation as resolved.
Show resolved Hide resolved
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
}

Expand Down
18 changes: 9 additions & 9 deletions x/leverage/keeper/collateral.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -86,23 +86,23 @@ 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)
}

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.
// 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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion x/leverage/keeper/grpc_query.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion x/leverage/keeper/grpc_query_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion x/leverage/keeper/inspector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
12 changes: 8 additions & 4 deletions x/leverage/keeper/inspector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
toteki marked this conversation as resolved.
Show resolved Hide resolved
123456789123456789123456789.123456789,
neat(sdk.MustNewDecFromStr("123456789123456789123456789.123456789")),
)
}
105 changes: 91 additions & 14 deletions x/leverage/keeper/limits.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
}
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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 {
Expand All @@ -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())
robert-zaremba marked this conversation as resolved.
Show resolved Hide resolved

// 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
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion x/leverage/keeper/liquidate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading