liquid staking (#1273)

* proto types

* proto generated types

* liquidstaking top level files: module, genesis

* liquidstaking types

* liquidstaking keeper

* liquidstaking client/cli

* add liquidstaking to app, simapp

* implement mint derivative

* set up liquidstaking keeper test suite

* test mint derivative

* rename module to liquid

* rename proto types, app.go to liquid

* use sdk.Coin instead of shares

* mint liquid tokens to delegator

* burn derivative tokens to receive delegation

* use conversion method instead of type cast

* simplify delegation transfer logic

* broaden delegation transfer tests

* simplify transfer delegation method
This removes a source of rounding errors

* move derivative denom to keeper config

* check for invalid coins in msg validation

* block 0share transfers to avoid handling edge case

* refactor MintDerivative to test calculations

* simplify burn method so shares and tokens equal

* convert TransferShares back to old design
this makes handling vesting tokens easier

* fix missed merge conflict

* remove deprecated constants

* tidy up msg.go

* add msg tests

* remove unused store key

* fix msg event sender

* remove unused params

* tidy up documentation and errors

* remove unused mocks

* remove unused keepers from AppModule

* tidy up msg return values
keeper return values to be used in router msgs

* reinstate unintentionally removed interface check

* catch invalid input for MnitDerivative
clear up test TODOs

* clear up InitGenesis TODO

* Update x/liquid/client/cli/tx.go

Co-authored-by: Derrick Lee <derrick@dlee.dev>

* Update x/liquid/client/cli/tx.go

Co-authored-by: Derrick Lee <derrick@dlee.dev>

* show error logs in devnet

* unblock mod account so it can receive dist rewards

* catch zero amout msgs early

* minor cli fixes

Co-authored-by: rhuairahrighairigh <ruaridh.odonnell@gmail.com>
Co-authored-by: Ruaridh <rhuairahrighairidh@users.noreply.github.com>
Co-authored-by: Derrick Lee <derrick@dlee.dev>
This commit is contained in:
Denali Marsh 2022-09-16 00:00:32 +02:00 committed by GitHub
parent ded692d2f4
commit ceaed3f0e1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 3384 additions and 3 deletions

View File

@ -125,6 +125,9 @@ import (
kavadistclient "github.com/kava-labs/kava/x/kavadist/client"
kavadistkeeper "github.com/kava-labs/kava/x/kavadist/keeper"
kavadisttypes "github.com/kava-labs/kava/x/kavadist/types"
"github.com/kava-labs/kava/x/liquid"
liquidkeeper "github.com/kava-labs/kava/x/liquid/keeper"
liquidtypes "github.com/kava-labs/kava/x/liquid/types"
pricefeed "github.com/kava-labs/kava/x/pricefeed"
pricefeedkeeper "github.com/kava-labs/kava/x/pricefeed/keeper"
pricefeedtypes "github.com/kava-labs/kava/x/pricefeed/types"
@ -190,6 +193,7 @@ var (
savings.AppModuleBasic{},
validatorvesting.AppModuleBasic{},
evmutil.AppModuleBasic{},
liquid.AppModuleBasic{},
earn.AppModuleBasic{},
)
@ -215,12 +219,12 @@ var (
cdptypes.LiquidatorMacc: {authtypes.Minter, authtypes.Burner},
hardtypes.ModuleAccountName: {authtypes.Minter},
savingstypes.ModuleAccountName: nil,
earntypes.ModuleName: nil,
liquidtypes.ModuleAccountName: {authtypes.Minter, authtypes.Burner},
earntypes.ModuleAccountName: nil,
}
)
// Verify app interface at compile time
// var _ simapp.App = (*App)(nil) // TODO
var _ servertypes.Application = (*App)(nil)
// Options bundles several configuration params for an App.
@ -285,6 +289,7 @@ type App struct {
committeeKeeper committeekeeper.Keeper
incentiveKeeper incentivekeeper.Keeper
savingsKeeper savingskeeper.Keeper
liquidKeeper liquidkeeper.Keeper
earnKeeper earnkeeper.Keeper
// make scoped keepers public for test purposes
@ -586,6 +591,12 @@ func NewApp(
app.accountKeeper,
app.bankKeeper,
)
app.liquidKeeper = liquidkeeper.NewDefaultKeeper(
appCodec,
app.accountKeeper,
app.bankKeeper,
&app.stakingKeeper,
)
app.incentiveKeeper = incentivekeeper.NewKeeper(
appCodec,
keys[incentivetypes.StoreKey],
@ -691,6 +702,7 @@ func NewApp(
incentive.NewAppModule(app.incentiveKeeper, app.accountKeeper, app.bankKeeper, app.cdpKeeper),
evmutil.NewAppModule(app.evmutilKeeper, app.bankKeeper),
savings.NewAppModule(app.savingsKeeper, app.accountKeeper, app.bankKeeper),
liquid.NewAppModule(app.liquidKeeper),
earn.NewAppModule(app.earnKeeper, app.accountKeeper, app.bankKeeper),
)
@ -738,6 +750,7 @@ func NewApp(
authz.ModuleName,
evmutiltypes.ModuleName,
savingstypes.ModuleName,
liquidtypes.ModuleName,
earntypes.ModuleName,
)
@ -777,6 +790,7 @@ func NewApp(
authz.ModuleName,
evmutiltypes.ModuleName,
savingstypes.ModuleName,
liquidtypes.ModuleName,
earntypes.ModuleName,
)
@ -816,6 +830,7 @@ func NewApp(
paramstypes.ModuleName,
upgradetypes.ModuleName,
validatorvestingtypes.ModuleName,
liquidtypes.ModuleName,
)
app.mm.RegisterInvariants(&app.crisisKeeper)
@ -979,10 +994,11 @@ func (app *App) loadBlockedMaccAddrs() map[string]bool {
modAccAddrs := app.ModuleAccountAddrs()
kavadistMaccAddr := app.accountKeeper.GetModuleAddress(kavadisttypes.ModuleName)
earnMaccAddr := app.accountKeeper.GetModuleAddress(earntypes.ModuleName)
liquidMaccAddr := app.accountKeeper.GetModuleAddress(liquidtypes.ModuleName)
for addr := range modAccAddrs {
// Set the kavadist and earn module account address as unblocked
if addr == kavadistMaccAddr.String() || addr == earnMaccAddr.String() {
if addr == kavadistMaccAddr.String() || addr == earnMaccAddr.String() || addr == liquidMaccAddr.String() {
modAccAddrs[addr] = false
}
}

View File

@ -40,6 +40,7 @@ import (
incentivekeeper "github.com/kava-labs/kava/x/incentive/keeper"
issuancekeeper "github.com/kava-labs/kava/x/issuance/keeper"
kavadistkeeper "github.com/kava-labs/kava/x/kavadist/keeper"
liquidkeeper "github.com/kava-labs/kava/x/liquid/keeper"
pricefeedkeeper "github.com/kava-labs/kava/x/pricefeed/keeper"
savingskeeper "github.com/kava-labs/kava/x/savings/keeper"
swapkeeper "github.com/kava-labs/kava/x/swap/keeper"
@ -109,6 +110,7 @@ func (tApp TestApp) GetEvmutilKeeper() evmutilkeeper.Keeper { return tApp.ev
func (tApp TestApp) GetEvmKeeper() *evmkeeper.Keeper { return tApp.evmKeeper }
func (tApp TestApp) GetSavingsKeeper() savingskeeper.Keeper { return tApp.savingsKeeper }
func (tApp TestApp) GetFeeMarketKeeper() feemarketkeeper.Keeper { return tApp.feeMarketKeeper }
func (tApp TestApp) GetLiquidKeeper() liquidkeeper.Keeper { return tApp.liquidKeeper }
func (tApp TestApp) GetEarnKeeper() earnkeeper.Keeper { return tApp.earnKeeper }
// LegacyAmino returns the app's amino codec.

View File

@ -35,6 +35,10 @@ sed -in-place='' 's/enable = false/enable = true/g' $DATA/config/app.toml
# Set evm tracer to json
sed -in-place='' 's/tracer = ""/tracer = "json"/g' $DATA/config/app.toml
# Enable full error trace to be returned on tx failure
sed -in-place='' '/iavl-cache-size/a\
trace = true' $DATA/config/app.toml
# Set client chain id
sed -in-place='' 's/chain-id = ""/chain-id = "kavalocalnet_8888-1"/g' $DATA/config/client.toml

View File

@ -385,6 +385,14 @@
- [Query](#kava.kavadist.v1beta1.Query)
- [kava/liquid/v1beta1/tx.proto](#kava/liquid/v1beta1/tx.proto)
- [MsgBurnDerivative](#kava.liquid.v1beta1.MsgBurnDerivative)
- [MsgBurnDerivativeResponse](#kava.liquid.v1beta1.MsgBurnDerivativeResponse)
- [MsgMintDerivative](#kava.liquid.v1beta1.MsgMintDerivative)
- [MsgMintDerivativeResponse](#kava.liquid.v1beta1.MsgMintDerivativeResponse)
- [Msg](#kava.liquid.v1beta1.Msg)
- [kava/pricefeed/v1beta1/store.proto](#kava/pricefeed/v1beta1/store.proto)
- [CurrentPrice](#kava.pricefeed.v1beta1.CurrentPrice)
- [Market](#kava.pricefeed.v1beta1.Market)
@ -5384,6 +5392,97 @@ Query defines the gRPC querier service.
<a name="kava/liquid/v1beta1/tx.proto"></a>
<p align="right"><a href="#top">Top</a></p>
## kava/liquid/v1beta1/tx.proto
<a name="kava.liquid.v1beta1.MsgBurnDerivative"></a>
### MsgBurnDerivative
MsgBurnDerivative defines the Msg/BurnDerivative request type.
| Field | Type | Label | Description |
| ----- | ---- | ----- | ----------- |
| `sender` | [string](#string) | | sender is the owner of the derivatives to be converted |
| `validator` | [string](#string) | | validator is the validator of the derivatives to be converted |
| `amount` | [cosmos.base.v1beta1.Coin](#cosmos.base.v1beta1.Coin) | | amount is the quantity of derivatives to be converted |
<a name="kava.liquid.v1beta1.MsgBurnDerivativeResponse"></a>
### MsgBurnDerivativeResponse
MsgBurnDerivativeResponse defines the Msg/BurnDerivative response type.
| Field | Type | Label | Description |
| ----- | ---- | ----- | ----------- |
| `received` | [string](#string) | | received is the number of delegation shares sent to the sender |
<a name="kava.liquid.v1beta1.MsgMintDerivative"></a>
### MsgMintDerivative
MsgMintDerivative defines the Msg/MintDerivative request type.
| Field | Type | Label | Description |
| ----- | ---- | ----- | ----------- |
| `sender` | [string](#string) | | sender is the owner of the delegation to be converted |
| `validator` | [string](#string) | | validator is the validator of the delegation to be converted |
| `amount` | [cosmos.base.v1beta1.Coin](#cosmos.base.v1beta1.Coin) | | amount is the quantity of staked assets to be converted |
<a name="kava.liquid.v1beta1.MsgMintDerivativeResponse"></a>
### MsgMintDerivativeResponse
MsgMintDerivativeResponse defines the Msg/MintDerivative response type.
| Field | Type | Label | Description |
| ----- | ---- | ----- | ----------- |
| `received` | [cosmos.base.v1beta1.Coin](#cosmos.base.v1beta1.Coin) | | received is the amount of staking derivative minted and sent to the sender |
<!-- end messages -->
<!-- end enums -->
<!-- end HasExtensions -->
<a name="kava.liquid.v1beta1.Msg"></a>
### Msg
Msg defines the liquid Msg service.
| Method Name | Request Type | Response Type | Description | HTTP Verb | Endpoint |
| ----------- | ------------ | ------------- | ------------| ------- | -------- |
| `MintDerivative` | [MsgMintDerivative](#kava.liquid.v1beta1.MsgMintDerivative) | [MsgMintDerivativeResponse](#kava.liquid.v1beta1.MsgMintDerivativeResponse) | MintDerivative defines a method for converting a delegation into staking deriviatives. | |
| `BurnDerivative` | [MsgBurnDerivative](#kava.liquid.v1beta1.MsgBurnDerivative) | [MsgBurnDerivativeResponse](#kava.liquid.v1beta1.MsgBurnDerivativeResponse) | BurnDerivative defines a method for converting staking deriviatives into a delegation. | |
<!-- end services -->
<a name="kava/pricefeed/v1beta1/store.proto"></a>
<p align="right"><a href="#top">Top</a></p>

View File

@ -0,0 +1,54 @@
syntax = "proto3";
package kava.liquid.v1beta1;
import "gogoproto/gogo.proto";
import "cosmos/base/v1beta1/coin.proto";
import "cosmos_proto/cosmos.proto";
option go_package = "github.com/kava-labs/kava/x/liquid/types";
// Msg defines the liquid Msg service.
service Msg {
// MintDerivative defines a method for converting a delegation into staking deriviatives.
rpc MintDerivative(MsgMintDerivative) returns (MsgMintDerivativeResponse);
// BurnDerivative defines a method for converting staking deriviatives into a delegation.
rpc BurnDerivative(MsgBurnDerivative) returns (MsgBurnDerivativeResponse);
}
// MsgMintDerivative defines the Msg/MintDerivative request type.
message MsgMintDerivative {
// sender is the owner of the delegation to be converted
string sender = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"];
// validator is the validator of the delegation to be converted
string validator = 2;
// amount is the quantity of staked assets to be converted
cosmos.base.v1beta1.Coin amount = 3 [(gogoproto.nullable) = false];
}
// MsgMintDerivativeResponse defines the Msg/MintDerivative response type.
message MsgMintDerivativeResponse {
// received is the amount of staking derivative minted and sent to the sender
cosmos.base.v1beta1.Coin received = 1 [(gogoproto.nullable) = false];
}
// MsgBurnDerivative defines the Msg/BurnDerivative request type.
message MsgBurnDerivative {
// sender is the owner of the derivatives to be converted
string sender = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"];
// validator is the validator of the derivatives to be converted
string validator = 2;
// amount is the quantity of derivatives to be converted
cosmos.base.v1beta1.Coin amount = 3 [(gogoproto.nullable) = false];
}
// MsgBurnDerivativeResponse defines the Msg/BurnDerivative response type.
message MsgBurnDerivativeResponse {
// received is the number of delegation shares sent to the sender
string received = 1 [
(cosmos_proto.scalar) = "cosmos.Dec",
(gogoproto.customtype) = "github.com/cosmos/cosmos-sdk/types.Dec",
(gogoproto.nullable) = false
];
}

View File

@ -0,0 +1,31 @@
package cli
import (
"github.com/spf13/cobra"
"github.com/cosmos/cosmos-sdk/client"
"github.com/cosmos/cosmos-sdk/client/flags"
"github.com/kava-labs/kava/x/liquid/types"
)
// GetQueryCmd returns the cli query commands for this module
func GetQueryCmd() *cobra.Command {
liquidQueryCmd := &cobra.Command{
Use: types.ModuleName,
Short: "Querying commands for the liquid module",
DisableFlagParsing: true,
SuggestionsMinimumDistance: 2,
RunE: client.ValidateCmd,
}
cmds := []*cobra.Command{}
for _, cmd := range cmds {
flags.AddQueryFlagsToCmd(cmd)
}
liquidQueryCmd.AddCommand(cmds...)
return liquidQueryCmd
}

122
x/liquid/client/cli/tx.go Normal file
View File

@ -0,0 +1,122 @@
package cli
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/cosmos/cosmos-sdk/client"
"github.com/cosmos/cosmos-sdk/client/flags"
"github.com/cosmos/cosmos-sdk/client/tx"
sdk "github.com/cosmos/cosmos-sdk/types"
sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
"github.com/cosmos/cosmos-sdk/version"
"github.com/kava-labs/kava/x/liquid/types"
)
// GetTxCmd returns the transaction commands for this module
func GetTxCmd() *cobra.Command {
liquidTxCmd := &cobra.Command{
Use: types.ModuleName,
Short: "liquid transactions subcommands",
DisableFlagParsing: true,
SuggestionsMinimumDistance: 2,
RunE: client.ValidateCmd,
}
cmds := []*cobra.Command{
getCmdMintDerivative(),
getCmdBurnDerivative(),
}
for _, cmd := range cmds {
flags.AddTxFlagsToCmd(cmd)
}
liquidTxCmd.AddCommand(cmds...)
return liquidTxCmd
}
func getCmdMintDerivative() *cobra.Command {
return &cobra.Command{
Use: "mint [validator-addr] [amount]",
Short: "mints staking derivative from a delegation",
Long: "Mint removes a portion of a user's staking delegation and issues them validator specific staking derivative tokens.",
Args: cobra.ExactArgs(2),
Example: fmt.Sprintf(
`%s tx %s mint kavavaloper16lnfpgn6llvn4fstg5nfrljj6aaxyee9z59jqd 10000000ukava --from <key>`, version.AppName, types.ModuleName,
),
RunE: func(cmd *cobra.Command, args []string) error {
clientCtx, err := client.GetClientTxContext(cmd)
if err != nil {
return err
}
valAddr, err := sdk.ValAddressFromBech32(args[0])
if err != nil {
return sdkerrors.Wrap(sdkerrors.ErrInvalidAddress, err.Error())
}
coin, err := sdk.ParseCoinNormalized(args[1])
if err != nil {
return err
}
msg := types.NewMsgMintDerivative(clientCtx.GetFromAddress(), valAddr, coin)
if err := msg.ValidateBasic(); err != nil {
return err
}
return tx.GenerateOrBroadcastTxCLI(clientCtx, cmd.Flags(), &msg)
},
}
}
func getCmdBurnDerivative() *cobra.Command {
return &cobra.Command{
Use: "burn [amount]",
Short: "burns staking derivative to redeem a delegation",
Long: "Burn removes some staking derivative from a user's account and converts it back to a staking delegation.",
Example: fmt.Sprintf(
`%s tx %s burn 10000000bkava-kavavaloper16lnfpgn6llvn4fstg5nfrljj6aaxyee9z59jqd --from <key>`, version.AppName, types.ModuleName,
),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
clientCtx, err := client.GetClientTxContext(cmd)
if err != nil {
return err
}
amount, err := sdk.ParseCoinNormalized(args[0])
if err != nil {
return err
}
valAddr, err := parseLiquidStakingTokenDenom(amount.Denom)
if err != nil {
return sdkerrors.Wrap(types.ErrInvalidDenom, err.Error())
}
msg := types.NewMsgBurnDerivative(clientCtx.GetFromAddress(), valAddr, amount)
if err := msg.ValidateBasic(); err != nil {
return err
}
return tx.GenerateOrBroadcastTxCLI(clientCtx, cmd.Flags(), &msg)
},
}
}
// parseLiquidStakingTokenDenom extracts a validator address from a derivative denom.
func parseLiquidStakingTokenDenom(denom string) (sdk.ValAddress, error) {
elements := strings.Split(denom, types.DenomSeparator)
if len(elements) != 2 {
return nil, fmt.Errorf("cannot parse denom %s", denom)
}
addr, err := sdk.ValAddressFromBech32(elements[1])
if err != nil {
return nil, err
}
return addr, nil
}

View File

@ -0,0 +1,118 @@
package keeper
import (
sdk "github.com/cosmos/cosmos-sdk/types"
sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
"github.com/kava-labs/kava/x/liquid/types"
)
// MintDerivative removes a user's staking delegation and mints them equivalent staking derivative coins.
//
// The input staking token amount is used to calculate shares in the user's delegation, which are transferred to a delegation owned by the module.
// Derivative coins are them minted and transferred to the user.
func (k Keeper) MintDerivative(ctx sdk.Context, delegatorAddr sdk.AccAddress, valAddr sdk.ValAddress, amount sdk.Coin) (sdk.Coin, error) {
bondDenom := k.stakingKeeper.BondDenom(ctx)
if amount.Denom != bondDenom {
return sdk.Coin{}, sdkerrors.Wrapf(types.ErrInvalidDenom, "expected %s", bondDenom)
}
derivativeAmount, shares, err := k.CalculateDerivativeSharesFromTokens(ctx, delegatorAddr, valAddr, amount.Amount)
if err != nil {
return sdk.Coin{}, err
}
// Fetching the module account will create it if it doesn't exist.
// This is necessary as otherwise TransferDelegation will create a normal account.
modAcc := k.accountKeeper.GetModuleAccount(ctx, types.ModuleAccountName)
if _, err := k.TransferDelegation(ctx, valAddr, delegatorAddr, modAcc.GetAddress(), shares); err != nil {
return sdk.Coin{}, err
}
liquidTokenDenom := k.GetLiquidStakingTokenDenom(valAddr)
liquidToken := sdk.NewCoin(liquidTokenDenom, derivativeAmount)
if err = k.mintCoins(ctx, delegatorAddr, sdk.NewCoins(liquidToken)); err != nil {
return sdk.Coin{}, err
}
ctx.EventManager().EmitEvent(
sdk.NewEvent(
types.EventTypeMintDerivative,
sdk.NewAttribute(types.AttributeKeyDelegator, delegatorAddr.String()),
sdk.NewAttribute(types.AttributeKeyValidator, valAddr.String()),
sdk.NewAttribute(sdk.AttributeKeyAmount, liquidToken.String()),
sdk.NewAttribute(types.AttributeKeySharesTransferred, shares.String()),
),
)
return liquidToken, nil
}
// CalculateDerivativeSharesFromTokens converts a staking token amount into its equivalent delegation shares, and staking derivative amount.
// This combines the code for calculating the shares to be transferred, and the derivative coins to be minted.
func (k Keeper) CalculateDerivativeSharesFromTokens(ctx sdk.Context, delegator sdk.AccAddress, validator sdk.ValAddress, tokens sdk.Int) (sdk.Int, sdk.Dec, error) {
if !tokens.IsPositive() {
return sdk.Int{}, sdk.Dec{}, sdkerrors.Wrap(types.ErrUntransferableShares, "token amount must be positive")
}
shares, err := k.stakingKeeper.ValidateUnbondAmount(ctx, delegator, validator, tokens)
if err != nil {
return sdk.Int{}, sdk.Dec{}, err
}
return shares.TruncateInt(), shares, nil
}
// BurnDerivative burns an user's staking derivative coins and returns them an equivalent staking delegation.
//
// The derivative coins are burned, and an equivalent number of shares in the module's staking delegation are transferred back to the user.
func (k Keeper) BurnDerivative(ctx sdk.Context, delegatorAddr sdk.AccAddress, valAddr sdk.ValAddress, amount sdk.Coin) (sdk.Dec, error) {
if amount.Denom != k.GetLiquidStakingTokenDenom(valAddr) {
return sdk.Dec{}, sdkerrors.Wrap(types.ErrInvalidDenom, "derivative denom does not match validator")
}
if err := k.burnCoins(ctx, delegatorAddr, sdk.NewCoins(amount)); err != nil {
return sdk.Dec{}, err
}
modAcc := k.accountKeeper.GetModuleAccount(ctx, types.ModuleAccountName)
shares := amount.Amount.ToDec()
receivedShares, err := k.TransferDelegation(ctx, valAddr, modAcc.GetAddress(), delegatorAddr, shares)
if err != nil {
return sdk.Dec{}, err
}
ctx.EventManager().EmitEvent(
sdk.NewEvent(
types.EventTypeBurnDerivative,
sdk.NewAttribute(types.AttributeKeyDelegator, delegatorAddr.String()),
sdk.NewAttribute(types.AttributeKeyValidator, valAddr.String()),
sdk.NewAttribute(sdk.AttributeKeyAmount, amount.String()),
sdk.NewAttribute(types.AttributeKeySharesTransferred, shares.String()),
),
)
return receivedShares, nil
}
func (k Keeper) GetLiquidStakingTokenDenom(valAddr sdk.ValAddress) string {
return types.GetLiquidStakingTokenDenom(k.derivativeDenom, valAddr)
}
func (k Keeper) mintCoins(ctx sdk.Context, receiver sdk.AccAddress, amount sdk.Coins) error {
if err := k.bankKeeper.MintCoins(ctx, types.ModuleAccountName, amount); err != nil {
return err
}
if err := k.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleAccountName, receiver, amount); err != nil {
return err
}
return nil
}
func (k Keeper) burnCoins(ctx sdk.Context, sender sdk.AccAddress, amount sdk.Coins) error {
if err := k.bankKeeper.SendCoinsFromAccountToModule(ctx, sender, types.ModuleAccountName, amount); err != nil {
return err
}
if err := k.bankKeeper.BurnCoins(ctx, types.ModuleAccountName, amount); err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,316 @@
package keeper_test
import (
"fmt"
sdk "github.com/cosmos/cosmos-sdk/types"
sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
authtypes "github.com/cosmos/cosmos-sdk/x/auth/types"
"github.com/cosmos/cosmos-sdk/x/staking"
stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types"
"github.com/kava-labs/kava/app"
"github.com/kava-labs/kava/x/liquid/types"
)
func (suite *KeeperTestSuite) TestBurnDerivative() {
_, addrs := app.GeneratePrivKeyAddressPairs(5)
valAccAddr, user := addrs[0], addrs[1]
valAddr := sdk.ValAddress(valAccAddr)
liquidDenom := suite.Keeper.GetLiquidStakingTokenDenom(valAddr)
testCases := []struct {
name string
balance sdk.Coin
moduleDelegation sdk.Int
burnAmount sdk.Coin
expectedErr error
}{
{
name: "user can burn their entire balance",
balance: c(liquidDenom, 1e9),
moduleDelegation: i(1e9),
burnAmount: c(liquidDenom, 1e9),
},
{
name: "user can burn minimum derivative unit",
balance: c(liquidDenom, 1e9),
moduleDelegation: i(1e9),
burnAmount: c(liquidDenom, 1),
},
{
name: "error when denom cannot be parsed",
balance: c(liquidDenom, 1e9),
moduleDelegation: i(1e9),
burnAmount: c(fmt.Sprintf("ckava-%s", valAddr), 1e6),
expectedErr: types.ErrInvalidDenom,
},
{
name: "error when burn amount is 0",
balance: c(liquidDenom, 1e9),
moduleDelegation: i(1e9),
burnAmount: c(liquidDenom, 0),
expectedErr: types.ErrUntransferableShares,
},
{
name: "error when user doesn't have enough funds",
balance: c("ukava", 10),
moduleDelegation: i(1e9),
burnAmount: c(liquidDenom, 1e9),
expectedErr: sdkerrors.ErrInsufficientFunds,
},
{
name: "error when backing delegation isn't large enough",
balance: c(liquidDenom, 1e9),
moduleDelegation: i(999_999_999),
burnAmount: c(liquidDenom, 1e9),
expectedErr: stakingtypes.ErrNotEnoughDelegationShares,
},
}
for _, tc := range testCases {
suite.Run(tc.name, func() {
suite.SetupTest()
suite.CreateAccountWithAddress(valAccAddr, suite.NewBondCoins(i(1e6)))
suite.CreateAccountWithAddress(user, sdk.NewCoins(tc.balance))
suite.AddCoinsToModule(types.ModuleAccountName, suite.NewBondCoins(tc.moduleDelegation))
// create delegation from module account to back the derivatives
moduleAccAddress := authtypes.NewModuleAddress(types.ModuleAccountName)
suite.CreateNewUnbondedValidator(valAddr, i(1e6))
suite.CreateDelegation(valAddr, moduleAccAddress, tc.moduleDelegation)
staking.EndBlocker(suite.Ctx, suite.StakingKeeper)
modBalance := suite.BankKeeper.GetAllBalances(suite.Ctx, moduleAccAddress)
_, err := suite.Keeper.BurnDerivative(suite.Ctx, user, valAddr, tc.burnAmount)
suite.Require().ErrorIs(err, tc.expectedErr)
if tc.expectedErr != nil {
// if an error is expected, state should be reverted so don't need to test state is unchanged
return
}
suite.AccountBalanceEqual(user, sdk.NewCoins(tc.balance.Sub(tc.burnAmount)))
suite.AccountBalanceEqual(moduleAccAddress, modBalance) // ensure derivatives are burned, and not in module account
sharesTransferred := tc.burnAmount.Amount.ToDec()
suite.DelegationSharesEqual(valAddr, user, sharesTransferred)
suite.DelegationSharesEqual(valAddr, moduleAccAddress, tc.moduleDelegation.ToDec().Sub(sharesTransferred))
suite.EventsContains(suite.Ctx.EventManager().Events(), sdk.NewEvent(
types.EventTypeBurnDerivative,
sdk.NewAttribute(types.AttributeKeyDelegator, user.String()),
sdk.NewAttribute(types.AttributeKeyValidator, valAddr.String()),
sdk.NewAttribute(sdk.AttributeKeyAmount, tc.burnAmount.String()),
sdk.NewAttribute(types.AttributeKeySharesTransferred, sharesTransferred.String()),
))
})
}
}
func (suite *KeeperTestSuite) TestCalculateShares() {
_, addrs := app.GeneratePrivKeyAddressPairs(5)
valAccAddr, delegator := addrs[0], addrs[1]
valAddr := sdk.ValAddress(valAccAddr)
type returns struct {
derivatives sdk.Int
shares sdk.Dec
err error
}
type validator struct {
tokens sdk.Int
delegatorShares sdk.Dec
}
testCases := []struct {
name string
validator *validator
delegation sdk.Dec
transfer sdk.Int
expected returns
}{
{
name: "error when validator not found",
validator: nil,
delegation: d("1000000000"),
transfer: i(500e6),
expected: returns{
err: stakingtypes.ErrNoValidatorFound,
},
},
{
name: "error when delegation not found",
validator: &validator{i(1e9), d("1000000000")},
delegation: sdk.Dec{},
transfer: i(500e6),
expected: returns{
err: stakingtypes.ErrNoDelegation,
},
},
{
name: "error when transfer < 0",
validator: &validator{i(10), d("10")},
delegation: d("10"),
transfer: i(-1),
expected: returns{
err: types.ErrUntransferableShares,
},
},
{ // disallow zero transfers
name: "error when transfer = 0",
validator: &validator{i(10), d("10")},
delegation: d("10"),
transfer: i(0),
expected: returns{
err: types.ErrUntransferableShares,
},
},
{
name: "error when transfer > delegated shares",
validator: &validator{i(10), d("10")},
delegation: d("10"),
transfer: i(11),
expected: returns{
err: sdkerrors.ErrInvalidRequest,
},
},
{
name: "error when validator has no tokens",
validator: &validator{i(0), d("10")},
delegation: d("10"),
transfer: i(5),
expected: returns{
err: stakingtypes.ErrInsufficientShares,
},
},
{
name: "shares and derivatives are truncated",
validator: &validator{i(3), d("4")},
delegation: d("4"),
transfer: i(2),
expected: returns{
derivatives: i(2), // truncated down
shares: d("2.666666666666666666"), // 2/3 * 4 not rounded to ...667
},
},
{
name: "error if calculated shares > shares in delegation",
validator: &validator{i(3), d("4")},
delegation: d("2.666666666666666665"), // one less than 2/3 * 4
transfer: i(2),
expected: returns{
err: sdkerrors.ErrInvalidRequest,
},
},
}
for _, tc := range testCases {
suite.Run(tc.name, func() {
suite.SetupTest()
if tc.validator != nil {
suite.StakingKeeper.SetValidator(suite.Ctx, stakingtypes.Validator{
OperatorAddress: valAddr.String(),
Tokens: tc.validator.tokens,
DelegatorShares: tc.validator.delegatorShares,
})
}
if !tc.delegation.IsNil() {
suite.StakingKeeper.SetDelegation(suite.Ctx, stakingtypes.Delegation{
DelegatorAddress: delegator.String(),
ValidatorAddress: valAddr.String(),
Shares: tc.delegation,
})
}
derivatives, shares, err := suite.Keeper.CalculateDerivativeSharesFromTokens(suite.Ctx, delegator, valAddr, tc.transfer)
if tc.expected.err != nil {
suite.ErrorIs(err, tc.expected.err)
} else {
suite.NoError(err)
suite.Equal(tc.expected.derivatives, derivatives, "expected '%s' got '%s'", tc.expected.derivatives, derivatives)
suite.Equal(tc.expected.shares, shares)
}
})
}
}
func (suite *KeeperTestSuite) TestMintDerivative() {
_, addrs := app.GeneratePrivKeyAddressPairs(5)
valAccAddr, delegator := addrs[0], addrs[1]
valAddr := sdk.ValAddress(valAccAddr)
moduleAccAddress := authtypes.NewModuleAddress(types.ModuleAccountName)
initialBalance := i(1e9)
vestedBalance := i(500e6)
testCases := []struct {
name string
amount sdk.Coin
expectedDerivatives sdk.Int
expectedSharesRemaining sdk.Dec
expectedSharesAdded sdk.Dec
expectedErr error
}{
{
name: "derivative is minted",
amount: suite.NewBondCoin(vestedBalance),
expectedDerivatives: i(500e6),
expectedSharesRemaining: d("500000000.0"),
expectedSharesAdded: d("500000000.0"),
},
{
name: "error when the input denom isn't correct",
amount: sdk.NewCoin("invalid", i(1000)),
expectedErr: types.ErrInvalidDenom,
},
{
name: "error when shares cannot be calculated",
amount: suite.NewBondCoin(initialBalance.Mul(i(100))),
expectedErr: sdkerrors.ErrInvalidRequest,
},
{
name: "error when shares cannot be transferred",
amount: suite.NewBondCoin(initialBalance), // trying to move vesting coins will fail in `TransferShares`
expectedErr: sdkerrors.ErrInsufficientFunds,
},
}
for _, tc := range testCases {
suite.Run(tc.name, func() {
suite.SetupTest()
suite.CreateAccountWithAddress(valAccAddr, suite.NewBondCoins(initialBalance))
suite.CreateVestingAccountWithAddress(delegator, suite.NewBondCoins(initialBalance), suite.NewBondCoins(vestedBalance))
suite.CreateNewUnbondedValidator(valAddr, initialBalance)
suite.CreateDelegation(valAddr, delegator, initialBalance)
staking.EndBlocker(suite.Ctx, suite.StakingKeeper)
_, err := suite.Keeper.MintDerivative(suite.Ctx, delegator, valAddr, tc.amount)
suite.Require().ErrorIs(err, tc.expectedErr)
if tc.expectedErr != nil {
// if an error is expected, state should be reverted so don't need to test state is unchanged
return
}
derivative := sdk.NewCoins(sdk.NewCoin(fmt.Sprintf("bkava-%s", valAddr), tc.expectedDerivatives))
suite.AccountBalanceEqual(delegator, derivative)
suite.DelegationSharesEqual(valAddr, delegator, tc.expectedSharesRemaining)
suite.DelegationSharesEqual(valAddr, moduleAccAddress, tc.expectedSharesAdded)
sharesTransferred := initialBalance.ToDec().Sub(tc.expectedSharesRemaining)
suite.EventsContains(suite.Ctx.EventManager().Events(), sdk.NewEvent(
types.EventTypeMintDerivative,
sdk.NewAttribute(types.AttributeKeyDelegator, delegator.String()),
sdk.NewAttribute(types.AttributeKeyValidator, valAddr.String()),
sdk.NewAttribute(sdk.AttributeKeyAmount, derivative.String()),
sdk.NewAttribute(types.AttributeKeySharesTransferred, sharesTransferred.String()),
))
})
}
}

52
x/liquid/keeper/keeper.go Normal file
View File

@ -0,0 +1,52 @@
package keeper
import (
"fmt"
"github.com/cosmos/cosmos-sdk/codec"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/tendermint/tendermint/libs/log"
"github.com/kava-labs/kava/x/liquid/types"
)
// Keeper struct for the liquid module.
type Keeper struct {
cdc codec.Codec
accountKeeper types.AccountKeeper
bankKeeper types.BankKeeper
stakingKeeper types.StakingKeeper
derivativeDenom string
}
// NewKeeper returns a new keeper for the liquid module.
func NewKeeper(
cdc codec.Codec,
ak types.AccountKeeper, bk types.BankKeeper, sk types.StakingKeeper,
derivativeDenom string,
) Keeper {
return Keeper{
cdc: cdc,
accountKeeper: ak,
bankKeeper: bk,
stakingKeeper: sk,
derivativeDenom: derivativeDenom,
}
}
// NewDefaultKeeper returns a new keeper for the liquid module with default values.
func NewDefaultKeeper(
cdc codec.Codec,
ak types.AccountKeeper, bk types.BankKeeper, sk types.StakingKeeper,
) Keeper {
return NewKeeper(cdc, ak, bk, sk, types.DefaultDerivativeDenom)
}
// Logger returns a module-specific logger.
func (k Keeper) Logger(ctx sdk.Context) log.Logger {
return ctx.Logger().With("module", fmt.Sprintf("x/%s", types.ModuleName))
}

View File

@ -0,0 +1,238 @@
package keeper_test
import (
"fmt"
"reflect"
"testing"
"github.com/cosmos/cosmos-sdk/crypto/keys/ed25519"
"github.com/cosmos/cosmos-sdk/simapp"
sdk "github.com/cosmos/cosmos-sdk/types"
authtypes "github.com/cosmos/cosmos-sdk/x/auth/types"
vestingtypes "github.com/cosmos/cosmos-sdk/x/auth/vesting/types"
bankkeeper "github.com/cosmos/cosmos-sdk/x/bank/keeper"
stakingkeeper "github.com/cosmos/cosmos-sdk/x/staking/keeper"
stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types"
"github.com/stretchr/testify/suite"
abci "github.com/tendermint/tendermint/abci/types"
tmproto "github.com/tendermint/tendermint/proto/tendermint/types"
tmtime "github.com/tendermint/tendermint/types/time"
"github.com/kava-labs/kava/app"
"github.com/kava-labs/kava/x/liquid/keeper"
)
// Test suite used for all keeper tests
type KeeperTestSuite struct {
suite.Suite
App app.TestApp
Ctx sdk.Context
Keeper keeper.Keeper
BankKeeper bankkeeper.Keeper
StakingKeeper stakingkeeper.Keeper
}
// The default state used by each test
func (suite *KeeperTestSuite) SetupTest() {
tApp := app.NewTestApp()
ctx := tApp.NewContext(true, tmproto.Header{Height: 1, Time: tmtime.Now()})
tApp.InitializeFromGenesisStates()
suite.App = tApp
suite.Ctx = ctx
suite.Keeper = tApp.GetLiquidKeeper()
suite.StakingKeeper = tApp.GetStakingKeeper()
suite.BankKeeper = tApp.GetBankKeeper()
}
// CreateAccount creates a new account (with a fixed address) from the provided balance.
func (suite *KeeperTestSuite) CreateAccount(initialBalance sdk.Coins) authtypes.AccountI {
_, addrs := app.GeneratePrivKeyAddressPairs(1)
return suite.CreateAccountWithAddress(addrs[0], initialBalance)
}
// CreateAccount creates a new account from the provided balance and address
func (suite *KeeperTestSuite) CreateAccountWithAddress(addr sdk.AccAddress, initialBalance sdk.Coins) authtypes.AccountI {
ak := suite.App.GetAccountKeeper()
acc := ak.NewAccountWithAddress(suite.Ctx, addr)
ak.SetAccount(suite.Ctx, acc)
err := simapp.FundAccount(suite.BankKeeper, suite.Ctx, acc.GetAddress(), initialBalance)
suite.Require().NoError(err)
return acc
}
// CreateVestingAccount creates a new vesting account. `vestingBalance` should be a fraction of `initialBalance`.
func (suite *KeeperTestSuite) CreateVestingAccountWithAddress(addr sdk.AccAddress, initialBalance sdk.Coins, vestingBalance sdk.Coins) authtypes.AccountI {
if vestingBalance.IsAnyGT(initialBalance) {
panic("vesting balance must be less than initial balance")
}
acc := suite.CreateAccountWithAddress(addr, initialBalance)
bacc := acc.(*authtypes.BaseAccount)
periods := vestingtypes.Periods{
vestingtypes.Period{
Length: 31556952,
Amount: vestingBalance,
},
}
vacc := vestingtypes.NewPeriodicVestingAccount(bacc, vestingBalance, suite.Ctx.BlockTime().Unix(), periods)
suite.App.GetAccountKeeper().SetAccount(suite.Ctx, vacc)
return vacc
}
// AddCoinsToModule adds coins to the a module account, creating it if it doesn't exist.
func (suite *KeeperTestSuite) AddCoinsToModule(module string, amount sdk.Coins) {
err := simapp.FundModuleAccount(suite.BankKeeper, suite.Ctx, module, amount)
suite.Require().NoError(err)
}
// AccountBalanceEqual checks if an account has the specified coins.
func (suite *KeeperTestSuite) AccountBalanceEqual(addr sdk.AccAddress, coins sdk.Coins) {
balance := suite.BankKeeper.GetAllBalances(suite.Ctx, addr)
suite.Equalf(coins, balance, "expected account balance to equal coins %s, but got %s", coins, balance)
}
func (suite *KeeperTestSuite) deliverMsgCreateValidator(ctx sdk.Context, address sdk.ValAddress, selfDelegation sdk.Coin) error {
msg, err := stakingtypes.NewMsgCreateValidator(
address,
ed25519.GenPrivKey().PubKey(),
selfDelegation,
stakingtypes.Description{},
stakingtypes.NewCommissionRates(sdk.ZeroDec(), sdk.ZeroDec(), sdk.ZeroDec()),
sdk.NewInt(1e6),
)
if err != nil {
return err
}
msgServer := stakingkeeper.NewMsgServerImpl(suite.StakingKeeper)
_, err = msgServer.CreateValidator(sdk.WrapSDKContext(suite.Ctx), msg)
return err
}
// NewBondCoin creates a Coin with the current staking denom.
func (suite *KeeperTestSuite) NewBondCoin(amount sdk.Int) sdk.Coin {
stakingDenom := suite.StakingKeeper.BondDenom(suite.Ctx)
return sdk.NewCoin(stakingDenom, amount)
}
// NewBondCoins creates Coins with the current staking denom.
func (suite *KeeperTestSuite) NewBondCoins(amount sdk.Int) sdk.Coins {
return sdk.NewCoins(suite.NewBondCoin(amount))
}
// CreateNewUnbondedValidator creates a new validator in the staking module.
// New validators are unbonded until the end blocker is run.
func (suite *KeeperTestSuite) CreateNewUnbondedValidator(addr sdk.ValAddress, selfDelegation sdk.Int) stakingtypes.Validator {
// Create a validator
err := suite.deliverMsgCreateValidator(suite.Ctx, addr, suite.NewBondCoin(selfDelegation))
suite.Require().NoError(err)
// New validators are created in an unbonded state. Note if the end blocker is run later this validator could become bonded.
validator, found := suite.StakingKeeper.GetValidator(suite.Ctx, addr)
suite.Require().True(found)
return validator
}
// SlashValidator burns tokens staked in a validator. new_tokens = old_tokens * (1-slashFraction)
func (suite *KeeperTestSuite) SlashValidator(addr sdk.ValAddress, slashFraction sdk.Dec) {
validator, found := suite.StakingKeeper.GetValidator(suite.Ctx, addr)
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 := suite.StakingKeeper.TokensToConsensusPower(suite.Ctx, validator.GetTokens())
suite.StakingKeeper.Slash(suite.Ctx, consAddr, infractionHeight, power, slashFraction)
}
// CreateDelegation delegates tokens to a validator.
func (suite *KeeperTestSuite) CreateDelegation(valAddr sdk.ValAddress, delegator sdk.AccAddress, amount sdk.Int) sdk.Dec {
stakingDenom := suite.StakingKeeper.BondDenom(suite.Ctx)
msg := stakingtypes.NewMsgDelegate(
delegator,
valAddr,
sdk.NewCoin(stakingDenom, amount),
)
msgServer := stakingkeeper.NewMsgServerImpl(suite.StakingKeeper)
_, err := msgServer.Delegate(sdk.WrapSDKContext(suite.Ctx), msg)
suite.Require().NoError(err)
del, found := suite.StakingKeeper.GetDelegation(suite.Ctx, delegator, valAddr)
suite.Require().True(found)
return del.Shares
}
// CreateRedelegation undelegates tokens from one validator and delegates to another.
func (suite *KeeperTestSuite) CreateRedelegation(delegator sdk.AccAddress, fromValidator, toValidator sdk.ValAddress, amount sdk.Int) {
stakingDenom := suite.StakingKeeper.BondDenom(suite.Ctx)
msg := stakingtypes.NewMsgBeginRedelegate(
delegator,
fromValidator,
toValidator,
sdk.NewCoin(stakingDenom, amount),
)
msgServer := stakingkeeper.NewMsgServerImpl(suite.StakingKeeper)
_, err := msgServer.BeginRedelegate(sdk.WrapSDKContext(suite.Ctx), msg)
suite.Require().NoError(err)
}
// DelegationSharesEqual checks if a delegation has the specified shares.
// It expects delegations with zero shares to not be stored in state.
func (suite *KeeperTestSuite) DelegationSharesEqual(valAddr sdk.ValAddress, delegator sdk.AccAddress, shares sdk.Dec) bool {
del, found := suite.StakingKeeper.GetDelegation(suite.Ctx, delegator, valAddr)
if shares.IsZero() {
return suite.Falsef(found, "expected delegator to not be found, got %s shares", del.Shares)
} else {
res := suite.True(found, "expected delegator to be found")
return res && suite.Truef(shares.Equal(del.Shares), "expected %s delegator shares but got %s", shares, del.Shares)
}
}
// EventsContains asserts that the expected event is in the provided events
func (suite *KeeperTestSuite) EventsContains(events sdk.Events, expectedEvent sdk.Event) {
foundMatch := false
for _, event := range events {
if event.Type == expectedEvent.Type {
if reflect.DeepEqual(attrsToMap(expectedEvent.Attributes), attrsToMap(event.Attributes)) {
foundMatch = true
}
}
}
suite.True(foundMatch, fmt.Sprintf("event of type %s not found or did not match", expectedEvent.Type))
}
// EventsDoNotContainType asserts that the provided events do contain an event of a certain type.
func (suite *KeeperTestSuite) EventsDoNotContainType(events sdk.Events, eventType string) {
for _, event := range events {
suite.Falsef(event.Type == eventType, "found unexpected event %s", eventType)
}
}
func attrsToMap(attrs []abci.EventAttribute) []sdk.Attribute {
out := []sdk.Attribute{}
for _, attr := range attrs {
out = append(out, sdk.NewAttribute(string(attr.Key), string(attr.Value)))
}
return out
}
func TestKeeperTestSuite(t *testing.T) {
suite.Run(t, new(KeeperTestSuite))
}

View File

@ -0,0 +1,84 @@
package keeper
import (
"context"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/kava-labs/kava/x/liquid/types"
)
type msgServer struct {
keeper Keeper
}
// NewMsgServerImpl returns an implementation of the liquid MsgServer interface
// for the provided Keeper.
func NewMsgServerImpl(keeper Keeper) types.MsgServer {
return &msgServer{keeper: keeper}
}
var _ types.MsgServer = msgServer{}
// MintDerivative handles MintDerivative msgs.
func (k msgServer) MintDerivative(goCtx context.Context, msg *types.MsgMintDerivative) (*types.MsgMintDerivativeResponse, error) {
ctx := sdk.UnwrapSDKContext(goCtx)
validator, err := sdk.ValAddressFromBech32(msg.Validator)
if err != nil {
return nil, err
}
sender, err := sdk.AccAddressFromBech32(msg.Sender)
if err != nil {
return nil, err
}
mintedDerivative, err := k.keeper.MintDerivative(ctx, sender, validator, msg.Amount)
if err != nil {
return nil, err
}
ctx.EventManager().EmitEvent(
sdk.NewEvent(
sdk.EventTypeMessage,
sdk.NewAttribute(sdk.AttributeKeyModule, types.AttributeValueCategory),
sdk.NewAttribute(sdk.AttributeKeySender, msg.Sender),
),
)
return &types.MsgMintDerivativeResponse{
Received: mintedDerivative,
}, nil
}
// BurnDerivative handles BurnDerivative msgs.
func (k msgServer) BurnDerivative(goCtx context.Context, msg *types.MsgBurnDerivative) (*types.MsgBurnDerivativeResponse, error) {
ctx := sdk.UnwrapSDKContext(goCtx)
sender, err := sdk.AccAddressFromBech32(msg.Sender)
if err != nil {
return nil, err
}
validator, err := sdk.ValAddressFromBech32(msg.Validator)
if err != nil {
return nil, err
}
sharesReceived, err := k.keeper.BurnDerivative(ctx, sender, validator, msg.Amount)
if err != nil {
return nil, err
}
ctx.EventManager().EmitEvent(
sdk.NewEvent(
sdk.EventTypeMessage,
sdk.NewAttribute(sdk.AttributeKeyModule, types.AttributeValueCategory),
sdk.NewAttribute(sdk.AttributeKeySender, msg.Sender),
),
)
return &types.MsgBurnDerivativeResponse{
Received: sharesReceived,
}, nil
}

109
x/liquid/keeper/staking.go Normal file
View File

@ -0,0 +1,109 @@
package keeper
import (
sdk "github.com/cosmos/cosmos-sdk/types"
sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types"
"github.com/kava-labs/kava/x/liquid/types"
)
// TransferDelegation moves some delegation shares between addresses, while keeping the same validator.
//
// Internally shares are unbonded, tokens moved then bonded again. This limits only vested tokens from being transferred.
// The sending delegation must not have any active redelegations.
// A validator cannot reduce self delegated shares below its min self delegation.
// Attempting to transfer zero shares will error.
func (k Keeper) TransferDelegation(ctx sdk.Context, valAddr sdk.ValAddress, fromDelegator, toDelegator sdk.AccAddress, shares sdk.Dec) (sdk.Dec, error) {
// Redelegations link a delegation to it's previous validator so slashes are propagated to the new validator.
// If the delegation is transferred to a new owner, the redelegation object must be updated.
// For expediency all transfers with redelegations are blocked.
if k.stakingKeeper.HasReceivingRedelegation(ctx, fromDelegator, valAddr) {
return sdk.Dec{}, types.ErrRedelegationsNotCompleted
}
if shares.IsNil() || shares.LT(sdk.ZeroDec()) {
return sdk.Dec{}, sdkerrors.Wrap(types.ErrUntransferableShares, "nil or negative shares")
}
if shares.Equal(sdk.ZeroDec()) {
// Block 0 transfers to reduce edge cases.
return sdk.Dec{}, sdkerrors.Wrap(types.ErrUntransferableShares, "zero shares")
}
fromDelegation, found := k.stakingKeeper.GetDelegation(ctx, fromDelegator, valAddr)
if !found {
return sdk.Dec{}, types.ErrNoDelegatorForAddress
}
validator, found := k.stakingKeeper.GetValidator(ctx, valAddr)
if !found {
return sdk.Dec{}, types.ErrNoValidatorFound
}
// Prevent validators from reducing their self delegation below the min.
isValidatorOperator := fromDelegator.Equals(valAddr)
if isValidatorOperator {
if isBelowMinSelfDelegation(validator, fromDelegation.Shares.Sub(shares)) {
return sdk.Dec{}, types.ErrSelfDelegationBelowMinimum
}
}
returnAmount, err := k.fastUndelegate(ctx, valAddr, fromDelegator, shares)
if err != nil {
return sdk.Dec{}, err
}
returnCoins := sdk.NewCoins(sdk.NewCoin(k.stakingKeeper.BondDenom(ctx), returnAmount))
if err := k.bankKeeper.SendCoins(ctx, fromDelegator, toDelegator, returnCoins); err != nil {
return sdk.Dec{}, err
}
receivedShares, err := k.delegateFromAccount(ctx, valAddr, toDelegator, returnAmount)
if err != nil {
return sdk.Dec{}, err
}
return receivedShares, nil
}
// isBelowMinSelfDelegation check if the supplied shares, converted to tokens, are under the validator's min_self_delegation.
func isBelowMinSelfDelegation(validator stakingtypes.ValidatorI, shares sdk.Dec) bool {
return validator.TokensFromShares(shares).TruncateInt().LT(validator.GetMinSelfDelegation())
}
// fastUndelegate undelegates shares from a validator skipping the unbonding period and not creating any unbonding delegations.
func (k Keeper) fastUndelegate(ctx sdk.Context, valAddr sdk.ValAddress, delegator sdk.AccAddress, shares sdk.Dec) (sdk.Int, error) {
validator, found := k.stakingKeeper.GetValidator(ctx, valAddr)
if !found {
return sdk.Int{}, types.ErrNoDelegatorForAddress
}
returnAmount, err := k.stakingKeeper.Unbond(ctx, delegator, valAddr, shares)
if err != nil {
return sdk.Int{}, err
}
returnCoins := sdk.NewCoins(sdk.NewCoin(k.stakingKeeper.BondDenom(ctx), returnAmount))
// transfer the validator tokens to the not bonded pool
if validator.IsBonded() {
if err := k.bankKeeper.SendCoinsFromModuleToModule(ctx, stakingtypes.BondedPoolName, stakingtypes.NotBondedPoolName, returnCoins); err != nil {
panic(err)
}
}
if err := k.bankKeeper.UndelegateCoinsFromModuleToAccount(ctx, stakingtypes.NotBondedPoolName, delegator, returnCoins); err != nil {
return sdk.Int{}, err
}
return returnAmount, nil
}
// delegateFromAccount delegates to a validator from an account (vs redelegating from an existing delegation)
func (k Keeper) delegateFromAccount(ctx sdk.Context, valAddr sdk.ValAddress, delegator sdk.AccAddress, amount sdk.Int) (sdk.Dec, error) {
validator, found := k.stakingKeeper.GetValidator(ctx, valAddr)
if !found {
return sdk.Dec{}, types.ErrNoValidatorFound
}
// source tokens are from an account, so subtractAccount true and tokenSrc unbonded
newShares, err := k.stakingKeeper.Delegate(ctx, delegator, amount, stakingtypes.Unbonded, validator, true)
if err != nil {
return sdk.Dec{}, err
}
return newShares, nil
}

View File

@ -0,0 +1,378 @@
package keeper_test
import (
"github.com/cosmos/cosmos-sdk/crypto/keys/ed25519"
sdk "github.com/cosmos/cosmos-sdk/types"
sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
authtypes "github.com/cosmos/cosmos-sdk/x/auth/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/kava-labs/kava/app"
"github.com/kava-labs/kava/x/liquid/types"
)
var (
// d is an alias for sdk.MustNewDecFromStr
d = sdk.MustNewDecFromStr
// i is an alias for sdk.NewInt
i = sdk.NewInt
// c is an alias for sdk.NewInt64Coin
c = sdk.NewInt64Coin
)
func (suite *KeeperTestSuite) TestTransferDelegation_ValidatorStates() {
_, addrs := app.GeneratePrivKeyAddressPairs(3)
valAccAddr, fromDelegator, toDelegator := addrs[0], addrs[1], addrs[2]
valAddr := sdk.ValAddress(valAccAddr)
initialBalance := i(1e9)
notBondedModAddr := authtypes.NewModuleAddress(stakingtypes.NotBondedPoolName)
bondedModAddr := authtypes.NewModuleAddress(stakingtypes.BondedPoolName)
testCases := []struct {
name string
createValidator func() (delegatorShares sdk.Dec, err error)
}{
{
name: "bonded validator",
createValidator: func() (sdk.Dec, error) {
suite.CreateNewUnbondedValidator(valAddr, initialBalance)
delegatorShares := suite.CreateDelegation(valAddr, fromDelegator, i(1e9))
// Run end blocker to update validator state to bonded.
staking.EndBlocker(suite.Ctx, suite.StakingKeeper)
return delegatorShares, nil
},
},
{
name: "unbonded validator",
createValidator: func() (sdk.Dec, error) {
suite.CreateNewUnbondedValidator(valAddr, initialBalance)
delegatorShares := suite.CreateDelegation(valAddr, fromDelegator, i(1e9))
// Don't run end blocker, new validators are by default unbonded.
return delegatorShares, nil
},
},
{
name: "ubonding (jailed) validator",
createValidator: func() (sdk.Dec, error) {
val := suite.CreateNewUnbondedValidator(valAddr, initialBalance)
delegatorShares := suite.CreateDelegation(valAddr, fromDelegator, i(1e9))
// Run end blocker to update validator state to bonded.
staking.EndBlocker(suite.Ctx, suite.StakingKeeper)
// Jail and run end blocker to transition validator to unbonding.
consAddr, err := val.GetConsAddr()
if err != nil {
return sdk.Dec{}, err
}
suite.StakingKeeper.Jail(suite.Ctx, consAddr)
staking.EndBlocker(suite.Ctx, suite.StakingKeeper)
return delegatorShares, nil
},
},
}
for _, tc := range testCases {
suite.Run(tc.name, func() {
suite.SetupTest()
suite.CreateAccountWithAddress(valAccAddr, suite.NewBondCoins(i(1e9)))
suite.CreateAccountWithAddress(fromDelegator, suite.NewBondCoins(i(1e9)))
fromDelegationShares, err := tc.createValidator()
suite.Require().NoError(err)
validator, found := suite.StakingKeeper.GetValidator(suite.Ctx, valAddr)
suite.Require().True(found)
notBondedBalance := suite.BankKeeper.GetAllBalances(suite.Ctx, notBondedModAddr)
bondedBalance := suite.BankKeeper.GetAllBalances(suite.Ctx, bondedModAddr)
shares := d("1000")
_, err = suite.Keeper.TransferDelegation(suite.Ctx, valAddr, fromDelegator, toDelegator, shares)
suite.Require().NoError(err)
// Transferring a delegation should move shares, and leave the validator and pool balances the same.
suite.DelegationSharesEqual(valAddr, fromDelegator, fromDelegationShares.Sub(shares))
suite.DelegationSharesEqual(valAddr, toDelegator, shares) // also creates new delegation
validatorAfter, found := suite.StakingKeeper.GetValidator(suite.Ctx, valAddr)
suite.Require().True(found)
suite.Equal(validator.GetTokens(), validatorAfter.GetTokens())
suite.Equal(validator.GetDelegatorShares(), validatorAfter.GetDelegatorShares())
suite.Equal(validator.GetStatus(), validatorAfter.GetStatus())
suite.AccountBalanceEqual(notBondedModAddr, notBondedBalance)
suite.AccountBalanceEqual(bondedModAddr, bondedBalance)
})
}
}
func (suite *KeeperTestSuite) TestTransferDelegation_Shares() {
_, addrs := app.GeneratePrivKeyAddressPairs(5)
valAccAddr, fromDelegator, toDelegator := addrs[0], addrs[1], addrs[2]
valAddr := sdk.ValAddress(valAccAddr)
initialBalance := i(1e12)
testCases := []struct {
name string
createDelegations func() (fromDelegatorShares, toDelegatorShares sdk.Dec, err error)
shares sdk.Dec
expectReceived sdk.Dec
expectedErr error
}{
{
name: "negative shares cannot be transferred",
createDelegations: func() (sdk.Dec, sdk.Dec, error) {
suite.CreateNewUnbondedValidator(valAddr, i(1e9))
fromDelegationShares := suite.CreateDelegation(valAddr, fromDelegator, i(1e9))
// Run end blocker to update validator state to bonded.
staking.EndBlocker(suite.Ctx, suite.StakingKeeper)
return fromDelegationShares, sdk.ZeroDec(), nil
},
shares: d("-1.0"),
expectedErr: types.ErrUntransferableShares,
},
{
name: "nil shares cannot be transferred",
createDelegations: func() (sdk.Dec, sdk.Dec, error) {
suite.CreateNewUnbondedValidator(valAddr, i(1e9))
fromDelegationShares := suite.CreateDelegation(valAddr, fromDelegator, i(1e9))
staking.EndBlocker(suite.Ctx, suite.StakingKeeper)
return fromDelegationShares, sdk.ZeroDec(), nil
},
shares: sdk.Dec{},
expectedErr: types.ErrUntransferableShares,
},
{
name: "0 shares cannot be transferred",
createDelegations: func() (sdk.Dec, sdk.Dec, error) {
suite.CreateNewUnbondedValidator(valAddr, i(1e9))
fromDelegationShares := suite.CreateDelegation(valAddr, fromDelegator, i(1e9))
toDelegationShares := suite.CreateDelegation(valAddr, toDelegator, i(2e9))
staking.EndBlocker(suite.Ctx, suite.StakingKeeper)
return fromDelegationShares, toDelegationShares, nil
},
shares: sdk.ZeroDec(),
expectedErr: types.ErrUntransferableShares,
},
{
name: "all shares can be transferred",
createDelegations: func() (sdk.Dec, sdk.Dec, error) {
suite.CreateNewUnbondedValidator(valAddr, i(1e9))
fromDelegationShares := suite.CreateDelegation(valAddr, fromDelegator, i(1e9))
toDelegationShares := suite.CreateDelegation(valAddr, toDelegator, i(2e9))
staking.EndBlocker(suite.Ctx, suite.StakingKeeper)
return fromDelegationShares, toDelegationShares, nil
},
shares: d("1000000000.0"),
expectReceived: d("1000000000.0"),
},
{
name: "excess shares cannot be transferred",
createDelegations: func() (sdk.Dec, sdk.Dec, error) {
suite.CreateNewUnbondedValidator(valAddr, i(1e9))
fromDelegationShares := suite.CreateDelegation(valAddr, fromDelegator, i(1e9))
staking.EndBlocker(suite.Ctx, suite.StakingKeeper)
return fromDelegationShares, sdk.ZeroDec(), nil
},
shares: d("1000000000.000000000000000001"),
expectedErr: stakingtypes.ErrNotEnoughDelegationShares,
},
{
name: "shares can be transferred to a non existent delegation",
createDelegations: func() (sdk.Dec, sdk.Dec, error) {
suite.CreateNewUnbondedValidator(valAddr, i(1e9))
fromDelegationShares := suite.CreateDelegation(valAddr, fromDelegator, i(1e9))
staking.EndBlocker(suite.Ctx, suite.StakingKeeper)
return fromDelegationShares, sdk.ZeroDec(), nil
},
shares: d("500000000.0"),
expectReceived: d("500000000.0"),
},
{
name: "shares cannot be transferred from a non existent delegation",
createDelegations: func() (sdk.Dec, sdk.Dec, error) {
suite.CreateNewUnbondedValidator(valAddr, i(1e9))
staking.EndBlocker(suite.Ctx, suite.StakingKeeper)
return sdk.ZeroDec(), sdk.ZeroDec(), nil
},
shares: d("500000000.0"),
expectedErr: types.ErrNoDelegatorForAddress,
},
{
name: "slashed validator shares can be transferred",
createDelegations: func() (sdk.Dec, sdk.Dec, error) {
suite.CreateNewUnbondedValidator(valAddr, i(1e9))
fromDelegationShares := suite.CreateDelegation(valAddr, fromDelegator, i(1e9))
staking.EndBlocker(suite.Ctx, suite.StakingKeeper)
suite.SlashValidator(valAddr, d("0.05"))
return fromDelegationShares, sdk.ZeroDec(), nil
},
shares: d("500000000.0"),
expectReceived: d("500000000.0"),
},
{
name: "zero shares received when transfer < 1 token",
createDelegations: func() (sdk.Dec, sdk.Dec, error) {
suite.CreateNewUnbondedValidator(valAddr, i(1e9))
fromDelegationShares := suite.CreateDelegation(valAddr, fromDelegator, i(1e9))
toDelegationShares := suite.CreateDelegation(valAddr, toDelegator, i(1e9))
staking.EndBlocker(suite.Ctx, suite.StakingKeeper)
// make 1 share worth more than 1 token
suite.SlashValidator(valAddr, d("0.05"))
return fromDelegationShares, toDelegationShares, nil
},
shares: d("1.0"), // send 1 share (truncates to zero tokens)
expectReceived: d("0.0"),
},
}
for _, tc := range testCases {
suite.Run(tc.name, func() {
suite.SetupTest()
suite.CreateAccountWithAddress(valAccAddr, suite.NewBondCoins(initialBalance))
suite.CreateAccountWithAddress(fromDelegator, suite.NewBondCoins(initialBalance))
suite.CreateAccountWithAddress(toDelegator, suite.NewBondCoins(initialBalance))
fromDelegationShares, toDelegationShares, err := tc.createDelegations()
suite.Require().NoError(err)
validator, found := suite.StakingKeeper.GetValidator(suite.Ctx, valAddr)
suite.Require().True(found)
_, err = suite.Keeper.TransferDelegation(suite.Ctx, valAddr, fromDelegator, toDelegator, tc.shares)
if tc.expectedErr != nil {
suite.ErrorIs(err, tc.expectedErr)
return
}
suite.NoError(err)
suite.DelegationSharesEqual(valAddr, fromDelegator, fromDelegationShares.Sub(tc.shares))
suite.DelegationSharesEqual(valAddr, toDelegator, toDelegationShares.Add(tc.expectReceived))
validatorAfter, found := suite.StakingKeeper.GetValidator(suite.Ctx, valAddr)
suite.Require().True(found)
// total tokens should not change
suite.Equal(validator.GetTokens(), validatorAfter.GetTokens())
// but total shares can differ
suite.Equal(
validator.GetDelegatorShares().Sub(tc.shares).Add(tc.expectReceived),
validatorAfter.GetDelegatorShares(),
)
})
}
}
func (suite *KeeperTestSuite) TestTransferDelegation_RedelegationsForbidden() {
_, addrs := app.GeneratePrivKeyAddressPairs(4)
val1AccAddr, val2AccAddr, fromDelegator, toDelegator := addrs[0], addrs[1], addrs[2], addrs[3]
val1Addr := sdk.ValAddress(val1AccAddr)
val2Addr := sdk.ValAddress(val2AccAddr)
initialBalance := i(1e12)
suite.CreateAccountWithAddress(val1AccAddr, suite.NewBondCoins(initialBalance))
suite.CreateAccountWithAddress(val2AccAddr, suite.NewBondCoins(initialBalance))
suite.CreateAccountWithAddress(fromDelegator, suite.NewBondCoins(initialBalance))
// create bonded validator 1 with a delegation
suite.CreateNewUnbondedValidator(val1Addr, i(1e9))
fromDelegationShares := suite.CreateDelegation(val1Addr, fromDelegator, i(1e9))
staking.EndBlocker(suite.Ctx, suite.StakingKeeper)
// create validator 2 and redelegate to it
suite.CreateNewUnbondedValidator(val2Addr, i(1e9))
suite.CreateRedelegation(fromDelegator, val1Addr, val2Addr, i(1e9))
staking.EndBlocker(suite.Ctx, suite.StakingKeeper)
_, err := suite.Keeper.TransferDelegation(suite.Ctx, val2Addr, fromDelegator, toDelegator, fromDelegationShares)
suite.ErrorIs(err, types.ErrRedelegationsNotCompleted)
suite.DelegationSharesEqual(val2Addr, fromDelegator, fromDelegationShares)
suite.DelegationSharesEqual(val2Addr, toDelegator, sdk.ZeroDec())
}
func (suite *KeeperTestSuite) TestTransferDelegation_CompliesWithMinSelfDelegation() {
_, addrs := app.GeneratePrivKeyAddressPairs(4)
valAccAddr, toDelegator := addrs[0], addrs[1]
valAddr := sdk.ValAddress(valAccAddr)
suite.CreateAccountWithAddress(valAccAddr, suite.NewBondCoins(i(1e12)))
// create bonded validator with minimum delegated
minSelfDelegation := i(1e9)
delegation := suite.NewBondCoin(i(1e9))
msg, err := stakingtypes.NewMsgCreateValidator(
valAddr,
ed25519.GenPrivKey().PubKey(),
delegation,
stakingtypes.Description{},
stakingtypes.NewCommissionRates(sdk.ZeroDec(), sdk.ZeroDec(), sdk.ZeroDec()),
minSelfDelegation,
)
suite.Require().NoError(err)
msgServer := stakingkeeper.NewMsgServerImpl(suite.StakingKeeper)
_, err = msgServer.CreateValidator(sdk.WrapSDKContext(suite.Ctx), msg)
suite.Require().NoError(err)
staking.EndBlocker(suite.Ctx, suite.StakingKeeper)
_, err = suite.Keeper.TransferDelegation(suite.Ctx, valAddr, valAccAddr, toDelegator, d("0.000000000000000001"))
suite.ErrorIs(err, types.ErrSelfDelegationBelowMinimum)
suite.DelegationSharesEqual(valAddr, valAccAddr, delegation.Amount.ToDec())
}
func (suite *KeeperTestSuite) TestTransferDelegation_CanTransferVested() {
_, addrs := app.GeneratePrivKeyAddressPairs(4)
valAccAddr, fromDelegator, toDelegator := addrs[0], addrs[1], addrs[2]
valAddr := sdk.ValAddress(valAccAddr)
suite.CreateAccountWithAddress(valAccAddr, suite.NewBondCoins(i(1e9)))
suite.CreateVestingAccountWithAddress(fromDelegator, suite.NewBondCoins(i(2e9)), suite.NewBondCoins(i(1e9)))
suite.CreateNewUnbondedValidator(valAddr, i(1e9))
fromDelegationShares := suite.CreateDelegation(valAddr, fromDelegator, i(2e9))
staking.EndBlocker(suite.Ctx, suite.StakingKeeper)
shares := d("1000000000.0")
_, err := suite.Keeper.TransferDelegation(suite.Ctx, valAddr, fromDelegator, toDelegator, shares)
suite.NoError(err)
suite.DelegationSharesEqual(valAddr, fromDelegator, fromDelegationShares.Sub(shares))
suite.DelegationSharesEqual(valAddr, toDelegator, shares)
}
func (suite *KeeperTestSuite) TestTransferDelegation_CannotTransferVesting() {
_, addrs := app.GeneratePrivKeyAddressPairs(4)
valAccAddr, fromDelegator, toDelegator := addrs[0], addrs[1], addrs[2]
valAddr := sdk.ValAddress(valAccAddr)
suite.CreateAccountWithAddress(valAccAddr, suite.NewBondCoins(i(1e9)))
suite.CreateVestingAccountWithAddress(fromDelegator, suite.NewBondCoins(i(2e9)), suite.NewBondCoins(i(1e9)))
suite.CreateNewUnbondedValidator(valAddr, i(1e9))
suite.CreateDelegation(valAddr, fromDelegator, i(2e9))
staking.EndBlocker(suite.Ctx, suite.StakingKeeper)
_, err := suite.Keeper.TransferDelegation(suite.Ctx, valAddr, fromDelegator, toDelegator, d("1000000001.0"))
suite.ErrorIs(err, sdkerrors.ErrInsufficientFunds)
}

138
x/liquid/module.go Normal file
View File

@ -0,0 +1,138 @@
package liquid
import (
"encoding/json"
"github.com/gorilla/mux"
"github.com/grpc-ecosystem/grpc-gateway/runtime"
"github.com/spf13/cobra"
"github.com/cosmos/cosmos-sdk/client"
"github.com/cosmos/cosmos-sdk/codec"
codectypes "github.com/cosmos/cosmos-sdk/codec/types"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/cosmos/cosmos-sdk/types/module"
abci "github.com/tendermint/tendermint/abci/types"
"github.com/kava-labs/kava/x/liquid/client/cli"
"github.com/kava-labs/kava/x/liquid/keeper"
"github.com/kava-labs/kava/x/liquid/types"
)
var (
_ module.AppModule = AppModule{}
_ module.AppModuleBasic = AppModuleBasic{}
)
// AppModuleBasic app module basics object
type AppModuleBasic struct{}
// Name get module name
func (AppModuleBasic) Name() string {
return types.ModuleName
}
// RegisterLegacyAminoCodec register module codec
func (AppModuleBasic) RegisterLegacyAminoCodec(cdc *codec.LegacyAmino) {
types.RegisterLegacyAminoCodec(cdc)
}
// DefaultGenesis default genesis state
func (AppModuleBasic) DefaultGenesis(_ codec.JSONCodec) json.RawMessage {
return []byte("{}")
}
// ValidateGenesis module validate genesis
func (AppModuleBasic) ValidateGenesis(_ codec.JSONCodec, _ client.TxEncodingConfig, _ json.RawMessage) error {
return nil
}
// RegisterInterfaces implements InterfaceModule.RegisterInterfaces
func (a AppModuleBasic) RegisterInterfaces(registry codectypes.InterfaceRegistry) {
types.RegisterInterfaces(registry)
}
// RegisterRESTRoutes registers REST routes for the module.
func (a AppModuleBasic) RegisterRESTRoutes(_ client.Context, _ *mux.Router) {}
// RegisterGRPCGatewayRoutes registers the gRPC Gateway routes for the module.
func (a AppModuleBasic) RegisterGRPCGatewayRoutes(clientCtx client.Context, mux *runtime.ServeMux) {}
// GetTxCmd returns the root tx command for the module.
func (AppModuleBasic) GetTxCmd() *cobra.Command {
return cli.GetTxCmd()
}
// GetQueryCmd returns no root query command for the module.
func (AppModuleBasic) GetQueryCmd() *cobra.Command {
return cli.GetQueryCmd()
}
//____________________________________________________________________________
// AppModule app module type
type AppModule struct {
AppModuleBasic
keeper keeper.Keeper
}
// NewAppModule creates a new AppModule object
func NewAppModule(keeper keeper.Keeper) AppModule {
return AppModule{
AppModuleBasic: AppModuleBasic{},
keeper: keeper,
}
}
// Name module name
func (am AppModule) Name() string {
return am.AppModuleBasic.Name()
}
// RegisterInvariants register module invariants
func (am AppModule) RegisterInvariants(_ sdk.InvariantRegistry) {}
// Route module message route name
func (am AppModule) Route() sdk.Route {
return sdk.Route{}
}
// QuerierRoute module querier route name
func (AppModule) QuerierRoute() string {
return ""
}
// LegacyQuerierHandler returns no sdk.Querier.
func (am AppModule) LegacyQuerierHandler(_ *codec.LegacyAmino) sdk.Querier {
return nil
}
// ConsensusVersion implements AppModule/ConsensusVersion.
func (AppModule) ConsensusVersion() uint64 {
return 1
}
// RegisterServices registers module services.
func (am AppModule) RegisterServices(cfg module.Configurator) {
types.RegisterMsgServer(cfg.MsgServer(), keeper.NewMsgServerImpl(am.keeper))
}
// InitGenesis module init-genesis
func (am AppModule) InitGenesis(_ sdk.Context, _ codec.JSONCodec, _ json.RawMessage) []abci.ValidatorUpdate {
return []abci.ValidatorUpdate{}
}
// ExportGenesis module export genesis
func (am AppModule) ExportGenesis(_ sdk.Context, cdc codec.JSONCodec) json.RawMessage {
return am.DefaultGenesis(cdc)
}
// BeginBlock module begin-block
func (am AppModule) BeginBlock(_ sdk.Context, _ abci.RequestBeginBlock) {}
// EndBlock module end-block
func (am AppModule) EndBlock(_ sdk.Context, _ abci.RequestEndBlock) []abci.ValidatorUpdate {
return []abci.ValidatorUpdate{}
}

38
x/liquid/types/codec.go Normal file
View File

@ -0,0 +1,38 @@
package types
import (
"github.com/cosmos/cosmos-sdk/codec"
"github.com/cosmos/cosmos-sdk/codec/types"
"github.com/cosmos/cosmos-sdk/types/msgservice"
cryptocodec "github.com/cosmos/cosmos-sdk/crypto/codec"
sdk "github.com/cosmos/cosmos-sdk/types"
)
// RegisterLegacyAminoCodec registers all the necessary types and interfaces for the module.
func RegisterLegacyAminoCodec(cdc *codec.LegacyAmino) {
cdc.RegisterConcrete(&MsgMintDerivative{}, "liquid/MsgMintDerivative", nil)
cdc.RegisterConcrete(&MsgBurnDerivative{}, "liquid/MsgBurnDerivative", nil)
}
// RegisterInterfaces registers proto messages under their interfaces for unmarshalling,
// in addition to registering the msg service for handling tx msgs.
func RegisterInterfaces(registry types.InterfaceRegistry) {
registry.RegisterImplementations((*sdk.Msg)(nil),
&MsgMintDerivative{},
&MsgBurnDerivative{},
)
msgservice.RegisterMsgServiceDesc(registry, &_Msg_serviceDesc)
}
var (
amino = codec.NewLegacyAmino()
ModuleCdc = codec.NewAminoCodec(amino)
)
func init() {
RegisterLegacyAminoCodec(amino)
cryptocodec.RegisterCrypto(amino)
}

View File

@ -0,0 +1,13 @@
package types_test
import (
"os"
"testing"
"github.com/kava-labs/kava/app"
)
func TestMain(m *testing.M) {
app.SetSDKConfig()
os.Exit(m.Run())
}

15
x/liquid/types/errors.go Normal file
View File

@ -0,0 +1,15 @@
package types
import (
sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
)
var (
ErrNoValidatorFound = sdkerrors.New(ModuleName, 2, "validator does not exist")
ErrNoDelegatorForAddress = sdkerrors.New(ModuleName, 3, "delegator does not contain delegation")
ErrInvalidDenom = sdkerrors.New(ModuleName, 4, "invalid denom")
ErrNotEnoughDelegationShares = sdkerrors.New(ModuleName, 5, "not enough delegation shares")
ErrRedelegationsNotCompleted = sdkerrors.New(ModuleName, 6, "active redelegations cannot be transferred")
ErrUntransferableShares = sdkerrors.New(ModuleName, 7, "shares cannot be transferred")
ErrSelfDelegationBelowMinimum = sdkerrors.Register(ModuleName, 8, "validator's self delegation must be greater than their minimum self delegation")
)

11
x/liquid/types/events.go Normal file
View File

@ -0,0 +1,11 @@
package types
const (
EventTypeMintDerivative = "mint_derivative"
EventTypeBurnDerivative = "burn_derivative"
AttributeValueCategory = ModuleName
AttributeKeyDelegator = "delegator"
AttributeKeyValidator = "validator"
AttributeKeySharesTransferred = "shares_transferred"
)

View File

@ -0,0 +1,46 @@
package types
import (
sdk "github.com/cosmos/cosmos-sdk/types"
authtypes "github.com/cosmos/cosmos-sdk/x/auth/types"
"github.com/cosmos/cosmos-sdk/x/staking/types"
stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types"
)
// BankKeeper defines the expected bank keeper
type BankKeeper interface {
SendCoinsFromModuleToModule(ctx sdk.Context, senderModule, recipientModule string, amt sdk.Coins) error
SendCoinsFromAccountToModule(ctx sdk.Context, senderAddr sdk.AccAddress, recipientModule string, amt sdk.Coins) error
SendCoinsFromModuleToAccount(ctx sdk.Context, senderModule string, recipientAddr sdk.AccAddress, amt sdk.Coins) error
SendCoins(ctx sdk.Context, fromAddr sdk.AccAddress, toAddr sdk.AccAddress, amt sdk.Coins) error
MintCoins(ctx sdk.Context, moduleName string, amt sdk.Coins) error
BurnCoins(ctx sdk.Context, moduleName string, amt sdk.Coins) error
UndelegateCoinsFromModuleToAccount(ctx sdk.Context, senderModule string, recipientAddr sdk.AccAddress, amt sdk.Coins) error
}
// AccountKeeper defines the expected keeper interface for interacting with account
type AccountKeeper interface {
GetModuleAccount(ctx sdk.Context, name string) authtypes.ModuleAccountI
}
// StakingKeeper defines the expected keeper interface for interacting with staking
type StakingKeeper interface {
BondDenom(ctx sdk.Context) (res string)
GetValidator(ctx sdk.Context, addr sdk.ValAddress) (validator stakingtypes.Validator, found bool)
GetDelegation(ctx sdk.Context, delAddr sdk.AccAddress, valAddr sdk.ValAddress) (delegation types.Delegation, found bool)
HasReceivingRedelegation(ctx sdk.Context, delAddr sdk.AccAddress, valDstAddr sdk.ValAddress) bool
ValidateUnbondAmount(
ctx sdk.Context, delAddr sdk.AccAddress, valAddr sdk.ValAddress, amt sdk.Int,
) (shares sdk.Dec, err error)
Delegate(
ctx sdk.Context, delAddr sdk.AccAddress, bondAmt sdk.Int, tokenSrc stakingtypes.BondStatus,
validator stakingtypes.Validator, subtractAccount bool,
) (newShares sdk.Dec, err error)
Unbond(
ctx sdk.Context, delAddr sdk.AccAddress, valAddr sdk.ValAddress, shares sdk.Dec,
) (amount sdk.Int, err error)
}

26
x/liquid/types/key.go Normal file
View File

@ -0,0 +1,26 @@
package types
import (
"fmt"
sdk "github.com/cosmos/cosmos-sdk/types"
)
const (
// ModuleName The name that will be used throughout the module
ModuleName = "liquid"
// RouterKey Top level router key
RouterKey = ModuleName
// ModuleAccountName is the module account's name
ModuleAccountName = ModuleName
DefaultDerivativeDenom = "bkava"
DenomSeparator = "-"
)
func GetLiquidStakingTokenDenom(bondDenom string, valAddr sdk.ValAddress) string {
return fmt.Sprintf("%s%s%s", bondDenom, DenomSeparator, valAddr.String())
}

120
x/liquid/types/msg.go Normal file
View File

@ -0,0 +1,120 @@
package types
import (
sdk "github.com/cosmos/cosmos-sdk/types"
sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
"github.com/cosmos/cosmos-sdk/x/auth/legacy/legacytx"
)
const (
// TypeMsgMintDerivative represents the type string for MsgMintDerivative
TypeMsgMintDerivative = "mint_derivative"
// TypeMsgBurnDerivative represents the type string for MsgBurnDerivative
TypeMsgBurnDerivative = "burn_derivative"
)
// ensure Msg interface compliance at compile time
var (
_ sdk.Msg = &MsgMintDerivative{}
_ legacytx.LegacyMsg = &MsgMintDerivative{}
_ sdk.Msg = &MsgBurnDerivative{}
_ legacytx.LegacyMsg = &MsgBurnDerivative{}
)
// NewMsgMintDerivative returns a new MsgMintDerivative
func NewMsgMintDerivative(sender sdk.AccAddress, validator sdk.ValAddress, amount sdk.Coin) MsgMintDerivative {
return MsgMintDerivative{
Sender: sender.String(),
Validator: validator.String(),
Amount: amount,
}
}
// Route return the message type used for routing the message.
func (msg MsgMintDerivative) Route() string { return RouterKey }
// Type returns a human-readable string for the message, intended for utilization within tags.
func (msg MsgMintDerivative) Type() string { return TypeMsgMintDerivative }
// ValidateBasic does a simple validation check that doesn't require access to any other information.
func (msg MsgMintDerivative) ValidateBasic() error {
_, err := sdk.AccAddressFromBech32(msg.Sender)
if err != nil {
return sdkerrors.Wrap(sdkerrors.ErrInvalidAddress, err.Error())
}
_, err = sdk.ValAddressFromBech32(msg.Validator)
if err != nil {
return sdkerrors.Wrap(sdkerrors.ErrInvalidAddress, err.Error())
}
if msg.Amount.IsNil() || !msg.Amount.IsValid() || msg.Amount.IsZero() {
return sdkerrors.Wrapf(sdkerrors.ErrInvalidCoins, "'%s'", msg.Amount)
}
return nil
}
// GetSignBytes gets the canonical byte representation of the Msg.
func (msg MsgMintDerivative) GetSignBytes() []byte {
bz := ModuleCdc.MustMarshalJSON(&msg)
return sdk.MustSortJSON(bz)
}
// GetSigners returns the addresses of signers that must sign.
func (msg MsgMintDerivative) GetSigners() []sdk.AccAddress {
sender, err := sdk.AccAddressFromBech32(msg.Sender)
if err != nil {
panic(err)
}
return []sdk.AccAddress{sender}
}
// NewMsgBurnDerivative returns a new MsgBurnDerivative
func NewMsgBurnDerivative(sender sdk.AccAddress, validator sdk.ValAddress, amount sdk.Coin) MsgBurnDerivative {
return MsgBurnDerivative{
Sender: sender.String(),
Validator: validator.String(),
Amount: amount,
}
}
// Route return the message type used for routing the message.
func (msg MsgBurnDerivative) Route() string { return RouterKey }
// Type returns a human-readable string for the message, intended for utilization within tags.
func (msg MsgBurnDerivative) Type() string { return TypeMsgBurnDerivative }
// ValidateBasic does a simple validation check that doesn't require access to any other information.
func (msg MsgBurnDerivative) ValidateBasic() error {
_, err := sdk.AccAddressFromBech32(msg.Sender)
if err != nil {
return sdkerrors.Wrap(sdkerrors.ErrInvalidAddress, err.Error())
}
_, err = sdk.ValAddressFromBech32(msg.Validator)
if err != nil {
return sdkerrors.Wrap(sdkerrors.ErrInvalidAddress, err.Error())
}
if msg.Amount.IsNil() || !msg.Amount.IsValid() || msg.Amount.IsZero() {
return sdkerrors.Wrapf(sdkerrors.ErrInvalidCoins, "'%s'", msg.Amount)
}
return nil
}
// GetSignBytes gets the canonical byte representation of the Msg.
func (msg MsgBurnDerivative) GetSignBytes() []byte {
bz := ModuleCdc.MustMarshalJSON(&msg)
return sdk.MustSortJSON(bz)
}
// GetSigners returns the addresses of signers that must sign.
func (msg MsgBurnDerivative) GetSigners() []sdk.AccAddress {
sender, err := sdk.AccAddressFromBech32(msg.Sender)
if err != nil {
panic(err)
}
return []sdk.AccAddress{sender}
}

163
x/liquid/types/msg_test.go Normal file
View File

@ -0,0 +1,163 @@
package types_test
import (
fmt "fmt"
"testing"
sdk "github.com/cosmos/cosmos-sdk/types"
sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/kava-labs/kava/x/liquid/types"
)
func TestMsgMintDerivative_Signing(t *testing.T) {
address := mustAccAddressFromBech32("kava1gepm4nwzz40gtpur93alv9f9wm5ht4l0hzzw9d")
validatorAddress := mustValAddressFromBech32("kavavaloper1ypjp0m04pyp73hwgtc0dgkx0e9rrydeckewa42")
msg := types.NewMsgMintDerivative(
address,
validatorAddress,
sdk.NewCoin("ukava", sdk.NewInt(1e9)),
)
// checking for the "type" field ensures the msg is registered on the amino codec
signBytes := []byte(
`{"type":"liquid/MsgMintDerivative","value":{"amount":{"amount":"1000000000","denom":"ukava"},"sender":"kava1gepm4nwzz40gtpur93alv9f9wm5ht4l0hzzw9d","validator":"kavavaloper1ypjp0m04pyp73hwgtc0dgkx0e9rrydeckewa42"}}`,
)
assert.Equal(t, []sdk.AccAddress{address}, msg.GetSigners())
assert.Equal(t, signBytes, msg.GetSignBytes())
}
func TestMsgBurnDerivative_Signing(t *testing.T) {
address := mustAccAddressFromBech32("kava1gepm4nwzz40gtpur93alv9f9wm5ht4l0hzzw9d")
validatorAddress := mustValAddressFromBech32("kavavaloper1ypjp0m04pyp73hwgtc0dgkx0e9rrydeckewa42")
msg := types.NewMsgBurnDerivative(
address,
validatorAddress,
sdk.NewCoin("bkava-kavavaloper1ypjp0m04pyp73hwgtc0dgkx0e9rrydeckewa42", sdk.NewInt(1e9)),
)
// checking for the "type" field ensures the msg is registered on the amino codec
signBytes := []byte(
`{"type":"liquid/MsgBurnDerivative","value":{"amount":{"amount":"1000000000","denom":"bkava-kavavaloper1ypjp0m04pyp73hwgtc0dgkx0e9rrydeckewa42"},"sender":"kava1gepm4nwzz40gtpur93alv9f9wm5ht4l0hzzw9d","validator":"kavavaloper1ypjp0m04pyp73hwgtc0dgkx0e9rrydeckewa42"}}`,
)
assert.Equal(t, []sdk.AccAddress{address}, msg.GetSigners())
assert.Equal(t, signBytes, msg.GetSignBytes())
}
func TestMsg_Validate(t *testing.T) {
validAddress := mustAccAddressFromBech32("kava1gepm4nwzz40gtpur93alv9f9wm5ht4l0hzzw9d")
validValidatorAddress := mustValAddressFromBech32("kavavaloper1ypjp0m04pyp73hwgtc0dgkx0e9rrydeckewa42")
validCoin := sdk.NewInt64Coin("ukava", 1e9)
type msgArgs struct {
sender string
validator string
amount sdk.Coin
}
tests := []struct {
name string
msgArgs msgArgs
expectedErr error
}{
{
name: "normal is valid",
msgArgs: msgArgs{
sender: validAddress.String(),
validator: validValidatorAddress.String(),
amount: validCoin,
},
},
{
name: "invalid sender",
msgArgs: msgArgs{
sender: "invalid",
validator: validValidatorAddress.String(),
amount: validCoin,
},
expectedErr: sdkerrors.ErrInvalidAddress,
},
{
name: "invalid short sender",
msgArgs: msgArgs{
sender: "kava1uexte6", // encoded zero length address
validator: validValidatorAddress.String(),
amount: validCoin,
},
expectedErr: sdkerrors.ErrInvalidAddress,
},
{
name: "invalid validator",
msgArgs: msgArgs{
sender: validAddress.String(),
validator: "invalid",
amount: validCoin,
},
expectedErr: sdkerrors.ErrInvalidAddress,
},
{
name: "invalid nil coin",
msgArgs: msgArgs{
sender: validAddress.String(),
validator: validValidatorAddress.String(),
amount: sdk.Coin{},
},
expectedErr: sdkerrors.ErrInvalidCoins,
},
{
name: "invalid zero coin",
msgArgs: msgArgs{
sender: validAddress.String(),
validator: validValidatorAddress.String(),
amount: sdk.NewInt64Coin("ukava", 0),
},
expectedErr: sdkerrors.ErrInvalidCoins,
},
}
for _, tc := range tests {
msgs := []sdk.Msg{
&types.MsgMintDerivative{
Sender: tc.msgArgs.sender,
Validator: tc.msgArgs.validator,
Amount: tc.msgArgs.amount,
},
&types.MsgBurnDerivative{
Sender: tc.msgArgs.sender,
Validator: tc.msgArgs.validator,
Amount: tc.msgArgs.amount,
},
}
for _, msg := range msgs {
t.Run(fmt.Sprintf("%s/%T", tc.name, msg), func(t *testing.T) {
err := msg.ValidateBasic()
if tc.expectedErr == nil {
require.NoError(t, err)
} else {
require.ErrorIs(t, err, tc.expectedErr, "expected error '%s' not found in actual '%s'", tc.expectedErr, err)
}
})
}
}
}
func mustAccAddressFromBech32(address string) sdk.AccAddress {
addr, err := sdk.AccAddressFromBech32(address)
if err != nil {
panic(err)
}
return addr
}
func mustValAddressFromBech32(address string) sdk.ValAddress {
addr, err := sdk.ValAddressFromBech32(address)
if err != nil {
panic(err)
}
return addr
}

1188
x/liquid/types/tx.pb.go Normal file

File diff suppressed because it is too large Load Diff