diff --git a/pkg/protocol/engine/consensus/blockgadget/gadget_test.go b/pkg/protocol/engine/consensus/blockgadget/gadget_test.go index 982731ca9..be92435f6 100644 --- a/pkg/protocol/engine/consensus/blockgadget/gadget_test.go +++ b/pkg/protocol/engine/consensus/blockgadget/gadget_test.go @@ -32,8 +32,7 @@ func TestBlockGadget(t *testing.T) { tf.Events.BlockPreConfirmed.Hook(checkOrder(&expectedPreConfirmationOrder, "pre-confirmation")) tf.Events.BlockConfirmed.Hook(checkOrder(&expectedConfirmationOrder, "confirmation")) - tf.SeatManager.AddRandomAccount("A") - tf.SeatManager.AddRandomAccount("B") + tf.SeatManager.AddRandomAccounts("A", "B") tf.SeatManager.SetOnline("A") tf.SeatManager.SetOnline("B") diff --git a/pkg/protocol/engine/tipmanager/tests/testframework.go b/pkg/protocol/engine/tipmanager/tests/testframework.go index 781dc41b9..a4503b99b 100644 --- a/pkg/protocol/engine/tipmanager/tests/testframework.go +++ b/pkg/protocol/engine/tipmanager/tests/testframework.go @@ -7,11 +7,15 @@ import ( "github.com/stretchr/testify/require" "github.com/iotaledger/hive.go/ds" + "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/tipmanager" tipmanagerv1 "github.com/iotaledger/iota-core/pkg/protocol/engine/tipmanager/v1" + "github.com/iotaledger/iota-core/pkg/protocol/sybilprotection/seatmanager/mock" + "github.com/iotaledger/iota-core/pkg/storage/prunable/epochstore" iotago "github.com/iotaledger/iota.go/v4" "github.com/iotaledger/iota.go/v4/builder" "github.com/iotaledger/iota.go/v4/tpkg" @@ -24,6 +28,9 @@ type TestFramework struct { tipMetadataByAlias map[string]tipmanager.TipMetadata blocksByID map[iotago.BlockID]*blocks.Block test *testing.T + time time.Time + + manualPOA mock.ManualPOA API iotago.API } @@ -35,6 +42,15 @@ func NewTestFramework(test *testing.T) *TestFramework { blocksByID: make(map[iotago.BlockID]*blocks.Block), test: test, API: tpkg.ZeroCostTestAPI, + time: time.Now(), + manualPOA: *mock.NewManualPOA(iotago.SingleVersionProvider(tpkg.ZeroCostTestAPI), + epochstore.NewStore[*account.SeatedAccounts]( + nil, + mapdb.NewMapDB(), + func(index iotago.EpochIndex) iotago.EpochIndex { return index }, + (*account.SeatedAccounts).Bytes, + account.SeatedAccountsFromBytes), + ), } t.blockIDsByAlias["Genesis"] = iotago.EmptyBlockID @@ -42,20 +58,74 @@ func NewTestFramework(test *testing.T) *TestFramework { t.Instance = tipmanagerv1.New(func(blockID iotago.BlockID) (block *blocks.Block, exists bool) { block, exists = t.blocksByID[blockID] return block, exists - }) + }, t.manualPOA.CommitteeInSlot) return t } +func (t *TestFramework) Validator(alias string) iotago.AccountID { + return t.manualPOA.AccountID(alias) +} + +func (t *TestFramework) AddValidators(aliases ...string) { + t.manualPOA.AddRandomAccounts(aliases...) + + for _, alias := range aliases { + seat, exists := t.manualPOA.GetSeat(alias) + if !exists { + panic("seat does not exist") + } + + t.Instance.AddSeat(seat) + } +} + func (t *TestFramework) AddBlock(alias string) tipmanager.TipMetadata { t.tipMetadataByAlias[alias] = t.Instance.AddBlock(t.Block(alias)) + t.tipMetadataByAlias[alias].ID().RegisterAlias(alias) return t.tipMetadataByAlias[alias] } -func (t *TestFramework) CreateBlock(alias string, parents map[iotago.ParentsType][]string, optBlockBuilder ...func(*builder.BasicBlockBuilder)) *blocks.Block { +func (t *TestFramework) CreateBasicBlock(alias string, parents map[iotago.ParentsType][]string, optBlockBuilder ...func(*builder.BasicBlockBuilder)) *blocks.Block { blockBuilder := builder.NewBasicBlockBuilder(t.API) - blockBuilder.IssuingTime(time.Now()) + + // Make sure that blocks don't have the same timestamp. + t.time = t.time.Add(1) + blockBuilder.IssuingTime(t.time) + + if strongParents, strongParentsExist := parents[iotago.StrongParentType]; strongParentsExist { + blockBuilder.StrongParents(lo.Map(strongParents, t.BlockID)) + } + if weakParents, weakParentsExist := parents[iotago.WeakParentType]; weakParentsExist { + blockBuilder.WeakParents(lo.Map(weakParents, t.BlockID)) + } + if shallowLikeParents, shallowLikeParentsExist := parents[iotago.ShallowLikeParentType]; shallowLikeParentsExist { + blockBuilder.ShallowLikeParents(lo.Map(shallowLikeParents, t.BlockID)) + } + + if len(optBlockBuilder) > 0 { + optBlockBuilder[0](blockBuilder) + } + + block, err := blockBuilder.Build() + require.NoError(t.test, err) + + modelBlock, err := model.BlockFromBlock(block) + require.NoError(t.test, err) + + t.blocksByID[modelBlock.ID()] = blocks.NewBlock(modelBlock) + t.blockIDsByAlias[alias] = modelBlock.ID() + + return t.blocksByID[modelBlock.ID()] +} + +func (t *TestFramework) CreateValidationBlock(alias string, parents map[iotago.ParentsType][]string, optBlockBuilder ...func(blockBuilder *builder.ValidationBlockBuilder)) *blocks.Block { + blockBuilder := builder.NewValidationBlockBuilder(t.API) + + // Make sure that blocks don't have the same timestamp. + t.time = t.time.Add(1) + blockBuilder.IssuingTime(t.time) if strongParents, strongParentsExist := parents[iotago.StrongParentType]; strongParentsExist { blockBuilder.StrongParents(lo.Map(strongParents, t.BlockID)) @@ -115,6 +185,14 @@ func (t *TestFramework) RequireStrongTips(aliases ...string) { require.Equal(t.test, len(aliases), len(t.Instance.StrongTips()), "strongTips size does not match") } +func (t *TestFramework) RequireValidationTips(aliases ...string) { + for _, alias := range aliases { + require.True(t.test, ds.NewSet(lo.Map(t.Instance.ValidationTips(), tipmanager.TipMetadata.ID)...).Has(t.BlockID(alias)), "validationTips does not contain block '%s'", alias) + } + + require.Equal(t.test, len(aliases), len(t.Instance.ValidationTips()), "validationTips size does not match") +} + func (t *TestFramework) RequireLivenessThresholdReached(alias string, expected bool) { require.Equal(t.test, expected, t.TipMetadata(alias).LivenessThresholdReached().Get()) } diff --git a/pkg/protocol/engine/tipmanager/tests/tipmanager_test.go b/pkg/protocol/engine/tipmanager/tests/tipmanager_test.go index b994fa882..d141da05d 100644 --- a/pkg/protocol/engine/tipmanager/tests/tipmanager_test.go +++ b/pkg/protocol/engine/tipmanager/tests/tipmanager_test.go @@ -5,18 +5,20 @@ import ( "github.com/iotaledger/iota-core/pkg/protocol/engine/tipmanager" iotago "github.com/iotaledger/iota.go/v4" + "github.com/iotaledger/iota.go/v4/builder" + "github.com/iotaledger/iota.go/v4/tpkg" ) func TestTipManager(t *testing.T) { tf := NewTestFramework(t) - tf.CreateBlock("Bernd", map[iotago.ParentsType][]string{ + tf.CreateBasicBlock("Bernd", map[iotago.ParentsType][]string{ iotago.StrongParentType: {"Genesis"}, }) - tf.CreateBlock("Bernd1", map[iotago.ParentsType][]string{ + tf.CreateBasicBlock("Bernd1", map[iotago.ParentsType][]string{ iotago.StrongParentType: {"Bernd"}, }) - tf.CreateBlock("Bernd1.1", map[iotago.ParentsType][]string{ + tf.CreateBasicBlock("Bernd1.1", map[iotago.ParentsType][]string{ iotago.StrongParentType: {"Bernd"}, }) @@ -33,13 +35,13 @@ func TestTipManager(t *testing.T) { func Test_Orphanage(t *testing.T) { tf := NewTestFramework(t) - tf.CreateBlock("A", map[iotago.ParentsType][]string{ + tf.CreateBasicBlock("A", map[iotago.ParentsType][]string{ iotago.StrongParentType: {"Genesis"}, }) - tf.CreateBlock("B", map[iotago.ParentsType][]string{ + tf.CreateBasicBlock("B", map[iotago.ParentsType][]string{ iotago.StrongParentType: {"Genesis"}, }) - tf.CreateBlock("C", map[iotago.ParentsType][]string{ + tf.CreateBasicBlock("C", map[iotago.ParentsType][]string{ iotago.StrongParentType: {"A", "B"}, }) @@ -56,3 +58,108 @@ func Test_Orphanage(t *testing.T) { blockB.LivenessThresholdReached().Trigger() tf.RequireStrongTips("A") } + +func Test_ValidationTips(t *testing.T) { + tf := NewTestFramework(t) + + tf.AddValidators("validatorA", "validatorB") + + { + tf.CreateBasicBlock("1", map[iotago.ParentsType][]string{ + iotago.StrongParentType: {"Genesis"}, + }) + tf.CreateBasicBlock("2", map[iotago.ParentsType][]string{ + iotago.StrongParentType: {"Genesis"}, + }) + + tf.AddBlock("1").TipPool().Set(tipmanager.StrongTipPool) + tf.AddBlock("2").TipPool().Set(tipmanager.StrongTipPool) + + tf.RequireStrongTips("1", "2") + } + + // Add validation tip for validatorA. + { + tf.CreateValidationBlock("3", map[iotago.ParentsType][]string{ + iotago.StrongParentType: {"2"}, + }, func(blockBuilder *builder.ValidationBlockBuilder) { + blockBuilder.Sign(tf.Validator("validatorA"), tpkg.RandEd25519PrivateKey()) + }) + + tf.AddBlock("3").TipPool().Set(tipmanager.StrongTipPool) + + tf.RequireValidationTips("3") + tf.RequireStrongTips("1", "3") + } + + // Add validation tip for validatorB. + { + tf.CreateValidationBlock("4", map[iotago.ParentsType][]string{ + iotago.StrongParentType: {"1"}, + }, func(blockBuilder *builder.ValidationBlockBuilder) { + blockBuilder.Sign(tf.Validator("validatorB"), tpkg.RandEd25519PrivateKey()) + }) + + tf.AddBlock("4").TipPool().Set(tipmanager.StrongTipPool) + + tf.RequireValidationTips("3", "4") + tf.RequireStrongTips("3", "4") + } + + // Add basic blocks in the future cone of the validation tips, referencing both existing validation tips. + { + tf.CreateBasicBlock("5", map[iotago.ParentsType][]string{ + iotago.StrongParentType: {"3", "4"}, + }) + + tf.AddBlock("5").TipPool().Set(tipmanager.StrongTipPool) + + tf.RequireValidationTips("5") + tf.RequireStrongTips("5") + } + + // Add basic blocks in the future cone of the validation tips. + { + tf.CreateBasicBlock("6", map[iotago.ParentsType][]string{ + iotago.StrongParentType: {"3"}, + }) + tf.CreateBasicBlock("7", map[iotago.ParentsType][]string{ + iotago.StrongParentType: {"4"}, + }) + + tf.AddBlock("6").TipPool().Set(tipmanager.StrongTipPool) + tf.AddBlock("7").TipPool().Set(tipmanager.StrongTipPool) + + tf.RequireValidationTips("5", "6", "7") + tf.RequireStrongTips("5", "6", "7") + } + + // A newer validation block replaces the previous validation tip of that validator. + { + tf.CreateValidationBlock("8", map[iotago.ParentsType][]string{ + iotago.StrongParentType: {"6"}, + }, func(blockBuilder *builder.ValidationBlockBuilder) { + blockBuilder.Sign(tf.Validator("validatorB"), tpkg.RandEd25519PrivateKey()) + }) + + tf.AddBlock("8").TipPool().Set(tipmanager.StrongTipPool) + + tf.RequireValidationTips("5", "8") + tf.RequireStrongTips("5", "7", "8") + } + + // A newer validation block (with parallel past cone) still becomes a validation tip and overwrites + // the previous validation tip of that validator. + { + tf.CreateValidationBlock("9", map[iotago.ParentsType][]string{ + iotago.StrongParentType: {"Genesis"}, + }, func(blockBuilder *builder.ValidationBlockBuilder) { + blockBuilder.Sign(tf.Validator("validatorA"), tpkg.RandEd25519PrivateKey()) + }) + + tf.AddBlock("9").TipPool().Set(tipmanager.StrongTipPool) + + tf.RequireValidationTips("8", "9") + tf.RequireStrongTips("5", "7", "8", "9") + } +} diff --git a/pkg/protocol/engine/tipmanager/tipmanager.go b/pkg/protocol/engine/tipmanager/tipmanager.go index 878f2b670..d3aa6ae4d 100644 --- a/pkg/protocol/engine/tipmanager/tipmanager.go +++ b/pkg/protocol/engine/tipmanager/tipmanager.go @@ -2,6 +2,7 @@ package tipmanager import ( "github.com/iotaledger/hive.go/runtime/module" + "github.com/iotaledger/iota-core/pkg/core/account" "github.com/iotaledger/iota-core/pkg/protocol/engine/blocks" iotago "github.com/iotaledger/iota.go/v4" ) @@ -27,6 +28,15 @@ type TipManager interface { // OnBlockAdded registers a callback that is triggered whenever a new Block was added to the TipManager. OnBlockAdded(handler func(block TipMetadata)) (unsubscribe func()) + // AddSeat adds a validator seat to the tracking of the TipManager. + AddSeat(seat account.SeatIndex) + + // RemoveSeat removes a validator seat from the tracking of the TipManager. + RemoveSeat(seat account.SeatIndex) + + // ValidationTips returns the validation tips of the TipManager (with an optional limit). + ValidationTips(optAmount ...int) []TipMetadata + // StrongTips returns the strong tips of the TipManager (with an optional limit). StrongTips(optAmount ...int) []TipMetadata diff --git a/pkg/protocol/engine/tipmanager/v1/provider.go b/pkg/protocol/engine/tipmanager/v1/provider.go index eb859133d..0a824884a 100644 --- a/pkg/protocol/engine/tipmanager/v1/provider.go +++ b/pkg/protocol/engine/tipmanager/v1/provider.go @@ -5,21 +5,29 @@ import ( "github.com/iotaledger/hive.go/runtime/event" "github.com/iotaledger/hive.go/runtime/module" "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/tipmanager" + iotago "github.com/iotaledger/iota.go/v4" ) // NewProvider creates a new TipManager provider, that can be used to inject the component into an engine. func NewProvider() module.Provider[*engine.Engine, tipmanager.TipManager] { return module.Provide(func(e *engine.Engine) tipmanager.TipManager { - t := New(e.BlockCache.Block) + t := New(e.BlockCache.Block, e.SybilProtection.SeatManager().CommitteeInSlot) e.Constructed.OnTrigger(func() { tipWorker := e.Workers.CreatePool("AddTip", workerpool.WithWorkerCount(2)) + e.Events.Scheduler.BlockScheduled.Hook(lo.Void(t.AddBlock), event.WithWorkerPool(tipWorker)) e.Events.Scheduler.BlockSkipped.Hook(lo.Void(t.AddBlock), event.WithWorkerPool(tipWorker)) e.BlockCache.Evict.Hook(t.Evict) + e.Events.SeatManager.OnlineCommitteeSeatAdded.Hook(func(index account.SeatIndex, _ iotago.AccountID) { + t.AddSeat(index) + }) + e.Events.SeatManager.OnlineCommitteeSeatRemoved.Hook(t.RemoveSeat) + e.Events.TipManager.BlockAdded.LinkTo(t.blockAdded) t.TriggerInitialized() diff --git a/pkg/protocol/engine/tipmanager/v1/tip_metadata.go b/pkg/protocol/engine/tipmanager/v1/tip_metadata.go index dcb28070b..9fcbcf67f 100644 --- a/pkg/protocol/engine/tipmanager/v1/tip_metadata.go +++ b/pkg/protocol/engine/tipmanager/v1/tip_metadata.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/iotaledger/hive.go/ds/reactive" + "github.com/iotaledger/hive.go/lo" "github.com/iotaledger/iota-core/pkg/protocol/engine/blocks" "github.com/iotaledger/iota-core/pkg/protocol/engine/tipmanager" iotago "github.com/iotaledger/iota.go/v4" @@ -48,12 +49,22 @@ type TipMetadata struct { // that is connected to the tips. isReferencedByTips reactive.Variable[bool] + // isLatestValidationBlock is true if the block is the latest block of a validator. + isLatestValidationBlock reactive.Variable[bool] + + // referencesLatestValidationBlock is true if the block is the latest validator block or has parents that reference + // the latest validator block. + referencesLatestValidationBlock reactive.Variable[bool] + // isStrongTip is true if the block is a strong tip pool member and is not strongly referenced by other tips. isStrongTip reactive.Variable[bool] // isWeakTip is true if the block is a weak tip pool member and is not referenced by other tips. isWeakTip reactive.Variable[bool] + // isValidationTip is true if the block is a strong tip and references the latest validator block. + isValidationTip reactive.Variable[bool] + // isMarkedOrphaned is true if the liveness threshold has been reached and the block was not accepted. isMarkedOrphaned reactive.Variable[bool] @@ -85,21 +96,30 @@ type TipMetadata struct { // weaklyOrphanedWeakParents holds the number of weak parents that are weakly orphaned. weaklyOrphanedWeakParents reactive.Counter[bool] + + // parentsReferencingLatestValidationBlock holds the number of parents that reference the latest validator block. + parentsReferencingLatestValidationBlock reactive.Counter[bool] } // NewBlockMetadata creates a new TipMetadata instance. func NewBlockMetadata(block *blocks.Block) *TipMetadata { t := &TipMetadata{ - block: block, - tipPool: reactive.NewVariable[tipmanager.TipPool](tipmanager.TipPool.Max), - livenessThresholdReached: reactive.NewEvent(), - evicted: reactive.NewEvent(), - stronglyConnectedStrongChildren: reactive.NewCounter[bool](), - connectedWeakChildren: reactive.NewCounter[bool](), - stronglyOrphanedStrongParents: reactive.NewCounter[bool](), - weaklyOrphanedWeakParents: reactive.NewCounter[bool](), + block: block, + tipPool: reactive.NewVariable[tipmanager.TipPool](tipmanager.TipPool.Max), + livenessThresholdReached: reactive.NewEvent(), + evicted: reactive.NewEvent(), + isLatestValidationBlock: reactive.NewVariable[bool](), + stronglyConnectedStrongChildren: reactive.NewCounter[bool](), + connectedWeakChildren: reactive.NewCounter[bool](), + stronglyOrphanedStrongParents: reactive.NewCounter[bool](), + weaklyOrphanedWeakParents: reactive.NewCounter[bool](), + parentsReferencingLatestValidationBlock: reactive.NewCounter[bool](), } + t.referencesLatestValidationBlock = reactive.NewDerivedVariable2(func(_ bool, isLatestValidationBlock bool, parentsReferencingLatestValidationBlock int) bool { + return isLatestValidationBlock || parentsReferencingLatestValidationBlock > 0 + }, t.isLatestValidationBlock, t.parentsReferencingLatestValidationBlock) + t.isMarkedOrphaned = reactive.NewDerivedVariable2[bool, bool](func(_ bool, isLivenessThresholdReached bool, isAccepted bool) bool { return isLivenessThresholdReached && !isAccepted }, t.livenessThresholdReached, block.Accepted()) @@ -160,6 +180,10 @@ func NewBlockMetadata(block *blocks.Block) *TipMetadata { return isWeakTipPoolMember && !isReferencedByTips }, t.isWeakTipPoolMember, t.isReferencedByTips) + t.isValidationTip = reactive.NewDerivedVariable2(func(_ bool, isStrongTip bool, referencesLatestValidationBlock bool) bool { + return isStrongTip && referencesLatestValidationBlock + }, t.isStrongTip, t.referencesLatestValidationBlock) + return t } @@ -203,9 +227,35 @@ func (t *TipMetadata) Evicted() reactive.Event { return t.evicted } +// registerAsLatestValidationBlock registers the TipMetadata as the latest validation block if it is newer than the +// currently registered block and sets the isLatestValidationBlock variable accordingly. The function returns true if the +// operation was successful. +func (t *TipMetadata) registerAsLatestValidationBlock(latestValidationBlock reactive.Variable[*TipMetadata]) (registered bool) { + latestValidationBlock.Compute(func(currentLatestValidationBlock *TipMetadata) *TipMetadata { + registered = currentLatestValidationBlock == nil || currentLatestValidationBlock.block.IssuingTime().Before(t.block.IssuingTime()) + + return lo.Cond(registered, t, currentLatestValidationBlock) + }) + + if registered { + t.isLatestValidationBlock.Set(true) + + // Once the latestValidationBlock is updated again (by another block), we need to reset the isLatestValidationBlock + // variable. + latestValidationBlock.OnUpdateOnce(func(_ *TipMetadata, _ *TipMetadata) { + t.isLatestValidationBlock.Set(false) + }, func(_ *TipMetadata, latestValidationBlock *TipMetadata) bool { + return latestValidationBlock != t + }) + } + + return registered +} + // connectStrongParent sets up the parent and children related properties for a strong parent. func (t *TipMetadata) connectStrongParent(strongParent *TipMetadata) { t.stronglyOrphanedStrongParents.Monitor(strongParent.isStronglyOrphaned) + t.parentsReferencingLatestValidationBlock.Monitor(strongParent.referencesLatestValidationBlock) // unsubscribe when the parent is evicted, since we otherwise continue to hold a reference to it. unsubscribe := strongParent.stronglyConnectedStrongChildren.Monitor(t.isStronglyConnectedToTips) diff --git a/pkg/protocol/engine/tipmanager/v1/tipmanager.go b/pkg/protocol/engine/tipmanager/v1/tipmanager.go index e71e079af..4206e04e8 100644 --- a/pkg/protocol/engine/tipmanager/v1/tipmanager.go +++ b/pkg/protocol/engine/tipmanager/v1/tipmanager.go @@ -4,11 +4,13 @@ import ( "fmt" "github.com/iotaledger/hive.go/ds/randommap" + "github.com/iotaledger/hive.go/ds/reactive" "github.com/iotaledger/hive.go/ds/shrinkingmap" "github.com/iotaledger/hive.go/lo" "github.com/iotaledger/hive.go/runtime/event" "github.com/iotaledger/hive.go/runtime/module" "github.com/iotaledger/hive.go/runtime/syncutils" + "github.com/iotaledger/iota-core/pkg/core/account" "github.com/iotaledger/iota-core/pkg/protocol/engine/blocks" "github.com/iotaledger/iota-core/pkg/protocol/engine/tipmanager" iotago "github.com/iotaledger/iota.go/v4" @@ -19,9 +21,18 @@ type TipManager struct { // retrieveBlock is a function that retrieves a Block from the Tangle. retrieveBlock func(blockID iotago.BlockID) (block *blocks.Block, exists bool) + // retrieveCommitteeInSlot is a function that retrieves the committee in a given slot. + retrieveCommitteeInSlot func(slot iotago.SlotIndex) (*account.SeatedAccounts, bool) + // tipMetadataStorage contains the TipMetadata of all Blocks that are managed by the TipManager. tipMetadataStorage *shrinkingmap.ShrinkingMap[iotago.SlotIndex, *shrinkingmap.ShrinkingMap[iotago.BlockID, *TipMetadata]] + // latestValidationBlocks contains a Variable for each validator that stores the latest validation block. + latestValidationBlocks *shrinkingmap.ShrinkingMap[account.SeatIndex, reactive.Variable[*TipMetadata]] + + // validationTipSet contains the subset of blocks from the strong tip set that reference the latest validation block. + validationTipSet *randommap.RandomMap[iotago.BlockID, *TipMetadata] + // strongTipSet contains the blocks of the strong tip pool that have no referencing children. strongTipSet *randommap.RandomMap[iotago.BlockID, *TipMetadata] @@ -42,13 +53,19 @@ type TipManager struct { } // New creates a new TipManager. -func New(blockRetriever func(blockID iotago.BlockID) (block *blocks.Block, exists bool)) *TipManager { +func New( + blockRetriever func(blockID iotago.BlockID) (block *blocks.Block, exists bool), + retrieveCommitteeInSlot func(slot iotago.SlotIndex) (*account.SeatedAccounts, bool), +) *TipManager { t := &TipManager{ - retrieveBlock: blockRetriever, - tipMetadataStorage: shrinkingmap.New[iotago.SlotIndex, *shrinkingmap.ShrinkingMap[iotago.BlockID, *TipMetadata]](), - strongTipSet: randommap.New[iotago.BlockID, *TipMetadata](), - weakTipSet: randommap.New[iotago.BlockID, *TipMetadata](), - blockAdded: event.New1[tipmanager.TipMetadata](), + retrieveBlock: blockRetriever, + retrieveCommitteeInSlot: retrieveCommitteeInSlot, + tipMetadataStorage: shrinkingmap.New[iotago.SlotIndex, *shrinkingmap.ShrinkingMap[iotago.BlockID, *TipMetadata]](), + latestValidationBlocks: shrinkingmap.New[account.SeatIndex, reactive.Variable[*TipMetadata]](), + validationTipSet: randommap.New[iotago.BlockID, *TipMetadata](), + strongTipSet: randommap.New[iotago.BlockID, *TipMetadata](), + weakTipSet: randommap.New[iotago.BlockID, *TipMetadata](), + blockAdded: event.New1[tipmanager.TipMetadata](), } t.TriggerConstructed() @@ -80,6 +97,26 @@ func (t *TipManager) OnBlockAdded(handler func(block tipmanager.TipMetadata)) (u return t.blockAdded.Hook(handler).Unhook } +// AddSeat adds a validator to the tracking of the TipManager. +func (t *TipManager) AddSeat(seat account.SeatIndex) { + t.latestValidationBlocks.GetOrCreate(seat, func() reactive.Variable[*TipMetadata] { + return reactive.NewVariable[*TipMetadata]() + }) +} + +// RemoveSeat removes a validator from the tracking of the TipManager. +func (t *TipManager) RemoveSeat(seat account.SeatIndex) { + latestValidationBlock, removed := t.latestValidationBlocks.DeleteAndReturn(seat) + if removed { + latestValidationBlock.Set(nil) + } + +} + +func (t *TipManager) ValidationTips(optAmount ...int) []tipmanager.TipMetadata { + return t.selectTips(t.validationTipSet, optAmount...) +} + // StrongTips returns the strong tips of the TipManager (with an optional limit). func (t *TipManager) StrongTips(optAmount ...int) []tipmanager.TipMetadata { return t.selectTips(t.strongTipSet, optAmount...) @@ -123,6 +160,18 @@ func (t *TipManager) Shutdown() { // setupBlockMetadata sets up the behavior of the given Block. func (t *TipManager) setupBlockMetadata(tipMetadata *TipMetadata) { + tipMetadata.isStrongTipPoolMember.WithNonEmptyValue(func(_ bool) func() { + return t.trackLatestValidationBlock(tipMetadata) + }) + + tipMetadata.isValidationTip.OnUpdate(func(_ bool, isValidationTip bool) { + if isValidationTip { + t.validationTipSet.Set(tipMetadata.ID(), tipMetadata) + } else { + t.validationTipSet.Delete(tipMetadata.ID()) + } + }) + tipMetadata.isStrongTip.OnUpdate(func(_ bool, isStrongTip bool) { if isStrongTip { t.strongTipSet.Set(tipMetadata.ID(), tipMetadata) @@ -150,6 +199,40 @@ func (t *TipManager) setupBlockMetadata(tipMetadata *TipMetadata) { t.blockAdded.Trigger(tipMetadata) } +// trackLatestValidationBlock tracks the latest validator block and takes care of marking the corresponding TipMetadata. +func (t *TipManager) trackLatestValidationBlock(tipMetadata *TipMetadata) (teardown func()) { + if _, isValidationBlock := tipMetadata.Block().ValidationBlock(); !isValidationBlock { + return + } + + committee, exists := t.retrieveCommitteeInSlot(tipMetadata.Block().ID().Slot()) + if !exists { + return + } + + seat, exists := committee.GetSeat(tipMetadata.Block().ProtocolBlock().Header.IssuerID) + if !exists { + return + } + + // We only track the validation blocks of validators that are tracked by the TipManager (via AddSeat). + latestValidationBlock, exists := t.latestValidationBlocks.Get(seat) + if !exists { + return + } + + if !tipMetadata.registerAsLatestValidationBlock(latestValidationBlock) { + return + } + + // reset the latest validator block to nil if we are still the latest one during teardown + return func() { + latestValidationBlock.Compute(func(latestValidationBlock *TipMetadata) *TipMetadata { + return lo.Cond(latestValidationBlock == tipMetadata, nil, latestValidationBlock) + }) + } +} + // forEachParentByType iterates through the parents of the given block and calls the consumer for each parent. func (t *TipManager) forEachParentByType(block *blocks.Block, consumer func(parentType iotago.ParentsType, parentMetadata *TipMetadata)) { for _, parent := range block.ParentsWithType() { diff --git a/pkg/protocol/engine/tipselection/v1/tip_selection.go b/pkg/protocol/engine/tipselection/v1/tip_selection.go index e9312d8e4..88db07991 100644 --- a/pkg/protocol/engine/tipselection/v1/tip_selection.go +++ b/pkg/protocol/engine/tipselection/v1/tip_selection.go @@ -5,6 +5,7 @@ import ( "github.com/iotaledger/hive.go/ds" "github.com/iotaledger/hive.go/ds/reactive" + "github.com/iotaledger/hive.go/ds/types" "github.com/iotaledger/hive.go/ierrors" "github.com/iotaledger/hive.go/lo" "github.com/iotaledger/hive.go/runtime/module" @@ -111,7 +112,7 @@ func (t *TipSelection) SelectTips(amount int) (references model.ParentReferences _ = t.spendDAG.ReadConsistent(func(_ spenddag.ReadLockedSpendDAG[iotago.TransactionID, mempool.StateID, ledger.BlockVoteRank]) error { previousLikedInsteadConflicts := ds.NewSet[iotago.TransactionID]() - if t.collectReferences(references, iotago.StrongParentType, t.tipManager.StrongTips, func(tip tipmanager.TipMetadata) { + if t.collectReferences(func(tip tipmanager.TipMetadata) { addedLikedInsteadReferences, updatedLikedInsteadConflicts, err := t.likedInsteadReferences(previousLikedInsteadConflicts, tip) if err != nil { tip.TipPool().Set(tipmanager.WeakTipPool) @@ -124,17 +125,26 @@ func (t *TipSelection) SelectTips(amount int) (references model.ParentReferences previousLikedInsteadConflicts = updatedLikedInsteadConflicts } - }, amount); len(references[iotago.StrongParentType]) == 0 { + }, func() int { + return len(references[iotago.StrongParentType]) + }, + // We select one validation tip as a strong parent. This is a security step to ensure that the tangle maintains + // acceptance by stitching together validation blocks. + types.NewTuple[func(optAmount ...int) []tipmanager.TipMetadata, int](t.tipManager.ValidationTips, 1), + types.NewTuple[func(optAmount ...int) []tipmanager.TipMetadata, int](t.tipManager.StrongTips, amount-1), + ); len(references[iotago.StrongParentType]) == 0 { references[iotago.StrongParentType] = iotago.BlockIDs{t.rootBlock()} } - t.collectReferences(references, iotago.WeakParentType, t.tipManager.WeakTips, func(tip tipmanager.TipMetadata) { + t.collectReferences(func(tip tipmanager.TipMetadata) { if !t.isValidWeakTip(tip.Block()) { tip.TipPool().Set(tipmanager.DroppedTipPool) } else if !shallowLikesParents.Has(tip.ID()) { references[iotago.WeakParentType] = append(references[iotago.WeakParentType], tip.ID()) } - }, t.optMaxWeakReferences) + }, func() int { + return len(references[iotago.WeakParentType]) + }, types.NewTuple[func(optAmount ...int) []tipmanager.TipMetadata, int](t.tipManager.WeakTips, t.optMaxWeakReferences)) return nil }) @@ -208,17 +218,19 @@ func (t *TipSelection) likedInsteadReferences(likedConflicts ds.Set[iotago.Trans return references, updatedLikedConflicts, nil } -// collectReferences collects tips from a tip selector (and calls the callback for each tip) until the amount of +// collectReferences collects tips from a tip selector (and calls the callback for each tip) until the number of // references of the given type is reached. -func (t *TipSelection) collectReferences(references model.ParentReferences, parentsType iotago.ParentsType, tipSelector func(optAmount ...int) []tipmanager.TipMetadata, callback func(tipmanager.TipMetadata), amount int) { +func (t *TipSelection) collectReferences(callback func(tipmanager.TipMetadata), referencesCountCallback func() int, tipSelectorsAmount ...*types.Tuple[func(optAmount ...int) []tipmanager.TipMetadata, int]) { seenTips := ds.NewSet[iotago.BlockID]() - selectUniqueTips := func(amount int) (uniqueTips []tipmanager.TipMetadata) { - if amount > 0 { - for _, tip := range tipSelector(amount + seenTips.Size()) { + + // selectUniqueTips selects 'amount' unique tips from the given tip selector. + selectUniqueTips := func(tipSelector func(optAmount ...int) []tipmanager.TipMetadata, currentReferencesCount int, targetAmount int) (uniqueTips []tipmanager.TipMetadata) { + if targetAmount > 0 { + for _, tip := range tipSelector(targetAmount + seenTips.Size()) { if seenTips.Add(tip.ID()) { uniqueTips = append(uniqueTips, tip) - if len(uniqueTips) == amount { + if currentReferencesCount+len(uniqueTips) == targetAmount { break } } @@ -228,9 +240,29 @@ func (t *TipSelection) collectReferences(references model.ParentReferences, pare return uniqueTips } - for tipCandidates := selectUniqueTips(amount); len(tipCandidates) != 0; tipCandidates = selectUniqueTips(amount - len(references[parentsType])) { - for _, tip := range tipCandidates { - callback(tip) + accumulatedTipAmount := 0 + // We select the desired number of tips from all given tip selectors, respectively. + for _, tipSelectorAmount := range tipSelectorsAmount { + // Make sure we select the total number of unique tips and not just the number of tips from the given tip pool, + // because of how selectUniqueTips works. + accumulatedTipAmount += tipSelectorAmount.B + + tipCandidates := selectUniqueTips(tipSelectorAmount.A, referencesCountCallback(), accumulatedTipAmount) + + // We exit the loop in two cases: + // 1. When we've seen all the tips and there are no more unique tips to process (len(tipCandidates) != 0). + // 2. When we've successfully selected the desired number of tips and added them to the references (referencesCountCallback() >= accumulatedTipAmount). + for len(tipCandidates) != 0 { + for _, tip := range tipCandidates { + callback(tip) + } + + referencesCount := referencesCountCallback() + if referencesCount >= accumulatedTipAmount { + break + } + + tipCandidates = selectUniqueTips(tipSelectorAmount.A, referencesCount, accumulatedTipAmount) } } } diff --git a/pkg/protocol/engine/tipselection/v1/tip_selection_test.go b/pkg/protocol/engine/tipselection/v1/tip_selection_test.go index a69695d55..e99d328da 100644 --- a/pkg/protocol/engine/tipselection/v1/tip_selection_test.go +++ b/pkg/protocol/engine/tipselection/v1/tip_selection_test.go @@ -12,7 +12,7 @@ import ( func TestTipSelection_DynamicLivenessThreshold_NoWitnesses(t *testing.T) { tf := NewTestFramework(t) - tf.TipManager.CreateBlock("Block", map[iotago.ParentsType][]string{iotago.StrongParentType: {"Genesis"}}) + tf.TipManager.CreateBasicBlock("Block", map[iotago.ParentsType][]string{iotago.StrongParentType: {"Genesis"}}) tf.TipManager.AddBlock("Block") expectedLivenessThreshold := tf.ExpectedLivenessThreshold("Block") @@ -43,7 +43,7 @@ func TestTipSelection_DynamicLivenessThreshold_NoWitnesses(t *testing.T) { func TestTipSelection_DynamicLivenessThreshold_WithSingleWitness(t *testing.T) { tf := NewTestFramework(t) - tf.TipManager.CreateBlock("Block", map[iotago.ParentsType][]string{iotago.StrongParentType: {"Genesis"}}) + tf.TipManager.CreateBasicBlock("Block", map[iotago.ParentsType][]string{iotago.StrongParentType: {"Genesis"}}) tf.TipManager.AddBlock("Block") expectedLivenessThresholdWithoutWitnesses := tf.ExpectedLivenessThreshold("Block") @@ -95,7 +95,7 @@ func TestTipSelection_DynamicLivenessThreshold_WithSingleWitness(t *testing.T) { func TestTipSelection_DynamicLivenessThreshold_WithMaxWitnesses(t *testing.T) { tf := NewTestFramework(t) - tf.TipManager.CreateBlock("Block", map[iotago.ParentsType][]string{iotago.StrongParentType: {"Genesis"}}) + tf.TipManager.CreateBasicBlock("Block", map[iotago.ParentsType][]string{iotago.StrongParentType: {"Genesis"}}) tf.TipManager.AddBlock("Block") livenessThresholdZero := tf.ExpectedLivenessThreshold("Block") @@ -178,7 +178,7 @@ func TestTipSelection_DynamicLivenessThreshold_WithMaxWitnesses(t *testing.T) { func TestDynamicLivenessThreshold(t *testing.T) { const committeeSize = 10 tf := NewTestFramework(t, WithCommitteeSize(committeeSize)) - tf.TipManager.CreateBlock("Block", map[iotago.ParentsType][]string{iotago.StrongParentType: {"Genesis"}}) + tf.TipManager.CreateBasicBlock("Block", map[iotago.ParentsType][]string{iotago.StrongParentType: {"Genesis"}}) tf.TipManager.AddBlock("Block") livenessThresholdLowerBound := tf.TipManager.API.ProtocolParameters().LivenessThresholdLowerBound() diff --git a/pkg/protocol/sybilprotection/seatmanager/mock/mockseatmanager.go b/pkg/protocol/sybilprotection/seatmanager/mock/mockseatmanager.go index a0aadf88f..dd75a4fef 100644 --- a/pkg/protocol/sybilprotection/seatmanager/mock/mockseatmanager.go +++ b/pkg/protocol/sybilprotection/seatmanager/mock/mockseatmanager.go @@ -58,18 +58,24 @@ func NewManualPOAProvider() module.Provider[*engine.Engine, seatmanager.SeatMana }) } -func (m *ManualPOA) AddRandomAccount(alias string) iotago.AccountID { - id := iotago.AccountID(tpkg.Rand32ByteArray()) - id.RegisterAlias(alias) - if err := m.accounts.Set(id, &account.Pool{ // We don't care about pools with PoA, but need to set something to avoid division by zero errors. - PoolStake: 1, - ValidatorStake: 1, - FixedCost: 1, - }); err != nil { - panic(err) - } +func (m *ManualPOA) AddRandomAccounts(aliases ...string) (accountIDs []iotago.AccountID) { + accountIDs = make([]iotago.AccountID, len(aliases)) + + for i, alias := range aliases { + id := iotago.AccountID(tpkg.Rand32ByteArray()) + id.RegisterAlias(alias) + if err := m.accounts.Set(id, &account.Pool{ // We don't care about pools with PoA, but need to set something to avoid division by zero errors. + PoolStake: 1, + ValidatorStake: 1, + FixedCost: 1, + }); err != nil { + panic(err) + } - m.aliases.Set(alias, id) + m.aliases.Set(alias, id) + + accountIDs[i] = id + } m.committee = m.accounts.SeatedAccounts() @@ -77,7 +83,7 @@ func (m *ManualPOA) AddRandomAccount(alias string) iotago.AccountID { panic(err) } - return id + return accountIDs } func (m *ManualPOA) AddAccount(id iotago.AccountID, alias string) iotago.AccountID { @@ -108,6 +114,10 @@ func (m *ManualPOA) AccountID(alias string) iotago.AccountID { return id } +func (m *ManualPOA) GetSeat(alias string) (account.SeatIndex, bool) { + return m.committee.GetSeat(m.AccountID(alias)) +} + func (m *ManualPOA) SetOnline(aliases ...string) { for _, alias := range aliases { seat, exists := m.committee.GetSeat(m.AccountID(alias))