ProposalValidator.java
/*
* Copyright 2020 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.
*
* SPDX-License-Identifier: Apache-2.0
*/
package org.hyperledger.besu.consensus.qbft.validation;
import static org.hyperledger.besu.consensus.common.bft.validation.ValidationHelpers.hasDuplicateAuthors;
import static org.hyperledger.besu.consensus.common.bft.validation.ValidationHelpers.hasSufficientEntries;
import org.hyperledger.besu.consensus.common.bft.BftBlockHeaderFunctions;
import org.hyperledger.besu.consensus.common.bft.BftBlockInterface;
import org.hyperledger.besu.consensus.common.bft.BftContext;
import org.hyperledger.besu.consensus.common.bft.BftExtraDataCodec;
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
import org.hyperledger.besu.consensus.common.bft.payload.Payload;
import org.hyperledger.besu.consensus.common.bft.payload.SignedData;
import org.hyperledger.besu.consensus.qbft.messagewrappers.Proposal;
import org.hyperledger.besu.consensus.qbft.payload.PreparePayload;
import org.hyperledger.besu.consensus.qbft.payload.PreparedRoundMetadata;
import org.hyperledger.besu.consensus.qbft.payload.RoundChangePayload;
import org.hyperledger.besu.datatypes.Address;
import org.hyperledger.besu.datatypes.Hash;
import org.hyperledger.besu.ethereum.BlockValidator;
import org.hyperledger.besu.ethereum.ProtocolContext;
import org.hyperledger.besu.ethereum.core.Block;
import org.hyperledger.besu.ethereum.mainnet.ProtocolSchedule;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** The Proposal validator. */
public class ProposalValidator {
private static final Logger LOG = LoggerFactory.getLogger(ProposalValidator.class);
private static final String ERROR_PREFIX = "Invalid Proposal Payload";
private final ProtocolContext protocolContext;
private final ProtocolSchedule protocolSchedule;
private final int quorumMessageCount;
private final Collection<Address> validators;
private final ConsensusRoundIdentifier roundIdentifier;
private final Address expectedProposer;
private final BftExtraDataCodec bftExtraDataCodec;
/**
* Instantiates a new Proposal validator.
*
* @param protocolContext the protocol context
* @param protocolSchedule the protocol schedule
* @param quorumMessageCount the quorum message count
* @param validators the validators
* @param roundIdentifier the round identifier
* @param expectedProposer the expected proposer
* @param bftExtraDataCodec the bft extra data codec
*/
public ProposalValidator(
final ProtocolContext protocolContext,
final ProtocolSchedule protocolSchedule,
final int quorumMessageCount,
final Collection<Address> validators,
final ConsensusRoundIdentifier roundIdentifier,
final Address expectedProposer,
final BftExtraDataCodec bftExtraDataCodec) {
this.protocolContext = protocolContext;
this.protocolSchedule = protocolSchedule;
this.quorumMessageCount = quorumMessageCount;
this.validators = validators;
this.roundIdentifier = roundIdentifier;
this.expectedProposer = expectedProposer;
this.bftExtraDataCodec = bftExtraDataCodec;
}
/**
* Validate.
*
* @param msg the Proposal msg
* @return the boolean
*/
public boolean validate(final Proposal msg) {
final BlockValidator blockValidator =
protocolSchedule.getByBlockHeader(msg.getBlock().getHeader()).getBlockValidator();
final ProposalPayloadValidator payloadValidator =
new ProposalPayloadValidator(
expectedProposer, roundIdentifier, blockValidator, protocolContext, bftExtraDataCodec);
if (!payloadValidator.validate(msg.getSignedPayload())) {
LOG.info("{}: invalid proposal payload in proposal message", ERROR_PREFIX);
return false;
}
if (!validateProposalAndRoundChangeAreConsistent(msg)) {
return false;
}
return true;
}
private boolean validateProposalAndRoundChangeAreConsistent(final Proposal proposal) {
final ConsensusRoundIdentifier proposalRoundIdentifier = proposal.getRoundIdentifier();
if (proposalRoundIdentifier.getRoundNumber() == 0) {
if (!validateRoundZeroProposalHasNoRoundChangesOrPrepares(proposal)) {
return false;
}
return validateBlockCoinbaseMatchesMsgAuthor(proposal);
} else {
if (!validateRoundChanges(proposal, proposal.getRoundChanges())) {
LOG.info("{}: failed to validate piggy-backed round change payloads", ERROR_PREFIX);
return false;
}
// The RoundChangePayloadValidator ensures the PreparedRound is less than targetRound
// therefore, no need to validate that here.
final Optional<SignedData<RoundChangePayload>> roundChangeWithLatestPreparedRound =
getRoundChangeWithLatestPreparedRound(proposal.getRoundChanges());
if (roundChangeWithLatestPreparedRound.isPresent()) {
final PreparedRoundMetadata metadata =
roundChangeWithLatestPreparedRound.get().getPayload().getPreparedRoundMetadata().get();
// The Hash in the roundchange/proposals is NOT the same as the value in the
// prepares/roundchanges
// as said payloads reference the block with an OLD round number in it - therefore, need
// to create a block with the old round in it, then re-calc expected hash
// Need to check that if we substitute the LatestPrepareCert round number into the supplied
// block that we get the SAME hash as PreparedCert.
final BftBlockInterface bftBlockInterface =
protocolContext.getConsensusContext(BftContext.class).getBlockInterface();
final Block currentBlockWithOldRound =
bftBlockInterface.replaceRoundInBlock(
proposal.getBlock(),
metadata.getPreparedRound(),
BftBlockHeaderFunctions.forCommittedSeal(bftExtraDataCodec));
final Hash expectedPriorBlockHash = currentBlockWithOldRound.getHash();
if (!metadata.getPreparedBlockHash().equals(expectedPriorBlockHash)) {
LOG.info(
"{}: Latest Prepared Metadata blockhash does not align with proposed block",
ERROR_PREFIX);
return false;
}
// validate the prepares
if (!validatePrepares(
metadata, proposal.getRoundIdentifier().getSequenceNumber(), proposal.getPrepares())) {
LOG.info("{}: Piggy-backed prepares failed validation", ERROR_PREFIX);
return false;
}
} else {
// no one prepared, so prepares should be empty
if (!proposal.getPrepares().isEmpty()) {
LOG.info("{}: No PreparedMetadata exists, so prepare list must be empty", ERROR_PREFIX);
return false;
}
return validateBlockCoinbaseMatchesMsgAuthor(proposal);
}
return true;
}
}
private boolean validateRoundZeroProposalHasNoRoundChangesOrPrepares(final Proposal proposal) {
if ((proposal.getRoundChanges().size() != 0) || proposal.getPrepares().size() != 0) {
LOG.info("{}: round-0 proposal must not contain any prepares or roundchanges", ERROR_PREFIX);
return false;
}
return true;
}
private boolean validateRoundChanges(
final Proposal proposal, final List<SignedData<RoundChangePayload>> roundChanges) {
if (hasDuplicateAuthors(roundChanges)) {
LOG.info("{}: multiple round changes from the same author.", ERROR_PREFIX);
return false;
}
if (!hasSufficientEntries(roundChanges, quorumMessageCount)) {
LOG.info("{}: Insufficient round changes for proposal", ERROR_PREFIX);
return false;
}
if (!metadataIsConsistentAcrossRoundChanges(roundChanges)) {
return false;
}
final RoundChangePayloadValidator roundChangePayloadValidator =
new RoundChangePayloadValidator(validators, roundIdentifier.getSequenceNumber());
if (!roundChanges.stream().allMatch(roundChangePayloadValidator::validate)) {
LOG.info("{}: invalid proposal, round changes did not pass validation", ERROR_PREFIX);
return false;
}
// This is required as the RoundChangePayloadValidator only checks height, not round.
if (!allMessagesTargetRound(roundChanges, proposal.getRoundIdentifier())) {
LOG.info("{}: not all roundChange payloads target the proposal round.", ERROR_PREFIX);
return false;
}
return true;
}
private boolean validatePrepares(
final PreparedRoundMetadata metaData,
final long currentHeight,
final List<SignedData<PreparePayload>> prepares) {
if (hasDuplicateAuthors(prepares)) {
LOG.info("{}}: multiple prepares from the same author.", ERROR_PREFIX);
return false;
}
if (!hasSufficientEntries(prepares, quorumMessageCount)) {
LOG.info("{}: Insufficient prepares for proposal", ERROR_PREFIX);
return false;
}
final ConsensusRoundIdentifier preparedRoundIdentifier =
new ConsensusRoundIdentifier(currentHeight, metaData.getPreparedRound());
final PrepareValidator prepareValidator =
new PrepareValidator(validators, preparedRoundIdentifier, metaData.getPreparedBlockHash());
if (!prepares.stream().allMatch(prepareValidator::validate)) {
LOG.info("{}: Prepare failed validation", ERROR_PREFIX);
return false;
}
return true;
}
private Optional<SignedData<RoundChangePayload>> getRoundChangeWithLatestPreparedRound(
final List<SignedData<RoundChangePayload>> roundChanges) {
final Comparator<SignedData<RoundChangePayload>> preparedRoundComparator =
(o1, o2) -> {
if (o1.getPayload().getPreparedRoundMetadata().isEmpty()) {
return -1;
}
if (o2.getPayload().getPreparedRoundMetadata().isEmpty()) {
return 1;
}
int o1Round = o1.getPayload().getPreparedRoundMetadata().get().getPreparedRound();
int o2Round = o2.getPayload().getPreparedRoundMetadata().get().getPreparedRound();
return Integer.compare(o1Round, o2Round);
};
return roundChanges.stream()
.max(preparedRoundComparator)
.flatMap(rc -> rc.getPayload().getPreparedRoundMetadata().map(metadata -> rc));
}
private boolean metadataIsConsistentAcrossRoundChanges(
final List<SignedData<RoundChangePayload>> roundChanges) {
final List<PreparedRoundMetadata> distinctMetadatas =
roundChanges.stream()
.map(rc -> rc.getPayload().getPreparedRoundMetadata())
.filter(Optional::isPresent)
.map(Optional::get)
.distinct()
.collect(Collectors.toList());
final List<Integer> preparedRounds =
distinctMetadatas.stream()
.map(PreparedRoundMetadata::getPreparedRound)
.collect(Collectors.toList());
for (final Integer preparedRound : preparedRounds) {
if (distinctMetadatas.stream().filter(dm -> dm.getPreparedRound() == preparedRound).count()
> 1) {
LOG.info("{}: Roundchanges have different prepared metadata for same round", ERROR_PREFIX);
return false;
}
}
return true;
}
private boolean validateBlockCoinbaseMatchesMsgAuthor(final Proposal msg) {
if (!msg.getBlock().getHeader().getCoinbase().equals(msg.getAuthor())) {
LOG.info("{}: block coinbase does not match the proposer's address", ERROR_PREFIX);
return false;
}
return true;
}
/**
* All messages target round boolean.
*
* @param <T> the type parameter
* @param payloads the payloads
* @param requiredRound the required round
* @return the boolean
*/
public static <T extends Payload> boolean allMessagesTargetRound(
final Collection<SignedData<T>> payloads, final ConsensusRoundIdentifier requiredRound) {
return payloads.stream()
.allMatch(payload -> payload.getPayload().getRoundIdentifier().equals(requiredRound));
}
}