Skip to content

Commit

Permalink
feat: add borrow factor (#2114)
Browse files Browse the repository at this point in the history
* cl++

* cl++

* rephrase test - sign changes based on cpu architecture so strict equal avoided

* implement borrow factor restriction

* fix new uToken calc

* Update x/leverage/keeper/oracle.go

* Update x/leverage/types/token.go

* revert

* revert

* implement borrow factor in userMaxBorrow

* implement userMaxBorrow + cause failing wasm test

* lint

* add denom to test suite registry

* max withdaw test

* withdraw and max withdraw tests

* borrow and maxborrow tests

* renaming suggestion

* halfDec

* rename

* README

* Update x/leverage/README.md

Co-authored-by: Robert Zaremba <robert@zaremba.ch>

* readme suggestions

---------

Co-authored-by: Robert Zaremba <robert@zaremba.ch>
  • Loading branch information
toteki and robert-zaremba committed Jun 28, 2023
1 parent a0d82e0 commit 4f5fad7
Show file tree
Hide file tree
Showing 15 changed files with 295 additions and 63 deletions.
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
16 changes: 16 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,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.
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)
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(
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())

// 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

0 comments on commit 4f5fad7

Please sign in to comment.