diff --git a/.changeset/purple-seas-destroy.md b/.changeset/purple-seas-destroy.md new file mode 100644 index 00000000000..edcc1dd94e5 --- /dev/null +++ b/.changeset/purple-seas-destroy.md @@ -0,0 +1,5 @@ +--- +"chainlink": minor +--- + +TXM feature to drop stale unstarted txes. #added diff --git a/core/chains/evm/txmgr/evm_tx_store.go b/core/chains/evm/txmgr/evm_tx_store.go index e83a83907e4..362c770947f 100644 --- a/core/chains/evm/txmgr/evm_tx_store.go +++ b/core/chains/evm/txmgr/evm_tx_store.go @@ -56,6 +56,7 @@ type TxStoreWebApi interface { FindTxAttempt(ctx context.Context, hash common.Hash) (*TxAttempt, error) FindTxWithAttempts(ctx context.Context, etxID int64) (etx Tx, err error) FindTxsByStateAndFromAddresses(ctx context.Context, addresses []common.Address, state txmgrtypes.TxState, chainID *big.Int) (txs []*Tx, err error) + DeleteUnstartedTxes(ctx context.Context, chainID *big.Int, subject uuid.UUID) error } type TestEvmTxStore interface { @@ -1872,6 +1873,39 @@ id < ( return } +func (o *evmTxStore) DeleteUnstartedTxes(ctx context.Context, chainID *big.Int, subject uuid.UUID) error { + var cancel context.CancelFunc + ctx, cancel = o.stopCh.Ctx(ctx) + defer cancel() + + err := o.Transact(ctx, false, func(orm *evmTxStore) error { + var err error + zeroUUID := uuid.UUID{} + if subject == zeroUUID { + // without subject deletes all unstarted txes + _, err = orm.q.ExecContext(ctx, ` +DELETE FROM evm.txes +WHERE state = 'unstarted' AND evm_chain_id = $1`, chainID.String()) + } else { + // with subject deletes only those subject unstarted txes + _, err = orm.q.ExecContext(ctx, ` +DELETE FROM evm.txes +WHERE state = 'unstarted' AND evm_chain_id = $1 AND subject = $2`, chainID.String(), subject) + } + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil + } + return fmt.Errorf("DeleteUnstartedTxes failed: %w", err) + } + + return nil + }) + + return err +} + func (o *evmTxStore) ReapTxHistory(ctx context.Context, minBlockNumberToKeep int64, timeThreshold time.Time, chainID *big.Int) error { var cancel context.CancelFunc ctx, cancel = o.stopCh.Ctx(ctx) diff --git a/core/chains/evm/txmgr/evm_tx_store_test.go b/core/chains/evm/txmgr/evm_tx_store_test.go index afb8de4ca52..9a217321587 100644 --- a/core/chains/evm/txmgr/evm_tx_store_test.go +++ b/core/chains/evm/txmgr/evm_tx_store_test.go @@ -1843,6 +1843,98 @@ func TestORM_PruneUnstartedTxQueue(t *testing.T) { }) } +func TestORM_DeleteUnstartedTxes(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + txStore := txmgr.NewTxStore(db, logger.Test(t)) + ethKeyStore := cltest.NewKeyStore(t, db).Eth() + evmtest.NewEthClientMockWithDefaultChain(t) + _, fromAddress := cltest.MustInsertRandomKeyReturningState(t, ethKeyStore) + + toAddress := testutils.NewAddress() + gasLimit := uint64(1000) + payload := []byte{1, 2, 3} + + txReq := txmgr.TxRequest{ + FromAddress: fromAddress, + ToAddress: toAddress, + EncodedPayload: payload, + FeeLimit: gasLimit, + Meta: nil, + Strategy: nil, + SignalCallback: true, + } + + strategyWithoutSubject := txmgrcommon.NewDropOldestStrategy(uuid.UUID{}, uint32(5)) + randomSubject := uuid.New() + strategyWithSubject := txmgrcommon.NewDropOldestStrategy(randomSubject, uint32(5)) + + t.Run("no transactions to delete when calling DeleteUnstartedTxes: handled gracefully", func(t *testing.T) { + err := txStore.DeleteUnstartedTxes(tests.Context(t), testutils.FixtureChainID, uuid.UUID{}) + assert.NoError(t, err) + }) + + t.Run("no subject provided to DeleteUnstartedTxes: deletes all unstarted txs.", func(t *testing.T) { + txReq.Strategy = strategyWithoutSubject + + // Create and assert 2 unstarted transactions without subject + _, err := txStore.CreateTransaction(tests.Context(t), txReq, testutils.FixtureChainID) + assert.NoError(t, err) + _, err = txStore.CreateTransaction(tests.Context(t), txReq, testutils.FixtureChainID) + assert.NoError(t, err) + AssertCountPerSubject(t, txStore, 2, uuid.UUID{}) + + // Create and assert 2 unstarted transactions with subject + txReq.Strategy = strategyWithSubject + _, err = txStore.CreateTransaction(tests.Context(t), txReq, testutils.FixtureChainID) + assert.NoError(t, err) + _, err = txStore.CreateTransaction(tests.Context(t), txReq, testutils.FixtureChainID) + assert.NoError(t, err) + AssertCountPerSubject(t, txStore, 2, randomSubject) + + // Deletes all unstarted transactions + err = txStore.DeleteUnstartedTxes(tests.Context(t), testutils.FixtureChainID, uuid.UUID{}) + assert.NoError(t, err) + + // Assert all unstarted transactions have been deleted + AssertCountPerSubject(t, txStore, 0, uuid.UUID{}) + }) + + t.Run("subject provided to DeleteUnstartedTxes: deletes only those subject unstarted txs.", func(t *testing.T) { + txReq.Strategy = strategyWithoutSubject + + // Create and assert 2 unstarted transactions without subject + _, err := txStore.CreateTransaction(tests.Context(t), txReq, testutils.FixtureChainID) + assert.NoError(t, err) + _, err = txStore.CreateTransaction(tests.Context(t), txReq, testutils.FixtureChainID) + assert.NoError(t, err) + AssertCountPerSubject(t, txStore, 2, uuid.UUID{}) + + // Create and assert 2 unstarted transactions with subject + txReq.Strategy = strategyWithSubject + _, err = txStore.CreateTransaction(tests.Context(t), txReq, testutils.FixtureChainID) + assert.NoError(t, err) + _, err = txStore.CreateTransaction(tests.Context(t), txReq, testutils.FixtureChainID) + assert.NoError(t, err) + AssertCountPerSubject(t, txStore, 2, randomSubject) + + // Deletes only unstarted transactions whose subject is randomSubject + err = txStore.DeleteUnstartedTxes(tests.Context(t), testutils.FixtureChainID, randomSubject) + assert.NoError(t, err) + + // Get txCount of unstarted transactions with and without subject. + txCountWithoutSubject, err := txStore.CountTxesByStateAndSubject(tests.Context(t), txmgrcommon.TxUnstarted, uuid.UUID{}) + assert.NoError(t, err) + txCountWithSubject, err := txStore.CountTxesByStateAndSubject(tests.Context(t), txmgrcommon.TxUnstarted, randomSubject) + assert.NoError(t, err) + + // Assert that the only transactions that have been deleted are those whose subject is randomSubject + require.Equal(t, txCountWithoutSubject, 2) + require.Equal(t, txCountWithSubject, 0) + }) +} + func TestORM_FindTxesWithAttemptsAndReceiptsByIdsAndState(t *testing.T) { t.Parallel() @@ -1867,7 +1959,7 @@ func TestORM_FindTxesWithAttemptsAndReceiptsByIdsAndState(t *testing.T) { func AssertCountPerSubject(t *testing.T, txStore txmgr.TestEvmTxStore, expected int64, subject uuid.UUID) { t.Helper() - count, err := txStore.CountTxesByStateAndSubject(tests.Context(t), "unstarted", subject) + count, err := txStore.CountTxesByStateAndSubject(tests.Context(t), txmgrcommon.TxUnstarted, subject) require.NoError(t, err) require.Equal(t, int(expected), count) } diff --git a/core/chains/evm/txmgr/mocks/evm_tx_store.go b/core/chains/evm/txmgr/mocks/evm_tx_store.go index b28e55ec324..32c456977dc 100644 --- a/core/chains/evm/txmgr/mocks/evm_tx_store.go +++ b/core/chains/evm/txmgr/mocks/evm_tx_store.go @@ -444,6 +444,54 @@ func (_c *EvmTxStore_DeleteInProgressAttempt_Call) RunAndReturn(run func(context return _c } +// DeleteUnstartedTxes provides a mock function with given fields: ctx, chainID, subject +func (_m *EvmTxStore) DeleteUnstartedTxes(ctx context.Context, chainID *big.Int, subject uuid.UUID) error { + ret := _m.Called(ctx, chainID, subject) + + if len(ret) == 0 { + panic("no return value specified for DeleteUnstartedTxes") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *big.Int, uuid.UUID) error); ok { + r0 = rf(ctx, chainID, subject) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// EvmTxStore_DeleteUnstartedTxes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteUnstartedTxes' +type EvmTxStore_DeleteUnstartedTxes_Call struct { + *mock.Call +} + +// DeleteUnstartedTxes is a helper method to define mock.On call +// - ctx context.Context +// - chainID *big.Int +// - subject uuid.UUID +func (_e *EvmTxStore_Expecter) DeleteUnstartedTxes(ctx interface{}, chainID interface{}, subject interface{}) *EvmTxStore_DeleteUnstartedTxes_Call { + return &EvmTxStore_DeleteUnstartedTxes_Call{Call: _e.mock.On("DeleteUnstartedTxes", ctx, chainID, subject)} +} + +func (_c *EvmTxStore_DeleteUnstartedTxes_Call) Run(run func(ctx context.Context, chainID *big.Int, subject uuid.UUID)) *EvmTxStore_DeleteUnstartedTxes_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*big.Int), args[2].(uuid.UUID)) + }) + return _c +} + +func (_c *EvmTxStore_DeleteUnstartedTxes_Call) Return(_a0 error) *EvmTxStore_DeleteUnstartedTxes_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *EvmTxStore_DeleteUnstartedTxes_Call) RunAndReturn(run func(context.Context, *big.Int, uuid.UUID) error) *EvmTxStore_DeleteUnstartedTxes_Call { + _c.Call.Return(run) + return _c +} + // FindEarliestUnconfirmedBroadcastTime provides a mock function with given fields: ctx, chainID func (_m *EvmTxStore) FindEarliestUnconfirmedBroadcastTime(ctx context.Context, chainID *big.Int) (null.Time, error) { ret := _m.Called(ctx, chainID) diff --git a/core/web/eth_keys_controller.go b/core/web/eth_keys_controller.go index 043362ff441..f1c71d07ed6 100644 --- a/core/web/eth_keys_controller.go +++ b/core/web/eth_keys_controller.go @@ -9,6 +9,8 @@ import ( "strconv" "strings" + "github.com/google/uuid" + commonassets "github.com/smartcontractkit/chainlink-common/pkg/assets" "github.com/smartcontractkit/chainlink/v2/core/chains/evm/assets" ubig "github.com/smartcontractkit/chainlink/v2/core/chains/evm/utils/big" @@ -25,7 +27,6 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/gin-gonic/gin" "github.com/pkg/errors" - "go.uber.org/multierr" ) // ETHKeysController manages account keys @@ -260,7 +261,6 @@ func (ekc *ETHKeysController) Export(c *gin.Context) { // Chain updates settings for a given chain for the key func (ekc *ETHKeysController) Chain(c *gin.Context) { - var err error kst := ekc.app.GetKeyStore().Eth() defer ekc.app.GetLogger().ErrorIfFn(c.Request.Body.Close, "Error closing Import request body") @@ -277,34 +277,61 @@ func (ekc *ETHKeysController) Chain(c *gin.Context) { return } - abandon := false + // Parse abandon flag if abandonStr := c.Query("abandon"); abandonStr != "" { - abandon, err = strconv.ParseBool(abandonStr) + abandon, err := strconv.ParseBool(abandonStr) if err != nil { jsonAPIError(c, http.StatusBadRequest, errors.Wrapf(err, "invalid value for abandon: expected boolean, got: %s", abandonStr)) return } + + // If flag is set, marks as error='abandoned' and state='fatal_error' + // txes that were in 'unconfirmed', 'in_progress', 'unstarted' states for given address. + if abandon { + err = chain.TxManager().Reset(address, abandon) + if err != nil { + if strings.Contains(err.Error(), "key state not found with address") { + jsonAPIError(c, http.StatusNotFound, err) + return + } + jsonAPIError(c, http.StatusInternalServerError, err) + return + } + } } - // Reset the chain - if abandon { - var resetErr error - err = chain.TxManager().Reset(address, abandon) - err = multierr.Combine(err, resetErr) + // Parse abandonUnstarted flag + if abandonUnstartedStr := c.Query("abandonUnstarted"); abandonUnstartedStr != "" { + abandonUnstarted, err := strconv.ParseBool(abandonUnstartedStr) if err != nil { - if strings.Contains(err.Error(), "key state not found with address") { - jsonAPIError(c, http.StatusNotFound, err) + jsonAPIError(c, http.StatusBadRequest, errors.Wrapf(err, "invalid value for abandonUnstarted: expected boolean, got: %s", abandonUnstartedStr)) + return + } + + // If flag set, it deletes txes with 'unstarted' state. + if abandonUnstarted { + // Parse optional subject. + subject := uuid.Nil + if subjectStr := c.Query("subject"); subjectStr != "" { + subject, err = uuid.Parse(subjectStr) + if err != nil { + jsonAPIError(c, http.StatusBadRequest, errors.Wrapf(err, "invalid value for subject: expected uuid, got: %s", subject)) + return + } + } + + // If a subject is given, it will only delete 'unstarted' txes for given subject. + // If no subject is provided, it will delete all 'unstarted' txes. + if err = ekc.app.TxmStorageService().DeleteUnstartedTxes(c, chain.ID(), subject); err != nil { + jsonAPIError(c, http.StatusInternalServerError, err) return } - jsonAPIError(c, http.StatusInternalServerError, err) - return } } enabledStr := c.Query("enabled") if enabledStr != "" { - var enabled bool - enabled, err = strconv.ParseBool(enabledStr) + enabled, err := strconv.ParseBool(enabledStr) if err != nil { jsonAPIError(c, http.StatusBadRequest, errors.Wrap(err, "enabled must be bool")) return diff --git a/core/web/eth_keys_controller_test.go b/core/web/eth_keys_controller_test.go index 9cb6a27b434..84a1079ab06 100644 --- a/core/web/eth_keys_controller_test.go +++ b/core/web/eth_keys_controller_test.go @@ -9,6 +9,8 @@ import ( "github.com/pkg/errors" "github.com/smartcontractkit/chainlink-common/pkg/assets" + "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" + txmgrcommon "github.com/smartcontractkit/chainlink/v2/common/txmgr" commontxmmocks "github.com/smartcontractkit/chainlink/v2/common/txmgr/types/mocks" commonmocks "github.com/smartcontractkit/chainlink/v2/common/types/mocks" "github.com/smartcontractkit/chainlink/v2/core/chains/evm/txmgr" @@ -493,6 +495,214 @@ func TestETHKeysController_ChainFailure_InvalidAbandon(t *testing.T) { assert.Equal(t, http.StatusBadRequest, resp.StatusCode) } +func TestETHKeysController_ChainFailure_AbandonUnstarted(t *testing.T) { + t.Parallel() + ctx := testutils.Context(t) + + ethClient := cltest.NewEthMocksWithStartupAssertions(t) + ethClient.On("PendingNonceAt", mock.Anything, mock.Anything).Return(uint64(0), nil) + cfg := configtest.NewGeneralConfig(t, func(c *chainlink.Config, s *chainlink.Secrets) { + c.EVM[0].NonceAutoSync = ptr(false) + c.EVM[0].BalanceMonitor.Enabled = ptr(false) + }) + app := cltest.NewApplicationWithConfig(t, cfg, ethClient) + + // enabled key + _, addr := cltest.MustInsertRandomKey(t, app.KeyStore.Eth()) + + require.NoError(t, app.KeyStore.Unlock(ctx, cltest.Password)) + + require.NoError(t, app.Start(ctx)) + + t.Run("invalid abandonStarted: returns StatusBadRequest", func(t *testing.T) { + client := app.NewHTTPClient(nil) + chainURL := url.URL{Path: "/v2/keys/evm/chain"} + query := chainURL.Query() + + query.Set("address", addr.Hex()) + query.Set("evmChainID", cltest.FixtureChainID.String()) + query.Set("abandon", "true") + query.Set("abandonUnstarted", "invalid") + query.Set("subject", "") + + chainURL.RawQuery = query.Encode() + resp, cleanup := client.Post(chainURL.String(), nil) + defer cleanup() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + t.Run("valid abandonStarted with invalid subject: returns StatusBadRequest", func(t *testing.T) { + client := app.NewHTTPClient(nil) + chainURL := url.URL{Path: "/v2/keys/evm/chain"} + query := chainURL.Query() + + query.Set("address", addr.Hex()) + query.Set("evmChainID", cltest.FixtureChainID.String()) + query.Set("abandon", "true") + query.Set("abandonUnstarted", "true") + query.Set("subject", "invalid") + + chainURL.RawQuery = query.Encode() + resp, cleanup := client.Post(chainURL.String(), nil) + defer cleanup() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestETHKeysController_ChainSuccess_AbandonUnstarted(t *testing.T) { + t.Parallel() + ctx := testutils.Context(t) + + ethClient := cltest.NewEthMocksWithStartupAssertions(t) + ethClient.On("PendingNonceAt", mock.Anything, mock.Anything).Return(uint64(0), nil) + cfg := configtest.NewGeneralConfig(t, func(c *chainlink.Config, s *chainlink.Secrets) { + c.EVM[0].NonceAutoSync = ptr(false) + c.EVM[0].BalanceMonitor.Enabled = ptr(false) + }) + app := cltest.NewApplicationWithConfig(t, cfg, ethClient) + txStore := txmgr.NewTxStore(app.GetDB(), logger.TestLogger(t)) + + require.NoError(t, app.KeyStore.Unlock(ctx, cltest.Password)) + + // enabled key + key, addr := cltest.MustInsertRandomKey(t, app.KeyStore.Eth()) + + ethClient.On("BalanceAt", mock.Anything, addr, mock.Anything).Return(big.NewInt(1), nil).Times(3) + ethClient.On("LINKBalance", mock.Anything, addr, mock.Anything).Return(assets.NewLinkFromJuels(1), nil).Times(3) + + require.NoError(t, app.Start(ctx)) + + chain := app.GetRelayers().LegacyEVMChains().Slice()[0] + + client := app.NewHTTPClient(nil) + chainURL := url.URL{Path: "/v2/keys/evm/chain"} + query := chainURL.Query() + query.Set("address", addr.Hex()) + query.Set("evmChainID", chain.ID().String()) + query.Set("abandon", "false") + query.Set("abandonUnstarted", "true") + + t.Run("abandonUnstarted with empty subject provided and no txes: handled gracefully", func(t *testing.T) { + // Makes the request + query.Set("subject", "") + chainURL.RawQuery = query.Encode() + resp, cleanup := client.Post(chainURL.String(), nil) + defer cleanup() + require.Equal(t, http.StatusOK, resp.StatusCode) + + var updatedKey webpresenters.ETHKeyResource + err := cltest.ParseJSONAPIResponse(t, resp, &updatedKey) + assert.NoError(t, err) + + assert.Equal(t, cltest.FormatWithPrefixedChainID(chain.ID().String(), key.Address.String()), updatedKey.ID) + assert.Equal(t, key.Address.String(), updatedKey.Address) + assert.Equal(t, chain.ID().String(), updatedKey.EVMChainID.String()) + assert.Equal(t, false, updatedKey.Disabled) + + txesCount, err := txStore.CountTransactionsByState(testutils.Context(t), "unstarted", chain.ID()) + require.NoError(t, err) + require.Equal(t, txesCount, uint32(0)) + }) + + toAddress := testutils.NewAddress() + gasLimit := uint64(1000) + payload := []byte{1, 2, 3} + + txReq := txmgr.TxRequest{ + FromAddress: addr, + ToAddress: toAddress, + EncodedPayload: payload, + FeeLimit: gasLimit, + Meta: nil, + Strategy: nil, + SignalCallback: true, + } + + strategyWithoutSubject := txmgrcommon.NewDropOldestStrategy(uuid.UUID{}, uint32(5)) + randomSubject := uuid.New() + strategyWithSubject := txmgrcommon.NewDropOldestStrategy(randomSubject, uint32(5)) + + t.Run("abandonUnstarted with empty subject provided and txes with diff subjects: deletes all", func(t *testing.T) { + txReq.Strategy = strategyWithoutSubject + // Create and assert 2 unstarted transactions without subject + _, err := txStore.CreateTransaction(tests.Context(t), txReq, chain.ID()) + assert.NoError(t, err) + _, err = txStore.CreateTransaction(tests.Context(t), txReq, chain.ID()) + assert.NoError(t, err) + assertUnstartedTxesCountPerSubject(t, txStore, 2, uuid.UUID{}) + + // Create and assert 2 unstarted transactions with subject + txReq.Strategy = strategyWithSubject + _, err = txStore.CreateTransaction(tests.Context(t), txReq, chain.ID()) + assert.NoError(t, err) + _, err = txStore.CreateTransaction(tests.Context(t), txReq, chain.ID()) + assert.NoError(t, err) + assertUnstartedTxesCountPerSubject(t, txStore, 2, randomSubject) + + // Makes the request + query.Set("subject", "") + + chainURL.RawQuery = query.Encode() + resp, cleanup := client.Post(chainURL.String(), nil) + defer cleanup() + require.Equal(t, http.StatusOK, resp.StatusCode) + + var updatedKey webpresenters.ETHKeyResource + err = cltest.ParseJSONAPIResponse(t, resp, &updatedKey) + assert.NoError(t, err) + + assert.Equal(t, cltest.FormatWithPrefixedChainID(chain.ID().String(), key.Address.String()), updatedKey.ID) + assert.Equal(t, key.Address.String(), updatedKey.Address) + assert.Equal(t, chain.ID().String(), updatedKey.EVMChainID.String()) + assert.Equal(t, false, updatedKey.Disabled) + + // Asserts that all have been deleted. + txesCount, err := txStore.CountTransactionsByState(testutils.Context(t), "unstarted", chain.ID()) + require.NoError(t, err) + require.Equal(t, txesCount, uint32(0)) + }) + + t.Run("abandonUnstarted with subject provided and txes with diff subjects: deletes only those subject txes", func(t *testing.T) { + txReq.Strategy = strategyWithoutSubject + // Create and assert 2 unstarted transactions without subject + _, err := txStore.CreateTransaction(tests.Context(t), txReq, chain.ID()) + assert.NoError(t, err) + _, err = txStore.CreateTransaction(tests.Context(t), txReq, chain.ID()) + assert.NoError(t, err) + assertUnstartedTxesCountPerSubject(t, txStore, 2, uuid.UUID{}) + + // Create and assert 2 unstarted transactions with subject + txReq.Strategy = strategyWithSubject + _, err = txStore.CreateTransaction(tests.Context(t), txReq, chain.ID()) + assert.NoError(t, err) + _, err = txStore.CreateTransaction(tests.Context(t), txReq, chain.ID()) + assert.NoError(t, err) + assertUnstartedTxesCountPerSubject(t, txStore, 2, randomSubject) + + // Makes the request + query.Set("subject", randomSubject.String()) + + chainURL.RawQuery = query.Encode() + resp, cleanup := client.Post(chainURL.String(), nil) + defer cleanup() + require.Equal(t, http.StatusOK, resp.StatusCode) + + var updatedKey webpresenters.ETHKeyResource + err = cltest.ParseJSONAPIResponse(t, resp, &updatedKey) + assert.NoError(t, err) + + assert.Equal(t, cltest.FormatWithPrefixedChainID(chain.ID().String(), key.Address.String()), updatedKey.ID) + assert.Equal(t, key.Address.String(), updatedKey.Address) + assert.Equal(t, chain.ID().String(), updatedKey.EVMChainID.String()) + assert.Equal(t, false, updatedKey.Disabled) + + // Asserts that only the txes of the provided subject have been deleted. + assertUnstartedTxesCountPerSubject(t, txStore, 2, uuid.UUID{}) + assertUnstartedTxesCountPerSubject(t, txStore, 0, randomSubject) + }) +} + func TestETHKeysController_ChainFailure_InvalidEnabled(t *testing.T) { t.Parallel() ctx := testutils.Context(t) @@ -744,3 +954,10 @@ func TestETHKeysController_DeleteFailure_KeyMissing(t *testing.T) { assert.Equal(t, http.StatusNotFound, resp.StatusCode) } + +func assertUnstartedTxesCountPerSubject(t *testing.T, txStore txmgr.TestEvmTxStore, expected int64, subject uuid.UUID) { + t.Helper() + count, err := txStore.CountTxesByStateAndSubject(tests.Context(t), txmgrcommon.TxUnstarted, subject) + require.NoError(t, err) + require.Equal(t, int(expected), count) +}