From 853799d3d3b10d852e600f3fa2ac0c2ad0acd3af Mon Sep 17 00:00:00 2001 From: tmohay <37158202+rain-on@users.noreply.github.com> Date: Tue, 11 Dec 2018 12:26:49 +1100 Subject: [PATCH] Ibft RoundState (#392) The IBFT RoundState is responsible for determining if sufficient valid messages have been received to deem the node "Prepared" or "Committed". --- .../ibft/statemachine/RoundState.java | 121 ++++++++ .../ibft/statemachine/RoundStateTest.java | 271 ++++++++++++++++++ 2 files changed, 392 insertions(+) create mode 100644 consensus/ibft/src/main/java/tech/pegasys/pantheon/consensus/ibft/statemachine/RoundState.java create mode 100644 consensus/ibft/src/test/java/tech/pegasys/pantheon/consensus/ibft/statemachine/RoundStateTest.java diff --git a/consensus/ibft/src/main/java/tech/pegasys/pantheon/consensus/ibft/statemachine/RoundState.java b/consensus/ibft/src/main/java/tech/pegasys/pantheon/consensus/ibft/statemachine/RoundState.java new file mode 100644 index 0000000000..92a72b429e --- /dev/null +++ b/consensus/ibft/src/main/java/tech/pegasys/pantheon/consensus/ibft/statemachine/RoundState.java @@ -0,0 +1,121 @@ +/* + * Copyright 2018 ConsenSys AG. + * + * 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. + */ +package tech.pegasys.pantheon.consensus.ibft.statemachine; + +import tech.pegasys.pantheon.consensus.ibft.ConsensusRoundIdentifier; +import tech.pegasys.pantheon.consensus.ibft.ibftmessagedata.CommitPayload; +import tech.pegasys.pantheon.consensus.ibft.ibftmessagedata.PreparePayload; +import tech.pegasys.pantheon.consensus.ibft.ibftmessagedata.PreparedCertificate; +import tech.pegasys.pantheon.consensus.ibft.ibftmessagedata.ProposalPayload; +import tech.pegasys.pantheon.consensus.ibft.ibftmessagedata.SignedData; +import tech.pegasys.pantheon.consensus.ibft.validation.MessageValidator; +import tech.pegasys.pantheon.crypto.SECP256K1.Signature; +import tech.pegasys.pantheon.ethereum.core.Block; + +import java.util.Collection; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import com.google.common.collect.Sets; + +// Data items used to define how a round will operate +public class RoundState { + + private final ConsensusRoundIdentifier roundIdentifier; + private final MessageValidator validator; + private final long quorumSize; + + private Optional> proposalMessage = Optional.empty(); + + // Must track the actual Prepare message, not just the sender, as these may need to be reused + // to send out in a PrepareCertificate. + private final Set> preparePayloads = Sets.newHashSet(); + private final Set> commitPayloads = Sets.newHashSet(); + + private boolean prepared = false; + private boolean committed = false; + + public RoundState( + final ConsensusRoundIdentifier roundIdentifier, + final int quorumSize, + final MessageValidator validator) { + this.roundIdentifier = roundIdentifier; + this.quorumSize = quorumSize; + this.validator = validator; + } + + public ConsensusRoundIdentifier getRoundIdentifier() { + return roundIdentifier; + } + + public boolean setProposedBlock(final SignedData msg) { + if (validator.addSignedProposalPayload(msg)) { + proposalMessage = Optional.of(msg); + } else { + return false; + } + preparePayloads.removeIf(p -> !validator.validatePrepareMessage(p)); + commitPayloads.removeIf(p -> !validator.validateCommmitMessage(p)); + updateState(); + return true; + } + + public void addPreparedPeer(final SignedData prepareMsg) { + if (!proposalMessage.isPresent() || validator.validatePrepareMessage(prepareMsg)) { + preparePayloads.add(prepareMsg); + } + updateState(); + } + + public void addCommitSeal(final SignedData commitPayload) { + if (!proposalMessage.isPresent() || validator.validateCommmitMessage(commitPayload)) { + commitPayloads.add(commitPayload); + } + + updateState(); + } + + private void updateState() { + // NOTE: The quorumSize for Prepare messages is 1 less than the quorum size as the proposer + // does not supply a prepare message + prepared = (preparePayloads.size() >= (quorumSize - 1)) && proposalMessage.isPresent(); + committed = (commitPayloads.size() >= quorumSize) && proposalMessage.isPresent(); + } + + public Optional getProposedBlock() { + return proposalMessage.map(p -> p.getPayload().getBlock()); + } + + public boolean isPrepared() { + return prepared; + } + + public boolean isCommitted() { + return committed; + } + + public Collection getCommitSeals() { + return commitPayloads + .stream() + .map(cp -> cp.getPayload().getCommitSeal()) + .collect(Collectors.toList()); + } + + public Optional constructPreparedCertificate() { + if (isPrepared()) { + return Optional.of(new PreparedCertificate(proposalMessage.get(), preparePayloads)); + } + return Optional.empty(); + } +} diff --git a/consensus/ibft/src/test/java/tech/pegasys/pantheon/consensus/ibft/statemachine/RoundStateTest.java b/consensus/ibft/src/test/java/tech/pegasys/pantheon/consensus/ibft/statemachine/RoundStateTest.java new file mode 100644 index 0000000000..65e090351e --- /dev/null +++ b/consensus/ibft/src/test/java/tech/pegasys/pantheon/consensus/ibft/statemachine/RoundStateTest.java @@ -0,0 +1,271 @@ +/* + * Copyright 2018 ConsenSys AG. + * + * 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. + */ +package tech.pegasys.pantheon.consensus.ibft.statemachine; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import tech.pegasys.pantheon.consensus.ibft.ConsensusRoundIdentifier; +import tech.pegasys.pantheon.consensus.ibft.ibftmessagedata.CommitPayload; +import tech.pegasys.pantheon.consensus.ibft.ibftmessagedata.MessageFactory; +import tech.pegasys.pantheon.consensus.ibft.ibftmessagedata.PreparePayload; +import tech.pegasys.pantheon.consensus.ibft.ibftmessagedata.ProposalPayload; +import tech.pegasys.pantheon.consensus.ibft.ibftmessagedata.SignedData; +import tech.pegasys.pantheon.consensus.ibft.validation.MessageValidator; +import tech.pegasys.pantheon.crypto.SECP256K1.KeyPair; +import tech.pegasys.pantheon.crypto.SECP256K1.Signature; +import tech.pegasys.pantheon.ethereum.core.Address; +import tech.pegasys.pantheon.ethereum.core.Block; +import tech.pegasys.pantheon.ethereum.core.Hash; +import tech.pegasys.pantheon.ethereum.core.Util; + +import java.math.BigInteger; +import java.util.List; + +import com.google.common.collect.Lists; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class RoundStateTest { + + private final List validatorKeys = Lists.newArrayList(); + private final List validatorMessageFactories = Lists.newArrayList(); + private final ConsensusRoundIdentifier roundIdentifier = new ConsensusRoundIdentifier(1, 1); + + private final List
validators = Lists.newArrayList(); + + @Mock private MessageValidator messageValidator; + + @Mock private Block block; + + @Before + public void setup() { + for (int i = 0; i < 3; i++) { + final KeyPair newKeyPair = KeyPair.generate(); + validatorKeys.add(newKeyPair); + validators.add(Util.publicKeyToAddress(newKeyPair.getPublicKey())); + validatorMessageFactories.add(new MessageFactory(newKeyPair)); + } + when(block.getHash()).thenReturn(Hash.fromHexStringLenient("1")); + } + + @Test + public void defaultRoundIsNotPreparedOrCommittedAndHasNoPreparedCertificate() { + final RoundState roundState = new RoundState(roundIdentifier, 1, messageValidator); + + assertThat(roundState.isPrepared()).isFalse(); + assertThat(roundState.isCommitted()).isFalse(); + assertThat(roundState.constructPreparedCertificate()).isEmpty(); + } + + @Test + public void ifProposalMessageFailsValidationMethodReturnsFalse() { + when(messageValidator.addSignedProposalPayload(any())).thenReturn(false); + final RoundState roundState = new RoundState(roundIdentifier, 1, messageValidator); + + final SignedData proposal = + validatorMessageFactories.get(0).createSignedProposalPayload(roundIdentifier, block); + + assertThat(roundState.setProposedBlock(proposal)).isFalse(); + assertThat(roundState.isPrepared()).isFalse(); + assertThat(roundState.isCommitted()).isFalse(); + assertThat(roundState.constructPreparedCertificate()).isEmpty(); + } + + @Test + public void singleValidatorIsPreparedWithJustProposal() { + when(messageValidator.addSignedProposalPayload(any())).thenReturn(true); + final RoundState roundState = new RoundState(roundIdentifier, 1, messageValidator); + + final SignedData proposal = + validatorMessageFactories.get(0).createSignedProposalPayload(roundIdentifier, block); + + assertThat(roundState.setProposedBlock(proposal)).isTrue(); + assertThat(roundState.isPrepared()).isTrue(); + assertThat(roundState.isCommitted()).isFalse(); + assertThat(roundState.constructPreparedCertificate()).isNotEmpty(); + assertThat(roundState.constructPreparedCertificate().get().getProposalPayload()) + .isEqualTo(proposal); + } + + @Test + public void singleValidatorRequiresCommitMessageToBeCommitted() { + when(messageValidator.addSignedProposalPayload(any())).thenReturn(true); + when(messageValidator.validateCommmitMessage(any())).thenReturn(true); + + final RoundState roundState = new RoundState(roundIdentifier, 1, messageValidator); + + final SignedData proposal = + validatorMessageFactories.get(0).createSignedProposalPayload(roundIdentifier, block); + + assertThat(roundState.setProposedBlock(proposal)).isTrue(); + assertThat(roundState.isPrepared()).isTrue(); + assertThat(roundState.isCommitted()).isFalse(); + + final SignedData commit = + validatorMessageFactories + .get(0) + .createSignedCommitPayload( + roundIdentifier, + block.getHash(), + Signature.create(BigInteger.ONE, BigInteger.ONE, (byte) 1)); + + roundState.addCommitSeal(commit); + assertThat(roundState.isPrepared()).isTrue(); + assertThat(roundState.isCommitted()).isTrue(); + assertThat(roundState.constructPreparedCertificate()).isNotEmpty(); + } + + @Test + public void prepareMessagesCanBeReceivedPriorToProposal() { + when(messageValidator.addSignedProposalPayload(any())).thenReturn(true); + when(messageValidator.validatePrepareMessage(any())).thenReturn(true); + + final RoundState roundState = new RoundState(roundIdentifier, 3, messageValidator); + + final SignedData firstPrepare = + validatorMessageFactories + .get(1) + .createSignedPreparePayload(roundIdentifier, block.getHash()); + + final SignedData secondPrepare = + validatorMessageFactories + .get(2) + .createSignedPreparePayload(roundIdentifier, block.getHash()); + + roundState.addPreparedPeer(firstPrepare); + assertThat(roundState.isPrepared()).isFalse(); + assertThat(roundState.isCommitted()).isFalse(); + assertThat(roundState.constructPreparedCertificate()).isEmpty(); + + roundState.addPreparedPeer(secondPrepare); + assertThat(roundState.isPrepared()).isFalse(); + assertThat(roundState.isCommitted()).isFalse(); + assertThat(roundState.constructPreparedCertificate()).isEmpty(); + + final SignedData proposal = + validatorMessageFactories.get(0).createSignedProposalPayload(roundIdentifier, block); + assertThat(roundState.setProposedBlock(proposal)).isTrue(); + assertThat(roundState.isPrepared()).isTrue(); + assertThat(roundState.isCommitted()).isFalse(); + assertThat(roundState.constructPreparedCertificate()).isNotEmpty(); + } + + @Test + public void invalidPriorPrepareMessagesAreDiscardedUponSubsequentProposal() { + final SignedData firstPrepare = + validatorMessageFactories + .get(1) + .createSignedPreparePayload(roundIdentifier, block.getHash()); + + final SignedData secondPrepare = + validatorMessageFactories + .get(2) + .createSignedPreparePayload(roundIdentifier, block.getHash()); + + // RoundState has a quorum size of 3, meaning 1 proposal and 2 prepare are required to be + // 'prepared'. + final RoundState roundState = new RoundState(roundIdentifier, 3, messageValidator); + + when(messageValidator.addSignedProposalPayload(any())).thenReturn(true); + when(messageValidator.validatePrepareMessage(firstPrepare)).thenReturn(true); + when(messageValidator.validatePrepareMessage(secondPrepare)).thenReturn(false); + + roundState.addPreparedPeer(firstPrepare); + roundState.addPreparedPeer(secondPrepare); + verify(messageValidator, never()).validatePrepareMessage(any()); + + final SignedData proposal = + validatorMessageFactories.get(0).createSignedProposalPayload(roundIdentifier, block); + + assertThat(roundState.setProposedBlock(proposal)).isTrue(); + assertThat(roundState.isPrepared()).isFalse(); + assertThat(roundState.isCommitted()).isFalse(); + assertThat(roundState.constructPreparedCertificate()).isEmpty(); + } + + @Test + public void prepareMessageIsValidatedAgainstExitingProposal() { + final RoundState roundState = new RoundState(roundIdentifier, 2, messageValidator); + + final SignedData firstPrepare = + validatorMessageFactories + .get(1) + .createSignedPreparePayload(roundIdentifier, block.getHash()); + + final SignedData secondPrepare = + validatorMessageFactories + .get(2) + .createSignedPreparePayload(roundIdentifier, block.getHash()); + + final SignedData proposal = + validatorMessageFactories.get(0).createSignedProposalPayload(roundIdentifier, block); + + when(messageValidator.addSignedProposalPayload(any())).thenReturn(true); + when(messageValidator.validatePrepareMessage(firstPrepare)).thenReturn(false); + when(messageValidator.validatePrepareMessage(secondPrepare)).thenReturn(true); + + roundState.setProposedBlock(proposal); + assertThat(roundState.isPrepared()).isFalse(); + + roundState.addPreparedPeer(firstPrepare); + assertThat(roundState.isPrepared()).isFalse(); + + roundState.addPreparedPeer(secondPrepare); + assertThat(roundState.isPrepared()).isTrue(); + } + + @Test + public void commitSealsAreExtractedFromReceivedMessages() { + when(messageValidator.addSignedProposalPayload(any())).thenReturn(true); + when(messageValidator.validateCommmitMessage(any())).thenReturn(true); + + final RoundState roundState = new RoundState(roundIdentifier, 2, messageValidator); + + final SignedData firstCommit = + validatorMessageFactories + .get(1) + .createSignedCommitPayload( + roundIdentifier, + block.getHash(), + Signature.create(BigInteger.ONE, BigInteger.TEN, (byte) 1)); + + final SignedData secondCommit = + validatorMessageFactories + .get(2) + .createSignedCommitPayload( + roundIdentifier, + block.getHash(), + Signature.create(BigInteger.TEN, BigInteger.TEN, (byte) 1)); + + final SignedData proposal = + validatorMessageFactories.get(0).createSignedProposalPayload(roundIdentifier, block); + + roundState.setProposedBlock(proposal); + roundState.addCommitSeal(firstCommit); + assertThat(roundState.isCommitted()).isFalse(); + roundState.addCommitSeal(secondCommit); + assertThat(roundState.isCommitted()).isTrue(); + + assertThat(roundState.getCommitSeals()) + .containsOnly( + firstCommit.getPayload().getCommitSeal(), secondCommit.getPayload().getCommitSeal()); + } +}