From d45fa58f5cd6be753f003b526a9b5c3c94981ffe Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Thu, 15 Jul 2021 09:42:30 -0500 Subject: [PATCH] Swap Genesis State (#960) * wip: add swap state persistent to genesis * separate pool record constructors; add tests for json and yaml encoding of record structs * beef up validation checks for state records * fix integration with master - renamed method * add test coverage for basic state array validations * extra test around pool record reserve and id ordering to ensure no regressions in the future * add validations to ensure pool records and share records are unique within the collection types * test genesis json and yaml encoding * validate in genesis that the total shares owned for each pool is equal to the total shares of each pool * update alias * nit lint * test genesis init and export * add migration todo Co-authored-by: Nick DeLuca --- migrate/v0_15/migrate.go | 3 +- x/swap/alias.go | 19 ++ x/swap/genesis.go | 14 +- x/swap/genesis_test.go | 73 +++++ x/swap/keeper/deposit.go | 4 +- x/swap/keeper/keeper_test.go | 2 +- x/swap/keeper/querier_test.go | 8 +- x/swap/keeper/swap.go | 2 +- x/swap/keeper/withdraw.go | 2 +- x/swap/types/genesis.go | 63 ++++- x/swap/types/genesis_test.go | 238 +++++++++++++++- x/swap/types/state.go | 131 ++++++++- x/swap/types/state_test.go | 497 +++++++++++++++++++++++++++++++++- 13 files changed, 1027 insertions(+), 29 deletions(-) create mode 100644 x/swap/genesis_test.go diff --git a/migrate/v0_15/migrate.go b/migrate/v0_15/migrate.go index 68b9619e..629d5e52 100644 --- a/migrate/v0_15/migrate.go +++ b/migrate/v0_15/migrate.go @@ -305,5 +305,6 @@ func loadStabilityComMembers() ([]sdk.AccAddress, error) { // Swap introduces new v0.15 swap genesis state func Swap() v0_15swap.GenesisState { - return v0_15swap.NewGenesisState(v0_15swap.DefaultParams()) + // TODO add swap genesis state + return v0_15swap.DefaultGenesisState() } diff --git a/x/swap/alias.go b/x/swap/alias.go index a0fce94f..76b95c84 100644 --- a/x/swap/alias.go +++ b/x/swap/alias.go @@ -25,7 +25,10 @@ const ( ModuleAccountName = types.ModuleAccountName ModuleName = types.ModuleName QuerierRoute = types.QuerierRoute + QueryGetDeposits = types.QueryGetDeposits QueryGetParams = types.QueryGetParams + QueryGetPool = types.QueryGetPool + QueryGetPools = types.QueryGetPools RouterKey = types.RouterKey StoreKey = types.StoreKey ) @@ -43,6 +46,7 @@ var ( NewBasePoolWithExistingShares = types.NewBasePoolWithExistingShares NewDenominatedPool = types.NewDenominatedPool NewDenominatedPoolWithExistingShares = types.NewDenominatedPoolWithExistingShares + NewDepositsQueryResult = types.NewDepositsQueryResult NewGenesisState = types.NewGenesisState NewMsgDeposit = types.NewMsgDeposit NewMsgSwapExactForTokens = types.NewMsgSwapExactForTokens @@ -50,6 +54,10 @@ var ( NewMsgWithdraw = types.NewMsgWithdraw NewParams = types.NewParams NewPoolRecord = types.NewPoolRecord + NewPoolRecordFromPool = types.NewPoolRecordFromPool + NewPoolStatsQueryResult = types.NewPoolStatsQueryResult + NewQueryDepositsParams = types.NewQueryDepositsParams + NewQueryPoolParams = types.NewQueryPoolParams NewShareRecord = types.NewShareRecord ParamKeyTable = types.ParamKeyTable PoolID = types.PoolID @@ -59,6 +67,8 @@ var ( // variable aliases DefaultAllowedPools = types.DefaultAllowedPools + DefaultPoolRecords = types.DefaultPoolRecords + DefaultShareRecords = types.DefaultShareRecords DefaultSwapFee = types.DefaultSwapFee DepositorPoolSharesPrefix = types.DepositorPoolSharesPrefix ErrDeadlineExceeded = types.ErrDeadlineExceeded @@ -86,6 +96,8 @@ type ( AllowedPools = types.AllowedPools BasePool = types.BasePool DenominatedPool = types.DenominatedPool + DepositsQueryResult = types.DepositsQueryResult + DepositsQueryResults = types.DepositsQueryResults GenesisState = types.GenesisState MsgDeposit = types.MsgDeposit MsgSwapExactForTokens = types.MsgSwapExactForTokens @@ -94,6 +106,13 @@ type ( MsgWithdraw = types.MsgWithdraw Params = types.Params PoolRecord = types.PoolRecord + PoolRecords = types.PoolRecords + PoolStatsQueryResult = types.PoolStatsQueryResult + PoolStatsQueryResults = types.PoolStatsQueryResults + QueryDepositsParams = types.QueryDepositsParams + QueryPoolParams = types.QueryPoolParams ShareRecord = types.ShareRecord + ShareRecords = types.ShareRecords SupplyKeeper = types.SupplyKeeper + SwapHooks = types.SwapHooks ) diff --git a/x/swap/genesis.go b/x/swap/genesis.go index 8ee02f32..2676d94f 100644 --- a/x/swap/genesis.go +++ b/x/swap/genesis.go @@ -3,9 +3,9 @@ package swap import ( "fmt" - sdk "github.com/cosmos/cosmos-sdk/types" - "github.com/kava-labs/kava/x/swap/types" + + sdk "github.com/cosmos/cosmos-sdk/types" ) // InitGenesis initializes story state from genesis file @@ -15,10 +15,18 @@ func InitGenesis(ctx sdk.Context, k Keeper, gs types.GenesisState) { } k.SetParams(ctx, gs.Params) + for _, pr := range gs.PoolRecords { + k.SetPool(ctx, pr) + } + for _, sh := range gs.ShareRecords { + k.SetDepositorShares(ctx, sh) + } } // ExportGenesis exports the genesis state func ExportGenesis(ctx sdk.Context, k Keeper) types.GenesisState { params := k.GetParams(ctx) - return types.NewGenesisState(params) + pools := k.GetAllPools(ctx) + shares := k.GetAllDepositorShares(ctx) + return types.NewGenesisState(params, pools, shares) } diff --git a/x/swap/genesis_test.go b/x/swap/genesis_test.go new file mode 100644 index 00000000..5b9ab4c6 --- /dev/null +++ b/x/swap/genesis_test.go @@ -0,0 +1,73 @@ +package swap_test + +import ( + "testing" + + "github.com/kava-labs/kava/x/swap" + "github.com/kava-labs/kava/x/swap/testutil" + "github.com/kava-labs/kava/x/swap/types" + "github.com/stretchr/testify/suite" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +type genesisTestSuite struct { + testutil.Suite +} + +func (suite *genesisTestSuite) Test_InitGenesis_ValidationPanic() { + invalidState := types.NewGenesisState( + types.Params{ + SwapFee: sdk.NewDec(-1), + }, + types.PoolRecords{}, + types.ShareRecords{}, + ) + + suite.Panics(func() { + swap.InitGenesis(suite.Ctx, suite.Keeper, invalidState) + }, "expected init genesis to panic with invalid state") +} + +func (suite *genesisTestSuite) Test_InitAndExportGenesis() { + depositor_1, err := sdk.AccAddressFromBech32("kava1mq9qxlhze029lm0frzw2xr6hem8c3k9ts54w0w") + suite.Require().NoError(err) + depositor_2, err := sdk.AccAddressFromBech32("kava1esagqd83rhqdtpy5sxhklaxgn58k2m3s3mnpea") + suite.Require().NoError(err) + + // slices are sorted by key as stored in the data store, so init and export can be compared with equal + state := types.NewGenesisState( + types.Params{ + AllowedPools: swap.AllowedPools{swap.NewAllowedPool("ukava", "usdx")}, + SwapFee: sdk.MustNewDecFromStr("0.00255"), + }, + types.PoolRecords{ + swap.NewPoolRecord(sdk.NewCoins(sdk.NewCoin("hard", sdk.NewInt(1e6)), sdk.NewCoin("usdx", sdk.NewInt(2e6))), sdk.NewInt(1e6)), + swap.NewPoolRecord(sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.NewCoin("usdx", sdk.NewInt(5e6))), sdk.NewInt(3e6)), + }, + types.ShareRecords{ + types.NewShareRecord(depositor_2, "hard/usdx", sdk.NewInt(1e6)), + types.NewShareRecord(depositor_1, "ukava/usdx", sdk.NewInt(3e6)), + }, + ) + + swap.InitGenesis(suite.Ctx, suite.Keeper, state) + suite.Equal(state.Params, suite.Keeper.GetParams(suite.Ctx)) + + poolRecord1, _ := suite.Keeper.GetPool(suite.Ctx, "hard/usdx") + suite.Equal(state.PoolRecords[0], poolRecord1) + poolRecord2, _ := suite.Keeper.GetPool(suite.Ctx, "ukava/usdx") + suite.Equal(state.PoolRecords[1], poolRecord2) + + shareRecord1, _ := suite.Keeper.GetDepositorShares(suite.Ctx, depositor_2, "hard/usdx") + suite.Equal(state.ShareRecords[0], shareRecord1) + shareRecord2, _ := suite.Keeper.GetDepositorShares(suite.Ctx, depositor_1, "ukava/usdx") + suite.Equal(state.ShareRecords[1], shareRecord2) + + exportedState := swap.ExportGenesis(suite.Ctx, suite.Keeper) + suite.Equal(state, exportedState) +} + +func TestGenesisTestSuite(t *testing.T) { + suite.Run(t, new(genesisTestSuite)) +} diff --git a/x/swap/keeper/deposit.go b/x/swap/keeper/deposit.go index 9417b915..6cca2ddb 100644 --- a/x/swap/keeper/deposit.go +++ b/x/swap/keeper/deposit.go @@ -113,7 +113,7 @@ func (k Keeper) initializePool(ctx sdk.Context, poolID string, depositor sdk.Acc return sdk.Coins{}, sdk.ZeroInt(), err } - poolRecord := types.NewPoolRecord(pool) + poolRecord := types.NewPoolRecordFromPool(pool) shareRecord := types.NewShareRecord(depositor, poolRecord.PoolID, pool.TotalShares()) k.SetPool(ctx, poolRecord) @@ -132,7 +132,7 @@ func (k Keeper) addLiquidityToPool(ctx sdk.Context, record types.PoolRecord, dep depositAmount, shares := pool.AddLiquidity(desiredAmount) - poolRecord := types.NewPoolRecord(pool) + poolRecord := types.NewPoolRecordFromPool(pool) shareRecord, sharesFound := k.GetDepositorShares(ctx, depositor, poolRecord.PoolID) if sharesFound { diff --git a/x/swap/keeper/keeper_test.go b/x/swap/keeper/keeper_test.go index 8e06d6fa..5b55a720 100644 --- a/x/swap/keeper/keeper_test.go +++ b/x/swap/keeper/keeper_test.go @@ -88,7 +88,7 @@ func (suite *keeperTestSuite) TestPool_Persistance() { pool, err := types.NewDenominatedPool(reserves) suite.Nil(err) - record := types.NewPoolRecord(pool) + record := types.NewPoolRecordFromPool(pool) suite.Keeper.SetPool(suite.Ctx, record) diff --git a/x/swap/keeper/querier_test.go b/x/swap/keeper/querier_test.go index ab0c4e35..08769d15 100644 --- a/x/swap/keeper/querier_test.go +++ b/x/swap/keeper/querier_test.go @@ -70,7 +70,7 @@ func (suite *querierTestSuite) TestQueryPool() { pool, err := types.NewDenominatedPool(sdk.NewCoins(coinA, coinB)) suite.Nil(err) - poolRecord := types.NewPoolRecord(pool) + poolRecord := types.NewPoolRecordFromPool(pool) suite.Keeper.SetPool(suite.Ctx, poolRecord) ctx := suite.Ctx.WithIsCheckTx(false) @@ -101,12 +101,12 @@ func (suite *querierTestSuite) TestQueryPools() { poolAB, err := types.NewDenominatedPool(sdk.NewCoins(coinA, coinB)) suite.Nil(err) - poolRecordAB := types.NewPoolRecord(poolAB) + poolRecordAB := types.NewPoolRecordFromPool(poolAB) suite.Keeper.SetPool(suite.Ctx, poolRecordAB) poolAC, err := types.NewDenominatedPool(sdk.NewCoins(coinA, coinC)) suite.Nil(err) - poolRecordAC := types.NewPoolRecord(poolAC) + poolRecordAC := types.NewPoolRecordFromPool(poolAC) suite.Keeper.SetPool(suite.Ctx, poolRecordAC) // Build a map of pools to compare to query results @@ -142,7 +142,7 @@ func (suite *querierTestSuite) TestQueryDeposit() { coinB := sdk.NewCoin("usdx", sdk.NewInt(200)) pool, err := types.NewDenominatedPool(sdk.NewCoins(coinA, coinB)) suite.Nil(err) - poolRecord := types.NewPoolRecord(pool) + poolRecord := types.NewPoolRecordFromPool(pool) suite.Keeper.SetPool(suite.Ctx, poolRecord) // Deposit into pool diff --git a/x/swap/keeper/swap.go b/x/swap/keeper/swap.go index bae0f3c1..c5d2c96c 100644 --- a/x/swap/keeper/swap.go +++ b/x/swap/keeper/swap.go @@ -96,7 +96,7 @@ func (k Keeper) commitSwap( feePaid sdk.Coin, exactDirection string, ) error { - k.SetPool(ctx, types.NewPoolRecord(pool)) + k.SetPool(ctx, types.NewPoolRecordFromPool(pool)) if err := k.supplyKeeper.SendCoinsFromAccountToModule(ctx, requester, types.ModuleAccountName, sdk.NewCoins(swapInput)); err != nil { return err diff --git a/x/swap/keeper/withdraw.go b/x/swap/keeper/withdraw.go index 299e57f9..b43b47c0 100644 --- a/x/swap/keeper/withdraw.go +++ b/x/swap/keeper/withdraw.go @@ -78,7 +78,7 @@ func (k Keeper) updatePool(ctx sdk.Context, poolID string, pool *types.Denominat if pool.TotalShares().IsZero() { k.DeletePool(ctx, poolID) } else { - k.SetPool(ctx, types.NewPoolRecord(pool)) + k.SetPool(ctx, types.NewPoolRecordFromPool(pool)) } } diff --git a/x/swap/types/genesis.go b/x/swap/types/genesis.go index 556b6406..df228121 100644 --- a/x/swap/types/genesis.go +++ b/x/swap/types/genesis.go @@ -1,16 +1,37 @@ package types -import "bytes" +import ( + "bytes" + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +type poolShares struct { + totalShares sdk.Int + totalSharesOwned sdk.Int +} + +var ( + // DefaultPoolRecords is used to set default records in default genesis state + DefaultPoolRecords = PoolRecords{} + // DefaultShareRecords is used to set default records in default genesis state + DefaultShareRecords = ShareRecords{} +) // GenesisState is the state that must be provided at genesis. type GenesisState struct { - Params Params `json:"params" yaml:"params"` + Params Params `json:"params" yaml:"params"` + PoolRecords `json:"pool_records" yaml:"pool_records"` + ShareRecords `json:"share_records" yaml:"share_records"` } // NewGenesisState creates a new genesis state. -func NewGenesisState(params Params) GenesisState { +func NewGenesisState(params Params, poolRecords PoolRecords, shareRecords ShareRecords) GenesisState { return GenesisState{ - Params: params, + Params: params, + PoolRecords: poolRecords, + ShareRecords: shareRecords, } } @@ -19,6 +40,38 @@ func (gs GenesisState) Validate() error { if err := gs.Params.Validate(); err != nil { return err } + if err := gs.PoolRecords.Validate(); err != nil { + return err + } + if err := gs.ShareRecords.Validate(); err != nil { + return err + } + + totalShares := make(map[string]poolShares) + for _, pr := range gs.PoolRecords { + totalShares[pr.PoolID] = poolShares{ + totalShares: pr.TotalShares, + totalSharesOwned: sdk.ZeroInt(), + } + } + for _, sr := range gs.ShareRecords { + if shares, found := totalShares[sr.PoolID]; found { + shares.totalSharesOwned = shares.totalSharesOwned.Add(sr.SharesOwned) + totalShares[sr.PoolID] = shares + } else { + totalShares[sr.PoolID] = poolShares{ + totalShares: sdk.ZeroInt(), + totalSharesOwned: sr.SharesOwned, + } + } + } + + for poolID, ps := range totalShares { + if !ps.totalShares.Equal(ps.totalSharesOwned) { + return fmt.Errorf("total depositor shares %s not equal to pool '%s' total shares %s", ps.totalSharesOwned.String(), poolID, ps.totalShares.String()) + } + } + return nil } @@ -26,6 +79,8 @@ func (gs GenesisState) Validate() error { func DefaultGenesisState() GenesisState { return NewGenesisState( DefaultParams(), + DefaultPoolRecords, + DefaultShareRecords, ) } diff --git a/x/swap/types/genesis_test.go b/x/swap/types/genesis_test.go index b41fe05d..934f6a10 100644 --- a/x/swap/types/genesis_test.go +++ b/x/swap/types/genesis_test.go @@ -1,6 +1,7 @@ package types_test import ( + "encoding/json" "testing" "github.com/kava-labs/kava/x/swap/types" @@ -8,6 +9,7 @@ import ( sdk "github.com/cosmos/cosmos-sdk/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" ) func TestGenesis_Default(t *testing.T) { @@ -133,8 +135,8 @@ func TestGenesis_Equal(t *testing.T) { sdk.MustNewDecFromStr("0.85"), } - genesisA := types.GenesisState{params} - genesisB := types.GenesisState{params} + genesisA := types.GenesisState{params, types.DefaultPoolRecords, types.DefaultShareRecords} + genesisB := types.GenesisState{params, types.DefaultPoolRecords, types.DefaultShareRecords} assert.True(t, genesisA.Equal(genesisB)) } @@ -147,17 +149,17 @@ func TestGenesis_NotEqual(t *testing.T) { // Base params genesisAParams := baseParams - genesisA := types.GenesisState{genesisAParams} + genesisA := types.GenesisState{genesisAParams, types.DefaultPoolRecords, types.DefaultShareRecords} // Different swap fee genesisBParams := baseParams genesisBParams.SwapFee = sdk.MustNewDecFromStr("0.84") - genesisB := types.GenesisState{genesisBParams} + genesisB := types.GenesisState{genesisBParams, types.DefaultPoolRecords, types.DefaultShareRecords} // Different pairs genesisCParams := baseParams genesisCParams.AllowedPools = types.NewAllowedPools(types.NewAllowedPool("ukava", "hard")) - genesisC := types.GenesisState{genesisCParams} + genesisC := types.GenesisState{genesisCParams, types.DefaultPoolRecords, types.DefaultShareRecords} // A and B have different swap fees assert.False(t, genesisA.Equal(genesisB)) @@ -166,3 +168,229 @@ func TestGenesis_NotEqual(t *testing.T) { // A and B and different swap fees and pair token B denoms assert.False(t, genesisA.Equal(genesisB)) } + +func TestGenesis_JSONEncoding(t *testing.T) { + raw := `{ + "params": { + "allowed_pools": [ + { + "token_a": "ukava", + "token_b": "usdx" + }, + { + "token_a": "hard", + "token_b": "busd" + } + ], + "swap_fee": "0.003000000000000000" + }, + "pool_records": [ + { + "pool_id": "ukava/usdx", + "reserves_a": { "denom": "ukava", "amount": "1000000" }, + "reserves_b": { "denom": "usdx", "amount": "5000000" }, + "total_shares": "3000000" + }, + { + "pool_id": "hard/usdx", + "reserves_a": { "denom": "ukava", "amount": "1000000" }, + "reserves_b": { "denom": "usdx", "amount": "2000000" }, + "total_shares": "2000000" + } + ], + "share_records": [ + { + "depositor": "kava1mq9qxlhze029lm0frzw2xr6hem8c3k9ts54w0w", + "pool_id": "ukava/usdx", + "shares_owned": "100000" + }, + { + "depositor": "kava1esagqd83rhqdtpy5sxhklaxgn58k2m3s3mnpea", + "pool_id": "hard/usdx", + "shares_owned": "200000" + } + ] + }` + + var state types.GenesisState + err := json.Unmarshal([]byte(raw), &state) + require.NoError(t, err) + + assert.Equal(t, 2, len(state.Params.AllowedPools)) + assert.Equal(t, sdk.MustNewDecFromStr("0.003"), state.Params.SwapFee) + assert.Equal(t, 2, len(state.PoolRecords)) + assert.Equal(t, 2, len(state.ShareRecords)) +} + +func TestGenesis_YAMLEncoding(t *testing.T) { + expected := `params: + allowed_pools: + - token_a: ukava + token_b: usdx + - token_a: hard + token_b: busd + swap_fee: "0.003000000000000000" +pool_records: +- pool_id: ukava/usdx + reserves_a: + denom: ukava + amount: "1000000" + reserves_b: + denom: usdx + amount: "5000000" + total_shares: "3000000" +- pool_id: hard/usdx + reserves_a: + denom: hard + amount: "1000000" + reserves_b: + denom: usdx + amount: "2000000" + total_shares: "1500000" +share_records: +- depositor: kava1mq9qxlhze029lm0frzw2xr6hem8c3k9ts54w0w + pool_id: ukava/usdx + shares_owned: "100000" +- depositor: kava1esagqd83rhqdtpy5sxhklaxgn58k2m3s3mnpea + pool_id: hard/usdx + shares_owned: "200000" +` + + depositor_1, err := sdk.AccAddressFromBech32("kava1mq9qxlhze029lm0frzw2xr6hem8c3k9ts54w0w") + require.NoError(t, err) + depositor_2, err := sdk.AccAddressFromBech32("kava1esagqd83rhqdtpy5sxhklaxgn58k2m3s3mnpea") + require.NoError(t, err) + + state := types.NewGenesisState( + types.NewParams( + types.NewAllowedPools( + types.NewAllowedPool("ukava", "usdx"), + types.NewAllowedPool("hard", "busd"), + ), + sdk.MustNewDecFromStr("0.003"), + ), + types.PoolRecords{ + types.NewPoolRecord(sdk.NewCoins(ukava(1e6), usdx(5e6)), i(3e6)), + types.NewPoolRecord(sdk.NewCoins(hard(1e6), usdx(2e6)), i(15e5)), + }, + types.ShareRecords{ + types.NewShareRecord(depositor_1, "ukava/usdx", i(1e5)), + types.NewShareRecord(depositor_2, "hard/usdx", i(2e5)), + }, + ) + + data, err := yaml.Marshal(state) + require.NoError(t, err) + + assert.Equal(t, expected, string(data)) +} + +func TestGenesis_ValidatePoolRecords(t *testing.T) { + invalidPoolRecord := types.NewPoolRecord(sdk.NewCoins(ukava(1e6), usdx(5e6)), i(-1)) + + state := types.NewGenesisState( + types.DefaultParams(), + types.PoolRecords{invalidPoolRecord}, + types.ShareRecords{}, + ) + + assert.Error(t, state.Validate()) +} + +func TestGenesis_ValidateShareRecords(t *testing.T) { + depositor, err := sdk.AccAddressFromBech32("kava1mq9qxlhze029lm0frzw2xr6hem8c3k9ts54w0w") + require.NoError(t, err) + + invalidShareRecord := types.NewShareRecord(depositor, "", i(-1)) + + state := types.NewGenesisState( + types.DefaultParams(), + types.PoolRecords{}, + types.ShareRecords{invalidShareRecord}, + ) + + assert.Error(t, state.Validate()) +} + +func TestGenesis_Validate_PoolShareIntegration(t *testing.T) { + depositor_1, err := sdk.AccAddressFromBech32("kava1mq9qxlhze029lm0frzw2xr6hem8c3k9ts54w0w") + require.NoError(t, err) + depositor_2, err := sdk.AccAddressFromBech32("kava1esagqd83rhqdtpy5sxhklaxgn58k2m3s3mnpea") + require.NoError(t, err) + + testCases := []struct { + name string + poolRecords types.PoolRecords + shareRecords types.ShareRecords + expectedErr string + }{ + { + name: "single pool record, zero share records", + poolRecords: types.PoolRecords{ + types.NewPoolRecord(sdk.NewCoins(ukava(1e6), usdx(5e6)), i(3e6)), + }, + shareRecords: types.ShareRecords{}, + expectedErr: "total depositor shares 0 not equal to pool 'ukava/usdx' total shares 3000000", + }, + { + name: "zero pool records, one share record", + poolRecords: types.PoolRecords{}, + shareRecords: types.ShareRecords{ + types.NewShareRecord(depositor_1, "ukava/usdx", i(5e6)), + }, + expectedErr: "total depositor shares 5000000 not equal to pool 'ukava/usdx' total shares 0", + }, + { + name: "one pool record, one share record", + poolRecords: types.PoolRecords{ + types.NewPoolRecord(sdk.NewCoins(ukava(1e6), usdx(5e6)), i(3e6)), + }, + shareRecords: types.ShareRecords{ + types.NewShareRecord(depositor_1, "ukava/usdx", i(15e5)), + }, + expectedErr: "total depositor shares 1500000 not equal to pool 'ukava/usdx' total shares 3000000", + }, + { + name: "more than one pool records, more than one share record", + poolRecords: types.PoolRecords{ + types.NewPoolRecord(sdk.NewCoins(ukava(1e6), usdx(5e6)), i(3e6)), + types.NewPoolRecord(sdk.NewCoins(hard(1e6), usdx(2e6)), i(2e6)), + }, + shareRecords: types.ShareRecords{ + types.NewShareRecord(depositor_1, "ukava/usdx", i(15e5)), + types.NewShareRecord(depositor_2, "ukava/usdx", i(15e5)), + types.NewShareRecord(depositor_1, "hard/usdx", i(1e6)), + }, + expectedErr: "total depositor shares 1000000 not equal to pool 'hard/usdx' total shares 2000000", + }, + { + name: "valid case with many pool records and share records", + poolRecords: types.PoolRecords{ + types.NewPoolRecord(sdk.NewCoins(ukava(1e6), usdx(5e6)), i(3e6)), + types.NewPoolRecord(sdk.NewCoins(hard(1e6), usdx(2e6)), i(2e6)), + types.NewPoolRecord(sdk.NewCoins(hard(7e6), ukava(10e6)), i(8e6)), + }, + shareRecords: types.ShareRecords{ + types.NewShareRecord(depositor_1, "ukava/usdx", i(15e5)), + types.NewShareRecord(depositor_2, "ukava/usdx", i(15e5)), + types.NewShareRecord(depositor_1, "hard/usdx", i(2e6)), + types.NewShareRecord(depositor_1, "hard/ukava", i(3e6)), + types.NewShareRecord(depositor_2, "hard/ukava", i(5e6)), + }, + expectedErr: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + state := types.NewGenesisState(types.DefaultParams(), tc.poolRecords, tc.shareRecords) + err := state.Validate() + + if tc.expectedErr == "" { + assert.NoError(t, err) + } else { + assert.EqualError(t, err, tc.expectedErr) + } + }) + } +} diff --git a/x/swap/types/state.go b/x/swap/types/state.go index 04ca4e24..1836d374 100644 --- a/x/swap/types/state.go +++ b/x/swap/types/state.go @@ -1,7 +1,9 @@ package types import ( + "errors" "fmt" + "strings" sdk "github.com/cosmos/cosmos-sdk/types" ) @@ -25,20 +27,32 @@ func PoolID(denomA string, denomB string) string { // and is used to store the state of a denominated pool type PoolRecord struct { // primary key - PoolID string + PoolID string `json:"pool_id" yaml:"pool_id"` ReservesA sdk.Coin `json:"reserves_a" yaml:"reserves_a"` ReservesB sdk.Coin `json:"reserves_b" yaml:"reserves_b"` TotalShares sdk.Int `json:"total_shares" yaml:"total_shares"` } -// Reserves returns the total reserves for a pool -func (p PoolRecord) Reserves() sdk.Coins { - return sdk.NewCoins(p.ReservesA, p.ReservesB) +// NewPoolRecord takes reserve coins and total shares, returning +// a new pool record with a id +func NewPoolRecord(reserves sdk.Coins, totalShares sdk.Int) PoolRecord { + if len(reserves) != 2 { + panic("reserves must have two denominations") + } + + poolID := PoolIDFromCoins(reserves) + + return PoolRecord{ + PoolID: poolID, + ReservesA: reserves[0], + ReservesB: reserves[1], + TotalShares: totalShares, + } } -// NewPoolRecord takes a pointer to a denominated pool and returns a +// NewPoolRecordFromPool takes a pointer to a denominated pool and returns a // pool record for storage in state. -func NewPoolRecord(pool *DenominatedPool) PoolRecord { +func NewPoolRecordFromPool(pool *DenominatedPool) PoolRecord { reserves := pool.Reserves() poolID := PoolIDFromCoins(reserves) @@ -50,9 +64,65 @@ func NewPoolRecord(pool *DenominatedPool) PoolRecord { } } +// Validate performs basic validation checks of the record data +func (p PoolRecord) Validate() error { + if p.PoolID == "" { + return errors.New("poolID must be set") + } + + tokens := strings.Split(p.PoolID, "/") + if len(tokens) != 2 || tokens[0] == "" || tokens[1] == "" || tokens[1] < tokens[0] || tokens[0] == tokens[1] { + return fmt.Errorf("poolID '%s' is invalid", p.PoolID) + } + if sdk.ValidateDenom(tokens[0]) != nil || sdk.ValidateDenom(tokens[1]) != nil { + return fmt.Errorf("poolID '%s' is invalid", p.PoolID) + } + if tokens[0] != p.ReservesA.Denom || tokens[1] != p.ReservesB.Denom { + return fmt.Errorf("poolID '%s' does not match reserves", p.PoolID) + } + + if !p.ReservesA.IsPositive() { + return fmt.Errorf("pool '%s' has invalid reserves: %s", p.PoolID, p.ReservesA) + } + + if !p.ReservesB.IsPositive() { + return fmt.Errorf("pool '%s' has invalid reserves: %s", p.PoolID, p.ReservesB) + } + + if !p.TotalShares.IsPositive() { + return fmt.Errorf("pool '%s' has invalid total shares: %s", p.PoolID, p.TotalShares) + } + + return nil +} + +// Reserves returns the total reserves for a pool +func (p PoolRecord) Reserves() sdk.Coins { + return sdk.NewCoins(p.ReservesA, p.ReservesB) +} + // PoolRecords is a slice of PoolRecord type PoolRecords []PoolRecord +// Validate performs basic validation checks on all records in the slice +func (prs PoolRecords) Validate() error { + seenPoolIDs := make(map[string]bool) + + for _, p := range prs { + if err := p.Validate(); err != nil { + return err + } + + if seenPoolIDs[p.PoolID] { + return fmt.Errorf("duplicate poolID '%s'", p.PoolID) + } + + seenPoolIDs[p.PoolID] = true + } + + return nil +} + // ShareRecord stores the shares owned for a depositor and pool type ShareRecord struct { // primary key @@ -72,5 +142,54 @@ func NewShareRecord(depositor sdk.AccAddress, poolID string, sharesOwned sdk.Int } } +// Validate performs basic validation checks of the record data +func (sr ShareRecord) Validate() error { + if sr.PoolID == "" { + return errors.New("poolID must be set") + } + + tokens := strings.Split(sr.PoolID, "/") + if len(tokens) != 2 || tokens[0] == "" || tokens[1] == "" || tokens[1] < tokens[0] || tokens[0] == tokens[1] { + return fmt.Errorf("poolID '%s' is invalid", sr.PoolID) + } + if sdk.ValidateDenom(tokens[0]) != nil || sdk.ValidateDenom(tokens[1]) != nil { + return fmt.Errorf("poolID '%s' is invalid", sr.PoolID) + } + + if sr.Depositor.Empty() { + return fmt.Errorf("share record cannot have empty depositor address") + } + + if !sr.SharesOwned.IsPositive() { + return fmt.Errorf("depositor '%s' and pool '%s' has invalid total shares: %s", sr.Depositor.String(), sr.PoolID, sr.SharesOwned.String()) + } + + return nil +} + // ShareRecords is a slice of ShareRecord type ShareRecords []ShareRecord + +// Validate performs basic validation checks on all records in the slice +func (srs ShareRecords) Validate() error { + seenDepositors := make(map[string]map[string]bool) + + for _, sr := range srs { + if err := sr.Validate(); err != nil { + return err + } + + if seenPools, found := seenDepositors[sr.Depositor.String()]; found { + if seenPools[sr.PoolID] { + return fmt.Errorf("duplicate depositor '%s' and poolID '%s'", sr.Depositor.String(), sr.PoolID) + } + seenPools[sr.PoolID] = true + } else { + seenPools := make(map[string]bool) + seenPools[sr.PoolID] = true + seenDepositors[sr.Depositor.String()] = seenPools + } + } + + return nil +} diff --git a/x/swap/types/state_test.go b/x/swap/types/state_test.go index 20dfbf03..79294040 100644 --- a/x/swap/types/state_test.go +++ b/x/swap/types/state_test.go @@ -1,6 +1,7 @@ package types_test import ( + "encoding/json" "testing" types "github.com/kava-labs/kava/x/swap/types" @@ -8,6 +9,7 @@ import ( sdk "github.com/cosmos/cosmos-sdk/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" ) func TestState_PoolID(t *testing.T) { @@ -37,17 +39,308 @@ func TestState_PoolID(t *testing.T) { func TestState_NewPoolRecord(t *testing.T) { reserves := sdk.NewCoins(usdx(50e6), ukava(10e6)) + totalShares := sdk.NewInt(30e6) + + poolRecord := types.NewPoolRecord(reserves, totalShares) + + assert.Equal(t, reserves[0], poolRecord.ReservesA) + assert.Equal(t, reserves[1], poolRecord.ReservesB) + assert.Equal(t, reserves, poolRecord.Reserves()) + assert.Equal(t, totalShares, poolRecord.TotalShares) + + assert.PanicsWithValue(t, "reserves must have two denominations", func() { + reserves := sdk.NewCoins(ukava(10e6)) + _ = types.NewPoolRecord(reserves, totalShares) + }, "expected panic with 1 coin in reserves") + + assert.PanicsWithValue(t, "reserves must have two denominations", func() { + reserves := sdk.NewCoins(ukava(10e6), hard(1e6), usdx(20e6)) + _ = types.NewPoolRecord(reserves, totalShares) + }, "expected panic with 3 coins in reserves") +} + +func TestState_NewPoolRecordFromPool(t *testing.T) { + reserves := sdk.NewCoins(usdx(50e6), ukava(10e6)) pool, err := types.NewDenominatedPool(reserves) require.NoError(t, err) - record := types.NewPoolRecord(pool) + record := types.NewPoolRecordFromPool(pool) assert.Equal(t, types.PoolID("ukava", "usdx"), record.PoolID) assert.Equal(t, ukava(10e6), record.ReservesA) assert.Equal(t, record.ReservesB, usdx(50e6)) assert.Equal(t, pool.TotalShares(), record.TotalShares) assert.Equal(t, sdk.NewCoins(ukava(10e6), usdx(50e6)), record.Reserves()) + assert.Nil(t, record.Validate()) +} + +func TestState_PoolRecord_JSONEncoding(t *testing.T) { + raw := `{ + "pool_id": "ukava/usdx", + "reserves_a": { "denom": "ukava", "amount": "1000000" }, + "reserves_b": { "denom": "usdx", "amount": "5000000" }, + "total_shares": "3000000" + }` + + var record types.PoolRecord + err := json.Unmarshal([]byte(raw), &record) + require.NoError(t, err) + + assert.Equal(t, "ukava/usdx", record.PoolID) + assert.Equal(t, ukava(1e6), record.ReservesA) + assert.Equal(t, usdx(5e6), record.ReservesB) + assert.Equal(t, i(3e6), record.TotalShares) +} + +func TestState_PoolRecord_YamlEncoding(t *testing.T) { + expected := `pool_id: ukava/usdx +reserves_a: + denom: ukava + amount: "1000000" +reserves_b: + denom: usdx + amount: "5000000" +total_shares: "3000000" +` + record := types.NewPoolRecord(sdk.NewCoins(ukava(1e6), usdx(5e6)), i(3e6)) + data, err := yaml.Marshal(record) + require.NoError(t, err) + + assert.Equal(t, expected, string(data)) +} + +func TestState_PoolRecord_Validations(t *testing.T) { + validRecord := types.NewPoolRecord( + sdk.NewCoins(usdx(500e6), ukava(100e6)), + i(300e6), + ) + testCases := []struct { + name string + poolID string + reservesA sdk.Coin + reservesB sdk.Coin + totalShares sdk.Int + expectedErr string + }{ + { + name: "empty pool id", + poolID: "", + reservesA: validRecord.ReservesA, + reservesB: validRecord.ReservesB, + totalShares: validRecord.TotalShares, + expectedErr: "poolID must be set", + }, + { + name: "no poolID tokens", + poolID: "ukavausdx", + reservesA: validRecord.ReservesA, + reservesB: validRecord.ReservesB, + totalShares: validRecord.TotalShares, + expectedErr: "poolID 'ukavausdx' is invalid", + }, + { + name: "poolID empty tokens", + poolID: "/", + reservesA: validRecord.ReservesA, + reservesB: validRecord.ReservesB, + totalShares: validRecord.TotalShares, + expectedErr: "poolID '/' is invalid", + }, + { + name: "poolID empty token a", + poolID: "/usdx", + reservesA: validRecord.ReservesA, + reservesB: validRecord.ReservesB, + totalShares: validRecord.TotalShares, + expectedErr: "poolID '/usdx' is invalid", + }, + { + name: "poolID empty token b", + poolID: "ukava/", + reservesA: validRecord.ReservesA, + reservesB: validRecord.ReservesB, + totalShares: validRecord.TotalShares, + expectedErr: "poolID 'ukava/' is invalid", + }, + { + name: "poolID is not sorted", + poolID: "usdx/ukava", + reservesA: validRecord.ReservesA, + reservesB: validRecord.ReservesB, + totalShares: validRecord.TotalShares, + expectedErr: "poolID 'usdx/ukava' is invalid", + }, + { + name: "poolID has invalid denom a", + poolID: "UKAVA/usdx", + reservesA: validRecord.ReservesA, + reservesB: validRecord.ReservesB, + totalShares: validRecord.TotalShares, + expectedErr: "poolID 'UKAVA/usdx' is invalid", + }, + { + name: "poolID has invalid denom b", + poolID: "ukava/USDX", + reservesA: validRecord.ReservesA, + reservesB: validRecord.ReservesB, + totalShares: validRecord.TotalShares, + expectedErr: "poolID 'ukava/USDX' is invalid", + }, + { + name: "poolID has duplicate denoms", + poolID: "ukava/ukava", + reservesA: validRecord.ReservesA, + reservesB: validRecord.ReservesB, + totalShares: validRecord.TotalShares, + expectedErr: "poolID 'ukava/ukava' is invalid", + }, + { + name: "poolID does not match reserve A", + poolID: "ukava/usdx", + reservesA: hard(5e6), + reservesB: validRecord.ReservesB, + totalShares: validRecord.TotalShares, + expectedErr: "poolID 'ukava/usdx' does not match reserves", + }, + { + name: "poolID does not match reserve B", + poolID: "ukava/usdx", + reservesA: validRecord.ReservesA, + reservesB: hard(5e6), + totalShares: validRecord.TotalShares, + expectedErr: "poolID 'ukava/usdx' does not match reserves", + }, + { + name: "negative reserve a", + poolID: "ukava/usdx", + reservesA: sdk.Coin{Denom: "ukava", Amount: sdk.NewInt(-1)}, + reservesB: validRecord.ReservesB, + totalShares: validRecord.TotalShares, + expectedErr: "pool 'ukava/usdx' has invalid reserves: -1ukava", + }, + { + name: "zero reserve a", + poolID: "ukava/usdx", + reservesA: sdk.Coin{Denom: "ukava", Amount: sdk.ZeroInt()}, + reservesB: validRecord.ReservesB, + totalShares: validRecord.TotalShares, + expectedErr: "pool 'ukava/usdx' has invalid reserves: 0ukava", + }, + { + name: "negative reserve b", + poolID: "ukava/usdx", + reservesA: validRecord.ReservesA, + reservesB: sdk.Coin{Denom: "usdx", Amount: sdk.NewInt(-1)}, + totalShares: validRecord.TotalShares, + expectedErr: "pool 'ukava/usdx' has invalid reserves: -1usdx", + }, + { + name: "zero reserve b", + poolID: "ukava/usdx", + reservesA: validRecord.ReservesA, + reservesB: sdk.Coin{Denom: "usdx", Amount: sdk.ZeroInt()}, + totalShares: validRecord.TotalShares, + expectedErr: "pool 'ukava/usdx' has invalid reserves: 0usdx", + }, + { + name: "negative total shares", + poolID: validRecord.PoolID, + reservesA: validRecord.ReservesA, + reservesB: validRecord.ReservesB, + totalShares: sdk.NewInt(-1), + expectedErr: "pool 'ukava/usdx' has invalid total shares: -1", + }, + { + name: "zero total shares", + poolID: validRecord.PoolID, + reservesA: validRecord.ReservesA, + reservesB: validRecord.ReservesB, + totalShares: sdk.ZeroInt(), + expectedErr: "pool 'ukava/usdx' has invalid total shares: 0", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + record := types.PoolRecord{ + PoolID: tc.poolID, + ReservesA: tc.reservesA, + ReservesB: tc.reservesB, + TotalShares: tc.totalShares, + } + err := record.Validate() + assert.EqualError(t, err, tc.expectedErr) + }) + } +} + +func TestState_PoolRecord_OrderedReserves(t *testing.T) { + invalidOrder := types.NewPoolRecord( + // force order to not be sorted + sdk.Coins{usdx(500e6), ukava(100e6)}, + i(300e6), + ) + assert.Error(t, invalidOrder.Validate()) + + validOrder := types.NewPoolRecord( + // force order to not be sorted + sdk.Coins{ukava(500e6), usdx(100e6)}, + i(300e6), + ) + assert.NoError(t, validOrder.Validate()) + + record_1 := types.NewPoolRecord(sdk.NewCoins(usdx(500e6), ukava(100e6)), i(300e6)) + record_2 := types.NewPoolRecord(sdk.NewCoins(ukava(100e6), usdx(500e6)), i(300e6)) + // ensure no regresssions in NewCoins ordering + assert.Equal(t, record_1, record_2) + assert.Equal(t, "ukava/usdx", record_1.PoolID) + assert.Equal(t, "ukava/usdx", record_2.PoolID) +} + +func TestState_PoolRecords_Validation(t *testing.T) { + validRecord := types.NewPoolRecord( + sdk.NewCoins(usdx(500e6), ukava(100e6)), + i(300e6), + ) + + invalidRecord := types.NewPoolRecord( + sdk.NewCoins(usdx(500e6), ukava(100e6)), + i(-1), + ) + + records := types.PoolRecords{ + validRecord, + } + assert.NoError(t, records.Validate()) + + records = append(records, invalidRecord) + err := records.Validate() + assert.Error(t, err) + assert.EqualError(t, err, "pool 'ukava/usdx' has invalid total shares: -1") +} + +func TestState_PoolRecords_ValidateUniquePools(t *testing.T) { + record_1 := types.NewPoolRecord( + sdk.NewCoins(usdx(500e6), ukava(100e6)), + i(300e6), + ) + + record_2 := types.NewPoolRecord( + sdk.NewCoins(usdx(5000e6), ukava(1000e6)), + i(3000e6), + ) + + record_3 := types.NewPoolRecord( + sdk.NewCoins(usdx(5000e6), hard(1000e6)), + i(3000e6), + ) + + validRecords := types.PoolRecords{record_1, record_3} + assert.NoError(t, validRecords.Validate()) + + invalidRecords := types.PoolRecords{record_1, record_2} + assert.EqualError(t, invalidRecords.Validate(), "duplicate poolID 'ukava/usdx'") } func TestState_NewShareRecord(t *testing.T) { @@ -61,3 +354,205 @@ func TestState_NewShareRecord(t *testing.T) { assert.Equal(t, poolID, record.PoolID) assert.Equal(t, shares, record.SharesOwned) } + +func TestState_ShareRecord_JSONEncoding(t *testing.T) { + raw := `{ + "depositor": "kava1mq9qxlhze029lm0frzw2xr6hem8c3k9ts54w0w", + "pool_id": "ukava/usdx", + "shares_owned": "3000000" + }` + + var record types.ShareRecord + err := json.Unmarshal([]byte(raw), &record) + require.NoError(t, err) + + assert.Equal(t, "kava1mq9qxlhze029lm0frzw2xr6hem8c3k9ts54w0w", record.Depositor.String()) + assert.Equal(t, "ukava/usdx", record.PoolID) + assert.Equal(t, i(3e6), record.SharesOwned) +} + +func TestState_ShareRecord_YamlEncoding(t *testing.T) { + expected := `depositor: kava1mq9qxlhze029lm0frzw2xr6hem8c3k9ts54w0w +pool_id: ukava/usdx +shares_owned: "3000000" +` + depositor, err := sdk.AccAddressFromBech32("kava1mq9qxlhze029lm0frzw2xr6hem8c3k9ts54w0w") + require.NoError(t, err) + + record := types.NewShareRecord(depositor, "ukava/usdx", i(3e6)) + data, err := yaml.Marshal(record) + require.NoError(t, err) + + assert.Equal(t, expected, string(data)) +} + +func TestState_InvalidShareRecordEmptyDepositor(t *testing.T) { + record := types.ShareRecord{ + Depositor: sdk.AccAddress{}, + PoolID: types.PoolID("ukava", "usdx"), + SharesOwned: sdk.NewInt(1e6), + } + require.Error(t, record.Validate()) +} + +func TestState_InvalidShareRecordNegativeShares(t *testing.T) { + record := types.ShareRecord{ + Depositor: sdk.AccAddress("some user"), + PoolID: types.PoolID("ukava", "usdx"), + SharesOwned: sdk.NewInt(-1e6), + } + require.Error(t, record.Validate()) +} + +func TestState_ShareRecord_Validations(t *testing.T) { + depositor, err := sdk.AccAddressFromBech32("kava1mq9qxlhze029lm0frzw2xr6hem8c3k9ts54w0w") + require.NoError(t, err) + validRecord := types.NewShareRecord( + depositor, + types.PoolID("ukava", "usdx"), + i(30e6), + ) + testCases := []struct { + name string + depositor sdk.AccAddress + poolID string + sharesOwned sdk.Int + expectedErr string + }{ + { + name: "empty pool id", + depositor: validRecord.Depositor, + poolID: "", + sharesOwned: validRecord.SharesOwned, + expectedErr: "poolID must be set", + }, + { + name: "no poolID tokens", + depositor: validRecord.Depositor, + poolID: "ukavausdx", + sharesOwned: validRecord.SharesOwned, + expectedErr: "poolID 'ukavausdx' is invalid", + }, + { + name: "poolID empty tokens", + depositor: validRecord.Depositor, + poolID: "/", + sharesOwned: validRecord.SharesOwned, + expectedErr: "poolID '/' is invalid", + }, + { + name: "poolID empty token a", + depositor: validRecord.Depositor, + poolID: "/usdx", + sharesOwned: validRecord.SharesOwned, + expectedErr: "poolID '/usdx' is invalid", + }, + { + name: "poolID empty token b", + depositor: validRecord.Depositor, + poolID: "ukava/", + sharesOwned: validRecord.SharesOwned, + expectedErr: "poolID 'ukava/' is invalid", + }, + { + name: "poolID is not sorted", + depositor: validRecord.Depositor, + poolID: "usdx/ukava", + sharesOwned: validRecord.SharesOwned, + expectedErr: "poolID 'usdx/ukava' is invalid", + }, + { + name: "poolID has invalid denom a", + depositor: validRecord.Depositor, + poolID: "UKAVA/usdx", + sharesOwned: validRecord.SharesOwned, + expectedErr: "poolID 'UKAVA/usdx' is invalid", + }, + { + name: "poolID has invalid denom b", + depositor: validRecord.Depositor, + poolID: "ukava/USDX", + sharesOwned: validRecord.SharesOwned, + expectedErr: "poolID 'ukava/USDX' is invalid", + }, + { + name: "poolID has duplicate denoms", + depositor: validRecord.Depositor, + poolID: "ukava/ukava", + sharesOwned: validRecord.SharesOwned, + expectedErr: "poolID 'ukava/ukava' is invalid", + }, + { + name: "negative total shares", + depositor: validRecord.Depositor, + poolID: validRecord.PoolID, + sharesOwned: sdk.NewInt(-1), + expectedErr: "depositor 'kava1mq9qxlhze029lm0frzw2xr6hem8c3k9ts54w0w' and pool 'ukava/usdx' has invalid total shares: -1", + }, + { + name: "zero total shares", + depositor: validRecord.Depositor, + poolID: validRecord.PoolID, + sharesOwned: sdk.ZeroInt(), + expectedErr: "depositor 'kava1mq9qxlhze029lm0frzw2xr6hem8c3k9ts54w0w' and pool 'ukava/usdx' has invalid total shares: 0", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + record := types.ShareRecord{ + Depositor: tc.depositor, + PoolID: tc.poolID, + SharesOwned: tc.sharesOwned, + } + err := record.Validate() + assert.EqualError(t, err, tc.expectedErr) + }) + } +} + +func TestState_ShareRecords_Validation(t *testing.T) { + depositor, err := sdk.AccAddressFromBech32("kava1mq9qxlhze029lm0frzw2xr6hem8c3k9ts54w0w") + require.NoError(t, err) + + validRecord := types.NewShareRecord( + depositor, + "ukava/usdx", + i(300e6), + ) + + invalidRecord := types.NewShareRecord( + depositor, + "hard/usdx", + i(-1), + ) + + records := types.ShareRecords{ + validRecord, + } + assert.NoError(t, records.Validate()) + + records = append(records, invalidRecord) + err = records.Validate() + assert.Error(t, err) + assert.EqualError(t, err, "depositor 'kava1mq9qxlhze029lm0frzw2xr6hem8c3k9ts54w0w' and pool 'hard/usdx' has invalid total shares: -1") +} + +func TestState_ShareRecords_ValidateUniqueShareRecords(t *testing.T) { + depositor_1, err := sdk.AccAddressFromBech32("kava1mq9qxlhze029lm0frzw2xr6hem8c3k9ts54w0w") + require.NoError(t, err) + + depositor_2, err := sdk.AccAddressFromBech32("kava1esagqd83rhqdtpy5sxhklaxgn58k2m3s3mnpea") + require.NoError(t, err) + + record_1 := types.NewShareRecord(depositor_1, "ukava/usdx", i(20e6)) + record_2 := types.NewShareRecord(depositor_1, "ukava/usdx", i(10e6)) + record_3 := types.NewShareRecord(depositor_1, "hard/usdx", i(20e6)) + record_4 := types.NewShareRecord(depositor_2, "ukava/usdx", i(20e6)) + + validRecords := types.ShareRecords{record_1, record_3, record_4} + assert.NoError(t, validRecords.Validate()) + + invalidRecords := types.ShareRecords{record_1, record_3, record_2, record_4} + assert.EqualError(t, invalidRecords.Validate(), "duplicate depositor 'kava1mq9qxlhze029lm0frzw2xr6hem8c3k9ts54w0w' and poolID 'ukava/usdx'") +}