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: implement fast-liquidation #2106

Merged
merged 33 commits into from
Jun 26, 2023
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
c52a440
save progress pre-refactor
toteki May 30, 2023
feb7cc7
godoc--
toteki Jun 5, 2023
30e1450
proto comment update
toteki Jun 21, 2023
3d786da
cleanup PR = empty function
toteki Jun 21, 2023
94d4ad7
Merge branch 'main' into adam/flashliq
toteki Jun 21, 2023
b55226a
make proto-all
toteki Jun 21, 2023
3b6ea4d
rename to FastLiquidate
toteki Jun 22, 2023
ce7087b
reoute returns empty string
toteki Jun 22, 2023
98907d5
changelog
toteki Jun 22, 2023
ac66cce
lint
toteki Jun 22, 2023
39d6f64
Merge branch 'main' into adam/flash
toteki Jun 22, 2023
ec064aa
add safety param to assertBorrowHealth
toteki Jun 22, 2023
026ac53
comment++
toteki Jun 22, 2023
e0a4ef9
++
toteki Jun 22, 2023
e6d67aa
refactor: postLiquidate
toteki Jun 22, 2023
59bbfad
Merge branch 'main' into adam/flash
toteki Jun 22, 2023
214cd78
implement FastLiquidate msg and adjust rounding
toteki Jun 23, 2023
84e37ed
cli and lint
toteki Jun 23, 2023
280df29
tests
toteki Jun 23, 2023
ae5c74a
Merge branch 'main' into adam/flash
toteki Jun 23, 2023
6b4ad62
cli doc++
toteki Jun 23, 2023
db5e8a3
80% limit and doc
toteki Jun 23, 2023
7d71421
rename to LeveragedLiquidate
toteki Jun 23, 2023
f611d60
++
toteki Jun 23, 2023
f7091b6
Update proto/umee/leverage/v1/tx.proto
toteki Jun 23, 2023
af1c8b4
rename++
toteki Jun 23, 2023
6404b62
rename++
toteki Jun 23, 2023
e4c7779
lint and docs
toteki Jun 23, 2023
9bb1f13
lint
toteki Jun 23, 2023
e333cff
Merge branch 'main' into adam/flash
robert-zaremba Jun 26, 2023
3eaf13d
Update x/leverage/README.md
toteki Jun 26, 2023
aa48275
Update x/leverage/client/cli/tx.go
toteki Jun 26, 2023
1925266
Update x/leverage/README.md
toteki Jun 26, 2023
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
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
16 changes: 8 additions & 8 deletions proto/umee/leverage/v1/tx.proto
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,14 @@ 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.
toteki marked this conversation as resolved.
Show resolved Hide resolved
rpc FastLiquidate(MsgFastLiquidate) returns (MsgFastLiquidateResponse);
rpc LeveragedLiquidate(MsgLeveragedLiquidate) returns (MsgLeveragedLiquidateResponse);

// SupplyCollateral combines the Supply and Collateralize actions.
rpc SupplyCollateral(MsgSupplyCollateral) returns (MsgSupplyCollateralResponse);
Expand Down Expand Up @@ -149,15 +149,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 +226,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
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: "Borrow tokens to liquidate a borrower's debt and immediately collateralize the reward.",
toteki marked this conversation as resolved.
Show resolved Hide resolved
toteki marked this conversation as resolved.
Show resolved Hide resolved
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,
}
fastLiquidate := 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,
fastLiquidate,
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(
toteki marked this conversation as resolved.
Show resolved Hide resolved
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 {
toteki marked this conversation as resolved.
Show resolved Hide resolved
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)
toteki marked this conversation as resolved.
Show resolved Hide resolved
}
59 changes: 37 additions & 22 deletions x/leverage/keeper/liquidate.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ func (k Keeper) getLiquidationAmounts(
requestedRepay sdk.Coin,
rewardDenom string,
directLiquidation bool,
fastLiquidate bool,
toteki marked this conversation as resolved.
Show resolved Hide resolved
) (tokenRepay sdk.Coin, collateralLiquidate sdk.Coin, tokenReward sdk.Coin, err error) {
repayDenom := requestedRepay.Denom
collateralDenom := types.ToUTokenDenom(rewardDenom)

// get relevant liquidator, borrower, and module balances
borrowerCollateral := k.GetBorrowerCollateral(ctx, targetAddr)
totalBorrowed := k.GetBorrowerBorrows(ctx, targetAddr)
availableRepay := k.bankKeeper.SpendableCoins(ctx, liquidatorAddr).AmountOf(repayDenom)
repayDenomBorrowed := sdk.NewCoin(repayDenom, totalBorrowed.AmountOf(repayDenom))

// calculate borrower health in USD values, using spot prices only (no historic)
Expand Down Expand Up @@ -93,10 +93,15 @@ func (k Keeper) getLiquidationAmounts(
}

// max repayment amount is limited by a number of factors
maxRepay := requestedRepay.Amount // maximum allowed by liquidator
maxRepay = sdk.MinInt(maxRepay, availableRepay) // liquidator account balance
maxRepay = sdk.MinInt(maxRepay, totalBorrowed.AmountOf(repayDenom)) // borrower position
maxRepay = sdk.MinInt(maxRepay, maxRepayAfterCloseFactor) // close factor
maxRepay := totalBorrowed.AmountOf(repayDenom) // borrower position
if !fastLiquidate {
// for traditional liquidations, liquidator account balance limits repayment
availableRepay := k.bankKeeper.SpendableCoins(ctx, liquidatorAddr).AmountOf(repayDenom)
maxRepay = sdk.MinInt(maxRepay, availableRepay)
// maximum requested by liquidator
maxRepay = sdk.MinInt(maxRepay, requestedRepay.Amount)
}
maxRepay = sdk.MinInt(maxRepay, maxRepayAfterCloseFactor) // close factor

// compute final liquidation amounts
repay, burn, reward := ComputeLiquidation(
Expand Down Expand Up @@ -137,7 +142,6 @@ func ComputeLiquidation(
// Start with the maximum possible repayment amount, as a decimal
maxRepay := toDec(availableRepay)
// Determine the base maxReward amount that would result from maximum repayment

maxReward := maxRepay.Mul(priceRatio).Mul(sdk.OneDec().Add(liquidationIncentive))
// Determine the maxCollateral burn amount that corresponds to base reward amount
maxCollateral := maxReward.Quo(uTokenExchangeRate)
Expand All @@ -161,7 +165,9 @@ func ComputeLiquidation(
toDec(availableReward).Quo(maxReward),
)
// Catch edge cases
ratio = sdk.MaxDec(ratio, sdk.ZeroDec())
if !ratio.IsPositive() {
return sdk.ZeroInt(), sdk.ZeroInt(), sdk.ZeroInt()
}

// Reduce repay and collateral limits by the most severe limiting factor encountered
maxRepay = maxRepay.Mul(ratio)
Expand All @@ -175,16 +181,11 @@ func ComputeLiquidation(
// the module. It also ensures borrow dust is always eliminated when encountered.
tokenRepay = maxRepay.Ceil().RoundInt()

// Next, the amount of collateral uToken the borrower will lose is rounded down.
// This is favors the borrower over the liquidator, and also protects the module.
collateralBurn = maxCollateral.TruncateInt()

// One danger to rounding collateral burn down is that of collateral dust. This
// can be considered in two scenarios:
// 1) If collateral was the limiting factor above, then it will have already been
// an integer amount and truncating is a no-op.
// 2) If collateral was not the limiting factor, then there will be a non-dust
// quantity left over anyway.
// Next, the amount of collateral uToken the borrower will lose is rounded up.
// This also eliminates dust, but favors the liquidator over the module slightly.
// This is safe as long as the gas price of a transaction is greater than the value
// of the smallest possible unit of the collateral token.
collateralBurn = maxCollateral.Ceil().RoundInt()

// Finally, the base token reward amount is derived directly from the collateral
// to burn. This will round down identically to MsgWithdraw, favoring the module
Expand Down Expand Up @@ -215,11 +216,11 @@ func ComputeLiquidation(
// Finally, if borrowedValue is less than smallLiquidationSize,
// closeFactor will always be 1 as long as the borrower is eligible for liquidation.
func ComputeCloseFactor(
borrowedValue sdk.Dec,
collateralValue sdk.Dec,
liquidationThreshold sdk.Dec,
smallLiquidationSize sdk.Dec,
minimumCloseFactor sdk.Dec,
borrowedValue,
collateralValue,
liquidationThreshold,
smallLiquidationSize,
minimumCloseFactor,
completeLiquidationThreshold sdk.Dec,
) (closeFactor sdk.Dec) {
if borrowedValue.LT(liquidationThreshold) {
Expand Down Expand Up @@ -257,3 +258,17 @@ func ComputeCloseFactor(

return closeFactor
}

// postLiquidate flags any bad debt and informs incentive module of any changes to an account's
// uToken collateral after a liquidation has occurred.
func (k Keeper) postLiquidate(ctx sdk.Context, borrowerAddr sdk.AccAddress, uDenom string) error {
// if borrower's collateral has reached zero, mark any remaining borrows as bad debt
if err := k.checkBadDebt(ctx, borrowerAddr); err != nil {
return 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
return k.reduceBondTo(ctx, borrowerAddr, k.GetCollateral(ctx, borrowerAddr, uDenom))
}
Loading