github.com/onflow/flow-go@v0.35.7-crescendo-preview.23-atree-inlining/engine/collection/ingest/engine_test.go (about) 1 package ingest 2 3 import ( 4 "context" 5 "errors" 6 "io" 7 "testing" 8 "time" 9 10 "github.com/rs/zerolog" 11 "github.com/stretchr/testify/mock" 12 "github.com/stretchr/testify/suite" 13 "golang.org/x/time/rate" 14 15 "github.com/onflow/flow-go/access" 16 "github.com/onflow/flow-go/engine" 17 "github.com/onflow/flow-go/model/flow" 18 "github.com/onflow/flow-go/model/flow/factory" 19 "github.com/onflow/flow-go/model/flow/filter" 20 "github.com/onflow/flow-go/module/component" 21 "github.com/onflow/flow-go/module/irrecoverable" 22 "github.com/onflow/flow-go/module/mempool" 23 "github.com/onflow/flow-go/module/mempool/epochs" 24 "github.com/onflow/flow-go/module/mempool/herocache" 25 "github.com/onflow/flow-go/module/metrics" 26 module "github.com/onflow/flow-go/module/mock" 27 "github.com/onflow/flow-go/network" 28 "github.com/onflow/flow-go/network/mocknetwork" 29 realprotocol "github.com/onflow/flow-go/state/protocol" 30 protocol "github.com/onflow/flow-go/state/protocol/mock" 31 "github.com/onflow/flow-go/storage" 32 "github.com/onflow/flow-go/utils/unittest" 33 "github.com/onflow/flow-go/utils/unittest/mocks" 34 ) 35 36 type Suite struct { 37 suite.Suite 38 39 N_COLLECTORS int 40 N_CLUSTERS uint 41 42 conduit *mocknetwork.Conduit 43 me *module.Local 44 conf Config 45 46 pools *epochs.TransactionPools 47 48 identities flow.IdentityList 49 clusters flow.ClusterList 50 51 state *protocol.State 52 snapshot *protocol.Snapshot 53 epochQuery *mocks.EpochQuery 54 root *flow.Block 55 56 // backend for mocks 57 blocks map[flow.Identifier]*flow.Block 58 final *flow.Block 59 60 engine *Engine 61 } 62 63 func TestIngest(t *testing.T) { 64 suite.Run(t, new(Suite)) 65 } 66 67 func (suite *Suite) SetupTest() { 68 var err error 69 70 suite.N_COLLECTORS = 4 71 suite.N_CLUSTERS = 2 72 73 log := zerolog.New(io.Discard) 74 metrics := metrics.NewNoopCollector() 75 76 net := new(mocknetwork.Network) 77 suite.conduit = new(mocknetwork.Conduit) 78 net.On("Register", mock.Anything, mock.Anything).Return(suite.conduit, nil).Once() 79 80 collectors := unittest.IdentityListFixture(suite.N_COLLECTORS, unittest.WithRole(flow.RoleCollection)) 81 me := collectors[0] 82 others := unittest.IdentityListFixture(4, unittest.WithAllRolesExcept(flow.RoleCollection)) 83 suite.identities = append(collectors, others...) 84 85 suite.me = new(module.Local) 86 suite.me.On("NodeID").Return(me.NodeID) 87 88 suite.pools = epochs.NewTransactionPools(func(_ uint64) mempool.Transactions { 89 return herocache.NewTransactions(1000, log, metrics) 90 }) 91 92 assignments := unittest.ClusterAssignment(suite.N_CLUSTERS, collectors.ToSkeleton()) 93 suite.clusters, err = factory.NewClusterList(assignments, collectors.ToSkeleton()) 94 suite.Require().NoError(err) 95 96 suite.root = unittest.GenesisFixture() 97 suite.final = suite.root 98 suite.blocks = make(map[flow.Identifier]*flow.Block) 99 suite.blocks[suite.root.ID()] = suite.root 100 101 suite.state = new(protocol.State) 102 suite.snapshot = new(protocol.Snapshot) 103 suite.state.On("Final").Return(suite.snapshot) 104 suite.snapshot.On("Head").Return( 105 func() *flow.Header { return suite.final.Header }, 106 func() error { return nil }, 107 ) 108 suite.state.On("AtBlockID", mock.Anything).Return( 109 func(blockID flow.Identifier) realprotocol.Snapshot { 110 snap := new(protocol.Snapshot) 111 block, ok := suite.blocks[blockID] 112 if ok { 113 snap.On("Head").Return(block.Header, nil) 114 } else { 115 snap.On("Head").Return(nil, storage.ErrNotFound) 116 } 117 snap.On("Epochs").Return(suite.epochQuery) 118 return snap 119 }) 120 121 // set up the current epoch by default, with counter=1 122 epoch := new(protocol.Epoch) 123 epoch.On("Counter").Return(uint64(1), nil) 124 epoch.On("Clustering").Return(suite.clusters, nil) 125 suite.epochQuery = mocks.NewEpochQuery(suite.T(), 1, epoch) 126 127 suite.conf = DefaultConfig() 128 chain := flow.Testnet.Chain() 129 suite.engine, err = New(log, net, suite.state, metrics, metrics, metrics, suite.me, chain, suite.pools, suite.conf, NewAddressRateLimiter(rate.Limit(1), 1)) 130 suite.Require().NoError(err) 131 } 132 133 func (suite *Suite) TestInvalidTransaction() { 134 135 suite.Run("missing field", func() { 136 tx := unittest.TransactionBodyFixture() 137 tx.ReferenceBlockID = suite.root.ID() 138 tx.Script = nil 139 140 err := suite.engine.ProcessTransaction(&tx) 141 suite.Assert().Error(err) 142 suite.Assert().True(errors.As(err, &access.IncompleteTransactionError{})) 143 }) 144 145 suite.Run("gas limit exceeds the maximum allowed", func() { 146 tx := unittest.TransactionBodyFixture() 147 tx.Payer = unittest.RandomAddressFixture() 148 tx.ReferenceBlockID = suite.root.ID() 149 tx.GasLimit = flow.DefaultMaxTransactionGasLimit + 1 150 151 err := suite.engine.ProcessTransaction(&tx) 152 suite.Assert().Error(err) 153 suite.Assert().True(errors.As(err, &access.InvalidGasLimitError{})) 154 }) 155 156 suite.Run("invalid reference block ID", func() { 157 tx := unittest.TransactionBodyFixture() 158 tx.ReferenceBlockID = unittest.IdentifierFixture() 159 160 err := suite.engine.ProcessTransaction(&tx) 161 suite.Assert().Error(err) 162 suite.Assert().True(errors.As(err, &engine.UnverifiableInputError{})) 163 }) 164 165 suite.Run("un-parseable script", func() { 166 tx := unittest.TransactionBodyFixture() 167 tx.ReferenceBlockID = suite.root.ID() 168 tx.Script = []byte("definitely a real transaction") 169 170 err := suite.engine.ProcessTransaction(&tx) 171 suite.Assert().Error(err) 172 suite.Assert().True(errors.As(err, &access.InvalidScriptError{})) 173 }) 174 175 suite.Run("invalid signature format", func() { 176 signer := flow.Testnet.Chain().ServiceAddress() 177 keyIndex := uint64(0) 178 179 sig1 := unittest.TransactionSignatureFixture() 180 sig1.KeyIndex = keyIndex 181 sig1.Address = signer 182 sig1.SignerIndex = 0 183 184 sig2 := unittest.TransactionSignatureFixture() 185 sig2.KeyIndex = keyIndex 186 sig2.Address = signer 187 sig2.SignerIndex = 1 188 189 suite.Run("invalid format of an envelope signature", func() { 190 invalidSig := unittest.InvalidFormatSignature() 191 tx := unittest.TransactionBodyFixture() 192 tx.ReferenceBlockID = suite.root.ID() 193 tx.EnvelopeSignatures[0] = invalidSig 194 195 err := suite.engine.ProcessTransaction(&tx) 196 suite.Assert().Error(err) 197 suite.Assert().True(errors.As(err, &access.InvalidSignatureError{})) 198 }) 199 200 suite.Run("invalid format of a payload signature", func() { 201 invalidSig := unittest.InvalidFormatSignature() 202 tx := unittest.TransactionBodyFixture() 203 tx.ReferenceBlockID = suite.root.ID() 204 tx.PayloadSignatures = []flow.TransactionSignature{invalidSig} 205 206 err := suite.engine.ProcessTransaction(&tx) 207 suite.Assert().Error(err) 208 suite.Assert().True(errors.As(err, &access.InvalidSignatureError{})) 209 }) 210 211 suite.Run("duplicated signature (envelope only)", func() { 212 tx := unittest.TransactionBodyFixture() 213 tx.ReferenceBlockID = suite.root.ID() 214 tx.EnvelopeSignatures = []flow.TransactionSignature{sig1, sig2} 215 err := suite.engine.ProcessTransaction(&tx) 216 suite.Assert().Error(err) 217 suite.Assert().True(errors.As(err, &access.DuplicatedSignatureError{})) 218 }) 219 220 suite.Run("duplicated signature (payload only)", func() { 221 tx := unittest.TransactionBodyFixture() 222 tx.ReferenceBlockID = suite.root.ID() 223 tx.PayloadSignatures = []flow.TransactionSignature{sig1, sig2} 224 225 err := suite.engine.ProcessTransaction(&tx) 226 suite.Assert().Error(err) 227 suite.Assert().True(errors.As(err, &access.DuplicatedSignatureError{})) 228 }) 229 230 suite.Run("duplicated signature (cross case)", func() { 231 tx := unittest.TransactionBodyFixture() 232 tx.ReferenceBlockID = suite.root.ID() 233 tx.PayloadSignatures = []flow.TransactionSignature{sig1} 234 tx.EnvelopeSignatures = []flow.TransactionSignature{sig2} 235 236 err := suite.engine.ProcessTransaction(&tx) 237 suite.Assert().Error(err) 238 suite.Assert().True(errors.As(err, &access.DuplicatedSignatureError{})) 239 }) 240 }) 241 242 suite.Run("invalid signature", func() { 243 // TODO cannot check signatures in MVP 244 unittest.SkipUnless(suite.T(), unittest.TEST_TODO, "skipping unimplemented test") 245 }) 246 247 suite.Run("invalid address", func() { 248 invalid := unittest.InvalidAddressFixture() 249 tx := unittest.TransactionBodyFixture() 250 tx.ReferenceBlockID = suite.root.ID() 251 tx.Payer = invalid 252 253 err := suite.engine.ProcessTransaction(&tx) 254 suite.Assert().Error(err) 255 suite.Assert().True(errors.As(err, &access.InvalidAddressError{})) 256 }) 257 258 suite.Run("expired reference block ID", func() { 259 // "finalize" a sufficiently high block that root block is expired 260 final := unittest.BlockFixture() 261 final.Header.Height = suite.root.Header.Height + flow.DefaultTransactionExpiry + 1 262 suite.final = &final 263 264 tx := unittest.TransactionBodyFixture() 265 tx.ReferenceBlockID = suite.root.ID() 266 267 err := suite.engine.ProcessTransaction(&tx) 268 suite.Assert().Error(err) 269 suite.Assert().True(errors.As(err, &access.ExpiredTransactionError{})) 270 }) 271 272 } 273 274 // should return an error if the engine is shutdown and not processing transactions 275 func (suite *Suite) TestComponentShutdown() { 276 tx := unittest.TransactionBodyFixture() 277 tx.ReferenceBlockID = suite.root.ID() 278 279 // start then shut down the engine 280 parentCtx, cancel := context.WithCancel(context.Background()) 281 ctx, _ := irrecoverable.WithSignaler(parentCtx) 282 suite.engine.Start(ctx) 283 unittest.AssertClosesBefore(suite.T(), suite.engine.Ready(), 10*time.Millisecond) 284 cancel() 285 unittest.AssertClosesBefore(suite.T(), suite.engine.ShutdownSignal(), 10*time.Millisecond) 286 287 err := suite.engine.ProcessTransaction(&tx) 288 suite.Assert().ErrorIs(err, component.ErrComponentShutdown) 289 } 290 291 // should store transactions for local cluster and propagate to other cluster members 292 func (suite *Suite) TestRoutingLocalCluster() { 293 294 local, _, ok := suite.clusters.ByNodeID(suite.me.NodeID()) 295 suite.Require().True(ok) 296 297 // get a transaction that will be routed to local cluster 298 tx := unittest.TransactionBodyFixture() 299 tx.ReferenceBlockID = suite.root.ID() 300 tx = unittest.AlterTransactionForCluster(tx, suite.clusters, local, func(transaction *flow.TransactionBody) {}) 301 302 // should route to local cluster 303 suite.conduit. 304 On("Multicast", &tx, suite.conf.PropagationRedundancy+1, local.NodeIDs()[0], local.NodeIDs()[1]). 305 Return(nil) 306 307 err := suite.engine.ProcessTransaction(&tx) 308 suite.Assert().NoError(err) 309 310 // should be added to local mempool for the current epoch 311 counter, err := suite.epochQuery.Current().Counter() 312 suite.Assert().NoError(err) 313 suite.Assert().True(suite.pools.ForEpoch(counter).Has(tx.ID())) 314 suite.conduit.AssertExpectations(suite.T()) 315 } 316 317 // should not store transactions for a different cluster and should propagate 318 // to the responsible cluster 319 func (suite *Suite) TestRoutingRemoteCluster() { 320 321 // find a remote cluster 322 _, index, ok := suite.clusters.ByNodeID(suite.me.NodeID()) 323 suite.Require().True(ok) 324 remote, ok := suite.clusters.ByIndex((index + 1) % suite.N_CLUSTERS) 325 suite.Require().True(ok) 326 327 // get a transaction that will be routed to remote cluster 328 tx := unittest.TransactionBodyFixture() 329 tx.ReferenceBlockID = suite.root.ID() 330 tx = unittest.AlterTransactionForCluster(tx, suite.clusters, remote, func(transaction *flow.TransactionBody) {}) 331 332 // should route to remote cluster 333 suite.conduit. 334 On("Multicast", &tx, suite.conf.PropagationRedundancy+1, remote[0].NodeID, remote[1].NodeID). 335 Return(nil) 336 337 err := suite.engine.ProcessTransaction(&tx) 338 suite.Assert().NoError(err) 339 340 // should not be added to local mempool 341 counter, err := suite.epochQuery.Current().Counter() 342 suite.Assert().NoError(err) 343 suite.Assert().False(suite.pools.ForEpoch(counter).Has(tx.ID())) 344 suite.conduit.AssertExpectations(suite.T()) 345 } 346 347 // should not store transactions for a different cluster and should not fail when propagating 348 // to an empty cluster 349 func (suite *Suite) TestRoutingToRemoteClusterWithNoNodes() { 350 351 // find a remote cluster 352 _, index, ok := suite.clusters.ByNodeID(suite.me.NodeID()) 353 suite.Require().True(ok) 354 355 // set the next cluster to be empty 356 emptyIdentityList := flow.IdentitySkeletonList{} 357 nextClusterIndex := (index + 1) % suite.N_CLUSTERS 358 suite.clusters[nextClusterIndex] = emptyIdentityList 359 360 // get a transaction that will be routed to remote cluster 361 tx := unittest.TransactionBodyFixture() 362 tx.ReferenceBlockID = suite.root.ID() 363 tx = unittest.AlterTransactionForCluster(tx, suite.clusters, emptyIdentityList, func(transaction *flow.TransactionBody) {}) 364 365 // should attempt route to remote cluster without providing any node ids 366 suite.conduit. 367 On("Multicast", &tx, suite.conf.PropagationRedundancy+1). 368 Return(network.EmptyTargetList) 369 370 err := suite.engine.ProcessTransaction(&tx) 371 suite.Assert().NoError(err) 372 373 // should not be added to local mempool 374 counter, err := suite.epochQuery.Current().Counter() 375 suite.Assert().NoError(err) 376 suite.Assert().False(suite.pools.ForEpoch(counter).Has(tx.ID())) 377 suite.conduit.AssertExpectations(suite.T()) 378 } 379 380 // should not propagate transactions received from another node (that node is 381 // responsible for propagation) 382 func (suite *Suite) TestRoutingLocalClusterFromOtherNode() { 383 384 local, _, ok := suite.clusters.ByNodeID(suite.me.NodeID()) 385 suite.Require().True(ok) 386 387 // another node will send us the transaction 388 sender := local.Filter(filter.Not(filter.HasNodeID[flow.IdentitySkeleton](suite.me.NodeID())))[0] 389 390 // get a transaction that will be routed to local cluster 391 tx := unittest.TransactionBodyFixture() 392 tx.ReferenceBlockID = suite.root.ID() 393 tx = unittest.AlterTransactionForCluster(tx, suite.clusters, local, func(transaction *flow.TransactionBody) {}) 394 395 // should not route to any node 396 suite.conduit.AssertNumberOfCalls(suite.T(), "Multicast", 0) 397 398 err := suite.engine.onTransaction(sender.NodeID, &tx) 399 suite.Assert().NoError(err) 400 401 // should be added to local mempool for current epoch 402 counter, err := suite.epochQuery.Current().Counter() 403 suite.Assert().NoError(err) 404 suite.Assert().True(suite.pools.ForEpoch(counter).Has(tx.ID())) 405 suite.conduit.AssertExpectations(suite.T()) 406 } 407 408 // should not route or store invalid transactions 409 func (suite *Suite) TestRoutingInvalidTransaction() { 410 411 // find a remote cluster 412 _, index, ok := suite.clusters.ByNodeID(suite.me.NodeID()) 413 suite.Require().True(ok) 414 remote, ok := suite.clusters.ByIndex((index + 1) % suite.N_CLUSTERS) 415 suite.Require().True(ok) 416 417 // get transaction for target cluster, but make it invalid 418 tx := unittest.TransactionBodyFixture() 419 tx = unittest.AlterTransactionForCluster(tx, suite.clusters, remote, 420 func(tx *flow.TransactionBody) { 421 tx.GasLimit = 0 422 }) 423 424 // should not route to any node 425 suite.conduit.AssertNumberOfCalls(suite.T(), "Multicast", 0) 426 427 _ = suite.engine.ProcessTransaction(&tx) 428 429 // should not be added to local mempool 430 counter, err := suite.epochQuery.Current().Counter() 431 suite.Assert().NoError(err) 432 suite.Assert().False(suite.pools.ForEpoch(counter).Has(tx.ID())) 433 suite.conduit.AssertExpectations(suite.T()) 434 } 435 436 // We should route to the appropriate cluster if our cluster assignment changes 437 // on an epoch boundary. In this test, the clusters in epoch 2 are the reverse 438 // of those in epoch 1, and we check that the transaction is routed based on 439 // the clustering in epoch 2. 440 func (suite *Suite) TestRouting_ClusterAssignmentChanged() { 441 442 epoch2Clusters := flow.ClusterList{ 443 suite.clusters[1], 444 suite.clusters[0], 445 } 446 epoch2 := new(protocol.Epoch) 447 epoch2.On("Counter").Return(uint64(2), nil) 448 epoch2.On("Clustering").Return(epoch2Clusters, nil) 449 // update the mocks to behave as though we have transitioned to epoch 2 450 suite.epochQuery.Add(epoch2) 451 suite.epochQuery.Transition() 452 453 // get the local cluster in epoch 2 454 epoch2Local, _, ok := epoch2Clusters.ByNodeID(suite.me.NodeID()) 455 suite.Require().True(ok) 456 457 // get a transaction that will be routed to local cluster 458 tx := unittest.TransactionBodyFixture() 459 tx.ReferenceBlockID = suite.root.ID() 460 tx = unittest.AlterTransactionForCluster(tx, epoch2Clusters, epoch2Local, func(transaction *flow.TransactionBody) {}) 461 462 // should route to local cluster 463 suite.conduit.On("Multicast", &tx, suite.conf.PropagationRedundancy+1, epoch2Local.NodeIDs()[0], epoch2Local.NodeIDs()[1]).Return(nil).Once() 464 465 err := suite.engine.ProcessTransaction(&tx) 466 suite.Assert().NoError(err) 467 468 // should add to local mempool for epoch 2 only 469 suite.Assert().True(suite.pools.ForEpoch(2).Has(tx.ID())) 470 suite.Assert().False(suite.pools.ForEpoch(1).Has(tx.ID())) 471 suite.conduit.AssertExpectations(suite.T()) 472 } 473 474 // We will discard all transactions when we aren't assigned to any cluster. 475 func (suite *Suite) TestRouting_ClusterAssignmentRemoved() { 476 477 // remove ourselves from the cluster assignment for epoch 2 478 withoutMe := suite.identities. 479 Filter(filter.Not(filter.HasNodeID[flow.Identity](suite.me.NodeID()))). 480 Filter(filter.HasRole[flow.Identity](flow.RoleCollection)).ToSkeleton() 481 epoch2Assignment := unittest.ClusterAssignment(suite.N_CLUSTERS, withoutMe) 482 epoch2Clusters, err := factory.NewClusterList(epoch2Assignment, withoutMe) 483 suite.Require().NoError(err) 484 485 epoch2 := new(protocol.Epoch) 486 epoch2.On("Counter").Return(uint64(2), nil) 487 epoch2.On("InitialIdentities").Return(withoutMe, nil) 488 epoch2.On("Clustering").Return(epoch2Clusters, nil) 489 // update the mocks to behave as though we have transitioned to epoch 2 490 suite.epochQuery.Add(epoch2) 491 suite.epochQuery.Transition() 492 493 // any transaction is OK here, since we're not in any cluster 494 tx := unittest.TransactionBodyFixture() 495 tx.ReferenceBlockID = suite.root.ID() 496 497 err = suite.engine.ProcessTransaction(&tx) 498 suite.Assert().Error(err) 499 500 // should not add to mempool 501 suite.Assert().False(suite.pools.ForEpoch(2).Has(tx.ID())) 502 suite.Assert().False(suite.pools.ForEpoch(1).Has(tx.ID())) 503 // should not propagate 504 suite.conduit.AssertNumberOfCalls(suite.T(), "Multicast", 0) 505 } 506 507 // The node is not a participant in epoch 2 and joins in epoch 3. We start the 508 // test in epoch 2. 509 // 510 // Test that the node discards transactions in epoch 2 and handles them 511 // in epoch 3. 512 func (suite *Suite) TestRouting_ClusterAssignmentAdded() { 513 514 // EPOCH 2: 515 516 // remove ourselves from the cluster assignment for epoch 2 517 withoutMe := suite.identities. 518 Filter(filter.Not(filter.HasNodeID[flow.Identity](suite.me.NodeID()))). 519 Filter(filter.HasRole[flow.Identity](flow.RoleCollection)).ToSkeleton() 520 epoch2Assignment := unittest.ClusterAssignment(suite.N_CLUSTERS, withoutMe) 521 epoch2Clusters, err := factory.NewClusterList(epoch2Assignment, withoutMe) 522 suite.Require().NoError(err) 523 524 epoch2 := new(protocol.Epoch) 525 epoch2.On("Counter").Return(uint64(2), nil) 526 epoch2.On("InitialIdentities").Return(withoutMe, nil) 527 epoch2.On("Clustering").Return(epoch2Clusters, nil) 528 // update the mocks to behave as though we have transitioned to epoch 2 529 suite.epochQuery.Add(epoch2) 530 suite.epochQuery.Transition() 531 532 // any transaction is OK here, since we're not in any cluster 533 tx := unittest.TransactionBodyFixture() 534 tx.ReferenceBlockID = suite.root.ID() 535 536 err = suite.engine.ProcessTransaction(&tx) 537 suite.Assert().Error(err) 538 539 // should not add to mempool 540 suite.Assert().False(suite.pools.ForEpoch(2).Has(tx.ID())) 541 suite.Assert().False(suite.pools.ForEpoch(1).Has(tx.ID())) 542 // should not propagate 543 suite.conduit.AssertNumberOfCalls(suite.T(), "Multicast", 0) 544 545 // EPOCH 3: 546 547 // include ourselves in cluster assignment 548 withMe := suite.identities.Filter(filter.HasRole[flow.Identity](flow.RoleCollection)).ToSkeleton() 549 epoch3Assignment := unittest.ClusterAssignment(suite.N_CLUSTERS, withMe) 550 epoch3Clusters, err := factory.NewClusterList(epoch3Assignment, withMe) 551 suite.Require().NoError(err) 552 553 epoch3 := new(protocol.Epoch) 554 epoch3.On("Counter").Return(uint64(3), nil) 555 epoch3.On("Clustering").Return(epoch3Clusters, nil) 556 // transition to epoch 3 557 suite.epochQuery.Add(epoch3) 558 suite.epochQuery.Transition() 559 560 // get the local cluster in epoch 2 561 epoch3Local, _, ok := epoch3Clusters.ByNodeID(suite.me.NodeID()) 562 suite.Require().True(ok) 563 564 // get a transaction that will be routed to local cluster 565 tx = unittest.TransactionBodyFixture() 566 tx.ReferenceBlockID = suite.root.ID() 567 tx = unittest.AlterTransactionForCluster(tx, epoch3Clusters, epoch3Local, func(transaction *flow.TransactionBody) {}) 568 569 // should route to local cluster 570 suite.conduit.On("Multicast", &tx, suite.conf.PropagationRedundancy+1, epoch3Local.NodeIDs()[0], epoch3Local.NodeIDs()[1]).Return(nil).Once() 571 572 err = suite.engine.ProcessTransaction(&tx) 573 suite.Assert().NoError(err) 574 }