Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Ref: https://keepachangelog.com/en/1.0.0/
## [Unreleased]

* (ibc-go) Use ibc v4.4.0 https://pkg.akt.dev/node/pull/1825
* Pause deployment groups when account is overdrawn
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Format the changelog entry per guidelines.

The changelog entry should follow the format specified in lines 15-19: * (<tag>) \#<issue-number> message. It should also be placed under an appropriate stanza (e.g., "Features" or "Improvements") rather than directly under [Unreleased].

Apply this diff to fix the formatting:

 ## [Unreleased]
 
 * (ibc-go) Use ibc v4.4.0 https://pkg.akt.dev/node/pull/1825
-* Pause deployment groups when account is overdrawn
 
 ### Improvements
 
+* (escrow) \#368 Pause deployment groups when account is overdrawn
 * (sdk) Bump Cosmos SDK version to [v0.38.3](https://github.com/cosmos/cosmos-sdk/releases/tag/v0.38.3)
🤖 Prompt for AI Agents
In CHANGELOG.md around line 42, the entry "* Pause deployment groups when
account is overdrawn" is not formatted per the project's changelog guideline and
sits directly under [Unreleased]; change it to the prescribed format and place
it under the correct stanza: convert the line to something like "* (improvement)
#<issue-number> Pause deployment groups when account is overdrawn" (replace
<issue-number> with the real issue/PR number and pick the correct tag, e.g.,
"improvement" or "feature"), and move that formatted bullet into the appropriate
section header (for example under "## Improvements" or "## Features") instead of
directly beneath [Unreleased].


### Improvements

Expand Down
1 change: 1 addition & 0 deletions app/types/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@ func (app *App) SetupHooks() {
)

app.Keepers.Akash.Escrow.AddOnAccountClosedHook(hook.OnEscrowAccountClosed)
app.Keepers.Akash.Escrow.AddOnAccountPausedHook(hook.OnEscrowAccountPaused)
app.Keepers.Akash.Escrow.AddOnPaymentClosedHook(hook.OnEscrowPaymentClosed)
}

Expand Down
9 changes: 7 additions & 2 deletions x/deployment/keeper/keeper.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,9 +311,15 @@ func (k Keeper) OnPauseGroup(ctx sdk.Context, group types.Group) error {
store.Delete(key)

group.State = types.GroupPaused

key, err := GroupKey(GroupStateToPrefix(group.State), group.ID)
if err != nil {
return fmt.Errorf("%s: failed to encode group key", err)
}

Comment on lines +314 to +319
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at OnCloseGroup, it seems like the new key with the updated prefix should have been used here or else it gets saved to the wrong part of the KVStore.

It does look like findGroup was checking all possible variants of a key when looking for a group, so it probably could have been found anyways, but this should still be a more accurate state/key for the Group

store.Set(key, k.cdc.MustMarshal(&group))

err := ctx.EventManager().EmitTypedEvent(
err = ctx.EventManager().EmitTypedEvent(
&v1.EventGroupPaused{
ID: group.ID,
},
Expand All @@ -322,7 +328,6 @@ func (k Keeper) OnPauseGroup(ctx sdk.Context, group types.Group) error {
return err
}

store.Set(key, k.cdc.MustMarshal(&group))
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was already called a few lines above

return nil
}

Expand Down
51 changes: 30 additions & 21 deletions x/deployment/keeper/keeper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,40 +117,49 @@ func Test_UpdateDeployment(t *testing.T) {
require.Equal(t, deployment, result)
}

func Test_OnEscrowAccountClosed_overdrawn(t *testing.T) {
t.Skip("Hooks Refactor")
func Test_OnEscrowAccountPaused_overdrawn(t *testing.T) {
ctx, keeper := setupKeeper(t)

_, groups := createActiveDeployment(t, ctx, keeper)

did := groups[0].ID.DeploymentID()

// eid := types.EscrowAccountForDeployment(did)

// eobj := etypes.Account{
// ID: eid,
// State: etypes.AccountOverdrawn,
// }

// keeper.OnEscrowAccountClosed(ctx, eobj)

{
t.Run("assert that deployment starts in active state", func(t *testing.T) {
deployment, ok := keeper.GetDeployment(ctx, did)
assert.True(t, ok)
assert.Equal(t, types.DeploymentActive, deployment.State)
})
t.Run("assert group 0 starts in open state", func(t *testing.T) {
group, ok := keeper.GetGroup(ctx, groups[0].ID)
assert.True(t, ok)
assert.Equal(t, v1beta4.GroupInsufficientFunds, group.State)
}

{
assert.Equal(t, v1beta4.GroupOpen, group.State)
})
t.Run("assert group 1 starts in open state", func(t *testing.T) {
group, ok := keeper.GetGroup(ctx, groups[1].ID)
assert.True(t, ok)
assert.Equal(t, v1beta4.GroupInsufficientFunds, group.State)
assert.Equal(t, v1beta4.GroupOpen, group.State)
})

// Simulate escrow account becoming overdrawn by calling pausing all groups
for _, group := range groups {
err := keeper.OnPauseGroup(ctx, group)
require.NoError(t, err)
}

{
t.Run("assert deployment is still active", func(t *testing.T) {
deployment, ok := keeper.GetDeployment(ctx, did)
assert.True(t, ok)
assert.Equal(t, types.DeploymentClosed, deployment.State)
}
assert.Equal(t, types.DeploymentActive, deployment.State)
})
t.Run("assert group 0 is paused", func(t *testing.T) {
group, ok := keeper.GetGroup(ctx, groups[0].ID)
assert.True(t, ok)
assert.Equal(t, v1beta4.GroupPaused, group.State)
})
t.Run("assert group 1 is paused", func(t *testing.T) {
group, ok := keeper.GetGroup(ctx, groups[1].ID)
assert.True(t, ok)
assert.Equal(t, v1beta4.GroupPaused, group.State)
})
}

func Test_OnBidClosed(t *testing.T) {
Expand Down
16 changes: 14 additions & 2 deletions x/escrow/keeper/keeper.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type Keeper interface {
GetAccount(ctx sdk.Context, id escrowid.Account) (etypes.Account, error)
GetPayment(ctx sdk.Context, id escrowid.Payment) (etypes.Payment, error)
AddOnAccountClosedHook(AccountHook) Keeper
AddOnAccountPausedHook(AccountHook) Keeper
AddOnPaymentClosedHook(PaymentHook) Keeper
WithAccounts(sdk.Context, func(etypes.Account) bool)
WithPayments(sdk.Context, func(etypes.Payment) bool)
Expand Down Expand Up @@ -76,6 +77,7 @@ type keeper struct {
feepool collections.Item[distrtypes.FeePool]
hooks struct {
onAccountClosed []AccountHook
onAccountPaused []AccountHook
onPaymentClosed []PaymentHook
}
}
Expand Down Expand Up @@ -319,7 +321,7 @@ func (k *keeper) AccountDeposit(ctx sdk.Context, id escrowid.Account, deposits [
return err
}

if obj.State.State != etypes.StateOpen {
if obj.State.State != etypes.StateOpen && obj.State.State != etypes.StateOverdrawn {
return module.ErrAccountClosed
}

Expand Down Expand Up @@ -584,6 +586,11 @@ func (k *keeper) AddOnAccountClosedHook(hook AccountHook) Keeper {
return k
}

func (k *keeper) AddOnAccountPausedHook(hook AccountHook) Keeper {
k.hooks.onAccountPaused = append(k.hooks.onAccountPaused, hook)
return k
}

func (k *keeper) AddOnPaymentClosedHook(hook PaymentHook) Keeper {
k.hooks.onPaymentClosed = append(k.hooks.onPaymentClosed, hook)
return k
Expand Down Expand Up @@ -769,11 +776,16 @@ func (k *keeper) saveAccount(ctx sdk.Context, obj account) error {

store.Set(key, k.cdc.MustMarshal(&obj.State))

if obj.State.State == etypes.StateClosed || obj.State.State == etypes.StateOverdrawn {
if obj.State.State == etypes.StateClosed {
// call hooks
for _, hook := range k.hooks.onAccountClosed {
hook(ctx, obj.Account)
}
} else if obj.State.State == etypes.StateOverdrawn {
// call hooks
for _, hook := range k.hooks.onAccountPaused {
hook(ctx, obj.Account)
}
}

return nil
Expand Down
24 changes: 24 additions & 0 deletions x/escrow/keeper/keeper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,20 @@ func Test_Payment_Overdraw(t *testing.T) {
powner := testutil.AccAddress(t)
rate := testutil.AkashCoin(t, 10)

// Track hook calls
var accountPausedHookCalled bool
var accountPausedHookAccount etypes.Account
var accountClosedHookCalled bool

// Add hook to track when account becomes overdrawn
keeper.AddOnAccountPausedHook(func(ctx sdk.Context, account etypes.Account) {
accountPausedHookCalled = true
accountPausedHookAccount = account
})
keeper.AddOnAccountClosedHook(func(ctx sdk.Context, account etypes.Account) {
accountClosedHookCalled = true
})

// create account
bkeeper.
On("SendCoinsFromAccountToModule", ctx, aowner, module.ModuleName, sdk.NewCoins(amt)).
Expand Down Expand Up @@ -221,6 +235,16 @@ func Test_Payment_Overdraw(t *testing.T) {
require.Equal(t, amt, payment.State.Withdrawn)
require.Equal(t, testutil.AkashDecCoin(t, 0), payment.State.Balance)
}

t.Run("ensure hooks were called when overdrawn", func(t *testing.T) {
// Verify hook was called
require.True(t, accountPausedHookCalled, "OnEscrowAccountPaused hook should have been called")
require.Equal(t, aid, accountPausedHookAccount.ID, "hook should have been called with correct account ID")
require.Equal(t, etypes.StateOverdrawn, accountPausedHookAccount.State.State, "hook should have been called with overdrawn account")
})
t.Run("ensure closed hook was not called when overdrawn", func(t *testing.T) {
require.False(t, accountClosedHookCalled, "OnEscrowAccountClosed hook should not have been called")
})
}

func Test_PaymentCreate_later(t *testing.T) {
Expand Down
1 change: 1 addition & 0 deletions x/market/hooks/external.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
24 changes: 24 additions & 0 deletions x/market/hooks/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

type Hooks interface {
OnEscrowAccountClosed(ctx sdk.Context, obj etypes.Account)
OnEscrowAccountPaused(ctx sdk.Context, obj etypes.Account)
OnEscrowPaymentClosed(ctx sdk.Context, obj etypes.Payment)
}

Expand Down Expand Up @@ -56,6 +57,29 @@ func (h *hooks) OnEscrowAccountClosed(ctx sdk.Context, obj etypes.Account) {
}
}

func (h *hooks) OnEscrowAccountPaused(ctx sdk.Context, obj etypes.Account) {
id, err := dv1.DeploymentIDFromEscrowID(obj.ID)
if err != nil {
return
}

deployment, found := h.dkeeper.GetDeployment(ctx, id)
if !found {
return
}

if deployment.State != dv1.DeploymentActive {
return
}

for _, group := range h.dkeeper.GetGroups(ctx, deployment.ID) {
if group.ValidatePausable() == nil {
_ = h.dkeeper.OnPauseGroup(ctx, group)
_ = h.mkeeper.OnGroupClosed(ctx, group.ID)
}
}
}
Comment on lines +60 to +81
Copy link

@coderabbitai coderabbitai bot Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify OnGroupClosed is correct when pausing groups.

Line 78 calls OnGroupClosed after pausing the group. While this mirrors the pattern in OnEscrowAccountClosed (line 55), please confirm this is intentional. When a group is paused (not closed), should market resources (orders, bids, leases) be closed?

If pausing should preserve market state for resumption, consider whether a different market keeper method (e.g., OnGroupPaused) might be more appropriate.


🏁 Script executed:

#!/bin/bash
# Verify OnGroupClosed behavior and check if OnGroupPaused exists

# Check if OnGroupPaused method exists in MarketKeeper interface
ast-grep --pattern $'OnGroupPaused($$$) $$$'

# Check OnGroupClosed implementation to understand what it does
ast-grep --pattern $'func ($$$) OnGroupClosed(ctx $_, id $_) error {
  $$$
}'

Length of output: 2724



---

🏁 Script executed:

```shell
#!/bin/bash
# Check if paused deployments can be resumed and explore pause/unpause semantics

# Search for resume or unpause functionality
rg -i "unpause|resume" -A 2 -B 2 --type go

# Check deployment state transitions
rg "DeploymentPaused\|DeploymentActive" -C 2 --type go

# Check what happens when escrow account is resumed/unpaused
rg "OnEscrowAccountResumed\|OnEscrowAccountUnpaused" -A 5 --type go

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Investigate deployment pause semantics more directly

# Check deployment state enum values
rg "DeploymentPaused|DeploymentClosed|type.*State" -B 2 -A 2 --type go | head -50

# Look at OnEscrowAccountPaused implementation to understand pattern
rg -A 20 "func.*OnEscrowAccountPaused" --type go

# Check if paused deployments have any restrictions or special handling
rg "State.*Paused\|Paused.*State" -C 3 --type go

Length of output: 4967


🏁 Script executed:

#!/bin/bash
# Find OnPauseGroup implementation and examine the test

# Search for OnPauseGroup method
rg -A 15 "func.*OnPauseGroup" --type go

# Get the full test case to understand intended behavior
rg -A 50 "func Test_OnEscrowAccountPaused_overdrawn" --type go

Length of output: 4348


🏁 Script executed:

#!/bin/bash
# Check if paused groups/deployments can resume or transition back to active state

# Search for transitions FROM GroupPaused or DeploymentPaused to active states
rg "GroupPaused|DeploymentPaused" -B 3 -A 3 --type go | grep -A 3 -B 3 "GroupOpen\|DeploymentActive\|Active\|resume"

# Check if there's any method that resumes or unpauses a group
rg "GroupOpen" --type go | grep -i "paused\|resume"

# Look for state transition logic
rg "group.State\s*=" -B 2 -A 2 --type go | head -80

Length of output: 1930


🏁 Script executed:

#!/bin/bash
# Find the method that transitions group.State to GroupOpen

# Search for methods containing the state transition to GroupOpen
rg -B 30 "group.State = types.GroupOpen" --type go | grep -A 30 "^x/deployment"

Length of output: 1551


Correct the hook to not close market resources on pause—groups can be resumed.

The review comment is valid. OnPauseGroup transitions the group state to GroupPaused, and there exists an OnStartGroup method that transitions paused groups back to GroupOpen. This means groups are designed to be resumable.

However, OnGroupClosed permanently closes orders, bids, and leases. Calling OnGroupClosed during pause means market resources are destroyed and cannot be restored if the group is later resumed via OnStartGroup. This violates pause semantics—pausing should be temporary.

The line 78 call to h.mkeeper.OnGroupClosed(ctx, group.ID) should be removed or replaced with a method like OnGroupPaused (if one is needed for market-side pause handling) to preserve market state for potential resumption.

🤖 Prompt for AI Agents
In x/market/hooks/hooks.go around lines 60 to 81, the hook currently calls
h.mkeeper.OnGroupClosed during an escrow pause which permanently destroys market
resources; remove the h.mkeeper.OnGroupClosed(ctx, group.ID) call (or replace it
with a non-destructive handler such as h.mkeeper.OnGroupPaused(ctx, group.ID) if
the market keeper exposes an explicit pause method) so that pausing only
transitions the group to GroupPaused and preserves orders/bids/leases for
potential resumption.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

May be useful to get some feedback here from someone more familiar with desired state. I was initially under the impression that on re-funding and resuming, new orders, etc would have to be created, though maybe this is not actually the case and was a misunderstanding on my side

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check OnStartGroup implementation to see if it recreates market resources
ast-grep --pattern $'func ($$$) OnStartGroup(ctx $_, $_, $_) error {
  $$$
}'

# Search for OnStartGroup usage and implementation
rg -A 20 "func.*OnStartGroup" --type go

# Check what OnGroupClosed does in the market keeper
rg -A 25 "func.*OnGroupClosed" --type go | head -100

Length of output: 4237


🏁 Script executed:

#!/bin/bash
# Look for order/bid/lease creation logic that might happen on resume
rg "CreateOrder|NewOrder|create.*order" -i -C 3 --type go | head -80

# Check if there's any logic that recreates market state
rg "resume.*order|unpause.*order|reactivate" -i -C 3 --type go

Length of output: 4934


🏁 Script executed:

#!/bin/bash
# Check the market keeper interface to understand available methods
cat x/market/hooks/external.go

# Look for any pause-related market keeper methods
rg "OnGroup" --type go -g "x/market/**/*.go" | grep -i "interface\|func"

Length of output: 1282


🏁 Script executed:

#!/bin/bash
# Check tests related to pause/resume to understand intended behavior
rg -A 30 "pause.*resume|resume.*pause" -i --type go | head -100

# Look at deployment handler to see what happens when groups are started
rg -A 20 "MsgStartGroup|HandleMsgStartGroup" --type go

Length of output: 2716


@cmmarslender Your initial understanding was actually correct! Based on the code investigation, here's what happens:

Current Design Pattern:

  1. On Pause (when overdrawn):

    • Groups transition to GroupPaused state
    • OnGroupClosed is called, which permanently closes all market resources (orders, bids, leases, and escrow payments)
  2. On Resume (via MsgStartGroup):

    • Group transitions from GroupPaused to GroupOpen
    • The handler explicitly creates NEW orders via ms.market.CreateOrder(ctx, group.ID, group.GroupSpec) (see x/deployment/handler/server.go line ~73)

So the current implementation in this PR is consistent with that pattern—calling OnGroupClosed during pause is appropriate because:

  • Market resources are destroyed when paused
  • Fresh orders are created when the group is resumed
  • The pause/resume cycle intentionally goes through a full teardown and recreation of market state

My initial review concern was incorrect. The code is working as designed. Calling OnGroupClosed ensures clean teardown of the marketplace side before resumption, which will trigger new order creation.

If this is the intended behavior for handling overdrawn accounts, the current implementation is correct. 👍


🧬 Code Graph Analysis Results
  • File: x/deployment/handler/keepers.go
    Line ranges: 19-22

    MarketKeeper interface {
        CreateOrder(ctx sdk.Context, id types.GroupID, spec v1beta4.GroupSpec) (mtypes.Order, error)
        OnGroupClosed(ctx sdk.Context, id types.GroupID) error
    }
    
  • File: x/market/handler/keepers.go
    Line ranges: 43-47

    DeploymentKeeper interface {
        GetGroup(ctx sdk.Context, id dtypes.GroupID) (dbeta.Group, bool)
        OnBidClosed(ctx sdk.Context, id dtypes.GroupID) error
        OnLeaseClosed(ctx sdk.Context, id dtypes.GroupID) (dbeta.Group, error)
    }
    
  • File: x/market/hooks/external.go
    Line ranges: 12-18

    DeploymentKeeper interface {
        GetDeployment(ctx sdk.Context, id dv1.DeploymentID) (dv1.Deployment, bool)
        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
    }
    
  • File: x/market/hooks/external.go
    Line ranges: 20-28

    MarketKeeper interface {
        GetOrder(ctx sdk.Context, id mv1.OrderID) (mtypes.Order, bool)
        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
        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
    }
    


func (h *hooks) OnEscrowPaymentClosed(ctx sdk.Context, obj etypes.Payment) {
id, err := mv1.LeaseIDFromPaymentID(obj.ID)
if err != nil {
Expand Down