Some checks are pending
docker-build-cometbft / vars (push) Waiting to run
docker-build-cometbft / build-images (amd64, ubuntu-24.04) (push) Blocked by required conditions
docker-build-cometbft / build-images (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
docker-build-cometbft / merge-images (push) Blocked by required conditions
docker-build-e2e-node / vars (push) Waiting to run
docker-build-e2e-node / build-images (amd64, ubuntu-24.04) (push) Blocked by required conditions
docker-build-e2e-node / build-images (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
docker-build-e2e-node / merge-images (push) Blocked by required conditions
801 lines
28 KiB
Go
801 lines
28 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"math/rand"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/cometbft/cometbft/abci/example/kvstore"
|
|
abci "github.com/cometbft/cometbft/abci/types"
|
|
"github.com/cometbft/cometbft/crypto"
|
|
cryptoenc "github.com/cometbft/cometbft/crypto/encoding"
|
|
"github.com/cometbft/cometbft/libs/log"
|
|
"github.com/cometbft/cometbft/libs/protoio"
|
|
cryptoproto "github.com/cometbft/cometbft/proto/tendermint/crypto"
|
|
cmtproto "github.com/cometbft/cometbft/proto/tendermint/types"
|
|
cmttypes "github.com/cometbft/cometbft/types"
|
|
"github.com/cometbft/cometbft/version"
|
|
)
|
|
|
|
const (
|
|
appVersion = 1
|
|
voteExtensionKey string = "extensionSum"
|
|
voteExtensionMaxVal int64 = 128
|
|
prefixReservedKey string = "reservedTxKey_"
|
|
suffixChainID string = "ChainID"
|
|
suffixVoteExtHeight string = "VoteExtensionsHeight"
|
|
suffixInitialHeight string = "InitialHeight"
|
|
)
|
|
|
|
// Application is an ABCI application for use by end-to-end tests. It is a
|
|
// simple key/value store for strings, storing data in memory and persisting
|
|
// to disk as JSON, taking state sync snapshots if requested.
|
|
type Application struct {
|
|
abci.BaseApplication
|
|
logger log.Logger
|
|
state *State
|
|
snapshots *SnapshotStore
|
|
cfg *Config
|
|
restoreSnapshot *abci.Snapshot
|
|
restoreChunks [][]byte
|
|
}
|
|
|
|
// Config allows for the setting of high level parameters for running the e2e Application
|
|
// KeyType and ValidatorUpdates must be the same for all nodes running the same application.
|
|
type Config struct {
|
|
// The directory with which state.json will be persisted in. Usually $HOME/.cometbft/data
|
|
Dir string `toml:"dir"`
|
|
|
|
// SnapshotInterval specifies the height interval at which the application
|
|
// will take state sync snapshots. Defaults to 0 (disabled).
|
|
SnapshotInterval uint64 `toml:"snapshot_interval"`
|
|
|
|
// RetainBlocks specifies the number of recent blocks to retain. Defaults to
|
|
// 0, which retains all blocks. Must be greater that PersistInterval,
|
|
// SnapshotInterval and EvidenceAgeHeight.
|
|
RetainBlocks uint64 `toml:"retain_blocks"`
|
|
|
|
// KeyType sets the curve that will be used by validators.
|
|
// Options are ed25519 & secp256k1
|
|
KeyType string `toml:"key_type"`
|
|
|
|
// PersistInterval specifies the height interval at which the application
|
|
// will persist state to disk. Defaults to 1 (every height), setting this to
|
|
// 0 disables state persistence.
|
|
PersistInterval uint64 `toml:"persist_interval"`
|
|
|
|
// ValidatorUpdates is a map of heights to validator names and their power,
|
|
// and will be returned by the ABCI application. For example, the following
|
|
// changes the power of validator01 and validator02 at height 1000:
|
|
//
|
|
// [validator_update.1000]
|
|
// validator01 = 20
|
|
// validator02 = 10
|
|
//
|
|
// Specifying height 0 returns the validator update during InitChain. The
|
|
// application returns the validator updates as-is, i.e. removing a
|
|
// validator must be done by returning it with power 0, and any validators
|
|
// not specified are not changed.
|
|
//
|
|
// height <-> pubkey <-> voting power
|
|
ValidatorUpdates map[string]map[string]uint8 `toml:"validator_update"`
|
|
|
|
// Add artificial delays to each of the main ABCI calls to mimic computation time
|
|
// of the application
|
|
PrepareProposalDelay time.Duration `toml:"prepare_proposal_delay"`
|
|
ProcessProposalDelay time.Duration `toml:"process_proposal_delay"`
|
|
CheckTxDelay time.Duration `toml:"check_tx_delay"`
|
|
FinalizeBlockDelay time.Duration `toml:"finalize_block_delay"`
|
|
VoteExtensionDelay time.Duration `toml:"vote_extension_delay"`
|
|
|
|
// VoteExtensionsEnableHeight configures the first height during which
|
|
// the chain will use and require vote extension data to be present
|
|
// in precommit messages.
|
|
VoteExtensionsEnableHeight int64 `toml:"vote_extensions_enable_height"`
|
|
|
|
// VoteExtensionsUpdateHeight configures the height at which consensus
|
|
// param VoteExtensionsEnableHeight will be set.
|
|
// -1 denotes it is set at genesis.
|
|
// 0 denotes it is set at InitChain.
|
|
VoteExtensionsUpdateHeight int64 `toml:"vote_extensions_update_height"`
|
|
}
|
|
|
|
func DefaultConfig(dir string) *Config {
|
|
return &Config{
|
|
PersistInterval: 1,
|
|
SnapshotInterval: 100,
|
|
Dir: dir,
|
|
}
|
|
}
|
|
|
|
// NewApplication creates the application.
|
|
func NewApplication(cfg *Config) (*Application, error) {
|
|
state, err := NewState(cfg.Dir, cfg.PersistInterval)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
snapshots, err := NewSnapshotStore(filepath.Join(cfg.Dir, "snapshots"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Application{
|
|
logger: log.NewTMLogger(log.NewSyncWriter(os.Stdout)),
|
|
state: state,
|
|
snapshots: snapshots,
|
|
cfg: cfg,
|
|
}, nil
|
|
}
|
|
|
|
// Info implements ABCI.
|
|
func (app *Application) Info(context.Context, *abci.RequestInfo) (*abci.ResponseInfo, error) {
|
|
height, hash := app.state.Info()
|
|
return &abci.ResponseInfo{
|
|
Version: version.ABCIVersion,
|
|
AppVersion: appVersion,
|
|
LastBlockHeight: int64(height),
|
|
LastBlockAppHash: hash,
|
|
}, nil
|
|
}
|
|
|
|
func (app *Application) updateVoteExtensionEnableHeight(currentHeight int64) *cmtproto.ConsensusParams {
|
|
var params *cmtproto.ConsensusParams
|
|
if app.cfg.VoteExtensionsUpdateHeight == currentHeight {
|
|
app.logger.Info("enabling vote extensions on the fly",
|
|
"current_height", currentHeight,
|
|
"enable_height", app.cfg.VoteExtensionsEnableHeight)
|
|
params = &cmtproto.ConsensusParams{
|
|
Abci: &cmtproto.ABCIParams{
|
|
VoteExtensionsEnableHeight: app.cfg.VoteExtensionsEnableHeight,
|
|
},
|
|
}
|
|
app.logger.Info("updating VoteExtensionsHeight in app_state", "height", app.cfg.VoteExtensionsEnableHeight)
|
|
app.state.Set(prefixReservedKey+suffixVoteExtHeight, strconv.FormatInt(app.cfg.VoteExtensionsEnableHeight, 10))
|
|
}
|
|
return params
|
|
}
|
|
|
|
// Info implements ABCI.
|
|
func (app *Application) InitChain(_ context.Context, req *abci.RequestInitChain) (*abci.ResponseInitChain, error) {
|
|
var err error
|
|
app.state.initialHeight = uint64(req.InitialHeight)
|
|
if len(req.AppStateBytes) > 0 {
|
|
err = app.state.Import(0, req.AppStateBytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
app.logger.Info("setting ChainID in app_state", "chainId", req.ChainId)
|
|
app.state.Set(prefixReservedKey+suffixChainID, req.ChainId)
|
|
app.logger.Info("setting VoteExtensionsHeight in app_state", "height", req.ConsensusParams.Abci.VoteExtensionsEnableHeight)
|
|
app.state.Set(prefixReservedKey+suffixVoteExtHeight, strconv.FormatInt(req.ConsensusParams.Abci.VoteExtensionsEnableHeight, 10))
|
|
app.logger.Info("setting initial height in app_state", "initial_height", req.InitialHeight)
|
|
app.state.Set(prefixReservedKey+suffixInitialHeight, strconv.FormatInt(req.InitialHeight, 10))
|
|
// Get validators from genesis
|
|
if req.Validators != nil {
|
|
for _, val := range req.Validators {
|
|
val := val
|
|
if err := app.storeValidator(&val); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
|
|
params := app.updateVoteExtensionEnableHeight(0)
|
|
|
|
resp := &abci.ResponseInitChain{
|
|
ConsensusParams: params,
|
|
AppHash: app.state.GetHash(),
|
|
}
|
|
if resp.Validators, err = app.validatorUpdates(0); err != nil {
|
|
return nil, err
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// CheckTx implements ABCI.
|
|
func (app *Application) CheckTx(_ context.Context, req *abci.RequestCheckTx) (*abci.ResponseCheckTx, error) {
|
|
key, _, err := parseTx(req.Tx)
|
|
if err != nil || key == prefixReservedKey {
|
|
return &abci.ResponseCheckTx{
|
|
Code: kvstore.CodeTypeEncodingError,
|
|
Log: err.Error(),
|
|
}, nil
|
|
}
|
|
|
|
if app.cfg.CheckTxDelay != 0 {
|
|
time.Sleep(app.cfg.CheckTxDelay)
|
|
}
|
|
|
|
return &abci.ResponseCheckTx{Code: kvstore.CodeTypeOK, GasWanted: 1}, nil
|
|
}
|
|
|
|
// FinalizeBlock implements ABCI.
|
|
func (app *Application) FinalizeBlock(_ context.Context, req *abci.RequestFinalizeBlock) (*abci.ResponseFinalizeBlock, error) {
|
|
txs := make([]*abci.ExecTxResult, len(req.Txs))
|
|
|
|
for i, tx := range req.Txs {
|
|
key, value, err := parseTx(tx)
|
|
if err != nil {
|
|
panic(err) // shouldn't happen since we verified it in CheckTx and ProcessProposal
|
|
}
|
|
if key == prefixReservedKey {
|
|
panic(fmt.Errorf("detected a transaction with key %q; this key is reserved and should have been filtered out", prefixReservedKey))
|
|
}
|
|
app.state.Set(key, value)
|
|
|
|
txs[i] = &abci.ExecTxResult{Code: kvstore.CodeTypeOK}
|
|
}
|
|
|
|
for _, ev := range req.Misbehavior {
|
|
app.logger.Info("Misbehavior. Slashing validator",
|
|
"validator_address", ev.GetValidator().Address,
|
|
"type", ev.GetType(),
|
|
"height", ev.GetHeight(),
|
|
"time", ev.GetTime(),
|
|
"total_voting_power", ev.GetTotalVotingPower(),
|
|
)
|
|
}
|
|
|
|
valUpdates, err := app.validatorUpdates(uint64(req.Height))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
params := app.updateVoteExtensionEnableHeight(req.Height)
|
|
|
|
if app.cfg.FinalizeBlockDelay != 0 {
|
|
time.Sleep(app.cfg.FinalizeBlockDelay)
|
|
}
|
|
|
|
return &abci.ResponseFinalizeBlock{
|
|
TxResults: txs,
|
|
ValidatorUpdates: valUpdates,
|
|
AppHash: app.state.Finalize(),
|
|
ConsensusParamUpdates: params,
|
|
Events: []abci.Event{
|
|
{
|
|
Type: "val_updates",
|
|
Attributes: []abci.EventAttribute{
|
|
{
|
|
Key: "size",
|
|
Value: strconv.Itoa(valUpdates.Len()),
|
|
},
|
|
{
|
|
Key: "height",
|
|
Value: strconv.Itoa(int(req.Height)),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// Commit implements ABCI.
|
|
func (app *Application) Commit(_ context.Context, _ *abci.RequestCommit) (*abci.ResponseCommit, error) {
|
|
height, err := app.state.Commit()
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
if app.cfg.SnapshotInterval > 0 && height%app.cfg.SnapshotInterval == 0 {
|
|
snapshot, err := app.snapshots.Create(app.state)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
app.logger.Info("created state sync snapshot", "height", snapshot.Height)
|
|
err = app.snapshots.Prune(maxSnapshotCount)
|
|
if err != nil {
|
|
app.logger.Error("failed to prune snapshots", "err", err)
|
|
}
|
|
}
|
|
retainHeight := int64(0)
|
|
if app.cfg.RetainBlocks > 0 {
|
|
retainHeight = int64(height - app.cfg.RetainBlocks + 1)
|
|
}
|
|
return &abci.ResponseCommit{
|
|
RetainHeight: retainHeight,
|
|
}, nil
|
|
}
|
|
|
|
// Query implements ABCI.
|
|
func (app *Application) Query(_ context.Context, req *abci.RequestQuery) (*abci.ResponseQuery, error) {
|
|
value, height := app.state.Query(string(req.Data))
|
|
return &abci.ResponseQuery{
|
|
Height: int64(height),
|
|
Key: req.Data,
|
|
Value: []byte(value),
|
|
}, nil
|
|
}
|
|
|
|
// ListSnapshots implements ABCI.
|
|
func (app *Application) ListSnapshots(context.Context, *abci.RequestListSnapshots) (*abci.ResponseListSnapshots, error) {
|
|
snapshots, err := app.snapshots.List()
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return &abci.ResponseListSnapshots{Snapshots: snapshots}, nil
|
|
}
|
|
|
|
// LoadSnapshotChunk implements ABCI.
|
|
func (app *Application) LoadSnapshotChunk(_ context.Context, req *abci.RequestLoadSnapshotChunk) (*abci.ResponseLoadSnapshotChunk, error) {
|
|
chunk, err := app.snapshots.LoadChunk(req.Height, req.Format, req.Chunk)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return &abci.ResponseLoadSnapshotChunk{Chunk: chunk}, nil
|
|
}
|
|
|
|
// OfferSnapshot implements ABCI.
|
|
func (app *Application) OfferSnapshot(_ context.Context, req *abci.RequestOfferSnapshot) (*abci.ResponseOfferSnapshot, error) {
|
|
if app.restoreSnapshot != nil {
|
|
panic("A snapshot is already being restored")
|
|
}
|
|
app.restoreSnapshot = req.Snapshot
|
|
app.restoreChunks = [][]byte{}
|
|
return &abci.ResponseOfferSnapshot{Result: abci.ResponseOfferSnapshot_ACCEPT}, nil
|
|
}
|
|
|
|
// ApplySnapshotChunk implements ABCI.
|
|
func (app *Application) ApplySnapshotChunk(_ context.Context, req *abci.RequestApplySnapshotChunk) (*abci.ResponseApplySnapshotChunk, error) {
|
|
if app.restoreSnapshot == nil {
|
|
panic("No restore in progress")
|
|
}
|
|
app.restoreChunks = append(app.restoreChunks, req.Chunk)
|
|
if len(app.restoreChunks) == int(app.restoreSnapshot.Chunks) {
|
|
bz := []byte{}
|
|
for _, chunk := range app.restoreChunks {
|
|
bz = append(bz, chunk...)
|
|
}
|
|
err := app.state.Import(app.restoreSnapshot.Height, bz)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
app.restoreSnapshot = nil
|
|
app.restoreChunks = nil
|
|
}
|
|
return &abci.ResponseApplySnapshotChunk{Result: abci.ResponseApplySnapshotChunk_ACCEPT}, nil
|
|
}
|
|
|
|
// PrepareProposal will take the given transactions and attempt to prepare a
|
|
// proposal from them when it's our turn to do so. If the current height has
|
|
// vote extension enabled, this method will use vote extensions from the previous
|
|
// height, passed from CometBFT as parameters to construct a special transaction
|
|
// whose value is the sum of all of the vote extensions from the previous round.
|
|
//
|
|
// Additionally, we verify the vote extension signatures passed from CometBFT and
|
|
// include all data necessary for such verification in the special transaction's
|
|
// payload so that ProcessProposal at other nodes can also verify the proposer
|
|
// constructed the special transaction correctly.
|
|
//
|
|
// If vote extensions are enabled for the current height, PrepareProposal makes
|
|
// sure there was at least one non-empty vote extension whose signature it could verify.
|
|
// If vote extensions are not enabled for the current height, PrepareProposal makes
|
|
// sure non-empty vote extensions are not present.
|
|
//
|
|
// The special vote extension-generated transaction must fit within an empty block
|
|
// and takes precedence over all other transactions coming from the mempool.
|
|
func (app *Application) PrepareProposal(
|
|
_ context.Context, req *abci.RequestPrepareProposal,
|
|
) (*abci.ResponsePrepareProposal, error) {
|
|
_, areExtensionsEnabled := app.checkHeightAndExtensions(true, req.Height, "PrepareProposal")
|
|
|
|
txs := make([][]byte, 0, len(req.Txs)+1)
|
|
var totalBytes int64
|
|
extTxPrefix := fmt.Sprintf("%s=", voteExtensionKey)
|
|
sum, err := app.verifyAndSum(areExtensionsEnabled, req.Height, &req.LocalLastCommit, "prepare_proposal")
|
|
if err != nil {
|
|
panic(fmt.Errorf("failed to sum and verify in PrepareProposal; err %w", err))
|
|
}
|
|
if areExtensionsEnabled {
|
|
extCommitBytes, err := req.LocalLastCommit.Marshal()
|
|
if err != nil {
|
|
panic("unable to marshall extended commit")
|
|
}
|
|
extCommitHex := hex.EncodeToString(extCommitBytes)
|
|
extTx := []byte(fmt.Sprintf("%s%d|%s", extTxPrefix, sum, extCommitHex))
|
|
extTxLen := cmttypes.ComputeProtoSizeForTxs([]cmttypes.Tx{extTx})
|
|
app.logger.Info("preparing proposal with special transaction from vote extensions", "extTxLen", extTxLen)
|
|
if extTxLen > req.MaxTxBytes {
|
|
panic(fmt.Errorf("serious problem in the e2e app configuration; "+
|
|
"the tx conveying the vote extension data does not fit in an empty block(%d > %d); "+
|
|
"please review the app's configuration",
|
|
extTxLen, req.MaxTxBytes))
|
|
}
|
|
txs = append(txs, extTx)
|
|
// Coherence: No need to call parseTx, as the check is stateless and has been performed by CheckTx
|
|
totalBytes = extTxLen
|
|
}
|
|
for _, tx := range req.Txs {
|
|
if areExtensionsEnabled && strings.HasPrefix(string(tx), extTxPrefix) {
|
|
// When vote extensions are enabled, our generated transaction takes precedence
|
|
// over any supplied transaction that attempts to modify the "extensionSum" value.
|
|
continue
|
|
}
|
|
if strings.HasPrefix(string(tx), prefixReservedKey) {
|
|
app.logger.Error("detected tx that should not come from the mempool", "tx", tx)
|
|
continue
|
|
}
|
|
txLen := cmttypes.ComputeProtoSizeForTxs([]cmttypes.Tx{tx})
|
|
if totalBytes+txLen > req.MaxTxBytes {
|
|
break
|
|
}
|
|
totalBytes += txLen
|
|
// Coherence: No need to call parseTx, as the check is stateless and has been performed by CheckTx
|
|
txs = append(txs, tx)
|
|
}
|
|
|
|
if app.cfg.PrepareProposalDelay != 0 {
|
|
time.Sleep(app.cfg.PrepareProposalDelay)
|
|
}
|
|
|
|
return &abci.ResponsePrepareProposal{Txs: txs}, nil
|
|
}
|
|
|
|
// ProcessProposal implements part of the Application interface.
|
|
// It accepts any proposal that does not contain a malformed transaction.
|
|
// NOTE It is up to real Applications to effect punitive behavior in the cases ProcessProposal
|
|
// returns ResponseProcessProposal_REJECT, as it is evidence of misbehavior.
|
|
func (app *Application) ProcessProposal(_ context.Context, req *abci.RequestProcessProposal) (*abci.ResponseProcessProposal, error) {
|
|
_, areExtensionsEnabled := app.checkHeightAndExtensions(true, req.Height, "ProcessProposal")
|
|
|
|
for _, tx := range req.Txs {
|
|
k, v, err := parseTx(tx)
|
|
if err != nil {
|
|
app.logger.Error("malformed transaction in ProcessProposal", "tx", tx, "err", err)
|
|
return &abci.ResponseProcessProposal{Status: abci.ResponseProcessProposal_REJECT}, nil
|
|
}
|
|
switch {
|
|
case areExtensionsEnabled && k == voteExtensionKey:
|
|
// Additional check for vote extension-related txs
|
|
if err := app.verifyExtensionTx(req.Height, v); err != nil {
|
|
app.logger.Error("vote extension transaction failed verification, rejecting proposal", k, v, "err", err)
|
|
return &abci.ResponseProcessProposal{Status: abci.ResponseProcessProposal_REJECT}, nil
|
|
}
|
|
case strings.HasPrefix(k, prefixReservedKey):
|
|
app.logger.Error("key prefix %q is reserved and cannot be used in transactions, rejecting proposal", k)
|
|
return &abci.ResponseProcessProposal{Status: abci.ResponseProcessProposal_REJECT}, nil
|
|
}
|
|
}
|
|
|
|
if app.cfg.ProcessProposalDelay != 0 {
|
|
time.Sleep(app.cfg.ProcessProposalDelay)
|
|
}
|
|
|
|
return &abci.ResponseProcessProposal{Status: abci.ResponseProcessProposal_ACCEPT}, nil
|
|
}
|
|
|
|
// ExtendVote will produce vote extensions in the form of random numbers to
|
|
// demonstrate vote extension nondeterminism.
|
|
//
|
|
// In the next block, if there are any vote extensions from the previous block,
|
|
// a new transaction will be proposed that updates a special value in the
|
|
// key/value store ("extensionSum") with the sum of all of the numbers collected
|
|
// from the vote extensions.
|
|
func (app *Application) ExtendVote(_ context.Context, req *abci.RequestExtendVote) (*abci.ResponseExtendVote, error) {
|
|
appHeight, areExtensionsEnabled := app.checkHeightAndExtensions(false, req.Height, "ExtendVote")
|
|
if !areExtensionsEnabled {
|
|
panic(fmt.Errorf("received call to ExtendVote at height %d, when vote extensions are disabled", appHeight))
|
|
}
|
|
|
|
ext := make([]byte, binary.MaxVarintLen64)
|
|
// We don't care that these values are generated by a weak random number
|
|
// generator. It's just for test purposes.
|
|
//nolint:gosec // G404: Use of weak random number generator
|
|
num := rand.Int63n(voteExtensionMaxVal)
|
|
extLen := binary.PutVarint(ext, num)
|
|
|
|
if app.cfg.VoteExtensionDelay != 0 {
|
|
time.Sleep(app.cfg.VoteExtensionDelay)
|
|
}
|
|
|
|
app.logger.Info("generated vote extension", "num", num, "ext", fmt.Sprintf("%x", ext[:extLen]), "height", appHeight)
|
|
return &abci.ResponseExtendVote{
|
|
VoteExtension: ext[:extLen],
|
|
}, nil
|
|
}
|
|
|
|
// VerifyVoteExtension simply validates vote extensions from other validators
|
|
// without doing anything about them. In this case, it just makes sure that the
|
|
// vote extension is a well-formed integer value.
|
|
func (app *Application) VerifyVoteExtension(_ context.Context, req *abci.RequestVerifyVoteExtension) (*abci.ResponseVerifyVoteExtension, error) {
|
|
appHeight, areExtensionsEnabled := app.checkHeightAndExtensions(false, req.Height, "VerifyVoteExtension")
|
|
if !areExtensionsEnabled {
|
|
panic(fmt.Errorf("received call to VerifyVoteExtension at height %d, when vote extensions are disabled", appHeight))
|
|
}
|
|
// We don't allow vote extensions to be optional
|
|
if len(req.VoteExtension) == 0 {
|
|
app.logger.Error("received empty vote extension")
|
|
return &abci.ResponseVerifyVoteExtension{
|
|
Status: abci.ResponseVerifyVoteExtension_REJECT,
|
|
}, nil
|
|
}
|
|
|
|
num, err := parseVoteExtension(req.VoteExtension)
|
|
if err != nil {
|
|
app.logger.Error("failed to parse vote extension", "vote_extension", req.VoteExtension, "err", err)
|
|
return &abci.ResponseVerifyVoteExtension{
|
|
Status: abci.ResponseVerifyVoteExtension_REJECT,
|
|
}, nil
|
|
}
|
|
|
|
if app.cfg.VoteExtensionDelay != 0 {
|
|
time.Sleep(app.cfg.VoteExtensionDelay)
|
|
}
|
|
|
|
app.logger.Info("verified vote extension value", "height", req.Height, "vote_extension", req.VoteExtension, "num", num)
|
|
return &abci.ResponseVerifyVoteExtension{
|
|
Status: abci.ResponseVerifyVoteExtension_ACCEPT,
|
|
}, nil
|
|
}
|
|
|
|
func (app *Application) Rollback() error {
|
|
return app.state.Rollback()
|
|
}
|
|
|
|
func (app *Application) getAppHeight() int64 {
|
|
initialHeightStr, height := app.state.Query(prefixReservedKey + suffixInitialHeight)
|
|
if len(initialHeightStr) == 0 {
|
|
panic("initial height not set in database")
|
|
}
|
|
initialHeight, err := strconv.ParseInt(initialHeightStr, 10, 64)
|
|
if err != nil {
|
|
panic(fmt.Errorf("malformed initial height %q in database", initialHeightStr))
|
|
}
|
|
|
|
appHeight := int64(height)
|
|
if appHeight == 0 {
|
|
appHeight = initialHeight - 1
|
|
}
|
|
return appHeight + 1
|
|
}
|
|
|
|
func (app *Application) checkHeightAndExtensions(isPrepareProcessProposal bool, height int64, callsite string) (int64, bool) {
|
|
appHeight := app.getAppHeight()
|
|
if height != appHeight {
|
|
panic(fmt.Errorf(
|
|
"got unexpected height in %s request; expected %d, actual %d",
|
|
callsite, appHeight, height,
|
|
))
|
|
}
|
|
|
|
voteExtHeightStr := app.state.Get(prefixReservedKey + suffixVoteExtHeight)
|
|
if len(voteExtHeightStr) == 0 {
|
|
panic("vote extension height not set in database")
|
|
}
|
|
voteExtHeight, err := strconv.ParseInt(voteExtHeightStr, 10, 64)
|
|
if err != nil {
|
|
panic(fmt.Errorf("malformed vote extension height %q in database", voteExtHeightStr))
|
|
}
|
|
currentHeight := appHeight
|
|
if isPrepareProcessProposal {
|
|
currentHeight-- // at exactly voteExtHeight, PrepareProposal still has no extensions, see RFC100
|
|
}
|
|
|
|
return appHeight, voteExtHeight != 0 && currentHeight >= voteExtHeight
|
|
}
|
|
|
|
func (app *Application) storeValidator(valUpdate *abci.ValidatorUpdate) error {
|
|
// Store validator data to verify extensions
|
|
pubKey, err := cryptoenc.PubKeyFromProto(valUpdate.PubKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
addr := pubKey.Address().String()
|
|
if valUpdate.Power > 0 {
|
|
pubKeyBytes, err := valUpdate.PubKey.Marshal()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
app.logger.Info("setting validator in app_state", "addr", addr)
|
|
app.state.Set(prefixReservedKey+addr, hex.EncodeToString(pubKeyBytes))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validatorUpdates generates a validator set update.
|
|
func (app *Application) validatorUpdates(height uint64) (abci.ValidatorUpdates, error) {
|
|
updates := app.cfg.ValidatorUpdates[fmt.Sprintf("%v", height)]
|
|
if len(updates) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
valUpdates := abci.ValidatorUpdates{}
|
|
for keyString, power := range updates {
|
|
|
|
keyBytes, err := base64.StdEncoding.DecodeString(keyString)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid base64 pubkey value %q: %w", keyString, err)
|
|
}
|
|
valUpdate := abci.UpdateValidator(keyBytes, int64(power), app.cfg.KeyType)
|
|
valUpdates = append(valUpdates, valUpdate)
|
|
if err := app.storeValidator(&valUpdate); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return valUpdates, nil
|
|
}
|
|
|
|
// parseTx parses a tx in 'key=value' format into a key and value.
|
|
func parseTx(tx []byte) (string, string, error) {
|
|
parts := bytes.Split(tx, []byte("="))
|
|
if len(parts) != 2 {
|
|
return "", "", fmt.Errorf("invalid tx format: %q", string(tx))
|
|
}
|
|
if len(parts[0]) == 0 {
|
|
return "", "", errors.New("key cannot be empty")
|
|
}
|
|
return string(parts[0]), string(parts[1]), nil
|
|
}
|
|
|
|
func (app *Application) verifyAndSum(
|
|
areExtensionsEnabled bool,
|
|
currentHeight int64,
|
|
extCommit *abci.ExtendedCommitInfo,
|
|
callsite string,
|
|
) (int64, error) {
|
|
var sum int64
|
|
var extCount int
|
|
for _, vote := range extCommit.Votes {
|
|
if vote.BlockIdFlag == cmtproto.BlockIDFlagUnknown || vote.BlockIdFlag > cmtproto.BlockIDFlagNil {
|
|
return 0, fmt.Errorf("vote with bad blockID flag value at height %d; blockID flag %d", currentHeight, vote.BlockIdFlag)
|
|
}
|
|
if vote.BlockIdFlag == cmtproto.BlockIDFlagAbsent || vote.BlockIdFlag == cmtproto.BlockIDFlagNil {
|
|
if len(vote.VoteExtension) != 0 {
|
|
return 0, fmt.Errorf("non-empty vote extension at height %d, for a vote with blockID flag %d",
|
|
currentHeight, vote.BlockIdFlag)
|
|
}
|
|
if len(vote.ExtensionSignature) != 0 {
|
|
return 0, fmt.Errorf("non-empty vote extension signature at height %d, for a vote with blockID flag %d",
|
|
currentHeight, vote.BlockIdFlag)
|
|
}
|
|
// Only interested in votes that can have extensions
|
|
continue
|
|
}
|
|
if !areExtensionsEnabled {
|
|
if len(vote.VoteExtension) != 0 {
|
|
return 0, fmt.Errorf("non-empty vote extension at height %d, which has extensions disabled",
|
|
currentHeight)
|
|
}
|
|
if len(vote.ExtensionSignature) != 0 {
|
|
return 0, fmt.Errorf("non-empty vote extension signature at height %d, which has extensions disabled",
|
|
currentHeight)
|
|
}
|
|
continue
|
|
}
|
|
if len(vote.VoteExtension) == 0 {
|
|
return 0, fmt.Errorf("received empty vote extension from %X at height %d (extensions enabled); "+
|
|
"e2e app's logic does not allow it", vote.Validator, currentHeight)
|
|
}
|
|
// Vote extension signatures are always provided. Apps can use them to verify the integrity of extensions
|
|
if len(vote.ExtensionSignature) == 0 {
|
|
return 0, fmt.Errorf("empty vote extension signature at height %d (extensions enabled)", currentHeight)
|
|
}
|
|
|
|
// Reconstruct vote extension's signed bytes...
|
|
chainID := app.state.Get(prefixReservedKey + suffixChainID)
|
|
if len(chainID) == 0 {
|
|
panic("chainID not set in database")
|
|
}
|
|
cve := cmtproto.CanonicalVoteExtension{
|
|
Extension: vote.VoteExtension,
|
|
Height: currentHeight - 1, // the vote extension was signed in the previous height
|
|
Round: int64(extCommit.Round),
|
|
ChainId: chainID,
|
|
}
|
|
extSignBytes, err := protoio.MarshalDelimited(&cve)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("error when marshaling signed bytes: %w", err)
|
|
}
|
|
|
|
//... and verify
|
|
valAddr := crypto.Address(vote.Validator.Address).String()
|
|
pubKeyHex := app.state.Get(prefixReservedKey + valAddr)
|
|
if len(pubKeyHex) == 0 {
|
|
return 0, fmt.Errorf("received vote from unknown validator with address %q", valAddr)
|
|
}
|
|
pubKeyBytes, err := hex.DecodeString(pubKeyHex)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("could not hex-decode public key for validator address %s, err %w", valAddr, err)
|
|
}
|
|
var pubKeyProto cryptoproto.PublicKey
|
|
err = pubKeyProto.Unmarshal(pubKeyBytes)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("unable to unmarshal public key for validator address %s, err %w", valAddr, err)
|
|
}
|
|
pubKey, err := cryptoenc.PubKeyFromProto(pubKeyProto)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("could not obtain a public key from its proto for validator address %s, err %w", valAddr, err)
|
|
}
|
|
if !pubKey.VerifySignature(extSignBytes, vote.ExtensionSignature) {
|
|
return 0, errors.New("received vote with invalid signature")
|
|
}
|
|
|
|
extValue, err := parseVoteExtension(vote.VoteExtension)
|
|
// The extension's format should have been verified in VerifyVoteExtension
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to parse vote extension: %w", err)
|
|
}
|
|
app.logger.Info(
|
|
"received and verified vote extension value",
|
|
"height", currentHeight,
|
|
"valAddr", valAddr,
|
|
"value", extValue,
|
|
"callsite", callsite,
|
|
)
|
|
sum += extValue
|
|
extCount++
|
|
}
|
|
|
|
if areExtensionsEnabled && (extCount == 0) {
|
|
return 0, errors.New("bad extension data, at least one extended vote should be present when extensions are enabled")
|
|
}
|
|
return sum, nil
|
|
}
|
|
|
|
// verifyExtensionTx parses and verifies the payload of a vote extension-generated tx
|
|
func (app *Application) verifyExtensionTx(height int64, payload string) error {
|
|
parts := strings.Split(payload, "|")
|
|
if len(parts) != 2 {
|
|
return fmt.Errorf("invalid payload format")
|
|
}
|
|
expSumStr := parts[0]
|
|
if len(expSumStr) == 0 {
|
|
return fmt.Errorf("sum cannot be empty in vote extension payload")
|
|
}
|
|
|
|
expSum, err := strconv.Atoi(expSumStr)
|
|
if err != nil {
|
|
return fmt.Errorf("malformed sum %q in vote extension payload", expSumStr)
|
|
}
|
|
|
|
extCommitHex := parts[1]
|
|
if len(extCommitHex) == 0 {
|
|
return fmt.Errorf("extended commit data cannot be empty in vote extension payload")
|
|
}
|
|
|
|
extCommitBytes, err := hex.DecodeString(extCommitHex)
|
|
if err != nil {
|
|
return fmt.Errorf("could not hex-decode vote extension payload")
|
|
}
|
|
|
|
var extCommit abci.ExtendedCommitInfo
|
|
if extCommit.Unmarshal(extCommitBytes) != nil {
|
|
return fmt.Errorf("unable to unmarshal extended commit")
|
|
}
|
|
|
|
sum, err := app.verifyAndSum(true, height, &extCommit, "process_proposal")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to sum and verify in process proposal: %w", err)
|
|
}
|
|
|
|
// Final check that the proposer behaved correctly
|
|
if int64(expSum) != sum {
|
|
return fmt.Errorf("sum is not consistent with vote extension payload: %d!=%d", expSum, sum)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parseVoteExtension attempts to parse the given extension data into a positive
|
|
// integer value.
|
|
func parseVoteExtension(ext []byte) (int64, error) {
|
|
num, errVal := binary.Varint(ext)
|
|
if errVal == 0 {
|
|
return 0, errors.New("vote extension is too small to parse")
|
|
}
|
|
if errVal < 0 {
|
|
return 0, errors.New("vote extension value is too large")
|
|
}
|
|
if num >= voteExtensionMaxVal {
|
|
return 0, fmt.Errorf("vote extension value must be smaller than %d (was %d)", voteExtensionMaxVal, num)
|
|
}
|
|
return num, nil
|
|
}
|