diff --git a/tm2/pkg/libtm/.gitignore b/tm2/pkg/libtm/.gitignore new file mode 100644 index 00000000000..15cf730b967 --- /dev/null +++ b/tm2/pkg/libtm/.gitignore @@ -0,0 +1,9 @@ +# MacOS Leftovers +.DS_Store + +# Editor Leftovers +.vscode +.idea + +# Log files +*.log diff --git a/tm2/pkg/libtm/LICENSE b/tm2/pkg/libtm/LICENSE new file mode 100644 index 00000000000..261eeb9e9f8 --- /dev/null +++ b/tm2/pkg/libtm/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tm2/pkg/libtm/Makefile b/tm2/pkg/libtm/Makefile new file mode 100644 index 00000000000..0b6622582f6 --- /dev/null +++ b/tm2/pkg/libtm/Makefile @@ -0,0 +1,19 @@ +.PHONY: lint +lint: + golangci-lint run --config golangci.yaml + +.PHONY: gofumpt +gofumpt: + go install mvdan.cc/gofumpt@latest + gofumpt -l -w . + +.PHONY: fixalign +fixalign: + go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest + fieldalignment -fix $(filter-out $@,$(MAKECMDGOALS)) # the full package name (not path!) + +.PHONY: protoc +protoc: + # Make sure the following prerequisites are installed before running these commands: + # https://grpc.io/docs/languages/go/quickstart/#prerequisites + protoc --go_out=./ ./messages/types/proto/*.proto diff --git a/tm2/pkg/libtm/README.md b/tm2/pkg/libtm/README.md new file mode 100644 index 00000000000..d55a4cad305 --- /dev/null +++ b/tm2/pkg/libtm/README.md @@ -0,0 +1,160 @@ +## Overview + +`libtm` is a simple, minimal and compact Go library that implements the Tendermint consensus engine. + +The implementation is based on Algorithm 1, of +the [Tendermint consensus whitepaper](https://arxiv.org/pdf/1807.04938.pdf) and +(more broadly) the [official Tendermint wiki](https://github.com/tendermint/tendermint/wiki). + +There are some implementation design decisions taken by the package authors: + +- it doesn't manage validator sets internally +- it doesn't implement a networking layer, or any kind of broadcast communication +- it doesn't assume, or implement, any kind of signature manipulation logic + +All of these responsibilities are left to the calling context, in the form of interface implementations. +The reason for these choices is simple - _to keep the library minimal_. + +> [!NOTE] +> We aim to make [libtm](https://github.com/gnolang/libtm) an independent project, both in terms of repository and +> governance. This will be pursued once we have successfully integrated `libtm` with `tm2` and demonstrated that the +> codebase and its API are stable and reliable for broader use. Until this integration is complete and stability is +> confirmed, `libtm` will continue to be improved upon within the current structure, in the gno monorepo. + +### What this library is + +This library is meant to be used as a consensus engine base for any distributed system that needs such functionality. +It is not exclusively made for the blockchain context -- you will find no mention or assumptions of blockchains in the +source code. + +### What this library is not + +This library is _not_ meant to replace your entire consensus setup. + +As mentioned before, certain design decisions have been taken to keep the source code minimal, which results in the +calling context being a bit more involved in orchestration. + +Please, before deciding to utilize this library in your project, understand the different moving parts and their +requirements. + +## Installation + +To get up and running with the `libtm` package, you can add it to your project using: + +```shell +go get -u github.com/gnolang/libtm +``` + +Currently, the minimum required go version is `go 1.21`. + +## Usage Examples + +```go +package main + +import ( + "context" + + "github.com/gnolang/libtm/core" + "github.com/gnolang/libtm/messages/types" +) + +// Verifier implements the libtm Verifier interface. +// Verifier is an abstraction over the outer consensus calling context +// that has access to validator set information +type Verifier struct { + // ... +} + +// Node implements the libtm Node interface. +// The Node interface is an abstraction over a single entity (current process) that runs +// the consensus algorithm +type Node struct { + // ... +} + +// Broadcast implements the libtm Broadcast interface. +// Broadcast is an abstraction over the networking / message sharing interface +// that enables message passing between validators +type Broadcast struct { + // ... +} + +// Signer implements the libtm Signer interface. +// Signer is an abstraction over the signature manipulation process +type Signer struct { + // ... +} + +// ... + +func main() { + // verifier, node, broadcast, signer, opts + var ( + verifier = NewVerifier() + node = NewNode() + broadcast = NewBroadcast() + signer = NewSigner() + ) + + tm := core.NewTendermint( + verifier, + node, + broadcast, + signer, + ) + + height := uint64(1) + ctx, cancelFn := context.WithCancel(context.Background()) + + go func() { + // Run the consensus sequence for the given height. + // When the method returns the finalized proposal, that means that + // consensus was reached within the given height (in any round) + finalizedProposal := tm.RunSequence(ctx, height) + + // Use the finalized proposal + // ... + }() + + go func() { + // Pipe messages into the consensus engine + var proposalMessage *types.ProposalMessage + + if err := tm.AddProposalMessage(proposalMessage); err != nil { + // ... + } + + // ... + + var prevoteMessage *types.PrevoteMessage + + if err := tm.AddPrevoteMessage(prevoteMessage); err != nil { + // ... + } + + // ... + + var precommitMessage *types.PrecommitMessage + + if err := tm.AddPrecommitMessage(precommitMessage); err != nil { + // ... + } + }() + + // ... + + // Stop the sequence at any time by cancelling the context + cancelFn() +} + +``` + +### Additional Options + +You can utilize additional options when creating the `Tendermint` consensus engine instance: + +- `WithLogger` - specifies the logger for the Tendermint consensus engine (slog) +- `WithProposeTimeout` specifies the propose state timeout +- `WithPrevoteTimeout` specifies the prevote state timeout +- `WithPrecommitTimeout` specifies the precommit state timeout diff --git a/tm2/pkg/libtm/core/broadcast.go b/tm2/pkg/libtm/core/broadcast.go new file mode 100644 index 00000000000..f636687a10a --- /dev/null +++ b/tm2/pkg/libtm/core/broadcast.go @@ -0,0 +1,77 @@ +package core + +import ( + "github.com/gnolang/libtm/messages/types" +) + +// buildProposalMessage builds a proposal message using the given proposal +func (t *Tendermint) buildProposalMessage(proposal []byte, proposalRound int64) *types.ProposalMessage { + var ( + height = t.state.getHeight() + round = t.state.getRound() + ) + + // Build the proposal message (assumes the node will sign it) + message := &types.ProposalMessage{ + View: &types.View{ + Height: height, + Round: round, + }, + Sender: t.node.ID(), + Proposal: proposal, + ProposalRound: proposalRound, + } + + // Sign the message + message.Signature = t.signer.Sign(message.GetSignaturePayload()) + + return message +} + +// buildPrevoteMessage builds a prevote message using the given proposal identifier +func (t *Tendermint) buildPrevoteMessage(id []byte) *types.PrevoteMessage { + var ( + height = t.state.getHeight() + round = t.state.getRound() + + processID = t.node.ID() + ) + + message := &types.PrevoteMessage{ + View: &types.View{ + Height: height, + Round: round, + }, + Sender: processID, + Identifier: id, + } + + // Sign the message + message.Signature = t.signer.Sign(message.GetSignaturePayload()) + + return message +} + +// buildPrecommitMessage builds a precommit message using the given precommit identifier +func (t *Tendermint) buildPrecommitMessage(id []byte) *types.PrecommitMessage { + var ( + height = t.state.getHeight() + round = t.state.getRound() + + processID = t.node.ID() + ) + + message := &types.PrecommitMessage{ + View: &types.View{ + Height: height, + Round: round, + }, + Sender: processID, + Identifier: id, + } + + // Sign the message + message.Signature = t.signer.Sign(message.GetSignaturePayload()) + + return message +} diff --git a/tm2/pkg/libtm/core/cache.go b/tm2/pkg/libtm/core/cache.go new file mode 100644 index 00000000000..3535f528c25 --- /dev/null +++ b/tm2/pkg/libtm/core/cache.go @@ -0,0 +1,63 @@ +package core + +import ( + "github.com/gnolang/libtm/messages/types" +) + +// msgType is the combined message type interface +type msgType interface { + *types.ProposalMessage | *types.PrevoteMessage | *types.PrecommitMessage +} + +type cacheMessage interface { + msgType + Message +} + +// messageCache contains filtered messages +// added in by the calling context +type messageCache[T cacheMessage] struct { + isValidFn func(T) bool + seenMap map[string]struct{} + filteredMessages []T +} + +// newMessageCache creates a new incoming message cache +func newMessageCache[T cacheMessage](isValidFn func(T) bool) messageCache[T] { + return messageCache[T]{ + isValidFn: isValidFn, + filteredMessages: make([]T, 0), + seenMap: make(map[string]struct{}), + } +} + +// addMessages pushes a new message list that is filtered +// and parsed by the cache +func (c *messageCache[T]) addMessages(messages []T) { + for _, message := range messages { + sender := message.GetSender() + + // Check if the message has been seen in the past + _, seen := c.seenMap[string(sender)] + if seen { + continue + } + + // Filter the message + if !c.isValidFn(message) { + continue + } + + // Mark the message as seen + c.seenMap[string(sender)] = struct{}{} + + // Save the message as it's + // been filtered, and doesn't exist in the cache + c.filteredMessages = append(c.filteredMessages, message) + } +} + +// getMessages returns the filtered out messages from the cache +func (c *messageCache[T]) getMessages() []T { + return c.filteredMessages +} diff --git a/tm2/pkg/libtm/core/cache_test.go b/tm2/pkg/libtm/core/cache_test.go new file mode 100644 index 00000000000..ea878aacda0 --- /dev/null +++ b/tm2/pkg/libtm/core/cache_test.go @@ -0,0 +1,70 @@ +package core + +import ( + "testing" + + "github.com/gnolang/libtm/messages/types" + "github.com/stretchr/testify/assert" +) + +func TestMessageCache_AddMessages(t *testing.T) { + t.Parallel() + + isValidFn := func(_ *types.PrevoteMessage) bool { + return true + } + + t.Run("non-duplicate messages", func(t *testing.T) { + t.Parallel() + + // Create the cache + cache := newMessageCache[*types.PrevoteMessage](isValidFn) + + // Generate non-duplicate messages + messages := generatePrevoteMessages(t, 10, &types.View{}, nil) + + // Add the messages + cache.addMessages(messages) + + // Make sure all messages are added + fetchedMessages := cache.getMessages() + + for index, message := range messages { + assert.True(t, message.Equals(fetchedMessages[index])) + } + }) + + t.Run("duplicate messages", func(t *testing.T) { + t.Parallel() + + var ( + numMessages = 10 + numDuplicates = numMessages / 2 + ) + + // Create the cache + cache := newMessageCache[*types.PrevoteMessage](isValidFn) + + // Generate non-duplicate messages + messages := generatePrevoteMessages(t, numMessages, &types.View{}, nil) + + // Make sure some are duplicated + for i := 0; i < numDuplicates; i++ { + messages[i].Sender = []byte("common sender") + } + + expectedMessages := messages[numDuplicates-1:] + + // Add the messages + cache.addMessages(messages) + + // Make sure all messages are added + fetchedMessages := cache.getMessages() + + assert.Len(t, fetchedMessages, len(expectedMessages)) + + for index, message := range expectedMessages { + assert.True(t, message.Equals(fetchedMessages[index])) + } + }) +} diff --git a/tm2/pkg/libtm/core/cluster_test.go b/tm2/pkg/libtm/core/cluster_test.go new file mode 100644 index 00000000000..2a3fbbfd763 --- /dev/null +++ b/tm2/pkg/libtm/core/cluster_test.go @@ -0,0 +1,435 @@ +package core + +import ( + "bytes" + "fmt" + "testing" + "time" + + "github.com/gnolang/libtm/messages/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// generateNodeAddresses generates dummy node addresses +func generateNodeAddresses(count uint64) [][]byte { + addresses := make([][]byte, count) + + for index := range addresses { + addresses[index] = []byte(fmt.Sprintf("node %d", index)) + } + + return addresses +} + +// TestConsensus_ValidFlow tests the following scenario: +// N = 4 +// +// - Node 0 is the proposer for block 1, round 0 +// - Node 0 proposes a valid block B +// - All nodes go through the consensus states to insert the valid block B +func TestConsensus_ValidFlow(t *testing.T) { + t.Parallel() + + var ( + broadcastProposeFn func(message *types.ProposalMessage) + broadcastPrevoteFn func(message *types.PrevoteMessage) + broadcastPrecommitFn func(message *types.PrecommitMessage) + + proposal = []byte("proposal") + proposalHash = []byte("proposal hash") + signature = []byte("signature") + numNodes = uint64(4) + nodes = generateNodeAddresses(numNodes) + + defaultTimeout = Timeout{ + Initial: 2 * time.Second, + Delta: 200 * time.Millisecond, + } + ) + + // commonBroadcastCallback is the common method modification + // required for Broadcast, for all nodes + commonBroadcastCallback := func(broadcast *mockBroadcast) { + broadcast.broadcastProposeFn = func(message *types.ProposalMessage) { + broadcastProposeFn(message) + } + + broadcast.broadcastPrevoteFn = func(message *types.PrevoteMessage) { + broadcastPrevoteFn(message) + } + + broadcast.broadcastPrecommitFn = func(message *types.PrecommitMessage) { + broadcastPrecommitFn(message) + } + } + + // commonNodeCallback is the common method modification required + // for the Node, for all nodes + commonNodeCallback := func(node *mockNode, nodeIndex int) { + node.idFn = func() []byte { + return nodes[nodeIndex] + } + + node.hashFn = func(_ []byte) []byte { + return proposalHash + } + } + + // commonSignerCallback is the common method modification required + // for the Signer, for all nodes + commonSignerCallback := func(signer *mockSigner) { + signer.signFn = func(_ []byte) []byte { + return signature + } + + signer.isValidSignatureFn = func(_, sig []byte) bool { + return bytes.Equal(sig, signature) + } + } + + // commonVerifierCallback is the common method modification required + // for the Verifier, for all nodes + commonVerifierCallback := func(verifier *mockVerifier) { + verifier.isProposerFn = func(from []byte, _ uint64, _ uint64) bool { + return bytes.Equal(from, nodes[0]) + } + + verifier.isValidProposalFn = func(newProposal []byte, _ uint64) bool { + return bytes.Equal(newProposal, proposal) + } + + verifier.isValidatorFn = func(_ []byte) bool { + return true + } + + verifier.getTotalVotingPowerFn = func(_ uint64) uint64 { + return numNodes + } + + verifier.getSumVotingPowerFn = func(messages []Message) uint64 { + return uint64(len(messages)) + } + } + + var ( + verifierCallbackMap = map[int]verifierConfigCallback{ + 0: func(verifier *mockVerifier) { + commonVerifierCallback(verifier) + }, + 1: func(verifier *mockVerifier) { + commonVerifierCallback(verifier) + }, + 2: func(verifier *mockVerifier) { + commonVerifierCallback(verifier) + }, + 3: func(verifier *mockVerifier) { + commonVerifierCallback(verifier) + }, + } + + nodeCallbackMap = map[int]nodeConfigCallback{ + 0: func(node *mockNode) { + commonNodeCallback(node, 0) + + node.buildProposalFn = func(_ uint64) []byte { + return proposal + } + }, + 1: func(node *mockNode) { + commonNodeCallback(node, 1) + }, + 2: func(node *mockNode) { + commonNodeCallback(node, 2) + }, + 3: func(node *mockNode) { + commonNodeCallback(node, 3) + }, + } + + broadcastCallbackMap = map[int]broadcastConfigCallback{ + 0: commonBroadcastCallback, + 1: commonBroadcastCallback, + 2: commonBroadcastCallback, + 3: commonBroadcastCallback, + } + + signerCallbackMap = map[int]signerConfigCallback{ + 0: commonSignerCallback, + 1: commonSignerCallback, + 2: commonSignerCallback, + 3: commonSignerCallback, + } + + commonOptions = []Option{ + WithProposeTimeout(defaultTimeout), + WithPrevoteTimeout(defaultTimeout), + WithPrecommitTimeout(defaultTimeout), + } + + optionsCallbackMap = map[int][]Option{ + 0: commonOptions, + 1: commonOptions, + 2: commonOptions, + 3: commonOptions, + } + ) + + // Create the mock cluster + cluster := newMockCluster( + numNodes, + verifierCallbackMap, + nodeCallbackMap, + broadcastCallbackMap, + signerCallbackMap, + optionsCallbackMap, + ) + + broadcastProposeFn = func(message *types.ProposalMessage) { + require.NoError(t, cluster.pushProposalMessage(message)) + } + + broadcastPrevoteFn = func(message *types.PrevoteMessage) { + require.NoError(t, cluster.pushPrevoteMessage(message)) + } + + broadcastPrecommitFn = func(message *types.PrecommitMessage) { + require.NoError(t, cluster.pushPrecommitMessage(message)) + } + + // Start the main run loops + cluster.runSequence(0) + + // Wait until the main run loops finish + cluster.ensureShutdown(5 * time.Second) + + // Make sure the finalized proposals match what node 0 proposed + for _, finalizedProposal := range cluster.finalizedProposals { + require.NotNil(t, finalizedProposal) + + assert.True(t, bytes.Equal(finalizedProposal.Data, proposal)) + assert.True(t, bytes.Equal(finalizedProposal.ID, proposalHash)) + } +} + +// TestConsensus_InvalidBlock tests the following scenario: +// N = 4 +// +// - Node 0 is the proposer for block 1, round 0 +// - Node 0 proposes an invalid block B +// - Other nodes should verify that the block is invalid +// - All nodes should move to round 1, and start a new consensus round +// - Node 1 is the proposer for block 1, round 1 +// - Node 1 proposes a valid block B' +// - All nodes go through the consensus states to insert the valid block B' +func TestConsensus_InvalidFlow(t *testing.T) { + t.Parallel() + + var ( + broadcastProposeFn func(message *types.ProposalMessage) + broadcastPrevoteFn func(message *types.PrevoteMessage) + broadcastPrecommitFn func(message *types.PrecommitMessage) + + proposals = [][]byte{ + []byte("proposal 1"), // proposed by node 0 + []byte("proposal 2"), // proposed by node 1 + } + + proposalHashes = [][]byte{ + []byte("proposal hash 1"), // for proposal 1 + []byte("proposal hash 2"), // for proposal 2 + } + + signature = []byte("signature") + numNodes = uint64(4) + nodes = generateNodeAddresses(numNodes) + + defaultTimeout = Timeout{ + Initial: 2 * time.Second, + Delta: 200 * time.Millisecond, + } + + precommitTimeout = Timeout{ + Initial: 300 * time.Millisecond, // low timeout, so a new round is started quicker + Delta: 200 * time.Millisecond, + } + ) + + // commonBroadcastCallback is the common method modification + // required for Broadcast, for all nodes + commonBroadcastCallback := func(broadcast *mockBroadcast) { + broadcast.broadcastProposeFn = func(message *types.ProposalMessage) { + broadcastProposeFn(message) + } + + broadcast.broadcastPrevoteFn = func(message *types.PrevoteMessage) { + broadcastPrevoteFn(message) + } + + broadcast.broadcastPrecommitFn = func(message *types.PrecommitMessage) { + broadcastPrecommitFn(message) + } + } + + // commonNodeCallback is the common method modification required + // for the Node, for all nodes + commonNodeCallback := func(node *mockNode, nodeIndex int) { + node.idFn = func() []byte { + return nodes[nodeIndex] + } + + node.hashFn = func(proposal []byte) []byte { + if bytes.Equal(proposal, proposals[0]) { + return proposalHashes[0] + } + + return proposalHashes[1] + } + } + + // commonSignerCallback is the common method modification required + // for the Signer, for all nodes + commonSignerCallback := func(signer *mockSigner) { + signer.signFn = func(_ []byte) []byte { + return signature + } + + signer.isValidSignatureFn = func(_, sig []byte) bool { + return bytes.Equal(sig, signature) + } + } + + // commonVerifierCallback is the common method modification required + // for the Verifier, for all nodes + commonVerifierCallback := func(verifier *mockVerifier) { + verifier.isProposerFn = func(from []byte, _ uint64, round uint64) bool { + // Node 0 is the proposer for round 0 + // Node 1 is the proposer for round 1 + return bytes.Equal(from, nodes[round]) + } + + verifier.isValidProposalFn = func(newProposal []byte, _ uint64) bool { + // Node 1 is the proposer for round 1, + // and their proposal is the only one that's valid + return bytes.Equal(newProposal, proposals[1]) + } + + verifier.isValidatorFn = func(_ []byte) bool { + return true + } + + verifier.getTotalVotingPowerFn = func(_ uint64) uint64 { + return numNodes + } + + verifier.getSumVotingPowerFn = func(messages []Message) uint64 { + return uint64(len(messages)) + } + } + + var ( + verifierCallbackMap = map[int]verifierConfigCallback{ + 0: func(verifier *mockVerifier) { + commonVerifierCallback(verifier) + }, + 1: func(verifier *mockVerifier) { + commonVerifierCallback(verifier) + }, + 2: func(verifier *mockVerifier) { + commonVerifierCallback(verifier) + }, + 3: func(verifier *mockVerifier) { + commonVerifierCallback(verifier) + }, + } + + nodeCallbackMap = map[int]nodeConfigCallback{ + 0: func(node *mockNode) { + commonNodeCallback(node, 0) + + node.buildProposalFn = func(_ uint64) []byte { + return proposals[0] + } + }, + 1: func(node *mockNode) { + commonNodeCallback(node, 1) + + node.buildProposalFn = func(_ uint64) []byte { + return proposals[1] + } + }, + 2: func(node *mockNode) { + commonNodeCallback(node, 2) + }, + 3: func(node *mockNode) { + commonNodeCallback(node, 3) + }, + } + + broadcastCallbackMap = map[int]broadcastConfigCallback{ + 0: commonBroadcastCallback, + 1: commonBroadcastCallback, + 2: commonBroadcastCallback, + 3: commonBroadcastCallback, + } + + signerCallbackMap = map[int]signerConfigCallback{ + 0: commonSignerCallback, + 1: commonSignerCallback, + 2: commonSignerCallback, + 3: commonSignerCallback, + } + + commonOptions = []Option{ + WithProposeTimeout(defaultTimeout), + WithPrevoteTimeout(defaultTimeout), + WithPrecommitTimeout(precommitTimeout), + } + + optionsCallbackMap = map[int][]Option{ + 0: commonOptions, + 1: commonOptions, + 2: commonOptions, + 3: commonOptions, + } + ) + + // Create the mock cluster + cluster := newMockCluster( + numNodes, + verifierCallbackMap, + nodeCallbackMap, + broadcastCallbackMap, + signerCallbackMap, + optionsCallbackMap, + ) + + broadcastProposeFn = func(message *types.ProposalMessage) { + _ = cluster.pushProposalMessage(message) //nolint:errcheck // No need to check + } + + broadcastPrevoteFn = func(message *types.PrevoteMessage) { + _ = cluster.pushPrevoteMessage(message) //nolint:errcheck // No need to check + } + + broadcastPrecommitFn = func(message *types.PrecommitMessage) { + _ = cluster.pushPrecommitMessage(message) //nolint:errcheck // No need to check + } + + // Start the main run loops + cluster.runSequence(0) + + // Wait until the main run loops finish + cluster.ensureShutdown(5 * time.Second) + + // Make sure the nodes switched to the new round + assert.True(t, cluster.areAllNodesOnRound(1)) + + // Make sure the finalized proposals match what node 0 proposed + for _, finalizedProposal := range cluster.finalizedProposals { + require.NotNil(t, finalizedProposal) + + assert.True(t, bytes.Equal(finalizedProposal.Data, proposals[1])) + assert.True(t, bytes.Equal(finalizedProposal.ID, proposalHashes[1])) + } +} diff --git a/tm2/pkg/libtm/core/messages.go b/tm2/pkg/libtm/core/messages.go new file mode 100644 index 00000000000..961e8586c8d --- /dev/null +++ b/tm2/pkg/libtm/core/messages.go @@ -0,0 +1,97 @@ +package core + +import ( + "errors" + "fmt" + + "github.com/gnolang/libtm/messages/types" +) + +var ( + ErrInvalidMessageSignature = errors.New("invalid message signature") + ErrMessageFromNonValidator = errors.New("message is from a non-validator") + ErrEarlierHeightMessage = errors.New("message is for an earlier height") + ErrEarlierRoundMessage = errors.New("message is for an earlier round") +) + +// AddProposalMessage verifies and adds a new proposal message to the consensus engine +func (t *Tendermint) AddProposalMessage(message *types.ProposalMessage) error { + // Verify the incoming message + if err := t.verifyMessage(message); err != nil { + return fmt.Errorf("unable to verify proposal message, %w", err) + } + + // Add the message to the store + t.store.addProposalMessage(message) + + return nil +} + +// AddPrevoteMessage verifies and adds a new prevote message to the consensus engine +func (t *Tendermint) AddPrevoteMessage(message *types.PrevoteMessage) error { + // Verify the incoming message + if err := t.verifyMessage(message); err != nil { + return fmt.Errorf("unable to verify prevote message, %w", err) + } + + // Add the message to the store + t.store.addPrevoteMessage(message) + + return nil +} + +// AddPrecommitMessage verifies and adds a new precommit message to the consensus engine +func (t *Tendermint) AddPrecommitMessage(message *types.PrecommitMessage) error { + // Verify the incoming message + if err := t.verifyMessage(message); err != nil { + return fmt.Errorf("unable to verify precommit message, %w", err) + } + + // Add the message to the store + t.store.addPrecommitMessage(message) + + return nil +} + +// verifyMessage is the common base message verification +func (t *Tendermint) verifyMessage(message Message) error { + // Check if the message is valid + if err := message.Verify(); err != nil { + return fmt.Errorf("unable to verify message, %w", err) + } + + // Make sure the message sender is a validator + if !t.verifier.IsValidator(message.GetSender()) { + return ErrMessageFromNonValidator + } + + // Get the signature payload + signPayload := message.GetSignaturePayload() + + // Make sure the signature is valid + if !t.signer.IsValidSignature(signPayload, message.GetSignature()) { + return ErrInvalidMessageSignature + } + + // Make sure the message view is valid + var ( + view = message.GetView() + + currentHeight = t.state.getHeight() + currentRound = t.state.getRound() + ) + + // Make sure the height is valid. + // The message height needs to be the current state height, or greater + if currentHeight > view.GetHeight() { + return ErrEarlierHeightMessage + } + + // Make sure the round is valid. + // The message rounds needs to be >= the current round + if currentRound > view.GetRound() { + return ErrEarlierRoundMessage + } + + return nil +} diff --git a/tm2/pkg/libtm/core/mocks_test.go b/tm2/pkg/libtm/core/mocks_test.go new file mode 100644 index 00000000000..174743f31cf --- /dev/null +++ b/tm2/pkg/libtm/core/mocks_test.go @@ -0,0 +1,446 @@ +package core + +import ( + "context" + "sync" + "sync/atomic" + "time" + + "github.com/gnolang/libtm/messages/types" +) + +type ( + broadcastProposeDelegate func(*types.ProposalMessage) + broadcastPrevoteDelegate func(*types.PrevoteMessage) + broadcastPrecommitDelegate func(*types.PrecommitMessage) +) + +type mockBroadcast struct { + broadcastProposeFn broadcastProposeDelegate + broadcastPrevoteFn broadcastPrevoteDelegate + broadcastPrecommitFn broadcastPrecommitDelegate +} + +func (m *mockBroadcast) BroadcastPropose(message *types.ProposalMessage) { + if m.broadcastProposeFn != nil { + m.broadcastProposeFn(message) + } +} + +func (m *mockBroadcast) BroadcastPrevote(message *types.PrevoteMessage) { + if m.broadcastPrevoteFn != nil { + m.broadcastPrevoteFn(message) + } +} + +func (m *mockBroadcast) BroadcastPrecommit(message *types.PrecommitMessage) { + if m.broadcastPrecommitFn != nil { + m.broadcastPrecommitFn(message) + } +} + +type ( + idDelegate func() []byte + hashDelegate func([]byte) []byte + buildProposalDelegate func(uint64) []byte +) + +type mockNode struct { + idFn idDelegate + hashFn hashDelegate + buildProposalFn buildProposalDelegate +} + +func (m *mockNode) ID() []byte { + if m.idFn != nil { + return m.idFn() + } + + return nil +} + +func (m *mockNode) Hash(proposal []byte) []byte { + if m.hashFn != nil { + return m.hashFn(proposal) + } + + return nil +} + +func (m *mockNode) BuildProposal(height uint64) []byte { + if m.buildProposalFn != nil { + return m.buildProposalFn(height) + } + + return nil +} + +type ( + signDelegate func([]byte) []byte + isValidSignatureDelegate func([]byte, []byte) bool +) + +type mockSigner struct { + signFn signDelegate + isValidSignatureFn isValidSignatureDelegate +} + +func (m *mockSigner) Sign(data []byte) []byte { + if m.signFn != nil { + return m.signFn(data) + } + + return nil +} + +func (m *mockSigner) IsValidSignature(data, signature []byte) bool { + if m.isValidSignatureFn != nil { + return m.isValidSignatureFn(data, signature) + } + + return false +} + +type ( + isProposerDelegate func([]byte, uint64, uint64) bool + isValidatorDelegate func([]byte) bool + isValidProposalDelegate func([]byte, uint64) bool + getTotalVotingPowerDelegate func(uint64) uint64 + getSumVotingPowerDelegate func([]Message) uint64 +) + +type mockVerifier struct { + isProposerFn isProposerDelegate + isValidatorFn isValidatorDelegate + isValidProposalFn isValidProposalDelegate + getTotalVotingPowerFn getTotalVotingPowerDelegate + getSumVotingPowerFn getSumVotingPowerDelegate +} + +func (m *mockVerifier) GetTotalVotingPower(height uint64) uint64 { + if m.getTotalVotingPowerFn != nil { + return m.getTotalVotingPowerFn(height) + } + + return 0 +} + +func (m *mockVerifier) GetSumVotingPower(msgs []Message) uint64 { + if m.getSumVotingPowerFn != nil { + return m.getSumVotingPowerFn(msgs) + } + + return 0 +} + +func (m *mockVerifier) IsProposer(id []byte, height, round uint64) bool { + if m.isProposerFn != nil { + return m.isProposerFn(id, height, round) + } + + return false +} + +func (m *mockVerifier) IsValidator(from []byte) bool { + if m.isValidatorFn != nil { + return m.isValidatorFn(from) + } + + return false +} + +func (m *mockVerifier) IsValidProposal(proposal []byte, height uint64) bool { + if m.isValidProposalFn != nil { + return m.isValidProposalFn(proposal, height) + } + + return false +} + +type ( + getViewDelegate func() *types.View + getSenderDelegate func() []byte + getSignatureDelegate func() []byte + getSignaturePayloadDelegate func() []byte + verifyDelegate func() error +) + +type mockMessage struct { + getViewFn getViewDelegate + getSenderFn getSenderDelegate + getSignatureFn getSignatureDelegate + getSignaturePayloadFn getSignaturePayloadDelegate + verifyFn verifyDelegate +} + +func (m *mockMessage) GetView() *types.View { + if m.getViewFn != nil { + return m.getViewFn() + } + + return nil +} + +func (m *mockMessage) GetSender() []byte { + if m.getSenderFn != nil { + return m.getSenderFn() + } + + return nil +} + +func (m *mockMessage) GetSignature() []byte { + if m.getSignatureFn != nil { + return m.getSignatureFn() + } + + return nil +} + +func (m *mockMessage) GetSignaturePayload() []byte { + if m.getSignaturePayloadFn != nil { + return m.getSignaturePayloadFn() + } + + return nil +} + +func (m *mockMessage) Verify() error { + if m.verifyFn != nil { + return m.verifyFn() + } + + return nil +} + +// mockNodeContext keeps track of the node runtime context +type mockNodeContext struct { + ctx context.Context + cancelFn context.CancelFunc +} + +// mockNodeWg is the WaitGroup wrapper for the cluster nodes +type mockNodeWg struct { + sync.WaitGroup + count int64 +} + +func (wg *mockNodeWg) Add(delta int) { + wg.WaitGroup.Add(delta) +} + +func (wg *mockNodeWg) Done() { + wg.WaitGroup.Done() + atomic.AddInt64(&wg.count, 1) +} + +func (wg *mockNodeWg) getDone() int64 { + return atomic.LoadInt64(&wg.count) +} + +func (wg *mockNodeWg) resetDone() { + atomic.StoreInt64(&wg.count, 0) +} + +type ( + verifierConfigCallback func(*mockVerifier) + nodeConfigCallback func(*mockNode) + broadcastConfigCallback func(*mockBroadcast) + signerConfigCallback func(*mockSigner) +) + +// mockCluster represents a mock Tendermint cluster +type mockCluster struct { + nodes []*Tendermint // references to the nodes in the cluster + ctxs []mockNodeContext // context handlers for the nodes in the cluster + finalizedProposals []*FinalizedProposal // finalized proposals for the nodes + + stoppedWg mockNodeWg +} + +// newMockCluster creates a new mock Tendermint cluster +func newMockCluster( + count uint64, + verifierCallbackMap map[int]verifierConfigCallback, + nodeCallbackMap map[int]nodeConfigCallback, + broadcastCallbackMap map[int]broadcastConfigCallback, + signerCallbackMap map[int]signerConfigCallback, + optionsMap map[int][]Option, +) *mockCluster { + if count < 1 { + return nil + } + + nodes := make([]*Tendermint, count) + nodeCtxs := make([]mockNodeContext, count) + + for index := 0; index < int(count); index++ { + var ( + verifier = &mockVerifier{} + node = &mockNode{} + broadcast = &mockBroadcast{} + signer = &mockSigner{} + options = make([]Option, 0) + ) + + // Execute set callbacks, if any + if verifierCallbackMap != nil { + if verifierCallback, isSet := verifierCallbackMap[index]; isSet { + verifierCallback(verifier) + } + } + + if nodeCallbackMap != nil { + if nodeCallback, isSet := nodeCallbackMap[index]; isSet { + nodeCallback(node) + } + } + + if broadcastCallbackMap != nil { + if broadcastCallback, isSet := broadcastCallbackMap[index]; isSet { + broadcastCallback(broadcast) + } + } + + if signerCallbackMap != nil { + if signerCallback, isSet := signerCallbackMap[index]; isSet { + signerCallback(signer) + } + } + + if optionsMap != nil { + if opts, isSet := optionsMap[index]; isSet { + options = opts + } + } + + // Create a new instance of the Tendermint node + nodes[index] = NewTendermint( + verifier, + node, + broadcast, + signer, + options..., + ) + + // Instantiate context for the nodes + ctx, cancelFn := context.WithCancel(context.Background()) + nodeCtxs[index] = mockNodeContext{ + ctx: ctx, + cancelFn: cancelFn, + } + } + + return &mockCluster{ + nodes: nodes, + ctxs: nodeCtxs, + finalizedProposals: make([]*FinalizedProposal, count), + } +} + +// runSequence runs the cluster sequence for the given height +func (m *mockCluster) runSequence(height uint64) { + m.stoppedWg.resetDone() + + for nodeIndex, node := range m.nodes { + m.stoppedWg.Add(1) + + go func( + ctx context.Context, + node *Tendermint, + nodeIndex int, + height uint64, + ) { + defer m.stoppedWg.Done() + + // Start the main run loop for the node + finalizedProposal := node.RunSequence(ctx, height) + + m.finalizedProposals[nodeIndex] = finalizedProposal + }(m.ctxs[nodeIndex].ctx, node, nodeIndex, height) + } +} + +// awaitCompletion waits for completion of all +// nodes in the cluster +func (m *mockCluster) awaitCompletion() { + // Wait for all main run loops to signalize + // that they're finished + m.stoppedWg.Wait() +} + +// ensureShutdown ensures the cluster is shutdown within the given duration +func (m *mockCluster) ensureShutdown(timeout time.Duration) { + ch := time.After(timeout) + + for { + select { + case <-ch: + m.forceShutdown() + + return + default: + if m.stoppedWg.getDone() == int64(len(m.nodes)) { + // All nodes are finished + return + } + } + } +} + +// forceShutdown sends a stop signal to all running nodes +// in the cluster, and awaits their completion +func (m *mockCluster) forceShutdown() { + // Send a stop signal to all the nodes + for _, ctx := range m.ctxs { + ctx.cancelFn() + } + + // Wait for all the nodes to finish + m.awaitCompletion() +} + +// pushProposalMessage relays the proposal message to all nodes in the cluster +func (m *mockCluster) pushProposalMessage(message *types.ProposalMessage) error { + for _, node := range m.nodes { + if err := node.AddProposalMessage(message); err != nil { + return err + } + } + + return nil +} + +// pushPrevoteMessage relays the prevote message to all nodes in the cluster +func (m *mockCluster) pushPrevoteMessage(message *types.PrevoteMessage) error { + for _, node := range m.nodes { + if err := node.AddPrevoteMessage(message); err != nil { + return err + } + } + + return nil +} + +// pushPrecommitMessage relays the precommit message to all nodes in the cluster +func (m *mockCluster) pushPrecommitMessage(message *types.PrecommitMessage) error { + for _, node := range m.nodes { + if err := node.AddPrecommitMessage(message); err != nil { + return err + } + } + + return nil +} + +// areAllNodesOnRound checks to make sure all nodes +// are on the same specified round +func (m *mockCluster) areAllNodesOnRound(round uint64) bool { + for _, node := range m.nodes { + if node.state.getRound() != round { + return false + } + } + + return true +} diff --git a/tm2/pkg/libtm/core/options.go b/tm2/pkg/libtm/core/options.go new file mode 100644 index 00000000000..9ff8581ada8 --- /dev/null +++ b/tm2/pkg/libtm/core/options.go @@ -0,0 +1,33 @@ +package core + +import "log/slog" + +type Option func(t *Tendermint) + +// WithLogger specifies the logger for the Tendermint consensus engine +func WithLogger(l *slog.Logger) Option { + return func(t *Tendermint) { + t.logger = l + } +} + +// WithProposeTimeout specifies the propose state timeout +func WithProposeTimeout(timeout Timeout) Option { + return func(t *Tendermint) { + t.timeouts[propose] = timeout + } +} + +// WithPrevoteTimeout specifies the prevote state timeout +func WithPrevoteTimeout(timeout Timeout) Option { + return func(t *Tendermint) { + t.timeouts[prevote] = timeout + } +} + +// WithPrecommitTimeout specifies the precommit state timeout +func WithPrecommitTimeout(timeout Timeout) Option { + return func(t *Tendermint) { + t.timeouts[precommit] = timeout + } +} diff --git a/tm2/pkg/libtm/core/options_test.go b/tm2/pkg/libtm/core/options_test.go new file mode 100644 index 00000000000..9d7909985fe --- /dev/null +++ b/tm2/pkg/libtm/core/options_test.go @@ -0,0 +1,87 @@ +package core + +import ( + "io" + "log/slog" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestNewTendermint_Options(t *testing.T) { + t.Parallel() + + t.Run("Withlogger", func(t *testing.T) { + t.Parallel() + + l := slog.New(slog.NewTextHandler(io.Discard, nil)) + + tm := NewTendermint( + nil, + nil, + nil, + nil, + WithLogger(l), + ) + + assert.Equal(t, tm.logger, l) + }) + + t.Run("WithProposeTimeout", func(t *testing.T) { + t.Parallel() + + timeout := Timeout{ + Initial: 500 * time.Millisecond, + Delta: 0, + } + + tm := NewTendermint( + nil, + nil, + nil, + nil, + WithProposeTimeout(timeout), + ) + + assert.Equal(t, tm.timeouts[propose], timeout) + }) + + t.Run("WithPrevoteTimeout", func(t *testing.T) { + t.Parallel() + + timeout := Timeout{ + Initial: 500 * time.Millisecond, + Delta: 0, + } + + tm := NewTendermint( + nil, + nil, + nil, + nil, + WithPrevoteTimeout(timeout), + ) + + assert.Equal(t, tm.timeouts[prevote], timeout) + }) + + t.Run("WithPrecommitTimeout", func(t *testing.T) { + t.Parallel() + + timeout := Timeout{ + Initial: 500 * time.Millisecond, + Delta: 0, + } + + tm := NewTendermint( + nil, + nil, + nil, + nil, + WithPrecommitTimeout(timeout), + ) + + assert.Equal(t, tm.timeouts[precommit], timeout) + }) +} diff --git a/tm2/pkg/libtm/core/quorum.go b/tm2/pkg/libtm/core/quorum.go new file mode 100644 index 00000000000..cf12bc9b8f5 --- /dev/null +++ b/tm2/pkg/libtm/core/quorum.go @@ -0,0 +1,23 @@ +package core + +// hasSuperMajority verifies that there is a 2F+1 voting power majority +// in the given message set. +// This follows the constraint that N > 3F, i.e., the total voting power of faulty processes is smaller than +// one third of the total voting power +func (t *Tendermint) hasSuperMajority(messages []Message) bool { + sumVotingPower := t.verifier.GetSumVotingPower(messages) + totalVotingPower := t.verifier.GetTotalVotingPower(t.state.getHeight()) + + return sumVotingPower > (2 * totalVotingPower / 3) +} + +// hasFaultyMajority verifies that there is an F+1 voting power majority +// in the given message set. +// This follows the constraint that N > 3F, i.e., the total voting power of faulty processes is smaller than +// one third of the total voting power +func (t *Tendermint) hasFaultyMajority(messages []Message) bool { + sumVotingPower := t.verifier.GetSumVotingPower(messages) + totalVotingPower := t.verifier.GetTotalVotingPower(t.state.getHeight()) + + return sumVotingPower > totalVotingPower/3 +} diff --git a/tm2/pkg/libtm/core/quorum_test.go b/tm2/pkg/libtm/core/quorum_test.go new file mode 100644 index 00000000000..23f804de2b7 --- /dev/null +++ b/tm2/pkg/libtm/core/quorum_test.go @@ -0,0 +1,219 @@ +package core + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTendermint_QuorumSuperMajority(t *testing.T) { + t.Parallel() + + var ( + // Equal voting power map + votingPowerMap = map[string]uint64{ + "1": 1, + "2": 1, + "3": 1, + "4": 1, + } + + mockMessages = []*mockMessage{ + { + getSenderFn: func() []byte { + return []byte("1") + }, + }, + { + getSenderFn: func() []byte { + return []byte("2") + }, + }, + { + getSenderFn: func() []byte { + return []byte("3") + }, + }, + { + getSenderFn: func() []byte { + return []byte("4") + }, + }, + } + + mockVerifier = &mockVerifier{ + getTotalVotingPowerFn: func(_ uint64) uint64 { + return uint64(len(votingPowerMap)) + }, + getSumVotingPowerFn: func(messages []Message) uint64 { + sum := uint64(0) + + for _, message := range messages { + sum += votingPowerMap[string(message.GetSender())] + } + + return sum + }, + } + ) + + testTable := []struct { + name string + messages []*mockMessage + shouldHaveMajority bool + }{ + { + "4/4 validators", + mockMessages, + true, + }, + { + "3/4 validators", + mockMessages[1:], + true, + }, + { + "2/4 validators", + mockMessages[2:], + false, + }, + { + "1/4 validators", + mockMessages[:1], + false, + }, + } + + for _, testCase := range testTable { + testCase := testCase + + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + tm := NewTendermint( + mockVerifier, + nil, + nil, + nil, + ) + + convertedMessages := make([]Message, 0, len(testCase.messages)) + + for _, mockMessage := range testCase.messages { + convertedMessages = append(convertedMessages, mockMessage) + } + + assert.Equal( + t, + testCase.shouldHaveMajority, + tm.hasSuperMajority(convertedMessages), + ) + }) + } +} + +func TestTendermint_QuorumFaultyMajority(t *testing.T) { + t.Parallel() + + var ( + // Equal voting power map + votingPowerMap = map[string]uint64{ + "1": 1, + "2": 1, + "3": 1, + "4": 1, + } + + mockMessages = []*mockMessage{ + { + getSenderFn: func() []byte { + return []byte("1") + }, + }, + { + getSenderFn: func() []byte { + return []byte("2") + }, + }, + { + getSenderFn: func() []byte { + return []byte("3") + }, + }, + { + getSenderFn: func() []byte { + return []byte("4") + }, + }, + } + + mockVerifier = &mockVerifier{ + getTotalVotingPowerFn: func(_ uint64) uint64 { + return uint64(len(votingPowerMap)) + }, + getSumVotingPowerFn: func(messages []Message) uint64 { + sum := uint64(0) + + for _, message := range messages { + sum += votingPowerMap[string(message.GetSender())] + } + + return sum + }, + } + ) + + testTable := []struct { + name string + messages []*mockMessage + shouldHaveMajority bool + }{ + { + "4/4 validators", + mockMessages, + true, + }, + { + "3/4 validators", + mockMessages[1:], + true, + }, + { + "2/4 validators", + mockMessages[2:], + true, + }, + { + "1/4 validators", + mockMessages[:1], + false, + }, + } + + for _, testCase := range testTable { + testCase := testCase + + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + tm := NewTendermint( + mockVerifier, + nil, + nil, + nil, + ) + + convertedMessages := make([]Message, 0, len(testCase.messages)) + + for _, mockMessage := range testCase.messages { + convertedMessages = append(convertedMessages, mockMessage) + } + + assert.Equal( + t, + testCase.shouldHaveMajority, + tm.hasFaultyMajority(convertedMessages), + ) + }) + } +} diff --git a/tm2/pkg/libtm/core/state.go b/tm2/pkg/libtm/core/state.go new file mode 100644 index 00000000000..6713a411e4d --- /dev/null +++ b/tm2/pkg/libtm/core/state.go @@ -0,0 +1,84 @@ +package core + +import ( + "sync/atomic" + + "github.com/gnolang/libtm/messages/types" +) + +// step is the current state step +type step uint32 + +const ( + propose step = iota + prevote + precommit +) + +// set updates the current step value [THREAD SAFE] +func (s *step) set(n step) { + atomic.SwapUint32((*uint32)(s), uint32(n)) +} + +// get fetches the current step value [THREAD SAFE] +func (s *step) get() step { + return step(atomic.LoadUint32((*uint32)(s))) +} + +// state holds information about the current consensus state +type state struct { + view *types.View + + acceptedProposal []byte + acceptedProposalID []byte + + lockedValue []byte + validValue []byte + + lockedRound int64 + validRound int64 + + step step +} + +// newState creates a fresh state using the given view +func newState() state { + return state{ + view: &types.View{ + Height: 0, // zero height + Round: 0, // zero round + }, + step: propose, + acceptedProposal: nil, + acceptedProposalID: nil, + lockedValue: nil, + lockedRound: -1, + validValue: nil, + validRound: -1, + } +} + +// getHeight fetches the current view height [THREAD SAFE] +func (s *state) getHeight() uint64 { + return atomic.LoadUint64(&s.view.Height) +} + +// getRound fetches the current view round [THREAD SAFE] +func (s *state) getRound() uint64 { + return atomic.LoadUint64(&s.view.Round) +} + +// increaseRound increases the current view round by 1 [THREAD SAFE] +func (s *state) increaseRound() { + atomic.AddUint64(&s.view.Round, 1) +} + +// setRound sets the current view round to the given value [THREAD SAFE] +func (s *state) setRound(r uint64) { + atomic.SwapUint64(&s.view.Round, r) +} + +// setHeight sets the current view height to the given value [THREAD SAFE] +func (s *state) setHeight(h uint64) { + atomic.SwapUint64(&s.view.Height, h) +} diff --git a/tm2/pkg/libtm/core/store.go b/tm2/pkg/libtm/core/store.go new file mode 100644 index 00000000000..395dd2d023e --- /dev/null +++ b/tm2/pkg/libtm/core/store.go @@ -0,0 +1,65 @@ +package core + +import ( + "github.com/gnolang/libtm/messages" + "github.com/gnolang/libtm/messages/types" +) + +// store is the message store +type store struct { + proposeMessages *messages.Collector[types.ProposalMessage] + prevoteMessages *messages.Collector[types.PrevoteMessage] + precommitMessages *messages.Collector[types.PrecommitMessage] +} + +// newStore creates a new message store +func newStore() store { + return store{ + proposeMessages: messages.NewCollector[types.ProposalMessage](), + prevoteMessages: messages.NewCollector[types.PrevoteMessage](), + precommitMessages: messages.NewCollector[types.PrecommitMessage](), + } +} + +// addProposalMessage adds a proposal message to the store +func (s *store) addProposalMessage(proposal *types.ProposalMessage) { + s.proposeMessages.AddMessage(proposal.View, proposal.Sender, proposal) +} + +// addPrevoteMessage adds a prevote message to the store +func (s *store) addPrevoteMessage(prevote *types.PrevoteMessage) { + s.prevoteMessages.AddMessage(prevote.View, prevote.Sender, prevote) +} + +// addPrecommitMessage adds a precommit message to the store +func (s *store) addPrecommitMessage(precommit *types.PrecommitMessage) { + s.precommitMessages.AddMessage(precommit.View, precommit.Sender, precommit) +} + +// subscribeToPropose subscribes to incoming PROPOSE messages +func (s *store) subscribeToPropose() (<-chan func() []*types.ProposalMessage, func()) { + return s.proposeMessages.Subscribe() +} + +// subscribeToPrevote subscribes to incoming PREVOTE messages +func (s *store) subscribeToPrevote() (<-chan func() []*types.PrevoteMessage, func()) { + return s.prevoteMessages.Subscribe() +} + +// subscribeToPrecommit subscribes to incoming PRECOMMIT messages +func (s *store) subscribeToPrecommit() (<-chan func() []*types.PrecommitMessage, func()) { + return s.precommitMessages.Subscribe() +} + +// dropMessages drops all messages from the store that are +// less than the given view (earlier) +func (s *store) dropMessages(view *types.View) { + // Clean up the propose messages + s.proposeMessages.DropMessages(view) + + // Clean up the prevote messages + s.prevoteMessages.DropMessages(view) + + // Clean up the precommit messages + s.precommitMessages.DropMessages(view) +} diff --git a/tm2/pkg/libtm/core/tendermint.go b/tm2/pkg/libtm/core/tendermint.go new file mode 100644 index 00000000000..46b610b6bee --- /dev/null +++ b/tm2/pkg/libtm/core/tendermint.go @@ -0,0 +1,907 @@ +package core + +import ( + "bytes" + "context" + "io" + "log/slog" + "sync" + + "github.com/gnolang/libtm/messages" + + "github.com/gnolang/libtm/messages/types" +) + +// Tendermint is the single consensus engine instance +type Tendermint struct { + // store is the message store + store store + + verifier Verifier + node Node + broadcast Broadcast + signer Signer + + // logger is the consensus engine logger + logger *slog.Logger + + // timeouts hold state timeout information (constant) + timeouts map[step]Timeout + + // state is the current Tendermint consensus state + state state + + // wg is the barrier for keeping all + // parallel consensus processes synced + wg sync.WaitGroup +} + +// FinalizedProposal is the finalized proposal wrapper, that +// contains the raw proposal data, and the ID of the data (usually hash) +type FinalizedProposal struct { + Data []byte // the raw proposal data, accepted proposal + ID []byte // the ID of the proposal (usually hash) +} + +// newFinalizedProposal creates a new finalized proposal wrapper +func newFinalizedProposal(data, id []byte) *FinalizedProposal { + return &FinalizedProposal{ + Data: data, + ID: id, + } +} + +// NewTendermint creates a new instance of the Tendermint consensus engine +func NewTendermint( + verifier Verifier, + node Node, + broadcast Broadcast, + signer Signer, + opts ...Option, +) *Tendermint { + t := &Tendermint{ + state: newState(), + store: newStore(), + verifier: verifier, + node: node, + broadcast: broadcast, + signer: signer, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + timeouts: getDefaultTimeoutMap(), + } + + // Apply any options + for _, opt := range opts { + opt(t) + } + + return t +} + +// RunSequence runs the Tendermint consensus sequence for a given height, +// returning only when a proposal has been finalized (consensus reached), or +// the context has been cancelled +func (t *Tendermint) RunSequence(ctx context.Context, h uint64) *FinalizedProposal { + t.logger.Debug( + "RunSequence", + slog.Uint64("height", h), + slog.String("node", string(t.node.ID())), + ) + + // Initialize the state before starting the sequence + t.state.setHeight(h) + + // Grab the process view + view := &types.View{ + Height: h, + Round: t.state.getRound(), + } + + // Drop all old messages + t.store.dropMessages(view) + + for { + // set up the round context + ctxRound, cancelRound := context.WithCancel(ctx) + teardown := func() { + cancelRound() + t.wg.Wait() + } + + select { + case proposal := <-t.finalizeProposal(ctxRound): + teardown() + + // Check if the proposal has been finalized + if proposal != nil { + t.logger.Info( + "RunSequence: proposal finalized", + slog.Uint64("height", h), + slog.String("node", string(t.node.ID())), + ) + + return proposal + } + + t.logger.Info( + "RunSequence round expired", + slog.Uint64("height", h), + slog.Uint64("round", t.state.getRound()), + slog.String("node", string(t.node.ID())), + ) + + // 65: Function OnTimeoutPrecommit(height, round) : + // 66: if height = hP ∧ round = roundP then + // 67: StartRound(roundP + 1) + t.state.increaseRound() + t.state.step.set(propose) + case recvRound := <-t.watchForRoundJumps(ctxRound): + teardown() + + t.logger.Info( + "RunSequence: round jump", + slog.Uint64("height", h), + slog.Uint64("from", t.state.getRound()), + slog.Uint64("to", recvRound), + slog.String("node", string(t.node.ID())), + ) + + t.state.setRound(recvRound) + t.state.step.set(propose) + case <-ctx.Done(): + teardown() + + t.logger.Info( + "RunSequence: context done", + slog.Uint64("height", h), + slog.Uint64("round", t.state.getRound()), + slog.String("node", string(t.node.ID())), + ) + + return nil + } + } +} + +// watchForRoundJumps monitors for F+1 (any) messages from a future round, and +// triggers the round switch context (channel) accordingly +func (t *Tendermint) watchForRoundJumps(ctx context.Context) <-chan uint64 { + var ( + height = t.state.getHeight() + round = t.state.getRound() + + ch = make(chan uint64, 1) + ) + + // Signals the round jump to the given channel + signalRoundJump := func(round uint64) { + select { + case <-ctx.Done(): + case ch <- round: + } + } + + t.wg.Add(1) + + go func() { + defer t.wg.Done() + + var ( + proposeCh, unsubscribeProposeFn = t.store.subscribeToPropose() + prevoteCh, unsubscribePrevoteFn = t.store.subscribeToPrevote() + precommitCh, unsubscribePrecommitFn = t.store.subscribeToPrecommit() + ) + + defer func() { + unsubscribeProposeFn() + unsubscribePrevoteFn() + unsubscribePrecommitFn() + }() + + var ( + isValidProposeFn = func(m *types.ProposalMessage) bool { + view := m.GetView() + + return view.GetRound() > round && view.GetHeight() == height + } + isValidPrevoteFn = func(m *types.PrevoteMessage) bool { + view := m.GetView() + + return view.GetRound() > round && view.GetHeight() == height + } + isValidPrecommitFn = func(m *types.PrecommitMessage) bool { + view := m.GetView() + + return view.GetRound() > round && view.GetHeight() == height + } + ) + + var ( + proposeCache = newMessageCache[*types.ProposalMessage](isValidProposeFn) + prevoteCache = newMessageCache[*types.PrevoteMessage](isValidPrevoteFn) + precommitCache = newMessageCache[*types.PrecommitMessage](isValidPrecommitFn) + ) + + generateRoundMap := func(messages ...[]Message) map[uint64][]Message { + combined := make([]Message, 0) + for _, message := range messages { + combined = append(combined, message...) + } + + // Group messages by round + roundMap := make(map[uint64][]Message) + + for _, message := range combined { + messageRound := message.GetView().GetRound() + roundMap[messageRound] = append(roundMap[messageRound], message) + } + + return roundMap + } + + for { + select { + case <-ctx.Done(): + close(ch) + + return + case getProposeFn := <-proposeCh: + proposeCache.addMessages(getProposeFn()) + case getPrevoteFn := <-prevoteCh: + prevoteCache.addMessages(getPrevoteFn()) + case getPrecommitFn := <-precommitCh: + precommitCache.addMessages(getPrecommitFn()) + } + + var ( + proposeMessages = proposeCache.getMessages() + prevoteMessages = prevoteCache.getMessages() + precommitMessages = precommitCache.getMessages() + ) + + var ( + convertedPropose = make([]Message, 0, len(proposeMessages)) + convertedPrevote = make([]Message, 0, len(prevoteMessages)) + convertedPrecommit = make([]Message, 0, len(precommitMessages)) + ) + + messages.ConvertToInterface(proposeMessages, func(m *types.ProposalMessage) { + convertedPropose = append(convertedPropose, m) + }) + + messages.ConvertToInterface(prevoteMessages, func(m *types.PrevoteMessage) { + convertedPrevote = append(convertedPrevote, m) + }) + + messages.ConvertToInterface(precommitMessages, func(m *types.PrecommitMessage) { + convertedPrecommit = append(convertedPrecommit, m) + }) + + // Generate the round map + roundMap := generateRoundMap( + convertedPropose, + convertedPrevote, + convertedPrecommit, + ) + + // Find the highest round that satisfies an F+1 voting power majority. + // This max round will always need to be > 0 + maxRound := uint64(0) + + for messageRound, roundMessages := range roundMap { + if !t.hasFaultyMajority(roundMessages) { + continue + } + + if messageRound > maxRound { + maxRound = messageRound + } + } + + // Make sure the max round that has a faulty majority + // is actually greater than the process round + if maxRound > round { + signalRoundJump(maxRound) + + return + } + } + }() + + return ch +} + +// finalizeProposal starts the proposal finalization sequence +func (t *Tendermint) finalizeProposal(ctx context.Context) <-chan *FinalizedProposal { + ch := make(chan *FinalizedProposal, 1) + + t.wg.Add(1) + + go func() { + defer func() { + close(ch) + t.wg.Done() + }() + + // Run the consensus state machine, and save the finalized proposal (if any) + if finalizedProposal := t.runStates(ctx); finalizedProposal != nil { + ch <- finalizedProposal + } + }() + + return ch +} + +// startRound starts the consensus round. +// It is a required middle step (proposal evaluation) before +// the state machine is in full swing and +// the runs the same flow for everyone (proposer / non-proposers) +func (t *Tendermint) startRound(height, round uint64) { + // 14: if proposer(hp, roundP) = p then + // + // The proposal value can either be: + // - an old (valid / locked) proposal from a previous round + // - a completely new proposal (built from scratch) + // + // 15: if validValueP != nil then + // 16: proposal ← validValueP + var ( + proposal = t.state.validValue + proposalRound = t.state.validRound + ) + + // Check if a new proposal needs to be built + if proposal == nil { + t.logger.Info( + "building a proposal", + slog.Uint64("height", height), + slog.Uint64("round", round), + slog.String("node", string(t.node.ID())), + ) + + // No previous valid value present, + // build a new proposal + // + // 17: else + // 18: proposal ← getValue() + proposal = t.node.BuildProposal(height) + } + + // Build the propose message + var ( + proposeMessage = t.buildProposalMessage(proposal, proposalRound) + id = t.node.Hash(proposal) + ) + + // Broadcast the proposal to other consensus nodes + // + // 19: broadcast + t.broadcast.BroadcastPropose(proposeMessage) + + // Save the accepted proposal in the state. + // NOTE: This is different from validValue / lockedValue, + // since they require a 2F+1 quorum of specific messages + // in order to be set, whereas this is simply a reference + // value for different states (prevote, precommit) + t.state.acceptedProposal = proposal + t.state.acceptedProposalID = id + + // Build and broadcast the prevote message + // + // 24/30: broadcast + t.broadcast.BroadcastPrevote(t.buildPrevoteMessage(id)) + + // Since the current process is the proposer, + // it can directly move to the prevote state + // 27/33: stepP ← prevote + t.state.step.set(prevote) +} + +// runStates runs the consensus states, depending on the current step +func (t *Tendermint) runStates(ctx context.Context) *FinalizedProposal { + for { + currentStep := t.state.step.get() + + select { + case <-ctx.Done(): + return nil + default: + switch currentStep { + case propose: + t.runPropose(ctx) + case prevote: + t.runPrevote(ctx) + case precommit: + return t.runPrecommit(ctx) + } + } + } +} + +// runPropose runs the propose state in which the process +// waits for a valid PROPOSE message. +// This state handles the following situations: +// +// - The proposer for view (hP, roundP) has proposed a value with a proposal round -1 (first ever proposal for height) +// 22: upon from proposer(hP, roundP) while stepP = propose do +// 23: if valid(v) ∧ (lockedRoundP = −1 ∨ lockedValueP = v) then +// 24: broadcast +// 25: else +// 26: broadcast +// 27: stepP ← prevote +// +// - The proposer for view (hP, roundP) has proposed a value that was accepted in some previous round +// 28: upon from proposer(hP, roundP) AND 2f + 1 +// while stepP = propose ∧ (vr >= 0 ∧ vr < roundP) do +// 29: if valid(v) ∧ (lockedRoundP ≤ vr ∨ lockedValueP = v) then +// 30: broadcast +// 31: else +// 32: broadcast +// 33: stepP ← prevote +// +// NOTE: the proposer for view (height, round) will send ONLY 1 proposal, be it a new one or an old agreed value +func (t *Tendermint) runPropose(ctx context.Context) { + var ( + height = t.state.getHeight() + round = t.state.getRound() + + lockedRound = t.state.lockedRound + lockedValue = t.state.lockedValue + ) + + t.logger.Debug( + "entering propose state", + slog.Uint64("height", height), + slog.Uint64("round", round), + slog.String("node", string(t.node.ID())), + ) + + // Check if the current process is the proposer for this view + if t.verifier.IsProposer(t.node.ID(), height, round) { + // Start the round by constructing and broadcasting a proposal + t.startRound(height, round) + + return + } + + // The current process is NOT the proposer, schedule a timeout + // + // 21: schedule OnTimeoutPropose(hP , roundP) to be executed after timeoutPropose(roundP) + var ( + expiredCh = make(chan struct{}, 1) + timerCtx, cancelTimeoutFn = context.WithCancel(ctx) + timeoutPropose = t.timeouts[propose].CalculateTimeout(round) + ) + + // Defer the timeout timer cancellation + defer cancelTimeoutFn() + + t.logger.Debug( + "scheduling timeoutPropose", + slog.Uint64("height", height), + slog.Uint64("round", round), + slog.Duration("timeout", timeoutPropose), + slog.String("node", string(t.node.ID())), + ) + + t.scheduleTimeout(timerCtx, timeoutPropose, expiredCh) + + // Subscribe to all propose messages + // (=current height; unique; >= current round) + ch, unsubscribeFn := t.store.subscribeToPropose() + defer unsubscribeFn() + + // Set up the verification callback. + // The idea is to get the single proposal from the proposer for the view (height, round), + // and verify if it is valid. + // If it turns out the proposal is not valid (the first one received), + // then the protocol needs to move to the prevote state, after + // broadcasting a PREVOTE message with a NIL ID + isFromProposerFn := func(proposal *types.ProposalMessage) bool { + // Make sure the proposal view matches the process view + if round != proposal.GetView().GetRound() { + return false + } + + // Check if the proposal came from the proposer + // for the current view + return t.verifier.IsProposer(proposal.GetSender(), height, round) + } + + // Validates the proposal by examining the proposal params + isValidProposal := func(proposal []byte, proposalRound int64) bool { + // Basic proposal message verification + if proposalRound < 0 { + // Make sure there is no locked round (-1), OR + // that the locked value matches the proposal value + if lockedRound != -1 && !bytes.Equal(lockedValue, proposal) { + return false + } + } else { + // Make sure the proposal round is an earlier round + // than the current process round (old proposal) + if proposalRound >= int64(round) { + return false + } + + // Make sure the locked round value is <= the proposal round, OR + // that the locked value matches the proposal value + if lockedRound > proposalRound && !bytes.Equal(lockedValue, proposal) { + return false + } + } + + // Make sure the proposal itself is valid + return t.verifier.IsValidProposal(proposal, height) + } + + // Create the message cache (local to this context only) + cache := newMessageCache[*types.ProposalMessage](isFromProposerFn) + + for { + select { + case <-ctx.Done(): + return + case <-expiredCh: + // Broadcast a PREVOTE message with a NIL ID + // 59: broadcast ⟨PREVOTE, hP, roundP, nil⟩ + t.broadcast.BroadcastPrevote(t.buildPrevoteMessage(nil)) + + // Move to the prevote state + // 60: stepP ← prevote + t.state.step.set(prevote) + + return + case getMessagesFn := <-ch: + // Add the messages to the cache + cache.addMessages(getMessagesFn()) + + // Check if at least 1 proposal message is valid, + // after validation and filtering + proposalMessages := cache.getMessages() + + if len(proposalMessages) == 0 { + // No valid proposal message yet + continue + } + + proposalMessage := proposalMessages[0] + + // Validate the proposal received + if !isValidProposal(proposalMessage.Proposal, proposalMessage.ProposalRound) { + // Broadcast a PREVOTE message with a NIL ID + // 26: broadcast ⟨PREVOTE, hP, roundP, nil⟩ + // 32: broadcast ⟨PREVOTE, hP, roundP, nil⟩ + t.broadcast.BroadcastPrevote(t.buildPrevoteMessage(nil)) + + // Move to the prevote state + // 27: stepP ← prevote + // 33: stepP ← prevote + t.state.step.set(prevote) + + t.logger.Debug( + "received invalid proposal", + slog.Uint64("height", height), + slog.Uint64("round", round), + slog.String("node", string(t.node.ID())), + ) + + return + } + + // Get the proposal from the message + proposal := proposalMessage.GetProposal() + + // Generate the proposal ID + id := t.node.Hash(proposal) + + // Accept the proposal, since it is valid + t.state.acceptedProposal = proposal + t.state.acceptedProposalID = id + + // Broadcast the PREVOTE message with a valid ID + // 24: broadcast ⟨PREVOTE, hP, roundP, id(v)⟩ + // 30: broadcast ⟨PREVOTE, hP, roundP, id(v)⟩ + t.broadcast.BroadcastPrevote(t.buildPrevoteMessage(id)) + + // Move to the prevote state + // 27: stepP ← prevote + // 33: stepP ← prevote + t.state.step.set(prevote) + + return + } + } +} + +// runPrevote runs the prevote state in which the process +// waits for a valid PREVOTE messages. +// This state handles the following situations: +// +// - A validator has received 2F+1 PREVOTE messages with a valid ID for the previously accepted proposal +// 36: upon ... AND 2f + 1 while valid(v) ∧ stepP ≥ prevote for the first time do +// 37: if stepP = prevote then +// 38: lockedValueP ← v +// 39: lockedRoundP ← roundP +// 40: broadcast +// 41: stepP ← precommit +// 42: validValueP ← v +// 43: validRoundP ← roundP +// +// - A validator has received 2F+1 PREVOTE messages with a NIL ID +// 44: upon 2f + 1 ⟨PREVOTE, hp, roundP, nil⟩ while stepP = prevote do +// 45: broadcast ⟨PRECOMMIT, hp, roundP, nil⟩ +// 46: stepP ← precommit + +// - A validator has received 2F+1 PREVOTE messages with any kind of ID (valid / NIL) +// 34: upon 2f + 1 while stepP = prevote for the first time do +// 35: schedule OnTimeoutPrevote(hP , roundP) to be executed after timeoutPrevote(roundP) +func (t *Tendermint) runPrevote(ctx context.Context) { + var ( + height = t.state.getHeight() + round = t.state.getRound() + acceptedProposalID = t.state.acceptedProposalID + + expiredCh = make(chan struct{}, 1) + timeoutCtx, cancelTimeoutFn = context.WithCancel(ctx) + timeoutPrevote = t.timeouts[prevote].CalculateTimeout(round) + ) + + t.logger.Debug( + "entering prevote state", + slog.Uint64("height", height), + slog.Uint64("round", round), + slog.String("node", string(t.node.ID())), + ) + + // Defer the timeout timer cancellation + defer cancelTimeoutFn() + + // Subscribe to all prevote messages + // (=current height; unique; >= current round) + ch, unsubscribeFn := t.store.subscribeToPrevote() + defer unsubscribeFn() + + var ( + isValidFn = func(prevote *types.PrevoteMessage) bool { + // Make sure the prevote view matches the process view + return round == prevote.GetView().GetRound() + } + nilMiddleware = func(prevote *types.PrevoteMessage) bool { + // Make sure the ID is NIL + return prevote.Identifier == nil + } + matchingIDMiddleware = func(prevote *types.PrevoteMessage) bool { + // Make sure the ID matches the accepted proposal ID + return bytes.Equal(acceptedProposalID, prevote.Identifier) + } + ) + + var ( + summedPrevotes = newMessageCache[*types.PrevoteMessage](isValidFn) + nilCache = newMessageCache[*types.PrevoteMessage](nilMiddleware) + nonNilCache = newMessageCache[*types.PrevoteMessage](matchingIDMiddleware) + + timeoutScheduled = false + ) + + for { + select { + case <-ctx.Done(): + return + case <-expiredCh: + // Build and broadcast the prevote message, with an ID of NIL + t.broadcast.BroadcastPrecommit(t.buildPrecommitMessage(nil)) + + t.state.step.set(precommit) + + return + case getMessagesFn := <-ch: + // Combine the prevote messages (NIL and non-NIL) + summedPrevotes.addMessages(getMessagesFn()) + prevotes := summedPrevotes.getMessages() + + convertedMessages := make([]Message, 0, len(prevotes)) + messages.ConvertToInterface( + prevotes, + func(m *types.PrevoteMessage) { + convertedMessages = append(convertedMessages, m) + }, + ) + + // Check if there is a super majority for the sum prevotes, to schedule a timeout + if !timeoutScheduled && t.hasSuperMajority(convertedMessages) { + // 35: schedule OnTimeoutPrevote(hp, roundP) to be executed after timeoutPrevote(roundP) + t.logger.Debug( + "scheduling timeoutPrevote", + slog.Uint64("round", round), + slog.Duration("timeout", timeoutPrevote), + slog.String("node", string(t.node.ID())), + ) + + t.scheduleTimeout(timeoutCtx, timeoutPrevote, expiredCh) + + timeoutScheduled = true + } + + // Filter the NIL prevote messages + nilCache.addMessages(prevotes) + nilPrevotes := nilCache.getMessages() + + convertedMessages = make([]Message, 0, len(nilPrevotes)) + messages.ConvertToInterface( + nilPrevotes, + func(m *types.PrevoteMessage) { + convertedMessages = append(convertedMessages, m) + }, + ) + + // Check if there are 2F+1 NIL prevote messages + if t.hasSuperMajority(convertedMessages) { + // 45: broadcast ⟨PRECOMMIT, hp, roundP, nil⟩ + // 46: stepP ← precommit + t.broadcast.BroadcastPrecommit(t.buildPrecommitMessage(nil)) + t.state.step.set(precommit) + + return + } + + // Filter the non-NIL prevote messages + nonNilCache.addMessages(prevotes) + nonNilPrevotes := nonNilCache.getMessages() + + convertedMessages = make([]Message, 0, len(nonNilPrevotes)) + messages.ConvertToInterface( + nonNilPrevotes, + func(m *types.PrevoteMessage) { + convertedMessages = append(convertedMessages, m) + }, + ) + + // Check if there are 2F+1 non-NIL prevote messages + if t.hasSuperMajority(convertedMessages) { + // 38: lockedValueP ← v + // 39: lockedRoundP ← roundP + t.state.lockedRound = int64(round) + t.state.lockedValue = t.state.acceptedProposal + + // 40: broadcast + t.broadcast.BroadcastPrecommit(t.buildPrecommitMessage(acceptedProposalID)) + + // 41: stepP ← precommit + t.state.step.set(precommit) + + // 42: validValueP ← v + // 43: validRoundP ← roundP + t.state.validValue = t.state.acceptedProposal + t.state.validRound = int64(round) + + return + } + } + } +} + +// runPrecommit runs the precommit state in which the process +// waits for a valid PRECOMMIT messages. +// This state handles the following situations: +// +// - A validator has received 2F+1 PRECOMMIT messages with a valid ID for the previously accepted proposal +// 49: upon from proposer(hP, r) AND 2f + 1 +// while decisionP[hP] = nil do +// 50: if valid(v) then +// 51: decisionP[hp] = v +// 52: hP ← hP + 1 +// 53: reset lockedRoundP, lockedValueP, validRoundP and validValueP to initial values and empty message log +// 54: StartRound(0) +// +// - A validator has received 2F+1 PRECOMMIT messages with any value (valid ID or NIL) +// 47: upon 2f + 1 for the first time do +// 48: schedule OnTimeoutPrecommit(hP , roundP) to be executed after timeoutPrecommit(roundP) +func (t *Tendermint) runPrecommit(ctx context.Context) *FinalizedProposal { + var ( + height = t.state.getHeight() + round = t.state.getRound() + acceptedProposalID = t.state.acceptedProposalID + + expiredCh = make(chan struct{}, 1) + timeoutCtx, cancelTimeoutFn = context.WithCancel(ctx) + timeoutPrecommit = t.timeouts[precommit].CalculateTimeout(round) + ) + + t.logger.Debug( + "entering precommit state", + slog.Uint64("height", height), + slog.Uint64("round", round), + slog.String("node", string(t.node.ID())), + ) + + // Defer the timeout timer cancellation + defer cancelTimeoutFn() + + // Subscribe to all precommit messages + // (=current height; unique; >= current round) + ch, unsubscribeFn := t.store.subscribeToPrecommit() + defer unsubscribeFn() + + var ( + isValidFn = func(precommit *types.PrecommitMessage) bool { + // Make sure the precommit view matches the process view + return round == precommit.GetView().GetRound() + } + nonNilIDFn = func(precommit *types.PrecommitMessage) bool { + // Make sure the precommit ID is not nil + if precommit.Identifier == nil { + return false + } + + // Make sure the ID matches the accepted proposal ID + return bytes.Equal(acceptedProposalID, precommit.Identifier) + } + ) + + var ( + summedPrecommits = newMessageCache[*types.PrecommitMessage](isValidFn) + nonNilCache = newMessageCache[*types.PrecommitMessage](nonNilIDFn) + + timeoutScheduled = false + ) + + for { + select { + case <-ctx.Done(): + // Context cancelled, no proposal is finalized + return nil + case <-expiredCh: + // Timeout triggered, no proposal is finalized + return nil + case getMessagesFn := <-ch: + // Combine the precommit messages (NIL and non-NIL) + summedPrecommits.addMessages(getMessagesFn()) + precommits := summedPrecommits.getMessages() + + convertedMessages := make([]Message, 0, len(precommits)) + messages.ConvertToInterface( + precommits, + func(m *types.PrecommitMessage) { + convertedMessages = append(convertedMessages, m) + }, + ) + + // Check if there is a super majority for the sum precommits, to schedule a timeout + if !timeoutScheduled && t.hasSuperMajority(convertedMessages) { + // 48: schedule OnTimeoutPrecommit(hP, roundP) to be executed after timeoutPrecommit(roundP) + t.logger.Debug( + "scheduling timeoutPrecommit", + slog.Uint64("round", round), + slog.Duration("timeout", timeoutPrecommit), + slog.String("node", string(t.node.ID())), + ) + + t.scheduleTimeout(timeoutCtx, timeoutPrecommit, expiredCh) + + timeoutScheduled = true + } + + // Filter the non-NIL precommit messages + nonNilCache.addMessages(precommits) + nonNilPrecommits := nonNilCache.getMessages() + + convertedMessages = make([]Message, 0, len(nonNilPrecommits)) + messages.ConvertToInterface( + nonNilPrecommits, + func(m *types.PrecommitMessage) { + convertedMessages = append(convertedMessages, m) + }, + ) + + // Check if there are 2F+1 non-NIL precommit messages + if t.hasSuperMajority(convertedMessages) { + return newFinalizedProposal( + t.state.acceptedProposal, + t.state.acceptedProposalID, + ) + } + } + } +} diff --git a/tm2/pkg/libtm/core/tendermint_test.go b/tm2/pkg/libtm/core/tendermint_test.go new file mode 100644 index 00000000000..db80ba04956 --- /dev/null +++ b/tm2/pkg/libtm/core/tendermint_test.go @@ -0,0 +1,2258 @@ +package core + +import ( + "bytes" + "context" + "fmt" + "testing" + "time" + + "github.com/gnolang/libtm/messages/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTendermint_AddMessage_Invalid(t *testing.T) { + t.Parallel() + + t.Run("invalid signature", func(t *testing.T) { + t.Parallel() + + var ( + signature = []byte("invalid signature") + message = &types.PrevoteMessage{ + View: &types.View{}, + Sender: []byte{}, + Signature: signature, + } + + signer = &mockSigner{ + isValidSignatureFn: func(_ []byte, sig []byte) bool { + require.Equal(t, signature, sig) + + return false + }, + } + verifier = &mockVerifier{ + isValidatorFn: func(_ []byte) bool { + return true + }, + } + ) + + tm := NewTendermint( + verifier, + nil, + nil, + signer, + ) + + assert.ErrorIs( + t, + tm.AddPrevoteMessage(message), + ErrInvalidMessageSignature, + ) + }) + + t.Run("sender is not a validator", func(t *testing.T) { + t.Parallel() + + var ( + signature = []byte("valid signature") + sender = []byte("sender") + + message = &types.PrevoteMessage{ + View: &types.View{}, + Sender: sender, + Signature: signature, + } + + signer = &mockSigner{ + isValidSignatureFn: func(_ []byte, sig []byte) bool { + require.Equal(t, signature, sig) + + return true + }, + } + verifier = &mockVerifier{ + isValidatorFn: func(from []byte) bool { + require.Equal(t, sender, from) + + return false + }, + } + ) + + tm := NewTendermint( + verifier, + nil, + nil, + signer, + ) + + assert.ErrorIs( + t, + tm.AddPrevoteMessage(message), + ErrMessageFromNonValidator, + ) + }) + + t.Run("message is for an earlier height", func(t *testing.T) { + t.Parallel() + + var ( + currentView = &types.View{ + Height: 10, + Round: 0, + } + signature = []byte("valid signature") + sender = []byte("sender") + + message = &types.PrevoteMessage{ + View: &types.View{ + Height: currentView.Height - 1, // earlier height + Round: currentView.Round, + }, + Sender: sender, + Signature: signature, + } + + signer = &mockSigner{ + isValidSignatureFn: func(_ []byte, sig []byte) bool { + require.Equal(t, signature, sig) + + return true + }, + } + verifier = &mockVerifier{ + isValidatorFn: func(from []byte) bool { + require.Equal(t, sender, from) + + return true + }, + } + ) + + tm := NewTendermint( + verifier, + nil, + nil, + signer, + ) + tm.state.setHeight(currentView.Height) + tm.state.setRound(currentView.Round) + + assert.ErrorIs( + t, + tm.AddPrevoteMessage(message), + ErrEarlierHeightMessage, + ) + }) + + t.Run("message is for an earlier round", func(t *testing.T) { + t.Parallel() + + var ( + currentView = &types.View{ + Height: 1, + Round: 10, + } + signature = []byte("valid signature") + sender = []byte("sender") + + message = &types.PrevoteMessage{ + View: &types.View{ + Height: currentView.Height, + Round: currentView.Round - 1, // earlier round + }, + Sender: sender, + Signature: signature, + } + + signer = &mockSigner{ + isValidSignatureFn: func(_ []byte, sig []byte) bool { + require.Equal(t, signature, sig) + + return true + }, + } + verifier = &mockVerifier{ + isValidatorFn: func(from []byte) bool { + require.Equal(t, sender, from) + + return true + }, + } + ) + + tm := NewTendermint( + verifier, + nil, + nil, + signer, + ) + tm.state.setHeight(currentView.Height) + tm.state.setRound(currentView.Round) + + assert.ErrorIs( + t, + tm.AddPrevoteMessage(message), + ErrEarlierRoundMessage, + ) + }) + + t.Run("invalid proposal message payload", func(t *testing.T) { + t.Parallel() + + var ( + currentView = &types.View{ + Height: 1, + Round: 10, + } + signature = []byte("valid signature") + sender = []byte("sender") + + message = &types.ProposalMessage{ + View: nil, // invalid view + Sender: sender, + Signature: signature, + } + + signer = &mockSigner{ + isValidSignatureFn: func(_ []byte, sig []byte) bool { + require.Equal(t, signature, sig) + + return true + }, + } + verifier = &mockVerifier{ + isValidatorFn: func(from []byte) bool { + require.Equal(t, sender, from) + + return true + }, + } + ) + + tm := NewTendermint( + verifier, + nil, + nil, + signer, + ) + tm.state.setHeight(currentView.Height) + tm.state.setRound(currentView.Round) + + assert.ErrorIs( + t, + tm.AddProposalMessage(message), + types.ErrInvalidMessageView, + ) + }) + + t.Run("invalid prevote message payload", func(t *testing.T) { + t.Parallel() + + var ( + currentView = &types.View{ + Height: 1, + Round: 10, + } + signature = []byte("valid signature") + sender = []byte("sender") + + message = &types.PrevoteMessage{ + View: nil, // invalid view + Sender: sender, + Signature: signature, + } + + signer = &mockSigner{ + isValidSignatureFn: func(_ []byte, sig []byte) bool { + require.Equal(t, signature, sig) + + return true + }, + } + verifier = &mockVerifier{ + isValidatorFn: func(from []byte) bool { + require.Equal(t, sender, from) + + return true + }, + } + ) + + tm := NewTendermint( + verifier, + nil, + nil, + signer, + ) + tm.state.setHeight(currentView.Height) + tm.state.setRound(currentView.Round) + + assert.ErrorIs( + t, + tm.AddPrevoteMessage(message), + types.ErrInvalidMessageView, + ) + }) + + t.Run("invalid precommit message payload", func(t *testing.T) { + t.Parallel() + + var ( + currentView = &types.View{ + Height: 1, + Round: 10, + } + signature = []byte("valid signature") + sender = []byte("sender") + + message = &types.PrecommitMessage{ + View: nil, // invalid view + Sender: sender, + Signature: signature, + } + + signer = &mockSigner{ + isValidSignatureFn: func(_ []byte, sig []byte) bool { + require.Equal(t, signature, sig) + + return true + }, + } + verifier = &mockVerifier{ + isValidatorFn: func(from []byte) bool { + require.Equal(t, sender, from) + + return true + }, + } + ) + + tm := NewTendermint( + verifier, + nil, + nil, + signer, + ) + tm.state.setHeight(currentView.Height) + tm.state.setRound(currentView.Round) + + assert.ErrorIs( + t, + tm.AddPrecommitMessage(message), + types.ErrInvalidMessageView, + ) + }) +} + +func TestTendermint_AddMessage_Valid(t *testing.T) { + t.Parallel() + + t.Run("valid proposal message", func(t *testing.T) { + t.Parallel() + + var ( + currentView = &types.View{ + Height: 1, + Round: 10, + } + + signature = []byte("valid signature") + sender = []byte("sender") + proposal = []byte("proposal") + + message = &types.ProposalMessage{ + View: currentView, + Sender: sender, + Proposal: proposal, + ProposalRound: -1, + Signature: signature, + } + + signer = &mockSigner{ + isValidSignatureFn: func(_ []byte, sig []byte) bool { + require.Equal(t, signature, sig) + + return true + }, + } + verifier = &mockVerifier{ + isValidatorFn: func(from []byte) bool { + require.Equal(t, sender, from) + + return true + }, + } + ) + + tm := NewTendermint( + verifier, + nil, + nil, + signer, + ) + tm.state.setHeight(currentView.Height) + tm.state.setRound(currentView.Round) + + sub, unsubFn := tm.store.subscribeToPropose() + defer unsubFn() + + // Make sure the message is added + require.NoError(t, tm.AddProposalMessage(message)) + + // Make sure the message is present in the store + var messages []*types.ProposalMessage + select { + case getMessages := <-sub: + messages = getMessages() + case <-time.After(5 * time.Second): + } + + require.Len(t, messages, 1) + + storeMessage := messages[0] + + assert.Equal( + t, + message.GetProposal(), + storeMessage.GetProposal(), + ) + assert.Equal( + t, + message.GetProposalRound(), + storeMessage.GetProposalRound(), + ) + assert.Equal( + t, + message.GetView(), + storeMessage.GetView(), + ) + assert.Equal( + t, + message.GetSender(), + storeMessage.GetSender(), + ) + }) + + t.Run("valid prevote message", func(t *testing.T) { + t.Parallel() + + var ( + currentView = &types.View{ + Height: 1, + Round: 10, + } + + signature = []byte("valid signature") + sender = []byte("sender") + id = []byte("prevote ID") + + message = &types.PrevoteMessage{ + View: currentView, + Sender: sender, + Identifier: id, + Signature: signature, + } + + signer = &mockSigner{ + isValidSignatureFn: func(_ []byte, sig []byte) bool { + require.Equal(t, signature, sig) + + return true + }, + } + verifier = &mockVerifier{ + isValidatorFn: func(from []byte) bool { + require.Equal(t, sender, from) + + return true + }, + } + ) + + tm := NewTendermint( + verifier, + nil, + nil, + signer, + ) + tm.state.setHeight(currentView.Height) + tm.state.setRound(currentView.Round) + + sub, unsubFn := tm.store.subscribeToPrevote() + defer unsubFn() + + // Make sure the message is added + require.NoError(t, tm.AddPrevoteMessage(message)) + + // Make sure the message is present in the store + var messages []*types.PrevoteMessage + select { + case getMessages := <-sub: + messages = getMessages() + case <-time.After(5 * time.Second): + } + + require.Len(t, messages, 1) + + storeMessage := messages[0] + + assert.Equal( + t, + message.GetIdentifier(), + storeMessage.GetIdentifier(), + ) + assert.Equal( + t, + message.GetView(), + storeMessage.GetView(), + ) + assert.Equal( + t, + message.GetSender(), + storeMessage.GetSender(), + ) + }) + + t.Run("valid precommit message", func(t *testing.T) { + t.Parallel() + + var ( + currentView = &types.View{ + Height: 1, + Round: 10, + } + + signature = []byte("valid signature") + sender = []byte("sender") + id = []byte("precommit ID") + + message = &types.PrecommitMessage{ + View: currentView, + Sender: sender, + Identifier: id, + Signature: signature, + } + + signer = &mockSigner{ + isValidSignatureFn: func(_ []byte, sig []byte) bool { + require.Equal(t, signature, sig) + + return true + }, + } + verifier = &mockVerifier{ + isValidatorFn: func(from []byte) bool { + require.Equal(t, sender, from) + + return true + }, + } + ) + + tm := NewTendermint( + verifier, + nil, + nil, + signer, + ) + tm.state.setHeight(currentView.Height) + tm.state.setRound(currentView.Round) + + sub, unsubFn := tm.store.subscribeToPrecommit() + defer unsubFn() + + // Make sure the message is added + require.NoError(t, tm.AddPrecommitMessage(message)) + + // Make sure the message is present in the store + var messages []*types.PrecommitMessage + select { + case getMessages := <-sub: + messages = getMessages() + case <-time.After(5 * time.Second): + } + + require.Len(t, messages, 1) + + storeMessage := messages[0] + + assert.Equal( + t, + message.GetIdentifier(), + storeMessage.GetIdentifier(), + ) + assert.Equal( + t, + message.GetView(), + storeMessage.GetView(), + ) + assert.Equal( + t, + message.GetSender(), + storeMessage.GetSender(), + ) + }) +} + +func TestTendermint_FinalizeProposal_Propose(t *testing.T) { + t.Parallel() + + t.Run("validator is the proposer", func(t *testing.T) { + t.Parallel() + + t.Run("validator builds new proposal", func(t *testing.T) { + t.Parallel() + + // Create the execution context + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + + var ( + id = []byte("node ID") + hash = []byte("hash") + signature = []byte("signature") + view = &types.View{ + Height: 10, + Round: 0, + } + proposal = []byte("proposal") + + broadcastPropose *types.ProposalMessage + broadcastPrevote *types.PrevoteMessage + + mockVerifier = &mockVerifier{ + isProposerFn: func(nodeID []byte, h uint64, r uint64) bool { + require.Equal(t, id, nodeID) + require.EqualValues(t, view.GetHeight(), h) + require.EqualValues(t, view.GetRound(), r) + + return true + }, + } + mockNode = &mockNode{ + idFn: func() []byte { + return id + }, + buildProposalFn: func(h uint64) []byte { + require.Equal(t, view.GetHeight(), h) + + return proposal + }, + hashFn: func(p []byte) []byte { + require.Equal(t, proposal, p) + + return hash + }, + } + mockSigner = &mockSigner{ + signFn: func(b []byte) []byte { + require.NotNil(t, b) + + return signature + }, + } + mockBroadcast = &mockBroadcast{ + broadcastProposeFn: func(proposalMessage *types.ProposalMessage) { + broadcastPropose = proposalMessage + }, + broadcastPrevoteFn: func(prevoteMessage *types.PrevoteMessage) { + broadcastPrevote = prevoteMessage + + // Stop the execution + cancelFn() + }, + } + ) + + // Create the tendermint instance + tm := NewTendermint( + mockVerifier, + mockNode, + mockBroadcast, + mockSigner, + ) + tm.state.setHeight(view.Height) + tm.state.setRound(view.Round) + + // Run through the states + tm.finalizeProposal(ctx) + + tm.wg.Wait() + + // Make sure the correct state was updated + require.Equal(t, prevote, tm.state.step) + assert.True(t, view.Equals(tm.state.view)) + assert.Equal(t, hash, tm.state.acceptedProposalID) + + // Make sure the broadcast propose was valid + require.NotNil(t, broadcastPropose) + require.NotNil(t, tm.state.acceptedProposal) + assert.Equal(t, broadcastPropose.GetProposal(), tm.state.acceptedProposal) + + assert.True(t, view.Equals(broadcastPropose.GetView())) + assert.Equal(t, id, broadcastPropose.GetSender()) + assert.Equal(t, signature, broadcastPropose.GetSignature()) + assert.Equal(t, proposal, broadcastPropose.GetProposal()) + assert.EqualValues(t, -1, broadcastPropose.GetProposalRound()) + + // Make sure the broadcast prevote was valid + require.NotNil(t, broadcastPrevote) + assert.True(t, view.Equals(broadcastPrevote.GetView())) + assert.Equal(t, id, broadcastPrevote.GetSender()) + assert.Equal(t, signature, broadcastPrevote.GetSignature()) + assert.Equal(t, hash, broadcastPrevote.GetIdentifier()) + }) + + t.Run("validator uses old proposal", func(t *testing.T) { + t.Parallel() + + // Create the execution context + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + + var ( + id = []byte("node ID") + hash = []byte("hash") + signature = []byte("signature") + view = &types.View{ + Height: 10, + Round: 10, + } + proposal = []byte("old proposal") + proposalRound = int64(5) + + broadcastPropose *types.ProposalMessage + broadcastPrevote *types.PrevoteMessage + + mockVerifier = &mockVerifier{ + isProposerFn: func(nodeID []byte, h uint64, r uint64) bool { + require.Equal(t, id, nodeID) + require.EqualValues(t, view.GetHeight(), h) + require.EqualValues(t, view.GetRound(), r) + + return true + }, + } + mockNode = &mockNode{ + idFn: func() []byte { + return id + }, + buildProposalFn: func(_ uint64) []byte { + t.FailNow() + + return nil + }, + hashFn: func(p []byte) []byte { + require.Equal(t, proposal, p) + + return hash + }, + } + mockSigner = &mockSigner{ + signFn: func(b []byte) []byte { + require.NotNil(t, b) + + return signature + }, + } + mockBroadcast = &mockBroadcast{ + broadcastProposeFn: func(proposalMessage *types.ProposalMessage) { + broadcastPropose = proposalMessage + }, + broadcastPrevoteFn: func(prevoteMessage *types.PrevoteMessage) { + broadcastPrevote = prevoteMessage + + // Stop the execution + cancelFn() + }, + } + ) + + // Create the tendermint instance + tm := NewTendermint( + mockVerifier, + mockNode, + mockBroadcast, + mockSigner, + ) + tm.state.setHeight(view.Height) + tm.state.setRound(view.Round) + + // Set the old proposal + tm.state.validValue = proposal + tm.state.validRound = proposalRound + + // Run through the states + tm.finalizeProposal(ctx) + + tm.wg.Wait() + + // Make sure the correct state was updated + require.Equal(t, prevote, tm.state.step) + assert.True(t, view.Equals(tm.state.view)) + assert.Equal(t, hash, tm.state.acceptedProposalID) + + // Make sure the broadcast propose was valid + require.NotNil(t, broadcastPropose) + require.NotNil(t, tm.state.acceptedProposal) + assert.Equal(t, broadcastPropose.GetProposal(), tm.state.acceptedProposal) + + assert.True(t, view.Equals(broadcastPropose.GetView())) + assert.Equal(t, id, broadcastPropose.GetSender()) + assert.Equal(t, signature, broadcastPropose.GetSignature()) + assert.Equal(t, proposal, broadcastPropose.GetProposal()) + assert.EqualValues(t, proposalRound, broadcastPropose.GetProposalRound()) + + // Make sure the broadcast prevote was valid + require.NotNil(t, broadcastPrevote) + assert.True(t, view.Equals(broadcastPrevote.GetView())) + assert.Equal(t, id, broadcastPrevote.GetSender()) + assert.Equal(t, signature, broadcastPrevote.GetSignature()) + assert.Equal(t, hash, broadcastPrevote.GetIdentifier()) + }) + }) + + t.Run("validator is not the proposer", func(t *testing.T) { + t.Parallel() + + t.Run("validator receives valid fresh proposal", func(t *testing.T) { + t.Parallel() + + // Create the execution context + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + + var ( + proposerID = []byte("proposer ID") + id = []byte("node ID") + hash = []byte("hash") + signature = []byte("signature") + view = &types.View{ + Height: 10, + Round: 0, + } + proposal = []byte("proposal") + + proposalMessage = &types.ProposalMessage{ + View: view, + Sender: proposerID, + Signature: signature, + Proposal: proposal, + ProposalRound: -1, + } + + broadcastPrevote *types.PrevoteMessage + + mockVerifier = &mockVerifier{ + isProposerFn: func(nodeID []byte, h uint64, r uint64) bool { + if bytes.Equal(id, nodeID) { + return false + } + + require.Equal(t, proposerID, nodeID) + require.EqualValues(t, view.GetHeight(), h) + require.EqualValues(t, view.GetRound(), r) + + return true + }, + isValidatorFn: func(id []byte) bool { + require.Equal(t, proposerID, id) + + return true + }, + isValidProposalFn: func(p []byte, h uint64) bool { + require.Equal(t, proposal, p) + require.EqualValues(t, view.GetHeight(), h) + + return true + }, + } + mockNode = &mockNode{ + idFn: func() []byte { + return id + }, + hashFn: func(p []byte) []byte { + require.Equal(t, proposal, p) + + return hash + }, + } + mockSigner = &mockSigner{ + signFn: func(b []byte) []byte { + require.NotNil(t, b) + + return signature + }, + isValidSignatureFn: func(raw []byte, signed []byte) bool { + require.Equal(t, proposalMessage.GetSignaturePayload(), raw) + require.Equal(t, signature, signed) + + return true + }, + } + mockBroadcast = &mockBroadcast{ + broadcastPrevoteFn: func(prevoteMessage *types.PrevoteMessage) { + broadcastPrevote = prevoteMessage + + // Stop the execution + cancelFn() + }, + } + ) + + // Create the tendermint instance + tm := NewTendermint(mockVerifier, mockNode, mockBroadcast, mockSigner) + tm.state.setHeight(view.Height) + tm.state.setRound(view.Round) + + // Add in the proposal message + require.NoError(t, tm.AddProposalMessage(proposalMessage)) + + // Run through the states + tm.finalizeProposal(ctx) + + tm.wg.Wait() + + // Make sure the correct state was updated + require.Equal(t, prevote, tm.state.step) + assert.True(t, view.Equals(tm.state.view)) + assert.Equal(t, hash, tm.state.acceptedProposalID) + + // Make sure the correct proposal was accepted + assert.Equal(t, proposalMessage.GetProposal(), tm.state.acceptedProposal) + assert.Equal(t, hash, tm.state.acceptedProposalID) + + // Make sure the broadcast prevote was valid + require.NotNil(t, broadcastPrevote) + assert.True(t, view.Equals(broadcastPrevote.GetView())) + assert.Equal(t, id, broadcastPrevote.GetSender()) + assert.Equal(t, signature, broadcastPrevote.GetSignature()) + assert.Equal(t, hash, broadcastPrevote.GetIdentifier()) + }) + + t.Run("validator receives valid locked proposal", func(t *testing.T) { + t.Parallel() + + // Create the execution context + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + + var ( + proposerID = []byte("proposer ID") + id = []byte("node ID") + hash = []byte("hash") + signature = []byte("signature") + view = &types.View{ + Height: 10, + Round: 10, + } + proposal = []byte("proposal") + proposalRound = int64(5) + + proposalMessage = &types.ProposalMessage{ + View: view, + Sender: proposerID, + Signature: signature, + Proposal: proposal, + ProposalRound: proposalRound, + } + + broadcastPrevote *types.PrevoteMessage + + mockVerifier = &mockVerifier{ + isProposerFn: func(nodeID []byte, h uint64, r uint64) bool { + if bytes.Equal(id, nodeID) { + return false + } + + require.Equal(t, proposerID, nodeID) + require.EqualValues(t, view.GetHeight(), h) + require.EqualValues(t, view.GetRound(), r) + + return true + }, + isValidatorFn: func(id []byte) bool { + require.Equal(t, proposerID, id) + + return true + }, + isValidProposalFn: func(p []byte, h uint64) bool { + require.Equal(t, proposal, p) + require.EqualValues(t, view.GetHeight(), h) + + return true + }, + } + mockNode = &mockNode{ + idFn: func() []byte { + return id + }, + hashFn: func(p []byte) []byte { + require.Equal(t, proposal, p) + + return hash + }, + } + mockSigner = &mockSigner{ + signFn: func(b []byte) []byte { + require.NotNil(t, b) + + return signature + }, + isValidSignatureFn: func(raw []byte, signed []byte) bool { + require.Equal(t, proposalMessage.GetSignaturePayload(), raw) + require.Equal(t, signature, signed) + + return true + }, + } + mockBroadcast = &mockBroadcast{ + broadcastPrevoteFn: func(prevoteMessage *types.PrevoteMessage) { + broadcastPrevote = prevoteMessage + + // Stop the execution + cancelFn() + }, + } + ) + + // Create the tendermint instance + tm := NewTendermint(mockVerifier, mockNode, mockBroadcast, mockSigner) + tm.state.setHeight(view.Height) + tm.state.setRound(view.Round) + + // Add in the proposal message + require.NoError(t, tm.AddProposalMessage(proposalMessage)) + + // Run through the states + tm.finalizeProposal(ctx) + + tm.wg.Wait() + + // Make sure the correct state was updated + require.Equal(t, prevote, tm.state.step) + assert.True(t, view.Equals(tm.state.view)) + assert.Equal(t, hash, tm.state.acceptedProposalID) + + // Make sure the correct proposal was accepted + assert.Equal(t, proposalMessage.GetProposal(), tm.state.acceptedProposal) + assert.Equal(t, hash, tm.state.acceptedProposalID) + + // Make sure the broadcast prevote was valid + require.NotNil(t, broadcastPrevote) + assert.True(t, view.Equals(broadcastPrevote.GetView())) + assert.Equal(t, id, broadcastPrevote.GetSender()) + assert.Equal(t, signature, broadcastPrevote.GetSignature()) + assert.Equal(t, hash, broadcastPrevote.GetIdentifier()) + }) + + t.Run("validator receives invalid fresh proposal", func(t *testing.T) { + t.Parallel() + + // Create the execution context + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + + var ( + proposerID = []byte("proposer ID") + id = []byte("node ID") + hash = []byte("hash") + signature = []byte("signature") + view = &types.View{ + Height: 10, + Round: 5, + } + proposal = []byte("proposal") + + lockedRound = int64(view.Round - 1) // earlier round + + proposalMessage = &types.ProposalMessage{ + View: view, + Sender: proposerID, + Signature: signature, + Proposal: proposal, + ProposalRound: -1, + } + + broadcastPrevote *types.PrevoteMessage + + mockVerifier = &mockVerifier{ + isProposerFn: func(nodeID []byte, h uint64, r uint64) bool { + if bytes.Equal(id, nodeID) { + return false + } + + require.Equal(t, proposerID, nodeID) + require.EqualValues(t, view.GetHeight(), h) + require.EqualValues(t, view.GetRound(), r) + + return true + }, + isValidatorFn: func(id []byte) bool { + require.Equal(t, proposerID, id) + + return true + }, + isValidProposalFn: func(p []byte, h uint64) bool { + require.Equal(t, proposal, p) + require.EqualValues(t, view.GetHeight(), h) + + return true + }, + } + mockNode = &mockNode{ + idFn: func() []byte { + return id + }, + hashFn: func(p []byte) []byte { + require.Equal(t, proposal, p) + + return hash + }, + } + mockSigner = &mockSigner{ + signFn: func(b []byte) []byte { + require.NotNil(t, b) + + return signature + }, + isValidSignatureFn: func(raw []byte, signed []byte) bool { + require.Equal(t, proposalMessage.GetSignaturePayload(), raw) + require.Equal(t, signature, signed) + + return true + }, + } + mockBroadcast = &mockBroadcast{ + broadcastPrevoteFn: func(prevoteMessage *types.PrevoteMessage) { + broadcastPrevote = prevoteMessage + + // Stop the execution + cancelFn() + }, + } + ) + + // Create the tendermint instance + tm := NewTendermint(mockVerifier, mockNode, mockBroadcast, mockSigner) + tm.state.setHeight(view.Height) + tm.state.setRound(view.Round) + + // Set the locked round + tm.state.lockedRound = lockedRound + + // Add in the proposal message + require.NoError(t, tm.AddProposalMessage(proposalMessage)) + + // Run through the states + tm.finalizeProposal(ctx) + + tm.wg.Wait() + + // Make sure the correct state was updated + require.Equal(t, prevote, tm.state.step) + assert.True(t, view.Equals(tm.state.view)) + + // Make sure the correct proposal was not accepted + assert.Nil(t, tm.state.acceptedProposal) + assert.Nil(t, tm.state.acceptedProposalID) + + // Make sure the broadcast prevote was NIL + require.NotNil(t, broadcastPrevote) + assert.True(t, view.Equals(broadcastPrevote.GetView())) + assert.Equal(t, id, broadcastPrevote.GetSender()) + assert.Equal(t, signature, broadcastPrevote.GetSignature()) + assert.Nil(t, broadcastPrevote.GetIdentifier()) + }) + + t.Run("validator receives locked proposal from an invalid round", func(t *testing.T) { + t.Parallel() + + // Create the execution context + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + + var ( + proposerID = []byte("proposer ID") + id = []byte("node ID") + hash = []byte("hash") + signature = []byte("signature") + view = &types.View{ + Height: 10, + Round: 5, + } + proposal = []byte("proposal") + + lockedRound = int64(view.Round - 1) // earlier round + + proposalMessage = &types.ProposalMessage{ + View: view, + Sender: proposerID, + Signature: signature, + Proposal: proposal, + ProposalRound: lockedRound + 1, // invalid proposal round + } + + broadcastPrevote *types.PrevoteMessage + + mockVerifier = &mockVerifier{ + isProposerFn: func(nodeID []byte, h uint64, r uint64) bool { + if bytes.Equal(id, nodeID) { + return false + } + + require.Equal(t, proposerID, nodeID) + require.EqualValues(t, view.GetHeight(), h) + require.EqualValues(t, view.GetRound(), r) + + return true + }, + isValidatorFn: func(id []byte) bool { + require.Equal(t, proposerID, id) + + return true + }, + isValidProposalFn: func(p []byte, h uint64) bool { + require.Equal(t, proposal, p) + require.EqualValues(t, view.GetHeight(), h) + + return true + }, + } + mockNode = &mockNode{ + idFn: func() []byte { + return id + }, + hashFn: func(p []byte) []byte { + require.Equal(t, proposal, p) + + return hash + }, + } + mockSigner = &mockSigner{ + signFn: func(b []byte) []byte { + require.NotNil(t, b) + + return signature + }, + isValidSignatureFn: func(raw []byte, signed []byte) bool { + require.Equal(t, proposalMessage.GetSignaturePayload(), raw) + require.Equal(t, signature, signed) + + return true + }, + } + mockBroadcast = &mockBroadcast{ + broadcastPrevoteFn: func(prevoteMessage *types.PrevoteMessage) { + broadcastPrevote = prevoteMessage + + // Stop the execution + cancelFn() + }, + } + ) + + // Create the tendermint instance + tm := NewTendermint(mockVerifier, mockNode, mockBroadcast, mockSigner) + tm.state.setHeight(view.Height) + tm.state.setRound(view.Round) + + // Set the locked round + tm.state.lockedRound = lockedRound + + // Add in the proposal message + require.NoError(t, tm.AddProposalMessage(proposalMessage)) + + // Run through the states + tm.finalizeProposal(ctx) + + tm.wg.Wait() + + // Make sure the correct state was updated + require.Equal(t, prevote, tm.state.step) + assert.True(t, view.Equals(tm.state.view)) + + // Make sure the correct proposal was not accepted + assert.Nil(t, tm.state.acceptedProposal) + assert.Nil(t, tm.state.acceptedProposalID) + + // Make sure the broadcast prevote was NIL + require.NotNil(t, broadcastPrevote) + assert.True(t, view.Equals(broadcastPrevote.GetView())) + assert.Equal(t, id, broadcastPrevote.GetSender()) + assert.Equal(t, signature, broadcastPrevote.GetSignature()) + assert.Nil(t, broadcastPrevote.GetIdentifier()) + }) + + t.Run("validator receives invalid locked proposal (round mismatch)", func(t *testing.T) { + t.Parallel() + + // Create the execution context + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + + var ( + proposerID = []byte("proposer ID") + id = []byte("node ID") + hash = []byte("hash") + signature = []byte("signature") + view = &types.View{ + Height: 10, + Round: 5, + } + proposal = []byte("proposal") + + lockedRound = int64(view.Round - 1) // earlier round + + proposalMessage = &types.ProposalMessage{ + View: view, + Sender: proposerID, + Signature: signature, + Proposal: proposal, + ProposalRound: lockedRound - 1, // invalid proposal round + } + + broadcastPrevote *types.PrevoteMessage + + mockVerifier = &mockVerifier{ + isProposerFn: func(nodeID []byte, h uint64, r uint64) bool { + if bytes.Equal(id, nodeID) { + return false + } + + require.Equal(t, proposerID, nodeID) + require.EqualValues(t, view.GetHeight(), h) + require.EqualValues(t, view.GetRound(), r) + + return true + }, + isValidatorFn: func(id []byte) bool { + require.Equal(t, proposerID, id) + + return true + }, + isValidProposalFn: func(p []byte, h uint64) bool { + require.Equal(t, proposal, p) + require.EqualValues(t, view.GetHeight(), h) + + return true + }, + } + mockNode = &mockNode{ + idFn: func() []byte { + return id + }, + hashFn: func(p []byte) []byte { + require.Equal(t, proposal, p) + + return hash + }, + } + mockSigner = &mockSigner{ + signFn: func(b []byte) []byte { + require.NotNil(t, b) + + return signature + }, + isValidSignatureFn: func(raw []byte, signed []byte) bool { + require.Equal(t, proposalMessage.GetSignaturePayload(), raw) + require.Equal(t, signature, signed) + + return true + }, + } + mockBroadcast = &mockBroadcast{ + broadcastPrevoteFn: func(prevoteMessage *types.PrevoteMessage) { + broadcastPrevote = prevoteMessage + + // Stop the execution + cancelFn() + }, + } + ) + + // Create the tendermint instance + tm := NewTendermint(mockVerifier, mockNode, mockBroadcast, mockSigner) + tm.state.setHeight(view.Height) + tm.state.setRound(view.Round) + + // Set the locked round + tm.state.lockedRound = lockedRound + + // Add in the proposal message + require.NoError(t, tm.AddProposalMessage(proposalMessage)) + + // Run through the states + tm.finalizeProposal(ctx) + + tm.wg.Wait() + + // Make sure the correct state was updated + require.Equal(t, prevote, tm.state.step) + assert.True(t, view.Equals(tm.state.view)) + + // Make sure the correct proposal was not accepted + assert.Nil(t, tm.state.acceptedProposal) + assert.Nil(t, tm.state.acceptedProposalID) + + // Make sure the broadcast prevote was NIL + require.NotNil(t, broadcastPrevote) + assert.True(t, view.Equals(broadcastPrevote.GetView())) + assert.Equal(t, id, broadcastPrevote.GetSender()) + assert.Equal(t, signature, broadcastPrevote.GetSignature()) + assert.Nil(t, broadcastPrevote.GetIdentifier()) + }) + + t.Run("validator receives a proposal that is not valid", func(t *testing.T) { + t.Parallel() + + // Create the execution context + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + + var ( + proposerID = []byte("proposer ID") + id = []byte("node ID") + hash = []byte("hash") + signature = []byte("signature") + view = &types.View{ + Height: 10, + Round: 0, + } + proposal = []byte("proposal") + + proposalMessage = &types.ProposalMessage{ + View: view, + Sender: proposerID, + Signature: signature, + Proposal: proposal, + ProposalRound: -1, + } + + broadcastPrevote *types.PrevoteMessage + + mockVerifier = &mockVerifier{ + isProposerFn: func(nodeID []byte, h uint64, r uint64) bool { + if bytes.Equal(id, nodeID) { + return false + } + + require.Equal(t, proposerID, nodeID) + require.EqualValues(t, view.GetHeight(), h) + require.EqualValues(t, view.GetRound(), r) + + return true + }, + isValidatorFn: func(id []byte) bool { + require.Equal(t, proposerID, id) + + return true + }, + isValidProposalFn: func(p []byte, h uint64) bool { + require.Equal(t, proposal, p) + require.EqualValues(t, view.GetHeight(), h) + + return false // invalid proposal + }, + } + mockNode = &mockNode{ + idFn: func() []byte { + return id + }, + hashFn: func(p []byte) []byte { + require.Equal(t, proposal, p) + + return hash + }, + } + mockSigner = &mockSigner{ + signFn: func(b []byte) []byte { + require.NotNil(t, b) + + return signature + }, + isValidSignatureFn: func(raw []byte, signed []byte) bool { + require.Equal(t, proposalMessage.GetSignaturePayload(), raw) + require.Equal(t, signature, signed) + + return true + }, + } + mockBroadcast = &mockBroadcast{ + broadcastPrevoteFn: func(prevoteMessage *types.PrevoteMessage) { + broadcastPrevote = prevoteMessage + + // Stop the execution + cancelFn() + }, + } + ) + + // Create the tendermint instance + tm := NewTendermint(mockVerifier, mockNode, mockBroadcast, mockSigner) + tm.state.setHeight(view.Height) + tm.state.setRound(view.Round) + + // Add in the proposal message + require.NoError(t, tm.AddProposalMessage(proposalMessage)) + + // Run through the states + tm.finalizeProposal(ctx) + + tm.wg.Wait() + + // Make sure the correct state was updated + require.Equal(t, prevote, tm.state.step) + assert.True(t, view.Equals(tm.state.view)) + + // Make sure the correct proposal was not accepted + assert.Nil(t, tm.state.acceptedProposal) + assert.Nil(t, tm.state.acceptedProposalID) + + // Make sure the broadcast prevote was NIL + require.NotNil(t, broadcastPrevote) + assert.True(t, view.Equals(broadcastPrevote.GetView())) + assert.Equal(t, id, broadcastPrevote.GetSender()) + assert.Equal(t, signature, broadcastPrevote.GetSignature()) + assert.Nil(t, broadcastPrevote.GetIdentifier()) + }) + + t.Run("validator does not receive a proposal in time", func(t *testing.T) { + t.Parallel() + + // Create the execution context + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + + var ( + proposerID = []byte("proposer ID") + id = []byte("node ID") + signature = []byte("signature") + view = &types.View{ + Height: 10, + Round: 0, + } + + timeout = Timeout{ + Initial: 100 * time.Millisecond, + Delta: 0, + } + + broadcastPrevote *types.PrevoteMessage + + mockVerifier = &mockVerifier{ + isProposerFn: func(nodeID []byte, h uint64, r uint64) bool { + if bytes.Equal(id, nodeID) { + return false + } + + require.Equal(t, proposerID, nodeID) + require.EqualValues(t, view.GetHeight(), h) + require.EqualValues(t, view.GetRound(), r) + + return true + }, + isValidatorFn: func(id []byte) bool { + require.Equal(t, proposerID, id) + + return true + }, + } + mockNode = &mockNode{ + idFn: func() []byte { + return id + }, + } + mockSigner = &mockSigner{ + signFn: func(b []byte) []byte { + require.NotNil(t, b) + + return signature + }, + } + mockBroadcast = &mockBroadcast{ + broadcastPrevoteFn: func(prevoteMessage *types.PrevoteMessage) { + broadcastPrevote = prevoteMessage + + // Stop the execution + cancelFn() + }, + } + ) + + // Create the tendermint instance + tm := NewTendermint( + mockVerifier, + mockNode, + mockBroadcast, + mockSigner, + WithProposeTimeout(timeout), + ) + tm.state.setHeight(view.Height) + tm.state.setRound(view.Round) + + // Run through the states + tm.finalizeProposal(ctx) + + tm.wg.Wait() + + // Make sure the correct state was updated + require.Equal(t, prevote, tm.state.step) + assert.True(t, view.Equals(tm.state.view)) + + // Make sure the correct proposal was not accepted + assert.Nil(t, tm.state.acceptedProposal) + assert.Nil(t, tm.state.acceptedProposalID) + + // Make sure the broadcast prevote was NIL + require.NotNil(t, broadcastPrevote) + assert.True(t, view.Equals(broadcastPrevote.GetView())) + assert.Equal(t, id, broadcastPrevote.GetSender()) + assert.Equal(t, signature, broadcastPrevote.GetSignature()) + assert.Nil(t, broadcastPrevote.GetIdentifier()) + }) + }) +} + +// generatePrevoteMessages generates basic prevote messages +// using the given view and ID +func generatePrevoteMessages( + t *testing.T, + count int, + view *types.View, + id []byte, +) []*types.PrevoteMessage { + t.Helper() + + messages := make([]*types.PrevoteMessage, count) + + for i := 0; i < count; i++ { + messages[i] = &types.PrevoteMessage{ + View: view, + Sender: []byte(fmt.Sprintf("sender %d", i)), + Signature: []byte("signature"), + Identifier: id, + } + } + + return messages +} + +// generatePrecommitMessages generates basic precommit messages +// using the given view and ID +func generatePrecommitMessages( + t *testing.T, + count int, + view *types.View, + id []byte, +) []*types.PrecommitMessage { + t.Helper() + + messages := make([]*types.PrecommitMessage, count) + + for i := 0; i < count; i++ { + messages[i] = &types.PrecommitMessage{ + View: view, + Sender: []byte(fmt.Sprintf("sender %d", i)), + Signature: []byte("signature"), + Identifier: id, + } + } + + return messages +} + +func TestTendermint_FinalizeProposal_Prevote(t *testing.T) { + t.Parallel() + + t.Run("validator received 2F+1 PREVOTEs with a valid ID", func(t *testing.T) { + t.Parallel() + + // Create the execution context + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + + var ( + id = []byte("node ID") + signature = []byte("signature") + view = &types.View{ + Height: 10, + Round: 0, + } + proposalID = []byte("proposal ID") + proposal = []byte("proposal") + + proposalMessage = &types.ProposalMessage{ + View: view, + Sender: []byte("proposer"), + Signature: []byte("proposer signature"), + Proposal: proposal, + ProposalRound: -1, + } + + numPrevotes = 10 + prevoteMessages = generatePrevoteMessages(t, numPrevotes, view, proposalID) + + broadcastPrecommit *types.PrecommitMessage + + mockVerifier = &mockVerifier{ + getTotalVotingPowerFn: func(h uint64) uint64 { + require.EqualValues(t, view.Height, h) + + return uint64(numPrevotes) + }, + getSumVotingPowerFn: func(messages []Message) uint64 { + return uint64(len(messages)) + }, + isValidatorFn: func(_ []byte) bool { + return true + }, + } + + mockNode = &mockNode{ + idFn: func() []byte { + return id + }, + } + mockSigner = &mockSigner{ + signFn: func(b []byte) []byte { + require.NotNil(t, b) + + return signature + }, + isValidSignatureFn: func(_ []byte, _ []byte) bool { + return true + }, + } + mockBroadcast = &mockBroadcast{ + broadcastPrecommitFn: func(precommitMessage *types.PrecommitMessage) { + broadcastPrecommit = precommitMessage + + // Stop the execution + cancelFn() + }, + } + ) + + // Create the tendermint instance + tm := NewTendermint(mockVerifier, mockNode, mockBroadcast, mockSigner) + tm.state.setHeight(view.Height) + tm.state.setRound(view.Round) + tm.state.step = prevote + tm.state.acceptedProposal = proposal + tm.state.acceptedProposalID = proposalID + + // Add in 2F+1 non-NIL prevote messages + for _, prevoteMessage := range prevoteMessages { + require.NoError(t, tm.AddPrevoteMessage(prevoteMessage)) + } + + // Run through the states + tm.finalizeProposal(ctx) + + tm.wg.Wait() + + // Make sure the correct state was updated + require.Equal(t, precommit, tm.state.step) + assert.True(t, view.Equals(tm.state.view)) + assert.EqualValues(t, view.Round, tm.state.lockedRound) + assert.Equal(t, proposalMessage.GetProposal(), tm.state.lockedValue) + assert.Equal(t, proposalMessage.GetProposal(), tm.state.validValue) + assert.EqualValues(t, view.Round, tm.state.validRound) + + // Make sure the broadcast precommit was valid + require.NotNil(t, broadcastPrecommit) + assert.True(t, view.Equals(broadcastPrecommit.GetView())) + assert.Equal(t, id, broadcastPrecommit.GetSender()) + assert.Equal(t, signature, broadcastPrecommit.GetSignature()) + assert.Equal(t, proposalID, broadcastPrecommit.GetIdentifier()) + }) + + t.Run("validator received 2F+1 PREVOTEs with a NIL ID", func(t *testing.T) { + t.Parallel() + + // Create the execution context + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + + var ( + id = []byte("node ID") + signature = []byte("signature") + view = &types.View{ + Height: 10, + Round: 0, + } + proposalID = []byte("proposal ID") + proposal = []byte("proposal") + + numPrevotes = 10 + prevoteMessages = generatePrevoteMessages(t, numPrevotes, view, nil) + + broadcastPrecommit *types.PrecommitMessage + + mockVerifier = &mockVerifier{ + getTotalVotingPowerFn: func(h uint64) uint64 { + require.EqualValues(t, view.Height, h) + + return uint64(numPrevotes) + }, + getSumVotingPowerFn: func(messages []Message) uint64 { + return uint64(len(messages)) + }, + isValidatorFn: func(_ []byte) bool { + return true + }, + } + + mockNode = &mockNode{ + idFn: func() []byte { + return id + }, + } + mockSigner = &mockSigner{ + signFn: func(b []byte) []byte { + require.NotNil(t, b) + + return signature + }, + isValidSignatureFn: func(_ []byte, _ []byte) bool { + return true + }, + } + mockBroadcast = &mockBroadcast{ + broadcastPrecommitFn: func(precommitMessage *types.PrecommitMessage) { + broadcastPrecommit = precommitMessage + + // Stop the execution + cancelFn() + }, + } + ) + + // Create the tendermint instance + tm := NewTendermint(mockVerifier, mockNode, mockBroadcast, mockSigner) + tm.state.setHeight(view.Height) + tm.state.setRound(view.Round) + tm.state.step = prevote + tm.state.acceptedProposal = proposal + tm.state.acceptedProposalID = proposalID + + // Add in 2F+1 non-NIL prevote messages + for _, prevoteMessage := range prevoteMessages { + require.NoError(t, tm.AddPrevoteMessage(prevoteMessage)) + } + + // Run through the states + tm.finalizeProposal(ctx) + + tm.wg.Wait() + + // Make sure the correct state was updated + require.Equal(t, precommit, tm.state.step) + assert.True(t, view.Equals(tm.state.view)) + + assert.EqualValues(t, -1, tm.state.lockedRound) + assert.Nil(t, tm.state.lockedValue) + assert.Nil(t, tm.state.validValue) + assert.EqualValues(t, -1, tm.state.validRound) + + // Make sure the broadcast precommit was valid + require.NotNil(t, broadcastPrecommit) + assert.True(t, view.Equals(broadcastPrecommit.GetView())) + assert.Equal(t, id, broadcastPrecommit.GetSender()) + assert.Equal(t, signature, broadcastPrecommit.GetSignature()) + assert.Nil(t, broadcastPrecommit.GetIdentifier()) + }) + + t.Run("validator does not receive quorum PREVOTEs in time", func(t *testing.T) { + t.Parallel() + + // Create the execution context + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + + var ( + id = []byte("node ID") + signature = []byte("signature") + view = &types.View{ + Height: 10, + Round: 0, + } + proposalID = []byte("proposal ID") + proposal = []byte("proposal") + + totalPrevoteCount = 10 + nilPrevoteMessages = generatePrevoteMessages(t, totalPrevoteCount/2, view, nil) + nonNilPrevoteMessages = generatePrevoteMessages(t, totalPrevoteCount/2, view, proposalID) + + timeout = Timeout{ + Initial: 100 * time.Millisecond, + Delta: 0, + } + + broadcastPrecommit *types.PrecommitMessage + + mockVerifier = &mockVerifier{ + getTotalVotingPowerFn: func(h uint64) uint64 { + require.EqualValues(t, view.Height, h) + + return uint64(totalPrevoteCount) + }, + getSumVotingPowerFn: func(messages []Message) uint64 { + return uint64(len(messages)) + }, + isValidatorFn: func(_ []byte) bool { + return true + }, + } + + mockNode = &mockNode{ + idFn: func() []byte { + return id + }, + } + mockSigner = &mockSigner{ + signFn: func(b []byte) []byte { + require.NotNil(t, b) + + return signature + }, + isValidSignatureFn: func(_ []byte, _ []byte) bool { + return true + }, + } + mockBroadcast = &mockBroadcast{ + broadcastPrecommitFn: func(precommitMessage *types.PrecommitMessage) { + broadcastPrecommit = precommitMessage + + // Stop the execution + cancelFn() + }, + } + ) + + // Create the tendermint instance + tm := NewTendermint( + mockVerifier, + mockNode, + mockBroadcast, + mockSigner, + WithPrevoteTimeout(timeout), + ) + tm.state.setHeight(view.Height) + tm.state.setRound(view.Round) + tm.state.step = prevote + tm.state.acceptedProposal = proposal + tm.state.acceptedProposalID = proposalID + + // Add in non-NIL prevote messages + for index, prevoteMessage := range nonNilPrevoteMessages { + // Change the senders for the non-NIL prevote messages. + // The reason the senders need to be changed is, so we can simulate the following scenario: + // 1/2 of the total voting power sent in non-NIL prevote messages + // 1/2 of the total voting power sent in NIL prevote messages + // In turn, there is a super majority when their voting powers are summed (non-NIL and NIL) + prevoteMessage.Sender = []byte(fmt.Sprintf("sender %d", index)) + + require.NoError(t, tm.AddPrevoteMessage(prevoteMessage)) + } + + // Add in NIL prevote messages + for index, prevoteMessage := range nilPrevoteMessages { + // Change the senders for the NIL prevote messages + prevoteMessage.Sender = []byte(fmt.Sprintf("sender %d", index+len(nonNilPrevoteMessages))) + + require.NoError(t, tm.AddPrevoteMessage(prevoteMessage)) + } + + // Run through the states + tm.finalizeProposal(ctx) + + tm.wg.Wait() + + // Make sure the correct state was updated + require.Equal(t, precommit, tm.state.step) + assert.True(t, view.Equals(tm.state.view)) + + assert.EqualValues(t, -1, tm.state.lockedRound) + assert.Nil(t, tm.state.lockedValue) + assert.Nil(t, tm.state.validValue) + assert.EqualValues(t, -1, tm.state.validRound) + + // Make sure the broadcast precommit was valid + require.NotNil(t, broadcastPrecommit) + assert.True(t, view.Equals(broadcastPrecommit.GetView())) + assert.Equal(t, id, broadcastPrecommit.GetSender()) + assert.Equal(t, signature, broadcastPrecommit.GetSignature()) + assert.Nil(t, broadcastPrecommit.GetIdentifier()) + }) +} + +func TestTendermint_FinalizeProposal_Precommit(t *testing.T) { + t.Parallel() + + t.Run("validator received 2F+1 PRECOMMITs with a valid ID", func(t *testing.T) { + t.Parallel() + + // Create the execution context + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + + var ( + id = []byte("node ID") + signature = []byte("signature") + view = &types.View{ + Height: 10, + Round: 0, + } + proposalID = []byte("proposal ID") + proposal = []byte("proposal") + + proposalMessage = &types.ProposalMessage{ + Proposal: proposal, + } + + numPrecommits = 10 + precommitMessages = generatePrecommitMessages(t, numPrecommits, view, proposalID) + + mockVerifier = &mockVerifier{ + getTotalVotingPowerFn: func(h uint64) uint64 { + require.EqualValues(t, view.Height, h) + + return uint64(numPrecommits) + }, + getSumVotingPowerFn: func(messages []Message) uint64 { + return uint64(len(messages)) + }, + isValidatorFn: func(_ []byte) bool { + return true + }, + } + + mockNode = &mockNode{ + idFn: func() []byte { + return id + }, + } + mockSigner = &mockSigner{ + signFn: func(b []byte) []byte { + require.NotNil(t, b) + + return signature + }, + isValidSignatureFn: func(_ []byte, _ []byte) bool { + return true + }, + } + ) + + // Create the tendermint instance + tm := NewTendermint(mockVerifier, mockNode, &mockBroadcast{}, mockSigner) + tm.state.setHeight(view.Height) + tm.state.setRound(view.Round) + tm.state.step = precommit + tm.state.acceptedProposal = proposal + tm.state.acceptedProposalID = proposalID + + // Add in 2F+1 non-NIL precommit messages + for _, precommitMessage := range precommitMessages { + require.NoError(t, tm.AddPrecommitMessage(precommitMessage)) + } + + // Run through the states + finalizedProposalCh := tm.finalizeProposal(ctx) + + // Get the finalized proposal + finalizedProposal := <-finalizedProposalCh + + cancelFn() + tm.wg.Wait() + + // Make sure the finalized proposal is valid + require.NotNil(t, finalizedProposal) + assert.Equal(t, proposalMessage.Proposal, finalizedProposal.Data) + }) + + t.Run("validator does not receive quorum PRECOMMITs in time", func(t *testing.T) { + t.Parallel() + + // Create the execution context + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + + var ( + id = []byte("node ID") + signature = []byte("signature") + view = &types.View{ + Height: 10, + Round: 0, + } + proposalID = []byte("proposal ID") + proposal = []byte("proposal") + + totalPrecommitCount = 10 + nilPrecommitMessages = generatePrecommitMessages(t, totalPrecommitCount/2, view, nil) + nonNilPrecommitMessages = generatePrecommitMessages(t, totalPrecommitCount/2, view, proposalID) + + timeout = Timeout{ + Initial: 100 * time.Millisecond, + Delta: 0, + } + + mockVerifier = &mockVerifier{ + getTotalVotingPowerFn: func(h uint64) uint64 { + require.EqualValues(t, view.Height, h) + + return uint64(totalPrecommitCount) + }, + getSumVotingPowerFn: func(messages []Message) uint64 { + return uint64(len(messages)) + }, + isValidatorFn: func(_ []byte) bool { + return true + }, + } + + mockNode = &mockNode{ + idFn: func() []byte { + return id + }, + } + mockSigner = &mockSigner{ + signFn: func(b []byte) []byte { + require.NotNil(t, b) + + return signature + }, + isValidSignatureFn: func(_ []byte, _ []byte) bool { + return true + }, + } + ) + + // Create the tendermint instance + tm := NewTendermint( + mockVerifier, + mockNode, + &mockBroadcast{}, + mockSigner, + WithPrecommitTimeout(timeout), + ) + tm.state.setHeight(view.Height) + tm.state.setRound(view.Round) + tm.state.step = precommit + tm.state.acceptedProposal = proposal + tm.state.acceptedProposalID = proposalID + + // Add in non-NIL precommit messages + for index, precommitMessage := range nonNilPrecommitMessages { + // Change the senders for the non-NIL precommit messages. + // The reason the senders need to be changed is, so we can simulate the following scenario: + // 1/2 of the total voting power sent in non-NIL precommit messages + // 1/2 of the total voting power sent in NIL precommit messages + // In turn, there is a super majority when their voting powers are summed (non-NIL and NIL) + precommitMessage.Sender = []byte(fmt.Sprintf("sender %d", index)) + + require.NoError(t, tm.AddPrecommitMessage(precommitMessage)) + } + + // Add in NIL precommit messages + for index, precommitMessage := range nilPrecommitMessages { + // Change the senders for the NIL precommit messages + precommitMessage.Sender = []byte(fmt.Sprintf("sender %d", index+len(nonNilPrecommitMessages))) + + require.NoError(t, tm.AddPrecommitMessage(precommitMessage)) + } + + // Run through the states + finalizedProposal := <-tm.finalizeProposal(ctx) + + cancelFn() + tm.wg.Wait() + + // Make sure the finalized proposal is NIL + assert.Nil(t, finalizedProposal) + }) +} + +func TestTendermint_WatchForFutureRounds(t *testing.T) { + t.Parallel() + + var ( + processView = &types.View{ + Height: 10, + Round: 5, + } + view = &types.View{ + Height: processView.Height, + Round: processView.Round + 5, // higher round + } + + totalMessagesPerType = 10 + prevoteMessages = generatePrevoteMessages(t, totalMessagesPerType/4, view, []byte("proposal ID")) + precommitMessages = generatePrecommitMessages(t, totalMessagesPerType, view, []byte("proposal ID")) + + mockVerifier = &mockVerifier{ + getTotalVotingPowerFn: func(h uint64) uint64 { + require.EqualValues(t, view.Height, h) + + return uint64(len(prevoteMessages) + len(precommitMessages)) + }, + getSumVotingPowerFn: func(messages []Message) uint64 { + return uint64(len(messages)) + }, + isValidatorFn: func(_ []byte) bool { + return true + }, + } + mockSigner = &mockSigner{ + signFn: func(b []byte) []byte { + require.NotNil(t, b) + + return []byte("signature") + }, + isValidSignatureFn: func(_ []byte, _ []byte) bool { + return true + }, + } + ) + + // Create the tendermint instance + tm := NewTendermint( + mockVerifier, + nil, + nil, + mockSigner, + ) + + // Set the process view + tm.state.setHeight(processView.GetHeight()) + tm.state.setRound(processView.GetRound()) + + // Make sure F+1 messages are added to the + // message queue, with a higher round + for _, message := range prevoteMessages { + require.NoError(t, tm.AddPrevoteMessage(message)) + } + + for _, message := range precommitMessages { + require.NoError(t, tm.AddPrecommitMessage(message)) + } + + // Set up the wait context + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + + // Wait for future round jumps + nextRound := <-tm.watchForRoundJumps(ctx) + + tm.wg.Wait() + + // Make sure the correct round was returned + assert.Equal(t, view.GetRound(), nextRound) +} diff --git a/tm2/pkg/libtm/core/timeout.go b/tm2/pkg/libtm/core/timeout.go new file mode 100644 index 00000000000..71462f87a3a --- /dev/null +++ b/tm2/pkg/libtm/core/timeout.go @@ -0,0 +1,62 @@ +package core + +import ( + "context" + "time" +) + +// getDefaultTimeoutMap returns the default timeout map +// for the Tendermint consensus engine +func getDefaultTimeoutMap() map[step]Timeout { + return map[step]Timeout{ + propose: { + Initial: 10 * time.Second, // 10s + Delta: 500 * time.Millisecond, // 0.5 + }, + prevote: { + Initial: 10 * time.Second, // 10s + Delta: 500 * time.Millisecond, // 0.5 + }, + precommit: { + Initial: 10 * time.Second, // 10s + Delta: 500 * time.Millisecond, // 0.5 + }, + } +} + +// Timeout is a holder for timeout duration information (constant) +type Timeout struct { + Initial time.Duration // the initial timeout duration + Delta time.Duration // the delta for future timeouts +} + +// CalculateTimeout calculates a new timeout duration using +// the formula: +// +// timeout(r) = initTimeout + r * timeoutDelta +func (t Timeout) CalculateTimeout(round uint64) time.Duration { + return t.Initial + time.Duration(round)*t.Delta +} + +// scheduleTimeout schedules a state timeout to be executed +func (t *Tendermint) scheduleTimeout( + ctx context.Context, + timeout time.Duration, + expiredCh chan<- struct{}, +) { + t.wg.Add(1) + + go func() { + defer t.wg.Done() + + select { + case <-ctx.Done(): + case <-time.After(timeout): + // Signal that the state expired + select { + case expiredCh <- struct{}{}: + default: + } + } + }() +} diff --git a/tm2/pkg/libtm/core/timeout_test.go b/tm2/pkg/libtm/core/timeout_test.go new file mode 100644 index 00000000000..ec57cc385fd --- /dev/null +++ b/tm2/pkg/libtm/core/timeout_test.go @@ -0,0 +1,93 @@ +package core + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestTimeout_CalculateTimeout(t *testing.T) { + t.Parallel() + + var ( + initial = 10 * time.Second + delta = 200 * time.Millisecond + + tm = Timeout{ + Initial: initial, + Delta: delta, + } + ) + + for round := uint64(0); round < 100; round++ { + assert.Equal( + t, + initial+time.Duration(round)*delta, + tm.CalculateTimeout(round), + ) + } +} + +func TestTimeout_ScheduleTimeoutPropose(t *testing.T) { + t.Parallel() + + testTable := []struct { + name string + step step + }{ + { + "OnTimeoutPropose", + propose, + }, + { + "OnTimeoutPrevote", + prevote, + }, + { + "OnTimeoutPrecommit", + precommit, + }, + } + + for _, testCase := range testTable { + testCase := testCase + + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + expiredCh := make(chan struct{}, 1) + + tm := NewTendermint( + nil, + nil, + nil, + nil, + ) + + // Set the timeout data for the step + tm.timeouts[testCase.step] = Timeout{ + Initial: 50 * time.Millisecond, + Delta: 50 * time.Millisecond, + } + + // Schedule the timeout + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + + timeoutPropose := tm.timeouts[testCase.step].CalculateTimeout(0) + + tm.scheduleTimeout(ctx, timeoutPropose, expiredCh) + + // Wait for the timer to trigger + select { + case <-time.After(5 * time.Second): + t.Fatal("timer not triggered") + case <-expiredCh: + } + + tm.wg.Wait() + }) + } +} diff --git a/tm2/pkg/libtm/core/types.go b/tm2/pkg/libtm/core/types.go new file mode 100644 index 00000000000..ba31f454d90 --- /dev/null +++ b/tm2/pkg/libtm/core/types.go @@ -0,0 +1,83 @@ +package core + +import ( + "github.com/gnolang/libtm/messages/types" +) + +// Signer is an abstraction over the signature manipulation process +type Signer interface { + // Sign generates a signature for the given raw data + Sign(data []byte) []byte + + // IsValidSignature verifies whether the signature matches the raw data + IsValidSignature(data []byte, signature []byte) bool +} + +// Verifier is an abstraction over the outer consensus calling context +// that has access to validator set information +type Verifier interface { + // IsProposer checks if the given ID matches the proposer for the given height + IsProposer(id []byte, height uint64, round uint64) bool + + // IsValidator checks if the given message sender ID belongs to a validator + IsValidator(id []byte) bool + + // IsValidProposal checks if the given proposal is valid, for the given height + IsValidProposal(proposal []byte, height uint64) bool + + // GetSumVotingPower returns the summed voting power from + // the given unique message authors + GetSumVotingPower(msgs []Message) uint64 + + // GetTotalVotingPower returns the total voting power + // of the entire validator set for the given height + GetTotalVotingPower(height uint64) uint64 +} + +// Node interface is an abstraction over a single entity (current process) that runs +// the consensus algorithm +type Node interface { + // ID returns the ID associated with the current process (validator) + ID() []byte + + // Hash generates a hash of the given data. + // It must not modify the slice proposal, even temporarily + // and must not retain the data + Hash(proposal []byte) []byte + + // BuildProposal generates a raw proposal for the given height + BuildProposal(height uint64) []byte +} + +// Broadcast is an abstraction over the networking / message sharing interface +// that enables message passing between validators +type Broadcast interface { + // BroadcastPropose broadcasts a PROPOSAL message + BroadcastPropose(message *types.ProposalMessage) + + // BroadcastPrevote broadcasts a PREVOTE message + BroadcastPrevote(message *types.PrevoteMessage) + + // BroadcastPrecommit broadcasts a PRECOMMIT message + BroadcastPrecommit(message *types.PrecommitMessage) +} + +// Message is the content being passed around +// between consensus validators. +// Message types: PROPOSAL, PREVOTE, PRECOMMIT +type Message interface { + // GetView fetches the message view + GetView() *types.View + + // GetSender fetches the message sender + GetSender() []byte + + // GetSignature fetches the message signature + GetSignature() []byte + + // GetSignaturePayload fetches the signature payload (sign data) + GetSignaturePayload() []byte + + // Verify verifies the message content is valid (base verification) + Verify() error +} diff --git a/tm2/pkg/libtm/go.mod b/tm2/pkg/libtm/go.mod new file mode 100644 index 00000000000..a611b2a4454 --- /dev/null +++ b/tm2/pkg/libtm/go.mod @@ -0,0 +1,15 @@ +module github.com/gnolang/libtm + +go 1.21 + +require ( + github.com/rs/xid v1.5.0 + github.com/stretchr/testify v1.9.0 + google.golang.org/protobuf v1.34.2 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/tm2/pkg/libtm/go.sum b/tm2/pkg/libtm/go.sum new file mode 100644 index 00000000000..2a141a0fa78 --- /dev/null +++ b/tm2/pkg/libtm/go.sum @@ -0,0 +1,18 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tm2/pkg/libtm/golangci.yaml b/tm2/pkg/libtm/golangci.yaml new file mode 100644 index 00000000000..00982e70b54 --- /dev/null +++ b/tm2/pkg/libtm/golangci.yaml @@ -0,0 +1,116 @@ +run: + concurrency: 8 + timeout: 10m + issue-exit-code: 1 + tests: true + skip-dirs-use-default: true + modules-download-mode: readonly + allow-parallel-runners: false + go: "" + +output: + uniq-by-line: false + path-prefix: "" + sort-results: true + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + new: false + fix: false + exclude-rules: + - path: (.+)_test.go + linters: + - nilnil + - gosec + +linters: + fast: false + disable-all: true + enable: + - asasalint # Check for pass []any as any in variadic func(...any) + - asciicheck # Detects funky ASCII characters + - bidichk # Checks for dangerous unicode character sequences + - durationcheck # Check for two durations multiplied together + - errcheck # Forces to not skip error check + - exportloopref # Checks for pointers to enclosing loop variables + - gocritic # Bundles different linting checks + - godot # Checks for periods at the end of comments + - gomoddirectives # Allow or ban replace directives in go.mod + - gosimple # Code simplification + - govet # Official Go tool + - ineffassign # Detects when assignments to existing variables are not used + - nakedret # Finds naked/bare returns and requires change them + - nilerr # Requires explicit returns + - nilnil # Requires explicit returns + - promlinter # Lints Prometheus metrics names + - reassign # Checks that package variables are not reassigned + - revive # Drop-in replacement for golint + - tenv # Detects using os.Setenv instead of t.Setenv + - testableexamples # Checks if examples are testable (have expected output) + - unparam # Finds unused params + - usestdlibvars # Detects the possibility to use variables/constants from stdlib + - wastedassign # Finds wasted assignment statements + - loggercheck # Checks the odd number of key and value pairs for common logger libraries + - nestif # Finds deeply nested if statements + - nonamedreturns # Reports all named returns + - decorder # Check declaration order of types, consts, vars and funcs + - gocheckcompilerdirectives # Checks that compiler directive comments (//go:) are valid + - gochecknoinits # Checks for init methods + - whitespace # Tool for detection of leading and trailing whitespace + - wsl # Forces you to use empty lines + - unconvert # Unnecessary type conversions + - tparallel # Detects inappropriate usage of t.Parallel() method in your Go test codes + - thelper # Detects golang test helpers without t.Helper() call and checks the consistency of test helpers + - stylecheck # Stylecheck is a replacement for golint + - prealloc # Finds slice declarations that could potentially be pre-allocated + - predeclared # Finds code that shadows one of Go's predeclared identifiers + - nolintlint # Ill-formed or insufficient nolint directives + - nlreturn # Checks for a new line before return and branch statements to increase code clarity + - misspell # Misspelled English words in comments + - makezero # Finds slice declarations with non-zero initial length + - lll # Long lines + - importas # Enforces consistent import aliases + - gosec # Security problems + - gofmt # Whether the code was gofmt-ed + - gofumpt # Stricter gofmt + - goimports # Unused imports + - goconst # Repeated strings that could be replaced by a constant + - dogsled # Checks assignments with too many blank identifiers (e.g. x, , , _, := f()) + - errname # Checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error + - errorlint # errorlint is a linter for that can be used to find code that will cause problems with the error wrapping scheme introduced in Go 1.13 + - unused # Checks Go code for unused constants, variables, functions and types + +linters-settings: + gocritic: + enabled-tags: + - diagnostic + - experimental + - opinionated + - performance + - style + disabled-checks: + - hugeParam + - rangeExprCopy + - rangeValCopy + - importShadow + - unnamedResult + errcheck: + check-type-assertions: false + check-blank: true + exclude-functions: + - io/ioutil.ReadFile + - io.Copy(*bytes.Buffer) + - io.Copy(os.Stdout) + nakedret: + max-func-lines: 1 + govet: + enable-all: true + gofmt: + simplify: true + goconst: + min-len: 3 + min-occurrences: 3 + godot: + scope: all + period: false diff --git a/tm2/pkg/libtm/messages/collector.go b/tm2/pkg/libtm/messages/collector.go new file mode 100644 index 00000000000..e94283f0ac1 --- /dev/null +++ b/tm2/pkg/libtm/messages/collector.go @@ -0,0 +1,176 @@ +package messages + +import ( + "fmt" + "strconv" + "strings" + "sync" + + "github.com/gnolang/libtm/messages/types" +) + +// msgType is the combined message type interface, +// for easy reference and type safety +type msgType interface { + types.ProposalMessage | types.PrevoteMessage | types.PrecommitMessage +} + +// this is because Go doesn't support covariance on slices +// []*T -> []I does not work +func ConvertToInterface[T msgType](msgs []*T, convertFunc func(m *T)) { + for _, msg := range msgs { + convertFunc(msg) + } +} + +type ( + // collection are the actual received messages. + // Maps a unique identifier -> their message (of a specific type) to avoid duplicates. + // Identifiers are derived from . + // Each validator in the consensus needs to send at most 1 message of every type + // (minus the PROPOSAL, which is only sent by the proposer), + // so the message system needs to keep track of only 1 message per type, per validator, per view + collection[T msgType] map[string]*T +) + +// Collector is a single message type collector +type Collector[T msgType] struct { + collection collection[T] // the message storage + subscriptions subscriptions[T] // the active message subscriptions + + collectionMux sync.RWMutex + subscriptionsMux sync.RWMutex +} + +// NewCollector creates a new message collector +func NewCollector[T msgType]() *Collector[T] { + return &Collector[T]{ + collection: make(collection[T]), + subscriptions: make(subscriptions[T]), + } +} + +// Subscribe creates a new collector subscription. +// Returns the channel for receiving messages, +// as well as the unsubscribe method +func (c *Collector[T]) Subscribe() (<-chan func() []*T, func()) { + c.subscriptionsMux.Lock() + defer c.subscriptionsMux.Unlock() + + // Create a new subscription + id, ch := c.subscriptions.add() + + // Create the unsubscribe callback + unsubscribeFn := func() { + c.subscriptionsMux.Lock() + defer c.subscriptionsMux.Unlock() + + c.subscriptions.remove(id) + } + + // Notify the subscription immediately, + // since there can be existing messages in the collection. + // This action assumes the channel is not blocking (created with initial size), + // since the calling context does not have access to it yet at this point + notifySubscription(ch, c.GetMessages) + + return ch, unsubscribeFn +} + +// GetMessages returns the currently present messages in the collector +func (c *Collector[T]) GetMessages() []*T { + c.collectionMux.RLock() + defer c.collectionMux.RUnlock() + + // Fetch the messages in the collection + return c.collection.getMessages() +} + +// getMessages fetches the messages in the collection +func (c *collection[T]) getMessages() []*T { + messages := make([]*T, 0, len(*c)) + + for _, senderMessage := range *c { + messages = append(messages, senderMessage) + } + + return messages +} + +// DropMessages drops all messages from the collection if their view +// is less than the given view (earlier) +func (c *Collector[T]) DropMessages(view *types.View) { + c.collectionMux.RLock() + defer c.collectionMux.RUnlock() + + // Filter out messages from the collection + shouldStay := func(key string) bool { + // Construct the view from the message + messageView := getViewFromKey(key) + + // Only messages who are >= the current view stay + return messageView.Height >= view.Height && + messageView.Round >= view.Round + } + + // Filter out the messages + c.collection.dropMessages(shouldStay) +} + +// dropMessages drops messages from the collection using the given filter +func (c *collection[T]) dropMessages(shouldStay func(string) bool) { + for key := range *c { + if shouldStay(key) { + continue + } + + delete(*c, key) + } +} + +// AddMessage adds a new message to the collector +func (c *Collector[T]) AddMessage(view *types.View, from []byte, message *T) { + c.collectionMux.Lock() + + // Add the message + c.collection.addMessage( + getCollectionKey(from, view), + message, + ) + + c.collectionMux.Unlock() + + // Notify the subscriptions + c.subscriptionsMux.RLock() + defer c.subscriptionsMux.RUnlock() + + c.subscriptions.notify(c.GetMessages) +} + +// addMessage adds a new message to the collection +func (c *collection[T]) addMessage(key string, message *T) { + (*c)[key] = message +} + +// getCollectionKey constructs a key based on the +// message sender and view information. +// This key guarantees uniqueness in the message store +func getCollectionKey(from []byte, view *types.View) string { + return fmt.Sprintf("%s_%d_%d", from, view.Height, view.Round) +} + +// getViewFromKey constructs the view information, +// based on the collection key +func getViewFromKey(key string) *types.View { + // Split the key + keyParts := strings.Split(key, "_") + + // Parse the view values + height, _ := strconv.ParseUint(keyParts[1], 10, 64) //nolint:errcheck // Key is valid + round, _ := strconv.ParseUint(keyParts[2], 10, 64) //nolint:errcheck // Key is valid + + return &types.View{ + Height: height, + Round: round, + } +} diff --git a/tm2/pkg/libtm/messages/collector_test.go b/tm2/pkg/libtm/messages/collector_test.go new file mode 100644 index 00000000000..192b3d088e8 --- /dev/null +++ b/tm2/pkg/libtm/messages/collector_test.go @@ -0,0 +1,440 @@ +package messages + +import ( + "sort" + "strconv" + "sync" + "testing" + "time" + + "github.com/gnolang/libtm/messages/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// generateProposalMessages generates dummy proposal messages +// for the given view and type +func generateProposalMessages( + t *testing.T, + count int, + view *types.View, +) []*types.ProposalMessage { + t.Helper() + + messages := make([]*types.ProposalMessage, 0, count) + + for index := 0; index < count; index++ { + message := &types.ProposalMessage{ + Sender: []byte(strconv.Itoa(index)), + View: view, + } + + messages = append(messages, message) + } + + return messages +} + +// generatePrevoteMessages generates dummy prevote messages +// for the given view and type +func generatePrevoteMessages( + t *testing.T, + count int, + view *types.View, +) []*types.PrevoteMessage { + t.Helper() + + messages := make([]*types.PrevoteMessage, 0, count) + + for index := 0; index < count; index++ { + message := &types.PrevoteMessage{ + Sender: []byte(strconv.Itoa(index)), + View: view, + } + + messages = append(messages, message) + } + + return messages +} + +// generatePrevoteMessages generates dummy prevote messages +// for the given view and type +func generatePrecommitMessages( + t *testing.T, + count int, + view *types.View, +) []*types.PrecommitMessage { + t.Helper() + + messages := make([]*types.PrecommitMessage, 0, count) + + for index := 0; index < count; index++ { + message := &types.PrecommitMessage{ + Sender: []byte(strconv.Itoa(index)), + View: view, + } + + messages = append(messages, message) + } + + return messages +} + +func TestCollector_AddMessage(t *testing.T) { + t.Parallel() + + t.Run("empty message queue", func(t *testing.T) { + t.Parallel() + + // Create the collector + c := NewCollector[types.ProposalMessage]() + + // Fetch the messages + messages := c.GetMessages() + + require.NotNil(t, messages) + assert.Len(t, messages, 0) + }) + + t.Run("valid PROPOSAL messages fetched", func(t *testing.T) { + t.Parallel() + + var ( + count = 5 + initialView = &types.View{ + Height: 1, + Round: 0, + } + ) + + // Create the collector + c := NewCollector[types.ProposalMessage]() + + generatedMessages := generateProposalMessages( + t, + count, + initialView, + ) + + expectedMessages := make([]*types.ProposalMessage, 0, count) + + for _, proposal := range generatedMessages { + c.AddMessage(proposal.View, proposal.Sender, proposal) + + expectedMessages = append(expectedMessages, proposal) + } + + // Sort the messages for the test + sort.SliceStable(expectedMessages, func(i, j int) bool { + return string(expectedMessages[i].Sender) < string(expectedMessages[j].Sender) + }) + + // Get the messages Sender the store + messages := c.GetMessages() + + // Sort the messages for the test + sort.SliceStable(messages, func(i, j int) bool { + return string(messages[i].Sender) < string(messages[j].Sender) + }) + + // Make sure the messages match + assert.Equal(t, expectedMessages, messages) + }) + + t.Run("valid PREVOTE messages fetched", func(t *testing.T) { + t.Parallel() + + var ( + count = 5 + initialView = &types.View{ + Height: 1, + Round: 0, + } + ) + + // Create the collector + c := NewCollector[types.PrevoteMessage]() + + generatedMessages := generatePrevoteMessages( + t, + count, + initialView, + ) + + expectedMessages := make([]*types.PrevoteMessage, 0, count) + + for _, prevote := range generatedMessages { + c.AddMessage(prevote.View, prevote.Sender, prevote) + + expectedMessages = append(expectedMessages, prevote) + } + + // Sort the messages for the test + sort.SliceStable(expectedMessages, func(i, j int) bool { + return string(expectedMessages[i].Sender) < string(expectedMessages[j].Sender) + }) + + // Get the messages from the store + messages := c.GetMessages() + + // Sort the messages for the test + sort.SliceStable(messages, func(i, j int) bool { + return string(messages[i].Sender) < string(messages[j].Sender) + }) + + // Make sure the messages match + assert.Equal(t, expectedMessages, messages) + }) + + t.Run("valid PRECOMMIT messages fetched", func(t *testing.T) { + t.Parallel() + + var ( + count = 5 + initialView = &types.View{ + Height: 1, + Round: 0, + } + ) + + // Create the collector + c := NewCollector[types.PrecommitMessage]() + + generatedMessages := generatePrecommitMessages( + t, + count, + initialView, + ) + + expectedMessages := make([]*types.PrecommitMessage, 0, count) + + for _, precommit := range generatedMessages { + c.AddMessage(precommit.View, precommit.Sender, precommit) + + expectedMessages = append(expectedMessages, precommit) + } + + // Sort the messages for the test + sort.SliceStable(expectedMessages, func(i, j int) bool { + return string(expectedMessages[i].Sender) < string(expectedMessages[j].Sender) + }) + + // Get the messages Sender the store + messages := c.GetMessages() + + // Sort the messages for the test + sort.SliceStable(messages, func(i, j int) bool { + return string(messages[i].Sender) < string(messages[j].Sender) + }) + + // Make sure the messages match + assert.Equal(t, expectedMessages, messages) + }) +} + +func TestCollector_AddDuplicateMessages(t *testing.T) { + t.Parallel() + + var ( + count = 5 + commonSender = []byte("sender 1") + view = &types.View{ + Height: 1, + Round: 1, + } + ) + + // Create the collector + c := NewCollector[types.PrevoteMessage]() + + generatedMessages := generatePrevoteMessages( + t, + count, + view, + ) + + for _, prevote := range generatedMessages { + // Make sure each message is from the same sender + prevote.Sender = commonSender + + c.AddMessage(prevote.View, prevote.Sender, prevote) + } + + // Check that only 1 message has been added + assert.Len(t, c.GetMessages(), 1) +} + +func TestCollector_Subscribe(t *testing.T) { + t.Parallel() + + t.Run("subscribe with pre-existing messages", func(t *testing.T) { + t.Parallel() + + var ( + count = 100 + view = &types.View{ + Height: 1, + Round: 0, + } + ) + + // Create the collector + c := NewCollector[types.PrevoteMessage]() + + generatedMessages := generatePrevoteMessages( + t, + count, + view, + ) + + expectedMessages := make([]*types.PrevoteMessage, 0, count) + + for _, prevote := range generatedMessages { + c.AddMessage(prevote.View, prevote.Sender, prevote) + + expectedMessages = append(expectedMessages, prevote) + } + + // Create a subscription + notifyCh, unsubscribeFn := c.Subscribe() + defer unsubscribeFn() + + var messages []*types.PrevoteMessage + + select { + case callback := <-notifyCh: + messages = callback() + case <-time.After(5 * time.Second): + } + + // Sort the messages for the test + sort.SliceStable(expectedMessages, func(i, j int) bool { + return string(expectedMessages[i].Sender) < string(expectedMessages[j].Sender) + }) + + // Sort the messages for the test + sort.SliceStable(messages, func(i, j int) bool { + return string(messages[i].Sender) < string(messages[j].Sender) + }) + + // Make sure the messages match + assert.Equal(t, expectedMessages, messages) + }) + + t.Run("subscribe with no pre-existing messages", func(t *testing.T) { + t.Parallel() + + var ( + count = 100 + view = &types.View{ + Height: 1, + Round: 0, + } + ) + + // Create the collector + c := NewCollector[types.PrevoteMessage]() + + generatedMessages := generatePrevoteMessages( + t, + count, + view, + ) + + expectedMessages := make([]*types.PrevoteMessage, 0, count) + + // Create a subscription + notifyCh, unsubscribeFn := c.Subscribe() + defer unsubscribeFn() + + for _, prevote := range generatedMessages { + c.AddMessage(prevote.View, prevote.Sender, prevote) + + expectedMessages = append(expectedMessages, prevote) + } + + var ( + messages []*types.PrevoteMessage + + wg sync.WaitGroup + ) + + wg.Add(1) + + go func() { + defer wg.Done() + + select { + case callback := <-notifyCh: + messages = callback() + case <-time.After(5 * time.Second): + } + }() + + wg.Wait() + + // Sort the messages for the test + sort.SliceStable(expectedMessages, func(i, j int) bool { + return string(expectedMessages[i].Sender) < string(expectedMessages[j].Sender) + }) + + // Sort the messages for the test + sort.SliceStable(messages, func(i, j int) bool { + return string(messages[i].Sender) < string(messages[j].Sender) + }) + + // Make sure the messages match + assert.Equal(t, expectedMessages, messages) + }) +} + +func TestCollector_DropMessages(t *testing.T) { + t.Parallel() + + var ( + count = 5 + view = &types.View{ + Height: 10, + Round: 5, + } + earlierView = &types.View{ + Height: view.Height, + Round: view.Round - 1, + } + ) + + // Create the collector + c := NewCollector[types.PrevoteMessage]() + + // Generate latest round messages + latestRoundMessages := generatePrevoteMessages( + t, + count, + view, + ) + + // Generate earlier round messages + earlierRoundMessages := generatePrevoteMessages( + t, + count, + earlierView, + ) + + for _, message := range latestRoundMessages { + c.AddMessage(message.GetView(), message.GetSender(), message) + } + + for _, message := range earlierRoundMessages { + c.AddMessage(message.GetView(), message.GetSender(), message) + } + + // Drop the older messages + c.DropMessages(view) + + // Make sure the messages were dropped + fetchedMessages := c.GetMessages() + + require.Len(t, fetchedMessages, len(latestRoundMessages)) + assert.ElementsMatch(t, fetchedMessages, latestRoundMessages) +} diff --git a/tm2/pkg/libtm/messages/subscription.go b/tm2/pkg/libtm/messages/subscription.go new file mode 100644 index 00000000000..c3d3c23333d --- /dev/null +++ b/tm2/pkg/libtm/messages/subscription.go @@ -0,0 +1,57 @@ +package messages + +import "github.com/rs/xid" + +type ( + // MsgCallback is the callback that returns all given messages + MsgCallback[T msgType] func() []*T + + // subscriptions is the subscription store, + // maps subscription id -> notification channel. + // Usage of this type is NOT thread safe + subscriptions[T msgType] map[string]chan func() []*T +) + +// add adds a new subscription to the subscription map. +// Returns the subscription ID, and update channel +func (s *subscriptions[T]) add() (string, chan func() []*T) { + var ( + id = xid.New().String() + ch = make(chan func() []*T, 1) + ) + + (*s)[id] = ch + + return id, ch +} + +// remove removes the given subscription +func (s *subscriptions[T]) remove(id string) { + if ch := (*s)[id]; ch != nil { + // Close the notification channel + close(ch) + } + + // Delete the subscription + delete(*s, id) +} + +// notify notifies all subscription listeners +func (s *subscriptions[T]) notify(callback func() []*T) { + // Notify the listeners + for _, ch := range *s { + notifySubscription(ch, callback) + } +} + +// notifySubscription alerts the notification channel +// about a callback. This function is pure syntactic sugar +func notifySubscription[T msgType]( + ch chan func() []*T, + callback MsgCallback[T], +) { + select { + case ch <- callback: + default: + } +} diff --git a/tm2/pkg/libtm/messages/types/messages.go b/tm2/pkg/libtm/messages/types/messages.go new file mode 100644 index 00000000000..d43e0be8a27 --- /dev/null +++ b/tm2/pkg/libtm/messages/types/messages.go @@ -0,0 +1,208 @@ +package types + +import ( + "bytes" + "errors" + + "google.golang.org/protobuf/proto" +) + +var ( + ErrInvalidMessageView = errors.New("invalid message view") + ErrInvalidMessageSender = errors.New("invalid message sender") + ErrInvalidMessageProposal = errors.New("invalid message proposal") + ErrInvalidMessageProposalRound = errors.New("invalid message proposal round") +) + +func (v *View) Equals(view *View) bool { + if v.GetHeight() != view.GetHeight() { + return false + } + + return v.GetRound() == view.GetRound() +} + +// GetSignaturePayload returns the sign payload for the proposal message +func (m *ProposalMessage) GetSignaturePayload() []byte { + //nolint:errcheck // No need to verify the error + raw, _ := proto.Marshal(&ProposalMessage{ + View: m.View, + Sender: m.Sender, + Proposal: m.Proposal, + ProposalRound: m.ProposalRound, + }) + + return raw +} + +// Marshal returns the marshalled message +func (m *ProposalMessage) Marshal() []byte { + //nolint:errcheck // No need to verify the error + raw, _ := proto.Marshal(&ProposalMessage{ + View: m.View, + Sender: m.Sender, + Signature: m.Signature, + Proposal: m.Proposal, + ProposalRound: m.ProposalRound, + }) + + return raw +} + +// Verify validates that the given message is valid +func (m *ProposalMessage) Verify() error { + // Make sure the view is present + if m.View == nil { + return ErrInvalidMessageView + } + + // Make sure the sender is present + if m.Sender == nil { + return ErrInvalidMessageSender + } + + // Make sure the proposal is present + if m.Proposal == nil { + return ErrInvalidMessageProposal + } + + // Make sure the proposal round is + // for a good round value + if m.ProposalRound < -1 { + return ErrInvalidMessageProposalRound + } + + return nil +} + +func (m *ProposalMessage) Equals(message *ProposalMessage) bool { + if !m.GetView().Equals(message.GetView()) { + return false + } + + if !bytes.Equal(m.GetSender(), message.GetSender()) { + return false + } + + if !bytes.Equal(m.GetSignature(), message.GetSignature()) { + return false + } + + if !bytes.Equal(m.GetProposal(), message.GetProposal()) { + return false + } + + return m.GetProposalRound() == message.GetProposalRound() +} + +// GetSignaturePayload returns the sign payload for the proposal message +func (m *PrevoteMessage) GetSignaturePayload() []byte { + //nolint:errcheck // No need to verify the error + raw, _ := proto.Marshal(&PrevoteMessage{ + View: m.View, + Sender: m.Sender, + Identifier: m.Identifier, + }) + + return raw +} + +// Marshal returns the marshalled message +func (m *PrevoteMessage) Marshal() []byte { + //nolint:errcheck // No need to verify the error + raw, _ := proto.Marshal(&PrevoteMessage{ + View: m.View, + Sender: m.Sender, + Signature: m.Signature, + Identifier: m.Identifier, + }) + + return raw +} + +// Verify validates that the given message is valid +func (m *PrevoteMessage) Verify() error { + // Make sure the view is present + if m.View == nil { + return ErrInvalidMessageView + } + + // Make sure the sender is present + if m.Sender == nil { + return ErrInvalidMessageSender + } + + return nil +} + +func (m *PrevoteMessage) Equals(message *PrevoteMessage) bool { + if !m.GetView().Equals(message.GetView()) { + return false + } + + if !bytes.Equal(m.GetSender(), message.GetSender()) { + return false + } + + if !bytes.Equal(m.GetSignature(), message.GetSignature()) { + return false + } + + return bytes.Equal(m.GetIdentifier(), message.GetIdentifier()) +} + +// GetSignaturePayload returns the sign payload for the proposal message +func (m *PrecommitMessage) GetSignaturePayload() []byte { + //nolint:errcheck // No need to verify the error + raw, _ := proto.Marshal(&PrecommitMessage{ + View: m.View, + Sender: m.Sender, + Identifier: m.Identifier, + }) + + return raw +} + +// Marshal returns the marshalled message +func (m *PrecommitMessage) Marshal() []byte { + //nolint:errcheck // No need to verify the error + raw, _ := proto.Marshal(&PrecommitMessage{ + View: m.View, + Sender: m.Sender, + Signature: m.Signature, + Identifier: m.Identifier, + }) + + return raw +} + +// Verify validates that the given message is valid +func (m *PrecommitMessage) Verify() error { + // Make sure the view is present + if m.View == nil { + return ErrInvalidMessageView + } + + // Make sure the sender is present + if m.Sender == nil { + return ErrInvalidMessageSender + } + + return nil +} + +func (m *PrecommitMessage) Equals(message *PrecommitMessage) bool { + if !m.GetView().Equals(message.GetView()) { + return false + } + + if !bytes.Equal(m.GetSender(), message.GetSender()) { + return false + } + + if !bytes.Equal(m.GetSignature(), message.GetSignature()) { + return false + } + + return bytes.Equal(m.GetIdentifier(), message.GetIdentifier()) +} diff --git a/tm2/pkg/libtm/messages/types/messages.pb.go b/tm2/pkg/libtm/messages/types/messages.pb.go new file mode 100644 index 00000000000..daa70cb84de --- /dev/null +++ b/tm2/pkg/libtm/messages/types/messages.pb.go @@ -0,0 +1,542 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.28.1 +// protoc v4.25.3 +// source: messages/types/proto/messages.proto + +package types + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// MessageType defines the types of messages +// that are related to the consensus process +type MessageType int32 + +const ( + MessageType_PROPOSAL MessageType = 0 + MessageType_PREVOTE MessageType = 1 + MessageType_PRECOMMIT MessageType = 2 +) + +// Enum value maps for MessageType. +var ( + MessageType_name = map[int32]string{ + 0: "PROPOSAL", + 1: "PREVOTE", + 2: "PRECOMMIT", + } + MessageType_value = map[string]int32{ + "PROPOSAL": 0, + "PREVOTE": 1, + "PRECOMMIT": 2, + } +) + +func (x MessageType) Enum() *MessageType { + p := new(MessageType) + *p = x + return p +} + +func (x MessageType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (MessageType) Descriptor() protoreflect.EnumDescriptor { + return file_messages_types_proto_messages_proto_enumTypes[0].Descriptor() +} + +func (MessageType) Type() protoreflect.EnumType { + return &file_messages_types_proto_messages_proto_enumTypes[0] +} + +func (x MessageType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use MessageType.Descriptor instead. +func (MessageType) EnumDescriptor() ([]byte, []int) { + return file_messages_types_proto_messages_proto_rawDescGZIP(), []int{0} +} + +// View is the consensus state associated with the message +type View struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // height represents the number of the proposal + Height uint64 `protobuf:"varint,1,opt,name=height,proto3" json:"height,omitempty"` + // round represents the round number within a + // specific height (starts from 0) + Round uint64 `protobuf:"varint,2,opt,name=round,proto3" json:"round,omitempty"` +} + +func (x *View) Reset() { + *x = View{} + if protoimpl.UnsafeEnabled { + mi := &file_messages_types_proto_messages_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *View) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*View) ProtoMessage() {} + +func (x *View) ProtoReflect() protoreflect.Message { + mi := &file_messages_types_proto_messages_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use View.ProtoReflect.Descriptor instead. +func (*View) Descriptor() ([]byte, []int) { + return file_messages_types_proto_messages_proto_rawDescGZIP(), []int{0} +} + +func (x *View) GetHeight() uint64 { + if x != nil { + return x.Height + } + return 0 +} + +func (x *View) GetRound() uint64 { + if x != nil { + return x.Round + } + return 0 +} + +// ProposalMessage is the message containing +// the consensus proposal for the view +// +type ProposalMessage struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // view is the current view for the message + // (the view in which the message was sent) + View *View `protobuf:"bytes,1,opt,name=view,proto3" json:"view,omitempty"` + // sender is the message sender (unique identifier) + Sender []byte `protobuf:"bytes,2,opt,name=sender,proto3" json:"sender,omitempty"` + // signature is the message signature of the sender + Signature []byte `protobuf:"bytes,3,opt,name=signature,proto3" json:"signature,omitempty"` + // proposal is the actual consensus proposal + Proposal []byte `protobuf:"bytes,4,opt,name=proposal,proto3" json:"proposal,omitempty"` + // proposalRound is the round associated with the + // proposal in the PROPOSE message. + // NOTE: this round value DOES NOT have + // to match the message view (proposal from an earlier round) + ProposalRound int64 `protobuf:"varint,5,opt,name=proposalRound,proto3" json:"proposalRound,omitempty"` +} + +func (x *ProposalMessage) Reset() { + *x = ProposalMessage{} + if protoimpl.UnsafeEnabled { + mi := &file_messages_types_proto_messages_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ProposalMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProposalMessage) ProtoMessage() {} + +func (x *ProposalMessage) ProtoReflect() protoreflect.Message { + mi := &file_messages_types_proto_messages_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProposalMessage.ProtoReflect.Descriptor instead. +func (*ProposalMessage) Descriptor() ([]byte, []int) { + return file_messages_types_proto_messages_proto_rawDescGZIP(), []int{1} +} + +func (x *ProposalMessage) GetView() *View { + if x != nil { + return x.View + } + return nil +} + +func (x *ProposalMessage) GetSender() []byte { + if x != nil { + return x.Sender + } + return nil +} + +func (x *ProposalMessage) GetSignature() []byte { + if x != nil { + return x.Signature + } + return nil +} + +func (x *ProposalMessage) GetProposal() []byte { + if x != nil { + return x.Proposal + } + return nil +} + +func (x *ProposalMessage) GetProposalRound() int64 { + if x != nil { + return x.ProposalRound + } + return 0 +} + +// PrevoteMessage is the message +// containing the consensus proposal prevote. +// The prevote message is pretty light, +// apart from containing the view, it just +// contains a unique identifier of the proposal +// for which this prevote is meant for (ex. proposal hash) +// +type PrevoteMessage struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // view is the current view for the message + // (the view in which the message was sent) + View *View `protobuf:"bytes,1,opt,name=view,proto3" json:"view,omitempty"` + // sender is the message sender (unique identifier) + Sender []byte `protobuf:"bytes,2,opt,name=sender,proto3" json:"sender,omitempty"` + // signature is the message signature of the sender + Signature []byte `protobuf:"bytes,3,opt,name=signature,proto3" json:"signature,omitempty"` + // identifier is the unique identifier for + // the proposal associated with this + // prevote message (ex. proposal hash) + Identifier []byte `protobuf:"bytes,4,opt,name=identifier,proto3" json:"identifier,omitempty"` +} + +func (x *PrevoteMessage) Reset() { + *x = PrevoteMessage{} + if protoimpl.UnsafeEnabled { + mi := &file_messages_types_proto_messages_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *PrevoteMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PrevoteMessage) ProtoMessage() {} + +func (x *PrevoteMessage) ProtoReflect() protoreflect.Message { + mi := &file_messages_types_proto_messages_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PrevoteMessage.ProtoReflect.Descriptor instead. +func (*PrevoteMessage) Descriptor() ([]byte, []int) { + return file_messages_types_proto_messages_proto_rawDescGZIP(), []int{2} +} + +func (x *PrevoteMessage) GetView() *View { + if x != nil { + return x.View + } + return nil +} + +func (x *PrevoteMessage) GetSender() []byte { + if x != nil { + return x.Sender + } + return nil +} + +func (x *PrevoteMessage) GetSignature() []byte { + if x != nil { + return x.Signature + } + return nil +} + +func (x *PrevoteMessage) GetIdentifier() []byte { + if x != nil { + return x.Identifier + } + return nil +} + +// PrecommitMessage is the message +// containing the consensus proposal precommit. +// The precommit message, same as the prevote message, +// contains a unique identifier for the proposal +// for which this precommit is meant for (ex. proposal hash) +// +type PrecommitMessage struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // view is the current view for the message + // (the view in which the message was sent) + View *View `protobuf:"bytes,1,opt,name=view,proto3" json:"view,omitempty"` + // sender is the message sender (unique identifier) + Sender []byte `protobuf:"bytes,2,opt,name=sender,proto3" json:"sender,omitempty"` + // signature is the message signature of the sender + Signature []byte `protobuf:"bytes,3,opt,name=signature,proto3" json:"signature,omitempty"` + // identifier is the unique identifier for + // the proposal associated with this + // precommit message (ex. proposal hash) + Identifier []byte `protobuf:"bytes,4,opt,name=identifier,proto3" json:"identifier,omitempty"` +} + +func (x *PrecommitMessage) Reset() { + *x = PrecommitMessage{} + if protoimpl.UnsafeEnabled { + mi := &file_messages_types_proto_messages_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *PrecommitMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PrecommitMessage) ProtoMessage() {} + +func (x *PrecommitMessage) ProtoReflect() protoreflect.Message { + mi := &file_messages_types_proto_messages_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PrecommitMessage.ProtoReflect.Descriptor instead. +func (*PrecommitMessage) Descriptor() ([]byte, []int) { + return file_messages_types_proto_messages_proto_rawDescGZIP(), []int{3} +} + +func (x *PrecommitMessage) GetView() *View { + if x != nil { + return x.View + } + return nil +} + +func (x *PrecommitMessage) GetSender() []byte { + if x != nil { + return x.Sender + } + return nil +} + +func (x *PrecommitMessage) GetSignature() []byte { + if x != nil { + return x.Signature + } + return nil +} + +func (x *PrecommitMessage) GetIdentifier() []byte { + if x != nil { + return x.Identifier + } + return nil +} + +var File_messages_types_proto_messages_proto protoreflect.FileDescriptor + +var file_messages_types_proto_messages_proto_rawDesc = []byte{ + 0x0a, 0x23, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2f, 0x74, 0x79, 0x70, 0x65, 0x73, + 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x34, 0x0a, 0x04, 0x56, 0x69, 0x65, 0x77, 0x12, 0x16, 0x0a, + 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x68, + 0x65, 0x69, 0x67, 0x68, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x72, 0x6f, 0x75, 0x6e, 0x64, 0x22, 0xa4, 0x01, 0x0a, 0x0f, + 0x50, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x61, 0x6c, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, + 0x19, 0x0a, 0x04, 0x76, 0x69, 0x65, 0x77, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x05, 0x2e, + 0x56, 0x69, 0x65, 0x77, 0x52, 0x04, 0x76, 0x69, 0x65, 0x77, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, + 0x6e, 0x64, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x73, 0x65, 0x6e, 0x64, + 0x65, 0x72, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, + 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x0c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x61, 0x6c, 0x12, 0x24, 0x0a, 0x0d, + 0x70, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x61, 0x6c, 0x52, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x0d, 0x70, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x61, 0x6c, 0x52, 0x6f, 0x75, + 0x6e, 0x64, 0x22, 0x81, 0x01, 0x0a, 0x0e, 0x50, 0x72, 0x65, 0x76, 0x6f, 0x74, 0x65, 0x4d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x19, 0x0a, 0x04, 0x76, 0x69, 0x65, 0x77, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x05, 0x2e, 0x56, 0x69, 0x65, 0x77, 0x52, 0x04, 0x76, 0x69, 0x65, 0x77, + 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x06, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x69, 0x67, 0x6e, + 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x69, 0x67, + 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, + 0x66, 0x69, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x69, 0x64, 0x65, 0x6e, + 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x22, 0x83, 0x01, 0x0a, 0x10, 0x50, 0x72, 0x65, 0x63, 0x6f, + 0x6d, 0x6d, 0x69, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x19, 0x0a, 0x04, 0x76, + 0x69, 0x65, 0x77, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x05, 0x2e, 0x56, 0x69, 0x65, 0x77, + 0x52, 0x04, 0x76, 0x69, 0x65, 0x77, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x12, 0x1c, + 0x0a, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x1e, 0x0a, 0x0a, + 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x0a, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x2a, 0x37, 0x0a, 0x0b, + 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0c, 0x0a, 0x08, 0x50, + 0x52, 0x4f, 0x50, 0x4f, 0x53, 0x41, 0x4c, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x50, 0x52, 0x45, + 0x56, 0x4f, 0x54, 0x45, 0x10, 0x01, 0x12, 0x0d, 0x0a, 0x09, 0x50, 0x52, 0x45, 0x43, 0x4f, 0x4d, + 0x4d, 0x49, 0x54, 0x10, 0x02, 0x42, 0x11, 0x5a, 0x0f, 0x2f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x73, 0x2f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_messages_types_proto_messages_proto_rawDescOnce sync.Once + file_messages_types_proto_messages_proto_rawDescData = file_messages_types_proto_messages_proto_rawDesc +) + +func file_messages_types_proto_messages_proto_rawDescGZIP() []byte { + file_messages_types_proto_messages_proto_rawDescOnce.Do(func() { + file_messages_types_proto_messages_proto_rawDescData = protoimpl.X.CompressGZIP(file_messages_types_proto_messages_proto_rawDescData) + }) + return file_messages_types_proto_messages_proto_rawDescData +} + +var file_messages_types_proto_messages_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_messages_types_proto_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_messages_types_proto_messages_proto_goTypes = []interface{}{ + (MessageType)(0), // 0: MessageType + (*View)(nil), // 1: View + (*ProposalMessage)(nil), // 2: ProposalMessage + (*PrevoteMessage)(nil), // 3: PrevoteMessage + (*PrecommitMessage)(nil), // 4: PrecommitMessage +} +var file_messages_types_proto_messages_proto_depIdxs = []int32{ + 1, // 0: ProposalMessage.view:type_name -> View + 1, // 1: PrevoteMessage.view:type_name -> View + 1, // 2: PrecommitMessage.view:type_name -> View + 3, // [3:3] is the sub-list for method output_type + 3, // [3:3] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_messages_types_proto_messages_proto_init() } +func file_messages_types_proto_messages_proto_init() { + if File_messages_types_proto_messages_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_messages_types_proto_messages_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*View); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_messages_types_proto_messages_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ProposalMessage); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_messages_types_proto_messages_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*PrevoteMessage); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_messages_types_proto_messages_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*PrecommitMessage); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_messages_types_proto_messages_proto_rawDesc, + NumEnums: 1, + NumMessages: 4, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_messages_types_proto_messages_proto_goTypes, + DependencyIndexes: file_messages_types_proto_messages_proto_depIdxs, + EnumInfos: file_messages_types_proto_messages_proto_enumTypes, + MessageInfos: file_messages_types_proto_messages_proto_msgTypes, + }.Build() + File_messages_types_proto_messages_proto = out.File + file_messages_types_proto_messages_proto_rawDesc = nil + file_messages_types_proto_messages_proto_goTypes = nil + file_messages_types_proto_messages_proto_depIdxs = nil +} diff --git a/tm2/pkg/libtm/messages/types/messages_test.go b/tm2/pkg/libtm/messages/types/messages_test.go new file mode 100644 index 00000000000..516b5436098 --- /dev/null +++ b/tm2/pkg/libtm/messages/types/messages_test.go @@ -0,0 +1,872 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" +) + +func TestProposalMessage_GetSignaturePayload(t *testing.T) { + t.Parallel() + + // Create the proposal message + m := &ProposalMessage{ + View: &View{ + Height: 10, + Round: 10, + }, + Sender: []byte("sender"), + Signature: []byte("signature"), + Proposal: []byte("proposal"), + ProposalRound: 0, + } + + // Get the signature payload + payload := m.GetSignaturePayload() + + var raw ProposalMessage + + require.NoError(t, proto.Unmarshal(payload, &raw)) + + // Make sure the signature was not marshalled + assert.Nil(t, raw.Signature) + + // Make sure other fields are intact + assert.Equal(t, m.GetView().GetHeight(), raw.GetView().GetHeight()) + assert.Equal(t, m.GetView().GetRound(), raw.GetView().GetRound()) + assert.Equal(t, m.GetSender(), raw.GetSender()) + assert.Equal(t, m.GetProposal(), raw.GetProposal()) + assert.Equal(t, m.GetProposalRound(), raw.GetProposalRound()) +} + +func TestProposalMessage_Marshal(t *testing.T) { + t.Parallel() + + // Create the proposal message + m := &ProposalMessage{ + View: &View{ + Height: 10, + Round: 10, + }, + Sender: []byte("sender"), + Signature: []byte("signature"), + Proposal: []byte("proposal"), + ProposalRound: 0, + } + + // Marshal the message + marshalled := m.Marshal() + + var raw ProposalMessage + + require.NoError(t, proto.Unmarshal(marshalled, &raw)) + + // Make sure other fields are intact + assert.Equal(t, m.GetView().GetHeight(), raw.GetView().GetHeight()) + assert.Equal(t, m.GetView().GetRound(), raw.GetView().GetRound()) + assert.Equal(t, m.GetSender(), raw.GetSender()) + assert.Equal(t, m.GetSignature(), raw.GetSignature()) + assert.Equal(t, m.GetProposal(), raw.GetProposal()) + assert.Equal(t, m.GetProposalRound(), raw.GetProposalRound()) +} + +func TestProposalMessage_Verify(t *testing.T) { + t.Parallel() + + t.Run("invalid view", func(t *testing.T) { + t.Parallel() + + m := &ProposalMessage{ + View: nil, + } + + assert.ErrorIs(t, m.Verify(), ErrInvalidMessageView) + }) + + t.Run("invalid sender", func(t *testing.T) { + t.Parallel() + + m := &ProposalMessage{ + View: &View{}, + Sender: nil, + } + + assert.ErrorIs(t, m.Verify(), ErrInvalidMessageSender) + }) + + t.Run("invalid proposal", func(t *testing.T) { + t.Parallel() + + m := &ProposalMessage{ + View: &View{}, + Sender: []byte{}, + Proposal: nil, + } + + assert.ErrorIs(t, m.Verify(), ErrInvalidMessageProposal) + }) + + t.Run("invalid proposal round", func(t *testing.T) { + t.Parallel() + + m := &ProposalMessage{ + View: &View{}, + Sender: []byte{}, + Proposal: []byte{}, + ProposalRound: -2, + } + + assert.ErrorIs(t, m.Verify(), ErrInvalidMessageProposalRound) + }) + + t.Run("valid proposal message", func(t *testing.T) { + t.Parallel() + + m := &ProposalMessage{ + View: &View{ + Height: 1, + Round: 0, + }, + Sender: []byte("sender"), + Proposal: []byte("proposal"), + ProposalRound: -1, + } + + assert.NoError(t, m.Verify()) + }) +} + +func TestPrevoteMessage_GetSignaturePayload(t *testing.T) { + t.Parallel() + + // Create the proposal message + m := &PrevoteMessage{ + View: &View{ + Height: 10, + Round: 10, + }, + Sender: []byte("sender"), + Signature: []byte("signature"), + } + + // Get the signature payload + payload := m.GetSignaturePayload() + + var raw PrevoteMessage + + require.NoError(t, proto.Unmarshal(payload, &raw)) + + // Make sure the signature was not marshalled + assert.Nil(t, raw.Signature) + + // Make sure other fields are intact + assert.Equal(t, m.GetView().GetHeight(), raw.GetView().GetHeight()) + assert.Equal(t, m.GetView().GetRound(), raw.GetView().GetRound()) + assert.Equal(t, m.GetSender(), raw.GetSender()) +} + +func TestPrevoteMessage_Marshal(t *testing.T) { + t.Parallel() + + // Create the proposal message + m := &PrevoteMessage{ + View: &View{ + Height: 10, + Round: 10, + }, + Sender: []byte("sender"), + Signature: []byte("signature"), + } + + // Marshal the message + marshalled := m.Marshal() + + var raw PrevoteMessage + + require.NoError(t, proto.Unmarshal(marshalled, &raw)) + + // Make sure other fields are intact + assert.Equal(t, m.GetView().GetHeight(), raw.GetView().GetHeight()) + assert.Equal(t, m.GetView().GetRound(), raw.GetView().GetRound()) + assert.Equal(t, m.GetSender(), raw.GetSender()) + assert.Equal(t, m.GetSignature(), raw.GetSignature()) +} + +func TestPrevoteMessage_Verify(t *testing.T) { + t.Parallel() + + t.Run("invalid view", func(t *testing.T) { + t.Parallel() + + m := &PrevoteMessage{ + View: nil, + } + + assert.ErrorIs(t, m.Verify(), ErrInvalidMessageView) + }) + + t.Run("invalid sender", func(t *testing.T) { + t.Parallel() + + m := &PrevoteMessage{ + View: &View{}, + Sender: nil, + } + + assert.ErrorIs(t, m.Verify(), ErrInvalidMessageSender) + }) +} + +func TestPrecommitMessage_GetSignaturePayload(t *testing.T) { + t.Parallel() + + // Create the proposal message + m := &PrecommitMessage{ + View: &View{ + Height: 10, + Round: 10, + }, + Sender: []byte("sender"), + Signature: []byte("signature"), + } + + // Get the signature payload + payload := m.GetSignaturePayload() + + var raw PrecommitMessage + + require.NoError(t, proto.Unmarshal(payload, &raw)) + + // Make sure the signature was not marshalled + assert.Nil(t, raw.Signature) + + // Make sure other fields are intact + assert.Equal(t, m.GetView().GetHeight(), raw.GetView().GetHeight()) + assert.Equal(t, m.GetView().GetRound(), raw.GetView().GetRound()) + assert.Equal(t, m.GetSender(), raw.GetSender()) +} + +func TestPrecommitMessage_Marshal(t *testing.T) { + t.Parallel() + + // Create the proposal message + m := &PrecommitMessage{ + View: &View{ + Height: 10, + Round: 10, + }, + Sender: []byte("sender"), + Signature: []byte("signature"), + } + + // Marshal the message + marshalled := m.Marshal() + + var raw PrecommitMessage + + require.NoError(t, proto.Unmarshal(marshalled, &raw)) + + // Make sure other fields are intact + assert.Equal(t, m.GetView().GetHeight(), raw.GetView().GetHeight()) + assert.Equal(t, m.GetView().GetRound(), raw.GetView().GetRound()) + assert.Equal(t, m.GetSender(), raw.GetSender()) + assert.Equal(t, m.GetSignature(), raw.GetSignature()) +} + +func TestPrecommitMessage_Verify(t *testing.T) { + t.Parallel() + + t.Run("invalid view", func(t *testing.T) { + t.Parallel() + + m := &PrecommitMessage{ + View: nil, + } + + assert.ErrorIs(t, m.Verify(), ErrInvalidMessageView) + }) + + t.Run("invalid sender", func(t *testing.T) { + t.Parallel() + + m := &PrecommitMessage{ + View: &View{}, + Sender: nil, + } + + assert.ErrorIs(t, m.Verify(), ErrInvalidMessageSender) + }) +} + +func TestView_Equals(t *testing.T) { + t.Parallel() + + testTable := []struct { + name string + views []*View + shouldEqual bool + }{ + { + "equal views", + []*View{ + { + Height: 10, + Round: 10, + }, + { + Height: 10, + Round: 10, + }, + }, + true, + }, + { + "not equal views", + []*View{ + { + Height: 10, + Round: 10, + }, + { + Height: 10, + Round: 5, // different round + }, + }, + false, + }, + } + + for _, testCase := range testTable { + testCase := testCase + + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + assert.Equal( + t, + testCase.shouldEqual, + testCase.views[0].Equals(testCase.views[1]), + ) + }) + } +} + +func TestProposalMessage_Equals(t *testing.T) { + t.Parallel() + + t.Run("equal proposal messages", func(t *testing.T) { + t.Parallel() + + var ( + view = &View{ + Height: 10, + Round: 0, + } + sender = []byte("sender") + signature = []byte("signature") + proposal = []byte("proposal") + proposalRound = int64(-1) + + left = &ProposalMessage{ + View: view, + Sender: sender, + Signature: signature, + Proposal: proposal, + ProposalRound: proposalRound, + } + + right = &ProposalMessage{ + View: view, + Sender: sender, + Signature: signature, + Proposal: proposal, + ProposalRound: proposalRound, + } + ) + + assert.True(t, left.Equals(right)) + }) + + t.Run("view mismatch", func(t *testing.T) { + t.Parallel() + + var ( + view = &View{ + Height: 10, + Round: 0, + } + sender = []byte("sender") + signature = []byte("signature") + proposal = []byte("proposal") + proposalRound = int64(-1) + + left = &ProposalMessage{ + View: view, + Sender: sender, + Signature: signature, + Proposal: proposal, + ProposalRound: proposalRound, + } + + right = &ProposalMessage{ + View: &View{ + Height: view.Height, + Round: view.Round + 1, // round mismatch + }, Sender: sender, + Signature: signature, + Proposal: proposal, + ProposalRound: proposalRound, + } + ) + + assert.False(t, left.Equals(right)) + }) + + t.Run("sender mismatch", func(t *testing.T) { + t.Parallel() + + var ( + view = &View{ + Height: 10, + Round: 0, + } + sender = []byte("sender") + signature = []byte("signature") + proposal = []byte("proposal") + proposalRound = int64(-1) + + left = &ProposalMessage{ + View: view, + Sender: sender, + Signature: signature, + Proposal: proposal, + ProposalRound: proposalRound, + } + + right = &ProposalMessage{ + View: view, + Sender: []byte("different sender"), // sender mismatch + Signature: signature, + Proposal: proposal, + ProposalRound: proposalRound, + } + ) + + assert.False(t, left.Equals(right)) + }) + + t.Run("signature mismatch", func(t *testing.T) { + t.Parallel() + + var ( + view = &View{ + Height: 10, + Round: 0, + } + sender = []byte("sender") + signature = []byte("signature") + proposal = []byte("proposal") + proposalRound = int64(-1) + + left = &ProposalMessage{ + View: view, + Sender: sender, + Signature: signature, + Proposal: proposal, + ProposalRound: proposalRound, + } + + right = &ProposalMessage{ + View: view, + Sender: sender, + Signature: []byte("different signature"), // signature mismatch + Proposal: proposal, + ProposalRound: proposalRound, + } + ) + + assert.False(t, left.Equals(right)) + }) + + t.Run("proposal mismatch", func(t *testing.T) { + t.Parallel() + + var ( + view = &View{ + Height: 10, + Round: 0, + } + sender = []byte("sender") + signature = []byte("signature") + proposal = []byte("proposal") + proposalRound = int64(-1) + + left = &ProposalMessage{ + View: view, + Sender: sender, + Signature: signature, + Proposal: proposal, + ProposalRound: proposalRound, + } + + right = &ProposalMessage{ + View: view, + Sender: sender, + Signature: signature, + Proposal: []byte("different proposal"), // proposal mismatch + ProposalRound: proposalRound, + } + ) + + assert.False(t, left.Equals(right)) + }) + + t.Run("proposal round mismatch", func(t *testing.T) { + t.Parallel() + + var ( + view = &View{ + Height: 10, + Round: 0, + } + sender = []byte("sender") + signature = []byte("signature") + proposal = []byte("proposal") + proposalRound = int64(-1) + + left = &ProposalMessage{ + View: view, + Sender: sender, + Signature: signature, + Proposal: proposal, + ProposalRound: proposalRound, + } + + right = &ProposalMessage{ + View: view, + Sender: sender, + Signature: signature, + Proposal: proposal, + ProposalRound: proposalRound + 1, // proposal round mismatch + } + ) + + assert.False(t, left.Equals(right)) + }) +} + +func TestPrevoteMessage_Equals(t *testing.T) { + t.Parallel() + + t.Run("equal prevote messages", func(t *testing.T) { + t.Parallel() + + var ( + view = &View{ + Height: 10, + Round: 0, + } + sender = []byte("sender") + signature = []byte("signature") + id = []byte("id") + + left = &PrevoteMessage{ + View: view, + Sender: sender, + Signature: signature, + Identifier: id, + } + + right = &PrevoteMessage{ + View: view, + Sender: sender, + Signature: signature, + Identifier: id, + } + ) + + assert.True(t, left.Equals(right)) + }) + + t.Run("view mismatch", func(t *testing.T) { + t.Parallel() + + var ( + view = &View{ + Height: 10, + Round: 0, + } + sender = []byte("sender") + signature = []byte("signature") + id = []byte("id") + + left = &PrevoteMessage{ + View: view, + Sender: sender, + Signature: signature, + Identifier: id, + } + + right = &PrevoteMessage{ + View: &View{ + Height: view.Height, + Round: view.Round + 1, // round mismatch + }, + Sender: sender, + Signature: signature, + Identifier: id, + } + ) + + assert.False(t, left.Equals(right)) + }) + + t.Run("sender mismatch", func(t *testing.T) { + t.Parallel() + + var ( + view = &View{ + Height: 10, + Round: 0, + } + sender = []byte("sender") + signature = []byte("signature") + id = []byte("id") + + left = &PrevoteMessage{ + View: view, + Sender: sender, + Signature: signature, + Identifier: id, + } + + right = &PrevoteMessage{ + View: view, + Sender: []byte("different sender"), // sender mismatch + Signature: signature, + Identifier: id, + } + ) + + assert.False(t, left.Equals(right)) + }) + + t.Run("signature mismatch", func(t *testing.T) { + t.Parallel() + + var ( + view = &View{ + Height: 10, + Round: 0, + } + sender = []byte("sender") + signature = []byte("signature") + id = []byte("id") + + left = &PrevoteMessage{ + View: view, + Sender: sender, + Signature: signature, + Identifier: id, + } + + right = &PrevoteMessage{ + View: view, + Sender: sender, + Signature: []byte("different signature"), // signature mismatch + Identifier: id, + } + ) + + assert.False(t, left.Equals(right)) + }) + + t.Run("identifier mismatch", func(t *testing.T) { + t.Parallel() + + var ( + view = &View{ + Height: 10, + Round: 0, + } + sender = []byte("sender") + signature = []byte("signature") + id = []byte("id") + + left = &PrevoteMessage{ + View: view, + Sender: sender, + Signature: signature, + Identifier: id, + } + + right = &PrevoteMessage{ + View: view, + Sender: sender, + Signature: signature, + Identifier: []byte("different identifier"), // identifier mismatch + } + ) + + assert.False(t, left.Equals(right)) + }) +} + +func TestPrecommitMessage_Equals(t *testing.T) { + t.Parallel() + + t.Run("equal precommit messages", func(t *testing.T) { + t.Parallel() + + var ( + view = &View{ + Height: 10, + Round: 0, + } + sender = []byte("sender") + signature = []byte("signature") + id = []byte("id") + + left = &PrecommitMessage{ + View: view, + Sender: sender, + Signature: signature, + Identifier: id, + } + + right = &PrecommitMessage{ + View: view, + Sender: sender, + Signature: signature, + Identifier: id, + } + ) + + assert.True(t, left.Equals(right)) + }) + + t.Run("view mismatch", func(t *testing.T) { + t.Parallel() + + var ( + view = &View{ + Height: 10, + Round: 0, + } + sender = []byte("sender") + signature = []byte("signature") + id = []byte("id") + + left = &PrecommitMessage{ + View: view, + Sender: sender, + Signature: signature, + Identifier: id, + } + + right = &PrecommitMessage{ + View: &View{ + Height: view.Height, + Round: view.Round + 1, // round mismatch + }, + Sender: sender, + Signature: signature, + Identifier: id, + } + ) + + assert.False(t, left.Equals(right)) + }) + + t.Run("sender mismatch", func(t *testing.T) { + t.Parallel() + + var ( + view = &View{ + Height: 10, + Round: 0, + } + sender = []byte("sender") + signature = []byte("signature") + id = []byte("id") + + left = &PrecommitMessage{ + View: view, + Sender: sender, + Signature: signature, + Identifier: id, + } + + right = &PrecommitMessage{ + View: view, + Sender: []byte("different sender"), // sender mismatch + Signature: signature, + Identifier: id, + } + ) + + assert.False(t, left.Equals(right)) + }) + + t.Run("signature mismatch", func(t *testing.T) { + t.Parallel() + + var ( + view = &View{ + Height: 10, + Round: 0, + } + sender = []byte("sender") + signature = []byte("signature") + id = []byte("id") + + left = &PrecommitMessage{ + View: view, + Sender: sender, + Signature: signature, + Identifier: id, + } + + right = &PrecommitMessage{ + View: view, + Sender: sender, + Signature: []byte("different signature"), // signature mismatch + Identifier: id, + } + ) + + assert.False(t, left.Equals(right)) + }) + + t.Run("identifier mismatch", func(t *testing.T) { + t.Parallel() + + var ( + view = &View{ + Height: 10, + Round: 0, + } + sender = []byte("sender") + signature = []byte("signature") + id = []byte("id") + + left = &PrecommitMessage{ + View: view, + Sender: sender, + Signature: signature, + Identifier: id, + } + + right = &PrecommitMessage{ + View: view, + Sender: sender, + Signature: signature, + Identifier: []byte("different identifier"), // identifier mismatch + } + ) + + assert.False(t, left.Equals(right)) + }) +} diff --git a/tm2/pkg/libtm/messages/types/proto/messages.proto b/tm2/pkg/libtm/messages/types/proto/messages.proto new file mode 100644 index 00000000000..b41c0e5efe8 --- /dev/null +++ b/tm2/pkg/libtm/messages/types/proto/messages.proto @@ -0,0 +1,92 @@ +syntax = "proto3"; + +option go_package = "/messages/types"; + +// MessageType defines the types of messages +// that are related to the consensus process +enum MessageType { + PROPOSAL = 0; + PREVOTE = 1; + PRECOMMIT = 2; +} + +// View is the consensus state associated with the message +message View { + // height represents the number of the proposal + uint64 height = 1; + + // round represents the round number within a + // specific height (starts from 0) + uint64 round = 2; +} + +// ProposalMessage is the message containing +// the consensus proposal for the view +// +message ProposalMessage { + // view is the current view for the message + // (the view in which the message was sent) + View view = 1; + + // sender is the message sender (unique identifier) + bytes sender = 2; + + // signature is the message signature of the sender + bytes signature = 3; + + // proposal is the actual consensus proposal + bytes proposal = 4; + + // proposalRound is the round associated with the + // proposal in the PROPOSE message. + // NOTE: this round value DOES NOT have + // to match the message view (proposal from an earlier round) + int64 proposalRound = 5; +} + +// PrevoteMessage is the message +// containing the consensus proposal prevote. +// The prevote message is pretty light, +// apart from containing the view, it just +// contains a unique identifier of the proposal +// for which this prevote is meant for (ex. proposal hash) +// +message PrevoteMessage { + // view is the current view for the message + // (the view in which the message was sent) + View view = 1; + + // sender is the message sender (unique identifier) + bytes sender = 2; + + // signature is the message signature of the sender + bytes signature = 3; + + // identifier is the unique identifier for + // the proposal associated with this + // prevote message (ex. proposal hash) + bytes identifier = 4; +} + +// PrecommitMessage is the message +// containing the consensus proposal precommit. +// The precommit message, same as the prevote message, +// contains a unique identifier for the proposal +// for which this precommit is meant for (ex. proposal hash) +// +message PrecommitMessage { + // view is the current view for the message + // (the view in which the message was sent) + View view = 1; + + // sender is the message sender (unique identifier) + bytes sender = 2; + + // signature is the message signature of the sender + bytes signature = 3; + + // identifier is the unique identifier for + // the proposal associated with this + // precommit message (ex. proposal hash) + bytes identifier = 4; +} \ No newline at end of file