diff --git a/app/app.go b/app/app.go index f68cc640..9a12aa7b 100644 --- a/app/app.go +++ b/app/app.go @@ -90,6 +90,8 @@ var ( ) // module account permissions + // if these are changed, then the permissions + // must also be migrated during a chain upgrade mAccPerms = map[string][]string{ auth.FeeCollectorName: nil, distr.ModuleName: nil, @@ -105,6 +107,7 @@ var ( kavadist.ModuleName: {supply.Minter}, issuance.ModuleAccountName: {supply.Minter, supply.Burner}, hard.ModuleAccountName: {supply.Minter}, + swap.ModuleAccountName: nil, } // module accounts that are allowed to receive tokens @@ -391,6 +394,8 @@ func NewApp(logger log.Logger, db dbm.DB, traceStore io.Writer, appOpts AppOptio app.cdc, keys[swap.StoreKey], swapSubspace, + app.accountKeeper, + app.supplyKeeper, ) app.incentiveKeeper = incentive.NewKeeper( app.cdc, @@ -492,6 +497,7 @@ func NewApp(logger log.Logger, db dbm.DB, traceStore io.Writer, appOpts AppOptio committee.NewAppModule(app.committeeKeeper, app.accountKeeper), issuance.NewAppModule(app.issuanceKeeper, app.accountKeeper, app.supplyKeeper), hard.NewAppModule(app.hardKeeper, app.supplyKeeper, app.pricefeedKeeper), + swap.NewAppModule(app.swapKeeper), ) app.sm.RegisterStoreDecoders() diff --git a/app/sim_test.go b/app/sim_test.go index 662e4758..6e8a1155 100644 --- a/app/sim_test.go +++ b/app/sim_test.go @@ -39,6 +39,7 @@ import ( "github.com/kava-labs/kava/x/incentive" "github.com/kava-labs/kava/x/kavadist" "github.com/kava-labs/kava/x/pricefeed" + "github.com/kava-labs/kava/x/swap" validatorvesting "github.com/kava-labs/kava/x/validator-vesting" ) @@ -185,6 +186,7 @@ func TestAppImportExport(t *testing.T) { {app.keys[pricefeed.StoreKey], newApp.keys[pricefeed.StoreKey], [][]byte{}}, {app.keys[validatorvesting.StoreKey], newApp.keys[validatorvesting.StoreKey], [][]byte{}}, {app.keys[committee.StoreKey], newApp.keys[committee.StoreKey], [][]byte{}}, + {app.keys[swap.StoreKey], newApp.keys[swap.StoreKey], [][]byte{}}, } for _, skp := range storeKeysPrefixes { diff --git a/docs/communitytools.md b/docs/communitytools.md index f0822b84..8e56a06f 100644 --- a/docs/communitytools.md +++ b/docs/communitytools.md @@ -2,28 +2,34 @@ parent: order: false ---> + # Community Tools The Kava community has developed some amazing tools and services. If you've built something great on, for, or in support of the Kava ecosystem let us know and we'll add it to this list. ## User Interfaces + - [Kava Web App](https://app.kava.io/) - Cosmostation Mobile App: [App Store](https://apps.apple.com/us/app/cosmostation/id1459830339), [Play Store](https://play.google.com/store/apps/details?id=wannabit.io.cosmostaion&hl=en) - Frontier Mobile App: [App Store](https://apps.apple.com/us/app/frontier-defi-wallet/id1482380988), [Play Store](https://play.google.com/store/apps/details?id=com.frontierwallet&hl=en) ## Explorers + - [Mintscan](https://kava.mintscan.io/) by Cosmostation - [Big Dipper](https://kava.bigdipper.live/) by Forbole - [PING.pub](https://kava.ping.pub/#/parameter) - [KAVAScan](https://kavascan.com) ## For Validators + - [QuickSync](https://kava.quicksync.io/) chain snapshots ## Staking + - [Staking rewards calculator](https://www.stakingrewards.com/earn/kava/calculate) ### Staking Providers + Listed in alphabetical order. - [ATEAM](https://nodeateam.com/) @@ -46,5 +52,3 @@ Listed in alphabetical order. - [StakeWith.Us](https://www.stakewith.us/) - [Staked](https://staked.us/) - [stake.fish](https://stake.fish/en/) - - diff --git a/migrate/v0_15/migrate.go b/migrate/v0_15/migrate.go index 0933d333..6eeb981a 100644 --- a/migrate/v0_15/migrate.go +++ b/migrate/v0_15/migrate.go @@ -14,6 +14,7 @@ import ( v0_15committee "github.com/kava-labs/kava/x/committee/types" v0_14incentive "github.com/kava-labs/kava/x/incentive/legacy/v0_14" v0_15incentive "github.com/kava-labs/kava/x/incentive/types" + v0_15swap "github.com/kava-labs/kava/x/swap/types" ) var ( @@ -74,6 +75,8 @@ func MigrateAppState(v0_14AppState genutil.AppMap) { // Marshal v15 committee genesis state v0_14AppState[v0_15committee.ModuleName] = v0_15Codec.MustMarshalJSON(Committee(committeeGS)) } + + v0_14AppState[v0_15swap.ModuleName] = v0_15Codec.MustMarshalJSON(Swap()) } func makeV014Codec() *codec.Codec { @@ -92,7 +95,8 @@ func Committee(genesisState v0_14committee.GenesisState) v0_15committee.GenesisS proposals := []v0_15committee.Proposal{} for _, com := range genesisState.Committees { - if com.ID == 1 { + switch com.ID { + case 1: // Initialize member committee without permissions stabilityCom := v0_15committee.NewMemberCommittee(com.ID, com.Description, com.Members, []v0_15committee.Permission{}, com.VoteThreshold, com.ProposalDuration, @@ -186,10 +190,9 @@ func Committee(genesisState v0_14committee.GenesisState) v0_15committee.GenesisS newStabilityCommitteePermissions = append(newStabilityCommitteePermissions, v0_15committee.TextPermission{}) // Set stability committee permissions - baseStabilityCom := stabilityCom.SetPermissions(newStabilityCommitteePermissions) - newStabilityCom := v0_15committee.MemberCommittee{BaseCommittee: baseStabilityCom} + newStabilityCom := stabilityCom.SetPermissions(newStabilityCommitteePermissions) committees = append(committees, newStabilityCom) - } else { + case 2: safetyCom := v0_15committee.NewMemberCommittee(com.ID, com.Description, com.Members, []v0_15committee.Permission{v0_15committee.SoftwareUpgradePermission{}}, com.VoteThreshold, com.ProposalDuration, v0_15committee.FirstPastThePost) @@ -197,6 +200,73 @@ func Committee(genesisState v0_14committee.GenesisState) v0_15committee.GenesisS } } + stabilityComMembers, err := loadStabilityComMembers() + if err != nil { + panic(err) + } + + // ---------------------------- Initialize hard governance committee ---------------------------- + hardGovDuration := time.Duration(time.Hour * 24 * 7) + hardGovThreshold := sdk.MustNewDecFromStr("0.5") + hardGovQuorum := sdk.MustNewDecFromStr("0.33") + + hardGovCom := v0_15committee.NewTokenCommittee(3, "Hard Governance Committee", stabilityComMembers, + []v0_15committee.Permission{}, hardGovThreshold, hardGovDuration, v0_15committee.Deadline, + hardGovQuorum, "hard") + + // Add hard money market committee permissions + var newHardCommitteePermissions []v0_15committee.Permission + var newHardSubParamPermissions v0_15committee.SubParamChangePermission + + // Allowed params permissions + hardComAllowedParams := v0_15committee.AllowedParams{ + v0_15committee.AllowedParam{Subspace: "hard", Key: "MoneyMarkets"}, + v0_15committee.AllowedParam{Subspace: "hard", Key: "MinimumBorrowUSDValue"}, + v0_15committee.AllowedParam{Subspace: "incentive", Key: "HardSupplyRewardPeriods"}, + v0_15committee.AllowedParam{Subspace: "incentive", Key: "HardBorrowRewardPeriods"}, + v0_15committee.AllowedParam{Subspace: "incentive", Key: "HardDelegatorRewardPeriods"}, + } + newHardSubParamPermissions.AllowedParams = hardComAllowedParams + + // Money market permissions + var newMoneyMarketParams v0_15committee.AllowedMoneyMarkets + hardMMDenoms := []string{"bnb", "busd", "btcb", "xrpb", "usdx", "ukava", "hard"} + for _, mmDenom := range hardMMDenoms { + newMoneyMarketParam := v0_15committee.NewAllowedMoneyMarket(mmDenom, true, true, false, true, true, true) + newMoneyMarketParams = append(newMoneyMarketParams, newMoneyMarketParam) + } + newHardSubParamPermissions.AllowedMoneyMarkets = newMoneyMarketParams + newHardCommitteePermissions = append(newHardCommitteePermissions, newHardSubParamPermissions) + + // Set hard governance committee permissions + permissionedHardGovCom := hardGovCom.SetPermissions(newHardCommitteePermissions) + committees = append(committees, permissionedHardGovCom) + + // ---------------------------- Initialize swp governance committee ---------------------------- + swpGovDuration := time.Duration(time.Hour * 24 * 7) + swpGovThreshold := sdk.MustNewDecFromStr("0.5") + swpGovQuorum := sdk.MustNewDecFromStr("0.33") + + swpGovCom := v0_15committee.NewTokenCommittee(4, "Swp Governance Committee", stabilityComMembers, + []v0_15committee.Permission{}, swpGovThreshold, swpGovDuration, v0_15committee.Deadline, + swpGovQuorum, "swp") + + // Add swap money market committee permissions + var newSwapCommitteePermissions []v0_15committee.Permission + var newSwapSubParamPermissions v0_15committee.SubParamChangePermission + + // TODO: add additional incentive params that manage LP rewards + swpAllowedParams := v0_15committee.AllowedParams{ + v0_15committee.AllowedParam{Subspace: "swap", Key: "AllowedPools"}, + v0_15committee.AllowedParam{Subspace: "swap", Key: "SwapFee"}, + v0_15committee.AllowedParam{Subspace: "incentive", Key: "HardDelegatorRewardPeriods"}, + } + newSwapSubParamPermissions.AllowedParams = swpAllowedParams + + newSwpCommitteePermissions := append(newSwapCommitteePermissions, newSwapSubParamPermissions) + permissionedSwapGovCom := swpGovCom.SetPermissions(newSwpCommitteePermissions) + committees = append(committees, permissionedSwapGovCom) + for _, v := range genesisState.Votes { newVote := v0_15committee.NewVote(v.ProposalID, v.Voter, v0_15committee.Yes) votes = append(votes, v0_15committee.Vote(newVote)) @@ -211,6 +281,32 @@ func Committee(genesisState v0_14committee.GenesisState) v0_15committee.GenesisS genesisState.NextProposalID, committees, proposals, votes) } +func loadStabilityComMembers() ([]sdk.AccAddress, error) { + strAddrs := []string{ + "kava1gru35up50ql2wxhegr880qy6ynl63ujlv8gum2", + "kava1sc3mh3pkas5e7xd269am4xm5mp6zweyzmhjagj", + "kava1c9ye54e3pzwm3e0zpdlel6pnavrj9qqv6e8r4h", + "kava1m7p6sjqrz6mylz776ct48wj6lpnpcd0z82209d", + "kava1a9pmkzk570egv3sflu3uwdf3gejl7qfy9hghzl", + } + + var addrs []sdk.AccAddress + for _, strAddr := range strAddrs { + addr, err := sdk.AccAddressFromBech32(strAddr) + if err != nil { + return addrs, err + } + addrs = append(addrs, addr) + } + + return addrs, nil +} + +// Swap introduces new v0.15 swap genesis state +func Swap() v0_15swap.GenesisState { + return v0_15swap.NewGenesisState(v0_15swap.DefaultParams()) +} + // Incentive migrates from a v0.14 incentive genesis state to a v0.15 incentive genesis state func Incentive(incentiveGS v0_14incentive.GenesisState) v0_15incentive.GenesisState { // Migrate params diff --git a/migrate/v0_15/migrate_test.go b/migrate/v0_15/migrate_test.go index 5632e7be..91ecd0ed 100644 --- a/migrate/v0_15/migrate_test.go +++ b/migrate/v0_15/migrate_test.go @@ -43,7 +43,7 @@ func TestCommittee(t *testing.T) { err = newGenState.Validate() require.NoError(t, err) - require.Equal(t, len(oldGenState.Committees), len(newGenState.Committees)) + require.Equal(t, len(oldGenState.Committees)+2, len(newGenState.Committees)) // New gen state has 2 additional committees for i := 0; i < len(oldGenState.Committees); i++ { require.Equal(t, len(oldGenState.Committees[i].Permissions), len(newGenState.Committees[i].GetPermissions())) } @@ -57,6 +57,12 @@ func TestCommittee(t *testing.T) { require.Equal(t, len(oldSPCP.AllowedMoneyMarkets), len(newSPCP.AllowedMoneyMarkets)) } +// exportGenesisJSON is a utility testing method +func exportGenesisJSON(genState v0_15committee.GenesisState) { + v15Cdc := app.MakeCodec() + ioutil.WriteFile(filepath.Join("testdata", "kava-8-committee-state.json"), v15Cdc.MustMarshalJSON(genState), 0644) +} + func TestIncentive(t *testing.T) { bz, err := ioutil.ReadFile(filepath.Join("testdata", "kava-7-incentive-state.json")) require.NoError(t, err) diff --git a/x/committee/proposal_handler_test.go b/x/committee/proposal_handler_test.go index 15a4ff73..a9d491bc 100644 --- a/x/committee/proposal_handler_test.go +++ b/x/committee/proposal_handler_test.go @@ -106,7 +106,6 @@ func (suite *ProposalHandlerTestSuite) TestProposalHandler_ChangeCommittee() { VoteThreshold: suite.testGenesis.Committees[0].GetVoteThreshold(), ProposalDuration: suite.testGenesis.Committees[0].GetProposalDuration(), TallyOption: types.FirstPastThePost, - Type: types.MemberCommitteeType, }, }, ), diff --git a/x/committee/types/codec.go b/x/committee/types/codec.go index b9a8e150..11a592a4 100644 --- a/x/committee/types/codec.go +++ b/x/committee/types/codec.go @@ -36,6 +36,7 @@ func RegisterCodec(cdc *codec.Codec) { // Committees cdc.RegisterInterface((*Committee)(nil), nil) + cdc.RegisterConcrete(BaseCommittee{}, "kava/BaseCommittee", nil) cdc.RegisterConcrete(MemberCommittee{}, "kava/MemberCommittee", nil) cdc.RegisterConcrete(TokenCommittee{}, "kava/TokenCommittee", nil) diff --git a/x/committee/types/committee.go b/x/committee/types/committee.go index 0b66655e..51c3ef98 100644 --- a/x/committee/types/committee.go +++ b/x/committee/types/committee.go @@ -24,6 +24,7 @@ const ( ) const ( + BaseCommitteeType = "kava/BaseCommittee" MemberCommitteeType = "kava/MemberCommittee" // Committee is composed of member addresses that vote to enact proposals within their permissions TokenCommitteeType = "kava/TokenCommittee" // Committee is composed of token holders with voting power determined by total token balance BondDenom = "ukava" @@ -33,6 +34,7 @@ func init() { // CommitteeChange/Delete proposals are registered on gov's ModuleCdc (see proposal.go). // But since these proposals contain Committees, these types also need registering: govtypes.ModuleCdc.RegisterInterface((*Committee)(nil), nil) + govtypes.RegisterProposalTypeCodec(BaseCommittee{}, "kava/BaseCommittee") govtypes.RegisterProposalTypeCodec(MemberCommittee{}, "kava/MemberCommittee") govtypes.RegisterProposalTypeCodec(TokenCommittee{}, "kava/TokenCommittee") } @@ -130,7 +132,7 @@ type Committee interface { HasMember(addr sdk.AccAddress) bool GetPermissions() []Permission - SetPermissions([]Permission) BaseCommittee + SetPermissions([]Permission) Committee HasPermissionsFor(ctx sdk.Context, appCdc *codec.Codec, pk ParamKeeper, proposal PubProposal) bool GetProposalDuration() time.Duration @@ -148,7 +150,6 @@ type Committees []Committee // BaseCommittee is a common type shared by all Committees type BaseCommittee struct { - Type string `json:"type" yaml:"type"` ID uint64 `json:"id" yaml:"id"` Description string `json:"description" yaml:"description"` Members []sdk.AccAddress `json:"members" yaml:"members"` @@ -159,7 +160,7 @@ type BaseCommittee struct { } // GetType is a getter for committee type -func (c BaseCommittee) GetType() string { return c.Type } +func (c BaseCommittee) GetType() string { return BaseCommitteeType } // GetID is a getter for committee ID func (c BaseCommittee) GetID() uint64 { return c.ID } @@ -275,14 +276,13 @@ func (c BaseCommittee) Validate() error { // String implements fmt.Stringer func (c BaseCommittee) String() string { return fmt.Sprintf(`Committee %d: - Type: %s Description: %s Members: %s Permissions: %s VoteThreshold: %s ProposalDuration: %s TallyOption: %s`, - c.ID, c.Type, c.Description, c.GetMembers(), c.Permissions, + c.ID, c.Description, c.GetMembers(), c.Permissions, c.VoteThreshold.String(), c.ProposalDuration.String(), c.TallyOption.String(), ) @@ -298,7 +298,6 @@ func NewMemberCommittee(id uint64, description string, members []sdk.AccAddress, threshold sdk.Dec, duration time.Duration, tallyOption TallyOption) MemberCommittee { return MemberCommittee{ BaseCommittee: BaseCommittee{ - Type: MemberCommitteeType, ID: id, Description: description, Members: members, @@ -310,6 +309,15 @@ func NewMemberCommittee(id uint64, description string, members []sdk.AccAddress, } } +// GetType is a getter for committee type +func (c MemberCommittee) GetType() string { return MemberCommitteeType } + +// SetPermissions is a setter for committee permissions +func (c MemberCommittee) SetPermissions(permissions []Permission) Committee { + c.Permissions = permissions + return c +} + // Validate validates the committee's fields func (c MemberCommittee) Validate() error { return c.BaseCommittee.Validate() @@ -327,7 +335,6 @@ func NewTokenCommittee(id uint64, description string, members []sdk.AccAddress, threshold sdk.Dec, duration time.Duration, tallyOption TallyOption, quorum sdk.Dec, tallyDenom string) TokenCommittee { return TokenCommittee{ BaseCommittee: BaseCommittee{ - Type: TokenCommitteeType, ID: id, Description: description, Members: members, @@ -341,12 +348,21 @@ func NewTokenCommittee(id uint64, description string, members []sdk.AccAddress, } } +// GetType is a getter for committee type +func (c TokenCommittee) GetType() string { return TokenCommitteeType } + // GetQuorum returns the quorum of the committee func (c TokenCommittee) GetQuorum() sdk.Dec { return c.Quorum } // GetTallyDenom returns the tally denom of the committee func (c TokenCommittee) GetTallyDenom() string { return c.TallyDenom } +// SetPermissions is a setter for committee permissions +func (c TokenCommittee) SetPermissions(permissions []Permission) Committee { + c.Permissions = permissions + return c +} + // Validate validates the committee's fields func (c TokenCommittee) Validate() error { if c.TallyDenom == BondDenom { diff --git a/x/committee/types/committee_test.go b/x/committee/types/committee_test.go index 223b15a2..80b10e8c 100644 --- a/x/committee/types/committee_test.go +++ b/x/committee/types/committee_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/cosmos/cosmos-sdk/codec" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/stretchr/testify/require" "github.com/tendermint/tendermint/crypto" @@ -199,7 +200,6 @@ func TestMemberCommittee(t *testing.T) { VoteThreshold: d("0.667"), ProposalDuration: time.Hour * 24 * 7, TallyOption: FirstPastThePost, - Type: MemberCommitteeType, }, }, expectPass: true, @@ -245,7 +245,6 @@ func TestTokenCommittee(t *testing.T) { VoteThreshold: d("0.667"), ProposalDuration: time.Hour * 24 * 7, TallyOption: FirstPastThePost, - Type: TokenCommitteeType, }, Quorum: d("0.4"), TallyDenom: "hard", @@ -263,7 +262,6 @@ func TestTokenCommittee(t *testing.T) { VoteThreshold: d("0.667"), ProposalDuration: time.Hour * 24 * 7, TallyOption: FirstPastThePost, - Type: TokenCommitteeType, }, Quorum: sdk.Dec{Int: nil}, TallyDenom: "hard", @@ -281,7 +279,6 @@ func TestTokenCommittee(t *testing.T) { VoteThreshold: d("0.667"), ProposalDuration: time.Hour * 24 * 7, TallyOption: FirstPastThePost, - Type: TokenCommitteeType, }, Quorum: d("-0.1"), TallyDenom: "hard", @@ -299,7 +296,6 @@ func TestTokenCommittee(t *testing.T) { VoteThreshold: d("0.667"), ProposalDuration: time.Hour * 24 * 7, TallyOption: FirstPastThePost, - Type: TokenCommitteeType, }, Quorum: d("1.001"), TallyDenom: "hard", @@ -317,7 +313,6 @@ func TestTokenCommittee(t *testing.T) { VoteThreshold: d("0.667"), ProposalDuration: time.Hour * 24 * 7, TallyOption: FirstPastThePost, - Type: TokenCommitteeType, }, Quorum: d("0.4"), TallyDenom: BondDenom, @@ -340,3 +335,50 @@ func TestTokenCommittee(t *testing.T) { }) } } + +// TestTokenCommitteeMarshalJSON tests TokenCommittee JSON marshaling +func TestTokenCommitteeMarshalJSON(t *testing.T) { + addresses := []sdk.AccAddress{ + sdk.AccAddress(crypto.AddressHash([]byte("KavaTest1"))), + sdk.AccAddress(crypto.AddressHash([]byte("KavaTest2"))), + } + + testCases := []struct { + name string + committee TokenCommittee + }{ + { + name: "normal", + committee: TokenCommittee{ + BaseCommittee: BaseCommittee{ + ID: 1, + Description: "This token committee is for testing.", + Members: addresses[:2], + Permissions: []Permission{GodPermission{}}, + VoteThreshold: d("0.667"), + ProposalDuration: time.Hour * 24 * 7, + TallyOption: Deadline, + }, + Quorum: d("0.4"), + TallyDenom: "hard", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := tc.committee.Validate() + require.NoError(t, err) + + // Marshal and unmarshal the TokenCommittee + cdc := codec.New() + RegisterCodec(cdc) + bz := cdc.MustMarshalJSON(tc.committee) + + var com TokenCommittee + cdc.MustUnmarshalJSON(bz, &com) + + require.Equal(t, tc.committee, com) + }) + } +} diff --git a/x/committee/types/genesis_test.go b/x/committee/types/genesis_test.go index 195224eb..932658f1 100644 --- a/x/committee/types/genesis_test.go +++ b/x/committee/types/genesis_test.go @@ -33,7 +33,6 @@ func TestGenesisState_Validate(t *testing.T) { Permissions: []Permission{GodPermission{}}, VoteThreshold: d("0.667"), ProposalDuration: time.Hour * 24 * 7, - Type: MemberCommitteeType, TallyOption: FirstPastThePost, }, }, @@ -45,7 +44,6 @@ func TestGenesisState_Validate(t *testing.T) { Permissions: nil, VoteThreshold: d("0.8"), ProposalDuration: time.Hour * 24 * 21, - Type: MemberCommitteeType, TallyOption: FirstPastThePost, }, }, @@ -57,7 +55,6 @@ func TestGenesisState_Validate(t *testing.T) { Permissions: nil, VoteThreshold: d("0.8"), ProposalDuration: time.Hour * 24 * 21, - Type: TokenCommitteeType, TallyOption: Deadline, }, Quorum: sdk.MustNewDecFromStr("0.4"), diff --git a/x/swap/alias.go b/x/swap/alias.go index e9f1d1e1..bff602eb 100644 --- a/x/swap/alias.go +++ b/x/swap/alias.go @@ -1,3 +1,5 @@ +// Code generated by aliasgen tool (github.com/rhuairahrighairidh/aliasgen) DO NOT EDIT. + package swap import ( @@ -6,23 +8,82 @@ import ( ) const ( - ModuleName = types.ModuleName - QuerierRoute = types.QuerierRoute - RouterKey = types.RouterKey - StoreKey = types.StoreKey - DefaultParamspace = types.DefaultParamspace -) - -type ( - GenesisState = types.GenesisState - Keeper = keeper.Keeper + AttributeKeyDepositor = types.AttributeKeyDepositor + AttributeKeyOwner = types.AttributeKeyOwner + AttributeKeyPoolID = types.AttributeKeyPoolID + AttributeKeyShares = types.AttributeKeyShares + AttributeValueCategory = types.AttributeValueCategory + DefaultParamspace = types.DefaultParamspace + EventTypeSwapDeposit = types.EventTypeSwapDeposit + EventTypeSwapWithdraw = types.EventTypeSwapWithdraw + ModuleAccountName = types.ModuleAccountName + ModuleName = types.ModuleName + QuerierRoute = types.QuerierRoute + QueryGetParams = types.QueryGetParams + RouterKey = types.RouterKey + StoreKey = types.StoreKey ) var ( - NewKeeper = keeper.NewKeeper - NewQuerier = keeper.NewQuerier - ModuleCdc = types.ModuleCdc - ParamKeyTable = types.ParamKeyTable - RegisterCodec = types.RegisterCodec - DefaultGenesisState = types.DefaultGenesisState + // function aliases + NewKeeper = keeper.NewKeeper + NewQuerier = keeper.NewQuerier + DefaultGenesisState = types.DefaultGenesisState + DefaultParams = types.DefaultParams + DepositorPoolSharesKey = types.DepositorPoolSharesKey + NewAllowedPool = types.NewAllowedPool + NewAllowedPools = types.NewAllowedPools + NewBasePool = types.NewBasePool + NewBasePoolWithExistingShares = types.NewBasePoolWithExistingShares + NewDenominatedPool = types.NewDenominatedPool + NewDenominatedPoolWithExistingShares = types.NewDenominatedPoolWithExistingShares + NewGenesisState = types.NewGenesisState + NewMsgDeposit = types.NewMsgDeposit + NewMsgWithdraw = types.NewMsgWithdraw + NewParams = types.NewParams + NewPoolRecord = types.NewPoolRecord + NewShareRecord = types.NewShareRecord + ParamKeyTable = types.ParamKeyTable + PoolID = types.PoolID + PoolIDFromCoins = types.PoolIDFromCoins + PoolKey = types.PoolKey + RegisterCodec = types.RegisterCodec + + // variable aliases + DefaultAllowedPools = types.DefaultAllowedPools + DefaultSwapFee = types.DefaultSwapFee + DepositorPoolSharesPrefix = types.DepositorPoolSharesPrefix + ErrDeadlineExceeded = types.ErrDeadlineExceeded + ErrDepositNotFound = types.ErrDepositNotFound + ErrInsufficientLiquidity = types.ErrInsufficientLiquidity + ErrInvalidCoin = types.ErrInvalidCoin + ErrInvalidDeadline = types.ErrInvalidDeadline + ErrInvalidPool = types.ErrInvalidPool + ErrInvalidShares = types.ErrInvalidShares + ErrInvalidSlippage = types.ErrInvalidSlippage + ErrNotAllowed = types.ErrNotAllowed + ErrNotImplemented = types.ErrNotImplemented + ErrSlippageExceeded = types.ErrSlippageExceeded + KeyAllowedPools = types.KeyAllowedPools + KeySwapFee = types.KeySwapFee + MaxSwapFee = types.MaxSwapFee + ModuleCdc = types.ModuleCdc + PoolKeyPrefix = types.PoolKeyPrefix +) + +type ( + Keeper = keeper.Keeper + AccountKeeper = types.AccountKeeper + AllowedPool = types.AllowedPool + AllowedPools = types.AllowedPools + BasePool = types.BasePool + DenominatedPool = types.DenominatedPool + GenesisState = types.GenesisState + MsgDeposit = types.MsgDeposit + MsgWithDeadline = types.MsgWithDeadline + MsgWithdraw = types.MsgWithdraw + Params = types.Params + PoolRecord = types.PoolRecord + ShareRecord = types.ShareRecord + SupplyKeeper = types.SupplyKeeper ) diff --git a/x/swap/client/cli/query.go b/x/swap/client/cli/query.go index b2c3f7ff..cf20d2d6 100644 --- a/x/swap/client/cli/query.go +++ b/x/swap/client/cli/query.go @@ -2,17 +2,26 @@ package cli import ( "fmt" + "strings" "github.com/spf13/cobra" + "github.com/spf13/viper" "github.com/cosmos/cosmos-sdk/client" "github.com/cosmos/cosmos-sdk/client/context" "github.com/cosmos/cosmos-sdk/client/flags" "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" "github.com/kava-labs/kava/x/swap/types" ) +// flags for cli queries +const ( + flagOwner = "owner" + flagPool = "pool" +) + // GetQueryCmd returns the cli query commands for the module func GetQueryCmd(queryRoute string, cdc *codec.Codec) *cobra.Command { swapQueryCmd := &cobra.Command{ @@ -25,6 +34,9 @@ func GetQueryCmd(queryRoute string, cdc *codec.Codec) *cobra.Command { swapQueryCmd.AddCommand(flags.GetCommands( queryParamsCmd(queryRoute, cdc), + queryDepositsCmd(queryRoute, cdc), + queryPoolCmd(queryRoute, cdc), + queryPoolsCmd(queryRoute, cdc), )...) return swapQueryCmd @@ -56,3 +68,134 @@ func queryParamsCmd(queryRoute string, cdc *codec.Codec) *cobra.Command { }, } } + +func queryDepositsCmd(queryRoute string, cdc *codec.Codec) *cobra.Command { + cmd := &cobra.Command{ + Use: "deposits", + Short: "get liquidity provider deposits", + Long: strings.TrimSpace(`get liquidity provider deposits: + Example: + $ kvcli q swap deposits --pool bnb/usdx + $ kvcli q swap deposits --owner kava1l0xsq2z7gqd7yly0g40y5836g0appumark77ny + $ kvcli q swap deposits --pool bnb/usdx --owner kava1l0xsq2z7gqd7yly0g40y5836g0appumark77ny + $ kvcli q swap deposits --page=2 --limit=100 + `, + ), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + bechOwnerAddr := viper.GetString(flagOwner) + pool := viper.GetString(flagPool) + page := viper.GetInt(flags.FlagPage) + limit := viper.GetInt(flags.FlagLimit) + + var owner sdk.AccAddress + if len(bechOwnerAddr) != 0 { + ownerAddr, err := sdk.AccAddressFromBech32(bechOwnerAddr) + if err != nil { + return err + } + owner = ownerAddr + } + + params := types.NewQueryDepositsParams(page, limit, owner, pool) + bz, err := cdc.MarshalJSON(params) + if err != nil { + return err + } + + cliCtx := context.NewCLIContext().WithCodec(cdc) + + route := fmt.Sprintf("custom/%s/%s", queryRoute, types.QueryGetDeposits) + res, height, err := cliCtx.QueryWithData(route, bz) + if err != nil { + return err + } + cliCtx = cliCtx.WithHeight(height) + + var deposits types.DepositsQueryResults + if err := cdc.UnmarshalJSON(res, &deposits); err != nil { + return fmt.Errorf("failed to unmarshal deposit results: %w", err) + } + return cliCtx.PrintOutput(deposits) + }, + } + cmd.Flags().Int(flags.FlagPage, 1, "pagination page of deposits to query for") + cmd.Flags().Int(flags.FlagLimit, 100, "pagination limit of deposits to query for") + cmd.Flags().String(flagPool, "", "pool name") + cmd.Flags().String(flagOwner, "", "owner, also known as a liquidity provider") + return cmd +} + +func queryPoolCmd(queryRoute string, cdc *codec.Codec) *cobra.Command { + cmd := &cobra.Command{ + Use: "pool", + Short: "get pool statistics", + Long: strings.TrimSpace(`get statistics about a given liquidity pool: + Example: + $ kvcli q swap pool ukava/usdx`, + ), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + + poolName := args[0] + if len(poolName) == 0 { + return fmt.Errorf("must specify pool ID") + } + + // Construct query with params + params := types.NewQueryPoolParams(poolName) + bz, err := cdc.MarshalJSON(params) + if err != nil { + return err + } + + // Execute query + route := fmt.Sprintf("custom/%s/%s", queryRoute, types.QueryGetPool) + res, height, err := cliCtx.QueryWithData(route, bz) + if err != nil { + return err + } + cliCtx = cliCtx.WithHeight(height) + + var poolStats types.PoolStatsQueryResult + if err := cdc.UnmarshalJSON(res, &poolStats); err != nil { + return fmt.Errorf("failed to unmarshal pool stats: %w", err) + } + return cliCtx.PrintOutput(poolStats) + }, + } + cmd.Flags().String(flagPool, "", "pool ID") + return cmd +} + +func queryPoolsCmd(queryRoute string, cdc *codec.Codec) *cobra.Command { + cmd := &cobra.Command{ + Use: "pools", + Short: "get statistics for all pools", + Long: strings.TrimSpace(`get statistics for all liquidity pools: + Example: + $ kvcli q swap pools`, + ), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + + // Execute query + route := fmt.Sprintf("custom/%s/%s", queryRoute, types.QueryGetPools) + res, height, err := cliCtx.QueryWithData(route, nil) + if err != nil { + return err + } + cliCtx = cliCtx.WithHeight(height) + + var poolStats types.PoolStatsQueryResults + if err := cdc.UnmarshalJSON(res, &poolStats); err != nil { + return fmt.Errorf("failed to unmarshal pools' stats: %w", err) + } + + return cliCtx.PrintOutput(poolStats) + }, + } + return cmd +} diff --git a/x/swap/client/cli/tx.go b/x/swap/client/cli/tx.go index cfa2b9fa..42768be0 100644 --- a/x/swap/client/cli/tx.go +++ b/x/swap/client/cli/tx.go @@ -1,13 +1,20 @@ package cli import ( + "bufio" "fmt" + "strconv" "github.com/spf13/cobra" "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/client/context" "github.com/cosmos/cosmos-sdk/client/flags" "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/version" + "github.com/cosmos/cosmos-sdk/x/auth" + "github.com/cosmos/cosmos-sdk/x/auth/client/utils" "github.com/kava-labs/kava/x/swap/types" ) @@ -22,7 +29,97 @@ func GetTxCmd(cdc *codec.Codec) *cobra.Command { RunE: client.ValidateCmd, } - swapTxCmd.AddCommand(flags.PostCommands()...) + swapTxCmd.AddCommand(flags.PostCommands( + getCmdDeposit(cdc), + getCmdWithdraw(cdc), + )...) return swapTxCmd } + +func getCmdDeposit(cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "deposit [tokenA] [tokenB] [slippage] [deadline]", + Short: "deposit coins to a swap liquidity pool", + Example: fmt.Sprintf( + `%s tx %s deposit 10000000ukava 10000000usdx 0.01 1624224736 --from `, version.ClientName, types.ModuleName, + ), + Args: cobra.ExactArgs(4), + RunE: func(cmd *cobra.Command, args []string) error { + inBuf := bufio.NewReader(cmd.InOrStdin()) + cliCtx := context.NewCLIContext().WithCodec(cdc) + txBldr := auth.NewTxBuilderFromCLI(inBuf).WithTxEncoder(utils.GetTxEncoder(cdc)) + + tokenA, err := sdk.ParseCoin(args[0]) + if err != nil { + return err + } + + tokenB, err := sdk.ParseCoin(args[1]) + if err != nil { + return err + } + + slippage, err := sdk.NewDecFromStr(args[2]) + if err != nil { + return err + } + + deadline, err := strconv.ParseInt(args[3], 10, 64) + if err != nil { + return err + } + + msg := types.NewMsgDeposit(cliCtx.GetFromAddress(), tokenA, tokenB, slippage, deadline) + if err := msg.ValidateBasic(); err != nil { + return err + } + + return utils.GenerateOrBroadcastMsgs(cliCtx, txBldr, []sdk.Msg{msg}) + }, + } +} + +func getCmdWithdraw(cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "withdraw [shares] [minCoinA] [minCoinB] [deadline]", + Short: "withdraw coins from a swap liquidity pool", + Example: fmt.Sprintf( + `%s tx %s withdraw 153000 10000000ukava 20000000usdx 176293740 --from `, version.ClientName, types.ModuleName, + ), + Args: cobra.ExactArgs(4), + RunE: func(cmd *cobra.Command, args []string) error { + inBuf := bufio.NewReader(cmd.InOrStdin()) + cliCtx := context.NewCLIContext().WithCodec(cdc) + txBldr := auth.NewTxBuilderFromCLI(inBuf).WithTxEncoder(utils.GetTxEncoder(cdc)) + + numShares, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return err + } + shares := sdk.NewInt(numShares) + + minTokenA, err := sdk.ParseCoin(args[1]) + if err != nil { + return err + } + + minTokenB, err := sdk.ParseCoin(args[2]) + if err != nil { + return err + } + + deadline, err := strconv.ParseInt(args[3], 10, 64) + if err != nil { + return err + } + + msg := types.NewMsgWithdraw(cliCtx.GetFromAddress(), shares, minTokenA, minTokenB, deadline) + if err := msg.ValidateBasic(); err != nil { + return err + } + + return utils.GenerateOrBroadcastMsgs(cliCtx, txBldr, []sdk.Msg{msg}) + }, + } +} diff --git a/x/swap/client/rest/query.go b/x/swap/client/rest/query.go index 8dfa6a99..037bcfeb 100644 --- a/x/swap/client/rest/query.go +++ b/x/swap/client/rest/query.go @@ -3,10 +3,12 @@ package rest import ( "fmt" "net/http" + "strings" "github.com/gorilla/mux" "github.com/cosmos/cosmos-sdk/client/context" + sdk "github.com/cosmos/cosmos-sdk/types" "github.com/cosmos/cosmos-sdk/types/rest" "github.com/kava-labs/kava/x/swap/types" @@ -14,6 +16,9 @@ import ( func registerQueryRoutes(cliCtx context.CLIContext, r *mux.Router) { r.HandleFunc(fmt.Sprintf("/%s/parameters", types.ModuleName), queryParamsHandlerFn(cliCtx)).Methods("GET") + r.HandleFunc(fmt.Sprintf("/%s/deposits", types.ModuleName), queryDepositsHandlerFn(cliCtx)).Methods("GET") + r.HandleFunc(fmt.Sprintf("/%s/pool", types.ModuleName), queryPoolHandlerFn(cliCtx)).Methods("GET") + r.HandleFunc(fmt.Sprintf("/%s/pools", types.ModuleName), queryPoolsHandlerFn(cliCtx)).Methods("GET") } func queryParamsHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { @@ -35,3 +40,110 @@ func queryParamsHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { rest.PostProcessResponse(w, cliCtx, res) } } + +func queryDepositsHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cliCtx, ok := rest.ParseQueryHeightOrReturnBadRequest(w, cliCtx, r) + if !ok { + return + } + + var owner sdk.AccAddress + var pool string + + _, page, limit, err := rest.ParseHTTPArgsWithLimit(r, 0) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + if x := r.URL.Query().Get(RestPool); len(x) != 0 { + pool = strings.TrimSpace(x) + } + + if x := r.URL.Query().Get(RestOwner); len(x) != 0 { + ownerStr := strings.ToLower(strings.TrimSpace(x)) + shareOwner, err := sdk.AccAddressFromBech32(ownerStr) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, fmt.Sprintf("cannot parse address from owner %s", ownerStr)) + return + } + owner = shareOwner + } + + params := types.NewQueryDepositsParams(page, limit, owner, pool) + + bz, err := cliCtx.Codec.MarshalJSON(params) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + route := fmt.Sprintf("custom/%s/%s", types.ModuleName, types.QueryGetDeposits) + res, height, err := cliCtx.QueryWithData(route, bz) + cliCtx = cliCtx.WithHeight(height) + if err != nil { + rest.WriteErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + rest.PostProcessResponse(w, cliCtx, res) + } +} + +func queryPoolHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Parse the query height + cliCtx, ok := rest.ParseQueryHeightOrReturnBadRequest(w, cliCtx, r) + if !ok { + return + } + + var poolName string + + if x := r.URL.Query().Get(RestPool); len(x) != 0 { + poolName = strings.TrimSpace(x) + } + if len(poolName) == 0 { + rest.WriteErrorResponse(w, http.StatusBadRequest, fmt.Sprintf("must specify pool param")) + return + } + + params := types.NewQueryPoolParams(poolName) + + bz, err := cliCtx.Codec.MarshalJSON(params) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + route := fmt.Sprintf("custom/%s/%s", types.ModuleName, types.QueryGetPool) + res, height, err := cliCtx.QueryWithData(route, bz) + cliCtx = cliCtx.WithHeight(height) + if err != nil { + rest.WriteErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + rest.PostProcessResponse(w, cliCtx, res) + } +} + +func queryPoolsHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Parse the query height + cliCtx, ok := rest.ParseQueryHeightOrReturnBadRequest(w, cliCtx, r) + if !ok { + return + } + + route := fmt.Sprintf("custom/%s/%s", types.QuerierRoute, types.QueryGetPools) + + res, height, err := cliCtx.QueryWithData(route, nil) + if err != nil { + rest.WriteErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + cliCtx = cliCtx.WithHeight(height) + rest.PostProcessResponse(w, cliCtx, res) + } +} diff --git a/x/swap/client/rest/rest.go b/x/swap/client/rest/rest.go index c238f421..96a5a2b9 100644 --- a/x/swap/client/rest/rest.go +++ b/x/swap/client/rest/rest.go @@ -4,14 +4,39 @@ import ( "github.com/gorilla/mux" "github.com/cosmos/cosmos-sdk/client/context" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/rest" ) // REST variable names // nolint -const () +const ( + RestPool = "pool" + RestOwner = "owner" +) // RegisterRoutes registers swap-related REST handlers to a router func RegisterRoutes(cliCtx context.CLIContext, r *mux.Router) { registerQueryRoutes(cliCtx, r) registerTxRoutes(cliCtx, r) } + +// PostCreateDepositReq defines the properties of a deposit create request's body +type PostCreateDepositReq struct { + BaseReq rest.BaseReq `json:"base_req" yaml:"base_req"` + From sdk.AccAddress `json:"from" yaml:"from"` + TokenA sdk.Coin `json:"token_a" yaml:"token_a"` + TokenB sdk.Coin `json:"token_b" yaml:"token_b"` + Slippage sdk.Dec `json:"slippage" yaml:"slippage"` + Deadline int64 `json:"deadline" yaml:"deadline"` +} + +// PostCreateWithdrawReq defines the properties of a withdraw create request's body +type PostCreateWithdrawReq struct { + BaseReq rest.BaseReq `json:"base_req" yaml:"base_req"` + From sdk.AccAddress `json:"from" yaml:"from"` + Shares sdk.Int `json:"shares" yaml:"shares"` + MinTokenA sdk.Coin `json:"token_a" yaml:"token_a"` + MinTokenB sdk.Coin `json:"token_b" yaml:"token_b"` + Deadline int64 `json:"deadline" yaml:"deadline"` +} diff --git a/x/swap/client/rest/tx.go b/x/swap/client/rest/tx.go index ae173af7..580e6255 100644 --- a/x/swap/client/rest/tx.go +++ b/x/swap/client/rest/tx.go @@ -1,9 +1,62 @@ package rest import ( + "fmt" + "net/http" + "github.com/gorilla/mux" "github.com/cosmos/cosmos-sdk/client/context" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/rest" + "github.com/cosmos/cosmos-sdk/x/auth/client/utils" + + "github.com/kava-labs/kava/x/swap/types" ) -func registerTxRoutes(cliCtx context.CLIContext, r *mux.Router) {} +func registerTxRoutes(cliCtx context.CLIContext, r *mux.Router) { + r.HandleFunc(fmt.Sprintf("/%s/deposit", types.ModuleName), postDepositHandlerFn(cliCtx)).Methods("POST") + r.HandleFunc(fmt.Sprintf("/%s/withdraw", types.ModuleName), postWithdrawHandlerFn(cliCtx)).Methods("POST") +} + +func postDepositHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Decode POST request body + var req PostCreateDepositReq + if !rest.ReadRESTReq(w, r, cliCtx.Codec, &req) { + return + } + req.BaseReq = req.BaseReq.Sanitize() + if !req.BaseReq.ValidateBasic(w) { + return + } + + msg := types.NewMsgDeposit(req.From, req.TokenA, req.TokenB, req.Slippage, req.Deadline) + if err := msg.ValidateBasic(); err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + utils.WriteGenerateStdTxResponse(w, cliCtx, req.BaseReq, []sdk.Msg{msg}) + } +} + +func postWithdrawHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Decode POST request body + var req PostCreateWithdrawReq + if !rest.ReadRESTReq(w, r, cliCtx.Codec, &req) { + return + } + req.BaseReq = req.BaseReq.Sanitize() + if !req.BaseReq.ValidateBasic(w) { + return + } + + msg := types.NewMsgWithdraw(req.From, req.Shares, req.MinTokenA, req.MinTokenA, req.Deadline) + if err := msg.ValidateBasic(); err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + utils.WriteGenerateStdTxResponse(w, cliCtx, req.BaseReq, []sdk.Msg{msg}) + } +} diff --git a/x/swap/handler.go b/x/swap/handler.go index 8b13f1a3..8776c421 100644 --- a/x/swap/handler.go +++ b/x/swap/handler.go @@ -3,15 +3,65 @@ package swap import ( sdk "github.com/cosmos/cosmos-sdk/types" sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + + "github.com/kava-labs/kava/x/swap/keeper" + "github.com/kava-labs/kava/x/swap/types" ) // NewHandler creates an sdk.Handler for swap messages func NewHandler(k Keeper) sdk.Handler { return func(ctx sdk.Context, msg sdk.Msg) (*sdk.Result, error) { ctx = ctx.WithEventManager(sdk.NewEventManager()) + + if deadlineMsg, ok := msg.(types.MsgWithDeadline); ok { + if deadlineExceeded := deadlineMsg.DeadlineExceeded(ctx.BlockTime()); deadlineExceeded { + return nil, sdkerrors.Wrapf(types.ErrDeadlineExceeded, "block time %d >= deadline %d", ctx.BlockTime().Unix(), deadlineMsg.GetDeadline().Unix()) + } + } + switch msg := msg.(type) { + case types.MsgDeposit: + return handleMsgDeposit(ctx, k, msg) + case types.MsgWithdraw: + return handleMsgWithdraw(ctx, k, msg) default: return nil, sdkerrors.Wrapf(sdkerrors.ErrUnknownRequest, "unrecognized %s message type: %T", ModuleName, msg) } } } + +func handleMsgDeposit(ctx sdk.Context, k keeper.Keeper, msg types.MsgDeposit) (*sdk.Result, error) { + if err := k.Deposit(ctx, msg.Depositor, msg.TokenA, msg.TokenB, msg.Slippage); err != nil { + return nil, err + } + + ctx.EventManager().EmitEvent( + sdk.NewEvent( + sdk.EventTypeMessage, + sdk.NewAttribute(sdk.AttributeKeyModule, types.AttributeValueCategory), + sdk.NewAttribute(sdk.AttributeKeySender, msg.Depositor.String()), + ), + ) + + return &sdk.Result{ + Events: ctx.EventManager().Events(), + }, nil +} + +func handleMsgWithdraw(ctx sdk.Context, k keeper.Keeper, msg types.MsgWithdraw) (*sdk.Result, error) { + if err := k.Withdraw(ctx, msg.From, msg.Shares, msg.MinTokenA, msg.MinTokenB); err != nil { + return nil, err + } + + ctx.EventManager().EmitEvent( + sdk.NewEvent( + sdk.EventTypeMessage, + sdk.NewAttribute(sdk.AttributeKeyModule, types.AttributeValueCategory), + sdk.NewAttribute(sdk.AttributeKeySender, msg.From.String()), + ), + ) + + return &sdk.Result{ + Events: ctx.EventManager().Events(), + }, nil +} diff --git a/x/swap/handler_test.go b/x/swap/handler_test.go new file mode 100644 index 00000000..149f1133 --- /dev/null +++ b/x/swap/handler_test.go @@ -0,0 +1,360 @@ +package swap_test + +import ( + "fmt" + "testing" + "time" + + "github.com/kava-labs/kava/x/swap" + "github.com/kava-labs/kava/x/swap/testutil" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/bank" + "github.com/stretchr/testify/suite" + abci "github.com/tendermint/tendermint/abci/types" + "github.com/tendermint/tendermint/crypto" + tmtime "github.com/tendermint/tendermint/types/time" +) + +var swapModuleAccountAddress = sdk.AccAddress(crypto.AddressHash([]byte(swap.ModuleAccountName))) + +type handlerTestSuite struct { + testutil.Suite + handler sdk.Handler +} + +func (suite *handlerTestSuite) SetupTest() { + suite.Suite.SetupTest() + suite.handler = swap.NewHandler(suite.Keeper) +} + +func (suite *handlerTestSuite) TestDeposit_CreatePool() { + pool := swap.NewAllowedPool("ukava", "usdx") + suite.Require().NoError(pool.Validate()) + suite.Keeper.SetParams(suite.Ctx, swap.NewParams(swap.NewAllowedPools(pool), swap.DefaultSwapFee)) + + balance := sdk.NewCoins( + sdk.NewCoin(pool.TokenA, sdk.NewInt(10e6)), + sdk.NewCoin(pool.TokenB, sdk.NewInt(50e6)), + ) + depositor := suite.CreateAccount(balance) + + deposit := swap.NewMsgDeposit( + depositor.GetAddress(), + sdk.NewCoin(pool.TokenA, depositor.GetCoins().AmountOf(pool.TokenA)), + sdk.NewCoin(pool.TokenB, depositor.GetCoins().AmountOf(pool.TokenB)), + sdk.MustNewDecFromStr("0.01"), + time.Now().Add(10*time.Minute).Unix(), + ) + + res, err := suite.handler(suite.Ctx, deposit) + suite.Require().NoError(err) + + suite.AccountBalanceEqual(depositor, sdk.Coins(nil)) + suite.ModuleAccountBalanceEqual(balance) + suite.PoolLiquidityEqual(balance) + suite.PoolShareValueEqual(depositor, pool, balance) + + suite.EventsContains(res.Events, sdk.NewEvent( + sdk.EventTypeMessage, + sdk.NewAttribute(sdk.AttributeKeyModule, swap.AttributeValueCategory), + sdk.NewAttribute(sdk.AttributeKeySender, depositor.GetAddress().String()), + )) + + suite.EventsContains(res.Events, sdk.NewEvent( + bank.EventTypeTransfer, + sdk.NewAttribute(bank.AttributeKeyRecipient, swapModuleAccountAddress.String()), + sdk.NewAttribute(bank.AttributeKeySender, depositor.GetAddress().String()), + sdk.NewAttribute(sdk.AttributeKeyAmount, balance.String()), + )) + + suite.EventsContains(res.Events, sdk.NewEvent( + swap.EventTypeSwapDeposit, + sdk.NewAttribute(swap.AttributeKeyPoolID, swap.PoolID(pool.TokenA, pool.TokenB)), + sdk.NewAttribute(swap.AttributeKeyDepositor, depositor.GetAddress().String()), + sdk.NewAttribute(sdk.AttributeKeyAmount, balance.String()), + sdk.NewAttribute(swap.AttributeKeyShares, "22360679"), + )) +} + +func (suite *handlerTestSuite) TestDeposit_DeadlineExceeded() { + pool := swap.NewAllowedPool("ukava", "usdx") + suite.Require().NoError(pool.Validate()) + suite.Keeper.SetParams(suite.Ctx, swap.NewParams(swap.NewAllowedPools(pool), swap.DefaultSwapFee)) + + balance := sdk.NewCoins( + sdk.NewCoin(pool.TokenA, sdk.NewInt(10e6)), + sdk.NewCoin(pool.TokenB, sdk.NewInt(50e6)), + ) + depositor := suite.CreateAccount(balance) + + deposit := swap.NewMsgDeposit( + depositor.GetAddress(), + sdk.NewCoin(pool.TokenA, depositor.GetCoins().AmountOf(pool.TokenA)), + sdk.NewCoin(pool.TokenB, depositor.GetCoins().AmountOf(pool.TokenB)), + sdk.MustNewDecFromStr("0.01"), + suite.Ctx.BlockTime().Add(-1*time.Second).Unix(), + ) + + res, err := suite.handler(suite.Ctx, deposit) + suite.EqualError(err, fmt.Sprintf("deadline exceeded: block time %d >= deadline %d", suite.Ctx.BlockTime().Unix(), deposit.GetDeadline().Unix())) + suite.Nil(res) +} + +func (suite *handlerTestSuite) TestDeposit_ExistingPool() { + pool := swap.NewAllowedPool("ukava", "usdx") + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + sdk.NewCoin("usdx", sdk.NewInt(50e6)), + ) + err := suite.CreatePool(reserves) + suite.Require().NoError(err) + + balance := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(1e6)), + sdk.NewCoin("usdx", sdk.NewInt(5e6)), + ) + depositor := suite.NewAccountFromAddr(sdk.AccAddress("new depositor"), balance) + + deposit := swap.NewMsgDeposit( + depositor.GetAddress(), + sdk.NewCoin("usdx", depositor.GetCoins().AmountOf("usdx")), + sdk.NewCoin("ukava", depositor.GetCoins().AmountOf("ukava")), + sdk.MustNewDecFromStr("0.01"), + time.Now().Add(10*time.Minute).Unix(), + ) + + res, err := suite.handler(suite.Ctx, deposit) + suite.Require().NoError(err) + + expectedDeposit := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(1e6)), + sdk.NewCoin("usdx", sdk.NewInt(5e6)), + ) + + expectedShareValue := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(999999)), + sdk.NewCoin("usdx", sdk.NewInt(4999998)), + ) + + suite.AccountBalanceEqual(depositor, balance.Sub(expectedDeposit)) + suite.ModuleAccountBalanceEqual(reserves.Add(expectedDeposit...)) + suite.PoolLiquidityEqual(reserves.Add(expectedDeposit...)) + suite.PoolShareValueEqual(depositor, pool, expectedShareValue) + + suite.EventsContains(res.Events, sdk.NewEvent( + sdk.EventTypeMessage, + sdk.NewAttribute(sdk.AttributeKeyModule, swap.AttributeValueCategory), + sdk.NewAttribute(sdk.AttributeKeySender, depositor.GetAddress().String()), + )) + + suite.EventsContains(res.Events, sdk.NewEvent( + bank.EventTypeTransfer, + sdk.NewAttribute(bank.AttributeKeyRecipient, swapModuleAccountAddress.String()), + sdk.NewAttribute(bank.AttributeKeySender, depositor.GetAddress().String()), + sdk.NewAttribute(sdk.AttributeKeyAmount, expectedDeposit.String()), + )) + + suite.EventsContains(res.Events, sdk.NewEvent( + swap.EventTypeSwapDeposit, + sdk.NewAttribute(swap.AttributeKeyPoolID, swap.PoolID(pool.TokenA, pool.TokenB)), + sdk.NewAttribute(swap.AttributeKeyDepositor, depositor.GetAddress().String()), + sdk.NewAttribute(sdk.AttributeKeyAmount, expectedDeposit.String()), + sdk.NewAttribute(swap.AttributeKeyShares, "2236067"), + )) +} + +func (suite *handlerTestSuite) TestDeposit_ExistingPool_SlippageFailure() { + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + sdk.NewCoin("usdx", sdk.NewInt(50e6)), + ) + err := suite.CreatePool(reserves) + suite.Require().NoError(err) + + balance := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(5e6)), + sdk.NewCoin("usdx", sdk.NewInt(5e6)), + ) + depositor := suite.CreateAccount(balance) + + deposit := swap.NewMsgDeposit( + depositor.GetAddress(), + sdk.NewCoin("usdx", depositor.GetCoins().AmountOf("usdx")), + sdk.NewCoin("ukava", depositor.GetCoins().AmountOf("ukava")), + sdk.MustNewDecFromStr("0.01"), + time.Now().Add(10*time.Minute).Unix(), + ) + + res, err := suite.handler(suite.Ctx, deposit) + suite.EqualError(err, "slippage exceeded: slippage 4.000000000000000000 > limit 0.010000000000000000") + suite.Nil(res) +} + +func (suite *handlerTestSuite) TestWithdraw_AllShares() { + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + sdk.NewCoin("usdx", sdk.NewInt(50e6)), + ) + depositor := suite.CreateAccount(reserves) + pool := swap.NewAllowedPool(reserves[0].Denom, reserves[1].Denom) + suite.Require().NoError(pool.Validate()) + suite.Keeper.SetParams(suite.Ctx, swap.NewParams(swap.NewAllowedPools(pool), swap.DefaultSwapFee)) + + err := suite.Keeper.Deposit(suite.Ctx, depositor.GetAddress(), reserves[0], reserves[1], sdk.MustNewDecFromStr("1")) + suite.Require().NoError(err) + + withdraw := swap.NewMsgWithdraw( + depositor.GetAddress(), + sdk.NewInt(22360679), + reserves[0], + reserves[1], + time.Now().Add(10*time.Minute).Unix(), + ) + + ctx := suite.App.NewContext(true, abci.Header{Height: 1, Time: tmtime.Now()}) + res, err := suite.handler(ctx, withdraw) + suite.Require().NoError(err) + + suite.AccountBalanceEqual(depositor, reserves) + suite.ModuleAccountBalanceEqual(sdk.Coins(nil)) + suite.PoolDeleted("ukava", "usdx") + suite.PoolSharesDeleted(depositor.GetAddress(), "ukava", "usdx") + + suite.EventsContains(res.Events, sdk.NewEvent( + sdk.EventTypeMessage, + sdk.NewAttribute(sdk.AttributeKeyModule, swap.AttributeValueCategory), + sdk.NewAttribute(sdk.AttributeKeySender, depositor.GetAddress().String()), + )) + + suite.EventsContains(res.Events, sdk.NewEvent( + bank.EventTypeTransfer, + sdk.NewAttribute(bank.AttributeKeyRecipient, depositor.GetAddress().String()), + sdk.NewAttribute(bank.AttributeKeySender, swapModuleAccountAddress.String()), + sdk.NewAttribute(sdk.AttributeKeyAmount, reserves.String()), + )) + + suite.EventsContains(res.Events, sdk.NewEvent( + swap.EventTypeSwapWithdraw, + sdk.NewAttribute(swap.AttributeKeyPoolID, swap.PoolID(pool.TokenA, pool.TokenB)), + sdk.NewAttribute(swap.AttributeKeyOwner, depositor.GetAddress().String()), + sdk.NewAttribute(sdk.AttributeKeyAmount, reserves.String()), + sdk.NewAttribute(swap.AttributeKeyShares, "22360679"), + )) +} + +func (suite *handlerTestSuite) TestWithdraw_PartialShares() { + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + sdk.NewCoin("usdx", sdk.NewInt(50e6)), + ) + depositor := suite.CreateAccount(reserves) + pool := swap.NewAllowedPool(reserves[0].Denom, reserves[1].Denom) + suite.Require().NoError(pool.Validate()) + suite.Keeper.SetParams(suite.Ctx, swap.NewParams(swap.NewAllowedPools(pool), swap.DefaultSwapFee)) + + err := suite.Keeper.Deposit(suite.Ctx, depositor.GetAddress(), reserves[0], reserves[1], sdk.MustNewDecFromStr("1")) + suite.Require().NoError(err) + + minTokenA := sdk.NewCoin("ukava", sdk.NewInt(4999999)) + minTokenB := sdk.NewCoin("usdx", sdk.NewInt(24999998)) + + withdraw := swap.NewMsgWithdraw( + depositor.GetAddress(), + sdk.NewInt(11180339), + minTokenA, + minTokenB, + time.Now().Add(10*time.Minute).Unix(), + ) + + ctx := suite.App.NewContext(true, abci.Header{Height: 1, Time: tmtime.Now()}) + res, err := suite.handler(ctx, withdraw) + suite.Require().NoError(err) + + expectedCoinsReceived := sdk.NewCoins(minTokenA, minTokenB) + + suite.AccountBalanceEqual(depositor, expectedCoinsReceived) + suite.ModuleAccountBalanceEqual(reserves.Sub(expectedCoinsReceived)) + suite.PoolLiquidityEqual(reserves.Sub(expectedCoinsReceived)) + suite.PoolShareValueEqual(depositor, swap.NewAllowedPool("ukava", "usdx"), reserves.Sub(expectedCoinsReceived)) + + suite.EventsContains(res.Events, sdk.NewEvent( + sdk.EventTypeMessage, + sdk.NewAttribute(sdk.AttributeKeyModule, swap.AttributeValueCategory), + sdk.NewAttribute(sdk.AttributeKeySender, depositor.GetAddress().String()), + )) + + suite.EventsContains(res.Events, sdk.NewEvent( + bank.EventTypeTransfer, + sdk.NewAttribute(bank.AttributeKeyRecipient, depositor.GetAddress().String()), + sdk.NewAttribute(bank.AttributeKeySender, swapModuleAccountAddress.String()), + sdk.NewAttribute(sdk.AttributeKeyAmount, expectedCoinsReceived.String()), + )) + + suite.EventsContains(res.Events, sdk.NewEvent( + swap.EventTypeSwapWithdraw, + sdk.NewAttribute(swap.AttributeKeyPoolID, swap.PoolID(pool.TokenA, pool.TokenB)), + sdk.NewAttribute(swap.AttributeKeyOwner, depositor.GetAddress().String()), + sdk.NewAttribute(sdk.AttributeKeyAmount, expectedCoinsReceived.String()), + sdk.NewAttribute(swap.AttributeKeyShares, "11180339"), + )) +} + +func (suite *handlerTestSuite) TestWithdraw_SlippageFailure() { + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + sdk.NewCoin("usdx", sdk.NewInt(50e6)), + ) + depositor := suite.CreateAccount(reserves) + pool := swap.NewAllowedPool(reserves[0].Denom, reserves[1].Denom) + suite.Require().NoError(pool.Validate()) + suite.Keeper.SetParams(suite.Ctx, swap.NewParams(swap.NewAllowedPools(pool), swap.DefaultSwapFee)) + + err := suite.Keeper.Deposit(suite.Ctx, depositor.GetAddress(), reserves[0], reserves[1], sdk.MustNewDecFromStr("1")) + suite.Require().NoError(err) + + minTokenA := sdk.NewCoin("ukava", sdk.NewInt(5e6)) + minTokenB := sdk.NewCoin("usdx", sdk.NewInt(25e6)) + + withdraw := swap.NewMsgWithdraw( + depositor.GetAddress(), + sdk.NewInt(11180339), + minTokenA, + minTokenB, + time.Now().Add(10*time.Minute).Unix(), + ) + + res, err := suite.handler(suite.Ctx, withdraw) + suite.EqualError(err, "slippage exceeded: minimum withdraw not met") + suite.Nil(res) +} + +func (suite *handlerTestSuite) TestWithdraw_DeadlineExceeded() { + balance := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + sdk.NewCoin("usdx", sdk.NewInt(50e6)), + ) + from := suite.CreateAccount(balance) + + withdraw := swap.NewMsgWithdraw( + from.GetAddress(), + sdk.NewInt(2e6), + sdk.NewCoin("ukava", sdk.NewInt(1e6)), + sdk.NewCoin("usdx", sdk.NewInt(5e6)), + suite.Ctx.BlockTime().Add(-1*time.Second).Unix(), + ) + + res, err := suite.handler(suite.Ctx, withdraw) + suite.EqualError(err, fmt.Sprintf("deadline exceeded: block time %d >= deadline %d", suite.Ctx.BlockTime().Unix(), withdraw.GetDeadline().Unix())) + suite.Nil(res) +} + +func (suite *handlerTestSuite) TestInvalidMsg() { + res, err := suite.handler(suite.Ctx, sdk.NewTestMsg()) + suite.Nil(res) + suite.EqualError(err, "unknown request: unrecognized swap message type: *types.TestMsg") +} + +func TestHandlerTestSuite(t *testing.T) { + suite.Run(t, new(handlerTestSuite)) +} diff --git a/x/swap/keeper/deposit.go b/x/swap/keeper/deposit.go new file mode 100644 index 00000000..a76fa991 --- /dev/null +++ b/x/swap/keeper/deposit.go @@ -0,0 +1,145 @@ +package keeper + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + "github.com/kava-labs/kava/x/swap/types" +) + +// Deposit creates a new pool or adds liquidity to an existing pool. For a pool to be created, a pool +// for the coin denominations must not exist yet, and it must be allowed by the swap module parameters. +// +// When adding liquidity to an existing pool, the provided coins are considered to be the desired deposit +// amount, and the actual deposited coins may be less than or equal to the provided coins. A deposit +// will never be exceed the coinA and coinB amounts. +// +// The slippage is calculated using both the price and inverse price of the provided coinA and coinB. +// Since adding liquidity is not directional, like a swap would be, using both the price (coinB/coinA), +// and the inverse price (coinA/coinB), protects the depositor from a large deviation in their deposit. +// +// The amount deposited may only change by B' < B or A' < A -- either B depreciates, or A depreciates. +// Therefore, slippage can be written as a function of this depreciation d. Where the new price is +// B*(1-d)/A or A*(1-d)/B, and the inverse of each, and is A/(B*(1-d)) and B/(A*(1-d)) +// respectively. +// +// Since 1/(1-d) >= (1-d) for d <= 1, the maximum slippage is always in the appreciating price +// A/(B*(1-d)) and B/(A*(1-d)). In other words, when the price of an asset depreciates, the +// inverse price -- or the price of the other pool asset, appreciates by a larger amount. +// It's this percent change we calculate and compare to the slippage limit provided. +// +// For example, if we have a pool with 100e6 ukava and 400e6 usdx. The ukava price is 4 usdx and the +// usdx price is 0.25 ukava. If a depositor adds liquidity of 4e6 ukava and 14e6 usdx, a kava price of +// 3.50 usdx and a usdx price of 0.29 ukava. This is a -12.5% slippage is the ukava price, and a 14.3% +// slippage in the usdx price. +// +// These slippages can be calculated by S_B = ((A/B')/(A/B) - 1) and S_A ((B/A')/(B/A) - 1), simplifying to +// S_B = (A/A' - 1), and S_B = (B/B' - 1). An error is returned when max(S_A, S_B) > slippageLimit. +func (k Keeper) Deposit(ctx sdk.Context, depositor sdk.AccAddress, coinA sdk.Coin, coinB sdk.Coin, slippageLimit sdk.Dec) error { + desiredAmount := sdk.NewCoins(coinA, coinB) + + poolID := types.PoolIDFromCoins(desiredAmount) + poolRecord, found := k.GetPool(ctx, poolID) + + var ( + depositAmount sdk.Coins + shares sdk.Int + err error + ) + if found { + depositAmount, shares, err = k.addLiquidityToPool(ctx, poolRecord, depositor, desiredAmount) + } else { + depositAmount, shares, err = k.initializePool(ctx, poolID, depositor, desiredAmount) + } + if err != nil { + return err + } + + if depositAmount.AmountOf(coinA.Denom).IsZero() || depositAmount.AmountOf(coinB.Denom).IsZero() { + return sdkerrors.Wrap(types.ErrInsufficientLiquidity, "deposit must be increased") + } + + if shares.IsZero() { + return sdkerrors.Wrap(types.ErrInsufficientLiquidity, "deposit must be increased") + } + + maxPercentPriceChange := sdk.MaxDec( + desiredAmount.AmountOf(coinA.Denom).ToDec().Quo(depositAmount.AmountOf(coinA.Denom).ToDec()), + desiredAmount.AmountOf(coinB.Denom).ToDec().Quo(depositAmount.AmountOf(coinB.Denom).ToDec()), + ) + slippage := maxPercentPriceChange.Sub(sdk.OneDec()) + + if slippage.GT(slippageLimit) { + return sdkerrors.Wrapf(types.ErrSlippageExceeded, "slippage %s > limit %s", slippage, slippageLimit) + } + + err = k.supplyKeeper.SendCoinsFromAccountToModule(ctx, depositor, types.ModuleAccountName, depositAmount) + if err != nil { + return err + } + + ctx.EventManager().EmitEvent( + sdk.NewEvent( + types.EventTypeSwapDeposit, + sdk.NewAttribute(types.AttributeKeyPoolID, poolID), + sdk.NewAttribute(types.AttributeKeyDepositor, depositor.String()), + sdk.NewAttribute(sdk.AttributeKeyAmount, depositAmount.String()), + sdk.NewAttribute(types.AttributeKeyShares, shares.String()), + ), + ) + + return nil +} + +func (k Keeper) depositAllowed(ctx sdk.Context, poolID string) bool { + params := k.GetParams(ctx) + for _, p := range params.AllowedPools { + if poolID == types.PoolID(p.TokenA, p.TokenB) { + return true + } + } + return false +} + +func (k Keeper) initializePool(ctx sdk.Context, poolID string, depositor sdk.AccAddress, reserves sdk.Coins) (sdk.Coins, sdk.Int, error) { + if allowed := k.depositAllowed(ctx, poolID); !allowed { + return sdk.Coins{}, sdk.ZeroInt(), sdkerrors.Wrap(types.ErrNotAllowed, fmt.Sprintf("can not create pool '%s'", poolID)) + } + + pool, err := types.NewDenominatedPool(reserves) + if err != nil { + return sdk.Coins{}, sdk.ZeroInt(), err + } + + poolRecord := types.NewPoolRecord(pool) + shareRecord := types.NewShareRecord(depositor, poolRecord.PoolID, pool.TotalShares()) + + k.SetPool(ctx, poolRecord) + k.SetDepositorShares(ctx, shareRecord) + + return pool.Reserves(), pool.TotalShares(), nil +} + +func (k Keeper) addLiquidityToPool(ctx sdk.Context, record types.PoolRecord, depositor sdk.AccAddress, desiredAmount sdk.Coins) (sdk.Coins, sdk.Int, error) { + pool, err := types.NewDenominatedPoolWithExistingShares(record.Reserves(), record.TotalShares) + if err != nil { + return sdk.Coins{}, sdk.ZeroInt(), err + } + + depositAmount, shares := pool.AddLiquidity(desiredAmount) + + poolRecord := types.NewPoolRecord(pool) + + shareRecord, found := k.GetDepositorShares(ctx, depositor, poolRecord.PoolID) + if found { + shareRecord.SharesOwned = shareRecord.SharesOwned.Add(shares) + } else { + shareRecord = types.NewShareRecord(depositor, poolRecord.PoolID, shares) + } + + k.SetPool(ctx, poolRecord) + k.SetDepositorShares(ctx, shareRecord) + + return depositAmount, shares, nil +} diff --git a/x/swap/keeper/deposit_test.go b/x/swap/keeper/deposit_test.go new file mode 100644 index 00000000..a65fa2e8 --- /dev/null +++ b/x/swap/keeper/deposit_test.go @@ -0,0 +1,343 @@ +package keeper_test + +import ( + "errors" + "fmt" + + "github.com/kava-labs/kava/x/swap/types" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + abci "github.com/tendermint/tendermint/abci/types" + tmtime "github.com/tendermint/tendermint/types/time" +) + +func (suite *keeperTestSuite) TestDeposit_CreatePool_PoolNotAllowed() { + depositor := suite.CreateAccount(sdk.Coins{}) + amountA := sdk.NewCoin("ukava", sdk.NewInt(10e6)) + amountB := sdk.NewCoin("usdx", sdk.NewInt(50e6)) + + err := suite.Keeper.Deposit(suite.Ctx, depositor.GetAddress(), amountA, amountB, sdk.MustNewDecFromStr("0.01")) + suite.Require().EqualError(err, "not allowed: can not create pool 'ukava/usdx'") +} + +func (suite *keeperTestSuite) TestDeposit_InsufficientFunds() { + testCases := []struct { + name string + balanceA sdk.Coin + balanceB sdk.Coin + depositA sdk.Coin + depositB sdk.Coin + }{ + { + name: "no balance", + balanceA: sdk.Coin{}, + balanceB: sdk.Coin{}, + depositA: sdk.NewCoin("ukava", sdk.NewInt(100)), + depositB: sdk.NewCoin("usdx", sdk.NewInt(100)), + }, + { + name: "low balance", + balanceA: sdk.NewCoin("ukava", sdk.NewInt(1000000)), + balanceB: sdk.NewCoin("usdx", sdk.NewInt(1000000)), + depositA: sdk.NewCoin("ukava", sdk.NewInt(1000001)), + depositB: sdk.NewCoin("usdx", sdk.NewInt(10000001)), + }, + { + name: "large balance difference", + balanceA: sdk.NewCoin("ukava", sdk.NewInt(100e6)), + balanceB: sdk.NewCoin("usdx", sdk.NewInt(500e6)), + depositA: sdk.NewCoin("ukava", sdk.NewInt(1000e6)), + depositB: sdk.NewCoin("usdx", sdk.NewInt(5000e6)), + }, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + suite.SetupTest() + + pool := types.NewAllowedPool(tc.depositA.Denom, tc.depositB.Denom) + suite.Require().NoError(pool.Validate()) + suite.Keeper.SetParams(suite.Ctx, types.NewParams(types.NewAllowedPools(pool), types.DefaultSwapFee)) + + balance := sdk.Coins{tc.balanceA, tc.balanceB} + balance.Sort() + depositor := suite.CreateAccount(balance) + + err := suite.Keeper.Deposit(suite.Ctx, depositor.GetAddress(), tc.depositA, tc.depositB, sdk.MustNewDecFromStr("0")) + // TODO: wrap in module specific error? + suite.Require().True(errors.Is(err, sdkerrors.ErrInsufficientFunds), fmt.Sprintf("got err %s", err)) + + suite.SetupTest() + // test deposit to existing pool insuffient funds + err = suite.CreatePool(sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(10e6)), sdk.NewCoin("usdx", sdk.NewInt(50e6)))) + suite.Require().NoError(err) + err = suite.Keeper.Deposit(suite.Ctx, depositor.GetAddress(), tc.depositA, tc.depositB, sdk.MustNewDecFromStr("10")) + suite.Require().True(errors.Is(err, sdkerrors.ErrInsufficientFunds)) + }) + } +} + +func (suite *keeperTestSuite) TestDeposit_InsufficientFunds_Vesting() { + testCases := []struct { + name string + balanceA sdk.Coin + balanceB sdk.Coin + vestingA sdk.Coin + vestingB sdk.Coin + depositA sdk.Coin + depositB sdk.Coin + }{ + { + name: "no balance, vesting only", + balanceA: sdk.Coin{}, + balanceB: sdk.Coin{}, + vestingA: sdk.NewCoin("ukava", sdk.NewInt(100)), + vestingB: sdk.NewCoin("ukava", sdk.NewInt(100)), + depositA: sdk.NewCoin("ukava", sdk.NewInt(100)), + depositB: sdk.NewCoin("usdx", sdk.NewInt(100)), + }, + { + name: "vesting matches balance exactly", + balanceA: sdk.NewCoin("ukava", sdk.NewInt(1000000)), + balanceB: sdk.NewCoin("usdx", sdk.NewInt(1000000)), + vestingA: sdk.NewCoin("ukava", sdk.NewInt(1)), + vestingB: sdk.NewCoin("usdx", sdk.NewInt(1)), + depositA: sdk.NewCoin("ukava", sdk.NewInt(1000001)), + depositB: sdk.NewCoin("usdx", sdk.NewInt(10000001)), + }, + { + name: "large balance difference, vesting covers difference", + balanceA: sdk.NewCoin("ukava", sdk.NewInt(100e6)), + balanceB: sdk.NewCoin("usdx", sdk.NewInt(500e6)), + vestingA: sdk.NewCoin("ukava", sdk.NewInt(1000e6)), + vestingB: sdk.NewCoin("usdx", sdk.NewInt(5000e6)), + depositA: sdk.NewCoin("ukava", sdk.NewInt(1000e6)), + depositB: sdk.NewCoin("usdx", sdk.NewInt(5000e6)), + }, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + suite.SetupTest() + + pool := types.NewAllowedPool(tc.depositA.Denom, tc.depositB.Denom) + suite.Require().NoError(pool.Validate()) + suite.Keeper.SetParams(suite.Ctx, types.NewParams(types.NewAllowedPools(pool), types.DefaultSwapFee)) + + balance := sdk.Coins{tc.balanceA, tc.balanceB} + balance.Sort() + vesting := sdk.Coins{tc.vestingA, tc.vestingB} + vesting.Sort() + depositor := suite.CreateVestingAccount(balance, vesting) + + // test create pool insuffient funds + err := suite.Keeper.Deposit(suite.Ctx, depositor.GetAddress(), tc.depositA, tc.depositB, sdk.MustNewDecFromStr("0")) + // TODO: wrap in module specific error? + suite.Require().True(errors.Is(err, sdkerrors.ErrInsufficientFunds)) + + suite.SetupTest() + // test deposit to existing pool insuffient funds + err = suite.CreatePool(sdk.NewCoins(sdk.NewCoin("ukava", sdk.NewInt(10e6)), sdk.NewCoin("usdx", sdk.NewInt(50e6)))) + suite.Require().NoError(err) + err = suite.Keeper.Deposit(suite.Ctx, depositor.GetAddress(), tc.depositA, tc.depositB, sdk.MustNewDecFromStr("4")) + suite.Require().True(errors.Is(err, sdkerrors.ErrInsufficientFunds)) + }) + } +} + +func (suite *keeperTestSuite) TestDeposit_CreatePool() { + pool := types.NewAllowedPool("ukava", "usdx") + suite.Require().NoError(pool.Validate()) + suite.Keeper.SetParams(suite.Ctx, types.NewParams(types.NewAllowedPools(pool), types.DefaultSwapFee)) + + amountA := sdk.NewCoin(pool.TokenA, sdk.NewInt(11e6)) + amountB := sdk.NewCoin(pool.TokenB, sdk.NewInt(51e6)) + balance := sdk.NewCoins(amountA, amountB) + depositor := suite.CreateAccount(balance) + + depositA := sdk.NewCoin(pool.TokenA, sdk.NewInt(10e6)) + depositB := sdk.NewCoin(pool.TokenB, sdk.NewInt(50e6)) + deposit := sdk.NewCoins(depositA, depositB) + + err := suite.Keeper.Deposit(suite.Ctx, depositor.GetAddress(), depositA, depositB, sdk.MustNewDecFromStr("0")) + suite.Require().NoError(err) + suite.AccountBalanceEqual(depositor, sdk.NewCoins(amountA.Sub(depositA), amountB.Sub(depositB))) + suite.ModuleAccountBalanceEqual(sdk.NewCoins(depositA, depositB)) + suite.PoolLiquidityEqual(deposit) + suite.PoolShareValueEqual(depositor, pool, deposit) + + suite.EventsContains(suite.Ctx.EventManager().Events(), sdk.NewEvent( + types.EventTypeSwapDeposit, + sdk.NewAttribute(types.AttributeKeyPoolID, pool.Name()), + sdk.NewAttribute(types.AttributeKeyDepositor, depositor.GetAddress().String()), + sdk.NewAttribute(sdk.AttributeKeyAmount, deposit.String()), + sdk.NewAttribute(types.AttributeKeyShares, "22360679"), + )) +} + +func (suite *keeperTestSuite) TestDeposit_PoolExists() { + pool := types.NewAllowedPool("ukava", "usdx") + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + sdk.NewCoin("usdx", sdk.NewInt(50e6)), + ) + err := suite.CreatePool(reserves) + suite.Require().NoError(err) + + balance := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(5e6)), + sdk.NewCoin("usdx", sdk.NewInt(5e6)), + ) + depositor := suite.NewAccountFromAddr(sdk.AccAddress("new depositor"), balance) + + depositA := sdk.NewCoin("usdx", depositor.GetCoins().AmountOf("usdx")) + depositB := sdk.NewCoin("ukava", depositor.GetCoins().AmountOf("ukava")) + + ctx := suite.App.NewContext(true, abci.Header{Height: 1, Time: tmtime.Now()}) + err = suite.Keeper.Deposit(ctx, depositor.GetAddress(), depositA, depositB, sdk.MustNewDecFromStr("4")) + suite.Require().NoError(err) + + expectedDeposit := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(1e6)), + sdk.NewCoin("usdx", sdk.NewInt(5e6)), + ) + + expectedShareValue := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(999999)), + sdk.NewCoin("usdx", sdk.NewInt(4999998)), + ) + + suite.AccountBalanceEqual(depositor, balance.Sub(expectedDeposit)) + suite.ModuleAccountBalanceEqual(reserves.Add(expectedDeposit...)) + suite.PoolLiquidityEqual(reserves.Add(expectedDeposit...)) + suite.PoolShareValueEqual(depositor, pool, expectedShareValue) + + suite.EventsContains(ctx.EventManager().Events(), sdk.NewEvent( + types.EventTypeSwapDeposit, + sdk.NewAttribute(types.AttributeKeyPoolID, types.PoolID(pool.TokenA, pool.TokenB)), + sdk.NewAttribute(types.AttributeKeyDepositor, depositor.GetAddress().String()), + sdk.NewAttribute(sdk.AttributeKeyAmount, expectedDeposit.String()), + sdk.NewAttribute(types.AttributeKeyShares, "2236067"), + )) +} + +func (suite *keeperTestSuite) TestDeposit_MultipleDeposit() { + fundsToDeposit := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(5e6)), + sdk.NewCoin("usdx", sdk.NewInt(25e6)), + ) + owner := suite.CreateAccount(fundsToDeposit) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + sdk.NewCoin("usdx", sdk.NewInt(50e6)), + ) + initialShares := sdk.NewInt(30e6) + poolID := suite.setupPool(reserves, initialShares, owner.GetAddress()) + + depositA := sdk.NewCoin("usdx", owner.GetCoins().AmountOf("usdx")) + depositB := sdk.NewCoin("ukava", owner.GetCoins().AmountOf("ukava")) + + err := suite.Keeper.Deposit(suite.Ctx, owner.GetAddress(), depositA, depositB, sdk.MustNewDecFromStr("4")) + suite.Require().NoError(err) + + totalDeposit := reserves.Add(fundsToDeposit...) + totalShares := initialShares.Add(sdk.NewInt(15e6)) + + suite.AccountBalanceEqual(owner, sdk.Coins(nil)) + suite.ModuleAccountBalanceEqual(totalDeposit) + suite.PoolLiquidityEqual(totalDeposit) + suite.PoolDepositorSharesEqual(owner.GetAddress(), poolID, totalShares) + + suite.EventsContains(suite.Ctx.EventManager().Events(), sdk.NewEvent( + types.EventTypeSwapDeposit, + sdk.NewAttribute(types.AttributeKeyPoolID, poolID), + sdk.NewAttribute(types.AttributeKeyDepositor, owner.GetAddress().String()), + sdk.NewAttribute(sdk.AttributeKeyAmount, fundsToDeposit.String()), + sdk.NewAttribute(types.AttributeKeyShares, "15000000"), + )) +} + +func (suite *keeperTestSuite) TestDeposit_Slippage() { + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + sdk.NewCoin("usdx", sdk.NewInt(50e6)), + ) + + testCases := []struct { + depositA sdk.Coin + depositB sdk.Coin + slippage sdk.Dec + shouldFail bool + }{ + {sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.NewCoin("ukava", sdk.NewInt(5e6)), sdk.MustNewDecFromStr("0.7"), true}, + {sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.NewCoin("ukava", sdk.NewInt(5e6)), sdk.MustNewDecFromStr("0.8"), true}, + {sdk.NewCoin("ukava", sdk.NewInt(5e6)), sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.MustNewDecFromStr("3"), true}, + {sdk.NewCoin("ukava", sdk.NewInt(5e6)), sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.MustNewDecFromStr("4"), false}, + {sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.MustNewDecFromStr("0"), false}, + {sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.NewCoin("usdx", sdk.NewInt(4e6)), sdk.MustNewDecFromStr("0.25"), false}, + {sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.NewCoin("usdx", sdk.NewInt(4e6)), sdk.MustNewDecFromStr("0.2"), true}, + } + + for _, tc := range testCases { + suite.Run(fmt.Sprintf("depositA=%s depositB=%s slippage=%s", tc.depositA, tc.depositB, tc.slippage), func() { + suite.SetupTest() + + err := suite.CreatePool(reserves) + suite.Require().NoError(err) + + balance := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(100e6)), + sdk.NewCoin("usdx", sdk.NewInt(100e6)), + ) + depositor := suite.CreateAccount(balance) + + ctx := suite.App.NewContext(true, abci.Header{Height: 1, Time: tmtime.Now()}) + + err = suite.Keeper.Deposit(ctx, depositor.GetAddress(), tc.depositA, tc.depositB, tc.slippage) + if tc.shouldFail { + suite.Require().Error(err) + suite.Contains(err.Error(), "slippage exceeded") + } else { + suite.NoError(err) + } + }) + } +} + +func (suite *keeperTestSuite) TestDeposit_InsufficientLiquidity() { + testCases := []struct { + poolA sdk.Coin + poolB sdk.Coin + poolShares sdk.Int + depositA sdk.Coin + depositB sdk.Coin + }{ + // test deposit amount truncating to zero + {sdk.NewCoin("ukava", sdk.NewInt(10e6)), sdk.NewCoin("usdx", sdk.NewInt(50e6)), sdk.NewInt(40e6), sdk.NewCoin("ukava", sdk.NewInt(1)), sdk.NewCoin("usdx", sdk.NewInt(1))}, + // test share value rounding to zero + {sdk.NewCoin("ukava", sdk.NewInt(10e6)), sdk.NewCoin("usdx", sdk.NewInt(10e6)), sdk.NewInt(100), sdk.NewCoin("ukava", sdk.NewInt(1000)), sdk.NewCoin("usdx", sdk.NewInt(1000))}, + } + + for _, tc := range testCases { + suite.Run(fmt.Sprintf("depositA=%s depositB=%s", tc.depositA, tc.depositB), func() { + suite.SetupTest() + + record := types.PoolRecord{ + PoolID: "ukava/usdx", + ReservesA: tc.poolA, + ReservesB: tc.poolB, + TotalShares: tc.poolShares, + } + + suite.Keeper.SetPool(suite.Ctx, record) + + balance := sdk.Coins{tc.depositA, tc.depositB} + balance.Sort() + depositor := suite.CreateAccount(balance) + + err := suite.Keeper.Deposit(suite.Ctx, depositor.GetAddress(), tc.depositA, tc.depositB, sdk.MustNewDecFromStr("10")) + suite.EqualError(err, "insufficient liquidity: deposit must be increased") + }) + } +} diff --git a/x/swap/keeper/integration_test.go b/x/swap/keeper/integration_test.go index 4f551d3d..938a6749 100644 --- a/x/swap/keeper/integration_test.go +++ b/x/swap/keeper/integration_test.go @@ -9,9 +9,14 @@ import ( "github.com/kava-labs/kava/x/swap/types" ) -func i(in int64) sdk.Int { return sdk.NewInt(in) } +//nolint +func i(in int64) sdk.Int { return sdk.NewInt(in) } + +//nolint func c(denom string, amount int64) sdk.Coin { return sdk.NewInt64Coin(denom, amount) } -func cs(coins ...sdk.Coin) sdk.Coins { return sdk.NewCoins(coins...) } + +//nolint +func cs(coins ...sdk.Coin) sdk.Coins { return sdk.NewCoins(coins...) } func NewAuthGenStateFromAccs(accounts ...authexported.GenesisAccount) app.GenesisState { authGenesis := auth.NewGenesisState(auth.DefaultParams(), accounts) diff --git a/x/swap/keeper/keeper.go b/x/swap/keeper/keeper.go index 3175be5a..eb85851d 100644 --- a/x/swap/keeper/keeper.go +++ b/x/swap/keeper/keeper.go @@ -1,11 +1,12 @@ package keeper import ( + "github.com/kava-labs/kava/x/swap/types" + "github.com/cosmos/cosmos-sdk/codec" + "github.com/cosmos/cosmos-sdk/store/prefix" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/cosmos/cosmos-sdk/x/params/subspace" - - "github.com/kava-labs/kava/x/swap/types" ) // Keeper keeper for the swap module @@ -13,10 +14,18 @@ type Keeper struct { key sdk.StoreKey cdc *codec.Codec paramSubspace subspace.Subspace + accountKeeper types.AccountKeeper + supplyKeeper types.SupplyKeeper } // NewKeeper creates a new keeper -func NewKeeper(cdc *codec.Codec, key sdk.StoreKey, paramstore subspace.Subspace) Keeper { +func NewKeeper( + cdc *codec.Codec, + key sdk.StoreKey, + paramstore subspace.Subspace, + accountKeeper types.AccountKeeper, + supplyKeeper types.SupplyKeeper, +) Keeper { if !paramstore.HasKeyTable() { paramstore = paramstore.WithKeyTable(types.ParamKeyTable()) } @@ -25,5 +34,141 @@ func NewKeeper(cdc *codec.Codec, key sdk.StoreKey, paramstore subspace.Subspace) key: key, cdc: cdc, paramSubspace: paramstore, + accountKeeper: accountKeeper, + supplyKeeper: supplyKeeper, } } + +// GetParams returns the params from the store +func (k Keeper) GetParams(ctx sdk.Context) types.Params { + var p types.Params + k.paramSubspace.GetParamSet(ctx, &p) + return p +} + +// SetParams sets params on the store +func (k Keeper) SetParams(ctx sdk.Context, params types.Params) { + k.paramSubspace.SetParamSet(ctx, ¶ms) +} + +// GetPool retrieves a pool record from the store +func (k Keeper) GetPool(ctx sdk.Context, poolID string) (types.PoolRecord, bool) { + store := prefix.NewStore(ctx.KVStore(k.key), types.PoolKeyPrefix) + + bz := store.Get(types.PoolKey(poolID)) + if bz == nil { + return types.PoolRecord{}, false + } + + var record types.PoolRecord + k.cdc.MustUnmarshalBinaryLengthPrefixed(bz, &record) + + return record, true +} + +// SetPool saves a pool record to the store +func (k Keeper) SetPool(ctx sdk.Context, record types.PoolRecord) { + store := prefix.NewStore(ctx.KVStore(k.key), types.PoolKeyPrefix) + bz := k.cdc.MustMarshalBinaryLengthPrefixed(record) + store.Set(types.PoolKey(record.PoolID), bz) +} + +// DeletePool deletes a pool record from the store +func (k Keeper) DeletePool(ctx sdk.Context, poolID string) { + store := prefix.NewStore(ctx.KVStore(k.key), types.PoolKeyPrefix) + store.Delete(types.PoolKey(poolID)) +} + +// IteratePools iterates over all pool objects in the store and performs a callback function +func (k Keeper) IteratePools(ctx sdk.Context, cb func(record types.PoolRecord) (stop bool)) { + store := prefix.NewStore(ctx.KVStore(k.key), types.PoolKeyPrefix) + iterator := sdk.KVStorePrefixIterator(store, []byte{}) + defer iterator.Close() + for ; iterator.Valid(); iterator.Next() { + var record types.PoolRecord + k.cdc.MustUnmarshalBinaryLengthPrefixed(iterator.Value(), &record) + if cb(record) { + break + } + } +} + +// GetAllPools returns all pool records from the store +func (k Keeper) GetAllPools(ctx sdk.Context) (records types.PoolRecords) { + k.IteratePools(ctx, func(record types.PoolRecord) bool { + records = append(records, record) + return false + }) + return +} + +// GetDepositorShares gets a share record from the store +func (k Keeper) GetDepositorShares(ctx sdk.Context, depositor sdk.AccAddress, poolID string) (types.ShareRecord, bool) { + store := prefix.NewStore(ctx.KVStore(k.key), types.DepositorPoolSharesPrefix) + bz := store.Get(types.DepositorPoolSharesKey(depositor, poolID)) + if bz == nil { + return types.ShareRecord{}, false + } + var record types.ShareRecord + k.cdc.MustUnmarshalBinaryLengthPrefixed(bz, &record) + return record, true +} + +// SetDepositorShares saves a share record to the store +func (k Keeper) SetDepositorShares(ctx sdk.Context, record types.ShareRecord) { + store := prefix.NewStore(ctx.KVStore(k.key), types.DepositorPoolSharesPrefix) + bz := k.cdc.MustMarshalBinaryLengthPrefixed(record) + store.Set(types.DepositorPoolSharesKey(record.Depositor, record.PoolID), bz) +} + +// DeleteDepositorShares deletes a share record from the store +func (k Keeper) DeleteDepositorShares(ctx sdk.Context, depositor sdk.AccAddress, poolID string) { + store := prefix.NewStore(ctx.KVStore(k.key), types.DepositorPoolSharesPrefix) + store.Delete(types.DepositorPoolSharesKey(depositor, poolID)) +} + +// IterateDepositorShares iterates over all pool objects in the store and performs a callback function +func (k Keeper) IterateDepositorShares(ctx sdk.Context, cb func(record types.ShareRecord) (stop bool)) { + store := prefix.NewStore(ctx.KVStore(k.key), types.DepositorPoolSharesPrefix) + iterator := sdk.KVStorePrefixIterator(store, []byte{}) + defer iterator.Close() + for ; iterator.Valid(); iterator.Next() { + var record types.ShareRecord + k.cdc.MustUnmarshalBinaryLengthPrefixed(iterator.Value(), &record) + if cb(record) { + break + } + } +} + +// GetAllDepositorShares returns all depositor share records from the store +func (k Keeper) GetAllDepositorShares(ctx sdk.Context) (records types.ShareRecords) { + k.IterateDepositorShares(ctx, func(record types.ShareRecord) bool { + records = append(records, record) + return false + }) + return +} + +// IterateDepositorSharesByOwner iterates over share records for a specific address and performs a callback function +func (k Keeper) IterateDepositorSharesByOwner(ctx sdk.Context, owner sdk.AccAddress, cb func(record types.ShareRecord) (stop bool)) { + store := prefix.NewStore(ctx.KVStore(k.key), types.DepositorPoolSharesPrefix) + iterator := sdk.KVStorePrefixIterator(store, owner.Bytes()) + defer iterator.Close() + for ; iterator.Valid(); iterator.Next() { + var record types.ShareRecord + k.cdc.MustUnmarshalBinaryLengthPrefixed(iterator.Value(), &record) + if cb(record) { + break + } + } +} + +// GetAllDepositorShares returns all depositor share records from the store for a specific address +func (k Keeper) GetAllDepositorSharesByOwner(ctx sdk.Context, owner sdk.AccAddress) (records types.ShareRecords) { + k.IterateDepositorSharesByOwner(ctx, owner, func(record types.ShareRecord) bool { + records = append(records, record) + return false + }) + return +} diff --git a/x/swap/keeper/keeper_test.go b/x/swap/keeper/keeper_test.go new file mode 100644 index 00000000..1405e321 --- /dev/null +++ b/x/swap/keeper/keeper_test.go @@ -0,0 +1,110 @@ +package keeper_test + +import ( + "testing" + + "github.com/kava-labs/kava/x/swap/testutil" + "github.com/kava-labs/kava/x/swap/types" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/suite" +) + +type keeperTestSuite struct { + testutil.Suite +} + +func (suite *keeperTestSuite) SetupTest() { + suite.Suite.SetupTest() + suite.Keeper.SetParams(suite.Ctx, types.DefaultParams()) +} + +func TestKeeperTestSuite(t *testing.T) { + suite.Run(t, new(keeperTestSuite)) +} + +func (suite *keeperTestSuite) setupPool(reserves sdk.Coins, totalShares sdk.Int, depositor sdk.AccAddress) string { + poolID := types.PoolIDFromCoins(reserves) + suite.AddCoinsToModule(reserves) + + poolRecord := types.PoolRecord{ + PoolID: poolID, + ReservesA: reserves[0], + ReservesB: reserves[1], + TotalShares: totalShares, + } + suite.Keeper.SetPool(suite.Ctx, poolRecord) + + shareRecord := types.ShareRecord{ + Depositor: depositor, + PoolID: poolID, + SharesOwned: totalShares, + } + suite.Keeper.SetDepositorShares(suite.Ctx, shareRecord) + + return poolID +} + +func (suite keeperTestSuite) TestParams_Persistance() { + keeper := suite.Keeper + + params := types.Params{ + AllowedPools: types.AllowedPools{ + types.NewAllowedPool("ukava", "usdx"), + }, + SwapFee: sdk.MustNewDecFromStr("0.03"), + } + keeper.SetParams(suite.Ctx, params) + suite.Equal(keeper.GetParams(suite.Ctx), params) + + oldParams := params + params = types.Params{ + AllowedPools: types.AllowedPools{ + types.NewAllowedPool("hard", "ukava"), + }, + SwapFee: sdk.MustNewDecFromStr("0.01"), + } + keeper.SetParams(suite.Ctx, params) + suite.NotEqual(keeper.GetParams(suite.Ctx), oldParams) + suite.Equal(keeper.GetParams(suite.Ctx), params) +} + +func (suite *keeperTestSuite) TestPool_Persistance() { + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + sdk.NewCoin("usdx", sdk.NewInt(50e6)), + ) + + pool, err := types.NewDenominatedPool(reserves) + suite.Nil(err) + record := types.NewPoolRecord(pool) + + suite.Keeper.SetPool(suite.Ctx, record) + + savedRecord, ok := suite.Keeper.GetPool(suite.Ctx, record.PoolID) + suite.True(ok) + suite.Equal(record, savedRecord) + + suite.Keeper.DeletePool(suite.Ctx, record.PoolID) + deletedPool, ok := suite.Keeper.GetPool(suite.Ctx, record.PoolID) + suite.False(ok) + suite.Equal(deletedPool, types.PoolRecord{}) +} + +func (suite *keeperTestSuite) TestShare_Persistance() { + poolID := "ukava/usdx" + depositor := sdk.AccAddress("testAddress1") + shares := sdk.NewInt(3126432331) + + record := types.NewShareRecord(depositor, poolID, shares) + suite.Keeper.SetDepositorShares(suite.Ctx, record) + + savedRecord, ok := suite.Keeper.GetDepositorShares(suite.Ctx, depositor, poolID) + suite.True(ok) + suite.Equal(record, savedRecord) + + suite.Keeper.DeleteDepositorShares(suite.Ctx, depositor, poolID) + deletedShares, ok := suite.Keeper.GetDepositorShares(suite.Ctx, depositor, poolID) + suite.False(ok) + suite.Equal(deletedShares, types.ShareRecord{}) +} diff --git a/x/swap/keeper/params.go b/x/swap/keeper/params.go deleted file mode 100644 index 7e08c876..00000000 --- a/x/swap/keeper/params.go +++ /dev/null @@ -1,19 +0,0 @@ -package keeper - -import ( - sdk "github.com/cosmos/cosmos-sdk/types" - - "github.com/kava-labs/kava/x/swap/types" -) - -// GetParams returns the params from the store -func (k Keeper) GetParams(ctx sdk.Context) types.Params { - var p types.Params - k.paramSubspace.GetParamSet(ctx, &p) - return p -} - -// SetParams sets params on the store -func (k Keeper) SetParams(ctx sdk.Context, params types.Params) { - k.paramSubspace.SetParamSet(ctx, ¶ms) -} diff --git a/x/swap/keeper/params_test.go b/x/swap/keeper/params_test.go deleted file mode 100644 index a6f44212..00000000 --- a/x/swap/keeper/params_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package keeper_test - -import ( - "testing" - - "github.com/kava-labs/kava/app" - "github.com/kava-labs/kava/x/swap/types" - - sdk "github.com/cosmos/cosmos-sdk/types" - "github.com/stretchr/testify/assert" - abci "github.com/tendermint/tendermint/abci/types" - tmtime "github.com/tendermint/tendermint/types/time" -) - -func TestParams_SetterAndGetter(t *testing.T) { - tApp := app.NewTestApp() - keeper := tApp.GetSwapKeeper() - - ctx := tApp.NewContext(true, abci.Header{Height: 1, Time: tmtime.Now()}) - params := types.Params{ - AllowedPools: types.AllowedPools{ - types.NewAllowedPool("ukava", "usdx"), - }, - SwapFee: sdk.MustNewDecFromStr("0.03"), - } - keeper.SetParams(ctx, params) - assert.Equal(t, keeper.GetParams(ctx), params) - - oldParams := params - params = types.Params{ - AllowedPools: types.AllowedPools{ - types.NewAllowedPool("hard", "ukava"), - }, - SwapFee: sdk.MustNewDecFromStr("0.01"), - } - keeper.SetParams(ctx, params) - assert.NotEqual(t, keeper.GetParams(ctx), oldParams) - assert.Equal(t, keeper.GetParams(ctx), params) -} diff --git a/x/swap/keeper/querier.go b/x/swap/keeper/querier.go index 2ab995f5..a976f5b3 100644 --- a/x/swap/keeper/querier.go +++ b/x/swap/keeper/querier.go @@ -1,6 +1,9 @@ package keeper import ( + "strings" + + "github.com/cosmos/cosmos-sdk/client" "github.com/cosmos/cosmos-sdk/codec" sdk "github.com/cosmos/cosmos-sdk/types" sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" @@ -16,6 +19,12 @@ func NewQuerier(k Keeper) sdk.Querier { switch path[0] { case types.QueryGetParams: return queryGetParams(ctx, req, k) + case types.QueryGetDeposits: + return queryGetDeposits(ctx, req, k) + case types.QueryGetPool: + return queryGetPool(ctx, req, k) + case types.QueryGetPools: + return queryGetPools(ctx, req, k) default: return nil, sdkerrors.Wrapf(sdkerrors.ErrUnknownRequest, "unknown %s query endpoint", types.ModuleName) } @@ -34,3 +43,135 @@ func queryGetParams(ctx sdk.Context, req abci.RequestQuery, keeper Keeper) ([]by } return bz, nil } + +func queryGetDeposits(ctx sdk.Context, req abci.RequestQuery, k Keeper) ([]byte, error) { + var params types.QueryDepositsParams + err := types.ModuleCdc.UnmarshalJSON(req.Data, ¶ms) + if err != nil { + return nil, sdkerrors.Wrap(sdkerrors.ErrJSONUnmarshal, err.Error()) + } + + var records types.ShareRecords + if len(params.Owner) > 0 { + records = k.GetAllDepositorSharesByOwner(ctx, params.Owner) + } else { + unfilteredRecords := k.GetAllDepositorShares(ctx) + records = filterShareRecords(ctx, unfilteredRecords, params) + } + + // Augment each deposit result with the actual share value of depositor's shares + var queryResults types.DepositsQueryResults + for _, record := range records { + pool, err := k.loadDenominatedPool(ctx, record.PoolID) + if err != nil { + return nil, err + } + shareValue := pool.ShareValue(record.SharesOwned) + queryResult := types.NewDepositsQueryResult(record, shareValue) + queryResults = append(queryResults, queryResult) + } + + var bz []byte + bz, err = codec.MarshalJSONIndent(types.ModuleCdc, queryResults) + if err != nil { + return nil, sdkerrors.Wrap(sdkerrors.ErrJSONMarshal, err.Error()) + } + return bz, nil +} + +func queryGetPool(ctx sdk.Context, req abci.RequestQuery, k Keeper) ([]byte, error) { + + var params types.QueryPoolParams + err := types.ModuleCdc.UnmarshalJSON(req.Data, ¶ms) + if err != nil { + return nil, sdkerrors.Wrap(sdkerrors.ErrJSONUnmarshal, err.Error()) + } + + hasPoolParam := len(params.Pool) > 0 + if !hasPoolParam { + return nil, sdkerrors.Wrap(sdkerrors.ErrInvalidRequest, "must specify pool param") + + } + + pool, err := k.loadDenominatedPool(ctx, params.Pool) + if err != nil { + return nil, err + } + totalCoins := pool.ShareValue(pool.TotalShares()) + poolStats := types.NewPoolStatsQueryResult(params.Pool, totalCoins, pool.TotalShares()) + + var bz []byte + bz, err = codec.MarshalJSONIndent(types.ModuleCdc, poolStats) + if err != nil { + return nil, sdkerrors.Wrap(sdkerrors.ErrJSONMarshal, err.Error()) + } + return bz, nil +} + +func queryGetPools(ctx sdk.Context, req abci.RequestQuery, k Keeper) ([]byte, error) { + pools := k.GetAllPools(ctx) + + var queryResults types.PoolStatsQueryResults + for _, pool := range pools { + denomPool, err := k.loadDenominatedPool(ctx, pool.PoolID) + if err != nil { + return nil, err + } + totalCoins := denomPool.ShareValue(denomPool.TotalShares()) + queryResult := types.NewPoolStatsQueryResult(pool.PoolID, totalCoins, denomPool.TotalShares()) + queryResults = append(queryResults, queryResult) + } + + // Encode results + bz, err := codec.MarshalJSONIndent(types.ModuleCdc, queryResults) + if err != nil { + return nil, sdkerrors.Wrap(sdkerrors.ErrJSONMarshal, err.Error()) + } + + return bz, nil +} + +// filterShareRecords retrieves share records filtered by a given set of params. +// If no filters are provided, all share records will be returned in paginated form. +func filterShareRecords(ctx sdk.Context, records types.ShareRecords, params types.QueryDepositsParams) types.ShareRecords { + filteredRecords := make(types.ShareRecords, 0, len(records)) + + for _, s := range records { + matchOwner, matchPool := true, true + + // match owner address (if supplied) + if len(params.Owner) > 0 { + matchOwner = s.Depositor.Equals(params.Owner) + } + + // match pool ID (if supplied) + if len(params.Pool) > 0 { + matchPool = strings.Compare(s.PoolID, params.Pool) == 0 + } + + if matchOwner && matchPool { + filteredRecords = append(filteredRecords, s) + } + } + + start, end := client.Paginate(len(filteredRecords), params.Page, params.Limit, 100) + if start < 0 || end < 0 { + filteredRecords = types.ShareRecords{} + } else { + filteredRecords = filteredRecords[start:end] + } + + return filteredRecords +} + +func (k Keeper) loadDenominatedPool(ctx sdk.Context, poolID string) (*types.DenominatedPool, error) { + poolRecord, found := k.GetPool(ctx, poolID) + if !found { + return &types.DenominatedPool{}, types.ErrInvalidPool + } + denominatedPool, err := types.NewDenominatedPoolWithExistingShares(poolRecord.Reserves(), poolRecord.TotalShares) + if err != nil { + return &types.DenominatedPool{}, types.ErrInvalidPool + } + return denominatedPool, nil +} diff --git a/x/swap/keeper/querier_test.go b/x/swap/keeper/querier_test.go index 6d18be55..ab0c4e35 100644 --- a/x/swap/keeper/querier_test.go +++ b/x/swap/keeper/querier_test.go @@ -1,51 +1,53 @@ package keeper_test import ( + "strings" "testing" - "github.com/stretchr/testify/suite" - - abci "github.com/tendermint/tendermint/abci/types" - tmtime "github.com/tendermint/tendermint/types/time" - sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/suite" + abci "github.com/tendermint/tendermint/abci/types" "github.com/kava-labs/kava/app" "github.com/kava-labs/kava/x/swap/keeper" + "github.com/kava-labs/kava/x/swap/testutil" "github.com/kava-labs/kava/x/swap/types" ) -type QuerierTestSuite struct { - suite.Suite - keeper keeper.Keeper - app app.TestApp - ctx sdk.Context - querier sdk.Querier +type querierTestSuite struct { + testutil.Suite + querier sdk.Querier + addresses []sdk.AccAddress } -func (suite *QuerierTestSuite) SetupTest() { - tApp := app.NewTestApp() - ctx := tApp.NewContext(true, abci.Header{Height: 1, Time: tmtime.Now()}) +func (suite *querierTestSuite) SetupTest() { + suite.Suite.SetupTest() - tApp.InitializeFromGenesisStates( + // Set up auth GenesisState + _, addrs := app.GeneratePrivKeyAddressPairs(5) + coins := []sdk.Coins{} + for j := 0; j < 5; j++ { + coins = append(coins, cs(c("ukava", 10000000000), c("bnb", 10000000000), c("usdx", 10000000000))) + } + suite.addresses = addrs + authGS := app.NewAuthGenState(addrs, coins) + + suite.App.InitializeFromGenesisStates( + authGS, NewSwapGenStateMulti(), ) - - suite.ctx = ctx - suite.app = tApp - suite.keeper = tApp.GetSwapKeeper() - suite.querier = keeper.NewQuerier(suite.keeper) + suite.querier = keeper.NewQuerier(suite.Keeper) } -func (suite *QuerierTestSuite) TestUnkownRequest() { - ctx := suite.ctx.WithIsCheckTx(false) +func (suite *querierTestSuite) TestUnkownRequest() { + ctx := suite.Ctx.WithIsCheckTx(false) bz, err := suite.querier(ctx, []string{"invalid-path"}, abci.RequestQuery{}) suite.Nil(bz) suite.EqualError(err, "unknown request: unknown swap query endpoint") } -func (suite *QuerierTestSuite) TestQueryParams() { - ctx := suite.ctx.WithIsCheckTx(false) +func (suite *querierTestSuite) TestQueryParams() { + ctx := suite.Ctx.WithIsCheckTx(false) bz, err := suite.querier(ctx, []string{types.QueryGetParams}, abci.RequestQuery{}) suite.Nil(err) suite.NotNil(bz) @@ -55,11 +57,117 @@ func (suite *QuerierTestSuite) TestQueryParams() { swapGenesisState := NewSwapGenStateMulti() gs := types.GenesisState{} - types.ModuleCdc.UnmarshalJSON(swapGenesisState["swap"], &gs) + err = types.ModuleCdc.UnmarshalJSON(swapGenesisState["swap"], &gs) + suite.Require().NoError(err) suite.Equal(gs.Params, p) } -func TestQuerierTestSuite(t *testing.T) { - suite.Run(t, new(QuerierTestSuite)) +func (suite *querierTestSuite) TestQueryPool() { + // Set up pool in store + coinA := sdk.NewCoin("ukava", sdk.NewInt(10)) + coinB := sdk.NewCoin("usdx", sdk.NewInt(200)) + + pool, err := types.NewDenominatedPool(sdk.NewCoins(coinA, coinB)) + suite.Nil(err) + poolRecord := types.NewPoolRecord(pool) + suite.Keeper.SetPool(suite.Ctx, poolRecord) + + ctx := suite.Ctx.WithIsCheckTx(false) + // Set up request query + query := abci.RequestQuery{ + Path: strings.Join([]string{"custom", types.QuerierRoute, types.QueryGetPool}, "/"), + Data: types.ModuleCdc.MustMarshalJSON(types.NewQueryPoolParams(poolRecord.PoolID)), + } + + bz, err := suite.querier(ctx, []string{types.QueryGetPool}, query) + suite.Nil(err) + suite.NotNil(bz) + + var res types.PoolStatsQueryResult + suite.Nil(types.ModuleCdc.UnmarshalJSON(bz, &res)) + + // Check that result matches expected result + totalCoins := pool.ShareValue(pool.TotalShares()) + expectedResult := types.NewPoolStatsQueryResult(poolRecord.PoolID, totalCoins, pool.TotalShares()) + suite.Equal(expectedResult, res) +} + +func (suite *querierTestSuite) TestQueryPools() { + // Set up pools in store + coinA := sdk.NewCoin("ukava", sdk.NewInt(10)) + coinB := sdk.NewCoin("usdx", sdk.NewInt(200)) + coinC := sdk.NewCoin("usdx", sdk.NewInt(200)) + + poolAB, err := types.NewDenominatedPool(sdk.NewCoins(coinA, coinB)) + suite.Nil(err) + poolRecordAB := types.NewPoolRecord(poolAB) + suite.Keeper.SetPool(suite.Ctx, poolRecordAB) + + poolAC, err := types.NewDenominatedPool(sdk.NewCoins(coinA, coinC)) + suite.Nil(err) + poolRecordAC := types.NewPoolRecord(poolAC) + suite.Keeper.SetPool(suite.Ctx, poolRecordAC) + + // Build a map of pools to compare to query results + pools := []types.PoolRecord{poolRecordAB, poolRecordAC} + poolsMap := make(map[string]types.PoolRecord) + for _, pool := range pools { + poolsMap[pool.PoolID] = pool + } + + ctx := suite.Ctx.WithIsCheckTx(false) + bz, err := suite.querier(ctx, []string{types.QueryGetPools}, abci.RequestQuery{}) + suite.Nil(err) + suite.NotNil(bz) + + var res types.PoolStatsQueryResults + suite.Nil(types.ModuleCdc.UnmarshalJSON(bz, &res)) + + // Check that all pools are accounted for + suite.Equal(len(poolsMap), len(res)) + // Check that each individual result matches the expected result + for _, pool := range res { + expectedPool, ok := poolsMap[pool.Name] + suite.True(ok) + suite.Equal(expectedPool.PoolID, pool.Name) + suite.Equal(sdk.NewCoins(expectedPool.ReservesA, expectedPool.ReservesB), pool.Coins) + suite.Equal(expectedPool.TotalShares, pool.TotalShares) + } +} + +func (suite *querierTestSuite) TestQueryDeposit() { + // Set up pool in store + coinA := sdk.NewCoin("ukava", sdk.NewInt(10)) + coinB := sdk.NewCoin("usdx", sdk.NewInt(200)) + pool, err := types.NewDenominatedPool(sdk.NewCoins(coinA, coinB)) + suite.Nil(err) + poolRecord := types.NewPoolRecord(pool) + suite.Keeper.SetPool(suite.Ctx, poolRecord) + + // Deposit into pool + owner := suite.addresses[0] + err = suite.Keeper.Deposit(suite.Ctx, owner, coinA, coinB, sdk.MustNewDecFromStr("0.20")) + suite.Nil(err) + + ctx := suite.Ctx.WithIsCheckTx(false) + // Set up request query + query := abci.RequestQuery{ + Path: strings.Join([]string{"custom", types.QuerierRoute, types.QueryGetDeposits}, "/"), + Data: types.ModuleCdc.MustMarshalJSON(types.NewQueryDepositsParams(1, 100, owner, poolRecord.PoolID)), + } + + bz, err := suite.querier(ctx, []string{types.QueryGetDeposits}, query) + suite.Nil(err) + suite.NotNil(bz) + + var res types.DepositsQueryResults + suite.Nil(types.ModuleCdc.UnmarshalJSON(bz, &res)) + + // As the only depositor all pool shares should belong to the owner + suite.Equal(poolRecord.TotalShares, res[0].SharesOwned) +} + +func TestQuerierTestSuite(t *testing.T) { + suite.Run(t, new(querierTestSuite)) } diff --git a/x/swap/keeper/withdraw.go b/x/swap/keeper/withdraw.go new file mode 100644 index 00000000..8bbe98ae --- /dev/null +++ b/x/swap/keeper/withdraw.go @@ -0,0 +1,90 @@ +package keeper + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + + "github.com/kava-labs/kava/x/swap/types" +) + +// Withdraw removes liquidity from an existing pool from an owners deposit, converting the provided shares for +// the returned pool liquidity. +// +// If 100% of the owners shares are removed, then the deposit is deleted. In addition, if all the pool shares +// are removed then the pool is deleted. +// +// The number of shares must be large enough to result in at least 1 unit of the smallest reserve in the pool. +// If the share input is below the minimum required for positive liquidity to be remove from both reserves, a +// insufficient error is returned. +// +// In addition, if the withdrawn liquidity for each reserve is below the provided minimum, a slippage exceeded +// error is returned. +func (k Keeper) Withdraw(ctx sdk.Context, owner sdk.AccAddress, shares sdk.Int, minCoinA, minCoinB sdk.Coin) error { + poolID := types.PoolID(minCoinA.Denom, minCoinB.Denom) + + shareRecord, found := k.GetDepositorShares(ctx, owner, poolID) + if !found { + return sdkerrors.Wrapf(types.ErrDepositNotFound, "no deposit for account %s and pool %s", owner, poolID) + } + + if shares.GT(shareRecord.SharesOwned) { + return sdkerrors.Wrapf(types.ErrInvalidShares, "withdraw of %s shares greater than %s shares owned", shares, shareRecord.SharesOwned) + } + + poolRecord, found := k.GetPool(ctx, poolID) + if !found { + panic(fmt.Sprintf("pool %s not found", poolID)) + } + + pool, err := types.NewDenominatedPoolWithExistingShares(poolRecord.Reserves(), poolRecord.TotalShares) + if err != nil { + panic(fmt.Sprintf("invalid pool %s: %s", poolID, err)) + } + + withdrawnAmount := pool.RemoveLiquidity(shares) + if withdrawnAmount.AmountOf(minCoinA.Denom).IsZero() || withdrawnAmount.AmountOf(minCoinB.Denom).IsZero() { + return sdkerrors.Wrap(types.ErrInsufficientLiquidity, "shares must be increased") + } + if withdrawnAmount.AmountOf(minCoinA.Denom).LT(minCoinA.Amount) || withdrawnAmount.AmountOf(minCoinB.Denom).LT(minCoinB.Amount) { + return sdkerrors.Wrap(types.ErrSlippageExceeded, "minimum withdraw not met") + } + + k.updatePool(ctx, poolID, pool) + k.updateShares(ctx, owner, poolID, shareRecord.SharesOwned.Sub(shares)) + + err = k.supplyKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleAccountName, owner, withdrawnAmount) + if err != nil { + panic(err) + } + + ctx.EventManager().EmitEvent( + sdk.NewEvent( + types.EventTypeSwapWithdraw, + sdk.NewAttribute(types.AttributeKeyPoolID, poolID), + sdk.NewAttribute(types.AttributeKeyOwner, owner.String()), + sdk.NewAttribute(sdk.AttributeKeyAmount, withdrawnAmount.String()), + sdk.NewAttribute(types.AttributeKeyShares, shares.String()), + ), + ) + + return nil +} + +func (k Keeper) updatePool(ctx sdk.Context, poolID string, pool *types.DenominatedPool) { + if pool.TotalShares().IsZero() { + k.DeletePool(ctx, poolID) + } else { + k.SetPool(ctx, types.NewPoolRecord(pool)) + } +} + +func (k Keeper) updateShares(ctx sdk.Context, owner sdk.AccAddress, poolID string, shares sdk.Int) { + if shares.IsZero() { + k.DeleteDepositorShares(ctx, owner, poolID) + } else { + shareRecord := types.NewShareRecord(owner, poolID, shares) + k.SetDepositorShares(ctx, shareRecord) + } +} diff --git a/x/swap/keeper/withdraw_test.go b/x/swap/keeper/withdraw_test.go new file mode 100644 index 00000000..7f06f931 --- /dev/null +++ b/x/swap/keeper/withdraw_test.go @@ -0,0 +1,223 @@ +package keeper_test + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/kava-labs/kava/x/swap/types" +) + +func (suite *keeperTestSuite) TestWithdraw_AllShares() { + owner := suite.CreateAccount(sdk.Coins{}) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + sdk.NewCoin("usdx", sdk.NewInt(50e6)), + ) + totalShares := sdk.NewInt(30e6) + poolID := suite.setupPool(reserves, totalShares, owner.GetAddress()) + + err := suite.Keeper.Withdraw(suite.Ctx, owner.GetAddress(), totalShares, reserves[0], reserves[1]) + suite.Require().NoError(err) + + suite.PoolDeleted(reserves[0].Denom, reserves[1].Denom) + suite.PoolSharesDeleted(owner.GetAddress(), reserves[0].Denom, reserves[1].Denom) + suite.AccountBalanceEqual(owner, reserves) + suite.ModuleAccountBalanceEqual(sdk.Coins(nil)) + + suite.EventsContains(suite.Ctx.EventManager().Events(), sdk.NewEvent( + types.EventTypeSwapWithdraw, + sdk.NewAttribute(types.AttributeKeyPoolID, poolID), + sdk.NewAttribute(types.AttributeKeyOwner, owner.GetAddress().String()), + sdk.NewAttribute(sdk.AttributeKeyAmount, reserves.String()), + sdk.NewAttribute(types.AttributeKeyShares, totalShares.String()), + )) +} + +func (suite *keeperTestSuite) TestWithdraw_PartialShares() { + owner := suite.CreateAccount(sdk.Coins{}) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + sdk.NewCoin("usdx", sdk.NewInt(50e6)), + ) + totalShares := sdk.NewInt(30e6) + poolID := suite.setupPool(reserves, totalShares, owner.GetAddress()) + + sharesToWithdraw := sdk.NewInt(15e6) + minCoinA := sdk.NewCoin("usdx", sdk.NewInt(25e6)) + minCoinB := sdk.NewCoin("ukava", sdk.NewInt(5e6)) + + err := suite.Keeper.Withdraw(suite.Ctx, owner.GetAddress(), sharesToWithdraw, minCoinA, minCoinB) + suite.Require().NoError(err) + + sharesLeft := totalShares.Sub(sharesToWithdraw) + reservesLeft := sdk.NewCoins(reserves[0].Sub(minCoinB), reserves[1].Sub(minCoinA)) + + suite.PoolShareTotalEqual(poolID, sharesLeft) + suite.PoolDepositorSharesEqual(owner.GetAddress(), poolID, sharesLeft) + suite.PoolReservesEqual(poolID, reservesLeft) + suite.AccountBalanceEqual(owner, sdk.NewCoins(minCoinA, minCoinB)) + suite.ModuleAccountBalanceEqual(reservesLeft) + + suite.EventsContains(suite.Ctx.EventManager().Events(), sdk.NewEvent( + types.EventTypeSwapWithdraw, + sdk.NewAttribute(types.AttributeKeyPoolID, poolID), + sdk.NewAttribute(types.AttributeKeyOwner, owner.GetAddress().String()), + sdk.NewAttribute(sdk.AttributeKeyAmount, sdk.NewCoins(minCoinA, minCoinB).String()), + sdk.NewAttribute(types.AttributeKeyShares, sharesToWithdraw.String()), + )) +} + +func (suite *keeperTestSuite) TestWithdraw_NoSharesOwned() { + owner := suite.CreateAccount(sdk.Coins{}) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + sdk.NewCoin("usdx", sdk.NewInt(50e6)), + ) + totalShares := sdk.NewInt(30e6) + poolID := suite.setupPool(reserves, totalShares, owner.GetAddress()) + + accWithNoDeposit := sdk.AccAddress("some account") + + err := suite.Keeper.Withdraw(suite.Ctx, accWithNoDeposit, totalShares, reserves[0], reserves[1]) + suite.EqualError(err, fmt.Sprintf("deposit not found: no deposit for account %s and pool %s", accWithNoDeposit.String(), poolID)) +} + +func (suite *keeperTestSuite) TestWithdraw_GreaterThanSharesOwned() { + owner := suite.CreateAccount(sdk.Coins{}) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + sdk.NewCoin("usdx", sdk.NewInt(50e6)), + ) + totalShares := sdk.NewInt(30e6) + suite.setupPool(reserves, totalShares, owner.GetAddress()) + + sharesToWithdraw := totalShares.Add(sdk.OneInt()) + err := suite.Keeper.Withdraw(suite.Ctx, owner.GetAddress(), sharesToWithdraw, reserves[0], reserves[1]) + suite.EqualError(err, fmt.Sprintf("invalid shares: withdraw of %s shares greater than %s shares owned", sharesToWithdraw, totalShares)) +} + +func (suite *keeperTestSuite) TestWithdraw_MinWithdraw() { + owner := suite.CreateAccount(sdk.Coins{}) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + sdk.NewCoin("usdx", sdk.NewInt(50e6)), + ) + totalShares := sdk.NewInt(30e6) + + testCases := []struct { + shares sdk.Int + minCoinA sdk.Coin + minCoinB sdk.Coin + shouldFail bool + }{ + {sdk.NewInt(1), sdk.NewCoin("ukava", sdk.NewInt(1)), sdk.NewCoin("usdx", sdk.NewInt(1)), true}, + {sdk.NewInt(1), sdk.NewCoin("usdx", sdk.NewInt(5)), sdk.NewCoin("ukava", sdk.NewInt(1)), true}, + + {sdk.NewInt(2), sdk.NewCoin("ukava", sdk.NewInt(1)), sdk.NewCoin("usdx", sdk.NewInt(1)), true}, + {sdk.NewInt(2), sdk.NewCoin("usdx", sdk.NewInt(5)), sdk.NewCoin("ukava", sdk.NewInt(1)), true}, + + {sdk.NewInt(3), sdk.NewCoin("ukava", sdk.NewInt(1)), sdk.NewCoin("usdx", sdk.NewInt(5)), false}, + {sdk.NewInt(3), sdk.NewCoin("usdx", sdk.NewInt(5)), sdk.NewCoin("ukava", sdk.NewInt(1)), false}, + } + + for _, tc := range testCases { + suite.Run(fmt.Sprintf("shares=%s minCoinA=%s minCoinB=%s", tc.shares, tc.minCoinA, tc.minCoinB), func() { + suite.SetupTest() + suite.setupPool(reserves, totalShares, owner.GetAddress()) + + err := suite.Keeper.Withdraw(suite.Ctx, owner.GetAddress(), tc.shares, tc.minCoinA, tc.minCoinB) + if tc.shouldFail { + suite.EqualError(err, "insufficient liquidity: shares must be increased") + } else { + suite.NoError(err, "expected no liquidity error") + } + }) + } +} + +func (suite *keeperTestSuite) TestWithdraw_BelowMinimum() { + owner := suite.CreateAccount(sdk.Coins{}) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + sdk.NewCoin("usdx", sdk.NewInt(50e6)), + ) + totalShares := sdk.NewInt(30e6) + + testCases := []struct { + shares sdk.Int + minCoinA sdk.Coin + minCoinB sdk.Coin + shouldFail bool + }{ + {sdk.NewInt(15e6), sdk.NewCoin("ukava", sdk.NewInt(5000001)), sdk.NewCoin("usdx", sdk.NewInt(25e6)), true}, + } + + for _, tc := range testCases { + suite.Run(fmt.Sprintf("shares=%s minCoinA=%s minCoinB=%s", tc.shares, tc.minCoinA, tc.minCoinB), func() { + suite.SetupTest() + suite.setupPool(reserves, totalShares, owner.GetAddress()) + + err := suite.Keeper.Withdraw(suite.Ctx, owner.GetAddress(), tc.shares, tc.minCoinA, tc.minCoinB) + if tc.shouldFail { + suite.EqualError(err, "slippage exceeded: minimum withdraw not met") + } else { + suite.NoError(err, "expected no slippage error") + } + }) + } +} + +func (suite *keeperTestSuite) TestWithdraw_PanicOnMissingPool() { + owner := suite.CreateAccount(sdk.Coins{}) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + sdk.NewCoin("usdx", sdk.NewInt(50e6)), + ) + totalShares := sdk.NewInt(30e6) + poolID := suite.setupPool(reserves, totalShares, owner.GetAddress()) + + suite.Keeper.DeletePool(suite.Ctx, poolID) + + suite.PanicsWithValue("pool ukava/usdx not found", func() { + _ = suite.Keeper.Withdraw(suite.Ctx, owner.GetAddress(), totalShares, reserves[0], reserves[1]) + }, "expected missing pool record to panic") +} + +func (suite *keeperTestSuite) TestWithdraw_PanicOnInvalidPool() { + owner := suite.CreateAccount(sdk.Coins{}) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + sdk.NewCoin("usdx", sdk.NewInt(50e6)), + ) + totalShares := sdk.NewInt(30e6) + poolID := suite.setupPool(reserves, totalShares, owner.GetAddress()) + + poolRecord, found := suite.Keeper.GetPool(suite.Ctx, poolID) + suite.Require().True(found, "expected pool record to exist") + + poolRecord.TotalShares = sdk.ZeroInt() + suite.Keeper.SetPool(suite.Ctx, poolRecord) + + suite.PanicsWithValue("invalid pool ukava/usdx: invalid pool: total shares must be greater than zero", func() { + _ = suite.Keeper.Withdraw(suite.Ctx, owner.GetAddress(), totalShares, reserves[0], reserves[1]) + }, "expected invalid pool record to panic") +} + +func (suite *keeperTestSuite) TestWithdraw_PanicOnModuleInsufficientFunds() { + owner := suite.CreateAccount(sdk.Coins{}) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(10e6)), + sdk.NewCoin("usdx", sdk.NewInt(50e6)), + ) + totalShares := sdk.NewInt(30e6) + suite.setupPool(reserves, totalShares, owner.GetAddress()) + + suite.RemoveCoinsFromModule(sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(1e6)), + sdk.NewCoin("usdx", sdk.NewInt(5e6)), + )) + + suite.Panics(func() { + _ = suite.Keeper.Withdraw(suite.Ctx, owner.GetAddress(), totalShares, reserves[0], reserves[1]) + }, "expected panic when module account does not have enough funds") +} diff --git a/x/swap/legacy/v0_15/types.go b/x/swap/legacy/v0_15/types.go new file mode 100644 index 00000000..ee50ba67 --- /dev/null +++ b/x/swap/legacy/v0_15/types.go @@ -0,0 +1,206 @@ +package v0_11 + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/params" +) + +const ( + // ModuleName name that will be used throughout the module + ModuleName = "swap" +) + +// Parameter keys and default values +var ( + KeyAllowedPools = []byte("AllowedPools") + KeySwapFee = []byte("SwapFee") + DefaultAllowedPools = AllowedPools{} + DefaultSwapFee = sdk.ZeroDec() + MaxSwapFee = sdk.OneDec() +) + +// AllowedPool defines a tradable pool +type AllowedPool struct { + TokenA string `json:"token_a" yaml:"token_a"` + TokenB string `json:"token_b" yaml:"token_b"` +} + +// NewAllowedPool returns a new AllowedPool object +func NewAllowedPool(tokenA, tokenB string) AllowedPool { + return AllowedPool{ + TokenA: tokenA, + TokenB: tokenB, + } +} + +// Validate validates allowedPool attributes and returns an error if invalid +func (p AllowedPool) Validate() error { + err := sdk.ValidateDenom(p.TokenA) + if err != nil { + return err + } + + err = sdk.ValidateDenom(p.TokenB) + if err != nil { + return err + } + + if p.TokenA == p.TokenB { + return fmt.Errorf( + "pool cannot have two tokens of the same type, received '%s' and '%s'", + p.TokenA, p.TokenB, + ) + } + + if p.TokenA > p.TokenB { + return fmt.Errorf( + "invalid token order: '%s' must come before '%s'", + p.TokenB, p.TokenA, + ) + } + + return nil +} + +// Name returns a unique name for a allowedPool in alphabetical order +func (p AllowedPool) Name() string { + return fmt.Sprintf("%s/%s", p.TokenA, p.TokenB) +} + +// String pretty prints the allowedPool +func (p AllowedPool) String() string { + return fmt.Sprintf(`AllowedPool: + Name: %s + Token A: %s + Token B: %s +`, p.Name(), p.TokenA, p.TokenB) +} + +// AllowedPools is a slice of AllowedPool +type AllowedPools []AllowedPool + +// NewAllowedPools returns AllowedPools from the provided values +func NewAllowedPools(allowedPools ...AllowedPool) AllowedPools { + return AllowedPools(allowedPools) +} + +// Validate validates each allowedPool and returns an error if there are any duplicates +func (p AllowedPools) Validate() error { + seenAllowedPools := make(map[string]bool) + for _, allowedPool := range p { + err := allowedPool.Validate() + if err != nil { + return err + } + + if seen := seenAllowedPools[allowedPool.Name()]; seen { + return fmt.Errorf("duplicate pool: %s", allowedPool.Name()) + } + seenAllowedPools[allowedPool.Name()] = true + } + + return nil +} + +// Params are governance parameters for the swap module +type Params struct { + AllowedPools AllowedPools `json:"allowed_pools" yaml:"allowed_pools"` + SwapFee sdk.Dec `json:"swap_fee" yaml:"swap_fee"` +} + +// NewParams returns a new params object +func NewParams(pairs AllowedPools, swapFee sdk.Dec) Params { + return Params{ + AllowedPools: pairs, + SwapFee: swapFee, + } +} + +// DefaultParams returns default params for swap module +func DefaultParams() Params { + return NewParams( + DefaultAllowedPools, + DefaultSwapFee, + ) +} + +// String implements fmt.Stringer +func (p Params) String() string { + return fmt.Sprintf(`Params: + AllowedPools: %s + SwapFee: %s`, + p.AllowedPools, p.SwapFee) +} + +// ParamKeyTable Key declaration for parameters +func ParamKeyTable() params.KeyTable { + return params.NewKeyTable().RegisterParamSet(&Params{}) +} + +// ParamSetPairs implements the ParamSet interface and returns all the key/value pairs +func (p *Params) ParamSetPairs() params.ParamSetPairs { + return params.ParamSetPairs{ + params.NewParamSetPair(KeyAllowedPools, &p.AllowedPools, validateAllowedPoolsParams), + params.NewParamSetPair(KeySwapFee, &p.SwapFee, validateSwapFee), + } +} + +// Validate checks that the parameters have valid values. +func (p Params) Validate() error { + if err := validateAllowedPoolsParams(p.AllowedPools); err != nil { + return err + } + + return validateSwapFee(p.SwapFee) +} + +func validateAllowedPoolsParams(i interface{}) error { + p, ok := i.(AllowedPools) + if !ok { + return fmt.Errorf("invalid parameter type: %T", i) + } + + return p.Validate() +} + +func validateSwapFee(i interface{}) error { + swapFee, ok := i.(sdk.Dec) + if !ok { + return fmt.Errorf("invalid parameter type: %T", i) + } + + if swapFee.IsNil() || swapFee.IsNegative() || swapFee.GT(MaxSwapFee) { + return fmt.Errorf(fmt.Sprintf("invalid swap fee: %s", swapFee)) + } + + return nil +} + +// GenesisState is the state that must be provided at genesis. +type GenesisState struct { + Params Params `json:"params" yaml:"params"` +} + +// NewGenesisState creates a new genesis state. +func NewGenesisState(params Params) GenesisState { + return GenesisState{ + Params: params, + } +} + +// Validate validates the module's genesis state +func (gs GenesisState) Validate() error { + if err := gs.Params.Validate(); err != nil { + return err + } + return nil +} + +// DefaultGenesisState returns a default genesis state +func DefaultGenesisState() GenesisState { + return NewGenesisState( + DefaultParams(), + ) +} diff --git a/x/swap/module.go b/x/swap/module.go index d82db67b..c98fd7de 100644 --- a/x/swap/module.go +++ b/x/swap/module.go @@ -2,6 +2,7 @@ package swap import ( "encoding/json" + "math/rand" "github.com/gorilla/mux" "github.com/spf13/cobra" @@ -10,20 +11,21 @@ import ( "github.com/cosmos/cosmos-sdk/codec" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/cosmos/cosmos-sdk/types/module" + sim "github.com/cosmos/cosmos-sdk/x/simulation" abci "github.com/tendermint/tendermint/abci/types" - // "github.com/kava-labs/kava/x/swap/simulation" "github.com/kava-labs/kava/x/swap/client/cli" "github.com/kava-labs/kava/x/swap/client/rest" "github.com/kava-labs/kava/x/swap/keeper" + "github.com/kava-labs/kava/x/swap/simulation" "github.com/kava-labs/kava/x/swap/types" ) var ( - _ module.AppModule = AppModule{} - _ module.AppModuleBasic = AppModuleBasic{} - // _ module.AppModuleSimulation = AppModule{} + _ module.AppModule = AppModule{} + _ module.AppModuleBasic = AppModuleBasic{} + _ module.AppModuleSimulation = AppModule{} ) // AppModuleBasic app module basics object @@ -140,27 +142,27 @@ func (am AppModule) EndBlock(_ sdk.Context, _ abci.RequestEndBlock) []abci.Valid //____________________________________________________________________________ -// // GenerateGenesisState creates a randomized GenState of the swap module -// func (AppModuleBasic) GenerateGenesisState(simState *module.SimulationState) { -// simulation.RandomizedGenState(simState) -// } +// GenerateGenesisState creates a randomized GenState of the swap module +func (AppModuleBasic) GenerateGenesisState(simState *module.SimulationState) { + simulation.RandomizedGenState(simState) +} -// // ProposalContents doesn't return any content functions for governance proposals. -// func (AppModuleBasic) ProposalContents(_ module.SimulationState) []sim.WeightedProposalContent { -// return nil -// } +// ProposalContents doesn't return any content functions for governance proposals. +func (AppModuleBasic) ProposalContents(_ module.SimulationState) []sim.WeightedProposalContent { + return nil +} -// // RandomizedParams returns nil because swap has no params. -// func (AppModuleBasic) RandomizedParams(r *rand.Rand) []sim.ParamChange { -// return simulation.ParamChanges(r) -// } +// RandomizedParams returns nil because swap has no params. +func (AppModuleBasic) RandomizedParams(r *rand.Rand) []sim.ParamChange { + return simulation.ParamChanges(r) +} -// // RegisterStoreDecoder registers a decoder for swap module's types -// func (AppModuleBasic) RegisterStoreDecoder(sdr sdk.StoreDecoderRegistry) { -// sdr[StoreKey] = simulation.DecodeStore -// } +// RegisterStoreDecoder registers a decoder for swap module's types +func (AppModuleBasic) RegisterStoreDecoder(sdr sdk.StoreDecoderRegistry) { + sdr[StoreKey] = simulation.DecodeStore +} -// // WeightedOperations returns the all the swap module operations with their respective weights. -// func (am AppModule) WeightedOperations(simState module.SimulationState) []sim.WeightedOperation { -// return nil -// } +// WeightedOperations returns the all the swap module operations with their respective weights. +func (am AppModule) WeightedOperations(simState module.SimulationState) []sim.WeightedOperation { + return nil +} diff --git a/x/swap/simulation/decoder.go b/x/swap/simulation/decoder.go new file mode 100644 index 00000000..22ad061a --- /dev/null +++ b/x/swap/simulation/decoder.go @@ -0,0 +1,13 @@ +package simulation + +import ( + "github.com/tendermint/tendermint/libs/kv" + + "github.com/cosmos/cosmos-sdk/codec" +) + +// DecodeStore unmarshals the KVPair's Value to the module's corresponding type +func DecodeStore(cdc *codec.Codec, kvA, kvB kv.Pair) string { + // TODO: as store keys are added to the module, test marshal/unmarshal of each key prefix + return "" +} diff --git a/x/swap/simulation/genesis.go b/x/swap/simulation/genesis.go new file mode 100644 index 00000000..6a786bd7 --- /dev/null +++ b/x/swap/simulation/genesis.go @@ -0,0 +1,100 @@ +package simulation + +import ( + "fmt" + "math/rand" + "sort" + "strings" + + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/module" + "github.com/cosmos/cosmos-sdk/x/simulation" + + "github.com/kava-labs/kava/x/swap/types" +) + +var ( + //nolint + accs []simulation.Account + consistentPools = [2][2]string{{"ukava", "usdx"}, {"hard", "usdx"}} +) + +// GenSwapFee generates a random SwapFee in range [0.01, 1.00] +func GenSwapFee(r *rand.Rand) sdk.Dec { + min := int(1) + max := int(100) + percentage := int64(r.Intn(int(max)-min) + min) + return sdk.NewDec(percentage).Quo(sdk.NewDec(100)) +} + +// GenAllowedPools generates random allowed pools +func GenAllowedPools(r *rand.Rand) types.AllowedPools { + var pools types.AllowedPools + + // Generate a set [1, 10] of random pools + numRandPools := (r.Intn(10) + 1) + for i := 0; i < numRandPools; i++ { + tokenA, tokenB := genTokenDenoms(r) + for strings.Compare(tokenA, tokenB) == 0 { + tokenA, tokenB = genTokenDenoms(r) + } + newPool := types.NewAllowedPool(tokenA, tokenB) + pools = append(pools, newPool) + } + + // Append consistent pools + for i := 0; i < len(consistentPools); i++ { + tokenA := consistentPools[i][0] + tokenB := consistentPools[i][1] + newPool := types.NewAllowedPool(tokenA, tokenB) + pools = append(pools, newPool) + } + + return pools +} + +func genTokenDenoms(r *rand.Rand) (string, string) { + tokenA := genTokenDenom(r) + tokenB := genTokenDenom(r) + for strings.Compare(tokenA, tokenB) == 0 { + tokenA = genTokenDenom(r) + } + tokens := []string{tokenA, tokenB} + sort.Strings(tokens) + return tokens[0], tokens[1] +} + +func genTokenDenom(r *rand.Rand) string { + denom := strings.ToLower(simulation.RandStringOfLength(r, 3)) + for err := sdk.ValidateDenom(denom); err != nil; { + denom = strings.ToLower(simulation.RandStringOfLength(r, 3)) + } + return denom +} + +// RandomizedGenState generates a random GenesisState +func RandomizedGenState(simState *module.SimulationState) { + accs = simState.Accounts + + swapGenesis := loadRandomSwapGenState(simState) + fmt.Printf("Selected randomly generated %s parameters:\n%s\n", types.ModuleName, codec.MustMarshalJSONIndent(simState.Cdc, swapGenesis)) + simState.GenState[types.ModuleName] = simState.Cdc.MustMarshalJSON(swapGenesis) +} + +func loadRandomSwapGenState(simState *module.SimulationState) types.GenesisState { + pools := GenAllowedPools(simState.Rand) + swapFee := GenSwapFee(simState.Rand) + + swapGenesis := types.GenesisState{ + Params: types.Params{ + AllowedPools: pools, + SwapFee: swapFee, + }, + } + + if err := swapGenesis.Validate(); err != nil { + panic(err) + } + return swapGenesis +} diff --git a/x/swap/simulation/operations.go b/x/swap/simulation/operations.go new file mode 100644 index 00000000..3f82cb9f --- /dev/null +++ b/x/swap/simulation/operations.go @@ -0,0 +1,12 @@ +package simulation + +import ( + "github.com/cosmos/cosmos-sdk/x/simulation" + + "github.com/kava-labs/kava/x/swap/types" +) + +var ( + //nolint + noOpMsg = simulation.NoOpMsg(types.ModuleName) +) diff --git a/x/swap/simulation/params.go b/x/swap/simulation/params.go new file mode 100644 index 00000000..93b7e263 --- /dev/null +++ b/x/swap/simulation/params.go @@ -0,0 +1,32 @@ +package simulation + +import ( + "fmt" + "math/rand" + + "github.com/cosmos/cosmos-sdk/x/simulation" + + "github.com/kava-labs/kava/x/swap/types" +) + +const ( + keyAllowedPools = "AllowedPools" + keySwapFee = "SwapFee" +) + +// ParamChanges defines the parameters that can be modified by param change proposals +// on the simulation +func ParamChanges(r *rand.Rand) []simulation.ParamChange { + return []simulation.ParamChange{ + simulation.NewSimParamChange(types.ModuleName, keyAllowedPools, + func(r *rand.Rand) string { + return fmt.Sprintf("\"%s\"", GenAllowedPools(r)) + }, + ), + simulation.NewSimParamChange(types.ModuleName, keySwapFee, + func(r *rand.Rand) string { + return fmt.Sprintf("\"%s\"", GenSwapFee(r)) + }, + ), + } +} diff --git a/x/swap/testutil/suite.go b/x/swap/testutil/suite.go new file mode 100644 index 00000000..6eead59d --- /dev/null +++ b/x/swap/testutil/suite.go @@ -0,0 +1,281 @@ +package testutil + +import ( + "fmt" + "reflect" + "time" + + "github.com/kava-labs/kava/app" + "github.com/kava-labs/kava/x/swap" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/auth" + authexported "github.com/cosmos/cosmos-sdk/x/auth/exported" + vestingtypes "github.com/cosmos/cosmos-sdk/x/auth/vesting/types" + "github.com/cosmos/cosmos-sdk/x/bank" + "github.com/cosmos/cosmos-sdk/x/supply" + "github.com/stretchr/testify/suite" + abci "github.com/tendermint/tendermint/abci/types" + kv "github.com/tendermint/tendermint/libs/kv" + tmtime "github.com/tendermint/tendermint/types/time" +) + +// Suite implements a test suite for the swap module integration tests +type Suite struct { + suite.Suite + Keeper swap.Keeper + App app.TestApp + Ctx sdk.Context + bankKeeper bank.Keeper + supplyKeeper supply.Keeper +} + +// SetupTest instantiates a new app, keepers, and sets suite state +func (suite *Suite) SetupTest() { + tApp := app.NewTestApp() + ctx := tApp.NewContext(true, abci.Header{Height: 1, Time: tmtime.Now()}) + keeper := tApp.GetSwapKeeper() + bankKeeper := tApp.GetBankKeeper() + supplyKeeper := tApp.GetSupplyKeeper() + + suite.Ctx = ctx + suite.App = tApp + suite.Keeper = keeper + suite.bankKeeper = bankKeeper + suite.supplyKeeper = supplyKeeper +} + +// AddCoinsToModule adds coins to the swap module account +func (suite *Suite) AddCoinsToModule(amount sdk.Coins) { + macc, _ := suite.supplyKeeper.GetModuleAccountAndPermissions(suite.Ctx, swap.ModuleName) + _, err := suite.bankKeeper.AddCoins(suite.Ctx, macc.GetAddress(), amount) + suite.Require().NoError(err) +} + +// RemoveCoinsFromModule adds coins to the swap module account +func (suite *Suite) RemoveCoinsFromModule(amount sdk.Coins) { + macc, _ := suite.supplyKeeper.GetModuleAccountAndPermissions(suite.Ctx, swap.ModuleName) + _, err := suite.bankKeeper.SubtractCoins(suite.Ctx, macc.GetAddress(), amount) + suite.Require().NoError(err) +} + +// GetAccount gets an existing account +func (suite *Suite) GetAccount(addr sdk.AccAddress) authexported.Account { + ak := suite.App.GetAccountKeeper() + return ak.GetAccount(suite.Ctx, addr) +} + +// CreateAccount creates a new account from the provided balance +func (suite *Suite) CreateAccount(initialBalance sdk.Coins) authexported.Account { + _, addrs := app.GeneratePrivKeyAddressPairs(1) + ak := suite.App.GetAccountKeeper() + + acc := ak.NewAccountWithAddress(suite.Ctx, addrs[0]) + err := acc.SetCoins(initialBalance) + suite.Require().NoError(err) + + ak.SetAccount(suite.Ctx, acc) + return acc +} + +// NewAccountFromAddr creates a new account from the provided address with the provided balance +func (suite *Suite) NewAccountFromAddr(addr sdk.AccAddress, balance sdk.Coins) authexported.Account { + ak := suite.App.GetAccountKeeper() + + acc := ak.NewAccountWithAddress(suite.Ctx, addr) + err := acc.SetCoins(balance) + suite.Require().NoError(err) + + ak.SetAccount(suite.Ctx, acc) + return acc +} + +// CreateVestingAccount creats a new vesting account from the provided balance and vesting balance +func (suite *Suite) CreateVestingAccount(initialBalance sdk.Coins, vestingBalance sdk.Coins) authexported.Account { + acc := suite.CreateAccount(initialBalance) + bacc := acc.(*auth.BaseAccount) + + periods := vestingtypes.Periods{ + vestingtypes.Period{ + Length: 31556952, + Amount: vestingBalance, + }, + } + vacc := vestingtypes.NewPeriodicVestingAccount(bacc, time.Now().Unix(), periods) + + return vacc +} + +// CreatePool creates a pool and stores it in state with the provided reserves +func (suite *Suite) CreatePool(reserves sdk.Coins) error { + depositor := suite.CreateAccount(reserves) + pool := swap.NewAllowedPool(reserves[0].Denom, reserves[1].Denom) + suite.Require().NoError(pool.Validate()) + suite.Keeper.SetParams(suite.Ctx, swap.NewParams(swap.NewAllowedPools(pool), swap.DefaultSwapFee)) + + return suite.Keeper.Deposit(suite.Ctx, depositor.GetAddress(), reserves[0], reserves[1], sdk.MustNewDecFromStr("1")) +} + +// AccountBalanceEqual asserts that the coins match the account balance +func (suite *Suite) AccountBalanceEqual(acc authexported.Account, coins sdk.Coins) { + ak := suite.App.GetAccountKeeper() + acc = ak.GetAccount(suite.Ctx, acc.GetAddress()) + suite.Equal(coins, acc.GetCoins(), fmt.Sprintf("expected account balance to equal coins %s, but got %s", coins, acc.GetCoins())) +} + +// AccountBalanceDelta asserts that the coins are within delta of the account balance +func (suite *Suite) AccountBalanceDelta(acc authexported.Account, coins sdk.Coins, delta float64) { + ak := suite.App.GetAccountKeeper() + acc = ak.GetAccount(suite.Ctx, acc.GetAddress()) + accCoins := acc.GetCoins() + allCoins := coins.Add(accCoins...) + for _, coin := range allCoins { + suite.InDelta( + coins.AmountOf(coin.Denom).Int64(), + accCoins.AmountOf(coin.Denom).Int64(), + delta, + fmt.Sprintf("expected module account balance to be in delta %f of coins %s, but got %s", delta, coins, accCoins), + ) + } +} + +// ModuleAccountBalanceEqual asserts that the swap module account balance matches the provided coins +func (suite *Suite) ModuleAccountBalanceEqual(coins sdk.Coins) { + macc, _ := suite.supplyKeeper.GetModuleAccountAndPermissions(suite.Ctx, swap.ModuleName) + suite.Require().NotNil(macc, "expected module account to be defined") + suite.Equal(coins, macc.GetCoins(), fmt.Sprintf("expected module account balance to equal coins %s, but got %s", coins, macc.GetCoins())) +} + +// ModuleAccountBalanceDelta asserts that the swap module account balance is within acceptable delta of the provided coins +func (suite *Suite) ModuleAccountBalanceDelta(coins sdk.Coins, delta float64) { + macc, _ := suite.supplyKeeper.GetModuleAccountAndPermissions(suite.Ctx, swap.ModuleName) + suite.Require().NotNil(macc, "expected module account to be defined") + + allCoins := coins.Add(macc.GetCoins()...) + for _, coin := range allCoins { + suite.InDelta( + coins.AmountOf(coin.Denom).Int64(), + macc.GetCoins().AmountOf(coin.Denom).Int64(), + delta, + fmt.Sprintf("expected module account balance to be in delta %f of coins %s, but got %s", delta, coins, macc.GetCoins()), + ) + } +} + +// PoolLiquidityEqual asserts that the pool matching the provided coins has those reserves +func (suite *Suite) PoolLiquidityEqual(coins sdk.Coins) { + poolRecord, ok := suite.Keeper.GetPool(suite.Ctx, swap.PoolIDFromCoins(coins)) + suite.Require().True(ok, "expected pool to exist") + reserves := sdk.NewCoins(poolRecord.ReservesA, poolRecord.ReservesB) + suite.Equal(coins, reserves, fmt.Sprintf("expected pool reserves of %s, got %s", coins, reserves)) +} + +// PoolDeleted asserts that the pool does not exist +func (suite *Suite) PoolDeleted(denomA, denomB string) { + _, ok := suite.Keeper.GetPool(suite.Ctx, swap.PoolID(denomA, denomB)) + suite.Require().False(ok, "expected pool to not exist") +} + +// PoolLiquidityDelta asserts that the pool matching the provided coins has those reserves within delta +func (suite *Suite) PoolLiquidityDelta(coins sdk.Coins, delta float64) { + poolRecord, ok := suite.Keeper.GetPool(suite.Ctx, swap.PoolIDFromCoins(coins)) + suite.Require().True(ok, "expected pool to exist") + + suite.InDelta( + poolRecord.ReservesA.Amount.Int64(), + coins.AmountOf(poolRecord.ReservesA.Denom).Int64(), + delta, + fmt.Sprintf("expected pool reserves within delta %f of %s, got %s", delta, coins, poolRecord.Reserves()), + ) + suite.InDelta( + poolRecord.ReservesB.Amount.Int64(), + coins.AmountOf(poolRecord.ReservesB.Denom).Int64(), + delta, + fmt.Sprintf("expected pool reserves within delta %f of %s, got %s", delta, coins, poolRecord.Reserves()), + ) +} + +// PoolShareTotalEqual asserts the total shares match the stored pool +func (suite *Suite) PoolShareTotalEqual(poolID string, totalShares sdk.Int) { + poolRecord, found := suite.Keeper.GetPool(suite.Ctx, poolID) + suite.Require().True(found, fmt.Sprintf("expected pool %s to exist", poolID)) + suite.Equal(totalShares, poolRecord.TotalShares, "expected pool total shares to be equal") +} + +// PoolDepositorSharesEqual asserts the depositor owns the shares for the provided pool +func (suite *Suite) PoolDepositorSharesEqual(depositor sdk.AccAddress, poolID string, shares sdk.Int) { + shareRecord, found := suite.Keeper.GetDepositorShares(suite.Ctx, depositor, poolID) + suite.Require().True(found, fmt.Sprintf("expected share record to exist for depositor %s and pool %s", depositor.String(), poolID)) + suite.Equal(shares, shareRecord.SharesOwned) +} + +// PoolReservesEqual assets the stored pool reserves are equal to the provided reserves +func (suite *Suite) PoolReservesEqual(poolID string, reserves sdk.Coins) { + poolRecord, found := suite.Keeper.GetPool(suite.Ctx, poolID) + suite.Require().True(found, fmt.Sprintf("expected pool %s to exist", poolID)) + suite.Equal(reserves, poolRecord.Reserves(), "expected pool reserves to be equal") +} + +// PoolShareValueEqual asserts that the depositor shares are in state and the value matches the expected coins +func (suite *Suite) PoolShareValueEqual(depositor authexported.Account, pool swap.AllowedPool, coins sdk.Coins) { + poolRecord, ok := suite.Keeper.GetPool(suite.Ctx, pool.Name()) + suite.Require().True(ok, fmt.Sprintf("expected pool %s to exist", pool.Name())) + shares, ok := suite.Keeper.GetDepositorShares(suite.Ctx, depositor.GetAddress(), poolRecord.PoolID) + suite.Require().True(ok, fmt.Sprintf("expected shares to exist for depositor %s", depositor.GetAddress())) + + storedPool, err := swap.NewDenominatedPoolWithExistingShares(sdk.NewCoins(poolRecord.ReservesA, poolRecord.ReservesB), poolRecord.TotalShares) + suite.Nil(err) + value := storedPool.ShareValue(shares.SharesOwned) + suite.Equal(coins, value, fmt.Sprintf("expected shares to equal %s, but got %s", coins, value)) +} + +// PoolShareValueDelta asserts that the depositor shares are in state and the value is within delta of the expected coins +func (suite *Suite) PoolShareValueDelta(depositor authexported.Account, pool swap.AllowedPool, coins sdk.Coins, delta float64) { + poolRecord, ok := suite.Keeper.GetPool(suite.Ctx, pool.Name()) + suite.Require().True(ok, fmt.Sprintf("expected pool %s to exist", pool.Name())) + shares, ok := suite.Keeper.GetDepositorShares(suite.Ctx, depositor.GetAddress(), poolRecord.PoolID) + suite.Require().True(ok, fmt.Sprintf("expected shares to exist for depositor %s", depositor.GetAddress())) + + storedPool, err := swap.NewDenominatedPoolWithExistingShares(sdk.NewCoins(poolRecord.ReservesA, poolRecord.ReservesB), poolRecord.TotalShares) + suite.Nil(err) + value := storedPool.ShareValue(shares.SharesOwned) + + for _, coin := range coins { + suite.InDelta( + coin.Amount.Int64(), + value.AmountOf(coin.Denom).Int64(), + delta, + fmt.Sprintf("expected shares to be within delta %f of %s, but got %s", delta, coins, value), + ) + } +} + +// PoolSharesDeleted asserts that the pool shares have been removed +func (suite *Suite) PoolSharesDeleted(depositor sdk.AccAddress, denomA, denomB string) { + _, ok := suite.Keeper.GetDepositorShares(suite.Ctx, depositor, swap.PoolID(denomA, denomB)) + suite.Require().False(ok, "expected pool shares to not exist") +} + +// EventsContains asserts that the expected event is in the provided events +func (suite *Suite) 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)) +} + +func attrsToMap(attrs []kv.Pair) []sdk.Attribute { + out := []sdk.Attribute{} + + for _, attr := range attrs { + out = append(out, sdk.NewAttribute(string(attr.Key), string(attr.Value))) + } + + return out +} diff --git a/x/swap/types/base_pool.go b/x/swap/types/base_pool.go new file mode 100644 index 00000000..a34ccf26 --- /dev/null +++ b/x/swap/types/base_pool.go @@ -0,0 +1,437 @@ +package types + +import ( + "fmt" + "math/big" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" +) + +var ( + zero = sdk.ZeroInt() +) + +// calculateInitialShares calculates initial shares as sqrt(A*B), the geometric mean of A and B +func calculateInitialShares(reservesA, reservesB sdk.Int) sdk.Int { + // Big.Int allows multiplication without overflow at 255 bits. + // In addition, Sqrt converges to a correct solution for inputs + // where sdk.Int.ApproxSqrt does not converge due to exceeding + // 100 iterations. + var result big.Int + result.Mul(reservesA.BigInt(), reservesB.BigInt()).Sqrt(&result) + return sdk.NewIntFromBigInt(&result) +} + +// BasePool implements a unitless constant-product liquidity pool. +// +// The pool is symmetric. For all A,B,s, any operation F on a pool (A,B,s) and pool (B,A,s) +// will result in equal state values of A', B', s': F(A,B,s) => (A',B',s'), F(B,A,s) => (B',A',s') +// +// In addition, the pool is protected from overflow in intermediate calculations, and will +// only overflow when A, B, or s become larger than the max sdk.Int. +// +// Pool operations with non-positive values are invalid, and all functions on a pool will panic +// when given zero or negative values. +type BasePool struct { + reservesA sdk.Int + reservesB sdk.Int + totalShares sdk.Int +} + +// NewBasePool returns a pointer to a base pool with reserves and total shares initialized +func NewBasePool(reservesA, reservesB sdk.Int) (*BasePool, error) { + if reservesA.LTE(zero) || reservesB.LTE(zero) { + return nil, sdkerrors.Wrap(ErrInvalidPool, "reserves must be greater than zero") + } + + totalShares := calculateInitialShares(reservesA, reservesB) + + return &BasePool{ + reservesA: reservesA, + reservesB: reservesB, + totalShares: totalShares, + }, nil +} + +// NewBasePoolWithExistingShares returns a pointer to a base pool with existing shares +func NewBasePoolWithExistingShares(reservesA, reservesB, totalShares sdk.Int) (*BasePool, error) { + if reservesA.LTE(zero) || reservesB.LTE(zero) { + return nil, sdkerrors.Wrap(ErrInvalidPool, "reserves must be greater than zero") + } + + if totalShares.LTE(zero) { + return nil, sdkerrors.Wrap(ErrInvalidPool, "total shares must be greater than zero") + } + + return &BasePool{ + reservesA: reservesA, + reservesB: reservesB, + totalShares: totalShares, + }, nil +} + +// ReservesA returns the A reserves of the pool +func (p *BasePool) ReservesA() sdk.Int { + return p.reservesA +} + +// ReservesB returns the B reserves of the pool +func (p *BasePool) ReservesB() sdk.Int { + return p.reservesB +} + +// IsEmpty returns true if all reserves are zero and +// returns false if reserveA or reserveB is not empty +func (p *BasePool) IsEmpty() bool { + return p.reservesA.IsZero() && p.reservesB.IsZero() +} + +// TotalShares returns the total number of shares in the pool +func (p *BasePool) TotalShares() sdk.Int { + return p.totalShares +} + +// AddLiquidity adds liquidity to the pool returns the actual reservesA, reservesB deposits in addition +// to the number of shares created. The deposits are always less than or equal to the provided and desired +// values. +func (p *BasePool) AddLiquidity(desiredA sdk.Int, desiredB sdk.Int) (sdk.Int, sdk.Int, sdk.Int) { + // Panics if provided values are zero + p.assertDepositsArePositive(desiredA, desiredB) + + // Reinitialize the pool if reserves are empty and return the initialized state. + if p.IsEmpty() { + p.reservesA = desiredA + p.reservesB = desiredB + p.totalShares = calculateInitialShares(desiredA, desiredB) + return p.ReservesA(), p.ReservesB(), p.TotalShares() + } + + // Panics if reserveA or reserveB is zero. + p.assertReservesArePositive() + + // In order to preserve the reserve ratio of the pool, we must deposit + // A and B in the same ratio of the existing reserves. In addition, + // we should not deposit more funds than requested. + // + // To meet these requirements, we first calculate the optimalB to deposit + // if we keep desiredA fixed. If this is less than or equal to the desiredB, + // then we use (desiredA, optimalB) as the deposit. + // + // If the optimalB is greater than the desiredB, we calculate the optimalA + // from the desiredB and use (optimalA, optimalB) as the deposit. + // + // These optimal values are calculated as: + // + // optimalB = reservesB * desiredA / reservesA + // optimalA = reservesA * desiredB / reservesB + // + // Which shows us: + // + // if optimalB < desiredB then optimalA > desiredA + // if optimalB = desiredB then optimalA = desiredA + // if optimalB > desiredB then optimalA < desiredA + // + // so we first check if optimalB <= desiredB, then deposit + // (desiredA, optimalB) else deposit (optimalA, desiredA). + // + // In order avoid precision loss, we rearrange the inequality + // of optimalB <= desiredB + // from: + // reservesB * desiredA / reservesA <= desiredB + // to: + // reservesB * desiredA <= desiredB * reservesA + // + // which also shares the same intermediate products + // as the calculations for optimalB and optimalA. + actualA := desiredA.BigInt() + actualB := desiredB.BigInt() + + // productA = reservesB * desiredA + var productA big.Int + productA.Mul(p.reservesB.BigInt(), actualA) + + // productB = reservesA * desiredB + var productB big.Int + productB.Mul(p.reservesA.BigInt(), actualB) + + // optimalB <= desiredB + if productA.Cmp(&productB) <= 0 { + actualB.Quo(&productA, p.reservesA.BigInt()) + } else { // optimalA < desiredA + actualA.Quo(&productB, p.reservesB.BigInt()) + } + + var sharesA big.Int + sharesA.Mul(actualA, p.totalShares.BigInt()).Quo(&sharesA, p.reservesA.BigInt()) + + var sharesB big.Int + sharesB.Mul(actualB, p.totalShares.BigInt()).Quo(&sharesB, p.reservesB.BigInt()) + + // a/A and b/B may not be equal due to discrete math and truncation errors, + // so use the smallest deposit ratio to calculate the number of shares + // + // If we do not use the min or max ratio, then the result becomes + // dependent on the order of reserves in the pool + // + // Min is used to always ensure the share ratio is never larger + // than the deposit ratio for either A or B, ensuring there are no + // cases where a withdraw will allow funds to be removed at a higher ratio + // than it was deposited. + var shares sdk.Int + if sharesA.Cmp(&sharesB) <= 0 { + shares = sdk.NewIntFromBigInt(&sharesA) + } else { + shares = sdk.NewIntFromBigInt(&sharesB) + } + + depositA := sdk.NewIntFromBigInt(actualA) + depositB := sdk.NewIntFromBigInt(actualB) + + // update internal pool state + p.reservesA = p.reservesA.Add(depositA) + p.reservesB = p.reservesB.Add(depositB) + p.totalShares = p.totalShares.Add(shares) + + return depositA, depositB, shares +} + +// RemoveLiquidity removes liquidity from the pool and panics if the +// shares provided are greater than the total shares of the pool +// or the shares are not positive. +// In addition, also panics if reserves go negative, which should not happen. +// If panic occurs, it is a bug. +func (p *BasePool) RemoveLiquidity(shares sdk.Int) (sdk.Int, sdk.Int) { + // calculate amount to withdraw from the pool based + // on the number of shares provided. s/S * reserves + withdrawA, withdrawB := p.ShareValue(shares) + + // update internal pool state + p.reservesA = p.reservesA.Sub(withdrawA) + p.reservesB = p.reservesB.Sub(withdrawB) + p.totalShares = p.totalShares.Sub(shares) + + // Panics if reserveA or reserveB are negative + // A zero value (100% withdraw) is OK and should not panic. + p.assertReservesAreNotNegative() + + return withdrawA, withdrawB +} + +// SwapExactAForB trades an exact value of a for b. Returns the positive amount b +// that is removed from the pool and the portion of a that is used for paying the fee. +func (p *BasePool) SwapExactAForB(a sdk.Int, fee sdk.Dec) (sdk.Int, sdk.Int) { + b, feeValue := p.calculateOutputForExactInput(a, p.reservesA, p.reservesB, fee) + + p.assertInvariantAndUpdateReserves( + p.reservesA.Add(a), feeValue, p.reservesB.Sub(b), sdk.ZeroInt(), + ) + + return b, feeValue +} + +// SwapExactBForA trades an exact value of b for a. Returns the positive amount a +// that is removed from the pool and the portion of b that is used for paying the fee. +func (p *BasePool) SwapExactBForA(b sdk.Int, fee sdk.Dec) (sdk.Int, sdk.Int) { + a, feeValue := p.calculateOutputForExactInput(b, p.reservesB, p.reservesA, fee) + + p.assertInvariantAndUpdateReserves( + p.reservesA.Sub(a), sdk.ZeroInt(), p.reservesB.Add(b), feeValue, + ) + + return a, feeValue +} + +// calculateOutputForExactInput calculates the output amount of a swap using a fixed input, returning this amount in +// addition to the amount of input that is used to pay the fee. +// +// The fee is ceiled, ensuring a minimum fee of 1 and ensuring fees of a trade can not be reduced +// by splitting a trade into multiple trades. +// +// The swap output is truncated to ensure the pool invariant is always greater than or equal to the previous invariant. +func (p *BasePool) calculateOutputForExactInput(in, inReserves, outReserves sdk.Int, fee sdk.Dec) (sdk.Int, sdk.Int) { + p.assertSwapInputIsValid(in) + p.assertFeeIsValid(fee) + + inAfterFee := in.ToDec().Mul(sdk.OneDec().Sub(fee)).TruncateInt() + + var result big.Int + result.Mul(outReserves.BigInt(), inAfterFee.BigInt()) + result.Quo(&result, inReserves.Add(inAfterFee).BigInt()) + + out := sdk.NewIntFromBigInt(&result) + feeValue := in.Sub(inAfterFee) + + return out, feeValue +} + +// SwapAForExactB trades a for an exact b. Returns the positive amount a +// that is added to the pool, and the portion of a that is used to pay the fee. +func (p *BasePool) SwapAForExactB(b sdk.Int, fee sdk.Dec) (sdk.Int, sdk.Int) { + a, feeValue := p.calculateInputForExactOutput(b, p.reservesB, p.reservesA, fee) + + p.assertInvariantAndUpdateReserves( + p.reservesA.Add(a), feeValue, p.reservesB.Sub(b), sdk.ZeroInt(), + ) + + return a, feeValue +} + +// SwapBForExactA trades b for an exact a. Returns the positive amount b +// that is added to the pool, and the portion of b that is used to pay the fee. +func (p *BasePool) SwapBForExactA(a sdk.Int, fee sdk.Dec) (sdk.Int, sdk.Int) { + b, feeValue := p.calculateInputForExactOutput(a, p.reservesA, p.reservesB, fee) + + p.assertInvariantAndUpdateReserves( + p.reservesA.Sub(a), sdk.ZeroInt(), p.reservesB.Add(b), feeValue, + ) + + return b, feeValue +} + +// calculateInputForExactOutput calculates the input amount of a swap using a fixed output, returning this amount in +// addition to the amount of input that is used to pay the fee. +// +// The fee is ceiled, ensuring a minimum fee of 1 and ensuring fees of a trade can not be reduced +// by splitting a trade into multiple trades. +// +// The swap input is ceiled to ensure the pool invariant is always greater than or equal to the previous invariant. +func (p *BasePool) calculateInputForExactOutput(out, outReserves, inReserves sdk.Int, fee sdk.Dec) (sdk.Int, sdk.Int) { + p.assertSwapOutputIsValid(out, outReserves) + p.assertFeeIsValid(fee) + + var result big.Int + result.Mul(inReserves.BigInt(), out.BigInt()) + + newOutReserves := outReserves.Sub(out) + var remainder big.Int + result.QuoRem(&result, newOutReserves.BigInt(), &remainder) + + inWithoutFee := sdk.NewIntFromBigInt(&result) + if remainder.Sign() != 0 { + inWithoutFee = inWithoutFee.Add(sdk.OneInt()) + } + + in := inWithoutFee.ToDec().Quo(sdk.OneDec().Sub(fee)).Ceil().TruncateInt() + feeValue := in.Sub(inWithoutFee) + + return in, feeValue +} + +// ShareValue returns the value of the provided shares and panics +// if the shares are greater than the total shares of the pool or +// if the shares are not positive. +func (p *BasePool) ShareValue(shares sdk.Int) (sdk.Int, sdk.Int) { + p.assertSharesArePositive(shares) + p.assertSharesAreLessThanTotal(shares) + + var resultA big.Int + resultA.Mul(p.reservesA.BigInt(), shares.BigInt()) + resultA.Quo(&resultA, p.totalShares.BigInt()) + + var resultB big.Int + resultB.Mul(p.reservesB.BigInt(), shares.BigInt()) + resultB.Quo(&resultB, p.totalShares.BigInt()) + + return sdk.NewIntFromBigInt(&resultA), sdk.NewIntFromBigInt(&resultB) +} + +// assertInvariantAndUpdateRerserves asserts the constant product invariant is not violated, subtracting +// any fees first, then updates the pool reserves. Panics if invariant is violated. +func (p *BasePool) assertInvariantAndUpdateReserves(newReservesA, feeA, newReservesB, feeB sdk.Int) { + var invariant big.Int + invariant.Mul(p.reservesA.BigInt(), p.reservesB.BigInt()) + + var newInvariant big.Int + newInvariant.Mul(newReservesA.Sub(feeA).BigInt(), newReservesB.Sub(feeB).BigInt()) + + p.assertInvariant(&invariant, &newInvariant) + + p.reservesA = newReservesA + p.reservesB = newReservesB +} + +// assertSwapInputIsValid checks if the provided swap input is positive +// and panics if it is 0 or negative +func (p *BasePool) assertSwapInputIsValid(input sdk.Int) { + if !input.IsPositive() { + panic("invalid value: swap input must be positive") + } +} + +// assertSwapOutputIsValid checks if the provided swap input is positive and +// less than the provided reserves. +func (p *BasePool) assertSwapOutputIsValid(output sdk.Int, reserves sdk.Int) { + if !output.IsPositive() { + panic("invalid value: swap output must be positive") + } + + if output.GTE(reserves) { + panic("invalid value: swap output must be less than reserves") + } +} + +// assertFeeIsValid checks if the provided fee is less +func (p *BasePool) assertFeeIsValid(fee sdk.Dec) { + if fee.IsNegative() || fee.GTE(sdk.OneDec()) { + panic("invalid value: fee must be between 0 and 1") + } +} + +// assertSharesPositive panics if shares is zero or negative +func (p *BasePool) assertSharesArePositive(shares sdk.Int) { + if !shares.IsPositive() { + panic("invalid value: shares must be positive") + } +} + +// assertSharesLessThanTotal panics if the number of shares is greater than the total shares +func (p *BasePool) assertSharesAreLessThanTotal(shares sdk.Int) { + if shares.GT(p.totalShares) { + panic(fmt.Sprintf("out of bounds: shares %s > total shares %s", shares, p.totalShares)) + } +} + +// assertDepositsPositive panics if a deposit is zero or negative +func (p *BasePool) assertDepositsArePositive(depositA, depositB sdk.Int) { + if !depositA.IsPositive() { + panic("invalid value: deposit A must be positive") + } + + if !depositB.IsPositive() { + panic("invalid state: deposit B must be positive") + } +} + +// assertReservesArePositive panics if any reserves are zero. This is an invalid +// state that should never happen. If this panic is seen, it is a bug. +func (p *BasePool) assertReservesArePositive() { + if !p.reservesA.IsPositive() { + panic("invalid state: reserves A must be positive") + } + + if !p.reservesB.IsPositive() { + panic("invalid state: reserves B must be positive") + } +} + +// assertReservesAreNotNegative panics if any reserves are negative. This is an invalid +// state that should never happen. If this panic is seen, it is a bug. +func (p *BasePool) assertReservesAreNotNegative() { + if p.reservesA.IsNegative() { + panic("invalid state: reserves A can not be negative") + } + + if p.reservesB.IsNegative() { + panic("invalid state: reserves B can not be negative") + } +} + +// assertInvariant panics if the new invariant is less than the previous invariant. This +// is an invalid state that should never happen. If this panic is seen, it is a bug. +func (p *BasePool) assertInvariant(prevInvariant, newInvariant *big.Int) { + // invariant > newInvariant + if prevInvariant.Cmp(newInvariant) == 1 { + panic(fmt.Sprintf("invalid state: invariant %s decreased to %s", prevInvariant.String(), newInvariant.String())) + } +} diff --git a/x/swap/types/base_pool_test.go b/x/swap/types/base_pool_test.go new file mode 100644 index 00000000..32210c22 --- /dev/null +++ b/x/swap/types/base_pool_test.go @@ -0,0 +1,589 @@ +package types_test + +import ( + "fmt" + "math/big" + "testing" + + types "github.com/kava-labs/kava/x/swap/types" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// i creates a new sdk.Int from int64 +func i(n int64) sdk.Int { + return sdk.NewInt(n) +} + +// s returns a new sdk.Int from a string +func s(str string) sdk.Int { + num, ok := sdk.NewIntFromString(str) + if !ok { + panic(fmt.Sprintf("overflow creating Int from %s", str)) + } + return num +} + +// d creates a new sdk.Dec from a string +func d(str string) sdk.Dec { + return sdk.MustNewDecFromStr(str) +} + +// exp takes a sdk.Int and computes the power +// helper to generate large numbers +func exp(n sdk.Int, power int64) sdk.Int { + b := n.BigInt() + b.Exp(b, big.NewInt(power), nil) + return sdk.NewIntFromBigInt(b) +} + +func TestBasePool_NewPool_Validation(t *testing.T) { + testCases := []struct { + reservesA sdk.Int + reservesB sdk.Int + expectedErr string + }{ + {i(0), i(1e6), "invalid pool: reserves must be greater than zero"}, + {i(0), i(0), "invalid pool: reserves must be greater than zero"}, + {i(-1), i(1e6), "invalid pool: reserves must be greater than zero"}, + {i(1e6), i(-1), "invalid pool: reserves must be greater than zero"}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("reservesA=%s reservesB=%s", tc.reservesA, tc.reservesB), func(t *testing.T) { + pool, err := types.NewBasePool(tc.reservesA, tc.reservesB) + require.EqualError(t, err, tc.expectedErr) + assert.Nil(t, pool) + }) + } +} + +func TestBasePool_NewPoolWithExistingShares_Validation(t *testing.T) { + testCases := []struct { + reservesA sdk.Int + reservesB sdk.Int + totalShares sdk.Int + expectedErr string + }{ + {i(0), i(1e6), i(1), "invalid pool: reserves must be greater than zero"}, + {i(0), i(0), i(1), "invalid pool: reserves must be greater than zero"}, + {i(-1), i(1e6), i(3), "invalid pool: reserves must be greater than zero"}, + {i(1e6), i(-1), i(100), "invalid pool: reserves must be greater than zero"}, + {i(1e6), i(-1), i(3), "invalid pool: reserves must be greater than zero"}, + {i(1e6), i(1e6), i(0), "invalid pool: total shares must be greater than zero"}, + {i(1e6), i(1e6), i(-1), "invalid pool: total shares must be greater than zero"}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("reservesA=%s reservesB=%s shares=%s", tc.reservesA, tc.reservesB, tc.totalShares), func(t *testing.T) { + pool, err := types.NewBasePoolWithExistingShares(tc.reservesA, tc.reservesB, tc.totalShares) + require.EqualError(t, err, tc.expectedErr) + assert.Nil(t, pool) + }) + } +} + +func TestBasePool_InitialState(t *testing.T) { + testCases := []struct { + reservesA sdk.Int + reservesB sdk.Int + expectedShares sdk.Int + }{ + {i(1), i(1), i(1)}, + {i(100), i(100), i(100)}, + {i(100), i(10000000), i(31622)}, + {i(1e5), i(5e6), i(707106)}, + {i(1e6), i(5e6), i(2236067)}, + {i(1e15), i(7e15), i(2645751311064590)}, + {i(1), i(6e18), i(2449489742)}, + {i(1.345678e18), i(4.313456e18), i(2409257736973775913)}, + // handle sqrt of large numbers, sdk.Int.ApproxSqrt() doesn't converge in 100 iterations + {i(145345664).Mul(exp(i(10), 26)), i(6432294561).Mul(exp(i(10), 20)), s("96690543695447979624812468142651")}, + {i(465432423).Mul(exp(i(10), 50)), i(4565432).Mul(exp(i(10), 50)), s("4609663846531258725944608083913166083991595286362304230475")}, + {exp(i(2), 253), exp(i(2), 253), s("14474011154664524427946373126085988481658748083205070504932198000989141204992")}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("reservesA=%s reservesB=%s", tc.reservesA, tc.reservesB), func(t *testing.T) { + pool, err := types.NewBasePool(tc.reservesA, tc.reservesB) + require.Nil(t, err) + assert.Equal(t, tc.reservesA, pool.ReservesA()) + assert.Equal(t, tc.reservesB, pool.ReservesB()) + assert.Equal(t, tc.expectedShares, pool.TotalShares()) + }) + } +} + +func TestBasePool_ExistingState(t *testing.T) { + testCases := []struct { + reservesA sdk.Int + reservesB sdk.Int + totalShares sdk.Int + }{ + {i(1), i(1), i(1)}, + {i(100), i(100), i(100)}, + {i(1e5), i(5e6), i(707106)}, + {i(1e15), i(7e15), i(2645751311064590)}, + {i(465432423).Mul(exp(i(10), 50)), i(4565432).Mul(exp(i(10), 50)), s("4609663846531258725944608083913166083991595286362304230475")}, + {exp(i(2), 253), exp(i(2), 253), s("14474011154664524427946373126085988481658748083205070504932198000989141204992")}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("reservesA=%s reservesB=%s shares=%s", tc.reservesA, tc.reservesB, tc.totalShares), func(t *testing.T) { + pool, err := types.NewBasePoolWithExistingShares(tc.reservesA, tc.reservesB, tc.totalShares) + require.Nil(t, err) + assert.Equal(t, tc.reservesA, pool.ReservesA()) + assert.Equal(t, tc.reservesB, pool.ReservesB()) + assert.Equal(t, tc.totalShares, pool.TotalShares()) + }) + } +} + +func TestBasePool_ShareValue_PoolCreator(t *testing.T) { + testCases := []struct { + reservesA sdk.Int + reservesB sdk.Int + }{ + {i(1), i(1)}, + {i(100), i(100)}, + {i(100), i(10000000)}, + {i(1e5), i(5e6)}, + {i(1e15), i(7e15)}, + {i(1), i(6e18)}, + {i(1.345678e18), i(4.313456e18)}, + // ensure no overflows in intermediate values + {exp(i(2), 253), exp(i(2), 253)}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("reservesA=%s reservesB=%s", tc.reservesA, tc.reservesB), func(t *testing.T) { + pool, err := types.NewBasePool(tc.reservesA, tc.reservesB) + assert.NoError(t, err) + + a, b := pool.ShareValue(pool.TotalShares()) + // pool creators experience zero truncation error and always + // and always receive their original balance on a 100% withdraw + // when there are no other deposits that result in a fractional share ownership + assert.Equal(t, tc.reservesA, a, "share value of reserves A not equal") + assert.Equal(t, tc.reservesB, b, "share value of reserves B not equal") + }) + } +} + +func TestBasePool_AddLiquidity(t *testing.T) { + testCases := []struct { + initialA sdk.Int + initialB sdk.Int + desiredA sdk.Int + desiredB sdk.Int + expectedA sdk.Int + expectedB sdk.Int + expectedShares sdk.Int + }{ + {i(1), i(1), i(1), i(1), i(1), i(1), i(1)}, // small pool, i(100)% deposit + {i(10), i(10), i(5), i(5), i(5), i(5), i(5)}, // i(50)% deposit + {i(10), i(10), i(3), i(3), i(3), i(3), i(3)}, // i(30)% deposit + {i(10), i(10), i(1), i(1), i(1), i(1), i(1)}, // i(10)% deposit + + // small pools, unequal deposit ratios + {i(11), i(10), i(5), i(6), i(5), i(4), i(4)}, + {i(11), i(10), i(5), i(5), i(5), i(4), i(4)}, + // this test case fails if we don't use min share ratio + {i(11), i(10), i(5), i(4), i(4), i(4), i(3)}, + + // small pools, unequal deposit ratios, reversed + {i(10), i(11), i(6), i(5), i(4), i(5), i(4)}, + {i(10), i(11), i(5), i(5), i(4), i(5), i(4)}, + // this test case fails if we don't use min share ratio + {i(10), i(11), i(4), i(5), i(4), i(4), i(3)}, + + {i(10e6), i(11e6), i(5e6), i(5e6), i(4545454), i(5e6), i(4767312)}, + {i(11e6), i(10e6), i(5e6), i(5e6), i(5e6), i(4545454), i(4767312)}, + + // pool size near max of sdk.Int, ensure intermidiate calculations do not overflow + {exp(i(10), 70), exp(i(10), 70), i(1e18), i(1e18), i(1e18), i(1e18), i(1e18)}, + } + + for _, tc := range testCases { + name := fmt.Sprintf("initialA=%s initialB=%s desiredA=%s desiredB=%s", tc.initialA, tc.initialB, tc.desiredA, tc.desiredB) + t.Run(name, func(t *testing.T) { + pool, err := types.NewBasePool(tc.initialA, tc.initialB) + require.NoError(t, err) + initialShares := pool.TotalShares() + + actualA, actualB, actualShares := pool.AddLiquidity(tc.desiredA, tc.desiredB) + + // assert correct values are retruned + assert.Equal(t, tc.expectedA, actualA, "deposited A liquidity not equal") + assert.Equal(t, tc.expectedB, actualB, "deposited B liquidity not equal") + assert.Equal(t, tc.expectedShares, actualShares, "calculated shares not equal") + + // assert pool liquidity and shares are updated + assert.Equal(t, tc.initialA.Add(actualA), pool.ReservesA(), "total reserves A not equal") + assert.Equal(t, tc.initialB.Add(actualB), pool.ReservesB(), "total reserves B not equal") + assert.Equal(t, initialShares.Add(actualShares), pool.TotalShares(), "total shares not equal") + + leftA := actualShares.BigInt() + leftA.Mul(leftA, tc.initialA.BigInt()) + rightA := initialShares.BigInt() + rightA.Mul(rightA, actualA.BigInt()) + + leftB := actualShares.BigInt() + leftB.Mul(leftB, tc.initialB.BigInt()) + rightB := initialShares.BigInt() + rightB.Mul(rightB, actualB.BigInt()) + + // assert that the share ratio is less than or equal to the deposit ratio + // actualShares / initialShares <= actualA / initialA + assert.True(t, leftA.Cmp(rightA) <= 0, "share ratio is greater than deposit A ratio") + // actualShares / initialShares <= actualB / initialB + assert.True(t, leftB.Cmp(rightB) <= 0, "share ratio is greater than deposit B ratio") + + // assert that share value of returned shares is not greater than the deposited amount + shareValueA, shareValueB := pool.ShareValue(actualShares) + assert.True(t, shareValueA.LTE(actualA), "share value A greater than deposited A") + assert.True(t, shareValueB.LTE(actualB), "share value B greater than deposited B") + }) + } +} + +func TestBasePool_RemoveLiquidity(t *testing.T) { + testCases := []struct { + reservesA sdk.Int + reservesB sdk.Int + shares sdk.Int + expectedA sdk.Int + expectedB sdk.Int + }{ + {i(1), i(1), i(1), i(1), i(1)}, + {i(100), i(100), i(50), i(50), i(50)}, + {i(100), i(10000000), i(10435), i(32), i(3299917)}, + {i(10000000), i(100), i(10435), i(3299917), i(32)}, + {i(1.345678e18), i(4.313456e18), i(3.134541e17), i(175078108044025869), i(561197935621412888)}, + // ensure no overflows in intermediate values + {exp(i(10), 70), exp(i(10), 70), i(1e18), i(1e18), i(1e18)}, + {exp(i(2), 253), exp(i(2), 253), exp(i(2), 253), exp(i(2), 253), exp(i(2), 253)}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("reservesA=%s reservesB=%s shares=%s", tc.reservesA, tc.reservesB, tc.shares), func(t *testing.T) { + pool, err := types.NewBasePool(tc.reservesA, tc.reservesB) + assert.NoError(t, err) + initialShares := pool.TotalShares() + + a, b := pool.RemoveLiquidity(tc.shares) + + // pool creators experience zero truncation error and always + // and always receive their original balance on a 100% withdraw + // when there are no other deposits that result in a fractional share ownership + assert.Equal(t, tc.expectedA, a, "withdrawn A not equal") + assert.Equal(t, tc.expectedB, b, "withdrawn B not equal") + + // asset that pool state is updated + assert.Equal(t, tc.reservesA.Sub(a), pool.ReservesA(), "reserves A after withdraw not equal") + assert.Equal(t, tc.reservesB.Sub(b), pool.ReservesB(), "reserves B after withdraw not equal") + assert.Equal(t, initialShares.Sub(tc.shares), pool.TotalShares(), "total shares after withdraw not equal") + }) + } +} + +func TestBasePool_Panic_OutOfBounds(t *testing.T) { + pool, err := types.NewBasePool(sdk.NewInt(100), sdk.NewInt(100)) + require.NoError(t, err) + + assert.Panics(t, func() { pool.ShareValue(pool.TotalShares().Add(sdk.NewInt(1))) }, "ShareValue did not panic when shares > totalShares") + assert.Panics(t, func() { pool.RemoveLiquidity(pool.TotalShares().Add(sdk.NewInt(1))) }, "RemoveLiquidity did not panic when shares > totalShares") +} + +func TestBasePool_EmptyAndRefill(t *testing.T) { + testCases := []struct { + reservesA sdk.Int + reservesB sdk.Int + }{ + {i(1), i(1)}, + {i(100), i(100)}, + {i(100), i(10000000)}, + {i(1e5), i(5e6)}, + {i(1e6), i(5e6)}, + {i(1e15), i(7e15)}, + {i(1), i(6e18)}, + {i(1.345678e18), i(4.313456e18)}, + {i(145345664).Mul(exp(i(10), 26)), i(6432294561).Mul(exp(i(10), 20))}, + {i(465432423).Mul(exp(i(10), 50)), i(4565432).Mul(exp(i(10), 50))}, + {exp(i(2), 253), exp(i(2), 253)}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("reservesA=%s reservesB=%s", tc.reservesA, tc.reservesB), func(t *testing.T) { + pool, err := types.NewBasePool(tc.reservesA, tc.reservesB) + require.NoError(t, err) + + initialShares := pool.TotalShares() + pool.RemoveLiquidity(initialShares) + + assert.True(t, pool.IsEmpty()) + assert.True(t, pool.TotalShares().IsZero(), "total shares are not depleted") + + pool.AddLiquidity(tc.reservesA, tc.reservesB) + assert.Equal(t, initialShares, pool.TotalShares(), "total shares not equal") + }) + } +} + +func TestBasePool_Panics_AddLiquidity(t *testing.T) { + assert.Panics(t, func() { + pool, err := types.NewBasePool(i(1e6), i(1e6)) + require.NoError(t, err) + + pool.AddLiquidity(i(0), i(1e6)) + }, "did not panic when reserve A is zero") + + assert.Panics(t, func() { + pool, err := types.NewBasePool(i(1e6), i(1e6)) + require.NoError(t, err) + + pool.AddLiquidity(i(-1), i(1e6)) + }, "did not panic when reserve A is negative") + + assert.Panics(t, func() { + pool, err := types.NewBasePool(i(1e6), i(1e6)) + require.NoError(t, err) + + pool.AddLiquidity(i(1e6), i(0)) + }, "did not panic when reserve B is zero") + + assert.Panics(t, func() { + pool, err := types.NewBasePool(i(1e6), i(1e6)) + require.NoError(t, err) + + pool.AddLiquidity(i(1e6), i(0)) + }, "did not panic when reserve B is zero") +} + +func TestBasePool_Panics_RemoveLiquidity(t *testing.T) { + assert.Panics(t, func() { + pool, err := types.NewBasePool(i(1e6), i(1e6)) + require.NoError(t, err) + + pool.RemoveLiquidity(i(0)) + }, "did not panic when shares are zero") + + assert.Panics(t, func() { + pool, err := types.NewBasePool(i(1e6), i(1e6)) + require.NoError(t, err) + + pool.RemoveLiquidity(i(-1)) + }, "did not panic when shares are negative") +} + +func TestBasePool_ReservesOnlyDepletedWithLastShare(t *testing.T) { + testCases := []struct { + reservesA sdk.Int + reservesB sdk.Int + }{ + {i(5), i(5)}, + {i(100), i(100)}, + {i(100), i(10000000)}, + {i(1e5), i(5e6)}, {i(1e6), i(5e6)}, + {i(1e15), i(7e15)}, + {i(1), i(6e18)}, + {i(1.345678e18), i(4.313456e18)}, + {i(145345664).Mul(exp(i(10), 26)), i(6432294561).Mul(exp(i(10), 20))}, + {i(465432423).Mul(exp(i(10), 50)), i(4565432).Mul(exp(i(10), 50))}, + {exp(i(2), 253), exp(i(2), 253)}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("reservesA=%s reservesB=%s", tc.reservesA, tc.reservesB), func(t *testing.T) { + pool, err := types.NewBasePool(tc.reservesA, tc.reservesB) + require.NoError(t, err) + + initialShares := pool.TotalShares() + pool.RemoveLiquidity(initialShares.Sub(i(1))) + + assert.False(t, pool.ReservesA().IsZero(), "reserves A equal to zero") + assert.False(t, pool.ReservesB().IsZero(), "reserves B equal to zero") + + pool.RemoveLiquidity(i(1)) + assert.True(t, pool.IsEmpty()) + }) + } +} + +func TestBasePool_Swap_ExactInput(t *testing.T) { + testCases := []struct { + reservesA sdk.Int + reservesB sdk.Int + exactInput sdk.Int + fee sdk.Dec + expectedOutput sdk.Int + expectedFee sdk.Int + }{ + // test small pools + {i(10), i(10), i(1), d("0.003"), i(0), i(1)}, + {i(10), i(10), i(3), d("0.003"), i(1), i(1)}, + {i(10), i(10), i(10), d("0.003"), i(4), i(1)}, + {i(10), i(10), i(91), d("0.003"), i(9), i(1)}, + // test fee values and ceil + {i(1e6), i(1e6), i(1000), d("0.003"), i(996), i(3)}, + {i(1e6), i(1e6), i(1000), d("0.0031"), i(995), i(4)}, + {i(1e6), i(1e6), i(1000), d("0.0039"), i(995), i(4)}, + {i(1e6), i(1e6), i(1000), d("0.001"), i(998), i(1)}, + {i(1e6), i(1e6), i(1000), d("0.025"), i(974), i(25)}, + {i(1e6), i(1e6), i(1000), d("0.1"), i(899), i(100)}, + {i(1e6), i(1e6), i(1000), d("0.5"), i(499), i(500)}, + // test various random pools and swaps + {i(10e6), i(500e6), i(1e6), d("0.0025"), i(45351216), i(2500)}, + {i(10e6), i(500e6), i(8e6), d("0.003456"), i(221794899), i(27648)}, + // test very large pools and swaps + {exp(i(2), 250), exp(i(2), 250), exp(i(2), 249), d("0.003"), s("601876423139828614225164081027182620796370196819963934493551943901658899790"), s("2713877091499598330239944961141122840311015265600950719674787125185463976")}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("reservesA=%s reservesB=%s exactInput=%s fee=%s", tc.reservesA, tc.reservesB, tc.exactInput, tc.fee), func(t *testing.T) { + poolA, err := types.NewBasePool(tc.reservesA, tc.reservesB) + require.NoError(t, err) + swapA, feeA := poolA.SwapExactAForB(tc.exactInput, tc.fee) + + poolB, err := types.NewBasePool(tc.reservesB, tc.reservesA) + require.NoError(t, err) + swapB, feeB := poolB.SwapExactBForA(tc.exactInput, tc.fee) + + // pool must be symmetric - if we swap reserves, then swap opposite direction + // then the results should be equal + require.Equal(t, swapA, swapB, "expected swap methods to have equal swap results") + require.Equal(t, feeA, feeB, "expected swap methods to have equal fee results") + require.Equal(t, poolA.ReservesA(), poolB.ReservesB(), "expected reserves A to be equal") + require.Equal(t, poolA.ReservesB(), poolB.ReservesA(), "expected reserves B to be equal") + + assert.Equal(t, tc.expectedOutput, swapA, "returned swap not equal") + assert.Equal(t, tc.expectedFee, feeA, "returned fee not equal") + + expectedReservesA := tc.reservesA.Add(tc.exactInput) + expectedReservesB := tc.reservesB.Sub(tc.expectedOutput) + + assert.Equal(t, expectedReservesA, poolA.ReservesA(), "expected new reserves A not equal") + assert.Equal(t, expectedReservesB, poolA.ReservesB(), "expected new reserves B not equal") + }) + } +} + +func TestBasePool_Swap_ExactOutput(t *testing.T) { + testCases := []struct { + reservesA sdk.Int + reservesB sdk.Int + exactOutput sdk.Int + fee sdk.Dec + expectedInput sdk.Int + expectedFee sdk.Int + }{ + // test small pools + {i(10), i(10), i(1), d("0.003"), i(3), i(1)}, + {i(10), i(10), i(9), d("0.003"), i(91), i(1)}, + // test fee values and ceil + {i(1e6), i(1e6), i(996), d("0.003"), i(1000), i(3)}, + {i(1e6), i(1e6), i(995), d("0.0031"), i(1000), i(4)}, + {i(1e6), i(1e6), i(995), d("0.0039"), i(1000), i(4)}, + {i(1e6), i(1e6), i(998), d("0.001"), i(1000), i(1)}, + {i(1e6), i(1e6), i(974), d("0.025"), i(1000), i(25)}, + {i(1e6), i(1e6), i(899), d("0.1"), i(1000), i(100)}, + {i(1e6), i(1e6), i(499), d("0.5"), i(1000), i(500)}, + // test various random pools and swaps + {i(10e6), i(500e6), i(45351216), d("0.0025"), i(1e6), i(2500)}, + {i(10e6), i(500e6), i(221794899), d("0.003456"), i(8e6), i(27648)}, + // test very large pools and swaps + {exp(i(2), 250), exp(i(2), 250), s("601876423139828614225164081027182620796370196819963934493551943901658899790"), d("0.003"), s("904625697166532776746648320380374280103671755200316906558262375061821325311"), s("2713877091499598330239944961141122840311015265600950719674787125185463976")}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("reservesA=%s reservesB=%s exactOutput=%s fee=%s", tc.reservesA, tc.reservesB, tc.exactOutput, tc.fee), func(t *testing.T) { + poolA, err := types.NewBasePool(tc.reservesA, tc.reservesB) + require.NoError(t, err) + swapA, feeA := poolA.SwapAForExactB(tc.exactOutput, tc.fee) + + poolB, err := types.NewBasePool(tc.reservesB, tc.reservesA) + require.NoError(t, err) + swapB, feeB := poolB.SwapBForExactA(tc.exactOutput, tc.fee) + + // pool must be symmetric - if we swap reserves, then swap opposite direction + // then the results should be equal + require.Equal(t, swapA, swapB, "expected swap methods to have equal swap results") + require.Equal(t, feeA, feeB, "expected swap methods to have equal fee results") + require.Equal(t, poolA.ReservesA(), poolB.ReservesB(), "expected reserves A to be equal") + require.Equal(t, poolA.ReservesB(), poolB.ReservesA(), "expected reserves B to be equal") + + assert.Equal(t, tc.expectedInput.String(), swapA.String(), "returned swap not equal") + assert.Equal(t, tc.expectedFee, feeA, "returned fee not equal") + + expectedReservesA := tc.reservesA.Add(tc.expectedInput) + expectedReservesB := tc.reservesB.Sub(tc.exactOutput) + + assert.Equal(t, expectedReservesA, poolA.ReservesA(), "expected new reserves A not equal") + assert.Equal(t, expectedReservesB, poolA.ReservesB(), "expected new reserves B not equal") + }) + } +} + +func TestBasePool_Panics_Swap_ExactInput(t *testing.T) { + testCases := []struct { + swap sdk.Int + fee sdk.Dec + }{ + {i(0), d("0.003")}, + {i(-1), d("0.003")}, + {i(1), d("1")}, + {i(1), d("-0.003")}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("swap=%s fee=%s", tc.swap, tc.fee), func(t *testing.T) { + assert.Panics(t, func() { + pool, err := types.NewBasePool(i(1e6), i(1e6)) + require.NoError(t, err) + + pool.SwapExactAForB(tc.swap, tc.fee) + }, "SwapExactAForB did not panic") + + assert.Panics(t, func() { + pool, err := types.NewBasePool(i(1e6), i(1e6)) + require.NoError(t, err) + + pool.SwapExactBForA(tc.swap, tc.fee) + }, "SwapExactBForA did not panic") + }) + } +} + +func TestBasePool_Panics_Swap_ExactOutput(t *testing.T) { + testCases := []struct { + swap sdk.Int + fee sdk.Dec + }{ + {i(0), d("0.003")}, + {i(-1), d("0.003")}, + {i(1), d("1")}, + {i(1), d("-0.003")}, + {i(1000000), d("0.003")}, + {i(1000001), d("0.003")}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("swap=%s fee=%s", tc.swap, tc.fee), func(t *testing.T) { + assert.Panics(t, func() { + pool, err := types.NewBasePool(i(1e6), i(1e6)) + require.NoError(t, err) + + pool.SwapAForExactB(tc.swap, tc.fee) + }, "SwapAForExactB did not panic") + + assert.Panics(t, func() { + pool, err := types.NewBasePool(i(1e6), i(1e6)) + require.NoError(t, err) + + pool.SwapBForExactA(tc.swap, tc.fee) + }, "SwapBForExactA did not panic") + }) + } +} diff --git a/x/swap/types/codec.go b/x/swap/types/codec.go index 6548cfcb..d52f25bc 100644 --- a/x/swap/types/codec.go +++ b/x/swap/types/codec.go @@ -14,5 +14,6 @@ func init() { // RegisterCodec registers the necessary types for swap module func RegisterCodec(cdc *codec.Codec) { - cdc.RegisterConcrete(AllowedPool{}, "swap/AllowedPool", nil) + cdc.RegisterConcrete(MsgDeposit{}, "swap/MsgDeposit", nil) + cdc.RegisterConcrete(MsgWithdraw{}, "swap/MsgWithdraw", nil) } diff --git a/x/swap/types/common_test.go b/x/swap/types/common_test.go new file mode 100644 index 00000000..fa6189ce --- /dev/null +++ b/x/swap/types/common_test.go @@ -0,0 +1,13 @@ +package types_test + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/kava-labs/kava/app" +) + +func init() { + kavaConfig := sdk.GetConfig() + app.SetBech32AddressPrefixes(kavaConfig) + app.SetBip44CoinType(kavaConfig) + kavaConfig.Seal() +} diff --git a/x/swap/types/denominated_pool.go b/x/swap/types/denominated_pool.go new file mode 100644 index 00000000..6e2d7fdf --- /dev/null +++ b/x/swap/types/denominated_pool.go @@ -0,0 +1,159 @@ +package types + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" +) + +// DenominatedPool implements a denominated constant-product liquidity pool +type DenominatedPool struct { + // all pool operations are implemented in a unitless base pool + pool *BasePool + // track units of the reserveA and reserveB in base pool + denomA string + denomB string +} + +// NewDenominatedPool creates a new denominated pool from reserve coins +func NewDenominatedPool(reserves sdk.Coins) (*DenominatedPool, error) { + if len(reserves) != 2 { + return nil, sdkerrors.Wrap(ErrInvalidPool, "reserves must have two denominations") + } + + // Coins should always sorted, so this is deterministic, though it does not need to be. + // The base pool calculation results do not depend on reserve order. + reservesA := reserves[0] + reservesB := reserves[1] + + pool, err := NewBasePool(reservesA.Amount, reservesB.Amount) + if err != nil { + return nil, err + } + + return &DenominatedPool{ + pool: pool, + denomA: reservesA.Denom, + denomB: reservesB.Denom, + }, nil +} + +// NewDenominatedPoolWithExistingShares creates a new denominated pool from reserve coins +func NewDenominatedPoolWithExistingShares(reserves sdk.Coins, totalShares sdk.Int) (*DenominatedPool, error) { + if len(reserves) != 2 { + return nil, sdkerrors.Wrap(ErrInvalidPool, "reserves must have two denominations") + } + + // Coins should always sorted, so this is deterministic, though it does not need to be. + // The base pool calculation results do not depend on reserve order. + reservesA := reserves[0] + reservesB := reserves[1] + + pool, err := NewBasePoolWithExistingShares(reservesA.Amount, reservesB.Amount, totalShares) + if err != nil { + return nil, err + } + + return &DenominatedPool{ + pool: pool, + denomA: reservesA.Denom, + denomB: reservesB.Denom, + }, nil +} + +// Reserves returns the reserves held in the pool +func (p *DenominatedPool) Reserves() sdk.Coins { + return p.coins(p.pool.ReservesA(), p.pool.ReservesB()) +} + +// TotalShares returns the total shares for the pool +func (p *DenominatedPool) TotalShares() sdk.Int { + return p.pool.TotalShares() +} + +// IsEmpty returns true if the pool is empty +func (p *DenominatedPool) IsEmpty() bool { + return p.pool.IsEmpty() +} + +// AddLiquidity adds liquidity to the reserves and returns the added amount and shares created +func (p *DenominatedPool) AddLiquidity(deposit sdk.Coins) (sdk.Coins, sdk.Int) { + desiredA := deposit.AmountOf(p.denomA) + desiredB := deposit.AmountOf(p.denomB) + + actualA, actualB, shares := p.pool.AddLiquidity(desiredA, desiredB) + + return p.coins(actualA, actualB), shares +} + +// RemoveLiquidity removes liquidity from the pool +func (p *DenominatedPool) RemoveLiquidity(shares sdk.Int) sdk.Coins { + withdrawnA, withdrawnB := p.pool.RemoveLiquidity(shares) + + return p.coins(withdrawnA, withdrawnB) +} + +// ShareValue returns the value of the provided shares +func (p *DenominatedPool) ShareValue(shares sdk.Int) sdk.Coins { + valueA, valueB := p.pool.ShareValue(shares) + + return p.coins(valueA, valueB) +} + +// SwapWithExactInput trades an exact input coin for the other. Returns the positive other coin amount +// that is removed from the pool and the portion of the input coin that is used for the fee. +// It panics if the input denom does not match the pool reserves. +func (p *DenominatedPool) SwapWithExactInput(swapInput sdk.Coin, fee sdk.Dec) (sdk.Coin, sdk.Coin) { + var ( + swapOutput sdk.Int + feePaid sdk.Int + ) + + switch swapInput.Denom { + case p.denomA: + swapOutput, feePaid = p.pool.SwapExactAForB(swapInput.Amount, fee) + return p.coinB(swapOutput), p.coinA(feePaid) + case p.denomB: + swapOutput, feePaid = p.pool.SwapExactBForA(swapInput.Amount, fee) + return p.coinA(swapOutput), p.coinB(feePaid) + default: + panic(fmt.Sprintf("invalid denomination: denom '%s' does not match pool reserves", swapInput.Denom)) + } +} + +// SwapWithExactOutput trades a coin for an exact output coin b. Returns the positive input coin +// that is added to the pool, and the portion of that input that is used to pay the fee. +// Panics if the output denom does not match the pool reserves. +func (p *DenominatedPool) SwapWithExactOutput(swapOutput sdk.Coin, fee sdk.Dec) (sdk.Coin, sdk.Coin) { + var ( + swapInput sdk.Int + feePaid sdk.Int + ) + + switch swapOutput.Denom { + case p.denomA: + swapInput, feePaid = p.pool.SwapBForExactA(swapOutput.Amount, fee) + return p.coinB(swapInput), p.coinB(feePaid) + case p.denomB: + swapInput, feePaid = p.pool.SwapAForExactB(swapOutput.Amount, fee) + return p.coinA(swapInput), p.coinA(feePaid) + default: + panic(fmt.Sprintf("invalid denomination: denom '%s' does not match pool reserves", swapOutput.Denom)) + } +} + +// coins returns a new coins slice with correct reserve denoms from ordered sdk.Ints +func (p *DenominatedPool) coins(amountA, amountB sdk.Int) sdk.Coins { + return sdk.NewCoins(p.coinA(amountA), p.coinB(amountB)) +} + +// coinA returns a new coin denominated in denomA +func (p *DenominatedPool) coinA(amount sdk.Int) sdk.Coin { + return sdk.NewCoin(p.denomA, amount) +} + +// coinA returns a new coin denominated in denomB +func (p *DenominatedPool) coinB(amount sdk.Int) sdk.Coin { + return sdk.NewCoin(p.denomB, amount) +} diff --git a/x/swap/types/denominated_pool_test.go b/x/swap/types/denominated_pool_test.go new file mode 100644 index 00000000..e986b1db --- /dev/null +++ b/x/swap/types/denominated_pool_test.go @@ -0,0 +1,182 @@ +package types_test + +import ( + "fmt" + "testing" + + types "github.com/kava-labs/kava/x/swap/types" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// create a new ukava coin from int64 +func ukava(amount int64) sdk.Coin { + return sdk.NewCoin("ukava", sdk.NewInt(amount)) +} + +// create a new usdx coin from int64 +func usdx(amount int64) sdk.Coin { + return sdk.NewCoin("usdx", sdk.NewInt(amount)) +} + +// create a new hard coin from int64 +func hard(amount int64) sdk.Coin { + return sdk.NewCoin("hard", sdk.NewInt(amount)) +} + +func TestDenominatedPool_NewDenominatedPool_Validation(t *testing.T) { + testCases := []struct { + reservesA sdk.Coin + reservesB sdk.Coin + expectedErr string + }{ + {ukava(0), usdx(1e6), "invalid pool: reserves must have two denominations"}, + {ukava(1e6), usdx(0), "invalid pool: reserves must have two denominations"}, + {usdx(0), ukava(1e6), "invalid pool: reserves must have two denominations"}, + {usdx(0), ukava(1e6), "invalid pool: reserves must have two denominations"}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("reservesA=%s reservesB=%s", tc.reservesA, tc.reservesB), func(t *testing.T) { + pool, err := types.NewDenominatedPool(sdk.NewCoins(tc.reservesA, tc.reservesB)) + require.EqualError(t, err, tc.expectedErr) + assert.Nil(t, pool) + }) + } +} + +func TestDenominatedPool_NewDenominatedPoolWithExistingShares_Validation(t *testing.T) { + testCases := []struct { + reservesA sdk.Coin + reservesB sdk.Coin + totalShares sdk.Int + expectedErr string + }{ + {ukava(0), usdx(1e6), i(1), "invalid pool: reserves must have two denominations"}, + {usdx(0), ukava(1e6), i(1), "invalid pool: reserves must have two denominations"}, + {ukava(1e6), usdx(1e6), i(0), "invalid pool: total shares must be greater than zero"}, + {usdx(1e6), ukava(1e6), i(-1), "invalid pool: total shares must be greater than zero"}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("reservesA=%s reservesB=%s", tc.reservesA, tc.reservesB), func(t *testing.T) { + pool, err := types.NewDenominatedPoolWithExistingShares(sdk.NewCoins(tc.reservesA, tc.reservesB), tc.totalShares) + require.EqualError(t, err, tc.expectedErr) + assert.Nil(t, pool) + }) + } +} + +func TestDenominatedPool_InitialState(t *testing.T) { + reserves := sdk.NewCoins(ukava(1e6), usdx(5e6)) + totalShares := i(2236067) + + pool, err := types.NewDenominatedPool(reserves) + require.NoError(t, err) + + assert.Equal(t, pool.Reserves(), reserves) + assert.Equal(t, pool.TotalShares(), totalShares) +} + +func TestDenominatedPool_InitialState_ExistingShares(t *testing.T) { + reserves := sdk.NewCoins(ukava(1e6), usdx(5e6)) + totalShares := i(2e6) + + pool, err := types.NewDenominatedPoolWithExistingShares(reserves, totalShares) + require.NoError(t, err) + + assert.Equal(t, pool.Reserves(), reserves) + assert.Equal(t, pool.TotalShares(), totalShares) +} + +func TestDenominatedPool_ShareValue(t *testing.T) { + reserves := sdk.NewCoins(ukava(10e6), usdx(50e6)) + + pool, err := types.NewDenominatedPool(reserves) + require.NoError(t, err) + + assert.Equal(t, reserves, pool.ShareValue(pool.TotalShares())) + + halfReserves := sdk.NewCoins(ukava(4999999), usdx(24999998)) + assert.Equal(t, halfReserves, pool.ShareValue(pool.TotalShares().Quo(i(2)))) +} + +func TestDenominatedPool_AddLiquidity(t *testing.T) { + reserves := sdk.NewCoins(ukava(10e6), usdx(50e6)) + desired := sdk.NewCoins(ukava(1e6), usdx(1e6)) + + pool, err := types.NewDenominatedPool(reserves) + require.NoError(t, err) + initialShares := pool.TotalShares() + + deposit, shares := pool.AddLiquidity(desired) + require.True(t, shares.IsPositive()) + require.True(t, deposit.IsAllPositive()) + + assert.Equal(t, reserves.Add(deposit...), pool.Reserves()) + assert.Equal(t, initialShares.Add(shares), pool.TotalShares()) +} + +func TestDenominatedPool_RemoveLiquidity(t *testing.T) { + reserves := sdk.NewCoins(ukava(10e6), usdx(50e6)) + + pool, err := types.NewDenominatedPool(reserves) + require.NoError(t, err) + + withdraw := pool.RemoveLiquidity(pool.TotalShares()) + + assert.True(t, pool.Reserves().IsZero()) + assert.True(t, pool.TotalShares().IsZero()) + assert.True(t, pool.IsEmpty()) + assert.Equal(t, reserves, withdraw) +} + +func TestDenominatedPool_SwapWithExactInput(t *testing.T) { + reserves := sdk.NewCoins(ukava(10e6), usdx(50e6)) + + pool, err := types.NewDenominatedPool(reserves) + require.NoError(t, err) + + output, fee := pool.SwapWithExactInput(ukava(1e6), d("0.003")) + + assert.Equal(t, usdx(4533054), output) + assert.Equal(t, ukava(3000), fee) + assert.Equal(t, sdk.NewCoins(ukava(11e6), usdx(45466946)), pool.Reserves()) + + pool, err = types.NewDenominatedPool(reserves) + require.NoError(t, err) + + output, fee = pool.SwapWithExactInput(usdx(5e6), d("0.003")) + + assert.Equal(t, ukava(906610), output) + assert.Equal(t, usdx(15000), fee) + assert.Equal(t, sdk.NewCoins(ukava(9093390), usdx(55e6)), pool.Reserves()) + + assert.Panics(t, func() { pool.SwapWithExactInput(hard(1e6), d("0.003")) }, "SwapWithExactInput did not panic on invalid denomination") +} + +func TestDenominatedPool_SwapWithExactOuput(t *testing.T) { + reserves := sdk.NewCoins(ukava(10e6), usdx(50e6)) + + pool, err := types.NewDenominatedPool(reserves) + require.NoError(t, err) + + input, fee := pool.SwapWithExactOutput(ukava(1e6), d("0.003")) + + assert.Equal(t, usdx(5572273), input) + assert.Equal(t, usdx(16717), fee) + assert.Equal(t, sdk.NewCoins(ukava(9e6), usdx(55572273)), pool.Reserves()) + + pool, err = types.NewDenominatedPool(reserves) + require.NoError(t, err) + + input, fee = pool.SwapWithExactOutput(usdx(5e6), d("0.003")) + + assert.Equal(t, ukava(1114456), input) + assert.Equal(t, ukava(3344), fee) + assert.Equal(t, sdk.NewCoins(ukava(11114456), usdx(45e6)), pool.Reserves()) + + assert.Panics(t, func() { pool.SwapWithExactOutput(hard(1e6), d("0.003")) }, "SwapWithExactOutput did not panic on invalid denomination") +} diff --git a/x/swap/types/errors.go b/x/swap/types/errors.go index 2e5fcc32..efb27b97 100644 --- a/x/swap/types/errors.go +++ b/x/swap/types/errors.go @@ -4,8 +4,17 @@ import ( sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" ) -// DONTCOVER - +// swap module errors var ( - ErrCustom = sdkerrors.Register(ModuleName, 2, "") + ErrNotAllowed = sdkerrors.Register(ModuleName, 2, "not allowed") + ErrInvalidDeadline = sdkerrors.Register(ModuleName, 3, "invalid deadline") + ErrDeadlineExceeded = sdkerrors.Register(ModuleName, 4, "deadline exceeded") + ErrSlippageExceeded = sdkerrors.Register(ModuleName, 5, "slippage exceeded") + ErrInvalidPool = sdkerrors.Register(ModuleName, 6, "invalid pool") + ErrInvalidSlippage = sdkerrors.Register(ModuleName, 7, "invalid slippage") + ErrInsufficientLiquidity = sdkerrors.Register(ModuleName, 8, "insufficient liquidity") + ErrInvalidShares = sdkerrors.Register(ModuleName, 9, "invalid shares") + ErrDepositNotFound = sdkerrors.Register(ModuleName, 10, "deposit not found") + ErrInvalidCoin = sdkerrors.Register(ModuleName, 11, "invalid coin") + ErrNotImplemented = sdkerrors.Register(ModuleName, 12, "not implemented") ) diff --git a/x/swap/types/events.go b/x/swap/types/events.go index 2c2124dc..303672f2 100644 --- a/x/swap/types/events.go +++ b/x/swap/types/events.go @@ -2,5 +2,11 @@ package types // Event types for swap module const ( -// EventTypeCustom = "" + AttributeValueCategory = ModuleName + EventTypeSwapDeposit = "swap_deposit" + EventTypeSwapWithdraw = "swap_withdraw" + AttributeKeyPoolID = "pool_id" + AttributeKeyDepositor = "depositor" + AttributeKeyShares = "shares" + AttributeKeyOwner = "owner" ) diff --git a/x/swap/types/expected_keepers.go b/x/swap/types/expected_keepers.go new file mode 100644 index 00000000..a3cd26b3 --- /dev/null +++ b/x/swap/types/expected_keepers.go @@ -0,0 +1,22 @@ +package types // noalias + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + authexported "github.com/cosmos/cosmos-sdk/x/auth/exported" + "github.com/cosmos/cosmos-sdk/x/supply/exported" +) + +// SupplyKeeper defines the expected supply keeper (noalias) +type SupplyKeeper interface { + GetModuleAddress(name string) sdk.AccAddress + GetModuleAccount(ctx sdk.Context, name string) exported.ModuleAccountI + 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 +} + +// AccountKeeper defines the expected keeper interface for interacting with account (noalias) +type AccountKeeper interface { + GetAccount(ctx sdk.Context, addr sdk.AccAddress) authexported.Account + SetAccount(ctx sdk.Context, acc authexported.Account) +} diff --git a/x/swap/types/keys.go b/x/swap/types/keys.go index f3f0711b..eda04523 100644 --- a/x/swap/types/keys.go +++ b/x/swap/types/keys.go @@ -1,9 +1,16 @@ package types +import ( + sdk "github.com/cosmos/cosmos-sdk/types" +) + const ( // ModuleName name that will be used throughout the module ModuleName = "swap" + // ModuleAccountName name of module account used to hold liquidity + ModuleAccountName = "swap" + // StoreKey Top level store key where all module items will be stored StoreKey = ModuleName @@ -17,6 +24,27 @@ const ( DefaultParamspace = ModuleName ) +// key prefixes for store var ( -// KeyPrefix = []byte{0x01} + PoolKeyPrefix = []byte{0x01} + DepositorPoolSharesPrefix = []byte{0x02} + + sep = []byte(":") ) + +// PoolKey returns a key generated from a poolID +func PoolKey(poolID string) []byte { + return []byte(poolID) +} + +// DepositorPoolSharesKey returns a key from a depositor and poolID +func DepositorPoolSharesKey(depositor sdk.AccAddress, poolID string) []byte { + return createKey(depositor, sep, []byte(poolID)) +} + +func createKey(bytes ...[]byte) (r []byte) { + for _, b := range bytes { + r = append(r, b...) + } + return +} diff --git a/x/swap/types/keys_test.go b/x/swap/types/keys_test.go new file mode 100644 index 00000000..968b6f24 --- /dev/null +++ b/x/swap/types/keys_test.go @@ -0,0 +1,18 @@ +package types_test + +import ( + "testing" + + "github.com/kava-labs/kava/x/swap/types" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/assert" +) + +func TestKeys(t *testing.T) { + key := types.PoolKey("ukava/usdx") + assert.Equal(t, "ukava/usdx", string(key)) + + key = types.DepositorPoolSharesKey(sdk.AccAddress("testaddress1"), "ukava/usdx") + assert.Equal(t, string(sdk.AccAddress("testaddress1"))+":ukava/usdx", string(key)) +} diff --git a/x/swap/types/msg.go b/x/swap/types/msg.go index ab1254f4..a0f3c239 100644 --- a/x/swap/types/msg.go +++ b/x/swap/types/msg.go @@ -1 +1,181 @@ package types + +import ( + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" +) + +var ( + _ sdk.Msg = &MsgDeposit{} + _ MsgWithDeadline = &MsgDeposit{} + _ sdk.Msg = &MsgWithdraw{} + _ MsgWithDeadline = &MsgWithdraw{} +) + +// MsgWithDeadline allows messages to define a deadline of when they are considered invalid +type MsgWithDeadline interface { + GetDeadline() time.Time + DeadlineExceeded(blockTime time.Time) bool +} + +// MsgDeposit deposits liquidity into a pool +type MsgDeposit struct { + Depositor sdk.AccAddress `json:"depositor" yaml:"depositor"` + TokenA sdk.Coin `json:"token_a" yaml:"token_a"` + TokenB sdk.Coin `json:"token_b" yaml:"token_b"` + Slippage sdk.Dec `json:"slippage" yaml:"slippage"` + Deadline int64 `json:"deadline" yaml:"deadline"` +} + +// NewMsgDeposit returns a new MsgDeposit +func NewMsgDeposit(depositor sdk.AccAddress, tokenA sdk.Coin, tokenB sdk.Coin, slippage sdk.Dec, deadline int64) MsgDeposit { + return MsgDeposit{ + Depositor: depositor, + TokenA: tokenA, + TokenB: tokenB, + Slippage: slippage, + Deadline: deadline, + } +} + +// Route return the message type used for routing the message. +func (msg MsgDeposit) Route() string { return RouterKey } + +// Type returns a human-readable string for the message, intended for utilization within tags. +func (msg MsgDeposit) Type() string { return "swap_deposit" } + +// ValidateBasic does a simple validation check that doesn't require access to any other information. +func (msg MsgDeposit) ValidateBasic() error { + if msg.Depositor.Empty() { + return sdkerrors.Wrap(sdkerrors.ErrInvalidAddress, "depositor address cannot be empty") + } + + if !msg.TokenA.IsValid() || msg.TokenA.IsZero() { + return sdkerrors.Wrapf(sdkerrors.ErrInvalidCoins, "token a deposit amount %s", msg.TokenA) + } + + if !msg.TokenB.IsValid() || msg.TokenB.IsZero() { + return sdkerrors.Wrapf(sdkerrors.ErrInvalidCoins, "token b deposit amount %s", msg.TokenB) + } + + if msg.TokenA.Denom == msg.TokenB.Denom { + return sdkerrors.Wrap(sdkerrors.ErrInvalidCoins, "denominations can not be equal") + } + + if msg.Slippage.IsNil() { + return sdkerrors.Wrapf(ErrInvalidSlippage, "slippage must be set") + } + + if msg.Slippage.IsNegative() { + return sdkerrors.Wrapf(ErrInvalidSlippage, "slippage can not be negative") + } + + if msg.Deadline <= 0 { + return sdkerrors.Wrapf(ErrInvalidDeadline, "deadline %d", msg.Deadline) + } + + return nil +} + +// GetSignBytes gets the canonical byte representation of the Msg. +func (msg MsgDeposit) GetSignBytes() []byte { + bz := ModuleCdc.MustMarshalJSON(msg) + return sdk.MustSortJSON(bz) +} + +// GetSigners returns the addresses of signers that must sign. +func (msg MsgDeposit) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{msg.Depositor} +} + +// GetDeadline returns the time at which the msg is considered invalid +func (msg MsgDeposit) GetDeadline() time.Time { + return time.Unix(msg.Deadline, 0) +} + +// DeadlineExceeded returns if the msg has exceeded it's deadline +func (msg MsgDeposit) DeadlineExceeded(blockTime time.Time) bool { + return blockTime.Unix() >= msg.Deadline +} + +// MsgWithdraw deposits liquidity into a pool +type MsgWithdraw struct { + From sdk.AccAddress `json:"from" yaml:"from"` + Shares sdk.Int `json:"shares" yaml:"shares"` + MinTokenA sdk.Coin `json:"min_token_a" yaml:"min_token_a"` + MinTokenB sdk.Coin `json:"min_token_b" yaml:"min_token_b"` + Deadline int64 `json:"deadline" yaml:"deadline"` +} + +// NewMsgWithdraw returns a new MsgWithdraw +func NewMsgWithdraw(from sdk.AccAddress, shares sdk.Int, minTokenA, minTokenB sdk.Coin, deadline int64) MsgWithdraw { + return MsgWithdraw{ + From: from, + Shares: shares, + MinTokenA: minTokenA, + MinTokenB: minTokenB, + Deadline: deadline, + } +} + +// Route return the message type used for routing the message. +func (msg MsgWithdraw) Route() string { return RouterKey } + +// Type returns a human-readable string for the message, intended for utilization within tags. +func (msg MsgWithdraw) Type() string { return "swap_withdraw" } + +// ValidateBasic does a simple validation check that doesn't require access to any other information. +func (msg MsgWithdraw) ValidateBasic() error { + if msg.From.Empty() { + return sdkerrors.Wrap(sdkerrors.ErrInvalidAddress, "from address cannot be empty") + } + + if msg.Shares.IsNil() { + return sdkerrors.Wrapf(ErrInvalidShares, "shares must be set") + } + + if msg.Shares.IsZero() || msg.Shares.IsNegative() { + return sdkerrors.Wrapf(ErrInvalidShares, msg.Shares.String()) + } + + if !msg.MinTokenA.IsValid() || msg.MinTokenA.IsZero() { + return sdkerrors.Wrapf(sdkerrors.ErrInvalidCoins, "min token a amount %s", msg.MinTokenA) + } + + if !msg.MinTokenB.IsValid() || msg.MinTokenB.IsZero() { + return sdkerrors.Wrapf(sdkerrors.ErrInvalidCoins, "min token b amount %s", msg.MinTokenB) + } + + if msg.MinTokenA.Denom == msg.MinTokenB.Denom { + return sdkerrors.Wrap(sdkerrors.ErrInvalidCoins, "denominations can not be equal") + } + + if msg.Deadline <= 0 { + return sdkerrors.Wrapf(ErrInvalidDeadline, "deadline %d", msg.Deadline) + } + + return nil +} + +// GetSignBytes gets the canonical byte representation of the Msg. +func (msg MsgWithdraw) GetSignBytes() []byte { + bz := ModuleCdc.MustMarshalJSON(msg) + return sdk.MustSortJSON(bz) +} + +// GetSigners returns the addresses of signers that must sign. +func (msg MsgWithdraw) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{msg.From} +} + +// GetDeadline returns the time at which the msg is considered invalid +func (msg MsgWithdraw) GetDeadline() time.Time { + return time.Unix(msg.Deadline, 0) +} + +// DeadlineExceeded returns if the msg has exceeded it's deadline +func (msg MsgWithdraw) DeadlineExceeded(blockTime time.Time) bool { + return blockTime.Unix() >= msg.Deadline +} diff --git a/x/swap/types/msg_test.go b/x/swap/types/msg_test.go new file mode 100644 index 00000000..4e0402f5 --- /dev/null +++ b/x/swap/types/msg_test.go @@ -0,0 +1,408 @@ +package types_test + +import ( + "testing" + "time" + + "github.com/kava-labs/kava/x/swap/types" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMsgDeposit_Attributes(t *testing.T) { + msg := types.MsgDeposit{} + assert.Equal(t, "swap", msg.Route()) + assert.Equal(t, "swap_deposit", msg.Type()) +} + +func TestMsgDeposit_Signing(t *testing.T) { + signData := `{"type":"swap/MsgDeposit","value":{"deadline":"1623606299","depositor":"kava1gepm4nwzz40gtpur93alv9f9wm5ht4l0hzzw9d","slippage":"0.010000000000000000","token_a":{"amount":"1000000","denom":"ukava"},"token_b":{"amount":"5000000","denom":"usdx"}}}` + signBytes := []byte(signData) + + addr, err := sdk.AccAddressFromBech32("kava1gepm4nwzz40gtpur93alv9f9wm5ht4l0hzzw9d") + require.NoError(t, err) + + msg := types.NewMsgDeposit(addr, sdk.NewCoin("ukava", sdk.NewInt(1e6)), sdk.NewCoin("usdx", sdk.NewInt(5e6)), sdk.MustNewDecFromStr("0.01"), 1623606299) + assert.Equal(t, []sdk.AccAddress{addr}, msg.GetSigners()) + assert.Equal(t, signBytes, msg.GetSignBytes()) +} + +func TestMsgDeposit_Validation(t *testing.T) { + validMsg := types.NewMsgDeposit( + sdk.AccAddress("test1"), + sdk.NewCoin("ukava", sdk.NewInt(1e6)), + sdk.NewCoin("usdx", sdk.NewInt(5e6)), + sdk.MustNewDecFromStr("0.01"), + 1623606299, + ) + require.NoError(t, validMsg.ValidateBasic()) + + testCases := []struct { + name string + depositor sdk.AccAddress + tokenA sdk.Coin + tokenB sdk.Coin + slippage sdk.Dec + deadline int64 + expectedErr string + }{ + { + name: "empty address", + depositor: sdk.AccAddress(""), + tokenA: validMsg.TokenA, + tokenB: validMsg.TokenB, + slippage: validMsg.Slippage, + deadline: validMsg.Deadline, + expectedErr: "invalid address: depositor address cannot be empty", + }, + { + name: "negative token a", + depositor: validMsg.Depositor, + tokenA: sdk.Coin{Denom: "ukava", Amount: sdk.NewInt(-1)}, + tokenB: validMsg.TokenB, + slippage: validMsg.Slippage, + deadline: validMsg.Deadline, + expectedErr: "invalid coins: token a deposit amount -1ukava", + }, + { + name: "zero token a", + depositor: validMsg.Depositor, + tokenA: sdk.Coin{Denom: "ukava", Amount: sdk.NewInt(0)}, + tokenB: validMsg.TokenB, + slippage: validMsg.Slippage, + deadline: validMsg.Deadline, + expectedErr: "invalid coins: token a deposit amount 0ukava", + }, + { + name: "invalid denom token a", + depositor: validMsg.Depositor, + tokenA: sdk.Coin{Denom: "UKAVA", Amount: sdk.NewInt(1e6)}, + tokenB: validMsg.TokenB, + slippage: validMsg.Slippage, + deadline: validMsg.Deadline, + expectedErr: "invalid coins: token a deposit amount 1000000UKAVA", + }, + { + name: "negative token b", + depositor: validMsg.Depositor, + tokenA: validMsg.TokenA, + tokenB: sdk.Coin{Denom: "ukava", Amount: sdk.NewInt(-1)}, + slippage: validMsg.Slippage, + deadline: validMsg.Deadline, + expectedErr: "invalid coins: token b deposit amount -1ukava", + }, + { + name: "zero token b", + depositor: validMsg.Depositor, + tokenA: validMsg.TokenA, + tokenB: sdk.Coin{Denom: "ukava", Amount: sdk.NewInt(0)}, + slippage: validMsg.Slippage, + deadline: validMsg.Deadline, + expectedErr: "invalid coins: token b deposit amount 0ukava", + }, + { + name: "invalid denom token b", + depositor: validMsg.Depositor, + tokenA: validMsg.TokenA, + tokenB: sdk.Coin{Denom: "UKAVA", Amount: sdk.NewInt(1e6)}, + slippage: validMsg.Slippage, + deadline: validMsg.Deadline, + expectedErr: "invalid coins: token b deposit amount 1000000UKAVA", + }, + { + name: "denoms can not be the same", + depositor: validMsg.Depositor, + tokenA: sdk.Coin{Denom: "ukava", Amount: sdk.NewInt(1e6)}, + tokenB: sdk.Coin{Denom: "ukava", Amount: sdk.NewInt(1e6)}, + slippage: validMsg.Slippage, + deadline: validMsg.Deadline, + expectedErr: "invalid coins: denominations can not be equal", + }, + { + name: "zero deadline", + depositor: validMsg.Depositor, + tokenA: validMsg.TokenA, + tokenB: validMsg.TokenB, + slippage: validMsg.Slippage, + deadline: 0, + expectedErr: "invalid deadline: deadline 0", + }, + { + name: "negative deadline", + depositor: validMsg.Depositor, + tokenA: validMsg.TokenA, + tokenB: validMsg.TokenB, + slippage: validMsg.Slippage, + deadline: -1, + expectedErr: "invalid deadline: deadline -1", + }, + { + name: "negative slippage", + depositor: validMsg.Depositor, + tokenA: validMsg.TokenA, + tokenB: validMsg.TokenB, + slippage: sdk.MustNewDecFromStr("-0.01"), + deadline: validMsg.Deadline, + expectedErr: "invalid slippage: slippage can not be negative", + }, + { + name: "nil slippage", + depositor: validMsg.Depositor, + tokenA: validMsg.TokenA, + tokenB: validMsg.TokenB, + slippage: sdk.Dec{}, + deadline: validMsg.Deadline, + expectedErr: "invalid slippage: slippage must be set", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + msg := types.NewMsgDeposit(tc.depositor, tc.tokenA, tc.tokenB, tc.slippage, tc.deadline) + err := msg.ValidateBasic() + assert.EqualError(t, err, tc.expectedErr) + }) + } +} + +func TestMsgDeposit_Deadline(t *testing.T) { + blockTime := time.Now() + + testCases := []struct { + name string + deadline int64 + isExceeded bool + }{ + { + name: "deadline in future", + deadline: blockTime.Add(1 * time.Second).Unix(), + isExceeded: false, + }, + { + name: "deadline in past", + deadline: blockTime.Add(-1 * time.Second).Unix(), + isExceeded: true, + }, + { + name: "deadline is equal", + deadline: blockTime.Unix(), + isExceeded: true, + }, + } + + for _, tc := range testCases { + msg := types.NewMsgDeposit( + sdk.AccAddress("test1"), + sdk.NewCoin("ukava", sdk.NewInt(1e6)), + sdk.NewCoin("usdx", sdk.NewInt(5e6)), + sdk.MustNewDecFromStr("0.01"), + tc.deadline, + ) + require.NoError(t, msg.ValidateBasic()) + assert.Equal(t, tc.isExceeded, msg.DeadlineExceeded(blockTime)) + assert.Equal(t, time.Unix(tc.deadline, 0), msg.GetDeadline()) + } +} + +func TestMsgWithdraw_Attributes(t *testing.T) { + msg := types.MsgWithdraw{} + assert.Equal(t, "swap", msg.Route()) + assert.Equal(t, "swap_withdraw", msg.Type()) +} + +func TestMsgWithdraw_Signing(t *testing.T) { + signData := `{"type":"swap/MsgWithdraw","value":{"deadline":"1623606299","from":"kava1gepm4nwzz40gtpur93alv9f9wm5ht4l0hzzw9d","min_token_a":{"amount":"1000000","denom":"ukava"},"min_token_b":{"amount":"2000000","denom":"usdx"},"shares":"1500000"}}` + signBytes := []byte(signData) + + addr, err := sdk.AccAddressFromBech32("kava1gepm4nwzz40gtpur93alv9f9wm5ht4l0hzzw9d") + require.NoError(t, err) + + msg := types.NewMsgWithdraw( + addr, + sdk.NewInt(1500000), + sdk.NewCoin("ukava", sdk.NewInt(1000000)), + sdk.NewCoin("usdx", sdk.NewInt(2000000)), + 1623606299, + ) + assert.Equal(t, []sdk.AccAddress{addr}, msg.GetSigners()) + assert.Equal(t, signBytes, msg.GetSignBytes()) +} + +func TestMsgWithdraw_Validation(t *testing.T) { + validMsg := types.NewMsgWithdraw( + sdk.AccAddress("test1"), + sdk.NewInt(1500000), + sdk.NewCoin("ukava", sdk.NewInt(1000000)), + sdk.NewCoin("usdx", sdk.NewInt(2000000)), + 1623606299, + ) + require.NoError(t, validMsg.ValidateBasic()) + + testCases := []struct { + name string + from sdk.AccAddress + shares sdk.Int + minTokenA sdk.Coin + minTokenB sdk.Coin + deadline int64 + expectedErr string + }{ + { + name: "empty address", + from: sdk.AccAddress(""), + shares: validMsg.Shares, + minTokenA: validMsg.MinTokenA, + minTokenB: validMsg.MinTokenB, + deadline: validMsg.Deadline, + expectedErr: "invalid address: from address cannot be empty", + }, + { + name: "zero token a", + from: validMsg.From, + shares: validMsg.Shares, + minTokenA: sdk.Coin{Denom: "ukava", Amount: sdk.NewInt(0)}, + minTokenB: validMsg.MinTokenB, + deadline: validMsg.Deadline, + expectedErr: "invalid coins: min token a amount 0ukava", + }, + { + name: "invalid denom token a", + from: validMsg.From, + shares: validMsg.Shares, + minTokenA: sdk.Coin{Denom: "UKAVA", Amount: sdk.NewInt(1e6)}, + minTokenB: validMsg.MinTokenB, + deadline: validMsg.Deadline, + expectedErr: "invalid coins: min token a amount 1000000UKAVA", + }, + { + name: "negative token b", + from: validMsg.From, + shares: validMsg.Shares, + minTokenA: validMsg.MinTokenA, + minTokenB: sdk.Coin{Denom: "ukava", Amount: sdk.NewInt(-1)}, + deadline: validMsg.Deadline, + expectedErr: "invalid coins: min token b amount -1ukava", + }, + { + name: "zero token b", + from: validMsg.From, + shares: validMsg.Shares, + minTokenA: validMsg.MinTokenA, + minTokenB: sdk.Coin{Denom: "ukava", Amount: sdk.NewInt(0)}, + deadline: validMsg.Deadline, + expectedErr: "invalid coins: min token b amount 0ukava", + }, + { + name: "invalid denom token b", + from: validMsg.From, + shares: validMsg.Shares, + minTokenA: validMsg.MinTokenA, + minTokenB: sdk.Coin{Denom: "UKAVA", Amount: sdk.NewInt(1e6)}, + deadline: validMsg.Deadline, + expectedErr: "invalid coins: min token b amount 1000000UKAVA", + }, + { + name: "denoms can not be the same", + from: validMsg.From, + shares: validMsg.Shares, + minTokenA: sdk.Coin{Denom: "ukava", Amount: sdk.NewInt(1e6)}, + minTokenB: sdk.Coin{Denom: "ukava", Amount: sdk.NewInt(1e6)}, + deadline: validMsg.Deadline, + expectedErr: "invalid coins: denominations can not be equal", + }, + { + name: "zero shares", + from: validMsg.From, + shares: sdk.ZeroInt(), + minTokenA: validMsg.MinTokenA, + minTokenB: validMsg.MinTokenB, + deadline: validMsg.Deadline, + expectedErr: "invalid shares: 0", + }, + { + name: "negative shares", + from: validMsg.From, + shares: sdk.NewInt(-1), + minTokenA: validMsg.MinTokenA, + minTokenB: validMsg.MinTokenB, + deadline: validMsg.Deadline, + expectedErr: "invalid shares: -1", + }, + { + name: "nil shares", + from: validMsg.From, + shares: sdk.Int{}, + minTokenA: validMsg.MinTokenA, + minTokenB: validMsg.MinTokenB, + deadline: validMsg.Deadline, + expectedErr: "invalid shares: shares must be set", + }, + { + name: "zero deadline", + from: validMsg.From, + shares: validMsg.Shares, + minTokenA: validMsg.MinTokenA, + minTokenB: validMsg.MinTokenB, + deadline: 0, + expectedErr: "invalid deadline: deadline 0", + }, + { + name: "negative deadline", + from: validMsg.From, + shares: validMsg.Shares, + minTokenA: validMsg.MinTokenA, + minTokenB: validMsg.MinTokenB, + deadline: -1, + expectedErr: "invalid deadline: deadline -1", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + msg := types.NewMsgWithdraw(tc.from, tc.shares, tc.minTokenA, tc.minTokenB, tc.deadline) + err := msg.ValidateBasic() + assert.EqualError(t, err, tc.expectedErr) + }) + } +} + +func TestMsgWithdraw_Deadline(t *testing.T) { + blockTime := time.Now() + + testCases := []struct { + name string + deadline int64 + isExceeded bool + }{ + { + name: "deadline in future", + deadline: blockTime.Add(1 * time.Second).Unix(), + isExceeded: false, + }, + { + name: "deadline in past", + deadline: blockTime.Add(-1 * time.Second).Unix(), + isExceeded: true, + }, + { + name: "deadline is equal", + deadline: blockTime.Unix(), + isExceeded: true, + }, + } + + for _, tc := range testCases { + msg := types.NewMsgWithdraw( + sdk.AccAddress("test1"), + sdk.NewInt(1500000), + sdk.NewCoin("ukava", sdk.NewInt(1000000)), + sdk.NewCoin("usdx", sdk.NewInt(2000000)), + tc.deadline, + ) + require.NoError(t, msg.ValidateBasic()) + assert.Equal(t, tc.isExceeded, msg.DeadlineExceeded(blockTime)) + assert.Equal(t, time.Unix(tc.deadline, 0), msg.GetDeadline()) + } +} diff --git a/x/swap/types/params.go b/x/swap/types/params.go index a434f727..5924b6f7 100644 --- a/x/swap/types/params.go +++ b/x/swap/types/params.go @@ -51,7 +51,7 @@ func ParamKeyTable() params.KeyTable { return params.NewKeyTable().RegisterParamSet(&Params{}) } -// ParamSetAllowedPools implements the ParamSet interface and returns all the key/value pairs +// ParamSetPairs implements the ParamSet interface and returns all the key/value pairs func (p *Params) ParamSetPairs() params.ParamSetPairs { return params.ParamSetPairs{ params.NewParamSetPair(KeyAllowedPools, &p.AllowedPools, validateAllowedPoolsParams), @@ -83,9 +83,92 @@ func validateSwapFee(i interface{}) error { return fmt.Errorf("invalid parameter type: %T", i) } - if swapFee.IsNil() || swapFee.IsNegative() || swapFee.GT(MaxSwapFee) { + if swapFee.IsNil() || swapFee.IsNegative() || swapFee.GTE(MaxSwapFee) { return fmt.Errorf(fmt.Sprintf("invalid swap fee: %s", swapFee)) } return nil } + +// AllowedPool defines a tradable pool +type AllowedPool struct { + TokenA string `json:"token_a" yaml:"token_a"` + TokenB string `json:"token_b" yaml:"token_b"` +} + +// NewAllowedPool returns a new AllowedPool object +func NewAllowedPool(tokenA, tokenB string) AllowedPool { + return AllowedPool{ + TokenA: tokenA, + TokenB: tokenB, + } +} + +// Validate validates allowedPool attributes and returns an error if invalid +func (p AllowedPool) Validate() error { + err := sdk.ValidateDenom(p.TokenA) + if err != nil { + return err + } + + err = sdk.ValidateDenom(p.TokenB) + if err != nil { + return err + } + + if p.TokenA == p.TokenB { + return fmt.Errorf( + "pool cannot have two tokens of the same type, received '%s' and '%s'", + p.TokenA, p.TokenB, + ) + } + + if p.TokenA > p.TokenB { + return fmt.Errorf( + "invalid token order: '%s' must come before '%s'", + p.TokenB, p.TokenA, + ) + } + + return nil +} + +// Name returns the name for the allowed pool +func (p AllowedPool) Name() string { + return PoolID(p.TokenA, p.TokenB) +} + +// String pretty prints the allowedPool +func (p AllowedPool) String() string { + return fmt.Sprintf(`AllowedPool: + Name: %s + Token A: %s + Token B: %s +`, p.Name(), p.TokenA, p.TokenB) +} + +// AllowedPools is a slice of AllowedPool +type AllowedPools []AllowedPool + +// NewAllowedPools returns AllowedPools from the provided values +func NewAllowedPools(allowedPools ...AllowedPool) AllowedPools { + return AllowedPools(allowedPools) +} + +// Validate validates each allowedPool and returns an error if there are any duplicates +func (p AllowedPools) Validate() error { + seenAllowedPools := make(map[string]bool) + for _, allowedPool := range p { + err := allowedPool.Validate() + if err != nil { + return err + } + + if seen := seenAllowedPools[allowedPool.Name()]; seen { + return fmt.Errorf("duplicate pool: %s", allowedPool.Name()) + } + seenAllowedPools[allowedPool.Name()] = true + } + + return nil +} diff --git a/x/swap/types/params_test.go b/x/swap/types/params_test.go index d62b9f0a..aeb9a94f 100644 --- a/x/swap/types/params_test.go +++ b/x/swap/types/params_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "reflect" + "strings" "testing" "github.com/kava-labs/kava/x/swap/types" @@ -86,7 +87,7 @@ func TestParams_ParamSetPairs_AllowedPools(t *testing.T) { var paramSetPair *paramstypes.ParamSetPair for _, pair := range defaultParams.ParamSetPairs() { - if bytes.Compare(pair.Key, types.KeyAllowedPools) == 0 { + if bytes.Equal(pair.Key, types.KeyAllowedPools) { paramSetPair = &pair break } @@ -107,7 +108,7 @@ func TestParams_ParamSetPairs_SwapFee(t *testing.T) { var paramSetPair *paramstypes.ParamSetPair for _, pair := range defaultParams.ParamSetPairs() { - if bytes.Compare(pair.Key, types.KeySwapFee) == 0 { + if bytes.Equal(pair.Key, types.KeySwapFee) { paramSetPair = &pair break } @@ -183,7 +184,7 @@ func TestParams_Validation(t *testing.T) { testFn: func(params *types.Params) { params.SwapFee = sdk.OneDec() }, - expectedErr: "", + expectedErr: "invalid swap fee: 1.000000000000000000", }, } @@ -202,7 +203,7 @@ func TestParams_Validation(t *testing.T) { var paramSetPair *paramstypes.ParamSetPair for _, pair := range params.ParamSetPairs() { - if bytes.Compare(pair.Key, tc.key) == 0 { + if bytes.Equal(pair.Key, tc.key) { paramSetPair = &pair break } @@ -231,3 +232,177 @@ func TestParams_String(t *testing.T) { assert.Contains(t, output, "ukava/usdx") assert.Contains(t, output, "0.5") } + +func TestAllowedPool_Validation(t *testing.T) { + testCases := []struct { + name string + allowedPool types.AllowedPool + expectedErr string + }{ + { + name: "blank token a", + allowedPool: types.NewAllowedPool("", "ukava"), + expectedErr: "invalid denom: ", + }, + { + name: "blank token b", + allowedPool: types.NewAllowedPool("ukava", ""), + expectedErr: "invalid denom: ", + }, + { + name: "invalid token a", + allowedPool: types.NewAllowedPool("1ukava", "ukava"), + expectedErr: "invalid denom: 1ukava", + }, + { + name: "invalid token b", + allowedPool: types.NewAllowedPool("ukava", "1ukava"), + expectedErr: "invalid denom: 1ukava", + }, + { + name: "no uppercase letters token a", + allowedPool: types.NewAllowedPool("uKava", "ukava"), + expectedErr: "invalid denom: uKava", + }, + { + name: "no uppercase letters token b", + allowedPool: types.NewAllowedPool("ukava", "UKAVA"), + expectedErr: "invalid denom: UKAVA", + }, + { + name: "matching tokens", + allowedPool: types.NewAllowedPool("ukava", "ukava"), + expectedErr: "pool cannot have two tokens of the same type, received 'ukava' and 'ukava'", + }, + { + name: "invalid token order", + allowedPool: types.NewAllowedPool("usdx", "ukava"), + expectedErr: "invalid token order: 'ukava' must come before 'usdx'", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := tc.allowedPool.Validate() + assert.EqualError(t, err, tc.expectedErr) + }) + } +} + +// ensure no regression in case insentive token matching if +// sdk.ValidateDenom ever allows upper case letters +func TestAllowedPool_TokenMatch(t *testing.T) { + allowedPool := types.NewAllowedPool("UKAVA", "ukava") + err := allowedPool.Validate() + assert.Error(t, err) + + allowedPool = types.NewAllowedPool("hard", "haRd") + err = allowedPool.Validate() + assert.Error(t, err) + + allowedPool = types.NewAllowedPool("Usdx", "uSdX") + err = allowedPool.Validate() + assert.Error(t, err) +} + +func TestAllowedPool_String(t *testing.T) { + allowedPool := types.NewAllowedPool("hard", "ukava") + require.NoError(t, allowedPool.Validate()) + + output := `AllowedPool: + Name: hard/ukava + Token A: hard + Token B: ukava +` + assert.Equal(t, output, allowedPool.String()) +} + +func TestAllowedPool_Name(t *testing.T) { + testCases := []struct { + tokens string + name string + }{ + { + tokens: "atoken btoken", + name: "atoken/btoken", + }, + { + tokens: "aaa aaaa", + name: "aaa/aaaa", + }, + { + tokens: "aaaa aaab", + name: "aaaa/aaab", + }, + { + tokens: "a001 a002", + name: "a001/a002", + }, + { + tokens: "hard ukava", + name: "hard/ukava", + }, + { + tokens: "bnb hard", + name: "bnb/hard", + }, + { + tokens: "bnb xrpb", + name: "bnb/xrpb", + }, + } + + for _, tc := range testCases { + t.Run(tc.tokens, func(t *testing.T) { + tokens := strings.Split(tc.tokens, " ") + require.Equal(t, 2, len(tokens)) + + allowedPool := types.NewAllowedPool(tokens[0], tokens[1]) + require.NoError(t, allowedPool.Validate()) + + assert.Equal(t, tc.name, allowedPool.Name()) + }) + } +} + +func TestAllowedPools_Validate(t *testing.T) { + testCases := []struct { + name string + allowedPools types.AllowedPools + expectedErr string + }{ + { + name: "invalid pool", + allowedPools: types.NewAllowedPools( + types.NewAllowedPool("hard", "ukava"), + types.NewAllowedPool("HARD", "UKAVA"), + ), + expectedErr: "invalid denom: HARD", + }, + { + name: "duplicate pool", + allowedPools: types.NewAllowedPools( + types.NewAllowedPool("hard", "ukava"), + types.NewAllowedPool("hard", "ukava"), + ), + expectedErr: "duplicate pool: hard/ukava", + }, + { + name: "duplicate pools", + allowedPools: types.NewAllowedPools( + types.NewAllowedPool("hard", "ukava"), + types.NewAllowedPool("bnb", "usdx"), + types.NewAllowedPool("btcb", "xrpb"), + types.NewAllowedPool("bnb", "usdx"), + ), + expectedErr: "duplicate pool: bnb/usdx", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := tc.allowedPools.Validate() + assert.EqualError(t, err, tc.expectedErr) + }) + } +} diff --git a/x/swap/types/pool.go b/x/swap/types/pool.go deleted file mode 100644 index b3d2ab52..00000000 --- a/x/swap/types/pool.go +++ /dev/null @@ -1,90 +0,0 @@ -package types - -import ( - "fmt" - - sdk "github.com/cosmos/cosmos-sdk/types" -) - -// AllowedPool defines a tradable pool -type AllowedPool struct { - TokenA string `json:"token_a" yaml:"token_a"` - TokenB string `json:"token_b" yaml:"token_b"` -} - -// NewAllowedPool returns a new AllowedPool object -func NewAllowedPool(tokenA, tokenB string) AllowedPool { - return AllowedPool{ - TokenA: tokenA, - TokenB: tokenB, - } -} - -// Validate validates allowedPool attributes and returns an error if invalid -func (p AllowedPool) Validate() error { - err := sdk.ValidateDenom(p.TokenA) - if err != nil { - return err - } - - err = sdk.ValidateDenom(p.TokenB) - if err != nil { - return err - } - - if p.TokenA == p.TokenB { - return fmt.Errorf( - "pool cannot have two tokens of the same type, received '%s' and '%s'", - p.TokenA, p.TokenB, - ) - } - - if p.TokenA > p.TokenB { - return fmt.Errorf( - "invalid token order: '%s' must come before '%s'", - p.TokenB, p.TokenA, - ) - } - - return nil -} - -// Name returns a unique name for a allowedPool in alphabetical order -func (p AllowedPool) Name() string { - return fmt.Sprintf("%s/%s", p.TokenA, p.TokenB) -} - -// String pretty prints the allowedPool -func (p AllowedPool) String() string { - return fmt.Sprintf(`AllowedPool: - Name: %s - Token A: %s - Token B: %s -`, p.Name(), p.TokenA, p.TokenB) -} - -// AllowedPools is a slice of AllowedPool -type AllowedPools []AllowedPool - -// NewAllowedPools returns AllowedPools from the provided values -func NewAllowedPools(allowedPools ...AllowedPool) AllowedPools { - return AllowedPools(allowedPools) -} - -// Validate validates each allowedPool and returns an error if there are any duplicates -func (p AllowedPools) Validate() error { - seenAllowedPools := make(map[string]bool) - for _, allowedPool := range p { - err := allowedPool.Validate() - if err != nil { - return err - } - - if seen := seenAllowedPools[allowedPool.Name()]; seen { - return fmt.Errorf("duplicate pool: %s", allowedPool.Name()) - } - seenAllowedPools[allowedPool.Name()] = true - } - - return nil -} diff --git a/x/swap/types/pool_test.go b/x/swap/types/pool_test.go deleted file mode 100644 index fd1941b7..00000000 --- a/x/swap/types/pool_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package types_test - -import ( - "strings" - "testing" - - types "github.com/kava-labs/kava/x/swap/types" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestAllowedPool_Validation(t *testing.T) { - testCases := []struct { - name string - allowedPool types.AllowedPool - expectedErr string - }{ - { - name: "blank token a", - allowedPool: types.NewAllowedPool("", "ukava"), - expectedErr: "invalid denom: ", - }, - { - name: "blank token b", - allowedPool: types.NewAllowedPool("ukava", ""), - expectedErr: "invalid denom: ", - }, - { - name: "invalid token a", - allowedPool: types.NewAllowedPool("1ukava", "ukava"), - expectedErr: "invalid denom: 1ukava", - }, - { - name: "invalid token b", - allowedPool: types.NewAllowedPool("ukava", "1ukava"), - expectedErr: "invalid denom: 1ukava", - }, - { - name: "no uppercase letters token a", - allowedPool: types.NewAllowedPool("uKava", "ukava"), - expectedErr: "invalid denom: uKava", - }, - { - name: "no uppercase letters token b", - allowedPool: types.NewAllowedPool("ukava", "UKAVA"), - expectedErr: "invalid denom: UKAVA", - }, - { - name: "matching tokens", - allowedPool: types.NewAllowedPool("ukava", "ukava"), - expectedErr: "pool cannot have two tokens of the same type, received 'ukava' and 'ukava'", - }, - { - name: "invalid token order", - allowedPool: types.NewAllowedPool("usdx", "ukava"), - expectedErr: "invalid token order: 'ukava' must come before 'usdx'", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - err := tc.allowedPool.Validate() - assert.EqualError(t, err, tc.expectedErr) - }) - } -} - -// ensure no regression in case insentive token matching if -// sdk.ValidateDenom ever allows upper case letters -func TestAllowedPool_TokenMatch(t *testing.T) { - allowedPool := types.NewAllowedPool("UKAVA", "ukava") - err := allowedPool.Validate() - assert.Error(t, err) - - allowedPool = types.NewAllowedPool("hard", "haRd") - err = allowedPool.Validate() - assert.Error(t, err) - - allowedPool = types.NewAllowedPool("Usdx", "uSdX") - err = allowedPool.Validate() - assert.Error(t, err) -} - -func TestAllowedPool_String(t *testing.T) { - allowedPool := types.NewAllowedPool("hard", "ukava") - require.NoError(t, allowedPool.Validate()) - - output := `AllowedPool: - Name: hard/ukava - Token A: hard - Token B: ukava -` - assert.Equal(t, output, allowedPool.String()) -} - -func TestAllowedPool_Name(t *testing.T) { - testCases := []struct { - tokens string - name string - }{ - { - tokens: "atoken btoken", - name: "atoken/btoken", - }, - { - tokens: "aaa aaaa", - name: "aaa/aaaa", - }, - { - tokens: "aaaa aaab", - name: "aaaa/aaab", - }, - { - tokens: "a001 a002", - name: "a001/a002", - }, - { - tokens: "hard ukava", - name: "hard/ukava", - }, - { - tokens: "bnb hard", - name: "bnb/hard", - }, - { - tokens: "bnb xrpb", - name: "bnb/xrpb", - }, - } - - for _, tc := range testCases { - t.Run(tc.tokens, func(t *testing.T) { - tokens := strings.Split(tc.tokens, " ") - require.Equal(t, 2, len(tokens)) - - allowedPool := types.NewAllowedPool(tokens[0], tokens[1]) - require.NoError(t, allowedPool.Validate()) - - assert.Equal(t, tc.name, allowedPool.Name()) - }) - } -} - -func TestAllowedPools_Validate(t *testing.T) { - testCases := []struct { - name string - allowedPools types.AllowedPools - expectedErr string - }{ - { - name: "invalid pool", - allowedPools: types.NewAllowedPools( - types.NewAllowedPool("hard", "ukava"), - types.NewAllowedPool("HARD", "UKAVA"), - ), - expectedErr: "invalid denom: HARD", - }, - { - name: "duplicate pool", - allowedPools: types.NewAllowedPools( - types.NewAllowedPool("hard", "ukava"), - types.NewAllowedPool("hard", "ukava"), - ), - expectedErr: "duplicate pool: hard/ukava", - }, - { - name: "duplicate pools", - allowedPools: types.NewAllowedPools( - types.NewAllowedPool("hard", "ukava"), - types.NewAllowedPool("bnb", "usdx"), - types.NewAllowedPool("btcb", "xrpb"), - types.NewAllowedPool("bnb", "usdx"), - ), - expectedErr: "duplicate pool: bnb/usdx", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - err := tc.allowedPools.Validate() - assert.EqualError(t, err, tc.expectedErr) - }) - } -} diff --git a/x/swap/types/querier.go b/x/swap/types/querier.go index 211fa8d0..218a29ea 100644 --- a/x/swap/types/querier.go +++ b/x/swap/types/querier.go @@ -1,6 +1,81 @@ package types +import sdk "github.com/cosmos/cosmos-sdk/types" + // Querier routes for the swap module const ( - QueryGetParams = "params" + QueryGetParams = "params" + QueryGetDeposits = "deposits" + QueryGetPool = "pool" + QueryGetPools = "pools" ) + +// QueryDepositsParams is the params for a filtered deposits query +type QueryDepositsParams struct { + Page int `json:"page" yaml:"page"` + Limit int `json:"limit" yaml:"limit"` + Owner sdk.AccAddress `json:"owner" yaml:"owner"` + Pool string `json:"pool" yaml:"pool"` +} + +// NewQueryDepositsParams creates a new QueryDepositsParams +func NewQueryDepositsParams(page, limit int, owner sdk.AccAddress, pool string) QueryDepositsParams { + return QueryDepositsParams{ + Page: page, + Limit: limit, + Owner: owner, + Pool: pool, + } +} + +// DepositsQueryResult contains the result of a deposits query +type DepositsQueryResult struct { + Depositor sdk.AccAddress `json:"depositor" yaml:"depositor"` + PoolID string `json:"pool_id" yaml:"pool_id"` + SharesOwned sdk.Int `json:"shares_owned" yaml:"shares_owned"` + SharesValue sdk.Coins `json:"shares_value" yaml:"shares_value"` +} + +// NewDepositsQueryResult creates a new DepositsQueryResult +func NewDepositsQueryResult(shareRecord ShareRecord, sharesValue sdk.Coins) DepositsQueryResult { + return DepositsQueryResult{ + Depositor: shareRecord.Depositor, + PoolID: shareRecord.PoolID, + SharesOwned: shareRecord.SharesOwned, + SharesValue: sharesValue, + } +} + +// DepositsQueryResults is a slice of DepositsQueryResult +type DepositsQueryResults []DepositsQueryResult + +// QueryPoolParams is the params for a pool query +type QueryPoolParams struct { + Pool string `json:"pool" yaml:"pool"` +} + +// NewQueryPoolParams creates a new QueryPoolParams +func NewQueryPoolParams(pool string) QueryPoolParams { + return QueryPoolParams{ + Pool: pool, + } +} + +// PoolStatsQueryResult contains the result of a pool query +type PoolStatsQueryResult struct { + Name string `json:"name" yaml:"name"` + Coins sdk.Coins `json:"coins" yaml:"coins"` + TotalShares sdk.Int `json:"total_shares" yaml:"total_shares"` +} + +// NewPoolStatsQueryResult creates a new PoolStatsQueryResult +func NewPoolStatsQueryResult(name string, coins sdk.Coins, totalShares sdk.Int) PoolStatsQueryResult { + return PoolStatsQueryResult{ + Name: name, + Coins: coins, + TotalShares: totalShares, + } +} + +// PoolStatsQueryResults is a slice of PoolStatsQueryResult +type PoolStatsQueryResults []PoolStatsQueryResult diff --git a/x/swap/types/state.go b/x/swap/types/state.go new file mode 100644 index 00000000..04ca4e24 --- /dev/null +++ b/x/swap/types/state.go @@ -0,0 +1,76 @@ +package types + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// PoolIDFromCoins returns a poolID from a coins object +func PoolIDFromCoins(coins sdk.Coins) string { + return PoolID(coins[0].Denom, coins[1].Denom) +} + +// PoolID returns an alphabetically sorted pool name from two denoms. +// The name is commutative for any all pairs A,B: f(A,B) == f(B,A). +func PoolID(denomA string, denomB string) string { + if denomB < denomA { + return fmt.Sprintf("%s/%s", denomB, denomA) + } + + return fmt.Sprintf("%s/%s", denomA, denomB) +} + +// PoolRecord represents the state of a liquidity pool +// and is used to store the state of a denominated pool +type PoolRecord struct { + // primary key + PoolID string + 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 a pointer to a denominated pool and returns a +// pool record for storage in state. +func NewPoolRecord(pool *DenominatedPool) PoolRecord { + reserves := pool.Reserves() + poolID := PoolIDFromCoins(reserves) + + return PoolRecord{ + PoolID: poolID, + ReservesA: reserves[0], + ReservesB: reserves[1], + TotalShares: pool.TotalShares(), + } +} + +// PoolRecords is a slice of PoolRecord +type PoolRecords []PoolRecord + +// ShareRecord stores the shares owned for a depositor and pool +type ShareRecord struct { + // primary key + Depositor sdk.AccAddress `json:"depositor" yaml:"depositor"` + // secondary / sort key + PoolID string `json:"pool_id" yaml:"pool_id"` + SharesOwned sdk.Int `json:"shares_owned" yaml:"shares_owned"` +} + +// NewShareRecord takes a depositor, poolID, and shares and returns +// a new share record for storage in state. +func NewShareRecord(depositor sdk.AccAddress, poolID string, sharesOwned sdk.Int) ShareRecord { + return ShareRecord{ + Depositor: depositor, + PoolID: poolID, + SharesOwned: sharesOwned, + } +} + +// ShareRecords is a slice of ShareRecord +type ShareRecords []ShareRecord diff --git a/x/swap/types/state_test.go b/x/swap/types/state_test.go new file mode 100644 index 00000000..20dfbf03 --- /dev/null +++ b/x/swap/types/state_test.go @@ -0,0 +1,63 @@ +package types_test + +import ( + "testing" + + types "github.com/kava-labs/kava/x/swap/types" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestState_PoolID(t *testing.T) { + testCases := []struct { + reserveA string + reserveB string + expectedID string + }{ + {"atoken", "btoken", "atoken/btoken"}, + {"btoken", "atoken", "atoken/btoken"}, + {"aaa", "aaaa", "aaa/aaaa"}, + {"aaaa", "aaa", "aaa/aaaa"}, + {"aaaa", "aaab", "aaaa/aaab"}, + {"aaab", "aaaa", "aaaa/aaab"}, + {"a001", "a002", "a001/a002"}, + {"a002", "a001", "a001/a002"}, + } + + for _, tc := range testCases { + assert.Equal(t, tc.expectedID, types.PoolID(tc.reserveA, tc.reserveB)) + assert.Equal(t, tc.expectedID, types.PoolID(tc.reserveB, tc.reserveA)) + + assert.Equal(t, tc.expectedID, types.PoolIDFromCoins(sdk.NewCoins(sdk.NewCoin(tc.reserveA, i(1)), sdk.NewCoin(tc.reserveB, i(1))))) + assert.Equal(t, tc.expectedID, types.PoolIDFromCoins(sdk.NewCoins(sdk.NewCoin(tc.reserveB, i(1)), sdk.NewCoin(tc.reserveA, i(1))))) + } +} + +func TestState_NewPoolRecord(t *testing.T) { + reserves := sdk.NewCoins(usdx(50e6), ukava(10e6)) + + pool, err := types.NewDenominatedPool(reserves) + require.NoError(t, err) + + record := types.NewPoolRecord(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()) +} + +func TestState_NewShareRecord(t *testing.T) { + depositor := sdk.AccAddress("some user") + poolID := types.PoolID("ukava", "usdx") + shares := sdk.NewInt(1e6) + + record := types.NewShareRecord(depositor, poolID, shares) + + assert.Equal(t, depositor, record.Depositor) + assert.Equal(t, poolID, record.PoolID) + assert.Equal(t, shares, record.SharesOwned) +}