Repository: iotaledger/iota-core
Pull request #646 (2024-01-09 08:42:55)

Use Reward Retention Period as pruning delay
Fixes iotaledger/iota-core#638 and iotaledger/iota-core#629.

- The hardcoded pruning delay is replaced by a function which captures an API provider to determine the retention period for the to-be-pruned epoch.
- The rewards calculation will now only fetch epochs which are retained according to the new protocol parameter. Without this, a node that has pruning disabled would retain rewards indefinitely and such a node would under certain circumstances disagree on the reward amount with a node that has automatic pruning enabled.
    - As long as we don't change the retention period parameter across protocol upgrades, this is fine and we can keep the user-managed prune action through the management API. However, if we were to increase the retention period, whether a node has automatic pruning or user-managed pruning would matter.
        - At some new epoch X, where the increased retention period becomes active through a new protocol version, an automatically-pruned node would have pruned older rewards already, while a user-managed node would potentially still have them and hence, nodes would disagree on the calculated rewards. At this point we would have to disable user-managed pruning to make all nodes agree. This can be left as a future problem or can be fixed by handing responsibility of the reward storage completely to the protocol.
- It's not entirely clear to me whether the approach in this PR would work when we would increase or decrease the retention period in a protocol upgrade. I'm not sure if we should take care of this now or if and when we want to upgrade this particular parameter.


Patch:
diff --git a/components/restapi/core/accounts.go b/components/restapi/core/accounts.go
index 86a4da6a..52e91b32 100644
--- a/components/restapi/core/accounts.go
+++ b/components/restapi/core/accounts.go
@@ -219,6 +219,7 @@ func rewardsByOutputID(c echo.Context) (*api.ManaRewardsResponse, error) {
 		delegationEnd := delegationOutput.EndEpoch
 		futureBoundedSlotIndex := slotIndex + apiForSlot.ProtocolParameters().MinCommittableAge()
 		claimingEpoch := apiForSlot.TimeProvider().EpochFromSlot(futureBoundedSlotIndex)
+
 		// If Delegation ID is zeroed, the output is in delegating state, which means its End Epoch is not set and we must use the
 		// "last epoch" for the rewards calculation.
 		// In this case the calculation must be consistent with the rewards calculation at execution time, so a client can specify
diff --git a/go.mod b/go.mod
index 37839e3d..1b9b5d4d 100644
--- a/go.mod
+++ b/go.mod
@@ -23,9 +23,9 @@ require (
 	github.com/iotaledger/hive.go/runtime v0.0.0-20231219105941-542b1b724494
 	github.com/iotaledger/hive.go/serializer/v2 v2.0.0-rc.1.0.20231219095137-dd4674b3226e
 	github.com/iotaledger/hive.go/stringify v0.0.0-20231219105941-542b1b724494
-	github.com/iotaledger/inx-app v1.0.0-rc.3.0.20231219130724-cb37c927be6d
-	github.com/iotaledger/inx/go v1.0.0-rc.2.0.20231219130433-ad0971fe6306
-	github.com/iotaledger/iota.go/v4 v4.0.0-20231220145409-bad3962d0185
+	github.com/iotaledger/inx-app v1.0.0-rc.3.0.20240110125343-5c50e43b71fa
+	github.com/iotaledger/inx/go v1.0.0-rc.2.0.20240110120225-05a4544ca1dd
+	github.com/iotaledger/iota.go/v4 v4.0.0-20240110093746-74501e609f1c
 	github.com/labstack/echo/v4 v4.11.3
 	github.com/labstack/gommon v0.4.1
 	github.com/libp2p/go-libp2p v0.32.0
diff --git a/go.sum b/go.sum
index d3ab18d7..6cbf26a2 100644
--- a/go.sum
+++ b/go.sum
@@ -303,14 +303,14 @@ github.com/iotaledger/hive.go/serializer/v2 v2.0.0-rc.1.0.20231219095137-dd4674b
 github.com/iotaledger/hive.go/serializer/v2 v2.0.0-rc.1.0.20231219095137-dd4674b3226e/go.mod h1:2Gl3qEk1CV9uFPF79JM0Fn4Da39P6SZO+uIF4YMy2kk=
 github.com/iotaledger/hive.go/stringify v0.0.0-20231219105941-542b1b724494 h1:aa2z4tUca8aZNd2MLcLmGS19sP6BAAO9ULSOYJTFcxo=
 github.com/iotaledger/hive.go/stringify v0.0.0-20231219105941-542b1b724494/go.mod h1:FTo/UWzNYgnQ082GI9QVM9HFDERqf9rw9RivNpqrnTs=
-github.com/iotaledger/inx-app v1.0.0-rc.3.0.20231219130724-cb37c927be6d h1:R7cleqpThJm2N/nYlXiMs2n0fdhMoto0DDR+oPMmPRY=
-github.com/iotaledger/inx-app v1.0.0-rc.3.0.20231219130724-cb37c927be6d/go.mod h1:oSGCn7uIoz2nULqgbji3EJkMO26FzpOmCu5t2hbvjME=
-github.com/iotaledger/inx/go v1.0.0-rc.2.0.20231219130433-ad0971fe6306 h1:8JAa8DrYuKMNAqPkunib1HAvfNoK4X/xO02hqhwyYzI=
-github.com/iotaledger/inx/go v1.0.0-rc.2.0.20231219130433-ad0971fe6306/go.mod h1:KcwBYlfpd7w1SmAY75GBO5Ce7k/RyeCXA4TRQcOwTdQ=
+github.com/iotaledger/inx-app v1.0.0-rc.3.0.20240110125343-5c50e43b71fa h1:/Czrql6nqnvQfUd0JEwM7Pt11Z55ZpiCI1fNiQ4ZmX4=
+github.com/iotaledger/inx-app v1.0.0-rc.3.0.20240110125343-5c50e43b71fa/go.mod h1:0QsQZN/hld/1Bi32QeHaDAtk8lTI/oSfE0G/8Aj8Znk=
+github.com/iotaledger/inx/go v1.0.0-rc.2.0.20240110120225-05a4544ca1dd h1:Fg4lm/P7f9ctJZ4HOlZt/R47aTYPse7hby0BFkjcpjM=
+github.com/iotaledger/inx/go v1.0.0-rc.2.0.20240110120225-05a4544ca1dd/go.mod h1:AI/DyXh10w2/ZxHb6pKAvtkkDR8AOpFJSjkmsBcc+5g=
 github.com/iotaledger/iota-crypto-demo v0.0.0-20231208171603-786bb32fdb00 h1:j5udgLtSN6wQgFI9vnhkdJsqsVdJmwtoc0yOmT/Ila4=
 github.com/iotaledger/iota-crypto-demo v0.0.0-20231208171603-786bb32fdb00/go.mod h1:gt+URx7DZu414nZME7jtGgxR4DVTSnNa1jF2trTUTZ0=
-github.com/iotaledger/iota.go/v4 v4.0.0-20231220145409-bad3962d0185 h1:VRxOxhIuOGHyfMSOgXLSvObJfL/YIMm/lSdTK7XbGXQ=
-github.com/iotaledger/iota.go/v4 v4.0.0-20231220145409-bad3962d0185/go.mod h1:66w9NjF5IDk4amUu+i54yPfSi3hLqpAOefotC0kZahs=
+github.com/iotaledger/iota.go/v4 v4.0.0-20240110093746-74501e609f1c h1:hO/nIqPhSbeddwBd71Yci7zOrQK/ZmIJfKY9QWC44OM=
+github.com/iotaledger/iota.go/v4 v4.0.0-20240110093746-74501e609f1c/go.mod h1:66w9NjF5IDk4amUu+i54yPfSi3hLqpAOefotC0kZahs=
 github.com/ipfs/boxo v0.13.1 h1:nQ5oQzcMZR3oL41REJDcTbrvDvuZh3J9ckc9+ILeRQI=
 github.com/ipfs/boxo v0.13.1/go.mod h1:btrtHy0lmO1ODMECbbEY1pxNtrLilvKSYLoGQt1yYCk=
 github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s=
diff --git a/pkg/protocol/sybilprotection/sybilprotectionv1/performance/rewards.go b/pkg/protocol/sybilprotection/sybilprotectionv1/performance/rewards.go
index 6cde0cb3..e8db30bd 100644
--- a/pkg/protocol/sybilprotection/sybilprotectionv1/performance/rewards.go
+++ b/pkg/protocol/sybilprotection/sybilprotectionv1/performance/rewards.go
@@ -42,6 +42,10 @@ func (t *Tracker) ValidatorReward(validatorID iotago.AccountID, stakingFeature *
 
 	decayEndEpoch := t.decayEndEpoch(claimingEpoch, lastRewardEpoch)
 
+	// Only fetch unexpired rewards from epochs by determining the earliest epoch
+	// for which rewards are still retained or available.
+	firstRewardEpoch = t.earliestRewardEpoch(firstRewardEpoch, claimingEpoch)
+
 	for epoch := firstRewardEpoch; epoch <= lastRewardEpoch; epoch++ {
 		rewardsForAccountInEpoch, exists, err := t.rewardsForAccount(validatorID, epoch)
 		if err != nil {
@@ -136,6 +140,10 @@ func (t *Tracker) DelegatorReward(validatorID iotago.AccountID, delegatedAmount
 
 	decayEndEpoch := t.decayEndEpoch(claimingEpoch, lastRewardEpoch)
 
+	// Only fetch unexpired rewards from epochs by determining the earliest epoch
+	// for which rewards are still retained or available.
+	firstRewardEpoch = t.earliestRewardEpoch(firstRewardEpoch, claimingEpoch)
+
 	for epoch := firstRewardEpoch; epoch <= lastRewardEpoch; epoch++ {
 		rewardsForAccountInEpoch, exists, err := t.rewardsForAccount(validatorID, epoch)
 		if err != nil {
@@ -229,6 +237,17 @@ func (t *Tracker) decayEndEpoch(claimingEpoch iotago.EpochIndex, lastRewardEpoch
 	return lo.Max(claimingEpoch, lastRewardEpoch)
 }
 
+// Returns the earliest epoch for which rewards are retained or available, whichever is later.
+func (t *Tracker) earliestRewardEpoch(firstRewardEpoch iotago.EpochIndex, claimingEpoch iotago.EpochIndex) iotago.EpochIndex {
+	retentionPeriod := iotago.EpochIndex(t.apiProvider.APIForEpoch(claimingEpoch).ProtocolParameters().RewardsParameters().RetentionPeriod)
+	var earliestRetainedRewardEpoch iotago.EpochIndex
+	if retentionPeriod < claimingEpoch {
+		earliestRetainedRewardEpoch = claimingEpoch - retentionPeriod
+	}
+
+	return lo.Max(earliestRetainedRewardEpoch, firstRewardEpoch)
+}
+
 func (t *Tracker) rewardsMap(epoch iotago.EpochIndex) (ads.Map[iotago.Identifier, iotago.AccountID, *model.PoolRewards], error) {
 	kv, err := t.rewardsStorePerEpochFunc(epoch)
 	if err != nil {
diff --git a/pkg/storage/prunable/epochstore/epoch_kv.go b/pkg/storage/prunable/epochstore/epoch_kv.go
index 1a1dff4d..a516d628 100644
--- a/pkg/storage/prunable/epochstore/epoch_kv.go
+++ b/pkg/storage/prunable/epochstore/epoch_kv.go
@@ -10,19 +10,19 @@ import (
 )
 
 type EpochKVStore struct {
-	realm        kvstore.Realm
-	kv           kvstore.KVStore
-	pruningDelay iotago.EpochIndex
+	realm            kvstore.Realm
+	kv               kvstore.KVStore
+	prunginDelayFunc func(iotago.EpochIndex) iotago.EpochIndex
 
 	lastAccessedEpoch *kvstore.TypedValue[iotago.EpochIndex]
 	lastPrunedEpoch   *model.PruningIndex
 }
 
-func NewEpochKVStore(storeRealm kvstore.Realm, kv kvstore.KVStore, pruningDelay iotago.EpochIndex) *EpochKVStore {
+func NewEpochKVStore(storeRealm kvstore.Realm, kv kvstore.KVStore, prunginDelayFunc func(iotago.EpochIndex) iotago.EpochIndex) *EpochKVStore {
 	return &EpochKVStore{
 		realm:             storeRealm,
 		kv:                lo.PanicOnErr(kv.WithExtendedRealm(append(storeRealm, entriesKey))),
-		pruningDelay:      pruningDelay,
+		prunginDelayFunc:  prunginDelayFunc,
 		lastAccessedEpoch: kvstore.NewTypedValue(kv, append(storeRealm, lastAccessedEpochKey), iotago.EpochIndex.Bytes, iotago.EpochIndexFromBytes),
 		lastPrunedEpoch:   model.NewPruningIndex(lo.PanicOnErr(kv.WithExtendedRealm(storeRealm)), kvstore.Realm{lastPrunedEpochKey}),
 	}
@@ -75,13 +75,15 @@ func (e *EpochKVStore) DeleteEpoch(epoch iotago.EpochIndex) error {
 }
 
 func (e *EpochKVStore) Prune(epoch iotago.EpochIndex, defaultPruningDelay iotago.EpochIndex) error {
+	minPruningDelay := e.prunginDelayFunc(epoch)
+
 	// The epoch we're trying to prune already takes into account the defaultPruningDelay.
-	// Therefore, we don't need to do anything if it is greater equal e.pruningDelay and take the difference otherwise.
+	// Therefore, we don't need to do anything if it is greater equal minPruningDelay and take the difference otherwise.
 	var pruningDelay iotago.EpochIndex
-	if defaultPruningDelay >= e.pruningDelay {
+	if defaultPruningDelay >= minPruningDelay {
 		pruningDelay = 0
 	} else {
-		pruningDelay = e.pruningDelay - defaultPruningDelay
+		pruningDelay = minPruningDelay - defaultPruningDelay
 	}
 
 	// No need to prune.
diff --git a/pkg/storage/prunable/epochstore/store.go b/pkg/storage/prunable/epochstore/store.go
index e2fbc32c..365be0e1 100644
--- a/pkg/storage/prunable/epochstore/store.go
+++ b/pkg/storage/prunable/epochstore/store.go
@@ -10,19 +10,19 @@ import (
 )
 
 type Store[V any] struct {
-	realm        kvstore.Realm
-	kv           *kvstore.TypedStore[iotago.EpochIndex, V]
-	pruningDelay iotago.EpochIndex
+	realm            kvstore.Realm
+	kv               *kvstore.TypedStore[iotago.EpochIndex, V]
+	prunginDelayFunc func(iotago.EpochIndex) iotago.EpochIndex
 
 	lastAccessedEpoch *kvstore.TypedValue[iotago.EpochIndex]
 	lastPrunedEpoch   *model.PruningIndex
 }
 
-func NewStore[V any](storeRealm kvstore.Realm, kv kvstore.KVStore, pruningDelay iotago.EpochIndex, vToBytes kvstore.ObjectToBytes[V], bytesToV kvstore.BytesToObject[V]) *Store[V] {
+func NewStore[V any](storeRealm kvstore.Realm, kv kvstore.KVStore, prunginDelayFunc func(iotago.EpochIndex) iotago.EpochIndex, vToBytes kvstore.ObjectToBytes[V], bytesToV kvstore.BytesToObject[V]) *Store[V] {
 	return &Store[V]{
 		realm:             storeRealm,
 		kv:                kvstore.NewTypedStore(lo.PanicOnErr(kv.WithExtendedRealm(append(storeRealm, entriesKey))), iotago.EpochIndex.Bytes, iotago.EpochIndexFromBytes, vToBytes, bytesToV),
-		pruningDelay:      pruningDelay,
+		prunginDelayFunc:  prunginDelayFunc,
 		lastAccessedEpoch: kvstore.NewTypedValue(kv, append(storeRealm, lastAccessedEpochKey), iotago.EpochIndex.Bytes, iotago.EpochIndexFromBytes),
 		lastPrunedEpoch:   model.NewPruningIndex(lo.PanicOnErr(kv.WithExtendedRealm(storeRealm)), kvstore.Realm{lastPrunedEpochKey}),
 	}
@@ -122,13 +122,15 @@ func (s *Store[V]) DeleteEpoch(epoch iotago.EpochIndex) error {
 }
 
 func (s *Store[V]) Prune(epoch iotago.EpochIndex, defaultPruningDelay iotago.EpochIndex) error {
+	minPruningDelay := s.prunginDelayFunc(epoch)
+
 	// The epoch we're trying to prune already takes into account the defaultPruningDelay.
-	// Therefore, we don't need to do anything if it is greater equal s.pruningDelay and take the difference otherwise.
+	// Therefore, we don't need to do anything if it is greater equal minPruningDelay and take the difference otherwise.
 	var pruningDelay iotago.EpochIndex
-	if defaultPruningDelay >= s.pruningDelay {
+	if defaultPruningDelay >= minPruningDelay {
 		pruningDelay = 0
 	} else {
-		pruningDelay = s.pruningDelay - defaultPruningDelay
+		pruningDelay = minPruningDelay - defaultPruningDelay
 	}
 
 	// No need to prune.
diff --git a/pkg/storage/prunable/prunable.go b/pkg/storage/prunable/prunable.go
index daa911be..dcce933d 100644
--- a/pkg/storage/prunable/prunable.go
+++ b/pkg/storage/prunable/prunable.go
@@ -35,6 +35,10 @@ func New(dbConfig database.Config, apiProvider iotago.APIProvider, errorHandler
 	// openedCallback is nil because we don't need to do anything when reopening the store.
 	semiPermanentDB := database.NewDBInstance(semiPermanentDBConfig, nil)
 
+	rewardPruningDelayFunc := func(epochToPrune iotago.EpochIndex) iotago.EpochIndex {
+		return iotago.EpochIndex(apiProvider.APIForEpoch(epochToPrune).ProtocolParameters().RewardsParameters().RetentionPeriod)
+	}
+
 	return &Prunable{
 		apiProvider:       apiProvider,
 		errorHandler:      errorHandler,
@@ -42,10 +46,32 @@ func New(dbConfig database.Config, apiProvider iotago.APIProvider, errorHandler
 
 		semiPermanentDBConfig: semiPermanentDBConfig,
 		semiPermanentDB:       semiPermanentDB,
-		decidedUpgradeSignals: epochstore.NewStore(kvstore.Realm{epochPrefixDecidedUpgradeSignals}, semiPermanentDB.KVStore(), pruningDelayDecidedUpgradeSignals, model.VersionAndHash.Bytes, model.VersionAndHashFromBytes),
-		poolRewards:           epochstore.NewEpochKVStore(kvstore.Realm{epochPrefixPoolRewards}, semiPermanentDB.KVStore(), pruningDelayPoolRewards),
-		poolStats:             epochstore.NewStore(kvstore.Realm{epochPrefixPoolStats}, semiPermanentDB.KVStore(), pruningDelayPoolStats, (*model.PoolsStats).Bytes, model.PoolsStatsFromBytes),
-		committee:             epochstore.NewStore(kvstore.Realm{epochPrefixCommittee}, semiPermanentDB.KVStore(), pruningDelayCommittee, (*account.Accounts).Bytes, account.AccountsFromBytes),
+		decidedUpgradeSignals: epochstore.NewStore(
+			kvstore.Realm{epochPrefixDecidedUpgradeSignals},
+			semiPermanentDB.KVStore(),
+			func(_ iotago.EpochIndex) iotago.EpochIndex { return pruningDelayDecidedUpgradeSignals },
+			model.VersionAndHash.Bytes,
+			model.VersionAndHashFromBytes,
+		),
+		poolRewards: epochstore.NewEpochKVStore(
+			kvstore.Realm{epochPrefixPoolRewards},
+			semiPermanentDB.KVStore(),
+			rewardPruningDelayFunc,
+		),
+		poolStats: epochstore.NewStore(
+			kvstore.Realm{epochPrefixPoolStats},
+			semiPermanentDB.KVStore(),
+			rewardPruningDelayFunc,
+			(*model.PoolsStats).Bytes,
+			model.PoolsStatsFromBytes,
+		),
+		committee: epochstore.NewStore(
+			kvstore.Realm{epochPrefixCommittee},
+			semiPermanentDB.KVStore(),
+			rewardPruningDelayFunc,
+			(*account.Accounts).Bytes,
+			account.AccountsFromBytes,
+		),
 	}
 }
 
diff --git a/pkg/storage/prunable/prunable_epoch.go b/pkg/storage/prunable/prunable_epoch.go
index 0c2186dd..f0e2d695 100644
--- a/pkg/storage/prunable/prunable_epoch.go
+++ b/pkg/storage/prunable/prunable_epoch.go
@@ -17,9 +17,6 @@ const (
 
 const (
 	pruningDelayDecidedUpgradeSignals = 7
-	pruningDelayPoolRewards           = 365
-	pruningDelayPoolStats             = 365
-	pruningDelayCommittee             = 365
 )
 
 func (p *Prunable) RewardsForEpoch(epoch iotago.EpochIndex) (kvstore.KVStore, error) {
diff --git a/tools/gendoc/go.mod b/tools/gendoc/go.mod
index 98152512..f964ddf4 100644
--- a/tools/gendoc/go.mod
+++ b/tools/gendoc/go.mod
@@ -68,10 +68,10 @@ require (
 	github.com/iotaledger/hive.go/runtime v0.0.0-20231219105941-542b1b724494 // indirect
 	github.com/iotaledger/hive.go/serializer/v2 v2.0.0-rc.1.0.20231219095137-dd4674b3226e // indirect
 	github.com/iotaledger/hive.go/stringify v0.0.0-20231219105941-542b1b724494 // indirect
-	github.com/iotaledger/inx-app v1.0.0-rc.3.0.20231219130724-cb37c927be6d // indirect
-	github.com/iotaledger/inx/go v1.0.0-rc.2.0.20231219130433-ad0971fe6306 // indirect
+	github.com/iotaledger/inx-app v1.0.0-rc.3.0.20240110125343-5c50e43b71fa // indirect
+	github.com/iotaledger/inx/go v1.0.0-rc.2.0.20240110120225-05a4544ca1dd // indirect
 	github.com/iotaledger/iota-crypto-demo v0.0.0-20231208171603-786bb32fdb00 // indirect
-	github.com/iotaledger/iota.go/v4 v4.0.0-20231220145409-bad3962d0185 // indirect
+	github.com/iotaledger/iota.go/v4 v4.0.0-20240110093746-74501e609f1c // indirect
 	github.com/ipfs/boxo v0.13.1 // indirect
 	github.com/ipfs/go-cid v0.4.1 // indirect
 	github.com/ipfs/go-datastore v0.6.0 // indirect
diff --git a/tools/gendoc/go.sum b/tools/gendoc/go.sum
index e4604dfe..2cf754c8 100644
--- a/tools/gendoc/go.sum
+++ b/tools/gendoc/go.sum
@@ -307,14 +307,14 @@ github.com/iotaledger/hive.go/serializer/v2 v2.0.0-rc.1.0.20231219095137-dd4674b
 github.com/iotaledger/hive.go/serializer/v2 v2.0.0-rc.1.0.20231219095137-dd4674b3226e/go.mod h1:2Gl3qEk1CV9uFPF79JM0Fn4Da39P6SZO+uIF4YMy2kk=
 github.com/iotaledger/hive.go/stringify v0.0.0-20231219105941-542b1b724494 h1:aa2z4tUca8aZNd2MLcLmGS19sP6BAAO9ULSOYJTFcxo=
 github.com/iotaledger/hive.go/stringify v0.0.0-20231219105941-542b1b724494/go.mod h1:FTo/UWzNYgnQ082GI9QVM9HFDERqf9rw9RivNpqrnTs=
-github.com/iotaledger/inx-app v1.0.0-rc.3.0.20231219130724-cb37c927be6d h1:R7cleqpThJm2N/nYlXiMs2n0fdhMoto0DDR+oPMmPRY=
-github.com/iotaledger/inx-app v1.0.0-rc.3.0.20231219130724-cb37c927be6d/go.mod h1:oSGCn7uIoz2nULqgbji3EJkMO26FzpOmCu5t2hbvjME=
-github.com/iotaledger/inx/go v1.0.0-rc.2.0.20231219130433-ad0971fe6306 h1:8JAa8DrYuKMNAqPkunib1HAvfNoK4X/xO02hqhwyYzI=
-github.com/iotaledger/inx/go v1.0.0-rc.2.0.20231219130433-ad0971fe6306/go.mod h1:KcwBYlfpd7w1SmAY75GBO5Ce7k/RyeCXA4TRQcOwTdQ=
+github.com/iotaledger/inx-app v1.0.0-rc.3.0.20240110125343-5c50e43b71fa h1:/Czrql6nqnvQfUd0JEwM7Pt11Z55ZpiCI1fNiQ4ZmX4=
+github.com/iotaledger/inx-app v1.0.0-rc.3.0.20240110125343-5c50e43b71fa/go.mod h1:0QsQZN/hld/1Bi32QeHaDAtk8lTI/oSfE0G/8Aj8Znk=
+github.com/iotaledger/inx/go v1.0.0-rc.2.0.20240110120225-05a4544ca1dd h1:Fg4lm/P7f9ctJZ4HOlZt/R47aTYPse7hby0BFkjcpjM=
+github.com/iotaledger/inx/go v1.0.0-rc.2.0.20240110120225-05a4544ca1dd/go.mod h1:AI/DyXh10w2/ZxHb6pKAvtkkDR8AOpFJSjkmsBcc+5g=
 github.com/iotaledger/iota-crypto-demo v0.0.0-20231208171603-786bb32fdb00 h1:j5udgLtSN6wQgFI9vnhkdJsqsVdJmwtoc0yOmT/Ila4=
 github.com/iotaledger/iota-crypto-demo v0.0.0-20231208171603-786bb32fdb00/go.mod h1:gt+URx7DZu414nZME7jtGgxR4DVTSnNa1jF2trTUTZ0=
-github.com/iotaledger/iota.go/v4 v4.0.0-20231220145409-bad3962d0185 h1:VRxOxhIuOGHyfMSOgXLSvObJfL/YIMm/lSdTK7XbGXQ=
-github.com/iotaledger/iota.go/v4 v4.0.0-20231220145409-bad3962d0185/go.mod h1:66w9NjF5IDk4amUu+i54yPfSi3hLqpAOefotC0kZahs=
+github.com/iotaledger/iota.go/v4 v4.0.0-20240110093746-74501e609f1c h1:hO/nIqPhSbeddwBd71Yci7zOrQK/ZmIJfKY9QWC44OM=
+github.com/iotaledger/iota.go/v4 v4.0.0-20240110093746-74501e609f1c/go.mod h1:66w9NjF5IDk4amUu+i54yPfSi3hLqpAOefotC0kZahs=
 github.com/ipfs/boxo v0.13.1 h1:nQ5oQzcMZR3oL41REJDcTbrvDvuZh3J9ckc9+ILeRQI=
 github.com/ipfs/boxo v0.13.1/go.mod h1:btrtHy0lmO1ODMECbbEY1pxNtrLilvKSYLoGQt1yYCk=
 github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s=
diff --git a/tools/genesis-snapshot/go.mod b/tools/genesis-snapshot/go.mod
index 234bf402..53e471cf 100644
--- a/tools/genesis-snapshot/go.mod
+++ b/tools/genesis-snapshot/go.mod
@@ -10,7 +10,7 @@ require (
 	github.com/iotaledger/hive.go/lo v0.0.0-20231219105941-542b1b724494
 	github.com/iotaledger/hive.go/runtime v0.0.0-20231219105941-542b1b724494
 	github.com/iotaledger/iota-core v0.0.0-00010101000000-000000000000
-	github.com/iotaledger/iota.go/v4 v4.0.0-20231220145409-bad3962d0185
+	github.com/iotaledger/iota.go/v4 v4.0.0-20240110093746-74501e609f1c
 	github.com/mr-tron/base58 v1.2.0
 	github.com/spf13/pflag v1.0.5
 	golang.org/x/crypto v0.17.0
diff --git a/tools/genesis-snapshot/go.sum b/tools/genesis-snapshot/go.sum
index 09935d24..c49aecea 100644
--- a/tools/genesis-snapshot/go.sum
+++ b/tools/genesis-snapshot/go.sum
@@ -56,8 +56,8 @@ github.com/iotaledger/hive.go/stringify v0.0.0-20231219105941-542b1b724494 h1:aa
 github.com/iotaledger/hive.go/stringify v0.0.0-20231219105941-542b1b724494/go.mod h1:FTo/UWzNYgnQ082GI9QVM9HFDERqf9rw9RivNpqrnTs=
 github.com/iotaledger/iota-crypto-demo v0.0.0-20231208171603-786bb32fdb00 h1:j5udgLtSN6wQgFI9vnhkdJsqsVdJmwtoc0yOmT/Ila4=
 github.com/iotaledger/iota-crypto-demo v0.0.0-20231208171603-786bb32fdb00/go.mod h1:gt+URx7DZu414nZME7jtGgxR4DVTSnNa1jF2trTUTZ0=
-github.com/iotaledger/iota.go/v4 v4.0.0-20231220145409-bad3962d0185 h1:VRxOxhIuOGHyfMSOgXLSvObJfL/YIMm/lSdTK7XbGXQ=
-github.com/iotaledger/iota.go/v4 v4.0.0-20231220145409-bad3962d0185/go.mod h1:66w9NjF5IDk4amUu+i54yPfSi3hLqpAOefotC0kZahs=
+github.com/iotaledger/iota.go/v4 v4.0.0-20240110093746-74501e609f1c h1:hO/nIqPhSbeddwBd71Yci7zOrQK/ZmIJfKY9QWC44OM=
+github.com/iotaledger/iota.go/v4 v4.0.0-20240110093746-74501e609f1c/go.mod h1:66w9NjF5IDk4amUu+i54yPfSi3hLqpAOefotC0kZahs=
 github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s=
 github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk=
 github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=


Test patch:
diff --git a/pkg/protocol/engine/consensus/blockgadget/testframework_test.go b/pkg/protocol/engine/consensus/blockgadget/testframework_test.go
index 00b2ae52..b2ddb517 100644
--- a/pkg/protocol/engine/consensus/blockgadget/testframework_test.go
+++ b/pkg/protocol/engine/consensus/blockgadget/testframework_test.go
@@ -45,7 +45,7 @@ func NewTestFramework(test *testing.T) *TestFramework {
 		T:      test,
 		blocks: shrinkingmap.New[string, *blocks.Block](),
 
-		SeatManager: mock.NewManualPOA(iotago.SingleVersionProvider(tpkg.ZeroCostTestAPI), epochstore.NewStore(kvstore.Realm{}, mapdb.NewMapDB(), 0, (*account.Accounts).Bytes, account.AccountsFromBytes)),
+		SeatManager: mock.NewManualPOA(iotago.SingleVersionProvider(tpkg.ZeroCostTestAPI), epochstore.NewStore(kvstore.Realm{}, mapdb.NewMapDB(), func(_ iotago.EpochIndex) iotago.EpochIndex { return 0 }, (*account.Accounts).Bytes, account.AccountsFromBytes)),
 	}
 
 	prunableStorage := prunable.New(database.Config{
diff --git a/pkg/protocol/sybilprotection/seatmanager/topstakers/topstakers_test.go b/pkg/protocol/sybilprotection/seatmanager/topstakers/topstakers_test.go
index a45851b4..d859d410 100644
--- a/pkg/protocol/sybilprotection/seatmanager/topstakers/topstakers_test.go
+++ b/pkg/protocol/sybilprotection/seatmanager/topstakers/topstakers_test.go
@@ -28,7 +28,9 @@ func TestTopStakers_InitializeCommittee(t *testing.T) {
 		),
 	)
 
-	committeeStore := epochstore.NewStore(kvstore.Realm{}, mapdb.NewMapDB(), 0, (*account.Accounts).Bytes, account.AccountsFromBytes)
+	committeeStore := epochstore.NewStore(kvstore.Realm{}, mapdb.NewMapDB(),
+		func(_ iotago.EpochIndex) iotago.EpochIndex { return 0 },
+		(*account.Accounts).Bytes, account.AccountsFromBytes)
 
 	topStakersSeatManager := &SeatManager{
 		apiProvider:     iotago.SingleVersionProvider(testAPI),
@@ -80,7 +82,9 @@ func TestTopStakers_RotateCommittee(t *testing.T) {
 		),
 	)
 
-	committeeStore := epochstore.NewStore(kvstore.Realm{}, mapdb.NewMapDB(), 0, (*account.Accounts).Bytes, account.AccountsFromBytes)
+	committeeStore := epochstore.NewStore(kvstore.Realm{}, mapdb.NewMapDB(),
+		func(_ iotago.EpochIndex) iotago.EpochIndex { return 0 },
+		(*account.Accounts).Bytes, account.AccountsFromBytes)
 
 	s := &SeatManager{
 		apiProvider:     iotago.SingleVersionProvider(testAPI),
diff --git a/pkg/protocol/sybilprotection/sybilprotectionv1/performance/testsuite_test.go b/pkg/protocol/sybilprotection/sybilprotectionv1/performance/testsuite_test.go
index 638db5f7..133bdfb2 100644
--- a/pkg/protocol/sybilprotection/sybilprotectionv1/performance/testsuite_test.go
+++ b/pkg/protocol/sybilprotection/sybilprotectionv1/performance/testsuite_test.go
@@ -30,8 +30,8 @@ type TestSuite struct {
 
 	api iotago.API
 
-	Instance             *Tracker
-	perforanceFactorFunc func(iotago.SlotIndex) *model.ValidatorPerformance
+	Instance              *Tracker
+	performanceFactorFunc func(iotago.SlotIndex) *model.ValidatorPerformance
 }
 
 func NewTestSuite(t *testing.T) *TestSuite {
@@ -44,7 +44,7 @@ func NewTestSuite(t *testing.T) *TestSuite {
 			iotago.NewV3SnapshotProtocolParameters(
 				iotago.WithTimeProviderOptions(0, time.Now().Unix(), 10, 3),
 				iotago.WithLivenessOptions(5, 5, 1, 2, 3),
-				iotago.WithRewardsOptions(8, 8, 11, 2, 1),
+				iotago.WithRewardsOptions(8, 8, 11, 2, 1, 5),
 			),
 		),
 	}
@@ -70,10 +70,12 @@ func (t *TestSuite) InitPerformanceTracker() {
 		return p, nil
 	}
 
-	rewardsStore := epochstore.NewEpochKVStore(kvstore.Realm{}, mapdb.NewMapDB(), 0)
-	poolStatsStore := epochstore.NewStore(kvstore.Realm{}, mapdb.NewMapDB(), 0, (*model.PoolsStats).Bytes, model.PoolsStatsFromBytes)
-	committeeStore := epochstore.NewStore(kvstore.Realm{}, mapdb.NewMapDB(), 0, (*account.Accounts).Bytes, account.AccountsFromBytes)
-	committeeCandidatesStore := epochstore.NewEpochKVStore(kvstore.Realm{}, mapdb.NewMapDB(), 0)
+	pruningDelayFunc := func(_ iotago.EpochIndex) iotago.EpochIndex { return 0 }
+
+	rewardsStore := epochstore.NewEpochKVStore(kvstore.Realm{}, mapdb.NewMapDB(), pruningDelayFunc)
+	poolStatsStore := epochstore.NewStore(kvstore.Realm{}, mapdb.NewMapDB(), pruningDelayFunc, (*model.PoolsStats).Bytes, model.PoolsStatsFromBytes)
+	committeeStore := epochstore.NewStore(kvstore.Realm{}, mapdb.NewMapDB(), pruningDelayFunc, (*account.Accounts).Bytes, account.AccountsFromBytes)
+	committeeCandidatesStore := epochstore.NewEpochKVStore(kvstore.Realm{}, mapdb.NewMapDB(), pruningDelayFunc)
 
 	t.Instance = NewTracker(
 		rewardsStore.GetEpoch,
diff --git a/pkg/tests/validator_test.go b/pkg/tests/validator_test.go
index b6aa2991..70e988df 100644
--- a/pkg/tests/validator_test.go
+++ b/pkg/tests/validator_test.go
@@ -29,7 +29,8 @@ func setupValidatorTestsuite(t *testing.T, walletOpts ...options.Option[testsuit
 			iotago.WithSupplyOptions(MAX_SUPPLY, 63, 1, 17, 32, 21, 70),
 			iotago.WithStakingOptions(1, validationBlocksPerSlot, 1),
 			// Pick larger values for ManaShareCoefficient and DecayBalancingConstant for more precision in the calculations.
-			iotago.WithRewardsOptions(8, 8, 11, 200, 200),
+			// Pick a small retention period so we can test rewards expiry.
+			iotago.WithRewardsOptions(8, 8, 11, 200, 200, 5),
 			// Pick Increase/Decrease threshold in accordance with sanity checks (necessary because we changed slot duration).
 			iotago.WithCongestionControlOptions(1, 0, 0, 400_000, 300_000, 100_000, 1000, 100),
 			iotago.WithTimeProviderOptions(
@@ -156,6 +157,33 @@ func Test_Validator_PerfectIssuanceWithHugeStake(t *testing.T) {
 	validatorTest(t, test)
 }
 
+func Test_Validator_PerfectIssuanceWithExpiredRewards(t *testing.T) {
+	ts := setupValidatorTestsuite(t)
+	defer ts.Shutdown()
+
+	validationBlocksPerSlot := ts.API.ProtocolParameters().ValidationBlocksPerSlot()
+	epochDurationSlots := uint64(ts.API.TimeProvider().EpochDurationSlots())
+
+	test := ValidatorTest{
+		ts:              ts,
+		issuancePerSlot: validationBlocksPerSlot,
+		// The retention period is 5, so the sum of rewards should be those from epochs 3 to 7 (= 5 epochs).
+		epochPerformanceFactors: EpochPerformanceMap{
+			// A validator cannot issue blocks in the genesis slot, so we deduct one slot worth of blocks.
+			0: (uint64(validationBlocksPerSlot) * (epochDurationSlots - 1)) >> uint64(ts.API.ProtocolParameters().SlotsPerEpochExponent()),
+			1: (uint64(validationBlocksPerSlot) * (epochDurationSlots)) >> uint64(ts.API.ProtocolParameters().SlotsPerEpochExponent()),
+			2: (uint64(validationBlocksPerSlot) * (epochDurationSlots)) >> uint64(ts.API.ProtocolParameters().SlotsPerEpochExponent()),
+			3: (uint64(validationBlocksPerSlot) * (epochDurationSlots)) >> uint64(ts.API.ProtocolParameters().SlotsPerEpochExponent()),
+			4: (uint64(validationBlocksPerSlot) * (epochDurationSlots)) >> uint64(ts.API.ProtocolParameters().SlotsPerEpochExponent()),
+			5: (uint64(validationBlocksPerSlot) * (epochDurationSlots)) >> uint64(ts.API.ProtocolParameters().SlotsPerEpochExponent()),
+			6: (uint64(validationBlocksPerSlot) * (epochDurationSlots)) >> uint64(ts.API.ProtocolParameters().SlotsPerEpochExponent()),
+			7: (uint64(validationBlocksPerSlot) * (epochDurationSlots)) >> uint64(ts.API.ProtocolParameters().SlotsPerEpochExponent()),
+		},
+	}
+
+	validatorTest(t, test)
+}
+
 func Test_Validator_OverIssuance(t *testing.T) {
 	ts := setupValidatorTestsuite(t)
 	defer ts.Shutdown()
@@ -276,6 +304,7 @@ func validatorTest(t *testing.T, test ValidatorTest) {
 	// Determine the rewards the validators actually got.
 	actualRewards := make(map[iotago.AccountID]iotago.Mana, len(ts.Validators()))
 	claimingEpoch := ts.API.TimeProvider().EpochFromSlot(ts.CurrentSlot())
+	retentionPeriod := iotago.EpochIndex(ts.API.ProtocolParameters().RewardsParameters().RetentionPeriod)
 
 	for _, validatorAccount := range []string{"Genesis:1", "Genesis:2"} {
 		output := ts.DefaultWallet().Output(validatorAccount)
@@ -296,13 +325,21 @@ func validatorTest(t *testing.T, test ValidatorTest) {
 	for accountID, actualReward := range actualRewards {
 		lastRewardEpoch := iotago.EpochIndex(len(test.epochPerformanceFactors))
 		rewards := make([]epochReward, 0, lastRewardEpoch)
-		for epoch := iotago.EpochIndex(0); epoch < lastRewardEpoch; epoch++ {
+
+		var firstRewardEpoch iotago.EpochIndex
+		if retentionPeriod < lastRewardEpoch {
+			firstRewardEpoch = lastRewardEpoch - retentionPeriod
+		} else {
+			firstRewardEpoch = 0
+		}
+
+		for epoch := firstRewardEpoch; epoch < lastRewardEpoch; epoch++ {
 			epochPerformanceFactor := test.epochPerformanceFactors[epoch]
 			epochReward := calculateEpochReward(t, ts, accountID, epoch, epochPerformanceFactor, totalStake, totalValidatorStake)
 			rewards = append(rewards, epochReward)
 		}
 
-		expectedReward := calculateValidatorReward(t, ts, accountID, rewards, startEpoch, claimingEpoch)
+		expectedReward := calculateValidatorReward(t, ts, accountID, rewards, firstRewardEpoch, claimingEpoch)
 
 		require.Equal(t, expectedReward, actualReward, "expected reward for account %s to be %d, was %d", accountID, expectedReward, actualReward)
 	}