Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fdb40c8
feat(abci)!: add the ApplyBlockRandomness method and commit beacon fi…
QuantumExplorer Nov 3, 2026
fec2860
fix(types): refuse commits whose beacon or app hash has the wrong size
QuantumExplorer Nov 3, 2026
b1a88ae
fix(types): decode blocks whose LastCommit carries mistyped beacon fi…
QuantumExplorer Nov 3, 2026
db66700
fix(kvstore): pick the ApplyBlockRandomness state by block hash
QuantumExplorer Nov 3, 2026
846b8e1
docs(abci): state what the randomness may change and what FinalizeBlo…
QuantumExplorer Nov 3, 2026
952361e
chore(abci): bump ABCISemVer to 1.5.0
QuantumExplorer Nov 3, 2026
720a335
fix(types): keep the commit's randomness bytes out of the base commit…
QuantumExplorer Nov 3, 2026
c0e8c7f
test(abci): cover ApplyBlockRandomness in the routed and proxy clients
QuantumExplorer Nov 3, 2026
3bd58e3
fix(types): split a block into fields as the generated decoder does w…
QuantumExplorer Nov 4, 2026
774a254
fix(state): pass the last commit's beacon and app hash in proposal re…
QuantumExplorer Nov 4, 2026
389f3b7
Merge branch 'v1.8-dev' into feat/abci-apply-block-randomness
QuantumExplorer Nov 4, 2026
5a2eff5
chore(types): mark the block decoder's wrapping conversions for gosec
QuantumExplorer Nov 4, 2026
c3a802e
Merge branch 'v1.8-dev' into feat/abci-apply-block-randomness
QuantumExplorer Nov 4, 2026
2be9a72
fix(state): drop a relayed commit's beacon and app hash from proposals
QuantumExplorer Nov 4, 2026
2c9287d
test(node): expect proposals to drop the commit's beacon and app hash
QuantumExplorer Nov 4, 2026
0ef2a67
fix: drop a commit's unsigned beacon and app hash before consensus ve…
QuantumExplorer Nov 4, 2026
003c022
perf(types): allocate once in the tolerant block decoder
QuantumExplorer Nov 4, 2026
f494ed8
refactor(kvstore): look up ApplyBlockRandomness by block hash only
QuantumExplorer Nov 4, 2026
fef4532
docs: document the commit beacon fields and ApplyBlockRandomness rules
QuantumExplorer Nov 4, 2026
ab508f8
Merge branch 'v1.8-dev' into feat/abci-apply-block-randomness
QuantumExplorer Nov 4, 2026
9e4b04d
Merge branch 'dev' into feat/abci-apply-block-randomness
QuantumExplorer Nov 6, 2026
cd6b4ec
fix(store): keep unsigned randomness out of the canonical commit
QuantumExplorer Nov 6, 2026
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
19 changes: 19 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,25 @@ opening context or calling `Stop`/`Wait` alone does not close the head file.
See [service lifecycle](docs/architecture/service-lifecycle.md) for ownership
and migration boundaries.

### ABCI: ApplyBlockRandomness and commit beacon fields

The ABCI version is 1.5.0. The `Application` interface and the gRPC `ABCIApplication` service gain
`ApplyBlockRandomness`, which hands the application a block's random beacon from consensus version 2.
This release defines consensus versions 0 and 1 only and never calls it.

- Go applications that do not embed `abci/types.BaseApplication` must implement the method to
compile. `BaseApplication.ApplyBlockRandomness` returns `ErrApplyBlockRandomnessNotImplemented`,
not an empty response, so an application must implement it before its chain moves to consensus
version 2.
- With the routed ABCI transport, a method no routing rule names goes to the `*` client. Route
`ApplyBlockRandomness` to the client that runs `ProcessProposal`, since it answers for the state
`ProcessProposal` left.
- `Commit` and `CommitInfo` gain `beacon` and `app_hash`. At every consensus version, a commit whose
beacon and app hash are not both empty, or exactly 96 and 32 bytes, is refused, and a block's
last commit is read without malformed ones. Before consensus version 2, nodes drop well-sized ones
from the commits they adopt, from the previous height's commit they store from a block, and from the
last commit they pass to the application.

### ABCI: commit vote-extension verification

Tenderdash now calls `VerifyVoteExtension` after `ProcessProposal` and before it
Expand Down
4 changes: 4 additions & 0 deletions abci/client/grpc_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,3 +274,7 @@ func (cli *grpcClient) VerifyVoteExtension(ctx context.Context, params *types.Re
func (cli *grpcClient) FinalizeBlock(ctx context.Context, params *types.RequestFinalizeBlock) (*types.ResponseFinalizeBlock, error) {
return cli.client.FinalizeBlock(ctx, types.ToRequestFinalizeBlock(params).GetFinalizeBlock(), grpc.WaitForReady(true))
}

func (cli *grpcClient) ApplyBlockRandomness(ctx context.Context, params *types.RequestApplyBlockRandomness) (*types.ResponseApplyBlockRandomness, error) {
return cli.client.ApplyBlockRandomness(ctx, types.ToRequestApplyBlockRandomness(params).GetApplyBlockRandomness(), grpc.WaitForReady(true))
}
68 changes: 68 additions & 0 deletions abci/client/mocks/client.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions abci/client/routed_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -375,3 +375,8 @@ func (cli *routedClient) FinalizeBlock(ctx context.Context, req *types.RequestFi
result, err := cli.delegate(ctx, req)
return result.(*types.ResponseFinalizeBlock), err
}

func (cli *routedClient) ApplyBlockRandomness(ctx context.Context, req *types.RequestApplyBlockRandomness) (*types.ResponseApplyBlockRandomness, error) {
result, err := cli.delegate(ctx, req)
return result.(*types.ResponseApplyBlockRandomness), err
}
10 changes: 10 additions & 0 deletions abci/client/routed_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
// * when a request of type FinalizeBlock is made, it should be first routed to queryApp, then to consensusApp
// * when a request of type CheckTx is made, it should be routed to queryApp
// * when a request of type PrepareProposal is made, it should be routed to to consensusApp
// * when a request of type ApplyBlockRandomness is made, it should be routed to consensusApp
func TestRouting(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
Expand Down Expand Up @@ -64,10 +65,14 @@ func TestRouting(t *testing.T) {
consensusApp.On("FinalizeBlock", mock.Anything, mock.Anything).Return(&types.ResponseFinalizeBlock{
RetainHeight: 1,
}, nil).Once()
consensusApp.On("ApplyBlockRandomness", mock.Anything, mock.Anything).Return(&types.ResponseApplyBlockRandomness{
AppHash: []byte("final apphash"),
}, nil).Once()

addr := fmt.Sprintf("CheckTx:socket:%s", querySocket) +
fmt.Sprintf(",FinalizeBlock:socket:%s,FinalizeBlock:socket:%s", querySocket, consensusSocket) +
fmt.Sprintf(",PrepareProposal:socket:%s", consensusSocket) +
fmt.Sprintf(",ApplyBlockRandomness:socket:%s", consensusSocket) +
fmt.Sprintf(",*:socket:%s", defaultSocket)

logger.Info("configuring routed abci client with address", "addr", addr)
Expand Down Expand Up @@ -101,6 +106,11 @@ func TestRouting(t *testing.T) {
_, err = routedClient.PrepareProposal(ctx, &types.RequestPrepareProposal{})
assert.NoError(t, err)

// ApplyBlockRandomness
randomness, err := routedClient.ApplyBlockRandomness(ctx, &types.RequestApplyBlockRandomness{})
assert.NoError(t, err)
assert.EqualValues(t, "final apphash", randomness.AppHash)

// unlock info
assert.False(t, infoExecuted)
infoMtx.Unlock()
Expand Down
10 changes: 10 additions & 0 deletions abci/client/socket_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,14 @@ func (cli *socketClient) FinalizeBlock(ctx context.Context, req *types.RequestFi
return res.GetFinalizeBlock(), nil
}

func (cli *socketClient) ApplyBlockRandomness(ctx context.Context, req *types.RequestApplyBlockRandomness) (*types.ResponseApplyBlockRandomness, error) {
res, err := cli.doRequest(ctx, types.ToRequestApplyBlockRandomness(req))
if err != nil {
return nil, err
}
return res.GetApplyBlockRandomness(), nil
}

//----------------------------------------

func resMatchesReq(req *types.Request, res *types.Response) (ok bool) {
Expand Down Expand Up @@ -431,6 +439,8 @@ func resMatchesReq(req *types.Request, res *types.Response) (ok bool) {
_, ok = res.Value.(*types.Response_OfferSnapshot)
case *types.Request_FinalizeBlock:
_, ok = res.Value.(*types.Response_FinalizeBlock)
case *types.Request_ApplyBlockRandomness:
_, ok = res.Value.(*types.Response_ApplyBlockRandomness)
}
return ok
}
Expand Down
45 changes: 43 additions & 2 deletions abci/example/kvstore/kvstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,12 @@ type Application struct {
// LastCommittedState is last state that was committed by Tenderdash and finalized with abci.FinalizeBlock()
LastCommittedState State
// roundStates contains state for each round, indexed by roundKey()
roundStates map[string]State
RetainBlocks int64 // blocks to retain after commit (via ResponseCommit.RetainHeight)
roundStates map[string]State
// processedBlocks maps each block ProcessProposal executed at this height, by round and block hash,
// to its key in roundStates. Two blocks of one round, from an equivocating proposer, have
// different keys.
processedBlocks map[processedBlock]string
RetainBlocks int64 // blocks to retain after commit (via ResponseCommit.RetainHeight)

// preparedProposals stores info about all rounds that got PrepareProposal executed, used to detect
// duplicate PrepareProposal calls.
Expand Down Expand Up @@ -218,6 +222,7 @@ func newApplication(stateStore StoreFactory, opts ...OptFunc) (*Application, err
logger: log.NewNopLogger(),
LastCommittedState: NewKvState(dbm.NewMemDB(), initialHeight), // initial state to avoid InitChain() in unit tests
roundStates: map[string]State{},
processedBlocks: map[processedBlock]string{},
preparedProposals: map[int32]bool{},
processedProposals: map[int32][]byte{},
validatorSetUpdates: map[int64]abci.ValidatorSetUpdate{},
Expand Down Expand Up @@ -392,6 +397,8 @@ func (app *Application) ProcessProposal(_ context.Context, req *abci.RequestProc
Status: abci.ResponseProcessProposal_REJECT,
}, err
}
block := processedBlock{round: req.Round, hash: string(req.Hash)}
app.processedBlocks[block] = roundKey(roundState.GetAppHash(), roundState.GetHeight(), roundState.GetRound())

if req.Version.App != app.appVersionForHeight(req.Height) {
app.logger.Error("app version mismatch in process proposal request",
Expand Down Expand Up @@ -490,6 +497,39 @@ func (app *Application) FinalizeBlock(_ context.Context, req *abci.RequestFinali
return resp, nil
}

// ApplyBlockRandomness implements ABCI. The example application uses no randomness, so it returns
// the app hash of the state that ProcessProposal left for the block and round.
func (app *Application) ApplyBlockRandomness(_ context.Context, req *abci.RequestApplyBlockRandomness) (*abci.ResponseApplyBlockRandomness, error) {
app.mu.Lock()
defer app.mu.Unlock()

roundState, err := app.processedRoundState(req.Hash, req.Height, req.Round)
if err != nil {
return nil, fmt.Errorf("apply block randomness (hash: %X): %w", req.Hash, err)
}

resp := &abci.ResponseApplyBlockRandomness{AppHash: roundState.GetAppHash()}
app.logger.Debug("ApplyBlockRandomness", "req", req, "resp", resp)
return resp, nil
}

// processedBlock identifies a block ProcessProposal executed at the current height.
type processedBlock struct {
round int32
hash string
}

// processedRoundState returns the state ProcessProposal left for the block with the given hash at
// the given height and round.
// Caller should lock the Application.
func (app *Application) processedRoundState(hash []byte, height int64, round int32) (State, error) {
state := app.roundStates[app.processedBlocks[processedBlock{round: round, hash: string(hash)}]]
if state == nil || state.GetHeight() != height {
return nil, fmt.Errorf("block not processed at height %d round %d", height, round)
}
return state, nil
}

// eventValUpdate generates an event that contains info about current validator set
func (app *Application) eventValUpdate(height int64) abci.Event {
size := 0
Expand Down Expand Up @@ -792,6 +832,7 @@ func (app *Application) resetRoundStates() {
state.Close()
}
app.roundStates = map[string]State{}
app.processedBlocks = map[processedBlock]string{}
}

// executeProposal executes transactions and creates new candidate state
Expand Down
101 changes: 101 additions & 0 deletions abci/example/kvstore/kvstore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,13 @@ func testClient(ctx context.Context, t *testing.T, app abciclient.Client, height
require.False(t, rpp.TxResults[0].IsErr())
require.Len(t, rpp.Events, 1)

rabr, err := app.ApplyBlockRandomness(ctx, &types.RequestApplyBlockRandomness{
Height: height,
Beacon: tmcrypto.CRandBytes(tmtypes.SignatureSize),
})
require.NoError(t, err)
require.Equal(t, rpp.AppHash, rabr.AppHash, "no randomness is applied, so the app hash is unchanged")

rfb := &types.RequestFinalizeBlock{Height: height}
rfb.Block, rfb.BlockID = makeBlock(t, height, [][]byte{tx}, rpp.AppHash)
ar, err := app.FinalizeBlock(ctx, rfb)
Expand Down Expand Up @@ -644,3 +651,97 @@ func TestProcessProposalRoundReprocessing(t *testing.T) {
_, err = app.FinalizeBlock(ctx, reqFin)
require.NoError(t, err)
}

// TestApplyBlockRandomness checks that ApplyBlockRandomness answers for the round that was processed,
// gives the same answer when called again, and fails for a round that was not processed.
func TestApplyBlockRandomness(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

app := newKvApp(ctx, t, 1)
const height = 1
resp, err := app.ProcessProposal(ctx, &types.RequestProcessProposal{
Txs: [][]byte{[]byte(testKey)},
Hash: []byte("block-hash"),
Height: height,
Round: 1,
Version: &pbversion.Consensus{App: uint64(height)},
})
require.NoError(t, err)

req := &types.RequestApplyBlockRandomness{
Hash: []byte("block-hash"),
Height: height,
Round: 1,
Beacon: tmcrypto.CRandBytes(tmtypes.SignatureSize),
}
first, err := app.ApplyBlockRandomness(ctx, req)
require.NoError(t, err)
require.Equal(t, resp.AppHash, first.AppHash)

again, err := app.ApplyBlockRandomness(ctx, req)
require.NoError(t, err)
require.Equal(t, first.AppHash, again.AppHash)

_, err = app.ApplyBlockRandomness(ctx, &types.RequestApplyBlockRandomness{Height: height, Round: 0})
require.ErrorContains(t, err, "block not processed")
}

// TestApplyBlockRandomnessEquivocation is the regression test for a node that executed two blocks of
// one round, as with an equivocating proposer: it processed block A at round 0, then a block at round 1,
// which lets round 0 be processed again, then the other round 0 block B, which the network committed.
// ApplyBlockRandomness used to pick the state by height and round only and failed with "more than one
// proposal executed", which is fatal; it now picks the state of the block the request names, and
// refuses a block that was not processed.
func TestApplyBlockRandomnessEquivocation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

app := newKvApp(ctx, t, 1)
process := func(height int64, round int32, hash string, txs ...string) *types.ResponseProcessProposal {
t.Helper()
resp, err := app.ProcessProposal(ctx, &types.RequestProcessProposal{
Txs: bytes2TxsBytes(txs),
Hash: []byte(hash),
Height: height,
Round: round,
Version: &pbversion.Consensus{App: uint64(height)},
})
require.NoError(t, err)
return resp
}
// The app hash changes with the txs only after the initial height.
h1 := process(1, 0, "h1", "k=v")
reqFin := &types.RequestFinalizeBlock{Height: 1}
reqFin.Block, reqFin.BlockID = makeBlock(t, 1, [][]byte{[]byte("k=v")}, h1.AppHash)
_, err := app.FinalizeBlock(ctx, reqFin)
require.NoError(t, err)

const height = 2
a := process(height, 0, "block-A", "a=1")
process(height, 1, "block-X", "x=3")
b := process(height, 0, "block-B", "b=2", "c=4")
require.NotEqual(t, a.AppHash, b.AppHash, "two states for round 0")

apply := func(hash string) (*types.ResponseApplyBlockRandomness, error) {
return app.ApplyBlockRandomness(ctx, &types.RequestApplyBlockRandomness{
Hash: []byte(hash), Height: height, Round: 0, Beacon: tmcrypto.CRandBytes(tmtypes.SignatureSize),
})
}
respB, err := apply("block-B")
require.NoError(t, err)
require.EqualValues(t, b.AppHash, respB.AppHash)
respA, err := apply("block-A")
require.NoError(t, err)
require.EqualValues(t, a.AppHash, respA.AppHash)
_, err = apply("block-never-processed")
require.ErrorContains(t, err, "block not processed")
}

func bytes2TxsBytes(txs []string) [][]byte {
out := make([][]byte, 0, len(txs))
for _, tx := range txs {
out = append(out, []byte(tx))
}
return out
}
6 changes: 6 additions & 0 deletions abci/server/socket_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,12 @@ func (s *SocketServer) processRequest(ctx context.Context, req *types.Request) (
return nil, err
}
return types.ToResponseFinalizeBlock(res), nil
case *types.Request_ApplyBlockRandomness:
res, err := s.app.ApplyBlockRandomness(ctx, r.ApplyBlockRandomness)
if err != nil {
return nil, err
}
return types.ToResponseApplyBlockRandomness(res), nil
default:
return types.ToResponseException("Unknown request"), errors.New("unknown request type")
}
Expand Down
Loading
Loading