Skip to content

Commit 239301e

Browse files
authored
Efficient Consensus State Iteration (#125)
* start with efficient consensus state lookup * writeup pruning logic * fix tests * add documentation * improve byte efficiency * actually fix tests and bug * deduplicate * fix return * edit changelog
1 parent db6f316 commit 239301e

File tree

5 files changed

+396
-64
lines changed

5 files changed

+396
-64
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ Ref: https://keepachangelog.com/en/1.0.0/
5757
### Improvements
5858

5959
* (modules/core/04-channel) [\#7949](https://github.com/cosmos/cosmos-sdk/issues/7949) Standardized channel `Acknowledgement` moved to its own file. Codec registration redundancy removed.
60+
* (modules/light-clients/07-tendermint) [\#125](https://github.com/cosmos/ibc-go/pull/125) Implement efficient iteration of consensus states and pruning of earliest expired consensus state on UpdateClient.
6061

6162
## IBC in the Cosmos SDK Repository
6263

modules/light-clients/07-tendermint/types/store.go

+152-2
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,42 @@
11
package types
22

33
import (
4+
"encoding/binary"
45
"strings"
56

67
"github.com/cosmos/cosmos-sdk/codec"
8+
"github.com/cosmos/cosmos-sdk/store/prefix"
79
sdk "github.com/cosmos/cosmos-sdk/types"
810
sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
911
clienttypes "github.com/cosmos/ibc-go/modules/core/02-client/types"
1012
host "github.com/cosmos/ibc-go/modules/core/24-host"
1113
"github.com/cosmos/ibc-go/modules/core/exported"
1214
)
1315

14-
// KeyProcessedTime is appended to consensus state key to store the processed time
15-
var KeyProcessedTime = []byte("/processedTime")
16+
/*
17+
This file contains the logic for storage and iteration over `IterationKey` metadata that is stored
18+
for each consensus state. The consensus state key specified in ICS-24 and expected by counterparty chains
19+
stores the consensus state under the key: `consensusStates/{revision_number}-{revision_height}`, with each number
20+
represented as a string.
21+
While this works fine for IBC proof verification, it makes efficient iteration difficult since the lexicographic order
22+
of the consensus state keys do not match the height order of consensus states. This makes consensus state pruning and
23+
monotonic time enforcement difficult since it is inefficient to find the earliest consensus state or to find the neigboring
24+
consensus states given a consensus state height.
25+
Changing the ICS-24 representation will be a major breaking change that requires counterparty chains to accept a new key format.
26+
Thus to avoid breaking IBC, we can store a lookup from a more efficiently formatted key: `iterationKey` to the consensus state key which
27+
stores the underlying consensus state. This efficient iteration key will be formatted like so: `iterateConsensusStates{BigEndianRevisionBytes}{BigEndianHeightBytes}`.
28+
This ensures that the lexicographic order of iteration keys match the height order of the consensus states. Thus, we can use the SDK store's
29+
Iterators to iterate over the consensus states in ascending/descending order by providing a mapping from `iterationKey -> consensusStateKey -> ConsensusState`.
30+
A future version of IBC may choose to replace the ICS24 ConsensusState path with the more efficient format and make this indirection unnecessary.
31+
*/
32+
33+
const KeyIterateConsensusStatePrefix = "iterateConsensusStates"
34+
35+
var (
36+
// KeyProcessedTime is appended to consensus state key to store the processed time
37+
KeyProcessedTime = []byte("/processedTime")
38+
KeyIteration = []byte("/iterationKey")
39+
)
1640

1741
// SetConsensusState stores the consensus state at the given height.
1842
func SetConsensusState(clientStore sdk.KVStore, cdc codec.BinaryMarshaler, consensusState *ConsensusState, height exported.Height) {
@@ -48,6 +72,12 @@ func GetConsensusState(store sdk.KVStore, cdc codec.BinaryMarshaler, height expo
4872
return consensusState, nil
4973
}
5074

75+
// deleteConsensusState deletes the consensus state at the given height
76+
func deleteConsensusState(clientStore sdk.KVStore, height exported.Height) {
77+
key := host.ConsensusStateKey(height)
78+
clientStore.Delete(key)
79+
}
80+
5181
// IterateProcessedTime iterates through the prefix store and applies the callback.
5282
// If the cb returns true, then iterator will close and stop.
5383
func IterateProcessedTime(store sdk.KVStore, cb func(key, val []byte) bool) {
@@ -94,3 +124,123 @@ func GetProcessedTime(clientStore sdk.KVStore, height exported.Height) (uint64,
94124
}
95125
return sdk.BigEndianToUint64(bz), true
96126
}
127+
128+
// deleteProcessedTime deletes the processedTime for a given height
129+
func deleteProcessedTime(clientStore sdk.KVStore, height exported.Height) {
130+
key := ProcessedTimeKey(height)
131+
clientStore.Delete(key)
132+
}
133+
134+
// Iteration Code
135+
136+
// IterationKey returns the key under which the consensus state key will be stored.
137+
// The iteration key is a BigEndian representation of the consensus state key to support efficient iteration.
138+
func IterationKey(height exported.Height) []byte {
139+
heightBytes := bigEndianHeightBytes(height)
140+
return append([]byte(KeyIterateConsensusStatePrefix), heightBytes...)
141+
}
142+
143+
// SetIterationKey stores the consensus state key under a key that is more efficient for ordered iteration
144+
func SetIterationKey(clientStore sdk.KVStore, height exported.Height) {
145+
key := IterationKey(height)
146+
val := host.ConsensusStateKey(height)
147+
clientStore.Set(key, val)
148+
}
149+
150+
// GetIterationKey returns the consensus state key stored under the efficient iteration key.
151+
// NOTE: This function is currently only used for testing purposes
152+
func GetIterationKey(clientStore sdk.KVStore, height exported.Height) []byte {
153+
key := IterationKey(height)
154+
return clientStore.Get(key)
155+
}
156+
157+
// deleteIterationKey deletes the iteration key for a given height
158+
func deleteIterationKey(clientStore sdk.KVStore, height exported.Height) {
159+
key := IterationKey(height)
160+
clientStore.Delete(key)
161+
}
162+
163+
// GetHeightFromIterationKey takes an iteration key and returns the height that it references
164+
func GetHeightFromIterationKey(iterKey []byte) exported.Height {
165+
bigEndianBytes := iterKey[len([]byte(KeyIterateConsensusStatePrefix)):]
166+
revisionBytes := bigEndianBytes[0:8]
167+
heightBytes := bigEndianBytes[8:]
168+
revision := binary.BigEndian.Uint64(revisionBytes)
169+
height := binary.BigEndian.Uint64(heightBytes)
170+
return clienttypes.NewHeight(revision, height)
171+
}
172+
173+
func IterateConsensusStateAscending(clientStore sdk.KVStore, cb func(height exported.Height) (stop bool)) error {
174+
iterator := sdk.KVStorePrefixIterator(clientStore, []byte(KeyIterateConsensusStatePrefix))
175+
defer iterator.Close()
176+
177+
for ; iterator.Valid(); iterator.Next() {
178+
iterKey := iterator.Key()
179+
height := GetHeightFromIterationKey(iterKey)
180+
if cb(height) {
181+
return nil
182+
}
183+
}
184+
return nil
185+
}
186+
187+
// GetNextConsensusState returns the lowest consensus state that is larger than the given height.
188+
// The Iterator returns a storetypes.Iterator which iterates from start (inclusive) to end (exclusive).
189+
// Thus, to get the next consensus state, we must first call iterator.Next() and then get the value.
190+
func GetNextConsensusState(clientStore sdk.KVStore, cdc codec.BinaryMarshaler, height exported.Height) (*ConsensusState, bool) {
191+
iterateStore := prefix.NewStore(clientStore, []byte(KeyIterateConsensusStatePrefix))
192+
iterator := iterateStore.Iterator(bigEndianHeightBytes(height), nil)
193+
defer iterator.Close()
194+
// ignore the consensus state at current height and get next height
195+
iterator.Next()
196+
if !iterator.Valid() {
197+
return nil, false
198+
}
199+
200+
csKey := iterator.Value()
201+
202+
return getTmConsensusState(clientStore, cdc, csKey)
203+
}
204+
205+
// GetPreviousConsensusState returns the highest consensus state that is lower than the given height.
206+
// The Iterator returns a storetypes.Iterator which iterates from the end (exclusive) to start (inclusive).
207+
// Thus to get previous consensus state we call iterator.Value() immediately.
208+
func GetPreviousConsensusState(clientStore sdk.KVStore, cdc codec.BinaryMarshaler, height exported.Height) (*ConsensusState, bool) {
209+
iterateStore := prefix.NewStore(clientStore, []byte(KeyIterateConsensusStatePrefix))
210+
iterator := iterateStore.ReverseIterator(nil, bigEndianHeightBytes(height))
211+
defer iterator.Close()
212+
213+
if !iterator.Valid() {
214+
return nil, false
215+
}
216+
217+
csKey := iterator.Value()
218+
219+
return getTmConsensusState(clientStore, cdc, csKey)
220+
}
221+
222+
// Helper function for GetNextConsensusState and GetPreviousConsensusState
223+
func getTmConsensusState(clientStore sdk.KVStore, cdc codec.BinaryMarshaler, key []byte) (*ConsensusState, bool) {
224+
bz := clientStore.Get(key)
225+
if bz == nil {
226+
return nil, false
227+
}
228+
229+
consensusStateI, err := clienttypes.UnmarshalConsensusState(cdc, bz)
230+
if err != nil {
231+
return nil, false
232+
}
233+
234+
consensusState, ok := consensusStateI.(*ConsensusState)
235+
if !ok {
236+
return nil, false
237+
}
238+
return consensusState, true
239+
}
240+
241+
func bigEndianHeightBytes(height exported.Height) []byte {
242+
heightBytes := make([]byte, 16)
243+
binary.BigEndian.PutUint64(heightBytes, height.GetRevisionNumber())
244+
binary.BigEndian.PutUint64(heightBytes[8:], height.GetRevisionHeight())
245+
return heightBytes
246+
}

modules/light-clients/07-tendermint/types/store_test.go

+76
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
package types_test
22

33
import (
4+
"math"
5+
"time"
6+
47
clienttypes "github.com/cosmos/ibc-go/modules/core/02-client/types"
8+
commitmenttypes "github.com/cosmos/ibc-go/modules/core/23-commitment/types"
59
host "github.com/cosmos/ibc-go/modules/core/24-host"
610
"github.com/cosmos/ibc-go/modules/core/exported"
711
solomachinetypes "github.com/cosmos/ibc-go/modules/light-clients/06-solomachine/types"
@@ -116,3 +120,75 @@ func (suite *TendermintTestSuite) TestGetProcessedTime() {
116120
_, ok = types.GetProcessedTime(store, clienttypes.NewHeight(1, 1))
117121
suite.Require().False(ok, "retrieved processed time for a non-existent consensus state")
118122
}
123+
124+
func (suite *TendermintTestSuite) TestIterationKey() {
125+
testHeights := []exported.Height{
126+
clienttypes.NewHeight(0, 1),
127+
clienttypes.NewHeight(0, 1234),
128+
clienttypes.NewHeight(7890, 4321),
129+
clienttypes.NewHeight(math.MaxUint64, math.MaxUint64),
130+
}
131+
for _, h := range testHeights {
132+
k := types.IterationKey(h)
133+
retrievedHeight := types.GetHeightFromIterationKey(k)
134+
suite.Require().Equal(h, retrievedHeight, "retrieving height from iteration key failed")
135+
}
136+
}
137+
138+
func (suite *TendermintTestSuite) TestIterateConsensusStates() {
139+
nextValsHash := []byte("nextVals")
140+
141+
// Set iteration keys and consensus states
142+
types.SetIterationKey(suite.chainA.App.GetIBCKeeper().ClientKeeper.ClientStore(suite.chainA.GetContext(), "testClient"), clienttypes.NewHeight(0, 1))
143+
suite.chainA.App.GetIBCKeeper().ClientKeeper.SetClientConsensusState(suite.chainA.GetContext(), "testClient", clienttypes.NewHeight(0, 1), types.NewConsensusState(time.Now(), commitmenttypes.NewMerkleRoot([]byte("hash0-1")), nextValsHash))
144+
types.SetIterationKey(suite.chainA.App.GetIBCKeeper().ClientKeeper.ClientStore(suite.chainA.GetContext(), "testClient"), clienttypes.NewHeight(4, 9))
145+
suite.chainA.App.GetIBCKeeper().ClientKeeper.SetClientConsensusState(suite.chainA.GetContext(), "testClient", clienttypes.NewHeight(4, 9), types.NewConsensusState(time.Now(), commitmenttypes.NewMerkleRoot([]byte("hash4-9")), nextValsHash))
146+
types.SetIterationKey(suite.chainA.App.GetIBCKeeper().ClientKeeper.ClientStore(suite.chainA.GetContext(), "testClient"), clienttypes.NewHeight(0, 10))
147+
suite.chainA.App.GetIBCKeeper().ClientKeeper.SetClientConsensusState(suite.chainA.GetContext(), "testClient", clienttypes.NewHeight(0, 10), types.NewConsensusState(time.Now(), commitmenttypes.NewMerkleRoot([]byte("hash0-10")), nextValsHash))
148+
types.SetIterationKey(suite.chainA.App.GetIBCKeeper().ClientKeeper.ClientStore(suite.chainA.GetContext(), "testClient"), clienttypes.NewHeight(0, 4))
149+
suite.chainA.App.GetIBCKeeper().ClientKeeper.SetClientConsensusState(suite.chainA.GetContext(), "testClient", clienttypes.NewHeight(0, 4), types.NewConsensusState(time.Now(), commitmenttypes.NewMerkleRoot([]byte("hash0-4")), nextValsHash))
150+
types.SetIterationKey(suite.chainA.App.GetIBCKeeper().ClientKeeper.ClientStore(suite.chainA.GetContext(), "testClient"), clienttypes.NewHeight(40, 1))
151+
suite.chainA.App.GetIBCKeeper().ClientKeeper.SetClientConsensusState(suite.chainA.GetContext(), "testClient", clienttypes.NewHeight(40, 1), types.NewConsensusState(time.Now(), commitmenttypes.NewMerkleRoot([]byte("hash40-1")), nextValsHash))
152+
153+
var testArr []string
154+
cb := func(height exported.Height) bool {
155+
testArr = append(testArr, height.String())
156+
return false
157+
}
158+
159+
types.IterateConsensusStateAscending(suite.chainA.App.GetIBCKeeper().ClientKeeper.ClientStore(suite.chainA.GetContext(), "testClient"), cb)
160+
expectedArr := []string{"0-1", "0-4", "0-10", "4-9", "40-1"}
161+
suite.Require().Equal(expectedArr, testArr)
162+
}
163+
164+
func (suite *TendermintTestSuite) TestGetNeighboringConsensusStates() {
165+
nextValsHash := []byte("nextVals")
166+
cs01 := types.NewConsensusState(time.Now().UTC(), commitmenttypes.NewMerkleRoot([]byte("hash0-1")), nextValsHash)
167+
cs04 := types.NewConsensusState(time.Now().UTC(), commitmenttypes.NewMerkleRoot([]byte("hash0-4")), nextValsHash)
168+
cs49 := types.NewConsensusState(time.Now().UTC(), commitmenttypes.NewMerkleRoot([]byte("hash4-9")), nextValsHash)
169+
height01 := clienttypes.NewHeight(0, 1)
170+
height04 := clienttypes.NewHeight(0, 4)
171+
height49 := clienttypes.NewHeight(4, 9)
172+
173+
// Set iteration keys and consensus states
174+
types.SetIterationKey(suite.chainA.App.GetIBCKeeper().ClientKeeper.ClientStore(suite.chainA.GetContext(), "testClient"), height01)
175+
suite.chainA.App.GetIBCKeeper().ClientKeeper.SetClientConsensusState(suite.chainA.GetContext(), "testClient", height01, cs01)
176+
types.SetIterationKey(suite.chainA.App.GetIBCKeeper().ClientKeeper.ClientStore(suite.chainA.GetContext(), "testClient"), height04)
177+
suite.chainA.App.GetIBCKeeper().ClientKeeper.SetClientConsensusState(suite.chainA.GetContext(), "testClient", height04, cs04)
178+
types.SetIterationKey(suite.chainA.App.GetIBCKeeper().ClientKeeper.ClientStore(suite.chainA.GetContext(), "testClient"), height49)
179+
suite.chainA.App.GetIBCKeeper().ClientKeeper.SetClientConsensusState(suite.chainA.GetContext(), "testClient", height49, cs49)
180+
181+
prevCs01, ok := types.GetPreviousConsensusState(suite.chainA.App.GetIBCKeeper().ClientKeeper.ClientStore(suite.chainA.GetContext(), "testClient"), suite.chainA.Codec, height01)
182+
suite.Require().Nil(prevCs01, "consensus state exists before lowest consensus state")
183+
suite.Require().False(ok)
184+
prevCs49, ok := types.GetPreviousConsensusState(suite.chainA.App.GetIBCKeeper().ClientKeeper.ClientStore(suite.chainA.GetContext(), "testClient"), suite.chainA.Codec, height49)
185+
suite.Require().Equal(cs04, prevCs49, "previous consensus state is not returned correctly")
186+
suite.Require().True(ok)
187+
188+
nextCs01, ok := types.GetNextConsensusState(suite.chainA.App.GetIBCKeeper().ClientKeeper.ClientStore(suite.chainA.GetContext(), "testClient"), suite.chainA.Codec, height01)
189+
suite.Require().Equal(cs04, nextCs01, "next consensus state not returned correctly")
190+
suite.Require().True(ok)
191+
nextCs49, ok := types.GetNextConsensusState(suite.chainA.App.GetIBCKeeper().ClientKeeper.ClientStore(suite.chainA.GetContext(), "testClient"), suite.chainA.Codec, height49)
192+
suite.Require().Nil(nextCs49, "next consensus state exists after highest consensus state")
193+
suite.Require().False(ok)
194+
}

modules/light-clients/07-tendermint/types/update.go

+36
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ import (
3737
// number must be the same. To update to a new revision, use a separate upgrade path
3838
// Tendermint client validity checking uses the bisection algorithm described
3939
// in the [Tendermint spec](https://github.com/tendermint/spec/blob/master/spec/consensus/light-client.md).
40+
//
41+
// Pruning:
42+
// UpdateClient will additionally retrieve the earliest consensus state for this clientID and check if it is expired. If it is,
43+
// that consensus state will be pruned from store along with all associated metadata. This will prevent the client store from
44+
// becoming bloated with expired consensus states that can no longer be used for updates and packet verification.
4045
func (cs ClientState) CheckHeaderAndUpdateState(
4146
ctx sdk.Context, cdc codec.BinaryMarshaler, clientStore sdk.KVStore,
4247
header exported.Header,
@@ -60,6 +65,35 @@ func (cs ClientState) CheckHeaderAndUpdateState(
6065
return nil, nil, err
6166
}
6267

68+
// Check the earliest consensus state to see if it is expired, if so then set the prune height
69+
// so that we can delete consensus state and all associated metadata.
70+
var (
71+
pruneHeight exported.Height
72+
pruneError error
73+
)
74+
pruneCb := func(height exported.Height) bool {
75+
consState, err := GetConsensusState(clientStore, cdc, height)
76+
// this error should never occur
77+
if err != nil {
78+
pruneError = err
79+
return true
80+
}
81+
if cs.IsExpired(consState.Timestamp, ctx.BlockTime()) {
82+
pruneHeight = height
83+
}
84+
return true
85+
}
86+
IterateConsensusStateAscending(clientStore, pruneCb)
87+
if pruneError != nil {
88+
return nil, nil, pruneError
89+
}
90+
// if pruneHeight is set, delete consensus state and metadata
91+
if pruneHeight != nil {
92+
deleteConsensusState(clientStore, pruneHeight)
93+
deleteProcessedTime(clientStore, pruneHeight)
94+
deleteIterationKey(clientStore, pruneHeight)
95+
}
96+
6397
newClientState, consensusState := update(ctx, clientStore, &cs, tmHeader)
6498
return newClientState, consensusState, nil
6599
}
@@ -180,7 +214,9 @@ func update(ctx sdk.Context, clientStore sdk.KVStore, clientState *ClientState,
180214

181215
// set context time as processed time as this is state internal to tendermint client logic.
182216
// client state and consensus state will be set by client keeper
217+
// set iteration key to provide ability for efficient ordered iteration of consensus states.
183218
SetProcessedTime(clientStore, header.GetHeight(), uint64(ctx.BlockTime().UnixNano()))
219+
SetIterationKey(clientStore, header.GetHeight())
184220

185221
return clientState, consensusState
186222
}

0 commit comments

Comments
 (0)