From 22e168d06ae09e0a6f9aaafd331fafc3b7f9de8f Mon Sep 17 00:00:00 2001 From: Ruaridh Date: Tue, 14 Jan 2020 15:00:37 +0100 Subject: [PATCH] Various Auction TODOs (#281) * make auctions not expire without bids * add events * improve genesis state validation * add genesis tests * update comment Co-Authored-By: Kevin Davis * add more events attributes Co-authored-by: Kevin Davis --- app/app.go | 2 +- x/auction/alias.go | 4 +- x/auction/genesis.go | 40 +++++++++-- x/auction/genesis_test.go | 109 +++++++++++++++++++++++++++++ x/auction/handler.go | 16 ++++- x/auction/keeper/auctions.go | 112 +++++++++++++++++++++++++++--- x/auction/keeper/auctions_test.go | 15 ++-- x/auction/module.go | 24 ++++--- x/auction/spec/04_events.md | 33 ++++++++- x/auction/types/auctions.go | 101 ++++++++++++++++++++------- x/auction/types/codec.go | 1 + x/auction/types/events.go | 17 +++++ x/auction/types/genesis.go | 55 +++++++++++---- x/auction/types/genesis_test.go | 46 ++++++++++++ 14 files changed, 493 insertions(+), 82 deletions(-) create mode 100644 x/auction/genesis_test.go create mode 100644 x/auction/types/events.go create mode 100644 x/auction/types/genesis_test.go diff --git a/app/app.go b/app/app.go index 9e9a1ea0..5ad36569 100644 --- a/app/app.go +++ b/app/app.go @@ -261,7 +261,7 @@ func NewApp(logger log.Logger, db dbm.DB, traceStore io.Writer, loadLatest bool, slashing.NewAppModule(app.slashingKeeper, app.stakingKeeper), staking.NewAppModule(app.stakingKeeper, app.accountKeeper, app.supplyKeeper), validatorvesting.NewAppModule(app.vvKeeper, app.accountKeeper), - auction.NewAppModule(app.auctionKeeper), + auction.NewAppModule(app.auctionKeeper, app.supplyKeeper), cdp.NewAppModule(app.cdpKeeper, app.pricefeedKeeper), pricefeed.NewAppModule(app.pricefeedKeeper), ) diff --git a/x/auction/alias.go b/x/auction/alias.go index 99b63c4f..7b8ece33 100644 --- a/x/auction/alias.go +++ b/x/auction/alias.go @@ -29,7 +29,6 @@ var ( RegisterCodec = types.RegisterCodec NewGenesisState = types.NewGenesisState DefaultGenesisState = types.DefaultGenesisState - ValidateGenesis = types.ValidateGenesis GetAuctionKey = types.GetAuctionKey GetAuctionByTimeKey = types.GetAuctionByTimeKey Uint64FromBytes = types.Uint64FromBytes @@ -58,7 +57,8 @@ type ( CollateralAuction = types.CollateralAuction WeightedAddresses = types.WeightedAddresses SupplyKeeper = types.SupplyKeeper - Auctions = types.Auctions + GenesisAuctions = types.GenesisAuctions + GenesisAuction = types.GenesisAuction GenesisState = types.GenesisState MsgPlaceBid = types.MsgPlaceBid Params = types.Params diff --git a/x/auction/genesis.go b/x/auction/genesis.go index b5fad1ab..eedcdf1e 100644 --- a/x/auction/genesis.go +++ b/x/auction/genesis.go @@ -1,17 +1,39 @@ package auction import ( + "fmt" + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/kava-labs/kava/x/auction/types" ) -// InitGenesis initializes the store state from genesis data. -func InitGenesis(ctx sdk.Context, keeper Keeper, data GenesisState) { - keeper.SetNextAuctionID(ctx, data.NextAuctionID) +// InitGenesis initializes the store state from a genesis state. +func InitGenesis(ctx sdk.Context, keeper Keeper, supplyKeeper types.SupplyKeeper, gs GenesisState) { + if err := gs.Validate(); err != nil { + panic(fmt.Sprintf("failed to validate %s genesis state: %s", ModuleName, err)) + } - keeper.SetParams(ctx, data.Params) + keeper.SetNextAuctionID(ctx, gs.NextAuctionID) - for _, a := range data.Auctions { + keeper.SetParams(ctx, gs.Params) + + totalAuctionCoins := sdk.NewCoins() + for _, a := range gs.Auctions { keeper.SetAuction(ctx, a) + // find the total coins that should be present in the module account + totalAuctionCoins.Add(a.GetModuleAccountCoins()) + } + + // check if the module account exists + moduleAcc := supplyKeeper.GetModuleAccount(ctx, ModuleName) + if moduleAcc == nil { + panic(fmt.Sprintf("%s module account has not been set", ModuleName)) + } + // check module coins match auction coins + // Note: Other sdk modules do not check this, instead just using the existing module account coins, or if zero, setting them. + if !moduleAcc.GetCoins().IsEqual(totalAuctionCoins) { + panic(fmt.Sprintf("total auction coins (%s) do not equal (%s) module account (%s) ", moduleAcc.GetCoins(), ModuleName, totalAuctionCoins)) } } @@ -24,9 +46,13 @@ func ExportGenesis(ctx sdk.Context, keeper Keeper) GenesisState { params := keeper.GetParams(ctx) - var genAuctions Auctions + genAuctions := GenesisAuctions{} // return empty list instead of nil if no auctions keeper.IterateAuctions(ctx, func(a Auction) bool { - genAuctions = append(genAuctions, a) + ga, ok := a.(types.GenesisAuction) + if !ok { + panic("could not convert stored auction to GenesisAuction type") + } + genAuctions = append(genAuctions, ga) return false }) diff --git a/x/auction/genesis_test.go b/x/auction/genesis_test.go new file mode 100644 index 00000000..a7352c52 --- /dev/null +++ b/x/auction/genesis_test.go @@ -0,0 +1,109 @@ +package auction_test + +import ( + "sort" + "testing" + "time" + + "github.com/stretchr/testify/require" + + abci "github.com/tendermint/tendermint/abci/types" + + "github.com/kava-labs/kava/app" + "github.com/kava-labs/kava/x/auction" +) + +var testTime = time.Date(1998, 1, 1, 0, 0, 0, 0, time.UTC) +var testAuction = auction.NewCollateralAuction( + "seller", + c("lotdenom", 10), + testTime, + c("biddenom", 1000), + auction.WeightedAddresses{}, + c("debt", 1000), +).WithID(3).(auction.GenesisAuction) + +func TestInitGenesis(t *testing.T) { + t.Run("valid", func(t *testing.T) { + // setup keepers + tApp := app.NewTestApp() + keeper := tApp.GetAuctionKeeper() + ctx := tApp.NewContext(true, abci.Header{}) + // create genesis + gs := auction.NewGenesisState( + 10, + auction.DefaultParams(), + auction.GenesisAuctions{testAuction}, + ) + + // run init + require.NotPanics(t, func() { + auction.InitGenesis(ctx, keeper, tApp.GetSupplyKeeper(), gs) + }) + + // check state is as expected + actualID, err := keeper.GetNextAuctionID(ctx) + require.NoError(t, err) + require.Equal(t, gs.NextAuctionID, actualID) + + require.Equal(t, gs.Params, keeper.GetParams(ctx)) + + // TODO is there a nicer way of comparing state? + sort.Slice(gs.Auctions, func(i, j int) bool { + return gs.Auctions[i].GetID() > gs.Auctions[j].GetID() + }) + i := 0 + keeper.IterateAuctions(ctx, func(a auction.Auction) bool { + require.Equal(t, gs.Auctions[i], a) + i++ + return false + }) + }) + t.Run("invalid", func(t *testing.T) { + // setup keepers + tApp := app.NewTestApp() + ctx := tApp.NewContext(true, abci.Header{}) + + // create invalid genesis + gs := auction.NewGenesisState( + 0, // next id < testAuction ID + auction.DefaultParams(), + auction.GenesisAuctions{testAuction}, + ) + + // check init fails + require.Panics(t, func() { + auction.InitGenesis(ctx, tApp.GetAuctionKeeper(), tApp.GetSupplyKeeper(), gs) + }) + }) +} + +func TestExportGenesis(t *testing.T) { + t.Run("default", func(t *testing.T) { + // setup state + tApp := app.NewTestApp() + ctx := tApp.NewContext(true, abci.Header{}) + tApp.InitializeFromGenesisStates() + + // export + gs := auction.ExportGenesis(ctx, tApp.GetAuctionKeeper()) + + // check state matches + require.Equal(t, auction.DefaultGenesisState(), gs) + }) + t.Run("one auction", func(t *testing.T) { + // setup state + tApp := app.NewTestApp() + ctx := tApp.NewContext(true, abci.Header{}) + tApp.InitializeFromGenesisStates() + tApp.GetAuctionKeeper().SetAuction(ctx, testAuction) + + // export + gs := auction.ExportGenesis(ctx, tApp.GetAuctionKeeper()) + + // check state matches + expectedGenesisState := auction.DefaultGenesisState() + expectedGenesisState.Auctions = append(expectedGenesisState.Auctions, testAuction) + require.Equal(t, expectedGenesisState, gs) + }) +} diff --git a/x/auction/handler.go b/x/auction/handler.go index f9b7c137..be5f9d30 100644 --- a/x/auction/handler.go +++ b/x/auction/handler.go @@ -4,11 +4,15 @@ import ( "fmt" sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/kava-labs/kava/x/auction/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 { + ctx = ctx.WithEventManager(sdk.NewEventManager()) + switch msg := msg.(type) { case MsgPlaceBid: return handleMsgPlaceBid(ctx, keeper, msg) @@ -26,5 +30,15 @@ func handleMsgPlaceBid(ctx sdk.Context, keeper Keeper, msg MsgPlaceBid) sdk.Resu return err.Result() } - return sdk.Result{} + ctx.EventManager().EmitEvent( + sdk.NewEvent( + sdk.EventTypeMessage, + sdk.NewAttribute(sdk.AttributeKeyModule, types.AttributeValueCategory), + sdk.NewAttribute(sdk.AttributeKeySender, msg.Bidder.String()), + ), + ) + + return sdk.Result{ + Events: ctx.EventManager().Events(), + } } diff --git a/x/auction/keeper/auctions.go b/x/auction/keeper/auctions.go index 50979e26..b47313ce 100644 --- a/x/auction/keeper/auctions.go +++ b/x/auction/keeper/auctions.go @@ -16,7 +16,7 @@ func (k Keeper) StartSurplusAuction(ctx sdk.Context, seller string, lot sdk.Coin seller, lot, bidDenom, - ctx.BlockTime().Add(k.GetParams(ctx).MaxAuctionDuration)) + types.DistantFuture) err := k.supplyKeeper.SendCoinsFromModuleToModule(ctx, seller, types.ModuleName, sdk.NewCoins(lot)) if err != nil { @@ -27,6 +27,16 @@ func (k Keeper) StartSurplusAuction(ctx sdk.Context, seller string, lot sdk.Coin if err != nil { return 0, err } + + ctx.EventManager().EmitEvent( + sdk.NewEvent( + types.EventTypeAuctionStart, + sdk.NewAttribute(types.AttributeKeyAuctionID, fmt.Sprintf("%d", auction.GetID())), + sdk.NewAttribute(types.AttributeKeyAuctionType, auction.Name()), + sdk.NewAttribute(types.AttributeKeyBidDenom, auction.Bid.Denom), + sdk.NewAttribute(types.AttributeKeyLotDenom, auction.Lot.Denom), + ), + ) return auctionID, nil } @@ -37,7 +47,7 @@ func (k Keeper) StartDebtAuction(ctx sdk.Context, buyer string, bid sdk.Coin, in buyer, bid, initialLot, - ctx.BlockTime().Add(k.GetParams(ctx).MaxAuctionDuration), + types.DistantFuture, debt) // This auction type mints coins at close. Need to check module account has minting privileges to avoid potential err in endblocker. @@ -55,6 +65,16 @@ func (k Keeper) StartDebtAuction(ctx sdk.Context, buyer string, bid sdk.Coin, in if err != nil { return 0, err } + + ctx.EventManager().EmitEvent( + sdk.NewEvent( + types.EventTypeAuctionStart, + sdk.NewAttribute(types.AttributeKeyAuctionID, fmt.Sprintf("%d", auction.GetID())), + sdk.NewAttribute(types.AttributeKeyAuctionType, auction.Name()), + sdk.NewAttribute(types.AttributeKeyBidDenom, auction.Bid.Denom), + sdk.NewAttribute(types.AttributeKeyLotDenom, auction.Lot.Denom), + ), + ) return auctionID, nil } @@ -68,7 +88,7 @@ func (k Keeper) StartCollateralAuction(ctx sdk.Context, seller string, lot sdk.C auction := types.NewCollateralAuction( seller, lot, - ctx.BlockTime().Add(types.DefaultMaxAuctionDuration), + types.DistantFuture, maxBid, weightedAddresses, debt) @@ -86,6 +106,16 @@ func (k Keeper) StartCollateralAuction(ctx sdk.Context, seller string, lot sdk.C if err != nil { return 0, err } + + ctx.EventManager().EmitEvent( + sdk.NewEvent( + types.EventTypeAuctionStart, + sdk.NewAttribute(types.AttributeKeyAuctionID, fmt.Sprintf("%d", auction.GetID())), + sdk.NewAttribute(types.AttributeKeyAuctionType, auction.Name()), + sdk.NewAttribute(types.AttributeKeyBidDenom, auction.Bid.Denom), + sdk.NewAttribute(types.AttributeKeyLotDenom, auction.Lot.Denom), + ), + ) return auctionID, nil } @@ -128,6 +158,7 @@ func (k Keeper) PlaceBid(ctx sdk.Context, auctionID uint64, bidder sdk.AccAddres } k.SetAuction(ctx, updatedAuction) + return nil } @@ -142,7 +173,7 @@ func (k Keeper) PlaceBidSurplus(ctx sdk.Context, a types.SurplusAuction, bidder } // New bidder pays back old bidder - // Catch edge cases of a bidder replacing their own bid, and the amount being zero (sending zero coins produces meaningless send events). + // Catch edge cases of a bidder replacing their own bid, or the amount being zero (sending zero coins produces meaningless send events). if !bidder.Equals(a.Bidder) && !a.Bid.IsZero() { err := k.supplyKeeper.SendCoinsFromAccountToModule(ctx, bidder, types.ModuleName, sdk.NewCoins(a.Bid)) if err != nil { @@ -166,7 +197,21 @@ func (k Keeper) PlaceBidSurplus(ctx sdk.Context, a types.SurplusAuction, bidder // Update Auction a.Bidder = bidder a.Bid = bid - a.EndTime = earliestTime(ctx.BlockTime().Add(k.GetParams(ctx).BidDuration), a.MaxEndTime) // increment timeout + if !a.HasReceivedBids { + a.MaxEndTime = ctx.BlockTime().Add(k.GetParams(ctx).MaxAuctionDuration) // set maximum ending time on receipt of first bid + } + a.EndTime = earliestTime(ctx.BlockTime().Add(k.GetParams(ctx).BidDuration), a.MaxEndTime) // increment timeout, up to MaxEndTime + a.HasReceivedBids = true + + ctx.EventManager().EmitEvent( + sdk.NewEvent( + types.EventTypeAuctionBid, + sdk.NewAttribute(types.AttributeKeyAuctionID, fmt.Sprintf("%d", a.ID)), + sdk.NewAttribute(types.AttributeKeyBidder, a.Bidder.String()), + sdk.NewAttribute(types.AttributeKeyBidAmount, a.Bid.Amount.String()), + sdk.NewAttribute(types.AttributeKeyEndTime, fmt.Sprintf("%d", a.EndTime.Unix())), + ), + ) return a, nil } @@ -216,13 +261,26 @@ func (k Keeper) PlaceForwardBidCollateral(ctx sdk.Context, a types.CollateralAuc return a, err } a.CorrespondingDebt = a.CorrespondingDebt.Sub(debtToReturn) // debtToReturn will always be ≤ a.CorrespondingDebt from the MinInt above - // TODO optionally burn out debt and stable just returned to liquidator } // Update Auction a.Bidder = bidder a.Bid = bid - a.EndTime = earliestTime(ctx.BlockTime().Add(k.GetParams(ctx).BidDuration), a.MaxEndTime) // increment timeout + if !a.HasReceivedBids { + a.MaxEndTime = ctx.BlockTime().Add(k.GetParams(ctx).MaxAuctionDuration) // set maximum ending time on receipt of first bid + } + a.EndTime = earliestTime(ctx.BlockTime().Add(k.GetParams(ctx).BidDuration), a.MaxEndTime) // increment timeout, up to MaxEndTime + a.HasReceivedBids = true + + ctx.EventManager().EmitEvent( + sdk.NewEvent( + types.EventTypeAuctionBid, + sdk.NewAttribute(types.AttributeKeyAuctionID, fmt.Sprintf("%d", a.ID)), + sdk.NewAttribute(types.AttributeKeyBidder, a.Bidder.String()), + sdk.NewAttribute(types.AttributeKeyBidAmount, a.Bid.Amount.String()), + sdk.NewAttribute(types.AttributeKeyEndTime, fmt.Sprintf("%d", a.EndTime.Unix())), + ), + ) return a, nil } @@ -271,7 +329,21 @@ func (k Keeper) PlaceReverseBidCollateral(ctx sdk.Context, a types.CollateralAuc // Update Auction a.Bidder = bidder a.Lot = lot - a.EndTime = earliestTime(ctx.BlockTime().Add(k.GetParams(ctx).BidDuration), a.MaxEndTime) // increment timeout + if !a.HasReceivedBids { + a.MaxEndTime = ctx.BlockTime().Add(k.GetParams(ctx).MaxAuctionDuration) // set maximum ending time on receipt of first bid + } + a.EndTime = earliestTime(ctx.BlockTime().Add(k.GetParams(ctx).BidDuration), a.MaxEndTime) // increment timeout, up to MaxEndTime + a.HasReceivedBids = true + + ctx.EventManager().EmitEvent( + sdk.NewEvent( + types.EventTypeAuctionBid, + sdk.NewAttribute(types.AttributeKeyAuctionID, fmt.Sprintf("%d", a.ID)), + sdk.NewAttribute(types.AttributeKeyBidder, a.Bidder.String()), + sdk.NewAttribute(types.AttributeKeyLotAmount, a.Lot.Amount.String()), + sdk.NewAttribute(types.AttributeKeyEndTime, fmt.Sprintf("%d", a.EndTime.Unix())), + ), + ) return a, nil } @@ -312,13 +384,26 @@ func (k Keeper) PlaceBidDebt(ctx sdk.Context, a types.DebtAuction, bidder sdk.Ac return a, err } a.CorrespondingDebt = a.CorrespondingDebt.Sub(debtToReturn) // debtToReturn will always be ≤ a.CorrespondingDebt from the MinInt above - // TODO optionally burn out debt and stable just returned to liquidator } // Update Auction a.Bidder = bidder a.Lot = lot - a.EndTime = earliestTime(ctx.BlockTime().Add(k.GetParams(ctx).BidDuration), a.MaxEndTime) // increment timeout + if !a.HasReceivedBids { + a.MaxEndTime = ctx.BlockTime().Add(k.GetParams(ctx).MaxAuctionDuration) // set maximum ending time on receipt of first bid + } + a.EndTime = earliestTime(ctx.BlockTime().Add(k.GetParams(ctx).BidDuration), a.MaxEndTime) // increment timeout, up to MaxEndTime + a.HasReceivedBids = true + + ctx.EventManager().EmitEvent( + sdk.NewEvent( + types.EventTypeAuctionBid, + sdk.NewAttribute(types.AttributeKeyAuctionID, fmt.Sprintf("%d", a.ID)), + sdk.NewAttribute(types.AttributeKeyBidder, a.Bidder.String()), + sdk.NewAttribute(types.AttributeKeyLotAmount, a.Lot.Amount.String()), + sdk.NewAttribute(types.AttributeKeyEndTime, fmt.Sprintf("%d", a.EndTime.Unix())), + ), + ) return a, nil } @@ -354,6 +439,13 @@ func (k Keeper) CloseAuction(ctx sdk.Context, auctionID uint64) sdk.Error { } k.DeleteAuction(ctx, auctionID) + + ctx.EventManager().EmitEvent( + sdk.NewEvent( + types.EventTypeAuctionClose, + sdk.NewAttribute(types.AttributeKeyAuctionID, fmt.Sprintf("%d", auction.GetID())), + ), + ) return nil } diff --git a/x/auction/keeper/auctions_test.go b/x/auction/keeper/auctions_test.go index 4ac3590d..3b0bc658 100644 --- a/x/auction/keeper/auctions_test.go +++ b/x/auction/keeper/auctions_test.go @@ -227,13 +227,14 @@ func TestStartSurplusAuction(t *testing.T) { // check auction in store and is correct require.True(t, found) expectedAuction := types.Auction(types.SurplusAuction{BaseAuction: types.BaseAuction{ - ID: 0, - Initiator: tc.args.seller, - Lot: tc.args.lot, - Bidder: nil, - Bid: c(tc.args.bidDenom, 0), - EndTime: tc.blockTime.Add(types.DefaultMaxAuctionDuration), - MaxEndTime: tc.blockTime.Add(types.DefaultMaxAuctionDuration), + ID: 0, + Initiator: tc.args.seller, + Lot: tc.args.lot, + Bidder: nil, + Bid: c(tc.args.bidDenom, 0), + HasReceivedBids: false, + EndTime: types.DistantFuture, + MaxEndTime: types.DistantFuture, }}) require.Equal(t, expectedAuction, actualAuc) } else { diff --git a/x/auction/module.go b/x/auction/module.go index 14c2d023..acd36010 100644 --- a/x/auction/module.go +++ b/x/auction/module.go @@ -2,18 +2,19 @@ package auction import ( "encoding/json" - - "github.com/gorilla/mux" - "github.com/spf13/cobra" + "fmt" "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" + "github.com/gorilla/mux" + "github.com/spf13/cobra" 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" + "github.com/kava-labs/kava/x/auction/types" ) var ( @@ -41,12 +42,12 @@ func (AppModuleBasic) DefaultGenesis() json.RawMessage { // ValidateGenesis performs genesis state validation for the auction module. func (AppModuleBasic) ValidateGenesis(bz json.RawMessage) error { - var data GenesisState - err := ModuleCdc.UnmarshalJSON(bz, &data) + var gs GenesisState + err := ModuleCdc.UnmarshalJSON(bz, &gs) if err != nil { - return err + return fmt.Errorf("failed to unmarshal %s genesis state: %w", ModuleName, err) } - return ValidateGenesis(data) + return gs.Validate() } // RegisterRESTRoutes registers the REST routes for the auction module. @@ -67,14 +68,17 @@ func (AppModuleBasic) GetQueryCmd(cdc *codec.Codec) *cobra.Command { // AppModule implements the sdk.AppModule interface. type AppModule struct { AppModuleBasic - keeper Keeper + + keeper Keeper + supplyKeeper types.SupplyKeeper } // NewAppModule creates a new AppModule object -func NewAppModule(keeper Keeper) AppModule { +func NewAppModule(keeper Keeper, supplyKeeper types.SupplyKeeper) AppModule { return AppModule{ AppModuleBasic: AppModuleBasic{}, keeper: keeper, + supplyKeeper: supplyKeeper, } } @@ -106,7 +110,7 @@ func (am AppModule) NewQuerierHandler() sdk.Querier { func (am AppModule) InitGenesis(ctx sdk.Context, data json.RawMessage) []abci.ValidatorUpdate { var genesisState GenesisState ModuleCdc.MustUnmarshalJSON(data, &genesisState) - InitGenesis(ctx, am.keeper, genesisState) + InitGenesis(ctx, am.keeper, am.supplyKeeper, genesisState) return []abci.ValidatorUpdate{} } diff --git a/x/auction/spec/04_events.md b/x/auction/spec/04_events.md index 0c6b3238..fae55424 100644 --- a/x/auction/spec/04_events.md +++ b/x/auction/spec/04_events.md @@ -1,5 +1,32 @@ # Events - +The `x/auction` module emits the following events: + +## Triggered By Other Modules + +| Type | Attribute Key | Attribute Value | +| ------------- | ------------- | ------------------- | +| auction_start | auction_id | {auction ID} | +| auction_start | auction_type | {auction type} | +| auction_start | lot_denom | {auction lot denom} | +| auction_start | bid_denom | {auction bid denom} | + +## Handlers + +### MsgPlaceBid + +| Type | Attribute Key | Attribute Value | +| ----------- | ------------- | ------------------ | +| auction_bid | auction_id | {auction ID} | +| auction_bid | bidder | {latest bidder} | +| auction_bid | bid_amount | {coin amount} | +| auction_bid | lot_amount | {coin amount} | +| auction_bid | end_time | {auction end time} | +| message | module | auction | +| message | sender | {sender address} | + +## EndBlock + +| Type | Attribute Key | Attribute Value | +| ------------- | ------------- | --------------- | +| auction_close | auction_id | {auction ID} | diff --git a/x/auction/types/auctions.go b/x/auction/types/auctions.go index beec6d24..c4b469f8 100644 --- a/x/auction/types/auctions.go +++ b/x/auction/types/auctions.go @@ -8,6 +8,11 @@ import ( "github.com/cosmos/cosmos-sdk/x/supply" ) +// distantFuture is a very large time value to use as initial the ending time for auctions. +// It is not set to the max time supported. This can cause problems with time comparisons, see https://stackoverflow.com/a/32620397. +// Also amino panics when encoding times ≥ the start of year 10000. +var DistantFuture time.Time = time.Date(9000, 1, 1, 0, 0, 0, 0, time.UTC) + // Auction is an interface for handling common actions on auctions. type Auction interface { GetID() uint64 @@ -17,13 +22,14 @@ type Auction interface { // BaseAuction is a common type shared by all Auctions. type BaseAuction struct { - ID uint64 - Initiator string // Module name that starts the auction. Pays out Lot. - Lot sdk.Coin // Coins that will paid out by Initiator to the winning bidder. - Bidder sdk.AccAddress // Latest bidder. Receiver of Lot. - Bid sdk.Coin // Coins paid into the auction the bidder. - EndTime time.Time // Current auction closing time. Triggers at the end of the block with time ≥ EndTime. - MaxEndTime time.Time // Maximum closing time. Auctions can close before this but never after. + ID uint64 + Initiator string // Module name that starts the auction. Pays out Lot. + Lot sdk.Coin // Coins that will paid out by Initiator to the winning bidder. + Bidder sdk.AccAddress // Latest bidder. Receiver of Lot. + Bid sdk.Coin // Coins paid into the auction the bidder. + HasReceivedBids bool // Whether the auction has received any bids or not. + EndTime time.Time // Current auction closing time. Triggers at the end of the block with time ≥ EndTime. + MaxEndTime time.Time // Maximum closing time. Auctions can close before this but never after. } // GetID is a getter for auction ID. @@ -32,6 +38,13 @@ func (a BaseAuction) GetID() uint64 { return a.ID } // GetEndTime is a getter for auction end time. func (a BaseAuction) GetEndTime() time.Time { return a.EndTime } +func (a BaseAuction) Validate() error { + if a.EndTime.After(a.MaxEndTime) { + return fmt.Errorf("MaxEndTime < EndTime (%s < %s)", a.MaxEndTime, a.EndTime) + } + return nil +} + func (a BaseAuction) String() string { return fmt.Sprintf(`Auction %d: Initiator: %s @@ -55,16 +68,27 @@ type SurplusAuction struct { // WithID returns an auction with the ID set. func (a SurplusAuction) WithID(id uint64) Auction { a.ID = id; return a } +// Name returns a name for this auction type. Used to identify auctions in event attributes. +func (a SurplusAuction) Name() string { return "surplus" } + +// GetModuleAccountCoins returns the total number of coins held in the module account for this auction. +// It is used in genesis initialize the module account correctly. +func (a SurplusAuction) GetModuleAccountCoins() sdk.Coins { + // a.Bid is paid out on bids, so is never stored in the module account + return sdk.NewCoins(a.Lot) +} + // NewSurplusAuction returns a new surplus auction. func NewSurplusAuction(seller string, lot sdk.Coin, bidDenom string, endTime time.Time) SurplusAuction { auction := SurplusAuction{BaseAuction{ // no ID - Initiator: seller, - Lot: lot, - Bidder: nil, - Bid: sdk.NewInt64Coin(bidDenom, 0), - EndTime: endTime, - MaxEndTime: endTime, + Initiator: seller, + Lot: lot, + Bidder: nil, + Bid: sdk.NewInt64Coin(bidDenom, 0), + HasReceivedBids: false, // new auctions don't have any bids + EndTime: endTime, + MaxEndTime: endTime, }} return auction } @@ -79,20 +103,32 @@ type DebtAuction struct { // WithID returns an auction with the ID set. func (a DebtAuction) WithID(id uint64) Auction { a.ID = id; return a } +// Name returns a name for this auction type. Used to identify auctions in event attributes. +func (a DebtAuction) Name() string { return "debt" } + +// GetModuleAccountCoins returns the total number of coins held in the module account for this auction. +// It is used in genesis initialize the module account correctly. +func (a DebtAuction) GetModuleAccountCoins() sdk.Coins { + // a.Lot is minted at auction close, so is never stored in the module account + // a.Bid is paid out on bids, so is never stored in the module account + return sdk.NewCoins(a.CorrespondingDebt) +} + // NewDebtAuction returns a new debt auction. -func NewDebtAuction(buyerModAccName string, bid sdk.Coin, initialLot sdk.Coin, EndTime time.Time, debt sdk.Coin) DebtAuction { +func NewDebtAuction(buyerModAccName string, bid sdk.Coin, initialLot sdk.Coin, endTime time.Time, debt sdk.Coin) DebtAuction { // Note: Bidder is set to the initiator's module account address instead of module name. (when the first bid is placed, it is paid out to the initiator) // Setting to the module account address bypasses calling supply.SendCoinsFromModuleToModule, instead calls SendCoinsFromModuleToAccount. // This isn't a problem currently, but if additional logic/validation was added for sending to coins to Module Accounts, it would be bypassed. auction := DebtAuction{ BaseAuction: BaseAuction{ // no ID - Initiator: buyerModAccName, - Lot: initialLot, - Bidder: supply.NewModuleAddress(buyerModAccName), // send proceeds from the first bid to the buyer. - Bid: bid, // amount that the buyer is buying - doesn't change over course of auction - EndTime: EndTime, - MaxEndTime: EndTime}, + Initiator: buyerModAccName, + Lot: initialLot, + Bidder: supply.NewModuleAddress(buyerModAccName), // send proceeds from the first bid to the buyer. + Bid: bid, // amount that the buyer is buying - doesn't change over course of auction + HasReceivedBids: false, // new auctions don't have any bids + EndTime: endTime, + MaxEndTime: endTime}, CorrespondingDebt: debt, } return auction @@ -113,6 +149,16 @@ type CollateralAuction struct { // WithID returns an auction with the ID set. func (a CollateralAuction) WithID(id uint64) Auction { a.ID = id; return a } +// Name returns a name for this auction type. Used to identify auctions in event attributes. +func (a CollateralAuction) Name() string { return "collateral" } + +// GetModuleAccountCoins returns the total number of coins held in the module account for this auction. +// It is used in genesis initialize the module account correctly. +func (a CollateralAuction) GetModuleAccountCoins() sdk.Coins { + // a.Bid is paid out on bids, so is never stored in the module account + return sdk.NewCoins(a.Lot).Add(sdk.NewCoins(a.CorrespondingDebt)) +} + // IsReversePhase returns whether the auction has switched over to reverse phase or not. // Auction initially start in forward phase. func (a CollateralAuction) IsReversePhase() bool { @@ -136,16 +182,17 @@ func (a CollateralAuction) String() string { } // NewCollateralAuction returns a new collateral auction. -func NewCollateralAuction(seller string, lot sdk.Coin, EndTime time.Time, maxBid sdk.Coin, lotReturns WeightedAddresses, debt sdk.Coin) CollateralAuction { +func NewCollateralAuction(seller string, lot sdk.Coin, endTime time.Time, maxBid sdk.Coin, lotReturns WeightedAddresses, debt sdk.Coin) CollateralAuction { auction := CollateralAuction{ BaseAuction: BaseAuction{ // no ID - Initiator: seller, - Lot: lot, - Bidder: nil, - Bid: sdk.NewInt64Coin(maxBid.Denom, 0), - EndTime: EndTime, - MaxEndTime: EndTime}, + Initiator: seller, + Lot: lot, + Bidder: nil, + Bid: sdk.NewInt64Coin(maxBid.Denom, 0), + HasReceivedBids: false, // new auctions don't have any bids + EndTime: endTime, + MaxEndTime: endTime}, CorrespondingDebt: debt, MaxBid: maxBid, LotReturns: lotReturns, diff --git a/x/auction/types/codec.go b/x/auction/types/codec.go index 1904f432..e185552b 100644 --- a/x/auction/types/codec.go +++ b/x/auction/types/codec.go @@ -15,6 +15,7 @@ func init() { func RegisterCodec(cdc *codec.Codec) { cdc.RegisterConcrete(MsgPlaceBid{}, "auction/MsgPlaceBid", nil) + cdc.RegisterInterface((*GenesisAuction)(nil), nil) cdc.RegisterInterface((*Auction)(nil), nil) cdc.RegisterConcrete(SurplusAuction{}, "auction/SurplusAuction", nil) cdc.RegisterConcrete(DebtAuction{}, "auction/DebtAuction", nil) diff --git a/x/auction/types/events.go b/x/auction/types/events.go new file mode 100644 index 00000000..032882c7 --- /dev/null +++ b/x/auction/types/events.go @@ -0,0 +1,17 @@ +package types + +const ( + EventTypeAuctionStart = "auction_start" + EventTypeAuctionBid = "auction_bid" + EventTypeAuctionClose = "auction_close" + + AttributeValueCategory = ModuleName + AttributeKeyAuctionID = "auction_id" + AttributeKeyAuctionType = "auction_type" + AttributeKeyBidder = "bidder" + AttributeKeyBidDenom = "bid_denom" + AttributeKeyLotDenom = "lot_denom" + AttributeKeyBidAmount = "bid_amount" + AttributeKeyLotAmount = "lot_amount" + AttributeKeyEndTime = "end_time" +) diff --git a/x/auction/types/genesis.go b/x/auction/types/genesis.go index 808ac050..ccf75c8e 100644 --- a/x/auction/types/genesis.go +++ b/x/auction/types/genesis.go @@ -2,20 +2,30 @@ package types import ( "bytes" + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" ) -// Auctions is a slice of auctions. -type Auctions []Auction +// GenesisAuction is an interface that extends the auction interface to add functionality needed for initializing auctions from genesis. +type GenesisAuction interface { + Auction + GetModuleAccountCoins() sdk.Coins + Validate() error +} + +// GenesisAuctions is a slice of genesis auctions. +type GenesisAuctions []GenesisAuction // GenesisState is auction state that must be provided at chain genesis. type GenesisState struct { - NextAuctionID uint64 `json:"next_auction_id" yaml:"next_auction_id"` - Params Params `json:"auction_params" yaml:"auction_params"` - Auctions Auctions `json:"genesis_auctions" yaml:"genesis_auctions"` + NextAuctionID uint64 `json:"next_auction_id" yaml:"next_auction_id"` + Params Params `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(nextID uint64, ap Params, ga Auctions) GenesisState { +func NewGenesisState(nextID uint64, ap Params, ga GenesisAuctions) GenesisState { return GenesisState{ NextAuctionID: nextID, Params: ap, @@ -25,25 +35,42 @@ func NewGenesisState(nextID uint64, ap Params, ga Auctions) GenesisState { // DefaultGenesisState returns the default genesis state for auction module. func DefaultGenesisState() GenesisState { - return NewGenesisState(0, DefaultParams(), Auctions{}) + return NewGenesisState(0, DefaultParams(), GenesisAuctions{}) } // Equal checks whether two GenesisState structs are equivalent. -func (data GenesisState) Equal(data2 GenesisState) bool { - b1 := ModuleCdc.MustMarshalBinaryBare(data) - b2 := ModuleCdc.MustMarshalBinaryBare(data2) +func (gs GenesisState) Equal(gs2 GenesisState) bool { + b1 := ModuleCdc.MustMarshalBinaryBare(gs) + b2 := ModuleCdc.MustMarshalBinaryBare(gs2) return bytes.Equal(b1, b2) } // IsEmpty returns true if a GenesisState is empty. -func (data GenesisState) IsEmpty() bool { - return data.Equal(GenesisState{}) +func (gs GenesisState) IsEmpty() bool { + return gs.Equal(GenesisState{}) } // ValidateGenesis validates genesis inputs. It returns error if validation of any input fails. -func ValidateGenesis(data GenesisState) error { - if err := data.Params.Validate(); err != nil { +func (gs GenesisState) Validate() error { + if err := gs.Params.Validate(); err != nil { return err } + + ids := map[uint64]bool{} + for _, a := range gs.Auctions { + + if err := a.Validate(); err != nil { + return fmt.Errorf("found invalid auction: %w", err) + } + + if ids[a.GetID()] { + return fmt.Errorf("found duplicate auction ID (%d)", a.GetID()) + } + ids[a.GetID()] = true + + if a.GetID() >= gs.NextAuctionID { + return fmt.Errorf("found auction ID >= the nextAuctionID (%d >= %d)", a.GetID(), gs.NextAuctionID) + } + } return nil } diff --git a/x/auction/types/genesis_test.go b/x/auction/types/genesis_test.go new file mode 100644 index 00000000..4810741e --- /dev/null +++ b/x/auction/types/genesis_test.go @@ -0,0 +1,46 @@ +package types + +import ( + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/require" +) + +var testCoin = sdk.NewInt64Coin("test", 20) + +func TestGenesisState_Validate(t *testing.T) { + testCases := []struct { + name string + nextID uint64 + auctions GenesisAuctions + expectPass bool + }{ + {"default", DefaultGenesisState().NextAuctionID, DefaultGenesisState().Auctions, true}, + {"invalid next ID", 54, GenesisAuctions{SurplusAuction{BaseAuction{ID: 105}}}, false}, + { + "repeated ID", + 1000, + GenesisAuctions{ + SurplusAuction{BaseAuction{ID: 105}}, + DebtAuction{BaseAuction{ID: 105}, testCoin}, + }, + false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + gs := NewGenesisState(tc.nextID, DefaultParams(), tc.auctions) + + err := gs.Validate() + + if tc.expectPass { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } + +}