Skip to content

Commit 7e2b9a2

Browse files
authored
fix: prune retained attestation state (#408)
* fix: prune retained attestation state * refactor: centralize attestation retention boundary * fix: keep default attestation history for IBC * fix: align attestation retention with pruning
1 parent 2deaf94 commit 7e2b9a2

6 files changed

Lines changed: 199 additions & 47 deletions

File tree

modules/network/README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,20 @@ The module's `EndBlocker` is executed at the end of every block and performs the
128128

129129
1. **Quorum Evaluation**: It iterates through recent blocks that have received attestations and checks if the cumulative voting power of the attesters has reached the required quorum.
130130
2. **Checkpoint Emission**: If quorum is met for a block, it emits an `EventSoftCheckpoint` with the `height`, `block_hash`, a bitmap of the participating attesters, and the total voting power.
131-
3. **Epoch Processing**: It checks if the current block is the end of an epoch. If it is, it performs accounting tasks, such as updating the attester index map for the next epoch.
131+
3. **Epoch Processing**: It checks if the current block is the end of an epoch. If it is, it performs accounting tasks, such as pruning old attestation state and updating the attester index map for the next epoch.
132+
133+
## Attestation State Retention
134+
135+
Attestation state is retained for the last `prune_after` epochs. At each epoch boundary, the module computes the first retained epoch as `current_epoch - prune_after` and prunes all attestation data below that boundary.
136+
137+
The retention policy covers the attestation stores together:
138+
139+
- raw per-height attestation bitmaps
140+
- stored attestation metadata
141+
- per-epoch participation bitmaps
142+
- per-height attester signatures
143+
144+
Queries for pruned heights behave like queries for missing data. `MsgAttest` uses the same height boundary and rejects attestations below the retention window so new writes cannot recreate pruned state.
132145

133146
## Genesis and Queries
134147

modules/network/keeper/abci.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,10 +112,9 @@ func (k Keeper) processEpochEnd(ctx sdk.Context, epoch uint64) error {
112112
}
113113
}
114114

115-
// todo (Alex): find a way to prune only bitmaps that are not used anymore
116-
// if err := k.PruneOldBitmaps(ctx, epoch); err != nil {
117-
// return fmt.Errorf("pruning old data at epoch %d: %w", epoch, err)
118-
// }
115+
if err := k.PruneOldBitmaps(ctx, epoch); err != nil {
116+
return fmt.Errorf("pruning old data at epoch %d: %w", epoch, err)
117+
}
119118

120119
if err := k.BuildValidatorIndexMap(ctx); err != nil {
121120
return fmt.Errorf("rebuilding validator index map at epoch %d: %w", epoch, err)

modules/network/keeper/keeper.go

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -308,40 +308,62 @@ func (k Keeper) IsSoftConfirmed(ctx sdk.Context, height int64) (bool, error) {
308308
return k.CheckQuorum(ctx, votedPower, totalPower)
309309
}
310310

311-
// PruneOldBitmaps removes bitmaps older than PruneAfter epochs
312-
func (k Keeper) PruneOldBitmaps(ctx sdk.Context, currentEpoch uint64) error {
311+
type attestationRetentionBoundary struct {
312+
firstRetainedEpoch uint64
313+
firstRetainedHeight int64
314+
}
315+
316+
func (b attestationRetentionBoundary) prunesHeight(height int64) bool {
317+
return height < b.firstRetainedHeight
318+
}
319+
320+
func (k Keeper) attestationRetentionBoundary(ctx sdk.Context, currentEpoch uint64) *attestationRetentionBoundary {
313321
params := k.GetParams(ctx)
314-
if params.PruneAfter == 0 { // Avoid pruning if PruneAfter is zero or not set
322+
if params.PruneAfter == 0 || params.EpochLength == 0 {
315323
return nil
316324
}
317325
if currentEpoch <= params.PruneAfter {
318326
return nil
319327
}
320328

321-
pruneBeforeEpoch := currentEpoch - params.PruneAfter
322-
pruneHeight := int64(pruneBeforeEpoch * params.EpochLength) // Assuming EpochLength defines blocks per epoch
329+
firstRetainedEpoch := currentEpoch - params.PruneAfter
330+
return &attestationRetentionBoundary{
331+
firstRetainedEpoch: firstRetainedEpoch,
332+
firstRetainedHeight: int64(firstRetainedEpoch * params.EpochLength),
333+
}
334+
}
335+
336+
// PruneOldBitmaps removes attestation state older than PruneAfter epochs.
337+
func (k Keeper) PruneOldBitmaps(ctx sdk.Context, currentEpoch uint64) error {
338+
boundary := k.attestationRetentionBoundary(ctx, currentEpoch)
339+
if boundary == nil {
340+
return nil
341+
}
323342

324343
// Prune attestation bitmaps (raw bitmaps)
325-
attestationRange := new(collections.Range[int64]).StartInclusive(0).EndExclusive(pruneHeight)
344+
attestationRange := new(collections.Range[int64]).StartInclusive(0).EndExclusive(boundary.firstRetainedHeight)
326345
if err := k.AttestationBitmap.Clear(ctx, attestationRange); err != nil {
327-
return fmt.Errorf("clearing attestation bitmaps before height %d: %w", pruneHeight, err)
346+
return fmt.Errorf("clearing attestation bitmaps before height %d: %w", boundary.firstRetainedHeight, err)
328347
}
329348
// Prune stored attestation info (full AttestationBitmap objects)
330-
storedAttestationInfoRange := new(collections.Range[int64]).StartInclusive(0).EndExclusive(pruneHeight)
349+
storedAttestationInfoRange := new(collections.Range[int64]).StartInclusive(0).EndExclusive(boundary.firstRetainedHeight)
331350
if err := k.StoredAttestationInfo.Clear(ctx, storedAttestationInfoRange); err != nil {
332-
return fmt.Errorf("clearing stored attestation info before height %d: %w", pruneHeight, err)
351+
return fmt.Errorf("clearing stored attestation info before height %d: %w", boundary.firstRetainedHeight, err)
333352
}
334353

335354
// Prune epoch bitmaps
336-
epochRange := new(collections.Range[uint64]).StartInclusive(0).EndExclusive(pruneBeforeEpoch)
355+
epochRange := new(collections.Range[uint64]).StartInclusive(0).EndExclusive(boundary.firstRetainedEpoch)
337356
if err := k.EpochBitmap.Clear(ctx, epochRange); err != nil {
338-
return fmt.Errorf("clearing epoch bitmaps before epoch %d: %w", pruneBeforeEpoch, err)
357+
return fmt.Errorf("clearing epoch bitmaps before epoch %d: %w", boundary.firstRetainedEpoch, err)
339358
}
340359

341-
// TODO: Consider pruning signatures associated with pruned heights.
342-
// This would involve iterating k.Signatures and removing entries where height < pruneHeight.
360+
signatureRange := new(collections.Range[collections.Pair[int64, string]]).
361+
EndExclusive(collections.Join(boundary.firstRetainedHeight, ""))
362+
if err := k.Signatures.Clear(ctx, signatureRange); err != nil {
363+
return fmt.Errorf("clearing signatures before height %d: %w", boundary.firstRetainedHeight, err)
364+
}
343365

344-
k.Logger(ctx).Info("Pruned old bitmaps and attestation info", "prunedBeforeEpoch", pruneBeforeEpoch, "prunedBeforeHeight", pruneHeight)
366+
k.Logger(ctx).Info("Pruned old attestation state", "prunedBeforeEpoch", boundary.firstRetainedEpoch, "prunedBeforeHeight", boundary.firstRetainedHeight)
345367
return nil
346368
}
347369

modules/network/keeper/msg_server.go

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -60,21 +60,14 @@ func (k msgServer) Attest(goCtx context.Context, msg *types.MsgAttest) (*types.M
6060
return nil, sdkerr.Wrapf(sdkerrors.ErrInvalidRequest, "attestation height %d exceeds max allowed height %d", msg.Height, maxFutureHeight)
6161
}
6262

63-
// Enforce attestation height lower bound: reject heights that fall below
64-
// the PruneAfter retention window. Attesting pruned/about-to-be-pruned
65-
// heights wastes storage and serves no purpose. This uses the same epoch
66-
// calculation as PruneOldBitmaps so the two stay aligned.
67-
params := k.GetParams(ctx)
68-
minHeight := int64(1)
69-
if params.PruneAfter > 0 && params.EpochLength > 0 {
70-
currentEpoch := uint64(currentHeight) / params.EpochLength
71-
if currentEpoch > params.PruneAfter {
72-
minHeight = int64((currentEpoch - params.PruneAfter) * params.EpochLength)
63+
// Txs run before EndBlocker, so the current epoch's pruning has not run yet.
64+
currentEpoch := k.GetCurrentEpoch(ctx)
65+
if currentEpoch > 0 {
66+
boundary := k.attestationRetentionBoundary(ctx, currentEpoch-1)
67+
if boundary != nil && boundary.prunesHeight(msg.Height) {
68+
return nil, sdkerr.Wrapf(sdkerrors.ErrInvalidRequest, "attestation height %d is below retention window (min %d)", msg.Height, boundary.firstRetainedHeight)
7369
}
7470
}
75-
if msg.Height < minHeight {
76-
return nil, sdkerr.Wrapf(sdkerrors.ErrInvalidRequest, "attestation height %d is below retention window (min %d)", msg.Height, minHeight)
77-
}
7871
bitmap, err := k.GetAttestationBitmap(ctx, msg.Height)
7972
if err != nil && !errors.Is(err, collections.ErrNotFound) {
8073
return nil, sdkerr.Wrap(err, "get attestation bitmap")

modules/network/keeper/msg_server_test.go

Lines changed: 139 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"testing"
1010
"time"
1111

12+
"cosmossdk.io/collections"
1213
"cosmossdk.io/log"
1314
"cosmossdk.io/math"
1415
storetypes "cosmossdk.io/store/types"
@@ -359,12 +360,17 @@ func newTestServer(t *testing.T, sk *MockStakingKeeper) (msgServer, Keeper, sdk.
359360
func TestAttestHeightBounds(t *testing.T) {
360361
myValAddr := sdk.ValAddress("validator1")
361362
ownerAddr := sdk.ValAddress("attester_owner")
362-
// With DefaultParams: EpochLength=1, PruneAfter=15
363-
// At blockHeight=100: currentEpoch=100, minHeight=(100-7)*1=93
363+
shortRetentionParams := types.DefaultParams()
364+
shortRetentionParams.PruneAfter = 15
365+
epochRetentionParams := types.DefaultParams()
366+
epochRetentionParams.EpochLength = 10
367+
epochRetentionParams.PruneAfter = 2
368+
364369
specs := map[string]struct {
365-
blockHeight int64
366-
attestH int64
367-
expErr error
370+
blockHeight int64
371+
attestH int64
372+
paramsOverride *types.Params
373+
expErr error
368374
}{
369375
"future height rejected": {
370376
blockHeight: 100,
@@ -385,18 +391,30 @@ func TestAttestHeightBounds(t *testing.T) {
385391
attestH: 101,
386392
},
387393
"stale height rejected": {
388-
blockHeight: 100,
389-
attestH: 1,
390-
expErr: sdkerrors.ErrInvalidRequest,
394+
blockHeight: 100,
395+
attestH: 1,
396+
paramsOverride: &shortRetentionParams,
397+
expErr: sdkerrors.ErrInvalidRequest,
391398
},
392399
"below retention window rejected": {
393-
blockHeight: 100,
394-
attestH: 84, // minHeight = 85
395-
expErr: sdkerrors.ErrInvalidRequest,
400+
blockHeight: 100,
401+
attestH: 83, // minHeight = 84
402+
paramsOverride: &shortRetentionParams,
403+
expErr: sdkerrors.ErrInvalidRequest,
396404
},
397405
"at retention boundary accepted": {
398-
blockHeight: 100,
399-
attestH: 93, // exactly minHeight
406+
blockHeight: 100,
407+
attestH: 84,
408+
paramsOverride: &shortRetentionParams,
409+
},
410+
"retained checkpoint accepted before next epoch pruning runs": {
411+
blockHeight: 50,
412+
attestH: 20,
413+
paramsOverride: &epochRetentionParams,
414+
},
415+
"default retention keeps IBC handshake history": {
416+
blockHeight: 139,
417+
attestH: 25,
400418
},
401419
"early chain no stale rejection": {
402420
blockHeight: 16,
@@ -419,7 +437,11 @@ func TestAttestHeightBounds(t *testing.T) {
419437
Height: spec.blockHeight,
420438
}, false, logger).WithContext(t.Context())
421439

422-
require.NoError(t, keeper.SetParams(ctx, types.DefaultParams()))
440+
params := types.DefaultParams()
441+
if spec.paramsOverride != nil {
442+
params = *spec.paramsOverride
443+
}
444+
require.NoError(t, keeper.SetParams(ctx, params))
423445

424446
joinMsg := &types.MsgJoinAttesterSet{
425447
Authority: ownerAddr.String(),
@@ -448,6 +470,109 @@ func TestAttestHeightBounds(t *testing.T) {
448470
}
449471
}
450472

473+
func TestPruneOldBitmapsRemovesAllAttestationStateBelowRetentionWindow(t *testing.T) {
474+
sk := NewMockStakingKeeper()
475+
_, keeper, ctx := newTestServer(t, &sk)
476+
477+
params := types.DefaultParams()
478+
params.EpochLength = 10
479+
params.PruneAfter = 2
480+
require.NoError(t, keeper.SetParams(ctx, params))
481+
482+
oldHeight := int64(19)
483+
boundaryHeight := int64(20)
484+
oldEpoch := uint64(1)
485+
boundaryEpoch := uint64(2)
486+
attester := sdk.ValAddress("validator1").String()
487+
488+
require.NoError(t, keeper.SetAttestationBitmap(ctx, oldHeight, []byte{0x01}))
489+
require.NoError(t, keeper.StoredAttestationInfo.Set(ctx, oldHeight, types.AttestationBitmap{
490+
Height: oldHeight,
491+
Bitmap: []byte{0x01},
492+
}))
493+
require.NoError(t, keeper.SetEpochBitmap(ctx, oldEpoch, []byte{0x01}))
494+
require.NoError(t, keeper.SetSignature(ctx, oldHeight, attester, []byte("old-signature")))
495+
496+
require.NoError(t, keeper.SetAttestationBitmap(ctx, boundaryHeight, []byte{0x02}))
497+
require.NoError(t, keeper.StoredAttestationInfo.Set(ctx, boundaryHeight, types.AttestationBitmap{
498+
Height: boundaryHeight,
499+
Bitmap: []byte{0x02},
500+
}))
501+
require.NoError(t, keeper.SetEpochBitmap(ctx, boundaryEpoch, []byte{0x02}))
502+
require.NoError(t, keeper.SetSignature(ctx, boundaryHeight, attester, []byte("boundary-signature")))
503+
504+
require.NoError(t, keeper.PruneOldBitmaps(ctx, 4))
505+
506+
_, err := keeper.GetAttestationBitmap(ctx, oldHeight)
507+
require.ErrorIs(t, err, collections.ErrNotFound)
508+
_, err = keeper.StoredAttestationInfo.Get(ctx, oldHeight)
509+
require.ErrorIs(t, err, collections.ErrNotFound)
510+
require.Nil(t, keeper.GetEpochBitmap(ctx, oldEpoch))
511+
hasOldSignature, err := keeper.HasSignature(ctx, oldHeight, attester)
512+
require.NoError(t, err)
513+
require.False(t, hasOldSignature)
514+
515+
bitmap, err := keeper.GetAttestationBitmap(ctx, boundaryHeight)
516+
require.NoError(t, err)
517+
require.Equal(t, []byte{0x02}, bitmap)
518+
_, err = keeper.StoredAttestationInfo.Get(ctx, boundaryHeight)
519+
require.NoError(t, err)
520+
require.Equal(t, []byte{0x02}, keeper.GetEpochBitmap(ctx, boundaryEpoch))
521+
hasBoundarySignature, err := keeper.HasSignature(ctx, boundaryHeight, attester)
522+
require.NoError(t, err)
523+
require.True(t, hasBoundarySignature)
524+
}
525+
526+
func TestAttestationRetentionBoundary(t *testing.T) {
527+
sk := NewMockStakingKeeper()
528+
_, keeper, ctx := newTestServer(t, &sk)
529+
530+
params := types.DefaultParams()
531+
params.EpochLength = 10
532+
params.PruneAfter = 2
533+
require.NoError(t, keeper.SetParams(ctx, params))
534+
535+
require.Nil(t, keeper.attestationRetentionBoundary(ctx, 2))
536+
537+
boundary := keeper.attestationRetentionBoundary(ctx, 4)
538+
require.NotNil(t, boundary)
539+
require.Equal(t, uint64(2), boundary.firstRetainedEpoch)
540+
require.Equal(t, int64(20), boundary.firstRetainedHeight)
541+
require.True(t, boundary.prunesHeight(19))
542+
require.False(t, boundary.prunesHeight(20))
543+
}
544+
545+
func TestEndBlockerPrunesAttestationStateOnEpochBoundary(t *testing.T) {
546+
sk := NewMockStakingKeeper()
547+
_, keeper, ctx := newTestServer(t, &sk)
548+
549+
params := types.DefaultParams()
550+
params.EpochLength = 10
551+
params.PruneAfter = 2
552+
require.NoError(t, keeper.SetParams(ctx, params))
553+
554+
ctx = ctx.WithBlockHeight(49)
555+
attester := sdk.ValAddress("validator1").String()
556+
require.NoError(t, keeper.SetAttestationBitmap(ctx, 19, []byte{0x01}))
557+
require.NoError(t, keeper.SetSignature(ctx, 19, attester, []byte("old-signature")))
558+
require.NoError(t, keeper.SetAttestationBitmap(ctx, 20, []byte{0x02}))
559+
require.NoError(t, keeper.SetSignature(ctx, 20, attester, []byte("boundary-signature")))
560+
561+
require.NoError(t, keeper.EndBlocker(ctx))
562+
563+
_, err := keeper.GetAttestationBitmap(ctx, 19)
564+
require.ErrorIs(t, err, collections.ErrNotFound)
565+
hasOldSignature, err := keeper.HasSignature(ctx, 19, attester)
566+
require.NoError(t, err)
567+
require.False(t, hasOldSignature)
568+
569+
_, err = keeper.GetAttestationBitmap(ctx, 20)
570+
require.NoError(t, err)
571+
hasBoundarySignature, err := keeper.HasSignature(ctx, 20, attester)
572+
require.NoError(t, err)
573+
require.True(t, hasBoundarySignature)
574+
}
575+
451576
var _ types.StakingKeeper = &MockStakingKeeper{}
452577

453578
type MockStakingKeeper struct {

modules/network/types/params.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ var (
1111
DefaultEpochLength = uint64(1) // Changed from 10 to 1 to allow attestations on every block
1212
DefaultQuorumFraction = math.LegacyNewDecWithPrec(667, 3) // 2/3
1313
DefaultMinParticipation = math.LegacyNewDecWithPrec(5, 1) // 1/2
14-
DefaultPruneAfter = uint64(15) // also used as number of blocks for attestations to land
14+
DefaultPruneAfter = uint64(1000)
1515
DefaultSignMode = SignMode_SIGN_MODE_CHECKPOINT
1616
)
1717

0 commit comments

Comments
 (0)