From 3ea3148129573b74685e443770f48307fb6e2228 Mon Sep 17 00:00:00 2001 From: Denali Marsh Date: Thu, 5 Nov 2020 18:36:49 +0100 Subject: [PATCH] Harvest: multiple deposits (#711) * test suite: dynamic pricefeed genesis * multiple deposits * improve borrow validation * improve test, add multiple deposits test case * add over limit multiple deposit test case * explicit function names * rename outdated variable --- x/harvest/keeper/borrow.go | 46 +++++--- x/harvest/keeper/borrow_test.go | 160 ++++++++++++++++++++++----- x/harvest/keeper/integration_test.go | 50 --------- 3 files changed, 161 insertions(+), 95 deletions(-) delete mode 100644 x/harvest/keeper/integration_test.go diff --git a/x/harvest/keeper/borrow.go b/x/harvest/keeper/borrow.go index 6cffa4c1..ba039859 100644 --- a/x/harvest/keeper/borrow.go +++ b/x/harvest/keeper/borrow.go @@ -41,42 +41,39 @@ func (k Keeper) Borrow(ctx sdk.Context, borrower sdk.AccAddress, coins sdk.Coins // ValidateBorrow validates a borrow request against borrower and protocol requirements func (k Keeper) ValidateBorrow(ctx sdk.Context, borrower sdk.AccAddress, amount sdk.Coin) error { - moneyMarket, found := k.GetMoneyMarket(ctx, amount.Denom) - if !found { - sdkerrors.Wrapf(types.ErrMarketNotFound, "no market found for denom %s", amount.Denom) - } - assetPriceInfo, err := k.pricefeedKeeper.GetCurrentPrice(ctx, moneyMarket.SpotMarketID) + proprosedBorrowUSDValue, err := k.calculateUSDValue(ctx, amount.Amount, amount.Denom) if err != nil { - return sdkerrors.Wrapf(types.ErrPriceNotFound, "no price found for market %s", moneyMarket.SpotMarketID) + return err } - proprosedBorrowUSDValue := sdk.NewDecFromInt(amount.Amount).Quo(sdk.NewDecFromInt(moneyMarket.ConversionFactor).Mul(assetPriceInfo.Price)) // Get the total value of the user's deposits deposits := k.GetDepositsByUser(ctx, borrower) if len(deposits) == 0 { return sdkerrors.Wrapf(types.ErrDepositsNotFound, "no deposits found for %s", borrower) } - deposit := deposits[0] // TODO: Here we assume there's only one deposit. To be addressed in future cards. - depositUSDValue, err := k.calculateUSDValue(ctx, deposit.Amount.Amount, deposit.Amount.Denom) - if err != nil { - return err + totalBorrowableAmount := sdk.ZeroDec() + for _, deposit := range deposits { + borrowableAmountForDeposit, err := k.getBorrowableAmountForDeposit(ctx, deposit) + if err != nil { + return err + } + totalBorrowableAmount = totalBorrowableAmount.Add(borrowableAmountForDeposit) } - previousBorrowUSDValue := sdk.ZeroDec() + previousBorrowsUSDValue := sdk.ZeroDec() previousBorrows, found := k.GetBorrow(ctx, borrower) if found { // TODO: here we're assuming that the user only has 1 previous borrow. To be addressed in future cards. previousBorrow := previousBorrows.Amount[0] - previousBorrowUSDValue, err = k.calculateUSDValue(ctx, previousBorrow.Amount, previousBorrow.Denom) + previousBorrowUSDValue, err := k.calculateUSDValue(ctx, previousBorrow.Amount, previousBorrow.Denom) if err != nil { return err } + previousBorrowsUSDValue = previousBorrowsUSDValue.Add(previousBorrowUSDValue) } - // Value of borrow cannot be greater than: - // (total value of user's deposits * the borrow asset denom's LTV ratio) - funds already borrowed - borrowValueLimit := depositUSDValue.Mul(moneyMarket.BorrowLimit.LoanToValue).Sub(previousBorrowUSDValue) - if proprosedBorrowUSDValue.GT(borrowValueLimit) { + // Validate that the proposed borrow's USD value is within user's borrowable limit + if proprosedBorrowUSDValue.GT(totalBorrowableAmount.Sub(previousBorrowsUSDValue)) { return sdkerrors.Wrapf(types.ErrInsufficientLoanToValue, "requested borrow %s is greater than maximum valid borrow", amount) } return nil @@ -91,5 +88,18 @@ func (k Keeper) calculateUSDValue(ctx sdk.Context, amount sdk.Int, denom string) if err != nil { return sdk.ZeroDec(), sdkerrors.Wrapf(types.ErrPriceNotFound, "no price found for market %s", moneyMarket.SpotMarketID) } - return sdk.NewDecFromInt(amount).Quo(sdk.NewDecFromInt(moneyMarket.ConversionFactor).Mul(assetPriceInfo.Price)), nil + return sdk.NewDecFromInt(amount).Quo(sdk.NewDecFromInt(moneyMarket.ConversionFactor)).Mul(assetPriceInfo.Price), nil +} + +func (k Keeper) getBorrowableAmountForDeposit(ctx sdk.Context, deposit types.Deposit) (sdk.Dec, error) { + moneyMarket, found := k.GetMoneyMarket(ctx, deposit.Amount.Denom) + if !found { + return sdk.ZeroDec(), sdkerrors.Wrapf(types.ErrMarketNotFound, "no market found for denom %s", deposit.Amount.Denom) + } + assetPriceInfo, err := k.pricefeedKeeper.GetCurrentPrice(ctx, moneyMarket.SpotMarketID) + if err != nil { + return sdk.ZeroDec(), sdkerrors.Wrapf(types.ErrPriceNotFound, "no price found for market %s", moneyMarket.SpotMarketID) + } + usdValue := sdk.NewDecFromInt(deposit.Amount.Amount).Quo(sdk.NewDecFromInt(moneyMarket.ConversionFactor)).Mul(assetPriceInfo.Price) + return usdValue.Mul(moneyMarket.BorrowLimit.LoanToValue), nil } diff --git a/x/harvest/keeper/borrow_test.go b/x/harvest/keeper/borrow_test.go index 98ced961..9511e9aa 100644 --- a/x/harvest/keeper/borrow_test.go +++ b/x/harvest/keeper/borrow_test.go @@ -11,14 +11,25 @@ import ( "github.com/kava-labs/kava/app" "github.com/kava-labs/kava/x/harvest/types" + "github.com/kava-labs/kava/x/pricefeed" +) + +const ( + USDX_CF = 1000000 + KAVA_CF = 1000000 + BTCB_CF = 100000000 ) func (suite *KeeperTestSuite) TestBorrow() { + type args struct { + priceKAVA sdk.Dec + loanToValueKAVA sdk.Dec + priceBTCB sdk.Dec + loanToValueBTCB sdk.Dec borrower sdk.AccAddress - depositCoin sdk.Coin - coins sdk.Coins - maxLoanToValue string + depositCoins []sdk.Coin + borrowCoins sdk.Coins expectedAccountBalance sdk.Coins expectedModAccountBalance sdk.Coins } @@ -35,12 +46,15 @@ func (suite *KeeperTestSuite) TestBorrow() { { "valid", args{ + priceKAVA: sdk.MustNewDecFromStr("5.00"), + loanToValueKAVA: sdk.MustNewDecFromStr("0.6"), + priceBTCB: sdk.MustNewDecFromStr("0.00"), + loanToValueBTCB: sdk.MustNewDecFromStr("0.01"), borrower: sdk.AccAddress(crypto.AddressHash([]byte("test"))), - depositCoin: sdk.NewCoin("ukava", sdk.NewInt(100)), - coins: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(50))), - maxLoanToValue: "0.6", - expectedAccountBalance: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(150))), - expectedModAccountBalance: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(950))), + depositCoins: []sdk.Coin{sdk.NewCoin("ukava", sdk.NewInt(100*KAVA_CF))}, + borrowCoins: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(20*KAVA_CF))), + expectedAccountBalance: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(20*KAVA_CF)), sdk.NewCoin("btcb", sdk.NewInt(100*BTCB_CF))), + expectedModAccountBalance: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(1080*KAVA_CF)), sdk.NewCoin("usdx", sdk.NewInt(200*USDX_CF))), }, errArgs{ expectPass: true, @@ -48,14 +62,53 @@ func (suite *KeeperTestSuite) TestBorrow() { }, }, { - "loan-to-value limited", + "invalid: loan-to-value limited", args{ + priceKAVA: sdk.MustNewDecFromStr("5.00"), + loanToValueKAVA: sdk.MustNewDecFromStr("0.6"), + priceBTCB: sdk.MustNewDecFromStr("0.00"), + loanToValueBTCB: sdk.MustNewDecFromStr("0.01"), borrower: sdk.AccAddress(crypto.AddressHash([]byte("test"))), - depositCoin: sdk.NewCoin("ukava", sdk.NewInt(20)), // 20 KAVA x $5.00 price = $100 - coins: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(61))), // 61 USDX x $1 price = $61 - maxLoanToValue: "0.6", - expectedAccountBalance: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(150))), - expectedModAccountBalance: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(950))), + depositCoins: []sdk.Coin{sdk.NewCoin("ukava", sdk.NewInt(20*KAVA_CF))}, // 20 KAVA x $5.00 price = $100 + borrowCoins: sdk.NewCoins(sdk.NewCoin("usdx", sdk.NewInt(61*USDX_CF))), // 61 USDX x $1 price = $61 + expectedAccountBalance: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(80*KAVA_CF)), sdk.NewCoin("btcb", sdk.NewInt(100*BTCB_CF))), + expectedModAccountBalance: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(1020*KAVA_CF)), sdk.NewCoin("usdx", sdk.NewInt(261*USDX_CF))), + }, + errArgs{ + expectPass: false, + contains: "total deposited value is insufficient for borrow request", + }, + }, + { + "valid: multiple deposits", + args{ + priceKAVA: sdk.MustNewDecFromStr("2.00"), + loanToValueKAVA: sdk.MustNewDecFromStr("0.80"), + priceBTCB: sdk.MustNewDecFromStr("10000.00"), + loanToValueBTCB: sdk.MustNewDecFromStr("0.10"), + borrower: sdk.AccAddress(crypto.AddressHash([]byte("test"))), + depositCoins: []sdk.Coin{sdk.NewCoin("ukava", sdk.NewInt(50*KAVA_CF)), sdk.NewCoin("btcb", sdk.NewInt(0.1*BTCB_CF))}, + borrowCoins: sdk.NewCoins(sdk.NewCoin("usdx", sdk.NewInt(180*USDX_CF))), + expectedAccountBalance: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(50*KAVA_CF)), sdk.NewCoin("btcb", sdk.NewInt(99.9*BTCB_CF)), sdk.NewCoin("usdx", sdk.NewInt(180*USDX_CF))), + expectedModAccountBalance: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(1050*KAVA_CF)), sdk.NewCoin("usdx", sdk.NewInt(20*USDX_CF)), sdk.NewCoin("btcb", sdk.NewInt(0.1*BTCB_CF))), + }, + errArgs{ + expectPass: true, + contains: "", + }, + }, + { + "invalid: multiple deposits", + args{ + priceKAVA: sdk.MustNewDecFromStr("2.00"), + loanToValueKAVA: sdk.MustNewDecFromStr("0.80"), + priceBTCB: sdk.MustNewDecFromStr("10000.00"), + loanToValueBTCB: sdk.MustNewDecFromStr("0.10"), + borrower: sdk.AccAddress(crypto.AddressHash([]byte("test"))), + depositCoins: []sdk.Coin{sdk.NewCoin("ukava", sdk.NewInt(50*KAVA_CF)), sdk.NewCoin("btcb", sdk.NewInt(0.1*BTCB_CF))}, + borrowCoins: sdk.NewCoins(sdk.NewCoin("usdx", sdk.NewInt(181*USDX_CF))), + expectedAccountBalance: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(50*KAVA_CF)), sdk.NewCoin("btcb", sdk.NewInt(99.9*BTCB_CF)), sdk.NewCoin("usdx", sdk.NewInt(180*USDX_CF))), + expectedModAccountBalance: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(1050*KAVA_CF)), sdk.NewCoin("usdx", sdk.NewInt(20*USDX_CF)), sdk.NewCoin("btcb", sdk.NewInt(0.1*BTCB_CF))), }, errArgs{ expectPass: false, @@ -68,15 +121,19 @@ func (suite *KeeperTestSuite) TestBorrow() { // Initialize test app and set context tApp := app.NewTestApp() ctx := tApp.NewContext(true, abci.Header{Height: 1, Time: tmtime.Now()}) + + // Auth module genesis state authGS := app.NewAuthGenState( []sdk.AccAddress{tc.args.borrower}, - []sdk.Coins{sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(100)))}) - loanToValue := sdk.MustNewDecFromStr(tc.args.maxLoanToValue) + []sdk.Coins{sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(100*KAVA_CF)), sdk.NewCoin("btcb", sdk.NewInt(100*BTCB_CF)))}) + + // Harvest module genesis state harvestGS := types.NewGenesisState(types.NewParams( true, types.DistributionSchedules{ types.NewDistributionSchedule(true, "usdx", time.Date(2020, 10, 8, 14, 0, 0, 0, time.UTC), time.Date(2020, 11, 22, 14, 0, 0, 0, time.UTC), sdk.NewCoin("hard", sdk.NewInt(5000)), time.Date(2021, 11, 22, 14, 0, 0, 0, time.UTC), types.Multipliers{types.NewMultiplier(types.Small, 0, sdk.MustNewDecFromStr("0.33")), types.NewMultiplier(types.Medium, 6, sdk.MustNewDecFromStr("0.5")), types.NewMultiplier(types.Medium, 24, sdk.OneDec())}), types.NewDistributionSchedule(true, "ukava", time.Date(2020, 10, 8, 14, 0, 0, 0, time.UTC), time.Date(2020, 11, 22, 14, 0, 0, 0, time.UTC), sdk.NewCoin("hard", sdk.NewInt(5000)), time.Date(2021, 11, 22, 14, 0, 0, 0, time.UTC), types.Multipliers{types.NewMultiplier(types.Small, 0, sdk.MustNewDecFromStr("0.33")), types.NewMultiplier(types.Medium, 6, sdk.MustNewDecFromStr("0.5")), types.NewMultiplier(types.Medium, 24, sdk.OneDec())}), + types.NewDistributionSchedule(true, "btcb", time.Date(2020, 10, 8, 14, 0, 0, 0, time.UTC), time.Date(2020, 11, 22, 14, 0, 0, 0, time.UTC), sdk.NewCoin("hard", sdk.NewInt(5000)), time.Date(2021, 11, 22, 14, 0, 0, 0, time.UTC), types.Multipliers{types.NewMultiplier(types.Small, 0, sdk.MustNewDecFromStr("0.33")), types.NewMultiplier(types.Medium, 6, sdk.MustNewDecFromStr("0.5")), types.NewMultiplier(types.Medium, 24, sdk.OneDec())}), }, types.DelegatorDistributionSchedules{types.NewDelegatorDistributionSchedule( types.NewDistributionSchedule(true, "usdx", time.Date(2020, 10, 8, 14, 0, 0, 0, time.UTC), time.Date(2025, 10, 8, 14, 0, 0, 0, time.UTC), sdk.NewCoin("hard", sdk.NewInt(500)), time.Date(2026, 10, 8, 14, 0, 0, 0, time.UTC), types.Multipliers{types.NewMultiplier(types.Small, 0, sdk.MustNewDecFromStr("0.33")), types.NewMultiplier(types.Medium, 6, sdk.MustNewDecFromStr("0.5")), types.NewMultiplier(types.Medium, 24, sdk.OneDec())}), @@ -84,35 +141,84 @@ func (suite *KeeperTestSuite) TestBorrow() { ), }, types.MoneyMarkets{ - types.NewMoneyMarket("usdx", sdk.NewInt(1000000000000000), loanToValue, "usdx:usd", sdk.NewInt(1000000)), - types.NewMoneyMarket("ukava", sdk.NewInt(1000000000000000), loanToValue, "kava:usd", sdk.NewInt(1000000)), + types.NewMoneyMarket("usdx", sdk.NewInt(100000000*USDX_CF), sdk.MustNewDecFromStr("0.01"), "usdx:usd", sdk.NewInt(USDX_CF)), + types.NewMoneyMarket("ukava", sdk.NewInt(100000000*KAVA_CF), tc.args.loanToValueKAVA, "kava:usd", sdk.NewInt(KAVA_CF)), + types.NewMoneyMarket("btcb", sdk.NewInt(100000000*BTCB_CF), tc.args.loanToValueBTCB, "btcb:usd", sdk.NewInt(BTCB_CF)), }, ), types.DefaultPreviousBlockTime, types.DefaultDistributionTimes) - tApp.InitializeFromGenesisStates(authGS, NewPricefeedGenStateMulti(), + + // Pricefeed module genesis state + pricefeedGS := pricefeed.GenesisState{ + Params: pricefeed.Params{ + Markets: []pricefeed.Market{ + {MarketID: "usdx:usd", BaseAsset: "bnb", QuoteAsset: "usd", Oracles: []sdk.AccAddress{}, Active: true}, + {MarketID: "kava:usd", BaseAsset: "kava", QuoteAsset: "usd", Oracles: []sdk.AccAddress{}, Active: true}, + {MarketID: "btcb:usd", BaseAsset: "btcb", QuoteAsset: "usd", Oracles: []sdk.AccAddress{}, Active: true}, + }, + }, + PostedPrices: []pricefeed.PostedPrice{ + { + MarketID: "usdx:usd", + OracleAddress: sdk.AccAddress{}, + Price: sdk.MustNewDecFromStr("1.00"), + Expiry: time.Now().Add(1 * time.Hour), + }, + { + MarketID: "kava:usd", + OracleAddress: sdk.AccAddress{}, + Price: tc.args.priceKAVA, + Expiry: time.Now().Add(1 * time.Hour), + }, + { + MarketID: "btcb:usd", + OracleAddress: sdk.AccAddress{}, + Price: tc.args.priceBTCB, + Expiry: time.Now().Add(1 * time.Hour), + }, + }, + } + + // Initialize test application + tApp.InitializeFromGenesisStates(authGS, + app.GenesisState{pricefeed.ModuleName: pricefeed.ModuleCdc.MustMarshalJSON(pricefeedGS)}, app.GenesisState{types.ModuleName: types.ModuleCdc.MustMarshalJSON(harvestGS)}) - keeper := tApp.GetHarvestKeeper() + + // Mint coins to Harvest module account supplyKeeper := tApp.GetSupplyKeeper() - supplyKeeper.MintCoins(ctx, types.ModuleAccountName, sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(1000)))) + harvestMaccCoins := sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(1000*KAVA_CF)), sdk.NewCoin("usdx", sdk.NewInt(200*USDX_CF))) + supplyKeeper.MintCoins(ctx, types.ModuleAccountName, harvestMaccCoins) + + keeper := tApp.GetHarvestKeeper() suite.app = tApp suite.ctx = ctx suite.keeper = keeper var err error - // deposit some coins - err = suite.keeper.Deposit(suite.ctx, tc.args.borrower, tc.args.depositCoin, types.LP) - suite.Require().NoError(err) + // Deposit coins to harvest + depositedCoins := sdk.NewCoins() + for _, depositCoin := range tc.args.depositCoins { + err = suite.keeper.Deposit(suite.ctx, tc.args.borrower, depositCoin, types.LP) + suite.Require().NoError(err) + depositedCoins.Add(depositCoin) + } // run the test - err = suite.keeper.Borrow(suite.ctx, tc.args.borrower, tc.args.coins) + err = suite.keeper.Borrow(suite.ctx, tc.args.borrower, tc.args.borrowCoins) // verify results if tc.errArgs.expectPass { suite.Require().NoError(err) + + // Check borrower balance acc := suite.getAccount(tc.args.borrower) - suite.Require().Equal(tc.args.expectedAccountBalance.Sub(sdk.NewCoins(tc.args.depositCoin)), acc.GetCoins()) + suite.Require().Equal(tc.args.expectedAccountBalance.Sub(depositedCoins), acc.GetCoins()) + + // Check module account balance mAcc := suite.getModuleAccount(types.ModuleAccountName) - suite.Require().Equal(tc.args.expectedModAccountBalance.Add(tc.args.depositCoin), mAcc.GetCoins()) + suite.Require().Equal(tc.args.expectedModAccountBalance.Add(depositedCoins...), mAcc.GetCoins()) + + // Check that borrow struct is in store _, f := suite.keeper.GetBorrow(suite.ctx, tc.args.borrower) suite.Require().True(f) } else { diff --git a/x/harvest/keeper/integration_test.go b/x/harvest/keeper/integration_test.go deleted file mode 100644 index cf0de24c..00000000 --- a/x/harvest/keeper/integration_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package keeper_test - -import ( - "time" - - sdk "github.com/cosmos/cosmos-sdk/types" - - "github.com/kava-labs/kava/app" - "github.com/kava-labs/kava/x/pricefeed" -) - -func NewPricefeedGenStateMulti() app.GenesisState { - pfGenesis := pricefeed.GenesisState{ - Params: pricefeed.Params{ - Markets: []pricefeed.Market{ - {MarketID: "btc:usd", BaseAsset: "btc", QuoteAsset: "usd", Oracles: []sdk.AccAddress{}, Active: true}, - {MarketID: "xrp:usd", BaseAsset: "xrp", QuoteAsset: "usd", Oracles: []sdk.AccAddress{}, Active: true}, - {MarketID: "bnb:usd", BaseAsset: "bnb", QuoteAsset: "usd", Oracles: []sdk.AccAddress{}, Active: true}, - {MarketID: "kava:usd", BaseAsset: "kava", QuoteAsset: "usd", Oracles: []sdk.AccAddress{}, Active: true}, - }, - }, - PostedPrices: []pricefeed.PostedPrice{ - { - MarketID: "btc:usd", - OracleAddress: sdk.AccAddress{}, - Price: sdk.MustNewDecFromStr("8000.00"), - Expiry: time.Now().Add(1 * time.Hour), - }, - { - MarketID: "xrp:usd", - OracleAddress: sdk.AccAddress{}, - Price: sdk.MustNewDecFromStr("0.25"), - Expiry: time.Now().Add(1 * time.Hour), - }, - { - MarketID: "bnb:usd", - OracleAddress: sdk.AccAddress{}, - Price: sdk.MustNewDecFromStr("17.25"), - Expiry: time.Now().Add(1 * time.Hour), - }, - { - MarketID: "kava:usd", - OracleAddress: sdk.AccAddress{}, - Price: sdk.MustNewDecFromStr("5.00"), - Expiry: time.Now().Add(1 * time.Hour), - }, - }, - } - return app.GenesisState{pricefeed.ModuleName: pricefeed.ModuleCdc.MustMarshalJSON(pfGenesis)} -}