diff --git a/x/hard/alias.go b/x/hard/alias.go index 7c57139c..bf530e60 100644 --- a/x/hard/alias.go +++ b/x/hard/alias.go @@ -45,6 +45,7 @@ const ( var ( // function aliases APYToSPY = keeper.APYToSPY + SPYToEstimatedAPY = keeper.SPYToEstimatedAPY CalculateBorrowInterestFactor = keeper.CalculateBorrowInterestFactor CalculateBorrowRate = keeper.CalculateBorrowRate CalculateSupplyInterestFactor = keeper.CalculateSupplyInterestFactor diff --git a/x/hard/client/cli/query.go b/x/hard/client/cli/query.go index c63bc6d7..df125f0d 100644 --- a/x/hard/client/cli/query.go +++ b/x/hard/client/cli/query.go @@ -41,6 +41,7 @@ func GetQueryCmd(queryRoute string, cdc *codec.Codec) *cobra.Command { queryTotalDepositedCmd(queryRoute, cdc), queryBorrowsCmd(queryRoute, cdc), queryTotalBorrowedCmd(queryRoute, cdc), + queryInterestRateCmd(queryRoute, cdc), )...) return hardQueryCmd @@ -316,3 +317,46 @@ func queryTotalDepositedCmd(queryRoute string, cdc *codec.Codec) *cobra.Command cmd.Flags().String(flagDenom, "", "(optional) filter total deposited coins by denom") return cmd } + +func queryInterestRateCmd(queryRoute string, cdc *codec.Codec) *cobra.Command { + cmd := &cobra.Command{ + Use: "interest-rate", + Short: "get current money market interest rates", + Long: strings.TrimSpace(`get current money market interest rates: + + Example: + $ kvcli q hard interest-rate + $ kvcli q hard interest-rate --denom bnb`, + ), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + + denom := viper.GetString(flagDenom) + + // Construct query with params + params := types.NewQueryInterestRateParams(denom) + bz, err := cdc.MarshalJSON(params) + if err != nil { + return err + } + + // Execute query + route := fmt.Sprintf("custom/%s/%s", queryRoute, types.QueryGetInterestRate) + res, height, err := cliCtx.QueryWithData(route, bz) + if err != nil { + return err + } + cliCtx = cliCtx.WithHeight(height) + + // Decode and print results + var moneyMarketInterestRates types.MoneyMarketInterestRates + if err := cdc.UnmarshalJSON(res, &moneyMarketInterestRates); err != nil { + return fmt.Errorf("failed to unmarshal money market interest rates: %w", err) + } + return cliCtx.PrintOutput(moneyMarketInterestRates) + }, + } + cmd.Flags().String(flagDenom, "", "(optional) filter interest rates by denom") + return cmd +} diff --git a/x/hard/client/rest/query.go b/x/hard/client/rest/query.go index cba86553..24b8929f 100644 --- a/x/hard/client/rest/query.go +++ b/x/hard/client/rest/query.go @@ -21,6 +21,7 @@ func registerQueryRoutes(cliCtx context.CLIContext, r *mux.Router) { r.HandleFunc(fmt.Sprintf("/%s/accounts", types.ModuleName), queryModAccountsHandlerFn(cliCtx)).Methods("GET") r.HandleFunc(fmt.Sprintf("/%s/borrows", types.ModuleName), queryBorrowsHandlerFn(cliCtx)).Methods("GET") r.HandleFunc(fmt.Sprintf("/%s/total-borrowed", types.ModuleName), queryTotalBorrowedHandlerFn(cliCtx)).Methods("GET") + r.HandleFunc(fmt.Sprintf("/%s/interest-rate", types.ModuleName), queryInterestRateHandlerFn(cliCtx)).Methods("GET") } func queryParamsHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { @@ -215,6 +216,44 @@ func queryTotalBorrowedHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { } } +func queryInterestRateHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + _, _, _, err := rest.ParseHTTPArgsWithLimit(r, 0) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + // Parse the query height + cliCtx, ok := rest.ParseQueryHeightOrReturnBadRequest(w, cliCtx, r) + if !ok { + return + } + + var denom string + + if x := r.URL.Query().Get(RestDenom); len(x) != 0 { + denom = strings.TrimSpace(x) + } + + params := types.NewQueryInterestRateParams(denom) + + bz, err := cliCtx.Codec.MarshalJSON(params) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + route := fmt.Sprintf("custom/%s/%s", types.ModuleName, types.QueryGetInterestRate) + res, height, err := cliCtx.QueryWithData(route, bz) + cliCtx = cliCtx.WithHeight(height) + if err != nil { + rest.WriteErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + rest.PostProcessResponse(w, cliCtx, res) + } +} + func queryModAccountsHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { _, page, limit, err := rest.ParseHTTPArgsWithLimit(r, 0) diff --git a/x/hard/keeper/interest.go b/x/hard/keeper/interest.go index bc28a8c8..0745bbea 100644 --- a/x/hard/keeper/interest.go +++ b/x/hard/keeper/interest.go @@ -300,6 +300,12 @@ func APYToSPY(apy sdk.Dec) (sdk.Dec, error) { return root, nil } +// SPYToEstimatedAPY converts the internal per second compounded interest rate into an estimated annual +// interest rate. The returned value is an estimate and should not be used for financial calculations. +func SPYToEstimatedAPY(apy sdk.Dec) sdk.Dec { + return apy.Power(uint64(secondsPerYear)) +} + // minInt64 returns the smaller of x or y func minDec(x, y sdk.Dec) sdk.Dec { if x.GT(y) { diff --git a/x/hard/keeper/interest_test.go b/x/hard/keeper/interest_test.go index ac34a479..db4b177c 100644 --- a/x/hard/keeper/interest_test.go +++ b/x/hard/keeper/interest_test.go @@ -1,6 +1,7 @@ package keeper_test import ( + "strconv" "testing" "time" @@ -454,6 +455,81 @@ func (suite *InterestTestSuite) TestAPYToSPY() { } } +func (suite *InterestTestSuite) TestSPYToEstimatedAPY() { + type args struct { + spy sdk.Dec + expectedAPY float64 + acceptableRange float64 + } + + type test struct { + name string + args args + } + + testCases := []test{ + { + "lowest apy", + args{ + spy: sdk.MustNewDecFromStr("0.999999831991472557"), + expectedAPY: 0.005, // Returned value: 0.004999999888241291 + acceptableRange: 0.00001, // +/- 1/10000th of a precent + }, + }, + { + "lower apy", + args{ + spy: sdk.MustNewDecFromStr("0.999999905005957279"), + expectedAPY: 0.05, // Returned value: 0.05000000074505806 + acceptableRange: 0.00001, // +/- 1/10000th of a precent + }, + }, + { + "medium-low apy", + args{ + spy: sdk.MustNewDecFromStr("0.999999978020447332"), + expectedAPY: 0.5, // Returned value: 0.5 + acceptableRange: 0.00001, // +/- 1/10000th of a precent + }, + }, + { + "medium-high apy", + args{ + spy: sdk.MustNewDecFromStr("1.000000051034942717"), + expectedAPY: 5, // Returned value: 5 + acceptableRange: 0.00001, // +/- 1/10000th of a precent + }, + }, + { + "high apy", + args{ + spy: sdk.MustNewDecFromStr("1.000000124049443433"), + expectedAPY: 50, // Returned value: 50 + acceptableRange: 0.00001, // +/- 1/10000th of a precent + }, + }, + { + "highest apy", + args{ + spy: sdk.MustNewDecFromStr("1.000000146028999310"), + expectedAPY: 100, // 100 + acceptableRange: 0.00001, // +/- 1/10000th of a precent + }, + }, + } + for _, tc := range testCases { + suite.Run(tc.name, func() { + // From SPY calculate APY and parse result from sdk.Dec to float64 + calculatedAPY := hard.SPYToEstimatedAPY(tc.args.spy) + calculatedAPYFloat, err := strconv.ParseFloat(calculatedAPY.String(), 32) + suite.Require().NoError(err) + + // Check that the calculated value is within an acceptable percentage range + suite.Require().InEpsilon(tc.args.expectedAPY, calculatedAPYFloat, tc.args.acceptableRange) + }) + } +} + type ExpectedBorrowInterest struct { elapsedTime int64 shouldBorrow bool diff --git a/x/hard/keeper/keeper.go b/x/hard/keeper/keeper.go index 6398337d..9386fe2a 100644 --- a/x/hard/keeper/keeper.go +++ b/x/hard/keeper/keeper.go @@ -235,6 +235,15 @@ func (k Keeper) IterateMoneyMarkets(ctx sdk.Context, cb func(denom string, money } } +// GetAllMoneyMarkets returns all money markets from the store +func (k Keeper) GetAllMoneyMarkets(ctx sdk.Context) (moneyMarkets types.MoneyMarkets) { + k.IterateMoneyMarkets(ctx, func(denom string, moneyMarket types.MoneyMarket) bool { + moneyMarkets = append(moneyMarkets, moneyMarket) + return false + }) + return +} + // 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) diff --git a/x/hard/keeper/querier.go b/x/hard/keeper/querier.go index c06db046..4065ff5b 100644 --- a/x/hard/keeper/querier.go +++ b/x/hard/keeper/querier.go @@ -28,6 +28,8 @@ func NewQuerier(k Keeper) sdk.Querier { return queryGetBorrows(ctx, req, k) case types.QueryGetTotalBorrowed: return queryGetTotalBorrowed(ctx, req, k) + case types.QueryGetInterestRate: + return queryGetInterestRate(ctx, req, k) default: return nil, sdkerrors.Wrapf(sdkerrors.ErrUnknownRequest, "unknown %s query endpoint", types.ModuleName) } @@ -264,3 +266,65 @@ func queryGetTotalDeposited(ctx sdk.Context, req abci.RequestQuery, k Keeper) ([ return bz, nil } + +func queryGetInterestRate(ctx sdk.Context, req abci.RequestQuery, k Keeper) ([]byte, error) { + var params types.QueryInterestRateParams + err := types.ModuleCdc.UnmarshalJSON(req.Data, ¶ms) + if err != nil { + return nil, sdkerrors.Wrap(sdkerrors.ErrJSONUnmarshal, err.Error()) + } + + var moneyMarketInterestRates types.MoneyMarketInterestRates + var moneyMarkets types.MoneyMarkets + if len(params.Denom) > 0 { + moneyMarket, found := k.GetMoneyMarket(ctx, params.Denom) + if !found { + return nil, types.ErrMoneyMarketNotFound + } + moneyMarkets = append(moneyMarkets, moneyMarket) + } else { + moneyMarkets = k.GetAllMoneyMarkets(ctx) + } + + // Calculate the borrow and supply APY interest rates for each money market + for _, moneyMarket := range moneyMarkets { + denom := moneyMarket.Denom + cash := k.supplyKeeper.GetModuleAccount(ctx, types.ModuleName).GetCoins().AmountOf(denom) + + borrowed := sdk.NewCoin(denom, sdk.ZeroInt()) + borrowedCoins, foundBorrowedCoins := k.GetBorrowedCoins(ctx) + if foundBorrowedCoins { + borrowed = sdk.NewCoin(denom, borrowedCoins.AmountOf(denom)) + } + + reserves, foundReserves := k.GetTotalReserves(ctx) + if !foundReserves { + reserves = sdk.NewCoins() + } + + // CalculateBorrowRate calculates the current interest rate based on utilization (the fraction of supply that has been borrowed) + borrowAPY, err := CalculateBorrowRate(moneyMarket.InterestRateModel, sdk.NewDecFromInt(cash), sdk.NewDecFromInt(borrowed.Amount), sdk.NewDecFromInt(reserves.AmountOf(denom))) + if err != nil { + return nil, err + } + + utilRatio := CalculateUtilizationRatio(sdk.NewDecFromInt(cash), sdk.NewDecFromInt(borrowed.Amount), sdk.NewDecFromInt(reserves.AmountOf(denom))) + fullSupplyAPY := borrowAPY.Mul(utilRatio) + realSupplyAPY := fullSupplyAPY.Mul(sdk.OneDec().Sub(moneyMarket.ReserveFactor)) + + moneyMarketInterestRate := types.MoneyMarketInterestRate{ + Denom: denom, + SupplyInterestRate: realSupplyAPY, + BorrowInterestRate: borrowAPY, + } + + moneyMarketInterestRates = append(moneyMarketInterestRates, moneyMarketInterestRate) + } + + bz, err := codec.MarshalJSONIndent(types.ModuleCdc, moneyMarketInterestRates) + if err != nil { + return nil, sdkerrors.Wrap(sdkerrors.ErrJSONMarshal, err.Error()) + } + + return bz, nil +} diff --git a/x/hard/types/querier.go b/x/hard/types/querier.go index 2e0e02b1..c799ff07 100644 --- a/x/hard/types/querier.go +++ b/x/hard/types/querier.go @@ -12,6 +12,7 @@ const ( QueryGetTotalDeposited = "total-deposited" QueryGetBorrows = "borrows" QueryGetTotalBorrowed = "total-borrowed" + QueryGetInterestRate = "interest-rate" ) // QueryDepositsParams is the params for a filtered deposit query @@ -89,3 +90,34 @@ func NewQueryTotalDepositedParams(denom string) QueryTotalDepositedParams { Denom: denom, } } + +// QueryInterestRateParams is the params for a filtered interest rate query +type QueryInterestRateParams struct { + Denom string `json:"denom" yaml:"denom"` +} + +// NewQueryInterestRateParams creates a new QueryInterestRateParams +func NewQueryInterestRateParams(denom string) QueryInterestRateParams { + return QueryInterestRateParams{ + Denom: denom, + } +} + +// MoneyMarketInterestRate is a unique type returned by interest rate queries +type MoneyMarketInterestRate struct { + Denom string `json:"denom" yaml:"denom"` + SupplyInterestRate sdk.Dec `json:"supply_interest_rate" yaml:"supply_interest_rate"` + BorrowInterestRate sdk.Dec `json:"borrow_interest_rate" yaml:"borrow_interest_rate"` +} + +// NewMoneyMarketInterestRate returns a new instance of MoneyMarketInterestRate +func NewMoneyMarketInterestRate(denom string, supplyInterestRate, borrowInterestRate sdk.Dec) MoneyMarketInterestRate { + return MoneyMarketInterestRate{ + Denom: denom, + SupplyInterestRate: supplyInterestRate, + BorrowInterestRate: borrowInterestRate, + } +} + +// MoneyMarketInterestRates is a slice of MoneyMarketInterestRate +type MoneyMarketInterestRates []MoneyMarketInterestRate