Sneaky Mercurial Python
High
Proposals can only be prepared in the first block of the current epoch, however due to a logical flaw, preparing proposals will return early after the first epoch (i.e epoch
= 0)
For Context,
- Votes are extended in the last block of the previous epoch
- the extended votes
As shown below, there is a check to ensure that proposals are prepared only in the first block of the current epoch
File: proposal.go
68: func (h *ProposalHandler) PrepareProposal() sdk.PrepareProposalHandler {
69: return func(ctx sdk.Context, req *abci.RequestPrepareProposal) (*abci.ResponsePrepareProposal, error) {
70: // call default handler first to do basic validation
71: res, err := h.defaultPrepareProposalHandler(ctx, req)
72: if err != nil {
73: return nil, fmt.Errorf("failed in default PrepareProposal handler: %w", err)
74: }
75:
76: k := h.ckptKeeper
77: proposalTxs := res.Txs
78: proposalRes := &abci.ResponsePrepareProposal{Txs: proposalTxs}
79:
80: >> epoch := k.GetEpoch(ctx)
81: // BLS signatures are sent in the last block of the previous epoch,
82: >> // so they should be aggregated in the first block of the new epoch
83: // and no BLS signatures are send in epoch 0
84: >> if !epoch.IsVoteExtensionProposal(ctx) {
The problem is that, the implementation of IsVoteExtensionProposal()
function will cause the PrepareProposal()
function to return early.
func (e Epoch) IsVoteExtensionProposal(ctx context.Context) bool {
if e.EpochNumber == 0 {
return false
}
>> return e.IsFirstBlockOfNextEpoch(ctx)
}
a look into the IsFirstBlockOfNextEpoch()
function shows that it wrongly adds e.CurrentEpochInterval
to the FirstBlockHeight
hence, whenever the call is made in the first block of a new epoch, false
will always be returned causing the function to return early
func (e Epoch) IsFirstBlockOfNextEpoch(ctx context.Context) bool {
sdkCtx := sdk.UnwrapSDKContext(ctx)
if e.EpochNumber == 0 {
return sdkCtx.HeaderInfo().Height == 1
} else {
height := uint64(sdkCtx.HeaderInfo().Height)
>> return e.FirstBlockHeight+e.CurrentEpochInterval == height
}
}
The IsVoteExtensionProposal()
and IsFirstBlockOfNextEpoch()
function are used widely in the protocol and this will cause other inconsistencies not mentioned in this report as it will become too lengthy.
Other affected core functions are
/gen_blocks.go::ApplyEmptyBlockWithSomeInvalidVoteExtensions()
ProcessProposal()
PreBlocker()
IsFirstBlockOfNextEpoch()
returns false in the first block of a new epoch
NIL
See POC
When preparing proposal in the first block of a new epoch,
- extended votes will not be validated
- checkpoint will not be built from the extended votes
- the returned
proposalTxs
will not contain the injected votes (since the execution flow will not get to the point where the injected tx bytes are built.
This may cause the chain to halt as proposals cannot be prepared due to a logical flaw
This applies to any succession of consecutive epochs (i, j, where j > i) but I'll use epoch 1 and epoch 2
- last epoch
epoch
= 1 (started at Height = 1) - assume epoch Lenght = 20
- current epoch,
epoch
= 2 (starting at Height = 21) FirstBlockHeight
of epoch 2 = 21sdkCtx.HeaderInfo().Height
= 21IsFirstBlockOfNextEpoch()
evaluates to
>> return e.FirstBlockHeight+e.CurrentEpochInterval == height
>> return (21 + 20) == 21 // => false
The PrepareProposal()
thus returns early.
This affects
Modify the IsVoteExtensionProposal()
function to use IsFirstBlock()
instead of IsFirstBlockOfNextEpoch()
func (e Epoch) IsVoteExtensionProposal(ctx context.Context) bool {
if e.EpochNumber == 0 {
return false
}
- return e.IsFirstBlockOfNextEpoch(ctx)
+ return e.IsFirstBlock(ctx)
}