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