Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement GRANDPA-GHOST #661

Merged
merged 20 commits into from
Jan 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.limechain.exception.grandpa;

public class GhostExecutionException extends GrandpaGenericException {
public GhostExecutionException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.limechain.exception.grandpa;

public class GrandpaGenericException extends RuntimeException {
public GrandpaGenericException(String message) {
super(message);
}
}
247 changes: 247 additions & 0 deletions src/main/java/com/limechain/grandpa/GrandpaService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
package com.limechain.grandpa;

import com.limechain.exception.grandpa.GhostExecutionException;
import com.limechain.exception.storage.BlockStorageGenericException;
import com.limechain.grandpa.state.GrandpaState;
import com.limechain.network.protocol.grandpa.messages.commit.Vote;
import com.limechain.network.protocol.grandpa.messages.vote.Subround;
import com.limechain.network.protocol.warp.dto.BlockHeader;
import com.limechain.storage.block.BlockState;
import io.emeraldpay.polkaj.types.Hash256;
import io.libp2p.core.crypto.PubKey;
import lombok.extern.java.Log;
import org.springframework.stereotype.Component;

import java.math.BigInteger;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Log
@Component
public class GrandpaService {

private final GrandpaState grandpaState;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
private final GrandpaState grandpaState;
private final GrandpaState grandpaState;

private final BlockState blockState;

public GrandpaService(GrandpaState grandpaState, BlockState blockState) {
this.grandpaState = grandpaState;
this.blockState = blockState;
}

/**
* Finds and returns the block with the most votes in the GRANDPA prevote stage.
* If there are multiple blocks with the same number of votes, selects the block with the highest number.
* If no block meets the criteria, throws an exception indicating no valid GHOST candidate.
*
* @return GRANDPA GHOST block as a vote
*/
public Vote getGrandpaGhost() {
var threshold = grandpaState.getThreshold();

Map<Hash256, BigInteger> blocks = getPossibleSelectedBlocks(threshold, Subround.PREVOTE);

if (blocks.isEmpty() || threshold.equals(BigInteger.ZERO)) {
throw new GhostExecutionException("GHOST not found");
}

return selectBlockWithMostVotes(blocks);
}

/**
* Selects the block with the most votes from the provided map of blocks.
* If multiple blocks have the same number of votes, it returns the one with the highest block number.
* Starts with the last finalized block as the initial candidate.
*
* @param blocks map of block that exceed the required threshold
* @return the block with the most votes from the provided map
*/
private Vote selectBlockWithMostVotes(Map<Hash256, BigInteger> blocks) {
var lastFinalizedBlockHeader = blockState.getHighestFinalizedHeader();

Vote highest = new Vote(
lastFinalizedBlockHeader.getHash(),
lastFinalizedBlockHeader.getBlockNumber()
);

for (Map.Entry<Hash256, BigInteger> entry : blocks.entrySet()) {
Hash256 hash = entry.getKey();
BigInteger number = entry.getValue();

if (number.compareTo(highest.getBlockNumber()) > 0) {
highest = new Vote(hash, number);
}
}

return highest;
}

/**
* Returns blocks with total votes over the threshold in a map of block hash to block number.
* If no blocks meet the threshold directly, recursively searches their ancestors for blocks with enough votes.
* Ancestors are included if their combined votes (including votes for their descendants) exceed the threshold.
*
* @param threshold minimum votes required for a block to qualify.
* @param subround stage of the GRANDPA process, such as PREVOTE, PRECOMMIT or PRIMARY_PROPOSAL.
* @return blocks that exceed the required vote threshold
*/
private Map<Hash256, BigInteger> getPossibleSelectedBlocks(BigInteger threshold, Subround subround) {
var votes = getDirectVotes(subround);
var blocks = new HashMap<Hash256, BigInteger>();

for (Vote vote : votes.keySet()) {
long totalVotes = getTotalVotesForBlock(vote.getBlockHash(), subround);

if (BigInteger.valueOf(totalVotes).compareTo(threshold) >= 0) {
blocks.put(vote.getBlockHash(), vote.getBlockNumber());
}
}

if (!blocks.isEmpty()) {
return blocks;
}

List<Vote> allVotes = getVotes(subround);
for (Vote vote : votes.keySet()) {
blocks = new HashMap<>(
getPossibleSelectedAncestors(allVotes, vote.getBlockHash(), blocks, subround, threshold)
);
}

return blocks;
}

/**
* Recursively searches for ancestors with more than 2/3 votes.
*
* @param votes voters list
* @param currentBlockHash the hash of the current block
* @param selected currently selected block hashes that exceed the required vote threshold
* @param subround stage of the GRANDPA process, such as PREVOTE, PRECOMMIT or PRIMARY_PROPOSAL.
* @param threshold minimum votes required for a block to qualify.
* @return map of block hash to block number for ancestors meeting the threshold condition.
*/
private Map<Hash256, BigInteger> getPossibleSelectedAncestors(List<Vote> votes,
Hash256 currentBlockHash,
Map<Hash256, BigInteger> selected,
Subround subround,
BigInteger threshold) {

for (Vote vote : votes) {
if (vote.getBlockHash().equals(currentBlockHash)) {
continue;
}

Hash256 ancestorBlockHash;
try {
ancestorBlockHash = blockState.lowestCommonAncestor(vote.getBlockHash(), currentBlockHash);
} catch (IllegalArgumentException | BlockStorageGenericException e) {
log.warning("Error finding the lowest common ancestor: " + e.getMessage());
continue;
}

// Happens when currentBlock is ancestor of the block in the vote
if (ancestorBlockHash.equals(currentBlockHash)) {
return selected;
}

long totalVotes = getTotalVotesForBlock(ancestorBlockHash, subround);

if (BigInteger.valueOf(totalVotes).compareTo(threshold) >= 0) {

BlockHeader header = blockState.getHeader(ancestorBlockHash);
if (header == null) {
throw new IllegalStateException("Header not found for block: " + ancestorBlockHash);
}

selected.put(ancestorBlockHash, header.getBlockNumber());
} else {
// Recursively process ancestors
selected = getPossibleSelectedAncestors(votes, ancestorBlockHash, selected, subround, threshold);
}
}

return selected;
}

/**
* Calculates the total votes for a block, including observed votes and equivocations,
* in the specified subround.
*
* @param blockHash hash of the block
* @param subround stage of the GRANDPA process, such as PREVOTE, PRECOMMIT or PRIMARY_PROPOSAL.
* @retyrn total votes for a specific block
*/
private long getTotalVotesForBlock(Hash256 blockHash, Subround subround) {
long votesForBlock = getObservedVotesForBlock(blockHash, subround);

if (votesForBlock == 0L) {
return 0L;
}

int equivocationCount = switch (subround) {
case Subround.PREVOTE -> grandpaState.getPvEquivocations().size();
case Subround.PRECOMMIT -> grandpaState.getPcEquivocations().size();
default -> 0;
};

return votesForBlock + equivocationCount;
}

/**
* Calculates the total observed votes for a block, including direct votes and votes from
* its descendants, in the specified subround.
*
* @param blockHash hash of the block
* @param subround stage of the GRANDPA process, such as PREVOTE, PRECOMMIT or PRIMARY_PROPOSAL.
* @return total observed votes
*/
private long getObservedVotesForBlock(Hash256 blockHash, Subround subround) {
var votes = getDirectVotes(subround);
var votesForBlock = 0L;

for (Map.Entry<Vote, Long> entry : votes.entrySet()) {
var vote = entry.getKey();
var count = entry.getValue();

try {
if (blockState.isDescendantOf(blockHash, vote.getBlockHash())) {
votesForBlock += count;
}
} catch (BlockStorageGenericException e) {
log.warning(e.getMessage());
return 0L; // Default to zero votes in case of block state error
} catch (Exception e) {
log.warning("An error occurred while checking block ancestry: " + e.getMessage());
}
}

return votesForBlock;
}

/**
* Aggregates direct (explicit) votes for a given subround into a map of Vote to their count
*
* @param subround stage of the GRANDPA process, such as PREVOTE, PRECOMMIT or PRIMARY_PROPOSAL.
* @return map of direct votes
*/
private HashMap<Vote, Long> getDirectVotes(Subround subround) {
var voteCounts = new HashMap<Vote, Long>();

Map<PubKey, Vote> votes = switch (subround) {
case Subround.PREVOTE -> grandpaState.getPrevotes();
case Subround.PRECOMMIT -> grandpaState.getPrecommits();
default -> new HashMap<>();
};

votes.values().forEach(vote -> voteCounts.merge(vote, 1L, Long::sum));

return voteCounts;
}

private List<Vote> getVotes(Subround subround) {
var votes = getDirectVotes(subround);
return new ArrayList<>(votes.keySet());
}
}
55 changes: 55 additions & 0 deletions src/main/java/com/limechain/grandpa/state/GrandpaState.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.limechain.grandpa.state;

import com.limechain.chain.lightsyncstate.Authority;
import com.limechain.network.protocol.grandpa.messages.catchup.res.SignedVote;
import com.limechain.network.protocol.grandpa.messages.commit.Vote;
import io.libp2p.core.crypto.PubKey;
import lombok.Getter;
import lombok.Setter;
import org.springframework.stereotype.Component;

import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
* Represents the state information for the current round and authorities that are needed
* for block finalization with GRANDPA.
* Note: Intended for use only when the host is configured as an Authoring Node.
*/
@Getter
@Setter //TODO: remove it when initialize() method is implemented
@Component
public class GrandpaState {

private static final BigInteger THRESHOLD_DENOMINATOR = BigInteger.valueOf(3);

private List<Authority> voters;
private BigInteger setId;
private BigInteger roundNumber;

//TODO: This may not be the best place for those maps
private Map<PubKey, Vote> precommits = new ConcurrentHashMap<>();
private Map<PubKey, Vote> prevotes = new ConcurrentHashMap<>();
private Map<PubKey, SignedVote> pvEquivocations = new ConcurrentHashMap<>();
private Map<PubKey, SignedVote> pcEquivocations = new ConcurrentHashMap<>();

/**
* The threshold is determined as the total weight of authorities
* subtracted by the weight of potentially faulty authorities (one-third of the total weight minus one).
*
* @return threshold for achieving a super-majority vote
*/
public BigInteger getThreshold() {
var totalWeight = getAuthoritiesTotalWeight();
var faulty = (totalWeight.subtract(BigInteger.ONE)).divide(THRESHOLD_DENOMINATOR);
return totalWeight.subtract(faulty);
}

private BigInteger getAuthoritiesTotalWeight() {
return voters.stream()
.map(Authority::getWeight)
.reduce(BigInteger.ZERO, BigInteger::add);
}
}
Loading
Loading