-
Notifications
You must be signed in to change notification settings - Fork 1
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
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
547494b
feat: add getThreshold() method for grandpa state
6f570c1
feat: add derivePrimary() to grandpa state
b6728e1
feat: add subround enum
278925c
feat: add auxiliary methods for grandpa ghost
8bb9fc7
feat: added all of the auxiliary methods for ghost
3ec9ec2
feat: add getGHOST method and comments to all other methods
fc5d7f5
feat: adjust threshold comparison logic
07a9092
chore: refactor
7392728
chore: refactor
3ea9371
chore: refactor
28cd621
feat: add unit tests
1bde574
Merge branch 'dev' into 395-grandpa-ghost
Grigorov-Georgi e0bedbe
feat: add grandpaState tests and rework grandpaState class
Grigorov-Georgi 4fd4822
feat: add more tests to grandpa service methods
Grigorov-Georgi 2fa850f
chore: refactor
Grigorov-Georgi 68be6ec
Merge branch 'dev' into 395-grandpa-ghost
Grigorov-Georgi d69aa86
chore: remove derivePrimary method and the test for it
Grigorov-Georgi 373a724
feat: remove duplicating subround enum
Grigorov-Georgi 9a8e387
chore: refactor
Grigorov-Georgi e7fab2d
chore: fix failing test
Grigorov-Georgi File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.