-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #661 from LimeChain/395-grandpa-ghost
Implement GRANDPA-GHOST
- Loading branch information
Showing
6 changed files
with
823 additions
and
0 deletions.
There are no files selected for viewing
7 changes: 7 additions & 0 deletions
7
src/main/java/com/limechain/exception/grandpa/GhostExecutionException.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
7 changes: 7 additions & 0 deletions
7
src/main/java/com/limechain/exception/grandpa/GrandpaGenericException.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
247
src/main/java/com/limechain/grandpa/GrandpaService.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
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
55
src/main/java/com/limechain/grandpa/state/GrandpaState.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
Oops, something went wrong.