Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

zoneconcierge API: find the BTC-finalised chain info before specific CZ height #264

Merged
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions proto/babylon/zoneconcierge/query.proto
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ service Query {
rpc FinalizedChainInfo(QueryFinalizedChainInfoRequest) returns (QueryFinalizedChainInfoResponse) {
option (google.api.http).get = "/babylon/zoneconcierge/v1/finalized_chain_info/{chain_id}";
}
// FinalizedChainInfoUntilHeight queries the BTC-finalised info no later than the provided CZ height, with proofs
rpc FinalizedChainInfoUntilHeight(QueryFinalizedChainInfoUntilHeightRequest) returns (QueryFinalizedChainInfoUntilHeightResponse) {
option (google.api.http).get = "/babylon/zoneconcierge/v1/finalized_chain_info/{chain_id}/height/{height}";
}
}

// QueryParamsRequest is request type for the Query/Params RPC method.
Expand Down Expand Up @@ -151,3 +155,44 @@ message QueryFinalizedChainInfoResponse {
// It is the two TransactionInfo in the best (i.e., earliest) checkpoint submission
repeated babylon.btccheckpoint.v1.TransactionInfo proof_epoch_submitted = 8;
}


Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extra empty line.

// QueryFinalizedChainInfoUntilHeightRequest is request type for the Query/FinalizedChainInfoUntilHeight RPC method.
message QueryFinalizedChainInfoUntilHeightRequest {
// chain_id is the ID of the CZ
string chain_id = 1;
// height is the height of the CZ chain
// such that the returned finalised chain info will be no later than this height
uint64 height = 2;
// prove indicates whether the querier wants to get proofs of this timestamp
bool prove = 3;
}

// QueryFinalizedChainInfoUntilHeightResponse is response type for the Query/FinalizedChainInfoUntilHeight RPC method.
message QueryFinalizedChainInfoUntilHeightResponse {
// finalized_chain_info is the info of the CZ
babylon.zoneconcierge.v1.ChainInfo finalized_chain_info = 1;

/*
The following fields include metadata related to this chain info
*/
// epoch_info is the metadata of the last BTC-finalised epoch
babylon.epoching.v1.Epoch epoch_info = 2;
// raw_checkpoint is the raw checkpoint of this epoch
babylon.checkpointing.v1.RawCheckpoint raw_checkpoint = 3;
// btc_submission_key is position of two BTC txs that include the raw checkpoint of this epoch
babylon.btccheckpoint.v1.SubmissionKey btc_submission_key = 4;

/*
The following fields include proofs that attest the chain info is BTC-finalised
*/
// proof_tx_in_block is the proof that tx that carries the header is included in a certain Babylon block
tendermint.types.TxProof proof_tx_in_block = 5;
// proof_header_in_epoch is the proof that the Babylon header is in a certain epoch
tendermint.crypto.Proof proof_header_in_epoch = 6;
// proof_epoch_sealed is the proof that the epoch is sealed
babylon.zoneconcierge.v1.ProofEpochSealed proof_epoch_sealed = 7;
// proof_epoch_submitted is the proof that the epoch's checkpoint is included in BTC ledger
// It is the two TransactionInfo in the best (i.e., earliest) checkpoint submission
repeated babylon.btccheckpoint.v1.TransactionInfo proof_epoch_submitted = 8;
}
4 changes: 3 additions & 1 deletion proto/babylon/zoneconcierge/zoneconcierge.proto
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,12 @@ message Forks {
message ChainInfo {
// chain_id is the ID of the chain
string chain_id = 1;
// latest_header is the latest header in the canonical chain of CZ
// latest_header is the latest header in CZ's canonical chain
IndexedHeader latest_header = 2;
// latest_forks is the latest forks, formed as a series of IndexedHeader (from low to high)
Forks latest_forks = 3;
// timestamped_headers_count is the number of timestamped headers in CZ's canonical chain
uint64 timestamped_headers_count = 4;
}

// ProofEpochSealed is the proof that an epoch is sealed by the sealer header, i.e., the 2nd header of the next epoch
Expand Down
30 changes: 30 additions & 0 deletions x/zoneconcierge/keeper/canonical_chain_indexer.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,42 @@
package keeper

import (
"fmt"

sdkerrors "cosmossdk.io/errors"
"github.com/babylonchain/babylon/x/zoneconcierge/types"
"github.com/cosmos/cosmos-sdk/store/prefix"
sdk "github.com/cosmos/cosmos-sdk/types"
)

// FindClosestHeader finds the IndexedHeader that is closest to (but not after) the given height
// TODO: test
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see tests for this in this PR.

func (k Keeper) FindClosestHeader(ctx sdk.Context, chainID string, height uint64) (*types.IndexedHeader, error) {
chainInfo := k.GetChainInfo(ctx, chainID)
if chainInfo.LatestHeader == nil {
return nil, fmt.Errorf("chain with ID %s does not have a timestamped header", chainID)
}

// if the given height is no lower than the latest header, return the latest header directly
if chainInfo.LatestHeader.Height <= height {
return chainInfo.LatestHeader, nil
}

// the requested height is lower than the latest header, trace back until finding a timestamped header
store := k.canonicalChainStore(ctx, chainID)
heightBytes := sdk.Uint64ToBigEndian(height)
iter := store.ReverseIterator(nil, heightBytes)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iterators need to be closed, defer iter.close() after this line should do the trick.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch!

// if there is no key within range [0, height], return error
if !iter.Valid() {
return nil, fmt.Errorf("chain with ID %s does not have a timestamped header before height %d", chainID, height)
}
// find the header in bytes, decode and return
headerBytes := iter.Value()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice way of finding the closest header 👏

var header types.IndexedHeader
k.cdc.MustUnmarshal(headerBytes, &header)
return &header, nil
}

func (k Keeper) GetHeader(ctx sdk.Context, chainID string, height uint64) (*types.IndexedHeader, error) {
store := k.canonicalChainStore(ctx, chainID)
heightBytes := sdk.Uint64ToBigEndian(height)
Expand Down
45 changes: 43 additions & 2 deletions x/zoneconcierge/keeper/canonical_chain_indexer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ func FuzzCanonicalChainIndexer(f *testing.F) {
ctx := babylonChain.GetContext()
hooks := zcKeeper.Hooks()

// invoke the hook a random number of times to simulate a random number of blocks
// simulate a random number of blocks
numHeaders := datagen.RandomInt(100) + 1
headers := SimulateHeadersViaHook(ctx, hooks, czChain.ChainID, numHeaders)
headers := SimulateHeadersViaHook(ctx, hooks, czChain.ChainID, 0, numHeaders)

// check if the canonical chain index is correct or not
for i := uint64(0); i < numHeaders; i++ {
Expand All @@ -42,3 +42,44 @@ func FuzzCanonicalChainIndexer(f *testing.F) {
require.Equal(t, headers[numHeaders-1].Header.LastCommitHash, chainInfo.LatestHeader.Hash)
})
}

func FuzzFindClosestHeader(f *testing.F) {
datagen.AddRandomSeedsToFuzzer(f, 10)

f.Fuzz(func(t *testing.T, seed int64) {
rand.Seed(seed)

_, babylonChain, czChain, babylonApp := SetupTest(t)
zcKeeper := babylonApp.ZoneConciergeKeeper

ctx := babylonChain.GetContext()
hooks := zcKeeper.Hooks()

// no header at the moment, FindClosestHeader invocation should give error
_, err := zcKeeper.FindClosestHeader(ctx, czChain.ChainID, 100)
require.Error(t, err)

// simulate a random number of blocks
numHeaders := datagen.RandomInt(100) + 1
headers := SimulateHeadersViaHook(ctx, hooks, czChain.ChainID, 0, numHeaders)

header, err := zcKeeper.FindClosestHeader(ctx, czChain.ChainID, numHeaders)
require.NoError(t, err)
require.Equal(t, headers[len(headers)-1].Header.LastCommitHash, header.Hash)

// skip some a non-zero number of headers in between
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't entirely understand this sentence

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry that's a typo 😢 I was meaning that here we skip a non-zero number of headers in between, in order to create a gap of non-timestamped headers. Fixed

gap := datagen.RandomInt(10) + 1

// simulate a random number of blocks
// where the new batch of headers has a gap with the previous batch
SimulateHeadersViaHook(ctx, hooks, czChain.ChainID, numHeaders+gap+1, numHeaders)

// get a random height that is in this gap
randomHeightInGap := datagen.RandomInt(int(gap+1)) + numHeaders
// find the closest header with the given randomHeightInGap
header, err = zcKeeper.FindClosestHeader(ctx, czChain.ChainID, randomHeightInGap)
require.NoError(t, err)
// the header should be the same as the last header in the last batch
require.Equal(t, headers[len(headers)-1].Header.LastCommitHash, header.Hash)
})
}
13 changes: 11 additions & 2 deletions x/zoneconcierge/keeper/chain_info_indexer.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,25 @@ func (k Keeper) GetChainInfo(ctx sdk.Context, chainID string) *types.ChainInfo {
return &chainInfo
}

// updateLatestHeader updates the chainInfo w.r.t. the given header, including
// - replace the old latest header with the given one
// - increment the number of timestamped headers
func (k Keeper) updateLatestHeader(ctx sdk.Context, chainID string, header *types.IndexedHeader) error {
if header == nil {
return sdkerrors.Wrapf(types.ErrInvalidHeader, "header is nil")
}
chainInfo := k.GetChainInfo(ctx, chainID)
chainInfo.LatestHeader = header
chainInfo.LatestHeader = header // replace the old latest header with the given one
chainInfo.TimestampedHeadersCount++ // increment the number of timestamped headers
k.setChainInfo(ctx, chainInfo)
return nil
}

// tryToUpdateLatestForkHeader tries to update the chainInfo w.r.t. the given fork header
// - If no fork exists, add this fork header as the latest one
// - If there is a fork header at the same height, add this fork to the set of latest fork headers
// - If this fork header is newer than the previous one, replace the old fork headers with this fork header
// - If this fork header is older than the current latest fork, ignore
func (k Keeper) tryToUpdateLatestForkHeader(ctx sdk.Context, chainID string, header *types.IndexedHeader) error {
if header == nil {
return sdkerrors.Wrapf(types.ErrInvalidHeader, "header is nil")
Expand All @@ -57,7 +66,7 @@ func (k Keeper) tryToUpdateLatestForkHeader(ctx sdk.Context, chainID string, hea
// there exists fork headers at the same height, add this fork header to the set of latest fork headers
chainInfo.LatestForks.Headers = append(chainInfo.LatestForks.Headers, header)
} else if chainInfo.LatestForks.Headers[0].Height < header.Height {
// this fork header is newer than the previous one, add this fork header as the latest one
// this fork header is newer than the previous one, replace the old fork headers with this fork header
chainInfo.LatestForks = &types.Forks{
Headers: []*types.IndexedHeader{header},
}
Expand Down
31 changes: 31 additions & 0 deletions x/zoneconcierge/keeper/epoch_chain_info_indexer.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,37 @@ func (k Keeper) GetEpochChainInfo(ctx sdk.Context, chainID string, epochNumber u
return &chainInfo, nil
}

// GetLastFinalizedChainInfo gets the last finalised chain info recorded for a given chain ID
// and the earliest epoch that snapshots this chain info
func (k Keeper) GetLastFinalizedChainInfo(ctx sdk.Context, chainID string) (uint64, *types.ChainInfo, error) {
// find the last finalised epoch
finalizedEpoch, err := k.GetFinalizedEpoch(ctx)
if err != nil {
return 0, nil, err
}

// find the chain info of this epoch
chainInfo, err := k.GetEpochChainInfo(ctx, chainID, finalizedEpoch)
if err != nil {
return finalizedEpoch, nil, err
}

// It's possible that the chain info's epoch is way before the last finalised epoch
// e.g., when there is no relayer for many epochs
// NOTE: if an epoch is finalised then all of its previous epochs are also finalised
if chainInfo.LatestHeader.BabylonEpoch < finalizedEpoch {
// remember the last finalised epoch
finalizedEpoch = chainInfo.LatestHeader.BabylonEpoch
// replace the chain info w.r.t. this last finalised epoch
chainInfo, err = k.GetEpochChainInfo(ctx, chainID, finalizedEpoch)
if err != nil {
return finalizedEpoch, nil, err
}
}

return finalizedEpoch, chainInfo, nil
}

// GetEpochHeaders gets the headers timestamped in a given epoch, in the ascending order
func (k Keeper) GetEpochHeaders(ctx sdk.Context, chainID string, epochNumber uint64) ([]*types.IndexedHeader, error) {
headers := []*types.IndexedHeader{}
Expand Down
1 change: 1 addition & 0 deletions x/zoneconcierge/keeper/epoch_chain_info_indexer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ func FuzzEpochChainInfoIndexer(f *testing.F) {
chainInfo, err := zcKeeper.GetEpochChainInfo(ctx, czChain.ChainID, epochNum)
require.NoError(t, err)
require.Equal(t, numHeaders-1, chainInfo.LatestHeader.Height)
require.Equal(t, numHeaders, chainInfo.TimestampedHeadersCount)
require.Equal(t, numForkHeaders, uint64(len(chainInfo.LatestForks.Headers)))
})
}
Expand Down
28 changes: 28 additions & 0 deletions x/zoneconcierge/keeper/epochs.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,39 @@
package keeper

import (
"fmt"

btcctypes "github.com/babylonchain/babylon/x/btccheckpoint/types"
checkpointingtypes "github.com/babylonchain/babylon/x/checkpointing/types"
epochingtypes "github.com/babylonchain/babylon/x/epoching/types"
"github.com/babylonchain/babylon/x/zoneconcierge/types"
sdk "github.com/cosmos/cosmos-sdk/types"
)

// GetCkptInfoForFinalizedEpoch gets the raw checkpoint and the associated best submission of a finalised epoch
// CONTRACT: the function can only take an epoch that has already been finalised as input
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is public function, at some point someone will do not read this comment and break this invariant. In general I would avoid panicking in public function. I would either return error in case of providing not finalized epoch number or restructure code somehow.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. This code snippet was not a function and panicking was expected in that scenario (specifically in RPC FinalizedChainInfo and FinalizedChainInfoUntilHeight). Given that this is just a private helper function dedicated for ZoneConcierge, I changed it to a private function. Then panic might make sense. Wdyt?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good. I have nothing against panicking in private helpers, if some module invariants are broken due to programming error

func (k Keeper) GetCkptInfoForFinalizedEpoch(ctx sdk.Context, epochNumber uint64) (*checkpointingtypes.RawCheckpoint, *btcctypes.SubmissionKey, error) {
// find the btc checkpoint tx index of this epoch
ed := k.btccKeeper.GetEpochData(ctx, epochNumber)
if ed.Status != btcctypes.Finalized {
err := fmt.Errorf("epoch %d should have been finalized, but is in status %s", epochNumber, ed.Status.String())
panic(err) // this can only be a programming error
}
if len(ed.Key) == 0 {
err := fmt.Errorf("finalized epoch %d should have at least 1 checkpoint submission", epochNumber)
panic(err) // this can only be a programming error
}
bestSubmissionKey := ed.Key[0] // index of checkpoint tx on BTC
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm the fact that finalized epoch must have one submission keys, which is best submission is a bit implementation detail and the fact that we are doing this check:

	if len(ed.Key) == 0 {
		err := fmt.Errorf("finalized epoch %d should have at least 1 checkpoint submission", epochNumber)
		panic(err) // this can only be a programming error
	}

is a bit of a smell that something is not right.

Maybe btccheckpoing module should have another query like getEpochBestSubmission which would return something along:

EpochInfo {
epochStatus: Status
bestCheckpointBytes: []bytes
}

?
Then all this checks would become internal to btcheckpoint module. In general GetEpochData should be private to btcheckpoint module (my bad 😅 ) , and there should be separate query for epoch info, which would honor all invariants.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I feel it's better to make the btccheckpoint module to provide the functionality of choosing the best submission. I have implemented a function GetEpochDataWithBestSubmission in btccheckpoint to this end, and removed the contracts/panics in the original implementation.

Nevertheless, I left GetEpochData to be public, since this will lead to a lot of changes (e.g., btccheckpoint's testkeeper requires it to be public). Perhaps we can do this in a future PR.


// get raw checkpoint of this epoch
rawCheckpointBytes := ed.RawCheckpoint
rawCheckpoint, err := checkpointingtypes.FromBTCCkptBytesToRawCkpt(rawCheckpointBytes)
if err != nil {
return nil, bestSubmissionKey, err
}
return rawCheckpoint, bestSubmissionKey, nil
}

// GetFinalizedEpoch gets the last finalised epoch
// used upon querying the last BTC-finalised chain info for CZs
func (k Keeper) GetFinalizedEpoch(ctx sdk.Context) (uint64, error) {
Expand Down
Loading