From 3074cd4bfd9d2792153a8dbaff14c9633d90ae94 Mon Sep 17 00:00:00 2001 From: Eva Date: Thu, 30 Oct 2025 17:06:57 +0200 Subject: [PATCH 1/2] consensus service negative tests for simple fees Signed-off-by: Eva --- .../bdd/suites/fees/SimpleFeesSuite.java | 803 ++++++++++++++++++ 1 file changed, 803 insertions(+) diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/fees/SimpleFeesSuite.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/fees/SimpleFeesSuite.java index bc0be4fc8f64..9cc5b2a1bece 100644 --- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/fees/SimpleFeesSuite.java +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/fees/SimpleFeesSuite.java @@ -1,21 +1,42 @@ // SPDX-License-Identifier: Apache-2.0 package com.hedera.services.bdd.suites.fees; +import static com.hedera.node.app.hapi.utils.CommonUtils.extractTransactionBody; import static com.hedera.services.bdd.spec.HapiSpec.hapiTest; +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.getReceipt; import static com.hedera.services.bdd.spec.queries.QueryVerbs.getTopicInfo; +import static com.hedera.services.bdd.spec.queries.QueryVerbs.getTxnRecord; 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.deleteTopic; import static com.hedera.services.bdd.spec.transactions.TxnVerbs.submitMessageTo; +import static com.hedera.services.bdd.spec.transactions.TxnVerbs.uncheckedSubmit; import static com.hedera.services.bdd.spec.transactions.TxnVerbs.updateTopic; +import static com.hedera.services.bdd.spec.utilops.CustomSpecAssert.allRunFor; 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.usableTxnIdNamed; import static com.hedera.services.bdd.spec.utilops.UtilVerbs.validateChargedUsd; +import static com.hedera.services.bdd.spec.utilops.UtilVerbs.withOpContext; 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_HUNDRED_HBARS; import static com.hedera.services.bdd.suites.HapiSuite.SIMPLE_FEE_SCHEDULE; +import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.DUPLICATE_TRANSACTION; +import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.INSUFFICIENT_PAYER_BALANCE; +import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.INSUFFICIENT_TX_FEE; +import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.INVALID_PAYER_SIGNATURE; +import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.INVALID_SIGNATURE; +import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.INVALID_TRANSACTION_DURATION; +import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.INVALID_TRANSACTION_START; +import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.MEMO_TOO_LONG; +import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.RECORD_NOT_FOUND; +import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.SUCCESS; +import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.TRANSACTION_EXPIRED; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import com.hedera.services.bdd.junit.HapiTest; import com.hedera.services.bdd.junit.HapiTestLifecycle; @@ -27,6 +48,7 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayName; @@ -401,4 +423,785 @@ final Stream submitBiggerMessageFee() { ))); } } + + @Nested + class TopicFeesComparisonNegativeCases { + + @Nested + class CreateTopicFailsOnIngest { + @LeakyHapiTest(overrides = {"fees.simpleFeesEnabled"}) + @DisplayName("create topic with insufficient txn fee fails on ingest and payer not charged") + final Stream createTopicInsufficientFeeFailsOnIngest() { + final AtomicLong initialBalance = new AtomicLong(); + final AtomicLong afterBalance = new AtomicLong(); + return runBeforeAfter( + cryptoCreate(PAYER).balance(ONE_HUNDRED_HBARS), + + // Save payer balance before + getAccountBalance(PAYER).exposingBalanceTo(initialBalance::set), + + // Create topic with insufficient fee + createTopic("testTopic") + .blankMemo() + .payingWith(PAYER) + .signedBy(PAYER) + .fee(ONE_HBAR / 100) // fee is too low + .via("create-topic-txn") + .hasPrecheck(INSUFFICIENT_TX_FEE), + + // assert no txn record is created + getTxnRecord("create-topic-txn").logged().hasAnswerOnlyPrecheckFrom(RECORD_NOT_FOUND), + + // Save payer balance after and assert changes + getAccountBalance(PAYER).exposingBalanceTo(afterBalance::set), + withOpContext((spec, log) -> { + assertEquals(initialBalance.get(), afterBalance.get()); + })); + } + + @LeakyHapiTest(overrides = {"fees.simpleFeesEnabled"}) + @DisplayName("create topic not signed by payer fails on ingest and payer not charged") + final Stream createTopicNotSignedByPayerFailsOnIngest() { + final AtomicLong initialBalance = new AtomicLong(); + final AtomicLong afterBalance = new AtomicLong(); + return runBeforeAfter( + cryptoCreate(PAYER).balance(ONE_HUNDRED_HBARS), + cryptoCreate(ADMIN).balance(ONE_HUNDRED_HBARS), + + // Save payer balance before + getAccountBalance(PAYER).exposingBalanceTo(initialBalance::set), + + // Create topic with admin key not signed by payer + createTopic("testTopic") + .blankMemo() + .payingWith(PAYER) + .adminKeyName(ADMIN) + .signedBy(ADMIN) + .fee(ONE_HBAR) + .via("create-topic-txn") + .hasPrecheck(INVALID_SIGNATURE), + + // assert no txn record is created + getTxnRecord("create-topic-txn").logged().hasAnswerOnlyPrecheckFrom(RECORD_NOT_FOUND), + + // Save payer balance after and assert changes + getAccountBalance(PAYER).exposingBalanceTo(afterBalance::set), + withOpContext((spec, log) -> { + assertEquals(initialBalance.get(), afterBalance.get()); + })); + } + + @LeakyHapiTest(overrides = {"fees.simpleFeesEnabled"}) + @DisplayName("create topic with insufficient payer balance fails on ingest and payer not charged") + final Stream createTopicWithInsufficientPayerBalanceFailsOnIngest() { + final AtomicLong initialBalance = new AtomicLong(); + final AtomicLong afterBalance = new AtomicLong(); + return runBeforeAfter( + cryptoCreate(PAYER).balance(ONE_HBAR / 1000), // insufficient balance + newKeyNamed(ADMIN), + + // Save payer balance before + getAccountBalance(PAYER).exposingBalanceTo(initialBalance::set), + + // Create topic with insufficient payer balance + createTopic("testTopic") + .blankMemo() + .payingWith(PAYER) + .adminKeyName(ADMIN) + .fee(ONE_HBAR) + .via("create-topic-txn") + .hasPrecheck(INSUFFICIENT_PAYER_BALANCE), + + // assert no txn record is created + getTxnRecord("create-topic-txn").logged().hasAnswerOnlyPrecheckFrom(RECORD_NOT_FOUND), + + // Save payer balance after and assert changes + getAccountBalance(PAYER).exposingBalanceTo(afterBalance::set), + withOpContext((spec, log) -> { + assertEquals(initialBalance.get(), afterBalance.get()); + })); + } + + @LeakyHapiTest(overrides = {"fees.simpleFeesEnabled"}) + @DisplayName("create topic with too long memo fails on ingest and payer not charged") + final Stream createTopicTooLongMemoFailsOnIngest() { + final var LONG_MEMO = "x".repeat(1025); // memo exceeds 1024 bytes limit + final AtomicLong initialBalance = new AtomicLong(); + final AtomicLong afterBalance = new AtomicLong(); + return runBeforeAfter( + cryptoCreate(PAYER).balance(ONE_HUNDRED_HBARS), + + // Save payer balance before + getAccountBalance(PAYER).exposingBalanceTo(initialBalance::set), + + // Create topic with too long memo + createTopic("testTopic") + .memo(LONG_MEMO) + .payingWith(PAYER) + .signedBy(PAYER) + .fee(ONE_HBAR) + .via("create-topic-txn") + .hasPrecheck(MEMO_TOO_LONG), + + // assert no txn record is created + getTxnRecord("create-topic-txn").logged().hasAnswerOnlyPrecheckFrom(RECORD_NOT_FOUND), + + // Save payer balance after and assert changes + getAccountBalance(PAYER).exposingBalanceTo(afterBalance::set), + withOpContext((spec, log) -> { + assertEquals(initialBalance.get(), afterBalance.get()); + })); + } + + @LeakyHapiTest(overrides = {"fees.simpleFeesEnabled"}) + @DisplayName("create topic expired transaction fails on ingest and payer not charged") + final Stream createTopicExpiredFailsOnIngest() { + final var expiredTxnId = "expiredCreateTopic"; + final var oneHourPast = -3_600L; // 1 hour before + final AtomicLong initialBalance = new AtomicLong(); + final AtomicLong afterBalance = new AtomicLong(); + return runBeforeAfter( + cryptoCreate(PAYER).balance(ONE_HUNDRED_HBARS), + + // Save payer balance before + getAccountBalance(PAYER).exposingBalanceTo(initialBalance::set), + + // Create expired topic + usableTxnIdNamed(expiredTxnId) + .modifyValidStart(oneHourPast) + .payerId(PAYER), + createTopic("testTopic") + .blankMemo() + .payingWith(PAYER) + .signedBy(PAYER) + .fee(ONE_HBAR) + .txnId(expiredTxnId) + .via("create-topic-txn") + .hasPrecheck(TRANSACTION_EXPIRED), + + // assert no txn record is created + getTxnRecord("create-topic-txn").logged().hasAnswerOnlyPrecheckFrom(RECORD_NOT_FOUND), + + // Save payer balance after and assert changes + getAccountBalance(PAYER).exposingBalanceTo(afterBalance::set), + withOpContext((spec, log) -> { + assertEquals(initialBalance.get(), afterBalance.get()); + })); + } + + @LeakyHapiTest(overrides = {"fees.simpleFeesEnabled"}) + @DisplayName("create topic with too far start time fails on ingest and payer not charged") + final Stream createTopicTooFarStartTimeFailsOnIngest() { + final var futureTxnId = "futureCreateTopic"; + final var oneHourFuture = 3_600L; // 1 hour before + final AtomicLong initialBalance = new AtomicLong(); + final AtomicLong afterBalance = new AtomicLong(); + return runBeforeAfter( + cryptoCreate(PAYER).balance(ONE_HUNDRED_HBARS), + + // Save payer balance before + getAccountBalance(PAYER).exposingBalanceTo(initialBalance::set), + + // Create topic with start time in the future + usableTxnIdNamed(futureTxnId) + .modifyValidStart(oneHourFuture) + .payerId(PAYER), + createTopic("testTopic") + .blankMemo() + .payingWith(PAYER) + .signedBy(PAYER) + .fee(ONE_HBAR) + .txnId(futureTxnId) + .via("create-topic-txn") + .hasPrecheck(INVALID_TRANSACTION_START), + + // assert no txn record is created + getTxnRecord("create-topic-txn").logged().hasAnswerOnlyPrecheckFrom(RECORD_NOT_FOUND), + + // Save payer balance after and assert changes + getAccountBalance(PAYER).exposingBalanceTo(afterBalance::set), + withOpContext((spec, log) -> { + assertEquals(initialBalance.get(), afterBalance.get()); + })); + } + + @LeakyHapiTest(overrides = {"fees.simpleFeesEnabled"}) + @DisplayName("create topic with invalid duration time fails on ingest and payer not charged") + final Stream createTopicInvalidDurationTimeFailsOnIngest() { + final AtomicLong initialBalance = new AtomicLong(); + final AtomicLong afterBalance = new AtomicLong(); + return runBeforeAfter( + cryptoCreate(PAYER).balance(ONE_HUNDRED_HBARS), + + // Save payer balance before + getAccountBalance(PAYER).exposingBalanceTo(initialBalance::set), + + // Create topic with invalid duration time + createTopic("testTopic") + .blankMemo() + .payingWith(PAYER) + .signedBy(PAYER) + .fee(ONE_HBAR) + .validDurationSecs(0) // invalid duration + .via("create-topic-txn") + .hasPrecheck(INVALID_TRANSACTION_DURATION), + + // assert no txn record is created + getTxnRecord("create-topic-txn").logged().hasAnswerOnlyPrecheckFrom(RECORD_NOT_FOUND), + + // Save payer balance after and assert changes + getAccountBalance(PAYER).exposingBalanceTo(afterBalance::set), + withOpContext((spec, log) -> { + assertEquals(initialBalance.get(), afterBalance.get()); + })); + } + + @LeakyHapiTest(overrides = {"fees.simpleFeesEnabled"}) + @DisplayName("create topic duplicate txn fails on ingest and payer not charged") + final Stream createTopicDuplicateTxnFailsOnIngest() { + final AtomicLong initialBalance = new AtomicLong(); + final AtomicLong afterBalance = new AtomicLong(); + return runBeforeAfter( + cryptoCreate(PAYER).balance(ONE_HUNDRED_HBARS), + + // Save payer balance before + getAccountBalance(PAYER).exposingBalanceTo(initialBalance::set), + + // Create topic successful first txn + createTopic("testTopic").blankMemo().fee(ONE_HBAR).via("create-topic-txn"), + // Create topic duplicate txn + createTopic("testTopicDuplicate") + .blankMemo() + .payingWith(PAYER) + .signedBy(PAYER) + .fee(ONE_HBAR) + .txnId("create-topic-txn") + .via("create-topic-duplicate-txn") + .hasPrecheck(DUPLICATE_TRANSACTION), + + // Save payer balance after and assert changes + getAccountBalance(PAYER).exposingBalanceTo(afterBalance::set), + withOpContext((spec, log) -> { + assertEquals(initialBalance.get(), afterBalance.get()); + })); + } + } + + @Nested + class CreateTopicFailsOnPreHandle { + @LeakyHapiTest(overrides = {"fees.simpleFeesEnabled"}) + @DisplayName("create topic with insufficient txn fee fails on pre-handle and payer is not charged") + final Stream createTopicInsufficientFeeFailsOnPreHandle() { + final AtomicLong initialBalance = new AtomicLong(); + final AtomicLong afterBalance = new AtomicLong(); + + final String INNER_ID = "create-topic-txn-inner-id"; + final String ENVELOPE_ID = "create-topic-txn-envelope-id"; + + return runBeforeAfter( + cryptoCreate(PAYER).balance(ONE_HUNDRED_HBARS), + + // Register a TxnId for the inner txn + usableTxnIdNamed(INNER_ID).payerId(PAYER), + + // Save payer balance before + getAccountBalance(PAYER).exposingBalanceTo(initialBalance::set), + withOpContext((spec, log) -> { + // build the inner txn + final var innerTxn = createTopic("testTopic") + .blankMemo() + .payingWith(PAYER) + .signedBy(PAYER) + .fee(ONE_HBAR / 100) // fee is too low + .txnId(INNER_ID) + .via(INNER_ID); + + // create signed bytes + final var signed = innerTxn.signedTxnFor(spec); + + // extract the txn body from the signed txn + final var txnBody = extractTransactionBody(signed); + + // save the txn id and bytes in the registry + spec.registry().saveTxnId(INNER_ID, txnBody.getTransactionID()); + spec.registry().saveBytes(INNER_ID, signed.toByteString()); + + // submit the unchecked wrapping txn + final var envelope = uncheckedSubmit(innerTxn).via(ENVELOPE_ID); + allRunFor(spec, envelope); + + final var operation = getTxnRecord(INNER_ID).assertingNothingAboutHashes(); + allRunFor(spec, operation); + + final var status = + operation.getResponseRecord().getReceipt().getStatus(); + assertEquals(INSUFFICIENT_TX_FEE, status, "Expected txn to fail but it succeeded"); + }), + + // Save payer balance after and assert payer was not charged + validateChargedUsd(INNER_ID, ucents_to_USD(0)), + getAccountBalance(PAYER).exposingBalanceTo(afterBalance::set), + withOpContext((spec, log) -> { + assertEquals(initialBalance.get(), afterBalance.get()); + })); + } + + @LeakyHapiTest(overrides = {"fees.simpleFeesEnabled"}) + @DisplayName("create topic not signed by payer fails on pre-handle and payer is not charged") + final Stream createTopicNotSignedByPayerFailsOnPreHandle() { + final AtomicLong initialBalance = new AtomicLong(); + final AtomicLong afterBalance = new AtomicLong(); + + final String INNER_ID = "create-topic-txn-inner-id"; + final String ENVELOPE_ID = "create-topic-txn-envelope-id"; + + return runBeforeAfter( + cryptoCreate(PAYER).balance(ONE_HUNDRED_HBARS), + cryptoCreate(ADMIN).balance(ONE_HUNDRED_HBARS), + + // Register a TxnId for the inner txn + usableTxnIdNamed(INNER_ID).payerId(PAYER), + + // Save payer balance before + getAccountBalance(PAYER).exposingBalanceTo(initialBalance::set), + withOpContext((spec, log) -> { + // build the inner txn + final var innerTxn = createTopic("testTopic") + .blankMemo() + .payingWith(PAYER) + .adminKeyName(ADMIN) + .signedBy(ADMIN) + .fee(ONE_HBAR / 100) // fee is too low + .txnId(INNER_ID) + .via(INNER_ID); + + // create signed bytes + final var signed = innerTxn.signedTxnFor(spec); + + // extract the txn body from the signed txn + final var txnBody = extractTransactionBody(signed); + + // save the txn id and bytes in the registry + spec.registry().saveTxnId(INNER_ID, txnBody.getTransactionID()); + spec.registry().saveBytes(INNER_ID, signed.toByteString()); + + // submit the unchecked wrapping txn + final var envelope = uncheckedSubmit(innerTxn).via(ENVELOPE_ID); + allRunFor(spec, envelope); + + final var operation = getTxnRecord(INNER_ID).assertingNothingAboutHashes(); + allRunFor(spec, operation); + + final var status = + operation.getResponseRecord().getReceipt().getStatus(); + assertEquals(INVALID_PAYER_SIGNATURE, status, "Expected txn to fail but it succeeded"); + }), + + // Save payer balance after and assert payer was not charged + validateChargedUsd(INNER_ID, ucents_to_USD(0)), + getAccountBalance(PAYER).exposingBalanceTo(afterBalance::set), + withOpContext((spec, log) -> { + assertEquals(initialBalance.get(), afterBalance.get()); + })); + } + + @LeakyHapiTest(overrides = {"fees.simpleFeesEnabled"}) + @DisplayName("create topic with insufficient payer balance fails on pre-handle and payer is not charged") + final Stream createTopicWithInsufficientPayerBalanceFailsOnPreHandle() { + final AtomicLong initialBalance = new AtomicLong(); + final AtomicLong afterBalance = new AtomicLong(); + + final String INNER_ID = "create-topic-txn-inner-id"; + final String ENVELOPE_ID = "create-topic-txn-envelope-id"; + + return runBeforeAfter( + cryptoCreate(PAYER).balance(ONE_HBAR / 1000), // insufficient balance + cryptoCreate(ADMIN).balance(ONE_HUNDRED_HBARS), + + // Register a TxnId for the inner txn + usableTxnIdNamed(INNER_ID).payerId(PAYER), + + // Save payer balance before + getAccountBalance(PAYER).exposingBalanceTo(initialBalance::set), + withOpContext((spec, log) -> { + // build the inner txn + final var innerTxn = createTopic("testTopic") + .blankMemo() + .payingWith(PAYER) + .adminKeyName(ADMIN) + .signedBy(ADMIN, PAYER) + .fee(ONE_HBAR) + .txnId(INNER_ID) + .via(INNER_ID); + + // create signed bytes + final var signed = innerTxn.signedTxnFor(spec); + + // extract the txn body from the signed txn + final var txnBody = extractTransactionBody(signed); + + // save the txn id and bytes in the registry + spec.registry().saveTxnId(INNER_ID, txnBody.getTransactionID()); + spec.registry().saveBytes(INNER_ID, signed.toByteString()); + + // submit the unchecked wrapping txn + final var envelope = uncheckedSubmit(innerTxn).via(ENVELOPE_ID); + allRunFor(spec, envelope); + + final var operation = getTxnRecord(INNER_ID).assertingNothingAboutHashes(); + allRunFor(spec, operation); + + final var status = + operation.getResponseRecord().getReceipt().getStatus(); + assertEquals(INSUFFICIENT_PAYER_BALANCE, status, "Expected txn to fail but it succeeded"); + }), + + // Save payer balance after and assert payer was not charged + validateChargedUsd(INNER_ID, ucents_to_USD(0)), + getAccountBalance(PAYER).exposingBalanceTo(afterBalance::set), + withOpContext((spec, log) -> { + assertEquals(initialBalance.get(), afterBalance.get()); + })); + } + + @LeakyHapiTest(overrides = {"fees.simpleFeesEnabled"}) + @DisplayName("create topic with admin key not signed by the admin fails on pre-handle and payer is charged") + final Stream createTopicWithAdminKeyNotSignedByAdminFailsOnPreHandlePayerIsCharged() { + final AtomicLong initialBalance = new AtomicLong(); + final AtomicLong afterBalance = new AtomicLong(); + final String INNER_ID = "create-topic-txn-inner-id"; + final String ENVELOPE_ID = "create-topic-txn-envelope-id"; + + return runBeforeAfter( + cryptoCreate(PAYER).balance(ONE_HUNDRED_HBARS), + cryptoCreate(ADMIN).balance(ONE_HUNDRED_HBARS), + + // Save payer balance before + getAccountBalance(PAYER).exposingBalanceTo(initialBalance::set), + + // Register a TxnId for the inner txn + usableTxnIdNamed(INNER_ID).payerId(PAYER), + withOpContext((spec, log) -> { + // build the inner txn + final var innerTxn = createTopic("testTopic") + .blankMemo() + .payingWith(PAYER) + .adminKeyName(ADMIN) + .signedBy(PAYER) + .fee(ONE_HBAR) + .txnId(INNER_ID) + .via(INNER_ID); + + // create signed bytes + final var signed = innerTxn.signedTxnFor(spec); + + // extract the txn body from the signed txn + final var txnBody = extractTransactionBody(signed); + + // save the txn id and bytes in the registry + spec.registry().saveTxnId(INNER_ID, txnBody.getTransactionID()); + spec.registry().saveBytes(INNER_ID, signed.toByteString()); + + // submit the unchecked wrapping txn + final var envelope = uncheckedSubmit(innerTxn).via(ENVELOPE_ID); + allRunFor(spec, envelope); + + final var operation = getTxnRecord(INNER_ID).assertingNothingAboutHashes(); + allRunFor(spec, operation); + + final var status = + operation.getResponseRecord().getReceipt().getStatus(); + assertEquals(INVALID_SIGNATURE, status, "Expected txn to fail but it succeeded"); + }), + // Save payer balance after and assert changes + validateChargedUsd(INNER_ID, ucents_to_USD(1021)), + getAccountBalance(PAYER).exposingBalanceTo(afterBalance::set), + withOpContext((spec, log) -> { + assertTrue(initialBalance.get() > afterBalance.get()); + })); + } + + // should we not charge the payer here? + @LeakyHapiTest(overrides = {"fees.simpleFeesEnabled"}) + @DisplayName("create topic with too long memo fails on pre-handle and payer is not charged") + final Stream createTopicWithTooLongMemoFailsOnPreHandlePayerIsNotCharged() { + final var LONG_MEMO = "x".repeat(1025); // memo exceeds 1024 bytes limit + final String INNER_ID = "create-topic-txn-inner-id"; + final String ENVELOPE_ID = "create-topic-txn-envelope-id"; + final AtomicLong initialBalance = new AtomicLong(); + final AtomicLong afterBalance = new AtomicLong(); + return runBeforeAfter( + cryptoCreate(PAYER).balance(ONE_HUNDRED_HBARS), + + // Save payer balance before + getAccountBalance(PAYER).exposingBalanceTo(initialBalance::set), + + // Register a TxnId for the inner txn + usableTxnIdNamed(INNER_ID).payerId(PAYER), + withOpContext((spec, log) -> { + // build the inner txn + final var innerTxn = createTopic("testTopic") + .memo(LONG_MEMO) + .payingWith(PAYER) + .signedBy(PAYER) + .fee(ONE_HBAR) + .txnId(INNER_ID) + .via(INNER_ID); + + // create signed bytes + final var signed = innerTxn.signedTxnFor(spec); + + // extract the txn body from the signed txn + final var txnBody = extractTransactionBody(signed); + + // save the txn id and bytes in the registry + spec.registry().saveTxnId(INNER_ID, txnBody.getTransactionID()); + spec.registry().saveBytes(INNER_ID, signed.toByteString()); + + // submit the unchecked wrapping txn + final var envelope = uncheckedSubmit(innerTxn).via(ENVELOPE_ID); + allRunFor(spec, envelope); + + final var operation = getTxnRecord(INNER_ID).assertingNothingAboutHashes(); + allRunFor(spec, operation); + + final var status = + operation.getResponseRecord().getReceipt().getStatus(); + assertEquals(MEMO_TOO_LONG, status, "Expected txn to fail but it succeeded"); + }), + validateChargedUsd(INNER_ID, ucents_to_USD(0)), + getAccountBalance(PAYER).exposingBalanceTo(afterBalance::set), + withOpContext((spec, log) -> { + assertEquals(initialBalance.get(), afterBalance.get()); + })); + } + + @LeakyHapiTest(overrides = {"fees.simpleFeesEnabled"}) + @DisplayName("create topic expired transaction fails on pre-handle and payer is not charged") + final Stream createTopicExpiredFailsOnPreHandlePayerIsNotCharged() { + final var oneHourPast = -3_600L; // 1 hour before + final String INNER_ID = "create-topic-txn-inner-id"; + final String ENVELOPE_ID = "create-topic-txn-envelope-id"; + final AtomicLong initialBalance = new AtomicLong(); + final AtomicLong afterBalance = new AtomicLong(); + return runBeforeAfter( + cryptoCreate(PAYER).balance(ONE_HUNDRED_HBARS), + + // Save payer balance before + getAccountBalance(PAYER).exposingBalanceTo(initialBalance::set), + + // Register a TxnId for the inner txn + usableTxnIdNamed(INNER_ID).modifyValidStart(oneHourPast).payerId(PAYER), + withOpContext((spec, log) -> { + // build the inner txn + final var innerTxn = createTopic("testTopic") + .blankMemo() + .payingWith(PAYER) + .signedBy(PAYER) + .fee(ONE_HBAR) + .txnId(INNER_ID) + .via(INNER_ID); + + // create signed bytes + final var signed = innerTxn.signedTxnFor(spec); + + // extract the txn body from the signed txn + final var txnBody = extractTransactionBody(signed); + + // save the txn id and bytes in the registry + spec.registry().saveTxnId(INNER_ID, txnBody.getTransactionID()); + spec.registry().saveBytes(INNER_ID, signed.toByteString()); + + // submit the unchecked wrapping txn + final var envelope = uncheckedSubmit(innerTxn).via(ENVELOPE_ID); + allRunFor(spec, envelope); + + final var operation = getTxnRecord(INNER_ID).assertingNothingAboutHashes(); + allRunFor(spec, operation); + + final var status = + operation.getResponseRecord().getReceipt().getStatus(); + assertEquals(TRANSACTION_EXPIRED, status, "Expected txn to fail but it succeeded"); + }), + validateChargedUsd(INNER_ID, ucents_to_USD(0)), + getAccountBalance(PAYER).exposingBalanceTo(afterBalance::set), + withOpContext((spec, log) -> { + assertEquals(initialBalance.get(), afterBalance.get()); + })); + } + + @LeakyHapiTest(overrides = {"fees.simpleFeesEnabled"}) + @DisplayName("create topic with too far start time fails on pre-handle and payer is not charged") + final Stream createTopicTooFarStartTimeFailsOnPreHandlePayerIsNotCharged() { + final var oneHourFuture = 3_600L; // 1 hour before + final String INNER_ID = "create-topic-txn-inner-id"; + final String ENVELOPE_ID = "create-topic-txn-envelope-id"; + final AtomicLong initialBalance = new AtomicLong(); + final AtomicLong afterBalance = new AtomicLong(); + return runBeforeAfter( + cryptoCreate(PAYER).balance(ONE_HUNDRED_HBARS), + + // Save payer balance before + getAccountBalance(PAYER).exposingBalanceTo(initialBalance::set), + + // Register a TxnId for the inner txn + usableTxnIdNamed(INNER_ID) + .modifyValidStart(oneHourFuture) + .payerId(PAYER), + withOpContext((spec, log) -> { + // build the inner txn + final var innerTxn = createTopic("testTopic") + .blankMemo() + .payingWith(PAYER) + .signedBy(PAYER) + .fee(ONE_HBAR) + .txnId(INNER_ID) + .via(INNER_ID); + + // create signed bytes + final var signed = innerTxn.signedTxnFor(spec); + + // extract the txn body from the signed txn + final var txnBody = extractTransactionBody(signed); + + // save the txn id and bytes in the registry + spec.registry().saveTxnId(INNER_ID, txnBody.getTransactionID()); + spec.registry().saveBytes(INNER_ID, signed.toByteString()); + + // submit the unchecked wrapping txn + final var envelope = uncheckedSubmit(innerTxn).via(ENVELOPE_ID); + allRunFor(spec, envelope); + + final var operation = getTxnRecord(INNER_ID).assertingNothingAboutHashes(); + allRunFor(spec, operation); + + final var status = + operation.getResponseRecord().getReceipt().getStatus(); + assertEquals(INVALID_TRANSACTION_START, status, "Expected txn to fail but it succeeded"); + }), + validateChargedUsd(INNER_ID, ucents_to_USD(0)), + getAccountBalance(PAYER).exposingBalanceTo(afterBalance::set), + withOpContext((spec, log) -> { + assertEquals(initialBalance.get(), afterBalance.get()); + })); + } + + @LeakyHapiTest(overrides = {"fees.simpleFeesEnabled"}) + @DisplayName("create topic with invalid duration time fails on pre-handle and payer is not charged") + final Stream createTopicInvalidDurationTimeFailsOnPreHandlePayerIsNotCharged() { + final String INNER_ID = "create-topic-txn-inner-id"; + final String ENVELOPE_ID = "create-topic-txn-envelope-id"; + final AtomicLong initialBalance = new AtomicLong(); + final AtomicLong afterBalance = new AtomicLong(); + return runBeforeAfter( + cryptoCreate(PAYER).balance(ONE_HUNDRED_HBARS), + + // Save payer balance before + getAccountBalance(PAYER).exposingBalanceTo(initialBalance::set), + + // Register a TxnId for the inner txn + usableTxnIdNamed(INNER_ID).payerId(PAYER), + withOpContext((spec, log) -> { + // build the inner txn + final var innerTxn = createTopic("testTopic") + .blankMemo() + .payingWith(PAYER) + .signedBy(PAYER) + .fee(ONE_HBAR) + .validDurationSecs(0) // invalid duration + .txnId(INNER_ID) + .via(INNER_ID); + + // create signed bytes + final var signed = innerTxn.signedTxnFor(spec); + + // extract the txn body from the signed txn + final var txnBody = extractTransactionBody(signed); + + // save the txn id and bytes in the registry + spec.registry().saveTxnId(INNER_ID, txnBody.getTransactionID()); + spec.registry().saveBytes(INNER_ID, signed.toByteString()); + + // submit the unchecked wrapping txn + final var envelope = uncheckedSubmit(innerTxn).via(ENVELOPE_ID); + allRunFor(spec, envelope); + + final var operation = getTxnRecord(INNER_ID).assertingNothingAboutHashes(); + allRunFor(spec, operation); + + final var status = + operation.getResponseRecord().getReceipt().getStatus(); + assertEquals(INVALID_TRANSACTION_DURATION, status, "Expected txn to fail but it succeeded"); + }), + validateChargedUsd(INNER_ID, ucents_to_USD(0)), + getAccountBalance(PAYER).exposingBalanceTo(afterBalance::set), + withOpContext((spec, log) -> { + assertEquals(initialBalance.get(), afterBalance.get()); + })); + } + + @LeakyHapiTest(overrides = {"fees.simpleFeesEnabled"}) + @DisplayName("create topic duplicate txn fails on pre-handle and payer is not charged") + final Stream createTopicDuplicateTxnFailsOnPreHandlePayerIsNotCharged() { + final String INNER_ID = "create-topic-txn-inner-id"; + final String ENVELOPE_ID = "create-topic-txn-envelope-id"; + final AtomicLong initialBalance = new AtomicLong(); + final AtomicLong afterBalance = new AtomicLong(); + return runBeforeAfter( + cryptoCreate(PAYER).balance(ONE_HUNDRED_HBARS), + + // Save payer balance before + getAccountBalance(PAYER).exposingBalanceTo(initialBalance::set), + + // Register a TxnId for the inner txn + usableTxnIdNamed(INNER_ID).payerId(PAYER), + withOpContext((spec, log) -> { + // build the inner txn + final var innerTxn = createTopic("testTopic") + .blankMemo() + .payingWith(PAYER) + .signedBy(PAYER) + .fee(ONE_HBAR) + .txnId(INNER_ID) + .via(INNER_ID); + + // create signed bytes + final var signed = innerTxn.signedTxnFor(spec); + + // extract the txn body from the signed txn + final var txnBody = extractTransactionBody(signed); + + // save the txn id and bytes in the registry + spec.registry().saveTxnId(INNER_ID, txnBody.getTransactionID()); + spec.registry().saveBytes(INNER_ID, signed.toByteString()); + + // submit the unchecked wrapping txn + final var envelope = uncheckedSubmit(innerTxn).via(ENVELOPE_ID); + final var envelopeDuplicate = + uncheckedSubmit(innerTxn).via(ENVELOPE_ID); + allRunFor(spec, envelope, envelopeDuplicate); + + final var operation = getTxnRecord(INNER_ID).assertingNothingAboutHashes(); + allRunFor(spec, operation); + + // assert original txn succeeded + final var status = + operation.getResponseRecord().getReceipt().getStatus(); + assertEquals(SUCCESS, status, "Expected txn to fail but it succeeded"); + }), + + // assert duplicate txn record is created and original txn charged + getReceipt(INNER_ID) + .andAnyDuplicates() + .logged() + .hasPriorityStatus(SUCCESS) + .hasDuplicateStatuses(DUPLICATE_TRANSACTION), + validateChargedUsd(INNER_ID, ucents_to_USD(1003)), + getAccountBalance(PAYER).exposingBalanceTo(afterBalance::set), + withOpContext((spec, log) -> { + assertTrue(initialBalance.get() > afterBalance.get()); + })); + } + } + } } From e9a60066833f3415d731920ec38d29df13029dd9 Mon Sep 17 00:00:00 2001 From: Eva Date: Fri, 31 Oct 2025 15:00:00 +0200 Subject: [PATCH 2/2] new negative tests added Signed-off-by: Eva --- .../bdd/suites/fees/SimpleFeesSuite.java | 321 ++++++++++++++++++ 1 file changed, 321 insertions(+) diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/fees/SimpleFeesSuite.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/fees/SimpleFeesSuite.java index a2b9c0ce97e6..52a2371193b2 100644 --- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/fees/SimpleFeesSuite.java +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/fees/SimpleFeesSuite.java @@ -62,6 +62,7 @@ public class SimpleFeesSuite { private static final String PAYER = "payer"; private static final String ADMIN = "admin"; + private static final String NEW_ADMIN = "newAdmin"; @BeforeAll static void beforeAll(@NonNull final TestLifecycle testLifecycle) { @@ -688,6 +689,129 @@ final Stream createTopicDuplicateTxnFailsOnIngest() { assertEquals(initialBalance.get(), afterBalance.get()); })); } + + @LeakyHapiTest(overrides = {"fees.simpleFeesEnabled"}) + @DisplayName("update topic not signed by payer fails on ingest and payer not charged") + final Stream updateTopicNotSignedByPayerFailsOnIngest() { + final AtomicLong initialBalance = new AtomicLong(); + final AtomicLong afterBalance = new AtomicLong(); + return runBeforeAfter( + cryptoCreate(PAYER).balance(ONE_HUNDRED_HBARS), + cryptoCreate(ADMIN).balance(ONE_HUNDRED_HBARS), + cryptoCreate(NEW_ADMIN).balance(ONE_HUNDRED_HBARS), + + // Save payer balance before + getAccountBalance(PAYER).exposingBalanceTo(initialBalance::set), + + // Create topic with admin key + createTopic("testTopic") + .blankMemo() + .payingWith(ADMIN) + .adminKeyName(ADMIN) + .signedBy(ADMIN) + .fee(ONE_HBAR) + .via("create-topic-txn"), + + // Update topic admin key not signed by payer + updateTopic("testTopic") + .blankMemo() + .payingWith(PAYER) + .adminKey(NEW_ADMIN) + .signedBy(ADMIN, NEW_ADMIN) + .fee(ONE_HBAR) + .via("update-topic-txn") + .hasPrecheck(INVALID_SIGNATURE), + + // assert no txn record is created + getTxnRecord("update-topic-txn").logged().hasAnswerOnlyPrecheckFrom(RECORD_NOT_FOUND), + + // Save payer balance after and assert changes + getAccountBalance(PAYER).exposingBalanceTo(afterBalance::set), + withOpContext((spec, log) -> { + assertEquals(initialBalance.get(), afterBalance.get()); + })); + } + + @LeakyHapiTest(overrides = {"fees.simpleFeesEnabled"}) + @DisplayName("delete topic not signed by payer fails on ingest and payer not charged") + final Stream deleteTopicNotSignedByPayerFailsOnIngest() { + final AtomicLong initialBalance = new AtomicLong(); + final AtomicLong afterBalance = new AtomicLong(); + return runBeforeAfter( + cryptoCreate(PAYER).balance(ONE_HUNDRED_HBARS), + cryptoCreate(ADMIN).balance(ONE_HUNDRED_HBARS), + + // Save payer balance before + getAccountBalance(PAYER).exposingBalanceTo(initialBalance::set), + + // Create topic with admin key + createTopic("testTopic") + .blankMemo() + .payingWith(ADMIN) + .adminKeyName(ADMIN) + .signedBy(ADMIN) + .fee(ONE_HBAR) + .via("create-topic-txn"), + + // Delete topic not signed by payer + deleteTopic("testTopic") + .blankMemo() + .payingWith(PAYER) + .signedBy(ADMIN) + .fee(ONE_HBAR) + .via("delete-topic-txn") + .hasPrecheck(INVALID_SIGNATURE), + + // assert no txn record is created + getTxnRecord("delete-topic-txn").logged().hasAnswerOnlyPrecheckFrom(RECORD_NOT_FOUND), + + // Save payer balance after and assert changes + getAccountBalance(PAYER).exposingBalanceTo(afterBalance::set), + withOpContext((spec, log) -> { + assertEquals(initialBalance.get(), afterBalance.get()); + })); + } + + @LeakyHapiTest(overrides = {"fees.simpleFeesEnabled"}) + @DisplayName("submit message to topic not signed by payer fails on ingest and payer not charged") + final Stream submitMessageToTopicNotSignedByPayerFailsOnIngest() { + final AtomicLong initialBalance = new AtomicLong(); + final AtomicLong afterBalance = new AtomicLong(); + return runBeforeAfter( + cryptoCreate(PAYER).balance(ONE_HUNDRED_HBARS), + cryptoCreate(ADMIN).balance(ONE_HUNDRED_HBARS), + + // Save payer balance before + getAccountBalance(PAYER).exposingBalanceTo(initialBalance::set), + + // Create topic with admin key + createTopic("testTopic") + .blankMemo() + .payingWith(ADMIN) + .adminKeyName(ADMIN) + .signedBy(ADMIN) + .fee(ONE_HBAR) + .via("create-topic-txn"), + + // Submit message to topic not signed by payer + submitMessageTo("testTopic") + .blankMemo() + .payingWith(PAYER) + .signedBy(ADMIN) + .fee(ONE_HBAR) + .message("Topic Message") + .via("submit-topic-message-txn") + .hasPrecheck(INVALID_SIGNATURE), + + // assert no txn record is created + getTxnRecord("submit-topic-message-txn").logged().hasAnswerOnlyPrecheckFrom(RECORD_NOT_FOUND), + + // Save payer balance after and assert changes + getAccountBalance(PAYER).exposingBalanceTo(afterBalance::set), + withOpContext((spec, log) -> { + assertEquals(initialBalance.get(), afterBalance.get()); + })); + } } @Nested @@ -1205,6 +1329,203 @@ final Stream createTopicDuplicateTxnFailsOnPreHandlePayerIsNotCharg assertTrue(initialBalance.get() > afterBalance.get()); })); } + + @LeakyHapiTest(overrides = {"fees.simpleFeesEnabled"}) + @DisplayName("update topic not signed by payer fails on pre-handle and payer not charged") + final Stream updateTopicNotSignedByPayerFailsOnPreHandle() { + final String INNER_ID = "create-topic-txn-inner-id"; + final String ENVELOPE_ID = "create-topic-txn-envelope-id"; + final AtomicLong initialBalance = new AtomicLong(); + final AtomicLong afterBalance = new AtomicLong(); + return runBeforeAfter( + cryptoCreate(PAYER).balance(ONE_HUNDRED_HBARS), + cryptoCreate(ADMIN).balance(ONE_HUNDRED_HBARS), + cryptoCreate(NEW_ADMIN).balance(ONE_HUNDRED_HBARS), + + // Save payer balance before + getAccountBalance(PAYER).exposingBalanceTo(initialBalance::set), + + // Register a TxnId for the inner txn + usableTxnIdNamed(INNER_ID).payerId(PAYER), + + // Create topic with admin key + createTopic("testTopic") + .blankMemo() + .payingWith(ADMIN) + .adminKeyName(ADMIN) + .signedBy(ADMIN) + .fee(ONE_HBAR) + .via("create-topic-txn"), + withOpContext((spec, log) -> { + // build the inner txn + final var innerTxn = updateTopic("testTopic") + .blankMemo() + .adminKey(NEW_ADMIN) + .payingWith(PAYER) + .signedBy(ADMIN, NEW_ADMIN) + .fee(ONE_HBAR) + .txnId(INNER_ID) + .via(INNER_ID); + + // create signed bytes + final var signed = innerTxn.signedTxnFor(spec); + + // extract the txn body from the signed txn + final var txnBody = extractTransactionBody(signed); + + // save the txn id and bytes in the registry + spec.registry().saveTxnId(INNER_ID, txnBody.getTransactionID()); + spec.registry().saveBytes(INNER_ID, signed.toByteString()); + + // submit the unchecked wrapping txn + final var envelope = uncheckedSubmit(innerTxn).via(ENVELOPE_ID); + allRunFor(spec, envelope); + + final var operation = getTxnRecord(INNER_ID).assertingNothingAboutHashes(); + allRunFor(spec, operation); + + final var status = + operation.getResponseRecord().getReceipt().getStatus(); + assertEquals(INVALID_PAYER_SIGNATURE, status, "Expected txn to fail but it succeeded"); + }), + + // Save payer balance after and assert payer was not charged + validateChargedUsd(INNER_ID, ucents_to_USD(0)), + getAccountBalance(PAYER).exposingBalanceTo(afterBalance::set), + withOpContext((spec, log) -> { + assertEquals(initialBalance.get(), afterBalance.get()); + })); + } + + @LeakyHapiTest(overrides = {"fees.simpleFeesEnabled"}) + @DisplayName("delete topic not signed by payer fails on pre-handle and payer not charged") + final Stream deleteTopicNotSignedByPayerFailsOnPreHandle() { + final String INNER_ID = "create-topic-txn-inner-id"; + final String ENVELOPE_ID = "create-topic-txn-envelope-id"; + final AtomicLong initialBalance = new AtomicLong(); + final AtomicLong afterBalance = new AtomicLong(); + return runBeforeAfter( + cryptoCreate(PAYER).balance(ONE_HUNDRED_HBARS), + cryptoCreate(ADMIN).balance(ONE_HUNDRED_HBARS), + cryptoCreate(NEW_ADMIN).balance(ONE_HUNDRED_HBARS), + + // Save payer balance before + getAccountBalance(PAYER).exposingBalanceTo(initialBalance::set), + + // Register a TxnId for the inner txn + usableTxnIdNamed(INNER_ID).payerId(PAYER), + + // Create topic with admin key + createTopic("testTopic") + .blankMemo() + .payingWith(ADMIN) + .adminKeyName(ADMIN) + .signedBy(ADMIN) + .fee(ONE_HBAR) + .via("create-topic-txn"), + withOpContext((spec, log) -> { + // build the inner txn + final var innerTxn = deleteTopic("testTopic") + .payingWith(PAYER) + .signedBy(ADMIN) + .fee(ONE_HBAR) + .txnId(INNER_ID) + .via(INNER_ID); + + // create signed bytes + final var signed = innerTxn.signedTxnFor(spec); + + // extract the txn body from the signed txn + final var txnBody = extractTransactionBody(signed); + + // save the txn id and bytes in the registry + spec.registry().saveTxnId(INNER_ID, txnBody.getTransactionID()); + spec.registry().saveBytes(INNER_ID, signed.toByteString()); + + // submit the unchecked wrapping txn + final var envelope = uncheckedSubmit(innerTxn).via(ENVELOPE_ID); + allRunFor(spec, envelope); + + final var operation = getTxnRecord(INNER_ID).assertingNothingAboutHashes(); + allRunFor(spec, operation); + + final var status = + operation.getResponseRecord().getReceipt().getStatus(); + assertEquals(INVALID_PAYER_SIGNATURE, status, "Expected txn to fail but it succeeded"); + }), + + // Save payer balance after and assert payer was not charged + validateChargedUsd(INNER_ID, ucents_to_USD(0)), + getAccountBalance(PAYER).exposingBalanceTo(afterBalance::set), + withOpContext((spec, log) -> { + assertEquals(initialBalance.get(), afterBalance.get()); + })); + } + + @LeakyHapiTest(overrides = {"fees.simpleFeesEnabled"}) + @DisplayName("submit message to topic not signed by payer fails on pre-handle and payer not charged") + final Stream submitMessageToTopicNotSignedByPayerFailsOnPreHandle() { + final String INNER_ID = "create-topic-txn-inner-id"; + final String ENVELOPE_ID = "create-topic-txn-envelope-id"; + final AtomicLong initialBalance = new AtomicLong(); + final AtomicLong afterBalance = new AtomicLong(); + return runBeforeAfter( + cryptoCreate(PAYER).balance(ONE_HUNDRED_HBARS), + cryptoCreate(ADMIN).balance(ONE_HUNDRED_HBARS), + cryptoCreate(NEW_ADMIN).balance(ONE_HUNDRED_HBARS), + + // Save payer balance before + getAccountBalance(PAYER).exposingBalanceTo(initialBalance::set), + + // Register a TxnId for the inner txn + usableTxnIdNamed(INNER_ID).payerId(PAYER), + + // Create topic with admin key + createTopic("testTopic") + .blankMemo() + .payingWith(ADMIN) + .adminKeyName(ADMIN) + .signedBy(ADMIN) + .fee(ONE_HBAR) + .via("create-topic-txn"), + withOpContext((spec, log) -> { + // build the inner txn + final var innerTxn = submitMessageTo("testTopic") + .payingWith(PAYER) + .signedBy(ADMIN) + .fee(ONE_HBAR) + .txnId(INNER_ID) + .via(INNER_ID); + + // create signed bytes + final var signed = innerTxn.signedTxnFor(spec); + + // extract the txn body from the signed txn + final var txnBody = extractTransactionBody(signed); + + // save the txn id and bytes in the registry + spec.registry().saveTxnId(INNER_ID, txnBody.getTransactionID()); + spec.registry().saveBytes(INNER_ID, signed.toByteString()); + + // submit the unchecked wrapping txn + final var envelope = uncheckedSubmit(innerTxn).via(ENVELOPE_ID); + allRunFor(spec, envelope); + + final var operation = getTxnRecord(INNER_ID).assertingNothingAboutHashes(); + allRunFor(spec, operation); + + final var status = + operation.getResponseRecord().getReceipt().getStatus(); + assertEquals(INVALID_PAYER_SIGNATURE, status, "Expected txn to fail but it succeeded"); + }), + + // Save payer balance after and assert payer was not charged + validateChargedUsd(INNER_ID, ucents_to_USD(0)), + getAccountBalance(PAYER).exposingBalanceTo(afterBalance::set), + withOpContext((spec, log) -> { + assertEquals(initialBalance.get(), afterBalance.get()); + })); + } } } }