Skip to content

Commit

Permalink
feat: special asset pairs full implementation (#2178)
Browse files Browse the repository at this point in the history
* isolate AccountPosition from #2164

* start test file

* create wide range of position testing assets, special pairs

* package change

* ++

* ++

* ++fix

* ++

* ++fix

* ++

* ++

* ++

* ++

* ++

* ++fix

* ++

* ++scenario

* ++ EXAMPLES.md

* fix merge

* sorting of pairs breaks ties using denom (alphabetical)

* maxBorrow step 1: surplus collateral

* ++

* ++

* ++

* ++

* ++

* ++

* ++

* ++validate

* ++

* max borrow can displace ordinary borrows

* ++

* todo comments

* todo comments

* --

* --err

* ++

* ++

* ++

* ++naive maxBorrow

* ++

* ++

* ++fix

* ++f

* ++

* ++

* lint

* ++

* ++

* ++

* ++

* ++

* ++

* ++

* ++

* ++ todo comments

* ++ todo comments

* sort function

* ++

* misc

* finish implementing MaxWithdraw

* --

* apply new borrow limit to assertBorrowerHealth

* switch module to new functions

* move maxWithdraw and maxBorrow over

* fix one price outage issue

* fix another price outage issue

* fix client test

* ++

* ++

* ++

* ++

* ++

* ++

* ++

* finish

* fix the lint and build issue

* fix the tests

* fix the markdown lint

---------

Co-authored-by: Sai Kumar <[email protected]>
  • Loading branch information
toteki and gsk967 authored Aug 11, 2023
1 parent 3aa69be commit 52106f7
Show file tree
Hide file tree
Showing 18 changed files with 2,267 additions and 433 deletions.
2 changes: 1 addition & 1 deletion util/coin/coin.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func Zero(denom string) sdk.Coin {
return sdk.NewInt64Coin(denom, 0)
}

// Zero returns new coin with zero amount
// ZeroDec returns new decCoin with zero amount
func ZeroDec(denom string) sdk.DecCoin {
return sdk.NewInt64DecCoin(denom, 0)
}
Expand Down
2 changes: 1 addition & 1 deletion util/coin/fixtures.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func UmeeDec(amount string) sdk.DecCoin {
return Dec(appparams.BondDenom, amount)
}

// Utoken creates a uToken DecCoin.
// Utoken creates a uToken Coin.
func Utoken(denom string, amount int64) sdk.Coin {
return New(ToUTokenDenom(denom), amount)
}
Expand Down
113 changes: 113 additions & 0 deletions x/leverage/EXAMPLES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Extended Example Scenarios

This is a collection of example scenarios which were too large for the [README](./README.md).

Some of them have also been reproduced as unit tests in the leverage module.

## Contents

1. **[Max Borrow Scenario A](#max-borrow-scenario-a)**

## Max Borrow Scenario A

Assume the following collateral weights:

| Asset | Collateral Weight |
| ----- | ----------------- |
| A | 0.4 |
| B | 0.3 |
| C | 0.2 |
| D | 0.1 |

And the following bidirectional special asset pairs:

| Assets | Special Weight |
| ------- | -------------- |
| A <-> B | 0.5 |
| A <-> C | 0.4 |

Start with a borrow with the following position:

| Collateral | Borrowed |
| --------------- | --------------------- |
| $100 A + $300 D | $20 B + $20 C + $20 D |

Once special asset pairs are taken into account, the position Behaves As:

| Collateral | Borrow | Weight |
| ---------- | ------ | ------------------- |
| $40 A | $20 B | 0.5 (special) |
| $50 A | $20 C | 0.4 (special) |
| $10 A | $1 D | 0.1 = min(0.1, 0.4) |
| $40 D | $4 D | 0.1 |
| $260 D | - | 0.1 |

Note that the position is arranged above such that an asset prefers to be in the highest row it can occupy (hence the unused collateral at the bottom, as all borrows filled in from the top).
It also reflects an order of "special pairs then regular assets; both categories sorted by collateral weight from highest to lowest" to maximize efficiency.

Suppose I wish to compute `MaxBorrow(B)` on this position.
Naively, I would simply see how much `B` can be borrowed by the unused collateral `D` at the bottom row.
This would consume `$260 D` collateral at a weight of `0.1` for a max borrow of `$26`.
However, this actually underestimates the max borrow amount because asset `B` qualifies for a special asset pair.

What will actually happen, is any newly borrowed `B` will be paired with collateral `A` in the highest priority special asset pair (and also any collateral `A` that is floating around in regular assets) before being matched with leftover collateral.
First it will take from the `$10 A` sitting in normal assets (and displace the `$1 D` which was being covered by that collateral onto the unused collateral at the bottom).
If the `$1 D` could only be partially moved due to a limited amount of unused collateral, we would compute the amount of `A` collateral that would be freed up, and the resulting size of the `B` max borrow, and return there.
(This logic is a recursive `MaxWithdraw(A)`)

Position after first displacement of collateral `A`:

| Collateral | Borrow | Weight | Change in Position |
| ---------- | ------ | ----------------- | ------------------ |
| $50 A | $25 B | 0.5 (special) | +$10 A +$5 B |
| $50 A | $20 C | 0.4 (special) | |
| $0 A | $0 D | - | -$10 A -$1 D |
| $50 D | $5 D | 0.1 | +$100 D +$1 D |
| $250 D | - | 0.1 | -$100 D |

But there is still unused collateral available after the step above, so the `B` looks for any more collateral `A` that can be moved to the topmost special pair.
This time, it takes collateral `A` from the special pair `($50 A, $20 C)` since that pair has lower weight.
Again, this displaces borrowed `C` which must find a new row to land in.
The displaced `C` first looks for lower weight special asset pairs that allow borrowed `C` (and finds none), then attempts to insert itself into the ordinary asset rows.
Since `C` has a higher collateral weight than `D`, it displaces all borrowed `D` to lower rows.
If the displacement fills all unused collateral before completing, returns with the amount of newly borrowed `B`.
This is still part of the recursive `MaxWithdraw(A)` mentioned above, since it is matching existing collateral `A` with new borrowed `B`, effectively withdrawing the `A` from the rest of the position.

Position after second displacement of collateral `A`:

| Collateral | Borrow | Weight | Change in Position |
| ---------- | ------ | ----------------- | ------------------ |
| $100 A | $50 B | 0.5 (special) | +$50A +$25B |
| $0 A | $0 C | - (special) | -$50A -$20C |
| $200 D | $20 C | 0.1 | +$200D +$20C |
| $50 D | $5 D | 0.1 | |
| $50 D | - | 0.1 | -$100 D |

Note that the `(A,C, 0.4)` special pair which was used is now unused, as its collateral was moved to the more efficient pair `(A,B,0.5)`.
There is still a little left over collateral `D`, so with all the special pairs dealt with, the ordinary assets can be settled.
Due to the rule "borrowed assets are listed by collateral weight, descending" any remaining borrow `B` will insert itself below rows containing borrowed `A`, but above any rows containing borrowed `C` or `D`.
This functions as a recursive `MaxWithdraw(D)` from this example position since only `D` collateral is being affected.
Position after final displacement of collateral `D`:

| Collateral | Borrow | Weight | Change in Position |
| ---------- | ------ | ----------------- | ------------------ |
| $100 A | $50 B | 0.5 (special) | |
| $50 D | $5 B | 0.1 | +$50D +$5B |
| $200 D | $20 C | 0.1 | |
| $50 D | $5 D | 0.1 | |
| $0 D | - | 0.1 | -$50D |

The position in the table above can be found at `TestMaxBorrowScenarioA` in the [unit tests](./types/documented_test.go).

Since this position had only `D` collateral under ordinary assets, the displacement is simple.
In a mixed position, borrows are actually being bumped down one row at a time until the bottom row (unused collateral) has been filled up.

Overall Result:

- Initial displacement of collateral A added $5B borrows
- Second displacement of collateral A added $25B borrows
- Final displacement of collateral D added $5B borrows

Therefore `MaxBorrow(B) = $25 + $5 + $5 = $35`.

This is greater than the naive estimate of `$26` from the start of the example.
28 changes: 7 additions & 21 deletions x/leverage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,13 +200,14 @@ For example, an account using a single collateral token with `CollateralWeight 0

The leverage module can define pairs of assets which are advantaged when one is used as collateral to borrow the other.

They are defined in the form `[Asset A, Asset B, Special Collateral Weight]`. In effect, this means that
They are defined in the form `[Asset A, Asset B, Special Collateral Weight, Special Liquidation Threshold]`. In effect, this means that

> When a user has collateral of `Asset A` and borrows `Asset B`, or vice versa, the `CollateralWeight` of both `Asset A` and `Asset B` are replaced by `Special Collateral Weight`.
> When a user has collateral of `Asset A` and borrows `Asset B`, or vice versa, the `CollateralWeight` of both `Asset A` and `Asset B` are replaced by `Special Collateral Weight`. The `LiquidationThreshold` of the assets is also replaced by that of the special pair.
#### Special Asset Pair Examples

> Consider a scenario where assets `A,B,C,D` all have collateral weight `0.75`. There is also a special asset pair `[A,B,0.9]` which privileges borrows between those two assets.
> (Note: Liquidation threshold has been omitted from the special pair in this example.)
>
> A user with `Collateral: $10A, Borrowed: $7A` is unaffected by any special asset pairs. The maximum `A` it could borrow is `$7.50`
>
Expand Down Expand Up @@ -295,10 +296,11 @@ It will abort and return zero if all collateral is in use.

#### Liquidation Threshold

Any user whose borrow value is above their liquidation threshold is eligible to be liquidated.

Each token in the `Token Registry` has a parameter called `LiquidationThreshold`, always greater than or equal to collateral weight, but less than 1, which determines the portion of the token's value that goes towards a borrower's liquidation threshold when the token is used as collateral.

When a borrow position is limited by simple borrow limit (without special asset pairs or borrow factor), a user's liquidation threshold is the sum of the contributions from each denomination of collateral they have deposited.
Any user whose borrow value is above their liquidation threshold is eligible to be liquidated.
When a borrow position is limited by simple borrow limit (without special asset pairs or borrow factor), a user's liquidation threshold is the sum of the contributions from each denomination of collateral they have deposited:

```go
collateral := GetBorrowerCollateral(borrower) // sdk.Coins
Expand All @@ -308,23 +310,7 @@ Any user whose borrow value is above their liquidation threshold is eligible to
```

Liquidation threshold can also be reduced by borrow factor or increased by special asset pairs.
In practice, the following calculation (which reduces to the logic above in simple cases) is used for liquidation threshold:

```go
effectiveCollateralWeight := GetBorrowLimit(borrower) / GetCollateralValue(borrower) // ranges 0-1
liquidationThresholdScale := (1 - AvgLiquidationThreshold(borrower)) / (1 - AvgCollateralWeight(borrower)) // ranges 0-1
effectiveLiquidationThreshold := 1 - ((1 - liquidationThresholdScale) * (1 - effectiveCollateralWeight)) // ranges 0-1
liquidationThreshold := effectiveLiquidationThreshold * GetCollateralValue(borrower) // dollar value
```

This utilizes the borrow limit, which has already been computed with special asset pairs, and the token parameters of borrower's collateral:

- The average (weighted by collateral value) collateral weights and liquidation thresholds of the borrower's collateral assets are collected.
- The distances from average collateral weight to average liquidation threshold and 1 are compared. (For example when `CW = 0.6` and `LT = 0.7`, then liquidation threshold is `25%` of the way from `CW` to `1`.)
- Then the borrower's liquidation threshold behaves the same as the average parameters (e.g. it will be `25%` of the way between `borrow limit` and `collateral value`).

This formula ensures that the behavior of an account's liquidation threshold remains intuitive:
It will always be somewhere between the user's borrow limit and the full value of their collateral, and it behaves like the average of the dominant collateral tokens on their account.
When those are taken into account, the procedure for deriving a user's liquidation threshold is identical to the procedure for borrow limit, except `LiquidationThreshold` is used instead of `CollateralWeight` for individual tokens and for special pairs.

#### Borrow APY

Expand Down
151 changes: 11 additions & 140 deletions x/leverage/keeper/borrows.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,46 +8,26 @@ import (
)

// assertBorrowerHealth returns an error if a borrower is currently above their borrow limit,
// 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.
// under either recent (historic median) or current prices. 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.
// This should be checked in msg_server.go at the end of any transaction which is restricted
// by borrow limits, i.e. Borrow, Decollateralize, Withdraw, MaxWithdraw.
// by borrow limits, i.e. Borrow, Decollateralize, Withdraw, MaxWithdraw, LeveragedLiquidate.
// MaxUsage sets the maximum percent of a user's borrow limit that can be in use: set to 1
// to allow up to 100% borrow limit, or a lower value (e.g. 0.9) if a transaction should fail
// if a safety margin is desired (e.g. <90% borrow limit).
func (k Keeper) assertBorrowerHealth(ctx sdk.Context, borrowerAddr sdk.AccAddress, maxUsage sdk.Dec) error {
borrowed := k.GetBorrowerBorrows(ctx, borrowerAddr)
collateral := k.GetBorrowerCollateral(ctx, borrowerAddr)

// check health using collateral weight
borrowValue, err := k.TotalTokenValue(ctx, borrowed, types.PriceModeHigh)
if err != nil {
return err
}
borrowLimit, err := k.VisibleBorrowLimit(ctx, collateral)
if err != nil {
return err
}
if borrowValue.GT(borrowLimit.Mul(maxUsage)) {
return types.ErrUndercollaterized.Wrapf(
"borrowed: %s, limit: %s, max usage %s", borrowValue, borrowLimit, maxUsage)
}

// check health using borrow factor
weightedBorrowValue, err := k.ValueWithBorrowFactor(ctx, borrowed, types.PriceModeHigh)
position, err := k.GetAccountPosition(ctx, borrowerAddr, false)
if err != nil {
return err
}
collateralValue, err := k.VisibleUTokensValue(ctx, collateral, types.PriceModeLow)
if err != nil {
return err
}
if weightedBorrowValue.GT(collateralValue.Mul(maxUsage)) {
borrowedValue := position.BorrowedValue()
borrowLimit := position.Limit()
if borrowedValue.GT(borrowLimit.Mul(maxUsage)) {
return types.ErrUndercollaterized.Wrapf(
"weighted borrow: %s, collateral value: %s, max usage %s", weightedBorrowValue, collateralValue, maxUsage)
"borrowed: %s, limit: %s, max usage %s", borrowedValue, borrowLimit, maxUsage,
)
}

return nil
Expand Down Expand Up @@ -125,115 +105,6 @@ func (k Keeper) SupplyUtilization(ctx sdk.Context, denom string) sdk.Dec {
return totalBorrowed.Quo(tokenSupply)
}

// CalculateBorrowLimit uses the price oracle to determine the borrow limit (in USD) provided by
// collateral sdk.Coins, using each token's uToken exchange rate and collateral weight.
// The lower of spot price or historic price is used for each collateral token.
// An error is returned if any input coins are not uTokens or if value calculation fails.
func (k Keeper) CalculateBorrowLimit(ctx sdk.Context, collateral sdk.Coins) (sdk.Dec, error) {
limit := sdk.ZeroDec()

for _, coin := range collateral {
// convert uToken collateral to base assets
baseAsset, err := k.ToToken(ctx, coin)
if err != nil {
return sdk.ZeroDec(), err
}

ts, err := k.GetTokenSettings(ctx, baseAsset.Denom)
if err != nil {
return sdk.ZeroDec(), err
}

// ignore blacklisted tokens
if !ts.Blacklist {
// get USD value of base assets using the chosen price mode
v, err := k.TokenValue(ctx, baseAsset, types.PriceModeLow)
if err != nil {
return sdk.ZeroDec(), err
}
// add each collateral coin's weighted value to borrow limit
limit = limit.Add(v.Mul(ts.CollateralWeight))
}
}

return limit, nil
}

// VisibleBorrowLimit uses the price oracle to determine the borrow limit (in USD) provided by
// collateral sdk.Coins, using each token's uToken exchange rate and collateral weight.
// The lower of spot price or historic price is used for each collateral token.
// An error is returned if any input coins are not uTokens.
// This function skips assets that are missing prices, which will lead to a lower borrow
// limit when prices are down instead of a complete loss of borrowing ability.
func (k Keeper) VisibleBorrowLimit(ctx sdk.Context, collateral sdk.Coins) (sdk.Dec, error) {
limit := sdk.ZeroDec()

for _, coin := range collateral {
// convert uToken collateral to base assets
baseAsset, err := k.ToToken(ctx, coin)
if err != nil {
return sdk.ZeroDec(), err
}

ts, err := k.GetTokenSettings(ctx, baseAsset.Denom)
if err != nil {
return sdk.ZeroDec(), err
}

// ignore blacklisted tokens
if !ts.Blacklist {
// get USD value of base assets using the chosen price mode
v, err := k.TokenValue(ctx, baseAsset, types.PriceModeLow)
if err == nil {
// if both spot and historic (if required) prices exist,
// add collateral coin's weighted value to borrow limit
limit = limit.Add(v.Mul(ts.CollateralWeight))
}
if nonOracleError(err) {
return sdk.ZeroDec(), err
}
}
}

return limit, nil
}

// CalculateLiquidationThreshold determines the maximum borrowed value (in USD) that a
// borrower with given collateral could reach before being eligible for liquidation, using
// each token's oracle price, uToken exchange rate, and liquidation threshold.
// An error is returned if any input coins are not uTokens or if value
// calculation fails. Always uses spot prices.
func (k Keeper) CalculateLiquidationThreshold(ctx sdk.Context, collateral sdk.Coins) (sdk.Dec, error) {
totalThreshold := sdk.ZeroDec()

for _, coin := range collateral {
// convert uToken collateral to base assets
baseAsset, err := k.ToToken(ctx, coin)
if err != nil {
return sdk.ZeroDec(), err
}

ts, err := k.GetTokenSettings(ctx, baseAsset.Denom)
if err != nil {
return sdk.ZeroDec(), err
}

// ignore blacklisted tokens
if !ts.Blacklist {
// get USD value of base assets
v, err := k.TokenValue(ctx, baseAsset, types.PriceModeSpot)
if err != nil {
return sdk.ZeroDec(), err
}

// add each collateral coin's weighted value to liquidation threshold
totalThreshold = totalThreshold.Add(v.Mul(ts.LiquidationThreshold))
}
}

return totalThreshold, nil
}

// checkSupplyUtilization returns the appropriate error if a token denom's
// supply utilization has exceeded MaxSupplyUtilization
func (k Keeper) checkSupplyUtilization(ctx sdk.Context, denom string) error {
Expand Down
Loading

0 comments on commit 52106f7

Please sign in to comment.