Repository: ovrclk/akash
Pull request #657 (2020-06-12 00:06:44)

feat: Close Group Message functionality
Enable closing a specific group within an existing Akash Deployment.
This will signal the provider to set the Group.State to Closed.

`akashctl tx deployment group close --owner=? --dseq=? --gseq=?` command
added to send the `MsgCloseGroup`.

Simulation tests passing: With a prequisite in SimulateMsgCloseGroup() to skip
sending the MsgGroupClose if the Group.State is already Closed. This is
not the same behavior as the Deployment's Simulation function, but
Simulate...Group throws the handleMsgCloseGroup() ErrGroupClosed when
the simulation runs.

fixes: #642


codecov[bot]: # [Codecov](https://codecov.io/gh/ovrclk/akash/pull/657?src=pr&el=h1) Report
> Merging [#657](https://codecov.io/gh/ovrclk/akash/pull/657?src=pr&el=desc) into [master](https://codecov.io/gh/ovrclk/akash/commit/40e6a853024f069dcd94d041e560de577d363df1&el=desc) will **decrease** coverage by `0.20%`.
> The diff coverage is `14.90%`.

[![Impacted file tree graph](https://codecov.io/gh/ovrclk/akash/pull/657/graphs/tree.svg?width=650&height=150&src=pr&token=QXe3zmrbI2)](https://codecov.io/gh/ovrclk/akash/pull/657?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #657      +/-   ##
==========================================
- Coverage   26.66%   26.45%   -0.21%     
==========================================
  Files         146      146              
  Lines        6882     7041     +159     
==========================================
+ Hits         1835     1863      +28     
- Misses       4916     5049     +133     
+ Partials      131      129       -2     
```


| [Impacted Files](https://codecov.io/gh/ovrclk/akash/pull/657?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [x/deployment/client/cli/tx.go](https://codecov.io/gh/ovrclk/akash/pull/657/diff?src=pr&el=tree#diff-eC9kZXBsb3ltZW50L2NsaWVudC9jbGkvdHguZ28=) | `0.00% <0.00%> (ø)` | |
| [x/deployment/handler/handler.go](https://codecov.io/gh/ovrclk/akash/pull/657/diff?src=pr&el=tree#diff-eC9kZXBsb3ltZW50L2hhbmRsZXIvaGFuZGxlci5nbw==) | `1.47% <0.00%> (-0.42%)` | :arrow_down: |
| [x/deployment/simulation/operations.go](https://codecov.io/gh/ovrclk/akash/pull/657/diff?src=pr&el=tree#diff-eC9kZXBsb3ltZW50L3NpbXVsYXRpb24vb3BlcmF0aW9ucy5nbw==) | `0.00% <0.00%> (ø)` | |
| [x/deployment/types/msgs.go](https://codecov.io/gh/ovrclk/akash/pull/657/diff?src=pr&el=tree#diff-eC9kZXBsb3ltZW50L3R5cGVzL21zZ3MuZ28=) | `0.00% <0.00%> (ø)` | |
| [x/deployment/types/types.go](https://codecov.io/gh/ovrclk/akash/pull/657/diff?src=pr&el=tree#diff-eC9kZXBsb3ltZW50L3R5cGVzL3R5cGVzLmdv) | `34.04% <0.00%> (-4.06%)` | :arrow_down: |
| [x/deployment/keeper/keeper.go](https://codecov.io/gh/ovrclk/akash/pull/657/diff?src=pr&el=tree#diff-eC9kZXBsb3ltZW50L2tlZXBlci9rZWVwZXIuZ28=) | `84.07% <52.38%> (-7.24%)` | :arrow_down: |
| [x/deployment/types/event.go](https://codecov.io/gh/ovrclk/akash/pull/657/diff?src=pr&el=tree#diff-eC9kZXBsb3ltZW50L3R5cGVzL2V2ZW50Lmdv) | `90.32% <63.15%> (-1.57%)` | :arrow_down: |
| [x/deployment/types/codec.go](https://codecov.io/gh/ovrclk/akash/pull/657/diff?src=pr&el=tree#diff-eC9kZXBsb3ltZW50L3R5cGVzL2NvZGVjLmdv) | `69.23% <100.00%> (+2.56%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/ovrclk/akash/pull/657?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/ovrclk/akash/pull/657?src=pr&el=footer). Last update [40e6a85...2a533f5](https://codecov.io/gh/ovrclk/akash/pull/657?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/Makefile b/Makefile
index ee3cb086..74c6703a 100644
--- a/Makefile
+++ b/Makefile
@@ -243,3 +243,5 @@ test-sim-after-import:
 	@echo "Running application simulation-after-import..."
 	go test -mod=readonly -tags=$(BUILD_MAINNET) $(APP_DIR) -run=TestAppSimulationAfterImport -Enabled=true \
 		-NumBlocks=50 -BlockSize=100 -Commit=true -Seed=99 -Period=5 -v -timeout 10m
+
+test-sims: test-sim-fullapp test-sim-nondeterminism test-sim-import-export test-sim-after-import
\ No newline at end of file
diff --git a/app/params/weights.go b/app/params/weights.go
index 78191a61..28c76cbb 100644
--- a/app/params/weights.go
+++ b/app/params/weights.go
@@ -8,6 +8,7 @@ const (
 	DefaultWeightMsgCreateDeployment int = 100
 	DefaultWeightMsgUpdateDeployment int = 10
 	DefaultWeightMsgCloseDeployment  int = 100
+	DefaultWeightMsgCloseGroup       int = 100
 
 	DefaultWeightMsgCreateBid  int = 100
 	DefaultWeightMsgCloseBid   int = 100
diff --git a/x/deployment/client/cli/tx.go b/x/deployment/client/cli/tx.go
index b0ba2e91..2def3c50 100644
--- a/x/deployment/client/cli/tx.go
+++ b/x/deployment/client/cli/tx.go
@@ -31,6 +31,7 @@ func GetTxCmd(key string, cdc *codec.Codec) *cobra.Command {
 		cmdCreate(key, cdc),
 		cmdUpdate(key, cdc),
 		cmdClose(key, cdc),
+		cmdGroupClose(key, cdc),
 	)...)
 	return cmd
 }
@@ -135,3 +136,34 @@ func cmdUpdate(key string, cdc *codec.Codec) *cobra.Command {
 	AddDeploymentIDFlags(cmd.Flags())
 	return cmd
 }
+
+func cmdGroupClose(_ string, cdc *codec.Codec) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:     "group-close",
+		Short:   "close a Deployment's specific Group",
+		Example: "akashctl tx deployment group-close --owner=[Account Address] --dseq=[uint64] --gseq=[uint32]",
+		Args:    cobra.ExactArgs(0),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			ctx := context.NewCLIContext().WithCodec(cdc)
+			bldr := auth.NewTxBuilderFromCLI(os.Stdin).WithTxEncoder(utils.GetTxEncoder(cdc))
+			id, err := GroupIDFromFlags(cmd.Flags())
+			if err != nil {
+				return err
+			}
+
+			msg := types.MsgCloseGroup{
+				ID: id,
+			}
+			err = msg.ValidateBasic()
+			if err != nil {
+				return err
+			}
+
+			return utils.GenerateOrBroadcastMsgs(ctx, bldr, []sdk.Msg{msg})
+		},
+	}
+	AddGroupIDFlags(cmd.Flags())
+	MarkReqGroupIDFlags(cmd)
+
+	return cmd
+}
diff --git a/x/deployment/handler/handler.go b/x/deployment/handler/handler.go
index 8f55a632..63a4300b 100644
--- a/x/deployment/handler/handler.go
+++ b/x/deployment/handler/handler.go
@@ -19,7 +19,9 @@ func NewHandler(keeper keeper.Keeper, mkeeper MarketKeeper) sdk.Handler {
 		case types.MsgUpdateDeployment:
 			return handleMsgUpdate(ctx, keeper, mkeeper, msg)
 		case types.MsgCloseDeployment:
-			return handleMsgClose(ctx, keeper, mkeeper, msg)
+			return handleMsgCloseDeployment(ctx, keeper, mkeeper, msg)
+		case types.MsgCloseGroup:
+			return handleMsgCloseGroup(ctx, keeper, mkeeper, msg)
 		default:
 			return nil, sdkerrors.ErrUnknownRequest
 		}
@@ -79,7 +81,8 @@ func handleMsgUpdate(ctx sdk.Context, keeper keeper.Keeper, _ MarketKeeper, msg
 	}, nil
 }
 
-func handleMsgClose(ctx sdk.Context, keeper keeper.Keeper, mkeeper MarketKeeper, msg types.MsgCloseDeployment) (*sdk.Result, error) {
+func handleMsgCloseDeployment(ctx sdk.Context, keeper keeper.Keeper, mkeeper MarketKeeper, msg types.MsgCloseDeployment) (*sdk.Result, error) {
+
 	deployment, found := keeper.GetDeployment(ctx, msg.ID)
 	if !found {
 		return nil, types.ErrDeploymentNotFound
@@ -104,3 +107,27 @@ func handleMsgClose(ctx sdk.Context, keeper keeper.Keeper, mkeeper MarketKeeper,
 		Events: ctx.EventManager().Events(),
 	}, nil
 }
+
+func handleMsgCloseGroup(ctx sdk.Context, keeper keeper.Keeper, mkeeper MarketKeeper, msg types.MsgCloseGroup) (*sdk.Result, error) {
+	group, found := keeper.GetGroup(ctx, msg.ID)
+	if !found {
+		return nil, types.ErrGroupNotFound
+	}
+
+	// if Group already closed; return the validation error
+	err := group.ValidateClosable()
+	if err != nil {
+		return nil, err
+	}
+
+	// Update the Group's state
+	err = keeper.OnCloseGroup(ctx, group)
+	if err != nil {
+		return nil, err
+	}
+	mkeeper.OnGroupClosed(ctx, group.ID())
+
+	return &sdk.Result{
+		Events: ctx.EventManager().Events(),
+	}, nil
+}
diff --git a/x/deployment/keeper/keeper.go b/x/deployment/keeper/keeper.go
index d8d46398..ca12ed13 100644
--- a/x/deployment/keeper/keeper.go
+++ b/x/deployment/keeper/keeper.go
@@ -2,18 +2,12 @@ package keeper
 
 import (
 	"github.com/cosmos/cosmos-sdk/codec"
-	"github.com/pkg/errors"
 
 	"github.com/ovrclk/akash/x/deployment/types"
 
 	sdk "github.com/cosmos/cosmos-sdk/types"
 )
 
-var (
-	ErrDeploymentAlreadyExists = errors.New("keeper: deployment already exists")
-	ErrDeploymentNotFound      = errors.New("keeper: deployment not found")
-)
-
 // Keeper of the deployment store
 type Keeper struct {
 	skey sdk.StoreKey
@@ -97,7 +91,7 @@ func (k Keeper) Create(ctx sdk.Context, deployment types.Deployment, groups []ty
 	key := deploymentKey(deployment.ID())
 
 	if store.Has(key) {
-		return ErrDeploymentAlreadyExists
+		return types.ErrDeploymentExists
 	}
 
 	store.Set(key, k.cdc.MustMarshalBinaryBare(deployment))
@@ -123,7 +117,7 @@ func (k Keeper) UpdateDeployment(ctx sdk.Context, deployment types.Deployment) e
 	key := deploymentKey(deployment.ID())
 
 	if !store.Has(key) {
-		return ErrDeploymentNotFound
+		return types.ErrDeploymentNotFound
 	}
 
 	ctx.EventManager().EmitEvent(
@@ -134,6 +128,24 @@ func (k Keeper) UpdateDeployment(ctx sdk.Context, deployment types.Deployment) e
 	return nil
 }
 
+// OnCloseGroup provides shutdown API for a Group
+func (k Keeper) OnCloseGroup(ctx sdk.Context, group types.Group) error {
+	store := ctx.KVStore(k.skey)
+	key := groupKey(group.ID())
+
+	if !store.Has(key) {
+		return types.ErrGroupNotFound
+	}
+	group.State = types.GroupClosed
+
+	ctx.EventManager().EmitEvent(
+		types.EventGroupClose{ID: group.ID()}.ToSDKEvent(),
+	)
+
+	store.Set(key, k.cdc.MustMarshalBinaryBare(group))
+	return nil
+}
+
 // WithDeployments iterates all deployments in deployment store
 func (k Keeper) WithDeployments(ctx sdk.Context, fn func(types.Deployment) bool) {
 	store := ctx.KVStore(k.skey)
diff --git a/x/deployment/simulation/operations.go b/x/deployment/simulation/operations.go
index 1187f276..47e107c7 100644
--- a/x/deployment/simulation/operations.go
+++ b/x/deployment/simulation/operations.go
@@ -23,6 +23,7 @@ const (
 	OpWeightMsgCreateDeployment = "op_weight_msg_create_deployment"
 	OpWeightMsgUpdateDeployment = "op_weight_msg_update_deployment"
 	OpWeightMsgCloseDeployment  = "op_weight_msg_close_deployment"
+	OpWeightMsgCloseGroup       = "op_weight_msg_close_group"
 )
 
 // WeightedOperations returns all the operations from the module with their respective weights
@@ -33,6 +34,7 @@ func WeightedOperations(
 		weightMsgCreateDeployment int
 		weightMsgUpdateDeployment int
 		weightMsgCloseDeployment  int
+		weightMsgCloseGroup       int
 	)
 
 	appParams.GetOrGenerate(
@@ -53,6 +55,12 @@ func WeightedOperations(
 		},
 	)
 
+	appParams.GetOrGenerate(
+		cdc, OpWeightMsgCloseGroup, &weightMsgCloseGroup, nil, func(r *rand.Rand) {
+			weightMsgCloseGroup = simappparams.DefaultWeightMsgCloseGroup
+		},
+	)
+
 	return simulation.WeightedOperations{
 		simulation.NewWeightedOperation(
 			weightMsgCreateDeployment,
@@ -66,6 +74,10 @@ func WeightedOperations(
 			weightMsgCloseDeployment,
 			SimulateMsgCloseDeployment(ak, k),
 		),
+		simulation.NewWeightedOperation(
+			weightMsgCloseGroup,
+			SimulateMsgCloseGroup(ak, k),
+		),
 	}
 }
 
@@ -243,3 +255,78 @@ func SimulateMsgCloseDeployment(ak govtypes.AccountKeeper, k keeper.Keeper) simu
 		return simulation.NewOperationMsg(msg, true, ""), nil, nil
 	}
 }
+
+// SimulateMsgCloseGroup generates a MsgCloseGroup for a random deployment
+func SimulateMsgCloseGroup(ak govtypes.AccountKeeper, k keeper.Keeper) simulation.Operation {
+	return func(r *rand.Rand, app *baseapp.BaseApp, ctx sdk.Context, accounts []simulation.Account,
+		chainID string) (simulation.OperationMsg, []simulation.FutureOperation, error) {
+		var deployments []types.Deployment
+
+		k.WithDeployments(ctx, func(deployment types.Deployment) bool {
+			if deployment.State == types.DeploymentActive {
+				deployments = append(deployments, deployment)
+			}
+
+			return false
+		})
+
+		if len(deployments) == 0 {
+			return simulation.NoOpMsg(types.ModuleName), nil, nil
+		}
+
+		// Get random deployment
+		i := r.Intn(len(deployments))
+		deployment := deployments[i]
+
+		simAccount, found := simulation.FindAccount(accounts, deployment.ID().Owner)
+		if !found {
+			err := errors.Errorf("deployment with %s not found", deployment.ID().Owner)
+			return simulation.NoOpMsg(err.Error()), nil, err
+		}
+
+		account := ak.GetAccount(ctx, simAccount.Address)
+
+		fees, err := simulation.RandomFees(r, ctx, account.SpendableCoins(ctx.BlockTime()))
+		if err != nil {
+			return simulation.NoOpMsg(types.ModuleName), nil, err
+		}
+
+		// Select Group to close
+		groups := k.GetGroups(ctx, deployment.ID())
+		if len(groups) < 1 {
+			// No groups to close
+			err := errors.Errorf("no groups for deployment ID: %v", deployment.ID())
+			return simulation.NoOpMsg(err.Error()), nil, err
+		}
+		i = r.Intn(len(groups))
+		group := groups[i]
+		if group.State == types.GroupClosed {
+			return simulation.NoOpMsg("group already closed"), nil, nil
+		}
+
+		msg := types.MsgCloseGroup{
+			ID: group.ID(),
+		}
+		err = msg.ValidateBasic()
+		if err != nil {
+			return simulation.NoOpMsg(errors.Wrapf(err, "%q close-group msg validation failure", types.ModuleName).Error()), nil, err
+		}
+
+		tx := helpers.GenTx(
+			[]sdk.Msg{msg},
+			fees,
+			helpers.DefaultGenTxGas,
+			chainID,
+			[]uint64{account.GetAccountNumber()},
+			[]uint64{account.GetSequence()},
+			simAccount.PrivKey,
+		)
+
+		_, _, err = app.Deliver(tx)
+		if err != nil {
+			err = errors.Wrapf(err, "%s: msg delivery error closing group: %v", types.ModuleName, group.ID())
+			return simulation.NoOpMsg(err.Error()), nil, err
+		}
+		return simulation.NewOperationMsg(msg, true, "submitting MsgCloseGroup"), nil, nil
+	}
+}
diff --git a/x/deployment/types/codec.go b/x/deployment/types/codec.go
index d4950093..64a3fdac 100644
--- a/x/deployment/types/codec.go
+++ b/x/deployment/types/codec.go
@@ -15,6 +15,7 @@ func RegisterCodec(cdc *codec.Codec) {
 	cdc.RegisterConcrete(MsgCreateDeployment{}, ModuleName+"/"+msgTypeCreateDeployment, nil)
 	cdc.RegisterConcrete(MsgUpdateDeployment{}, ModuleName+"/"+msgTypeUpdateDeployment, nil)
 	cdc.RegisterConcrete(MsgCloseDeployment{}, ModuleName+"/"+msgTypeCloseDeployment, nil)
+	cdc.RegisterConcrete(MsgCloseGroup{}, ModuleName+"/"+msgTypeCloseGroup, nil)
 }
 
 // MustMarshalJSON panics if an error occurs. Besides that it behaves exactly like MarshalJSON
diff --git a/x/deployment/types/errors.go b/x/deployment/types/errors.go
index 777bb11c..9eed6823 100644
--- a/x/deployment/types/errors.go
+++ b/x/deployment/types/errors.go
@@ -17,6 +17,9 @@ const (
 	errInternal
 	errInvalidDeployment
 	errInvalidGroupID
+	errGroupNotFound
+	errGroupClosed
+	errGroupNotOpen
 )
 
 var (
@@ -32,7 +35,7 @@ var (
 	ErrDeploymentClosed = sdkerrors.Register(ModuleName, errDeploymentClosed, "Deployment closed")
 	// ErrOwnerAcctMissing is the error for owner account missing
 	ErrOwnerAcctMissing = sdkerrors.Register(ModuleName, errOwnerAcctMissing, "Owner account missing")
-	// ErrEmptyGroups is the error when groups are empty
+	// ErrInvalidGroups is the error when groups are empty
 	ErrInvalidGroups = sdkerrors.Register(ModuleName, errInvalidGroups, "Invalid groups")
 	// ErrInvalidDeploymentID is the error for invalid deployment id
 	ErrInvalidDeploymentID = sdkerrors.Register(ModuleName, errInvalidDeploymentID, "Invalid: deployment id")
@@ -44,4 +47,10 @@ var (
 	ErrInvalidDeployment = sdkerrors.Register(ModuleName, errInvalidDeployment, "Invalid deployment")
 	// ErrInvalidGroupID is the error when already deployment exists
 	ErrInvalidGroupID = sdkerrors.Register(ModuleName, errInvalidGroupID, "Deployment exists")
+	// ErrGroupNotFound is the keeper's error for not finding a group
+	ErrGroupNotFound = sdkerrors.Register(ModuleName, errGroupNotFound, "Group not found")
+	// ErrGroupClosed is the error when deployment is closed
+	ErrGroupClosed = sdkerrors.Register(ModuleName, errGroupClosed, "Group already closed")
+	// ErrGroupNotOpen indicates the Group state has progressed beyond initial Open.
+	ErrGroupNotOpen = sdkerrors.Register(ModuleName, errGroupNotOpen, "Group not open")
 )
diff --git a/x/deployment/types/event.go b/x/deployment/types/event.go
index d4f1eb8c..1fb04ea9 100644
--- a/x/deployment/types/event.go
+++ b/x/deployment/types/event.go
@@ -11,6 +11,7 @@ const (
 	evActionDeploymentCreate = "deployment-create"
 	evActionDeploymentUpdate = "deployment-update"
 	evActionDeploymentClose  = "deployment-close"
+	evActionGroupClose       = "group-close"
 	evOwnerKey               = "owner"
 	evDSeqKey                = "dseq"
 	evGSeqKey                = "gseq"
@@ -87,6 +88,21 @@ func ParseEVDeploymentID(attrs []sdk.Attribute) (DeploymentID, error) {
 	}, nil
 }
 
+// EventGroupClose provides SDK event to signal group termination
+type EventGroupClose struct {
+	ID GroupID
+}
+
+// ToSDKEvent produces the SDK notification for Event
+func (ev EventGroupClose) ToSDKEvent() sdk.Event {
+	return sdk.NewEvent(sdkutil.EventTypeMessage,
+		append([]sdk.Attribute{
+			sdk.NewAttribute(sdk.AttributeKeyModule, ModuleName),
+			sdk.NewAttribute(sdk.AttributeKeyAction, evActionGroupClose),
+		}, GroupIDEVAttributes(ev.ID)...)...,
+	)
+}
+
 // GroupIDEVAttributes returns event attribues for given GroupID
 func GroupIDEVAttributes(id GroupID) []sdk.Attribute {
 	return append(DeploymentIDEVAttributes(id.DeploymentID()),
@@ -140,6 +156,12 @@ func ParseEvent(ev sdkutil.Event) (sdkutil.ModuleEvent, error) {
 			return nil, err
 		}
 		return EventDeploymentClose{ID: did}, nil
+	case evActionGroupClose:
+		gid, err := ParseEVGroupID(ev.Attributes)
+		if err != nil {
+			return nil, err
+		}
+		return EventGroupClose{ID: gid}, nil
 	default:
 		return nil, sdkutil.ErrUnknownAction
 	}
diff --git a/x/deployment/types/msgs.go b/x/deployment/types/msgs.go
index 90dac0a3..10d707b9 100644
--- a/x/deployment/types/msgs.go
+++ b/x/deployment/types/msgs.go
@@ -8,6 +8,7 @@ const (
 	msgTypeCreateDeployment = "create-deployment"
 	msgTypeUpdateDeployment = "update-deployment"
 	msgTypeCloseDeployment  = "close-deployment"
+	msgTypeCloseGroup       = "close-group"
 )
 
 // MsgCreateDeployment defines an SDK message for creating deployment
@@ -108,3 +109,32 @@ func (msg MsgCloseDeployment) GetSignBytes() []byte {
 func (msg MsgCloseDeployment) GetSigners() []sdk.AccAddress {
 	return []sdk.AccAddress{msg.ID.Owner}
 }
+
+// MsgCloseGroup defines SDK message to close a single Group within a Deployment.
+type MsgCloseGroup struct {
+	ID GroupID
+}
+
+// Route implements the sdk.Msg interface for routing
+func (msg MsgCloseGroup) Route() string { return RouterKey }
+
+// Type implements the sdk.Msg interface exposing message type
+func (msg MsgCloseGroup) Type() string { return msgTypeCloseGroup }
+
+// ValidateBasic calls underlying GroupID.Validate() check and returns result
+func (msg MsgCloseGroup) ValidateBasic() error {
+	if err := msg.ID.Validate(); err != nil {
+		return err
+	}
+	return nil
+}
+
+// GetSignBytes encodes the message for signing
+func (msg MsgCloseGroup) GetSignBytes() []byte {
+	return sdk.MustSortJSON(cdc.MustMarshalJSON(msg))
+}
+
+// GetSigners defines whose signature is required
+func (msg MsgCloseGroup) GetSigners() []sdk.AccAddress {
+	return []sdk.AccAddress{msg.ID.Owner}
+}
diff --git a/x/deployment/types/types.go b/x/deployment/types/types.go
index 3e6fa72f..e90ff058 100644
--- a/x/deployment/types/types.go
+++ b/x/deployment/types/types.go
@@ -1,8 +1,6 @@
 package types
 
 import (
-	"errors"
-
 	sdk "github.com/cosmos/cosmos-sdk/types"
 
 	"github.com/ovrclk/akash/types"
@@ -20,10 +18,6 @@ const (
 	DeploymentClosed // closed
 )
 
-var (
-	ErrGroupNotOpen = errors.New("group not open")
-)
-
 // DeploymentStateMap is used to decode deployment state flag value
 var DeploymentStateMap = map[string]DeploymentState{
 	"active": DeploymentActive,
@@ -58,15 +52,6 @@ const (
 	GroupClosed // closed
 )
 
-// GroupStateMap is used to decode group state flag value
-var GroupStateMap = map[string]GroupState{
-	"open":               GroupOpen,
-	"ordered":            GroupOrdered,
-	"matched":            GroupMatched,
-	"insufficient-funds": GroupInsufficientFunds,
-	"closed":             GroupClosed,
-}
-
 // GroupSpec stores group specifications
 type GroupSpec struct {
 	Name         string          `json:"name"`
@@ -126,13 +111,13 @@ type Group struct {
 }
 
 // ID method returns GroupID details of specific group
-func (d Group) ID() GroupID {
-	return d.GroupID
+func (g Group) ID() GroupID {
+	return g.GroupID
 }
 
-// ValidateOrderable method checks whether group opened or not
-func (d Group) ValidateOrderable() error {
-	switch d.State {
+// ValidateOrderable method checks whether group status is Open or not
+func (g Group) ValidateOrderable() error {
+	switch g.State {
 	case GroupOpen:
 		return nil
 	default:
@@ -140,6 +125,17 @@ func (d Group) ValidateOrderable() error {
 	}
 }
 
+// ValidateClosable provides error response if group is already closed,
+// and thus should not be closed again, else nil.
+func (g Group) ValidateClosable() error {
+	switch g.State {
+	case GroupClosed:
+		return ErrGroupClosed
+	default:
+		return nil
+	}
+}
+
 // Resource stores unit, count and price of each resource
 type Resource struct {
 	Unit  types.Unit `json:"unit"`


Test patch:
diff --git a/events/publish_test.go b/events/publish_test.go
index a10d25e6..2da67bd1 100644
--- a/events/publish_test.go
+++ b/events/publish_test.go
@@ -18,6 +18,7 @@ func Test_processEvent(t *testing.T) {
 		dtypes.EventDeploymentCreate{ID: testutil.DeploymentID(t)},
 		dtypes.EventDeploymentUpdate{ID: testutil.DeploymentID(t)},
 		dtypes.EventDeploymentClose{ID: testutil.DeploymentID(t)},
+		dtypes.EventGroupClose{ID: testutil.GroupID(t)},
 
 		// x/market events
 		mtypes.EventOrderCreated{ID: testutil.OrderID(t)},
diff --git a/x/deployment/keeper/keeper_test.go b/x/deployment/keeper/keeper_test.go
index 43f21cd2..ecc07832 100644
--- a/x/deployment/keeper/keeper_test.go
+++ b/x/deployment/keeper/keeper_test.go
@@ -221,6 +221,53 @@ func Test_OnDeploymentClosed(t *testing.T) {
 	})
 }
 
+func Test_CloseGroup(t *testing.T) {
+	ctx, keeper := setupKeeper(t)
+	groups := createActiveDeployment(t, ctx, keeper)
+
+	t.Run("assert group 0 state closed", func(t *testing.T) {
+		assert.NoError(t, keeper.OnCloseGroup(ctx, groups[0]))
+		group, ok := keeper.GetGroup(ctx, groups[0].ID())
+		assert.True(t, ok)
+		assert.Equal(t, types.GroupClosed, group.State)
+
+		keeper.OnDeploymentClosed(ctx, group) // coverage: catch an additional code path
+		assert.Equal(t, types.GroupClosed, group.State)
+	})
+	t.Run("group 1 matched-state not-orderable", func(t *testing.T) {
+		group := groups[1]
+		assert.Equal(t, group.ValidateOrderable(), types.ErrGroupNotOpen, group.State.String())
+	})
+}
+
+func Test_CloseOpenGroups(t *testing.T) {
+	ctx, keeper := setupKeeper(t)
+	groups := createDeploymentsWithState(t, ctx, types.GroupOpen, keeper)
+
+	t.Run("assert group 0 state closed", func(t *testing.T) {
+		assert.NoError(t, keeper.OnCloseGroup(ctx, groups[0]))
+		group, ok := keeper.GetGroup(ctx, groups[0].ID())
+		assert.True(t, ok)
+		assert.Equal(t, types.GroupClosed, group.State)
+	})
+	t.Run("group 1 matched-state still orderable", func(t *testing.T) {
+		group := groups[1]
+		assert.Equal(t, group.ValidateOrderable(), nil, group.State.String())
+	})
+}
+
+func Test_Empty_CloseGroup(t *testing.T) {
+	ctx, keeper := setupKeeper(t)
+	group := types.Group{
+		GroupID: testutil.GroupID(t),
+	}
+
+	t.Run("assert non-existent group returns error", func(t *testing.T) {
+		err := keeper.OnCloseGroup(ctx, group)
+		assert.Error(t, err, "'group not found' error should be returned")
+	})
+}
+
 func createActiveDeployment(t testing.TB, ctx sdk.Context, keeper keeper.Keeper) []types.Group {
 	t.Helper()
 
@@ -239,6 +286,24 @@ func createActiveDeployment(t testing.TB, ctx sdk.Context, keeper keeper.Keeper)
 	return groups
 }
 
+func createDeploymentsWithState(t testing.TB, ctx sdk.Context, gState types.GroupState, keeper keeper.Keeper) []types.Group {
+	t.Helper()
+
+	deployment := testutil.Deployment(t)
+	groups := []types.Group{
+		testutil.DeploymentGroup(t, deployment.ID(), 0),
+		testutil.DeploymentGroup(t, deployment.ID(), 1),
+	}
+	for i := range groups {
+		groups[i].State = gState
+	}
+
+	err := keeper.Create(ctx, deployment, groups)
+	require.NoError(t, err)
+
+	return groups
+}
+
 func setupKeeper(t testing.TB) (sdk.Context, keeper.Keeper) {
 	t.Helper()
 	key := sdk.NewKVStoreKey(types.StoreKey)
diff --git a/x/deployment/types/events_test.go b/x/deployment/types/events_test.go
index 475b2cb0..1149f18a 100644
--- a/x/deployment/types/events_test.go
+++ b/x/deployment/types/events_test.go
@@ -1,7 +1,7 @@
 package types
 
 import (
-	"fmt"
+	"strconv"
 	"testing"
 
 	sdk "github.com/cosmos/cosmos-sdk/types"
@@ -12,9 +12,7 @@ import (
 )
 
 var (
-	keyAcc, _ = sdk.AccAddressFromBech32("akash1qtqpdszzakz7ugkey7ka2cmss95z26ygar2mgr")
-	//keyParams = sdk.NewKVStoreKey(params.StoreKey)
-
+	keyAcc, _   = sdk.AccAddressFromBech32("akash1qtqpdszzakz7ugkey7ka2cmss95z26ygar2mgr")
 	errWildcard = errors.New("wildcard string error can't be matched")
 )
 
@@ -143,6 +141,64 @@ var TEPS = []testEventParsing{
 		},
 		expErr: nil,
 	},
+	{
+		msg: sdkutil.Event{
+			Type:   sdkutil.EventTypeMessage,
+			Module: ModuleName,
+			Action: evActionGroupClose,
+			Attributes: []sdk.Attribute{
+				{
+					Key:   evOwnerKey,
+					Value: keyAcc.String(),
+				},
+				{
+					Key:   evDSeqKey,
+					Value: "5",
+				},
+				{
+					Key:   evGSeqKey,
+					Value: "1",
+				},
+			},
+		},
+		expErr: nil,
+	},
+	{
+		msg: sdkutil.Event{
+			Type:   sdkutil.EventTypeMessage,
+			Module: ModuleName,
+			Action: evActionGroupClose,
+			Attributes: []sdk.Attribute{
+				{
+					Key:   evOwnerKey,
+					Value: keyAcc.String(),
+				},
+				{
+					Key:   evDSeqKey,
+					Value: "5",
+				},
+			},
+		},
+		expErr: errWildcard,
+	},
+	{
+		msg: sdkutil.Event{
+			Type:   sdkutil.EventTypeMessage,
+			Module: ModuleName,
+			Action: evActionGroupClose,
+			Attributes: []sdk.Attribute{
+				{
+					Key:   evOwnerKey,
+					Value: keyAcc.String(),
+				},
+				{
+					Key:   evGSeqKey,
+					Value: "1",
+				},
+			},
+		},
+		expErr: errWildcard,
+	},
 	{
 		msg: sdkutil.Event{
 			Type:   sdkutil.EventTypeMessage,
@@ -179,7 +235,6 @@ var TEPS = []testEventParsing{
 
 func TestEventParsing(t *testing.T) {
 	for i, test := range TEPS {
-		t.Run(fmt.Sprintf("%d", i),
-			test.testMessageType())
+		t.Run(strconv.Itoa(i), test.testMessageType())
 	}
 }
diff --git a/x/deployment/types/types_test.go b/x/deployment/types/types_test.go
new file mode 100644
index 00000000..8f9e4a32
--- /dev/null
+++ b/x/deployment/types/types_test.go
@@ -0,0 +1,56 @@
+package types_test
+
+import (
+	"testing"
+
+	"github.com/ovrclk/akash/testutil"
+	"github.com/ovrclk/akash/x/deployment/types"
+	"github.com/stretchr/testify/assert"
+)
+
+type gStateTest struct {
+	state                types.GroupState
+	expValidateOrderable error
+	expValidateClosable  error
+}
+
+func TestGroupState(t *testing.T) {
+	tests := []gStateTest{
+		{
+			state: types.GroupOpen,
+		},
+		{
+			state:                types.GroupOrdered,
+			expValidateOrderable: types.ErrGroupNotOpen,
+		},
+		{
+			state:                types.GroupMatched,
+			expValidateOrderable: types.ErrGroupNotOpen,
+		},
+		{
+			state:                types.GroupInsufficientFunds,
+			expValidateOrderable: types.ErrGroupNotOpen,
+		},
+		{
+			state:                types.GroupClosed,
+			expValidateClosable:  types.ErrGroupClosed,
+			expValidateOrderable: types.ErrGroupNotOpen,
+		},
+		{
+			state:                types.GroupState(99),
+			expValidateOrderable: types.ErrGroupNotOpen,
+		},
+	}
+
+	for i, test := range tests {
+		t.Logf("------test-%d: %#v", i, test)
+		group := types.Group{
+			GroupID: testutil.GroupID(t),
+			State:   test.state,
+		}
+
+		assert.Equal(t, group.ValidateOrderable(), test.expValidateOrderable, group.State)
+
+		assert.Equal(t, group.ValidateClosable(), test.expValidateClosable, group.State)
+	}
+}