Skip to content

Commit 7699b6c

Browse files
committed
fix(market): close all losing bid escrows on lease creation
1 parent 09c8b0f commit 7699b6c

4 files changed

Lines changed: 223 additions & 7 deletions

File tree

x/escrow/keeper/keeper.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,11 +1091,15 @@ func (k *keeper) saveAccount(ctx sdk.Context, obj *account) error {
10911091
store.Set(key, k.cdc.MustMarshal(&obj.State))
10921092

10931093
if obj.State.State == etypes.StateClosed || obj.State.State == etypes.StateOverdrawn {
1094-
// call hooks
1095-
for _, hook := range k.hooks.onAccountClosed {
1096-
err := hook(ctx, obj.Account)
1097-
if err != nil {
1098-
return err
1094+
// OnEscrowAccountClosed is deployment-only (deployment teardown). Bid and other scopes
1095+
// must not invoke it — DeploymentIDFromEscrowID rejects non-deployment IDs and would
1096+
// fail AccountClose during e.g. CreateLease loser processing.
1097+
if obj.ID.Scope == escrowid.ScopeDeployment {
1098+
for _, hook := range k.hooks.onAccountClosed {
1099+
err := hook(ctx, obj.Account)
1100+
if err != nil {
1101+
return err
1102+
}
10991103
}
11001104
}
11011105
}

x/escrow/keeper/keeper_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/stretchr/testify/mock"
1111
"github.com/stretchr/testify/require"
1212

13+
escrowid "pkg.akt.dev/go/node/escrow/id/v1"
1314
"pkg.akt.dev/go/node/escrow/module"
1415
etypes "pkg.akt.dev/go/node/escrow/types/v1"
1516
"pkg.akt.dev/go/testutil"
@@ -446,3 +447,80 @@ func Test_PaymentCreate_later(t *testing.T) {
446447
require.Equal(t, ctx.BlockHeight()-1, acct.State.SettledAt)
447448
}
448449
}
450+
451+
// Test_AccountCloseHooksRunOnlyForDeploymentEscrow guards the regression where bid escrow
452+
// AccountClose invoked the same onAccountClosed hooks as deployment escrows. The market
453+
// hook parses obj.ID as a deployment escrow id; ScopeBid ids made AccountClose fail and
454+
// broke CreateLease's loser loop. Hooks must run only for deployment-scoped accounts.
455+
func Test_AccountCloseHooksRunOnlyForDeploymentEscrow(t *testing.T) {
456+
ssuite := state.SetupTestSuite(t)
457+
ctx := ssuite.Context()
458+
ekeeper := ssuite.EscrowKeeper()
459+
bkeeper := ssuite.BankKeeper()
460+
461+
var hookScopes []escrowid.Scope
462+
ekeeper.AddOnAccountClosedHook(func(_ sdk.Context, acc etypes.Account) error {
463+
hookScopes = append(hookScopes, acc.ID.Scope)
464+
return nil
465+
})
466+
467+
mockBMEWithdrawals := func() {
468+
bkeeper.
469+
On("SendCoinsFromModuleToModule", mock.Anything, module.ModuleName, bmemodule.ModuleName, mock.Anything).
470+
Return(nil).Maybe()
471+
bkeeper.
472+
On("MintCoins", mock.Anything, bmemodule.ModuleName, mock.Anything).
473+
Return(nil).Maybe()
474+
bkeeper.
475+
On("BurnCoins", mock.Anything, bmemodule.ModuleName, mock.Anything).
476+
Return(nil).Maybe()
477+
bkeeper.
478+
On("SendCoinsFromModuleToAccount", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
479+
Return(nil).Maybe()
480+
}
481+
482+
bidID := testutil.BidID(t)
483+
bidAid := bidID.ToEscrowAccountID()
484+
require.Equal(t, escrowid.ScopeBid, bidAid.Scope)
485+
486+
bidOwner := testutil.AccAddress(t)
487+
bidAmt := testutil.ACTCoin(t, 800)
488+
ssuite.MockBMEForDeposit(bidOwner, bidAmt)
489+
require.NoError(t, ekeeper.AccountCreate(ctx, bidAid, bidOwner, []etypes.Depositor{{
490+
Owner: bidOwner.String(),
491+
Height: ctx.BlockHeight(),
492+
Balance: sdk.NewDecCoinFromCoin(bidAmt),
493+
}}))
494+
495+
ctx = ctx.WithBlockHeight(ctx.BlockHeight() + 5)
496+
mockBMEWithdrawals()
497+
require.NoError(t, ekeeper.AccountClose(ctx, bidAid))
498+
require.Empty(t, hookScopes, "no onAccountClosed hook should run for bid escrows")
499+
500+
bidAcct, err := ekeeper.GetAccount(ctx, bidAid)
501+
require.NoError(t, err)
502+
require.Equal(t, etypes.StateClosed, bidAcct.State.State)
503+
504+
hookScopes = nil
505+
depAid := testutil.DeploymentID(t).ToEscrowAccountID()
506+
require.Equal(t, escrowid.ScopeDeployment, depAid.Scope)
507+
508+
depOwner := testutil.AccAddress(t)
509+
depAmt := testutil.ACTCoinRandom(t)
510+
ssuite.MockBMEForDeposit(depOwner, depAmt)
511+
require.NoError(t, ekeeper.AccountCreate(ctx, depAid, depOwner, []etypes.Depositor{{
512+
Owner: depOwner.String(),
513+
Height: ctx.BlockHeight(),
514+
Balance: sdk.NewDecCoinFromCoin(depAmt),
515+
}}))
516+
517+
ctx = ctx.WithBlockHeight(ctx.BlockHeight() + 5)
518+
mockBMEWithdrawals()
519+
require.NoError(t, ekeeper.AccountClose(ctx, depAid))
520+
require.Equal(t, []escrowid.Scope{escrowid.ScopeDeployment}, hookScopes,
521+
"registered hooks must run for deployment escrow closes")
522+
523+
depAcct, err := ekeeper.GetAccount(ctx, depAid)
524+
require.NoError(t, err)
525+
require.Equal(t, etypes.StateClosed, depAcct.State.State)
526+
}

x/market/handler/handler_test.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1369,6 +1369,132 @@ func TestCloseBidUnknownOrder(t *testing.T) {
13691369
require.Error(t, err)
13701370
}
13711371

1372+
// Regression: CreateLease must mark every other open bid on the order lost, close each
1373+
// loser's bid escrow, and never stop the loser loop early. Mainnet had BID_OPEN + open
1374+
// bid escrow when deployment-only onAccountClosed hooks ran against ScopeBid accounts.
1375+
func TestCreateLease_AllOtherBidsOnOrderMarkedLost(t *testing.T) {
1376+
for _, tc := range []struct {
1377+
name string
1378+
numLosers int
1379+
}{
1380+
{name: "one_loser", numLosers: 1},
1381+
{name: "two_losers", numLosers: 2},
1382+
{name: "three_losers", numLosers: 3},
1383+
} {
1384+
t.Run(tc.name, func(t *testing.T) {
1385+
defaultDeposit, err := dtypes.DefaultParams().MinDepositFor("uact")
1386+
require.NoError(t, err)
1387+
1388+
suite := setupTestSuite(t)
1389+
ctx := suite.Context()
1390+
1391+
deployment := testutil.Deployment(t)
1392+
group := testutil.DeploymentGroup(t, deployment.ID, 0)
1393+
group.GroupSpec.Resources = testutil.Resources(t, testutil.WithDenom("uact"))
1394+
1395+
owner := sdk.MustAccAddressFromBech32(deployment.ID.Owner)
1396+
1397+
dmsg := &dtypes.MsgCreateDeployment{
1398+
ID: deployment.ID,
1399+
Groups: dtypes.GroupSpecs{group.GroupSpec},
1400+
Deposit: deposit.Deposit{
1401+
Amount: defaultDeposit,
1402+
Sources: deposit.Sources{deposit.SourceBalance},
1403+
},
1404+
}
1405+
1406+
suite.PrepareMocks(func(ts *state.TestSuite) {
1407+
ts.BankKeeper().
1408+
On("SendCoinsFromAccountToModule", mock.Anything, owner, emodule.ModuleName, sdk.NewCoins(dmsg.Deposit.Amount)).
1409+
Return(nil).Once()
1410+
})
1411+
1412+
res, err := suite.dhandler(ctx, dmsg)
1413+
require.NoError(t, err)
1414+
require.NotNil(t, res)
1415+
1416+
order, found := suite.MarketKeeper().GetOrder(ctx, mv1.OrderID{
1417+
Owner: deployment.ID.Owner,
1418+
DSeq: deployment.ID.DSeq,
1419+
GSeq: 1,
1420+
OSeq: 1,
1421+
})
1422+
require.True(t, found)
1423+
1424+
attr := group.GroupSpec.Requirements.Attributes
1425+
totalBids := tc.numLosers + 1
1426+
addrs := make([]sdk.AccAddress, totalBids)
1427+
bmsgs := make([]*mvbeta.MsgCreateBid, totalBids)
1428+
for i := 0; i < totalBids; i++ {
1429+
prov := suite.createProvider(attr)
1430+
addr, err := sdk.AccAddressFromBech32(prov.Owner)
1431+
require.NoError(t, err)
1432+
addrs[i] = addr
1433+
bmsgs[i] = &mvbeta.MsgCreateBid{
1434+
ID: mv1.MakeBidID(order.ID, addr),
1435+
Price: sdk.NewDecCoin(sdkutil.DenomUact, sdkmath.NewInt(int64(i+1))),
1436+
Deposit: deposit.Deposit{
1437+
Amount: mvbeta.DefaultBidMinDepositACT,
1438+
Sources: deposit.Sources{deposit.SourceBalance},
1439+
},
1440+
}
1441+
}
1442+
1443+
suite.PrepareMocks(func(ts *state.TestSuite) {
1444+
for i := range bmsgs {
1445+
ts.MockBMEForDeposit(addrs[i], bmsgs[i].Deposit.Amount)
1446+
}
1447+
})
1448+
1449+
for _, bm := range bmsgs {
1450+
res, err := suite.handler(ctx, bm)
1451+
require.NoError(t, err)
1452+
require.NotNil(t, res)
1453+
}
1454+
1455+
winnerID := mv1.MakeBidID(order.ID, addrs[0])
1456+
loserIDs := make([]mv1.BidID, tc.numLosers)
1457+
for i := 0; i < tc.numLosers; i++ {
1458+
loserIDs[i] = mv1.MakeBidID(order.ID, addrs[i+1])
1459+
}
1460+
1461+
// Loser bid AccountClose settles deposit back to provider; bank hooks must be allowed.
1462+
suite.PrepareMocks(func(ts *state.TestSuite) {
1463+
ts.BankKeeper().
1464+
On("SendCoinsFromModuleToAccount", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
1465+
Return(nil).Maybe().
1466+
On("SendCoinsFromModuleToModule", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
1467+
Return(nil).Maybe()
1468+
})
1469+
1470+
lmsg := &mvbeta.MsgCreateLease{BidID: winnerID}
1471+
leaseRes, err := suite.handler(ctx, lmsg)
1472+
require.NoError(t, err)
1473+
require.NotNil(t, leaseRes)
1474+
1475+
gotW, ok := suite.MarketKeeper().GetBid(ctx, winnerID)
1476+
require.True(t, ok)
1477+
require.Equal(t, mvbeta.BidActive, gotW.State)
1478+
1479+
winEscrow, err := suite.EscrowKeeper().GetAccount(ctx, winnerID.ToEscrowAccountID())
1480+
require.NoError(t, err)
1481+
require.Equal(t, etypes.StateOpen, winEscrow.State.State,
1482+
"winning bid escrow stays open for the lease")
1483+
1484+
for _, lid := range loserIDs {
1485+
got, ok := suite.MarketKeeper().GetBid(ctx, lid)
1486+
require.True(t, ok, "bid %v", lid)
1487+
require.Equal(t, mvbeta.BidLost, got.State, "bid %v", lid)
1488+
1489+
eacc, err := suite.EscrowKeeper().GetAccount(ctx, lid.ToEscrowAccountID())
1490+
require.NoError(t, err)
1491+
require.Equal(t, etypes.StateClosed, eacc.State.State,
1492+
"loser bid escrow must close (no deployment hook on ScopeBid)")
1493+
}
1494+
})
1495+
}
1496+
}
1497+
13721498
func (st *testSuite) createLease() (mv1.LeaseID, mvbeta.Bid, mvbeta.Order) {
13731499
st.t.Helper()
13741500
bid, order := st.createBid()

x/market/handler/server.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,11 +225,19 @@ func (ms msgServer) CreateLease(goCtx context.Context, msg *mvbeta.MsgCreateLeas
225225
ms.keepers.Market.OnBidMatched(ctx, bid)
226226

227227
// close losing bids
228+
//
229+
// Every other BID_OPEN on this order must run OnBidLost. Do not stop the loop when
230+
// AccountClose fails: returning true from the callback skips remaining bids, leaving them
231+
// BID_OPEN while the winning lease is already committed (mainnet-visible bug).
228232
ms.keepers.Market.WithBidsForOrder(ctx, msg.BidID.OrderID(), mvbeta.BidOpen, func(cbid mvbeta.Bid) bool {
229233
ms.keepers.Market.OnBidLost(ctx, cbid)
230234

231-
if err = ms.keepers.Escrow.AccountClose(ctx, cbid.ID.ToEscrowAccountID()); err != nil {
232-
return true
235+
if closeErr := ms.keepers.Escrow.AccountClose(ctx, cbid.ID.ToEscrowAccountID()); closeErr != nil {
236+
ctx.Logger().Error(
237+
"escrow AccountClose failed for losing bid after lease created; bid already marked lost",
238+
"bid", cbid.ID.String(),
239+
"error", closeErr,
240+
)
233241
}
234242
return false
235243
})

0 commit comments

Comments
 (0)