Skip to content

Commit

Permalink
feat: implement fast-liquidation (#2106)
Browse files Browse the repository at this point in the history
* save progress pre-refactor

* godoc--

* proto comment update

* cleanup PR = empty function

* make proto-all

* rename to FastLiquidate

* reoute returns empty string

* changelog

* lint

* add safety param to assertBorrowHealth

* comment++

* ++

* refactor: postLiquidate

* implement FastLiquidate msg and adjust rounding

* cli and lint

* tests

* cli doc++

* 80% limit and doc

* rename to LeveragedLiquidate

* ++

* Update proto/umee/leverage/v1/tx.proto

* rename++

* rename++

* lint and docs

* lint

* Update x/leverage/README.md

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

* Update x/leverage/client/cli/tx.go

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

* Update x/leverage/README.md

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

---------

Co-authored-by: Robert Zaremba <robert@zaremba.ch>
  • Loading branch information
toteki and robert-zaremba authored Jun 26, 2023
1 parent 0fc0a4d commit f899adf
Show file tree
Hide file tree
Showing 15 changed files with 607 additions and 219 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Ref: https://keepachangelog.com/en/1.0.0/

### Features

- [2192](https://github.com/umee-network/umee/pull/2102) Add `MsgFastLiquidate` 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.
- [2015](https://github.com/umee-network/umee/pull/2015), [2050](https://github.com/umee-network/umee/pull/2050) Add `x/ugov` module.
Expand Down
19 changes: 10 additions & 9 deletions proto/umee/leverage/v1/tx.proto
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,15 @@ service Msg {
// of the target's collateral.
rpc Liquidate(MsgLiquidate) returns (MsgLiquidateResponse);

// FastLiquidate allows a user to repay a different user's borrowed coins in exchange for some
// of the target's collateral. For flash liquidations, the tokens to repay are borrowed instead of
// LeveragedLiquidate allows a user to repay a different user's borrowed coins in exchange for some
// of the target's collateral. For leveraged liquidations, the tokens to repay are borrowed instead of
// being taken from the liquidator's wallet, and the reward is immediately collateralized. Borrow
// limit checks for the liquidator are deferred until after the reward is collateralized, allowing
// this initial borrow to exceed the liquidator's borrow limit as long as it is healthy by the end
// of the transaction. Repay amount is calculated automatically, so the liquidator only specifies
// repay and reward token denoms.
rpc FastLiquidate(MsgFastLiquidate) returns (MsgFastLiquidateResponse);
// repay and reward token denoms. For safety, the liquidator cannot exceed 80% of their borrow limit when
// executing this transaction, instead of the regular 100%.
rpc LeveragedLiquidate(MsgLeveragedLiquidate) returns (MsgLeveragedLiquidateResponse);

// SupplyCollateral combines the Supply and Collateralize actions.
rpc SupplyCollateral(MsgSupplyCollateral) returns (MsgSupplyCollateralResponse);
Expand Down Expand Up @@ -149,15 +150,15 @@ message MsgLiquidate {
string reward_denom = 4;
}

// MsgFastLiquidate is the request structure for the FastLiquidate RPC.
message MsgFastLiquidate {
// MsgLeveragedLiquidate is the request structure for the LeveragedLiquidate RPC.
message MsgLeveragedLiquidate {
// Liquidator is the account address performing a liquidation and the signer
// of the message.
string liquidator = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"];
// Borrower is the account whose borrow is being repaid, and collateral consumed,
// by the liquidation. It does not sign the message.
string borrower = 2 [(cosmos_proto.scalar) = "cosmos.AddressString"];
// RepayDenom is the base token that the liquidator will borrow and repay on behalf of
// RepayDenom is the base token that the liquidator will borrow in order to repay on behalf of
// the borrower.
string repay_denom = 3;
// RewardDenom is the uToken denom that the liquidator will receive as a liquidation reward
Expand Down Expand Up @@ -226,8 +227,8 @@ message MsgLiquidateResponse {
cosmos.base.v1beta1.Coin reward = 3 [(gogoproto.nullable) = false];
}

// MsgFastLiquidateResponse defines the Msg/FastLiquidate response type.
message MsgFastLiquidateResponse {
// MsgLeveragedLiquidateResponse defines the Msg/LeveragedLiquidate response type.
message MsgLeveragedLiquidateResponse {
// Repaid is the amount of base tokens that the liquidator borrowed and repaid
// to the module on behalf of the borrower.
cosmos.base.v1beta1.Coin repaid = 1 [(gogoproto.nullable) = false];
Expand Down
29 changes: 28 additions & 1 deletion x/leverage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,34 @@ umeed start

## Messages

See [leverage tx proto](https://github.com/umee-network/umee/blob/main/proto/umee/leverage/v1/tx.proto#L11) for list of supported messages.
See [leverage tx proto](https://github.com/umee-network/umee/blob/main/proto/umee/leverage/v1/tx.proto#L11) for full documentation of supported messages.

Here are their basic functions:

### Supplying

- `MsgSupply`: Supplies base tokens to the module and receives uTokens in exchange. UTokens can later be used to withdraw.
- `MsgWithdraw`: Exchanges uTokens for the base tokens originally supplied, plus interest. UTokens withdrawn can be any combination of wallet uTokens (from supply) or collateral uTokens. When withdrawing collateral, borrow limit cannot be exceeded or the withdrawal will fail.
- `MsgMaxWithdraw`: Withdraws the maximum allowed amount of uTokens, respecting the user's borrow limit and the module's liquidity requirements, if any.

### Collateralizing

- `MsgCollateralize`: Sends uTokens to the module as the collateral. Collateral increases a user's borrow limit, but can be siezed in a liquidation if borrowed value exceeds a certain threshold above borrow limit due to price movements or interest owed. Collateral tokens still earn supply interest while collateralized.
- `MsgSupplyCollateral`: Combines `MsgSupply` and `MsgCollateralize`.
- `MsgDecollateralize`: Returns some collateral uTokens to wallet balance, without withdrawing base tokens. Borrow limit cannot be exceeded or the decollateralize will fail.

### Borrowing

- `MsgBorrow` Borrows base tokens from the module. Borrow limit cannot be exceeded or the transaction will fail.
- `MsgRepay` Repays borrowed tokens to the module, plus interest owed.

### Liquidation

- `MsgLiquidate` Liquidates a borrower whose borrowed value has exceeded their liquidation threshold (which is a certain amount above their borrow limit). The liquidator repays a portion of their debt using base tokens, and receives uTokens from the target's collateral, or the equivalent base tokens. The maximum liquidation amount is restricted by both the liquidator's specified amount and the borrower's liquidation eligibility, which may be partial.
- `MsgLeveragedLiquidate` Liquidates a borrower, but instead of repaying with base tokens from the liquidator wallet balance, moves the debt from the borrower to the liquidator and creates a new borrow position for the liquidator. Liquidator receives uTokens from the bororwer's collateral, and immediately collateralizes them to secure the liquidator positoin.

This transaction will succeed even if the liquidator could not afford to borrow the initial tokens (thanks to the new collateral position acquired from the borrower), as long as they are below 80% usage of their new borrow limit after the reward collateral is added.
The liquidator is left with a new borrow that they must pay off, and new collateral which can eventually be withdrawn.

## Update Registry Proposal

Expand Down
50 changes: 50 additions & 0 deletions x/leverage/client/cli/tx.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ func GetTxCmd() *cobra.Command {
GetCmdMaxBorrow(),
GetCmdRepay(),
GetCmdLiquidate(),
GetCmdLeveragedLiquidate(),
GetCmdSupplyCollateral(),
)

Expand Down Expand Up @@ -316,6 +317,55 @@ $ umeed tx leverage liquidate %s 50000000uumee u/uumee --from mykey`,
return cmd
}

// GetCmdLeveragedLiquidate creates a Cobra command to generate or broadcast a
// transaction with a MsgLeveragedLiquidate message.
func GetCmdLeveragedLiquidate() *cobra.Command {
cmd := &cobra.Command{
Use: "lev-liquidate [borrower] [repay-denom] [reward-denom]",
Args: cobra.ExactArgs(3),
Short: "Liquidates by moving borrower debt to the liquidator and immediately collateralizes the reward.",
Long: strings.TrimSpace(
fmt.Sprintf(`
Borrow tokens to liquidate a borrower's debt and immediately collateralize the reward.
Will attempt to repay the maximum amount allowed by the targeted borrower's debt and collateral positions.
The transaction will fail if the liquidator, with new borrow and collateral positions, would be above 0.8 borrow limit.
Example:
$ umeed tx leverage lev-liquidate %s uumee uumee --from mykey`,
"umee1qqy7cst5qm83ldupph2dcq0wypprkfpc9l3jg2",
),
),

RunE: func(cmd *cobra.Command, args []string) error {
clientCtx, err := client.GetClientTxContext(cmd)
if err != nil {
return err
}

borrowerAddr, err := sdk.AccAddressFromBech32(args[0])
if err != nil {
return err
}

repayDenom := args[1]
rewardDenom := args[2]

msg := types.NewMsgLeveragedLiquidate(clientCtx.GetFromAddress(), borrowerAddr, repayDenom, rewardDenom)
if err = msg.ValidateBasic(); err != nil {
return err
}

return tx.GenerateOrBroadcastTxCLI(clientCtx, cmd.Flags(), msg)
},
}

flags.AddTxFlagsToCmd(cmd)

return cmd
}

// GetCmdSupplyCollateral creates a Cobra command to generate or broadcast a
// transaction with a MsgSupply message.
func GetCmdSupplyCollateral() *cobra.Command {
Expand Down
11 changes: 11 additions & 0 deletions x/leverage/client/tests/tests.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,16 @@ func (s *IntegrationTests) TestLeverageScenario() {
},
ExpectedErr: types.ErrLiquidationIneligible,
}
leveragedLiquidate := itestsuite.TestTransaction{
Name: "liquidate",
Command: cli.GetCmdLeveragedLiquidate(),
Args: []string{
val.Address.String(),
"uumee", // borrower attempts to liquidate itself, but is ineligible
"uumee",
},
ExpectedErr: types.ErrLiquidationIneligible,
}

repay := itestsuite.TestTransaction{
Name: "repay",
Expand Down Expand Up @@ -483,6 +493,7 @@ func (s *IntegrationTests) TestLeverageScenario() {
// These transactions run after nonzero queries are finished
s.RunTestTransactions(
liquidate,
leveragedLiquidate,
repay,
removeCollateral,
withdraw,
Expand Down
19 changes: 16 additions & 3 deletions x/leverage/keeper/borrows.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import (
// 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.
func (k Keeper) assertBorrowerHealth(ctx sdk.Context, borrowerAddr sdk.AccAddress) error {
// 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)

Expand All @@ -26,9 +29,9 @@ func (k Keeper) assertBorrowerHealth(ctx sdk.Context, borrowerAddr sdk.AccAddres
if err != nil {
return err
}
if value.GT(limit) {
if value.GT(limit.Mul(maxUsage)) {
return types.ErrUndercollaterized.Wrapf(
"borrowed: %s, limit: %s", value, limit)
"borrowed: %s, limit: %s, max usage %s", value, limit, maxUsage)
}
return nil
}
Expand All @@ -52,6 +55,16 @@ func (k Keeper) repayBorrow(ctx sdk.Context, fromAddr, borrowAddr sdk.AccAddress
return k.setBorrow(ctx, borrowAddr, k.GetBorrow(ctx, borrowAddr, repay.Denom).Sub(repay))
}

// moveBorrow transfers a debt from fromAddr to toAddr without moving any tokens. This occurs during
// fast liquidations, where a liquidator takes on a borrower's debt.
func (k Keeper) moveBorrow(ctx sdk.Context, fromAddr, toAddr sdk.AccAddress, repay sdk.Coin) error {
err := k.setBorrow(ctx, fromAddr, k.GetBorrow(ctx, fromAddr, repay.Denom).Sub(repay))
if err != nil {
return err
}
return k.setBorrow(ctx, toAddr, k.GetBorrow(ctx, toAddr, repay.Denom).Add(repay))
}

// setBorrow sets the amount borrowed by an address in a given denom.
// If the amount is zero, any stored value is cleared.
func (k Keeper) setBorrow(ctx sdk.Context, borrowerAddr sdk.AccAddress, borrow sdk.Coin) error {
Expand Down
10 changes: 10 additions & 0 deletions x/leverage/keeper/collateral.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ func (k Keeper) decollateralize(ctx sdk.Context, fromAddr, toAddr sdk.AccAddress
return k.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleName, toAddr, sdk.NewCoins(uToken))
}

// moveCollateral moves collateral from one address to another while keeping the uTokens in the module.
// It occurs during fast liquidations.
func (k Keeper) moveCollateral(ctx sdk.Context, fromAddr, toAddr sdk.AccAddress, uToken sdk.Coin) error {
err := k.setCollateral(ctx, fromAddr, k.GetCollateral(ctx, fromAddr, uToken.Denom).Sub(uToken))
if err != nil {
return err
}
return k.setCollateral(ctx, toAddr, k.GetCollateral(ctx, toAddr, uToken.Denom).Add(uToken))
}

// GetTotalCollateral returns an sdk.Coin representing how much of a given uToken
// the x/leverage module account currently holds as collateral. Non-uTokens return zero.
func (k Keeper) GetTotalCollateral(ctx sdk.Context, denom string) sdk.Coin {
Expand Down
56 changes: 42 additions & 14 deletions x/leverage/keeper/keeper.go
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ func (k Keeper) Liquidate(
requestedRepay,
rewardDenom,
directLiquidation,
false,
)
if err != nil {
return sdk.Coin{}, sdk.Coin{}, sdk.Coin{}, err
Expand All @@ -355,16 +356,8 @@ func (k Keeper) Liquidate(
return sdk.Coin{}, sdk.Coin{}, sdk.Coin{}, err
}

// if borrower's collateral has reached zero, mark any remaining borrows as bad debt
if err := k.checkBadDebt(ctx, borrowerAddr); err != nil {
return sdk.Coin{}, sdk.Coin{}, sdk.Coin{}, err
}

// finally, force incentive module to update bond and unbonding amounts if required,
// by ending existing unbondings early or instantly unbonding some bonded tokens
// until bonded + unbonding for the account is not greater than its collateral amount
err = k.reduceBondTo(ctx, borrowerAddr, k.GetCollateral(ctx, borrowerAddr, uTokenLiquidate.Denom))
if err != nil {
// check for bad debt and trigger forced unbond hooks
if err := k.postLiquidate(ctx, borrowerAddr, uTokenLiquidate.Denom); err != nil {
return sdk.Coin{}, sdk.Coin{}, sdk.Coin{}, err
}

Expand All @@ -375,9 +368,44 @@ func (k Keeper) Liquidate(
return tokenRepay, uTokenLiquidate, uTokenLiquidate, nil
}

// FastLiquidate
func (k Keeper) FastLiquidate(
_ sdk.Context, _, _ sdk.AccAddress, _, _ string,
// LeveragedLiquidate
func (k Keeper) LeveragedLiquidate(
ctx sdk.Context, liquidatorAddr, borrowerAddr sdk.AccAddress, repayDenom, rewardDenom string,
) (repaid sdk.Coin, reward sdk.Coin, err error) {
return sdk.Coin{}, sdk.Coin{}, err
if err := k.validateAcceptedDenom(ctx, repayDenom); err != nil {
return sdk.Coin{}, sdk.Coin{}, err
}
if err := k.validateAcceptedDenom(ctx, rewardDenom); err != nil {
return sdk.Coin{}, sdk.Coin{}, err
}
uRewardDenom := types.ToUTokenDenom(rewardDenom)

tokenRepay, uTokenReward, _, err := k.getLiquidationAmounts(
ctx,
liquidatorAddr,
borrowerAddr,
sdk.NewCoin(repayDenom, sdk.OneInt()), // amount is ignored for LeveragedLiquidate
rewardDenom,
false,
true,
)
if err != nil {
return sdk.Coin{}, sdk.Coin{}, err
}
if tokenRepay.IsZero() || uTokenReward.IsZero() {
return sdk.Coin{}, sdk.Coin{}, types.ErrLiquidationRepayZero
}

// directly move debt from borrower to liquidator without transferring any tokens between accounts
if err := k.moveBorrow(ctx, borrowerAddr, liquidatorAddr, tokenRepay); err != nil {
return sdk.Coin{}, sdk.Coin{}, err
}

// directly move collateral from borrower to liquidator while keeping it collateralized
if err := k.moveCollateral(ctx, borrowerAddr, liquidatorAddr, uTokenReward); err != nil {
return sdk.Coin{}, sdk.Coin{}, err
}

// check for bad debt and trigger forced unbond hooks
return tokenRepay, uTokenReward, k.postLiquidate(ctx, borrowerAddr, uRewardDenom)
}
Loading

0 comments on commit f899adf

Please sign in to comment.