From 76c54fceab4f7d15a7538845aa2a293dedfe5e49 Mon Sep 17 00:00:00 2001 From: Derek Riley Date: Fri, 25 Oct 2024 16:35:14 -0400 Subject: [PATCH] feat: Initial TssVoteHandler (#16061) Signed-off-by: Derek Riley Signed-off-by: Neeharika-Sompalli Co-authored-by: Neeharika-Sompalli --- .../node/app/spi/workflows/HandleContext.java | 7 + .../node/app/tss/handlers/TssVoteHandler.java | 63 ++++++ .../handle/DispatchHandleContext.java | 8 +- .../app/tss/handlers/TssVoteHandlerTest.java | 181 ++++++++++++++++-- .../proposals/TSS-Ledger-Id/TSS-Ledger-Id.md | 2 +- 5 files changed, 248 insertions(+), 13 deletions(-) diff --git a/hedera-node/hedera-app-spi/src/main/java/com/hedera/node/app/spi/workflows/HandleContext.java b/hedera-node/hedera-app-spi/src/main/java/com/hedera/node/app/spi/workflows/HandleContext.java index 78a4207bde59..c761bab4dd7b 100644 --- a/hedera-node/hedera-app-spi/src/main/java/com/hedera/node/app/spi/workflows/HandleContext.java +++ b/hedera-node/hedera-app-spi/src/main/java/com/hedera/node/app/spi/workflows/HandleContext.java @@ -38,6 +38,7 @@ import com.hedera.node.app.spi.workflows.record.StreamBuilder; import com.swirlds.config.api.Configuration; import com.swirlds.state.spi.info.NetworkInfo; +import com.swirlds.state.spi.info.NodeInfo; import edu.umd.cs.findbugs.annotations.NonNull; import edu.umd.cs.findbugs.annotations.Nullable; import java.time.Instant; @@ -551,6 +552,12 @@ static void throwIfMissingPayerId(@NonNull final TransactionBody body) { @NonNull Map dispatchPaidRewards(); + /** + * Returns the {@link NodeInfo} for the node this transaction is created from. + * @return the node info + */ + NodeInfo creatorInfo(); + /** * Whether a dispatch should be throttled at consensus. True for everything except certain dispatches * internal to the EVM which are only constrained by gas. diff --git a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/tss/handlers/TssVoteHandler.java b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/tss/handlers/TssVoteHandler.java index d0d20c44b5ed..873d98d46461 100644 --- a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/tss/handlers/TssVoteHandler.java +++ b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/tss/handlers/TssVoteHandler.java @@ -18,6 +18,9 @@ import static java.util.Objects.requireNonNull; +import com.hedera.hapi.node.state.roster.Roster; +import com.hedera.hapi.node.state.roster.RosterEntry; +import com.hedera.hapi.node.state.tss.TssVoteMapKey; import com.hedera.hapi.node.transaction.TransactionBody; import com.hedera.hapi.services.auxiliary.tss.TssVoteTransactionBody; import com.hedera.node.app.spi.workflows.HandleContext; @@ -25,6 +28,9 @@ import com.hedera.node.app.spi.workflows.PreCheckException; import com.hedera.node.app.spi.workflows.PreHandleContext; import com.hedera.node.app.spi.workflows.TransactionHandler; +import com.hedera.node.app.tss.stores.WritableTssStore; +import com.hedera.pbj.runtime.io.buffer.Bytes; +import com.swirlds.platform.state.service.ReadableRosterStore; import edu.umd.cs.findbugs.annotations.NonNull; import javax.inject.Inject; import javax.inject.Singleton; @@ -35,6 +41,7 @@ */ @Singleton public class TssVoteHandler implements TransactionHandler { + @Inject public TssVoteHandler() { // Dagger2 @@ -53,5 +60,61 @@ public void pureChecks(@NonNull final TransactionBody txn) throws PreCheckExcept @Override public void handle(@NonNull final HandleContext context) throws HandleException { requireNonNull(context); + final var txBody = context.body().tssVoteOrThrow(); + final var tssBaseStore = context.storeFactory().writableStore(WritableTssStore.class); + final TssVoteMapKey tssVoteMapKey = new TssVoteMapKey( + txBody.targetRosterHash(), context.creatorInfo().nodeId()); + if (tssBaseStore.exists(tssVoteMapKey)) { + // Duplicate vote + return; + } + + if (!TssVoteHandler.hasReachedThreshold(txBody, context)) { + tssBaseStore.put(tssVoteMapKey, txBody); + } + } + + /** + * Check if the threshold number of votes (totaling at least 1/3 of weight) have already been received for the + * candidate roster, all with the same vote byte array. + * + * @param tssVoteTransaction the TssVoteTransaction to check + * @param context the HandleContext + * @return true if the threshold has been reached, false otherwise + */ + public static boolean hasReachedThreshold( + @NonNull final TssVoteTransactionBody tssVoteTransaction, @NonNull final HandleContext context) { + final var rosterStore = context.storeFactory().readableStore(ReadableRosterStore.class); + + final Roster activeRoster = rosterStore.getActiveRoster(); + if (activeRoster == null) { + throw new IllegalArgumentException("No active roster found"); + } + // Get the target roster from the TssVoteTransactionBody + final Bytes targetRosterHash = tssVoteTransaction.targetRosterHash(); + + // Also get the total active roster weight + long activeRosterTotalWeight = 0; + // Initialize a counter for the total weight of votes with the same vote byte array + long voteWeight = 0L; + final var tssBaseStore = context.storeFactory().writableStore(WritableTssStore.class); + // For every node in the active roster, check if there is a vote for the target roster hash + for (final RosterEntry rosterEntry : activeRoster.rosterEntries()) { + activeRosterTotalWeight += rosterEntry.weight(); + final var tssVoteMapKey = new TssVoteMapKey(targetRosterHash, rosterEntry.nodeId()); + if (tssBaseStore.exists(tssVoteMapKey)) { + final var vote = tssBaseStore.getVote(tssVoteMapKey); + // If the vote byte array matches the one in the TssVoteTransaction, add the weight of the vote to the + // counter + if (vote.tssVote().equals(tssVoteTransaction.tssVote())) { + voteWeight += rosterEntry.weight(); + } + } + } + + // Check if the total weight of votes with the same vote byte array is at least 1/3 of the + // total weight of the network + // Adding a +1 to the threshold to account for rounding errors. + return voteWeight >= (activeRosterTotalWeight / 3) + ((activeRosterTotalWeight % 3) == 0 ? 0 : 1); } } diff --git a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/handle/DispatchHandleContext.java b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/handle/DispatchHandleContext.java index eb6cd3288d20..92e9d42e40f1 100644 --- a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/handle/DispatchHandleContext.java +++ b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/handle/DispatchHandleContext.java @@ -180,8 +180,9 @@ public boolean tryToChargePayer(final long amount) { return feeAccumulator.chargeNetworkFee(payerId, amount); } + @NonNull @Override - public @NonNull Configuration configuration() { + public Configuration configuration() { return config; } @@ -462,6 +463,11 @@ public Map dispatchPaidRewards() { return dispatchPaidRewards == null ? emptyMap() : dispatchPaidRewards; } + @Override + public NodeInfo creatorInfo() { + return creatorInfo; + } + private T dispatchForRecord( @NonNull final TransactionBody childTxBody, @NonNull final Class recordBuilderClass, diff --git a/hedera-node/hedera-app/src/test/java/com/hedera/node/app/tss/handlers/TssVoteHandlerTest.java b/hedera-node/hedera-app/src/test/java/com/hedera/node/app/tss/handlers/TssVoteHandlerTest.java index cac2aea3bd97..24bfecfc37af 100644 --- a/hedera-node/hedera-app/src/test/java/com/hedera/node/app/tss/handlers/TssVoteHandlerTest.java +++ b/hedera-node/hedera-app/src/test/java/com/hedera/node/app/tss/handlers/TssVoteHandlerTest.java @@ -17,42 +17,201 @@ package com.hedera.node.app.tss.handlers; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mock.Strictness.LENIENT; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import com.hedera.hapi.node.state.roster.Roster; +import com.hedera.hapi.node.state.roster.RosterEntry; +import com.hedera.hapi.node.state.tss.TssVoteMapKey; import com.hedera.hapi.node.transaction.TransactionBody; +import com.hedera.hapi.services.auxiliary.tss.TssVoteTransactionBody; +import com.hedera.node.app.spi.store.StoreFactory; import com.hedera.node.app.spi.workflows.HandleContext; +import com.hedera.node.app.spi.workflows.HandleException; import com.hedera.node.app.spi.workflows.PreHandleContext; +import com.hedera.node.app.tss.stores.WritableTssStore; +import com.hedera.pbj.runtime.io.buffer.Bytes; +import com.swirlds.platform.state.service.ReadableRosterStore; +import com.swirlds.state.spi.info.NodeInfo; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.MockitoAnnotations; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) class TssVoteHandlerTest { - @Mock - private TssSubmissions submissionManager; @Mock private PreHandleContext preHandleContext; - @Mock + @Mock(strictness = LENIENT) private HandleContext handleContext; - private TssVoteHandler subject; + @Mock + private WritableTssStore tssBaseStore; + + @Mock + private ReadableRosterStore rosterStore; + + @Mock + private TssVoteTransactionBody tssVoteTransactionBody; + + @Mock + private TransactionBody transactionBody; + + @Mock + private StoreFactory storeFactory; + + @Mock(strictness = LENIENT) + private NodeInfo nodeInfo; + + private TssVoteHandler tssVoteHandler; @BeforeEach void setUp() { - subject = new TssVoteHandler(); + MockitoAnnotations.openMocks(this); + tssVoteHandler = new TssVoteHandler(); + when(handleContext.creatorInfo()).thenReturn(nodeInfo); + when(nodeInfo.nodeId()).thenReturn(1L); + } + + @Test + void handleDoesNotThrowWhenValidContext() throws HandleException { + when(handleContext.body()).thenReturn(transactionBody); + when(transactionBody.tssVoteOrThrow()).thenReturn(tssVoteTransactionBody); + when(handleContext.storeFactory()).thenReturn(storeFactory); + when(storeFactory.writableStore(WritableTssStore.class)).thenReturn(tssBaseStore); + ; + + when(tssVoteTransactionBody.targetRosterHash()).thenReturn(Bytes.EMPTY); + when(tssBaseStore.exists(any(TssVoteMapKey.class))).thenReturn(false); + + try (MockedStatic mockedStatic = mockStatic(TssVoteHandler.class)) { + mockedStatic + .when(() -> TssVoteHandler.hasReachedThreshold(any(), any())) + .thenReturn(false); + tssVoteHandler.handle(handleContext); + } + + verify(tssBaseStore).put(any(TssVoteMapKey.class), eq(tssVoteTransactionBody)); + } + + @Test + void handleReturnsWhenDuplicateVoteExists() throws HandleException { + when(handleContext.body()).thenReturn(transactionBody); + when(transactionBody.tssVoteOrThrow()).thenReturn(tssVoteTransactionBody); + when(handleContext.storeFactory()).thenReturn(storeFactory); + when(storeFactory.writableStore(WritableTssStore.class)).thenReturn(tssBaseStore); + when(tssVoteTransactionBody.targetRosterHash()).thenReturn(Bytes.EMPTY); + when(tssBaseStore.exists(any(TssVoteMapKey.class))).thenReturn(true); + + tssVoteHandler.handle(handleContext); + + verify(tssBaseStore, never()).put(any(TssVoteMapKey.class), eq(tssVoteTransactionBody)); + } + + @Test + void hasReachedThresholdReturnsFalseWhenThresholdIsNotMet() { + // Setup in-memory data + final RosterEntry rosterEntry1 = new RosterEntry(1L, 1L, null, null, List.of()); + final RosterEntry rosterEntry2 = new RosterEntry(2L, 4L, null, null, List.of()); + final RosterEntry rosterEntry3 = new RosterEntry(3L, 2L, null, null, List.of()); + final Roster roster = new Roster(List.of(rosterEntry1, rosterEntry2, rosterEntry3)); + final TssVoteTransactionBody voteTransactionBody = + new TssVoteTransactionBody(Bytes.EMPTY, Bytes.EMPTY, Bytes.EMPTY, Bytes.EMPTY, Bytes.EMPTY); + final TssVoteTransactionBody voteTransactionBody2 = + new TssVoteTransactionBody(Bytes.EMPTY, Bytes.EMPTY, Bytes.EMPTY, Bytes.EMPTY, Bytes.fromHex("01")); + final TssVoteTransactionBody voteTransactionBody3 = + new TssVoteTransactionBody(Bytes.EMPTY, Bytes.EMPTY, Bytes.EMPTY, Bytes.EMPTY, Bytes.fromHex("02")); + + // Setup stores + final Map voteStore = new HashMap<>(); + voteStore.put(new TssVoteMapKey(Bytes.EMPTY, 1L), voteTransactionBody); + voteStore.put(new TssVoteMapKey(Bytes.EMPTY, 2L), voteTransactionBody2); + voteStore.put(new TssVoteMapKey(Bytes.EMPTY, 3L), voteTransactionBody3); + + // Mock behavior + when(handleContext.storeFactory()).thenReturn(storeFactory); + when(storeFactory.writableStore(WritableTssStore.class)).thenReturn(tssBaseStore); + when(storeFactory.readableStore(ReadableRosterStore.class)).thenReturn(rosterStore); + when(rosterStore.getActiveRoster()).thenReturn(roster); + when(tssBaseStore.exists(any(TssVoteMapKey.class))) + .thenAnswer(invocation -> voteStore.containsKey(invocation.getArgument(0))); + when(tssBaseStore.getVote(any(TssVoteMapKey.class))) + .thenAnswer(invocation -> voteStore.get(invocation.getArgument(0))); + + final boolean result = TssVoteHandler.hasReachedThreshold(voteTransactionBody, handleContext); + + assertFalse(result, "Threshold should not be met"); + } + + @Test + void hasReachedThresholdReturnsTrueWhenThresholdIsMet() { + // Setup in-memory data + final RosterEntry rosterEntry1 = new RosterEntry(1L, 1L, null, null, List.of()); + final RosterEntry rosterEntry2 = new RosterEntry(2L, 2L, null, null, List.of()); + final RosterEntry rosterEntry3 = new RosterEntry(3L, 3L, null, null, List.of()); + final Roster roster = new Roster(List.of(rosterEntry1, rosterEntry2, rosterEntry3)); + final TssVoteTransactionBody voteTransactionBody = + new TssVoteTransactionBody(Bytes.EMPTY, Bytes.EMPTY, Bytes.EMPTY, Bytes.EMPTY, Bytes.EMPTY); + final TssVoteTransactionBody voteTransactionBody2 = + new TssVoteTransactionBody(Bytes.EMPTY, Bytes.EMPTY, Bytes.EMPTY, Bytes.EMPTY, Bytes.fromHex("01")); + final TssVoteTransactionBody voteTransactionBody3 = + new TssVoteTransactionBody(Bytes.EMPTY, Bytes.EMPTY, Bytes.EMPTY, Bytes.EMPTY, Bytes.EMPTY); + + // Setup stores + final Map voteStore = new HashMap<>(); + voteStore.put(new TssVoteMapKey(Bytes.EMPTY, 1L), voteTransactionBody); + voteStore.put(new TssVoteMapKey(Bytes.EMPTY, 2L), voteTransactionBody2); + voteStore.put(new TssVoteMapKey(Bytes.EMPTY, 3L), voteTransactionBody3); + + // Mock behavior + when(handleContext.storeFactory()).thenReturn(storeFactory); + when(storeFactory.writableStore(WritableTssStore.class)).thenReturn(tssBaseStore); + when(storeFactory.readableStore(ReadableRosterStore.class)).thenReturn(rosterStore); + when(rosterStore.getActiveRoster()).thenReturn(roster); + when(tssBaseStore.exists(any(TssVoteMapKey.class))) + .thenAnswer(invocation -> voteStore.containsKey(invocation.getArgument(0))); + when(tssBaseStore.getVote(any(TssVoteMapKey.class))) + .thenAnswer(invocation -> voteStore.get(invocation.getArgument(0))); + + boolean result = TssVoteHandler.hasReachedThreshold(voteTransactionBody, handleContext); + + assertTrue(result, "Threshold should be met"); } @Test - void nothingImplementedYet() { - assertDoesNotThrow(() -> subject.preHandle(preHandleContext)); - assertDoesNotThrow(() -> subject.pureChecks(tssVote())); - assertDoesNotThrow(() -> subject.handle(handleContext)); + void preHandleDoesNotThrowWhenContextIsValid() { + assertDoesNotThrow(() -> tssVoteHandler.preHandle(preHandleContext)); } - private TransactionBody tssVote() { - return TransactionBody.DEFAULT; + @Test + void pureChecksDoesNotThrowWhenTransactionBodyIsValid() { + assertDoesNotThrow(() -> tssVoteHandler.pureChecks(transactionBody)); + } + + @Test + void hasReachedThresholdThrowsIllegalArgumentExceptionWhenActiveRosterIsNull() { + when(handleContext.storeFactory()).thenReturn(storeFactory); + when(storeFactory.readableStore(ReadableRosterStore.class)).thenReturn(rosterStore); + when(rosterStore.getActiveRoster()).thenReturn(null); + + TssVoteTransactionBody voteTransactionBody = + new TssVoteTransactionBody(Bytes.EMPTY, Bytes.EMPTY, Bytes.EMPTY, Bytes.EMPTY, Bytes.EMPTY); + + assertThrows( + IllegalArgumentException.class, + () -> TssVoteHandler.hasReachedThreshold(voteTransactionBody, handleContext)); } } diff --git a/platform-sdk/docs/proposals/TSS-Ledger-Id/TSS-Ledger-Id.md b/platform-sdk/docs/proposals/TSS-Ledger-Id/TSS-Ledger-Id.md index a5df2d8c1c5c..28dbc5a07080 100644 --- a/platform-sdk/docs/proposals/TSS-Ledger-Id/TSS-Ledger-Id.md +++ b/platform-sdk/docs/proposals/TSS-Ledger-Id/TSS-Ledger-Id.md @@ -945,7 +945,7 @@ Outputs: 1. If voting is closed for the target roster or the vote is a second vote from the originating node, do nothing. 2. Add the `TssVoteTransaction` to the list for the target roster. -3. If the voting threshold is met by at least 1/2 consensus weight voting yes: +3. If the voting threshold is met by at least 1/3 consensus weight voting yes: 1. add the target roster hash to the` `votingClosed` set. 2. Non-Dynamic Address Book Semantics 1. if `keyActiveRoster` is false, do nothing here, rely on the startup logic to rotate the candidate roster to