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 <nickdeluca08@gmail.com>
This commit is contained in:
Kevin Davis 2021-07-15 09:42:30 -05:00 committed by GitHub
parent 013093ecb5
commit d45fa58f5c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1027 additions and 29 deletions

View File

@ -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()
}

View File

@ -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
)

View File

@ -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)
}

73
x/swap/genesis_test.go Normal file
View File

@ -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))
}

View File

@ -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 {

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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))
}
}

View File

@ -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"`
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,
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,
)
}

View File

@ -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)
}
})
}
}

View File

@ -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
}

View File

@ -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'")
}