diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/fees/BulkOperationsBase.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/fees/BulkOperationsBase.java new file mode 100644 index 000000000000..2e9027ca4d45 --- /dev/null +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/fees/BulkOperationsBase.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2024-2025 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hedera.services.bdd.suites.fees; + +import static com.hedera.services.bdd.spec.transactions.TxnVerbs.cryptoCreate; +import static com.hedera.services.bdd.spec.transactions.TxnVerbs.tokenCreate; +import static com.hedera.services.bdd.spec.utilops.UtilVerbs.newKeyNamed; +import static com.hedera.services.bdd.suites.HapiSuite.ONE_HUNDRED_HBARS; +import static com.hederahashgraph.api.proto.java.TokenType.FUNGIBLE_COMMON; +import static com.hederahashgraph.api.proto.java.TokenType.NON_FUNGIBLE_UNIQUE; + +import com.hedera.services.bdd.spec.SpecOperation; +import com.hederahashgraph.api.proto.java.TokenSupplyType; +import java.util.ArrayList; +import java.util.List; + +/** + * Base class for tests to validate bulk operations fees + */ +public class BulkOperationsBase { + protected static final String OWNER = "owner"; + protected static final String RECEIVER = "receiver"; + protected static final String ASSOCIATE_ACCOUNT = "associateAccount"; + + protected static final String NFT_TOKEN = "nftToken"; + protected static final String NFT_BURN_ONE_TOKEN = "nftBurnOneToken"; + protected static final String NFT_BURN_TOKEN = "nftBurnToken"; + protected static final String FT_TOKEN = "ftToken"; + + /** + * Create tokens and accounts + * + * @return array of operations + */ + protected static SpecOperation[] createTokensAndAccounts() { + var supplyKey = "supplyKey"; + final var t = new ArrayList(List.of( + newKeyNamed(supplyKey), + cryptoCreate(OWNER).balance(ONE_HUNDRED_HBARS).key(supplyKey), + tokenCreate(NFT_TOKEN) + .treasury(OWNER) + .tokenType(NON_FUNGIBLE_UNIQUE) + .supplyKey(supplyKey) + .supplyType(TokenSupplyType.INFINITE) + .initialSupply(0), + tokenCreate(FT_TOKEN) + .treasury(OWNER) + .tokenType(FUNGIBLE_COMMON) + .supplyKey(supplyKey) + .initialSupply(1000L), + tokenCreate(NFT_BURN_TOKEN) + .treasury(OWNER) + .tokenType(NON_FUNGIBLE_UNIQUE) + .supplyKey(supplyKey) + .supplyType(TokenSupplyType.INFINITE) + .initialSupply(0), + tokenCreate(NFT_BURN_ONE_TOKEN) + .treasury(OWNER) + .tokenType(NON_FUNGIBLE_UNIQUE) + .supplyKey(supplyKey) + .supplyType(TokenSupplyType.INFINITE) + .initialSupply(0))); + + return t.toArray(new SpecOperation[0]); + } +} diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/fees/TokenServiceFeesSuite.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/fees/TokenServiceFeesSuite.java index 2720ab302451..31d49516d210 100644 --- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/fees/TokenServiceFeesSuite.java +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/fees/TokenServiceFeesSuite.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 Hedera Hashgraph, LLC + * Copyright (C) 2024-2025 Hedera Hashgraph, LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -84,11 +84,13 @@ import com.hederahashgraph.api.proto.java.TokenSupplyType; import com.hederahashgraph.api.proto.java.TokenType; import java.time.Instant; +import java.util.Arrays; import java.util.List; import java.util.stream.IntStream; import java.util.stream.Stream; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Tag; @Tag(TOKEN) @@ -122,6 +124,8 @@ public class TokenServiceFeesSuite { private static final String FUNGIBLE_TOKEN = "fungibleToken"; private static final String RECEIVER_WITH_0_AUTO_ASSOCIATIONS = "receiverWith0AutoAssociations"; + private static final String TOKEN_UPDATE_METADATA = "tokenUpdateMetadata"; + private static final double EXPECTED_NFT_WIPE_PRICE_USD = 0.001; private static final double EXPECTED_FREEZE_PRICE_USD = 0.001; private static final double EXPECTED_UNFREEZE_PRICE_USD = 0.001; @@ -132,6 +136,10 @@ public class TokenServiceFeesSuite { private static final double EXPECTED_FUNGIBLE_MINT_PRICE_USD = 0.001; private static final double EXPECTED_FUNGIBLE_REJECT_PRICE_USD = 0.001; private static final double EXPECTED_NFT_REJECT_PRICE_USD = 0.001; + + private static final double EXPECTED_ASSOCIATE_TOKEN_PRICE = 0.05; + private static final double EXPECTED_NFT_UPDATE_PRICE = 0.001; + private static final String OWNER = "owner"; @HapiTest @@ -905,6 +913,123 @@ final Stream tokenUpdateNftsFeeChargedAsExpected() { validateChargedUsd("nftUpdateTxn", expectedTokenUpdateNfts)); } + // verify bulk operations base fees + @Nested + @DisplayName("Token Bulk Operations - without custom fees") + class BulkTokenOperationsWithoutCustomFeesTest extends BulkOperationsBase { + + @HapiTest + final Stream mintOneNftTokenWithoutCustomFees() { + return mintBulkNftAndValidateFees(1); + } + + @HapiTest + final Stream mintFiveBulkNftTokenWithoutCustomFees() { + return mintBulkNftAndValidateFees(5); + } + + @HapiTest + final Stream mintTenBulkNftTokensWithoutCustomFees() { + return mintBulkNftAndValidateFees(10); + } + + @HapiTest + final Stream associateOneFtTokenWithoutCustomFees() { + return associateBulkTokensAndValidateFees(List.of(FT_TOKEN)); + } + + @HapiTest + final Stream associateBulkFtTokensWithoutCustomFees() { + return associateBulkTokensAndValidateFees(List.of(FT_TOKEN, NFT_TOKEN, NFT_BURN_TOKEN, NFT_BURN_ONE_TOKEN)); + } + + @HapiTest + final Stream updateOneNftTokenWithoutCustomFees() { + return updateBulkNftTokensAndValidateFees(10, Arrays.asList(1L)); + } + + @HapiTest + final Stream updateFiveBulkNftTokensWithoutCustomFees() { + return updateBulkNftTokensAndValidateFees(10, Arrays.asList(1L, 2L, 3L, 4L, 5L)); + } + + @HapiTest + final Stream updateTenBulkNftTokensWithoutCustomFees() { + return updateBulkNftTokensAndValidateFees(10, Arrays.asList(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L, 10L)); + } + + // define reusable methods + private Stream mintBulkNftAndValidateFees(final int rangeAmount) { + final var supplyKey = "supplyKey"; + return hapiTest( + newKeyNamed(supplyKey), + cryptoCreate(OWNER).balance(ONE_HUNDRED_HBARS).key(supplyKey), + tokenCreate(NFT_TOKEN) + .treasury(OWNER) + .tokenType(NON_FUNGIBLE_UNIQUE) + .supplyKey(supplyKey) + .supplyType(TokenSupplyType.INFINITE) + .initialSupply(0), + mintToken( + NFT_TOKEN, + IntStream.range(0, rangeAmount) + .mapToObj(a -> ByteString.copyFromUtf8(String.valueOf(a))) + .toList()) + .payingWith(OWNER) + .signedBy(supplyKey) + .blankMemo() + .via("mintTxn"), + validateChargedUsdWithin( + "mintTxn", EXPECTED_NFT_MINT_PRICE_USD * rangeAmount, ALLOWED_DIFFERENCE_PERCENTAGE)); + } + + private Stream associateBulkTokensAndValidateFees(final List tokens) { + final var supplyKey = "supplyKey"; + return hapiTest(flattened( + createTokensAndAccounts(), + newKeyNamed(supplyKey), + cryptoCreate(ASSOCIATE_ACCOUNT).balance(ONE_HUNDRED_HBARS).key(supplyKey), + tokenAssociate(ASSOCIATE_ACCOUNT, tokens) + .payingWith(ASSOCIATE_ACCOUNT) + .via("associateTxn"), + validateChargedUsdWithin( + "associateTxn", + EXPECTED_ASSOCIATE_TOKEN_PRICE * tokens.size(), + ALLOWED_DIFFERENCE_PERCENTAGE))); + } + + private Stream updateBulkNftTokensAndValidateFees( + final int mintAmount, final List updateAmounts) { + final var supplyKey = "supplyKey"; + return hapiTest( + newKeyNamed(supplyKey), + cryptoCreate(OWNER).balance(ONE_HUNDRED_HBARS).key(supplyKey), + tokenCreate(NFT_TOKEN) + .treasury(OWNER) + .tokenType(NON_FUNGIBLE_UNIQUE) + .supplyKey(supplyKey) + .supplyType(TokenSupplyType.INFINITE) + .initialSupply(0), + mintToken( + NFT_TOKEN, + IntStream.range(0, mintAmount) + .mapToObj(a -> ByteString.copyFromUtf8(String.valueOf(a))) + .toList()) + .payingWith(OWNER) + .signedBy(supplyKey) + .blankMemo(), + tokenUpdateNfts(NFT_TOKEN, TOKEN_UPDATE_METADATA, updateAmounts) + .payingWith(OWNER) + .signedBy(supplyKey) + .blankMemo() + .via("updateTxn"), + validateChargedUsdWithin( + "updateTxn", + EXPECTED_NFT_UPDATE_PRICE * updateAmounts.size(), + ALLOWED_DIFFERENCE_PERCENTAGE)); + } + } + private String txnFor(String tokenSubType) { return tokenSubType + "Txn"; }