diff --git a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/validators/TokenAirdropValidator.java b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/validators/TokenAirdropValidator.java index d6540ad84dd4..e788eb5431b3 100644 --- a/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/validators/TokenAirdropValidator.java +++ b/hedera-node/hedera-token-service-impl/src/main/java/com/hedera/node/app/service/token/impl/validators/TokenAirdropValidator.java @@ -16,6 +16,7 @@ package com.hedera.node.app.service.token.impl.validators; +import static com.hedera.hapi.node.base.ResponseCodeEnum.BATCH_SIZE_LIMIT_EXCEEDED; import static com.hedera.hapi.node.base.ResponseCodeEnum.EMPTY_TOKEN_TRANSFER_BODY; import static com.hedera.hapi.node.base.ResponseCodeEnum.INSUFFICIENT_TOKEN_BALANCE; import static com.hedera.hapi.node.base.ResponseCodeEnum.INVALID_ACCOUNT_ID; @@ -24,7 +25,7 @@ import static com.hedera.hapi.node.base.ResponseCodeEnum.INVALID_TRANSACTION_BODY; import static com.hedera.hapi.node.base.ResponseCodeEnum.SENDER_DOES_NOT_OWN_NFT_SERIAL_NO; import static com.hedera.hapi.node.base.ResponseCodeEnum.TOKEN_AIRDROP_WITH_FALLBACK_ROYALTY; -import static com.hedera.hapi.node.base.ResponseCodeEnum.TOKEN_REFERENCE_LIST_SIZE_LIMIT_EXCEEDED; +import static com.hedera.hapi.node.base.ResponseCodeEnum.TOKEN_TRANSFER_LIST_SIZE_LIMIT_EXCEEDED; import static com.hedera.node.app.service.token.impl.util.TokenHandlerHelper.getIfUsable; import static com.hedera.node.app.service.token.impl.util.TokenHandlerHelper.getIfUsableForAliasedId; import static com.hedera.node.app.service.token.impl.validators.CryptoTransferValidator.validateTokenTransfers; @@ -48,7 +49,7 @@ import com.hedera.node.app.service.token.impl.handlers.transfer.customfees.CustomFeeExemptions; import com.hedera.node.app.spi.workflows.HandleContext; import com.hedera.node.app.spi.workflows.PreCheckException; -import com.hedera.node.config.data.TokensConfig; +import com.hedera.node.config.data.LedgerConfig; import edu.umd.cs.findbugs.annotations.NonNull; import java.util.List; import javax.inject.Inject; @@ -97,11 +98,9 @@ public void validateSemantics( @NonNull final ReadableTokenStore tokenStore, @NonNull final ReadableTokenRelationStore tokenRelStore, @NonNull final ReadableNftStore nftStore) { - var tokensConfig = context.configuration().getConfigData(TokensConfig.class); - validateTrue( - op.tokenTransfers().size() <= tokensConfig.maxAllowedAirdropTransfersPerTx(), - TOKEN_REFERENCE_LIST_SIZE_LIMIT_EXCEEDED); - + var ledgerConfig = context.configuration().getConfigData(LedgerConfig.class); + var totalFungibleTransfers = 0; + var totalNftTransfers = 0; for (final var xfers : op.tokenTransfers()) { final var tokenId = xfers.tokenOrThrow(); final var token = getIfUsable(tokenId, tokenStore); @@ -118,6 +117,12 @@ public void validateSemantics( // 1. Validate token associations validateFungibleTransfers( context.payer(), senderAccount, tokenId, senderAccountAmount.get(), tokenRelStore); + totalFungibleTransfers += xfers.transfers().size(); + + // Verify that the current total number of (counted) fungible transfers does not exceed the limit + validateTrue( + totalFungibleTransfers <= ledgerConfig.tokenTransfersMaxLen(), + TOKEN_TRANSFER_LIST_SIZE_LIMIT_EXCEEDED); } // process non-fungible tokens transfers if any @@ -137,14 +142,11 @@ public void validateSemantics( final var senderId = nftTransfer.orElseThrow().senderAccountIDOrThrow(); final var senderAccount = accountStore.getAliasedAccountById(senderId); validateTrue(senderAccount != null, INVALID_ACCOUNT_ID); - validateNftTransfers( - context.payer(), - senderAccount, - tokenId, - xfers.nftTransfers(), - tokenRelStore, - tokenStore, - nftStore); + validateNftTransfers(senderAccount, tokenId, xfers.nftTransfers(), tokenRelStore, tokenStore, nftStore); + + totalNftTransfers += xfers.nftTransfers().size(); + // Verify that the current total number of (counted) nft transfers does not exceed the limit + validateTrue(totalNftTransfers <= ledgerConfig.nftTransfersMaxLen(), BATCH_SIZE_LIMIT_EXCEEDED); } } } @@ -184,7 +186,6 @@ private static void validateFungibleTransfers( } private void validateNftTransfers( - @NonNull final AccountID payer, @NonNull final Account senderAccount, @NonNull final TokenID tokenId, @NonNull final List nftTransfers, diff --git a/hedera-node/hedera-token-service-impl/src/test/java/com/hedera/node/app/service/token/impl/test/handlers/TokenAirdropHandlerTest.java b/hedera-node/hedera-token-service-impl/src/test/java/com/hedera/node/app/service/token/impl/test/handlers/TokenAirdropHandlerTest.java index b9ff3d46b11d..568146da84d0 100644 --- a/hedera-node/hedera-token-service-impl/src/test/java/com/hedera/node/app/service/token/impl/test/handlers/TokenAirdropHandlerTest.java +++ b/hedera-node/hedera-token-service-impl/src/test/java/com/hedera/node/app/service/token/impl/test/handlers/TokenAirdropHandlerTest.java @@ -16,12 +16,12 @@ package com.hedera.node.app.service.token.impl.test.handlers; +import static com.hedera.hapi.node.base.ResponseCodeEnum.BATCH_SIZE_LIMIT_EXCEEDED; import static com.hedera.hapi.node.base.ResponseCodeEnum.EMPTY_TOKEN_TRANSFER_ACCOUNT_AMOUNTS; import static com.hedera.hapi.node.base.ResponseCodeEnum.EMPTY_TOKEN_TRANSFER_BODY; import static com.hedera.hapi.node.base.ResponseCodeEnum.INVALID_ACCOUNT_AMOUNTS; import static com.hedera.hapi.node.base.ResponseCodeEnum.OK; import static com.hedera.hapi.node.base.ResponseCodeEnum.TOKEN_NOT_ASSOCIATED_TO_ACCOUNT; -import static com.hedera.hapi.node.base.ResponseCodeEnum.TOKEN_REFERENCE_LIST_SIZE_LIMIT_EXCEEDED; import static com.hedera.node.app.service.token.impl.handlers.BaseTokenHandler.asToken; import static com.hedera.node.app.service.token.impl.test.handlers.transfer.AirDropTransferType.NFT_AIRDROP; import static com.hedera.node.app.service.token.impl.test.handlers.transfer.AirDropTransferType.TOKEN_AIRDROP; @@ -39,6 +39,7 @@ import static org.mockito.Mockito.when; import com.hedera.hapi.node.base.AccountID; +import com.hedera.hapi.node.base.NftTransfer; import com.hedera.hapi.node.base.PendingAirdropId; import com.hedera.hapi.node.base.PendingAirdropValue; import com.hedera.hapi.node.base.ResponseCodeEnum; @@ -379,13 +380,10 @@ void tokenTransfersAboveMax() { tokenAirdropHandler = new TokenAirdropHandler(tokenAirdropValidator, validator); given(handleContext.savepointStack()).willReturn(stack); given(stack.getBaseBuilder(TokenAirdropStreamBuilder.class)).willReturn(tokenAirdropRecordBuilder); - var tokenWithNoCustomFees = - fungibleToken.copyBuilder().customFees(Collections.emptyList()).build(); var nftWithNoCustomFees = nonFungibleToken .copyBuilder() .customFees(Collections.emptyList()) .build(); - writableTokenStore.put(tokenWithNoCustomFees); writableTokenStore.put(nftWithNoCustomFees); given(storeFactory.writableStore(WritableTokenStore.class)).willReturn(writableTokenStore); given(storeFactory.readableStore(ReadableTokenStore.class)).willReturn(writableTokenStore); @@ -396,26 +394,13 @@ void tokenTransfersAboveMax() { .build(); givenAirdropTxn(txn, payerId); - given(handleContext.dispatchRemovablePrecedingTransaction( - any(), eq(TokenAirdropStreamBuilder.class), eq(null), eq(payerId), any())) - .will((invocation) -> { - var pendingAirdropId = PendingAirdropId.newBuilder().build(); - var pendingAirdropValue = PendingAirdropValue.newBuilder().build(); - var pendingAirdropRecord = PendingAirdropRecord.newBuilder() - .pendingAirdropId(pendingAirdropId) - .pendingAirdropValue(pendingAirdropValue) - .build(); - - return tokenAirdropRecordBuilder.addPendingAirdrop(pendingAirdropRecord); - }); - given(handleContext.expiryValidator()).willReturn(expiryValidator); given(handleContext.feeCalculatorFactory()).willReturn(feeCalculatorFactory); given(handleContext.tryToChargePayer(anyLong())).willReturn(true); Assertions.assertThatThrownBy(() -> tokenAirdropHandler.handle(handleContext)) .isInstanceOf(HandleException.class) - .has(responseCode(TOKEN_REFERENCE_LIST_SIZE_LIMIT_EXCEEDED)); + .has(responseCode(BATCH_SIZE_LIMIT_EXCEEDED)); } @Test @@ -575,9 +560,12 @@ private List transactionBodyAboveMaxTransferLimit() { for (int i = 0; i <= MAX_TOKEN_TRANSFERS; i++) { result.add(TokenTransferList.newBuilder() - .token(TOKEN_2468) - .transfers(ACCT_4444_PLUS_10) - .nftTransfers(SERIAL_1_FROM_3333_TO_4444, SERIAL_1_FROM_3333_TO_4444) + .token(nonFungibleTokenId) + .nftTransfers(NftTransfer.newBuilder() + .serialNumber(1) + .senderAccountID(ownerId) + .receiverAccountID(tokenReceiverId) + .build()) .build()); } diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/hip904/TokenAirdropTest.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/hip904/TokenAirdropTest.java index 727d46bb7644..d16ef46f2736 100644 --- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/hip904/TokenAirdropTest.java +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/hip904/TokenAirdropTest.java @@ -83,6 +83,7 @@ import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.ACCOUNT_DELETED; import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.ACCOUNT_FROZEN_FOR_TOKEN; import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.ACCOUNT_HAS_PENDING_AIRDROPS; +import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.BATCH_SIZE_LIMIT_EXCEEDED; import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.CUSTOM_FEE_CHARGING_EXCEEDED_MAX_RECURSION_DEPTH; import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.EMPTY_TOKEN_TRANSFER_BODY; import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.INSUFFICIENT_PAYER_BALANCE; @@ -101,7 +102,7 @@ import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.TOKEN_AIRDROP_WITH_FALLBACK_ROYALTY; import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.TOKEN_IS_PAUSED; import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.TOKEN_NOT_ASSOCIATED_TO_ACCOUNT; -import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.TOKEN_REFERENCE_LIST_SIZE_LIMIT_EXCEEDED; +import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.TOKEN_TRANSFER_LIST_SIZE_LIMIT_EXCEEDED; import static com.hederahashgraph.api.proto.java.TokenType.FUNGIBLE_COMMON; import static com.hederahashgraph.api.proto.java.TokenType.NON_FUNGIBLE_UNIQUE; @@ -113,6 +114,7 @@ import com.hedera.services.bdd.junit.HapiTestLifecycle; import com.hedera.services.bdd.junit.LeakyHapiTest; import com.hedera.services.bdd.junit.support.TestLifecycle; +import com.hedera.services.bdd.spec.SpecOperation; import com.hedera.services.bdd.spec.keys.SigControl; import com.hedera.services.bdd.spec.transactions.token.HapiTokenCreate; import com.hedera.services.bdd.spec.transactions.token.TokenMovement; @@ -122,10 +124,12 @@ import com.swirlds.common.utility.CommonUtils; import edu.umd.cs.findbugs.annotations.NonNull; import java.math.BigInteger; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.IntStream; +import java.util.stream.LongStream; import java.util.stream.Stream; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; @@ -478,23 +482,13 @@ final Stream tokenAirdropMultipleTokens() { createTokenWithName("FT2"), createTokenWithName("FT3"), createTokenWithName("FT4"), - createTokenWithName("FT5"), - createTokenWithName("FT6"), - createTokenWithName("FT7"), - createTokenWithName("FT8"), - createTokenWithName("FT9"), - createTokenWithName("FT10")) + createTokenWithName("FT5")) .when(tokenAirdrop( defaultMovementOfToken("FT1"), defaultMovementOfToken("FT2"), defaultMovementOfToken("FT3"), defaultMovementOfToken("FT4"), - defaultMovementOfToken("FT5"), - defaultMovementOfToken("FT6"), - defaultMovementOfToken("FT7"), - defaultMovementOfToken("FT8"), - defaultMovementOfToken("FT9"), - defaultMovementOfToken("FT10")) + defaultMovementOfToken("FT5")) .payingWith(OWNER) .via("fungible airdrop")) .then( @@ -508,17 +502,7 @@ final Stream tokenAirdropMultipleTokens() { getAccountBalance(RECEIVER_WITH_UNLIMITED_AUTO_ASSOCIATIONS) .hasTokenBalance("FT4", 10), getAccountBalance(RECEIVER_WITH_UNLIMITED_AUTO_ASSOCIATIONS) - .hasTokenBalance("FT5", 10), - getAccountBalance(RECEIVER_WITH_UNLIMITED_AUTO_ASSOCIATIONS) - .hasTokenBalance("FT6", 10), - getAccountBalance(RECEIVER_WITH_UNLIMITED_AUTO_ASSOCIATIONS) - .hasTokenBalance("FT7", 10), - getAccountBalance(RECEIVER_WITH_UNLIMITED_AUTO_ASSOCIATIONS) - .hasTokenBalance("FT8", 10), - getAccountBalance(RECEIVER_WITH_UNLIMITED_AUTO_ASSOCIATIONS) - .hasTokenBalance("FT9", 10), - getAccountBalance(RECEIVER_WITH_UNLIMITED_AUTO_ASSOCIATIONS) - .hasTokenBalance("FT10", 10)); + .hasTokenBalance("FT5", 10)); } } @@ -1380,7 +1364,7 @@ final Stream aboveMaxTransfersFails() { defaultMovementOfToken("FUNGIBLE10"), defaultMovementOfToken("FUNGIBLE11")) .payingWith(OWNER) - .hasKnownStatus(TOKEN_REFERENCE_LIST_SIZE_LIMIT_EXCEEDED)); + .hasKnownStatus(TOKEN_TRANSFER_LIST_SIZE_LIMIT_EXCEEDED)); } @HapiTest @@ -1785,7 +1769,7 @@ final Stream moreThanTenTokensToMultipleAccounts() { moving(10L, FUNGIBLE_TOKEN_J).between(ALICE, STEVE), moving(10L, FUNGIBLE_TOKEN_K).between(ALICE, STEVE)) .signedByPayerAnd(ALICE) - .hasKnownStatus(TOKEN_REFERENCE_LIST_SIZE_LIMIT_EXCEEDED)); + .hasKnownStatus(TOKEN_TRANSFER_LIST_SIZE_LIMIT_EXCEEDED)); } @HapiTest @@ -1967,6 +1951,70 @@ final Stream airdropTo0x0Address() { .payingWith(OWNER) .hasKnownStatus(INVALID_ACCOUNT_ID)); } + + @HapiTest + @DisplayName("airdrop 1 fungible token to 10 accounts") + final Stream pendingAirdropOneTokenToMoreThan10Accounts() { + final var accountNames = generateAccountNames(10); + return hapiTest(flattened( + // create 10 accounts with 0 auto associations + createAccounts(accountNames, 0), + tokenAirdrop(distributeTokens(FUNGIBLE_TOKEN, OWNER, accountNames)) + .payingWith(OWNER) + .hasKnownStatus(TOKEN_TRANSFER_LIST_SIZE_LIMIT_EXCEEDED))); + } + + @HapiTest + @DisplayName("airdrop more than 10 nft") + final Stream airdropMoreThan10Nft() { + final var nft = "nft"; + var nftSupplyKey = "nftSupplyKey"; + return hapiTest(flattened( + newKeyNamed(nftSupplyKey), + tokenCreate(nft) + .supplyKey(nftSupplyKey) + .tokenType(NON_FUNGIBLE_UNIQUE) + .initialSupply(0) + .treasury(OWNER), + // mint from 1 to 10 serials + mintToken( + nft, + IntStream.range(0, 10) + .mapToObj(a -> ByteString.copyFromUtf8(String.valueOf(a))) + .toList()), + // mint 11th serial + mintToken(nft, List.of(ByteString.copyFromUtf8(String.valueOf(11)))), + // try to airdrop 11 NFT + tokenAirdrop(distributeNFT(nft, OWNER, RECEIVER_WITH_0_AUTO_ASSOCIATIONS)) + .payingWith(OWNER) + .hasKnownStatus(BATCH_SIZE_LIMIT_EXCEEDED))); + } + + private static ArrayList generateAccountNames(int count) { + final var accountNames = new ArrayList(count); + for (int i = 0; i < count; i++) { + accountNames.add(String.format("account%d", i)); + } + return accountNames; + } + + private static ArrayList createAccounts( + ArrayList accountNames, int numberOfAutoAssociations) { + final var specOps = new ArrayList(accountNames.size()); + for (String accountName : accountNames) { + specOps.add(cryptoCreate(accountName).maxAutomaticTokenAssociations(numberOfAutoAssociations)); + } + return specOps; + } + + private static TokenMovement distributeTokens(String token, String sender, ArrayList accountNames) { + return moving(accountNames.size(), token).distributing(sender, accountNames.toArray(new String[0])); + } + + private static TokenMovement distributeNFT(String token, String sender, String receiver) { + final long[] serials = LongStream.rangeClosed(1, 11).toArray(); + return TokenMovement.movingUnique(token, serials).between(sender, receiver); + } } @EmbeddedHapiTest(EmbeddedReason.NEEDS_STATE_ACCESS) diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/hip904/TokenClaimAirdropTest.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/hip904/TokenClaimAirdropTest.java index 8043c0b319c6..ada038b67402 100644 --- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/hip904/TokenClaimAirdropTest.java +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/hip904/TokenClaimAirdropTest.java @@ -374,7 +374,9 @@ final Stream tokenClaimOfTenFT() { inParallel( mapNTokens(token -> createFT(token, DEFAULT_PAYER, 1000L), SpecOperation.class, "ft", 1, 10)), tokenAirdrop(mapNTokens( - token -> moving(1, token).between(DEFAULT_PAYER, recipient), TokenMovement.class, "ft", 1, 10)), + token -> moving(1, token).between(DEFAULT_PAYER, recipient), TokenMovement.class, "ft", 1, 5)), + tokenAirdrop(mapNTokens( + token -> moving(1, token).between(DEFAULT_PAYER, recipient), TokenMovement.class, "ft", 6, 10)), claimAndFailToReclaim(() -> tokenClaimAirdrop(mapNTokens( token -> pendingAirdrop(DEFAULT_PAYER, recipient, token), Function.class, "ft", 1, 10)) .payingWith(recipient)), @@ -407,7 +409,8 @@ final Stream tokenClaimOfTenFTAndNFTToTwoReceivers() { moving(1, FUNGIBLE_TOKEN_2).between(DEFAULT_PAYER, BOB), moving(1, FUNGIBLE_TOKEN_3).between(DEFAULT_PAYER, BOB), moving(1, FUNGIBLE_TOKEN_4).between(DEFAULT_PAYER, BOB), - movingUnique(NON_FUNGIBLE_TOKEN, 1).between(DEFAULT_PAYER, BOB), + movingUnique(NON_FUNGIBLE_TOKEN, 1).between(DEFAULT_PAYER, BOB)), + tokenAirdrop( moving(1, FUNGIBLE_TOKEN_6).between(DEFAULT_PAYER, CAROL), moving(1, FUNGIBLE_TOKEN_7).between(DEFAULT_PAYER, CAROL), moving(1, FUNGIBLE_TOKEN_8).between(DEFAULT_PAYER, CAROL), @@ -476,7 +479,9 @@ final Stream tokenClaimByFiveDifferentReceivers() { moving(1, FUNGIBLE_TOKEN_2).between(ALICE, CAROL), moving(1, FUNGIBLE_TOKEN_3).between(ALICE, YULIA), moving(1, FUNGIBLE_TOKEN_4).between(ALICE, TOM), - moving(1, FUNGIBLE_TOKEN_5).between(ALICE, STEVE), + moving(1, FUNGIBLE_TOKEN_5).between(ALICE, STEVE)) + .payingWith(ALICE), + tokenAirdrop( moving(1, FUNGIBLE_TOKEN_6).between(ALICE, BOB), moving(1, FUNGIBLE_TOKEN_7).between(ALICE, CAROL), moving(1, FUNGIBLE_TOKEN_8).between(ALICE, YULIA),