diff --git a/x/market/hooks/external.go b/x/market/hooks/external.go index bcc227eb37..af41829154 100644 --- a/x/market/hooks/external.go +++ b/x/market/hooks/external.go @@ -14,6 +14,7 @@ type DeploymentKeeper interface { GetGroups(ctx sdk.Context, id dv1.DeploymentID) dtypes.Groups CloseDeployment(ctx sdk.Context, deployment dv1.Deployment) error OnCloseGroup(ctx sdk.Context, group dtypes.Group, state dtypes.Group_State) error + OnPauseGroup(ctx sdk.Context, group dtypes.Group) error } type MarketKeeper interface { @@ -21,6 +22,7 @@ type MarketKeeper interface { GetBid(ctx sdk.Context, id mv1.BidID) (mtypes.Bid, bool) GetLease(ctx sdk.Context, id mv1.LeaseID) (mv1.Lease, bool) OnGroupClosed(ctx sdk.Context, id dv1.GroupID) error + OnGroupPaused(ctx sdk.Context, id dv1.GroupID) error OnOrderClosed(ctx sdk.Context, order mtypes.Order) error OnBidClosed(ctx sdk.Context, bid mtypes.Bid) error OnLeaseClosed(ctx sdk.Context, lease mv1.Lease, state mv1.Lease_State, reason mv1.LeaseClosedReason) error diff --git a/x/market/hooks/hooks.go b/x/market/hooks/hooks.go index 212880f30a..852c7fdd0c 100644 --- a/x/market/hooks/hooks.go +++ b/x/market/hooks/hooks.go @@ -41,17 +41,29 @@ func (h *hooks) OnEscrowAccountClosed(ctx sdk.Context, obj etypes.Account) { if deployment.State != dv1.DeploymentActive { return } - _ = h.dkeeper.CloseDeployment(ctx, deployment) - gstate := dtypes.GroupClosed - if obj.State.State == etypes.StateOverdrawn { - gstate = dtypes.GroupInsufficientFunds + var gstate dtypes.Group_State + + switch obj.State.State { + case etypes.StateOverdrawn: + gstate = dtypes.GroupPaused + default: + gstate = dtypes.GroupClosed + _ = h.dkeeper.CloseDeployment(ctx, deployment) } for _, group := range h.dkeeper.GetGroups(ctx, deployment.ID) { - if group.ValidateClosable() == nil { - _ = h.dkeeper.OnCloseGroup(ctx, group, gstate) - _ = h.mkeeper.OnGroupClosed(ctx, group.ID) + switch gstate { + case dtypes.GroupPaused: + if group.ValidatePausable() == nil { + _ = h.dkeeper.OnPauseGroup(ctx, group) + _ = h.mkeeper.OnGroupPaused(ctx, group.ID) + } + case dtypes.GroupClosed: + if group.ValidateClosable() == nil { + _ = h.dkeeper.OnCloseGroup(ctx, group, gstate) + _ = h.mkeeper.OnGroupClosed(ctx, group.ID) + } } } } diff --git a/x/market/hooks/hooks_test.go b/x/market/hooks/hooks_test.go new file mode 100644 index 0000000000..0488156dce --- /dev/null +++ b/x/market/hooks/hooks_test.go @@ -0,0 +1,197 @@ +package hooks_test + +import ( + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dv1 "pkg.akt.dev/go/node/deployment/v1" + dtypes "pkg.akt.dev/go/node/deployment/v1beta4" + ev1 "pkg.akt.dev/go/node/escrow/id/v1" + etypes "pkg.akt.dev/go/node/escrow/types/v1" + "pkg.akt.dev/go/testutil" + cmocks "pkg.akt.dev/node/testutil/cosmos/mocks" + "pkg.akt.dev/node/testutil/state" + dkeeper "pkg.akt.dev/node/x/deployment/keeper" + ekeeper "pkg.akt.dev/node/x/escrow/keeper" + "pkg.akt.dev/node/x/market/hooks" + mkeeper "pkg.akt.dev/node/x/market/keeper" +) + +type testSuite struct { + t testing.TB + ctx sdk.Context + ekeeper ekeeper.Keeper + dkeeper dkeeper.IKeeper + mkeeper mkeeper.IKeeper + bkeeper *cmocks.BankKeeper +} + +type testSeedData struct { + did dv1.DeploymentID + aid ev1.Account +} + +type testInput struct { + accountState etypes.AccountState + deploymentState dv1.Deployment_State + groupState dtypes.Group_State +} + +func setupTestSuite(t *testing.T) *testSuite { + ssuite := state.SetupTestSuite(t) + + suite := &testSuite{ + t: t, + ctx: ssuite.Context(), + ekeeper: ssuite.EscrowKeeper(), + dkeeper: ssuite.DeploymentKeeper(), + mkeeper: ssuite.MarketKeeper(), + bkeeper: ssuite.BankKeeper(), + } + + return suite +} + +func TestEscrowAccountClose(t *testing.T) { + suite := setupTestSuite(t) + + tests := []struct { + description string + testInput testInput + expectedDeploymentState dv1.Deployment_State + expectedGroupState dtypes.Group_State + }{ + { + "Overdrawn account when deployment is active", + testInput{ + etypes.AccountState{ + State: etypes.StateOverdrawn, + }, + dv1.DeploymentActive, + dtypes.GroupOpen, + }, + dv1.DeploymentActive, + dtypes.GroupPaused, + }, + { + "Overdrawn account when deployment is closed", + testInput{ + etypes.AccountState{ + State: etypes.StateOverdrawn, + }, + dv1.DeploymentClosed, + dtypes.GroupClosed, + }, + dv1.DeploymentClosed, + dtypes.GroupClosed, + }, + { + "Account in good standing when deployment is active", + testInput{ + etypes.AccountState{ + State: etypes.StateOpen, + }, + dv1.DeploymentActive, + dtypes.GroupOpen, + }, + dv1.DeploymentClosed, + dtypes.GroupClosed, + }, + { + "Account in good standing when deployment is closed", + testInput{ + etypes.AccountState{ + State: etypes.StateOpen, + }, + dv1.DeploymentClosed, + dtypes.GroupClosed, + }, + dv1.DeploymentClosed, + dtypes.GroupClosed, + }, + { + "Account already closed", + testInput{ + etypes.AccountState{ + State: etypes.StateClosed, + }, + dv1.DeploymentActive, + dtypes.GroupOpen, + }, + dv1.DeploymentClosed, + dtypes.GroupClosed, + }, + { + "Account is in an invalid state", + testInput{ + etypes.AccountState{ + State: etypes.StateInvalid, + }, + dv1.DeploymentActive, + dtypes.GroupOpen, + }, + dv1.DeploymentClosed, + dtypes.GroupClosed, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + ctx := suite.ctx + hooks := hooks.New(suite.dkeeper, suite.mkeeper) + + seedData := setupSeedData(t, suite, tt.testInput) + + hooks.OnEscrowAccountClosed( + ctx, + etypes.Account{ + ID: seedData.aid, + State: tt.testInput.accountState, + }) + + deployment, found := suite.dkeeper.GetDeployment(ctx, seedData.did) + assert.NotNil(t, deployment) + assert.True(t, found) + + assert.Equal(t, tt.expectedDeploymentState, deployment.State) + + groups := suite.dkeeper.GetGroups(ctx, seedData.did) + + for _, g := range groups { + assert.Equal(t, tt.expectedGroupState, g.State) + } + }) + } +} + +func setupSeedData(t testing.TB, suite *testSuite, ti testInput) testSeedData { + t.Helper() + + ctx := suite.ctx + + did := testutil.DeploymentID(t) + aid := did.ToEscrowAccountID() + + deployment := dv1.Deployment{ + ID: did, + State: ti.deploymentState, + } + + groupCount := 3 + + groups := make([]dtypes.Group, groupCount) + for i := range groups { + groups[i] = testutil.DeploymentGroup(t, did, uint32(i)+1) + groups[i].State = ti.groupState + } + + require.NoError(t, suite.dkeeper.Create(ctx, deployment, groups)) + + return testSeedData{ + did: did, + aid: aid, + } +} diff --git a/x/market/keeper/keeper.go b/x/market/keeper/keeper.go index 107f983046..bf0d0e7cb1 100644 --- a/x/market/keeper/keeper.go +++ b/x/market/keeper/keeper.go @@ -29,6 +29,7 @@ type IKeeper interface { OnOrderClosed(ctx sdk.Context, order types.Order) error OnLeaseClosed(ctx sdk.Context, lease mv1.Lease, state mv1.Lease_State, reason mv1.LeaseClosedReason) error OnGroupClosed(ctx sdk.Context, id dtypes.GroupID) error + OnGroupPaused(ctx sdk.Context, id dtypes.GroupID) error GetOrder(ctx sdk.Context, id mv1.OrderID) (types.Order, bool) GetBid(ctx sdk.Context, id mv1.BidID) (types.Bid, bool) GetLease(ctx sdk.Context, id mv1.LeaseID) (mv1.Lease, bool) @@ -346,8 +347,23 @@ func (k Keeper) OnLeaseClosed(ctx sdk.Context, lease mv1.Lease, state mv1.Lease_ return nil } -// OnGroupClosed updates state of all orders, bids and leases in group to closed +// OnGroupClosed updates market resources when the group is closed func (k Keeper) OnGroupClosed(ctx sdk.Context, id dtypes.GroupID) error { + // OnGroupClosed is callable by x/deployment only so only reason is owner + return k.closeMarketResourcesForGroup(ctx, id, mv1.LeaseClosedReasonOwner) +} + +// OnGroupPaused updates market resources when the group is paused +func (k Keeper) OnGroupPaused(ctx sdk.Context, id dtypes.GroupID) error { + // OnGroupPaused can only be called when the group is paused due to insufficient funds (at the moment). + // This is hardcoded here to avoid passing extra parameters through multiple layers. + // And avoid exposing the lease closed reason in the signature. + // TODO Consider adding a group paused reason in the future if needed. + return k.closeMarketResourcesForGroup(ctx, id, mv1.LeaseClosedReasonInsufficientFunds) +} + +// closeMarketResourcesForGroup updates the state of all orders, bids and leases to closed for the associated group +func (k Keeper) closeMarketResourcesForGroup(ctx sdk.Context, id dtypes.GroupID, reason mv1.LeaseClosedReason) error { processClose := func(ctx sdk.Context, bid types.Bid) error { err := k.OnBidClosed(ctx, bid) if err != nil { @@ -355,8 +371,7 @@ func (k Keeper) OnGroupClosed(ctx sdk.Context, id dtypes.GroupID) error { } if lease, ok := k.GetLease(ctx, bid.ID.LeaseID()); ok { - // OnGroupClosed is callable by x/deployment only so only reason is owner - err = k.OnLeaseClosed(ctx, lease, mv1.LeaseClosed, mv1.LeaseClosedReasonOwner) + err = k.OnLeaseClosed(ctx, lease, mv1.LeaseClosed, reason) if err := k.ekeeper.PaymentClose(ctx, lease.ID.ToEscrowPaymentID()); err != nil { ctx.Logger().With("err", err).Info("error closing payment") }