diff --git a/hedera-node/hapi-utils/src/main/java/com/hedera/node/app/hapi/utils/CommonPbjConverters.java b/hedera-node/hapi-utils/src/main/java/com/hedera/node/app/hapi/utils/CommonPbjConverters.java index af860715e111..46a1758ae679 100644 --- a/hedera-node/hapi-utils/src/main/java/com/hedera/node/app/hapi/utils/CommonPbjConverters.java +++ b/hedera-node/hapi-utils/src/main/java/com/hedera/node/app/hapi/utils/CommonPbjConverters.java @@ -170,6 +170,15 @@ public static byte[] asBytes(@NonNull Codec codec, @NonNul }; } + /** + * Given a PBJ type, converts it to a proto type. + * @param pbj the PBJ type + * @param pbjClass the PBJ class + * @param protoClass the proto class + * @return the proto type + * @param the PBJ type + * @param the proto type + */ public static R pbjToProto( final T pbj, final Class pbjClass, final Class protoClass) { try { diff --git a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/handle/DispatchProcessor.java b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/handle/DispatchProcessor.java index c09b09d44eab..5e1a6f4bfc0b 100644 --- a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/handle/DispatchProcessor.java +++ b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/handle/DispatchProcessor.java @@ -27,7 +27,6 @@ import static com.hedera.hapi.node.base.ResponseCodeEnum.SUCCESS; import static com.hedera.hapi.node.base.ResponseCodeEnum.UNAUTHORIZED; import static com.hedera.node.app.spi.workflows.HandleContext.TransactionCategory.NODE; -import static com.hedera.node.app.spi.workflows.HandleContext.TransactionCategory.PRECEDING; import static com.hedera.node.app.spi.workflows.HandleContext.TransactionCategory.USER; import static com.hedera.node.app.workflows.handle.HandleWorkflow.ALERT_MESSAGE; import static com.hedera.node.app.workflows.handle.dispatch.DispatchValidator.DuplicateStatus.DUPLICATE; @@ -128,11 +127,7 @@ public void processDispatch(@NonNull final Dispatch dispatch) { } dispatchUsageManager.finalizeAndSaveUsage(dispatch); recordFinalizer.finalizeRecord(dispatch); - if (dispatch.txnCategory() == USER || dispatch.txnCategory() == NODE) { - dispatch.stack().commitTransaction(dispatch.recordBuilder()); - } else { - dispatch.stack().commitFullStack(); - } + dispatch.stack().commitFullStack(); } /** @@ -150,12 +145,7 @@ private void tryHandle(@NonNull final Dispatch dispatch, @NonNull final Validati dispatchUsageManager.screenForCapacity(dispatch); dispatcher.dispatchHandle(dispatch.handleContext()); dispatch.recordBuilder().status(SUCCESS); - // Only user or preceding transactions can trigger system updates in the current system - if (dispatch.txnCategory() == USER - || dispatch.txnCategory() == PRECEDING - || dispatch.txnCategory() == NODE) { - handleSystemUpdates(dispatch); - } + handleSystemUpdates(dispatch); } catch (HandleException e) { // In case of a ContractCall when it reverts, the gas charged should not be rolled back rollback(e.shouldRollbackStack(), e.getStatus(), dispatch.stack(), dispatch.recordBuilder()); diff --git a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/handle/HandleWorkflow.java b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/handle/HandleWorkflow.java index a50568480ac7..78e546fec444 100644 --- a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/handle/HandleWorkflow.java +++ b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/handle/HandleWorkflow.java @@ -349,7 +349,7 @@ private void handlePlatformTransaction( final var userTxn = userTxnFactory.createUserTxn(state, creator, txn, consensusNow, type); var lastRecordManagerTime = streamMode == RECORDS ? blockRecordManager.consTimeOfLastHandledTxn() : null; - final var handleOutput = execute(userTxn, txnVersion); + final var handleOutput = executeTopLevel(userTxn, txnVersion); if (streamMode != BLOCKS) { final var records = ((LegacyListRecordSource) handleOutput.recordSourceOrThrow()).precomputedRecords(); blockRecordManager.endUserTransaction(records.stream(), state); @@ -368,79 +368,112 @@ private void handlePlatformTransaction( purgeScheduling(state, lastRecordManagerTime, userTxn.consensusNow()); } else { final var executionStart = blockStreamManager.lastIntervalProcessTime(); - if (Instant.EPOCH.equals(executionStart)) { - blockStreamManager.setLastIntervalProcessTime(userTxn.consensusNow()); - } else if (executionStart.getEpochSecond() > lastExecutedSecond) { - final var schedulingConfig = userTxn.config().getConfigData(SchedulingConfig.class); - final var consensusConfig = userTxn.config().getConfigData(ConsensusConfig.class); - // Since the next consensus time may be (now + separationNanos), we need to ensure that - // even if the last scheduled execution time is followed by the maximum number of records, - // its final assigned time will be strictly before the first of the next consensus time's - // preceding records; i.e. (now + separationNanos) - (maxAfter + maxBefore + 1) - final var lastUsableTime = userTxn.consensusNow() - .plusNanos(schedulingConfig.consTimeSeparationNanos() - - consensusConfig.handleMaxPrecedingRecords() - - (consensusConfig.handleMaxFollowingRecords() + 1)); - // And the first possible time for the next execution is strictly after the last execution - // time plus the maximum number of preceding records - var nextTime = boundaryStateChangeListener - .lastConsensusTimeOrThrow() - .plusNanos(consensusConfig.handleMaxPrecedingRecords() + 1); - final var iter = scheduleService.executableTxns( + try { + // We execute as many schedules expiring in [lastIntervalProcessTime, consensusNow] + // as there are available consensus times and execution slots (ordinarily there will + // be more than enough of both, but we must be prepared for the edge cases) + executeAsManyScheduled( + state, executionStart, userTxn.consensusNow(), userTxn.creatorInfo(), userTxn.type()); + } catch (Exception e) { + logger.error( + "{} - unhandled exception while executing schedules between [{}, {}]", + ALERT_MESSAGE, executionStart, userTxn.consensusNow(), - StoreFactoryImpl.from(state, ScheduleService.NAME, userTxn.config(), storeMetricsService)); - final var writableStates = state.getWritableStates(ScheduleService.NAME); - int n = schedulingConfig.maxExecutionsPerUserTxn(); - // If we discover an executable transaction somewhere in the middle of the interval, this will - // be revised to the NBF time of that transaction; but for now we assume that everything up to - // the last second of the interval was executed - var executionEnd = userTxn.consensusNow(); - while (iter.hasNext() && !nextTime.isAfter(lastUsableTime) && n > 0) { - final var executableTxn = iter.next(); - if (schedulingConfig.longTermEnabled()) { - final var scheduledTxn = userTxnFactory.createUserTxn( - state, - userTxn.creatorInfo(), - nextTime, - ORDINARY_TRANSACTION, - executableTxn.payerId(), - executableTxn.body()); - final var baseBuilder = baseBuilderFor(executableTxn, scheduledTxn); - final var scheduledDispatch = userTxnFactory.createDispatch( - scheduledTxn, baseBuilder, executableTxn.keyVerifier(), SCHEDULED); - dispatchProcessor.processDispatch(scheduledDispatch); - final var scheduledOutput = scheduledTxn - .stack() - .buildHandleOutput(scheduledTxn.consensusNow(), exchangeRateManager.exchangeRates()); - recordCache.addRecordSource( - scheduledTxn.creatorInfo().nodeId(), - scheduledTxn.txnInfo().txnIdOrThrow(), - DueDiligenceFailure.NO, - scheduledOutput.preferringBlockRecordSource()); - scheduledOutput.blockRecordSourceOrThrow().forEachItem(blockStreamManager::writeItem); - if (streamMode == BOTH) { - final var records = ((LegacyListRecordSource) scheduledOutput.recordSourceOrThrow()) - .precomputedRecords(); - blockRecordManager.endUserTransaction(records.stream(), state); - } + e); + // This should never happen, but if it does, we skip over everything in the interval to + // avoid being stuck in a crash loop here + blockStreamManager.setLastIntervalProcessTime(userTxn.consensusNow()); + } + } + } + + /** + * Executes as many transactions scheduled to expire in the interval {@code [executionStart, consensusNow]} as + * possible from the given state, given some context of the triggering user transaction. + *

+ * As a side effect on the workflow internal state, updates the {@link BlockStreamManager}'s last interval process + * time to the latest time known to have been processed; and the {@link #lastExecutedSecond} value to the last + * second of the interval for which all scheduled transactions were executed. + * @param state the state to execute scheduled transactions from + * @param executionStart the start of the interval to execute transactions in + * @param consensusNow the consensus time at which the user transaction triggering this execution was processed + * @param creatorInfo the node info of the user transaction creator + * @param type the type of the user transaction triggering this execution + */ + private void executeAsManyScheduled( + @NonNull final State state, + @NonNull final Instant executionStart, + @NonNull final Instant consensusNow, + @NonNull final NodeInfo creatorInfo, + @NonNull final TransactionType type) { + // Non-final right endpoint of the execution interval, in case we cannot do all the scheduled work + var executionEnd = consensusNow; + // We only construct an Iterator if this is not genesis, and we haven't already + // created and exhausted iterators through the last second in the interval + if (type != GENESIS_TRANSACTION && executionEnd.getEpochSecond() > lastExecutedSecond) { + final var config = configProvider.getConfiguration(); + final var schedulingConfig = config.getConfigData(SchedulingConfig.class); + final var consensusConfig = config.getConfigData(ConsensusConfig.class); + // Since the next platform-assigned consensus time may be as early as (now + separationNanos), + // we must ensure that even if the last scheduled execution time is followed by the maximum + // number of child transactions, the last child's assigned time will be strictly before the + // first of the next consensus time's possible preceding children; that is, strictly before + // (now + separationNanos) - (maxAfter + maxBefore + 1) + final var lastUsableTime = consensusNow.plusNanos(schedulingConfig.consTimeSeparationNanos() + - consensusConfig.handleMaxPrecedingRecords() + - (consensusConfig.handleMaxFollowingRecords() + 1)); + // The first possible time for the next execution is strictly after the last execution time + // consumed for the triggering user transaction; plus the maximum number of preceding children + var nextTime = boundaryStateChangeListener + .lastConsensusTimeOrThrow() + .plusNanos(consensusConfig.handleMaxPrecedingRecords() + 1); + // Now we construct the iterator and start executing transactions in this interval + final var iter = scheduleService.executableTxns( + executionStart, + consensusNow, + StoreFactoryImpl.from(state, ScheduleService.NAME, config, storeMetricsService)); + final var writableStates = state.getWritableStates(ScheduleService.NAME); + // Configuration sets a maximum number of execution slots per user transaction + int n = schedulingConfig.maxExecutionsPerUserTxn(); + while (iter.hasNext() && !nextTime.isAfter(lastUsableTime) && n > 0) { + final var executableTxn = iter.next(); + if (schedulingConfig.longTermEnabled()) { + stakePeriodManager.setCurrentStakePeriodFor(nextTime); + if (streamMode == BOTH) { + blockRecordManager.startUserTransaction(nextTime, state); + } + final var handleOutput = executeScheduled(state, nextTime, creatorInfo, executableTxn); + handleOutput.blockRecordSourceOrThrow().forEachItem(blockStreamManager::writeItem); + if (streamMode == BOTH) { + final var records = + ((LegacyListRecordSource) handleOutput.recordSourceOrThrow()).precomputedRecords(); + blockRecordManager.endUserTransaction(records.stream(), state); } - executionEnd = executableTxn.nbf(); - doStreamingKVChanges(writableStates, executionEnd, iter::remove); - nextTime = boundaryStateChangeListener - .lastConsensusTimeOrThrow() - .plusNanos(consensusConfig.handleMaxPrecedingRecords() + 1); - n--; - } - blockStreamManager.setLastIntervalProcessTime(executionEnd); - if (!iter.hasNext() && executionEnd.getEpochSecond() > executionStart.getEpochSecond()) { - // Since the execution interval spanned at least full second and there are no remaining - // transactions to execute in it, we can mark the last full second as executed - lastExecutedSecond = executionEnd.getEpochSecond() - 1; } - doStreamingKVChanges(writableStates, executionEnd, iter::purgeUntilNext); + executionEnd = executableTxn.nbf(); + doStreamingKVChanges(writableStates, executionEnd, iter::remove); + nextTime = boundaryStateChangeListener + .lastConsensusTimeOrThrow() + .plusNanos(consensusConfig.handleMaxPrecedingRecords() + 1); + n--; + } + // The purgeUntilNext() iterator extension purges any schedules with wait_until_expiry=false + // that expire after the last schedule returned from next(), until either the next executable + // schedule or the iterator boundary is reached + doStreamingKVChanges(writableStates, executionEnd, iter::purgeUntilNext); + // If the iterator is not exhausted, we can only mark the second _before_ the last-executed NBF time + // as complete; if it is exhausted, we mark the rightmost second of the interval as complete + if (iter.hasNext()) { + lastExecutedSecond = executionEnd.getEpochSecond() - 1; + } else { + // We exhausted the iterator, so jump back ahead to the interval right endpoint + executionEnd = consensusNow; + lastExecutedSecond = consensusNow.getEpochSecond(); } } + // Update our last-processed time with where we ended + blockStreamManager.setLastIntervalProcessTime(executionEnd); } /** @@ -477,24 +510,6 @@ private void purgeScheduling(@NonNull final State state, final Instant then, fin } } - private void doStreamingKVChanges( - @NonNull final WritableStates writableStates, @NonNull final Instant now, @NonNull final Runnable action) { - if (streamMode != RECORDS) { - kvStateChangeListener.reset(); - } - action.run(); - ((CommittableWritableStates) writableStates).commit(); - if (streamMode != RECORDS) { - final var changes = kvStateChangeListener.getStateChanges(); - if (!changes.isEmpty()) { - final var stateChangesItem = BlockItem.newBuilder() - .stateChanges(new StateChanges(asTimestamp(now), new ArrayList<>(changes))) - .build(); - blockStreamManager.writeItem(stateChangesItem); - } - } - } - /** * Executes the user transaction and returns the output that should be externalized in the * block stream. (And if still producing records, the precomputed records.) @@ -507,7 +522,7 @@ private void doStreamingKVChanges( * @param txnVersion the software version for the event containing the transaction * @return the stream output from executing the transaction */ - private HandleOutput execute(@NonNull final UserTxn userTxn, @NonNull final SemanticVersion txnVersion) { + private HandleOutput executeTopLevel(@NonNull final UserTxn userTxn, @NonNull final SemanticVersion txnVersion) { try { if (isOlderSoftwareEvent(txnVersion)) { if (streamMode != BLOCKS) { @@ -558,14 +573,7 @@ private HandleOutput execute(@NonNull final UserTxn userTxn, @NonNull final Sema } final var dispatch = userTxnFactory.createDispatch(userTxn, exchangeRateManager.exchangeRates()); - // WARNING: this relies on the BlockStreamManager's last-handled time not being updated yet to - // correctly detect stake period boundary, so the order of the following two lines is important - processStakePeriodChanges(userTxn, dispatch); - blockStreamManager.setLastHandleTime(userTxn.consensusNow()); - if (streamMode != BLOCKS) { - // This updates consTimeOfLastHandledTxn as a side effect - blockRecordManager.advanceConsensusClock(userTxn.consensusNow(), userTxn.state()); - } + advanceTimeFor(userTxn, dispatch); logPreDispatch(userTxn); if (userTxn.type() != ORDINARY_TRANSACTION) { if (userTxn.type() == GENESIS_TRANSACTION) { @@ -587,7 +595,7 @@ private HandleOutput execute(@NonNull final UserTxn userTxn, @NonNull final Sema userTxn.stack().buildHandleOutput(userTxn.consensusNow(), exchangeRateManager.exchangeRates()); recordCache.addRecordSource( userTxn.creatorInfo().nodeId(), - userTxn.txnInfo().txnIdOrThrow(), + userTxn.txnInfo().transactionID(), userTxn.preHandleResult().dueDiligenceFailure(), handleOutput.preferringBlockRecordSource()); return handleOutput; @@ -597,6 +605,88 @@ private HandleOutput execute(@NonNull final UserTxn userTxn, @NonNull final Sema } } + /** + * Executes the scheduled transaction against the given state at the given time and returns + * the output that should be externalized in the block stream. (And if still producing records, + * the precomputed records.) + *

+ * Never throws an exception without a fundamental breakdown of the system invariants. If + * there is an internal error when executing the transaction, returns stream output of just the + * scheduled transaction with a {@link ResponseCodeEnum#FAIL_INVALID} transaction result, and + * no other side effects. + * @param state the state to execute the transaction against + * @param consensusNow the time to execute the transaction at + * @return the stream output from executing the transaction + */ + private HandleOutput executeScheduled( + @NonNull final State state, + @NonNull final Instant consensusNow, + @NonNull final NodeInfo creatorInfo, + @NonNull final ExecutableTxn executableTxn) { + final var scheduledTxn = userTxnFactory.createUserTxn( + state, creatorInfo, consensusNow, ORDINARY_TRANSACTION, executableTxn.payerId(), executableTxn.body()); + final var baseBuilder = baseBuilderFor(executableTxn, scheduledTxn); + final var dispatch = + userTxnFactory.createDispatch(scheduledTxn, baseBuilder, executableTxn.keyVerifier(), SCHEDULED); + advanceTimeFor(scheduledTxn, dispatch); + try { + dispatchProcessor.processDispatch(dispatch); + final var handleOutput = scheduledTxn + .stack() + .buildHandleOutput(scheduledTxn.consensusNow(), exchangeRateManager.exchangeRates()); + recordCache.addRecordSource( + scheduledTxn.creatorInfo().nodeId(), + scheduledTxn.txnInfo().transactionID(), + DueDiligenceFailure.NO, + handleOutput.preferringBlockRecordSource()); + return handleOutput; + } catch (final Exception e) { + logger.error("{} - exception thrown while handling scheduled transaction", ALERT_MESSAGE, e); + return failInvalidStreamItems(scheduledTxn); + } + } + + /** + * Manages time-based side effects for the given user transaction and dispatch. + * @param userTxn the user transaction to manage time for + * @param dispatch the dispatch to manage time for + */ + private void advanceTimeFor(@NonNull final UserTxn userTxn, @NonNull final Dispatch dispatch) { + // WARNING: this relies on the BlockStreamManager's last-handled time not being updated yet to + // correctly detect stake period boundary, so the order of the following two lines is important + processStakePeriodChanges(userTxn, dispatch); + blockStreamManager.setLastHandleTime(userTxn.consensusNow()); + if (streamMode != BLOCKS) { + // This updates consTimeOfLastHandledTxn as a side effect + blockRecordManager.advanceConsensusClock(userTxn.consensusNow(), userTxn.state()); + } + } + + /** + * Commits an action with side effects while capturing its key/value state changes and writing them to the + * block stream. + * @param writableStates the writable states to commit the action to + * @param now the consensus timestamp of the action + * @param action the action to commit + */ + private void doStreamingKVChanges( + @NonNull final WritableStates writableStates, @NonNull final Instant now, @NonNull final Runnable action) { + if (streamMode != RECORDS) { + kvStateChangeListener.reset(); + } + action.run(); + ((CommittableWritableStates) writableStates).commit(); + if (streamMode != RECORDS) { + final var changes = kvStateChangeListener.getStateChanges(); + if (!changes.isEmpty()) { + final var stateChangesItem = BlockItem.newBuilder() + .stateChanges(new StateChanges(asTimestamp(now), new ArrayList<>(changes))) + .build(); + blockStreamManager.writeItem(stateChangesItem); + } + } + } + /** * Returns a stream of a single {@link ResponseCodeEnum#FAIL_INVALID} record * for the given user transaction. diff --git a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/handle/dispatch/RecordFinalizer.java b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/handle/dispatch/RecordFinalizer.java index 4923d0e74ef7..6797428908f3 100644 --- a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/handle/dispatch/RecordFinalizer.java +++ b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/handle/dispatch/RecordFinalizer.java @@ -18,6 +18,7 @@ import static com.hedera.hapi.node.base.ResponseCodeEnum.SUCCESS; import static java.util.Collections.emptySet; +import static java.util.Objects.requireNonNull; import com.hedera.hapi.node.base.AccountAmount; import com.hedera.hapi.node.base.AccountID; @@ -60,15 +61,17 @@ public RecordFinalizer(final FinalizeRecordHandler recordFinalizer) { * and the child record finalizer is used for child and preceding transactions. * @param dispatch the dispatch */ - public void finalizeRecord(final Dispatch dispatch) { - switch (dispatch.txnCategory()) { - case USER, SCHEDULED -> recordFinalizer.finalizeStakingRecord( + public void finalizeRecord(@NonNull final Dispatch dispatch) { + requireNonNull(dispatch); + if (dispatch.stack().permitsStakingRewards()) { + recordFinalizer.finalizeStakingRecord( dispatch.finalizeContext(), dispatch.txnInfo().functionality(), extraRewardReceivers( dispatch.txnInfo().txBody(), dispatch.txnInfo().functionality(), dispatch.recordBuilder()), dispatch.handleContext().dispatchPaidRewards()); - case CHILD, PRECEDING -> recordFinalizer.finalizeNonStakingRecord( + } else { + recordFinalizer.finalizeNonStakingRecord( dispatch.finalizeContext(), dispatch.txnInfo().functionality()); } } diff --git a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/handle/stack/SavepointStackImpl.java b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/handle/stack/SavepointStackImpl.java index 093a3059802c..e0a2cdeaab5c 100644 --- a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/handle/stack/SavepointStackImpl.java +++ b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/handle/stack/SavepointStackImpl.java @@ -37,6 +37,7 @@ import com.hedera.node.app.blocks.impl.PairedStreamBuilder; import com.hedera.node.app.spi.records.RecordSource; import com.hedera.node.app.spi.workflows.HandleContext; +import com.hedera.node.app.spi.workflows.HandleContext.TransactionCategory; import com.hedera.node.app.spi.workflows.record.StreamBuilder; import com.hedera.node.app.state.ReadonlyStatesWrapper; import com.hedera.node.app.state.SingleTransactionRecord; @@ -132,7 +133,7 @@ public static SavepointStackImpl newRootStack( public static SavepointStackImpl newChildStack( @NonNull final SavepointStackImpl root, @NonNull final StreamBuilder.ReversingBehavior reversingBehavior, - @NonNull final HandleContext.TransactionCategory category, + @NonNull final TransactionCategory category, @NonNull final StreamBuilder.TransactionCustomizer customizer, @NonNull final StreamMode streamMode) { return new SavepointStackImpl(root, reversingBehavior, category, customizer, streamMode); @@ -177,7 +178,7 @@ private SavepointStackImpl( private SavepointStackImpl( @NonNull final SavepointStackImpl parent, @NonNull final StreamBuilder.ReversingBehavior reversingBehavior, - @NonNull final HandleContext.TransactionCategory category, + @NonNull final TransactionCategory category, @NonNull final StreamBuilder.TransactionCustomizer customizer, @NonNull final StreamMode streamMode) { requireNonNull(reversingBehavior); @@ -274,6 +275,28 @@ public void rollbackFullStack() { setupFirstSavepoint(baseBuilder.category()); } + /** + * Returns true when this stack's base builder should be finalized with staking rewards. There are + * two qualifying cases: + *

    + *
  1. The stack is for top-level transaction (either a user transaction or a triggered execution + * like a expiring scheduled transaction with {@code wait_for_expiry=true}); or,
  2. + *
  3. The stack is for executing a scheduled transaction with {@code wait_for_expiry=false}, and + * whose triggering parent was a user transaction.
  4. + *
+ * The second category is solely for backward compatibility with mono-service, and should be considered + * for deprecation and removal. + */ + public boolean permitsStakingRewards() { + return builderSink != null + || + // For backward compatibility with mono-service, we permit paying staking rewards to + // scheduled transactions that are exactly children of user transactions + (baseBuilder.category() == SCHEDULED + && state instanceof SavepointStackImpl parent + && parent.txnCategory() == USER); + } + /** * Returns the root {@link ReadableStates} for the given service name. * @@ -397,11 +420,11 @@ public void forEachNonBaseBuilder(@NonNull final Class builderClass, @Non } /** - * Returns the {@link HandleContext.TransactionCategory} of the transaction that created this stack. + * Returns the {@link TransactionCategory} of the transaction that created this stack. * * @return the transaction category */ - public HandleContext.TransactionCategory txnCategory() { + public TransactionCategory txnCategory() { return baseBuilder.category(); } @@ -524,7 +547,7 @@ public HandleOutput buildHandleOutput( return new HandleOutput(blockRecordSource, recordSource); } - private void setupFirstSavepoint(@NonNull final HandleContext.TransactionCategory category) { + private void setupFirstSavepoint(@NonNull final TransactionCategory category) { if (state instanceof SavepointStackImpl parent) { stack.push(new FirstChildSavepoint(new WrappedState(state), parent.peek(), category)); } else { diff --git a/hedera-node/hedera-app/src/test/java/com/hedera/node/app/workflows/handle/DispatchProcessorTest.java b/hedera-node/hedera-app/src/test/java/com/hedera/node/app/workflows/handle/DispatchProcessorTest.java index 0fae990e56b3..0c543ea44844 100644 --- a/hedera-node/hedera-app/src/test/java/com/hedera/node/app/workflows/handle/DispatchProcessorTest.java +++ b/hedera-node/hedera-app/src/test/java/com/hedera/node/app/workflows/handle/DispatchProcessorTest.java @@ -218,9 +218,7 @@ void waivedFeesDoesNotCharge() { .willReturn(true); given(recordBuilder.exchangeRate(any())).willReturn(recordBuilder); given(dispatch.handleContext()).willReturn(context); - given(dispatch.txnCategory()).willReturn(USER); givenAuthorization(); - given(dispatch.txnCategory()).willReturn(USER); subject.processDispatch(dispatch); @@ -564,7 +562,6 @@ void happyPathFreeChildCryptoTransferAsExpected() { given(dispatchValidator.validationReportFor(dispatch)).willReturn(newSuccess(CREATOR_ACCOUNT_ID, PAYER)); given(dispatch.payerId()).willReturn(PAYER_ACCOUNT_ID); given(dispatch.txnInfo()).willReturn(CRYPTO_TRANSFER_TXN_INFO); - given(dispatch.txnCategory()).willReturn(HandleContext.TransactionCategory.CHILD); given(dispatch.handleContext()).willReturn(context); givenAuthorization(CRYPTO_TRANSFER_TXN_INFO); @@ -648,11 +645,7 @@ private void assertFinished() { private void assertFinished(@NonNull final IsRootStack isRootStack) { verify(recordFinalizer).finalizeRecord(dispatch); - if (isRootStack == IsRootStack.YES) { - verify(stack).commitTransaction(any()); - } else { - verify(stack).commitFullStack(); - } + verify(stack).commitFullStack(); } private void verifyTrackedFeePayments() { diff --git a/hedera-node/hedera-app/src/test/java/com/hedera/node/app/workflows/handle/dispatch/RecordFinalizerTest.java b/hedera-node/hedera-app/src/test/java/com/hedera/node/app/workflows/handle/dispatch/RecordFinalizerTest.java index eab0e85b9eb8..8433a9f0f139 100644 --- a/hedera-node/hedera-app/src/test/java/com/hedera/node/app/workflows/handle/dispatch/RecordFinalizerTest.java +++ b/hedera-node/hedera-app/src/test/java/com/hedera/node/app/workflows/handle/dispatch/RecordFinalizerTest.java @@ -20,7 +20,6 @@ import static com.hedera.hapi.node.base.ResponseCodeEnum.INVALID_TRANSACTION; import static com.hedera.hapi.node.base.ResponseCodeEnum.SUCCESS; import static com.hedera.hapi.node.base.ResponseCodeEnum.TOKEN_NOT_ASSOCIATED_TO_ACCOUNT; -import static com.hedera.node.app.spi.workflows.HandleContext.TransactionCategory.CHILD; import static com.hedera.node.app.spi.workflows.HandleContext.TransactionCategory.USER; import static com.hedera.node.app.spi.workflows.record.StreamBuilder.ReversingBehavior.REVERSIBLE; import static com.hedera.node.app.spi.workflows.record.StreamBuilder.TransactionCustomizer.NOOP_TRANSACTION_CUSTOMIZER; @@ -28,9 +27,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -48,6 +47,7 @@ import com.hedera.node.app.workflows.TransactionInfo; import com.hedera.node.app.workflows.handle.Dispatch; import com.hedera.node.app.workflows.handle.record.RecordStreamBuilder; +import com.hedera.node.app.workflows.handle.stack.SavepointStackImpl; import com.hedera.pbj.runtime.io.buffer.Bytes; import java.time.Instant; import java.util.List; @@ -69,6 +69,9 @@ public class RecordFinalizerTest { @Mock private Dispatch dispatch; + @Mock + private SavepointStackImpl stack; + @Mock private FinalizeContext finalizeContext; @@ -114,8 +117,9 @@ void setUp() { } @Test - public void testFinalizeRecordUserTransaction() { - when(dispatch.txnCategory()).thenReturn(USER); + public void finalizesStakingRecordForScheduledDispatchOfUserTxn() { + given(dispatch.stack()).willReturn(stack); + given(stack.permitsStakingRewards()).willReturn(true); when(dispatch.handleContext().dispatchPaidRewards()).thenReturn(Map.of()); @@ -126,12 +130,13 @@ public void testFinalizeRecordUserTransaction() { } @Test - public void testFinalizeRecordChildTransaction() { - when(dispatch.txnCategory()).thenReturn(CHILD); + public void finalizesNonStakingRecordForIneligibleDispatchStack() { + given(dispatch.stack()).willReturn(stack); subject.finalizeRecord(dispatch); + verify(finalizeRecordHandler, never()).finalizeStakingRecord(any(), any(), any(), any()); - verify(finalizeRecordHandler, times(1)).finalizeNonStakingRecord(any(), any()); + verify(finalizeRecordHandler).finalizeNonStakingRecord(any(), any()); } @Test diff --git a/hedera-node/hedera-app/src/test/java/com/hedera/node/app/workflows/handle/stack/SavepointStackImplTest.java b/hedera-node/hedera-app/src/test/java/com/hedera/node/app/workflows/handle/stack/SavepointStackImplTest.java index 489c79b66a74..a323d6fb1d38 100644 --- a/hedera-node/hedera-app/src/test/java/com/hedera/node/app/workflows/handle/stack/SavepointStackImplTest.java +++ b/hedera-node/hedera-app/src/test/java/com/hedera/node/app/workflows/handle/stack/SavepointStackImplTest.java @@ -16,14 +16,19 @@ package com.hedera.node.app.workflows.handle.stack; +import static com.hedera.node.app.spi.workflows.HandleContext.TransactionCategory.SCHEDULED; +import static com.hedera.node.app.spi.workflows.record.StreamBuilder.ReversingBehavior.REVERSIBLE; +import static com.hedera.node.app.spi.workflows.record.StreamBuilder.TransactionCustomizer.NOOP_TRANSACTION_CUSTOMIZER; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; import static org.mockito.Mock.Strictness.LENIENT; import static org.mockito.Mockito.when; import com.hedera.node.app.blocks.impl.BoundaryStateChangeListener; import com.hedera.node.app.blocks.impl.KVStateChangeListener; +import com.hedera.node.app.spi.workflows.HandleContext; import com.hedera.node.config.VersionedConfigImpl; import com.hedera.node.config.data.BlockStreamConfig; import com.hedera.node.config.testfixtures.HederaTestConfigBuilder; @@ -48,7 +53,6 @@ @ExtendWith(MockitoExtension.class) class SavepointStackImplTest extends StateTestBase { - private static final String FOOD_SERVICE = "FOOD_SERVICE"; private final Map BASE_DATA = Map.of( @@ -63,6 +67,12 @@ class SavepointStackImplTest extends StateTestBase { @Mock(strictness = LENIENT) private State baseState; + @Mock + private SavepointStackImpl parent; + + @Mock + private Savepoint savepoint; + @Mock private BoundaryStateChangeListener roundStateChangeListener; @@ -82,6 +92,46 @@ void setup() { streamMode = config.getConfigData(BlockStreamConfig.class).streamMode(); } + @Test + void topLevelPermitsStakingRewards() { + final var subject = SavepointStackImpl.newRootStack( + baseState, 3, 50, roundStateChangeListener, kvStateChangeListener, StreamMode.BOTH); + assertThat(subject.permitsStakingRewards()).isTrue(); + } + + @Test + void childDoesNotPermitStakingRewardsIfNotScheduled() { + given(parent.peek()).willReturn(savepoint); + given(savepoint.followingCapacity()).willReturn(123); + final var subject = SavepointStackImpl.newChildStack( + parent, + REVERSIBLE, + HandleContext.TransactionCategory.CHILD, + NOOP_TRANSACTION_CUSTOMIZER, + StreamMode.BOTH); + assertThat(subject.permitsStakingRewards()).isFalse(); + } + + @Test + void childDoesNotPermitStakingRewardsIfNotScheduledByUser() { + given(parent.peek()).willReturn(savepoint); + given(savepoint.followingCapacity()).willReturn(123); + given(parent.txnCategory()).willReturn(HandleContext.TransactionCategory.CHILD); + final var subject = SavepointStackImpl.newChildStack( + parent, REVERSIBLE, SCHEDULED, NOOP_TRANSACTION_CUSTOMIZER, StreamMode.BOTH); + assertThat(subject.permitsStakingRewards()).isFalse(); + } + + @Test + void scheduledTopLevelIfSchedulingParentIsUser() { + given(parent.peek()).willReturn(savepoint); + given(savepoint.followingCapacity()).willReturn(123); + given(parent.txnCategory()).willReturn(HandleContext.TransactionCategory.USER); + final var subject = SavepointStackImpl.newChildStack( + parent, REVERSIBLE, SCHEDULED, NOOP_TRANSACTION_CUSTOMIZER, StreamMode.BOTH); + assertThat(subject.permitsStakingRewards()).isTrue(); + } + @Test void testConstructor() { // when diff --git a/hedera-node/hedera-app/src/testFixtures/java/com/hedera/node/app/fixtures/state/FakeState.java b/hedera-node/hedera-app/src/testFixtures/java/com/hedera/node/app/fixtures/state/FakeState.java index 20a963c30f68..635e1dfd7eef 100644 --- a/hedera-node/hedera-app/src/testFixtures/java/com/hedera/node/app/fixtures/state/FakeState.java +++ b/hedera-node/hedera-app/src/testFixtures/java/com/hedera/node/app/fixtures/state/FakeState.java @@ -61,6 +61,14 @@ public class FakeState implements State { */ private final List listeners = new ArrayList<>(); + /** + * Exposes the underlying states for direct manipulation in tests. + * @return the states + */ + public Map> getStates() { + return states; + } + /** * Adds to the service with the given name the {@link ReadableKVState} {@code states} */ @@ -73,8 +81,7 @@ public FakeState addService(@NonNull final String serviceName, @NonNull final Ma }); // Purge any readable or writable states whose state definitions are now stale, // since they don't include the new data sources we just added - readableStates.remove(serviceName); - writableStates.remove(serviceName); + purgeStatesCaches(serviceName); return this; } @@ -91,6 +98,7 @@ public void removeServiceState(@NonNull final String serviceName, @NonNull final v.remove(stateKey); return v; }); + purgeStatesCaches(serviceName); } @NonNull @@ -242,4 +250,9 @@ public void mapDeleteChange(@NonNull final K key) { } }); } + + private void purgeStatesCaches(@NonNull final String serviceName) { + readableStates.remove(serviceName); + writableStates.remove(serviceName); + } } diff --git a/hedera-node/hedera-schedule-service-impl/src/main/java/com/hedera/node/app/service/schedule/impl/ScheduleServiceImpl.java b/hedera-node/hedera-schedule-service-impl/src/main/java/com/hedera/node/app/service/schedule/impl/ScheduleServiceImpl.java index 37a4f9c1ee3b..26b557eaf3b6 100644 --- a/hedera-node/hedera-schedule-service-impl/src/main/java/com/hedera/node/app/service/schedule/impl/ScheduleServiceImpl.java +++ b/hedera-node/hedera-schedule-service-impl/src/main/java/com/hedera/node/app/service/schedule/impl/ScheduleServiceImpl.java @@ -65,6 +65,14 @@ public ExecutableTxnIterator executableTxns( * {@link ExecutableTxnIterator#purgeUntilNext()}. */ private static class PurgingIterator implements ExecutableTxnIterator { + /** + * No loop should exceed this iteration limit; if that happens, the iterator will throw an unchecked + * exception to trigger the handle workflow to skip over the interval used to construct this iterator + * and log an {@code ERROR} event. (The limit is up to an interval of hundred days being processed + * at once; or a day in which every second had the maximum 100 of transactions scheduled.) + */ + private static final int LOOP_INVARIANT_LIMIT = 86_400 * 100; + private static final Comparator ORDER_COMPARATOR = Comparator.comparingLong(ScheduledOrder::expirySecond).thenComparingInt(ScheduledOrder::orderNumber); @@ -148,9 +156,15 @@ public void remove() { } // Pointer to the order whose executable transaction metadata should be purged var order = requireNonNull(previousOrder); + int i = LOOP_INVARIANT_LIMIT; while (ORDER_COMPARATOR.compare(order, nextOrder) <= 0) { final var lastOfSecond = scheduleStore.purgeByOrder(order); order = next(order, lastOfSecond); + i--; + if (i == 0) { + throw new IllegalStateException("Loop invariant limit exceeded during remove() after comparing " + + order + " to " + nextOrder); + } } candidateOrder = order; previousOrder = null; @@ -159,14 +173,21 @@ public void remove() { @Override public boolean purgeUntilNext() { if (!nextKnown) { - throw new IllegalStateException("purgeUntilNext() called before next()"); + throw new IllegalStateException("purgeUntilNext() called before hasNext()"); } if (previousOrder != null) { var order = previousOrder; final var boundaryOrder = nextOrder != null ? nextOrder : new ScheduledOrder(endSecond + 1, 0); + int i = LOOP_INVARIANT_LIMIT; while (ORDER_COMPARATOR.compare(order, boundaryOrder) < 0) { final var lastOfSecond = scheduleStore.purgeByOrder(order); order = next(order, lastOfSecond); + i--; + if (i == 0) { + throw new IllegalStateException( + "Loop invariant limit exceeded during purgeUntilNext() after comparing " + order + + " to " + boundaryOrder); + } } return true; } @@ -202,6 +223,7 @@ public boolean purgeUntilNext() { order = new ScheduledOrder(startSecond, startCounts.numberProcessed()); } } + int i = LOOP_INVARIANT_LIMIT; while (order.expirySecond() <= endSecond) { final var nextId = scheduleStore.getByOrder(order); if (nextId != null) { @@ -219,6 +241,11 @@ public boolean purgeUntilNext() { } else { order = next(order, true); } + i--; + if (i == 0) { + throw new IllegalStateException("Loop invariant limit exceeded during prepNext() after comparing " + + order + " expiry second to " + endSecond); + } } nextKnown = true; return nextOrder; diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/embedded/AbstractEmbeddedHedera.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/embedded/AbstractEmbeddedHedera.java index 405a05a62378..1c7626c1a99e 100644 --- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/embedded/AbstractEmbeddedHedera.java +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/embedded/AbstractEmbeddedHedera.java @@ -213,9 +213,10 @@ public Timestamp nextValidStart() { candidateNano = 0; nextNano.set(1); } + final var then = now().minusSeconds(validStartOffsetSecs()).minusNanos(candidateNano); return Timestamp.newBuilder() - .setSeconds(now().getEpochSecond() - validStartOffsetSecs()) - .setNanos(candidateNano) + .setSeconds(then.getEpochSecond()) + .setNanos(then.getNano()) .build(); } @@ -256,10 +257,12 @@ public TransactionResponse submit( } /** - * If block stream is enabled, notify the block stream manager of the state hash at the end of the round. - * @param roundNumber the round number + * If block stream is enabled, notifies the block stream manager of the state hash at the end of the round + * given by {@code roundNumber}. (The block stream manager must have this information to construct the + * block hash for round {@code roundNumber + 1}.) + * @param roundNumber the round number of the state hash */ - protected void notifyBlockStreamManagerIfEnabled(final long roundNumber) { + protected void notifyStateHashed(final long roundNumber) { if (blockStreamEnabled) { hedera.blockStreamManager().notify(new StateHashedNotification(roundNumber, FAKE_START_OF_STATE_HASH)); } diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/embedded/ConcurrentEmbeddedHedera.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/embedded/ConcurrentEmbeddedHedera.java index 1bb5aadd4b4e..8b0d1fcd9ffa 100644 --- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/embedded/ConcurrentEmbeddedHedera.java +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/embedded/ConcurrentEmbeddedHedera.java @@ -162,7 +162,7 @@ private void handleTransactions() { final var round = new FakeRound(roundNo.getAndIncrement(), roster, consensusEvents); hedera.handleWorkflow().handleRound(state, round); hedera.onSealConsensusRound(round, state); - notifyBlockStreamManagerIfEnabled(round.getRoundNum()); + notifyStateHashed(round.getRoundNum()); prehandledEvents.clear(); } // Now drain all events that will go in the next round and pre-handle them diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/embedded/RepeatableEmbeddedHedera.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/embedded/RepeatableEmbeddedHedera.java index be752a3f03d7..e6a250279da6 100644 --- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/embedded/RepeatableEmbeddedHedera.java +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/embedded/RepeatableEmbeddedHedera.java @@ -16,6 +16,7 @@ package com.hedera.services.bdd.junit.hedera.embedded; +import static com.hedera.hapi.node.base.HederaFunctionality.TSS_SHARE_SIGNATURE; import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.OK; import static com.swirlds.platform.system.transaction.TransactionWrapperUtils.createAppPayloadWrapper; import static java.util.Objects.requireNonNull; @@ -49,10 +50,18 @@ */ public class RepeatableEmbeddedHedera extends AbstractEmbeddedHedera implements EmbeddedHedera { private static final Instant FIXED_POINT = Instant.parse("2024-06-24T12:05:41.487328Z"); - private static final Duration SIMULATED_ROUND_DURATION = Duration.ofSeconds(1); + + // Using a default round duration of one second makes it easier to structure tests with + // time-based events like transactions scheduled with wait_for_expiry=true + public static final Duration DEFAULT_ROUND_DURATION = Duration.ofSeconds(1); + private final FakeTime time = new FakeTime(FIXED_POINT, Duration.ZERO); private final SynchronousFakePlatform platform; + // The amount of consensus time that will be simulated to elapse before the next transaction---note + // that in repeatable mode, every transaction gets its own event, and each event gets its own round + private Duration roundDuration = DEFAULT_ROUND_DURATION; + public RepeatableEmbeddedHedera(@NonNull final EmbeddedNode node) { super(node); platform = new SynchronousFakePlatform(defaultNodeId, addressBook, executorService); @@ -121,16 +130,31 @@ public long lastRoundNo() { return platform.lastRoundNo(); } - private void handleNextRound(boolean skipsSignatureTxn) { + /** + * Sets the duration of each simulated consensus round, and hence the consensus time that will + * elapse before the next transaction is handled. + * @param roundDuration the duration of each simulated round + */ + public void setRoundDuration(@NonNull final Duration roundDuration) { + this.roundDuration = requireNonNull(roundDuration); + } + + /** + * Executes the transaction in the last-created event within its own round, unless that transaction + * is a {@link HederaFunctionality#TSS_SHARE_SIGNATURE} transaction and we are instructed to skip + * signatures. + * @param skipsSignatureTxn whether to skip handling the last-created event if it is a signature txn + */ + private void handleNextRound(final boolean skipsSignatureTxn) { hedera.onPreHandle(platform.lastCreatedEvent, state); - if (skipsSignatureTxn && platform.lastCreatedEvent.function() == HederaFunctionality.TSS_SHARE_SIGNATURE) { + if (skipsSignatureTxn && platform.lastCreatedEvent.function() == TSS_SHARE_SIGNATURE) { return; } final var round = platform.nextConsensusRound(); // Handle each transaction in own round hedera.handleWorkflow().handleRound(state, round); hedera.onSealConsensusRound(round, state); - notifyBlockStreamManagerIfEnabled(round.getRoundNum()); + notifyStateHashed(round.getRoundNum()); } private class SynchronousFakePlatform extends AbstractFakePlatform implements Platform { @@ -156,7 +180,7 @@ public void start() { } private Round nextConsensusRound() { - time.tick(SIMULATED_ROUND_DURATION); + time.tick(roundDuration); final var firstRoundTime = time.now(); final var consensusEvents = List.of(new FakeConsensusEvent( requireNonNull(lastCreatedEvent), diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/queries/QueryVerbs.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/queries/QueryVerbs.java index 52055c076c50..f844861d9601 100644 --- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/queries/QueryVerbs.java +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/queries/QueryVerbs.java @@ -16,6 +16,7 @@ package com.hedera.services.bdd.spec.queries; +import static com.hedera.node.app.hapi.utils.CommonPbjConverters.pbjToProto; import static com.hedera.services.bdd.spec.queries.contract.HapiContractCallLocal.fromDetails; import static com.hedera.services.bdd.suites.contract.Utils.FunctionType.FUNCTION; import static com.hedera.services.bdd.suites.contract.Utils.getABIFor; @@ -101,6 +102,11 @@ public static HapiGetTxnRecord getTxnRecord(final TransactionID txnId) { return new HapiGetTxnRecord(txnId); } + public static HapiGetTxnRecord getTxnRecord(final com.hedera.hapi.node.base.TransactionID txnId) { + return new HapiGetTxnRecord( + pbjToProto(txnId, com.hedera.hapi.node.base.TransactionID.class, TransactionID.class)); + } + public static HapiGetContractInfo getContractInfo(final String contract) { return new HapiGetContractInfo(contract); } diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/queries/meta/HapiGetTxnRecord.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/queries/meta/HapiGetTxnRecord.java index eaf8af5f0555..ef00884462e8 100644 --- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/queries/meta/HapiGetTxnRecord.java +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/queries/meta/HapiGetTxnRecord.java @@ -863,12 +863,7 @@ private void validateAccountAmount( final var found = foundInAccountAmountsList(accountID, amount, accountAmountsList); assertTrue( found, - "Cannot find AccountID: " - + accountID - + " and amount: " - + amount - + " in the transferList of the " - + "txnRecord"); + "Cannot find AccountID: " + accountID + " and amount: " + amount + " in :: " + accountAmountsList); } private boolean foundInAccountAmountsList( diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/transactions/schedule/HapiScheduleCreate.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/transactions/schedule/HapiScheduleCreate.java index a45ccad14a9c..af0fb6a69594 100644 --- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/transactions/schedule/HapiScheduleCreate.java +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/transactions/schedule/HapiScheduleCreate.java @@ -172,12 +172,12 @@ public HapiScheduleCreate waitForExpiry(boolean value) { public HapiScheduleCreate expiringAt(final long expiry) { this.longTermExpiry = expiry; - return waitForExpiry(); + return this; } public HapiScheduleCreate expiringIn(final long lifetime) { this.longTermLifetime = lifetime; - return waitForExpiry(); + return this; } public HapiScheduleCreate withRelativeExpiry(String txnId, long offsetSeconds) { diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/utilops/EmbeddedVerbs.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/utilops/EmbeddedVerbs.java index a559bde83c3a..b390b7b23f97 100644 --- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/utilops/EmbeddedVerbs.java +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/utilops/EmbeddedVerbs.java @@ -66,6 +66,7 @@ import com.swirlds.state.spi.WritableKVState; import edu.umd.cs.findbugs.annotations.NonNull; import java.time.Duration; +import java.time.Instant; import java.util.function.Consumer; import java.util.function.IntConsumer; @@ -315,6 +316,18 @@ public static ViewPendingAirdropOp viewAccountPendingAirdrop( return new ViewPendingAirdropOp(tokenName, senderName, receiverName, observer); } + /** + * Returns an operation that sleeps until the given instant when in repeatable mode. + * @param then the instant to sleep until + * @return the operation that will sleep until the given instant in repeatable mode + */ + public static SpecOperation sleepToExactly(@NonNull final Instant then) { + return doingContextual(spec -> { + final var embeddedHedera = spec.repeatableEmbeddedHederaOrThrow(); + embeddedHedera.tick(Duration.between(spec.consensusTime(), then)); + }); + } + /** * Returns an operation that changes the state of an embedded network to appear to be handling * the first transaction after an upgrade. diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/utilops/UtilVerbs.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/utilops/UtilVerbs.java index 6edd01472637..fc2f08f222f6 100644 --- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/utilops/UtilVerbs.java +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/utilops/UtilVerbs.java @@ -17,6 +17,7 @@ package com.hedera.services.bdd.spec.utilops; import static com.hedera.node.app.hapi.utils.CommonPbjConverters.fromByteString; +import static com.hedera.node.app.hapi.utils.CommonPbjConverters.protoToPbj; import static com.hedera.node.app.hapi.utils.CommonUtils.asEvmAddress; import static com.hedera.node.app.hapi.utils.EthSigsUtils.recoverAddressFromPubKey; import static com.hedera.node.app.service.contract.impl.utils.ConversionUtils.explicitFromHeadlong; @@ -93,17 +94,21 @@ import com.esaulpaugh.headlong.abi.Address; import com.esaulpaugh.headlong.abi.Tuple; import com.google.protobuf.ByteString; +import com.hedera.hapi.block.stream.output.TransactionResult; +import com.hedera.hapi.node.base.TransactionID; import com.hedera.hapi.node.state.addressbook.Node; import com.hedera.hapi.node.state.token.Account; import com.hedera.services.bdd.junit.hedera.MarkerFile; import com.hedera.services.bdd.junit.hedera.NodeSelector; import com.hedera.services.bdd.junit.hedera.embedded.EmbeddedNetwork; import com.hedera.services.bdd.junit.hedera.embedded.SyntheticVersion; +import com.hedera.services.bdd.junit.support.translators.inputs.TransactionParts; import com.hedera.services.bdd.spec.HapiSpec; import com.hedera.services.bdd.spec.HapiSpecOperation; import com.hedera.services.bdd.spec.SpecOperation; import com.hedera.services.bdd.spec.assertions.TransactionRecordAsserts; import com.hedera.services.bdd.spec.infrastructure.OpProvider; +import com.hedera.services.bdd.spec.infrastructure.RegistryNotFound; import com.hedera.services.bdd.spec.keys.KeyShape; import com.hedera.services.bdd.spec.queries.HapiQueryOp; import com.hedera.services.bdd.spec.queries.meta.HapiGetTxnRecord; @@ -2151,6 +2156,97 @@ public static HapiSpecOperation assumingNoStakingChildRecordCausesMaxChildRecord })); } + /** + * Asserts that a scheduled execution is as expected. + */ + public interface ScheduledExecutionAssertion { + /** + * Tests that a scheduled execution body and result are as expected within the given spec. + * @param spec the context in which the assertion is being made + * @param body the transaction body of the scheduled execution + * @param result the transaction result of the scheduled execution + * @throws AssertionError if the assertion fails + */ + void test( + @NonNull HapiSpec spec, + @NonNull com.hedera.hapi.node.transaction.TransactionBody body, + @NonNull TransactionResult result); + } + + /** + * Returns a {@link ScheduledExecutionAssertion} that asserts the status of the execution result + * is as expected; and that the record of the scheduled execution is queryable, again with the expected status. + * @param status the expected status + * @return the assertion + */ + public static ScheduledExecutionAssertion withStatus( + @NonNull final com.hedera.hapi.node.base.ResponseCodeEnum status) { + requireNonNull(status); + return (spec, body, result) -> { + assertEquals(status, result.status()); + allRunFor(spec, getTxnRecord(body.transactionIDOrThrow()).assertingNothingAboutHashes()); + }; + } + + /** + * Returns a {@link ScheduledExecutionAssertion} that asserts the status of the execution result + * is as expected; and that a query for its record, customized by the given spec, passes. + * @return the assertion + */ + public static ScheduledExecutionAssertion withRecordSpec(@NonNull final Consumer querySpec) { + requireNonNull(querySpec); + return (spec, body, result) -> { + final var op = getTxnRecord(body.transactionIDOrThrow()).assertingNothingAboutHashes(); + querySpec.accept(op); + try { + allRunFor(spec, op); + } catch (Exception e) { + Assertions.fail(Optional.ofNullable(e.getCause()).orElse(e).getMessage()); + } + }; + } + + /** + * Returns a {@link BlockStreamAssertion} factory that asserts the result of a scheduled execution + * of the given named transaction passes the given assertion. + * @param creationTxn the name of the transaction that created the scheduled execution + * @param assertion the assertion to apply to the scheduled execution + * @return a factory for a {@link BlockStreamAssertion} that asserts the result of the scheduled execution + */ + public static Function scheduledExecutionResult( + @NonNull final String creationTxn, @NonNull final ScheduledExecutionAssertion assertion) { + requireNonNull(creationTxn); + requireNonNull(assertion); + return spec -> block -> { + final com.hederahashgraph.api.proto.java.TransactionID creationTxnId; + try { + creationTxnId = spec.registry().getTxnId(creationTxn); + } catch (RegistryNotFound ignore) { + return false; + } + final var executionTxnId = + protoToPbj(creationTxnId.toBuilder().setScheduled(true).build(), TransactionID.class); + final var items = block.items(); + for (int i = 0, n = items.size(); i < n; i++) { + final var item = items.get(i); + if (item.hasEventTransaction()) { + final var parts = + TransactionParts.from(item.eventTransactionOrThrow().applicationTransactionOrThrow()); + if (parts.transactionIdOrThrow().equals(executionTxnId)) { + for (int j = i + 1; j < n; j++) { + final var followingItem = items.get(j); + if (followingItem.hasTransactionResult()) { + assertion.test(spec, parts.body(), followingItem.transactionResultOrThrow()); + return true; + } + } + } + } + } + return false; + }; + } + public static class TransferListBuilder { private Tuple transferList; diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/hip423/LongTermScheduleUtils.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/hip423/LongTermScheduleUtils.java index 8ef04a3f0026..ccc568b68a8e 100644 --- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/hip423/LongTermScheduleUtils.java +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/hip423/LongTermScheduleUtils.java @@ -16,18 +16,23 @@ package com.hedera.services.bdd.suites.hip423; +import static com.hedera.services.bdd.spec.queries.QueryVerbs.getScheduleInfo; import static com.hedera.services.bdd.spec.transactions.TxnUtils.randomUppercase; +import static com.hedera.services.bdd.spec.transactions.TxnVerbs.cryptoCreate; import static com.hedera.services.bdd.spec.transactions.TxnVerbs.scheduleCreate; import static com.hedera.services.bdd.spec.utilops.UtilVerbs.buildUpgradeZipFrom; import static com.hedera.services.bdd.spec.utilops.UtilVerbs.prepareUpgrade; +import static com.hedera.services.bdd.spec.utilops.UtilVerbs.sleepForSeconds; import static com.hedera.services.bdd.spec.utilops.UtilVerbs.sourcing; import static com.hedera.services.bdd.spec.utilops.UtilVerbs.updateSpecialFile; import static com.hedera.services.bdd.spec.utilops.upgrade.BuildUpgradeZipOp.FAKE_UPGRADE_ZIP_LOC; import static com.hedera.services.bdd.suites.HapiSuite.GENESIS; +import static com.hedera.services.bdd.suites.HapiSuite.flattened; import static com.hedera.services.bdd.suites.freeze.CommonUpgradeResources.DEFAULT_UPGRADE_FILE_ID; import static com.hedera.services.bdd.suites.freeze.CommonUpgradeResources.FAKE_ASSETS_LOC; import static com.hedera.services.bdd.suites.freeze.CommonUpgradeResources.upgradeFileAppendsPerBurst; import static com.hedera.services.bdd.suites.freeze.CommonUpgradeResources.upgradeFileHashAt; +import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.INVALID_SCHEDULE_ID; import com.hedera.services.bdd.spec.SpecOperation; import com.hedera.services.bdd.spec.queries.meta.HapiGetTxnRecord; @@ -39,15 +44,17 @@ public final class LongTermScheduleUtils { - static final String SENDER = "sender"; + public static final String SENDER = "sender"; + public static final String SENDER_KEY = "senderKey"; + public static final String SENDER_TXN = "senderTxn"; + public static final String NEW_SENDER_KEY = "newSenderKey"; + public static final String RECEIVER = "receiver"; + public static final String CREATE_TXN = "createTxn"; static final String PAYER = "payer"; static final String ADMIN = "admin"; static final String EXTRA_KEY = "extraKey"; static final String SHARED_KEY = "sharedKey"; - static final String NEW_SENDER_KEY = "newSenderKey"; - static final String SENDER_TXN = "senderTxn"; - static final String CREATE_TXN = "createTxn"; - static final String RECEIVER = "receiver"; + static final String TRIGGERING_TXN = "triggeringTxn"; static final String BASIC_XFER = "basicXfer"; static final String TWO_SIG_XFER = "twoSigXfer"; static final String DEFERRED_XFER = "deferredXfer"; @@ -127,8 +134,19 @@ static SpecOperation[] scheduleFakeUpgrade( .designatingPayer(GENESIS) .payingWith(payer) .recordingScheduledTxn() + .waitForExpiry() .expiringIn(lifetime) .via(via))); return operations.toArray(SpecOperation[]::new); } + + public static SpecOperation[] triggerSchedule(String schedule, long waitForSeconds) { + return flattened( + sleepForSeconds(waitForSeconds), + cryptoCreate("foo").via(TRIGGERING_TXN), + // Pause execution for 1 second to allow time for the scheduled transaction to be + // processed and removed from the state + sleepForSeconds(1), + getScheduleInfo(schedule).hasCostAnswerPrecheck(INVALID_SCHEDULE_ID)); + } } diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/hip423/ScheduleLongTermExecutionTest.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/hip423/ScheduleLongTermExecutionTest.java index 02aac454f35c..82dd8f293c42 100644 --- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/hip423/ScheduleLongTermExecutionTest.java +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/hip423/ScheduleLongTermExecutionTest.java @@ -73,6 +73,7 @@ import static com.hedera.services.bdd.suites.hip423.LongTermScheduleUtils.WRONG_TRANSFER_LIST; import static com.hedera.services.bdd.suites.hip423.LongTermScheduleUtils.scheduleFakeUpgrade; import static com.hedera.services.bdd.suites.hip423.LongTermScheduleUtils.transferListCheck; +import static com.hedera.services.bdd.suites.hip423.LongTermScheduleUtils.triggerSchedule; import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.ACCOUNT_DELETED; import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.AUTHORIZATION_FAILED; import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.INSUFFICIENT_ACCOUNT_BALANCE; @@ -1067,34 +1068,29 @@ public Stream executionTriggersWithWeirdlyRepeatedKey() { @HapiTest @Order(16) final Stream scheduledFreezeWorksAsExpected() { - - return defaultHapiSpec("ScheduledFreezeWorksAsExpectedAtExpiry") - .given(flattened( - cryptoCreate(PAYING_ACCOUNT).via(PAYER_TXN), - scheduleFakeUpgrade(PAYING_ACCOUNT, 4, SUCCESS_TXN))) - .when(scheduleSign(VALID_SCHEDULE) + return hapiTest(flattened( + cryptoCreate(PAYING_ACCOUNT).via(PAYER_TXN), + scheduleFakeUpgrade(PAYING_ACCOUNT, 4, SUCCESS_TXN), + scheduleSign(VALID_SCHEDULE) .alsoSigningWith(GENESIS) .payingWith(PAYING_ACCOUNT) - .hasKnownStatus(SUCCESS)) - .then( - getScheduleInfo(VALID_SCHEDULE) - .hasScheduleId(VALID_SCHEDULE) - .hasWaitForExpiry() - .isNotExecuted() - .isNotDeleted() - .hasRecordedScheduledTxn(), - sleepFor(5000), - cryptoCreate("foo").via(TRIGGERING_TXN), - getScheduleInfo(VALID_SCHEDULE).hasCostAnswerPrecheck(INVALID_SCHEDULE_ID), - freezeAbort().payingWith(GENESIS), - withOpContext((spec, opLog) -> { - var triggeredTx = getTxnRecord(SUCCESS_TXN).scheduled(); - allRunFor(spec, triggeredTx); - Assertions.assertEquals( - SUCCESS, - triggeredTx.getResponseRecord().getReceipt().getStatus(), - SCHEDULED_TRANSACTION_MUST_NOT_SUCCEED); - })); + .hasKnownStatus(SUCCESS), + getScheduleInfo(VALID_SCHEDULE) + .hasScheduleId(VALID_SCHEDULE) + .hasWaitForExpiry() + .isNotExecuted() + .isNotDeleted() + .hasRecordedScheduledTxn(), + triggerSchedule(VALID_SCHEDULE, 5), + freezeAbort().payingWith(GENESIS), + withOpContext((spec, opLog) -> { + var triggeredTx = getTxnRecord(SUCCESS_TXN).scheduled(); + allRunFor(spec, triggeredTx); + Assertions.assertEquals( + SUCCESS, + triggeredTx.getResponseRecord().getReceipt().getStatus(), + SCHEDULED_TRANSACTION_MUST_NOT_SUCCEED); + }))); } @HapiTest diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/integration/RepeatableHip423Tests.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/integration/RepeatableHip423Tests.java index 4693740247db..90e917317911 100644 --- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/integration/RepeatableHip423Tests.java +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/integration/RepeatableHip423Tests.java @@ -18,6 +18,7 @@ import static com.hedera.hapi.node.base.ResponseCodeEnum.INVALID_SIGNATURE; import static com.hedera.hapi.node.base.ResponseCodeEnum.SUCCESS; +import static com.hedera.hapi.node.base.ResponseCodeEnum.TRANSFER_LIST_SIZE_LIMIT_EXCEEDED; import static com.hedera.node.app.hapi.utils.CommonPbjConverters.protoToPbj; import static com.hedera.node.app.service.schedule.impl.ScheduleStoreUtility.calculateBytesHash; import static com.hedera.node.app.service.schedule.impl.schemas.V0490ScheduleSchema.SCHEDULES_BY_ID_KEY; @@ -30,19 +31,44 @@ import static com.hedera.services.bdd.junit.RepeatableReason.NEEDS_VIRTUAL_TIME_FOR_FAST_EXECUTION; import static com.hedera.services.bdd.junit.RepeatableReason.THROTTLE_OVERRIDES; import static com.hedera.services.bdd.junit.TestTags.INTEGRATION; +import static com.hedera.services.bdd.junit.hedera.NodeSelector.byNodeId; import static com.hedera.services.bdd.junit.hedera.embedded.EmbeddedMode.REPEATABLE; +import static com.hedera.services.bdd.junit.hedera.embedded.RepeatableEmbeddedHedera.DEFAULT_ROUND_DURATION; import static com.hedera.services.bdd.spec.HapiSpec.hapiTest; +import static com.hedera.services.bdd.spec.keys.ControlForKey.forKey; +import static com.hedera.services.bdd.spec.keys.KeyShape.CONTRACT; +import static com.hedera.services.bdd.spec.keys.KeyShape.ED25519; +import static com.hedera.services.bdd.spec.keys.KeyShape.sigs; +import static com.hedera.services.bdd.spec.keys.KeyShape.threshOf; +import static com.hedera.services.bdd.spec.keys.SigControl.OFF; +import static com.hedera.services.bdd.spec.keys.SigControl.ON; import static com.hedera.services.bdd.spec.queries.QueryVerbs.getAccountBalance; +import static com.hedera.services.bdd.spec.queries.QueryVerbs.getFileContents; +import static com.hedera.services.bdd.spec.queries.QueryVerbs.getScheduleInfo; import static com.hedera.services.bdd.spec.queries.QueryVerbs.getTxnRecord; +import static com.hedera.services.bdd.spec.transactions.TxnVerbs.burnToken; +import static com.hedera.services.bdd.spec.transactions.TxnVerbs.contractCall; +import static com.hedera.services.bdd.spec.transactions.TxnVerbs.contractCreate; import static com.hedera.services.bdd.spec.transactions.TxnVerbs.createTopic; import static com.hedera.services.bdd.spec.transactions.TxnVerbs.cryptoCreate; import static com.hedera.services.bdd.spec.transactions.TxnVerbs.cryptoTransfer; +import static com.hedera.services.bdd.spec.transactions.TxnVerbs.cryptoUpdate; +import static com.hedera.services.bdd.spec.transactions.TxnVerbs.fileUpdate; import static com.hedera.services.bdd.spec.transactions.TxnVerbs.scheduleCreate; import static com.hedera.services.bdd.spec.transactions.TxnVerbs.scheduleDelete; import static com.hedera.services.bdd.spec.transactions.TxnVerbs.scheduleSign; +import static com.hedera.services.bdd.spec.transactions.TxnVerbs.submitMessageTo; +import static com.hedera.services.bdd.spec.transactions.TxnVerbs.tokenAssociate; +import static com.hedera.services.bdd.spec.transactions.TxnVerbs.tokenCreate; +import static com.hedera.services.bdd.spec.transactions.TxnVerbs.tokenFeeScheduleUpdate; +import static com.hedera.services.bdd.spec.transactions.TxnVerbs.tokenUpdate; +import static com.hedera.services.bdd.spec.transactions.TxnVerbs.uploadInitCode; import static com.hedera.services.bdd.spec.transactions.crypto.HapiCryptoTransfer.tinyBarsFromTo; +import static com.hedera.services.bdd.spec.transactions.token.CustomFeeSpecs.fixedHbarFee; +import static com.hedera.services.bdd.spec.transactions.token.TokenMovement.movingHbar; import static com.hedera.services.bdd.spec.utilops.CustomSpecAssert.allRunFor; import static com.hedera.services.bdd.spec.utilops.EmbeddedVerbs.exposeMaxSchedulable; +import static com.hedera.services.bdd.spec.utilops.UtilVerbs.assertHgcaaLogDoesNotContain; import static com.hedera.services.bdd.spec.utilops.UtilVerbs.blockStreamMustIncludePassFrom; import static com.hedera.services.bdd.spec.utilops.UtilVerbs.blockingOrder; import static com.hedera.services.bdd.spec.utilops.UtilVerbs.doAdhoc; @@ -50,70 +76,100 @@ import static com.hedera.services.bdd.spec.utilops.UtilVerbs.doWithStartupConfigNow; import static com.hedera.services.bdd.spec.utilops.UtilVerbs.doingContextual; import static com.hedera.services.bdd.spec.utilops.UtilVerbs.exposeSpecSecondTo; +import static com.hedera.services.bdd.spec.utilops.UtilVerbs.keyFromMutation; import static com.hedera.services.bdd.spec.utilops.UtilVerbs.newKeyNamed; import static com.hedera.services.bdd.spec.utilops.UtilVerbs.overriding; import static com.hedera.services.bdd.spec.utilops.UtilVerbs.overridingAllOf; import static com.hedera.services.bdd.spec.utilops.UtilVerbs.overridingThrottles; +import static com.hedera.services.bdd.spec.utilops.UtilVerbs.scheduledExecutionResult; import static com.hedera.services.bdd.spec.utilops.UtilVerbs.sleepFor; import static com.hedera.services.bdd.spec.utilops.UtilVerbs.sleepForSeconds; import static com.hedera.services.bdd.spec.utilops.UtilVerbs.sourcing; import static com.hedera.services.bdd.spec.utilops.UtilVerbs.sourcingContextual; +import static com.hedera.services.bdd.spec.utilops.UtilVerbs.uploadScheduledContractPrices; import static com.hedera.services.bdd.spec.utilops.UtilVerbs.validateChargedUsd; import static com.hedera.services.bdd.spec.utilops.UtilVerbs.waitUntilStartOfNextStakingPeriod; import static com.hedera.services.bdd.spec.utilops.UtilVerbs.withOpContext; +import static com.hedera.services.bdd.spec.utilops.UtilVerbs.withStatus; +import static com.hedera.services.bdd.suites.HapiSuite.APP_PROPERTIES; import static com.hedera.services.bdd.suites.HapiSuite.CIVILIAN_PAYER; import static com.hedera.services.bdd.suites.HapiSuite.DEFAULT_PAYER; import static com.hedera.services.bdd.suites.HapiSuite.FUNDING; +import static com.hedera.services.bdd.suites.HapiSuite.GENESIS; +import static com.hedera.services.bdd.suites.HapiSuite.NODE_REWARD; import static com.hedera.services.bdd.suites.HapiSuite.ONE_HBAR; import static com.hedera.services.bdd.suites.HapiSuite.ONE_HUNDRED_HBARS; import static com.hedera.services.bdd.suites.HapiSuite.ONE_MILLION_HBARS; +import static com.hedera.services.bdd.suites.HapiSuite.STAKING_REWARD; +import static com.hedera.services.bdd.suites.HapiSuite.SYSTEM_ADMIN; +import static com.hedera.services.bdd.suites.HapiSuite.flattened; +import static com.hedera.services.bdd.suites.contract.Utils.asAddress; +import static com.hedera.services.bdd.suites.hip423.LongTermScheduleUtils.CREATE_TXN; +import static com.hedera.services.bdd.suites.hip423.LongTermScheduleUtils.NEW_SENDER_KEY; +import static com.hedera.services.bdd.suites.hip423.LongTermScheduleUtils.SENDER_KEY; +import static com.hedera.services.bdd.suites.hip423.LongTermScheduleUtils.SENDER_TXN; +import static com.hedera.services.bdd.suites.hip423.LongTermScheduleUtils.triggerSchedule; import static com.hederahashgraph.api.proto.java.HederaFunctionality.ConsensusCreateTopic; import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.BUSY; +import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.INVALID_ALIAS_KEY; +import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.INVALID_MAX_AUTO_ASSOCIATIONS; import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.INVALID_SCHEDULE_ID; +import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.INVALID_TOPIC_ID; import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.MISSING_EXPIRY_TIME; import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.SCHEDULE_EXPIRATION_TIME_MUST_BE_HIGHER_THAN_CONSENSUS_TIME; import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.SCHEDULE_EXPIRATION_TIME_TOO_FAR_IN_FUTURE; import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.SCHEDULE_EXPIRY_IS_BUSY; +import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.TOKEN_ALREADY_ASSOCIATED_TO_ACCOUNT; import static java.util.Objects.requireNonNull; import static java.util.Spliterator.DISTINCT; import static java.util.Spliterator.NONNULL; import static java.util.Spliterators.spliteratorUnknownSize; +import static java.util.stream.Collectors.toMap; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; -import com.hedera.hapi.block.stream.output.TransactionResult; -import com.hedera.hapi.node.base.ResponseCodeEnum; +import com.google.protobuf.ByteString; +import com.hedera.hapi.node.base.HederaFunctionality; import com.hedera.hapi.node.base.ScheduleID; +import com.hedera.hapi.node.base.ServicesConfigurationList; +import com.hedera.hapi.node.base.Setting; import com.hedera.hapi.node.base.TimestampSeconds; -import com.hedera.hapi.node.base.TransactionID; import com.hedera.hapi.node.state.primitives.ProtoBytes; import com.hedera.hapi.node.state.schedule.Schedule; import com.hedera.hapi.node.state.schedule.ScheduledCounts; import com.hedera.hapi.node.state.schedule.ScheduledOrder; import com.hedera.hapi.node.state.throttles.ThrottleUsageSnapshots; -import com.hedera.hapi.node.transaction.TransactionBody; import com.hedera.node.app.service.schedule.ScheduleService; +import com.hedera.node.app.spi.store.StoreFactory; +import com.hedera.pbj.runtime.ParseException; +import com.hedera.pbj.runtime.io.buffer.Bytes; import com.hedera.services.bdd.junit.HapiTestLifecycle; import com.hedera.services.bdd.junit.LeakyRepeatableHapiTest; import com.hedera.services.bdd.junit.RepeatableHapiTest; import com.hedera.services.bdd.junit.TargetEmbeddedMode; import com.hedera.services.bdd.junit.support.TestLifecycle; -import com.hedera.services.bdd.junit.support.translators.inputs.TransactionParts; import com.hedera.services.bdd.spec.HapiSpec; import com.hedera.services.bdd.spec.SpecOperation; -import com.hedera.services.bdd.spec.infrastructure.RegistryNotFound; -import com.hedera.services.bdd.spec.utilops.streams.assertions.BlockStreamAssertion; +import com.hedera.services.bdd.spec.keys.ControlForKey; +import com.hedera.services.bdd.spec.keys.KeyShape; +import com.hedera.services.bdd.spec.transactions.contract.HapiParserUtil; +import com.hedera.services.bdd.spec.transactions.token.TokenMovement; +import com.hederahashgraph.api.proto.java.Key; +import com.hederahashgraph.api.proto.java.TokenType; +import com.swirlds.common.utility.CommonUtils; import com.swirlds.state.spi.ReadableKVState; import com.swirlds.state.spi.WritableKVState; import edu.umd.cs.findbugs.annotations.NonNull; +import java.time.Duration; +import java.time.Instant; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; -import java.util.function.BiConsumer; import java.util.function.Consumer; -import java.util.function.Function; import java.util.stream.IntStream; import java.util.stream.Stream; import java.util.stream.StreamSupport; @@ -124,17 +180,36 @@ import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.TestMethodOrder; -@Order(2) +@Order(3) @Tag(INTEGRATION) @HapiTestLifecycle @TargetEmbeddedMode(REPEATABLE) @TestMethodOrder(OrderAnnotation.class) public class RepeatableHip423Tests { + private static final long ONE_MINUTE = 60; + private static final long FORTY_MINUTES = TimeUnit.MINUTES.toSeconds(40); + private static final long THIRTY_MINUTES = 30 * ONE_MINUTE; + private static final String SIGNER = "anybody"; + private static final String TOKEN_TREASURY = "treasury"; + public static final String ASSOCIATE_CONTRACT = "AssociateDissociate"; + private static final KeyShape THRESHOLD_KEY_SHAPE = KeyShape.threshOf(1, ED25519, CONTRACT); + private static final String FUNGIBLE_TOKEN = "fungibleToken"; + private static final String ACCOUNT = "anybody"; + private static final String CONTRACT_KEY = "ContractKey"; + private static final String PAYING_ACCOUNT = "payingAccount"; + private static final String RECEIVER = "receiver"; + private static final String SENDER = "sender"; @BeforeAll static void beforeAll(@NonNull final TestLifecycle testLifecycle) { - testLifecycle.overrideInClass(Map.of("scheduling.longTermEnabled", "true")); + testLifecycle.overrideInClass(Map.of( + "scheduling.longTermEnabled", + "true", + "scheduling.whitelist", + "ConsensusSubmitMessage,CryptoTransfer,TokenMint,TokenBurn," + + "CryptoCreate,CryptoUpdate,FileUpdate,SystemDelete,SystemUndelete," + + "Freeze,ContractCall,ContractCreate,ContractUpdate,ContractDelete")); } /** @@ -156,10 +231,12 @@ final Stream cannotScheduleTooManyTxnsInOneSecond() { // Consensus time advances exactly one second per transaction in repeatable mode exposeSpecSecondTo(now -> expiry.set(now + oddLifetime - 1)), sourcing(() -> scheduleCreate("second", cryptoTransfer(tinyBarsFromTo(DEFAULT_PAYER, FUNDING, 456L))) + .waitForExpiry() .payingWith(CIVILIAN_PAYER) .fee(ONE_HBAR) .expiringAt(expiry.get())), sourcing(() -> scheduleCreate("third", cryptoTransfer(tinyBarsFromTo(DEFAULT_PAYER, FUNDING, 789))) + .waitForExpiry() .payingWith(CIVILIAN_PAYER) .fee(ONE_HBAR) .expiringAt(expiry.get()) @@ -191,9 +268,9 @@ final Stream expiryMustBeValid() { } /** - * Tests that the consensus {@link com.hedera.hapi.node.base.HederaFunctionality#SCHEDULE_CREATE} throttle is + * Tests that the consensus {@link HederaFunctionality#SCHEDULE_CREATE} throttle is * enforced by overriding the dev throttles to the more restrictive mainnet throttles and scheduling one more - * {@link com.hedera.hapi.node.base.HederaFunctionality#CONSENSUS_CREATE_TOPIC} that is allowed. + * {@link HederaFunctionality#CONSENSUS_CREATE_TOPIC} that is allowed. */ @LeakyRepeatableHapiTest( value = { @@ -239,9 +316,9 @@ final Stream throttlingAndExecutionAsExpected() { } /** - * Tests that the consensus {@link com.hedera.hapi.node.base.HederaFunctionality#SCHEDULE_CREATE} throttle is + * Tests that the consensus {@link HederaFunctionality#SCHEDULE_CREATE} throttle is * enforced by overriding the dev throttles to the more restrictive mainnet throttles and scheduling one more - * {@link com.hedera.hapi.node.base.HederaFunctionality#CONSENSUS_CREATE_TOPIC} that is allowed. + * {@link HederaFunctionality#CONSENSUS_CREATE_TOPIC} that is allowed. */ @LeakyRepeatableHapiTest( value = { @@ -355,6 +432,7 @@ final Stream executeImmediateAndDeletedLongTermAreStillPurgedWhenTi getAccountBalance("luckyYou").hasTinyBars(1L + 2L), doingContextual(spec -> lastExecuteImmediateExpiry.set(expiryOf("last", spec))), sourcing(() -> scheduleCreate("deleted", cryptoTransfer(tinyBarsFromTo(DEFAULT_PAYER, "luckyYou", 3L))) + .waitForExpiry() .adminKey("adminKey") .expiringAt(lastExecuteImmediateExpiry.get())), scheduleDelete("deleted").signedBy(DEFAULT_PAYER, "adminKey"), @@ -391,10 +469,13 @@ final Stream executionPurgesScheduleStateAsExpectedSplitAcrossUserT cryptoCreate("luckyYou").balance(0L), // Schedule the three transfers to lucky you sourcing(() -> scheduleCreate("one", cryptoTransfer(tinyBarsFromTo(DEFAULT_PAYER, "luckyYou", 1L))) + .waitForExpiry() .expiringAt(lastSecond.get() + ONE_MINUTE)), sourcing(() -> scheduleCreate("two", cryptoTransfer(tinyBarsFromTo(DEFAULT_PAYER, "luckyYou", 2L))) + .waitForExpiry() .expiringAt(lastSecond.get() + ONE_MINUTE)), sourcing(() -> scheduleCreate("three", cryptoTransfer(tinyBarsFromTo(DEFAULT_PAYER, "luckyYou", 3L))) + .waitForExpiry() .expiringAt(lastSecond.get() + ONE_MINUTE + 1)), viewScheduleStateSizes(currentSizes::set), // Check that schedule state sizes changed as expected @@ -477,10 +558,13 @@ final Stream lastProcessTimeDoesNotAffectStakePeriodBoundaryCrossin cryptoCreate("luckyYou").balance(0L), // Schedule the three transfers to lucky you sourcing(() -> scheduleCreate("one", cryptoTransfer(tinyBarsFromTo(DEFAULT_PAYER, "luckyYou", 1L))) + .waitForExpiry() .expiringAt(lastSecond.get() + stakePeriodMins.get() * ONE_MINUTE - 1)), sourcing(() -> scheduleCreate("two", cryptoTransfer(tinyBarsFromTo(DEFAULT_PAYER, "luckyYou", 2L))) + .waitForExpiry() .expiringAt(lastSecond.get() + stakePeriodMins.get() * ONE_MINUTE - 1)), sourcing(() -> scheduleCreate("three", cryptoTransfer(tinyBarsFromTo(DEFAULT_PAYER, "luckyYou", 3L))) + .waitForExpiry() .expiringAt(lastSecond.get() + stakePeriodMins.get() * ONE_MINUTE - 1)), sourcing(() -> waitUntilStartOfNextStakingPeriod(stakePeriodMins.get())), // Now execute them one at a time and assert the expected changes to state @@ -525,12 +609,16 @@ final Stream executionPurgesScheduleStateAsWhenRunningOutOfConsensu "scheduling.consTimeSeparationNanos", "15")), // Schedule the four transfers to lucky you sourcing(() -> scheduleCreate("one", cryptoTransfer(tinyBarsFromTo(DEFAULT_PAYER, "luckyYou", 1L))) + .waitForExpiry() .expiringAt(lastSecond.get() + ONE_MINUTE)), sourcing(() -> scheduleCreate("two", cryptoTransfer(tinyBarsFromTo(DEFAULT_PAYER, "luckyYou", 2L))) + .waitForExpiry() .expiringAt(lastSecond.get() + ONE_MINUTE)), sourcing(() -> scheduleCreate("three", cryptoTransfer(tinyBarsFromTo(DEFAULT_PAYER, "luckyYou", 3L))) + .waitForExpiry() .expiringAt(lastSecond.get() + ONE_MINUTE + 3)), sourcing(() -> scheduleCreate("four", cryptoTransfer(tinyBarsFromTo(DEFAULT_PAYER, "luckyYou", 4L))) + .waitForExpiry() .expiringAt(lastSecond.get() + ONE_MINUTE + 3)), // Let all the schedules expire sleepFor((ONE_MINUTE + 4) * 1_000), @@ -557,10 +645,12 @@ final Stream executionResultsAreStreamedAsExpected() { cryptoCreate("cautiousYou").balance(0L).receiverSigRequired(true), sourcing( () -> scheduleCreate("payerOnly", cryptoTransfer(tinyBarsFromTo(DEFAULT_PAYER, "luckyYou", 1L))) + .waitForExpiry() .expiringIn(ONE_MINUTE) .via("one")), sourcing(() -> scheduleCreate( "receiverSigRequired", cryptoTransfer(tinyBarsFromTo(DEFAULT_PAYER, "cautiousYou", 2L))) + .waitForExpiry() .expiringIn(ONE_MINUTE) .via("two")), sleepForSeconds(ONE_MINUTE), @@ -569,7 +659,251 @@ final Stream executionResultsAreStreamedAsExpected() { } @RepeatableHapiTest(NEEDS_VIRTUAL_TIME_FOR_FAST_EXECUTION) - final Stream testFailingScheduleSingChargesFee() { + final Stream changeTokenFeeWhenScheduled() { + return hapiTest( + newKeyNamed("feeScheduleKey"), + cryptoCreate(TOKEN_TREASURY), + cryptoCreate("feeCollector").balance(0L), + cryptoCreate("receiver"), + cryptoCreate("sender"), + tokenCreate("fungibleToken") + .treasury(TOKEN_TREASURY) + .initialSupply(1000) + .feeScheduleKey("feeScheduleKey"), + tokenAssociate("receiver", "fungibleToken"), + tokenAssociate("sender", "fungibleToken"), + cryptoTransfer(TokenMovement.moving(5, "fungibleToken").between(TOKEN_TREASURY, "sender")), + scheduleCreate( + "schedule", + cryptoTransfer( + TokenMovement.moving(5, "fungibleToken").between("sender", "receiver"))) + .expiringIn(ONE_MINUTE), + tokenFeeScheduleUpdate("fungibleToken") + .payingWith(DEFAULT_PAYER) + .signedBy(DEFAULT_PAYER, "feeScheduleKey") + .withCustom(fixedHbarFee(1, "feeCollector")), + scheduleSign("schedule").payingWith("sender"), + sleepForSeconds(ONE_MINUTE), + cryptoCreate("trigger"), + getAccountBalance("receiver").hasTokenBalance("fungibleToken", 5), + getAccountBalance("feeCollector").hasTinyBars(1)); + } + + @RepeatableHapiTest(NEEDS_VIRTUAL_TIME_FOR_FAST_EXECUTION) + final Stream scheduleCreateExecutes() { + return hapiTest( + cryptoCreate("luckyYou").balance(0L), + scheduleCreate("one", cryptoTransfer(tinyBarsFromTo(DEFAULT_PAYER, "luckyYou", 1L))) + .waitForExpiry(false) + .expiringIn(FORTY_MINUTES) + .via("createTxn"), + getScheduleInfo("one") + .isExecuted() + .hasWaitForExpiry(false) + .hasRelativeExpiry("createTxn", FORTY_MINUTES - 1)); + } + + /** + * Validates that once the {@code Iterator} for a consensus second has been exhausted, that + * iterator is not recreated when handling another transaction in the same consensus second. + *

+ * Accomplishes this by temporarily removing a critical {@link ScheduleService}'s state key, so that + * {@link ScheduleService#executableTxns(Instant, Instant, StoreFactory)} will throw an unhandled exception when it + * tries to access the schedule service states.) Then executes another transaction in the same consensus second; and + * verifies no exception is logged. + */ + @RepeatableHapiTest({NEEDS_VIRTUAL_TIME_FOR_FAST_EXECUTION, NEEDS_STATE_ACCESS}) + final Stream afterConsensusSecondIteratorIsExhaustedIsNotRecreated() { + final var halfSecond = Duration.ofMillis(500); + final AtomicReference>> services = new AtomicReference<>(); + return hapiTest( + cryptoCreate("luckyYou").balance(0L), + scheduleCreate("one", cryptoTransfer(tinyBarsFromTo(DEFAULT_PAYER, "luckyYou", 1L))) + .waitForExpiry(true) + .expiringIn(ONE_MINUTE), + sleepForSeconds(ONE_MINUTE - 1), + cryptoCreate("trigger"), + getAccountBalance("luckyYou").hasTinyBars(1), + doingContextual(spec -> { + final var embeddedHedera = spec.repeatableEmbeddedHederaOrThrow(); + final var state = embeddedHedera.state(); + // Create a sufficiently deep copy of the current states and remember it + final var backupStates = state.getStates().entrySet().stream() + .collect(toMap(Map.Entry::getKey, entry -> + (Map) new ConcurrentHashMap<>(entry.getValue()))); + services.set(backupStates); + // And temporarily remove the schedule-by-id state + state.removeServiceState(ScheduleService.NAME, SCHEDULES_BY_ID_KEY); + // Then ensure the same consensus second is used for the next transaction + embeddedHedera.setRoundDuration(halfSecond); + }), + cryptoCreate("sameConsSecond"), + doingContextual(spec -> { + final var embeddedHedera = spec.repeatableEmbeddedHederaOrThrow(); + final var state = embeddedHedera.state(); + // Repeat this to purge the cached metadata for the schedule service + state.removeServiceState(ScheduleService.NAME, SCHEDULES_BY_ID_KEY); + state.getStates().putAll(services.get()); + embeddedHedera.setRoundDuration(DEFAULT_ROUND_DURATION); + }), + // Verify the second transaction in the same second did not recreate the iterator + assertHgcaaLogDoesNotContain( + byNodeId(0L), "Unknown k/v state key SCHEDULES_BY_ID", Duration.ofMillis(100L))); + } + + /** + * Validates that if a privileged {@link HederaFunctionality#FILE_UPDATE} transaction is scheduled to be executed + * then later scheduled transactions executions in the same second reflect the new values of any just-overridden + * properties. + */ + @RepeatableHapiTest({NEEDS_VIRTUAL_TIME_FOR_FAST_EXECUTION, NEEDS_STATE_ACCESS}) + final Stream scheduledPrivilegedOverridesAreReflectedInSubsequentSchedules() { + final AtomicReference originalFile121 = new AtomicReference<>(); + final var lastSecond = new AtomicLong(); + return hapiTest( + blockStreamMustIncludePassFrom( + scheduledExecutionResult("creation", withStatus(TRANSFER_LIST_SIZE_LIMIT_EXCEEDED))), + exposeSpecSecondTo(lastSecond::set), + getFileContents(APP_PROPERTIES).consumedBy(bytes -> originalFile121.set(Bytes.wrap(bytes))), + // We cannot schedule an overriding() because it is not a HapiSpecOperation; so instead schedule + // an FileUpdate to 0.0.121 with the temporary limit on token symbol bytes + sourcing(() -> scheduleCreate( + "transferListLimitOverride", + fileUpdate(APP_PROPERTIES) + .contents(withAddedConfig(originalFile121.get(), "ledger.transfers.maxLen", "2") + .toByteArray())) + .designatingPayer(GENESIS) + .expiringAt(lastSecond.get() + ONE_MINUTE)), + // Also schedule a token create with symbol length 3 > 2 + sourcing(() -> scheduleCreate( + "nowOverLongTransferList", + cryptoTransfer(movingHbar(2L).distributing(DEFAULT_PAYER, STAKING_REWARD, NODE_REWARD))) + .expiringAt(lastSecond.get() + ONE_MINUTE) + .via("creation")), + // Trigger execution of both schedules + sleepForSeconds(ONE_MINUTE), + cryptoCreate("trigger"), + // Restore the initial 0.0.121 contents manually now + sourcing(() -> fileUpdate(APP_PROPERTIES) + .contents(originalFile121.get().toByteArray()) + .payingWith(GENESIS))); + } + + @RepeatableHapiTest(NEEDS_VIRTUAL_TIME_FOR_FAST_EXECUTION) + final Stream receiverSigRequiredUpdateIsRecognized() { + var senderShape = threshOf(2, 3); + var sigOne = senderShape.signedWith(sigs(ON, OFF, OFF)); + var sigTwo = senderShape.signedWith(sigs(OFF, ON, OFF)); + String schedule = "Z"; + + return hapiTest(flattened( + newKeyNamed(SENDER_KEY).shape(senderShape), + cryptoCreate(SENDER).key(SENDER_KEY).via(SENDER_TXN), + cryptoCreate(RECEIVER).balance(0L), + scheduleCreate(schedule, cryptoTransfer(tinyBarsFromTo(SENDER, RECEIVER, 1))) + .payingWith(DEFAULT_PAYER) + .waitForExpiry() + .expiringIn(FORTY_MINUTES) + .recordingScheduledTxn() + .alsoSigningWith(SENDER) + .sigControl(forKey(SENDER_KEY, sigOne)) + .via(CREATE_TXN), + getAccountBalance(RECEIVER).hasTinyBars(0L), + cryptoUpdate(RECEIVER).receiverSigRequired(true), + scheduleSign(schedule).alsoSigningWith(SENDER_KEY).sigControl(forKey(SENDER_KEY, sigTwo)), + getAccountBalance(RECEIVER).hasTinyBars(0L), + scheduleSign(schedule).alsoSigningWith(RECEIVER), + getAccountBalance(RECEIVER).hasTinyBars(0), + getScheduleInfo(schedule) + .hasScheduleId(schedule) + .hasWaitForExpiry() + .isNotExecuted() + .isNotDeleted() + .hasRelativeExpiry(CREATE_TXN, FORTY_MINUTES - 1) + .hasRecordedScheduledTxn(), + triggerSchedule(schedule, FORTY_MINUTES), + getAccountBalance(RECEIVER).hasTinyBars(1), + scheduleSign(schedule) + .alsoSigningWith(SENDER_KEY) + .sigControl(forKey(SENDER_KEY, sigTwo)) + .hasKnownStatus(INVALID_SCHEDULE_ID), + getAccountBalance(RECEIVER).hasTinyBars(1))); + } + + @RepeatableHapiTest(NEEDS_VIRTUAL_TIME_FOR_FAST_EXECUTION) + final Stream changeInNestedSigningReqsRespected() { + var senderShape = threshOf(2, threshOf(1, 3), threshOf(1, 3), threshOf(1, 3)); + var sigOne = senderShape.signedWith(sigs(sigs(OFF, OFF, ON), sigs(OFF, OFF, OFF), sigs(OFF, OFF, OFF))); + var firstSigThree = senderShape.signedWith(sigs(sigs(OFF, OFF, OFF), sigs(OFF, OFF, OFF), sigs(ON, OFF, OFF))); + var secondSigThree = senderShape.signedWith(sigs(sigs(OFF, OFF, OFF), sigs(OFF, OFF, OFF), sigs(ON, ON, OFF))); + String schedule = "Z"; + + return hapiTest(flattened( + newKeyNamed(SENDER_KEY).shape(senderShape), + keyFromMutation(NEW_SENDER_KEY, SENDER_KEY).changing(this::bumpThirdNestedThresholdSigningReq), + cryptoCreate(SENDER).key(SENDER_KEY), + cryptoCreate(RECEIVER).balance(0L), + scheduleCreate(schedule, cryptoTransfer(tinyBarsFromTo(SENDER, RECEIVER, 1))) + .payingWith(DEFAULT_PAYER) + .waitForExpiry() + .expiringIn(FORTY_MINUTES) + .recordingScheduledTxn() + .alsoSigningWith(SENDER) + .sigControl(ControlForKey.forKey(SENDER_KEY, sigOne)) + .via(CREATE_TXN), + getAccountBalance(RECEIVER).hasTinyBars(0L), + cryptoUpdate(SENDER).key(NEW_SENDER_KEY), + scheduleSign(schedule) + .alsoSigningWith(NEW_SENDER_KEY) + .sigControl(forKey(NEW_SENDER_KEY, firstSigThree)), + getAccountBalance(RECEIVER).hasTinyBars(0L), + scheduleSign(schedule) + .alsoSigningWith(NEW_SENDER_KEY) + .sigControl(forKey(NEW_SENDER_KEY, secondSigThree)), + getAccountBalance(RECEIVER).hasTinyBars(0L), + getScheduleInfo(schedule) + .hasScheduleId(schedule) + .hasWaitForExpiry() + .isNotExecuted() + .isNotDeleted() + .hasRelativeExpiry(CREATE_TXN, FORTY_MINUTES - 1) + .hasRecordedScheduledTxn(), + triggerSchedule(schedule, FORTY_MINUTES), + getAccountBalance(RECEIVER).hasTinyBars(1L))); + } + + /** + * Tests that system accounts are exempt from throttles. + */ + @LeakyRepeatableHapiTest( + value = NEEDS_LAST_ASSIGNED_CONSENSUS_TIME, + overrides = {"scheduling.maxTxnPerSec"}) + final Stream systemAccountsExemptFromThrottles() { + final AtomicLong expiry = new AtomicLong(); + final var oddLifetime = 123 * ONE_MINUTE; + return hapiTest( + overriding("scheduling.maxTxnPerSec", "2"), + cryptoCreate(CIVILIAN_PAYER).balance(10 * ONE_HUNDRED_HBARS), + scheduleCreate("first", cryptoTransfer(tinyBarsFromTo(DEFAULT_PAYER, FUNDING, 123L))) + .payingWith(CIVILIAN_PAYER) + .fee(ONE_HBAR) + .expiringIn(oddLifetime), + // Consensus time advances exactly one second per transaction in repeatable mode + exposeSpecSecondTo(now -> expiry.set(now + oddLifetime - 1)), + sourcing(() -> scheduleCreate("second", cryptoTransfer(tinyBarsFromTo(DEFAULT_PAYER, FUNDING, 456L))) + .payingWith(CIVILIAN_PAYER) + .fee(ONE_HBAR) + .expiringAt(expiry.get())), + // When scheduling with the system account, the throttle should not apply + sourcing(() -> scheduleCreate("third", cryptoTransfer(tinyBarsFromTo(DEFAULT_PAYER, FUNDING, 789))) + .payingWith(SYSTEM_ADMIN) + .fee(ONE_HBAR) + .expiringAt(expiry.get())), + purgeExpiringWithin(oddLifetime)); + } + + @RepeatableHapiTest(NEEDS_VIRTUAL_TIME_FOR_FAST_EXECUTION) + final Stream testFailingScheduleSignChargesFee() { return hapiTest( cryptoCreate("sender").balance(ONE_HBAR), cryptoCreate("receiver").balance(0L).receiverSigRequired(true), @@ -586,43 +920,246 @@ final Stream testFailingScheduleSingChargesFee() { validateChargedUsd("signTxn", 0.001)); } - private static BiConsumer withStatus(@NonNull final ResponseCodeEnum status) { - requireNonNull(status); - return (body, result) -> assertEquals(status, result.status()); + @RepeatableHapiTest(NEEDS_VIRTUAL_TIME_FOR_FAST_EXECUTION) + final Stream scheduleCreateWithAllSignatures() { + return hapiTest( + cryptoCreate("luckyYou").balance(0L), + scheduleCreate("payerOnly", cryptoTransfer(tinyBarsFromTo(DEFAULT_PAYER, "luckyYou", 1L))) + .waitForExpiry(true) + .expiringIn(THIRTY_MINUTES + 1), + getAccountBalance("luckyYou").hasTinyBars(0), + sleepForSeconds(THIRTY_MINUTES + 10), + cryptoCreate("TRIGGER"), + sleepForSeconds(1), + getAccountBalance("luckyYou").hasTinyBars(1L)); } - private static Function scheduledExecutionResult( - @NonNull final String creationTxn, @NonNull final BiConsumer observer) { - requireNonNull(creationTxn); - requireNonNull(observer); - return spec -> block -> { - final com.hederahashgraph.api.proto.java.TransactionID creationTxnId; - try { - creationTxnId = spec.registry().getTxnId(creationTxn); - } catch (RegistryNotFound ignore) { - return false; - } - final var executionTxnId = - protoToPbj(creationTxnId.toBuilder().setScheduled(true).build(), TransactionID.class); - final var items = block.items(); - for (int i = 0, n = items.size(); i < n; i++) { - final var item = items.get(i); - if (item.hasEventTransaction()) { - final var parts = - TransactionParts.from(item.eventTransactionOrThrow().applicationTransactionOrThrow()); - if (parts.transactionIdOrThrow().equals(executionTxnId)) { - for (int j = i + 1; j < n; j++) { - final var followingItem = items.get(j); - if (followingItem.hasTransactionResult()) { - observer.accept(parts.body(), followingItem.transactionResultOrThrow()); - return true; - } - } - } - } - } - return false; - }; + @RepeatableHapiTest(NEEDS_VIRTUAL_TIME_FOR_FAST_EXECUTION) + final Stream scheduleTransactionWithHandleAndPreHandleErrorsForTriggering() { + final var schedule = "s"; + + final var longZeroAddress = ByteString.copyFrom(CommonUtils.unhex("0000000000000000000000000000000fffffffff")); + + return hapiTest( + cryptoCreate(SENDER), + cryptoCreate(PAYING_ACCOUNT), + cryptoCreate(RECEIVER).balance(0L), + scheduleCreate(schedule, cryptoTransfer(tinyBarsFromTo(SENDER, RECEIVER, 1L))) + .waitForExpiry(true) + .expiringIn(THIRTY_MINUTES), + getAccountBalance(RECEIVER).hasTinyBars(0L), + + // sign with the other required key + scheduleSign(schedule).alsoSigningWith(SENDER), + sleepForSeconds(THIRTY_MINUTES * 2), + + // try to trigger the scheduled transaction with failing crypto create(on pre-handle) + cryptoCreate("trigger").evmAddress(longZeroAddress).hasPrecheck(INVALID_ALIAS_KEY), + sleepForSeconds(1), + + // the balance not is changed + getAccountBalance(RECEIVER).hasTinyBars(0L), + + // try to trigger the scheduled transaction with failing crypto create(on handle) + cryptoCreate("trigger2") + .maxAutomaticTokenAssociations(5001) + .hasKnownStatus(INVALID_MAX_AUTO_ASSOCIATIONS), + sleepForSeconds(1), + + // the balance is changed + getAccountBalance(RECEIVER).hasTinyBars(1L)); + } + + @RepeatableHapiTest(NEEDS_VIRTUAL_TIME_FOR_FAST_EXECUTION) + final Stream scheduleTransactionWithThrottleErrorForTriggering() { + final var schedule = "s"; + + return hapiTest( + cryptoCreate(SENDER), + cryptoCreate(PAYING_ACCOUNT), + cryptoCreate(RECEIVER).balance(0L), + scheduleCreate(schedule, cryptoTransfer(tinyBarsFromTo(SENDER, RECEIVER, 1L))) + .waitForExpiry(true) + .expiringIn(THIRTY_MINUTES), + getAccountBalance(RECEIVER).hasTinyBars(0L), + + // sign with the other required key + scheduleSign(schedule).alsoSigningWith(SENDER), + sleepForSeconds(THIRTY_MINUTES * 2), + + // try to trigger the scheduled transaction with failing throttle + submitMessageTo((String) null).hasRetryPrecheckFrom(BUSY).hasKnownStatus(INVALID_TOPIC_ID), + + // the balance is changed + getAccountBalance(RECEIVER).hasTinyBars(1L)); + } + + @RepeatableHapiTest(NEEDS_VIRTUAL_TIME_FOR_FAST_EXECUTION) + final Stream scheduledTransactionNotTriggeredWhenKeyIsChanged() { + final var schedule = "s"; + + return hapiTest( + cryptoCreate(SENDER), + cryptoCreate(PAYING_ACCOUNT), + cryptoCreate(RECEIVER).receiverSigRequired(true).balance(0L), + scheduleCreate(schedule, cryptoTransfer(tinyBarsFromTo(SENDER, RECEIVER, 1L))) + .waitForExpiry() + .expiringIn(THIRTY_MINUTES * 2), + getAccountBalance(RECEIVER).hasTinyBars(0L), + + // sign with the first required key + scheduleSign(schedule).alsoSigningWith(SENDER), + + // change the key + newKeyNamed("new_key"), + cryptoUpdate(SENDER).key("new_key"), + + // sign with the other required key + scheduleSign(schedule).alsoSigningWith(RECEIVER), + sleepForSeconds(THIRTY_MINUTES * 3), + cryptoCreate("trigger"), + sleepForSeconds(1), + + // the balance is not changed + getAccountBalance(RECEIVER).hasTinyBars(0L)); + } + + @RepeatableHapiTest(NEEDS_VIRTUAL_TIME_FOR_FAST_EXECUTION) + final Stream scheduledTransactionNotTriggeredWhenKeyIsChangedAndThenChangedBack() { + final var schedule = "s"; + + return hapiTest( + newKeyNamed("original_key"), + cryptoCreate(SENDER).key("original_key"), + cryptoCreate(PAYING_ACCOUNT), + cryptoCreate(RECEIVER).receiverSigRequired(true).balance(0L), + scheduleCreate(schedule, cryptoTransfer(tinyBarsFromTo(SENDER, RECEIVER, 1L))) + .waitForExpiry() + .expiringIn(THIRTY_MINUTES * 2), + getAccountBalance(RECEIVER).hasTinyBars(0L), + + // sign with the first required key + scheduleSign(schedule).alsoSigningWith(SENDER), + + // change the key + newKeyNamed("new_key"), + cryptoUpdate(SENDER).key("new_key"), + + // change the key back to the old one + cryptoUpdate(SENDER).key("original_key"), + + // sign with the other required key + scheduleSign(schedule).alsoSigningWith(RECEIVER), + sleepForSeconds(THIRTY_MINUTES * 3), + cryptoCreate("trigger"), + sleepForSeconds(1), + + // the balance is changed + getAccountBalance(RECEIVER).hasTinyBars(1L)); + } + + @RepeatableHapiTest(NEEDS_VIRTUAL_TIME_FOR_FAST_EXECUTION) + final Stream scheduleWithWaitForExpiryNotTriggeredWithoutSignatures() { + final var schedule = "s"; + + return hapiTest( + cryptoCreate(SENDER), + cryptoCreate(RECEIVER).receiverSigRequired(true).balance(0L), + scheduleCreate(schedule, cryptoTransfer(tinyBarsFromTo(SENDER, RECEIVER, 1L))) + .waitForExpiry(true) + .expiringIn(THIRTY_MINUTES), + getAccountBalance(RECEIVER).hasTinyBars(0L), + sleepForSeconds(THIRTY_MINUTES * 2), + cryptoCreate("trigger"), + sleepForSeconds(1), + getAccountBalance(RECEIVER).hasTinyBars(0L), + getScheduleInfo(schedule).hasCostAnswerPrecheck(INVALID_SCHEDULE_ID)); + } + + @RepeatableHapiTest(NEEDS_VIRTUAL_TIME_FOR_FAST_EXECUTION) + final Stream scheduleWithWaitForExpiryFalseNotTriggeredWithoutSignatures() { + final var schedule = "s"; + + return hapiTest( + cryptoCreate(SENDER), + cryptoCreate(RECEIVER).receiverSigRequired(true).balance(0L), + scheduleCreate(schedule, cryptoTransfer(tinyBarsFromTo(SENDER, RECEIVER, 1L))) + .waitForExpiry(false) + .expiringIn(THIRTY_MINUTES), + getAccountBalance(RECEIVER).hasTinyBars(0L), + sleepForSeconds(THIRTY_MINUTES * 2), + cryptoCreate("trigger"), + sleepForSeconds(1), + getAccountBalance(RECEIVER).hasTinyBars(0L), + getScheduleInfo(schedule).hasCostAnswerPrecheck(INVALID_SCHEDULE_ID)); + } + + @RepeatableHapiTest(NEEDS_VIRTUAL_TIME_FOR_FAST_EXECUTION) + final Stream scheduleV2SecurityAssociateSingleTokenWithDelegateContractKey() { + return hapiTest( + // upload fees for SCHEDULE_CREATE_CONTRACT_CALL + uploadScheduledContractPrices(GENESIS), + cryptoCreate(TOKEN_TREASURY).balance(ONE_HUNDRED_HBARS), + cryptoCreate(SIGNER).balance(ONE_MILLION_HBARS), + cryptoCreate(ACCOUNT).balance(10 * ONE_HUNDRED_HBARS), + tokenCreate(FUNGIBLE_TOKEN) + .tokenType(TokenType.FUNGIBLE_COMMON) + .treasury(TOKEN_TREASURY) + .supplyKey(TOKEN_TREASURY) + .adminKey(TOKEN_TREASURY), + uploadInitCode(ASSOCIATE_CONTRACT), + contractCreate(ASSOCIATE_CONTRACT), + withOpContext((spec, opLog) -> allRunFor( + spec, + newKeyNamed(CONTRACT_KEY).shape(THRESHOLD_KEY_SHAPE.signedWith(sigs(ON, ASSOCIATE_CONTRACT))), + cryptoUpdate(SIGNER).key(CONTRACT_KEY), + tokenUpdate(FUNGIBLE_TOKEN).supplyKey(CONTRACT_KEY).signedByPayerAnd(TOKEN_TREASURY), + scheduleCreate( + "schedule", + contractCall( + ASSOCIATE_CONTRACT, + "tokenAssociate", + HapiParserUtil.asHeadlongAddress(asAddress( + spec.registry().getAccountID(ACCOUNT))), + HapiParserUtil.asHeadlongAddress(asAddress( + spec.registry().getTokenID(FUNGIBLE_TOKEN)))) + .signedBy(SIGNER) + .payingWith(SIGNER) + .hasRetryPrecheckFrom(BUSY) + .via("fungibleTokenAssociate") + .gas(4_000_000L) + .hasKnownStatus( + com.hederahashgraph.api.proto.java.ResponseCodeEnum.SUCCESS)) + .waitForExpiry(true) + .expiringIn(THIRTY_MINUTES))), + sleepForSeconds(THIRTY_MINUTES * 2), + cryptoCreate("trigger"), + sleepForSeconds(1), + tokenAssociate(ACCOUNT, FUNGIBLE_TOKEN).hasKnownStatus(TOKEN_ALREADY_ASSOCIATED_TO_ACCOUNT)); + } + + @RepeatableHapiTest(NEEDS_VIRTUAL_TIME_FOR_FAST_EXECUTION) + final Stream scheduleBurnSignAndChangeTheSupplyKey() { + final var schedule = "s"; + return hapiTest( + newKeyNamed("adminKey"), + cryptoCreate(TOKEN_TREASURY), + newKeyNamed("supplyKey"), + tokenCreate("token") + .adminKey("adminKey") + .initialSupply(100) + .treasury("treasury") + .supplyKey("supplyKey"), + scheduleCreate(schedule, burnToken("token", 50).signedByPayerAnd("supplyKey")) + .waitForExpiry() + .expiringIn(THIRTY_MINUTES), + scheduleSign(schedule).alsoSigningWith("supplyKey"), + newKeyNamed("newSupplyKey"), + tokenUpdate("token").supplyKey("newSupplyKey"), + sleepForSeconds(THIRTY_MINUTES * 2), + cryptoCreate("trigger"), + sleepForSeconds(1), + getAccountBalance("treasury").hasTokenBalance("token", 100)); } private record ScheduleStateSizes( @@ -726,10 +1263,40 @@ private static SpecOperation purgeExpiringWithin(final long seconds) { * @return the calculated expiration second of the schedule */ private static long expiryOf(@NonNull final String schedule, @NonNull final HapiSpec spec) { + requireNonNull(schedule); final ReadableKVState schedules = spec.embeddedStateOrThrow() .getReadableStates(ScheduleService.NAME) .get(SCHEDULES_BY_ID_KEY); return requireNonNull(schedules.get(protoToPbj(spec.registry().getScheduleId(schedule), ScheduleID.class))) .calculatedExpirationSecond(); } + + private Key bumpThirdNestedThresholdSigningReq(Key source) { + var newKey = source.getThresholdKey().getKeys().getKeys(2).toBuilder(); + newKey.setThresholdKey(newKey.getThresholdKeyBuilder().setThreshold(2)); + var newKeyList = source.getThresholdKey().getKeys().toBuilder().setKeys(2, newKey); + return source.toBuilder() + .setThresholdKey(source.getThresholdKey().toBuilder().setKeys(newKeyList)) + .build(); + } + + /** + * Returns the given config list with an added setting with the given name and value. + * @param rawConfigList the raw bytes of the config list + * @param name the name of the setting to add + * @param value the value of the setting to add + * @return the updated config list + */ + private static Bytes withAddedConfig( + @NonNull final Bytes rawConfigList, @NonNull final String name, @NonNull final String value) { + try { + final var configList = ServicesConfigurationList.PROTOBUF.parse(rawConfigList); + final var updatedConfigList = new ServicesConfigurationList( + Stream.concat(configList.nameValue().stream(), Stream.of(new Setting(name, value, Bytes.EMPTY))) + .toList()); + return ServicesConfigurationList.PROTOBUF.toBytes(updatedConfigList); + } catch (ParseException e) { + throw new IllegalStateException(e); + } + } } diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/staking/RepeatableStakingTests.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/staking/RepeatableStakingTests.java index 5d2ae3320aa8..5384e289e642 100644 --- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/staking/RepeatableStakingTests.java +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/staking/RepeatableStakingTests.java @@ -21,37 +21,61 @@ import static com.hedera.services.bdd.spec.queries.QueryVerbs.getTxnRecord; import static com.hedera.services.bdd.spec.transactions.TxnVerbs.cryptoCreate; import static com.hedera.services.bdd.spec.transactions.TxnVerbs.cryptoTransfer; +import static com.hedera.services.bdd.spec.transactions.TxnVerbs.scheduleCreate; import static com.hedera.services.bdd.spec.transactions.crypto.HapiCryptoTransfer.tinyBarsFromTo; import static com.hedera.services.bdd.spec.utilops.CustomSpecAssert.allRunFor; +import static com.hedera.services.bdd.spec.utilops.EmbeddedVerbs.sleepToExactly; +import static com.hedera.services.bdd.spec.utilops.UtilVerbs.blockStreamMustIncludePassFrom; +import static com.hedera.services.bdd.spec.utilops.UtilVerbs.doWithStartupConfig; +import static com.hedera.services.bdd.spec.utilops.UtilVerbs.doingContextual; import static com.hedera.services.bdd.spec.utilops.UtilVerbs.overridingThree; +import static com.hedera.services.bdd.spec.utilops.UtilVerbs.scheduledExecutionResult; +import static com.hedera.services.bdd.spec.utilops.UtilVerbs.sourcing; import static com.hedera.services.bdd.spec.utilops.UtilVerbs.waitUntilStartOfNextStakingPeriod; import static com.hedera.services.bdd.spec.utilops.UtilVerbs.withOpContext; +import static com.hedera.services.bdd.spec.utilops.UtilVerbs.withRecordSpec; +import static com.hedera.services.bdd.suites.HapiSuite.DEFAULT_PAYER; import static com.hedera.services.bdd.suites.HapiSuite.FUNDING; import static com.hedera.services.bdd.suites.HapiSuite.GENESIS; import static com.hedera.services.bdd.suites.HapiSuite.ONE_HBAR; import static com.hedera.services.bdd.suites.HapiSuite.ONE_MILLION_HBARS; import static com.hedera.services.bdd.suites.HapiSuite.STAKING_REWARD; +import com.hedera.services.bdd.junit.HapiTestLifecycle; import com.hedera.services.bdd.junit.RepeatableHapiTest; +import com.hedera.services.bdd.junit.support.TestLifecycle; +import edu.umd.cs.findbugs.annotations.NonNull; +import java.time.Instant; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Stream; import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DynamicTest; /** - * Validates that staking metadata stays up-to-date even when returning to a staked account - * after a long period of inactivity. + * Staking tests that need virtual time for fast execution. */ +@HapiTestLifecycle public class RepeatableStakingTests { - @RepeatableHapiTest(NEEDS_VIRTUAL_TIME_FOR_FAST_EXECUTION) - Stream noStakingInteractionsForExtendedPeriodIsFine() { - final var numPeriodsToElapse = 366; - return hapiTest( + @BeforeAll + static void beforeAll(@NonNull final TestLifecycle testLifecycle) { + testLifecycle.doAdhoc( overridingThree( "staking.startThreshold", "" + 10 * ONE_HBAR, "staking.perHbarRewardRate", "1", "staking.rewardBalanceThreshold", "0"), - cryptoTransfer(tinyBarsFromTo(GENESIS, STAKING_REWARD, ONE_MILLION_HBARS)), + cryptoTransfer(tinyBarsFromTo(GENESIS, STAKING_REWARD, ONE_MILLION_HBARS))); + } + + /** + * Validates that staking metadata stays up-to-date even when returning to a staked account + * after a long period of inactivity. + */ + @RepeatableHapiTest(NEEDS_VIRTUAL_TIME_FOR_FAST_EXECUTION) + Stream noStakingInteractionsForExtendedPeriodIsFine() { + final var numPeriodsToElapse = 366; + return hapiTest( cryptoCreate("forgottenStaker").stakedNodeId(0).balance(ONE_HBAR), withOpContext((spec, opLog) -> { for (int i = 0; i < numPeriodsToElapse; i++) { @@ -64,4 +88,40 @@ Stream noStakingInteractionsForExtendedPeriodIsFine() { cryptoTransfer(tinyBarsFromTo(GENESIS, "forgottenStaker", 1L)).via("collection"), getTxnRecord("collection").hasPaidStakingRewards(List.of(Pair.of("forgottenStaker", 365L)))); } + + /** + * Validates that staking metadata stays up-to-date even when returning to a staked account + * after a long period of inactivity. + */ + @RepeatableHapiTest(NEEDS_VIRTUAL_TIME_FOR_FAST_EXECUTION) + Stream scheduledTransactionCrossingThresholdTriggersExpectedRewards() { + final AtomicReference secondBoundary = new AtomicReference<>(); + return hapiTest( + blockStreamMustIncludePassFrom(scheduledExecutionResult( + "trigger", withRecordSpec(op -> op.hasPaidStakingRewards(List.of(Pair.of("staker", 2L)))))), + cryptoCreate("staker").stakedNodeId(0).balance(ONE_HBAR), + waitUntilStartOfNextStakingPeriod(1), + doingContextual(spec -> secondBoundary.set( + Instant.ofEpochSecond(spec.consensusTime().getEpochSecond()) + .plusSeconds(2 * 60))), + // The first transaction of the first staking period the staker is eligible for rewards + cryptoTransfer(tinyBarsFromTo(GENESIS, FUNDING, 1L)), + sourcing(() -> scheduleCreate("one", cryptoTransfer(tinyBarsFromTo(DEFAULT_PAYER, "staker", 1L))) + .waitForExpiry() + .expiringAt(secondBoundary.get().getEpochSecond() - 1) + .via("trigger")), + waitUntilStartOfNextStakingPeriod(1), + // The first transaction of the next staking period + cryptoTransfer(tinyBarsFromTo(GENESIS, FUNDING, 1L)), + doWithStartupConfig( + "consensus.handle.maxPrecedingRecords", + value -> sleepToExactly(secondBoundary + .get() + // The next transaction will happen one second after the time we sleep to + .minusSeconds(1) + // And we adjust the nanos so the user transaction will be in this staking + // period, but the triggered transaction will be in the next staking period + .minusNanos(Long.parseLong(value) + 1))), + cryptoCreate("justBeforeSecondPeriod")); + } }