diff --git a/app/app.go b/app/app.go index 9843e066..310b7bbc 100644 --- a/app/app.go +++ b/app/app.go @@ -615,6 +615,7 @@ func NewApp( earnSubspace, app.accountKeeper, app.bankKeeper, + app.liquidKeeper, &hardKeeper, &savingsKeeper, ) diff --git a/x/earn/keeper/deposit_test.go b/x/earn/keeper/deposit_test.go index 18f0217e..17a1fd16 100644 --- a/x/earn/keeper/deposit_test.go +++ b/x/earn/keeper/deposit_test.go @@ -170,7 +170,7 @@ func (suite *depositTestSuite) TestDeposit_PrivateVault() { func (suite *depositTestSuite) TestDeposit_bKava() { vaultDenom := "bkava" - coinDenom := vaultDenom + "-kavavaloper16xyempempp92x9hyzz9wrgf94r6j9h5f2w4n2l" + coinDenom := testutil.TestBkavaDenoms[0] startBalance := sdk.NewInt64Coin(coinDenom, 1000) depositAmount := sdk.NewInt64Coin(coinDenom, 100) diff --git a/x/earn/keeper/grpc_query.go b/x/earn/keeper/grpc_query.go index 7fc4a0e0..c081547b 100644 --- a/x/earn/keeper/grpc_query.go +++ b/x/earn/keeper/grpc_query.go @@ -3,13 +3,12 @@ package keeper import ( "context" "fmt" + "strings" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" - "github.com/cosmos/cosmos-sdk/store/prefix" sdk "github.com/cosmos/cosmos-sdk/types" - "github.com/cosmos/cosmos-sdk/types/query" "github.com/kava-labs/kava/x/earn/types" ) @@ -51,6 +50,14 @@ func (s queryServer) Vaults( sdkCtx := sdk.UnwrapSDKContext(ctx) + allowedVaults := s.keeper.GetAllowedVaults(sdkCtx) + allowedVaultsMap := make(map[string]types.AllowedVault) + visitedMap := make(map[string]bool) + for _, av := range allowedVaults { + allowedVaultsMap[av.Denom] = av + visitedMap[av.Denom] = false + } + vaults := []types.VaultResponse{} var vaultRecordsErr error @@ -58,9 +65,16 @@ func (s queryServer) Vaults( // Iterate over vault records instead of AllowedVaults to get all bkava-* // vaults s.keeper.IterateVaultRecords(sdkCtx, func(record types.VaultRecord) bool { - allowedVault, found := s.keeper.GetAllowedVault(sdkCtx, record.TotalShares.Denom) + // Check if bkava, use allowed vault + allowedVaultDenom := record.TotalShares.Denom + if strings.HasPrefix(record.TotalShares.Denom, bkavaPrefix) { + allowedVaultDenom = bkavaDenom + } + + allowedVault, found := allowedVaultsMap[allowedVaultDenom] if !found { vaultRecordsErr = fmt.Errorf("vault record not found for vault record denom %s", record.TotalShares.Denom) + return true } totalValue, err := s.keeper.GetVaultTotalValue(sdkCtx, record.TotalShares.Denom) @@ -79,6 +93,9 @@ func (s queryServer) Vaults( TotalValue: totalValue.Amount, }) + // Mark this allowed vault as visited + visitedMap[allowedVaultDenom] = true + return false }) @@ -86,6 +103,30 @@ func (s queryServer) Vaults( return nil, vaultRecordsErr } + // Add the allowed vaults that have not been visited yet + // These are always empty vaults, as the vault would have been visited + // earlier if there are any deposits + for denom, visited := range visitedMap { + if visited { + continue + } + + allowedVault, found := allowedVaultsMap[denom] + if !found { + return nil, fmt.Errorf("vault record not found for vault record denom %s", denom) + } + + vaults = append(vaults, types.VaultResponse{ + Denom: denom, + Strategies: allowedVault.Strategies, + IsPrivateVault: allowedVault.IsPrivateVault, + AllowedDepositors: addressSliceToStringSlice(allowedVault.AllowedDepositors), + // No shares, no value + TotalShares: sdk.ZeroDec().String(), + TotalValue: sdk.ZeroInt(), + }) + } + // Does not include vaults that have no deposits, only iterates over vault // records which exists only for those with deposits. return &types.QueryVaultsResponse{ @@ -114,6 +155,11 @@ func (s queryServer) Vault( return nil, status.Errorf(codes.NotFound, "vault not found with specified denom") } + // Handle bkava separately to get total of **all** bkava vaults + if req.Denom == "bkava" { + return s.getAggregateBkavaVault(sdkCtx, allowedVault) + } + // Must be req.Denom and not allowedVault.Denom to get full "bkava" denom vaultRecord, found := s.keeper.GetVaultRecord(sdkCtx, req.Denom) if !found { @@ -141,6 +187,54 @@ func (s queryServer) Vault( }, nil } +// getAggregateBkavaVault returns a VaultResponse of the total of all bkava +// vaults. +func (s queryServer) getAggregateBkavaVault( + ctx sdk.Context, + allowedVault types.AllowedVault, +) (*types.QueryVaultResponse, error) { + allBkava := sdk.NewCoins() + + var iterErr error + s.keeper.IterateVaultRecords(ctx, func(record types.VaultRecord) (stop bool) { + // Skip non bkava vaults + if !strings.HasPrefix(record.TotalShares.Denom, "bkava") { + return false + } + + vaultValue, err := s.keeper.GetVaultTotalValue(ctx, record.TotalShares.Denom) + if err != nil { + iterErr = err + return false + } + + allBkava = allBkava.Add(vaultValue) + + return false + }) + + if iterErr != nil { + return nil, iterErr + } + + vaultValue, err := s.keeper.liquidKeeper.GetStakedTokensForDerivatives(ctx, allBkava) + if err != nil { + return nil, err + } + + return &types.QueryVaultResponse{ + Vault: types.VaultResponse{ + Denom: "bkava", + Strategies: allowedVault.Strategies, + IsPrivateVault: allowedVault.IsPrivateVault, + AllowedDepositors: addressSliceToStringSlice(allowedVault.AllowedDepositors), + // Empty for shares, as adding up all shares is not useful information + TotalShares: "0", + TotalValue: vaultValue.Amount, + }, + }, nil +} + // Deposits implements the gRPC service handler for querying x/earn deposits. func (s queryServer) Deposits( ctx context.Context, @@ -150,30 +244,29 @@ func (s queryServer) Deposits( return nil, status.Errorf(codes.InvalidArgument, "empty request") } + if req.Depositor == "" { + return nil, status.Errorf(codes.InvalidArgument, "depositor is required") + } + sdkCtx := sdk.UnwrapSDKContext(ctx) - // 1. Specific account and specific vault - if req.Depositor != "" && req.Denom != "" { - return s.getAccountVaultDeposit(sdkCtx, req) + // bkava aggregate total + if req.Denom == "bkava" { + return s.getOneAccountBkavaVaultDeposit(sdkCtx, req) } - // 2. All accounts, specific vault - if req.Depositor == "" && req.Denom != "" { - return s.getVaultAllDeposits(sdkCtx, req) + // specific vault + if req.Denom != "" { + return s.getOneAccountOneVaultDeposit(sdkCtx, req) } - // 3. Specific account, all vaults - if req.Depositor != "" && req.Denom == "" { - return s.getAccountAllDeposits(sdkCtx, req) - } - - // 4. All accounts, all vaults - return s.getAllDeposits(sdkCtx, req) + // all vaults + return s.getOneAccountAllDeposits(sdkCtx, req) } -// getAccountVaultDeposit returns deposits for a specific vault and a specific +// getOneAccountOneVaultDeposit returns deposits for a specific vault and a specific // account -func (s queryServer) getAccountVaultDeposit( +func (s queryServer) getOneAccountOneVaultDeposit( ctx sdk.Context, req *types.QueryDepositsRequest, ) (*types.QueryDepositsResponse, error) { @@ -184,90 +277,95 @@ func (s queryServer) getAccountVaultDeposit( shareRecord, found := s.keeper.GetVaultShareRecord(ctx, depositor) if !found { - return nil, status.Error(codes.NotFound, "No deposit found for owner") + return &types.QueryDepositsResponse{ + Deposits: []types.DepositResponse{ + { + Depositor: depositor.String(), + // Zero shares and zero value for no deposits + Shares: types.NewVaultShares(types.NewVaultShare(req.Denom, sdk.ZeroDec())), + Value: sdk.NewCoins(sdk.NewCoin(req.Denom, sdk.ZeroInt())), + }, + }, + Pagination: nil, + }, nil } - if shareRecord.Shares.AmountOf(req.Denom).IsZero() { - return nil, status.Error(codes.NotFound, fmt.Sprintf("No deposit for denom %s found for owner", req.Denom)) - } - - value, err := getAccountValue(ctx, s.keeper, depositor, shareRecord.Shares) + // Only requesting the value of the specified denom + value, err := s.keeper.GetVaultAccountValue(ctx, req.Denom, depositor) if err != nil { - return nil, status.Error(codes.InvalidArgument, err.Error()) + return nil, status.Error(codes.NotFound, err.Error()) } return &types.QueryDepositsResponse{ Deposits: []types.DepositResponse{ { Depositor: depositor.String(), - Shares: shareRecord.Shares, - Value: value, + // Only respond with requested denom shares + Shares: types.NewVaultShares( + types.NewVaultShare(req.Denom, shareRecord.Shares.AmountOf(req.Denom)), + ), + Value: sdk.NewCoins(value), }, }, Pagination: nil, }, nil } -// getVaultAllDeposits returns all deposits for a specific vault -func (s queryServer) getVaultAllDeposits( +// getOneAccountBkavaVaultDeposit returns deposits for the aggregated bkava vault +// and a specific account +func (s queryServer) getOneAccountBkavaVaultDeposit( ctx sdk.Context, req *types.QueryDepositsRequest, ) (*types.QueryDepositsResponse, error) { - _, found := s.keeper.GetVaultRecord(ctx, req.Denom) - if !found { - return nil, status.Error(codes.NotFound, "Vault record for denom not found") + depositor, err := sdk.AccAddressFromBech32(req.Depositor) + if err != nil { + return nil, status.Error(codes.InvalidArgument, "Invalid address") } - deposits := []types.DepositResponse{} - store := prefix.NewStore(ctx.KVStore(s.keeper.key), types.VaultShareRecordKeyPrefix) + shareRecord, found := s.keeper.GetVaultShareRecord(ctx, depositor) + if !found { + return &types.QueryDepositsResponse{ + Deposits: []types.DepositResponse{ + { + Depositor: depositor.String(), + // Zero shares and zero value for no deposits + Shares: types.NewVaultShares(types.NewVaultShare(req.Denom, sdk.ZeroDec())), + Value: sdk.NewCoins(sdk.NewCoin(req.Denom, sdk.ZeroInt())), + }, + }, + Pagination: nil, + }, nil + } - pageRes, err := query.FilteredPaginate( - store, - req.Pagination, - func(key []byte, value []byte, accumulate bool) (bool, error) { - var record types.VaultShareRecord - err := s.keeper.cdc.Unmarshal(value, &record) - if err != nil { - return false, err - } - - // Only those that have amount of requested denom - if record.Shares.AmountOf(req.Denom).IsZero() { - // inform paginate that there was no match on this key - return false, nil - } - - if accumulate { - accValue, err := getAccountValue(ctx, s.keeper, record.Depositor, record.Shares) - if err != nil { - return false, err - } - - // only add to results if paginate tells us to - deposits = append(deposits, types.DepositResponse{ - Depositor: record.Depositor.String(), - Shares: record.Shares, - Value: accValue, - }) - } - - // inform paginate that were was a match on this key - return true, nil - }, - ) + // Get all account deposit values to add up bkava + totalAccountValue, err := getAccountTotalValue(ctx, s.keeper, depositor, shareRecord.Shares) + if err != nil { + return nil, err + } + // Use account value with only the aggregate bkava converted to underlying staked tokens + stakedValue, err := s.keeper.liquidKeeper.GetStakedTokensForDerivatives(ctx, totalAccountValue) if err != nil { return nil, err } return &types.QueryDepositsResponse{ - Deposits: deposits, - Pagination: pageRes, + Deposits: []types.DepositResponse{ + { + Depositor: depositor.String(), + // Only respond with requested denom shares + Shares: types.NewVaultShares( + types.NewVaultShare(req.Denom, shareRecord.Shares.AmountOf(req.Denom)), + ), + Value: sdk.NewCoins(stakedValue), + }, + }, + Pagination: nil, }, nil } -// getAccountAllDeposits returns deposits for all vaults for a specific account -func (s queryServer) getAccountAllDeposits( +// getOneAccountAllDeposits returns deposits for all vaults for a specific account +func (s queryServer) getOneAccountAllDeposits( ctx sdk.Context, req *types.QueryDepositsRequest, ) (*types.QueryDepositsResponse, error) { @@ -280,10 +378,13 @@ func (s queryServer) getAccountAllDeposits( accountShare, found := s.keeper.GetVaultShareRecord(ctx, depositor) if !found { - return nil, status.Error(codes.NotFound, "No deposit found for depositor") + return &types.QueryDepositsResponse{ + Deposits: []types.DepositResponse{}, + Pagination: nil, + }, nil } - value, err := getAccountValue(ctx, s.keeper, depositor, accountShare.Shares) + value, err := getAccountTotalValue(ctx, s.keeper, depositor, accountShare.Shares) if err != nil { return nil, status.Error(codes.InvalidArgument, err.Error()) } @@ -300,51 +401,9 @@ func (s queryServer) getAccountAllDeposits( }, nil } -// getAllDeposits returns all deposits for all vaults -func (s queryServer) getAllDeposits( - ctx sdk.Context, - req *types.QueryDepositsRequest, -) (*types.QueryDepositsResponse, error) { - deposits := []types.DepositResponse{} - store := prefix.NewStore(ctx.KVStore(s.keeper.key), types.VaultShareRecordKeyPrefix) - - pageRes, err := query.Paginate( - store, - req.Pagination, - func(key []byte, value []byte) error { - var record types.VaultShareRecord - err := s.keeper.cdc.Unmarshal(value, &record) - if err != nil { - return err - } - - accValue, err := getAccountValue(ctx, s.keeper, record.Depositor, record.Shares) - if err != nil { - return err - } - - // only add to results if paginate tells us to - deposits = append(deposits, types.DepositResponse{ - Depositor: record.Depositor.String(), - Shares: record.Shares, - Value: accValue, - }) - - return nil - }, - ) - - if err != nil { - return nil, err - } - - return &types.QueryDepositsResponse{ - Deposits: deposits, - Pagination: pageRes, - }, nil -} - -func getAccountValue( +// getAccountTotalValue returns the total value for all vaults for a specific +// account based on their shares. +func getAccountTotalValue( ctx sdk.Context, keeper Keeper, account sdk.AccAddress, diff --git a/x/earn/keeper/grpc_query_test.go b/x/earn/keeper/grpc_query_test.go index f920ac48..567396c8 100644 --- a/x/earn/keeper/grpc_query_test.go +++ b/x/earn/keeper/grpc_query_test.go @@ -5,14 +5,19 @@ import ( "testing" "github.com/cosmos/cosmos-sdk/baseapp" + "github.com/cosmos/cosmos-sdk/crypto/keys/ed25519" sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/staking" + stakingkeeper "github.com/cosmos/cosmos-sdk/x/staking/keeper" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" + "github.com/stretchr/testify/suite" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "github.com/kava-labs/kava/x/earn/keeper" "github.com/kava-labs/kava/x/earn/testutil" "github.com/kava-labs/kava/x/earn/types" - "github.com/stretchr/testify/suite" + liquidtypes "github.com/kava-labs/kava/x/liquid/types" ) type grpcQueryTestSuite struct { @@ -81,13 +86,32 @@ func (suite *grpcQueryTestSuite) TestVaults_ZeroSupply() { suite.Run("all", func() { res, err := suite.queryClient.Vaults(context.Background(), types.NewQueryVaultsRequest()) suite.Require().NoError(err) - suite.Require().Empty(res.Vaults) + suite.Require().ElementsMatch([]types.VaultResponse{ + { + Denom: "usdx", + Strategies: []types.StrategyType{types.STRATEGY_TYPE_HARD}, + IsPrivateVault: false, + AllowedDepositors: nil, + TotalShares: sdk.ZeroDec().String(), + TotalValue: sdk.ZeroInt(), + }, + { + Denom: "busd", + Strategies: []types.StrategyType{types.STRATEGY_TYPE_HARD}, + IsPrivateVault: false, + AllowedDepositors: nil, + TotalShares: sdk.ZeroDec().String(), + TotalValue: sdk.ZeroInt(), + }, + }, + res.Vaults, + ) }) } func (suite *grpcQueryTestSuite) TestVaults_WithSupply() { vaultDenom := "usdx" - vault2Denom := "bkava-kavavaloper16xyempempp92x9hyzz9wrgf94r6j9h5f2w4n2l" + vault2Denom := testutil.TestBkavaDenoms[0] depositAmount := sdk.NewInt64Coin(vaultDenom, 100) deposit2Amount := sdk.NewInt64Coin(vault2Denom, 100) @@ -132,6 +156,60 @@ func (suite *grpcQueryTestSuite) TestVaults_WithSupply() { ) } +func (suite *grpcQueryTestSuite) TestVaults_MixedSupply() { + vaultDenom := "usdx" + vault2Denom := "busd" + vault3Denom := testutil.TestBkavaDenoms[0] + + depositAmount := sdk.NewInt64Coin(vault3Denom, 100) + + suite.CreateVault(vaultDenom, types.StrategyTypes{types.STRATEGY_TYPE_HARD}, false, nil) + suite.CreateVault(vault2Denom, types.StrategyTypes{types.STRATEGY_TYPE_HARD}, false, nil) + suite.CreateVault("bkava", types.StrategyTypes{types.STRATEGY_TYPE_SAVINGS}, false, nil) + + acc := suite.CreateAccount(sdk.NewCoins( + sdk.NewInt64Coin(vaultDenom, 1000), + sdk.NewInt64Coin(vault2Denom, 1000), + sdk.NewInt64Coin(vault3Denom, 1000), + ), 0) + + err := suite.Keeper.Deposit(suite.Ctx, acc.GetAddress(), depositAmount, types.STRATEGY_TYPE_SAVINGS) + suite.Require().NoError(err) + + res, err := suite.queryClient.Vaults(context.Background(), types.NewQueryVaultsRequest()) + suite.Require().NoError(err) + suite.Require().Len(res.Vaults, 3) + suite.Require().ElementsMatch( + []types.VaultResponse{ + { + Denom: vaultDenom, + Strategies: []types.StrategyType{types.STRATEGY_TYPE_HARD}, + IsPrivateVault: false, + AllowedDepositors: nil, + TotalShares: sdk.ZeroDec().String(), + TotalValue: sdk.ZeroInt(), + }, + { + Denom: vault2Denom, + Strategies: []types.StrategyType{types.STRATEGY_TYPE_HARD}, + IsPrivateVault: false, + AllowedDepositors: nil, + TotalShares: sdk.ZeroDec().String(), + TotalValue: sdk.ZeroInt(), + }, + { + Denom: vault3Denom, + Strategies: []types.StrategyType{types.STRATEGY_TYPE_SAVINGS}, + IsPrivateVault: false, + AllowedDepositors: nil, + TotalShares: depositAmount.Amount.ToDec().String(), + TotalValue: depositAmount.Amount, + }, + }, + res.Vaults, + ) +} + func (suite *grpcQueryTestSuite) TestVault_NotFound() { _, err := suite.queryClient.Vault(context.Background(), types.NewQueryVaultRequest("usdx")) suite.Require().Error(err) @@ -141,7 +219,7 @@ func (suite *grpcQueryTestSuite) TestVault_NotFound() { func (suite *grpcQueryTestSuite) TestDeposits() { vault1Denom := "usdx" vault2Denom := "busd" - vault3Denom := "bkava-kavavaloper16xyempempp92x9hyzz9wrgf94r6j9h5f2w4n2l" + vault3Denom := testutil.TestBkavaDenoms[0] // Add vaults suite.CreateVault(vault1Denom, types.StrategyTypes{types.STRATEGY_TYPE_HARD}, false, nil) @@ -153,6 +231,7 @@ func (suite *grpcQueryTestSuite) TestDeposits() { sdk.NewInt64Coin(vault2Denom, 1000), sdk.NewInt64Coin(vault3Denom, 1000), ) + deposit1Amount := sdk.NewInt64Coin(vault1Denom, 100) deposit2Amount := sdk.NewInt64Coin(vault2Denom, 200) deposit3Amount := sdk.NewInt64Coin(vault3Denom, 200) @@ -163,7 +242,7 @@ func (suite *grpcQueryTestSuite) TestDeposits() { // Deposit into each vault from each account - 4 total deposits // Acc 1: usdx + busd - // Acc 2: usdx + usdc + // Acc 2: usdx + bkava err := suite.Keeper.Deposit(suite.Ctx, acc1, deposit1Amount, types.STRATEGY_TYPE_HARD) suite.Require().NoError(err) err = suite.Keeper.Deposit(suite.Ctx, acc1, deposit2Amount, types.STRATEGY_TYPE_HARD) @@ -174,7 +253,7 @@ func (suite *grpcQueryTestSuite) TestDeposits() { err = suite.Keeper.Deposit(suite.Ctx, acc2, deposit3Amount, types.STRATEGY_TYPE_SAVINGS) suite.Require().NoError(err) - suite.Run("1) 1 vault for 1 account", func() { + suite.Run("specific vault", func() { // Query all deposits for account 1 res, err := suite.queryClient.Deposits( context.Background(), @@ -186,12 +265,12 @@ func (suite *grpcQueryTestSuite) TestDeposits() { []types.DepositResponse{ { Depositor: acc1.String(), - // Still includes all deposits + // Only includes specified deposit shares Shares: types.NewVaultShares( types.NewVaultShare(deposit1Amount.Denom, deposit1Amount.Amount.ToDec()), - types.NewVaultShare(deposit2Amount.Denom, deposit2Amount.Amount.ToDec()), ), - Value: sdk.NewCoins(deposit1Amount, deposit2Amount), + // Only the specified vault denom value + Value: sdk.NewCoins(deposit1Amount), }, }, res.Deposits, @@ -200,16 +279,41 @@ func (suite *grpcQueryTestSuite) TestDeposits() { ) }) - suite.Run("1) invalid vault for 1 account", func() { + suite.Run("specific bkava vault", func() { + res, err := suite.queryClient.Deposits( + context.Background(), + types.NewQueryDepositsRequest(acc2.String(), vault3Denom, nil), + ) + suite.Require().NoError(err) + suite.Require().Len(res.Deposits, 1) + suite.Require().ElementsMatchf( + []types.DepositResponse{ + { + Depositor: acc2.String(), + // Only includes specified deposit shares + Shares: types.NewVaultShares( + types.NewVaultShare(deposit3Amount.Denom, deposit3Amount.Amount.ToDec()), + ), + // Only the specified vault denom value + Value: sdk.NewCoins(deposit3Amount), + }, + }, + res.Deposits, + "deposits should match, got %v", + res.Deposits, + ) + }) + + suite.Run("invalid vault", func() { _, err := suite.queryClient.Deposits( context.Background(), types.NewQueryDepositsRequest(acc1.String(), "notavaliddenom", nil), ) suite.Require().Error(err) - suite.Require().ErrorIs(err, status.Errorf(codes.NotFound, "No deposit for denom notavaliddenom found for owner")) + suite.Require().ErrorIs(err, status.Errorf(codes.NotFound, "vault for notavaliddenom not found")) }) - suite.Run("3) all vaults for 1 account", func() { + suite.Run("all vaults", func() { // Query all deposits for account 1 res, err := suite.queryClient.Deposits( context.Background(), @@ -231,55 +335,35 @@ func (suite *grpcQueryTestSuite) TestDeposits() { res.Deposits, ) }) +} - suite.Run("2) all accounts, specific vault", func() { - // Query all deposits for vault 3 +func (suite *grpcQueryTestSuite) TestDeposits_NoDeposits() { + vault1Denom := "usdx" + vault2Denom := "busd" + + // Add vaults + suite.CreateVault(vault1Denom, types.StrategyTypes{types.STRATEGY_TYPE_HARD}, false, nil) + suite.CreateVault(vault2Denom, types.StrategyTypes{types.STRATEGY_TYPE_HARD}, false, nil) + suite.CreateVault("bkava", types.StrategyTypes{types.STRATEGY_TYPE_SAVINGS}, false, nil) + + // Accounts + acc1 := suite.CreateAccount(sdk.NewCoins(), 0).GetAddress() + + suite.Run("specific vault", func() { + // Query all deposits for account 1 res, err := suite.queryClient.Deposits( context.Background(), - types.NewQueryDepositsRequest("", vault3Denom, nil), + types.NewQueryDepositsRequest(acc1.String(), vault1Denom, nil), ) suite.Require().NoError(err) suite.Require().Len(res.Deposits, 1) - suite.Require().ElementsMatch( - []types.DepositResponse{ - { - Depositor: acc2.String(), - Shares: types.NewVaultShares( - types.NewVaultShare(deposit1Amount.Denom, deposit1Amount.Amount.ToDec()), - types.NewVaultShare(deposit3Amount.Denom, deposit3Amount.Amount.ToDec()), - ), - Value: sdk.NewCoins(deposit1Amount, deposit3Amount), - }, - }, - res.Deposits, - ) - }) - - suite.Run("4) all vaults and all accounts", func() { - // Query all deposits for all vaults - res, err := suite.queryClient.Deposits( - context.Background(), - types.NewQueryDepositsRequest("", "", nil), - ) - suite.Require().NoError(err) - suite.Require().Len(res.Deposits, 2) suite.Require().ElementsMatchf( []types.DepositResponse{ { Depositor: acc1.String(), - Shares: types.NewVaultShares( - types.NewVaultShare(deposit1Amount.Denom, deposit1Amount.Amount.ToDec()), - types.NewVaultShare(deposit2Amount.Denom, deposit2Amount.Amount.ToDec()), - ), - Value: sdk.NewCoins(deposit1Amount, deposit2Amount), - }, - { - Depositor: acc2.String(), - Shares: types.NewVaultShares( - types.NewVaultShare(deposit1Amount.Denom, deposit1Amount.Amount.ToDec()), - types.NewVaultShare(deposit3Amount.Denom, deposit3Amount.Amount.ToDec()), - ), - Value: sdk.NewCoins(deposit1Amount, deposit3Amount), + // Zero shares and zero value + Shares: nil, + Value: nil, }, }, res.Deposits, @@ -287,15 +371,25 @@ func (suite *grpcQueryTestSuite) TestDeposits() { res.Deposits, ) }) + + suite.Run("all vaults", func() { + // Query all deposits for account 1 + res, err := suite.queryClient.Deposits( + context.Background(), + types.NewQueryDepositsRequest(acc1.String(), "", nil), + ) + suite.Require().NoError(err) + suite.Require().Empty(res.Deposits) + }) } -func (suite *grpcQueryTestSuite) TestDeposits_NotFound() { +func (suite *grpcQueryTestSuite) TestDeposits_NoDepositor() { _, err := suite.queryClient.Deposits( context.Background(), types.NewQueryDepositsRequest("", "usdx", nil), ) suite.Require().Error(err) - suite.Require().ErrorIs(err, status.Error(codes.NotFound, "Vault record for denom not found")) + suite.Require().ErrorIs(err, status.Error(codes.InvalidArgument, "depositor is required")) } func (suite *grpcQueryTestSuite) TestDeposits_InvalidAddress() { @@ -314,9 +408,89 @@ func (suite *grpcQueryTestSuite) TestDeposits_InvalidAddress() { suite.Require().ErrorIs(err, status.Error(codes.InvalidArgument, "Invalid address")) } -func (suite *grpcQueryTestSuite) TestVault_bKava() { +func (suite *grpcQueryTestSuite) TestDeposits_bKava() { + // vault denom is only "bkava" which has it's own special handler + suite.CreateVault( + "bkava", + types.StrategyTypes{types.STRATEGY_TYPE_SAVINGS}, + false, + []sdk.AccAddress{}, + ) + + address1, derivatives1, _ := suite.createAccountWithDerivatives(testutil.TestBkavaDenoms[0], sdk.NewInt(1e9)) + address2, derivatives2, _ := suite.createAccountWithDerivatives(testutil.TestBkavaDenoms[1], sdk.NewInt(1e9)) + + // Slash the last validator to reduce the value of it's derivatives to test bkava to underlying token conversion. + // First call end block to bond validator to enable slashing. + staking.EndBlocker(suite.Ctx, suite.App.GetStakingKeeper()) + err := suite.slashValidator(sdk.ValAddress(address2), sdk.MustNewDecFromStr("0.5")) + suite.Require().NoError(err) + + suite.Run("no deposits", func() { + // Query all deposits for account 1 + res, err := suite.queryClient.Deposits( + context.Background(), + types.NewQueryDepositsRequest(address1.String(), "bkava", nil), + ) + suite.Require().NoError(err) + suite.Require().Len(res.Deposits, 1) + suite.Require().ElementsMatchf( + []types.DepositResponse{ + { + Depositor: address1.String(), + // Zero shares for "bkava" aggregate + Shares: nil, + // Only the specified vault denom value + Value: nil, + }, + }, + res.Deposits, + "deposits should match, got %v", + res.Deposits, + ) + }) + + err = suite.Keeper.Deposit(suite.Ctx, address1, derivatives1, types.STRATEGY_TYPE_SAVINGS) + suite.Require().NoError(err) + + err = suite.BankKeeper.SendCoins(suite.Ctx, address2, address1, sdk.NewCoins(derivatives2)) + suite.Require().NoError(err) + err = suite.Keeper.Deposit(suite.Ctx, address1, derivatives2, types.STRATEGY_TYPE_SAVINGS) + suite.Require().NoError(err) + + suite.Run("multiple deposits", func() { + // Query all deposits for account 1 + res, err := suite.queryClient.Deposits( + context.Background(), + types.NewQueryDepositsRequest(address1.String(), "bkava", nil), + ) + suite.Require().NoError(err) + suite.Require().Len(res.Deposits, 1) + // first validator isn't slashed, so bkava units equal to underlying staked tokens + // last validator slashed 50% so derivatives are worth half + expectedValue := derivatives1.Amount.Add(derivatives2.Amount.QuoRaw(2)) + suite.Require().ElementsMatchf( + []types.DepositResponse{ + { + Depositor: address1.String(), + // Zero shares for "bkava" aggregate + Shares: nil, + // Value returned in units of staked token + Value: sdk.NewCoins( + sdk.NewCoin(suite.bondDenom(), expectedValue), + ), + }, + }, + res.Deposits, + "deposits should match, got %v", + res.Deposits, + ) + }) +} + +func (suite *grpcQueryTestSuite) TestVault_bKava_Single() { vaultDenom := "bkava" - coinDenom := vaultDenom + "-kavavaloper16xyempempp92x9hyzz9wrgf94r6j9h5f2w4n2l" + coinDenom := testutil.TestBkavaDenoms[0] startBalance := sdk.NewInt64Coin(coinDenom, 1000) depositAmount := sdk.NewInt64Coin(coinDenom, 100) @@ -356,3 +530,132 @@ func (suite *grpcQueryTestSuite) TestVault_bKava() { res.Vault, ) } + +func (suite *grpcQueryTestSuite) TestVault_bKava_Aggregate() { + vaultDenom := "bkava" + + address1, derivatives1, _ := suite.createAccountWithDerivatives(testutil.TestBkavaDenoms[0], sdk.NewInt(1e9)) + address2, derivatives2, _ := suite.createAccountWithDerivatives(testutil.TestBkavaDenoms[1], sdk.NewInt(1e9)) + address3, derivatives3, _ := suite.createAccountWithDerivatives(testutil.TestBkavaDenoms[2], sdk.NewInt(1e9)) + // Slash the last validator to reduce the value of it's derivatives to test bkava to underlying token conversion. + // First call end block to bond validator to enable slashing. + staking.EndBlocker(suite.Ctx, suite.App.GetStakingKeeper()) + err := suite.slashValidator(sdk.ValAddress(address3), sdk.MustNewDecFromStr("0.5")) + suite.Require().NoError(err) + + // vault denom is only "bkava" which has it's own special handler + suite.CreateVault( + vaultDenom, + types.StrategyTypes{types.STRATEGY_TYPE_SAVINGS}, + false, + []sdk.AccAddress{}, + ) + + err = suite.Keeper.Deposit(suite.Ctx, address1, derivatives1, types.STRATEGY_TYPE_SAVINGS) + suite.Require().NoError(err) + + err = suite.Keeper.Deposit(suite.Ctx, address2, derivatives2, types.STRATEGY_TYPE_SAVINGS) + suite.Require().NoError(err) + + err = suite.Keeper.Deposit(suite.Ctx, address3, derivatives3, types.STRATEGY_TYPE_SAVINGS) + suite.Require().NoError(err) + + // Query "bkava" to get aggregate amount + res, err := suite.queryClient.Vault( + context.Background(), + types.NewQueryVaultRequest(vaultDenom), + ) + suite.Require().NoError(err) + // first two validators are not slashed, so bkava units equal to underlying staked tokens + expectedValue := derivatives1.Amount.Add(derivatives2.Amount) + // last validator slashed 50% so derivatives are worth half + expectedValue = expectedValue.Add(derivatives2.Amount.QuoRaw(2)) + suite.Require().Equal( + types.VaultResponse{ + Denom: vaultDenom, + Strategies: types.StrategyTypes{ + types.STRATEGY_TYPE_SAVINGS, + }, + IsPrivateVault: false, + AllowedDepositors: []string(nil), + // No shares for aggregate + TotalShares: "0", + TotalValue: expectedValue, + }, + res.Vault, + ) +} + +// createUnbondedValidator creates an unbonded validator with the given amount of self-delegation. +func (suite *grpcQueryTestSuite) createUnbondedValidator(address sdk.ValAddress, selfDelegation sdk.Coin, minSelfDelegation sdk.Int) error { + msg, err := stakingtypes.NewMsgCreateValidator( + address, + ed25519.GenPrivKey().PubKey(), + selfDelegation, + stakingtypes.Description{}, + stakingtypes.NewCommissionRates(sdk.ZeroDec(), sdk.ZeroDec(), sdk.ZeroDec()), + minSelfDelegation, + ) + if err != nil { + return err + } + + msgServer := stakingkeeper.NewMsgServerImpl(suite.App.GetStakingKeeper()) + _, err = msgServer.CreateValidator(sdk.WrapSDKContext(suite.Ctx), msg) + return err +} + +// createAccountWithDerivatives creates an account with the given amount and denom of derivative token. +// Internally, it creates a validator account and mints derivatives from the validator's self delegation. +func (suite *grpcQueryTestSuite) createAccountWithDerivatives(denom string, amount sdk.Int) (sdk.AccAddress, sdk.Coin, sdk.Coins) { + valAddress, err := liquidtypes.ParseLiquidStakingTokenDenom(denom) + suite.Require().NoError(err) + address := sdk.AccAddress(valAddress) + + remainingSelfDelegation := sdk.NewInt(1e6) + selfDelegation := sdk.NewCoin( + suite.bondDenom(), + amount.Add(remainingSelfDelegation), + ) + + suite.NewAccountFromAddr(address, sdk.NewCoins(selfDelegation)) + + err = suite.createUnbondedValidator(valAddress, selfDelegation, remainingSelfDelegation) + suite.Require().NoError(err) + + toConvert := sdk.NewCoin(suite.bondDenom(), amount) + derivatives, err := suite.App.GetLiquidKeeper().MintDerivative(suite.Ctx, + address, + valAddress, + toConvert, + ) + suite.Require().NoError(err) + + fullBalance := suite.BankKeeper.GetAllBalances(suite.Ctx, address) + + return address, derivatives, fullBalance +} + +// slashValidator slashes the validator with the given address by the given percentage. +func (suite *grpcQueryTestSuite) slashValidator(address sdk.ValAddress, slashFraction sdk.Dec) error { + stakingKeeper := suite.App.GetStakingKeeper() + + validator, found := stakingKeeper.GetValidator(suite.Ctx, address) + suite.Require().True(found) + consAddr, err := validator.GetConsAddr() + suite.Require().NoError(err) + + // Assume infraction was at current height. Note unbonding delegations and redelegations are only slashed if created after + // the infraction height so none will be slashed. + infractionHeight := suite.Ctx.BlockHeight() + + power := stakingKeeper.TokensToConsensusPower(suite.Ctx, validator.GetTokens()) + + stakingKeeper.Slash(suite.Ctx, consAddr, infractionHeight, power, slashFraction) + return nil +} + +// bondDenom fetches the staking denom from the staking module. +func (suite *grpcQueryTestSuite) bondDenom() string { + return suite.App.GetStakingKeeper().BondDenom(suite.Ctx) +} diff --git a/x/earn/keeper/keeper.go b/x/earn/keeper/keeper.go index 2f759fd0..22f55e1c 100644 --- a/x/earn/keeper/keeper.go +++ b/x/earn/keeper/keeper.go @@ -16,6 +16,7 @@ type Keeper struct { hooks types.EarnHooks accountKeeper types.AccountKeeper bankKeeper types.BankKeeper + liquidKeeper types.LiquidKeeper // Keepers used for strategies hardKeeper types.HardKeeper @@ -29,6 +30,7 @@ func NewKeeper( paramstore paramtypes.Subspace, accountKeeper types.AccountKeeper, bankKeeper types.BankKeeper, + liquidKeeper types.LiquidKeeper, hardKeeper types.HardKeeper, savingsKeeper types.SavingsKeeper, ) Keeper { @@ -42,6 +44,7 @@ func NewKeeper( paramSubspace: paramstore, accountKeeper: accountKeeper, bankKeeper: bankKeeper, + liquidKeeper: liquidKeeper, hardKeeper: hardKeeper, savingsKeeper: savingsKeeper, } diff --git a/x/earn/keeper/withdraw_test.go b/x/earn/keeper/withdraw_test.go index 7547bc6d..e4db67f1 100644 --- a/x/earn/keeper/withdraw_test.go +++ b/x/earn/keeper/withdraw_test.go @@ -244,7 +244,7 @@ func (suite *withdrawTestSuite) TestWithdraw_Partial() { func (suite *withdrawTestSuite) TestWithdraw_bKava() { vaultDenom := "bkava" - coinDenom := vaultDenom + "-kavavaloper16xyempempp92x9hyzz9wrgf94r6j9h5f2w4n2l" + coinDenom := testutil.TestBkavaDenoms[0] startBalance := sdk.NewInt64Coin(coinDenom, 1000) depositAmount := sdk.NewInt64Coin(coinDenom, 100) diff --git a/x/earn/testutil/suite.go b/x/earn/testutil/suite.go index b26b3f50..04bbb8bc 100644 --- a/x/earn/testutil/suite.go +++ b/x/earn/testutil/suite.go @@ -28,6 +28,12 @@ import ( tmtime "github.com/tendermint/tendermint/types/time" ) +var TestBkavaDenoms = []string{ + "bkava-kavavaloper15gqc744d05xacn4n6w2furuads9fu4pqn6zxlu", + "bkava-kavavaloper15qdefkmwswysgg4qxgqpqr35k3m49pkx8yhpte", + "bkava-kavavaloper1ypjp0m04pyp73hwgtc0dgkx0e9rrydeckewa42", +} + // Suite implements a test suite for the earn module integration tests type Suite struct { suite.Suite @@ -146,7 +152,9 @@ func (suite *Suite) SetupTest() { savingstypes.NewParams( []string{ "ukava", - "bkava-kavavaloper16xyempempp92x9hyzz9wrgf94r6j9h5f2w4n2l", + TestBkavaDenoms[0], + TestBkavaDenoms[1], + TestBkavaDenoms[2], }, ), nil, diff --git a/x/earn/types/expected_keepers.go b/x/earn/types/expected_keepers.go index fe253e78..8193cb51 100644 --- a/x/earn/types/expected_keepers.go +++ b/x/earn/types/expected_keepers.go @@ -25,6 +25,11 @@ type BankKeeper interface { SendCoinsFromAccountToModule(ctx sdk.Context, senderAddr sdk.AccAddress, recipientModule string, amt sdk.Coins) error } +// LiquidKeeper defines the expected interface needed for derivative to staked token conversions. +type LiquidKeeper interface { + GetStakedTokensForDerivatives(ctx sdk.Context, derivatives sdk.Coins) (sdk.Coin, error) +} + // HardKeeper defines the expected interface needed for the hard strategy. type HardKeeper interface { Deposit(ctx sdk.Context, depositor sdk.AccAddress, coins sdk.Coins) error diff --git a/x/liquid/keeper/derivative.go b/x/liquid/keeper/derivative.go index fbed7ec4..fe9dd4df 100644 --- a/x/liquid/keeper/derivative.go +++ b/x/liquid/keeper/derivative.go @@ -111,28 +111,29 @@ func (k Keeper) IsDerivativeDenom(ctx sdk.Context, denom string) bool { return found } -// GetKavaForDerivatives returns the total amount of the provided derivatives -// in Kava accounting for the specific share prices. -func (k Keeper) GetKavaForDerivatives(ctx sdk.Context, coins sdk.Coins) (sdk.Int, error) { - totalKava := sdk.ZeroInt() +// GetStakedTokensForDerivatives returns the total value of the provided derivatives +// in staked tokens, accounting for the specific share prices. +func (k Keeper) GetStakedTokensForDerivatives(ctx sdk.Context, coins sdk.Coins) (sdk.Coin, error) { + total := sdk.ZeroInt() for _, coin := range coins { valAddr, err := types.ParseLiquidStakingTokenDenom(coin.Denom) if err != nil { - return sdk.Int{}, fmt.Errorf("invalid derivative denom: %w", err) + return sdk.Coin{}, fmt.Errorf("invalid derivative denom: %w", err) } validator, found := k.stakingKeeper.GetValidator(ctx, valAddr) if !found { - return sdk.Int{}, fmt.Errorf("invalid derivative denom %s: validator not found", coin.Denom) + return sdk.Coin{}, fmt.Errorf("invalid derivative denom %s: validator not found", coin.Denom) } // bkava is 1:1 to delegation shares valTokens := validator.TokensFromSharesTruncated(coin.Amount.ToDec()) - totalKava = totalKava.Add(valTokens.TruncateInt()) + total = total.Add(valTokens.TruncateInt()) } - return totalKava, nil + totalCoin := sdk.NewCoin(k.stakingKeeper.BondDenom(ctx), total) + return totalCoin, nil } func (k Keeper) mintCoins(ctx sdk.Context, receiver sdk.AccAddress, amount sdk.Coins) error { diff --git a/x/liquid/keeper/derivative_test.go b/x/liquid/keeper/derivative_test.go index 9713e060..b3e1303b 100644 --- a/x/liquid/keeper/derivative_test.go +++ b/x/liquid/keeper/derivative_test.go @@ -379,7 +379,7 @@ func (suite *KeeperTestSuite) TestIsDerivativeDenom() { } } -func (suite *KeeperTestSuite) TestGetKavaForDerivatives() { +func (suite *KeeperTestSuite) TestGetStakedTokensForDerivatives() { _, addrs := app.GeneratePrivKeyAddressPairs(5) valAccAddr1, delegator, valAccAddr2, valAccAddr3 := addrs[0], addrs[1], addrs[2], addrs[3] valAddr1 := sdk.ValAddress(valAccAddr1) @@ -458,13 +458,13 @@ func (suite *KeeperTestSuite) TestGetKavaForDerivatives() { for _, tc := range testCases { suite.Run(tc.name, func() { - kavaAmount, err := suite.Keeper.GetKavaForDerivatives(suite.Ctx, tc.derivatives) + kavaAmount, err := suite.Keeper.GetStakedTokensForDerivatives(suite.Ctx, tc.derivatives) if tc.err != nil { suite.Require().Error(err) } else { suite.Require().NoError(err) - suite.Require().Equal(tc.wantKavaAmount, kavaAmount) + suite.Require().Equal(suite.NewBondCoin(tc.wantKavaAmount), kavaAmount) } }) }