diff --git a/x/auction/abci.go b/x/auction/abci.go new file mode 100644 index 00000000..164b35b7 --- /dev/null +++ b/x/auction/abci.go @@ -0,0 +1,23 @@ +package auction + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// EndBlocker runs at the end of every block. +func EndBlocker(ctx sdk.Context, k Keeper) { + + // get an iterator of expired auctions + expiredAuctions := k.GetQueueIterator(ctx, EndTime(ctx.BlockHeight())) + defer expiredAuctions.Close() + + // loop through and close them - distribute funds, delete from store (and queue) + for ; expiredAuctions.Valid(); expiredAuctions.Next() { + + auctionID := k.DecodeAuctionID(ctx, expiredAuctions.Value()) + err := k.CloseAuction(ctx, auctionID) + if err != nil { + panic(err) // TODO how should errors be handled here? + } + } +} diff --git a/x/auction/abci_test.go b/x/auction/abci_test.go new file mode 100644 index 00000000..2fb36bda --- /dev/null +++ b/x/auction/abci_test.go @@ -0,0 +1,28 @@ +package auction + +import ( + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/require" + abci "github.com/tendermint/tendermint/abci/types" +) + +func TestKeeper_EndBlocker(t *testing.T) { + // setup keeper and auction + mapp, keeper, addresses, _ := setUpMockApp() + header := abci.Header{Height: mapp.LastBlockHeight() + 1} + mapp.BeginBlock(abci.RequestBeginBlock{Header: header}) + ctx := mapp.BaseApp.NewContext(false, header) + + seller := addresses[0] + keeper.StartForwardAuction(ctx, seller, sdk.NewInt64Coin("token1", 20), sdk.NewInt64Coin("token2", 0)) + + // run the endblocker, simulating a block height after auction expiry + expiryBlock := ctx.BlockHeight() + int64(DefaultMaxAuctionDuration) + EndBlocker(ctx.WithBlockHeight(expiryBlock), keeper) + + // check auction has been closed + _, found := keeper.GetAuction(ctx, 0) + require.False(t, found) +} diff --git a/x/auction/alias.go b/x/auction/alias.go new file mode 100644 index 00000000..58040464 --- /dev/null +++ b/x/auction/alias.go @@ -0,0 +1,66 @@ +// nolint +// autogenerated code using github.com/rigelrozanski/multitool +// aliases generated for the following subdirectories: +// ALIASGEN: github.com/kava-labs/kava/x/auction/types/ +// ALIASGEN: github.com/kava-labs/kava/x/auction/keeper/ +package auction + +import ( + "github.com/kava-labs/kava/x/auction/keeper" + "github.com/kava-labs/kava/x/auction/types" +) + +const ( + ModuleName = types.ModuleName + StoreKey = types.StoreKey + RouterKey = types.RouterKey + DefaultParamspace = types.DefaultParamspace + DefaultMaxAuctionDuration = types.DefaultMaxAuctionDuration + DefaultMaxBidDuration = types.DefaultMaxBidDuration + DefaultStartingAuctionID = types.DefaultStartingAuctionID + QueryGetAuction = types.QueryGetAuction +) + +var ( + // functions aliases + NewIDFromString = types.NewIDFromString + NewBaseAuction = types.NewBaseAuction + NewForwardAuction = types.NewForwardAuction + NewReverseAuction = types.NewReverseAuction + NewForwardReverseAuction = types.NewForwardReverseAuction + RegisterCodec = types.RegisterCodec + NewGenesisState = types.NewGenesisState + DefaultGenesisState = types.DefaultGenesisState + ValidateGenesis = types.ValidateGenesis + NewMsgPlaceBid = types.NewMsgPlaceBid + NewAuctionParams = types.NewAuctionParams + DefaultAuctionParams = types.DefaultAuctionParams + ParamKeyTable = types.ParamKeyTable + NewKeeper = keeper.NewKeeper + NewQuerier = keeper.NewQuerier + + // variable aliases + ModuleCdc = types.ModuleCdc + KeyAuctionBidDuration = types.KeyAuctionBidDuration + KeyAuctionDuration = types.KeyAuctionDuration + KeyAuctionStartingID = types.KeyAuctionStartingID +) + +type ( + Auction = types.Auction + BaseAuction = types.BaseAuction + ID = types.ID + EndTime = types.EndTime + BankInput = types.BankInput + BankOutput = types.BankOutput + ForwardAuction = types.ForwardAuction + ReverseAuction = types.ReverseAuction + ForwardReverseAuction = types.ForwardReverseAuction + BankKeeper = types.BankKeeper + GenesisAuctions = types.GenesisAuctions + GenesisState = types.GenesisState + MsgPlaceBid = types.MsgPlaceBid + AuctionParams = types.AuctionParams + QueryResAuctions = types.QueryResAuctions + Keeper = keeper.Keeper +) diff --git a/x/auction/app_test.go b/x/auction/app_test.go new file mode 100644 index 00000000..20106efb --- /dev/null +++ b/x/auction/app_test.go @@ -0,0 +1,171 @@ +package auction + +import ( + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/bank" + "github.com/cosmos/cosmos-sdk/x/mock" + abci "github.com/tendermint/tendermint/abci/types" + "github.com/tendermint/tendermint/crypto" +) + +// TestApp contans several basic integration tests of creating an auction, placing a bid, and the auction closing. + +func TestApp_ForwardAuction(t *testing.T) { + // Setup + mapp, keeper, addresses, privKeys := setUpMockApp() + seller := addresses[0] + //sellerKey := privKeys[0] + buyer := addresses[1] + buyerKey := privKeys[1] + + // Create a block where an auction is started (lot: 20 t1, initialBid: 0 t2) + header := abci.Header{Height: mapp.LastBlockHeight() + 1} + mapp.BeginBlock(abci.RequestBeginBlock{Header: header}) + ctx := mapp.BaseApp.NewContext(false, header) // make sure first arg is false, otherwise no db writes + keeper.StartForwardAuction(ctx, seller, sdk.NewInt64Coin("token1", 20), sdk.NewInt64Coin("token2", 0)) // lot, initialBid + mapp.EndBlock(abci.RequestEndBlock{}) + mapp.Commit() + + // Check seller's coins have decreased + mock.CheckBalance(t, mapp, seller, sdk.NewCoins(sdk.NewInt64Coin("token1", 80), sdk.NewInt64Coin("token2", 100))) + + // Deliver a block that contains a PlaceBid tx (bid: 10 t2, lot: same as starting) + msgs := []sdk.Msg{NewMsgPlaceBid(0, buyer, sdk.NewInt64Coin("token2", 10), sdk.NewInt64Coin("token1", 20))} // bid, lot + header = abci.Header{Height: mapp.LastBlockHeight() + 1} + mock.SignCheckDeliver(t, mapp.Cdc, mapp.BaseApp, header, msgs, []uint64{1}, []uint64{0}, true, true, buyerKey) // account number for the buyer account is 1 + + // Check buyer's coins have decreased + mock.CheckBalance(t, mapp, buyer, sdk.NewCoins(sdk.NewInt64Coin("token1", 100), sdk.NewInt64Coin("token2", 90))) + // Check seller's coins have increased + mock.CheckBalance(t, mapp, seller, sdk.NewCoins(sdk.NewInt64Coin("token1", 80), sdk.NewInt64Coin("token2", 110))) + + // Deliver empty blocks until the auction should be closed (bid placed on block 3) + // TODO is there a way of skipping ahead? This takes a while and prints a lot. + for h := mapp.LastBlockHeight() + 1; h < int64(DefaultMaxBidDuration)+4; h++ { + mapp.BeginBlock(abci.RequestBeginBlock{Header: abci.Header{Height: h}}) + mapp.EndBlock(abci.RequestEndBlock{Height: h}) + mapp.Commit() + } + // Check buyer's coins increased + mock.CheckBalance(t, mapp, buyer, sdk.NewCoins(sdk.NewInt64Coin("token1", 120), sdk.NewInt64Coin("token2", 90))) +} + +func TestApp_ReverseAuction(t *testing.T) { + // Setup + mapp, keeper, addresses, privKeys := setUpMockApp() + seller := addresses[0] + sellerKey := privKeys[0] + buyer := addresses[1] + //buyerKey := privKeys[1] + + // Create a block where an auction is started + header := abci.Header{Height: mapp.LastBlockHeight() + 1} + mapp.BeginBlock(abci.RequestBeginBlock{Header: header}) + ctx := mapp.BaseApp.NewContext(false, header) + keeper.StartReverseAuction(ctx, buyer, sdk.NewInt64Coin("token1", 20), sdk.NewInt64Coin("token2", 99)) // buyer, bid, initialLot + mapp.EndBlock(abci.RequestEndBlock{}) + mapp.Commit() + + // Check buyer's coins have decreased + mock.CheckBalance(t, mapp, buyer, sdk.NewCoins(sdk.NewInt64Coin("token1", 100), sdk.NewInt64Coin("token2", 1))) + + // Deliver a block that contains a PlaceBid tx + msgs := []sdk.Msg{NewMsgPlaceBid(0, seller, sdk.NewInt64Coin("token1", 20), sdk.NewInt64Coin("token2", 10))} // bid, lot + header = abci.Header{Height: mapp.LastBlockHeight() + 1} + mock.SignCheckDeliver(t, mapp.Cdc, mapp.BaseApp, header, msgs, []uint64{0}, []uint64{0}, true, true, sellerKey) + + // Check seller's coins have decreased + mock.CheckBalance(t, mapp, seller, sdk.NewCoins(sdk.NewInt64Coin("token1", 80), sdk.NewInt64Coin("token2", 100))) + // Check buyer's coins have increased + mock.CheckBalance(t, mapp, buyer, sdk.NewCoins(sdk.NewInt64Coin("token1", 120), sdk.NewInt64Coin("token2", 90))) + + // Deliver empty blocks until the auction should be closed (bid placed on block 3) + for h := mapp.LastBlockHeight() + 1; h < int64(DefaultMaxBidDuration)+4; h++ { + mapp.BeginBlock(abci.RequestBeginBlock{Header: abci.Header{Height: h}}) + mapp.EndBlock(abci.RequestEndBlock{Height: h}) + mapp.Commit() + } + + // Check seller's coins increased + mock.CheckBalance(t, mapp, seller, sdk.NewCoins(sdk.NewInt64Coin("token1", 80), sdk.NewInt64Coin("token2", 110))) +} +func TestApp_ForwardReverseAuction(t *testing.T) { + // Setup + mapp, keeper, addresses, privKeys := setUpMockApp() + seller := addresses[0] + //sellerKey := privKeys[0] + buyer := addresses[1] + buyerKey := privKeys[1] + recipient := addresses[2] + + // Create a block where an auction is started + header := abci.Header{Height: mapp.LastBlockHeight() + 1} + mapp.BeginBlock(abci.RequestBeginBlock{Header: header}) + ctx := mapp.BaseApp.NewContext(false, header) + keeper.StartForwardReverseAuction(ctx, seller, sdk.NewInt64Coin("token1", 20), sdk.NewInt64Coin("token2", 50), recipient) // seller, lot, maxBid, otherPerson + mapp.EndBlock(abci.RequestEndBlock{}) + mapp.Commit() + + // Check seller's coins have decreased + mock.CheckBalance(t, mapp, seller, sdk.NewCoins(sdk.NewInt64Coin("token1", 80), sdk.NewInt64Coin("token2", 100))) + + // Deliver a block that contains a PlaceBid tx + msgs := []sdk.Msg{NewMsgPlaceBid(0, buyer, sdk.NewInt64Coin("token2", 50), sdk.NewInt64Coin("token1", 15))} // bid, lot + header = abci.Header{Height: mapp.LastBlockHeight() + 1} + mock.SignCheckDeliver(t, mapp.Cdc, mapp.BaseApp, header, msgs, []uint64{1}, []uint64{0}, true, true, buyerKey) + + // Check bidder's coins have decreased + mock.CheckBalance(t, mapp, buyer, sdk.NewCoins(sdk.NewInt64Coin("token1", 100), sdk.NewInt64Coin("token2", 50))) + // Check seller's coins have increased + mock.CheckBalance(t, mapp, seller, sdk.NewCoins(sdk.NewInt64Coin("token1", 80), sdk.NewInt64Coin("token2", 150))) + // Check "recipient" has received coins + mock.CheckBalance(t, mapp, recipient, sdk.NewCoins(sdk.NewInt64Coin("token1", 105), sdk.NewInt64Coin("token2", 100))) + + // Deliver empty blocks until the auction should be closed (bid placed on block 3) + for h := mapp.LastBlockHeight() + 1; h < int64(DefaultMaxBidDuration)+4; h++ { + mapp.BeginBlock(abci.RequestBeginBlock{Header: abci.Header{Height: h}}) + mapp.EndBlock(abci.RequestEndBlock{Height: h}) + mapp.Commit() + } + + // Check buyer's coins increased + mock.CheckBalance(t, mapp, buyer, sdk.NewCoins(sdk.NewInt64Coin("token1", 115), sdk.NewInt64Coin("token2", 50))) +} + +func setUpMockApp() (*mock.App, Keeper, []sdk.AccAddress, []crypto.PrivKey) { + // Create uninitialized mock app + mapp := mock.NewApp() + + // Register codecs + RegisterCodec(mapp.Cdc) + + // Create keepers + keyAuction := sdk.NewKVStoreKey("auction") + blacklistedAddrs := make(map[string]bool) + bankKeeper := bank.NewBaseKeeper(mapp.AccountKeeper, mapp.ParamsKeeper.Subspace(bank.DefaultParamspace), bank.DefaultCodespace, blacklistedAddrs) + auctionKeeper := NewKeeper(mapp.Cdc, bankKeeper, keyAuction, mapp.ParamsKeeper.Subspace(DefaultParamspace)) + + // Register routes + mapp.Router().AddRoute("auction", NewHandler(auctionKeeper)) + + // Add endblocker + mapp.SetEndBlocker( + func(ctx sdk.Context, req abci.RequestEndBlock) abci.ResponseEndBlock { + EndBlocker(ctx, auctionKeeper) + return abci.ResponseEndBlock{} + }, + ) + // Mount and load the stores + err := mapp.CompleteSetup(keyAuction) + if err != nil { + panic("mock app setup failed") + } + + // Create a bunch (ie 10) of pre-funded accounts to use for tests + genAccs, addrs, _, privKeys := mock.CreateGenAccounts(10, sdk.NewCoins(sdk.NewInt64Coin("token1", 100), sdk.NewInt64Coin("token2", 100))) + mock.SetGenesis(mapp, genAccs) + + return mapp, auctionKeeper, addrs, privKeys +} diff --git a/x/auction/client/cli/query.go b/x/auction/client/cli/query.go new file mode 100644 index 00000000..3c5127fa --- /dev/null +++ b/x/auction/client/cli/query.go @@ -0,0 +1,49 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/codec" + "github.com/kava-labs/kava/x/auction/types" +) + +// GetQueryCmd returns the cli query commands for this module +func GetQueryCmd(queryRoute string, cdc *codec.Codec) *cobra.Command { + // Group nameservice queries under a subcommand + auctionQueryCmd := &cobra.Command{ + Use: "auction", + Short: "Querying commands for the auction module", + } + + auctionQueryCmd.AddCommand(client.GetCommands( + GetCmdGetAuctions(queryRoute, cdc), + )...) + + return auctionQueryCmd +} + +// GetCmdGetAuctions queries the auctions in the store +func GetCmdGetAuctions(queryRoute string, cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "getauctions", + Short: "get a list of active auctions", + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + res, _, err := cliCtx.QueryWithData(fmt.Sprintf("custom/%s/getauctions", queryRoute), nil) + if err != nil { + fmt.Printf("error when getting auctions - %s", err) + return nil + } + var out types.QueryResAuctions + cdc.MustUnmarshalJSON(res, &out) + if len(out) == 0 { + out = append(out, "There are currently no auctions") + } + return cliCtx.PrintOutput(out) + }, + } +} diff --git a/x/auction/client/cli/tx.go b/x/auction/client/cli/tx.go new file mode 100644 index 00000000..c663658b --- /dev/null +++ b/x/auction/client/cli/tx.go @@ -0,0 +1,67 @@ +package cli + +import ( + "fmt" + + "github.com/kava-labs/kava/x/auction/types" + "github.com/spf13/cobra" + + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/auth" + "github.com/cosmos/cosmos-sdk/x/auth/client/utils" +) + +// GetTxCmd returns the transaction commands for this module +// TODO: Tests, see: https://github.com/cosmos/cosmos-sdk/blob/18de630d0ae1887113e266982b51c2bf1f662edb/x/staking/client/cli/tx_test.go +func GetTxCmd(cdc *codec.Codec) *cobra.Command { + auctionTxCmd := &cobra.Command{ + Use: "auction", + Short: "auction transactions subcommands", + } + + auctionTxCmd.AddCommand(client.PostCommands( + GetCmdPlaceBid(cdc), + )...) + + return auctionTxCmd +} + +// GetCmdPlaceBid cli command for creating and modifying cdps. +func GetCmdPlaceBid(cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "placebid [AuctionID] [Bidder] [Bid] [Lot]", + Short: "place a bid on an auction", + Args: cobra.ExactArgs(4), + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + txBldr := auth.NewTxBuilderFromCLI().WithTxEncoder(utils.GetTxEncoder(cdc)) + + id, err := types.NewIDFromString(args[0]) + if err != nil { + fmt.Printf("invalid auction id - %s \n", string(args[0])) + return err + } + + bid, err := sdk.ParseCoin(args[2]) + if err != nil { + fmt.Printf("invalid bid amount - %s \n", string(args[2])) + return err + } + + lot, err := sdk.ParseCoin(args[3]) + if err != nil { + fmt.Printf("invalid lot - %s \n", string(args[3])) + return err + } + msg := types.NewMsgPlaceBid(id, cliCtx.GetFromAddress(), bid, lot) + err = msg.ValidateBasic() + if err != nil { + return err + } + return utils.GenerateOrBroadcastMsgs(cliCtx, txBldr, []sdk.Msg{msg}) + }, + } +} diff --git a/x/auction/client/rest/query.go b/x/auction/client/rest/query.go new file mode 100644 index 00000000..87a21915 --- /dev/null +++ b/x/auction/client/rest/query.go @@ -0,0 +1,30 @@ +package rest + +import ( + "fmt" + "net/http" + + "github.com/gorilla/mux" + + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/types/rest" +) + +// r.HandleFunc(fmt.Sprintf("/auction/bid/{%s}/{%s}/{%s}/{%s}", restAuctionID, restBidder, restBid, restLot), bidHandlerFn(cdc, cliCtx)).Methods("PUT") + +func registerQueryRoutes(cliCtx context.CLIContext, r *mux.Router) { + r.HandleFunc(fmt.Sprintf("/auction/getauctions"), queryGetAuctionsHandlerFn(cliCtx)).Methods("GET") + +} + +func queryGetAuctionsHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + res, height, err := cliCtx.QueryWithData("/custom/auction/getauctions", nil) + if err != nil { + rest.WriteErrorResponse(w, http.StatusNotFound, err.Error()) + return + } + cliCtx = cliCtx.WithHeight(height) + rest.PostProcessResponse(w, cliCtx, res) + } +} diff --git a/x/auction/client/rest/rest.go b/x/auction/client/rest/rest.go new file mode 100644 index 00000000..84eb85fd --- /dev/null +++ b/x/auction/client/rest/rest.go @@ -0,0 +1,13 @@ +package rest + +import ( + "github.com/gorilla/mux" + + "github.com/cosmos/cosmos-sdk/client/context" +) + +// RegisterRoutes - Central function to define routes that get registered by the main application +func RegisterRoutes(cliCtx context.CLIContext, r *mux.Router) { + registerQueryRoutes(cliCtx, r) + registerTxRoutes(cliCtx, r) +} diff --git a/x/auction/client/rest/tx.go b/x/auction/client/rest/tx.go new file mode 100644 index 00000000..61c9e18f --- /dev/null +++ b/x/auction/client/rest/tx.go @@ -0,0 +1,86 @@ +package rest + +import ( + "bytes" + "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/auction/types" +) + +type placeBidReq struct { + BaseReq rest.BaseReq `json:"base_req"` + AuctionID string `json:"auction_id"` + Bidder string `json:"bidder"` + Bid string `json:"bid"` + Lot string `json:"lot"` +} + +const ( + restAuctionID = "auction_id" + restBidder = "bidder" + restBid = "bid" + restLot = "lot" +) + +func registerTxRoutes(cliCtx context.CLIContext, r *mux.Router) { + r.HandleFunc( + fmt.Sprintf("/auction/bid/{%s}/{%s}/{%s}/{%s}", restAuctionID, restBidder, restBid, restLot), bidHandlerFn(cliCtx)).Methods("PUT") +} + +func bidHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + + var req placeBidReq + vars := mux.Vars(r) + strAuctionID := vars[restAuctionID] + bechBidder := vars[restBidder] + strBid := vars[restBid] + strLot := vars[restLot] + + auctionID, err := types.NewIDFromString(strAuctionID) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + bidder, err := sdk.AccAddressFromBech32(bechBidder) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + bid, err := sdk.ParseCoin(strBid) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + lot, err := sdk.ParseCoin(strLot) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + msg := types.NewMsgPlaceBid(auctionID, bidder, bid, lot) + if err := msg.ValidateBasic(); err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + fromAddr, err := sdk.AccAddressFromBech32(req.BaseReq.From) + if !bytes.Equal(fromAddr, bidder) { + rest.WriteErrorResponse(w, http.StatusUnauthorized, "must bid from own address") + return + } + utils.WriteGenerateStdTxResponse(w, cliCtx, req.BaseReq, []sdk.Msg{msg}) + + } +} diff --git a/x/auction/doc.go b/x/auction/doc.go new file mode 100644 index 00000000..79e8def7 --- /dev/null +++ b/x/auction/doc.go @@ -0,0 +1,14 @@ +/* +Package auction is a module for creating generic auctions and allowing users to place bids until a timeout is reached. + +TODO + - investigate when exactly auctions close and verify queue/endblocker logic is ok + - add more test cases, add stronger validation to user inputs + - add minimum bid increment + - decided whether to put auction params like default timeouts into the auctions themselves + - add docs + - Add constants for the module and route names + - user facing things like cli, rest, querier, tags + - custom error types, codespace +*/ +package auction diff --git a/x/auction/genesis.go b/x/auction/genesis.go new file mode 100644 index 00000000..1b7a327c --- /dev/null +++ b/x/auction/genesis.go @@ -0,0 +1,30 @@ +package auction + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// InitGenesis - initializes the store state from genesis data +func InitGenesis(ctx sdk.Context, keeper Keeper, data GenesisState) { + keeper.SetParams(ctx, data.AuctionParams) + + for _, a := range data.Auctions { + keeper.SetAuction(ctx, a) + } +} + +// ExportGenesis returns a GenesisState for a given context and keeper. +func ExportGenesis(ctx sdk.Context, keeper Keeper) GenesisState { + params := keeper.GetParams(ctx) + + var genAuctions GenesisAuctions + iterator := keeper.GetAuctionIterator(ctx) + + for ; iterator.Valid(); iterator.Next() { + + auction := keeper.DecodeAuction(ctx, iterator.Value()) + genAuctions = append(genAuctions, auction) + + } + return NewGenesisState(params, genAuctions) +} diff --git a/x/auction/handler.go b/x/auction/handler.go new file mode 100644 index 00000000..ec238957 --- /dev/null +++ b/x/auction/handler.go @@ -0,0 +1,30 @@ +package auction + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// NewHandler returns a function to handle all "auction" type messages. +func NewHandler(keeper Keeper) sdk.Handler { + return func(ctx sdk.Context, msg sdk.Msg) sdk.Result { + switch msg := msg.(type) { + case MsgPlaceBid: + return handleMsgPlaceBid(ctx, keeper, msg) + default: + errMsg := fmt.Sprintf("Unrecognized auction msg type: %T", msg) + return sdk.ErrUnknownRequest(errMsg).Result() + } + } +} + +func handleMsgPlaceBid(ctx sdk.Context, keeper Keeper, msg MsgPlaceBid) sdk.Result { + + err := keeper.PlaceBid(ctx, msg.AuctionID, msg.Bidder, msg.Bid, msg.Lot) + if err != nil { + return err.Result() + } + + return sdk.Result{} +} diff --git a/x/auction/keeper/keeper.go b/x/auction/keeper/keeper.go new file mode 100644 index 00000000..1ffc561b --- /dev/null +++ b/x/auction/keeper/keeper.go @@ -0,0 +1,314 @@ +package keeper + +import ( + "bytes" + "fmt" + + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/params/subspace" + "github.com/kava-labs/kava/x/auction/types" +) + +type Keeper struct { + bankKeeper types.BankKeeper + storeKey sdk.StoreKey + cdc *codec.Codec + paramSubspace subspace.Subspace + // TODO codespace +} + +// NewKeeper returns a new auction keeper. +func NewKeeper(cdc *codec.Codec, bankKeeper types.BankKeeper, storeKey sdk.StoreKey, paramstore subspace.Subspace) Keeper { + return Keeper{ + bankKeeper: bankKeeper, + storeKey: storeKey, + cdc: cdc, + paramSubspace: paramstore.WithKeyTable(types.ParamKeyTable()), + } +} + +// TODO these 3 start functions be combined or abstracted away? + +// StartForwardAuction starts a normal auction. Known as flap in maker. +func (k Keeper) StartForwardAuction(ctx sdk.Context, seller sdk.AccAddress, lot sdk.Coin, initialBid sdk.Coin) (types.ID, sdk.Error) { + // create auction + auction, initiatorOutput := types.NewForwardAuction(seller, lot, initialBid, types.EndTime(ctx.BlockHeight())+types.DefaultMaxAuctionDuration) + // start the auction + auctionID, err := k.startAuction(ctx, &auction, initiatorOutput) + if err != nil { + return 0, err + } + return auctionID, nil +} + +// StartReverseAuction starts an auction where sellers compete by offering decreasing prices. Known as flop in maker. +func (k Keeper) StartReverseAuction(ctx sdk.Context, buyer sdk.AccAddress, bid sdk.Coin, initialLot sdk.Coin) (types.ID, sdk.Error) { + // create auction + auction, initiatorOutput := types.NewReverseAuction(buyer, bid, initialLot, types.EndTime(ctx.BlockHeight())+types.DefaultMaxAuctionDuration) + // start the auction + auctionID, err := k.startAuction(ctx, &auction, initiatorOutput) + if err != nil { + return 0, err + } + return auctionID, nil +} + +// StartForwardReverseAuction starts an auction where bidders bid up to a maxBid, then switch to bidding down on price. Known as flip in maker. +func (k Keeper) StartForwardReverseAuction(ctx sdk.Context, seller sdk.AccAddress, lot sdk.Coin, maxBid sdk.Coin, otherPerson sdk.AccAddress) (types.ID, sdk.Error) { + // create auction + initialBid := sdk.NewInt64Coin(maxBid.Denom, 0) // set the bidding coin denomination from the specified max bid + auction, initiatorOutput := types.NewForwardReverseAuction(seller, lot, initialBid, types.EndTime(ctx.BlockHeight())+types.DefaultMaxAuctionDuration, maxBid, otherPerson) + // start the auction + auctionID, err := k.startAuction(ctx, &auction, initiatorOutput) + if err != nil { + return 0, err + } + return auctionID, nil +} + +func (k Keeper) startAuction(ctx sdk.Context, auction types.Auction, initiatorOutput types.BankOutput) (types.ID, sdk.Error) { + // get ID + newAuctionID, err := k.getNextAuctionID(ctx) + if err != nil { + return 0, err + } + // set ID + auction.SetID(newAuctionID) + + // subtract coins from initiator + _, err = k.bankKeeper.SubtractCoins(ctx, initiatorOutput.Address, sdk.NewCoins(initiatorOutput.Coin)) + if err != nil { + return 0, err + } + + // store auction + k.SetAuction(ctx, auction) + k.incrementNextAuctionID(ctx) + return newAuctionID, nil +} + +// PlaceBid places a bid on any auction. +func (k Keeper) PlaceBid(ctx sdk.Context, auctionID types.ID, bidder sdk.AccAddress, bid sdk.Coin, lot sdk.Coin) sdk.Error { + + // get auction from store + auction, found := k.GetAuction(ctx, auctionID) + if !found { + return sdk.ErrInternal("auction doesn't exist") + } + + // place bid + coinOutputs, coinInputs, err := auction.PlaceBid(types.EndTime(ctx.BlockHeight()), bidder, lot, bid) // update auction according to what type of auction it is // TODO should this return updated Auction to be more immutable? + if err != nil { + return err + } + // TODO this will fail if someone tries to update their bid without the full bid amount sitting in their account + // sub outputs + for _, output := range coinOutputs { + _, err = k.bankKeeper.SubtractCoins(ctx, output.Address, sdk.NewCoins(output.Coin)) // TODO handle errors properly here. All coin transfers should be atomic. InputOutputCoins may work + if err != nil { + panic(err) + } + } + // add inputs + for _, input := range coinInputs { + _, err = k.bankKeeper.AddCoins(ctx, input.Address, sdk.NewCoins(input.Coin)) // TODO errors + if err != nil { + panic(err) + } + } + + // store updated auction + k.SetAuction(ctx, auction) + + return nil +} + +// CloseAuction closes an auction and distributes funds to the seller and highest bidder. +// TODO because this is called by the end blocker, it has to be valid for the duration of the EndTime block. Should maybe move this to a begin blocker? +func (k Keeper) CloseAuction(ctx sdk.Context, auctionID types.ID) sdk.Error { + + // get the auction from the store + auction, found := k.GetAuction(ctx, auctionID) + if !found { + return sdk.ErrInternal("auction doesn't exist") + } + // error if auction has not reached the end time + if ctx.BlockHeight() < int64(auction.GetEndTime()) { // auctions close at the end of the block with blockheight == EndTime + return sdk.ErrInternal(fmt.Sprintf("auction can't be closed as curent block height (%v) is under auction end time (%v)", ctx.BlockHeight(), auction.GetEndTime())) + } + // payout to the last bidder + coinInput := auction.GetPayout() + _, err := k.bankKeeper.AddCoins(ctx, coinInput.Address, sdk.NewCoins(coinInput.Coin)) + if err != nil { + return err + } + + // delete auction from store (and queue) + k.deleteAuction(ctx, auctionID) + + return nil +} + +// ---------- Store methods ---------- +// Use these to add and remove auction from the store. + +// getNextAuctionID gets the next available global AuctionID +func (k Keeper) getNextAuctionID(ctx sdk.Context) (types.ID, sdk.Error) { // TODO don't need error return here + // get next ID from store + store := ctx.KVStore(k.storeKey) + bz := store.Get(k.getNextAuctionIDKey()) + if bz == nil { + // if not found, set the id at 0 + bz = k.cdc.MustMarshalBinaryLengthPrefixed(types.ID(0)) + store.Set(k.getNextAuctionIDKey(), bz) + // TODO Why does the gov module set the id in genesis? : + //return 0, ErrInvalidGenesis(keeper.codespace, "InitialProposalID never set") + } + var auctionID types.ID + k.cdc.MustUnmarshalBinaryLengthPrefixed(bz, &auctionID) + return auctionID, nil +} + +// incrementNextAuctionID increments the global ID in the store by 1 +func (k Keeper) incrementNextAuctionID(ctx sdk.Context) sdk.Error { + // get next ID from store + store := ctx.KVStore(k.storeKey) + bz := store.Get(k.getNextAuctionIDKey()) + if bz == nil { + panic("initial auctionID never set in genesis") + //return 0, ErrInvalidGenesis(keeper.codespace, "InitialProposalID never set") // TODO is this needed? Why not just set it zero here? + } + var auctionID types.ID + k.cdc.MustUnmarshalBinaryLengthPrefixed(bz, &auctionID) + + // increment the stored next ID + bz = k.cdc.MustMarshalBinaryLengthPrefixed(auctionID + 1) + store.Set(k.getNextAuctionIDKey(), bz) + + return nil +} + +// SetAuction puts the auction into the database and adds it to the queue +// it overwrites any pre-existing auction with same ID +func (k Keeper) SetAuction(ctx sdk.Context, auction types.Auction) { + // remove the auction from the queue if it is already in there + existingAuction, found := k.GetAuction(ctx, auction.GetID()) + if found { + k.removeFromQueue(ctx, existingAuction.GetEndTime(), existingAuction.GetID()) + } + + // store auction + store := ctx.KVStore(k.storeKey) + bz := k.cdc.MustMarshalBinaryLengthPrefixed(auction) + store.Set(k.getAuctionKey(auction.GetID()), bz) + + // add to the queue + k.insertIntoQueue(ctx, auction.GetEndTime(), auction.GetID()) +} + +// getAuction gets an auction from the store by auctionID +func (k Keeper) GetAuction(ctx sdk.Context, auctionID types.ID) (types.Auction, bool) { + var auction types.Auction + + store := ctx.KVStore(k.storeKey) + bz := store.Get(k.getAuctionKey(auctionID)) + if bz == nil { + return auction, false // TODO what is the correct behavior when an auction is not found? gov module follows this pattern of returning a bool + } + + k.cdc.MustUnmarshalBinaryLengthPrefixed(bz, &auction) + return auction, true +} + +// deleteAuction removes an auction from the store without any validation +func (k Keeper) deleteAuction(ctx sdk.Context, auctionID types.ID) { + // remove from queue + auction, found := k.GetAuction(ctx, auctionID) + if found { + k.removeFromQueue(ctx, auction.GetEndTime(), auctionID) + } + + // delete auction + store := ctx.KVStore(k.storeKey) + store.Delete(k.getAuctionKey(auctionID)) +} + +// ---------- Queue and key methods ---------- +// These are lower level function used by the store methods above. + +func (k Keeper) getNextAuctionIDKey() []byte { + return []byte("nextAuctionID") +} +func (k Keeper) getAuctionKey(auctionID types.ID) []byte { + return []byte(fmt.Sprintf("auctions:%d", auctionID)) +} + +// Inserts a AuctionID into the queue at endTime +func (k Keeper) insertIntoQueue(ctx sdk.Context, endTime types.EndTime, auctionID types.ID) { + // get the store + store := ctx.KVStore(k.storeKey) + // marshal thing to be inserted + bz := k.cdc.MustMarshalBinaryLengthPrefixed(auctionID) + // store it + store.Set( + getQueueElementKey(endTime, auctionID), + bz, + ) +} + +// removes an auctionID from the queue +func (k Keeper) removeFromQueue(ctx sdk.Context, endTime types.EndTime, auctionID types.ID) { + store := ctx.KVStore(k.storeKey) + store.Delete(getQueueElementKey(endTime, auctionID)) +} + +// Returns an iterator for all the auctions in the queue that expire by endTime +func (k Keeper) GetQueueIterator(ctx sdk.Context, endTime types.EndTime) sdk.Iterator { // TODO rename to "getAuctionsByExpiry" ? + // get store + store := ctx.KVStore(k.storeKey) + // get an interator + return store.Iterator( + queueKeyPrefix, // start key + sdk.PrefixEndBytes(getQueueElementKeyPrefix(endTime)), // end key (apparently exclusive but tests suggested otherwise) + ) +} + +// GetAuctionIterator returns an iterator over all auctions in the store +func (k Keeper) GetAuctionIterator(ctx sdk.Context) sdk.Iterator { + store := ctx.KVStore(k.storeKey) + return sdk.KVStorePrefixIterator(store, nil) +} + +var queueKeyPrefix = []byte("queue") +var keyDelimiter = []byte(":") + +// Returns half a key for an auctionID in the queue, it missed the id off the end +func getQueueElementKeyPrefix(endTime types.EndTime) []byte { + return bytes.Join([][]byte{ + queueKeyPrefix, + sdk.Uint64ToBigEndian(uint64(endTime)), // TODO check this gives correct ordering + }, keyDelimiter) +} + +// Returns the key for an auctionID in the queue +func getQueueElementKey(endTime types.EndTime, auctionID types.ID) []byte { + return bytes.Join([][]byte{ + queueKeyPrefix, + sdk.Uint64ToBigEndian(uint64(endTime)), // TODO check this gives correct ordering + sdk.Uint64ToBigEndian(uint64(auctionID)), + }, keyDelimiter) +} + +// GetAuctionID returns the id from an input Auction +func (k Keeper) DecodeAuctionID(ctx sdk.Context, idBytes []byte) types.ID { + var auctionID types.ID + k.cdc.MustUnmarshalBinaryLengthPrefixed(idBytes, &auctionID) + return auctionID +} + +func (k Keeper) DecodeAuction(ctx sdk.Context, auctionBytes []byte) types.Auction { + var auction types.Auction + k.cdc.MustUnmarshalBinaryBare(auctionBytes, &auction) + return auction +} diff --git a/x/auction/keeper/keeper_test.go b/x/auction/keeper/keeper_test.go new file mode 100644 index 00000000..6c77b32e --- /dev/null +++ b/x/auction/keeper/keeper_test.go @@ -0,0 +1,88 @@ +package keeper + +import ( + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/kava-labs/kava/x/auction/types" + "github.com/stretchr/testify/require" + abci "github.com/tendermint/tendermint/abci/types" +) + +func TestKeeper_SetGetDeleteAuction(t *testing.T) { + // setup keeper, create auction + mapp, keeper, addresses, _ := setUpMockApp() + header := abci.Header{Height: mapp.LastBlockHeight() + 1} + mapp.BeginBlock(abci.RequestBeginBlock{Header: header}) // Without this it panics about "invalid memory address or nil pointer dereference" + ctx := mapp.BaseApp.NewContext(false, header) + auction, _ := types.NewForwardAuction(addresses[0], sdk.NewInt64Coin("usdx", 100), sdk.NewInt64Coin("kava", 0), types.EndTime(1000)) + id := types.ID(5) + auction.SetID(id) + + // write and read from store + keeper.SetAuction(ctx, &auction) + readAuction, found := keeper.GetAuction(ctx, id) + + // check before and after match + require.True(t, found) + require.Equal(t, &auction, readAuction) + t.Log(auction) + t.Log(readAuction.GetID()) + // check auction is in queue + iter := keeper.GetQueueIterator(ctx, 100000) + require.Equal(t, 1, len(convertIteratorToSlice(keeper, iter))) + iter.Close() + + // delete auction + keeper.deleteAuction(ctx, id) + + // check auction does not exist + _, found = keeper.GetAuction(ctx, id) + require.False(t, found) + // check auction not in queue + iter = keeper.GetQueueIterator(ctx, 100000) + require.Equal(t, 0, len(convertIteratorToSlice(keeper, iter))) + iter.Close() + +} + +// TODO convert to table driven test with more test cases +func TestKeeper_ExpiredAuctionQueue(t *testing.T) { + // setup keeper + mapp, keeper, _, _ := setUpMockApp() + header := abci.Header{Height: mapp.LastBlockHeight() + 1} + mapp.BeginBlock(abci.RequestBeginBlock{Header: header}) + ctx := mapp.BaseApp.NewContext(false, header) + // create an example queue + type queue []struct { + endTime types.EndTime + auctionID types.ID + } + q := queue{{1000, 0}, {1300, 2}, {5200, 1}} + + // write and read queue + for _, v := range q { + keeper.insertIntoQueue(ctx, v.endTime, v.auctionID) + } + iter := keeper.GetQueueIterator(ctx, 1000) + + // check before and after match + i := 0 + for ; iter.Valid(); iter.Next() { + var auctionID types.ID + keeper.cdc.MustUnmarshalBinaryLengthPrefixed(iter.Value(), &auctionID) + require.Equal(t, q[i].auctionID, auctionID) + i++ + } + +} + +func convertIteratorToSlice(keeper Keeper, iterator sdk.Iterator) []types.ID { + var queue []types.ID + for ; iterator.Valid(); iterator.Next() { + var auctionID types.ID + keeper.cdc.MustUnmarshalBinaryLengthPrefixed(iterator.Value(), &auctionID) + queue = append(queue, auctionID) + } + return queue +} diff --git a/x/auction/keeper/params.go b/x/auction/keeper/params.go new file mode 100644 index 00000000..c832caf5 --- /dev/null +++ b/x/auction/keeper/params.go @@ -0,0 +1,17 @@ +package keeper + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/kava-labs/kava/x/auction/types" +) + +// SetParams sets the auth module's parameters. +func (k Keeper) SetParams(ctx sdk.Context, params types.AuctionParams) { + k.paramSubspace.SetParamSet(ctx, ¶ms) +} + +// GetParams gets the auth module's parameters. +func (k Keeper) GetParams(ctx sdk.Context) (params types.AuctionParams) { + k.paramSubspace.GetParamSet(ctx, ¶ms) + return +} \ No newline at end of file diff --git a/x/auction/keeper/querier.go b/x/auction/keeper/querier.go new file mode 100644 index 00000000..15748733 --- /dev/null +++ b/x/auction/keeper/querier.go @@ -0,0 +1,40 @@ +package keeper + +import ( + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/kava-labs/kava/x/auction/types" + abci "github.com/tendermint/tendermint/abci/types" +) + +// NewQuerier is the module level router for state queries +func NewQuerier(keeper Keeper) sdk.Querier { + return func(ctx sdk.Context, path []string, req abci.RequestQuery) (res []byte, err sdk.Error) { + switch path[0] { + case types.QueryGetAuction: + return queryAuctions(ctx, req, keeper) + default: + return nil, sdk.ErrUnknownRequest("unknown auction query endpoint") + } + } +} + +func queryAuctions(ctx sdk.Context, req abci.RequestQuery, keeper Keeper) (res []byte, err sdk.Error) { + var AuctionsList types.QueryResAuctions + + iterator := keeper.GetAuctionIterator(ctx) + + for ; iterator.Valid(); iterator.Next() { + + var auction types.Auction + keeper.cdc.MustUnmarshalBinaryBare(iterator.Value(), &auction) + AuctionsList = append(AuctionsList, auction.String()) + } + + bz, err2 := codec.MarshalJSONIndent(keeper.cdc, AuctionsList) + if err2 != nil { + panic("could not marshal result to JSON") + } + + return bz, nil +} diff --git a/x/auction/keeper/test_common.go b/x/auction/keeper/test_common.go new file mode 100644 index 00000000..b5702438 --- /dev/null +++ b/x/auction/keeper/test_common.go @@ -0,0 +1,35 @@ +package keeper + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/bank" + "github.com/cosmos/cosmos-sdk/x/mock" + "github.com/kava-labs/kava/x/auction/types" + "github.com/tendermint/tendermint/crypto" +) + +func setUpMockApp() (*mock.App, Keeper, []sdk.AccAddress, []crypto.PrivKey) { + // Create uninitialized mock app + mapp := mock.NewApp() + + // Register codecs + types.RegisterCodec(mapp.Cdc) + + // Create keepers + keyAuction := sdk.NewKVStoreKey("auction") + blacklistedAddrs := make(map[string]bool) + bankKeeper := bank.NewBaseKeeper(mapp.AccountKeeper, mapp.ParamsKeeper.Subspace(bank.DefaultParamspace), bank.DefaultCodespace, blacklistedAddrs) + auctionKeeper := NewKeeper(mapp.Cdc, bankKeeper, keyAuction, mapp.ParamsKeeper.Subspace(types.DefaultParamspace)) + + // Mount and load the stores + err := mapp.CompleteSetup(keyAuction) + if err != nil { + panic("mock app setup failed") + } + + // Create a bunch (ie 10) of pre-funded accounts to use for tests + genAccs, addrs, _, privKeys := mock.CreateGenAccounts(10, sdk.NewCoins(sdk.NewInt64Coin("token1", 100), sdk.NewInt64Coin("token2", 100))) + mock.SetGenesis(mapp, genAccs) + + return mapp, auctionKeeper, addrs, privKeys +} diff --git a/x/auction/module.go b/x/auction/module.go new file mode 100644 index 00000000..7e0f1bd7 --- /dev/null +++ b/x/auction/module.go @@ -0,0 +1,131 @@ +package auction + +import ( + "encoding/json" + + "github.com/gorilla/mux" + "github.com/spf13/cobra" + + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/module" + abci "github.com/tendermint/tendermint/abci/types" + + "github.com/kava-labs/kava/x/auction/client/cli" + "github.com/kava-labs/kava/x/auction/client/rest" +) + +var ( + _ module.AppModule = AppModule{} + _ module.AppModuleBasic = AppModuleBasic{} +) + +// AppModuleBasic app module basics object +type AppModuleBasic struct{} + +// Name get module name +func (AppModuleBasic) Name() string { + return ModuleName +} + +// RegisterCodec register module codec +func (AppModuleBasic) RegisterCodec(cdc *codec.Codec) { + RegisterCodec(cdc) +} + +// DefaultGenesis default genesis state +func (AppModuleBasic) DefaultGenesis() json.RawMessage { + return ModuleCdc.MustMarshalJSON(DefaultGenesisState()) +} + +// ValidateGenesis performs genesis state validation for the auction module. +func (AppModuleBasic) ValidateGenesis(bz json.RawMessage) error { + var data GenesisState + err := ModuleCdc.UnmarshalJSON(bz, &data) + if err != nil { + return err + } + return ValidateGenesis(data) +} + +// RegisterRESTRoutes registers the REST routes for the auction module. +func (AppModuleBasic) RegisterRESTRoutes(ctx context.CLIContext, rtr *mux.Router) { + rest.RegisterRoutes(ctx, rtr) +} + +// GetTxCmd returns the root tx command for the auction module. +func (AppModuleBasic) GetTxCmd(cdc *codec.Codec) *cobra.Command { + return cli.GetTxCmd(cdc) +} + +// GetQueryCmd returns the root query command for the auction module. +func (AppModuleBasic) GetQueryCmd(cdc *codec.Codec) *cobra.Command { + return cli.GetQueryCmd(StoreKey, cdc) +} + +// AppModule app module type +type AppModule struct { + AppModuleBasic + keeper Keeper +} + +// NewAppModule creates a new AppModule object +func NewAppModule(keeper Keeper) AppModule { + return AppModule{ + AppModuleBasic: AppModuleBasic{}, + keeper: keeper, + } +} + +// Name module name +func (AppModule) Name() string { + return ModuleName +} + +// RegisterInvariants performs a no-op. +func (AppModule) RegisterInvariants(_ sdk.InvariantRegistry) {} + +// Route module message route name +func (AppModule) Route() string { + return ModuleName +} + +// NewHandler module handler +func (am AppModule) NewHandler() sdk.Handler { + return NewHandler(am.keeper) +} + +// QuerierRoute module querier route name +func (AppModule) QuerierRoute() string { + return ModuleName +} + +// NewQuerierHandler module querier +func (am AppModule) NewQuerierHandler() sdk.Querier { + return NewQuerier(am.keeper) +} + +// InitGenesis performs genesis initialization for the auction module. It returns +// no validator updates. +func (am AppModule) InitGenesis(ctx sdk.Context, data json.RawMessage) []abci.ValidatorUpdate { + var genesisState GenesisState + ModuleCdc.MustUnmarshalJSON(data, &genesisState) + InitGenesis(ctx, am.keeper, genesisState) + return []abci.ValidatorUpdate{} +} + +// ExportGenesis module export genesis +func (am AppModule) ExportGenesis(ctx sdk.Context) json.RawMessage { + gs := ExportGenesis(ctx, am.keeper) + return ModuleCdc.MustMarshalJSON(gs) +} + +// BeginBlock module begin-block +func (AppModule) BeginBlock(_ sdk.Context, _ abci.RequestBeginBlock) {} + +// EndBlock module end-block +func (am AppModule) EndBlock(ctx sdk.Context, _ abci.RequestEndBlock) []abci.ValidatorUpdate { + EndBlocker(ctx, am.keeper) + return []abci.ValidatorUpdate{} +} diff --git a/x/auction/types/auctions.go b/x/auction/types/auctions.go new file mode 100644 index 00000000..386fb232 --- /dev/null +++ b/x/auction/types/auctions.go @@ -0,0 +1,307 @@ +package types + +import ( + "fmt" + "strconv" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// Auction is an interface to several types of auction. +type Auction interface { + GetID() ID + SetID(ID) + PlaceBid(currentBlockHeight EndTime, bidder sdk.AccAddress, lot sdk.Coin, bid sdk.Coin) ([]BankOutput, []BankInput, sdk.Error) + GetEndTime() EndTime // auctions close at the end of the block with blockheight EndTime (ie bids placed in that block are valid) + GetPayout() BankInput + String() string +} + +// BaseAuction type shared by all Auctions +type BaseAuction struct { + ID ID + Initiator sdk.AccAddress // Person who starts the auction. Giving away Lot (aka seller in a forward auction) + Lot sdk.Coin // Amount of coins up being given by initiator (FA - amount for sale by seller, RA - cost of good by buyer (bid)) + Bidder sdk.AccAddress // Person who bids in the auction. Receiver of Lot. (aka buyer in forward auction, seller in RA) + Bid sdk.Coin // Amount of coins being given by the bidder (FA - bid, RA - amount being sold) + EndTime EndTime // Block height at which the auction closes. It closes at the end of this block + MaxEndTime EndTime // Maximum closing time. Auctions can close before this but never after. +} + +// ID type for auction IDs +type ID uint64 + +// NewIDFromString generate new auction ID from a string +func NewIDFromString(s string) (ID, error) { + n, err := strconv.ParseUint(s, 10, 64) // copied from how the gov module rest handler's parse proposal IDs + if err != nil { + return 0, err + } + return ID(n), nil +} + +// EndTime type for end time of auctions +type EndTime int64 // TODO rename to Blockheight or don't define custom type + +// BankInput the input and output types from the bank module where used here. But they use sdk.Coins instad of sdk.Coin. So it caused a lot of type conversion as auction mainly uses sdk.Coin. +type BankInput struct { + Address sdk.AccAddress + Coin sdk.Coin +} + +// BankOutput output type for auction bids +type BankOutput struct { + Address sdk.AccAddress + Coin sdk.Coin +} + +// GetID getter for auction ID +func (a BaseAuction) GetID() ID { return a.ID } + +// SetID setter for auction ID +func (a *BaseAuction) SetID(id ID) { a.ID = id } + +// GetEndTime getter for auction end time +func (a BaseAuction) GetEndTime() EndTime { return a.EndTime } + +// GetPayout implements Auction +func (a BaseAuction) GetPayout() BankInput { + return BankInput{a.Bidder, a.Lot} +} + +// PlaceBid implements Auction +func (a *BaseAuction) PlaceBid(currentBlockHeight EndTime, bidder sdk.AccAddress, lot sdk.Coin, bid sdk.Coin) ([]BankOutput, []BankInput, sdk.Error) { + // TODO check lot size matches lot? + // check auction has not closed + if currentBlockHeight > a.EndTime { + return []BankOutput{}, []BankInput{}, sdk.ErrInternal("auction has closed") + } + // check bid is greater than last bid + if !a.Bid.IsLT(bid) { // TODO add minimum bid size + return []BankOutput{}, []BankInput{}, sdk.ErrInternal("bid not greater than last bid") + } + // calculate coin movements + outputs := []BankOutput{{bidder, bid}} // new bidder pays bid now + inputs := []BankInput{{a.Bidder, a.Bid}, {a.Initiator, bid.Sub(a.Bid)}} // old bidder is paid back, extra goes to seller + + // update auction + a.Bidder = bidder + a.Bid = bid + // increment timeout // TODO into keeper? + a.EndTime = EndTime(min(int64(currentBlockHeight+DefaultMaxBidDuration), int64(a.MaxEndTime))) // TODO is there a better way to structure these types? + + return outputs, inputs, nil +} + +func (e EndTime) String() string { + return string(e) +} + +func (a BaseAuction) String() string { + return fmt.Sprintf(`Auction %d: + Initiator: %s + Lot: %s + Bidder: %s + Bid: %s + End Time: %s + Max End Time: %s`, + a.GetID(), a.Initiator, a.Lot, + a.Bidder, a.Bid, a.GetEndTime().String(), + a.MaxEndTime.String(), + ) +} + +// NewBaseAuction creates a new base auction +func NewBaseAuction(seller sdk.AccAddress, lot sdk.Coin, initialBid sdk.Coin, EndTime EndTime) BaseAuction { + auction := BaseAuction{ + // no ID + Initiator: seller, + Lot: lot, + Bidder: seller, // send the proceeds from the first bid back to the seller + Bid: initialBid, // set this to zero most of the time + EndTime: EndTime, + MaxEndTime: EndTime, + } + return auction +} + +// ForwardAuction type for forward auctions +type ForwardAuction struct { + BaseAuction +} + +// NewForwardAuction creates a new forward auction +func NewForwardAuction(seller sdk.AccAddress, lot sdk.Coin, initialBid sdk.Coin, EndTime EndTime) (ForwardAuction, BankOutput) { + auction := ForwardAuction{BaseAuction{ + // no ID + Initiator: seller, + Lot: lot, + Bidder: seller, // send the proceeds from the first bid back to the seller + Bid: initialBid, // set this to zero most of the time + EndTime: EndTime, + MaxEndTime: EndTime, + }} + output := BankOutput{seller, lot} + return auction, output +} + +// PlaceBid implements Auction +func (a *ForwardAuction) PlaceBid(currentBlockHeight EndTime, bidder sdk.AccAddress, lot sdk.Coin, bid sdk.Coin) ([]BankOutput, []BankInput, sdk.Error) { + // TODO check lot size matches lot? + // check auction has not closed + if currentBlockHeight > a.EndTime { + return []BankOutput{}, []BankInput{}, sdk.ErrInternal("auction has closed") + } + // check bid is greater than last bid + if !a.Bid.IsLT(bid) { // TODO add minimum bid size + return []BankOutput{}, []BankInput{}, sdk.ErrInternal("bid not greater than last bid") + } + // calculate coin movements + outputs := []BankOutput{{bidder, bid}} // new bidder pays bid now + inputs := []BankInput{{a.Bidder, a.Bid}, {a.Initiator, bid.Sub(a.Bid)}} // old bidder is paid back, extra goes to seller + + // update auction + a.Bidder = bidder + a.Bid = bid + // increment timeout // TODO into keeper? + a.EndTime = EndTime(min(int64(currentBlockHeight+DefaultMaxBidDuration), int64(a.MaxEndTime))) // TODO is there a better way to structure these types? + + return outputs, inputs, nil +} + +// ReverseAuction type for reverse auctions +// TODO when exporting state and initializing a new genesis, we'll need a way to differentiate forward from reverse auctions +type ReverseAuction struct { + BaseAuction +} + +// NewReverseAuction creates a new reverse auction +func NewReverseAuction(buyer sdk.AccAddress, bid sdk.Coin, initialLot sdk.Coin, EndTime EndTime) (ReverseAuction, BankOutput) { + auction := ReverseAuction{BaseAuction{ + // no ID + Initiator: buyer, + Lot: initialLot, + Bidder: buyer, // send proceeds from the first bid to the buyer + Bid: bid, // amount that the buyer it buying - doesn't change over course of auction + EndTime: EndTime, + MaxEndTime: EndTime, + }} + output := BankOutput{buyer, initialLot} + return auction, output +} + +// PlaceBid implements Auction +func (a *ReverseAuction) PlaceBid(currentBlockHeight EndTime, bidder sdk.AccAddress, lot sdk.Coin, bid sdk.Coin) ([]BankOutput, []BankInput, sdk.Error) { + + // check bid size matches bid? + // check auction has not closed + if currentBlockHeight > a.EndTime { + return []BankOutput{}, []BankInput{}, sdk.ErrInternal("auction has closed") + } + // check bid is less than last bid + if !lot.IsLT(a.Lot) { // TODO add min bid decrements + return []BankOutput{}, []BankInput{}, sdk.ErrInternal("lot not smaller than last lot") + } + // calculate coin movements + outputs := []BankOutput{{bidder, a.Bid}} // new bidder pays bid now + inputs := []BankInput{{a.Bidder, a.Bid}, {a.Initiator, a.Lot.Sub(lot)}} // old bidder is paid back, decrease in price for goes to buyer + + // update auction + a.Bidder = bidder + a.Lot = lot + // increment timeout // TODO into keeper? + a.EndTime = EndTime(min(int64(currentBlockHeight+DefaultMaxBidDuration), int64(a.MaxEndTime))) // TODO is there a better way to structure these types? + + return outputs, inputs, nil +} + +// ForwardReverseAuction type for forward reverse auction +type ForwardReverseAuction struct { + BaseAuction + MaxBid sdk.Coin + OtherPerson sdk.AccAddress // TODO rename, this is normally the original CDP owner +} + +func (a ForwardReverseAuction) String() string { + return fmt.Sprintf(`Auction %d: + Initiator: %s + Lot: %s + Bidder: %s + Bid: %s + End Time: %s + Max End Time: %s + Max Bid %s + Other Person %s`, + a.GetID(), a.Initiator, a.Lot, + a.Bidder, a.Bid, a.GetEndTime().String(), + a.MaxEndTime.String(), a.MaxBid, a.OtherPerson, + ) +} + +// NewForwardReverseAuction creates a new forward reverse auction +func NewForwardReverseAuction(seller sdk.AccAddress, lot sdk.Coin, initialBid sdk.Coin, EndTime EndTime, maxBid sdk.Coin, otherPerson sdk.AccAddress) (ForwardReverseAuction, BankOutput) { + auction := ForwardReverseAuction{ + BaseAuction: BaseAuction{ + // no ID + Initiator: seller, + Lot: lot, + Bidder: seller, // send the proceeds from the first bid back to the seller + Bid: initialBid, // 0 most of the time + EndTime: EndTime, + MaxEndTime: EndTime}, + MaxBid: maxBid, + OtherPerson: otherPerson, + } + output := BankOutput{seller, lot} + return auction, output +} + +// PlaceBid implements auction +func (a *ForwardReverseAuction) PlaceBid(currentBlockHeight EndTime, bidder sdk.AccAddress, lot sdk.Coin, bid sdk.Coin) (outputs []BankOutput, inputs []BankInput, err sdk.Error) { + // check auction has not closed + if currentBlockHeight > a.EndTime { + return []BankOutput{}, []BankInput{}, sdk.ErrInternal("auction has closed") + } + + // determine phase of auction + switch { + case a.Bid.IsLT(a.MaxBid) && bid.IsLT(a.MaxBid): + // Forward auction phase + if !a.Bid.IsLT(bid) { // TODO add min bid increments + return []BankOutput{}, []BankInput{}, sdk.ErrInternal("bid not greater than last bid") + } + outputs = []BankOutput{{bidder, bid}} // new bidder pays bid now + inputs = []BankInput{{a.Bidder, a.Bid}, {a.Initiator, bid.Sub(a.Bid)}} // old bidder is paid back, extra goes to seller + case a.Bid.IsLT(a.MaxBid): + // Switch over phase + if !bid.IsEqual(a.MaxBid) { // require bid == a.MaxBid + return []BankOutput{}, []BankInput{}, sdk.ErrInternal("bid greater than the max bid") + } + outputs = []BankOutput{{bidder, bid}} // new bidder pays bid now + inputs = []BankInput{ + {a.Bidder, a.Bid}, // old bidder is paid back + {a.Initiator, bid.Sub(a.Bid)}, // extra goes to seller + {a.OtherPerson, a.Lot.Sub(lot)}, //decrease in price for goes to original CDP owner + } + + case a.Bid.IsEqual(a.MaxBid): + // Reverse auction phase + if !lot.IsLT(a.Lot) { // TODO add min bid decrements + return []BankOutput{}, []BankInput{}, sdk.ErrInternal("lot not smaller than last lot") + } + outputs = []BankOutput{{bidder, a.Bid}} // new bidder pays bid now + inputs = []BankInput{{a.Bidder, a.Bid}, {a.OtherPerson, a.Lot.Sub(lot)}} // old bidder is paid back, decrease in price for goes to original CDP owner + default: + panic("should never be reached") // TODO + } + + // update auction + a.Bidder = bidder + a.Lot = lot + a.Bid = bid + // increment timeout + // TODO use bid duration param + a.EndTime = EndTime(min(int64(currentBlockHeight+DefaultMaxBidDuration), int64(a.MaxEndTime))) // TODO is there a better way to structure these types? + + return outputs, inputs, nil +} diff --git a/x/auction/types/auctions_test.go b/x/auction/types/auctions_test.go new file mode 100644 index 00000000..b2252f55 --- /dev/null +++ b/x/auction/types/auctions_test.go @@ -0,0 +1,403 @@ +package types + +import ( + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/require" +) + +// TODO can this be less verbose? Should PlaceBid() be split into smaller functions? +// It would be possible to combine all auction tests into one test runner. +func TestForwardAuction_PlaceBid(t *testing.T) { + seller := sdk.AccAddress([]byte("a_seller")) + buyer1 := sdk.AccAddress([]byte("buyer1")) + buyer2 := sdk.AccAddress([]byte("buyer2")) + end := EndTime(10000) + now := EndTime(10) + + type args struct { + currentBlockHeight EndTime + bidder sdk.AccAddress + lot sdk.Coin + bid sdk.Coin + } + tests := []struct { + name string + auction ForwardAuction + args args + expectedOutputs []BankOutput + expectedInputs []BankInput + expectedEndTime EndTime + expectedBidder sdk.AccAddress + expectedBid sdk.Coin + expectpass bool + }{ + { + "normal", + ForwardAuction{BaseAuction{ + Initiator: seller, + Lot: c("usdx", 100), + Bidder: buyer1, + Bid: c("kava", 6), + EndTime: end, + MaxEndTime: end, + }}, + args{now, buyer2, c("usdx", 100), c("kava", 10)}, + []BankOutput{{buyer2, c("kava", 10)}}, + []BankInput{{buyer1, c("kava", 6)}, {seller, c("kava", 4)}}, + now + DefaultMaxBidDuration, + buyer2, + c("kava", 10), + true, + }, + { + "lowBid", + ForwardAuction{BaseAuction{ + Initiator: seller, + Lot: c("usdx", 100), + Bidder: buyer1, + Bid: c("kava", 6), + EndTime: end, + MaxEndTime: end, + }}, + args{now, buyer2, c("usdx", 100), c("kava", 5)}, + []BankOutput{}, + []BankInput{}, + end, + buyer1, + c("kava", 6), + false, + }, + { + "equalBid", + ForwardAuction{BaseAuction{ + Initiator: seller, + Lot: c("usdx", 100), + Bidder: buyer1, + Bid: c("kava", 6), + EndTime: end, + MaxEndTime: end, + }}, + args{now, buyer2, c("usdx", 100), c("kava", 6)}, + []BankOutput{}, + []BankInput{}, + end, + buyer1, + c("kava", 6), + false, + }, + { + "timeout", + ForwardAuction{BaseAuction{ + Initiator: seller, + Lot: c("usdx", 100), + Bidder: buyer1, + Bid: c("kava", 6), + EndTime: end, + MaxEndTime: end, + }}, + args{end + 1, buyer2, c("usdx", 100), c("kava", 10)}, + []BankOutput{}, + []BankInput{}, + end, + buyer1, + c("kava", 6), + false, + }, + { + "hitMaxEndTime", + ForwardAuction{BaseAuction{ + Initiator: seller, + Lot: c("usdx", 100), + Bidder: buyer1, + Bid: c("kava", 6), + EndTime: end, + MaxEndTime: end, + }}, + args{end - 1, buyer2, c("usdx", 100), c("kava", 10)}, + []BankOutput{{buyer2, c("kava", 10)}}, + []BankInput{{buyer1, c("kava", 6)}, {seller, c("kava", 4)}}, + end, // end time should be capped at MaxEndTime + buyer2, + c("kava", 10), + true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // update auction and return in/outputs + outputs, inputs, err := tc.auction.PlaceBid(tc.args.currentBlockHeight, tc.args.bidder, tc.args.lot, tc.args.bid) + + // check for err + if tc.expectpass { + require.Nil(t, err) + } else { + require.NotNil(t, err) + } + // check for correct in/outputs + require.Equal(t, tc.expectedOutputs, outputs) + require.Equal(t, tc.expectedInputs, inputs) + // check for correct EndTime, bidder, bid + require.Equal(t, tc.expectedEndTime, tc.auction.EndTime) + require.Equal(t, tc.expectedBidder, tc.auction.Bidder) + require.Equal(t, tc.expectedBid, tc.auction.Bid) + }) + } +} + +func TestReverseAuction_PlaceBid(t *testing.T) { + buyer := sdk.AccAddress([]byte("a_buyer")) + seller1 := sdk.AccAddress([]byte("seller1")) + seller2 := sdk.AccAddress([]byte("seller2")) + end := EndTime(10000) + now := EndTime(10) + + type args struct { + currentBlockHeight EndTime + bidder sdk.AccAddress + lot sdk.Coin + bid sdk.Coin + } + tests := []struct { + name string + auction ReverseAuction + args args + expectedOutputs []BankOutput + expectedInputs []BankInput + expectedEndTime EndTime + expectedBidder sdk.AccAddress + expectedLot sdk.Coin + expectpass bool + }{ + { + "normal", + ReverseAuction{BaseAuction{ + Initiator: buyer, + Lot: c("kava", 10), + Bidder: seller1, + Bid: c("usdx", 100), + EndTime: end, + MaxEndTime: end, + }}, + args{now, seller2, c("kava", 9), c("usdx", 100)}, + []BankOutput{{seller2, c("usdx", 100)}}, + []BankInput{{seller1, c("usdx", 100)}, {buyer, c("kava", 1)}}, + now + DefaultMaxBidDuration, + seller2, + c("kava", 9), + true, + }, + { + "highBid", + ReverseAuction{BaseAuction{ + Initiator: buyer, + Lot: c("kava", 10), + Bidder: seller1, + Bid: c("usdx", 100), + EndTime: end, + MaxEndTime: end, + }}, + args{now, seller2, c("kava", 11), c("usdx", 100)}, + []BankOutput{}, + []BankInput{}, + end, + seller1, + c("kava", 10), + false, + }, + { + "equalBid", + ReverseAuction{BaseAuction{ + Initiator: buyer, + Lot: c("kava", 10), + Bidder: seller1, + Bid: c("usdx", 100), + EndTime: end, + MaxEndTime: end, + }}, + args{now, seller2, c("kava", 10), c("usdx", 100)}, + []BankOutput{}, + []BankInput{}, + end, + seller1, + c("kava", 10), + false, + }, + { + "timeout", + ReverseAuction{BaseAuction{ + Initiator: buyer, + Lot: c("kava", 10), + Bidder: seller1, + Bid: c("usdx", 100), + EndTime: end, + MaxEndTime: end, + }}, + args{end + 1, seller2, c("kava", 9), c("usdx", 100)}, + []BankOutput{}, + []BankInput{}, + end, + seller1, + c("kava", 10), + false, + }, + { + "hitMaxEndTime", + ReverseAuction{BaseAuction{ + Initiator: buyer, + Lot: c("kava", 10), + Bidder: seller1, + Bid: c("usdx", 100), + EndTime: end, + MaxEndTime: end, + }}, + args{end - 1, seller2, c("kava", 9), c("usdx", 100)}, + []BankOutput{{seller2, c("usdx", 100)}}, + []BankInput{{seller1, c("usdx", 100)}, {buyer, c("kava", 1)}}, + end, // end time should be capped at MaxEndTime + seller2, + c("kava", 9), + true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // update auction and return in/outputs + outputs, inputs, err := tc.auction.PlaceBid(tc.args.currentBlockHeight, tc.args.bidder, tc.args.lot, tc.args.bid) + + // check for err + if tc.expectpass { + require.Nil(t, err) + } else { + require.NotNil(t, err) + } + // check for correct in/outputs + require.Equal(t, tc.expectedOutputs, outputs) + require.Equal(t, tc.expectedInputs, inputs) + // check for correct EndTime, bidder, bid + require.Equal(t, tc.expectedEndTime, tc.auction.EndTime) + require.Equal(t, tc.expectedBidder, tc.auction.Bidder) + require.Equal(t, tc.expectedLot, tc.auction.Lot) + }) + } +} + +func TestForwardReverseAuction_PlaceBid(t *testing.T) { + cdpOwner := sdk.AccAddress([]byte("a_cdp_owner")) + seller := sdk.AccAddress([]byte("a_seller")) + buyer1 := sdk.AccAddress([]byte("buyer1")) + buyer2 := sdk.AccAddress([]byte("buyer2")) + end := EndTime(10000) + now := EndTime(10) + + type args struct { + currentBlockHeight EndTime + bidder sdk.AccAddress + lot sdk.Coin + bid sdk.Coin + } + tests := []struct { + name string + auction ForwardReverseAuction + args args + expectedOutputs []BankOutput + expectedInputs []BankInput + expectedEndTime EndTime + expectedBidder sdk.AccAddress + expectedLot sdk.Coin + expectedBid sdk.Coin + expectpass bool + }{ + { + "normalForwardBid", + ForwardReverseAuction{BaseAuction: BaseAuction{ + Initiator: seller, + Lot: c("xrp", 100), + Bidder: buyer1, + Bid: c("usdx", 5), + EndTime: end, + MaxEndTime: end}, + MaxBid: c("usdx", 10), + OtherPerson: cdpOwner, + }, + args{now, buyer2, c("xrp", 100), c("usdx", 6)}, + []BankOutput{{buyer2, c("usdx", 6)}}, + []BankInput{{buyer1, c("usdx", 5)}, {seller, c("usdx", 1)}}, + now + DefaultMaxBidDuration, + buyer2, + c("xrp", 100), + c("usdx", 6), + true, + }, + { + "normalSwitchOverBid", + ForwardReverseAuction{BaseAuction: BaseAuction{ + Initiator: seller, + Lot: c("xrp", 100), + Bidder: buyer1, + Bid: c("usdx", 5), + EndTime: end, + MaxEndTime: end}, + MaxBid: c("usdx", 10), + OtherPerson: cdpOwner, + }, + args{now, buyer2, c("xrp", 99), c("usdx", 10)}, + []BankOutput{{buyer2, c("usdx", 10)}}, + []BankInput{{buyer1, c("usdx", 5)}, {seller, c("usdx", 5)}, {cdpOwner, c("xrp", 1)}}, + now + DefaultMaxBidDuration, + buyer2, + c("xrp", 99), + c("usdx", 10), + true, + }, + { + "normalReverseBid", + ForwardReverseAuction{BaseAuction: BaseAuction{ + Initiator: seller, + Lot: c("xrp", 99), + Bidder: buyer1, + Bid: c("usdx", 10), + EndTime: end, + MaxEndTime: end}, + MaxBid: c("usdx", 10), + OtherPerson: cdpOwner, + }, + args{now, buyer2, c("xrp", 90), c("usdx", 10)}, + []BankOutput{{buyer2, c("usdx", 10)}}, + []BankInput{{buyer1, c("usdx", 10)}, {cdpOwner, c("xrp", 9)}}, + now + DefaultMaxBidDuration, + buyer2, + c("xrp", 90), + c("usdx", 10), + true, + }, + // TODO more test cases + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // update auction and return in/outputs + outputs, inputs, err := tc.auction.PlaceBid(tc.args.currentBlockHeight, tc.args.bidder, tc.args.lot, tc.args.bid) + + // check for err + if tc.expectpass { + require.Nil(t, err) + } else { + require.NotNil(t, err) + } + // check for correct in/outputs + require.Equal(t, tc.expectedOutputs, outputs) + require.Equal(t, tc.expectedInputs, inputs) + // check for correct EndTime, bidder, bid + require.Equal(t, tc.expectedEndTime, tc.auction.EndTime) + require.Equal(t, tc.expectedBidder, tc.auction.Bidder) + require.Equal(t, tc.expectedLot, tc.auction.Lot) + require.Equal(t, tc.expectedBid, tc.auction.Bid) + }) + } +} + +// defined to avoid cluttering test cases with long function name +func c(denom string, amount int64) sdk.Coin { + return sdk.NewInt64Coin(denom, amount) +} diff --git a/x/auction/types/codec.go b/x/auction/types/codec.go new file mode 100644 index 00000000..00e97c8b --- /dev/null +++ b/x/auction/types/codec.go @@ -0,0 +1,23 @@ +package types + +import ( + "github.com/cosmos/cosmos-sdk/codec" +) + +// ModuleCdc module level codec +var ModuleCdc = codec.New() + +func init() { + RegisterCodec(ModuleCdc) +} + +// RegisterCodec registers concrete types on the codec. +func RegisterCodec(cdc *codec.Codec) { + cdc.RegisterConcrete(MsgPlaceBid{}, "auction/MsgPlaceBid", nil) + + // Register the Auction interface and concrete types + cdc.RegisterInterface((*Auction)(nil), nil) + cdc.RegisterConcrete(&ForwardAuction{}, "auction/ForwardAuction", nil) + cdc.RegisterConcrete(&ReverseAuction{}, "auction/ReverseAuction", nil) + cdc.RegisterConcrete(&ForwardReverseAuction{}, "auction/ForwardReverseAuction", nil) +} diff --git a/x/auction/types/expected_keepers.go b/x/auction/types/expected_keepers.go new file mode 100644 index 00000000..5a99550d --- /dev/null +++ b/x/auction/types/expected_keepers.go @@ -0,0 +1,10 @@ +package types + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" +) + +type BankKeeper interface { + SubtractCoins(sdk.Context, sdk.AccAddress, sdk.Coins) (sdk.Coins, sdk.Error) + AddCoins(sdk.Context, sdk.AccAddress, sdk.Coins) (sdk.Coins, sdk.Error) +} diff --git a/x/auction/types/genesis.go b/x/auction/types/genesis.go new file mode 100644 index 00000000..31c1efb5 --- /dev/null +++ b/x/auction/types/genesis.go @@ -0,0 +1,47 @@ +package types + +import ( + "bytes" +) + +// GenesisAuctions type for an array of auctions +type GenesisAuctions []Auction + +// GenesisState - auction state that must be provided at genesis +type GenesisState struct { + AuctionParams AuctionParams `json:"auction_params" yaml:"auction_params"` + Auctions GenesisAuctions `json:"genesis_auctions" yaml:"genesis_auctions"` +} + +// NewGenesisState returns a new genesis state object for auctions module +func NewGenesisState(ap AuctionParams, ga GenesisAuctions) GenesisState { + return GenesisState{ + AuctionParams: ap, + Auctions: ga, + } +} + +// DefaultGenesisState defines default genesis state for auction module +func DefaultGenesisState() GenesisState { + return NewGenesisState(DefaultAuctionParams(), GenesisAuctions{}) +} + +// Equal checks whether two GenesisState structs are equivalent +func (data GenesisState) Equal(data2 GenesisState) bool { + b1 := ModuleCdc.MustMarshalBinaryBare(data) + b2 := ModuleCdc.MustMarshalBinaryBare(data2) + return bytes.Equal(b1, b2) +} + +// IsEmpty returns true if a GenesisState is empty +func (data GenesisState) IsEmpty() bool { + return data.Equal(GenesisState{}) +} + +// ValidateGenesis validates genesis inputs. Returns error if validation of any input fails. +func ValidateGenesis(data GenesisState) error { + if err := data.AuctionParams.Validate(); err != nil { + return err + } + return nil +} diff --git a/x/auction/types/keys.go b/x/auction/types/keys.go new file mode 100644 index 00000000..cf2153b3 --- /dev/null +++ b/x/auction/types/keys.go @@ -0,0 +1,15 @@ +package types + +const ( + // ModuleName The name that will be used throughout the module + ModuleName = "auction" + + // StoreKey Top level store key where all module items will be stored + StoreKey = ModuleName + + // RouterKey Top level router key + RouterKey = ModuleName + + // DefaultParamspace default name for parameter store + DefaultParamspace = ModuleName +) diff --git a/x/auction/types/msg.go b/x/auction/types/msg.go new file mode 100644 index 00000000..5cdc60e3 --- /dev/null +++ b/x/auction/types/msg.go @@ -0,0 +1,93 @@ +package types + +import sdk "github.com/cosmos/cosmos-sdk/types" + +// MsgPlaceBid is the message type used to place a bid on any type of auction. +type MsgPlaceBid struct { + AuctionID ID + Bidder sdk.AccAddress // This can be a buyer (who increments bid), or a seller (who decrements lot) TODO rename to be clearer? + Bid sdk.Coin + Lot sdk.Coin +} + +// NewMsgPlaceBid returns a new MsgPlaceBid. +func NewMsgPlaceBid(auctionID ID, bidder sdk.AccAddress, bid sdk.Coin, lot sdk.Coin) MsgPlaceBid { + return MsgPlaceBid{ + AuctionID: auctionID, + Bidder: bidder, + Bid: bid, + Lot: lot, + } +} + +// Route return the message type used for routing the message. +func (msg MsgPlaceBid) Route() string { return "auction" } + +// Type returns a human-readable string for the message, intended for utilization within tags. +func (msg MsgPlaceBid) Type() string { return "place_bid" } + +// ValidateBasic does a simple validation check that doesn't require access to any other information. +func (msg MsgPlaceBid) ValidateBasic() sdk.Error { + if msg.Bidder.Empty() { + return sdk.ErrInternal("invalid (empty) bidder address") + } + if msg.Bid.Amount.LT(sdk.ZeroInt()) { + return sdk.ErrInternal("invalid (negative) bid amount") + } + if msg.Lot.Amount.LT(sdk.ZeroInt()) { + return sdk.ErrInternal("invalid (negative) lot amount") + } + // TODO check coin denoms + return nil +} + +// GetSignBytes gets the canonical byte representation of the Msg. +func (msg MsgPlaceBid) GetSignBytes() []byte { + bz := ModuleCdc.MustMarshalJSON(msg) + return sdk.MustSortJSON(bz) +} + +// GetSigners returns the addresses of signers that must sign. +func (msg MsgPlaceBid) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{msg.Bidder} +} + +// The CDP system doesn't need Msgs for starting auctions. But they could be added to allow people to create random auctions of their own, and to make this module more general purpose. + +// type MsgStartForwardAuction struct { +// Seller sdk.AccAddress +// Amount sdk.Coins +// // TODO add starting bid amount? +// // TODO specify asset denom to be received +// } + +// // NewMsgStartAuction returns a new MsgStartAuction. +// func NewMsgStartAuction(seller sdk.AccAddress, amount sdk.Coins, maxBid sdk.Coins) MsgStartAuction { +// return MsgStartAuction{ +// Seller: seller, +// Amount: amount, +// MaxBid: maxBid, +// } +// } + +// // Route return the message type used for routing the message. +// func (msg MsgStartAuction) Route() string { return "auction" } + +// // Type returns a human-readable string for the message, intended for utilization within tags. +// func (msg MsgStartAuction) Type() string { return "start_auction" } + +// // ValidateBasic does a simple validation check that doesn't require access to any other information. +// func (msg MsgStartAuction) ValidateBasic() sdk.Error { +// return nil +// } + +// // GetSignBytes gets the canonical byte representation of the Msg. +// func (msg MsgStartAuction) GetSignBytes() []byte { +// bz := msgCdc.MustMarshalJSON(msg) +// return sdk.MustSortJSON(bz) +// } + +// // GetSigners returns the addresses of signers that must sign. +// func (msg MsgStartAuction) GetSigners() []sdk.AccAddress { +// return []sdk.AccAddress{msg.Seller} +// } diff --git a/x/auction/types/msg_test.go b/x/auction/types/msg_test.go new file mode 100644 index 00000000..cfb51b5c --- /dev/null +++ b/x/auction/types/msg_test.go @@ -0,0 +1,32 @@ +package types + +import ( + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/require" +) + +func TestMsgPlaceBid_ValidateBasic(t *testing.T) { + addr := sdk.AccAddress([]byte("someName")) + tests := []struct { + name string + msg MsgPlaceBid + expectPass bool + }{ + {"normal", MsgPlaceBid{0, addr, sdk.NewInt64Coin("usdx", 10), sdk.NewInt64Coin("kava", 20)}, true}, + {"emptyAddr", MsgPlaceBid{0, sdk.AccAddress{}, sdk.NewInt64Coin("usdx", 10), sdk.NewInt64Coin("kava", 20)}, false}, + {"negativeBid", MsgPlaceBid{0, addr, sdk.Coin{"usdx", sdk.NewInt(-10)}, sdk.NewInt64Coin("kava", 20)}, false}, + {"negativeLot", MsgPlaceBid{0, addr, sdk.NewInt64Coin("usdx", 10), sdk.Coin{"kava", sdk.NewInt(-20)}}, false}, + {"zerocoins", MsgPlaceBid{0, addr, sdk.NewInt64Coin("usdx", 0), sdk.NewInt64Coin("kava", 0)}, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.expectPass { + require.Nil(t, tc.msg.ValidateBasic()) + } else { + require.NotNil(t, tc.msg.ValidateBasic()) + } + }) + } +} diff --git a/x/auction/types/params.go b/x/auction/types/params.go new file mode 100644 index 00000000..527cd844 --- /dev/null +++ b/x/auction/types/params.go @@ -0,0 +1,98 @@ +package types + +import ( + "bytes" + "fmt" + + "github.com/cosmos/cosmos-sdk/x/params/subspace" +) + +// Defaults for auction params +const ( + // DefaultMaxAuctionDuration max length of auction, roughly 2 days in blocks + DefaultMaxAuctionDuration EndTime = 2 * 24 * 3600 / 5 + // DefaultBidDuration how long an auction gets extended when someone bids, roughly 3 hours in blocks + DefaultMaxBidDuration EndTime = 3 * 3600 / 5 + // DefaultStartingAuctionID what the id of the first auction will be + DefaultStartingAuctionID ID = ID(0) +) + +// Parameter keys +var ( + // ParamStoreKeyAuctionParams Param store key for auction params + KeyAuctionBidDuration = []byte("MaxBidDuration") + KeyAuctionDuration = []byte("MaxAuctionDuration") + KeyAuctionStartingID = []byte("StartingAuctionID") +) + +var _ subspace.ParamSet = &AuctionParams{} + +// AuctionParams governance parameters for auction module +type AuctionParams struct { + MaxAuctionDuration EndTime `json:"max_auction_duration" yaml:"max_auction_duration"` // max length of auction, in blocks + MaxBidDuration EndTime `json:"max_bid_duration" yaml:"max_bid_duration"` + StartingAuctionID ID `json:"starting_auction_id" yaml:"starting_auction_id"` +} + +// NewAuctionParams creates a new AuctionParams object +func NewAuctionParams(maxAuctionDuration EndTime, bidDuration EndTime, startingID ID) AuctionParams { + return AuctionParams{ + MaxAuctionDuration: maxAuctionDuration, + MaxBidDuration: bidDuration, + StartingAuctionID: startingID, + } +} + +// DefaultAuctionParams default parameters for auctions +func DefaultAuctionParams() AuctionParams { + return NewAuctionParams( + DefaultMaxAuctionDuration, + DefaultMaxBidDuration, + DefaultStartingAuctionID, + ) +} + +// ParamKeyTable Key declaration for parameters +func ParamKeyTable() subspace.KeyTable { + return subspace.NewKeyTable().RegisterParamSet(&AuctionParams{}) +} + +// ParamSetPairs implements the ParamSet interface and returns all the key/value pairs +// pairs of auth module's parameters. +// nolint +func (ap *AuctionParams) ParamSetPairs() subspace.ParamSetPairs { + return subspace.ParamSetPairs{ + {KeyAuctionBidDuration, &ap.MaxBidDuration}, + {KeyAuctionDuration, &ap.MaxAuctionDuration}, + {KeyAuctionStartingID, &ap.StartingAuctionID}, + } +} + +// Equal returns a boolean determining if two AuctionParams types are identical. +func (ap AuctionParams) Equal(ap2 AuctionParams) bool { + bz1 := ModuleCdc.MustMarshalBinaryLengthPrefixed(&ap) + bz2 := ModuleCdc.MustMarshalBinaryLengthPrefixed(&ap2) + return bytes.Equal(bz1, bz2) +} + +// String implements stringer interface +func (ap AuctionParams) String() string { + return fmt.Sprintf(`Auction Params: + Max Auction Duration: %s + Max Bid Duration: %s + Starting Auction ID: %v`, ap.MaxAuctionDuration, ap.MaxBidDuration, ap.StartingAuctionID) +} + +// Validate checks that the parameters have valid values. +func (ap AuctionParams) Validate() error { + if ap.MaxAuctionDuration <= EndTime(0) { + return fmt.Errorf("max auction duration should be positive, is %s", ap.MaxAuctionDuration) + } + if ap.MaxBidDuration <= EndTime(0) { + return fmt.Errorf("bid duration should be positive, is %s", ap.MaxBidDuration) + } + if ap.StartingAuctionID <= ID(0) { + return fmt.Errorf("starting auction ID should be positive, is %v", ap.StartingAuctionID) + } + return nil +} diff --git a/x/auction/types/quierier.go b/x/auction/types/quierier.go new file mode 100644 index 00000000..571c3908 --- /dev/null +++ b/x/auction/types/quierier.go @@ -0,0 +1,18 @@ +package types + +import ( + "strings" +) + +const ( + // QueryGetAuction command for getting the information about a particular auction + QueryGetAuction = "getauctions" +) + +// QueryResAuctions Result Payload for an auctions query +type QueryResAuctions []string + +// implement fmt.Stringer +func (n QueryResAuctions) String() string { + return strings.Join(n[:], "\n") +} diff --git a/x/auction/types/utils.go b/x/auction/types/utils.go new file mode 100644 index 00000000..b1aad11e --- /dev/null +++ b/x/auction/types/utils.go @@ -0,0 +1,9 @@ +package types + +// Go doesn't have a built in min function for integers :( +func min(a, b int64) int64 { + if a < b { + return a + } + return b +} diff --git a/x/cdp/alias.go b/x/cdp/alias.go new file mode 100644 index 00000000..f22e0366 --- /dev/null +++ b/x/cdp/alias.go @@ -0,0 +1,61 @@ +// nolint +// autogenerated code using github.com/rigelrozanski/multitool +// aliases generated for the following subdirectories: +// ALIASGEN: github.com/kava-labs/kava/x/cdp/types/ +// ALIASGEN: github.com/kava-labs/kava/x/cdp/keeper/ +package cdp + +import ( + "github.com/kava-labs/kava/x/cdp/keeper" + "github.com/kava-labs/kava/x/cdp/types" +) + +const ( + ModuleName = types.ModuleName + StoreKey = types.StoreKey + RouterKey = types.RouterKey + DefaultParamspace = types.DefaultParamspace + QueryGetCdps = types.QueryGetCdps + QueryGetParams = types.QueryGetParams + RestOwner = types.RestOwner + RestCollateralDenom = types.RestCollateralDenom + RestUnderCollateralizedAt = types.RestUnderCollateralizedAt + GovDenom = types.GovDenom +) + +var ( + // functions aliases + RegisterCodec = types.RegisterCodec + DefaultGenesisState = types.DefaultGenesisState + ValidateGenesis = types.ValidateGenesis + NewMsgCreateOrModifyCDP = types.NewMsgCreateOrModifyCDP + ParamKeyTable = types.ParamKeyTable + DefaultParams = types.DefaultParams + NewKeeper = keeper.NewKeeper + NewQuerier = keeper.NewQuerier + + // variable aliases + ModuleCdc = types.ModuleCdc + KeyGlobalDebtLimit = types.KeyGlobalDebtLimit + KeyCollateralParams = types.KeyCollateralParams + KeyStableDenoms = types.KeyStableDenoms + LiquidatorAccountAddress = keeper.LiquidatorAccountAddress +) + +type ( + BankKeeper = types.BankKeeper + PricefeedKeeper = types.PricefeedKeeper + GenesisState = types.GenesisState + MsgCreateOrModifyCDP = types.MsgCreateOrModifyCDP + MsgTransferCDP = types.MsgTransferCDP + CdpParams = types.CdpParams + CollateralParams = types.CollateralParams + QueryCdpsParams = types.QueryCdpsParams + ModifyCdpRequestBody = types.ModifyCdpRequestBody + CDP = types.CDP + CDPs = types.CDPs + ByCollateralRatio = types.ByCollateralRatio + CollateralState = types.CollateralState + Keeper = keeper.Keeper + LiquidatorModuleAccount = keeper.LiquidatorModuleAccount +) diff --git a/x/cdp/app_test.go b/x/cdp/app_test.go new file mode 100644 index 00000000..39737d43 --- /dev/null +++ b/x/cdp/app_test.go @@ -0,0 +1,67 @@ +package cdp + +import ( + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/mock" + abci "github.com/tendermint/tendermint/abci/types" + + "github.com/kava-labs/kava/x/pricefeed" +) + +func TestApp_CreateModifyDeleteCDP(t *testing.T) { + // Setup + mapp, keeper, pfKeeper := setUpMockAppWithoutGenesis() + genAccs, addrs, _, privKeys := mock.CreateGenAccounts(1, cs(c("xrp", 100))) + testAddr := addrs[0] + testPrivKey := privKeys[0] + mock.SetGenesis(mapp, genAccs) + mock.CheckBalance(t, mapp, testAddr, cs(c("xrp", 100))) + // setup pricefeed, TODO can this be shortened a bit? + header := abci.Header{Height: mapp.LastBlockHeight() + 1} + mapp.BeginBlock(abci.RequestBeginBlock{Header: header}) + ctx := mapp.BaseApp.NewContext(false, header) + params := CdpParams{ + GlobalDebtLimit: sdk.NewInt(100000), + CollateralParams: []CollateralParams{ + { + Denom: "xrp", + LiquidationRatio: sdk.MustNewDecFromStr("1.5"), + DebtLimit: sdk.NewInt(10000), + }, + }, + StableDenoms: []string{"usdx"}, + } + keeper.SetParams(ctx, params) + keeper.SetGlobalDebt(ctx, sdk.NewInt(0)) + ap := pricefeed.AssetParams{ + Assets: []pricefeed.Asset{pricefeed.Asset{AssetCode: "xrp", Description: ""}}, + } + pfKeeper.SetAssetParams(ctx, ap) + pfKeeper.SetPrice( + ctx, sdk.AccAddress{}, "xrp", + sdk.MustNewDecFromStr("1.00"), + sdk.NewInt(10)) + pfKeeper.SetCurrentPrices(ctx) + mapp.EndBlock(abci.RequestEndBlock{}) + mapp.Commit() + + // Create CDP + msgs := []sdk.Msg{NewMsgCreateOrModifyCDP(testAddr, "xrp", i(10), i(5))} + mock.SignCheckDeliver(t, mapp.Cdc, mapp.BaseApp, abci.Header{Height: mapp.LastBlockHeight() + 1}, msgs, []uint64{0}, []uint64{0}, true, true, testPrivKey) + + mock.CheckBalance(t, mapp, testAddr, cs(c("usdx", 5), c("xrp", 90))) + + // Modify CDP + msgs = []sdk.Msg{NewMsgCreateOrModifyCDP(testAddr, "xrp", i(40), i(5))} + mock.SignCheckDeliver(t, mapp.Cdc, mapp.BaseApp, abci.Header{Height: mapp.LastBlockHeight() + 1}, msgs, []uint64{0}, []uint64{1}, true, true, testPrivKey) + + mock.CheckBalance(t, mapp, testAddr, cs(c("usdx", 10), c("xrp", 50))) + + // Delete CDP + msgs = []sdk.Msg{NewMsgCreateOrModifyCDP(testAddr, "xrp", i(-50), i(-10))} + mock.SignCheckDeliver(t, mapp.Cdc, mapp.BaseApp, abci.Header{Height: mapp.LastBlockHeight() + 1}, msgs, []uint64{0}, []uint64{2}, true, true, testPrivKey) + + mock.CheckBalance(t, mapp, testAddr, cs(c("xrp", 100))) +} diff --git a/x/cdp/client/cli/query.go b/x/cdp/client/cli/query.go new file mode 100644 index 00000000..e1cdaefd --- /dev/null +++ b/x/cdp/client/cli/query.go @@ -0,0 +1,167 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/kava-labs/kava/x/cdp/types" +) + +// GetQueryCmd returns the cli query commands for this module +func GetQueryCmd(queryRoute string, cdc *codec.Codec) *cobra.Command { + // Group nameservice queries under a subcommand + cdpQueryCmd := &cobra.Command{ + Use: "cdp", + Short: "Querying commands for the cdp module", + } + + cdpQueryCmd.AddCommand(client.GetCommands( + GetCmdGetCdp(queryRoute, cdc), + GetCmdGetCdps(queryRoute, cdc), + GetCmdGetUnderCollateralizedCdps(queryRoute, cdc), + GetCmdGetParams(queryRoute, cdc), + )...) + + return cdpQueryCmd +} + +// GetCmdGetCdp queries the latest info about a particular cdp +func GetCmdGetCdp(queryRoute string, cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "cdp [ownerAddress] [collateralType]", + Short: "get info about a cdp", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + + // Prepare params for querier + ownerAddress, err := sdk.AccAddressFromBech32(args[0]) + if err != nil { + return err + } + collateralType := args[1] // TODO validation? + bz, err := cdc.MarshalJSON(types.QueryCdpsParams{ + Owner: ownerAddress, + CollateralDenom: collateralType, + }) + if err != nil { + return err + } + + // Query + route := fmt.Sprintf("custom/%s/%s", queryRoute, types.QueryGetCdps) + res, _, err := cliCtx.QueryWithData(route, bz) + if err != nil { + fmt.Printf("error when getting cdp info - %s", err) + fmt.Printf("could not get current cdp info - %s %s \n", string(ownerAddress), string(collateralType)) + return err + } + + // Decode and print results + var cdps types.CDPs + cdc.MustUnmarshalJSON(res, &cdps) + if len(cdps) != 1 { + panic("Unexpected number of CDPs returned from querier. This shouldn't happen.") + } + return cliCtx.PrintOutput(cdps[0]) + }, + } +} + +// GetCmdGetCdps queries the store for all cdps for a collateral type +func GetCmdGetCdps(queryRoute string, cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "cdps [collateralType]", + Short: "get info about many cdps", + Long: "Get all CDPs. Specify a collateral type to get only CDPs with that collateral type.", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + + // Prepare params for querier + bz, err := cdc.MarshalJSON(types.QueryCdpsParams{CollateralDenom: args[0]}) // denom="" returns all CDPs // TODO will this fail if there are no args? + if err != nil { + return err + } + + // Query + route := fmt.Sprintf("custom/%s/%s", queryRoute, types.QueryGetCdps) + res, _, err := cliCtx.QueryWithData(route, bz) + if err != nil { + return err + } + + // Decode and print results + var out types.CDPs + cdc.MustUnmarshalJSON(res, &out) + return cliCtx.PrintOutput(out) + }, + } +} + +func GetCmdGetUnderCollateralizedCdps(queryRoute string, cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "bad-cdps [collateralType] [price]", + Short: "get under collateralized CDPs", + Long: "Get all CDPS of a particular collateral type that will be under collateralized at the specified price. Pass in the current price to get currently under collateralized CDPs.", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + + // Prepare params for querier + price, errSdk := sdk.NewDecFromStr(args[1]) + if errSdk != nil { + return fmt.Errorf(errSdk.Error()) // TODO check this returns useful output + } + bz, err := cdc.MarshalJSON(types.QueryCdpsParams{ + CollateralDenom: args[0], + UnderCollateralizedAt: price, + }) + if err != nil { + return err + } + + // Query + route := fmt.Sprintf("custom/%s/%s", queryRoute, types.QueryGetCdps) + res, _, err := cliCtx.QueryWithData(route, bz) + if err != nil { + return err + } + + // Decode and print results + var out types.CDPs + cdc.MustUnmarshalJSON(res, &out) + return cliCtx.PrintOutput(out) + }, + } +} + +func GetCmdGetParams(queryRoute string, cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "params", + Short: "get the cdp module parameters", + Long: "Get the current global cdp module parameters.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + + // Query + route := fmt.Sprintf("custom/%s/%s", queryRoute, types.QueryGetParams) + res, _, err := cliCtx.QueryWithData(route, nil) // TODO use cliCtx.QueryStore? + if err != nil { + return err + } + + // Decode and print results + var out types.CdpParams + cdc.MustUnmarshalJSON(res, &out) + return cliCtx.PrintOutput(out) + }, + } +} diff --git a/x/cdp/client/cli/tx.go b/x/cdp/client/cli/tx.go new file mode 100644 index 00000000..5bb0a0a7 --- /dev/null +++ b/x/cdp/client/cli/tx.go @@ -0,0 +1,61 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/auth" + "github.com/cosmos/cosmos-sdk/x/auth/client/utils" + + "github.com/kava-labs/kava/x/cdp/types" + +) + +// GetTxCmd returns the transaction commands for this module +// TODO: Tests, see: https://github.com/cosmos/cosmos-sdk/blob/18de630d0ae1887113e266982b51c2bf1f662edb/x/staking/client/cli/tx_test.go +func GetTxCmd(cdc *codec.Codec) *cobra.Command { + cdpTxCmd := &cobra.Command{ + Use: "cdp", + Short: "cdp transactions subcommands", + } + + cdpTxCmd.AddCommand(client.PostCommands( + GetCmdModifyCdp(cdc), + )...) + + return cdpTxCmd +} + +// GetCmdModifyCdp cli command for creating and modifying cdps. +func GetCmdModifyCdp(cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "modifycdp [ownerAddress] [collateralType] [collateralChange] [debtChange]", + Short: "create or modify a cdp", + Args: cobra.ExactArgs(4), + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + txBldr := auth.NewTxBuilderFromCLI().WithTxEncoder(utils.GetTxEncoder(cdc)) + collateralChange, ok := sdk.NewIntFromString(args[2]) + if !ok { + fmt.Printf("invalid collateral amount - %s \n", string(args[2])) + return nil + } + debtChange, ok := sdk.NewIntFromString(args[3]) + if !ok { + fmt.Printf("invalid debt amount - %s \n", string(args[3])) + return nil + } + msg := types.NewMsgCreateOrModifyCDP(cliCtx.GetFromAddress(), args[1], collateralChange, debtChange) + err := msg.ValidateBasic() + if err != nil { + return err + } + return utils.GenerateOrBroadcastMsgs(cliCtx, txBldr, []sdk.Msg{msg}) + }, + } +} diff --git a/x/cdp/client/module_client.go b/x/cdp/client/module_client.go new file mode 100644 index 00000000..799aaa0c --- /dev/null +++ b/x/cdp/client/module_client.go @@ -0,0 +1,51 @@ +package client + +import ( + "github.com/cosmos/cosmos-sdk/client" + cdpcmd "github.com/kava-labs/kava/x/cdp/client/cli" + "github.com/spf13/cobra" + amino "github.com/tendermint/go-amino" +) + +// ModuleClient exports all client functionality from this module +type ModuleClient struct { + storeKey string + cdc *amino.Codec +} + +// NewModuleClient creates client for the module +func NewModuleClient(storeKey string, cdc *amino.Codec) ModuleClient { + return ModuleClient{storeKey, cdc} +} + +// GetQueryCmd returns the cli query commands for this module +func (mc ModuleClient) GetQueryCmd() *cobra.Command { + // Group nameservice queries under a subcommand + cdpQueryCmd := &cobra.Command{ + Use: "cdp", + Short: "Querying commands for the cdp module", + } + + cdpQueryCmd.AddCommand(client.GetCommands( + cdpcmd.GetCmdGetCdp(mc.storeKey, mc.cdc), + cdpcmd.GetCmdGetCdps(mc.storeKey, mc.cdc), + cdpcmd.GetCmdGetUnderCollateralizedCdps(mc.storeKey, mc.cdc), + cdpcmd.GetCmdGetParams(mc.storeKey, mc.cdc), + )...) + + return cdpQueryCmd +} + +// GetTxCmd returns the transaction commands for this module +func (mc ModuleClient) GetTxCmd() *cobra.Command { + cdpTxCmd := &cobra.Command{ + Use: "cdp", + Short: "cdp transactions subcommands", + } + + cdpTxCmd.AddCommand(client.PostCommands( + cdpcmd.GetCmdModifyCdp(mc.cdc), + )...) + + return cdpTxCmd +} diff --git a/x/cdp/client/rest/query.go b/x/cdp/client/rest/query.go new file mode 100644 index 00000000..94d93d2c --- /dev/null +++ b/x/cdp/client/rest/query.go @@ -0,0 +1,100 @@ +package rest + +import ( + "fmt" + "net/http" + + "github.com/gorilla/mux" + + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/types/rest" + + "github.com/kava-labs/kava/x/cdp/types" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +/* +API Design: + +Currently CDPs do not have IDs so standard REST uri conventions (ie GET /cdps/{cdp-id}) don't work too well. + +Get one or more cdps + GET /cdps?collateralDenom={denom}&owner={address}&underCollateralizedAt={price} +Modify a CDP (idempotent). Create is not separated out because conceptually all CDPs already exist (just with zero collateral and debt). // TODO is making this idempotent actually useful? + PUT /cdps +Get the module params, including authorized collateral denoms. + GET /params +*/ + +// RegisterRoutes - Central function to define routes that get registered by the main application +func registerQueryRoutes(cliCtx context.CLIContext, r *mux.Router) { + r.HandleFunc("/cdps", getCdpsHandlerFn(cliCtx)).Methods("GET") + r.HandleFunc("/cdps/params", getParamsHandlerFn(cliCtx)).Methods("GET") +} + +func getCdpsHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // get parameters from the URL + ownerBech32 := r.URL.Query().Get(types.RestOwner) + collateralDenom := r.URL.Query().Get(types.RestCollateralDenom) + priceString := r.URL.Query().Get(types.RestUnderCollateralizedAt) + + // Construct querier params + querierParams := types.QueryCdpsParams{} + + if len(ownerBech32) != 0 { + owner, err := sdk.AccAddressFromBech32(ownerBech32) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + querierParams.Owner = owner + } + + if len(collateralDenom) != 0 { + // TODO validate denom + querierParams.CollateralDenom = collateralDenom + } + + if len(priceString) != 0 { + price, err := sdk.NewDecFromStr(priceString) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + querierParams.UnderCollateralizedAt = price + } + + querierParamsBz, err := cliCtx.Codec.MarshalJSON(querierParams) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + // Get the CDPs + res, height, err := cliCtx.QueryWithData(fmt.Sprintf("custom/cdp/%s", types.QueryGetCdps), querierParamsBz) + if err != nil { + rest.WriteErrorResponse(w, http.StatusNotFound, err.Error()) + return + } + + cliCtx = cliCtx.WithHeight(height) + rest.PostProcessResponse(w, cliCtx, res) + + } +} + +func getParamsHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Get the params + res, height, err := cliCtx.QueryWithData(fmt.Sprintf("custom/cdp/%s", types.QueryGetParams), nil) + cliCtx = cliCtx.WithHeight(height) + if err != nil { + rest.WriteErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + // Return the params + rest.PostProcessResponse(w, cliCtx, res) + } +} diff --git a/x/cdp/client/rest/rest.go b/x/cdp/client/rest/rest.go new file mode 100644 index 00000000..84eb85fd --- /dev/null +++ b/x/cdp/client/rest/rest.go @@ -0,0 +1,13 @@ +package rest + +import ( + "github.com/gorilla/mux" + + "github.com/cosmos/cosmos-sdk/client/context" +) + +// RegisterRoutes - Central function to define routes that get registered by the main application +func RegisterRoutes(cliCtx context.CLIContext, r *mux.Router) { + registerQueryRoutes(cliCtx, r) + registerTxRoutes(cliCtx, r) +} diff --git a/x/cdp/client/rest/tx.go b/x/cdp/client/rest/tx.go new file mode 100644 index 00000000..e1459a46 --- /dev/null +++ b/x/cdp/client/rest/tx.go @@ -0,0 +1,69 @@ +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/cdp/types" +) + +func registerTxRoutes(cliCtx context.CLIContext, r *mux.Router) { + r.HandleFunc("/cdps", modifyCdpHandlerFn(cliCtx)).Methods("PUT") +} + +func modifyCdpHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Decode PUT request body + var requestBody types.ModifyCdpRequestBody + if !rest.ReadRESTReq(w, r, cliCtx.Codec, &requestBody) { + return + } + requestBody.BaseReq = requestBody.BaseReq.Sanitize() + if !requestBody.BaseReq.ValidateBasic(w) { + return + } + + // Get the stored CDP + querierParams := types.QueryCdpsParams{ + Owner: requestBody.Cdp.Owner, + CollateralDenom: requestBody.Cdp.CollateralDenom, + } + querierParamsBz, err := cliCtx.Codec.MarshalJSON(querierParams) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + res, height, err := cliCtx.QueryWithData(fmt.Sprintf("custom/cdp/%s", types.QueryGetCdps), querierParamsBz) + if err != nil { + rest.WriteErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + cliCtx = cliCtx.WithHeight(height) + var cdps types.CDPs + err = cliCtx.Codec.UnmarshalJSON(res, &cdps) + if len(cdps) != 1 || err != nil { + rest.WriteErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + // Calculate CDP updates + collateralDelta := requestBody.Cdp.CollateralAmount.Sub(cdps[0].CollateralAmount) + debtDelta := requestBody.Cdp.Debt.Sub(cdps[0].Debt) + + // Create and return msg + msg := types.NewMsgCreateOrModifyCDP( + requestBody.Cdp.Owner, + requestBody.Cdp.CollateralDenom, + collateralDelta, + debtDelta, + ) + utils.WriteGenerateStdTxResponse(w, cliCtx, requestBody.BaseReq, []sdk.Msg{msg}) + } +} diff --git a/x/cdp/doc.go b/x/cdp/doc.go new file mode 100644 index 00000000..1207e925 --- /dev/null +++ b/x/cdp/doc.go @@ -0,0 +1,27 @@ +/* +Package CDP manages the storage of Collateralized Debt Positions. It handles their creation, modification, and stores the global state of all CDPs. + +Notes + - sdk.Int is used for all the number types to maintain compatibility with internal type of sdk.Coin - saves type conversion when doing maths. + Also it allows for changes to a CDP to be expressed as a +ve or -ve number. + - Only allowing one CDP per account-collateralDenom pair for now to keep things simple. + - Genesis forces the global debt to start at zero, ie no stable coins in existence. This could be changed. + - The cdp module fulfills the bank keeper interface and keeps track of the liquidator module's coins. This won't be needed with module accounts. + - GetCDPs does not return an iterator, but instead reads out (potentially) all CDPs from the store. This isn't a huge performance concern as it is never used during a block, only for querying. + An iterator could be created, following the queue style construct in gov and auction, where CDP IDs are stored under ordered keys. + These keys could be a collateral-denom:collateral-ratio so that it is efficient to obtain the undercollateralized CDP for a given price and liquidation ratio. + However creating a byte sortable representation of a collateral ratio wasn't very easy so the simpler approach was chosen. + +TODO + - A shorter name for an under-collateralized CDP would shorten a lot of function names + - remove fake bank keeper and setup a proper liquidator module account + - what happens if a collateral type is removed from the list of allowed ones? + - Should the values used to generate a key for a stored struct be in the struct? + - Add constants for the module and route names + - Many more TODOs in the code + - add more aggressive test cases + - tags + - custom error types, codespace + +*/ +package cdp diff --git a/x/cdp/genesis.go b/x/cdp/genesis.go new file mode 100644 index 00000000..5bd639fa --- /dev/null +++ b/x/cdp/genesis.go @@ -0,0 +1,47 @@ +package cdp + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +func InitGenesis(ctx sdk.Context, k Keeper, pk PricefeedKeeper, data GenesisState) { + // validate denoms - check that any collaterals in the CdpParams are in the pricefeed, pricefeed needs to initgenesis before cdp + collateralMap := make(map[string]int) + ap := pk.GetAssetParams(ctx) + for _, a := range ap.Assets { + collateralMap[a.AssetCode] = 1 + } + + for _, col := range data.Params.CollateralParams { + _, found := collateralMap[col.Denom] + if !found { + panic(fmt.Sprintf("%s collateral not found in pricefeed", col.Denom)) + } + } + + k.SetParams(ctx, data.Params) + + for _, cdp := range data.CDPs { + k.SetCDP(ctx, cdp) + } + + k.SetGlobalDebt(ctx, data.GlobalDebt) + +} + +func ExportGenesis(ctx sdk.Context, k Keeper) GenesisState { + params := k.GetParams(ctx) + cdps, err := k.GetCDPs(ctx, "", sdk.Dec{}) + if err != nil { + panic(err) + } + debt := k.GetGlobalDebt(ctx) + + return GenesisState{ + Params: params, + GlobalDebt: debt, + CDPs: cdps, + } +} diff --git a/x/cdp/handler.go b/x/cdp/handler.go new file mode 100644 index 00000000..c7cd84d6 --- /dev/null +++ b/x/cdp/handler.go @@ -0,0 +1,30 @@ +package cdp + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// Handle all cdp messages. +func NewHandler(keeper Keeper) sdk.Handler { + return func(ctx sdk.Context, msg sdk.Msg) sdk.Result { + switch msg := msg.(type) { + case MsgCreateOrModifyCDP: + return handleMsgCreateOrModifyCDP(ctx, keeper, msg) + default: + errMsg := fmt.Sprintf("Unrecognized cdp msg type: %T", msg) + return sdk.ErrUnknownRequest(errMsg).Result() + } + } +} + +func handleMsgCreateOrModifyCDP(ctx sdk.Context, keeper Keeper, msg MsgCreateOrModifyCDP) sdk.Result { + + err := keeper.ModifyCDP(ctx, msg.Sender, msg.CollateralDenom, msg.CollateralChange, msg.DebtChange) + if err != nil { + return err.Result() + } + + return sdk.Result{} +} diff --git a/x/cdp/keeper/bank_test.go b/x/cdp/keeper/bank_test.go new file mode 100644 index 00000000..ddac5479 --- /dev/null +++ b/x/cdp/keeper/bank_test.go @@ -0,0 +1,67 @@ +package keeper + +import ( + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/auth" + authexported "github.com/cosmos/cosmos-sdk/x/auth/exported" + "github.com/cosmos/cosmos-sdk/x/mock" + "github.com/stretchr/testify/require" + abci "github.com/tendermint/tendermint/abci/types" +) + +// Test the bank functionality of the CDP keeper +func TestKeeper_AddSubtractGetCoins(t *testing.T) { + _, addrs := mock.GeneratePrivKeyAddressPairs(1) + normalAddr := addrs[0] + + tests := []struct { + name string + address sdk.AccAddress + shouldAdd bool + amount sdk.Coins + expectedCoins sdk.Coins + }{ + {"addNormalAddress", normalAddr, true, cs(c("usdx", 53)), cs(c("usdx", 153), c("kava", 100))}, + {"subNormalAddress", normalAddr, false, cs(c("usdx", 53)), cs(c("usdx", 47), c("kava", 100))}, + {"addLiquidatorStable", LiquidatorAccountAddress, true, cs(c("usdx", 53)), cs(c("usdx", 153))}, + {"subLiquidatorStable", LiquidatorAccountAddress, false, cs(c("usdx", 53)), cs(c("usdx", 47))}, + {"addLiquidatorGov", LiquidatorAccountAddress, true, cs(c("kava", 53)), cs(c("usdx", 100))}, // no change to balance + {"subLiquidatorGov", LiquidatorAccountAddress, false, cs(c("kava", 53)), cs(c("usdx", 100))}, // no change to balance + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // setup keeper + mapp, keeper, _, _ := setUpMockAppWithoutGenesis() + // initialize an account with coins + genAcc := auth.BaseAccount{ + Address: normalAddr, + Coins: cs(c("usdx", 100), c("kava", 100)), + } + mock.SetGenesis(mapp, []authexported.Account{&genAcc}) + + // create a new context and setup the liquidator account + header := abci.Header{Height: mapp.LastBlockHeight() + 1} + mapp.BeginBlock(abci.RequestBeginBlock{Header: header}) + ctx := mapp.BaseApp.NewContext(false, header) + keeper.setLiquidatorModuleAccount(ctx, LiquidatorModuleAccount{cs(c("usdx", 100))}) // set gov coin "balance" to zero + + // perform the test action + var err sdk.Error + if tc.shouldAdd { + _, err = keeper.AddCoins(ctx, tc.address, tc.amount) + } else { + _, err = keeper.SubtractCoins(ctx, tc.address, tc.amount) + } + + mapp.EndBlock(abci.RequestEndBlock{}) + mapp.Commit() + + // check balances are as expected + require.NoError(t, err) + require.Equal(t, tc.expectedCoins, keeper.GetCoins(ctx, tc.address)) + }) + } +} diff --git a/x/cdp/keeper/keeper.go b/x/cdp/keeper/keeper.go new file mode 100644 index 00000000..ee9f2818 --- /dev/null +++ b/x/cdp/keeper/keeper.go @@ -0,0 +1,499 @@ +package keeper + +import ( + "bytes" + "fmt" + "sort" + + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/params/subspace" + "github.com/kava-labs/kava/x/cdp/types" +) + +// Keeper cdp Keeper +type Keeper struct { + key sdk.StoreKey + cdc *codec.Codec + paramSubspace subspace.Subspace + pricefeedKeeper types.PricefeedKeeper + bankKeeper types.BankKeeper +} + +// NewKeeper creates a new keeper +func NewKeeper(cdc *codec.Codec, key sdk.StoreKey, paramstore subspace.Subspace, pfk types.PricefeedKeeper, bk types.BankKeeper) Keeper { + return Keeper{ + key: key, + cdc: cdc, + paramSubspace: paramstore.WithKeyTable(types.ParamKeyTable()), + pricefeedKeeper: pfk, + bankKeeper: bk, + } +} + +// ModifyCDP creates, changes, or deletes a CDP +// TODO can/should this function be split up? +func (k Keeper) ModifyCDP(ctx sdk.Context, owner sdk.AccAddress, collateralDenom string, changeInCollateral sdk.Int, changeInDebt sdk.Int) sdk.Error { + + // Phase 1: Get state, make changes in memory and check if they're ok. + + // Check collateral type ok + p := k.GetParams(ctx) + if !p.IsCollateralPresent(collateralDenom) { // maybe abstract this logic into GetCDP + return sdk.ErrInternal("collateral type not enabled to create CDPs") + } + + // Check the owner has enough collateral and stable coins + if changeInCollateral.IsPositive() { // adding collateral to CDP + ok := k.bankKeeper.HasCoins(ctx, owner, sdk.NewCoins(sdk.NewCoin(collateralDenom, changeInCollateral))) + if !ok { + return sdk.ErrInsufficientCoins("not enough collateral in sender's account") + } + } + if changeInDebt.IsNegative() { // reducing debt, by adding stable coin to CDP + ok := k.bankKeeper.HasCoins(ctx, owner, sdk.NewCoins(sdk.NewCoin("usdx", changeInDebt.Neg()))) + if !ok { + return sdk.ErrInsufficientCoins("not enough stable coin in sender's account") + } + } + + // Change collateral and debt recorded in CDP + // Get CDP (or create if not exists) + cdp, found := k.GetCDP(ctx, owner, collateralDenom) + if !found { + cdp = types.CDP{Owner: owner, CollateralDenom: collateralDenom, CollateralAmount: sdk.ZeroInt(), Debt: sdk.ZeroInt()} + } + // Add/Subtract collateral and debt + cdp.CollateralAmount = cdp.CollateralAmount.Add(changeInCollateral) + if cdp.CollateralAmount.IsNegative() { + return sdk.ErrInternal(" can't withdraw more collateral than exists in CDP") + } + cdp.Debt = cdp.Debt.Add(changeInDebt) + if cdp.Debt.IsNegative() { + return sdk.ErrInternal("can't pay back more debt than exists in CDP") + } + isUnderCollateralized := cdp.IsUnderCollateralized( + k.pricefeedKeeper.GetCurrentPrice(ctx, cdp.CollateralDenom).Price, + p.GetCollateralParams(cdp.CollateralDenom).LiquidationRatio, + ) + if isUnderCollateralized { + return sdk.ErrInternal("Change to CDP would put it below liquidation ratio") + } + // TODO check for dust + + // Add/Subtract from global debt limit + gDebt := k.GetGlobalDebt(ctx) + gDebt = gDebt.Add(changeInDebt) + if gDebt.IsNegative() { + return sdk.ErrInternal("global debt can't be negative") // This should never happen if debt per CDP can't be negative + } + if gDebt.GT(p.GlobalDebtLimit) { + return sdk.ErrInternal("change to CDP would put the system over the global debt limit") + } + + // Add/Subtract from collateral debt limit + collateralState, found := k.GetCollateralState(ctx, cdp.CollateralDenom) + if !found { + collateralState = types.CollateralState{Denom: cdp.CollateralDenom, TotalDebt: sdk.ZeroInt()} // Already checked that this denom is authorized, so ok to create new CollateralState + } + collateralState.TotalDebt = collateralState.TotalDebt.Add(changeInDebt) + if collateralState.TotalDebt.IsNegative() { + return sdk.ErrInternal("total debt for this collateral type can't be negative") // This should never happen if debt per CDP can't be negative + } + if collateralState.TotalDebt.GT(p.GetCollateralParams(cdp.CollateralDenom).DebtLimit) { + return sdk.ErrInternal("change to CDP would put the system over the debt limit for this collateral type") + } + + // Phase 2: Update all the state + + // change owner's coins (increase or decrease) + var err sdk.Error + if changeInCollateral.IsNegative() { + _, err = k.bankKeeper.AddCoins(ctx, owner, sdk.NewCoins(sdk.NewCoin(collateralDenom, changeInCollateral.Neg()))) + } else { + _, err = k.bankKeeper.SubtractCoins(ctx, owner, sdk.NewCoins(sdk.NewCoin(collateralDenom, changeInCollateral))) + } + if err != nil { + panic(err) // this shouldn't happen because coin balance was checked earlier + } + if changeInDebt.IsNegative() { + _, err = k.bankKeeper.SubtractCoins(ctx, owner, sdk.NewCoins(sdk.NewCoin("usdx", changeInDebt.Neg()))) + } else { + _, err = k.bankKeeper.AddCoins(ctx, owner, sdk.NewCoins(sdk.NewCoin("usdx", changeInDebt))) + } + if err != nil { + panic(err) // this shouldn't happen because coin balance was checked earlier + } + // Set CDP + if cdp.CollateralAmount.IsZero() && cdp.Debt.IsZero() { // TODO maybe abstract this logic into SetCDP + k.deleteCDP(ctx, cdp) + } else { + k.SetCDP(ctx, cdp) + } + // set total debts + k.SetGlobalDebt(ctx, gDebt) + k.setCollateralState(ctx, collateralState) + + return nil +} + +// TODO +// // TransferCDP allows people to transfer ownership of their CDPs to others +// func (k Keeper) TransferCDP(ctx sdk.Context, from sdk.AccAddress, to sdk.AccAddress, collateralDenom string) sdk.Error { +// return nil +// } + +// PartialSeizeCDP removes collateral and debt from a CDP and decrements global debt counters. It does not move collateral to another account so is unsafe. +// TODO should this be made safer by moving collateral to liquidatorModuleAccount ? If so how should debt be moved? +func (k Keeper) PartialSeizeCDP(ctx sdk.Context, owner sdk.AccAddress, collateralDenom string, collateralToSeize sdk.Int, debtToSeize sdk.Int) sdk.Error { + // get CDP + cdp, found := k.GetCDP(ctx, owner, collateralDenom) + if !found { + return sdk.ErrInternal("could not find CDP") + } + + // Check if CDP is undercollateralized + p := k.GetParams(ctx) + isUnderCollateralized := cdp.IsUnderCollateralized( + k.pricefeedKeeper.GetCurrentPrice(ctx, cdp.CollateralDenom).Price, + p.GetCollateralParams(cdp.CollateralDenom).LiquidationRatio, + ) + if !isUnderCollateralized { + return sdk.ErrInternal("CDP is not currently under the liquidation ratio") + } + + // Remove Collateral + if collateralToSeize.IsNegative() { + return sdk.ErrInternal("cannot seize negative collateral") + } + cdp.CollateralAmount = cdp.CollateralAmount.Sub(collateralToSeize) + if cdp.CollateralAmount.IsNegative() { + return sdk.ErrInternal("can't seize more collateral than exists in CDP") + } + + // Remove Debt + if debtToSeize.IsNegative() { + return sdk.ErrInternal("cannot seize negative debt") + } + cdp.Debt = cdp.Debt.Sub(debtToSeize) + if cdp.Debt.IsNegative() { + return sdk.ErrInternal("can't seize more debt than exists in CDP") + } + + // Update debt per collateral type + collateralState, found := k.GetCollateralState(ctx, cdp.CollateralDenom) + if !found { + return sdk.ErrInternal("could not find collateral state") + } + collateralState.TotalDebt = collateralState.TotalDebt.Sub(debtToSeize) + if collateralState.TotalDebt.IsNegative() { + return sdk.ErrInternal("Total debt per collateral type is negative.") // This should not happen given the checks on the CDP. + } + + // Note: Global debt is not decremented here. It's only decremented when debt and stable coin are annihilated (aka heal) + // TODO update global seized debt? this is what maker does (named vice in Vat.grab) but it's not used anywhere + + // Store updated state + if cdp.CollateralAmount.IsZero() && cdp.Debt.IsZero() { // TODO maybe abstract this logic into SetCDP + k.deleteCDP(ctx, cdp) + } else { + k.SetCDP(ctx, cdp) + } + k.setCollateralState(ctx, collateralState) + return nil +} + +// ReduceGlobalDebt decreases the stored global debt counter. It is used by the liquidator when it annihilates debt and stable coin. +// TODO Can the interface between cdp and liquidator modules be improved so that this function doesn't exist? +func (k Keeper) ReduceGlobalDebt(ctx sdk.Context, amount sdk.Int) sdk.Error { + if amount.IsNegative() { + return sdk.ErrInternal("reduction in global debt must be a positive amount") + } + newGDebt := k.GetGlobalDebt(ctx).Sub(amount) + if newGDebt.IsNegative() { + return sdk.ErrInternal("cannot reduce global debt by amount specified") + } + k.SetGlobalDebt(ctx, newGDebt) + return nil +} + +func (k Keeper) GetStableDenom() string { + return "usdx" +} +func (k Keeper) GetGovDenom() string { + return "kava" +} + +// ---------- Store Wrappers ---------- + +func (k Keeper) getCDPKeyPrefix(collateralDenom string) []byte { + return bytes.Join( + [][]byte{ + []byte("cdp"), + []byte(collateralDenom), + }, + nil, // no separator + ) +} +func (k Keeper) getCDPKey(owner sdk.AccAddress, collateralDenom string) []byte { + return bytes.Join( + [][]byte{ + k.getCDPKeyPrefix(collateralDenom), + []byte(owner.String()), + }, + nil, // no separator + ) +} +func (k Keeper) GetCDP(ctx sdk.Context, owner sdk.AccAddress, collateralDenom string) (types.CDP, bool) { + // get store + store := ctx.KVStore(k.key) + // get CDP + bz := store.Get(k.getCDPKey(owner, collateralDenom)) + // unmarshal + if bz == nil { + return types.CDP{}, false + } + var cdp types.CDP + k.cdc.MustUnmarshalBinaryLengthPrefixed(bz, &cdp) + return cdp, true +} +func (k Keeper) SetCDP(ctx sdk.Context, cdp types.CDP) { + // get store + store := ctx.KVStore(k.key) + // marshal and set + bz := k.cdc.MustMarshalBinaryLengthPrefixed(cdp) + store.Set(k.getCDPKey(cdp.Owner, cdp.CollateralDenom), bz) +} +func (k Keeper) deleteCDP(ctx sdk.Context, cdp types.CDP) { // TODO should this id the cdp by passing in owner,collateralDenom pair? + // get store + store := ctx.KVStore(k.key) + // delete key + store.Delete(k.getCDPKey(cdp.Owner, cdp.CollateralDenom)) +} + +// GetCDPs returns all CDPs, optionally filtered by collateral type and liquidation price. +// `price` filters for CDPs that will be below the liquidation ratio when the collateral is at that specified price. +func (k Keeper) GetCDPs(ctx sdk.Context, collateralDenom string, price sdk.Dec) (types.CDPs, sdk.Error) { + // Validate inputs + params := k.GetParams(ctx) + if len(collateralDenom) != 0 && !params.IsCollateralPresent(collateralDenom) { + return nil, sdk.ErrInternal("collateral denom not authorized") + } + if len(collateralDenom) == 0 && !(price.IsNil() || price.IsNegative()) { + return nil, sdk.ErrInternal("cannot specify price without collateral denom") + } + + // Get an iterator over CDPs + store := ctx.KVStore(k.key) + iter := sdk.KVStorePrefixIterator(store, k.getCDPKeyPrefix(collateralDenom)) // could be all CDPs is collateralDenom is "" + + // Decode CDPs into slice + var cdps types.CDPs + for ; iter.Valid(); iter.Next() { + var cdp types.CDP + k.cdc.MustUnmarshalBinaryLengthPrefixed(iter.Value(), &cdp) + cdps = append(cdps, cdp) + } + + // Sort by collateral ratio (collateral/debt) + sort.Sort(types.ByCollateralRatio(cdps)) // TODO this doesn't make much sense across different collateral types + + // Filter for CDPs that would be under-collateralized at the specified price + // If price is nil or -ve, skip the filtering as it would return all CDPs anyway + if !price.IsNil() && !price.IsNegative() { + var filteredCDPs types.CDPs + for _, cdp := range cdps { + if cdp.IsUnderCollateralized(price, params.GetCollateralParams(collateralDenom).LiquidationRatio) { + filteredCDPs = append(filteredCDPs, cdp) + } else { + break // break early because list is sorted + } + } + cdps = filteredCDPs + } + + return cdps, nil +} + +var globalDebtKey = []byte("globalDebt") + +func (k Keeper) GetGlobalDebt(ctx sdk.Context) sdk.Int { + // get store + store := ctx.KVStore(k.key) + // get bytes + bz := store.Get(globalDebtKey) + // unmarshal + if bz == nil { + panic("global debt not found") + } + var globalDebt sdk.Int + k.cdc.MustUnmarshalBinaryLengthPrefixed(bz, &globalDebt) + return globalDebt +} +func (k Keeper) SetGlobalDebt(ctx sdk.Context, globalDebt sdk.Int) { + // get store + store := ctx.KVStore(k.key) + // marshal and set + bz := k.cdc.MustMarshalBinaryLengthPrefixed(globalDebt) + store.Set(globalDebtKey, bz) +} + +func (k Keeper) getCollateralStateKey(collateralDenom string) []byte { + return []byte(collateralDenom) +} +func (k Keeper) GetCollateralState(ctx sdk.Context, collateralDenom string) (types.CollateralState, bool) { + // get store + store := ctx.KVStore(k.key) + // get bytes + bz := store.Get(k.getCollateralStateKey(collateralDenom)) + // unmarshal + if bz == nil { + return types.CollateralState{}, false + } + var collateralState types.CollateralState + k.cdc.MustUnmarshalBinaryLengthPrefixed(bz, &collateralState) + return collateralState, true +} +func (k Keeper) setCollateralState(ctx sdk.Context, collateralstate types.CollateralState) { + // get store + store := ctx.KVStore(k.key) + // marshal and set + bz := k.cdc.MustMarshalBinaryLengthPrefixed(collateralstate) + store.Set(k.getCollateralStateKey(collateralstate.Denom), bz) +} + +// ---------- Weird Bank Stuff ---------- +// This only exists because module accounts aren't really a thing yet. +// Also because we need module accounts that allow for burning/minting. + +// These functions make the CDP module act as a bank keeper, ie it fulfills the bank.Keeper interface. +// It intercepts calls to send coins to/from the liquidator module account, otherwise passing the calls onto the normal bank keeper. + +// Not sure if module accounts are good, but they make the auction module more general: +// - startAuction would just "mints" coins, relying on calling function to decrement them somewhere +// - closeAuction would have to call something specific for the receiver module to accept coins (like liquidationKeeper.AddStableCoins) + +// The auction and liquidator modules can probably just use SendCoins to keep things safe (instead of AddCoins and SubtractCoins). +// So they should define their own interfaces which this module should fulfill, rather than this fulfilling the entire bank.Keeper interface. + +// bank.Keeper interfaces: +// type SendKeeper interface { +// type ViewKeeper interface { +// GetCoins(ctx sdk.Context, addr sdk.AccAddress) sdk.Coins +// HasCoins(ctx sdk.Context, addr sdk.AccAddress, amt sdk.Coins) bool +// Codespace() sdk.CodespaceType +// } +// SendCoins(ctx sdk.Context, fromAddr sdk.AccAddress, toAddr sdk.AccAddress, amt sdk.Coins) (sdk.Tags, sdk.Error) +// GetSendEnabled(ctx sdk.Context) bool +// SetSendEnabled(ctx sdk.Context, enabled bool) +// } +// type Keeper interface { +// SendKeeper +// SetCoins(ctx sdk.Context, addr sdk.AccAddress, amt sdk.Coins) sdk.Error +// SubtractCoins(ctx sdk.Context, addr sdk.AccAddress, amt sdk.Coins) (sdk.Coins, sdk.Tags, sdk.Error) +// AddCoins(ctx sdk.Context, addr sdk.AccAddress, amt sdk.Coins) (sdk.Coins, sdk.Tags, sdk.Error) +// InputOutputCoins(ctx sdk.Context, inputs []Input, outputs []Output) (sdk.Tags, sdk.Error) +// DelegateCoins(ctx sdk.Context, addr sdk.AccAddress, amt sdk.Coins) (sdk.Tags, sdk.Error) +// UndelegateCoins(ctx sdk.Context, addr sdk.AccAddress, amt sdk.Coins) (sdk.Tags, sdk.Error) + +var LiquidatorAccountAddress = sdk.AccAddress([]byte("whatever")) +var liquidatorAccountKey = []byte("liquidatorAccount") + +func (k Keeper) GetLiquidatorAccountAddress() sdk.AccAddress { + return LiquidatorAccountAddress +} + +type LiquidatorModuleAccount struct { + Coins sdk.Coins // keeps track of seized collateral, surplus usdx, and mints/burns gov coins +} + +func (k Keeper) AddCoins(ctx sdk.Context, address sdk.AccAddress, amount sdk.Coins) (sdk.Coins, sdk.Error) { + // intercept module account + if address.Equals(LiquidatorAccountAddress) { + if !amount.IsValid() { + return nil, sdk.ErrInvalidCoins(amount.String()) + } + // remove gov token from list + filteredCoins := stripGovCoin(amount) + // add coins to module account + lma := k.getLiquidatorModuleAccount(ctx) + updatedCoins := lma.Coins.Add(filteredCoins) + if updatedCoins.IsAnyNegative() { + return amount, sdk.ErrInsufficientCoins(fmt.Sprintf("insufficient account funds; %s < %s", lma.Coins, amount)) + } + lma.Coins = updatedCoins + k.setLiquidatorModuleAccount(ctx, lma) + return updatedCoins, nil + } else { + return k.bankKeeper.AddCoins(ctx, address, amount) + } +} + +// TODO abstract stuff better +func (k Keeper) SubtractCoins(ctx sdk.Context, address sdk.AccAddress, amount sdk.Coins) (sdk.Coins, sdk.Error) { + // intercept module account + if address.Equals(LiquidatorAccountAddress) { + if !amount.IsValid() { + return nil, sdk.ErrInvalidCoins(amount.String()) + } + // remove gov token from list + filteredCoins := stripGovCoin(amount) + // subtract coins from module account + lma := k.getLiquidatorModuleAccount(ctx) + updatedCoins, isNegative := lma.Coins.SafeSub(filteredCoins) + if isNegative { + return amount, sdk.ErrInsufficientCoins(fmt.Sprintf("insufficient account funds; %s < %s", lma.Coins, amount)) + } + lma.Coins = updatedCoins + k.setLiquidatorModuleAccount(ctx, lma) + return updatedCoins, nil + } else { + return k.bankKeeper.SubtractCoins(ctx, address, amount) + } +} + +// TODO Should this return anything for the gov coin balance? Currently returns nothing. +func (k Keeper) GetCoins(ctx sdk.Context, address sdk.AccAddress) sdk.Coins { + if address.Equals(LiquidatorAccountAddress) { + return k.getLiquidatorModuleAccount(ctx).Coins + } else { + return k.bankKeeper.GetCoins(ctx, address) + } +} + +// TODO test this with unsorted coins +func (k Keeper) HasCoins(ctx sdk.Context, address sdk.AccAddress, amount sdk.Coins) bool { + if address.Equals(LiquidatorAccountAddress) { + return true + } else { + return k.getLiquidatorModuleAccount(ctx).Coins.IsAllGTE(stripGovCoin(amount)) + } +} + +func (k Keeper) getLiquidatorModuleAccount(ctx sdk.Context) LiquidatorModuleAccount { + // get store + store := ctx.KVStore(k.key) + // get bytes + bz := store.Get(liquidatorAccountKey) + if bz == nil { + return LiquidatorModuleAccount{} // TODO is it safe to do this, or better to initialize the account explicitly + } + // unmarshal + var lma LiquidatorModuleAccount + k.cdc.MustUnmarshalBinaryLengthPrefixed(bz, &lma) + return lma +} +func (k Keeper) setLiquidatorModuleAccount(ctx sdk.Context, lma LiquidatorModuleAccount) { + store := ctx.KVStore(k.key) + bz := k.cdc.MustMarshalBinaryLengthPrefixed(lma) + store.Set(liquidatorAccountKey, bz) +} +func stripGovCoin(coins sdk.Coins) sdk.Coins { + filteredCoins := sdk.NewCoins() + for _, c := range coins { + if c.Denom != "kava" { + filteredCoins = append(filteredCoins, c) + } + } + return filteredCoins +} diff --git a/x/cdp/keeper/keeper_test.go b/x/cdp/keeper/keeper_test.go new file mode 100644 index 00000000..17f03000 --- /dev/null +++ b/x/cdp/keeper/keeper_test.go @@ -0,0 +1,356 @@ +package keeper + +import ( + "fmt" + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/auth" + authexported "github.com/cosmos/cosmos-sdk/x/auth/exported" + "github.com/cosmos/cosmos-sdk/x/mock" + "github.com/kava-labs/kava/x/cdp/types" + "github.com/kava-labs/kava/x/pricefeed" + "github.com/stretchr/testify/require" + abci "github.com/tendermint/tendermint/abci/types" +) + +// How could one reduce the number of params in the test cases. Create a table driven test for each of the 4 add/withdraw collateral/debt? + +// These are more like app level tests - I think this is a symptom of having 'ModifyCDP' do a lot. Could be easier for testing purposes to break it down. +func TestKeeper_ModifyCDP(t *testing.T) { + _, addrs := mock.GeneratePrivKeyAddressPairs(1) + ownerAddr := addrs[0] + + type state struct { // TODO this allows invalid state to be set up, should it? + CDP types.CDP + OwnerCoins sdk.Coins + GlobalDebt sdk.Int + CollateralState types.CollateralState + } + type args struct { + owner sdk.AccAddress + collateralDenom string + changeInCollateral sdk.Int + changeInDebt sdk.Int + } + + tests := []struct { + name string + priorState state + price string + // also missing CDPModuleParams + args args + expectPass bool + expectedState state + }{ + { + "addCollateralAndDecreaseDebt", + state{types.CDP{ownerAddr, "xrp", i(100), i(2)}, cs(c("xrp", 10), c("usdx", 2)), i(2), types.CollateralState{"xrp", i(2)}}, + "10.345", + args{ownerAddr, "xrp", i(10), i(-1)}, + true, + state{types.CDP{ownerAddr, "xrp", i(110), i(1)}, cs( /* 0xrp */ c("usdx", 1)), i(1), types.CollateralState{"xrp", i(1)}}, + }, + { + "removeTooMuchCollateral", + state{types.CDP{ownerAddr, "xrp", i(1000), i(200)}, cs(c("xrp", 10), c("usdx", 10)), i(200), types.CollateralState{"xrp", i(200)}}, + "1.00", + args{ownerAddr, "xrp", i(-601), i(0)}, + false, + state{types.CDP{ownerAddr, "xrp", i(1000), i(200)}, cs(c("xrp", 10), c("usdx", 10)), i(200), types.CollateralState{"xrp", i(200)}}, + }, + { + "withdrawTooMuchStableCoin", + state{types.CDP{ownerAddr, "xrp", i(1000), i(200)}, cs(c("xrp", 10), c("usdx", 10)), i(200), types.CollateralState{"xrp", i(200)}}, + "1.00", + args{ownerAddr, "xrp", i(0), i(301)}, + false, + state{types.CDP{ownerAddr, "xrp", i(1000), i(200)}, cs(c("xrp", 10), c("usdx", 10)), i(200), types.CollateralState{"xrp", i(200)}}, + }, + { + "createCDPAndWithdrawStable", + state{types.CDP{}, cs(c("xrp", 10), c("usdx", 10)), i(0), types.CollateralState{"xrp", i(0)}}, + "1.00", + args{ownerAddr, "xrp", i(5), i(2)}, + true, + state{types.CDP{ownerAddr, "xrp", i(5), i(2)}, cs(c("xrp", 5), c("usdx", 12)), i(2), types.CollateralState{"xrp", i(2)}}, + }, + { + "emptyCDP", + state{types.CDP{ownerAddr, "xrp", i(1000), i(200)}, cs(c("xrp", 10), c("usdx", 201)), i(200), types.CollateralState{"xrp", i(200)}}, + "1.00", + args{ownerAddr, "xrp", i(-1000), i(-200)}, + true, + state{types.CDP{}, cs(c("xrp", 1010), c("usdx", 1)), i(0), types.CollateralState{"xrp", i(0)}}, + }, + { + "invalidCollateralType", + state{types.CDP{}, cs(c("shitcoin", 5000000)), i(0), types.CollateralState{}}, + "0.000001", + args{ownerAddr, "shitcoin", i(5000000), i(1)}, // ratio of 5:1 + false, + state{types.CDP{}, cs(c("shitcoin", 5000000)), i(0), types.CollateralState{}}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // setup keeper + mapp, keeper, _, _ := setUpMockAppWithoutGenesis() + // initialize cdp owner account with coins + genAcc := auth.BaseAccount{ + Address: ownerAddr, + Coins: tc.priorState.OwnerCoins, + } + mock.SetGenesis(mapp, []authexported.Account{&genAcc}) + // create a new context + header := abci.Header{Height: mapp.LastBlockHeight() + 1} + mapp.BeginBlock(abci.RequestBeginBlock{Header: header}) + ctx := mapp.BaseApp.NewContext(false, header) + keeper.SetParams(ctx, defaultParamsSingle()) + // setup store state + ap := pricefeed.AssetParams{ + Assets: []pricefeed.Asset{ + pricefeed.Asset{AssetCode: "xrp", Description: ""}, + }, + } + keeper.pricefeedKeeper.SetAssetParams(ctx, ap) + _, err := keeper.pricefeedKeeper.SetPrice( + ctx, ownerAddr, "xrp", + sdk.MustNewDecFromStr(tc.price), + sdk.NewInt(ctx.BlockHeight()+10)) + if err != nil { + t.Log("test context height", ctx.BlockHeight()) + t.Log(err) + t.Log(tc.name) + } + err = keeper.pricefeedKeeper.SetCurrentPrices(ctx) + if err != nil { + t.Log("test context height", ctx.BlockHeight()) + t.Log(err) + t.Log(tc.name) + } + if tc.priorState.CDP.CollateralDenom != "" { // check if the prior CDP should be created or not (see if an empty one was specified) + keeper.SetCDP(ctx, tc.priorState.CDP) + } + keeper.SetGlobalDebt(ctx, tc.priorState.GlobalDebt) + if tc.priorState.CollateralState.Denom != "" { + keeper.setCollateralState(ctx, tc.priorState.CollateralState) + } + + // call func under test + err = keeper.ModifyCDP(ctx, tc.args.owner, tc.args.collateralDenom, tc.args.changeInCollateral, tc.args.changeInDebt) + mapp.EndBlock(abci.RequestEndBlock{}) + mapp.Commit() + + // get new state for verification + actualCDP, found := keeper.GetCDP(ctx, tc.args.owner, tc.args.collateralDenom) + if tc.name == "removeTooMuchCollateral" { + t.Log(actualCDP.String()) + } + + // check for err + if tc.expectPass { + require.NoError(t, err, fmt.Sprint(err)) + } else { + require.Error(t, err) + } + actualGDebt := keeper.GetGlobalDebt(ctx) + actualCstate, _ := keeper.GetCollateralState(ctx, tc.args.collateralDenom) + // check state + require.Equal(t, tc.expectedState.CDP, actualCDP) + if tc.expectedState.CDP.CollateralDenom == "" { // if the expected CDP is blank, then expect the CDP to have been deleted (hence not found) + require.False(t, found) + } else { + require.True(t, found) + } + require.Equal(t, tc.expectedState.GlobalDebt, actualGDebt) + require.Equal(t, tc.expectedState.CollateralState, actualCstate) + // check owner balance + mock.CheckBalance(t, mapp, ownerAddr, tc.expectedState.OwnerCoins) + }) + } +} + +// TODO change to table driven test to test more test cases +func TestKeeper_PartialSeizeCDP(t *testing.T) { + // Setup + const collateral = "xrp" + mapp, keeper, _, _ := setUpMockAppWithoutGenesis() + genAccs, addrs, _, _ := mock.CreateGenAccounts(1, cs(c(collateral, 100))) + testAddr := addrs[0] + mock.SetGenesis(mapp, genAccs) + // setup pricefeed + header := abci.Header{Height: mapp.LastBlockHeight() + 1} + mapp.BeginBlock(abci.RequestBeginBlock{Header: header}) + ctx := mapp.BaseApp.NewContext(false, header) + keeper.SetParams(ctx, defaultParamsSingle()) + ap := pricefeed.AssetParams{ + Assets: []pricefeed.Asset{ + pricefeed.Asset{AssetCode: "xrp", Description: ""}, + }, + } + keeper.pricefeedKeeper.SetAssetParams(ctx, ap) + keeper.pricefeedKeeper.SetPrice( + ctx, sdk.AccAddress{}, collateral, + sdk.MustNewDecFromStr("1.00"), + i(10)) + keeper.pricefeedKeeper.SetCurrentPrices(ctx) + // Create CDP + keeper.SetGlobalDebt(ctx, i(0)) + err := keeper.ModifyCDP(ctx, testAddr, collateral, i(10), i(5)) + require.NoError(t, err) + // Reduce price + keeper.pricefeedKeeper.SetPrice( + ctx, sdk.AccAddress{}, collateral, + sdk.MustNewDecFromStr("0.90"), + i(10)) + keeper.pricefeedKeeper.SetCurrentPrices(ctx) + + // Seize entire CDP + err = keeper.PartialSeizeCDP(ctx, testAddr, collateral, i(10), i(5)) + + // Check + require.NoError(t, err) + _, found := keeper.GetCDP(ctx, testAddr, collateral) + require.False(t, found) + collateralState, found := keeper.GetCollateralState(ctx, collateral) + require.True(t, found) + require.Equal(t, sdk.ZeroInt(), collateralState.TotalDebt) +} + +func TestKeeper_GetCDPs(t *testing.T) { + // setup keeper + mapp, keeper, _, _ := setUpMockAppWithoutGenesis() + mock.SetGenesis(mapp, []authexported.Account(nil)) + header := abci.Header{Height: mapp.LastBlockHeight() + 1} + mapp.BeginBlock(abci.RequestBeginBlock{Header: header}) + ctx := mapp.BaseApp.NewContext(false, header) + keeper.SetParams(ctx, defaultParamsMulti()) + // setup CDPs + _, addrs := mock.GeneratePrivKeyAddressPairs(2) + cdps := types.CDPs{ + {addrs[0], "xrp", i(4000), i(5)}, + {addrs[1], "xrp", i(4000), i(2000)}, + {addrs[0], "btc", i(10), i(20)}, + } + for _, cdp := range cdps { + keeper.SetCDP(ctx, cdp) + } + + // Check nil params returns all CDPs + returnedCdps, err := keeper.GetCDPs(ctx, "", sdk.Dec{}) + require.NoError(t, err) + require.Equal(t, + types.CDPs{ + {addrs[0], "btc", i(10), i(20)}, + {addrs[1], "xrp", i(4000), i(2000)}, + {addrs[0], "xrp", i(4000), i(5)}}, + returnedCdps, + ) + // Check correct CDPs filtered by collateral and sorted + returnedCdps, err = keeper.GetCDPs(ctx, "xrp", d("0.00000001")) + require.NoError(t, err) + require.Equal(t, + types.CDPs{ + {addrs[1], "xrp", i(4000), i(2000)}, + {addrs[0], "xrp", i(4000), i(5)}}, + returnedCdps, + ) + returnedCdps, err = keeper.GetCDPs(ctx, "xrp", sdk.Dec{}) + require.NoError(t, err) + require.Equal(t, + types.CDPs{ + {addrs[1], "xrp", i(4000), i(2000)}, + {addrs[0], "xrp", i(4000), i(5)}}, + returnedCdps, + ) + returnedCdps, err = keeper.GetCDPs(ctx, "xrp", d("0.9")) + require.NoError(t, err) + require.Equal(t, + types.CDPs{ + {addrs[1], "xrp", i(4000), i(2000)}}, + returnedCdps, + ) + // Check high price returns no CDPs + returnedCdps, err = keeper.GetCDPs(ctx, "xrp", d("999999999.99")) + require.NoError(t, err) + require.Equal(t, + types.CDPs(nil), + returnedCdps, + ) + // Check unauthorized collateral denom returns error + _, err = keeper.GetCDPs(ctx, "a non existent coin", d("0.34023")) + require.Error(t, err) + // Check price without collateral returns error + _, err = keeper.GetCDPs(ctx, "", d("0.34023")) + require.Error(t, err) + // Check deleting a CDP removes it + keeper.deleteCDP(ctx, cdps[0]) + returnedCdps, err = keeper.GetCDPs(ctx, "", sdk.Dec{}) + require.NoError(t, err) + require.Equal(t, + types.CDPs{ + {addrs[0], "btc", i(10), i(20)}, + {addrs[1], "xrp", i(4000), i(2000)}}, + returnedCdps, + ) +} +func TestKeeper_GetSetDeleteCDP(t *testing.T) { + // setup keeper, create CDP + mapp, keeper, _, _ := setUpMockAppWithoutGenesis() + header := abci.Header{Height: mapp.LastBlockHeight() + 1} + mapp.BeginBlock(abci.RequestBeginBlock{Header: header}) + ctx := mapp.BaseApp.NewContext(false, header) + keeper.SetParams(ctx, defaultParamsSingle()) + _, addrs := mock.GeneratePrivKeyAddressPairs(1) + cdp := types.CDP{addrs[0], "xrp", i(412), i(56)} + + // write and read from store + keeper.SetCDP(ctx, cdp) + readCDP, found := keeper.GetCDP(ctx, cdp.Owner, cdp.CollateralDenom) + + // check before and after match + require.True(t, found) + require.Equal(t, cdp, readCDP) + + // delete auction + keeper.deleteCDP(ctx, cdp) + + // check auction does not exist + _, found = keeper.GetCDP(ctx, cdp.Owner, cdp.CollateralDenom) + require.False(t, found) +} +func TestKeeper_GetSetGDebt(t *testing.T) { + // setup keeper, create GDebt + mapp, keeper, _, _ := setUpMockAppWithoutGenesis() + header := abci.Header{Height: mapp.LastBlockHeight() + 1} + mapp.BeginBlock(abci.RequestBeginBlock{Header: header}) + ctx := mapp.BaseApp.NewContext(false, header) + keeper.SetParams(ctx, defaultParamsSingle()) + gDebt := i(4120000) + + // write and read from store + keeper.SetGlobalDebt(ctx, gDebt) + readGDebt := keeper.GetGlobalDebt(ctx) + + // check before and after match + require.Equal(t, gDebt, readGDebt) +} + +func TestKeeper_GetSetCollateralState(t *testing.T) { + // setup keeper, create CState + mapp, keeper, _, _ := setUpMockAppWithoutGenesis() + header := abci.Header{Height: mapp.LastBlockHeight() + 1} + mapp.BeginBlock(abci.RequestBeginBlock{Header: header}) + ctx := mapp.BaseApp.NewContext(false, header) + keeper.SetParams(ctx, defaultParamsSingle()) + collateralState := types.CollateralState{"xrp", i(15400)} + + // write and read from store + keeper.setCollateralState(ctx, collateralState) + readCState, found := keeper.GetCollateralState(ctx, collateralState.Denom) + + // check before and after match + require.Equal(t, collateralState, readCState) + require.True(t, found) +} diff --git a/x/cdp/keeper/params.go b/x/cdp/keeper/params.go new file mode 100644 index 00000000..4b12e9ae --- /dev/null +++ b/x/cdp/keeper/params.go @@ -0,0 +1,19 @@ +package keeper + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/kava-labs/kava/x/cdp/types" +) + +// ---------- Module Parameters ---------- +// GetParams returns the params from the store +func (k Keeper) GetParams(ctx sdk.Context) types.CdpParams { + var p types.CdpParams + k.paramSubspace.GetParamSet(ctx, &p) + return p +} + +// SetParams sets params on the store +func (k Keeper) SetParams(ctx sdk.Context, cdpParams types.CdpParams) { + k.paramSubspace.SetParamSet(ctx, &cdpParams) +} \ No newline at end of file diff --git a/x/cdp/keeper/querier.go b/x/cdp/keeper/querier.go new file mode 100644 index 00000000..c8d3f04f --- /dev/null +++ b/x/cdp/keeper/querier.go @@ -0,0 +1,83 @@ +package keeper + +import ( + "fmt" + + abci "github.com/tendermint/tendermint/abci/types" + + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/kava-labs/kava/x/cdp/types" +) + + + +func NewQuerier(keeper Keeper) sdk.Querier { + return func(ctx sdk.Context, path []string, req abci.RequestQuery) (res []byte, err sdk.Error) { + switch path[0] { + case types.QueryGetCdps: + return queryGetCdps(ctx, req, keeper) + case types.QueryGetParams: + return queryGetParams(ctx, req, keeper) + default: + return nil, sdk.ErrUnknownRequest("unknown cdp query endpoint") + } + } +} + + +// queryGetCdps fetches CDPs, optionally filtering by any of the query params (in QueryCdpsParams). +// While CDPs do not have an ID, this method can be used to get one CDP by specifying the collateral and owner. +func queryGetCdps(ctx sdk.Context, req abci.RequestQuery, keeper Keeper) ([]byte, sdk.Error) { + // Decode request + var requestParams types.QueryCdpsParams + err := keeper.cdc.UnmarshalJSON(req.Data, &requestParams) + if err != nil { + return nil, sdk.ErrInternal(fmt.Sprintf("failed to parse params: %s", err)) + } + + // Get CDPs + var cdps types.CDPs + if len(requestParams.Owner) != 0 { + if len(requestParams.CollateralDenom) != 0 { + // owner and collateral specified - get a single CDP + cdp, found := keeper.GetCDP(ctx, requestParams.Owner, requestParams.CollateralDenom) + if !found { + cdp = types.CDP{Owner: requestParams.Owner, CollateralDenom: requestParams.CollateralDenom, CollateralAmount: sdk.ZeroInt(), Debt: sdk.ZeroInt()} + } + cdps = types.CDPs{cdp} + } else { + // owner, but no collateral specified - get all CDPs for one address + return nil, sdk.ErrInternal("getting all CDPs belonging to one owner not implemented") + } + } else { + // owner not specified -- get all CDPs or all CDPs of one collateral type, optionally filtered by price + var errSdk sdk.Error // := doesn't work here + cdps, errSdk = keeper.GetCDPs(ctx, requestParams.CollateralDenom, requestParams.UnderCollateralizedAt) + if errSdk != nil { + return nil, errSdk + } + + } + + // Encode results + bz, err := codec.MarshalJSONIndent(keeper.cdc, cdps) + if err != nil { + return nil, sdk.ErrInternal(sdk.AppendMsgToErr("could not marshal result to JSON", err.Error())) + } + return bz, nil +} + +// queryGetParams fetches the cdp module parameters +// TODO does this need to exist? Can you use cliCtx.QueryStore instead? +func queryGetParams(ctx sdk.Context, req abci.RequestQuery, keeper Keeper) ([]byte, sdk.Error) { + // Get params + params := keeper.GetParams(ctx) + + // Encode results + bz, err := codec.MarshalJSONIndent(keeper.cdc, params) + if err != nil { + return nil, sdk.ErrInternal(sdk.AppendMsgToErr("could not marshal result to JSON", err.Error())) + } + return bz, nil +} diff --git a/x/cdp/keeper/test_common.go b/x/cdp/keeper/test_common.go new file mode 100644 index 00000000..474b069e --- /dev/null +++ b/x/cdp/keeper/test_common.go @@ -0,0 +1,88 @@ +package keeper + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/bank" + "github.com/cosmos/cosmos-sdk/x/mock" + + "github.com/kava-labs/kava/x/cdp/types" + "github.com/kava-labs/kava/x/pricefeed" + "github.com/tendermint/tendermint/crypto" +) + +// Mock app is an ABCI app with an in memory database. +// This function creates an app, setting up the keepers, routes, begin and end blockers. +// But leaves it to the tests to call InitChain (done by calling mock.SetGenesis) +// The app works by submitting ABCI messages. +// - InitChain sets up the app db from genesis. +// - BeginBlock starts the delivery of a new block +// - DeliverTx delivers a tx +// - EndBlock signals the end of a block +// - Commit ? +func setUpMockAppWithoutGenesis() (*mock.App, Keeper, []sdk.AccAddress, []crypto.PrivKey) { + // Create uninitialized mock app + mapp := mock.NewApp() + + // Register codecs + types.RegisterCodec(mapp.Cdc) + + // Create keepers + keyCDP := sdk.NewKVStoreKey("cdp") + keyPriceFeed := sdk.NewKVStoreKey(pricefeed.StoreKey) + pk := mapp.ParamsKeeper + priceFeedKeeper := pricefeed.NewKeeper(keyPriceFeed, mapp.Cdc, pk.Subspace(pricefeed.DefaultParamspace).WithKeyTable(pricefeed.ParamKeyTable()), pricefeed.DefaultCodespace) + blacklistedAddrs := make(map[string]bool) + bankKeeper := bank.NewBaseKeeper(mapp.AccountKeeper, pk.Subspace(bank.DefaultParamspace), bank.DefaultCodespace, blacklistedAddrs) + cdpKeeper := NewKeeper(mapp.Cdc, keyCDP, pk.Subspace(types.DefaultParamspace), priceFeedKeeper, bankKeeper) + + // Mount and load the stores + err := mapp.CompleteSetup(keyPriceFeed, keyCDP) + if err != nil { + panic("mock app setup failed") + } + + // Create a bunch (ie 10) of pre-funded accounts to use for tests + genAccs, addrs, _, privKeys := mock.CreateGenAccounts(10, sdk.NewCoins(sdk.NewInt64Coin("token1", 100), sdk.NewInt64Coin("token2", 100))) + mock.SetGenesis(mapp, genAccs) + + return mapp, cdpKeeper, addrs, privKeys +} + +// Avoid cluttering test cases with long function name +func i(in int64) sdk.Int { return sdk.NewInt(in) } +func d(str string) sdk.Dec { return sdk.MustNewDecFromStr(str) } +func c(denom string, amount int64) sdk.Coin { return sdk.NewInt64Coin(denom, amount) } +func cs(coins ...sdk.Coin) sdk.Coins { return sdk.NewCoins(coins...) } + +func defaultParamsMulti() types.CdpParams { + return types.CdpParams{ + GlobalDebtLimit: sdk.NewInt(1000000), + CollateralParams: []types.CollateralParams{ + { + Denom: "btc", + LiquidationRatio: sdk.MustNewDecFromStr("1.5"), + DebtLimit: sdk.NewInt(500000), + }, + { + Denom: "xrp", + LiquidationRatio: sdk.MustNewDecFromStr("2.0"), + DebtLimit: sdk.NewInt(500000), + }, + }, + StableDenoms: []string{"usdx"}, + } +} + +func defaultParamsSingle() types.CdpParams { + return types.CdpParams{ + GlobalDebtLimit: sdk.NewInt(1000000), + CollateralParams: []types.CollateralParams{ + { + Denom: "xrp", + LiquidationRatio: sdk.MustNewDecFromStr("2.0"), + DebtLimit: sdk.NewInt(500000), + }, + }, + StableDenoms: []string{"usdx"}, + } +} diff --git a/x/cdp/module.go b/x/cdp/module.go new file mode 100644 index 00000000..792b2cbe --- /dev/null +++ b/x/cdp/module.go @@ -0,0 +1,132 @@ +package cdp + +import ( + "encoding/json" + + "github.com/gorilla/mux" + "github.com/spf13/cobra" + + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/module" + abci "github.com/tendermint/tendermint/abci/types" + + "github.com/kava-labs/kava/x/cdp/client/cli" + "github.com/kava-labs/kava/x/cdp/client/rest" +) + +var ( + _ module.AppModule = AppModule{} + _ module.AppModuleBasic = AppModuleBasic{} +) + +// AppModuleBasic app module basics object +type AppModuleBasic struct{} + +// Name get module name +func (AppModuleBasic) Name() string { + return ModuleName +} + +// RegisterCodec register module codec +func (AppModuleBasic) RegisterCodec(cdc *codec.Codec) { + RegisterCodec(cdc) +} + +// DefaultGenesis default genesis state +func (AppModuleBasic) DefaultGenesis() json.RawMessage { + return ModuleCdc.MustMarshalJSON(DefaultGenesisState()) +} + +// ValidateGenesis module validate genesis +func (AppModuleBasic) ValidateGenesis(bz json.RawMessage) error { + var data GenesisState + err := ModuleCdc.UnmarshalJSON(bz, &data) + if err != nil { + return err + } + return ValidateGenesis(data) +} + +// RegisterRESTRoutes registers the REST routes for the cdp module. +func (AppModuleBasic) RegisterRESTRoutes(ctx context.CLIContext, rtr *mux.Router) { + rest.RegisterRoutes(ctx, rtr) +} + +// GetTxCmd returns the root tx command for the cdp module. +func (AppModuleBasic) GetTxCmd(cdc *codec.Codec) *cobra.Command { + return cli.GetTxCmd(cdc) +} + +// GetQueryCmd returns the root query command for the auction module. +func (AppModuleBasic) GetQueryCmd(cdc *codec.Codec) *cobra.Command { + return cli.GetQueryCmd(StoreKey, cdc) +} + +// AppModule app module type +type AppModule struct { + AppModuleBasic + keeper Keeper + pricefeedKeeper PricefeedKeeper +} + +// NewAppModule creates a new AppModule object +func NewAppModule(keeper Keeper, pricefeedKeeper PricefeedKeeper) AppModule { + return AppModule{ + AppModuleBasic: AppModuleBasic{}, + keeper: keeper, + pricefeedKeeper: pricefeedKeeper, + } +} + +// Name module name +func (AppModule) Name() string { + return ModuleName +} + +// RegisterInvariants register module invariants +func (AppModule) RegisterInvariants(_ sdk.InvariantRegistry) {} + +// Route module message route name +func (AppModule) Route() string { + return ModuleName +} + +// NewHandler module handler +func (am AppModule) NewHandler() sdk.Handler { + return NewHandler(am.keeper) +} + +// QuerierRoute module querier route name +func (AppModule) QuerierRoute() string { + return ModuleName +} + +// NewQuerierHandler module querier +func (am AppModule) NewQuerierHandler() sdk.Querier { + return NewQuerier(am.keeper) +} + +// InitGenesis module init-genesis +func (am AppModule) InitGenesis(ctx sdk.Context, data json.RawMessage) []abci.ValidatorUpdate { + var genesisState GenesisState + ModuleCdc.MustUnmarshalJSON(data, &genesisState) + InitGenesis(ctx, am.keeper, am.pricefeedKeeper, genesisState) + + return []abci.ValidatorUpdate{} +} + +// ExportGenesis module export genesis +func (am AppModule) ExportGenesis(ctx sdk.Context) json.RawMessage { + gs := ExportGenesis(ctx, am.keeper) + return ModuleCdc.MustMarshalJSON(gs) +} + +// BeginBlock module begin-block +func (AppModule) BeginBlock(_ sdk.Context, _ abci.RequestBeginBlock) {} + +// EndBlock module end-block +func (am AppModule) EndBlock(ctx sdk.Context, _ abci.RequestEndBlock) []abci.ValidatorUpdate { + return []abci.ValidatorUpdate{} +} diff --git a/x/cdp/test_common.go b/x/cdp/test_common.go new file mode 100644 index 00000000..54066f56 --- /dev/null +++ b/x/cdp/test_common.go @@ -0,0 +1,52 @@ +package cdp + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/bank" + "github.com/cosmos/cosmos-sdk/x/mock" + + "github.com/kava-labs/kava/x/pricefeed" +) + +// Mock app is an ABCI app with an in memory database. +// This function creates an app, setting up the keepers, routes, begin and end blockers. +// But leaves it to the tests to call InitChain (done by calling mock.SetGenesis) +// The app works by submitting ABCI messages. +// - InitChain sets up the app db from genesis. +// - BeginBlock starts the delivery of a new block +// - DeliverTx delivers a tx +// - EndBlock signals the end of a block +// - Commit ? +func setUpMockAppWithoutGenesis() (*mock.App, Keeper, PricefeedKeeper) { + // Create uninitialized mock app + mapp := mock.NewApp() + + // Register codecs + RegisterCodec(mapp.Cdc) + + // Create keepers + keyCDP := sdk.NewKVStoreKey("cdp") + keyPriceFeed := sdk.NewKVStoreKey(pricefeed.StoreKey) + pk := mapp.ParamsKeeper + priceFeedKeeper := pricefeed.NewKeeper(keyPriceFeed, mapp.Cdc, pk.Subspace(pricefeed.DefaultParamspace).WithKeyTable(pricefeed.ParamKeyTable()), pricefeed.DefaultCodespace) + blacklistedAddrs := make(map[string]bool) + bankKeeper := bank.NewBaseKeeper(mapp.AccountKeeper, pk.Subspace(bank.DefaultParamspace), bank.DefaultCodespace, blacklistedAddrs) + cdpKeeper := NewKeeper(mapp.Cdc, keyCDP, pk.Subspace(DefaultParamspace), priceFeedKeeper, bankKeeper) + + // Register routes + mapp.Router().AddRoute("cdp", NewHandler(cdpKeeper)) + + // Mount and load the stores + err := mapp.CompleteSetup(keyPriceFeed, keyCDP) + if err != nil { + panic("mock app setup failed") + } + + return mapp, cdpKeeper, priceFeedKeeper +} + +// Avoid cluttering test cases with long function name +func i(in int64) sdk.Int { return sdk.NewInt(in) } +func d(str string) sdk.Dec { return sdk.MustNewDecFromStr(str) } +func c(denom string, amount int64) sdk.Coin { return sdk.NewInt64Coin(denom, amount) } +func cs(coins ...sdk.Coin) sdk.Coins { return sdk.NewCoins(coins...) } diff --git a/x/cdp/types/codec.go b/x/cdp/types/codec.go new file mode 100644 index 00000000..118eb582 --- /dev/null +++ b/x/cdp/types/codec.go @@ -0,0 +1,19 @@ +package types + +import "github.com/cosmos/cosmos-sdk/codec" + +// ModuleCdc generic sealed codec to be used throughout module +var ModuleCdc *codec.Codec + +func init() { + cdc := codec.New() + RegisterCodec(cdc) + codec.RegisterCrypto(cdc) + ModuleCdc = cdc.Seal() +} + +// RegisterCodec registers concrete types on the codec. +func RegisterCodec(cdc *codec.Codec) { + cdc.RegisterConcrete(MsgCreateOrModifyCDP{}, "cdp/MsgCreateOrModifyCDP", nil) + cdc.RegisterConcrete(MsgTransferCDP{}, "cdp/MsgTransferCDP", nil) +} diff --git a/x/cdp/types/expected_keepers.go b/x/cdp/types/expected_keepers.go new file mode 100644 index 00000000..fe41bed2 --- /dev/null +++ b/x/cdp/types/expected_keepers.go @@ -0,0 +1,22 @@ +package types + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + pftypes "github.com/kava-labs/kava/x/pricefeed/types" +) + +type BankKeeper interface { + GetCoins(sdk.Context, sdk.AccAddress) sdk.Coins + HasCoins(sdk.Context, sdk.AccAddress, sdk.Coins) bool + AddCoins(sdk.Context, sdk.AccAddress, sdk.Coins) (sdk.Coins, sdk.Error) + SubtractCoins(sdk.Context, sdk.AccAddress, sdk.Coins) (sdk.Coins, sdk.Error) +} + +type PricefeedKeeper interface { + GetCurrentPrice(sdk.Context, string) pftypes.CurrentPrice + GetAssetParams(sdk.Context) pftypes.AssetParams + // These are used for testing TODO replace mockApp with keeper in tests to remove these + SetAssetParams(sdk.Context, pftypes.AssetParams) + SetPrice(sdk.Context, sdk.AccAddress, string, sdk.Dec, sdk.Int) (pftypes.PostedPrice, sdk.Error) + SetCurrentPrices(sdk.Context) sdk.Error +} diff --git a/x/cdp/types/genesis.go b/x/cdp/types/genesis.go new file mode 100644 index 00000000..96596e46 --- /dev/null +++ b/x/cdp/types/genesis.go @@ -0,0 +1,37 @@ +package types + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// GenesisState is the state that must be provided at genesis. +// TODO What is globaldebt and is is separate from the global debt limit in CdpParams + +type GenesisState struct { + Params CdpParams `json:"params" yaml:"params"` + GlobalDebt sdk.Int `json:"global_debt" yaml:"global_debt"` + CDPs CDPs `json:"cdps" yaml:"cdps"` + // don't need to setup CollateralStates as they are created as needed +} + +// DefaultGenesisState returns a default genesis state +// TODO make this empty, load test values independent +func DefaultGenesisState() GenesisState { + return GenesisState{ + Params: DefaultParams(), + GlobalDebt: sdk.ZeroInt(), + CDPs: CDPs{}, + } +} + +// ValidateGenesis performs basic validation of genesis data returning an +// error for any failed validation criteria. +func ValidateGenesis(data GenesisState) error { + + if err := data.Params.Validate(); err != nil { + return err + } + + // check global debt is zero - force the chain to always start with zero stable coin, otherwise collateralStatus's will need to be set up as well. - what? This seems indefensible. + return nil +} diff --git a/x/cdp/types/keys.go b/x/cdp/types/keys.go new file mode 100644 index 00000000..e096801d --- /dev/null +++ b/x/cdp/types/keys.go @@ -0,0 +1,15 @@ +package types + +const ( + // ModuleName The name that will be used throughout the module + ModuleName = "cdp" + + // StoreKey Top level store key where all module items will be stored + StoreKey = ModuleName + + // RouterKey Top level router key + RouterKey = ModuleName + + // DefaultParamspace default name for parameter store + DefaultParamspace = ModuleName +) \ No newline at end of file diff --git a/x/cdp/types/msg.go b/x/cdp/types/msg.go new file mode 100644 index 00000000..dc9bd18d --- /dev/null +++ b/x/cdp/types/msg.go @@ -0,0 +1,55 @@ +package types + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// MsgCreateOrModifyCDP creates, adds/removes collateral/stable coin from a cdp +// TODO Make this more user friendly - maybe split into four functions. +type MsgCreateOrModifyCDP struct { + Sender sdk.AccAddress + CollateralDenom string + CollateralChange sdk.Int + DebtChange sdk.Int +} + +// NewMsgPlaceBid returns a new MsgPlaceBid. +func NewMsgCreateOrModifyCDP(sender sdk.AccAddress, collateralDenom string, collateralChange sdk.Int, debtChange sdk.Int) MsgCreateOrModifyCDP { + return MsgCreateOrModifyCDP{ + Sender: sender, + CollateralDenom: collateralDenom, + CollateralChange: collateralChange, + DebtChange: debtChange, + } +} + +// Route return the message type used for routing the message. +func (msg MsgCreateOrModifyCDP) Route() string { return "cdp" } + +// Type returns a human-readable string for the message, intended for utilization within tags. +func (msg MsgCreateOrModifyCDP) Type() string { return "create_modify_cdp" } // TODO snake case? + +// ValidateBasic does a simple validation check that doesn't require access to any other information. +func (msg MsgCreateOrModifyCDP) ValidateBasic() sdk.Error { + if msg.Sender.Empty() { + return sdk.ErrInternal("invalid (empty) sender address") + } + // TODO check coin denoms + return nil +} + +// GetSignBytes gets the canonical byte representation of the Msg. +func (msg MsgCreateOrModifyCDP) GetSignBytes() []byte { + bz := ModuleCdc.MustMarshalJSON(msg) + return sdk.MustSortJSON(bz) +} + +// GetSigners returns the addresses of signers that must sign. +func (msg MsgCreateOrModifyCDP) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{msg.Sender} +} + +// MsgTransferCDP changes the ownership of a cdp +type MsgTransferCDP struct { + // TODO +} diff --git a/x/cdp/types/params.go b/x/cdp/types/params.go new file mode 100644 index 00000000..d4afa0dd --- /dev/null +++ b/x/cdp/types/params.go @@ -0,0 +1,138 @@ +package types + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/params/subspace" +) + +/* +How this uses the sdk params module: + - Put all the params for this module in one struct `CDPModuleParams` + - Store this in the keeper's paramSubspace under one key + - Provide a function to load the param struct all at once `keeper.GetParams(ctx)` +It's possible to set individual key value pairs within a paramSubspace, but reading and setting them is awkward (an empty variable needs to be created, then Get writes the value into it) +This approach will be awkward if we ever need to write individual parameters (because they're stored all together). If this happens do as the sdk modules do - store parameters separately with custom get/set func for each. +*/ + +// CdpParams governance parameters for cdp module +type CdpParams struct { + GlobalDebtLimit sdk.Int + CollateralParams []CollateralParams + StableDenoms []string +} + +// CollateralParams governance parameters for each collateral type within the cdp module +type CollateralParams struct { + Denom string // Coin name of collateral type + LiquidationRatio sdk.Dec // The ratio (Collateral (priced in stable coin) / Debt) under which a CDP will be liquidated + DebtLimit sdk.Int // Maximum amount of debt allowed to be drawn from this collateral type + //DebtFloor sdk.Int // used to prevent dust +} + +// Parameter keys +var ( + // ParamStoreKeyAuctionParams Param store key for auction params + KeyGlobalDebtLimit = []byte("GlobalDebtLimit") + KeyCollateralParams = []byte("CollateralParams") + KeyStableDenoms = []byte("StableDenoms") +) + +// ParamKeyTable Key declaration for parameters +func ParamKeyTable() subspace.KeyTable { + return subspace.NewKeyTable().RegisterParamSet(&CdpParams{}) +} + +// ParamSetPairs implements the ParamSet interface and returns all the key/value pairs +// pairs of auth module's parameters. +// nolint +func (p *CdpParams) ParamSetPairs() subspace.ParamSetPairs { + return subspace.ParamSetPairs{ + {KeyGlobalDebtLimit, &p.GlobalDebtLimit}, + {KeyCollateralParams, &p.CollateralParams}, + {KeyStableDenoms, &p.StableDenoms}, + } +} + +// String implements fmt.Stringer +func (p CdpParams) String() string { + out := fmt.Sprintf(`Params: + Global Debt Limit: %s + Collateral Params:`, + p.GlobalDebtLimit, + ) + for _, cp := range p.CollateralParams { + out += fmt.Sprintf(` + %s + Liquidation Ratio: %s + Debt Limit: %s`, + cp.Denom, + cp.LiquidationRatio, + cp.DebtLimit, + ) + } + return out +} + +// GetCollateralParams returns params for a specific collateral denom +func (p CdpParams) GetCollateralParams(collateralDenom string) CollateralParams { + // search for matching denom, return + for _, cp := range p.CollateralParams { + if cp.Denom == collateralDenom { + return cp + } + } + // panic if not found, to be safe + panic("collateral params not found in module params") +} + +// IsCollateralPresent returns true if the denom is among the collaterals in cdp module +func (p CdpParams) IsCollateralPresent(collateralDenom string) bool { + // search for matching denom, return + for _, cp := range p.CollateralParams { + if cp.Denom == collateralDenom { + return true + } + } + return false +} + +// Validate checks that the parameters have valid values. +func (p CdpParams) Validate() error { + collateralDupMap := make(map[string]int) + denomDupMap := make(map[string]int) + for _, collateral := range p.CollateralParams { + _, found := collateralDupMap[collateral.Denom] + if found { + return fmt.Errorf("duplicate denom: %s", collateral.Denom) + } + collateralDupMap[collateral.Denom] = 1 + + if collateral.DebtLimit.IsNegative() { + return fmt.Errorf("debt limit should be positive, is %s for %s", collateral.DebtLimit, collateral.Denom) + } + + // TODO do we want to enforce overcollateralization at this level? -- probably not, as it's technically a governance thing (kevin) + } + if p.GlobalDebtLimit.IsNegative() { + return fmt.Errorf("global debt limit should be positive, is %s", p.GlobalDebtLimit) + } + + for _, denom := range p.StableDenoms { + _, found := denomDupMap[denom] + if found { + return fmt.Errorf("duplicate stable denom: %s", denom) + } + denomDupMap[denom] = 1 + } + return nil +} + +func DefaultParams() CdpParams { + return CdpParams{ + GlobalDebtLimit: sdk.NewInt(0), + CollateralParams: []CollateralParams{}, + StableDenoms: []string{"usdx"}, + } +} diff --git a/x/cdp/types/querier.go b/x/cdp/types/querier.go new file mode 100644 index 00000000..0f1c6fe1 --- /dev/null +++ b/x/cdp/types/querier.go @@ -0,0 +1,25 @@ +package types + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/rest" +) + +const ( + QueryGetCdps = "cdps" + QueryGetParams = "params" + RestOwner = "owner" + RestCollateralDenom = "collateralDenom" + RestUnderCollateralizedAt = "underCollateralizedAt" +) + +type QueryCdpsParams struct { + CollateralDenom string // get CDPs with this collateral denom + Owner sdk.AccAddress // get CDPs belonging to this owner + UnderCollateralizedAt sdk.Dec // get CDPs that will be below the liquidation ratio when the collateral is at this price. +} + +type ModifyCdpRequestBody struct { + BaseReq rest.BaseReq `json:"base_req"` + Cdp CDP `json:"cdp"` +} diff --git a/x/cdp/types/types.go b/x/cdp/types/types.go new file mode 100644 index 00000000..02051ed4 --- /dev/null +++ b/x/cdp/types/types.go @@ -0,0 +1,77 @@ +package types + +import ( + "fmt" + "strings" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// GovDenom asset code of the governance coin +const GovDenom = "kava" + +// CDP is the state of a single Collateralized Debt Position. +type CDP struct { + //ID []byte // removing IDs for now to make things simpler + Owner sdk.AccAddress `json:"owner" yaml:"owner"` // Account that authorizes changes to the CDP + CollateralDenom string `json:"collateral_denom" yaml:"collateral_denom"` // Type of collateral stored in this CDP + CollateralAmount sdk.Int `json:"collateral_amount" yaml:"collateral_amount"` // Amount of collateral stored in this CDP + Debt sdk.Int `json:"debt" yaml:"debt"` // Amount of stable coin drawn from this CDP +} + +func (cdp CDP) IsUnderCollateralized(price sdk.Dec, liquidationRatio sdk.Dec) bool { + collateralValue := sdk.NewDecFromInt(cdp.CollateralAmount).Mul(price) + minCollateralValue := liquidationRatio.Mul(sdk.NewDecFromInt(cdp.Debt)) + return collateralValue.LT(minCollateralValue) // TODO LT or LTE? +} + +func (cdp CDP) String() string { + return strings.TrimSpace(fmt.Sprintf(`CDP: + Owner: %s + Collateral: %s + Debt: %s`, + cdp.Owner, + sdk.NewCoin(cdp.CollateralDenom, cdp.CollateralAmount), + sdk.NewCoin("usdx", cdp.Debt), + )) +} + +type CDPs []CDP + +// String implements stringer +func (cdps CDPs) String() string { + out := "" + for _, cdp := range cdps { + out += cdp.String() + "\n" + } + return out +} + +// ByCollateralRatio is used to sort CDPs +type ByCollateralRatio CDPs + +func (cdps ByCollateralRatio) Len() int { return len(cdps) } +func (cdps ByCollateralRatio) Swap(i, j int) { cdps[i], cdps[j] = cdps[j], cdps[i] } +func (cdps ByCollateralRatio) Less(i, j int) bool { + // Sort by "collateral ratio" ie collateralAmount/Debt + // The comparison is: collat_i/debt_i < collat_j/debt_j + // But to avoid division this can be rearranged to: collat_i*debt_j < collat_j*debt_i + // Provided the values are positive, so check for positive values. + if cdps[i].CollateralAmount.IsNegative() || + cdps[i].Debt.IsNegative() || + cdps[j].CollateralAmount.IsNegative() || + cdps[j].Debt.IsNegative() { + panic("negative collateral and debt not supported in CDPs") + } + // TODO overflows could cause panics + left := cdps[i].CollateralAmount.Mul(cdps[j].Debt) + right := cdps[j].CollateralAmount.Mul(cdps[i].Debt) + return left.LT(right) +} + +// CollateralState stores global information tied to a particular collateral type. +type CollateralState struct { + Denom string `json:"denom" yaml:"denom"` // Type of collateral + TotalDebt sdk.Int `json:"total_debt" yaml:"total_debt"` // total debt collateralized by a this coin type + //AccumulatedFees sdk.Int // Ignoring fees for now +} diff --git a/x/liquidator/alias.go b/x/liquidator/alias.go new file mode 100644 index 00000000..da53e9d2 --- /dev/null +++ b/x/liquidator/alias.go @@ -0,0 +1,52 @@ +// nolint +// autogenerated code using github.com/rigelrozanski/multitool +// aliases generated for the following subdirectories: +// ALIASGEN: github.com/kava-labs/kava/x/liquidator/types/ +// ALIASGEN: github.com/kava-labs/kava/x/liquidator/keeper/ +package liquidator + +import ( + "github.com/kava-labs/kava/x/liquidator/keeper" + "github.com/kava-labs/kava/x/liquidator/types" +) + +const ( + ModuleName = types.ModuleName + StoreKey = types.StoreKey + RouterKey = types.RouterKey + QuerierRoute = types.QuerierRoute + DefaultParamspace = types.DefaultParamspace + QueryGetOutstandingDebt = types.QueryGetOutstandingDebt +) + +var ( + // functions aliases + RegisterCodec = types.RegisterCodec + DefaultGenesisState = types.DefaultGenesisState + ValidateGenesis = types.ValidateGenesis + NewLiquidatorParams = types.NewLiquidatorParams + ParamKeyTable = types.ParamKeyTable + DefaultParams = types.DefaultParams + NewKeeper = keeper.NewKeeper + NewQuerier = keeper.NewQuerier + + // variable aliases + ModuleCdc = types.ModuleCdc + KeyDebtAuctionSize = types.KeyDebtAuctionSize + KeyCollateralParams = types.KeyCollateralParams +) + +type ( + CdpKeeper = types.CdpKeeper + BankKeeper = types.BankKeeper + AuctionKeeper = types.AuctionKeeper + GenesisState = types.GenesisState + MsgSeizeAndStartCollateralAuction = types.MsgSeizeAndStartCollateralAuction + MsgStartDebtAuction = types.MsgStartDebtAuction + LiquidatorParams = types.LiquidatorParams + CollateralParams = types.CollateralParams + SeizeAndStartCollateralAuctionRequest = types.SeizeAndStartCollateralAuctionRequest + StartDebtAuctionRequest = types.StartDebtAuctionRequest + SeizedDebt = types.SeizedDebt + Keeper = keeper.Keeper +) diff --git a/x/liquidator/client/cli/query.go b/x/liquidator/client/cli/query.go new file mode 100644 index 00000000..45ea4d91 --- /dev/null +++ b/x/liquidator/client/cli/query.go @@ -0,0 +1,49 @@ +package cli + +import ( + "fmt" + + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/spf13/cobra" + + "github.com/kava-labs/kava/x/liquidator/types" +) + +// GetQueryCmd returns the cli query commands for this module +func GetQueryCmd(queryRoute string, cdc *codec.Codec) *cobra.Command { + // Group nameservice queries under a subcommand + queryCmd := &cobra.Command{ + Use: "liquidator", + Short: "Querying commands for the cdp liquidator", + } + + queryCmd.AddCommand(client.GetCommands( + GetCmdGetOutstandingDebt(queryRoute, cdc), + )...) + + return queryCmd +} + +// GetCmdGetOutstandingDebt queries for the remaining available debt in the liquidator module after settlement with the module's stablecoin balance. +func GetCmdGetOutstandingDebt(queryRoute string, cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "debt", + Short: "get the outstanding seized debt", + Long: "Get the remaining available debt after settlement with the liquidator's stable coin balance.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + + res, _, err := cliCtx.QueryWithData(fmt.Sprintf("custom/%s/%s", queryRoute, types.QueryGetOutstandingDebt), nil) + if err != nil { + return err + } + var outstandingDebt sdk.Int + cdc.MustUnmarshalJSON(res, &outstandingDebt) + return cliCtx.PrintOutput(outstandingDebt) + }, + } +} diff --git a/x/liquidator/client/cli/tx.go b/x/liquidator/client/cli/tx.go new file mode 100644 index 00000000..bfd72d45 --- /dev/null +++ b/x/liquidator/client/cli/tx.go @@ -0,0 +1,98 @@ +package cli + +import ( + "github.com/spf13/cobra" + + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/client/context" + + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/auth" + "github.com/cosmos/cosmos-sdk/x/auth/client/utils" + + "github.com/kava-labs/kava/x/liquidator/types" +) + +// GetTxCmd returns the transaction commands for this module +func GetTxCmd(cdc *codec.Codec) *cobra.Command { + txCmd := &cobra.Command{ + Use: "liquidator", + Short: "liquidator transactions subcommands", + } + + txCmd.AddCommand(client.PostCommands( + GetCmdSeizeAndStartCollateralAuction(cdc), + GetCmdStartDebtAuction(cdc), + )...) + + return txCmd +} + +// GetCmdSeizeAndStartCollateralAuction seize funds from a CDP and send to auction +func GetCmdSeizeAndStartCollateralAuction(cdc *codec.Codec) *cobra.Command { + cmd := &cobra.Command{ + Use: "seize [cdp-owner] [collateral-denom]", + Short: "", + Long: `Seize a fixed amount of collateral and debt from a CDP then start an auction with the collateral. +The amount of collateral seized is given by the 'AuctionSize' module parameter or, if there isn't enough collateral in the CDP, all the CDP's collateral is seized. +Debt is seized in proportion to the collateral seized so that the CDP stays at the same collateral to debt ratio. +A 'forward-reverse' auction is started selling the seized collateral for some stable coin, with a maximum bid of stable coin set to equal the debt seized. +As this is a forward-reverse auction type, if the max stable coin is bid then bidding continues by bidding down the amount of collateral taken by the bidder. At the end, extra collateral is returned to the original CDP owner.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + txBldr := auth.NewTxBuilderFromCLI().WithTxEncoder(utils.GetTxEncoder(cdc)) + + // Validate inputs + sender := cliCtx.GetFromAddress() + cdpOwner, err := sdk.AccAddressFromBech32(args[0]) + if err != nil { + return err + } + denom := args[1] + // TODO validate denom? + + // Prepare and send message + msg := types.MsgSeizeAndStartCollateralAuction{ + Sender: sender, + CdpOwner: cdpOwner, + CollateralDenom: denom, + } + err = msg.ValidateBasic() + if err != nil { + return err + } + return utils.GenerateOrBroadcastMsgs(cliCtx, txBldr, []sdk.Msg{msg}) + }, + } + return cmd +} + +func GetCmdStartDebtAuction(cdc *codec.Codec) *cobra.Command { + cmd := &cobra.Command{ + Use: "mint", + Short: "start a debt auction, minting gov coin to cover debt", + Long: "Start a reverse auction, selling off minted gov coin to raise a fixed amount of stable coin.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + txBldr := auth.NewTxBuilderFromCLI().WithTxEncoder(utils.GetTxEncoder(cdc)) + + sender := cliCtx.GetFromAddress() + + // Prepare and send message + msg := types.MsgStartDebtAuction{ + Sender: sender, + } + + err := msg.ValidateBasic() + if err != nil { + return err + } + // TODO print out results like auction ID? + return utils.GenerateOrBroadcastMsgs(cliCtx, txBldr, []sdk.Msg{msg}) + }, + } + return cmd +} diff --git a/x/liquidator/client/rest/query.go b/x/liquidator/client/rest/query.go new file mode 100644 index 00000000..7fea3a3d --- /dev/null +++ b/x/liquidator/client/rest/query.go @@ -0,0 +1,30 @@ +package rest + +import ( + "fmt" + "net/http" + + "github.com/gorilla/mux" + + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/types/rest" + "github.com/kava-labs/kava/x/liquidator/types" +) + +// RegisterRoutes - Central function to define routes that get registered by the main application +func registerQueryRoutes(cliCtx context.CLIContext, r *mux.Router) { + r.HandleFunc("/liquidator/outstandingdebt", queryDebtHandlerFn(cliCtx)).Methods("GET") + // r.HandleFunc("liquidator/burn", surplusAuctionHandlerFn(cdc, cliCtx).Methods("POST")) +} + +func queryDebtHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + res, height, err := cliCtx.QueryWithData(fmt.Sprintf("custom/liquidator/%s", types.QueryGetOutstandingDebt), nil) + cliCtx = cliCtx.WithHeight(height) + if err != nil { + rest.WriteErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + rest.PostProcessResponse(w, cliCtx, res) // write JSON to response writer + } +} diff --git a/x/liquidator/client/rest/rest.go b/x/liquidator/client/rest/rest.go new file mode 100644 index 00000000..6369945e --- /dev/null +++ b/x/liquidator/client/rest/rest.go @@ -0,0 +1,13 @@ +package rest + +import ( + "github.com/gorilla/mux" + + "github.com/cosmos/cosmos-sdk/client/context" +) + +// RegisterRoutes - Central function to define routes that get registered by the main application +func RegisterRoutes(cliCtx context.CLIContext, r *mux.Router) { + registerQueryRoutes(cliCtx, r) + registerTxRoutes(cliCtx, r) +} \ No newline at end of file diff --git a/x/liquidator/client/rest/tx.go b/x/liquidator/client/rest/tx.go new file mode 100644 index 00000000..e854fe93 --- /dev/null +++ b/x/liquidator/client/rest/tx.go @@ -0,0 +1,73 @@ +package rest + +import ( + "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/liquidator/types" +) + +func registerTxRoutes(cliCtx context.CLIContext, r *mux.Router) { + r.HandleFunc("/liquidator/seize", seizeCdpHandlerFn(cliCtx)).Methods("POST") + r.HandleFunc("/liquidator/mint", debtAuctionHandlerFn(cliCtx)).Methods("POST") +} + +func seizeCdpHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Get args from post body + var req types.SeizeAndStartCollateralAuctionRequest + if !rest.ReadRESTReq(w, r, cliCtx.Codec, &req) { // This function writes a response on error + return + } + req.BaseReq = req.BaseReq.Sanitize() + if !req.BaseReq.ValidateBasic(w) { // This function writes a response on error + return + } + + // Create msg + msg := types.MsgSeizeAndStartCollateralAuction{ + req.Sender, + req.CdpOwner, + req.CollateralDenom, + } + if err := msg.ValidateBasic(); err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + // Generate tx and write response + utils.WriteGenerateStdTxResponse(w, cliCtx, req.BaseReq, []sdk.Msg{msg}) + } +} + +func debtAuctionHandlerFn(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Get args from post body + var req types.StartDebtAuctionRequest + if !rest.ReadRESTReq(w, r, cliCtx.Codec, &req) { + return + } + req.BaseReq = req.BaseReq.Sanitize() + if !req.BaseReq.ValidateBasic(w) { + return + } + + // Create msg + msg := types.MsgStartDebtAuction{ + req.Sender, + } + if err := msg.ValidateBasic(); err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + // Generate tx and write response + utils.WriteGenerateStdTxResponse(w, cliCtx, req.BaseReq, []sdk.Msg{msg}) + } +} diff --git a/x/liquidator/doc.go b/x/liquidator/doc.go new file mode 100644 index 00000000..ee48245b --- /dev/null +++ b/x/liquidator/doc.go @@ -0,0 +1,21 @@ +/* +Package Liquidator settles bad debt from undercollateralized CDPs by seizing them and raising funds through auctions. + +Notes + - Missing the debt queue thing from Vow + - seized collateral and usdx are stored in the module account, but debt (aka Sin) is stored in keeper + - The boundary between the liquidator and the cdp modules is messy. + - The CDP type is used in liquidator + - cdp knows about seizing + - seizing of a CDP is split across each module + - recording of debt is split across modules + - liquidator needs get access to stable and gov denoms from the cdp module + +TODO + - Is returning unsold collateral to the CDP owner rather than the CDP a problem? It could prevent the CDP from becoming safe again. + - Add some kind of more complete test + - Add constants for the module and route names + - tags + - custom error types, codespace +*/ +package liquidator diff --git a/x/liquidator/genesis.go b/x/liquidator/genesis.go new file mode 100644 index 00000000..7c3b5adf --- /dev/null +++ b/x/liquidator/genesis.go @@ -0,0 +1,19 @@ +package liquidator + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// InitGenesis sets the genesis state in the keeper. +func InitGenesis(ctx sdk.Context, keeper Keeper, data GenesisState) { + + keeper.SetParams(ctx, data.Params) +} + +// ExportGenesis returns a GenesisState for a given context and keeper. +func ExportGenesis(ctx sdk.Context, keeper Keeper) GenesisState { + params := keeper.GetParams(ctx) + return GenesisState{ + Params: params, + } +} diff --git a/x/liquidator/handler.go b/x/liquidator/handler.go new file mode 100644 index 00000000..ace3fb9d --- /dev/null +++ b/x/liquidator/handler.go @@ -0,0 +1,54 @@ +package liquidator + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// Handle all liquidator messages. +func NewHandler(keeper Keeper) sdk.Handler { + return func(ctx sdk.Context, msg sdk.Msg) sdk.Result { + switch msg := msg.(type) { + case MsgSeizeAndStartCollateralAuction: + return handleMsgSeizeAndStartCollateralAuction(ctx, keeper, msg) + case MsgStartDebtAuction: + return handleMsgStartDebtAuction(ctx, keeper) + // case MsgStartSurplusAuction: + // return handleMsgStartSurplusAuction(ctx, keeper) + default: + errMsg := fmt.Sprintf("Unrecognized liquidator msg type: %T", msg) + return sdk.ErrUnknownRequest(errMsg).Result() + } + } +} + +func handleMsgSeizeAndStartCollateralAuction(ctx sdk.Context, keeper Keeper, msg MsgSeizeAndStartCollateralAuction) sdk.Result { + _, err := keeper.SeizeAndStartCollateralAuction(ctx, msg.CdpOwner, msg.CollateralDenom) + if err != nil { + return err.Result() + } + return sdk.Result{} // TODO tags, return auction ID +} + +func handleMsgStartDebtAuction(ctx sdk.Context, keeper Keeper) sdk.Result { + // cancel out any debt and stable coins before trying to start auction + keeper.SettleDebt(ctx) + // start an auction + _, err := keeper.StartDebtAuction(ctx) + if err != nil { + return err.Result() + } + return sdk.Result{} // TODO tags, return auction ID +} + +// With no stability and liquidation fees, surplus auctions can never be run. +// func handleMsgStartSurplusAuction(ctx sdk.Context, keeper Keeper) sdk.Result { +// // cancel out any debt and stable coins before trying to start auction +// keeper.settleDebt(ctx) +// _, err := keeper.StartSurplusAuction(ctx) +// if err != nil { +// return err.Result() +// } +// return sdk.Result{} // TODO tags +// } diff --git a/x/liquidator/keeper/keeper.go b/x/liquidator/keeper/keeper.go new file mode 100644 index 00000000..e5a15014 --- /dev/null +++ b/x/liquidator/keeper/keeper.go @@ -0,0 +1,204 @@ +package keeper + +import ( + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/params/subspace" + + "github.com/kava-labs/kava/x/auction" + "github.com/kava-labs/kava/x/liquidator/types" +) + +type Keeper struct { + cdc *codec.Codec + paramSubspace subspace.Subspace + key sdk.StoreKey + cdpKeeper types.CdpKeeper + auctionKeeper types.AuctionKeeper + bankKeeper types.BankKeeper +} + +func NewKeeper(cdc *codec.Codec, storeKey sdk.StoreKey, paramstore subspace.Subspace, cdpKeeper types.CdpKeeper, auctionKeeper types.AuctionKeeper, bankKeeper types.BankKeeper) Keeper { + subspace := paramstore.WithKeyTable(types.ParamKeyTable()) + return Keeper{ + cdc: cdc, + paramSubspace: subspace, + key: storeKey, + cdpKeeper: cdpKeeper, + auctionKeeper: auctionKeeper, + bankKeeper: bankKeeper, + } +} + +// SeizeAndStartCollateralAuction pulls collateral out of a CDP and sells it in an auction for stable coin. Excess collateral goes to the original CDP owner. +// Known as Cat.bite in maker +// result: stable coin is transferred to module account, collateral is transferred from module account to buyer, (and any excess collateral is transferred to original CDP owner) +func (k Keeper) SeizeAndStartCollateralAuction(ctx sdk.Context, owner sdk.AccAddress, collateralDenom string) (auction.ID, sdk.Error) { + // Get CDP + cdp, found := k.cdpKeeper.GetCDP(ctx, owner, collateralDenom) + if !found { + return 0, sdk.ErrInternal("CDP not found") + } + + // Calculate amount of collateral to sell in this auction + paramsMap := make(map[string]types.CollateralParams) + params := k.GetParams(ctx).CollateralParams + for _, cp := range params { + paramsMap[cp.Denom] = cp + } + collateralParams, found := paramsMap[collateralDenom] + if !found { + return 0, sdk.ErrInternal("collateral denom not found") + } + collateralToSell := sdk.MinInt(cdp.CollateralAmount, collateralParams.AuctionSize) + // Calculate the corresponding maximum amount of stable coin to raise TODO test maths + stableToRaise := sdk.NewDecFromInt(collateralToSell).Quo(sdk.NewDecFromInt(cdp.CollateralAmount)).Mul(sdk.NewDecFromInt(cdp.Debt)).RoundInt() + + // Seize the collateral and debt from the CDP + err := k.partialSeizeCDP(ctx, owner, collateralDenom, collateralToSell, stableToRaise) + if err != nil { + return 0, err + } + + // Start "forward reverse" auction type + lot := sdk.NewCoin(cdp.CollateralDenom, collateralToSell) + maxBid := sdk.NewCoin(k.cdpKeeper.GetStableDenom(), stableToRaise) + auctionID, err := k.auctionKeeper.StartForwardReverseAuction(ctx, k.cdpKeeper.GetLiquidatorAccountAddress(), lot, maxBid, owner) + if err != nil { + panic(err) // TODO how can errors here be handled to be safe with the state update in PartialSeizeCDP? + } + return auctionID, nil +} + +// StartDebtAuction sells off minted gov coin to raise set amounts of stable coin. +// Known as Vow.flop in maker +// result: minted gov coin moved to highest bidder, stable coin moved to moduleAccount +func (k Keeper) StartDebtAuction(ctx sdk.Context) (auction.ID, sdk.Error) { + + // Ensure amount of seized stable coin is 0 (ie Joy = 0) + stableCoins := k.bankKeeper.GetCoins(ctx, k.cdpKeeper.GetLiquidatorAccountAddress()).AmountOf(k.cdpKeeper.GetStableDenom()) + if !stableCoins.IsZero() { + return 0, sdk.ErrInternal("debt auction cannot be started as there is outstanding stable coins") + } + + // check the seized debt is above a threshold + params := k.GetParams(ctx) + seizedDebt := k.GetSeizedDebt(ctx) + if seizedDebt.Available().LT(params.DebtAuctionSize) { + return 0, sdk.ErrInternal("not enough seized debt to start an auction") + } + // start reverse auction, selling minted gov coin for stable coin + auctionID, err := k.auctionKeeper.StartReverseAuction( + ctx, + k.cdpKeeper.GetLiquidatorAccountAddress(), + sdk.NewCoin(k.cdpKeeper.GetStableDenom(), params.DebtAuctionSize), + sdk.NewInt64Coin(k.cdpKeeper.GetGovDenom(), 2^255-1), // TODO is there a way to avoid potentially minting infinite gov coin? + ) + if err != nil { + return 0, err + } + // Record amount of debt sent for auction. Debt can only be reduced in lock step with reducing stable coin + seizedDebt.SentToAuction = seizedDebt.SentToAuction.Add(params.DebtAuctionSize) + k.setSeizedDebt(ctx, seizedDebt) + return auctionID, nil +} + +// With no stability and liquidation fees, surplus auctions can never be run. +// StartSurplusAuction sells off excess stable coin in exchange for gov coin, which is burned +// Known as Vow.flap in maker +// result: stable coin removed from module account (eventually to buyer), gov coin transferred to module account +// func (k Keeper) StartSurplusAuction(ctx sdk.Context) (auction.ID, sdk.Error) { + +// // TODO ensure seized debt is 0 + +// // check there is enough surplus to be sold +// surplus := k.bankKeeper.GetCoins(ctx, k.cdpKeeper.GetLiquidatorAccountAddress()).AmountOf(k.cdpKeeper.GetStableDenom()) +// if surplus.LT(SurplusAuctionSize) { +// return 0, sdk.ErrInternal("not enough surplus stable coin to start an auction") +// } +// // start normal auction, selling stable coin +// auctionID, err := k.auctionKeeper.StartForwardAuction( +// ctx, +// k.cdpKeeper.GetLiquidatorAccountAddress(), +// sdk.NewCoin(k.cdpKeeper.GetStableDenom(), SurplusAuctionSize), +// sdk.NewInt64Coin(k.cdpKeeper.GetGovDenom(), 0), +// ) +// if err != nil { +// return 0, err +// } +// // Starting the auction will remove coins from the account, so they don't need modified here. +// return auctionID, nil +// } + +// PartialSeizeCDP seizes some collateral and debt from an under-collateralized CDP. +func (k Keeper) partialSeizeCDP(ctx sdk.Context, owner sdk.AccAddress, collateralDenom string, collateralToSeize sdk.Int, debtToSeize sdk.Int) sdk.Error { // aka Cat.bite + // Seize debt and collateral in the cdp module. This also validates the inputs. + err := k.cdpKeeper.PartialSeizeCDP(ctx, owner, collateralDenom, collateralToSeize, debtToSeize) + if err != nil { + return err // cdp could be not found, or not under collateralized, or inputs invalid + } + + // increment the total seized debt (Awe) by cdp.debt + seizedDebt := k.GetSeizedDebt(ctx) + seizedDebt.Total = seizedDebt.Total.Add(debtToSeize) + k.setSeizedDebt(ctx, seizedDebt) + + // add cdp.collateral amount of coins to the moduleAccount (so they can be transferred to the auction later) + coins := sdk.NewCoins(sdk.NewCoin(collateralDenom, collateralToSeize)) + _, err = k.bankKeeper.AddCoins(ctx, k.cdpKeeper.GetLiquidatorAccountAddress(), coins) + if err != nil { + panic(err) // TODO this shouldn't happen? + } + return nil +} + +// SettleDebt removes equal amounts of debt and stable coin from the liquidator's reserves (and also updates the global debt in the cdp module). +// This is called in the handler when a debt or surplus auction is started +// TODO Should this be called with an amount, rather than annihilating the maximum? +func (k Keeper) SettleDebt(ctx sdk.Context) sdk.Error { + // Calculate max amount of debt and stable coins that can be settled (ie annihilated) + debt := k.GetSeizedDebt(ctx) + stableCoins := k.bankKeeper.GetCoins(ctx, k.cdpKeeper.GetLiquidatorAccountAddress()).AmountOf(k.cdpKeeper.GetStableDenom()) + settleAmount := sdk.MinInt(debt.Total, stableCoins) + + // Call cdp module to reduce GlobalDebt. This can fail if genesis not set + err := k.cdpKeeper.ReduceGlobalDebt(ctx, settleAmount) + if err != nil { + return err + } + + // Decrement total seized debt (also decrement from SentToAuction debt) + updatedDebt, err := debt.Settle(settleAmount) + if err != nil { + return err // this should not error in this context + } + k.setSeizedDebt(ctx, updatedDebt) + + // Subtract stable coin from moduleAccout + k.bankKeeper.SubtractCoins(ctx, k.cdpKeeper.GetLiquidatorAccountAddress(), sdk.Coins{sdk.NewCoin(k.cdpKeeper.GetStableDenom(), settleAmount)}) + return nil +} + +// ---------- Store Wrappers ---------- + +func (k Keeper) getSeizedDebtKey() []byte { + return []byte("seizedDebt") +} +func (k Keeper) GetSeizedDebt(ctx sdk.Context) types.SeizedDebt { + store := ctx.KVStore(k.key) + bz := store.Get(k.getSeizedDebtKey()) + if bz == nil { + // TODO make initial seized debt and CDPs configurable at genesis, then panic here if not found + bz = k.cdc.MustMarshalBinaryLengthPrefixed(types.SeizedDebt{ + Total: sdk.ZeroInt(), + SentToAuction: sdk.ZeroInt()}) + } + var seizedDebt types.SeizedDebt + k.cdc.MustUnmarshalBinaryLengthPrefixed(bz, &seizedDebt) + return seizedDebt +} +func (k Keeper) setSeizedDebt(ctx sdk.Context, debt types.SeizedDebt) { + store := ctx.KVStore(k.key) + bz := k.cdc.MustMarshalBinaryLengthPrefixed(debt) + store.Set(k.getSeizedDebtKey(), bz) +} diff --git a/x/liquidator/keeper/keeper_test.go b/x/liquidator/keeper/keeper_test.go new file mode 100644 index 00000000..d69b5547 --- /dev/null +++ b/x/liquidator/keeper/keeper_test.go @@ -0,0 +1,136 @@ +package keeper + +import ( + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/mock" + "github.com/stretchr/testify/require" + + "github.com/kava-labs/kava/x/cdp" + "github.com/kava-labs/kava/x/liquidator/types" + "github.com/kava-labs/kava/x/pricefeed" +) + +func TestKeeper_SeizeAndStartCollateralAuction(t *testing.T) { + // Setup + ctx, k := setupTestKeepers() + + _, addrs := mock.GeneratePrivKeyAddressPairs(1) + + pricefeed.InitGenesis(ctx, k.pricefeedKeeper, pricefeedGenesis()) + k.pricefeedKeeper.SetPrice(ctx, addrs[0], "btc", sdk.MustNewDecFromStr("8000.00"), i(999999999)) + k.pricefeedKeeper.SetCurrentPrices(ctx) + cdp.InitGenesis(ctx, k.cdpKeeper, k.pricefeedKeeper, cdpDefaultGenesis()) + dp := defaultParams() + k.liquidatorKeeper.SetParams(ctx, dp) + k.bankKeeper.AddCoins(ctx, addrs[0], cs(c("btc", 100))) + + k.cdpKeeper.ModifyCDP(ctx, addrs[0], "btc", i(3), i(16000)) + + k.pricefeedKeeper.SetPrice(ctx, addrs[0], "btc", sdk.MustNewDecFromStr("7999.99"), i(999999999)) + k.pricefeedKeeper.SetCurrentPrices(ctx) + + // Run test function + auctionID, err := k.liquidatorKeeper.SeizeAndStartCollateralAuction(ctx, addrs[0], "btc") + + // Check CDP + require.NoError(t, err) + cdp, found := k.cdpKeeper.GetCDP(ctx, addrs[0], "btc") + require.True(t, found) + require.Equal(t, cdp.CollateralAmount, i(2)) // original amount - params.CollateralAuctionSize + require.Equal(t, cdp.Debt, i(10667)) // original debt scaled by amount of collateral removed + // Check auction exists + _, found = k.auctionKeeper.GetAuction(ctx, auctionID) + require.True(t, found) + // TODO check auction values are correct? +} + +func TestKeeper_StartDebtAuction(t *testing.T) { + // Setup + ctx, k := setupTestKeepers() + k.liquidatorKeeper.SetParams(ctx, defaultParams()) + initSDebt := types.SeizedDebt{i(2000), i(0)} + k.liquidatorKeeper.setSeizedDebt(ctx, initSDebt) + + // Execute + auctionID, err := k.liquidatorKeeper.StartDebtAuction(ctx) + + // Check + require.NoError(t, err) + require.Equal(t, + types.SeizedDebt{ + initSDebt.Total, + initSDebt.SentToAuction.Add(k.liquidatorKeeper.GetParams(ctx).DebtAuctionSize), + }, + k.liquidatorKeeper.GetSeizedDebt(ctx), + ) + _, found := k.auctionKeeper.GetAuction(ctx, auctionID) + require.True(t, found) + // TODO check auction values are correct? +} + +// func TestKeeper_StartSurplusAuction(t *testing.T) { +// // Setup +// ctx, k := setupTestKeepers() +// initSurplus := i(2000) +// k.liquidatorKeeper.bankKeeper.AddCoins(ctx, k.cdpKeeper.GetLiquidatorAccountAddress(), cs(sdk.NewCoin(k.cdpKeeper.GetStableDenom(), initSurplus))) +// k.liquidatorKeeper.setSeizedDebt(ctx, i(0)) + +// // Execute +// auctionID, err := k.liquidatorKeeper.StartSurplusAuction(ctx) + +// // Check +// require.NoError(t, err) +// require.Equal(t, +// initSurplus.Sub(SurplusAuctionSize), +// k.liquidatorKeeper.bankKeeper.GetCoins(ctx, +// k.cdpKeeper.GetLiquidatorAccountAddress(), +// ).AmountOf(k.cdpKeeper.GetStableDenom()), +// ) +// _, found := k.auctionKeeper.GetAuction(ctx, auctionID) +// require.True(t, found) +// } + +func TestKeeper_partialSeizeCDP(t *testing.T) { + // Setup + ctx, k := setupTestKeepers() + + _, addrs := mock.GeneratePrivKeyAddressPairs(1) + + pricefeed.InitGenesis(ctx, k.pricefeedKeeper, pricefeedGenesis()) + + k.pricefeedKeeper.SetPrice(ctx, addrs[0], "btc", sdk.MustNewDecFromStr("8000.00"), i(999999999)) + k.pricefeedKeeper.SetCurrentPrices(ctx) + k.bankKeeper.AddCoins(ctx, addrs[0], cs(c("btc", 100))) + cdp.InitGenesis(ctx, k.cdpKeeper, k.pricefeedKeeper, cdpDefaultGenesis()) + k.liquidatorKeeper.SetParams(ctx, defaultParams()) + + k.cdpKeeper.ModifyCDP(ctx, addrs[0], "btc", i(3), i(16000)) + + k.pricefeedKeeper.SetPrice(ctx, addrs[0], "btc", sdk.MustNewDecFromStr("7999.99"), i(999999999)) + k.pricefeedKeeper.SetCurrentPrices(ctx) + + // Run test function + err := k.liquidatorKeeper.partialSeizeCDP(ctx, addrs[0], "btc", i(2), i(10000)) + + // Check + require.NoError(t, err) + cdp, found := k.cdpKeeper.GetCDP(ctx, addrs[0], "btc") + require.True(t, found) + require.Equal(t, i(1), cdp.CollateralAmount) + require.Equal(t, i(6000), cdp.Debt) +} + +func TestKeeper_GetSetSeizedDebt(t *testing.T) { + // Setup + ctx, k := setupTestKeepers() + debt := types.SeizedDebt{i(234247645), i(2343)} + + // Run test function + k.liquidatorKeeper.setSeizedDebt(ctx, debt) + readDebt := k.liquidatorKeeper.GetSeizedDebt(ctx) + + // Check + require.Equal(t, debt, readDebt) +} diff --git a/x/liquidator/keeper/params.go b/x/liquidator/keeper/params.go new file mode 100644 index 00000000..71d0f31b --- /dev/null +++ b/x/liquidator/keeper/params.go @@ -0,0 +1,18 @@ +package keeper + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/kava-labs/kava/x/liquidator/types" +) + +// GetParams returns the params for liquidator module +func (k Keeper) GetParams(ctx sdk.Context) types.LiquidatorParams { + var params types.LiquidatorParams + k.paramSubspace.GetParamSet(ctx, ¶ms) + return params +} + +// SetParams sets params for the liquidator module +func (k Keeper) SetParams(ctx sdk.Context, params types.LiquidatorParams) { + k.paramSubspace.SetParamSet(ctx, ¶ms) +} \ No newline at end of file diff --git a/x/liquidator/keeper/querier.go b/x/liquidator/keeper/querier.go new file mode 100644 index 00000000..4bff0ad5 --- /dev/null +++ b/x/liquidator/keeper/querier.go @@ -0,0 +1,45 @@ +package keeper + +import ( + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/kava-labs/kava/x/liquidator/types" + abci "github.com/tendermint/tendermint/abci/types" +) + +func NewQuerier(keeper Keeper) sdk.Querier { + return func(ctx sdk.Context, path []string, req abci.RequestQuery) (res []byte, err sdk.Error) { + switch path[0] { + case types.QueryGetOutstandingDebt: + return queryGetOutstandingDebt(ctx, path[1:], req, keeper) + // case QueryGetSurplus: + // return queryGetSurplus() + default: + return nil, sdk.ErrUnknownRequest("unknown liquidator query endpoint") + } + } +} + +func queryGetOutstandingDebt(ctx sdk.Context, path []string, req abci.RequestQuery, keeper Keeper) ([]byte, sdk.Error) { + // Calculate the remaining seized debt after settling with the liquidator's stable coins. + stableCoins := keeper.bankKeeper.GetCoins( + ctx, + keeper.cdpKeeper.GetLiquidatorAccountAddress(), + ).AmountOf(keeper.cdpKeeper.GetStableDenom()) + seizedDebt := keeper.GetSeizedDebt(ctx) + settleAmount := sdk.MinInt(seizedDebt.Total, stableCoins) + seizedDebt, err := seizedDebt.Settle(settleAmount) + if err != nil { + return nil, err // this shouldn't error in this context + } + + // Get the available debt after settling + oustandingDebt := seizedDebt.Available() + + // Encode and return + bz, err2 := codec.MarshalJSONIndent(keeper.cdc, oustandingDebt) + if err2 != nil { + return nil, sdk.ErrInternal(sdk.AppendMsgToErr("could not marshal result to JSON", err.Error())) + } + return bz, nil +} diff --git a/x/liquidator/keeper/test_common.go b/x/liquidator/keeper/test_common.go new file mode 100644 index 00000000..62751c9f --- /dev/null +++ b/x/liquidator/keeper/test_common.go @@ -0,0 +1,170 @@ +package keeper + +import ( + "github.com/cosmos/cosmos-sdk/codec" + "github.com/cosmos/cosmos-sdk/store" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/auth" + "github.com/cosmos/cosmos-sdk/x/bank" + "github.com/cosmos/cosmos-sdk/x/params" + abci "github.com/tendermint/tendermint/abci/types" + "github.com/tendermint/tendermint/libs/log" + dbm "github.com/tendermint/tm-db" + + "github.com/kava-labs/kava/x/auction" + "github.com/kava-labs/kava/x/cdp" + "github.com/kava-labs/kava/x/liquidator/types" + "github.com/kava-labs/kava/x/pricefeed" +) + +// Avoid cluttering test cases with long function name +func i(in int64) sdk.Int { return sdk.NewInt(in) } +func c(denom string, amount int64) sdk.Coin { return sdk.NewInt64Coin(denom, amount) } +func cs(coins ...sdk.Coin) sdk.Coins { return sdk.NewCoins(coins...) } + +type keepers struct { + paramsKeeper params.Keeper + accountKeeper auth.AccountKeeper + bankKeeper bank.Keeper + pricefeedKeeper pricefeed.Keeper + auctionKeeper auction.Keeper + cdpKeeper cdp.Keeper + liquidatorKeeper Keeper +} + +func setupTestKeepers() (sdk.Context, keepers) { + + // Setup in memory database + keyParams := sdk.NewKVStoreKey(params.StoreKey) + tkeyParams := sdk.NewTransientStoreKey(params.TStoreKey) + keyAcc := sdk.NewKVStoreKey(auth.StoreKey) + keyPriceFeed := sdk.NewKVStoreKey(pricefeed.StoreKey) + keyCDP := sdk.NewKVStoreKey("cdp") + keyAuction := sdk.NewKVStoreKey("auction") + keyLiquidator := sdk.NewKVStoreKey("liquidator") + + db := dbm.NewMemDB() + ms := store.NewCommitMultiStore(db) + ms.MountStoreWithDB(keyParams, sdk.StoreTypeIAVL, db) + ms.MountStoreWithDB(tkeyParams, sdk.StoreTypeTransient, db) + ms.MountStoreWithDB(keyAcc, sdk.StoreTypeIAVL, db) + ms.MountStoreWithDB(keyPriceFeed, sdk.StoreTypeIAVL, db) + ms.MountStoreWithDB(keyCDP, sdk.StoreTypeIAVL, db) + ms.MountStoreWithDB(keyAuction, sdk.StoreTypeIAVL, db) + ms.MountStoreWithDB(keyLiquidator, sdk.StoreTypeIAVL, db) + err := ms.LoadLatestVersion() + if err != nil { + panic(err) + } + + // Create Codec + cdc := makeTestCodec() + + // Create Keepers + paramsKeeper := params.NewKeeper(cdc, keyParams, tkeyParams, params.DefaultCodespace) + accountKeeper := auth.NewAccountKeeper( + cdc, + keyAcc, + paramsKeeper.Subspace(auth.DefaultParamspace), + auth.ProtoBaseAccount, + ) + blacklistedAddrs := make(map[string]bool) + bankKeeper := bank.NewBaseKeeper( + accountKeeper, + paramsKeeper.Subspace(bank.DefaultParamspace), + bank.DefaultCodespace, + blacklistedAddrs, + ) + pricefeedKeeper := pricefeed.NewKeeper(keyPriceFeed, cdc, paramsKeeper.Subspace(pricefeed.DefaultParamspace).WithKeyTable(pricefeed.ParamKeyTable()), pricefeed.DefaultCodespace) + cdpKeeper := cdp.NewKeeper( + cdc, + keyCDP, + paramsKeeper.Subspace(cdp.DefaultParamspace), + pricefeedKeeper, + bankKeeper, + ) + auctionKeeper := auction.NewKeeper(cdc, cdpKeeper, keyAuction, paramsKeeper.Subspace(auction.DefaultParamspace)) // Note: cdp keeper stands in for bank keeper + liquidatorKeeper := NewKeeper( + cdc, + keyLiquidator, + paramsKeeper.Subspace(types.DefaultParamspace), + cdpKeeper, + auctionKeeper, + cdpKeeper, + ) // Note: cdp keeper stands in for bank keeper + + // Create context + ctx := sdk.NewContext(ms, abci.Header{ChainID: "testchain"}, false, log.NewNopLogger()) + + return ctx, keepers{ + paramsKeeper, + accountKeeper, + bankKeeper, + pricefeedKeeper, + auctionKeeper, + cdpKeeper, + liquidatorKeeper, + } +} + +func makeTestCodec() *codec.Codec { + var cdc = codec.New() + auth.RegisterCodec(cdc) + bank.RegisterCodec(cdc) + pricefeed.RegisterCodec(cdc) + auction.RegisterCodec(cdc) + cdp.RegisterCodec(cdc) + types.RegisterCodec(cdc) + sdk.RegisterCodec(cdc) + codec.RegisterCrypto(cdc) + return cdc +} + +func defaultParams() types.LiquidatorParams { + return types.LiquidatorParams{ + DebtAuctionSize: sdk.NewInt(1000), + CollateralParams: []types.CollateralParams{ + { + Denom: "btc", + AuctionSize: sdk.NewInt(1), + }, + }, + } +} + +func cdpDefaultGenesis() cdp.GenesisState { + return cdp.GenesisState{ + cdp.CdpParams{ + GlobalDebtLimit: sdk.NewInt(1000000), + CollateralParams: []cdp.CollateralParams{ + { + Denom: "btc", + LiquidationRatio: sdk.MustNewDecFromStr("1.5"), + DebtLimit: sdk.NewInt(500000), + }, + }, + }, + sdk.ZeroInt(), + cdp.CDPs{}, + } +} + +func pricefeedGenesis() pricefeed.GenesisState { + ap := pricefeed.AssetParams{ + Assets: []pricefeed.Asset{ + pricefeed.Asset{AssetCode: "btc", Description: "a description"}, + }, + } + return pricefeed.GenesisState{ + AssetParams: ap, + OracleParams: pricefeed.DefaultOracleParams(), + PostedPrices: []pricefeed.PostedPrice{ + pricefeed.PostedPrice{ + AssetCode: "btc", + OracleAddress: "", + Price: sdk.MustNewDecFromStr("8000.00"), + Expiry: sdk.NewInt(999999999), + }, + }, + } +} diff --git a/x/liquidator/module.go b/x/liquidator/module.go new file mode 100644 index 00000000..7f163de8 --- /dev/null +++ b/x/liquidator/module.go @@ -0,0 +1,130 @@ +package liquidator + +import ( + "encoding/json" + + "github.com/gorilla/mux" + "github.com/spf13/cobra" + + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/module" + abci "github.com/tendermint/tendermint/abci/types" + + "github.com/kava-labs/kava/x/liquidator/client/cli" + "github.com/kava-labs/kava/x/liquidator/client/rest" +) + +var ( + _ module.AppModule = AppModule{} + _ module.AppModuleBasic = AppModuleBasic{} +) + +// AppModuleBasic app module basics object +type AppModuleBasic struct{} + +// Name get module name +func (AppModuleBasic) Name() string { + return ModuleName +} + +// RegisterCodec register module codec +func (AppModuleBasic) RegisterCodec(cdc *codec.Codec) { + RegisterCodec(cdc) +} + +// DefaultGenesis default genesis state +func (AppModuleBasic) DefaultGenesis() json.RawMessage { + return ModuleCdc.MustMarshalJSON(DefaultGenesisState()) +} + +// ValidateGenesis module validate genesis +func (AppModuleBasic) ValidateGenesis(bz json.RawMessage) error { + var data GenesisState + err := ModuleCdc.UnmarshalJSON(bz, &data) + if err != nil { + return err + } + return ValidateGenesis(data) +} + +// RegisterRESTRoutes registers the REST routes for the liquidator module. +func (AppModuleBasic) RegisterRESTRoutes(ctx context.CLIContext, rtr *mux.Router) { + rest.RegisterRoutes(ctx, rtr) +} + +// GetTxCmd returns the root tx command for the liquidator module. +func (AppModuleBasic) GetTxCmd(cdc *codec.Codec) *cobra.Command { + return cli.GetTxCmd(cdc) +} + +// GetQueryCmd returns the root query command for the auction module. +func (AppModuleBasic) GetQueryCmd(cdc *codec.Codec) *cobra.Command { + return cli.GetQueryCmd(StoreKey, cdc) +} + +// AppModule app module type +type AppModule struct { + AppModuleBasic + keeper Keeper +} + +// NewAppModule creates a new AppModule object +func NewAppModule(keeper Keeper) AppModule { + return AppModule{ + AppModuleBasic: AppModuleBasic{}, + keeper: keeper, + } +} + +// Name module name +func (AppModule) Name() string { + return ModuleName +} + +// RegisterInvariants register module invariants +func (AppModule) RegisterInvariants(_ sdk.InvariantRegistry) {} + +// Route module message route name +func (AppModule) Route() string { + return ModuleName +} + +// NewHandler module handler +func (am AppModule) NewHandler() sdk.Handler { + return NewHandler(am.keeper) +} + +// QuerierRoute module querier route name +func (AppModule) QuerierRoute() string { + return ModuleName +} + +// NewQuerierHandler module querier +func (am AppModule) NewQuerierHandler() sdk.Querier { + return NewQuerier(am.keeper) +} + +// InitGenesis module init-genesis +func (am AppModule) InitGenesis(ctx sdk.Context, data json.RawMessage) []abci.ValidatorUpdate { + var genesisState GenesisState + ModuleCdc.MustUnmarshalJSON(data, &genesisState) + InitGenesis(ctx, am.keeper, genesisState) + + return []abci.ValidatorUpdate{} +} + +// ExportGenesis module export genesis +func (am AppModule) ExportGenesis(ctx sdk.Context) json.RawMessage { + gs := ExportGenesis(ctx, am.keeper) + return ModuleCdc.MustMarshalJSON(gs) +} + +// BeginBlock module begin-block +func (AppModule) BeginBlock(_ sdk.Context, _ abci.RequestBeginBlock) {} + +// EndBlock module end-block +func (am AppModule) EndBlock(ctx sdk.Context, _ abci.RequestEndBlock) []abci.ValidatorUpdate { + return []abci.ValidatorUpdate{} +} diff --git a/x/liquidator/types/codec.go b/x/liquidator/types/codec.go new file mode 100644 index 00000000..da70df34 --- /dev/null +++ b/x/liquidator/types/codec.go @@ -0,0 +1,20 @@ +package types + +import "github.com/cosmos/cosmos-sdk/codec" + +// ModuleCdc module level codec +var ModuleCdc = codec.New() + +func init() { + cdc := codec.New() + RegisterCodec(cdc) + codec.RegisterCrypto(cdc) + ModuleCdc = cdc.Seal() +} + +// RegisterCodec registers concrete types on the codec. +func RegisterCodec(cdc *codec.Codec) { + cdc.RegisterConcrete(MsgSeizeAndStartCollateralAuction{}, "liquidator/MsgSeizeAndStartCollateralAuction", nil) + cdc.RegisterConcrete(MsgStartDebtAuction{}, "liquidator/MsgStartDebtAuction", nil) + // cdc.RegisterConcrete(MsgStartSurplusAuction{}, "liquidator/MsgStartSurplusAuction", nil) +} diff --git a/x/liquidator/types/expected_keepers.go b/x/liquidator/types/expected_keepers.go new file mode 100644 index 00000000..deea2bd4 --- /dev/null +++ b/x/liquidator/types/expected_keepers.go @@ -0,0 +1,32 @@ +package types + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/kava-labs/kava/x/auction" + "github.com/kava-labs/kava/x/cdp" +) + +// CdpKeeper expected interface for the cdp keeper +type CdpKeeper interface { + GetCDP(sdk.Context, sdk.AccAddress, string) (cdp.CDP, bool) + PartialSeizeCDP(sdk.Context, sdk.AccAddress, string, sdk.Int, sdk.Int) sdk.Error + ReduceGlobalDebt(sdk.Context, sdk.Int) sdk.Error + GetStableDenom() string // TODO can this be removed somehow? + GetGovDenom() string + GetLiquidatorAccountAddress() sdk.AccAddress // This won't need to exist once the module account is defined in this module (instead of in the cdp module) +} + +// BankKeeper expected interface for the bank keeper +type BankKeeper interface { + GetCoins(sdk.Context, sdk.AccAddress) sdk.Coins + AddCoins(sdk.Context, sdk.AccAddress, sdk.Coins) (sdk.Coins, sdk.Error) + SubtractCoins(sdk.Context, sdk.AccAddress, sdk.Coins) (sdk.Coins, sdk.Error) +} + +// AuctionKeeper expected interface for the auction keeper +type AuctionKeeper interface { + StartForwardAuction(sdk.Context, sdk.AccAddress, sdk.Coin, sdk.Coin) (auction.ID, sdk.Error) + StartReverseAuction(sdk.Context, sdk.AccAddress, sdk.Coin, sdk.Coin) (auction.ID, sdk.Error) + StartForwardReverseAuction(sdk.Context, sdk.AccAddress, sdk.Coin, sdk.Coin, sdk.AccAddress) (auction.ID, sdk.Error) +} diff --git a/x/liquidator/types/genesis.go b/x/liquidator/types/genesis.go new file mode 100644 index 00000000..0b4bdf62 --- /dev/null +++ b/x/liquidator/types/genesis.go @@ -0,0 +1,22 @@ +package types + +// GenesisState is the state that must be provided at genesis. +type GenesisState struct { + Params LiquidatorParams `json:"liquidator_params" yaml:"liquidator_params"` +} + +// DefaultGenesisState returns a default genesis state +// TODO pick better values +func DefaultGenesisState() GenesisState { + return GenesisState{ + DefaultParams(), + } +} + +// ValidateGenesis performs basic validation of genesis data returning an error for any failed validation criteria. +func ValidateGenesis(data GenesisState) error { + if err := data.Params.Validate(); err != nil { + return err + } + return nil +} diff --git a/x/liquidator/types/keys.go b/x/liquidator/types/keys.go new file mode 100644 index 00000000..9e4ffd6b --- /dev/null +++ b/x/liquidator/types/keys.go @@ -0,0 +1,18 @@ +package types + +const ( + // ModuleName is the name of the module + ModuleName = "liquidator" + + // StoreKey is the store key string for liquidator + StoreKey = ModuleName + + // RouterKey is the message route for liquidator + RouterKey = ModuleName + + // QuerierRoute is the querier route for liquidator + QuerierRoute = ModuleName + + // DefaultParamspace default name for parameter store + DefaultParamspace = ModuleName +) \ No newline at end of file diff --git a/x/liquidator/types/msg.go b/x/liquidator/types/msg.go new file mode 100644 index 00000000..3b5cbe81 --- /dev/null +++ b/x/liquidator/types/msg.go @@ -0,0 +1,97 @@ +package types + +import sdk "github.com/cosmos/cosmos-sdk/types" + +/* +Message types for starting various auctions. +Note: these message types are not final and will likely change. +Design options and problems: + - msgs that only start auctions + - senders have to pay fees + - these msgs cannot be bundled into a tx with a PlaceBid msg because PlaceBid requires an auction ID + - msgs that start auctions and place an initial bid + - place bid can fail, leaving auction without bids which is similar to first case + - no msgs, auctions started automatically + - running this as an endblocker adds complexity and potential vulnerabilities +*/ + +// MsgSeizeAndStartCollateralAuction siezes a cdp that is below liquidation ratio and starts an auction for the collateral +type MsgSeizeAndStartCollateralAuction struct { + Sender sdk.AccAddress // only needed to pay the tx fees + CdpOwner sdk.AccAddress + CollateralDenom string +} + +// Route return the message type used for routing the message. +func (msg MsgSeizeAndStartCollateralAuction) Route() string { return "liquidator" } + +// Type returns a human-readable string for the message, intended for utilization within tags. +func (msg MsgSeizeAndStartCollateralAuction) Type() string { return "seize_and_start_auction" } // TODO snake case? + +// ValidateBasic does a simple validation check that doesn't require access to any other information. +func (msg MsgSeizeAndStartCollateralAuction) ValidateBasic() sdk.Error { + if msg.Sender.Empty() { + return sdk.ErrInternal("invalid (empty) sender address") + } + if msg.CdpOwner.Empty() { + return sdk.ErrInternal("invalid (empty) CDP owner address") + } + // TODO check coin denoms + return nil +} + +// GetSignBytes gets the canonical byte representation of the Msg. +func (msg MsgSeizeAndStartCollateralAuction) GetSignBytes() []byte { + bz := ModuleCdc.MustMarshalJSON(msg) + return sdk.MustSortJSON(bz) +} + +// GetSigners returns the addresses of signers that must sign. +func (msg MsgSeizeAndStartCollateralAuction) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{msg.Sender} +} + +// MsgStartDebtAuction starts an auction of gov tokens for stable tokens +type MsgStartDebtAuction struct { + Sender sdk.AccAddress // only needed to pay the tx fees +} + +// Route returns the route for this message +func (msg MsgStartDebtAuction) Route() string { return "liquidator" } + +// Type returns the type for this message +func (msg MsgStartDebtAuction) Type() string { return "start_debt_auction" } + +// ValidateBasic simple validation check +func (msg MsgStartDebtAuction) ValidateBasic() sdk.Error { + if msg.Sender.Empty() { + return sdk.ErrInternal("invalid (empty) sender address") + } + return nil +} + +// GetSignBytes returns canonical byte representation of the message +func (msg MsgStartDebtAuction) GetSignBytes() []byte { + return sdk.MustSortJSON(ModuleCdc.MustMarshalJSON(msg)) +} + +// GetSigners returns the addresses of the signers of the message +func (msg MsgStartDebtAuction) GetSigners() []sdk.AccAddress { return []sdk.AccAddress{msg.Sender} } + +// With no stability and liquidation fees, surplus auctions can never be run. +// type MsgStartSurplusAuction struct { +// Sender sdk.AccAddress // only needed to pay the tx fees +// } + +// func (msg MsgStartSurplusAuction) Route() string { return "liquidator" } +// func (msg MsgStartSurplusAuction) Type() string { return "start_surplus_auction" } // TODO snake case? +// func (msg MsgStartSurplusAuction) ValidateBasic() sdk.Error { +// if msg.Sender.Empty() { +// return sdk.ErrInternal("invalid (empty) sender address") +// } +// return nil +// } +// func (msg MsgStartSurplusAuction) GetSignBytes() []byte { +// return sdk.MustSortJSON(msgCdc.MustMarshalJSON(msg)) +// } +// func (msg MsgStartSurplusAuction) GetSigners() []sdk.AccAddress { return []sdk.AccAddress{msg.Sender} } diff --git a/x/liquidator/types/params.go b/x/liquidator/types/params.go new file mode 100644 index 00000000..c72c8285 --- /dev/null +++ b/x/liquidator/types/params.go @@ -0,0 +1,100 @@ +package types + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/params/subspace" +) + +// Parameter keys +var ( + KeyDebtAuctionSize = []byte("DebtAuctionSize") + KeyCollateralParams = []byte("CollateralParams") +) + +// LiquidatorParams store params for the liquidator module +type LiquidatorParams struct { + DebtAuctionSize sdk.Int + //SurplusAuctionSize sdk.Int + CollateralParams []CollateralParams +} + +// NewLiquidatorParams returns a new params object for the liquidator module +func NewLiquidatorParams(debtAuctionSize sdk.Int, collateralParams []CollateralParams) LiquidatorParams { + return LiquidatorParams{ + DebtAuctionSize: debtAuctionSize, + CollateralParams: collateralParams, + } +} + +// String implements fmt.Stringer +func (p LiquidatorParams) String() string { + out := fmt.Sprintf(`Params: + Debt Auction Size: %s + Collateral Params: `, + p.DebtAuctionSize, + ) + for _, cp := range p.CollateralParams { + out += fmt.Sprintf(` + %s`, cp.String()) + } + return out +} + +// CollateralParams params storing information about each collateral for the liquidator module +type CollateralParams struct { + Denom string // Coin name of collateral type + AuctionSize sdk.Int // Max amount of collateral to sell off in any one auction. Known as lump in Maker. + // LiquidationPenalty +} + +// String implements stringer interface +func (cp CollateralParams) String() string { + return fmt.Sprintf(` + Denom: %s + AuctionSize: %s`, cp.Denom, cp.AuctionSize) +} + +// ParamKeyTable for the liquidator module +func ParamKeyTable() subspace.KeyTable { + return subspace.NewKeyTable().RegisterParamSet(&LiquidatorParams{}) +} + +// ParamSetPairs implements the ParamSet interface and returns all the key/value pairs +// pairs of liquidator module's parameters. +// nolint +func (p *LiquidatorParams) ParamSetPairs() subspace.ParamSetPairs { + return subspace.ParamSetPairs{ + subspace.NewParamSetPair(KeyDebtAuctionSize, &p.DebtAuctionSize), + subspace.NewParamSetPair(KeyCollateralParams, &p.CollateralParams), + } +} + +// DefaultParams for the liquidator module +func DefaultParams() LiquidatorParams { + return LiquidatorParams{ + DebtAuctionSize: sdk.NewInt(1000), + CollateralParams: []CollateralParams{}, + } +} + +func (p LiquidatorParams) Validate() error { + if p.DebtAuctionSize.IsNegative() { + return fmt.Errorf("debt auction size should be positive, is %s", p.DebtAuctionSize) + } + denomDupMap := make(map[string]int) + for _, cp := range p.CollateralParams { + _, found := denomDupMap[cp.Denom] + if found { + return fmt.Errorf("duplicate denom: %s", cp.Denom) + } + denomDupMap[cp.Denom] = 1 + if cp.AuctionSize.IsNegative() { + return fmt.Errorf( + "auction size for each collateral should be positive, is %s for %s", cp.AuctionSize, cp.Denom, + ) + } + } + return nil +} diff --git a/x/liquidator/types/querier.go b/x/liquidator/types/querier.go new file mode 100644 index 00000000..5fb8d6be --- /dev/null +++ b/x/liquidator/types/querier.go @@ -0,0 +1,22 @@ +package types + +import( + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/rest" +) + +const ( + QueryGetOutstandingDebt = "outstanding_debt" // Get the outstanding seized debt +) + +type SeizeAndStartCollateralAuctionRequest struct { + BaseReq rest.BaseReq `json:"base_req"` + Sender sdk.AccAddress `json:"sender"` + CdpOwner sdk.AccAddress `json:"cdp_owner"` + CollateralDenom string `json:"collateral_denom"` +} + +type StartDebtAuctionRequest struct { + BaseReq rest.BaseReq `json:"base_req"` + Sender sdk.AccAddress `json:"sender"` // TODO use baseReq.From instead? +} \ No newline at end of file diff --git a/x/liquidator/types/types.go b/x/liquidator/types/types.go new file mode 100644 index 00000000..fdf1c536 --- /dev/null +++ b/x/liquidator/types/types.go @@ -0,0 +1,30 @@ +package types + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// SeizedDebt tracks debt seized from liquidated CDPs. +type SeizedDebt struct { + Total sdk.Int // Total debt seized from CDPs. Known as Awe in maker. + SentToAuction sdk.Int // Portion of seized debt that has had a (reverse) auction was started for it. Known as Ash in maker. + // SentToAuction should always be < Total +} + +// Available gets the seized debt that has not been sent for auction. Known as Woe in maker. +func (sd SeizedDebt) Available() sdk.Int { + return sd.Total.Sub(sd.SentToAuction) +} + +// Settle reduces the amount of debt +func (sd SeizedDebt) Settle(amount sdk.Int) (SeizedDebt, sdk.Error) { + if amount.IsNegative() { + return sd, sdk.ErrInternal("tried to settle a negative amount") + } + if amount.GT(sd.Total) { + return sd, sdk.ErrInternal("tried to settle more debt than exists") + } + sd.Total = sd.Total.Sub(amount) + sd.SentToAuction = sdk.MaxInt(sd.SentToAuction.Sub(amount), sdk.ZeroInt()) + return sd, nil +} diff --git a/x/pricefeed/alias.go b/x/pricefeed/alias.go new file mode 100644 index 00000000..61be278c --- /dev/null +++ b/x/pricefeed/alias.go @@ -0,0 +1,64 @@ +// nolint +// autogenerated code using github.com/rigelrozanski/multitool +// aliases generated for the following subdirectories: +// ALIASGEN: github.com/kava-labs/kava/x/pricefeed/types/ +package pricefeed + +import ( + "github.com/kava-labs/kava/x/pricefeed/types" +) + +const ( + DefaultCodespace = types.DefaultCodespace + CodeEmptyInput = types.CodeEmptyInput + CodeExpired = types.CodeExpired + CodeInvalidPrice = types.CodeInvalidPrice + CodeInvalidAsset = types.CodeInvalidAsset + CodeInvalidOracle = types.CodeInvalidOracle + ModuleName = types.ModuleName + StoreKey = types.StoreKey + RouterKey = types.RouterKey + TypeMsgPostPrice = types.TypeMsgPostPrice + QueryCurrentPrice = types.QueryCurrentPrice + QueryRawPrices = types.QueryRawPrices + QueryAssets = types.QueryAssets +) + +var ( + // functions aliases + RegisterCodec = types.RegisterCodec + ErrEmptyInput = types.ErrEmptyInput + ErrExpired = types.ErrExpired + ErrNoValidPrice = types.ErrNoValidPrice + ErrInvalidAsset = types.ErrInvalidAsset + ErrInvalidOracle = types.ErrInvalidOracle + NewGenesisState = types.NewGenesisState + DefaultGenesisState = types.DefaultGenesisState + ValidateGenesis = types.ValidateGenesis + NewMsgPostPrice = types.NewMsgPostPrice + ParamKeyTable = types.ParamKeyTable + NewAssetParams = types.NewAssetParams + DefaultAssetParams = types.DefaultAssetParams + NewOracleParams = types.NewOracleParams + DefaultOracleParams = types.DefaultOracleParams + + // variable aliases + ModuleCdc = types.ModuleCdc + ParamStoreKeyOracles = types.ParamStoreKeyOracles + ParamStoreKeyAssets = types.ParamStoreKeyAssets +) + +type ( + GenesisState = types.GenesisState + MsgPostPrice = types.MsgPostPrice + AssetParams = types.AssetParams + OracleParams = types.OracleParams + ParamSubspace = types.ParamSubspace + QueryRawPricesResp = types.QueryRawPricesResp + QueryAssetsResp = types.QueryAssetsResp + Asset = types.Asset + Oracle = types.Oracle + CurrentPrice = types.CurrentPrice + PostedPrice = types.PostedPrice + SortDecs = types.SortDecs +) diff --git a/x/pricefeed/client/cli/query.go b/x/pricefeed/client/cli/query.go new file mode 100644 index 00000000..3de15af3 --- /dev/null +++ b/x/pricefeed/client/cli/query.go @@ -0,0 +1,93 @@ +package cli + +import ( + "fmt" + + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/codec" + "github.com/kava-labs/kava/x/pricefeed/types" + "github.com/spf13/cobra" +) + +// GetQueryCmd returns the cli query commands for this module +func GetQueryCmd(storeKey string, cdc *codec.Codec) *cobra.Command { + // Group nameservice queries under a subcommand + pricefeedQueryCmd := &cobra.Command{ + Use: types.ModuleName, + Short: "Querying commands for the pricefeed module", + DisableFlagParsing: true, + SuggestionsMinimumDistance: 2, + RunE: client.ValidateCmd, + } + + pricefeedQueryCmd.AddCommand(client.GetCommands( + GetCmdCurrentPrice(storeKey, cdc), + GetCmdRawPrices(storeKey, cdc), + GetCmdAssets(storeKey, cdc), + )...) + + return pricefeedQueryCmd +} + +// GetCmdCurrentPrice queries the current price of an asset +func GetCmdCurrentPrice(queryRoute string, cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "price [assetCode]", + Short: "get the current price of an asset", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + assetCode := args[0] + res, _, err := cliCtx.QueryWithData(fmt.Sprintf("custom/%s/price/%s", queryRoute, assetCode), nil) + if err != nil { + fmt.Printf("error when querying current price - %s", err) + fmt.Printf("could not get current price for - %s \n", assetCode) + return nil + } + var out types.CurrentPrice + cdc.MustUnmarshalJSON(res, &out) + return cliCtx.PrintOutput(out) + }, + } +} + +// GetCmdRawPrices queries the current price of an asset +func GetCmdRawPrices(queryRoute string, cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "rawprices [assetCode]", + Short: "get the raw oracle prices for an asset", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + assetCode := args[0] + res, _, err := cliCtx.QueryWithData(fmt.Sprintf("custom/%s/rawprices/%s", queryRoute, assetCode), nil) + if err != nil { + fmt.Printf("could not get raw prices for - %s \n", assetCode) + return nil + } + var out types.QueryRawPricesResp + cdc.MustUnmarshalJSON(res, &out) + return cliCtx.PrintOutput(out) + }, + } +} + +// GetCmdAssets queries list of assets in the pricefeed +func GetCmdAssets(queryRoute string, cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "assets", + Short: "get the assets in the pricefeed", + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + res, _, err := cliCtx.QueryWithData(fmt.Sprintf("custom/%s/assets", queryRoute), nil) + if err != nil { + fmt.Printf("could not get assets") + return nil + } + var out types.QueryAssetsResp + cdc.MustUnmarshalJSON(res, &out) + return cliCtx.PrintOutput(out) + }, + } +} diff --git a/x/pricefeed/client/cli/tx.go b/x/pricefeed/client/cli/tx.go new file mode 100644 index 00000000..8c505856 --- /dev/null +++ b/x/pricefeed/client/cli/tx.go @@ -0,0 +1,63 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/auth" + "github.com/cosmos/cosmos-sdk/x/auth/client/utils" + "github.com/kava-labs/kava/x/pricefeed/types" +) + +// GetTxCmd returns the transaction commands for this module +func GetTxCmd(storeKey string, cdc *codec.Codec) *cobra.Command { + pricefeedTxCmd := &cobra.Command{ + Use: types.ModuleName, + Short: "Pricefeed transactions subcommands", + DisableFlagParsing: true, + SuggestionsMinimumDistance: 2, + RunE: client.ValidateCmd, + } + + pricefeedTxCmd.AddCommand(client.PostCommands( + GetCmdPostPrice(cdc), + )...) + + return pricefeedTxCmd +} + +// GetCmdPostPrice cli command for posting prices. +func GetCmdPostPrice(cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "postprice [assetCode] [price] [expiry]", + Short: "post the latest price for a particular asset", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + txBldr := auth.NewTxBuilderFromCLI().WithTxEncoder(utils.GetTxEncoder(cdc)) + // if err := cliCtx.EnsureAccountExists(); err != nil { + // return err + // } + price, err := sdk.NewDecFromStr(args[1]) + if err != nil { + return err + } + expiry, ok := sdk.NewIntFromString(args[2]) + if !ok { + fmt.Printf("invalid expiry - %s \n", args[2]) + return nil + } + msg := types.NewMsgPostPrice(cliCtx.GetFromAddress(), args[0], price, expiry) + err = msg.ValidateBasic() + if err != nil { + return err + } + return utils.GenerateOrBroadcastMsgs(cliCtx, txBldr, []sdk.Msg{msg}) + }, + } +} diff --git a/x/pricefeed/client/rest/rest.go b/x/pricefeed/client/rest/rest.go new file mode 100644 index 00000000..ea6990a6 --- /dev/null +++ b/x/pricefeed/client/rest/rest.go @@ -0,0 +1,113 @@ +package rest + +import ( + "fmt" + "net/http" + + "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/gorilla/mux" + "github.com/kava-labs/kava/x/pricefeed/types" +) + +const ( + restName = "assetCode" +) + +type postPriceReq struct { + BaseReq rest.BaseReq `json:"base_req"` + AssetCode string `json:"asset_code"` + Price string `json:"price"` + Expiry string `json:"expiry"` +} + +// RegisterRoutes - Central function to define routes that get registered by the main application +func RegisterRoutes(cliCtx context.CLIContext, r *mux.Router, storeName string) { + r.HandleFunc(fmt.Sprintf("/%s/rawprices", storeName), postPriceHandler(cliCtx)).Methods("PUT") + r.HandleFunc(fmt.Sprintf("/%s/rawprices/{%s}", storeName, restName), getRawPricesHandler(cliCtx, storeName)).Methods("GET") + r.HandleFunc(fmt.Sprintf("/%s/currentprice/{%s}", storeName, restName), getCurrentPriceHandler(cliCtx, storeName)).Methods("GET") + r.HandleFunc(fmt.Sprintf("/%s/assets", storeName), getAssetsHandler(cliCtx, storeName)).Methods("GET") +} + +func postPriceHandler(cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req postPriceReq + + if !rest.ReadRESTReq(w, r, cliCtx.Codec, &req) { + rest.WriteErrorResponse(w, http.StatusBadRequest, "failed to parse request") + return + } + + baseReq := req.BaseReq.Sanitize() + if !baseReq.ValidateBasic(w) { + return + } + + addr, err := sdk.AccAddressFromBech32(baseReq.From) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + price, err := sdk.NewDecFromStr(req.Price) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + expiry, ok := sdk.NewIntFromString(req.Expiry) + if !ok { + rest.WriteErrorResponse(w, http.StatusBadRequest, "invalid expiry") + return + } + + // create the message + msg := types.NewMsgPostPrice(addr, req.AssetCode, price, expiry) + err = msg.ValidateBasic() + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + utils.WriteGenerateStdTxResponse(w, cliCtx, baseReq, []sdk.Msg{msg}) + } +} + +func getRawPricesHandler(cliCtx context.CLIContext, storeName string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + paramType := vars[restName] + res, _, err := cliCtx.QueryWithData(fmt.Sprintf("custom/%s/rawprices/%s", storeName, paramType), nil) + if err != nil { + rest.WriteErrorResponse(w, http.StatusNotFound, err.Error()) + return + } + rest.PostProcessResponse(w, cliCtx, res) + } +} + +func getCurrentPriceHandler(cliCtx context.CLIContext, storeName string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + paramType := vars[restName] + res, _, err := cliCtx.QueryWithData(fmt.Sprintf("custom/%s/price/%s", storeName, paramType), nil) + if err != nil { + rest.WriteErrorResponse(w, http.StatusNotFound, err.Error()) + return + } + rest.PostProcessResponse(w, cliCtx, res) + } +} + +func getAssetsHandler(cliCtx context.CLIContext, storeName string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + res, _, err := cliCtx.QueryWithData(fmt.Sprintf("custom/%s/assets/", storeName), nil) + if err != nil { + rest.WriteErrorResponse(w, http.StatusNotFound, err.Error()) + return + } + rest.PostProcessResponse(w, cliCtx, res) + } +} diff --git a/x/pricefeed/doc.go b/x/pricefeed/doc.go new file mode 100644 index 00000000..0d403145 --- /dev/null +++ b/x/pricefeed/doc.go @@ -0,0 +1,6 @@ +/* + +Package pricefeed allows a group of white-listed oracles to post price information of specific assets that are tracked by the system. For each asset, the module computes the median of all posted prices by white-listed oracles and takes that as the current price value. + +*/ +package pricefeed diff --git a/x/pricefeed/genesis.go b/x/pricefeed/genesis.go new file mode 100644 index 00000000..2dafc2b3 --- /dev/null +++ b/x/pricefeed/genesis.go @@ -0,0 +1,51 @@ +package pricefeed + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" +) + + +// InitGenesis sets distribution information for genesis. +func InitGenesis(ctx sdk.Context, keeper Keeper, data GenesisState) { + + // Set the assets and oracles from params + keeper.SetAssetParams(ctx, data.AssetParams) + keeper.SetOracleParams(ctx ,data.OracleParams) + + // Iterate through the posted prices and set them in the store + for _, pp := range data.PostedPrices { + addr, err := sdk.AccAddressFromBech32(pp.OracleAddress) + if err != nil { + panic(err) + } + _, err = keeper.SetPrice(ctx, addr, pp.AssetCode, pp.Price, pp.Expiry) + if err != nil { + panic(err) + } + } + + // Set the current price (if any) based on what's now in the store + if err := keeper.SetCurrentPrices(ctx); err != nil { + panic(err) + } +} + +// ExportGenesis returns a GenesisState for a given context and keeper. +func ExportGenesis(ctx sdk.Context, keeper Keeper) GenesisState { + + // Get the params for assets and oracles + assetParams := keeper.GetAssetParams(ctx) + oracleParams := keeper.GetOracleParams(ctx) + + var postedPrices []PostedPrice + for _, asset := range keeper.GetAssets(ctx) { + pp := keeper.GetRawPrices(ctx, asset.AssetCode) + postedPrices = append(postedPrices, pp...) + } + + return GenesisState{ + AssetParams: assetParams, + OracleParams: oracleParams, + PostedPrices: postedPrices, + } +} diff --git a/x/pricefeed/handler.go b/x/pricefeed/handler.go new file mode 100644 index 00000000..1b54d4e4 --- /dev/null +++ b/x/pricefeed/handler.go @@ -0,0 +1,50 @@ +package pricefeed + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// NewHandler handles all pricefeed type messages +func NewHandler(k Keeper) sdk.Handler { + return func(ctx sdk.Context, msg sdk.Msg) sdk.Result { + switch msg := msg.(type) { + case MsgPostPrice: + return HandleMsgPostPrice(ctx, k, msg) + default: + errMsg := fmt.Sprintf("unrecognized pricefeed message type: %T", msg) + return sdk.ErrUnknownRequest(errMsg).Result() + } + } +} + +// price feed questions: +// do proposers need to post the round in the message? If not, how do we determine the round? + +// HandleMsgPostPrice handles prices posted by oracles +func HandleMsgPostPrice( + ctx sdk.Context, + k Keeper, + msg MsgPostPrice) sdk.Result { + + // TODO cleanup message validation and errors + err := k.ValidatePostPrice(ctx, msg) + if err != nil { + return err.Result() + } + k.SetPrice(ctx, msg.From, msg.AssetCode, msg.Price, msg.Expiry) + return sdk.Result{} +} + +// EndBlocker updates the current pricefeed +func EndBlocker(ctx sdk.Context, k Keeper) { + // TODO val_state_change.go is relevant if we want to rotate the oracle set + + // Running in the end blocker ensures that prices will update at most once per block, + // which seems preferable to having state storage values change in response to multiple transactions + // which occur during a block + //TODO use an iterator and update the prices for all assets in the store + k.SetCurrentPrices(ctx) + return +} diff --git a/x/pricefeed/keeper.go b/x/pricefeed/keeper.go new file mode 100644 index 00000000..40473812 --- /dev/null +++ b/x/pricefeed/keeper.go @@ -0,0 +1,280 @@ +package pricefeed + +import ( + "sort" + + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// TODO refactor constants to app.go +const ( + // QuerierRoute is the querier route for gov + QuerierRoute = ModuleName + + // Parameter store default namestore + DefaultParamspace = ModuleName + + // Store prefix for the raw pricefeed of an asset + RawPriceFeedPrefix = StoreKey + ":raw:" + + // Store prefix for the current price of an asset + CurrentPricePrefix = StoreKey + ":currentprice:" + + // Store Prefix for the assets in the pricefeed system + AssetPrefix = StoreKey + ":assets" + + // OraclePrefix store prefix for the oracle accounts + OraclePrefix = StoreKey + ":oracles" +) + +// Keeper struct for pricefeed module +type Keeper struct { + // The reference to the Paramstore to get and set pricefeed specific params + paramSpace ParamSubspace + // The keys used to access the stores from Context + storeKey sdk.StoreKey + // Codec for binary encoding/decoding + cdc *codec.Codec + // Reserved codespace + codespace sdk.CodespaceType +} + +// NewKeeper returns a new keeper for the pricefeed module. It handles: +// - adding oracles +// - adding/removing assets from the pricefeed +func NewKeeper( + storeKey sdk.StoreKey, cdc *codec.Codec, paramSpace ParamSubspace, codespace sdk.CodespaceType, +) Keeper { + return Keeper{ + paramSpace: paramSpace, + storeKey: storeKey, + cdc: cdc, + codespace: codespace, + } +} + +// // AddOracle adds an Oracle to the store +// func (k Keeper) AddOracle(ctx sdk.Context, address string) { + +// oracles := k.GetOracles(ctx) +// oracles = append(oracles, Oracle{OracleAddress: address}) +// store := ctx.KVStore(k.storeKey) +// store.Set( +// []byte(OraclePrefix), k.cdc.MustMarshalBinaryBare(oracles), +// ) +// } + +// // AddAsset adds an asset to the store +// func (k Keeper) AddAsset( +// ctx sdk.Context, +// assetCode string, +// desc string, +// ) { +// assets := k.GetAssets(ctx) +// assets = append(assets, Asset{AssetCode: assetCode, Description: desc}) +// store := ctx.KVStore(k.storeKey) +// store.Set( +// []byte(AssetPrefix), k.cdc.MustMarshalBinaryBare(assets), +// ) +// } + +func (k Keeper) SetAssetParams(ctx sdk.Context, ap AssetParams) { + k.paramSpace.Set(ctx, ParamStoreKeyAssets, &ap) +} + +func (k Keeper) SetOracleParams(ctx sdk.Context, op OracleParams) { + k.paramSpace.Set(ctx, ParamStoreKeyOracles, &op) +} + +// SetPrice updates the posted price for a specific oracle +func (k Keeper) SetPrice( + ctx sdk.Context, + oracle sdk.AccAddress, + assetCode string, + price sdk.Dec, + expiry sdk.Int) (PostedPrice, sdk.Error) { + // If the expiry is less than or equal to the current blockheight, we consider the price valid + if expiry.GTE(sdk.NewInt(ctx.BlockHeight())) { + store := ctx.KVStore(k.storeKey) + prices := k.GetRawPrices(ctx, assetCode) + var index int + found := false + for i := range prices { + if prices[i].OracleAddress == oracle.String() { + index = i + found = true + break + } + } + // set the price for that particular oracle + if found { + prices[index] = PostedPrice{AssetCode: assetCode, OracleAddress: oracle.String(), Price: price, Expiry: expiry} + } else { + prices = append(prices, PostedPrice{ + assetCode, oracle.String(), price, expiry, + }) + index = len(prices) - 1 + } + + store.Set( + []byte(RawPriceFeedPrefix+assetCode), k.cdc.MustMarshalBinaryBare(prices), + ) + return prices[index], nil + } + return PostedPrice{}, ErrExpired(k.codespace) + +} + +// SetCurrentPrices updates the price of an asset to the meadian of all valid oracle inputs +func (k Keeper) SetCurrentPrices(ctx sdk.Context) sdk.Error { + assets := k.GetAssets(ctx) + for _, v := range assets { + assetCode := v.AssetCode + prices := k.GetRawPrices(ctx, assetCode) + var notExpiredPrices []CurrentPrice + // filter out expired prices + for _, v := range prices { + if v.Expiry.GTE(sdk.NewInt(ctx.BlockHeight())) { + notExpiredPrices = append(notExpiredPrices, CurrentPrice{ + AssetCode: v.AssetCode, + Price: v.Price, + Expiry: v.Expiry, + }) + } + } + l := len(notExpiredPrices) + var medianPrice sdk.Dec + var expiry sdk.Int + // TODO make threshold for acceptance (ie. require 51% of oracles to have posted valid prices + if l == 0 { + // Error if there are no valid prices in the raw pricefeed + return ErrNoValidPrice(k.codespace) + } else if l == 1 { + // Return immediately if there's only one price + medianPrice = notExpiredPrices[0].Price + expiry = notExpiredPrices[0].Expiry + } else { + // sort the prices + sort.Slice(notExpiredPrices, func(i, j int) bool { + return notExpiredPrices[i].Price.LT(notExpiredPrices[j].Price) + }) + // If there's an even number of prices + if l%2 == 0 { + // TODO make sure this is safe. + // Since it's a price and not a balance, division with precision loss is OK. + price1 := notExpiredPrices[l/2-1].Price + price2 := notExpiredPrices[l/2].Price + sum := price1.Add(price2) + divsor, _ := sdk.NewDecFromStr("2") + medianPrice = sum.Quo(divsor) + // TODO Check if safe, makes sense + // Takes the average of the two expiries rounded down to the nearest Int. + expiry = notExpiredPrices[l/2-1].Expiry.Add(notExpiredPrices[l/2].Expiry).Quo(sdk.NewInt(2)) + } else { + // integer division, so we'll get an integer back, rounded down + medianPrice = notExpiredPrices[l/2].Price + expiry = notExpiredPrices[l/2].Expiry + } + } + + store := ctx.KVStore(k.storeKey) + currentPrice := CurrentPrice{ + AssetCode: assetCode, + Price: medianPrice, + Expiry: expiry, + } + store.Set( + []byte(CurrentPricePrefix+assetCode), k.cdc.MustMarshalBinaryBare(currentPrice), + ) + } + + return nil +} + +func (k Keeper) GetOracleParams(ctx sdk.Context) OracleParams { + var op OracleParams + k.paramSpace.Get(ctx, ParamStoreKeyOracles, &op) + return op +} + +// GetOracles returns the oracles in the pricefeed store +func (k Keeper) GetOracles(ctx sdk.Context) []Oracle { + var op OracleParams + k.paramSpace.Get(ctx, ParamStoreKeyOracles, &op) + return op.Oracles +} + +func (k Keeper) GetAssetParams(ctx sdk.Context) AssetParams { + var ap AssetParams + k.paramSpace.Get(ctx, ParamStoreKeyAssets, &ap) + return ap +} + +// GetAssets returns the assets in the pricefeed store +func (k Keeper) GetAssets(ctx sdk.Context) []Asset { + var ap AssetParams + k.paramSpace.Get(ctx, ParamStoreKeyAssets, &ap) + return ap.Assets +} + +// GetAsset returns the asset if it is in the pricefeed system +func (k Keeper) GetAsset(ctx sdk.Context, assetCode string) (Asset, bool) { + assets := k.GetAssets(ctx) + + for i := range assets { + if assets[i].AssetCode == assetCode { + return assets[i], true + } + } + return Asset{}, false + +} + +// GetOracle returns the oracle address as a string if it is in the pricefeed store +func (k Keeper) GetOracle(ctx sdk.Context, oracle string) (Oracle, bool) { + oracles := k.GetOracles(ctx) + + for i := range oracles { + if oracles[i].OracleAddress == oracle { + return oracles[i], true + } + } + return Oracle{}, false + +} + +// GetCurrentPrice fetches the current median price of all oracles for a specific asset +func (k Keeper) GetCurrentPrice(ctx sdk.Context, assetCode string) CurrentPrice { + store := ctx.KVStore(k.storeKey) + bz := store.Get([]byte(CurrentPricePrefix + assetCode)) + // TODO panic or return error if not found + var price CurrentPrice + k.cdc.MustUnmarshalBinaryBare(bz, &price) + return price +} + +// GetRawPrices fetches the set of all prices posted by oracles for an asset +func (k Keeper) GetRawPrices(ctx sdk.Context, assetCode string) []PostedPrice { + store := ctx.KVStore(k.storeKey) + bz := store.Get([]byte(RawPriceFeedPrefix + assetCode)) + var prices []PostedPrice + k.cdc.MustUnmarshalBinaryBare(bz, &prices) + return prices +} + +// ValidatePostPrice makes sure the person posting the price is an oracle +func (k Keeper) ValidatePostPrice(ctx sdk.Context, msg MsgPostPrice) sdk.Error { + // TODO implement this + + _, assetFound := k.GetAsset(ctx, msg.AssetCode) + if !assetFound { + return ErrInvalidAsset(k.codespace) + } + _, oracleFound := k.GetOracle(ctx, msg.From.String()) + if !oracleFound { + return ErrInvalidOracle(k.codespace) + } + + return nil +} diff --git a/x/pricefeed/keeper_test.go b/x/pricefeed/keeper_test.go new file mode 100644 index 00000000..9ee5f857 --- /dev/null +++ b/x/pricefeed/keeper_test.go @@ -0,0 +1,124 @@ +package pricefeed + +import ( + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/require" + abci "github.com/tendermint/tendermint/abci/types" +) + +// TestKeeper_SetGetAsset tests adding assets to the pricefeed, getting assets from the store +func TestKeeper_SetGetAsset(t *testing.T) { + helper := getMockApp(t, 0, GenesisState{}, nil) + header := abci.Header{Height: helper.mApp.LastBlockHeight() + 1} + helper.mApp.BeginBlock(abci.RequestBeginBlock{Header: header}) + ctx := helper.mApp.BaseApp.NewContext(false, abci.Header{}) + ap := AssetParams{ + Assets: []Asset{Asset{AssetCode: "tst", Description: "the future of finance"}}, + } + helper.keeper.SetAssetParams(ctx, ap) + assets := helper.keeper.GetAssets(ctx) + require.Equal(t, len(assets), 1) + require.Equal(t, assets[0].AssetCode, "tst") + + _, found := helper.keeper.GetAsset(ctx, "tst") + require.Equal(t, found, true) + + ap = AssetParams{ + Assets: []Asset{ + Asset{AssetCode: "tst", Description: "the future of finance"}, + Asset{AssetCode: "tst2", Description: "the future of finance"}}, + } + helper.keeper.SetAssetParams(ctx, ap) + assets = helper.keeper.GetAssets(ctx) + require.Equal(t, len(assets), 2) + require.Equal(t, assets[0].AssetCode, "tst") + require.Equal(t, assets[1].AssetCode, "tst2") + + _, found = helper.keeper.GetAsset(ctx, "nan") + require.Equal(t, found, false) +} + +// TestKeeper_GetSetPrice Test Posting the price by an oracle +func TestKeeper_GetSetPrice(t *testing.T) { + helper := getMockApp(t, 2, GenesisState{}, nil) + header := abci.Header{Height: helper.mApp.LastBlockHeight() + 1} + helper.mApp.BeginBlock(abci.RequestBeginBlock{Header: header}) + ctx := helper.mApp.BaseApp.NewContext(false, abci.Header{}) + ap := AssetParams{ + Assets: []Asset{Asset{AssetCode: "tst", Description: "the future of finance"}}, + } + helper.keeper.SetAssetParams(ctx, ap) + // Set price by oracle 1 + _, err := helper.keeper.SetPrice( + ctx, helper.addrs[0], "tst", + sdk.MustNewDecFromStr("0.33"), + sdk.NewInt(10)) + require.NoError(t, err) + // Get raw prices + rawPrices := helper.keeper.GetRawPrices(ctx, "tst") + require.Equal(t, len(rawPrices), 1) + require.Equal(t, rawPrices[0].Price.Equal(sdk.MustNewDecFromStr("0.33")), true) + // Set price by oracle 2 + _, err = helper.keeper.SetPrice( + ctx, helper.addrs[1], "tst", + sdk.MustNewDecFromStr("0.35"), + sdk.NewInt(10)) + require.NoError(t, err) + + rawPrices = helper.keeper.GetRawPrices(ctx, "tst") + require.Equal(t, len(rawPrices), 2) + require.Equal(t, rawPrices[1].Price.Equal(sdk.MustNewDecFromStr("0.35")), true) + + // Update Price by Oracle 1 + _, err = helper.keeper.SetPrice( + ctx, helper.addrs[0], "tst", + sdk.MustNewDecFromStr("0.37"), + sdk.NewInt(10)) + require.NoError(t, err) + rawPrices = helper.keeper.GetRawPrices(ctx, "tst") + require.Equal(t, rawPrices[0].Price.Equal(sdk.MustNewDecFromStr("0.37")), true) +} + +// TestKeeper_GetSetCurrentPrice Test Setting the median price of an Asset +func TestKeeper_GetSetCurrentPrice(t *testing.T) { + helper := getMockApp(t, 4, GenesisState{}, nil) + header := abci.Header{Height: helper.mApp.LastBlockHeight() + 1} + helper.mApp.BeginBlock(abci.RequestBeginBlock{Header: header}) + ctx := helper.mApp.BaseApp.NewContext(false, abci.Header{}) + // Odd number of oracles + ap := AssetParams{ + Assets: []Asset{Asset{AssetCode: "tst", Description: "the future of finance"}}, + } + helper.keeper.SetAssetParams(ctx, ap) + helper.keeper.SetPrice( + ctx, helper.addrs[0], "tst", + sdk.MustNewDecFromStr("0.33"), + sdk.NewInt(10)) + helper.keeper.SetPrice( + ctx, helper.addrs[1], "tst", + sdk.MustNewDecFromStr("0.35"), + sdk.NewInt(10)) + helper.keeper.SetPrice( + ctx, helper.addrs[2], "tst", + sdk.MustNewDecFromStr("0.34"), + sdk.NewInt(10)) + // Set current price + err := helper.keeper.SetCurrentPrices(ctx) + require.NoError(t, err) + // Get Current price + price := helper.keeper.GetCurrentPrice(ctx, "tst") + require.Equal(t, price.Price.Equal(sdk.MustNewDecFromStr("0.34")), true) + + // Even number of oracles + helper.keeper.SetPrice( + ctx, helper.addrs[3], "tst", + sdk.MustNewDecFromStr("0.36"), + sdk.NewInt(10)) + err = helper.keeper.SetCurrentPrices(ctx) + require.NoError(t, err) + price = helper.keeper.GetCurrentPrice(ctx, "tst") + require.Equal(t, price.Price.Equal(sdk.MustNewDecFromStr("0.345")), true) + +} diff --git a/x/pricefeed/module.go b/x/pricefeed/module.go new file mode 100644 index 00000000..7b52be5a --- /dev/null +++ b/x/pricefeed/module.go @@ -0,0 +1,129 @@ +package pricefeed + +import ( + "encoding/json" + + "github.com/gorilla/mux" + "github.com/spf13/cobra" + + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/codec" + "github.com/cosmos/cosmos-sdk/types/module" + "github.com/kava-labs/kava/x/pricefeed/client/cli" + "github.com/kava-labs/kava/x/pricefeed/client/rest" + + sdk "github.com/cosmos/cosmos-sdk/types" + abci "github.com/tendermint/tendermint/abci/types" +) + +var ( + _ module.AppModule = AppModule{} + _ module.AppModuleBasic = AppModuleBasic{} +) +// AppModuleBasic app module basics object +type AppModuleBasic struct{} + +// Name get module name +func (AppModuleBasic) Name() string { + return ModuleName +} + +// RegisterCodec register module codec +func (AppModuleBasic) RegisterCodec(cdc *codec.Codec) { + RegisterCodec(cdc) +} + +// DefaultGenesis default genesis state +func (AppModuleBasic) DefaultGenesis() json.RawMessage { + return ModuleCdc.MustMarshalJSON(DefaultGenesisState()) +} + +// ValidateGenesis module validate genesis +func (AppModuleBasic) ValidateGenesis(bz json.RawMessage) error { + var data GenesisState + err := ModuleCdc.UnmarshalJSON(bz, &data) + if err != nil { + return err + } + return ValidateGenesis(data) +} + +// RegisterRESTRoutes register rest routes +func (AppModuleBasic) RegisterRESTRoutes(ctx context.CLIContext, rtr *mux.Router) { + rest.RegisterRoutes(ctx, rtr, StoreKey) +} + +// GetTxCmd get the root tx command of this module +func (AppModuleBasic) GetTxCmd(cdc *codec.Codec) *cobra.Command { + return cli.GetTxCmd(StoreKey, cdc) +} + +// GetQueryCmd get the root query command of this module +func (AppModuleBasic) GetQueryCmd(cdc *codec.Codec) *cobra.Command { + return cli.GetQueryCmd(StoreKey, cdc) +} + +// AppModule app module type +type AppModule struct { + AppModuleBasic + keeper Keeper +} + +// NewAppModule creates a new AppModule object +func NewAppModule(keeper Keeper) AppModule { + return AppModule{ + AppModuleBasic: AppModuleBasic{}, + keeper: keeper, + } +} + +// Name module name +func (AppModule) Name() string { + return ModuleName +} + +// RegisterInvariants register module invariants +func (AppModule) RegisterInvariants(_ sdk.InvariantRegistry) {} + +// Route module message route name +func (AppModule) Route() string { + return ModuleName +} + +// NewHandler module handler +func (am AppModule) NewHandler() sdk.Handler { + return NewHandler(am.keeper) +} + +// QuerierRoute module querier route name +func (AppModule) QuerierRoute() string { + return ModuleName +} + +// NewQuerierHandler module querier +func (am AppModule) NewQuerierHandler() sdk.Querier { + return NewQuerier(am.keeper) +} + +// BeginBlock module begin-block +func (AppModule) BeginBlock(_ sdk.Context, _ abci.RequestBeginBlock) {} + +// EndBlock module end-block +func (am AppModule) EndBlock(ctx sdk.Context, _ abci.RequestEndBlock) []abci.ValidatorUpdate { + EndBlocker(ctx, am.keeper) + return []abci.ValidatorUpdate{} +} + +// InitGenesis module init-genesis +func (am AppModule) InitGenesis(ctx sdk.Context, data json.RawMessage) []abci.ValidatorUpdate { + var genesisState GenesisState + ModuleCdc.MustUnmarshalJSON(data, &genesisState) + InitGenesis(ctx, am.keeper, genesisState) + return []abci.ValidatorUpdate{} +} + +// ExportGenesis module export genesis +func (am AppModule) ExportGenesis(ctx sdk.Context) json.RawMessage { + gs := ExportGenesis(ctx, am.keeper) + return ModuleCdc.MustMarshalJSON(gs) +} diff --git a/x/pricefeed/querier.go b/x/pricefeed/querier.go new file mode 100644 index 00000000..ecc7936c --- /dev/null +++ b/x/pricefeed/querier.go @@ -0,0 +1,74 @@ +package pricefeed + +import ( + "github.com/cosmos/cosmos-sdk/codec" + + abci "github.com/tendermint/tendermint/abci/types" + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// NewQuerier is the module level router for state queries +func NewQuerier(keeper Keeper) sdk.Querier { + return func(ctx sdk.Context, path []string, req abci.RequestQuery) (res []byte, err sdk.Error) { + switch path[0] { + case QueryCurrentPrice: + return queryCurrentPrice(ctx, path[1:], req, keeper) + case QueryRawPrices: + return queryRawPrices(ctx, path[1:], req, keeper) + case QueryAssets: + return queryAssets(ctx, req, keeper) + default: + return nil, sdk.ErrUnknownRequest("unknown pricefeed query endpoint") + } + } + +} + +func queryCurrentPrice(ctx sdk.Context, path []string, req abci.RequestQuery, keeper Keeper) (res []byte, err sdk.Error) { + assetCode := path[0] + _, found := keeper.GetAsset(ctx, assetCode) + if !found { + return []byte{}, sdk.ErrUnknownRequest("asset not found") + } + currentPrice := keeper.GetCurrentPrice(ctx, assetCode) + + bz, err2 := codec.MarshalJSONIndent(keeper.cdc, currentPrice) + if err2 != nil { + panic("could not marshal result to JSON") + } + + return bz, nil +} + +func queryRawPrices(ctx sdk.Context, path []string, req abci.RequestQuery, keeper Keeper) (res []byte, err sdk.Error) { + var priceList QueryRawPricesResp + assetCode := path[0] + _, found := keeper.GetAsset(ctx, assetCode) + if !found { + return []byte{}, sdk.ErrUnknownRequest("asset not found") + } + rawPrices := keeper.GetRawPrices(ctx, assetCode) + for _, price := range rawPrices { + priceList = append(priceList, price.String()) + } + bz, err2 := codec.MarshalJSONIndent(keeper.cdc, priceList) + if err2 != nil { + panic("could not marshal result to JSON") + } + + return bz, nil +} + +func queryAssets(ctx sdk.Context, req abci.RequestQuery, keeper Keeper) (res []byte, err sdk.Error) { + var assetList QueryAssetsResp + assets := keeper.GetAssets(ctx) + for _, asset := range assets { + assetList = append(assetList, asset.String()) + } + bz, err2 := codec.MarshalJSONIndent(keeper.cdc, assetList) + if err2 != nil { + panic("could not marshal result to JSON") + } + + return bz, nil +} \ No newline at end of file diff --git a/x/pricefeed/test_common.go b/x/pricefeed/test_common.go new file mode 100644 index 00000000..5fea3df1 --- /dev/null +++ b/x/pricefeed/test_common.go @@ -0,0 +1,57 @@ +package pricefeed + +import ( + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + authexported "github.com/cosmos/cosmos-sdk/x/auth/exported" + "github.com/cosmos/cosmos-sdk/x/mock" + "github.com/stretchr/testify/require" + abci "github.com/tendermint/tendermint/abci/types" + "github.com/tendermint/tendermint/crypto" +) + +type testHelper struct { + mApp *mock.App + keeper Keeper + addrs []sdk.AccAddress + pubKeys []crypto.PubKey + privKeys []crypto.PrivKey +} + +func getMockApp(t *testing.T, numGenAccs int, genState GenesisState, genAccs []authexported.Account) testHelper { + mApp := mock.NewApp() + RegisterCodec(mApp.Cdc) + keyPricefeed := sdk.NewKVStoreKey(StoreKey) + pk := mApp.ParamsKeeper + keeper := NewKeeper(keyPricefeed, mApp.Cdc, pk.Subspace(DefaultParamspace).WithKeyTable(ParamKeyTable()), DefaultCodespace) + + // Register routes + mApp.Router().AddRoute(RouterKey, NewHandler(keeper)) + mApp.SetEndBlocker(getEndBlocker(keeper)) + + require.NoError(t, mApp.CompleteSetup(keyPricefeed)) + + valTokens := sdk.TokensFromConsensusPower(42) + var ( + addrs []sdk.AccAddress + pubKeys []crypto.PubKey + privKeys []crypto.PrivKey + ) + + if genAccs == nil || len(genAccs) == 0 { + genAccs, addrs, pubKeys, privKeys = mock.CreateGenAccounts(numGenAccs, + sdk.NewCoins(sdk.NewCoin(sdk.DefaultBondDenom, valTokens))) + } + + mock.SetGenesis(mApp, genAccs) + return testHelper{mApp, keeper, addrs, pubKeys, privKeys} +} + +// gov and staking endblocker +func getEndBlocker(keeper Keeper) sdk.EndBlocker { + return func(ctx sdk.Context, req abci.RequestEndBlock) abci.ResponseEndBlock { + EndBlocker(ctx, keeper) + return abci.ResponseEndBlock{} + } +} diff --git a/x/pricefeed/types/codec.go b/x/pricefeed/types/codec.go new file mode 100644 index 00000000..cd0437e3 --- /dev/null +++ b/x/pricefeed/types/codec.go @@ -0,0 +1,16 @@ +package types + +import ( + "github.com/cosmos/cosmos-sdk/codec" +) + +var ModuleCdc = codec.New() + +func init() { + RegisterCodec(ModuleCdc) +} + +// RegisterCode registers concrete types on the Amino code +func RegisterCodec(cdc *codec.Codec) { + cdc.RegisterConcrete(MsgPostPrice{}, "pricefeed/MsgPostPrice", nil) +} diff --git a/x/pricefeed/types/errors.go b/x/pricefeed/types/errors.go new file mode 100644 index 00000000..7a7c4548 --- /dev/null +++ b/x/pricefeed/types/errors.go @@ -0,0 +1,48 @@ +package types + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +const ( + // DefaultCodespace codespace for the module + DefaultCodespace sdk.CodespaceType = ModuleName + + // CodeEmptyInput error code for empty input errors + CodeEmptyInput sdk.CodeType = 1 + // CodeExpired error code for expired prices + CodeExpired sdk.CodeType = 2 + // CodeInvalidPrice error code for all input prices expired + CodeInvalidPrice sdk.CodeType = 3 + // CodeInvalidAsset error code for invalid asset + CodeInvalidAsset sdk.CodeType = 4 + // CodeInvalidOracle error code for invalid oracle + CodeInvalidOracle sdk.CodeType = 5 +) + +// ErrEmptyInput Error constructor +func ErrEmptyInput(codespace sdk.CodespaceType) sdk.Error { + return sdk.NewError(codespace, CodeEmptyInput, fmt.Sprintf("Input must not be empty.")) +} + +// ErrExpired Error constructor for posted price messages with expired price +func ErrExpired(codespace sdk.CodespaceType) sdk.Error { + return sdk.NewError(codespace, CodeExpired, fmt.Sprintf("Price is expired.")) +} + +// ErrNoValidPrice Error constructor for posted price messages with expired price +func ErrNoValidPrice(codespace sdk.CodespaceType) sdk.Error { + return sdk.NewError(codespace, CodeInvalidPrice, fmt.Sprintf("All input prices are expired.")) +} + +// ErrInvalidAsset Error constructor for posted price messages for invalid assets +func ErrInvalidAsset(codespace sdk.CodespaceType) sdk.Error { + return sdk.NewError(codespace, CodeInvalidAsset, fmt.Sprintf("Asset code does not exist.")) +} + +// ErrInvalidOracle Error constructor for posted price messages for invalid oracles +func ErrInvalidOracle(codespace sdk.CodespaceType) sdk.Error { + return sdk.NewError(codespace, CodeInvalidOracle, fmt.Sprintf("Oracle does not exist or not authorized.")) +} diff --git a/x/pricefeed/types/genesis.go b/x/pricefeed/types/genesis.go new file mode 100644 index 00000000..d798e6d8 --- /dev/null +++ b/x/pricefeed/types/genesis.go @@ -0,0 +1,63 @@ +package types + +import ( + "bytes" + "fmt" +) + +// GenesisState - pricefeed state that must be provided at genesis +type GenesisState struct { + AssetParams AssetParams `json:"asset_params" yaml:"asset_params"` + OracleParams OracleParams `json:"oracle_params" yaml:"oracle_params"` + PostedPrices []PostedPrice `json:"posted_prices" yaml:"posted_prices"` +} + +// NewGenesisState creates a new genesis state for the pricefeed module +func NewGenesisState(ap AssetParams, op OracleParams, pp []PostedPrice) GenesisState { + return GenesisState{ + AssetParams: ap, + OracleParams: op, + PostedPrices: pp, + } +} + +// DefaultGenesisState defines default GenesisState for pricefeed +func DefaultGenesisState() GenesisState { + return NewGenesisState( + DefaultAssetParams(), + DefaultOracleParams(), + []PostedPrice{}, + ) +} + +// Equal checks whether two gov GenesisState structs are equivalent +func (data GenesisState) Equal(data2 GenesisState) bool { + b1 := ModuleCdc.MustMarshalBinaryBare(data) + b2 := ModuleCdc.MustMarshalBinaryBare(data2) + return bytes.Equal(b1, b2) +} + +// IsEmpty returns true if a GenesisState is empty +func (data GenesisState) IsEmpty() bool { + return data.Equal(GenesisState{}) +} + +// ValidateGenesis performs basic validation of genesis data returning an +// error for any failed validation criteria. +func ValidateGenesis(data GenesisState) error { + // iterate over assets and verify them + for _, asset := range data.AssetParams.Assets { + if asset.AssetCode == "" { + return fmt.Errorf("invalid asset: %s. missing asset code", asset.String()) + } + } + + // iterate over oracles and verify them + for _, oracle := range data.OracleParams.Oracles { + if oracle.OracleAddress == "" { + return fmt.Errorf("invalid oracle: %s. missing oracle address", oracle.String()) + } + } + + return nil +} diff --git a/x/pricefeed/types/key.go b/x/pricefeed/types/key.go new file mode 100644 index 00000000..e39d2d56 --- /dev/null +++ b/x/pricefeed/types/key.go @@ -0,0 +1,12 @@ +package types + +const ( + // ModuleName The name that will be used throughout the module + ModuleName = "pricefeed" + + // StoreKey Top level store key where all module items will be stored + StoreKey = ModuleName + + // RouterKey Top level router key + RouterKey = ModuleName +) diff --git a/x/pricefeed/types/msgs.go b/x/pricefeed/types/msgs.go new file mode 100644 index 00000000..3aa23ece --- /dev/null +++ b/x/pricefeed/types/msgs.go @@ -0,0 +1,68 @@ +package types + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" +) + +const ( + // TypeMsgPostPrice type of PostPrice msg + TypeMsgPostPrice = "post_price" +) + +// MsgPostPrice struct representing a posted price message. +// Used by oracles to input prices to the pricefeed +type MsgPostPrice struct { + From sdk.AccAddress // client that sent in this address + AssetCode string // asset code used by exchanges/api + Price sdk.Dec // price in decimal (max precision 18) + Expiry sdk.Int // block height +} + +// NewMsgPostPrice creates a new post price msg +func NewMsgPostPrice( + from sdk.AccAddress, + assetCode string, + price sdk.Dec, + expiry sdk.Int) MsgPostPrice { + return MsgPostPrice{ + From: from, + AssetCode: assetCode, + Price: price, + Expiry: expiry, + } +} + +// Route Implements Msg. +func (msg MsgPostPrice) Route() string { return RouterKey } + +// Type Implements Msg +func (msg MsgPostPrice) Type() string { return TypeMsgPostPrice } + +// GetSignBytes Implements Msg. +func (msg MsgPostPrice) GetSignBytes() []byte { + bz := ModuleCdc.MustMarshalJSON(msg) + return sdk.MustSortJSON(bz) +} + +// GetSigners Implements Msg. +func (msg MsgPostPrice) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{msg.From} +} + +// ValidateBasic does a simple validation check that doesn't require access to any other information. +func (msg MsgPostPrice) ValidateBasic() sdk.Error { + if msg.From.Empty() { + return sdk.ErrInternal("invalid (empty) bidder address") + } + if len(msg.AssetCode) == 0 { + return sdk.ErrInternal("invalid (empty) asset code") + } + if msg.Price.LT(sdk.ZeroDec()) { + return sdk.ErrInternal("invalid (negative) price") + } + if msg.Expiry.LT(sdk.ZeroInt()) { + return sdk.ErrInternal("invalid (negative) expiry") + } + // TODO check coin denoms + return nil +} diff --git a/x/pricefeed/types/msgs_test.go b/x/pricefeed/types/msgs_test.go new file mode 100644 index 00000000..73857091 --- /dev/null +++ b/x/pricefeed/types/msgs_test.go @@ -0,0 +1,40 @@ +package types + +import ( + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/require" +) + +func TestMsgPlaceBid_ValidateBasic(t *testing.T) { + addr := sdk.AccAddress([]byte("someName")) + // oracles := []Oracle{Oracle{ + // OracleAddress: addr.String(), + // }} + price, _ := sdk.NewDecFromStr("0.3005") + expiry, _ := sdk.NewIntFromString("10") + negativeExpiry, _ := sdk.NewIntFromString("-3") + negativePrice, _ := sdk.NewDecFromStr("-3.05") + + tests := []struct { + name string + msg MsgPostPrice + expectPass bool + }{ + {"normal", MsgPostPrice{addr, "xrp", price, expiry}, true}, + {"emptyAddr", MsgPostPrice{sdk.AccAddress{}, "xrp", price, expiry}, false}, + {"emptyAsset", MsgPostPrice{addr, "", price, expiry}, false}, + {"negativePrice", MsgPostPrice{addr, "xrp", negativePrice, expiry}, false}, + {"negativeExpiry", MsgPostPrice{addr, "xrp", price, negativeExpiry}, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.expectPass { + require.Nil(t, tc.msg.ValidateBasic()) + } else { + require.NotNil(t, tc.msg.ValidateBasic()) + } + }) + } +} diff --git a/x/pricefeed/types/params.go b/x/pricefeed/types/params.go new file mode 100644 index 00000000..a3d86768 --- /dev/null +++ b/x/pricefeed/types/params.go @@ -0,0 +1,85 @@ +package types + +import ( + "fmt" + "strings" + + sdk "github.com/cosmos/cosmos-sdk/types" + params "github.com/cosmos/cosmos-sdk/x/params/subspace" +) + +// Parameter store key +var ( + // ParamStoreKeyOracles Param store key for oracles + ParamStoreKeyOracles = []byte("oracles") + // ParamStoreKeyAssets Param store key for assets + ParamStoreKeyAssets = []byte("assets") +) + +// ParamKeyTable Key declaration for parameters +func ParamKeyTable() params.KeyTable { + return params.NewKeyTable( + ParamStoreKeyOracles, OracleParams{}, + ParamStoreKeyAssets, AssetParams{}, + ) +} + +// AssetParams params for assets. Can be altered via governance +type AssetParams struct { + Assets []Asset `json:"assets,omitempty" yaml:"assets,omitempty"` // Array containing the assets supported by the pricefeed +} + +// NewAssetParams creates a new AssetParams object +func NewAssetParams(assets []Asset) AssetParams { + return AssetParams{ + Assets: assets, + } +} + +// DefaultAssetParams default params for assets +func DefaultAssetParams() AssetParams { + return NewAssetParams([]Asset{}) +} + +// implements fmt.stringer +func (ap AssetParams) String() string { + var assetListString []string + for _, asset := range ap.Assets { + assetListString = append(assetListString, asset.String()) + } + return strings.TrimSpace(fmt.Sprintf(`Asset Params: + Assets: %s\`, strings.Join(assetListString, ", "))) +} + +// OracleParams params for assets. Can be altered via governance +type OracleParams struct { + Oracles []Oracle `json:"oracles,omitempty" yaml:"oracles,omitempty"` // Array containing the oracles supported by the pricefeed +} + +// NewOracleParams creates a new OracleParams object +func NewOracleParams(oracles []Oracle) OracleParams { + return OracleParams{ + Oracles: oracles, + } +} + +// DefaultOracleParams default params for assets +func DefaultOracleParams() OracleParams { + return NewOracleParams([]Oracle{}) +} + +// implements fmt.stringer +func (op OracleParams) String() string { + var oracleListString []string + for _, oracle := range op.Oracles { + oracleListString = append(oracleListString, oracle.String()) + } + return strings.TrimSpace(fmt.Sprintf(`Oracle Params: + Oracles: %s\`, strings.Join(oracleListString, ", "))) +} + +// ParamSubspace defines the expected Subspace interface for parameters +type ParamSubspace interface { + Get(ctx sdk.Context, key []byte, ptr interface{}) + Set(ctx sdk.Context, key []byte, param interface{}) +} diff --git a/x/pricefeed/types/querier.go b/x/pricefeed/types/querier.go new file mode 100644 index 00000000..7439c362 --- /dev/null +++ b/x/pricefeed/types/querier.go @@ -0,0 +1,35 @@ +package types + +import ( + "strings" +) + +// price Takes an [assetcode] and returns CurrentPrice for that asset +// pricefeed Takes an [assetcode] and returns the raw []PostedPrice for that asset +// assets Returns []Assets in the pricefeed system + +const ( + // QueryCurrentPrice command for current price queries + QueryCurrentPrice = "price" + // QueryRawPrices command for raw price queries + QueryRawPrices = "rawprices" + // QueryAssets command for assets query + QueryAssets = "assets" +) + +// QueryRawPricesResp response to a rawprice query +type QueryRawPricesResp []string + +// implement fmt.Stringer +func (n QueryRawPricesResp) String() string { + return strings.Join(n[:], "\n") +} + +// QueryAssetsResp response to a assets query +type QueryAssetsResp []string + +// implement fmt.Stringer +func (n QueryAssetsResp) String() string { + return strings.Join(n[:], "\n") +} + diff --git a/x/pricefeed/types/types.go b/x/pricefeed/types/types.go new file mode 100644 index 00000000..e07d9b55 --- /dev/null +++ b/x/pricefeed/types/types.go @@ -0,0 +1,67 @@ +package types + +import ( + "fmt" + "strings" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// Asset struct that represents an asset in the pricefeed +type Asset struct { + AssetCode string `json:"asset_code"` + Description string `json:"description"` +} + +// implement fmt.Stringer +func (a Asset) String() string { + return strings.TrimSpace(fmt.Sprintf(`AssetCode: %s +Description: %s`, a.AssetCode, a.Description)) +} + +// Oracle struct that documents which address an oracle is using +type Oracle struct { + OracleAddress string `json:"oracle_address"` +} + +// implement fmt.Stringer +func (o Oracle) String() string { + return strings.TrimSpace(fmt.Sprintf(`OracleAddress: %s`, o.OracleAddress)) +} + +// CurrentPrice struct that contains the metadata of a current price for a particular asset in the pricefeed module. +type CurrentPrice struct { + AssetCode string `json:"asset_code"` + Price sdk.Dec `json:"price"` + Expiry sdk.Int `json:"expiry"` +} + +// PostedPrice struct represented a price for an asset posted by a specific oracle +type PostedPrice struct { + AssetCode string `json:"asset_code"` + OracleAddress string `json:"oracle_address"` + Price sdk.Dec `json:"price"` + Expiry sdk.Int `json:"expiry"` +} + +// implement fmt.Stringer +func (cp CurrentPrice) String() string { + return strings.TrimSpace(fmt.Sprintf(`AssetCode: %s +Price: %s +Expiry: %s`, cp.AssetCode, cp.Price, cp.Expiry)) +} + +// implement fmt.Stringer +func (pp PostedPrice) String() string { + return strings.TrimSpace(fmt.Sprintf(`AssetCode: %s +OracleAddress: %s +Price: %s +Expiry: %s`, pp.AssetCode, pp.OracleAddress, pp.Price, pp.Expiry)) +} + +// SortDecs provides the interface needed to sort sdk.Dec slices +type SortDecs []sdk.Dec + +func (a SortDecs) Len() int { return len(a) } +func (a SortDecs) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a SortDecs) Less(i, j int) bool { return a[i].LT(a[j]) }