diff --git a/src/main/java/com/limechain/exception/grandpa/GhostExecutionException.java b/src/main/java/com/limechain/exception/grandpa/GhostExecutionException.java new file mode 100644 index 00000000..087ac158 --- /dev/null +++ b/src/main/java/com/limechain/exception/grandpa/GhostExecutionException.java @@ -0,0 +1,7 @@ +package com.limechain.exception.grandpa; + +public class GhostExecutionException extends GrandpaGenericException { + public GhostExecutionException(String message) { + super(message); + } +} diff --git a/src/main/java/com/limechain/exception/grandpa/GrandpaGenericException.java b/src/main/java/com/limechain/exception/grandpa/GrandpaGenericException.java new file mode 100644 index 00000000..7d4f33f2 --- /dev/null +++ b/src/main/java/com/limechain/exception/grandpa/GrandpaGenericException.java @@ -0,0 +1,7 @@ +package com.limechain.exception.grandpa; + +public class GrandpaGenericException extends RuntimeException { + public GrandpaGenericException(String message) { + super(message); + } +} diff --git a/src/main/java/com/limechain/grandpa/GrandpaService.java b/src/main/java/com/limechain/grandpa/GrandpaService.java new file mode 100644 index 00000000..e923bb25 --- /dev/null +++ b/src/main/java/com/limechain/grandpa/GrandpaService.java @@ -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 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 blocks) { + var lastFinalizedBlockHeader = blockState.getHighestFinalizedHeader(); + + Vote highest = new Vote( + lastFinalizedBlockHeader.getHash(), + lastFinalizedBlockHeader.getBlockNumber() + ); + + for (Map.Entry 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 getPossibleSelectedBlocks(BigInteger threshold, Subround subround) { + var votes = getDirectVotes(subround); + var blocks = new HashMap(); + + 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 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 getPossibleSelectedAncestors(List votes, + Hash256 currentBlockHash, + Map 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 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 getDirectVotes(Subround subround) { + var voteCounts = new HashMap(); + + Map 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 getVotes(Subround subround) { + var votes = getDirectVotes(subround); + return new ArrayList<>(votes.keySet()); + } +} diff --git a/src/main/java/com/limechain/grandpa/state/GrandpaState.java b/src/main/java/com/limechain/grandpa/state/GrandpaState.java new file mode 100644 index 00000000..ba46d3eb --- /dev/null +++ b/src/main/java/com/limechain/grandpa/state/GrandpaState.java @@ -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 voters; + private BigInteger setId; + private BigInteger roundNumber; + + //TODO: This may not be the best place for those maps + private Map precommits = new ConcurrentHashMap<>(); + private Map prevotes = new ConcurrentHashMap<>(); + private Map pvEquivocations = new ConcurrentHashMap<>(); + private Map 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); + } +} diff --git a/src/test/java/com/limechain/grandpa/GrandpaServiceTest.java b/src/test/java/com/limechain/grandpa/GrandpaServiceTest.java new file mode 100644 index 00000000..e99cc163 --- /dev/null +++ b/src/test/java/com/limechain/grandpa/GrandpaServiceTest.java @@ -0,0 +1,461 @@ +package com.limechain.grandpa; + +import com.limechain.exception.grandpa.GhostExecutionException; +import com.limechain.grandpa.state.GrandpaState; +import com.limechain.network.protocol.grandpa.messages.catchup.res.SignedVote; +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.network.protocol.warp.dto.ConsensusEngine; +import com.limechain.network.protocol.warp.dto.DigestType; +import com.limechain.network.protocol.warp.dto.HeaderDigest; +import com.limechain.storage.block.BlockState; +import com.limechain.utils.Ed25519Utils; +import io.emeraldpay.polkaj.types.Hash256; +import io.libp2p.core.crypto.PubKey; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.math.BigInteger; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.limechain.utils.TestUtils.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class GrandpaServiceTest { + + private static final byte[] ZEROS_ARRAY = new byte[32]; + private static final byte[] ONES_ARRAY = + new byte[]{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}; + private static final byte[] TWOS_ARRAY = + new byte[]{2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2}; + private static final byte[] THREES_ARRAY = + new byte[]{3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3}; + + private GrandpaState grandpaState; + private BlockState blockState; + private GrandpaService grandpaService; + + @BeforeEach + void setUp() { + grandpaState = mock(GrandpaState.class); + blockState = mock(BlockState.class); + grandpaService = new GrandpaService(grandpaState, blockState); + } + + @Test + void testGetGrandpaGHOSTWhereNoBlocksPassThreshold() { + when(grandpaState.getThreshold()).thenReturn(BigInteger.valueOf(10)); + when(grandpaState.getPrevotes()).thenReturn(Map.of()); + assertThrows(GhostExecutionException.class, () -> grandpaService.getGrandpaGhost()); + } + + @Test + void testGetGrandpaGHOSTWithBlockPassingThreshold() { + when(grandpaState.getThreshold()).thenReturn(BigInteger.valueOf(1)); + + Vote firstVote = new Vote(new Hash256(ONES_ARRAY), BigInteger.valueOf(3)); + Vote secondVote = new Vote(new Hash256(ONES_ARRAY), BigInteger.valueOf(3)); + + when(grandpaState.getPrevotes()).thenReturn(Map.of( + Ed25519Utils.generateKeyPair().publicKey(), firstVote, + Ed25519Utils.generateKeyPair().publicKey(), secondVote + )); + + BlockHeader blockHeader = createBlockHeader(); + blockHeader.setBlockNumber(BigInteger.valueOf(1)); + when(blockState.getHighestFinalizedHeader()).thenReturn(blockHeader); + + when(blockState.isDescendantOf(firstVote.getBlockHash(), firstVote.getBlockHash())).thenReturn(true); + when(blockState.isDescendantOf(secondVote.getBlockHash(), secondVote.getBlockHash())).thenReturn(true); + + Vote result = grandpaService.getGrandpaGhost(); + assertNotNull(result); + assertEquals(firstVote.getBlockHash(), result.getBlockHash()); + } + + @Test + void testGetDirectVotesForPrevotes() throws Exception { + // Prepare mock data + PubKey pubKey1 = Ed25519Utils.generateKeyPair().publicKey(); + PubKey pubKey2 = Ed25519Utils.generateKeyPair().publicKey(); + + Vote firstVote = new Vote(new Hash256(ONES_ARRAY), BigInteger.valueOf(3)); + Vote secondVote = new Vote(new Hash256(TWOS_ARRAY), BigInteger.valueOf(4)); + + Map prevotes = new HashMap<>(); + prevotes.put(pubKey1, firstVote); + prevotes.put(pubKey2, secondVote); + + when(grandpaState.getPrevotes()).thenReturn(prevotes); + + // Call the private method via reflection + Method method = GrandpaService.class.getDeclaredMethod("getDirectVotes", Subround.class); + method.setAccessible(true); + + Map result = (HashMap) method.invoke(grandpaService, Subround.PREVOTE); + + assertEquals(1L, result.get(firstVote)); + assertEquals(1L, result.get(secondVote)); + } + + @Test + void testGetDirectVotesWithMultipleVotesForSingleBlockForPrevotes() throws Exception { + // Prepare mock data + PubKey pubKey1 = Ed25519Utils.generateKeyPair().publicKey(); + PubKey pubKey2 = Ed25519Utils.generateKeyPair().publicKey(); + + Vote firstVote = new Vote(new Hash256(ONES_ARRAY), BigInteger.valueOf(3)); + Vote secondVote = new Vote(new Hash256(ONES_ARRAY), BigInteger.valueOf(3)); + + Map prevotes = new HashMap<>(); + prevotes.put(pubKey1, firstVote); + prevotes.put(pubKey2, secondVote); + + when(grandpaState.getPrevotes()).thenReturn(prevotes); + + // Call the private method via reflection + Method method = GrandpaService.class.getDeclaredMethod("getDirectVotes", Subround.class); + method.setAccessible(true); + + Map result = (HashMap) method.invoke(grandpaService, Subround.PREVOTE); + + assertEquals(2L, result.get(firstVote)); + } + + @Test + void testGetVotes() throws Exception { + // Prepare mock data + PubKey pubKey1 = Ed25519Utils.generateKeyPair().publicKey(); + + Vote firstVote = new Vote(new Hash256(ONES_ARRAY), BigInteger.valueOf(3)); + + Map prevotes = new HashMap<>(); + prevotes.put(pubKey1, firstVote); + + when(grandpaState.getPrevotes()).thenReturn(prevotes); + + // Call the private method via reflection + Method method = GrandpaService.class.getDeclaredMethod("getVotes", Subround.class); + method.setAccessible(true); + + List result = (List) method.invoke(grandpaService, Subround.PREVOTE); + + assertTrue(result.contains(firstVote)); + } + + @Test + void testGetVotesWithMultipleVotes() throws Exception { + // Prepare mock data + PubKey pubKey1 = Ed25519Utils.generateKeyPair().publicKey(); + PubKey pubKey2 = Ed25519Utils.generateKeyPair().publicKey(); + + Vote firstVote = new Vote(new Hash256(ONES_ARRAY), BigInteger.valueOf(3)); + Vote secondVote = new Vote(new Hash256(TWOS_ARRAY), BigInteger.valueOf(4)); + + Map prevotes = new HashMap<>(); + prevotes.put(pubKey1, firstVote); + prevotes.put(pubKey2, secondVote); + + when(grandpaState.getPrevotes()).thenReturn(prevotes); + + // Call the private method via reflection + Method method = GrandpaService.class.getDeclaredMethod("getVotes", Subround.class); + method.setAccessible(true); + + List result = (List) method.invoke(grandpaService, Subround.PREVOTE); + + assertTrue(result.contains(firstVote)); + assertTrue(result.contains(secondVote)); + } + + @Test + void testGetObservedVotesForBlockWhereVotesAreNotDescendantsOfProvidedBlockHash() throws Exception { + PubKey pubKey1 = Ed25519Utils.generateKeyPair().publicKey(); + PubKey pubKey2 = Ed25519Utils.generateKeyPair().publicKey(); + + Vote firstVote = new Vote(new Hash256(ONES_ARRAY), BigInteger.valueOf(3)); + Vote secondVote = new Vote(new Hash256(TWOS_ARRAY), BigInteger.valueOf(4)); + + Map prevotes = new HashMap<>(); + prevotes.put(pubKey1, firstVote); + prevotes.put(pubKey2, secondVote); + + when(grandpaState.getPrevotes()).thenReturn(prevotes); + when(blockState.isDescendantOf(any(), any())).thenReturn(false); + + Method method = GrandpaService.class.getDeclaredMethod( + "getObservedVotesForBlock", + Hash256.class, + Subround.class + ); + + method.setAccessible(true); + + long result = (long) method.invoke(grandpaService, new Hash256(ZEROS_ARRAY), Subround.PREVOTE); + + assertEquals(0L, result); + } + + @Test + void testGetObservedVotesForBlockWhereVotesAreDescendantsOfProvidedBlockHash() throws Exception { + PubKey pubKey1 = Ed25519Utils.generateKeyPair().publicKey(); + PubKey pubKey2 = Ed25519Utils.generateKeyPair().publicKey(); + + Vote firstVote = new Vote(new Hash256(ONES_ARRAY), BigInteger.valueOf(3)); + Vote secondVote = new Vote(new Hash256(TWOS_ARRAY), BigInteger.valueOf(4)); + + Map prevotes = new HashMap<>(); + prevotes.put(pubKey1, firstVote); + prevotes.put(pubKey2, secondVote); + + when(grandpaState.getPrevotes()).thenReturn(prevotes); + when(blockState.isDescendantOf(any(), any())).thenReturn(true); + + Method method = GrandpaService.class.getDeclaredMethod( + "getObservedVotesForBlock", + Hash256.class, + Subround.class + ); + + method.setAccessible(true); + + long result = (long) method.invoke(grandpaService, new Hash256(ZEROS_ARRAY), Subround.PREVOTE); + + assertEquals(2L, result); + } + + @Test + void testGetTotalVotesForBlockWithoutObservedVotes() throws Exception { + PubKey pubKey1 = Ed25519Utils.generateKeyPair().publicKey(); + PubKey pubKey2 = Ed25519Utils.generateKeyPair().publicKey(); + + Vote firstVote = new Vote(new Hash256(ONES_ARRAY), BigInteger.valueOf(3)); + Vote secondVote = new Vote(new Hash256(TWOS_ARRAY), BigInteger.valueOf(4)); + + Map prevotes = new HashMap<>(); + prevotes.put(pubKey1, firstVote); + prevotes.put(pubKey2, secondVote); + + PubKey pubKey3 = Ed25519Utils.generateKeyPair().publicKey(); + + Map pvEquivocations = new HashMap<>(); + pvEquivocations.put(pubKey3, new SignedVote()); + + when(grandpaState.getPrevotes()).thenReturn(prevotes); + when(grandpaState.getPvEquivocations()).thenReturn(pvEquivocations); + when(blockState.isDescendantOf(any(), any())).thenReturn(false); + + Method method = GrandpaService.class.getDeclaredMethod( + "getTotalVotesForBlock", Hash256.class, Subround.class); + + method.setAccessible(true); + + long result = (long) method.invoke(grandpaService, new Hash256(ZEROS_ARRAY), Subround.PREVOTE); + + // Observed votes: 0 + // Equivocations: 1 + // Total votes: 0 + assertEquals(0, result); + } + + @Test + void testGetTotalVotesForBlockWithObservedVotes() throws Exception { + PubKey pubKey1 = Ed25519Utils.generateKeyPair().publicKey(); + PubKey pubKey2 = Ed25519Utils.generateKeyPair().publicKey(); + + Vote firstVote = new Vote(new Hash256(ONES_ARRAY), BigInteger.valueOf(3)); + Vote secondVote = new Vote(new Hash256(TWOS_ARRAY), BigInteger.valueOf(4)); + + Map prevotes = new HashMap<>(); + prevotes.put(pubKey1, firstVote); + prevotes.put(pubKey2, secondVote); + + when(grandpaState.getPrevotes()).thenReturn(prevotes); + when(grandpaState.getPvEquivocations()).thenReturn(new HashMap<>()); + when(blockState.isDescendantOf(any(), any())).thenReturn(true); + + Method method = GrandpaService.class.getDeclaredMethod( + "getTotalVotesForBlock", Hash256.class, Subround.class); + + method.setAccessible(true); + + long result = (long) method.invoke(grandpaService, new Hash256(ZEROS_ARRAY), Subround.PREVOTE); + + // Observed votes: 2 + // Equivocations: 0 + // Total votes: 2 + assertEquals(2, result); + } + + @Test + void testGetTotalVotesForBlockWithObservedVotesAndEquivocations() throws Exception { + PubKey pubKey1 = Ed25519Utils.generateKeyPair().publicKey(); + PubKey pubKey2 = Ed25519Utils.generateKeyPair().publicKey(); + + Vote firstVote = new Vote(new Hash256(ONES_ARRAY), BigInteger.valueOf(3)); + Vote secondVote = new Vote(new Hash256(TWOS_ARRAY), BigInteger.valueOf(4)); + + Map prevotes = new HashMap<>(); + prevotes.put(pubKey1, firstVote); + prevotes.put(pubKey2, secondVote); + + PubKey pubKey3 = Ed25519Utils.generateKeyPair().publicKey(); + + Map pvEquivocations = new HashMap<>(); + pvEquivocations.put(pubKey3, new SignedVote()); + + when(grandpaState.getPrevotes()).thenReturn(prevotes); + when(grandpaState.getPvEquivocations()).thenReturn(pvEquivocations); + when(blockState.isDescendantOf(any(), any())).thenReturn(true); + + Method method = GrandpaService.class.getDeclaredMethod( + "getTotalVotesForBlock", Hash256.class, Subround.class); + + method.setAccessible(true); + + long result = (long) method.invoke(grandpaService, new Hash256(ZEROS_ARRAY), Subround.PREVOTE); + + // Observed votes: 2 + // Equivocations: 1 + // Total votes: 3 + assertEquals(3, result); + } + + @Test + void testGetPossibleSelectedAncestors() throws Exception { + // ZEROS_ARRAY Block is parent of ONES- and THREES_ARRAY Blocks + // + // ZEROS_ARRAY_BLOCK --> ONES_ARRAY_BLOCK (block from votes) + // | + // --> THREES_ARRAY_BLOCK (current block) + Vote firstVote = new Vote(new Hash256(ONES_ARRAY), BigInteger.valueOf(3)); + List votes = List.of(firstVote); + + PubKey pubKey1 = Ed25519Utils.generateKeyPair().publicKey(); + + Map prevotes = new HashMap<>(); + prevotes.put(pubKey1, firstVote); + + when(grandpaState.getPrevotes()).thenReturn(prevotes); + when(grandpaState.getPvEquivocations()).thenReturn(new HashMap<>()); + when(blockState.isDescendantOf(any(), any())).thenReturn(true); + + when(blockState.lowestCommonAncestor(new Hash256(ONES_ARRAY), new Hash256(THREES_ARRAY))) + .thenReturn(new Hash256(ZEROS_ARRAY)); + + when(blockState.getHeader(new Hash256(ZEROS_ARRAY))) + .thenReturn(createBlockHeader()); + + Method method = GrandpaService.class.getDeclaredMethod("getPossibleSelectedAncestors", + List.class, + Hash256.class, + Map.class, + Subround.class, + BigInteger.class + ); + + method.setAccessible(true); + + Map selected = new HashMap<>(); + + Map result = (Map) method.invoke( + grandpaService, + votes, + new Hash256(THREES_ARRAY), + selected, + Subround.PREVOTE, + BigInteger.valueOf(1) + ); + + assertEquals(1, result.size()); + assertTrue(result.containsKey(new Hash256(ZEROS_ARRAY))); + } + + + @Test + void testGetPossibleSelectedBlocksThatAreOverThreshold() throws Exception { + Vote firstVote = new Vote(new Hash256(ONES_ARRAY), BigInteger.valueOf(3)); + Vote secondVote = new Vote(new Hash256(TWOS_ARRAY), BigInteger.valueOf(4)); + + when(grandpaState.getPrevotes()).thenReturn(Map.of( + Ed25519Utils.generateKeyPair().publicKey(), firstVote, + Ed25519Utils.generateKeyPair().publicKey(), secondVote + )); + + when(blockState.isDescendantOf(any(), any())).thenReturn(true); + when(grandpaState.getThreshold()).thenReturn(BigInteger.valueOf(3)); + + Method method = GrandpaService.class.getDeclaredMethod( + "getPossibleSelectedBlocks", BigInteger.class, Subround.class); + method.setAccessible(true); + + Map result = (Map) method.invoke( + grandpaService, BigInteger.valueOf(1), Subround.PREVOTE); + + assertEquals(2, result.size()); + assertTrue(result.containsKey(new Hash256(ONES_ARRAY))); + assertTrue(result.containsKey(new Hash256(TWOS_ARRAY))); + } + + @Test + void testSelectBlockWithMostVotes() throws Exception { + Map blocks = new HashMap<>(); + blocks.put(new Hash256(ONES_ARRAY), BigInteger.valueOf(3)); + blocks.put(new Hash256(TWOS_ARRAY), BigInteger.valueOf(4)); + + BlockHeader blockHeader = createBlockHeader(); + when(blockState.getHighestFinalizedHeader()).thenReturn(blockHeader); + + Method method = GrandpaService.class.getDeclaredMethod("selectBlockWithMostVotes", Map.class); + method.setAccessible(true); + + Vote result = (Vote) method.invoke(grandpaService, blocks); + + assertNotNull(result); + assertEquals(new Hash256(TWOS_ARRAY), result.getBlockHash()); + assertEquals(BigInteger.valueOf(4), result.getBlockNumber()); + } + + @Test + void testSelectBlockWithMostVotesWhereLastFinalizedBlockIsWithGreaterBlockNumber() throws Exception { + Map blocks = new HashMap<>(); + blocks.put(new Hash256(ONES_ARRAY), BigInteger.valueOf(0)); + + BlockHeader blockHeader = createBlockHeader(); + when(blockState.getHighestFinalizedHeader()).thenReturn(blockHeader); + + Method method = GrandpaService.class.getDeclaredMethod("selectBlockWithMostVotes", Map.class); + method.setAccessible(true); + + Vote result = (Vote) method.invoke(grandpaService, blocks); + + assertNotNull(result); + assertEquals(blockHeader.getHash(), result.getBlockHash()); + assertEquals(blockHeader.getBlockNumber(), result.getBlockNumber()); + } + + private BlockHeader createBlockHeader() { + HeaderDigest headerDigest = new HeaderDigest(); + headerDigest.setType(DigestType.CONSENSUS_MESSAGE); + headerDigest.setId(ConsensusEngine.GRANDPA); + headerDigest.setMessage(ZEROS_ARRAY); + + BlockHeader blockHeader = new BlockHeader(); + blockHeader.setBlockNumber(BigInteger.valueOf(1)); + blockHeader.setParentHash(new Hash256(ZEROS_ARRAY)); + blockHeader.setDigest(new HeaderDigest[]{headerDigest}); + blockHeader.setStateRoot(new Hash256(ZEROS_ARRAY)); + blockHeader.setExtrinsicsRoot(new Hash256(ZEROS_ARRAY)); + + return blockHeader; + } +} diff --git a/src/test/java/com/limechain/grandpa/state/GrandpaStateTest.java b/src/test/java/com/limechain/grandpa/state/GrandpaStateTest.java new file mode 100644 index 00000000..4db9d468 --- /dev/null +++ b/src/test/java/com/limechain/grandpa/state/GrandpaStateTest.java @@ -0,0 +1,46 @@ +package com.limechain.grandpa.state; + +import com.limechain.chain.lightsyncstate.Authority; +import com.limechain.utils.Ed25519Utils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigInteger; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@ExtendWith(MockitoExtension.class) +class GrandpaStateTest { + + @InjectMocks + private GrandpaState grandpaState; + + @Test + void testGetThreshold() { + Authority authority1 = new Authority(Ed25519Utils.generateKeyPair().publicKey().bytes(), BigInteger.ONE); + Authority authority2 = new Authority(Ed25519Utils.generateKeyPair().publicKey().bytes(), BigInteger.ONE); + Authority authority3 = new Authority(Ed25519Utils.generateKeyPair().publicKey().bytes(), BigInteger.ONE); + Authority authority4 = new Authority(Ed25519Utils.generateKeyPair().publicKey().bytes(), BigInteger.ONE); + Authority authority5 = new Authority(Ed25519Utils.generateKeyPair().publicKey().bytes(), BigInteger.ONE); + Authority authority6 = new Authority(Ed25519Utils.generateKeyPair().publicKey().bytes(), BigInteger.ONE); + Authority authority7 = new Authority(Ed25519Utils.generateKeyPair().publicKey().bytes(), BigInteger.ONE); + Authority authority8 = new Authority(Ed25519Utils.generateKeyPair().publicKey().bytes(), BigInteger.ONE); + Authority authority9 = new Authority(Ed25519Utils.generateKeyPair().publicKey().bytes(), BigInteger.ONE); + Authority authority10 = new Authority(Ed25519Utils.generateKeyPair().publicKey().bytes(), BigInteger.ONE); + + grandpaState.setVoters( + List.of( + authority1, authority2, authority3, authority4, authority5, + authority6, authority7, authority8, authority9, authority10 + ) + ); + + // Total weight: 10 + // Faulty: (10 - 1) / 3 = 3 + // Threshold: 10 - faulty = 7 + assertEquals(BigInteger.valueOf(7), grandpaState.getThreshold()); + } +}