diff --git a/Makefile b/Makefile index 2cacb941..b0cfcaf0 100644 --- a/Makefile +++ b/Makefile @@ -101,7 +101,7 @@ clean: # Set to exclude riot links as they trigger false positives link-check: @go get -u github.com/raviqqe/liche@f57a5d1c5be4856454cb26de155a65a4fd856ee3 - liche -r . --exclude "^http://127.*|^https://riot.im/app*|^http://kava-testnet*|^https://testnet-dex*|^https://kava3.data.kava.io*|^https://ipfs.io*|^https://apps.apple.com*" + liche -r . --exclude "^http://127.*|^https://riot.im/app*|^http://kava-testnet*|^https://testnet-dex*|^https://kava3.data.kava.io*|^https://ipfs.io*|^https://apps.apple.com*|^https://kava.quicksync.io*" lint: @@ -201,4 +201,4 @@ docs-develop: docs-build: @cd docs && \ npm install && \ - npm run build \ No newline at end of file + npm run build diff --git a/x/harvest/alias.go b/x/harvest/alias.go index 1a664418..539460be 100644 --- a/x/harvest/alias.go +++ b/x/harvest/alias.go @@ -50,6 +50,10 @@ var ( // function aliases NewKeeper = keeper.NewKeeper NewQuerier = keeper.NewQuerier + CalculateUtilizationRatio = keeper.CalculateUtilizationRatio + CalculateBorrowRate = keeper.CalculateBorrowRate + CalculateInterestFactor = keeper.CalculateInterestFactor + APYToSPY = keeper.APYToSPY ClaimKey = types.ClaimKey DefaultGenesisState = types.DefaultGenesisState DefaultParams = types.DefaultParams diff --git a/x/harvest/keeper/borrow.go b/x/harvest/keeper/borrow.go index adc3a063..152eb379 100644 --- a/x/harvest/keeper/borrow.go +++ b/x/harvest/keeper/borrow.go @@ -11,6 +11,17 @@ import ( // Borrow funds func (k Keeper) Borrow(ctx sdk.Context, borrower sdk.AccAddress, coins sdk.Coins) error { + // Set any new denoms' global borrow index to 1.0 + for _, coin := range coins { + _, foundBorrowIndex := k.GetBorrowIndex(ctx, coin.Denom) + if !foundBorrowIndex { + k.SetBorrowIndex(ctx, coin.Denom, sdk.OneDec()) + } + } + + // Sync user's borrow balance (only for coins user is requesting to borrow) + k.SyncBorrowInterest(ctx, borrower, coins) + // Validate borrow amount within user and protocol limits err := k.ValidateBorrow(ctx, borrower, coins) if err != nil { @@ -34,22 +45,22 @@ func (k Keeper) Borrow(ctx sdk.Context, borrower sdk.AccAddress, coins sdk.Coins } } - // Update user's borrow in store borrow, found := k.GetBorrow(ctx, borrower) if !found { - borrow = types.NewBorrow(borrower, coins) - } else { - borrow.Amount = borrow.Amount.Add(coins...) + return types.ErrBorrowNotFound // This should never happen } + // Add the newly borrowed coins to the user's borrow object + borrow.Amount = borrow.Amount.Add(coins...) k.SetBorrow(ctx, borrow) - // Update total borrowed amount + // Update total borrowed amount by newly borrowed coins. Don't add user's pending interest as + // it has already been included in the total borrowed coins by the BeginBlocker. k.IncrementBorrowedCoins(ctx, coins) ctx.EventManager().EmitEvent( sdk.NewEvent( types.EventTypeHarvestBorrow, - sdk.NewAttribute(types.AttributeKeyBorrower, borrow.Borrower.String()), + sdk.NewAttribute(types.AttributeKeyBorrower, borrower.String()), sdk.NewAttribute(types.AttributeKeyBorrowCoins, coins.String()), ), ) @@ -57,6 +68,57 @@ func (k Keeper) Borrow(ctx sdk.Context, borrower sdk.AccAddress, coins sdk.Coins return nil } +// SyncBorrowInterest updates the user's owed interest on newly borrowed coins to the latest global state, +// returning an sdk.Coins object containing the amount of newly accumulated interest. +func (k Keeper) SyncBorrowInterest(ctx sdk.Context, borrower sdk.AccAddress, coins sdk.Coins) sdk.Coins { + totalNewInterest := sdk.Coins{} + + // Update user's borrow index list for each asset in the 'coins' array. + // We use a list of BorrowIndexItem here because Amino doesn't support marshaling maps. + borrow, found := k.GetBorrow(ctx, borrower) + if !found { // User's first borrow + // Build borrow index list containing (denoms, borrow index value at borrow time) + var borrowIndexes types.BorrowIndexes + for _, coin := range coins { + borrowIndexValue, _ := k.GetBorrowIndex(ctx, coin.Denom) + borrowIndex := types.NewBorrowIndexItem(coin.Denom, borrowIndexValue) + borrowIndexes = append(borrowIndexes, borrowIndex) + } + borrow = types.NewBorrow(borrower, sdk.Coins{}, borrowIndexes) + } else { // User has existing borrow + for _, coin := range coins { + // Locate the borrow index item by coin denom in the user's list of borrow indexes + foundAtIndex := -1 + for i := range borrow.Index { + if borrow.Index[i].Denom == coin.Denom { + foundAtIndex = i + break + } + } + + borrowIndexValue, _ := k.GetBorrowIndex(ctx, coin.Denom) + if foundAtIndex == -1 { // First time user has borrowed this denom + borrow.Index = append(borrow.Index, types.NewBorrowIndexItem(coin.Denom, borrowIndexValue)) + } else { // User has an existing borrow index for this denom + // Calculate interest owed by user since asset's last borrow index update + storedAmount := sdk.NewDecFromInt(borrow.Amount.AmountOf(coin.Denom)) + userLastBorrowIndex := borrow.Index[foundAtIndex].Value + interest := (storedAmount.Quo(userLastBorrowIndex).Mul(borrowIndexValue)).Sub(storedAmount) + totalNewInterest = totalNewInterest.Add(sdk.NewCoin(coin.Denom, interest.TruncateInt())) + // We're synced up, so update user's borrow index value to match the current global borrow index value + borrow.Index[foundAtIndex].Value = borrowIndexValue + } + } + // Add all pending interest to user's borrow + borrow.Amount = borrow.Amount.Add(totalNewInterest...) + } + + // Update user's borrow in the store + k.SetBorrow(ctx, borrow) + + return totalNewInterest +} + // ValidateBorrow validates a borrow request against borrower and protocol requirements func (k Keeper) ValidateBorrow(ctx sdk.Context, borrower sdk.AccAddress, amount sdk.Coins) error { if amount.IsZero() { @@ -70,7 +132,7 @@ func (k Keeper) ValidateBorrow(ctx sdk.Context, borrower sdk.AccAddress, amount moneyMarket, ok := moneyMarketCache[coin.Denom] // Fetch money market and store in local cache if !ok { - newMoneyMarket, found := k.GetMoneyMarket(ctx, coin.Denom) + newMoneyMarket, found := k.GetMoneyMarketParam(ctx, coin.Denom) if !found { return sdkerrors.Wrapf(types.ErrMarketNotFound, "no market found for denom %s", coin.Denom) } @@ -114,7 +176,7 @@ func (k Keeper) ValidateBorrow(ctx sdk.Context, borrower sdk.AccAddress, amount moneyMarket, ok := moneyMarketCache[deposit.Amount.Denom] // Fetch money market and store in local cache if !ok { - newMoneyMarket, found := k.GetMoneyMarket(ctx, deposit.Amount.Denom) + newMoneyMarket, found := k.GetMoneyMarketParam(ctx, deposit.Amount.Denom) if !found { return sdkerrors.Wrapf(types.ErrMarketNotFound, "no market found for denom %s", deposit.Amount.Denom) } @@ -140,7 +202,7 @@ func (k Keeper) ValidateBorrow(ctx sdk.Context, borrower sdk.AccAddress, amount moneyMarket, ok := moneyMarketCache[borrowedCoin.Denom] // Fetch money market and store in local cache if !ok { - newMoneyMarket, found := k.GetMoneyMarket(ctx, borrowedCoin.Denom) + newMoneyMarket, found := k.GetMoneyMarketParam(ctx, borrowedCoin.Denom) if !found { return sdkerrors.Wrapf(types.ErrMarketNotFound, "no market found for denom %s", borrowedCoin.Denom) } diff --git a/x/harvest/keeper/borrow_test.go b/x/harvest/keeper/borrow_test.go index 9c0f6084..1a9bc8ad 100644 --- a/x/harvest/keeper/borrow_test.go +++ b/x/harvest/keeper/borrow_test.go @@ -275,12 +275,12 @@ func (suite *KeeperTestSuite) TestBorrow() { ), }, types.MoneyMarkets{ - types.NewMoneyMarket("usdx", true, tc.args.usdxBorrowLimit, sdk.MustNewDecFromStr("1"), "usdx:usd", sdk.NewInt(USDX_CF), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10"))), - types.NewMoneyMarket("busd", false, sdk.NewDec(100000000*BUSD_CF), sdk.MustNewDecFromStr("1"), "busd:usd", sdk.NewInt(BUSD_CF), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10"))), - types.NewMoneyMarket("ukava", false, sdk.NewDec(100000000*KAVA_CF), tc.args.loanToValueKAVA, "kava:usd", sdk.NewInt(KAVA_CF), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10"))), - types.NewMoneyMarket("btcb", false, sdk.NewDec(100000000*BTCB_CF), tc.args.loanToValueBTCB, "btcb:usd", sdk.NewInt(BTCB_CF), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10"))), - types.NewMoneyMarket("bnb", false, sdk.NewDec(100000000*BNB_CF), tc.args.loanToValueBNB, "bnb:usd", sdk.NewInt(BNB_CF), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10"))), - types.NewMoneyMarket("xyz", false, sdk.NewDec(1), tc.args.loanToValueBNB, "xyz:usd", sdk.NewInt(1), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10"))), + types.NewMoneyMarket("usdx", types.NewBorrowLimit(true, tc.args.usdxBorrowLimit, sdk.MustNewDecFromStr("1")), "usdx:usd", sdk.NewInt(USDX_CF), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10")), sdk.MustNewDecFromStr("0.05")), + types.NewMoneyMarket("busd", types.NewBorrowLimit(false, sdk.NewDec(100000000*BUSD_CF), sdk.MustNewDecFromStr("1")), "busd:usd", sdk.NewInt(BUSD_CF), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10")), sdk.MustNewDecFromStr("0.05")), + types.NewMoneyMarket("ukava", types.NewBorrowLimit(false, sdk.NewDec(100000000*KAVA_CF), tc.args.loanToValueKAVA), "kava:usd", sdk.NewInt(KAVA_CF), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10")), sdk.MustNewDecFromStr("0.05")), + types.NewMoneyMarket("btcb", types.NewBorrowLimit(false, sdk.NewDec(100000000*BTCB_CF), tc.args.loanToValueBTCB), "btcb:usd", sdk.NewInt(BTCB_CF), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10")), sdk.MustNewDecFromStr("0.05")), + types.NewMoneyMarket("bnb", types.NewBorrowLimit(false, sdk.NewDec(100000000*BNB_CF), tc.args.loanToValueBNB), "bnb:usd", sdk.NewInt(BNB_CF), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10")), sdk.MustNewDecFromStr("0.05")), + types.NewMoneyMarket("xyz", types.NewBorrowLimit(false, sdk.NewDec(1), tc.args.loanToValueBNB), "xyz:usd", sdk.NewInt(1), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10")), sdk.MustNewDecFromStr("0.05")), }, ), types.DefaultPreviousBlockTime, types.DefaultDistributionTimes) diff --git a/x/harvest/keeper/claim_test.go b/x/harvest/keeper/claim_test.go index 116d9e55..3c8f2b06 100644 --- a/x/harvest/keeper/claim_test.go +++ b/x/harvest/keeper/claim_test.go @@ -265,8 +265,8 @@ func (suite *KeeperTestSuite) TestClaim() { ), }, types.MoneyMarkets{ - types.NewMoneyMarket("usdx", false, sdk.NewDec(1000000000000000), loanToValue, "usdx:usd", sdk.NewInt(1000000), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10"))), - types.NewMoneyMarket("ukava", false, sdk.NewDec(1000000000000000), loanToValue, "kava:usd", sdk.NewInt(1000000), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10"))), + types.NewMoneyMarket("usdx", types.NewBorrowLimit(false, sdk.NewDec(1000000000000000), loanToValue), "usdx:usd", sdk.NewInt(1000000), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10")), sdk.MustNewDecFromStr("0.05")), + types.NewMoneyMarket("ukava", types.NewBorrowLimit(false, sdk.NewDec(1000000000000000), loanToValue), "kava:usd", sdk.NewInt(1000000), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10")), sdk.MustNewDecFromStr("0.05")), }, ), types.DefaultPreviousBlockTime, types.DefaultDistributionTimes) tApp.InitializeFromGenesisStates(authGS, app.GenesisState{types.ModuleName: types.ModuleCdc.MustMarshalJSON(harvestGS)}) diff --git a/x/harvest/keeper/deposit.go b/x/harvest/keeper/deposit.go index 6f176137..c16fe5e0 100644 --- a/x/harvest/keeper/deposit.go +++ b/x/harvest/keeper/deposit.go @@ -10,7 +10,6 @@ import ( // Deposit deposit func (k Keeper) Deposit(ctx sdk.Context, depositor sdk.AccAddress, amount sdk.Coin) error { - err := k.ValidateDeposit(ctx, amount) if err != nil { return err @@ -59,6 +58,7 @@ func (k Keeper) Withdraw(ctx sdk.Context, depositor sdk.AccAddress, amount sdk.C if !found { return sdkerrors.Wrapf(types.ErrDepositNotFound, "no %s deposit found for %s", amount.Denom, depositor) } + if !deposit.Amount.IsGTE(amount) { return sdkerrors.Wrapf(types.ErrInvalidWithdrawAmount, "%s>%s", amount, deposit.Amount) } diff --git a/x/harvest/keeper/deposit_test.go b/x/harvest/keeper/deposit_test.go index 443932ba..99d8895b 100644 --- a/x/harvest/keeper/deposit_test.go +++ b/x/harvest/keeper/deposit_test.go @@ -108,8 +108,8 @@ func (suite *KeeperTestSuite) TestDeposit() { ), }, types.MoneyMarkets{ - types.NewMoneyMarket("usdx", false, sdk.NewDec(1000000000000000), loanToValue, "usdx:usd", sdk.NewInt(1000000), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10"))), - types.NewMoneyMarket("ukava", false, sdk.NewDec(1000000000000000), loanToValue, "kava:usd", sdk.NewInt(1000000), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10"))), + types.NewMoneyMarket("usdx", types.NewBorrowLimit(false, sdk.NewDec(1000000000000000), loanToValue), "usdx:usd", sdk.NewInt(1000000), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10")), sdk.MustNewDecFromStr("0.05")), + types.NewMoneyMarket("ukava", types.NewBorrowLimit(false, sdk.NewDec(1000000000000000), loanToValue), "kava:usd", sdk.NewInt(1000000), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10")), sdk.MustNewDecFromStr("0.05")), }, ), types.DefaultPreviousBlockTime, types.DefaultDistributionTimes) tApp.InitializeFromGenesisStates(authGS, app.GenesisState{types.ModuleName: types.ModuleCdc.MustMarshalJSON(harvestGS)}) @@ -251,8 +251,8 @@ func (suite *KeeperTestSuite) TestWithdraw() { ), }, types.MoneyMarkets{ - types.NewMoneyMarket("usdx", false, sdk.NewDec(1000000000000000), loanToValue, "usdx:usd", sdk.NewInt(1000000), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10"))), - types.NewMoneyMarket("ukava", false, sdk.NewDec(1000000000000000), loanToValue, "kava:usd", sdk.NewInt(1000000), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10"))), + types.NewMoneyMarket("usdx", types.NewBorrowLimit(false, sdk.NewDec(1000000000000000), loanToValue), "usdx:usd", sdk.NewInt(1000000), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10")), sdk.MustNewDecFromStr("0.05")), + types.NewMoneyMarket("ukava", types.NewBorrowLimit(false, sdk.NewDec(1000000000000000), loanToValue), "kava:usd", sdk.NewInt(1000000), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10")), sdk.MustNewDecFromStr("0.05")), }, ), types.DefaultPreviousBlockTime, types.DefaultDistributionTimes) tApp.InitializeFromGenesisStates(authGS, app.GenesisState{types.ModuleName: types.ModuleCdc.MustMarshalJSON(harvestGS)}) diff --git a/x/harvest/keeper/interest.go b/x/harvest/keeper/interest.go index 8f1fa310..ec2dc3f4 100644 --- a/x/harvest/keeper/interest.go +++ b/x/harvest/keeper/interest.go @@ -2,30 +2,192 @@ package keeper import ( sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" "github.com/kava-labs/kava/x/harvest/types" ) -// ApplyInterestRateUpdates translates the current interest rate models from the params to the store +var ( + scalingFactor = 1e18 + secondsPerYear = 31536000 +) + +// ApplyInterestRateUpdates translates the current interest rate models from the params to the store, +// with each money market accruing interest. func (k Keeper) ApplyInterestRateUpdates(ctx sdk.Context) { denomSet := map[string]bool{} params := k.GetParams(ctx) for _, mm := range params.MoneyMarkets { - model, found := k.GetInterestRateModel(ctx, mm.Denom) + // Set any new money markets in the store + moneyMarket, found := k.GetMoneyMarket(ctx, mm.Denom) if !found { - k.SetInterestRateModel(ctx, mm.Denom, mm.InterestRateModel) - continue + moneyMarket = mm + k.SetMoneyMarket(ctx, mm.Denom, moneyMarket) } - if !model.Equal(mm.InterestRateModel) { - k.SetInterestRateModel(ctx, mm.Denom, mm.InterestRateModel) + + // Accrue interest according to the current money markets in the store + err := k.AccrueInterest(ctx, mm.Denom) + if err != nil { + panic(err) + } + + // Update the interest rate in the store if the params have changed + if !moneyMarket.Equal(mm) { + k.SetMoneyMarket(ctx, mm.Denom, mm) } denomSet[mm.Denom] = true } - k.IterateInterestRateModels(ctx, func(denom string, i types.InterestRateModel) bool { + // Edge case: money markets removed from params that still exist in the store + k.IterateMoneyMarkets(ctx, func(denom string, i types.MoneyMarket) bool { if !denomSet[denom] { - k.DeleteInterestRateModel(ctx, denom) + // Accrue interest according to current store money market + err := k.AccrueInterest(ctx, denom) + if err != nil { + panic(err) + } + + // Delete the money market from the store + k.DeleteMoneyMarket(ctx, denom) } return false }) } + +// AccrueInterest applies accrued interest to total borrows and reserves by calculating +// interest from the last checkpoint time and writing the updated values to the store. +func (k Keeper) AccrueInterest(ctx sdk.Context, denom string) error { + + previousAccrualTime, found := k.GetPreviousAccrualTime(ctx, denom) + if !found { + k.SetPreviousAccrualTime(ctx, denom, ctx.BlockTime()) + return nil + } + + timeElapsed := ctx.BlockTime().Unix() - previousAccrualTime.Unix() + if timeElapsed == 0 { + return nil + } + + // Get available harvest module account cash on hand + cashPrior := k.supplyKeeper.GetModuleAccount(ctx, types.ModuleName).GetCoins().AmountOf(denom) + + // Get prior borrows + borrowsPrior := sdk.NewCoin(denom, sdk.ZeroInt()) + borrowCoinsPrior, foundBorrowCoinsPrior := k.GetBorrowedCoins(ctx) + if foundBorrowCoinsPrior { + borrowsPrior = sdk.NewCoin(denom, borrowCoinsPrior.AmountOf(denom)) + } + + reservesPrior, foundReservesPrior := k.GetTotalReserves(ctx, denom) + if !foundReservesPrior { + newReservesPrior := sdk.NewCoin(denom, sdk.ZeroInt()) + k.SetTotalReserves(ctx, denom, newReservesPrior) + reservesPrior = newReservesPrior + } + + borrowIndexPrior, foundBorrowIndexPrior := k.GetBorrowIndex(ctx, denom) + if !foundBorrowIndexPrior { + newBorrowIndexPrior := sdk.MustNewDecFromStr("1.0") + k.SetBorrowIndex(ctx, denom, newBorrowIndexPrior) + borrowIndexPrior = newBorrowIndexPrior + } + + // Fetch money market from the store + mm, found := k.GetMoneyMarket(ctx, denom) + if !found { + return sdkerrors.Wrapf(types.ErrMoneyMarketNotFound, "%s", denom) + } + + // GetBorrowRate calculates the current interest rate based on utilization (the fraction of supply that has been borrowed) + borrowRateApy, err := CalculateBorrowRate(mm.InterestRateModel, sdk.NewDecFromInt(cashPrior), sdk.NewDecFromInt(borrowsPrior.Amount), sdk.NewDecFromInt(reservesPrior.Amount)) + if err != nil { + return err + } + + // Convert from APY to SPY, expressed as (1 + borrow rate) + borrowRateSpy, err := APYToSPY(sdk.OneDec().Add(borrowRateApy)) + if err != nil { + return err + } + + interestFactor := CalculateInterestFactor(borrowRateSpy, sdk.NewInt(timeElapsed)) + interestAccumulated := (interestFactor.Mul(sdk.NewDecFromInt(borrowsPrior.Amount)).TruncateInt()).Sub(borrowsPrior.Amount) + totalBorrowInterestAccumulated := sdk.NewCoins(sdk.NewCoin(denom, interestAccumulated)) + totalReservesNew := reservesPrior.Add(sdk.NewCoin(denom, sdk.NewDecFromInt(interestAccumulated).Mul(mm.ReserveFactor).TruncateInt())) + borrowIndexNew := borrowIndexPrior.Mul(interestFactor) + + k.SetBorrowIndex(ctx, denom, borrowIndexNew) + k.IncrementBorrowedCoins(ctx, totalBorrowInterestAccumulated) + k.SetTotalReserves(ctx, denom, totalReservesNew) + k.SetPreviousAccrualTime(ctx, denom, ctx.BlockTime()) + return nil +} + +// CalculateBorrowRate calculates the borrow rate, which is the current APY expressed as a decimal +// based on the current utilization. +func CalculateBorrowRate(model types.InterestRateModel, cash, borrows, reserves sdk.Dec) (sdk.Dec, error) { + utilRatio := CalculateUtilizationRatio(cash, borrows, reserves) + + // Calculate normal borrow rate (under kink) + if utilRatio.LTE(model.Kink) { + return utilRatio.Mul(model.BaseMultiplier).Add(model.BaseRateAPY), nil + } + + // Calculate jump borrow rate (over kink) + normalRate := model.Kink.Mul(model.BaseMultiplier).Add(model.BaseRateAPY) + excessUtil := utilRatio.Sub(model.Kink) + return excessUtil.Mul(model.JumpMultiplier).Add(normalRate), nil +} + +// CalculateUtilizationRatio calculates an asset's current utilization rate +func CalculateUtilizationRatio(cash, borrows, reserves sdk.Dec) sdk.Dec { + // Utilization rate is 0 when there are no borrows + if borrows.Equal(sdk.ZeroDec()) { + return sdk.ZeroDec() + } + + totalSupply := cash.Add(borrows).Sub(reserves) + if totalSupply.IsNegative() { + return sdk.OneDec() + } + + return sdk.MinDec(sdk.OneDec(), borrows.Quo(totalSupply)) +} + +// CalculateInterestFactor calculates the simple interest scaling factor, +// which is equal to: (per-second interest rate * number of seconds elapsed) +// Will return 1.000x, multiply by principal to get new principal with added interest +func CalculateInterestFactor(perSecondInterestRate sdk.Dec, secondsElapsed sdk.Int) sdk.Dec { + scalingFactorUint := sdk.NewUint(uint64(scalingFactor)) + scalingFactorInt := sdk.NewInt(int64(scalingFactor)) + + // Convert per-second interest rate to a uint scaled by 1e18 + interestMantissa := sdk.NewUint(perSecondInterestRate.MulInt(scalingFactorInt).RoundInt().Uint64()) + // Convert seconds elapsed to uint (*not scaled*) + secondsElapsedUint := sdk.NewUint(secondsElapsed.Uint64()) + // Calculate the interest factor as a uint scaled by 1e18 + interestFactorMantissa := sdk.RelativePow(interestMantissa, secondsElapsedUint, scalingFactorUint) + + // Convert interest factor to an unscaled sdk.Dec + return sdk.NewDecFromBigInt(interestFactorMantissa.BigInt()).QuoInt(scalingFactorInt) +} + +// APYToSPY converts the input annual interest rate. For example, 10% apy would be passed as 1.10. +// SPY = Per second compounded interest rate is how cosmos mathematically represents APY. +func APYToSPY(apy sdk.Dec) (sdk.Dec, error) { + // Note: any APY 179 or greater will cause an out-of-bounds error + root, err := apy.ApproxRoot(uint64(secondsPerYear)) + if err != nil { + return sdk.ZeroDec(), err + } + return root, nil +} + +// minInt64 returns the smaller of x or y +func minDec(x, y sdk.Dec) sdk.Dec { + if x.GT(y) { + return y + } + return x +} diff --git a/x/harvest/keeper/interest_test.go b/x/harvest/keeper/interest_test.go new file mode 100644 index 00000000..920e1b12 --- /dev/null +++ b/x/harvest/keeper/interest_test.go @@ -0,0 +1,791 @@ +package keeper_test + +import ( + "testing" + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/suite" + abci "github.com/tendermint/tendermint/abci/types" + "github.com/tendermint/tendermint/crypto" + tmtime "github.com/tendermint/tendermint/types/time" + + "github.com/kava-labs/kava/app" + "github.com/kava-labs/kava/x/harvest" + "github.com/kava-labs/kava/x/harvest/types" + "github.com/kava-labs/kava/x/pricefeed" +) + +type InterestTestSuite struct { + suite.Suite +} + +func (suite *InterestTestSuite) TestCalculateUtilizationRatio() { + type args struct { + cash sdk.Dec + borrows sdk.Dec + reserves sdk.Dec + expectedValue sdk.Dec + } + + type test struct { + name string + args args + } + + testCases := []test{ + { + "normal", + args{ + cash: sdk.MustNewDecFromStr("1000"), + borrows: sdk.MustNewDecFromStr("5000"), + reserves: sdk.MustNewDecFromStr("100"), + expectedValue: sdk.MustNewDecFromStr("0.847457627118644068"), + }, + }, + { + "high util ratio", + args{ + cash: sdk.MustNewDecFromStr("1000"), + borrows: sdk.MustNewDecFromStr("250000"), + reserves: sdk.MustNewDecFromStr("100"), + expectedValue: sdk.MustNewDecFromStr("0.996412913511359107"), + }, + }, + { + "very high util ratio", + args{ + cash: sdk.MustNewDecFromStr("1000"), + borrows: sdk.MustNewDecFromStr("250000000000"), + reserves: sdk.MustNewDecFromStr("100"), + expectedValue: sdk.MustNewDecFromStr("0.999999996400000013"), + }, + }, + { + "low util ratio", + args{ + cash: sdk.MustNewDecFromStr("1000"), + borrows: sdk.MustNewDecFromStr("50"), + reserves: sdk.MustNewDecFromStr("100"), + expectedValue: sdk.MustNewDecFromStr("0.052631578947368421"), + }, + }, + { + "very low util ratio", + args{ + cash: sdk.MustNewDecFromStr("10000000"), + borrows: sdk.MustNewDecFromStr("50"), + reserves: sdk.MustNewDecFromStr("100"), + expectedValue: sdk.MustNewDecFromStr("0.000005000025000125"), + }, + }, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + utilRatio := harvest.CalculateUtilizationRatio(tc.args.cash, tc.args.borrows, tc.args.reserves) + suite.Require().Equal(tc.args.expectedValue, utilRatio) + }) + } +} + +func (suite *InterestTestSuite) TestCalculateBorrowRate() { + type args struct { + cash sdk.Dec + borrows sdk.Dec + reserves sdk.Dec + model types.InterestRateModel + expectedValue sdk.Dec + } + + type test struct { + name string + args args + } + + // Normal model has: + // - BaseRateAPY: 0.0 + // - BaseMultiplier: 0.1 + // - Kink: 0.8 + // - JumpMultiplier: 0.5 + normalModel := types.NewInterestRateModel(sdk.MustNewDecFromStr("0"), sdk.MustNewDecFromStr("0.1"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("0.5")) + + testCases := []test{ + { + "normal no jump", + args{ + cash: sdk.MustNewDecFromStr("5000"), + borrows: sdk.MustNewDecFromStr("1000"), + reserves: sdk.MustNewDecFromStr("1000"), + model: normalModel, + expectedValue: sdk.MustNewDecFromStr("0.020000000000000000"), + }, + }, + { + "normal with jump", + args{ + cash: sdk.MustNewDecFromStr("1000"), + borrows: sdk.MustNewDecFromStr("5000"), + reserves: sdk.MustNewDecFromStr("100"), + model: normalModel, + expectedValue: sdk.MustNewDecFromStr("0.103728813559322034"), + }, + }, + { + "high cash", + args{ + cash: sdk.MustNewDecFromStr("10000000"), + borrows: sdk.MustNewDecFromStr("5000"), + reserves: sdk.MustNewDecFromStr("100"), + model: normalModel, + expectedValue: sdk.MustNewDecFromStr("0.000049975511999120"), + }, + }, + { + "high borrows", + args{ + cash: sdk.MustNewDecFromStr("1000"), + borrows: sdk.MustNewDecFromStr("5000000000000"), + reserves: sdk.MustNewDecFromStr("100"), + model: normalModel, + expectedValue: sdk.MustNewDecFromStr("0.179999999910000000"), + }, + }, + { + "high reserves", + args{ + cash: sdk.MustNewDecFromStr("1000"), + borrows: sdk.MustNewDecFromStr("5000"), + reserves: sdk.MustNewDecFromStr("1000000000000"), + model: normalModel, + expectedValue: sdk.MustNewDecFromStr("0.180000000000000000"), + }, + }, + { + "random numbers", + args{ + cash: sdk.MustNewDecFromStr("125"), + borrows: sdk.MustNewDecFromStr("11"), + reserves: sdk.MustNewDecFromStr("82"), + model: normalModel, + expectedValue: sdk.MustNewDecFromStr("0.020370370370370370"), + }, + }, + { + "increased base multiplier", + args{ + cash: sdk.MustNewDecFromStr("1000"), + borrows: sdk.MustNewDecFromStr("5000"), + reserves: sdk.MustNewDecFromStr("100"), + model: types.NewInterestRateModel(sdk.MustNewDecFromStr("0"), sdk.MustNewDecFromStr("0.5"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("1.0")), + expectedValue: sdk.MustNewDecFromStr("0.447457627118644068"), + }, + }, + { + "decreased kink", + args{ + cash: sdk.MustNewDecFromStr("1000"), + borrows: sdk.MustNewDecFromStr("5000"), + reserves: sdk.MustNewDecFromStr("100"), + model: types.NewInterestRateModel(sdk.MustNewDecFromStr("0"), sdk.MustNewDecFromStr("0.5"), sdk.MustNewDecFromStr("0.1"), sdk.MustNewDecFromStr("1.0")), + expectedValue: sdk.MustNewDecFromStr("0.797457627118644068"), + }, + }, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + borrowRate, err := harvest.CalculateBorrowRate(tc.args.model, tc.args.cash, tc.args.borrows, tc.args.reserves) + suite.Require().NoError(err) + suite.Require().Equal(tc.args.expectedValue, borrowRate) + }) + } +} + +func (suite *InterestTestSuite) TestCalculateInterestFactor() { + type args struct { + perSecondInterestRate sdk.Dec + timeElapsed sdk.Int + expectedValue sdk.Dec + } + + type test struct { + name string + args args + } + + oneYearInSeconds := int64(31536000) + + testCases := []test{ + { + "1 year", + args{ + perSecondInterestRate: sdk.MustNewDecFromStr("1.000000005555"), + timeElapsed: sdk.NewInt(oneYearInSeconds), + expectedValue: sdk.MustNewDecFromStr("1.191463614477847370"), + }, + }, + { + "10 year", + args{ + perSecondInterestRate: sdk.MustNewDecFromStr("1.000000005555"), + timeElapsed: sdk.NewInt(oneYearInSeconds * 10), + expectedValue: sdk.MustNewDecFromStr("5.765113233897391189"), + }, + }, + { + "1 month", + args{ + perSecondInterestRate: sdk.MustNewDecFromStr("1.000000005555"), + timeElapsed: sdk.NewInt(oneYearInSeconds / 12), + expectedValue: sdk.MustNewDecFromStr("1.014705619075717373"), + }, + }, + { + "1 day", + args{ + perSecondInterestRate: sdk.MustNewDecFromStr("1.000000005555"), + timeElapsed: sdk.NewInt(oneYearInSeconds / 365), + expectedValue: sdk.MustNewDecFromStr("1.000480067194057924"), + }, + }, + { + "1 year: low interest rate", + args{ + perSecondInterestRate: sdk.MustNewDecFromStr("1.000000000555"), + timeElapsed: sdk.NewInt(oneYearInSeconds), + expectedValue: sdk.MustNewDecFromStr("1.017656545925063632"), + }, + }, + { + "1 year, lower interest rate", + args{ + perSecondInterestRate: sdk.MustNewDecFromStr("1.000000000055"), + timeElapsed: sdk.NewInt(oneYearInSeconds), + expectedValue: sdk.MustNewDecFromStr("1.001735985079841390"), + }, + }, + { + "1 year, lowest interest rate", + args{ + perSecondInterestRate: sdk.MustNewDecFromStr("1.000000000005"), + timeElapsed: sdk.NewInt(oneYearInSeconds), + expectedValue: sdk.MustNewDecFromStr("1.000157692432076670"), + }, + }, + { + "1 year: high interest rate", + args{ + perSecondInterestRate: sdk.MustNewDecFromStr("1.000000055555"), + timeElapsed: sdk.NewInt(oneYearInSeconds), + expectedValue: sdk.MustNewDecFromStr("5.766022095987868825"), + }, + }, + { + "1 year: higher interest rate", + args{ + perSecondInterestRate: sdk.MustNewDecFromStr("1.000000555555"), + timeElapsed: sdk.NewInt(oneYearInSeconds), + expectedValue: sdk.MustNewDecFromStr("40628388.864535408465693310"), + }, + }, + // If we raise the per second interest rate too much we'll cause an integer overflow. + // For example, perSecondInterestRate: '1.000005555555' will cause a panic. + { + "1 year: highest interest rate", + args{ + perSecondInterestRate: sdk.MustNewDecFromStr("1.000001555555"), + timeElapsed: sdk.NewInt(oneYearInSeconds), + expectedValue: sdk.MustNewDecFromStr("2017093013158200407564.613502861572552603"), + }, + }, + } + + for _, tc := range testCases { + interestFactor := harvest.CalculateInterestFactor(tc.args.perSecondInterestRate, tc.args.timeElapsed) + suite.Require().Equal(tc.args.expectedValue, interestFactor) + } +} + +func (suite *InterestTestSuite) TestAPYToSPY() { + type args struct { + apy sdk.Dec + expectedValue sdk.Dec + } + + type test struct { + name string + args args + expectError bool + } + + testCases := []test{ + { + "lowest apy", + args{ + apy: sdk.MustNewDecFromStr("0.005"), + expectedValue: sdk.MustNewDecFromStr("0.999999831991472557"), + }, + false, + }, + { + "lower apy", + args{ + apy: sdk.MustNewDecFromStr("0.05"), + expectedValue: sdk.MustNewDecFromStr("0.999999905005957279"), + }, + false, + }, + { + "medium-low apy", + args{ + apy: sdk.MustNewDecFromStr("0.5"), + expectedValue: sdk.MustNewDecFromStr("0.999999978020447332"), + }, + false, + }, + { + "medium-high apy", + args{ + apy: sdk.MustNewDecFromStr("5"), + expectedValue: sdk.MustNewDecFromStr("1.000000051034942717"), + }, + false, + }, + { + "high apy", + args{ + apy: sdk.MustNewDecFromStr("50"), + expectedValue: sdk.MustNewDecFromStr("1.000000124049443433"), + }, + false, + }, + { + "highest apy", + args{ + apy: sdk.MustNewDecFromStr("170"), + expectedValue: sdk.MustNewDecFromStr("1.000000162855113371"), + }, + false, + }, + { + "out of bounds error after 178", + args{ + apy: sdk.MustNewDecFromStr("178"), + expectedValue: sdk.ZeroDec(), + }, + true, + }, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + spy, err := harvest.APYToSPY(tc.args.apy) + if tc.expectError { + suite.Require().Error(err) + } else { + suite.Require().NoError(err) + suite.Require().Equal(tc.args.expectedValue, spy) + } + }) + } +} + +type ExpectedInterest struct { + elapsedTime int64 + shouldBorrow bool + borrowCoin sdk.Coin +} + +func (suite *KeeperTestSuite) TestInterest() { + type args struct { + user sdk.AccAddress + initialBorrowerCoins sdk.Coins + initialModuleCoins sdk.Coins + borrowCoinDenom string + borrowCoins sdk.Coins + interestRateModel types.InterestRateModel + reserveFactor sdk.Dec + expectedInterestSnaphots []ExpectedInterest + } + + type errArgs struct { + expectPass bool + contains string + } + + type interestTest struct { + name string + args args + errArgs errArgs + } + + normalModel := types.NewInterestRateModel(sdk.MustNewDecFromStr("0"), sdk.MustNewDecFromStr("0.1"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("0.5")) + + oneDayInSeconds := int64(86400) + oneWeekInSeconds := int64(604800) + oneMonthInSeconds := int64(2592000) + oneYearInSeconds := int64(31536000) + + testCases := []interestTest{ + { + "one day", + args{ + user: sdk.AccAddress(crypto.AddressHash([]byte("test"))), + initialBorrowerCoins: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(100*KAVA_CF))), + initialModuleCoins: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(1000*KAVA_CF))), + borrowCoinDenom: "ukava", + borrowCoins: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(20*KAVA_CF))), + interestRateModel: normalModel, + reserveFactor: sdk.MustNewDecFromStr("0.05"), + expectedInterestSnaphots: []ExpectedInterest{ + { + elapsedTime: oneDayInSeconds, + shouldBorrow: false, + borrowCoin: sdk.Coin{}, + }, + }, + }, + errArgs{ + expectPass: true, + contains: "", + }, + }, + { + "one week", + args{ + user: sdk.AccAddress(crypto.AddressHash([]byte("test"))), + initialBorrowerCoins: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(100*KAVA_CF))), + initialModuleCoins: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(1000*KAVA_CF))), + borrowCoinDenom: "ukava", + borrowCoins: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(20*KAVA_CF))), + interestRateModel: normalModel, + reserveFactor: sdk.MustNewDecFromStr("0.05"), + expectedInterestSnaphots: []ExpectedInterest{ + { + elapsedTime: oneWeekInSeconds, + shouldBorrow: false, + borrowCoin: sdk.Coin{}, + }, + }, + }, + errArgs{ + expectPass: true, + contains: "", + }, + }, + { + "one month", + args{ + user: sdk.AccAddress(crypto.AddressHash([]byte("test"))), + initialBorrowerCoins: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(100*KAVA_CF))), + initialModuleCoins: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(1000*KAVA_CF))), + borrowCoinDenom: "ukava", + borrowCoins: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(20*KAVA_CF))), + interestRateModel: normalModel, + reserveFactor: sdk.MustNewDecFromStr("0.05"), + expectedInterestSnaphots: []ExpectedInterest{ + { + elapsedTime: oneMonthInSeconds, + shouldBorrow: false, + borrowCoin: sdk.Coin{}, + }, + }, + }, + errArgs{ + expectPass: true, + contains: "", + }, + }, + { + "one year", + args{ + user: sdk.AccAddress(crypto.AddressHash([]byte("test"))), + initialBorrowerCoins: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(100*KAVA_CF))), + initialModuleCoins: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(1000*KAVA_CF))), + borrowCoinDenom: "ukava", + borrowCoins: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(20*KAVA_CF))), + interestRateModel: normalModel, + reserveFactor: sdk.MustNewDecFromStr("0.05"), + expectedInterestSnaphots: []ExpectedInterest{ + { + elapsedTime: oneYearInSeconds, + shouldBorrow: false, + borrowCoin: sdk.Coin{}, + }, + }, + }, + errArgs{ + expectPass: true, + contains: "", + }, + }, + { + "0 reserve factor", + args{ + user: sdk.AccAddress(crypto.AddressHash([]byte("test"))), + initialBorrowerCoins: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(100*KAVA_CF))), + initialModuleCoins: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(1000*KAVA_CF))), + borrowCoinDenom: "ukava", + borrowCoins: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(20*KAVA_CF))), + interestRateModel: normalModel, + reserveFactor: sdk.MustNewDecFromStr("0"), + expectedInterestSnaphots: []ExpectedInterest{ + { + elapsedTime: oneYearInSeconds, + shouldBorrow: false, + borrowCoin: sdk.Coin{}, + }, + }, + }, + errArgs{ + expectPass: true, + contains: "", + }, + }, + { + "borrow during snapshot", + args{ + user: sdk.AccAddress(crypto.AddressHash([]byte("test"))), + initialBorrowerCoins: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(100*KAVA_CF))), + initialModuleCoins: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(1000*KAVA_CF))), + borrowCoinDenom: "ukava", + borrowCoins: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(20*KAVA_CF))), + interestRateModel: normalModel, + reserveFactor: sdk.MustNewDecFromStr("0.05"), + expectedInterestSnaphots: []ExpectedInterest{ + { + elapsedTime: oneYearInSeconds, + shouldBorrow: true, + borrowCoin: sdk.NewCoin("ukava", sdk.NewInt(1*KAVA_CF)), + }, + }, + }, + errArgs{ + expectPass: true, + contains: "", + }, + }, + { + "multiple snapshots", + args{ + user: sdk.AccAddress(crypto.AddressHash([]byte("test"))), + initialBorrowerCoins: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(100*KAVA_CF))), + initialModuleCoins: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(1000*KAVA_CF))), + borrowCoinDenom: "ukava", + borrowCoins: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(20*KAVA_CF))), + interestRateModel: normalModel, + reserveFactor: sdk.MustNewDecFromStr("0.05"), + expectedInterestSnaphots: []ExpectedInterest{ + { + elapsedTime: oneMonthInSeconds, + shouldBorrow: false, + borrowCoin: sdk.Coin{}, + }, + { + elapsedTime: oneMonthInSeconds, + shouldBorrow: false, + borrowCoin: sdk.Coin{}, + }, + }, + }, + errArgs{ + expectPass: true, + contains: "", + }, + }, + { + "varied snapshots", + args{ + user: sdk.AccAddress(crypto.AddressHash([]byte("test"))), + initialBorrowerCoins: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(100*KAVA_CF))), + initialModuleCoins: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(1000*KAVA_CF))), + borrowCoinDenom: "ukava", + borrowCoins: sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(20*KAVA_CF))), + interestRateModel: normalModel, + reserveFactor: sdk.MustNewDecFromStr("0.05"), + expectedInterestSnaphots: []ExpectedInterest{ + { + elapsedTime: oneDayInSeconds, + shouldBorrow: false, + borrowCoin: sdk.Coin{}, + }, + { + elapsedTime: oneWeekInSeconds, + shouldBorrow: false, + borrowCoin: sdk.Coin{}, + }, + { + elapsedTime: oneMonthInSeconds, + shouldBorrow: false, + borrowCoin: sdk.Coin{}, + }, + { + elapsedTime: oneYearInSeconds, + shouldBorrow: false, + borrowCoin: sdk.Coin{}, + }, + }, + }, + errArgs{ + expectPass: true, + contains: "", + }, + }, + } + for _, tc := range testCases { + suite.Run(tc.name, func() { + // 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.user}, + []sdk.Coins{tc.args.initialBorrowerCoins}, + ) + + // Harvest module genesis state + harvestGS := types.NewGenesisState(types.NewParams( + true, + types.DistributionSchedules{ + 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.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())}), + time.Hour*24, + ), + }, + types.MoneyMarkets{ + types.NewMoneyMarket("ukava", + types.NewBorrowLimit(false, sdk.NewDec(100000000*KAVA_CF), sdk.MustNewDecFromStr("0.8")), // Borrow Limit + "kava:usd", // Market ID + sdk.NewInt(KAVA_CF), // Conversion Factor + tc.args.interestRateModel, + tc.args.reserveFactor), // Reserve Factor + }, + ), types.DefaultPreviousBlockTime, types.DefaultDistributionTimes) + + // Pricefeed module genesis state + pricefeedGS := pricefeed.GenesisState{ + Params: pricefeed.Params{ + Markets: []pricefeed.Market{ + {MarketID: "kava:usd", BaseAsset: "kava", QuoteAsset: "usd", Oracles: []sdk.AccAddress{}, Active: true}, + }, + }, + PostedPrices: []pricefeed.PostedPrice{ + { + MarketID: "kava:usd", + OracleAddress: sdk.AccAddress{}, + Price: sdk.MustNewDecFromStr("2.00"), + Expiry: time.Now().Add(100 * time.Hour), + }, + }, + } + + // Initialize test application + tApp.InitializeFromGenesisStates(authGS, + app.GenesisState{pricefeed.ModuleName: pricefeed.ModuleCdc.MustMarshalJSON(pricefeedGS)}, + app.GenesisState{types.ModuleName: types.ModuleCdc.MustMarshalJSON(harvestGS)}) + + // Mint coins to Harvest module account + supplyKeeper := tApp.GetSupplyKeeper() + supplyKeeper.MintCoins(ctx, types.ModuleAccountName, tc.args.initialModuleCoins) + + keeper := tApp.GetHarvestKeeper() + suite.app = tApp + suite.ctx = ctx + suite.keeper = keeper + + var err error + + // Run begin blocker and store initial block time + harvest.BeginBlocker(suite.ctx, suite.keeper) + + // Deposit 2x as many coins for each coin we intend to borrow + for _, coin := range tc.args.borrowCoins { + err = suite.keeper.Deposit(suite.ctx, tc.args.user, sdk.NewCoin(coin.Denom, coin.Amount.Mul(sdk.NewInt(2)))) + suite.Require().NoError(err) + } + + // Borrow coins + err = suite.keeper.Borrow(suite.ctx, tc.args.user, tc.args.borrowCoins) + suite.Require().NoError(err) + + // Check that the initial module-level borrow balance is correct and store it + initialBorrowedCoins, _ := suite.keeper.GetBorrowedCoins(suite.ctx) + suite.Require().Equal(tc.args.borrowCoins, initialBorrowedCoins) + + // Check interest levels for each snapshot + prevCtx := suite.ctx + for _, snapshot := range tc.args.expectedInterestSnaphots { + // ---------------------------- Calculate expected interest ---------------------------- + // 1. Get cash, borrows, reserves, and borrow index + cashPrior := suite.getModuleAccountAtCtx(types.ModuleName, prevCtx).GetCoins().AmountOf(tc.args.borrowCoinDenom) + + borrowCoinsPrior, borrowCoinsPriorFound := suite.keeper.GetBorrowedCoins(prevCtx) + suite.Require().True(borrowCoinsPriorFound) + borrowCoinPriorAmount := borrowCoinsPrior.AmountOf(tc.args.borrowCoinDenom) + + reservesPrior, foundReservesPrior := suite.keeper.GetTotalReserves(prevCtx, tc.args.borrowCoinDenom) + if !foundReservesPrior { + reservesPrior = sdk.NewCoin(tc.args.borrowCoinDenom, sdk.ZeroInt()) + } + + borrowIndexPrior, foundBorrowIndexPrior := suite.keeper.GetBorrowIndex(prevCtx, tc.args.borrowCoinDenom) + suite.Require().True(foundBorrowIndexPrior) + + // 2. Calculate expected interest owed + borrowRateApy, err := harvest.CalculateBorrowRate(tc.args.interestRateModel, sdk.NewDecFromInt(cashPrior), sdk.NewDecFromInt(borrowCoinPriorAmount), sdk.NewDecFromInt(reservesPrior.Amount)) + suite.Require().NoError(err) + + // Convert from APY to SPY, expressed as (1 + borrow rate) + borrowRateSpy, err := harvest.APYToSPY(sdk.OneDec().Add(borrowRateApy)) + suite.Require().NoError(err) + + interestFactor := harvest.CalculateInterestFactor(borrowRateSpy, sdk.NewInt(snapshot.elapsedTime)) + expectedInterest := (interestFactor.Mul(sdk.NewDecFromInt(borrowCoinPriorAmount)).TruncateInt()).Sub(borrowCoinPriorAmount) + expectedReserves := reservesPrior.Add(sdk.NewCoin(tc.args.borrowCoinDenom, sdk.NewDecFromInt(expectedInterest).Mul(tc.args.reserveFactor).TruncateInt())) + expectedBorrowIndex := borrowIndexPrior.Mul(interestFactor) + // ------------------------------------------------------------------------------------- + + // Set up snapshot chain context and run begin blocker + runAtTime := time.Unix(prevCtx.BlockTime().Unix()+(snapshot.elapsedTime), 0) + snapshotCtx := prevCtx.WithBlockTime(runAtTime) + harvest.BeginBlocker(snapshotCtx, suite.keeper) + + // Check that the total amount of borrowed coins has increased by expected interest amount + expectedBorrowedCoins := borrowCoinsPrior.AmountOf(tc.args.borrowCoinDenom).Add(expectedInterest) + currBorrowedCoins, _ := suite.keeper.GetBorrowedCoins(snapshotCtx) + suite.Require().Equal(expectedBorrowedCoins, currBorrowedCoins.AmountOf(tc.args.borrowCoinDenom)) + + // Check that the total reserves have changed as expected + currTotalReserves, _ := suite.keeper.GetTotalReserves(snapshotCtx, tc.args.borrowCoinDenom) + suite.Require().Equal(expectedReserves, currTotalReserves) + + // Check that the borrow index has increased as expected + currIndexPrior, _ := suite.keeper.GetBorrowIndex(snapshotCtx, tc.args.borrowCoinDenom) + suite.Require().Equal(expectedBorrowIndex, currIndexPrior) + + // After borrowing again user's borrow balance should have any outstanding interest applied + if snapshot.shouldBorrow { + borrowCoinsBefore, _ := suite.keeper.GetBorrow(snapshotCtx, tc.args.user) + expectedInterestCoins := sdk.NewCoin(tc.args.borrowCoinDenom, expectedInterest) + expectedBorrowCoinsAfter := borrowCoinsBefore.Amount.Add(snapshot.borrowCoin).Add(expectedInterestCoins) + + err = suite.keeper.Borrow(snapshotCtx, tc.args.user, sdk.NewCoins(snapshot.borrowCoin)) + suite.Require().NoError(err) + + borrowCoinsAfter, _ := suite.keeper.GetBorrow(snapshotCtx, tc.args.user) + suite.Require().Equal(expectedBorrowCoinsAfter, borrowCoinsAfter.Amount) + } + // Update previous context to this snapshot's context, segmenting time periods between snapshots + prevCtx = snapshotCtx + } + }) + } +} + +func TestInterestTestSuite(t *testing.T) { + suite.Run(t, new(InterestTestSuite)) +} diff --git a/x/harvest/keeper/keeper.go b/x/harvest/keeper/keeper.go index 5e111d56..d7341dc5 100644 --- a/x/harvest/keeper/keeper.go +++ b/x/harvest/keeper/keeper.go @@ -256,42 +256,99 @@ func (k Keeper) GetBorrowedCoins(ctx sdk.Context) (sdk.Coins, bool) { return borrowedCoins, true } -// GetInterestRateModel returns an interest rate model from the store for a denom -func (k Keeper) GetInterestRateModel(ctx sdk.Context, denom string) (types.InterestRateModel, bool) { - store := prefix.NewStore(ctx.KVStore(k.key), types.InterestRateModelsPrefix) +// GetMoneyMarket returns a money market from the store for a denom +func (k Keeper) GetMoneyMarket(ctx sdk.Context, denom string) (types.MoneyMarket, bool) { + store := prefix.NewStore(ctx.KVStore(k.key), types.MoneyMarketsPrefix) bz := store.Get([]byte(denom)) if bz == nil { - return types.InterestRateModel{}, false + return types.MoneyMarket{}, false } - var interestRateModel types.InterestRateModel - k.cdc.MustUnmarshalBinaryBare(bz, &interestRateModel) - return interestRateModel, true + var moneyMarket types.MoneyMarket + k.cdc.MustUnmarshalBinaryBare(bz, &moneyMarket) + return moneyMarket, true } -// SetInterestRateModel sets an interest rate model in the store for a denom -func (k Keeper) SetInterestRateModel(ctx sdk.Context, denom string, interestRateModel types.InterestRateModel) { - store := prefix.NewStore(ctx.KVStore(k.key), types.InterestRateModelsPrefix) - bz := k.cdc.MustMarshalBinaryBare(interestRateModel) +// SetMoneyMarket sets a money market in the store for a denom +func (k Keeper) SetMoneyMarket(ctx sdk.Context, denom string, moneyMarket types.MoneyMarket) { + store := prefix.NewStore(ctx.KVStore(k.key), types.MoneyMarketsPrefix) + bz := k.cdc.MustMarshalBinaryBare(moneyMarket) store.Set([]byte(denom), bz) } -// DeleteInterestRateModel deletes an interest rate model from the store -func (k Keeper) DeleteInterestRateModel(ctx sdk.Context, denom string) { - store := prefix.NewStore(ctx.KVStore(k.key), types.InterestRateModelsPrefix) +// DeleteMoneyMarket deletes a money market from the store +func (k Keeper) DeleteMoneyMarket(ctx sdk.Context, denom string) { + store := prefix.NewStore(ctx.KVStore(k.key), types.MoneyMarketsPrefix) store.Delete([]byte(denom)) } -// IterateInterestRateModels iterates over all interest rate model objects in the store and performs a callback function -// that returns both the interest rate model value and the key it's stored under -func (k Keeper) IterateInterestRateModels(ctx sdk.Context, cb func(denom string, interestRateModel types.InterestRateModel) (stop bool)) { - store := prefix.NewStore(ctx.KVStore(k.key), types.InterestRateModelsPrefix) +// IterateMoneyMarkets iterates over all money markets objects in the store and performs a callback function +// that returns both the money market and the key (denom) it's stored under +func (k Keeper) IterateMoneyMarkets(ctx sdk.Context, cb func(denom string, moneyMarket types.MoneyMarket) (stop bool)) { + store := prefix.NewStore(ctx.KVStore(k.key), types.MoneyMarketsPrefix) iterator := sdk.KVStorePrefixIterator(store, []byte{}) defer iterator.Close() for ; iterator.Valid(); iterator.Next() { - var interestRateModel types.InterestRateModel - k.cdc.MustUnmarshalBinaryBare(iterator.Value(), &interestRateModel) - if cb(string(iterator.Key()), interestRateModel) { + var moneyMarket types.MoneyMarket + k.cdc.MustUnmarshalBinaryBare(iterator.Value(), &moneyMarket) + if cb(string(iterator.Key()), moneyMarket) { break } } } + +// GetPreviousAccrualTime returns the last time an individual market accrued interest +func (k Keeper) GetPreviousAccrualTime(ctx sdk.Context, denom string) (time.Time, bool) { + store := prefix.NewStore(ctx.KVStore(k.key), types.PreviousAccrualTimePrefix) + bz := store.Get([]byte(denom)) + if bz == nil { + return time.Time{}, false + } + var previousAccrualTime time.Time + k.cdc.MustUnmarshalBinaryBare(bz, &previousAccrualTime) + return previousAccrualTime, true +} + +// SetPreviousAccrualTime sets the most recent accrual time for a particular market +func (k Keeper) SetPreviousAccrualTime(ctx sdk.Context, denom string, previousAccrualTime time.Time) { + store := prefix.NewStore(ctx.KVStore(k.key), types.PreviousAccrualTimePrefix) + bz := k.cdc.MustMarshalBinaryBare(previousAccrualTime) + store.Set([]byte(denom), bz) +} + +// GetTotalReserves returns the total reserves for an individual market +func (k Keeper) GetTotalReserves(ctx sdk.Context, denom string) (sdk.Coin, bool) { + store := prefix.NewStore(ctx.KVStore(k.key), types.TotalReservesPrefix) + bz := store.Get([]byte(denom)) + if bz == nil { + return sdk.Coin{}, false + } + var totalReserves sdk.Coin + k.cdc.MustUnmarshalBinaryBare(bz, &totalReserves) + return totalReserves, true +} + +// SetTotalReserves sets the total reserves for an individual market +func (k Keeper) SetTotalReserves(ctx sdk.Context, denom string, coin sdk.Coin) { + store := prefix.NewStore(ctx.KVStore(k.key), types.TotalReservesPrefix) + bz := k.cdc.MustMarshalBinaryBare(coin) + store.Set([]byte(denom), bz) +} + +// GetBorrowIndex returns the current borrow index for an individual market +func (k Keeper) GetBorrowIndex(ctx sdk.Context, denom string) (sdk.Dec, bool) { + store := prefix.NewStore(ctx.KVStore(k.key), types.BorrowIndexPrefix) + bz := store.Get([]byte(denom)) + if bz == nil { + return sdk.ZeroDec(), false + } + var borrowIndex sdk.Dec + k.cdc.MustUnmarshalBinaryBare(bz, &borrowIndex) + return borrowIndex, true +} + +// SetBorrowIndex sets the current borrow index for an individual market +func (k Keeper) SetBorrowIndex(ctx sdk.Context, denom string, borrowIndex sdk.Dec) { + store := prefix.NewStore(ctx.KVStore(k.key), types.BorrowIndexPrefix) + bz := k.cdc.MustMarshalBinaryBare(borrowIndex) + store.Set([]byte(denom), bz) +} diff --git a/x/harvest/keeper/keeper_test.go b/x/harvest/keeper/keeper_test.go index 6d09e39f..d2565aef 100644 --- a/x/harvest/keeper/keeper_test.go +++ b/x/harvest/keeper/keeper_test.go @@ -21,7 +21,6 @@ import ( // Test suite used for all keeper tests type KeeperTestSuite struct { suite.Suite - keeper keeper.Keeper app app.TestApp ctx sdk.Context @@ -157,45 +156,52 @@ func (suite *KeeperTestSuite) TestGetSetDeleteClaim() { func (suite *KeeperTestSuite) TestGetSetDeleteInterestRateModel() { denom := "test" model := types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10")) + borrowLimit := types.NewBorrowLimit(false, sdk.MustNewDecFromStr("0.2"), sdk.MustNewDecFromStr("0.5")) + moneyMarket := types.NewMoneyMarket(denom, borrowLimit, denom+":usd", sdk.NewInt(1000000), model, sdk.MustNewDecFromStr("0.05")) - _, f := suite.keeper.GetInterestRateModel(suite.ctx, denom) + _, f := suite.keeper.GetMoneyMarket(suite.ctx, denom) suite.Require().False(f) - suite.keeper.SetInterestRateModel(suite.ctx, denom, model) + suite.keeper.SetMoneyMarket(suite.ctx, denom, moneyMarket) - testInterestRateModel, f := suite.keeper.GetInterestRateModel(suite.ctx, denom) + testMoneyMarket, f := suite.keeper.GetMoneyMarket(suite.ctx, denom) suite.Require().True(f) - suite.Require().Equal(model, testInterestRateModel) + suite.Require().Equal(moneyMarket, testMoneyMarket) - suite.Require().NotPanics(func() { suite.keeper.DeleteInterestRateModel(suite.ctx, denom) }) + suite.Require().NotPanics(func() { suite.keeper.DeleteMoneyMarket(suite.ctx, denom) }) - _, f = suite.keeper.GetInterestRateModel(suite.ctx, denom) + _, f = suite.keeper.GetMoneyMarket(suite.ctx, denom) suite.Require().False(f) - } func (suite *KeeperTestSuite) TestIterateInterestRateModels() { testDenom := "test" - var setModels types.InterestRateModels + var setMMs types.MoneyMarkets var setDenoms []string for i := 0; i < 5; i++ { + // Initialize a new money market denom := testDenom + strconv.Itoa(i) model := types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10")) - suite.Require().NotPanics(func() { suite.keeper.SetInterestRateModel(suite.ctx, denom, model) }) + borrowLimit := types.NewBorrowLimit(false, sdk.MustNewDecFromStr("0.2"), sdk.MustNewDecFromStr("0.5")) + moneyMarket := types.NewMoneyMarket(denom, borrowLimit, denom+":usd", sdk.NewInt(1000000), model, sdk.MustNewDecFromStr("0.05")) + + // Store money market in the module's store + suite.Require().NotPanics(func() { suite.keeper.SetMoneyMarket(suite.ctx, denom, moneyMarket) }) + // Save the denom and model setDenoms = append(setDenoms, denom) - setModels = append(setModels, model) + setMMs = append(setMMs, moneyMarket) } - var seenModels types.InterestRateModels + var seenMMs types.MoneyMarkets var seenDenoms []string - suite.keeper.IterateInterestRateModels(suite.ctx, func(denom string, i types.InterestRateModel) bool { + suite.keeper.IterateMoneyMarkets(suite.ctx, func(denom string, i types.MoneyMarket) bool { seenDenoms = append(seenDenoms, denom) - seenModels = append(seenModels, i) + seenMMs = append(seenMMs, i) return false }) - suite.Require().Equal(setModels, seenModels) + suite.Require().Equal(setMMs, seenMMs) suite.Require().Equal(setDenoms, seenDenoms) } @@ -204,11 +210,21 @@ func (suite *KeeperTestSuite) getAccount(addr sdk.AccAddress) authexported.Accou return ak.GetAccount(suite.ctx, addr) } +func (suite *KeeperTestSuite) getAccountAtCtx(addr sdk.AccAddress, ctx sdk.Context) authexported.Account { + ak := suite.app.GetAccountKeeper() + return ak.GetAccount(ctx, addr) +} + func (suite *KeeperTestSuite) getModuleAccount(name string) supplyexported.ModuleAccountI { sk := suite.app.GetSupplyKeeper() return sk.GetModuleAccount(suite.ctx, name) } +func (suite *KeeperTestSuite) getModuleAccountAtCtx(name string, ctx sdk.Context) supplyexported.ModuleAccountI { + sk := suite.app.GetSupplyKeeper() + return sk.GetModuleAccount(ctx, name) +} + func TestKeeperTestSuite(t *testing.T) { suite.Run(t, new(KeeperTestSuite)) } diff --git a/x/harvest/keeper/params.go b/x/harvest/keeper/params.go index c4b22075..a6381cc7 100644 --- a/x/harvest/keeper/params.go +++ b/x/harvest/keeper/params.go @@ -40,8 +40,8 @@ func (k Keeper) GetDelegatorSchedule(ctx sdk.Context, denom string) (types.Deleg return types.DelegatorDistributionSchedule{}, false } -// GetMoneyMarket returns the corresponding Money Market param for a specific denom -func (k Keeper) GetMoneyMarket(ctx sdk.Context, denom string) (types.MoneyMarket, bool) { +// GetMoneyMarketParam returns the corresponding Money Market param for a specific denom +func (k Keeper) GetMoneyMarketParam(ctx sdk.Context, denom string) (types.MoneyMarket, bool) { params := k.GetParams(ctx) for _, mm := range params.MoneyMarkets { if mm.Denom == denom { diff --git a/x/harvest/keeper/rewards_test.go b/x/harvest/keeper/rewards_test.go index 8f558516..b2eacea3 100644 --- a/x/harvest/keeper/rewards_test.go +++ b/x/harvest/keeper/rewards_test.go @@ -75,8 +75,8 @@ func (suite *KeeperTestSuite) TestApplyDepositRewards() { ), }, types.MoneyMarkets{ - types.NewMoneyMarket("usdx", false, sdk.NewDec(1000000000000000), loanToValue, "usdx:usd", sdk.NewInt(1000000), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10"))), - types.NewMoneyMarket("ukava", false, sdk.NewDec(1000000000000000), loanToValue, "kava:usd", sdk.NewInt(1000000), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10"))), + types.NewMoneyMarket("usdx", types.NewBorrowLimit(false, sdk.NewDec(1000000000000000), loanToValue), "usdx:usd", sdk.NewInt(1000000), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10")), sdk.MustNewDecFromStr("0.05")), + types.NewMoneyMarket("ukava", types.NewBorrowLimit(false, sdk.NewDec(1000000000000000), loanToValue), "kava:usd", sdk.NewInt(1000000), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10")), sdk.MustNewDecFromStr("0.05")), }, ), tc.args.previousBlockTime, types.DefaultDistributionTimes) tApp.InitializeFromGenesisStates(app.GenesisState{types.ModuleName: types.ModuleCdc.MustMarshalJSON(harvestGS)}) @@ -443,8 +443,8 @@ func harvestGenesisState(rewardRate sdk.Coin) app.GenesisState { ), }, types.MoneyMarkets{ - types.NewMoneyMarket("usdx", false, sdk.NewDec(1000000000000000), loanToValue, "usdx:usd", sdk.NewInt(1000000), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10"))), - types.NewMoneyMarket("ukava", false, sdk.NewDec(1000000000000000), loanToValue, "kava:usd", sdk.NewInt(1000000), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10"))), + types.NewMoneyMarket("usdx", types.NewBorrowLimit(false, sdk.NewDec(1000000000000000), loanToValue), "usdx:usd", sdk.NewInt(1000000), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10")), sdk.MustNewDecFromStr("0.05")), + types.NewMoneyMarket("ukava", types.NewBorrowLimit(false, sdk.NewDec(1000000000000000), loanToValue), "kava:usd", sdk.NewInt(1000000), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10")), sdk.MustNewDecFromStr("0.05")), }, ), types.DefaultPreviousBlockTime, diff --git a/x/harvest/keeper/timelock_test.go b/x/harvest/keeper/timelock_test.go index 50538b18..8f8d3889 100644 --- a/x/harvest/keeper/timelock_test.go +++ b/x/harvest/keeper/timelock_test.go @@ -291,8 +291,8 @@ func (suite *KeeperTestSuite) TestSendTimeLockedCoinsToAccount() { ), }, types.MoneyMarkets{ - types.NewMoneyMarket("usdx", false, sdk.NewDec(1000000000000000), loanToValue, "usdx:usd", sdk.NewInt(1000000), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10"))), - types.NewMoneyMarket("ukava", false, sdk.NewDec(1000000000000000), loanToValue, "kava:usd", sdk.NewInt(1000000), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10"))), + types.NewMoneyMarket("usdx", types.NewBorrowLimit(false, sdk.NewDec(1000000000000000), loanToValue), "usdx:usd", sdk.NewInt(1000000), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10")), sdk.MustNewDecFromStr("0.05")), + types.NewMoneyMarket("ukava", types.NewBorrowLimit(false, sdk.NewDec(1000000000000000), loanToValue), "kava:usd", sdk.NewInt(1000000), types.NewInterestRateModel(sdk.MustNewDecFromStr("0.05"), sdk.MustNewDecFromStr("2"), sdk.MustNewDecFromStr("0.8"), sdk.MustNewDecFromStr("10")), sdk.MustNewDecFromStr("0.05")), }, ), types.DefaultPreviousBlockTime, types.DefaultDistributionTimes) tApp.InitializeFromGenesisStates(authGS, app.GenesisState{types.ModuleName: types.ModuleCdc.MustMarshalJSON(harvestGS)}) diff --git a/x/harvest/types/borrow.go b/x/harvest/types/borrow.go index 7217312f..e14431c8 100644 --- a/x/harvest/types/borrow.go +++ b/x/harvest/types/borrow.go @@ -4,16 +4,35 @@ import ( sdk "github.com/cosmos/cosmos-sdk/types" ) +// BorrowIndexItem defines an individual borrow index +type BorrowIndexItem struct { + Denom string `json:"denom" yaml:"denom"` + Value sdk.Dec `json:"value" yaml:"value"` +} + +// NewBorrowIndexItem returns a new BorrowIndexItem instance +func NewBorrowIndexItem(denom string, value sdk.Dec) BorrowIndexItem { + return BorrowIndexItem{ + Denom: denom, + Value: value, + } +} + +// BorrowIndexes is a slice of BorrowIndexItem, because Amino won't marshal maps +type BorrowIndexes []BorrowIndexItem + // Borrow defines an amount of coins borrowed from a harvest module account type Borrow struct { Borrower sdk.AccAddress `json:"borrower" yaml:"borrower"` Amount sdk.Coins `json:"amount" yaml:"amount"` + Index BorrowIndexes `json:"index" yaml:"index"` } // NewBorrow returns a new Borrow instance -func NewBorrow(borrower sdk.AccAddress, amount sdk.Coins) Borrow { +func NewBorrow(borrower sdk.AccAddress, amount sdk.Coins, index BorrowIndexes) Borrow { return Borrow{ Borrower: borrower, Amount: amount, + Index: index, } } diff --git a/x/harvest/types/errors.go b/x/harvest/types/errors.go index ce3d96fb..ec37e74f 100644 --- a/x/harvest/types/errors.go +++ b/x/harvest/types/errors.go @@ -55,4 +55,8 @@ var ( ErrGreaterThanAssetBorrowLimit = sdkerrors.Register(ModuleName, 24, "fails global asset borrow limit validation") // ErrBorrowEmptyCoins error for when you cannot borrow empty coins ErrBorrowEmptyCoins = sdkerrors.Register(ModuleName, 25, "cannot borrow zero coins") + // ErrPreviousAccrualTimeNotFound error for no previous accrual time found in store + ErrPreviousAccrualTimeNotFound = sdkerrors.Register(ModuleName, 26, "no previous accrual time found") + // ErrBorrowNotFound error for when borrow not found in store + ErrBorrowNotFound = sdkerrors.Register(ModuleName, 27, "no borrow found") ) diff --git a/x/harvest/types/keys.go b/x/harvest/types/keys.go index f112b90c..334eff24 100644 --- a/x/harvest/types/keys.go +++ b/x/harvest/types/keys.go @@ -37,7 +37,10 @@ var ( ClaimsKeyPrefix = []byte{0x04} BorrowsKeyPrefix = []byte{0x05} BorrowedCoinsPrefix = []byte{0x06} - InterestRateModelsPrefix = []byte{0x07} + MoneyMarketsPrefix = []byte{0x07} + PreviousAccrualTimePrefix = []byte{0x08} // denom -> time + TotalReservesPrefix = []byte{0x09} // denom -> sdk.Coin + BorrowIndexPrefix = []byte{0x10} // denom -> sdk.Dec sep = []byte(":") ) diff --git a/x/harvest/types/params.go b/x/harvest/types/params.go index f9c7949d..920de402 100644 --- a/x/harvest/types/params.go +++ b/x/harvest/types/params.go @@ -251,6 +251,20 @@ func (bl BorrowLimit) Validate() error { return nil } +// Equal returns a boolean indicating if an BorrowLimit is equal to another BorrowLimit +func (bl BorrowLimit) Equal(blCompareTo BorrowLimit) bool { + if bl.HasMaxLimit != blCompareTo.HasMaxLimit { + return false + } + if !bl.MaximumLimit.Equal(blCompareTo.MaximumLimit) { + return false + } + if !bl.LoanToValue.Equal(blCompareTo.LoanToValue) { + return false + } + return true +} + // MoneyMarket is a money market for an individual asset type MoneyMarket struct { Denom string `json:"denom" yaml:"denom"` @@ -258,17 +272,19 @@ type MoneyMarket struct { SpotMarketID string `json:"spot_market_id" yaml:"spot_market_id"` ConversionFactor sdk.Int `json:"conversion_factor" yaml:"conversion_factor"` InterestRateModel InterestRateModel `json:"interest_rate_model" yaml:"interest_rate_model"` + ReserveFactor sdk.Dec `json:"reserve_factor" yaml:"reserve_factor"` } // NewMoneyMarket returns a new MoneyMarket -func NewMoneyMarket(denom string, hasMaxLimit bool, maximumLimit, loanToValue sdk.Dec, - spotMarketID string, conversionFactor sdk.Int, interestRateModel InterestRateModel) MoneyMarket { +func NewMoneyMarket(denom string, borrowLimit BorrowLimit, spotMarketID string, + conversionFactor sdk.Int, interestRateModel InterestRateModel, reserveFactor sdk.Dec) MoneyMarket { return MoneyMarket{ Denom: denom, - BorrowLimit: NewBorrowLimit(hasMaxLimit, maximumLimit, loanToValue), + BorrowLimit: borrowLimit, SpotMarketID: spotMarketID, ConversionFactor: conversionFactor, InterestRateModel: interestRateModel, + ReserveFactor: reserveFactor, } } @@ -285,9 +301,36 @@ func (mm MoneyMarket) Validate() error { if err := mm.InterestRateModel.Validate(); err != nil { return err } + + if mm.ReserveFactor.IsNegative() || mm.ReserveFactor.GT(sdk.OneDec()) { + return fmt.Errorf("Reserve factor must be between 0.0-1.0") + } return nil } +// Equal returns a boolean indicating if a MoneyMarket is equal to another MoneyMarket +func (mm MoneyMarket) Equal(mmCompareTo MoneyMarket) bool { + if mm.Denom != mmCompareTo.Denom { + return false + } + if !mm.BorrowLimit.Equal(mmCompareTo.BorrowLimit) { + return false + } + if mm.SpotMarketID != mmCompareTo.SpotMarketID { + return false + } + if !mm.ConversionFactor.Equal(mmCompareTo.ConversionFactor) { + return false + } + if !mm.InterestRateModel.Equal(mmCompareTo.InterestRateModel) { + return false + } + if !mm.ReserveFactor.Equal(mmCompareTo.ReserveFactor) { + return false + } + return true +} + // MoneyMarkets slice of MoneyMarket type MoneyMarkets []MoneyMarket @@ -341,17 +384,17 @@ func (irm InterestRateModel) Validate() error { } // Equal returns a boolean indicating if an InterestRateModel is equal to another InterestRateModel -func (irm InterestRateModel) Equal(comparisonIRM InterestRateModel) bool { - if !irm.BaseRateAPY.Equal(comparisonIRM.BaseRateAPY) { +func (irm InterestRateModel) Equal(irmCompareTo InterestRateModel) bool { + if !irm.BaseRateAPY.Equal(irmCompareTo.BaseRateAPY) { return false } - if !irm.BaseMultiplier.Equal(comparisonIRM.BaseMultiplier) { + if !irm.BaseMultiplier.Equal(irmCompareTo.BaseMultiplier) { return false } - if !irm.Kink.Equal(comparisonIRM.Kink) { + if !irm.Kink.Equal(irmCompareTo.Kink) { return false } - if !irm.JumpMultiplier.Equal(comparisonIRM.JumpMultiplier) { + if !irm.JumpMultiplier.Equal(irmCompareTo.JumpMultiplier) { return false } return true