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/epoching: proof that a header is in an epoch #248

Merged
merged 9 commits into from
Dec 16, 2022
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
2,781 changes: 2,386 additions & 395 deletions client/docs/swagger-ui/swagger.yaml

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion proto/babylon/epoching/v1/epoching.proto
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@ message Epoch {
// Babylon needs to remember the last header of each epoch to complete unbonding validators/delegations when a previous epoch's checkpoint is finalised.
// The last_block_header field is nil in the epoch's beginning, and is set upon the end of this epoch.
tendermint.types.Header last_block_header = 4;
// app_hash_root is the Merkle root of all AppHashs in this epoch
// It will be used for proving a block is in an epoch
bytes app_hash_root = 5;
// sealer_header is the 2nd header of the next epoch
// This validator set has generated a BLS multisig on `last_commit_hash` of the sealer header
tendermint.types.Header sealer_header = 5;
tendermint.types.Header sealer_header = 6;
}

// QueuedMessage is a message that can change the validator set and is delayed to the epoch boundary
Expand Down
4 changes: 2 additions & 2 deletions proto/babylon/zoneconcierge/query.proto
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ message QueryFinalizedChainInfoResponse {
*/
// 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_block_in_epoch is the proof that the Babylon block is in a certain epoch
tendermint.crypto.ProofOps proof_block_in_epoch = 6;
// 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
Expand Down
4 changes: 2 additions & 2 deletions proto/babylon/zoneconcierge/zoneconcierge.proto
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ message IndexedHeader {
// height is the height of this header on CZ ledger
// (hash, height) jointly provides the position of the header on CZ ledger
uint64 height = 3;
// babylon_block_height is the height of the Babylon block that includes this header
uint64 babylon_block_height = 4;
// babylon_header is the header of the babylon block that includes this CZ header
tendermint.types.Header babylon_header = 4;
// epoch is the epoch number of this header on Babylon ledger
uint64 babylon_epoch = 5;
// babylon_tx_hash is the hash of the tx that includes this header
Expand Down
12 changes: 11 additions & 1 deletion x/epoching/abci.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

// BeginBlocker is called at the beginning of every block.
// Upon each BeginBlock,
// - record the current AppHash
// - if reaching the epoch beginning, then
// - increment epoch number
// - trigger AfterEpochBegins hook
Expand All @@ -24,6 +25,9 @@ import (
func BeginBlocker(ctx sdk.Context, k keeper.Keeper, req abci.RequestBeginBlock) {
defer telemetry.ModuleMeasureSince(types.ModuleName, time.Now(), telemetry.MetricKeyBeginBlocker)

// record the current AppHash
k.RecordAppHash(ctx)

// if this block is the first block of the next epoch
// note that we haven't incremented the epoch number yet
epoch := k.GetEpoch(ctx)
Expand Down Expand Up @@ -69,7 +73,13 @@ func EndBlocker(ctx sdk.Context, k keeper.Keeper) []abci.ValidatorUpdate {
epoch := k.GetEpoch(ctx)
if epoch.IsLastBlock(ctx) {
// finalise this epoch, i.e., record the current header
k.RecordLastBlockHeader(ctx)
if err := k.RecordLastBlockHeader(ctx); err != nil {
panic(err)
}
// record the Merkle root of all AppHashs in this epoch
if err := k.RecordAppHashRoot(ctx); err != nil {
panic(err)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

It seems that the epoch info is stored twice in the two functions. Is it possible to combine the two into one like RecordEpochInfo?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yep that's a good idea. Merged them to RecordLastHeaderAndAppHashRoot so that we only write to DB once 👍

// get all msgs in the msg queue
queuedMsgs := k.GetCurrentEpochMsgs(ctx)
// forward each msg in the msg queue to the right keeper
Expand Down
126 changes: 126 additions & 0 deletions x/epoching/keeper/apphash_chain.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package keeper

import (
"crypto/sha256"
"fmt"

"github.com/babylonchain/babylon/x/epoching/types"
"github.com/cosmos/cosmos-sdk/store/prefix"
sdk "github.com/cosmos/cosmos-sdk/types"
sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
"github.com/tendermint/tendermint/crypto/merkle"
tmcrypto "github.com/tendermint/tendermint/proto/tendermint/crypto"
)

func (k Keeper) setAppHash(ctx sdk.Context, height uint64, appHash []byte) {
store := k.appHashStore(ctx)
heightBytes := sdk.Uint64ToBigEndian(height)
store.Set(heightBytes, appHash)
}

// GetAppHash gets the AppHash of the header at the given height
func (k Keeper) GetAppHash(ctx sdk.Context, height uint64) ([]byte, error) {
store := k.appHashStore(ctx)
heightBytes := sdk.Uint64ToBigEndian(height)
appHash := store.Get(heightBytes)
if appHash == nil {
return nil, sdkerrors.Wrapf(types.ErrInvalidHeight, "height %d is now known in DB yet", height)
}
return appHash, nil
}

// RecordAppHash stores the AppHash of the current header to KVStore
func (k Keeper) RecordAppHash(ctx sdk.Context) {
header := ctx.BlockHeader()
height := uint64(header.Height)
k.setAppHash(ctx, height, header.AppHash)
}

// GetAllAppHashsForEpoch fetches all AppHashs in the given epoch
func (k Keeper) GetAllAppHashsForEpoch(ctx sdk.Context, epochNumber uint64) ([][]byte, error) {
epoch, err := k.GetHistoricalEpoch(ctx, epochNumber)
if err != nil {
return nil, err
}
// if this epoch is the most recent AND has not ended, then we cannot get all AppHashs for this epoch
if k.GetEpoch(ctx).EpochNumber == epoch.EpochNumber && !epoch.IsLastBlock(ctx) {
return nil, sdkerrors.Wrapf(types.ErrInvalidHeight, "GetAllAppHashsForEpoch can only be invoked when this epoch has ended")
}

// fetch each AppHash in this epoch
appHashs := [][]byte{}
for i := epoch.FirstBlockHeight; i <= uint64(epoch.LastBlockHeader.Height); i++ {
appHash, err := k.GetAppHash(ctx, i)
if err != nil {
return nil, err
}
appHashs = append(appHashs, appHash)
}

return appHashs, nil
}

// RecordAppHashRoot calculates the Merkle root of all AppHashs in the current epoch, and stores it to epoch metadata
func (k Keeper) RecordAppHashRoot(ctx sdk.Context) error {
epoch := k.GetEpoch(ctx)
appHashs, err := k.GetAllAppHashsForEpoch(ctx, epoch.EpochNumber)
if err != nil {
return err
}
appHashRoot := merkle.HashFromByteSlices(appHashs)
epoch.AppHashRoot = appHashRoot
k.setEpochInfo(ctx, epoch.EpochNumber, epoch)
return nil
}

// ProveAppHashInEpoch generates a proof that the given appHash is in a given epoch
func (k Keeper) ProveAppHashInEpoch(ctx sdk.Context, height uint64, epochNumber uint64) (*tmcrypto.Proof, error) {
// ensure height is inside this epoch
epoch, err := k.GetHistoricalEpoch(ctx, epochNumber)
if err != nil {
return nil, err
}
if height < epoch.FirstBlockHeight || uint64(epoch.LastBlockHeader.Height) < height {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
if height < epoch.FirstBlockHeight || uint64(epoch.LastBlockHeader.Height) < height {
if height < epoch.FirstBlockHeight || height > uint64(epoch.LastBlockHeader.Height) {

Same thing but seems more intuitive. To be cleaner, maybe we can add a method to Epoch like WithinBoundary(height uint64) bool

Copy link
Member Author

Choose a reason for hiding this comment

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

The WithinBoundary is really a good idea! Done 👍

return nil, sdkerrors.Wrapf(types.ErrInvalidHeight, "the given height %d is not in epoch %d (interval [%d, %d])", height, epoch.EpochNumber, epoch.FirstBlockHeight, uint64(epoch.LastBlockHeader.Height))
}

// calculate index of this height in this epoch
idx := height - epoch.FirstBlockHeight

// fetch all AppHashs, calculate Merkle tree and proof
appHashs, err := k.GetAllAppHashsForEpoch(ctx, epochNumber)
if err != nil {
return nil, err
}
_, proofs := merkle.ProofsFromByteSlices(appHashs)

return proofs[idx].ToProto(), nil
}

// VerifyAppHashInclusion verifies whether the given appHash is in the Merkle tree w.r.t. the appHashRoot
func VerifyAppHashInclusion(appHash []byte, appHashRoot []byte, proof *tmcrypto.Proof) error {
if len(appHash) != sha256.Size {
return fmt.Errorf("appHash with length %d is not a Sha256 hash", len(appHash))
}
if len(appHashRoot) != sha256.Size {
return fmt.Errorf("appHash with length %d is not a Sha256 hash", len(appHashRoot))
}
if proof == nil {
return fmt.Errorf("proof is nil")
}

unwrappedProof, err := merkle.ProofFromProto(proof)
if err != nil {
return err
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
return err
return fmt.Errorf("failed to unwrap proof: %w", err)

Just to be more informative

}
return unwrappedProof.Verify(appHashRoot, appHash)
}

// appHashStore returns the KVStore for the AppHash of each header
// prefix: AppHashKey
// key: height
// value: AppHash in bytes
func (k Keeper) appHashStore(ctx sdk.Context) prefix.Store {
store := ctx.KVStore(k.storeKey)
return prefix.NewStore(store, types.AppHashKey)
}
58 changes: 58 additions & 0 deletions x/epoching/keeper/apphash_chain_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package keeper_test

import (
"math/rand"
"testing"

"github.com/babylonchain/babylon/testutil/datagen"
"github.com/babylonchain/babylon/x/epoching/keeper"
"github.com/babylonchain/babylon/x/epoching/testepoching"
"github.com/babylonchain/babylon/x/epoching/types"
"github.com/stretchr/testify/require"
)

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

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

helper := testepoching.NewHelper(t)
ctx, k := helper.Ctx, helper.EpochingKeeper
// ensure that the epoch info is correct at the genesis
epoch := k.GetEpoch(ctx)
require.Equal(t, epoch.EpochNumber, uint64(0))
require.Equal(t, epoch.FirstBlockHeight, uint64(0))

// set a random epoch interval
epochInterval := rand.Uint64()%100 + 2 // the epoch interval should at at least 2
k.SetParams(ctx, types.Params{
EpochInterval: epochInterval,
})

// reach the end of the 1st epoch
expectedHeight := epochInterval
expectedAppHashs := [][]byte{}
for i := uint64(0); i < expectedHeight; i++ {
ctx = helper.GenAndApplyEmptyBlock()
expectedAppHashs = append(expectedAppHashs, ctx.BlockHeader().AppHash)
}
// ensure epoch number is 1
epoch = k.GetEpoch(ctx)
require.Equal(t, uint64(1), epoch.EpochNumber)

// ensure appHashs are same as expectedAppHashs
appHashs, err := k.GetAllAppHashsForEpoch(ctx, epoch.EpochNumber)
require.NoError(t, err)
require.Equal(t, expectedAppHashs, appHashs)

// ensure prover and verifier are correct
randomHeightInEpoch := uint64(rand.Intn(int(expectedHeight)) + 1)
randomAppHash, err := k.GetAppHash(ctx, randomHeightInEpoch)
require.NoError(t, err)
proof, err := k.ProveAppHashInEpoch(ctx, randomHeightInEpoch, epoch.EpochNumber)
require.NoError(t, err)
err = keeper.VerifyAppHashInclusion(randomAppHash, epoch.AppHashRoot, proof)
require.NoError(t, err)
})
}
7 changes: 4 additions & 3 deletions x/epoching/keeper/epochs.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"github.com/babylonchain/babylon/x/epoching/types"
"github.com/cosmos/cosmos-sdk/store/prefix"
sdk "github.com/cosmos/cosmos-sdk/types"
sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
)

const (
Expand Down Expand Up @@ -75,15 +76,15 @@ func (k Keeper) GetHistoricalEpoch(ctx sdk.Context, epochNumber uint64) (*types.
return epoch, err
}

func (k Keeper) RecordLastBlockHeader(ctx sdk.Context) *types.Epoch {
func (k Keeper) RecordLastBlockHeader(ctx sdk.Context) error {
epoch := k.GetEpoch(ctx)
if !epoch.IsLastBlock(ctx) {
panic("RecordLastBlockHeader can only be invoked at the last block of an epoch")
return sdkerrors.Wrapf(types.ErrInvalidHeight, "RecordLastBlockHeader can only be invoked at the last block of an epoch")
}
Comment on lines 83 to 86
Copy link
Contributor

Choose a reason for hiding this comment

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

As the name implies, the caller should ensure that it is this is the last block of the epoch. So maybe we don't need to check it here again. We can add a note about calling this function tho.

Copy link
Member Author

Choose a reason for hiding this comment

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

Hmm yeah such a contract can do the job as well, but I feel checking it here again can be a better precaution for misuses. Now constructing the full epoch metadata becomes quite cryptic with the following steps:

  • Upon the first block of epoch i, create the epoch metadata with empty LastBlockheader, AppHashRoot, and SealerHeader.
  • Upon the last block of epoch i, fill LastBlockHeader and AppHashRoot
  • Upon the 2nd block of epoch i+1, fill SealerHeader for epoch i

So I feel it might be better to impose as many restrictions to these functions as possible.

header := ctx.BlockHeader()
epoch.LastBlockHeader = &header
k.setEpochInfo(ctx, epoch.EpochNumber, epoch)
return epoch
return nil
}

// RecordSealerHeaderForPrevEpoch records the sealer header for the previous epoch,
Expand Down
8 changes: 5 additions & 3 deletions x/epoching/testepoching/helper.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package testepoching

import (
"github.com/babylonchain/babylon/crypto/bls12381"
"testing"

"github.com/babylonchain/babylon/crypto/bls12381"
"github.com/babylonchain/babylon/testutil/datagen"

"cosmossdk.io/math"
appparams "github.com/babylonchain/babylon/app/params"

Expand Down Expand Up @@ -110,7 +112,7 @@ func (h *Helper) GenAndApplyEmptyBlock() sdk.Context {
valhash := CalculateValHash(valSet)
newHeader := tmproto.Header{
Height: newHeight,
AppHash: h.App.LastCommitID().Hash,
AppHash: datagen.GenRandomByteArray(32),
ValidatorsHash: valhash,
NextValidatorsHash: valhash,
}
Expand All @@ -129,7 +131,7 @@ func (h *Helper) BeginBlock() sdk.Context {
valhash := CalculateValHash(valSet)
newHeader := tmproto.Header{
Height: newHeight,
AppHash: h.App.LastCommitID().Hash,
AppHash: datagen.GenRandomByteArray(32),
ValidatorsHash: valhash,
NextValidatorsHash: valhash,
}
Expand Down
Loading