From 5d5a8b7ec92830c3c127b92560975718794f79bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20R=C3=B3=C5=BCa=C5=84ski?= Date: Fri, 6 Sep 2024 20:44:28 +0000 Subject: [PATCH] start creating proposal as soon as signer session is initialized (#6317) ## Motivation Speed up creating and publishing a proposal in 1:N setups. --- miner/metrics.go | 12 +- miner/mocks/mocks.go | 62 +++++++++ miner/proposal_builder.go | 247 +++++++++++++++++++-------------- miner/proposal_builder_test.go | 163 +++++++++++++++++++++- 4 files changed, 372 insertions(+), 112 deletions(-) diff --git a/miner/metrics.go b/miner/metrics.go index 113334a3ba..2625866fb3 100644 --- a/miner/metrics.go +++ b/miner/metrics.go @@ -21,11 +21,12 @@ type latencyTracker struct { start time.Time end time.Time - data time.Duration - tortoise time.Duration - hash time.Duration - txs time.Duration - publish time.Duration + data time.Duration + tortoise time.Duration + hash time.Duration + activeSet time.Duration + txs time.Duration + publish time.Duration } func (lt *latencyTracker) total() time.Duration { @@ -34,6 +35,7 @@ func (lt *latencyTracker) total() time.Duration { func (lt *latencyTracker) MarshalLogObject(encoder zapcore.ObjectEncoder) error { encoder.AddDuration("data", lt.data) + encoder.AddDuration("active set", lt.activeSet) encoder.AddDuration("tortoise", lt.tortoise) encoder.AddDuration("hash", lt.hash) encoder.AddDuration("txs", lt.txs) diff --git a/miner/mocks/mocks.go b/miner/mocks/mocks.go index b5f57af641..c873f06297 100644 --- a/miner/mocks/mocks.go +++ b/miner/mocks/mocks.go @@ -357,3 +357,65 @@ func (c *MocklayerClockLayerToTimeCall) DoAndReturn(f func(types.LayerID) time.T c.Call = c.Call.DoAndReturn(f) return c } + +// MockatxSearch is a mock of atxSearch interface. +type MockatxSearch struct { + ctrl *gomock.Controller + recorder *MockatxSearchMockRecorder +} + +// MockatxSearchMockRecorder is the mock recorder for MockatxSearch. +type MockatxSearchMockRecorder struct { + mock *MockatxSearch +} + +// NewMockatxSearch creates a new mock instance. +func NewMockatxSearch(ctrl *gomock.Controller) *MockatxSearch { + mock := &MockatxSearch{ctrl: ctrl} + mock.recorder = &MockatxSearchMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockatxSearch) EXPECT() *MockatxSearchMockRecorder { + return m.recorder +} + +// GetIDByEpochAndNodeID mocks base method. +func (m *MockatxSearch) GetIDByEpochAndNodeID(ctx context.Context, epoch types.EpochID, nodeID types.NodeID) (types.ATXID, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetIDByEpochAndNodeID", ctx, epoch, nodeID) + ret0, _ := ret[0].(types.ATXID) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetIDByEpochAndNodeID indicates an expected call of GetIDByEpochAndNodeID. +func (mr *MockatxSearchMockRecorder) GetIDByEpochAndNodeID(ctx, epoch, nodeID any) *MockatxSearchGetIDByEpochAndNodeIDCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetIDByEpochAndNodeID", reflect.TypeOf((*MockatxSearch)(nil).GetIDByEpochAndNodeID), ctx, epoch, nodeID) + return &MockatxSearchGetIDByEpochAndNodeIDCall{Call: call} +} + +// MockatxSearchGetIDByEpochAndNodeIDCall wrap *gomock.Call +type MockatxSearchGetIDByEpochAndNodeIDCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockatxSearchGetIDByEpochAndNodeIDCall) Return(arg0 types.ATXID, arg1 error) *MockatxSearchGetIDByEpochAndNodeIDCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockatxSearchGetIDByEpochAndNodeIDCall) Do(f func(context.Context, types.EpochID, types.NodeID) (types.ATXID, error)) *MockatxSearchGetIDByEpochAndNodeIDCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockatxSearchGetIDByEpochAndNodeIDCall) DoAndReturn(f func(context.Context, types.EpochID, types.NodeID) (types.ATXID, error)) *MockatxSearchGetIDByEpochAndNodeIDCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/miner/proposal_builder.go b/miner/proposal_builder.go index fe58751353..a9a3403502 100644 --- a/miner/proposal_builder.go +++ b/miner/proposal_builder.go @@ -14,6 +14,7 @@ import ( "go.uber.org/zap/zapcore" "golang.org/x/exp/maps" "golang.org/x/sync/errgroup" + "golang.org/x/sync/semaphore" "github.com/spacemeshos/go-spacemesh/atxsdata" "github.com/spacemeshos/go-spacemesh/codec" @@ -55,6 +56,22 @@ type layerClock interface { LayerToTime(types.LayerID) time.Time } +type atxSearch interface { + GetIDByEpochAndNodeID(ctx context.Context, epoch types.EpochID, nodeID types.NodeID) (types.ATXID, error) +} + +type defaultAtxSearch struct { + db sql.Executor +} + +func (p defaultAtxSearch) GetIDByEpochAndNodeID( + _ context.Context, + epoch types.EpochID, + nodeID types.NodeID, +) (types.ATXID, error) { + return atxs.GetIDByEpochAndNodeID(p.db, epoch, nodeID) +} + // ProposalBuilder builds Proposals for a miner. type ProposalBuilder struct { logger *zap.Logger @@ -69,6 +86,7 @@ type ProposalBuilder struct { tortoise votesEncoder syncer system.SyncStateProvider activeGen *activeSetGenerator + atxs atxSearch signers struct { mu sync.Mutex @@ -260,6 +278,12 @@ func WithActivesetPreparation(prep ActiveSetPreparation) Opt { } } +func withAtxSearch(p atxSearch) Opt { + return func(pb *ProposalBuilder) { + pb.atxs = p + } +} + // New creates a struct of block builder type. func New( clock layerClock, @@ -286,6 +310,7 @@ func New( tortoise: trtl, syncer: syncer, conState: conState, + atxs: defaultAtxSearch{db}, signers: struct { mu sync.Mutex signers map[types.NodeID]*signerSession @@ -351,19 +376,11 @@ func (pb *ProposalBuilder) Run(ctx context.Context) error { continue } if err := pb.build(ctx, current); err != nil { - if errors.Is(err, errAtxNotAvailable) { - pb.logger.Debug("signer is not active in epoch", - log.ZContext(ctx), - zap.Uint32("lid", current.Uint32()), - zap.Error(err), - ) - } else { - pb.logger.Warn("failed to build proposal", - log.ZContext(ctx), - zap.Uint32("lid", current.Uint32()), - zap.Error(err), - ) - } + pb.logger.Warn("failed to build proposal", + log.ZContext(ctx), + zap.Uint32("lid", current.Uint32()), + zap.Error(err), + ) } } } @@ -449,7 +466,7 @@ func (pb *ProposalBuilder) UpdateActiveSet(target types.EpochID, set []types.ATX pb.activeGen.updateFallback(target, set) } -func (pb *ProposalBuilder) initSharedData(ctx context.Context, current types.LayerID) error { +func (pb *ProposalBuilder) initSharedData(current types.LayerID) error { if pb.shared.epoch != current.GetEpoch() { pb.shared = sharedSession{epoch: current.GetEpoch()} } @@ -479,20 +496,16 @@ func (pb *ProposalBuilder) initSharedData(ctx context.Context, current types.Lay return nil } -func (pb *ProposalBuilder) initSignerData( - ctx context.Context, - ss *signerSession, - lid types.LayerID, -) error { +func (pb *ProposalBuilder) initSignerData(ctx context.Context, ss *signerSession, lid types.LayerID) error { if ss.session.epoch != lid.GetEpoch() { ss.session = session{epoch: lid.GetEpoch()} } if ss.session.atx == types.EmptyATXID { - atxid, err := atxs.GetIDByEpochAndNodeID(pb.db, ss.session.epoch-1, ss.signer.NodeID()) - if err != nil { - if errors.Is(err, sql.ErrNotFound) { - err = errAtxNotAvailable - } + atxid, err := pb.atxs.GetIDByEpochAndNodeID(ctx, ss.session.epoch-1, ss.signer.NodeID()) + switch { + case errors.Is(err, sql.ErrNotFound): + return errAtxNotAvailable + case err != nil: return fmt.Errorf("get atx in epoch %v: %w", ss.session.epoch-1, err) } atx := pb.atxsdata.Get(ss.session.epoch, atxid) @@ -558,23 +571,71 @@ func (pb *ProposalBuilder) initSignerData( } func (pb *ProposalBuilder) build(ctx context.Context, lid types.LayerID) error { - for _, ss := range pb.signers.signers { - ss.latency.start = time.Now() - } - if err := pb.initSharedData(ctx, lid); err != nil { + buildStartTime := time.Now() + if err := pb.initSharedData(lid); err != nil { return err } - pb.signers.mu.Lock() // don't accept registration in the middle of computing proposals + pb.signers.mu.Lock() signers := maps.Values(pb.signers.signers) pb.signers.mu.Unlock() + encodeVotesOnce := sync.OnceValues(func() (*types.Opinion, error) { + pb.tortoise.TallyVotes(ctx, lid) + // TODO(dshulyak) get rid from the EncodeVotesWithCurrent option in a followup + // there are some dependencies in the tests + opinion, err := pb.tortoise.EncodeVotes(ctx, tortoise.EncodeVotesWithCurrent(lid)) + if err != nil { + return nil, fmt.Errorf("encoding votes: %w", err) + } + return opinion, nil + }) + + calcMeshHashOnce := sync.OnceValue(func() types.Hash32 { + start := time.Now() + meshHash := pb.decideMeshHash(ctx, lid) + duration := time.Since(start) + for _, ss := range signers { + ss.latency.hash = duration + } + return meshHash + }) + + persistActiveSetOnce := sync.OnceValue(func() error { + err := activesets.Add(pb.db, pb.shared.active.id, &types.EpochActiveSet{ + Epoch: pb.shared.epoch, + Set: pb.shared.active.set, + }) + if err != nil && !errors.Is(err, sql.ErrObjectExists) { + return err + } + return nil + }) + + // Two stage pipeline, with the stages running in parallel. + // 1. Initializes signers. Runs limited number of goroutines because the initialization is CPU and DB bound. + // 2. Collects eligible signers' sessions from the stage 1 and creates and publishes proposals. + + // Used to pass eligible singers from stage 1 → 2. + // Buffered with capacity for all signers so that writes don't block. + eligible := make(chan *signerSession, len(signers)) + + // Stage 1 + // Use a semaphore instead of eg.SetLimit so that the stage 2 starts immediately after + // scheduling all signers in the stage 1. Otherwise, stage 2 would wait for all stage 1 + // goroutines to at least start, which is not what we want. We want to start stage 2 as soon as possible. + limiter := semaphore.NewWeighted(int64(pb.cfg.workersLimit)) var eg errgroup.Group - eg.SetLimit(pb.cfg.workersLimit) for _, ss := range signers { eg.Go(func() error { + if err := limiter.Acquire(ctx, 1); err != nil { + return err + } + defer limiter.Release(1) + start := time.Now() + ss.latency.start = buildStartTime if err := pb.initSignerData(ctx, ss, lid); err != nil { if errors.Is(err, errAtxNotAvailable) { ss.log.Debug("smesher doesn't have atx that targets this epoch", @@ -585,94 +646,67 @@ func (pb *ProposalBuilder) build(ctx context.Context, lid types.LayerID) error { return err } } + ss.latency.data = time.Since(start) if lid <= ss.session.prev { - return fmt.Errorf( - "layer %d was already built by signer %s", - lid, - ss.signer.NodeID().ShortString(), - ) + return fmt.Errorf("layer %d was already built by signer %s", lid, ss.signer.NodeID().ShortString()) } ss.session.prev = lid - ss.latency.data = time.Since(start) - return nil - }) - } - if err := eg.Wait(); err != nil { - return err - } - - start := time.Now() - any := false - for _, ss := range signers { - if n := len(ss.session.eligibilities.proofs[lid]); n == 0 { - ss.log.Debug("not eligible for proposal in layer", - log.ZContext(ctx), - zap.Uint32("layer_id", lid.Uint32()), - zap.Uint32("epoch_id", lid.GetEpoch().Uint32()), - ) - continue - } else { + proofs := ss.session.eligibilities.proofs[lid] + if len(proofs) == 0 { + ss.log.Debug("not eligible for proposal in layer", + log.ZContext(ctx), + zap.Uint32("layer_id", lid.Uint32()), + zap.Uint32("epoch_id", lid.GetEpoch().Uint32()), + ) + return nil + } ss.log.Debug("eligible for proposals in layer", log.ZContext(ctx), zap.Uint32("layer_id", lid.Uint32()), - zap.Int("num proposals", n), + zap.Uint32("epoch_id", lid.GetEpoch().Uint32()), + zap.Int("num proposals", len(proofs)), ) - any = true - } - } - if !any { - return nil - } - - pb.tortoise.TallyVotes(ctx, lid) - // TODO(dshulyak) get rid from the EncodeVotesWithCurrent option in a followup - // there are some dependencies in the tests - opinion, err := pb.tortoise.EncodeVotes(ctx, tortoise.EncodeVotesWithCurrent(lid)) - if err != nil { - return fmt.Errorf("encode votes: %w", err) - } - for _, ss := range signers { - ss.latency.tortoise = time.Since(start) + eligible <- ss // won't block + return nil + }) } - start = time.Now() - meshHash := pb.decideMeshHash(ctx, lid) - for _, ss := range signers { - ss.latency.hash = time.Since(start) - } + var stage1Err error + go func() { + stage1Err = eg.Wait() + close(eligible) + }() - start = time.Now() - for _, ss := range signers { - proofs := ss.session.eligibilities.proofs[lid] - if len(proofs) == 0 { - ss.log.Debug("not eligible for proposal in layer", - log.ZContext(ctx), - zap.Uint32("layer_id", lid.Uint32()), - zap.Uint32("epoch_id", lid.GetEpoch().Uint32()), - ) - continue + // Stage 2 + eg2 := errgroup.Group{} + for ss := range eligible { + start := time.Now() + opinion, err := encodeVotesOnce() + if err != nil { + return err } - ss.log.Debug("eligible for proposals in layer", - log.ZContext(ctx), - zap.Uint32("layer_id", lid.Uint32()), - zap.Int("num proposals", len(proofs)), - ) + ss.latency.tortoise = time.Since(start) - txs := pb.conState.SelectProposalTXs(lid, len(proofs)) - ss.latency.txs = time.Since(start) + start = time.Now() + meshHash := calcMeshHashOnce() + ss.latency.hash = time.Since(start) - // needs to be saved before publishing, as we will query it in handler - if ss.session.ref == types.EmptyBallotID { - if err := activesets.Add(pb.db, pb.shared.active.id, &types.EpochActiveSet{ - Epoch: ss.session.epoch, - Set: pb.shared.active.set, - }); err != nil && !errors.Is(err, sql.ErrObjectExists) { - return err + eg2.Go(func() error { + // needs to be saved before publishing, as we will query it in handler + if ss.session.ref == types.EmptyBallotID { + start := time.Now() + if err := persistActiveSetOnce(); err != nil { + return err + } + ss.latency.activeSet = time.Since(start) } - } + proofs := ss.session.eligibilities.proofs[lid] - eg.Go(func() error { - start := time.Now() + start = time.Now() + txs := pb.conState.SelectProposalTXs(lid, len(proofs)) + ss.latency.txs = time.Since(start) + + start = time.Now() proposal := createProposal( &ss.session, pb.shared.beacon, @@ -706,7 +740,8 @@ func (pb *ProposalBuilder) build(ctx context.Context, lid types.LayerID) error { return nil }) } - return eg.Wait() + + return errors.Join(stage1Err, eg2.Wait()) } func createProposal( @@ -762,8 +797,8 @@ func calcEligibilityProofs( slots uint32, layersPerEpoch uint32, ) map[types.LayerID][]types.VotingEligibility { - proofs := map[types.LayerID][]types.VotingEligibility{} - for counter := uint32(0); counter < slots; counter++ { + proofs := make(map[types.LayerID][]types.VotingEligibility, slots) + for counter := range slots { vrf := signer.Sign(proposals.MustSerializeVRFMessage(beacon, epoch, nonce, counter)) layer := proposals.CalcEligibleLayer(epoch, layersPerEpoch, vrf) proofs[layer] = append(proofs[layer], types.VotingEligibility{ diff --git a/miner/proposal_builder_test.go b/miner/proposal_builder_test.go index e4b67fad08..59280e51c0 100644 --- a/miner/proposal_builder_test.go +++ b/miner/proposal_builder_test.go @@ -244,6 +244,121 @@ type step struct { expectErr string } +// The test verifies that the proposal builder creates proposals as soon as possible +// for initialized and eligible signers, especially if the uninitialized ones take +// long to init or are blocked entirely. +func TestBuild_BlockedSignerInitDoesntBlockEligible(t *testing.T) { + var signers []*signing.EdSigner + rng := rand.New(rand.NewSource(10101)) + for range 2 { + signer, err := signing.NewEdSigner(signing.WithKeyFromRand(rng)) + require.NoError(t, err) + signers = append(signers, signer) + } + + var ( + ctrl = gomock.NewController(t) + conState = mocks.NewMockconservativeState(ctrl) + clock = mocks.NewMocklayerClock(ctrl) + publisher = pmocks.NewMockPublisher(ctrl) + trtl = mocks.NewMockvotesEncoder(ctrl) + syncer = smocks.NewMockSyncStateProvider(ctrl) + db = statesql.InMemoryTest(t) + localdb = localsql.InMemoryTest(t) + atxsdata = atxsdata.New() + // singer[1] is blocked + atxSearch = mocks.NewMockatxSearch(ctrl) + ) + opts := []Opt{ + WithLayerPerEpoch(types.GetLayersPerEpoch()), + WithLayerSize(2), + WithLogger(zaptest.NewLogger(t)), + WithSigners(signers...), + withAtxSearch(atxSearch), + } + builder := New(clock, db, localdb, atxsdata, publisher, trtl, syncer, conState, opts...) + lid := types.LayerID(15) + + // only signer[0] has ATX + atx := gatx(types.ATXID{1}, lid.GetEpoch()-1, signers[0].NodeID(), 1, genAtxWithNonce(777)) + require.NoError(t, atxs.Add(db, atx, types.AtxBlob{})) + atxsdata.AddFromAtx(atx, false) + atxSearch.EXPECT().GetIDByEpochAndNodeID(gomock.Any(), lid.GetEpoch()-1, signers[0].NodeID()).DoAndReturn( + func(_ context.Context, epoch types.EpochID, nodeID types.NodeID) (types.ATXID, error) { + return atxs.GetIDByEpochAndNodeID(db, epoch, nodeID) + }, + ) + atxSearch.EXPECT().GetIDByEpochAndNodeID(gomock.Any(), lid.GetEpoch()-1, signers[1].NodeID()).DoAndReturn( + func(ctx context.Context, epoch types.EpochID, nodeID types.NodeID) (types.ATXID, error) { + <-ctx.Done() + return types.EmptyATXID, ctx.Err() + }, + ) + + beacon := types.Beacon{1} + require.NoError(t, beacons.Add(db, lid.GetEpoch(), beacon)) + opinion := types.Opinion{Hash: types.Hash32{1}} + txs := []types.TransactionID{{1}, {2}} + expectedProposal := expectProposal( + signers[0], lid, atx.ID(), opinion, + expectEpochData(gactiveset(atx.ID()), 10, beacon), + expectTxs(txs), + expectCounters(signers[0], 3, beacon, 777, 0, 6, 9), + ) + + clock.EXPECT().LayerToTime(gomock.Any()).Return(time.Unix(0, 0)).AnyTimes() + conState.EXPECT().SelectProposalTXs(lid, gomock.Any()).Return(txs) + trtl.EXPECT().TallyVotes(gomock.Any(), lid) + trtl.EXPECT().EncodeVotes(gomock.Any(), gomock.Any()).Return(&opinion, nil) + trtl.EXPECT().LatestComplete().Return(lid - 1) + ctx, cancel := context.WithCancel(context.Background()) + publisher.EXPECT(). + Publish(ctx, pubsub.ProposalProtocol, gomock.Any()). + DoAndReturn(func(_ context.Context, _ string, msg []byte) error { + defer cancel() // unblock the build hang on atx lookup for signer[1] + var proposal types.Proposal + codec.MustDecode(msg, &proposal) + proposal.MustInitialize() + require.Equal(t, *expectedProposal, proposal) + require.NoError(t, ballots.Add(db, &proposal.Ballot)) + return nil + }) + + require.ErrorIs(t, builder.build(ctx, lid), context.Canceled) + + // Try again in the next layer + // signer[1] is still NOT initialized (missing ATX) but it won't block this time + lid += 1 + txs = []types.TransactionID{{17}, {22}} + atxSearch.EXPECT().GetIDByEpochAndNodeID(gomock.Any(), lid.GetEpoch()-1, signers[1].NodeID()).DoAndReturn( + func(_ context.Context, epoch types.EpochID, nodeID types.NodeID) (types.ATXID, error) { + return atxs.GetIDByEpochAndNodeID(db, epoch, nodeID) + }, + ) + expectedProposal = expectProposal( + signers[0], lid, atx.ID(), opinion, + expectTxs(txs), + expectCounters(signers[0], 3, beacon, 777, 2, 5), + expectRef(expectedProposal.Ballot.ID()), + ) + trtl.EXPECT().TallyVotes(gomock.Any(), lid) + trtl.EXPECT().EncodeVotes(gomock.Any(), gomock.Any()).Return(&opinion, nil) + trtl.EXPECT().LatestComplete().Return(lid - 1) + conState.EXPECT().SelectProposalTXs(lid, gomock.Any()).Return(txs) + + publisher.EXPECT(). + Publish(context.Background(), pubsub.ProposalProtocol, gomock.Any()). + DoAndReturn(func(_ context.Context, _ string, msg []byte) error { + var proposal types.Proposal + codec.MustDecode(msg, &proposal) + proposal.MustInitialize() + require.Equal(t, *expectedProposal, proposal) + return nil + }) + + require.NoError(t, builder.build(context.Background(), lid)) +} + func TestBuild(t *testing.T) { signers := make([]*signing.EdSigner, 4) rng := rand.New(rand.NewSource(10101)) @@ -739,6 +854,52 @@ func TestBuild(t *testing.T) { }, }, }, + { + desc: "publish two proposals in different layers", + opts: []Opt{WithLayerSize(2)}, + steps: []step{ + { + lid: 15, + beacon: types.Beacon{1}, + atxs: []*types.ActivationTx{ + gatx(types.ATXID{1}, 2, signer.NodeID(), 1, genAtxWithNonce(777)), + }, + + activeset: types.ATXIDList{{1}}, + txs: []types.TransactionID{{1}, {2}}, + opinion: &types.Opinion{Hash: types.Hash32{1}}, + latestComplete: 8, + expectProposal: expectProposal( + signer, 15, types.ATXID{1}, types.Opinion{Hash: types.Hash32{1}}, + expectEpochData(gactiveset(types.ATXID{1}), 10, types.Beacon{1}), + expectTxs([]types.TransactionID{{1}, {2}}), + expectCounters(signer, 3, types.Beacon{1}, 777, 0, 6, 9), + ), + }, + { + lid: 16, + ballots: []*types.Ballot{ + gballot(types.BallotID{1}, types.ATXID{1}, signer.NodeID(), 15, &types.EpochData{ + ActiveSetHash: types.ATXIDList{{1}}.Hash(), + EligibilityCount: 10, + Beacon: types.Beacon{1}, + }), + }, + txs: []types.TransactionID{{5}}, + opinion: &types.Opinion{Hash: types.Hash32{1}}, + latestComplete: 15, + hare: []types.LayerID{9, 10, 11, 12, 13, 14, 15, 16}, + aggHashes: []aggHash{{lid: 15, hash: types.Hash32{9, 9, 9}}}, + expectProposal: expectProposal( + signer, 16, types.ATXID{1}, types.Opinion{Hash: types.Hash32{1}}, + expectRef(types.BallotID{1}), + expectTxs([]types.TransactionID{{5}}), + expectMeshHash(types.Hash32{9, 9, 9}), + expectCounters(signer, 3, types.Beacon{1}, 777, 2, 5), + ), + }, + }, + }, } { t.Run(tc.desc, func(t *testing.T) { var ( @@ -852,7 +1013,7 @@ func TestBuild(t *testing.T) { err := builder.build(ctx, step.lid) close(decoded) if len(step.expectErr) > 0 { - require.ErrorContains(t, err, step.expectErr) + require.ErrorContains(t, err, step.expectErr, "expected: %s", step.expectErr) } else { require.NoError(t, err) expect := step.expectProposals