Repository: iotaledger/iota-core
Pull request #408 (2023-10-09 07:42:10)

Implement candidate registration and TopStakers seat manager.
This PR adds support for top staker committee selection as well as registering candidates for next epoch committee selection.

Candidates are registered whenever they issue a single block before Epoch Nearing Threshold in the previous epoch.


Patch:
diff --git a/components/debugapi/node.go b/components/debugapi/node.go
index 893fb7c4..280311b7 100644
--- a/components/debugapi/node.go
+++ b/components/debugapi/node.go
@@ -1,6 +1,7 @@
 package debugapi
 
 import (
+	"github.com/iotaledger/hive.go/ierrors"
 	"github.com/iotaledger/hive.go/lo"
 	"github.com/iotaledger/iota-core/pkg/core/account"
 	iotago "github.com/iotaledger/iota.go/v4"
@@ -10,8 +11,12 @@ import (
 func validatorsSummary() (*ValidatorsSummaryResponse, error) {
 	seatManager := deps.Protocol.MainEngineInstance().SybilProtection.SeatManager()
 	latestSlotIndex := deps.Protocol.MainEngineInstance().Storage.Settings().LatestCommitment().Slot()
-	latestCommittee := seatManager.Committee(latestSlotIndex)
-	validatorSeats := []*Validator{}
+	latestCommittee, exists := seatManager.CommitteeInSlot(latestSlotIndex)
+	if !exists {
+		return nil, ierrors.Errorf("committee for slot %d was not selected", latestSlotIndex)
+	}
+
+	var validatorSeats []*Validator
 	latestCommittee.Accounts().ForEach(func(id iotago.AccountID, pool *account.Pool) bool {
 		validatorSeats = append(validatorSeats, &Validator{
 			AccountID:      id,
diff --git a/components/restapi/core/accounts.go b/components/restapi/core/accounts.go
index ed6f6264..995e7db3 100644
--- a/components/restapi/core/accounts.go
+++ b/components/restapi/core/accounts.go
@@ -110,7 +110,11 @@ func validatorByAccountID(c echo.Context) (*apimodels.ValidatorResponse, error)
 		return nil, ierrors.Errorf("account not found: %s for latest committedSlot %d", accountID.ToHex(), latestCommittedSlot)
 	}
 	nextEpoch := deps.Protocol.APIForSlot(latestCommittedSlot).TimeProvider().EpochFromSlot(latestCommittedSlot) + 1
-	active := deps.Protocol.MainEngineInstance().SybilProtection.IsCandidateActive(accountID, nextEpoch)
+
+	active, err := deps.Protocol.MainEngineInstance().SybilProtection.IsCandidateActive(accountID, nextEpoch)
+	if err != nil {
+		return nil, ierrors.Wrapf(err, "failed to check if account %s is an active candidate", accountID.ToHex())
+	}
 
 	return &apimodels.ValidatorResponse{
 		AccountID:                      accountID,
@@ -198,7 +202,13 @@ func selectedCommittee(c echo.Context) *apimodels.CommitteeResponse {
 		slot = timeProvider.EpochEnd(epoch)
 	}
 
-	seatedAccounts := deps.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(slot)
+	seatedAccounts, exists := deps.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(slot)
+	if !exists {
+		return &apimodels.CommitteeResponse{
+			Epoch: epoch,
+		}
+	}
+
 	committee := make([]*apimodels.CommitteeMemberResponse, 0, seatedAccounts.Accounts().Size())
 	seatedAccounts.Accounts().ForEach(func(accountID iotago.AccountID, seat *account.Pool) bool {
 		committee = append(committee, &apimodels.CommitteeMemberResponse{
diff --git a/components/validator/issuer.go b/components/validator/issuer.go
index 7f325777..8a748b58 100644
--- a/components/validator/issuer.go
+++ b/components/validator/issuer.go
@@ -81,7 +81,15 @@ func issueValidatorBlock(ctx context.Context) {
 		return
 	}
 
-	if !engineInstance.SybilProtection.SeatManager().Committee(deps.Protocol.CommittedAPI().TimeProvider().SlotFromTime(blockIssuingTime)).HasAccount(validatorAccount.ID()) {
+	blockSlot := deps.Protocol.CommittedAPI().TimeProvider().SlotFromTime(blockIssuingTime)
+	committee, exists := engineInstance.SybilProtection.SeatManager().CommitteeInSlot(blockSlot)
+	if !exists {
+		Component.LogWarnf("committee for slot %d not selected: %s", blockSlot, err.Error())
+
+		return
+	}
+
+	if !committee.HasAccount(validatorAccount.ID()) {
 		// update nextBroadcast value here, so that this updated value is used in the `defer`
 		// callback to schedule issuing of the next block at a different interval than for committee members
 		nextBroadcast = blockIssuingTime.Add(ParamsValidator.CandidateBroadcastInterval)
diff --git a/pkg/protocol/commitment_verifier.go b/pkg/protocol/commitment_verifier.go
index 1df7c3a9..85e715d8 100644
--- a/pkg/protocol/commitment_verifier.go
+++ b/pkg/protocol/commitment_verifier.go
@@ -19,15 +19,18 @@ type CommitmentVerifier struct {
 	validatorAccountsAtFork map[iotago.AccountID]*accounts.AccountData
 }
 
-func NewCommitmentVerifier(mainEngine *engine.Engine, lastCommonCommitmentBeforeFork *model.Commitment) *CommitmentVerifier {
-	committeeAtForkingPoint := mainEngine.SybilProtection.SeatManager().Committee(lastCommonCommitmentBeforeFork.Slot()).Accounts().IDs()
+func NewCommitmentVerifier(mainEngine *engine.Engine, lastCommonCommitmentBeforeFork *model.Commitment) (*CommitmentVerifier, error) {
+	committeeAtForkingPoint, exists := mainEngine.SybilProtection.SeatManager().CommitteeInSlot(lastCommonCommitmentBeforeFork.Slot())
+	if !exists {
+		return nil, ierrors.Errorf("committee in slot %d does not exist", lastCommonCommitmentBeforeFork.Slot())
+	}
 
 	return &CommitmentVerifier{
 		engine:                  mainEngine,
 		cumulativeWeight:        lastCommonCommitmentBeforeFork.CumulativeWeight(),
-		validatorAccountsAtFork: lo.PanicOnErr(mainEngine.Ledger.PastAccounts(committeeAtForkingPoint, lastCommonCommitmentBeforeFork.Slot())),
+		validatorAccountsAtFork: lo.PanicOnErr(mainEngine.Ledger.PastAccounts(committeeAtForkingPoint.Accounts().IDs(), lastCommonCommitmentBeforeFork.Slot())),
 		// TODO: what happens if the committee rotated after the fork?
-	}
+	}, nil
 }
 
 func (c *CommitmentVerifier) verifyCommitment(commitment *model.Commitment, attestations []*iotago.Attestation, merkleProof *merklehasher.Proof[iotago.Identifier]) (blockIDsFromAttestations iotago.BlockIDs, cumulativeWeight uint64, err error) {
@@ -153,7 +156,13 @@ func (c *CommitmentVerifier) verifyAttestations(attestations []*iotago.Attestati
 		if err != nil {
 			return nil, 0, ierrors.Wrap(err, "error calculating blockID from attestation")
 		}
-		if _, seatExists := c.engine.SybilProtection.SeatManager().Committee(attestationBlockID.Slot()).GetSeat(att.IssuerID); seatExists {
+
+		committee, exists := c.engine.SybilProtection.SeatManager().CommitteeInSlot(attestationBlockID.Slot())
+		if !exists {
+			return nil, 0, ierrors.Errorf("committee for slot %d does not exist", attestationBlockID.Slot())
+		}
+
+		if _, seatExists := committee.GetSeat(att.IssuerID); seatExists {
 			seatCount++
 		}
 
diff --git a/pkg/protocol/engine/congestioncontrol/scheduler/drr/scheduler.go b/pkg/protocol/engine/congestioncontrol/scheduler/drr/scheduler.go
index dacc96fa..034ead08 100644
--- a/pkg/protocol/engine/congestioncontrol/scheduler/drr/scheduler.go
+++ b/pkg/protocol/engine/congestioncontrol/scheduler/drr/scheduler.go
@@ -66,12 +66,18 @@ func NewProvider(opts ...options.Option[Scheduler]) module.Provider[*engine.Engi
 			})
 			e.Events.Notarization.LatestCommitmentUpdated.Hook(func(commitment *model.Commitment) {
 				// when the last slot of an epoch is committed, remove the queues of validators that are no longer in the committee.
-				if s.apiProvider.CommittedAPI().TimeProvider().SlotsBeforeNextEpoch(commitment.Slot()) == 0 {
+				if s.apiProvider.APIForSlot(commitment.Slot()).TimeProvider().SlotsBeforeNextEpoch(commitment.Slot()) == 0 {
 					s.bufferMutex.Lock()
 					defer s.bufferMutex.Unlock()
+					committee, exists := s.seatManager.CommitteeInSlot(commitment.Slot() + 1)
+					if !exists {
+						s.errorHandler(ierrors.Errorf("committee does not exist in committed slot %d", commitment.Slot()+1))
+
+						return
+					}
 
 					s.validatorBuffer.buffer.ForEach(func(accountID iotago.AccountID, validatorQueue *ValidatorQueue) bool {
-						if !s.seatManager.Committee(commitment.Slot() + 1).HasAccount(accountID) {
+						if !committee.HasAccount(accountID) {
 							s.shutdownValidatorQueue(validatorQueue)
 							s.validatorBuffer.Delete(accountID)
 						}
@@ -615,7 +621,7 @@ func (s *Scheduler) updateDeficit(accountID iotago.AccountID, delta Deficit) err
 func (s *Scheduler) incrementDeficit(issuerID iotago.AccountID, rounds Deficit, slot iotago.SlotIndex) error {
 	quantum, err := s.quantumFunc(issuerID, slot)
 	if err != nil {
-		return err
+		return ierrors.Wrap(err, "failed to retrieve quantum")
 	}
 
 	delta, err := safemath.SafeMul(quantum, rounds)
diff --git a/pkg/protocol/engine/consensus/blockgadget/thresholdblockgadget/gadget.go b/pkg/protocol/engine/consensus/blockgadget/thresholdblockgadget/gadget.go
index 55f53835..9ee2becd 100644
--- a/pkg/protocol/engine/consensus/blockgadget/thresholdblockgadget/gadget.go
+++ b/pkg/protocol/engine/consensus/blockgadget/thresholdblockgadget/gadget.go
@@ -5,6 +5,7 @@ import (
 
 	"github.com/iotaledger/hive.go/ds"
 	"github.com/iotaledger/hive.go/ds/walker"
+	"github.com/iotaledger/hive.go/ierrors"
 	"github.com/iotaledger/hive.go/runtime/event"
 	"github.com/iotaledger/hive.go/runtime/module"
 	"github.com/iotaledger/hive.go/runtime/options"
@@ -22,8 +23,9 @@ import (
 type Gadget struct {
 	events *blockgadget.Events
 
-	seatManager seatmanager.SeatManager
-	blockCache  *blocks.Blocks
+	seatManager  seatmanager.SeatManager
+	blockCache   *blocks.Blocks
+	errorHandler func(error)
 
 	optsAcceptanceThreshold               float64
 	optsConfirmationThreshold             float64
@@ -34,7 +36,7 @@ type Gadget struct {
 
 func NewProvider(opts ...options.Option[Gadget]) module.Provider[*engine.Engine, blockgadget.Gadget] {
 	return module.Provide(func(e *engine.Engine) blockgadget.Gadget {
-		g := New(e.BlockCache, e.SybilProtection.SeatManager(), opts...)
+		g := New(e.BlockCache, e.SybilProtection.SeatManager(), e.ErrorHandler("gadget"), opts...)
 
 		wp := e.Workers.CreatePool("ThresholdBlockGadget", workerpool.WithWorkerCount(1))
 		e.Events.Booker.BlockBooked.Hook(g.TrackWitnessWeight, event.WithWorkerPool(wp))
@@ -45,11 +47,12 @@ func NewProvider(opts ...options.Option[Gadget]) module.Provider[*engine.Engine,
 	})
 }
 
-func New(blockCache *blocks.Blocks, seatManager seatmanager.SeatManager, opts ...options.Option[Gadget]) *Gadget {
+func New(blockCache *blocks.Blocks, seatManager seatmanager.SeatManager, errorHandler func(error), opts ...options.Option[Gadget]) *Gadget {
 	return options.Apply(&Gadget{
-		events:      blockgadget.NewEvents(),
-		seatManager: seatManager,
-		blockCache:  blockCache,
+		events:       blockgadget.NewEvents(),
+		seatManager:  seatManager,
+		blockCache:   blockCache,
+		errorHandler: errorHandler,
 
 		optsAcceptanceThreshold:               0.67,
 		optsConfirmationThreshold:             0.67,
@@ -98,8 +101,15 @@ func (g *Gadget) isCommitteeValidationBlock(block *blocks.Block) (seat account.S
 		return 0, false
 	}
 
+	committee, exists := g.seatManager.CommitteeInSlot(block.ID().Slot())
+	if !exists {
+		g.errorHandler(ierrors.Errorf("committee for slot %d does not exist", block.ID().Slot()))
+
+		return 0, false
+	}
+
 	// Only accept blocks for issuers that are part of the committee.
-	return g.seatManager.Committee(block.ID().Slot()).GetSeat(block.ProtocolBlock().IssuerID)
+	return committee.GetSeat(block.ProtocolBlock().IssuerID)
 }
 
 func anyChildInSet(block *blocks.Block, set ds.Set[iotago.BlockID]) bool {
diff --git a/pkg/protocol/engine/engine.go b/pkg/protocol/engine/engine.go
index 6406d5b2..1bb299aa 100644
--- a/pkg/protocol/engine/engine.go
+++ b/pkg/protocol/engine/engine.go
@@ -407,7 +407,7 @@ func (e *Engine) acceptanceHandler() {
 
 	e.Events.BlockGadget.BlockAccepted.Hook(func(block *blocks.Block) {
 		e.Ledger.TrackBlock(block)
-		e.SybilProtection.TrackValidationBlock(block)
+		e.SybilProtection.TrackBlock(block)
 		e.UpgradeOrchestrator.TrackValidationBlock(block)
 
 		e.Events.AcceptedBlockProcessed.Trigger(block)
diff --git a/pkg/protocol/engine/filter/blockfilter/filter.go b/pkg/protocol/engine/filter/blockfilter/filter.go
index 6b20f66f..0ed6900e 100644
--- a/pkg/protocol/engine/filter/blockfilter/filter.go
+++ b/pkg/protocol/engine/filter/blockfilter/filter.go
@@ -29,7 +29,7 @@ type Filter struct {
 
 	optsMaxAllowedWallClockDrift time.Duration
 
-	committeeFunc func(iotago.SlotIndex) *account.SeatedAccounts
+	committeeFunc func(iotago.SlotIndex) (*account.SeatedAccounts, bool)
 
 	module.Module
 }
@@ -42,7 +42,7 @@ func NewProvider(opts ...options.Option[Filter]) module.Provider[*engine.Engine,
 		e.HookConstructed(func() {
 			e.Events.Filter.LinkTo(f.events)
 			e.SybilProtection.HookInitialized(func() {
-				f.committeeFunc = e.SybilProtection.SeatManager().Committee
+				f.committeeFunc = e.SybilProtection.SeatManager().CommitteeInSlot
 			})
 			f.TriggerInitialized()
 		})
@@ -92,7 +92,18 @@ func (f *Filter) ProcessReceivedBlock(block *model.Block, source peer.ID) {
 
 	if _, isValidation := block.ValidationBlock(); isValidation {
 		blockSlot := block.ProtocolBlock().API.TimeProvider().SlotFromTime(block.ProtocolBlock().IssuingTime)
-		if !f.committeeFunc(blockSlot).HasAccount(block.ProtocolBlock().IssuerID) {
+		committee, exists := f.committeeFunc(blockSlot)
+		if !exists {
+			f.events.BlockPreFiltered.Trigger(&filter.BlockPreFilteredEvent{
+				Block:  block,
+				Reason: ierrors.Wrapf(ErrValidatorNotInCommittee, "no committee for slot %d", blockSlot),
+				Source: source,
+			})
+
+			return
+		}
+
+		if !committee.HasAccount(block.ProtocolBlock().IssuerID) {
 			f.events.BlockPreFiltered.Trigger(&filter.BlockPreFilteredEvent{
 				Block:  block,
 				Reason: ierrors.Wrapf(ErrValidatorNotInCommittee, "validation block issuer %s is not part of the committee for slot %d", block.ProtocolBlock().IssuerID, blockSlot),
diff --git a/pkg/protocol/engine/ledger/ledger/ledger.go b/pkg/protocol/engine/ledger/ledger/ledger.go
index 30130774..a6c17720 100644
--- a/pkg/protocol/engine/ledger/ledger/ledger.go
+++ b/pkg/protocol/engine/ledger/ledger/ledger.go
@@ -748,7 +748,12 @@ func (l *Ledger) resolveState(stateRef mempool.StateReference) *promise.Promise[
 func (l *Ledger) blockPreAccepted(block *blocks.Block) {
 	voteRank := ledger.NewBlockVoteRank(block.ID(), block.ProtocolBlock().IssuingTime)
 
-	seat, exists := l.sybilProtection.SeatManager().Committee(block.ID().Slot()).GetSeat(block.ProtocolBlock().IssuerID)
+	committee, exists := l.sybilProtection.SeatManager().CommitteeInSlot(block.ID().Slot())
+	if !exists {
+		panic("committee should exist because we pre-accepted the block")
+	}
+
+	seat, exists := committee.GetSeat(block.ProtocolBlock().IssuerID)
 	if !exists {
 		return
 	}
diff --git a/pkg/protocol/engine/notarization/slotnotarization/manager.go b/pkg/protocol/engine/notarization/slotnotarization/manager.go
index 1555dc0d..217b3288 100644
--- a/pkg/protocol/engine/notarization/slotnotarization/manager.go
+++ b/pkg/protocol/engine/notarization/slotnotarization/manager.go
@@ -143,9 +143,7 @@ func (m *Manager) notarizeAcceptedBlock(block *blocks.Block) (err error) {
 		return ierrors.Wrap(err, "failed to add accepted block to slot mutations")
 	}
 
-	m.attestation.AddAttestationFromValidationBlock(block)
-
-	return
+	return m.attestation.AddAttestationFromValidationBlock(block)
 }
 
 func (m *Manager) tryCommitSlotUntil(acceptedBlockIndex iotago.SlotIndex) {
diff --git a/pkg/protocol/engine/upgrade/signalingupgradeorchestrator/orchestrator.go b/pkg/protocol/engine/upgrade/signalingupgradeorchestrator/orchestrator.go
index 48b7f0e4..4e5ee602 100644
--- a/pkg/protocol/engine/upgrade/signalingupgradeorchestrator/orchestrator.go
+++ b/pkg/protocol/engine/upgrade/signalingupgradeorchestrator/orchestrator.go
@@ -146,7 +146,11 @@ func (o *Orchestrator) TrackValidationBlock(block *blocks.Block) {
 	}
 	newSignaledBlock := model.NewSignaledBlock(block.ID(), block.ProtocolBlock(), validationBlock)
 
-	committee := o.seatManager.Committee(block.ID().Slot())
+	committee, exists := o.seatManager.CommitteeInSlot(block.ID().Slot())
+	if !exists {
+		return
+	}
+
 	seat, exists := committee.GetSeat(block.ProtocolBlock().IssuerID)
 	if !exists {
 		return
diff --git a/pkg/protocol/protocol_fork.go b/pkg/protocol/protocol_fork.go
index 88e50802..1425276c 100644
--- a/pkg/protocol/protocol_fork.go
+++ b/pkg/protocol/protocol_fork.go
@@ -194,7 +194,11 @@ func (p *Protocol) processFork(fork *chainmanager.Fork) (anchorBlockIDs iotago.B
 	ch := make(chan *commitmentVerificationResult)
 	defer close(ch)
 
-	commitmentVerifier := NewCommitmentVerifier(p.MainEngineInstance(), fork.MainChain.Commitment(fork.ForkingPoint.Slot()-1).Commitment())
+	commitmentVerifier, err := NewCommitmentVerifier(p.MainEngineInstance(), fork.MainChain.Commitment(fork.ForkingPoint.Slot()-1).Commitment())
+	if err != nil {
+		return nil, false, true, ierrors.Wrapf(err, "failed to create commitment verifier for %s", fork.MainChain.Commitment(fork.ForkingPoint.Slot()-1).ID())
+	}
+
 	verifyCommitmentFunc := func(commitment *model.Commitment, attestations []*iotago.Attestation, merkleProof *merklehasher.Proof[iotago.Identifier], _ peer.ID) {
 		blockIDs, actualCumulativeWeight, err := commitmentVerifier.verifyCommitment(commitment, attestations, merkleProof)
 
diff --git a/pkg/protocol/snapshotcreator/options.go b/pkg/protocol/snapshotcreator/options.go
index f1da75f4..74d986fe 100644
--- a/pkg/protocol/snapshotcreator/options.go
+++ b/pkg/protocol/snapshotcreator/options.go
@@ -6,6 +6,8 @@ import (
 	"github.com/iotaledger/iota-core/pkg/protocol/engine"
 	"github.com/iotaledger/iota-core/pkg/protocol/engine/ledger"
 	ledger1 "github.com/iotaledger/iota-core/pkg/protocol/engine/ledger/ledger"
+	"github.com/iotaledger/iota-core/pkg/protocol/sybilprotection/seatmanager"
+	"github.com/iotaledger/iota-core/pkg/protocol/sybilprotection/seatmanager/poa"
 	iotago "github.com/iotaledger/iota.go/v4"
 )
 
@@ -29,19 +31,27 @@ type Options struct {
 	// BasicOutput defines the basic outputs that are created in the ledger as part of the Genesis.
 	BasicOutputs []BasicOutputDetails
 
-	DataBaseVersion byte
-	LedgerProvider  func() module.Provider[*engine.Engine, ledger.Ledger]
+	DataBaseVersion     byte
+	LedgerProvider      module.Provider[*engine.Engine, ledger.Ledger]
+	SeatManagerProvider module.Provider[*engine.Engine, seatmanager.SeatManager]
 }
 
 func NewOptions(opts ...options.Option[Options]) *Options {
 	return options.Apply(&Options{
-		FilePath:        "snapshot.bin",
-		DataBaseVersion: 1,
-		LedgerProvider:  ledger1.NewProvider,
+		FilePath:            "snapshot.bin",
+		DataBaseVersion:     1,
+		LedgerProvider:      ledger1.NewProvider(),
+		SeatManagerProvider: poa.NewProvider(),
 	}, opts)
 }
 
-func WithLedgerProvider(ledgerProvider func() module.Provider[*engine.Engine, ledger.Ledger]) options.Option[Options] {
+func WithSeatManagerProvider(seatManagerProvider module.Provider[*engine.Engine, seatmanager.SeatManager]) options.Option[Options] {
+	return func(m *Options) {
+		m.SeatManagerProvider = seatManagerProvider
+	}
+}
+
+func WithLedgerProvider(ledgerProvider module.Provider[*engine.Engine, ledger.Ledger]) options.Option[Options] {
 	return func(m *Options) {
 		m.LedgerProvider = ledgerProvider
 	}
diff --git a/pkg/protocol/snapshotcreator/snapshotcreator.go b/pkg/protocol/snapshotcreator/snapshotcreator.go
index eae10d08..5223e008 100644
--- a/pkg/protocol/snapshotcreator/snapshotcreator.go
+++ b/pkg/protocol/snapshotcreator/snapshotcreator.go
@@ -9,9 +9,9 @@ import (
 	"github.com/iotaledger/hive.go/lo"
 	"github.com/iotaledger/hive.go/runtime/options"
 	"github.com/iotaledger/hive.go/runtime/workerpool"
-	"github.com/iotaledger/iota-core/pkg/core/account"
 	"github.com/iotaledger/iota-core/pkg/model"
 	"github.com/iotaledger/iota-core/pkg/protocol/engine"
+	"github.com/iotaledger/iota-core/pkg/protocol/engine/accounts"
 	"github.com/iotaledger/iota-core/pkg/protocol/engine/attestation/slotattestation"
 	"github.com/iotaledger/iota-core/pkg/protocol/engine/blockdag/inmemoryblockdag"
 	"github.com/iotaledger/iota-core/pkg/protocol/engine/booker/inmemorybooker"
@@ -70,20 +70,27 @@ func CreateSnapshot(opts ...options.Option[Options]) error {
 		return ierrors.Wrap(err, "failed to store empty commitment")
 	}
 
-	accounts := account.NewAccounts()
-	for _, accountData := range opt.Accounts {
+	committeeAccountsData := make(accounts.AccountsData, 0)
+	for _, snapshotAccountDetails := range opt.Accounts {
 		// Only add genesis validators if an account has both - StakedAmount and StakingEndEpoch - specified.
-		if accountData.StakedAmount > 0 && accountData.StakingEpochEnd > 0 {
-			blockIssuerKeyEd25519, ok := accountData.IssuerKey.(*iotago.Ed25519PublicKeyBlockIssuerKey)
+		if snapshotAccountDetails.StakedAmount > 0 && snapshotAccountDetails.StakingEpochEnd > 0 {
+			blockIssuerKeyEd25519, ok := snapshotAccountDetails.IssuerKey.(*iotago.Ed25519PublicKeyBlockIssuerKey)
 			if !ok {
 				panic("block issuer key must be of type ed25519")
 			}
 			ed25519PubKey := blockIssuerKeyEd25519.ToEd25519PublicKey()
 			accountID := blake2b.Sum256(ed25519PubKey[:])
-			accounts.Set(accountID, &account.Pool{
-				PoolStake:      accountData.StakedAmount,
-				ValidatorStake: accountData.StakedAmount,
-				FixedCost:      accountData.FixedCost,
+			committeeAccountsData = append(committeeAccountsData, &accounts.AccountData{
+				ID:                                    accountID,
+				Credits:                               &accounts.BlockIssuanceCredits{Value: snapshotAccountDetails.BlockIssuanceCredits, UpdateTime: 0},
+				ExpirySlot:                            snapshotAccountDetails.ExpirySlot,
+				OutputID:                              iotago.OutputID{},
+				BlockIssuerKeys:                       iotago.BlockIssuerKeys{snapshotAccountDetails.IssuerKey},
+				ValidatorStake:                        snapshotAccountDetails.StakedAmount,
+				DelegationStake:                       0,
+				FixedCost:                             snapshotAccountDetails.FixedCost,
+				StakeEndEpoch:                         snapshotAccountDetails.StakingEpochEnd,
+				LatestSupportedProtocolVersionAndHash: model.VersionAndHash{},
 			})
 		}
 	}
@@ -98,10 +105,11 @@ func CreateSnapshot(opts ...options.Option[Options]) error {
 		blocktime.NewProvider(),
 		thresholdblockgadget.NewProvider(),
 		totalweightslotgadget.NewProvider(),
-		sybilprotectionv1.NewProvider(sybilprotectionv1.WithInitialCommittee(accounts)),
+		sybilprotectionv1.NewProvider(sybilprotectionv1.WithInitialCommittee(committeeAccountsData),
+			sybilprotectionv1.WithSeatManagerProvider(opt.SeatManagerProvider)),
 		slotnotarization.NewProvider(),
 		slotattestation.NewProvider(),
-		opt.LedgerProvider(),
+		opt.LedgerProvider,
 		passthrough.NewProvider(),
 		tipmanagerv1.NewProvider(),
 		tipselectionv1.NewProvider(),
diff --git a/pkg/protocol/sybilprotection/activitytracker/activitytracker.go b/pkg/protocol/sybilprotection/activitytracker/activitytracker.go
new file mode 100644
index 00000000..07b95bb3
--- /dev/null
+++ b/pkg/protocol/sybilprotection/activitytracker/activitytracker.go
@@ -0,0 +1,14 @@
+package activitytracker
+
+import (
+	"time"
+
+	"github.com/iotaledger/hive.go/ds"
+	"github.com/iotaledger/iota-core/pkg/core/account"
+	iotago "github.com/iotaledger/iota.go/v4"
+)
+
+type ActivityTracker interface {
+	OnlineCommittee() ds.Set[account.SeatIndex]
+	MarkSeatActive(seat account.SeatIndex, id iotago.AccountID, seatActivityTime time.Time)
+}
diff --git a/pkg/protocol/sybilprotection/activitytracker/activitytrackerv1/activitytracker.go b/pkg/protocol/sybilprotection/activitytracker/activitytrackerv1/activitytracker.go
new file mode 100644
index 00000000..e167e2ff
--- /dev/null
+++ b/pkg/protocol/sybilprotection/activitytracker/activitytrackerv1/activitytracker.go
@@ -0,0 +1,83 @@
+package activitytrackerv1
+
+import (
+	"time"
+
+	"github.com/iotaledger/hive.go/ds"
+	"github.com/iotaledger/hive.go/ds/shrinkingmap"
+	"github.com/iotaledger/hive.go/runtime/syncutils"
+	"github.com/iotaledger/hive.go/runtime/timed"
+	"github.com/iotaledger/iota-core/pkg/core/account"
+	"github.com/iotaledger/iota-core/pkg/protocol/sybilprotection/activitytracker"
+	iotago "github.com/iotaledger/iota.go/v4"
+)
+
+// ActivityTracker is a sybil protection module for tracking activity of committee members.
+type ActivityTracker struct {
+	Events *activitytracker.Events
+
+	onlineCommittee  ds.Set[account.SeatIndex]
+	inactivityQueue  timed.PriorityQueue[account.SeatIndex]
+	lastActivities   *shrinkingmap.ShrinkingMap[account.SeatIndex, time.Time]
+	lastActivityTime time.Time
+	activityMutex    syncutils.RWMutex
+
+	activityWindow time.Duration
+}
+
+func NewActivityTracker(activityWindow time.Duration) *ActivityTracker {
+	return &ActivityTracker{
+		Events:          activitytracker.NewEvents(),
+		onlineCommittee: ds.NewSet[account.SeatIndex](),
+		inactivityQueue: timed.NewPriorityQueue[account.SeatIndex](true),
+		lastActivities:  shrinkingmap.New[account.SeatIndex, time.Time](),
+
+		activityWindow: activityWindow,
+	}
+}
+
+// OnlineCommittee returns the set of validators selected to be part of the committee that has been seen recently.
+func (a *ActivityTracker) OnlineCommittee() ds.Set[account.SeatIndex] {
+	a.activityMutex.RLock()
+	defer a.activityMutex.RUnlock()
+
+	return a.onlineCommittee
+}
+
+func (a *ActivityTracker) MarkSeatActive(seat account.SeatIndex, id iotago.AccountID, seatActivityTime time.Time) {
+	a.activityMutex.Lock()
+	defer a.activityMutex.Unlock()
+
+	if lastActivity, exists := a.lastActivities.Get(seat); (exists && lastActivity.After(seatActivityTime)) || seatActivityTime.Before(a.lastActivityTime.Add(-a.activityWindow)) {
+		return
+	} else if !exists {
+		a.onlineCommittee.Add(seat)
+		a.Events.OnlineCommitteeSeatAdded.Trigger(seat, id)
+	}
+
+	a.lastActivities.Set(seat, seatActivityTime)
+
+	a.inactivityQueue.Push(seat, seatActivityTime)
+
+	if seatActivityTime.Before(a.lastActivityTime) {
+		return
+	}
+
+	a.lastActivityTime = seatActivityTime
+
+	activityThreshold := seatActivityTime.Add(-a.activityWindow)
+	for _, inactiveSeat := range a.inactivityQueue.PopUntil(activityThreshold) {
+		if lastActivityForInactiveSeat, exists := a.lastActivities.Get(inactiveSeat); exists && lastActivityForInactiveSeat.After(activityThreshold) {
+			continue
+		}
+
+		a.markSeatInactive(inactiveSeat)
+	}
+}
+
+func (a *ActivityTracker) markSeatInactive(seat account.SeatIndex) {
+	a.lastActivities.Delete(seat)
+	a.onlineCommittee.Delete(seat)
+
+	a.Events.OnlineCommitteeSeatRemoved.Trigger(seat)
+}
diff --git a/pkg/protocol/sybilprotection/activitytracker/events.go b/pkg/protocol/sybilprotection/activitytracker/events.go
new file mode 100644
index 00000000..1c210f50
--- /dev/null
+++ b/pkg/protocol/sybilprotection/activitytracker/events.go
@@ -0,0 +1,22 @@
+package activitytracker
+
+import (
+	"github.com/iotaledger/hive.go/runtime/event"
+	"github.com/iotaledger/iota-core/pkg/core/account"
+	iotago "github.com/iotaledger/iota.go/v4"
+)
+
+type Events struct {
+	OnlineCommitteeSeatAdded   *event.Event2[account.SeatIndex, iotago.AccountID]
+	OnlineCommitteeSeatRemoved *event.Event1[account.SeatIndex]
+
+	event.Group[Events, *Events]
+}
+
+// NewEvents contains the constructor of the Events object (it is generated by a generic factory).
+var NewEvents = event.CreateGroupConstructor(func() (newEvents *Events) {
+	return &Events{
+		OnlineCommitteeSeatAdded:   event.New2[account.SeatIndex, iotago.AccountID](),
+		OnlineCommitteeSeatRemoved: event.New1[account.SeatIndex](),
+	}
+})
diff --git a/pkg/protocol/sybilprotection/seatmanager/mock/mockseatmanager.go b/pkg/protocol/sybilprotection/seatmanager/mock/mockseatmanager.go
index 223f212d..d8b53b4a 100644
--- a/pkg/protocol/sybilprotection/seatmanager/mock/mockseatmanager.go
+++ b/pkg/protocol/sybilprotection/seatmanager/mock/mockseatmanager.go
@@ -2,20 +2,27 @@ package mock
 
 import (
 	"fmt"
+	"time"
 
 	"github.com/iotaledger/hive.go/ds"
 	"github.com/iotaledger/hive.go/ds/shrinkingmap"
+	"github.com/iotaledger/hive.go/ierrors"
 	"github.com/iotaledger/hive.go/runtime/module"
 	"github.com/iotaledger/iota-core/pkg/core/account"
 	"github.com/iotaledger/iota-core/pkg/protocol/engine"
+	"github.com/iotaledger/iota-core/pkg/protocol/engine/accounts"
 	"github.com/iotaledger/iota-core/pkg/protocol/engine/blocks"
 	"github.com/iotaledger/iota-core/pkg/protocol/sybilprotection/seatmanager"
+	"github.com/iotaledger/iota-core/pkg/storage/prunable/epochstore"
 	iotago "github.com/iotaledger/iota.go/v4"
 	"github.com/iotaledger/iota.go/v4/tpkg"
 )
 
 type ManualPOA struct {
-	events    *seatmanager.Events
+	events         *seatmanager.Events
+	apiProvider    iotago.APIProvider
+	committeeStore *epochstore.Store[*account.Accounts]
+
 	accounts  *account.Accounts
 	committee *account.SeatedAccounts
 	online    ds.Set[account.SeatIndex]
@@ -24,12 +31,14 @@ type ManualPOA struct {
 	module.Module
 }
 
-func NewManualPOA() *ManualPOA {
+func NewManualPOA(e iotago.APIProvider, committeeStore *epochstore.Store[*account.Accounts]) *ManualPOA {
 	m := &ManualPOA{
-		events:   seatmanager.NewEvents(),
-		accounts: account.NewAccounts(),
-		online:   ds.NewSet[account.SeatIndex](),
-		aliases:  shrinkingmap.New[string, iotago.AccountID](),
+		events:         seatmanager.NewEvents(),
+		apiProvider:    e,
+		committeeStore: committeeStore,
+		accounts:       account.NewAccounts(),
+		online:         ds.NewSet[account.SeatIndex](),
+		aliases:        shrinkingmap.New[string, iotago.AccountID](),
 	}
 	m.committee = m.accounts.SelectCommittee()
 
@@ -38,7 +47,7 @@ func NewManualPOA() *ManualPOA {
 
 func NewManualPOAProvider() module.Provider[*engine.Engine, seatmanager.SeatManager] {
 	return module.Provide(func(e *engine.Engine) seatmanager.SeatManager {
-		poa := NewManualPOA()
+		poa := NewManualPOA(e, e.Storage.Committee())
 		e.Events.CommitmentFilter.BlockAllowed.Hook(func(block *blocks.Block) {
 			poa.events.BlockProcessed.Trigger(block)
 		})
@@ -52,17 +61,36 @@ func NewManualPOAProvider() module.Provider[*engine.Engine, seatmanager.SeatMana
 func (m *ManualPOA) AddRandomAccount(alias string) iotago.AccountID {
 	id := iotago.AccountID(tpkg.Rand32ByteArray())
 	id.RegisterAlias(alias)
-	m.accounts.Set(id, &account.Pool{}) // We don't care about pools with PoA
+	m.accounts.Set(id, &account.Pool{
+		PoolStake:      1,
+		ValidatorStake: 1,
+		FixedCost:      1,
+	}) // We don't care about pools with PoA, but need to set something to avoid division by zero errors.
+
 	m.aliases.Set(alias, id)
-	m.committee.Set(account.SeatIndex(m.committee.SeatCount()), id)
+
+	m.committee = m.accounts.SelectCommittee(m.accounts.IDs()...)
+
+	if err := m.committeeStore.Store(0, m.accounts); err != nil {
+		panic(err)
+	}
 
 	return id
 }
 
 func (m *ManualPOA) AddAccount(id iotago.AccountID, alias string) iotago.AccountID {
-	m.accounts.Set(id, &account.Pool{}) // We don't care about pools with PoA
+	m.accounts.Set(id, &account.Pool{
+		PoolStake:      1,
+		ValidatorStake: 1,
+		FixedCost:      1,
+	}) // We don't care about pools with PoA, but need to set something to avoid division by zero errors.
 	m.aliases.Set(alias, id)
-	m.committee.Set(account.SeatIndex(m.committee.SeatCount()), id)
+
+	m.committee = m.accounts.SelectCommittee(m.accounts.IDs()...)
+
+	if err := m.committeeStore.Store(0, m.accounts); err != nil {
+		panic(err)
+	}
 
 	return id
 }
@@ -100,8 +128,27 @@ func (m *ManualPOA) Accounts() *account.Accounts {
 	return m.accounts
 }
 
-func (m *ManualPOA) Committee(_ iotago.SlotIndex) *account.SeatedAccounts {
-	return m.committee
+// CommitteeInSlot returns the set of validators selected to be part of the committee in the given slot.
+func (m *ManualPOA) CommitteeInSlot(slot iotago.SlotIndex) (*account.SeatedAccounts, bool) {
+	return m.committeeInEpoch(m.apiProvider.APIForSlot(slot).TimeProvider().EpochFromSlot(slot))
+}
+
+// CommitteeInEpoch returns the set of validators selected to be part of the committee in the given epoch.
+func (m *ManualPOA) CommitteeInEpoch(epoch iotago.EpochIndex) (*account.SeatedAccounts, bool) {
+	return m.committeeInEpoch(epoch)
+}
+
+func (m *ManualPOA) committeeInEpoch(epoch iotago.EpochIndex) (*account.SeatedAccounts, bool) {
+	c, err := m.committeeStore.Load(epoch)
+	if err != nil {
+		panic(ierrors.Wrapf(err, "failed to load committee for epoch %d", epoch))
+	}
+
+	if c == nil {
+		return nil, false
+	}
+
+	return c.SelectCommittee(c.IDs()...), true
 }
 
 func (m *ManualPOA) OnlineCommittee() ds.Set[account.SeatIndex] {
@@ -112,16 +159,42 @@ func (m *ManualPOA) SeatCount() int {
 	return m.committee.SeatCount()
 }
 
-func (m *ManualPOA) RotateCommittee(_ iotago.EpochIndex, _ *account.Accounts) *account.SeatedAccounts {
-	return m.committee
+func (m *ManualPOA) RotateCommittee(epoch iotago.EpochIndex, validators accounts.AccountsData) (*account.SeatedAccounts, error) {
+	if m.committee == nil || m.accounts.Size() == 0 {
+		m.accounts = account.NewAccounts()
+
+		for _, validatorData := range validators {
+			m.accounts.Set(validatorData.ID, &account.Pool{
+				PoolStake:      validatorData.ValidatorStake + validatorData.DelegationStake,
+				ValidatorStake: validatorData.ValidatorStake,
+				FixedCost:      validatorData.FixedCost,
+			})
+		}
+		m.committee = m.accounts.SelectCommittee(m.accounts.IDs()...)
+	}
+
+	if err := m.committeeStore.Store(epoch, m.accounts); err != nil {
+		panic(err)
+	}
+
+	return m.committee, nil
 }
 
-func (m *ManualPOA) SetCommittee(_ iotago.EpochIndex, _ *account.Accounts) {
+func (m *ManualPOA) SetCommittee(epoch iotago.EpochIndex, validators *account.Accounts) error {
+	if m.committee == nil || m.accounts.Size() == 0 {
+		m.accounts = validators
+		m.committee = m.accounts.SelectCommittee(validators.IDs()...)
+	}
+
+	if err := m.committeeStore.Store(epoch, validators); err != nil {
+		panic(err)
+	}
+
+	return nil
 }
 
-func (m *ManualPOA) ImportCommittee(_ iotago.EpochIndex, validators *account.Accounts) {
-	m.accounts = validators
-	m.committee = m.accounts.SelectCommittee(validators.IDs()...)
+func (m *ManualPOA) InitializeCommittee(_ iotago.EpochIndex, _ time.Time) error {
+	return nil
 }
 
 func (m *ManualPOA) Shutdown() {}
diff --git a/pkg/protocol/sybilprotection/seatmanager/poa/poa.go b/pkg/protocol/sybilprotection/seatmanager/poa/poa.go
index 8d5de21d..8a0256e6 100644
--- a/pkg/protocol/sybilprotection/seatmanager/poa/poa.go
+++ b/pkg/protocol/sybilprotection/seatmanager/poa/poa.go
@@ -4,34 +4,31 @@ import (
 	"time"
 
 	"github.com/iotaledger/hive.go/ds"
-	"github.com/iotaledger/hive.go/ds/shrinkingmap"
+	"github.com/iotaledger/hive.go/ierrors"
 	"github.com/iotaledger/hive.go/runtime/module"
 	"github.com/iotaledger/hive.go/runtime/options"
 	"github.com/iotaledger/hive.go/runtime/syncutils"
-	"github.com/iotaledger/hive.go/runtime/timed"
-	"github.com/iotaledger/hive.go/runtime/workerpool"
 	"github.com/iotaledger/iota-core/pkg/core/account"
 	"github.com/iotaledger/iota-core/pkg/protocol/engine"
+	"github.com/iotaledger/iota-core/pkg/protocol/engine/accounts"
 	"github.com/iotaledger/iota-core/pkg/protocol/engine/blocks"
-	"github.com/iotaledger/iota-core/pkg/protocol/engine/clock"
+	"github.com/iotaledger/iota-core/pkg/protocol/sybilprotection/activitytracker"
+	"github.com/iotaledger/iota-core/pkg/protocol/sybilprotection/activitytracker/activitytrackerv1"
 	"github.com/iotaledger/iota-core/pkg/protocol/sybilprotection/seatmanager"
+	"github.com/iotaledger/iota-core/pkg/storage/prunable/epochstore"
 	iotago "github.com/iotaledger/iota.go/v4"
 )
 
 // SeatManager is a sybil protection module for the engine that manages the weights of actors according to their stake.
 type SeatManager struct {
-	events *seatmanager.Events
-
-	clock            clock.Clock
-	workers          *workerpool.Group
-	accounts         *account.Accounts
-	committee        *account.SeatedAccounts
-	onlineCommittee  ds.Set[account.SeatIndex]
-	inactivityQueue  timed.PriorityQueue[account.SeatIndex]
-	lastActivities   *shrinkingmap.ShrinkingMap[account.SeatIndex, time.Time]
-	lastActivityTime time.Time
-	activityMutex    syncutils.RWMutex
-	committeeMutex   syncutils.RWMutex
+	events      *seatmanager.Events
+	apiProvider iotago.APIProvider
+
+	committee       *account.SeatedAccounts
+	committeeStore  *epochstore.Store[*account.Accounts]
+	activityTracker activitytracker.ActivityTracker
+
+	committeeMutex syncutils.RWMutex
 
 	optsActivityWindow         time.Duration
 	optsOnlineCommitteeStartup []iotago.AccountID
@@ -39,34 +36,39 @@ type SeatManager struct {
 	module.Module
 }
 
-// NewProvider returns a new sybil protection provider that uses the ProofOfStake module.
+// NewProvider returns a new sybil protection provider that uses the ProofOfAuthority module.
 func NewProvider(opts ...options.Option[SeatManager]) module.Provider[*engine.Engine, seatmanager.SeatManager] {
 	return module.Provide(func(e *engine.Engine) seatmanager.SeatManager {
 		return options.Apply(
 			&SeatManager{
-				events:          seatmanager.NewEvents(),
-				workers:         e.Workers.CreateGroup("SeatManager"),
-				accounts:        account.NewAccounts(),
-				onlineCommittee: ds.NewSet[account.SeatIndex](),
-				inactivityQueue: timed.NewPriorityQueue[account.SeatIndex](true),
-				lastActivities:  shrinkingmap.New[account.SeatIndex, time.Time](),
+				events:         seatmanager.NewEvents(),
+				apiProvider:    e,
+				committeeStore: e.Storage.Committee(),
 
 				optsActivityWindow: time.Second * 30,
 			}, opts, func(s *SeatManager) {
+				activityTracker := activitytrackerv1.NewActivityTracker(s.optsActivityWindow)
+				s.activityTracker = activityTracker
+				s.events.OnlineCommitteeSeatAdded.LinkTo(activityTracker.Events.OnlineCommitteeSeatAdded)
+				s.events.OnlineCommitteeSeatRemoved.LinkTo(activityTracker.Events.OnlineCommitteeSeatRemoved)
+
 				e.Events.SeatManager.LinkTo(s.events)
 
 				e.HookConstructed(func() {
-					s.clock = e.Clock
-
 					s.TriggerConstructed()
 
 					// We need to mark validators as active upon solidity of blocks as otherwise we would not be able to
 					// recover if no node was part of the online committee anymore.
 					e.Events.CommitmentFilter.BlockAllowed.Hook(func(block *blocks.Block) {
 						// Only track identities that are part of the committee.
-						seat, exists := s.Committee(block.ID().Slot()).GetSeat(block.ProtocolBlock().IssuerID)
+						committee, exists := s.CommitteeInSlot(block.ID().Slot())
+						if !exists {
+							panic(ierrors.Errorf("committee not selected for slot %d, but received block in that slot", block.ID().Slot()))
+						}
+
+						seat, exists := committee.GetSeat(block.ProtocolBlock().IssuerID)
 						if exists {
-							s.markSeatActive(seat, block.ProtocolBlock().IssuerID, block.IssuingTime())
+							s.activityTracker.MarkSeatActive(seat, block.ProtocolBlock().IssuerID, block.IssuingTime())
 						}
 
 						s.events.BlockProcessed.Trigger(block)
@@ -78,29 +80,64 @@ func NewProvider(opts ...options.Option[SeatManager]) module.Provider[*engine.En
 
 var _ seatmanager.SeatManager = &SeatManager{}
 
-func (s *SeatManager) RotateCommittee(_ iotago.EpochIndex, _ *account.Accounts) *account.SeatedAccounts {
+func (s *SeatManager) RotateCommittee(epoch iotago.EpochIndex, validators accounts.AccountsData) (*account.SeatedAccounts, error) {
+	s.committeeMutex.RLock()
+	defer s.committeeMutex.RUnlock()
+
+	// if committee is not set, then set it according to passed validators (used for creating a snapshot)
+	if s.committee == nil {
+		committeeAccounts := account.NewAccounts()
+
+		for _, validatorData := range validators {
+			committeeAccounts.Set(validatorData.ID, &account.Pool{
+				PoolStake:      validatorData.ValidatorStake + validatorData.DelegationStake,
+				ValidatorStake: validatorData.ValidatorStake,
+				FixedCost:      validatorData.FixedCost,
+			})
+		}
+		s.committee = committeeAccounts.SelectCommittee(committeeAccounts.IDs()...)
+	}
+
+	err := s.committeeStore.Store(epoch, s.committee.Accounts())
+	if err != nil {
+		return nil, ierrors.Wrapf(err, "error while storing committee for epoch %d", epoch)
+	}
+
+	return s.committee, nil
+}
+
+// CommitteeInSlot returns the set of validators selected to be part of the committee in the given slot.
+func (s *SeatManager) CommitteeInSlot(slot iotago.SlotIndex) (*account.SeatedAccounts, bool) {
 	s.committeeMutex.RLock()
 	defer s.committeeMutex.RUnlock()
 
-	// we do nothing on PoA, we keep the same accounts and committee
-	return s.committee
+	return s.committeeInEpoch(s.apiProvider.APIForSlot(slot).TimeProvider().EpochFromSlot(slot))
 }
 
-// Committee returns the set of validators selected to be part of the committee.
-func (s *SeatManager) Committee(_ iotago.SlotIndex) *account.SeatedAccounts {
+// CommitteeInEpoch returns the set of validators selected to be part of the committee in the given epoch.
+func (s *SeatManager) CommitteeInEpoch(epoch iotago.EpochIndex) (*account.SeatedAccounts, bool) {
 	s.committeeMutex.RLock()
 	defer s.committeeMutex.RUnlock()
 
-	// Note: we have PoA so our committee do not rotate right now
-	return s.committee
+	return s.committeeInEpoch(epoch)
+}
+
+func (s *SeatManager) committeeInEpoch(epoch iotago.EpochIndex) (*account.SeatedAccounts, bool) {
+	c, err := s.committeeStore.Load(epoch)
+	if err != nil {
+		panic(ierrors.Wrapf(err, "failed to load committee for epoch %d", epoch))
+	}
+
+	if c == nil {
+		return nil, false
+	}
+
+	return c.SelectCommittee(c.IDs()...), true
 }
 
 // OnlineCommittee returns the set of validators selected to be part of the committee that has been seen recently.
 func (s *SeatManager) OnlineCommittee() ds.Set[account.SeatIndex] {
-	s.activityMutex.RLock()
-	defer s.activityMutex.RUnlock()
-
-	return s.onlineCommittee
+	return s.activityTracker.OnlineCommittee()
 }
 
 func (s *SeatManager) SeatCount() int {
@@ -112,76 +149,47 @@ func (s *SeatManager) SeatCount() int {
 
 func (s *SeatManager) Shutdown() {
 	s.TriggerStopped()
-	s.workers.Shutdown()
 }
 
-func (s *SeatManager) ImportCommittee(_ iotago.EpochIndex, validators *account.Accounts) {
+func (s *SeatManager) InitializeCommittee(epoch iotago.EpochIndex, activityTime time.Time) error {
 	s.committeeMutex.Lock()
 	defer s.committeeMutex.Unlock()
 
-	s.accounts = validators
-	s.committee = s.accounts.SelectCommittee(validators.IDs()...)
+	committeeAccounts, err := s.committeeStore.Load(epoch)
+	if err != nil {
+		return ierrors.Wrapf(err, "failed to load PoA committee for epoch %d", epoch)
+	}
+
+	s.committee = committeeAccounts.SelectCommittee(committeeAccounts.IDs()...)
 
-	onlineValidators := s.accounts.IDs()
+	onlineValidators := committeeAccounts.IDs()
 	if len(s.optsOnlineCommitteeStartup) > 0 {
 		onlineValidators = s.optsOnlineCommitteeStartup
 	}
 
 	for _, v := range onlineValidators {
-		activityTime := s.clock.Accepted().RelativeTime()
-
 		seat, exists := s.committee.GetSeat(v)
 		if !exists {
 			// Only track identities that are part of the committee.
-			return
+			continue
 		}
 
-		s.markSeatActive(seat, v, activityTime)
+		s.activityTracker.MarkSeatActive(seat, v, activityTime)
 	}
+
+	return nil
 }
 
-func (s *SeatManager) SetCommittee(_ iotago.EpochIndex, validators *account.Accounts) {
+func (s *SeatManager) SetCommittee(epoch iotago.EpochIndex, validators *account.Accounts) error {
 	s.committeeMutex.Lock()
 	defer s.committeeMutex.Unlock()
 
-	s.accounts = validators
-	s.committee = s.accounts.SelectCommittee(validators.IDs()...)
-}
-
-func (s *SeatManager) markSeatActive(seat account.SeatIndex, id iotago.AccountID, seatActivityTime time.Time) {
-	s.activityMutex.Lock()
-	defer s.activityMutex.Unlock()
-
-	if lastActivity, exists := s.lastActivities.Get(seat); (exists && lastActivity.After(seatActivityTime)) || seatActivityTime.Before(s.lastActivityTime.Add(-s.optsActivityWindow)) {
-		return
-	} else if !exists {
-		s.onlineCommittee.Add(seat)
-		s.events.OnlineCommitteeSeatAdded.Trigger(seat, id)
-	}
-
-	s.lastActivities.Set(seat, seatActivityTime)
-
-	s.inactivityQueue.Push(seat, seatActivityTime)
+	s.committee = validators.SelectCommittee(validators.IDs()...)
 
-	if seatActivityTime.Before(s.lastActivityTime) {
-		return
+	err := s.committeeStore.Store(epoch, s.committee.Accounts())
+	if err != nil {
+		return ierrors.Wrapf(err, "failed to set committee for epoch %d", epoch)
 	}
 
-	s.lastActivityTime = seatActivityTime
-
-	activityThreshold := seatActivityTime.Add(-s.optsActivityWindow)
-	for _, inactiveSeat := range s.inactivityQueue.PopUntil(activityThreshold) {
-		if lastActivityForInactiveSeat, exists := s.lastActivities.Get(inactiveSeat); exists && lastActivityForInactiveSeat.After(activityThreshold) {
-			continue
-		}
-
-		s.markSeatInactive(inactiveSeat)
-	}
-}
-
-func (s *SeatManager) markSeatInactive(seat account.SeatIndex) {
-	s.lastActivities.Delete(seat)
-	s.onlineCommittee.Delete(seat)
-
-	s.events.OnlineCommitteeSeatRemoved.Trigger(seat)
+	return nil
 }
diff --git a/pkg/protocol/sybilprotection/seatmanager/seatmanager.go b/pkg/protocol/sybilprotection/seatmanager/seatmanager.go
index 32f28256..fe87322c 100644
--- a/pkg/protocol/sybilprotection/seatmanager/seatmanager.go
+++ b/pkg/protocol/sybilprotection/seatmanager/seatmanager.go
@@ -1,27 +1,33 @@
 package seatmanager
 
 import (
+	"time"
+
 	"github.com/iotaledger/hive.go/ds"
 	"github.com/iotaledger/hive.go/runtime/module"
 	"github.com/iotaledger/iota-core/pkg/core/account"
+	"github.com/iotaledger/iota-core/pkg/protocol/engine/accounts"
 	iotago "github.com/iotaledger/iota.go/v4"
 )
 
 // SeatManager is the minimal interface for the SeatManager component of the IOTA protocol.
 type SeatManager interface {
 	// RotateCommittee rotates the committee evaluating the given set of candidates to produce the new committee.
-	RotateCommittee(epoch iotago.EpochIndex, candidates *account.Accounts) *account.SeatedAccounts
+	RotateCommittee(epoch iotago.EpochIndex, candidates accounts.AccountsData) (*account.SeatedAccounts, error)
 
 	// SetCommittee sets the committee for a given slot.
 	// This is used when re-using the same committee for consecutive epochs.
-	SetCommittee(epoch iotago.EpochIndex, committee *account.Accounts)
+	SetCommittee(epoch iotago.EpochIndex, committee *account.Accounts) error
 
-	// ImportCommittee sets the committee for a given slot and marks the whole committee as active.
+	// InitializeCommittee initializes the committee for the current slot by marking whole or a subset of the committee as active.
 	// This is used when initializing committee after node startup (loaded from snapshot or database).
-	ImportCommittee(epoch iotago.EpochIndex, committee *account.Accounts)
+	InitializeCommittee(epoch iotago.EpochIndex, activityTime time.Time) error
+
+	// CommitteeInSlot returns the set of validators that is used to track confirmation at a given slot.
+	CommitteeInSlot(slot iotago.SlotIndex) (*account.SeatedAccounts, bool)
 
-	// Committee returns the set of validators that is used to track confirmation.
-	Committee(slot iotago.SlotIndex) *account.SeatedAccounts
+	// CommitteeInEpoch returns the set of validators that is used to track confirmation in a given epoch.
+	CommitteeInEpoch(epoch iotago.EpochIndex) (*account.SeatedAccounts, bool)
 
 	// OnlineCommittee returns the set of online validators that is used to track acceptance.
 	OnlineCommittee() ds.Set[account.SeatIndex]
diff --git a/pkg/protocol/sybilprotection/seatmanager/topstakers/options.go b/pkg/protocol/sybilprotection/seatmanager/topstakers/options.go
new file mode 100644
index 00000000..4f190f18
--- /dev/null
+++ b/pkg/protocol/sybilprotection/seatmanager/topstakers/options.go
@@ -0,0 +1,27 @@
+package topstakers
+
+import (
+	"time"
+
+	"github.com/iotaledger/hive.go/runtime/options"
+	iotago "github.com/iotaledger/iota.go/v4"
+)
+
+// WithActivityWindow sets the duration for which a validator is recognized as active after issuing a block.
+func WithActivityWindow(activityWindow time.Duration) options.Option[SeatManager] {
+	return func(p *SeatManager) {
+		p.optsActivityWindow = activityWindow
+	}
+}
+
+func WithOnlineCommitteeStartup(optsOnlineCommittee ...iotago.AccountID) options.Option[SeatManager] {
+	return func(p *SeatManager) {
+		p.optsOnlineCommitteeStartup = optsOnlineCommittee
+	}
+}
+
+func WithSeatCount(optsSeatCount uint32) options.Option[SeatManager] {
+	return func(p *SeatManager) {
+		p.optsSeatCount = optsSeatCount
+	}
+}
diff --git a/pkg/protocol/sybilprotection/seatmanager/topstakers/topstakers.go b/pkg/protocol/sybilprotection/seatmanager/topstakers/topstakers.go
new file mode 100644
index 00000000..5d85f85b
--- /dev/null
+++ b/pkg/protocol/sybilprotection/seatmanager/topstakers/topstakers.go
@@ -0,0 +1,245 @@
+package topstakers
+
+import (
+	"bytes"
+	"sort"
+	"time"
+
+	"github.com/iotaledger/hive.go/ds"
+	"github.com/iotaledger/hive.go/ierrors"
+	"github.com/iotaledger/hive.go/runtime/module"
+	"github.com/iotaledger/hive.go/runtime/options"
+	"github.com/iotaledger/hive.go/runtime/syncutils"
+	"github.com/iotaledger/iota-core/pkg/core/account"
+	"github.com/iotaledger/iota-core/pkg/protocol/engine"
+	"github.com/iotaledger/iota-core/pkg/protocol/engine/accounts"
+	"github.com/iotaledger/iota-core/pkg/protocol/engine/blocks"
+	"github.com/iotaledger/iota-core/pkg/protocol/sybilprotection/activitytracker"
+	"github.com/iotaledger/iota-core/pkg/protocol/sybilprotection/activitytracker/activitytrackerv1"
+	"github.com/iotaledger/iota-core/pkg/protocol/sybilprotection/seatmanager"
+	"github.com/iotaledger/iota-core/pkg/storage/prunable/epochstore"
+	iotago "github.com/iotaledger/iota.go/v4"
+)
+
+// SeatManager is a sybil protection module for the engine that manages the weights of actors according to their stake.
+type SeatManager struct {
+	apiProvider iotago.APIProvider
+	events      *seatmanager.Events
+
+	committeeStore  *epochstore.Store[*account.Accounts]
+	committeeMutex  syncutils.RWMutex
+	activityTracker activitytracker.ActivityTracker
+
+	optsSeatCount              uint32
+	optsActivityWindow         time.Duration
+	optsOnlineCommitteeStartup []iotago.AccountID
+
+	module.Module
+}
+
+// NewProvider returns a new sybil protection provider that uses the ProofOfStake module.
+func NewProvider(opts ...options.Option[SeatManager]) module.Provider[*engine.Engine, seatmanager.SeatManager] {
+	return module.Provide(func(e *engine.Engine) seatmanager.SeatManager {
+		return options.Apply(
+			&SeatManager{
+				apiProvider:    e,
+				events:         seatmanager.NewEvents(),
+				committeeStore: e.Storage.Committee(),
+
+				optsActivityWindow: time.Second * 30,
+			}, opts, func(s *SeatManager) {
+				activityTracker := activitytrackerv1.NewActivityTracker(s.optsActivityWindow)
+				s.activityTracker = activityTracker
+				s.events.OnlineCommitteeSeatAdded.LinkTo(activityTracker.Events.OnlineCommitteeSeatAdded)
+				s.events.OnlineCommitteeSeatRemoved.LinkTo(activityTracker.Events.OnlineCommitteeSeatRemoved)
+
+				e.Events.SeatManager.LinkTo(s.events)
+
+				e.HookConstructed(func() {
+					s.TriggerConstructed()
+
+					// We need to mark validators as active upon solidity of blocks as otherwise we would not be able to
+					// recover if no node was part of the online committee anymore.
+					e.Events.CommitmentFilter.BlockAllowed.Hook(func(block *blocks.Block) {
+						// Only track identities that are part of the committee.
+						committee, exists := s.CommitteeInSlot(block.ID().Slot())
+						if !exists {
+							panic(ierrors.Errorf("committee not selected for slot %d, but received block in that slot", block.ID().Slot()))
+						}
+
+						seat, exists := committee.GetSeat(block.ProtocolBlock().IssuerID)
+						if exists {
+							s.activityTracker.MarkSeatActive(seat, block.ProtocolBlock().IssuerID, block.IssuingTime())
+						}
+
+						s.events.BlockProcessed.Trigger(block)
+					})
+				})
+			})
+	})
+}
+
+var _ seatmanager.SeatManager = &SeatManager{}
+
+func (s *SeatManager) RotateCommittee(epoch iotago.EpochIndex, candidates accounts.AccountsData) (*account.SeatedAccounts, error) {
+	s.committeeMutex.Lock()
+	defer s.committeeMutex.Unlock()
+
+	// If there are fewer candidates than required for epoch 0, then the previous committee cannot be copied.
+	if len(candidates) < s.SeatCount() && epoch == 0 {
+		return nil, ierrors.Errorf("at least %d candidates are required for committee in epoch 0, got %d", s.SeatCount(), len(candidates))
+	}
+
+	// If there are fewer candidates than required, then re-use the previous committee.
+	if len(candidates) < s.SeatCount() {
+		// TODO: what if staking period of a committee member ends in the next epoch?
+		committee, exists := s.committeeInEpoch(epoch - 1)
+		if !exists {
+			return nil, ierrors.Errorf("cannot re-use previous committee from epoch %d as it does not exist", epoch-1)
+		}
+
+		err := s.committeeStore.Store(epoch, committee.Accounts())
+		if err != nil {
+			return nil, ierrors.Wrapf(err, "error while storing committee for epoch %d", epoch)
+		}
+
+		return committee, nil
+	}
+
+	committee := s.selectNewCommittee(candidates)
+
+	err := s.committeeStore.Store(epoch, committee.Accounts())
+	if err != nil {
+		return nil, ierrors.Wrapf(err, "error while storing committee for epoch %d", epoch)
+	}
+
+	return committee, nil
+}
+
+// CommitteeInSlot returns the set of validators selected to be part of the committee in the given slot.
+func (s *SeatManager) CommitteeInSlot(slot iotago.SlotIndex) (*account.SeatedAccounts, bool) {
+	s.committeeMutex.RLock()
+	defer s.committeeMutex.RUnlock()
+
+	return s.committeeInEpoch(s.apiProvider.APIForSlot(slot).TimeProvider().EpochFromSlot(slot))
+}
+
+// CommitteeInEpoch returns the set of validators selected to be part of the committee in the given epoch.
+func (s *SeatManager) CommitteeInEpoch(epoch iotago.EpochIndex) (*account.SeatedAccounts, bool) {
+	s.committeeMutex.RLock()
+	defer s.committeeMutex.RUnlock()
+
+	return s.committeeInEpoch(epoch)
+}
+
+func (s *SeatManager) committeeInEpoch(epoch iotago.EpochIndex) (*account.SeatedAccounts, bool) {
+	c, err := s.committeeStore.Load(epoch)
+	if err != nil {
+		panic(ierrors.Wrapf(err, "failed to load committee for epoch %d", epoch))
+	}
+
+	if c == nil {
+		return nil, false
+	}
+
+	return c.SelectCommittee(c.IDs()...), true
+}
+
+// OnlineCommittee returns the set of validators selected to be part of the committee that has been seen recently.
+func (s *SeatManager) OnlineCommittee() ds.Set[account.SeatIndex] {
+	return s.activityTracker.OnlineCommittee()
+}
+
+func (s *SeatManager) SeatCount() int {
+	return int(s.optsSeatCount)
+}
+
+func (s *SeatManager) Shutdown() {
+	s.TriggerStopped()
+}
+
+func (s *SeatManager) InitializeCommittee(epoch iotago.EpochIndex, activityTime time.Time) error {
+	s.committeeMutex.Lock()
+	defer s.committeeMutex.Unlock()
+
+	committeeAccounts, err := s.committeeStore.Load(epoch)
+	if err != nil {
+		return ierrors.Wrapf(err, "failed to load PoA committee for epoch %d", epoch)
+	}
+
+	committee := committeeAccounts.SelectCommittee(committeeAccounts.IDs()...)
+
+	onlineValidators := committeeAccounts.IDs()
+	if len(s.optsOnlineCommitteeStartup) > 0 {
+		onlineValidators = s.optsOnlineCommitteeStartup
+	}
+
+	for _, v := range onlineValidators {
+		seat, exists := committee.GetSeat(v)
+		if !exists {
+			// Only track identities that are part of the committee.
+			continue
+		}
+
+		s.activityTracker.MarkSeatActive(seat, v, activityTime)
+	}
+
+	return nil
+}
+
+func (s *SeatManager) SetCommittee(epoch iotago.EpochIndex, validators *account.Accounts) error {
+	s.committeeMutex.Lock()
+	defer s.committeeMutex.Unlock()
+
+	if validators.Size() != int(s.optsSeatCount) {
+		return ierrors.Errorf("invalid number of validators: %d, expected: %d", validators.Size(), s.optsSeatCount)
+	}
+
+	err := s.committeeStore.Store(epoch, validators)
+	if err != nil {
+		return ierrors.Wrapf(err, "failed to set committee for epoch %d", epoch)
+	}
+
+	return nil
+}
+
+func (s *SeatManager) selectNewCommittee(candidates accounts.AccountsData) *account.SeatedAccounts {
+	sort.Slice(candidates, func(i, j int) bool {
+		// Prioritize the candidate that has a larger pool stake.
+		if candidates[i].ValidatorStake+candidates[i].DelegationStake != candidates[j].ValidatorStake+candidates[j].DelegationStake {
+			return candidates[i].ValidatorStake+candidates[i].DelegationStake > candidates[j].ValidatorStake+candidates[j].DelegationStake
+		}
+
+		// Prioritize the candidate that has a larger validator stake.
+		if candidates[i].ValidatorStake != candidates[j].ValidatorStake {
+			return candidates[i].ValidatorStake > candidates[j].ValidatorStake
+		}
+
+		// Prioritize the candidate that declares a longer staking period.
+		if candidates[i].StakeEndEpoch != candidates[j].StakeEndEpoch {
+			return candidates[i].StakeEndEpoch > candidates[j].StakeEndEpoch
+		}
+
+		// Prioritize the candidate that has smaller FixedCost.
+		if candidates[i].FixedCost != candidates[j].FixedCost {
+			return candidates[i].FixedCost < candidates[j].FixedCost
+		}
+
+		// two candidates never have the same account ID because they come in a map
+		return bytes.Compare(candidates[i].ID[:], candidates[j].ID[:]) > 0
+	})
+
+	// Create new Accounts instance that only included validators selected to be part of the committee.
+	newCommitteeAccounts := account.NewAccounts()
+
+	for _, candidateData := range candidates[:s.optsSeatCount] {
+		newCommitteeAccounts.Set(candidateData.ID, &account.Pool{
+			PoolStake:      candidateData.ValidatorStake + candidateData.DelegationStake,
+			ValidatorStake: candidateData.ValidatorStake,
+			FixedCost:      candidateData.FixedCost,
+		})
+	}
+	committee := newCommitteeAccounts.SelectCommittee(newCommitteeAccounts.IDs()...)
+
+	return committee
+}
diff --git a/pkg/protocol/sybilprotection/sybilprotection.go b/pkg/protocol/sybilprotection/sybilprotection.go
index 4935e457..74db9aa0 100644
--- a/pkg/protocol/sybilprotection/sybilprotection.go
+++ b/pkg/protocol/sybilprotection/sybilprotection.go
@@ -12,10 +12,10 @@ import (
 )
 
 type SybilProtection interface {
-	TrackValidationBlock(block *blocks.Block)
+	TrackBlock(block *blocks.Block)
 	EligibleValidators(epoch iotago.EpochIndex) (accounts.AccountsData, error)
 	OrderedRegisteredCandidateValidatorsList(epoch iotago.EpochIndex) ([]*apimodels.ValidatorResponse, error)
-	IsCandidateActive(validatorID iotago.AccountID, epoch iotago.EpochIndex) bool
+	IsCandidateActive(validatorID iotago.AccountID, epoch iotago.EpochIndex) (bool, error)
 	// ValidatorReward returns the amount of mana that a validator has earned in a given epoch range.
 	// The actual used epoch range is returned, only until usedEnd the decay was applied.
 	ValidatorReward(validatorID iotago.AccountID, stakeAmount iotago.BaseToken, epochStart, epochEnd iotago.EpochIndex) (validatorReward iotago.Mana, decayedStart, decayedEnd iotago.EpochIndex, err error)
diff --git a/pkg/protocol/sybilprotection/sybilprotectionv1/performance/performance.go b/pkg/protocol/sybilprotection/sybilprotectionv1/performance/performance.go
index 0a246846..37f061d5 100644
--- a/pkg/protocol/sybilprotection/sybilprotectionv1/performance/performance.go
+++ b/pkg/protocol/sybilprotection/sybilprotectionv1/performance/performance.go
@@ -5,9 +5,9 @@ import (
 	"time"
 
 	"github.com/iotaledger/hive.go/ds"
+	"github.com/iotaledger/hive.go/ds/shrinkingmap"
 	"github.com/iotaledger/hive.go/ierrors"
 	"github.com/iotaledger/hive.go/kvstore"
-	"github.com/iotaledger/hive.go/lo"
 	"github.com/iotaledger/hive.go/runtime/syncutils"
 	"github.com/iotaledger/iota-core/pkg/core/account"
 	"github.com/iotaledger/iota-core/pkg/model"
@@ -18,12 +18,13 @@ import (
 )
 
 type Tracker struct {
-	rewardsStorePerEpochFunc func(epoch iotago.EpochIndex) (kvstore.KVStore, error)
-	poolStatsStore           *epochstore.Store[*model.PoolsStats]
-	committeeStore           *epochstore.Store[*account.Accounts]
-
-	validatorPerformancesFunc func(slot iotago.SlotIndex) (*slotstore.Store[iotago.AccountID, *model.ValidatorPerformance], error)
-	latestAppliedEpoch        iotago.EpochIndex
+	rewardsStorePerEpochFunc       func(epoch iotago.EpochIndex) (kvstore.KVStore, error)
+	poolStatsStore                 *epochstore.Store[*model.PoolsStats]
+	committeeStore                 *epochstore.Store[*account.Accounts]
+	committeeCandidatesInEpochFunc func(epoch iotago.EpochIndex) (kvstore.KVStore, error)
+	nextEpochCommitteeCandidates   *shrinkingmap.ShrinkingMap[iotago.AccountID, iotago.SlotIndex]
+	validatorPerformancesFunc      func(slot iotago.SlotIndex) (*slotstore.Store[iotago.AccountID, *model.ValidatorPerformance], error)
+	latestAppliedEpoch             iotago.EpochIndex
 
 	apiProvider iotago.APIProvider
 
@@ -33,44 +34,39 @@ type Tracker struct {
 	mutex                   syncutils.RWMutex
 }
 
-func NewTracker(
-	rewardsStorePerEpochFunc func(epoch iotago.EpochIndex) (kvstore.KVStore, error),
-	poolStatsStore *epochstore.Store[*model.PoolsStats],
-	committeeStore *epochstore.Store[*account.Accounts],
-	validatorPerformancesFunc func(slot iotago.SlotIndex) (*slotstore.Store[iotago.AccountID, *model.ValidatorPerformance], error),
-	latestAppliedEpoch iotago.EpochIndex,
-	apiProvider iotago.APIProvider,
-	errHandler func(error),
-) *Tracker {
+func NewTracker(rewardsStorePerEpochFunc func(epoch iotago.EpochIndex) (kvstore.KVStore, error), poolStatsStore *epochstore.Store[*model.PoolsStats], committeeStore *epochstore.Store[*account.Accounts], committeeCandidatesInEpochFunc func(epoch iotago.EpochIndex) (kvstore.KVStore, error), validatorPerformancesFunc func(slot iotago.SlotIndex) (*slotstore.Store[iotago.AccountID, *model.ValidatorPerformance], error), latestAppliedEpoch iotago.EpochIndex, apiProvider iotago.APIProvider, errHandler func(error)) *Tracker {
 	return &Tracker{
-		rewardsStorePerEpochFunc:  rewardsStorePerEpochFunc,
-		poolStatsStore:            poolStatsStore,
-		committeeStore:            committeeStore,
-		validatorPerformancesFunc: validatorPerformancesFunc,
-		latestAppliedEpoch:        latestAppliedEpoch,
-		apiProvider:               apiProvider,
-		errHandler:                errHandler,
+		nextEpochCommitteeCandidates:   shrinkingmap.New[iotago.AccountID, iotago.SlotIndex](),
+		rewardsStorePerEpochFunc:       rewardsStorePerEpochFunc,
+		poolStatsStore:                 poolStatsStore,
+		committeeStore:                 committeeStore,
+		committeeCandidatesInEpochFunc: committeeCandidatesInEpochFunc,
+		validatorPerformancesFunc:      validatorPerformancesFunc,
+		latestAppliedEpoch:             latestAppliedEpoch,
+		apiProvider:                    apiProvider,
+		errHandler:                     errHandler,
 	}
 }
 
-func (t *Tracker) RegisterCommittee(epoch iotago.EpochIndex, committee *account.Accounts) error {
-	return t.committeeStore.Store(epoch, committee)
+func (t *Tracker) ClearCandidates() {
+	// clean the candidate cache stored in memory to make room for candidates in the next epoch
+	t.nextEpochCommitteeCandidates.Clear()
 }
 
 func (t *Tracker) TrackValidationBlock(block *blocks.Block) {
+	t.mutex.Lock()
+	defer t.mutex.Unlock()
+
 	validatorBlock, isValidationBlock := block.ValidationBlock()
 	if !isValidationBlock {
 		return
 	}
 
-	t.mutex.Lock()
-	defer t.mutex.Unlock()
-
 	t.performanceFactorsMutex.Lock()
 	defer t.performanceFactorsMutex.Unlock()
 	isCommitteeMember, err := t.isCommitteeMember(block.ID().Slot(), block.ProtocolBlock().IssuerID)
 	if err != nil {
-		t.errHandler(ierrors.Errorf("failed to check if account %s is committee member", block.ProtocolBlock().IssuerID))
+		t.errHandler(ierrors.Wrapf(err, "error while checking if account %s is a committee member in slot %d", block.ProtocolBlock().IssuerID, block.ID().Slot()))
 
 		return
 	}
@@ -80,35 +76,103 @@ func (t *Tracker) TrackValidationBlock(block *blocks.Block) {
 	}
 }
 
-func (t *Tracker) EligibleValidatorCandidates(epoch iotago.EpochIndex) ds.Set[iotago.AccountID] {
-	// TODO: to be implemented for 1.1, for now we just pick previous committee
+func (t *Tracker) TrackCandidateBlock(block *blocks.Block) {
+	t.mutex.Lock()
+	defer t.mutex.Unlock()
 
-	eligible := ds.NewSet[iotago.AccountID]()
+	blockEpoch := t.apiProvider.APIForSlot(block.ID().Slot()).TimeProvider().EpochFromSlot(block.ID().Slot())
 
-	lo.PanicOnErr(t.committeeStore.Load(epoch - 1)).ForEach(func(accountID iotago.AccountID, _ *account.Pool) bool {
-		eligible.Add(accountID)
+	if block.Payload().PayloadType() != iotago.PayloadCandidacyAnnouncement {
+		return
+	}
 
-		return true
+	var rollback bool
+	t.nextEpochCommitteeCandidates.Compute(block.ProtocolBlock().IssuerID, func(currentValue iotago.SlotIndex, exists bool) iotago.SlotIndex {
+		if !exists || currentValue > block.ID().Slot() {
+			committeeCandidatesStore, err := t.committeeCandidatesInEpochFunc(blockEpoch)
+			if err != nil {
+				// if there is an error, and we don't register a candidate, then we might eventually create a different commitment
+				t.errHandler(ierrors.Wrapf(err, "error while retrieving candidate storage for epoch %d", blockEpoch))
+
+				// rollback on error if entry did not exist before
+				rollback = !exists
+
+				return currentValue
+			}
+
+			err = committeeCandidatesStore.Set(block.ProtocolBlock().IssuerID[:], block.ID().Slot().MustBytes())
+			if err != nil {
+				// if there is an error, and we don't register a candidate, then we might eventually create a different commitment
+				t.errHandler(ierrors.Wrapf(err, "error while updating candidate activity for epoch %d", blockEpoch))
+
+				// rollback on error if entry did not exist before
+				rollback = !exists
+
+				return currentValue
+			}
+
+			return block.ID().Slot()
+		}
+
+		return currentValue
 	})
 
-	return eligible
-
-	// epochStart := t.apiProvider.APIForEpoch(epoch).TimeProvider().EpochStart(epoch)
-	// registeredStore := t.registeredValidatorsFunc(epochStart)
-	// eligible := ds.NewSet[iotago.AccountID]()
-	// registeredStore.ForEach(func(accountID iotago.AccountID, a *prunable.RegisteredValidatorActivity) bool {
-	//	if a.Active {
-	//		eligible.Add(accountID)
-	//	}
-	//	return true
-	// }
+	// if there was an error when computing the value,
+	// and it was the first entry for the given issuer, then remove the entry
+	if rollback {
+		t.nextEpochCommitteeCandidates.Delete(block.ProtocolBlock().IssuerID)
+	}
+
+}
+
+func (t *Tracker) EligibleValidatorCandidates(epoch iotago.EpochIndex) (ds.Set[iotago.AccountID], error) {
+	t.mutex.RLock()
+	defer t.mutex.RUnlock()
+
+	return t.getValidatorCandidates(epoch)
 }
 
 // ValidatorCandidates returns the registered validator candidates for the given epoch.
-func (t *Tracker) ValidatorCandidates(_ iotago.EpochIndex) ds.Set[iotago.AccountID] {
-	// TODO: we should choose candidates we tracked performance for no matter if they were active
+func (t *Tracker) ValidatorCandidates(epoch iotago.EpochIndex) (ds.Set[iotago.AccountID], error) {
+	t.mutex.RLock()
+	defer t.mutex.RUnlock()
 
-	return ds.NewSet[iotago.AccountID]()
+	return t.getValidatorCandidates(epoch)
+}
+
+func (t *Tracker) getValidatorCandidates(epoch iotago.EpochIndex) (ds.Set[iotago.AccountID], error) {
+	// we store candidates in the store for the epoch of their activity, but the passed argument points to the target epoch,
+	// so it's necessary to subtract one epoch from the passed value
+	candidateStore, err := t.committeeCandidatesInEpochFunc(epoch - 1)
+	if err != nil {
+		return nil, ierrors.Wrapf(err, "error while retrieving candidates for epoch %d", epoch)
+	}
+
+	candidates := ds.NewSet[iotago.AccountID]()
+
+	var innerErr error
+	err = candidateStore.IterateKeys(kvstore.EmptyPrefix, func(key kvstore.Key) bool {
+		accountID, _, err := iotago.AccountIDFromBytes(key)
+		if err != nil {
+			innerErr = err
+
+			return false
+		}
+
+		candidates.Add(accountID)
+
+		return true
+	})
+
+	if innerErr != nil {
+		return nil, ierrors.Wrapf(innerErr, "error while iterating through candidates for epoch %d", epoch)
+	}
+
+	if err != nil {
+		return nil, ierrors.Wrapf(err, "error while retrieving candidates for epoch %d", epoch)
+	}
+
+	return candidates, nil
 }
 
 func (t *Tracker) LoadCommitteeForEpoch(epoch iotago.EpochIndex) (committee *account.Accounts, exists bool) {
@@ -116,6 +180,7 @@ func (t *Tracker) LoadCommitteeForEpoch(epoch iotago.EpochIndex) (committee *acc
 	if err != nil {
 		panic(ierrors.Wrapf(err, "failed to load committee for epoch %d", epoch))
 	}
+
 	if c == nil {
 		return nil, false
 	}
@@ -259,25 +324,31 @@ func (t *Tracker) trackCommitteeMemberPerformance(validationBlock *iotago.Valida
 	validatorPerformance, err := validatorPerformances.Load(block.ProtocolBlock().IssuerID)
 	if err != nil {
 		t.errHandler(ierrors.Errorf("failed to load performance factor for account %s", block.ProtocolBlock().IssuerID))
+
+		return
 	}
+
 	// key not found
 	if validatorPerformance == nil {
 		validatorPerformance = model.NewValidatorPerformance()
 	}
 
-	// set bit at subslotIndex to 1 to indicate activity in that subslot
+	// set a bit at subslotIndex to 1 to indicate activity in that subslot
 	validatorPerformance.SlotActivityVector = validatorPerformance.SlotActivityVector | (1 << t.subslotIndex(block.ID().Slot(), block.ProtocolBlock().IssuingTime))
 
 	apiForSlot := t.apiProvider.APIForSlot(block.ID().Slot())
+
 	// we restrict the number up to ValidatorBlocksPerSlot + 1 to know later if the validator issued more blocks than allowed and be able to punish for it
 	// also it can fint into uint8
 	if validatorPerformance.BlockIssuedCount < apiForSlot.ProtocolParameters().ValidationBlocksPerSlot()+1 {
 		validatorPerformance.BlockIssuedCount++
 	}
+
 	validatorPerformance.HighestSupportedVersionAndHash = model.VersionAndHash{
 		Version: validationBlock.HighestSupportedVersion,
 		Hash:    validationBlock.ProtocolParametersHash,
 	}
+
 	if err = validatorPerformances.Store(block.ProtocolBlock().IssuerID, validatorPerformance); err != nil {
 		t.errHandler(ierrors.Errorf("failed to store performance factor for account %s", block.ProtocolBlock().IssuerID))
 	}
diff --git a/pkg/protocol/sybilprotection/sybilprotectionv1/sybilprotection.go b/pkg/protocol/sybilprotection/sybilprotectionv1/sybilprotection.go
index 01ac56ae..1e15ec4e 100644
--- a/pkg/protocol/sybilprotection/sybilprotectionv1/sybilprotection.go
+++ b/pkg/protocol/sybilprotection/sybilprotectionv1/sybilprotection.go
@@ -37,7 +37,7 @@ type SybilProtection struct {
 
 	errHandler func(error)
 
-	optsInitialCommittee    *account.Accounts
+	optsInitialCommittee    accounts.AccountsData
 	optsSeatManagerProvider module.Provider[*engine.Engine, seatmanager.SeatManager]
 
 	mutex syncutils.Mutex
@@ -62,34 +62,25 @@ func NewProvider(opts ...options.Option[SybilProtection]) module.Provider[*engin
 
 					latestCommittedSlot := e.Storage.Settings().LatestCommitment().Slot()
 					latestCommittedEpoch := o.apiProvider.APIForSlot(latestCommittedSlot).TimeProvider().EpochFromSlot(latestCommittedSlot)
-					o.performanceTracker = performance.NewTracker(e.Storage.RewardsForEpoch, e.Storage.PoolStats(), e.Storage.Committee(), e.Storage.ValidatorPerformances, latestCommittedEpoch, e, o.errHandler)
+					o.performanceTracker = performance.NewTracker(e.Storage.RewardsForEpoch, e.Storage.PoolStats(), e.Storage.Committee(), e.Storage.CommitteeCandidates, e.Storage.ValidatorPerformances, latestCommittedEpoch, e, o.errHandler)
 					o.lastCommittedSlot = latestCommittedSlot
 
 					if o.optsInitialCommittee != nil {
-						if err := o.performanceTracker.RegisterCommittee(0, o.optsInitialCommittee); err != nil {
+						if _, err := o.seatManager.RotateCommittee(0, o.optsInitialCommittee); err != nil {
 							panic(ierrors.Wrap(err, "error while registering initial committee for epoch 0"))
 						}
 					}
+
 					o.TriggerConstructed()
 
 					// When the engine is triggered initialized, snapshot has been read or database has been initialized properly,
 					// so the committee should be available in the performance manager.
 					e.HookInitialized(func() {
-						// Make sure that the sybil protection knows about the committee of the current epoch
-						// (according to the latest committed slot), and potentially the next selected
-						// committee if we have one.
-
-						currentEpoch := e.LatestAPI().TimeProvider().EpochFromSlot(e.Storage.Settings().LatestCommitment().Slot())
-
-						committee, exists := o.performanceTracker.LoadCommitteeForEpoch(currentEpoch)
-						if !exists {
-							panic("failed to load committee for last finalized slot to initialize sybil protection")
-						}
-						o.seatManager.ImportCommittee(currentEpoch, committee)
-						fmt.Println("committee import", committee.TotalStake(), currentEpoch)
-						if nextCommittee, nextCommitteeExists := o.performanceTracker.LoadCommitteeForEpoch(currentEpoch + 1); nextCommitteeExists {
-							o.seatManager.ImportCommittee(currentEpoch+1, nextCommittee)
-							fmt.Println("next committee", nextCommittee.TotalStake(), currentEpoch+1)
+						// Mark the committee for the last committed slot as active.
+						currentEpoch := e.CommittedAPI().TimeProvider().EpochFromSlot(e.Storage.Settings().LatestCommitment().Slot())
+						err := o.seatManager.InitializeCommittee(currentEpoch, e.Clock.Accepted().RelativeTime())
+						if err != nil {
+							panic(ierrors.Wrap(err, "error while initializing committee"))
 						}
 
 						o.TriggerInitialized()
@@ -108,8 +99,43 @@ func (o *SybilProtection) Shutdown() {
 	o.TriggerStopped()
 }
 
-func (o *SybilProtection) TrackValidationBlock(block *blocks.Block) {
-	o.performanceTracker.TrackValidationBlock(block)
+func (o *SybilProtection) TrackBlock(block *blocks.Block) {
+	if _, isValidationBlock := block.ValidationBlock(); isValidationBlock {
+		o.performanceTracker.TrackValidationBlock(block)
+
+		return
+	}
+
+	accountData, exists, err := o.ledger.Account(block.ProtocolBlock().IssuerID, block.SlotCommitmentID().Slot())
+	if err != nil {
+		o.errHandler(ierrors.Wrapf(err, "error while retrieving account from account %s in slot %d from accounts ledger", block.ProtocolBlock().IssuerID, block.SlotCommitmentID().Slot()))
+
+		return
+	}
+
+	if !exists {
+		return
+	}
+
+	blockEpoch := o.apiProvider.APIForSlot(block.ID().Slot()).TimeProvider().EpochFromSlot(block.ID().Slot())
+
+	// if the block is issued before the stake end epoch, then it's not a valid validator or candidate block
+	if accountData.StakeEndEpoch < blockEpoch {
+		return
+	}
+
+	// if a candidate block is issued in the stake end epoch,
+	// or if block is issued after EpochEndSlot - EpochNearingThreshold, because candidates can register only until that point.
+	// then don't consider it because the validator can't be part of the committee in the next epoch
+	if accountData.StakeEndEpoch == blockEpoch ||
+		block.ID().Slot()+o.apiProvider.APIForSlot(block.ID().Slot()).ProtocolParameters().EpochNearingThreshold() > o.apiProvider.APIForSlot(block.ID().Slot()).TimeProvider().EpochEnd(blockEpoch) {
+
+		return
+	}
+
+	if block.Payload().PayloadType() == iotago.PayloadCandidacyAnnouncement {
+		o.performanceTracker.TrackCandidateBlock(block)
+	}
 }
 
 func (o *SybilProtection) CommitSlot(slot iotago.SlotIndex) (committeeRoot, rewardsRoot iotago.Identifier, err error) {
@@ -126,23 +152,23 @@ func (o *SybilProtection) CommitSlot(slot iotago.SlotIndex) (committeeRoot, rewa
 	// If the committed slot is `maxCommittableSlot`
 	// away from the end of the epoch, then register a committee for the next epoch.
 	if timeProvider.EpochEnd(currentEpoch) == slot+maxCommittableAge {
-		if _, committeeExists := o.performanceTracker.LoadCommitteeForEpoch(nextEpoch); !committeeExists {
+		if _, committeeExists := o.seatManager.CommitteeInEpoch(nextEpoch); !committeeExists {
 			// If the committee for the epoch wasn't set before due to finalization of a slot,
 			// we promote the current committee to also serve in the next epoch.
-			committee, exists := o.performanceTracker.LoadCommitteeForEpoch(currentEpoch)
+			committee, exists := o.seatManager.CommitteeInEpoch(currentEpoch)
 			if !exists {
 				// that should never happen as it is already the fallback strategy
 				panic(fmt.Sprintf("committee for current epoch %d not found", currentEpoch))
 			}
 
-			committee.SetReused()
-			o.seatManager.SetCommittee(nextEpoch, committee)
-
-			o.events.CommitteeSelected.Trigger(committee, nextEpoch)
-
-			if err = o.performanceTracker.RegisterCommittee(nextEpoch, committee); err != nil {
-				return iotago.Identifier{}, iotago.Identifier{}, ierrors.Wrapf(err, "failed to register committee for epoch %d", nextEpoch)
+			committeeAccounts := committee.Accounts()
+			committeeAccounts.SetReused()
+			if err = o.seatManager.SetCommittee(nextEpoch, committeeAccounts); err != nil {
+				return iotago.Identifier{}, iotago.Identifier{}, ierrors.Wrapf(err, "failed to set committee for epoch %d", nextEpoch)
 			}
+			o.performanceTracker.ClearCandidates()
+
+			o.events.CommitteeSelected.Trigger(committeeAccounts, nextEpoch)
 		}
 	}
 
@@ -191,7 +217,7 @@ func (o *SybilProtection) CommitSlot(slot iotago.SlotIndex) (committeeRoot, rewa
 func (o *SybilProtection) committeeRoot(targetCommitteeEpoch iotago.EpochIndex) (committeeRoot iotago.Identifier, err error) {
 	committee, exists := o.performanceTracker.LoadCommitteeForEpoch(targetCommitteeEpoch)
 	if !exists {
-		return iotago.Identifier{}, ierrors.Wrapf(err, "committee for a finished epoch %d not found", targetCommitteeEpoch)
+		return iotago.Identifier{}, ierrors.Wrapf(err, "committee for an epoch %d not found", targetCommitteeEpoch)
 	}
 
 	committeeTree := ads.NewSet[iotago.Identifier](
@@ -251,23 +277,30 @@ func (o *SybilProtection) slotFinalized(slot iotago.SlotIndex) {
 	if slot+apiForSlot.ProtocolParameters().EpochNearingThreshold() == epochEndSlot &&
 		epochEndSlot > o.lastCommittedSlot+apiForSlot.ProtocolParameters().MaxCommittableAge() {
 		newCommittee := o.selectNewCommittee(slot)
-		fmt.Println("new committee selection finalization", epoch, newCommittee.TotalStake(), newCommittee.TotalValidatorStake())
 		o.events.CommitteeSelected.Trigger(newCommittee, epoch+1)
 	}
 }
 
 // IsCandidateActive returns true if the given validator is currently active.
-func (o *SybilProtection) IsCandidateActive(validatorID iotago.AccountID, epoch iotago.EpochIndex) bool {
-	activeCandidates := o.performanceTracker.EligibleValidatorCandidates(epoch)
-	return activeCandidates.Has(validatorID)
+func (o *SybilProtection) IsCandidateActive(validatorID iotago.AccountID, epoch iotago.EpochIndex) (bool, error) {
+	activeCandidates, err := o.performanceTracker.EligibleValidatorCandidates(epoch)
+	if err != nil {
+		return false, ierrors.Wrapf(err, "failed to retrieve eligible candidates")
+	}
+
+	return activeCandidates.Has(validatorID), nil
 }
 
 // EligibleValidators returns the currently known list of recently active validator candidates for the given epoch.
 func (o *SybilProtection) EligibleValidators(epoch iotago.EpochIndex) (accounts.AccountsData, error) {
-	candidates := o.performanceTracker.EligibleValidatorCandidates(epoch)
+	candidates, err := o.performanceTracker.EligibleValidatorCandidates(epoch)
+	if err != nil {
+		return nil, ierrors.Wrapf(err, "failed to retrieve eligible validator candidates for epoch %d", epoch)
+	}
+
 	validators := make(accounts.AccountsData, 0)
 
-	if err := candidates.ForEach(func(candidate iotago.AccountID) error {
+	if err = candidates.ForEach(func(candidate iotago.AccountID) error {
 		accountData, exists, err := o.ledger.Account(candidate, o.lastCommittedSlot)
 		if err != nil {
 			return ierrors.Wrapf(err, "failed to load account data for candidate %s", candidate)
@@ -291,8 +324,15 @@ func (o *SybilProtection) EligibleValidators(epoch iotago.EpochIndex) (accounts.
 
 // OrderedRegisteredCandidateValidatorsList returns the currently known list of registered validator candidates for the given epoch.
 func (o *SybilProtection) OrderedRegisteredCandidateValidatorsList(epoch iotago.EpochIndex) ([]*apimodels.ValidatorResponse, error) {
-	candidates := o.performanceTracker.ValidatorCandidates(epoch)
-	activeCandidates := o.performanceTracker.EligibleValidatorCandidates(epoch)
+	candidates, err := o.performanceTracker.ValidatorCandidates(epoch)
+	if err != nil {
+		return nil, ierrors.Wrapf(err, "failed to retrieve candidates")
+	}
+
+	activeCandidates, err := o.performanceTracker.EligibleValidatorCandidates(epoch)
+	if err != nil {
+		return nil, ierrors.Wrapf(err, "failed to retrieve eligible candidates")
+	}
 
 	validatorResp := make([]*apimodels.ValidatorResponse, 0, candidates.Size())
 	if err := candidates.ForEach(func(candidate iotago.AccountID) error {
@@ -335,43 +375,41 @@ func (o *SybilProtection) selectNewCommittee(slot iotago.SlotIndex) *account.Acc
 	timeProvider := o.apiProvider.APIForSlot(slot).TimeProvider()
 	currentEpoch := timeProvider.EpochFromSlot(slot)
 	nextEpoch := currentEpoch + 1
-	candidates := o.performanceTracker.EligibleValidatorCandidates(nextEpoch)
+	candidates, err := o.performanceTracker.EligibleValidatorCandidates(nextEpoch)
+	if err != nil {
+		panic(ierrors.Wrapf(err, "failed to retrieve candidates for epoch %d", nextEpoch))
+	}
 
-	weightedCandidates := account.NewAccounts()
+	candidateAccounts := make(accounts.AccountsData, 0)
 	if err := candidates.ForEach(func(candidate iotago.AccountID) error {
-		a, exists, err := o.ledger.Account(candidate, slot)
+		accountData, exists, err := o.ledger.Account(candidate, slot)
 		if err != nil {
 			return err
 		}
 		if !exists {
-			o.errHandler(ierrors.Errorf("account of committee candidate does not exist: %s", candidate))
+			return ierrors.Errorf("account of committee candidate %s does not exist in slot %d", candidate, slot)
 		}
 
-		weightedCandidates.Set(candidate, &account.Pool{
-			PoolStake:      a.ValidatorStake + a.DelegationStake,
-			ValidatorStake: a.ValidatorStake,
-			FixedCost:      a.FixedCost,
-		})
+		candidateAccounts = append(candidateAccounts, accountData)
 
 		return nil
 	}); err != nil {
-		o.errHandler(err)
+		panic(ierrors.Wrap(err, "failed to iterate through candidates"))
 	}
 
-	newCommittee := o.seatManager.RotateCommittee(nextEpoch, weightedCandidates)
-	weightedCommittee := newCommittee.Accounts()
-
-	err := o.performanceTracker.RegisterCommittee(nextEpoch, weightedCommittee)
+	newCommittee, err := o.seatManager.RotateCommittee(nextEpoch, candidateAccounts)
 	if err != nil {
-		o.errHandler(ierrors.Wrap(err, "failed to register committee for epoch"))
+		panic(ierrors.Wrap(err, "failed to rotate committee"))
 	}
 
-	return weightedCommittee
+	o.performanceTracker.ClearCandidates()
+
+	return newCommittee.Accounts()
 }
 
 // WithInitialCommittee registers the passed committee on a given slot.
 // This is needed to generate Genesis snapshot with some initial committee.
-func WithInitialCommittee(committee *account.Accounts) options.Option[SybilProtection] {
+func WithInitialCommittee(committee accounts.AccountsData) options.Option[SybilProtection] {
 	return func(o *SybilProtection) {
 		o.optsInitialCommittee = committee
 	}
diff --git a/pkg/storage/prunable/prunable.go b/pkg/storage/prunable/prunable.go
index 1fc39287..9e439653 100644
--- a/pkg/storage/prunable/prunable.go
+++ b/pkg/storage/prunable/prunable.go
@@ -155,6 +155,10 @@ func (p *Prunable) Rollback(targetSlot iotago.SlotIndex) error {
 		return ierrors.Wrapf(err, "error while rolling back slots in a bucket for epoch %d", targetSlotEpoch)
 	}
 
+	if err := p.rollbackCommitteesCandidates(targetSlotEpoch, targetSlot); err != nil {
+		return ierrors.Wrapf(err, "error while rolling back committee for epoch %d", targetSlotEpoch)
+	}
+
 	// Shut down the prunableSlotStore in order to flush and get consistent state on disk after reopening.
 	p.prunableSlotStore.Shutdown()
 
@@ -218,3 +222,49 @@ func (p *Prunable) shouldRollbackCommittee(epoch iotago.EpochIndex, targetSlot i
 
 	return true, nil
 }
+
+func (p *Prunable) rollbackCommitteesCandidates(targetSlotEpoch iotago.EpochIndex, targetSlot iotago.SlotIndex) error {
+	candidatesToRollback := make([]iotago.AccountID, 0)
+
+	candidates, err := p.CommitteeCandidates(targetSlotEpoch)
+	if err != nil {
+		return ierrors.Wrap(err, "failed to get candidates store")
+	}
+
+	var innerErr error
+	if err = candidates.Iterate(kvstore.EmptyPrefix, func(key kvstore.Key, value kvstore.Value) bool {
+		accountID, _, err := iotago.AccountIDFromBytes(key)
+		if err != nil {
+			innerErr = err
+
+			return false
+		}
+
+		candidacySlot, _, err := iotago.SlotIndexFromBytes(value)
+		if err != nil {
+			innerErr = err
+
+			return false
+		}
+
+		if candidacySlot < targetSlot {
+			candidatesToRollback = append(candidatesToRollback, accountID)
+		}
+
+		return true
+	}); err != nil {
+		return ierrors.Wrap(err, "failed to collect candidates to rollback")
+	}
+
+	if innerErr != nil {
+		return ierrors.Wrap(innerErr, "failed to iterate through candidates")
+	}
+
+	for _, candidateToRollback := range candidatesToRollback {
+		if err = candidates.Delete(candidateToRollback[:]); err != nil {
+			return ierrors.Wrapf(innerErr, "failed to rollback candidate %s", candidateToRollback)
+		}
+	}
+
+	return nil
+}
diff --git a/pkg/storage/prunable/prunable_slot.go b/pkg/storage/prunable/prunable_slot.go
index a7632ca3..0549278b 100644
--- a/pkg/storage/prunable/prunable_slot.go
+++ b/pkg/storage/prunable/prunable_slot.go
@@ -21,6 +21,7 @@ const (
 	slotPrefixUpgradeSignals
 	slotPrefixRoots
 	slotPrefixRetainer
+	epochPrefixCommitteeCandidates
 )
 
 func (p *Prunable) getKVStoreFromSlot(slot iotago.SlotIndex, prefix kvstore.Realm) (kvstore.KVStore, error) {
@@ -52,6 +53,12 @@ func (p *Prunable) RootBlocks(slot iotago.SlotIndex) (*slotstore.Store[iotago.Bl
 	), nil
 }
 
+func (p *Prunable) CommitteeCandidates(epoch iotago.EpochIndex) (kvstore.KVStore, error) {
+	// Use the first slot of an epoch to avoid random clashes with other keys.
+	// Candidates belong to an epoch, but we store them here so that they're pruned more quickly and easily without unnecessary key iteration.
+	return p.prunableSlotStore.Get(epoch, byteutils.ConcatBytes(p.apiProvider.APIForEpoch(epoch).TimeProvider().EpochStart(epoch).MustBytes(), kvstore.Realm{epochPrefixCommitteeCandidates}))
+}
+
 func (p *Prunable) Mutations(slot iotago.SlotIndex) (kvstore.KVStore, error) {
 	return p.getKVStoreFromSlot(slot, kvstore.Realm{slotPrefixMutations})
 }
diff --git a/pkg/storage/storage_prunable.go b/pkg/storage/storage_prunable.go
index a65cd940..5403484d 100644
--- a/pkg/storage/storage_prunable.go
+++ b/pkg/storage/storage_prunable.go
@@ -29,6 +29,10 @@ func (s *Storage) Committee() *epochstore.Store[*account.Accounts] {
 	return s.prunable.Committee()
 }
 
+func (s *Storage) CommitteeCandidates(epoch iotago.EpochIndex) (kvstore.KVStore, error) {
+	return s.prunable.CommitteeCandidates(epoch)
+}
+
 func (s *Storage) Blocks(slot iotago.SlotIndex) (*slotstore.Blocks, error) {
 	return s.prunable.Blocks(slot)
 }


Test patch:
diff --git a/pkg/protocol/engine/attestation/attestations.go b/pkg/protocol/engine/attestation/attestations.go
index 06572e53..1803a4b0 100644
--- a/pkg/protocol/engine/attestation/attestations.go
+++ b/pkg/protocol/engine/attestation/attestations.go
@@ -17,7 +17,7 @@ type Attestations interface {
 	// GetMap returns the attestations that are included in the commitment of the given slot as ads.Map.
 	// If attestationCommitmentOffset=3 and commitment is 10, then the returned attestations are blocks from 7 to 10 that commit to at least 7.
 	GetMap(index iotago.SlotIndex) (attestations ads.Map[iotago.Identifier, iotago.AccountID, *iotago.Attestation], err error)
-	AddAttestationFromValidationBlock(block *blocks.Block)
+	AddAttestationFromValidationBlock(block *blocks.Block) error
 	Commit(index iotago.SlotIndex) (newCW uint64, attestationsRoot iotago.Identifier, err error)
 
 	Import(reader io.ReadSeeker) (err error)
diff --git a/pkg/protocol/engine/attestation/slotattestation/manager.go b/pkg/protocol/engine/attestation/slotattestation/manager.go
index 890ffe56..6b27b5b2 100644
--- a/pkg/protocol/engine/attestation/slotattestation/manager.go
+++ b/pkg/protocol/engine/attestation/slotattestation/manager.go
@@ -49,7 +49,7 @@ const (
 //		- obtain and evict from it attestations that *commit to* lastCommittedSlot-attestationCommitmentOffset
 //	- committed attestations: retrieved at slot that we are committing, stored at slot lastCommittedSlot-attestationCommitmentOffset
 type Manager struct {
-	committeeFunc func(slot iotago.SlotIndex) *account.SeatedAccounts
+	committeeFunc func(slot iotago.SlotIndex) (*account.SeatedAccounts, bool)
 
 	futureAttestations  *memstorage.IndexedStorage[iotago.SlotIndex, iotago.AccountID, *iotago.Attestation]
 	pendingAttestations *memstorage.IndexedStorage[iotago.SlotIndex, iotago.AccountID, *iotago.Attestation]
@@ -73,7 +73,7 @@ func NewProvider() module.Provider[*engine.Engine, attestation.Attestations] {
 			latestCommitment.Slot(),
 			latestCommitment.CumulativeWeight(),
 			e.Storage.Attestations,
-			e.SybilProtection.SeatManager().Committee,
+			e.SybilProtection.SeatManager().CommitteeInSlot,
 			e,
 		)
 	})
@@ -83,7 +83,7 @@ func NewManager(
 	lastCommittedSlot iotago.SlotIndex,
 	lastCumulativeWeight uint64,
 	bucketedStorage func(slot iotago.SlotIndex) (kvstore.KVStore, error),
-	committeeFunc func(slot iotago.SlotIndex) *account.SeatedAccounts,
+	committeeFunc func(slot iotago.SlotIndex) (*account.SeatedAccounts, bool),
 	apiProvider iotago.APIProvider,
 ) *Manager {
 	m := &Manager{
@@ -145,15 +145,19 @@ func (m *Manager) GetMap(slot iotago.SlotIndex) (ads.Map[iotago.Identifier, iota
 }
 
 // AddAttestationFromValidationBlock adds an attestation from a block to the future attestations (beyond the attestation window).
-func (m *Manager) AddAttestationFromValidationBlock(block *blocks.Block) {
+func (m *Manager) AddAttestationFromValidationBlock(block *blocks.Block) error {
 	// Only track validator blocks.
 	if _, isValidationBlock := block.ValidationBlock(); !isValidationBlock {
-		return
+		return nil
 	}
 
+	committee, exists := m.committeeFunc(block.ID().Slot())
+	if !exists {
+		return ierrors.Errorf("committee for slot %d does not exist", block.ID().Slot())
+	}
 	// Only track attestations of active committee members.
-	if _, exists := m.committeeFunc(block.ID().Slot()).GetSeat(block.ProtocolBlock().IssuerID); !exists {
-		return
+	if _, exists := committee.GetSeat(block.ProtocolBlock().IssuerID); !exists {
+		return nil
 	}
 
 	m.commitmentMutex.RLock()
@@ -161,7 +165,7 @@ func (m *Manager) AddAttestationFromValidationBlock(block *blocks.Block) {
 
 	// We only care about attestations that are newer than the last committed slot.
 	if block.ID().Slot() <= m.lastCommittedSlot {
-		return
+		return nil
 	}
 
 	newAttestation := iotago.NewAttestation(m.apiProvider.APIForSlot(block.ID().Slot()), block.ProtocolBlock())
@@ -179,6 +183,8 @@ func (m *Manager) AddAttestationFromValidationBlock(block *blocks.Block) {
 
 		return currentValue
 	})
+
+	return nil
 }
 
 func (m *Manager) applyToPendingAttestations(attestation *iotago.Attestation, cutoffSlot iotago.SlotIndex) {
@@ -254,9 +260,14 @@ func (m *Manager) Commit(slot iotago.SlotIndex) (newCW uint64, attestationsRoot
 	}
 
 	// Add all attestations to the tree and calculate the new cumulative weight.
+	committee, exists := m.committeeFunc(slot)
+	if !exists {
+		return 0, iotago.Identifier{}, ierrors.Wrapf(err, "failed to get committee when committing slot %d", slot)
+	}
+
 	for _, a := range attestations {
 		// TODO: which weight are we using here? The current one? Or the one of the slot of the attestation/commitmentID?
-		if _, exists := m.committeeFunc(slot).GetSeat(a.IssuerID); exists {
+		if _, exists := committee.GetSeat(a.IssuerID); exists {
 			if err := tree.Set(a.IssuerID, a); err != nil {
 				return 0, iotago.Identifier{}, ierrors.Wrapf(err, "failed to set attestation %s in tree", a.IssuerID)
 			}
diff --git a/pkg/protocol/engine/attestation/slotattestation/testframework_test.go b/pkg/protocol/engine/attestation/slotattestation/testframework_test.go
index b931d2a2..ad77b48f 100644
--- a/pkg/protocol/engine/attestation/slotattestation/testframework_test.go
+++ b/pkg/protocol/engine/attestation/slotattestation/testframework_test.go
@@ -57,7 +57,7 @@ func NewTestFramework(test *testing.T) *TestFramework {
 		})), nil
 	}
 
-	committeeFunc := func(index iotago.SlotIndex) *account.SeatedAccounts {
+	committeeFunc := func(index iotago.SlotIndex) (*account.SeatedAccounts, bool) {
 		accounts := account.NewAccounts()
 		var members []iotago.AccountID
 		t.issuerByAlias.ForEach(func(alias string, issuer *issuer) bool {
@@ -65,7 +65,7 @@ func NewTestFramework(test *testing.T) *TestFramework {
 			members = append(members, issuer.accountID)
 			return true
 		})
-		return accounts.SelectCommittee(members...)
+		return accounts.SelectCommittee(members...), true
 	}
 
 	t.testAPI = iotago.V3API(
diff --git a/pkg/protocol/engine/consensus/blockgadget/testframework_test.go b/pkg/protocol/engine/consensus/blockgadget/testframework_test.go
index 14430710..a2d7371b 100644
--- a/pkg/protocol/engine/consensus/blockgadget/testframework_test.go
+++ b/pkg/protocol/engine/consensus/blockgadget/testframework_test.go
@@ -9,14 +9,17 @@ import (
 	"github.com/stretchr/testify/require"
 
 	"github.com/iotaledger/hive.go/ds/shrinkingmap"
+	"github.com/iotaledger/hive.go/kvstore"
 	"github.com/iotaledger/hive.go/kvstore/mapdb"
 	"github.com/iotaledger/hive.go/lo"
+	"github.com/iotaledger/iota-core/pkg/core/account"
 	"github.com/iotaledger/iota-core/pkg/model"
 	"github.com/iotaledger/iota-core/pkg/protocol/engine/blocks"
 	"github.com/iotaledger/iota-core/pkg/protocol/engine/consensus/blockgadget"
 	"github.com/iotaledger/iota-core/pkg/protocol/engine/consensus/blockgadget/thresholdblockgadget"
 	"github.com/iotaledger/iota-core/pkg/protocol/engine/eviction"
 	"github.com/iotaledger/iota-core/pkg/protocol/sybilprotection/seatmanager/mock"
+	"github.com/iotaledger/iota-core/pkg/storage/prunable/epochstore"
 	"github.com/iotaledger/iota-core/pkg/storage/prunable/slotstore"
 	iotago "github.com/iotaledger/iota.go/v4"
 	"github.com/iotaledger/iota.go/v4/api"
@@ -40,7 +43,7 @@ func NewTestFramework(test *testing.T) *TestFramework {
 		T:      test,
 		blocks: shrinkingmap.New[string, *blocks.Block](),
 
-		SeatManager: mock.NewManualPOA(),
+		SeatManager: mock.NewManualPOA(api.SingleVersionProvider(tpkg.TestAPI), epochstore.NewStore(kvstore.Realm{}, kvstore.Realm{}, mapdb.NewMapDB(), 0, (*account.Accounts).Bytes, account.AccountsFromBytes)),
 	}
 
 	evictionState := eviction.NewState(mapdb.NewMapDB(), func(slot iotago.SlotIndex) (*slotstore.Store[iotago.BlockID, iotago.CommitmentID], error) {
@@ -53,7 +56,10 @@ func NewTestFramework(test *testing.T) *TestFramework {
 	})
 
 	t.blockCache = blocks.New(evictionState, api.SingleVersionProvider(tpkg.TestAPI))
-	instance := thresholdblockgadget.New(t.blockCache, t.SeatManager)
+	instance := thresholdblockgadget.New(t.blockCache, t.SeatManager, func(err error) {
+		fmt.Printf(">> Gadget.Error: %s\n", err)
+	})
+
 	t.Events = instance.Events()
 	t.Instance = instance
 
diff --git a/pkg/protocol/engine/filter/blockfilter/filter_test.go b/pkg/protocol/engine/filter/blockfilter/filter_test.go
index 1e7347fc..02646bb5 100644
--- a/pkg/protocol/engine/filter/blockfilter/filter_test.go
+++ b/pkg/protocol/engine/filter/blockfilter/filter_test.go
@@ -96,14 +96,14 @@ func (t *TestFramework) IssueBlockAtSlotWithVersion(alias string, slot iotago.Sl
 	return t.processBlock(alias, block)
 }
 
-func mockedCommitteeFunc(validatorAccountID iotago.AccountID) func(iotago.SlotIndex) *account.SeatedAccounts {
+func mockedCommitteeFunc(validatorAccountID iotago.AccountID) func(iotago.SlotIndex) (*account.SeatedAccounts, bool) {
 	mockedAccounts := account.NewAccounts()
 	mockedAccounts.Set(validatorAccountID, new(account.Pool))
 	seatedAccounts := account.NewSeatedAccounts(mockedAccounts)
 	seatedAccounts.Set(account.SeatIndex(0), validatorAccountID)
 
-	return func(slot iotago.SlotIndex) *account.SeatedAccounts {
-		return seatedAccounts
+	return func(slot iotago.SlotIndex) (*account.SeatedAccounts, bool) {
+		return seatedAccounts, true
 	}
 }
 
diff --git a/pkg/protocol/sybilprotection/seatmanager/topstakers/topstakers_test.go b/pkg/protocol/sybilprotection/seatmanager/topstakers/topstakers_test.go
new file mode 100644
index 00000000..4b539e72
--- /dev/null
+++ b/pkg/protocol/sybilprotection/seatmanager/topstakers/topstakers_test.go
@@ -0,0 +1,227 @@
+package topstakers
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/iotaledger/hive.go/ds"
+	"github.com/iotaledger/hive.go/kvstore"
+	"github.com/iotaledger/hive.go/kvstore/mapdb"
+	"github.com/iotaledger/hive.go/lo"
+	"github.com/iotaledger/iota-core/pkg/core/account"
+	"github.com/iotaledger/iota-core/pkg/protocol/engine/accounts"
+	"github.com/iotaledger/iota-core/pkg/protocol/sybilprotection/activitytracker/activitytrackerv1"
+	"github.com/iotaledger/iota-core/pkg/protocol/sybilprotection/seatmanager"
+	"github.com/iotaledger/iota-core/pkg/storage/prunable/epochstore"
+	iotago "github.com/iotaledger/iota.go/v4"
+	"github.com/iotaledger/iota.go/v4/api"
+	"github.com/iotaledger/iota.go/v4/tpkg"
+)
+
+func TestTopStakers_InitializeCommittee(t *testing.T) {
+	committeeStore := epochstore.NewStore(kvstore.Realm{}, kvstore.Realm{}, mapdb.NewMapDB(), 0, (*account.Accounts).Bytes, account.AccountsFromBytes)
+
+	topStakersSeatManager := &SeatManager{
+		apiProvider:     api.SingleVersionProvider(tpkg.TestAPI),
+		committeeStore:  committeeStore,
+		events:          seatmanager.NewEvents(),
+		activityTracker: activitytrackerv1.NewActivityTracker(time.Second * 30),
+
+		optsSeatCount: 3,
+	}
+
+	// Create committee for epoch 0
+	initialCommittee := account.NewAccounts()
+	for i := 0; i < 3; i++ {
+		initialCommittee.Set(tpkg.RandAccountID(), &account.Pool{
+			PoolStake:      1900,
+			ValidatorStake: 900,
+			FixedCost:      11,
+		})
+	}
+	// Try setting committee that is too small - should return an error.
+	err := topStakersSeatManager.SetCommittee(0, initialCommittee)
+	require.NoError(t, err)
+	weightedSeats, exists := topStakersSeatManager.CommitteeInEpoch(0)
+	require.True(t, exists)
+	initialCommitteeAccountIDs := initialCommittee.IDs()
+
+	// Make sure that the online committee is handled correctly.
+	require.True(t, topStakersSeatManager.OnlineCommittee().IsEmpty())
+
+	require.NoError(t, topStakersSeatManager.InitializeCommittee(0, time.Time{}))
+	assertOnlineCommittee(t, topStakersSeatManager.OnlineCommittee(), lo.Return1(weightedSeats.GetSeat(initialCommitteeAccountIDs[0])), lo.Return1(weightedSeats.GetSeat(initialCommitteeAccountIDs[2])), lo.Return1(weightedSeats.GetSeat(initialCommitteeAccountIDs[2])))
+
+}
+
+func TestTopStakers_RotateCommittee(t *testing.T) {
+	committeeStore := epochstore.NewStore(kvstore.Realm{}, kvstore.Realm{}, mapdb.NewMapDB(), 0, (*account.Accounts).Bytes, account.AccountsFromBytes)
+
+	topStakersSeatManager := &SeatManager{
+		apiProvider:     api.SingleVersionProvider(tpkg.TestAPI),
+		committeeStore:  committeeStore,
+		events:          seatmanager.NewEvents(),
+		activityTracker: activitytrackerv1.NewActivityTracker(time.Second * 30),
+
+		optsSeatCount: 3,
+	}
+
+	// Committee should not exist because it was never set.
+	_, exists := topStakersSeatManager.CommitteeInSlot(10)
+	require.False(t, exists)
+
+	_, exists = topStakersSeatManager.CommitteeInEpoch(0)
+	require.False(t, exists)
+
+	// Create committee for epoch 0
+	initialCommittee := account.NewAccounts()
+	initialCommittee.Set(tpkg.RandAccountID(), &account.Pool{
+		PoolStake:      1900,
+		ValidatorStake: 900,
+		FixedCost:      11,
+	})
+
+	initialCommittee.Set(tpkg.RandAccountID(), &account.Pool{
+		PoolStake:      1900,
+		ValidatorStake: 900,
+		FixedCost:      11,
+	})
+
+	// Try setting committee that is too small - should return an error.
+	err := topStakersSeatManager.SetCommittee(0, initialCommittee)
+	require.Error(t, err)
+
+	initialCommittee.Set(tpkg.RandAccountID(), &account.Pool{
+		PoolStake:      1900,
+		ValidatorStake: 900,
+		FixedCost:      11,
+	})
+
+	// Set committee with the correct size
+	err = topStakersSeatManager.SetCommittee(0, initialCommittee)
+	require.NoError(t, err)
+	weightedSeats, exists := topStakersSeatManager.CommitteeInEpoch(0)
+	require.True(t, exists)
+	initialCommitteeAccountIDs := initialCommittee.IDs()
+
+	// Make sure that the online committee is handled correctly.
+	require.True(t, topStakersSeatManager.OnlineCommittee().IsEmpty())
+
+	topStakersSeatManager.activityTracker.MarkSeatActive(lo.Return1(weightedSeats.GetSeat(initialCommitteeAccountIDs[0])), initialCommitteeAccountIDs[0], tpkg.TestAPI.TimeProvider().SlotStartTime(1))
+	assertOnlineCommittee(t, topStakersSeatManager.OnlineCommittee(), lo.Return1(weightedSeats.GetSeat(initialCommitteeAccountIDs[0])))
+
+	topStakersSeatManager.activityTracker.MarkSeatActive(lo.Return1(weightedSeats.GetSeat(initialCommitteeAccountIDs[1])), initialCommitteeAccountIDs[1], tpkg.TestAPI.TimeProvider().SlotStartTime(2))
+	assertOnlineCommittee(t, topStakersSeatManager.OnlineCommittee(), lo.Return1(weightedSeats.GetSeat(initialCommitteeAccountIDs[0])), lo.Return1(weightedSeats.GetSeat(initialCommitteeAccountIDs[1])))
+
+	topStakersSeatManager.activityTracker.MarkSeatActive(lo.Return1(weightedSeats.GetSeat(initialCommitteeAccountIDs[2])), initialCommitteeAccountIDs[2], tpkg.TestAPI.TimeProvider().SlotStartTime(3))
+	assertOnlineCommittee(t, topStakersSeatManager.OnlineCommittee(), lo.Return1(weightedSeats.GetSeat(initialCommitteeAccountIDs[0])), lo.Return1(weightedSeats.GetSeat(initialCommitteeAccountIDs[1])), lo.Return1(weightedSeats.GetSeat(initialCommitteeAccountIDs[2])))
+
+	// Make sure that after a period of inactivity, the inactive seats are marked as offline.
+	topStakersSeatManager.activityTracker.MarkSeatActive(lo.Return1(weightedSeats.GetSeat(initialCommitteeAccountIDs[2])), initialCommitteeAccountIDs[2], tpkg.TestAPI.TimeProvider().SlotEndTime(7))
+	assertOnlineCommittee(t, topStakersSeatManager.OnlineCommittee(), lo.Return1(weightedSeats.GetSeat(initialCommitteeAccountIDs[2])))
+
+	// Make sure that the committee was assigned to the correct epoch.
+	_, exists = topStakersSeatManager.CommitteeInEpoch(1)
+	require.False(t, exists)
+
+	// Make sure that the committee members match the expected ones.
+	committee, exists := topStakersSeatManager.CommitteeInEpoch(0)
+	require.True(t, exists)
+	assertCommittee(t, initialCommittee, committee)
+
+	committee, exists = topStakersSeatManager.CommitteeInSlot(3)
+	require.True(t, exists)
+	assertCommittee(t, initialCommittee, committee)
+
+	// Design candidate list and expected committee members.
+	accountsContext := make(accounts.AccountsData, 0)
+	expectedCommittee := account.NewAccounts()
+	numCandidates := 10
+
+	// Add some candidates that have the same fields to test sorting by secondary fields.
+	candidate1ID := tpkg.RandAccountID()
+	accountsContext = append(accountsContext, &accounts.AccountData{
+		ID:              candidate1ID,
+		ValidatorStake:  399,
+		DelegationStake: 800 - 399,
+		FixedCost:       3,
+		StakeEndEpoch:   iotago.MaxEpochIndex,
+	})
+
+	candidate2ID := tpkg.RandAccountID()
+	accountsContext = append(accountsContext, &accounts.AccountData{
+		ID:              candidate2ID,
+		ValidatorStake:  399,
+		DelegationStake: 800 - 399,
+		FixedCost:       3,
+		StakeEndEpoch:   iotago.MaxEpochIndex,
+	})
+
+	for i := 1; i <= numCandidates; i++ {
+		candidateAccountID := tpkg.RandAccountID()
+		candidatePool := &account.Pool{
+			PoolStake:      iotago.BaseToken(i * 100),
+			ValidatorStake: iotago.BaseToken(i * 50),
+			FixedCost:      iotago.Mana(i),
+		}
+		accountsContext = append(accountsContext, &accounts.AccountData{
+			ID:              candidateAccountID,
+			ValidatorStake:  iotago.BaseToken(i * 50),
+			DelegationStake: iotago.BaseToken(i*100) - iotago.BaseToken(i*50),
+			FixedCost:       tpkg.RandMana(iotago.MaxMana),
+			StakeEndEpoch:   tpkg.RandEpoch(),
+		})
+
+		if i+topStakersSeatManager.SeatCount() > numCandidates {
+			expectedCommittee.Set(candidateAccountID, candidatePool)
+		}
+	}
+
+	// Rotate the committee and make sure that the returned committee matches the expected.
+	newCommittee, err := topStakersSeatManager.RotateCommittee(1, accountsContext)
+	require.NoError(t, err)
+	assertCommittee(t, expectedCommittee, newCommittee)
+
+	// Make sure that after committee rotation, the online committee is not changed.
+	assertOnlineCommittee(t, topStakersSeatManager.OnlineCommittee(), lo.Return1(weightedSeats.GetSeat(initialCommitteeAccountIDs[2])))
+
+	newCommitteeMemberIDs := newCommittee.Accounts().IDs()
+
+	// A new committee member appears online and makes the previously active committee seat inactive.
+	topStakersSeatManager.activityTracker.MarkSeatActive(lo.Return1(weightedSeats.GetSeat(newCommitteeMemberIDs[0])), newCommitteeMemberIDs[0], tpkg.TestAPI.TimeProvider().SlotEndTime(14))
+	assertOnlineCommittee(t, topStakersSeatManager.OnlineCommittee(), lo.Return1(weightedSeats.GetSeat(newCommitteeMemberIDs[0])))
+
+	// Make sure that the committee retrieved from the committee store matches the expected.
+	committee, exists = topStakersSeatManager.CommitteeInEpoch(1)
+	require.True(t, exists)
+	assertCommittee(t, expectedCommittee, committee)
+
+	committee, exists = topStakersSeatManager.CommitteeInSlot(tpkg.TestAPI.TimeProvider().EpochStart(1))
+	require.True(t, exists)
+	assertCommittee(t, expectedCommittee, committee)
+
+	// Make sure that the previous committee was not modified and is still accessible.
+	committee, exists = topStakersSeatManager.CommitteeInEpoch(0)
+	require.True(t, exists)
+	assertCommittee(t, initialCommittee, committee)
+
+	committee, exists = topStakersSeatManager.CommitteeInSlot(tpkg.TestAPI.TimeProvider().EpochEnd(0))
+	require.True(t, exists)
+	assertCommittee(t, initialCommittee, committee)
+}
+
+func assertCommittee(t *testing.T, expectedCommittee *account.Accounts, actualCommittee *account.SeatedAccounts) {
+	require.Equal(t, actualCommittee.Accounts().Size(), expectedCommittee.Size())
+	for _, memberID := range expectedCommittee.IDs() {
+		require.Truef(t, actualCommittee.Accounts().Has(memberID), "expected account %s to be part of committee, but it is not, actual committee members: %s", memberID, actualCommittee.Accounts().IDs())
+	}
+}
+
+func assertOnlineCommittee(t *testing.T, onlineCommittee ds.Set[account.SeatIndex], expectedOnlineSeats ...account.SeatIndex) {
+	require.Equal(t, onlineCommittee.Size(), len(expectedOnlineSeats))
+	for _, seatIndex := range expectedOnlineSeats {
+		require.Truef(t, onlineCommittee.Has(seatIndex), "expected account %s to be part of committee, but it is not, actual committee members: %s", seatIndex, onlineCommittee)
+	}
+}
diff --git a/pkg/protocol/sybilprotection/sybilprotectionv1/performance/testsuite_test.go b/pkg/protocol/sybilprotection/sybilprotectionv1/performance/testsuite_test.go
index 5d9c9ce5..6615023d 100644
--- a/pkg/protocol/sybilprotection/sybilprotectionv1/performance/testsuite_test.go
+++ b/pkg/protocol/sybilprotection/sybilprotectionv1/performance/testsuite_test.go
@@ -73,8 +73,18 @@ func (t *TestSuite) InitPerformanceTracker() {
 	rewardsStore := epochstore.NewEpochKVStore(kvstore.Realm{}, kvstore.Realm{}, mapdb.NewMapDB(), 0)
 	poolStatsStore := epochstore.NewStore(kvstore.Realm{}, kvstore.Realm{}, mapdb.NewMapDB(), 0, (*model.PoolsStats).Bytes, model.PoolsStatsFromBytes)
 	committeeStore := epochstore.NewStore(kvstore.Realm{}, kvstore.Realm{}, mapdb.NewMapDB(), 0, (*account.Accounts).Bytes, account.AccountsFromBytes)
-
-	t.Instance = NewTracker(rewardsStore.GetEpoch, poolStatsStore, committeeStore, performanceFactorFunc, t.latestCommittedEpoch, api.SingleVersionProvider(t.api), func(err error) {})
+	committeeCandidatesStore := epochstore.NewEpochKVStore(kvstore.Realm{}, kvstore.Realm{}, mapdb.NewMapDB(), 0)
+
+	t.Instance = NewTracker(
+		rewardsStore.GetEpoch,
+		poolStatsStore,
+		committeeStore,
+		committeeCandidatesStore.GetEpoch,
+		performanceFactorFunc,
+		t.latestCommittedEpoch,
+		api.SingleVersionProvider(t.api),
+		func(err error) {},
+	)
 }
 
 func (t *TestSuite) Account(alias string, createIfNotExists bool) iotago.AccountID {
@@ -103,7 +113,8 @@ func (t *TestSuite) ApplyEpochActions(epoch iotago.EpochIndex, actions map[strin
 		})
 	}
 
-	err := t.Instance.RegisterCommittee(epoch, committee)
+	// Store directly on the committee store, because in actual code the SeatManager is responsible for adding the storage entry.
+	err := t.Instance.committeeStore.Store(epoch, committee)
 	require.NoError(t.T, err)
 	for accIDAlias, action := range actions {
 		accID := t.Account(accIDAlias, false)
diff --git a/pkg/protocol/sybilprotection/sybilprotectionv1/performance/tracker_test.go b/pkg/protocol/sybilprotection/sybilprotectionv1/performance/tracker_test.go
index 46033f79..49a1a95b 100644
--- a/pkg/protocol/sybilprotection/sybilprotectionv1/performance/tracker_test.go
+++ b/pkg/protocol/sybilprotection/sybilprotectionv1/performance/tracker_test.go
@@ -3,7 +3,14 @@ package performance
 import (
 	"testing"
 
+	"github.com/stretchr/testify/require"
+
+	"github.com/iotaledger/hive.go/ds"
+	"github.com/iotaledger/hive.go/lo"
+	"github.com/iotaledger/iota-core/pkg/model"
+	"github.com/iotaledger/iota-core/pkg/protocol/engine/blocks"
 	iotago "github.com/iotaledger/iota.go/v4"
+	"github.com/iotaledger/iota.go/v4/tpkg"
 )
 
 func TestManager_Rewards(t *testing.T) {
@@ -80,5 +87,77 @@ func TestManager_Rewards(t *testing.T) {
 	ts.AssertRewardForDelegatorsOnly("D", epoch, epochActions)
 
 	// test the epoch after initial phase
+}
+
+func TestManager_Candidates(t *testing.T) {
+	ts := NewTestSuite(t)
+
+	issuer1 := tpkg.RandAccountID()
+	issuer2 := tpkg.RandAccountID()
+	issuer3 := tpkg.RandAccountID()
+	{
+		block1 := tpkg.RandProtocolBlock(tpkg.RandBasicBlock(ts.api, iotago.PayloadCandidacyAnnouncement), ts.api, 0)
+
+		block1.IssuingTime = ts.api.TimeProvider().SlotStartTime(1)
+		block1.IssuerID = issuer1
+
+		block2 := tpkg.RandProtocolBlock(tpkg.RandBasicBlock(ts.api, iotago.PayloadCandidacyAnnouncement), ts.api, 0)
+
+		block2.IssuingTime = ts.api.TimeProvider().SlotStartTime(2)
+		block2.IssuerID = issuer2
+
+		block3 := tpkg.RandProtocolBlock(tpkg.RandBasicBlock(ts.api, iotago.PayloadCandidacyAnnouncement), ts.api, 0)
+
+		block3.IssuingTime = ts.api.TimeProvider().SlotStartTime(3)
+		block3.IssuerID = issuer3
+
+		ts.Instance.TrackCandidateBlock(blocks.NewBlock(lo.PanicOnErr(model.BlockFromBlock(block1))))
+		ts.Instance.TrackCandidateBlock(blocks.NewBlock(lo.PanicOnErr(model.BlockFromBlock(block2))))
+		ts.Instance.TrackCandidateBlock(blocks.NewBlock(lo.PanicOnErr(model.BlockFromBlock(block3))))
+	}
+
+	{
+		block4 := tpkg.RandProtocolBlock(tpkg.RandBasicBlock(ts.api, iotago.PayloadCandidacyAnnouncement), ts.api, 0)
+		block4.IssuingTime = ts.api.TimeProvider().SlotStartTime(4)
+		block4.IssuerID = issuer1
+
+		block5 := tpkg.RandProtocolBlock(tpkg.RandBasicBlock(ts.api, iotago.PayloadCandidacyAnnouncement), ts.api, 0)
+
+		block5.IssuingTime = ts.api.TimeProvider().SlotStartTime(5)
+		block5.IssuerID = issuer2
+
+		block6 := tpkg.RandProtocolBlock(tpkg.RandBasicBlock(ts.api, iotago.PayloadCandidacyAnnouncement), ts.api, 0)
+
+		block6.IssuingTime = ts.api.TimeProvider().SlotStartTime(6)
+		block6.IssuerID = issuer3
+
+		ts.Instance.TrackCandidateBlock(blocks.NewBlock(lo.PanicOnErr(model.BlockFromBlock(block4))))
+		ts.Instance.TrackCandidateBlock(blocks.NewBlock(lo.PanicOnErr(model.BlockFromBlock(block5))))
+		ts.Instance.TrackCandidateBlock(blocks.NewBlock(lo.PanicOnErr(model.BlockFromBlock(block6))))
+	}
+
+	require.True(t, lo.PanicOnErr(ts.Instance.EligibleValidatorCandidates(1)).HasAll(ds.NewReadableSet(issuer1, issuer2, issuer3)))
+	require.True(t, lo.PanicOnErr(ts.Instance.ValidatorCandidates(1)).HasAll(ds.NewReadableSet(issuer1, issuer2, issuer3)))
+	require.True(t, lo.PanicOnErr(ts.Instance.EligibleValidatorCandidates(2)).IsEmpty())
+	require.True(t, lo.PanicOnErr(ts.Instance.ValidatorCandidates(2)).IsEmpty())
+
+	// retrieve epoch candidates for epoch 0, because we candidates prefixed with epoch in which they candidated
+	candidatesStore, err := ts.Instance.committeeCandidatesInEpochFunc(0)
+	require.NoError(t, err)
+
+	candidacySlotIssuer1, err := candidatesStore.Get(issuer1[:])
+	require.NoError(t, err)
+	require.Equal(t, iotago.SlotIndex(1).MustBytes(), candidacySlotIssuer1)
+
+	candidacySlotIssuer2, err := candidatesStore.Get(issuer2[:])
+	require.NoError(t, err)
+	require.Equal(t, iotago.SlotIndex(2).MustBytes(), candidacySlotIssuer2)
+
+	candidacySlotIssuer3, err := candidatesStore.Get(issuer3[:])
+	require.NoError(t, err)
+	require.Equal(t, iotago.SlotIndex(3).MustBytes(), candidacySlotIssuer3)
+
+	ts.Instance.ClearCandidates()
 
+	require.True(t, ts.Instance.nextEpochCommitteeCandidates.IsEmpty())
 }
diff --git a/pkg/tests/committee_rotation_test.go b/pkg/tests/committee_rotation_test.go
new file mode 100644
index 00000000..56b4f397
--- /dev/null
+++ b/pkg/tests/committee_rotation_test.go
@@ -0,0 +1,115 @@
+package tests
+
+import (
+	"testing"
+
+	"github.com/iotaledger/hive.go/runtime/options"
+	"github.com/iotaledger/iota-core/pkg/protocol"
+	"github.com/iotaledger/iota-core/pkg/protocol/snapshotcreator"
+	"github.com/iotaledger/iota-core/pkg/protocol/sybilprotection/seatmanager/topstakers"
+	"github.com/iotaledger/iota-core/pkg/protocol/sybilprotection/sybilprotectionv1"
+	"github.com/iotaledger/iota-core/pkg/testsuite"
+	iotago "github.com/iotaledger/iota.go/v4"
+)
+
+func Test_TopStakersRotation(t *testing.T) {
+	ts := testsuite.NewTestSuite(t,
+		testsuite.WithProtocolParametersOptions(
+			iotago.WithTimeProviderOptions(
+				testsuite.GenesisTimeWithOffsetBySlots(1000, testsuite.DefaultSlotDurationInSeconds),
+				testsuite.DefaultSlotDurationInSeconds,
+				4,
+			),
+			iotago.WithLivenessOptions(
+				10,
+				10,
+				3,
+				4,
+				5,
+			),
+		),
+		testsuite.WithSnapshotOptions(
+			snapshotcreator.WithSeatManagerProvider(
+				topstakers.NewProvider(
+					topstakers.WithSeatCount(3),
+				),
+			),
+		),
+	)
+	defer ts.Shutdown()
+
+	ts.AddValidatorNode("node1", 1_000_006)
+	ts.AddValidatorNode("node2", 1_000_005)
+	ts.AddValidatorNode("node3", 1_000_004)
+	ts.AddValidatorNode("node4", 1_000_003)
+	ts.AddValidatorNode("node5", 1_000_002)
+	ts.AddValidatorNode("node6", 1_000_001)
+
+	nodeOptions := make(map[string][]options.Option[protocol.Protocol])
+
+	for _, node := range ts.Nodes() {
+		nodeOptions[node.Name] = []options.Option[protocol.Protocol]{protocol.WithSybilProtectionProvider(
+			sybilprotectionv1.NewProvider(
+				sybilprotectionv1.WithSeatManagerProvider(
+					topstakers.NewProvider(
+						topstakers.WithSeatCount(3),
+					),
+				),
+			),
+		)}
+	}
+	ts.Run(true, nodeOptions)
+
+	for _, node := range ts.Nodes() {
+		nodeOptions[node.Name] = []options.Option[protocol.Protocol]{protocol.WithSybilProtectionProvider(
+			sybilprotectionv1.NewProvider(
+				sybilprotectionv1.WithSeatManagerProvider(
+					topstakers.NewProvider(topstakers.WithSeatCount(3)),
+				),
+			),
+		)}
+	}
+	ts.AssertSybilProtectionCommittee(0, []iotago.AccountID{
+		ts.Node("node1").Validator.AccountID,
+		ts.Node("node2").Validator.AccountID,
+		ts.Node("node3").Validator.AccountID,
+	}, ts.Nodes()...)
+
+	ts.IssueBlocksAtSlots("wave-1:", []iotago.SlotIndex{1, 2, 3, 4}, 4, "Genesis", ts.Nodes(), true, nil)
+
+	ts.IssueCandidacyAnnouncementInSlot("node1-candidacy:1", 4, "wave-1:4.3", ts.Node("node1"))
+	ts.IssueCandidacyAnnouncementInSlot("node4-candidacy:1", 5, "node1-candidacy:1", ts.Node("node4"))
+
+	ts.IssueBlocksAtSlots("wave-2:", []iotago.SlotIndex{5, 6, 7, 8, 9}, 4, "node4-candidacy:1", ts.Nodes(), true, nil)
+
+	ts.IssueCandidacyAnnouncementInSlot("node4-candidacy:2", 9, "wave-2:9.3", ts.Node("node4"))
+	ts.IssueCandidacyAnnouncementInSlot("node5-candidacy:1", 9, "node4-candidacy:2", ts.Node("node5"))
+
+	// This candidacy should be considered as it's announced at the last possible slot.
+	ts.IssueCandidacyAnnouncementInSlot("node6-candidacy:1", 10, "node5-candidacy:1", ts.Node("node6"))
+
+	ts.IssueBlocksAtSlots("wave-3:", []iotago.SlotIndex{10}, 4, "node6-candidacy:1", ts.Nodes(), true, nil)
+
+	// Those candidacies should not be considered as they're issued after EpochNearingThreshold (slot 10).
+	ts.IssueCandidacyAnnouncementInSlot("node2-candidacy:1", 11, "wave-3:10.3", ts.Node("node2"))
+	ts.IssueCandidacyAnnouncementInSlot("node3-candidacy:1", 11, "node2-candidacy:1", ts.Node("node3"))
+	ts.IssueCandidacyAnnouncementInSlot("node4-candidacy:3", 11, "node3-candidacy:1", ts.Node("node3"))
+	ts.IssueCandidacyAnnouncementInSlot("node5-candidacy:2", 11, "node4-candidacy:3", ts.Node("node3"))
+
+	// Assert that only candidates that issued before slot 11 are considered.
+	ts.AssertSybilProtectionCandidates(1, []iotago.AccountID{
+		ts.Node("node1").Validator.AccountID,
+		ts.Node("node4").Validator.AccountID,
+		ts.Node("node5").Validator.AccountID,
+		ts.Node("node6").Validator.AccountID,
+	}, ts.Nodes()...)
+
+	ts.IssueBlocksAtSlots("wave-4:", []iotago.SlotIndex{11, 12, 13, 14, 15, 16, 17}, 4, "node5-candidacy:2", ts.Nodes(), true, nil)
+
+	ts.AssertLatestFinalizedSlot(13, ts.Nodes()...)
+	ts.AssertSybilProtectionCommittee(1, []iotago.AccountID{
+		ts.Node("node1").Validator.AccountID,
+		ts.Node("node4").Validator.AccountID,
+		ts.Node("node5").Validator.AccountID,
+	}, ts.Nodes()...)
+}
diff --git a/pkg/tests/confirmation_state_test.go b/pkg/tests/confirmation_state_test.go
index 4a0285d0..c874dd40 100644
--- a/pkg/tests/confirmation_state_test.go
+++ b/pkg/tests/confirmation_state_test.go
@@ -108,7 +108,7 @@ func TestConfirmationFlags(t *testing.T) {
 		testsuite.WithChainID(genesisCommitment.MustID()),
 		testsuite.WithStorageCommitments([]*iotago.Commitment{genesisCommitment}),
 		testsuite.WithSybilProtectionCommittee(0, expectedCommittee),
-		testsuite.WithSybilProtectionOnlineCommittee(lo.Return1(nodeA.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(nodeA.Validator.AccountID))),
+		testsuite.WithSybilProtectionOnlineCommittee(lo.Return1(lo.Return1(nodeA.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(nodeA.Validator.AccountID))),
 		testsuite.WithEvictedSlot(0),
 		testsuite.WithActiveRootBlocks(ts.Blocks("Genesis")),
 		testsuite.WithStorageRootBlocks(ts.Blocks("Genesis")),
@@ -163,10 +163,10 @@ func TestConfirmationFlags(t *testing.T) {
 			testsuite.WithLatestFinalizedSlot(0),
 			testsuite.WithLatestCommitmentSlotIndex(2),
 			testsuite.WithEqualStoredCommitmentAtIndex(2),
-			testsuite.WithSybilProtectionCommittee(slot2CommittableSlot, expectedCommittee),
+			testsuite.WithSybilProtectionCommittee(ts.API.TimeProvider().EpochFromSlot(slot2CommittableSlot), expectedCommittee),
 			testsuite.WithSybilProtectionOnlineCommittee(
-				lo.Return1(nodeA.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(nodeA.Validator.AccountID)),
-				lo.Return1(nodeA.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(nodeB.Validator.AccountID)),
+				lo.Return1(lo.Return1(nodeA.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(nodeA.Validator.AccountID)),
+				lo.Return1(lo.Return1(nodeA.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(nodeB.Validator.AccountID)),
 			),
 			testsuite.WithEvictedSlot(2),
 		)
@@ -203,11 +203,11 @@ func TestConfirmationFlags(t *testing.T) {
 			testsuite.WithLatestFinalizedSlot(0),
 			testsuite.WithLatestCommitmentSlotIndex(2),
 			testsuite.WithEqualStoredCommitmentAtIndex(2),
-			testsuite.WithSybilProtectionCommittee(slot3CommittableSlot, expectedCommittee),
+			testsuite.WithSybilProtectionCommittee(ts.API.TimeProvider().EpochFromSlot(slot3CommittableSlot), expectedCommittee),
 			testsuite.WithSybilProtectionOnlineCommittee(
-				lo.Return1(nodeA.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(nodeA.Validator.AccountID)),
-				lo.Return1(nodeA.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(nodeB.Validator.AccountID)),
-				lo.Return1(nodeA.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(nodeC.Validator.AccountID)),
+				lo.Return1(lo.Return1(nodeA.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(nodeA.Validator.AccountID)),
+				lo.Return1(lo.Return1(nodeA.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(nodeB.Validator.AccountID)),
+				lo.Return1(lo.Return1(nodeA.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(nodeC.Validator.AccountID)),
 			),
 			testsuite.WithEvictedSlot(2),
 		)
@@ -258,11 +258,11 @@ func TestConfirmationFlags(t *testing.T) {
 			testsuite.WithLatestFinalizedSlot(1),
 			testsuite.WithLatestCommitmentSlotIndex(3),
 			testsuite.WithEqualStoredCommitmentAtIndex(3),
-			testsuite.WithSybilProtectionCommittee(slot4CommittableSlot, expectedCommittee),
+			testsuite.WithSybilProtectionCommittee(ts.API.TimeProvider().EpochFromSlot(slot4CommittableSlot), expectedCommittee),
 			testsuite.WithSybilProtectionOnlineCommittee(
-				lo.Return1(nodeA.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(nodeA.Validator.AccountID)),
-				lo.Return1(nodeA.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(nodeB.Validator.AccountID)),
-				lo.Return1(nodeA.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(nodeC.Validator.AccountID)),
+				lo.Return1(lo.Return1(nodeA.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(nodeA.Validator.AccountID)),
+				lo.Return1(lo.Return1(nodeA.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(nodeB.Validator.AccountID)),
+				lo.Return1(lo.Return1(nodeA.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(nodeC.Validator.AccountID)),
 			),
 			testsuite.WithEvictedSlot(3),
 		)
diff --git a/pkg/tests/protocol_engine_rollback_test.go b/pkg/tests/protocol_engine_rollback_test.go
index 1ab75a4e..e99339b1 100644
--- a/pkg/tests/protocol_engine_rollback_test.go
+++ b/pkg/tests/protocol_engine_rollback_test.go
@@ -65,7 +65,6 @@ func TestProtocol_EngineRollbackFinalization(t *testing.T) {
 			return poa
 		})
 	}
-
 	nodeOptions := make(map[string][]options.Option[protocol.Protocol])
 	for _, node := range ts.Nodes() {
 		nodeOptions[node.Name] = []options.Option[protocol.Protocol]{
@@ -105,10 +104,10 @@ func TestProtocol_EngineRollbackFinalization(t *testing.T) {
 		node3.Validator.AccountID,
 	}
 	expectedOnlineCommitteeFull := []account.SeatIndex{
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node0.Validator.AccountID)),
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node1.Validator.AccountID)),
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node2.Validator.AccountID)),
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node3.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node0.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node1.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node2.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node3.Validator.AccountID)),
 	}
 
 	for _, node := range ts.Nodes() {
@@ -144,7 +143,7 @@ func TestProtocol_EngineRollbackFinalization(t *testing.T) {
 			testsuite.WithLatestCommitmentSlotIndex(9),
 			testsuite.WithEqualStoredCommitmentAtIndex(9),
 			testsuite.WithLatestCommitmentCumulativeWeight(28), // 7 for each slot starting from 4
-			testsuite.WithSybilProtectionCommittee(9, expectedCommittee),
+			testsuite.WithSybilProtectionCommittee(ts.API.TimeProvider().EpochFromSlot(9), expectedCommittee),
 			testsuite.WithSybilProtectionOnlineCommittee(expectedOnlineCommitteeFull...),
 			testsuite.WithEvictedSlot(9),
 		)
@@ -170,7 +169,7 @@ func TestProtocol_EngineRollbackFinalization(t *testing.T) {
 			testsuite.WithLatestCommitmentSlotIndex(14),
 			testsuite.WithEqualStoredCommitmentAtIndex(14),
 			testsuite.WithLatestCommitmentCumulativeWeight(48), // 7 for each slot starting from 4
-			testsuite.WithSybilProtectionCommittee(14, expectedCommittee),
+			testsuite.WithSybilProtectionCommittee(ts.API.TimeProvider().EpochFromSlot(14), expectedCommittee),
 			testsuite.WithSybilProtectionOnlineCommittee(expectedOnlineCommitteeFull...),
 			testsuite.WithEvictedSlot(14),
 		)
@@ -292,15 +291,15 @@ func TestProtocol_EngineRollbackNoFinalization(t *testing.T) {
 		node3.Validator.AccountID,
 	}
 	expectedOnlineCommitteeFull := []account.SeatIndex{
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node0.Validator.AccountID)),
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node1.Validator.AccountID)),
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node2.Validator.AccountID)),
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node3.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node0.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node1.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node2.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node3.Validator.AccountID)),
 	}
 
 	expectedOnlineCommitteeHalf := []account.SeatIndex{
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node0.Validator.AccountID)),
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node1.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node0.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node1.Validator.AccountID)),
 	}
 
 	for _, node := range ts.Nodes() {
@@ -327,7 +326,7 @@ func TestProtocol_EngineRollbackNoFinalization(t *testing.T) {
 	}
 
 	// Issue up to slot 11 - just before committee selection for the next epoch.
-	// Committee will be reused at slot 10 is finalized or slot 12 is committed, whichever happens first.
+	// Committee will be reused when slot 10 is finalized or slot 12 is committed, whichever happens first.
 	{
 		ts.IssueBlocksAtSlots("P0:", []iotago.SlotIndex{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}, 4, "Genesis", ts.Nodes(), true, nil)
 
@@ -336,7 +335,7 @@ func TestProtocol_EngineRollbackNoFinalization(t *testing.T) {
 			testsuite.WithLatestCommitmentSlotIndex(9),
 			testsuite.WithEqualStoredCommitmentAtIndex(9),
 			testsuite.WithLatestCommitmentCumulativeWeight(28), // 7 for each slot starting from 4
-			testsuite.WithSybilProtectionCommittee(9, expectedCommittee),
+			testsuite.WithSybilProtectionCommittee(ts.API.TimeProvider().EpochFromSlot(9), expectedCommittee),
 			testsuite.WithSybilProtectionOnlineCommittee(expectedOnlineCommitteeFull...),
 			testsuite.WithEvictedSlot(9),
 		)
@@ -369,7 +368,7 @@ func TestProtocol_EngineRollbackNoFinalization(t *testing.T) {
 			testsuite.WithLatestCommitmentSlotIndex(14),
 			testsuite.WithEqualStoredCommitmentAtIndex(14),
 			testsuite.WithLatestCommitmentCumulativeWeight(44), // 7 for each slot starting from 4
-			testsuite.WithSybilProtectionCommittee(14, expectedCommittee),
+			testsuite.WithSybilProtectionCommittee(ts.API.TimeProvider().EpochFromSlot(14), expectedCommittee),
 			testsuite.WithSybilProtectionOnlineCommittee(expectedOnlineCommitteeHalf...),
 			testsuite.WithEvictedSlot(14),
 		)
@@ -491,15 +490,15 @@ func TestProtocol_EngineRollbackNoFinalizationLastSlot(t *testing.T) {
 		node3.Validator.AccountID,
 	}
 	expectedOnlineCommitteeFull := []account.SeatIndex{
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node0.Validator.AccountID)),
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node1.Validator.AccountID)),
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node2.Validator.AccountID)),
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node3.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node0.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node1.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node2.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node3.Validator.AccountID)),
 	}
 
 	expectedOnlineCommitteeHalf := []account.SeatIndex{
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node0.Validator.AccountID)),
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node1.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node0.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node1.Validator.AccountID)),
 	}
 
 	for _, node := range ts.Nodes() {
@@ -535,7 +534,7 @@ func TestProtocol_EngineRollbackNoFinalizationLastSlot(t *testing.T) {
 			testsuite.WithLatestCommitmentSlotIndex(9),
 			testsuite.WithEqualStoredCommitmentAtIndex(9),
 			testsuite.WithLatestCommitmentCumulativeWeight(28), // 7 for each slot starting from 4
-			testsuite.WithSybilProtectionCommittee(9, expectedCommittee),
+			testsuite.WithSybilProtectionCommittee(ts.API.TimeProvider().EpochFromSlot(9), expectedCommittee),
 			testsuite.WithSybilProtectionOnlineCommittee(expectedOnlineCommitteeFull...),
 			testsuite.WithEvictedSlot(9),
 		)
@@ -568,7 +567,7 @@ func TestProtocol_EngineRollbackNoFinalizationLastSlot(t *testing.T) {
 			testsuite.WithLatestCommitmentSlotIndex(17),
 			testsuite.WithEqualStoredCommitmentAtIndex(17),
 			testsuite.WithLatestCommitmentCumulativeWeight(50), // 7 for each slot starting from 4
-			testsuite.WithSybilProtectionCommittee(17, expectedCommittee),
+			testsuite.WithSybilProtectionCommittee(ts.API.TimeProvider().EpochFromSlot(17), expectedCommittee),
 			testsuite.WithSybilProtectionOnlineCommittee(expectedOnlineCommitteeHalf...),
 			testsuite.WithEvictedSlot(17),
 		)
@@ -690,15 +689,15 @@ func TestProtocol_EngineRollbackNoFinalizationBeforePointOfNoReturn(t *testing.T
 		node3.Validator.AccountID,
 	}
 	expectedOnlineCommitteeFull := []account.SeatIndex{
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node0.Validator.AccountID)),
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node1.Validator.AccountID)),
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node2.Validator.AccountID)),
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node3.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node0.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node1.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node2.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node3.Validator.AccountID)),
 	}
 
 	expectedOnlineCommitteeHalf := []account.SeatIndex{
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node0.Validator.AccountID)),
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node1.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node0.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node1.Validator.AccountID)),
 	}
 
 	for _, node := range ts.Nodes() {
@@ -734,7 +733,7 @@ func TestProtocol_EngineRollbackNoFinalizationBeforePointOfNoReturn(t *testing.T
 			testsuite.WithLatestCommitmentSlotIndex(9),
 			testsuite.WithEqualStoredCommitmentAtIndex(9),
 			testsuite.WithLatestCommitmentCumulativeWeight(28), // 7 for each slot starting from 4
-			testsuite.WithSybilProtectionCommittee(9, expectedCommittee),
+			testsuite.WithSybilProtectionCommittee(ts.API.TimeProvider().EpochFromSlot(9), expectedCommittee),
 			testsuite.WithSybilProtectionOnlineCommittee(expectedOnlineCommitteeFull...),
 			testsuite.WithEvictedSlot(9),
 		)
@@ -767,7 +766,7 @@ func TestProtocol_EngineRollbackNoFinalizationBeforePointOfNoReturn(t *testing.T
 			testsuite.WithLatestCommitmentSlotIndex(13),
 			testsuite.WithEqualStoredCommitmentAtIndex(13),
 			testsuite.WithLatestCommitmentCumulativeWeight(42), // 7 for each slot starting from 4
-			testsuite.WithSybilProtectionCommittee(13, expectedCommittee),
+			testsuite.WithSybilProtectionCommittee(ts.API.TimeProvider().EpochFromSlot(13), expectedCommittee),
 			testsuite.WithSybilProtectionOnlineCommittee(expectedOnlineCommitteeHalf...),
 			testsuite.WithEvictedSlot(13),
 		)
diff --git a/pkg/tests/protocol_engine_switching_test.go b/pkg/tests/protocol_engine_switching_test.go
index fdec6c46..b0c262f1 100644
--- a/pkg/tests/protocol_engine_switching_test.go
+++ b/pkg/tests/protocol_engine_switching_test.go
@@ -124,15 +124,15 @@ func TestProtocol_EngineSwitching(t *testing.T) {
 		node7.Validator.AccountID,
 	}
 	expectedP1OnlineCommittee := []account.SeatIndex{
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node0.Validator.AccountID)),
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node1.Validator.AccountID)),
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node2.Validator.AccountID)),
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node3.Validator.AccountID)),
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node4.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node0.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node1.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node2.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node3.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node4.Validator.AccountID)),
 	}
 	expectedP2OnlineCommittee := []account.SeatIndex{
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node6.Validator.AccountID)),
-		lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(node7.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node6.Validator.AccountID)),
+		lo.Return1(lo.Return1(node0.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(node7.Validator.AccountID)),
 	}
 	expectedOnlineCommittee := append(expectedP1OnlineCommittee, expectedP2OnlineCommittee...)
 
@@ -170,7 +170,7 @@ func TestProtocol_EngineSwitching(t *testing.T) {
 			testsuite.WithLatestCommitmentSlotIndex(11),
 			testsuite.WithEqualStoredCommitmentAtIndex(11),
 			testsuite.WithLatestCommitmentCumulativeWeight(56), // 7 for each slot starting from 4
-			testsuite.WithSybilProtectionCommittee(11, expectedCommittee),
+			testsuite.WithSybilProtectionCommittee(ts.API.TimeProvider().EpochFromSlot(11), expectedCommittee),
 			testsuite.WithSybilProtectionOnlineCommittee(expectedOnlineCommittee...),
 			testsuite.WithEvictedSlot(11),
 		)
@@ -225,7 +225,7 @@ func TestProtocol_EngineSwitching(t *testing.T) {
 			testsuite.WithLatestCommitmentSlotIndex(18),
 			testsuite.WithEqualStoredCommitmentAtIndex(18),
 			testsuite.WithLatestCommitmentCumulativeWeight(99), // 56 + slot 12-15=7 + 5 for each slot starting from 16
-			testsuite.WithSybilProtectionCommittee(18, expectedCommittee),
+			testsuite.WithSybilProtectionCommittee(ts.API.TimeProvider().EpochFromSlot(18), expectedCommittee),
 			testsuite.WithSybilProtectionOnlineCommittee(expectedP1OnlineCommittee...),
 			testsuite.WithEvictedSlot(18),
 		)
@@ -279,7 +279,7 @@ func TestProtocol_EngineSwitching(t *testing.T) {
 			testsuite.WithLatestCommitmentSlotIndex(18),
 			testsuite.WithEqualStoredCommitmentAtIndex(18),
 			testsuite.WithLatestCommitmentCumulativeWeight(90), // 56 + slot 12-15=7 + 2 for each slot starting from 16
-			testsuite.WithSybilProtectionCommittee(18, expectedCommittee),
+			testsuite.WithSybilProtectionCommittee(ts.API.TimeProvider().EpochFromSlot(18), expectedCommittee),
 			testsuite.WithSybilProtectionOnlineCommittee(expectedP2OnlineCommittee...),
 			testsuite.WithEvictedSlot(18),
 		)
diff --git a/pkg/tests/protocol_startup_test.go b/pkg/tests/protocol_startup_test.go
index b624b62c..20e957be 100644
--- a/pkg/tests/protocol_startup_test.go
+++ b/pkg/tests/protocol_startup_test.go
@@ -58,7 +58,7 @@ func Test_BookInCommittedSlot(t *testing.T) {
 	}
 
 	expectedOnlineCommittee := []account.SeatIndex{
-		lo.Return1(nodeA.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(nodeA.Validator.AccountID)),
+		lo.Return1(lo.Return1(nodeA.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(nodeA.Validator.AccountID)),
 	}
 
 	// Verify that nodes have the expected states.
@@ -168,8 +168,8 @@ func Test_StartNodeFromSnapshotAndDisk(t *testing.T) {
 	}
 
 	expectedOnlineCommittee := []account.SeatIndex{
-		lo.Return1(nodeA.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(nodeA.Validator.AccountID)),
-		lo.Return1(nodeA.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(1).GetSeat(nodeB.Validator.AccountID)),
+		lo.Return1(lo.Return1(nodeA.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(nodeA.Validator.AccountID)),
+		lo.Return1(lo.Return1(nodeA.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(1)).GetSeat(nodeB.Validator.AccountID)),
 	}
 
 	// Verify that nodes have the expected states.
@@ -222,7 +222,7 @@ func Test_StartNodeFromSnapshotAndDisk(t *testing.T) {
 			testsuite.WithLatestCommitmentSlotIndex(5),
 			testsuite.WithEqualStoredCommitmentAtIndex(5),
 			testsuite.WithLatestCommitmentCumulativeWeight(4), // 2 for each slot starting from 4
-			testsuite.WithSybilProtectionCommittee(5, expectedCommittee),
+			testsuite.WithSybilProtectionCommittee(ts.API.TimeProvider().EpochFromSlot(5), expectedCommittee),
 			testsuite.WithSybilProtectionOnlineCommittee(expectedOnlineCommittee...),
 			testsuite.WithEvictedSlot(5),
 			testsuite.WithActiveRootBlocks(expectedActiveRootBlocks),
@@ -270,7 +270,7 @@ func Test_StartNodeFromSnapshotAndDisk(t *testing.T) {
 			testsuite.WithLatestCommitmentSlotIndex(11),
 			testsuite.WithEqualStoredCommitmentAtIndex(11),
 			testsuite.WithLatestCommitmentCumulativeWeight(16), // 2 for each slot starting from 4
-			testsuite.WithSybilProtectionCommittee(11, expectedCommittee),
+			testsuite.WithSybilProtectionCommittee(ts.API.TimeProvider().EpochFromSlot(11), expectedCommittee),
 			testsuite.WithSybilProtectionOnlineCommittee(expectedOnlineCommittee...),
 			testsuite.WithEvictedSlot(11),
 			testsuite.WithActiveRootBlocks(expectedActiveRootBlocks),
@@ -354,7 +354,7 @@ func Test_StartNodeFromSnapshotAndDisk(t *testing.T) {
 				testsuite.WithLatestCommitmentSlotIndex(11),
 				testsuite.WithEqualStoredCommitmentAtIndex(11),
 				testsuite.WithLatestCommitmentCumulativeWeight(16), // 2 for each slot starting from 4
-				testsuite.WithSybilProtectionCommittee(11, expectedCommittee),
+				testsuite.WithSybilProtectionCommittee(ts.API.TimeProvider().EpochFromSlot(11), expectedCommittee),
 				testsuite.WithSybilProtectionOnlineCommittee(expectedOnlineCommittee...),
 				testsuite.WithEvictedSlot(11),
 				testsuite.WithActiveRootBlocks(expectedActiveRootBlocks),
@@ -410,7 +410,7 @@ func Test_StartNodeFromSnapshotAndDisk(t *testing.T) {
 			testsuite.WithLatestCommitmentSlotIndex(37),
 			testsuite.WithEqualStoredCommitmentAtIndex(37),
 			testsuite.WithLatestCommitmentCumulativeWeight(68), // 2 for each slot starting from 4
-			testsuite.WithSybilProtectionCommittee(37, expectedCommittee),
+			testsuite.WithSybilProtectionCommittee(ts.API.TimeProvider().EpochFromSlot(37), expectedCommittee),
 			testsuite.WithSybilProtectionOnlineCommittee(expectedOnlineCommittee...),
 			testsuite.WithEvictedSlot(37),
 			testsuite.WithActiveRootBlocks(expectedActiveRootBlocks),
diff --git a/pkg/testsuite/mock/blockissuer.go b/pkg/testsuite/mock/blockissuer.go
index 67c8cf28..527bcff1 100644
--- a/pkg/testsuite/mock/blockissuer.go
+++ b/pkg/testsuite/mock/blockissuer.go
@@ -150,7 +150,7 @@ func (i *BlockIssuer) CreateValidationBlock(ctx context.Context, alias string, i
 	blockBuilder.IssuingTime(*blockParams.BlockHeader.IssuingTime)
 
 	strongParents, exists := blockParams.BlockHeader.References[iotago.StrongParentType]
-	require.True(i.Testing, exists && len(strongParents) > 0)
+	require.True(i.Testing, exists && len(strongParents) > 0, "block should have strong parents (exists: %t, parents: %s)", exists, strongParents)
 	blockBuilder.StrongParents(strongParents)
 
 	if weakParents, exists := blockParams.BlockHeader.References[iotago.WeakParentType]; exists {
diff --git a/pkg/testsuite/mock/node.go b/pkg/testsuite/mock/node.go
index d1d77be1..afca4da9 100644
--- a/pkg/testsuite/mock/node.go
+++ b/pkg/testsuite/mock/node.go
@@ -381,6 +381,10 @@ func (n *Node) attachEngineLogsWithName(failOnBlockFiltered bool, instance *engi
 		fmt.Printf("%s > [%s] SybilProtection.OnlineCommitteeSeatRemoved: %d\n", n.Name, engineName, seat)
 	})
 
+	events.SybilProtection.CommitteeSelected.Hook(func(committee *account.Accounts, epoch iotago.EpochIndex) {
+		fmt.Printf("%s > [%s] SybilProtection.CommitteeSelected: epoch %d - %s\n", n.Name, engineName, epoch, committee.IDs())
+	})
+
 	events.ConflictDAG.ConflictCreated.Hook(func(conflictID iotago.TransactionID) {
 		fmt.Printf("%s > [%s] ConflictDAG.ConflictCreated: %s\n", n.Name, engineName, conflictID)
 	})
diff --git a/pkg/testsuite/node_state.go b/pkg/testsuite/node_state.go
index 88b7ee2b..65b60c72 100644
--- a/pkg/testsuite/node_state.go
+++ b/pkg/testsuite/node_state.go
@@ -32,8 +32,8 @@ func (t *TestSuite) AssertNodeState(nodes []*mock.Node, opts ...options.Option[N
 	if state.chainID != nil {
 		t.AssertChainID(*state.chainID, nodes...)
 	}
-	if state.sybilProtectionCommitteeSlot != nil && state.sybilProtectionCommittee != nil {
-		t.AssertSybilProtectionCommittee(*state.sybilProtectionCommitteeSlot, *state.sybilProtectionCommittee, nodes...)
+	if state.sybilProtectionCommitteeEpoch != nil && state.sybilProtectionCommittee != nil {
+		t.AssertSybilProtectionCommittee(*state.sybilProtectionCommitteeEpoch, *state.sybilProtectionCommittee, nodes...)
 	}
 	if state.sybilProtectionOnlineCommittee != nil {
 		t.AssertSybilProtectionOnlineCommittee(*state.sybilProtectionOnlineCommittee, nodes...)
@@ -67,7 +67,7 @@ type NodeState struct {
 	latestFinalizedSlot              *iotago.SlotIndex
 	chainID                          *iotago.CommitmentID
 
-	sybilProtectionCommitteeSlot   *iotago.SlotIndex
+	sybilProtectionCommitteeEpoch  *iotago.EpochIndex
 	sybilProtectionCommittee       *[]iotago.AccountID
 	sybilProtectionOnlineCommittee *[]account.SeatIndex
 
@@ -130,9 +130,9 @@ func WithChainID(chainID iotago.CommitmentID) options.Option[NodeState] {
 	}
 }
 
-func WithSybilProtectionCommittee(slot iotago.SlotIndex, committee []iotago.AccountID) options.Option[NodeState] {
+func WithSybilProtectionCommittee(epoch iotago.EpochIndex, committee []iotago.AccountID) options.Option[NodeState] {
 	return func(state *NodeState) {
-		state.sybilProtectionCommitteeSlot = &slot
+		state.sybilProtectionCommitteeEpoch = &epoch
 		state.sybilProtectionCommittee = &committee
 	}
 }
diff --git a/pkg/testsuite/sybilprotection.go b/pkg/testsuite/sybilprotection.go
index edbfe0fe..3eac2c52 100644
--- a/pkg/testsuite/sybilprotection.go
+++ b/pkg/testsuite/sybilprotection.go
@@ -2,19 +2,22 @@ package testsuite
 
 import (
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 
 	"github.com/iotaledger/hive.go/ierrors"
+	"github.com/iotaledger/hive.go/lo"
 	"github.com/iotaledger/iota-core/pkg/core/account"
+	"github.com/iotaledger/iota-core/pkg/protocol/engine/accounts"
 	"github.com/iotaledger/iota-core/pkg/testsuite/mock"
 	iotago "github.com/iotaledger/iota.go/v4"
 )
 
-func (t *TestSuite) AssertSybilProtectionCommittee(slot iotago.SlotIndex, expectedAccounts []iotago.AccountID, nodes ...*mock.Node) {
+func (t *TestSuite) AssertSybilProtectionCommittee(epoch iotago.EpochIndex, expectedAccounts []iotago.AccountID, nodes ...*mock.Node) {
 	mustNodes(nodes)
 
 	for _, node := range nodes {
 		t.Eventually(func() error {
-			accounts := node.Protocol.MainEngineInstance().SybilProtection.SeatManager().Committee(slot).Accounts().IDs()
+			accounts := lo.Return1(node.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInEpoch(epoch)).Accounts().IDs()
 			if !assert.ElementsMatch(t.fakeTesting, expectedAccounts, accounts) {
 				return ierrors.Errorf("AssertSybilProtectionCommittee: %s: expected %s, got %s", node.Name, expectedAccounts, accounts)
 			}
@@ -28,6 +31,30 @@ func (t *TestSuite) AssertSybilProtectionCommittee(slot iotago.SlotIndex, expect
 	}
 }
 
+func (t *TestSuite) AssertSybilProtectionCandidates(epoch iotago.EpochIndex, expectedAccounts []iotago.AccountID, nodes ...*mock.Node) {
+	mustNodes(nodes)
+
+	for _, node := range nodes {
+		t.Eventually(func() error {
+			candidates, err := node.Protocol.MainEngineInstance().SybilProtection.EligibleValidators(epoch)
+			candidateIDs := lo.Map(candidates, func(candidate *accounts.AccountData) iotago.AccountID {
+				return candidate.ID
+			})
+			require.NoError(t.Testing, err)
+
+			if !assert.ElementsMatch(t.fakeTesting, expectedAccounts, candidateIDs) {
+				return ierrors.Errorf("AssertSybilProtectionCandidates: %s: expected %s, got %s", node.Name, expectedAccounts, candidateIDs)
+			}
+
+			if len(expectedAccounts) != len(candidates) {
+				return ierrors.Errorf("AssertSybilProtectionCandidates: %s: expected %v, got %v", node.Name, len(expectedAccounts), len(candidateIDs))
+			}
+
+			return nil
+		})
+	}
+}
+
 func (t *TestSuite) AssertSybilProtectionOnlineCommittee(expectedSeats []account.SeatIndex, nodes ...*mock.Node) {
 	mustNodes(nodes)
 
diff --git a/pkg/testsuite/testsuite_issue_blocks.go b/pkg/testsuite/testsuite_issue_blocks.go
index 18ef9ae6..8b07356a 100644
--- a/pkg/testsuite/testsuite_issue_blocks.go
+++ b/pkg/testsuite/testsuite_issue_blocks.go
@@ -168,6 +168,22 @@ func (t *TestSuite) IssueValidationBlock(alias string, node *mock.Node, blockHea
 
 	return block
 }
+func (t *TestSuite) IssueCandidacyAnnouncementInSlot(alias string, slot iotago.SlotIndex, parentsPrefixAlias string, node *mock.Node, issuingOptions ...options.Option[mock.BlockHeaderParams]) *blocks.Block {
+	timeProvider := t.API.TimeProvider()
+	issuingTime := timeProvider.SlotStartTime(slot).Add(time.Duration(t.uniqueBlockTimeCounter.Add(1)))
+	require.Truef(t.Testing, issuingTime.Before(time.Now()), "node: %s: issued block (%s, slot: %d) is in the current (%s, slot: %d) or future slot", node.Name, issuingTime, slot, time.Now(), timeProvider.SlotFromTime(time.Now()))
+
+	return t.IssuePayloadWithOptions(
+		alias,
+		node.Validator,
+		node,
+		&iotago.CandidacyAnnouncement{},
+		append(issuingOptions,
+			mock.WithStrongParents(t.BlockIDsWithPrefix(parentsPrefixAlias)...),
+			mock.WithIssuingTime(issuingTime),
+		)...,
+	)
+}
 
 func (t *TestSuite) IssueBlockRowInSlot(prefix string, slot iotago.SlotIndex, row int, parentsPrefixAlias string, nodes []*mock.Node, issuingOptions map[string][]options.Option[mock.BlockHeaderParams]) []*blocks.Block {
 	blockIssuers := t.BlockIssuersForNodes(nodes)
@@ -185,7 +201,8 @@ func (t *TestSuite) IssueBlockRowInSlot(prefix string, slot iotago.SlotIndex, ro
 		require.Truef(t.Testing, issuingTime.Before(time.Now()), "node: %s: issued block (%s, slot: %d) is in the current (%s, slot: %d) or future slot", node.Name, issuingTime, slot, time.Now(), timeProvider.SlotFromTime(time.Now()))
 
 		var b *blocks.Block
-		if blockIssuers[index].Validator {
+		// Only issue validator blocks if account has staking feature and is part of committee.
+		if blockIssuers[index].Validator && lo.Return1(node.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(slot)).HasAccount(node.Validator.AccountID) {
 			blockHeaderOptions := append(issuingOptionsCopy[node.Name], mock.WithIssuingTime(issuingTime))
 			t.assertParentsCommitmentExistFromBlockOptions(blockHeaderOptions, node)
 			t.assertParentsExistFromBlockOptions(blockHeaderOptions, node)
@@ -295,17 +312,31 @@ func (t *TestSuite) CommitUntilSlot(slot iotago.SlotIndex, parent *blocks.Block)
 		// preacceptance of nextBlockSlot
 		for _, node := range activeValidators {
 			require.True(t.Testing, node.IsValidator(), "node: %s: is not a validator node", node.Name)
-			blockAlias := fmt.Sprintf("chain-%s-%d-%s", parent.ID().Alias(), chainIndex, node.Name)
-			tip = t.IssueValidationBlockAtSlot(blockAlias, nextBlockSlot, node.Protocol.MainEngineInstance().Storage.Settings().LatestCommitment().Commitment(), node, tip.ID())
+			committeeAtBlockSlot, exists := node.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(nextBlockSlot)
+			require.True(t.Testing, exists, "node: %s: does not have committee selected for slot %d", node.Name, nextBlockSlot)
+			if committeeAtBlockSlot.HasAccount(node.Validator.AccountID) {
+				blockAlias := fmt.Sprintf("chain-%s-%d-%s", parent.ID().Alias(), chainIndex, node.Name)
+				tip = t.IssueValidationBlockAtSlot(blockAlias, nextBlockSlot, node.Protocol.MainEngineInstance().Storage.Settings().LatestCommitment().Commitment(), node, tip.ID())
+			}
 		}
 		// acceptance of nextBlockSlot
 		for _, node := range activeValidators {
-			blockAlias := fmt.Sprintf("chain-%s-%d-%s", parent.ID().Alias(), chainIndex+1, node.Name)
-			tip = t.IssueValidationBlockAtSlot(blockAlias, nextBlockSlot, node.Protocol.MainEngineInstance().Storage.Settings().LatestCommitment().Commitment(), node, tip.ID())
+			committeeAtBlockSlot, exists := node.Protocol.MainEngineInstance().SybilProtection.SeatManager().CommitteeInSlot(nextBlockSlot)
+			require.True(t.Testing, exists, "node: %s: does not have committee selected for slot %d", node.Name, nextBlockSlot)
+			if committeeAtBlockSlot.HasAccount(node.Validator.AccountID) {
+				blockAlias := fmt.Sprintf("chain-%s-%d-%s", parent.ID().Alias(), chainIndex+1, node.Name)
+				tip = t.IssueValidationBlockAtSlot(blockAlias, nextBlockSlot, node.Protocol.MainEngineInstance().Storage.Settings().LatestCommitment().Commitment(), node, tip.ID())
+			}
+		}
+
+		for _, node := range activeValidators {
+			t.AssertLatestCommitmentSlotIndex(nextBlockSlot-t.API.ProtocolParameters().MinCommittableAge(), node)
 		}
+
 		if nextBlockSlot == slot+t.API.ProtocolParameters().MinCommittableAge() {
 			break
 		}
+
 		nextBlockSlot = lo.Min(slot+t.API.ProtocolParameters().MinCommittableAge(), nextBlockSlot+t.API.ProtocolParameters().MinCommittableAge())
 		chainIndex += 2
 	}