diff --git a/blocks/generator_test.go b/blocks/generator_test.go index 8519f059d4..90b934a6d4 100644 --- a/blocks/generator_test.go +++ b/blocks/generator_test.go @@ -37,12 +37,15 @@ const ( numUnit = 12 defaultGas = 100 baseTickHeight = 3 + + layerSize = 10 + epochSize = 3 ) func testConfig() Config { return Config{ - LayerSize: 10, - LayersPerEpoch: 3, + LayerSize: layerSize, + LayersPerEpoch: epochSize, GenBlockInterval: 10 * time.Millisecond, BlockGasLimit: math.MaxUint64, OptFilterThreshold: 90, @@ -190,9 +193,12 @@ func createProposal( InnerProposal: types.InnerProposal{ Ballot: types.Ballot{ InnerBallot: types.InnerBallot{ - Layer: lid, - AtxID: atxID, - EpochData: &types.EpochData{Beacon: types.RandomBeacon()}, + Layer: lid, + AtxID: atxID, + EpochData: &types.EpochData{ + Beacon: types.RandomBeacon(), + EligibilityCount: uint32(layerSize * epochSize / len(activeSet)), + }, }, EligibilityProofs: make([]types.VotingEligibility, numEligibility), ActiveSet: activeSet, diff --git a/miner/oracle.go b/miner/oracle.go index 263efd69b3..69426db8c1 100644 --- a/miner/oracle.go +++ b/miner/oracle.go @@ -32,9 +32,10 @@ type EpochEligibility struct { // Oracle provides proposal eligibility proofs for the miner. type Oracle struct { - avgLayerSize uint32 - layersPerEpoch uint32 - cdb *datastore.CachedDB + avgLayerSize uint32 + layersPerEpoch uint32 + minActiveSetWeight uint64 + cdb *datastore.CachedDB vrfSigner *signing.VRFSigner nodeID types.NodeID @@ -44,15 +45,16 @@ type Oracle struct { cache *EpochEligibility } -func newMinerOracle(layerSize, layersPerEpoch uint32, cdb *datastore.CachedDB, vrfSigner *signing.VRFSigner, nodeID types.NodeID, log log.Log) *Oracle { +func newMinerOracle(layerSize, layersPerEpoch uint32, minActiveSetWeight uint64, cdb *datastore.CachedDB, vrfSigner *signing.VRFSigner, nodeID types.NodeID, log log.Log) *Oracle { return &Oracle{ - avgLayerSize: layerSize, - layersPerEpoch: layersPerEpoch, - cdb: cdb, - vrfSigner: vrfSigner, - nodeID: nodeID, - log: log, - cache: &EpochEligibility{}, + avgLayerSize: layerSize, + layersPerEpoch: layersPerEpoch, + minActiveSetWeight: minActiveSetWeight, + cdb: cdb, + vrfSigner: vrfSigner, + nodeID: nodeID, + log: log, + cache: &EpochEligibility{}, } } @@ -138,7 +140,7 @@ func (o *Oracle) calcEligibilityProofs(atx *types.ActivationTxHeader, epoch type log.Uint64("total weight", totalWeight), ) - numEligibleSlots, err := proposals.GetNumEligibleSlots(weight, totalWeight, o.avgLayerSize, o.layersPerEpoch) + numEligibleSlots, err := proposals.GetNumEligibleSlots(weight, o.minActiveSetWeight, totalWeight, o.avgLayerSize, o.layersPerEpoch) if err != nil { return nil, fmt.Errorf("oracle get num slots: %w", err) } @@ -162,6 +164,7 @@ func (o *Oracle) calcEligibilityProofs(atx *types.ActivationTxHeader, epoch type epoch, beacon, log.Uint64("weight", weight), + log.Uint64("min activeset weight", o.minActiveSetWeight), log.Uint64("total weight", totalWeight), log.Uint32("total num slots", numEligibleSlots), log.Int("num layers eligible", len(eligibilityProofs)), diff --git a/miner/oracle_test.go b/miner/oracle_test.go index 75ce8f3f2b..2e51ea7fea 100644 --- a/miner/oracle_test.go +++ b/miner/oracle_test.go @@ -89,7 +89,7 @@ func genBallotWithEligibility( return ballot } -func createTestOracle(tb testing.TB, layerSize, layersPerEpoch uint32) *testOracle { +func createTestOracle(tb testing.TB, layerSize, layersPerEpoch uint32, minActiveSetWeight uint64) *testOracle { types.SetLayersPerEpoch(layersPerEpoch) lg := logtest.New(tb) @@ -97,7 +97,7 @@ func createTestOracle(tb testing.TB, layerSize, layersPerEpoch uint32) *testOrac nodeID, edSigner, vrfSigner := generateNodeIDAndSigner(tb) return &testOracle{ - Oracle: newMinerOracle(layerSize, layersPerEpoch, cdb, vrfSigner, nodeID, lg), + Oracle: newMinerOracle(layerSize, layersPerEpoch, minActiveSetWeight, cdb, vrfSigner, nodeID, lg), nodeID: nodeID, edSigner: edSigner, vrfSigner: vrfSigner, @@ -143,7 +143,7 @@ func TestMinerOracle(t *testing.T) { } func testMinerOracleAndProposalValidator(t *testing.T, layerSize uint32, layersPerEpoch uint32) { - o := createTestOracle(t, layerSize, layersPerEpoch) + o := createTestOracle(t, layerSize, layersPerEpoch, 0) ctrl := gomock.NewController(t) mbc := mocks.NewMockBeaconCollector(ctrl) @@ -153,7 +153,7 @@ func testMinerOracleAndProposalValidator(t *testing.T, layerSize uint32, layersP nonceFetcher := proposals.NewMocknonceFetcher(ctrl) nonce := types.VRFPostIndex(rand.Uint64()) - validator := proposals.NewEligibilityValidator(layerSize, layersPerEpoch, o.cdb, mbc, nil, o.log.WithName("blkElgValidator"), vrfVerifier, + validator := proposals.NewEligibilityValidator(layerSize, layersPerEpoch, 0, o.cdb, mbc, nil, o.log.WithName("blkElgValidator"), vrfVerifier, proposals.WithNonceFetcher(nonceFetcher), ) @@ -195,7 +195,7 @@ func testMinerOracleAndProposalValidator(t *testing.T, layerSize uint32, layersP func TestOracle_OwnATXNotFound(t *testing.T) { avgLayerSize := uint32(10) layersPerEpoch := uint32(20) - o := createTestOracle(t, avgLayerSize, layersPerEpoch) + o := createTestOracle(t, avgLayerSize, layersPerEpoch, 0) lid := types.LayerID(layersPerEpoch * 3) ee, err := o.GetProposalEligibility(lid, types.RandomBeacon(), types.VRFPostIndex(1)) require.ErrorIs(t, err, errMinerHasNoATXInPreviousEpoch) @@ -205,7 +205,7 @@ func TestOracle_OwnATXNotFound(t *testing.T) { func TestOracle_EligibilityCached(t *testing.T) { avgLayerSize := uint32(10) layersPerEpoch := uint32(20) - o := createTestOracle(t, avgLayerSize, layersPerEpoch) + o := createTestOracle(t, avgLayerSize, layersPerEpoch, 0) lid := types.LayerID(layersPerEpoch * 3) epochInfo := genATXForTargetEpochs(t, o.cdb, lid.GetEpoch(), lid.GetEpoch()+1, o.edSigner, layersPerEpoch) info, ok := epochInfo[lid.GetEpoch()] @@ -219,3 +219,27 @@ func TestOracle_EligibilityCached(t *testing.T) { require.NoError(t, err) require.Equal(t, ee1, ee2) } + +func TestOracle_MinimalActiveSetWeight(t *testing.T) { + avgLayerSize := uint32(10) + layersPerEpoch := uint32(20) + + o := createTestOracle(t, avgLayerSize, layersPerEpoch, 0) + lid := types.LayerID(layersPerEpoch * 3) + epochInfo := genATXForTargetEpochs(t, o.cdb, lid.GetEpoch(), lid.GetEpoch()+1, o.edSigner, layersPerEpoch) + + info, ok := epochInfo[lid.GetEpoch()] + require.True(t, ok) + + ee1, err := o.GetProposalEligibility(lid, info.beacon, types.VRFPostIndex(1)) + require.NoError(t, err) + require.NotNil(t, ee1) + + o.minActiveSetWeight = 100000 + o.cache.Epoch = 0 + ee2, err := o.GetProposalEligibility(lid, info.beacon, types.VRFPostIndex(1)) + require.NoError(t, err) + require.NotNil(t, ee1) + + require.Less(t, ee2.Slots, ee1.Slots) +} diff --git a/miner/proposal_builder.go b/miner/proposal_builder.go index 094fa59584..538f7a2360 100644 --- a/miner/proposal_builder.go +++ b/miner/proposal_builder.go @@ -61,10 +61,11 @@ type ProposalBuilder struct { // config defines configuration for the ProposalBuilder. type config struct { - layerSize uint32 - layersPerEpoch uint32 - hdist uint32 - nodeID types.NodeID + layerSize uint32 + layersPerEpoch uint32 + hdist uint32 + minActiveSetWeight uint64 + nodeID types.NodeID } type defaultFetcher struct { @@ -96,6 +97,12 @@ func WithLayerPerEpoch(layers uint32) Opt { } } +func WithMinimalActiveSetWeight(weight uint64) Opt { + return func(pb *ProposalBuilder) { + pb.cfg.minActiveSetWeight = weight + } +} + // WithNodeID defines the miner's NodeID. func WithNodeID(id types.NodeID) Opt { return func(pb *ProposalBuilder) { @@ -162,7 +169,7 @@ func NewProposalBuilder( } if pb.proposalOracle == nil { - pb.proposalOracle = newMinerOracle(pb.cfg.layerSize, pb.cfg.layersPerEpoch, cdb, vrfSigner, pb.cfg.nodeID, pb.logger) + pb.proposalOracle = newMinerOracle(pb.cfg.layerSize, pb.cfg.layersPerEpoch, pb.cfg.minActiveSetWeight, cdb, vrfSigner, pb.cfg.nodeID, pb.logger) } if pb.nonceFetcher == nil { diff --git a/node/node.go b/node/node.go index c664e10c18..09d50c5e12 100644 --- a/node/node.go +++ b/node/node.go @@ -672,11 +672,12 @@ func (app *App) initServices(ctx context.Context, poetClients []activation.PoetP proposalListener := proposals.NewHandler(app.cachedDB, app.edVerifier, app.host, fetcherWrapped, beaconProtocol, msh, trtl, vrfVerifier, app.clock, proposals.WithLogger(app.addLogger(ProposalListenerLogger, lg)), proposals.WithConfig(proposals.Config{ - LayerSize: layerSize, - LayersPerEpoch: layersPerEpoch, - GoldenATXID: goldenATXID, - MaxExceptions: trtlCfg.MaxExceptions, - Hdist: trtlCfg.Hdist, + LayerSize: layerSize, + LayersPerEpoch: layersPerEpoch, + GoldenATXID: goldenATXID, + MaxExceptions: trtlCfg.MaxExceptions, + Hdist: trtlCfg.Hdist, + MinimalActiveSetWeight: trtlCfg.MinimalActiveSetWeight, }), ) @@ -782,6 +783,7 @@ func (app *App) initServices(ctx context.Context, poetClients []activation.PoetP miner.WithNodeID(app.edSgn.NodeID()), miner.WithLayerSize(layerSize), miner.WithLayerPerEpoch(layersPerEpoch), + miner.WithMinimalActiveSetWeight(app.Config.Tortoise.MinimalActiveSetWeight), miner.WithHdist(app.Config.Tortoise.Hdist), miner.WithLogger(app.addLogger(ProposalBuilderLogger, lg)), ) diff --git a/proposals/eligibility_validator.go b/proposals/eligibility_validator.go index 0b2fdb99e7..33226cd23f 100644 --- a/proposals/eligibility_validator.go +++ b/proposals/eligibility_validator.go @@ -27,14 +27,15 @@ var ( // Validator validates the eligibility of a Ballot. // the validation focuses on eligibility only and assumes the Ballot to be valid otherwise. type Validator struct { - avgLayerSize uint32 - layersPerEpoch uint32 - cdb *datastore.CachedDB - mesh meshProvider - beacons system.BeaconCollector - logger log.Log - vrfVerifier vrfVerifier - nonceFetcher nonceFetcher + minActiveSetWeight uint64 + avgLayerSize uint32 + layersPerEpoch uint32 + cdb *datastore.CachedDB + mesh meshProvider + beacons system.BeaconCollector + logger log.Log + vrfVerifier vrfVerifier + nonceFetcher nonceFetcher } type defaultFetcher struct { @@ -60,16 +61,17 @@ func WithNonceFetcher(nf nonceFetcher) ValidatorOpt { // NewEligibilityValidator returns a new EligibilityValidator. func NewEligibilityValidator( - avgLayerSize, layersPerEpoch uint32, cdb *datastore.CachedDB, bc system.BeaconCollector, m meshProvider, lg log.Log, vrfVerifier vrfVerifier, opts ...ValidatorOpt, + avgLayerSize, layersPerEpoch uint32, minActiveSetWeight uint64, cdb *datastore.CachedDB, bc system.BeaconCollector, m meshProvider, lg log.Log, vrfVerifier vrfVerifier, opts ...ValidatorOpt, ) *Validator { v := &Validator{ - avgLayerSize: avgLayerSize, - layersPerEpoch: layersPerEpoch, - cdb: cdb, - mesh: m, - beacons: bc, - logger: lg, - vrfVerifier: vrfVerifier, + minActiveSetWeight: minActiveSetWeight, + avgLayerSize: avgLayerSize, + layersPerEpoch: layersPerEpoch, + cdb: cdb, + mesh: m, + beacons: bc, + logger: lg, + vrfVerifier: vrfVerifier, } for _, opt := range opts { opt(v) @@ -141,7 +143,7 @@ func (v *Validator) CheckEligibility(ctx context.Context, ballot *types.Ballot) atxWeight = owned.GetWeight() - numEligibleSlots, err := GetNumEligibleSlots(atxWeight, totalWeight, v.avgLayerSize, v.layersPerEpoch) + numEligibleSlots, err := GetNumEligibleSlots(atxWeight, v.minActiveSetWeight, totalWeight, v.avgLayerSize, v.layersPerEpoch) if err != nil { return false, err } diff --git a/proposals/eligibility_validator_test.go b/proposals/eligibility_validator_test.go index c876d208d6..572bc8b8a2 100644 --- a/proposals/eligibility_validator_test.go +++ b/proposals/eligibility_validator_test.go @@ -82,7 +82,7 @@ func createTestValidator(tb testing.TB) *testValidator { lg := logtest.New(tb) return &testValidator{ - Validator: NewEligibilityValidator(layerAvgSize, layersPerEpoch, datastore.NewCachedDB(sql.InMemory(), lg), ms.mbc, ms.mm, lg, ms.mvrf, + Validator: NewEligibilityValidator(layerAvgSize, layersPerEpoch, 0, datastore.NewCachedDB(sql.InMemory(), lg), ms.mbc, ms.mm, lg, ms.mvrf, WithNonceFetcher(ms.mNonce), ), mockSet: ms, @@ -91,7 +91,7 @@ func createTestValidator(tb testing.TB) *testValidator { func createBallots(tb testing.TB, signer *signing.EdSigner, activeSet types.ATXIDList, beacon types.Beacon) []*types.Ballot { totalWeight := uint64(len(activeSet)-1)*uint64(defaultATXUnit) + uint64(testedATXUnit) - slots, err := GetNumEligibleSlots(uint64(testedATXUnit), totalWeight, layerAvgSize, layersPerEpoch) + slots, err := GetNumEligibleSlots(uint64(testedATXUnit), 0, totalWeight, layerAvgSize, layersPerEpoch) require.NoError(tb, err) require.Equal(tb, eligibleSlots, slots) eligibilityProofs := map[types.LayerID][]types.VotingEligibility{} @@ -501,3 +501,41 @@ func TestCheckEligibility_AtxNotIncluded(t *testing.T) { require.ErrorContains(t, err, "is not included into the active set") require.False(t, eligibile) } + +func TestCheckEligibility_MinActiveSetWeight(t *testing.T) { + tv := createTestValidator(t) + + atx := &types.ActivationTx{ + InnerActivationTx: types.InnerActivationTx{ + NumUnits: 2, + }, + } + atx.PositioningATX = types.ATXID{2} + atx.SetID(types.ATXID{1}) + atx.SetEffectiveNumUnits(atx.NumUnits) + atx.SetReceived(time.Time{}.Add(1)) + vatx, err := atx.Verify(0, 100) + require.NoError(t, err) + require.NoError(t, atxs.Add(tv.cdb, vatx)) + + tv.minActiveSetWeight = 2 * vatx.GetWeight() + + ballot := &types.Ballot{} + ballot.EligibilityProofs = []types.VotingEligibility{{}} + ballot.SetID(types.BallotID{1}) + ballot.AtxID = vatx.ID() + ballot.Layer = vatx.TargetEpoch().FirstLayer() + activeSet := types.ATXIDList{vatx.ID()} + computed, err := GetNumEligibleSlots(vatx.GetWeight(), 0, vatx.GetWeight(), tv.avgLayerSize, tv.layersPerEpoch) + require.NoError(t, err) + ballot.EpochData = &types.EpochData{ + ActiveSetHash: activeSet.Hash(), + Beacon: types.Beacon{1}, + EligibilityCount: computed, + } + ballot.ActiveSet = activeSet + + eligibile, err := tv.CheckEligibility(context.Background(), ballot) + require.ErrorContains(t, err, "ballot has incorrect eligibility count: expected 15, got: 30") + require.False(t, eligibile) +} diff --git a/proposals/handler.go b/proposals/handler.go index a7e6de87b1..329eb1fa59 100644 --- a/proposals/handler.go +++ b/proposals/handler.go @@ -61,11 +61,12 @@ type Handler struct { // Config defines configuration for the handler. type Config struct { - LayerSize uint32 - LayersPerEpoch uint32 - GoldenATXID types.ATXID - MaxExceptions int - Hdist uint32 + LayerSize uint32 + LayersPerEpoch uint32 + GoldenATXID types.ATXID + MaxExceptions int + Hdist uint32 + MinimalActiveSetWeight uint64 } // defaultConfig for BlockHandler. @@ -127,7 +128,7 @@ func NewHandler( opt(b) } if b.validator == nil { - b.validator = NewEligibilityValidator(b.cfg.LayerSize, b.cfg.LayersPerEpoch, cdb, bc, m, b.logger, verifier) + b.validator = NewEligibilityValidator(b.cfg.LayerSize, b.cfg.LayersPerEpoch, b.cfg.MinimalActiveSetWeight, cdb, bc, m, b.logger, verifier) } return b } diff --git a/proposals/util/util.go b/proposals/util/util.go index 4023b7cd37..dec25f5713 100644 --- a/proposals/util/util.go +++ b/proposals/util/util.go @@ -29,16 +29,23 @@ func CalcEligibleLayer(epochNumber types.EpochID, layersPerEpoch uint32, vrfSig return epochNumber.FirstLayer().Add(uint32(eligibleLayerOffset)) } +func maxWeight(a, b uint64) uint64 { + if a > b { + return a + } + return b +} + // GetNumEligibleSlots calculates the number of eligible slots for a smesher in an epoch. -func GetNumEligibleSlots(weight, totalWeight uint64, committeeSize uint32, layersPerEpoch uint32) (uint32, error) { +func GetNumEligibleSlots(weight, minWeight, totalWeight uint64, committeeSize uint32, layersPerEpoch uint32) (uint32, error) { if totalWeight == 0 { return 0, ErrZeroTotalWeight } - numberOfEligibleBlocks := weight * uint64(committeeSize) * uint64(layersPerEpoch) / totalWeight // TODO: ensure no overflow - if numberOfEligibleBlocks == 0 { - numberOfEligibleBlocks = 1 + numEligible := weight * uint64(committeeSize) * uint64(layersPerEpoch) / maxWeight(minWeight, totalWeight) // TODO: ensure no overflow + if numEligible == 0 { + numEligible = 1 } - return uint32(numberOfEligibleBlocks), nil + return uint32(numEligible), nil } // ComputeWeightPerEligibility computes the ballot weight per eligibility w.r.t the active set recorded in its reference ballot. @@ -49,10 +56,10 @@ func ComputeWeightPerEligibility( layersPerEpoch uint32, ) (*big.Rat, error) { var ( - refBallot = ballot - hdr *types.ActivationTxHeader - err error - total, atxWeight uint64 + refBallot = ballot + hdr *types.ActivationTxHeader + err error + atxWeight uint64 ) if ballot.EpochData == nil { if ballot.RefBallot == types.EmptyBallotID { @@ -66,27 +73,27 @@ func ComputeWeightPerEligibility( if len(refBallot.ActiveSet) == 0 { return nil, fmt.Errorf("ref ballot missing active set %s (for %s)", ballot.RefBallot, ballot.ID()) } + if refBallot.EpochData == nil { + return nil, fmt.Errorf("epoch data is nil on ballot %d/%s", refBallot.Layer, refBallot.ID()) + } + if refBallot.EpochData.EligibilityCount == 0 { + return nil, fmt.Errorf("eligibility count is 0 on ballot %d/%s", refBallot.Layer, refBallot.ID()) + } for _, atxID := range refBallot.ActiveSet { hdr, err = cdb.GetAtxHeader(atxID) if err != nil { return nil, fmt.Errorf("%w: missing atx %s in active set of %s (for %s)", err, atxID, refBallot.ID(), ballot.ID()) } - weight := hdr.GetWeight() - total += weight if atxID == ballot.AtxID { - atxWeight = weight + atxWeight = hdr.GetWeight() + break } } if atxWeight == 0 { return nil, fmt.Errorf("atx id %v is not found in the active set of the reference ballot %v with atxid %v", ballot.AtxID, refBallot.ID(), refBallot.AtxID) } - - expNumSlots, err := GetNumEligibleSlots(atxWeight, total, layerSize, layersPerEpoch) - if err != nil { - return nil, fmt.Errorf("failed to compute num eligibility for atx %s: %w", ballot.AtxID, err) - } return new(big.Rat).SetFrac( new(big.Int).SetUint64(atxWeight), - new(big.Int).SetUint64(uint64(expNumSlots)), + new(big.Int).SetUint64(uint64(refBallot.EpochData.EligibilityCount)), ), nil } diff --git a/tortoise/algorithm.go b/tortoise/algorithm.go index c5bc8902ff..edc05b3141 100644 --- a/tortoise/algorithm.go +++ b/tortoise/algorithm.go @@ -24,6 +24,10 @@ type Config struct { BadBeaconVoteDelayLayers uint32 `mapstructure:"tortoise-delay-layers"` // EnableTracer will write tortoise traces to the stderr. EnableTracer bool `mapstructure:"tortoise-enable-tracer"` + // MinimalActiveSetWeight denotes weight that will replace weight + // recorded in the first ballot, if that weight is less than minimal + // for purposes of eligibility computation. + MinimalActiveSetWeight uint64 `mapstructure:"tortoise-activeset-weight"` LayerSize uint32 } diff --git a/tortoise/fixture_test.go b/tortoise/fixture_test.go index bb80ad5f1b..259273dcff 100644 --- a/tortoise/fixture_test.go +++ b/tortoise/fixture_test.go @@ -691,6 +691,12 @@ func (s *session) withDelay(val uint32) *session { return s } +func (s *session) withMinActiveSetWeight(weight uint64) *session { + s.ensureConfig() + s.config.MinimalActiveSetWeight = weight + return s +} + func (s *session) tortoise() *Tortoise { s.ensureConfig() trt, err := New(WithLogger(logtest.New(s.tb)), WithConfig(*s.config)) diff --git a/tortoise/tortoise.go b/tortoise/tortoise.go index ce63e6b63d..c5463ab103 100644 --- a/tortoise/tortoise.go +++ b/tortoise/tortoise.go @@ -694,7 +694,7 @@ func (t *turtle) decodeBallot(ballot *types.BallotTortoiseData) (*ballotInfo, ty if err != nil { return nil, 0, err } - expected, err := util.GetNumEligibleSlots(atx.weight, total, t.LayerSize, types.GetLayersPerEpoch()) + expected, err := util.GetNumEligibleSlots(atx.weight, t.MinimalActiveSetWeight, total, t.LayerSize, types.GetLayersPerEpoch()) if err != nil { return nil, 0, err } diff --git a/tortoise/tortoise_test.go b/tortoise/tortoise_test.go index bacf07334f..18916cc758 100644 --- a/tortoise/tortoise_test.go +++ b/tortoise/tortoise_test.go @@ -3055,6 +3055,22 @@ func TestUpdates(t *testing.T) { }) } +func TestMinimalActiveSetWeight(t *testing.T) { + s := newSession(t). + withMinActiveSetWeight(1000) + + s.smesher(0).atx(1, new(aopt).height(10).weight(2)) + s.beacon(1, "a") + s.smesher(0).atx(1).ballot(1, new(bopt). + activeset(s.smesher(0).atx(1)). + beacon("a"). + eligibilities(1), + ) + s.tallyWait(1) + s.updates(t, new(results).verified(0).next(1)) + s.runInorder() +} + func TestDuplicateBallot(t *testing.T) { s := newSession(t) s.smesher(0).atx(1, new(aopt).height(10).weight(2))